homelab-butler/ui.html

125 lines
31 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">
<meta name="color-scheme" content="dark">
<title>Pfannkuchen Butler</title>
<style>
:root{--bg:#08090a;--panel:#0f1011;--surface:#191a1b;--surface2:#222326;--border:rgba(255,255,255,.08);--border2:rgba(255,255,255,.05);--text:#f7f8f8;--secondary:#d0d6e0;--muted:#8a8f98;--dim:#62666d;--accent:#7170ff;--accent2:#5e6ad2;--green:#10b981;--yellow:#f59e0b;--red:#ef4444;--radius:10px}
*{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;background:radial-gradient(circle at 65% -20%,rgba(113,112,255,.14),transparent 34%),var(--bg);color:var(--text);font-family:Inter,ui-sans-serif,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;font-feature-settings:"cv01","ss03";min-height:100vh}button,input,select{font:inherit}button{cursor:pointer}a{color:inherit}.hidden{display:none!important}
.shell{display:grid;grid-template-columns:248px minmax(0,1fr);min-height:100vh}.sidebar{position:sticky;top:0;height:100vh;padding:22px 16px;border-right:1px solid var(--border2);background:rgba(15,16,17,.92);backdrop-filter:blur(18px);z-index:20}.brand{display:flex;align-items:center;gap:11px;padding:0 8px 24px}.logo{width:34px;height:34px;border:1px solid rgba(113,112,255,.35);border-radius:10px;display:grid;place-items:center;background:linear-gradient(145deg,rgba(113,112,255,.22),rgba(255,255,255,.03));box-shadow:inset 0 0 16px rgba(113,112,255,.12)}.brand strong{font-size:14px;font-weight:590}.brand small{display:block;color:var(--muted);font-size:11px;margin-top:2px}.nav-label{font-size:10px;text-transform:uppercase;letter-spacing:.1em;color:var(--dim);padding:15px 10px 7px}.nav button{width:100%;display:flex;align-items:center;gap:10px;border:0;background:transparent;color:var(--muted);padding:9px 10px;border-radius:7px;text-align:left;font-size:13px;font-weight:510}.nav button:hover,.nav button.active{background:rgba(255,255,255,.05);color:var(--text)}.nav .icon{width:18px;text-align:center;color:var(--dim)}.sidebar-foot{position:absolute;left:16px;right:16px;bottom:18px}.session-pill{display:flex;align-items:center;justify-content:space-between;border:1px solid var(--border);border-radius:8px;padding:9px 10px;color:var(--muted);font-size:11px;background:rgba(255,255,255,.02)}.dot{width:7px;height:7px;border-radius:50%;background:var(--green);box-shadow:0 0 10px rgba(16,185,129,.65)}
main{min-width:0}.topbar{height:68px;position:sticky;top:0;z-index:15;display:flex;align-items:center;justify-content:space-between;padding:0 30px;border-bottom:1px solid var(--border2);background:rgba(8,9,10,.78);backdrop-filter:blur(18px)}.topbar h1{font-size:15px;margin:0;font-weight:510}.top-actions{display:flex;gap:8px}.btn{border:1px solid var(--border);background:rgba(255,255,255,.03);color:var(--secondary);border-radius:7px;padding:8px 12px;font-size:12px;font-weight:510}.btn:hover{background:rgba(255,255,255,.07);color:var(--text)}.btn.primary{background:var(--accent2);border-color:transparent;color:white}.btn.danger{color:#fca5a5}.mobile-menu{display:none}.content{max-width:1440px;margin:0 auto;padding:30px}.view{display:none}.view.active{display:block}.eyebrow{font-size:11px;color:var(--accent);text-transform:uppercase;letter-spacing:.11em;font-weight:590}.hero{display:flex;justify-content:space-between;align-items:flex-end;gap:24px;margin:4px 0 26px}.hero h2{font-size:31px;letter-spacing:-.7px;font-weight:510;margin:8px 0 6px}.hero p{margin:0;color:var(--muted);font-size:14px;line-height:1.55}.updated{font:11px ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--dim)}
.metrics{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px;margin-bottom:18px}.metric,.panel,.operation{border:1px solid var(--border);background:rgba(255,255,255,.025);border-radius:var(--radius)}.metric{padding:17px}.metric-label{font-size:11px;color:var(--muted);margin-bottom:12px}.metric-value{font-size:25px;letter-spacing:-.45px;font-weight:510}.metric-meta{font-size:11px;color:var(--dim);margin-top:7px}.metric.good .metric-value{color:#a7f3d0}.metric.warn .metric-value{color:#fcd34d}.metric.bad .metric-value{color:#fca5a5}.grid-2{display:grid;grid-template-columns:minmax(0,1.4fr) minmax(300px,.6fr);gap:14px}.panel{padding:18px;min-width:0}.panel-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:15px}.panel-title{font-size:13px;font-weight:590}.panel-sub{font-size:11px;color:var(--dim)}.empty{border:1px dashed var(--border);border-radius:8px;color:var(--dim);padding:26px;text-align:center;font-size:12px}.finding{display:grid;grid-template-columns:9px 1fr auto;gap:10px;align-items:start;padding:11px 0;border-bottom:1px solid var(--border2)}.finding:last-child{border-bottom:0}.finding-dot{width:7px;height:7px;border-radius:50%;background:var(--yellow);margin-top:5px}.finding.critical .finding-dot{background:var(--red)}.finding strong{font-size:12px}.finding p{font-size:11px;color:var(--muted);margin:4px 0 0}.badge{display:inline-flex;align-items:center;border:1px solid var(--border);border-radius:999px;padding:3px 7px;font:10px ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--muted);white-space:nowrap}.badge.get,.badge.read_only{color:#a7f3d0;border-color:rgba(16,185,129,.25);background:rgba(16,185,129,.06)}.badge.post,.badge.put,.badge.patch{color:#c4b5fd;border-color:rgba(113,112,255,.3);background:rgba(113,112,255,.07)}.badge.delete,.badge.destructive{color:#fca5a5;border-color:rgba(239,68,68,.25);background:rgba(239,68,68,.06)}
.form-row{display:grid;grid-template-columns:1fr auto;gap:9px}.form-row.triple{grid-template-columns:160px 1fr auto}.sharing-form{grid-template-columns:140px 170px minmax(180px,1fr) auto}.input{width:100%;border:1px solid var(--border);background:rgba(255,255,255,.025);color:var(--text);border-radius:7px;padding:10px 12px;outline:0;font-size:13px}.input:focus{border-color:rgba(113,112,255,.6);box-shadow:0 0 0 3px rgba(113,112,255,.1)}select.input{appearance:none}.result{margin-top:14px;min-height:110px}.layer-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:9px}.layer{border:1px solid var(--border2);background:rgba(255,255,255,.02);border-radius:8px;padding:12px}.layer h4{font-size:11px;margin:0 0 8px;text-transform:uppercase;color:var(--muted);letter-spacing:.07em}.layer pre,.json{white-space:pre-wrap;word-break:break-word;margin:0;color:var(--secondary);font:11px/1.55 ui-monospace,SFMono-Regular,Menlo,monospace}.safe-banner{display:flex;align-items:center;gap:12px;border-radius:9px;padding:14px;border:1px solid rgba(16,185,129,.25);background:rgba(16,185,129,.06)}.safe-banner.blocked{border-color:rgba(239,68,68,.25);background:rgba(239,68,68,.06)}.safe-icon{font-size:21px}
.toolbar{display:flex;align-items:center;gap:9px;flex-wrap:wrap;margin-bottom:15px}.toolbar .input{max-width:360px}.chips{display:flex;gap:6px;flex-wrap:wrap}.chip{border:1px solid var(--border);background:transparent;color:var(--muted);border-radius:999px;padding:6px 10px;font-size:11px}.chip.active,.chip:hover{color:var(--text);background:rgba(255,255,255,.05)}.operations{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px}.operation{padding:14px;display:flex;flex-direction:column;gap:10px;min-height:145px;transition:.16s ease}.operation:hover{border-color:rgba(113,112,255,.35);transform:translateY(-1px);background:rgba(255,255,255,.035)}.operation-top{display:flex;justify-content:space-between;gap:8px}.operation-path{font:11px/1.45 ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--secondary);word-break:break-all}.operation h3{font-size:12px;font-weight:590;margin:0}.operation p{font-size:11px;color:var(--muted);line-height:1.45;margin:0;flex:1}.operation-badges{display:flex;gap:5px;flex-wrap:wrap}.table-wrap{overflow:auto;border:1px solid var(--border);border-radius:9px}table{width:100%;border-collapse:collapse;min-width:760px}th,td{text-align:left;padding:10px 12px;border-bottom:1px solid var(--border2);font-size:11px}th{color:var(--dim);text-transform:uppercase;letter-spacing:.07em;font-size:9px;background:rgba(255,255,255,.02)}td{color:var(--secondary)}td.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace}.status-code.ok{color:#a7f3d0}.status-code.err{color:#fca5a5}
.login{position:fixed;inset:0;z-index:100;background:radial-gradient(circle at 50% 15%,rgba(113,112,255,.18),transparent 30%),#08090a;display:grid;place-items:center;padding:20px}.login-card{width:min(420px,100%);border:1px solid var(--border);background:#0f1011;border-radius:14px;padding:28px;box-shadow:0 28px 90px rgba(0,0,0,.5)}.login-card .logo{margin-bottom:22px}.login-card h1{font-size:25px;letter-spacing:-.5px;font-weight:510;margin:0 0 8px}.login-card p{font-size:13px;color:var(--muted);line-height:1.5;margin:0 0 20px}.login-card form{display:grid;gap:10px}.error{color:#fca5a5;font-size:11px;min-height:16px}.toast{position:fixed;right:20px;bottom:20px;z-index:120;background:#191a1b;border:1px solid var(--border);border-radius:9px;padding:11px 14px;font-size:12px;color:var(--secondary);box-shadow:0 12px 40px rgba(0,0,0,.35);transform:translateY(20px);opacity:0;pointer-events:none;transition:.2s}.toast.show{transform:none;opacity:1}.spinner{width:15px;height:15px;border:2px solid rgba(255,255,255,.15);border-top-color:var(--accent);border-radius:50%;animation:spin .7s linear infinite;display:inline-block}@keyframes spin{to{transform:rotate(360deg)}}
@media(max-width:1050px){.operations{grid-template-columns:repeat(2,minmax(0,1fr))}.metrics{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-2{grid-template-columns:1fr}}
@media(max-width:760px){.shell{display:block}.sidebar{position:fixed;transform:translateX(-100%);transition:.2s;width:260px}.sidebar.open{transform:none}.mobile-menu{display:inline-flex}.topbar{padding:0 15px}.content{padding:20px 14px}.hero{align-items:flex-start;flex-direction:column}.hero h2{font-size:25px}.operations{grid-template-columns:1fr}.metrics{grid-template-columns:repeat(2,1fr)}.form-row,.form-row.triple,.sharing-form{grid-template-columns:1fr}.btn,.input{min-height:44px}.top-actions .desktop-only{display:none}}
@media(max-width:420px){.metrics{grid-template-columns:1fr}.content{padding:18px 10px}.panel{padding:14px}.metric{padding:14px}}
</style>
</head>
<body>
<div id="app" class="shell">
<aside id="sidebar" class="sidebar">
<div class="brand"><div class="logo">🍳</div><div><strong>Butler</strong><small>Pfannkuchen Operations</small></div></div>
<nav class="nav" aria-label="Hauptnavigation">
<div class="nav-label">Betrieb</div>
<button class="active" data-view="overview"><span class="icon">◫</span>Übersicht</button>
<button data-view="doctor"><span class="icon">⌕</span>Doctor</button>
<button data-view="sharing"><span class="icon">◉</span>Emby Account Sharing</button>
<button data-view="safety"><span class="icon">◇</span>Wartung & Drift</button>
<button data-view="audit"><span class="icon">≡</span>Audit</button>
<div class="nav-label">API</div>
<button data-view="functions"><span class="icon">⌘</span>Alle Funktionen</button>
<a class="btn" href="/docs" target="_blank" rel="noreferrer" style="display:block;margin:9px 6px;text-decoration:none;text-align:center">Swagger öffnen ↗</a>
</nav>
<div class="sidebar-foot"><div class="session-pill"><span>Read-only Sitzung</span><span class="dot"></span></div></div>
</aside>
<main>
<header class="topbar"><div style="display:flex;align-items:center;gap:9px"><button id="menu" class="btn mobile-menu" aria-label="Menü öffnen">☰</button><h1 id="view-title">Übersicht</h1></div><div class="top-actions"><button id="refresh" class="btn desktop-only">↻ Aktualisieren</button></div></header>
<div class="content">
<section id="view-overview" class="view active">
<div class="hero"><div><div class="eyebrow">Live Zustand</div><h2>Dein Homelab auf einen Blick.</h2><p>Deterministisch aus Butler-Collectoren – keine geschätzten Zustände.</p></div><div id="updated" class="updated">Noch nicht geladen</div></div>
<div class="metrics">
<article id="metric-state" class="metric"><div class="metric-label">Gesamtzustand</div><div class="metric-value">—</div><div class="metric-meta">Butler Overview</div></article>
<article class="metric"><div class="metric-label">Services</div><div id="metric-services" class="metric-value">—</div><div class="metric-meta">Funktionsprobes</div></article>
<article class="metric"><div class="metric-label">Hosts</div><div id="metric-hosts" class="metric-value">—</div><div class="metric-meta">Inventory erreichbar</div></article>
<article class="metric"><div class="metric-label">Backups</div><div id="metric-backups" class="metric-value">—</div><div class="metric-meta">inkl. Policy</div></article>
</div>
<div class="grid-2"><article class="panel"><div class="panel-head"><div class="panel-title">Findings</div><span id="finding-count" class="badge">0</span></div><div id="findings"><div class="empty">Overview wird geladen …</div></div></article><article class="panel"><div class="panel-head"><div class="panel-title">API-Abdeckung</div><span class="panel-sub">live aus OpenAPI</span></div><div id="capability-summary" class="layer-grid"></div></article></div>
</section>
<section id="view-doctor" class="view">
<div class="hero"><div><div class="eyebrow">Korrelierte Diagnose</div><h2>Doctor</h2><p>Service, Host, Container, Backup und Disk in einer Prüfung.</p></div></div>
<article class="panel"><form id="doctor-form" class="form-row"><input id="doctor-target" class="input" placeholder="z. B. emby-chris oder guck-vps" required><button class="btn primary" type="submit">Diagnose starten</button></form><div id="doctor-result" class="result"><div class="empty">Gib einen bekannten Host oder Service ein.</div></div></article>
</section>
<section id="view-sharing" class="view">
<div class="hero"><div><div class="eyebrow">Konservative Anomalieerkennung</div><h2>Emby Account Sharing</h2><p>Zeitgleiche öffentliche Netze und geografisch unmögliche Wechsel – über beide Emby-Server.</p></div><span class="badge read_only">read-only</span></div>
<article class="panel">
<form id="sharing-form" class="form-row sharing-form">
<select id="sharing-days" class="input" aria-label="Analysezeitraum"><option value="7">7 Tage</option><option value="30" selected>30 Tage</option><option value="90">90 Tage</option></select>
<select id="sharing-server" class="input" aria-label="Emby-Server"><option value="all">Beide Server</option><option value="emby-sascha">Emby Sascha</option><option value="emby-chris">Emby Chris</option></select>
<input id="sharing-username" class="input" placeholder="Benutzer optional" aria-label="Benutzerfilter">
<button class="btn primary" type="submit">Analysieren</button>
</form>
<div class="panel-sub" style="margin-top:10px">IPv4 wird exakt verglichen. IPv6 wird als /64 angezeigt und für die Haushaltserkennung konservativ auf /48 zusammengefasst.</div>
</article>
<div id="sharing-summary" class="metrics" style="margin-top:14px"><article class="metric"><div class="metric-label">Analysierte Benutzer</div><div class="metric-value">—</div></article><article class="metric"><div class="metric-label">Auffällige Benutzer</div><div class="metric-value">—</div></article><article class="metric"><div class="metric-label">Zeitgleiche Netze</div><div class="metric-value">—</div></article><article class="metric"><div class="metric-label">Unmögliche Wechsel</div><div class="metric-value">—</div></article></div>
<div class="grid-2"><article class="panel"><div class="panel-head"><div class="panel-title">Erkannte Ereignisse</div><span id="sharing-event-count" class="badge">0</span></div><div id="sharing-events"><div class="empty">Analyse noch nicht gestartet.</div></div></article><article class="panel"><div class="panel-head"><div class="panel-title">Benutzer-Risiko</div><span class="panel-sub">nur begründete Treffer</span></div><div id="sharing-users"><div class="empty">Noch keine Daten.</div></div></article></div>
</section>
<section id="view-safety" class="view">
<div class="hero"><div><div class="eyebrow">Read-only Safety Gate</div><h2>Wartung & Drift</h2><p>Blocker erkennen, bevor eine Änderung Schaden anrichtet.</p></div></div>
<div class="grid-2"><article class="panel"><div class="panel-head"><div class="panel-title">Maintenance Preflight</div></div><form id="preflight-form" class="form-row triple"><select id="preflight-action" class="input"><option value="general">Allgemein</option><option value="docker">Docker</option><option value="network">Netzwerk</option><option value="vm">VM</option></select><input id="preflight-target" class="input" placeholder="Zielhost (optional)"><button class="btn primary" type="submit">Prüfen</button></form><div id="preflight-result" class="result"><div class="empty">Noch kein Preflight ausgeführt.</div></div></article><article class="panel"><div class="panel-head"><div class="panel-title">Collector Drift</div><button id="drift-refresh" class="btn">Prüfen</button></div><div id="drift-result"><div class="empty">Drift wird geladen …</div></div></article></div>
</section>
<section id="view-audit" class="view">
<div class="hero"><div><div class="eyebrow">KI- und API-Protokoll</div><h2>Butler-Aktivitäten</h2><p>Wer hat wann welche Butler-Funktion aufgerufen? Sensible Werte bleiben redigiert.</p></div></div>
<article class="panel"><div class="panel-head"><div class="panel-title">Letzte Aktivitäten</div><div class="top-actions"><select id="audit-actor" class="input" aria-label="Akteur filtern" style="min-width:170px"><option value="all">Alle Akteure</option><option value="ai">Nur KI / API</option><option value="Weboberfläche">Nur Weboberfläche</option></select><button id="audit-refresh" class="btn">Aktualisieren</button></div></div><div class="table-wrap"><table><thead><tr><th>Zeit</th><th>Akteur</th><th>Methode</th><th>Endpoint</th><th>Status</th><th>Dry-run</th><th>Detail</th></tr></thead><tbody id="audit-body"><tr><td colspan="7">Wird geladen …</td></tr></tbody></table></div></article>
</section>
<section id="view-functions" class="view">
<div class="hero"><div><div class="eyebrow">Vollständiger Katalog</div><h2>Alle Butler-Funktionen</h2><p>Jede konkrete Operation aus OpenAPI sowie Proxy- und Metawege. Mutationen werden hier bewusst nicht blind ausgeführt.</p></div><div id="operation-total" class="updated">—</div></div>
<div class="toolbar"><input id="operation-search" class="input" type="search" placeholder="Pfad oder Funktion durchsuchen …"><div class="chips"><button class="chip active" data-filter="all">Alle</button><button class="chip" data-filter="read_only">Read-only</button><button class="chip" data-filter="mutation">Mutationen</button><button class="chip" data-filter="destructive">Destruktiv</button></div></div>
<div id="operations-grid" class="operations"></div>
</section>
</div>
</main>
</div>
<div id="toast" class="toast" role="status"></div>
<script>
(() => {
'use strict';
const state={capabilities:null,operations:[],audit:[],filter:'all'};
const $=s=>document.querySelector(s);
const $$=s=>[...document.querySelectorAll(s)];
const esc=v=>String(v??'').replace(/[&<>'"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;',"'":'&#39;','"':'&quot;'}[c]));
const cookie=name=>document.cookie.split('; ').find(x=>x.startsWith(name+'='))?.split('=').slice(1).join('=')||'';
const toast=msg=>{const el=$('#toast');el.textContent=msg;el.classList.add('show');setTimeout(()=>el.classList.remove('show'),2600)};
async function api(path,options={}){const method=(options.method||'GET').toUpperCase();const headers={...(options.headers||{})};if(!['GET','HEAD','OPTIONS'].includes(method))headers['X-CSRF-Token']=decodeURIComponent(cookie('butler_csrf'));if(options.body&&!headers['Content-Type'])headers['Content-Type']='application/json';const res=await fetch(path,{credentials:'same-origin',...options,headers});if(res.status===401){await fetch('/ui/session',{credentials:'same-origin'});location.reload();throw new Error('Read-only Sitzung wurde erneuert')}const text=await res.text();let data;try{data=JSON.parse(text)}catch{data=text}if(!res.ok)throw new Error(data?.detail||data?.error||`HTTP ${res.status}`);return data}
$$('.nav button[data-view]').forEach(btn=>btn.addEventListener('click',()=>switchView(btn.dataset.view)));
function switchView(name){$$('.view').forEach(v=>v.classList.toggle('active',v.id===`view-${name}`));$$('.nav button[data-view]').forEach(b=>b.classList.toggle('active',b.dataset.view===name));$('#view-title').textContent={overview:'Übersicht',doctor:'Doctor',sharing:'Emby Account Sharing',safety:'Wartung & Drift',audit:'Butler-Aktivitäten',functions:'Alle Funktionen'}[name];$('#sidebar').classList.remove('open')}
$('#menu').addEventListener('click',()=>$('#sidebar').classList.toggle('open'));
$('#refresh').addEventListener('click',refreshAll);$('#drift-refresh').addEventListener('click',loadDrift);$('#audit-refresh').addEventListener('click',loadAudit);$('#audit-actor').addEventListener('change',renderAudit);
async function refreshAll(){toast('Aktualisierung gestartet');await Promise.allSettled([loadOverview(),loadDrift(),loadAudit(),loadCapabilities()]);toast('Live-Daten aktualisiert')}
async function loadOverview(){try{const x=await api('/overview');const good=x.overall_state==='healthy';const box=$('#metric-state');box.className=`metric ${good?'good':x.overall_state==='warning'?'warn':'bad'}`;box.querySelector('.metric-value').textContent=x.overall_state;$('#metric-services').textContent=x.components.services.healthy;$('#metric-hosts').textContent=x.components.hosts.healthy;$('#metric-backups').textContent=x.components.backups.healthy;$('#finding-count').textContent=x.findings.length;$('#updated').textContent=new Date(x.generated).toLocaleString('de-DE');$('#findings').innerHTML=x.findings.length?x.findings.map(f=>`<div class="finding ${esc(f.severity)}"><span class="finding-dot"></span><div><strong>${esc(f.target)} · ${esc(f.code)}</strong><p>${esc(f.message)}</p></div><span class="badge">${esc(f.severity)}</span></div>`).join(''):'<div class="empty">Keine Findings. Alles sieht gut aus.</div>'}catch(e){$('#findings').innerHTML=`<div class="empty">${esc(e.message)}</div>`}}
async function loadCapabilities(){try{const x=await api('/capabilities');state.capabilities=x;state.operations=x.operations;$('#capability-summary').innerHTML=[['Operationen',x.counts.total],['Read-only',x.counts.read_only],['Mutationen',x.counts.mutations],['Destruktiv',x.counts.destructive]].map(([k,v])=>`<div class="layer"><h4>${k}</h4><div class="metric-value">${v}</div></div>`).join('');renderOperations()}catch(e){$('#operations-grid').innerHTML=`<div class="empty">${esc(e.message)}</div>`}}
function extraOperations(){return [{method:'PROXY',path:'/{service}/{path}',summary:'Generischer Service-Proxy',description:state.capabilities?.proxy?.note||'Backend-Proxy mit automatischer Authentifizierung',mode:'mutation',dry_run:false,critical:false,destructive:false},{method:'META',path:'/docs',summary:'Swagger UI',description:'Interaktive technische API-Dokumentation',mode:'read_only'},{method:'META',path:'/openapi.json',summary:'OpenAPI Schema',description:'Maschinenlesbare Schematruth',mode:'read_only'},{method:'META',path:'/health',summary:'Butler Health',description:'Öffentlicher Prozess- und Versionszustand',mode:'read_only'},{method:'META',path:'/',summary:'Self-Onboarding',description:'Service- und Endpoint-Übersicht',mode:'read_only'}]}
function renderOperations(){const q=$('#operation-search').value.trim().toLowerCase();const all=[...state.operations,...extraOperations()];const filtered=all.filter(o=>(state.filter==='all'||(state.filter==='destructive'?o.destructive:o.mode===state.filter))&&`${o.method} ${o.path} ${o.summary} ${o.description}`.toLowerCase().includes(q));$('#operation-total').textContent=`${filtered.length} von ${all.length} Punkten`;$('#operations-grid').innerHTML=filtered.map(o=>`<article class="operation"><div class="operation-top"><span class="badge ${esc(o.method.toLowerCase())}">${esc(o.method)}</span><div class="operation-badges">${o.critical?'<span class="badge">kritisch</span>':''}${o.destructive?'<span class="badge destructive">destruktiv</span>':''}</div></div><div class="operation-path">${esc(o.path)}</div><h3>${esc(o.summary||'Butler Operation')}</h3><p>${esc(o.description||'Keine zusätzliche Beschreibung im OpenAPI-Schema.')}</p><div class="operation-badges"><span class="badge ${esc(o.mode)}">${esc(o.mode)}</span>${o.dry_run?'<span class="badge">dry-run</span>':''}${o.confirmation_required?'<span class="badge">Bestätigung</span>':''}</div></article>`).join('')||'<div class="empty">Keine Funktion entspricht dem Filter.</div>'}
$('#operation-search').addEventListener('input',renderOperations);$$('.chip').forEach(c=>c.addEventListener('click',()=>{$$('.chip').forEach(x=>x.classList.remove('active'));c.classList.add('active');state.filter=c.dataset.filter;renderOperations()}));
$('#doctor-form').addEventListener('submit',async e=>{e.preventDefault();const target=$('#doctor-target').value.trim();const out=$('#doctor-result');out.innerHTML='<span class="spinner"></span>';try{const x=await api(`/doctor/${encodeURIComponent(target)}`);out.innerHTML=`<div class="safe-banner ${x.state==='critical'?'blocked':''}"><span class="safe-icon">${x.state==='healthy'?'✓':'!'}</span><div><strong>${esc(x.target)}: ${esc(x.state)}</strong><div class="panel-sub">${x.findings.length} Findings</div></div></div><div class="layer-grid" style="margin-top:10px">${Object.entries(x.layers).map(([k,v])=>`<div class="layer"><h4>${esc(k)}</h4><pre>${esc(JSON.stringify(v,null,2))}</pre></div>`).join('')}</div>`}catch(err){out.innerHTML=`<div class="empty">${esc(err.message)}</div>`}});
function sharingLocation(item){const l=item.location||{};return [l.city,l.region,l.country].filter(Boolean).map(esc).join(', ')||'Standort unbekannt'}
function renderSharing(x){const values=[x.summary.users_analyzed,x.summary.flagged_users,x.summary.concurrent_events,x.summary.impossible_travel_events],riskLabel={high:'hoch',medium:'mittel',none:'unauffällig'},flagged=x.users.filter(user=>user.risk!=='none');$$('#sharing-summary .metric-value').forEach((node,index)=>node.textContent=values[index]);$('#sharing-event-count').textContent=x.events.length;$('#sharing-events').innerHTML=x.events.length?x.events.map(event=>`<div class="finding ${event.severity==='high'?'critical':''}"><span class="finding-dot"></span><div><strong>${esc(event.username)} · ${event.type==='concurrent_networks'?'Zeitgleiche Netze':'Unplausibler Standortwechsel'}</strong><p>${esc(event.reason)}</p><p>${esc(new Date(event.start).toLocaleString('de-DE'))} – ${esc(new Date(event.end).toLocaleString('de-DE'))}${event.distance_km?` · ${esc(event.distance_km)} km · nötig: ${esc(event.required_speed_kmh)} km/h`:''}</p>${event.evidence.map(item=>`<div class="layer" style="margin-top:8px"><h4>${esc(item.server)} · ${sharingLocation(item)}</h4><div class="operation-path">IP ${esc(item.ip)}<br>Netz ${esc(item.network)}${item.version===6?`<br>IPv6-Elternblock ${esc(item.parent)}`:''}</div></div>`).join('')}</div><span class="badge ${event.severity==='high'?'destructive':''}">${esc(riskLabel[event.severity]||event.severity)}</span></div>`).join(''):'<div class="empty">Keine belastbaren Sharing-Hinweise im gewählten Zeitraum.</div>';$('#sharing-users').innerHTML=flagged.length?flagged.map(user=>`<div class="finding ${user.risk==='high'?'critical':''}"><span class="finding-dot" style="background:${user.risk==='medium'?'var(--yellow)':'var(--red)'}"></span><div><strong>${esc(user.username)}</strong><p>${esc(user.network_identities)} Netzidentitäten · ${esc(user.events)} Ereignisse · ${user.servers.map(esc).join(', ')}</p></div><span class="badge ${user.risk==='high'?'destructive':''}">${esc(riskLabel[user.risk]||user.risk)}</span></div>`).join(''):'<div class="empty">Keine Benutzer mit begründetem Sharing-Hinweis.</div>'}
$('#sharing-form').addEventListener('submit',async e=>{e.preventDefault();const q=new URLSearchParams({days:$('#sharing-days').value,server:$('#sharing-server').value});const username=$('#sharing-username').value.trim();if(username)q.set('username',username);$('#sharing-events').innerHTML='<div class="empty"><span class="spinner"></span> Sitzungsverlauf wird analysiert …</div>';$('#sharing-users').innerHTML='<div class="empty">Analyse läuft …</div>';try{renderSharing(await api(`/emby/account-sharing?${q}`))}catch(err){$('#sharing-events').innerHTML=`<div class="empty">${esc(err.message)}</div>`;$('#sharing-users').innerHTML='<div class="empty">Keine Auswertung verfügbar.</div>'}});
$('#preflight-form').addEventListener('submit',async e=>{e.preventDefault();const action=$('#preflight-action').value,target=$('#preflight-target').value.trim();const out=$('#preflight-result');out.innerHTML='<span class="spinner"></span>';try{const q=new URLSearchParams({action});if(target)q.set('target',target);const x=await api(`/maintenance/preflight?${q}`);out.innerHTML=`<div class="safe-banner ${x.safe?'':'blocked'}"><span class="safe-icon">${x.safe?'✓':'×'}</span><div><strong>${x.safe?'Wartung ist freigegeben':'Wartung ist blockiert'}</strong><div class="panel-sub">${x.blockers.length} Blocker · ${x.warnings.length} Warnungen</div></div></div>${x.blockers.concat(x.warnings).length?`<pre class="json" style="margin-top:12px">${esc(JSON.stringify({blockers:x.blockers,warnings:x.warnings},null,2))}</pre>`:''}`}catch(err){out.innerHTML=`<div class="empty">${esc(err.message)}</div>`}});
async function loadDrift(){const out=$('#drift-result');try{const x=await api('/drift');out.innerHTML=`<div class="safe-banner ${x.state==='healthy'?'':'blocked'}"><span class="safe-icon">${x.state==='healthy'?'✓':'!'}</span><div><strong>Drift: ${esc(x.state)}</strong><div class="panel-sub">${x.findings.length} Abdeckungslücken</div></div></div><div class="layer-grid" style="margin-top:10px">${Object.entries(x.coverage).map(([k,v])=>`<div class="layer"><h4>${esc(k)}</h4><div class="metric-value">${v}</div></div>`).join('')}</div>`}catch(e){out.innerHTML=`<div class="empty">${esc(e.message)}</div>`}}
function renderAudit(){const mode=$('#audit-actor').value;const rows=state.audit.filter(a=>mode==='all'||(mode==='ai'?a.actor!=='Weboberfläche'&&a.actor!=='System/Öffentlich':a.actor===mode));$('#audit-body').innerHTML=rows.length?rows.map(a=>`<tr><td>${esc(new Date(a.ts).toLocaleString('de-DE'))}</td><td><span class="badge">${esc(a.actor||'Legacy/API')}</span></td><td><span class="badge ${esc(a.method.toLowerCase())}">${esc(a.method)}</span></td><td class="mono">${esc(a.endpoint)}</td><td class="status-code ${a.status<400?'ok':'err'}">${esc(a.status)}</td><td>${a.dry_run?'ja':'nein'}</td><td>${esc(a.detail)}</td></tr>`).join(''):'<tr><td colspan="7">Keine Aktivitäten für diesen Filter.</td></tr>'}
async function loadAudit(){try{state.audit=await api('/audit?limit=100');renderAudit()}catch(e){$('#audit-body').innerHTML=`<tr><td colspan="7">${esc(e.message)}</td></tr>`}}
(async()=>{try{await fetch('/ui/session',{credentials:'same-origin'});await refreshAll()}catch{toast('Butler ist momentan nicht erreichbar')}})();
})();
</script>
</body>
</html>