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:
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import {
|
||||
Loader2, AlertCircle, ExternalLink, Globe, Server,
|
||||
@@ -8,207 +9,321 @@ import {
|
||||
import { useAnatomy, type Anatomy } from "@/components/project/use-anatomy";
|
||||
|
||||
/**
|
||||
* Hosting tab.
|
||||
* Hosting tab — runtime + reachability surface.
|
||||
*
|
||||
* Surfaces "where this product runs" — Coolify production apps,
|
||||
* dev/services, live preview URLs from active dev_servers, and the
|
||||
* domains pointing at any of them. All from one /anatomy fetch.
|
||||
* Same shell as Product:
|
||||
* - Left rail: 4 sections (Production / Services / Previews /
|
||||
* Domains). Each section is always rendered with a count badge,
|
||||
* so the user learns what belongs here even on a brand-new
|
||||
* project. Items inside each section are clickable tiles.
|
||||
* - Right pane: details for the selected item — status, FQDN,
|
||||
* branch, source. (Action buttons land in a future pass.)
|
||||
*/
|
||||
|
||||
type Selection =
|
||||
| { kind: "production"; uuid: string }
|
||||
| { kind: "service"; uuid: string }
|
||||
| { kind: "preview"; id: string }
|
||||
| { kind: "domain"; host: string }
|
||||
| null;
|
||||
|
||||
export default function HostingTab() {
|
||||
const params = useParams();
|
||||
const projectId = params.projectId as string;
|
||||
const { anatomy, loading, error } = useAnatomy(projectId);
|
||||
|
||||
if (loading && !anatomy) {
|
||||
return <Center><Loader2 size={14} className="animate-spin" /> Loading…</Center>;
|
||||
}
|
||||
if (error) {
|
||||
return <Center><AlertCircle size={14} /> {error}</Center>;
|
||||
}
|
||||
if (!anatomy) return null;
|
||||
const [selection, setSelection] = useState<Selection>(null);
|
||||
|
||||
const { production, services, previewUrls, domains } = anatomy.hosting;
|
||||
const hasAnything =
|
||||
production.length + services.length + previewUrls.length + domains.length > 0;
|
||||
const showLoading = loading && !anatomy;
|
||||
|
||||
return (
|
||||
<div style={pageWrap}>
|
||||
<div style={inner}>
|
||||
{!hasAnything && <NothingDeployedBanner anatomy={anatomy} />}
|
||||
<div style={grid}>
|
||||
{/* ── Left rail ── */}
|
||||
<section style={leftCol}>
|
||||
{showLoading && (
|
||||
<Inline><Loader2 size={13} className="animate-spin" /> Loading…</Inline>
|
||||
)}
|
||||
{error && (
|
||||
<Inline><AlertCircle size={13} /> {error}</Inline>
|
||||
)}
|
||||
{anatomy && (
|
||||
<>
|
||||
<RailGroup
|
||||
title="Production"
|
||||
count={anatomy.hosting.production.length}
|
||||
emptyHint="Coolify apps deployed from this repo will appear here."
|
||||
>
|
||||
{anatomy.hosting.production.map(app => (
|
||||
<RailItem
|
||||
key={app.uuid}
|
||||
icon={Cloud}
|
||||
title={app.name}
|
||||
subtitle={[app.branch, app.buildPack].filter(Boolean).join(" · ") || "—"}
|
||||
statusColor={statusColor(app.status)}
|
||||
active={selection?.kind === "production" && selection.uuid === app.uuid}
|
||||
onClick={() => setSelection({ kind: "production", uuid: app.uuid })}
|
||||
/>
|
||||
))}
|
||||
</RailGroup>
|
||||
|
||||
<Section icon={Cloud} title="Production" count={production.length}>
|
||||
{production.length === 0 ? (
|
||||
<Empty
|
||||
message={anatomy.project.gitea
|
||||
? "No Coolify app deploys this repo yet."
|
||||
: "No Gitea repo connected, so nothing to deploy."}
|
||||
hint={anatomy.project.gitea
|
||||
? `Looking for an app whose repo matches ${anatomy.project.gitea}.`
|
||||
: undefined}
|
||||
/>
|
||||
) : production.map(app => (
|
||||
<Row
|
||||
key={app.uuid}
|
||||
title={app.name}
|
||||
subtitle={[app.branch, app.buildPack].filter(Boolean).join(" · ") || "—"}
|
||||
statusDot={statusColor(app.status)}
|
||||
statusLabel={app.status}
|
||||
href={app.fqdn ? hrefForFqdn(app.fqdn) : undefined}
|
||||
hrefLabel={app.fqdn ? primaryHost(app.fqdn) : undefined}
|
||||
/>
|
||||
))}
|
||||
</Section>
|
||||
<RailGroup
|
||||
title="Services"
|
||||
count={anatomy.hosting.services.length}
|
||||
emptyHint="Self-hosted apps (Twenty, n8n, Plausible…) appear here."
|
||||
>
|
||||
{anatomy.hosting.services.map(svc => (
|
||||
<RailItem
|
||||
key={svc.uuid}
|
||||
icon={Server}
|
||||
title={svc.name}
|
||||
subtitle={svc.serviceType ?? "service"}
|
||||
statusColor={statusColor(svc.status ?? "")}
|
||||
active={selection?.kind === "service" && selection.uuid === svc.uuid}
|
||||
onClick={() => setSelection({ kind: "service", uuid: svc.uuid })}
|
||||
/>
|
||||
))}
|
||||
</RailGroup>
|
||||
|
||||
<Section icon={Server} title="Dev services" count={services.length}>
|
||||
{services.length === 0 ? (
|
||||
<Empty
|
||||
message="No dev services running for this project."
|
||||
hint="Path B containers (vibn-dev) appear here once provisioned."
|
||||
/>
|
||||
) : services.map(svc => (
|
||||
<Row
|
||||
key={svc.uuid}
|
||||
title={svc.name}
|
||||
subtitle={svc.serviceType ?? "service"}
|
||||
statusDot={statusColor(svc.status ?? "unknown")}
|
||||
statusLabel={svc.status ?? "unknown"}
|
||||
/>
|
||||
))}
|
||||
</Section>
|
||||
<RailGroup
|
||||
title="Preview URLs"
|
||||
count={anatomy.hosting.previewUrls.length}
|
||||
emptyHint="Dev servers started from chat get a preview URL here."
|
||||
>
|
||||
{anatomy.hosting.previewUrls.map(p => (
|
||||
<RailItem
|
||||
key={p.id}
|
||||
icon={Zap}
|
||||
title={`${p.name} :${p.port}`}
|
||||
subtitle={p.state}
|
||||
statusColor={p.state === "running" ? "#2e7d32" : "#a09a90"}
|
||||
active={selection?.kind === "preview" && selection.id === p.id}
|
||||
onClick={() => setSelection({ kind: "preview", id: p.id })}
|
||||
/>
|
||||
))}
|
||||
</RailGroup>
|
||||
|
||||
<Section icon={Zap} title="Preview URLs" count={previewUrls.length}>
|
||||
{previewUrls.length === 0 ? (
|
||||
<Empty
|
||||
message="No active preview URLs."
|
||||
hint="Start a dev server from chat to expose one on *.preview.vibnai.com."
|
||||
/>
|
||||
) : previewUrls.map(p => (
|
||||
<Row
|
||||
key={p.id}
|
||||
title={`${p.name} :${p.port}`}
|
||||
subtitle={`Started ${formatRelative(p.startedAt)}`}
|
||||
statusDot={p.state === "running" ? "#2e7d32" : "#a09a90"}
|
||||
statusLabel={p.state}
|
||||
href={p.url}
|
||||
hrefLabel={hostOf(p.url)}
|
||||
/>
|
||||
))}
|
||||
</Section>
|
||||
<RailGroup
|
||||
title="Domains"
|
||||
count={anatomy.hosting.domains.length}
|
||||
emptyHint="Custom domains attached to anything above show up here."
|
||||
>
|
||||
{anatomy.hosting.domains.map(d => (
|
||||
<RailItem
|
||||
key={d.host}
|
||||
icon={Globe}
|
||||
title={d.host}
|
||||
subtitle={d.source === "production" ? "Production" : "Preview"}
|
||||
active={selection?.kind === "domain" && selection.host === d.host}
|
||||
onClick={() => setSelection({ kind: "domain", host: d.host })}
|
||||
/>
|
||||
))}
|
||||
</RailGroup>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<Section icon={Globe} title="Domains" count={domains.length}>
|
||||
{domains.length === 0 ? (
|
||||
<Empty message="No custom domains attached yet." />
|
||||
) : domains.map(d => (
|
||||
<Row
|
||||
key={d.host}
|
||||
title={d.host}
|
||||
subtitle={d.source === "production" ? "Production" : "Preview"}
|
||||
href={`https://${d.host}`}
|
||||
hrefLabel="open"
|
||||
/>
|
||||
))}
|
||||
</Section>
|
||||
{/* ── Right pane ── */}
|
||||
<aside style={rightCol}>
|
||||
<h3 style={heading}>{paneHeading(selection, anatomy)}</h3>
|
||||
<div style={panel}>
|
||||
{anatomy && selection
|
||||
? <Detail selection={selection} anatomy={anatomy} />
|
||||
: <Empty>Pick something on the left to see its details.</Empty>}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// Bits
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// ──────────────────────────────────────────────────
|
||||
// Detail pane
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
function NothingDeployedBanner({ anatomy }: { anatomy: Anatomy }) {
|
||||
const reason = anatomy.project.gitea
|
||||
? "The repo exists on Gitea but no production app is wired up in Coolify yet."
|
||||
: "This project doesn't have a Gitea repo connected, so there's nothing to deploy.";
|
||||
return (
|
||||
<div style={bannerBox}>
|
||||
<div style={bannerTitle}>Nothing is deployed for this project</div>
|
||||
<div style={bannerBody}>{reason}</div>
|
||||
</div>
|
||||
);
|
||||
function Detail({ selection, anatomy }: { selection: Selection; anatomy: Anatomy }) {
|
||||
if (!selection) return null;
|
||||
|
||||
if (selection.kind === "production") {
|
||||
const app = anatomy.hosting.production.find(a => a.uuid === selection.uuid);
|
||||
if (!app) return <Empty>This app is no longer in the project.</Empty>;
|
||||
return (
|
||||
<DetailLayout>
|
||||
<DetailRow label="Status" value={app.status} dot={statusColor(app.status)} />
|
||||
<DetailRow label="Branch" value={app.branch ?? "—"} />
|
||||
<DetailRow label="Pack" value={app.buildPack ?? "—"} />
|
||||
{app.fqdn && (
|
||||
<DetailRow
|
||||
label="URL"
|
||||
value={primaryHost(app.fqdn)}
|
||||
href={`https://${primaryHost(app.fqdn)}`}
|
||||
/>
|
||||
)}
|
||||
</DetailLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (selection.kind === "service") {
|
||||
const svc = anatomy.hosting.services.find(s => s.uuid === selection.uuid);
|
||||
if (!svc) return <Empty>This service is no longer in the project.</Empty>;
|
||||
return (
|
||||
<DetailLayout>
|
||||
<DetailRow label="Status" value={svc.status ?? "unknown"} dot={statusColor(svc.status ?? "")} />
|
||||
<DetailRow label="Type" value={svc.serviceType ?? "—"} />
|
||||
</DetailLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (selection.kind === "preview") {
|
||||
const p = anatomy.hosting.previewUrls.find(p => p.id === selection.id);
|
||||
if (!p) return <Empty>This preview URL is no longer active.</Empty>;
|
||||
return (
|
||||
<DetailLayout>
|
||||
<DetailRow label="State" value={p.state} dot={p.state === "running" ? "#2e7d32" : "#a09a90"} />
|
||||
<DetailRow label="Port" value={String(p.port)} />
|
||||
<DetailRow label="URL" value={hostOf(p.url)} href={p.url} />
|
||||
<DetailRow label="Started" value={formatRelative(p.startedAt)} />
|
||||
</DetailLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (selection.kind === "domain") {
|
||||
const d = anatomy.hosting.domains.find(d => d.host === selection.host);
|
||||
if (!d) return <Empty>This domain is no longer attached.</Empty>;
|
||||
return (
|
||||
<DetailLayout>
|
||||
<DetailRow label="Host" value={d.host} href={`https://${d.host}`} />
|
||||
<DetailRow label="Source" value={d.source === "production" ? "Production" : "Preview"} />
|
||||
</DetailLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function Section({
|
||||
icon: Icon, title, count, children,
|
||||
function paneHeading(s: Selection, a: Anatomy | null): string {
|
||||
if (!s) return "Details";
|
||||
if (!a) return "Details";
|
||||
if (s.kind === "production") return `Details · ${a.hosting.production.find(x => x.uuid === s.uuid)?.name ?? "Production"}`;
|
||||
if (s.kind === "service") return `Details · ${a.hosting.services.find(x => x.uuid === s.uuid)?.name ?? "Service"}`;
|
||||
if (s.kind === "preview") return `Details · ${a.hosting.previewUrls.find(x => x.id === s.id)?.name ?? "Preview"}`;
|
||||
if (s.kind === "domain") return `Details · ${s.host}`;
|
||||
return "Details";
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// Bits
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
function RailGroup({
|
||||
title, count, emptyHint, children,
|
||||
}: {
|
||||
icon: React.ComponentType<{ size?: number; style?: React.CSSProperties }>;
|
||||
title: string;
|
||||
count: number;
|
||||
emptyHint: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section style={sectionWrap}>
|
||||
<header style={sectionHeader}>
|
||||
<Icon size={14} style={{ color: INK.mid }} />
|
||||
<span style={sectionTitle}>{title}</span>
|
||||
<div style={railGroup}>
|
||||
<header style={railGroupHeader}>
|
||||
<span style={railGroupTitle}>{title}</span>
|
||||
<span style={countPill}>{count}</span>
|
||||
</header>
|
||||
<div style={sectionBody}>{children}</div>
|
||||
</section>
|
||||
{count === 0 ? (
|
||||
<div style={railEmpty}>{emptyHint}</div>
|
||||
) : (
|
||||
<div style={railItems}>{children}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({
|
||||
title, subtitle, statusDot, statusLabel, href, hrefLabel,
|
||||
function RailItem({
|
||||
icon: Icon, title, subtitle, statusColor: dot, active, onClick,
|
||||
}: {
|
||||
icon: React.ComponentType<{ size?: number; style?: React.CSSProperties }>;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
statusDot?: string;
|
||||
statusLabel?: string;
|
||||
href?: string;
|
||||
hrefLabel?: string;
|
||||
statusColor?: string;
|
||||
active?: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div style={rowWrap}>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div style={rowTitle}>{title}</div>
|
||||
{subtitle && <div style={rowSubtitle}>{subtitle}</div>}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
style={{
|
||||
...railItem,
|
||||
borderColor: active ? INK.ink : INK.borderSoft,
|
||||
boxShadow: active ? `0 0 0 1px ${INK.ink}` : "none",
|
||||
background: active ? "#fffdf8" : INK.cardBg,
|
||||
}}
|
||||
aria-pressed={active}
|
||||
>
|
||||
<Icon size={13} style={{ color: INK.mid, flexShrink: 0 }} />
|
||||
<div style={{ minWidth: 0, textAlign: "left", flex: 1 }}>
|
||||
<div style={tileLabel}>{title}</div>
|
||||
{subtitle && <div style={tileHint}>{subtitle}</div>}
|
||||
</div>
|
||||
{statusDot && (
|
||||
<span style={statusPill}>
|
||||
<CircleDot size={9} style={{ color: statusDot }} />
|
||||
<span style={{ color: INK.mid, fontSize: "0.74rem" }}>{statusLabel}</span>
|
||||
</span>
|
||||
)}
|
||||
{href && (
|
||||
<a href={href} target="_blank" rel="noreferrer" style={openLink}>
|
||||
<ExternalLink size={12} /> {hrefLabel ?? "open"}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
{dot && <CircleDot size={9} style={{ color: dot, flexShrink: 0 }} />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function Empty({ message, hint }: { message: string; hint?: string }) {
|
||||
function DetailLayout({ children }: { children: React.ReactNode }) {
|
||||
return <div style={{ display: "flex", flexDirection: "column", gap: 1 }}>{children}</div>;
|
||||
}
|
||||
|
||||
function DetailRow({
|
||||
label, value, dot, href,
|
||||
}: {
|
||||
label: string; value: string; dot?: string; href?: string;
|
||||
}) {
|
||||
return (
|
||||
<div style={emptyWrap}>
|
||||
<div style={emptyMsg}>{message}</div>
|
||||
{hint && <div style={emptyHint}>{hint}</div>}
|
||||
<div style={detailRow}>
|
||||
<span style={detailLabel}>{label}</span>
|
||||
<span style={detailValue}>
|
||||
{dot && <CircleDot size={9} style={{ color: dot, marginRight: 6 }} />}
|
||||
{href ? (
|
||||
<a href={href} target="_blank" rel="noreferrer" style={detailLink}>
|
||||
{value} <ExternalLink size={11} />
|
||||
</a>
|
||||
) : value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Center({ children }: { children: React.ReactNode }) {
|
||||
function Inline({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div style={{
|
||||
padding: "60px 20px", textAlign: "center", color: INK.mid,
|
||||
fontSize: "0.88rem", display: "flex", justifyContent: "center", gap: 8, alignItems: "center",
|
||||
}}>{children}</div>
|
||||
display: "flex", alignItems: "center", gap: 8,
|
||||
padding: "12px 14px", fontSize: "0.82rem", color: INK.mid,
|
||||
background: INK.cardBg, border: `1px solid ${INK.borderSoft}`, borderRadius: 8,
|
||||
}}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
function Empty({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div style={{
|
||||
flex: 1, display: "flex", alignItems: "center", justifyContent: "center",
|
||||
color: INK.mid, fontSize: "0.85rem", padding: "32px 16px", textAlign: "center",
|
||||
}}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
function primaryHost(fqdn: string) {
|
||||
return fqdn.split(",")[0]?.trim().replace(/^https?:\/\//, "").replace(/\/$/, "") || fqdn;
|
||||
}
|
||||
function hrefForFqdn(fqdn: string) {
|
||||
const host = primaryHost(fqdn);
|
||||
return host.startsWith("http") ? host : `https://${host}`;
|
||||
}
|
||||
function hostOf(url: string) {
|
||||
try { return new URL(url).host; } catch { return url; }
|
||||
}
|
||||
@@ -216,7 +331,7 @@ function statusColor(status: string) {
|
||||
const s = status.toLowerCase();
|
||||
if (s.includes("running") || s.includes("healthy")) return "#2e7d32";
|
||||
if (s.includes("starting") || s.includes("deploying")) return "#d4a04a";
|
||||
if (s.includes("exited") || s.includes("failed") || s.includes("unhealthy")) return "#c5392b";
|
||||
if (s.includes("exit") || s.includes("fail") || s.includes("unhealthy")) return "#c5392b";
|
||||
return "#a09a90";
|
||||
}
|
||||
function formatRelative(iso: string) {
|
||||
@@ -226,13 +341,12 @@ function formatRelative(iso: string) {
|
||||
if (min < 60) return `${min}m ago`;
|
||||
const hr = Math.floor(min / 60);
|
||||
if (hr < 24) return `${hr}h ago`;
|
||||
const d = Math.floor(hr / 24);
|
||||
return `${d}d ago`;
|
||||
return `${Math.floor(hr / 24)}d ago`;
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// ──────────────────────────────────────────────────
|
||||
// Tokens
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
const INK = {
|
||||
ink: "#1a1a1a",
|
||||
@@ -249,51 +363,75 @@ const pageWrap: React.CSSProperties = {
|
||||
fontFamily: INK.fontSans,
|
||||
color: INK.ink,
|
||||
};
|
||||
const inner: React.CSSProperties = {
|
||||
maxWidth: 960, margin: "0 auto",
|
||||
display: "flex", flexDirection: "column", gap: 16,
|
||||
const grid: React.CSSProperties = {
|
||||
display: "grid",
|
||||
gridTemplateColumns: "minmax(280px, 360px) minmax(0, 1fr)",
|
||||
gap: 28,
|
||||
maxWidth: 1400,
|
||||
margin: "0 auto",
|
||||
alignItems: "stretch",
|
||||
};
|
||||
const bannerBox: React.CSSProperties = {
|
||||
padding: "14px 18px", borderRadius: 10,
|
||||
background: "#fff7e8", border: "1px solid #f0deb6",
|
||||
const leftCol: React.CSSProperties = {
|
||||
minWidth: 0, display: "flex", flexDirection: "column", gap: 18,
|
||||
};
|
||||
const bannerTitle: React.CSSProperties = {
|
||||
fontWeight: 600, color: "#7a5818", fontSize: "0.88rem", marginBottom: 4,
|
||||
const rightCol: React.CSSProperties = {
|
||||
minWidth: 0, display: "flex", flexDirection: "column",
|
||||
};
|
||||
const bannerBody: React.CSSProperties = { color: "#7a5818", fontSize: "0.82rem", lineHeight: 1.5 };
|
||||
const sectionWrap: React.CSSProperties = {
|
||||
background: INK.cardBg, border: `1px solid ${INK.border}`, borderRadius: 10, overflow: "hidden",
|
||||
const heading: React.CSSProperties = {
|
||||
fontSize: "0.72rem", fontWeight: 600, letterSpacing: "0.12em",
|
||||
textTransform: "uppercase", color: INK.muted, margin: "0 0 14px",
|
||||
};
|
||||
const sectionHeader: React.CSSProperties = {
|
||||
display: "flex", alignItems: "center", gap: 8,
|
||||
padding: "14px 18px", borderBottom: `1px solid ${INK.borderSoft}`,
|
||||
const railGroup: React.CSSProperties = {
|
||||
display: "flex", flexDirection: "column",
|
||||
};
|
||||
const sectionTitle: React.CSSProperties = {
|
||||
fontSize: "0.78rem", fontWeight: 600, letterSpacing: "0.06em", textTransform: "uppercase", color: INK.ink,
|
||||
const railGroupHeader: React.CSSProperties = {
|
||||
display: "flex", alignItems: "center", justifyContent: "space-between",
|
||||
padding: "0 4px 8px",
|
||||
};
|
||||
const railGroupTitle: React.CSSProperties = {
|
||||
fontSize: "0.68rem", fontWeight: 600, letterSpacing: "0.12em",
|
||||
textTransform: "uppercase", color: INK.muted,
|
||||
};
|
||||
const countPill: React.CSSProperties = {
|
||||
marginLeft: "auto", fontSize: "0.72rem", fontWeight: 600, color: INK.mid,
|
||||
padding: "2px 8px", borderRadius: 999, background: "#f3eee4",
|
||||
fontSize: "0.7rem", fontWeight: 600, color: INK.mid,
|
||||
padding: "1px 7px", borderRadius: 999, background: "#f3eee4",
|
||||
};
|
||||
const sectionBody: React.CSSProperties = { display: "flex", flexDirection: "column" };
|
||||
const rowWrap: React.CSSProperties = {
|
||||
display: "flex", alignItems: "center", gap: 14,
|
||||
padding: "12px 18px", borderTop: `1px solid ${INK.borderSoft}`,
|
||||
const railItems: React.CSSProperties = {
|
||||
display: "flex", flexDirection: "column", gap: 8,
|
||||
};
|
||||
const rowTitle: React.CSSProperties = { fontSize: "0.88rem", fontWeight: 600, color: INK.ink };
|
||||
const rowSubtitle: React.CSSProperties = { fontSize: "0.74rem", color: INK.mid, marginTop: 2 };
|
||||
const statusPill: React.CSSProperties = {
|
||||
display: "inline-flex", alignItems: "center", gap: 6, flexShrink: 0,
|
||||
const railItem: React.CSSProperties = {
|
||||
display: "flex", alignItems: "center", gap: 10,
|
||||
width: "100%", padding: "10px 12px",
|
||||
border: `1px solid ${INK.borderSoft}`, borderRadius: 8,
|
||||
cursor: "pointer", font: "inherit", color: "inherit",
|
||||
transition: "border-color 0.12s, background 0.12s, box-shadow 0.12s",
|
||||
};
|
||||
const openLink: React.CSSProperties = {
|
||||
display: "inline-flex", alignItems: "center", gap: 5,
|
||||
fontSize: "0.78rem", color: INK.mid, textDecoration: "none",
|
||||
border: `1px solid ${INK.borderSoft}`, borderRadius: 6, padding: "4px 8px",
|
||||
flexShrink: 0,
|
||||
const railEmpty: React.CSSProperties = {
|
||||
padding: "10px 12px", fontSize: "0.74rem", color: INK.muted,
|
||||
fontStyle: "italic", border: `1px dashed ${INK.borderSoft}`, borderRadius: 8,
|
||||
lineHeight: 1.4,
|
||||
};
|
||||
const emptyWrap: React.CSSProperties = {
|
||||
padding: "20px 18px", textAlign: "center",
|
||||
borderTop: `1px solid ${INK.borderSoft}`,
|
||||
const tileLabel: React.CSSProperties = {
|
||||
fontSize: "0.85rem", fontWeight: 600, color: INK.ink, marginBottom: 2,
|
||||
};
|
||||
const tileHint: React.CSSProperties = {
|
||||
fontSize: "0.74rem", color: INK.mid, lineHeight: 1.4,
|
||||
};
|
||||
const panel: React.CSSProperties = {
|
||||
background: INK.cardBg, border: `1px solid ${INK.border}`, borderRadius: 10,
|
||||
padding: 16, flex: 1, minHeight: 0, display: "flex", flexDirection: "column",
|
||||
};
|
||||
const detailRow: React.CSSProperties = {
|
||||
display: "flex", alignItems: "center", justifyContent: "space-between",
|
||||
padding: "12px 4px", borderBottom: `1px solid ${INK.borderSoft}`,
|
||||
};
|
||||
const detailLabel: React.CSSProperties = {
|
||||
fontSize: "0.72rem", fontWeight: 600, letterSpacing: "0.06em",
|
||||
textTransform: "uppercase", color: INK.muted,
|
||||
};
|
||||
const detailValue: React.CSSProperties = {
|
||||
fontSize: "0.85rem", color: INK.ink, display: "inline-flex", alignItems: "center",
|
||||
};
|
||||
const detailLink: React.CSSProperties = {
|
||||
color: INK.ink, textDecoration: "none", display: "inline-flex", alignItems: "center", gap: 6,
|
||||
};
|
||||
const emptyMsg: React.CSSProperties = { fontSize: "0.82rem", color: INK.mid, marginBottom: 4 };
|
||||
const emptyHint: React.CSSProperties = { fontSize: "0.74rem", color: INK.muted, fontStyle: "italic" };
|
||||
|
||||
@@ -2,29 +2,43 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Loader2, AlertCircle, ChevronDown, ChevronRight, Box } from "lucide-react";
|
||||
import { Loader2, AlertCircle, ChevronDown, ChevronRight, Box, Server, CircleDot } from "lucide-react";
|
||||
import { GiteaFileTree } from "@/components/project/gitea-file-tree";
|
||||
import { GiteaFileViewer } from "@/components/project/gitea-file-viewer";
|
||||
import { DevContainerDetail } from "@/components/project/dev-container-detail";
|
||||
import { useAnatomy } from "@/components/project/use-anatomy";
|
||||
|
||||
/**
|
||||
* Product tab — IDE-style.
|
||||
* Product tab — the build surface.
|
||||
*
|
||||
* Reads codebases from the shared /anatomy endpoint. Left column is
|
||||
* a stack of expandable codebase tiles, each with its own inline
|
||||
* Gitea file tree. Clicking a file previews its content on the right.
|
||||
* Left rail (top → bottom):
|
||||
* - Workspace section: dev container tile (the vibn-dev-* service
|
||||
* where the AI edits code; clicking it shows status + active
|
||||
* dev servers in the right pane).
|
||||
* - Codebases section: one tile per codebase, each expanding inline
|
||||
* into its Gitea file tree. Clicking a file previews it.
|
||||
*
|
||||
* Right pane swaps between three view kinds based on the active
|
||||
* selection: "devContainer", "file", or "empty".
|
||||
*/
|
||||
|
||||
type Selection =
|
||||
| { type: "devContainer"; uuid: string }
|
||||
| { type: "file"; codebaseId: string; path: string }
|
||||
| null;
|
||||
|
||||
export default function ProductTab() {
|
||||
const params = useParams();
|
||||
const projectId = params.projectId as string;
|
||||
const { anatomy, loading, error } = useAnatomy(projectId);
|
||||
|
||||
const codebases = anatomy?.codebases ?? null;
|
||||
const reason = anatomy?.codebasesReason;
|
||||
const codebases = anatomy?.codebases ?? null;
|
||||
const reason = anatomy?.codebasesReason;
|
||||
const devContainer = anatomy?.product.devContainers[0]; // only one per project
|
||||
const previewUrls = anatomy?.hosting.previewUrls ?? [];
|
||||
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
const [selectedFile, setSelectedFile] = useState<{ codebaseId: string; path: string } | null>(null);
|
||||
const [selection, setSelection] = useState<Selection>(null);
|
||||
|
||||
// Auto-expand the first codebase whenever anatomy lands
|
||||
useEffect(() => {
|
||||
@@ -33,9 +47,9 @@ export default function ProductTab() {
|
||||
}
|
||||
}, [codebases]);
|
||||
|
||||
// Reset selection when project changes
|
||||
// Reset on project change
|
||||
useEffect(() => {
|
||||
setSelectedFile(null);
|
||||
setSelection(null);
|
||||
setExpanded(new Set());
|
||||
}, [projectId]);
|
||||
|
||||
@@ -48,25 +62,50 @@ export default function ProductTab() {
|
||||
});
|
||||
};
|
||||
|
||||
const showLoading = loading && !codebases;
|
||||
const showError = !!error;
|
||||
const showLoading = loading && !anatomy;
|
||||
const showError = !!error;
|
||||
|
||||
return (
|
||||
<div style={pageWrap}>
|
||||
<div style={grid}>
|
||||
{/* ── Left: codebases column ── */}
|
||||
{/* ── Left: workspace + codebases ── */}
|
||||
<section style={leftCol}>
|
||||
<h3 style={heading}>Codebases</h3>
|
||||
{/* Workspace section */}
|
||||
<h3 style={heading}>Workspace</h3>
|
||||
<div style={stack}>
|
||||
{showLoading && (
|
||||
<Inline>
|
||||
<Loader2 size={13} className="animate-spin" /> Loading…
|
||||
</Inline>
|
||||
<Inline><Loader2 size={13} className="animate-spin" /> Loading…</Inline>
|
||||
)}
|
||||
{!showLoading && devContainer && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelection({ type: "devContainer", uuid: devContainer.uuid })}
|
||||
style={{
|
||||
...flatTile,
|
||||
borderColor: selection?.type === "devContainer" ? INK.ink : INK.borderSoft,
|
||||
boxShadow: selection?.type === "devContainer" ? `0 0 0 1px ${INK.ink}` : "none",
|
||||
background: selection?.type === "devContainer" ? "#fffdf8" : INK.cardBg,
|
||||
}}
|
||||
aria-pressed={selection?.type === "devContainer"}
|
||||
>
|
||||
<Server size={13} style={{ color: INK.mid, flexShrink: 0 }} />
|
||||
<div style={{ minWidth: 0, textAlign: "left", flex: 1 }}>
|
||||
<div style={tileLabel}>Dev container</div>
|
||||
<div style={tileHint}>{devContainer.name}</div>
|
||||
</div>
|
||||
<CircleDot size={9} style={{ color: colorForStatus(devContainer.status) }} />
|
||||
</button>
|
||||
)}
|
||||
{!showLoading && !devContainer && (
|
||||
<Inline>No dev container provisioned yet.</Inline>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Codebases section */}
|
||||
<h3 style={{ ...heading, marginTop: 24 }}>Codebases</h3>
|
||||
<div style={stack}>
|
||||
{showError && (
|
||||
<Inline>
|
||||
<AlertCircle size={13} /> {error}
|
||||
</Inline>
|
||||
<Inline><AlertCircle size={13} /> {error}</Inline>
|
||||
)}
|
||||
{codebases && codebases.length === 0 && (
|
||||
<Inline>
|
||||
@@ -87,10 +126,10 @@ export default function ProductTab() {
|
||||
>
|
||||
<span style={chevronCell}>
|
||||
{isOpen
|
||||
? <ChevronDown size={13} style={{ color: "#5f5e5a" }} />
|
||||
: <ChevronRight size={13} style={{ color: "#5f5e5a" }} />}
|
||||
? <ChevronDown size={13} style={{ color: INK.mid }} />
|
||||
: <ChevronRight size={13} style={{ color: INK.mid }} />}
|
||||
</span>
|
||||
<Box size={13} style={{ color: "#5f5e5a", flexShrink: 0 }} />
|
||||
<Box size={13} style={{ color: INK.mid, flexShrink: 0 }} />
|
||||
<div style={{ minWidth: 0, textAlign: "left" }}>
|
||||
<div style={tileLabel}>{cb.label}</div>
|
||||
{cb.hint && <div style={tileHint}>{cb.hint}</div>}
|
||||
@@ -102,10 +141,12 @@ export default function ProductTab() {
|
||||
projectId={projectId}
|
||||
rootPath={cb.path}
|
||||
selectedPath={
|
||||
selectedFile?.codebaseId === cb.id ? selectedFile.path : undefined
|
||||
selection?.type === "file" && selection.codebaseId === cb.id
|
||||
? selection.path
|
||||
: undefined
|
||||
}
|
||||
onSelectFile={(p) =>
|
||||
setSelectedFile({ codebaseId: cb.id, path: p })
|
||||
setSelection({ type: "file", codebaseId: cb.id, path: p })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
@@ -116,16 +157,19 @@ export default function ProductTab() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Right: file preview ── */}
|
||||
{/* ── Right: contextual preview ── */}
|
||||
<aside style={rightCol}>
|
||||
<h3 style={heading}>
|
||||
{selectedFile ? `Preview · ${shortPath(selectedFile.path)}` : "Preview"}
|
||||
</h3>
|
||||
<h3 style={heading}>{previewHeading(selection)}</h3>
|
||||
<div style={previewPanel}>
|
||||
<GiteaFileViewer
|
||||
projectId={projectId}
|
||||
path={selectedFile?.path ?? null}
|
||||
/>
|
||||
{selection?.type === "devContainer" && devContainer && (
|
||||
<DevContainerDetail container={devContainer} previewUrls={previewUrls} />
|
||||
)}
|
||||
{selection?.type === "file" && (
|
||||
<GiteaFileViewer projectId={projectId} path={selection.path} />
|
||||
)}
|
||||
{!selection && (
|
||||
<Empty>Pick a codebase file or the dev container on the left.</Empty>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
@@ -133,27 +177,50 @@ export default function ProductTab() {
|
||||
);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
function previewHeading(s: Selection): string {
|
||||
if (!s) return "Preview";
|
||||
if (s.type === "devContainer") return "Preview · Dev container";
|
||||
return `Preview · ${shortPath(s.path)}`;
|
||||
}
|
||||
function shortPath(p: string) {
|
||||
const parts = p.split("/");
|
||||
if (parts.length <= 2) return p;
|
||||
return ".../" + parts.slice(-2).join("/");
|
||||
}
|
||||
function colorForStatus(s?: string) {
|
||||
if (!s) return "#a09a90";
|
||||
if (/running|healthy/i.test(s)) return "#2e7d32";
|
||||
if (/starting|deploying/i.test(s)) return "#d4a04a";
|
||||
if (/exit|fail|unhealthy/i.test(s)) return "#c5392b";
|
||||
return "#a09a90";
|
||||
}
|
||||
|
||||
function Inline({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div style={{
|
||||
display: "flex", alignItems: "center", gap: 8,
|
||||
padding: "12px 14px", fontSize: "0.82rem", color: "#5f5e5a",
|
||||
background: "#fff", border: "1px solid #efebe1", borderRadius: 8,
|
||||
padding: "12px 14px", fontSize: "0.82rem", color: INK.mid,
|
||||
background: INK.cardBg, border: `1px solid ${INK.borderSoft}`, borderRadius: 8,
|
||||
}}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// Styles
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
function Empty({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div style={{
|
||||
flex: 1, display: "flex", alignItems: "center", justifyContent: "center",
|
||||
color: INK.mid, fontSize: "0.85rem", padding: "32px 16px", textAlign: "center",
|
||||
}}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
const INK = {
|
||||
ink: "#1a1a1a",
|
||||
@@ -170,7 +237,6 @@ const pageWrap: React.CSSProperties = {
|
||||
fontFamily: INK.fontSans,
|
||||
color: INK.ink,
|
||||
};
|
||||
|
||||
const grid: React.CSSProperties = {
|
||||
display: "grid",
|
||||
gridTemplateColumns: "minmax(280px, 360px) minmax(0, 1fr)",
|
||||
@@ -179,88 +245,47 @@ const grid: React.CSSProperties = {
|
||||
margin: "0 auto",
|
||||
alignItems: "stretch",
|
||||
};
|
||||
|
||||
const leftCol: React.CSSProperties = {
|
||||
minWidth: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
minWidth: 0, display: "flex", flexDirection: "column",
|
||||
};
|
||||
|
||||
const rightCol: React.CSSProperties = {
|
||||
minWidth: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
minWidth: 0, display: "flex", flexDirection: "column",
|
||||
};
|
||||
|
||||
const heading: React.CSSProperties = {
|
||||
fontSize: "0.72rem",
|
||||
fontWeight: 600,
|
||||
letterSpacing: "0.12em",
|
||||
textTransform: "uppercase",
|
||||
color: INK.muted,
|
||||
margin: "0 0 14px",
|
||||
fontSize: "0.72rem", fontWeight: 600, letterSpacing: "0.12em",
|
||||
textTransform: "uppercase", color: INK.muted, margin: "0 0 14px",
|
||||
};
|
||||
|
||||
const stack: React.CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 10,
|
||||
display: "flex", flexDirection: "column", gap: 10,
|
||||
};
|
||||
const flatTile: React.CSSProperties = {
|
||||
display: "flex", alignItems: "center", gap: 10,
|
||||
width: "100%", padding: "12px 14px",
|
||||
background: INK.cardBg, border: `1px solid ${INK.borderSoft}`, borderRadius: 10,
|
||||
cursor: "pointer", font: "inherit", color: "inherit",
|
||||
transition: "border-color 0.12s, background 0.12s, box-shadow 0.12s",
|
||||
};
|
||||
|
||||
const codebaseTile: React.CSSProperties = {
|
||||
background: INK.cardBg,
|
||||
border: `1px solid ${INK.borderSoft}`,
|
||||
borderRadius: 10,
|
||||
overflow: "hidden",
|
||||
background: INK.cardBg, border: `1px solid ${INK.borderSoft}`, borderRadius: 10, overflow: "hidden",
|
||||
};
|
||||
|
||||
const tileHeader: React.CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
width: "100%",
|
||||
padding: "12px 14px",
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
borderBottom: `1px solid transparent`,
|
||||
cursor: "pointer",
|
||||
font: "inherit",
|
||||
color: "inherit",
|
||||
display: "flex", alignItems: "center", gap: 8, width: "100%",
|
||||
padding: "12px 14px", background: "transparent", border: "none",
|
||||
cursor: "pointer", font: "inherit", color: "inherit",
|
||||
};
|
||||
|
||||
const tileLabel: React.CSSProperties = {
|
||||
fontSize: "0.85rem",
|
||||
fontWeight: 600,
|
||||
color: INK.ink,
|
||||
marginBottom: 2,
|
||||
fontSize: "0.85rem", fontWeight: 600, color: INK.ink, marginBottom: 2,
|
||||
};
|
||||
|
||||
const tileHint: React.CSSProperties = {
|
||||
fontSize: "0.74rem",
|
||||
color: INK.mid,
|
||||
lineHeight: 1.4,
|
||||
fontSize: "0.74rem", color: INK.mid, lineHeight: 1.4,
|
||||
};
|
||||
|
||||
const tileBody: React.CSSProperties = {
|
||||
padding: "8px 10px 12px",
|
||||
borderTop: `1px solid ${INK.borderSoft}`,
|
||||
padding: "8px 10px 12px", borderTop: `1px solid ${INK.borderSoft}`,
|
||||
};
|
||||
|
||||
const chevronCell: React.CSSProperties = {
|
||||
width: 14,
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
width: 14, display: "inline-flex", alignItems: "center", justifyContent: "center", flexShrink: 0,
|
||||
};
|
||||
|
||||
const previewPanel: React.CSSProperties = {
|
||||
background: INK.cardBg,
|
||||
border: `1px solid ${INK.border}`,
|
||||
borderRadius: 10,
|
||||
padding: 16,
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
background: INK.cardBg, border: `1px solid ${INK.border}`, borderRadius: 10,
|
||||
padding: 16, flex: 1, minHeight: 0, display: "flex", flexDirection: "column",
|
||||
};
|
||||
|
||||
@@ -50,6 +50,13 @@ interface DevService {
|
||||
status?: string;
|
||||
}
|
||||
|
||||
/** Dev container = the vibn-dev-* Coolify service this project edits in. */
|
||||
interface DevContainer {
|
||||
uuid: string;
|
||||
name: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
interface PreviewUrl {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -68,6 +75,9 @@ interface Anatomy {
|
||||
project: { id: string; name: string; gitea?: string; coolifyProjectUuid?: string };
|
||||
codebases: Codebase[];
|
||||
codebasesReason?: "no_repo" | "empty_repo";
|
||||
product: {
|
||||
devContainers: DevContainer[];
|
||||
};
|
||||
hosting: {
|
||||
production: ProductionApp[];
|
||||
services: DevService[];
|
||||
@@ -188,22 +198,22 @@ async function loadProductionApps(giteaRepo: string | undefined): Promise<Produc
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDevServices(coolifyProjectUuid: string | undefined): Promise<DevService[]> {
|
||||
/** Returns ALL services in the Coolify project. Caller splits dev
|
||||
* containers from deployed services by name prefix. */
|
||||
async function loadAllServices(coolifyProjectUuid: string | undefined): Promise<CoolifyService[]> {
|
||||
if (!coolifyProjectUuid) return [];
|
||||
try {
|
||||
const services = await listServicesInProject(coolifyProjectUuid);
|
||||
return services.map((s: CoolifyService) => ({
|
||||
uuid: s.uuid,
|
||||
name: s.name,
|
||||
serviceType: s.service_type,
|
||||
status: s.status,
|
||||
}));
|
||||
return await listServicesInProject(coolifyProjectUuid);
|
||||
} catch (err) {
|
||||
console.error("[anatomy] listServicesInProject failed:", err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function isDevContainer(svc: CoolifyService): boolean {
|
||||
return svc.name.startsWith("vibn-dev-");
|
||||
}
|
||||
|
||||
async function loadPreviewUrls(projectId: string): Promise<PreviewUrl[]> {
|
||||
try {
|
||||
const rows = await query<{
|
||||
@@ -291,7 +301,7 @@ export async function GET(
|
||||
"Project";
|
||||
|
||||
// Run the slow bits in parallel
|
||||
const [codebasesResult, production, services, previews] = await Promise.all([
|
||||
const [codebasesResult, production, allServices, previews] = await Promise.all([
|
||||
giteaRepo
|
||||
? discoverCodebases(giteaRepo).catch(err => {
|
||||
console.error("[anatomy] discoverCodebases failed:", err);
|
||||
@@ -299,10 +309,27 @@ export async function GET(
|
||||
})
|
||||
: Promise.resolve({ codebases: [] as Codebase[], reason: undefined as undefined }),
|
||||
loadProductionApps(giteaRepo),
|
||||
loadDevServices(coolifyProjectUuid),
|
||||
loadAllServices(coolifyProjectUuid),
|
||||
loadPreviewUrls(projectId),
|
||||
]);
|
||||
|
||||
// Split services: vibn-dev-* belong to Product (the dev workbench).
|
||||
// Everything else is a deployed service that belongs in Hosting.
|
||||
const devContainers: DevContainer[] = [];
|
||||
const deployedServices: DevService[] = [];
|
||||
for (const s of allServices) {
|
||||
if (isDevContainer(s)) {
|
||||
devContainers.push({ uuid: s.uuid, name: s.name, status: s.status });
|
||||
} else {
|
||||
deployedServices.push({
|
||||
uuid: s.uuid,
|
||||
name: s.name,
|
||||
serviceType: s.service_type,
|
||||
status: s.status,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const codebasesReason: "no_repo" | "empty_repo" | undefined = !giteaRepo
|
||||
? "no_repo"
|
||||
: codebasesResult.reason;
|
||||
@@ -316,9 +343,10 @@ export async function GET(
|
||||
},
|
||||
codebases: codebasesResult.codebases,
|
||||
codebasesReason,
|
||||
product: { devContainers },
|
||||
hosting: {
|
||||
production,
|
||||
services,
|
||||
services: deployedServices,
|
||||
previewUrls: previews,
|
||||
domains: dedupeDomains(production, previews),
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user