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 savingDbMaint = ref(false);
|
||||||
const dbMaintSaved = ref(false);
|
const dbMaintSaved = ref(false);
|
||||||
const runningDbMaint = 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 pluginInstallCommands = computed(() => {
|
||||||
const mkt = (pluginMarketplaceUrl.value || '').trim()
|
const mkt = (pluginMarketplaceUrl.value || '').trim()
|
||||||
|| serverMarketplaceUrl.value
|
|| serverMarketplaceUrl.value
|
||||||
@@ -468,6 +485,7 @@ onMounted(async () => {
|
|||||||
} catch {
|
} catch {
|
||||||
// leave defaults
|
// leave defaults
|
||||||
}
|
}
|
||||||
|
await loadDbHealth();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load admin settings
|
// 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() {
|
async function runDbMaintenanceNow() {
|
||||||
runningDbMaint.value = true;
|
runningDbMaint.value = true;
|
||||||
try {
|
try {
|
||||||
@@ -747,6 +776,7 @@ async function runDbMaintenanceNow() {
|
|||||||
failed ? `Maintenance ran with ${failed} error(s)` : "Maintenance complete",
|
failed ? `Maintenance ran with ${failed} error(s)` : "Maintenance complete",
|
||||||
failed ? "error" : "success",
|
failed ? "error" : "success",
|
||||||
);
|
);
|
||||||
|
await loadDbHealth(); // reflect the dead-tuple drop
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const body = (e as { body?: { error?: string } }).body;
|
const body = (e as { body?: { error?: string } }).body;
|
||||||
toastStore.show(body?.error || "Maintenance run failed", "error");
|
toastStore.show(body?.error || "Maintenance run failed", "error");
|
||||||
@@ -1825,6 +1855,41 @@ function formatUserDate(iso: string): string {
|
|||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<section class="settings-section full-width">
|
<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 .dm-status { color: var(--color-text-muted); }
|
||||||
.db-maint-table-list li.dm-failed .dm-status { color: var(--color-danger); }
|
.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) {
|
.btn-warn:hover:not(:disabled) {
|
||||||
background: var(--color-warning);
|
background: var(--color-warning);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
|
|||||||
@@ -225,6 +225,14 @@ async def get_db_maintenance():
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route("/db-maintenance/health", methods=["GET"])
|
||||||
|
@admin_required
|
||||||
|
async def get_db_maintenance_health():
|
||||||
|
"""Read-only per-table bloat/health stats from Postgres + total DB size."""
|
||||||
|
from scribe.services.db_maintenance import get_table_health
|
||||||
|
return jsonify(await get_table_health())
|
||||||
|
|
||||||
|
|
||||||
@admin_bp.route("/db-maintenance", methods=["PUT"])
|
@admin_bp.route("/db-maintenance", methods=["PUT"])
|
||||||
@admin_required
|
@admin_required
|
||||||
async def update_db_maintenance():
|
async def update_db_maintenance():
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ import logging
|
|||||||
import time
|
import time
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from scribe.models import engine
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from scribe.models import async_session, engine
|
||||||
from scribe.services.settings import get_admin_setting, set_admin_setting
|
from scribe.services.settings import get_admin_setting, set_admin_setting
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -104,6 +106,63 @@ async def run_maintenance(tables: tuple[str, ...] | list[str] | None = None) ->
|
|||||||
return summary
|
return summary
|
||||||
|
|
||||||
|
|
||||||
|
# Read-only table-health query against Postgres' own statistics views. The key
|
||||||
|
# signal is the dead-tuple ratio (dead / (live + dead)) — that IS bloat; a high
|
||||||
|
# ratio on a large table means autovacuum isn't keeping up. Timestamps confirm
|
||||||
|
# maintenance is actually running on each table.
|
||||||
|
_HEALTH_SQL = text("""
|
||||||
|
SELECT
|
||||||
|
relname AS table_name,
|
||||||
|
n_live_tup AS live,
|
||||||
|
n_dead_tup AS dead,
|
||||||
|
CASE WHEN n_live_tup + n_dead_tup > 0
|
||||||
|
THEN round(100.0 * n_dead_tup / (n_live_tup + n_dead_tup), 1)
|
||||||
|
ELSE 0 END AS dead_pct,
|
||||||
|
pg_total_relation_size(relid) AS total_bytes,
|
||||||
|
n_mod_since_analyze AS mod_since_analyze,
|
||||||
|
GREATEST(last_vacuum, last_autovacuum) AS last_vacuum,
|
||||||
|
GREATEST(last_analyze, last_autoanalyze) AS last_analyze
|
||||||
|
FROM pg_stat_user_tables
|
||||||
|
ORDER BY n_dead_tup DESC, total_bytes DESC
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
def _iso(value) -> str | None:
|
||||||
|
return value.isoformat() if value is not None else None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_table_health() -> dict:
|
||||||
|
"""Per-table health from Postgres statistics + the total database size.
|
||||||
|
|
||||||
|
Read-only (system catalogs only). Returns:
|
||||||
|
{"db_bytes": int, "tables": [
|
||||||
|
{"table": str, "live": int, "dead": int, "dead_pct": float,
|
||||||
|
"total_bytes": int, "mod_since_analyze": int,
|
||||||
|
"last_vacuum": iso|None, "last_analyze": iso|None}
|
||||||
|
]}
|
||||||
|
"""
|
||||||
|
async with async_session() as session:
|
||||||
|
db_bytes = (
|
||||||
|
await session.execute(text("SELECT pg_database_size(current_database())"))
|
||||||
|
).scalar() or 0
|
||||||
|
rows = (await session.execute(_HEALTH_SQL)).mappings().all()
|
||||||
|
|
||||||
|
tables = [
|
||||||
|
{
|
||||||
|
"table": r["table_name"],
|
||||||
|
"live": int(r["live"] or 0),
|
||||||
|
"dead": int(r["dead"] or 0),
|
||||||
|
"dead_pct": float(r["dead_pct"] or 0),
|
||||||
|
"total_bytes": int(r["total_bytes"] or 0),
|
||||||
|
"mod_since_analyze": int(r["mod_since_analyze"] or 0),
|
||||||
|
"last_vacuum": _iso(r["last_vacuum"]),
|
||||||
|
"last_analyze": _iso(r["last_analyze"]),
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
return {"db_bytes": int(db_bytes), "tables": tables}
|
||||||
|
|
||||||
|
|
||||||
async def get_last_run() -> dict | None:
|
async def get_last_run() -> dict | None:
|
||||||
"""Return the most recent run summary, or None if it has never run."""
|
"""Return the most recent run summary, or None if it has never run."""
|
||||||
raw = await get_admin_setting(_LAST_RUN_KEY, "")
|
raw = await get_admin_setting(_LAST_RUN_KEY, "")
|
||||||
|
|||||||
@@ -3,7 +3,12 @@
|
|||||||
|
|
||||||
def test_admin_handlers_callable():
|
def test_admin_handlers_callable():
|
||||||
from scribe.routes import admin as admin_routes
|
from scribe.routes import admin as admin_routes
|
||||||
for name in ("get_db_maintenance", "update_db_maintenance", "run_db_maintenance_now"):
|
for name in (
|
||||||
|
"get_db_maintenance",
|
||||||
|
"update_db_maintenance",
|
||||||
|
"run_db_maintenance_now",
|
||||||
|
"get_db_maintenance_health",
|
||||||
|
):
|
||||||
assert callable(getattr(admin_routes, name))
|
assert callable(getattr(admin_routes, name))
|
||||||
|
|
||||||
|
|
||||||
@@ -13,6 +18,7 @@ def test_db_maintenance_routes_registered():
|
|||||||
rules = {r.rule for r in app.url_map.iter_rules()}
|
rules = {r.rule for r in app.url_map.iter_rules()}
|
||||||
assert "/api/admin/db-maintenance" in rules
|
assert "/api/admin/db-maintenance" in rules
|
||||||
assert "/api/admin/db-maintenance/run" in rules
|
assert "/api/admin/db-maintenance/run" in rules
|
||||||
|
assert "/api/admin/db-maintenance/health" in rules
|
||||||
|
|
||||||
|
|
||||||
def test_scheduler_surface():
|
def test_scheduler_surface():
|
||||||
@@ -28,8 +34,13 @@ def test_scheduler_surface():
|
|||||||
|
|
||||||
|
|
||||||
def test_service_surface_and_allowlist():
|
def test_service_surface_and_allowlist():
|
||||||
from scribe.services.db_maintenance import MAINTENANCE_TABLES, get_last_run, run_maintenance
|
from scribe.services.db_maintenance import (
|
||||||
assert callable(run_maintenance) and callable(get_last_run)
|
MAINTENANCE_TABLES,
|
||||||
|
get_last_run,
|
||||||
|
get_table_health,
|
||||||
|
run_maintenance,
|
||||||
|
)
|
||||||
|
assert callable(run_maintenance) and callable(get_last_run) and callable(get_table_health)
|
||||||
# The allowlist is a closed tuple covering exactly the high-churn tables.
|
# The allowlist is a closed tuple covering exactly the high-churn tables.
|
||||||
assert isinstance(MAINTENANCE_TABLES, tuple)
|
assert isinstance(MAINTENANCE_TABLES, tuple)
|
||||||
assert set(MAINTENANCE_TABLES) == {
|
assert set(MAINTENANCE_TABLES) == {
|
||||||
|
|||||||
@@ -76,6 +76,41 @@ async def test_one_table_failure_does_not_abort_the_rest():
|
|||||||
assert all(r["ok"] for r in summary["tables"][1:])
|
assert all(r["ok"] for r in summary["tables"][1:])
|
||||||
|
|
||||||
|
|
||||||
|
def _health_session(db_bytes, rows):
|
||||||
|
s = AsyncMock()
|
||||||
|
s.__aenter__ = AsyncMock(return_value=s)
|
||||||
|
s.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
size_res = MagicMock()
|
||||||
|
size_res.scalar.return_value = db_bytes
|
||||||
|
rows_res = MagicMock()
|
||||||
|
rows_res.mappings.return_value.all.return_value = rows
|
||||||
|
s.execute = AsyncMock(side_effect=[size_res, rows_res])
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_table_health_shapes_rows_and_db_size():
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from scribe.services.db_maintenance import get_table_health
|
||||||
|
vac = datetime(2026, 6, 14, 4, 0, tzinfo=timezone.utc)
|
||||||
|
rows = [
|
||||||
|
{"table_name": "notes", "live": 1000, "dead": 300, "dead_pct": 23.1,
|
||||||
|
"total_bytes": 5_000_000, "mod_since_analyze": 50,
|
||||||
|
"last_vacuum": vac, "last_analyze": None},
|
||||||
|
]
|
||||||
|
with patch("scribe.services.db_maintenance.async_session",
|
||||||
|
return_value=_health_session(42_000_000, rows)):
|
||||||
|
health = await get_table_health()
|
||||||
|
|
||||||
|
assert health["db_bytes"] == 42_000_000
|
||||||
|
t = health["tables"][0]
|
||||||
|
assert t["table"] == "notes"
|
||||||
|
assert t["dead_pct"] == 23.1
|
||||||
|
assert t["last_vacuum"] == vac.isoformat()
|
||||||
|
assert t["last_analyze"] is None # null timestamp passes through as None
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_summary_is_persisted_as_admin_setting():
|
async def test_summary_is_persisted_as_admin_setting():
|
||||||
engine, _ = _mock_engine()
|
engine, _ = _mock_engine()
|
||||||
|
|||||||
Reference in New Issue
Block a user