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>
This commit is contained in:
kantarciandClaude Opus 5.5 committed 2026-10-09 18:33:53 +02:00
1 parent 339a48c810
commit a6a334fe68
15 files changed
+973 -42

No files matched your search

+31
View File
@@ -1049,3 +1049,34 @@ details.form-card > summary.form-card-summary::before, details.form-card[open] >
.language-texts h3 { margin: 0 0 6px; font-size: 15px }
.language-text-group { display: grid; gap: 6px; border: 1px solid #dfe5e1; border-radius: 8px; padding: 12px 16px; margin: 12px 0; min-width: 0 }
.language-text-group legend { font-weight: 600; padding: 0 4px }
/* ---- Update mit einem Klick (ADR 113), nach dem Vorbild von reiseSoft --------------------- */
.update-wait, .update-aktuell { display: flex; align-items: center; gap: 10px; margin: 0 0 12px }
.update-spinner { width: 16px; height: 16px; flex: none; border: 2px solid currentColor; border-right-color: transparent; border-radius: 50%; animation: update-dreh .8s linear infinite }
@keyframes update-dreh { to { transform: rotate(360deg) } }
@media (prefers-reduced-motion: reduce) { .update-spinner { animation-duration: 2.4s } }
.update-neu { border: 1px solid #b9d7c2; background: #f1f8f2; border-radius: 10px; padding: 16px 18px; margin: 0 0 14px; display: grid; gap: 6px; justify-items: start }
.update-neu h3 { margin: 0; font-size: 17px }
.update-neu .muted { margin: 0 }
.update-neu-label { margin: 0; font-size: 11px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; color: #1f6b45 }
.update-notizen { max-height: 220px; overflow: auto; width: 100%; font-size: 13px; line-height: 1.55; color: #33413a; background: #fff; border: 1px solid #dfe5e1; border-radius: 8px; padding: 10px 12px; margin: 6px 0 10px }
.update-lauf { background: #1f2d27; color: #e9efe9; border-radius: 12px; padding: 18px 20px; margin: 0 0 14px; display: grid; gap: 12px; justify-items: start }
.update-lauf-kopf { display: flex; align-items: center; gap: 10px; margin: 0; font-weight: 650 }
.update-schritte { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; width: 100% }
.update-schritt { display: flex; align-items: center; gap: 10px; font-size: 14px }
.update-marke { display: grid; place-items: center; width: 22px; height: 22px; flex: none; border-radius: 50%; border: 2px solid #5e6f66; font-size: 12px; font-weight: 700 }
.update-schritt.is-erledigt .update-marke { background: #3fa46a; border-color: #3fa46a; color: #fff }
.update-schritt.is-aktiv .update-marke { border-color: #f2c14e; box-shadow: 0 0 0 4px #f2c14e33; animation: update-puls 1.2s ease-in-out infinite }
.update-schritt.is-offen { color: #8f9e96 }
.update-schritt.is-fehler .update-marke { background: #d4553f; border-color: #d4553f; color: #fff }
@keyframes update-puls { 50% { box-shadow: 0 0 0 8px #f2c14e11 } }
@media (prefers-reduced-motion: reduce) { .update-schritt.is-aktiv .update-marke { animation: none } }
.update-meldung { margin: 0; font-size: 13px; color: #c9d4cd }
.update-meldung.is-ok { color: #9fdcb5 }
.update-meldung.is-fehler { color: #ffb4a6 }
.update-lauf .secondary { background: transparent; color: #fff; border-color: #ffffff66 }
.update-letztes { margin: 0 0 12px }
.update-manual { margin-top: 6px }
.update-dialog { max-width: 460px; border: 0; border-radius: 12px; padding: 24px 26px; box-shadow: 0 18px 48px #0003 }
.update-dialog::backdrop { background: #1f2d2766 }
.update-dialog h2 { margin: 0 0 10px; font-size: 18px }
.update-dialog .dialog-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 18px }
+190
View File
@@ -0,0 +1,190 @@
/*
* 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();
}
+17 -6
View File
@@ -1,6 +1,7 @@
<?php
use App\Kernel;
use App\Ops\Notfall;
use Symfony\Component\HttpFoundation\Request;
// PHP nennt in jeder Antwort seine genaue Fassung („X-Powered-By: PHP/8.4.22"). Das nützt
@@ -8,9 +9,19 @@ use Symfony\Component\HttpFoundation\Request;
// Hosting laesst sich expose_php nicht umstellen, dieser Aufruf wirkt trotzdem.
header_remove('X-Powered-By');
require dirname(__DIR__).'/config/bootstrap.php';
$kernel = new Kernel($_SERVER['APP_ENV'], (bool) $_SERVER['APP_DEBUG']);
$request = Request::createFromGlobals();
$response = $kernel->handle($request);
$response->send();
$kernel->terminate($request, $response);
// Die Notfallschicht zuerst und ohne Autoloader (ADR 113): Scheitert alles Weitere - fehlende
// Abhängigkeiten, eine unlesbare Datenbank, ein Fehler beim Start -, sehen Besucher eine
// Wartungsseite statt einer weißen Seite, und der Grund steht in var/log/notfall.log.
require_once dirname(__DIR__).'/src/Ops/Notfall.php';
Notfall::einschalten(dirname(__DIR__));
try {
require dirname(__DIR__).'/config/bootstrap.php';
$kernel = new Kernel($_SERVER['APP_ENV'], (bool) $_SERVER['APP_DEBUG']);
$request = Request::createFromGlobals();
$response = $kernel->handle($request);
$response->send();
$kernel->terminate($request, $response);
} catch (Throwable $fehler) {
Notfall::ausnahme($fehler, $_SERVER['REQUEST_URI'] ?? '');
}