MeshDD-Bot/static/js/nina.js
ppfeiffer 0ca0ffb0d1 feat: NINA BBK Warn-App Integration (v0.8.0)
Neue NINA-Integration: Automatisches Polling der BBK-Warn-API
(warnung.bund.de/api31) und Weiterleitung von Warnmeldungen ins
Meshtastic-Netz. Separate Admin-Konfigurationsseite (/nina) analog
zum Scheduler.

- meshbot/nina.py: NinaBot – Polling, De-Duplikation, Schweregrad-
  und Quellen-Filterung, WebSocket-Broadcast (nina_alert)
- nina.yaml + conf/nina.yaml: Hot-reload-faehige Konfiguration
- static/nina.html + static/js/nina.js: Konfigurationsseite mit
  AGS-Code-Verwaltung, Quellen-Auswahl und Live-Alerts-Tabelle
- webserver.py: GET/PUT /api/nina/config + GET /nina (Admin-only)
- main.py: NinaBot initialisieren, watch/start/stop im Lifecycle
- app.js: NINA-Sidebar-Eintrag (Admin-only, shield-exclamation)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-19 11:21:01 +01:00

207 lines
8.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

let currentUser = null;
let agsCodes = [];
const MAX_ALERTS = 50;
const alerts = [];
initPage({ onAuth: (user) => { currentUser = user; } });
// ── AGS code list ────────────────────────────────────────────────────────────
function renderAgsList() {
const container = document.getElementById('agsList');
if (agsCodes.length === 0) {
container.innerHTML = '<small class="text-body-secondary">Keine AGS-Codes konfiguriert.</small>';
return;
}
container.innerHTML = agsCodes.map((code, idx) =>
`<span class="badge bg-secondary me-1 mb-1">
${escapeHtml(code)}
<button type="button" class="btn-close btn-close-white ms-1" style="font-size:.55rem;vertical-align:middle"
onclick="removeAgs(${idx})" aria-label="Entfernen"></button>
</span>`
).join('');
}
function removeAgs(idx) {
agsCodes.splice(idx, 1);
renderAgsList();
}
document.getElementById('btnAddAgs').addEventListener('click', () => {
const input = document.getElementById('agsInput');
const code = input.value.trim().replace(/\D/g, '');
if (!code) return;
if (code.length < 5 || code.length > 12) {
input.setCustomValidity('AGS-Code muss 512 Stellen haben.');
input.reportValidity();
return;
}
input.setCustomValidity('');
if (!agsCodes.includes(code)) {
agsCodes.push(code);
renderAgsList();
}
input.value = '';
});
document.getElementById('agsInput').addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
document.getElementById('btnAddAgs').click();
}
});
// ── Load config ──────────────────────────────────────────────────────────────
async function loadConfig() {
try {
const resp = await fetch('/api/nina/config');
if (!resp.ok) return;
const cfg = await resp.json();
applyConfig(cfg);
updateStatusBadge(cfg);
} catch (e) {
console.error('NINA config load failed:', e);
}
}
function applyConfig(cfg) {
document.getElementById('ninaEnabled').checked = !!cfg.enabled;
document.getElementById('pollInterval').value = cfg.poll_interval ?? 300;
document.getElementById('ninaChannel').value = cfg.channel ?? 0;
document.getElementById('minSeverity').value = cfg.min_severity ?? 'Severe';
agsCodes = Array.isArray(cfg.ags_codes) ? [...cfg.ags_codes] : [];
renderAgsList();
const src = cfg.sources ?? {};
document.getElementById('srcKatwarn').checked = src.katwarn !== false;
document.getElementById('srcBiwapp').checked = src.biwapp !== false;
document.getElementById('srcMowas').checked = src.mowas !== false;
document.getElementById('srcDwd').checked = src.dwd !== false;
document.getElementById('srcLhp').checked = src.lhp !== false;
document.getElementById('srcPolice').checked = !!src.police;
}
function updateStatusBadge(cfg) {
const badge = document.getElementById('statusBadge');
if (cfg.enabled) {
const codes = Array.isArray(cfg.ags_codes) ? cfg.ags_codes.length : 0;
badge.className = 'badge bg-success';
badge.textContent = `Aktiv · ${codes} Region${codes !== 1 ? 'en' : ''}`;
} else {
badge.className = 'badge bg-secondary';
badge.textContent = 'Deaktiviert';
}
}
// ── Save config ──────────────────────────────────────────────────────────────
document.getElementById('btnSaveNina').addEventListener('click', async () => {
const payload = {
enabled: document.getElementById('ninaEnabled').checked,
poll_interval: parseInt(document.getElementById('pollInterval').value) || 300,
channel: parseInt(document.getElementById('ninaChannel').value) || 0,
min_severity: document.getElementById('minSeverity').value,
ags_codes: [...agsCodes],
sources: {
katwarn: document.getElementById('srcKatwarn').checked,
biwapp: document.getElementById('srcBiwapp').checked,
mowas: document.getElementById('srcMowas').checked,
dwd: document.getElementById('srcDwd').checked,
lhp: document.getElementById('srcLhp').checked,
police: document.getElementById('srcPolice').checked,
},
};
const statusEl = document.getElementById('saveStatus');
try {
const resp = await fetch('/api/nina/config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (resp.ok) {
const cfg = await resp.json();
updateStatusBadge(cfg);
statusEl.textContent = 'Gespeichert ✓';
statusEl.className = 'align-self-center small text-success';
statusEl.classList.remove('d-none');
setTimeout(() => statusEl.classList.add('d-none'), 3000);
} else {
statusEl.textContent = 'Fehler beim Speichern';
statusEl.className = 'align-self-center small text-danger';
statusEl.classList.remove('d-none');
}
} catch (e) {
console.error('Save failed:', e);
statusEl.textContent = 'Netzwerkfehler';
statusEl.className = 'align-self-center small text-danger';
statusEl.classList.remove('d-none');
}
});
// ── Alerts table ─────────────────────────────────────────────────────────────
const SEV_CLASS = {
Extreme: 'danger',
Severe: 'warning',
Moderate: 'info',
Minor: 'secondary',
};
const SEV_LABEL = {
Extreme: 'EXTREM',
Severe: 'Schwerwiegend',
Moderate: 'Mäßig',
Minor: 'Gering',
Unknown: 'Unbekannt',
};
function renderAlerts() {
const tbody = document.getElementById('alertsTable');
if (alerts.length === 0) {
tbody.innerHTML = '<tr><td colspan="4" class="text-center text-body-secondary py-3">Keine Meldungen empfangen.</td></tr>';
return;
}
tbody.innerHTML = alerts.map(a => {
const cls = a.msgType === 'Cancel' ? 'secondary' : (SEV_CLASS[a.severity] ?? 'secondary');
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' }) : '';
return `<tr>
<td><span class="badge bg-${cls} bg-opacity-85">${escapeHtml(sevLabel)}</span></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">${ts}</small></td>
</tr>`;
}).join('');
}
function addAlert(alert) {
alerts.unshift(alert);
if (alerts.length > MAX_ALERTS) alerts.pop();
renderAlerts();
}
// ── WebSocket ─────────────────────────────────────────────────────────────────
function connectWebSocket() {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const ws = new WebSocket(`${proto}//${location.host}/ws`);
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
if (msg.type === 'nina_alert') {
addAlert(msg.data);
}
};
ws.onclose = () => { setTimeout(connectWebSocket, 3000); };
ws.onerror = () => { ws.close(); };
}
// ── Init ──────────────────────────────────────────────────────────────────────
loadConfig();
connectWebSocket();