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