Text in admin cards had no size of its own, so 16px browser default sat next to 13px notes; cards now use 13px throughout while fields stay at 16px for iOS. The mail page gets option cards, a 2x2 SMTP grid, a styled save button in the card foot and a log with status pills and collapsed server messages, plus "Protokoll leeren". The package card uses the same sections and a styled file picker. The E-Mail-Versand subitem only shows inside System and sits beside it in the horizontal strip. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
361 lines
24 KiB
Twig
361 lines
24 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>
|
||
{# HTTPS erzwingen (ADR 120): schaltet die Zeilen in public/.htaccess, mit Probe davor
|
||
und danach. HSTS erst als zweiter Schritt. #}
|
||
<dt>HTTPS</dt>
|
||
<dd>
|
||
{% if https.redirect %}
|
||
<strong>Wird erzwungen.</strong> <span class="muted">– http:// leitet mit 301 auf HTTPS um.</span>
|
||
{% elseif origin.adresse starts with 'https://' %}
|
||
<span class="check-bad"><strong>Nicht erzwungen.</strong></span> <span class="muted">– die Website ist auch über http:// erreichbar, ohne Umleitung.</span>
|
||
{% else %}
|
||
<strong>Nicht erzwungen.</strong> <span class="muted">– erst die Adresse mit https:// eintragen.</span>
|
||
{% endif %}
|
||
{% if https.redirect %}<br>HSTS: {% if https.hsts %}<strong>eingeschaltet</strong> <span class="muted">– Browser bleiben ein Jahr lang bei HTTPS, Subdomains eingeschlossen.</span>{% else %}<span class="muted">aus. Erst einschalten, wenn HTTPS ein paar Tage gut läuft: Ein Browser merkt sich HSTS ein Jahr lang, das lässt sich nicht zurücknehmen.</span>{% endif %}{% endif %}
|
||
{% if not https.found %}
|
||
<br><span class="muted">Die Zeilen dafür stehen nicht in <code>public/.htaccess</code> – geändert oder eine alte Fassung. Das nächste Update bringt sie zurück.</span>
|
||
{% elseif not https.writable %}
|
||
<br><span class="muted">PHP darf <code>public/.htaccess</code> nicht schreiben. Von Hand: das <code>#</code> vor den drei Zeilen unter „HTTPS erzwingen“ entfernen.</span>
|
||
{% else %}
|
||
<form class="https-actions" action="{{ path('admin_system_https') }}" method="post">
|
||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('system') }}">
|
||
{% if not https.redirect %}
|
||
<button class="secondary" type="submit" name="action" value="redirect-an"{% if not (origin.adresse starts with 'https://') %} disabled{% endif %}>HTTPS erzwingen</button>
|
||
{% else %}
|
||
{% if not https.hsts %}<button class="secondary" type="submit" name="action" value="hsts-an">HSTS einschalten</button>{% else %}<button class="secondary" type="submit" name="action" value="hsts-aus">HSTS ausschalten</button>{% endif %}
|
||
<button class="secondary" type="submit" name="action" value="redirect-aus">Umleitung ausschalten</button>
|
||
{% endif %}
|
||
</form>
|
||
{% if not https.redirect and origin.adresse starts with 'https://' %}<p class="field-help">Vorher wird geprüft, ob {{ origin.adresse }} mit gültigem Zertifikat hier ankommt, danach, ob sie es weiterhin tut. Klappt das nicht, bleibt alles, wie es war.</p>{% endif %}
|
||
{% 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" id="paket">
|
||
<div class="card-head"><h2>Inhalte übertragen</h2><span class="muted">Export und Import · var/pakete</span></div>
|
||
<div class="card-body">
|
||
<p class="muted">Ein Inhaltspaket enthält Seiten mit allen Fassungen, Bereiche, Menüs, Medien und PDFs, Formulare, Weiterleitungen, Kategorien, Übersetzungen, Design und Einstellungen. Nicht darin: Anfragen, Statistik, Zugangsdaten zu Diensten (Turnstile) und – ohne Häkchen – die Konten.</p>
|
||
|
||
<form class="card-section" action="{{ path('admin_package_export') }}" method="post">
|
||
<h3>Exportieren</h3>
|
||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('system') }}">
|
||
<label class="check-label"><input type="checkbox" name="benutzer" value="1"> Konten mit Passwörtern mitnehmen</label>
|
||
<p class="field-help">Nur nötig, wenn die andere Website noch kein Konto hat. Das Paket enthält dann die Passwort-Hashes – nicht weitergeben.</p>
|
||
<div class="button-row"><button class="secondary" type="submit">Paket herunterladen</button></div>
|
||
</form>
|
||
|
||
<form class="card-section" action="{{ path('admin_package_import') }}" method="post" enctype="multipart/form-data">
|
||
<h3>Importieren</h3>
|
||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('system') }}">
|
||
<label class="field-first" for="paket-datei">Paket hochladen (.zip)</label>
|
||
<input id="paket-datei" type="file" name="paket" accept=".zip,application/zip">
|
||
<p class="field-help">Höchstens {{ uploadLimit }} je Upload (Einstellung des Hostings). Größere Pakete per FTP nach <code>var/pakete/</code> legen, dann erscheinen sie hier zur Auswahl.</p>
|
||
{% if packages is not empty %}
|
||
<fieldset class="field-set">
|
||
<legend>Oder ein Paket aus var/pakete/</legend>
|
||
{% for package in packages %}
|
||
<label class="check-label"><input type="radio" name="wartend" value="{{ package.name }}">
|
||
<code>{{ package.name }}</code> – {% if package.manifest %}„{{ package.manifest.website }}“, Fassung {{ package.manifest.fassung }}, {{ package.manifest.inhalt.page|default(0) }} Seiten, {{ package.manifest.medien }} Dateien{% if package.manifest.benutzer %}, mit Konten{% endif %}{% else %}<span class="muted">kein gültiges Paket</span>{% endif %}
|
||
</label>
|
||
{% endfor %}
|
||
</fieldset>
|
||
{% endif %}
|
||
<label class="check-label"><input type="checkbox" name="benutzer" value="1"> Konten und Passwörter aus dem Paket übernehmen</label>
|
||
<p class="field-help">Ohne Häkchen bleiben Benutzer und Passwörter dieser Website, wie sie sind.</p>
|
||
<label class="check-label"><input type="checkbox" name="bestaetigt" value="1" required> Ich weiß, dass die Inhalte dieser Website ersetzt werden.</label>
|
||
<p class="field-help">Vorher wird automatisch eine Sicherung angelegt; während des Imports antwortet die Website mit der Wartungsseite.</p>
|
||
<div class="button-row"><button class="primary" type="submit">Paket importieren</button></div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
<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 %}
|