fix(anatomy): harvest inner-app fqdns + prioritise custom domains

Coolify stores user-facing domains on the inner application of a
service (e.g. Twenty's `twenty` app), not on the parent service. The
anatomy endpoint was reading the service-level fqdn and getting null,
so live URL chips never rendered.

- smartServiceMetaFor (replaces smartServiceStatusFor): collects
  fqdns from non-excluded inner apps in addition to status
- prioritiseFqdns: pushes auto-generated *.sslip.io / *.coolify.app
  URLs to the back so real custom domains surface first
- fqdnsOf: strips default ports (443, 80, container 3000) so chips
  link to the public Traefik-served URL, not the internal port

Made-with: Cursor
This commit is contained in:
2026-04-30 13:45:06 -07:00
parent 996b875983
commit 73b672f2c9

View File

@@ -90,6 +90,10 @@ interface LiveEndpoint {
buildPack?: string;
/** Last finished deployment, only for source = "repo" */
lastBuild?: BuildSummary;
/** In-flight deployment (queued / in_progress / building). Present
* iff Coolify has an active deploy not yet marked finished. The
* status pill flips to "Deploying" the moment this is non-null. */
inFlightBuild?: BuildSummary;
}
interface Preview {
@@ -286,6 +290,65 @@ async function loadProjectServices(coolifyProjectUuid: string | undefined): Prom
}
}
// Coolify's service-level status aggregates ALL inner apps, including
// sidecars that don't define a healthcheck (e.g. Twenty's worker, n8n's
// queue runner). Those apps perpetually report `running:unknown` or
// `starting:unknown`, which poisons the aggregate even when the
// user-facing app is `running:healthy`.
//
// Fetching the service detail gives us per-app statuses + the
// `exclude_from_status` flag. We pick the best status from non-excluded
// apps so the project header pill reflects what the user actually
// cares about. This costs N extra Coolify GETs per anatomy call (one
// per image service) — small N in practice, parallelised below.
interface ServiceAppStatus { name: string; status?: string; exclude_from_status?: boolean; fqdn?: string }
async function smartServiceMetaFor(
uuid: string,
fallbackStatus: string | undefined,
serviceLevelFqdn: string | undefined,
): Promise<{ status: string; fqdns: string[] }> {
try {
const detail = await fetch(
`${process.env.COOLIFY_URL}/api/v1/services/${uuid}`,
{ headers: { Authorization: `Bearer ${process.env.COOLIFY_API_TOKEN}` }, cache: "no-store" },
);
if (!detail.ok) return { status: fallbackStatus ?? "unknown", fqdns: fqdnsOf(serviceLevelFqdn) };
const body = (await detail.json()) as { applications?: ServiceAppStatus[] };
const apps = body.applications ?? [];
const userFacing = apps.filter((a) => !a.exclude_from_status);
// Fqdn harvesting: for image services Coolify usually leaves the
// service-level fqdn null and stores the real domain on the inner
// application (e.g. Twenty's `twenty` app). Prefer fqdns from
// user-facing apps; fall back to the service-level value.
const harvested: string[] = [];
for (const a of userFacing) for (const f of fqdnsOf(a.fqdn)) harvested.push(f);
const fqdns = harvested.length > 0 ? harvested : fqdnsOf(serviceLevelFqdn);
if (userFacing.length === 0) return { status: fallbackStatus ?? "unknown", fqdns };
const ranked = userFacing.map((a) => rankServiceAppStatus(a.status));
let status: string;
if (ranked.some((r) => r === "down")) status = "exited";
else if (ranked.some((r) => r === "up")) status = "running:healthy";
else if (ranked.some((r) => r === "transient")) status = "starting:unknown";
else status = fallbackStatus ?? "unknown";
return { status, fqdns };
} catch (err) {
console.error(`[anatomy] smartServiceMetaFor(${uuid}) failed:`, err);
return { status: fallbackStatus ?? "unknown", fqdns: fqdnsOf(serviceLevelFqdn) };
}
}
function rankServiceAppStatus(s?: string): "up" | "transient" | "down" | "unknown" {
const v = (s ?? "").toLowerCase();
if (!v) return "unknown";
if (/^(running|healthy)/.test(v)) return "up";
if (/^(starting|restarting|created|paused|deploying|building|in_progress|queued)/.test(v)) return "transient";
if (/^(exited|dead|failed|stopped|unhealthy|error)/.test(v)) return "down";
return "unknown";
}
const isDevContainer = (svc: CoolifyService) => svc.name.startsWith("vibn-dev-");
/** Extract image:version from a Coolify docker_compose_raw blob.
@@ -306,15 +369,39 @@ function fqdnsOf(value: string | undefined): string[] {
if (!value) return [];
return value
.split(",")
.map(s => s.trim().replace(/^https?:\/\//, "").replace(/\/$/, ""))
.map(s => {
const trimmed = s.trim().replace(/\/$/, "");
if (!trimmed) return "";
// Strip default ports that won't work through a public reverse
// proxy (https on 443, http on 80, and Coolify's internal :3000
// which Traefik bridges to 443 anyway).
try {
const u = new URL(/^https?:\/\//.test(trimmed) ? trimmed : `https://${trimmed}`);
const host = u.hostname;
const port = u.port;
const isHttps = u.protocol === "https:";
const showPort = port && !((isHttps && (port === "443" || port === "3000")) || (!isHttps && port === "80"));
return showPort ? `${host}:${port}` : host;
} catch {
return trimmed.replace(/^https?:\/\//, "");
}
})
.filter(Boolean);
}
// Heuristic: Coolify auto-generates *.sslip.io URLs as a default
// reachable hostname when no custom domain is set. If a user has
// configured a real domain, that's what we want to surface in the
// header — push sslip.io URLs to the back.
function prioritiseFqdns(fqdns: string[]): string[] {
const isAuto = (f: string) => /\.sslip\.io(?::|$)/i.test(f) || /\.coolify\.app(?::|$)/i.test(f);
return [...fqdns].sort((a, b) => Number(isAuto(a)) - Number(isAuto(b)));
}
async function lastBuildFor(uuid: string): Promise<BuildSummary | undefined> {
try {
const deployments = await listApplicationDeployments(uuid);
if (!deployments.length) return undefined;
// Prefer the most recently finished; fall back to first.
const finished = deployments.find(d => d.finished_at) ?? deployments[0];
return {
status: finished.status,
@@ -327,6 +414,36 @@ async function lastBuildFor(uuid: string): Promise<BuildSummary | undefined> {
}
}
// In-flight deployments — anything queued/building/in_progress that hasn't
// finished. Used by the project header status pill so the user sees
// "Deploying…" the moment a build kicks off, instead of staring at the
// stale "Live" badge until the page is reloaded.
async function deploymentActivityFor(uuid: string): Promise<{
lastBuild?: BuildSummary;
inFlight?: BuildSummary;
}> {
try {
const deployments = await listApplicationDeployments(uuid);
if (!deployments.length) return {};
const isFinished = (s: string) => /^(success|finished|failed|cancelled|error|exited)$/i.test(s);
const inFlightDep = deployments.find(d => !d.finished_at && !isFinished(d.status ?? ""));
const finishedDep = deployments.find(d => d.finished_at) ?? deployments[0];
return {
lastBuild: finishedDep
? { status: finishedDep.status, finishedAt: finishedDep.finished_at, commit: finishedDep.commit }
: undefined,
inFlight: inFlightDep
? { status: inFlightDep.status, finishedAt: inFlightDep.finished_at, commit: inFlightDep.commit }
: undefined,
};
} catch (err) {
console.error(`[anatomy] deploymentActivityFor(${uuid}) failed:`, err);
return {};
}
}
// ──────────────────────────────────────────────────
// Infrastructure helpers
// ──────────────────────────────────────────────────
@@ -665,10 +782,11 @@ export async function GET(
loadBundledStorage(workspaceId),
]);
// Pull last-build summaries for repo apps in parallel (small N).
// In parallel, fan out env-var fetches to drive provider/secret detection.
const [builds, allEnvs] = await Promise.all([
Promise.all(repoApps.map(a => lastBuildFor(a.uuid))),
// Pull last-build + in-flight build summaries for repo apps in
// parallel (small N). In parallel, fan out env-var fetches to drive
// provider/secret detection.
const [activities, allEnvs] = await Promise.all([
Promise.all(repoApps.map(a => deploymentActivityFor(a.uuid))),
loadAllEnvs(repoApps, allServices.filter(s => !isDevContainer(s))),
]);
@@ -688,7 +806,7 @@ export async function GET(
});
const liveFromRepo: LiveEndpoint[] = repoApps.map((app, i) => {
const domains = fqdnsOf(app.fqdn);
const domains = prioritiseFqdns(fqdnsOf(app.fqdn));
return {
uuid: app.uuid,
name: app.name,
@@ -699,19 +817,28 @@ export async function GET(
domains,
branch: app.git_branch,
buildPack: app.build_pack,
lastBuild: builds[i],
lastBuild: activities[i]?.lastBuild,
inFlightBuild: activities[i]?.inFlight,
};
});
const liveFromImage: LiveEndpoint[] = imageServices.map(s => {
const domains = fqdnsOf((s as unknown as { fqdn?: string }).fqdn);
// Compute smart aggregate statuses + harvest inner-app fqdns for
// every image service in parallel — see smartServiceMetaFor.
const smartMeta = await Promise.all(
imageServices.map((s) =>
smartServiceMetaFor(s.uuid, s.status, (s as unknown as { fqdn?: string }).fqdn),
),
);
const liveFromImage: LiveEndpoint[] = imageServices.map((s, i) => {
const domains = prioritiseFqdns(smartMeta[i].fqdns);
const { image, version } = extractImageInfo(s);
return {
uuid: s.uuid,
name: s.name,
source: "image",
sourceLabel: version ? `${image}:${version}` : image,
status: s.status ?? "unknown",
status: smartMeta[i].status,
fqdn: domains[0],
domains,
};