From 3db719114699d201ba8ba3f7f3872b63cf4c0fce Mon Sep 17 00:00:00 2001 From: Mark Henderson Date: Tue, 28 Apr 2026 18:54:19 -0700 Subject: [PATCH] feat(project): split dev containers into Product; convert Hosting to tile-rail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The vibn-dev-* services that the AI authors code in conceptually belong to Product (build surface), not Hosting (runtime + reach). Anatomy endpoint now splits Coolify services by name prefix: - vibn-dev-* → product.devContainers[] - everything else → hosting.services[] Product tab gains a "Workspace" section above the codebases stack with a single dev-container tile. Selecting it shows status + active dev servers in the right pane. Codebase + file selection behaves the same as before. Hosting tab restructured from a stack of always-visible cards to the same tile-rail pattern Product uses: left rail has 4 always- present categories (Production / Services / Previews / Domains) each with a count badge, items inside are clickable tiles, right pane shows details for the selected item. Empty categories show a one-liner explaining what would appear there — teaches the user the model on a brand-new project without being preachy. Made-with: Cursor --- .../[projectId]/(home)/hosting/page.tsx | 506 +++++++++++------- .../[projectId]/(home)/product/page.tsx | 229 ++++---- app/api/projects/[projectId]/anatomy/route.ts | 50 +- components/project/dev-container-detail.tsx | 187 +++++++ components/project/use-anatomy.ts | 3 + 5 files changed, 678 insertions(+), 297 deletions(-) create mode 100644 components/project/dev-container-detail.tsx diff --git a/app/[workspace]/project/[projectId]/(home)/hosting/page.tsx b/app/[workspace]/project/[projectId]/(home)/hosting/page.tsx index f454d16a..ab617b16 100644 --- a/app/[workspace]/project/[projectId]/(home)/hosting/page.tsx +++ b/app/[workspace]/project/[projectId]/(home)/hosting/page.tsx @@ -1,5 +1,6 @@ "use client"; +import { useState } from "react"; import { useParams } from "next/navigation"; import { Loader2, AlertCircle, ExternalLink, Globe, Server, @@ -8,207 +9,321 @@ import { import { useAnatomy, type Anatomy } from "@/components/project/use-anatomy"; /** - * Hosting tab. + * Hosting tab — runtime + reachability surface. * - * Surfaces "where this product runs" — Coolify production apps, - * dev/services, live preview URLs from active dev_servers, and the - * domains pointing at any of them. All from one /anatomy fetch. + * 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.) */ + +type Selection = + | { kind: "production"; uuid: string } + | { kind: "service"; uuid: string } + | { kind: "preview"; id: string } + | { kind: "domain"; host: string } + | null; + export default function HostingTab() { const params = useParams(); const projectId = params.projectId as string; const { anatomy, loading, error } = useAnatomy(projectId); - if (loading && !anatomy) { - return
Loading…
; - } - if (error) { - return
{error}
; - } - if (!anatomy) return null; + const [selection, setSelection] = useState(null); - const { production, services, previewUrls, domains } = anatomy.hosting; - const hasAnything = - production.length + services.length + previewUrls.length + domains.length > 0; + const showLoading = loading && !anatomy; return (
-
- {!hasAnything && } +
+ {/* ── Left rail ── */} +
+ {showLoading && ( + Loading… + )} + {error && ( + {error} + )} + {anatomy && ( + <> + + {anatomy.hosting.production.map(app => ( + setSelection({ kind: "production", uuid: app.uuid })} + /> + ))} + -
- {production.length === 0 ? ( - - ) : production.map(app => ( - - ))} -
+ + {anatomy.hosting.services.map(svc => ( + setSelection({ kind: "service", uuid: svc.uuid })} + /> + ))} + -
- {services.length === 0 ? ( - - ) : services.map(svc => ( - - ))} -
+ + {anatomy.hosting.previewUrls.map(p => ( + setSelection({ kind: "preview", id: p.id })} + /> + ))} + -
- {previewUrls.length === 0 ? ( - - ) : previewUrls.map(p => ( - - ))} -
+ + {anatomy.hosting.domains.map(d => ( + setSelection({ kind: "domain", host: d.host })} + /> + ))} + + + )} +
-
- {domains.length === 0 ? ( - - ) : domains.map(d => ( - - ))} -
+ {/* ── Right pane ── */} +
); } -// ────────────────────────────────────────────────────────────────────── -// Bits -// ────────────────────────────────────────────────────────────────────── +// ────────────────────────────────────────────────── +// Detail pane +// ────────────────────────────────────────────────── -function NothingDeployedBanner({ anatomy }: { anatomy: Anatomy }) { - const reason = anatomy.project.gitea - ? "The repo exists on Gitea but no production app is wired up in Coolify yet." - : "This project doesn't have a Gitea repo connected, so there's nothing to deploy."; - return ( -
-
Nothing is deployed for this project
-
{reason}
-
- ); +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.; + return ( + + + + + {app.fqdn && ( + + )} + + ); + } + + 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 ( + + + + + ); + } + + if (selection.kind === "preview") { + const p = anatomy.hosting.previewUrls.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 Section({ - icon: Icon, title, count, children, +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}`; + return "Details"; +} + +// ────────────────────────────────────────────────── +// Bits +// ────────────────────────────────────────────────── + +function RailGroup({ + title, count, emptyHint, children, }: { - icon: React.ComponentType<{ size?: number; style?: React.CSSProperties }>; title: string; count: number; + emptyHint: string; children: React.ReactNode; }) { return ( -
-
- - {title} +
+
+ {title} {count}
-
{children}
-
+ {count === 0 ? ( +
{emptyHint}
+ ) : ( +
{children}
+ )} +
); } -function Row({ - title, subtitle, statusDot, statusLabel, href, hrefLabel, +function RailItem({ + icon: Icon, title, subtitle, statusColor: dot, active, onClick, }: { + icon: React.ComponentType<{ size?: number; style?: React.CSSProperties }>; title: string; subtitle?: string; - statusDot?: string; - statusLabel?: string; - href?: string; - hrefLabel?: string; + statusColor?: string; + active?: boolean; + onClick: () => void; }) { return ( -
-
-
{title}
- {subtitle &&
{subtitle}
} +
+ {dot && } + ); } -function Empty({ message, hint }: { message: string; hint?: string }) { +function DetailLayout({ children }: { children: React.ReactNode }) { + return
{children}
; +} + +function DetailRow({ + label, value, dot, href, +}: { + label: string; value: string; dot?: string; href?: string; +}) { return ( -
-
{message}
- {hint &&
{hint}
} +
+ {label} + + {dot && } + {href ? ( + + {value} + + ) : value} +
); } -function Center({ children }: { children: React.ReactNode }) { +function Inline({ children }: { children: React.ReactNode }) { return (
{children}
+ display: "flex", alignItems: "center", gap: 8, + padding: "12px 14px", fontSize: "0.82rem", color: INK.mid, + background: INK.cardBg, border: `1px solid ${INK.borderSoft}`, borderRadius: 8, + }}> + {children} +
); } -// ────────────────────────────────────────────────────────────────────── +function Empty({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +// ────────────────────────────────────────────────── // Helpers -// ────────────────────────────────────────────────────────────────────── +// ────────────────────────────────────────────────── function primaryHost(fqdn: string) { return fqdn.split(",")[0]?.trim().replace(/^https?:\/\//, "").replace(/\/$/, "") || fqdn; } -function hrefForFqdn(fqdn: string) { - const host = primaryHost(fqdn); - return host.startsWith("http") ? host : `https://${host}`; -} function hostOf(url: string) { try { return new URL(url).host; } catch { return url; } } @@ -216,7 +331,7 @@ 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("exited") || s.includes("failed") || s.includes("unhealthy")) return "#c5392b"; + if (s.includes("exit") || s.includes("fail") || s.includes("unhealthy")) return "#c5392b"; return "#a09a90"; } function formatRelative(iso: string) { @@ -226,13 +341,12 @@ function formatRelative(iso: string) { if (min < 60) return `${min}m ago`; const hr = Math.floor(min / 60); if (hr < 24) return `${hr}h ago`; - const d = Math.floor(hr / 24); - return `${d}d ago`; + return `${Math.floor(hr / 24)}d ago`; } -// ────────────────────────────────────────────────────────────────────── +// ────────────────────────────────────────────────── // Tokens -// ────────────────────────────────────────────────────────────────────── +// ────────────────────────────────────────────────── const INK = { ink: "#1a1a1a", @@ -249,51 +363,75 @@ const pageWrap: React.CSSProperties = { fontFamily: INK.fontSans, color: INK.ink, }; -const inner: React.CSSProperties = { - maxWidth: 960, margin: "0 auto", - display: "flex", flexDirection: "column", gap: 16, +const grid: React.CSSProperties = { + display: "grid", + gridTemplateColumns: "minmax(280px, 360px) minmax(0, 1fr)", + gap: 28, + maxWidth: 1400, + margin: "0 auto", + alignItems: "stretch", }; -const bannerBox: React.CSSProperties = { - padding: "14px 18px", borderRadius: 10, - background: "#fff7e8", border: "1px solid #f0deb6", +const leftCol: React.CSSProperties = { + minWidth: 0, display: "flex", flexDirection: "column", gap: 18, }; -const bannerTitle: React.CSSProperties = { - fontWeight: 600, color: "#7a5818", fontSize: "0.88rem", marginBottom: 4, +const rightCol: React.CSSProperties = { + minWidth: 0, display: "flex", flexDirection: "column", }; -const bannerBody: React.CSSProperties = { color: "#7a5818", fontSize: "0.82rem", lineHeight: 1.5 }; -const sectionWrap: React.CSSProperties = { - background: INK.cardBg, border: `1px solid ${INK.border}`, borderRadius: 10, overflow: "hidden", +const heading: React.CSSProperties = { + fontSize: "0.72rem", fontWeight: 600, letterSpacing: "0.12em", + textTransform: "uppercase", color: INK.muted, margin: "0 0 14px", }; -const sectionHeader: React.CSSProperties = { - display: "flex", alignItems: "center", gap: 8, - padding: "14px 18px", borderBottom: `1px solid ${INK.borderSoft}`, +const railGroup: React.CSSProperties = { + display: "flex", flexDirection: "column", }; -const sectionTitle: React.CSSProperties = { - fontSize: "0.78rem", fontWeight: 600, letterSpacing: "0.06em", textTransform: "uppercase", color: INK.ink, +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 = { - marginLeft: "auto", fontSize: "0.72rem", fontWeight: 600, color: INK.mid, - padding: "2px 8px", borderRadius: 999, background: "#f3eee4", + fontSize: "0.7rem", fontWeight: 600, color: INK.mid, + padding: "1px 7px", borderRadius: 999, background: "#f3eee4", }; -const sectionBody: React.CSSProperties = { display: "flex", flexDirection: "column" }; -const rowWrap: React.CSSProperties = { - display: "flex", alignItems: "center", gap: 14, - padding: "12px 18px", borderTop: `1px solid ${INK.borderSoft}`, +const railItems: React.CSSProperties = { + display: "flex", flexDirection: "column", gap: 8, }; -const rowTitle: React.CSSProperties = { fontSize: "0.88rem", fontWeight: 600, color: INK.ink }; -const rowSubtitle: React.CSSProperties = { fontSize: "0.74rem", color: INK.mid, marginTop: 2 }; -const statusPill: React.CSSProperties = { - display: "inline-flex", alignItems: "center", gap: 6, flexShrink: 0, +const railItem: React.CSSProperties = { + display: "flex", alignItems: "center", gap: 10, + width: "100%", padding: "10px 12px", + border: `1px solid ${INK.borderSoft}`, borderRadius: 8, + cursor: "pointer", font: "inherit", color: "inherit", + transition: "border-color 0.12s, background 0.12s, box-shadow 0.12s", }; -const openLink: React.CSSProperties = { - display: "inline-flex", alignItems: "center", gap: 5, - fontSize: "0.78rem", color: INK.mid, textDecoration: "none", - border: `1px solid ${INK.borderSoft}`, borderRadius: 6, padding: "4px 8px", - flexShrink: 0, +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 emptyWrap: React.CSSProperties = { - padding: "20px 18px", textAlign: "center", - borderTop: `1px solid ${INK.borderSoft}`, +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 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: "none", display: "inline-flex", alignItems: "center", gap: 6, }; -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/app/[workspace]/project/[projectId]/(home)/product/page.tsx b/app/[workspace]/project/[projectId]/(home)/product/page.tsx index 27154694..4899bb83 100644 --- a/app/[workspace]/project/[projectId]/(home)/product/page.tsx +++ b/app/[workspace]/project/[projectId]/(home)/product/page.tsx @@ -2,29 +2,43 @@ import { useEffect, useState } from "react"; import { useParams } from "next/navigation"; -import { Loader2, AlertCircle, ChevronDown, ChevronRight, Box } from "lucide-react"; +import { Loader2, AlertCircle, ChevronDown, ChevronRight, Box, Server, 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"; /** - * Product tab — IDE-style. + * Product tab — the build surface. * - * Reads codebases from the shared /anatomy endpoint. Left column is - * a stack of expandable codebase tiles, each with its own inline - * Gitea file tree. Clicking a file previews its content on the right. + * 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. + * + * Right pane swaps between three view kinds based on the active + * selection: "devContainer", "file", or "empty". */ +type Selection = + | { type: "devContainer"; uuid: string } + | { type: "file"; codebaseId: string; path: string } + | null; + export default function ProductTab() { const params = useParams(); const projectId = params.projectId as string; const { anatomy, loading, error } = useAnatomy(projectId); - const codebases = anatomy?.codebases ?? null; - const reason = anatomy?.codebasesReason; + 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 [expanded, setExpanded] = useState>(new Set()); - const [selectedFile, setSelectedFile] = useState<{ codebaseId: string; path: string } | null>(null); + const [selection, setSelection] = useState(null); // Auto-expand the first codebase whenever anatomy lands useEffect(() => { @@ -33,9 +47,9 @@ export default function ProductTab() { } }, [codebases]); - // Reset selection when project changes + // Reset on project change useEffect(() => { - setSelectedFile(null); + setSelection(null); setExpanded(new Set()); }, [projectId]); @@ -48,25 +62,50 @@ export default function ProductTab() { }); }; - const showLoading = loading && !codebases; - const showError = !!error; + const showLoading = loading && !anatomy; + const showError = !!error; return (
- {/* ── Left: codebases column ── */} + {/* ── Left: workspace + codebases ── */}
-

Codebases

+ {/* Workspace section */} +

Workspace

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

Codebases

+
{showError && ( - - {error} - + {error} )} {codebases && codebases.length === 0 && ( @@ -87,10 +126,10 @@ export default function ProductTab() { > {isOpen - ? - : } + ? + : } - +
{cb.label}
{cb.hint &&
{cb.hint}
} @@ -102,10 +141,12 @@ export default function ProductTab() { projectId={projectId} rootPath={cb.path} selectedPath={ - selectedFile?.codebaseId === cb.id ? selectedFile.path : undefined + selection?.type === "file" && selection.codebaseId === cb.id + ? selection.path + : undefined } onSelectFile={(p) => - setSelectedFile({ codebaseId: cb.id, path: p }) + setSelection({ type: "file", codebaseId: cb.id, path: p }) } />
@@ -116,16 +157,19 @@ export default function ProductTab() {
- {/* ── Right: file preview ── */} + {/* ── Right: contextual preview ── */}
@@ -133,27 +177,50 @@ export default function ProductTab() { ); } +// ────────────────────────────────────────────────── + +function previewHeading(s: Selection): string { + if (!s) return "Preview"; + if (s.type === "devContainer") return "Preview · Dev container"; + return `Preview · ${shortPath(s.path)}`; +} function shortPath(p: string) { const parts = p.split("/"); if (parts.length <= 2) return p; return ".../" + parts.slice(-2).join("/"); } +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 Inline({ children }: { children: React.ReactNode }) { return (
{children}
); } -// ────────────────────────────────────────────────────────────────────── -// Styles -// ────────────────────────────────────────────────────────────────────── +function Empty({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +// ────────────────────────────────────────────────── const INK = { ink: "#1a1a1a", @@ -170,7 +237,6 @@ const pageWrap: React.CSSProperties = { fontFamily: INK.fontSans, color: INK.ink, }; - const grid: React.CSSProperties = { display: "grid", gridTemplateColumns: "minmax(280px, 360px) minmax(0, 1fr)", @@ -179,88 +245,47 @@ const grid: React.CSSProperties = { margin: "0 auto", alignItems: "stretch", }; - const leftCol: React.CSSProperties = { - minWidth: 0, - display: "flex", - flexDirection: "column", + minWidth: 0, display: "flex", flexDirection: "column", }; - const rightCol: React.CSSProperties = { - minWidth: 0, - display: "flex", - flexDirection: "column", + minWidth: 0, display: "flex", flexDirection: "column", }; - const heading: React.CSSProperties = { - fontSize: "0.72rem", - fontWeight: 600, - letterSpacing: "0.12em", - textTransform: "uppercase", - color: INK.muted, - margin: "0 0 14px", + 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, + display: "flex", flexDirection: "column", gap: 10, +}; +const flatTile: React.CSSProperties = { + display: "flex", alignItems: "center", gap: 10, + width: "100%", padding: "12px 14px", + background: INK.cardBg, border: `1px solid ${INK.borderSoft}`, borderRadius: 10, + cursor: "pointer", font: "inherit", color: "inherit", + transition: "border-color 0.12s, background 0.12s, box-shadow 0.12s", }; - const codebaseTile: React.CSSProperties = { - background: INK.cardBg, - border: `1px solid ${INK.borderSoft}`, - borderRadius: 10, - overflow: "hidden", + background: INK.cardBg, border: `1px solid ${INK.borderSoft}`, borderRadius: 10, overflow: "hidden", }; - const tileHeader: React.CSSProperties = { - display: "flex", - alignItems: "center", - gap: 8, - width: "100%", - padding: "12px 14px", - background: "transparent", - border: "none", - borderBottom: `1px solid transparent`, - cursor: "pointer", - font: "inherit", - color: "inherit", + display: "flex", alignItems: "center", gap: 8, width: "100%", + padding: "12px 14px", background: "transparent", border: "none", + cursor: "pointer", font: "inherit", color: "inherit", }; - const tileLabel: React.CSSProperties = { - fontSize: "0.85rem", - fontWeight: 600, - color: INK.ink, - marginBottom: 2, + fontSize: "0.85rem", fontWeight: 600, color: INK.ink, marginBottom: 2, }; - const tileHint: React.CSSProperties = { - fontSize: "0.74rem", - color: INK.mid, - lineHeight: 1.4, + fontSize: "0.74rem", color: INK.mid, lineHeight: 1.4, }; - const tileBody: React.CSSProperties = { - padding: "8px 10px 12px", - borderTop: `1px solid ${INK.borderSoft}`, + padding: "8px 10px 12px", borderTop: `1px solid ${INK.borderSoft}`, }; - const chevronCell: React.CSSProperties = { - width: 14, - display: "inline-flex", - alignItems: "center", - justifyContent: "center", - flexShrink: 0, + width: 14, display: "inline-flex", alignItems: "center", justifyContent: "center", flexShrink: 0, }; - const previewPanel: React.CSSProperties = { - background: INK.cardBg, - border: `1px solid ${INK.border}`, - borderRadius: 10, - padding: 16, - flex: 1, - minHeight: 0, - display: "flex", - flexDirection: "column", + background: INK.cardBg, border: `1px solid ${INK.border}`, borderRadius: 10, + padding: 16, flex: 1, minHeight: 0, display: "flex", flexDirection: "column", }; diff --git a/app/api/projects/[projectId]/anatomy/route.ts b/app/api/projects/[projectId]/anatomy/route.ts index 4e60e45e..e828a1fb 100644 --- a/app/api/projects/[projectId]/anatomy/route.ts +++ b/app/api/projects/[projectId]/anatomy/route.ts @@ -50,6 +50,13 @@ interface DevService { status?: string; } +/** Dev container = the vibn-dev-* Coolify service this project edits in. */ +interface DevContainer { + uuid: string; + name: string; + status?: string; +} + interface PreviewUrl { id: string; name: string; @@ -68,6 +75,9 @@ interface Anatomy { project: { id: string; name: string; gitea?: string; coolifyProjectUuid?: string }; codebases: Codebase[]; codebasesReason?: "no_repo" | "empty_repo"; + product: { + devContainers: DevContainer[]; + }; hosting: { production: ProductionApp[]; services: DevService[]; @@ -188,22 +198,22 @@ async function loadProductionApps(giteaRepo: string | undefined): Promise { +/** Returns ALL services in the Coolify project. Caller splits dev + * containers from deployed services by name prefix. */ +async function loadAllServices(coolifyProjectUuid: string | undefined): Promise { if (!coolifyProjectUuid) return []; try { - const services = await listServicesInProject(coolifyProjectUuid); - return services.map((s: CoolifyService) => ({ - uuid: s.uuid, - name: s.name, - serviceType: s.service_type, - status: s.status, - })); + return await listServicesInProject(coolifyProjectUuid); } catch (err) { console.error("[anatomy] listServicesInProject failed:", err); return []; } } +function isDevContainer(svc: CoolifyService): boolean { + return svc.name.startsWith("vibn-dev-"); +} + async function loadPreviewUrls(projectId: string): Promise { try { const rows = await query<{ @@ -291,7 +301,7 @@ export async function GET( "Project"; // Run the slow bits in parallel - const [codebasesResult, production, services, previews] = await Promise.all([ + const [codebasesResult, production, allServices, previews] = await Promise.all([ giteaRepo ? discoverCodebases(giteaRepo).catch(err => { console.error("[anatomy] discoverCodebases failed:", err); @@ -299,10 +309,27 @@ export async function GET( }) : Promise.resolve({ codebases: [] as Codebase[], reason: undefined as undefined }), loadProductionApps(giteaRepo), - loadDevServices(coolifyProjectUuid), + loadAllServices(coolifyProjectUuid), loadPreviewUrls(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, + }); + } + } + const codebasesReason: "no_repo" | "empty_repo" | undefined = !giteaRepo ? "no_repo" : codebasesResult.reason; @@ -316,9 +343,10 @@ export async function GET( }, codebases: codebasesResult.codebases, codebasesReason, + product: { devContainers }, hosting: { production, - services, + services: deployedServices, previewUrls: previews, domains: dedupeDomains(production, previews), }, diff --git a/components/project/dev-container-detail.tsx b/components/project/dev-container-detail.tsx new file mode 100644 index 00000000..5c2d77c0 --- /dev/null +++ b/components/project/dev-container-detail.tsx @@ -0,0 +1,187 @@ +"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/use-anatomy.ts b/components/project/use-anatomy.ts index 865d6dc7..b2d3ba7c 100644 --- a/components/project/use-anatomy.ts +++ b/components/project/use-anatomy.ts @@ -12,6 +12,9 @@ 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;