From 938cd39c0696a247c68451fa318d30499c697c0e Mon Sep 17 00:00:00 2001 From: oliver Date: Thu, 6 Aug 2026 09:36:17 +0800 Subject: [PATCH] Show netx database used size on the workbench storage gauge. Co-authored-by: Cursor --- netx_api/db_storage_metrics.py | 65 ++++++++++++++++++++++++++++++++ netx_api/metrics_router.py | 5 +++ tests/test_db_storage_metrics.py | 60 +++++++++++++++++++++++++++++ web/src/i18n/en.ts | 3 ++ web/src/i18n/zh.ts | 3 ++ web/src/pages/WorkbenchPage.tsx | 38 ++++++++++++++++++- web/src/services/api.ts | 6 +++ 7 files changed, 178 insertions(+), 2 deletions(-) create mode 100644 netx_api/db_storage_metrics.py create mode 100644 tests/test_db_storage_metrics.py diff --git a/netx_api/db_storage_metrics.py b/netx_api/db_storage_metrics.py new file mode 100644 index 0000000..c9fe91f --- /dev/null +++ b/netx_api/db_storage_metrics.py @@ -0,0 +1,65 @@ +"""PostgreSQL database size for workbench (used bytes only).""" + +from __future__ import annotations + +import threading +import time +from typing import Any + +from sqlalchemy import text + +from .config import settings + +_CACHE_LOCK = threading.Lock() +_CACHED: dict[str, Any] | None = None +_CACHED_AT = 0.0 +_CACHE_TTL_SEC = 30.0 + + +def _open_session(): + """Indirection so tests can patch without importing the full engine.""" + from .db import SessionLocal + + return SessionLocal() + + +def _collect_uncached() -> dict[str, Any]: + url = str(settings.database_url or "") + out: dict[str, Any] = { + "used_bytes": 0, + "source": "none", + } + if url.startswith("sqlite"): + out["source"] = "sqlite" + out["error"] = "not_applicable" + return out + + try: + with _open_session() as db: + row = db.execute( + text( + "SELECT current_database() AS db_name, " + "pg_database_size(current_database())::bigint AS used_bytes" + ) + ).mappings().one() + out["db_name"] = str(row["db_name"] or "") + out["used_bytes"] = int(row["used_bytes"] or 0) + out["source"] = "pg_database_size" + except Exception as exc: # noqa: BLE001 + out["source"] = "error" + out["error"] = str(exc)[:200] + return out + + +def collect_db_storage_metrics() -> dict[str, Any]: + """Return PG used-size dict; never raises. Cached ~30s.""" + global _CACHED, _CACHED_AT + now = time.monotonic() + with _CACHE_LOCK: + if _CACHED is not None and (now - _CACHED_AT) < _CACHE_TTL_SEC: + return dict(_CACHED) + out = _collect_uncached() + with _CACHE_LOCK: + _CACHED = dict(out) + _CACHED_AT = time.monotonic() + return out diff --git a/netx_api/metrics_router.py b/netx_api/metrics_router.py index b8989fa..a4138fd 100644 --- a/netx_api/metrics_router.py +++ b/netx_api/metrics_router.py @@ -12,6 +12,7 @@ from .audit_async import audit_queue_status from .cli_budget import cli_budget_status from .config import settings from .db import db_pool_status +from .db_storage_metrics import collect_db_storage_metrics from .host_metrics import collect_host_metrics from .oclaw_alarm_forwarder import forwarder_status @@ -26,6 +27,7 @@ def collect_runtime_metrics() -> dict[str, Any]: "pid": os.getpid(), "thread_count": threading.active_count(), "db_pool": db_pool_status(), + "db_storage": collect_db_storage_metrics(), "cli_budget": cli_budget_status(), "audit_queue": audit_queue_status(), "oclaw_forwarder": forwarder_status(), @@ -142,6 +144,9 @@ def _prom_lines(metrics: dict[str, Any]) -> str: lines.append(f'netx_host_mem_used_bytes {host["mem_used_bytes"]}') if host.get("mem_total_bytes") is not None: lines.append(f'netx_host_mem_total_bytes {host["mem_total_bytes"]}') + storage = metrics.get("db_storage") or {} + if storage.get("used_bytes") is not None: + lines.append(f'netx_db_storage_used_bytes {int(storage.get("used_bytes") or 0)}') lines.append("") return "\n".join(lines) diff --git a/tests/test_db_storage_metrics.py b/tests/test_db_storage_metrics.py new file mode 100644 index 0000000..3c8d9c8 --- /dev/null +++ b/tests/test_db_storage_metrics.py @@ -0,0 +1,60 @@ +"""PostgreSQL storage metrics (database used size only).""" + +from __future__ import annotations + +import unittest +from unittest.mock import MagicMock, patch + +import netx_api.db_storage_metrics as db_storage +from netx_api.db_storage_metrics import collect_db_storage_metrics + + +class DbStorageMetricsTests(unittest.TestCase): + def setUp(self) -> None: + db_storage._CACHED = None + db_storage._CACHED_AT = 0.0 + + def test_pg_used_bytes(self) -> None: + row = {"db_name": "netx", "used_bytes": 12_345_678} + session = MagicMock() + session.__enter__.return_value = session + session.__exit__.return_value = False + session.execute.return_value.mappings.return_value.one.return_value = row + + with ( + patch.object(db_storage.settings, "database_url", "postgresql://u:p@10.0.0.8:5432/netx"), + patch.object(db_storage, "_open_session", return_value=session), + ): + out = collect_db_storage_metrics() + + self.assertEqual(out["used_bytes"], 12_345_678) + self.assertEqual(out["db_name"], "netx") + self.assertEqual(out["source"], "pg_database_size") + self.assertNotIn("total_bytes", out) + self.assertNotIn("percent", out) + + def test_sqlite_not_applicable(self) -> None: + with patch.object(db_storage.settings, "database_url", "sqlite:///tmp.db"): + out = collect_db_storage_metrics() + self.assertEqual(out["source"], "sqlite") + self.assertEqual(out["error"], "not_applicable") + self.assertEqual(out["used_bytes"], 0) + + def test_query_error(self) -> None: + session = MagicMock() + session.__enter__.return_value = session + session.__exit__.return_value = False + session.execute.side_effect = RuntimeError("boom") + + with ( + patch.object(db_storage.settings, "database_url", "postgresql://u:p@127.0.0.1:5432/netx"), + patch.object(db_storage, "_open_session", return_value=session), + ): + out = collect_db_storage_metrics() + + self.assertEqual(out["source"], "error") + self.assertIn("boom", str(out.get("error") or "")) + + +if __name__ == "__main__": + unittest.main() diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index edb86a8..35c57c9 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -45,6 +45,9 @@ const en = { gauge: { cpu: "CPU", mem: "Memory", + storage: "Storage", + storageUsed: "Used", + storageUnavailable: "Unavailable", cli: "CLI budget", db: "DB pool", webcrt: "WebCRT", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 49319e4..68d3bbd 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -45,6 +45,9 @@ const zh = { gauge: { cpu: "CPU", mem: "内存", + storage: "存储", + storageUsed: "已用", + storageUnavailable: "不可用", cli: "CLI 并发", db: "数据库连接", webcrt: "WebCRT", diff --git a/web/src/pages/WorkbenchPage.tsx b/web/src/pages/WorkbenchPage.tsx index bebb830..0bce01c 100644 --- a/web/src/pages/WorkbenchPage.tsx +++ b/web/src/pages/WorkbenchPage.tsx @@ -42,7 +42,26 @@ function formatBytes(n: number, locale: string): string { return `${(v / mib).toFixed(0)} MiB`; } -function Gauge({ label, hint, pct }: { label: string; hint: string; pct: number }) { +function formatBytesCompact(n: number): string { + const v = Math.max(0, Number(n) || 0); + const gib = 1024 ** 3; + const mib = 1024 ** 2; + if (v >= gib) return `${(v / gib).toFixed(v >= 10 * gib ? 0 : 1)}G`; + if (v >= mib) return `${(v / mib).toFixed(0)}M`; + return `${Math.max(1, Math.round(v / 1024))}K`; +} + +function Gauge({ + label, + hint, + pct, + valueText, +}: { + label: string; + hint: string; + pct: number; + valueText?: string; +}) { const tone = gaugeTone(pct); const safe = clampPct(pct); return ( @@ -54,7 +73,7 @@ function Gauge({ label, hint, pct }: { label: string; hint: string; pct: number background: `conic-gradient(${tone} ${safe}%, rgba(148, 163, 184, 0.16) 0)`, }} > - {safe}% + {valueText ?? `${safe}%`}
{label}
@@ -102,6 +121,15 @@ export function WorkbenchPage() { const memUsed = Number(host?.mem_used_bytes || 0); const memTotal = Number(host?.mem_total_bytes || 0); + const storage = metrics?.db_storage; + const storageUsed = Number(storage?.used_bytes || 0); + const storageOk = + storageUsed > 0 || (storage?.source === "pg_database_size" && !storage?.error); + const storageHint = storageOk + ? `${t("workbench.gauge.storageUsed")} · ${formatBytes(storageUsed, locale)}` + : t("workbench.gauge.storageUnavailable"); + const storageValue = storageOk ? formatBytesCompact(storageUsed) : "—"; + const cliUsed = Number(metrics?.cli_budget?.in_use || 0); const cliLimit = Math.max(1, Number(metrics?.cli_budget?.limit || 0)); const cliPct = ratioPct(cliUsed, cliLimit); @@ -156,6 +184,12 @@ export function WorkbenchPage() { } pct={memPct} /> +