From 73b672f2c90edeaf71df1e5866c3a0f4a85de4af Mon Sep 17 00:00:00 2001 From: Mark Henderson Date: Thu, 30 Apr 2026 13:45:06 -0700 Subject: [PATCH] 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 --- app/api/projects/[projectId]/anatomy/route.ts | 149 ++++++++++++++++-- 1 file changed, 138 insertions(+), 11 deletions(-) diff --git a/app/api/projects/[projectId]/anatomy/route.ts b/app/api/projects/[projectId]/anatomy/route.ts index e43cd87c..ae270584 100644 --- a/app/api/projects/[projectId]/anatomy/route.ts +++ b/app/api/projects/[projectId]/anatomy/route.ts @@ -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 { 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 { } } +// 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, };