Compare commits
No commits in common. "815bcd94cf3895e2e4ebaa55164c0f578bb01e1f" and "63978bff1849c3d0af0c169e623b5d87cb6dd8c5" have entirely different histories.
815bcd94cf
...
63978bff18
3 changed files with 36 additions and 428 deletions
280
app.py
280
app.py
|
|
@ -1,7 +1,7 @@
|
||||||
"""Homelab Butler v2.1 – Unified API proxy for Pfannkuchen homelab.
|
"""Homelab Butler v2.1 – Unified API proxy for Pfannkuchen homelab.
|
||||||
Reads service config from butler.yaml, credentials from Vaultwarden cache with flat-file fallback."""
|
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, math
|
import os, json, asyncio, logging, time, base64, re, subprocess, ipaddress, secrets, sqlite3
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
import httpx, yaml
|
import httpx, yaml
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
@ -246,24 +246,6 @@ def _ui_session(request: Request) -> dict | None:
|
||||||
return None
|
return None
|
||||||
return session
|
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):
|
def _verify(request: Request):
|
||||||
if not BUTLER_TOKEN:
|
if not BUTLER_TOKEN:
|
||||||
return
|
return
|
||||||
|
|
@ -274,253 +256,10 @@ def _verify(request: Request):
|
||||||
if not session:
|
if not session:
|
||||||
raise HTTPException(401, "Invalid token")
|
raise HTTPException(401, "Invalid token")
|
||||||
if request.method not in {"GET", "HEAD", "OPTIONS"}:
|
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", "")
|
csrf = request.headers.get("x-csrf-token", "")
|
||||||
if not csrf or not secrets.compare_digest(csrf, session["csrf"]):
|
if not csrf or not secrets.compare_digest(csrf, session["csrf"]):
|
||||||
raise HTTPException(403, "Invalid CSRF token")
|
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):
|
def _get_key(cfg):
|
||||||
vault_key = cfg.get("vault_key")
|
vault_key = cfg.get("vault_key")
|
||||||
if vault_key and vault_key in _vault_cache:
|
if vault_key and vault_key in _vault_cache:
|
||||||
|
|
@ -589,19 +328,18 @@ async def ui():
|
||||||
async def ui_login(payload: UiLoginRequest):
|
async def ui_login(payload: UiLoginRequest):
|
||||||
if not BUTLER_TOKEN or not secrets.compare_digest(payload.token, BUTLER_TOKEN):
|
if not BUTLER_TOKEN or not secrets.compare_digest(payload.token, BUTLER_TOKEN):
|
||||||
raise HTTPException(401, "Invalid token")
|
raise HTTPException(401, "Invalid token")
|
||||||
return _issue_ui_session(read_only=False)
|
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")
|
@app.get("/ui/session")
|
||||||
async def ui_session(request: Request):
|
async def ui_session(request: Request):
|
||||||
session = _ui_session(request)
|
return {"authenticated": _ui_session(request) is not None}
|
||||||
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")
|
@app.post("/ui/logout")
|
||||||
|
|
|
||||||
|
|
@ -54,11 +54,6 @@ def test_ui_serves_self_contained_operator_console():
|
||||||
assert 'id="operations-grid"' in response.text
|
assert 'id="operations-grid"' in response.text
|
||||||
assert 'id="doctor-form"' in response.text
|
assert 'id="doctor-form"' in response.text
|
||||||
assert 'id="preflight-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
|
assert "localStorage" not in response.text
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -91,131 +86,6 @@ def test_ui_session_login_uses_httponly_cookie_and_csrf():
|
||||||
assert allowed.status_code == 200
|
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():
|
def test_capabilities_is_live_machine_readable_safety_map():
|
||||||
with TestClient(app.app) as client:
|
with TestClient(app.app) as client:
|
||||||
response = client.get(
|
response = client.get(
|
||||||
|
|
|
||||||
54
ui.html
54
ui.html
|
|
@ -11,33 +11,47 @@
|
||||||
.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)}
|
.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)}
|
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)}
|
.metrics{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px;margin-bottom:18px}.metric,.panel,.operation{border:1px solid var(--border);background:rgba(255,255,255,.025);border-radius:var(--radius)}.metric{padding:17px}.metric-label{font-size:11px;color:var(--muted);margin-bottom:12px}.metric-value{font-size:25px;letter-spacing:-.45px;font-weight:510}.metric-meta{font-size:11px;color:var(--dim);margin-top:7px}.metric.good .metric-value{color:#a7f3d0}.metric.warn .metric-value{color:#fcd34d}.metric.bad .metric-value{color:#fca5a5}.grid-2{display:grid;grid-template-columns:minmax(0,1.4fr) minmax(300px,.6fr);gap:14px}.panel{padding:18px;min-width:0}.panel-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:15px}.panel-title{font-size:13px;font-weight:590}.panel-sub{font-size:11px;color:var(--dim)}.empty{border:1px dashed var(--border);border-radius:8px;color:var(--dim);padding:26px;text-align:center;font-size:12px}.finding{display:grid;grid-template-columns:9px 1fr auto;gap:10px;align-items:start;padding:11px 0;border-bottom:1px solid var(--border2)}.finding:last-child{border-bottom:0}.finding-dot{width:7px;height:7px;border-radius:50%;background:var(--yellow);margin-top:5px}.finding.critical .finding-dot{background:var(--red)}.finding strong{font-size:12px}.finding p{font-size:11px;color:var(--muted);margin:4px 0 0}.badge{display:inline-flex;align-items:center;border:1px solid var(--border);border-radius:999px;padding:3px 7px;font:10px ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--muted);white-space:nowrap}.badge.get,.badge.read_only{color:#a7f3d0;border-color:rgba(16,185,129,.25);background:rgba(16,185,129,.06)}.badge.post,.badge.put,.badge.patch{color:#c4b5fd;border-color:rgba(113,112,255,.3);background:rgba(113,112,255,.07)}.badge.delete,.badge.destructive{color:#fca5a5;border-color:rgba(239,68,68,.25);background:rgba(239,68,68,.06)}
|
||||||
.form-row{display:grid;grid-template-columns:1fr auto;gap:9px}.form-row.triple{grid-template-columns:160px 1fr auto}.sharing-form{grid-template-columns:140px 170px minmax(180px,1fr) auto}.input{width:100%;border:1px solid var(--border);background:rgba(255,255,255,.025);color:var(--text);border-radius:7px;padding:10px 12px;outline:0;font-size:13px}.input:focus{border-color:rgba(113,112,255,.6);box-shadow:0 0 0 3px rgba(113,112,255,.1)}select.input{appearance:none}.result{margin-top:14px;min-height:110px}.layer-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:9px}.layer{border:1px solid var(--border2);background:rgba(255,255,255,.02);border-radius:8px;padding:12px}.layer h4{font-size:11px;margin:0 0 8px;text-transform:uppercase;color:var(--muted);letter-spacing:.07em}.layer pre,.json{white-space:pre-wrap;word-break:break-word;margin:0;color:var(--secondary);font:11px/1.55 ui-monospace,SFMono-Regular,Menlo,monospace}.safe-banner{display:flex;align-items:center;gap:12px;border-radius:9px;padding:14px;border:1px solid rgba(16,185,129,.25);background:rgba(16,185,129,.06)}.safe-banner.blocked{border-color:rgba(239,68,68,.25);background:rgba(239,68,68,.06)}.safe-icon{font-size:21px}
|
.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}
|
.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)}}
|
.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:1050px){.operations{grid-template-columns:repeat(2,minmax(0,1fr))}.metrics{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-2{grid-template-columns:1fr}}
|
||||||
@media(max-width:760px){.shell{display:block}.sidebar{position:fixed;transform:translateX(-100%);transition:.2s;width:260px}.sidebar.open{transform:none}.mobile-menu{display:inline-flex}.topbar{padding:0 15px}.content{padding:20px 14px}.hero{align-items:flex-start;flex-direction:column}.hero h2{font-size:25px}.operations{grid-template-columns:1fr}.metrics{grid-template-columns:repeat(2,1fr)}.form-row,.form-row.triple,.sharing-form{grid-template-columns:1fr}.btn,.input{min-height:44px}.top-actions .desktop-only{display:none}}
|
@media(max-width: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}}
|
@media(max-width:420px){.metrics{grid-template-columns:1fr}.content{padding:18px 10px}.panel{padding:14px}.metric{padding:14px}}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app" class="shell">
|
<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">
|
<aside id="sidebar" class="sidebar">
|
||||||
<div class="brand"><div class="logo">🍳</div><div><strong>Butler</strong><small>Pfannkuchen Operations</small></div></div>
|
<div class="brand"><div class="logo">🍳</div><div><strong>Butler</strong><small>Pfannkuchen Operations</small></div></div>
|
||||||
<nav class="nav" aria-label="Hauptnavigation">
|
<nav class="nav" aria-label="Hauptnavigation">
|
||||||
<div class="nav-label">Betrieb</div>
|
<div class="nav-label">Betrieb</div>
|
||||||
<button class="active" data-view="overview"><span class="icon">◫</span>Übersicht</button>
|
<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="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="safety"><span class="icon">◇</span>Wartung & Drift</button>
|
||||||
<button data-view="audit"><span class="icon">≡</span>Audit</button>
|
<button data-view="audit"><span class="icon">≡</span>Audit</button>
|
||||||
<div class="nav-label">API</div>
|
<div class="nav-label">API</div>
|
||||||
<button data-view="functions"><span class="icon">⌘</span>Alle Funktionen</button>
|
<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>
|
<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>
|
</nav>
|
||||||
<div class="sidebar-foot"><div class="session-pill"><span>Read-only Sitzung</span><span class="dot"></span></div></div>
|
<div class="sidebar-foot"><div class="session-pill"><span>Session aktiv</span><span class="dot"></span></div></div>
|
||||||
</aside>
|
</aside>
|
||||||
<main>
|
<main>
|
||||||
<header class="topbar"><div style="display:flex;align-items:center;gap:9px"><button id="menu" class="btn mobile-menu" aria-label="Menü öffnen">☰</button><h1 id="view-title">Übersicht</h1></div><div class="top-actions"><button id="refresh" class="btn desktop-only">↻ Aktualisieren</button></div></header>
|
<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">
|
<div class="content">
|
||||||
<section id="view-overview" class="view active">
|
<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="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>
|
||||||
|
|
@ -55,21 +69,6 @@
|
||||||
<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>
|
<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>
|
||||||
|
|
||||||
<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">
|
<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="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>
|
<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>
|
||||||
|
|
@ -99,9 +98,13 @@
|
||||||
const esc=v=>String(v??'').replace(/[&<>'"]/g,c=>({'&':'&','<':'<','>':'>',"'":''','"':'"'}[c]));
|
const esc=v=>String(v??'').replace(/[&<>'"]/g,c=>({'&':'&','<':'<','>':'>',"'":''','"':'"'}[c]));
|
||||||
const cookie=name=>document.cookie.split('; ').find(x=>x.startsWith(name+'='))?.split('=').slice(1).join('=')||'';
|
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)};
|
const toast=msg=>{const el=$('#toast');el.textContent=msg;el.classList.add('show');setTimeout(()=>el.classList.remove('show'),2600)};
|
||||||
async function api(path,options={}){const method=(options.method||'GET').toUpperCase();const headers={...(options.headers||{})};if(!['GET','HEAD','OPTIONS'].includes(method))headers['X-CSRF-Token']=decodeURIComponent(cookie('butler_csrf'));if(options.body&&!headers['Content-Type'])headers['Content-Type']='application/json';const res=await fetch(path,{credentials:'same-origin',...options,headers});if(res.status===401){await fetch('/ui/session',{credentials:'same-origin'});location.reload();throw new Error('Read-only Sitzung wurde erneuert')}const text=await res.text();let data;try{data=JSON.parse(text)}catch{data=text}if(!res.ok)throw new Error(data?.detail||data?.error||`HTTP ${res.status}`);return data}
|
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)));
|
$$('.nav button[data-view]').forEach(btn=>btn.addEventListener('click',()=>switchView(btn.dataset.view)));
|
||||||
function switchView(name){$$('.view').forEach(v=>v.classList.toggle('active',v.id===`view-${name}`));$$('.nav button[data-view]').forEach(b=>b.classList.toggle('active',b.dataset.view===name));$('#view-title').textContent={overview:'Übersicht',doctor:'Doctor',sharing:'Emby Account Sharing',safety:'Wartung & Drift',audit:'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',safety:'Wartung & Drift',audit:'Audit',functions:'Alle Funktionen'}[name];$('#sidebar').classList.remove('open')}
|
||||||
$('#menu').addEventListener('click',()=>$('#sidebar').classList.toggle('open'));
|
$('#menu').addEventListener('click',()=>$('#sidebar').classList.toggle('open'));
|
||||||
$('#refresh').addEventListener('click',refreshAll);$('#drift-refresh').addEventListener('click',loadDrift);$('#audit-refresh').addEventListener('click',loadAudit);
|
$('#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 refreshAll(){toast('Aktualisierung gestartet');await Promise.allSettled([loadOverview(),loadDrift(),loadAudit(),loadCapabilities()]);toast('Live-Daten aktualisiert')}
|
||||||
|
|
@ -111,13 +114,10 @@
|
||||||
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>'}
|
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()}));
|
$('#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>`}});
|
$('#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>`}});
|
$('#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 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 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{await fetch('/ui/session',{credentials:'same-origin'});await refreshAll()}catch{toast('Butler ist momentan nicht erreichbar')}})();
|
(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>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue