Merge pull request 'feat: detailed control center status and history' (#2) from feat/control-center-history-20260907 into main

This commit is contained in:
sascha 2026-09-07 10:11:29 +02:00
commit 2d36b64a58
6 changed files with 232 additions and 44 deletions

View file

@ -33,15 +33,60 @@ 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, tc_rc = rate_map()
tc_detail, tc_rc = tc_metrics()
tc = {key: value["rate_mbit"] for key, value in tc_detail.items()}
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"])
@ -54,8 +99,9 @@ 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()), "limits": limits, "tc": tc, "ipsets": ipsets,
"services": services, "active_sessions": active_sessions, "active_blocks": active_blocks,
"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,
"policy": {
"enabled": settings.get("multisession_enabled", "0") == "1",
"mode": settings.get("multisession_mode", "monitor"),
@ -67,6 +113,29 @@ 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()):
@ -133,6 +202,8 @@ 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:

View file

@ -1,8 +1,13 @@
"""Network profile control surface; backend access is restricted to a forced SSH command."""
from __future__ import annotations
import json, os, secrets, subprocess, time
import json, os, secrets, sqlite3, subprocess, time
from datetime import datetime
from zoneinfo import ZoneInfo
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)
@ -15,28 +20,42 @@ PROFILES = {
}
_CACHE = {"at": 0.0, "data": None}
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()
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:
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
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):
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]}
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"]
try:
data = rpc("state")
except Exception as exc:
data = {"ok": False, "error": str(exc)[:300], "limits": {}, "tc": {}, "services": {}, "policy": {}, "ipsets": {}}
data = rpc("state")
data.setdefault("limits", {}); data.setdefault("tc", {}); data.setdefault("services", {}); data.setdefault("policy", {}); data.setdefault("ipsets", {})
_CACHE.update(at=now, data=data)
return data
@ -48,6 +67,26 @@ 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")):
@ -55,16 +94,25 @@ def protect_post():
@app.get("/")
def dashboard():
state = get_state()
return render_template("dashboard.html", state=state, profiles=PROFILES)
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")
@app.post("/profile/<name>")
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"))
@ -73,12 +121,11 @@ 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"))
@ -87,35 +134,29 @@ 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/<action>")
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():

View file

@ -3,3 +3,8 @@
.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}}

View file

@ -10,12 +10,12 @@
<aside class="sidebar">
<a class="brand" href="/"><span class="brand-mark">◈</span><span>Stream<span>Scope</span></span></a>
<p class="nav-label">CONTROL</p>
<nav><a class="nav-item active" href="/"><i>⌁</i>Traffic Policy</a><a class="nav-item" href="/api/status"><i>{ }</i>Status API</a></nav>
<nav><a class="nav-item {{ 'active' if page=='dashboard' }}" href="/"><i>⌁</i>Übersicht</a><a class="nav-item {{ 'active' if page=='history' }}" href="/history"><i>◷</i>History</a><a class="nav-item" href="/api/status"><i>{ }</i>Status API</a></nav>
<div class="sidebar-foot"><span class="status-dot {{ 'ok' if state.get('ok') else 'down' }}"></span><small>Edge {{ 'verbunden' if state.get('ok') else 'nicht erreichbar' }}</small></div>
</aside>
<header class="mobile-head"><a class="brand" href="/"><span class="brand-mark">◈</span><span>Stream<span>Scope</span></span></a></header>
<main>
<div class="topbar"><div><p class="eyebrow">REMOTE CONTROL</p><h1>TV & Session Policy</h1></div><div class="top-actions"><span class="status-pill {{ 'ok' if state.get('ok') else 'down' }}">{{ 'OVH ONLINE' if state.get('ok') else 'OVH OFFLINE' }}</span></div></div>
<div class="topbar"><div><p class="eyebrow">{% block eyebrow %}REMOTE CONTROL{% endblock %}</p><h1>{% block heading %}TV & Session Policy{% endblock %}</h1></div><div class="top-actions"><span class="status-pill {{ 'ok' if state.get('ok') else 'down' }}">{{ 'OVH ONLINE' if state.get('ok') else 'OVH OFFLINE' }}</span></div></div>
{% with messages=get_flashed_messages(with_categories=true) %}{% for category,message in messages %}<div class="notice {{ category }}"><strong>{{ '✓' if category=='success' else '!' }}</strong><p>{{ message }}</p></div>{% endfor %}{% endwith %}
{% block content %}{% endblock %}
</main>

