Files
jbergner bca5c1335c
release-tag / release-image (push) Successful in 1m39s
KB Kategorie-Mapping
2026-07-30 08:14:37 +02:00

72 lines
18 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="color-scheme" content="dark">
<title>GLPI AI Agent · Kategorie-Mapping</title>
<style>
:root{color-scheme:dark;--bg:#08101c;--panel:#101b2b;--panel2:#142238;--panel3:#182940;--border:#263952;--border2:#314965;--text:#eef5ff;--muted:#8fa3bc;--muted2:#b4c2d2;--accent:#5b8cff;--green:#41d695;--amber:#f4bd5f;--red:#ff7474;--cyan:#56d6e7;font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at 80% -20%,rgba(91,140,255,.16),transparent 35%),linear-gradient(180deg,var(--bg),#07101a 70%);color:var(--text);min-height:100vh}button,input{font:inherit}.shell{max-width:1650px;margin:0 auto;padding:26px 30px 50px}.top{display:flex;justify-content:space-between;gap:20px;align-items:flex-start;margin-bottom:20px}.eyebrow{font-size:12px;letter-spacing:.12em;text-transform:uppercase;color:var(--accent);font-weight:700}.title{font-size:30px;letter-spacing:-.035em;margin:5px 0 7px}.sub{color:var(--muted);max-width:850px;line-height:1.5}.toolbar{display:flex;gap:8px;flex-wrap:wrap;justify-content:flex-end}.btn{border:1px solid var(--border2);background:var(--panel2);color:var(--text);border-radius:10px;padding:9px 12px;cursor:pointer;text-decoration:none;display:inline-flex;align-items:center;gap:7px}.btn:hover{background:var(--panel3)}.btn.primary{background:linear-gradient(135deg,#3f75ef,#6c52e7);border-color:transparent}.btn.danger{border-color:rgba(255,116,116,.35);background:rgba(126,35,44,.3);color:#ffb9b9}.btn:disabled{opacity:.45;cursor:not-allowed}.stats{display:grid;grid-template-columns:repeat(5,minmax(145px,1fr));gap:12px;margin-bottom:16px}.stat,.panel{background:linear-gradient(180deg,rgba(16,27,43,.98),rgba(13,23,38,.98));border:1px solid var(--border);border-radius:15px}.stat{padding:15px}.stat .k{font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:var(--muted)}.stat .v{font-size:26px;font-weight:760;margin-top:9px}.stat .f{font-size:12px;color:var(--muted);margin-top:4px}.layout{display:grid;grid-template-columns:minmax(460px,.9fr) minmax(520px,1.1fr);gap:14px;align-items:start}.panel-head{padding:16px 17px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;gap:12px;align-items:center}.panel-title{font-weight:720}.panel-sub{font-size:12px;color:var(--muted);margin-top:3px}.panel-body{padding:16px}.filters{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px}.search{flex:1;min-width:220px}.search input,.select-search{width:100%;background:#0b1625;border:1px solid var(--border2);border-radius:10px;color:var(--text);padding:9px 10px;outline:none}.search input:focus,.select-search:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(91,140,255,.12)}.seg{display:flex;border:1px solid var(--border2);border-radius:10px;overflow:hidden}.seg button{border:0;border-right:1px solid var(--border2);background:#0b1625;color:var(--muted2);padding:8px 10px;cursor:pointer}.seg button:last-child{border-right:0}.seg button.active{background:rgba(91,140,255,.16);color:#dce8ff}.list{display:grid;gap:7px;max-height:calc(100vh - 305px);overflow:auto;padding-right:3px}.row{border:1px solid var(--border);border-radius:11px;background:rgba(15,28,46,.7);padding:11px 12px;cursor:pointer;display:grid;grid-template-columns:minmax(0,1fr) auto;gap:10px}.row:hover,.row.active{border-color:#42658d;background:rgba(91,140,255,.075)}.row-title{font-weight:680;overflow-wrap:anywhere}.row-meta{font-size:11px;color:var(--muted);margin-top:4px}.chips{display:flex;gap:5px;flex-wrap:wrap;margin-top:7px}.chip{display:inline-flex;align-items:center;border:1px solid var(--border);border-radius:999px;padding:4px 8px;font-size:11px;color:var(--muted2);background:rgba(16,27,43,.8)}.chip.good{border-color:rgba(65,214,149,.3);background:rgba(65,214,149,.09);color:#9aefc8}.chip.warn{border-color:rgba(244,189,95,.3);background:rgba(244,189,95,.09);color:#f7d89d}.chip.bad{border-color:rgba(255,116,116,.3);background:rgba(255,116,116,.09);color:#ffb5b5}.chip.info{border-color:rgba(91,140,255,.3);background:rgba(91,140,255,.09);color:#bdd0ff}.count{font-size:12px;color:var(--muted);white-space:nowrap}.empty{padding:28px;text-align:center;color:var(--muted);border:1px dashed var(--border2);border-radius:12px}.detail-title{font-size:23px;font-weight:760;letter-spacing:-.025em;margin:5px 0}.detail-note{font-size:12px;color:var(--muted);line-height:1.5}.section{margin-top:17px}.label{font-size:11px;text-transform:uppercase;letter-spacing:.065em;color:var(--muted);font-weight:650;margin-bottom:8px}.glpi-list{display:grid;gap:6px;max-height:410px;overflow:auto;padding-right:3px}.glpi-item{display:grid;grid-template-columns:22px minmax(0,1fr) auto;gap:9px;align-items:start;border:1px solid var(--border);border-radius:10px;padding:9px 10px;background:rgba(11,22,37,.75);cursor:pointer}.glpi-item:hover{border-color:#42658d}.glpi-item input{margin-top:2px}.glpi-name{font-size:13px}.glpi-id{font:11px ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--muted)}.suggest{font-size:10px;border:1px solid rgba(86,214,231,.3);background:rgba(86,214,231,.08);color:#a9edf5;border-radius:999px;padding:3px 6px}.actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:16px;padding-top:15px;border-top:1px solid var(--border)}.notice{border:1px solid var(--border);border-left:3px solid var(--accent);border-radius:10px;padding:10px 12px;font-size:12px;color:var(--muted2);line-height:1.5;margin-bottom:12px;background:rgba(20,34,56,.55)}.notice.warn{border-left-color:var(--amber)}.notice.bad{border-left-color:var(--red)}.saving{position:fixed;inset:0;background:rgba(3,8,15,.72);backdrop-filter:blur(4px);display:none;z-index:20;place-items:center}.saving.show{display:grid}.saving-card{width:min(520px,90vw);background:var(--panel);border:1px solid var(--border2);border-radius:15px;padding:22px;box-shadow:0 25px 70px rgba(0,0,0,.4)}.spinner{width:28px;height:28px;border:3px solid var(--border2);border-top-color:var(--accent);border-radius:50%;animation:spin .8s linear infinite;margin-bottom:12px}@keyframes spin{to{transform:rotate(360deg)}}.toast{position:fixed;right:22px;bottom:22px;background:#112138;border:1px solid var(--border2);border-radius:11px;padding:11px 13px;box-shadow:0 15px 45px rgba(0,0,0,.35);display:none;z-index:30;max-width:420px}.toast.show{display:block}.toast.bad{border-color:rgba(255,116,116,.45);color:#ffb5b5}.dirty{color:var(--amber);font-size:12px}.mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}@media(max-width:980px){.layout{grid-template-columns:1fr}.stats{grid-template-columns:repeat(2,1fr)}.list{max-height:470px}.top{flex-direction:column}.toolbar{justify-content:flex-start}}@media(max-width:560px){.shell{padding:18px 14px}.stats{grid-template-columns:1fr 1fr}.title{font-size:25px}}
</style>
</head>
<body>
<div class="shell">
<header class="top">
<div><div class="eyebrow">Knowledge Governance</div><h1 class="title">Kategorie-Mapping</h1><div class="sub">Externe Kategorien aus den indexierten Knowledge-Dokumenten werden hier explizit mit GLPI-ITIL-Kategorien verbunden. Die Zuordnungen steuern direkt <span class="mono">KNOWLEDGE_CATEGORY_MAP_FILE</span>.</div></div>
<div class="toolbar"><a class="btn" href="/">← Control Center</a><a class="btn" href="/diagnostics">Diagnose</a><button class="btn primary" id="saveAll">Änderungen speichern</button></div>
</header>
<div id="warning"></div>
<section class="stats">
<div class="stat"><div class="k">KB-Kategorien</div><div class="v" id="stObserved"></div><div class="f">im aktiven Index gefunden</div></div>
<div class="stat"><div class="k">Zugeordnet</div><div class="v" id="stMapped"></div><div class="f">mindestens eine GLPI-Kategorie</div></div>
<div class="stat"><div class="k">Nicht zugeordnet</div><div class="v" id="stUnmapped"></div><div class="f">potenzielle Policy-Blocker</div></div>
<div class="stat"><div class="k">GLPI-Kategorien</div><div class="v" id="stGlpi"></div><div class="f">aktuell aus GLPI gelesen</div></div>
<div class="stat"><div class="k">Verwaiste Mappings</div><div class="v" id="stOrphan"></div><div class="f">Mapping vorhanden, derzeit keine KB</div></div>
</section>
<div class="layout">
<section class="panel">
<div class="panel-head"><div><div class="panel-title">Externe KB-Kategorien</div><div class="panel-sub" id="fileInfo">Lade Mapping …</div></div><div class="dirty" id="dirty"></div></div>
<div class="panel-body">
<div class="filters"><div class="search"><input id="externalSearch" placeholder="Kategorie suchen …"></div><div class="seg" id="filterSeg"><button class="active" data-filter="all">Alle</button><button data-filter="unmapped">Offen</button><button data-filter="mapped">Gemappt</button><button data-filter="orphan">Verwaist</button></div></div>
<div class="list" id="externalList"><div class="empty">Lade Kategorien …</div></div>
</div>
</section>
<section class="panel">
<div class="panel-head"><div><div class="panel-title">Zuordnung zu GLPI</div><div class="panel-sub">Mehrfachzuordnungen sind möglich.</div></div></div>
<div class="panel-body" id="detail"><div class="empty">Links eine externe Kategorie auswählen.</div></div>
</section>
</div>
</div>
<div class="saving" id="saving"><div class="saving-card"><div class="spinner"></div><div style="font-weight:720;font-size:17px">Mapping wird angewendet …</div><div class="detail-note" style="margin-top:7px">Die Datei wird atomar gespeichert und die lokalen KB-Metadaten werden neu ausgewertet. Vorhandene Embeddings werden dabei wiederverwendet.</div></div></div>
<div class="toast" id="toast"></div>
<script>
'use strict';
const $=q=>document.querySelector(q), esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
let data=null, entries=[], glpi=[], selected='', filter='all', dirty=false, editable=false;
const normalize=s=>String(s||'').toLowerCase().trim().replace(/\s+/g,' ');
const tokens=s=>new Set(normalize(s).split(/[^a-z0-9äöüß]+/i).filter(x=>x.length>2));
function toast(msg,bad=false){const t=$('#toast');t.textContent=msg;t.className='toast show'+(bad?' bad':'');clearTimeout(t._timer);t._timer=setTimeout(()=>t.className='toast',4200)}
async function api(url,opt={}){const headers={'X-Requested-With':'GLPI-AI-Agent',...(opt.headers||{})};if(opt.body)headers['Content-Type']='application/json';const r=await fetch(url,{...opt,headers});if(!r.ok)throw new Error((await r.text()).trim()||`HTTP ${r.status}`);if(r.status===204)return null;return r.json()}
function markDirty(v=true){dirty=v;$('#dirty').textContent=v?'● Nicht gespeichert':'';$('#saveAll').disabled=!editable||!v}
function entryByKey(k){return entries.find(e=>e.normalized===k)}
function selectedIDs(e){return new Set((e?.mapped_ids||[]).map(Number))}
function scoreSuggestion(label,c){const a=tokens(label),b=tokens(`${c.name||''} ${c.completename||''}`);if(!a.size||!b.size)return 0;let hit=0;a.forEach(x=>{if(b.has(x))hit++});let score=hit/Math.max(a.size,1);const nl=normalize(label),nn=normalize(c.name);if(nl===nn)score+=1;if(normalize(c.completename).endsWith(' > '+nl)||normalize(c.completename)===nl)score+=.7;return score}
function renderStats(){const observed=entries.filter(e=>e.observed),mapped=observed.filter(e=>e.mapped_ids?.length),unmapped=observed.filter(e=>!e.mapped_ids?.length),orphan=entries.filter(e=>!e.observed&&e.configured);$('#stObserved').textContent=observed.length.toLocaleString('de-DE');$('#stMapped').textContent=mapped.length.toLocaleString('de-DE');$('#stUnmapped').textContent=unmapped.length.toLocaleString('de-DE');$('#stGlpi').textContent=glpi.length.toLocaleString('de-DE');$('#stOrphan').textContent=orphan.length.toLocaleString('de-DE')}
function mappingNames(ids){return (ids||[]).map(id=>{const c=glpi.find(x=>Number(x.id)===Number(id));return c?(c.completename||c.name)+` (#${id})`:`#${id}`})}
function visibleEntries(){const q=normalize($('#externalSearch').value);return entries.filter(e=>{if(q&&!normalize(`${e.label} ${mappingNames(e.mapped_ids).join(' ')}`).includes(q))return false;if(filter==='unmapped'&&(!e.observed||e.mapped_ids?.length))return false;if(filter==='mapped'&&!e.mapped_ids?.length)return false;if(filter==='orphan'&&(e.observed||!e.configured))return false;return true})}
function renderExternal(){const rows=visibleEntries();$('#externalList').innerHTML=rows.map(e=>{const mapped=e.mapped_ids?.length>0;const chips=mapped?mappingNames(e.mapped_ids).slice(0,3).map(n=>`<span class="chip good">${esc(n)}</span>`).join(''):`<span class="chip bad">nicht zugeordnet</span>`;const more=(e.mapped_ids?.length||0)>3?`<span class="chip">+${e.mapped_ids.length-3}</span>`:'';return `<div class="row ${selected===e.normalized?'active':''}" data-key="${esc(e.normalized)}"><div><div class="row-title">${esc(e.label)}</div><div class="row-meta">${e.observed?`${Number(e.usage_count||0).toLocaleString('de-DE')} Knowledge-Dokumente`:'Nicht mehr im aktuellen Index vorhanden'}</div><div class="chips">${chips}${more}${!e.observed?'<span class="chip warn">verwaist</span>':''}</div></div><div class="count">${mapped?'✓':'!'}</div></div>`}).join('')||'<div class="empty">Keine Kategorien für diesen Filter.</div>';document.querySelectorAll('[data-key]').forEach(x=>x.onclick=()=>{selected=x.dataset.key;renderExternal();renderDetail()})}
function renderDetail(){const e=entryByKey(selected);if(!e){$('#detail').innerHTML='<div class="empty">Links eine externe Kategorie auswählen.</div>';return}const ids=selectedIDs(e);const qHTML=`<input class="select-search" id="glpiSearch" placeholder="GLPI-Kategorie suchen …">`;const suggested=glpi.map(c=>({c,score:scoreSuggestion(e.label,c)})).filter(x=>x.score>.35).sort((a,b)=>b.score-a.score).slice(0,6).map(x=>Number(x.c.id));const rows=glpi.map(c=>{const id=Number(c.id),checked=ids.has(id),suggest=suggested.includes(id);return `<label class="glpi-item" data-glpi-text="${esc(normalize(`${c.completename||c.name} ${id}`))}"><input type="checkbox" value="${id}" ${checked?'checked':''} ${editable?'':'disabled'}><div><div class="glpi-name">${esc(c.completename||c.name)}</div><div class="glpi-id">#${id}${c.name&&c.completename!==c.name?` · ${esc(c.name)}`:''}</div></div>${suggest?'<span class="suggest">Vorschlag</span>':''}</label>`}).join('');$('#detail').innerHTML=`<div class="eyebrow">Externe Kategorie</div><div class="detail-title">${esc(e.label)}</div><div class="detail-note">${e.observed?`In ${Number(e.usage_count||0).toLocaleString('de-DE')} indexierten KB-Dokumenten gefunden.`:'Dieses Mapping steht noch in der Datei, wird aber derzeit von keinem indexierten KB-Dokument verwendet.'}</div><div class="section"><div class="label">Aktuelle Zuordnung</div><div class="chips" id="currentChips">${ids.size?mappingNames([...ids]).map(n=>`<span class="chip good">${esc(n)}</span>`).join(''):'<span class="chip bad">Keine GLPI-Kategorie zugeordnet</span>'}</div></div><div class="section"><div class="label">GLPI-ITIL-Kategorien auswählen</div>${qHTML}<div class="detail-note" style="margin:7px 0 9px">„Vorschlag“ basiert nur auf Namensähnlichkeit und wird niemals automatisch gespeichert.</div><div class="glpi-list" id="glpiList">${rows}</div></div><div class="actions"><button class="btn" id="clearSelected" ${editable?'':'disabled'}>Zuordnung entfernen</button>${!e.observed?`<button class="btn danger" id="deleteOrphan" ${editable?'':'disabled'}>Verwaistes Mapping löschen</button>`:''}</div>`;$('#glpiSearch').oninput=ev=>{const q=normalize(ev.target.value);document.querySelectorAll('.glpi-item').forEach(x=>x.style.display=!q||x.dataset.glpiText.includes(q)?'grid':'none')};document.querySelectorAll('#glpiList input[type=checkbox]').forEach(cb=>cb.onchange=()=>{const id=Number(cb.value),set=selectedIDs(e);cb.checked?set.add(id):set.delete(id);e.mapped_ids=[...set].sort((a,b)=>a-b);e.configured=e.mapped_ids.length>0;markDirty();renderStats();renderExternal();renderDetail()});$('#clearSelected').onclick=()=>{e.mapped_ids=[];e.configured=false;markDirty();renderStats();renderExternal();renderDetail()};const del=$('#deleteOrphan');if(del)del.onclick=()=>{entries=entries.filter(x=>x.normalized!==e.normalized);selected='';markDirty();renderStats();renderExternal();renderDetail()}}
function renderAll(){renderStats();renderExternal();renderDetail();$('#fileInfo').textContent=data?.mapping?.configured?`${data.mapping.path} · Modus ${data.mapping.mode}`:'Keine Mapping-Datei konfiguriert';$('#warning').innerHTML=!data?.mapping?.configured?'<div class="notice bad">KNOWLEDGE_CATEGORY_MAP_FILE ist nicht konfiguriert. Der Editor ist nur lesbar.</div>':!editable?'<div class="notice warn">Der Mapping-Editor ist schreibgeschützt. Für Änderungen muss KNOWLEDGE_WEB_EDIT_ENABLED=true sein.</div>':'<div class="notice">Speichern schreibt die Mapping-Datei atomar und bewertet anschließend die lokalen Knowledge-Dokumente neu. Die Embeddings werden nicht neu erzeugt, solange Titel/Text/Keywords unverändert sind.</div>'}
async function load(){data=await api('/api/category-mappings');entries=(data.mapping.entries||[]).map(e=>({...e,mapped_ids:(e.mapped_ids||[]).map(Number)}));glpi=data.glpi||[];editable=!!data.editable;selected=entries.find(e=>e.observed&&!e.mapped_ids?.length)?.normalized||entries.find(e=>e.observed)?.normalized||entries[0]?.normalized||'';markDirty(false);renderAll()}
async function save(){if(!editable||!dirty)return;const payload={mappings:entries.filter(e=>e.mapped_ids?.length).map(e=>({label:e.label,category_ids:e.mapped_ids.map(Number)}))};$('#saving').classList.add('show');$('#saveAll').disabled=true;try{await api('/api/category-mappings',{method:'PUT',body:JSON.stringify(payload)});toast('Kategorie-Mapping gespeichert und auf den Knowledge-Index angewendet.');await load()}catch(e){toast(e.message,true);markDirty(true)}finally{$('#saving').classList.remove('show')}}
$('#externalSearch').oninput=renderExternal;document.querySelectorAll('#filterSeg button').forEach(b=>b.onclick=()=>{filter=b.dataset.filter;document.querySelectorAll('#filterSeg button').forEach(x=>x.classList.toggle('active',x===b));renderExternal()});$('#saveAll').onclick=save;window.addEventListener('beforeunload',e=>{if(dirty){e.preventDefault();e.returnValue=''}});load().catch(e=>{$('#warning').innerHTML=`<div class="notice bad">${esc(e.message)}</div>`;toast(e.message,true)});
</script>
</body>
</html>