fix: stabilize software updates

This commit is contained in:
admin committed 2026-08-29 18:38:38 +02:00
1 parent fc59092030
commit 26a960c6bd
25 files changed
+487 -146

No files matched your search

+141 -9
View File
@@ -1,4 +1,32 @@
(() => {
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;
@@ -255,22 +283,101 @@
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 setUpdatePhase = (phase, text) => {
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;
const currentIndex = phaseOrder.indexOf(phase);
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);
node.classList.toggle('is-active', nodeIndex === currentIndex && phase !== 'complete' && phase !== 'failed');
node.classList.toggle('is-complete', currentIndex > nodeIndex || phase === 'complete');
}
};
@@ -292,11 +399,11 @@
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.',
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.');
setUpdatePhase(phase, messages[phase] || state.message || 'Update läuft.', state.updated_at);
if (phase === 'complete') {
polling = false;
window.setTimeout(() => window.location.assign('/system'), 1400);
@@ -308,7 +415,11 @@
return;
}
} catch {
setUpdatePhase('reconnecting', 'Dienst startet neu – Verbindung wird automatisch wiederhergestellt.');
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);
};
@@ -318,17 +429,37 @@
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: formData, headers: {'Accept': 'application/json'}});
const result = await response.json().catch(() => ({}));
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;
startedAt = Date.now();
window.setTimeout(pollSoftwareUpdate, 400);
} catch (error) {
setUpdatePhase('failed', error instanceof Error ? error.message : 'Update konnte nicht gestartet werden.');
@@ -342,6 +473,7 @@
overlay.hidden = true;
document.body.classList.remove('software-update-open');
close.hidden = true;
stopProgressTimer();
for (const form of softwareUpdateForms) form.querySelector('button')?.removeAttribute('disabled');
});
}