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:
333
app/api/projects/[projectId]/anatomy/route.ts
Normal file
333
app/api/projects/[projectId]/anatomy/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user