Files
Athena-Deck/dashboard-ui.js
T

235 lines
32 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const DashboardUI=(()=>{
let host=null,root=null,timers=[],controller=null;
const esc=v=>String(v??'–').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const pct=n=>n==null?'–':`${Number(n).toFixed(1)}%`;
const gib=b=>b==null?'–':`${(Number(b)/1073741824).toFixed(1)} GiB`;
const dur=s=>{if(s==null)return'–';let d=Math.floor(s/86400),h=Math.floor(s%86400/3600),m=Math.floor(s%3600/60);return d?`${d}d ${h}h`:`${h}h ${m}m`};
const $=id=>root.querySelector(`#${id}`);
const imagePhaseLabel=p=>({'running':'Bildauftrag läuft','loading':'Bildmodell lädt','complete':'bereit','failed':'Fehler'})[p]||p||'inaktiv';
function cleanup(){for(const timer of timers)clearInterval(timer);timers=[];controller?.abort();controller=null;host=null;root=null}
function gpuCard(g){let total=g.memory_total_mib||0,used=g.memory_used_mib||0,p=total&&g.memory_used_mib!=null?used/total*100:0,load=Math.max(0,Math.min(100,g.gpu_percent||0)),memoryText=total&&g.memory_used_mib!=null?`${(used/1024).toFixed(1)} / ${(total/1024).toFixed(1)} GiB`:'–';return `<article class="card span6"><div class="gpu-title"><div><div class="label">GPU ${esc(g.index)}</div><div class="value">${esc(g.name)}</div></div><span class="badge">${esc(g.pstate)}</span></div><div class="metrics"><div class="metric"><b>${pct(g.gpu_percent)}</b><span>GPU-Kern</span></div><div class="metric"><b>${memoryText}</b><span>VRAM</span></div><div class="metric"><b>${esc(g.temperature_c)} °C</b><span>Temperatur</span></div><div class="metric"><b>${esc(g.power_w)} / ${esc(g.power_limit_w)} W</b><span>Leistung</span></div><div class="metric"><b>${esc(g.memory_controller_percent)} %</b><span>Speichercontroller</span></div><div class="metric"><b>${esc(g.graphics_clock_mhz)} MHz</b><span>GPU-Takt</span></div><div class="metric"><b>${esc(g.memory_clock_mhz)} MHz</b><span>Speichertakt</span></div><div class="metric"><b>${esc(g.fan_percent)} %</b><span>Lüfter</span></div></div><div class="bar-label"><span>GPU-Auslastung</span><span>${pct(load)}</span></div><div class="bar"><div class="fill gpu-load" style="width:${load}%"></div></div><div class="bar-label"><span>VRAM</span><span>${pct(p)}</span></div><div class="bar"><div class="fill" style="width:${p}%"></div></div></article>`}
function mount(){
historyColors=document.documentElement.dataset.theme==='athena-classic'?['#45d7ff','#ffc65c','#66e3a4','#bda7ff']:['#c5edaf','#ffb454','#7fc8b6','#c39bff'];
controller=new AbortController();
root=host;
root.innerHTML=`<div class="dashboard-root"><div class="top"><div><div class="kicker">ATHENA / TELEMETRIE</div><h1>Dashboard</h1><p>System, Inferenz und Verlauf auf einen Blick.</p></div><div class="live"><span class="dot" id="dot"></span><span id="updated">verbinde …</span></div></div><section class="grid"> <article class="card span3"><div class="label">Aktives Profil</div><div class="value" id="profile">–</div><div class="sub" id="profileSub">Deck wird abgefragt</div></article>
<article class="card span3"><div class="label">Modell</div><div class="value" id="model">–</div><div class="sub" id="modelSub">–</div></article>
<article class="card span3"><div class="label">CPU</div><div class="value" id="cpu">–</div><div class="bar"><div class="fill" id="cpuBar"></div></div><div class="sub" id="load">–</div></article>
<article class="card span3"><div class="label">System-RAM</div><div class="value" id="ram">–</div><div class="bar"><div class="fill" id="ramBar"></div></div><div class="sub" id="ramSub">–</div></article>
<div id="gpuCards" class="span12 grid"></div>
<div class="section-title">Inferenz · Live</div>
<article class="card span3"><div class="label">Generierung</div><div class="value" id="generationRate">–</div><div class="sub" id="generationSub">Token pro Sekunde</div></article>
<article class="card span3"><div class="label">Prompt-Einlesen</div><div class="value" id="promptRate">–</div><div class="sub" id="promptSub">Token pro Sekunde</div></article>
<article class="card span3"><div class="label">Prompt-Cache</div><div class="value" id="cacheHit">–</div><div class="bar"><div class="fill" id="cacheBar"></div></div><div class="sub" id="cacheSub">–</div></article>
<article class="card span3"><div class="label">Anfragen</div><div class="value" id="requestState">–</div><div class="sub" id="requestSub">–</div></article>
<article class="card span12"><div class="row"><div><div class="label">Slots und Kontextfenster</div><div class="sub">Aktuelle Belegung und Verlauf des genutzten Kontextfensters</div></div><div class="history-controls" id="slotHistoryRanges"><button data-range="1h">1 h</button><button data-range="24h" class="active">24 h</button><button data-range="7d">7 Tage</button><button data-range="21d">21 Tage</button><button data-range="all">Gesamt</button></div></div><div class="slot-list" id="slotCards"><div class="sub">Telemetrie wird geladen …</div></div><div class="chart-legend" id="slotLegend"></div><canvas class="chart" id="slotHistoryChart"></canvas><div class="history-note" id="slotHistoryNote">Historie wird geladen …</div></article>
<article class="card span4"><div class="label">MTP / Speculative Decoding</div><div class="value" id="mtpAcceptance">–</div><div class="bar"><div class="fill gpu-load" id="mtpBar"></div></div><div class="metrics"><div class="metric"><b id="mtpDrafted">–</b><span>Draft-Token</span></div><div class="metric"><b id="mtpAccepted">–</b><span>akzeptiert</span></div><div class="metric"><b id="mtpSteps">–</b><span>Prüfschritte</span></div><div class="metric"><b id="mtpDepth">–</b><span>Draft-Tiefe</span></div></div></article>
<article class="card span4"><div class="label">Tokenzähler seit Modellstart</div><div class="metrics" id="counters"></div></article>
<article class="card span4"><div class="label">Modell-Eigenschaften</div><div class="metrics" id="modelDetails"></div></article>
<div class="section-title">Langzeitstatistik</div>
<article class="card span4"><div class="label">Eingabe-Tokens gesamt</div><div class="token-total" id="historyInput">–</div><div class="sub" id="historyInputSub">neu + Prompt-Cache</div></article>
<article class="card span4"><div class="label">Ausgabe-Tokens gesamt</div><div class="token-total" id="historyOutput">–</div><div class="sub" id="historyOutputSub">seit Beginn der Aufzeichnung</div></article>
<article class="card span4"><div class="label">Historie</div><div class="token-total" id="historyStorage">–</div><div class="sub">21 Tage detailliert, danach Stundenwerte</div></article>
<article class="card span12"><div class="row"><div><div class="label">GPU-Verlauf</div><div class="sub">Auslastung und Temperatur beider Karten</div></div><div class="history-controls" id="historyRanges"><button data-range="1h">1 h</button><button data-range="24h" class="active">24 h</button><button data-range="7d">7 Tage</button><button data-range="21d">21 Tage</button><button data-range="all">Gesamt</button></div></div><div class="chart-legend" id="gpuLegend"></div><canvas class="chart" id="gpuHistoryChart"></canvas><div class="history-note" id="historyNote">Historie wird geladen …</div></article>
<article class="card span12"><div class="row"><div><div class="label">Nutzung nach Profil und Modell</div><div class="sub">Prozentanteil im oben gewählten Zeitraum</div></div><div class="history-controls" id="usageModes"><button data-mode="total" class="active">Gesamte Tokenarbeit</button><button data-mode="output">Nur Ausgabe</button></div></div><div class="usage-list" id="profileUsage"><div class="sub">Nutzungsverteilung wird geladen …</div></div></article>
<article class="card span12"><div class="label">Dauerhafte Modellwechsel</div><table><thead><tr><th>Zeit</th><th>Profil</th><th>Modell</th></tr></thead><tbody id="historyEvents"><tr><td colspan="3">Noch keine Wechsel aufgezeichnet</td></tr></tbody></table></article>
<div class="section-title">Deck · Profile · Dienste</div>
<article class="card span6"><div class="label">Inferenz</div><div class="value status" id="availability">–</div><div class="metrics"><div class="metric"><b id="activeChats">–</b><span>aktive Anfragen</span></div><div class="metric"><b id="routerUptime">–</b><span>Deck-Uptime</span></div><div class="metric"><b id="switching">–</b><span>Profilwechsel</span></div><div class="metric"><b id="dataDisk">–</b><span>/data belegt</span></div></div></article>
<article class="card span6"><div class="label">Verfügbare Profile</div><div class="profiles" id="profiles"></div></article>
<article class="card span6"><div class="label">Zusatzdienste</div><div class="metrics" id="services"></div></article>
<article class="card span6"><div class="label">Netzwerk und Host</div><div class="metrics" id="hostMetrics"></div></article>
<div class="section-title">Hardware · Dateien · Laufzeit</div>
<article class="card span6"><div class="label">GPU-Prozesse</div><table><thead><tr><th>GPU</th><th>Prozess</th><th>PID</th><th>VRAM</th></tr></thead><tbody id="processes"><tr><td colspan="4">–</td></tr></tbody></table></article>
<article class="card span6"><div class="label">Ereignisse seit Dashboard-Start</div><div id="events"><div class="sub">Noch keine Zustandsänderung</div></div></article>
<article class="card span12"><div class="label">llama.cpp Laufzeitkonfiguration</div><div class="metrics" id="runtime"><div class="metric"><b>–</b><span>wird gelesen</span></div></div></article>
<article class="card span12"><div class="row"><div><div class="label">Verfügbare GGUF-Dateien</div><div class="sub" id="modelSummary">–</div></div></div><div class="scroll"><table><thead><tr><th>Datei</th><th>Pfad</th><th>Größe</th><th>Geändert</th></tr></thead><tbody id="modelFiles"><tr><td colspan="4">–</td></tr></tbody></table></div></article>
<div class="section-title">Notfall-Backups · herunterladen</div>
<article class="card span12"><div class="row"><div><div class="label">Verschlüsselte portable Sicherungen</div><div class="sub">Unersetzliche Daten ohne erneut ladbare Modellgewichte · maximal fünf Generationen</div></div></div><div class="scroll"><table><thead><tr><th>Erstellt</th><th>Größe</th><th>SHA256</th><th></th></tr></thead><tbody id="backupFiles"><tr><td colspan="4">Backups werden geladen …</td></tr></tbody></table></div><div class="sub" id="backupNote">Zum Wiederherstellen wird der separat verwahrte Age-Schlüssel benötigt.</div></article>
<article class="card span12 error" id="errors" hidden></article>
</section><div class="footer">Aktualisierung jede Sekunde · Steuerung über die Deck-Übersicht</div></div>`;
const deNum=n=>n==null?'–':Number(n).toLocaleString('de-DE',{maximumFractionDigits:1});
const bytesRate=n=>n==null?'–':n>=1048576?`${(n/1048576).toFixed(1)} MiB/s`:`${(n/1024).toFixed(1)} KiB/s`;
const metric=(value,label)=>`<div class="metric"><b>${esc(value)}</b><span>${esc(label)}</span></div>`;
const boolLabel=v=>v===true?'ja':v===false?'nein':'–';
const liveRateState={prompt:{tokens:null,seconds:null,value:null,seen:0},generation:{tokens:null,seconds:null,value:null,seen:0}};
function stableLiveRate(kind,tokensTotal,secondsTotal,gauge,active){
const now=Date.now(),state=liveRateState[kind],tokens=Number(tokensTotal),seconds=Number(secondsTotal),raw=Number(gauge);
let measured=Number.isFinite(raw)&&raw>0?raw:null;
if(Number.isFinite(tokens)&&Number.isFinite(seconds)&&state.tokens!=null&&tokens>=state.tokens&&seconds>state.seconds){
const derived=(tokens-state.tokens)/(seconds-state.seconds);
if(Number.isFinite(derived)&&derived>0) measured=measured??derived;
}
if(Number.isFinite(tokens)&&Number.isFinite(seconds)){
state.tokens=tokens;state.seconds=seconds;
}
if(measured!=null&&measured<100000){state.value=measured;state.seen=now;}
if(state.value!=null&&(active||now-state.seen<8000)) return {text:`${deNum(state.value)} tok/s`,fresh:now-state.seen<2500};
return {text:active?'misst …':'–',fresh:false};
}
function sharedSlotPool(slots,unified){
if(!unified||!Array.isArray(slots)||slots.length<2)return null;
const total=Math.max(0,...slots.map(s=>Number(s.n_ctx)||0));
const used=slots.reduce((sum,s)=>sum+Math.max(0,Number(s.context_used)||0),0);
return total?{total,used,free:Math.max(0,total-used)}:null;
}
function slotHtml(s,pool){
let used=Math.max(0,Number(s.context_used)||0),total=Math.max(0,Number(s.n_ctx)||0);
let available=pool?Math.min(total,used+pool.free):total;
let p=available?Math.min(100,used/available*100):0;
let state=s.processing?'arbeitet':'frei';
let capacity=pool?`${deNum(used)} / ${deNum(available)} Token aktuell möglich`:`${deNum(used)} / ${deNum(total)} Token`;
let shared=pool?`<span>Gemeinsamer Pool: ${deNum(pool.free)} Token frei</span>`:'';
return `<div class="slot"><div class="slot-head"><b>Slot ${s.id??'–'} · ${state}</b><span class="badge">${s.speculative?'MTP aktiv':'Standard'}</span></div><div class="bar-label"><span>${capacity}</span><span>${p.toFixed(1)} %</span></div><div class="bar"><div class="fill" style="width:${p}%"></div></div>${shared?`<div class="sub">${shared}</div>`:''}<div class="metrics">${metric(deNum(s.prompt_tokens),'Prompt')}${metric(deNum(s.prompt_cached),'Cache-Token')}${metric(deNum(s.decoded_tokens),'generiert')}${metric(deNum(s.remaining_generation),'Ausgabe übrig')}${metric(s.task_id??'–','Task-ID')}${metric(s.temperature??'–','Temperatur')}${metric(boolLabel(s.stream),'Streaming')}${metric(deNum(s.max_tokens),'Ausgabelimit')}</div></div>`;
}
async function refreshFull(){
try{
let response=await fetch('/api/v1/dashboard',{cache:'no-store'}); if(!response.ok) return;
let d=await response.json(),rt=d.router||{},lt=rt.llama_telemetry||{},met=lt.metrics||{},props=lt.props||{},lr=d.llama_runtime||{},cpu=d.cpu||{},net=cpu.network||{};
let running=Number(met.requests_processing??(rt.qwen||{}).active_chats??0),waiting=Number(met.requests_deferred||0),active=running>0;
let gen=stableLiveRate('generation',met.tokens_predicted_total,met.tokens_predicted_seconds_total,met.predicted_tokens_seconds,active);
let prompt=stableLiveRate('prompt',met.prompt_tokens_total,met.prompt_seconds_total,met.prompt_tokens_seconds,active);
$('generationRate').textContent=gen.text;
let genAvg=met.tokens_predicted_seconds_total?met.tokens_predicted_total/met.tokens_predicted_seconds_total:null;
$('generationSub').textContent=active&&!gen.fresh?'Anfrage läuft · letzter Messwert':(genAvg==null?'Aktuelle llama.cpp-Messung':`Gesamtdurchschnitt ${deNum(genAvg)} tok/s`);
$('promptRate').textContent=prompt.text;
let promptAvg=met.prompt_seconds_total?met.prompt_tokens_total/met.prompt_seconds_total:null;
$('promptSub').textContent=active&&!prompt.fresh?'Prompt wird verarbeitet · letzter Messwert':(promptAvg==null?'Aktuelle llama.cpp-Messung':`Gesamtdurchschnitt ${deNum(promptAvg)} tok/s`);
let cached=Number(met.prompt_tokens_cached_total||0),processed=Number(met.prompt_tokens_total||0),hit=(cached+processed)?cached/(cached+processed)*100:null;
$('cacheHit').textContent=hit==null?'–':`${hit.toFixed(1)} %`;$('cacheBar').style.width=`${hit||0}%`;
$('cacheSub').textContent=hit==null?'Keine Cache-Metrik':`${deNum(cached)} wiederverwendet · ${deNum(processed)} neu`;
$('requestState').textContent=`${running} aktiv · ${waiting} wartet`;
$('requestSub').textContent=`${(lt.slots||[]).filter(s=>!s.processing).length} freie Slots`;
let slots=lt.slots||[],slotPool=sharedSlotPool(slots,lr.kv_unified===true);
$('slotCards').innerHTML=slots.map(s=>slotHtml(s,slotPool)).join('')||'<div class="sub">Slot-Telemetrie momentan nicht verfügbar</div>';
let drafted=Number(met.spec_decode_num_draft_tokens_total||0),accepted=Number(met.spec_decode_num_accepted_tokens_total||0),acceptance=drafted?accepted/drafted*100:null;
$('mtpAcceptance').textContent=acceptance==null?'–':`${acceptance.toFixed(1)} % akzeptiert`;$('mtpBar').style.width=`${acceptance||0}%`;
$('mtpDrafted').textContent=deNum(drafted);$('mtpAccepted').textContent=deNum(accepted);$('mtpSteps').textContent=deNum(met.spec_decode_num_drafts_total);$('mtpDepth').textContent=lr.mtp_draft_tokens??'–';
$('counters').innerHTML=metric(deNum(met.prompt_tokens_total),'Prompt neu')+metric(deNum(met.prompt_tokens_cached_total),'Prompt aus Cache')+metric(deNum(met.tokens_predicted_total),'generierte Token')+metric(deNum(met.n_decode_total),'Decode-Aufrufe')+metric(deNum(met.n_tokens_max),'größte Sequenz')+metric(deNum(met.n_busy_slots_per_decode),'Slots je Decode');
let modalities=Object.entries(props.modalities||{}).filter(([,v])=>v).map(([k])=>k).join(', ')||'–';
$('modelDetails').innerHTML=metric(props.model_ftype||'–','Quantisierung')+metric(props.total_slots??lr.parallel??'–','Slots')+metric(modalities,'Modalitäten')+metric(deNum(props.default_context||lr.context_size),'Kontext')+metric(props.model_alias||lr.alias||'–','Alias')+metric(lr.reasoning_budget??'–','Reasoning-Budget');
$('profiles').innerHTML=Object.entries(rt.profiles||{}).map(([name,ctx])=>`<div class="profile-item ${name===rt.current_profile?'active':''}"><b>${esc(name)}</b><span>${Number(ctx).toLocaleString('de-DE')} Token${name===rt.current_profile?' · aktiv':''}</span></div>`).join('')||'<div class="sub">Keine Profile gemeldet</div>';
let tts=rt.tts||{},stt=rt.stt||{},img=rt.image||{};
$('services').innerHTML=metric(tts.ready?'bereit':'nicht bereit',`TTS · ${tts.engine||'–'}`)+metric(tts.speaker||'–','Stimme')+metric(stt.reachable?'bereit':'aus','STT')+metric(img.worker||'–','Bild-Worker')+metric(img.model||'–','Bildmodell')+metric(img.phase==='idle'?'inaktiv':imagePhaseLabel(img.phase),'Bildstatus')+metric(img.model_loaded?'geladen':'entladen','Modellzustand')+metric(img.last_seconds==null?'–':`${deNum(img.last_seconds)} s`,'letztes Bild');
$('hostMetrics').innerHTML=metric(bytesRate(net.rx_bytes_per_second),'Netzwerk empfangen')+metric(bytesRate(net.tx_bytes_per_second),'Netzwerk gesendet')+metric(dur(cpu.host_uptime_seconds),'Host-Uptime')+metric(dur(d.dashboard_uptime_seconds),'Dashboard-Uptime')+metric((cpu.load||[]).join(' / ')||'–','Load 1/5/15')+metric(net.interfaces??'–','Interfaces');
let summary=d.model_summary||{};$('modelSummary').textContent=`${summary.count??0} Dateien · ${gib(summary.total_size||0)} gesamt`;
$('modelFiles').innerHTML=(d.models||[]).map(f=>`<tr><td>${esc(f.name)}</td><td>${esc(f.relative_path)}</td><td>${gib(f.size)}</td><td>${new Date(f.modified*1000).toLocaleString('de-DE')}</td></tr>`).join('')||'<tr><td colspan="4">Keine GGUF-Dateien im eingebundenen Modellordner</td></tr>';
$('events').innerHTML=(d.events||[]).map(e=>`<div class="event"><time>${new Date(e.timestamp*1000).toLocaleTimeString('de-DE')}</time><b>${esc(e.name)}</b>: ${esc(e.from)} → ${esc(e.to)}</div>`).join('')||'<div class="sub">Noch keine Zustandsänderung</div>';
}catch(_){/* Die bestehende Verbindungsanzeige meldet Fehler bereits sichtbar. */}
}
refreshFull();timers.push(setInterval(()=>{if(host?.isConnected)refreshFull();else cleanup()},1000));
let historyRange='24h',usageMode='total',lastProfileUsage=[];
let historyColors=['#c5edaf','#ffb454','#7fc8b6','#c39bff'];
const hiddenHistorySeries=new Set();let lastHistoryPoints=[];
const hiddenSlotSeries=new Set();let lastSlotPoints=[];
function drawHistory(points){
lastHistoryPoints=points;
const canvas=$('gpuHistoryChart'),rect=canvas.getBoundingClientRect(),ratio=window.devicePixelRatio||1;
canvas.width=Math.max(1,Math.floor(rect.width*ratio));canvas.height=Math.max(1,Math.floor(rect.height*ratio));
const x=canvas.getContext('2d');x.scale(ratio,ratio);const w=rect.width,h=rect.height,pad={l:42,r:44,t:16,b:28};
x.clearRect(0,0,w,h);x.strokeStyle='#303a37';x.fillStyle='#97a79e';x.font='11px system-ui';x.lineWidth=1;
for(let i=0;i<=4;i++){let y=pad.t+(h-pad.t-pad.b)*i/4;x.beginPath();x.moveTo(pad.l,y);x.lineTo(w-pad.r,y);x.stroke();x.fillText(`${100-i*25}%`,4,y+4);x.fillText(`${100-i*25}°`,w-pad.r+7,y+4)}
if(!points.length){x.fillText('Noch keine historischen Messwerte',pad.l+10,h/2);return}
const min=Math.min(...points.map(p=>p.ts)),max=Math.max(...points.map(p=>p.ts));
const px=t=>pad.l+(t-min)/Math.max(1,max-min)*(w-pad.l-pad.r), py=v=>pad.t+(100-Math.max(0,Math.min(100,v)))/100*(h-pad.t-pad.b);
const span=max-min,ticks=5;
for(let i=0;i<ticks;i++){
const ts=min+span*i/(ticks-1),xx=px(ts),date=new Date(ts*1000);
const label=span<=2*86400
?date.toLocaleTimeString('de-DE',{hour:'2-digit',minute:'2-digit'})
:span<=45*86400
?date.toLocaleString('de-DE',{day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'})
:date.toLocaleDateString('de-DE',{day:'2-digit',month:'2-digit',year:'2-digit'});
x.strokeStyle='#2b3532';x.beginPath();x.moveTo(xx,pad.t);x.lineTo(xx,h-pad.b);x.stroke();
x.fillStyle='#97a79e';x.textAlign=i===0?'left':i===ticks-1?'right':'center';x.fillText(label,xx,h-7);
}
x.textAlign='start';
const ids=[...new Set(points.map(p=>p.gpu_index))].sort();
$('gpuLegend').innerHTML=ids.flatMap((id,idx)=>[['load',`GPU ${id} Auslastung`,historyColors[idx*2%historyColors.length]],['temp',`GPU ${id} Temperatur`,historyColors[(idx*2+1)%historyColors.length]]].map(([kind,label,color])=>{let key=`${id}:${kind}`;return `<button data-series="${key}" class="${hiddenHistorySeries.has(key)?'off':''}" style="--series:${color}" aria-pressed="${hiddenHistorySeries.has(key)?'false':'true'}">${label}</button>`})).join('');
ids.forEach((id,idx)=>{let rows=points.filter(p=>p.gpu_index===id),load=historyColors[idx*2%historyColors.length],temp=historyColors[(idx*2+1)%historyColors.length];
[[load,'gpu_util','load'],[temp,'temperature_c','temp']].forEach(([color,key,kind])=>{if(hiddenHistorySeries.has(`${id}:${kind}`))return;x.beginPath();x.strokeStyle=color;x.lineWidth=2;let first=true;rows.forEach(p=>{if(p[key]==null)return;let xx=px(p.ts),yy=py(Number(p[key]));first?(x.moveTo(xx,yy),first=false):x.lineTo(xx,yy)});x.stroke()})});
}
function drawSlotHistory(points){
lastSlotPoints=points;
const canvas=$('slotHistoryChart'),rect=canvas.getBoundingClientRect(),ratio=window.devicePixelRatio||1;
canvas.width=Math.max(1,Math.floor(rect.width*ratio));canvas.height=Math.max(1,Math.floor(rect.height*ratio));
const x=canvas.getContext('2d');x.scale(ratio,ratio);const w=rect.width,h=rect.height,pad={l:42,r:18,t:16,b:28};
x.clearRect(0,0,w,h);x.strokeStyle='#303a37';x.fillStyle='#97a79e';x.font='11px system-ui';x.lineWidth=1;
for(let i=0;i<=4;i++){let y=pad.t+(h-pad.t-pad.b)*i/4;x.beginPath();x.moveTo(pad.l,y);x.lineTo(w-pad.r,y);x.stroke();x.fillText(`${100-i*25}%`,4,y+4)}
if(!points.length){x.fillText('Noch keine historischen Slot-Messwerte',pad.l+10,h/2);$('slotLegend').innerHTML='';return}
const min=Math.min(...points.map(p=>p.ts)),max=Math.max(...points.map(p=>p.ts));
const px=t=>pad.l+(t-min)/Math.max(1,max-min)*(w-pad.l-pad.r),py=v=>pad.t+(100-Math.max(0,Math.min(100,v)))/100*(h-pad.t-pad.b);
const span=max-min,ticks=5;
for(let i=0;i<ticks;i++){
const ts=min+span*i/(ticks-1),xx=px(ts),date=new Date(ts*1000);
const label=span<=2*86400?date.toLocaleTimeString('de-DE',{hour:'2-digit',minute:'2-digit'}):span<=45*86400?date.toLocaleString('de-DE',{day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'}):date.toLocaleDateString('de-DE',{day:'2-digit',month:'2-digit',year:'2-digit'});
x.strokeStyle='#2b3532';x.beginPath();x.moveTo(xx,pad.t);x.lineTo(xx,h-pad.b);x.stroke();x.fillStyle='#97a79e';x.textAlign=i===0?'left':i===ticks-1?'right':'center';x.fillText(label,xx,h-7);
}
x.textAlign='start';const ids=[...new Set(points.map(p=>p.slot_id))].sort((a,b)=>a-b);
$('slotLegend').innerHTML=ids.map((id,idx)=>{let color=historyColors[idx%historyColors.length],key=String(id);return `<button data-slot-series="${key}" class="${hiddenSlotSeries.has(key)?'off':''}" style="--series:${color}" aria-pressed="${hiddenSlotSeries.has(key)?'false':'true'}">Slot ${id} · Kontext</button>`}).join('');
ids.forEach((id,idx)=>{let key=String(id);if(hiddenSlotSeries.has(key))return;let rows=points.filter(p=>p.slot_id===id);x.beginPath();x.strokeStyle=historyColors[idx%historyColors.length];x.lineWidth=2;let first=true;rows.forEach(p=>{if(p.context_percent==null)return;let xx=px(p.ts),yy=py(Number(p.context_percent));first?(x.moveTo(xx,yy),first=false):x.lineTo(xx,yy)});x.stroke()});
}
async function refreshHistory(){try{let r=await fetch(`/api/v1/dashboard/history?range=${historyRange}`,{cache:'no-store'});if(!r.ok)throw Error(`HTTP ${r.status}`);let d=await r.json(),t=d.token_totals||{},input=Number(t.prompt_tokens||0)+Number(t.cached_tokens||0);$('historyInput').textContent=deNum(input);$('historyInputSub').textContent=`${deNum(t.prompt_tokens)} neu · ${deNum(t.cached_tokens)} aus Cache`;$('historyOutput').textContent=deNum(t.output_tokens||0);$('historyStorage').textContent=`${deNum((d.storage||{}).rows||0)} Messpunkte`;drawHistory(d.points||[]);drawSlotHistory(d.slot_points||[]);renderProfileUsage(d.profile_usage||[]);let note=`Bereich ${d.range} · Messung alle ${d.sample_interval_seconds}s · Detaildaten ${d.detail_retention_days} Tage`;$('historyNote').textContent=note;$('slotHistoryNote').textContent=`${note} · ${deNum((d.slot_storage||{}).rows||0)} Slot-Messpunkte`;$('historyEvents').innerHTML=(d.model_events||[]).slice(0,15).map(e=>`<tr><td>${new Date(e.ts*1000).toLocaleString('de-DE')}</td><td>${esc(e.previous_profile||'–')} → ${esc(e.profile||'–')}</td><td>${esc(e.previous_model||'–')} → ${esc(e.model||'–')}</td></tr>`).join('')||'<tr><td colspan="3">Noch keine Wechsel aufgezeichnet</td></tr>'}catch(e){$('historyNote').textContent=`Historie nicht verfügbar: ${e.message}`;$('slotHistoryNote').textContent=`Historie nicht verfügbar: ${e.message}`}}
function renderProfileUsage(rows){lastProfileUsage=rows;let value=r=>usageMode==='output'?Number(r.output_tokens||0):Number(r.prompt_tokens||0)+Number(r.cached_tokens||0)+Number(r.output_tokens||0),sum=rows.reduce((n,r)=>n+value(r),0);$('profileUsage').innerHTML=rows.map((r,i)=>{let v=value(r),p=sum?v/sum*100:0,input=Number(r.prompt_tokens||0)+Number(r.cached_tokens||0);return `<div class="usage-row"><div class="usage-head"><b>${esc(r.profile||'unbekannt')} <span>· ${esc(r.model||'–')}</span></b><strong>${p.toFixed(1)} %</strong></div><div class="bar"><div class="fill" style="width:${p}%;background:${historyColors[i%historyColors.length]}"></div></div><div class="usage-meta"><span>${deNum(input)} Eingabe · ${deNum(r.output_tokens||0)} Ausgabe</span><span>${deNum(v)} gewertet</span></div></div>`}).join('')||'<div class="sub">In diesem Zeitraum wurden noch keine Token aufgezeichnet.</div>'}
$('usageModes').addEventListener('click',e=>{let b=e.target.closest('button[data-mode]');if(!b)return;usageMode=b.dataset.mode;root.querySelectorAll('#usageModes button').forEach(x=>x.classList.toggle('active',x===b));renderProfileUsage(lastProfileUsage)});
$('historyRanges').addEventListener('click',e=>setHistoryRange(e));
$('slotHistoryRanges').addEventListener('click',e=>setHistoryRange(e));
function setHistoryRange(e){let b=e.target.closest('button[data-range]');if(!b)return;historyRange=b.dataset.range;root.querySelectorAll('#historyRanges button,#slotHistoryRanges button').forEach(x=>x.classList.toggle('active',x.dataset.range===historyRange));refreshHistory()}
$('gpuLegend').addEventListener('click',e=>{let b=e.target.closest('button[data-series]');if(!b)return;let key=b.dataset.series;hiddenHistorySeries.has(key)?hiddenHistorySeries.delete(key):hiddenHistorySeries.add(key);drawHistory(lastHistoryPoints)});
$('slotLegend').addEventListener('click',e=>{let b=e.target.closest('button[data-slot-series]');if(!b)return;let key=b.dataset.slotSeries;hiddenSlotSeries.has(key)?hiddenSlotSeries.delete(key):hiddenSlotSeries.add(key);drawSlotHistory(lastSlotPoints)});
window.addEventListener('resize',()=>{if(host?.isConnected){drawHistory(lastHistoryPoints);drawSlotHistory(lastSlotPoints)}},{signal:controller.signal});refreshHistory();timers.push(setInterval(()=>{if(host?.isConnected)refreshHistory();else cleanup()},15000));
async function refreshSummary(){
try{
const response=await fetch('/api/v1/dashboard',{cache:'no-store'});
if(!response.ok)throw Error(`HTTP ${response.status}`);
const d=await response.json(),c=d.cpu||{},m=c.memory||{},rt=d.router||{},up=rt.upstream||{},q=rt.qwen||{},lr=d.llama_runtime||{},img=rt.image||{};
const imageActive=img.phase&&img.phase!=='idle';
$('profile').textContent=imageActive?'Bildgenerierung':(rt.current_profile||'nicht geladen');
$('profileSub').textContent=imageActive?'Bildauftrag läuft':(rt.switching?'Profilwechsel läuft':`Kontext: ${up.ctx?Number(up.ctx).toLocaleString('de-DE'):'–'} Token`);
$('model').textContent=imageActive?(img.model||'Bildmodell'):(up.model||'–');
$('modelSub').textContent=imageActive?(img.model_loaded?'geladen':'wird vorbereitet'):(lr.model_file||(up.reachable?'llama.cpp erreichbar':'llama.cpp nicht geladen'));
$('cpu').textContent=pct(c.usage_percent);$('cpuBar').style.width=`${c.usage_percent||0}%`;
$('load').textContent=`${c.logical_cpus||'–'} Threads · Load ${(c.load||[]).join(' / ')}`;
const rp=m.total?m.used/m.total*100:0;$('ram').textContent=pct(m.total?rp:null);$('ramBar').style.width=`${rp}%`;$('ramSub').textContent=`${gib(m.used)} / ${gib(m.total)}`;
$('gpuCards').innerHTML=(d.gpus||[]).map(gpuCard).join('')||'<article class="card span12 error">Keine GPU-Daten verfügbar</article>';
$('availability').textContent=imageActive?'Bildauftrag läuft':(q.available?'bereit':'nicht geladen');
$('availability').className=`value status ${q.available||imageActive?'':'bad'}`;
$('activeChats').textContent=q.active_chats??'–';$('routerUptime').textContent=dur(rt.uptime_seconds);
$('switching').textContent=rt.switching||'nein';
const disk=c.disk_data||{};$('dataDisk').textContent=pct(disk.total?100*disk.used/disk.total:null);
$('processes').innerHTML=(d.gpu_processes||[]).map(p=>`<tr><td>${esc((d.gpus||[]).find(g=>g.uuid===p.gpu_uuid)?.index)}</td><td>${esc(p.name)}</td><td>${esc(p.pid)}</td><td>${esc(p.memory_mib)} MiB</td></tr>`).join('')||'<tr><td colspan="4">Keine Compute-Prozesse gemeldet</td></tr>';
const runtime=[['Modell-Datei',lr.model_file],['PID',lr.pid],['Kontext',lr.context_size?Number(lr.context_size).toLocaleString('de-DE'):'–'],['Batch / µBatch',`${lr.batch_size??'–'} / ${lr.ubatch_size??'–'}`],['Parallel',lr.parallel],['Threads',`${lr.threads??'–'} / ${lr.threads_batch??'–'}`],['Geräte',lr.device],['Tensor-Split',lr.tensor_split],['KV-Cache',`${lr.cache_k??'–'} / ${lr.cache_v??'–'}`],['Flash Attention',lr.flash_attention==null?'–':lr.flash_attention?'an':'aus'],['Prompt-Cache',lr.prompt_cache==null?'–':lr.prompt_cache?'an':'aus'],['MTP Draft',lr.mtp_draft_tokens]];
$('runtime').innerHTML=runtime.map(([k,v])=>metric(v,k)).join('');
const errors=Object.entries(d.errors||{}).filter(([,v])=>v);
$('errors').hidden=!errors.length;$('errors').textContent=errors.map(([k,v])=>`${k}: ${v}`).join('\n');
$('updated').textContent=`Live · ${new Date(d.timestamp*1000).toLocaleTimeString('de-DE')}`;$('dot').style.background='var(--green)';
}catch(e){if(!host?.isConnected)return;$('updated').textContent=`Verbindung gestört: ${e.message}`;$('dot').style.background='var(--red)'}
}
async function refreshBackups(){
try{
const response=await fetch('/api/v1/dashboard/backups',{cache:'no-store'});
if(!response.ok)throw Error(`HTTP ${response.status}`);
const d=await response.json(),rows=d.backups||[];
$('backupFiles').innerHTML=rows.map(b=>`<tr><td>${new Date(b.modified*1000).toLocaleString('de-DE')}</td><td>${gib(b.size)}</td><td class="hash">${esc(b.sha256||'Prüfsumme fehlt')}</td><td><a class="download" href="${esc(b.download_url)}">Herunterladen</a></td></tr>`).join('')||'<tr><td colspan="4">Keine portablen Backups gefunden</td></tr>';
$('backupNote').textContent=d.available?`${rows.length} von maximal 5 Generationen · verschlüsselt mit Age`:'Backup-Verzeichnis auf diesem Host nicht eingebunden.';
}catch(e){if(host?.isConnected)$('backupNote').textContent=`Backup-Liste nicht verfügbar: ${e.message}`}
}
refreshSummary();refreshBackups();
timers.push(setInterval(()=>{if(host?.isConnected)refreshSummary();else cleanup()},1000));
timers.push(setInterval(()=>{if(host?.isConnected)refreshBackups();else cleanup()},60000));
}
return {render(){
const view=document.getElementById('view');
if(host?.isConnected)return;
if(host)cleanup();
view.innerHTML='<div id="deck-dashboard-host"></div>';
host=view.firstElementChild;
mount();
}};
})();