Show event freshness and device coverage
This commit is contained in:
@@ -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/<machine_name>")
|
||||
@@ -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")
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ report[0] }} - OC Sentinel{% endblock %}
|
||||
{% block content %}
|
||||
<section class="panel"><div class="panel-heading"><h2>{{ report[0] }}</h2><span class="state {{ report[6] }}">{{ report[6] }}</span></div></section>
|
||||
<section class="panel"><div class="panel-heading"><h2>{{ report[0] }}</h2><span class="state {{ report[6] }}">{{ report[6] }}</span>{% if report[8] %}<span class="state {{ 'current' if event.is_current else 'historic' }}">{{ 'aktuell' if event.is_current else 'historisch' }}: {{ event.label }}</span>{% endif %}</div></section>
|
||||
<section class="metrics compact-metrics"><article><span>Ereignisse</span><strong>{{ report[8] }}</strong></article><article><span>Quell-IPs</span><strong>{{ report[9] }}</strong></article><article><span>CVEs</span><strong>{{ report[10] }}</strong></article><article><span>Kritische CVEs</span><strong class="critical">{{ report[11] }}</strong></article></section>
|
||||
<section class="panel"><pre>{{ payload_pretty }}</pre></section>
|
||||
{% endblock %}
|
||||
|
||||
@@ -13,13 +13,18 @@
|
||||
<article><span>Letzte Meldung</span><strong class="timestamp">{{ summary[7] or '-' }}</strong></article>
|
||||
</section>
|
||||
|
||||
<section class="panel coverage-panel">
|
||||
<div class="panel-heading"><h2>Geraeteabdeckung</h2></div>
|
||||
<div class="coverage-metrics"><article><span>Bekannt</span><strong>{{ coverage[0] }}</strong></article><article><span>Meldend < 36 Std.</span><strong class="ok">{{ coverage[1] }}</strong></article><article><span>Stumm > 36 Std.</span><strong class="{% if coverage[2] %}warning{% endif %}">{{ coverage[2] }}</strong></article></div>
|
||||
</section>
|
||||
|
||||
{% if alerts %}
|
||||
<section class="panel alert-panel">
|
||||
<div class="panel-heading"><h2>Auffaellige Geraete</h2></div>
|
||||
<div class="panel-heading"><h2>Auffaellige Geraete <small>{{ current_alert_count }} aktuell, {{ alerts|length - current_alert_count }} historisch</small></h2></div>
|
||||
<div class="alert-grid">
|
||||
{% for alert in alerts %}
|
||||
<a class="alert-card {{ alert[1] }}" href="{{ url_for('device', machine_name=alert[0]) }}">
|
||||
<span>{{ alert[1] }}</span><strong>{{ alert[0] }}</strong><small>{{ alert[2] }} Ereignisse | {{ alert[3] }} Quell-IPs | {{ alert[4] }}</small>
|
||||
<a class="alert-card {{ alert.alert_state }}" href="{{ url_for('device', machine_name=alert.machine_name) }}">
|
||||
<span>{{ alert.alert_state }} | {{ 'aktuell' if alert.event.is_current else 'historisch' }}</span><strong>{{ alert.machine_name }}</strong><small>{{ alert.total_events }} Ereignisse | {{ alert.unique_ip_count }} Quell-IPs | letztes Ereignis {{ alert.event.label }}</small>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
@@ -29,6 +34,6 @@
|
||||
<section class="panel">
|
||||
<div class="panel-heading"><h2>Geraetestatus</h2></div>
|
||||
<div class="table-wrap"><table><thead><tr><th>Geraet</th><th>Organisation</th><th>Status</th><th>Ereignisse</th><th>Quell-IPs</th><th>Empfangen</th></tr></thead>
|
||||
<tbody>{% for row in reports %}<tr><td><a href="{{ url_for('device', machine_name=row[0]) }}">{{ row[0] }}</a></td><td>{{ row[1] or '-' }}</td><td><span class="state {{ row[3] }}">{{ row[3] }}</span></td><td>{{ row[4] }}</td><td>{{ row[5] }}</td><td>{{ row[2] or '-' }}</td></tr>{% else %}<tr><td colspan="6">Keine Geraeteberichte.</td></tr>{% endfor %}</tbody></table></div>
|
||||
<tbody>{% for row in reports %}<tr><td><a href="{{ url_for('device', machine_name=row.machine_name) }}">{{ row.machine_name }}</a></td><td>{{ row.organization_name or '-' }}</td><td><span class="state {{ row.alert_state }}">{{ row.alert_state }}</span>{% if row.total_events %}<span class="state {{ 'current' if row.event.is_current else 'historic' }}">{{ 'aktuell' if row.event.is_current else 'historisch' }}</span>{% endif %}</td><td>{{ row.total_events }}</td><td>{{ row.unique_ip_count }}</td><td>{{ row.received_at or '-' }}</td></tr>{% else %}<tr><td colspan="6">Keine Geraeteberichte.</td></tr>{% endfor %}</tbody></table></div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user