mirror of
https://github.com/hansjone/netx.git
synced 2026-10-08 23:33:21 +08:00
Show netx database used size on the workbench storage gauge.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
155cb4e921
commit
938cd39c06
7 changed files with 178 additions and 2 deletions
65
netx_api/db_storage_metrics.py
Normal file
65
netx_api/db_storage_metrics.py
Normal 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
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
60
tests/test_db_storage_metrics.py
Normal file
60
tests/test_db_storage_metrics.py
Normal 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()
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -45,6 +45,9 @@ const zh = {
|
|||
gauge: {
|
||||
cpu: "CPU",
|
||||
mem: "内存",
|
||||
storage: "存储",
|
||||
storageUsed: "已用",
|
||||
storageUnavailable: "不可用",
|
||||
cli: "CLI 并发",
|
||||
db: "数据库连接",
|
||||
webcrt: "WebCRT",
|
||||
|
|
|
|||
|
|
@ -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)`,
|
||||
}}
|
||||
>
|
||||
<span className="wb-gauge__value">{safe}%</span>
|
||||
<span className="wb-gauge__value">{valueText ?? `${safe}%`}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="wb-gauge__label">{label}</div>
|
||||
|
|
@ -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}
|
||||
/>
|
||||
<Gauge
|
||||
label={t("workbench.gauge.storage")}
|
||||
hint={storageHint}
|
||||
pct={0}
|
||||
valueText={storageValue}
|
||||
/>
|
||||
<Gauge
|
||||
label={t("workbench.gauge.cli")}
|
||||
hint={`${cliUsed}/${cliLimit || "—"} · ${loadHint(cliPct, t)}`}
|
||||
|
|
|
|||
|
|
@ -252,6 +252,12 @@ export type RuntimeMetrics = {
|
|||
overflow?: number;
|
||||
size?: number;
|
||||
};
|
||||
db_storage?: {
|
||||
db_name?: string;
|
||||
used_bytes?: number;
|
||||
source?: string;
|
||||
error?: string;
|
||||
};
|
||||
cli_budget?: {
|
||||
limit?: number;
|
||||
in_use?: number;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue