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:
@@ -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" };
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
68
components/project/project-stage-pill.tsx
Normal file
68
components/project/project-stage-pill.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
98
components/project/use-anatomy.ts
Normal file
98
components/project/use-anatomy.ts
Normal 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) };
|
||||
}
|
||||
Reference in New Issue
Block a user