Files
KantSites/public/assets/system-update.js
T
kantarciandClaude Opus 5.5 a6a334fe68 Show a maintenance page instead of a blank 500 and update in one click
Emergency layer logs fatal errors to var/log/notfall.log and answers 503.
The system page checks for a new release on load, shows its notes, runs
backup, download, switch, migration and health check in one flow with live
progress, and switches back automatically when the new version fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 18:33:53 +02:00

191 lines
9.0 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.
/*
* Update mit einem Klick (ADR 113), nach dem Vorbild von reiseSoft.
*
* Beim Öffnen der Systemseite fragt sie selbst, ob es eine neue Fassung gibt, und zeigt deren
* Hinweise und einen Knopf. Nach der Bestätigung startet eine Anfrage das Update (Sichern, Laden,
* Umschalten); diese Seite fragt währenddessen alle anderthalb Sekunden den Fortschritt ab. Ist
* der Tausch durch, ruft sie den Abschluss auf - eine neue Anfrage mit dem neuen Code:
* Datenbank anpassen, prüfen, Wartungsseite aus. Schlägt etwas fehl, schaltet der Server zurück
* und die Seite sagt es.
*
* Ohne Skript bleiben die Formulare unter „Weitere Möglichkeiten“.
*/
const karte = document.querySelector('[data-update]');
if (karte) {
const live = karte.querySelector('[data-update-live]');
const dialog = document.getElementById('update-dialog');
const csrf = karte.dataset.csrf;
const SCHRITTE = [
['sichern', 'Daten sichern'],
['laden', 'Fassung laden und prüfen'],
['umschalten', 'Umschalten'],
['migrieren', 'Datenbank anpassen'],
['pruefen', 'Funktion prüfen'],
];
let ziel = null, timer = null, laeuft = false;
const el = (tag, className, text) => {
const node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = text;
return node;
};
const datum = iso => iso ? new Date(iso + 'T00:00:00').toLocaleDateString('de-DE', { day: 'numeric', month: 'long', year: 'numeric' }) : '';
const abfragen = async url => {
const antwort = await fetch(url, { credentials: 'same-origin', cache: 'no-store', headers: { Accept: 'application/json' } });
if (!antwort.ok) throw new Error(String(antwort.status));
return antwort.json();
};
const senden = async (url, daten) => {
const antwort = await fetch(url, {
method: 'POST', credentials: 'same-origin', cache: 'no-store',
headers: { 'X-CSRF-Token': csrf, Accept: 'application/json' },
body: new URLSearchParams(daten),
});
const text = await antwort.text();
try { return JSON.parse(text); } catch { throw new Error(`Antwort ${antwort.status}`); }
};
// Die Hinweise der Releaseseite sind Markdown. Gezeigt wird Text mit Zeilen und **fett** -
// als Textknoten, nie als HTML: was dort steht, kommt von außen.
function notizen(text) {
const box = el('div', 'update-notizen');
text.split('\n').forEach((zeile, index) => {
if (index) box.append(document.createElement('br'));
zeile.replace(/^#+\s*/, '').split(/(\*\*[^*]+\*\*)/).forEach(teil => {
box.append(teil.startsWith('**') && teil.endsWith('**') && teil.length > 4 ? el('strong', '', teil.slice(2, -2)) : document.createTextNode(teil));
});
});
return box;
}
// ------------------------------------------------------------------ Ansicht: Prüfen
function warten(text) {
live.hidden = false;
live.replaceChildren(el('p', 'update-wait', text));
live.firstChild.prepend(el('span', 'update-spinner'));
}
function zeigeErgebnis(daten) {
live.hidden = false;
live.replaceChildren();
const letzter = daten.status;
if (letzter && !letzter.laeuft && ['fertig', 'fehler', 'zurueckgerollt'].includes(letzter.phase) && letzter.auf) {
const box = el('p', `notice ${letzter.phase === 'fertig' ? 'done' : 'error'} update-letztes`);
box.append(el('strong', '', letzter.phase === 'fertig' ? `Letztes Update auf ${letzter.auf} erfolgreich.` : `Letztes Update auf ${letzter.auf} nicht durchgelaufen.`));
if (letzter.phase !== 'fertig') box.append(' ', letzter.meldung || '');
live.append(box);
}
if (daten.fehler) {
const zeile = el('p', 'notice error', daten.fehler + ' ');
const nochmal = el('button', 'link-button', 'Erneut fragen'); nochmal.type = 'button';
nochmal.addEventListener('click', pruefen);
zeile.append(nochmal); live.append(zeile);
return;
}
if (!daten.neu) {
live.append(el('p', 'update-aktuell', `Du bist auf dem neuesten Stand (${daten.installiert}).`));
return;
}
ziel = daten.neu.version;
const box = el('div', 'update-neu');
box.append(el('p', 'update-neu-label', 'Neue Fassung verfügbar'));
box.append(el('h3', '', daten.neu.name || `KantSites ${ziel}`));
box.append(el('p', 'muted', [ziel, daten.neu.datum ? `veröffentlicht am ${datum(daten.neu.datum)}` : ''].filter(Boolean).join(' · ')));
if (daten.neu.notizen) box.append(notizen(daten.neu.notizen));
const knopf = el('button', 'primary', 'Jetzt aktualisieren'); knopf.type = 'button';
knopf.addEventListener('click', () => { dialog.querySelector('[data-update-ziel]').textContent = ziel; dialog.showModal(); });
box.append(knopf);
live.append(box);
}
async function pruefen() {
warten('Frage nach neuen Fassungen …');
try {
const daten = await abfragen(karte.dataset.pruefen);
if (daten.status?.laeuft) { zeigeLauf(daten.status); verfolgen(); return; }
zeigeErgebnis(daten);
} catch {
zeigeErgebnis({ fehler: 'Die Abfrage ist fehlgeschlagen.' });
}
}
// ------------------------------------------------------------------ Ansicht: Lauf
function zeigeLauf(status, server = true) {
live.hidden = false;
const box = el('div', 'update-lauf');
const kopf = el('p', 'update-lauf-kopf');
const ende = ['fertig', 'fehler', 'zurueckgerollt'].includes(status.phase);
if (!ende) kopf.append(el('span', 'update-spinner'));
kopf.append(ende
? (status.phase === 'fertig' ? `Update auf ${status.auf} abgeschlossen` : `Update auf ${status.auf} nicht durchgelaufen`)
: `Update auf ${status.auf || ziel || ''} läuft`);
box.append(kopf);
// Bei einem Fehler oder beim Zurückschalten markiert die Liste den Schritt, in dem es passierte.
const gescheitert = ['fehler', 'zurueckgerollt', 'zurueckschalten'].includes(status.phase);
const aktuell = gescheitert
? Math.max(0, SCHRITTE.findIndex(([phase]) => phase === status.bei))
: SCHRITTE.findIndex(([phase]) => phase === status.phase);
const liste = el('ol', 'update-schritte');
SCHRITTE.forEach(([phase, text], index) => {
let zustand = index < aktuell || status.phase === 'fertig' ? 'erledigt' : index === aktuell && !ende ? 'aktiv' : 'offen';
if (gescheitert && index === aktuell) zustand = 'fehler';
const punkt = el('li', `update-schritt is-${zustand}`);
punkt.append(el('span', 'update-marke', zustand === 'erledigt' ? '✓' : zustand === 'fehler' ? '!' : ''), el('span', '', text));
punkt.querySelector('.update-marke').setAttribute('aria-hidden', 'true');
punkt.append(el('span', 'visually-hidden', { erledigt: ' – erledigt', aktiv: ' – läuft', offen: '', fehler: ' – fehlgeschlagen' }[zustand]));
liste.append(punkt);
});
box.append(liste);
const meldung = el('p', `update-meldung${status.phase === 'fertig' ? ' is-ok' : ende ? ' is-fehler' : ''}`,
server ? (status.meldung || '') : 'Der Server tauscht gerade die Dateien – die Seite meldet sich gleich wieder.');
box.append(meldung);
if (ende) {
const weiter = el('button', 'secondary', status.phase === 'fertig' ? 'Seite neu laden' : 'Zur Übersicht'); weiter.type = 'button';
weiter.addEventListener('click', () => location.reload());
box.append(weiter);
}
live.replaceChildren(box);
}
async function tick() {
try {
const daten = await abfragen(karte.dataset.status);
if (daten.status) zeigeLauf(daten.status);
if (daten.installiert) karte.querySelector('[data-update-installiert]').textContent = daten.installiert;
if (daten.status && !daten.status.laeuft && !laeuft) stoppen();
} catch {
// Während des Tauschs kann eine Abfrage scheitern; die nächste klappt wieder.
const letzter = live.querySelector('.update-meldung');
if (letzter) letzter.textContent = 'Der Server tauscht gerade die Dateien – die Seite meldet sich gleich wieder.';
}
}
function verfolgen() { if (!timer) timer = setInterval(tick, 1500); }
function stoppen() { clearInterval(timer); timer = null; }
async function starten() {
dialog.close();
laeuft = true;
zeigeLauf({ phase: 'sichern', auf: ziel, meldung: 'Das Update beginnt …', laeuft: true });
verfolgen();
try {
const start = await senden(karte.dataset.starten, { version: ziel });
if (!start.ok) throw Object.assign(new Error(start.meldung || 'Das Update ist nicht durchgelaufen.'), { status: start.status });
const ende = await senden(karte.dataset.abschliessen, { token: start.token });
laeuft = false;
await tick();
stoppen();
if (ende.ok) setTimeout(() => location.reload(), 2500);
} catch (fehler) {
laeuft = false;
await tick();
stoppen();
if (!live.querySelector('.update-meldung.is-fehler')) zeigeLauf(fehler.status || { phase: 'fehler', auf: ziel, meldung: fehler.message });
}
}
dialog.querySelector('[data-update-abbrechen]').addEventListener('click', () => dialog.close());
dialog.querySelector('[data-update-los]').addEventListener('click', starten);
pruefen();
}