Files
hetzner-api-dyndns-records-…/internal/web/static/app.js
T

743 lines
33 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.
(() => {
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 kein Update-Lauf 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();
});
})();