634 lines
27 KiB
JavaScript
634 lines
27 KiB
JavaScript
(() => {
|
||
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 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();
|
||
});
|
||
})();
|