MeshDD-Bot/static/js/nina.js
ppfeiffer 2db03510c8 v0.8.4: NINA Sofortabfrage nach Speichern + Zeitstempel letzte Abfrage
- 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>
2026-02-19 16:18:15 +01:00

271 lines
11 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; } });
// ── 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;
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();