feat: add constrained-device resource diagnostics
This commit is contained in:
parent
e18bc6a8cc
commit
90e74149d9
@ -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,
|
||||||
|
|||||||
6
TODO.md
6
TODO.md
@ -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)
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
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
@ -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);
|
||||||
})();
|
})();
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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; }
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user