/* * 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(); }