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>
294 lines
18 KiB
Twig
294 lines
18 KiB
Twig
{% 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 %}
|
||
|
||
<div class="card">
|
||
<div class="card-head">
|
||
<h2>Diese Installation</h2>
|
||
<span class="muted">{{ state.maintenance ? 'Wartungszustand eingeschaltet' : 'im Betrieb' }}</span>
|
||
</div>
|
||
<div class="card-body">
|
||
<dl class="facts">
|
||
<dt>Fassung</dt><dd><strong>{{ state.version }}</strong></dd>
|
||
<dt>Verwaltung</dt>
|
||
<dd>
|
||
<code>/{{ adminPath }}</code>
|
||
{% if adminPath == 'admin' %}
|
||
<span class="muted">– Standardpfad, den jeder Scanner zuerst probiert. Ändern: <code>php bin/console cms:admin:path <neu></code>.</span>
|
||
{% else %}
|
||
<span class="muted">– geändert; steht in keiner öffentlichen Datei. Zurück: <code>php bin/console cms:admin:path admin</code>.</span>
|
||
{% endif %}
|
||
</dd>
|
||
<dt>Datenbank</dt>
|
||
<dd>
|
||
<strong>{{ database.art }}</strong>
|
||
{% if database.datei %}
|
||
<code>{{ database.datei }}</code>
|
||
{% if database.bytes %}<span class="muted">· {{ (database.bytes / 1048576)|number_format(1, ',', '.') }} MB</span>{% endif %}
|
||
{% if not database.schreibbar %}<br><span class="check-bad"><strong>Nicht beschreibbar.</strong> SQLite braucht Schreibrechte auf die Datei <em>und</em> auf den Ordner darüber – nach einem FTP-Upload gehört sie dem FTP-Benutzer.</span>{% endif %}
|
||
{% else %}
|
||
<code>{{ database.adresse }}</code>
|
||
{% endif %}
|
||
<br><span class="muted">Eingestellt in <code>{{ database.quelle }}</code>; Server-Umgebungsvariablen haben Vorrang.</span>
|
||
</dd>
|
||
<dt>Adresse</dt>
|
||
<dd>
|
||
{% if origin.adresse %}<code>{{ origin.adresse }}</code>{% else %}<span class="check-bad"><strong>Nicht eingetragen.</strong></span>{% endif %}
|
||
{% if origin.passt %}
|
||
<span class="muted">– Canonical, Sitemap und Feed nennen sie; die Schreibweise {{ origin.adresse matches '{^https?://www\.}' ? 'ohne' : 'mit' }} www leitet hierher um.</span>
|
||
{% else %}
|
||
<br><span class="check-bad"><strong>Diese Verwaltung läuft gerade unter <code>{{ origin.host }}</code>.</strong> Canonical, Sitemap und Feed nennen trotzdem die eingetragene Adresse. Stimmt die nicht mehr, unten ändern.</span>
|
||
{% endif %}
|
||
{% if origin.quelle == '.env' %}
|
||
{# Wie „WordPress-Adresse“: eine Stelle für mit oder ohne www. Vor dem Speichern fragt
|
||
die Installation sich selbst unter der neuen Adresse an. #}
|
||
{% set host = origin.adresse|split('://')|last|split(':')|first %}
|
||
{% set twin = host starts with 'www.' ? host|slice(4) : 'www.' ~ host %}
|
||
<form class="origin-form" action="{{ path('admin_system_origin') }}" method="post">
|
||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('system') }}">
|
||
<label for="origin">Adresse der Website</label>
|
||
<div class="origin-row">
|
||
<input id="origin" name="origin" type="url" inputmode="url" maxlength="200" required placeholder="https://www.meine-firma.de" value="{{ originRejected ?: origin.adresse }}" aria-describedby="origin-help">
|
||
<button class="secondary" type="submit">Adresse speichern</button>
|
||
</div>
|
||
{% if originRejected %}
|
||
<label class="check-label" for="origin-force"><input id="origin-force" type="checkbox" name="force" value="1"> Trotzdem übernehmen – ich habe DNS und Zertifikat geprüft</label>
|
||
{% endif %}
|
||
<p class="field-help" id="origin-help">Nur Schema und Domain. Vor dem Speichern wird geprüft, ob die Website unter der neuen Adresse antwortet – sonst leitete die andere Schreibweise ins Leere, die Verwaltung eingeschlossen.{% if origin.adresse and host matches '/^[a-z0-9-]+(\.[a-z0-9-]+)+$/' and not (host matches '/^[0-9.]+$/') %} Umstellen auf <button class="link-button" type="submit" name="origin" value="{{ origin.adresse|split('://')|first }}://{{ twin }}" formnovalidate>{{ twin }}</button>.{% endif %}</p>
|
||
</form>
|
||
{% else %}
|
||
<br><span class="muted">Eingestellt in der Server-Umgebung; sie hat Vorrang vor <code>.env</code> und lässt sich nur beim Hoster ändern.</span>
|
||
{% endif %}
|
||
</dd>
|
||
{% if state.worktree %}
|
||
<dt>Stand</dt><dd><code>{{ state.describe }}</code>{% if state.commit != 'unbekannt' %} · Commit <code>{{ state.commit }}</code>{% endif %}</dd>
|
||
{% else %}
|
||
<dt>Eingerichtet über</dt>
|
||
<dd><strong>Releasearchiv</strong> <span class="muted">– kein Git; Updates laufen über die Karte unten.</span></dd>
|
||
{% endif %}
|
||
{% if state.worktree %}
|
||
<dt>Verfügbar</dt>
|
||
<dd>
|
||
{% if available %}
|
||
<strong>{{ available }}</strong>{% if available == 'v' ~ state.version %} – das ist die installierte Fassung.{% endif %}
|
||
{% elseif asked %}
|
||
Kein Tag der Form <code>v1.2.3</code> in der Herkunft gefunden.
|
||
{% else %}
|
||
<a href="{{ path('admin_system', {pruefen: 1}) }}">Beim Repository nachfragen</a>
|
||
{% endif %}
|
||
</dd>
|
||
{% endif %}
|
||
</dl>
|
||
|
||
{% if state.maintenance %}
|
||
<p class="notice error" role="alert">Die Website antwortet Besuchern mit 503. Wenn das nicht beabsichtigt ist, hebe den Wartungszustand auf – ein gescheitertes Update kann ihn eingeschaltet gelassen haben.</p>
|
||
{% endif %}
|
||
|
||
<div class="sheet-actions">
|
||
<form action="{{ path('admin_system_maintenance') }}" method="post">
|
||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('system') }}">
|
||
<input type="hidden" name="state" value="{{ state.maintenance ? 'aus' : 'an' }}">
|
||
<button class="secondary" type="submit">Wartungszustand {{ state.maintenance ? 'aufheben' : 'einschalten' }}</button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{# Die Vorprüfung gehört zum Updateweg über Git: Arbeitsverzeichnis sauber, Datenbank
|
||
außerhalb von Git, Tag erreichbar. Auf einer Installation aus dem Releasearchiv prüft sie
|
||
Bedingungen, die dort keine sind - und zeigt lauter rote Ausrufezeichen für etwas, das
|
||
vollkommen in Ordnung ist. #}
|
||
{% if state.worktree %}
|
||
<div class="card">
|
||
<div class="card-head"><h2>Vorprüfung</h2><span class="muted">{{ checks|length }} Bedingungen</span></div>
|
||
<div class="card-body">
|
||
<ul class="check-list">
|
||
{% for check in checks %}
|
||
<li class="{{ check.ok ? 'check-ok' : 'check-bad' }}">
|
||
<span class="check-mark" aria-hidden="true">{{ check.ok ? '✓' : '!' }}</span>
|
||
<span><strong>{{ check.name }}</strong> <span class="visually-hidden">{{ check.ok ? 'erfüllt' : 'nicht erfüllt' }}:</span> {{ check.detail }}</span>
|
||
</li>
|
||
{% endfor %}
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
{% endif %}
|
||
|
||
{# Waehrend das Archiv-Update auf seinen zweiten Schritt wartet, gehoert der Knopf dorthin -
|
||
zwei Knoepfe, die dasselbe tun, sind eine Frage zu viel. #}
|
||
{% if pendingMigrations|default([]) and not archivePending %}
|
||
<div class="card">
|
||
<div class="card-head">
|
||
<h2>Migrationen stehen aus</h2>
|
||
<span class="muted">{{ pendingMigrations|length }} Stück</span>
|
||
</div>
|
||
<div class="card-body">
|
||
<p class="notice">Der Code ist neuer als die Datenbank – typisch nach einem Update von Hand über FTP.</p>
|
||
<ul class="log-list">{% for name in pendingMigrations %}<li><code>{{ name }}</code></li>{% endfor %}</ul>
|
||
<p class="field-help">Vorher sichern: Migrationen sind nicht umkehrbar, der Rückweg ist die Sicherung.</p>
|
||
<form action="{{ path('admin_system_migrate') }}" method="post">
|
||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('system') }}">
|
||
<button class="primary" type="submit">Migrationen jetzt ausführen →</button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
{% endif %}
|
||
|
||
{# 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>Update</h2>
|
||
<span class="muted">Installiert <strong data-update-installiert>{{ state.version }}</strong></span>
|
||
</div>
|
||
<div class="card-body">
|
||
{% if archivePending %}
|
||
<p class="notice">Die Dateien der Fassung <strong>{{ archivePending.fassung }}</strong> liegen bereits (seit {{ archivePending.seit|slice(0, 16)|replace({"T": " "}) }} UTC). <strong>Es fehlen noch die Migrationen</strong> – bis dahin bleibt die Website im Wartungszustand.</p>
|
||
<form action="{{ path('admin_system_archive') }}" method="post">
|
||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('system') }}">
|
||
<input type="hidden" name="finish" value="1">
|
||
<button class="primary" type="submit">Migrationen ausführen und freigeben →</button>
|
||
</form>
|
||
{% endif %}
|
||
<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>
|
||
<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">
|
||
<div class="card-head"><h2>Update einspielen</h2><span class="muted">{{ ready ? 'Vorprüfung bestanden' : 'erst die Vorprüfung erfüllen' }}</span></div>
|
||
<div class="card-body">
|
||
<p class="muted">Sichern, Wartungszustand ein, auf den Tag wechseln, Abhängigkeiten, Migrationen, Cache, Antwortprüfung. Scheitert ein Schritt, wird zurückgesetzt – die Datenbank nur, wenn die Migration schon begonnen hatte.</p>
|
||
<details class="hint">
|
||
<summary>Zwei Hinweise vorab</summary>
|
||
<p class="field-help"><strong>Der Medienordner wird nicht gesichert.</strong> Ein Update ist keine Datensicherung.</p>
|
||
<p class="field-help">Bricht der Browseraufruf auf dem Hosting ab: <code>php bin/console cms:update</code> – dieselben Schritte mit <code>--check</code>, <code>--dry-run</code> und <code>--to=v1.2.0</code>.</p>
|
||
</details>
|
||
|
||
<form action="{{ path('admin_system_update') }}" method="post">
|
||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('system') }}">
|
||
<div class="fields">
|
||
<div>
|
||
<label for="target">Ziel</label>
|
||
<input id="target" name="target" maxlength="40" placeholder="{{ available ?: 'v1.2.0' }}" pattern="v[0-9]+\.[0-9]+\.[0-9]+">
|
||
<p class="field-help">Leer lassen für den höchsten verfügbaren Tag.</p>
|
||
</div>
|
||
<div>
|
||
<label class="check-label" for="dryRun"><input id="dryRun" name="dryRun" type="checkbox" value="1" checked> Nur Probelauf</label>
|
||
<p class="field-help">Prüft alles und verändert nichts.</p>
|
||
</div>
|
||
</div>
|
||
<button class="primary" type="submit"{{ ready ? '' : ' disabled' }}>Update ausführen</button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
{% endif %}
|
||
|
||
{% if steps is not empty %}
|
||
<div class="card">
|
||
<div class="card-head"><h2>Letzter Durchlauf</h2><span class="muted">{{ steps|length }} Schritte</span></div>
|
||
<div class="card-body">
|
||
<ol class="check-list">
|
||
{% for step in steps %}
|
||
<li class="{{ step.ok ? 'check-ok' : 'check-bad' }}">
|
||
<span class="check-mark" aria-hidden="true">{{ step.ok ? '✓' : '!' }}</span>
|
||
{# `step.label` ist die Schreibweise bis 1.4.4. Sie muss hier stehen bleiben: Beim
|
||
Update von 1.4.4 auf 1.4.5 schreibt der **alte** Code die Schritte in die
|
||
Sitzung und der **neue** zeigt sie an - eine Fassung, die nur die neue
|
||
Schreibweise kennt, könnte das Update, das sie selbst einspielt, nicht
|
||
berichten. #}
|
||
<span><strong>{{ step.step|default(step.label|default('Schritt')) }}</strong> {{ step.detail }}</span>
|
||
</li>
|
||
{% endfor %}
|
||
</ol>
|
||
</div>
|
||
</div>
|
||
{% endif %}
|
||
|
||
<div class="card">
|
||
<div class="card-head"><h2>Sicherungen</h2><span class="muted">{{ backups|length }} vorhanden · var/backups</span></div>
|
||
<div class="card-body">
|
||
<p class="muted">Eine Sicherung enthält die Datenbank als vollständige Kopie und den Medienordner – nicht <code>.env</code>, <code>vendor/</code> und den Quellcode.</p>
|
||
<p class="field-help">Zurückspielen nur auf der Konsole: <code>php bin/console cms:restore <name></code>. Die tägliche Sicherung erledigt der <a href="{{ path('admin_tasks') }}">Aufgabenplan</a>.</p>
|
||
<form action="{{ path('admin_system_backup') }}" method="post">
|
||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('system') }}">
|
||
<button class="primary" type="submit">Sicherung jetzt anlegen</button>
|
||
</form>
|
||
|
||
{% if backups is not empty %}
|
||
<table class="table table-stack">
|
||
<caption class="visually-hidden">Vorhandene Sicherungen</caption>
|
||
<thead><tr><th scope="col">Name</th><th scope="col">Erstellt</th><th scope="col">Fassung</th><th scope="col">Inhalt</th><th scope="col">Größe</th><th scope="col">Zustand</th></tr></thead>
|
||
<tbody>
|
||
{% for backup in backups %}
|
||
<tr>
|
||
<td data-label="Name"><code>{{ backup.name }}</code></td>
|
||
<td data-label="Erstellt">{{ backup.manifest ? backup.manifest.erstellt|date('d.m.Y H:i') : '—' }}{% if backup.manifest %}<br><span class="muted">{{ backup.manifest.anlass }}</span>{% endif %}</td>
|
||
<td data-label="Fassung">{{ backup.manifest ? backup.manifest.fassung : '—' }}</td>
|
||
<td data-label="Inhalt">{% if backup.manifest %}{{ backup.manifest.inhalt.seiten }} Seiten, {{ backup.manifest.medien.dateien }} Dateien{% else %}—{% endif %}</td>
|
||
<td data-label="Größe">{{ (backup.bytes / 1048576)|number_format(1, ',', '.') }} MB</td>
|
||
<td data-label="Zustand">{% if backup.intact %}<span class="pill pill-live">unversehrt</span>{% else %}<span class="pill pill-off">Prüfsumme falsch</span>{% endif %}</td>
|
||
</tr>
|
||
{% endfor %}
|
||
</tbody>
|
||
</table>
|
||
<p class="field-help">„Unversehrt“ heißt: die Prüfsumme stimmt noch. Stimmt sie nicht, wird die Sicherung nicht eingespielt.</p>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<div class="card-head"><h2>Verlauf</h2><span class="muted">var/updates.log</span></div>
|
||
<div class="card-body">
|
||
{% if state.log is empty %}
|
||
<p class="muted">Noch kein Update über diesen Weg eingespielt.</p>
|
||
{% else %}
|
||
{# Zwanzig Protokollzeilen sind auf einem Handy zwanzig Absätze. Die jüngsten fünf
|
||
stehen offen, der Rest liegt eine Umdrehung dahinter. #}
|
||
<ul class="log-list">
|
||
{% for line in state.log|slice(0, 5) %}<li><code>{{ line }}</code></li>{% endfor %}
|
||
</ul>
|
||
{% if state.log|length > 5 %}
|
||
<details class="hint">
|
||
<summary>Ältere Einträge ({{ state.log|length - 5 }})</summary>
|
||
<ul class="log-list">
|
||
{% for line in state.log|slice(5) %}<li><code>{{ line }}</code></li>{% endfor %}
|
||
</ul>
|
||
</details>
|
||
{% endif %}
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
|
||
<p class="footnote">KantSites · Arbeitsstand</p>
|
||
{% endblock %}
|