feat: add constrained-device resource diagnostics

This commit is contained in:
mARTin-B78 2026-09-04 09:51:49 +02:00
parent e18bc6a8cc
commit 90e74149d9
8 changed files with 95 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]
### Added ### Added
- **Resource Diagnostics.** Performance now exposes read-only server RSS, configured/temp disk usage, persisted-job
count, DOM/audio counts, and browser heap when available; host Docker metrics remain explicitly outside the panel's scope.
- **Throttled Desktop Smoke Coverage.** Chromium CDP now exercises app navigation at 4× CPU throttling and - **Throttled Desktop Smoke Coverage.** Chromium CDP now exercises app navigation at 4× CPU throttling and
asserts bounded DOM/audio counts while capturing the browser heap metric when available. asserts bounded DOM/audio counts while capturing the browser heap metric when available.
- **Background Job History Panel.** The Benchmark section now shows sanitized, restart-safe background-job status, - **Background Job History Panel.** The Benchmark section now shows sanitized, restart-safe background-job status,

View File

@ -206,8 +206,10 @@ the first block before investing in architecture or polish.
asserts navigation plus bounded DOM/audio counts, with JS heap captured when Chromium exposes it. asserts navigation plus bounded DOM/audio counts, with JS heap captured when Chromium exposes it.
- [x] Test the current image in a container limited to 4 GB RAM; the app served `/` with a 4 GiB cgroup limit, - [x] Test the current image in a container limited to 4 GB RAM; the app served `/` with a 4 GiB cgroup limit,
~50.6 MiB idle memory, 0.09% CPU, and no OOM kill. ~50.6 MiB idle memory, 0.09% CPU, and no OOM kill.
- [ ] Monitor browser-tab memory, JavaScript heap, DOM-node count, active audio blobs, Docker memory, and temporary disk usage. - [x] Monitor browser-tab memory, JavaScript heap where supported, DOM-node count, audio elements, persisted jobs,
- [ ] Add a memory diagnostics page showing browser heap where supported, rendered rows, cache count/size, active jobs, server/container memory, and largest recent operations. 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.
## 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.5 1.24.6

View File

@ -2,15 +2,32 @@
from __future__ import annotations from __future__ import annotations
import time import time
import resource
import shutil
from pathlib import Path
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from core.database import background_job_artifact_path, background_job_get, background_job_list from core.database import background_job_artifact_path, background_job_get, background_job_list
from core.constants import CONFIG_DIR
from core.registry import TEMP_DIR
router = APIRouter() router = APIRouter()
def _directory_bytes(root: Path) -> int:
total = 0
try:
for path in root.rglob("*"):
if path.is_file():
try: total += path.stat().st_size
except OSError: pass
except OSError:
pass
return total
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)
@ -37,6 +54,17 @@ async def jobs_list(kind: str = "", limit: int = 50):
return {"jobs": [_public_job(job) for job in background_job_list(kind, limit) if job]} return {"jobs": [_public_job(job) for job in background_job_list(kind, limit) if job]}
@router.get("/api/diagnostics")
async def diagnostics():
usage = shutil.disk_usage(str(CONFIG_DIR))
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)),
}
@router.get("/api/jobs/{job_id}") @router.get("/api/jobs/{job_id}")
async def jobs_get(job_id: str): async def jobs_get(job_id: str):
job = background_job_get(job_id) job = background_job_get(job_id)

File diff suppressed because one or more lines are too long

View File

