Files
hetzner-api-dyndns-records-…/internal/web/static/app.js
T
admin f02ec9977c
Container-Image veröffentlichen / container (push) Has been cancelled
feat: add resilient updates and access controls
2026-08-28 14:02:07 +02:00

419 lines
17 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 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 message = overlay?.querySelector('[data-software-update-message]');
const close = overlay?.querySelector('[data-software-update-close]');
const phaseNodes = [...(overlay?.querySelectorAll('[data-update-phase]') || [])];
const phaseOrder = ['pulling', 'restarting', 'verifying'];
let polling = false;
let targetVersion = '';
let startedAt = 0;
const setUpdatePhase = (phase, text) => {
if (!overlay) return;
overlay.dataset.updateState = phase;
if (message && text) message.textContent = text;
const currentIndex = phaseOrder.indexOf(phase);
for (const node of phaseNodes) {
const nodeIndex = phaseOrder.indexOf(node.dataset.updatePhase);
node.classList.toggle('is-active', nodeIndex === currentIndex);
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.',
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.');
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 {
setUpdatePhase('reconnecting', 'Dienst startet neu – Verbindung wird automatisch wiederhergestellt.');
}
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);
targetVersion = String(formData.get('version') || '');
overlay.hidden = false;
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: formData, headers: {'Accept': 'application/json'}});
const result = await response.json().catch(() => ({}));
if (!response.ok || !result.accepted) throw new Error(result.message || 'Update konnte nicht gestartet werden.');
polling = true;
startedAt = Date.now();
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;
for (const form of softwareUpdateForms) form.querySelector('button')?.removeAttribute('disabled');
});
}
const countdowns = [...document.querySelectorAll('[data-countdown]')];
const currentIPv4Fields = [...document.querySelectorAll('[data-current-ipv4-placeholder]')];
if (countdowns.length === 0 && currentIPv4Fields.length === 0) 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;
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 refreshSchedule = async () => {
if (document.hidden) return;
const now = Date.now();
if (now - lastRefresh < 2500) return;
lastRefresh = now;
try {
const response = await fetch('/api/v1/scheduler', {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;
if (typeof state.last_ipv4 === 'string' && state.last_ipv4.trim()) {
for (const field of currentIPv4Fields) field.placeholder = state.last_ipv4.trim();
}
} catch {
// Visible scheduler data keeps its last known state when the request fails.
}
};
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();
});
})();