235 lines
32 KiB
JavaScript
235 lines
32 KiB
JavaScript
const DashboardUI=(()=>{
|
||
let host=null,root=null,timers=[],controller=null;
|
||
let historyColors=['#c5edaf','#ffb454','#7fc8b6','#c39bff'];
|
||
const esc=v=>String(v??'–').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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=[];
|
||
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();
|
||
}};
|
||
})();
|