feat(project): wire Infrastructure tab to live Coolify data
Three sub-areas, all real, no static placeholders:
Databases — listDatabasesInProject(coolifyProjectUuid). Type is
normalised (postgresql / redis / mongodb / mysql / keydb
/ dragonfly / clickhouse) so the tile subtitle is stable
regardless of how Coolify spells the engine.
Providers — auto-detected from env-var keys across every app + service
in the project. 35+ patterns covering Auth (Clerk, Auth0,
Supabase, NextAuth, SuperTokens, WorkOS, Firebase Auth),
Email (Resend, Mailgun, Postmark, SendGrid, SES, Loops),
SMS (Twilio, Vonage), Payments (Stripe, LemonSqueezy,
Paddle), Analytics (PostHog, Mixpanel, Amplitude, Plausible,
Umami), LLM (OpenAI, Anthropic, Google AI, Mistral, Cohere,
Groq, OpenRouter), Storage (S3, R2, GCS, Supabase),
Search (Algolia, Meilisearch, Typesense), Monitoring
(Sentry, Datadog, LogSnag). Each tile drills down to show
which app/service the keys live in and which keys matched.
Secrets — env-var totals per app/service, sorted by count. Values
are never read or returned from this surface — keys only.
The detail pane explains how to read/edit (via AI chat
with services.envs.* / apps.envs.* MCP tools).
Anatomy endpoint extended in the same single-fetch shape: env vars are
loaded once, then both detectProviders() and summariseSecrets() run
against that one source so we don't double-fetch.
The static What-lives-here grid is gone — every tile shown corresponds
to something that actually exists in the project.
Made-with: Cursor
This commit is contained in:
@@ -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<Selection>(null);
|
||||
const showLoading = loading && !anatomy;
|
||||
|
||||
return (
|
||||
<SectionScaffold
|
||||
subAreas={[
|
||||
{ label: "Database", hint: "Postgres, Redis, vector DBs." },
|
||||
{ label: "Auth", hint: "Identity, sessions, SSO providers." },
|
||||
{ label: "File storage", hint: "Uploads, attachments, CDNs." },
|
||||
{ label: "Email & SMS", hint: "Transactional + outbound messaging." },
|
||||
{ label: "Payments", hint: "Stripe, billing, subscriptions." },
|
||||
{ label: "Analytics", hint: "Product analytics + event pipelines." },
|
||||
{ label: "LLM providers", hint: "Model APIs, embeddings, search." },
|
||||
{ label: "Secrets", hint: "API keys + environment variables." },
|
||||
]}
|
||||
rightPanel={
|
||||
<>
|
||||
<StatusPanel title="Connected providers">
|
||||
<EmptyState
|
||||
message="No providers connected yet"
|
||||
hint="Phase 2 will inventory Coolify databases and any third-party services tied to this project."
|
||||
/>
|
||||
</StatusPanel>
|
||||
<StatusPanel title="Secrets vault">
|
||||
<EmptyState
|
||||
message="Not wired yet"
|
||||
hint="Phase 2 will surface env vars + API keys with a single source of truth."
|
||||
/>
|
||||
</StatusPanel>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<div style={pageWrap}>
|
||||
<div style={grid}>
|
||||
{/* ── Left rail ── */}
|
||||
<section style={leftCol}>
|
||||
{showLoading && (
|
||||
<Inline><Loader2 size={13} className="animate-spin" /> Loading…</Inline>
|
||||
)}
|
||||
{error && !showLoading && (
|
||||
<Inline><AlertCircle size={13} /> {error}</Inline>
|
||||
)}
|
||||
{anatomy && (
|
||||
<>
|
||||
<RailGroup
|
||||
title="Databases"
|
||||
count={anatomy.infrastructure.databases.length}
|
||||
emptyHint="Postgres, Redis, Mongo, etc. provisioned in Coolify show up here."
|
||||
>
|
||||
{anatomy.infrastructure.databases.map(db => {
|
||||
const active = selection?.kind === "database" && selection.uuid === db.uuid;
|
||||
return (
|
||||
<button
|
||||
key={db.uuid}
|
||||
type="button"
|
||||
onClick={() => setSelection({ kind: "database", uuid: db.uuid })}
|
||||
style={{
|
||||
...railItem,
|
||||
borderColor: active ? INK.ink : INK.borderSoft,
|
||||
boxShadow: active ? `0 0 0 1px ${INK.ink}` : "none",
|
||||
background: active ? "#fffdf8" : INK.cardBg,
|
||||
}}
|
||||
aria-pressed={active}
|
||||
>
|
||||
<Database size={13} style={{ color: INK.mid, flexShrink: 0 }} />
|
||||
<div style={{ minWidth: 0, textAlign: "left", flex: 1 }}>
|
||||
<div style={tileLabel}>{db.name}</div>
|
||||
<div style={tileHint}>{db.type}</div>
|
||||
</div>
|
||||
<CircleDot size={9} style={{ color: statusColor(db.status), flexShrink: 0 }} />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</RailGroup>
|
||||
|
||||
<RailGroup
|
||||
title="Providers"
|
||||
count={anatomy.infrastructure.providers.length}
|
||||
emptyHint="Third-party services (Stripe, Resend, OpenAI…) are auto-detected from env-var keys."
|
||||
>
|
||||
{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 (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
onClick={() => setSelection({ kind: "provider", id: p.id })}
|
||||
style={{
|
||||
...railItem,
|
||||
borderColor: active ? INK.ink : INK.borderSoft,
|
||||
boxShadow: active ? `0 0 0 1px ${INK.ink}` : "none",
|
||||
background: active ? "#fffdf8" : INK.cardBg,
|
||||
}}
|
||||
aria-pressed={active}
|
||||
>
|
||||
<Icon size={13} style={{ color: INK.mid, flexShrink: 0 }} />
|
||||
<div style={{ minWidth: 0, textAlign: "left", flex: 1 }}>
|
||||
<div style={tileLabel}>{p.vendor}</div>
|
||||
<div style={tileHint}>{meta.label}</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</RailGroup>
|
||||
|
||||
<RailGroup
|
||||
title="Secrets"
|
||||
count={anatomy.infrastructure.secrets.total}
|
||||
emptyHint="Env vars across every app + service. Values are never read or stored here."
|
||||
>
|
||||
{anatomy.infrastructure.secrets.byResource.map(r => {
|
||||
const active = selection?.kind === "secrets" && selection.resourceUuid === r.resourceUuid;
|
||||
return (
|
||||
<button
|
||||
key={r.resourceUuid}
|
||||
type="button"
|
||||
onClick={() => setSelection({ kind: "secrets", resourceUuid: r.resourceUuid })}
|
||||
style={{
|
||||
...railItem,
|
||||
borderColor: active ? INK.ink : INK.borderSoft,
|
||||
boxShadow: active ? `0 0 0 1px ${INK.ink}` : "none",
|
||||
background: active ? "#fffdf8" : INK.cardBg,
|
||||
}}
|
||||
aria-pressed={active}
|
||||
>
|
||||
<KeyRound size={13} style={{ color: INK.mid, flexShrink: 0 }} />
|
||||
<div style={{ minWidth: 0, textAlign: "left", flex: 1 }}>
|
||||
<div style={tileLabel}>{r.resourceName}</div>
|
||||
<div style={tileHint}>{r.resourceKind}</div>
|
||||
</div>
|
||||
<span style={countPill}>{r.count}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</RailGroup>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* ── Right pane ── */}
|
||||
<aside style={rightCol}>
|
||||
<h3 style={heading}>{paneHeading(selection, anatomy)}</h3>
|
||||
<div style={panel}>
|
||||
{anatomy && selection
|
||||
? <Detail selection={selection} anatomy={anatomy} />
|
||||
: <Empty>Pick something on the left to see its details.</Empty>}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// 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 <Empty>This database is no longer in the project.</Empty>;
|
||||
return (
|
||||
<DetailLayout>
|
||||
<DetailRow label="Type" value={db.type} />
|
||||
<DetailRow label="Status" value={db.status} dot={statusColor(db.status)} />
|
||||
<DetailRow label="Public" value={db.isPublic ? "yes" : "no"} />
|
||||
{db.publicPort != null && <DetailRow label="Port" value={String(db.publicPort)} />}
|
||||
</DetailLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (selection.kind === "provider") {
|
||||
const p = anatomy.infrastructure.providers.find(x => x.id === selection.id);
|
||||
if (!p) return <Empty>This provider is no longer detected.</Empty>;
|
||||
const meta = CATEGORY_META[p.category];
|
||||
return (
|
||||
<DetailLayout>
|
||||
<DetailRow label="Vendor" value={p.vendor} />
|
||||
<DetailRow label="Category" value={meta.label} />
|
||||
<DetailRow
|
||||
label="Detected from"
|
||||
value={`${p.attachments.length} resource${p.attachments.length === 1 ? "" : "s"}`}
|
||||
/>
|
||||
<div style={{ marginTop: 12 }}>
|
||||
{p.attachments.map(att => (
|
||||
<div key={att.resourceUuid} style={attachmentBlock}>
|
||||
<div style={attachmentHeader}>
|
||||
{att.resourceName} <span style={attachmentBadge}>{att.resourceKind}</span>
|
||||
</div>
|
||||
<div style={keyList}>
|
||||
{att.keys.map(k => <code key={k} style={keyChip}>{k}</code>)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</DetailLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (selection.kind === "secrets") {
|
||||
const r = anatomy.infrastructure.secrets.byResource.find(x => x.resourceUuid === selection.resourceUuid);
|
||||
if (!r) return <Empty>This resource is no longer in the project.</Empty>;
|
||||
return (
|
||||
<DetailLayout>
|
||||
<DetailRow label="Resource" value={r.resourceName} />
|
||||
<DetailRow label="Kind" value={r.resourceKind} />
|
||||
<DetailRow label="Env vars" value={String(r.count)} />
|
||||
<div style={{ marginTop: 12, padding: 12, background: "#fafaf6", borderRadius: 8, fontSize: "0.78rem", color: INK.mid, lineHeight: 1.5 }}>
|
||||
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.
|
||||
</div>
|
||||
</DetailLayout>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div style={railGroup}>
|
||||
<header style={railGroupHeader}>
|
||||
<span style={railGroupTitle}>{title}</span>
|
||||
<span style={countPill}>{count}</span>
|
||||
</header>
|
||||
{count === 0 ? (
|
||||
<div style={railEmpty}>{emptyHint}</div>
|
||||
) : (
|
||||
<div style={railItems}>{children}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailLayout({ children }: { children: React.ReactNode }) {
|
||||
return <div style={{ display: "flex", flexDirection: "column" }}>{children}</div>;
|
||||
}
|
||||
|
||||
function DetailRow({
|
||||
label, value, dot,
|
||||
}: { label: string; value: string; dot?: string }) {
|
||||
return (
|
||||
<div style={detailRow}>
|
||||
<span style={detailLabel}>{label}</span>
|
||||
<span style={detailValue}>
|
||||
{dot && <CircleDot size={9} style={{ color: dot, marginRight: 6 }} />}
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Inline({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div style={{
|
||||
display: "flex", alignItems: "center", gap: 8,
|
||||
padding: "12px 14px", fontSize: "0.82rem", color: INK.mid,
|
||||
background: INK.cardBg, border: `1px solid ${INK.borderSoft}`, borderRadius: 8,
|
||||
}}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Empty({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div style={{
|
||||
flex: 1, display: "flex", alignItems: "center", justifyContent: "center",
|
||||
color: INK.mid, fontSize: "0.85rem", padding: "32px 16px", textAlign: "center",
|
||||
}}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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',
|
||||
};
|
||||
|
||||
@@ -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<BuildSummary | undefined> {
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// 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<InfraDatabase[]> {
|
||||
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<ResourceEnvs[]> {
|
||||
const appPromises = apps.map(async (a): Promise<ResourceEnvs> => {
|
||||
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<ResourceEnvs> => {
|
||||
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<string, InfraProvider>();
|
||||
|
||||
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<Preview[]> {
|
||||
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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user