215 lines
105 KiB
JavaScript
215 lines
105 KiB
JavaScript
'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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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();
|