Files
vibn-frontend/lib/db-introspect.ts
Mark Henderson 7b359e399e 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
2026-04-29 15:22:58 -07:00

212 lines
7.6 KiB
TypeScript

/**
* 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);
}