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

@@ -1,39 +1,299 @@
import { SectionScaffold, StatusPanel, EmptyState } from "@/components/project/section-scaffold";
"use client";
import { useParams } from "next/navigation";
import {
Loader2, AlertCircle, ExternalLink, Globe, Server,
Cloud, Zap, CircleDot,
} from "lucide-react";
import { useAnatomy, type Anatomy } from "@/components/project/use-anatomy";
/**
* Hosting tab.
*
* Surfaces "where this product runs" — Coolify production apps,
* dev/services, live preview URLs from active dev_servers, and the
* domains pointing at any of them. All from one /anatomy fetch.
*/
export default function HostingTab() {
const params = useParams();
const projectId = params.projectId as string;
const { anatomy, loading, error } = useAnatomy(projectId);
if (loading && !anatomy) {
return <Center><Loader2 size={14} className="animate-spin" /> Loading</Center>;
}
if (error) {
return <Center><AlertCircle size={14} /> {error}</Center>;
}
if (!anatomy) return null;
const { production, services, previewUrls, domains } = anatomy.hosting;
const hasAnything =
production.length + services.length + previewUrls.length + domains.length > 0;
return (
<SectionScaffold
subAreas={[
{ label: "Production", hint: "The Coolify app serving real users." },
{ label: "Preview URLs", hint: "Per-port previews from your dev container." },
{ label: "Domains & DNS", hint: "Custom domains, SSL, cert renewal." },
{ label: "CDN & cache", hint: "Edge caching, asset delivery." },
{ label: "Observability", hint: "Uptime, logs, metrics, alerts." },
{ label: "Backups", hint: "Database snapshots + restore points." },
{ label: "Cost", hint: "Monthly $$ across all hosting." },
]}
rightPanel={
<>
<StatusPanel title="Production">
<EmptyState
message="No production app linked yet"
hint="Phase 2 will list Coolify apps wired to this project repo."
<div style={pageWrap}>
<div style={inner}>
{!hasAnything && <NothingDeployedBanner anatomy={anatomy} />}
<Section icon={Cloud} title="Production" count={production.length}>
{production.length === 0 ? (
<Empty
message={anatomy.project.gitea
? "No Coolify app deploys this repo yet."
: "No Gitea repo connected, so nothing to deploy."}
hint={anatomy.project.gitea
? `Looking for an app whose repo matches ${anatomy.project.gitea}.`
: undefined}
/>
</StatusPanel>
<StatusPanel title="Preview URLs">
<EmptyState
message="No active dev servers"
hint="Start a dev server from chat to get a *.preview.vibnai.com URL here."
) : production.map(app => (
<Row
key={app.uuid}
title={app.name}
subtitle={[app.branch, app.buildPack].filter(Boolean).join(" · ") || "—"}
statusDot={statusColor(app.status)}
statusLabel={app.status}
href={app.fqdn ? hrefForFqdn(app.fqdn) : undefined}
hrefLabel={app.fqdn ? primaryHost(app.fqdn) : undefined}
/>
</StatusPanel>
<StatusPanel title="Domains">
<EmptyState
message="No custom domains"
hint="Phase 2 will let you attach + verify a domain in one place."
))}
</Section>
<Section icon={Server} title="Dev services" count={services.length}>
{services.length === 0 ? (
<Empty
message="No dev services running for this project."
hint="Path B containers (vibn-dev) appear here once provisioned."
/>
</StatusPanel>
</>
}
/>
) : services.map(svc => (
<Row
key={svc.uuid}
title={svc.name}
subtitle={svc.serviceType ?? "service"}
statusDot={statusColor(svc.status ?? "unknown")}
statusLabel={svc.status ?? "unknown"}
/>
))}
</Section>
<Section icon={Zap} title="Preview URLs" count={previewUrls.length}>
{previewUrls.length === 0 ? (
<Empty
message="No active preview URLs."
hint="Start a dev server from chat to expose one on *.preview.vibnai.com."
/>
) : previewUrls.map(p => (
<Row
key={p.id}
title={`${p.name} :${p.port}`}
subtitle={`Started ${formatRelative(p.startedAt)}`}
statusDot={p.state === "running" ? "#2e7d32" : "#a09a90"}
statusLabel={p.state}
href={p.url}
hrefLabel={hostOf(p.url)}
/>
))}
</Section>
<Section icon={Globe} title="Domains" count={domains.length}>
{domains.length === 0 ? (
<Empty message="No custom domains attached yet." />
) : domains.map(d => (
<Row
key={d.host}
title={d.host}
subtitle={d.source === "production" ? "Production" : "Preview"}
href={`https://${d.host}`}
hrefLabel="open"
/>
))}
</Section>
</div>
</div>
);
}
// ──────────────────────────────────────────────────────────────────────
// Bits
// ──────────────────────────────────────────────────────────────────────
function NothingDeployedBanner({ anatomy }: { anatomy: Anatomy }) {
const reason = anatomy.project.gitea
? "The repo exists on Gitea but no production app is wired up in Coolify yet."
: "This project doesn't have a Gitea repo connected, so there's nothing to deploy.";
return (
<div style={bannerBox}>
<div style={bannerTitle}>Nothing is deployed for this project</div>
<div style={bannerBody}>{reason}</div>
</div>
);
}
function Section({
icon: Icon, title, count, children,
}: {
icon: React.ComponentType<{ size?: number; style?: React.CSSProperties }>;
title: string;
count: number;
children: React.ReactNode;
}) {
return (
<section style={sectionWrap}>
<header style={sectionHeader}>
<Icon size={14} style={{ color: INK.mid }} />
<span style={sectionTitle}>{title}</span>
<span style={countPill}>{count}</span>
</header>
<div style={sectionBody}>{children}</div>
</section>
);
}
function Row({
title, subtitle, statusDot, statusLabel, href, hrefLabel,
}: {
title: string;
subtitle?: string;
statusDot?: string;
statusLabel?: string;
href?: string;
hrefLabel?: string;
}) {
return (
<div style={rowWrap}>
<div style={{ minWidth: 0, flex: 1 }}>
<div style={rowTitle}>{title}</div>
{subtitle && <div style={rowSubtitle}>{subtitle}</div>}
</div>
{statusDot && (
<span style={statusPill}>
<CircleDot size={9} style={{ color: statusDot }} />
<span style={{ color: INK.mid, fontSize: "0.74rem" }}>{statusLabel}</span>
</span>
)}
{href && (
<a href={href} target="_blank" rel="noreferrer" style={openLink}>
<ExternalLink size={12} /> {hrefLabel ?? "open"}
</a>
)}
</div>
);
}
function Empty({ message, hint }: { message: string; hint?: string }) {
return (
<div style={emptyWrap}>
<div style={emptyMsg}>{message}</div>
{hint && <div style={emptyHint}>{hint}</div>}
</div>
);
}
function Center({ children }: { children: React.ReactNode }) {
return (
<div style={{
padding: "60px 20px", textAlign: "center", color: INK.mid,
fontSize: "0.88rem", display: "flex", justifyContent: "center", gap: 8, alignItems: "center",
}}>{children}</div>
);
}
// ──────────────────────────────────────────────────────────────────────
// Helpers
// ──────────────────────────────────────────────────────────────────────
function primaryHost(fqdn: string) {
return fqdn.split(",")[0]?.trim().replace(/^https?:\/\//, "").replace(/\/$/, "") || fqdn;
}
function hrefForFqdn(fqdn: string) {
const host = primaryHost(fqdn);
return host.startsWith("http") ? host : `https://${host}`;
}
function hostOf(url: string) {
try { return new URL(url).host; } catch { return url; }
}
function statusColor(status: string) {
const s = status.toLowerCase();
if (s.includes("running") || s.includes("healthy")) return "#2e7d32";
if (s.includes("starting") || s.includes("deploying")) return "#d4a04a";
if (s.includes("exited") || s.includes("failed") || s.includes("unhealthy")) return "#c5392b";
return "#a09a90";
}
function formatRelative(iso: string) {
const ms = Date.now() - new Date(iso).getTime();
const min = Math.floor(ms / 60_000);
if (min < 1) return "just now";
if (min < 60) return `${min}m ago`;
const hr = Math.floor(min / 60);
if (hr < 24) return `${hr}h ago`;
const d = Math.floor(hr / 24);
return `${d}d ago`;
}
// ──────────────────────────────────────────────────────────────────────
// Tokens
// ──────────────────────────────────────────────────────────────────────
const INK = {
ink: "#1a1a1a",
mid: "#5f5e5a",
muted: "#a09a90",
border: "#e8e4dc",
borderSoft: "#efebe1",
cardBg: "#fff",
fontSans: '"Outfit", "Inter", ui-sans-serif, sans-serif',
} as const;
const pageWrap: React.CSSProperties = {
padding: "28px 48px 48px",
fontFamily: INK.fontSans,
color: INK.ink,
};
const inner: React.CSSProperties = {
maxWidth: 960, margin: "0 auto",
display: "flex", flexDirection: "column", gap: 16,
};
const bannerBox: React.CSSProperties = {
padding: "14px 18px", borderRadius: 10,
background: "#fff7e8", border: "1px solid #f0deb6",
};
const bannerTitle: React.CSSProperties = {
fontWeight: 600, color: "#7a5818", fontSize: "0.88rem", marginBottom: 4,
};
const bannerBody: React.CSSProperties = { color: "#7a5818", fontSize: "0.82rem", lineHeight: 1.5 };
const sectionWrap: React.CSSProperties = {
background: INK.cardBg, border: `1px solid ${INK.border}`, borderRadius: 10, overflow: "hidden",
};
const sectionHeader: React.CSSProperties = {
display: "flex", alignItems: "center", gap: 8,
padding: "14px 18px", borderBottom: `1px solid ${INK.borderSoft}`,
};
const sectionTitle: React.CSSProperties = {
fontSize: "0.78rem", fontWeight: 600, letterSpacing: "0.06em", textTransform: "uppercase", color: INK.ink,
};
const countPill: React.CSSProperties = {
marginLeft: "auto", fontSize: "0.72rem", fontWeight: 600, color: INK.mid,
padding: "2px 8px", borderRadius: 999, background: "#f3eee4",
};
const sectionBody: React.CSSProperties = { display: "flex", flexDirection: "column" };
const rowWrap: React.CSSProperties = {
display: "flex", alignItems: "center", gap: 14,
padding: "12px 18px", borderTop: `1px solid ${INK.borderSoft}`,
};
const rowTitle: React.CSSProperties = { fontSize: "0.88rem", fontWeight: 600, color: INK.ink };
const rowSubtitle: React.CSSProperties = { fontSize: "0.74rem", color: INK.mid, marginTop: 2 };
const statusPill: React.CSSProperties = {
display: "inline-flex", alignItems: "center", gap: 6, flexShrink: 0,
};
const openLink: React.CSSProperties = {
display: "inline-flex", alignItems: "center", gap: 5,
fontSize: "0.78rem", color: INK.mid, textDecoration: "none",
border: `1px solid ${INK.borderSoft}`, borderRadius: 6, padding: "4px 8px",
flexShrink: 0,
};
const emptyWrap: React.CSSProperties = {
padding: "20px 18px", textAlign: "center",
borderTop: `1px solid ${INK.borderSoft}`,
};
const emptyMsg: React.CSSProperties = { fontSize: "0.82rem", color: INK.mid, marginBottom: 4 };
const emptyHint: React.CSSProperties = { fontSize: "0.74rem", color: INK.muted, fontStyle: "italic" };

View File

@@ -24,6 +24,7 @@ import { Settings } from "lucide-react";
import { VIBNSidebar } from "@/components/layout/vibn-sidebar";
import { ProjectAssociationPrompt } from "@/components/project-association-prompt";
import { ProjectTabBar } from "@/components/project/project-tab-bar";
import { ProjectStagePill } from "@/components/project/project-stage-pill";
import { query } from "@/lib/db-postgres";
interface ProjectMeta {
@@ -75,7 +76,7 @@ export default async function ProjectTabsLayout({
{project.vision && <p style={projectVisionText}>{project.vision}</p>}
</div>
<div style={{ display: "flex", alignItems: "center", gap: 10, flexShrink: 0 }}>
<StagePill stage={project.stage} />
<ProjectStagePill projectId={projectId} fallbackStage={project.stage} />
<Link
href={`/${workspace}/project/${projectId}/settings`}
style={settingsBtn}
@@ -99,28 +100,6 @@ export default async function ProjectTabsLayout({
);
}
function StagePill({ stage }: { stage: string }) {
const map: Record<string, { label: string; color: string; bg: string }> = {
discovery: { label: "Defining", color: "#9a7b3a", bg: "#d4a04a14" },
architecture: { label: "Planning", color: "#3d5afe", bg: "#3d5afe10" },
building: { label: "Building", color: "#3d5afe", bg: "#3d5afe10" },
active: { label: "Live", color: "#2e7d32", bg: "#2e7d3210" },
};
const s = map[stage] ?? map.discovery;
return (
<span style={{
display: "inline-flex", alignItems: "center", gap: 6,
padding: "4px 10px", borderRadius: 4,
fontSize: "0.7rem", fontWeight: 600, letterSpacing: "0.02em",
color: s.color, background: s.bg,
whiteSpace: "nowrap",
}}>
<span style={{ width: 7, height: 7, borderRadius: "50%", background: s.color }} />
{s.label}
</span>
);
}
const INK = {
ink: "#1a1a1a",
mid: "#5f5e5a",

View File

@@ -5,92 +5,38 @@ import { useParams } from "next/navigation";
import { Loader2, AlertCircle, ChevronDown, ChevronRight, Box } from "lucide-react";
import { GiteaFileTree } from "@/components/project/gitea-file-tree";
import { GiteaFileViewer } from "@/components/project/gitea-file-viewer";
import { useAnatomy } from "@/components/project/use-anatomy";
/**
* Product tab — IDE-style.
*
* Left column: codebases stack. Each codebase is a panel with its
* own header (name) and an inline expandable Gitea file tree below.
* Single-codebase projects auto-expand on load. Clicking a file in
* any tree updates the right column with that file's content.
* Reads codebases from the shared /anatomy endpoint. Left column is
* a stack of expandable codebase tiles, each with its own inline
* Gitea file tree. Clicking a file previews its content on the right.
*/
interface Codebase {
id: string;
label: string;
path: string;
hint?: string;
}
interface CodebasesResponse {
codebases: Codebase[];
reason?: "no_repo" | "empty_repo";
error?: string;
}
export default function ProductTab() {
const params = useParams();
const projectId = params.projectId as string;
const { anatomy, loading, error } = useAnatomy(projectId);
const [codebases, setCodebases] = useState<Codebase[] | null>(null);
const [reason, setReason] = useState<CodebasesResponse["reason"]>();
const [listError, setListError] = useState<string | null>(null);
const codebases = anatomy?.codebases ?? null;
const reason = anatomy?.codebasesReason;
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [selectedFile, setSelectedFile] = useState<{ codebaseId: string; path: string } | null>(null);
// Auto-expand the first codebase whenever anatomy lands
useEffect(() => {
if (codebases && codebases[0]) {
setExpanded(prev => (prev.size === 0 ? new Set([codebases[0].id]) : prev));
}
}, [codebases]);
// Reset selection when project changes
useEffect(() => {
let cancelled = false;
setCodebases(null);
setListError(null);
setReason(undefined);
setSelectedFile(null);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10_000);
fetch(`/api/projects/${projectId}/codebases`, {
credentials: "include",
signal: controller.signal,
})
.then(async r => {
let body: CodebasesResponse | { error?: string } = {};
try {
body = await r.json();
} catch {
/* non-JSON body — fall through to status-only error */
}
if (!r.ok) {
const msg = (body as { error?: string }).error || `HTTP ${r.status} ${r.statusText}`.trim();
throw new Error(msg);
}
return body as CodebasesResponse;
})
.then(data => {
if (cancelled) return;
setCodebases(data.codebases ?? []);
setReason(data.reason);
if (data.codebases?.[0]) {
setExpanded(new Set([data.codebases[0].id]));
}
})
.catch(err => {
if (cancelled) return;
if (err?.name === "AbortError") {
setListError("Request timed out after 10s.");
} else {
setListError(err?.message || "Failed to load codebases");
}
})
.finally(() => {
clearTimeout(timeout);
});
return () => {
cancelled = true;
controller.abort();
clearTimeout(timeout);
};
setExpanded(new Set());
}, [projectId]);
const toggleCodebase = (id: string) => {
@@ -102,6 +48,9 @@ export default function ProductTab() {
});
};
const showLoading = loading && !codebases;
const showError = !!error;
return (
<div style={pageWrap}>
<div style={grid}>
@@ -109,14 +58,14 @@ export default function ProductTab() {
<section style={leftCol}>
<h3 style={heading}>Codebases</h3>
<div style={stack}>
{codebases === null && !listError && (
{showLoading && (
<Inline>
<Loader2 size={13} className="animate-spin" /> Loading
</Inline>
)}
{listError && (
{showError && (
<Inline>
<AlertCircle size={13} /> {listError}
<AlertCircle size={13} /> {error}
</Inline>
)}
{codebases && codebases.length === 0 && (

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 });
}
}

View File

@@ -0,0 +1,68 @@
"use client";
/**
* Lives in the project header. Shows the project's *real* stage
* derived from hosting reality, not the legacy `data.status` field
* (which historically lied).
*
* - any running production app → "Live" (green)
* - any failed production app → "Down" (red)
* - any service / preview URL → "Building" (blue)
* - else → fallbackStage from data.status
* (typically "Defining" or "Planning")
*/
import { useAnatomy } from "./use-anatomy";
interface ProjectStagePillProps {
projectId: string;
/** Stage value pulled from fs_projects.data.status — used only as
* a fallback if no live infra exists yet. */
fallbackStage: "discovery" | "architecture" | "building" | "active";
}
export function ProjectStagePill({ projectId, fallbackStage }: ProjectStagePillProps) {
const { anatomy, loading } = useAnatomy(projectId);
if (loading && !anatomy) return <Pill {...PRESETS[fallbackStage]} />;
const prod = anatomy?.hosting.production ?? [];
const services = anatomy?.hosting.services ?? [];
const previews = anatomy?.hosting.previewUrls ?? [];
const anyRunning = prod.some(p => /running|healthy/i.test(p.status));
const anyFailed = prod.some(p => /failed|exited|unhealthy/i.test(p.status));
const buildingNow = !anyRunning && (services.length > 0 || previews.length > 0);
if (anyFailed) return <Pill label="Down" color="#c5392b" bg="#c5392b14" />;
if (anyRunning) return <Pill label="Live" color="#2e7d32" bg="#2e7d3210" />;
if (buildingNow) return <Pill label="Building" color="#3d5afe" bg="#3d5afe10" />;
return <Pill {...PRESETS[fallbackStage]} />;
}
// ──────────────────────────────────────────────────
const PRESETS: Record<
"discovery" | "architecture" | "building" | "active",
{ label: string; color: string; bg: string }
> = {
discovery: { label: "Defining", color: "#9a7b3a", bg: "#d4a04a14" },
architecture: { label: "Planning", color: "#3d5afe", bg: "#3d5afe10" },
building: { label: "Building", color: "#3d5afe", bg: "#3d5afe10" },
active: { label: "Live", color: "#2e7d32", bg: "#2e7d3210" },
};
function Pill({ label, color, bg }: { label: string; color: string; bg: string }) {
return (
<span style={{
display: "inline-flex", alignItems: "center", gap: 6,
padding: "4px 10px", borderRadius: 4,
fontSize: "0.7rem", fontWeight: 600, letterSpacing: "0.02em",
color, background: bg, whiteSpace: "nowrap",
}}>
<span style={{ width: 7, height: 7, borderRadius: "50%", background: color }} />
{label}
</span>
);
}

View File

@@ -0,0 +1,98 @@
"use client";
/**
* Single-fetch anatomy hook shared by the Product / Infrastructure /
* Hosting tabs. Hardened against silent failure: 10s timeout, error
* surfacing, and graceful unmount.
*/
import { useEffect, useState } from "react";
export interface Anatomy {
project: { id: string; name: string; gitea?: string; coolifyProjectUuid?: string };
codebases: Array<{ id: string; label: string; path: string; hint?: string }>;
codebasesReason?: "no_repo" | "empty_repo";
hosting: {
production: Array<{
uuid: string;
name: string;
status: string;
fqdn?: string;
branch?: string;
buildPack?: string;
}>;
services: Array<{
uuid: string;
name: string;
serviceType?: string;
status?: string;
}>;
previewUrls: Array<{
id: string;
name: string;
port: number;
url: string;
state: string;
startedAt: string;
}>;
domains: Array<{ host: string; source: "production" | "preview" }>;
};
infrastructure: { placeholder: true };
}
export interface UseAnatomyResult {
anatomy: Anatomy | null;
loading: boolean;
error: string | null;
reload: () => void;
}
export function useAnatomy(projectId: string): UseAnatomyResult {
const [anatomy, setAnatomy] = useState<Anatomy | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [tick, setTick] = useState(0);
useEffect(() => {
let cancelled = false;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10_000);
setLoading(true);
setError(null);
fetch(`/api/projects/${projectId}/anatomy`, {
credentials: "include",
signal: controller.signal,
})
.then(async r => {
let body: unknown = {};
try { body = await r.json(); } catch { /* keep {} */ }
if (!r.ok) {
const msg = (body as { error?: string }).error || `HTTP ${r.status} ${r.statusText}`.trim();
throw new Error(msg);
}
return body as Anatomy;
})
.then(data => {
if (!cancelled) setAnatomy(data);
})
.catch(err => {
if (cancelled) return;
if (err?.name === "AbortError") setError("Request timed out after 10s.");
else setError(err?.message || "Failed to load project anatomy");
})
.finally(() => {
clearTimeout(timeout);
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
controller.abort();
clearTimeout(timeout);
};
}, [projectId, tick]);
return { anatomy, loading, error, reload: () => setTick(t => t + 1) };
}