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>
207 lines
8.1 KiB
JavaScript
207 lines
8.1 KiB
JavaScript
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 5–12 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();
|