View file

@ -7,6 +7,23 @@
<article class="kpi"><span>POLICY</span><strong>{{ ('AUS' if not policy.get('enabled') else policy.get('mode','—')|upper) }}</strong><small>ab {{ policy.get('min_sessions','—') }} Streams</small></article>
</section>
{% set host=state.get('host',{}) %}{% set tc_detail=state.get('tc_detail',{}) %}
<section class="detail-strip">
<div><span>OVH Uptime</span><strong>{{ host.get('uptime_seconds',0)|duration }}</strong></div>
<div><span>Load 1/5/15</span><strong>{{ (host.get('load') or ['—','—','—'])|join(' / ') }}</strong></div>
<div><span>RAM</span><strong>{{ host.get('memory_used',0)|bytes }} / {{ host.get('memory_total',0)|bytes }}</strong></div>
<div><span>Root-Disk</span><strong>{{ host.get('disk_used',0)|bytes }} / {{ host.get('disk_total',0)|bytes }}</strong></div>
<div><span>Historie</span><strong>{{ state.get('totals',{}).get('sessions',0) }} Sessions · {{ state.get('totals',{}).get('known_ips',0) }} IPs</strong></div>
</section>
{% if state.get('sessions') %}
<section class="panel"><div class="panel-head"><div><p class="eyebrow">LIVE</p><h2>Aktive Sessions</h2></div><a class="text-link" href="/history#sessions">vollständige History →</a></div>
<div class="table-wrap"><table><thead><tr><th>Benutzer</th><th>Titel</th><th>Gerät</th><th>Standort</th><th>IP</th><th>Seit</th></tr></thead><tbody>
{% for s in state.get('sessions',[]) %}<tr><td><strong>{{ s.user_name }}</strong></td><td>{{ s.series or s.title }}{% if s.series %}<small class="cell-sub">{{ s.title }}</small>{% endif %}</td><td>{{ s.device }}<small class="cell-sub">{{ s.client }}</small></td><td>{{ [s.city,s.country]|select|join(', ') }}<small class="cell-sub">{{ s.asn }}</small></td><td class="mono">{{ s.ip }}</td><td>{{ s.started_at|dt }}</td></tr>{% endfor %}
</tbody></table></div>
</section>
{% endif %}
<section class="panel"><div class="panel-head"><div><p class="eyebrow">SCHNELLWAHL</p><h2>Traffic-Profile</h2></div><small>Wirkt sofort auf die aktiven tc-Klassen am OVH</small></div>
<div class="profile-grid control-profiles">
{% for key,p in profiles.items() %}
@ -44,6 +61,7 @@
<tr><td>Markierungsregeln</td><td><strong>{{ state.get('mark_rules','—') }}</strong></td></tr>
<tr><td>Region-Netze</td><td><strong>{{ state.get('ipsets',{}).get('geo-a-v4',0)+state.get('ipsets',{}).get('geo-a-v6',0) }}</strong></td></tr>
<tr><td>Cloud-Netze</td><td><strong>{{ state.get('ipsets',{}).get('cloud-v4',0)+state.get('ipsets',{}).get('cloud-v6',0) }}</strong></td></tr>
{% for key,label in [('ch','Region-Traffic'),('vpn','Cloud-Traffic'),('default','Standard-Traffic')] %}{% set c=tc_detail.get(key,{}) %}<tr><td>{{ label }}</td><td><strong>{{ c.get('bytes',0)|bytes }}</strong><small class="cell-sub">{{ c.get('packets',0) }} Pakete · {{ c.get('drops',0) }} Drops</small></td></tr>{% endfor %}
</tbody></table></div>
<div class="action-row">
{% for action,label,cls in [('refresh','Netzlisten aktualisieren','ghost'),('worker','Worker neu starten','ghost'),('admin','Admin neu starten','ghost')] %}<form method="post" action="{{ url_for('maintenance',action=action) }}"><input type="hidden" name="csrf" value="{{ csrf_token() }}"><button class="{{ cls }}">{{ label }}</button></form>{% endfor %}

