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:
@@ -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:])
|
||||
|
||||
|
||||
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
|
||||
async def test_summary_is_persisted_as_admin_setting():
|
||||
engine, _ = _mock_engine()
|
||||
|
||||
Reference in New Issue
Block a user