diff --git a/app.py b/app.py index bac1a05..2b6da2e 100644 --- a/app.py +++ b/app.py @@ -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, math +import os, json, asyncio, logging, time, base64, re, subprocess, ipaddress, secrets, sqlite3 from datetime import datetime, timezone import httpx, yaml from typing import Literal @@ -246,24 +246,6 @@ 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 @@ -274,253 +256,10 @@ 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: @@ -589,19 +328,18 @@ 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") - 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") async def ui_session(request: Request): - 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) + return {"authenticated": _ui_session(request) is not None} @app.post("/ui/logout") diff --git a/tests/test_app.py b/tests/test_app.py index a8d4645..82356fd 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -54,11 +54,6 @@ 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 @@ -91,131 +86,6 @@ 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( diff --git a/ui.html b/ui.html index f837d9f..9ad3d27 100644 --- a/ui.html +++ b/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)} main{min-width:0}.topbar{height:68px;position:sticky;top:0;z-index:15;display:flex;align-items:center;justify-content:space-between;padding:0 30px;border-bottom:1px solid var(--border2);background:rgba(8,9,10,.78);backdrop-filter:blur(18px)}.topbar h1{font-size:15px;margin:0;font-weight:510}.top-actions{display:flex;gap:8px}.btn{border:1px solid var(--border);background:rgba(255,255,255,.03);color:var(--secondary);border-radius:7px;padding:8px 12px;font-size:12px;font-weight:510}.btn:hover{background:rgba(255,255,255,.07);color:var(--text)}.btn.primary{background:var(--accent2);border-color:transparent;color:white}.btn.danger{color:#fca5a5}.mobile-menu{display:none}.content{max-width:1440px;margin:0 auto;padding:30px}.view{display:none}.view.active{display:block}.eyebrow{font-size:11px;color:var(--accent);text-transform:uppercase;letter-spacing:.11em;font-weight:590}.hero{display:flex;justify-content:space-between;align-items:flex-end;gap:24px;margin:4px 0 26px}.hero h2{font-size:31px;letter-spacing:-.7px;font-weight:510;margin:8px 0 6px}.hero p{margin:0;color:var(--muted);font-size:14px;line-height:1.55}.updated{font:11px ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--dim)} .metrics{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px;margin-bottom:18px}.metric,.panel,.operation{border:1px solid var(--border);background:rgba(255,255,255,.025);border-radius:var(--radius)}.metric{padding:17px}.metric-label{font-size:11px;color:var(--muted);margin-bottom:12px}.metric-value{font-size:25px;letter-spacing:-.45px;font-weight:510}.metric-meta{font-size:11px;color:var(--dim);margin-top:7px}.metric.good .metric-value{color:#a7f3d0}.metric.warn .metric-value{color:#fcd34d}.metric.bad .metric-value{color:#fca5a5}.grid-2{display:grid;grid-template-columns:minmax(0,1.4fr) minmax(300px,.6fr);gap:14px}.panel{padding:18px;min-width:0}.panel-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:15px}.panel-title{font-size:13px;font-weight:590}.panel-sub{font-size:11px;color:var(--dim)}.empty{border:1px dashed var(--border);border-radius:8px;color:var(--dim);padding:26px;text-align:center;font-size:12px}.finding{display:grid;grid-template-columns:9px 1fr auto;gap:10px;align-items:start;padding:11px 0;border-bottom:1px solid var(--border2)}.finding:last-child{border-bottom:0}.finding-dot{width:7px;height:7px;border-radius:50%;background:var(--yellow);margin-top:5px}.finding.critical .finding-dot{background:var(--red)}.finding strong{font-size:12px}.finding p{font-size:11px;color:var(--muted);margin:4px 0 0}.badge{display:inline-flex;align-items:center;border:1px solid var(--border);border-radius:999px;padding:3px 7px;font:10px ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--muted);white-space:nowrap}.badge.get,.badge.read_only{color:#a7f3d0;border-color:rgba(16,185,129,.25);background:rgba(16,185,129,.06)}.badge.post,.badge.put,.badge.patch{color:#c4b5fd;border-color:rgba(113,112,255,.3);background:rgba(113,112,255,.07)}.badge.delete,.badge.destructive{color:#fca5a5;border-color:rgba(239,68,68,.25);background:rgba(239,68,68,.06)} - .form-row{display:grid;grid-template-columns:1fr auto;gap:9px}.form-row.triple{grid-template-columns:160px 1fr auto}.sharing-form{grid-template-columns:140px 170px minmax(180px,1fr) auto}.input{width:100%;border:1px solid var(--border);background:rgba(255,255,255,.025);color:var(--text);border-radius:7px;padding:10px 12px;outline:0;font-size:13px}.input:focus{border-color:rgba(113,112,255,.6);box-shadow:0 0 0 3px rgba(113,112,255,.1)}select.input{appearance:none}.result{margin-top:14px;min-height:110px}.layer-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:9px}.layer{border:1px solid var(--border2);background:rgba(255,255,255,.02);border-radius:8px;padding:12px}.layer h4{font-size:11px;margin:0 0 8px;text-transform:uppercase;color:var(--muted);letter-spacing:.07em}.layer pre,.json{white-space:pre-wrap;word-break:break-word;margin:0;color:var(--secondary);font:11px/1.55 ui-monospace,SFMono-Regular,Menlo,monospace}.safe-banner{display:flex;align-items:center;gap:12px;border-radius:9px;padding:14px;border:1px solid rgba(16,185,129,.25);background:rgba(16,185,129,.06)}.safe-banner.blocked{border-color:rgba(239,68,68,.25);background:rgba(239,68,68,.06)}.safe-icon{font-size:21px} + .form-row{display:grid;grid-template-columns:1fr auto;gap:9px}.form-row.triple{grid-template-columns:160px 1fr auto}.input{width:100%;border:1px solid var(--border);background:rgba(255,255,255,.025);color:var(--text);border-radius:7px;padding:10px 12px;outline:0;font-size:13px}.input:focus{border-color:rgba(113,112,255,.6);box-shadow:0 0 0 3px rgba(113,112,255,.1)}select.input{appearance:none}.result{margin-top:14px;min-height:110px}.layer-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:9px}.layer{border:1px solid var(--border2);background:rgba(255,255,255,.02);border-radius:8px;padding:12px}.layer h4{font-size:11px;margin:0 0 8px;text-transform:uppercase;color:var(--muted);letter-spacing:.07em}.layer pre,.json{white-space:pre-wrap;word-break:break-word;margin:0;color:var(--secondary);font:11px/1.55 ui-monospace,SFMono-Regular,Menlo,monospace}.safe-banner{display:flex;align-items:center;gap:12px;border-radius:9px;padding:14px;border:1px solid rgba(16,185,129,.25);background:rgba(16,185,129,.06)}.safe-banner.blocked{border-color:rgba(239,68,68,.25);background:rgba(239,68,68,.06)}.safe-icon{font-size:21px} .toolbar{display:flex;align-items:center;gap:9px;flex-wrap:wrap;margin-bottom:15px}.toolbar .input{max-width:360px}.chips{display:flex;gap:6px;flex-wrap:wrap}.chip{border:1px solid var(--border);background:transparent;color:var(--muted);border-radius:999px;padding:6px 10px;font-size:11px}.chip.active,.chip:hover{color:var(--text);background:rgba(255,255,255,.05)}.operations{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px}.operation{padding:14px;display:flex;flex-direction:column;gap:10px;min-height:145px;transition:.16s ease}.operation:hover{border-color:rgba(113,112,255,.35);transform:translateY(-1px);background:rgba(255,255,255,.035)}.operation-top{display:flex;justify-content:space-between;gap:8px}.operation-path{font:11px/1.45 ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--secondary);word-break:break-all}.operation h3{font-size:12px;font-weight:590;margin:0}.operation p{font-size:11px;color:var(--muted);line-height:1.45;margin:0;flex:1}.operation-badges{display:flex;gap:5px;flex-wrap:wrap}.table-wrap{overflow:auto;border:1px solid var(--border);border-radius:9px}table{width:100%;border-collapse:collapse;min-width:760px}th,td{text-align:left;padding:10px 12px;border-bottom:1px solid var(--border2);font-size:11px}th{color:var(--dim);text-transform:uppercase;letter-spacing:.07em;font-size:9px;background:rgba(255,255,255,.02)}td{color:var(--secondary)}td.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace}.status-code.ok{color:#a7f3d0}.status-code.err{color:#fca5a5} .login{position:fixed;inset:0;z-index:100;background:radial-gradient(circle at 50% 15%,rgba(113,112,255,.18),transparent 30%),#08090a;display:grid;place-items:center;padding:20px}.login-card{width:min(420px,100%);border:1px solid var(--border);background:#0f1011;border-radius:14px;padding:28px;box-shadow:0 28px 90px rgba(0,0,0,.5)}.login-card .logo{margin-bottom:22px}.login-card h1{font-size:25px;letter-spacing:-.5px;font-weight:510;margin:0 0 8px}.login-card p{font-size:13px;color:var(--muted);line-height:1.5;margin:0 0 20px}.login-card form{display:grid;gap:10px}.error{color:#fca5a5;font-size:11px;min-height:16px}.toast{position:fixed;right:20px;bottom:20px;z-index:120;background:#191a1b;border:1px solid var(--border);border-radius:9px;padding:11px 14px;font-size:12px;color:var(--secondary);box-shadow:0 12px 40px rgba(0,0,0,.35);transform:translateY(20px);opacity:0;pointer-events:none;transition:.2s}.toast.show{transform:none;opacity:1}.spinner{width:15px;height:15px;border:2px solid rgba(255,255,255,.15);border-top-color:var(--accent);border-radius:50%;animation:spin .7s linear infinite;display:inline-block}@keyframes spin{to{transform:rotate(360deg)}} @media(max-width:1050px){.operations{grid-template-columns:repeat(2,minmax(0,1fr))}.metrics{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-2{grid-template-columns:1fr}} - @media(max-width:760px){.shell{display:block}.sidebar{position:fixed;transform:translateX(-100%);transition:.2s;width:260px}.sidebar.open{transform:none}.mobile-menu{display:inline-flex}.topbar{padding:0 15px}.content{padding:20px 14px}.hero{align-items:flex-start;flex-direction:column}.hero h2{font-size:25px}.operations{grid-template-columns:1fr}.metrics{grid-template-columns:repeat(2,1fr)}.form-row,.form-row.triple,.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}} -
+
+ +
+ +