feat: add VPS resource health panel

This commit is contained in:
Michael Lam
2026-05-04 23:57:58 -07:00
committed by test
parent cebca4700b
commit fdeac578da
7 changed files with 471 additions and 0 deletions

View File

@@ -480,6 +480,7 @@ from api.helpers import (
_redact_text,
)
from api.agent_health import build_agent_health_payload
from api.system_health import build_system_health_payload
def _clear_stale_stream_state(session) -> bool:
@@ -2491,6 +2492,10 @@ def handle_get(handler, parsed) -> bool:
if parsed.path == "/api/health/agent":
return j(handler, build_agent_health_payload())
if parsed.path == "/api/system/health":
j(handler, build_system_health_payload())
return True
if parsed.path == "/api/models":
return j(handler, get_available_models())

167
api/system_health.py Normal file
View File

@@ -0,0 +1,167 @@
"""Safe aggregate host resource metrics for the WebUI VPS panel (#693).
The browser only needs coarse CPU/RAM/disk usage. Keep this module intentionally
small and dependency-free: no process lists, command strings, user identities,
environment variables, or filesystem topology leave the server.
"""
from __future__ import annotations
import shutil
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
_PROC_STAT = Path("/proc/stat")
_PROC_MEMINFO = Path("/proc/meminfo")
_CPU_SAMPLE_SECONDS = 0.05
def _checked_at() -> str:
return datetime.now(timezone.utc).isoformat()
def _clamp_percent(value: Any) -> float:
try:
numeric = float(value)
except (TypeError, ValueError):
return 0.0
if numeric < 0:
numeric = 0.0
if numeric > 100:
numeric = 100.0
return round(numeric, 1)
def _read_proc_stat_cpu() -> tuple[int, int]:
"""Return (idle_ticks, total_ticks) from Linux /proc/stat."""
with _PROC_STAT.open("r", encoding="utf-8") as handle:
first = handle.readline().strip().split()
if not first or first[0] != "cpu":
raise RuntimeError("proc_stat_unavailable")
values = [int(part) for part in first[1:]]
if len(values) < 4:
raise RuntimeError("proc_stat_unavailable")
idle = values[3] + (values[4] if len(values) > 4 else 0)
total = sum(values)
if total <= 0:
raise RuntimeError("proc_stat_unavailable")
return idle, total
def _cpu_delta_percent(start: tuple[int, int], end: tuple[int, int]) -> float:
idle_delta = end[0] - start[0]
total_delta = end[1] - start[1]
if total_delta <= 0:
return 0.0
busy_delta = max(0, total_delta - max(0, idle_delta))
return _clamp_percent((busy_delta / total_delta) * 100.0)
def _cpu_percent() -> float:
"""Sample aggregate CPU usage without psutil.
A short local sample avoids storing cross-request state and returns a stable
percentage on the first poll. Unsupported platforms raise a safe error code.
"""
start = _read_proc_stat_cpu()
time.sleep(_CPU_SAMPLE_SECONDS)
end = _read_proc_stat_cpu()
return _cpu_delta_percent(start, end)
def _read_meminfo_kib() -> dict[str, int]:
data: dict[str, int] = {}
with _PROC_MEMINFO.open("r", encoding="utf-8") as handle:
for line in handle:
key, _, rest = line.partition(":")
if not key or not rest:
continue
parts = rest.strip().split()
if not parts:
continue
try:
data[key] = int(parts[0])
except ValueError:
continue
return data
def _memory_usage() -> dict[str, int | float]:
meminfo = _read_meminfo_kib()
total = int(meminfo.get("MemTotal") or 0) * 1024
if total <= 0:
raise RuntimeError("meminfo_unavailable")
available_kib = meminfo.get("MemAvailable")
if available_kib is None:
available_kib = (
meminfo.get("MemFree", 0)
+ meminfo.get("Buffers", 0)
+ meminfo.get("Cached", 0)
+ meminfo.get("SReclaimable", 0)
- meminfo.get("Shmem", 0)
)
available = max(0, int(available_kib) * 1024)
used = max(0, min(total, total - available))
return {
"used_bytes": used,
"total_bytes": total,
"percent": _clamp_percent((used / total) * 100.0),
}
def _disk_usage() -> dict[str, int | float]:
usage = shutil.disk_usage("/")
total = int(usage.total)
if total <= 0:
raise RuntimeError("disk_unavailable")
used = int(usage.used)
return {
"used_bytes": used,
"total_bytes": total,
"percent": _clamp_percent((used / total) * 100.0),
}
def _safe_error(metric: str, exc: Exception) -> dict[str, str]:
# Keep this intentionally coarse. Exception messages can contain local paths
# on unusual platforms; the browser only needs a safe unavailable reason.
return {"metric": metric, "code": type(exc).__name__}
def build_system_health_payload() -> dict[str, Any]:
metrics: dict[str, Any] = {"cpu": None, "memory": None, "disk": None}
errors: list[dict[str, str]] = []
collectors = {
"cpu": _cpu_percent,
"memory": _memory_usage,
"disk": _disk_usage,
}
for name, collect in collectors.items():
try:
value = collect()
if name == "cpu":
metrics[name] = {"percent": _clamp_percent(value)}
else:
metrics[name] = {
"used_bytes": max(0, int(value["used_bytes"])),
"total_bytes": max(0, int(value["total_bytes"])),
"percent": _clamp_percent(value["percent"]),
}
except Exception as exc:
errors.append(_safe_error(name, exc))
available = any(metrics[name] is not None for name in metrics)
status = "ok" if available and not errors else "partial" if available else "unavailable"
return {
"status": status,
"available": available,
"checked_at": _checked_at(),
"cpu": metrics["cpu"],
"memory": metrics["memory"],
"disk": metrics["disk"],
"errors": errors,
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

View File

@@ -79,6 +79,27 @@
</div>
<div class="app-titlebar-spacer" aria-hidden="true"></div>
</header>
<section class="system-health-panel loading" id="systemHealthPanel" aria-label="Host resource health" aria-live="polite">
<div class="system-health-title">
<span class="system-health-dot" aria-hidden="true"></span>
<span>VPS health</span>
<span class="system-health-status" id="systemHealthStatus">Loading…</span>
</div>
<div class="system-health-metrics">
<div class="system-health-metric" data-system-health-metric="cpu">
<div class="system-health-label"><span>CPU</span><span class="system-health-value" data-system-health-value></span></div>
<div class="system-health-bar" role="progressbar" aria-label="CPU usage" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0"><div class="system-health-bar-fill"></div></div>
</div>
<div class="system-health-metric" data-system-health-metric="memory">
<div class="system-health-label"><span>RAM</span><span class="system-health-value" data-system-health-value></span></div>
<div class="system-health-bar" role="progressbar" aria-label="RAM usage" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0"><div class="system-health-bar-fill"></div></div>
</div>
<div class="system-health-metric" data-system-health-metric="disk">
<div class="system-health-label"><span>Disk</span><span class="system-health-value" data-system-health-value></span></div>
<div class="system-health-bar" role="progressbar" aria-label="Disk usage" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0"><div class="system-health-bar-fill"></div></div>
</div>
</div>
</section>
<div class="layout">
<nav class="rail" aria-label="Primary navigation">
<button class="rail-btn nav-tab active" data-panel="chat" onclick="switchPanel('chat')" title="Chat" data-i18n-title="tab_chat" aria-label="Chat"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg></button>

View File

@@ -293,6 +293,18 @@
.layout{display:flex;width:100%;flex:1 1 auto;min-height:0;}
.app-titlebar{display:flex;align-items:center;justify-content:center;height:38px;flex-shrink:0;background:var(--sidebar);border-bottom:1px solid var(--border);padding:0 12px;padding-top:var(--app-titlebar-safe-top);padding-left:max(12px,env(safe-area-inset-left,0));padding-right:max(12px,env(safe-area-inset-right,0));box-sizing:content-box;font-size:12px;color:var(--muted);user-select:none;-webkit-app-region:drag;position:relative;z-index:20;}
.app-titlebar-inner{display:flex;align-items:center;gap:8px;min-width:0;max-width:100%;justify-content:center;}
.system-health-panel{display:flex;align-items:center;gap:14px;min-height:38px;padding:6px 14px;padding-left:max(14px,env(safe-area-inset-left,0));padding-right:max(14px,env(safe-area-inset-right,0));border-bottom:1px solid var(--border);background:color-mix(in srgb,var(--sidebar) 92%,var(--bg));color:var(--muted);box-sizing:border-box;flex-shrink:0;}
.system-health-panel.unavailable{display:none;}
.system-health-title{display:flex;align-items:center;gap:7px;font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;white-space:nowrap;color:var(--muted);}
.system-health-dot{width:7px;height:7px;border-radius:999px;background:var(--accent);box-shadow:0 0 0 3px var(--accent-bg);opacity:.88;}
.system-health-panel.loading .system-health-dot{background:var(--muted);box-shadow:none;opacity:.55;}
.system-health-status{font-size:10px;font-weight:600;letter-spacing:0;text-transform:none;color:var(--muted);opacity:.8;}
.system-health-metrics{display:grid;grid-template-columns:repeat(3,minmax(90px,1fr));gap:10px;flex:1;min-width:0;}
.system-health-metric{min-width:0;display:flex;flex-direction:column;gap:3px;}
.system-health-label{display:flex;align-items:center;justify-content:space-between;gap:8px;font-size:11px;line-height:1;color:var(--muted);}
.system-health-value{font-variant-numeric:tabular-nums;color:var(--text);font-weight:650;}
.system-health-bar{height:5px;overflow:hidden;border-radius:999px;background:color-mix(in srgb,var(--border) 70%,transparent);border:1px solid color-mix(in srgb,var(--border) 75%,transparent);}
.system-health-bar-fill{height:100%;width:0%;border-radius:inherit;background:linear-gradient(90deg,var(--accent),var(--accent-hover));transition:width .25s ease;}
.app-titlebar-icon{display:inline-flex;align-items:center;color:var(--accent);}
.app-titlebar-title{font-size:12px;font-weight:600;color:var(--text);letter-spacing:-.01em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:60vw;}
.app-titlebar-sub{font-size:10px;color:var(--muted);background:var(--hover-bg);padding:2px 7px;border-radius:4px;font-family:'SF Mono',ui-monospace,monospace;white-space:nowrap;flex-shrink:0;}
@@ -1280,6 +1292,11 @@
.app-titlebar{justify-content:space-between;}
.app-titlebar-hamburger,.app-titlebar-spacer{display:flex;}
.app-titlebar-inner{flex:1 1 auto;}
.system-health-panel{align-items:stretch;gap:6px;min-height:0;padding:6px 10px;flex-direction:column;}
.system-health-title{font-size:10px;justify-content:space-between;}
.system-health-metrics{grid-template-columns:repeat(3,minmax(0,1fr));gap:6px;}
.system-health-label{font-size:10px;gap:4px;}
.system-health-bar{height:4px;}
/* Overlay backdrop */
.mobile-overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,.5);
z-index:199;-webkit-tap-highlight-color:transparent;}

