72 lines
18 KiB
HTML
72 lines
18 KiB
HTML
<!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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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>
|