feat(project): unified anatomy endpoint + live Hosting tab + truthful Live pill

Adds GET /api/projects/[id]/anatomy returning the full project shape
in one shot — codebases (Gitea), production apps (Coolify
applications matched by repo URL), dev services (Coolify services in
the project's coolifyProjectUuid), preview URLs (active fs_dev_servers
rows), and aggregated domains. Each tab reads its own slice via the
new useAnatomy() hook so the page never fans out 3+ requests.

Hosting tab is now real: surfaces production / dev services / preview
URLs / domains with empty-state CTAs explaining what each means and
why it's empty when applicable. Includes a banner when nothing at all
is deployed for the project.

Project header pill (previously hard-coded from data.status, which
historically lied) now derives stage from hosting reality:
  - any production app running → Live (green)
  - any failed app             → Down (red)
  - any service / preview      → Building (blue)
  - else                       → fallback to data.status

Product tab refactored onto the same useAnatomy hook so we no longer
maintain two near-identical fetchers.

Made-with: Cursor
This commit is contained in:
2026-04-28 17:38:57 -07:00
parent b9adcb76b6
commit 6fca78dca9
6 changed files with 814 additions and 127 deletions

View File

@@ -0,0 +1,333 @@
/**
* GET /api/projects/[projectId]/anatomy
*
* Returns the full anatomy of a project across the three tabs:
* - codebases: discovered from Gitea (apps/* or repo root)
* - hosting: production apps + dev services + preview URLs + domains
* - infrastructure: TODO (returns placeholder shape for now)
*
* Single endpoint per page so the UI doesn't fan out 3+ requests on
* every navigation. Each tab consumes its own slice.
*/
import { NextResponse } from "next/server";
import { authSession } from "@/lib/auth/session-server";
import { query } from "@/lib/db-postgres";
import {
listApplications,
listServicesInProject,
type CoolifyApplication,
type CoolifyService,
} from "@/lib/coolify";
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 ProductionApp {
uuid: string;
name: string;
status: string;
fqdn?: string;
branch?: string;
buildPack?: string;
}
interface DevService {
uuid: string;
name: string;
serviceType?: string;
status?: string;
}
interface PreviewUrl {
id: string;
name: string;
port: number;
url: string;
state: string;
startedAt: string;
}
interface Domain {
host: string;
source: "production" | "preview";
}
interface Anatomy {
project: { id: string; name: string; gitea?: string; coolifyProjectUuid?: string };
codebases: Codebase[];
codebasesReason?: "no_repo" | "empty_repo";
hosting: {
production: ProductionApp[];
services: DevService[];
previewUrls: PreviewUrl[];
domains: Domain[];
};
infrastructure: {
/** TODO Phase 4 — see PROJECT_PAGE_ARCHITECTURE.md for the design call. */
placeholder: true;
};
}
// ──────────────────────────────────────────────────
// Gitea
// ──────────────────────────────────────────────────
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 };
}
// ──────────────────────────────────────────────────
// Hosting — Coolify + fs_dev_servers
// ──────────────────────────────────────────────────
/** Strip credentials + .git suffix and normalise to lowercase */
function normaliseRepoUrl(url: string | undefined): string {
if (!url) return "";
let u = url.toLowerCase();
// Remove user:pass@ if present
u = u.replace(/^https?:\/\/[^/@]*@/, "https://");
u = u.replace(/\.git$/, "");
return u;
}
/** Returns the canonical short form: "owner/repo" */
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;
// Also match if either side contains the other (loose fallback for legacy data)
return Boolean(app.git_repository && app.git_repository.toLowerCase().includes(target));
}
async function loadProductionApps(giteaRepo: string | undefined): Promise<ProductionApp[]> {
if (!giteaRepo) return [];
try {
const all = await listApplications();
return all
.filter(app => appMatchesRepo(app, giteaRepo))
.map(app => ({
uuid: app.uuid,
name: app.name,
status: app.status,
fqdn: app.fqdn,
branch: app.git_branch,
buildPack: app.build_pack,
}));
} catch (err) {
console.error("[anatomy] listApplications failed:", err);
return [];
}
}
async function loadDevServices(coolifyProjectUuid: string | undefined): Promise<DevService[]> {
if (!coolifyProjectUuid) return [];
try {
const services = await listServicesInProject(coolifyProjectUuid);
return services.map((s: CoolifyService) => ({
uuid: s.uuid,
name: s.name,
serviceType: s.service_type,
status: s.status,
}));
} catch (err) {
console.error("[anatomy] listServicesInProject failed:", err);
return [];
}
}
async function loadPreviewUrls(projectId: string): Promise<PreviewUrl[]> {
try {
const rows = await query<{
id: string;
name: string;
port: number;
preview_url: string;
state: string;
started_at: string;
}>(
`SELECT id, name, port, preview_url, state, started_at
FROM fs_dev_servers
WHERE project_id = $1 AND state != 'stopped'
ORDER BY started_at DESC`,
[projectId]
);
return rows.map(r => ({
id: r.id,
name: r.name,
port: r.port,
url: r.preview_url,
state: r.state,
startedAt: r.started_at,
}));
} catch (err) {
// fs_dev_servers may not exist yet on older deployments — treat as empty
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 [];
}
}
function dedupeDomains(prod: ProductionApp[], previews: PreviewUrl[]): Domain[] {
const map = new Map<string, Domain>();
for (const app of prod) {
if (!app.fqdn) continue;
// fqdn can be a comma-separated list
for (const raw of app.fqdn.split(",")) {
const host = raw.trim().replace(/^https?:\/\//, "").replace(/\/$/, "");
if (host && !map.has(host)) map.set(host, { host, source: "production" });
}
}
for (const p of previews) {
try {
const host = new URL(p.url).host;
if (host && !map.has(host)) map.set(host, { host, source: "preview" });
} catch { /* malformed URL, skip */ }
}
return [...map.values()];
}
// ──────────────────────────────────────────────────
// 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> }>(
`SELECT p.data 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 giteaRepo = data?.giteaRepo as string | undefined;
const coolifyProjectUuid = data?.coolifyProjectUuid as string | undefined;
const projectName =
(data?.productName as string | undefined) ??
(data?.name as string | undefined) ??
"Project";
// Run the slow bits in parallel
const [codebasesResult, production, services, previews] = 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 }),
loadProductionApps(giteaRepo),
loadDevServices(coolifyProjectUuid),
loadPreviewUrls(projectId),
]);
const codebasesReason: "no_repo" | "empty_repo" | undefined = !giteaRepo
? "no_repo"
: codebasesResult.reason;
const anatomy: Anatomy = {
project: {
id: projectId,
name: projectName,
gitea: giteaRepo,
coolifyProjectUuid,
},
codebases: codebasesResult.codebases,
codebasesReason,
hosting: {
production,
services,
previewUrls: previews,
domains: dedupeDomains(production, previews),
},
infrastructure: { placeholder: true },
};
return NextResponse.json(anatomy);
} catch (err) {
console.error("[anatomy API]", err);
return NextResponse.json({ error: "Failed to build anatomy" }, { status: 500 });
}
}