From 7b359e399e5d24b33bbfcf61ce19da926fe61979 Mon Sep 17 00:00:00 2001 From: Mark Henderson Date: Wed, 29 Apr 2026 15:22:58 -0700 Subject: [PATCH] feat(infra): collapse to 7 categories + live Postgres table inspection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UX rework after iteration with the user: - Drop SMS, Analytics, Search, Monitoring categories from the rail. They were detection-only with no first-class UX behind them; surface is cleaner without them and they can return when each gets real flows (auth-style "edit configurables", payment-style "connect"). - Storage no longer tries to detect S3/R2/GCS env vars. Instead it surfaces the workspace's bundled Vibn-provisioned GCS bucket (S3-compatible HMAC), with status, region, access id, and a one-shot env snippet for app config. - Email category no longer mixes in SMS providers. - LLM renamed to "Models"; empty state mentions BYOK as upcoming. - Payments empty state has a "Connect Stripe (coming soon)" CTA; Stripe detail surfaces the webhook URL guidance. - Secrets detail now lists actual env-var key names per resource, grouped by detected provider (Stripe block, OpenAI block, etc.) with an "Other (project-defined)" catch-all. Each row has Edit + Rotate icon buttons (currently disabled with tooltips — wire-up to apps.envs.upsert / services.envs.upsert lands in iter 2). Live database inspection (Postgres only for now): - New /api/projects/[id]/databases/[uuid]/tables — auth-scoped, lists user-tables across non-system schemas via SSH-exec into the database container's psql. Hard caps: 50 tables, 8s timeout, no mutating queries possible (only SELECT row_to_json with LIMIT). - New /api/projects/[id]/databases/[uuid]/preview — returns first 50 rows of a single table. Identifiers locked to /[A-Za-z0-9_]+/ so splicing them into the SELECT is safe. - DatabaseTableTree (lazy-fetch, schema-grouped, public-flat, approximate row counts from pg_class.reltuples) and TableViewer (sticky-header data grid, zebra rows, per-cell ellipsis at 360px). - Fix in lib/coolify.ts: listDatabasesInProject was flattening every db endpoint array (postgresqls, redises, mongodbs…) without tagging the output rows with the engine. Every consumer was seeing type=undefined which then bucketed as "unknown" and blocked the table inspector. Now we tag at the flatten step so every CoolifyDatabase has a stable type. - Infrastructure tab: database tile is now expandable inline like Codebases on Product. Auto-expands the first DB; click any table to preview rows on the right. Made-with: Cursor --- .../(home)/infrastructure/page.tsx | 1135 +++++++++++++---- app/api/projects/[projectId]/anatomy/route.ts | 180 ++- .../databases/[dbUuid]/preview/route.ts | 88 ++ .../databases/[dbUuid]/tables/route.ts | 82 ++ components/project/database-table-tree.tsx | 238 ++++ components/project/table-viewer.tsx | 180 +++ components/project/use-anatomy.ts | 14 +- lib/coolify.ts | 33 +- lib/db-introspect.ts | 211 +++ 9 files changed, 1861 insertions(+), 300 deletions(-) create mode 100644 app/api/projects/[projectId]/databases/[dbUuid]/preview/route.ts create mode 100644 app/api/projects/[projectId]/databases/[dbUuid]/tables/route.ts create mode 100644 components/project/database-table-tree.tsx create mode 100644 components/project/table-viewer.tsx create mode 100644 lib/db-introspect.ts diff --git a/app/[workspace]/project/[projectId]/(home)/infrastructure/page.tsx b/app/[workspace]/project/[projectId]/(home)/infrastructure/page.tsx index 1c638d6a..310fb01b 100644 --- a/app/[workspace]/project/[projectId]/(home)/infrastructure/page.tsx +++ b/app/[workspace]/project/[projectId]/(home)/infrastructure/page.tsx @@ -1,51 +1,107 @@ "use client"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useParams } from "next/navigation"; import { Loader2, AlertCircle, Database, KeyRound, CircleDot, - ShieldCheck, Mail, MessageSquare, CreditCard, BarChart3, - Sparkles, HardDrive, Search, Activity, + ShieldCheck, Mail, CreditCard, Sparkles, HardDrive, + ExternalLink, Info, Pencil, RotateCw, + ChevronDown, ChevronRight, } from "lucide-react"; import { useAnatomy, type Anatomy } from "@/components/project/use-anatomy"; +import { DatabaseTableTree } from "@/components/project/database-table-tree"; +import { TableViewer } from "@/components/project/table-viewer"; /** * Infrastructure tab — supporting plumbing the product runs on. * - * Three sub-areas in the left rail (same tile-rail pattern as Product - * and Hosting): - * - Databases — Coolify-managed Postgres / Redis / Mongo / etc. - * - Providers — third-party services detected from env-var keys - * (Stripe, Resend, OpenAI…). Each tile shows the vendor - * + which app/service the keys live in. - * - Secrets — env-var totals per app/service. Counts only; values - * stay redacted. + * Six fixed sub-areas, always visible (even when empty) so a founder + * learns the model on a brand-new project: * - * Anything visible here is real — pulled live from Coolify on each - * load. The tab is empty when nothing is connected; it never shows - * static placeholders. + * Databases · Auth · Email · Payments · Models · Storage · Secrets + * + * Categories explicitly NOT shown (yet): + * - SMS, Analytics, Search, Monitoring — removed Apr 29 2026 to keep + * the surface focused on what's actually wired today. Add back when + * each gets real UX (not just env-var detection). + * + * Storage is special: it's the workspace's bundled GCS-via-HMAC bucket + * (S3-compatible) — not a third-party detection. Every workspace gets + * one auto-provisioned, so the tile is always there with status. */ +type ProviderCategory = Anatomy["infrastructure"]["providers"][number]["category"]; + +type CategoryKey = + | "databases" + | "auth" | "email" | "payments" | "llm" + | "storage" + | "secrets"; + type Selection = + | { kind: "category"; category: CategoryKey } | { kind: "database"; uuid: string } + | { kind: "table"; dbUuid: string; schema: string; name: string } | { kind: "provider"; id: string } + | { kind: "storage" } | { kind: "secrets"; resourceUuid: string } | null; -const CATEGORY_META: Record< - Anatomy["infrastructure"]["providers"][number]["category"], - { label: string; icon: React.ComponentType<{ size?: number; style?: React.CSSProperties }> } -> = { - auth: { label: "Auth", icon: ShieldCheck }, - email: { label: "Email", icon: Mail }, - sms: { label: "SMS", icon: MessageSquare }, - payments: { label: "Payments", icon: CreditCard }, - analytics: { label: "Analytics", icon: BarChart3 }, - llm: { label: "LLM", icon: Sparkles }, - storage: { label: "Storage", icon: HardDrive }, - search: { label: "Search", icon: Search }, - monitoring: { label: "Monitoring", icon: Activity }, -}; +interface CategoryDef { + key: CategoryKey; + label: string; + icon: React.ComponentType<{ size?: number; style?: React.CSSProperties }>; + blurb: string; + examples: string[]; + /** Vendor → external dashboard URL */ + dashboards?: Record; +} + +const CATEGORIES: CategoryDef[] = [ + { + key: "databases", label: "Databases", icon: Database, + blurb: "Postgres, Redis, Mongo, ClickHouse — anything your app reads or writes from.", + examples: ["PostgreSQL", "Redis", "MongoDB", "ClickHouse", "MySQL"], + }, + { + key: "auth", label: "Auth", icon: ShieldCheck, + blurb: "Identity, sessions, SSO. Detected from env-var keys (CLERK_*, AUTH0_*, NEXTAUTH_*…).", + examples: ["Clerk", "Auth0", "Supabase Auth", "NextAuth", "WorkOS", "SuperTokens"], + dashboards: { Clerk: "https://dashboard.clerk.com", Auth0: "https://manage.auth0.com", "Supabase Auth": "https://supabase.com/dashboard", WorkOS: "https://dashboard.workos.com" }, + }, + { + key: "email", label: "Email", icon: Mail, + blurb: "Transactional + outbound email. SMS is intentionally not here yet.", + examples: ["Resend", "Postmark", "SendGrid", "Mailgun", "AWS SES", "Loops"], + dashboards: { Resend: "https://resend.com/emails", Postmark: "https://account.postmarkapp.com", SendGrid: "https://app.sendgrid.com", Mailgun: "https://app.mailgun.com", "AWS SES": "https://console.aws.amazon.com/ses" }, + }, + { + key: "payments", label: "Payments", icon: CreditCard, + blurb: "Stripe / Paddle / LemonSqueezy. Connect Stripe to enable webhook + checkout setup (coming soon).", + examples: ["Stripe", "LemonSqueezy", "Paddle"], + dashboards: { Stripe: "https://dashboard.stripe.com", LemonSqueezy: "https://app.lemonsqueezy.com", Paddle: "https://vendors.paddle.com" }, + }, + { + key: "llm", label: "Models", icon: Sparkles, + blurb: "AI models the project calls. BYOK (Bring Your Own Key) lets you use your own provider keys instead of platform defaults.", + examples: ["OpenAI", "Anthropic", "Google AI", "Mistral", "Cohere", "Groq", "OpenRouter"], + dashboards: { OpenAI: "https://platform.openai.com", Anthropic: "https://console.anthropic.com", "Google AI": "https://aistudio.google.com", Mistral: "https://console.mistral.ai", Groq: "https://console.groq.com", OpenRouter: "https://openrouter.ai" }, + }, + { + key: "storage", label: "Storage", icon: HardDrive, + blurb: "Vibn provisions an S3-compatible bucket per workspace. Every project in the workspace shares it.", + examples: ["GCS bucket (S3-compatible)"], + }, + { + key: "secrets", label: "Secrets", icon: KeyRound, + blurb: "Every env var across every app + service. Values are never read here; rotate via the AI chat.", + examples: [], + }, +]; + +function categoryDef(key: CategoryKey): CategoryDef { + return CATEGORIES.find(c => c.key === key)!; +} export default function InfrastructureTab() { const params = useParams(); @@ -53,6 +109,25 @@ export default function InfrastructureTab() { const { anatomy, loading, error } = useAnatomy(projectId); const [selection, setSelection] = useState(null); + /** Which database tiles are expanded inline to show their tables. */ + const [expandedDbs, setExpandedDbs] = useState>(new Set()); + + const toggleDb = (uuid: string) => { + setExpandedDbs(prev => { + const next = new Set(prev); + if (next.has(uuid)) next.delete(uuid); else next.add(uuid); + return next; + }); + }; + + // Auto-expand the first database tile once anatomy lands so the user + // sees tables immediately on a project that has one DB. + useEffect(() => { + if (anatomy && anatomy.infrastructure.databases[0] && expandedDbs.size === 0) { + setExpandedDbs(new Set([anatomy.infrastructure.databases[0].uuid])); + } + }, [anatomy, expandedDbs.size]); + const showLoading = loading && !anatomy; return ( @@ -66,112 +141,50 @@ export default function InfrastructureTab() { {error && !showLoading && ( {error} )} - {anatomy && ( - <> - - {anatomy.infrastructure.databases.map(db => { - const active = selection?.kind === "database" && selection.uuid === db.uuid; - return ( - - ); - })} - - - {anatomy.infrastructure.providers.map(p => { - const active = selection?.kind === "provider" && selection.id === p.id; - const meta = CATEGORY_META[p.category]; - const Icon = meta.icon; - return ( - - ); - })} - - - - {anatomy.infrastructure.secrets.byResource.map(r => { - const active = selection?.kind === "secrets" && selection.resourceUuid === r.resourceUuid; - return ( - - ); - })} - - - )} + {anatomy && CATEGORIES.map(def => ( + + ))} {/* ── Right pane ── */} @@ -180,121 +193,667 @@ export default function InfrastructureTab() { } // ────────────────────────────────────────────────── -// Detail pane +// Left-rail group per category // ────────────────────────────────────────────────── -function Detail({ selection, anatomy }: { selection: Selection; anatomy: Anatomy }) { - if (!selection) return null; +function CategoryRail({ + def, anatomy, selection, onSelect, + projectId, expandedDbs, onToggleDb, +}: { + def: CategoryDef; + anatomy: Anatomy; + selection: Selection; + onSelect: (s: Selection) => void; + projectId: string; + expandedDbs: Set; + onToggleDb: (uuid: string) => void; +}) { + const Icon = def.icon; + const headerActive = selection?.kind === "category" && selection.category === def.key; - if (selection.kind === "database") { - const db = anatomy.infrastructure.databases.find(d => d.uuid === selection.uuid); - if (!db) return This database is no longer in the project.; + // Storage is a single-tile category backed by workspace state + if (def.key === "storage") { + const s = anatomy.infrastructure.bundledStorage; + const present = s.status !== "unprovisioned"; + const active = selection?.kind === "storage"; return ( - - - - - {db.publicPort != null && } - - ); - } - - if (selection.kind === "provider") { - const p = anatomy.infrastructure.providers.find(x => x.id === selection.id); - if (!p) return This provider is no longer detected.; - const meta = CATEGORY_META[p.category]; - return ( - - - - -
- {p.attachments.map(att => ( -
-
- {att.resourceName} {att.resourceKind} +
+ onSelect({ kind: "category", category: def.key })} /> + {present ? ( +
+
- ))} -
- + + +
+ ) : ( + + )} +
); } - if (selection.kind === "secrets") { - const r = anatomy.infrastructure.secrets.byResource.find(x => x.resourceUuid === selection.resourceUuid); - if (!r) return This resource is no longer in the project.; + // Databases render as expandable cards (like Codebases on Product) so + // the table list lives inline; click a table to preview rows on the right. + if (def.key === "databases") { + const dbs = anatomy.infrastructure.databases; return ( - - - - -
- Values are never returned to this page. To read or edit a key, - use the AI chat — it can list and update env vars in this - resource via tenant-scoped MCP tools. -
-
+
+ onSelect({ kind: "category", category: def.key })} /> + {dbs.length === 0 ? ( + + ) : ( +
+ {dbs.map(db => { + const open = expandedDbs.has(db.uuid); + const tileSelected = + selection?.kind === "database" && selection.uuid === db.uuid; + const selectedTable = + selection?.kind === "table" && selection.dbUuid === db.uuid + ? { schema: selection.schema, name: selection.name } + : undefined; + + return ( +
+ + {open && ( +
+ + onSelect({ kind: "table", dbUuid: db.uuid, schema, name }) + } + /> +
+ )} +
+ ); + })} +
+ )} +
); } - return null; -} + const items = itemsForCategory(def.key, anatomy); + const count = + def.key === "secrets" + ? anatomy.infrastructure.secrets.total + : items.length; -function paneHeading(s: Selection, a: Anatomy | null): string { - if (!s || !a) return "Details"; - if (s.kind === "database") return `Details · ${a.infrastructure.databases.find(x => x.uuid === s.uuid)?.name ?? "Database"}`; - if (s.kind === "provider") return `Details · ${a.infrastructure.providers.find(x => x.id === s.id)?.vendor ?? "Provider"}`; - if (s.kind === "secrets") return `Details · ${a.infrastructure.secrets.byResource.find(x => x.resourceUuid === s.resourceUuid)?.resourceName ?? "Secrets"}`; - return "Details"; -} - -// ────────────────────────────────────────────────── -// Bits -// ────────────────────────────────────────────────── - -function RailGroup({ - title, count, emptyHint, children, -}: { title: string; count: number; emptyHint: string; children: React.ReactNode }) { return (
-
- {title} - {count} -
- {count === 0 ? ( -
{emptyHint}
+ onSelect({ kind: "category", category: def.key })} /> + {items.length === 0 ? ( + ) : ( -
{children}
+
+ {items.map(item => renderRailItem(def.key, item, selection, onSelect))} +
)}
); } -function DetailLayout({ children }: { children: React.ReactNode }) { - return
{children}
; +function CategoryHeader({ + def, count, active, onClick, +}: { + def: CategoryDef; count: number; active: boolean; onClick: () => void; +}) { + const Icon = def.icon; + return ( + + ); } -function DetailRow({ - label, value, dot, -}: { label: string; value: string; dot?: string }) { +function itemsForCategory(key: CategoryKey, a: Anatomy): unknown[] { + if (key === "databases") return a.infrastructure.databases; + if (key === "secrets") return a.infrastructure.secrets.byResource; + if (key === "storage") return []; + return a.infrastructure.providers.filter(p => p.category === key); +} + +function renderRailItem( + key: CategoryKey, + item: unknown, + selection: Selection, + onSelect: (s: Selection) => void +) { + if (key === "databases") { + const db = item as Anatomy["infrastructure"]["databases"][number]; + const active = selection?.kind === "database" && selection.uuid === db.uuid; + return ( + + ); + } + if (key === "secrets") { + const r = item as Anatomy["infrastructure"]["secrets"]["byResource"][number]; + const active = selection?.kind === "secrets" && selection.resourceUuid === r.resourceUuid; + return ( + + ); + } + // Provider + const p = item as Anatomy["infrastructure"]["providers"][number]; + const active = selection?.kind === "provider" && selection.id === p.id; + const totalKeys = p.attachments.reduce((n, a) => n + a.keys.length, 0); + return ( + + ); +} + +// ────────────────────────────────────────────────── +// Right-pane content +// ────────────────────────────────────────────────── + +function Overview({ + anatomy, onJump, +}: { anatomy: Anatomy; onJump: (s: Selection) => void }) { + const dbCount = anatomy.infrastructure.databases.length; + const providerCount = anatomy.infrastructure.providers.length; + const secretsCount = anatomy.infrastructure.secrets.total; + const storage = anatomy.infrastructure.bundledStorage; + + return ( +
+ + Infrastructure here is auto-discovered from your Coolify + project + the env vars across every app and service in it. Click any + tile on the left for live details. Click a category header (or its + empty hint) to learn what lives there. + + +
+ dbCount && onJump({ kind: "database", uuid: anatomy.infrastructure.databases[0].uuid })} + /> + providerCount && onJump({ kind: "provider", id: anatomy.infrastructure.providers[0].id })} + /> + onJump({ kind: "storage" })} + /> + secretsCount && onJump({ kind: "secrets", resourceUuid: anatomy.infrastructure.secrets.byResource[0].resourceUuid })} + /> +
+
+ ); +} + +function CategoryDetail({ + def, anatomy, onJump, +}: { def: CategoryDef; anatomy: Anatomy; onJump: (s: Selection) => void }) { + const items = itemsForCategory(def.key, anatomy); + const count = + def.key === "secrets" ? anatomy.infrastructure.secrets.total : + def.key === "storage" ? (anatomy.infrastructure.bundledStorage.status === "unprovisioned" ? 0 : 1) : + items.length; + + // Special CTAs per category + let actionRow: React.ReactNode = null; + if (def.key === "payments" && !items.length) { + actionRow = ( + + ); + } else if (def.key === "llm") { + actionRow = ( +
+ + BYOK (Bring Your Own Key) lets you use your own model provider keys + instead of platform defaults — coming soon. Today, set keys via env vars + and they'll appear here. +
+ ); + } else if (def.key === "storage") { + actionRow = ( + + ); + } + + return ( +
+ {def.label}. {def.blurb} + + {def.examples.length > 0 && ( +
+ Examples +
+ {def.examples.map(ex => {ex})} +
+
+ )} + + {actionRow} + +
+ + Connected{" "} + ({count}) + + {count === 0 ? ( +
+ + Nothing detected yet. Set a matching env var (or provision a Coolify + resource) and it'll appear here on next reload. +
+ ) : def.key === "secrets" ? ( +
+ {anatomy.infrastructure.secrets.byResource.map(r => ( +
+ {r.resourceName} + {r.count} keys · {r.resourceKind} +
+ ))} +
+ ) : def.key === "databases" ? ( +
+ {anatomy.infrastructure.databases.map(db => ( +
+ {db.name} + {db.type} · {db.status} +
+ ))} +
+ ) : def.key === "storage" ? ( +
+
+ Workspace bucket + {anatomy.infrastructure.bundledStorage.bucketName ?? "—"} +
+
+ ) : ( +
+ {(items as Anatomy["infrastructure"]["providers"]).map(p => ( +
+ {p.vendor} + + {p.attachments.length} resource{p.attachments.length === 1 ? "" : "s"} + +
+ ))} +
+ )} +
+
+ ); +} + +function DatabaseDetail({ uuid, anatomy }: { uuid: string; anatomy: Anatomy }) { + const db = anatomy.infrastructure.databases.find(d => d.uuid === uuid); + if (!db) return This database is no longer in the project.; + + return ( +
+ + + + {db.publicPort != null && } + {db.internalAddress && } + +
+ + Expand this database in the left rail to browse tables; click any + table there to preview rows here. +
+ +
+ How apps connect +
+ {db.consumerEnvKey}={""} +
+ + Set {db.consumerEnvKey} on any app or + service that needs to talk to this database. In-cluster apps reach it + at {db.internalAddress ?? db.name}. + +
+
+ ); +} + +function ProviderDetail({ id, anatomy }: { id: string; anatomy: Anatomy }) { + const p = anatomy.infrastructure.providers.find(x => x.id === id); + if (!p) return This provider is no longer detected.; + const def = categoryDef(p.category); + const dashboard = def.dashboards?.[p.vendor]; + const totalKeys = p.attachments.reduce((n, a) => n + a.keys.length, 0); + + // Stripe-specific connection callout + const isStripe = p.vendor === "Stripe"; + + return ( +
+ + + + + {dashboard && ( + + Open {p.vendor} dashboard + + )} + + {isStripe && ( +
+ + Stripe's webhook URL should point at{" "} + https://<your-app-domain>/api/stripe/webhook. + Full Connect-with-Stripe OAuth flow is coming next so we can wire + webhook secret + price IDs automatically. +
+ )} + +
+ Detected here + {p.attachments.map(att => ( +
+
+ {att.resourceName} {att.resourceKind} +
+
+ {att.keys.map(k => {k})} +
+
+ ))} +
+ + + + Values aren't shown here. To read or edit a key, open the matching + resource under Secrets or ask the AI in chat. + +
+ ); +} + +function StorageDetail({ anatomy }: { anatomy: Anatomy }) { + const s = anatomy.infrastructure.bundledStorage; + + if (s.status === "unprovisioned") { + return ( +
+ + This workspace doesn't have a bucket provisioned yet. The first time + you ask the AI to upload, store or serve files, Vibn will create one + automatically. + +
+ + You can also provision it now from the workspace settings (coming soon). +
+
+ ); + } + + return ( +
+ + {s.bucketName && } + {s.region && } + {s.hmacAccessId && } + +
+ How apps connect +
+ +{`STORAGE_ENDPOINT=https://storage.googleapis.com +STORAGE_REGION=${s.region ?? "auto"} +STORAGE_BUCKET=${s.bucketName ?? ""} +STORAGE_ACCESS_KEY_ID=${s.hmacAccessId ?? ""} +STORAGE_SECRET_ACCESS_KEY=`} + +
+ + Vibn-bundled storage is GCS exposed via the S3-compatible HMAC + interop API, so any AWS S3 SDK works. The secret access key is + encrypted at rest and never returned to the browser — to inject it + into an app, ask the AI to run{" "} + services.envs.upsert. + +
+ + {s.errorMessage && ( +
+ + {s.errorMessage} +
+ )} +
+ ); +} + +function SecretsDetail({ + resourceUuid, anatomy, +}: { resourceUuid: string; anatomy: Anatomy }) { + const r = anatomy.infrastructure.secrets.byResource.find(x => x.resourceUuid === resourceUuid); + if (!r) return This resource is no longer in the project.; + + // Group keys by detected provider so the user sees Stripe / Resend / + // OpenAI bunched together with an Other catch-all for unrecognised keys. + const providerByKey = new Map(); + for (const p of anatomy.infrastructure.providers) { + const att = p.attachments.find(a => a.resourceUuid === resourceUuid); + if (!att) continue; + for (const k of att.keys) { + providerByKey.set(k, { vendor: p.vendor, category: categoryDef(p.category as CategoryKey).label }); + } + } + + const groups = new Map(); + for (const k of r.keys) { + const tag = providerByKey.get(k); + const groupKey = tag ? `${tag.vendor}` : "Other"; + if (!groups.has(groupKey)) { + groups.set(groupKey, { label: tag ? `${tag.vendor} · ${tag.category}` : "Other (project-defined)", keys: [] }); + } + groups.get(groupKey)!.keys.push(k); + } + + return ( +
+ + + + +
+ Keys + + Values are never read on this surface. Edit sets a new + value via {r.resourceKind}s.envs.upsert;{" "} + Rotate opens AI chat with a pre-filled rotation request + for that key. (Both wire-ups land in the next iteration.) + +
+ {[...groups.values()].map(g => ( +
+
{g.label}
+ {g.keys.map(k => ( +
+ + {k} + +
+ + +
+
+ ))} +
+ ))} +
+
+
+ ); +} + +// ────────────────────────────────────────────────── +// Tiny helpers +// ────────────────────────────────────────────────── + +function paneHeading(s: Selection, a: Anatomy | null): string { + if (!a) return "Details"; + if (!s) return "Overview"; + if (s.kind === "category") return `About · ${categoryDef(s.category).label}`; + if (s.kind === "database") return `Database · ${a.infrastructure.databases.find(x => x.uuid === s.uuid)?.name ?? ""}`; + if (s.kind === "table") return `Preview · ${s.schema === "public" ? s.name : `${s.schema}.${s.name}`}`; + if (s.kind === "provider") return `Provider · ${a.infrastructure.providers.find(x => x.id === s.id)?.vendor ?? ""}`; + if (s.kind === "storage") return "Storage · Workspace bucket"; + if (s.kind === "secrets") return `Secrets · ${a.infrastructure.secrets.byResource.find(x => x.resourceUuid === s.resourceUuid)?.resourceName ?? ""}`; + return "Details"; +} + +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"; +} +function storageColor(status: string) { + if (status === "ready") return "#2e7d32"; + if (status === "pending" || status === "partial") return "#d4a04a"; + if (status === "error") return "#c5392b"; + return "#a09a90"; +} + +function tileButtonStyle(active: boolean): React.CSSProperties { + return { + ...railItem, + borderColor: active ? INK.ink : INK.borderSoft, + boxShadow: active ? `0 0 0 1px ${INK.ink}` : "none", + background: active ? "#fffdf8" : INK.cardBg, + }; +} + +function KvRow({ + label, value, dot, mono, +}: { label: string; value: string; dot?: string; mono?: boolean }) { return (
{label} {dot && } - {value} + + {value} +
); } +function SectionTitle({ children }: { children: React.ReactNode }) { + return
{children}
; +} + +function Para({ children, style }: { children: React.ReactNode; style?: React.CSSProperties }) { + return

{children}

; +} + function Inline({ children }: { children: React.ReactNode }) { return (
void }) { + return ( + + ); } // ────────────────────────────────────────────────── @@ -354,7 +925,7 @@ const grid: React.CSSProperties = { alignItems: "stretch", }; const leftCol: React.CSSProperties = { - minWidth: 0, display: "flex", flexDirection: "column", gap: 18, + minWidth: 0, display: "flex", flexDirection: "column", gap: 14, }; const rightCol: React.CSSProperties = { minWidth: 0, display: "flex", flexDirection: "column", @@ -366,7 +937,8 @@ const heading: React.CSSProperties = { const railGroup: React.CSSProperties = { display: "flex", flexDirection: "column" }; const railGroupHeader: React.CSSProperties = { display: "flex", alignItems: "center", justifyContent: "space-between", - padding: "0 4px 8px", + padding: "6px 8px", borderRadius: 6, + cursor: "pointer", font: "inherit", color: "inherit", }; const railGroupTitle: React.CSSProperties = { fontSize: "0.68rem", fontWeight: 600, letterSpacing: "0.12em", @@ -376,7 +948,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: 6, marginTop: 4 }; const railItem: React.CSSProperties = { display: "flex", alignItems: "center", gap: 10, width: "100%", padding: "10px 12px", @@ -384,10 +956,29 @@ const railItem: React.CSSProperties = { cursor: "pointer", font: "inherit", color: "inherit", transition: "border-color 0.12s, background 0.12s, box-shadow 0.12s", }; -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 railEmptyButton: React.CSSProperties = { + width: "100%", textAlign: "left", + padding: "8px 12px", fontSize: "0.74rem", color: INK.muted, + background: "transparent", + border: `1px dashed ${INK.borderSoft}`, borderRadius: 8, + lineHeight: 1.45, cursor: "pointer", marginTop: 4, + fontFamily: "inherit", +}; +const tileBody: React.CSSProperties = { minWidth: 0, textAlign: "left", flex: 1 }; +const dbCard: React.CSSProperties = { + background: INK.cardBg, border: `1px solid ${INK.borderSoft}`, + borderRadius: 10, overflow: "hidden", +}; +const dbCardHeader: React.CSSProperties = { + display: "flex", alignItems: "center", gap: 8, width: "100%", + padding: "10px 12px", background: "transparent", border: "none", + cursor: "pointer", font: "inherit", color: "inherit", +}; +const dbCardBody: 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 tileLabel: React.CSSProperties = { fontSize: "0.85rem", fontWeight: 600, color: INK.ink, marginBottom: 2, @@ -395,13 +986,15 @@ const tileLabel: React.CSSProperties = { const tileHint: React.CSSProperties = { fontSize: "0.74rem", color: INK.mid, lineHeight: 1.4, textTransform: "capitalize", }; +const iconStyle: React.CSSProperties = { color: INK.mid, flexShrink: 0 }; const panel: React.CSSProperties = { background: INK.cardBg, border: `1px solid ${INK.border}`, borderRadius: 10, - padding: 16, flex: 1, minHeight: 0, display: "flex", flexDirection: "column", + padding: 18, flex: 1, minHeight: 0, display: "flex", flexDirection: "column", + overflowY: "auto", }; const detailRow: React.CSSProperties = { display: "flex", alignItems: "center", justifyContent: "space-between", - padding: "12px 4px", borderBottom: `1px solid ${INK.borderSoft}`, + padding: "10px 4px", borderBottom: `1px solid ${INK.borderSoft}`, }; const detailLabel: React.CSSProperties = { fontSize: "0.72rem", fontWeight: 600, letterSpacing: "0.06em", @@ -410,6 +1003,80 @@ const detailLabel: React.CSSProperties = { const detailValue: React.CSSProperties = { fontSize: "0.85rem", color: INK.ink, display: "inline-flex", alignItems: "center", }; +const sectionTitle: React.CSSProperties = { + fontSize: "0.72rem", fontWeight: 600, letterSpacing: "0.08em", + textTransform: "uppercase", color: INK.muted, marginBottom: 8, +}; +const para: React.CSSProperties = { + margin: 0, fontSize: "0.85rem", color: INK.ink, lineHeight: 1.55, +}; +const overviewGrid: React.CSSProperties = { + display: "grid", gridTemplateColumns: "repeat(2, minmax(0, 1fr))", gap: 10, +}; +const overviewStat: React.CSSProperties = { + display: "flex", flexDirection: "column", alignItems: "flex-start", gap: 4, + padding: "14px 16px", border: `1px solid ${INK.borderSoft}`, borderRadius: 10, + background: INK.cardBg, color: INK.ink, font: "inherit", +}; +const overviewStatValue: React.CSSProperties = { + fontSize: "1.6rem", fontWeight: 600, lineHeight: 1, +}; +const overviewStatLabel: React.CSSProperties = { + fontSize: "0.7rem", color: INK.muted, letterSpacing: "0.08em", textTransform: "uppercase", +}; +const chipRow: React.CSSProperties = { display: "flex", flexWrap: "wrap", gap: 6 }; +const chip: React.CSSProperties = { + fontSize: "0.74rem", color: INK.mid, + padding: "3px 9px", borderRadius: 999, + background: "#fafaf6", border: `1px solid ${INK.borderSoft}`, +}; +const listBox: React.CSSProperties = { + display: "flex", flexDirection: "column", + border: `1px solid ${INK.borderSoft}`, borderRadius: 8, overflow: "hidden", +}; +const listRow: React.CSSProperties = { + display: "flex", alignItems: "center", justifyContent: "space-between", + padding: "10px 12px", fontSize: "0.82rem", + borderBottom: `1px solid ${INK.borderSoft}`, +}; +const emptyBox: React.CSSProperties = { + padding: "12px 14px", fontSize: "0.82rem", color: INK.mid, + background: "#fafaf6", border: `1px dashed ${INK.borderSoft}`, borderRadius: 8, + lineHeight: 1.5, +}; +const errorBox: React.CSSProperties = { + padding: "12px 14px", fontSize: "0.82rem", color: "#7a1f15", + background: "#fbe9e7", border: `1px solid #f4c2bc`, borderRadius: 8, + lineHeight: 1.5, +}; +const codeBox: React.CSSProperties = { + padding: "10px 12px", + background: "#fafaf6", + border: `1px solid ${INK.borderSoft}`, borderRadius: 8, + overflowX: "auto", + whiteSpace: "pre", +}; +const code: React.CSSProperties = { + fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', + fontSize: "0.78rem", color: INK.ink, whiteSpace: "pre", +}; +const inlineCode: React.CSSProperties = { + fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', + fontSize: "0.78rem", padding: "1px 5px", + background: "#fafaf6", border: `1px solid ${INK.borderSoft}`, borderRadius: 4, +}; +const dashboardLink: React.CSSProperties = { + display: "inline-flex", alignItems: "center", gap: 6, + padding: "8px 12px", borderRadius: 8, + background: INK.ink, color: "#fff", fontSize: "0.82rem", fontWeight: 600, + textDecoration: "none", alignSelf: "flex-start", +}; +const ctaButton: React.CSSProperties = { + alignSelf: "flex-start", + padding: "8px 14px", borderRadius: 8, + background: INK.ink, color: "#fff", fontSize: "0.82rem", fontWeight: 600, + border: "none", cursor: "pointer", font: "inherit", +}; const attachmentBlock: React.CSSProperties = { padding: "10px 12px", marginTop: 8, background: "#fafaf6", borderRadius: 8, @@ -430,3 +1097,23 @@ const keyChip: React.CSSProperties = { background: INK.cardBg, border: `1px solid ${INK.borderSoft}`, borderRadius: 4, fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', }; +const secretGroupHeader: React.CSSProperties = { + padding: "8px 12px", fontSize: "0.7rem", color: INK.muted, + letterSpacing: "0.08em", textTransform: "uppercase", + background: "#fafaf6", +}; +const secretRow: React.CSSProperties = { + display: "flex", alignItems: "center", justifyContent: "space-between", + padding: "8px 12px", + borderTop: `1px solid ${INK.borderSoft}`, +}; +const secretActions: React.CSSProperties = { + display: "flex", gap: 4, +}; +const iconButton: React.CSSProperties = { + display: "inline-flex", alignItems: "center", justifyContent: "center", + width: 24, height: 24, padding: 0, + background: "transparent", border: `1px solid ${INK.borderSoft}`, + borderRadius: 4, cursor: "not-allowed", color: INK.muted, + font: "inherit", +}; diff --git a/app/api/projects/[projectId]/anatomy/route.ts b/app/api/projects/[projectId]/anatomy/route.ts index d82a4f0d..c488fb56 100644 --- a/app/api/projects/[projectId]/anatomy/route.ts +++ b/app/api/projects/[projectId]/anatomy/route.ts @@ -37,6 +37,8 @@ import { type CoolifyService, type CoolifyDatabase, } from "@/lib/coolify"; +import { getWorkspaceGcsState } from "@/lib/workspace-gcs"; +import { VIBN_GCS_LOCATION } from "@/lib/gcp/storage"; const GITEA_API_URL = process.env.GITEA_API_URL ?? "https://git.vibnai.com"; const GITEA_API_TOKEN = process.env.GITEA_API_TOKEN ?? ""; @@ -105,6 +107,11 @@ interface InfraDatabase { status: string; isPublic: boolean; publicPort?: number; + /** "host:port" for the in-cluster reachable DB (no creds). */ + internalAddress?: string; + /** Stable env-var key apps should set to consume this DB + * (DATABASE_URL for SQL, REDIS_URL for Redis, etc.). */ + consumerEnvKey: string; } /** A non-database third-party provider detected by env-var pattern. @@ -113,9 +120,7 @@ interface InfraDatabase { interface InfraProvider { /** Stable id used by the UI for selection */ id: string; - category: - | "auth" | "email" | "sms" | "payments" - | "analytics" | "llm" | "storage" | "search" | "monitoring"; + category: "auth" | "email" | "payments" | "llm" | "storage"; vendor: string; // "Stripe", "Resend", "OpenAI", … /** Where the env keys for this provider live */ attachments: Array<{ @@ -126,15 +131,32 @@ interface InfraProvider { }>; } +/** Workspace-bundled S3 (GCS) storage that Vibn provisions for each + * workspace. Same record across every project in the workspace — we + * surface it on each project's Infrastructure tab so users can see + * what's available without going to settings. */ +interface BundledStorage { + status: "ready" | "pending" | "partial" | "error" | "unprovisioned"; + bucketName?: string; + hmacAccessId?: string; + region?: string; + errorMessage?: string; +} + interface InfraSecretSummary { /** Total number of env vars across every app + service in the project */ total: number; - /** Per-resource breakdown for drill-down */ + /** Per-resource breakdown for drill-down. Includes the actual env-var + * KEYS (never values) so the Secrets detail pane can show what's + * set. Values are intentionally excluded from this surface — to + * read or rotate them, route through apps.envs.* / services.envs.* + * MCP tools which audit-log and tenant-scope every access. */ byResource: Array<{ resourceUuid: string; resourceName: string; resourceKind: "app" | "service"; count: number; + keys: string[]; }>; } @@ -152,6 +174,7 @@ interface Anatomy { infrastructure: { databases: InfraDatabase[]; providers: InfraProvider[]; + bundledStorage: BundledStorage; secrets: InfraSecretSummary; }; } @@ -319,18 +342,43 @@ function dbTypeOf(d: CoolifyDatabase): string { return raw || "database"; } +/** Best-effort host:port from the in-cluster URL (creds stripped). */ +function parseInternalAddress(internalUrl: string | undefined): string | undefined { + if (!internalUrl) return undefined; + try { + const u = new URL(internalUrl); + return u.port ? `${u.hostname}:${u.port}` : u.hostname; + } catch { + // Coolify sometimes returns non-URL formats (e.g. raw mongo conn strings) + const m = internalUrl.match(/@([^/]+)\/?/); + return m ? m[1] : undefined; + } +} + +function consumerKeyFor(type: string): string { + if (type === "redis" || type === "keydb" || type === "dragonfly") return "REDIS_URL"; + if (type === "mongodb") return "MONGODB_URI"; + if (type === "clickhouse") return "CLICKHOUSE_URL"; + return "DATABASE_URL"; +} + async function loadDatabases(coolifyProjectUuid: string | undefined): Promise { if (!coolifyProjectUuid) return []; try { const dbs = await listDatabasesInProject(coolifyProjectUuid); - return dbs.map(d => ({ - uuid: d.uuid, - name: d.name, - type: dbTypeOf(d), - status: d.status ?? "unknown", - isPublic: !!d.is_public, - publicPort: d.public_port, - })); + return dbs.map(d => { + const type = dbTypeOf(d); + return { + uuid: d.uuid, + name: d.name, + type, + status: d.status ?? "unknown", + isPublic: !!d.is_public, + publicPort: d.public_port, + internalAddress: parseInternalAddress(d.internal_db_url), + consumerEnvKey: consumerKeyFor(type), + }; + }); } catch (err) { console.error("[anatomy] listDatabasesInProject failed:", err); return []; @@ -343,60 +391,48 @@ async function loadDatabases(coolifyProjectUuid: string | undefined): Promise = [ // Auth - { category: "auth", vendor: "Clerk", pattern: /^(NEXT_PUBLIC_)?CLERK_/ }, - { category: "auth", vendor: "NextAuth", pattern: /^NEXTAUTH_/ }, - { category: "auth", vendor: "Auth0", pattern: /^AUTH0_/ }, - { category: "auth", vendor: "Supabase Auth",pattern: /^SUPABASE_(SERVICE_ROLE|JWT|ANON)/ }, - { category: "auth", vendor: "SuperTokens", pattern: /^SUPERTOKENS_/ }, - { category: "auth", vendor: "WorkOS", pattern: /^WORKOS_/ }, - { category: "auth", vendor: "Firebase Auth",pattern: /^FIREBASE_(AUTH|API_KEY)/ }, + { category: "auth", vendor: "Clerk", pattern: /^(NEXT_PUBLIC_)?CLERK_/ }, + { category: "auth", vendor: "NextAuth", pattern: /^NEXTAUTH_/ }, + { category: "auth", vendor: "Auth0", pattern: /^AUTH0_/ }, + { category: "auth", vendor: "Supabase Auth",pattern: /^SUPABASE_(SERVICE_ROLE|JWT|ANON)/ }, + { category: "auth", vendor: "SuperTokens", pattern: /^SUPERTOKENS_/ }, + { category: "auth", vendor: "WorkOS", pattern: /^WORKOS_/ }, + { category: "auth", vendor: "Firebase Auth",pattern: /^FIREBASE_(AUTH|API_KEY)/ }, // Email - { category: "email", vendor: "Resend", pattern: /^RESEND_/ }, - { category: "email", vendor: "Mailgun", pattern: /^MAILGUN_/ }, - { category: "email", vendor: "Postmark", pattern: /^POSTMARK_/ }, - { category: "email", vendor: "SendGrid", pattern: /^SENDGRID_/ }, - { category: "email", vendor: "AWS SES", pattern: /^(SES_|AWS_SES_)/ }, - { category: "email", vendor: "Loops", pattern: /^LOOPS_/ }, - // SMS - { category: "sms", vendor: "Twilio", pattern: /^TWILIO_/ }, - { category: "sms", vendor: "Vonage", pattern: /^VONAGE_/ }, + { category: "email", vendor: "Resend", pattern: /^RESEND_/ }, + { category: "email", vendor: "Mailgun", pattern: /^MAILGUN_/ }, + { category: "email", vendor: "Postmark", pattern: /^POSTMARK_/ }, + { category: "email", vendor: "SendGrid", pattern: /^SENDGRID_/ }, + { category: "email", vendor: "AWS SES", pattern: /^(SES_|AWS_SES_)/ }, + { category: "email", vendor: "Loops", pattern: /^LOOPS_/ }, // Payments - { category: "payments", vendor: "Stripe", pattern: /^(NEXT_PUBLIC_)?STRIPE_/ }, - { category: "payments", vendor: "LemonSqueezy", pattern: /^LEMON(SQUEEZY)?_/ }, - { category: "payments", vendor: "Paddle", pattern: /^PADDLE_/ }, - // Analytics - { category: "analytics", vendor: "PostHog", pattern: /^(NEXT_PUBLIC_)?POSTHOG_/ }, - { category: "analytics", vendor: "Mixpanel", pattern: /^(NEXT_PUBLIC_)?MIXPANEL_/ }, - { category: "analytics", vendor: "Amplitude", pattern: /^(NEXT_PUBLIC_)?AMPLITUDE_/ }, - { category: "analytics", vendor: "Plausible", pattern: /^PLAUSIBLE_/ }, - { category: "analytics", vendor: "Umami", pattern: /^(NEXT_PUBLIC_)?UMAMI_/ }, - // LLM - { category: "llm", vendor: "OpenAI", pattern: /^OPENAI_/ }, - { category: "llm", vendor: "Anthropic", pattern: /^ANTHROPIC_/ }, - { category: "llm", vendor: "Google AI", pattern: /^(GEMINI_|GOOGLE_AI_|GOOGLE_GENAI_)/ }, - { category: "llm", vendor: "Mistral", pattern: /^MISTRAL_/ }, - { category: "llm", vendor: "Cohere", pattern: /^COHERE_/ }, - { category: "llm", vendor: "Groq", pattern: /^GROQ_/ }, - { category: "llm", vendor: "OpenRouter", pattern: /^OPENROUTER_/ }, - // Storage - { category: "storage", vendor: "AWS S3", pattern: /^(AWS_S3_|S3_(ACCESS|SECRET|BUCKET|REGION))/ }, - { category: "storage", vendor: "Cloudflare R2",pattern: /^(R2_|CLOUDFLARE_R2_)/ }, - { category: "storage", vendor: "Google Cloud Storage", pattern: /^(GCS_|GCP_STORAGE_)/ }, - { category: "storage", vendor: "Supabase Storage", pattern: /^SUPABASE_STORAGE_/ }, - // Search - { category: "search", vendor: "Algolia", pattern: /^ALGOLIA_/ }, - { category: "search", vendor: "Meilisearch", pattern: /^MEILI(SEARCH)?_/ }, - { category: "search", vendor: "Typesense", pattern: /^TYPESENSE_/ }, - // Monitoring - { category: "monitoring", vendor: "Sentry", pattern: /^(NEXT_PUBLIC_)?SENTRY_/ }, - { category: "monitoring", vendor: "Datadog", pattern: /^(DD_|DATADOG_)/ }, - { category: "monitoring", vendor: "LogSnag", pattern: /^LOGSNAG_/ }, + { category: "payments", vendor: "Stripe", pattern: /^(NEXT_PUBLIC_)?STRIPE_/ }, + { category: "payments", vendor: "LemonSqueezy", pattern: /^LEMON(SQUEEZY)?_/ }, + { category: "payments", vendor: "Paddle", pattern: /^PADDLE_/ }, + // LLM (a.k.a. Models) + { category: "llm", vendor: "OpenAI", pattern: /^OPENAI_/ }, + { category: "llm", vendor: "Anthropic", pattern: /^ANTHROPIC_/ }, + { category: "llm", vendor: "Google AI", pattern: /^(GEMINI_|GOOGLE_AI_|GOOGLE_GENAI_)/ }, + { category: "llm", vendor: "Mistral", pattern: /^MISTRAL_/ }, + { category: "llm", vendor: "Cohere", pattern: /^COHERE_/ }, + { category: "llm", vendor: "Groq", pattern: /^GROQ_/ }, + { category: "llm", vendor: "OpenRouter", pattern: /^OPENROUTER_/ }, ]; interface ResourceEnvs { @@ -475,12 +511,31 @@ function summariseSecrets(allEnvs: ResourceEnvs[]): InfraSecretSummary { resourceName: e.resourceName, resourceKind: e.resourceKind, count: e.keys.length, + keys: [...e.keys].sort(), })) .sort((a, b) => b.count - a.count); const total = byResource.reduce((sum, r) => sum + r.count, 0); return { total, byResource }; } +async function loadBundledStorage(workspaceId: string | undefined): Promise { + if (!workspaceId) return { status: "unprovisioned" }; + try { + const ws = await getWorkspaceGcsState(workspaceId); + if (!ws) return { status: "unprovisioned" }; + return { + status: ws.gcp_provision_status ?? "unprovisioned", + bucketName: ws.gcs_default_bucket_name ?? undefined, + hmacAccessId: ws.gcs_hmac_access_id ?? undefined, + region: VIBN_GCS_LOCATION, + errorMessage: ws.gcp_provision_error ?? undefined, + }; + } catch (err) { + console.error("[anatomy] getWorkspaceGcsState failed:", err); + return { status: "error", errorMessage: err instanceof Error ? err.message : String(err) }; + } +} + async function loadPreviews(projectId: string): Promise { try { const rows = await query<{ @@ -529,8 +584,8 @@ export async function GET( return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - const rows = await query<{ data: Record }>( - `SELECT p.data FROM fs_projects p + const rows = await query<{ data: Record; vibn_workspace_id: string | null }>( + `SELECT p.data, p.vibn_workspace_id FROM fs_projects p JOIN fs_users u ON u.id = p.user_id WHERE p.id = $1 AND u.data->>'email' = $2 LIMIT 1`, [projectId, session.user.email] @@ -540,6 +595,7 @@ export async function GET( } const data = rows[0].data; + const workspaceId = rows[0].vibn_workspace_id ?? undefined; const giteaRepo = data?.giteaRepo as string | undefined; const coolifyProjectUuid = data?.coolifyProjectUuid as string | undefined; const projectName = @@ -547,7 +603,7 @@ export async function GET( (data?.name as string | undefined) ?? "Project"; - const [codebasesResult, repoApps, allServices, previews, databases] = await Promise.all([ + const [codebasesResult, repoApps, allServices, previews, databases, bundledStorage] = await Promise.all([ giteaRepo ? discoverCodebases(giteaRepo).catch(err => { console.error("[anatomy] discoverCodebases failed:", err); @@ -558,6 +614,7 @@ export async function GET( loadProjectServices(coolifyProjectUuid), loadPreviews(projectId), loadDatabases(coolifyProjectUuid), + loadBundledStorage(workspaceId), ]); // Pull last-build summaries for repo apps in parallel (small N). @@ -630,6 +687,7 @@ export async function GET( infrastructure: { databases, providers: detectProviders(allEnvs), + bundledStorage, secrets: summariseSecrets(allEnvs), }, }; diff --git a/app/api/projects/[projectId]/databases/[dbUuid]/preview/route.ts b/app/api/projects/[projectId]/databases/[dbUuid]/preview/route.ts new file mode 100644 index 00000000..fa847602 --- /dev/null +++ b/app/api/projects/[projectId]/databases/[dbUuid]/preview/route.ts @@ -0,0 +1,88 @@ +/** + * GET /api/projects/[projectId]/databases/[dbUuid]/preview?schema=…&table=… + * + * Returns the first ~50 rows of the requested table. Read-only — backed + * by lib/db-introspect.previewTable which only emits SELECT statements + * with a hard LIMIT and rejects identifiers containing anything outside + * /[A-Za-z0-9_]+/. + * + * Returns: { columns: string[], rows: Record[], truncated: boolean } + * + * Same auth + tenancy chain as the tables route. + */ + +import { NextResponse } from "next/server"; +import { authSession } from "@/lib/auth/session-server"; +import { query } from "@/lib/db-postgres"; +import { listDatabasesInProject } from "@/lib/coolify"; +import { previewTable, IntrospectionUnsupportedError } from "@/lib/db-introspect"; + +function dbEngineOf(type: string | undefined): string { + const raw = (type ?? "").toLowerCase(); + if (raw.includes("postgres")) return "postgresql"; + if (raw.includes("redis")) return "redis"; + if (raw.includes("mongo")) return "mongodb"; + if (raw.includes("mysql") || raw.includes("mariadb")) return "mysql"; + if (raw.includes("clickhouse")) return "clickhouse"; + return raw || "unknown"; +} + +export async function GET( + req: Request, + { params }: { params: Promise<{ projectId: string; dbUuid: string }> } +) { + try { + const { projectId, dbUuid } = await params; + const url = new URL(req.url); + const schema = url.searchParams.get("schema") ?? ""; + const table = url.searchParams.get("table") ?? ""; + if (!schema || !table) { + return NextResponse.json({ error: "Both 'schema' and 'table' are required" }, { status: 400 }); + } + + const session = await authSession(); + if (!session?.user?.email) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const rows = await query<{ data: Record }>( + `SELECT p.data FROM fs_projects p + JOIN fs_users u ON u.id = p.user_id + WHERE p.id = $1 AND u.data->>'email' = $2 LIMIT 1`, + [projectId, session.user.email] + ); + if (rows.length === 0) { + return NextResponse.json({ error: "Project not found" }, { status: 404 }); + } + const coolifyProjectUuid = rows[0].data?.coolifyProjectUuid as string | undefined; + if (!coolifyProjectUuid) { + return NextResponse.json({ error: "Project has no Coolify project linked" }, { status: 400 }); + } + + const dbs = await listDatabasesInProject(coolifyProjectUuid); + const db = dbs.find(d => d.uuid === dbUuid); + if (!db) { + return NextResponse.json({ error: "Database not in this project" }, { status: 404 }); + } + + const engine = dbEngineOf(db.type); + try { + const result = await previewTable(dbUuid, engine, schema, table); + return NextResponse.json(result); + } catch (err) { + if (err instanceof IntrospectionUnsupportedError) { + return NextResponse.json( + { error: err.message, unsupported: true }, + { status: 501 } + ); + } + throw err; + } + } catch (err) { + console.error("[db preview API]", err); + return NextResponse.json( + { error: err instanceof Error ? err.message : "Failed to preview table" }, + { status: 500 } + ); + } +} diff --git a/app/api/projects/[projectId]/databases/[dbUuid]/tables/route.ts b/app/api/projects/[projectId]/databases/[dbUuid]/tables/route.ts new file mode 100644 index 00000000..1b7e74ea --- /dev/null +++ b/app/api/projects/[projectId]/databases/[dbUuid]/tables/route.ts @@ -0,0 +1,82 @@ +/** + * GET /api/projects/[projectId]/databases/[dbUuid]/tables + * + * Lists user-tables for a Coolify-managed database that belongs to the + * given Vibn project. Postgres only for now; other engines return 501. + * + * Auth + tenancy chain: + * - session.user.email must own the fs_projects row + * - the database uuid must live in the project's coolify project + * + * Hard caps: 50 tables, 8s timeout (enforced inside lib/db-introspect). + */ + +import { NextResponse } from "next/server"; +import { authSession } from "@/lib/auth/session-server"; +import { query } from "@/lib/db-postgres"; +import { listDatabasesInProject } from "@/lib/coolify"; +import { listTables, IntrospectionUnsupportedError } from "@/lib/db-introspect"; + +function dbEngineOf(type: string | undefined): string { + const raw = (type ?? "").toLowerCase(); + if (raw.includes("postgres")) return "postgresql"; + if (raw.includes("redis")) return "redis"; + if (raw.includes("mongo")) return "mongodb"; + if (raw.includes("mysql") || raw.includes("mariadb")) return "mysql"; + if (raw.includes("clickhouse")) return "clickhouse"; + return raw || "unknown"; +} + +export async function GET( + _req: Request, + { params }: { params: Promise<{ projectId: string; dbUuid: string }> } +) { + try { + const { projectId, dbUuid } = await params; + const session = await authSession(); + if (!session?.user?.email) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const rows = await query<{ data: Record }>( + `SELECT p.data FROM fs_projects p + JOIN fs_users u ON u.id = p.user_id + WHERE p.id = $1 AND u.data->>'email' = $2 LIMIT 1`, + [projectId, session.user.email] + ); + if (rows.length === 0) { + return NextResponse.json({ error: "Project not found" }, { status: 404 }); + } + const coolifyProjectUuid = rows[0].data?.coolifyProjectUuid as string | undefined; + if (!coolifyProjectUuid) { + return NextResponse.json({ error: "Project has no Coolify project linked" }, { status: 400 }); + } + + // Authorise the dbUuid against this project + const dbs = await listDatabasesInProject(coolifyProjectUuid); + const db = dbs.find(d => d.uuid === dbUuid); + if (!db) { + return NextResponse.json({ error: "Database not in this project" }, { status: 404 }); + } + + const engine = dbEngineOf(db.type); + try { + const tables = await listTables(dbUuid, engine); + return NextResponse.json({ engine, tables }); + } catch (err) { + if (err instanceof IntrospectionUnsupportedError) { + return NextResponse.json( + { engine, tables: [], unsupported: true, message: err.message }, + { status: 200 } + ); + } + throw err; + } + } catch (err) { + console.error("[db tables API]", err); + return NextResponse.json( + { error: err instanceof Error ? err.message : "Failed to list tables" }, + { status: 500 } + ); + } +} diff --git a/components/project/database-table-tree.tsx b/components/project/database-table-tree.tsx new file mode 100644 index 00000000..9e7f2baf --- /dev/null +++ b/components/project/database-table-tree.tsx @@ -0,0 +1,238 @@ +"use client"; + +/** + * Inline tree view of tables inside a Coolify-managed database. + * Mirrors the pattern of GiteaFileTree: lazy-fetched on first mount, + * one-level deep, click a table to "preview" it on the right. + * + * Tables are grouped by schema. The default `public` schema is rendered + * flat (no schema header) since 95% of small projects only have one + * schema and the extra heading is just noise. + */ + +import { useEffect, useState } from "react"; +import { Table, ChevronDown, ChevronRight, Loader2, AlertCircle, Info } from "lucide-react"; + +interface IntrospectedTable { + schema: string; + name: string; + approxRows?: number; +} + +interface ApiResp { + engine: string; + tables: IntrospectedTable[]; + unsupported?: boolean; + message?: string; +} + +interface Props { + projectId: string; + dbUuid: string; + selectedTable?: { schema: string; name: string }; + onSelectTable: (t: { schema: string; name: string }) => void; +} + +export function DatabaseTableTree({ + projectId, dbUuid, selectedTable, onSelectTable, +}: Props) { + const [data, setData] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let cancelled = false; + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), 12_000); + setLoading(true); + setError(null); + + fetch(`/api/projects/${projectId}/databases/${dbUuid}/tables`, { + credentials: "include", signal: ctrl.signal, + }) + .then(async r => { + let body: unknown = {}; + try { body = await r.json(); } catch {/* keep {} */} + if (!r.ok) { + throw new Error((body as { error?: string }).error || `HTTP ${r.status}`); + } + return body as ApiResp; + }) + .then(d => { if (!cancelled) setData(d); }) + .catch(err => { + if (cancelled) return; + if (err?.name === "AbortError") setError("Timed out after 12s."); + else setError(err?.message || "Failed to list tables"); + }) + .finally(() => { clearTimeout(t); if (!cancelled) setLoading(false); }); + + return () => { cancelled = true; ctrl.abort(); clearTimeout(t); }; + }, [projectId, dbUuid]); + + if (loading) { + return ( +
+ Inspecting database… +
+ ); + } + if (error) { + return ( +
+ {error} +
+ ); + } + if (!data) return null; + + if (data.unsupported) { + return ( +
+ + Table inspection isn't wired up for {data.engine} yet — Postgres + is the only engine supported today. +
+ ); + } + + if (data.tables.length === 0) { + return ( +
+ + No user-tables found. (Pre-deploy databases often start empty.) +
+ ); + } + + // Group by schema; flatten `public` since most projects only use it. + const bySchema = new Map(); + for (const t of data.tables) { + if (!bySchema.has(t.schema)) bySchema.set(t.schema, []); + bySchema.get(t.schema)!.push(t); + } + const schemas = [...bySchema.keys()].sort(); + + return ( +
+ {schemas.map(schema => ( + + ))} +
+ ); +} + +function SchemaGroup({ + schema, tables, selectedTable, onSelectTable, +}: { + schema: string; + tables: IntrospectedTable[]; + selectedTable?: { schema: string; name: string }; + onSelectTable: (t: { schema: string; name: string }) => void; +}) { + const isPublic = schema === "public"; + const [open, setOpen] = useState(true); + + const items = ( +
    + {tables.map(t => { + const active = selectedTable?.schema === t.schema && selectedTable?.name === t.name; + return ( +
  • +
+
+ ); +} + +// ────────────────────────────────────────────────── + +const INK = { + ink: "#1a1a1a", + mid: "#5f5e5a", + muted: "#a09a90", + borderSoft: "#efebe1", + border: "#e8e4dc", +} as const; + +const wrap: React.CSSProperties = { + display: "flex", flexDirection: "column", gap: 8, minHeight: 0, flex: 1, +}; +const meta: React.CSSProperties = { + fontSize: "0.74rem", color: INK.mid, +}; +const qual: React.CSSProperties = { + fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', + color: INK.ink, +}; +const tableScroll: React.CSSProperties = { + flex: 1, minHeight: 0, overflow: "auto", + border: `1px solid ${INK.borderSoft}`, borderRadius: 6, +}; +const tableEl: React.CSSProperties = { + borderCollapse: "collapse", + fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', + fontSize: "0.76rem", + width: "100%", +}; +const th: React.CSSProperties = { + position: "sticky", top: 0, + textAlign: "left", padding: "6px 10px", + background: "#fafaf6", color: INK.ink, + fontWeight: 600, fontSize: "0.72rem", + borderBottom: `1px solid ${INK.border}`, + whiteSpace: "nowrap", +}; +const td: React.CSSProperties = { + padding: "5px 10px", color: INK.ink, + borderBottom: `1px solid ${INK.borderSoft}`, + whiteSpace: "nowrap", maxWidth: 360, + overflow: "hidden", textOverflow: "ellipsis", +}; +const trEven: React.CSSProperties = { background: "#fff" }; +const trOdd: React.CSSProperties = { background: "#fcfaf3" }; +const center: React.CSSProperties = { + flex: 1, display: "flex", alignItems: "center", justifyContent: "center", + color: INK.mid, fontSize: "0.85rem", +}; +const errorBox: React.CSSProperties = { + display: "flex", alignItems: "center", gap: 6, + padding: "10px 12px", fontSize: "0.82rem", color: "#7a1f15", + background: "#fbe9e7", border: `1px solid #f4c2bc`, borderRadius: 8, +}; +const infoBox: React.CSSProperties = { + display: "flex", alignItems: "center", gap: 6, + padding: "10px 12px", fontSize: "0.82rem", color: INK.mid, + background: "#fafaf6", border: `1px dashed ${INK.borderSoft}`, borderRadius: 8, +}; diff --git a/components/project/use-anatomy.ts b/components/project/use-anatomy.ts index 2c136635..e62f13ce 100644 --- a/components/project/use-anatomy.ts +++ b/components/project/use-anatomy.ts @@ -52,12 +52,12 @@ export interface Anatomy { status: string; isPublic: boolean; publicPort?: number; + internalAddress?: string; + consumerEnvKey: string; }>; providers: Array<{ id: string; - category: - | "auth" | "email" | "sms" | "payments" - | "analytics" | "llm" | "storage" | "search" | "monitoring"; + category: "auth" | "email" | "payments" | "llm" | "storage"; vendor: string; attachments: Array<{ resourceUuid: string; @@ -66,6 +66,13 @@ export interface Anatomy { keys: string[]; }>; }>; + bundledStorage: { + status: "ready" | "pending" | "partial" | "error" | "unprovisioned"; + bucketName?: string; + hmacAccessId?: string; + region?: string; + errorMessage?: string; + }; secrets: { total: number; byResource: Array<{ @@ -73,6 +80,7 @@ export interface Anatomy { resourceName: string; resourceKind: "app" | "service"; count: number; + keys: string[]; }>; }; }; diff --git a/lib/coolify.ts b/lib/coolify.ts index ef33c5af..699a541f 100644 --- a/lib/coolify.ts +++ b/lib/coolify.ts @@ -1248,15 +1248,19 @@ interface CoolifyProjectEnvResources { clickhouses?: CoolifyDatabase[]; } -const DB_ARRAY_KEYS: Array = [ - 'postgresqls', - 'mysqls', - 'mariadbs', - 'mongodbs', - 'redis', - 'keydbs', - 'dragonflies', - 'clickhouses', +/** Maps Coolify's plural endpoint key → the engine label we want to + * surface on every CoolifyDatabase record. Coolify's flattened + * per-resource shape doesn't include `type`, so we derive it from + * whichever array we pulled the row out of. */ +const DB_ARRAY_KEYS_TO_TYPE: Array<{ key: keyof CoolifyProjectEnvResources; type: string }> = [ + { key: 'postgresqls', type: 'postgresql' }, + { key: 'mysqls', type: 'mysql' }, + { key: 'mariadbs', type: 'mariadb' }, + { key: 'mongodbs', type: 'mongodb' }, + { key: 'redis', type: 'redis' }, + { key: 'keydbs', type: 'keydb' }, + { key: 'dragonflies', type: 'dragonfly' }, + { key: 'clickhouses', type: 'clickhouse' }, ]; async function getProjectEnvResources( @@ -1290,9 +1294,14 @@ export async function listDatabasesInProject( ): Promise { return forEachEnv(projectUuid, r => { const out: CoolifyDatabase[] = []; - for (const k of DB_ARRAY_KEYS) { - const arr = r[k]; - if (Array.isArray(arr)) out.push(...(arr as CoolifyDatabase[])); + for (const { key, type } of DB_ARRAY_KEYS_TO_TYPE) { + const arr = r[key]; + if (!Array.isArray(arr)) continue; + for (const db of arr as CoolifyDatabase[]) { + // Always tag with the engine we derived from the array key — + // Coolify itself doesn't set `type` on the individual record. + out.push({ ...db, type: db.type ?? type }); + } } return out; }); diff --git a/lib/db-introspect.ts b/lib/db-introspect.ts new file mode 100644 index 00000000..dce422eb --- /dev/null +++ b/lib/db-introspect.ts @@ -0,0 +1,211 @@ +/** + * Database introspection helpers — list tables and preview rows. + * + * Coolify-managed databases run in their own docker network, unreachable + * from vibn-frontend's container. We route through the Coolify host via + * SSH and `docker exec` into the database container itself, where the + * native client (psql / redis-cli / mongosh) is already installed and + * pre-authenticated via the container's own POSTGRES_USER / etc envs. + * + * v1 supports Postgres; Redis + MongoDB are stubbed and return helpful + * "not yet supported" errors so the UI can render a useful empty state. + * + * Hard limits, by design: + * - 50 tables max returned per call + * - 50 rows max per table preview + * - 200 chars truncation per cell value + * - 8s wall-clock SSH timeout + * - Only SELECT-style queries; never any mutation + */ + +import { runOnCoolifyHost } from "./coolify-ssh"; +import { listContainersForApp } from "./coolify-containers"; + +export interface IntrospectedTable { + schema: string; + name: string; + /** Approximate row count from pg_class.reltuples — fast, not exact. */ + approxRows?: number; +} + +export interface PreviewedTable { + columns: string[]; + rows: Array>; + truncated: boolean; + totalRowsApprox?: number; +} + +const MAX_TABLES = 50; +const MAX_ROWS = 50; +const MAX_CELL_CHARS = 200; +const SSH_TIMEOUT_MS = 8_000; + +/** Find the running container for a Coolify database uuid. */ +async function resolveDbContainer(dbUuid: string): Promise { + const containers = await listContainersForApp(dbUuid); + const running = containers.find(c => /up /i.test(c.status)); + const target = running ?? containers[0]; + if (!target) { + throw new Error(`No container found for database ${dbUuid}. Database may be stopped.`); + } + return target.name; +} + +/** Single-quote escape a token for bash. */ +function sq(s: string): string { + return `'${s.replace(/'/g, `'\\''`)}'`; +} + +// ────────────────────────────────────────────────── +// Postgres +// ────────────────────────────────────────────────── + +/** + * List tables across every non-system schema. Returns at most MAX_TABLES. + * Uses `\copy (SELECT …) TO STDOUT` style approach via psql -A -t for a + * stable, parseable output (no horizontal lines, no headers). + */ +async function pgListTables(container: string): Promise { + const sql = ` + SELECT n.nspname || '|' || c.relname || '|' || COALESCE(c.reltuples::bigint, 0) + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE c.relkind IN ('r', 'p') + AND n.nspname NOT IN ('pg_catalog', 'information_schema') + AND n.nspname NOT LIKE 'pg_%' + ORDER BY n.nspname, c.relname + LIMIT ${MAX_TABLES + 1}; + `.replace(/\s+/g, " ").trim(); + + const cmd = + `docker exec ${sq(container)} bash -c ` + + sq(`psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -tAF '|' -c ${sqInner(sql)}`); + + const res = await runOnCoolifyHost(cmd, { timeoutMs: SSH_TIMEOUT_MS }); + if (res.code !== 0) { + throw new Error(`psql exited ${res.code}: ${res.stderr.trim() || "(no stderr)"}`); + } + + const tables: IntrospectedTable[] = []; + for (const line of res.stdout.split("\n")) { + const trimmed = line.trim(); + if (!trimmed) continue; + const [schema, name, rowsStr] = trimmed.split("|"); + if (!schema || !name) continue; + const approxRows = Number(rowsStr); + tables.push({ schema, name, approxRows: Number.isFinite(approxRows) && approxRows >= 0 ? approxRows : undefined }); + } + return tables.slice(0, MAX_TABLES); +} + +/** + * Preview the first MAX_ROWS rows of a single table. Identifiers are + * locked to /^[A-Za-z0-9_]+$/ so we can safely splice them into SQL — + * Postgres identifiers can technically be wider, but anything outside + * that range gets rejected up-front to keep the surface small. + */ +async function pgPreviewTable( + container: string, + schema: string, + table: string, +): Promise { + if (!/^[A-Za-z0-9_]+$/.test(schema) || !/^[A-Za-z0-9_]+$/.test(table)) { + throw new Error("Invalid identifier"); + } + + // Build `SELECT row_to_json(t)::text FROM (SELECT * FROM s.t LIMIT N) t` + // so each output line is a self-contained JSON object that's resilient + // to embedded pipes / newlines. + const sql = + `SELECT row_to_json(t)::text ` + + `FROM (SELECT * FROM "${schema}"."${table}" LIMIT ${MAX_ROWS + 1}) t;`; + + const cmd = + `docker exec ${sq(container)} bash -c ` + + sq(`psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -tA -c ${sqInner(sql)}`); + + const res = await runOnCoolifyHost(cmd, { timeoutMs: SSH_TIMEOUT_MS }); + if (res.code !== 0) { + throw new Error(`psql exited ${res.code}: ${res.stderr.trim() || "(no stderr)"}`); + } + + const lines = res.stdout.split("\n").map(l => l.trim()).filter(Boolean); + const truncated = lines.length > MAX_ROWS; + const rowJson = lines.slice(0, MAX_ROWS); + + const columnSet = new Set(); + const parsed: Record[] = []; + for (const line of rowJson) { + try { + const obj = JSON.parse(line) as Record; + Object.keys(obj).forEach(k => columnSet.add(k)); + parsed.push(obj); + } catch { + // Skip non-JSON lines (shouldn't happen with -tA, but be safe). + } + } + + // Stable column order: order they appeared in the first row, then + // any extras at the end. + const firstRowKeys = parsed[0] ? Object.keys(parsed[0]) : []; + const extras = [...columnSet].filter(k => !firstRowKeys.includes(k)); + const columns = [...firstRowKeys, ...extras]; + + const rows = parsed.map(row => { + const out: Record = {}; + for (const col of columns) { + out[col] = formatCell(row[col]); + } + return out; + }); + + return { columns, rows, truncated }; +} + +/** Inner sq for the bash -c '…' wrapper: we need to single-quote the + * SQL itself, but we're already inside one layer of single quotes + * for `bash -c '…'` — switch to double quotes for the inner level + * and rely on the fact that psql's -c argument tolerates them. */ +function sqInner(s: string): string { + return `"${s.replace(/"/g, '\\"').replace(/\$/g, "\\$")}"`; +} + +function formatCell(v: unknown): string { + if (v == null) return "—"; + if (typeof v === "string") { + return v.length > MAX_CELL_CHARS ? v.slice(0, MAX_CELL_CHARS) + "…" : v; + } + if (typeof v === "number" || typeof v === "boolean") return String(v); + const json = JSON.stringify(v); + return json.length > MAX_CELL_CHARS ? json.slice(0, MAX_CELL_CHARS) + "…" : json; +} + +// ────────────────────────────────────────────────── +// Public API +// ────────────────────────────────────────────────── + +export class IntrospectionUnsupportedError extends Error { + constructor(public engine: string) { + super(`Introspection not yet supported for ${engine}`); + } +} + +export async function listTables( + dbUuid: string, + engine: string, +): Promise { + if (engine !== "postgresql") throw new IntrospectionUnsupportedError(engine); + const container = await resolveDbContainer(dbUuid); + return pgListTables(container); +} + +export async function previewTable( + dbUuid: string, + engine: string, + schema: string, + table: string, +): Promise { + if (engine !== "postgresql") throw new IntrospectionUnsupportedError(engine); + const container = await resolveDbContainer(dbUuid); + return pgPreviewTable(container, schema, table); +}