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]
### 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
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,

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.
- [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.
- [ ] Monitor browser-tab memory, JavaScript heap, DOM-node count, active audio blobs, Docker memory, and temporary disk usage.
- [ ] Add a memory diagnostics page showing browser heap where supported, rendered rows, cache count/size, active jobs, server/container memory, and largest recent operations.
- [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.
## 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
import time
import resource
import shutil
from pathlib import Path
from fastapi import APIRouter, HTTPException
from fastapi.responses import FileResponse
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()
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:
payload = dict(job)
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]}
@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}")
async def jobs_get(job_id: str):
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() {
const list = document.getElementById('background-jobs-list');
const refresh = document.getElementById('background-jobs-refresh');
const diagnostics = document.getElementById('resource-diagnostics');
const diagnosticsRefresh = document.getElementById('resource-diagnostics-refresh');
if (!list || !refresh) return;
const labels = { transcription: 'Transcription', voice_design: 'Voice design', casting: 'Voice casting', consistent_images: 'Consistent images' };
let loading = false;
let diagnosticsLoading = false;
function node(value, fallback = '—') {
const item = document.createElement('span');
@ -118,9 +121,49 @@
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);
diagnosticsRefresh?.addEventListener('click', loadDiagnostics);
load();
loadDiagnostics();
setInterval(() => {
if (document.getElementById('s-performance')?.classList.contains('is-active')) load();
if (document.getElementById('s-performance')?.classList.contains('is-active')) { load(); loadDiagnostics(); }
}, 10000);
})();

View File

@ -234,6 +234,17 @@
<div class="perf-history-empty">Loading background jobs…</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 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-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; }
.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-done .background-job-meta { color: var(--green, #22c55e); }
.background-job-actions { display: flex; align-items: center; gap: 9px; flex-shrink: 0; }