feat: add resilient updates and access controls
Container-Image veröffentlichen / container (push) Has been cancelled
Container-Image veröffentlichen / container (push) Has been cancelled
This commit is contained in:
1 parent
a000152423
commit
f02ec9977c
41 files changed
+3277
-151
No files matched your search
+143
-8
@@ -91,6 +91,32 @@
|
||||
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]');
|
||||
@@ -226,12 +252,107 @@
|
||||
});
|
||||
}
|
||||
|
||||
const countdowns = [...document.querySelectorAll('[data-countdown]')];
|
||||
if (countdowns.length === 0) return;
|
||||
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;
|
||||
|
||||
let nextRun = Number(countdowns[0].dataset.countdown || 0);
|
||||
let running = countdowns[0].dataset.running === 'true';
|
||||
let enabled = countdowns[0].dataset.enabled !== 'false';
|
||||
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) => {
|
||||
@@ -243,6 +364,7 @@
|
||||
};
|
||||
|
||||
const refreshSchedule = async () => {
|
||||
if (document.hidden) return;
|
||||
const now = Date.now();
|
||||
if (now - lastRefresh < 2500) return;
|
||||
lastRefresh = now;
|
||||
@@ -253,12 +375,17 @@
|
||||
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 {
|
||||
// The visible countdown keeps its last known state when the status request fails.
|
||||
// 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';
|
||||
@@ -276,8 +403,16 @@
|
||||
if (running || nextRun === 0 || remaining === 0) refreshSchedule();
|
||||
};
|
||||
|
||||
tick();
|
||||
if (countdowns.length > 0) {
|
||||
tick();
|
||||
window.setInterval(tick, 1000);
|
||||
}
|
||||
refreshSchedule();
|
||||
window.setInterval(tick, 1000);
|
||||
window.setInterval(refreshSchedule, 30000);
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden) return;
|
||||
lastRefresh = 0;
|
||||
tick();
|
||||
refreshSchedule();
|
||||
});
|
||||
})();
|
||||
Reference in new issue
Block a user