homelab-butler/ui.html

124 lines
27 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>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}.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{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="login" class="login">
<section class="login-card" aria-labelledby="login-title">
<div class="logo" aria-hidden="true">🍳</div>
<div class="eyebrow">Homelab Control Plane</div>
<h1 id="login-title">Pfannkuchen Butler</h1>
<p>Geschützte Operations- und Diagnosekonsole. Der Token bleibt nur für den Login im Arbeitsspeicher und wird danach durch eine HttpOnly-Sitzung ersetzt.</p>
<form id="login-form">
<label for="token" class="panel-sub">Butler-Token</label>
<input id="token" class="input" type="password" autocomplete="current-password" required autofocus>
<button class="btn primary" type="submit">Sicher anmelden</button>
<div id="login-error" class="error" role="alert"></div>
</form>
</section>
</div>
<div id="app" class="shell hidden">
<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="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>Session aktiv</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><button id="logout" class="btn danger">Abmelden</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-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">Persistente Historie</div><h2>Audit</h2><p>Redigierte Butler-Aktionen aus SQLite.</p></div></div>
<article class="panel"><div class="panel-head"><div class="panel-title">Letzte Aktionen</div><button id="audit-refresh" class="btn">Aktualisieren</button></div><div class="table-wrap"><table><thead><tr><th>Zeit</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="6">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:[],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){showLogin();throw new Error('Sitzung abgelaufen')}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}
function showLogin(){ $('#login').classList.remove('hidden');$('#app').classList.add('hidden') }
function showApp(){ $('#login').classList.add('hidden');$('#app').classList.remove('hidden') }
$('#login-form').addEventListener('submit',async e=>{e.preventDefault();const error=$('#login-error');error.textContent='';const token=$('#token').value;try{const res=await fetch('/ui/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token}),credentials:'same-origin'});if(!res.ok)throw new Error('Token wurde abgelehnt');$('#token').value='';showApp();await refreshAll()}catch(err){error.textContent=err.message}});
$('#logout').addEventListener('click',async()=>{try{await api('/ui/logout',{method:'POST'})}finally{showLogin()}});
$$('.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',safety:'Wartung & Drift',audit:'Audit',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);
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>`}});
$('#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>`}}
async function loadAudit(){try{const x=await api('/audit?limit=50');$('#audit-body').innerHTML=x.length?x.map(a=>`<tr><td>${esc(new Date(a.ts).toLocaleString('de-DE'))}</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="6">Noch keine Audit-Einträge.</td></tr>'}catch(e){$('#audit-body').innerHTML=`<tr><td colspan="6">${esc(e.message)}</td></tr>`}}
(async()=>{try{const s=await fetch('/ui/session',{credentials:'same-origin'}).then(r=>r.json());if(s.authenticated){showApp();await refreshAll()}else showLogin()}catch{showLogin()}})();
})();
</script>
</body>
</html>