MeshDD-Bot/static/js/nina.js
ppfeiffer 33928fca7b v0.8.3: NINA Gebietsanzeige + AGS-Ortsname + Sachsen-Combobox
- Warnmeldungen zeigen jetzt das Herkunftsgebiet (AGS-Regionsname) in
  der Weboberfläche (neue Spalte "Gebiet") und im Mesh-Nachrichtentext
  (z.B. "[NINA] Schwerwiegend: Sturmböen (Dresden, Stadt)")
- AGS-Code-Tabelle zeigt lesbaren Ortsnamen als zweite Spalte
- Datalist mit allen sächsischen AGS-Codes bei der Eingabe

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

258 lines
11 KiB
JavaScript
Raw Permalink 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; } });
// ── Sachsen AGS-Lookup ────────────────────────────────────────────────────────
const AGS_NAMES = {
'145110000000': 'Chemnitz, Stadt',
'145210000000': 'Erzgebirgskreis',
'145220000000': 'Mittelsachsen',
'145230000000': 'Vogtlandkreis',
'145240000000': 'Zwickau',
'146120000000': 'Dresden, Stadt',
'146250000000': 'Bautzen',
'146260000000': 'Görlitz',
'146270000000': 'Meißen',
'146280000000': 'Sächsische Schweiz-Osterzgebirge',
'147130000000': 'Leipzig, Stadt',
'147290000000': 'Landkreis Leipzig',
'147300000000': 'Nordsachsen',
};
function agsName(code) {
// Normalisiere auf 12 Stellen für den Lookup
const padded = code.padEnd(12, '0');
return AGS_NAMES[padded] || AGS_NAMES[code] || '';
}
function fillDatalist() {
const dl = document.getElementById('agsSachsenList');
if (!dl) return;
dl.innerHTML = Object.entries(AGS_NAMES)
.map(([code, name]) => `<option value="${escapeHtml(code)}">${escapeHtml(name)}</option>`)
.join('');
}
// ── AGS code list ────────────────────────────────────────────────────────────
function renderAgsList() {
const tbody = document.getElementById('agsList');
if (agsCodes.length === 0) {
tbody.innerHTML = '<tr><td colspan="3" class="text-center text-body-secondary py-2"><small>Keine AGS-Codes konfiguriert.</small></td></tr>';
return;
}
tbody.innerHTML = agsCodes.map((code, idx) =>
`<tr>
<td><code>${escapeHtml(code)}</code></td>
<td class="text-body-secondary">${escapeHtml(agsName(code))}</td>
<td class="text-end">
<button type="button" class="btn btn-outline-danger btn-sm py-0 px-1"
onclick="removeAgs(${idx})" title="Entfernen">
<i class="bi bi-trash" style="font-size:.75rem"></i>
</button>
</td>
</tr>`
).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('ninaSendToMesh').checked = cfg.send_to_mesh !== false;
document.getElementById('pollInterval').value = cfg.poll_interval ?? 300;
document.getElementById('resendInterval').value = cfg.resend_interval ?? 3600;
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;
const mode = cfg.send_to_mesh !== false ? 'Mesh+Web' : 'Nur Web';
badge.className = cfg.send_to_mesh !== false ? 'badge bg-success' : 'badge bg-warning text-dark';
badge.textContent = `Aktiv · ${codes} Region${codes !== 1 ? 'en' : ''} · ${mode}`;
} else {
badge.className = 'badge bg-secondary text-white';
badge.textContent = 'Deaktiviert';
}
}
// ── Save config ──────────────────────────────────────────────────────────────
document.getElementById('btnSaveNina').addEventListener('click', async () => {
const payload = {
enabled: document.getElementById('ninaEnabled').checked,
send_to_mesh: document.getElementById('ninaSendToMesh').checked,
poll_interval: parseInt(document.getElementById('pollInterval').value) || 300,
resend_interval: parseInt(document.getElementById('resendInterval').value) || 3600,
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: { bg: 'danger', text: 'text-white' },
Severe: { bg: 'warning', text: 'text-dark' },
Moderate: { bg: 'info', text: 'text-white' },
Minor: { bg: 'secondary', text: 'text-white' },
};
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="6" class="text-center text-body-secondary py-3">Keine Meldungen empfangen.</td></tr>';
return;
}
tbody.innerHTML = alerts.map(a => {
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 ts = a.sent ? new Date(a.sent).toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'short' }) : '';
const meshIcon = a.monitor_only
? '<i class="bi bi-eye text-warning" title="Nur Weboberfläche"></i>'
: '<i class="bi bi-broadcast text-success" title="Ins Mesh gesendet"></i>';
const area = a.area ? escapeHtml(a.area) : '<span class="text-body-secondary"></span>';
return `<tr>
<td><span class="badge bg-${bgCls} ${txCls}">${escapeHtml(sevLabel)}</span></td>
<td>${escapeHtml(a.headline)}</td>
<td><small class="text-body-secondary">${area}</small></td>
<td><small class="text-body-secondary">${escapeHtml(a.id?.split('.')[0] ?? '')}</small></td>
<td class="text-center">${meshIcon}</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 ──────────────────────────────────────────────────────────────────────
fillDatalist();
loadConfig();
connectWebSocket();