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 os
import re
import time import time
import ipaddress import ipaddress
import logging import logging
from datetime import datetime, timedelta, timezone
import requests import requests
from flask import Flask, render_template, request, jsonify, abort from flask import Flask, render_template, request, jsonify, abort
@@ -42,6 +44,52 @@ def _jwt():
return _token["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): def _bouncer(method, path, **kw):
headers = kw.pop("headers", {}) headers = kw.pop("headers", {})
headers["X-Api-Key"] = BOUNCER_KEY headers["X-Api-Key"] = BOUNCER_KEY
@@ -114,10 +162,44 @@ def list_decisions():
r = _bouncer("GET", "/v1/decisions", params=params) r = _bouncer("GET", "/v1/decisions", params=params)
if r.status_code != 200: 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 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) 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") @app.post("/unban")
def unban(): def unban():
ip = request.form.get("ip", "").strip() ip = request.form.get("ip", "").strip()

View File

@@ -0,0 +1,84 @@
{% if error %}
<p class="err">{{ error }}</p>
{% endif %}
{% if alerts %}
<p style="font-size:.8rem;color:#888;margin:.4rem 0;">
Showing <strong>{{ total }}</strong> alert{{ '' if total == 1 else 's' }}
since <code>{{ since }}</code>
(server limit {{ limit }}{% if total == limit %} — at cap, raise limit if more expected{% endif %}).
</p>
<table>
<thead>
<tr>
<th>ID</th>
<th>Scope:Value</th>
<th>Reason</th>
<th>Country</th>
<th>AS</th>
<th>Events</th>
<th>Decisions</th>
<th>Created</th>
</tr>
</thead>
<tbody>
{% for a in alerts %}
{% set src = a.source or {} %}
<tr>
<td><code>{{ a.id }}</code></td>
<td><code>{{ src.scope or a.scope or '?' }}:{{ src.value or a.value or '?' }}</code></td>
<td>
{{ a.scenario }}
{% if a.events_count and a.events_count > 1 %}<span class="pill">×{{ a.events_count }}</span>{% endif %}
</td>
<td>{{ src.cn or '—' }}</td>
<td>{% if src.as_number %}{{ src.as_number }}{% if src.as_name %} {{ src.as_name }}{% endif %}{% else %}—{% endif %}</td>
<td>{{ a.events_count or 0 }}</td>
<td>
{% if a.decisions %}
{% for dec in a.decisions %}
<span class="pill" title="origin={{ dec.origin }} until={{ dec.until }}">{{ dec.type }} {{ dec.duration }}{% if dec.simulated %} sim{% endif %}</span>
{% endfor %}
{% else %}
{% endif %}
</td>
<td><time class="ts" datetime="{{ a.created_at }}">{{ a.created_at }}</time></td>
</tr>
{% endfor %}
</tbody>
</table>
<script>
(function(){
const RTF = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' });
const FMT = new Intl.DateTimeFormat(undefined, {
year: 'numeric', month: 'short', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit',
hour12: false
});
const UNITS = [['year',31536000],['month',2592000],['day',86400],['hour',3600],['minute',60],['second',1]];
function relative(target, now){
const diff = (target - now) / 1000;
const abs = Math.abs(diff);
for (const [u, s] of UNITS) {
if (abs >= s || u === 'second') return RTF.format(Math.round(diff / s), u);
}
}
const now = new Date();
document.querySelectorAll('#alerts time.ts').forEach(t => {
const iso = t.getAttribute('datetime');
if (!iso) return;
const d = new Date(iso);
if (isNaN(d)) return;
t.textContent = FMT.format(d);
t.title = iso + ' (' + relative(d, now) + ')';
const small = document.createElement('small');
small.style.color = '#888';
small.style.marginLeft = '.35rem';
small.textContent = relative(d, now);
t.appendChild(small);
});
})();
</script>
{% else %}
{% if not error %}<p>No alerts in window.</p>{% endif %}
{% endif %}

View File

