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
212 lines
7.6 KiB
TypeScript
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);
|
|
}
|