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:
2026-04-28 19:36:35 -07:00
parent 3db7191146
commit 307c3ca858
7 changed files with 678 additions and 621 deletions

View File

@@ -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",
};

View File

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

View File

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