feat(project): unify Product+Hosting around code/images and live/previews

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
This commit is contained in:
2026-04-28 19:36:35 -07:00
parent 3db7191146
commit 307c3ca858
7 changed files with 678 additions and 621 deletions

View File

@@ -3,28 +3,27 @@
import { useState } from "react";
import { useParams } from "next/navigation";
import {
Loader2, AlertCircle, ExternalLink, Globe, Server,
Cloud, Zap, CircleDot,
Loader2, AlertCircle, ExternalLink, Cloud, Container, Zap, CircleDot,
} from "lucide-react";
import { useAnatomy, type Anatomy } from "@/components/project/use-anatomy";
/**
* Hosting tab — runtime + reachability surface.
* Hosting tab — runtime + reachability, unified.
*
* Same shell as Product:
* - Left rail: 4 sections (Production / Services / Previews /
* Domains). Each section is always rendered with a count badge,
* so the user learns what belongs here even on a brand-new
* project. Items inside each section are clickable tiles.
* - Right pane: details for the selected item — status, FQDN,
* branch, source. (Action buttons land in a future pass.)
* Two sub-areas in the left rail:
* - Live = every running endpoint (repo-built apps + image-based
* services). Each item shows its source badge, a status
* dot, attached domain, and last-build time inline.
* - Previews = active dev container preview URLs.
*
* No separate Build, Domains or Services categories — those concepts
* live as properties on each Live item (build = lastBuild, domain =
* fqdn/domains[]).
*/
type Selection =
| { kind: "production"; uuid: string }
| { kind: "service"; uuid: string }
| { kind: "preview"; id: string }
| { kind: "domain"; host: string }
| { kind: "live"; uuid: string }
| { kind: "preview"; id: string }
| null;
export default function HostingTab() {
@@ -44,80 +43,87 @@ export default function HostingTab() {
{showLoading && (
<Inline><Loader2 size={13} className="animate-spin" /> Loading</Inline>
)}
{error && (
{error && !showLoading && (
<Inline><AlertCircle size={13} /> {error}</Inline>
)}
{anatomy && (
<>
<RailGroup
title="Production"
count={anatomy.hosting.production.length}
emptyHint="Coolify apps deployed from this repo will appear here."
title="Live"
count={anatomy.hosting.live.length}
emptyHint="Anything you deploy — your app or an image like Twenty CRM — shows up here."
>
{anatomy.hosting.production.map(app => (
<RailItem
key={app.uuid}
icon={Cloud}
title={app.name}
subtitle={[app.branch, app.buildPack].filter(Boolean).join(" · ") || "—"}
statusColor={statusColor(app.status)}
active={selection?.kind === "production" && selection.uuid === app.uuid}
onClick={() => setSelection({ kind: "production", uuid: app.uuid })}
/>
))}
{anatomy.hosting.live.map(item => {
const active = selection?.kind === "live" && selection.uuid === item.uuid;
const Icon = item.source === "repo" ? Cloud : Container;
return (
<button
key={item.uuid}
type="button"
onClick={() => setSelection({ kind: "live", uuid: item.uuid })}
style={{
...railItem,
borderColor: active ? INK.ink : INK.borderSoft,
boxShadow: active ? `0 0 0 1px ${INK.ink}` : "none",
background: active ? "#fffdf8" : INK.cardBg,
}}
aria-pressed={active}
>
<Icon size={13} style={{ color: INK.mid, flexShrink: 0 }} />
<div style={{ minWidth: 0, textAlign: "left", flex: 1 }}>
<div style={tileLabel}>{item.name}</div>
<div style={tileSubLine}>
<span style={sourceBadge(item.source)}>
{item.source === "repo" ? "repo" : "image"}
</span>
<span style={tileHint}>
{item.fqdn ?? item.sourceLabel}
</span>
</div>
<div style={tileMetaLine}>
{item.source === "repo" && item.lastBuild
? `${item.lastBuild.status} · ${formatRelative(item.lastBuild.finishedAt)}`
: item.source === "image" && item.sourceLabel}
</div>
</div>
<CircleDot size={9} style={{ color: statusColor(item.status), flexShrink: 0 }} />
</button>
);
})}
</RailGroup>
<RailGroup
title="Services"
count={anatomy.hosting.services.length}
emptyHint="Self-hosted apps (Twenty, n8n, Plausible…) appear here."
title="Previews"
count={anatomy.hosting.previews.length}
emptyHint="Dev servers started from chat get a temporary preview URL here."
>
{anatomy.hosting.services.map(svc => (
<RailItem
key={svc.uuid}
icon={Server}
title={svc.name}
subtitle={svc.serviceType ?? "service"}
statusColor={statusColor(svc.status ?? "")}
active={selection?.kind === "service" && selection.uuid === svc.uuid}
onClick={() => setSelection({ kind: "service", uuid: svc.uuid })}
/>
))}
</RailGroup>
<RailGroup
title="Preview URLs"
count={anatomy.hosting.previewUrls.length}
emptyHint="Dev servers started from chat get a preview URL here."
>
{anatomy.hosting.previewUrls.map(p => (
<RailItem
key={p.id}
icon={Zap}
title={`${p.name} :${p.port}`}
subtitle={p.state}
statusColor={p.state === "running" ? "#2e7d32" : "#a09a90"}
active={selection?.kind === "preview" && selection.id === p.id}
onClick={() => setSelection({ kind: "preview", id: p.id })}
/>
))}
</RailGroup>
<RailGroup
title="Domains"
count={anatomy.hosting.domains.length}
emptyHint="Custom domains attached to anything above show up here."
>
{anatomy.hosting.domains.map(d => (
<RailItem
key={d.host}
icon={Globe}
title={d.host}
subtitle={d.source === "production" ? "Production" : "Preview"}
active={selection?.kind === "domain" && selection.host === d.host}
onClick={() => setSelection({ kind: "domain", host: d.host })}
/>
))}
{anatomy.hosting.previews.map(p => {
const active = selection?.kind === "preview" && selection.id === p.id;
return (
<button
key={p.id}
type="button"
onClick={() => setSelection({ kind: "preview", id: p.id })}
style={{
...railItem,
borderColor: active ? INK.ink : INK.borderSoft,
boxShadow: active ? `0 0 0 1px ${INK.ink}` : "none",
background: active ? "#fffdf8" : INK.cardBg,
}}
aria-pressed={active}
>
<Zap size={13} style={{ color: INK.mid, flexShrink: 0 }} />
<div style={{ minWidth: 0, textAlign: "left", flex: 1 }}>
<div style={tileLabel}>{p.name} · port {p.port}</div>
<div style={tileHint}>{hostOf(p.url)}</div>
</div>
<CircleDot
size={9}
style={{ color: p.state === "running" ? "#2e7d32" : "#a09a90", flexShrink: 0 }}
/>
</button>
);
})}
</RailGroup>
</>
)}
@@ -144,70 +150,52 @@ export default function HostingTab() {
function Detail({ selection, anatomy }: { selection: Selection; anatomy: Anatomy }) {
if (!selection) return null;
if (selection.kind === "production") {
const app = anatomy.hosting.production.find(a => a.uuid === selection.uuid);
if (!app) return <Empty>This app is no longer in the project.</Empty>;
if (selection.kind === "live") {
const item = anatomy.hosting.live.find(l => l.uuid === selection.uuid);
if (!item) return <Empty>This endpoint is no longer in the project.</Empty>;
return (
<DetailLayout>
<DetailRow label="Status" value={app.status} dot={statusColor(app.status)} />
<DetailRow label="Branch" value={app.branch ?? "—"} />
<DetailRow label="Pack" value={app.buildPack ?? "—"} />
{app.fqdn && (
<DetailRow label="Source" value={item.source === "repo" ? `Built from ${item.sourceLabel}` : `Image ${item.sourceLabel}`} />
<DetailRow label="Status" value={item.status} dot={statusColor(item.status)} />
{item.branch && <DetailRow label="Branch" value={item.branch} />}
{item.buildPack && <DetailRow label="Pack" value={item.buildPack} />}
{item.lastBuild && (
<DetailRow
label="URL"
value={primaryHost(app.fqdn)}
href={`https://${primaryHost(app.fqdn)}`}
label="Last build"
value={`${item.lastBuild.status}${item.lastBuild.finishedAt ? " · " + formatRelative(item.lastBuild.finishedAt) : ""}`}
dot={statusColor(item.lastBuild.status)}
/>
)}
</DetailLayout>
);
}
if (selection.kind === "service") {
const svc = anatomy.hosting.services.find(s => s.uuid === selection.uuid);
if (!svc) return <Empty>This service is no longer in the project.</Empty>;
return (
<DetailLayout>
<DetailRow label="Status" value={svc.status ?? "unknown"} dot={statusColor(svc.status ?? "")} />
<DetailRow label="Type" value={svc.serviceType ?? "—"} />
{item.domains.length === 0 && (
<DetailRow label="Domain" value="— (no domain attached)" />
)}
{item.domains.map(d => (
<DetailRow key={d} label="Domain" value={d} href={`https://${d}`} />
))}
</DetailLayout>
);
}
if (selection.kind === "preview") {
const p = anatomy.hosting.previewUrls.find(p => p.id === selection.id);
const p = anatomy.hosting.previews.find(p => p.id === selection.id);
if (!p) return <Empty>This preview URL is no longer active.</Empty>;
return (
<DetailLayout>
<DetailRow label="State" value={p.state} dot={p.state === "running" ? "#2e7d32" : "#a09a90"} />
<DetailRow label="Port" value={String(p.port)} />
<DetailRow label="URL" value={hostOf(p.url)} href={p.url} />
<DetailRow label="State" value={p.state} dot={p.state === "running" ? "#2e7d32" : "#a09a90"} />
<DetailRow label="Port" value={String(p.port)} />
<DetailRow label="URL" value={hostOf(p.url)} href={p.url} />
<DetailRow label="Started" value={formatRelative(p.startedAt)} />
</DetailLayout>
);
}
if (selection.kind === "domain") {
const d = anatomy.hosting.domains.find(d => d.host === selection.host);
if (!d) return <Empty>This domain is no longer attached.</Empty>;
return (
<DetailLayout>
<DetailRow label="Host" value={d.host} href={`https://${d.host}`} />
<DetailRow label="Source" value={d.source === "production" ? "Production" : "Preview"} />
</DetailLayout>
);
}
return null;
}
function paneHeading(s: Selection, a: Anatomy | null): string {
if (!s) return "Details";
if (!a) return "Details";
if (s.kind === "production") return `Details · ${a.hosting.production.find(x => x.uuid === s.uuid)?.name ?? "Production"}`;
if (s.kind === "service") return `Details · ${a.hosting.services.find(x => x.uuid === s.uuid)?.name ?? "Service"}`;
if (s.kind === "preview") return `Details · ${a.hosting.previewUrls.find(x => x.id === s.id)?.name ?? "Preview"}`;
if (s.kind === "domain") return `Details · ${s.host}`;
if (!s || !a) return "Details";
if (s.kind === "live") return `Details · ${a.hosting.live.find(x => x.uuid === s.uuid)?.name ?? "Endpoint"}`;
if (s.kind === "preview") return `Details · ${a.hosting.previews.find(x => x.id === s.id)?.name ?? "Preview"}`;
return "Details";
}
@@ -218,10 +206,7 @@ function paneHeading(s: Selection, a: Anatomy | null): string {
function RailGroup({
title, count, emptyHint, children,
}: {
title: string;
count: number;
emptyHint: string;
children: React.ReactNode;
title: string; count: number; emptyHint: string; children: React.ReactNode;
}) {
return (
<div style={railGroup}>
@@ -238,47 +223,13 @@ function RailGroup({
);
}
function RailItem({
icon: Icon, title, subtitle, statusColor: dot, active, onClick,
}: {
icon: React.ComponentType<{ size?: number; style?: React.CSSProperties }>;
title: string;
subtitle?: string;
statusColor?: string;
active?: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
style={{
...railItem,
borderColor: active ? INK.ink : INK.borderSoft,
boxShadow: active ? `0 0 0 1px ${INK.ink}` : "none",
background: active ? "#fffdf8" : INK.cardBg,
}}
aria-pressed={active}
>
<Icon size={13} style={{ color: INK.mid, flexShrink: 0 }} />
<div style={{ minWidth: 0, textAlign: "left", flex: 1 }}>
<div style={tileLabel}>{title}</div>
{subtitle && <div style={tileHint}>{subtitle}</div>}
</div>
{dot && <CircleDot size={9} style={{ color: dot, flexShrink: 0 }} />}
</button>
);
}
function DetailLayout({ children }: { children: React.ReactNode }) {
return <div style={{ display: "flex", flexDirection: "column", gap: 1 }}>{children}</div>;
}
function DetailRow({
label, value, dot, href,
}: {
label: string; value: string; dot?: string; href?: string;
}) {
}: { label: string; value: string; dot?: string; href?: string }) {
return (
<div style={detailRow}>
<span style={detailLabel}>{label}</span>
@@ -321,21 +272,20 @@ function Empty({ children }: { children: React.ReactNode }) {
// Helpers
// ──────────────────────────────────────────────────
function primaryHost(fqdn: string) {
return fqdn.split(",")[0]?.trim().replace(/^https?:\/\//, "").replace(/\/$/, "") || fqdn;
}
function hostOf(url: string) {
try { return new URL(url).host; } catch { return url; }
}
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";
const s = (status ?? "").toLowerCase();
if (s.includes("running") || s.includes("healthy") || s.includes("success")) return "#2e7d32";
if (s.includes("starting") || s.includes("deploying") || s.includes("queued") || s.includes("in_progress")) return "#d4a04a";
if (s.includes("exit") || s.includes("fail") || s.includes("unhealthy") || s.includes("error")) return "#c5392b";
return "#a09a90";
}
function formatRelative(iso: string) {
function formatRelative(iso: string | undefined) {
if (!iso) return "never";
const ms = Date.now() - new Date(iso).getTime();
if (Number.isNaN(ms)) return "—";
const min = Math.floor(ms / 60_000);
if (min < 1) return "just now";
if (min < 60) return `${min}m ago`;
@@ -343,6 +293,20 @@ function formatRelative(iso: string) {
if (hr < 24) return `${hr}h ago`;
return `${Math.floor(hr / 24)}d ago`;
}
function sourceBadge(source: "repo" | "image"): React.CSSProperties {
const isRepo = source === "repo";
return {
fontSize: "0.62rem",
fontWeight: 700,
letterSpacing: "0.08em",
textTransform: "uppercase",
color: isRepo ? "#2e6d2e" : "#3b5a78",
background: isRepo ? "#eaf3e8" : "#e9eff5",
padding: "1px 6px",
borderRadius: 4,
flexShrink: 0,
};
}
// ──────────────────────────────────────────────────
// Tokens
@@ -381,9 +345,7 @@ const heading: React.CSSProperties = {
fontSize: "0.72rem", fontWeight: 600, letterSpacing: "0.12em",
textTransform: "uppercase", color: INK.muted, margin: "0 0 14px",
};
const railGroup: React.CSSProperties = {
display: "flex", flexDirection: "column",
};
const railGroup: React.CSSProperties = { display: "flex", flexDirection: "column" };
const railGroupHeader: React.CSSProperties = {
display: "flex", alignItems: "center", justifyContent: "space-between",
padding: "0 4px 8px",
@@ -396,9 +358,7 @@ 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: 8,
};
const railItems: React.CSSProperties = { display: "flex", flexDirection: "column", gap: 8 };
const railItem: React.CSSProperties = {
display: "flex", alignItems: "center", gap: 10,
width: "100%", padding: "10px 12px",
@@ -414,8 +374,15 @@ const railEmpty: React.CSSProperties = {
const tileLabel: React.CSSProperties = {
fontSize: "0.85rem", fontWeight: 600, color: INK.ink, marginBottom: 2,
};
const tileSubLine: React.CSSProperties = {
display: "flex", alignItems: "center", gap: 6, minWidth: 0,
};
const tileHint: React.CSSProperties = {
fontSize: "0.74rem", color: INK.mid, lineHeight: 1.4,
whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis",
};
const tileMetaLine: React.CSSProperties = {
fontSize: "0.7rem", color: INK.muted, lineHeight: 1.4, marginTop: 2,
};
const panel: React.CSSProperties = {
background: INK.cardBg, border: `1px solid ${INK.border}`, borderRadius: 10,

View File

@@ -2,29 +2,30 @@
import { useEffect, useState } from "react";
import { useParams } from "next/navigation";
import { Loader2, AlertCircle, ChevronDown, ChevronRight, Box, Server, CircleDot } from "lucide-react";
import {
Loader2, AlertCircle, ChevronDown, ChevronRight,
Box, Container, CircleDot,
} from "lucide-react";
import { GiteaFileTree } from "@/components/project/gitea-file-tree";
import { GiteaFileViewer } from "@/components/project/gitea-file-viewer";
import { DevContainerDetail } from "@/components/project/dev-container-detail";
import { useAnatomy } from "@/components/project/use-anatomy";
import { useAnatomy, type Anatomy } from "@/components/project/use-anatomy";
/**
* Product tab — the build surface.
* Product tab — everything that makes up the thing being shipped.
*
* Left rail (top → bottom):
* - Workspace section: dev container tile (the vibn-dev-* service
* where the AI edits code; clicking it shows status + active
* dev servers in the right pane).
* - Codebases section: one tile per codebase, each expanding inline
* into its Gitea file tree. Clicking a file previews it.
* 1. Codebases — Gitea repos, each tile expands inline into a file
* tree; clicking a file previews it on the right.
* 2. Images — Coolify services backed by an upstream Docker image
* (Twenty CRM, n8n…). Clicking shows image meta on the right.
*
* Right pane swaps between three view kinds based on the active
* selection: "devContainer", "file", or "empty".
* Dev containers do not appear here — they are the AI's workshop, not
* part of the product surface.
*/
type Selection =
| { type: "devContainer"; uuid: string }
| { type: "file"; codebaseId: string; path: string }
| { type: "image"; uuid: string }
| null;
export default function ProductTab() {
@@ -32,22 +33,19 @@ export default function ProductTab() {
const projectId = params.projectId as string;
const { anatomy, loading, error } = useAnatomy(projectId);
const codebases = anatomy?.codebases ?? null;
const reason = anatomy?.codebasesReason;
const devContainer = anatomy?.product.devContainers[0]; // only one per project
const previewUrls = anatomy?.hosting.previewUrls ?? [];
const codebases = anatomy?.product.codebases ?? null;
const images = anatomy?.product.images ?? null;
const reason = anatomy?.codebasesReason;
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [selection, setSelection] = useState<Selection>(null);
// Auto-expand the first codebase whenever anatomy lands
useEffect(() => {
if (codebases && codebases[0]) {
setExpanded(prev => (prev.size === 0 ? new Set([codebases[0].id]) : prev));
}
}, [codebases]);
// Reset on project change
useEffect(() => {
setSelection(null);
setExpanded(new Set());
@@ -63,112 +61,119 @@ export default function ProductTab() {
};
const showLoading = loading && !anatomy;
const showError = !!error;
return (
<div style={pageWrap}>
<div style={grid}>
{/* ── Left: workspace + codebases ── */}
{/* ── Left rail ── */}
<section style={leftCol}>
{/* Workspace section */}
<h3 style={heading}>Workspace</h3>
<div style={stack}>
{showLoading && (
<Inline><Loader2 size={13} className="animate-spin" /> Loading</Inline>
)}
{!showLoading && devContainer && (
<button
type="button"
onClick={() => setSelection({ type: "devContainer", uuid: devContainer.uuid })}
style={{
...flatTile,
borderColor: selection?.type === "devContainer" ? INK.ink : INK.borderSoft,
boxShadow: selection?.type === "devContainer" ? `0 0 0 1px ${INK.ink}` : "none",
background: selection?.type === "devContainer" ? "#fffdf8" : INK.cardBg,
}}
aria-pressed={selection?.type === "devContainer"}
>
<Server size={13} style={{ color: INK.mid, flexShrink: 0 }} />
<div style={{ minWidth: 0, textAlign: "left", flex: 1 }}>
<div style={tileLabel}>Dev container</div>
<div style={tileHint}>{devContainer.name}</div>
</div>
<CircleDot size={9} style={{ color: colorForStatus(devContainer.status) }} />
</button>
)}
{!showLoading && !devContainer && (
<Inline>No dev container provisioned yet.</Inline>
)}
</div>
{showLoading && (
<Inline><Loader2 size={13} className="animate-spin" /> Loading</Inline>
)}
{error && !showLoading && (
<Inline><AlertCircle size={13} /> {error}</Inline>
)}
{/* Codebases section */}
<h3 style={{ ...heading, marginTop: 24 }}>Codebases</h3>
<div style={stack}>
{showError && (
<Inline><AlertCircle size={13} /> {error}</Inline>
)}
{codebases && codebases.length === 0 && (
<Inline>
{reason === "no_repo"
? "No Gitea repo connected to this project yet."
: "Repo is empty — push a first commit."}
</Inline>
)}
{codebases?.map(cb => {
const isOpen = expanded.has(cb.id);
return (
<article key={cb.id} style={codebaseTile}>
{anatomy && (
<>
{/* Codebases */}
<RailGroup title="Codebases" count={codebases?.length ?? 0}>
{codebases && codebases.length === 0 && (
<RailEmpty>
{reason === "no_repo"
? "No Gitea repo connected to this project yet."
: "Repo is empty — push a first commit."}
</RailEmpty>
)}
{codebases?.map(cb => {
const isOpen = expanded.has(cb.id);
return (
<article key={cb.id} style={codebaseTile}>
<button
type="button"
onClick={() => toggleCodebase(cb.id)}
style={tileHeader}
aria-expanded={isOpen}
>
<span style={chevronCell}>
{isOpen
? <ChevronDown size={13} style={{ color: INK.mid }} />
: <ChevronRight size={13} style={{ color: INK.mid }} />}
</span>
<Box size={13} style={{ color: INK.mid, flexShrink: 0 }} />
<div style={{ minWidth: 0, textAlign: "left" }}>
<div style={tileLabel}>{cb.label}</div>
{cb.hint && <div style={tileHint}>{cb.hint}</div>}
</div>
</button>
{isOpen && (
<div style={tileBody}>
<GiteaFileTree
projectId={projectId}
rootPath={cb.path}
selectedPath={
selection?.type === "file" && selection.codebaseId === cb.id
? selection.path
: undefined
}
onSelectFile={(p) =>
setSelection({ type: "file", codebaseId: cb.id, path: p })
}
/>
</div>
)}
</article>
);
})}
</RailGroup>
{/* Images */}
<RailGroup title="Images" count={images?.length ?? 0}>
{images && images.length === 0 && (
<RailEmpty>
Self-hosted apps (Twenty, n8n, Plausible) you adopt as part of the product appear here.
</RailEmpty>
)}
{images?.map(img => (
<button
key={img.uuid}
type="button"
onClick={() => toggleCodebase(cb.id)}
style={tileHeader}
aria-expanded={isOpen}
onClick={() => setSelection({ type: "image", uuid: img.uuid })}
style={{
...flatTile,
borderColor: selection?.type === "image" && selection.uuid === img.uuid ? INK.ink : INK.borderSoft,
boxShadow: selection?.type === "image" && selection.uuid === img.uuid ? `0 0 0 1px ${INK.ink}` : "none",
background: selection?.type === "image" && selection.uuid === img.uuid ? "#fffdf8" : INK.cardBg,
}}
aria-pressed={selection?.type === "image" && selection.uuid === img.uuid}
>
<span style={chevronCell}>
{isOpen
? <ChevronDown size={13} style={{ color: INK.mid }} />
: <ChevronRight size={13} style={{ color: INK.mid }} />}
</span>
<Box size={13} style={{ color: INK.mid, flexShrink: 0 }} />
<div style={{ minWidth: 0, textAlign: "left" }}>
<div style={tileLabel}>{cb.label}</div>
{cb.hint && <div style={tileHint}>{cb.hint}</div>}
<Container size={13} style={{ color: INK.mid, flexShrink: 0 }} />
<div style={{ minWidth: 0, textAlign: "left", flex: 1 }}>
<div style={tileLabel}>{img.name}</div>
<div style={tileHint}>
{img.image}{img.version ? `:${img.version}` : ""}
</div>
</div>
{img.status && <CircleDot size={9} style={{ color: statusColor(img.status), flexShrink: 0 }} />}
</button>
{isOpen && (
<div style={tileBody}>
<GiteaFileTree
projectId={projectId}
rootPath={cb.path}
selectedPath={
selection?.type === "file" && selection.codebaseId === cb.id
? selection.path
: undefined
}
onSelectFile={(p) =>
setSelection({ type: "file", codebaseId: cb.id, path: p })
}
/>
</div>
)}
</article>
);
})}
</div>
))}
</RailGroup>
</>
)}
</section>
{/* ── Right: contextual preview ── */}
{/* ── Right pane ── */}
<aside style={rightCol}>
<h3 style={heading}>{previewHeading(selection)}</h3>
<div style={previewPanel}>
{selection?.type === "devContainer" && devContainer && (
<DevContainerDetail container={devContainer} previewUrls={previewUrls} />
)}
<h3 style={heading}>{paneHeading(selection)}</h3>
<div style={panel}>
{selection?.type === "file" && (
<GiteaFileViewer projectId={projectId} path={selection.path} />
)}
{selection?.type === "image" && anatomy && (
<ImageDetail uuid={selection.uuid} anatomy={anatomy} />
)}
{!selection && (
<Empty>Pick a codebase file or the dev container on the left.</Empty>
<Empty>Pick a codebase file or an image on the left.</Empty>
)}
</div>
</aside>
@@ -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 <Empty>This image is no longer in the project.</Empty>;
const live = anatomy.hosting.live.find(l => l.uuid === uuid);
return (
<div style={{ display: "flex", flexDirection: "column", gap: 1 }}>
<DetailRow label="Image" value={img.image} />
<DetailRow label="Version" value={img.version || "latest"} />
<DetailRow label="Type" value={img.serviceType ?? "—"} />
<DetailRow
label="Status"
value={img.status ?? "unknown"}
dot={statusColor(img.status ?? "")}
/>
{live?.fqdn && (
<DetailRow label="URL" value={live.fqdn} href={`https://${live.fqdn}`} />
)}
</div>
);
}
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 (
<div style={railGroup}>
<header style={railGroupHeader}>
<span style={railGroupTitle}>{title}</span>
<span style={countPill}>{count}</span>
</header>
<div style={railItems}>{children}</div>
</div>
);
}
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 <div style={railEmpty}>{children}</div>;
}
function DetailRow({
label, value, dot, href,
}: { label: string; value: string; dot?: string; href?: string }) {
return (
<div style={detailRow}>
<span style={detailLabel}>{label}</span>
<span style={detailValue}>
{dot && <CircleDot size={9} style={{ color: dot, marginRight: 6 }} />}
{href ? (
<a href={href} target="_blank" rel="noreferrer" style={detailLink}>{value}</a>
) : value}
</span>
</div>
);
}
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",
};

View File

@@ -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<string, a
return NextResponse.json({ result: { ok: true, key } });
}
// ──────────────────────────────────────────────────
// services.* — Coolify Services (Twenty CRM, n8n, etc.)
//
// Services are upstream Docker images that Coolify pulls and runs as
// docker-compose stacks. Distinct from apps (which build from Git).
// All ops are tenant-scoped via the workspace's owned Coolify projects
// — agents from one workspace cannot read or mutate another's services.
// ──────────────────────────────────────────────────
async function toolServicesList(principal: Principal, params: Record<string, any> = {}) {
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<string, any>) {
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<string, any>) {
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<string, any>) {
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<string, any>) {
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<string, any>) {
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
// ──────────────────────────────────────────────────

View File

@@ -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<ProductionApp[]> {
async function loadRepoApps(giteaRepo: string | undefined): Promise<CoolifyApplication[]> {
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<CoolifyService[]> {
async function loadProjectServices(coolifyProjectUuid: string | undefined): Promise<CoolifyService[]> {
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<PreviewUrl[]> {
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<BuildSummary | undefined> {
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<Preview[]> {
try {
const rows = await query<{
id: string;
@@ -239,7 +280,6 @@ async function loadPreviewUrls(projectId: string): Promise<PreviewUrl[]> {
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<PreviewUrl[]> {
}
}
function dedupeDomains(prod: ProductionApp[], previews: PreviewUrl[]): Domain[] {
const map = new Map<string, Domain>();
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 },
};

View File

@@ -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 (
<div style={wrap}>
<div style={statusRow}>
<Server size={14} style={{ color: INK.mid }} />
<span style={{ flex: 1, color: INK.ink, fontSize: "0.85rem" }}>{container.name}</span>
<span style={statusPill}>
<CircleDot size={9} style={{ color: statusColor }} />
<span style={{ fontSize: "0.74rem", color: INK.mid }}>{container.status ?? "unknown"}</span>
</span>
</div>
<Section title="Active dev servers">
{previewUrls.length === 0 ? (
<Empty
message="No dev servers running."
hint="Ask Vibn to start one — `npm run dev`, `flask run`, etc."
/>
) : (
previewUrls.map(p => (
<Row
key={p.id}
icon={Zap}
title={`${p.name} :${p.port}`}
subtitle={`${p.state}`}
href={p.url}
hrefLabel={hostOf(p.url)}
/>
))
)}
</Section>
</div>
);
}
// ──────────────────────────────────────────────────
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section style={sectionWrap}>
<header style={sectionHeader}>{title}</header>
<div>{children}</div>
</section>
);
}
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 (
<div style={rowStyle}>
<Icon size={13} style={{ color: INK.mid, flexShrink: 0 }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: "0.85rem", fontWeight: 600, color: INK.ink }}>{title}</div>
{subtitle && <div style={{ fontSize: "0.74rem", color: INK.mid }}>{subtitle}</div>}
</div>
{href && (
<a href={href} target="_blank" rel="noreferrer" style={openLink}>
<ExternalLink size={11} /> {hrefLabel ?? "open"}
</a>
)}
</div>
);
}
function Empty({ message, hint }: { message: string; hint?: string }) {
return (
<div style={emptyWrap}>
<div style={emptyMsg}>{message}</div>
{hint && <div style={emptyHint}>{hint}</div>}
</div>
);
}
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",
};

View File

@@ -26,13 +26,12 @@ export function ProjectStagePill({ projectId, fallbackStage }: ProjectStagePillP
if (loading && !anatomy) return <Pill {...PRESETS[fallbackStage]} />;
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 <Pill label="Down" color="#c5392b" bg="#c5392b14" />;
if (anyRunning) return <Pill label="Live" color="#2e7d32" bg="#2e7d3210" />;

View File

@@ -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 };
}