fix: stabilize software updates
This commit is contained in:
1 parent
fc59092030
commit
26a960c6bd
25 files changed
+487
-146
No files matched your search
+141
-9
@@ -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');
|
||||
});
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user