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 .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)
|
||||||
|
|
||||||
|
|
|
||||||
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: {
|
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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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)}`}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue