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
99 lines
2.7 KiB
TypeScript
99 lines
2.7 KiB
TypeScript
"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) };
|
|
}
|