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] ## [Unreleased]
### Changed ### 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; - **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. 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` - **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. ~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, - [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. 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; - [x] 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. 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) ## 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 resource
import shutil import shutil
from pathlib import Path from pathlib import Path
from typing import Any
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
@ -28,6 +29,36 @@ def _directory_bytes(root: Path) -> int:
return total 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: def _public_job(job: dict) -> dict:
payload = dict(job) payload = dict(job)
payload.pop("fingerprint", None) payload.pop("fingerprint", None)
@ -74,11 +105,18 @@ async def jobs_list(kind: str = "", limit: int = 50):
@router.get("/api/diagnostics") @router.get("/api/diagnostics")
async def diagnostics(): async def diagnostics():
usage = shutil.disk_usage(str(CONFIG_DIR)) usage = shutil.disk_usage(str(CONFIG_DIR))
artifacts = CONFIG_DIR / 'background-artifacts'
return { return {
"server_rss_mb": round(float(resource.getrusage(resource.RUSAGE_SELF).ru_maxrss) / 1024, 1), "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}, "config_disk": {"used_bytes": _directory_bytes(CONFIG_DIR), "free_bytes": usage.free},
"temp_disk": {"used_bytes": _directory_bytes(TEMP_DIR)}, "temp_disk": {"used_bytes": _directory_bytes(TEMP_DIR)},
"background_jobs": len(background_job_list(limit=100)), "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, domNodes: document.getElementsByTagName('*').length,
audioElements: document.querySelectorAll('audio').length, audioElements: document.querySelectorAll('audio').length,
jsHeapBytes: performance.memory?.usedJSHeapSize || null, 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(); diagnostics.replaceChildren();
[["Server RSS", `${server.server_rss_mb} MiB`], ["Config data", formatBytes(server.config_disk?.used_bytes)], [["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)], ["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]) => { ["Audio elements", browser.audioElements], ["JS heap", formatBytes(browser.jsHeapBytes)]].forEach(([label, value]) => {
const item = document.createElement('div'); const item = document.createElement('div');
item.className = 'resource-diagnostic-item'; item.className = 'resource-diagnostic-item';
@ -171,6 +177,17 @@
const valueNode = document.createElement('strong'); valueNode.textContent = value == null ? '—' : String(value); const valueNode = document.createElement('strong'); valueNode.textContent = value == null ? '—' : String(value);
item.append(name, valueNode); diagnostics.appendChild(item); 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) { } catch (error) {
diagnostics.replaceChildren(); diagnostics.replaceChildren();
const item = node('Diagnostics unavailable: ' + error.message); item.className = 'perf-history-empty'; diagnostics.appendChild(item); 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 { 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 span { color: var(--subtext); font-size: 11px; }
.resource-diagnostic-item strong { font-size: 14px; } .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-error .background-job-detail { color: var(--red); }
.background-job-row.is-done .background-job-meta { color: var(--green, #22c55e); } .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; } .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(); expect(clicked).toBeTruthy();
await expect.poll(() => retryRequests).toBe(1); 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');
});