1098 lines
35 KiB
TypeScript
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 },
|
|
);
|
|
}
|
|
}
|