(() => { const localTimeFormatter = new Intl.DateTimeFormat('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false, }); for (const node of document.querySelectorAll('[data-local-time]')) { const unixTime = Number(node.dataset.localTime || 0); if (!Number.isFinite(unixTime) || unixTime <= 0) continue; node.textContent = localTimeFormatter.format(new Date(unixTime * 1000)).replace(',', ' ·'); } const updateBanner = document.querySelector('[data-software-update-banner]'); if (updateBanner) { const version = updateBanner.dataset.softwareUpdateBanner || 'unknown'; const storageKey = 'domain-control:update-banner-seen-version'; try { if (window.localStorage.getItem(storageKey) !== version) { window.localStorage.setItem(storageKey, version); window.requestAnimationFrame(() => updateBanner.classList.add('should-animate')); } } catch { // A blocked storage API must not make the banner flicker on every page. } } const toast = document.querySelector('[data-toast]'); if (toast) { const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; const region = toast.closest('.toast-region'); const duration = toast.classList.contains('toast-error') ? 6500 : 4800; let remaining = duration; let startedAt = 0; let dismissTimer; let timerRunning = false; const dismiss = () => { if (!toast.isConnected || toast.classList.contains('is-leaving')) return; window.clearTimeout(dismissTimer); timerRunning = false; toast.classList.add('is-leaving'); if (reducedMotion) { region?.remove(); return; } window.setTimeout(() => region?.remove(), 220); }; const resume = () => { if (timerRunning || toast.classList.contains('is-leaving')) return; toast.classList.remove('is-paused'); startedAt = performance.now(); timerRunning = true; dismissTimer = window.setTimeout(dismiss, remaining); }; const pause = () => { if (!timerRunning || toast.classList.contains('is-leaving')) return; remaining = Math.max(0, remaining - (performance.now() - startedAt)); window.clearTimeout(dismissTimer); timerRunning = false; toast.classList.add('is-paused'); }; toast.querySelector('[data-toast-close]')?.addEventListener('click', dismiss); toast.addEventListener('pointerenter', pause); toast.addEventListener('pointerleave', () => { if (!toast.contains(document.activeElement)) resume(); }); toast.addEventListener('focusin', pause); toast.addEventListener('focusout', () => { window.requestAnimationFrame(() => { if (!toast.matches(':hover') && !toast.contains(document.activeElement)) resume(); }); }); resume(); } const currentURL = new URL(window.location.href); if (currentURL.searchParams.has('notice')) { currentURL.searchParams.delete('notice'); currentURL.searchParams.delete('notice_kind'); const cleanLocation = `${currentURL.pathname}${currentURL.search}${currentURL.hash}`; window.history.replaceState(window.history.state, '', cleanLocation); } const settingsForm = document.querySelector('[data-settings-form]'); if (settingsForm) { const updaterToggle = settingsForm.querySelector('[data-updater-toggle]'); const ipv6Toggle = settingsForm.querySelector('[data-ipv6-toggle]'); const ipv6Settings = settingsForm.querySelector('[data-ipv6-settings]'); const syncToggleCard = (input) => { if (!input) return; const card = input.closest('.setting-switch-card'); card?.classList.toggle('is-enabled', input.checked); const label = input.closest('.setting-switch')?.querySelector('b'); if (label) label.textContent = input.checked ? 'Aktiv' : 'Inaktiv'; }; const syncIPv6Settings = () => { if (!ipv6Toggle || !ipv6Settings) return; ipv6Settings.classList.toggle('is-disabled', !ipv6Toggle.checked); ipv6Settings.setAttribute('aria-disabled', String(!ipv6Toggle.checked)); const textarea = ipv6Settings.querySelector('textarea'); if (textarea) textarea.required = ipv6Toggle.checked; }; updaterToggle?.addEventListener('change', () => syncToggleCard(updaterToggle)); ipv6Toggle?.addEventListener('change', () => { syncToggleCard(ipv6Toggle); syncIPv6Settings(); }); syncToggleCard(updaterToggle); syncToggleCard(ipv6Toggle); syncIPv6Settings(); } const setupForm = document.querySelector('[data-setup-form]'); if (setupForm) { const password = setupForm.querySelector('[data-setup-password]'); const confirmation = setupForm.querySelector('[data-setup-confirm]'); const lengthHint = setupForm.querySelector('[data-password-length]'); const matchHint = setupForm.querySelector('[data-password-match]'); const syncPasswordGuidance = () => { const length = password ? [...password.value].length : 0; const longEnough = length >= 15 && length <= 128; const matches = Boolean(confirmation?.value) && confirmation.value === password?.value; lengthHint?.classList.toggle('is-valid', longEnough); matchHint?.classList.toggle('is-valid', matches); lengthHint?.setAttribute('data-state', longEnough ? 'valid' : 'pending'); matchHint?.setAttribute('data-state', matches ? 'valid' : 'pending'); password?.classList.toggle('is-valid', longEnough); confirmation?.classList.toggle('is-valid', matches); setupForm.classList.toggle('is-password-ready', longEnough && matches); confirmation?.setCustomValidity(confirmation.value && !matches ? 'Die Passphrasen stimmen nicht überein.' : ''); }; password?.addEventListener('input', syncPasswordGuidance); confirmation?.addEventListener('input', syncPasswordGuidance); syncPasswordGuidance(); } for (const form of document.querySelectorAll('[data-target-form]')) { const active = form.querySelector('[data-target-active]'); const staticFields = form.querySelector('[data-static-ip-fields]'); const ipv4Toggle = form.querySelector('[name="update_ipv4"]'); const ipv6Toggle = form.querySelector('[name="update_ipv6"]'); const staticIPv4 = form.querySelector('[data-static-ipv4]'); const staticIPv6 = form.querySelector('[data-static-ipv6]'); const requirement = form.querySelector('[data-target-requirement]'); const submit = form.querySelector('button[type="submit"]'); const ipv6Enabled = form.dataset.ipv6Enabled !== 'false'; const ipv4Selected = () => ipv4Toggle ? ipv4Toggle.checked : form.dataset.updateIpv4 === 'true'; const ipv6Selected = () => ipv6Enabled && (ipv6Toggle ? ipv6Toggle.checked : form.dataset.updateIpv6 === 'true'); const syncStaticFields = () => { if (!active || !staticFields) return; const useIPv4 = ipv4Selected(); const useIPv6 = ipv6Selected(); const hasFamily = useIPv4 || useIPv6; if (active.checked && !hasFamily) active.checked = false; active.disabled = !hasFamily; active.closest('.switch')?.classList.toggle('switch-disabled', !hasFamily); const useStatic = !active.checked && hasFamily; staticFields.hidden = !useStatic; staticFields.setAttribute('aria-hidden', String(!useStatic)); if (staticIPv4) { staticIPv4.disabled = !useStatic || !useIPv4; staticIPv4.required = useStatic && useIPv4; } if (staticIPv6) { staticIPv6.disabled = !useStatic || !useIPv6; staticIPv6.required = useStatic && useIPv6; } const missingStaticIP = useStatic && ( (useIPv4 && !staticIPv4?.value.trim()) || (useIPv6 && !staticIPv6?.value.trim()) ); if (submit) submit.disabled = missingStaticIP; if (requirement) requirement.hidden = !missingStaticIP; }; active?.addEventListener('change', syncStaticFields); for (const familyToggle of [ipv4Toggle, ipv6Toggle]) { familyToggle?.addEventListener('change', () => { if (!familyToggle.checked && active) active.checked = false; syncStaticFields(); }); } staticIPv4?.addEventListener('input', syncStaticFields); staticIPv6?.addEventListener('input', syncStaticFields); syncStaticFields(); } for (const zone of document.querySelectorAll('[data-access-zone]')) { const access = zone.querySelector('[data-zone-access]'); const manage = zone.querySelector('[data-zone-manage]'); const create = zone.querySelector('[data-zone-create]'); access?.addEventListener('change', () => { if (!access.checked) { if (manage) manage.checked = false; if (create) create.checked = false; } }); manage?.addEventListener('change', () => { if (manage.checked && access) access.checked = true; if (!manage.checked && create) create.checked = false; }); create?.addEventListener('change', () => { if (create.checked) { if (access) access.checked = true; if (manage) manage.checked = true; } }); } for (const target of document.querySelectorAll('[data-access-target]')) { const access = target.querySelector('[data-target-access]'); const manage = target.querySelector('[data-target-manage]'); access?.addEventListener('change', () => { if (!access.checked && manage) manage.checked = false; }); manage?.addEventListener('change', () => { if (manage.checked && access) access.checked = true; }); } for (const form of document.querySelectorAll('[data-dirty-form]')) { const submit = form.querySelector('[data-dirty-submit]'); if (!submit) continue; const state = () => new URLSearchParams(new FormData(form)).toString(); const initialState = state(); const syncDirtyState = () => { const dirty = state() !== initialState; submit.disabled = !dirty; submit.setAttribute('aria-disabled', String(!dirty)); }; form.addEventListener('input', syncDirtyState); form.addEventListener('change', syncDirtyState); form.addEventListener('submit', (event) => { syncDirtyState(); if (submit.disabled) event.preventDefault(); }); syncDirtyState(); } for (const details of document.querySelectorAll('details[data-scroll-page-end]')) { details.addEventListener('toggle', () => { if (!details.open) return; window.requestAnimationFrame(() => { window.scrollTo({ top: document.documentElement.scrollHeight, behavior: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth', }); }); }); } const copyButtons = document.querySelectorAll('[data-copy-target]'); for (const button of copyButtons) { button.addEventListener('click', async () => { const target = document.getElementById(button.dataset.copyTarget); if (!target) return; try { await navigator.clipboard.writeText(target.textContent); const original = button.textContent; button.textContent = 'Kopiert'; window.setTimeout(() => { button.textContent = original; }, 2000); } catch { button.textContent = 'Manuell kopieren'; } }); } const softwareUpdateForms = [...document.querySelectorAll('[data-software-update-form]')]; if (softwareUpdateForms.length > 0) { const overlay = document.querySelector('[data-software-update-overlay]'); const title = overlay?.querySelector('[data-software-update-title]'); const message = overlay?.querySelector('[data-software-update-message]'); const progress = overlay?.querySelector('[data-software-update-progress]'); const progressTrack = overlay?.querySelector('[data-software-update-progress-track]'); const progressLabel = overlay?.querySelector('[data-software-update-progress-label]'); const percent = overlay?.querySelector('[data-software-update-percent]'); const close = overlay?.querySelector('[data-software-update-close]'); const phaseNodes = [...(overlay?.querySelectorAll('[data-update-phase]') || [])]; const phaseOrder = ['pulling', 'restarting', 'verifying']; const phasePresentation = { queued: {title: 'Update wird vorbereitet', label: 'Vorbereitung', start: 4, end: 9, timeConstant: 10000}, pulling: {title: 'Image wird sicher geladen', label: 'Image-Download', start: 12, end: 42, timeConstant: 22000}, restarting: {title: 'Container wird gewechselt', label: 'Containerwechsel', start: 44, end: 51, timeConstant: 6000}, reconnecting: {title: 'Verbindung wird aufgebaut', label: 'Neustart', start: 52, end: 57, timeConstant: 15000}, verifying: {title: 'Neue Instanz wird geprüft', label: 'Healthcheck', start: 58, end: 98, timeConstant: 35000}, complete: {title: 'Update erfolgreich', label: 'Abgeschlossen', start: 100, end: 100, timeConstant: 1}, failed: {title: 'Update nicht abgeschlossen', label: 'Angehalten'}, }; let polling = false; let targetVersion = ''; let startedAt = 0; let lastKnownPhase = 'pulling'; let currentPhase = ''; let phaseStartedAt = 0; let displayedProgress = 0; let progressTimer; const progressTickInterval = window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 1000 : 500; const formatElapsed = (milliseconds) => { const seconds = Math.max(0, Math.floor(milliseconds / 1000)); return `${String(Math.floor(seconds / 60)).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`; }; const stopProgressTimer = () => { if (progressTimer) window.clearTimeout(progressTimer); progressTimer = undefined; }; const renderProgress = (value) => { const bounded = Math.max(0, Math.min(100, value)); displayedProgress = Math.max(displayedProgress, bounded); if (progress) progress.style.width = `${displayedProgress.toFixed(2)}%`; if (percent) percent.textContent = `${Math.floor(displayedProgress)}%`; progressTrack?.setAttribute('aria-valuenow', String(Math.floor(displayedProgress))); }; const phaseTime = (raw) => { const now = Date.now(); const parsed = Date.parse(String(raw || '')); if (Number.isFinite(parsed) && parsed >= now - (12 * 60 * 1000) && parsed <= now + 60000) { return Math.min(parsed, now); } return now; }; const tickProgress = () => { progressTimer = undefined; const presentation = phasePresentation[currentPhase]; if (!presentation || typeof presentation.end !== 'number' || presentation.end <= presentation.start) return; const elapsed = Math.max(0, Date.now() - phaseStartedAt); const distance = presentation.end - presentation.start; const next = presentation.start + distance * (1 - Math.exp(-elapsed / presentation.timeConstant)); renderProgress(next); if (progressLabel) { progressLabel.textContent = currentPhase === 'verifying' || currentPhase === 'reconnecting' ? `${presentation.label} · ${formatElapsed(elapsed)}` : presentation.label; } progressTimer = window.setTimeout(tickProgress, progressTickInterval); }; const setUpdatePhase = (phase, text, updatedAt = '') => { if (!overlay) return; const phaseChanged = currentPhase !== phase; if (phaseChanged) { currentPhase = phase; phaseStartedAt = phaseTime(updatedAt); stopProgressTimer(); } else if (updatedAt) { phaseStartedAt = Math.min(phaseStartedAt, phaseTime(updatedAt)); } overlay.dataset.updateState = phase; overlay.setAttribute('aria-busy', String(phase !== 'complete' && phase !== 'failed')); const presentation = phasePresentation[phase] || phasePresentation.queued; if (title) title.textContent = presentation.title; if (progressLabel) progressLabel.textContent = presentation.label; if (typeof presentation.start === 'number') renderProgress(presentation.start); if (phaseChanged && presentation.end > presentation.start) tickProgress(); if (message && text) message.textContent = text; if (phaseOrder.includes(phase)) lastKnownPhase = phase; const visualPhase = phase === 'reconnecting' || phase === 'failed' ? lastKnownPhase : phase; const currentIndex = phaseOrder.indexOf(visualPhase); for (const node of phaseNodes) { const nodeIndex = phaseOrder.indexOf(node.dataset.updatePhase); node.classList.toggle('is-active', nodeIndex === currentIndex && phase !== 'complete' && phase !== 'failed'); node.classList.toggle('is-complete', currentIndex > nodeIndex || phase === 'complete'); } }; const pollSoftwareUpdate = async () => { if (!polling) return; if (Date.now() - startedAt > 12 * 60 * 1000) { polling = false; setUpdatePhase('failed', 'Das Update dauert ungewöhnlich lange. Der DNS-Dienst läuft unabhängig davon weiter; prüfe den Status erneut.'); if (close) close.hidden = false; return; } try { const response = await fetch('/api/v1/software-update/status', {headers: {'Accept': 'application/json'}, cache: 'no-store'}); if (!response.ok) throw new Error('status unavailable'); const state = await response.json(); const phase = state.phase || 'queued'; const messages = { queued: 'Update ist eingereiht.', pulling: 'Das neue Container-Image wird geladen. Der laufende Dienst bleibt dabei online.', restarting: 'Der Dienst wechselt kontrolliert auf die neue Version.', verifying: 'Die neue Instanz durchläuft den Healthcheck. Dieser Schritt kann etwas dauern.', complete: `Version ${state.target_version || targetVersion} ist bereit.`, failed: state.message || 'Update fehlgeschlagen. Die bisherige Version bleibt aktiv.', }; setUpdatePhase(phase, messages[phase] || state.message || 'Update läuft.', state.updated_at); if (phase === 'complete') { polling = false; window.setTimeout(() => window.location.assign('/system'), 1400); return; } if (phase === 'failed') { polling = false; if (close) close.hidden = false; return; } } catch { const recoveryPhase = currentPhase === 'verifying' ? 'verifying' : 'reconnecting'; const recoveryMessage = recoveryPhase === 'verifying' ? 'Der Healthcheck läuft weiter – die Statusverbindung wird automatisch wiederhergestellt.' : 'Dienst startet neu – Verbindung wird automatisch wiederhergestellt.'; setUpdatePhase(recoveryPhase, recoveryMessage); } window.setTimeout(pollSoftwareUpdate, 1500); }; for (const form of softwareUpdateForms) { form.addEventListener('submit', async (event) => { event.preventDefault(); if (polling || !overlay) return; const formData = new FormData(form); const requestBody = new URLSearchParams(); for (const [name, value] of formData) { if (typeof value === 'string') requestBody.append(name, value); } targetVersion = String(formData.get('version') || ''); stopProgressTimer(); currentPhase = ''; phaseStartedAt = 0; displayedProgress = 0; lastKnownPhase = 'pulling'; if (progress) progress.style.width = '0%'; if (percent) percent.textContent = '0%'; progressTrack?.setAttribute('aria-valuenow', '0'); startedAt = Date.now(); overlay.hidden = false; if (close) close.hidden = true; document.body.classList.add('software-update-open'); setUpdatePhase('queued', `Update auf ${targetVersion} wird vorbereitet.`); for (const candidate of softwareUpdateForms) candidate.querySelector('button')?.setAttribute('disabled', ''); try { const response = await fetch(form.action, {method: 'POST', body: requestBody, headers: {'Accept': 'application/json'}, credentials: 'same-origin'}); const contentType = response.headers.get('content-type') || ''; let result = {}; if (contentType.includes('application/json')) { result = await response.json().catch(() => ({})); } else { const responseText = (await response.text()).trim(); if (responseText && !responseText.startsWith('<')) result.message = responseText.slice(0, 240); } if (!response.ok || !result.accepted) throw new Error(result.message || 'Update konnte nicht gestartet werden.'); polling = true; window.setTimeout(pollSoftwareUpdate, 400); } catch (error) { setUpdatePhase('failed', error instanceof Error ? error.message : 'Update konnte nicht gestartet werden.'); if (close) close.hidden = false; for (const candidate of softwareUpdateForms) candidate.querySelector('button')?.removeAttribute('disabled'); } }); } close?.addEventListener('click', () => { overlay.hidden = true; document.body.classList.remove('software-update-open'); close.hidden = true; stopProgressTimer(); for (const form of softwareUpdateForms) form.querySelector('button')?.removeAttribute('disabled'); }); } const containerRestartForm = document.querySelector('[data-container-restart-form]'); if (containerRestartForm) { const overlay = document.querySelector('[data-container-restart-overlay]'); const title = overlay?.querySelector('[data-container-restart-title]'); const message = overlay?.querySelector('[data-container-restart-message]'); const close = overlay?.querySelector('[data-container-restart-close]'); const submit = containerRestartForm.querySelector('button[type="submit"]'); let polling = false; let startedAt = 0; let expectedInstance = containerRestartForm.dataset.instanceId || ''; let consecutiveReady = 0; let sawUnavailable = false; const setRestartState = (state, heading, text) => { if (!overlay) return; overlay.dataset.updateState = state; overlay.setAttribute('aria-busy', String(state !== 'complete' && state !== 'failed')); if (title) title.textContent = heading; if (message) message.textContent = text; }; const pollContainerRestart = async () => { if (!polling) return; if (Date.now() - startedAt > 90 * 1000) { polling = false; setRestartState('failed', 'Neustart nicht bestätigt', 'Die neue Container-Instanz wurde nicht rechtzeitig erreichbar. Prüfe den Containerstatus am Docker-Host.'); if (close) close.hidden = false; return; } try { const response = await fetch(`/healthz?restart-check=${Date.now()}`, {cache: 'no-store', credentials: 'same-origin'}); if (!response.ok) throw new Error('healthcheck unavailable'); const currentInstance = response.headers.get('X-Domain-Control-Instance') || ''; const isNewInstance = expectedInstance ? (currentInstance !== '' && currentInstance !== expectedInstance) || (currentInstance === '' && sawUnavailable) : sawUnavailable; if (isNewInstance) { consecutiveReady += 1; setRestartState('restarting', 'Neue Instanz wird geprüft', 'Der Container ist wieder erreichbar. Die Verbindung wird noch einmal bestätigt.'); if (consecutiveReady >= 2) { polling = false; setRestartState('complete', 'Container neu gestartet', 'Die neue Container-Instanz ist bereit. Die Seite wird neu geladen.'); window.setTimeout(() => window.location.assign('/system'), 900); return; } } else { consecutiveReady = 0; setRestartState('restarting', 'Container wird neu gestartet', 'Die bisherige Instanz wird geordnet beendet.'); } } catch { sawUnavailable = true; consecutiveReady = 0; setRestartState('restarting', 'Verbindung wird aufgebaut', 'Der Container ist kurz nicht erreichbar und startet automatisch neu.'); } window.setTimeout(pollContainerRestart, 700); }; containerRestartForm.addEventListener('submit', async (event) => { event.preventDefault(); if (polling || !overlay) return; if (!window.confirm('Den vollständigen DynDNS-Container jetzt neu starten? Der Dienst ist kurz nicht erreichbar.')) return; const formData = new FormData(containerRestartForm); const requestBody = new URLSearchParams(); for (const [name, value] of formData) { if (typeof value === 'string') requestBody.append(name, value); } polling = true; startedAt = Date.now(); consecutiveReady = 0; sawUnavailable = false; overlay.hidden = false; if (close) close.hidden = true; if (submit) submit.disabled = true; document.body.classList.add('software-update-open'); setRestartState('restarting', 'Neustart wird vorbereitet', 'Die Anwendung wird geordnet beendet und automatisch wieder gestartet.'); let responseReceived = false; try { const response = await fetch(containerRestartForm.action, {method: 'POST', body: requestBody, headers: {'Accept': 'application/json'}, credentials: 'same-origin'}); responseReceived = true; const contentType = response.headers.get('content-type') || ''; const result = contentType.includes('application/json') ? await response.json().catch(() => ({})) : {message: (await response.text()).trim().slice(0, 240)}; if (!response.ok || !result.accepted) throw new Error(result.message || 'Container-Neustart konnte nicht gestartet werden.'); expectedInstance = result.instance_id || expectedInstance; } catch (error) { if (responseReceived) { polling = false; setRestartState('failed', 'Neustart nicht gestartet', error instanceof Error ? error.message : 'Container-Neustart konnte nicht gestartet werden.'); if (close) close.hidden = false; if (submit) submit.disabled = false; return; } sawUnavailable = true; } window.setTimeout(pollContainerRestart, 350); }); close?.addEventListener('click', () => { polling = false; overlay.hidden = true; close.hidden = true; document.body.classList.remove('software-update-open'); if (submit) submit.disabled = false; }); } const countdowns = [...document.querySelectorAll('[data-countdown]')]; const currentIPv4Fields = [...document.querySelectorAll('[data-current-ipv4-placeholder]')]; const dashboardIPv4 = document.querySelector('[data-dashboard-ipv4]'); const dashboardIPv6 = document.querySelector('[data-dashboard-ipv6]'); const recentRuns = document.querySelector('[data-recent-runs]'); const runNowButton = document.querySelector('[data-run-now-button]'); if (countdowns.length === 0 && currentIPv4Fields.length === 0 && !dashboardIPv4 && !dashboardIPv6 && !recentRuns && !runNowButton) return; let nextRun = Number(countdowns[0]?.dataset.countdown || 0); let running = countdowns[0]?.dataset.running === 'true'; let enabled = countdowns[0]?.dataset.enabled !== 'false'; let lastRefresh = 0; let refreshing = false; let recentRunsSignature = ''; const formatRemaining = (seconds) => { const hours = Math.floor(seconds / 3600); const minutes = Math.floor((seconds % 3600) / 60); const rest = seconds % 60; if (hours > 0) return `${hours}:${String(minutes).padStart(2, '0')}:${String(rest).padStart(2, '0')}`; return `${String(minutes).padStart(2, '0')}:${String(rest).padStart(2, '0')}`; }; const runStatusClass = (value) => { const status = String(value || '').toLowerCase(); if (status.includes('ok') || status.includes('unverändert') || status.includes('aktualisiert') || status.includes('angelegt')) return 'status-good'; if (status.includes('error') || status.includes('fehler') || status.includes('partial')) return 'status-bad'; return 'status-warn'; }; const formatRunTime = (unixTime) => { const date = new Date(Number(unixTime || 0) * 1000); if (Number.isNaN(date.getTime())) return '–'; const day = String(date.getDate()).padStart(2, '0'); const month = String(date.getMonth() + 1).padStart(2, '0'); const hours = String(date.getHours()).padStart(2, '0'); const minutes = String(date.getMinutes()).padStart(2, '0'); return `${day}.${month}.${date.getFullYear()} · ${hours}:${minutes}`; }; const renderRecentRuns = (runs) => { if (!recentRuns || !Array.isArray(runs)) return; const signature = JSON.stringify(runs.map((run) => [run.id, run.started_at_unix, run.status, run.summary])); if (signature === recentRunsSignature) return; recentRunsSignature = signature; if (runs.length === 0) { const empty = document.createElement('div'); empty.className = 'empty-state'; empty.textContent = 'Noch keine Änderungen oder Fehler protokolliert.'; recentRuns.replaceChildren(empty); return; } const list = document.createElement('div'); list.className = 'data-list'; for (const run of runs) { const status = String(run.status || 'unbekannt'); const statusClass = runStatusClass(status); const summary = String(run.summary || '').trim() || (status === 'running' ? 'Aktualisierung läuft' : 'Update-Lauf'); const row = document.createElement('div'); row.className = 'data-row'; const dot = document.createElement('span'); dot.className = `state-dot ${statusClass}`; const details = document.createElement('span'); const title = document.createElement('strong'); title.textContent = summary; const time = document.createElement('small'); time.textContent = formatRunTime(run.started_at_unix); details.append(title, time); const pill = document.createElement('span'); pill.className = `status-pill ${statusClass}`; pill.textContent = status; row.append(dot, details, pill); list.append(row); } recentRuns.replaceChildren(list); }; const renderScheduleState = (state) => { const lastIPv4 = typeof state.last_ipv4 === 'string' ? state.last_ipv4.trim() : ''; const lastIPv6 = typeof state.last_ipv6 === 'string' ? state.last_ipv6.trim() : ''; if (lastIPv4) { for (const field of currentIPv4Fields) field.placeholder = lastIPv4; } if (dashboardIPv4) dashboardIPv4.textContent = lastIPv4 || 'Nicht ermittelt'; if (dashboardIPv6) dashboardIPv6.textContent = state.ipv6_enabled === false ? 'Deaktiviert' : (lastIPv6 || 'Nicht ermittelt'); if (runNowButton) { runNowButton.disabled = !enabled || running; runNowButton.textContent = running ? 'Update läuft' : 'Update jetzt ausführen'; if (!enabled) runNowButton.title = 'Updater ist deaktiviert'; else runNowButton.removeAttribute('title'); } if (recentRuns) recentRuns.setAttribute('aria-busy', String(running)); renderRecentRuns(state.recent_runs); }; const refreshSchedule = async () => { if (document.hidden || refreshing) return; const now = Date.now(); if (now - lastRefresh < 2500) return; lastRefresh = now; refreshing = true; try { const endpoint = recentRuns ? '/api/v1/scheduler?runs=1' : '/api/v1/scheduler'; const response = await fetch(endpoint, {headers: {'Accept': 'application/json'}, cache: 'no-store'}); if (!response.ok) return; const state = await response.json(); nextRun = Number(state.next_run_unix || 0); running = Boolean(state.running); enabled = state.enabled !== false; renderScheduleState(state); } catch { // Visible scheduler data keeps its last known state when the request fails. } finally { refreshing = false; } }; const tick = () => { if (document.hidden) return; if (countdowns.length === 0) return; if (!enabled) { for (const node of countdowns) { node.textContent = 'Inaktiv'; node.setAttribute('aria-label', 'Automatischer Updater ist deaktiviert'); } return; } const now = Math.floor(Date.now() / 1000); const remaining = Math.max(0, nextRun - now); const label = running ? 'Update läuft' : (nextRun > 0 ? formatRemaining(remaining) : '--:--'); for (const node of countdowns) { node.textContent = label; node.setAttribute('aria-label', running ? 'Update läuft' : `Nächste Aktualisierung in ${remaining} Sekunden`); } if (running || nextRun === 0 || remaining === 0) refreshSchedule(); }; if (countdowns.length > 0) { tick(); window.setInterval(tick, 1000); } refreshSchedule(); window.setInterval(refreshSchedule, 30000); document.addEventListener('visibilitychange', () => { if (document.hidden) return; lastRefresh = 0; tick(); refreshSchedule(); }); })();