feat(infra): collapse to 7 categories + live Postgres table inspection

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
This commit is contained in:
2026-04-29 15:22:58 -07:00
parent 63f18d46a5
commit 7b359e399e
9 changed files with 1861 additions and 300 deletions

View File

@@ -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<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,
}));
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<In
* specific enough to avoid false positives (e.g. `STRIPE_*` not just
* `*KEY*`).
*/
/**
* Provider detection rules. Categories surfaced today: Auth, Email,
* Payments, LLM. Storage is intentionally NOT auto-detected here —
* it's served by the workspace's bundled GCS bucket on the same tab.
*
* (Earlier iteration also detected SMS, Analytics, Search and
* Monitoring categories. Removed Apr 29 2026 to keep the surface
* focused on what users are actually plumbing today; rules can be
* added back when those categories get real UX.)
*/
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)/ },
{ 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<BundledStorage> {
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<Preview[]> {
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<string, unknown> }>(
`SELECT p.data FROM fs_projects p
const rows = await query<{ data: Record<string, unknown>; 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),
},
};

View File

@@ -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<string,string>[], 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<string, unknown> }>(
`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 }
);
}
}

View File

@@ -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<string, unknown> }>(
`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 }
);
}
}

View File

@@ -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<ApiResp | null>(null);
const [error, setError] = useState<string | null>(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 (
<div style={inline}>
<Loader2 size={12} className="animate-spin" /> Inspecting database
</div>
);
}
if (error) {
return (
<div style={errorBox}>
<AlertCircle size={12} /> {error}
</div>
);
}
if (!data) return null;
if (data.unsupported) {
return (
<div style={infoBox}>
<Info size={12} />
Table inspection isn't wired up for {data.engine} yet — Postgres
is the only engine supported today.
</div>
);
}
if (data.tables.length === 0) {
return (
<div style={infoBox}>
<Info size={12} />
No user-tables found. (Pre-deploy databases often start empty.)
</div>
);
}
// Group by schema; flatten `public` since most projects only use it.
const bySchema = new Map<string, IntrospectedTable[]>();
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 (
<div style={treeWrap}>
{schemas.map(schema => (
<SchemaGroup
key={schema}
schema={schema}
tables={bySchema.get(schema)!}
selectedTable={selectedTable}
onSelectTable={onSelectTable}
/>
))}
</div>
);
}
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 = (
<ul style={list}>
{tables.map(t => {
const active = selectedTable?.schema === t.schema && selectedTable?.name === t.name;
return (
<li key={`${t.schema}.${t.name}`}>
<button
type="button"
onClick={() => onSelectTable({ schema: t.schema, name: t.name })}
style={{
...row,
background: active ? "#fffdf8" : "transparent",
borderColor: active ? INK.ink : "transparent",
}}
aria-pressed={active}
>
<Table size={11} style={{ color: INK.mid, flexShrink: 0 }} />
<span style={tableName}>{t.name}</span>
{t.approxRows != null && t.approxRows > 0 && (
<span style={rowCount}>~{formatCount(t.approxRows)}</span>
)}
</button>
</li>
);
})}
</ul>
);
if (isPublic) return items;
return (
<div>
<button type="button" onClick={() => setOpen(o => !o)} style={schemaHeader}>
{open ? <ChevronDown size={11} /> : <ChevronRight size={11} />}
{schema}
</button>
{open && items}
</div>
);
}
function formatCount(n: number) {
if (n < 1_000) return String(n);
if (n < 1_000_000) return (n / 1_000).toFixed(n < 10_000 ? 1 : 0) + "k";
return (n / 1_000_000).toFixed(1) + "M";
}
// ──────────────────────────────────────────────────
const INK = {
ink: "#1a1a1a",
mid: "#5f5e5a",
muted: "#a09a90",
borderSoft: "#efebe1",
} as const;
const treeWrap: React.CSSProperties = { display: "flex", flexDirection: "column", gap: 6 };
const list: React.CSSProperties = { listStyle: "none", margin: 0, padding: 0, display: "flex", flexDirection: "column", gap: 1 };
const row: React.CSSProperties = {
display: "flex", alignItems: "center", gap: 6,
width: "100%", padding: "5px 8px",
border: "1px solid transparent", borderRadius: 5,
cursor: "pointer", font: "inherit", color: "inherit",
textAlign: "left",
};
const tableName: React.CSSProperties = {
fontSize: "0.78rem", color: INK.ink, flex: 1,
whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis",
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
};
const rowCount: React.CSSProperties = {
fontSize: "0.68rem", color: INK.muted, flexShrink: 0,
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
};
const schemaHeader: React.CSSProperties = {
display: "flex", alignItems: "center", gap: 4,
padding: "4px 6px",
fontSize: "0.7rem", fontWeight: 600,
letterSpacing: "0.06em", textTransform: "uppercase",
color: INK.muted,
background: "transparent", border: "none",
cursor: "pointer", font: "inherit",
};
const inline: React.CSSProperties = {
display: "flex", alignItems: "center", gap: 6,
padding: "8px 10px", fontSize: "0.76rem", color: INK.mid,
};
const infoBox: React.CSSProperties = {
display: "flex", alignItems: "flex-start", gap: 6,
padding: "8px 10px", fontSize: "0.74rem", color: INK.mid,
background: "#fafaf6", border: `1px dashed ${INK.borderSoft}`, borderRadius: 6,
lineHeight: 1.4,
};
const errorBox: React.CSSProperties = {
display: "flex", alignItems: "center", gap: 6,
padding: "8px 10px", fontSize: "0.74rem", color: "#7a1f15",
background: "#fbe9e7", border: `1px solid #f4c2bc`, borderRadius: 6,
};

