feat(db): table-health readout — per-table bloat metrics in admin card
You can't decide what to maintain without seeing what's bloating. Adds a read-only health panel driven by Postgres' own statistics views. - services/db_maintenance.py: get_table_health() queries pg_stat_user_tables + pg_total_relation_size + pg_database_size — per-table size, live/dead tuples, dead-tuple ratio (the bloat signal), and last (auto)vacuum/(auto)analyze. - routes/admin.py: admin-only GET /api/admin/db-maintenance/health. - SettingsView.vue: 'Table health' table in the maintenance card, all tables sorted by dead tuples, rows >=20% dead-ratio flagged; total DB size shown; refreshes after a Run-now so the dead-tuple drop is visible. - Tests: health row/size shaping + null-timestamp passthrough; route + service surface. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -160,6 +160,23 @@ const dbMaintLastRun = ref<DbMaintRun | null>(null);
|
||||
const savingDbMaint = ref(false);
|
||||
const dbMaintSaved = ref(false);
|
||||
const runningDbMaint = ref(false);
|
||||
interface DbTableHealth {
|
||||
table: string; live: number; dead: number; dead_pct: number;
|
||||
total_bytes: number; mod_since_analyze: number;
|
||||
last_vacuum: string | null; last_analyze: string | null;
|
||||
}
|
||||
interface DbHealth { db_bytes: number; tables: DbTableHealth[] }
|
||||
const dbHealth = ref<DbHealth | null>(null);
|
||||
const loadingHealth = ref(false);
|
||||
const DEAD_PCT_WARN = 20; // dead-tuple ratio above this = autovacuum falling behind
|
||||
|
||||
function formatBytes(n: number): string {
|
||||
if (n < 1024) return `${n} B`;
|
||||
const units = ["KB", "MB", "GB", "TB"];
|
||||
let v = n / 1024, i = 0;
|
||||
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
|
||||
return `${v.toFixed(v >= 10 || i === 0 ? 0 : 1)} ${units[i]}`;
|
||||
}
|
||||
const pluginInstallCommands = computed(() => {
|
||||
const mkt = (pluginMarketplaceUrl.value || '').trim()
|
||||
|| serverMarketplaceUrl.value
|
||||
@@ -468,6 +485,7 @@ onMounted(async () => {
|
||||
} catch {
|
||||
// leave defaults
|
||||
}
|
||||
await loadDbHealth();
|
||||
}
|
||||
|
||||
// Load admin settings
|
||||
@@ -737,6 +755,17 @@ async function saveDbMaintenance() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDbHealth() {
|
||||
loadingHealth.value = true;
|
||||
try {
|
||||
dbHealth.value = await apiGet<DbHealth>("/api/admin/db-maintenance/health");
|
||||
} catch {
|
||||
// leave previous value
|
||||
} finally {
|
||||
loadingHealth.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function runDbMaintenanceNow() {
|
||||
runningDbMaint.value = true;
|
||||
try {
|
||||
@@ -747,6 +776,7 @@ async function runDbMaintenanceNow() {
|
||||
failed ? `Maintenance ran with ${failed} error(s)` : "Maintenance complete",
|
||||
failed ? "error" : "success",
|
||||
);
|
||||
await loadDbHealth(); // reflect the dead-tuple drop
|
||||
} catch (e) {
|
||||
const body = (e as { body?: { error?: string } }).body;
|
||||
toastStore.show(body?.error || "Maintenance run failed", "error");
|
||||
@@ -1825,6 +1855,41 @@ function formatUserDate(iso: string): string {
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="db-health">
|
||||
<h3 class="subsection-label">
|
||||
Table health
|
||||
<span v-if="dbHealth" class="db-health-total">· database {{ formatBytes(dbHealth.db_bytes) }}</span>
|
||||
</h3>
|
||||
<p class="field-hint">
|
||||
Dead-tuple ratio is bloat — rows left by updates/deletes not yet reclaimed.
|
||||
Above {{ DEAD_PCT_WARN }}% on a large table means autovacuum is falling behind;
|
||||
consider adding it to the maintenance set.
|
||||
</p>
|
||||
<p v-if="loadingHealth && !dbHealth" class="field-hint">Loading…</p>
|
||||
<div v-else-if="dbHealth" class="db-health-scroll">
|
||||
<table class="db-health-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Table</th><th class="num">Size</th><th class="num">Live</th>
|
||||
<th class="num">Dead</th><th class="num">Dead %</th>
|
||||
<th>Last vacuum</th><th>Last analyze</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="t in dbHealth.tables" :key="t.table" :class="{ 'dh-warn': t.dead_pct >= DEAD_PCT_WARN }">
|
||||
<td><code>{{ t.table }}</code></td>
|
||||
<td class="num">{{ formatBytes(t.total_bytes) }}</td>
|
||||
<td class="num">{{ t.live.toLocaleString() }}</td>
|
||||
<td class="num">{{ t.dead.toLocaleString() }}</td>
|
||||
<td class="num">{{ t.dead_pct }}%</td>
|
||||
<td>{{ t.last_vacuum ? new Date(t.last_vacuum).toLocaleString() : "—" }}</td>
|
||||
<td>{{ t.last_analyze ? new Date(t.last_analyze).toLocaleString() : "—" }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="settings-section full-width">
|
||||
@@ -2481,6 +2546,32 @@ function formatUserDate(iso: string): string {
|
||||
}
|
||||
.db-maint-table-list .dm-status { color: var(--color-text-muted); }
|
||||
.db-maint-table-list li.dm-failed .dm-status { color: var(--color-danger); }
|
||||
|
||||
/* DB table-health readout */
|
||||
.db-health { margin-top: 1.5rem; }
|
||||
.db-health-total { color: var(--color-text-muted); font-weight: 400; }
|
||||
.db-health-scroll { overflow-x: auto; margin-top: 0.5rem; }
|
||||
.db-health-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.db-health-table th, .db-health-table td {
|
||||
text-align: left;
|
||||
padding: 0.35rem 0.6rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.db-health-table th {
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-text-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
.db-health-table td.num, .db-health-table th.num { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.db-health-table tr.dh-warn td { color: var(--color-warning); }
|
||||
.db-health-table tr.dh-warn td:first-child code { color: var(--color-warning); }
|
||||
.btn-warn:hover:not(:disabled) {
|
||||
background: var(--color-warning);
|
||||
color: #fff;
|
||||
|
||||
Reference in New Issue
Block a user