Files
vibn-agent-runner/vibn-frontend/app/api/projects/[projectId]/anatomy/route.ts

1098 lines
35 KiB
TypeScript

/**
* GET /api/projects/[projectId]/anatomy
*
* Single-fetch shape consumed by the Product / Hosting / Infrastructure
* tabs. Keeping it one endpoint keeps page transitions cheap and avoids
* fan-out.
*
* Conceptual model (locked Apr 28 2026):
* Product = "what makes up the thing you're shipping"
* → codebases (Gitea repos) + images (Coolify services
* backed by an upstream Docker image, e.g. Twenty CRM,
* n8n). Both are first-class product surfaces.
* → vibn-dev-* containers are NOT shown — the dev
* container is the AI's workshop, not the product.
*
* Hosting = "where does it live and how does it get there"
* → unified `live` list of running endpoints (each item
* carries source = "repo" | "image", attached domains,
* and last build/deploy status inline) + `previews`
* (dev container preview URLs).
* → no separate Build, Domains, or Services categories.
*
* Infrastructure = TODO (placeholder).
*/
import { NextResponse } from "next/server";
import { authSession } from "@/lib/auth/session-server";
import { query } from "@/lib/db-postgres";
import {
listApplications,
listApplicationDeployments,
listApplicationEnvs,
listServicesInProject,
listServiceEnvs,
listDatabasesInProject,
listProjects as listCoolifyProjects,
createProject as createCoolifyProject,
type CoolifyApplication,
type CoolifyService,
type CoolifyDatabase,
} from "@/lib/coolify";
import { getWorkspaceGcsState } from "@/lib/workspace-gcs";
import { VIBN_GCS_LOCATION } from "@/lib/gcp/storage";
import { sortDevPreviewsFrontendFirst } from "@/lib/dev-preview-priority";
const GITEA_API_URL = process.env.GITEA_API_URL ?? "https://git.vibnai.com";
const GITEA_API_TOKEN = process.env.GITEA_API_TOKEN ?? "";
// ──────────────────────────────────────────────────
// Types
// ──────────────────────────────────────────────────
interface Codebase {
id: string;
label: string;
path: string;
hint?: string;
}
interface ProductImage {
uuid: string;
name: string;
/** "twentycrm/twenty" */
image: string;
/** "v1.15" — empty string when not pinned */
version: string;
serviceType?: string;
/** Coolify service status, surfaced so the Product tile can show a dot */
status?: string;
}
interface BuildSummary {
status: string;
createdAt?: string;
finishedAt?: string;
commit?: string;
}
interface LiveEndpoint {
uuid: string;
name: string;
/** repo = built from Gitea, image = pulled docker image (Coolify service) */
source: "repo" | "image";
/** "apps/web" or "twentycrm/twenty:v1.15" */
sourceLabel: string;
status: string;
/** primary host (no scheme) when one exists */
fqdn?: string;
/** all attached hosts */
domains: string[];
branch?: string;
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 {
id: string;
name: string;
/** Last start command — helps debug preview readiness failures */
command?: string;
port: number;
url: string;
state: string;
startedAt: string;
}
/** A Coolify database resource attached to this project. */
interface InfraDatabase {
uuid: string;
name: string;
type: string; // postgresql / redis / mongodb / mysql / keydb / clickhouse
status: string;
isPublic: boolean;
publicPort?: number;
/** "host:port" for the in-cluster reachable DB (no creds). */
internalAddress?: string;
/** Stable env-var key apps should set to consume this DB
* (DATABASE_URL for SQL, REDIS_URL for Redis, etc.). */
consumerEnvKey: string;
}
/** A non-database third-party provider detected by env-var pattern.
* Coolify doesn't model these natively, so we infer them from the
* keys present in app + service env vars. */
interface InfraProvider {
/** Stable id used by the UI for selection */
id: string;
category: "auth" | "email" | "payments" | "llm" | "storage";
vendor: string; // "Stripe", "Resend", "OpenAI", …
/** Where the env keys for this provider live */
attachments: Array<{
resourceUuid: string;
resourceName: string;
resourceKind: "app" | "service";
keys: string[]; // matching env var keys (values redacted)
}>;
}
/** Workspace-bundled S3 (GCS) storage that Vibn provisions for each
* workspace. Same record across every project in the workspace — we
* surface it on each project's Infrastructure tab so users can see
* what's available without going to settings. */
interface BundledStorage {
status: "ready" | "pending" | "partial" | "error" | "unprovisioned";
bucketName?: string;
hmacAccessId?: string;
region?: string;
errorMessage?: string;
}
interface InfraSecretSummary {
/** Total number of env vars across every app + service in the project */
total: number;
/** Per-resource breakdown for drill-down. Includes the actual env-var
* KEYS (never values) so the Secrets detail pane can show what's
* set. Values are intentionally excluded from this surface — to
* read or rotate them, route through apps.envs.* / services.envs.*
* MCP tools which audit-log and tenant-scope every access. */
byResource: Array<{
resourceUuid: string;
resourceName: string;
resourceKind: "app" | "service";
count: number;
keys: string[];
}>;
}
interface Anatomy {
project: {
id: string;
name: string;
gitea?: string;
coolifyProjectUuid?: string;
};
codebasesReason?: "no_repo" | "empty_repo";
product: {
codebases: Codebase[];
images: ProductImage[];
};
hosting: {
live: LiveEndpoint[];
previews: Preview[];
};
infrastructure: {
databases: InfraDatabase[];
providers: InfraProvider[];
bundledStorage: BundledStorage;
secrets: InfraSecretSummary;
};
}
// ──────────────────────────────────────────────────
// Gitea (codebase discovery)
// ──────────────────────────────────────────────────
interface GiteaItem {
name: string;
path: string;
type: "file" | "dir" | "symlink";
}
async function giteaList(
repo: string,
path: string,
): Promise<GiteaItem[] | null> {
const encoded = path ? encodeURIComponent(path).replace(/%2F/g, "/") : "";
const res = await fetch(
`${GITEA_API_URL}/api/v1/repos/${repo}/contents/${encoded}`,
{
headers: { Authorization: `token ${GITEA_API_TOKEN}` },
next: { revalidate: 30 },
},
);
if (res.status === 404) return null;
if (!res.ok) throw new Error(`Gitea ${res.status} listing ${repo}/${path}`);
const data = await res.json();
return Array.isArray(data) ? (data as GiteaItem[]) : null;
}
async function discoverCodebases(giteaRepo: string): Promise<{
codebases: Codebase[];
reason?: "empty_repo";
}> {
const root = await giteaList(giteaRepo, "");
if (!root) return { codebases: [], reason: "empty_repo" };
const appsDir = root.find(
(item) => item.type === "dir" && item.name === "apps",
);
let codebases: Codebase[] = [];
if (appsDir) {
const appsChildren = await giteaList(giteaRepo, "apps");
if (appsChildren) {
codebases = appsChildren
.filter((item) => item.type === "dir")
.map((item) => ({
id: item.name,
label: item.name,
path: `apps/${item.name}`,
}));
}
}
if (codebases.length === 0) {
const repoName = giteaRepo.split("/").pop() || "app";
codebases = [
{
id: "root",
label: repoName,
path: "",
hint: "Single-codebase project — repository root.",
},
];
}
return { codebases };
}
// ──────────────────────────────────────────────────
// Coolify helpers
// ──────────────────────────────────────────────────
function normaliseRepoUrl(url: string | undefined): string {
if (!url) return "";
let u = url.toLowerCase();
u = u.replace(/^https?:\/\/[^/@]*@/, "https://");
u = u.replace(/\.git$/, "");
return u;
}
function shortFormOfRepo(url: string | undefined): string {
if (!url) return "";
const cleaned = normaliseRepoUrl(url).replace(/^https?:\/\/[^/]+\//, "");
return cleaned.replace(/\.git$/, "").toLowerCase();
}
function appMatchesRepo(app: CoolifyApplication, giteaRepo: string): boolean {
const target = giteaRepo.toLowerCase();
const appShort = shortFormOfRepo(app.git_repository);
if (appShort && appShort === target) return true;
return Boolean(
app.git_repository && app.git_repository.toLowerCase().includes(target),
);
}
async function loadRepoApps(
giteaRepo: string | undefined,
): Promise<CoolifyApplication[]> {
if (!giteaRepo) return [];
try {
const all = await listApplications();
return all.filter((app) => appMatchesRepo(app, giteaRepo));
} catch (err) {
console.error("[anatomy] listApplications failed:", err);
return [];
}
}
async function loadProjectServices(
coolifyProjectUuid: string | undefined,
): Promise<CoolifyService[]> {
if (!coolifyProjectUuid) return [];
try {
return await listServicesInProject(coolifyProjectUuid);
} catch (err) {
console.error("[anatomy] listServicesInProject failed:", err);
return [];
}
}
// 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.
* Best-effort regex; we only want a sensible label, not perfection. */
function extractImageInfo(svc: CoolifyService): {
image: string;
version: string;
} {
const raw =
(svc as unknown as { docker_compose_raw?: string }).docker_compose_raw ??
"";
const m = raw.match(/image:\s*['"]?([^\s'"\n]+)['"]?/);
if (!m) return { image: svc.service_type ?? svc.name, version: "" };
const full = m[1];
const at = full.lastIndexOf(":");
if (at <= 0 || full.slice(at).includes("/")) {
return { image: full, version: "" };
}
return { image: full.slice(0, at), version: full.slice(at + 1) };
}
function fqdnsOf(value: string | undefined): string[] {
if (!value) return [];
return value
.split(",")
.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;
const finished = deployments.find((d) => d.finished_at) ?? deployments[0];
return {
status: finished.status,
finishedAt: finished.finished_at,
commit: finished.commit,
};
} catch (err) {
console.error(`[anatomy] listApplicationDeployments(${uuid}) failed:`, err);
return 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,
createdAt: finishedDep.created_at,
finishedAt: finishedDep.finished_at,
commit: finishedDep.commit,
}
: undefined,
inFlight: inFlightDep
? {
status: inFlightDep.status,
createdAt: inFlightDep.created_at,
finishedAt: inFlightDep.finished_at,
commit: inFlightDep.commit,
}
: undefined,
};
} catch (err) {
console.error(`[anatomy] deploymentActivityFor(${uuid}) failed:`, err);
return {};
}
}
// ──────────────────────────────────────────────────
// Infrastructure helpers
// ──────────────────────────────────────────────────
/** Coolify database type names → friendly normalised label. */
function dbTypeOf(d: CoolifyDatabase): string {
const raw = (d.type ?? "").toLowerCase();
if (raw.includes("postgres")) return "postgresql";
if (raw.includes("redis")) return "redis";
if (raw.includes("keydb")) return "keydb";
if (raw.includes("dragonfly")) return "dragonfly";
if (raw.includes("mongo")) return "mongodb";
if (raw.includes("mysql") || raw.includes("mariadb")) return "mysql";
if (raw.includes("clickhouse")) return "clickhouse";
return raw || "database";
}
/** Best-effort host:port from the in-cluster URL (creds stripped). */
function parseInternalAddress(
internalUrl: string | undefined,
): string | undefined {
if (!internalUrl) return undefined;
try {
const u = new URL(internalUrl);
return u.port ? `${u.hostname}:${u.port}` : u.hostname;
} catch {
// Coolify sometimes returns non-URL formats (e.g. raw mongo conn strings)
const m = internalUrl.match(/@([^/]+)\/?/);
return m ? m[1] : undefined;
}
}
function consumerKeyFor(type: string): string {
if (type === "redis" || type === "keydb" || type === "dragonfly")
return "REDIS_URL";
if (type === "mongodb") return "MONGODB_URI";
if (type === "clickhouse") return "CLICKHOUSE_URL";
return "DATABASE_URL";
}
async function loadDatabases(
coolifyProjectUuid: string | undefined,
): Promise<InfraDatabase[]> {
if (!coolifyProjectUuid) return [];
try {
const dbs = await listDatabasesInProject(coolifyProjectUuid);
return dbs.map((d) => {
const type = dbTypeOf(d);
return {
uuid: d.uuid,
name: d.name,
type,
status: d.status ?? "unknown",
isPublic: !!d.is_public,
publicPort: d.public_port,
internalAddress: parseInternalAddress(d.internal_db_url),
consumerEnvKey: consumerKeyFor(type),
};
});
} catch (err) {
console.error("[anatomy] listDatabasesInProject failed:", err);
return [];
}
}
/**
* Provider detection rules. Each rule maps a category + vendor to a
* regex tested against env-var keys. We deliberately keep the prefix
* specific enough to avoid false positives (e.g. `STRIPE_*` not just
* `*KEY*`).
*/
/**
* Provider detection rules. Categories surfaced today: Auth, Email,
* Payments, LLM. Storage is intentionally NOT auto-detected here —
* it's served by the workspace's bundled GCS bucket on the same tab.
*
* (Earlier iteration also detected SMS, Analytics, Search and
* Monitoring categories. Removed Apr 29 2026 to keep the surface
* focused on what users are actually plumbing today; rules can be
* added back when those categories get real UX.)
*/
const PROVIDER_RULES: Array<{
category: InfraProvider["category"];
vendor: string;
pattern: RegExp;
}> = [
// Auth
{ category: "auth", vendor: "Clerk", pattern: /^(NEXT_PUBLIC_)?CLERK_/ },
{ category: "auth", vendor: "NextAuth", pattern: /^NEXTAUTH_/ },
{ category: "auth", vendor: "Auth0", pattern: /^AUTH0_/ },
{
category: "auth",
vendor: "Supabase Auth",
pattern: /^SUPABASE_(SERVICE_ROLE|JWT|ANON)/,
},
{ category: "auth", vendor: "SuperTokens", pattern: /^SUPERTOKENS_/ },
{ category: "auth", vendor: "WorkOS", pattern: /^WORKOS_/ },
{
category: "auth",
vendor: "Firebase Auth",
pattern: /^FIREBASE_(AUTH|API_KEY)/,
},
// Email
{ category: "email", vendor: "Resend", pattern: /^RESEND_/ },
{ category: "email", vendor: "Mailgun", pattern: /^MAILGUN_/ },
{ category: "email", vendor: "Postmark", pattern: /^POSTMARK_/ },
{ category: "email", vendor: "SendGrid", pattern: /^SENDGRID_/ },
{ category: "email", vendor: "AWS SES", pattern: /^(SES_|AWS_SES_)/ },
{ category: "email", vendor: "Loops", pattern: /^LOOPS_/ },
// Payments
{
category: "payments",
vendor: "Stripe",
pattern: /^(NEXT_PUBLIC_)?STRIPE_/,
},
{
category: "payments",
vendor: "LemonSqueezy",
pattern: /^LEMON(SQUEEZY)?_/,
},
{ category: "payments", vendor: "Paddle", pattern: /^PADDLE_/ },
// LLM (a.k.a. Models)
{ category: "llm", vendor: "OpenAI", pattern: /^OPENAI_/ },
{ category: "llm", vendor: "Anthropic", pattern: /^ANTHROPIC_/ },
{
category: "llm",
vendor: "Google AI",
pattern: /^(GEMINI_|GOOGLE_AI_|GOOGLE_GENAI_)/,
},
{ category: "llm", vendor: "Mistral", pattern: /^MISTRAL_/ },
{ category: "llm", vendor: "Cohere", pattern: /^COHERE_/ },
{ category: "llm", vendor: "Groq", pattern: /^GROQ_/ },
{ category: "llm", vendor: "OpenRouter", pattern: /^OPENROUTER_/ },
];
interface ResourceEnvs {
resourceUuid: string;
resourceName: string;
resourceKind: "app" | "service";
keys: string[];
}
async function loadAllEnvs(
apps: CoolifyApplication[],
services: CoolifyService[],
): Promise<ResourceEnvs[]> {
const appPromises = apps.map(async (a): Promise<ResourceEnvs> => {
try {
const envs = await listApplicationEnvs(a.uuid);
return {
resourceUuid: a.uuid,
resourceName: a.name,
resourceKind: "app",
keys: envs.map((e) => e.key),
};
} catch (err) {
console.error(`[anatomy] listApplicationEnvs(${a.uuid}) failed:`, err);
return {
resourceUuid: a.uuid,
resourceName: a.name,
resourceKind: "app",
keys: [],
};
}
});
const svcPromises = services.map(async (s): Promise<ResourceEnvs> => {
try {
const envs = await listServiceEnvs(s.uuid);
return {
resourceUuid: s.uuid,
resourceName: s.name,
resourceKind: "service",
keys: envs.map((e) => e.key),
};
} catch (err) {
console.error(`[anatomy] listServiceEnvs(${s.uuid}) failed:`, err);
return {
resourceUuid: s.uuid,
resourceName: s.name,
resourceKind: "service",
keys: [],
};
}
});
return Promise.all([...appPromises, ...svcPromises]);
}
function detectProviders(allEnvs: ResourceEnvs[]): InfraProvider[] {
// vendor → { category, attachments-by-resource }
const byVendor = new Map<string, InfraProvider>();
for (const env of allEnvs) {
if (env.keys.length === 0) continue;
for (const rule of PROVIDER_RULES) {
const matches = env.keys.filter((k) => rule.pattern.test(k));
if (matches.length === 0) continue;
const id = `${rule.category}:${rule.vendor.toLowerCase().replace(/\s+/g, "-")}`;
let entry = byVendor.get(id);
if (!entry) {
entry = {
id,
category: rule.category,
vendor: rule.vendor,
attachments: [],
};
byVendor.set(id, entry);
}
entry.attachments.push({
resourceUuid: env.resourceUuid,
resourceName: env.resourceName,
resourceKind: env.resourceKind,
keys: matches,
});
}
}
return Array.from(byVendor.values());
}
function summariseSecrets(allEnvs: ResourceEnvs[]): InfraSecretSummary {
const byResource = allEnvs
.filter((e) => e.keys.length > 0)
.map((e) => ({
resourceUuid: e.resourceUuid,
resourceName: e.resourceName,
resourceKind: e.resourceKind,
count: e.keys.length,
keys: [...e.keys].sort(),
}))
.sort((a, b) => b.count - a.count);
const total = byResource.reduce((sum, r) => sum + r.count, 0);
return { total, byResource };
}
async function loadBundledStorage(
workspaceId: string | undefined,
): Promise<BundledStorage> {
if (!workspaceId) return { status: "unprovisioned" };
try {
const ws = await getWorkspaceGcsState(workspaceId);
if (!ws) return { status: "unprovisioned" };
return {
status: ws.gcp_provision_status ?? "unprovisioned",
bucketName: ws.gcs_default_bucket_name ?? undefined,
hmacAccessId: ws.gcs_hmac_access_id ?? undefined,
region: VIBN_GCS_LOCATION,
errorMessage: ws.gcp_provision_error ?? undefined,
};
} catch (err) {
console.error("[anatomy] getWorkspaceGcsState failed:", err);
return {
status: "error",
errorMessage: err instanceof Error ? err.message : String(err),
};
}
}
async function loadPreviews(projectId: string): Promise<Preview[]> {
try {
const rows = await query<{
id: string;
name: string;
command: string | null;
port: number;
preview_url: string;
state: string;
started_at: string;
}>(
`SELECT id, name, command, port, preview_url, state, started_at
FROM fs_dev_servers
WHERE project_id = $1 AND state != 'stopped'`,
[projectId],
);
// Filter out zombies: if a server is marked 'running' but the URL returns a 50x
// Gateway error or times out, the process died. We mark it stopped so the
// UI can trigger an auto-restart.
const activePreviews: typeof rows = [];
await Promise.all(
rows.map(async (r) => {
if (r.state !== "running") {
activePreviews.push(r);
return;
}
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 2500); // Fast 2.5s timeout
const ping = await fetch(r.preview_url, {
method: "HEAD",
signal: controller.signal,
});
clearTimeout(timeout);
// 502/503/504 means Traefik is up but the container isn't answering.
// 404 means Traefik doesn't even know about the route.
if (
ping.status === 502 ||
ping.status === 503 ||
ping.status === 504 ||
ping.status === 404
) {
console.warn(
`[anatomy] Preview zombie detected for ${r.preview_url} (HTTP ${ping.status}). Marking stopped.`,
);
await query(
`UPDATE fs_dev_servers SET state = 'stopped' WHERE id = $1`,
[r.id],
).catch(() => {});
} else {
activePreviews.push(r);
}
} catch (e: any) {
// If the fetch completely fails (e.g. timeout, DNS failure), it's dead.
console.warn(
`[anatomy] Preview zombie detected for ${r.preview_url} (${e.message}). Marking stopped.`,
);
await query(
`UPDATE fs_dev_servers SET state = 'stopped' WHERE id = $1`,
[r.id],
).catch(() => {});
}
}),
);
return sortDevPreviewsFrontendFirst(activePreviews).map((r) => ({
id: r.id,
name: r.name,
command: r.command ?? undefined,
port: r.port,
url: r.preview_url,
state: r.state,
startedAt: r.started_at,
}));
} catch (err) {
if (
err instanceof Error &&
/relation "fs_dev_servers" does not exist/i.test(err.message)
) {
return [];
}
console.error("[anatomy] fs_dev_servers query failed:", err);
return [];
}
}
// ──────────────────────────────────────────────────
// Handler
// ──────────────────────────────────────────────────
export async function GET(
_req: Request,
{ params }: { params: Promise<{ projectId: string }> },
) {
try {
const { projectId } = await params;
const session = await authSession();
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const rows = await query<{
data: Record<string, unknown>;
vibn_workspace_id: string | null;
}>(
`SELECT p.data, p.vibn_workspace_id FROM fs_projects p
JOIN fs_users u ON u.id = p.user_id
WHERE p.id = $1 AND u.data->>'email' = $2 LIMIT 1`,
[projectId, session.user.email],
);
if (rows.length === 0) {
return NextResponse.json({ error: "Project not found" }, { status: 404 });
}
const data = rows[0].data;
const workspaceId = rows[0].vibn_workspace_id ?? undefined;
const giteaRepo = data?.giteaRepo as string | undefined;
let coolifyProjectUuid = data?.coolifyProjectUuid as string | undefined;
// Self-heal: if a previous bug stored the *workspace*'s Coolify
// project UUID on this project (which would surface every other
// project's services as if they belonged to this one), wipe it
// and re-provision a dedicated Coolify project on the fly.
if (coolifyProjectUuid && workspaceId) {
try {
const wsRow = await query<{
slug: string;
coolify_project_uuid: string | null;
}>(
`SELECT slug, coolify_project_uuid FROM vibn_workspaces WHERE id = $1 LIMIT 1`,
[workspaceId],
);
const ws = wsRow[0];
if (
ws &&
ws.coolify_project_uuid &&
ws.coolify_project_uuid === coolifyProjectUuid
) {
console.warn(
"[anatomy] Project",
projectId,
"had workspace-UUID stored as coolifyProjectUuid — re-provisioning.",
);
const projectSlug = (data?.slug as string | undefined) ?? projectId;
const projectNm =
(data?.productName as string | undefined) ?? projectSlug;
const wantName = `vibn-${ws.slug}-${projectSlug}`;
try {
const all = await listCoolifyProjects();
const existing = all.find((p) => p.name === wantName);
const fresh = existing
? existing
: await createCoolifyProject(
wantName,
`Vibn project ${projectNm} - workspace ${ws.slug}`,
);
await query(
`UPDATE fs_projects
SET data = data || jsonb_build_object('coolifyProjectUuid', $2::text),
updated_at = NOW()
WHERE id = $1`,
[projectId, fresh.uuid],
);
coolifyProjectUuid = fresh.uuid;
} catch (provErr) {
console.error("[anatomy] auto-heal provisioning failed:", provErr);
coolifyProjectUuid = undefined; // refuse to serve workspace-level resources
}
}
} catch (wsErr) {
console.error(
"[anatomy] workspace lookup for self-heal failed:",
wsErr,
);
}
}
const projectName =
(data?.productName as string | undefined) ??
(data?.name as string | undefined) ??
"Project";
const [
codebasesResult,
repoApps,
allServices,
previews,
databases,
bundledStorage,
] = await Promise.all([
giteaRepo
? discoverCodebases(giteaRepo).catch((err) => {
console.error("[anatomy] discoverCodebases failed:", err);
return {
codebases: [] as Codebase[],
reason: "empty_repo" as const,
};
})
: Promise.resolve({
codebases: [] as Codebase[],
reason: undefined as undefined,
}),
loadRepoApps(giteaRepo),
loadProjectServices(coolifyProjectUuid),
loadPreviews(projectId),
loadDatabases(coolifyProjectUuid),
loadBundledStorage(workspaceId),
]);
// 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)),
),
]);
// Image services (Coolify services minus vibn-dev-*)
const imageServices = allServices.filter((s) => !isDevContainer(s));
const productImages: ProductImage[] = imageServices.map((s) => {
const { image, version } = extractImageInfo(s);
return {
uuid: s.uuid,
name: s.name,
image,
version,
serviceType: s.service_type,
status: s.status,
};
});
const liveFromRepo: LiveEndpoint[] = repoApps.map((app, i) => {
const domains = prioritiseFqdns(fqdnsOf(app.fqdn));
return {
uuid: app.uuid,
name: app.name,
source: "repo",
sourceLabel:
shortFormOfRepo(app.git_repository) || (giteaRepo ?? "repo"),
status: app.status,
fqdn: domains[0],
domains,
branch: app.git_branch,
buildPack: app.build_pack,
lastBuild: activities[i]?.lastBuild,
inFlightBuild: activities[i]?.inFlight,
};
});
// 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: smartMeta[i].status,
fqdn: domains[0],
domains,
};
});
const codebasesReason: "no_repo" | "empty_repo" | undefined = !giteaRepo
? "no_repo"
: codebasesResult.reason;
const anatomy: Anatomy = {
project: {
id: projectId,
name: projectName,
gitea: giteaRepo,
coolifyProjectUuid,
},
codebasesReason,
product: {
codebases: codebasesResult.codebases,
images: productImages,
},
hosting: {
live: [...liveFromRepo, ...liveFromImage],
previews,
},
infrastructure: {
databases,
providers: detectProviders(allEnvs),
bundledStorage,
secrets: summariseSecrets(allEnvs),
},
};
return NextResponse.json(anatomy);
} catch (err) {
console.error("[anatomy API]", err);
return NextResponse.json(
{ error: "Failed to build anatomy" },
{ status: 500 },
);
}
}