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 -28
View File
@@ -1,6 +1,7 @@
{% extends 'admin/shell.html.twig' %}
{% set nav = 'system' %}
{% block title %}System{% endblock %}
{% block scripts %}<script type="module" src="/assets/system-update.js?v={{ kant_version() }}"></script>{% endblock %}
{% block heading %}System und Update{% endblock %}
{% block lead %}Fassung, Updateweg und Sicherungen dieser Installation.{% endblock %}
{% block content %}
@@ -137,10 +138,17 @@
</div>
{% endif %}
<div class="card">
{# Update mit einem Klick (ADR 113), nach dem Vorbild von reiseSoft: Beim Öffnen fragt die Seite
selbst nach einer neuen Fassung, zeigt die Hinweise dazu und einen Knopf. Danach laufen die
Schritte sichtbar durch - bis „abgeschlossen“ oder „zurückgeschaltet“. Ohne Skript bleiben die
Formulare darunter. #}
<div class="card update-card" id="update" data-update
data-installiert="{{ state.version }}" data-csrf="{{ csrf_token('system') }}"
data-pruefen="{{ path('admin_update_check') }}" data-status="{{ path('admin_update_status') }}"
data-starten="{{ path('admin_update_start') }}" data-abschliessen="{{ path('admin_update_finish') }}">
<div class="card-head">
<h2>{{ state.worktree ? 'Update aus dem Releasearchiv' : 'Update einspielen' }}</h2>
<span class="muted">{{ state.worktree ? 'Alternative zum Weg über Git' : 'holt die neue Fassung' }}</span>
<h2>Update</h2>
<span class="muted">Installiert <strong data-update-installiert>{{ state.version }}</strong></span>
</div>
<div class="card-body">
{% if archivePending %}
@@ -150,35 +158,30 @@
<input type="hidden" name="finish" value="1">
<button class="primary" type="submit">Migrationen ausführen und freigeben →</button>
</form>
{% else %}
<p class="muted">Ohne Git: Archiv laden, Prüfsumme vergleichen, Datenbank und Medien sichern, Wartungszustand ein, Dateien tauschen. Der vorherige Stand wird beiseitegelegt, nicht gelöscht.</p>
{# Das Kleingedruckte steht zugeklappt: Wer aktualisiert, braucht zuerst den Knopf. #}
<details class="hint">
<summary>Was unberührt bleibt</summary>
<p class="field-help"><code>var/</code>, <code>.env</code>, <code>.env.local</code>, die geladenen Schriften und das erzeugte Stylesheet-Bündel.</p>
<p class="field-help">Danach folgt ein zweiter Knopf für die Migrationen. Das ist kein Umweg: Erst eine neue Anfrage startet mit dem Code der neuen Fassung.</p>
</details>
{% if archiveVersion %}
<p class="notice done">Verfügbar: <strong>{{ archiveVersion }}</strong> (installiert: {{ state.version }}).</p>
{% elseif asked %}
<p class="field-help">Die Releaseseite nennt nichts Neueres als {{ state.version }}.</p>
{% else %}
<p class="field-help"><a href="{{ path('admin_system', {pruefen: 1}) }}">Nachsehen, ob es etwas Neues gibt</a> – dauert einen Moment.</p>
{% endif %}
<form action="{{ path('admin_system_archive') }}" method="post">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('system') }}">
<div class="fields">
<div>
<label for="archive-target">Fassung</label>
<input id="archive-target" name="target" maxlength="20" placeholder="{{ archiveVersion ?: '1.4.2' }}" pattern="[0-9]+\.[0-9]+\.[0-9]+">
<p class="field-help">Leer lassen für die neueste veröffentlichte Fassung.</p>
<div class="update-live" data-update-live role="status" aria-live="polite" hidden></div>
<details class="hint update-manual">
<summary>Weitere Möglichkeiten</summary>
<p class="field-help">Ohne Git: Archiv laden, Prüfsumme vergleichen, Datenbank und Medien sichern, Wartungsseite an, Dateien tauschen, Datenbank anpassen, prüfen. Unberührt bleiben <code>var/</code>, <code>.env</code>, die geladenen Schriften und das erzeugte Stylesheet-Bündel.</p>
<form action="{{ path('admin_system_archive') }}" method="post">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('system') }}">
<div class="fields">
<div>
<label for="archive-target">Bestimmte Fassung</label>
<input id="archive-target" name="target" maxlength="20" placeholder="1.4.2" pattern="[0-9]+\.[0-9]+\.[0-9]+">
<p class="field-help">Leer lassen für die neueste veröffentlichte Fassung. Dieser Weg funktioniert auch ohne JavaScript.</p>
</div>
</div>
</div>
<button class="primary" type="submit">Update aus dem Archiv einspielen →</button>
</form>
{% endif %}
<button class="secondary" type="submit">Update aus dem Archiv einspielen</button>
</form>
</details>
</div>
</div>
<dialog id="update-dialog" class="update-dialog" aria-labelledby="update-dialog-title">
<h2 id="update-dialog-title">Auf <span data-update-ziel></span> aktualisieren?</h2>
<p>Vorher werden Datenbank und Medien automatisch gesichert. Die Website zeigt etwa eine Minute lang eine Wartungsseite. Schlägt etwas fehl, wird automatisch auf {{ state.version }} zurückgeschaltet.</p>
<div class="dialog-actions"><button type="button" class="secondary" data-update-abbrechen>Abbrechen</button><button type="button" class="primary" data-update-los>Update starten</button></div>
</dialog>
{% if state.worktree %}
<div class="card">