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:
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