Anatomy + UI rewrite — locked the conceptual model after user feedback:
Product = "what makes up the thing you're shipping":
- Codebases (Gitea repos)
- Images (Coolify services backed by upstream Docker images: Twenty
CRM, n8n, etc.)
- Dev containers no longer surface here. The vibn-dev-* container is
the AI's workshop, not a product surface; previews it serves still
appear under Hosting → Previews.
Hosting = "where it lives + how it gets there", unified:
- Live: every running endpoint as one list. Each item carries a
source badge ("repo" | "image"), status dot, attached domain, and
last-build summary inline. No separate Build, Domains or Services
categories — those are properties on each Live item.
- Previews: dev container preview URLs (unchanged).
Anatomy endpoint reshaped accordingly:
- product.{codebases, images}
- hosting.{live, previews} (was production/services/previewUrls/domains)
- lastBuild summary fetched per repo-app via listApplicationDeployments
in parallel.
ProjectStagePill rewired to derive Live/Down/Building from hosting.live
+ hosting.previews. dev-container-detail.tsx removed.
services.* MCP tools added so AI agents can manage Coolify services
(Twenty CRM, n8n, …) the same way they manage apps:
- services.list, services.get
- services.start, services.stop
- services.envs.list, services.envs.upsert
All tenant-scoped via getServiceInWorkspace + getOwnedCoolifyProjectUuids.
vibn-dev-* containers stay hidden from services.list.
Made-with: Cursor
106 lines
2.8 KiB
TypeScript
106 lines
2.8 KiB
TypeScript
"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 };
|
|
}>;
|
|
previews: Array<{
|
|
id: string;
|
|
name: string;
|
|
port: number;
|
|
url: string;
|
|
state: string;
|
|
startedAt: string;
|
|
}>;
|
|
};
|
|
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) };
|
|
}
|