- {selection?.type === "devContainer" && devContainer && (
-
- )}
+
{paneHeading(selection)}
+
{selection?.type === "file" && (
)}
+ {selection?.type === "image" && anatomy && (
+
+ )}
{!selection && (
- Pick a codebase file or the dev container on the left.
+ Pick a codebase file or an image on the left.
)}
@@ -177,24 +182,68 @@ export default function ProductTab() {
);
}
+// ──────────────────────────────────────────────────
+// Image details (right pane)
// ──────────────────────────────────────────────────
-function previewHeading(s: Selection): string {
- if (!s) return "Preview";
- if (s.type === "devContainer") return "Preview · Dev container";
- return `Preview · ${shortPath(s.path)}`;
+function ImageDetail({ uuid, anatomy }: { uuid: string; anatomy: Anatomy }) {
+ const img = anatomy.product.images.find(i => i.uuid === uuid);
+ if (!img) return
This image is no longer in the project.;
+ const live = anatomy.hosting.live.find(l => l.uuid === uuid);
+
+ return (
+
+
+
+
+
+ {live?.fqdn && (
+
+ )}
+
+ );
}
-function shortPath(p: string) {
- const parts = p.split("/");
- if (parts.length <= 2) return p;
- return ".../" + parts.slice(-2).join("/");
+
+// ──────────────────────────────────────────────────
+// Bits
+// ──────────────────────────────────────────────────
+
+function RailGroup({
+ title, count, children,
+}: { title: string; count: number; children: React.ReactNode }) {
+ return (
+
+ );
}
-function colorForStatus(s?: string) {
- if (!s) return "#a09a90";
- if (/running|healthy/i.test(s)) return "#2e7d32";
- if (/starting|deploying/i.test(s)) return "#d4a04a";
- if (/exit|fail|unhealthy/i.test(s)) return "#c5392b";
- return "#a09a90";
+
+function RailEmpty({ children }: { children: React.ReactNode }) {
+ return
{children}
;
+}
+
+function DetailRow({
+ label, value, dot, href,
+}: { label: string; value: string; dot?: string; href?: string }) {
+ return (
+
+
{label}
+
+ {dot && }
+ {href ? (
+ {value}
+ ) : value}
+
+
+ );
}
function Inline({ children }: { children: React.ReactNode }) {
@@ -222,6 +271,28 @@ function Empty({ children }: { children: React.ReactNode }) {
// ──────────────────────────────────────────────────
+function paneHeading(s: Selection): string {
+ if (!s) return "Preview";
+ if (s.type === "file") return `Preview · ${shortPath(s.path)}`;
+ return "Image";
+}
+function shortPath(p: string) {
+ const parts = p.split("/");
+ if (parts.length <= 2) return p;
+ return ".../" + parts.slice(-2).join("/");
+}
+function statusColor(status: string) {
+ const s = status.toLowerCase();
+ if (s.includes("running") || s.includes("healthy")) return "#2e7d32";
+ if (s.includes("starting") || s.includes("deploying")) return "#d4a04a";
+ if (s.includes("exit") || s.includes("fail") || s.includes("unhealthy")) return "#c5392b";
+ return "#a09a90";
+}
+
+// ──────────────────────────────────────────────────
+// Tokens
+// ──────────────────────────────────────────────────
+
const INK = {
ink: "#1a1a1a",
mid: "#5f5e5a",
@@ -246,7 +317,7 @@ const grid: React.CSSProperties = {
alignItems: "stretch",
};
const leftCol: React.CSSProperties = {
- minWidth: 0, display: "flex", flexDirection: "column",
+ minWidth: 0, display: "flex", flexDirection: "column", gap: 18,
};
const rightCol: React.CSSProperties = {
minWidth: 0, display: "flex", flexDirection: "column",
@@ -255,8 +326,24 @@ const heading: React.CSSProperties = {
fontSize: "0.72rem", fontWeight: 600, letterSpacing: "0.12em",
textTransform: "uppercase", color: INK.muted, margin: "0 0 14px",
};
-const stack: React.CSSProperties = {
- display: "flex", flexDirection: "column", gap: 10,
+const railGroup: React.CSSProperties = { display: "flex", flexDirection: "column" };
+const railGroupHeader: React.CSSProperties = {
+ display: "flex", alignItems: "center", justifyContent: "space-between",
+ padding: "0 4px 8px",
+};
+const railGroupTitle: React.CSSProperties = {
+ fontSize: "0.68rem", fontWeight: 600, letterSpacing: "0.12em",
+ textTransform: "uppercase", color: INK.muted,
+};
+const countPill: React.CSSProperties = {
+ fontSize: "0.7rem", fontWeight: 600, color: INK.mid,
+ padding: "1px 7px", borderRadius: 999, background: "#f3eee4",
+};
+const railItems: React.CSSProperties = { display: "flex", flexDirection: "column", gap: 10 };
+const railEmpty: React.CSSProperties = {
+ padding: "10px 12px", fontSize: "0.74rem", color: INK.muted,
+ fontStyle: "italic", border: `1px dashed ${INK.borderSoft}`, borderRadius: 8,
+ lineHeight: 1.4,
};
const flatTile: React.CSSProperties = {
display: "flex", alignItems: "center", gap: 10,
@@ -276,16 +363,28 @@ const tileHeader: React.CSSProperties = {
const tileLabel: React.CSSProperties = {
fontSize: "0.85rem", fontWeight: 600, color: INK.ink, marginBottom: 2,
};
-const tileHint: React.CSSProperties = {
- fontSize: "0.74rem", color: INK.mid, lineHeight: 1.4,
-};
+const tileHint: React.CSSProperties = { fontSize: "0.74rem", color: INK.mid, lineHeight: 1.4 };
const tileBody: React.CSSProperties = {
padding: "8px 10px 12px", borderTop: `1px solid ${INK.borderSoft}`,
};
const chevronCell: React.CSSProperties = {
width: 14, display: "inline-flex", alignItems: "center", justifyContent: "center", flexShrink: 0,
};
-const previewPanel: React.CSSProperties = {
+const panel: React.CSSProperties = {
background: INK.cardBg, border: `1px solid ${INK.border}`, borderRadius: 10,
padding: 16, flex: 1, minHeight: 0, display: "flex", flexDirection: "column",
};
+const detailRow: React.CSSProperties = {
+ display: "flex", alignItems: "center", justifyContent: "space-between",
+ padding: "12px 4px", borderBottom: `1px solid ${INK.borderSoft}`,
+};
+const detailLabel: React.CSSProperties = {
+ fontSize: "0.72rem", fontWeight: 600, letterSpacing: "0.06em",
+ textTransform: "uppercase", color: INK.muted,
+};
+const detailValue: React.CSSProperties = {
+ fontSize: "0.85rem", color: INK.ink, display: "inline-flex", alignItems: "center",
+};
+const detailLink: React.CSSProperties = {
+ color: INK.ink, textDecoration: "underline",
+};
diff --git a/app/api/mcp/route.ts b/app/api/mcp/route.ts
index 8200daa1..f5e3f619 100644
--- a/app/api/mcp/route.ts
+++ b/app/api/mcp/route.ts
@@ -79,6 +79,7 @@ import {
createDockerImageApp,
createDockerComposeApp,
startService,
+ stopService,
getService,
listAllServices,
listServiceEnvs,
@@ -392,6 +393,19 @@ export async function POST(request: Request) {
case 'ship':
return await toolShip(principal, params);
+ case 'services.list':
+ return await toolServicesList(principal, params);
+ case 'services.get':
+ return await toolServicesGet(principal, params);
+ case 'services.start':
+ return await toolServicesStart(principal, params);
+ case 'services.stop':
+ return await toolServicesStop(principal, params);
+ case 'services.envs.list':
+ return await toolServicesEnvsList(principal, params);
+ case 'services.envs.upsert':
+ return await toolServicesEnvsUpsert(principal, params);
+
default:
return NextResponse.json(
{ error: `Unknown tool "${action}"` },
@@ -1052,6 +1066,121 @@ async function toolAppsEnvsDelete(principal: Principal, params: Record
= {}) {
+ const projectUuid = requireCoolifyProject(principal);
+ if (projectUuid instanceof NextResponse) return projectUuid;
+
+ // Mirror apps.list scoping: optional `projectId` narrows to a single
+ // Vibn project's Coolify env; otherwise scan everything we own.
+ const ownedUuids = await getOwnedCoolifyProjectUuids(principal.workspace);
+ let target: string[];
+ if (params.projectId) {
+ const pUuid = await getProjectCoolifyUuid(String(params.projectId), principal.workspace);
+ if (!pUuid) return NextResponse.json({ error: 'Project not found in this workspace' }, { status: 404 });
+ target = [pUuid];
+ } else {
+ target = Array.from(ownedUuids);
+ if (target.length === 0 && principal.workspace.coolify_project_uuid) {
+ target = [principal.workspace.coolify_project_uuid];
+ }
+ }
+ if (target.length === 0) return NextResponse.json({ result: [] });
+
+ const results = await Promise.allSettled(target.map(uuid => listServicesInProject(uuid)));
+ const services = results.flatMap((r, i) =>
+ r.status === 'fulfilled'
+ ? r.value
+ // Hide vibn-dev-* dev containers from this surface — those are
+ // the AI's own workshop, not part of the user's product.
+ .filter(s => !s.name.startsWith('vibn-dev-'))
+ .map(s => ({
+ uuid: s.uuid,
+ name: s.name,
+ status: s.status ?? 'unknown',
+ serviceType: s.service_type ?? null,
+ coolifyProjectUuid: target[i],
+ }))
+ : []
+ );
+ return NextResponse.json({ result: services });
+}
+
+async function toolServicesGet(principal: Principal, params: Record) {
+ const projectUuid = requireCoolifyProject(principal);
+ if (projectUuid instanceof NextResponse) return projectUuid;
+ const uuid = String(params.uuid ?? '').trim();
+ if (!uuid) return NextResponse.json({ error: 'Param "uuid" is required' }, { status: 400 });
+
+ const ownedUuids = await getOwnedCoolifyProjectUuids(principal.workspace);
+ const svc = await getServiceInWorkspace(uuid, ownedUuids);
+ return NextResponse.json({ result: svc });
+}
+
+async function toolServicesStart(principal: Principal, params: Record) {
+ const projectUuid = requireCoolifyProject(principal);
+ if (projectUuid instanceof NextResponse) return projectUuid;
+ const uuid = String(params.uuid ?? '').trim();
+ if (!uuid) return NextResponse.json({ error: 'Param "uuid" is required' }, { status: 400 });
+
+ const ownedUuids = await getOwnedCoolifyProjectUuids(principal.workspace);
+ await getServiceInWorkspace(uuid, ownedUuids);
+ await startService(uuid);
+ return NextResponse.json({ result: { ok: true, uuid, action: 'start' } });
+}
+
+async function toolServicesStop(principal: Principal, params: Record) {
+ const projectUuid = requireCoolifyProject(principal);
+ if (projectUuid instanceof NextResponse) return projectUuid;
+ const uuid = String(params.uuid ?? '').trim();
+ if (!uuid) return NextResponse.json({ error: 'Param "uuid" is required' }, { status: 400 });
+
+ const ownedUuids = await getOwnedCoolifyProjectUuids(principal.workspace);
+ await getServiceInWorkspace(uuid, ownedUuids);
+ await stopService(uuid);
+ return NextResponse.json({ result: { ok: true, uuid, action: 'stop' } });
+}
+
+async function toolServicesEnvsList(principal: Principal, params: Record) {
+ const projectUuid = requireCoolifyProject(principal);
+ if (projectUuid instanceof NextResponse) return projectUuid;
+ const uuid = String(params.uuid ?? '').trim();
+ if (!uuid) return NextResponse.json({ error: 'Param "uuid" is required' }, { status: 400 });
+
+ const ownedUuids = await getOwnedCoolifyProjectUuids(principal.workspace);
+ await getServiceInWorkspace(uuid, ownedUuids);
+ const envs = await listServiceEnvs(uuid);
+ return NextResponse.json({ result: envs });
+}
+
+async function toolServicesEnvsUpsert(principal: Principal, params: Record) {
+ const projectUuid = requireCoolifyProject(principal);
+ if (projectUuid instanceof NextResponse) return projectUuid;
+ const uuid = String(params.uuid ?? '').trim();
+ const key = typeof params.key === 'string' ? params.key : '';
+ const value = typeof params.value === 'string' ? params.value : '';
+ if (!uuid || !key) {
+ return NextResponse.json({ error: 'Params "uuid" and "key" are required' }, { status: 400 });
+ }
+ const ownedUuids = await getOwnedCoolifyProjectUuids(principal.workspace);
+ await getServiceInWorkspace(uuid, ownedUuids);
+ const result = await upsertServiceEnv(uuid, {
+ key,
+ value,
+ is_preview: !!params.is_preview,
+ is_literal: !!params.is_literal,
+ });
+ return NextResponse.json({ result });
+}
+
// ──────────────────────────────────────────────────
// Phase 4: apps create/update/delete + domains
// ──────────────────────────────────────────────────
diff --git a/app/api/projects/[projectId]/anatomy/route.ts b/app/api/projects/[projectId]/anatomy/route.ts
index e828a1fb..697d4e64 100644
--- a/app/api/projects/[projectId]/anatomy/route.ts
+++ b/app/api/projects/[projectId]/anatomy/route.ts
@@ -1,13 +1,26 @@
/**
* GET /api/projects/[projectId]/anatomy
*
- * Returns the full anatomy of a project across the three tabs:
- * - codebases: discovered from Gitea (apps/* or repo root)
- * - hosting: production apps + dev services + preview URLs + domains
- * - infrastructure: TODO (returns placeholder shape for now)
+ * Single-fetch shape consumed by the Product / Hosting / Infrastructure
+ * tabs. Keeping it one endpoint keeps page transitions cheap and avoids
+ * fan-out.
*
- * Single endpoint per page so the UI doesn't fan out 3+ requests on
- * every navigation. Each tab consumes its own slice.
+ * Conceptual model (locked Apr 28 2026):
+ * Product = "what makes up the thing you're shipping"
+ * → codebases (Gitea repos) + images (Coolify services
+ * backed by an upstream Docker image, e.g. Twenty CRM,
+ * n8n). Both are first-class product surfaces.
+ * → vibn-dev-* containers are NOT shown — the dev
+ * container is the AI's workshop, not the product.
+ *
+ * Hosting = "where does it live and how does it get there"
+ * → unified `live` list of running endpoints (each item
+ * carries source = "repo" | "image", attached domains,
+ * and last build/deploy status inline) + `previews`
+ * (dev container preview URLs).
+ * → no separate Build, Domains, or Services categories.
+ *
+ * Infrastructure = TODO (placeholder).
*/
import { NextResponse } from "next/server";
@@ -15,6 +28,7 @@ import { authSession } from "@/lib/auth/session-server";
import { query } from "@/lib/db-postgres";
import {
listApplications,
+ listApplicationDeployments,
listServicesInProject,
type CoolifyApplication,
type CoolifyService,
@@ -34,30 +48,43 @@ interface Codebase {
hint?: string;
}
-interface ProductionApp {
+interface ProductImage {
uuid: string;
name: string;
+ /** "twentycrm/twenty" */
+ image: string;
+ /** "v1.15" — empty string when not pinned */
+ version: string;
+ serviceType?: string;
+ /** Coolify service status, surfaced so the Product tile can show a dot */
+ status?: string;
+}
+
+interface BuildSummary {
status: string;
+ finishedAt?: string;
+ commit?: string;
+}
+
+interface LiveEndpoint {
+ uuid: string;
+ name: string;
+ /** repo = built from Gitea, image = pulled docker image (Coolify service) */
+ source: "repo" | "image";
+ /** "apps/web" or "twentycrm/twenty:v1.15" */
+ sourceLabel: string;
+ status: string;
+ /** primary host (no scheme) when one exists */
fqdn?: string;
+ /** all attached hosts */
+ domains: string[];
branch?: string;
buildPack?: string;
+ /** Last finished deployment, only for source = "repo" */
+ lastBuild?: BuildSummary;
}
-interface DevService {
- uuid: string;
- name: string;
- serviceType?: string;
- status?: string;
-}
-
-/** Dev container = the vibn-dev-* Coolify service this project edits in. */
-interface DevContainer {
- uuid: string;
- name: string;
- status?: string;
-}
-
-interface PreviewUrl {
+interface Preview {
id: string;
name: string;
port: number;
@@ -66,32 +93,24 @@ interface PreviewUrl {
startedAt: string;
}
-interface Domain {
- host: string;
- source: "production" | "preview";
-}
-
interface Anatomy {
project: { id: string; name: string; gitea?: string; coolifyProjectUuid?: string };
- codebases: Codebase[];
codebasesReason?: "no_repo" | "empty_repo";
product: {
- devContainers: DevContainer[];
+ codebases: Codebase[];
+ images: ProductImage[];
};
hosting: {
- production: ProductionApp[];
- services: DevService[];
- previewUrls: PreviewUrl[];
- domains: Domain[];
+ live: LiveEndpoint[];
+ previews: Preview[];
};
infrastructure: {
- /** TODO Phase 4 — see PROJECT_PAGE_ARCHITECTURE.md for the design call. */
placeholder: true;
};
}
// ──────────────────────────────────────────────────
-// Gitea
+// Gitea (codebase discovery)
// ──────────────────────────────────────────────────
interface GiteaItem {
@@ -150,20 +169,17 @@ async function discoverCodebases(giteaRepo: string): Promise<{
}
// ──────────────────────────────────────────────────
-// Hosting — Coolify + fs_dev_servers
+// Coolify helpers
// ──────────────────────────────────────────────────
-/** Strip credentials + .git suffix and normalise to lowercase */
function normaliseRepoUrl(url: string | undefined): string {
if (!url) return "";
let u = url.toLowerCase();
- // Remove user:pass@ if present
u = u.replace(/^https?:\/\/[^/@]*@/, "https://");
u = u.replace(/\.git$/, "");
return u;
}
-/** Returns the canonical short form: "owner/repo" */
function shortFormOfRepo(url: string | undefined): string {
if (!url) return "";
const cleaned = normaliseRepoUrl(url).replace(/^https?:\/\/[^/]+\//, "");
@@ -174,33 +190,21 @@ function appMatchesRepo(app: CoolifyApplication, giteaRepo: string): boolean {
const target = giteaRepo.toLowerCase();
const appShort = shortFormOfRepo(app.git_repository);
if (appShort && appShort === target) return true;
- // Also match if either side contains the other (loose fallback for legacy data)
return Boolean(app.git_repository && app.git_repository.toLowerCase().includes(target));
}
-async function loadProductionApps(giteaRepo: string | undefined): Promise {
+async function loadRepoApps(giteaRepo: string | undefined): Promise {
if (!giteaRepo) return [];
try {
const all = await listApplications();
- return all
- .filter(app => appMatchesRepo(app, giteaRepo))
- .map(app => ({
- uuid: app.uuid,
- name: app.name,
- status: app.status,
- fqdn: app.fqdn,
- branch: app.git_branch,
- buildPack: app.build_pack,
- }));
+ return all.filter(app => appMatchesRepo(app, giteaRepo));
} catch (err) {
console.error("[anatomy] listApplications failed:", err);
return [];
}
}
-/** Returns ALL services in the Coolify project. Caller splits dev
- * containers from deployed services by name prefix. */
-async function loadAllServices(coolifyProjectUuid: string | undefined): Promise {
+async function loadProjectServices(coolifyProjectUuid: string | undefined): Promise {
if (!coolifyProjectUuid) return [];
try {
return await listServicesInProject(coolifyProjectUuid);
@@ -210,11 +214,48 @@ async function loadAllServices(coolifyProjectUuid: string | undefined): Promise<
}
}
-function isDevContainer(svc: CoolifyService): boolean {
- return svc.name.startsWith("vibn-dev-");
+const isDevContainer = (svc: CoolifyService) => svc.name.startsWith("vibn-dev-");
+
+/** Extract image:version from a Coolify docker_compose_raw blob.
+ * Best-effort regex; we only want a sensible label, not perfection. */
+function extractImageInfo(svc: CoolifyService): { image: string; version: string } {
+ const raw = (svc as unknown as { docker_compose_raw?: string }).docker_compose_raw ?? "";
+ const m = raw.match(/image:\s*['"]?([^\s'"\n]+)['"]?/);
+ if (!m) return { image: svc.service_type ?? svc.name, version: "" };
+ const full = m[1];
+ const at = full.lastIndexOf(":");
+ if (at <= 0 || full.slice(at).includes("/")) {
+ return { image: full, version: "" };
+ }
+ return { image: full.slice(0, at), version: full.slice(at + 1) };
}
-async function loadPreviewUrls(projectId: string): Promise {
+function fqdnsOf(value: string | undefined): string[] {
+ if (!value) return [];
+ return value
+ .split(",")
+ .map(s => s.trim().replace(/^https?:\/\//, "").replace(/\/$/, ""))
+ .filter(Boolean);
+}
+
+async function lastBuildFor(uuid: string): Promise {
+ try {
+ const deployments = await listApplicationDeployments(uuid);
+ if (!deployments.length) return undefined;
+ // Prefer the most recently finished; fall back to first.
+ const finished = deployments.find(d => d.finished_at) ?? deployments[0];
+ return {
+ status: finished.status,
+ finishedAt: finished.finished_at,
+ commit: finished.commit,
+ };
+ } catch (err) {
+ console.error(`[anatomy] listApplicationDeployments(${uuid}) failed:`, err);
+ return undefined;
+ }
+}
+
+async function loadPreviews(projectId: string): Promise {
try {
const rows = await query<{
id: string;
@@ -239,7 +280,6 @@ async function loadPreviewUrls(projectId: string): Promise {
startedAt: r.started_at,
}));
} catch (err) {
- // fs_dev_servers may not exist yet on older deployments — treat as empty
if (err instanceof Error && /relation "fs_dev_servers" does not exist/i.test(err.message)) {
return [];
}
@@ -248,25 +288,6 @@ async function loadPreviewUrls(projectId: string): Promise {
}
}
-function dedupeDomains(prod: ProductionApp[], previews: PreviewUrl[]): Domain[] {
- const map = new Map();
- for (const app of prod) {
- if (!app.fqdn) continue;
- // fqdn can be a comma-separated list
- for (const raw of app.fqdn.split(",")) {
- const host = raw.trim().replace(/^https?:\/\//, "").replace(/\/$/, "");
- if (host && !map.has(host)) map.set(host, { host, source: "production" });
- }
- }
- for (const p of previews) {
- try {
- const host = new URL(p.url).host;
- if (host && !map.has(host)) map.set(host, { host, source: "preview" });
- } catch { /* malformed URL, skip */ }
- }
- return [...map.values()];
-}
-
// ──────────────────────────────────────────────────
// Handler
// ──────────────────────────────────────────────────
@@ -300,55 +321,80 @@ export async function GET(
(data?.name as string | undefined) ??
"Project";
- // Run the slow bits in parallel
- const [codebasesResult, production, allServices, previews] = await Promise.all([
+ const [codebasesResult, repoApps, allServices, previews] = await Promise.all([
giteaRepo
? discoverCodebases(giteaRepo).catch(err => {
console.error("[anatomy] discoverCodebases failed:", err);
return { codebases: [] as Codebase[], reason: "empty_repo" as const };
})
: Promise.resolve({ codebases: [] as Codebase[], reason: undefined as undefined }),
- loadProductionApps(giteaRepo),
- loadAllServices(coolifyProjectUuid),
- loadPreviewUrls(projectId),
+ loadRepoApps(giteaRepo),
+ loadProjectServices(coolifyProjectUuid),
+ loadPreviews(projectId),
]);
- // Split services: vibn-dev-* belong to Product (the dev workbench).
- // Everything else is a deployed service that belongs in Hosting.
- const devContainers: DevContainer[] = [];
- const deployedServices: DevService[] = [];
- for (const s of allServices) {
- if (isDevContainer(s)) {
- devContainers.push({ uuid: s.uuid, name: s.name, status: s.status });
- } else {
- deployedServices.push({
- uuid: s.uuid,
- name: s.name,
- serviceType: s.service_type,
- status: s.status,
- });
- }
- }
+ // Pull last-build summaries for repo apps in parallel (small N).
+ const builds = await Promise.all(repoApps.map(a => lastBuildFor(a.uuid)));
+
+ // Image services (Coolify services minus vibn-dev-*)
+ const imageServices = allServices.filter(s => !isDevContainer(s));
+
+ const productImages: ProductImage[] = imageServices.map(s => {
+ const { image, version } = extractImageInfo(s);
+ return {
+ uuid: s.uuid,
+ name: s.name,
+ image,
+ version,
+ serviceType: s.service_type,
+ status: s.status,
+ };
+ });
+
+ const liveFromRepo: LiveEndpoint[] = repoApps.map((app, i) => {
+ const domains = fqdnsOf(app.fqdn);
+ return {
+ uuid: app.uuid,
+ name: app.name,
+ source: "repo",
+ sourceLabel: shortFormOfRepo(app.git_repository) || (giteaRepo ?? "repo"),
+ status: app.status,
+ fqdn: domains[0],
+ domains,
+ branch: app.git_branch,
+ buildPack: app.build_pack,
+ lastBuild: builds[i],
+ };
+ });
+
+ const liveFromImage: LiveEndpoint[] = imageServices.map(s => {
+ const domains = fqdnsOf((s as unknown as { fqdn?: string }).fqdn);
+ const { image, version } = extractImageInfo(s);
+ return {
+ uuid: s.uuid,
+ name: s.name,
+ source: "image",
+ sourceLabel: version ? `${image}:${version}` : image,
+ status: s.status ?? "unknown",
+ fqdn: domains[0],
+ domains,
+ };
+ });
const codebasesReason: "no_repo" | "empty_repo" | undefined = !giteaRepo
? "no_repo"
: codebasesResult.reason;
const anatomy: Anatomy = {
- project: {
- id: projectId,
- name: projectName,
- gitea: giteaRepo,
- coolifyProjectUuid,
- },
- codebases: codebasesResult.codebases,
+ project: { id: projectId, name: projectName, gitea: giteaRepo, coolifyProjectUuid },
codebasesReason,
- product: { devContainers },
+ product: {
+ codebases: codebasesResult.codebases,
+ images: productImages,
+ },
hosting: {
- production,
- services: deployedServices,
- previewUrls: previews,
- domains: dedupeDomains(production, previews),
+ live: [...liveFromRepo, ...liveFromImage],
+ previews,
},
infrastructure: { placeholder: true },
};
diff --git a/components/project/dev-container-detail.tsx b/components/project/dev-container-detail.tsx
deleted file mode 100644
index 5c2d77c0..00000000
--- a/components/project/dev-container-detail.tsx
+++ /dev/null
@@ -1,187 +0,0 @@
-"use client";
-
-/**
- * Right-panel detail view for a vibn-dev container.
- * Today: shows status, dev servers running inside it, and active
- * preview URLs. Future: tail container logs, restart button.
- */
-
-import { Server, ExternalLink, CircleDot, Zap } from "lucide-react";
-import type { Anatomy } from "./use-anatomy";
-
-interface DevContainerDetailProps {
- container: Anatomy["product"]["devContainers"][number];
- previewUrls: Anatomy["hosting"]["previewUrls"];
-}
-
-export function DevContainerDetail({ container, previewUrls }: DevContainerDetailProps) {
- const statusColor = colorForStatus(container.status);
-
- return (
-
-
-
- {container.name}
-
-
- {container.status ?? "unknown"}
-
-
-
-
- {previewUrls.length === 0 ? (
-
- ) : (
- previewUrls.map(p => (
-
- ))
- )}
-
-
- );
-}
-
-// ──────────────────────────────────────────────────
-
-function Section({ title, children }: { title: string; children: React.ReactNode }) {
- return (
-
- );
-}
-
-function Row({
- icon: Icon, title, subtitle, href, hrefLabel,
-}: {
- icon: React.ComponentType<{ size?: number; style?: React.CSSProperties }>;
- title: string;
- subtitle?: string;
- href?: string;
- hrefLabel?: string;
-}) {
- return (
-
- );
-}
-
-function Empty({ message, hint }: { message: string; hint?: string }) {
- return (
-
-
{message}
- {hint &&
{hint}
}
-
- );
-}
-
-function colorForStatus(s?: string) {
- if (!s) return "#a09a90";
- if (/running|healthy/i.test(s)) return "#2e7d32";
- if (/starting|deploying/i.test(s)) return "#d4a04a";
- if (/exit|fail|unhealthy/i.test(s)) return "#c5392b";
- return "#a09a90";
-}
-
-function hostOf(url: string) {
- try { return new URL(url).host; } catch { return url; }
-}
-
-const INK = {
- ink: "#1a1a1a",
- mid: "#5f5e5a",
- muted: "#a09a90",
- border: "#e8e4dc",
- borderSoft: "#efebe1",
-} as const;
-
-const wrap: React.CSSProperties = {
- flex: 1,
- minHeight: 0,
- display: "flex",
- flexDirection: "column",
- gap: 14,
- margin: "-4px -4px",
-};
-const statusRow: React.CSSProperties = {
- display: "flex",
- alignItems: "center",
- gap: 10,
- padding: "12px 14px",
- border: `1px solid ${INK.borderSoft}`,
- borderRadius: 8,
-};
-const statusPill: React.CSSProperties = {
- display: "inline-flex",
- alignItems: "center",
- gap: 5,
- flexShrink: 0,
-};
-const sectionWrap: React.CSSProperties = {
- border: `1px solid ${INK.borderSoft}`,
- borderRadius: 8,
- overflow: "hidden",
-};
-const sectionHeader: React.CSSProperties = {
- padding: "10px 14px",
- fontSize: "0.72rem",
- fontWeight: 600,
- letterSpacing: "0.06em",
- textTransform: "uppercase",
- color: INK.mid,
- borderBottom: `1px solid ${INK.borderSoft}`,
-};
-const rowStyle: React.CSSProperties = {
- display: "flex",
- alignItems: "center",
- gap: 10,
- padding: "10px 14px",
- borderBottom: `1px solid ${INK.borderSoft}`,
-};
-const openLink: React.CSSProperties = {
- display: "inline-flex",
- alignItems: "center",
- gap: 5,
- fontSize: "0.76rem",
- color: INK.mid,
- textDecoration: "none",
- border: `1px solid ${INK.borderSoft}`,
- borderRadius: 6,
- padding: "3px 8px",
- flexShrink: 0,
-};
-const emptyWrap: React.CSSProperties = {
- padding: "16px 14px",
- textAlign: "center",
-};
-const emptyMsg: React.CSSProperties = {
- fontSize: "0.82rem",
- color: INK.mid,
- marginBottom: 4,
-};
-const emptyHint: React.CSSProperties = {
- fontSize: "0.74rem",
- color: INK.muted,
- fontStyle: "italic",
-};
diff --git a/components/project/project-stage-pill.tsx b/components/project/project-stage-pill.tsx
index 7e9d8d6d..410994c5 100644
--- a/components/project/project-stage-pill.tsx
+++ b/components/project/project-stage-pill.tsx
@@ -26,13 +26,12 @@ export function ProjectStagePill({ projectId, fallbackStage }: ProjectStagePillP
if (loading && !anatomy) return ;
- const prod = anatomy?.hosting.production ?? [];
- const services = anatomy?.hosting.services ?? [];
- const previews = anatomy?.hosting.previewUrls ?? [];
+ const live = anatomy?.hosting.live ?? [];
+ const previews = anatomy?.hosting.previews ?? [];
- 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);
+ const anyRunning = live.some(l => /running|healthy/i.test(l.status));
+ const anyFailed = live.some(l => /failed|exited|unhealthy/i.test(l.status));
+ const buildingNow = !anyRunning && (live.length > 0 || previews.length > 0);
if (anyFailed) return ;
if (anyRunning) return ;
diff --git a/components/project/use-anatomy.ts b/components/project/use-anatomy.ts
index b2d3ba7c..0a7a7a69 100644
--- a/components/project/use-anatomy.ts
+++ b/components/project/use-anatomy.ts
@@ -1,36 +1,41 @@
"use client";
/**
- * Single-fetch anatomy hook shared by the Product / Infrastructure /
- * Hosting tabs. Hardened against silent failure: 10s timeout, error
- * surfacing, and graceful unmount.
+ * 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 };
- codebases: Array<{ id: string; label: string; path: string; hint?: string }>;
codebasesReason?: "no_repo" | "empty_repo";
product: {
- devContainers: Array<{ uuid: string; name: string; status?: string }>;
- };
- hosting: {
- production: Array<{
- uuid: string;
- name: string;
- status: string;
- fqdn?: string;
- branch?: string;
- buildPack?: string;
- }>;
- services: Array<{
+ codebases: Array<{ id: string; label: string; path: string; hint?: string }>;
+ images: Array<{
uuid: string;
name: string;
+ image: string;
+ version: string;
serviceType?: string;
status?: string;
}>;
- previewUrls: Array<{
+ };
+ 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;
@@ -38,7 +43,6 @@ export interface Anatomy {
state: string;
startedAt: string;
}>;
- domains: Array<{ host: string; source: "production" | "preview" }>;
};
infrastructure: { placeholder: true };
}