"use client"; /** * Single-fetch anatomy hook shared by the Product / 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; }; codebasesReason?: "no_repo" | "empty_repo"; product: { codebases: Array<{ id: string; label: string; path: string; hint?: string; }>; images: Array<{ uuid: string; name: string; image: string; version: string; serviceType?: string; status?: string; }>; }; hosting: { live: Array<{ uuid: string; name: string; source: "repo" | "image"; sourceLabel: string; status: string; fqdn?: string; domains: string[]; branch?: string; buildPack?: string; lastBuild?: { status: string; finishedAt?: string; commit?: string }; inFlightBuild?: { status: string; finishedAt?: string; commit?: string }; }>; previews: Array<{ id: string; name: string; command?: string; port: number; url: string; state: string; startedAt: string; }>; }; infrastructure: { databases: Array<{ uuid: string; name: string; type: string; status: string; isPublic: boolean; publicPort?: number; internalAddress?: string; consumerEnvKey: string; }>; providers: Array<{ id: string; category: "auth" | "email" | "payments" | "llm" | "storage"; vendor: string; attachments: Array<{ resourceUuid: string; resourceName: string; resourceKind: "app" | "service"; keys: string[]; }>; }>; bundledStorage: { status: "ready" | "pending" | "partial" | "error" | "unprovisioned"; bucketName?: string; hmacAccessId?: string; region?: string; errorMessage?: string; }; secrets: { total: number; byResource: Array<{ resourceUuid: string; resourceName: string; resourceKind: "app" | "service"; count: number; keys: string[]; }>; }; }; } export interface UseAnatomyResult { anatomy: Anatomy | null; loading: boolean; error: string | null; reload: () => void; } export interface UseAnatomyOptions { /** When set, re-fetch anatomy every N ms while the component is * mounted. Used by the project-header status pill so it surfaces * Coolify build state transitions live (e.g. queued → in_progress * → success) without the user having to refresh. Pass undefined or * 0 to disable polling. */ pollMs?: number; } export function useAnatomy( projectId: string, options: UseAnatomyOptions = {}, ): UseAnatomyResult { const { pollMs } = options; const [anatomy, setAnatomy] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [tick, setTick] = useState(0); // Background poll. We bump `tick` on an interval, which re-runs the // fetch effect below. Skipping the timer entirely when pollMs is // zero/undefined keeps the default render path identical to before. useEffect(() => { if (!pollMs || pollMs <= 0) return; const id = setInterval(() => setTick((t) => t + 1), pollMs); return () => clearInterval(id); }, [pollMs]); 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, cache: "no-store", }) .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) }; }