feat: NINA Wiederholungsintervall, AGS-Tabelle, Badge-Fix (v0.8.1)
- resend_interval: zweiter Intervall fuer erneutes Senden aktiver Warnmeldungen ins Mesh (nur bei send_to_mesh=true). Standard 3600s. NinaBot._resend_loop() laeuft als eigener asyncio-Task; _active-Dict haelt aktive Warnungen vor (Cancel entfernt Eintraege). - AGS-Code-Verwaltung als Tabelle mit Trash-Button je Zeile statt Badges mit X-Button. - Severity-Badges: explizite text-white/text-dark, kein bg-opacity-85 mehr - lesbar in beiden Themes. - colspan-Fix: leere Alerts-Zeile auf 5 Spalten korrigiert. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
4c465ed170
commit
2feff2c2c7
15
CHANGELOG.md
15
CHANGELOG.md
|
|
@ -1,5 +1,20 @@
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [0.8.1] - 2026-02-19
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **NINA Wiederholungsintervall** (`resend_interval`): Zweiter konfigurierbarer Intervall,
|
||||||
|
der aktive Warnmeldungen in regelmäßigen Abständen erneut ins Mesh sendet
|
||||||
|
(nur wenn `send_to_mesh=true`). Standard: 3600 Sekunden (1 Stunde).
|
||||||
|
- **NINA AGS-Code-Tabelle**: AGS-Codes werden jetzt in einer Tabelle mit Lösch-Button
|
||||||
|
je Zeile angezeigt – übersichtlicher als die bisherigen Badge-Einträge.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Badge-Lesbarkeit**: Severity-Badges in der Alerts-Tabelle haben jetzt explizite
|
||||||
|
Textfarben (`text-white` / `text-dark`) ohne `bg-opacity`, damit der Text auf allen
|
||||||
|
Hintergründen und in beiden Themes lesbar bleibt.
|
||||||
|
- **colspan**: Leere Zeile in der Alerts-Tabelle korrekt auf 5 Spalten gesetzt.
|
||||||
|
|
||||||
## [0.8.0] - 2026-02-19
|
## [0.8.0] - 2026-02-19
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
enabled: false
|
enabled: false
|
||||||
send_to_mesh: false # true = ins Mesh senden | false = nur Weboberfläche (Monitor-Modus)
|
send_to_mesh: false # true = ins Mesh senden | false = nur Weboberfläche (Monitor-Modus)
|
||||||
poll_interval: 300
|
poll_interval: 300
|
||||||
|
resend_interval: 3600 # Aktive Warnungen alle N Sekunden erneut ins Mesh senden
|
||||||
channel: 0
|
channel: 0
|
||||||
min_severity: Severe
|
min_severity: Severe
|
||||||
ags_codes:
|
ags_codes:
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
version: "0.8.0"
|
version: "0.8.1"
|
||||||
|
|
||||||
bot:
|
bot:
|
||||||
name: "MeshDD-Bot"
|
name: "MeshDD-Bot"
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,7 @@ DEFAULT_CONFIG = {
|
||||||
"enabled": False,
|
"enabled": False,
|
||||||
"send_to_mesh": True,
|
"send_to_mesh": True,
|
||||||
"poll_interval": 300,
|
"poll_interval": 300,
|
||||||
|
"resend_interval": 3600,
|
||||||
"channel": 0,
|
"channel": 0,
|
||||||
"min_severity": "Severe",
|
"min_severity": "Severe",
|
||||||
"ags_codes": [],
|
"ags_codes": [],
|
||||||
|
|
@ -88,9 +89,11 @@ class NinaBot:
|
||||||
self.ws_manager = ws_manager
|
self.ws_manager = ws_manager
|
||||||
self.config: dict = {}
|
self.config: dict = {}
|
||||||
self._mtime: float = 0.0
|
self._mtime: float = 0.0
|
||||||
self._known: dict[str, str] = {} # normalised_id -> sent (de-dup)
|
self._known: dict[str, str] = {} # normalised_id -> sent (de-dup)
|
||||||
|
self._active: dict[str, dict] = {} # normalised_id -> {text, channel, headline, severity, id, sent}
|
||||||
self._running = False
|
self._running = False
|
||||||
self._task: asyncio.Task | None = None
|
self._task: asyncio.Task | None = None
|
||||||
|
self._resend_task: asyncio.Task | None = None
|
||||||
self._load()
|
self._load()
|
||||||
|
|
||||||
# ── Config ──────────────────────────────────────────────────────────────
|
# ── Config ──────────────────────────────────────────────────────────────
|
||||||
|
|
@ -149,16 +152,18 @@ class NinaBot:
|
||||||
async def start(self):
|
async def start(self):
|
||||||
self._running = True
|
self._running = True
|
||||||
self._task = asyncio.create_task(self._poll_loop())
|
self._task = asyncio.create_task(self._poll_loop())
|
||||||
|
self._resend_task = asyncio.create_task(self._resend_loop())
|
||||||
logger.info("NinaBot started")
|
logger.info("NinaBot started")
|
||||||
|
|
||||||
async def stop(self):
|
async def stop(self):
|
||||||
self._running = False
|
self._running = False
|
||||||
if self._task:
|
for task in (self._task, self._resend_task):
|
||||||
self._task.cancel()
|
if task:
|
||||||
try:
|
task.cancel()
|
||||||
await self._task
|
try:
|
||||||
except asyncio.CancelledError:
|
await task
|
||||||
pass
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
# ── Hot-reload ───────────────────────────────────────────────────────────
|
# ── Hot-reload ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -187,6 +192,20 @@ class NinaBot:
|
||||||
interval = max(60, int(self.config.get("poll_interval", 300)))
|
interval = max(60, int(self.config.get("poll_interval", 300)))
|
||||||
await asyncio.sleep(interval)
|
await asyncio.sleep(interval)
|
||||||
|
|
||||||
|
async def _resend_loop(self):
|
||||||
|
"""Re-broadcast all active warnings at resend_interval when send_to_mesh is enabled."""
|
||||||
|
while self._running:
|
||||||
|
interval = max(60, int(self.config.get("resend_interval", 3600)))
|
||||||
|
await asyncio.sleep(interval)
|
||||||
|
try:
|
||||||
|
if self.config.get("enabled") and self.config.get("send_to_mesh", True):
|
||||||
|
if self._active:
|
||||||
|
logger.info("NINA resend: %d aktive Warnmeldungen", len(self._active))
|
||||||
|
for entry in list(self._active.values()):
|
||||||
|
await self.send_callback(entry["text"], entry["channel"])
|
||||||
|
except Exception:
|
||||||
|
logger.exception("NINA resend error")
|
||||||
|
|
||||||
async def _check_alerts(self):
|
async def _check_alerts(self):
|
||||||
min_level = SEVERITY_ORDER.get(self.config.get("min_severity", "Severe"), 2)
|
min_level = SEVERITY_ORDER.get(self.config.get("min_severity", "Severe"), 2)
|
||||||
channel = int(self.config.get("channel", 0))
|
channel = int(self.config.get("channel", 0))
|
||||||
|
|
@ -377,6 +396,21 @@ class NinaBot:
|
||||||
text: str,
|
text: str,
|
||||||
channel: int,
|
channel: int,
|
||||||
):
|
):
|
||||||
|
dedup_key = self._normalise_id(identifier)
|
||||||
|
|
||||||
|
# Keep _active up to date for re-broadcast
|
||||||
|
if msg_type == "Cancel":
|
||||||
|
self._active.pop(dedup_key, None)
|
||||||
|
else:
|
||||||
|
self._active[dedup_key] = {
|
||||||
|
"text": text,
|
||||||
|
"channel": channel,
|
||||||
|
"headline": headline,
|
||||||
|
"severity": severity,
|
||||||
|
"id": identifier,
|
||||||
|
"sent": sent,
|
||||||
|
}
|
||||||
|
|
||||||
if self.config.get("send_to_mesh", True):
|
if self.config.get("send_to_mesh", True):
|
||||||
await self.send_callback(text, channel)
|
await self.send_callback(text, channel)
|
||||||
else:
|
else:
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
enabled: false
|
enabled: false
|
||||||
send_to_mesh: false # true = ins Mesh senden | false = nur Weboberfläche (Monitor-Modus)
|
send_to_mesh: false # true = ins Mesh senden | false = nur Weboberfläche (Monitor-Modus)
|
||||||
poll_interval: 300
|
poll_interval: 300
|
||||||
|
resend_interval: 3600 # Aktive Warnungen alle N Sekunden erneut ins Mesh senden
|
||||||
channel: 0
|
channel: 0
|
||||||
min_severity: Severe
|
min_severity: Severe
|
||||||
ags_codes:
|
ags_codes:
|
||||||
|
|
|
||||||
|
|
@ -8,17 +8,21 @@ initPage({ onAuth: (user) => { currentUser = user; } });
|
||||||
// ── AGS code list ────────────────────────────────────────────────────────────
|
// ── AGS code list ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function renderAgsList() {
|
function renderAgsList() {
|
||||||
const container = document.getElementById('agsList');
|
const tbody = document.getElementById('agsList');
|
||||||
if (agsCodes.length === 0) {
|
if (agsCodes.length === 0) {
|
||||||
container.innerHTML = '<small class="text-body-secondary">Keine AGS-Codes konfiguriert.</small>';
|
tbody.innerHTML = '<tr><td colspan="2" class="text-center text-body-secondary py-2"><small>Keine AGS-Codes konfiguriert.</small></td></tr>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
container.innerHTML = agsCodes.map((code, idx) =>
|
tbody.innerHTML = agsCodes.map((code, idx) =>
|
||||||
`<span class="badge bg-secondary me-1 mb-1">
|
`<tr>
|
||||||
${escapeHtml(code)}
|
<td><code>${escapeHtml(code)}</code></td>
|
||||||
<button type="button" class="btn-close btn-close-white ms-1" style="font-size:.55rem;vertical-align:middle"
|
<td class="text-end">
|
||||||
onclick="removeAgs(${idx})" aria-label="Entfernen"></button>
|
<button type="button" class="btn btn-outline-danger btn-sm py-0 px-1"
|
||||||
</span>`
|
onclick="removeAgs(${idx})" title="Entfernen">
|
||||||
|
<i class="bi bi-trash" style="font-size:.75rem"></i>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>`
|
||||||
).join('');
|
).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -66,11 +70,12 @@ async function loadConfig() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyConfig(cfg) {
|
function applyConfig(cfg) {
|
||||||
document.getElementById('ninaEnabled').checked = !!cfg.enabled;
|
document.getElementById('ninaEnabled').checked = !!cfg.enabled;
|
||||||
document.getElementById('ninaSendToMesh').checked = cfg.send_to_mesh !== false;
|
document.getElementById('ninaSendToMesh').checked = cfg.send_to_mesh !== false;
|
||||||
document.getElementById('pollInterval').value = cfg.poll_interval ?? 300;
|
document.getElementById('pollInterval').value = cfg.poll_interval ?? 300;
|
||||||
document.getElementById('ninaChannel').value = cfg.channel ?? 0;
|
document.getElementById('resendInterval').value = cfg.resend_interval ?? 3600;
|
||||||
document.getElementById('minSeverity').value = cfg.min_severity ?? 'Severe';
|
document.getElementById('ninaChannel').value = cfg.channel ?? 0;
|
||||||
|
document.getElementById('minSeverity').value = cfg.min_severity ?? 'Severe';
|
||||||
|
|
||||||
agsCodes = Array.isArray(cfg.ags_codes) ? [...cfg.ags_codes] : [];
|
agsCodes = Array.isArray(cfg.ags_codes) ? [...cfg.ags_codes] : [];
|
||||||
renderAgsList();
|
renderAgsList();
|
||||||
|
|
@ -92,7 +97,7 @@ function updateStatusBadge(cfg) {
|
||||||
badge.className = cfg.send_to_mesh !== false ? 'badge bg-success' : 'badge bg-warning text-dark';
|
badge.className = cfg.send_to_mesh !== false ? 'badge bg-success' : 'badge bg-warning text-dark';
|
||||||
badge.textContent = `Aktiv · ${codes} Region${codes !== 1 ? 'en' : ''} · ${mode}`;
|
badge.textContent = `Aktiv · ${codes} Region${codes !== 1 ? 'en' : ''} · ${mode}`;
|
||||||
} else {
|
} else {
|
||||||
badge.className = 'badge bg-secondary';
|
badge.className = 'badge bg-secondary text-white';
|
||||||
badge.textContent = 'Deaktiviert';
|
badge.textContent = 'Deaktiviert';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -101,11 +106,12 @@ function updateStatusBadge(cfg) {
|
||||||
|
|
||||||
document.getElementById('btnSaveNina').addEventListener('click', async () => {
|
document.getElementById('btnSaveNina').addEventListener('click', async () => {
|
||||||
const payload = {
|
const payload = {
|
||||||
enabled: document.getElementById('ninaEnabled').checked,
|
enabled: document.getElementById('ninaEnabled').checked,
|
||||||
send_to_mesh: document.getElementById('ninaSendToMesh').checked,
|
send_to_mesh: document.getElementById('ninaSendToMesh').checked,
|
||||||
poll_interval: parseInt(document.getElementById('pollInterval').value) || 300,
|
poll_interval: parseInt(document.getElementById('pollInterval').value) || 300,
|
||||||
channel: parseInt(document.getElementById('ninaChannel').value) || 0,
|
resend_interval: parseInt(document.getElementById('resendInterval').value) || 3600,
|
||||||
min_severity: document.getElementById('minSeverity').value,
|
channel: parseInt(document.getElementById('ninaChannel').value) || 0,
|
||||||
|
min_severity: document.getElementById('minSeverity').value,
|
||||||
ags_codes: [...agsCodes],
|
ags_codes: [...agsCodes],
|
||||||
sources: {
|
sources: {
|
||||||
katwarn: document.getElementById('srcKatwarn').checked,
|
katwarn: document.getElementById('srcKatwarn').checked,
|
||||||
|
|
@ -147,10 +153,10 @@ document.getElementById('btnSaveNina').addEventListener('click', async () => {
|
||||||
// ── Alerts table ─────────────────────────────────────────────────────────────
|
// ── Alerts table ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const SEV_CLASS = {
|
const SEV_CLASS = {
|
||||||
Extreme: 'danger',
|
Extreme: { bg: 'danger', text: 'text-white' },
|
||||||
Severe: 'warning',
|
Severe: { bg: 'warning', text: 'text-dark' },
|
||||||
Moderate: 'info',
|
Moderate: { bg: 'info', text: 'text-white' },
|
||||||
Minor: 'secondary',
|
Minor: { bg: 'secondary', text: 'text-white' },
|
||||||
};
|
};
|
||||||
|
|
||||||
const SEV_LABEL = {
|
const SEV_LABEL = {
|
||||||
|
|
@ -164,18 +170,20 @@ const SEV_LABEL = {
|
||||||
function renderAlerts() {
|
function renderAlerts() {
|
||||||
const tbody = document.getElementById('alertsTable');
|
const tbody = document.getElementById('alertsTable');
|
||||||
if (alerts.length === 0) {
|
if (alerts.length === 0) {
|
||||||
tbody.innerHTML = '<tr><td colspan="4" class="text-center text-body-secondary py-3">Keine Meldungen empfangen.</td></tr>';
|
tbody.innerHTML = '<tr><td colspan="5" class="text-center text-body-secondary py-3">Keine Meldungen empfangen.</td></tr>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
tbody.innerHTML = alerts.map(a => {
|
tbody.innerHTML = alerts.map(a => {
|
||||||
const cls = a.msgType === 'Cancel' ? 'secondary' : (SEV_CLASS[a.severity] ?? 'secondary');
|
const sev = a.msgType === 'Cancel' ? null : (SEV_CLASS[a.severity] ?? { bg: 'secondary', text: 'text-white' });
|
||||||
|
const bgCls = a.msgType === 'Cancel' ? 'secondary' : sev.bg;
|
||||||
|
const txCls = a.msgType === 'Cancel' ? 'text-white' : sev.text;
|
||||||
const sevLabel = a.msgType === 'Cancel' ? 'Aufgehoben' : (SEV_LABEL[a.severity] ?? a.severity);
|
const sevLabel = a.msgType === 'Cancel' ? 'Aufgehoben' : (SEV_LABEL[a.severity] ?? a.severity);
|
||||||
const ts = a.sent ? new Date(a.sent).toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'short' }) : '–';
|
const ts = a.sent ? new Date(a.sent).toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'short' }) : '–';
|
||||||
const meshIcon = a.monitor_only
|
const meshIcon = a.monitor_only
|
||||||
? '<i class="bi bi-eye text-warning" title="Nur Weboberfläche"></i>'
|
? '<i class="bi bi-eye text-warning" title="Nur Weboberfläche"></i>'
|
||||||
: '<i class="bi bi-broadcast text-success" title="Ins Mesh gesendet"></i>';
|
: '<i class="bi bi-broadcast text-success" title="Ins Mesh gesendet"></i>';
|
||||||
return `<tr>
|
return `<tr>
|
||||||
<td><span class="badge bg-${cls} bg-opacity-85">${escapeHtml(sevLabel)}</span></td>
|
<td><span class="badge bg-${bgCls} ${txCls}">${escapeHtml(sevLabel)}</span></td>
|
||||||
<td>${escapeHtml(a.headline)}</td>
|
<td>${escapeHtml(a.headline)}</td>
|
||||||
<td><small class="text-body-secondary">${escapeHtml(a.id?.split('.')[0] ?? '–')}</small></td>
|
<td><small class="text-body-secondary">${escapeHtml(a.id?.split('.')[0] ?? '–')}</small></td>
|
||||||
<td class="text-center">${meshIcon}</td>
|
<td class="text-center">${meshIcon}</td>
|
||||||
|
|
|
||||||
|
|
@ -71,13 +71,19 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row g-3 mb-3">
|
<div class="row g-3 mb-3">
|
||||||
<div class="col-7">
|
<div class="col-5">
|
||||||
<label for="pollInterval" class="form-label">Abfrageintervall (Sek.)</label>
|
<label for="pollInterval" class="form-label">Abfrage­intervall (Sek.)</label>
|
||||||
<input type="number" class="form-control form-control-sm" id="pollInterval"
|
<input type="number" class="form-control form-control-sm" id="pollInterval"
|
||||||
min="60" max="3600" step="60" value="300">
|
min="60" max="3600" step="60" value="300">
|
||||||
<div class="form-text">Min. 60 Sekunden</div>
|
<div class="form-text">Neue Warnmeldungen</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-5">
|
<div class="col-5">
|
||||||
|
<label for="resendInterval" class="form-label">Wieder­holungsintervall (Sek.)</label>
|
||||||
|
<input type="number" class="form-control form-control-sm" id="resendInterval"
|
||||||
|
min="60" step="60" value="3600">
|
||||||
|
<div class="form-text">Aktive Warnungen</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-2">
|
||||||
<label for="ninaChannel" class="form-label">Kanal</label>
|
<label for="ninaChannel" class="form-label">Kanal</label>
|
||||||
<input type="number" class="form-control form-control-sm" id="ninaChannel"
|
<input type="number" class="form-control form-control-sm" id="ninaChannel"
|
||||||
min="0" max="7" value="0">
|
min="0" max="7" value="0">
|
||||||
|
|
@ -97,12 +103,22 @@
|
||||||
<!-- AGS Codes -->
|
<!-- AGS Codes -->
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">AGS-Codes (Amtliche Gemeindeschlüssel)</label>
|
<label class="form-label">AGS-Codes (Amtliche Gemeindeschlüssel)</label>
|
||||||
<div id="agsList" class="mb-2"></div>
|
<div class="table-responsive mb-2" style="max-height:160px;overflow-y:auto">
|
||||||
|
<table class="table table-sm table-hover table-striped mb-0 align-middle">
|
||||||
|
<thead class="table-dark">
|
||||||
|
<tr>
|
||||||
|
<th>AGS-Code</th>
|
||||||
|
<th style="width:42px"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="agsList"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
<div class="input-group input-group-sm">
|
<div class="input-group input-group-sm">
|
||||||
<input type="text" class="form-control" id="agsInput"
|
<input type="text" class="form-control" id="agsInput"
|
||||||
placeholder="z.B. 091620000000 (München)" maxlength="12">
|
placeholder="z.B. 091620000000 (München)" maxlength="12">
|
||||||
<button class="btn btn-outline-secondary" type="button" id="btnAddAgs">
|
<button class="btn btn-outline-secondary" type="button" id="btnAddAgs">
|
||||||
<i class="bi bi-plus-lg"></i>
|
<i class="bi bi-plus-lg"></i> Hinzufügen
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-text">
|
<div class="form-text">
|
||||||
|
|
@ -177,7 +193,7 @@
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="alertsTable">
|
<tbody id="alertsTable">
|
||||||
<tr><td colspan="4" class="text-center text-body-secondary py-3">Keine Meldungen – NINA aktivieren und AGS-Codes konfigurieren.</td></tr>
|
<tr><td colspan="5" class="text-center text-body-secondary py-3">Keine Meldungen – NINA aktivieren und AGS-Codes konfigurieren.</td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue