feat(project): unify Product+Hosting around code/images and live/previews
Anatomy + UI rewrite — locked the conceptual model after user feedback:
Product = "what makes up the thing you're shipping":
- Codebases (Gitea repos)
- Images (Coolify services backed by upstream Docker images: Twenty
CRM, n8n, etc.)
- Dev containers no longer surface here. The vibn-dev-* container is
the AI's workshop, not a product surface; previews it serves still
appear under Hosting → Previews.
Hosting = "where it lives + how it gets there", unified:
- Live: every running endpoint as one list. Each item carries a
source badge ("repo" | "image"), status dot, attached domain, and
last-build summary inline. No separate Build, Domains or Services
categories — those are properties on each Live item.
- Previews: dev container preview URLs (unchanged).
Anatomy endpoint reshaped accordingly:
- product.{codebases, images}
- hosting.{live, previews} (was production/services/previewUrls/domains)
- lastBuild summary fetched per repo-app via listApplicationDeployments
in parallel.
ProjectStagePill rewired to derive Live/Down/Building from hosting.live
+ hosting.previews. dev-container-detail.tsx removed.
services.* MCP tools added so AI agents can manage Coolify services
(Twenty CRM, n8n, …) the same way they manage apps:
- services.list, services.get
- services.start, services.stop
- services.envs.list, services.envs.upsert
All tenant-scoped via getServiceInWorkspace + getOwnedCoolifyProjectUuids.
vibn-dev-* containers stay hidden from services.list.
Made-with: Cursor
This commit is contained in:
@@ -1,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 },
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user