feat(project): split dev containers into Product; convert Hosting to tile-rail

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
This commit is contained in:
2026-04-28 18:54:19 -07:00
parent ba69a78a5f
commit 3db7191146
5 changed files with 678 additions and 297 deletions

View File

@@ -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 <Center><Loader2 size={14} className="animate-spin" /> Loading</Center>;
}
if (error) {
return <Center><AlertCircle size={14} /> {error}</Center>;
}
if (!anatomy) return null;
const [selection, setSelection] = useState<Selection>(null);
const { production, services, previewUrls, domains } = anatomy.hosting;
const hasAnything =
production.length + services.length + previewUrls.length + domains.length > 0;
const showLoading = loading && !anatomy;
return (
<div style={pageWrap}>
<div style={inner}>
{!hasAnything && <NothingDeployedBanner anatomy={anatomy} />}
<div style={grid}>
{/* ── Left rail ── */}
<section style={leftCol}>
{showLoading && (
<Inline><Loader2 size={13} className="animate-spin" /> Loading</Inline>
)}
{error && (
<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."
>
{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 })}
/>
))}
</RailGroup>
<Section icon={Cloud} title="Production" count={production.length}>
{production.length === 0 ? (
<Empty
message={anatomy.project.gitea
? "No Coolify app deploys this repo yet."
: "No Gitea repo connected, so nothing to deploy."}
hint={anatomy.project.gitea
? `Looking for an app whose repo matches ${anatomy.project.gitea}.`
: undefined}
/>
) : production.map(app => (
<Row
key={app.uuid}
title={app.name}
subtitle={[app.branch, app.buildPack].filter(Boolean).join(" · ") || "—"}
statusDot={statusColor(app.status)}
statusLabel={app.status}
href={app.fqdn ? hrefForFqdn(app.fqdn) : undefined}
hrefLabel={app.fqdn ? primaryHost(app.fqdn) : undefined}
/>
))}
</Section>
<RailGroup
title="Services"
count={anatomy.hosting.services.length}
emptyHint="Self-hosted apps (Twenty, n8n, Plausible…) appear 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>
<Section icon={Server} title="Dev services" count={services.length}>
{services.length === 0 ? (
<Empty
message="No dev services running for this project."
hint="Path B containers (vibn-dev) appear here once provisioned."
/>
) : services.map(svc => (
<Row
key={svc.uuid}
title={svc.name}
subtitle={svc.serviceType ?? "service"}
statusDot={statusColor(svc.status ?? "unknown")}
statusLabel={svc.status ?? "unknown"}
/>
))}
</Section>
<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>
<Section icon={Zap} title="Preview URLs" count={previewUrls.length}>
{previewUrls.length === 0 ? (
<Empty
message="No active preview URLs."
hint="Start a dev server from chat to expose one on *.preview.vibnai.com."
/>
) : previewUrls.map(p => (
<Row
key={p.id}
title={`${p.name} :${p.port}`}
subtitle={`Started ${formatRelative(p.startedAt)}`}
statusDot={p.state === "running" ? "#2e7d32" : "#a09a90"}
statusLabel={p.state}
href={p.url}
hrefLabel={hostOf(p.url)}
/>
))}
</Section>
<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 })}
/>
))}
</RailGroup>
</>
)}
</section>
<Section icon={Globe} title="Domains" count={domains.length}>
{domains.length === 0 ? (
<Empty message="No custom domains attached yet." />
) : domains.map(d => (
<Row
key={d.host}
title={d.host}
subtitle={d.source === "production" ? "Production" : "Preview"}
href={`https://${d.host}`}
hrefLabel="open"
/>
))}
</Section>
{/* ── Right pane ── */}
<aside style={rightCol}>
<h3 style={heading}>{paneHeading(selection, anatomy)}</h3>
<div style={panel}>
{anatomy && selection
? <Detail selection={selection} anatomy={anatomy} />
: <Empty>Pick something on the left to see its details.</Empty>}
</div>
</aside>
</div>
</div>
);
}
// ──────────────────────────────────────────────────────────────────────
// 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 (
<div style={bannerBox}>
<div style={bannerTitle}>Nothing is deployed for this project</div>
<div style={bannerBody}>{reason}</div>
</div>
);
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>;
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="URL"
value={primaryHost(app.fqdn)}
href={`https://${primaryHost(app.fqdn)}`}
/>
)}
</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 ?? "—"} />
</DetailLayout>
);
}
if (selection.kind === "preview") {
const p = anatomy.hosting.previewUrls.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="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 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 (
<section style={sectionWrap}>
<header style={sectionHeader}>
<Icon size={14} style={{ color: INK.mid }} />
<span style={sectionTitle}>{title}</span>
<div style={railGroup}>
<header style={railGroupHeader}>
<span style={railGroupTitle}>{title}</span>
<span style={countPill}>{count}</span>
</header>
<div style={sectionBody}>{children}</div>
</section>
{count === 0 ? (
<div style={railEmpty}>{emptyHint}</div>
) : (
<div style={railItems}>{children}</div>
)}
</div>
);
}
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 (
<div style={rowWrap}>
<div style={{ minWidth: 0, flex: 1 }}>
<div style={rowTitle}>{title}</div>
{subtitle && <div style={rowSubtitle}>{subtitle}</div>}
<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>
{statusDot && (
<span style={statusPill}>
<CircleDot size={9} style={{ color: statusDot }} />
<span style={{ color: INK.mid, fontSize: "0.74rem" }}>{statusLabel}</span>
</span>
)}
{href && (
<a href={href} target="_blank" rel="noreferrer" style={openLink}>
<ExternalLink size={12} /> {hrefLabel ?? "open"}
</a>
)}
</div>
{dot && <CircleDot size={9} style={{ color: dot, flexShrink: 0 }} />}
</button>
);
}
function Empty({ message, hint }: { message: string; hint?: string }) {
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;
}) {
return (
<div style={emptyWrap}>
<div style={emptyMsg}>{message}</div>
{hint && <div style={emptyHint}>{hint}</div>}
<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} <ExternalLink size={11} />
</a>
) : value}
</span>
</div>
);
}
function Center({ children }: { children: React.ReactNode }) {
function Inline({ children }: { children: React.ReactNode }) {
return (
<div style={{
padding: "60px 20px", textAlign: "center", color: INK.mid,
fontSize: "0.88rem", display: "flex", justifyContent: "center", gap: 8, alignItems: "center",
}}>{children}</div>
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}
</div>
);
}
// ──────────────────────────────────────────────────────────────────────
function Empty({ children }: { children: React.ReactNode }) {
return (
<div style={{
flex: 1, display: "flex", alignItems: "center", justifyContent: "center",
color: INK.mid, fontSize: "0.85rem", padding: "32px 16px", textAlign: "center",
}}>
{children}
</div>
);
}
// ──────────────────────────────────────────────────
// 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" };

