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:
187
components/project/dev-container-detail.tsx
Normal file
187
components/project/dev-container-detail.tsx
Normal 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",
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user