diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue index 13110a2..d3f7791 100644 --- a/frontend/src/views/SettingsView.vue +++ b/frontend/src/views/SettingsView.vue @@ -160,6 +160,23 @@ const dbMaintLastRun = ref(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(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("/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 { + +
+

+ Table health + · database {{ formatBytes(dbHealth.db_bytes) }} +

+

+ 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. +

+

Loading…

+
+ + + + + + + + + + + + + + + + + + + +
TableSizeLiveDeadDead %Last vacuumLast analyze
{{ t.table }}{{ formatBytes(t.total_bytes) }}{{ t.live.toLocaleString() }}{{ t.dead.toLocaleString() }}{{ t.dead_pct }}%{{ t.last_vacuum ? new Date(t.last_vacuum).toLocaleString() : "—" }}{{ t.last_analyze ? new Date(t.last_analyze).toLocaleString() : "—" }}
+
+
@@ -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; diff --git a/src/scribe/routes/admin.py b/src/scribe/routes/admin.py index fe948b5..1a5d0d7 100644 --- a/src/scribe/routes/admin.py +++ b/src/scribe/routes/admin.py @@ -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_required async def update_db_maintenance(): diff --git a/src/scribe/services/db_maintenance.py b/src/scribe/services/db_maintenance.py index d28eb3f..98a5c40 100644 --- a/src/scribe/services/db_maintenance.py +++ b/src/scribe/services/db_maintenance.py @@ -18,7 +18,9 @@ import logging import time 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 logger = logging.getLogger(__name__) @@ -104,6 +106,63 @@ async def run_maintenance(tables: tuple[str, ...] | list[str] | None = None) -> 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: """Return the most recent run summary, or None if it has never run.""" raw = await get_admin_setting(_LAST_RUN_KEY, "") diff --git a/tests/test_routes_db_maintenance.py b/tests/test_routes_db_maintenance.py index 6718577..5a9a17e 100644 --- a/tests/test_routes_db_maintenance.py +++ b/tests/test_routes_db_maintenance.py @@ -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) == { diff --git a/tests/test_services_db_maintenance.py b/tests/test_services_db_maintenance.py index f495a3f..0b32397 100644 --- a/tests/test_services_db_maintenance.py +++ b/tests/test_services_db_maintenance.py @@ -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()