Files
2026-09-11 06:14:38 +02:00

215 lines
105 KiB
JavaScript
Raw Permalink 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.
'use strict';
const S={bootstrap:null,session:null,overview:null,recent:[],rollups:null,models:[],aliases:null,aliasEditing:null,placement:null,placementEditingWorker:null,policies:null,config:null,configDirty:false,apiKeys:null,storage:null,autotune:null,autotuneSelected:null,warm:null,warmEditing:null,alerts:null,simulation:null,jobs:[],batches:null,operations:[],timer:null,page:'overview',live:{requests:[],connected:false,version:0,generatedAt:null,counts:null,truncated:false},cluster:{gateways:[],workers:[],requests:[],counts:{},queue:0,running:0,connected:false,mode:'in-memory',version:0,generatedAt:null},liveAbort:null,clusterAbort:null,liveRAF:null,livePaused:window.matchMedia?.('(prefers-reduced-motion: reduce)').matches||false,livePauseTime:0,liveHit:[],clusterHit:[]};
const $=(q,r=document)=>r.querySelector(q), $$=(q,r=document)=>[...r.querySelectorAll(q)];
const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const num=(v,d=0)=>Number(v||0).toLocaleString('de-DE',{maximumFractionDigits:d});
const credits=v=>Number(v||0).toLocaleString('de-DE',{maximumFractionDigits:2});
const bytes=v=>{v=Number(v||0);if(!v)return '0 B';const u=['B','KB','MB','GB','TB'];let i=0;while(v>=1024&&i<u.length-1){v/=1024;i++}return `${v.toLocaleString('de-DE',{maximumFractionDigits:i?1:0})} ${u[i]}`};
const ms=v=>{v=Number(v||0);return v<1000?`${num(v)} ms`:`${(v/1000).toLocaleString('de-DE',{maximumFractionDigits:2})} s`};
const when=v=>v?new Date(v).toLocaleString('de-DE'):'–';
const infinity=v=>Number(v||0)<=0?'∞':num(v,2);
const capsHTML=a=>(a||[]).length?(a||[]).map(c=>`<span class="cap-badge cap-${esc(c)}">${esc(c)}</span>`).join(''):'<span class="muted">unbekannt</span>';
const pct1=v=>Number(v||0).toLocaleString('de-DE',{maximumFractionDigits:1})+' %';
const isAdmin=()=>!!S.session?.admin;
function authHeaders(){const h={'Accept':'application/json'};const key=sessionStorage.getItem('ofgCredential');if(key)h['Authorization']='Bearer '+key;const csrf=getCookie('ofg_csrf');if(csrf)h['X-CSRF-Token']=csrf;return h}
function getCookie(n){return document.cookie.split(';').map(v=>v.trim()).find(v=>v.startsWith(n+'='))?.slice(n.length+1)||''}
async function api(path,opt={}){const headers={...authHeaders(),...(opt.headers||{})};if(opt.body&&!headers['Content-Type'])headers['Content-Type']='application/json';const r=await fetch(path,{...opt,headers,credentials:'same-origin'});let data=null;const ct=r.headers.get('content-type')||'';if(ct.includes('json'))data=await r.json().catch(()=>null);else data=await r.text();if(!r.ok){const e=new Error(data?.error?.message||data?.message||`HTTP ${r.status}`);e.status=r.status;throw e}return data}
function toast(msg,bad=false){const x=document.createElement('div');x.className='toast'+(bad?' bad':'');x.textContent=msg;$('#toast-root').appendChild(x);setTimeout(()=>x.remove(),3200)}
async function copyText(v){if(navigator.clipboard?.writeText){try{await navigator.clipboard.writeText(v);return true}catch{}}const x=document.createElement('textarea');x.value=v;x.setAttribute('readonly','');x.style.position='fixed';x.style.opacity='0';document.body.appendChild(x);x.select();let ok=false;try{ok=document.execCommand('copy')}catch{}x.remove();return ok}
async function init(){try{S.bootstrap=await fetch('/gateway/ui-api/bootstrap',{credentials:'same-origin'}).then(r=>r.json());document.title=S.bootstrap.title||'Ollama Fair Gateway';$('#brand-title').textContent=S.bootstrap.title;$('#login-title').textContent=S.bootstrap.title;if(S.bootstrap.oidc_enabled){$('#oidc-login').classList.remove('hidden');$('#login-divider').classList.remove('hidden');$('#oidc-login').href=S.bootstrap.oidc_login_url}await establishSession()}catch(e){showLogin(e.message)}}
async function establishSession(){try{S.session=await api('/gateway/ui-api/session');showApp();await refreshAll(true)}catch(e){if(e.status===401)showLogin();else showLogin(e.message)}}
function showLogin(err=''){clearInterval(S.timer);stopLiveStream();stopClusterStream();$('#app').classList.add('hidden');$('#login').classList.remove('hidden');$('#login-error').textContent=err;$('#login-error').classList.toggle('hidden',!err)}
function showApp(){$('#login').classList.add('hidden');$('#app').classList.remove('hidden');$('#identity-name').textContent=S.session.subject||S.session.application||'–';$('#identity-tenant').textContent=`${S.session.tenant} · ${S.session.auth_type}`;$$('[data-admin]').forEach(x=>x.classList.toggle('hidden',!isAdmin()));const requested=location.hash.replace(/^#/,'');if(!isAdmin()&&S.page!=='usage')goto('usage');else if(requested&&$('#page-'+requested))goto(requested);clearInterval(S.timer);S.timer=setInterval(()=>refreshAll(false),3500);if(isAdmin()){startLiveStream();startClusterStream();startLiveAnimation()}}
$('#key-login').addEventListener('submit',async e=>{e.preventDefault();const v=$('#login-key').value.trim();if(!v)return;sessionStorage.setItem('ofgCredential',v);await establishSession()});
$('#logout').onclick=async()=>{sessionStorage.removeItem('ofgCredential');try{await fetch((S.bootstrap?.ui_path||'/admin')+'/logout',{method:'POST',headers:authHeaders(),credentials:'same-origin'})}catch{}location.reload()};
$$('#nav button').forEach(b=>b.onclick=()=>goto(b.dataset.page));$$('[data-goto]').forEach(b=>b.onclick=()=>goto(b.dataset.goto));$('#refresh').onclick=()=>refreshAll(true);
function goto(p){S.page=p;if(location.hash!=='#'+p)history.replaceState(null,'','#'+p);$$('#nav button').forEach(b=>b.classList.toggle('active',b.dataset.page===p));$$('.page').forEach(x=>x.classList.toggle('active',x.id==='page-'+p));if(p==='usage'&&isAdmin()&&S.rollups===null)loadRollups().catch(e=>toast(e.message,true));const titles={overview:['Übersicht','Live-Zustand des Gateways'],live:['Live Flow','Animierte Request-Pulse durch Queue, Scheduler und Worker'],infrastructure:['Infrastruktur','Globale LLM-Topologie über Gateways, Worker und Modelle'],workers:['Worker','Gesundheit, Slots und Modell-Affinity'],models:['Modelle','Inventar und Modellverwaltung'],routing:['Model Placement','Harte Modell-zu-Worker-Routingregeln'],fairness:['Fairness & Quotas','Gewichte, Credit-Buckets und persistente Overrides'],autotune:['Auto-Tuning','Concurrency-Benchmarks und OpenTelemetry-Status'],warm:['Warm Models','Residency, Preload und Idle-Eviction für Modelle'],alerts:['Alerts','Betriebssignale und signierte Webhooks'],usage:['Nutzung','Tokens, Credits und Request-Journal'],jobs:['Jobs','Aktive Inferenz-Requests überwachen und abbrechen'],batches:['Batch Jobs','Durable Hintergrundjobs überwachen, pausieren und fortsetzen'],operations:['Operationen','Laufende und abgeschlossene Admin-Aktionen'],security:['Sicherheit','OIDC, API-Keys und Netzwerk-Bypass'],storage:['Persistenz','Durabler Control-Plane-State, Usage und Backups'],config:['Konfiguration','Persistente, validierte Gateway-Konfiguration']};$('#page-title').textContent=titles[p][0];$('#page-subtitle').textContent=titles[p][1];render()}
async function refreshAll(loud){try{const jobs=[api('/gateway/v1/usage/me').then(x=>S.myUsage=x)];if(isAdmin()){jobs.push(api('/gateway/ui-api/overview').then(x=>S.overview=x),api('/gateway/ui-api/recent?limit=1000').then(x=>S.recent=x.events||[]),api('/gateway/ui-api/policies').then(x=>S.policies=x),api('/gateway/ui-api/jobs').then(x=>S.jobs=x.jobs||[]),api('/gateway/ui-api/batches').then(x=>S.batches=x),api('/gateway/ui-api/operations').then(x=>S.operations=x.operations||[]));if(['models','workers','overview'].includes(S.page)||!S.models.length)jobs.push(api('/gateway/ui-api/models').then(x=>S.models=x.inventories||[]));if(S.page==='models'||S.aliases===null)jobs.push(api('/gateway/ui-api/model-aliases').then(x=>S.aliases=x));if(S.page==='routing'||S.placement===null)jobs.push(api('/gateway/ui-api/placement').then(x=>S.placement=x));if(['security','config'].includes(S.page)&&!S.config)jobs.push(api('/gateway/ui-api/config').then(x=>S.config=x));if(S.page==='security'||S.apiKeys===null)jobs.push(api('/gateway/ui-api/api-keys').then(x=>S.apiKeys=x.keys||[]));if(S.page==='autotune'||S.autotune===null)jobs.push(api('/gateway/ui-api/autotune').then(x=>S.autotune=x));if(S.page==='warm'||S.warm===null)jobs.push(api('/gateway/ui-api/warm-models').then(x=>S.warm=x));if(S.page==='alerts'||S.alerts===null)jobs.push(api('/gateway/ui-api/alerts').then(x=>S.alerts=x));if(S.page==='storage'||S.storage===null)jobs.push(api('/gateway/ui-api/storage').then(x=>S.storage=x));if(S.page==='usage')jobs.push(loadRollups())}await Promise.all(jobs);setConnection(true);render();if(loud)toast('Aktualisiert')}catch(e){setConnection(false,e.message);if(e.status===401)return showLogin('Sitzung abgelaufen oder Credential ungültig.');if(loud)toast(e.message,true)}}
function setConnection(ok,msg=''){$('#connection-state').className='status-line '+(ok?'ok':'bad');$('#connection-state span:last-child').textContent=ok?'Gateway verbunden':(msg||'Verbindung gestört')}
function render(){renderUsageMine();if(!isAdmin())return;if(!S.overview)return;renderOverview();renderLivePanel();renderClusterPanel();renderWorkers();renderModels();renderPlacement();renderFairness();renderModelAccess();renderAutoTune();renderWarm();renderAlerts();renderPolicySimulator();renderUsage();renderJobs();renderBatches();renderOperations();renderStorage();if(S.config){renderSecurity();const ed=$('#config-json');if(ed&&!S.configDirty)ed.value=JSON.stringify(S.config,null,2)}}
function renderOverview(){const o=S.overview, g=o.usage||{}, sch=o.scheduler||{}, ws=o.workers||[];$('#kpi-queue').textContent=num(sch.queued);$('#kpi-running').textContent=num(sch.running);$('#kpi-requests').textContent=num(g.requests);$('#kpi-credits').textContent=credits(g.credits);$('#kpi-tokens').textContent=num((g.prompt_tokens||0)+(g.completion_tokens||0));$('#kpi-workers').textContent=`${ws.filter(w=>w.healthy).length}/${ws.length}`;renderTraffic();$('#system-health').innerHTML=ws.map(w=>`<div class="health-item"><span class="state-dot ${w.healthy?'ok':''}"></span><div class="grow"><strong>${esc(w.name)}</strong><small>${w.active}/${w.max_concurrent} Slots · ${w.models?.length||0} geladen</small></div><span class="status-chip ${w.healthy?'ok':'bad'}">${w.healthy?'healthy':'down'}</span></div>`).join('')||'<div class="empty">Keine Worker</div>';const tops=groupRecent('model');renderBars('#top-models',tops,'credits');$('#recent-mini').innerHTML=tableRecent(S.recent.slice(0,6),true)}
function groupRecent(field){const m=new Map();for(const e of S.recent){const k=e[field]||'(none)',x=m.get(k)||{name:k,credits:0,requests:0};x.credits+=Number(e.actual_credits||0);x.requests++;m.set(k,x)}return [...m.values()].sort((a,b)=>b.credits-a.credits).slice(0,7)}
function renderBars(sel,items,metric){const max=Math.max(1,...items.map(x=>Number(x[metric]||0)));$(sel).innerHTML=items.length?items.map(x=>`<div class="bar-row"><div class="bar-meta"><span>${esc(x.name)}</span><span>${metric==='credits'?credits(x[metric])+' cr':num(x[metric])}</span></div><div class="bar-track"><div class="bar-fill" style="width:${Math.max(2,100*Number(x[metric]||0)/max)}%"></div></div></div>`).join(''):'<div class="empty">Noch keine Daten</div>'}
function renderTraffic(){const ev=[...S.recent].reverse().slice(-240);if(!ev.length){$('#traffic-chart').innerHTML='<div class="empty">Noch keine Requests</div>';return}const buckets=new Map();for(const e of ev){const d=new Date(e.time),k=new Date(Math.floor(d.getTime()/60000)*60000).getTime(),x=buckets.get(k)||{t:k,r:0,c:0};x.r++;x.c+=Number(e.actual_credits||0);buckets.set(k,x)}const a=[...buckets.values()].sort((x,y)=>x.t-y.t);const W=800,H=230,p=28,maxR=Math.max(1,...a.map(x=>x.r)),maxC=Math.max(1,...a.map(x=>x.c));const pts=(key,max)=>a.map((x,i)=>`${p+(W-2*p)*(a.length===1?.5:i/(a.length-1))},${H-p-(H-2*p)*x[key]/max}`).join(' ');const labels=[a[0],a[Math.floor((a.length-1)/2)],a[a.length-1]].filter((x,i,z)=>z.indexOf(x)===i);$('#traffic-chart').innerHTML=`<svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="none"><defs><linearGradient id="areaGradient" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#147fa5" stop-opacity=".18"/><stop offset="1" stop-color="#147fa5" stop-opacity="0"/></linearGradient></defs><line class="axis" x1="${p}" y1="${H-p}" x2="${W-p}" y2="${H-p}"/><polyline class="line" points="${pts('r',maxR)}"/><polyline class="credit-line" points="${pts('c',maxC)}"/>${labels.map(x=>{const i=a.indexOf(x),xx=p+(W-2*p)*(a.length===1?.5:i/(a.length-1));return `<text x="${xx}" y="${H-7}" text-anchor="middle">${new Date(x.t).toLocaleTimeString('de-DE',{hour:'2-digit',minute:'2-digit'})}</text>`}).join('')}</svg>`}
function renderWorkers(){const ws=S.overview.workers||[];$('#worker-cards').innerHTML=ws.map(w=>{const perf=(w.performance||[]).filter(x=>Number(x.samples)>0).sort((a,b)=>Number(b.output_tps||0)-Number(a.output_tps||0)).slice(0,3);const tele=[w.gpu_utilization_percent?`GPU ${pct1(w.gpu_utilization_percent)}`:'',w.vram_total_bytes?`VRAM ${bytes(w.vram_used_bytes)} / ${bytes(w.vram_total_bytes)}`:'',w.gpu_temperature_c?`${num(w.gpu_temperature_c,1)} °C`:'',w.gpu_power_watts?`${num(w.gpu_power_watts,1)} W`:''].filter(Boolean);const maint=w.maintenance||'active',circuit=w.circuit_state||'closed';const actions=maint==='active'?`<button class="button small" data-worker-mode="draining" data-worker="${esc(w.name)}">Drain</button><button class="button small danger-button" data-worker-mode="disabled" data-worker="${esc(w.name)}">Disable</button>`:`<button class="button small primary" data-worker-mode="active" data-worker="${esc(w.name)}">Aktivieren</button>`;return `<article class="worker-card"><div class="worker-title"><span class="state-dot ${w.healthy?'ok':''}"></span><div class="grow"><strong>${esc(w.name)}</strong><small>${esc(w.url)}</small></div><span class="status-chip ${maint==='active'?'ok':maint==='draining'?'warn':'bad'}">${esc(maint)}</span><span class="status-chip ${circuit==='closed'?'ok':'bad'}">CB ${esc(circuit)}</span></div><div class="slot-row"><span>Slots</span><strong>${w.active} / ${w.max_concurrent}</strong></div><div class="slot-bar"><div class="slot-fill" style="width:${100*w.active/Math.max(1,w.max_concurrent)}%"></div></div>${tele.length?`<div class="telemetry-line">${tele.map(x=>`<span>${x}</span>`).join('')}</div>`:''}<div class="model-tags">${(w.models||[]).slice(0,8).map(m=>`<span class="model-tag">${esc(m)}</span>`).join('')}${w.models?.length>8?`<span class="model-tag">+${w.models.length-8}</span>`:''}</div>${perf.length?`<div class="perf-list">${perf.map(x=>`<div><span class="mono">${esc(x.model)}</span><strong>${num(x.output_tps,1)} tok/s</strong></div>`).join('')}</div>`:''}<div class="actions">${actions}${circuit!=='closed'?`<button class="button small" data-circuit-reset="${esc(w.name)}">Circuit reset</button>`:''}</div>${w.last_circuit_error?`<div class="callout danger">Circuit: ${esc(w.last_circuit_error)}</div>`:''}${w.telemetry_error?`<div class="callout danger">Telemetry: ${esc(w.telemetry_error)}</div>`:''}${w.last_error?`<div class="callout danger">${esc(w.last_error)}</div>`:''}</article>`}).join('');$('#worker-table').innerHTML=table(['Worker','Status','Maintenance','Circuit','Slots','GPU','VRAM','Letzter Check'],ws.map(w=>[esc(w.name),`<span class="status-chip ${w.healthy?'ok':'bad'}">${w.healthy?'Healthy':'Down'}</span>`,esc(w.maintenance||'active'),esc(w.circuit_state||'closed'),`${w.active} / ${w.max_concurrent}`,w.gpu_utilization_percent?pct1(w.gpu_utilization_percent):'–',w.vram_total_bytes?`${bytes(w.vram_used_bytes)} / ${bytes(w.vram_total_bytes)}`:'–',when(w.last_check)]))}
function flatModels(){return S.models.flatMap(inv=>(inv.models||[]).map(m=>({...m,worker:inv.worker,worker_error:inv.error})))}
function aliasData(){return S.aliases?.aliases||S.overview?.model_aliases||{}}
function renderModels(){let ms=flatModels(),f=($('#model-filter')?.value||'').toLowerCase();if(f)ms=ms.filter(m=>`${m.name} ${m.worker} ${m.details?.family||''} ${m.details?.parameter_size||''} ${(m.capabilities||[]).join(' ')}`.toLowerCase().includes(f));const aliases=aliasData();const aliasRows=Object.entries(aliases).sort((a,b)=>a[0].localeCompare(b[0])).map(([name,a])=>[`<strong>${esc(name)}</strong>`,esc((a.models||[]).join(' → ')),esc((a.required_capabilities||[]).join(', ')||'–'),a.visible===false?'versteckt':'sichtbar',`<div class="actions"><button class="button small" data-alias-edit="${esc(name)}">Edit</button><button class="button small danger-button" data-alias-delete="${esc(name)}">Delete</button></div>`]);$('#alias-table').innerHTML=aliasRows.length?table(['Alias','Fallback-Reihenfolge','Capabilities','Discovery','Aktionen'],aliasRows):'<div class="empty">Keine Model-Aliases konfiguriert.</div>';$('#model-table').innerHTML=table(['Modell','Worker','Capabilities','Modell-Max','Konfig.','Geladen','Parameter','Quant.','Größe','Status','Aktionen'],ms.map(m=>[`<strong>${esc(m.name)}</strong><br><small class="muted mono">${esc((m.digest||'').slice(0,14))}</small>${m.metadata_error?`<br><small class="warn-text" title="${esc(m.metadata_error)}">Metadaten nicht verfügbar</small>`:''}`,esc(m.worker),`<div class="cap-list">${capsHTML(m.capabilities)}</div>`,m.context_length?`${num(m.context_length)} tok`:'–',m.configured_context_length?`${num(m.configured_context_length)} tok`:'–',m.loaded_context_length?`${num(m.loaded_context_length)} tok`:'–',esc(m.details?.parameter_size||'–'),esc(m.details?.quantization_level||'–'),bytes(m.size),`<span class="status-chip ${m.loaded?'ok':''}">${m.loaded?'loaded':'stored'}</span>`,`<div class="actions"><button class="button small" data-model-action="stop" data-worker="${esc(m.worker)}" data-model="${esc(m.name)}">Stop</button><button class="button small danger" data-model-action="delete" data-worker="${esc(m.worker)}" data-model="${esc(m.name)}">Delete</button></div>`]))}
function openAliasDialog(name=''){const a=aliasData()[name]||{};S.aliasEditing=name;$('#alias-name').value=name;$('#alias-name').readOnly=!!name;$('#alias-models').value=(a.models||[]).join('\n');$('#alias-capabilities').value=(a.required_capabilities||[]).join('\n');$('#alias-visible').checked=a.visible!==false;$('#alias-dialog').showModal()}
async function reloadAliases(){S.aliases=await api('/gateway/ui-api/model-aliases');S.overview=await api('/gateway/ui-api/overview');renderModels();renderPolicySimulator()}
$('#new-alias')?.addEventListener('click',()=>openAliasDialog(''));
$('#alias-table')?.addEventListener('click',async e=>{const edit=e.target.closest('[data-alias-edit]');if(edit){openAliasDialog(edit.dataset.aliasEdit);return}const del=e.target.closest('[data-alias-delete]');if(!del)return;const name=del.dataset.aliasDelete;if(!confirm(`Model Alias ${name} wirklich löschen?`))return;try{await api('/gateway/ui-api/model-aliases/'+encodeURIComponent(name),{method:'DELETE'});await reloadAliases();toast('Alias gelöscht')}catch(err){toast(err.message,true)}});
$('#alias-form')?.addEventListener('submit',async e=>{e.preventDefault();const name=$('#alias-name').value.trim(),payload={models:lines($('#alias-models').value),required_capabilities:lines($('#alias-capabilities').value),visible:$('#alias-visible').checked};if(!name||!payload.models.length){toast('Alias-Name und mindestens ein Fallback-Modell sind erforderlich.',true);return}try{await api('/gateway/ui-api/model-aliases/'+encodeURIComponent(name),{method:'PUT',body:JSON.stringify(payload)});$('#alias-dialog').close();await reloadAliases();toast('Alias gespeichert und aktiviert')}catch(err){toast(err.message,true)}});
function placementRule(worker){return (S.placement?.workers||[]).find(x=>x.worker===worker)}
function placementRows(){return S.placement?.models||[]}
function placementCell(row,worker){return row?.workers?.[worker]||{allowed:false,installed:false,loaded:false,source:'unknown'}}
function placementSourceLabel(c){if(c.source==='allow_rule')return c.pattern?`allow ${c.pattern}`:'allow';if(c.source==='deny_rule')return c.pattern?`deny ${c.pattern}`:'deny';if(c.source==='whitelist_default')return 'Whitelist';if(c.source==='allow_all_default')return 'Allow all';return c.source||'–'}
function renderPlacement(){if(!S.placement)return;const workers=S.placement.workers||[],rows=placementRows();const routable=r=>workers.some(w=>{const c=placementCell(r,w.worker);return c.allowed&&(c.installed||!w.inventory_known)}),unroutable=rows.filter(r=>!routable(r)).length;$('#placement-workers').textContent=num(workers.length);$('#placement-models').textContent=num(rows.length);$('#placement-overrides').textContent=num(workers.filter(w=>w.override).length);$('#placement-unroutable').textContent=num(unroutable);let list=rows.slice(),f=($('#placement-filter')?.value||'').trim().toLowerCase(),view=$('#placement-view')?.value||'all';if(f)list=list.filter(r=>r.model.toLowerCase().includes(f));if(view==='routable')list=list.filter(routable);else if(view==='unroutable')list=list.filter(r=>!routable(r));else if(view==='multi')list=list.filter(r=>workers.filter(w=>{const c=placementCell(r,w.worker);return c.allowed&&(c.installed||!w.inventory_known)}).length>1);const headers=['Modell',...workers.map(w=>`<span title="${esc(w.override?'persistenter UI-Override':'Config-Default')}">${esc(w.worker)}${w.override?' ↳':''}</span>`)],tableRows=list.map(r=>{const destinations=workers.filter(w=>{const c=placementCell(r,w.worker);return c.allowed&&c.installed}).length;return [`<div class="placement-model-cell"><strong>${esc(r.model)}</strong><small>${destinations?destinations+' aktive Ziel'+(destinations===1?'':'e'):'kein installiertes Ziel'}</small></div>`,...workers.map(w=>placementCellHTML(r,w))]});$('#placement-matrix').innerHTML=table(headers,tableRows);$('#placement-worker-cards').innerHTML=workers.length?workers.map(placementWorkerCard).join(''):'<div class="empty">Keine Worker</div>'}
function placementCellHTML(row,w){const c=placementCell(row,w.worker),klass=`placement-cell ${c.allowed?'allowed':'denied'} ${c.installed?'installed':''} ${c.loaded?'loaded':''}`,icon=c.loaded?'●':c.allowed?(c.installed?'✓':'○'):'⛔',title=`${row.model} → ${w.worker}\n${c.allowed?'erlaubt':'gesperrt'} · ${c.installed?'installiert':'nicht installiert'}${c.loaded?' · geladen':''}\nQuelle: ${placementSourceLabel(c)}\nKlick: ${c.exact_override?'exakte Ausnahme entfernen':c.allowed?'explizit sperren':'explizit erlauben'}`;return `<button type="button" class="${klass}" data-placement-cell="1" data-worker="${esc(w.worker)}" data-model="${esc(row.model)}" data-allowed="${c.allowed?'1':'0'}" data-exact="${c.exact_override?'1':'0'}" data-worker-override="${w.override?'1':'0'}" title="${esc(title)}"><span class="placement-main"><b>${icon}</b>${c.loaded?'<span>loaded</span>':c.installed?'<span>installed</span>':c.allowed?'<span>allowed</span>':'<span>blocked</span>'}${c.exact_override?'<i class="override-mark">↳</i>':''}</span><small>${esc(placementSourceLabel(c))}</small></button>`}
function placementWorkerCard(w){const e=w.effective||{},base=w.baseline||{},allow=e.allowed_models||[],deny=e.denied_models||[],mode=e.mode||'allow_all';return `<article class="placement-worker-card"><div class="worker-rule-head"><div><strong>${esc(w.worker)}</strong><small>${w.inventory_known?'Inventar bekannt':'Inventar momentan unbekannt'}</small></div><button class="button small" type="button" data-placement-edit="${esc(w.worker)}">Bearbeiten</button></div><div class="placement-rule-meta"><span class="status-chip ${mode==='whitelist'?'warn':'ok'}">${mode==='whitelist'?'Whitelist':'Allow all'}</span><span class="status-chip ${w.override?'ok':''}">${w.override?'persistent override':'config default'}</span>${JSON.stringify(e)!==JSON.stringify(base)?'<span class="status-chip warn">abweichend</span>':''}</div><div class="placement-rule-list"><div class="placement-rule-box"><span>Allow</span>${allow.length?allow.slice(0,8).map(x=>`<code>${esc(x)}</code>`).join(''):'<div class="empty-rule">keine expliziten Regeln</div>'}${allow.length>8?`<small>+${allow.length-8}</small>`:''}</div><div class="placement-rule-box"><span>Deny</span>${deny.length?deny.slice(0,8).map(x=>`<code>${esc(x)}</code>`).join(''):'<div class="empty-rule">keine expliziten Regeln</div>'}${deny.length>8?`<small>+${deny.length-8}</small>`:''}</div></div>${S.placement?.inventory_errors?.[w.worker]?`<div class="placement-inventory-error">Inventory: ${esc(S.placement.inventory_errors[w.worker])}</div>`:''}</article>`}
async function loadPlacement(){S.placement=await api('/gateway/ui-api/placement');renderPlacement();return S.placement}
function openPlacementDialog(worker){const w=placementRule(worker);if(!w)return;S.placementEditingWorker=worker;const e=w.effective||{};$('#placement-dialog-worker').textContent=worker+(w.override?' · persistenter Override':' · Config-Default');$('#placement-mode').value=e.mode||'allow_all';$('#placement-allowed').value=(e.allowed_models||[]).join('\n');$('#placement-denied').value=(e.denied_models||[]).join('\n');$('#placement-reset').classList.toggle('hidden',!w.override);$('#placement-dialog').showModal()}
function lines(v){return [...new Set(String(v||'').split(/\r?\n|,/).map(x=>x.trim()).filter(Boolean))]}
function installedForWorker(worker){return placementRows().filter(r=>placementCell(r,worker).installed).map(r=>r.model).sort()}
async function savePlacementRule(worker,rule){await api('/gateway/ui-api/placement/'+encodeURIComponent(worker),{method:'PUT',body:JSON.stringify(rule)});await loadPlacement();toast(`Routing-Regel für ${worker} gespeichert`)}
async function placementExactAction(worker,model,action){await api('/gateway/ui-api/placement/'+encodeURIComponent(worker)+'/model',{method:'POST',body:JSON.stringify({model,action})});await loadPlacement();toast(`${model} → ${worker}: ${action==='inherit'?'exakte Regel entfernt':action==='allow'?'erlaubt':'gesperrt'}`)}
$('#placement-filter')?.addEventListener('input',renderPlacement);$('#placement-view')?.addEventListener('change',renderPlacement);$('#refresh-placement')?.addEventListener('click',async()=>{try{await loadPlacement();toast('Placement aktualisiert')}catch(e){toast(e.message,true)}});
$('#placement-matrix')?.addEventListener('click',async e=>{const b=e.target.closest('[data-placement-cell]');if(!b)return;const action=b.dataset.exact==='1'&&b.dataset.workerOverride==='1'?'inherit':b.dataset.allowed==='1'?'deny':'allow';try{b.disabled=true;await placementExactAction(b.dataset.worker,b.dataset.model,action)}catch(err){toast(err.message,true)}finally{b.disabled=false}});
$('#placement-worker-cards')?.addEventListener('click',e=>{const b=e.target.closest('[data-placement-edit]');if(b)openPlacementDialog(b.dataset.placementEdit)});
$('#placement-form')?.addEventListener('submit',async e=>{e.preventDefault();const worker=S.placementEditingWorker;if(!worker)return;const rule={mode:$('#placement-mode').value,allowed_models:lines($('#placement-allowed').value),denied_models:lines($('#placement-denied').value)};try{await savePlacementRule(worker,rule);$('#placement-dialog').close()}catch(err){toast(err.message,true)}});
$('#placement-reset')?.addEventListener('click',async()=>{const worker=S.placementEditingWorker;if(!worker||!confirm(`Placement-Override für ${worker} löschen und auf config.json zurücksetzen?`))return;try{await api('/gateway/ui-api/placement/'+encodeURIComponent(worker),{method:'DELETE'});await loadPlacement();$('#placement-dialog').close();toast('Config-Default wiederhergestellt')}catch(e){toast(e.message,true)}});
$$('[data-placement-preset]').forEach(b=>b.addEventListener('click',()=>{const worker=S.placementEditingWorker;if(!worker)return;switch(b.dataset.placementPreset){case'all':$('#placement-mode').value='allow_all';$('#placement-allowed').value='';$('#placement-denied').value='';break;case'installed':{const w=placementRule(worker),installed=installedForWorker(worker);if(!w?.inventory_known&&!installed.length){toast('Inventar dieses Workers ist unbekannt; Preset wurde nicht angewendet.',true);return}$('#placement-mode').value='whitelist';$('#placement-allowed').value=installed.join('\n');$('#placement-denied').value='';break;}case'none':$('#placement-mode').value='whitelist';$('#placement-allowed').value='';$('#placement-denied').value='';break}}));
function renderModelAccess(){const el=$('#model-access-table');if(!el)return;const ma=S.overview?.model_access||{},def=ma.default||{},tenants=ma.tenants||{};const rows=[['<strong>* default</strong>',esc(def.mode||'allow_all'),esc((def.allowed_models||[]).join(', ')||'–'),esc((def.denied_models||[]).join(', ')||'–'),'<span class="muted">Config-Basis</span>'],...Object.entries(tenants).sort((a,b)=>a[0].localeCompare(b[0])).map(([tenant,r])=>[esc(tenant),esc(r.mode||'allow_all'),esc((r.allowed_models||[]).join(', ')||'–'),esc((r.denied_models||[]).join(', ')||'–'),`<div class="actions"><button class="button small" data-model-access-edit="${esc(tenant)}">Edit</button><button class="button small danger-button" data-model-access-delete="${esc(tenant)}">Reset</button></div>`])];el.innerHTML=table(['Tenant','Modus','Allow','Deny','Aktionen'],rows)}
function openModelAccessDialog(tenant=''){const tenants=S.overview?.model_access?.tenants||{},r=tenants[tenant]||{};$('#model-access-tenant').value=tenant;$('#model-access-tenant').readOnly=!!tenant;$('#model-access-mode').value=r.mode||'allow_all';$('#model-access-allowed').value=(r.allowed_models||[]).join('\n');$('#model-access-denied').value=(r.denied_models||[]).join('\n');$('#model-access-dialog').showModal()}
async function reloadModelAccess(){S.overview=await api('/gateway/ui-api/overview');if(S.config)S.config=await api('/gateway/ui-api/config');renderModelAccess();renderPolicySimulator()}
$('#new-model-access')?.addEventListener('click',()=>openModelAccessDialog(''));
$('#model-access-table')?.addEventListener('click',async e=>{const edit=e.target.closest('[data-model-access-edit]');if(edit){openModelAccessDialog(edit.dataset.modelAccessEdit);return}const del=e.target.closest('[data-model-access-delete]');if(!del)return;const tenant=del.dataset.modelAccessDelete;if(!confirm(`Tenant Model-ACL für ${tenant} auf die Default-Regel zurücksetzen?`))return;try{await api('/gateway/ui-api/model-access/'+encodeURIComponent(tenant),{method:'DELETE'});await reloadModelAccess();toast('Tenant Model-ACL zurückgesetzt')}catch(err){toast(err.message,true)}});
$('#model-access-form')?.addEventListener('submit',async e=>{e.preventDefault();const tenant=$('#model-access-tenant').value.trim(),payload={mode:$('#model-access-mode').value,allowed_models:lines($('#model-access-allowed').value),denied_models:lines($('#model-access-denied').value)};if(!tenant){toast('Tenant ist erforderlich.',true);return}try{await api('/gateway/ui-api/model-access/'+encodeURIComponent(tenant),{method:'PUT',body:JSON.stringify(payload)});$('#model-access-dialog').close();await reloadModelAccess();toast('Tenant Model-ACL gespeichert und aktiviert')}catch(err){toast(err.message,true)}});
function renderFairness(){const c=S.overview.scheduler_config||{};$('#fair-concurrency').textContent=num(c.global_concurrency);$('#fair-maxqueue').textContent=num(c.max_queue);$('#fair-actorqueue').textContent=num(c.max_queue_per_actor);const p=S.policies||{baseline:{},overrides:{}};const names=[...new Set([...Object.keys(p.baseline||{}),...Object.keys(p.overrides||{})])].sort();$('#policy-table').innerHTML=table(['Tenant','Quelle','Tenant W.','Actor W.','Actor cr/min','Actor Burst','Tenant cr/min','Tenant Burst','Aktionen'],names.map(n=>{const ov=p.overrides?.[n],x=ov||p.baseline[n];return[esc(n),`<span class="status-chip ${ov?'ok':''}">${ov?'persistent':'config'}</span>`,num(x.tenant_weight,2),num(x.actor_weight,2),infinity(x.actor_credits_per_minute),infinity(x.actor_burst_credits),infinity(x.tenant_credits_per_minute),infinity(x.tenant_burst_credits),`<div class="actions"><button class="button small" data-policy-edit="${esc(n)}">Edit</button>${ov?`<button class="button small danger" data-policy-delete="${esc(n)}">Reset</button>`:''}</div>`] }));const sc=S.overview.service_classes||{},stats=S.overview.scheduler?.classes||{};const cls=Object.keys(sc.classes||{}).sort();$('#service-class-table')&&($('#service-class-table').innerHTML=table(['Klasse','Default','Weight','Max Queue','Concurrency','Queued','Running'],cls.map(n=>{const x=sc.classes[n]||{},st=stats[n]||{};return[esc(n),n===sc.default?'<span class="status-chip ok">default</span>':'',num(x.weight,2),esc(x.max_queue_wait||'–'),Number(x.max_concurrent||0)>0?num(x.max_concurrent):'∞',num(st.queued),num(st.running)]})));renderBars('#tenant-usage',(S.overview.tenants||[]).map(x=>({name:x.name,credits:x.summary.credits})),'credits')}
$('#new-policy').onclick=()=>openPolicy('');function syncUnlimited(prefix,on){for(const id of prefix==='actor'?['#policy-acpm','#policy-ab']:['#policy-tcpm','#policy-tb']){$(id).disabled=on;if(on)$(id).value=0}}function openPolicy(n){const p=S.policies?.overrides?.[n]||S.policies?.baseline?.[n]||{};$('#policy-tenant').value=n;$('#policy-tenant').readOnly=!!n;for(const [id,k] of [['#policy-tw','tenant_weight'],['#policy-aw','actor_weight'],['#policy-acpm','actor_credits_per_minute'],['#policy-ab','actor_burst_credits'],['#policy-tcpm','tenant_credits_per_minute'],['#policy-tb','tenant_burst_credits']])$(id).value=p[k]??'';const au=Number(p.actor_credits_per_minute||0)<=0&&Number(p.actor_burst_credits||0)<=0,tu=Number(p.tenant_credits_per_minute||0)<=0&&Number(p.tenant_burst_credits||0)<=0;$('#policy-actor-unlimited').checked=au;$('#policy-tenant-unlimited').checked=tu;syncUnlimited('actor',au);syncUnlimited('tenant',tu);$('#policy-dialog').showModal()}
$('#policy-actor-unlimited')?.addEventListener('change',e=>syncUnlimited('actor',e.target.checked));$('#policy-tenant-unlimited')?.addEventListener('change',e=>syncUnlimited('tenant',e.target.checked));$('#policy-form').addEventListener('submit',async e=>{e.preventDefault();const n=$('#policy-tenant').value.trim(),v=id=>Number($(id).value||0),policy={tenant_weight:v('#policy-tw'),actor_weight:v('#policy-aw'),actor_credits_per_minute:$('#policy-actor-unlimited').checked?0:v('#policy-acpm'),actor_burst_credits:$('#policy-actor-unlimited').checked?0:v('#policy-ab'),tenant_credits_per_minute:$('#policy-tenant-unlimited').checked?0:v('#policy-tcpm'),tenant_burst_credits:$('#policy-tenant-unlimited').checked?0:v('#policy-tb')};try{await api('/gateway/ui-api/policies/'+encodeURIComponent(n),{method:'PUT',body:JSON.stringify(policy)});$('#policy-dialog').close();toast('Policy gespeichert');S.policies=await api('/gateway/ui-api/policies');renderFairness()}catch(x){toast(x.message,true)}});async function deletePolicy(n){if(!confirm(`Runtime-Override für ${n} entfernen?`))return;try{await api('/gateway/ui-api/policies/'+encodeURIComponent(n),{method:'DELETE'});S.policies=await api('/gateway/ui-api/policies');renderFairness();toast('Override entfernt')}catch(e){toast(e.message,true)}}
function autotuneAppliedCount(a){return Object.values(a||{}).reduce((n,m)=>n+Object.keys(m||{}).length,0)}
function renderAutoTune(){const el=$('#autotune-table');if(!el)return;const a=S.autotune||{enabled:false,profiles:[],applied:{},config:{}},profiles=a.profiles||[],ot=S.overview?.opentelemetry||{};$('#autotune-enabled').textContent=a.enabled?'aktiv':'aus';$('#autotune-profiles').textContent=num(profiles.length);$('#autotune-applied').textContent=num(autotuneAppliedCount(a.applied));$('#otel-exported').textContent=num(ot.exported_spans||0);$('#otel-status').textContent=ot.enabled?`${num(ot.failed_spans||0)} failed · ${num(ot.dropped_spans||0)} dropped`:'deaktiviert';const oc=$('#otel-card');if(oc)oc.innerHTML=`<div>Status</div><div>${ot.enabled?'<span class="status-chip ok">aktiv</span>':'<span class="status-chip">aus</span>'}</div><div>Endpoint</div><div class="mono">${esc(ot.endpoint||'–')}</div><div>Service</div><div>${esc(ot.service_name||'–')}</div><div>Sampling</div><div>${num(Number(ot.sample_ratio||0)*100,1)}%</div><div>Exportiert</div><div>${num(ot.exported_spans||0)} Spans</div><div>Fehlgeschlagen</div><div>${num(ot.failed_spans||0)}</div><div>Gedroppt</div><div>${num(ot.dropped_spans||0)}</div>`;const ws=(S.overview?.workers||[]).map(w=>w.name),wsel=$('#autotune-worker'),msel=$('#autotune-model');if(wsel&&document.activeElement!==wsel){const old=wsel.value;wsel.innerHTML=ws.map(n=>`<option value="${esc(n)}">${esc(n)}</option>`).join('');if(ws.includes(old))wsel.value=old}const selectedWorker=wsel?.value||ws[0],models=[...new Set((S.models||[]).filter(x=>!selectedWorker||x.worker===selectedWorker).flatMap(x=>(x.models||[]).map(m=>m.model||m.name)).filter(Boolean))].sort();if(msel&&document.activeElement!==msel){const old=msel.value;msel.innerHTML=models.map(n=>`<option value="${esc(n)}">${esc(n)}</option>`).join('');if(models.includes(old))msel.value=old}el.innerHTML=table(['Start','Worker / Modell','Status','Empfehlung','Bestes Level','Aktionen'],profiles.map(p=>{const best=(p.levels||[]).slice().sort((x,y)=>(y.score||0)-(x.score||0))[0],actions=p.status==='running'||p.status==='queued'?`<button class="button small danger" data-autotune-cancel="${esc(p.id)}">Abbrechen</button>`:p.status==='completed'?`<button class="button small" data-autotune-show="${esc(p.id)}">Details</button> <button class="button small primary" data-autotune-apply="${esc(p.id)}">${p.applied?'Erneut anwenden':'Empfehlung anwenden'}</button>`:`<button class="button small" data-autotune-show="${esc(p.id)}">Details</button>`;return[when(p.started_at),`<strong>${esc(p.worker)}</strong><br><small>${esc(p.model)}</small>`,`<span class="status-chip ${p.status==='completed'?'ok':p.status==='failed'?'bad':p.status==='running'?'warn':''}">${esc(p.status)}</span>${p.error?`<br><small>${esc(p.error)}</small>`:''}`,p.recommended_concurrency?`${num(p.recommended_concurrency)}${p.applied?' · applied':''}`:'–',best?`${num(best.aggregate_output_tps,1)} tok/s · TTFT p95 ${num(best.ttft_p95_ms,0)} ms`:'–',actions]}));if(!S.autotuneSelected&&profiles.length)S.autotuneSelected=profiles[0].id;renderAutoTuneLevels()}
function renderAutoTuneLevels(){const el=$('#autotune-levels');if(!el)return;const p=(S.autotune?.profiles||[]).find(x=>x.id===S.autotuneSelected);if(!p){el.innerHTML='<div class="empty">Kein Profil ausgewählt</div>';return}$('#autotune-detail-title').textContent=`${p.worker} · ${p.model} · Empfehlung ${p.recommended_concurrency||'–'}`;el.innerHTML=table(['Concurrency','Erfolg','TTFT p50','TTFT p95','Service Ø','Output tok/s Ø','Aggregate tok/s','Peak VRAM','Peak GPU','Score'],(p.levels||[]).map(x=>[num(x.concurrency),`${num(x.successful)}/${num(x.requests)}`,`${num(x.ttft_p50_ms,0)} ms`,`${num(x.ttft_p95_ms,0)} ms`,`${num(x.mean_service_ms,0)} ms`,num(x.mean_output_tps,1),num(x.aggregate_output_tps,1),x.peak_vram_bytes?bytes(x.peak_vram_bytes):'–',x.peak_gpu_percent?num(x.peak_gpu_percent,1)+'%':'–',num(x.score,3)]))}
function warmAllModels(){return[...new Set((S.models||[]).flatMap(x=>(x.models||[]).map(m=>m.model||m.name)).filter(Boolean))].sort()}
function warmWorkers(){return(S.overview?.workers||[]).map(w=>w.name)}
function clearWarmForm(){S.warmEditing=null;$('#warm-pattern').value='';$('#warm-class').value='warm';$('#warm-replicas').value='1';$('#warm-idle').value='30m';$('#warm-preload').checked=false;$$('[data-warm-worker]').forEach(x=>x.checked=false)}
function editWarmRule(pattern){const p=S.warm?.policies?.[pattern];if(!p)return;S.warmEditing=pattern;$('#warm-pattern').value=pattern;$('#warm-class').value=p.class||'warm';$('#warm-replicas').value=Number(p.replicas||1);$('#warm-idle').value=p.idle_timeout||((p.class==='cold')?'5m':'30m');$('#warm-preload').checked=!!p.preload;const set=new Set(p.workers||[]);$$('[data-warm-worker]').forEach(x=>x.checked=set.has(x.value));$('#warm-pattern').focus()}
function renderWarm(){const x=S.warm;if(!x||!$('#warm-policy-table'))return;const ps=x.policies||{},actions=x.actions||[],sugs=x.eviction_suggestions||[];$('#warm-enabled').textContent=x.enabled?'aktiv':'aus';$('#warm-policy-count').textContent=num(Object.keys(ps).length);$('#warm-running').textContent=num(actions.filter(a=>a.status==='running').length);$('#warm-suggestions').textContent=num(sugs.length);const dl=$('#warm-model-list');if(dl)dl.innerHTML=warmAllModels().map(m=>`<option value="${esc(m)}"></option>`).join('');const ww=$('#warm-workers');if(ww&&document.activeElement?.closest?.('#warm-policy-form')!==ww){const checked=new Set($$('[data-warm-worker]').filter(x=>x.checked).map(x=>x.value));ww.innerHTML=warmWorkers().map(w=>`<label class="model-tag"><input data-warm-worker type="checkbox" value="${esc(w)}" ${checked.has(w)?'checked':''}> ${esc(w)}</label>`).join('')||'<span class="muted">Keine Worker</span>'}const rows=Object.keys(ps).sort().map(k=>{const p=ps[k],workers=(p.workers||[]).length?(p.workers||[]).join(', '):'auto';return[`<strong>${esc(k)}</strong>`,`<span class="status-chip ${p.class==='hot'?'ok':p.class==='cold'?'warn':''}">${esc(p.class||'warm')}</span>`,num(p.replicas||1),esc(workers),p.preload?'ja':'–',esc(p.idle_timeout||'–'),`<div class="actions"><button class="button small" data-warm-edit="${esc(k)}">Edit</button><button class="button small danger" data-warm-delete="${esc(k)}">Löschen</button></div>`]});$('#warm-policy-table').innerHTML=table(['Modell / Pattern','Klasse','Replicas','Worker','Preload','Idle','Aktionen'],rows);$('#warm-suggestion-list').innerHTML=sugs.length?sugs.map(v=>`<div class="health-item"><span class="state-dot warn"></span><div class="grow"><strong>${esc(v.model)}</strong><small>${esc(v.worker)} · ${esc(v.class)} · ${num(v.vram_percent,1)}% VRAM${v.last_used?' · last '+esc(when(v.last_used)):''}</small></div><span class="status-chip warn">Evict candidate</span></div>`).join(''):'<div class="empty">Keine Eviction-Empfehlungen</div>';$('#warm-actions-table').innerHTML=table(['Zeit','Aktion','Worker','Modell','Policy','Status','Meldung'],actions.slice(0,100).map(a=>[when(a.time),esc(a.type),esc(a.worker),esc(a.model),esc(a.policy||'–'),`<span class="status-chip ${a.status==='completed'?'ok':a.status==='failed'?'bad':a.status==='running'?'warn':''}">${esc(a.status)}</span>`,esc(a.message||'–')]))}
async function reloadWarm(){S.warm=await api('/gateway/ui-api/warm-models');renderWarm()}
function renderAlerts(){const x=S.alerts;if(!x||!$('#alerts-active-list'))return;const active=x.active||[],wh=x.webhooks||[],hist=x.history||[],del=x.deliveries||[];$('#alerts-enabled').textContent=x.enabled?'aktiv':'aus';$('#alerts-active').textContent=num(active.length);$('#alerts-webhooks').textContent=num(wh.filter(w=>w.enabled).length);$('#alerts-last').textContent=x.last_evaluate?new Date(x.last_evaluate).toLocaleTimeString('de-DE'):'–';$('#alerts-active-list').innerHTML=active.length?active.map(a=>`<div class="health-item"><span class="state-dot ${a.severity==='critical'?'bad':'warn'}"></span><div class="grow"><strong>${esc(a.type)}</strong><small>${esc(a.message)} · seit ${esc(when(a.started_at))}</small></div><span class="status-chip ${a.severity==='critical'?'bad':'warn'}">${esc(a.severity)}</span></div>`).join(''):'<div class="empty">Keine aktiven Alerts</div>';$('#alerts-webhook-list').innerHTML=wh.length?wh.map(w=>`<div class="health-item"><span class="state-dot ${w.enabled?'ok':''}"></span><div class="grow"><strong>${esc(w.name||'webhook')}</strong><small>${esc(w.url)} · ${w.signed?'HMAC signiert':'unsigned'}</small></div>${w.enabled?`<button class="button small" data-alert-test="${esc(w.name||'')}">Test</button>`:''}</div>`).join(''):'<div class="empty">Keine Webhooks konfiguriert</div>';$('#alerts-history-table').innerHTML=table(['Zeit','Status','Typ','Ziel','Meldung'],hist.slice(0,200).map(a=>[when(a.updated_at),`<span class="status-chip ${a.state==='resolved'?'ok':a.severity==='critical'?'bad':'warn'}">${esc(a.state)}</span>`,esc(a.type),esc(a.worker||a.tenant||'–'),esc(a.message)]));$('#alerts-delivery-table').innerHTML=table(['Zeit','Event','Webhook','Versuch','HTTP','Fehler'],del.slice(0,100).map(d=>[when(d.time),esc(d.event_id),esc(d.webhook),num(d.attempt||1),d.status_code?num(d.status_code):'–',esc(d.error||'–')]))}
async function reloadAlerts(){S.alerts=await api('/gateway/ui-api/alerts');renderAlerts()}
async function reloadAutoTune(){S.autotune=await api('/gateway/ui-api/autotune');renderAutoTune()}
$('#autotune-worker')?.addEventListener('change',renderAutoTune);
$('#refresh-autotune')?.addEventListener('click',()=>reloadAutoTune().catch(e=>toast(e.message,true)));
$('#warm-policy-form')?.addEventListener('submit',async e=>{e.preventDefault();const pattern=$('#warm-pattern').value.trim();if(!pattern)return;const policies=structuredClone(S.warm?.policies||{});if(S.warmEditing&&S.warmEditing!==pattern)delete policies[S.warmEditing];policies[pattern]={class:$('#warm-class').value,replicas:Number($('#warm-replicas').value||1),preload:$('#warm-preload').checked,idle_timeout:$('#warm-idle').value.trim()||($('#warm-class').value==='cold'?'5m':'30m'),workers:$$('[data-warm-worker]').filter(x=>x.checked).map(x=>x.value)};try{S.warm=await api('/gateway/ui-api/warm-models',{method:'PUT',body:JSON.stringify({policies})});clearWarmForm();renderWarm();toast('Warm-Policy gespeichert')}catch(err){toast(err.message,true)}});
$('#warm-form-clear')?.addEventListener('click',clearWarmForm);
$('#warm-policy-table')?.addEventListener('click',async e=>{const ed=e.target.closest('[data-warm-edit]');if(ed){editWarmRule(ed.dataset.warmEdit);return}const del=e.target.closest('[data-warm-delete]');if(del){const pattern=del.dataset.warmDelete;if(!confirm(`Warm-Policy ${pattern} löschen?`))return;const policies=structuredClone(S.warm?.policies||{});delete policies[pattern];try{S.warm=await api('/gateway/ui-api/warm-models',{method:'PUT',body:JSON.stringify({policies})});renderWarm();toast('Warm-Policy gelöscht')}catch(err){toast(err.message,true)}}});
$('#warm-reset')?.addEventListener('click',async()=>{if(!confirm('Alle Runtime-Warm-Policies auf Config-Default zurücksetzen?'))return;try{S.warm=await api('/gateway/ui-api/warm-models',{method:'DELETE'});clearWarmForm();renderWarm();toast('Warm-Policies zurückgesetzt')}catch(err){toast(err.message,true)}});
$('#warm-reconcile')?.addEventListener('click',async()=>{try{S.warm=await api('/gateway/ui-api/warm-models/reconcile',{method:'POST'});renderWarm();toast('Residency-Abgleich gestartet')}catch(err){toast(err.message,true)}});
$('#alerts-webhook-list')?.addEventListener('click',async e=>{const b=e.target.closest('[data-alert-test]');if(!b)return;try{await api('/gateway/ui-api/alerts/test',{method:'POST',body:JSON.stringify({name:b.dataset.alertTest})});toast('Test-Webhook gesendet');setTimeout(()=>reloadAlerts().catch(()=>{}),500)}catch(err){toast(err.message,true)}});
$('#autotune-form')?.addEventListener('submit',async e=>{e.preventDefault();const body={worker:$('#autotune-worker').value,model:$('#autotune-model').value,max_concurrency:Number($('#autotune-max-concurrency').value||0),samples_per_level:Number($('#autotune-samples').value||0),max_tokens:Number($('#autotune-max-tokens').value||0)};if(!body.worker||!body.model)return toast('Worker und Modell auswählen.',true);if(!confirm(`Benchmark für ${body.model} auf ${body.worker} starten? Dies erzeugt echte GPU-Last.`))return;try{const out=await api('/gateway/ui-api/autotune/start',{method:'POST',body:JSON.stringify(body)});S.autotuneSelected=out.profile?.id;toast('Auto-Tuning gestartet');await reloadAutoTune()}catch(err){toast(err.message,true)}});
$('#autotune-table')?.addEventListener('click',async e=>{const show=e.target.closest('[data-autotune-show]');if(show){S.autotuneSelected=show.dataset.autotuneShow;renderAutoTuneLevels();return}const cancel=e.target.closest('[data-autotune-cancel]');if(cancel){try{await api('/gateway/ui-api/autotune/'+encodeURIComponent(cancel.dataset.autotuneCancel)+'/cancel',{method:'POST'});toast('Benchmark-Abbruch angefordert');await reloadAutoTune()}catch(err){toast(err.message,true)}return}const apply=e.target.closest('[data-autotune-apply]');if(apply){try{await api('/gateway/ui-api/autotune/'+encodeURIComponent(apply.dataset.autotuneApply)+'/apply',{method:'POST'});toast('Empfohlene Concurrency persistent angewendet');await reloadAutoTune();await refreshAll(false)}catch(err){toast(err.message,true)}}});
function renderUsageMine(){const u=S.myUsage||{};$('#my-requests').textContent=num(u.requests);$('#my-credits').textContent=credits(u.credits);$('#my-prompt').textContent=num(u.prompt_tokens);$('#my-completion').textContent=num(u.completion_tokens)}
function renderUsage(){let a=S.recent.slice(0,Number($('#usage-limit').value||250)),q=($('#usage-filter').value||'').toLowerCase(),st=$('#usage-status').value;if(q)a=a.filter(e=>`${e.tenant} ${e.actor} ${e.subject} ${e.model} ${e.path} ${e.worker}`.toLowerCase().includes(q));if(st)a=a.filter(e=>String(e.status).startsWith(st));$('#usage-table').innerHTML=tableRecent(a,false);renderRollups()}
async function loadRollups(){const g=$('#rollup-granularity')?.value||'daily',d=$('#rollup-dimension')?.value||'global',n=$('#rollup-name')?.value?.trim()||'';if(d!=='global'&&!n){S.rollups={points:[],granularity:g,dimension:d};renderRollups();return}const q=new URLSearchParams({granularity:g,dimension:d,limit:g==='daily'?'120':'120'});if(n)q.set('name',n);S.rollups=await api('/gateway/ui-api/usage/rollups?'+q.toString());renderRollups()}
function renderRollups(){const el=$('#rollup-table');if(!el)return;const a=S.rollups?.points||[];el.innerHTML=table(['Periode','Requests','Fehler','Prompt','Completion','Credits','Ø Queue','Ø Service','Prompt tok/s','Output tok/s'],a.slice().reverse().map(x=>[esc(x.period),num(x.requests),num(x.errors),num(x.prompt_tokens),num(x.completion_tokens),credits(x.credits),x.requests?ms(x.queue_ms/x.requests):'–',x.requests?ms(x.service_ms/x.requests):'–',x.prompt_tps?num(x.prompt_tps,1):'–',x.output_tps?num(x.output_tps,1):'–']))}
$('#usage-filter').addEventListener('input',renderUsage);$('#usage-status').addEventListener('change',renderUsage);$('#usage-limit').addEventListener('change',renderUsage);$('#rollup-granularity')?.addEventListener('change',()=>{S.rollups=null;loadRollups().catch(e=>toast(e.message,true))});$('#rollup-dimension')?.addEventListener('change',()=>{const d=$('#rollup-dimension').value;$('#rollup-name').classList.toggle('hidden',d==='global');S.rollups=null;if(d==='global')loadRollups().catch(e=>toast(e.message,true))});$('#rollup-name')?.addEventListener('change',()=>{S.rollups=null;loadRollups().catch(e=>toast(e.message,true))});
function tableRecent(a,mini){const heads=mini?['Zeit','Modell','Status','Latenz']:['Zeit','Tenant / Actor','API / Pfad','Modell / Worker','Status','Queue','Service','Tokens','Credits'];const rows=a.map(e=>mini?[new Date(e.time).toLocaleTimeString('de-DE'),esc(e.model||'–'),status(e.status),ms(e.service_ms)]:[when(e.time),`<strong>${esc(e.tenant)}</strong><br><small class="muted">${esc(e.actor||e.subject)}</small>`,`<span class="status-chip">${esc(e.api)}</span><br><small class="mono muted">${esc(e.path)}</small>`,`<strong>${esc(e.model||'–')}</strong><br><small class="muted">${esc(e.worker||'–')}</small>`,status(e.status),ms(e.queue_ms),ms(e.service_ms),`${num(e.usage?.prompt_tokens)} / ${num(e.usage?.completion_tokens)}`,credits(e.actual_credits)]);return table(heads,rows)}function status(s){const c=s>=200&&s<400?'ok':s>=500?'bad':'warn';return `<span class="status-chip ${c}">${s||'–'}</span>`}
function renderJobs(){const el=$('#jobs-table');if(!el)return;const a=(S.jobs||[]).slice().sort((x,y)=>new Date(x.queued_at||x.updated_at)-new Date(y.queued_at||y.updated_at));el.innerHTML=table(['Request','Tenant / Actor','Modell / Worker','Status','Laufzeit','Tokens','Aktion'],a.map(j=>{const active=!['completed','failed','cancelled'].includes(j.state),dur=j.started_at?liveAge(j,'started_at'):liveAge(j,'queued_at'),tok=Number(j.prompt_tokens||j.estimated_prompt_tokens||0)+Number(j.completion_tokens||0),cancel=j.cancelling?'<span class="status-chip warn">cancelling</span>':active&&j.cancellable?`<button class="button small danger" data-job-cancel="${esc(j.id)}">Abbrechen</button>`:'–';return [`<span class="mono">${esc(j.id)}</span><br><small>${esc(j.api||'')} · ${esc(j.path||'')}</small>`,`<strong>${esc(j.tenant)}</strong><br><small>${esc(j.actor||'–')}</small>`,`<strong>${esc(j.model||'–')}</strong><br><small>${esc(j.worker||'wartet auf Routing')}</small>`,`<span class="status-chip ${j.state==='streaming'||j.state==='running'?'warn':j.state==='queued'?'':j.state==='cancelled'?'bad':''}">${esc(j.state)}</span>`,`${dur.toFixed(dur<10?1:0)} s`,num(tok),cancel]}))}
function renderBatches(){const el=$('#batches-table'),meta=$('#batches-meta');if(!el||!meta)return;const b=S.batches;if(!b){meta.textContent='Lade Batch-Status …';el.innerHTML='';return}if(!b.enabled){meta.className='callout warn-callout';meta.textContent='Durable Batch Jobs sind deaktiviert. Aktiviere batch_jobs.enabled und die Service Class batch in der Konfiguration.';el.innerHTML='<div class="empty">Batch-System deaktiviert</div>';return}meta.className='callout';meta.textContent=`Retention ${b.retention||'–'} · maximal ${num(b.max_jobs)} Jobs · ${num(b.max_concurrent)} parallel · Input-Limit ${bytes(b.max_input_bytes)}`;const a=(b.jobs||[]).slice().sort((x,y)=>new Date(y.created_at)-new Date(x.created_at));el.innerHTML=table(['Batch','Tenant / Actor','Request','Status','Versuche','Zeit','Aktionen'],a.map(j=>{const state=j.state||'',statusClass=state==='completed'?'ok':state==='failed'||state==='cancelled'?'bad':state==='running'||state==='pausing'||state==='cancelling'?'warn':'';const actions=[];if(state==='queued'||state==='running')actions.push(`<button class="button small" data-batch-action="pause" data-batch-id="${esc(j.id)}">Pause</button>`);if(state==='paused')actions.push(`<button class="button small primary" data-batch-action="resume" data-batch-id="${esc(j.id)}">Resume</button>`);if(['queued','running','paused','pausing'].includes(state))actions.push(`<button class="button small danger" data-batch-action="cancel" data-batch-id="${esc(j.id)}">Cancel</button>`);if(j.output_ref)actions.push(`<button class="button small" data-batch-output="${esc(j.id)}">Output</button>`);const times=`${when(j.created_at)}${j.finished_at?`<br><small>fertig ${when(j.finished_at)}</small>`:j.started_at?`<br><small>gestartet ${when(j.started_at)}</small>`:''}`;const err=j.error?`<br><small class="danger-text">${esc(j.error)}</small>`:'';return [`<span class="mono">${esc(j.id)}</span><br><small>${esc(j.service_class||'batch')}</small>`,`<strong>${esc(j.identity?.tenant||'–')}</strong><br><small>${esc(j.identity?.actor||j.identity?.subject||'–')}</small>`,`<strong>${esc(j.model||'–')}</strong><br><small>${esc(j.path||'–')}</small>`,`<span class="status-chip ${statusClass}">${esc(state)}</span>${err}`,num(j.attempts),times,actions.join(' ')||'–']}))}
async function batchAction(id,action){try{await api('/gateway/ui-api/batches/'+encodeURIComponent(id)+'/'+action,{method:'POST'});toast(`Batch ${action}: ${id}`);S.batches=await api('/gateway/ui-api/batches');renderBatches()}catch(e){toast(e.message,true)}}
async function downloadBatchOutput(id){try{const r=await fetch('/gateway/ui-api/batches/'+encodeURIComponent(id)+'/output',{headers:authHeaders(),credentials:'same-origin'});if(!r.ok){let msg=`HTTP ${r.status}`;try{const x=await r.json();msg=x?.error?.message||x?.message||msg}catch{}throw new Error(msg)}const blob=await r.blob(),u=URL.createObjectURL(blob),a=document.createElement('a');a.href=u;a.download=id+'.response';document.body.appendChild(a);a.click();a.remove();setTimeout(()=>URL.revokeObjectURL(u),1000)}catch(e){toast(e.message,true)}}
async function cancelJob(id){if(!confirm(`Request ${id} wirklich abbrechen?`))return;try{await api('/gateway/ui-api/jobs/'+encodeURIComponent(id)+'/cancel',{method:'POST'});toast('Job-Abbruch angefordert');S.jobs=(await api('/gateway/ui-api/jobs')).jobs||[];renderJobs()}catch(e){toast(e.message,true)}}
$('#refresh-jobs')?.addEventListener('click',async()=>{try{S.jobs=(await api('/gateway/ui-api/jobs')).jobs||[];renderJobs()}catch(e){toast(e.message,true)}});
$('#refresh-batches')?.addEventListener('click',async()=>{try{S.batches=await api('/gateway/ui-api/batches');renderBatches()}catch(e){toast(e.message,true)}});
function renderOperations(){const a=S.operations;$('#operations-list').innerHTML=a.length?a.map(o=>`<div class="operation"><div class="operation-top"><div><strong>${esc(o.type)} · ${esc(o.model)}</strong><small>${esc(o.worker)} · ${when(o.started_at)}</small></div><div><span class="status-chip ${o.status==='completed'?'ok':o.status==='failed'?'bad':o.status==='running'?'warn':''}">${esc(o.status)}</span></div></div><div class="progress"><div style="width:${100*(o.progress||0)}%"></div></div><div class="muted">${esc(o.message||'')} ${o.total?`· ${bytes(o.completed)} / ${bytes(o.total)}`:''}</div>${o.error?`<div class="callout danger">${esc(o.error)}</div>`:''}${['running','queued'].includes(o.status)?`<div class="actions" style="margin-top:10px"><button class="button small danger" data-operation-cancel="${esc(o.id)}">Abbrechen</button></div>`:''}</div>`).join(''):'<div class="empty">Keine Operationen</div>'}
async function cancelOperation(id){try{await api('/gateway/ui-api/operations/'+id+'/cancel',{method:'POST'});toast('Abbruch angefordert');await refreshAll(false)}catch(e){toast(e.message,true)}}
function renderStorage(){const st=S.storage;if(!st)return;const rt=st.usage?.retention||{};$('#storage-total').textContent=bytes(st.total_bytes);$('#storage-usage-files').textContent=num((rt.raw_files??st.usage?.files)||0);$('#storage-config-override').textContent=st.config_override_active?'aktiv':'bootstrap';$('#storage-flush-interval').textContent=st.flush_interval||'–';$('#storage-summary').innerHTML=`<strong>${esc(st.mode||'local-persistent')}</strong> · <span class="mono">${esc(st.data_dir||'–')}</span> · Detail ${bytes(rt.raw_bytes||st.usage?.size_bytes||0)} · Rollups ${bytes((rt.daily_bytes||0)+(rt.monthly_bytes||0))}`;const rows=(st.files||[]).map(f=>[esc(f.name),esc(f.kind),`<span class="mono">${esc(f.path)}</span>`,f.exists?'<span class="status-chip ok">persistent</span>':'<span class="status-chip">noch leer</span>',bytes(f.size_bytes||0),f.modified_at?esc(when(f.modified_at)):'–']);$('#storage-table').innerHTML=table(['Store','Typ','Pfad','Status','Größe','Geändert'],rows);$('#storage-volatile').innerHTML=(st.volatile||[]).map(x=>`<span class="model-tag">${esc(x)}</span>`).join('')||'<span class="muted">–</span>';const monthly=Number(rt.monthly_months||0)<=0?'unbegrenzt':`${num(rt.monthly_months)} Monate`;$('#retention-summary').innerHTML=`<div class="retention-card"><span>Request-Details</span><strong>${num(rt.detail_days||0)} Tage</strong><small>${num(rt.raw_files||0)} Dateien · ${bytes(rt.raw_bytes||0)}</small></div><div class="retention-card"><span>Tagesaggregate</span><strong>${num(rt.daily_days||0)} Tage</strong><small>${num(rt.daily_files||0)} Dateien · ${bytes(rt.daily_bytes||0)}</small></div><div class="retention-card"><span>Monatsaggregate</span><strong>${monthly}</strong><small>${num(rt.monthly_files||0)} Dateien · ${bytes(rt.monthly_bytes||0)}</small></div><div class="retention-card"><span>Letzte Kompaktierung</span><strong>${rt.last_compaction?esc(when(rt.last_compaction)):'–'}</strong><small>${rt.last_reclaimed_bytes?bytes(rt.last_reclaimed_bytes)+' freigegeben':'kein Speicher freigegeben'}${rt.last_error?' · '+esc(rt.last_error):''}</small></div>`;$('#storage-flush').onclick=async()=>{try{const out=await api('/gateway/ui-api/storage/flush',{method:'POST'});S.storage=out.storage;renderStorage();toast('Persistenter State geflusht')}catch(e){toast(e.message,true)}};$('#storage-compact').onclick=async()=>{if(!confirm('Usage-Journale jetzt gemäß Retention-Regeln kompaktieren?'))return;try{const out=await api('/gateway/ui-api/storage/compact',{method:'POST'});S.storage=out.storage;S.rollups=null;renderStorage();toast(`Kompaktierung abgeschlossen · ${bytes(out.retention?.last_reclaimed_bytes||0)} freigegeben`)}catch(e){toast(e.message,true)}};$('#storage-backup').onclick=async()=>{try{const r=await fetch('/gateway/ui-api/storage/backup',{headers:authHeaders(),credentials:'same-origin'});if(!r.ok){let msg=`HTTP ${r.status}`;try{const x=await r.json();msg=x?.error?.message||x?.error||msg}catch{}throw new Error(msg)}const blob=await r.blob(),cd=r.headers.get('content-disposition')||'',m=/filename="?([^";]+)"?/i.exec(cd),name=m?.[1]||'ollama-gateway-backup.zip',u=URL.createObjectURL(blob),a=document.createElement('a');a.href=u;a.download=name;document.body.appendChild(a);a.click();a.remove();setTimeout(()=>URL.revokeObjectURL(u),1000);toast('Backup erstellt')}catch(e){toast(e.message,true)}}}
function simulatorModels(){const set=new Set();(S.models||[]).forEach(inv=>(inv.models||[]).forEach(m=>set.add(m.model||m.name)));Object.keys(S.overview?.model_aliases||{}).forEach(x=>set.add(x));return [...set].filter(Boolean).sort()}
function simulatorTenants(){const set=new Set();Object.keys(S.overview?.tenants||{}).forEach(x=>set.add(x));Object.keys(S.policies?.effective||S.policies?.policies||{}).filter(x=>x!=='*').forEach(x=>set.add(x));(S.apiKeys||[]).forEach(k=>k.tenant&&set.add(k.tenant));return [...set].sort()}
function renderPolicySimulator(){const form=$('#policy-simulator-form');if(!form)return;$('#sim-tenant-list').innerHTML=simulatorTenants().map(x=>`<option value="${esc(x)}"></option>`).join('');$('#sim-model-list').innerHTML=simulatorModels().map(x=>`<option value="${esc(x)}"></option>`).join('');const tenant=$('#sim-tenant')?.value||'';const keys=(S.apiKeys||[]).filter(k=>!tenant||k.tenant===tenant);const keySel=$('#sim-api-key');const old=keySel?.value||'';if(keySel){keySel.innerHTML='<option value="">Tenant-Regel ohne Key-ACL</option>'+keys.map(k=>`<option value="${esc(k.id||'')}" data-name="${esc(k.name)}">${esc(k.name)} · ${esc(k.tenant)}${k.allowed_models?.length||k.denied_models?.length?' · ACL':''}</option>`).join('');if([...keySel.options].some(o=>o.value===old))keySel.value=old}const sc=$('#sim-service-class');if(sc){const oldsc=sc.value;sc.innerHTML='<option value="">Default</option>'+Object.keys(S.overview?.service_classes?.classes||{}).sort().map(x=>`<option value="${esc(x)}">${esc(x)}</option>`).join('');if([...sc.options].some(o=>o.value===oldsc))sc.value=oldsc}if(!S.simulation)return;const r=S.simulation,ok=r.decision==='would_route';const access=r.access||{};const alias=(r.alias_candidates||[]).map(x=>`<div class="sim-step ${x.routable?'ok':'bad'}"><strong>${esc(x.model)}</strong><span>${x.routable?'routbar':esc(x.reason||'nicht routbar')}</span></div>`).join('');const workers=(r.workers||[]).map((w,i)=>`<tr class="${w.worker===r.selected_worker?'selected-row':''}"><td><strong>${esc(w.worker)}</strong>${w.worker===r.selected_worker?' <span class="status-chip ok">selected</span>':''}</td><td>${w.eligible?'✓':'✗'}${w.available_now?' · frei':w.eligible?' · ausgelastet':''}</td><td>${w.placement?.allowed?'✓ '+esc(w.placement.source||''):'✗ '+esc(w.placement?.source||'')}</td><td>${w.inventory_known?(w.installed?'installiert':'fehlt'):'unbekannt'}${w.loaded?' · loaded':''}</td><td>${w.active||0}/${w.max_concurrent||0} · model ${w.model_active||0}/${w.model_limit||0}</td><td>${w.output_tps?num(w.output_tps,1)+' tok/s':'–'}</td><td>${w.vram_percent?num(w.vram_percent,1)+' %':'–'}</td><td>${w.eligible?num(w.score,1):esc(w.reason||'–')}</td></tr>`).join('');const errors=(r.errors||[]).length?`<div class="callout danger"><strong>Hinweise:</strong> ${esc(r.errors.join(' · '))}</div>`:'';$('#policy-simulator-result').innerHTML=`<div class="sim-decision ${ok?'ok':'bad'}"><div><small>Entscheidung</small><strong>${esc(r.decision)}</strong></div><div><small>Auflösung</small><strong>${esc(r.requested_model)}${r.resolved_model&&r.resolved_model!==r.requested_model?' → '+esc(r.resolved_model):''}</strong></div><div><small>Worker</small><strong>${esc(r.selected_worker||'–')}</strong></div><div><small>Estimated Cost</small><strong>${num(r.estimated_credits||0,2)} cr</strong></div></div><div class="sim-grid"><div class="sim-card"><h3>Model Access</h3><div class="kv"><div>Tenant ACL</div><div>${access.tenant_allowed?'✓ erlaubt':'✗ gesperrt'}</div><div>API-Key ACL</div><div>${access.key_applied?(access.key_allowed?'✓ erlaubt':'✗ gesperrt'):'nicht gesetzt'}</div><div>Gesamt</div><div><strong>${access.allowed?'✓ PASS':'✗ DENY'}</strong></div></div></div><div class="sim-card"><h3>Preflight</h3><div class="kv"><div>Capabilities</div><div>${r.capabilities_ok?'✓':'✗'} ${esc((r.capabilities_required||[]).join(', ')||'keine')}</div><div>Context</div><div>${r.context_ok?'✓':'✗'} ${num(r.context_requested||0)} / effektiv ${r.context_effective_max?num(r.context_effective_max):'?'} · Modell-Max ${r.context_length?num(r.context_length):'?'}</div><div>QoS</div><div>${esc(r.service_class||'–')} · weight ${num(r.service_class_config?.weight||0,1)}</div></div></div></div>${r.alias?`<div class="sim-card"><h3>Alias ${esc(r.alias)}</h3>${alias||'<div class="empty">Keine Kandidaten</div>'}</div>`:''}${errors}<div class="table-wrap"><table><thead><tr><th>Worker</th><th>Eligibility</th><th>Placement</th><th>Inventar</th><th>Slots</th><th>Output</th><th>VRAM</th><th>Score / Grund</th></tr></thead><tbody>${workers||'<tr><td colspan="8">Keine Worker</td></tr>'}</tbody></table></div>`}
$('#sim-tenant')?.addEventListener('input',()=>{S.simulation=null;renderPolicySimulator()});
$('#policy-simulator-form')?.addEventListener('submit',async e=>{e.preventDefault();const keySel=$('#sim-api-key'),opt=keySel?.selectedOptions?.[0];const body={tenant:$('#sim-tenant').value.trim(),api_key_id:keySel?.value||'',api_key_name:!keySel?.value?(opt?.dataset?.name||''):'',model:$('#sim-model').value.trim(),service_class:$('#sim-service-class').value,input_tokens:Number($('#sim-input-tokens').value||0),output_tokens:Number($('#sim-output-tokens').value||0),required_capabilities:($('#sim-capabilities').value||'').split(',').map(x=>x.trim()).filter(Boolean)};try{S.simulation=await api('/gateway/ui-api/policy-simulator',{method:'POST',body:JSON.stringify(body)});renderPolicySimulator()}catch(err){toast(err.message,true)}});
function renderSecurity(){const c=S.config,auth=c.auth||{},oidc=auth.oidc||{},keys=S.apiKeys||[];const keyRows=keys.length?keys.map(k=>`<div class="api-key-row"><div class="grow"><div class="api-key-title"><strong>${esc(k.name)}</strong><span class="status-chip ${k.source==='persistent'?'ok':k.source==='runtime'?'warn':''}">${esc(k.source||'config')}</span></div><small>${esc(k.tenant)} · ${esc(k.application||k.subject||'–')}</small><div class="api-key-meta"><span class="mono">${esc(k.key_hint||'Secret redacted')}</span>${k.service_class?`<span>QoS: ${esc(k.service_class)}</span>`:''}${(k.scopes||[]).length?`<span>${esc(k.scopes.join(', '))}</span>`:'<span>keine Scopes</span>'}${(k.allowed_models||[]).length?`<span>Allow: ${esc(k.allowed_models.join(', '))}</span>`:''}${(k.denied_models||[]).length?`<span>Deny: ${esc(k.denied_models.join(', '))}</span>`:''}${k.created_at?`<span>${esc(when(k.created_at))}</span>`:''}</div></div>${k.deletable?`<button class="button small danger-button" type="button" data-api-key-delete="${esc(k.id)}" data-api-key-name="${esc(k.name)}">Löschen</button>`:'<span class="muted api-key-locked">aus Konfiguration</span>'}</div>`).join(''):'<div class="empty">Keine API-Keys</div>';const cards=[['OIDC',`<div class="kv"><div>Status</div><div>${oidc.enabled?'aktiv':'inaktiv'}</div><div>Issuer</div><div class="mono">${esc(oidc.issuer||'–')}</div><div>Audience</div><div class="mono">${esc(oidc.audience||'–')}</div><div>Admin Groups</div><div>${esc((oidc.admin_groups||[]).join(', ')||'–')}</div></div>`],['API Keys',`<div class="security-card-head"><div><p>UI-Keys werden persistent gespeichert; im State liegt ausschließlich ihr SHA-256-Hash.</p></div><button id="new-api-key" class="button primary small" type="button">API-Key erstellen</button></div><div class="api-key-list">${keyRows}</div>`],['Persistenz',`<div class="kv"><div>Data directory</div><div class="mono">${esc(c.storage?.data_dir||'–')}</div><div>Config</div><div class="mono">${esc(c.storage?.config_file||'–')}</div><div>API keys</div><div class="mono">${esc(c.storage?.api_keys_file||'–')}</div><div>Policies</div><div class="mono">${esc(c.storage?.policies_file||'–')}</div><div>Metrics</div><div class="mono">${esc(c.storage?.metrics_file||'–')}</div><div>Quota</div><div class="mono">${esc(c.storage?.quota_file||'–')}</div><div>Worker performance</div><div class="mono">${esc(c.storage?.worker_performance_file||'–')}</div><div>Model placement</div><div class="mono">${esc(c.storage?.model_placement_file||'–')}</div><div>Worker state</div><div class="mono">${esc(c.storage?.worker_state_file||'–')}</div><div>Usage</div><div class="mono">${esc(c.usage?.journal_dir||'–')}</div></div>`],['IP Bypass',auth.ip_bypass?.length?auth.ip_bypass.map(x=>`<div class="health-item"><div><strong>${esc((x.cidrs||[]).join(', '))}</strong><small>${esc(x.tenant)} · ${esc(x.subject||'')}</small></div></div>`).join(''):'<div class="empty">Kein IP-Bypass</div>'],['Trusted Proxies',`<div class="model-tags">${(auth.trusted_proxies||[]).map(x=>`<span class="model-tag mono">${esc(x)}</span>`).join('')||'<span class="muted">Keine</span>'}</div>`]];$('#security-grid').innerHTML=cards.map(([t,b])=>`<article class="panel security-card"><h3>${t}</h3>${b}</article>`).join('');$('#new-api-key')?.addEventListener('click',openAPIKeyDialog)}
function openAPIKeyDialog(){const f=$('#api-key-form');f.reset();$('#api-key-tenant').value=S.session?.tenant||'';const sel=$('#api-key-service-class'),cls=Object.keys(S.overview?.service_classes?.classes||{}).sort();if(sel)sel.innerHTML='<option value="">Gateway-Default</option>'+cls.map(n=>`<option value="${esc(n)}">${esc(n)}</option>`).join('');$('#api-key-dialog').showModal()}
$('#api-key-form')?.addEventListener('submit',async e=>{e.preventDefault();const splitRules=v=>v.split(/[\n,]+/).map(x=>x.trim()).filter(Boolean);const scopes=$('#api-key-scopes').value.split(',').map(x=>x.trim()).filter(Boolean);const body={name:$('#api-key-name').value.trim(),tenant:$('#api-key-tenant').value.trim(),subject:$('#api-key-subject').value.trim(),application:$('#api-key-application').value.trim(),scopes,allowed_models:splitRules($('#api-key-allowed-models').value),denied_models:splitRules($('#api-key-denied-models').value),service_class:$('#api-key-service-class')?.value||''};if(!body.name||!body.tenant)return toast('Name und Tenant sind erforderlich.',true);try{const out=await api('/gateway/ui-api/api-keys',{method:'POST',body:JSON.stringify(body)});$('#api-key-dialog').close();$('#created-key-name').textContent=out.key?.name||body.name;$('#created-key-secret').value=out.secret||'';$('#created-key-dialog').showModal();S.apiKeys=(await api('/gateway/ui-api/api-keys')).keys||[];renderSecurity();toast('API-Key erstellt')}catch(err){toast(err.message,true)}});
$('#copy-created-key')?.addEventListener('click',async()=>{const v=$('#created-key-secret').value;if(!v)return;if(await copyText(v))toast('API-Key kopiert');else toast('Kopieren nicht möglich – bitte den Key manuell markieren.',true)});
$('#created-key-dialog')?.addEventListener('close',()=>{$('#created-key-secret').value='' });
$('#security-grid')?.addEventListener('click',async e=>{const b=e.target.closest('[data-api-key-delete]');if(!b)return;const name=b.dataset.apiKeyName||'diesen API-Key';if(!confirm(`API-Key „${name}“ wirklich löschen?\n\nDer Key wird sofort ungültig.`))return;try{await api('/gateway/ui-api/api-keys/'+encodeURIComponent(b.dataset.apiKeyDelete),{method:'DELETE'});S.apiKeys=(await api('/gateway/ui-api/api-keys')).keys||[];renderSecurity();toast('API-Key gelöscht')}catch(err){toast(err.message,true)}});
$('#copy-config').onclick=async()=>{if(await copyText($('#config-json').value))toast('Konfiguration kopiert');else toast('Kopieren nicht möglich.',true)};
function setConfigEditing(on){const ed=$('#config-json');if(!ed)return;ed.readOnly=!on;S.configDirty=on;$('#config-actions')?.classList.toggle('hidden',!on);$('#edit-config').textContent=on?'Bearbeitung aktiv':'Bearbeiten';if(on){ed.focus();$('#config-status').textContent='JSON wird vor dem Speichern vollständig validiert. Aktivierung aller Änderungen erfolgt nach einem Gateway-Neustart.'}else{$('#config-status').textContent='Effektive Konfiguration. Änderungen an der Storage-Sektion sind nur in der Bootstrap-Datei möglich.'}}
$('#edit-config')?.addEventListener('click',()=>setConfigEditing($('#config-json').readOnly));
$('#cancel-config')?.addEventListener('click',()=>{S.configDirty=false;$('#config-json').value=JSON.stringify(S.config,null,2);setConfigEditing(false)});
$('#save-config')?.addEventListener('click',async()=>{let obj;try{obj=JSON.parse($('#config-json').value)}catch(e){return toast('Ungültiges JSON: '+e.message,true)};if(!confirm('Konfiguration persistent speichern? Die Änderungen werden vollständig nach dem nächsten Gateway-Neustart aktiv.'))return;try{const out=await api('/gateway/ui-api/config',{method:'PUT',body:JSON.stringify(obj)});S.configDirty=false;setConfigEditing(false);$('#config-status').className='callout ok-callout';$('#config-status').textContent='Persistent gespeichert. Gateway neu starten, um alle Änderungen zu aktivieren.';toast(out.message||'Konfiguration gespeichert')}catch(e){toast(e.message,true)}});
$('#reset-config')?.addEventListener('click',async()=>{if(!confirm('Persistenten Konfigurations-Override löschen? Nach dem nächsten Neustart wird wieder die Bootstrap-config.json verwendet.'))return;try{const out=await api('/gateway/ui-api/config',{method:'DELETE'});toast(out.message||'Persistenter Override gelöscht');$('#config-status').className='callout warn-callout';$('#config-status').textContent='Override gelöscht. Nach Neustart gilt wieder die Bootstrap-Konfiguration.'}catch(e){toast(e.message,true)}});
function table(headers,rows){if(!rows.length)return '<div class="empty">Keine Daten</div>';return `<table><thead><tr>${headers.map(x=>`<th>${x}</th>`).join('')}</tr></thead><tbody>${rows.map(r=>`<tr>${r.map(c=>`<td>${c??''}</td>`).join('')}</tr>`).join('')}</tbody></table>`}
document.addEventListener('click',e=>{const b=e.target.closest('button');if(!b)return;if(b.dataset.modelAction)modelAction(b.dataset.modelAction,b.dataset.worker,b.dataset.model);else if(b.dataset.policyEdit!==undefined)openPolicy(b.dataset.policyEdit);else if(b.dataset.policyDelete!==undefined)deletePolicy(b.dataset.policyDelete);else if(b.dataset.jobCancel)cancelJob(b.dataset.jobCancel);else if(b.dataset.batchAction)batchAction(b.dataset.batchId,b.dataset.batchAction);else if(b.dataset.batchOutput)downloadBatchOutput(b.dataset.batchOutput);else if(b.dataset.operationCancel)cancelOperation(b.dataset.operationCancel)});
// ---- Local in-memory infrastructure map -------------------------------------
function stopClusterStream(){if(S.clusterAbort){S.clusterAbort.abort();S.clusterAbort=null}S.cluster.connected=false;updateClusterConnection()}
function startClusterStream(){if(!isAdmin()||S.clusterAbort)return;const ctl=new AbortController();S.clusterAbort=ctl;void clusterStreamLoop(ctl.signal)}
async function clusterStreamLoop(signal){let retry=700;while(!signal.aborted){try{await clusterStreamOnce(signal);retry=700}catch(e){if(signal.aborted)return;S.cluster.connected=false;updateClusterConnection(e.message);if(e.status===401){showLogin('Sitzung abgelaufen oder Credential ungültig.');return}try{handleClusterSnapshot(await api('/gateway/ui-api/infrastructure'))}catch{}await liveSleep(retry,signal);retry=Math.min(5000,retry*1.7)}}}
async function clusterStreamOnce(signal){const headers=authHeaders();headers.Accept='text/event-stream';const r=await fetch('/gateway/ui-api/infrastructure/stream',{headers,credentials:'same-origin',signal});if(!r.ok){const e=new Error(`Infrastructure-Stream HTTP ${r.status}`);e.status=r.status;throw e}if(!r.body)throw new Error('Infrastructure-Stream nicht verfügbar');S.cluster.connected=true;updateClusterConnection();const reader=r.body.getReader(),decoder=new TextDecoder();let buf='';while(!signal.aborted){const {value,done}=await reader.read();if(done)throw new Error('Infrastructure-Stream beendet');buf+=decoder.decode(value,{stream:true}).replace(/\r\n/g,'\n');let i;while((i=buf.indexOf('\n\n'))>=0){const raw=buf.slice(0,i);buf=buf.slice(i+2);let event='message';const data=[];for(const line of raw.split('\n')){if(line.startsWith('event:'))event=line.slice(6).trim();else if(line.startsWith('data:'))data.push(line.slice(5).trimStart())}if(event==='snapshot'&&data.length){try{handleClusterSnapshot(JSON.parse(data.join('\n')))}catch{}}}}}
function handleClusterSnapshot(x){S.cluster={...S.cluster,...x,gateways:Array.isArray(x?.gateways)?x.gateways:[],workers:Array.isArray(x?.workers)?x.workers:[],requests:Array.isArray(x?.requests)?x.requests:[],counts:x?.counts||{},connected:true};updateClusterConnection();renderClusterPanel()}
function updateClusterConnection(msg=''){const el=$('#cluster-stream-state');if(!el)return;el.className='status-chip '+(S.cluster.connected?'ok':'warn');el.textContent=S.cluster.connected?'In-Memory live':(msg?'Reconnect':'verbinde …')}
function clusterFilteredRequests(includeTerminal=true){const q=($('#cluster-search')?.value||'').trim().toLowerCase();return (S.cluster.requests||[]).filter(r=>(includeTerminal||liveActive(r))&&(!q||`${r.gateway_name||''} ${r.tenant||''} ${r.actor||''} ${r.worker||''} ${r.model||''}`.toLowerCase().includes(q)))}
function pct(v,total){return total>0?Math.max(0,Math.min(100,100*Number(v||0)/Number(total))):0}
function renderClusterPanel(){if(!isAdmin())return;const c=S.cluster.counts||{};$('#cluster-gateways')&&($('#cluster-gateways').textContent=num(c.gateways??S.cluster.gateways.length));$('#cluster-workers')&&($('#cluster-workers').textContent=num(c.workers??S.cluster.workers.length));$('#cluster-active')&&($('#cluster-active').textContent=num(c.active??clusterFilteredRequests(false).length));$('#cluster-queue')&&($('#cluster-queue').textContent=num(S.cluster.queue||0));$('#cluster-models')&&($('#cluster-models').textContent=num(c.models||0));$('#cluster-map-empty')?.classList.toggle('hidden',(S.cluster.gateways?.length||0)>0);renderClusterGateways();renderClusterWorkers();updateClusterConnection()}
function renderClusterGateways(){const el=$('#cluster-gateway-list');if(!el)return;const gs=S.cluster.gateways||[];el.innerHTML=gs.length?gs.map(g=>{const age=Math.max(0,(Date.now()-new Date(g.last_seen).getTime())/1000);return `<div class="cluster-node"><div class="cluster-node-head"><div><strong>${esc(g.node_name)}</strong><small>${esc(g.hostname||g.node_id)}</small></div><span class="status-chip ${age<8?'ok':'warn'}">${age<3?'live':age.toFixed(0)+'s'}</span></div><div class="cluster-mini-grid"><span>Queue <b>${num(g.queued)}</b></span><span>Running <b>${num(g.running)}</b></span><span>Requests <b>${num(g.live_active)}</b></span><span>Worker <b>${num(g.workers)}</b></span></div></div>`}).join(''):'<div class="empty">Keine Gateway-Nodes</div>'}
function renderClusterWorkers(){const el=$('#cluster-worker-list');if(!el)return;const ws=S.cluster.workers||[];el.innerHTML=ws.length?ws.map(w=>{const memUsed=Number(w.memory_used_bytes||w.resident_bytes||0),memTotal=Number(w.memory_total_bytes||w.memory_capacity_bytes||0),vrUsed=Number(w.vram_used_bytes||w.vram_bytes||0),vrTotal=Number(w.vram_total_bytes||w.vram_capacity_bytes||0),vp=pct(vrUsed,vrTotal),mp=pct(memUsed,memTotal),models=(w.loaded_models||[]).slice(0,5),memLabel=w.memory_used_bytes?'System RAM':'Model resident',vrLabel=w.vram_used_bytes?'VRAM used':'VRAM footprint',gpu=Number(w.gpu_utilization_percent||0);return `<div class="cluster-node"><div class="cluster-node-head"><div><strong>${esc(w.name)}</strong><small>${esc((w.gateways||[]).join(', ')||w.url)}${w.telemetry_source?` · ${esc(w.telemetry_source)}`:''}</small></div><span class="status-chip ${w.healthy?'ok':'bad'}">${w.active}/${w.max_concurrent}</span></div><div class="resource-line"><span>${memLabel}</span><b>${bytes(memUsed)}${memTotal?' / '+bytes(memTotal):''}</b></div><div class="resource-bar"><i style="width:${memTotal?mp:Math.min(100,10+(w.loaded_models?.length||0)*15)}%"></i></div><div class="resource-line"><span>${vrLabel}</span><b>${bytes(vrUsed)}${vrTotal?' / '+bytes(vrTotal):''}</b></div><div class="resource-bar vram"><i style="width:${vrTotal?vp:Math.min(100,10+(w.loaded_models?.length||0)*15)}%"></i></div>${gpu?`<div class="resource-line"><span>GPU utilization</span><b>${num(gpu,1)}%</b></div><div class="resource-bar gpu"><i style="width:${Math.min(100,gpu)}%"></i></div>`:''}<div class="live-worker-models">${models.map(m=>`<span>${esc(m.name||m.model)} · ${bytes(m.size_vram)}</span>`).join('')||'<span>idle</span>'}</div>${w.telemetry_error?`<small class="muted">Telemetry: ${esc(w.telemetry_error)}</small>`:''}</div>`}).join(''):'<div class="empty">Keine Worker</div>'}
function clusterLayout(reqs,W,H){const gs=(S.cluster.gateways||[]).slice(0,8),ws=(S.cluster.workers||[]).slice(0,12),margin=65,gatewayPos=new Map(),queuePos=new Map(),workerPos=new Map(),modelPos=new Map(),tenantPos=new Map();const tenants=[...new Set(reqs.map(r=>r.tenant).filter(Boolean))].slice(0,9);tenants.forEach((n,i)=>tenantPos.set(n,{x:Math.max(55,W*.055),y:margin+(H-2*margin)*(tenants.length===1?.5:i/Math.max(1,tenants.length-1))}));gs.forEach((g,i)=>{const y=margin+(H-2*margin)*(gs.length===1?.5:i/Math.max(1,gs.length-1));gatewayPos.set(g.node_name,{x:W*.27,y});queuePos.set(g.node_name,{x:W*.44,y})});ws.forEach((w,i)=>{const y=margin+(H-2*margin)*(ws.length===1?.5:i/Math.max(1,ws.length-1));const key=w.name+'\0'+w.url,lm=w.loaded_models||[];workerPos.set(key,{x:W*.72,y,w});for(const [j,m] of lm.slice(0,4).entries()){const off=(j-(Math.min(4,lm.length)-1)/2)*24;modelPos.set(key+'\0'+(m.name||m.model),{x:W*.94,y:Math.max(36,Math.min(H-36,y+off)),m})}});return{tenantPos,gatewayPos,queuePos,workerPos,modelPos,gateways:gs,workers:ws}}
function clusterWorkerPoint(l,r){for(const [k,p] of l.workerPos){if(p.w.name===r.worker)return {key:k,p}}return null}
function clusterRoute(l,r,H){const tp=l.tenantPos.get(r.tenant)||{x:55,y:40+(liveHash(r.tenant)%(Math.max(80,H-80)))},gp=l.gatewayPos.get(r.gateway_name)||{x:220,y:H*.5},qp=l.queuePos.get(r.gateway_name)||{x:360,y:gp.y},wp=clusterWorkerPoint(l,r),w=wp?.p||{x:qp.x+220,y:H*.5},m=l.modelPos.get((wp?.key||'')+'\0'+r.model)||w,j=((liveHash(r.actor)%17)-8);return[Object.assign(curve({x:tp.x+6,y:tp.y+j},gp,.45,j*.25),{w:1}),Object.assign(curve(gp,qp,.5,j*.1),{w:.55}),Object.assign(curve(qp,w,.5,(w.y-qp.y)*-.08+j),{w:1.35}),Object.assign(curve(w,m,.5,j*.1),{w:.55})]}
function drawClusterMap(tms){const canvas=$('#cluster-map-canvas'),stage=$('#cluster-map-stage');if(!canvas||!stage)return;const rect=stage.getBoundingClientRect();if(rect.width<20||rect.height<20)return;const dpr=Math.min(2,window.devicePixelRatio||1),cw=Math.round(rect.width*dpr),ch=Math.round(rect.height*dpr);if(canvas.width!==cw||canvas.height!==ch){canvas.width=cw;canvas.height=ch}const ctx=canvas.getContext('2d');ctx.setTransform(dpr,0,0,dpr,0,0);const W=rect.width,H=rect.height;ctx.clearRect(0,0,W,H);const all=clusterFilteredRequests(true).slice(0,320),active=all.filter(liveActive),l=clusterLayout(active,W,H),time=tms/1000;ctx.lineCap='round';ctx.lineJoin='round';for(const g of l.gateways){const gp=l.gatewayPos.get(g.node_name),qp=l.queuePos.get(g.node_name);ctx.strokeStyle='rgba(26,128,95,.22)';ctx.lineWidth=1.3;drawCurve(ctx,curve(gp,qp,.5,0));for(const [k,p] of l.workerPos){if((p.w.gateways||[]).includes(g.node_name)){ctx.strokeStyle='rgba(20,127,165,.18)';ctx.lineWidth=1;drawCurve(ctx,curve(qp,p,.5,(p.y-qp.y)*-.08))}}}for(const [key,p] of l.modelPos){const parts=key.split('\0'),wp=l.workerPos.get(parts[0]+'\0'+parts[1]);if(wp){ctx.strokeStyle='rgba(20,127,165,.12)';ctx.lineWidth=.8;drawCurve(ctx,curve(wp,p,.5,0))}}for(const r of active){const tp=l.tenantPos.get(r.tenant),gp=l.gatewayPos.get(r.gateway_name);if(tp&&gp){ctx.strokeStyle=liveColor(r.tenant,.07);ctx.lineWidth=.7;drawCurve(ctx,curve(tp,gp,.45,0))}}drawClusterNodes(ctx,l,active,W,H);S.clusterHit=[];for(const r of all)drawClusterRequest(ctx,l,r,H,time)}
function drawClusterNodes(ctx,l,active,W,H){ctx.save();ctx.textBaseline='middle';for(const [n,p] of l.tenantPos){ctx.fillStyle=liveColor(n,.8);ctx.beginPath();ctx.arc(p.x,p.y,3.8,0,Math.PI*2);ctx.fill();ctx.fillStyle='rgba(55,74,92,.82)';ctx.textAlign='left';ctx.font='9px ui-monospace,SFMono-Regular,Consolas,monospace';ctx.fillText(n.slice(0,15),p.x+8,p.y)}for(const g of l.gateways){const p=l.gatewayPos.get(g.node_name),q=l.queuePos.get(g.node_name),live=active.filter(r=>r.gateway_name===g.node_name).length;ctx.fillStyle='rgba(255,255,255,.97)';ctx.strokeStyle='rgba(26,128,95,.58)';ctx.lineWidth=1.4;ctx.beginPath();ctx.arc(p.x,p.y,14+Math.min(6,Math.sqrt(live)*2),0,Math.PI*2);ctx.fill();ctx.stroke();ctx.fillStyle='rgba(37,51,69,.94)';ctx.textAlign='center';ctx.font='700 9px ui-monospace,SFMono-Regular,Consolas,monospace';ctx.fillText(g.node_name.slice(0,16),p.x,p.y-2);ctx.fillStyle='rgba(102,119,138,.90)';ctx.font='8px ui-monospace,SFMono-Regular,Consolas,monospace';ctx.fillText(`${live} live`,p.x,p.y+10);const qr=8+Math.min(10,Math.sqrt(Number(g.queued||0))*2.5);ctx.fillStyle='rgba(247,249,251,.98)';ctx.strokeStyle=g.queued?'rgba(165,102,0,.65)':'rgba(102,119,138,.30)';ctx.beginPath();ctx.arc(q.x,q.y,qr,0,Math.PI*2);ctx.fill();ctx.stroke();ctx.fillStyle='rgba(45,63,81,.88)';ctx.font='700 8px ui-monospace,SFMono-Regular,Consolas,monospace';ctx.fillText(String(g.queued||0),q.x,q.y)}for(const [k,p] of l.workerPos){const w=p.w,util=Math.min(1,Number(w.active||0)/Math.max(1,Number(w.max_concurrent||1))),vr=pct(w.vram_used_bytes||w.vram_bytes,w.vram_total_bytes||w.vram_capacity_bytes),gpu=Math.min(100,Number(w.gpu_utilization_percent||0));ctx.fillStyle='rgba(255,255,255,.97)';ctx.strokeStyle=w.healthy?'rgba(20,127,165,.58)':'rgba(184,61,75,.66)';ctx.lineWidth=1.3;ctx.beginPath();ctx.arc(p.x,p.y,13,0,Math.PI*2);ctx.fill();ctx.stroke();ctx.strokeStyle='rgba(26,128,95,.76)';ctx.lineWidth=2;ctx.beginPath();ctx.arc(p.x,p.y,17,-Math.PI/2,-Math.PI/2+Math.PI*2*util);ctx.stroke();if(w.vram_total_bytes||w.vram_capacity_bytes){ctx.strokeStyle='rgba(107,91,181,.76)';ctx.beginPath();ctx.arc(p.x,p.y,20,-Math.PI/2,-Math.PI/2+Math.PI*2*(vr/100));ctx.stroke()}if(gpu){ctx.strokeStyle='rgba(165,102,0,.78)';ctx.beginPath();ctx.arc(p.x,p.y,23,-Math.PI/2,-Math.PI/2+Math.PI*2*(gpu/100));ctx.stroke()}ctx.fillStyle='rgba(37,51,69,.92)';ctx.textAlign='center';ctx.font='600 9px ui-monospace,SFMono-Regular,Consolas,monospace';ctx.fillText(w.name.slice(0,16),p.x,p.y+29);ctx.fillStyle='rgba(102,119,138,.88)';ctx.font='8px ui-monospace,SFMono-Regular,Consolas,monospace';ctx.fillText(`${w.active}/${w.max_concurrent} slots`,p.x,p.y+40)}for(const [k,p] of l.modelPos){ctx.fillStyle='rgba(107,91,181,.62)';ctx.beginPath();ctx.arc(p.x,p.y,3,0,Math.PI*2);ctx.fill();ctx.fillStyle='rgba(92,108,126,.86)';ctx.textAlign='right';ctx.font='8px ui-monospace,SFMono-Regular,monospace';ctx.fillText((p.m.name||p.m.model||'').slice(0,18),p.x-7,p.y-7)}ctx.restore()}
function drawClusterRequest(ctx,l,r,H,time){const route=clusterRoute(l,r,H),seed=(liveHash(r.id)%1000)/1000;ctx.save();ctx.strokeStyle=liveColor(r.tenant,.15);ctx.lineWidth=.6+Math.min(2,Math.sqrt(Math.max(.05,Number(r.estimated_credits||.1)))*.35);for(const x of route)drawCurve(ctx,x);let pts=[];if(r.state==='queued'){const age=liveAge(r),p=Math.min(.46,.03+age*.22);pts=[{p:p>=.45?.43+.015*Math.sin(time*2.2+seed*6.28):p,a:1}]}else if(r.state==='routing'){pts=[{p:.46+Math.min(.4,liveAge(r,'updated_at')*.7),a:1}]}else if(r.state==='running'){for(let k=0;k<2;k++)pts.push({p:.48+.5*((time*.2+seed+k*.47)%1),a:.9-k*.2})}else if(r.state==='streaming'){for(let k=0;k<3;k++)pts.push({p:1-((time*.28+seed+k*.3)%1),a:1-k*.2})}else{const age=liveAge(r,'finished_at'),fade=Math.max(0,1-age/10);pts=[{p:r.state==='failed'?.7:Math.max(.02,1-age*.15),a:fade}]}for(const x of pts){const p=routePoint(route,x.p),col=r.state==='failed'?`rgba(184,61,75,${x.a})`:r.state==='queued'?`rgba(165,102,0,${x.a})`:r.state==='streaming'?`rgba(26,128,95,${x.a})`:liveColor(r.tenant,x.a,44),rad=2.2+Math.min(3.5,Math.sqrt(Math.max(.1,Number(r.estimated_credits||.1))));ctx.shadowColor=col;ctx.shadowBlur=14;ctx.fillStyle=col;ctx.beginPath();ctx.arc(p.x,p.y,rad,0,Math.PI*2);ctx.fill();ctx.shadowBlur=0;S.clusterHit.push({x:p.x,y:p.y,r,rad:rad+6})}ctx.restore()}
function clusterPointerMove(e){const canvas=$('#cluster-map-canvas'),tt=$('#cluster-map-tooltip');if(!canvas||!tt)return;const b=canvas.getBoundingClientRect(),x=e.clientX-b.left,y=e.clientY-b.top;let best=null,d=Infinity;for(const h of S.clusterHit){const x2=Math.hypot(h.x-x,h.y-y);if(x2<h.rad&&x2<d){best=h;d=x2}}if(!best){tt.classList.add('hidden');return}const r=best.r,sw=canvas.clientWidth,sh=canvas.clientHeight;tt.innerHTML=`<strong>${esc(r.model||r.path||'Request')}</strong><div>${esc(r.tenant)} · ${esc(r.actor||'–')}</div><div>${esc(r.gateway_name||'Gateway')} → ${esc(r.worker||'–')}</div><div>${esc(r.state)} · ${ms(r.queue_ms||0)} Queue</div><div class="mono">${esc(r.id)}</div>`;tt.classList.remove('hidden');tt.style.left=Math.min(sw-310,Math.max(8,x+14))+'px';tt.style.top=Math.min(sh-120,Math.max(8,y+14))+'px'}
// ---- Live request pulse map -------------------------------------------------
const LIVE_TERMINAL=new Set(['completed','failed','cancelled']);
const liveActive=r=>!LIVE_TERMINAL.has(r.state);
const liveAge=(r,field='queued_at')=>Math.max(0,(Date.now()-new Date(r[field]||r.updated_at||Date.now()).getTime())/1000);
const liveHash=v=>{let h=2166136261;for(const c of String(v||'')){h^=c.charCodeAt(0);h=Math.imul(h,16777619)}return h>>>0};
const liveColor=(v,a=1,l=44)=>`hsla(${(liveHash(v)%290)+30},68%,${Math.min(l,52)}%,${a})`;
function stopLiveStream(){if(S.liveAbort){S.liveAbort.abort();S.liveAbort=null}S.live.connected=false;updateLiveConnection()}
function startLiveStream(){if(!isAdmin()||S.liveAbort)return;const ctl=new AbortController();S.liveAbort=ctl;void liveStreamLoop(ctl.signal)}
async function liveStreamLoop(signal){let retry=650;while(!signal.aborted){try{await liveStreamOnce(signal);retry=650}catch(e){if(signal.aborted)return;S.live.connected=false;updateLiveConnection(e.message);if(e.status===401){showLogin('Sitzung abgelaufen oder Credential ungültig.');return}try{const snap=await api('/gateway/ui-api/live');handleLiveSnapshot(snap)}catch{}await liveSleep(retry,signal);retry=Math.min(5000,retry*1.7)}}}
async function liveStreamOnce(signal){const headers=authHeaders();headers.Accept='text/event-stream';const r=await fetch('/gateway/ui-api/live/stream',{headers,credentials:'same-origin',signal});if(!r.ok){const e=new Error(`Live-Stream HTTP ${r.status}`);e.status=r.status;throw e}if(!r.body)throw new Error('Live-Stream nicht verfügbar');S.live.connected=true;updateLiveConnection();const reader=r.body.getReader(),decoder=new TextDecoder();let buf='';while(!signal.aborted){const {value,done}=await reader.read();if(done)throw new Error('Live-Stream beendet');buf+=decoder.decode(value,{stream:true}).replace(/\r\n/g,'\n');let i;while((i=buf.indexOf('\n\n'))>=0){const raw=buf.slice(0,i);buf=buf.slice(i+2);let event='message';const data=[];for(const line of raw.split('\n')){if(line.startsWith('event:'))event=line.slice(6).trim();else if(line.startsWith('data:'))data.push(line.slice(5).trimStart())}if(event==='snapshot'&&data.length){try{handleLiveSnapshot(JSON.parse(data.join('\n')))}catch{}}}}}
function liveSleep(ms,signal){return new Promise(resolve=>{const t=setTimeout(resolve,ms);signal?.addEventListener('abort',()=>{clearTimeout(t);resolve()},{once:true})})}
function handleLiveSnapshot(snap){S.live.requests=Array.isArray(snap?.requests)?snap.requests:[];S.live.version=Number(snap?.version||0);S.live.generatedAt=snap?.generated_at||null;S.live.counts=snap?.counts||null;S.live.truncated=!!snap?.truncated;S.live.connected=true;updateLiveConnection();renderLivePanel()}
function updateLiveConnection(msg=''){const el=$('#live-stream-state');if(!el)return;el.className='status-chip '+(S.live.connected?'ok':'warn');el.textContent=S.live.connected?'SSE live':(msg?'Reconnect':'verbinde …')}
function liveFiltered(includeTerminal=true){const state=$('#live-state-filter')?.value||'',q=($('#live-search')?.value||'').trim().toLowerCase();return S.live.requests.filter(r=>(includeTerminal||liveActive(r))&&(!state||r.state===state)&&(!q||`${r.tenant} ${r.actor} ${r.application||''} ${r.model||''} ${r.worker||''} ${r.path||''}`.toLowerCase().includes(q)))}
function renderLivePanel(){if(!isAdmin())return;const all=S.live.requests,active=all.filter(liveActive),queued=active.filter(r=>r.state==='queued'),streaming=active.filter(r=>r.state==='streaming'),c=S.live.counts||{};const tokens=active.reduce((n,r)=>n+Number(r.prompt_tokens||r.estimated_prompt_tokens||0)+Number(r.completion_tokens||0),0);if($('#live-active')){$('#live-active').textContent=num(c.active??active.length);$('#live-queued').textContent=num(c.queued??queued.length);$('#live-streaming').textContent=num(c.streaming??streaming.length);$('#live-tokens').textContent=num(tokens);$('#live-flow-empty').classList.toggle('hidden',liveFiltered(false).length>0)}renderLiveRequestList();renderLiveWorkerList();updateLiveConnection()}
function renderLiveRequestList(){const el=$('#live-request-list');if(!el)return;const a=liveFiltered(true).sort((x,y)=>Number(liveActive(y))-Number(liveActive(x))||new Date(y.updated_at)-new Date(x.updated_at)).slice(0,40);el.innerHTML=a.length?a.map(r=>{const dur=r.started_at?liveAge(r,'started_at'):liveAge(r,'queued_at'),tok=Number(r.prompt_tokens||r.estimated_prompt_tokens||0)+Number(r.completion_tokens||0);return `<div class="live-request"><span class="live-request-pulse ${esc(r.state)}"></span><div class="live-request-main"><strong>${esc(r.model||r.path||'request')}</strong><small>${esc(r.tenant)} · ${esc(r.actor||'–')} ${r.worker?`→ ${esc(r.worker)}`:''}</small></div><div class="live-request-meta"><strong>${esc(r.state)}</strong><small>${dur.toFixed(dur<10?1:0)} s · ${num(tok)} tok</small>${liveActive(r)?`<button class="button small danger" data-job-cancel="${esc(r.id)}">Abbrechen</button>`:''}</div></div>`}).join(''):'<div class="empty">Keine Requests für diesen Filter</div>'}
function renderLiveWorkerList(){const el=$('#live-worker-list');if(!el)return;const active=liveFiltered(false),configured=S.overview?.workers||[],names=[...new Set([...configured.map(w=>w.name),...active.map(r=>r.worker).filter(Boolean)])];const max=Math.max(1,...names.map(n=>active.filter(r=>r.worker===n).length));el.innerHTML=names.length?names.map(n=>{const rs=active.filter(r=>r.worker===n),w=configured.find(x=>x.name===n),models=[...new Set(rs.map(r=>r.model).filter(Boolean))].slice(0,6);return `<div class="live-worker"><div class="live-worker-top"><div><strong>${esc(n)}</strong><small>${w?`${w.active}/${w.max_concurrent} Slots`:''}</small></div><span class="status-chip ${w?.healthy?'ok':w?'bad':''}">${rs.length} live</span></div><div class="live-worker-bar"><div style="width:${100*rs.length/max}%"></div></div><div class="live-worker-models">${models.map(m=>`<span>${esc(m)}</span>`).join('')||'<span>idle</span>'}</div></div>`}).join(''):'<div class="empty">Keine Worker</div>'}
function startLiveAnimation(){if(S.liveRAF)return;const loop=t=>{S.liveRAF=requestAnimationFrame(loop);if(S.page==='live')drawLiveFlow(S.livePaused?(S.livePauseTime||t):t);else if(S.page==='infrastructure')drawClusterMap(S.livePaused?(S.livePauseTime||t):t)};S.liveRAF=requestAnimationFrame(loop);for(const b of [$('#live-motion'),$('#cluster-motion')].filter(Boolean))b.textContent=S.livePaused?'Animation fortsetzen':'Animation pausieren'}
function toggleLiveMotion(){S.livePaused=!S.livePaused;if(S.livePaused)S.livePauseTime=performance.now();else S.livePauseTime=0;for(const b of [$('#live-motion'),$('#cluster-motion')].filter(Boolean))b.textContent=S.livePaused?'Animation fortsetzen':'Animation pausieren'}
function cubic(a,b,c,d,t){const u=1-t;return{x:u*u*u*a.x+3*u*u*t*b.x+3*u*t*t*c.x+t*t*t*d.x,y:u*u*u*a.y+3*u*u*t*b.y+3*u*t*t*c.y+t*t*t*d.y}}
function curve(a,d,bias=.5,bend=0){const dx=d.x-a.x;return{a,b:{x:a.x+dx*bias,y:a.y+bend},c:{x:d.x-dx*bias,y:d.y-bend},d}}
function routePoint(route,p){p=Math.max(0,Math.min(.999999,p));const weights=route.map(s=>s.w||1),sum=weights.reduce((a,b)=>a+b,0);let x=p*sum;for(let i=0;i<route.length;i++){if(x<=weights[i]||i===route.length-1)return cubic(route[i].a,route[i].b,route[i].c,route[i].d,x/weights[i]);x-=weights[i]}return route.at(-1).d}
function drawCurve(ctx,s){ctx.beginPath();ctx.moveTo(s.a.x,s.a.y);ctx.bezierCurveTo(s.b.x,s.b.y,s.c.x,s.c.y,s.d.x,s.d.y);ctx.stroke()}
function liveLayout(reqs,W,H){const marginY=70;const counts=new Map();for(const r of reqs)counts.set(r.tenant,(counts.get(r.tenant)||0)+1);let tenants=[...counts].sort((a,b)=>b[1]-a[1]).map(x=>x[0]);const visibleTenants=tenants.slice(0,8),tenantPos=new Map();visibleTenants.forEach((n,i)=>tenantPos.set(n,{x:Math.max(70,W*.075),y:marginY+(H-2*marginY)*(visibleTenants.length===1?.5:i/Math.max(1,visibleTenants.length-1))}));const admission={x:W*.31,y:H*.50},queue={x:W*.50,y:H*.50};const configured=S.overview?.workers||[],workerNames=[...new Set([...configured.map(w=>w.name),...reqs.map(r=>r.worker).filter(Boolean)])].slice(0,8),workerPos=new Map();workerNames.forEach((n,i)=>workerPos.set(n,{x:W*.78,y:marginY+(H-2*marginY)*(workerNames.length===1?.5:i/Math.max(1,workerNames.length-1))}));const modelPos=new Map();for(const w of workerNames){const models=[...new Set(reqs.filter(r=>r.worker===w).map(r=>r.model).filter(Boolean))].slice(0,4),wp=workerPos.get(w);models.forEach((m,i)=>{const off=(i-(models.length-1)/2)*26;modelPos.set(`${w}\0${m}`,{x:W*.94,y:Math.max(42,Math.min(H-42,wp.y+off))})})}return{tenantPos,visibleTenants,admission,queue,workerPos,workerNames,modelPos}}
function tenantLane(layout,r,H){const p=layout.tenantPos.get(r.tenant);if(p)return p;const lanes=[...layout.tenantPos.values()];if(lanes.length)return lanes[liveHash(r.tenant)%lanes.length];return{x:70,y:H*.5}}
function requestRoute(layout,r,H){const tp=tenantLane(layout,r,H),a=layout.admission,q=layout.queue,w=layout.workerPos.get(r.worker)||{x:q.x+140,y:q.y+(liveHash(r.model)%160)-80},m=layout.modelPos.get(`${r.worker}\0${r.model}`)||w;const jitter=((liveHash(r.actor)%21)-10)*1.1;return[Object.assign(curve({x:tp.x+8,y:tp.y+jitter},a,.45,jitter*.35),{w:1.25}),Object.assign(curve(a,q,.5,jitter*.15),{w:.75}),Object.assign(curve(q,w,.52,(w.y-q.y)*-.08+jitter),{w:1.45}),Object.assign(curve(w,m,.5,jitter*.1),{w:.55})]}
function drawLiveFlow(tms){const canvas=$('#live-flow-canvas'),stage=$('#live-flow-stage');if(!canvas||!stage)return;const rect=stage.getBoundingClientRect();if(rect.width<20||rect.height<20)return;const dpr=Math.min(2,window.devicePixelRatio||1),cw=Math.round(rect.width*dpr),ch=Math.round(rect.height*dpr);if(canvas.width!==cw||canvas.height!==ch){canvas.width=cw;canvas.height=ch}const ctx=canvas.getContext('2d');ctx.setTransform(dpr,0,0,dpr,0,0);const W=rect.width,H=rect.height;ctx.clearRect(0,0,W,H);const all=liveFiltered(true).slice(0,220),layout=liveLayout(all,W,H),active=all.filter(liveActive),time=tms/1000;
// topology trunks
ctx.lineCap='round';ctx.lineJoin='round';ctx.shadowBlur=0;ctx.strokeStyle='rgba(92,117,140,.22)';ctx.lineWidth=1;
for(const [name,p] of layout.tenantPos){drawCurve(ctx,curve({x:p.x+7,y:p.y},layout.admission,.46,0))}
ctx.strokeStyle='rgba(26,128,95,.22)';ctx.lineWidth=1.3;drawCurve(ctx,curve(layout.admission,layout.queue,.5,0));
for(const [name,p] of layout.workerPos){ctx.strokeStyle='rgba(20,127,165,.20)';ctx.lineWidth=1;drawCurve(ctx,curve(layout.queue,p,.52,(p.y-layout.queue.y)*-.08))}
for(const [key,p] of layout.modelPos){const worker=key.split('\0')[0],wp=layout.workerPos.get(worker);if(wp){ctx.strokeStyle='rgba(20,127,165,.14)';ctx.lineWidth=.8;drawCurve(ctx,curve(wp,p,.5,0))}}
drawLiveNodes(ctx,layout,W,H,active);
S.liveHit=[];
for(const r of all){drawLiveRequest(ctx,layout,r,W,H,time)}
}
function drawLiveNodes(ctx,l,W,H,active){ctx.save();ctx.textBaseline='middle';ctx.font='600 10px ui-monospace,SFMono-Regular,Consolas,monospace';for(const [n,p] of l.tenantPos){const c=active.filter(r=>r.tenant===n).length;ctx.fillStyle=liveColor(n,.85);ctx.beginPath();ctx.arc(p.x,p.y,4.2,0,Math.PI*2);ctx.fill();ctx.shadowBlur=0;ctx.fillStyle='rgba(48,67,85,.90)';ctx.textAlign='left';ctx.fillText(n.slice(0,18),p.x+10,p.y-5);ctx.fillStyle='rgba(102,119,138,.88)';ctx.font='9px ui-monospace,SFMono-Regular,Consolas,monospace';ctx.fillText(`${c} live`,p.x+10,p.y+8);ctx.font='600 10px ui-monospace,SFMono-Regular,Consolas,monospace'}drawHub(ctx,l.admission,'ADMISSION',active.length);drawHub(ctx,l.queue,'FAIR QUEUE',active.filter(r=>r.state==='queued').length);for(const [n,p] of l.workerPos){const c=active.filter(r=>r.worker===n).length;ctx.strokeStyle=c?'rgba(26,128,95,.58)':'rgba(20,127,165,.30)';ctx.fillStyle='rgba(255,255,255,.96)';ctx.lineWidth=1.2;ctx.beginPath();ctx.arc(p.x,p.y,10,0,Math.PI*2);ctx.fill();ctx.stroke();ctx.fillStyle='rgba(42,59,77,.92)';ctx.textAlign='center';ctx.font='600 9px ui-monospace,SFMono-Regular,Consolas,monospace';ctx.fillText(n.slice(0,16),p.x,p.y+19);ctx.fillStyle='rgba(102,119,138,.90)';ctx.fillText(`${c} live`,p.x,p.y+31)}for(const [key,p] of l.modelPos){const m=key.split('\0')[1];ctx.fillStyle='rgba(20,127,165,.55)';ctx.beginPath();ctx.arc(p.x,p.y,2.5,0,Math.PI*2);ctx.fill();ctx.fillStyle='rgba(88,105,123,.82)';ctx.textAlign='right';ctx.font='8px ui-monospace,SFMono-Regular,monospace';ctx.fillText((m||'').slice(0,18),p.x-7,p.y-7)}ctx.restore()}
function drawHub(ctx,p,label,count){ctx.save();const radius=13+Math.min(7,Math.sqrt(count||0)*2);ctx.strokeStyle='rgba(26,128,95,.42)';ctx.fillStyle='rgba(255,255,255,.97)';ctx.lineWidth=1.2;ctx.beginPath();ctx.arc(p.x,p.y,radius,0,Math.PI*2);ctx.fill();ctx.stroke();ctx.fillStyle='rgba(37,51,69,.94)';ctx.textAlign='center';ctx.font='700 9px ui-monospace,SFMono-Regular,Consolas,monospace';ctx.fillText(label,p.x,p.y-2);ctx.fillStyle='rgba(102,119,138,.90)';ctx.font='9px ui-monospace,SFMono-Regular,Consolas,monospace';ctx.fillText(`${count||0}`,p.x,p.y+10);ctx.restore()}
function drawLiveRequest(ctx,layout,r,W,H,time){const route=requestRoute(layout,r,H),base=liveColor(r.tenant,.20),width=.55+Math.min(2.6,Math.sqrt(Math.max(.05,Number(r.estimated_credits||.1)))*.4);ctx.save();ctx.strokeStyle=base;ctx.lineWidth=width;for(const s of route)drawCurve(ctx,s);const seed=(liveHash(r.id)%1000)/1000;let pts=[];if(r.state==='queued'){const age=liveAge(r),p=Math.min(.48,.05+age*.22);pts=[{p:p>=.47?.455+.018*Math.sin(time*2.2+seed*6.28):p,a:.95}]}else if(r.state==='routing'){const age=liveAge(r,'updated_at');pts=[{p:.48+Math.min(.43,age*.75),a:1}]}else if(r.state==='running'){for(let k=0;k<2;k++)pts.push({p:.48+(.50*((time*.22+seed+k*.48)%1)),a:.85-k*.18})}else if(r.state==='streaming'){for(let k=0;k<3;k++)pts.push({p:1-((time*.30+seed+k*.29)%1),a:1-k*.20});pts.push({p:.98,a:.75})}else{const age=liveAge(r,'finished_at'),fade=Math.max(0,1-age/10),p=r.state==='failed'?.72:Math.max(.02,1-age*.16);pts=[{p,a:fade}]}
for(const x of pts){const p=routePoint(route,x.p),terminal=LIVE_TERMINAL.has(r.state),col=r.state==='failed'?`rgba(184,61,75,${x.a})`:r.state==='queued'?`rgba(165,102,0,${x.a})`:r.state==='streaming'?`rgba(26,128,95,${x.a})`:liveColor(r.tenant,x.a,44);const rad=2.2+Math.min(4,Math.sqrt(Math.max(.1,Number(r.estimated_credits||.1))));ctx.shadowColor=col;ctx.shadowBlur=terminal?8:16;ctx.fillStyle=col;ctx.beginPath();ctx.arc(p.x,p.y,rad,0,Math.PI*2);ctx.fill();ctx.shadowBlur=0;S.liveHit.push({x:p.x,y:p.y,r,rad:rad+6})}
ctx.restore()}
function showLiveTooltip(r,x,y){const tt=$('#live-flow-tooltip'),stage=$('#live-flow-stage');if(!tt||!stage)return;const tok=Number(r.prompt_tokens||r.estimated_prompt_tokens||0)+Number(r.completion_tokens||0);tt.innerHTML=`<strong>${esc(r.model||r.path||'Request')}</strong><div>${esc(r.tenant)} · ${esc(r.actor||'–')}</div><div class="mono">${esc(r.id)}</div><div>${esc(r.state)} ${r.worker?`→ ${esc(r.worker)}`:''}</div><div>${num(tok)} Tokens · ${credits(r.estimated_credits)} cr est.</div>`;tt.classList.remove('hidden');const sw=stage.clientWidth,sh=stage.clientHeight;tt.style.left=Math.min(sw-310,Math.max(8,x+14))+'px';tt.style.top=Math.min(sh-120,Math.max(8,y+14))+'px'}
function livePointerMove(e){const canvas=$('#live-flow-canvas'),tt=$('#live-flow-tooltip');if(!canvas||!tt)return;const b=canvas.getBoundingClientRect(),x=e.clientX-b.left,y=e.clientY-b.top;let best=null,dist=Infinity;for(const h of S.liveHit){const d=Math.hypot(h.x-x,h.y-y);if(d<h.rad&&d<dist){best=h;dist=d}}if(best)showLiveTooltip(best.r,x,y);else tt.classList.add('hidden')}
$('#cluster-search')?.addEventListener('input',renderClusterPanel);
$('#cluster-motion')?.addEventListener('click',toggleLiveMotion);
$('#cluster-map-canvas')?.addEventListener('pointermove',clusterPointerMove);
$('#cluster-map-canvas')?.addEventListener('pointerleave',()=>$('#cluster-map-tooltip')?.classList.add('hidden'));
$('#live-state-filter')?.addEventListener('change',renderLivePanel);
$('#live-search')?.addEventListener('input',renderLivePanel);
$('#live-motion')?.addEventListener('click',toggleLiveMotion);
$('#live-flow-canvas')?.addEventListener('pointermove',livePointerMove);
$('#live-flow-canvas')?.addEventListener('pointerleave',()=>$('#live-flow-tooltip')?.classList.add('hidden'));
document.addEventListener('click',e=>{const b=e.target.closest('[data-close-dialog]');if(!b)return;document.getElementById(b.dataset.closeDialog)?.close()});
$('#worker-cards')?.addEventListener('click',async e=>{const b=e.target.closest('[data-worker-mode]');if(b){try{await api('/gateway/ui-api/workers/'+encodeURIComponent(b.dataset.worker)+'/maintenance',{method:'POST',body:JSON.stringify({mode:b.dataset.workerMode})});toast(`Worker ${b.dataset.worker}: ${b.dataset.workerMode}`);await refreshAll(false)}catch(err){toast(err.message,true)}return}const c=e.target.closest('[data-circuit-reset]');if(c){try{await api('/gateway/ui-api/workers/'+encodeURIComponent(c.dataset.circuitReset)+'/circuit/reset',{method:'POST'});toast('Circuit Breaker zurückgesetzt');await refreshAll(false)}catch(err){toast(err.message,true)}}});
init();