- Rollensystem: Public → Mitarbeiter → Admin (Rolle user entfällt) - DB-Migration: must_change_password-Spalte, user→mitarbeiter - require_staff_api(): erlaubt mitarbeiter + admin - POST /api/admin/invite: Einladung mit auto-generiertem Passwort + E-Mail - POST /auth/change-password: Pflicht-Passwortwechsel - Login: force_password_change-Redirect - Sidebar: sidebar-staff für Scheduler/NINA/Einstellungen - Scheduler/NINA: read-only für Mitarbeiter Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
312 lines
13 KiB
JavaScript
312 lines
13 KiB
JavaScript
let currentUser = null;
|
||
let agsCodes = [];
|
||
const MAX_ALERTS = 50;
|
||
const alerts = [];
|
||
|
||
initPage({ onAuth: (user) => {
|
||
currentUser = user;
|
||
if (!user || user.role !== 'admin') {
|
||
// Schreibkontrollen für Nur-Lesezugriff deaktivieren
|
||
['ninaEnabled', 'ninaSendToMesh', 'pollInterval', 'resendInterval',
|
||
'ninaChannel', 'minSeverity', 'agsInput', 'btnAddAgs', 'btnSaveNina',
|
||
'srcKatwarn', 'srcBiwapp', 'srcMowas', 'srcDwd', 'srcLhp', 'srcPolice']
|
||
.forEach(id => {
|
||
const el = document.getElementById(id);
|
||
if (el) el.disabled = true;
|
||
});
|
||
const badge = document.createElement('span');
|
||
badge.className = 'badge bg-secondary-subtle text-secondary-emphasis ms-2';
|
||
badge.style.fontSize = '.65rem';
|
||
badge.textContent = 'Nur Lesezugriff';
|
||
const saveBtn = document.getElementById('btnSaveNina');
|
||
if (saveBtn && saveBtn.parentNode) saveBtn.parentNode.insertBefore(badge, saveBtn);
|
||
}
|
||
loadConfig();
|
||
} });
|
||
|
||
// ── 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 = Math.round((cfg.poll_interval ?? 300) / 60);
|
||
document.getElementById('resendInterval').value = Math.round((cfg.resend_interval ?? 3600) / 60);
|
||
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) {
|
||
lpEl.textContent = cfg.last_poll
|
||
? 'Letzte Abfrage: ' + new Date(cfg.last_poll).toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'medium' })
|
||
: '';
|
||
}
|
||
const lsEl = document.getElementById('lastSent');
|
||
if (lsEl) {
|
||
lsEl.textContent = cfg.last_sent
|
||
? 'Zuletzt gesendet: ' + new Date(cfg.last_sent).toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'medium' })
|
||
: '';
|
||
}
|
||
}
|
||
|
||
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) || 5) * 60,
|
||
resend_interval: (parseInt(document.getElementById('resendInterval').value) || 60) * 60,
|
||
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) {
|
||
// Replace existing entry with same id (dedup)
|
||
const idx = alerts.findIndex(a => a.id === alert.id);
|
||
if (idx !== -1) alerts.splice(idx, 1);
|
||
alerts.unshift(alert);
|
||
if (alerts.length > MAX_ALERTS) alerts.pop();
|
||
renderAlerts();
|
||
}
|
||
|
||
async function loadAlerts() {
|
||
try {
|
||
const resp = await fetch('/api/nina/alerts');
|
||
if (!resp.ok) return;
|
||
const data = await resp.json();
|
||
if (!Array.isArray(data) || data.length === 0) return;
|
||
data.forEach(a => {
|
||
alerts.push(a);
|
||
});
|
||
if (alerts.length > MAX_ALERTS) alerts.length = MAX_ALERTS;
|
||
renderAlerts();
|
||
} catch (e) {
|
||
console.error('NINA alerts load failed:', e);
|
||
}
|
||
}
|
||
|
||
// ── 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();
|
||
loadAlerts();
|
||
connectWebSocket();
|