feat: expand resource diagnostics

This commit is contained in:
mARTin-B78 2026-09-04 10:42:08 +02:00
parent 8f95978486
commit c815b7941a
8 changed files with 83 additions and 5 deletions

View File

@ -8,6 +8,8 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
## [Unreleased]
### Changed
- **Expanded resource diagnostics.** The Performance panel now reports bounded browser cache counters, container
cgroup current/max memory, artifact-cache bytes, and sanitized largest recent operation durations.
- **Current-release constrained validation.** Built `1.24.21` and served it under a hard 4 GiB cgroup limit;
startup remained healthy at 52.54 MiB, 0.10% CPU, 6 PIDs, and no OOM kill.
- **Release cache-busting.** Updated the HTML app-version, stylesheet URL, and loader URL to match `VERSION`

View File

@ -229,8 +229,9 @@ architecture change: the larger Audiobook/Rehearser/style split.
~50.6 MiB idle memory, 0.09% CPU, and no OOM kill.
- [x] Monitor browser-tab memory, JavaScript heap where supported, DOM-node count, audio elements, persisted jobs,
server RSS, configured-data disk, and temporary disk usage through the read-only Performance diagnostics panel.
- [ ] Add a memory diagnostics page showing cache count/size, Docker/container memory, and largest recent operations;
the current panel covers browser/server/temp basics without pretending to measure unavailable host-level metrics.
- [x] Add a memory diagnostics page showing cache count/size, Docker/container memory, and largest recent operations.
The authenticated panel reports browser cache counters, cgroup memory current/max, server RSS, configured/temp
storage, persisted jobs, and sanitized operation durations; host-wide Docker metrics remain unavailable by design.
## P1 — Speaker attribution: measured state and next steps (paused 2026-09-02)

View File

@ -1 +1 @@
1.24.22
1.24.23

View File

@ -5,6 +5,7 @@ import time
import resource
import shutil
from pathlib import Path
from typing import Any
from fastapi import APIRouter, HTTPException
from fastapi.responses import FileResponse
@ -28,6 +29,36 @@ def _directory_bytes(root: Path) -> int:
return total
def _cgroup_bytes(name: str) -> int | None:
"""Read the container's cgroup v2 memory metric when available."""
try:
value = (Path('/sys/fs/cgroup') / name).read_text(encoding='utf-8').strip()
if value == 'max': return None
return max(0, int(value))
except (OSError, ValueError):
return None
def _largest_recent_jobs(limit: int = 5) -> list[dict[str, Any]]:
rows = []
now = time.time()
for job in background_job_list(limit=100):
if not job: continue
try:
started = float(job.get('created') or now)
finished = float(job.get('updated') or now)
elapsed = max(0.0, finished - started)
except (TypeError, ValueError):
continue
rows.append({
'kind': str(job.get('kind') or 'job'),
'job_id': str(job.get('job_id') or '')[:12],
'elapsed': round(elapsed, 1),
'status': 'failed' if job.get('error') else ('completed' if job.get('done') else 'running'),
})
return sorted(rows, key=lambda row: row['elapsed'], reverse=True)[:max(1, min(10, limit))]
def _public_job(job: dict) -> dict:
payload = dict(job)
payload.pop("fingerprint", None)
@ -74,11 +105,18 @@ async def jobs_list(kind: str = "", limit: int = 50):
@router.get("/api/diagnostics")
async def diagnostics():
usage = shutil.disk_usage(str(CONFIG_DIR))
artifacts = CONFIG_DIR / 'background-artifacts'
return {
"server_rss_mb": round(float(resource.getrusage(resource.RUSAGE_SELF).ru_maxrss) / 1024, 1),
"config_disk": {"used_bytes": _directory_bytes(CONFIG_DIR), "free_bytes": usage.free},
"temp_disk": {"used_bytes": _directory_bytes(TEMP_DIR)},
"background_jobs": len(background_job_list(limit=100)),
"container_memory": {
"current_bytes": _cgroup_bytes('memory.current'),
"max_bytes": _cgroup_bytes('memory.max'),
},
"cache_storage": {"background_artifacts_bytes": _directory_bytes(artifacts)},
"largest_operations": _largest_recent_jobs(),
}

File diff suppressed because one or more lines are too long

View File

