"use client"; /** * Single-fetch anatomy hook shared by the Product / Hosting tabs. * Hardened against silent failure: 10s timeout, error surfacing, and * graceful unmount. * * Uses SWR to deduplicate requests across components (ProjectStagePill, * ProjectHeaderUrls, Hosting page) so they share a single network request * instead of hammering the API independently. */ import useSWR from "swr"; import { useCallback } 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; } const fetcher = async (url: string) => { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 10_000); try { const res = await fetch(url, { credentials: "include", signal: controller.signal, cache: "no-store", }); let body: unknown = {}; try { body = await res.json(); } catch { /* keep {} */ } if (!res.ok) { const msg = (body as { error?: string }).error || `HTTP ${res.status} ${res.statusText}`.trim(); throw new Error(msg); } return body as Anatomy; } catch (err: any) { if (err.name === "AbortError") { throw new Error("Request timed out after 10s."); } throw err; } finally { clearTimeout(timeout); } }; export function useAnatomy( projectId: string, options: UseAnatomyOptions = {}, ): UseAnatomyResult { const pollMs = options.pollMs && options.pollMs > 0 ? options.pollMs : 0; const { data, error, isLoading, mutate } = useSWR( projectId ? `/api/projects/${projectId}/anatomy` : null, fetcher, { refreshInterval: pollMs, dedupingInterval: 5000, revalidateOnFocus: false, revalidateOnReconnect: false, }, ); const reload = useCallback(() => { mutate(); }, [mutate]); return { anatomy: data ?? null, loading: isLoading, error: error?.message ?? null, reload, }; }