Butler Operator-Weboberfläche #42

Merged
sascha merged 4 commits from feature/butler-operator-ui into main 2026-08-16 20:56:41 +02:00
4 changed files with 234 additions and 2 deletions

70
app.py
View file

@ -7,7 +7,7 @@ import httpx, yaml
from typing import Literal
from pydantic import BaseModel, Field
from fastapi import FastAPI, Request, HTTPException, Depends, Query
from fastapi.responses import JSONResponse, RedirectResponse, Response
from fastapi.responses import JSONResponse, RedirectResponse, Response, HTMLResponse
from contextlib import asynccontextmanager
log = logging.getLogger("butler")
@ -17,6 +17,7 @@ API_DIR = os.environ.get("API_KEY_DIR", "/data/api")
VAULT_CACHE_DIR = os.environ.get("VAULT_CACHE_DIR", "/data/vault-cache")
BUTLER_TOKEN = os.environ.get("BUTLER_TOKEN", "")
CONFIG_PATH = os.environ.get("BUTLER_CONFIG", "/data/butler.yaml")
UI_PATH = os.environ.get("BUTLER_UI_PATH", os.path.join(os.path.dirname(__file__), "ui.html"))
# --- Config loading ---
@ -227,12 +228,37 @@ async def _dockhand_login(client):
# --- Auth ---
_ui_sessions: dict[str, dict] = {}
UI_SESSION_TTL = 8 * 60 * 60
class UiLoginRequest(BaseModel):
token: str
def _ui_session(request: Request) -> dict | None:
session_id = request.cookies.get("butler_session", "")
session = _ui_sessions.get(session_id)
if not session:
return None
if session["expires"] <= time.time():
_ui_sessions.pop(session_id, None)
return None
return session
def _verify(request: Request):
if not BUTLER_TOKEN:
return
auth = request.headers.get("authorization", "")
if auth != f"Bearer {BUTLER_TOKEN}":
if secrets.compare_digest(auth, f"Bearer {BUTLER_TOKEN}"):
return
session = _ui_session(request)
if not session:
raise HTTPException(401, "Invalid token")
if request.method not in {"GET", "HEAD", "OPTIONS"}:
csrf = request.headers.get("x-csrf-token", "")
if not csrf or not secrets.compare_digest(csrf, session["csrf"]):
raise HTTPException(403, "Invalid CSRF token")
def _get_key(cfg):
vault_key = cfg.get("vault_key")
@ -290,6 +316,45 @@ def _inventory_hosts(text: str) -> list[dict]:
# --- Routes ---
@app.get("/ui", response_class=HTMLResponse)
async def ui():
try:
return HTMLResponse(open(UI_PATH, encoding="utf-8").read())
except FileNotFoundError:
raise HTTPException(503, "Butler UI asset is missing")
@app.post("/ui/login")
async def ui_login(payload: UiLoginRequest):
if not BUTLER_TOKEN or not secrets.compare_digest(payload.token, BUTLER_TOKEN):
raise HTTPException(401, "Invalid token")
session_id = secrets.token_urlsafe(32)
csrf = secrets.token_urlsafe(24)
_ui_sessions[session_id] = {"csrf": csrf, "expires": time.time() + UI_SESSION_TTL}
response = JSONResponse({"authenticated": True, "expires_in": UI_SESSION_TTL})
response.set_cookie("butler_session", session_id, max_age=UI_SESSION_TTL, httponly=True, samesite="strict", path="/")
response.set_cookie("butler_csrf", csrf, max_age=UI_SESSION_TTL, httponly=False, samesite="strict", path="/")
return response
@app.get("/ui/session")
async def ui_session(request: Request):
return {"authenticated": _ui_session(request) is not None}
@app.post("/ui/logout")
async def ui_logout(request: Request):
session = _ui_session(request)
if session:
csrf = request.headers.get("x-csrf-token", "")
if not csrf or not secrets.compare_digest(csrf, session["csrf"]):
raise HTTPException(403, "Invalid CSRF token")
_ui_sessions.pop(request.cookies.get("butler_session", ""), None)
response = JSONResponse({"authenticated": False})
response.delete_cookie("butler_session", path="/")
response.delete_cookie("butler_csrf", path="/")
return response
@app.get("/")
async def root():
"""AI self-onboarding: returns all available endpoints and services."""
@ -536,6 +601,7 @@ async def info(_=Depends(_verify)):
},
"endpoints": {
"capabilities": "/capabilities",
"ui": "/ui",
"doctor": "/doctor/{target}",
"drift": "/drift",
"maintenance_preflight": "/maintenance/preflight",

View file

@ -11,6 +11,7 @@ services:
- /home/sascha/.ssh:/root/.ssh:ro
- ./butler.yaml:/data/butler.yaml:ro
- ./app.py:/app/app.py:ro
- ./ui.html:/app/ui.html:ro
- ./state:/data/state
environment:
- API_KEY_DIR=/data/api

View file

@ -46,6 +46,46 @@ def test_health_exposes_current_version():
assert response.json()["version"] == app.VERSION == "2.3.5"
def test_ui_serves_self_contained_operator_console():
with TestClient(app.app) as client:
response = client.get("/ui")
assert response.status_code == 200
assert "Pfannkuchen Butler" in response.text
assert 'id="operations-grid"' in response.text
assert 'id="doctor-form"' in response.text
assert 'id="preflight-form"' in response.text
assert "localStorage" not in response.text
def test_ui_asset_is_mounted_read_only_in_compose():
from pathlib import Path
import yaml
compose = yaml.safe_load(Path(app.__file__).with_name("compose.yaml").read_text(encoding="utf-8"))
mounts = compose["services"]["homelab-butler"]["volumes"]
assert "./ui.html:/app/ui.html:ro" in mounts
def test_ui_session_login_uses_httponly_cookie_and_csrf():
app._ui_sessions.clear()
with TestClient(app.app) as client:
denied = client.post("/ui/login", json={"token": "wrong"})
assert denied.status_code == 401
login = client.post("/ui/login", json={"token": "test-token"})
assert login.status_code == 200
assert "HttpOnly" in login.headers.get("set-cookie", "")
csrf = client.cookies.get("butler_csrf")
assert csrf
capabilities = client.get("/capabilities")
assert capabilities.status_code == 200
blocked = client.post("/config/reload")
assert blocked.status_code == 403
allowed = client.post("/config/reload", headers={"X-CSRF-Token": csrf})
assert allowed.status_code == 200
def test_capabilities_is_live_machine_readable_safety_map():
with TestClient(app.app) as client:
response = client.get(
@ -74,6 +114,7 @@ def test_info_advertises_capabilities_endpoint():
assert response.json()["endpoints"]["doctor"] == "/doctor/{target}"
assert response.json()["endpoints"]["drift"] == "/drift"
assert response.json()["endpoints"]["maintenance_preflight"] == "/maintenance/preflight"
assert response.json()["endpoints"]["ui"] == "/ui"
def test_audit_persists_and_redacts_secrets(tmp_path, monkeypatch):

124
ui.html Normal file
View file

@ -0,0 +1,124 @@
<!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>