diff --git a/infra/debug-dashboard/app.py b/infra/debug-dashboard/app.py index f1b13f6..07a437b 100644 --- a/infra/debug-dashboard/app.py +++ b/infra/debug-dashboard/app.py @@ -2,6 +2,7 @@ import hashlib import hmac import json import os +from datetime import datetime, timezone import psycopg from flask import Flask, abort, redirect, render_template, request, url_for @@ -9,6 +10,9 @@ from flask import Flask, abort, redirect, render_template, request, url_for app = Flask(__name__) +CURRENT_EVENT_HOURS = 24 +STALE_REPORT_HOURS = 36 + def db_connection(): return psycopg.connect( @@ -32,6 +36,39 @@ def require_csrf(): abort(400) +def event_metadata(payload): + latest_event = None + for event in (payload or {}).get("Events", []): + value = event.get("Timestamp") + if not value: + continue + try: + timestamp = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + continue + if timestamp.tzinfo is None: + timestamp = timestamp.replace(tzinfo=timezone.utc) + if latest_event is None or timestamp > latest_event: + latest_event = timestamp + + if latest_event is None: + return {"is_current": False, "label": "keine Ereignisse", "timestamp": None} + + age_seconds = max(0, int((datetime.now(timezone.utc) - latest_event.astimezone(timezone.utc)).total_seconds())) + if age_seconds < 3600: + age_label = f"vor {max(1, age_seconds // 60)} Min." + elif age_seconds < 86400: + age_label = f"vor {age_seconds // 3600} Std." + else: + age_label = f"vor {age_seconds // 86400} Tg." + + return { + "is_current": age_seconds <= CURRENT_EVENT_HOURS * 3600, + "label": age_label, + "timestamp": latest_event, + } + + @app.get("/") def overview(): with db_connection() as connection, connection.cursor() as cursor: @@ -41,11 +78,12 @@ def overview(): cursor.execute( """ SELECT machine_name, organization_name, received_at, alert_state, - total_events, unique_ip_count, cve_total, cve_critical + total_events, unique_ip_count, cve_total, cve_critical, payload FROM ( SELECT machine_name, received_at, alert_state, total_events, unique_ip_count, cve_total, cve_critical, - payload #>> '{NinjaOne,OrganizationName}' AS organization_name + payload #>> '{NinjaOne,OrganizationName}' AS organization_name, + payload FROM ocsentinel.current_device_status ) AS status ORDER BY received_at DESC NULLS LAST @@ -54,6 +92,16 @@ def overview(): ) reports = cursor.fetchall() + cursor.execute( + """ + SELECT count(*) AS known_devices, + count(*) FILTER (WHERE received_at >= now() - interval '36 hours') AS reporting_devices, + count(*) FILTER (WHERE received_at IS NULL OR received_at < now() - interval '36 hours') AS stale_devices + FROM ocsentinel.current_device_status + """ + ) + coverage = cursor.fetchone() + cursor.execute( """ SELECT machine_name, alert_state, total_events, unique_ip_count, @@ -65,7 +113,43 @@ def overview(): ) alerts = cursor.fetchall() - return render_template("overview.html", summary=summary, reports=reports, alerts=alerts) + report_rows = [] + for row in reports: + event = event_metadata(row[8]) + report_rows.append( + { + "machine_name": row[0], + "organization_name": row[1], + "received_at": row[2], + "alert_state": row[3], + "total_events": row[4], + "unique_ip_count": row[5], + "event": event, + } + ) + + alert_rows = [] + for row in alerts: + event = event_metadata(row[5]) + alert_rows.append( + { + "machine_name": row[0], + "alert_state": row[1], + "total_events": row[2], + "unique_ip_count": row[3], + "received_at": row[4], + "event": event, + } + ) + + return render_template( + "overview.html", + summary=summary, + coverage=coverage, + reports=report_rows, + alerts=alert_rows, + current_alert_count=sum(alert["event"]["is_current"] for alert in alert_rows), + ) @app.get("/device/") @@ -87,7 +171,13 @@ def device(machine_name): abort(404) payload = report[12] - return render_template("device.html", report=report, payload=payload, payload_pretty=json.dumps(payload, indent=2, ensure_ascii=False)) + return render_template( + "device.html", + report=report, + event=event_metadata(payload), + payload=payload, + payload_pretty=json.dumps(payload, indent=2, ensure_ascii=False), + ) @app.get("/reports") diff --git a/infra/debug-dashboard/static/app.css b/infra/debug-dashboard/static/app.css index 5fb2c85..3c86d78 100644 --- a/infra/debug-dashboard/static/app.css +++ b/infra/debug-dashboard/static/app.css @@ -6,8 +6,9 @@ body { margin:0; color:var(--ink); background:radial-gradient(circle at 86% -10% main { max-width:1280px; margin:auto; padding:32px 6vw 80px; }.hero { max-width:760px; margin-bottom:32px; }.hero h1 { font-size:clamp(34px,5vw,64px); line-height:.98; letter-spacing:-.06em; margin:10px 0; }.hero p { color:var(--muted); font-size:18px; }.hero.compact h1 { font-size:48px; }.hero-note { display:flex; align-items:center; gap:8px; margin-top:20px; color:var(--green); font:700 11px 'Roboto',sans-serif; letter-spacing:.03em; }.hero-note span { width:8px; height:8px; border-radius:50%; background:var(--lime); box-shadow:0 0 0 4px rgba(199,238,107,.25); } .metrics { display:grid; grid-template-columns:repeat(5,1fr); gap:10px; margin:25px 0 46px; background:transparent; }.metrics article { min-height:130px; padding:20px; border:1px solid var(--line); border-radius:5px; background:var(--panel); box-shadow:0 5px 16px rgba(35,56,42,.035); transition:transform .18s ease,box-shadow .18s ease; }.metrics article:hover { transform:translateY(-3px); box-shadow:0 12px 24px rgba(35,56,42,.09); }.metrics span { display:block; color:var(--muted); font:700 10px 'Roboto',sans-serif; letter-spacing:.09em; text-transform:uppercase; }.metrics strong { display:block; margin-top:16px; font:700 31px 'Roboto',sans-serif; letter-spacing:-.05em; }.metrics .timestamp { font-size:14px; line-height:1.25; letter-spacing:-.02em; }.warning { color:var(--amber); }.critical { color:var(--red); } .situation { display:flex; align-items:center; justify-content:space-between; gap:22px; margin:0 0 24px; padding:20px 22px; border:1px solid #b9d8c2; background:#edf8f0; color:#195235; }.situation.warning { border-color:#f2cf99; background:#fff6e8; color:#80450d; }.situation.critical { border-color:#edb4aa; background:#fff0ed; color:#8a2a20; }.situation strong { display:block; margin-top:7px; font:700 19px/1.15 'Roboto',sans-serif; letter-spacing:-.025em; }.situation > span { padding:7px 9px; border:1px solid currentColor; border-radius:20px; font:700 10px 'Roboto',sans-serif; letter-spacing:.1em; } -.panel { margin-top:26px; padding:26px; background:var(--panel); border:1px solid var(--line); border-radius:5px; box-shadow:0 6px 18px rgba(35,56,42,.035); }.panel-heading h2 { margin:8px 0 22px; font-size:28px; letter-spacing:-.04em; }.alert-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(210px,1fr)); gap:12px; }.alert-card { padding:17px; border-left:5px solid var(--amber); border-radius:3px; background:#fff7e9; color:var(--ink); text-decoration:none; transition:transform .18s ease,box-shadow .18s ease; }.alert-card:hover { transform:translateY(-2px); box-shadow:0 9px 18px rgba(88,57,20,.12); }.alert-card.critical { border-color:var(--red); background:#fff0ed; }.alert-card span,.alert-card small { display:block; font:700 10px 'Roboto',sans-serif; letter-spacing:.08em; text-transform:uppercase; }.alert-card strong { display:block; margin:10px 0; font:700 22px 'Roboto',sans-serif; letter-spacing:-.04em; } -table { width:100%; border-collapse:collapse; font-family:'Roboto',sans-serif; font-size:13px; } th { text-align:left; color:var(--muted); font-size:10px; letter-spacing:.1em; text-transform:uppercase; } th,td { padding:13px 8px; border-bottom:1px solid var(--line); } td a { color:var(--green); font-weight:700; text-decoration:none; }.state { display:inline-block; padding:4px 7px; border-radius:12px; background:#e2efe6; color:var(--green); font:700 10px 'Roboto',sans-serif; text-transform:uppercase; }.state.warning { background:#fff0d7; color:var(--amber); }.state.critical { background:#ffe0db; color:var(--red); } pre { margin:0; padding:18px; overflow:auto; color:#dce7da; background:#13221b; border-radius:4px; font:12px/1.5 'Cascadia Code',Consolas,monospace; }.table-wrap { overflow:auto; } +.panel { margin-top:26px; padding:26px; background:var(--panel); border:1px solid var(--line); border-radius:5px; box-shadow:0 6px 18px rgba(35,56,42,.035); }.panel-heading h2 { margin:8px 0 22px; font-size:28px; letter-spacing:-.04em; }.panel-heading h2 small { color:var(--muted); font-size:12px; font-weight:500; letter-spacing:0; }.alert-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(210px,1fr)); gap:12px; }.alert-card { padding:17px; border-left:5px solid var(--amber); border-radius:3px; background:#fff7e9; color:var(--ink); text-decoration:none; transition:transform .18s ease,box-shadow .18s ease; }.alert-card:hover { transform:translateY(-2px); box-shadow:0 9px 18px rgba(88,57,20,.12); }.alert-card.critical { border-color:var(--red); background:#fff0ed; }.alert-card span,.alert-card small { display:block; font:700 10px 'Roboto',sans-serif; letter-spacing:.08em; text-transform:uppercase; }.alert-card strong { display:block; margin:10px 0; font:700 22px 'Roboto',sans-serif; letter-spacing:-.04em; } +.coverage-panel { padding-bottom:22px; }.coverage-metrics { display:grid; grid-template-columns:repeat(3,1fr); gap:10px; }.coverage-metrics article { padding:15px; border:1px solid var(--line); border-radius:4px; background:#faf9f4; }.coverage-metrics span { display:block; color:var(--muted); font:700 10px 'Roboto',sans-serif; letter-spacing:.08em; text-transform:uppercase; }.coverage-metrics strong { display:block; margin-top:8px; font-size:26px; }.ok { color:var(--green); } +table { width:100%; border-collapse:collapse; font-family:'Roboto',sans-serif; font-size:13px; } th { text-align:left; color:var(--muted); font-size:10px; letter-spacing:.1em; text-transform:uppercase; } th,td { padding:13px 8px; border-bottom:1px solid var(--line); } td a { color:var(--green); font-weight:700; text-decoration:none; }.state { display:inline-block; margin:1px 3px 1px 0; padding:4px 7px; border-radius:12px; background:#e2efe6; color:var(--green); font:700 10px 'Roboto',sans-serif; text-transform:uppercase; }.state.warning { background:#fff0d7; color:var(--amber); }.state.critical { background:#ffe0db; color:var(--red); }.state.current { background:#e2efe6; color:var(--green); }.state.historic { background:#ece9e1; color:#68736e; } pre { margin:0; padding:18px; overflow:auto; color:#dce7da; background:#13221b; border-radius:4px; font:12px/1.5 'Cascadia Code',Consolas,monospace; }.table-wrap { overflow:auto; } .report-frame { background:#fff; border:1px solid var(--line); box-shadow:0 12px 40px rgba(20,35,27,.1); } .panel-heading p:last-child { max-width:720px; margin:-13px 0 20px; color:var(--muted); font-size:14px; }.calm-panel { border-color:#b9d8c2; background:#f4fbf5; } .recipient-form { display:grid; grid-template-columns:minmax(220px,1fr) minmax(260px,1fr) auto; gap:14px; align-items:end; }.recipient-form label { display:grid; gap:6px; color:var(--muted); font:700 10px 'Roboto',sans-serif; letter-spacing:.08em; text-transform:uppercase; }.recipient-form input,.recipient-form select { min-height:40px; padding:9px 10px; border:1px solid var(--line); border-radius:4px; background:#fff; color:var(--ink); font:14px 'Roboto',sans-serif; }.recipient-form button,.rule-actions button { min-height:40px; padding:9px 13px; border:1px solid var(--green); border-radius:4px; background:var(--green); color:#fff; cursor:pointer; font:700 12px 'Roboto',sans-serif; }.rule-actions { display:flex; gap:8px; }.rule-actions form { margin:0; }.rule-actions .button-secondary { border-color:#d8d4c6; background:#fffdf7; color:var(--ink); }.rule-actions .button-danger { border-color:#e3afa7; background:#fff0ed; color:#8a2a20; } diff --git a/infra/debug-dashboard/templates/device.html b/infra/debug-dashboard/templates/device.html index 4113041..cd3d305 100644 --- a/infra/debug-dashboard/templates/device.html +++ b/infra/debug-dashboard/templates/device.html @@ -1,7 +1,7 @@ {% extends "base.html" %} {% block title %}{{ report[0] }} - OC Sentinel{% endblock %} {% block content %} -

{{ report[0] }}

{{ report[6] }}
+

{{ report[0] }}

{{ report[6] }}{% if report[8] %}{{ 'aktuell' if event.is_current else 'historisch' }}: {{ event.label }}{% endif %}
Ereignisse{{ report[8] }}
Quell-IPs{{ report[9] }}
CVEs{{ report[10] }}
Kritische CVEs{{ report[11] }}
{{ payload_pretty }}
{% endblock %} diff --git a/infra/debug-dashboard/templates/overview.html b/infra/debug-dashboard/templates/overview.html index d2678cc..e8a9c28 100644 --- a/infra/debug-dashboard/templates/overview.html +++ b/infra/debug-dashboard/templates/overview.html @@ -13,13 +13,18 @@
Letzte Meldung{{ summary[7] or '-' }}
+
+

Geraeteabdeckung

+
Bekannt{{ coverage[0] }}
Meldend < 36 Std.{{ coverage[1] }}
Stumm > 36 Std.{{ coverage[2] }}
+
+ {% if alerts %}
-

Auffaellige Geraete

+

Auffaellige Geraete {{ current_alert_count }} aktuell, {{ alerts|length - current_alert_count }} historisch

@@ -29,6 +34,6 @@

Geraetestatus

- {% for row in reports %}{% else %}{% endfor %}
GeraetOrganisationStatusEreignisseQuell-IPsEmpfangen
{{ row[0] }}{{ row[1] or '-' }}{{ row[3] }}{{ row[4] }}{{ row[5] }}{{ row[2] or '-' }}
Keine Geraeteberichte.
+ {% for row in reports %}{{ row.machine_name }}{{ row.organization_name or '-' }}{{ row.alert_state }}{% if row.total_events %}{{ 'aktuell' if row.event.is_current else 'historisch' }}{% endif %}{{ row.total_events }}{{ row.unique_ip_count }}{{ row.received_at or '-' }}{% else %}Keine Geraeteberichte.{% endfor %}
{% endblock %}