diff --git a/stream-control/agent/netstate b/stream-control/agent/netstate index 53f6a2d..381a043 100644 --- a/stream-control/agent/netstate +++ b/stream-control/agent/netstate @@ -33,60 +33,15 @@ def rate_map(): found[target] = value return found, rc - -def tc_metrics(): - rc, out, _ = run(["sudo", "tc", "-s", "class", "show", "dev", DEV]) - result = {} - current = None - for line in out.splitlines(): - m = re.search(r"^class htb (1:\w+).*?\brate\s+([0-9.]+)([KMG])bit", line, re.I) - if m: - current = next((key for key, value in CLASSES.items() if value == m.group(1)), m.group(1)) - rate = float(m.group(2)) * {"K": .001, "M": 1, "G": 1000}[m.group(3).upper()] - result[current] = {"rate_mbit": rate, "bytes": 0, "packets": 0, "drops": 0} - continue - if current: - sent = re.search(r"Sent\s+(\d+)\s+bytes\s+(\d+)\s+pkt.*?dropped\s+(\d+)", line) - if sent: - result[current].update(bytes=int(sent.group(1)), packets=int(sent.group(2)), drops=int(sent.group(3))) - current = None - return result, rc - - -def host_metrics(): - up = int(float(open("/proc/uptime").read().split()[0])) - load = [round(value, 2) for value in os.getloadavg()] - mem = {} - for line in open("/proc/meminfo"): - key, value = line.split(":", 1) - mem[key] = int(value.strip().split()[0]) - disk = os.statvfs("/") - return { - "uptime_seconds": up, - "load": load, - "memory_total": mem.get("MemTotal", 0) * 1024, - "memory_used": (mem.get("MemTotal", 0) - mem.get("MemAvailable", 0)) * 1024, - "disk_total": disk.f_blocks * disk.f_frsize, - "disk_used": (disk.f_blocks - disk.f_bavail) * disk.f_frsize, - } - def state(): c = db() limits = {r["target"]: r["limit_mbit"] for r in c.execute("SELECT target,limit_mbit FROM global_limits")} settings = {r["key"]: r["value"] for r in c.execute("SELECT key,value FROM settings")} active_sessions = c.execute("SELECT count(*) FROM session_history WHERE active=1").fetchone()[0] - session_rows = [dict(r) for r in c.execute("SELECT user_name,device,client,ip,title,series,started_at,last_seen,country,city,asn,bytes FROM session_history WHERE active=1 ORDER BY last_seen DESC LIMIT 20")] recent = [dict(r) for r in c.execute("SELECT ts,user_name,kind,detail FROM multisession_events ORDER BY id DESC LIMIT 8")] active_blocks = c.execute("SELECT count(*) FROM multisession_blocks WHERE active=1 AND expires_at>strftime('%s','now')").fetchone()[0] - totals = { - "sessions": c.execute("SELECT count(*) FROM session_history").fetchone()[0], - "known_ips": c.execute("SELECT count(*) FROM ip_history").fetchone()[0], - "policy_events": c.execute("SELECT count(*) FROM multisession_events").fetchone()[0], - "blocks": c.execute("SELECT count(*) FROM multisession_blocks").fetchone()[0], - } c.close() - tc_detail, tc_rc = tc_metrics() - tc = {key: value["rate_mbit"] for key, value in tc_detail.items()} + tc, tc_rc = rate_map() ipsets = {} for name in ("geo-a-v4", "geo-a-v6", "cloud-v4", "cloud-v6", "persist-v4", "persist-v6"): rc, out, _ = run(["sudo", "ipset", "list", name, "-t"]) @@ -99,9 +54,8 @@ def state(): rc, rules, _ = run(["sudo", "iptables", "-t", "mangle", "-S"]) return { "ok": tc_rc == 0 and all(v == "active" for k, v in services.items() if k != "netqos"), - "ts": int(time.time()), "host": host_metrics(), "limits": limits, "tc": tc, "tc_detail": tc_detail, "ipsets": ipsets, - "services": services, "active_sessions": active_sessions, "sessions": session_rows, - "active_blocks": active_blocks, "totals": totals, + "ts": int(time.time()), "limits": limits, "tc": tc, "ipsets": ipsets, + "services": services, "active_sessions": active_sessions, "active_blocks": active_blocks, "policy": { "enabled": settings.get("multisession_enabled", "0") == "1", "mode": settings.get("multisession_mode", "monitor"), @@ -113,29 +67,6 @@ def state(): "recent_events": recent, } - -def history(): - c = db() - data = { - "sessions": [dict(r) for r in c.execute( - "SELECT user_name,device,client,ip,title,series,started_at,ended_at,last_seen,bytes,country,city,asn,active " - "FROM session_history ORDER BY last_seen DESC LIMIT 150" - )], - "ips": [dict(r) for r in c.execute( - "SELECT user_name,ip,device,client,first_seen,last_seen,sightings,limited_sightings,last_classification,country,city,asn " - "FROM ip_history ORDER BY last_seen DESC LIMIT 150" - )], - "events": [dict(r) for r in c.execute( - "SELECT ts,user_name,kind,detail FROM multisession_events ORDER BY id DESC LIMIT 150" - )], - "blocks": [dict(r) for r in c.execute( - "SELECT ip,user_name,reason,blocked_at,expires_at,active,manual_allow_until FROM multisession_blocks ORDER BY blocked_at DESC LIMIT 150" - )], - } - c.close() - return {"ok": True, "history": data, "generated_at": int(time.time())} - - def set_limits(ch, vpn): values = {"ch": float(ch), "vpn": float(vpn)} if any(not 0.1 <= value <= 100 for value in values.values()): @@ -202,8 +133,6 @@ def main(): if not argv: argv = ["state"] if argv == ["state"]: result = state() - elif argv == ["history"]: - result = history() elif len(argv) == 3 and argv[0] == "set-limits": result = set_limits(argv[1], argv[2]) elif argv[0] == "set-policy" and 2 <= len(argv) <= 4: diff --git a/stream-control/app.py b/stream-control/app.py index c321389..5dc1203 100644 --- a/stream-control/app.py +++ b/stream-control/app.py @@ -1,13 +1,8 @@ """Network profile control surface; backend access is restricted to a forced SSH command.""" from __future__ import annotations -import json, os, secrets, sqlite3, subprocess, time -from datetime import datetime -from zoneinfo import ZoneInfo +import json, os, secrets, subprocess, time from flask import Flask, flash, jsonify, redirect, render_template, request, session, url_for -BASE_DIR = os.path.dirname(os.path.abspath(__file__)) -AUDIT_DB = os.path.join(BASE_DIR, "control-history.db") -TZ = ZoneInfo("Europe/Berlin") app = Flask(__name__) app.secret_key = os.environ["SESSION_SECRET"] app.config.update(SESSION_COOKIE_HTTPONLY=True, SESSION_COOKIE_SAMESITE="Strict", SESSION_COOKIE_SECURE=True) @@ -20,42 +15,28 @@ PROFILES = { } _CACHE = {"at": 0.0, "data": None} -def init_audit(): - with sqlite3.connect(AUDIT_DB) as db: - db.execute("CREATE TABLE IF NOT EXISTS audit(id INTEGER PRIMARY KEY AUTOINCREMENT,ts INTEGER NOT NULL,action TEXT NOT NULL,detail TEXT,ok INTEGER NOT NULL,error TEXT,remote_ip TEXT)") - db.execute("CREATE INDEX IF NOT EXISTS idx_audit_ts ON audit(ts DESC)") - -def audit(action, detail, result): - try: - remote = (request.headers.get("X-Forwarded-For") or request.remote_addr or "").split(",")[0].strip() - with sqlite3.connect(AUDIT_DB) as db: - db.execute("INSERT INTO audit(ts,action,detail,ok,error,remote_ip) VALUES(?,?,?,?,?,?)", (int(time.time()), action, detail[:500], 1 if result.get("ok") else 0, str(result.get("error", ""))[:500], remote[:80])) - db.execute("DELETE FROM audit WHERE id NOT IN (SELECT id FROM audit ORDER BY id DESC LIMIT 2000)") - except Exception: - app.logger.exception("audit write failed") - -init_audit() - def rpc(command: str, timeout: int = 25): + p = subprocess.run( + ["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=8", "edge-maint", command], + capture_output=True, text=True, timeout=timeout, + ) + raw = p.stdout.strip() try: - p = subprocess.run(["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=8", "edge-maint", command], capture_output=True, text=True, timeout=timeout) - raw = p.stdout.strip() - try: - data = json.loads(raw) if raw else {} - except json.JSONDecodeError: - data = {"ok": False, "error": (p.stderr or raw or "invalid response")[:300]} - if p.returncode and data.get("ok") is not False: - data = {"ok": False, "error": (p.stderr or raw or f"rpc exit {p.returncode}")[:300]} - return data - except Exception as exc: - return {"ok": False, "error": str(exc)[:300]} + data = json.loads(raw) if raw else {} + except json.JSONDecodeError: + data = {"ok": False, "error": (p.stderr or raw or "invalid response")[:300]} + if p.returncode and data.get("ok") is not False: + data = {"ok": False, "error": (p.stderr or raw or f"rpc exit {p.returncode}")[:300]} + return data def get_state(force=False): now = time.monotonic() if not force and _CACHE["data"] is not None and now - _CACHE["at"] < 3: return _CACHE["data"] - data = rpc("state") - data.setdefault("limits", {}); data.setdefault("tc", {}); data.setdefault("services", {}); data.setdefault("policy", {}); data.setdefault("ipsets", {}) + try: + data = rpc("state") + except Exception as exc: + data = {"ok": False, "error": str(exc)[:300], "limits": {}, "tc": {}, "services": {}, "policy": {}, "ipsets": {}} _CACHE.update(at=now, data=data) return data @@ -67,26 +48,6 @@ def csrf_token(): app.jinja_env.globals["csrf_token"] = csrf_token -@app.template_filter("dt") -def fmt_datetime(value): - try: return datetime.fromtimestamp(int(value), TZ).strftime("%d.%m.%Y %H:%M:%S") - except (TypeError, ValueError, OSError): return "—" - -@app.template_filter("bytes") -def fmt_bytes(value): - try: number = float(value or 0) - except (TypeError, ValueError): return "—" - for unit in ("B", "KiB", "MiB", "GiB", "TiB"): - if number < 1024 or unit == "TiB": return f"{number:.1f} {unit}" - number /= 1024 - -@app.template_filter("duration") -def fmt_duration(value): - try: seconds = max(0, int(value)) - except (TypeError, ValueError): return "—" - days, seconds = divmod(seconds, 86400); hours, seconds = divmod(seconds, 3600); minutes, _ = divmod(seconds, 60) - return (f"{days}d {hours}h" if days else f"{hours}h {minutes}m" if hours else f"{minutes}m") - @app.before_request def protect_post(): if request.method == "POST" and not secrets.compare_digest(request.form.get("csrf", ""), session.get("csrf", "invalid")): @@ -94,25 +55,16 @@ def protect_post(): @app.get("/") def dashboard(): - return render_template("dashboard.html", state=get_state(), profiles=PROFILES, page="dashboard") - -@app.get("/history") -def history_page(): - remote = rpc("history", timeout=35) - with sqlite3.connect(AUDIT_DB) as db: - db.row_factory = sqlite3.Row - local_audit = [dict(row) for row in db.execute("SELECT ts,action,detail,ok,error,remote_ip FROM audit ORDER BY id DESC LIMIT 250")] - history = remote.get("history", {}) if remote.get("ok") else {} - for name in ("sessions", "ips", "events", "blocks"): history.setdefault(name, []) - return render_template("history.html", state=get_state(), history=history, audit=local_audit, history_error=remote.get("error"), page="history") + state = get_state() + return render_template("dashboard.html", state=state, profiles=PROFILES) @app.post("/profile/") def apply_profile(name): profile = PROFILES.get(name) if not profile: - flash("Unbekanntes Profil", "error"); return redirect(url_for("dashboard")) + flash("Unbekanntes Profil", "error") + return redirect(url_for("dashboard")) result = rpc(f"set-limits {profile['ch']} {profile['vpn']}") - audit("profile", f"{profile['label']}: Region {profile['ch']} / Cloud {profile['vpn']} Mbit", result) _CACHE["data"] = None flash(f"Profil {profile['label']} aktiviert" if result.get("ok") else f"Fehler: {result.get('error','unbekannt')}", "success" if result.get("ok") else "error") return redirect(url_for("dashboard")) @@ -121,11 +73,12 @@ def apply_profile(name): def custom_limits(): try: ch = float(request.form["ch"]); vpn = float(request.form["vpn"]) - if not 0.1 <= ch <= 100 or not 0.1 <= vpn <= 100: raise ValueError + if not 0.1 <= ch <= 100 or not 0.1 <= vpn <= 100: + raise ValueError except (KeyError, ValueError): - flash("Limits müssen zwischen 0,1 und 100 Mbit liegen", "error"); return redirect(url_for("dashboard")) + flash("Limits müssen zwischen 0,1 und 100 Mbit liegen", "error") + return redirect(url_for("dashboard")) result = rpc(f"set-limits {ch:g} {vpn:g}") - audit("limits", f"Region {ch:g} / Cloud {vpn:g} Mbit", result) _CACHE["data"] = None flash("Benutzerdefinierte Limits gesetzt" if result.get("ok") else f"Fehler: {result.get('error','unbekannt')}", "success" if result.get("ok") else "error") return redirect(url_for("dashboard")) @@ -134,29 +87,35 @@ def custom_limits(): def policy(): mode = request.form.get("mode", "") try: - min_sessions = int(request.form.get("min_sessions", "2")); block_minutes = int(request.form.get("block_minutes", "10")) - if mode not in {"off", "monitor", "block"} or not 2 <= min_sessions <= 8 or not 1 <= block_minutes <= 1440: raise ValueError + min_sessions = int(request.form.get("min_sessions", "2")) + block_minutes = int(request.form.get("block_minutes", "10")) + if mode not in {"off", "monitor", "block"} or not 2 <= min_sessions <= 8 or not 1 <= block_minutes <= 1440: + raise ValueError except ValueError: - flash("Ungültige Policy-Werte", "error"); return redirect(url_for("dashboard")) + flash("Ungültige Policy-Werte", "error") + return redirect(url_for("dashboard")) result = rpc(f"set-policy {mode} {min_sessions} {block_minutes}") - audit("policy", f"Modus {mode}, ab {min_sessions} Streams, {block_minutes} Minuten", result) _CACHE["data"] = None flash("Session-Policy aktualisiert" if result.get("ok") else f"Fehler: {result.get('error','unbekannt')}", "success" if result.get("ok") else "error") return redirect(url_for("dashboard")) @app.post("/maintenance/") def maintenance(action): - mapping = {"refresh": "limiter refresh", "start": "limiter start", "stop": "limiter stop", "worker": "restart worker", "admin": "restart admin"} + mapping = { + "refresh": "limiter refresh", "start": "limiter start", "stop": "limiter stop", + "worker": "restart worker", "admin": "restart admin", + } command = mapping.get(action) - if not command: return "unknown action", 404 + if not command: + return "unknown action", 404 result = rpc(command) - audit("maintenance", action, result) _CACHE["data"] = None flash(f"Aktion {action} gestartet" if result.get("ok") else f"Fehler: {result.get('error','unbekannt')}", "success" if result.get("ok") else "error") return redirect(url_for("dashboard")) @app.get("/api/status") -def api_status(): return jsonify(get_state()) +def api_status(): + return jsonify(get_state()) @app.get("/health") def health(): diff --git a/stream-control/static/admin.css b/stream-control/static/admin.css index 3f26a91..dc36270 100644 --- a/stream-control/static/admin.css +++ b/stream-control/static/admin.css @@ -3,8 +3,3 @@ .sidebar-foot{margin-top:auto;padding:14px;color:var(--muted);display:flex;align-items:center;gap:9px}.status-dot{width:9px;height:9px;border-radius:50%;background:var(--red);box-shadow:0 0 12px rgba(255,102,125,.5)}.status-dot.ok{background:var(--green);box-shadow:0 0 12px rgba(50,214,163,.5)}.control-profiles{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;padding:18px}.profile-card{position:relative;padding:18px;border:1px solid var(--border);border-top:3px solid var(--accent);border-radius:12px;background:var(--panel2);display:flex;flex-direction:column;gap:8px}.profile-card h3{margin:0}.profile-card strong{font-size:22px}.profile-card small{color:var(--muted)}.profile-dot{position:absolute;right:16px;top:16px;width:10px;height:10px;border-radius:50%;background:var(--accent);box-shadow:0 0 12px var(--accent)}.profile-card button{margin-top:8px}.compact-form{padding:18px}.compact-form label{display:flex;flex-direction:column;gap:7px;color:var(--muted);font-size:12px}.compact-form input,.compact-form select{width:100%;background:var(--bg2);border:1px solid var(--border);border-radius:8px;color:var(--text);padding:11px}.compact-form button.full{grid-column:1/-1}.action-row{display:flex;gap:8px;flex-wrap:wrap;padding:14px}.event-list{padding:6px 18px 18px}.event{display:flex;gap:12px;padding:11px 0;border-bottom:1px solid var(--border)}.event:last-child{border:0}.event-kind{min-width:66px;height:max-content;padding:4px 7px;border-radius:6px;background:rgba(120,87,246,.15);color:var(--violet2);font:10px ui-monospace,monospace;text-transform:uppercase}.event div{min-width:0}.event strong,.event small{display:block}.event small{color:var(--muted);margin-top:4px;overflow-wrap:anywhere}.muted{color:var(--muted)} @media(max-width:900px){.control-profiles{grid-template-columns:repeat(2,1fr)}}@media(max-width:560px){.control-profiles{grid-template-columns:1fr}.action-row form,.action-row button{width:100%}} - - -/* center-history-v2 */ -.detail-strip{display:grid;grid-template-columns:repeat(5,1fr);gap:1px;margin:0 0 18px;background:var(--border);border:1px solid var(--border);border-radius:12px;overflow:hidden}.detail-strip>div{padding:14px 16px;background:var(--panel)}.detail-strip span,.detail-strip strong{display:block}.detail-strip span{color:var(--muted);font-size:10px;letter-spacing:1px;text-transform:uppercase}.detail-strip strong{margin-top:5px;font-size:13px}.cell-sub{display:block;color:var(--muted);font-size:10px;margin-top:4px}.text-link{color:var(--violet2);font-size:12px}.nowrap{white-space:nowrap}.break{word-break:break-all;max-width:230px}.danger-text{color:var(--red)}.history-toolbar{display:flex;justify-content:space-between;align-items:center;padding:12px;margin-bottom:16px}.history-tabs{display:flex;gap:6px;flex-wrap:wrap}.history-tab{border:1px solid var(--border);background:var(--panel2);color:var(--muted);padding:9px 14px;border-radius:8px;cursor:pointer}.history-tab.active{background:rgba(120,87,246,.2);border-color:var(--violet);color:var(--text)}#history-search{width:min(360px,100%);background:var(--bg2);border:1px solid var(--border);border-radius:9px;color:var(--text);padding:10px 12px}.history-pane{display:none}.history-pane.active{display:block}.history-split{padding:0}.history-split>.table-wrap{margin:0}.history-split>.event-list{max-height:560px;overflow:auto;padding:12px 18px}.filter-row[hidden]{display:none} -@media(max-width:1100px){.detail-strip{grid-template-columns:repeat(2,1fr)}.detail-strip>div:last-child{grid-column:1/-1}}@media(max-width:760px){.history-toolbar{align-items:stretch;flex-direction:column;gap:10px}.history-tabs{display:grid;grid-template-columns:repeat(2,1fr)}#history-search{width:100%}.detail-strip{grid-template-columns:1fr 1fr}.table-wrap{overflow-x:auto}.table-wrap table{min-width:800px}} diff --git a/stream-control/templates/base.html b/stream-control/templates/base.html index 291f0f8..fd38237 100644 --- a/stream-control/templates/base.html +++ b/stream-control/templates/base.html @@ -10,12 +10,12 @@
◈StreamScope
-

{% block eyebrow %}REMOTE CONTROL{% endblock %}

{% block heading %}TV & Session Policy{% endblock %}

{{ 'OVH ONLINE' if state.get('ok') else 'OVH OFFLINE' }}
+

REMOTE CONTROL

TV & Session Policy

{{ 'OVH ONLINE' if state.get('ok') else 'OVH OFFLINE' }}
{% with messages=get_flashed_messages(with_categories=true) %}{% for category,message in messages %}
{{ '✓' if category=='success' else '!' }}

{{ message }}

{% endfor %}{% endwith %} {% block content %}{% endblock %}
diff --git a/stream-control/templates/dashboard.html b/stream-control/templates/dashboard.html index eed200f..63a5f5a 100644 --- a/stream-control/templates/dashboard.html +++ b/stream-control/templates/dashboard.html @@ -7,23 +7,6 @@
POLICY{{ ('AUS' if not policy.get('enabled') else policy.get('mode','—')|upper) }}ab {{ policy.get('min_sessions','—') }} Streams
-{% set host=state.get('host',{}) %}{% set tc_detail=state.get('tc_detail',{}) %} -
-
OVH Uptime{{ host.get('uptime_seconds',0)|duration }}
-
Load 1/5/15{{ (host.get('load') or ['—','—','—'])|join(' / ') }}
-
RAM{{ host.get('memory_used',0)|bytes }} / {{ host.get('memory_total',0)|bytes }}
-
Root-Disk{{ host.get('disk_used',0)|bytes }} / {{ host.get('disk_total',0)|bytes }}
-
Historie{{ state.get('totals',{}).get('sessions',0) }} Sessions · {{ state.get('totals',{}).get('known_ips',0) }} IPs
-
- -{% if state.get('sessions') %} -

LIVE

Aktive Sessions

vollständige History →
-
- {% for s in state.get('sessions',[]) %}{% endfor %} -
BenutzerTitelGerätStandortIPSeit
{{ s.user_name }}{{ s.series or s.title }}{% if s.series %}{{ s.title }}{% endif %}{{ s.device }}{{ s.client }}{{ [s.city,s.country]|select|join(', ') }}{{ s.asn }}{{ s.ip }}{{ s.started_at|dt }}
-
-{% endif %} -

SCHNELLWAHL

Traffic-Profile

Wirkt sofort auf die aktiven tc-Klassen am OVH
{% for key,p in profiles.items() %} @@ -61,7 +44,6 @@ Markierungsregeln{{ state.get('mark_rules','—') }} Region-Netze{{ state.get('ipsets',{}).get('geo-a-v4',0)+state.get('ipsets',{}).get('geo-a-v6',0) }} Cloud-Netze{{ state.get('ipsets',{}).get('cloud-v4',0)+state.get('ipsets',{}).get('cloud-v6',0) }} - {% for key,label in [('ch','Region-Traffic'),('vpn','Cloud-Traffic'),('default','Standard-Traffic')] %}{% set c=tc_detail.get(key,{}) %}{{ label }}{{ c.get('bytes',0)|bytes }}{{ c.get('packets',0) }} Pakete · {{ c.get('drops',0) }} Drops{% endfor %}
{% for action,label,cls in [('refresh','Netzlisten aktualisieren','ghost'),('worker','Worker neu starten','ghost'),('admin','Admin neu starten','ghost')] %}
{% endfor %} diff --git a/stream-control/templates/history.html b/stream-control/templates/history.html deleted file mode 100644 index 154b8c6..0000000 --- a/stream-control/templates/history.html +++ /dev/null @@ -1,53 +0,0 @@ -{% extends 'base.html' %}{% block eyebrow %}AUDIT & FORENSIK{% endblock %}{% block heading %}History{% endblock %}{% block content %} -
-
SESSIONS{{ history.sessions|length }}letzte Datensätze
-
BEKANNTE IPs{{ history.ips|length }}letzte Datensätze
-
POLICY-EVENTS{{ history.events|length }}Block / Freigabe
-
CONTROL-AKTIONEN{{ audit|length }}lokales Audit
-
-{% if history_error %}
!

{{ history_error }}

{% endif %} - -
-
- - - - -
- -
- -
-

CENTER AUDIT

Ausgeführte Änderungen

{{ audit|length }} Einträge
-
- {% for row in audit %}{% else %}{% endfor %} -
ZeitAktionDetailsQuelleErgebnis
{{ row.ts|dt }}{{ row.action }}{{ row.detail }}{% if row.error %}{{ row.error }}{% endif %}{{ row.remote_ip }}{{ 'OK' if row.ok else 'FEHLER' }}
Noch keine Control-Aktionen protokolliert.
-
- -
-

PLAYBACK

Session-History

{{ history.sessions|length }} Einträge
-
- {% for row in history.sessions %}{% else %}{% endfor %} -
ZuletztBenutzerTitelGerätStandort / ASNIPTrafficStatus
{{ row.last_seen|dt }}{{ row.user_name }}{{ row.series or row.title }}{% if row.series %}{{ row.title }}{% endif %}{{ row.device }}{{ row.client }}{{ [row.city,row.country]|select|join(', ') }}{{ row.asn }}{{ row.ip }}{{ row.bytes|bytes }}{{ 'AKTIV' if row.active else 'BEENDET' }}
Keine Sessions vorhanden.
-
- -
-

CLIENT FORENSIK

IP-History

{{ history.ips|length }} Einträge
-
- {% for row in history.ips %}{% else %}{% endfor %} -
ZuletztBenutzerIPGerätStandort / ASNKlasseSichtungen
{{ row.last_seen|dt }}zuerst {{ row.first_seen|dt }}{{ row.user_name }}{{ row.ip }}{{ row.device }}{{ row.client }}{{ [row.city,row.country]|select|join(', ') }}{{ row.asn }}{{ row.last_classification }}{{ row.sightings }}{{ row.limited_sightings }} limitiert
Keine IP-History vorhanden.
-
- -
-

POLICY AUDIT

Blocks & Ereignisse

{{ history.blocks|length }} Blocks · {{ history.events|length }} Events
-
-
- {% for row in history.blocks %}{% else %}{% endfor %} -
ZeitBenutzer / IPGrundStatus
{{ row.blocked_at|dt }}{{ row.user_name or '—' }}{{ row.ip }}{{ row.reason }}{{ 'AKTIV' if row.active else 'ABGELAUFEN' }}
Keine Blocks.
-
{% for row in history.events %}
{{ row.kind }}
{{ row.user_name or 'System' }}{{ row.ts|dt }} · {{ row.detail }}
{% else %}

Keine Policy-Ereignisse.

{% endfor %}
-
-
- -{% endblock %}