feat: alerts view + created-at timestamps on decisions
All checks were successful
Deploy / deploy (push) Successful in 22s

Mirror cscli alerts list: new /alerts endpoint hits LAPI machine auth
with since/ip/scenario/origin filters, renders ID, scope:value, reason,
country, AS, events, decisions, created_at.

Decisions table gains a Created column derived from until - duration
(LAPI does not expose created_at on /v1/decisions). Both views format
timestamps locally and append a relative "x ago" via Intl.RelativeTimeFormat,
refreshed every 30s on the decisions view.
This commit is contained in:
2026-06-21 15:13:14 +02:00
parent 13e672c18d
commit 708f6373f1
4 changed files with 242 additions and 3 deletions

View File

@@ -1,7 +1,9 @@
import os
import re
import time
import ipaddress
import logging
from datetime import datetime, timedelta, timezone
import requests
from flask import Flask, render_template, request, jsonify, abort
@@ -42,6 +44,52 @@ def _jwt():
return _token["jwt"]
_GO_DURATION_RE = re.compile(
r"^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+(?:\.\d+)?)s)?(?:(\d+(?:\.\d+)?)ms)?(?:(\d+(?:\.\d+)?)(?:us|µs))?(?:(\d+)ns)?$"
)
def _parse_go_duration(s):
if not s:
return None
m = _GO_DURATION_RE.match(s.strip())
if not m or not any(m.groups()):
return None
h, mn, sc, ms, us, ns = m.groups()
return timedelta(
hours=int(h or 0),
minutes=int(mn or 0),
seconds=float(sc or 0),
milliseconds=float(ms or 0),
microseconds=float(us or 0) + (float(ns or 0) / 1000.0),
)
def _parse_iso(s):
if not s:
return None
# CrowdSec emits RFC3339 w/ nanos; Python fromisoformat tolerates up to micros only.
s = s.strip()
if s.endswith("Z"):
s = s[:-1] + "+00:00"
# Trim fractional seconds to 6 digits.
s = re.sub(r"(\.\d{6})\d+", r"\1", s)
try:
return datetime.fromisoformat(s)
except ValueError:
return None
def _enrich(decisions):
for d in decisions:
until = _parse_iso(d.get("until"))
dur = _parse_go_duration(d.get("duration"))
if until and dur:
created = until - dur
d["created_at"] = created.astimezone(timezone.utc).isoformat()
return decisions
def _bouncer(method, path, **kw):
headers = kw.pop("headers", {})
headers["X-Api-Key"] = BOUNCER_KEY
@@ -114,10 +162,44 @@ def list_decisions():
r = _bouncer("GET", "/v1/decisions", params=params)
if r.status_code != 200:
return render_template("_decisions.html", error=f"LAPI {r.status_code}: {r.text[:200]}", decisions=[], total=0, limit=limit), 502
decisions = r.json() or []
decisions = _enrich(r.json() or [])
return render_template("_decisions.html", decisions=decisions, error=None, total=len(decisions), limit=limit)
ALERT_SINCE_DEFAULT = "1h"
ALERT_SINCE_ALLOWED = {"15m", "1h", "6h", "24h", "168h", "720h"}
@app.get("/alerts")
def list_alerts():
since = request.args.get("since", ALERT_SINCE_DEFAULT).strip()
if since not in ALERT_SINCE_ALLOWED:
since = ALERT_SINCE_DEFAULT
ip = request.args.get("ip", "").strip()
scenario = request.args.get("scenario", "").strip()
origin = request.args.get("origin", "").strip()
try:
limit = max(1, min(MAX_LIMIT, int(request.args.get("limit", DEFAULT_LIMIT))))
except ValueError:
limit = DEFAULT_LIMIT
params = {"limit": limit, "since": since}
if ip:
if not valid_ip(ip):
return render_template("_alerts.html", error="invalid IP", alerts=[], total=0, limit=limit, since=since), 400
params["ip"] = ip
if scenario:
params["scenario"] = scenario
if origin:
params["origin"] = origin
r = _machine("GET", "/v1/alerts", params=params)
if r.status_code != 200:
return render_template("_alerts.html", error=f"LAPI {r.status_code}: {r.text[:200]}", alerts=[], total=0, limit=limit, since=since), 502
alerts = r.json() or []
return render_template("_alerts.html", alerts=alerts, error=None, total=len(alerts), limit=limit, since=since)
@app.post("/unban")
def unban():
ip = request.form.get("ip", "").strip()