@ -159,11 +159,17 @@
domNodes: document.getElementsByTagName('*').length,
audioElements: document.querySelectorAll('audio').length,
jsHeapBytes: performance.memory?.usedJSHeapSize || null,
imageCacheEntries: window.TTVC?.imageCache?.size?.() ?? null,
rehearserAudioEntries: window.rehState?.synthCache?.size ?? null,
voiceRecords: Array.isArray(window._voices) ? window._voices.length : null,
})));
diagnostics.replaceChildren();
[["Server RSS", `${server.server_rss_mb} MiB`], ["Config data", formatBytes(server.config_disk?.used_bytes)],
["Config free", formatBytes(server.config_disk?.free_bytes)], ["Temp data", formatBytes(server.temp_disk?.used_bytes)],
["Persisted jobs", server.background_jobs], ["DOM nodes", browser.domNodes],
["Container memory", `${formatBytes(server.container_memory?.current_bytes)} / ${formatBytes(server.container_memory?.max_bytes)}`],
["Artifact cache", formatBytes(server.cache_storage?.background_artifacts_bytes)], ["Persisted jobs", server.background_jobs],
["Image cache entries", browser.imageCacheEntries], ["Rehearser audio entries", browser.rehearserAudioEntries],
["Voice records", browser.voiceRecords], ["DOM nodes", browser.domNodes],
["Audio elements", browser.audioElements], ["JS heap", formatBytes(browser.jsHeapBytes)]].forEach(([label, value]) => {
const item = document.createElement('div');
item.className = 'resource-diagnostic-item';
@ -171,6 +177,17 @@
const valueNode = document.createElement('strong'); valueNode.textContent = value == null ? '—' : String(value);
item.append(name, valueNode); diagnostics.appendChild(item);
});
const operations = Array.isArray(server.largest_operations) ? server.largest_operations : [];
if (operations.length) {
const heading = document.createElement('div'); heading.className = 'resource-diagnostic-subhead'; heading.textContent = 'Largest recent operations';
diagnostics.appendChild(heading);
operations.forEach(operation => {
const item = document.createElement('div'); item.className = 'resource-diagnostic-item';
const name = document.createElement('span'); name.textContent = `${operation.kind} · ${operation.status}`;
const valueNode = document.createElement('strong'); valueNode.textContent = `${Number(operation.elapsed || 0).toFixed(1)}s`;
item.append(name, valueNode); diagnostics.appendChild(item);
});
}
} catch (error) {
diagnostics.replaceChildren();
const item = node('Diagnostics unavailable: ' + error.message); item.className = 'perf-history-empty'; diagnostics.appendChild(item);

View File

@ -3746,6 +3746,7 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
.resource-diagnostic-item { display: flex; flex-direction: column; gap: 3px; padding: 9px 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--panel); }
.resource-diagnostic-item span { color: var(--subtext); font-size: 11px; }
.resource-diagnostic-item strong { font-size: 14px; }
.resource-diagnostic-subhead { grid-column: 1 / -1; margin-top: 8px; color: var(--subtext); font-size: 12px; font-weight: 700; }
.background-job-row.is-error .background-job-detail { color: var(--red); }
.background-job-row.is-done .background-job-meta { color: var(--green, #22c55e); }
.background-job-actions { display: flex; align-items: center; gap: 9px; flex-shrink: 0; }

View File

@ -109,3 +109,22 @@ test('background job history exposes retry only when the server allows it', asyn
expect(clicked).toBeTruthy();
await expect.poll(() => retryRequests).toBe(1);
});
test('resource diagnostics expose cache, container, and operation metrics', async ({ page }) => {
await page.route('**/api/diagnostics', route => route.fulfill({ json: {
server_rss_mb: 52.5,
config_disk: { used_bytes: 1024, free_bytes: 2048 },
temp_disk: { used_bytes: 512 },
background_jobs: 2,
container_memory: { current_bytes: 4096, max_bytes: 8192 },
cache_storage: { background_artifacts_bytes: 256 },
largest_operations: [{ kind: 'voice_design', status: 'completed', elapsed: 4.2 }],
} }));
await page.goto('/', { waitUntil: 'domcontentloaded' });
await navTo(page, 's-performance');
const diagnostics = page.locator('#resource-diagnostics');
await expect(diagnostics).toContainText('Image cache entries');
await expect(diagnostics).toContainText('Container memory');
await expect(diagnostics).toContainText('Largest recent operations');
await expect(diagnostics).toContainText('voice_design · completed');
});