Files
glpi-ai-agent/internal/web/templates/dashboard.html
groot 4cfdac042d
All checks were successful
release-tag / release-image (push) Successful in 1m35s
Update GLPI-Knowledge
2026-07-28 14:35:03 +02:00

110 lines
22 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>GLPI AI Agent</title>
<style>
:root{color-scheme:dark;font-family:Inter,system-ui,-apple-system,Segoe UI,sans-serif;background:#0b1020;color:#e5e7eb}*{box-sizing:border-box}body{margin:0}.wrap{max-width:1600px;margin:auto;padding:24px}.top{display:flex;justify-content:space-between;gap:16px;align-items:center;margin-bottom:20px}.badge,.pill{display:inline-block;padding:4px 8px;border-radius:999px;background:#1f2937;font-size:12px}.badge.warn,.pill-warn{background:#713f12;color:#fde68a}.pill-ok{background:#14532d;color:#bbf7d0}.pill-bad{background:#7f1d1d;color:#fecaca}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px;margin:16px 0 28px}.card,.panel{background:#111827;border:1px solid #273244;border-radius:14px;padding:16px}.k{color:#9ca3af;font-size:11px;text-transform:uppercase;letter-spacing:.08em}.v{font-size:23px;font-weight:700;margin-top:7px;overflow-wrap:anywhere}.ok{color:#86efac}.bad{color:#fca5a5}.muted,.sub{color:#9ca3af}.sub{font-size:12px;margin-top:3px}h1{margin:0;font-size:25px}h2{margin-top:30px}h3{margin:0 0 14px}table{width:100%;border-collapse:collapse;background:#111827;border-radius:14px;overflow:hidden}th,td{text-align:left;vertical-align:top;padding:11px;border-bottom:1px solid #273244;font-size:13px}th{color:#9ca3af}.decision{line-height:1.5;min-width:245px}.decision strong{color:#f3f4f6}.reason{max-width:520px;line-height:1.45}.policy{margin-top:7px;color:#cbd5e1}.knowledge{margin-top:7px;color:#93c5fd}.actions{display:flex;gap:7px;flex-wrap:wrap;margin-top:8px}button{cursor:pointer;border:1px solid #374151;background:#1f2937;color:#f3f4f6;border-radius:8px;padding:7px 10px}button:hover{background:#374151}button.primary{background:#1d4ed8;border-color:#2563eb}button.danger{background:#7f1d1d;border-color:#991b1b}button:disabled{opacity:.5;cursor:not-allowed}input,textarea,select{width:100%;background:#0b1220;color:#e5e7eb;border:1px solid #374151;border-radius:8px;padding:9px}textarea{min-height:110px;resize:vertical}.formgrid{display:grid;grid-template-columns:repeat(4,1fr);gap:12px}.span2{grid-column:span 2}.span4{grid-column:1/-1}.field label{display:block;font-size:12px;color:#9ca3af;margin-bottom:5px}.check{display:flex;align-items:center;gap:8px}.check input{width:auto}.twocol{display:grid;grid-template-columns:1.15fr .85fr;gap:16px}.msg{display:none;margin:12px 0;padding:10px 12px;border-radius:8px;background:#1f2937}.msg.show{display:block}.msg.err{background:#7f1d1d}.msg.good{background:#14532d}.modal{display:none;position:fixed;inset:0;background:#0009;align-items:center;justify-content:center;padding:20px;z-index:20}.modal.show{display:flex}.modalbox{width:min(600px,100%);background:#111827;border:1px solid #374151;border-radius:14px;padding:20px}.kb-title{font-weight:700}.scroll{overflow:auto}@media(max-width:1100px){.twocol{grid-template-columns:1fr}.formgrid{grid-template-columns:repeat(2,1fr)}.span4{grid-column:1/-1}}@media(max-width:760px){.wrap{padding:14px}.hide-md{display:none}.formgrid{grid-template-columns:1fr}.span2,.span4{grid-column:1}.top{align-items:flex-start;flex-direction:column}}@media(max-width:560px){.hide-sm{display:none}}
</style>
</head>
<body>
<div class="wrap">
<div class="top">
<div><h1>GLPI AI Agent</h1><div class="muted">Status, Audit, Lernen & Knowledge Base</div></div>
<div>{{if .DryRun}}<span class="badge warn">DRY RUN</span>{{else}}<span class="badge">LIVE</span>{{end}} {{if .AutoReply}}<span class="badge">Auto-Reply an</span>{{else}}<span class="badge">Auto-Reply aus</span>{{end}}</div>
</div>
<div id="globalMsg" class="msg"></div>
<div id="cards" class="grid"></div>
<h2>Letzte Verarbeitungen</h2>
<div class="scroll"><table>
<thead><tr><th>Zeit</th><th>Ticket</th><th>Ergebnis</th><th>Kategorieentscheidung</th><th>Antwortentscheidung</th><th class="hide-md">Kontext</th><th class="hide-sm">Begründung / Policy</th></tr></thead>
<tbody id="runs"><tr><td colspan="7">Lade…</td></tr></tbody>
</table></div>
<div class="twocol">
<section>
<h2>Interne Knowledge Base</h2>
<div class="panel">
<h3 id="kbFormTitle">Knowledge-Eintrag anlegen</h3>
<div id="kbDisabled" class="msg"></div>
<form id="kbForm">
<div class="formgrid">
<div class="field"><label>ID</label><input id="kbId" required placeholder="KB-AD-001"></div>
<div class="field span2"><label>Titel</label><input id="kbTitle" required placeholder="Benutzerkonto gesperrt"></div>
<div class="field"><label>Quelle</label><select id="kbSource"></select></div>
<div class="field"><label>Sprache</label><input id="kbLanguage" value="de-DE"></div>
<div class="field"><label>Stil</label><select id="kbStyle"><option value="formal">formal</option><option value="neutral">neutral</option><option value="informal">informal</option></select></div>
<div class="field"><label>Min. RAG-Score</label><input id="kbScore" type="number" min="0" max="1" step="0.01" value="0.88"></div>
<div class="field span2"><label>GLPI-Kategorien (leer = alle)</label><select id="kbCategories" multiple size="6"></select></div>
<div class="field span2"><label>Keywords</label><input id="kbKeywords" placeholder="Konto gesperrt, Login, Passwort"></div>
<div class="field span2"><label>Source URI (optional)</label><input id="kbUri" placeholder="kb://identity/account-locked"></div>
<div class="field span4"><label>Wissen / Diagnosekontext</label><textarea id="kbText" required></textarea></div>
<div class="field span4"><label>Freigegebener Antworttext</label><textarea id="kbAnswer" placeholder="Nur der fachliche Text; Anrede und Signatur ergänzt der Agent."></textarea></div>
<div class="field span4 check"><input id="kbAutoReply" type="checkbox"><label for="kbAutoReply">Für automatische Antwort grundsätzlich freigeben</label></div>
</div>
<div class="actions"><button class="primary" type="submit">Speichern</button><button type="button" onclick="resetKBForm()">Neu / Zurücksetzen</button></div>
</form>
</div>
<div class="scroll" style="margin-top:12px"><table><thead><tr><th>ID / Titel</th><th>Quelle</th><th>Auto-Reply</th><th>Kategorien</th><th>Aktionen</th></tr></thead><tbody id="kbRows"><tr><td colspan="5">Lade…</td></tr></tbody></table></div>
</section>
<section>
<h2>Bestätigtes Lernen</h2>
<div class="panel"><div class="muted">Nur von Ihnen bestätigte Zuordnungen werden als Beispiele an die KI weitergegeben. KI-Entscheidungen lernen niemals automatisch aus sich selbst.</div></div>
<div class="scroll" style="margin-top:12px"><table><thead><tr><th>Beispiel</th><th>Kategorie</th><th>Zeit</th><th></th></tr></thead><tbody id="learningRows"><tr><td colspan="4">Lade…</td></tr></tbody></table></div>
</section>
</div>
</div>
<div id="learnModal" class="modal" onclick="if(event.target===this)closeLearn()">
<div class="modalbox"><h3>Kategorie als Lernbeispiel bestätigen</h3><p id="learnTicket" class="muted"></p><div class="field"><label>Korrekte GLPI-Kategorie</label><select id="learnCategory"></select></div><div class="actions"><button class="primary" onclick="saveLearning()">Bestätigen</button><button onclick="closeLearn()">Abbrechen</button></div></div>
</div>
<script>
const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const pct=v=>`${Math.round(Number(v||0)*100)} %`;
const cat=(name,id)=>name?`${name} (#${id})`:`#${id||0}`;
let categories=[], kbDocs=[], runsData=[], currentLearnRun='';
function msg(text,kind='good'){const x=document.querySelector('#globalMsg');x.textContent=text;x.className=`msg show ${kind}`;setTimeout(()=>x.className='msg',5000)}
async function api(url,opt={}){opt.headers={...(opt.headers||{}),'X-Requested-With':'GLPI-AI-Agent'};if(opt.body)opt.headers['Content-Type']='application/json';const r=await fetch(url,opt);if(!r.ok){throw new Error((await r.text()).trim()||`HTTP ${r.status}`)};if(r.status===204)return null;return r.json()}
function categoryDecision(x){
const code=x.category_decision||''; const current=cat(x.category_before_name||((x.category_before||0)===0?'Nicht gesetzt':''),x.category_before||0); const threshold=pct(x.category_threshold);
let ai=x.ai_recommended_category_id?`${cat(x.ai_recommended_category_name,x.ai_recommended_category_id)} · ${pct(x.ai_category_confidence)}`:`Keine passende Kategorie · Sicherheit ${pct(x.ai_category_confidence)}`;
let label='Keine Aktion',cls='pill-warn',detail=code||'keine Policy-Information';
if(code==='category_written'){label='Geändert';cls='pill-ok';detail=`KI ${pct(x.ai_category_confidence)}${threshold}`}
else if(code==='category_accepted_dry_run'){label='Würde ändern';cls='pill-ok';detail=`DRY RUN · KI ${pct(x.ai_category_confidence)}${threshold}`}
else if(code==='category_accepted'){label='Freigegeben';cls='pill-ok';detail=`KI ${pct(x.ai_category_confidence)}${threshold}`}
else if(code==='category_already_correct'){label='Bereits korrekt';cls='pill-ok';detail=`KI ${pct(x.ai_category_confidence)}`}
else if(code==='category_confidence_below_threshold'){label='Blockiert';detail=`KI ${pct(x.ai_category_confidence)} < Schwellwert ${threshold}`}
else if(code==='category_unknown'){label='Blockiert';cls='pill-bad';detail='Kategorie-ID ist nicht in GLPI bekannt'}
else if(code==='category_no_recommendation'){label='Keine Empfehlung';detail='Keine angebotene Kategorie fachlich ausreichend'}
else if(code==='category_auto_disabled'){label='Auto-Kategorie aus';detail='Empfehlung wird nur protokolliert'}
else if(code==='category_ticket_changed_before_write'){label='Abgebrochen';detail='Ticket wurde während der Analyse verändert'}
else if(code==='category_write_failed'){label='Schreibfehler';cls='pill-bad';detail='GLPI-Kategorie konnte nicht geschrieben werden'}
return `<div class="decision"><div><strong>Aktuell:</strong> ${esc(current)}</div><div><strong>KI:</strong> ${esc(ai)}</div><div class="sub">Schwellwert ${esc(threshold)}</div><div style="margin-top:6px"><span class="pill ${cls}">${esc(label)}</span> <span class="sub">${esc(detail)}</span></div>${(!x.error&&x.outcome==='processed')?`<div class="actions"><button onclick="openLearn('${esc(x.run_id)}',${Number(x.ai_recommended_category_id||x.category_before||0)})">✓ Kategorie bestätigen/korrigieren</button></div>`:''}</div>`;
}
function replyDecision(x){
const code=x.reply_decision||'';const conf=pct(x.ai_reply_confidence),threshold=pct(x.reply_threshold);let label='Keine Antwort',cls='pill-warn',detail=code||'keine Policy-Information';
if(code==='reply_written'){label='Geschrieben';cls='pill-ok';detail=`KI ${conf}${threshold}`} else if(code==='reply_accepted_dry_run'){label='Würde antworten';cls='pill-ok';detail=`DRY RUN · KI ${conf}${threshold}`} else if(code==='reply_accepted'){label='Freigegeben';cls='pill-ok'} else if(code==='reply_auto_disabled'){detail='AUTO_REPLY=false'} else if(code==='reply_no_knowledge_candidates'){detail='Keine freigegebene Wissensquelle gefunden'} else if(code==='reply_model_not_recommended'){detail='KI empfiehlt keine automatische Antwort'} else if(code==='reply_confidence_below_threshold'){detail=`KI ${conf} < Schwellwert ${threshold}`} else if(code==='reply_no_knowledge_selected'){detail='Keine Knowledge-ID ausgewählt'} else if(code==='reply_context_incomplete'){detail='Kontextquelle unvollständig / nicht erreichbar'} else if(code==='reply_relevant_incident'){detail='Relevanter Major Incident oder Service-Ausfall'} else if(code==='reply_existing_followup'){detail='Ticket hatte bereits ein Followup'} else if(code==='reply_followup_appeared_before_write'){detail='Während der Analyse ist ein Followup hinzugekommen'} else if(code==='reply_knowledge_not_found'){detail='Gewählte Knowledge-ID nicht gefunden'} else if(code==='reply_source_not_allowed'){detail='Knowledge-Quelle nicht erlaubt'} else if(code==='reply_source_not_allowed_for_auto_reply'){detail='Quelle darf nicht automatisch antworten'} else if(code==='reply_language_mismatch'){detail='Knowledge-Sprache passt nicht'} else if(code==='reply_style_mismatch'){detail='Knowledge-Stil passt nicht'} else if(code==='reply_knowledge_auto_reply_disabled'){detail='KB nicht für Auto-Reply freigegeben'} else if(code==='reply_knowledge_score_below_threshold'){detail='Knowledge-Ähnlichkeit unter Schwellwert'} else if(code==='reply_knowledge_answer_empty'){detail='Kein freigegebener Antworttext'} else if(code==='reply_category_not_allowed'){detail='KB nicht für Zielkategorie freigegeben'} else if(code==='reply_ticket_changed_before_write'){detail='Ticket wurde während der Analyse verändert'} else if(code==='reply_write_failed'){label='Schreibfehler';cls='pill-bad';detail='Followup konnte nicht geschrieben werden'}
const ai=x.ai_reply_recommended?`Ja · ${conf}`:`Nein · ${conf}`;let knowledge=x.knowledge_top_id?`<div class="knowledge">Top-KB: ${esc(x.knowledge_top_title||x.knowledge_top_id)} (${esc(x.knowledge_top_id)}) · ${esc(pct(x.knowledge_score))}</div>`:'<div class="knowledge">Knowledge: keine Treffer</div>';
return `<div class="decision"><div><strong>Lösungsvorschlag KI:</strong> ${esc(ai)}</div><div class="sub">Reply-Schwellwert ${esc(threshold)}${x.ai_knowledge_id?` · KB ${esc(x.ai_knowledge_id)}`:''}</div><div style="margin-top:6px"><span class="pill ${cls}">${esc(label)}</span> <span class="sub">${esc(detail)}</span></div>${knowledge}</div>`;
}
function renderRuns(r){runsData=r;document.querySelector('#runs').innerHTML=r.length?r.map(x=>{const aiReason=x.ai_reason||x.reason||'';const policy=x.policy_reason||[x.category_decision,x.reply_decision].filter(Boolean).join('; ')||'';return `<tr><td>${esc(new Date(x.finished_at).toLocaleString('de-DE'))}</td><td>#${esc(x.ticket_id)} ${esc(x.ticket_name)}</td><td><span class="pill">${esc(x.outcome)}</span>${x.dry_run?'<div class="sub">Dry Run</div>':''}</td><td>${categoryDecision(x)}</td><td>${replyDecision(x)}</td><td class="hide-md">C:${esc(x.context_changes||0)} I:${esc(x.context_incidents||0)} U:${esc(x.context_issues||0)} D:${esc(x.context_devices||0)}${(x.context_warnings||[]).length?' ⚠':''}</td><td class="hide-sm reason"><div><strong>KI:</strong> ${esc(aiReason)}</div><div class="policy"><strong>Policy:</strong> ${esc(policy)}</div>${x.error?`<div class="bad"><strong>Fehler:</strong> ${esc(x.error)}</div>`:''}</td></tr>`}).join(''):'<tr><td colspan="7">Noch keine Verarbeitung.</td></tr>'}
function renderKB(){document.querySelector('#kbRows').innerHTML=kbDocs.length?kbDocs.map(d=>`<tr><td><div class="kb-title">${esc(d.id)}</div>${esc(d.title)}</td><td>${esc(d.source)}<div class="sub">${d.managed?'Web-verwaltet':(d.origin&&d.origin!=='static'?`${d.origin} / read-only`:'statisch / read-only')}</div></td><td>${d.auto_reply?'<span class="pill pill-ok">ja</span>':'<span class="pill">nein</span>'}</td><td>${esc((d.categories||[]).join(', ')||'alle')} ${(d.source_category_ids||[]).length?`<div class="sub">GLPI-KB-Kategorien: ${esc(d.source_category_ids.join(', '))}</div>`:''}</td><td><div class="actions">${d.managed?`<button onclick="editKB('${esc(d.id)}')">Bearbeiten</button><button class="danger" onclick="deleteKB('${esc(d.id)}')">Löschen</button>`:'<span class="sub">über Git/Datei verwalten</span>'}</div></td></tr>`).join(''):'<tr><td colspan="5">Noch keine Knowledge-Einträge.</td></tr>'}
function renderLearning(rows){document.querySelector('#learningRows').innerHTML=rows.length?rows.map(x=>`<tr><td><strong>#${esc(x.ticket_id)} ${esc(x.subject)}</strong><div class="sub">${esc((x.text||'').slice(0,180))}</div></td><td>${esc(x.category_name)} (#${esc(x.category_id)})<div class="sub">${x.correction?'Korrektur':'Bestätigung'}${x.ai_recommended_category_id?` · KI #${esc(x.ai_recommended_category_id)} ${esc(pct(x.ai_confidence))}`:''}</div></td><td>${esc(new Date(x.created_at).toLocaleString('de-DE'))}</td><td><button class="danger" onclick="deleteLearning('${esc(x.id)}')">Löschen</button></td></tr>`).join(''):'<tr><td colspan="4">Noch keine bestätigten Beispiele.</td></tr>'}
async function refresh(){try{const [s,r,c,k,l]=await Promise.all([api('/api/status'),api('/api/runs?limit=50'),api('/api/categories'),api('/api/knowledge'),api('/api/learning')]);categories=c;kbDocs=k;const srcSel=document.querySelector('#kbSource');const oldSource=srcSel.value;srcSel.innerHTML=(s.knowledge_allowed_sources||[]).map(x=>`<option value="${esc(x)}">${esc(x)}</option>`).join('');if(oldSource&&[...srcSel.options].some(o=>o.value===oldSource))srcSel.value=oldSource;else if([...srcSel.options].some(o=>o.value==='internal-kb'))srcSel.value='internal-kb';const kbSel=document.querySelector('#kbCategories');const selected=new Set([...kbSel.selectedOptions].map(o=>Number(o.value)));kbSel.innerHTML=categories.map(x=>`<option value="${Number(x.id)}">${esc(x.completename||x.name)} (#${Number(x.id)})</option>`).join('');[...kbSel.options].forEach(o=>o.selected=selected.has(Number(o.value)));const cards=[['GLPI',s.glpi_ok?'OK':'Fehler',s.glpi_ok],['Ollama',s.ollama_ok?'OK':'Fehler',s.ollama_ok],['Verarbeitet',s.processed,true],['Fehler',s.errors,s.errors===0],['Knowledge',s.knowledge_docs,true],['Lernbeispiele',s.learning_examples,true],['Kategorie-Schwelle',pct(s.category_confidence),true],['Reply-Schwelle',pct(s.reply_confidence),true],['Sprache',s.communication_language,true],['Stil',s.communication_style,true],['KB-Editor',s.knowledge_edit_enabled?'aktiv':'aus',s.knowledge_edit_enabled],['GLPI-KB',s.glpi_kb_enabled?(s.glpi_kb_ok?`${s.glpi_kb_documents} synchronisiert`:'Fehler'):'aus',!s.glpi_kb_enabled||s.glpi_kb_ok],['Uptime Kuma',s.uptime_kuma_enabled?'an':'aus',true]];document.querySelector('#cards').innerHTML=cards.map(c=>`<div class="card"><div class="k">${esc(c[0])}</div><div class="v ${c[2]?'ok':'bad'}">${esc(c[1])}</div></div>`).join('');document.querySelector('#kbForm').querySelectorAll('input,textarea,select,button').forEach(x=>x.disabled=!s.knowledge_edit_enabled);const kd=document.querySelector('#kbDisabled');if(!s.knowledge_edit_enabled){kd.textContent='KB-Bearbeitung ist deaktiviert. Setzen Sie KNOWLEDGE_WEB_EDIT_ENABLED=true (nur mit authentifiziertem Dashboard).';kd.className='msg show'}else{kd.className='msg'}renderRuns(r);renderKB();renderLearning(l)}catch(e){msg(e.message,'err')}}
function resetKBForm(){document.querySelector('#kbForm').reset();if([...document.querySelector('#kbSource').options].some(o=>o.value==='internal-kb'))document.querySelector('#kbSource').value='internal-kb';document.querySelector('#kbLanguage').value='de-DE';document.querySelector('#kbStyle').value='formal';document.querySelector('#kbScore').value='0.88';document.querySelector('#kbFormTitle').textContent='Knowledge-Eintrag anlegen'}
function editKB(id){const d=kbDocs.find(x=>x.id===id);if(!d)return;document.querySelector('#kbFormTitle').textContent=`Knowledge-Eintrag bearbeiten: ${id}`;document.querySelector('#kbId').value=d.id||'';document.querySelector('#kbTitle').value=d.title||'';document.querySelector('#kbSource').value=d.source||'internal-kb';document.querySelector('#kbLanguage').value=d.language||'de-DE';document.querySelector('#kbStyle').value=d.communication_style||'formal';document.querySelector('#kbScore').value=d.min_score??0.88;[...document.querySelector('#kbCategories').options].forEach(o=>o.selected=(d.categories||[]).includes(Number(o.value)));document.querySelector('#kbKeywords').value=(d.keywords||[]).join(', ');document.querySelector('#kbUri').value=d.source_uri||'';document.querySelector('#kbText').value=d.text||'';document.querySelector('#kbAnswer').value=d.answer||'';document.querySelector('#kbAutoReply').checked=!!d.auto_reply;document.querySelector('#kbForm').scrollIntoView({behavior:'smooth'})}
document.querySelector('#kbForm').addEventListener('submit',async e=>{e.preventDefault();const strs=v=>v.split(',').map(x=>x.trim()).filter(Boolean);const selectedCats=[...document.querySelector('#kbCategories').selectedOptions].map(o=>Number(o.value));const d={id:document.querySelector('#kbId').value.trim(),title:document.querySelector('#kbTitle').value.trim(),source:document.querySelector('#kbSource').value.trim(),language:document.querySelector('#kbLanguage').value.trim(),communication_style:document.querySelector('#kbStyle').value,text:document.querySelector('#kbText').value.trim(),answer:document.querySelector('#kbAnswer').value.trim(),auto_reply:document.querySelector('#kbAutoReply').checked,min_score:Number(document.querySelector('#kbScore').value||0),categories:selectedCats,keywords:strs(document.querySelector('#kbKeywords').value),source_uri:document.querySelector('#kbUri').value.trim()};try{await api('/api/knowledge',{method:'POST',body:JSON.stringify(d)});msg('Knowledge-Eintrag gespeichert.','good');resetKBForm();await refresh()}catch(e){msg(e.message,'err')}})
async function deleteKB(id){if(!confirm(`Knowledge-Eintrag ${id} wirklich löschen?`))return;try{await api(`/api/knowledge/${encodeURIComponent(id)}`,{method:'DELETE'});msg('Knowledge-Eintrag gelöscht.','good');await refresh()}catch(e){msg(e.message,'err')}}
function openLearn(runID,preferred){currentLearnRun=runID;const run=runsData.find(x=>x.run_id===runID);document.querySelector('#learnTicket').textContent=run?`#${run.ticket_id} ${run.ticket_name}`:runID;const sel=document.querySelector('#learnCategory');sel.innerHTML=categories.map(c=>`<option value="${Number(c.id)}">${esc(c.completename||c.name)} (#${Number(c.id)})</option>`).join('');if(preferred>0)sel.value=String(preferred);document.querySelector('#learnModal').classList.add('show')}
function closeLearn(){document.querySelector('#learnModal').classList.remove('show');currentLearnRun=''}
async function saveLearning(){const id=Number(document.querySelector('#learnCategory').value);try{await api('/api/learning',{method:'POST',body:JSON.stringify({run_id:currentLearnRun,category_id:id})});msg('Kategorie bestätigt. Das Beispiel wird künftig für ähnliche Tickets verwendet.','good');closeLearn();await refresh()}catch(e){msg(e.message,'err')}}
async function deleteLearning(id){if(!confirm('Lernbeispiel wirklich löschen?'))return;try{await api(`/api/learning/${encodeURIComponent(id)}`,{method:'DELETE'});msg('Lernbeispiel gelöscht.','good');await refresh()}catch(e){msg(e.message,'err')}}
refresh();setInterval(async()=>{try{const [s,r]=await Promise.all([api('/api/status'),api('/api/runs?limit=50')]);renderRuns(r)}catch(e){}},5000);
</script>
</body>
</html>