@ -3,10 +3,13 @@
(function initBackgroundJobs() { (function initBackgroundJobs() {
const list = document.getElementById('background-jobs-list'); const list = document.getElementById('background-jobs-list');
const refresh = document.getElementById('background-jobs-refresh'); const refresh = document.getElementById('background-jobs-refresh');
const diagnostics = document.getElementById('resource-diagnostics');
const diagnosticsRefresh = document.getElementById('resource-diagnostics-refresh');
if (!list || !refresh) return; if (!list || !refresh) return;
const labels = { transcription: 'Transcription', voice_design: 'Voice design', casting: 'Voice casting', consistent_images: 'Consistent images' }; const labels = { transcription: 'Transcription', voice_design: 'Voice design', casting: 'Voice casting', consistent_images: 'Consistent images' };
let loading = false; let loading = false;
let diagnosticsLoading = false;
function node(value, fallback = '—') { function node(value, fallback = '—') {
const item = document.createElement('span'); const item = document.createElement('span');
@ -118,9 +121,49 @@
refresh.disabled = false; refresh.disabled = false;
} }
} }
function formatBytes(value) {
const bytes = Number(value);
if (!Number.isFinite(bytes)) return '—';
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KiB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MiB`;
}
async function loadDiagnostics() {
if (!diagnostics || diagnosticsLoading) return;
diagnosticsLoading = true;
if (diagnosticsRefresh) diagnosticsRefresh.disabled = true;
try {
const response = await fetch('/api/diagnostics', { cache: 'no-store' });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const server = await response.json();
const browser = await new Promise(resolve => requestAnimationFrame(() => resolve({
domNodes: document.getElementsByTagName('*').length,
audioElements: document.querySelectorAll('audio').length,
jsHeapBytes: performance.memory?.usedJSHeapSize || 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],
["Audio elements", browser.audioElements], ["JS heap", formatBytes(browser.jsHeapBytes)]].forEach(([label, value]) => {
const item = document.createElement('div');
item.className = 'resource-diagnostic-item';
const name = document.createElement('span'); name.textContent = label;
const valueNode = document.createElement('strong'); valueNode.textContent = value == null ? '—' : String(value);
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);
} finally {
diagnosticsLoading = false;
if (diagnosticsRefresh) diagnosticsRefresh.disabled = false;
}
}
refresh.addEventListener('click', load); refresh.addEventListener('click', load);
diagnosticsRefresh?.addEventListener('click', loadDiagnostics);
load(); load();
loadDiagnostics();
setInterval(() => { setInterval(() => {
if (document.getElementById('s-performance')?.classList.contains('is-active')) load(); if (document.getElementById('s-performance')?.classList.contains('is-active')) { load(); loadDiagnostics(); }
}, 10000); }, 10000);
})(); })();

View File

@ -234,6 +234,17 @@
<div class="perf-history-empty">Loading background jobs…</div> <div class="perf-history-empty">Loading background jobs…</div>
</div> </div>
</div> </div>
<div class="card" id="resource-diagnostics-card">
<div class="bench-card-head">
<div>
<h2>Resource diagnostics</h2>
<p class="card-subtitle">Read-only runtime indicators for constrained-device troubleshooting.</p>
</div>
<button class="btn-secondary btn-sm" id="resource-diagnostics-refresh" type="button"><span class="mdi mdi-refresh"></span> Refresh</button>
</div>
<div id="resource-diagnostics" class="resource-diagnostics-grid" aria-live="polite"><span class="perf-history-empty">Loading diagnostics…</span></div>
</div>
</section> </section>
<section class="bench-pane" id="bench-pane-turn" data-bench-pane="turn"> <section class="bench-pane" id="bench-pane-turn" data-bench-pane="turn">

View File

@ -3742,6 +3742,10 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
.background-job-main code { color: var(--subtext); font-size: 10px; } .background-job-main code { color: var(--subtext); font-size: 10px; }
.background-job-detail, .background-job-meta { grid-column: 1 / -1; color: var(--subtext); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .background-job-detail, .background-job-meta { grid-column: 1 / -1; color: var(--subtext); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.background-job-artifact { grid-column: 1 / -1; color: var(--accent); font-size: 11px; } .background-job-artifact { grid-column: 1 / -1; color: var(--accent); font-size: 11px; }
.resource-diagnostics-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 8px; }
.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; }
.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; }