- Konfiguration speichern löst sofort eine NINA-Abfrage aus (trigger_poll) - Frontend lädt Config 5s nach Save neu um last_poll-Zeitstempel zu zeigen - Unterhalb Abfrageintervall-Feld: Datum/Uhrzeit der letzten Abfrage Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
271 lines
11 KiB
JavaScript
271 lines
11 KiB
JavaScript
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 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('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;
|
||
|
||
const lpEl = document.getElementById('lastPoll');
|
||
if (lpEl) {
|
||
if (cfg.last_poll) {
|
||
const d = new Date(cfg.last_poll);
|
||
lpEl.textContent = 'Letzte Abfrage: ' + d.toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'medium' });
|
||
} else {
|
||
lpEl.textContent = '';
|
||
}
|
||
}
|
||
}
|
||
|
||
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);
|
||
applyConfig(cfg);
|
||
statusEl.textContent = 'Gespeichert ✓';
|
||
statusEl.className = 'align-self-center small text-success';
|
||
statusEl.classList.remove('d-none');
|
||
setTimeout(() => statusEl.classList.add('d-none'), 3000);
|
||
// Config nach kurzer Pause nachladen, damit last_poll den abgeschlossenen Poll zeigt
|
||
setTimeout(loadConfig, 5000);
|
||
} 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();
|