diff --git a/app/[workspace]/project/[projectId]/(home)/infrastructure/page.tsx b/app/[workspace]/project/[projectId]/(home)/infrastructure/page.tsx index 3d56675a..1c638d6a 100644 --- a/app/[workspace]/project/[projectId]/(home)/infrastructure/page.tsx +++ b/app/[workspace]/project/[projectId]/(home)/infrastructure/page.tsx @@ -1,34 +1,432 @@ -import { SectionScaffold, StatusPanel, EmptyState } from "@/components/project/section-scaffold"; +"use client"; + +import { useState } from "react"; +import { useParams } from "next/navigation"; +import { + Loader2, AlertCircle, Database, KeyRound, CircleDot, + ShieldCheck, Mail, MessageSquare, CreditCard, BarChart3, + Sparkles, HardDrive, Search, Activity, +} from "lucide-react"; +import { useAnatomy, type Anatomy } from "@/components/project/use-anatomy"; + +/** + * 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. + * + * 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. + */ + +type Selection = + | { kind: "database"; uuid: string } + | { kind: "provider"; id: string } + | { 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 }, +}; export default function InfrastructureTab() { + const params = useParams(); + const projectId = params.projectId as string; + const { anatomy, loading, error } = useAnatomy(projectId); + + const [selection, setSelection] = useState(null); + const showLoading = loading && !anatomy; + return ( - - - - - - - - - } - /> +
+
+ {/* ── Left rail ── */} +
+ {showLoading && ( + Loading… + )} + {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 ( + + ); + })} + + + )} +
+ + {/* ── Right pane ── */} + +
+
); } + +// ────────────────────────────────────────────────── +// Detail pane +// ────────────────────────────────────────────────── + +function Detail({ selection, anatomy }: { selection: Selection; anatomy: Anatomy }) { + if (!selection) return null; + + 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.; + 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} +
+
+ {att.keys.map(k => {k})} +
+
+ ))} +
+
+ ); + } + + 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.; + 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. +
+
+ ); + } + + return null; +} + +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}
+ ) : ( +
{children}
+ )} +
+ ); +} + +function DetailLayout({ children }: { children: React.ReactNode }) { + return
{children}
; +} + +function DetailRow({ + label, value, dot, +}: { label: string; value: string; dot?: string }) { + return ( +
+ {label} + + {dot && } + {value} + +
+ ); +} + +function Inline({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +function Empty({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +function statusColor(status: string) { + const s = (status ?? "").toLowerCase(); + if (s.includes("running") || s.includes("healthy")) return "#2e7d32"; + if (s.includes("starting") || s.includes("deploying")) return "#d4a04a"; + if (s.includes("exit") || s.includes("fail") || s.includes("unhealthy")) return "#c5392b"; + return "#a09a90"; +} + +// ────────────────────────────────────────────────── +// Tokens +// ────────────────────────────────────────────────── + +const INK = { + ink: "#1a1a1a", + mid: "#5f5e5a", + muted: "#a09a90", + border: "#e8e4dc", + borderSoft: "#efebe1", + cardBg: "#fff", + fontSans: '"Outfit", "Inter", ui-sans-serif, sans-serif', +} as const; + +const pageWrap: React.CSSProperties = { + padding: "28px 48px 48px", + fontFamily: INK.fontSans, + color: INK.ink, +}; +const grid: React.CSSProperties = { + display: "grid", + gridTemplateColumns: "minmax(280px, 360px) minmax(0, 1fr)", + gap: 28, + maxWidth: 1400, + margin: "0 auto", + alignItems: "stretch", +}; +const leftCol: React.CSSProperties = { + minWidth: 0, display: "flex", flexDirection: "column", gap: 18, +}; +const rightCol: React.CSSProperties = { + 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", +}; +const railGroup: React.CSSProperties = { display: "flex", flexDirection: "column" }; +const railGroupHeader: React.CSSProperties = { + display: "flex", alignItems: "center", justifyContent: "space-between", + padding: "0 4px 8px", +}; +const railGroupTitle: React.CSSProperties = { + fontSize: "0.68rem", fontWeight: 600, letterSpacing: "0.12em", + textTransform: "uppercase", color: INK.muted, +}; +const countPill: React.CSSProperties = { + fontSize: "0.7rem", fontWeight: 600, color: INK.mid, + padding: "1px 7px", borderRadius: 999, background: "#f3eee4", +}; +const railItems: React.CSSProperties = { display: "flex", flexDirection: "column", gap: 8 }; +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 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 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, textTransform: "capitalize", +}; +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 attachmentBlock: React.CSSProperties = { + padding: "10px 12px", marginTop: 8, + background: "#fafaf6", borderRadius: 8, +}; +const attachmentHeader: React.CSSProperties = { + fontSize: "0.82rem", fontWeight: 600, color: INK.ink, marginBottom: 6, + display: "flex", alignItems: "center", gap: 8, +}; +const attachmentBadge: React.CSSProperties = { + fontSize: "0.62rem", fontWeight: 700, letterSpacing: "0.08em", textTransform: "uppercase", + color: INK.mid, background: "#ece6da", padding: "1px 6px", borderRadius: 4, +}; +const keyList: React.CSSProperties = { + display: "flex", flexWrap: "wrap", gap: 4, +}; +const keyChip: React.CSSProperties = { + fontSize: "0.7rem", color: INK.mid, padding: "2px 6px", + background: INK.cardBg, border: `1px solid ${INK.borderSoft}`, borderRadius: 4, + fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', +}; diff --git a/app/api/projects/[projectId]/anatomy/route.ts b/app/api/projects/[projectId]/anatomy/route.ts index 697d4e64..d82a4f0d 100644 --- a/app/api/projects/[projectId]/anatomy/route.ts +++ b/app/api/projects/[projectId]/anatomy/route.ts @@ -29,9 +29,13 @@ import { query } from "@/lib/db-postgres"; import { listApplications, listApplicationDeployments, + listApplicationEnvs, listServicesInProject, + listServiceEnvs, + listDatabasesInProject, type CoolifyApplication, type CoolifyService, + type CoolifyDatabase, } from "@/lib/coolify"; const GITEA_API_URL = process.env.GITEA_API_URL ?? "https://git.vibnai.com"; @@ -93,6 +97,47 @@ interface Preview { startedAt: string; } +/** A Coolify database resource attached to this project. */ +interface InfraDatabase { + uuid: string; + name: string; + type: string; // postgresql / redis / mongodb / mysql / keydb / clickhouse + status: string; + isPublic: boolean; + publicPort?: number; +} + +/** A non-database third-party provider detected by env-var pattern. + * Coolify doesn't model these natively, so we infer them from the + * keys present in app + service env vars. */ +interface InfraProvider { + /** Stable id used by the UI for selection */ + id: string; + category: + | "auth" | "email" | "sms" | "payments" + | "analytics" | "llm" | "storage" | "search" | "monitoring"; + vendor: string; // "Stripe", "Resend", "OpenAI", … + /** Where the env keys for this provider live */ + attachments: Array<{ + resourceUuid: string; + resourceName: string; + resourceKind: "app" | "service"; + keys: string[]; // matching env var keys (values redacted) + }>; +} + +interface InfraSecretSummary { + /** Total number of env vars across every app + service in the project */ + total: number; + /** Per-resource breakdown for drill-down */ + byResource: Array<{ + resourceUuid: string; + resourceName: string; + resourceKind: "app" | "service"; + count: number; + }>; +} + interface Anatomy { project: { id: string; name: string; gitea?: string; coolifyProjectUuid?: string }; codebasesReason?: "no_repo" | "empty_repo"; @@ -105,7 +150,9 @@ interface Anatomy { previews: Preview[]; }; infrastructure: { - placeholder: true; + databases: InfraDatabase[]; + providers: InfraProvider[]; + secrets: InfraSecretSummary; }; } @@ -255,6 +302,185 @@ async function lastBuildFor(uuid: string): Promise { } } +// ────────────────────────────────────────────────── +// Infrastructure helpers +// ────────────────────────────────────────────────── + +/** Coolify database type names → friendly normalised label. */ +function dbTypeOf(d: CoolifyDatabase): string { + const raw = (d.type ?? "").toLowerCase(); + if (raw.includes("postgres")) return "postgresql"; + if (raw.includes("redis")) return "redis"; + if (raw.includes("keydb")) return "keydb"; + if (raw.includes("dragonfly")) return "dragonfly"; + if (raw.includes("mongo")) return "mongodb"; + if (raw.includes("mysql") || raw.includes("mariadb")) return "mysql"; + if (raw.includes("clickhouse")) return "clickhouse"; + return raw || "database"; +} + +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, + })); + } catch (err) { + console.error("[anatomy] listDatabasesInProject failed:", err); + return []; + } +} + +/** + * Provider detection rules. Each rule maps a category + vendor to a + * regex tested against env-var keys. We deliberately keep the prefix + * specific enough to avoid false positives (e.g. `STRIPE_*` not just + * `*KEY*`). + */ +const PROVIDER_RULES: Array<{ + category: InfraProvider["category"]; + vendor: string; + pattern: RegExp; +}> = [ + // 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)/ }, + // 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_/ }, + // 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_/ }, +]; + +interface ResourceEnvs { + resourceUuid: string; + resourceName: string; + resourceKind: "app" | "service"; + keys: string[]; +} + +async function loadAllEnvs( + apps: CoolifyApplication[], + services: CoolifyService[] +): Promise { + const appPromises = apps.map(async (a): Promise => { + try { + const envs = await listApplicationEnvs(a.uuid); + return { + resourceUuid: a.uuid, + resourceName: a.name, + resourceKind: "app", + keys: envs.map(e => e.key), + }; + } catch (err) { + console.error(`[anatomy] listApplicationEnvs(${a.uuid}) failed:`, err); + return { resourceUuid: a.uuid, resourceName: a.name, resourceKind: "app", keys: [] }; + } + }); + const svcPromises = services.map(async (s): Promise => { + try { + const envs = await listServiceEnvs(s.uuid); + return { + resourceUuid: s.uuid, + resourceName: s.name, + resourceKind: "service", + keys: envs.map(e => e.key), + }; + } catch (err) { + console.error(`[anatomy] listServiceEnvs(${s.uuid}) failed:`, err); + return { resourceUuid: s.uuid, resourceName: s.name, resourceKind: "service", keys: [] }; + } + }); + return Promise.all([...appPromises, ...svcPromises]); +} + +function detectProviders(allEnvs: ResourceEnvs[]): InfraProvider[] { + // vendor → { category, attachments-by-resource } + const byVendor = new Map(); + + for (const env of allEnvs) { + if (env.keys.length === 0) continue; + for (const rule of PROVIDER_RULES) { + const matches = env.keys.filter(k => rule.pattern.test(k)); + if (matches.length === 0) continue; + const id = `${rule.category}:${rule.vendor.toLowerCase().replace(/\s+/g, "-")}`; + let entry = byVendor.get(id); + if (!entry) { + entry = { id, category: rule.category, vendor: rule.vendor, attachments: [] }; + byVendor.set(id, entry); + } + entry.attachments.push({ + resourceUuid: env.resourceUuid, + resourceName: env.resourceName, + resourceKind: env.resourceKind, + keys: matches, + }); + } + } + return Array.from(byVendor.values()); +} + +function summariseSecrets(allEnvs: ResourceEnvs[]): InfraSecretSummary { + const byResource = allEnvs + .filter(e => e.keys.length > 0) + .map(e => ({ + resourceUuid: e.resourceUuid, + resourceName: e.resourceName, + resourceKind: e.resourceKind, + count: e.keys.length, + })) + .sort((a, b) => b.count - a.count); + const total = byResource.reduce((sum, r) => sum + r.count, 0); + return { total, byResource }; +} + async function loadPreviews(projectId: string): Promise { try { const rows = await query<{ @@ -321,7 +547,7 @@ export async function GET( (data?.name as string | undefined) ?? "Project"; - const [codebasesResult, repoApps, allServices, previews] = await Promise.all([ + const [codebasesResult, repoApps, allServices, previews, databases] = await Promise.all([ giteaRepo ? discoverCodebases(giteaRepo).catch(err => { console.error("[anatomy] discoverCodebases failed:", err); @@ -331,10 +557,15 @@ export async function GET( loadRepoApps(giteaRepo), loadProjectServices(coolifyProjectUuid), loadPreviews(projectId), + loadDatabases(coolifyProjectUuid), ]); // Pull last-build summaries for repo apps in parallel (small N). - const builds = await Promise.all(repoApps.map(a => lastBuildFor(a.uuid))); + // In parallel, fan out env-var fetches to drive provider/secret detection. + const [builds, allEnvs] = await Promise.all([ + Promise.all(repoApps.map(a => lastBuildFor(a.uuid))), + loadAllEnvs(repoApps, allServices.filter(s => !isDevContainer(s))), + ]); // Image services (Coolify services minus vibn-dev-*) const imageServices = allServices.filter(s => !isDevContainer(s)); @@ -396,7 +627,11 @@ export async function GET( live: [...liveFromRepo, ...liveFromImage], previews, }, - infrastructure: { placeholder: true }, + infrastructure: { + databases, + providers: detectProviders(allEnvs), + secrets: summariseSecrets(allEnvs), + }, }; return NextResponse.json(anatomy); diff --git a/components/project/use-anatomy.ts b/components/project/use-anatomy.ts index 0a7a7a69..2c136635 100644 --- a/components/project/use-anatomy.ts +++ b/components/project/use-anatomy.ts @@ -44,7 +44,38 @@ export interface Anatomy { startedAt: string; }>; }; - infrastructure: { placeholder: true }; + infrastructure: { + databases: Array<{ + uuid: string; + name: string; + type: string; + status: string; + isPublic: boolean; + publicPort?: number; + }>; + providers: Array<{ + id: string; + category: + | "auth" | "email" | "sms" | "payments" + | "analytics" | "llm" | "storage" | "search" | "monitoring"; + vendor: string; + attachments: Array<{ + resourceUuid: string; + resourceName: string; + resourceKind: "app" | "service"; + keys: string[]; + }>; + }>; + secrets: { + total: number; + byResource: Array<{ + resourceUuid: string; + resourceName: string; + resourceKind: "app" | "service"; + count: number; + }>; + }; + }; } export interface UseAnatomyResult {