feat(db): table-health readout — per-table bloat metrics in admin card
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 44s
CI & Build / Build & push image (push) Successful in 52s

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:
2026-06-14 17:53:24 -04:00
parent c4553d937c
commit 96079d5b77
5 changed files with 208 additions and 4 deletions
+14 -3
View File
@@ -3,7 +3,12 @@
def test_admin_handlers_callable():
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))
@@ -13,6 +18,7 @@ def test_db_maintenance_routes_registered():
rules = {r.rule for r in app.url_map.iter_rules()}
assert "/api/admin/db-maintenance" in rules
assert "/api/admin/db-maintenance/run" in rules
assert "/api/admin/db-maintenance/health" in rules
def test_scheduler_surface():
@@ -28,8 +34,13 @@ def test_scheduler_surface():
def test_service_surface_and_allowlist():
from scribe.services.db_maintenance import MAINTENANCE_TABLES, get_last_run, run_maintenance
assert callable(run_maintenance) and callable(get_last_run)
from scribe.services.db_maintenance import (
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.
assert isinstance(MAINTENANCE_TABLES, tuple)
assert set(MAINTENANCE_TABLES) == {
+35
View File
@@ -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()