18 lines
7.0 KiB
JavaScript
18 lines
7.0 KiB
JavaScript
const $=s=>document.querySelector(s);let snapshot=null,timer=null,raf=0,lastFetch=0;
|
||
const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||
const pct=(a,b)=>b>0?Math.max(0,Math.min(100,a/b*100)):0;const fmtPct=v=>Number.isFinite(v)?`${Math.round(v)}%`:'–';
|
||
const uptime=s=>{s=Math.max(0,Math.floor(s||0));const d=Math.floor(s/86400);s%=86400;const h=Math.floor(s/3600);s%=3600;const m=Math.floor(s/60);return d?`${d}d ${h}h`:h?`${h}h ${m}m`:`${m}m`};
|
||
const stateLabel=s=>({queued:'Queue',routing:'Routing',running:'Compute',streaming:'Streaming',completed:'Fertig',failed:'Fehler',cancelled:'Abbruch'}[s]||s||'–');
|
||
function setText(id,v){const e=$(id);if(e)e.textContent=v}
|
||
function render(s){snapshot=s;document.title=s.title||'Gateway Status';setText('#title',s.title||'Gateway Status');setText('#subtitle',s.subtitle||'Live-Auslastung und Infrastruktur');setText('#k-queue',s.counts?.queued??0);setText('#k-active',s.counts?.active??0);setText('#k-workers',`${s.counts?.healthy_workers??0}/${s.counts?.workers??0}`);setText('#k-models',s.counts?.models??0);setText('#k-streaming',s.counts?.streaming??0);setText('#k-uptime',uptime(s.uptime_seconds));setText('#live-count',`${s.requests?.length||0} EVENTS`);setText('#footer-refresh',`Refresh ${Math.round((s.refresh_interval_ms||2000)/100)/10}s`);const when=new Date(s.generated_at);setText('#updated',Number.isNaN(when.getTime())?'–':`Update ${when.toLocaleTimeString()}`);const wrap=$('.status-wrap');wrap?.classList.remove('ok','bad');const healthy=(s.counts?.workers||0)>0&&(s.counts?.healthy_workers||0)===(s.counts?.workers||0);if(healthy){wrap?.classList.add('ok');setText('#status-text',(s.counts?.queued||0)>0?'Ausgelastet':'Operational')}else{wrap?.classList.add('bad');setText('#status-text','Degraded')}renderWorkers(s.workers||[]);renderLive(s.requests||[]);$('#map-empty')?.classList.toggle('hidden',(s.workers||[]).length>0);drawMap();}
|
||
function renderWorkers(ws){const root=$('#workers');if(!root)return;if(!ws.length){root.innerHTML='<div class="empty">Keine Worker-Telemetrie verfügbar.</div>';return}root.innerHTML=ws.map(w=>{const slots=`${w.active||0}/${w.max_concurrent||0} Slots`;const mods=(w.loaded_models||[]).map(m=>`<span>${esc(m)}</span>`).join('');const metrics=[];if(w.resource_metrics){const r=w.resource_metrics;if(r.vram_total_bytes>0)metrics.push(metric('VRAM',fmtPct(pct(r.vram_used_bytes,r.vram_total_bytes)),pct(r.vram_used_bytes,r.vram_total_bytes)));if(r.memory_total_bytes>0)metrics.push(metric('RAM',fmtPct(pct(r.memory_used_bytes,r.memory_total_bytes)),pct(r.memory_used_bytes,r.memory_total_bytes)));if(Number.isFinite(r.gpu_utilization_percent))metrics.push(metric('GPU',fmtPct(r.gpu_utilization_percent),r.gpu_utilization_percent));if(r.gpu_temperature_c>0)metrics.push(metric('Temp',`${Math.round(r.gpu_temperature_c)}°C`,Math.min(100,r.gpu_temperature_c)));}return `<article class="worker"><div class="worker-head"><div><strong>${esc(w.name)}</strong><small>${esc(slots)} · ${esc(w.maintenance||'active')}</small></div><span class="chip ${w.healthy?'':'bad'}">${w.healthy?'online':'offline'}</span></div>${metrics.length?`<div class="metrics">${metrics.join('')}</div>`:''}${mods?`<div class="models">${mods}</div>`:''}</article>`}).join('')}
|
||
function metric(k,v,p){return `<div class="metric"><span>${esc(k)}</span><b>${esc(v)}</b><div class="bar"><i style="width:${Math.max(0,Math.min(100,p||0))}%"></i></div></div>`}
|
||
function renderLive(rs){const root=$('#live');if(!root)return;if(!rs.length){root.innerHTML='<div class="empty">Aktuell keine sichtbare Request-Aktivität.</div>';return}root.innerHTML=rs.map(r=>{const cls=r.state==='completed'?'done':r.state;const model=r.model?` · ${esc(r.model)}`:'';const worker=r.worker?esc(r.worker):'wartet';const toks=(r.prompt_tokens||0)+(r.completion_tokens||0);return `<article class="event ${esc(cls)}"><div class="event-head"><strong>${esc(r.id)}</strong><span class="state">${esc(stateLabel(r.state))}</span></div><small>${worker}${model}</small><div class="event-meta"><span>Queue ${r.queue_ms||0}ms</span><span>${toks?`${toks} tok`:'–'}</span></div></article>`}).join('')}
|
||
async function poll(){try{const r=await fetch('./api/snapshot',{headers:{Accept:'application/json'}});if(!r.ok)throw new Error(`HTTP ${r.status}`);const s=await r.json();render(s);lastFetch=Date.now();const delay=Math.max(1000,s.refresh_interval_ms||2000);clearTimeout(timer);timer=setTimeout(poll,delay)}catch(e){const wrap=$('.status-wrap');wrap?.classList.remove('ok');wrap?.classList.add('bad');setText('#status-text','Unavailable');setText('#updated',e.message);clearTimeout(timer);timer=setTimeout(poll,5000)}}
|
||
function fitCanvas(){const c=$('#map-canvas'),stage=$('#map-stage');if(!c||!stage)return null;const dpr=Math.min(devicePixelRatio||1,2),r=stage.getBoundingClientRect();const w=Math.max(1,Math.floor(r.width)),h=Math.max(1,Math.floor(r.height));if(c.width!==Math.floor(w*dpr)||c.height!==Math.floor(h*dpr)){c.width=Math.floor(w*dpr);c.height=Math.floor(h*dpr)}const x=c.getContext('2d');x.setTransform(dpr,0,0,dpr,0,0);return{x,w,h}}
|
||
function drawMap(ts=performance.now()){const fit=fitCanvas();if(!fit){raf=requestAnimationFrame(drawMap);return}const {x,w,h}=fit;x.clearRect(0,0,w,h);const s=snapshot;if(s){const ws=s.workers||[],req=s.requests||[];const queue={x:w*.18,y:h*.5},gateway={x:w*.48,y:h*.5};const nodes=ws.map((v,i)=>({v,x:w*.80,y:h*(i+1)/(ws.length+1)}));line(x,queue,gateway,'#b9c7d3',1.4);for(const n of nodes)line(x,gateway,n,'#b9c7d3',1.2);node(x,queue,54,'#a56600','QUEUE',String(s.counts?.queued||0));node(x,gateway,66,'#147fa5','GATEWAY',`${s.counts?.active||0} active`);for(const n of nodes)node(x,n,52,n.v.healthy?'#1a805f':'#b83d4b',n.v.name,`${n.v.active||0}/${n.v.max_concurrent||0}`);req.slice(0,40).forEach((r,i)=>{let a=queue,b=gateway;if(r.worker){const n=nodes.find(n=>n.v.name===r.worker);if(n){a=gateway;b=n}}const phase=((ts/1800)+(hash(r.id)%100)/100)%1;const p=r.state==='queued'?Math.min(.25,phase*.25):phase;const px=a.x+(b.x-a.x)*p,py=a.y+(b.y-a.y)*p;const col=r.state==='streaming'?'#6b5bb5':r.state==='queued'?'#a56600':'#147fa5';x.beginPath();x.fillStyle=col;x.arc(px,py,3.5,0,Math.PI*2);x.fill()})}raf=requestAnimationFrame(drawMap)}
|
||
function line(x,a,b,c,l){x.beginPath();x.moveTo(a.x,a.y);x.lineTo(b.x,b.y);x.strokeStyle=c;x.lineWidth=l;x.stroke()}
|
||
function node(x,n,r,c,title,sub){x.save();x.beginPath();x.fillStyle='#fff';x.strokeStyle=c;x.lineWidth=2;x.arc(n.x,n.y,r,0,Math.PI*2);x.fill();x.stroke();x.textAlign='center';x.fillStyle='#2a4054';x.font='700 9px '+getComputedStyle(document.documentElement).getPropertyValue('--mono');x.fillText(String(title).slice(0,18),n.x,n.y-2);x.fillStyle='#7b8c9d';x.font='8px '+getComputedStyle(document.documentElement).getPropertyValue('--mono');x.fillText(String(sub).slice(0,18),n.x,n.y+12);x.restore()}
|
||
function hash(s){let h=2166136261;for(const c of String(s))h=Math.imul(h^c.charCodeAt(0),16777619);return h>>>0}
|
||
document.addEventListener('visibilitychange',()=>{if(!document.hidden&&Date.now()-lastFetch>3000)poll()});poll();drawMap();
|