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:
@@ -3,28 +3,27 @@
|
||||
import { useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import {
|
||||
Loader2, AlertCircle, ExternalLink, Globe, Server,
|
||||
Cloud, Zap, CircleDot,
|
||||
Loader2, AlertCircle, ExternalLink, Cloud, Container, Zap, CircleDot,
|
||||
} from "lucide-react";
|
||||
import { useAnatomy, type Anatomy } from "@/components/project/use-anatomy";
|
||||
|
||||
/**
|
||||
* Hosting tab — runtime + reachability surface.
|
||||
* Hosting tab — runtime + reachability, unified.
|
||||
*
|
||||
* 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.)
|
||||
* Two sub-areas in the left rail:
|
||||
* - Live = every running endpoint (repo-built apps + image-based
|
||||
* services). Each item shows its source badge, a status
|
||||
* dot, attached domain, and last-build time inline.
|
||||
* - Previews = active dev container preview URLs.
|
||||
*
|
||||
* No separate Build, Domains or Services categories — those concepts
|
||||
* live as properties on each Live item (build = lastBuild, domain =
|
||||
* fqdn/domains[]).
|
||||
*/
|
||||
|
||||
type Selection =
|
||||
| { kind: "production"; uuid: string }
|
||||
| { kind: "service"; uuid: string }
|
||||
| { kind: "preview"; id: string }
|
||||
| { kind: "domain"; host: string }
|
||||
| { kind: "live"; uuid: string }
|
||||
| { kind: "preview"; id: string }
|
||||
| null;
|
||||
|
||||
export default function HostingTab() {
|
||||
@@ -44,80 +43,87 @@ export default function HostingTab() {
|
||||
{showLoading && (
|
||||
<Inline><Loader2 size={13} className="animate-spin" /> Loading…</Inline>
|
||||
)}
|
||||
{error && (
|
||||
{error && !showLoading && (
|
||||
<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."
|
||||
title="Live"
|
||||
count={anatomy.hosting.live.length}
|
||||
emptyHint="Anything you deploy — your app or an image like Twenty CRM — shows up 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 })}
|
||||
/>
|
||||
))}
|
||||
{anatomy.hosting.live.map(item => {
|
||||
const active = selection?.kind === "live" && selection.uuid === item.uuid;
|
||||
const Icon = item.source === "repo" ? Cloud : Container;
|
||||
return (
|
||||
<button
|
||||
key={item.uuid}
|
||||
type="button"
|
||||
onClick={() => setSelection({ kind: "live", uuid: item.uuid })}
|
||||
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}>{item.name}</div>
|
||||
<div style={tileSubLine}>
|
||||
<span style={sourceBadge(item.source)}>
|
||||
{item.source === "repo" ? "repo" : "image"}
|
||||
</span>
|
||||
<span style={tileHint}>
|
||||
{item.fqdn ?? item.sourceLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div style={tileMetaLine}>
|
||||
{item.source === "repo" && item.lastBuild
|
||||
? `${item.lastBuild.status} · ${formatRelative(item.lastBuild.finishedAt)}`
|
||||
: item.source === "image" && item.sourceLabel}
|
||||
</div>
|
||||
</div>
|
||||
<CircleDot size={9} style={{ color: statusColor(item.status), flexShrink: 0 }} />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</RailGroup>
|
||||
|
||||
<RailGroup
|
||||
title="Services"
|
||||
count={anatomy.hosting.services.length}
|
||||
emptyHint="Self-hosted apps (Twenty, n8n, Plausible…) appear here."
|
||||
title="Previews"
|
||||
count={anatomy.hosting.previews.length}
|
||||
emptyHint="Dev servers started from chat get a temporary preview URL 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>
|
||||
|
||||
<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>
|
||||
|
||||
<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 })}
|
||||
/>
|
||||
))}
|
||||
{anatomy.hosting.previews.map(p => {
|
||||
const active = selection?.kind === "preview" && selection.id === p.id;
|
||||
return (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
onClick={() => setSelection({ kind: "preview", id: p.id })}
|
||||
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}
|
||||
>
|
||||
<Zap size={13} style={{ color: INK.mid, flexShrink: 0 }} />
|
||||
<div style={{ minWidth: 0, textAlign: "left", flex: 1 }}>
|
||||
<div style={tileLabel}>{p.name} · port {p.port}</div>
|
||||
<div style={tileHint}>{hostOf(p.url)}</div>
|
||||
</div>
|
||||
<CircleDot
|
||||
size={9}
|
||||
style={{ color: p.state === "running" ? "#2e7d32" : "#a09a90", flexShrink: 0 }}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</RailGroup>
|
||||
</>
|
||||
)}
|
||||
@@ -144,70 +150,52 @@ export default function HostingTab() {
|
||||
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>;
|
||||
if (selection.kind === "live") {
|
||||
const item = anatomy.hosting.live.find(l => l.uuid === selection.uuid);
|
||||
if (!item) return <Empty>This endpoint 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="Source" value={item.source === "repo" ? `Built from ${item.sourceLabel}` : `Image ${item.sourceLabel}`} />
|
||||
<DetailRow label="Status" value={item.status} dot={statusColor(item.status)} />
|
||||
{item.branch && <DetailRow label="Branch" value={item.branch} />}
|
||||
{item.buildPack && <DetailRow label="Pack" value={item.buildPack} />}
|
||||
{item.lastBuild && (
|
||||
<DetailRow
|
||||
label="URL"
|
||||
value={primaryHost(app.fqdn)}
|
||||
href={`https://${primaryHost(app.fqdn)}`}
|
||||
label="Last build"
|
||||
value={`${item.lastBuild.status}${item.lastBuild.finishedAt ? " · " + formatRelative(item.lastBuild.finishedAt) : ""}`}
|
||||
dot={statusColor(item.lastBuild.status)}
|
||||
/>
|
||||
)}
|
||||
</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 ?? "—"} />
|
||||
{item.domains.length === 0 && (
|
||||
<DetailRow label="Domain" value="— (no domain attached)" />
|
||||
)}
|
||||
{item.domains.map(d => (
|
||||
<DetailRow key={d} label="Domain" value={d} href={`https://${d}`} />
|
||||
))}
|
||||
</DetailLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (selection.kind === "preview") {
|
||||
const p = anatomy.hosting.previewUrls.find(p => p.id === selection.id);
|
||||
const p = anatomy.hosting.previews.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="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 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}`;
|
||||
if (!s || !a) return "Details";
|
||||
if (s.kind === "live") return `Details · ${a.hosting.live.find(x => x.uuid === s.uuid)?.name ?? "Endpoint"}`;
|
||||
if (s.kind === "preview") return `Details · ${a.hosting.previews.find(x => x.id === s.id)?.name ?? "Preview"}`;
|
||||
return "Details";
|
||||
}
|
||||
|
||||
@@ -218,10 +206,7 @@ function paneHeading(s: Selection, a: Anatomy | null): string {
|
||||
function RailGroup({
|
||||
title, count, emptyHint, children,
|
||||
}: {
|
||||
title: string;
|
||||
count: number;
|
||||
emptyHint: string;
|
||||
children: React.ReactNode;
|
||||
title: string; count: number; emptyHint: string; children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div style={railGroup}>
|
||||
@@ -238,47 +223,13 @@ function RailGroup({
|
||||
);
|
||||
}
|
||||
|
||||
function RailItem({
|
||||
icon: Icon, title, subtitle, statusColor: dot, active, onClick,
|
||||
}: {
|
||||
icon: React.ComponentType<{ size?: number; style?: React.CSSProperties }>;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
statusColor?: string;
|
||||
active?: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<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>
|
||||
{dot && <CircleDot size={9} style={{ color: dot, flexShrink: 0 }} />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}) {
|
||||
}: { label: string; value: string; dot?: string; href?: string }) {
|
||||
return (
|
||||
<div style={detailRow}>
|
||||
<span style={detailLabel}>{label}</span>
|
||||
@@ -321,21 +272,20 @@ function Empty({ children }: { children: React.ReactNode }) {
|
||||
// Helpers
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
function primaryHost(fqdn: string) {
|
||||
return fqdn.split(",")[0]?.trim().replace(/^https?:\/\//, "").replace(/\/$/, "") || fqdn;
|
||||
}
|
||||
function hostOf(url: string) {
|
||||
try { return new URL(url).host; } catch { return url; }
|
||||
}
|
||||
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("exit") || s.includes("fail") || s.includes("unhealthy")) return "#c5392b";
|
||||
const s = (status ?? "").toLowerCase();
|
||||
if (s.includes("running") || s.includes("healthy") || s.includes("success")) return "#2e7d32";
|
||||
if (s.includes("starting") || s.includes("deploying") || s.includes("queued") || s.includes("in_progress")) return "#d4a04a";
|
||||
if (s.includes("exit") || s.includes("fail") || s.includes("unhealthy") || s.includes("error")) return "#c5392b";
|
||||
return "#a09a90";
|
||||
}
|
||||
function formatRelative(iso: string) {
|
||||
function formatRelative(iso: string | undefined) {
|
||||
if (!iso) return "never";
|
||||
const ms = Date.now() - new Date(iso).getTime();
|
||||
if (Number.isNaN(ms)) return "—";
|
||||
const min = Math.floor(ms / 60_000);
|
||||
if (min < 1) return "just now";
|
||||
if (min < 60) return `${min}m ago`;
|
||||
@@ -343,6 +293,20 @@ function formatRelative(iso: string) {
|
||||
if (hr < 24) return `${hr}h ago`;
|
||||
return `${Math.floor(hr / 24)}d ago`;
|
||||
}
|
||||
function sourceBadge(source: "repo" | "image"): React.CSSProperties {
|
||||
const isRepo = source === "repo";
|
||||
return {
|
||||
fontSize: "0.62rem",
|
||||
fontWeight: 700,
|
||||
letterSpacing: "0.08em",
|
||||
textTransform: "uppercase",
|
||||
color: isRepo ? "#2e6d2e" : "#3b5a78",
|
||||
background: isRepo ? "#eaf3e8" : "#e9eff5",
|
||||
padding: "1px 6px",
|
||||
borderRadius: 4,
|
||||
flexShrink: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// Tokens
|
||||
@@ -381,9 +345,7 @@ const heading: React.CSSProperties = {
|
||||
fontSize: "0.72rem", fontWeight: 600, letterSpacing: "0.12em",
|
||||
textTransform: "uppercase", color: INK.muted, margin: "0 0 14px",
|
||||
};
|
||||
const railGroup: React.CSSProperties = {
|
||||
display: "flex", flexDirection: "column",
|
||||
};
|
||||
const railGroup: React.CSSProperties = { display: "flex", flexDirection: "column" };
|
||||
const railGroupHeader: React.CSSProperties = {
|
||||
display: "flex", alignItems: "center", justifyContent: "space-between",
|
||||
padding: "0 4px 8px",
|
||||
@@ -396,9 +358,7 @@ const countPill: React.CSSProperties = {
|
||||
fontSize: "0.7rem", fontWeight: 600, color: INK.mid,
|
||||
padding: "1px 7px", borderRadius: 999, background: "#f3eee4",
|
||||
};
|
||||
const railItems: React.CSSProperties = {
|
||||
display: "flex", flexDirection: "column", gap: 8,
|
||||
};
|
||||
const railItems: React.CSSProperties = { display: "flex", flexDirection: "column", gap: 8 };
|
||||
const railItem: React.CSSProperties = {
|
||||
display: "flex", alignItems: "center", gap: 10,
|
||||
width: "100%", padding: "10px 12px",
|
||||
@@ -414,8 +374,15 @@ const railEmpty: React.CSSProperties = {
|
||||
const tileLabel: React.CSSProperties = {
|
||||
fontSize: "0.85rem", fontWeight: 600, color: INK.ink, marginBottom: 2,
|
||||
};
|
||||
const tileSubLine: React.CSSProperties = {
|
||||
display: "flex", alignItems: "center", gap: 6, minWidth: 0,
|
||||
};
|
||||
const tileHint: React.CSSProperties = {
|
||||
fontSize: "0.74rem", color: INK.mid, lineHeight: 1.4,
|
||||
whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis",
|
||||
};
|
||||
const tileMetaLine: React.CSSProperties = {
|
||||
fontSize: "0.7rem", color: INK.muted, lineHeight: 1.4, marginTop: 2,
|
||||
};
|
||||
const panel: React.CSSProperties = {
|
||||
background: INK.cardBg, border: `1px solid ${INK.border}`, borderRadius: 10,
|
||||
|
||||
@@ -2,29 +2,30 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Loader2, AlertCircle, ChevronDown, ChevronRight, Box, Server, CircleDot } from "lucide-react";
|
||||
import {
|
||||
Loader2, AlertCircle, ChevronDown, ChevronRight,
|
||||
Box, Container, 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";
|
||||
import { useAnatomy, type Anatomy } from "@/components/project/use-anatomy";
|
||||
|
||||
/**
|
||||
* Product tab — the build surface.
|
||||
* Product tab — everything that makes up the thing being shipped.
|
||||
*
|
||||
* 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.
|
||||
* 1. Codebases — Gitea repos, each tile expands inline into a file
|
||||
* tree; clicking a file previews it on the right.
|
||||
* 2. Images — Coolify services backed by an upstream Docker image
|
||||
* (Twenty CRM, n8n…). Clicking shows image meta on the right.
|
||||
*
|
||||
* Right pane swaps between three view kinds based on the active
|
||||
* selection: "devContainer", "file", or "empty".
|
||||
* Dev containers do not appear here — they are the AI's workshop, not
|
||||
* part of the product surface.
|
||||
*/
|
||||
|
||||
type Selection =
|
||||
| { type: "devContainer"; uuid: string }
|
||||
| { type: "file"; codebaseId: string; path: string }
|
||||
| { type: "image"; uuid: string }
|
||||
| null;
|
||||
|
||||
export default function ProductTab() {
|
||||
@@ -32,22 +33,19 @@ export default function ProductTab() {
|
||||
const projectId = params.projectId as string;
|
||||
const { anatomy, loading, error } = useAnatomy(projectId);
|
||||
|
||||
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 codebases = anatomy?.product.codebases ?? null;
|
||||
const images = anatomy?.product.images ?? null;
|
||||
const reason = anatomy?.codebasesReason;
|
||||
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
const [selection, setSelection] = useState<Selection>(null);
|
||||
|
||||
// Auto-expand the first codebase whenever anatomy lands
|
||||
useEffect(() => {
|
||||
if (codebases && codebases[0]) {
|
||||
setExpanded(prev => (prev.size === 0 ? new Set([codebases[0].id]) : prev));
|
||||
}
|
||||
}, [codebases]);
|
||||
|
||||
// Reset on project change
|
||||
useEffect(() => {
|
||||
setSelection(null);
|
||||
setExpanded(new Set());
|
||||
@@ -63,112 +61,119 @@ export default function ProductTab() {
|
||||
};
|
||||
|
||||
const showLoading = loading && !anatomy;
|
||||
const showError = !!error;
|
||||
|
||||
return (
|
||||
<div style={pageWrap}>
|
||||
<div style={grid}>
|
||||
{/* ── Left: workspace + codebases ── */}
|
||||
{/* ── Left rail ── */}
|
||||
<section style={leftCol}>
|
||||
{/* Workspace section */}
|
||||
<h3 style={heading}>Workspace</h3>
|
||||
<div style={stack}>
|
||||
{showLoading && (
|
||||
<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>
|
||||
{showLoading && (
|
||||
<Inline><Loader2 size={13} className="animate-spin" /> Loading…</Inline>
|
||||
)}
|
||||
{error && !showLoading && (
|
||||
<Inline><AlertCircle size={13} /> {error}</Inline>
|
||||
)}
|
||||
|
||||
{/* Codebases section */}
|
||||
<h3 style={{ ...heading, marginTop: 24 }}>Codebases</h3>
|
||||
<div style={stack}>
|
||||
{showError && (
|
||||
<Inline><AlertCircle size={13} /> {error}</Inline>
|
||||
)}
|
||||
{codebases && codebases.length === 0 && (
|
||||
<Inline>
|
||||
{reason === "no_repo"
|
||||
? "No Gitea repo connected to this project yet."
|
||||
: "Repo is empty — push a first commit."}
|
||||
</Inline>
|
||||
)}
|
||||
{codebases?.map(cb => {
|
||||
const isOpen = expanded.has(cb.id);
|
||||
return (
|
||||
<article key={cb.id} style={codebaseTile}>
|
||||
{anatomy && (
|
||||
<>
|
||||
{/* Codebases */}
|
||||
<RailGroup title="Codebases" count={codebases?.length ?? 0}>
|
||||
{codebases && codebases.length === 0 && (
|
||||
<RailEmpty>
|
||||
{reason === "no_repo"
|
||||
? "No Gitea repo connected to this project yet."
|
||||
: "Repo is empty — push a first commit."}
|
||||
</RailEmpty>
|
||||
)}
|
||||
{codebases?.map(cb => {
|
||||
const isOpen = expanded.has(cb.id);
|
||||
return (
|
||||
<article key={cb.id} style={codebaseTile}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleCodebase(cb.id)}
|
||||
style={tileHeader}
|
||||
aria-expanded={isOpen}
|
||||
>
|
||||
<span style={chevronCell}>
|
||||
{isOpen
|
||||
? <ChevronDown size={13} style={{ color: INK.mid }} />
|
||||
: <ChevronRight size={13} style={{ color: INK.mid }} />}
|
||||
</span>
|
||||
<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>}
|
||||
</div>
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div style={tileBody}>
|
||||
<GiteaFileTree
|
||||
projectId={projectId}
|
||||
rootPath={cb.path}
|
||||
selectedPath={
|
||||
selection?.type === "file" && selection.codebaseId === cb.id
|
||||
? selection.path
|
||||
: undefined
|
||||
}
|
||||
onSelectFile={(p) =>
|
||||
setSelection({ type: "file", codebaseId: cb.id, path: p })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</RailGroup>
|
||||
|
||||
{/* Images */}
|
||||
<RailGroup title="Images" count={images?.length ?? 0}>
|
||||
{images && images.length === 0 && (
|
||||
<RailEmpty>
|
||||
Self-hosted apps (Twenty, n8n, Plausible…) you adopt as part of the product appear here.
|
||||
</RailEmpty>
|
||||
)}
|
||||
{images?.map(img => (
|
||||
<button
|
||||
key={img.uuid}
|
||||
type="button"
|
||||
onClick={() => toggleCodebase(cb.id)}
|
||||
style={tileHeader}
|
||||
aria-expanded={isOpen}
|
||||
onClick={() => setSelection({ type: "image", uuid: img.uuid })}
|
||||
style={{
|
||||
...flatTile,
|
||||
borderColor: selection?.type === "image" && selection.uuid === img.uuid ? INK.ink : INK.borderSoft,
|
||||
boxShadow: selection?.type === "image" && selection.uuid === img.uuid ? `0 0 0 1px ${INK.ink}` : "none",
|
||||
background: selection?.type === "image" && selection.uuid === img.uuid ? "#fffdf8" : INK.cardBg,
|
||||
}}
|
||||
aria-pressed={selection?.type === "image" && selection.uuid === img.uuid}
|
||||
>
|
||||
<span style={chevronCell}>
|
||||
{isOpen
|
||||
? <ChevronDown size={13} style={{ color: INK.mid }} />
|
||||
: <ChevronRight size={13} style={{ color: INK.mid }} />}
|
||||
</span>
|
||||
<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>}
|
||||
<Container size={13} style={{ color: INK.mid, flexShrink: 0 }} />
|
||||
<div style={{ minWidth: 0, textAlign: "left", flex: 1 }}>
|
||||
<div style={tileLabel}>{img.name}</div>
|
||||
<div style={tileHint}>
|
||||
{img.image}{img.version ? `:${img.version}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
{img.status && <CircleDot size={9} style={{ color: statusColor(img.status), flexShrink: 0 }} />}
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div style={tileBody}>
|
||||
<GiteaFileTree
|
||||
projectId={projectId}
|
||||
rootPath={cb.path}
|
||||
selectedPath={
|
||||
selection?.type === "file" && selection.codebaseId === cb.id
|
||||
? selection.path
|
||||
: undefined
|
||||
}
|
||||
onSelectFile={(p) =>
|
||||
setSelection({ type: "file", codebaseId: cb.id, path: p })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</RailGroup>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* ── Right: contextual preview ── */}
|
||||
{/* ── Right pane ── */}
|
||||
<aside style={rightCol}>
|
||||
<h3 style={heading}>{previewHeading(selection)}</h3>
|
||||
<div style={previewPanel}>
|
||||
{selection?.type === "devContainer" && devContainer && (
|
||||
<DevContainerDetail container={devContainer} previewUrls={previewUrls} />
|
||||
)}
|
||||
<h3 style={heading}>{paneHeading(selection)}</h3>
|
||||
<div style={panel}>
|
||||
{selection?.type === "file" && (
|
||||
<GiteaFileViewer projectId={projectId} path={selection.path} />
|
||||
)}
|
||||
{selection?.type === "image" && anatomy && (
|
||||
<ImageDetail uuid={selection.uuid} anatomy={anatomy} />
|
||||
)}
|
||||
{!selection && (
|
||||
<Empty>Pick a codebase file or the dev container on the left.</Empty>
|
||||
<Empty>Pick a codebase file or an image on the left.</Empty>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
@@ -177,24 +182,68 @@ export default function ProductTab() {
|
||||
);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// Image details (right pane)
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
function previewHeading(s: Selection): string {
|
||||
if (!s) return "Preview";
|
||||
if (s.type === "devContainer") return "Preview · Dev container";
|
||||
return `Preview · ${shortPath(s.path)}`;
|
||||
function ImageDetail({ uuid, anatomy }: { uuid: string; anatomy: Anatomy }) {
|
||||
const img = anatomy.product.images.find(i => i.uuid === uuid);
|
||||
if (!img) return <Empty>This image is no longer in the project.</Empty>;
|
||||
const live = anatomy.hosting.live.find(l => l.uuid === uuid);
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 1 }}>
|
||||
<DetailRow label="Image" value={img.image} />
|
||||
<DetailRow label="Version" value={img.version || "latest"} />
|
||||
<DetailRow label="Type" value={img.serviceType ?? "—"} />
|
||||
<DetailRow
|
||||
label="Status"
|
||||
value={img.status ?? "unknown"}
|
||||
dot={statusColor(img.status ?? "")}
|
||||
/>
|
||||
{live?.fqdn && (
|
||||
<DetailRow label="URL" value={live.fqdn} href={`https://${live.fqdn}`} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function shortPath(p: string) {
|
||||
const parts = p.split("/");
|
||||
if (parts.length <= 2) return p;
|
||||
return ".../" + parts.slice(-2).join("/");
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// Bits
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
function RailGroup({
|
||||
title, count, children,
|
||||
}: { title: string; count: number; children: React.ReactNode }) {
|
||||
return (
|
||||
<div style={railGroup}>
|
||||
<header style={railGroupHeader}>
|
||||
<span style={railGroupTitle}>{title}</span>
|
||||
<span style={countPill}>{count}</span>
|
||||
</header>
|
||||
<div style={railItems}>{children}</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 RailEmpty({ children }: { children: React.ReactNode }) {
|
||||
return <div style={railEmpty}>{children}</div>;
|
||||
}
|
||||
|
||||
function DetailRow({
|
||||
label, value, dot, href,
|
||||
}: { label: string; value: string; dot?: string; href?: string }) {
|
||||
return (
|
||||
<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}</a>
|
||||
) : value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Inline({ children }: { children: React.ReactNode }) {
|
||||
@@ -222,6 +271,28 @@ function Empty({ children }: { children: React.ReactNode }) {
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
function paneHeading(s: Selection): string {
|
||||
if (!s) return "Preview";
|
||||
if (s.type === "file") return `Preview · ${shortPath(s.path)}`;
|
||||
return "Image";
|
||||
}
|
||||
function shortPath(p: string) {
|
||||
const parts = p.split("/");
|
||||
if (parts.length <= 2) return p;
|
||||
return ".../" + parts.slice(-2).join("/");
|
||||
}
|
||||
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("exit") || s.includes("fail") || s.includes("unhealthy")) return "#c5392b";
|
||||
return "#a09a90";
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// Tokens
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
const INK = {
|
||||
ink: "#1a1a1a",
|
||||
mid: "#5f5e5a",
|
||||
@@ -246,7 +317,7 @@ const grid: React.CSSProperties = {
|
||||
alignItems: "stretch",
|
||||
};
|
||||
const leftCol: React.CSSProperties = {
|
||||
minWidth: 0, display: "flex", flexDirection: "column",
|
||||
minWidth: 0, display: "flex", flexDirection: "column", gap: 18,
|
||||
};
|
||||
const rightCol: React.CSSProperties = {
|
||||
minWidth: 0, display: "flex", flexDirection: "column",
|
||||
@@ -255,8 +326,24 @@ const heading: React.CSSProperties = {
|
||||
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,
|
||||
const railGroup: React.CSSProperties = { display: "flex", flexDirection: "column" };
|
||||
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 = {
|
||||
fontSize: "0.7rem", fontWeight: 600, color: INK.mid,
|
||||
padding: "1px 7px", borderRadius: 999, background: "#f3eee4",
|
||||
};
|
||||
const railItems: React.CSSProperties = { display: "flex", flexDirection: "column", gap: 10 };
|
||||
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 flatTile: React.CSSProperties = {
|
||||
display: "flex", alignItems: "center", gap: 10,
|
||||
@@ -276,16 +363,28 @@ const tileHeader: React.CSSProperties = {
|
||||
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 tileHint: React.CSSProperties = { fontSize: "0.74rem", color: INK.mid, lineHeight: 1.4 };
|
||||
const tileBody: React.CSSProperties = {
|
||||
padding: "8px 10px 12px", borderTop: `1px solid ${INK.borderSoft}`,
|
||||
};
|
||||
const chevronCell: React.CSSProperties = {
|
||||
width: 14, display: "inline-flex", alignItems: "center", justifyContent: "center", flexShrink: 0,
|
||||
};
|
||||
const previewPanel: React.CSSProperties = {
|
||||
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: "underline",
|
||||
};
|
||||
|
||||
@@ -79,6 +79,7 @@ import {
|
||||
createDockerImageApp,
|
||||
createDockerComposeApp,
|
||||
startService,
|
||||
stopService,
|
||||
getService,
|
||||
listAllServices,
|
||||
listServiceEnvs,
|
||||
@@ -392,6 +393,19 @@ export async function POST(request: Request) {
|
||||
case 'ship':
|
||||
return await toolShip(principal, params);
|
||||
|
||||
case 'services.list':
|
||||
return await toolServicesList(principal, params);
|
||||
case 'services.get':
|
||||
return await toolServicesGet(principal, params);
|
||||
case 'services.start':
|
||||
return await toolServicesStart(principal, params);
|
||||
case 'services.stop':
|
||||
return await toolServicesStop(principal, params);
|
||||
case 'services.envs.list':
|
||||
return await toolServicesEnvsList(principal, params);
|
||||
case 'services.envs.upsert':
|
||||
return await toolServicesEnvsUpsert(principal, params);
|
||||
|
||||
default:
|
||||
return NextResponse.json(
|
||||
{ error: `Unknown tool "${action}"` },
|
||||
@@ -1052,6 +1066,121 @@ async function toolAppsEnvsDelete(principal: Principal, params: Record<string, a
|
||||
return NextResponse.json({ result: { ok: true, key } });
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// services.* — Coolify Services (Twenty CRM, n8n, etc.)
|
||||
//
|
||||
// Services are upstream Docker images that Coolify pulls and runs as
|
||||
// docker-compose stacks. Distinct from apps (which build from Git).
|
||||
// All ops are tenant-scoped via the workspace's owned Coolify projects
|
||||
// — agents from one workspace cannot read or mutate another's services.
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
async function toolServicesList(principal: Principal, params: Record<string, any> = {}) {
|
||||
const projectUuid = requireCoolifyProject(principal);
|
||||
if (projectUuid instanceof NextResponse) return projectUuid;
|
||||
|
||||
// Mirror apps.list scoping: optional `projectId` narrows to a single
|
||||
// Vibn project's Coolify env; otherwise scan everything we own.
|
||||
const ownedUuids = await getOwnedCoolifyProjectUuids(principal.workspace);
|
||||
let target: string[];
|
||||
if (params.projectId) {
|
||||
const pUuid = await getProjectCoolifyUuid(String(params.projectId), principal.workspace);
|
||||
if (!pUuid) return NextResponse.json({ error: 'Project not found in this workspace' }, { status: 404 });
|
||||
target = [pUuid];
|
||||
} else {
|
||||
target = Array.from(ownedUuids);
|
||||
if (target.length === 0 && principal.workspace.coolify_project_uuid) {
|
||||
target = [principal.workspace.coolify_project_uuid];
|
||||
}
|
||||
}
|
||||
if (target.length === 0) return NextResponse.json({ result: [] });
|
||||
|
||||
const results = await Promise.allSettled(target.map(uuid => listServicesInProject(uuid)));
|
||||
const services = results.flatMap((r, i) =>
|
||||
r.status === 'fulfilled'
|
||||
? r.value
|
||||
// Hide vibn-dev-* dev containers from this surface — those are
|
||||
// the AI's own workshop, not part of the user's product.
|
||||
.filter(s => !s.name.startsWith('vibn-dev-'))
|
||||
.map(s => ({
|
||||
uuid: s.uuid,
|
||||
name: s.name,
|
||||
status: s.status ?? 'unknown',
|
||||
serviceType: s.service_type ?? null,
|
||||
coolifyProjectUuid: target[i],
|
||||
}))
|
||||
: []
|
||||
);
|
||||
return NextResponse.json({ result: services });
|
||||
}
|
||||
|
||||
async function toolServicesGet(principal: Principal, params: Record<string, any>) {
|
||||
const projectUuid = requireCoolifyProject(principal);
|
||||
if (projectUuid instanceof NextResponse) return projectUuid;
|
||||
const uuid = String(params.uuid ?? '').trim();
|
||||
if (!uuid) return NextResponse.json({ error: 'Param "uuid" is required' }, { status: 400 });
|
||||
|
||||
const ownedUuids = await getOwnedCoolifyProjectUuids(principal.workspace);
|
||||
const svc = await getServiceInWorkspace(uuid, ownedUuids);
|
||||
return NextResponse.json({ result: svc });
|
||||
}
|
||||
|
||||
async function toolServicesStart(principal: Principal, params: Record<string, any>) {
|
||||
const projectUuid = requireCoolifyProject(principal);
|
||||
if (projectUuid instanceof NextResponse) return projectUuid;
|
||||
const uuid = String(params.uuid ?? '').trim();
|
||||
if (!uuid) return NextResponse.json({ error: 'Param "uuid" is required' }, { status: 400 });
|
||||
|
||||
const ownedUuids = await getOwnedCoolifyProjectUuids(principal.workspace);
|
||||
await getServiceInWorkspace(uuid, ownedUuids);
|
||||
await startService(uuid);
|
||||
return NextResponse.json({ result: { ok: true, uuid, action: 'start' } });
|
||||
}
|
||||
|
||||
async function toolServicesStop(principal: Principal, params: Record<string, any>) {
|
||||
const projectUuid = requireCoolifyProject(principal);
|
||||
if (projectUuid instanceof NextResponse) return projectUuid;
|
||||
const uuid = String(params.uuid ?? '').trim();
|
||||
if (!uuid) return NextResponse.json({ error: 'Param "uuid" is required' }, { status: 400 });
|
||||
|
||||
const ownedUuids = await getOwnedCoolifyProjectUuids(principal.workspace);
|
||||
await getServiceInWorkspace(uuid, ownedUuids);
|
||||
await stopService(uuid);
|
||||
return NextResponse.json({ result: { ok: true, uuid, action: 'stop' } });
|
||||
}
|
||||
|
||||
async function toolServicesEnvsList(principal: Principal, params: Record<string, any>) {
|
||||
const projectUuid = requireCoolifyProject(principal);
|
||||
if (projectUuid instanceof NextResponse) return projectUuid;
|
||||
const uuid = String(params.uuid ?? '').trim();
|
||||
if (!uuid) return NextResponse.json({ error: 'Param "uuid" is required' }, { status: 400 });
|
||||
|
||||
const ownedUuids = await getOwnedCoolifyProjectUuids(principal.workspace);
|
||||
await getServiceInWorkspace(uuid, ownedUuids);
|
||||
const envs = await listServiceEnvs(uuid);
|
||||
return NextResponse.json({ result: envs });
|
||||
}
|
||||
|
||||
async function toolServicesEnvsUpsert(principal: Principal, params: Record<string, any>) {
|
||||
const projectUuid = requireCoolifyProject(principal);
|
||||
if (projectUuid instanceof NextResponse) return projectUuid;
|
||||
const uuid = String(params.uuid ?? '').trim();
|
||||
const key = typeof params.key === 'string' ? params.key : '';
|
||||
const value = typeof params.value === 'string' ? params.value : '';
|
||||
if (!uuid || !key) {
|
||||
return NextResponse.json({ error: 'Params "uuid" and "key" are required' }, { status: 400 });
|
||||
}
|
||||
const ownedUuids = await getOwnedCoolifyProjectUuids(principal.workspace);
|
||||
await getServiceInWorkspace(uuid, ownedUuids);
|
||||
const result = await upsertServiceEnv(uuid, {
|
||||
key,
|
||||
value,
|
||||
is_preview: !!params.is_preview,
|
||||
is_literal: !!params.is_literal,
|
||||
});
|
||||
return NextResponse.json({ result });
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// Phase 4: apps create/update/delete + domains
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
@@ -1,13 +1,26 @@
|
||||
/**
|
||||
* GET /api/projects/[projectId]/anatomy
|
||||
*
|
||||
* Returns the full anatomy of a project across the three tabs:
|
||||
* - codebases: discovered from Gitea (apps/* or repo root)
|
||||
* - hosting: production apps + dev services + preview URLs + domains
|
||||
* - infrastructure: TODO (returns placeholder shape for now)
|
||||
* Single-fetch shape consumed by the Product / Hosting / Infrastructure
|
||||
* tabs. Keeping it one endpoint keeps page transitions cheap and avoids
|
||||
* fan-out.
|
||||
*
|
||||
* Single endpoint per page so the UI doesn't fan out 3+ requests on
|
||||
* every navigation. Each tab consumes its own slice.
|
||||
* Conceptual model (locked Apr 28 2026):
|
||||
* Product = "what makes up the thing you're shipping"
|
||||
* → codebases (Gitea repos) + images (Coolify services
|
||||
* backed by an upstream Docker image, e.g. Twenty CRM,
|
||||
* n8n). Both are first-class product surfaces.
|
||||
* → vibn-dev-* containers are NOT shown — the dev
|
||||
* container is the AI's workshop, not the product.
|
||||
*
|
||||
* Hosting = "where does it live and how does it get there"
|
||||
* → unified `live` list of running endpoints (each item
|
||||
* carries source = "repo" | "image", attached domains,
|
||||
* and last build/deploy status inline) + `previews`
|
||||
* (dev container preview URLs).
|
||||
* → no separate Build, Domains, or Services categories.
|
||||
*
|
||||
* Infrastructure = TODO (placeholder).
|
||||
*/
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
@@ -15,6 +28,7 @@ import { authSession } from "@/lib/auth/session-server";
|
||||
import { query } from "@/lib/db-postgres";
|
||||
import {
|
||||
listApplications,
|
||||
listApplicationDeployments,
|
||||
listServicesInProject,
|
||||
type CoolifyApplication,
|
||||
type CoolifyService,
|
||||
@@ -34,30 +48,43 @@ interface Codebase {
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
interface ProductionApp {
|
||||
interface ProductImage {
|
||||
uuid: string;
|
||||
name: string;
|
||||
/** "twentycrm/twenty" */
|
||||
image: string;
|
||||
/** "v1.15" — empty string when not pinned */
|
||||
version: string;
|
||||
serviceType?: string;
|
||||
/** Coolify service status, surfaced so the Product tile can show a dot */
|
||||
status?: string;
|
||||
}
|
||||
|
||||
interface BuildSummary {
|
||||
status: string;
|
||||
finishedAt?: string;
|
||||
commit?: string;
|
||||
}
|
||||
|
||||
interface LiveEndpoint {
|
||||
uuid: string;
|
||||
name: string;
|
||||
/** repo = built from Gitea, image = pulled docker image (Coolify service) */
|
||||
source: "repo" | "image";
|
||||
/** "apps/web" or "twentycrm/twenty:v1.15" */
|
||||
sourceLabel: string;
|
||||
status: string;
|
||||
/** primary host (no scheme) when one exists */
|
||||
fqdn?: string;
|
||||
/** all attached hosts */
|
||||
domains: string[];
|
||||
branch?: string;
|
||||
buildPack?: string;
|
||||
/** Last finished deployment, only for source = "repo" */
|
||||
lastBuild?: BuildSummary;
|
||||
}
|
||||
|
||||
interface DevService {
|
||||
uuid: string;
|
||||
name: string;
|
||||
serviceType?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
/** Dev container = the vibn-dev-* Coolify service this project edits in. */
|
||||
interface DevContainer {
|
||||
uuid: string;
|
||||
name: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
interface PreviewUrl {
|
||||
interface Preview {
|
||||
id: string;
|
||||
name: string;
|
||||
port: number;
|
||||
@@ -66,32 +93,24 @@ interface PreviewUrl {
|
||||
startedAt: string;
|
||||
}
|
||||
|
||||
interface Domain {
|
||||
host: string;
|
||||
source: "production" | "preview";
|
||||
}
|
||||
|
||||
interface Anatomy {
|
||||
project: { id: string; name: string; gitea?: string; coolifyProjectUuid?: string };
|
||||
codebases: Codebase[];
|
||||
codebasesReason?: "no_repo" | "empty_repo";
|
||||
product: {
|
||||
devContainers: DevContainer[];
|
||||
codebases: Codebase[];
|
||||
images: ProductImage[];
|
||||
};
|
||||
hosting: {
|
||||
production: ProductionApp[];
|
||||
services: DevService[];
|
||||
previewUrls: PreviewUrl[];
|
||||
domains: Domain[];
|
||||
live: LiveEndpoint[];
|
||||
previews: Preview[];
|
||||
};
|
||||
infrastructure: {
|
||||
/** TODO Phase 4 — see PROJECT_PAGE_ARCHITECTURE.md for the design call. */
|
||||
placeholder: true;
|
||||
};
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// Gitea
|
||||
// Gitea (codebase discovery)
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
interface GiteaItem {
|
||||
@@ -150,20 +169,17 @@ async function discoverCodebases(giteaRepo: string): Promise<{
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// Hosting — Coolify + fs_dev_servers
|
||||
// Coolify helpers
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
/** Strip credentials + .git suffix and normalise to lowercase */
|
||||
function normaliseRepoUrl(url: string | undefined): string {
|
||||
if (!url) return "";
|
||||
let u = url.toLowerCase();
|
||||
// Remove user:pass@ if present
|
||||
u = u.replace(/^https?:\/\/[^/@]*@/, "https://");
|
||||
u = u.replace(/\.git$/, "");
|
||||
return u;
|
||||
}
|
||||
|
||||
/** Returns the canonical short form: "owner/repo" */
|
||||
function shortFormOfRepo(url: string | undefined): string {
|
||||
if (!url) return "";
|
||||
const cleaned = normaliseRepoUrl(url).replace(/^https?:\/\/[^/]+\//, "");
|
||||
@@ -174,33 +190,21 @@ function appMatchesRepo(app: CoolifyApplication, giteaRepo: string): boolean {
|
||||
const target = giteaRepo.toLowerCase();
|
||||
const appShort = shortFormOfRepo(app.git_repository);
|
||||
if (appShort && appShort === target) return true;
|
||||
// Also match if either side contains the other (loose fallback for legacy data)
|
||||
return Boolean(app.git_repository && app.git_repository.toLowerCase().includes(target));
|
||||
}
|
||||
|
||||
async function loadProductionApps(giteaRepo: string | undefined): Promise<ProductionApp[]> {
|
||||
async function loadRepoApps(giteaRepo: string | undefined): Promise<CoolifyApplication[]> {
|
||||
if (!giteaRepo) return [];
|
||||
try {
|
||||
const all = await listApplications();
|
||||
return all
|
||||
.filter(app => appMatchesRepo(app, giteaRepo))
|
||||
.map(app => ({
|
||||
uuid: app.uuid,
|
||||
name: app.name,
|
||||
status: app.status,
|
||||
fqdn: app.fqdn,
|
||||
branch: app.git_branch,
|
||||
buildPack: app.build_pack,
|
||||
}));
|
||||
return all.filter(app => appMatchesRepo(app, giteaRepo));
|
||||
} catch (err) {
|
||||
console.error("[anatomy] listApplications failed:", err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** 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[]> {
|
||||
async function loadProjectServices(coolifyProjectUuid: string | undefined): Promise<CoolifyService[]> {
|
||||
if (!coolifyProjectUuid) return [];
|
||||
try {
|
||||
return await listServicesInProject(coolifyProjectUuid);
|
||||
@@ -210,11 +214,48 @@ async function loadAllServices(coolifyProjectUuid: string | undefined): Promise<
|
||||
}
|
||||
}
|
||||
|
||||
function isDevContainer(svc: CoolifyService): boolean {
|
||||
return svc.name.startsWith("vibn-dev-");
|
||||
const isDevContainer = (svc: CoolifyService) => svc.name.startsWith("vibn-dev-");
|
||||
|
||||
/** Extract image:version from a Coolify docker_compose_raw blob.
|
||||
* Best-effort regex; we only want a sensible label, not perfection. */
|
||||
function extractImageInfo(svc: CoolifyService): { image: string; version: string } {
|
||||
const raw = (svc as unknown as { docker_compose_raw?: string }).docker_compose_raw ?? "";
|
||||
const m = raw.match(/image:\s*['"]?([^\s'"\n]+)['"]?/);
|
||||
if (!m) return { image: svc.service_type ?? svc.name, version: "" };
|
||||
const full = m[1];
|
||||
const at = full.lastIndexOf(":");
|
||||
if (at <= 0 || full.slice(at).includes("/")) {
|
||||
return { image: full, version: "" };
|
||||
}
|
||||
return { image: full.slice(0, at), version: full.slice(at + 1) };
|
||||
}
|
||||
|
||||
async function loadPreviewUrls(projectId: string): Promise<PreviewUrl[]> {
|
||||
function fqdnsOf(value: string | undefined): string[] {
|
||||
if (!value) return [];
|
||||
return value
|
||||
.split(",")
|
||||
.map(s => s.trim().replace(/^https?:\/\//, "").replace(/\/$/, ""))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
async function lastBuildFor(uuid: string): Promise<BuildSummary | undefined> {
|
||||
try {
|
||||
const deployments = await listApplicationDeployments(uuid);
|
||||
if (!deployments.length) return undefined;
|
||||
// Prefer the most recently finished; fall back to first.
|
||||
const finished = deployments.find(d => d.finished_at) ?? deployments[0];
|
||||
return {
|
||||
status: finished.status,
|
||||
finishedAt: finished.finished_at,
|
||||
commit: finished.commit,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error(`[anatomy] listApplicationDeployments(${uuid}) failed:`, err);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPreviews(projectId: string): Promise<Preview[]> {
|
||||
try {
|
||||
const rows = await query<{
|
||||
id: string;
|
||||
@@ -239,7 +280,6 @@ async function loadPreviewUrls(projectId: string): Promise<PreviewUrl[]> {
|
||||
startedAt: r.started_at,
|
||||
}));
|
||||
} catch (err) {
|
||||
// fs_dev_servers may not exist yet on older deployments — treat as empty
|
||||
if (err instanceof Error && /relation "fs_dev_servers" does not exist/i.test(err.message)) {
|
||||
return [];
|
||||
}
|
||||
@@ -248,25 +288,6 @@ async function loadPreviewUrls(projectId: string): Promise<PreviewUrl[]> {
|
||||
}
|
||||
}
|
||||
|
||||
function dedupeDomains(prod: ProductionApp[], previews: PreviewUrl[]): Domain[] {
|
||||
const map = new Map<string, Domain>();
|
||||
for (const app of prod) {
|
||||
if (!app.fqdn) continue;
|
||||
// fqdn can be a comma-separated list
|
||||
for (const raw of app.fqdn.split(",")) {
|
||||
const host = raw.trim().replace(/^https?:\/\//, "").replace(/\/$/, "");
|
||||
if (host && !map.has(host)) map.set(host, { host, source: "production" });
|
||||
}
|
||||
}
|
||||
for (const p of previews) {
|
||||
try {
|
||||
const host = new URL(p.url).host;
|
||||
if (host && !map.has(host)) map.set(host, { host, source: "preview" });
|
||||
} catch { /* malformed URL, skip */ }
|
||||
}
|
||||
return [...map.values()];
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// Handler
|
||||
// ──────────────────────────────────────────────────
|
||||
@@ -300,55 +321,80 @@ export async function GET(
|
||||
(data?.name as string | undefined) ??
|
||||
"Project";
|
||||
|
||||
// Run the slow bits in parallel
|
||||
const [codebasesResult, production, allServices, previews] = await Promise.all([
|
||||
const [codebasesResult, repoApps, allServices, previews] = await Promise.all([
|
||||
giteaRepo
|
||||
? discoverCodebases(giteaRepo).catch(err => {
|
||||
console.error("[anatomy] discoverCodebases failed:", err);
|
||||
return { codebases: [] as Codebase[], reason: "empty_repo" as const };
|
||||
})
|
||||
: Promise.resolve({ codebases: [] as Codebase[], reason: undefined as undefined }),
|
||||
loadProductionApps(giteaRepo),
|
||||
loadAllServices(coolifyProjectUuid),
|
||||
loadPreviewUrls(projectId),
|
||||
loadRepoApps(giteaRepo),
|
||||
loadProjectServices(coolifyProjectUuid),
|
||||
loadPreviews(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,
|
||||
});
|
||||
}
|
||||
}
|
||||
// Pull last-build summaries for repo apps in parallel (small N).
|
||||
const builds = await Promise.all(repoApps.map(a => lastBuildFor(a.uuid)));
|
||||
|
||||
// Image services (Coolify services minus vibn-dev-*)
|
||||
const imageServices = allServices.filter(s => !isDevContainer(s));
|
||||
|
||||
const productImages: ProductImage[] = imageServices.map(s => {
|
||||
const { image, version } = extractImageInfo(s);
|
||||
return {
|
||||
uuid: s.uuid,
|
||||
name: s.name,
|
||||
image,
|
||||
version,
|
||||
serviceType: s.service_type,
|
||||
status: s.status,
|
||||
};
|
||||
});
|
||||
|
||||
const liveFromRepo: LiveEndpoint[] = repoApps.map((app, i) => {
|
||||
const domains = fqdnsOf(app.fqdn);
|
||||
return {
|
||||
uuid: app.uuid,
|
||||
name: app.name,
|
||||
source: "repo",
|
||||
sourceLabel: shortFormOfRepo(app.git_repository) || (giteaRepo ?? "repo"),
|
||||
status: app.status,
|
||||
fqdn: domains[0],
|
||||
domains,
|
||||
branch: app.git_branch,
|
||||
buildPack: app.build_pack,
|
||||
lastBuild: builds[i],
|
||||
};
|
||||
});
|
||||
|
||||
const liveFromImage: LiveEndpoint[] = imageServices.map(s => {
|
||||
const domains = fqdnsOf((s as unknown as { fqdn?: string }).fqdn);
|
||||
const { image, version } = extractImageInfo(s);
|
||||
return {
|
||||
uuid: s.uuid,
|
||||
name: s.name,
|
||||
source: "image",
|
||||
sourceLabel: version ? `${image}:${version}` : image,
|
||||
status: s.status ?? "unknown",
|
||||
fqdn: domains[0],
|
||||
domains,
|
||||
};
|
||||
});
|
||||
|
||||
const codebasesReason: "no_repo" | "empty_repo" | undefined = !giteaRepo
|
||||
? "no_repo"
|
||||
: codebasesResult.reason;
|
||||
|
||||
const anatomy: Anatomy = {
|
||||
project: {
|
||||
id: projectId,
|
||||
name: projectName,
|
||||
gitea: giteaRepo,
|
||||
coolifyProjectUuid,
|
||||
},
|
||||
codebases: codebasesResult.codebases,
|
||||
project: { id: projectId, name: projectName, gitea: giteaRepo, coolifyProjectUuid },
|
||||
codebasesReason,
|
||||
product: { devContainers },
|
||||
product: {
|
||||
codebases: codebasesResult.codebases,
|
||||
images: productImages,
|
||||
},
|
||||
hosting: {
|
||||
production,
|
||||
services: deployedServices,
|
||||
previewUrls: previews,
|
||||
domains: dedupeDomains(production, previews),
|
||||
live: [...liveFromRepo, ...liveFromImage],
|
||||
previews,
|
||||
},
|
||||
infrastructure: { placeholder: true },
|
||||
};
|
||||
|
||||
@@ -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