Skip to content

Commit 0ea3dfb

Browse files
Merge pull request #1713 from nesquena/stage-302
v0.51.5 — 4-PR full-sweep batch
2 parents cebca47 + b59c697 commit 0ea3dfb

22 files changed

Lines changed: 743 additions & 29 deletions

CHANGELOG.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,46 @@
11
# Hermes Web UI -- Changelog
22

3+
## [v0.51.5] — 2026-05-05 — 4-PR full-sweep batch
4+
5+
### Added
6+
7+
- **PR #1688** by @Michaelyklam — VPS resource health Insights panel (closes #693). New `api/system_health.py` provides a dependency-free Linux/stdlib metrics collector for aggregate CPU (via /proc/stat delta sample), memory (/proc/meminfo), and root disk (shutil.disk_usage). Authenticated `GET /api/system/health` returns sanitized aggregate fields only — no process argv, env, paths, or secrets. The card lives in the Insights tab (NOT always-visible top chrome) per maintainer placement feedback. Polling is gated by `visibilityState` so hidden tabs don't poll, and on macOS/Windows the panel hides itself instead of showing a noisy error. 7 regression tests pin endpoint registration, payload sanitization, Insights placement, and absence from top chrome.
8+
9+
### Fixed
10+
11+
- **PR #1709** by @Michaelyklam — Preserve scroll on stream completion (closes #1690). `_run_background_title_refresh()` and terminal stream handlers were clearing `S.activeStreamId` before the final `renderMessages()` call, while `renderMessages()` chose between `scrollIfPinned()` and `scrollToBottom()` based on stream liveness alone. Result: long stream + user scrolls up to read earlier content + stream finishes → cursor jumped to bottom. Fix adds `_scrollAfterMessageRender(preserveScroll)` helper. When `preserveScroll=true`, calls `scrollIfPinned()` (respects pin state); when false (load/switch path), legacy `scrollToBottom()`. 4 callsites in messages.js terminal-stream paths (`done`, `error`, `cancel`, fallback) pass `{preserveScroll: true}`.
12+
- **PR #1711** by @nesquena-hermes — Hide 'Double-click to rename' tooltip on folders (closes #1710). Workspace file-tree row tooltip said "Double-click to rename" on every entry — including folders. But folder dblclick navigates via `loadDir()`, not rename; rename for folders lives in the right-click context menu. The tooltip was misleading. 4-line fix in `_renderTreeItems()`: gate `nameEl.title = t('double_click_rename')` on `item.type !== 'dir'`. Reported by @Deor in the WebUI Discord testers thread May 5 2026.
13+
- **PR #1712** by @24601 — Guard `localStorage.setItem('hermes-webui-model')` against `QuotaExceededError`. On setups with localStorage near quota, the bare `setItem` call threw an unhandled `DOMException` that broke model selection and prevented the chat UI from loading. Wraps both callsites (boot.js modelSelect.onchange handler, onboarding.js _saveOnboardingDefaults) in `try{...}catch{}` so the error is silently absorbed and the UI falls back to server-side model state on next load. The stored value (a model ID string) is tiny — quota failure is from overall localStorage pressure, not this key.
14+
15+
### Tests
16+
17+
4504 → **4527 passing** (+23 regression tests across the 4 PRs, mostly from #1688's 7-test suite). 0 regressions. Full suite ~130s.
18+
19+
### Pre-release verification
20+
21+
- Stage-302: 4 PRs merged with zero conflicts (each rebased clean against current master). Zero stage-applied edits to any file — every change ships exactly as the contributor wrote it.
22+
- All JS files syntax-clean (`node -c static/{boot,messages,onboarding,panels,ui}.js`).
23+
- All Python files syntax-clean (py_compile on every changed file).
24+
- Live browser walkthrough on port 8789:
25+
- `/api/system/health` returns sanitized JSON with CPU/memory/disk percentages (no /proc paths, no argv leakage)
26+
- System health card renders in Insights with Live badge + 3 progress bars (visual rated 9.5/10 via vision check)
27+
- System health card NOT in top chrome (per nesquena placement feedback)
28+
- Sidebar scroll holds at 400px (carry-over fix from v0.51.2 preserved)
29+
- `_scrollAfterMessageRender` 4-branch behavioral test all correct (preserveScroll respects pin state in all paths)
30+
- Recent-release feature inventory verified: PR #1644 model picker chip, PR #1685 Codex spark group, PR #1684 update banner network detection, PR #1671 quota card endpoint, PR #1676 heartbeat banner default-hidden, PR #1664 LLM Wiki endpoint, PR #1662 Logs nav button (via aria-label), PR #1706 paste-multiple fix
31+
- Opus advisor: SHIP, 6/6 verification clean, 0 MUST-FIX, 0 SHOULD-FIX. Two non-blocking observations:
32+
- `/api/system/health` could use `Cache-Control: no-store` (optional, defensive)
33+
- `}catch{}` in #1712 swallows all errors silently (acceptable for 2-LOC defensive guard)
34+
35+
### Notes on this sweep
36+
37+
- **#1686** (Docker enhance by @binhpt310) was held back. Opus advisor flagged a blocker: the PR's `docker-compose.yml` change (`build context: ..`) and `COPY hermes-agent-desktop/...` Dockerfile additions assume a sibling `hermes-agent-desktop/` directory at clone time, which would break standalone clones. Left open for follow-up.
38+
- **#1712** was force-pushed mid-sweep to a simpler form (drops `console.warn`). v2 adopted; fits in the original `test_provider_mismatch.py` 1100-char window so no test widening needed.
39+
- **#1688** was on the held list (ux + hold labels) but per maintainer call ("Looks much better, thanks! Going to move towards review and merge"), labels removed and PR included in batch. CI was already green on all 3 Python versions.
40+
41+
Closes #693, #1690, #1710.
42+
43+
344
## [v0.51.5] — 2026-05-05 — single-PR hotfix (#1707)
445

546
### Fixed

ROADMAP.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
> Web companion to the Hermes Agent CLI. Same workflows, browser-native.
44
>
5-
> Last updated: v0.51.5 (May 5, 2026) — 4517 tests collected — single-PR hotfix #1707 (workspace filename single-click regression)
5+
> Last updated: v0.51.5 (May 5, 2026) — 4527 tests collected
66
> Test source: `pytest tests/ --collect-only -q`
77
> Per-version detail: see [CHANGELOG.md](./CHANGELOG.md)
88

TESTING.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1836,7 +1836,7 @@ Bridged CLI sessions:
18361836
---
18371837

18381838
*Last updated: v0.51.5, May 5, 2026*
1839-
*Total automated tests collected: 4503*
1839+
*Total automated tests collected: 4527*
18401840
*Regression gate: tests/test_regressions.py*
18411841
*Run: pytest tests/ -v --timeout=60*
18421842
*Source: <repo>/*

api/routes.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -480,6 +480,7 @@ def _clear_live_models_cache() -> None:
480480
_redact_text,
481481
)
482482
from api.agent_health import build_agent_health_payload
483+
from api.system_health import build_system_health_payload
483484

484485

485486
def _clear_stale_stream_state(session) -> bool:
@@ -2491,6 +2492,10 @@ def handle_get(handler, parsed) -> bool:
24912492
if parsed.path == "/api/health/agent":
24922493
return j(handler, build_agent_health_payload())
24932494

2495+
if parsed.path == "/api/system/health":
2496+
j(handler, build_system_health_payload())
2497+
return True
2498+
24942499
if parsed.path == "/api/models":
24952500
return j(handler, get_available_models())
24962501

api/system_health.py

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
"""Safe aggregate host resource metrics for the WebUI VPS panel (#693).
2+
3+
The browser only needs coarse CPU/RAM/disk usage. Keep this module intentionally
4+
small and dependency-free: no process lists, command strings, user identities,
5+
environment variables, or filesystem topology leave the server.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import shutil
11+
import time
12+
from datetime import datetime, timezone
13+
from pathlib import Path
14+
from typing import Any
15+
16+
17+
_PROC_STAT = Path("/proc/stat")
18+
_PROC_MEMINFO = Path("/proc/meminfo")
19+
_CPU_SAMPLE_SECONDS = 0.05
20+
21+
22+
def _checked_at() -> str:
23+
return datetime.now(timezone.utc).isoformat()
24+
25+
26+
def _clamp_percent(value: Any) -> float:
27+
try:
28+
numeric = float(value)
29+
except (TypeError, ValueError):
30+
return 0.0
31+
if numeric < 0:
32+
numeric = 0.0
33+
if numeric > 100:
34+
numeric = 100.0
35+
return round(numeric, 1)
36+
37+
38+
def _read_proc_stat_cpu() -> tuple[int, int]:
39+
"""Return (idle_ticks, total_ticks) from Linux /proc/stat."""
40+
with _PROC_STAT.open("r", encoding="utf-8") as handle:
41+
first = handle.readline().strip().split()
42+
if not first or first[0] != "cpu":
43+
raise RuntimeError("proc_stat_unavailable")
44+
values = [int(part) for part in first[1:]]
45+
if len(values) < 4:
46+
raise RuntimeError("proc_stat_unavailable")
47+
idle = values[3] + (values[4] if len(values) > 4 else 0)
48+
total = sum(values)
49+
if total <= 0:
50+
raise RuntimeError("proc_stat_unavailable")
51+
return idle, total
52+
53+
54+
def _cpu_delta_percent(start: tuple[int, int], end: tuple[int, int]) -> float:
55+
idle_delta = end[0] - start[0]
56+
total_delta = end[1] - start[1]
57+
if total_delta <= 0:
58+
return 0.0
59+
busy_delta = max(0, total_delta - max(0, idle_delta))
60+
return _clamp_percent((busy_delta / total_delta) * 100.0)
61+
62+
63+
def _cpu_percent() -> float:
64+
"""Sample aggregate CPU usage without psutil.
65+
66+
A short local sample avoids storing cross-request state and returns a stable
67+
percentage on the first poll. Unsupported platforms raise a safe error code.
68+
"""
69+
start = _read_proc_stat_cpu()
70+
time.sleep(_CPU_SAMPLE_SECONDS)
71+
end = _read_proc_stat_cpu()
72+
return _cpu_delta_percent(start, end)
73+
74+
75+
def _read_meminfo_kib() -> dict[str, int]:
76+
data: dict[str, int] = {}
77+
with _PROC_MEMINFO.open("r", encoding="utf-8") as handle:
78+
for line in handle:
79+
key, _, rest = line.partition(":")
80+
if not key or not rest:
81+
continue
82+
parts = rest.strip().split()
83+
if not parts:
84+
continue
85+
try:
86+
data[key] = int(parts[0])
87+
except ValueError:
88+
continue
89+
return data
90+
91+
92+
def _memory_usage() -> dict[str, int | float]:
93+
meminfo = _read_meminfo_kib()
94+
total = int(meminfo.get("MemTotal") or 0) * 1024
95+
if total <= 0:
96+
raise RuntimeError("meminfo_unavailable")
97+
available_kib = meminfo.get("MemAvailable")
98+
if available_kib is None:
99+
available_kib = (
100+
meminfo.get("MemFree", 0)
101+
+ meminfo.get("Buffers", 0)
102+
+ meminfo.get("Cached", 0)
103+
+ meminfo.get("SReclaimable", 0)
104+
- meminfo.get("Shmem", 0)
105+
)
106+
available = max(0, int(available_kib) * 1024)
107+
used = max(0, min(total, total - available))
108+
return {
109+
"used_bytes": used,
110+
"total_bytes": total,
111+
"percent": _clamp_percent((used / total) * 100.0),
112+
}
113+
114+
115+
def _disk_usage() -> dict[str, int | float]:
116+
usage = shutil.disk_usage("/")
117+
total = int(usage.total)
118+
if total <= 0:
119+
raise RuntimeError("disk_unavailable")
120+
used = int(usage.used)
121+
return {
122+
"used_bytes": used,
123+
"total_bytes": total,
124+
"percent": _clamp_percent((used / total) * 100.0),
125+
}
126+
127+
128+
def _safe_error(metric: str, exc: Exception) -> dict[str, str]:
129+
# Keep this intentionally coarse. Exception messages can contain local paths
130+
# on unusual platforms; the browser only needs a safe unavailable reason.
131+
return {"metric": metric, "code": type(exc).__name__}
132+
133+
134+
def build_system_health_payload() -> dict[str, Any]:
135+
metrics: dict[str, Any] = {"cpu": None, "memory": None, "disk": None}
136+
errors: list[dict[str, str]] = []
137+
138+
collectors = {
139+
"cpu": _cpu_percent,
140+
"memory": _memory_usage,
141+
"disk": _disk_usage,
142+
}
143+
for name, collect in collectors.items():
144+
try:
145+
value = collect()
146+
if name == "cpu":
147+
metrics[name] = {"percent": _clamp_percent(value)}
148+
else:
149+
metrics[name] = {
150+
"used_bytes": max(0, int(value["used_bytes"])),
151+
"total_bytes": max(0, int(value["total_bytes"])),
152+
"percent": _clamp_percent(value["percent"]),
153+
}
154+
except Exception as exc:
155+
errors.append(_safe_error(name, exc))
156+
157+
available = any(metrics[name] is not None for name in metrics)
158+
status = "ok" if available and not errors else "partial" if available else "unavailable"
159+
return {
160+
"status": status,
161+
"available": available,
162+
"checked_at": _checked_at(),
163+
"cpu": metrics["cpu"],
164+
"memory": metrics["memory"],
165+
"disk": metrics["disk"],
166+
"errors": errors,
167+
}
132 KB
Loading
63.2 KB
Loading
58.5 KB
Loading
143 KB
Loading

static/boot.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -815,7 +815,7 @@ $('modelSelect').onchange=async()=>{
815815
: {model:selectedModel,model_provider:null};
816816
if(typeof closeModelDropdown==='function') closeModelDropdown();
817817
if(typeof _writePersistedModelState==='function') _writePersistedModelState(modelState.model,modelState.model_provider);
818-
else localStorage.setItem('hermes-webui-model', modelState.model);
818+
else try{localStorage.setItem('hermes-webui-model',modelState.model)}catch{}
819819
await api('/api/session/update',{method:'POST',body:JSON.stringify({
820820
session_id:S.session.session_id,
821821
workspace:S.session.workspace,

0 commit comments

Comments
 (0)