142 lines
11 KiB
JavaScript
142 lines
11 KiB
JavaScript
'use strict';
|
|
const $ = id => document.getElementById(id);
|
|
const colors = ['#6977db','#8d9ae7','#9e83c9','#50a994','#548ba9','#344779','#e9954c','#b6bdca'];
|
|
const names = {NEW:'Eingang',VALIDATED:'Angenommen',BUILDING_CASE:'Fallaufbau',INTERPRETING_CASE:'Fallplanung',GENERATING_HYPOTHESES:'Hypothesen',INVESTIGATING_HYPOTHESES:'Untersuchung',BUILDING_DIAGNOSIS:'Abschluss',BLOCKED:'Wartet',REOPENED:'Wiedereröffnet'};
|
|
const finished = new Set(['OUTPUT_READY','FAILED_TO_ANALYZE','HANDED_OFF']);
|
|
const number = n => new Intl.NumberFormat('de-DE').format(n);
|
|
let token = '', timer = null, overview = null, projects = [], sending = false, submission = null, session = 0;
|
|
const text = (tag, value, cls) => { const e=document.createElement(tag); e.textContent=value; if(cls)e.className=cls; return e; };
|
|
function error(message='') { $('error').hidden=!message; $('error').textContent=message; }
|
|
async function api(path, options={}) {
|
|
const credential=token;
|
|
const response=await fetch(path,{...options,headers:{...options.headers,Authorization:`Bearer ${credential}`},cache:'no-store'});
|
|
const data=await response.json();
|
|
if(!response.ok) { if(response.status===401 && credential===token) logout(); throw new Error(data.code || `HTTP ${response.status}`); }
|
|
return data;
|
|
}
|
|
function progress(value, label) {
|
|
const outer=text('div','','progress'); outer.setAttribute('role','progressbar');
|
|
outer.setAttribute('aria-label',label); outer.setAttribute('aria-valuemin','0'); outer.setAttribute('aria-valuemax','100'); outer.setAttribute('aria-valuenow',String(value));
|
|
const inner=text('div','','progress-fill'); inner.style.width=`${value}%`; outer.append(inner); return outer;
|
|
}
|
|
function logout() {
|
|
session++;token=''; clearTimeout(timer); $('token').value=''; $('content').hidden=true; $('login').hidden=false;
|
|
$('logout').hidden=true; $('connection').textContent='Nicht verbunden'; document.body.classList.remove('connected');
|
|
$('updated').textContent='Warte auf Verbindung'; overview=null;projects=[];submission=null;
|
|
$('submit-form').reset();$('receipt').textContent='';$('new-files').hidden=false;$('return-files').hidden=true;
|
|
['workers','case-rows','bars','legend','project','project-filter'].forEach(id=>$(id).replaceChildren());
|
|
['active-count','token-count','correction-count','finished-count'].forEach(id=>$(id).textContent='—');
|
|
$('pie').getContext('2d').clearRect(0,0,360,360);
|
|
}
|
|
function renderWorkers(workers, project) {
|
|
const root=$('workers'); root.replaceChildren();
|
|
const visible=workers.filter(w=>!project || (w.case && w.case.project_id===project));
|
|
$('worker-count').textContent=`${visible.length} online`;
|
|
if(!visible.length)root.append(text('div','Kein aktiver Worker für diese Auswahl.','empty'));
|
|
visible.forEach(worker=>{
|
|
const row=text('article','','worker'); const head=text('div','','worker-top');
|
|
head.append(document.createElement('i'),text('span',worker.name)); row.append(head);
|
|
if(worker.case) {
|
|
const c=worker.case, line=text('div','','worker-case');
|
|
line.append(text('span',`${c.case_id} · ${c.title}`),text('span',`${c.progress} %`));
|
|
row.append(line,progress(c.progress,`Fortschritt ${c.case_id}`));
|
|
} else row.append(text('p','Bereit für den nächsten Auftrag.','hint'));
|
|
root.append(row);
|
|
});
|
|
}
|
|
function sumBuckets(cases) {
|
|
// Per-project aggregation comes from server-filtered individual usage summaries.
|
|
return cases.reduce((map,c)=>{
|
|
(c.usage || []).forEach(b=>{const a=map.get(b.id)||{...b,input_tokens:0,output_tokens:0,calls:0,unknown_usage:0};
|
|
['input_tokens','output_tokens','calls','unknown_usage'].forEach(k=>a[k]+=b[k]||0);map.set(b.id,a);});return map;
|
|
},new Map());
|
|
}
|
|
function renderPie(buckets) {
|
|
const canvas=$('pie'), ctx=canvas.getContext('2d'), size=360;
|
|
ctx.clearRect(0,0,size,size); const total=buckets.reduce((n,b)=>n+b.input_tokens+b.output_tokens,0);
|
|
const legend=$('legend'); legend.replaceChildren(); let angle=-Math.PI/2;
|
|
if(!total) {ctx.beginPath();ctx.arc(180,180,140,0,Math.PI*2);ctx.fillStyle='#edf0f7';ctx.fill();}
|
|
buckets.forEach((b,i)=>{
|
|
const count=b.input_tokens+b.output_tokens;
|
|
if(b.id==='unknown'&&!count&&!b.calls)return;
|
|
const color=colors[i%colors.length]; if(total&&count) {const end=angle+count/total*Math.PI*2;
|
|
ctx.beginPath();ctx.moveTo(180,180);ctx.arc(180,180,140,angle,end);ctx.closePath();ctx.fillStyle=color;ctx.fill();angle=end;}
|
|
const item=document.createElement('li'), swatch=text('span','','swatch');swatch.style.background=color;
|
|
const name=text('span',b.label), val=text('b',total?`${(count/total*100).toFixed(1).replace('.',',')} %`:'—');
|
|
item.title=`${b.label}: ${number(count)} Tokens, ${b.calls} Aufrufe${b.unknown_usage?' · Messwerte unvollständig':''}`;
|
|
item.append(swatch,name,val);legend.append(item);
|
|
});
|
|
canvas.setAttribute('aria-label',total?'Tokenanteile: '+buckets.filter(b=>b.input_tokens+b.output_tokens).map(b=>`${b.label}: ${number(b.input_tokens+b.output_tokens)}`).join(', '):'Noch keine gemessene Tokennutzung');
|
|
}
|
|
function renderBars(buckets) {
|
|
const root=$('bars');root.replaceChildren(); const max=Math.max(1,...buckets.map(b=>b.input_tokens+b.output_tokens));
|
|
buckets.filter(b=>b.calls).forEach(b=>{
|
|
const row=text('div','',`bar-row ${b.id}`), label=text('div','','bar-label');
|
|
label.append(text('span',b.label),text('span',number(b.input_tokens+b.output_tokens)));
|
|
const track=text('div','','bar-track'), input=text('div','','bar-input'), output=text('div','','bar-output');
|
|
input.style.width=`${b.input_tokens/max*100}%`;output.style.width=`${b.output_tokens/max*100}%`;
|
|
row.title=`Eingabe: ${number(b.input_tokens)} · Ausgabe inkl. Denken: ${number(b.output_tokens)}`;
|
|
track.append(input,output);row.append(label,track);root.append(row);
|
|
});
|
|
if(!root.children.length)root.append(text('div','Noch keine gemessene Tokennutzung.','empty'));
|
|
}
|
|
function render() {
|
|
if(!overview)return;
|
|
const project=$('project-filter').value;
|
|
const cases=overview.cases.filter(c=>!project || c.project_id===project), active=cases.filter(c=>!finished.has(c.state));
|
|
const buckets=project?[...sumBuckets(cases).values()]:overview.usage.buckets;
|
|
const total=buckets.reduce((n,b)=>n+b.input_tokens+b.output_tokens,0), corrections=buckets.find(b=>b.id==='correction');
|
|
const corrected=corrections?corrections.input_tokens+corrections.output_tokens:0;
|
|
$('active-count').textContent=number(active.length);$('finished-count').textContent=number(cases.length-active.length);
|
|
$('token-count').textContent=number(total);$('correction-count').textContent=total?`${(corrected/total*100).toFixed(1).replace('.',',')} %`:'—';
|
|
renderWorkers(overview.workers,project);renderPie(buckets);renderBars(buckets);
|
|
const missing=buckets.reduce((n,b)=>n+b.unknown_usage,0), unassigned=buckets.find(b=>b.id==='unknown');
|
|
$('coverage').textContent=`${buckets.reduce((n,b)=>n+b.calls,0)} erfasste Aufrufe. ${missing?`${missing} Aufrufe mit fehlenden Tokenmesswerten; die Diagramme zeigen nur bekannte Werte.`:'Alle erfassten Aufrufe mit vollständigen Eingabe-/Ausgabewerten.'}${unassigned?.calls?` ${unassigned.calls} ältere Aufrufe ohne sichere Korrekturzuordnung.`:''}`;
|
|
const root=$('case-rows');root.replaceChildren();
|
|
active.forEach(c=>{const row=document.createElement('tr'), title=document.createElement('td');
|
|
title.append(text('strong',c.case_id),text('small',c.title));row.append(title,text('td',c.project_id),text('td',names[c.state]||c.state),text('td',String(c.analysis_revision)));
|
|
const cell=document.createElement('td');cell.append(progress(c.progress,`Fortschritt ${c.case_id}`));row.append(cell);root.append(row);});
|
|
if(!active.length){const row=document.createElement('tr'),cell=text('td','Keine offenen Fälle.','empty');cell.colSpan=5;row.append(cell);root.append(row);}
|
|
}
|
|
async function refresh() {
|
|
clearTimeout(timer); if(!token)return;const generation=session;
|
|
try {const response=await api('/v1/overview');if(generation!==session)return;overview=response;render();$('updated').textContent=`Aktualisiert ${new Date().toLocaleTimeString('de-DE')}`;
|
|
error(overview.read_errors?'Einige Akten konnten nicht gelesen werden. Die Verbrauchsdaten können unvollständig sein.':'');
|
|
} catch(e) {if(generation===session)error(`Aktualisierung fehlgeschlagen: ${e.message}`);}
|
|
if(token && generation===session)timer=setTimeout(refresh,4000);
|
|
}
|
|
$('login-form').addEventListener('submit',async e=>{
|
|
e.preventDefault();const generation=++session;token=$('token').value.trim();error();
|
|
try {const response=await api('/v1/profiles');if(generation!==session)return;projects=response.projects;
|
|
$('project').replaceChildren();$('project-filter').replaceChildren(new Option('Alle freigegebenen Projekte',''));
|
|
projects.forEach(p=>{$('project').add(new Option(p.project_id,p.project_id));$('project-filter').add(new Option(p.project_id,p.project_id));});
|
|
$('login').hidden=true;$('content').hidden=false;$('logout').hidden=false;$('token').value='';
|
|
$('connection').textContent='Verbunden';document.body.classList.add('connected');await refresh();
|
|
} catch(e) {if(generation===session){token='';error(`Verbindung fehlgeschlagen: ${e.message}`);}else if(!token)error('Verbindung fehlgeschlagen: API-Zugangsschlüssel prüfen.');}
|
|
});
|
|
$('logout').addEventListener('click',logout);$('project-filter').addEventListener('change',render);
|
|
$('kind').addEventListener('change',()=>{$('new-files').hidden=$('kind').value!=='new_case';$('return-files').hidden=$('kind').value!=='case_return';});
|
|
$('submit-form').addEventListener('submit',async e=>{
|
|
e.preventDefault();if(sending)return;error();
|
|
const project=projects.find(p=>p.project_id===$('project').value), kind=$('kind').value;
|
|
if(!project)return;
|
|
const observations=$('observations').value.split('\n').map(v=>v.trim()).filter(Boolean);
|
|
if(!observations.length)return error('Bitte mindestens eine Beobachtung angeben.');
|
|
const metadata={kind,project_id:project.project_id,profile_id:project.profile_id,case_id:$('case-id').value.trim()};
|
|
const form=new FormData(), chosen=[];
|
|
function append(role, files){for(const file of files){form.append(role,file);chosen.push([role,file.name,file.size,file.lastModified]);}}
|
|
if(kind==='new_case') {if(!$('wowdiag').files.length)return error('Bitte das wowdiag-Archiv auswählen.');
|
|
metadata.report={title:$('title').value.trim(),observations};append('wowdiag',$('wowdiag').files);append('wowsrc',$('wowsrc').files);
|
|
} else {if(!$('case-package').files.length)return error('Bitte die abgeschlossene ZIP-Akte auswählen.');
|
|
metadata.feedback={outcome:$('outcome').value,summary:$('title').value.trim(),observations};append('case_package',$('case-package').files);}
|
|
append('attachments',$('attachments').files);form.append('metadata',JSON.stringify(metadata));
|
|
const signature=JSON.stringify([metadata,chosen]);
|
|
if(!submission || submission.signature!==signature)submission={signature,key:crypto.randomUUID()};
|
|
const generation=session;sending=true;$('send').disabled=true;$('receipt').textContent='Auftrag wird übertragen …';
|
|
try {const receipt=await api('/v1/submissions',{method:'POST',headers:{'Idempotency-Key':submission.key},body:form});
|
|
if(generation!==session)return;
|
|
$('receipt').textContent=`Angenommen: ${receipt.case_id} · ${receipt.status} · Auftrags-ID ${receipt.submission_id}`;await refresh();
|
|
} catch(e) {if(generation===session){$('receipt').textContent='';error(`Übergabe fehlgeschlagen: ${e.message}. Bei unveränderten Eingaben wird derselbe Idempotenzschlüssel wiederverwendet.`);}}
|
|
finally{sending=false;$('send').disabled=false;}
|
|
});
|