From c815b7941ab1662965d9679653cbc3e26423c3e9 Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Fri, 4 Sep 2026 10:42:08 +0200 Subject: [PATCH] feat: expand resource diagnostics --- CHANGELOG.md | 2 ++ TODO.md | 5 +++-- VERSION | 2 +- routes/jobs.py | 38 ++++++++++++++++++++++++++++++++++++++ static/dist/main.min.js | 2 +- static/js/jobs.js | 19 ++++++++++++++++++- static/style.css | 1 + tests/smoke.spec.js | 19 +++++++++++++++++++ 8 files changed, 83 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f6d339a..b7af742 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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` diff --git a/TODO.md b/TODO.md index c881dc2..c9d91c8 100644 --- a/TODO.md +++ b/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) diff --git a/VERSION b/VERSION index 30cfa4c..89394fd 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.24.22 +1.24.23 diff --git a/routes/jobs.py b/routes/jobs.py index 588f77b..feb8d61 100644 --- a/routes/jobs.py +++ b/routes/jobs.py @@ -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(), } diff --git a/static/dist/main.min.js b/static/dist/main.min.js index 46ff867..4bb9610 100644 --- a/static/dist/main.min.js +++ b/static/dist/main.min.js @@ -197,7 +197,7 @@ var _a,_b,_c,_d,_e,_f,_g,_h,_i,_j,_k,_l,_m,_n,_o,_p,_q,_r,_s,_t,_u,_v,_w,_x,_y,_ ${can?'':""} - `}function render(data){const items=data.items||[];items.length?(grid.innerHTML=items.map(card).join(""),grid.querySelectorAll(".fa-card").forEach((el,i)=>{var _a3,_b3;const v=items[i];(_a3=el.querySelector(".fa-play"))==null||_a3.addEventListener("click",e=>{e.stopPropagation(),preview(v,el)}),(_b3=el.querySelector(".fa-import"))==null||_b3.addEventListener("click",e=>{e.stopPropagation(),importVoice(v,el)})})):grid.innerHTML='
No voices found \u2014 try a different search, language or filter.
';const pager=$2("fa-pager");if(pager){pager.hidden=!1,$2("fa-prev").disabled=_page<=1;const pages=Math.max(1,Math.ceil((data.total||0)/(data.page_size||24)));$2("fa-next").disabled=_page>=pages,$2("fa-pager-info").textContent=`Page ${_page} \xB7 ${(data.total||0).toLocaleString()} voices`}}async function browse(){status2("Loading voices from fish.audio\u2026"),grid.innerHTML='
Loading\u2026
';try{const d=await fetch("/api/fishaudio/voices?"+params().toString()).then(r=>{if(!r.ok)throw new Error("HTTP "+r.status);return r.json()});render(d),status2(d.offline?"\u26A0 fish.audio unreachable \u2014 showing cached results":`${(d.total||0).toLocaleString()} matching voices`,!!d.offline)}catch(e){grid.innerHTML=`
Failed to load: ${esc(e.message)}
`,status2("")}}function stopPreview(){_audio&&(_audio.pause(),_audio=null),_playingCard==null||_playingCard.classList.remove("fa-card-playing"),_playingCard=null}function preview(v,el){if(_playingCard===el){stopPreview();return}stopPreview(),v.sample_audio&&(_audio=new Audio(v.sample_audio),_playingCard=el,el.classList.add("fa-card-playing"),_audio.addEventListener("ended",stopPreview),_audio.play().catch(()=>{stopPreview(),typeof toast=="function"&&toast("Could not play preview","error")}))}async function importVoice(v,el){const btn=el.querySelector(".fa-import"),orig=btn.innerHTML;btn.disabled=!0,btn.innerHTML='';const lang=(v.language||"EN").slice(0,2).toUpperCase(),base=(typeof _umlautSafe=="function"?_umlautSafe(v.title||"fishaudio"):v.title||"fishaudio").replace(/[^A-Za-z0-9]+/g,"_").replace(/^_+|_+$/g,"").slice(0,40)||"Voice",voiceId=`${lang}_${base}`;try{const r=await fetch("/api/quick-import-voice",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({voice_id:voiceId,audio_url:v.sample_audio,transcript:v.sample_text||v.default_text||""})});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}const d=await r.json();typeof saveMeta=="function"&&await saveMeta(d.voice_id,{name:v.title||base,tag:"fish-audio",group:"fish-audio",origin:"cloned",gender:(v.gender||"").charAt(0).toUpperCase(),note:(v.description||"").slice(0,180)}).catch(()=>{}),v.image&&await fetch("/api/voice/picture-url",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({voice_id:d.voice_id,image_url:v.image})}).catch(()=>{}),btn.innerHTML=' Imported',el.classList.add("fa-card-imported"),typeof toast=="function"&&toast(`Imported "${v.title}" as ${d.voice_id}`,"success"),typeof loadVoiceLibrary=="function"&&loadVoiceLibrary().catch(()=>{})}catch(e){btn.disabled=!1,btn.innerHTML=orig,typeof toast=="function"&&toast("Import failed: "+e.message,"error")}}(_a2=$2("fa-fetch"))==null||_a2.addEventListener("click",()=>{_page=1,browse()}),(_b2=$2("fa-search"))==null||_b2.addEventListener("keydown",e=>{e.key==="Enter"&&(_page=1,browse())}),(_c2=$2("fa-tag"))==null||_c2.addEventListener("keydown",e=>{e.key==="Enter"&&(_page=1,browse(),applyFilters())}),(_d2=$2("fa-lang"))==null||_d2.addEventListener("change",()=>{_page=1,browse()}),(_e2=$2("fa-sort"))==null||_e2.addEventListener("change",()=>{_page=1,browse()}),(_f2=$2("fa-prev"))==null||_f2.addEventListener("click",()=>{_page>1&&(_page--,browse())}),(_g2=$2("fa-next"))==null||_g2.addEventListener("click",()=>{_page++,browse()});const pop=$2("fa-filter-pop");function positionPop(){const btn=$2("fa-filter-btn");if(!pop||!btn||pop.hidden)return;const r=btn.getBoundingClientRect(),w=pop.offsetWidth||320,margin=8;let left=Math.min(r.right-w,window.innerWidth-w-margin);left{e.stopPropagation(),openPop()}),(_i2=$2("fa-filter-close"))==null||_i2.addEventListener("click",e=>{e.stopPropagation(),openPop(!1)}),pop==null||pop.addEventListener("click",e=>e.stopPropagation()),document.addEventListener("click",()=>openPop(!1)),document.addEventListener("keydown",e=>{e.key==="Escape"&&openPop(!1)}),window.addEventListener("resize",positionPop),window.addEventListener("scroll",positionPop,!0),document.querySelectorAll("#fa-filter-pop .fa-pchip").forEach(chip=>{chip.addEventListener("click",()=>{const wrap=chip.closest(".fa-pchips");wrap.classList.contains("fa-pchips-multi")?chip.classList.toggle("active"):(wrap.querySelectorAll(".fa-pchip").forEach(c=>c.classList.remove("active")),chip.classList.add("active")),updateFilterBadge()})}),(_j2=$2("fa-tag"))==null||_j2.addEventListener("input",updateFilterBadge);function applyFilters(){_page=1,openPop(!1),browse()}(_k2=$2("fa-filter-apply"))==null||_k2.addEventListener("click",applyFilters),(_l2=$2("fa-filter-reset"))==null||_l2.addEventListener("click",()=>{document.querySelectorAll('#fa-filter-pop .fa-pchips[data-group="tag"] .fa-pchip').forEach(c=>c.classList.remove("active")),["gender","age"].forEach(g=>{const wrap=document.querySelector(`#fa-filter-pop .fa-pchips[data-group="${g}"]`);wrap==null||wrap.querySelectorAll(".fa-pchip").forEach((c,i)=>c.classList.toggle("active",i===0))}),$2("fa-tag")&&($2("fa-tag").value=""),updateFilterBadge(),applyFilters()})}(),function(){const tabs=document.getElementById("gvo-tabs");if(!tabs)return;const map={direct:"tab-getvoices",fish:"fa-browser-card",eleven:"el-browser-card"};function show(src){map[src]||(src="direct"),Object.entries(map).forEach(([k,id])=>{const el=document.getElementById(id);el&&(k===src?el.style.removeProperty("display"):el.style.setProperty("display","none","important"))}),tabs.querySelectorAll(".gvo-tab").forEach(t=>t.classList.toggle("active",t.dataset.src===src));try{localStorage.setItem("gvo-src",src)}catch{}}tabs.querySelectorAll(".gvo-tab").forEach(t=>t.addEventListener("click",()=>show(t.dataset.src)));let saved="direct";try{saved=localStorage.getItem("gvo-src")||"direct"}catch{}show(saved)}(),function(){const KEY="ttsvc_voice_library_cache_v2";let inFlight=null;function read(){try{const value=JSON.parse(sessionStorage.getItem(KEY)||"null");return!value||!Array.isArray(value.voices)||!Number.isFinite(value.savedAt)?null:Date.now()-value.savedAt<=3e4?value.voices:null}catch{return null}}function write(voices){try{sessionStorage.setItem(KEY,JSON.stringify({savedAt:Date.now(),voices}))}catch{}}function invalidate(){try{sessionStorage.removeItem(KEY)}catch{}}async function fetchVoices(options={}){const refresh=options===!0||options.refresh===!0;if(!refresh){const cached=read();if(cached)return cached}return inFlight||(inFlight=fetch("/api/voices"+(refresh?"?refresh=1":""),{cache:"no-store"}).then(async response=>{if(!response.ok){const error=await response.json().catch(()=>({}));throw new Error(error.detail||response.statusText)}const voices=await response.json();if(!Array.isArray(voices))throw new Error("Voice library response was not an array");return write(voices),voices}).finally(()=>{inFlight=null}),inFlight)}window.fetchVoiceLibrary=fetchVoices,window.invalidateVoiceLibraryCache=invalidate}(),function(){const list=document.getElementById("background-jobs-list"),refresh=document.getElementById("background-jobs-refresh"),diagnostics=document.getElementById("resource-diagnostics"),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=!1,diagnosticsLoading=!1;function node(value,fallback="\u2014"){const item=document.createElement("span");return item.textContent=value==null||value===""?fallback:String(value),item}function statusClass(job){return job.cancelled||job.stage==="cancelled"?"is-cancelled":job.error||job.stage==="error"||job.stage==="failed"?"is-error":job.done?"is-done":"is-active"}function statusLabel(job){return job.cancelled||job.stage==="cancelled"?"Cancelled":job.error||job.stage==="error"||job.stage==="failed"?"Failed":job.done?"Completed":job.stage==="queued"?"Queued":"Running"}function cancelEndpoint(kind,id){const paths={transcription:"/api/transcribe/cancel/",voice_design:"/api/voice-design/cancel/",casting:"/api/match-characters-voices/cancel/",consistent_images:"/api/character-generate-consistent-images/cancel/"};return paths[kind]?paths[kind]+encodeURIComponent(id):""}function retryEndpoint(kind,id){const paths={transcription:"/api/transcribe/retry/",voice_design:"/api/voice-design/retry/",casting:"/api/match-characters-voices/retry/",consistent_images:"/api/character-generate-consistent-images/retry/"};return paths[kind]&&id?paths[kind]+encodeURIComponent(id):""}function render(jobs){if(list.replaceChildren(),!jobs.length){const empty=node("No background jobs yet. Start a transcription, voice design, cast, or image workflow to see it here.");empty.className="perf-history-empty",list.appendChild(empty);return}jobs.forEach(job=>{const row=document.createElement("div");row.className="background-job-row "+statusClass(job);const main=document.createElement("div");main.className="background-job-main";const title=document.createElement("strong");title.textContent=labels[job.kind]||job.kind||"Background job",main.appendChild(title);const id=document.createElement("code");id.textContent=String(job.job_id||"").slice(0,12),main.appendChild(id);const detail=node(job.error||job.detail||job.stage);detail.className="background-job-detail",main.appendChild(detail);const meta=node(`${statusLabel(job)} \xB7 ${Number(job.elapsed||0).toFixed(1)}s`);meta.className="background-job-meta",main.appendChild(meta);const storedResult=job.result&&typeof job.result=="object"?job.result:{},artifactList=[];storedResult.artifact&&artifactList.push(storedResult.artifact),Array.isArray(storedResult.artifacts)&&artifactList.push(...storedResult.artifacts),artifactList.filter(item=>item&&typeof item.url=="string"&&item.url.startsWith("/api/jobs/")).forEach(item=>{const link=document.createElement("a");link.className="background-job-artifact",link.href=item.url,link.target="_blank",link.rel="noopener",link.textContent=`Download ${item.kind||"result"}`,main.appendChild(link)}),row.appendChild(main);const actions=document.createElement("div");if(actions.className="background-job-actions",job.done&&job.error&&job.retry_available){const button=document.createElement("button");button.type="button",button.className="btn-secondary btn-sm",button.textContent="Retry",button.addEventListener("click",async()=>{button.disabled=!0;try{const response=await fetch(retryEndpoint(job.kind,job.job_id),{method:"POST"});if(!response.ok){const detail2=await response.json().catch(()=>({}));throw new Error(detail2.detail||`HTTP ${response.status}`)}await load()}catch(error){button.disabled=!1,typeof toast=="function"&&toast("Could not retry job: "+error.message,"error")}}),actions.appendChild(button)}if(!job.done&&!job.cancelled&&job.job_id){const endpoint=cancelEndpoint(job.kind,job.job_id);if(endpoint){const button=document.createElement("button");button.type="button",button.className="btn-secondary btn-sm",button.textContent="Cancel",button.addEventListener("click",async()=>{button.disabled=!0;try{const response=await fetch(endpoint,{method:"POST"});if(!response.ok)throw new Error(`HTTP ${response.status}`);await load()}catch(error){button.disabled=!1,typeof toast=="function"&&toast("Could not cancel job: "+error.message,"error")}}),actions.appendChild(button)}}const note=node("Reopen source to retry");note.className="background-job-retry-note",note.title="Inputs are not retained for replay, by design.",(!job.done||!job.error||!job.retry_available)&&actions.appendChild(note),row.appendChild(actions),list.appendChild(row)})}async function load(){if(!loading){loading=!0,refresh.disabled=!0;try{const response=await fetch("/api/jobs?limit=50",{cache:"no-store"});if(!response.ok)throw new Error(`HTTP ${response.status}`);const payload=await response.json();render(Array.isArray(payload.jobs)?payload.jobs:[])}catch(error){list.replaceChildren();const empty=node("Background job history unavailable: "+error.message);empty.className="perf-history-empty",list.appendChild(empty)}finally{loading=!1,refresh.disabled=!1}}}function formatBytes(value){const bytes=Number(value);return Number.isFinite(bytes)?bytes<1024*1024?`${(bytes/1024).toFixed(1)} KiB`:`${(bytes/1024/1024).toFixed(1)} MiB`:"\u2014"}async function loadDiagnostics(){var _a2,_b2,_c2;if(!(!diagnostics||diagnosticsLoading)){diagnosticsLoading=!0,diagnosticsRefresh&&(diagnosticsRefresh.disabled=!0);try{const response=await fetch("/api/diagnostics",{cache:"no-store"});if(!response.ok)throw new Error(`HTTP ${response.status}`);const server=await response.json(),browser=await new Promise(resolve=>requestAnimationFrame(()=>{var _a3;return resolve({domNodes:document.getElementsByTagName("*").length,audioElements:document.querySelectorAll("audio").length,jsHeapBytes:((_a3=performance.memory)==null?void 0:_a3.usedJSHeapSize)||null})}));diagnostics.replaceChildren(),[["Server RSS",`${server.server_rss_mb} MiB`],["Config data",formatBytes((_a2=server.config_disk)==null?void 0:_a2.used_bytes)],["Config free",formatBytes((_b2=server.config_disk)==null?void 0:_b2.free_bytes)],["Temp data",formatBytes((_c2=server.temp_disk)==null?void 0:_c2.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?"\u2014":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=!1,diagnosticsRefresh&&(diagnosticsRefresh.disabled=!1)}}}refresh.addEventListener("click",load),diagnosticsRefresh==null||diagnosticsRefresh.addEventListener("click",loadDiagnostics),load(),loadDiagnostics(),setInterval(()=>{var _a2;(_a2=document.getElementById("s-performance"))!=null&&_a2.classList.contains("is-active")&&(load(),loadDiagnostics())},1e4)}(),function(){"use strict";var root=window.TTVC=window.TTVC||{},entries=new Map,visible=new Set,observer="IntersectionObserver"in window?new IntersectionObserver(function(records){records.forEach(function(record){var img=record.target;if(record.isIntersecting){visible.add(img);var source=img.dataset.ttvcSrc;source&&!img.getAttribute("src")&&img.setAttribute("src",source),entries.delete(img),entries.set(img,Date.now())}else visible.delete(img)}),evict()},{rootMargin:"160px"}):null;function limit(){return typeof window.ttvcIsLowMemoryDevice=="function"&&window.ttvcIsLowMemoryDevice()?24:64}function evict(){for(entries.forEach(function(_,img2){img2.isConnected||entries.delete(img2)});entries.size>limit();){var candidate=null;for(var img of entries.keys())if(!visible.has(img)){candidate=img;break}if(!candidate)break;entries.delete(candidate),candidate.getAttribute("src")&&(candidate.dataset.ttvcSrc=candidate.getAttribute("src")),candidate.removeAttribute("src")}}function observeImages(container){container&&(container.querySelectorAll("img").forEach(function(img){!img.dataset.ttvcSrc&&img.getAttribute("src")&&(img.dataset.ttvcSrc=img.getAttribute("src")),observer&&!img.dataset.ttvcObserved&&(img.dataset.ttvcObserved="1",observer.observe(img)),entries.delete(img),entries.set(img,Date.now())}),evict())}root.imageCache={observe:observeImages,evict,size:function(){return entries.size}},window.ttvcObserveImages=observeImages}();function cleanBaseUrl(url){return String(url||"").trim().replace(/\/+$/,"")}function getTtsBaseUrl(){var _a2;return cleanBaseUrl((_a2=$("s-tts-url"))==null?void 0:_a2.value)||"http://localhost:8020"}function getTtsV1Url(){const base=getTtsBaseUrl();return base.endsWith("/v1")?base:base+"/v1"}function getTtsStreamBaseUrl(){var _a2;return cleanBaseUrl(((_a2=$("s-tts-stream-url"))==null?void 0:_a2.value)||_appSettings.tts_stream_url)||"http://localhost:8023"}function getTtsStreamV1Url(){const base=getTtsStreamBaseUrl();return base.endsWith("/v1")?base:base+"/v1"}function getCreatorV1Url(){const loc=window.location,protocol=loc.protocol||"http:",port=loc.port?":"+loc.port:"",host=loc.hostname==="0.0.0.0"?"localhost":loc.hostname;return`${protocol}//${host}${port}/v1`}function updateCreatorUrlHints(){const warning=$("routing-url-warning"),badBindHost=window.location.hostname==="0.0.0.0";warning&&warning.classList.toggle("show",badBindHost)}function activeVoiceIds(){return(_voices||[]).filter(v=>v.enabled!==!1).slice().sort((a,b)=>a.id.localeCompare(b.id)).map(v=>v.id)}function integrationVoiceExample(){return activeVoiceIds()[0]||"EN_F_ExampleVoice"}function integrationVoiceList(){const ids=activeVoiceIds();return ids.length?ids.join(", "):"EN_F_ExampleVoice, DE_M_ExampleVoice"}function virtualDesignVoiceIds(){return Object.keys(loadDesignPresets?loadDesignPresets():{}).sort((a,b)=>a.localeCompare(b)).map(name=>"vd_"+(typeof _umlautSafe=="function"?_umlautSafe(name):name).replace(/[^A-Za-z0-9_.-]+/g,"_").replace(/^_+|_+$/g,""))}function renderIntegrationSnippets(){if(!$("snippet-sillytavern"))return;const base=getTtsBaseUrl(),v1=getTtsV1Url(),streamV1=getTtsStreamV1Url(),proxyV1=getCreatorV1Url(),proxyBase=proxyV1.replace(/\/v1$/,"");updateCreatorUrlHints();const voice=integrationVoiceExample(),voices=integrationVoiceList(),vdVoices=virtualDesignVoiceIds(),vdVoice=vdVoices[0]||"vd_EN_F_Warm_Narrator";$("integration-url-label").textContent="TTS backend: "+base,$("snippet-sillytavern").textContent=`Provider: OpenAI compatible TTS + `}function render(data){const items=data.items||[];items.length?(grid.innerHTML=items.map(card).join(""),grid.querySelectorAll(".fa-card").forEach((el,i)=>{var _a3,_b3;const v=items[i];(_a3=el.querySelector(".fa-play"))==null||_a3.addEventListener("click",e=>{e.stopPropagation(),preview(v,el)}),(_b3=el.querySelector(".fa-import"))==null||_b3.addEventListener("click",e=>{e.stopPropagation(),importVoice(v,el)})})):grid.innerHTML='
No voices found \u2014 try a different search, language or filter.
';const pager=$2("fa-pager");if(pager){pager.hidden=!1,$2("fa-prev").disabled=_page<=1;const pages=Math.max(1,Math.ceil((data.total||0)/(data.page_size||24)));$2("fa-next").disabled=_page>=pages,$2("fa-pager-info").textContent=`Page ${_page} \xB7 ${(data.total||0).toLocaleString()} voices`}}async function browse(){status2("Loading voices from fish.audio\u2026"),grid.innerHTML='
Loading\u2026
';try{const d=await fetch("/api/fishaudio/voices?"+params().toString()).then(r=>{if(!r.ok)throw new Error("HTTP "+r.status);return r.json()});render(d),status2(d.offline?"\u26A0 fish.audio unreachable \u2014 showing cached results":`${(d.total||0).toLocaleString()} matching voices`,!!d.offline)}catch(e){grid.innerHTML=`
Failed to load: ${esc(e.message)}
`,status2("")}}function stopPreview(){_audio&&(_audio.pause(),_audio=null),_playingCard==null||_playingCard.classList.remove("fa-card-playing"),_playingCard=null}function preview(v,el){if(_playingCard===el){stopPreview();return}stopPreview(),v.sample_audio&&(_audio=new Audio(v.sample_audio),_playingCard=el,el.classList.add("fa-card-playing"),_audio.addEventListener("ended",stopPreview),_audio.play().catch(()=>{stopPreview(),typeof toast=="function"&&toast("Could not play preview","error")}))}async function importVoice(v,el){const btn=el.querySelector(".fa-import"),orig=btn.innerHTML;btn.disabled=!0,btn.innerHTML='';const lang=(v.language||"EN").slice(0,2).toUpperCase(),base=(typeof _umlautSafe=="function"?_umlautSafe(v.title||"fishaudio"):v.title||"fishaudio").replace(/[^A-Za-z0-9]+/g,"_").replace(/^_+|_+$/g,"").slice(0,40)||"Voice",voiceId=`${lang}_${base}`;try{const r=await fetch("/api/quick-import-voice",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({voice_id:voiceId,audio_url:v.sample_audio,transcript:v.sample_text||v.default_text||""})});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}const d=await r.json();typeof saveMeta=="function"&&await saveMeta(d.voice_id,{name:v.title||base,tag:"fish-audio",group:"fish-audio",origin:"cloned",gender:(v.gender||"").charAt(0).toUpperCase(),note:(v.description||"").slice(0,180)}).catch(()=>{}),v.image&&await fetch("/api/voice/picture-url",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({voice_id:d.voice_id,image_url:v.image})}).catch(()=>{}),btn.innerHTML=' Imported',el.classList.add("fa-card-imported"),typeof toast=="function"&&toast(`Imported "${v.title}" as ${d.voice_id}`,"success"),typeof loadVoiceLibrary=="function"&&loadVoiceLibrary().catch(()=>{})}catch(e){btn.disabled=!1,btn.innerHTML=orig,typeof toast=="function"&&toast("Import failed: "+e.message,"error")}}(_a2=$2("fa-fetch"))==null||_a2.addEventListener("click",()=>{_page=1,browse()}),(_b2=$2("fa-search"))==null||_b2.addEventListener("keydown",e=>{e.key==="Enter"&&(_page=1,browse())}),(_c2=$2("fa-tag"))==null||_c2.addEventListener("keydown",e=>{e.key==="Enter"&&(_page=1,browse(),applyFilters())}),(_d2=$2("fa-lang"))==null||_d2.addEventListener("change",()=>{_page=1,browse()}),(_e2=$2("fa-sort"))==null||_e2.addEventListener("change",()=>{_page=1,browse()}),(_f2=$2("fa-prev"))==null||_f2.addEventListener("click",()=>{_page>1&&(_page--,browse())}),(_g2=$2("fa-next"))==null||_g2.addEventListener("click",()=>{_page++,browse()});const pop=$2("fa-filter-pop");function positionPop(){const btn=$2("fa-filter-btn");if(!pop||!btn||pop.hidden)return;const r=btn.getBoundingClientRect(),w=pop.offsetWidth||320,margin=8;let left=Math.min(r.right-w,window.innerWidth-w-margin);left{e.stopPropagation(),openPop()}),(_i2=$2("fa-filter-close"))==null||_i2.addEventListener("click",e=>{e.stopPropagation(),openPop(!1)}),pop==null||pop.addEventListener("click",e=>e.stopPropagation()),document.addEventListener("click",()=>openPop(!1)),document.addEventListener("keydown",e=>{e.key==="Escape"&&openPop(!1)}),window.addEventListener("resize",positionPop),window.addEventListener("scroll",positionPop,!0),document.querySelectorAll("#fa-filter-pop .fa-pchip").forEach(chip=>{chip.addEventListener("click",()=>{const wrap=chip.closest(".fa-pchips");wrap.classList.contains("fa-pchips-multi")?chip.classList.toggle("active"):(wrap.querySelectorAll(".fa-pchip").forEach(c=>c.classList.remove("active")),chip.classList.add("active")),updateFilterBadge()})}),(_j2=$2("fa-tag"))==null||_j2.addEventListener("input",updateFilterBadge);function applyFilters(){_page=1,openPop(!1),browse()}(_k2=$2("fa-filter-apply"))==null||_k2.addEventListener("click",applyFilters),(_l2=$2("fa-filter-reset"))==null||_l2.addEventListener("click",()=>{document.querySelectorAll('#fa-filter-pop .fa-pchips[data-group="tag"] .fa-pchip').forEach(c=>c.classList.remove("active")),["gender","age"].forEach(g=>{const wrap=document.querySelector(`#fa-filter-pop .fa-pchips[data-group="${g}"]`);wrap==null||wrap.querySelectorAll(".fa-pchip").forEach((c,i)=>c.classList.toggle("active",i===0))}),$2("fa-tag")&&($2("fa-tag").value=""),updateFilterBadge(),applyFilters()})}(),function(){const tabs=document.getElementById("gvo-tabs");if(!tabs)return;const map={direct:"tab-getvoices",fish:"fa-browser-card",eleven:"el-browser-card"};function show(src){map[src]||(src="direct"),Object.entries(map).forEach(([k,id])=>{const el=document.getElementById(id);el&&(k===src?el.style.removeProperty("display"):el.style.setProperty("display","none","important"))}),tabs.querySelectorAll(".gvo-tab").forEach(t=>t.classList.toggle("active",t.dataset.src===src));try{localStorage.setItem("gvo-src",src)}catch{}}tabs.querySelectorAll(".gvo-tab").forEach(t=>t.addEventListener("click",()=>show(t.dataset.src)));let saved="direct";try{saved=localStorage.getItem("gvo-src")||"direct"}catch{}show(saved)}(),function(){const KEY="ttsvc_voice_library_cache_v2";let inFlight=null;function read(){try{const value=JSON.parse(sessionStorage.getItem(KEY)||"null");return!value||!Array.isArray(value.voices)||!Number.isFinite(value.savedAt)?null:Date.now()-value.savedAt<=3e4?value.voices:null}catch{return null}}function write(voices){try{sessionStorage.setItem(KEY,JSON.stringify({savedAt:Date.now(),voices}))}catch{}}function invalidate(){try{sessionStorage.removeItem(KEY)}catch{}}async function fetchVoices(options={}){const refresh=options===!0||options.refresh===!0;if(!refresh){const cached=read();if(cached)return cached}return inFlight||(inFlight=fetch("/api/voices"+(refresh?"?refresh=1":""),{cache:"no-store"}).then(async response=>{if(!response.ok){const error=await response.json().catch(()=>({}));throw new Error(error.detail||response.statusText)}const voices=await response.json();if(!Array.isArray(voices))throw new Error("Voice library response was not an array");return write(voices),voices}).finally(()=>{inFlight=null}),inFlight)}window.fetchVoiceLibrary=fetchVoices,window.invalidateVoiceLibraryCache=invalidate}(),function(){const list=document.getElementById("background-jobs-list"),refresh=document.getElementById("background-jobs-refresh"),diagnostics=document.getElementById("resource-diagnostics"),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=!1,diagnosticsLoading=!1;function node(value,fallback="\u2014"){const item=document.createElement("span");return item.textContent=value==null||value===""?fallback:String(value),item}function statusClass(job){return job.cancelled||job.stage==="cancelled"?"is-cancelled":job.error||job.stage==="error"||job.stage==="failed"?"is-error":job.done?"is-done":"is-active"}function statusLabel(job){return job.cancelled||job.stage==="cancelled"?"Cancelled":job.error||job.stage==="error"||job.stage==="failed"?"Failed":job.done?"Completed":job.stage==="queued"?"Queued":"Running"}function cancelEndpoint(kind,id){const paths={transcription:"/api/transcribe/cancel/",voice_design:"/api/voice-design/cancel/",casting:"/api/match-characters-voices/cancel/",consistent_images:"/api/character-generate-consistent-images/cancel/"};return paths[kind]?paths[kind]+encodeURIComponent(id):""}function retryEndpoint(kind,id){const paths={transcription:"/api/transcribe/retry/",voice_design:"/api/voice-design/retry/",casting:"/api/match-characters-voices/retry/",consistent_images:"/api/character-generate-consistent-images/retry/"};return paths[kind]&&id?paths[kind]+encodeURIComponent(id):""}function render(jobs){if(list.replaceChildren(),!jobs.length){const empty=node("No background jobs yet. Start a transcription, voice design, cast, or image workflow to see it here.");empty.className="perf-history-empty",list.appendChild(empty);return}jobs.forEach(job=>{const row=document.createElement("div");row.className="background-job-row "+statusClass(job);const main=document.createElement("div");main.className="background-job-main";const title=document.createElement("strong");title.textContent=labels[job.kind]||job.kind||"Background job",main.appendChild(title);const id=document.createElement("code");id.textContent=String(job.job_id||"").slice(0,12),main.appendChild(id);const detail=node(job.error||job.detail||job.stage);detail.className="background-job-detail",main.appendChild(detail);const meta=node(`${statusLabel(job)} \xB7 ${Number(job.elapsed||0).toFixed(1)}s`);meta.className="background-job-meta",main.appendChild(meta);const storedResult=job.result&&typeof job.result=="object"?job.result:{},artifactList=[];storedResult.artifact&&artifactList.push(storedResult.artifact),Array.isArray(storedResult.artifacts)&&artifactList.push(...storedResult.artifacts),artifactList.filter(item=>item&&typeof item.url=="string"&&item.url.startsWith("/api/jobs/")).forEach(item=>{const link=document.createElement("a");link.className="background-job-artifact",link.href=item.url,link.target="_blank",link.rel="noopener",link.textContent=`Download ${item.kind||"result"}`,main.appendChild(link)}),row.appendChild(main);const actions=document.createElement("div");if(actions.className="background-job-actions",job.done&&job.error&&job.retry_available){const button=document.createElement("button");button.type="button",button.className="btn-secondary btn-sm",button.textContent="Retry",button.addEventListener("click",async()=>{button.disabled=!0;try{const response=await fetch(retryEndpoint(job.kind,job.job_id),{method:"POST"});if(!response.ok){const detail2=await response.json().catch(()=>({}));throw new Error(detail2.detail||`HTTP ${response.status}`)}await load()}catch(error){button.disabled=!1,typeof toast=="function"&&toast("Could not retry job: "+error.message,"error")}}),actions.appendChild(button)}if(!job.done&&!job.cancelled&&job.job_id){const endpoint=cancelEndpoint(job.kind,job.job_id);if(endpoint){const button=document.createElement("button");button.type="button",button.className="btn-secondary btn-sm",button.textContent="Cancel",button.addEventListener("click",async()=>{button.disabled=!0;try{const response=await fetch(endpoint,{method:"POST"});if(!response.ok)throw new Error(`HTTP ${response.status}`);await load()}catch(error){button.disabled=!1,typeof toast=="function"&&toast("Could not cancel job: "+error.message,"error")}}),actions.appendChild(button)}}const note=node("Reopen source to retry");note.className="background-job-retry-note",note.title="Inputs are not retained for replay, by design.",(!job.done||!job.error||!job.retry_available)&&actions.appendChild(note),row.appendChild(actions),list.appendChild(row)})}async function load(){if(!loading){loading=!0,refresh.disabled=!0;try{const response=await fetch("/api/jobs?limit=50",{cache:"no-store"});if(!response.ok)throw new Error(`HTTP ${response.status}`);const payload=await response.json();render(Array.isArray(payload.jobs)?payload.jobs:[])}catch(error){list.replaceChildren();const empty=node("Background job history unavailable: "+error.message);empty.className="perf-history-empty",list.appendChild(empty)}finally{loading=!1,refresh.disabled=!1}}}function formatBytes(value){const bytes=Number(value);return Number.isFinite(bytes)?bytes<1024*1024?`${(bytes/1024).toFixed(1)} KiB`:`${(bytes/1024/1024).toFixed(1)} MiB`:"\u2014"}async function loadDiagnostics(){var _a2,_b2,_c2,_d2,_e2,_f2;if(!(!diagnostics||diagnosticsLoading)){diagnosticsLoading=!0,diagnosticsRefresh&&(diagnosticsRefresh.disabled=!0);try{const response=await fetch("/api/diagnostics",{cache:"no-store"});if(!response.ok)throw new Error(`HTTP ${response.status}`);const server=await response.json(),browser=await new Promise(resolve=>requestAnimationFrame(()=>{var _a3,_b3,_c3,_d3,_e3,_f3,_g2,_h2;return resolve({domNodes:document.getElementsByTagName("*").length,audioElements:document.querySelectorAll("audio").length,jsHeapBytes:((_a3=performance.memory)==null?void 0:_a3.usedJSHeapSize)||null,imageCacheEntries:(_e3=(_d3=(_c3=(_b3=window.TTVC)==null?void 0:_b3.imageCache)==null?void 0:_c3.size)==null?void 0:_d3.call(_c3))!=null?_e3:null,rehearserAudioEntries:(_h2=(_g2=(_f3=window.rehState)==null?void 0:_f3.synthCache)==null?void 0:_g2.size)!=null?_h2:null,voiceRecords:Array.isArray(window._voices)?window._voices.length:null})}));diagnostics.replaceChildren(),[["Server RSS",`${server.server_rss_mb} MiB`],["Config data",formatBytes((_a2=server.config_disk)==null?void 0:_a2.used_bytes)],["Config free",formatBytes((_b2=server.config_disk)==null?void 0:_b2.free_bytes)],["Temp data",formatBytes((_c2=server.temp_disk)==null?void 0:_c2.used_bytes)],["Container memory",`${formatBytes((_d2=server.container_memory)==null?void 0:_d2.current_bytes)} / ${formatBytes((_e2=server.container_memory)==null?void 0:_e2.max_bytes)}`],["Artifact cache",formatBytes((_f2=server.cache_storage)==null?void 0:_f2.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";const name=document.createElement("span");name.textContent=label;const valueNode=document.createElement("strong");valueNode.textContent=value==null?"\u2014":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} \xB7 ${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)}finally{diagnosticsLoading=!1,diagnosticsRefresh&&(diagnosticsRefresh.disabled=!1)}}}refresh.addEventListener("click",load),diagnosticsRefresh==null||diagnosticsRefresh.addEventListener("click",loadDiagnostics),load(),loadDiagnostics(),setInterval(()=>{var _a2;(_a2=document.getElementById("s-performance"))!=null&&_a2.classList.contains("is-active")&&(load(),loadDiagnostics())},1e4)}(),function(){"use strict";var root=window.TTVC=window.TTVC||{},entries=new Map,visible=new Set,observer="IntersectionObserver"in window?new IntersectionObserver(function(records){records.forEach(function(record){var img=record.target;if(record.isIntersecting){visible.add(img);var source=img.dataset.ttvcSrc;source&&!img.getAttribute("src")&&img.setAttribute("src",source),entries.delete(img),entries.set(img,Date.now())}else visible.delete(img)}),evict()},{rootMargin:"160px"}):null;function limit(){return typeof window.ttvcIsLowMemoryDevice=="function"&&window.ttvcIsLowMemoryDevice()?24:64}function evict(){for(entries.forEach(function(_,img2){img2.isConnected||entries.delete(img2)});entries.size>limit();){var candidate=null;for(var img of entries.keys())if(!visible.has(img)){candidate=img;break}if(!candidate)break;entries.delete(candidate),candidate.getAttribute("src")&&(candidate.dataset.ttvcSrc=candidate.getAttribute("src")),candidate.removeAttribute("src")}}function observeImages(container){container&&(container.querySelectorAll("img").forEach(function(img){!img.dataset.ttvcSrc&&img.getAttribute("src")&&(img.dataset.ttvcSrc=img.getAttribute("src")),observer&&!img.dataset.ttvcObserved&&(img.dataset.ttvcObserved="1",observer.observe(img)),entries.delete(img),entries.set(img,Date.now())}),evict())}root.imageCache={observe:observeImages,evict,size:function(){return entries.size}},window.ttvcObserveImages=observeImages}();function cleanBaseUrl(url){return String(url||"").trim().replace(/\/+$/,"")}function getTtsBaseUrl(){var _a2;return cleanBaseUrl((_a2=$("s-tts-url"))==null?void 0:_a2.value)||"http://localhost:8020"}function getTtsV1Url(){const base=getTtsBaseUrl();return base.endsWith("/v1")?base:base+"/v1"}function getTtsStreamBaseUrl(){var _a2;return cleanBaseUrl(((_a2=$("s-tts-stream-url"))==null?void 0:_a2.value)||_appSettings.tts_stream_url)||"http://localhost:8023"}function getTtsStreamV1Url(){const base=getTtsStreamBaseUrl();return base.endsWith("/v1")?base:base+"/v1"}function getCreatorV1Url(){const loc=window.location,protocol=loc.protocol||"http:",port=loc.port?":"+loc.port:"",host=loc.hostname==="0.0.0.0"?"localhost":loc.hostname;return`${protocol}//${host}${port}/v1`}function updateCreatorUrlHints(){const warning=$("routing-url-warning"),badBindHost=window.location.hostname==="0.0.0.0";warning&&warning.classList.toggle("show",badBindHost)}function activeVoiceIds(){return(_voices||[]).filter(v=>v.enabled!==!1).slice().sort((a,b)=>a.id.localeCompare(b.id)).map(v=>v.id)}function integrationVoiceExample(){return activeVoiceIds()[0]||"EN_F_ExampleVoice"}function integrationVoiceList(){const ids=activeVoiceIds();return ids.length?ids.join(", "):"EN_F_ExampleVoice, DE_M_ExampleVoice"}function virtualDesignVoiceIds(){return Object.keys(loadDesignPresets?loadDesignPresets():{}).sort((a,b)=>a.localeCompare(b)).map(name=>"vd_"+(typeof _umlautSafe=="function"?_umlautSafe(name):name).replace(/[^A-Za-z0-9_.-]+/g,"_").replace(/^_+|_+$/g,""))}function renderIntegrationSnippets(){if(!$("snippet-sillytavern"))return;const base=getTtsBaseUrl(),v1=getTtsV1Url(),streamV1=getTtsStreamV1Url(),proxyV1=getCreatorV1Url(),proxyBase=proxyV1.replace(/\/v1$/,"");updateCreatorUrlHints();const voice=integrationVoiceExample(),voices=integrationVoiceList(),vdVoices=virtualDesignVoiceIds(),vdVoice=vdVoices[0]||"vd_EN_F_Warm_Narrator";$("integration-url-label").textContent="TTS backend: "+base,$("snippet-sillytavern").textContent=`Provider: OpenAI compatible TTS API base URL: ${v1} API key: dummy Model: qwen3-tts diff --git a/static/js/jobs.js b/static/js/jobs.js index b75b9f5..02d8c05 100644 --- a/static/js/jobs.js +++ b/static/js/jobs.js @@ -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); diff --git a/static/style.css b/static/style.css index 5b39f0f..531237d 100644 --- a/static/style.css +++ b/static/style.css @@ -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; } diff --git a/tests/smoke.spec.js b/tests/smoke.spec.js index 1578988..cf14da5 100644 --- a/tests/smoke.spec.js +++ b/tests/smoke.spec.js @@ -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'); +});