fix(databases): properly display row count pills for un-analyzed empty postgres tables

This commit is contained in:
2026-06-14 14:32:29 -07:00
parent 0894f1093d
commit 1668cf1fb4

View File

@@ -43,10 +43,12 @@ const SSH_TIMEOUT_MS = 8_000;
/** Find the running container for a Coolify database uuid. */ /** Find the running container for a Coolify database uuid. */
async function resolveDbContainer(dbUuid: string): Promise<string> { async function resolveDbContainer(dbUuid: string): Promise<string> {
const containers = await listContainersForApp(dbUuid); const containers = await listContainersForApp(dbUuid);
const running = containers.find(c => /up /i.test(c.status)); const running = containers.find((c) => /up /i.test(c.status));
const target = running ?? containers[0]; const target = running ?? containers[0];
if (!target) { if (!target) {
throw new Error(`No container found for database ${dbUuid}. Database may be stopped.`); throw new Error(
`No container found for database ${dbUuid}. Database may be stopped.`,
);
} }
return target.name; return target.name;
} }
@@ -85,7 +87,9 @@ async function pgListTables(container: string): Promise<IntrospectedTable[]> {
AND n.nspname NOT LIKE 'pg_%' AND n.nspname NOT LIKE 'pg_%'
ORDER BY n.nspname, c.relname ORDER BY n.nspname, c.relname
LIMIT ${MAX_TABLES + 1}; LIMIT ${MAX_TABLES + 1};
`.replace(/\s+/g, " ").trim(); `
.replace(/\s+/g, " ")
.trim();
const all: IntrospectedTable[] = []; const all: IntrospectedTable[] = [];
const multi = dbs.length > 1; const multi = dbs.length > 1;
@@ -93,7 +97,9 @@ async function pgListTables(container: string): Promise<IntrospectedTable[]> {
if (all.length >= MAX_TABLES) break; if (all.length >= MAX_TABLES) break;
const cmd = const cmd =
`docker exec ${sq(container)} bash -c ` + `docker exec ${sq(container)} bash -c ` +
sq(`psql -U "$POSTGRES_USER" -d ${sqInner(db)} -tAF '|' -c ${sqInner(tablesSql)}`); sq(
`psql -U "$POSTGRES_USER" -d ${sqInner(db)} -tAF '|' -c ${sqInner(tablesSql)}`,
);
const res = await runOnCoolifyHost(cmd, { timeoutMs: SSH_TIMEOUT_MS }); const res = await runOnCoolifyHost(cmd, { timeoutMs: SSH_TIMEOUT_MS });
if (res.code !== 0) { if (res.code !== 0) {
// Skip dbs the role can't connect to (e.g. owned by another user). // Skip dbs the role can't connect to (e.g. owned by another user).
@@ -104,11 +110,17 @@ async function pgListTables(container: string): Promise<IntrospectedTable[]> {
if (!trimmed) continue; if (!trimmed) continue;
const [schema, name, rowsStr] = trimmed.split("|"); const [schema, name, rowsStr] = trimmed.split("|");
if (!schema || !name) continue; if (!schema || !name) continue;
const approxRows = Number(rowsStr); let approxRows = Number(rowsStr);
// Postgres 14+ sets reltuples to -1 if the table has never been analyzed
if (approxRows === -1) approxRows = 0;
all.push({ all.push({
schema: multi ? `${db}.${schema}` : schema, schema: multi ? `${db}.${schema}` : schema,
name, name,
approxRows: Number.isFinite(approxRows) && approxRows >= 0 ? approxRows : undefined, approxRows:
Number.isFinite(approxRows) && approxRows >= 0
? approxRows
: undefined,
}); });
if (all.length >= MAX_TABLES) break; if (all.length >= MAX_TABLES) break;
} }
@@ -127,12 +139,14 @@ async function pgListDatabases(container: string): Promise<string[]> {
sq(`psql -U "$POSTGRES_USER" -d postgres -tA -c ${sqInner(sql)}`); sq(`psql -U "$POSTGRES_USER" -d postgres -tA -c ${sqInner(sql)}`);
const res = await runOnCoolifyHost(cmd, { timeoutMs: SSH_TIMEOUT_MS }); const res = await runOnCoolifyHost(cmd, { timeoutMs: SSH_TIMEOUT_MS });
if (res.code !== 0) { if (res.code !== 0) {
throw new Error(`psql -l exited ${res.code}: ${res.stderr.trim() || "(no stderr)"}`); throw new Error(
`psql -l exited ${res.code}: ${res.stderr.trim() || "(no stderr)"}`,
);
} }
return res.stdout return res.stdout
.split("\n") .split("\n")
.map(l => l.trim()) .map((l) => l.trim())
.filter(name => name && /^[A-Za-z0-9_]+$/.test(name)); .filter((name) => name && /^[A-Za-z0-9_]+$/.test(name));
} }
/** /**
@@ -173,14 +187,21 @@ async function pgPreviewTable(
const cmd = const cmd =
`docker exec ${sq(container)} bash -c ` + `docker exec ${sq(container)} bash -c ` +
sq(`psql -U "$POSTGRES_USER" -d ${sqInner(database)} -tA -c ${sqInner(sql)}`); sq(
`psql -U "$POSTGRES_USER" -d ${sqInner(database)} -tA -c ${sqInner(sql)}`,
);
const res = await runOnCoolifyHost(cmd, { timeoutMs: SSH_TIMEOUT_MS }); const res = await runOnCoolifyHost(cmd, { timeoutMs: SSH_TIMEOUT_MS });
if (res.code !== 0) { if (res.code !== 0) {
throw new Error(`psql exited ${res.code}: ${res.stderr.trim() || "(no stderr)"}`); 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 lines = res.stdout
.split("\n")
.map((l) => l.trim())
.filter(Boolean);
const truncated = lines.length > MAX_ROWS; const truncated = lines.length > MAX_ROWS;
const rowJson = lines.slice(0, MAX_ROWS); const rowJson = lines.slice(0, MAX_ROWS);
@@ -189,7 +210,7 @@ async function pgPreviewTable(
for (const line of rowJson) { for (const line of rowJson) {
try { try {
const obj = JSON.parse(line) as Record<string, unknown>; const obj = JSON.parse(line) as Record<string, unknown>;
Object.keys(obj).forEach(k => columnSet.add(k)); Object.keys(obj).forEach((k) => columnSet.add(k));
parsed.push(obj); parsed.push(obj);
} catch { } catch {
// Skip non-JSON lines (shouldn't happen with -tA, but be safe). // Skip non-JSON lines (shouldn't happen with -tA, but be safe).
@@ -199,10 +220,10 @@ async function pgPreviewTable(
// Stable column order: order they appeared in the first row, then // Stable column order: order they appeared in the first row, then
// any extras at the end. // any extras at the end.
const firstRowKeys = parsed[0] ? Object.keys(parsed[0]) : []; const firstRowKeys = parsed[0] ? Object.keys(parsed[0]) : [];
const extras = [...columnSet].filter(k => !firstRowKeys.includes(k)); const extras = [...columnSet].filter((k) => !firstRowKeys.includes(k));
const columns = [...firstRowKeys, ...extras]; const columns = [...firstRowKeys, ...extras];
const rows = parsed.map(row => { const rows = parsed.map((row) => {
const out: Record<string, string> = {}; const out: Record<string, string> = {};
for (const col of columns) { for (const col of columns) {
out[col] = formatCell(row[col]); out[col] = formatCell(row[col]);
@@ -228,7 +249,9 @@ function formatCell(v: unknown): string {
} }
if (typeof v === "number" || typeof v === "boolean") return String(v); if (typeof v === "number" || typeof v === "boolean") return String(v);
const json = JSON.stringify(v); const json = JSON.stringify(v);
return json.length > MAX_CELL_CHARS ? json.slice(0, MAX_CELL_CHARS) + "…" : json; return json.length > MAX_CELL_CHARS
? json.slice(0, MAX_CELL_CHARS) + "…"
: json;
} }
// ────────────────────────────────────────────────── // ──────────────────────────────────────────────────