feat: alerts view + created-at timestamps on decisions
All checks were successful
Deploy / deploy (push) Successful in 22s
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:
84
app/app.py
84
app/app.py
@@ -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()
|
||||||
|
|||||||
84
app/templates/_alerts.html
Normal file
84
app/templates/_alerts.html
Normal 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 %}
|
||||||
@@ -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');
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user