View File

@@ -0,0 +1,180 @@
"use client";
/**
* Right-pane table preview. Shows the first ~50 rows of the selected
* table as a compact data grid. Cells longer than 200 chars are
* truncated server-side; we render the rest as-is. Read-only.
*/
import { useEffect, useState } from "react";
import { Loader2, AlertCircle, Info } from "lucide-react";
interface PreviewedTable {
columns: string[];
rows: Array<Record<string, string>>;
truncated: boolean;
unsupported?: boolean;
error?: string;
}
interface Props {
projectId: string;
dbUuid: string;
schema: string;
table: string;
}
export function TableViewer({ projectId, dbUuid, schema, table }: Props) {
const [data, setData] = useState<PreviewedTable | null>(null);
const [error, setError] = useState<string | null>(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);
setData(null);
const url = `/api/projects/${projectId}/databases/${dbUuid}/preview` +
`?schema=${encodeURIComponent(schema)}&table=${encodeURIComponent(table)}`;
fetch(url, { 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 PreviewedTable;
})
.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 load preview");
})
.finally(() => { clearTimeout(t); if (!cancelled) setLoading(false); });
return () => { cancelled = true; ctrl.abort(); clearTimeout(t); };
}, [projectId, dbUuid, schema, table]);
if (loading) {
return (
<div style={center}>
<Loader2 size={14} className="animate-spin" />
<span style={{ marginLeft: 8 }}>Querying {schema}.{table}</span>
</div>
);
}
if (error) {
return (
<div style={errorBox}>
<AlertCircle size={13} /> {error}
</div>
);
}
if (!data) return null;
if (data.rows.length === 0) {
return (
<div style={infoBox}>
<Info size={13} />
Table is empty.
</div>
);
}
return (
<div style={wrap}>
<div style={meta}>
Showing {data.rows.length} row{data.rows.length === 1 ? "" : "s"}
{data.truncated && " (truncated to first 50)"} ·{" "}
{data.columns.length} column{data.columns.length === 1 ? "" : "s"} ·{" "}
<code style={qual}>{schema}.{table}</code>
</div>
<div style={tableScroll}>
<table style={tableEl}>
<thead>
<tr>
{data.columns.map(c => (
<th key={c} style={th}>{c}</th>
))}
</tr>
</thead>
<tbody>
{data.rows.map((row, i) => (
<tr key={i} style={i % 2 === 0 ? trEven : trOdd}>
{data.columns.map(c => (
<td key={c} style={td} title={row[c]}>
{row[c]}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
// ──────────────────────────────────────────────────
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,
};

View File

@@ -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[];
}>;
};
};

View File

@@ -1248,15 +1248,19 @@ interface CoolifyProjectEnvResources {
clickhouses?: CoolifyDatabase[];
}
const DB_ARRAY_KEYS: Array<keyof CoolifyProjectEnvResources> = [
'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<CoolifyDatabase[]> {
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;
});

211
lib/db-introspect.ts Normal file
View File

@@ -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<Record<string, string>>;
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<string> {
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<IntrospectedTable[]> {
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<PreviewedTable> {
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<string>();
const parsed: Record<string, unknown>[] = [];
for (const line of rowJson) {
try {
const obj = JSON.parse(line) as Record<string, unknown>;
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<string, string> = {};
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<IntrospectedTable[]> {
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<PreviewedTable> {
if (engine !== "postgresql") throw new IntrospectionUnsupportedError(engine);
const container = await resolveDbContainer(dbUuid);
return pgPreviewTable(container, schema, table);
}