Compare commits

..

4 commits

3 changed files with 428 additions and 36 deletions

280
app.py
View file

@ -1,7 +1,7 @@
"""Homelab Butler v2.1 – Unified API proxy for Pfannkuchen homelab.
Reads service config from butler.yaml, credentials from Vaultwarden cache with flat-file fallback."""
import os, json, asyncio, logging, time, base64, re, subprocess, ipaddress, secrets, sqlite3
import os, json, asyncio, logging, time, base64, re, subprocess, ipaddress, secrets, sqlite3, math
from datetime import datetime, timezone
import httpx, yaml
from typing import Literal
@ -246,6 +246,24 @@ def _ui_session(request: Request) -> dict | None:
return None
return session
def _issue_ui_session(read_only: bool) -> JSONResponse:
session_id = secrets.token_urlsafe(32)
csrf = secrets.token_urlsafe(24)
_ui_sessions[session_id] = {
"csrf": csrf,
"expires": time.time() + UI_SESSION_TTL,
"read_only": read_only,
}
response = JSONResponse({
"authenticated": True,
"read_only": read_only,
"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
def _verify(request: Request):
if not BUTLER_TOKEN:
return
@ -256,10 +274,253 @@ def _verify(request: Request):
if not session:
raise HTTPException(401, "Invalid token")
if request.method not in {"GET", "HEAD", "OPTIONS"}:
if session.get("read_only", False):
raise HTTPException(403, "Anonymous UI session is read-only")
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 _emby_network_identity(endpoint: str) -> dict:
"""Normalize an Emby endpoint without treating IPv6 privacy addresses as new households."""
raw = str(endpoint or "").strip()
if raw.startswith("[") and "]" in raw:
raw = raw[1:raw.index("]")]
try:
address = ipaddress.ip_address(raw)
except ValueError:
if raw.count(":") == 1:
raw = raw.rsplit(":", 1)[0]
try:
address = ipaddress.ip_address(raw)
except ValueError as exc:
raise ValueError("Invalid Emby remote endpoint") from exc
if address.version == 4:
network = ipaddress.ip_network(f"{address}/32", strict=False)
parent = network
else:
network = ipaddress.ip_network(f"{address}/64", strict=False)
parent = ipaddress.ip_network(f"{address}/48", strict=False)
return {
"ip": str(address),
"version": address.version,
"network": str(network),
"parent": str(parent),
"identity": str(parent if address.version == 6 else network),
"public": address.is_global,
}
def _emby_location(metric: dict) -> dict:
def coordinate(name):
try:
return float(metric.get(name, 0))
except (TypeError, ValueError):
return 0.0
return {
"city": metric.get("city", ""),
"region": metric.get("region", ""),
"country": metric.get("countryCode", ""),
"latitude": coordinate("latitude"),
"longitude": coordinate("longitude"),
}
def _analyze_emby_sharing(series: list[dict], step_seconds: int) -> dict:
observations: dict[str, dict[int, dict[str, dict]]] = {}
tracks: dict[str, dict[str, dict]] = {}
identities_by_user: dict[str, set[str]] = {}
servers_by_user: dict[str, set[str]] = {}
for item in series:
metric = item.get("metric", {})
username = str(metric.get("username", "")).strip()
if not username:
continue
try:
network = _emby_network_identity(metric.get("remoteEndPoint", ""))
except ValueError:
continue
if not network["public"]:
continue
evidence = {
**network,
"server": metric.get("job", ""),
"location": _emby_location(metric),
}
identities_by_user.setdefault(username, set()).add(network["identity"])
servers_by_user.setdefault(username, set()).add(str(metric.get("job", "")))
track = tracks.setdefault(username, {}).setdefault(network["identity"], {"evidence": evidence, "timestamps": []})
for value in item.get("values", []):
if not isinstance(value, list) or len(value) < 2 or str(value[1]).lower() in {"0", "nan"}:
continue
timestamp = int(float(value[0]))
track["timestamps"].append(timestamp)
observations.setdefault(username, {}).setdefault(timestamp, {}).setdefault(network["identity"], evidence)
raw_events = []
for username, timeline in observations.items():
buckets = []
for timestamp in sorted(timeline):
evidence = timeline[timestamp]
if len(evidence) >= 2:
buckets.append((timestamp, tuple(sorted(evidence)), evidence))
current = None
for timestamp, identity_key, evidence in buckets:
if current and current["identity_key"] == identity_key and timestamp - current["end_ts"] <= step_seconds * 2:
current["end_ts"] = timestamp
current["samples"] += 1
continue
if current and current["samples"] >= 2 and current["end_ts"] - current["start_ts"] + step_seconds >= 360:
raw_events.append(current)
current = {
"username": username, "identity_key": identity_key, "start_ts": timestamp,
"end_ts": timestamp, "samples": 1, "evidence": list(evidence.values()),
}
if current and current["samples"] >= 2 and current["end_ts"] - current["start_ts"] + step_seconds >= 360:
raw_events.append(current)
events = [{
"type": "concurrent_networks",
"severity": "high",
"username": item["username"],
"start": datetime.fromtimestamp(item["start_ts"], timezone.utc).isoformat(),
"end": datetime.fromtimestamp(item["end_ts"], timezone.utc).isoformat(),
"duration_seconds": item["end_ts"] - item["start_ts"] + step_seconds,
"samples": item["samples"],
"evidence": item["evidence"],
"reason": "Zeitgleiche Nutzung desselben Emby-Benutzers aus unterschiedlichen öffentlichen Netzen",
} for item in raw_events]
def distance_km(first: dict, second: dict) -> float:
lat1, lon1 = first["latitude"], first["longitude"]
lat2, lon2 = second["latitude"], second["longitude"]
if not all((-90 <= lat <= 90 and -180 <= lon <= 180) for lat, lon in ((lat1, lon1), (lat2, lon2))):
return 0.0
phi1, phi2 = math.radians(lat1), math.radians(lat2)
dphi, dlambda = math.radians(lat2 - lat1), math.radians(lon2 - lon1)
value = math.sin(dphi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(dlambda / 2) ** 2
return 6371.0 * 2 * math.atan2(math.sqrt(value), math.sqrt(max(0.0, 1 - value)))
travel_events = []
for username, user_tracks in tracks.items():
intervals = []
for identity, track in user_tracks.items():
current = None
for timestamp in sorted(set(track["timestamps"])):
if current and timestamp - current["end"] <= step_seconds * 2:
current["end"] = timestamp
current["samples"] += 1
else:
if current and current["samples"] >= 2:
intervals.append(current)
current = {"identity": identity, "start": timestamp, "end": timestamp, "samples": 1, "evidence": track["evidence"]}
if current and current["samples"] >= 2:
intervals.append(current)
intervals.sort(key=lambda item: item["start"])
for previous, current in zip(intervals, intervals[1:]):
if previous["identity"] == current["identity"] or current["start"] <= previous["end"]:
continue
distance = distance_km(previous["evidence"]["location"], current["evidence"]["location"])
gap_hours = max((current["start"] - previous["end"]) / 3600, 1 / 60)
speed = distance / gap_hours
if distance < 300 or speed <= 1000:
continue
travel_events.append({
"type": "impossible_travel", "severity": "medium", "username": username,
"start": datetime.fromtimestamp(previous["end"], timezone.utc).isoformat(),
"end": datetime.fromtimestamp(current["start"], timezone.utc).isoformat(),
"duration_seconds": current["start"] - previous["end"],
"samples": previous["samples"] + current["samples"],
"distance_km": round(distance, 1), "required_speed_kmh": round(speed, 1),
"evidence": [previous["evidence"], current["evidence"]],
"reason": "Geografischer Wechsel zwischen öffentlichen Netzen wäre in der verfügbaren Zeit nicht plausibel",
})
events.extend(travel_events)
events.sort(key=lambda item: item["start"], reverse=True)
flagged = {item["username"] for item in events}
users = [{
"username": username,
"risk": "high" if any(item["username"] == username and item["type"] == "concurrent_networks" for item in events) else ("medium" if username in flagged else "none"),
"events": sum(item["username"] == username for item in events),
"network_identities": len(identities_by_user.get(username, set())),
"servers": sorted(servers_by_user.get(username, set())),
} for username in sorted(observations)]
return {
"summary": {
"users_analyzed": len(observations),
"flagged_users": len(flagged),
"concurrent_events": len(raw_events),
"impossible_travel_events": len(travel_events),
},
"users": users,
"events": events,
}
async def _fetch_emby_session_history(days: int, server: str) -> tuple[list[dict], int]:
cfg = SERVICES.get("grafana")
if not cfg:
raise HTTPException(503, "Grafana service is not configured")
request_data = _service_auth(cfg)
datasource_uid = os.environ.get("EMBY_PROMETHEUS_UID", "bdpu4276997nkc")
labels = "job,username,remoteEndPoint,city,region,countryCode,latitude,longitude"
selector = 'emby_sessions{username!=""}'
if server != "all":
selector = f'emby_sessions{{username!="",job="{server}"}}'
query = f"max by ({labels}) ({selector})"
end = int(time.time())
start = end - days * 86400
step = max(60, math.ceil(((end - start) / 30000) / 60) * 60)
url = f"{request_data['base_url'].rstrip('/')}/api/datasources/proxy/uid/{datasource_uid}/api/v1/query_range"
try:
async with httpx.AsyncClient(timeout=90) as client:
response = await client.get(
url,
params={"query": query, "start": start, "end": end, "step": step},
headers=request_data["headers"], cookies=request_data["cookies"],
)
response.raise_for_status()
payload = response.json()
except (httpx.HTTPError, ValueError) as exc:
log.warning("Emby sharing history query failed: %s", type(exc).__name__)
raise HTTPException(502, "Emby session history is temporarily unavailable")
if payload.get("status") != "success":
raise HTTPException(502, "Prometheus rejected the Emby session history query")
return payload.get("data", {}).get("result", []), step
@app.get("/emby/account-sharing")
async def emby_account_sharing(
days: int = Query(30, ge=1, le=90),
username: str | None = Query(None, min_length=1, max_length=100),
server: Literal["all", "emby-sascha", "emby-chris"] = "all",
_=Depends(_verify),
):
"""Conservative read-only analysis of concurrent networks and geographically impossible changes."""
series, step = await _fetch_emby_session_history(days, server)
if username:
wanted = username.casefold()
series = [item for item in series if str(item.get("metric", {}).get("username", "")).casefold() == wanted]
result = _analyze_emby_sharing(series, step)
return {
"generated": datetime.now(timezone.utc).isoformat(),
"period": {"days": days, "server": server, "step_seconds": step, "series": len(series)},
"policy": {
"mode": "conservative",
"ipv4_detection_identity": "/32",
"ipv6_display_network": "/64",
"ipv6_detection_identity": "/48",
"minimum_samples": 2,
"minimum_concurrent_seconds": 360,
"prometheus_staleness_guard": True,
"impossible_travel_minimum_km": 300,
"impossible_travel_speed_kmh": 1000,
"private_networks_excluded": True,
"automatic_enforcement": False,
},
**result,
}
def _get_key(cfg):
vault_key = cfg.get("vault_key")
if vault_key and vault_key in _vault_cache:
@ -328,18 +589,19 @@ async def ui():
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
return _issue_ui_session(read_only=False)
@app.get("/ui/session")
async def ui_session(request: Request):
return {"authenticated": _ui_session(request) is not None}
session = _ui_session(request)
if session:
return {
"authenticated": True,
"read_only": session.get("read_only", False),
"expires_in": max(0, int(session["expires"] - time.time())),
}
return _issue_ui_session(read_only=True)
@app.post("/ui/logout")

View file

@ -54,6 +54,11 @@ def test_ui_serves_self_contained_operator_console():
assert 'id="operations-grid"' in response.text
assert 'id="doctor-form"' in response.text
assert 'id="preflight-form"' in response.text
assert 'id="sharing-form"' in response.text
assert 'id="sharing-events"' in response.text
assert "/emby/account-sharing" in response.text
assert 'id="login"' not in response.text
assert "Butler-Token" not in response.text
assert "localStorage" not in response.text
@ -86,6 +91,131 @@ def test_ui_session_login_uses_httponly_cookie_and_csrf():
assert allowed.status_code == 200
def test_ui_anonymous_session_is_automatic_and_strictly_read_only():
app._ui_sessions.clear()
with TestClient(app.app) as client:
session = client.get("/ui/session")
assert session.status_code == 200
assert session.json()["authenticated"] is True
assert session.json()["read_only"] is True
assert "HttpOnly" in session.headers.get("set-cookie", "")
capabilities = client.get("/capabilities")
assert capabilities.status_code == 200
csrf = client.cookies.get("butler_csrf")
mutation = client.post("/config/reload", headers={"X-CSRF-Token": csrf})
assert mutation.status_code == 403
assert "read-only" in mutation.json()["detail"].lower()
def test_emby_network_identity_normalizes_ipv4_and_ipv6_privacy_addresses():
ipv4 = app._emby_network_identity("203.0.113.9:443")
assert ipv4["ip"] == "203.0.113.9"
assert ipv4["network"] == "203.0.113.9/32"
assert ipv4["identity"] == "203.0.113.9/32"
first = app._emby_network_identity("2003:abcd:1234:5678::1")
privacy_peer = app._emby_network_identity("[2003:abcd:1234:5678:ffff::99]:443")
sibling_subnet = app._emby_network_identity("2003:abcd:1234:9999::1")
assert first["network"] == privacy_peer["network"] == "2003:abcd:1234:5678::/64"
assert first["parent"] == sibling_subnet["parent"] == "2003:abcd:1234::/48"
assert first["identity"] == sibling_subnet["identity"] == "2003:abcd:1234::/48"
def test_emby_sharing_flags_concurrent_distinct_networks_but_not_sibling_ipv6_subnets():
def series(endpoint, values, city):
return {
"metric": {
"job": "emby-sascha", "username": "Alice", "remoteEndPoint": endpoint,
"city": city, "region": "Test", "countryCode": "DE",
"latitude": "48.1", "longitude": "11.5",
},
"values": [[timestamp, "1"] for timestamp in values],
}
payload = [
series("2606:4700:1234:1000::1", [100, 160, 220, 280, 340, 400], "Home"),
series("2606:4700:1234:2000::2", [100, 160, 220, 280, 340, 400], "Home privacy subnet"),
series("2001:4860:9999:1000::1", [100, 160, 220, 280, 340, 400], "Away"),
]
result = app._analyze_emby_sharing(payload, step_seconds=60)
assert result["summary"]["concurrent_events"] == 1
event = result["events"][0]
assert event["type"] == "concurrent_networks"
assert event["username"] == "Alice"
assert len(event["evidence"]) == 2
assert {item["identity"] for item in event["evidence"]} == {
"2606:4700:1234::/48", "2001:4860:9999::/48"
}
def test_emby_sharing_ignores_short_overlap_inside_prometheus_staleness_window():
def series(endpoint):
return {"metric": {"job": "emby-sascha", "username": "Alice", "remoteEndPoint": endpoint,
"city": "Munich", "region": "Bavaria", "countryCode": "DE",
"latitude": "48.1", "longitude": "11.5"},
"values": [[100, "1"], [160, "1"]]}
result = app._analyze_emby_sharing([series("8.8.8.8"), series("1.1.1.1")], step_seconds=60)
assert result["summary"]["concurrent_events"] == 0
def test_emby_sharing_flags_geographically_impossible_network_change():
payload = [
{
"metric": {"job": "emby-chris", "username": "Bob", "remoteEndPoint": "8.8.8.8",
"city": "Berlin", "region": "Berlin", "countryCode": "DE",
"latitude": "52.5200", "longitude": "13.4050"},
"values": [[100, "1"], [160, "1"]],
},
{
"metric": {"job": "emby-chris", "username": "Bob", "remoteEndPoint": "1.1.1.1",
"city": "New York", "region": "New York", "countryCode": "US",
"latitude": "40.7128", "longitude": "-74.0060"},
"values": [[400, "1"], [460, "1"]],
},
]
result = app._analyze_emby_sharing(payload, step_seconds=60)
assert result["summary"]["concurrent_events"] == 0
assert result["summary"]["impossible_travel_events"] == 1
event = result["events"][0]
assert event["type"] == "impossible_travel"
assert event["distance_km"] > 6000
assert event["required_speed_kmh"] > 1000
def test_emby_account_sharing_endpoint_is_read_only_and_filterable(monkeypatch):
async def history(days, server):
assert days == 7
assert server == "all"
return ([{
"metric": {"job": "emby-sascha", "username": "Alice", "remoteEndPoint": "8.8.8.8",
"city": "Munich", "region": "Bavaria", "countryCode": "DE",
"latitude": "48.1", "longitude": "11.5"},
"values": [[100, "1"], [160, "1"]],
}], 60)
monkeypatch.setattr(app, "_fetch_emby_session_history", history)
with TestClient(app.app) as client:
response = client.get(
"/emby/account-sharing?days=7&username=alice",
headers={"Authorization": "Bearer test-token"},
)
assert response.status_code == 200
payload = response.json()
assert payload["policy"]["mode"] == "conservative"
assert payload["policy"]["ipv6_detection_identity"] == "/48"
assert payload["summary"]["users_analyzed"] == 1
assert payload["users"][0]["username"] == "Alice"
def test_capabilities_is_live_machine_readable_safety_map():
with TestClient(app.app) as client:
response = client.get(

54
ui.html
View file

@ -11,47 +11,33 @@
.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}
.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{grid-template-columns:1fr}.btn,.input{min-height:44px}.top-actions .desktop-only{display:none}}
@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="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">
<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>Session aktiv</span><span class="dot"></span></div></div>
<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><button id="logout" class="btn danger">Abmelden</button></div></header>
<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>
@ -69,6 +55,21 @@
<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>
@ -98,13 +99,9 @@
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()}});
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',safety:'Wartung & Drift',audit:'Audit',functions:'Alle Funktionen'}[name];$('#sidebar').classList.remove('open')}
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:'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')}
@ -114,10 +111,13 @@
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>`}}
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()}})();
(async()=>{try{await fetch('/ui/session',{credentials:'same-origin'});await refreshAll()}catch{toast('Butler ist momentan nicht erreichbar')}})();
})();
</script>
</body>