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:
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user