feat: expand resource diagnostics
This commit is contained in:
parent
8f95978486
commit
c815b7941a
@ -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`
|
||||
|
||||
5
TODO.md
5
TODO.md
@ -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)
|
||||
|
||||
|
||||
@ -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(),
|
||||
}
|
||||
|
||||
|
||||
|
||||
2
static/dist/main.min.js
vendored
2
static/dist/main.min.js
vendored
File diff suppressed because one or more lines are too long
@ -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);
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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');
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user