mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-26 11:40:26 +00:00
Merge pull request #1713 from nesquena/stage-302
v0.51.5 — 4-PR full-sweep batch
This commit is contained in:
@@ -1,5 +1,46 @@
|
||||
# Hermes Web UI -- Changelog
|
||||
|
||||
## [v0.51.5] — 2026-05-05 — 4-PR full-sweep batch
|
||||
|
||||
### Added
|
||||
|
||||
- **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.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **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}`.
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
### Tests
|
||||
|
||||
4504 → **4527 passing** (+23 regression tests across the 4 PRs, mostly from #1688's 7-test suite). 0 regressions. Full suite ~130s.
|
||||
|
||||
### Pre-release verification
|
||||
|
||||
- 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.
|
||||
- All JS files syntax-clean (`node -c static/{boot,messages,onboarding,panels,ui}.js`).
|
||||
- All Python files syntax-clean (py_compile on every changed file).
|
||||
- Live browser walkthrough on port 8789:
|
||||
- `/api/system/health` returns sanitized JSON with CPU/memory/disk percentages (no /proc paths, no argv leakage)
|
||||
- System health card renders in Insights with Live badge + 3 progress bars (visual rated 9.5/10 via vision check)
|
||||
- System health card NOT in top chrome (per nesquena placement feedback)
|
||||
- Sidebar scroll holds at 400px (carry-over fix from v0.51.2 preserved)
|
||||
- `_scrollAfterMessageRender` 4-branch behavioral test all correct (preserveScroll respects pin state in all paths)
|
||||
- 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
|
||||
- Opus advisor: SHIP, 6/6 verification clean, 0 MUST-FIX, 0 SHOULD-FIX. Two non-blocking observations:
|
||||
- `/api/system/health` could use `Cache-Control: no-store` (optional, defensive)
|
||||
- `}catch{}` in #1712 swallows all errors silently (acceptable for 2-LOC defensive guard)
|
||||
|
||||
### Notes on this sweep
|
||||
|
||||
- **#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.
|
||||
- **#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.
|
||||
- **#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.
|
||||
|
||||
Closes #693, #1690, #1710.
|
||||
|
||||
|
||||
## [v0.51.5] — 2026-05-05 — single-PR hotfix (#1707)
|
||||
|
||||
### Fixed
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
> Web companion to the Hermes Agent CLI. Same workflows, browser-native.
|
||||
>
|
||||
> Last updated: v0.51.5 (May 5, 2026) — 4517 tests collected — single-PR hotfix #1707 (workspace filename single-click regression)
|
||||
> Last updated: v0.51.5 (May 5, 2026) — 4527 tests collected
|
||||
> Test source: `pytest tests/ --collect-only -q`
|
||||
> Per-version detail: see [CHANGELOG.md](./CHANGELOG.md)
|
||||
|
||||
|
||||
+1
-1
@@ -1836,7 +1836,7 @@ Bridged CLI sessions:
|
||||
---
|
||||
|
||||
*Last updated: v0.51.5, May 5, 2026*
|
||||
*Total automated tests collected: 4503*
|
||||
*Total automated tests collected: 4527*
|
||||
*Regression gate: tests/test_regressions.py*
|
||||
*Run: pytest tests/ -v --timeout=60*
|
||||
*Source: <repo>/*
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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: 132 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 63 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 58 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 143 KiB |
+1
-1
@@ -815,7 +815,7 @@ $('modelSelect').onchange=async()=>{
|
||||
: {model:selectedModel,model_provider:null};
|
||||
if(typeof closeModelDropdown==='function') closeModelDropdown();
|
||||
if(typeof _writePersistedModelState==='function') _writePersistedModelState(modelState.model,modelState.model_provider);
|
||||
else localStorage.setItem('hermes-webui-model', modelState.model);
|
||||
else try{localStorage.setItem('hermes-webui-model',modelState.model)}catch{}
|
||||
await api('/api/session/update',{method:'POST',body:JSON.stringify({
|
||||
session_id:S.session.session_id,
|
||||
workspace:S.session.workspace,
|
||||
|
||||
+8
-8
@@ -932,7 +932,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
// No-reply guard (#373): if agent returned nothing, show inline error
|
||||
if(!S.messages.some(m=>m.role==='assistant'&&String(m.content||'').trim())&&!assistantText){removeThinking();S.messages.push({role:'assistant',content:'**No response received.** Check your API key and model selection.'});}
|
||||
if(isSessionViewed) _markSessionViewed(completedSid, completedSession.message_count ?? S.messages.length);
|
||||
syncTopbar();renderMessages();loadDir('.');
|
||||
syncTopbar();renderMessages({preserveScroll:true});loadDir('.');
|
||||
// TTS auto-read: speak the last assistant response if enabled (#499)
|
||||
if(typeof autoReadLastAssistant==='function') setTimeout(()=>autoReadLastAssistant(), 300);
|
||||
}
|
||||
@@ -1038,7 +1038,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
S.messages.push({role:'assistant',content:'**Error:** An error occurred. Check server logs.'});
|
||||
}
|
||||
_markSessionViewed(activeSid, S.messages.length);
|
||||
renderMessages();
|
||||
renderMessages({preserveScroll:true});
|
||||
}else if(typeof trackBackgroundError==='function'){
|
||||
const _errTitle=(typeof _allSessions!=='undefined'&&_allSessions.find(s=>s.session_id===activeSid)||{}).title||null;
|
||||
try{const d=JSON.parse(e.data);trackBackgroundError(activeSid,_errTitle,d.message||'Error');}
|
||||
@@ -1113,13 +1113,13 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
S.messages=(data.session.messages||[]).filter(m=>m&&m.role);
|
||||
clearLiveToolCards();if(!assistantText)removeThinking();
|
||||
_markSessionViewed(activeSid, data.session.message_count ?? S.messages.length);
|
||||
renderMessages();
|
||||
renderMessages({preserveScroll:true});
|
||||
}
|
||||
}catch(_){
|
||||
// Fallback to local cancel message if API fails
|
||||
if(S.session&&S.session.session_id===activeSid){
|
||||
clearLiveToolCards();if(!assistantText)removeThinking();
|
||||
S.messages.push({role:'assistant',content:'*Task cancelled.*'});renderMessages();
|
||||
S.messages.push({role:'assistant',content:'*Task cancelled.*'});renderMessages({preserveScroll:true});
|
||||
_markSessionViewed(activeSid, S.messages.length);
|
||||
}
|
||||
}
|
||||
@@ -1169,7 +1169,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
S.toolCalls=[];
|
||||
}
|
||||
if(isSessionViewed) _markSessionViewed(completedSid, session.message_count ?? S.messages.length);
|
||||
syncTopbar();renderMessages();
|
||||
syncTopbar();renderMessages({preserveScroll:true});
|
||||
}
|
||||
_queueDrainSid=activeSid;renderSessionList();setBusy(false);setComposerStatus('');
|
||||
return true;
|
||||
@@ -1192,7 +1192,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
if(S.session&&S.session.session_id===activeSid){
|
||||
S.activeStreamId=null;
|
||||
clearLiveToolCards();if(!assistantText)removeThinking();
|
||||
S.messages.push({role:'assistant',content:'**Error:** Connection lost'});renderMessages();
|
||||
S.messages.push({role:'assistant',content:'**Error:** Connection lost'});renderMessages({preserveScroll:true});
|
||||
_markSessionViewed(activeSid, S.messages.length);
|
||||
}else{
|
||||
if(typeof trackBackgroundError==='function'){
|
||||
@@ -1223,7 +1223,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
removeThinking();
|
||||
_queueDrainSid=activeSid;setBusy(false);
|
||||
setComposerStatus('');
|
||||
renderMessages();
|
||||
renderMessages({preserveScroll:true});
|
||||
renderSessionList();
|
||||
}
|
||||
return;
|
||||
@@ -1980,7 +1980,7 @@ function startBackgroundPolling(parentSid, taskId, prompt){
|
||||
delete _bgPollTimers[taskId];
|
||||
const msg={role:'assistant',content:`**${t('bg_label')}** ${prompt.slice(0,80)}\n\n${res.answer||t('bg_no_answer')}`,'_background':true,_ts:Date.now()/1000};
|
||||
S.messages.push(msg);
|
||||
renderMessages();
|
||||
renderMessages({preserveScroll:true});
|
||||
showToast(t('bg_complete'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -465,7 +465,7 @@ async function _saveOnboardingDefaults(){
|
||||
if(ONBOARDING.status){
|
||||
ONBOARDING.status.settings={...(ONBOARDING.status.settings||{}),password_enabled:!!saved.auth_enabled};
|
||||
}
|
||||
localStorage.setItem('hermes-webui-model',model);
|
||||
try{localStorage.setItem('hermes-webui-model',model)}catch{}
|
||||
if($('modelSelect')) _applyModelToDropdown(model,$('modelSelect'));
|
||||
}
|
||||
|
||||
|
||||
@@ -215,6 +215,7 @@ async function switchPanel(name, opts = {}) {
|
||||
if (nextPanel === 'insights') await loadInsights();
|
||||
if (nextPanel === 'logs') await loadLogs();
|
||||
_syncLogsAutoRefresh();
|
||||
if (typeof _syncSystemHealthMonitorVisibility === 'function') _syncSystemHealthMonitorVisibility();
|
||||
if (nextPanel === 'settings') {
|
||||
switchSettingsSection(_currentSettingsSection);
|
||||
loadSettingsPanel();
|
||||
@@ -2118,6 +2119,8 @@ async function loadInsights(animate) {
|
||||
api('/api/wiki/status').catch(err => ({status:'error', error: err.message || String(err)})),
|
||||
]);
|
||||
_renderInsights(data, box, wikiStatus);
|
||||
if (typeof _syncSystemHealthMonitorVisibility === 'function') _syncSystemHealthMonitorVisibility();
|
||||
if (typeof pollSystemHealth === 'function') void pollSystemHealth();
|
||||
} catch(e) {
|
||||
box.innerHTML = `<div style="color:var(--accent);font-size:12px">${esc(t('error_prefix') + e.message)}</div>`;
|
||||
} finally {
|
||||
@@ -2134,6 +2137,34 @@ function _formatLlmWikiTimestamp(value) {
|
||||
catch (_) { return String(value); }
|
||||
}
|
||||
|
||||
function _renderSystemHealthPanel() {
|
||||
return `
|
||||
<section class="insights-card system-health-panel loading" id="systemHealthPanel" aria-label="Host resource health" aria-live="polite">
|
||||
<div class="system-health-head">
|
||||
<div>
|
||||
<div class="insights-card-title">System health</div>
|
||||
<div class="system-health-sub">Current VPS resource usage</div>
|
||||
</div>
|
||||
<span class="system-health-status" id="systemHealthStatus"><span class="system-health-dot" aria-hidden="true"></span>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>
|
||||
<div class="system-health-foot">Live snapshot only; historical resource charts can build on this surface later.</div>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
function _renderLlmWikiStatus(d) {
|
||||
const status = d || {status:'error'};
|
||||
const isReady = status.available && status.status === 'ready';
|
||||
@@ -2279,6 +2310,7 @@ function _renderInsights(d, box, wikiStatus) {
|
||||
</div>`;
|
||||
|
||||
box.innerHTML = `
|
||||
${_renderSystemHealthPanel()}
|
||||
${_renderLlmWikiStatus(wikiStatus)}
|
||||
<div class="insights-grid">
|
||||
${overviewCards.map(c => `<div class="insights-stat"><div class="insights-stat-icon">${c.icon}</div><div class="insights-stat-info"><div class="insights-stat-value">${c.value}</div><div class="insights-stat-label">${esc(c.label)}</div></div></div>`).join('')}
|
||||
|
||||
@@ -293,6 +293,20 @@
|
||||
.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.insights-card{display:flex;flex-direction:column;gap:12px;color:var(--muted);}
|
||||
.system-health-panel.unavailable{display:none;}
|
||||
.system-health-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;}
|
||||
.system-health-sub{font-size:11px;color:var(--muted);margin-top:-4px;}
|
||||
.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{display:inline-flex;align-items:center;gap:7px;border-radius:999px;padding:3px 8px;font-size:11px;font-weight:700;border:1px solid var(--border);color:var(--muted);background:var(--surface);white-space:nowrap;}
|
||||
.system-health-metrics{display:grid;grid-template-columns:repeat(3,minmax(120px,1fr));gap:10px;min-width:0;}
|
||||
.system-health-metric{min-width:0;display:flex;flex-direction:column;gap:5px;padding:10px 11px;border:1px solid var(--border);border-radius:8px;background:var(--surface);}
|
||||
.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;}
|
||||
.system-health-foot{font-size:11px;color:var(--muted);line-height:1.45;opacity:.82;}
|
||||
.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 +1294,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.insights-card{gap:10px;padding:12px;}
|
||||
.system-health-head{align-items:flex-start;}
|
||||
.system-health-metrics{grid-template-columns:1fr;gap:8px;}
|
||||
.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;}
|
||||
|
||||
+118
-8
@@ -3091,6 +3091,100 @@ 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;
|
||||
if(!_systemHealthPanelIsVisible()) return;
|
||||
try{
|
||||
const payload=await api('/api/system/health');
|
||||
renderSystemHealth(payload);
|
||||
}catch(_){
|
||||
setSystemHealthUnavailable('Unavailable');
|
||||
}
|
||||
}
|
||||
function _systemHealthPanelIsVisible(){
|
||||
return document.visibilityState === 'visible' &&
|
||||
!!document.querySelector('main.main.showing-insights') &&
|
||||
!!$('systemHealthPanel');
|
||||
}
|
||||
function startSystemHealthMonitor(){
|
||||
if(!_systemHealthPanelIsVisible()) return;
|
||||
if(_systemHealthTimer) return;
|
||||
void pollSystemHealth();
|
||||
_systemHealthTimer=setInterval(pollSystemHealth,SYSTEM_HEALTH_INTERVAL_MS);
|
||||
}
|
||||
function stopSystemHealthMonitor(){
|
||||
if(_systemHealthTimer){clearInterval(_systemHealthTimer);_systemHealthTimer=null;}
|
||||
}
|
||||
function _syncSystemHealthMonitorVisibility(){
|
||||
if(_systemHealthPanelIsVisible()) 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';
|
||||
@@ -4014,7 +4108,23 @@ function clearMessageRenderCache(){
|
||||
_sessionHtmlCacheSid=null;
|
||||
}
|
||||
|
||||
function renderMessages(){
|
||||
function _scrollAfterMessageRender(preserveScroll){
|
||||
// Terminal stream renders can happen after S.activeStreamId is cleared.
|
||||
// In that case, preserveScroll asks the normal pin-state helper to decide:
|
||||
// pinned users stay at bottom; users who manually scrolled up stay put.
|
||||
if(preserveScroll){
|
||||
scrollIfPinned();
|
||||
return;
|
||||
}
|
||||
if(S.activeStreamId){
|
||||
scrollIfPinned();
|
||||
return;
|
||||
}
|
||||
scrollToBottom();
|
||||
}
|
||||
|
||||
function renderMessages(options){
|
||||
const preserveScroll=!!(options&&options.preserveScroll);
|
||||
const inner=$('msgInner');
|
||||
const sid=S.session?S.session.session_id:null;
|
||||
const msgCount=S.messages.length;
|
||||
@@ -4039,7 +4149,7 @@ function renderMessages(){
|
||||
inner.innerHTML=cached.html;
|
||||
_sessionHtmlCacheSid=sid;
|
||||
_wireMessageWindowLoadEarlierButton();
|
||||
if(S.activeStreamId){scrollIfPinned();}else{scrollToBottom();}
|
||||
_scrollAfterMessageRender(preserveScroll);
|
||||
requestAnimationFrame(()=>{highlightCode();addCopyButtons();loadDiffInline();loadCsvInline();loadExcalidrawInline();loadPdfInline();loadHtmlInline();renderMermaidBlocks();renderKatexBlocks();});
|
||||
requestAnimationFrame(()=>{highlightCode();addCopyButtons();initTreeViews();loadPdfInline();loadHtmlInline();renderMermaidBlocks();renderKatexBlocks();});
|
||||
if(typeof _initMediaPlaybackObserver==='function') _initMediaPlaybackObserver();
|
||||
@@ -4574,11 +4684,7 @@ function renderMessages(){
|
||||
// Only force-scroll when not actively streaming — mid-stream re-renders
|
||||
// (tool completion, session switch) must not override the user's scroll position.
|
||||
// scrollIfPinned() respects _scrollPinned, so it's a no-op if user scrolled up.
|
||||
if(S.activeStreamId){
|
||||
scrollIfPinned();
|
||||
} else {
|
||||
scrollToBottom();
|
||||
}
|
||||
_scrollAfterMessageRender(preserveScroll);
|
||||
// Apply syntax highlighting after DOM is built
|
||||
requestAnimationFrame(()=>{highlightCode();addCopyButtons();loadDiffInline();loadCsvInline();loadExcalidrawInline();loadPdfInline();loadHtmlInline();renderMermaidBlocks();renderKatexBlocks();});
|
||||
requestAnimationFrame(()=>{highlightCode();addCopyButtons();initTreeViews();loadPdfInline();loadHtmlInline();renderMermaidBlocks();renderKatexBlocks();});
|
||||
@@ -5701,7 +5807,11 @@ function _renderTreeItems(container, entries, depth){
|
||||
|
||||
// Name
|
||||
const nameEl=document.createElement('span');
|
||||
nameEl.className='file-name';nameEl.textContent=item.name;nameEl.title=t('double_click_rename');
|
||||
nameEl.className='file-name';nameEl.textContent=item.name;
|
||||
// Tooltip only on FILES — dblclick renames them. On directories, dblclick
|
||||
// navigates into the folder; rename lives in the right-click context menu
|
||||
// (the "Double-click to rename" hint here would be misleading). #1710.
|
||||
if(item.type!=='dir')nameEl.title=t('double_click_rename');
|
||||
// Single-click opens (file) or expand-toggles (dir) but is debounced 300ms so a
|
||||
// double-click can cancel it and trigger rename instead. Without the debounce, the
|
||||
// click bubbles to el.onclick before dblclick can fire — that's #1698. Without the
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Tests for #1710 — file-tree tooltip says "Double-click to rename" on folders too,
|
||||
but folders don't rename on double-click; they navigate via loadDir(). The tooltip
|
||||
is therefore misleading on directory rows.
|
||||
|
||||
Fix: gate the tooltip on `item.type !== 'dir'` so it appears only on files.
|
||||
Folder rename is still reachable via the right-click context menu.
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
UI_JS_PATH = REPO_ROOT / "static" / "ui.js"
|
||||
|
||||
|
||||
def _read_ui_js() -> str:
|
||||
with open(UI_JS_PATH, encoding="utf-8") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
def _name_block() -> str:
|
||||
"""Source slice covering the file-tree row's name span construction."""
|
||||
src = _read_ui_js()
|
||||
start = src.find("// Name\n const nameEl=document.createElement('span');")
|
||||
assert start >= 0, "name span construction marker not found in static/ui.js"
|
||||
end = src.find("el.appendChild(nameEl);", start)
|
||||
assert end >= 0, "el.appendChild(nameEl) not found after name span"
|
||||
return src[start:end]
|
||||
|
||||
|
||||
class TestFolderTooltipGated:
|
||||
"""The 'Double-click to rename' tooltip must only attach to files, not dirs."""
|
||||
|
||||
def test_tooltip_assignment_is_guarded_by_item_type(self):
|
||||
block = _name_block()
|
||||
# We expect the tooltip line to be wrapped in an `if(item.type!=='dir')` guard.
|
||||
# The pre-fix shape was `nameEl.title=t('double_click_rename');` unconditionally.
|
||||
# Find every line that assigns nameEl.title and confirm at least one is gated.
|
||||
gated = "if(item.type!=='dir')nameEl.title=t('double_click_rename')"
|
||||
unguarded = " nameEl.className='file-name';nameEl.textContent=item.name;nameEl.title=t('double_click_rename');"
|
||||
assert gated in block, (
|
||||
"tooltip assignment must be guarded by `if(item.type!=='dir')` so directories "
|
||||
"do not show the misleading 'Double-click to rename' hint (#1701)"
|
||||
)
|
||||
assert unguarded not in block, (
|
||||
"the pre-fix unguarded tooltip assignment is still present — folders will "
|
||||
"still show the misleading hint"
|
||||
)
|
||||
|
||||
def test_dir_dblclick_still_navigates_not_renames(self):
|
||||
"""Sanity: directory dblclick path is unchanged — must still call loadDir()."""
|
||||
block = _name_block()
|
||||
assert "if(item.type==='dir'){loadDir(item.path);return;}" in block, (
|
||||
"directory dblclick must still navigate (call loadDir); the rename-only "
|
||||
"tooltip gating depends on this contract being unchanged"
|
||||
)
|
||||
|
||||
def test_files_still_get_tooltip(self):
|
||||
"""Sanity: the tooltip text is still defined for files via the i18n key."""
|
||||
block = _name_block()
|
||||
assert "t('double_click_rename')" in block, (
|
||||
"tooltip i18n key must still be referenced — the gate hides it for dirs, "
|
||||
"not for files"
|
||||
)
|
||||
|
||||
def test_i18n_key_still_defined_in_all_locales(self):
|
||||
"""The i18n key must remain defined in every locale block in static/i18n.js."""
|
||||
i18n = (REPO_ROOT / "static" / "i18n.js").read_text(encoding="utf-8")
|
||||
# i18n.js has 9 locale blocks with the same key. Lock that the key still exists
|
||||
# at least 5 times (en, plus a quorum of locales) — exact count is i18n maintenance.
|
||||
count = i18n.count("double_click_rename:")
|
||||
assert count >= 5, (
|
||||
f"i18n key 'double_click_rename' should be defined in multiple locales; "
|
||||
f"found {count} occurrences — did this PR accidentally drop translations?"
|
||||
)
|
||||
@@ -0,0 +1,75 @@
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
UI_JS = (REPO / "static" / "ui.js").read_text(encoding="utf-8")
|
||||
MESSAGES_JS = (REPO / "static" / "messages.js").read_text(encoding="utf-8")
|
||||
SESSIONS_JS = (REPO / "static" / "sessions.js").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _function_body(src: str, name: str) -> str:
|
||||
start = src.index(f"function {name}")
|
||||
brace = src.index("{", start)
|
||||
depth = 0
|
||||
for i in range(brace, len(src)):
|
||||
if src[i] == "{":
|
||||
depth += 1
|
||||
elif src[i] == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return src[start : i + 1]
|
||||
raise AssertionError(f"function {name} body not found")
|
||||
|
||||
|
||||
def _event_listener_body(src: str, event_name: str) -> str:
|
||||
needle = f"source.addEventListener('{event_name}'"
|
||||
start = src.index(needle)
|
||||
brace = src.index("{", start)
|
||||
depth = 0
|
||||
for i in range(brace, len(src)):
|
||||
if src[i] == "{":
|
||||
depth += 1
|
||||
elif src[i] == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return src[start : i + 1]
|
||||
raise AssertionError(f"event listener {event_name!r} body not found")
|
||||
|
||||
|
||||
def test_terminal_done_render_preserves_manual_scroll_after_active_stream_is_cleared():
|
||||
done_block = _event_listener_body(MESSAGES_JS, "done")
|
||||
|
||||
clear_idx = done_block.index("S.activeStreamId=null")
|
||||
render_idx = done_block.index("renderMessages({preserveScroll:true})")
|
||||
|
||||
assert clear_idx < render_idx, (
|
||||
"the done handler should clear stream liveness before the final render, "
|
||||
"but must pass preserveScroll so renderMessages does not infer bottom-pin "
|
||||
"from S.activeStreamId alone"
|
||||
)
|
||||
|
||||
|
||||
def test_render_messages_preserve_scroll_option_uses_user_pin_state_not_stream_liveness():
|
||||
render_body = _function_body(UI_JS, "renderMessages")
|
||||
scroll_helper = _function_body(UI_JS, "_scrollAfterMessageRender")
|
||||
|
||||
assert "function renderMessages(options)" in render_body
|
||||
assert "const preserveScroll=!!(options&&options.preserveScroll);" in render_body
|
||||
assert "_scrollAfterMessageRender(preserveScroll);" in render_body
|
||||
assert "if(preserveScroll){\n scrollIfPinned();\n return;\n }" in scroll_helper
|
||||
assert "if(S.activeStreamId){\n scrollIfPinned();\n return;\n }" in scroll_helper
|
||||
|
||||
|
||||
def test_cached_render_path_uses_same_scroll_policy_as_fresh_render():
|
||||
render_body = _function_body(UI_JS, "renderMessages")
|
||||
cached_branch = render_body[render_body.index("if(sid&&sid!==_sessionHtmlCacheSid") : render_body.index("const compressionState=")]
|
||||
|
||||
assert "_scrollAfterMessageRender(preserveScroll);" in cached_branch
|
||||
assert "if(S.activeStreamId){scrollIfPinned();}else{scrollToBottom();}" not in cached_branch
|
||||
|
||||
|
||||
def test_session_switch_and_idle_session_load_keep_default_bottom_pin_behavior():
|
||||
load_session = _function_body(SESSIONS_JS, "loadSession")
|
||||
idle_branch = load_session[load_session.index("}else{\n S.busy=false;") : load_session.index("// Sync context usage indicator")]
|
||||
|
||||
assert "syncTopbar();renderMessages();" in idle_branch
|
||||
assert "preserveScroll:true" not in idle_branch
|
||||
@@ -25,18 +25,23 @@ class TestScrollPinningFix:
|
||||
instead when S.activeStreamId is set.
|
||||
"""
|
||||
# Find renderMessages function
|
||||
rm_start = UI_JS.find("function renderMessages()")
|
||||
rm_start = UI_JS.find("function renderMessages(")
|
||||
assert rm_start != -1, "renderMessages() not found in ui.js"
|
||||
rm_end = UI_JS.find("\nfunction ", rm_start + 1)
|
||||
rm_body = UI_JS[rm_start:rm_end]
|
||||
helper_start = UI_JS.find("function _scrollAfterMessageRender")
|
||||
assert helper_start != -1, "renderMessages scroll helper not found in ui.js"
|
||||
helper_end = UI_JS.find("\nfunction ", helper_start + 1)
|
||||
helper_body = UI_JS[helper_start:helper_end]
|
||||
|
||||
# Must check activeStreamId before deciding which scroll fn to call
|
||||
assert "activeStreamId" in rm_body, (
|
||||
assert "activeStreamId" in helper_body, (
|
||||
"renderMessages() must check S.activeStreamId before scrolling — "
|
||||
"unconditional scrollToBottom() overrides user scroll position (#677)"
|
||||
)
|
||||
# scrollIfPinned must be called inside renderMessages (stream path)
|
||||
assert "scrollIfPinned()" in rm_body, (
|
||||
# scrollIfPinned must be called through the renderMessages scroll policy (stream path)
|
||||
assert "_scrollAfterMessageRender(preserveScroll);" in rm_body
|
||||
assert "scrollIfPinned()" in helper_body, (
|
||||
"renderMessages() must call scrollIfPinned() during streaming (#677)"
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
"""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")
|
||||
PANELS_JS = (REPO_ROOT / "static" / "panels.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_live_under_insights_not_top_chrome():
|
||||
top_shell = INDEX_HTML[: INDEX_HTML.index('<div class="layout">')]
|
||||
assert 'id="systemHealthPanel"' not in top_shell
|
||||
assert 'aria-label="Host resource health"' not in top_shell
|
||||
assert 'function _renderSystemHealthPanel()' in PANELS_JS
|
||||
assert 'id="systemHealthPanel"' in PANELS_JS
|
||||
assert 'aria-label="Host resource health"' in PANELS_JS
|
||||
assert 'System health' in PANELS_JS
|
||||
assert 'Current VPS resource usage' in PANELS_JS
|
||||
assert PANELS_JS.index('_renderSystemHealthPanel()') < PANELS_JS.index('_renderLlmWikiStatus(wikiStatus)')
|
||||
assert 'data-system-health-metric="cpu"' in PANELS_JS
|
||||
assert 'data-system-health-metric="memory"' in PANELS_JS
|
||||
assert 'data-system-health-metric="disk"' in PANELS_JS
|
||||
assert ".system-health-panel.insights-card" 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.insights-card" 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.querySelector('main.main.showing-insights')" in UI_JS
|
||||
assert "document.addEventListener('visibilitychange',_syncSystemHealthMonitorVisibility)" in UI_JS
|
||||
assert "typeof _syncSystemHealthMonitorVisibility === 'function'" in PANELS_JS
|
||||
assert "function renderSystemHealth(payload)" in UI_JS
|
||||
assert "setSystemHealthUnavailable" in UI_JS
|
||||
assert "data-system-health-metric" in PANELS_JS
|
||||
assert "CPU" in PANELS_JS and "RAM" in PANELS_JS and "Disk" in PANELS_JS
|
||||
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
|
||||
@@ -24,7 +24,7 @@ def test_load_earlier_expands_local_window_before_server_pagination_and_preserve
|
||||
|
||||
|
||||
def test_windowed_render_keeps_streaming_and_tool_activity_anchored_to_rendered_messages():
|
||||
assert "if(S.activeStreamId){\n scrollIfPinned();" in UI_JS
|
||||
assert "_scrollAfterMessageRender(preserveScroll);" in UI_JS
|
||||
assert "const assistantIdxs=[...assistantSegments.keys()].sort((a,b)=>a-b);" in UI_JS
|
||||
assert "if(aIdx<assistantIdxs[0]) continue;" in UI_JS
|
||||
assert "const renderedAssistantIdxs=[...assistantSegments.keys()].sort((a,b)=>a-b);" in UI_JS
|
||||
|
||||
@@ -302,7 +302,7 @@ def test_hidden_active_done_still_updates_current_pane_but_not_read_state():
|
||||
viewed_const_idx = done_block.find("const isSessionViewed=_isSessionActivelyViewed(activeSid);")
|
||||
active_guard_idx = done_block.find("if(isActiveSession){", viewed_const_idx)
|
||||
session_update_idx = done_block.find("S.session=d.session", active_guard_idx)
|
||||
render_idx = done_block.find("renderMessages()", active_guard_idx)
|
||||
render_idx = done_block.find("renderMessages(", active_guard_idx)
|
||||
load_dir_idx = done_block.find("loadDir('.')", active_guard_idx)
|
||||
mark_viewed_idx = done_block.find("if(isSessionViewed) _markSessionViewed(completedSid", active_guard_idx)
|
||||
|
||||
|
||||
@@ -436,11 +436,12 @@ def test_done_handler_sets_busy_false_before_renderMessages(cleanup_test_session
|
||||
stream_end_idx = src.find("source.addEventListener('stream_end'", done_idx)
|
||||
assert stream_end_idx >= 0, "stream_end listener after done handler not found"
|
||||
done_block = src[done_idx:stream_end_idx]
|
||||
# S.busy=false must appear before renderMessages() within the done handler
|
||||
# S.busy=false must appear before the terminal render call within the done handler.
|
||||
busy_pos = done_block.find("S.busy=false;")
|
||||
render_pos = done_block.find("renderMessages()")
|
||||
render_pos = done_block.find("renderMessages(")
|
||||
assert busy_pos >= 0, "done handler must set S.busy=false before renderMessages()"
|
||||
assert busy_pos < render_pos, f"S.busy=false (pos {busy_pos}) must come before renderMessages() (pos {render_pos})"
|
||||
assert render_pos >= 0, "done handler must call renderMessages after settling state"
|
||||
assert busy_pos < render_pos, f"S.busy=false (pos {busy_pos}) must come before renderMessages (pos {render_pos})"
|
||||
|
||||
|
||||
# ── R14: send() uses stale modelSelect.value instead of session model ────────
|
||||
|
||||
Reference in New Issue
Block a user