diff --git a/app/[workspace]/project/[projectId]/(home)/hosting/page.tsx b/app/[workspace]/project/[projectId]/(home)/hosting/page.tsx index ab617b16..86e877d9 100644 --- a/app/[workspace]/project/[projectId]/(home)/hosting/page.tsx +++ b/app/[workspace]/project/[projectId]/(home)/hosting/page.tsx @@ -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 && ( Loading… )} - {error && ( + {error && !showLoading && ( {error} )} {anatomy && ( <> - {anatomy.hosting.production.map(app => ( - 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 ( + + ); + })} - {anatomy.hosting.services.map(svc => ( - setSelection({ kind: "service", uuid: svc.uuid })} - /> - ))} - - - - {anatomy.hosting.previewUrls.map(p => ( - setSelection({ kind: "preview", id: p.id })} - /> - ))} - - - - {anatomy.hosting.domains.map(d => ( - setSelection({ kind: "domain", host: d.host })} - /> - ))} + {anatomy.hosting.previews.map(p => { + const active = selection?.kind === "preview" && selection.id === p.id; + return ( + + ); + })} )} @@ -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 This app is no longer in the project.; + if (selection.kind === "live") { + const item = anatomy.hosting.live.find(l => l.uuid === selection.uuid); + if (!item) return This endpoint is no longer in the project.; return ( - - - - {app.fqdn && ( + + + {item.branch && } + {item.buildPack && } + {item.lastBuild && ( )} - - ); - } - - if (selection.kind === "service") { - const svc = anatomy.hosting.services.find(s => s.uuid === selection.uuid); - if (!svc) return This service is no longer in the project.; - return ( - - - + {item.domains.length === 0 && ( + + )} + {item.domains.map(d => ( + + ))} ); } 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 This preview URL is no longer active.; return ( - - - + + + ); } - if (selection.kind === "domain") { - const d = anatomy.hosting.domains.find(d => d.host === selection.host); - if (!d) return This domain is no longer attached.; - return ( - - - - - ); - } - 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 (
@@ -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 ( - - ); -} - function DetailLayout({ children }: { children: React.ReactNode }) { return
{children}
; } function DetailRow({ label, value, dot, href, -}: { - label: string; value: string; dot?: string; href?: string; -}) { +}: { label: string; value: string; dot?: string; href?: string }) { return (
{label} @@ -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, diff --git a/app/[workspace]/project/[projectId]/(home)/product/page.tsx b/app/[workspace]/project/[projectId]/(home)/product/page.tsx index 4899bb83..c78353f2 100644 --- a/app/[workspace]/project/[projectId]/(home)/product/page.tsx +++ b/app/[workspace]/project/[projectId]/(home)/product/page.tsx @@ -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>(new Set()); const [selection, setSelection] = useState(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 (
- {/* ── Left: workspace + codebases ── */} + {/* ── Left rail ── */}
- {/* Workspace section */} -

Workspace

-
- {showLoading && ( - Loading… - )} - {!showLoading && devContainer && ( - - )} - {!showLoading && !devContainer && ( - No dev container provisioned yet. - )} -
+ {showLoading && ( + Loading… + )} + {error && !showLoading && ( + {error} + )} - {/* Codebases section */} -

Codebases

-
- {showError && ( - {error} - )} - {codebases && codebases.length === 0 && ( - - {reason === "no_repo" - ? "No Gitea repo connected to this project yet." - : "Repo is empty — push a first commit."} - - )} - {codebases?.map(cb => { - const isOpen = expanded.has(cb.id); - return ( -
+ {anatomy && ( + <> + {/* Codebases */} + + {codebases && codebases.length === 0 && ( + + {reason === "no_repo" + ? "No Gitea repo connected to this project yet." + : "Repo is empty — push a first commit."} + + )} + {codebases?.map(cb => { + const isOpen = expanded.has(cb.id); + return ( +
+ + {isOpen && ( +
+ + setSelection({ type: "file", codebaseId: cb.id, path: p }) + } + /> +
+ )} +
+ ); + })} +
+ + {/* Images */} + + {images && images.length === 0 && ( + + Self-hosted apps (Twenty, n8n, Plausible…) you adopt as part of the product appear here. + + )} + {images?.map(img => ( - {isOpen && ( -
- - setSelection({ type: "file", codebaseId: cb.id, path: p }) - } - /> -
- )} -
- ); - })} -
+ ))} + + + )}
- {/* ── Right: contextual preview ── */} + {/* ── Right pane ── */} @@ -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 ( +
+
+ {title} + {count} +
+
{children}
+
+ ); } -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 ( -
-
{title}
-
{children}
-
- ); -} - -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 ( -
- -
-
{title}
- {subtitle &&
{subtitle}
} -
- {href && ( - - {hrefLabel ?? "open"} - - )} -
- ); -} - -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 }; }