View File

@@ -3091,6 +3091,94 @@ function dismissReconnect() {
clearInflight();
}
// ── Live host resource health panel (#693) ──
const SYSTEM_HEALTH_INTERVAL_MS=5000;
let _systemHealthTimer=null;
function _systemHealthPercent(metric){
const percent=Number(metric&&metric.percent);
if(!Number.isFinite(percent)) return null;
return Math.max(0,Math.min(100,Math.round(percent*10)/10));
}
function _formatSystemHealthPercent(percent){
if(percent == null) return '—';
return `${percent.toFixed(percent%1?1:0)}%`;
}
function _formatSystemHealthBytes(metric){
if(!metric||!metric.used_bytes||!metric.total_bytes) return '';
const units=['B','KB','MB','GB','TB'];
const fmt=(bytes)=>{
let value=Number(bytes)||0, idx=0;
while(value>=1024&&idx<units.length-1){value/=1024;idx++;}
return `${value.toFixed(value>=10||idx===0?0:1)} ${units[idx]}`;
};
return `${fmt(metric.used_bytes)} / ${fmt(metric.total_bytes)}`;
}
function _updateSystemHealthMetric(name,metric){
const row=document.querySelector(`[data-system-health-metric="${name}"]`);
if(!row) return;
const rawPercent=_systemHealthPercent(metric);
const percent=rawPercent == null ? 0 : rawPercent;
const label=row.querySelector('[data-system-health-value]');
const bar=row.querySelector('.system-health-bar');
const fill=row.querySelector('.system-health-bar-fill');
const text=_formatSystemHealthPercent(rawPercent);
if(label){
label.textContent=text;
const bytes=(name==='memory'||name==='disk')?_formatSystemHealthBytes(metric):'';
label.title=bytes||text;
}
if(bar) bar.setAttribute('aria-valuenow',String(percent));
if(fill) fill.style.width=`${percent}%`;
}
function setSystemHealthUnavailable(message){
const panel=$('systemHealthPanel');
const status=$('systemHealthStatus');
if(!panel) return;
panel.classList.remove('loading');
panel.classList.add('unavailable');
if(status) status.textContent=message||'Unavailable';
['cpu','memory','disk'].forEach(name=>_updateSystemHealthMetric(name,null));
}
function renderSystemHealth(payload){
const panel=$('systemHealthPanel');
const status=$('systemHealthStatus');
if(!panel) return;
if(!payload||payload.available===false){
setSystemHealthUnavailable('Unavailable');
return;
}
panel.classList.remove('loading','unavailable');
if(status) status.textContent=payload.status==='partial'?'Partial':'Live';
_updateSystemHealthMetric('cpu',payload.cpu);
_updateSystemHealthMetric('memory',payload.memory);
_updateSystemHealthMetric('disk',payload.disk);
}
async function pollSystemHealth(){
if(document.visibilityState !== 'visible') return;
try{
const payload=await api('/api/system/health');
renderSystemHealth(payload);
}catch(_){
setSystemHealthUnavailable('Unavailable');
}
}
function startSystemHealthMonitor(){
if(document.visibilityState !== 'visible') return;
if(_systemHealthTimer) return;
void pollSystemHealth();
_systemHealthTimer=setInterval(pollSystemHealth,SYSTEM_HEALTH_INTERVAL_MS);
}
function stopSystemHealthMonitor(){
if(_systemHealthTimer){clearInterval(_systemHealthTimer);_systemHealthTimer=null;}
}
function _syncSystemHealthMonitorVisibility(){
if(document.visibilityState === 'visible') startSystemHealthMonitor();
else stopSystemHealthMonitor();
}
document.addEventListener('visibilitychange',_syncSystemHealthMonitorVisibility);
if(document.readyState==='loading') document.addEventListener('DOMContentLoaded',startSystemHealthMonitor);
else startSystemHealthMonitor();
// ── Hermes agent/gateway heartbeat alert (#716) ──
const AGENT_HEALTH_INTERVAL_MS=30000;
const AGENT_HEALTH_DISMISSED_KEY='agent-health-dismissed';