View File

@@ -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<Set<string>>(new Set());
const [selectedFile, setSelectedFile] = useState<{ codebaseId: string; path: string } | null>(null);
const [selection, setSelection] = useState<Selection>(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 (
<div style={pageWrap}>
<div style={grid}>
{/* ── Left: codebases column ── */}
{/* ── Left: workspace + codebases ── */}
<section style={leftCol}>
<h3 style={heading}>Codebases</h3>
{/* Workspace section */}
<h3 style={heading}>Workspace</h3>
<div style={stack}>
{showLoading && (
<Inline>
<Loader2 size={13} className="animate-spin" /> Loading
</Inline>
<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>
{/* Codebases section */}
<h3 style={{ ...heading, marginTop: 24 }}>Codebases</h3>
<div style={stack}>
{showError && (
<Inline>
<AlertCircle size={13} /> {error}
</Inline>
<Inline><AlertCircle size={13} /> {error}</Inline>
)}
{codebases && codebases.length === 0 && (
<Inline>
@@ -87,10 +126,10 @@ export default function ProductTab() {
>
<span style={chevronCell}>
{isOpen
? <ChevronDown size={13} style={{ color: "#5f5e5a" }} />
: <ChevronRight size={13} style={{ color: "#5f5e5a" }} />}
? <ChevronDown size={13} style={{ color: INK.mid }} />
: <ChevronRight size={13} style={{ color: INK.mid }} />}
</span>
<Box size={13} style={{ color: "#5f5e5a", flexShrink: 0 }} />
<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>}
@@ -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 })
}
/>
</div>
@@ -116,16 +157,19 @@ export default function ProductTab() {
</div>
</section>
{/* ── Right: file preview ── */}
{/* ── Right: contextual preview ── */}
<aside style={rightCol}>
<h3 style={heading}>
{selectedFile ? `Preview · ${shortPath(selectedFile.path)}` : "Preview"}
</h3>
<h3 style={heading}>{previewHeading(selection)}</h3>
<div style={previewPanel}>
<GiteaFileViewer
projectId={projectId}
path={selectedFile?.path ?? null}
/>
{selection?.type === "devContainer" && devContainer && (
<DevContainerDetail container={devContainer} previewUrls={previewUrls} />
)}
{selection?.type === "file" && (
<GiteaFileViewer projectId={projectId} path={selection.path} />
)}
{!selection && (
<Empty>Pick a codebase file or the dev container on the left.</Empty>
)}
</div>
</aside>
</div>
@@ -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 (
<div style={{
display: "flex", alignItems: "center", gap: 8,
padding: "12px 14px", fontSize: "0.82rem", color: "#5f5e5a",
background: "#fff", border: "1px solid #efebe1", borderRadius: 8,
padding: "12px 14px", fontSize: "0.82rem", color: INK.mid,
background: INK.cardBg, border: `1px solid ${INK.borderSoft}`, borderRadius: 8,
}}>
{children}
</div>
);
}
// ──────────────────────────────────────────────────────────────────────
// Styles
// ──────────────────────────────────────────────────────────────────────
function Empty({ children }: { children: React.ReactNode }) {
return (
<div style={{
flex: 1, display: "flex", alignItems: "center", justifyContent: "center",
color: INK.mid, fontSize: "0.85rem", padding: "32px 16px", textAlign: "center",
}}>
{children}
</div>
);
}
// ──────────────────────────────────────────────────
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",
};

View File

@@ -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<Produc
}
}
async function loadDevServices(coolifyProjectUuid: string | undefined): Promise<DevService[]> {
/** 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[]> {
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<PreviewUrl[]> {
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),
},

View File

@@ -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 (
<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

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