Show netx database used size on the workbench storage gauge.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-08-06 09:36:17 +08:00
parent 155cb4e921
commit 938cd39c06
7 changed files with 178 additions and 2 deletions

View file

@ -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

View file

@ -12,6 +12,7 @@ from .audit_async import audit_queue_status
from .cli_budget import cli_budget_status from .cli_budget import cli_budget_status
from .config import settings from .config import settings
from .db import db_pool_status from .db import db_pool_status
from .db_storage_metrics import collect_db_storage_metrics
from .host_metrics import collect_host_metrics from .host_metrics import collect_host_metrics
from .oclaw_alarm_forwarder import forwarder_status from .oclaw_alarm_forwarder import forwarder_status
@ -26,6 +27,7 @@ def collect_runtime_metrics() -> dict[str, Any]:
"pid": os.getpid(), "pid": os.getpid(),
"thread_count": threading.active_count(), "thread_count": threading.active_count(),
"db_pool": db_pool_status(), "db_pool": db_pool_status(),
"db_storage": collect_db_storage_metrics(),
"cli_budget": cli_budget_status(), "cli_budget": cli_budget_status(),
"audit_queue": audit_queue_status(), "audit_queue": audit_queue_status(),
"oclaw_forwarder": forwarder_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"]}') lines.append(f'netx_host_mem_used_bytes {host["mem_used_bytes"]}')
if host.get("mem_total_bytes") is not None: if host.get("mem_total_bytes") is not None:
lines.append(f'netx_host_mem_total_bytes {host["mem_total_bytes"]}') 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("") lines.append("")
return "\n".join(lines) return "\n".join(lines)

View file

@ -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()

View file

@ -45,6 +45,9 @@ const en = {
gauge: { gauge: {
cpu: "CPU", cpu: "CPU",
mem: "Memory", mem: "Memory",
storage: "Storage",
storageUsed: "Used",
storageUnavailable: "Unavailable",
cli: "CLI budget", cli: "CLI budget",
db: "DB pool", db: "DB pool",
webcrt: "WebCRT", webcrt: "WebCRT",

View file

@ -45,6 +45,9 @@ const zh = {
gauge: { gauge: {
cpu: "CPU", cpu: "CPU",
mem: "内存", mem: "内存",
storage: "存储",
storageUsed: "已用",
storageUnavailable: "不可用",
cli: "CLI 并发", cli: "CLI 并发",
db: "数据库连接", db: "数据库连接",
webcrt: "WebCRT", webcrt: "WebCRT",

View file

@ -42,7 +42,26 @@ function formatBytes(n: number, locale: string): string {
return `${(v / mib).toFixed(0)} MiB`; 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 tone = gaugeTone(pct);
const safe = clampPct(pct); const safe = clampPct(pct);
return ( 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)`, background: `conic-gradient(${tone} ${safe}%, rgba(148, 163, 184, 0.16) 0)`,
}} }}
> >
<span className="wb-gauge__value">{safe}%</span> <span className="wb-gauge__value">{valueText ?? `${safe}%`}</span>
</div> </div>
</div> </div>
<div className="wb-gauge__label">{label}</div> <div className="wb-gauge__label">{label}</div>
@ -102,6 +121,15 @@ export function WorkbenchPage() {
const memUsed = Number(host?.mem_used_bytes || 0); const memUsed = Number(host?.mem_used_bytes || 0);
const memTotal = Number(host?.mem_total_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 cliUsed = Number(metrics?.cli_budget?.in_use || 0);
const cliLimit = Math.max(1, Number(metrics?.cli_budget?.limit || 0)); const cliLimit = Math.max(1, Number(metrics?.cli_budget?.limit || 0));
const cliPct = ratioPct(cliUsed, cliLimit); const cliPct = ratioPct(cliUsed, cliLimit);
@ -156,6 +184,12 @@ export function WorkbenchPage() {
} }
pct={memPct} pct={memPct}
/> />
<Gauge
label={t("workbench.gauge.storage")}
hint={storageHint}
pct={0}
valueText={storageValue}
/>
<Gauge <Gauge
label={t("workbench.gauge.cli")} label={t("workbench.gauge.cli")}
hint={`${cliUsed}/${cliLimit || "—"} · ${loadHint(cliPct, t)}`} hint={`${cliUsed}/${cliLimit || "—"} · ${loadHint(cliPct, t)}`}

View file

@ -252,6 +252,12 @@ export type RuntimeMetrics = {
overflow?: number; overflow?: number;
size?: number; size?: number;
}; };
db_storage?: {
db_name?: string;
used_bytes?: number;
source?: string;
error?: string;
};
cli_budget?: { cli_budget?: {
limit?: number; limit?: number;
in_use?: number; in_use?: number;