@@ -16,7 +16,7 @@
<thead> <thead>
<tr> <tr>
<th><input type="checkbox" id="sel-all"></th> <th><input type="checkbox" id="sel-all"></th>
<th>ID</th><th>IP / value</th><th>Scope</th><th>Type</th><th>Reason</th><th>Until</th><th>Origin</th><th></th> <th>ID</th><th>IP / value</th><th>Scope</th><th>Type</th><th>Reason</th><th>Created</th><th>Until</th><th>Origin</th><th></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -28,7 +28,8 @@
<td>{{ d.scope }}</td> <td>{{ d.scope }}</td>
<td>{{ d.type }}</td> <td>{{ d.type }}</td>
<td>{{ d.scenario }}</td> <td>{{ d.scenario }}</td>
<td>{{ d.until }}</td> <td><time class="ts" datetime="{{ d.created_at }}">{{ d.created_at or '—' }}</time></td>
<td><time class="ts" datetime="{{ d.until }}">{{ d.until }}</time></td>
<td>{{ d.origin }}</td> <td>{{ d.origin }}</td>
<td> <td>
<button class="danger" type="button" <button class="danger" type="button"
@@ -42,6 +43,47 @@
</table> </table>
</form> </form>
<script> <script>
(function(){
const RTF = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' });
const FMT = new Intl.DateTimeFormat(undefined, {
year: 'numeric', month: 'short', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit',
hour12: false
});
const UNITS = [
['year', 31536000],
['month', 2592000],
['day', 86400],
['hour', 3600],
['minute', 60],
['second', 1],
];
function relative(target, now){
const diff = (target - now) / 1000;
const abs = Math.abs(diff);
for (const [u, s] of UNITS) {
if (abs >= s || u === 'second') return RTF.format(Math.round(diff / s), u);
}
}
function render(){
const now = new Date();
document.querySelectorAll('#decisions time.ts').forEach(t => {
const iso = t.getAttribute('datetime');
if (!iso) return;
const d = new Date(iso);
if (isNaN(d)) return;
t.textContent = FMT.format(d);
t.title = iso + ' (' + relative(d, now) + ')';
const small = document.createElement('small');
small.style.color = '#888';
small.style.marginLeft = '.35rem';
small.textContent = relative(d, now);
t.appendChild(small);
});
}
render();
setInterval(render, 30000);
})();
(function(){ (function(){
const form = document.getElementById('bulk-form'); const form = document.getElementById('bulk-form');
const all = document.getElementById('sel-all'); const all = document.getElementById('sel-all');

View File

@@ -39,6 +39,37 @@
</div> </div>
<div id="unban-me-result"></div> <div id="unban-me-result"></div>
<section>
<h2>Recent alerts</h2>
<form hx-get="/alerts" hx-target="#alerts" hx-trigger="submit, load delay:200ms" hx-swap="innerHTML" hx-indicator="#alerts" class="row">
<select name="since">
<option value="15m">15m</option>
<option value="1h" selected>1h</option>
<option value="6h">6h</option>
<option value="24h">24h</option>
<option value="168h">7d</option>
<option value="720h">30d</option>
</select>
<input type="text" name="ip" placeholder="IP (optional)">
<input type="text" name="scenario" placeholder="Scenario (optional)">
<select name="origin">
<option value="">any origin</option>
<option value="crowdsec">crowdsec</option>
<option value="cscli">cscli</option>
<option value="CAPI">CAPI</option>
<option value="lists">lists</option>
</select>
<select name="limit">
<option value="100">100</option>
<option value="200" selected>200</option>
<option value="500">500</option>
<option value="1000">1000</option>
</select>
<button type="submit">Search</button>
</form>
<div id="alerts">Loading…</div>
</section>
<section> <section>
<h2>Active decisions</h2> <h2>Active decisions</h2>
<form hx-get="/decisions" hx-target="#decisions" hx-trigger="submit, load delay:200ms" hx-swap="innerHTML" hx-indicator="#decisions" class="row"> <form hx-get="/decisions" hx-target="#decisions" hx-trigger="submit, load delay:200ms" hx-swap="innerHTML" hx-indicator="#decisions" class="row">