feat: server-side reason/origin/limit filters + client-side row filter
All checks were successful
Deploy / deploy (push) Successful in 17s
All checks were successful
Deploy / deploy (push) Successful in 17s
Two new performance + UX wins: - Server: /decisions now accepts reason (scenarios_containing), origin (origins), and limit. Default 200, max 2000. Header shows current count and warns when at the cap. - Client: a "quick filter rows" input does substring match across all visible columns instantly with no server round-trip. Useful when the server-side result is small enough to scroll but you still want to find one scenario fast. - Bulk select-all now toggles only currently visible rows so you can filter then bulk-unban. Drops inline onclick handlers in favor of delegated change listeners to keep per-row cost low at large limits. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
32
app/app.py
32
app/app.py
@@ -87,19 +87,35 @@ def index():
|
|||||||
return render_template("index.html", my_ip=caller_ip())
|
return render_template("index.html", my_ip=caller_ip())
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_LIMIT = 200
|
||||||
|
MAX_LIMIT = 2000
|
||||||
|
|
||||||
|
|
||||||
@app.get("/decisions")
|
@app.get("/decisions")
|
||||||
def list_decisions():
|
def list_decisions():
|
||||||
q = request.args.get("ip", "").strip()
|
ip = request.args.get("ip", "").strip()
|
||||||
params = {}
|
reason = request.args.get("reason", "").strip()
|
||||||
if q:
|
origin = request.args.get("origin", "").strip()
|
||||||
if not valid_ip(q):
|
try:
|
||||||
return render_template("_decisions.html", error="invalid IP", decisions=[]), 400
|
limit = max(1, min(MAX_LIMIT, int(request.args.get("limit", DEFAULT_LIMIT))))
|
||||||
params["ip"] = q
|
except ValueError:
|
||||||
|
limit = DEFAULT_LIMIT
|
||||||
|
|
||||||
|
params = {"limit": limit}
|
||||||
|
if ip:
|
||||||
|
if not valid_ip(ip):
|
||||||
|
return render_template("_decisions.html", error="invalid IP", decisions=[], total=0, limit=limit), 400
|
||||||
|
params["ip"] = ip
|
||||||
|
if reason:
|
||||||
|
params["scenarios_containing"] = reason
|
||||||
|
if origin:
|
||||||
|
params["origins"] = origin
|
||||||
|
|
||||||
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=[]), 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 = r.json() or []
|
||||||
return render_template("_decisions.html", decisions=decisions, error=None)
|
return render_template("_decisions.html", decisions=decisions, error=None, total=len(decisions), limit=limit)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/unban")
|
@app.post("/unban")
|
||||||
|
|||||||
@@ -2,23 +2,27 @@
|
|||||||
<p class="err">{{ error }}</p>
|
<p class="err">{{ error }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if decisions %}
|
{% if decisions %}
|
||||||
|
<p style="font-size:.8rem;color:#888;margin:.4rem 0;">
|
||||||
|
Showing <strong>{{ total }}</strong> decision{{ '' if total == 1 else 's' }}
|
||||||
|
(server limit {{ limit }}{% if total == limit %} — at cap, raise limit if more expected{% endif %}).
|
||||||
|
</p>
|
||||||
<form hx-post="/unban-bulk" hx-target="#decisions" hx-swap="innerHTML"
|
<form hx-post="/unban-bulk" hx-target="#decisions" hx-swap="innerHTML"
|
||||||
hx-confirm="Unban all selected decisions?" id="bulk-form">
|
hx-confirm="Unban all selected decisions?" id="bulk-form">
|
||||||
<div class="row" style="margin: .5rem 0;">
|
<div class="row" style="margin: .5rem 0;">
|
||||||
<button class="danger" type="submit">Unban selected</button>
|
<button class="danger" type="submit">Unban selected</button>
|
||||||
<span class="pill"><span id="sel-count">0</span> selected of {{ decisions|length }}</span>
|
<span class="pill"><span id="sel-count">0</span> selected</span>
|
||||||
</div>
|
</div>
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th><input type="checkbox" id="sel-all" onclick="document.querySelectorAll('#bulk-form .sel').forEach(c=>{c.checked=this.checked});updateCount()"></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>Until</th><th>Origin</th><th></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for d in decisions %}
|
{% for d in decisions %}
|
||||||
<tr>
|
<tr>
|
||||||
<td><input type="checkbox" class="sel" name="id" value="{{ d.id }}" onclick="updateCount()"></td>
|
<td><input type="checkbox" class="sel" name="id" value="{{ d.id }}"></td>
|
||||||
<td><code>{{ d.id }}</code></td>
|
<td><code>{{ d.id }}</code></td>
|
||||||
<td><code>{{ d.value }}</code></td>
|
<td><code>{{ d.value }}</code></td>
|
||||||
<td>{{ d.scope }}</td>
|
<td>{{ d.scope }}</td>
|
||||||
@@ -38,14 +42,25 @@
|
|||||||
</table>
|
</table>
|
||||||
</form>
|
</form>
|
||||||
<script>
|
<script>
|
||||||
function updateCount(){
|
(function(){
|
||||||
const n = document.querySelectorAll('#bulk-form .sel:checked').length;
|
const form = document.getElementById('bulk-form');
|
||||||
const total = document.querySelectorAll('#bulk-form .sel').length;
|
|
||||||
document.getElementById('sel-count').textContent = n;
|
|
||||||
const all = document.getElementById('sel-all');
|
const all = document.getElementById('sel-all');
|
||||||
if (all) all.checked = (n === total && total > 0);
|
const cnt = document.getElementById('sel-count');
|
||||||
|
function refresh(){
|
||||||
|
const sel = form.querySelectorAll('.sel');
|
||||||
|
const ck = form.querySelectorAll('.sel:checked');
|
||||||
|
cnt.textContent = ck.length;
|
||||||
|
if (all) all.checked = (sel.length > 0 && ck.length === sel.length);
|
||||||
}
|
}
|
||||||
updateCount();
|
if (all) all.addEventListener('change', e => {
|
||||||
|
const checked = e.target.checked;
|
||||||
|
// Only toggle visible rows so a filtered subset can be bulk-selected
|
||||||
|
form.querySelectorAll('tbody tr:not(.hidden-row) .sel').forEach(c => c.checked = checked);
|
||||||
|
refresh();
|
||||||
|
});
|
||||||
|
form.addEventListener('change', e => { if (e.target.classList.contains('sel')) refresh(); });
|
||||||
|
refresh();
|
||||||
|
})();
|
||||||
</script>
|
</script>
|
||||||
{% else %}
|
{% else %}
|
||||||
{% if not error %}<p>No active decisions.</p>{% endif %}
|
{% if not error %}<p>No active decisions.</p>{% endif %}
|
||||||
|
|||||||
@@ -22,6 +22,11 @@
|
|||||||
section { margin-top: 1.5rem; padding: 1rem; background:#181818; border-radius:6px; }
|
section { margin-top: 1.5rem; padding: 1rem; background:#181818; border-radius:6px; }
|
||||||
.err { color:#f88; }
|
.err { color:#f88; }
|
||||||
code { background:#222; padding:.1rem .3rem; border-radius:3px; }
|
code { background:#222; padding:.1rem .3rem; border-radius:3px; }
|
||||||
|
.row > * { flex: 0 0 auto; }
|
||||||
|
.grow { flex: 1 1 200px; }
|
||||||
|
.htmx-request #decisions::after { content: " loading…"; color:#888; }
|
||||||
|
.hidden-row { display:none; }
|
||||||
|
select { background:#222; color:#ddd; border:1px solid #444; padding:.4rem; border-radius:4px; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -36,13 +41,47 @@
|
|||||||
|
|
||||||
<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" class="row">
|
<form hx-get="/decisions" hx-target="#decisions" hx-trigger="submit, load delay:200ms" hx-swap="innerHTML" hx-indicator="#decisions" class="row">
|
||||||
<input type="text" name="ip" placeholder="Filter by IP (optional)">
|
<input type="text" name="ip" placeholder="IP (server-side)">
|
||||||
|
<input type="text" name="reason" placeholder="Reason contains (server-side)">
|
||||||
|
<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>
|
||||||
|
<option value="2000">2000</option>
|
||||||
|
</select>
|
||||||
<button type="submit">Search</button>
|
<button type="submit">Search</button>
|
||||||
<button type="button" hx-get="/decisions" hx-target="#decisions" hx-swap="innerHTML">Refresh all</button>
|
|
||||||
</form>
|
</form>
|
||||||
|
<div class="row" style="margin-top:.6rem;">
|
||||||
|
<input type="text" id="client-filter" placeholder="Quick filter rows (any column)…" class="grow"
|
||||||
|
oninput="filterRows()">
|
||||||
|
<span class="pill" id="visible-count"></span>
|
||||||
|
</div>
|
||||||
<div id="decisions">Loading…</div>
|
<div id="decisions">Loading…</div>
|
||||||
</section>
|
</section>
|
||||||
|
<script>
|
||||||
|
function filterRows(){
|
||||||
|
const q = (document.getElementById('client-filter').value || '').toLowerCase();
|
||||||
|
const rows = document.querySelectorAll('#decisions tbody tr');
|
||||||
|
let shown = 0;
|
||||||
|
rows.forEach(r => {
|
||||||
|
const match = !q || r.textContent.toLowerCase().includes(q);
|
||||||
|
r.classList.toggle('hidden-row', !match);
|
||||||
|
if (match) shown++;
|
||||||
|
});
|
||||||
|
const p = document.getElementById('visible-count');
|
||||||
|
if (p) p.textContent = shown + ' / ' + rows.length + ' visible';
|
||||||
|
}
|
||||||
|
document.body.addEventListener('htmx:afterSwap', e => { if (e.detail.target.id === 'decisions') filterRows(); });
|
||||||
|
</script>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2>Unban by IP</h2>
|
<h2>Unban by IP</h2>
|
||||||
|
|||||||
Reference in New Issue
Block a user