feat(project): unify Product+Hosting around code/images and live/previews
Anatomy + UI rewrite — locked the conceptual model after user feedback:
Product = "what makes up the thing you're shipping":
- Codebases (Gitea repos)
- Images (Coolify services backed by upstream Docker images: Twenty
CRM, n8n, etc.)
- Dev containers no longer surface here. The vibn-dev-* container is
the AI's workshop, not a product surface; previews it serves still
appear under Hosting → Previews.
Hosting = "where it lives + how it gets there", unified:
- Live: every running endpoint as one list. Each item carries a
source badge ("repo" | "image"), status dot, attached domain, and
last-build summary inline. No separate Build, Domains or Services
categories — those are properties on each Live item.
- Previews: dev container preview URLs (unchanged).
Anatomy endpoint reshaped accordingly:
- product.{codebases, images}
- hosting.{live, previews} (was production/services/previewUrls/domains)
- lastBuild summary fetched per repo-app via listApplicationDeployments
in parallel.
ProjectStagePill rewired to derive Live/Down/Building from hosting.live
+ hosting.previews. dev-container-detail.tsx removed.
services.* MCP tools added so AI agents can manage Coolify services
(Twenty CRM, n8n, …) the same way they manage apps:
- services.list, services.get
- services.start, services.stop
- services.envs.list, services.envs.upsert
All tenant-scoped via getServiceInWorkspace + getOwnedCoolifyProjectUuids.
vibn-dev-* containers stay hidden from services.list.
Made-with: Cursor
This commit is contained in:
@@ -1,187 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Right-panel detail view for a vibn-dev container.
|
||||
* Today: shows status, dev servers running inside it, and active
|
||||
* preview URLs. Future: tail container logs, restart button.
|
||||
*/
|
||||
|
||||
import { Server, ExternalLink, CircleDot, Zap } from "lucide-react";
|
||||
import type { Anatomy } from "./use-anatomy";
|
||||
|
||||
interface DevContainerDetailProps {
|
||||
container: Anatomy["product"]["devContainers"][number];
|
||||
previewUrls: Anatomy["hosting"]["previewUrls"];
|
||||
}
|
||||
|
||||
export function DevContainerDetail({ container, previewUrls }: DevContainerDetailProps) {
|
||||
const statusColor = colorForStatus(container.status);
|
||||
|
||||
return (
|
||||
<div style={wrap}>
|
||||
<div style={statusRow}>
|
||||
<Server size={14} style={{ color: INK.mid }} />
|
||||
<span style={{ flex: 1, color: INK.ink, fontSize: "0.85rem" }}>{container.name}</span>
|
||||
<span style={statusPill}>
|
||||
<CircleDot size={9} style={{ color: statusColor }} />
|
||||
<span style={{ fontSize: "0.74rem", color: INK.mid }}>{container.status ?? "unknown"}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Section title="Active dev servers">
|
||||
{previewUrls.length === 0 ? (
|
||||
<Empty
|
||||
message="No dev servers running."
|
||||
hint="Ask Vibn to start one — `npm run dev`, `flask run`, etc."
|
||||
/>
|
||||
) : (
|
||||
previewUrls.map(p => (
|
||||
<Row
|
||||
key={p.id}
|
||||
icon={Zap}
|
||||
title={`${p.name} :${p.port}`}
|
||||
subtitle={`${p.state}`}
|
||||
href={p.url}
|
||||
hrefLabel={hostOf(p.url)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<section style={sectionWrap}>
|
||||
<header style={sectionHeader}>{title}</header>
|
||||
<div>{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({
|
||||
icon: Icon, title, subtitle, href, hrefLabel,
|
||||
}: {
|
||||
icon: React.ComponentType<{ size?: number; style?: React.CSSProperties }>;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
href?: string;
|
||||
hrefLabel?: string;
|
||||
}) {
|
||||
return (
|
||||
<div style={rowStyle}>
|
||||
<Icon size={13} style={{ color: INK.mid, flexShrink: 0 }} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: "0.85rem", fontWeight: 600, color: INK.ink }}>{title}</div>
|
||||
{subtitle && <div style={{ fontSize: "0.74rem", color: INK.mid }}>{subtitle}</div>}
|
||||
</div>
|
||||
{href && (
|
||||
<a href={href} target="_blank" rel="noreferrer" style={openLink}>
|
||||
<ExternalLink size={11} /> {hrefLabel ?? "open"}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Empty({ message, hint }: { message: string; hint?: string }) {
|
||||
return (
|
||||
<div style={emptyWrap}>
|
||||
<div style={emptyMsg}>{message}</div>
|
||||
{hint && <div style={emptyHint}>{hint}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function colorForStatus(s?: string) {
|
||||
if (!s) return "#a09a90";
|
||||
if (/running|healthy/i.test(s)) return "#2e7d32";
|
||||
if (/starting|deploying/i.test(s)) return "#d4a04a";
|
||||
if (/exit|fail|unhealthy/i.test(s)) return "#c5392b";
|
||||
return "#a09a90";
|
||||
}
|
||||
|
||||
function hostOf(url: string) {
|
||||
try { return new URL(url).host; } catch { return url; }
|
||||
}
|
||||
|
||||
const INK = {
|
||||
ink: "#1a1a1a",
|
||||
mid: "#5f5e5a",
|
||||
muted: "#a09a90",
|
||||
border: "#e8e4dc",
|
||||
borderSoft: "#efebe1",
|
||||
} as const;
|
||||
|
||||
const wrap: React.CSSProperties = {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 14,
|
||||
margin: "-4px -4px",
|
||||
};
|
||||
const statusRow: React.CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
padding: "12px 14px",
|
||||
border: `1px solid ${INK.borderSoft}`,
|
||||
borderRadius: 8,
|
||||
};
|
||||
const statusPill: React.CSSProperties = {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 5,
|
||||
flexShrink: 0,
|
||||
};
|
||||
const sectionWrap: React.CSSProperties = {
|
||||
border: `1px solid ${INK.borderSoft}`,
|
||||
borderRadius: 8,
|
||||
overflow: "hidden",
|
||||
};
|
||||
const sectionHeader: React.CSSProperties = {
|
||||
padding: "10px 14px",
|
||||
fontSize: "0.72rem",
|
||||
fontWeight: 600,
|
||||
letterSpacing: "0.06em",
|
||||
textTransform: "uppercase",
|
||||
color: INK.mid,
|
||||
borderBottom: `1px solid ${INK.borderSoft}`,
|
||||
};
|
||||
const rowStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
padding: "10px 14px",
|
||||
borderBottom: `1px solid ${INK.borderSoft}`,
|
||||
};
|
||||
const openLink: React.CSSProperties = {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 5,
|
||||
fontSize: "0.76rem",
|
||||
color: INK.mid,
|
||||
textDecoration: "none",
|
||||
border: `1px solid ${INK.borderSoft}`,
|
||||
borderRadius: 6,
|
||||
padding: "3px 8px",
|
||||
flexShrink: 0,
|
||||
};
|
||||
const emptyWrap: React.CSSProperties = {
|
||||
padding: "16px 14px",
|
||||
textAlign: "center",
|
||||
};
|
||||
const emptyMsg: React.CSSProperties = {
|
||||
fontSize: "0.82rem",
|
||||
color: INK.mid,
|
||||
marginBottom: 4,
|
||||
};
|
||||
const emptyHint: React.CSSProperties = {
|
||||
fontSize: "0.74rem",
|
||||
color: INK.muted,
|
||||
fontStyle: "italic",
|
||||
};
|
||||
@@ -26,13 +26,12 @@ export function ProjectStagePill({ projectId, fallbackStage }: ProjectStagePillP
|
||||
|
||||
if (loading && !anatomy) return <Pill {...PRESETS[fallbackStage]} />;
|
||||
|
||||
const prod = anatomy?.hosting.production ?? [];
|
||||
const services = anatomy?.hosting.services ?? [];
|
||||
const previews = anatomy?.hosting.previewUrls ?? [];
|
||||
const live = anatomy?.hosting.live ?? [];
|
||||
const previews = anatomy?.hosting.previews ?? [];
|
||||
|
||||
const anyRunning = prod.some(p => /running|healthy/i.test(p.status));
|
||||
const anyFailed = prod.some(p => /failed|exited|unhealthy/i.test(p.status));
|
||||
const buildingNow = !anyRunning && (services.length > 0 || previews.length > 0);
|
||||
const anyRunning = live.some(l => /running|healthy/i.test(l.status));
|
||||
const anyFailed = live.some(l => /failed|exited|unhealthy/i.test(l.status));
|
||||
const buildingNow = !anyRunning && (live.length > 0 || previews.length > 0);
|
||||
|
||||
if (anyFailed) return <Pill label="Down" color="#c5392b" bg="#c5392b14" />;
|
||||
if (anyRunning) return <Pill label="Live" color="#2e7d32" bg="#2e7d3210" />;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user