View file

@ -0,0 +1,53 @@
{% extends 'base.html' %}{% block eyebrow %}AUDIT & FORENSIK{% endblock %}{% block heading %}History{% endblock %}{% block content %}
<section class="kpi-grid">
<article class="kpi"><span>SESSIONS</span><strong>{{ history.sessions|length }}</strong><small>letzte Datensätze</small></article>
<article class="kpi"><span>BEKANNTE IPs</span><strong>{{ history.ips|length }}</strong><small>letzte Datensätze</small></article>
<article class="kpi"><span>POLICY-EVENTS</span><strong>{{ history.events|length }}</strong><small>Block / Freigabe</small></article>
<article class="kpi"><span>CONTROL-AKTIONEN</span><strong>{{ audit|length }}</strong><small>lokales Audit</small></article>
</section>
{% if history_error %}<div class="notice error"><strong>!</strong><p>{{ history_error }}</p></div>{% endif %}
<section class="history-toolbar panel">
<div class="history-tabs" role="tablist">
<button class="history-tab active" data-tab="control">Control</button>
<button class="history-tab" data-tab="sessions">Sessions</button>
<button class="history-tab" data-tab="ips">IP-History</button>
<button class="history-tab" data-tab="policy">Policy</button>
</div>
<input id="history-search" type="search" placeholder="Benutzer, IP, Titel, Gerät, ASN …" autocomplete="off">
</section>
<section class="panel history-pane active" data-pane="control">
<div class="panel-head"><div><p class="eyebrow">CENTER AUDIT</p><h2>Ausgeführte Änderungen</h2></div><small>{{ audit|length }} Einträge</small></div>
<div class="table-wrap"><table><thead><tr><th>Zeit</th><th>Aktion</th><th>Details</th><th>Quelle</th><th>Ergebnis</th></tr></thead><tbody>
{% for row in audit %}<tr class="filter-row"><td class="nowrap">{{ row.ts|dt }}</td><td><span class="event-kind">{{ row.action }}</span></td><td>{{ row.detail }}{% if row.error %}<small class="cell-sub danger-text">{{ row.error }}</small>{% endif %}</td><td class="mono">{{ row.remote_ip }}</td><td><span class="badge {{ 'active' if row.ok else 'danger' }}">{{ 'OK' if row.ok else 'FEHLER' }}</span></td></tr>{% else %}<tr><td colspan="5">Noch keine Control-Aktionen protokolliert.</td></tr>{% endfor %}
</tbody></table></div>
</section>
<section id="sessions" class="panel history-pane" data-pane="sessions">
<div class="panel-head"><div><p class="eyebrow">PLAYBACK</p><h2>Session-History</h2></div><small>{{ history.sessions|length }} Einträge</small></div>
<div class="table-wrap"><table><thead><tr><th>Zuletzt</th><th>Benutzer</th><th>Titel</th><th>Gerät</th><th>Standort / ASN</th><th>IP</th><th>Traffic</th><th>Status</th></tr></thead><tbody>
{% for row in history.sessions %}<tr class="filter-row"><td class="nowrap">{{ row.last_seen|dt }}</td><td><strong>{{ row.user_name }}</strong></td><td>{{ row.series or row.title }}{% if row.series %}<small class="cell-sub">{{ row.title }}</small>{% endif %}</td><td>{{ row.device }}<small class="cell-sub">{{ row.client }}</small></td><td>{{ [row.city,row.country]|select|join(', ') }}<small class="cell-sub">{{ row.asn }}</small></td><td class="mono break">{{ row.ip }}</td><td>{{ row.bytes|bytes }}</td><td><span class="badge {{ 'active' if row.active else '' }}">{{ 'AKTIV' if row.active else 'BEENDET' }}</span></td></tr>{% else %}<tr><td colspan="8">Keine Sessions vorhanden.</td></tr>{% endfor %}
</tbody></table></div>
</section>
<section class="panel history-pane" data-pane="ips">
<div class="panel-head"><div><p class="eyebrow">CLIENT FORENSIK</p><h2>IP-History</h2></div><small>{{ history.ips|length }} Einträge</small></div>
<div class="table-wrap"><table><thead><tr><th>Zuletzt</th><th>Benutzer</th><th>IP</th><th>Gerät</th><th>Standort / ASN</th><th>Klasse</th><th>Sichtungen</th></tr></thead><tbody>
{% for row in history.ips %}<tr class="filter-row"><td class="nowrap">{{ row.last_seen|dt }}<small class="cell-sub">zuerst {{ row.first_seen|dt }}</small></td><td><strong>{{ row.user_name }}</strong></td><td class="mono break">{{ row.ip }}</td><td>{{ row.device }}<small class="cell-sub">{{ row.client }}</small></td><td>{{ [row.city,row.country]|select|join(', ') }}<small class="cell-sub">{{ row.asn }}</small></td><td><span class="badge">{{ row.last_classification }}</span></td><td>{{ row.sightings }}<small class="cell-sub">{{ row.limited_sightings }} limitiert</small></td></tr>{% else %}<tr><td colspan="7">Keine IP-History vorhanden.</td></tr>{% endfor %}
</tbody></table></div>
</section>
<section class="panel history-pane" data-pane="policy">
<div class="panel-head"><div><p class="eyebrow">POLICY AUDIT</p><h2>Blocks & Ereignisse</h2></div><small>{{ history.blocks|length }} Blocks · {{ history.events|length }} Events</small></div>
<div class="grid two history-split">
<div class="table-wrap"><table><thead><tr><th>Zeit</th><th>Benutzer / IP</th><th>Grund</th><th>Status</th></tr></thead><tbody>
{% for row in history.blocks %}<tr class="filter-row"><td class="nowrap">{{ row.blocked_at|dt }}</td><td><strong>{{ row.user_name or '—' }}</strong><small class="cell-sub mono">{{ row.ip }}</small></td><td>{{ row.reason }}</td><td><span class="badge {{ 'danger' if row.active else '' }}">{{ 'AKTIV' if row.active else 'ABGELAUFEN' }}</span></td></tr>{% else %}<tr><td colspan="4">Keine Blocks.</td></tr>{% endfor %}
</tbody></table></div>
<div class="event-list">{% for row in history.events %}<div class="event filter-row"><span class="event-kind">{{ row.kind }}</span><div><strong>{{ row.user_name or 'System' }}</strong><small>{{ row.ts|dt }} · {{ row.detail }}</small></div></div>{% else %}<p class="muted">Keine Policy-Ereignisse.</p>{% endfor %}</div>
</div>
</section>
<script>
(()=>{const tabs=[...document.querySelectorAll('.history-tab')],panes=[...document.querySelectorAll('.history-pane')],search=document.querySelector('#history-search');function select(name){tabs.forEach(x=>x.classList.toggle('active',x.dataset.tab===name));panes.forEach(x=>x.classList.toggle('active',x.dataset.pane===name));location.hash=name==='control'?'':name}tabs.forEach(x=>x.addEventListener('click',()=>select(x.dataset.tab)));search.addEventListener('input',()=>{const q=search.value.toLocaleLowerCase('de');document.querySelectorAll('.filter-row').forEach(r=>r.hidden=q&&!r.textContent.toLocaleLowerCase('de').includes(q))});const initial=location.hash.slice(1);if(tabs.some(x=>x.dataset.tab===initial))select(initial)})();
</script>
{% endblock %}