Compare commits

...
Sign in to create a new pull request.

8 commits

6 changed files with 232 additions and 44 deletions

View file

@ -33,15 +33,60 @@ def rate_map():
found[target] = value found[target] = value
return found, rc 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(): def state():
c = db() c = db()
limits = {r["target"]: r["limit_mbit"] for r in c.execute("SELECT target,limit_mbit FROM global_limits")} 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")} 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] 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")] 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] 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() 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 = {} ipsets = {}
for name in ("geo-a-v4", "geo-a-v6", "cloud-v4", "cloud-v6", "persist-v4", "persist-v6"): 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"]) rc, out, _ = run(["sudo", "ipset", "list", name, "-t"])
@ -54,8 +99,9 @@ def state():
rc, rules, _ = run(["sudo", "iptables", "-t", "mangle", "-S"]) rc, rules, _ = run(["sudo", "iptables", "-t", "mangle", "-S"])
return { return {
"ok": tc_rc == 0 and all(v == "active" for k, v in services.items() if k != "netqos"), "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, "ts": int(time.time()), "host": host_metrics(), "limits": limits, "tc": tc, "tc_detail": tc_detail, "ipsets": ipsets,
"services": services, "active_sessions": active_sessions, "active_blocks": active_blocks, "services": services, "active_sessions": active_sessions, "sessions": session_rows,
"active_blocks": active_blocks, "totals": totals,
"policy": { "policy": {
"enabled": settings.get("multisession_enabled", "0") == "1", "enabled": settings.get("multisession_enabled", "0") == "1",
"mode": settings.get("multisession_mode", "monitor"), "mode": settings.get("multisession_mode", "monitor"),
@ -67,6 +113,29 @@ def state():
"recent_events": recent, "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): def set_limits(ch, vpn):
values = {"ch": float(ch), "vpn": float(vpn)} values = {"ch": float(ch), "vpn": float(vpn)}
if any(not 0.1 <= value <= 100 for value in values.values()): if any(not 0.1 <= value <= 100 for value in values.values()):
@ -133,6 +202,8 @@ def main():
if not argv: argv = ["state"] if not argv: argv = ["state"]
if argv == ["state"]: if argv == ["state"]:
result = state() result = state()
elif argv == ["history"]:
result = history()
elif len(argv) == 3 and argv[0] == "set-limits": elif len(argv) == 3 and argv[0] == "set-limits":
result = set_limits(argv[1], argv[2]) result = set_limits(argv[1], argv[2])
elif argv[0] == "set-policy" and 2 <= len(argv) <= 4: 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.""" """Network profile control surface; backend access is restricted to a forced SSH command."""
from __future__ import annotations 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 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 = Flask(__name__)
app.secret_key = os.environ["SESSION_SECRET"] app.secret_key = os.environ["SESSION_SECRET"]
app.config.update(SESSION_COOKIE_HTTPONLY=True, SESSION_COOKIE_SAMESITE="Strict", SESSION_COOKIE_SECURE=True) app.config.update(SESSION_COOKIE_HTTPONLY=True, SESSION_COOKIE_SAMESITE="Strict", SESSION_COOKIE_SECURE=True)
@ -15,11 +20,25 @@ PROFILES = {
} }
_CACHE = {"at": 0.0, "data": None} _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): def rpc(command: str, timeout: int = 25):
p = subprocess.run( try:
["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=8", "edge-maint", command], p = subprocess.run(["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=8", "edge-maint", command], capture_output=True, text=True, timeout=timeout)
capture_output=True, text=True, timeout=timeout,
)
raw = p.stdout.strip() raw = p.stdout.strip()
try: try:
data = json.loads(raw) if raw else {} data = json.loads(raw) if raw else {}
@ -28,15 +47,15 @@ def rpc(command: str, timeout: int = 25):
if p.returncode and data.get("ok") is not False: 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]} data = {"ok": False, "error": (p.stderr or raw or f"rpc exit {p.returncode}")[:300]}
return data return data
except Exception as exc:
return {"ok": False, "error": str(exc)[:300]}
def get_state(force=False): def get_state(force=False):
now = time.monotonic() now = time.monotonic()
if not force and _CACHE["data"] is not None and now - _CACHE["at"] < 3: if not force and _CACHE["data"] is not None and now - _CACHE["at"] < 3:
return _CACHE["data"] return _CACHE["data"]
try:
data = rpc("state") data = rpc("state")
except Exception as exc: data.setdefault("limits", {}); data.setdefault("tc", {}); data.setdefault("services", {}); data.setdefault("policy", {}); data.setdefault("ipsets", {})
data = {"ok": False, "error": str(exc)[:300], "limits": {}, "tc": {}, "services": {}, "policy": {}, "ipsets": {}}
_CACHE.update(at=now, data=data) _CACHE.update(at=now, data=data)
return data return data
@ -48,6 +67,26 @@ def csrf_token():
app.jinja_env.globals["csrf_token"] = 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 @app.before_request
def protect_post(): def protect_post():
if request.method == "POST" and not secrets.compare_digest(request.form.get("csrf", ""), session.get("csrf", "invalid")): 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("/") @app.get("/")
def dashboard(): def dashboard():
state = get_state() return render_template("dashboard.html", state=get_state(), profiles=PROFILES, page="dashboard")
return render_template("dashboard.html", state=state, profiles=PROFILES)
@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>") @app.post("/profile/<name>")
def apply_profile(name): def apply_profile(name):
profile = PROFILES.get(name) profile = PROFILES.get(name)
if not profile: if not profile:
flash("Unbekanntes Profil", "error") flash("Unbekanntes Profil", "error"); return redirect(url_for("dashboard"))
return redirect(url_for("dashboard"))
result = rpc(f"set-limits {profile['ch']} {profile['vpn']}") 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 _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") 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")) return redirect(url_for("dashboard"))
@ -73,12 +121,11 @@ def apply_profile(name):
def custom_limits(): def custom_limits():
try: try:
ch = float(request.form["ch"]); vpn = float(request.form["vpn"]) ch = float(request.form["ch"]); vpn = float(request.form["vpn"])
if not 0.1 <= ch <= 100 or not 0.1 <= vpn <= 100: if not 0.1 <= ch <= 100 or not 0.1 <= vpn <= 100: raise ValueError
raise ValueError
except (KeyError, ValueError): except (KeyError, ValueError):
flash("Limits müssen zwischen 0,1 und 100 Mbit liegen", "error") flash("Limits müssen zwischen 0,1 und 100 Mbit liegen", "error"); return redirect(url_for("dashboard"))
return redirect(url_for("dashboard"))
result = rpc(f"set-limits {ch:g} {vpn:g}") result = rpc(f"set-limits {ch:g} {vpn:g}")
audit("limits", f"Region {ch:g} / Cloud {vpn:g} Mbit", result)
_CACHE["data"] = None _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") 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")) return redirect(url_for("dashboard"))
@ -87,35 +134,29 @@ def custom_limits():
def policy(): def policy():
mode = request.form.get("mode", "") mode = request.form.get("mode", "")
try: try:
min_sessions = int(request.form.get("min_sessions", "2")) min_sessions = int(request.form.get("min_sessions", "2")); block_minutes = int(request.form.get("block_minutes", "10"))
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
if mode not in {"off", "monitor", "block"} or not 2 <= min_sessions <= 8 or not 1 <= block_minutes <= 1440:
raise ValueError
except ValueError: except ValueError:
flash("Ungültige Policy-Werte", "error") flash("Ungültige Policy-Werte", "error"); return redirect(url_for("dashboard"))
return redirect(url_for("dashboard"))
result = rpc(f"set-policy {mode} {min_sessions} {block_minutes}") 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 _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") 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")) return redirect(url_for("dashboard"))
@app.post("/maintenance/<action>") @app.post("/maintenance/<action>")
def maintenance(action): def maintenance(action):
mapping = { mapping = {"refresh": "limiter refresh", "start": "limiter start", "stop": "limiter stop", "worker": "restart worker", "admin": "restart admin"}
"refresh": "limiter refresh", "start": "limiter start", "stop": "limiter stop",
"worker": "restart worker", "admin": "restart admin",
}
command = mapping.get(action) command = mapping.get(action)
if not command: if not command: return "unknown action", 404
return "unknown action", 404
result = rpc(command) result = rpc(command)
audit("maintenance", action, result)
_CACHE["data"] = None _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") 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")) return redirect(url_for("dashboard"))
@app.get("/api/status") @app.get("/api/status")
def api_status(): def api_status(): return jsonify(get_state())
return jsonify(get_state())
@app.get("/health") @app.get("/health")
def 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)} .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%}} @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"> <aside class="sidebar">
<a class="brand" href="/"><span class="brand-mark">◈</span><span>Stream<span>Scope</span></span></a> <a class="brand" href="/"><span class="brand-mark">◈</span><span>Stream<span>Scope</span></span></a>
<p class="nav-label">CONTROL</p> <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> <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> </aside>
<header class="mobile-head"><a class="brand" href="/"><span class="brand-mark">◈</span><span>Stream<span>Scope</span></span></a></header> <header class="mobile-head"><a class="brand" href="/"><span class="brand-mark">◈</span><span>Stream<span>Scope</span></span></a></header>
<main> <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 %} {% 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 %} {% block content %}{% endblock %}
</main> </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> <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> </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> <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"> <div class="profile-grid control-profiles">
{% for key,p in profiles.items() %} {% 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>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>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> <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> </tbody></table></div>
<div class="action-row"> <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 %} {% 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 %}