- FastAPI + htmx + Jinja2 web frontend, started with --web flag - JWT HS256 auth (WEB_SECRET_KEY) with httpOnly cookies; access (15 min) + refresh (7 day) tokens; refresh rotation + JTI revocation in data/web.db - RBAC: superadmin > admin > reader enforced per route - Live SSE dashboard fed by tui/events broadcast queue - Config editor: keyword groups and channel list saved to data/runtime_config.json and hot-reloaded in-process (scorer.reload_from_config, signal_channel_changed) - config.py migrated to load groups/channels from runtime_config.json; falls back to hardcoded defaults when file absent - tui/events.py: subscribe/unsubscribe broadcast, set_bot_context/signal_channel_changed - utils/scorer.py: import config as _config (fixes local binding); reload_from_config() - utils/database.py: count_by_severity, recent_for_domains, count_by_severity_for_domains - 53 new tests (events bus, JWT lifecycle, web DB CRUD, RBAC enforcement, config round-trip); total 141 passing
146 lines
5.3 KiB
HTML
146 lines
5.3 KiB
HTML
{% extends "base.html" %}
|
|
{% block title %}Config — ULPgrammer{% endblock %}
|
|
{% block content %}
|
|
|
|
<h2>Configuration</h2>
|
|
|
|
<div class="tabs">
|
|
<a href="/config/keywords" class="tab {% if active_tab == 'keywords' %}active{% endif %}">Keywords</a>
|
|
<a href="/config/channels" class="tab {% if active_tab == 'channels' %}active{% endif %}">Channels</a>
|
|
</div>
|
|
|
|
{% if active_tab == 'keywords' %}
|
|
<section>
|
|
<h3>Keyword Groups</h3>
|
|
<p class="hint">Each group can have multiple regex patterns. Patterns containing <code>@</code> trigger CRITICAL on matching email usernames.</p>
|
|
|
|
<form id="kw-form">
|
|
<div id="groups-container">
|
|
{% for g in groups %}
|
|
<fieldset class="group-fieldset">
|
|
<legend>
|
|
<input name="group_name" value="{{ g.name }}" placeholder="Group name" required>
|
|
<button type="button" class="btn-danger" onclick="this.closest('fieldset').remove()">Remove group</button>
|
|
</legend>
|
|
<input type="hidden" name="group_id" value="{{ g.id }}">
|
|
<table class="patterns-table">
|
|
<thead><tr><th>Regex</th><th>Label</th><th></th></tr></thead>
|
|
<tbody>
|
|
{% for p in g.patterns %}
|
|
<tr>
|
|
<td><input name="regex" value="{{ p.regex }}" required></td>
|
|
<td><input name="label" value="{{ p.label }}"></td>
|
|
<td><button type="button" onclick="this.closest('tr').remove()">✕</button></td>
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
<button type="button" class="btn-add" onclick="addPatternRow(this)">+ Add pattern</button>
|
|
</fieldset>
|
|
{% endfor %}
|
|
</div>
|
|
<button type="button" onclick="addGroup()">+ Add group</button>
|
|
<button type="submit" class="btn-primary">Save keywords</button>
|
|
<span id="kw-status"></span>
|
|
</form>
|
|
</section>
|
|
|
|
{% elif active_tab == 'channels' %}
|
|
<section>
|
|
<h3>Watched Channels</h3>
|
|
<p class="hint">Username (without @) or numeric channel ID (e.g. <code>-1002748707556</code>).</p>
|
|
|
|
<form id="ch-form">
|
|
<ul id="channels-list">
|
|
{% for ch in channels %}
|
|
<li>
|
|
<input name="channel" value="{{ ch }}" required>
|
|
<button type="button" onclick="this.closest('li').remove()">✕</button>
|
|
</li>
|
|
{% endfor %}
|
|
</ul>
|
|
<button type="button" onclick="addChannel()">+ Add channel</button>
|
|
<button type="submit" class="btn-primary">Save channels</button>
|
|
<span id="ch-status"></span>
|
|
</form>
|
|
</section>
|
|
{% endif %}
|
|
|
|
<script>
|
|
function addPatternRow(btn) {
|
|
const tbody = btn.previousElementSibling.querySelector("tbody");
|
|
const tr = document.createElement("tr");
|
|
tr.innerHTML = '<td><input name="regex" required></td><td><input name="label"></td><td><button type="button" onclick="this.closest(\'tr\').remove()">✕</button></td>';
|
|
tbody.appendChild(tr);
|
|
}
|
|
|
|
function addGroup() {
|
|
const id = "group_" + Date.now();
|
|
const fs = document.createElement("fieldset");
|
|
fs.className = "group-fieldset";
|
|
fs.innerHTML = `
|
|
<legend>
|
|
<input name="group_name" placeholder="Group name" required>
|
|
<button type="button" class="btn-danger" onclick="this.closest('fieldset').remove()">Remove group</button>
|
|
</legend>
|
|
<input type="hidden" name="group_id" value="${id}">
|
|
<table class="patterns-table">
|
|
<thead><tr><th>Regex</th><th>Label</th><th></th></tr></thead>
|
|
<tbody></tbody>
|
|
</table>
|
|
<button type="button" class="btn-add" onclick="addPatternRow(this)">+ Add pattern</button>
|
|
`;
|
|
document.getElementById("groups-container").appendChild(fs);
|
|
}
|
|
|
|
function addChannel() {
|
|
const li = document.createElement("li");
|
|
li.innerHTML = '<input name="channel" required><button type="button" onclick="this.closest(\'li\').remove()">✕</button>';
|
|
document.getElementById("channels-list").appendChild(li);
|
|
}
|
|
|
|
// Keyword form submit
|
|
document.getElementById("kw-form")?.addEventListener("submit", async e => {
|
|
e.preventDefault();
|
|
const groups = collectGroups();
|
|
const res = await fetch("/config/keywords", {
|
|
method: "PUT",
|
|
headers: {"Content-Type": "application/json"},
|
|
body: JSON.stringify({groups}),
|
|
});
|
|
document.getElementById("kw-status").textContent = res.ok ? "Saved." : "Error: " + await res.text();
|
|
});
|
|
|
|
// Channel form submit
|
|
document.getElementById("ch-form")?.addEventListener("submit", async e => {
|
|
e.preventDefault();
|
|
const inputs = document.querySelectorAll("#channels-list input[name=channel]");
|
|
const channels = [...inputs].map(i => {
|
|
const v = i.value.trim();
|
|
return /^-?\d+$/.test(v) ? parseInt(v, 10) : v;
|
|
});
|
|
const res = await fetch("/config/channels", {
|
|
method: "PUT",
|
|
headers: {"Content-Type": "application/json"},
|
|
body: JSON.stringify({channels}),
|
|
});
|
|
document.getElementById("ch-status").textContent = res.ok ? "Saved." : "Error: " + await res.text();
|
|
});
|
|
|
|
function collectGroups() {
|
|
const fieldsets = document.querySelectorAll(".group-fieldset");
|
|
return [...fieldsets].map(fs => {
|
|
const id = fs.querySelector("input[name=group_id]").value;
|
|
const name = fs.querySelector("input[name=group_name]").value;
|
|
const rows = fs.querySelectorAll("tbody tr");
|
|
const patterns = [...rows].map(tr => ({
|
|
regex: tr.querySelector("input[name=regex]").value,
|
|
label: tr.querySelector("input[name=label]").value,
|
|
}));
|
|
return {id, name, patterns};
|
|
});
|
|
}
|
|
</script>
|
|
|
|
{% endblock %}
|