View File

@@ -0,0 +1,173 @@
"""Regression coverage for #693 live VPS host resource health panel."""
from __future__ import annotations
import json
import pathlib
from types import SimpleNamespace
from urllib.parse import urlparse
REPO_ROOT = pathlib.Path(__file__).parent.parent
UI_JS = (REPO_ROOT / "static" / "ui.js").read_text(encoding="utf-8")
INDEX_HTML = (REPO_ROOT / "static" / "index.html").read_text(encoding="utf-8")
STYLE_CSS = (REPO_ROOT / "static" / "style.css").read_text(encoding="utf-8")
ROUTES_PY = (REPO_ROOT / "api" / "routes.py").read_text(encoding="utf-8")
AUTH_PY = (REPO_ROOT / "api" / "auth.py").read_text(encoding="utf-8")
class _FakeHandler:
def __init__(self):
self.status = None
self.sent_headers = []
self.body = bytearray()
self.wfile = self
self.headers = {}
def send_response(self, status):
self.status = status
def send_header(self, name, value):
self.sent_headers.append((name, value))
def end_headers(self):
pass
def write(self, data):
self.body.extend(data)
def json_body(self):
return json.loads(bytes(self.body).decode("utf-8"))
def test_system_health_payload_normalizes_safe_aggregate_metrics(monkeypatch):
from api import system_health
monkeypatch.setattr(system_health, "_cpu_percent", lambda: 17.345)
monkeypatch.setattr(
system_health,
"_memory_usage",
lambda: {"used_bytes": 4_000, "total_bytes": 10_000, "percent": 40.0},
)
monkeypatch.setattr(
system_health,
"_disk_usage",
lambda: {"used_bytes": 55_500, "total_bytes": 100_000, "percent": 55.5},
)
payload = system_health.build_system_health_payload()
assert payload["status"] == "ok"
assert payload["available"] is True
assert payload["cpu"] == {"percent": 17.3}
assert payload["memory"] == {"used_bytes": 4000, "total_bytes": 10000, "percent": 40.0}
assert payload["disk"] == {"used_bytes": 55500, "total_bytes": 100000, "percent": 55.5}
assert payload["checked_at"]
rendered = repr(payload)
for private_fragment in ("/home/", "/Users/", "mount", "path", "argv", "command", "env", "token"):
assert private_fragment not in rendered
def test_system_health_payload_partial_and_unavailable_are_graceful(monkeypatch):
from api import system_health
def boom():
raise RuntimeError("private /home/user/path should not leak")
monkeypatch.setattr(system_health, "_cpu_percent", boom)
monkeypatch.setattr(system_health, "_memory_usage", boom)
monkeypatch.setattr(
system_health,
"_disk_usage",
lambda: {"used_bytes": 1, "total_bytes": 4, "percent": 25.0},
)
partial = system_health.build_system_health_payload()
assert partial["status"] == "partial"
assert partial["available"] is True
assert partial["disk"]["percent"] == 25.0
assert partial["cpu"] is None
assert partial["memory"] is None
assert {e["metric"] for e in partial["errors"]} == {"cpu", "memory"}
assert "/home/user" not in repr(partial)
monkeypatch.setattr(system_health, "_disk_usage", boom)
unavailable = system_health.build_system_health_payload()
assert unavailable["status"] == "unavailable"
assert unavailable["available"] is False
assert unavailable["cpu"] is None
assert unavailable["memory"] is None
assert unavailable["disk"] is None
assert "/home/user" not in repr(unavailable)
def test_system_health_route_registered_and_auth_gated(monkeypatch):
assert 'parsed.path == "/api/system/health"' in ROUTES_PY
assert "build_system_health_payload()" in ROUTES_PY
assert '"/api/system/health"' not in AUTH_PY, "system metrics must not be public"
monkeypatch.setenv("HERMES_WEBUI_PASSWORD", "test-password")
from api.auth import check_auth
handler = _FakeHandler()
assert check_auth(handler, SimpleNamespace(path="/api/system/health", query="")) is False
assert handler.status in (302, 401)
def test_system_health_route_returns_only_sanitized_payload(monkeypatch):
from api import routes
monkeypatch.setattr(
routes,
"build_system_health_payload",
lambda: {
"status": "ok",
"available": True,
"checked_at": "2026-05-05T00:00:00+00:00",
"cpu": {"percent": 12.0},
"memory": {"used_bytes": 1, "total_bytes": 2, "percent": 50.0},
"disk": {"used_bytes": 3, "total_bytes": 4, "percent": 75.0},
"errors": [],
},
)
handler = _FakeHandler()
assert routes.handle_get(handler, urlparse("http://example.test/api/system/health")) is True
payload = handler.json_body()
assert payload["cpu"]["percent"] == 12.0
assert set(payload) == {"status", "available", "checked_at", "cpu", "memory", "disk", "errors"}
def test_system_health_panel_markup_and_styles_exist():
assert 'id="systemHealthPanel"' in INDEX_HTML
assert 'aria-label="Host resource health"' in INDEX_HTML
assert 'data-system-health-metric="cpu"' in INDEX_HTML
assert 'data-system-health-metric="memory"' in INDEX_HTML
assert 'data-system-health-metric="disk"' in INDEX_HTML
assert ".system-health-panel" in STYLE_CSS
assert ".system-health-bar-fill" in STYLE_CSS
assert ".system-health-panel.unavailable" in STYLE_CSS
assert "@media(max-width:640px)" in STYLE_CSS and ".system-health-panel" in STYLE_CSS
def test_system_health_frontend_polls_visible_and_renders_progress_labels():
assert "const SYSTEM_HEALTH_INTERVAL_MS=5000" in UI_JS
assert "api('/api/system/health')" in UI_JS
assert "document.visibilityState !== 'visible'" in UI_JS
assert "document.addEventListener('visibilitychange',_syncSystemHealthMonitorVisibility)" in UI_JS
assert "function renderSystemHealth(payload)" in UI_JS
assert "setSystemHealthUnavailable" in UI_JS
assert "data-system-health-metric" in INDEX_HTML
assert "CPU" in INDEX_HTML and "RAM" in INDEX_HTML and "Disk" in INDEX_HTML
assert "aria-valuenow" in UI_JS
assert "style.width=`${percent}%`" in UI_JS
def test_system_health_backend_uses_no_shell_or_private_process_sources():
src = (REPO_ROOT / "api" / "system_health.py").read_text(encoding="utf-8")
assert "import subprocess" not in src
assert "import psutil" not in src
assert "os.environ" not in src
assert "ps aux" not in src
assert "/proc/self/environ" not in src
for private_field in ("argv", "cmdline", "username", "mountpoint"):
assert private_field not in src