Fassung 1.6.0: der Aufgabenplan wird veroeffentlicht
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s

Wiederkehrende Aufgaben ohne Cronjob (ADR 073): Sicherung, Statistik kuerzen,
geplante Veroeffentlichung, Updatepruefung und Aufraeumen laufen als Plan in
der Tabelle scheduled_task - eine Zeile je Aufgabe mit Faelligkeit, Sperre und
letztem Ergebnis. Auf geteiltem Hosting gibt es oft keinen Cronjob; der Plan
haengt stattdessen an Anfragen und an einem Takt aus dem geoeffneten Backend.

Diese Arbeit entstand in einer parallel laufenden Sitzung und lag bis hierher
unveroeffentlicht im Arbeitsverzeichnis: die Releases 1.5.0 bis 1.5.5 wurden
bewusst aus dem jeweiligen Tag gebaut und enthielten sie deshalb nicht.

Vor der Veroeffentlichung geprueft, nicht bloss eingepackt:

- phpunit 452/452, npm test 28/28, npm run test:editor vollstaendig.
- Die Migration 20260914000100 legt nur die Tabelle scheduled_task an und nimmt
  sie beim Zurueckrollen wieder weg. Sie fasst keine bestehenden Daten an.
- Seit einer Stunde keine Schreibzugriffe der anderen Sitzung mehr.

Dabei zwei Fehler behoben:

- config/services.yaml verwendet !tagged_iterator. Das ist gueltiges Symfony,
  aber "lint:yaml config" kennt eigene Tags nur mit --parse-tags und gab 1
  zurueck - der CI-Lauf waere daran gescheitert. Flag ergaenzt.
- AdminHeadersSubscriber.php (aus 1.5.3) entsprach nicht dem Stil von
  php-cs-fixer; composer lint gab 8 zurueck. Behoben.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
kantarciandClaude Opus 5 committed 2026-09-14 08:30:13 +02:00
1 parent d1ac9d760b
commit 05ea703655
35 files changed
+2229 -13

No files matched your search

+3
View File
@@ -24,6 +24,9 @@
<script src="/assets/color-field.js?v={{ kant_version() }}" defer></script>
{% block styles %}{% endblock %}
{% block scripts %}{% endblock %}
{# Eigener Block, nicht Teil von `scripts`: Bildschirme wie die Mediathek ersetzen `scripts`
vollstaendig, und der Takt der Verwaltung darf davon nicht abhaengen. #}
{% block worker %}{% endblock %}
</head>
<body class="{% block body_class %}{% endblock %}">
<a class="skip-link" href="#admin-main">Zum Inhalt springen</a>
+1
View File
@@ -14,6 +14,7 @@
theme: 'M12 3a9 9 0 1 0 0 18Zm0 0a9 9 0 0 1 0 18',
settings: 'M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6Zm8-3a8 8 0 0 0-.2-1.7l2-1.5-2-3.5-2.3 1a8 8 0 0 0-3-1.7L14 2h-4l-.5 2.6a8 8 0 0 0-3 1.7l-2.3-1-2 3.5 2 1.5a8 8 0 0 0 0 3.4l-2 1.5 2 3.5 2.3-1a8 8 0 0 0 3 1.7L10 22h4l.5-2.6a8 8 0 0 0 3-1.7l2.3 1 2-3.5-2-1.5c.1-.6.2-1.1.2-1.7Z',
system: 'M8 8h8v8H8Zm-4 2h4m-4 4h4m8-4h4m-4 4h4M10 4v4m4-4v4m-4 8v4m4-4v4',
tasks: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Zm0-13.5V12l3.5 2.2',
users: 'M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm-8 8c0-3.3 3.6-5 8-5s8 1.7 8 5',
external: 'M14 4h6v6m0-6-8 8M18 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h5'
} %}
+1 -1
View File
@@ -201,7 +201,7 @@
<div class="field">
<label for="publish-{{ page.id }}">Veröffentlichen am</label>
<input id="publish-{{ page.id }}" name="publishAt" type="datetime-local" value="{{ page.publish_at ? page.publish_at|date('Y-m-d\\TH:i') : '' }}">
<p class="field-help">Zeiten in UTC. Leer lassen, um die Planung zu entfernen. Die Veröffentlichung erfolgt, sobald <code>cms:publish-due</code> läuft.</p>
<p class="field-help">Zeiten in UTC. Leer lassen, um die Planung zu entfernen. Veröffentlicht wird beim ersten Aufruf der Website nach diesem Zeitpunkt; wie oft nachgesehen wird, steht unter <a href="{{ path('admin_tasks') }}">Aufgaben</a>.</p>
</div>
<button class="secondary" type="submit">Zeitpunkt speichern</button>
</form>
+9 -1
View File
@@ -2,6 +2,13 @@
click away and the current one is visible. Screens fill in heading, lead, actions and content. #}
{% extends 'admin/base.html.twig' %}
{% block body_class %}admin-shell{% endblock %}
{# Solange jemand hier arbeitet, laeuft der Aufgabenplan - auch auf einer Website ohne Besucher.
Das Skript fragt im Minutentakt an; was faellig ist, entscheidet der Server. #}
{% block worker %}
<meta name="kant-tick" content="{{ path('admin_tasks_tick') }}">
<meta name="kant-tick-token" content="{{ csrf_token('tasks-tick') }}">
<script src="/assets/task-worker.js?v={{ kant_version() }}" defer></script>
{% endblock %}
{% block body %}
<header class="shell-nav">
<a class="brand" href="{{ path('admin_pages') }}"><span class="brand-icon" aria-hidden="true">k.</span>kant<span>sites</span></a>
@@ -15,7 +22,7 @@
<span class="shell-menu-label">{{ {
'pages': 'Seiten', 'partials': 'Bereiche', 'menus': 'Menüs', 'media': 'Mediathek',
'forms': 'Formulare', 'statistics': 'Statistik', 'theme': 'Design',
'settings': 'Einstellungen', 'system': 'System', 'users': 'Benutzer',
'settings': 'Einstellungen', 'system': 'System', 'tasks': 'Aufgaben', 'users': 'Benutzer',
'account': 'Mein Konto'
}[nav|default('')]|default('Verwaltung') }}</span>
</summary>
@@ -39,6 +46,7 @@
<li><a class="nav-item" href="{{ path('admin_theme') }}"{{ nav|default('') == 'theme' ? ' aria-current="page"' : '' }}>{% include 'admin/icon.html.twig' with {name: 'theme'} only %}Design</a></li>
<li><a class="nav-item" href="{{ path('admin_settings') }}"{{ nav|default('') == 'settings' ? ' aria-current="page"' : '' }}>{% include 'admin/icon.html.twig' with {name: 'settings'} only %}Einstellungen</a></li>
{% if is_granted('ROLE_ADMIN') %}<li><a class="nav-item" href="{{ path('admin_system') }}"{{ nav|default('') == 'system' ? ' aria-current="page"' : '' }}>{% include 'admin/icon.html.twig' with {name: 'system'} only %}System</a></li>{% endif %}
{% if is_granted('ROLE_ADMIN') %}<li><a class="nav-item" href="{{ path('admin_tasks') }}"{{ nav|default('') == 'tasks' ? ' aria-current="page"' : '' }}>{% include 'admin/icon.html.twig' with {name: 'tasks'} only %}Aufgaben</a></li>{% endif %}
{% if is_granted('ROLE_ADMIN') %}<li><a class="nav-item" href="{{ path('admin_users') }}"{{ nav|default('') == 'users' ? ' aria-current="page"' : '' }}>{% include 'admin/icon.html.twig' with {name: 'users'} only %}Benutzer</a></li>{% endif %}
<li><a class="nav-item nav-out" href="/">{% include 'admin/icon.html.twig' with {name: 'external'} only %}Website ansehen</a></li>
<li><a class="nav-item nav-out" href="/beitraege">{% include 'admin/icon.html.twig' with {name: 'external'} only %}Beitragsübersicht</a></li>
+1 -1
View File
@@ -186,7 +186,7 @@
<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 – nicht als Dateikopie während des Schreibens, die unbrauchbar sein kann – und den Medienordner. <strong>Nicht enthalten sind <code>.env</code>, <code>vendor/</code> und der Quellcode</strong>: die kommen aus dem Repository, und <code>.env</code> enthält das Geheimnis dieser Installation.</p>
<p class="field-help">Zurückspielen geht bewusst nur auf der Konsole: <code>php bin/console cms:restore &lt;name&gt;</code>. Ein Knopf, der die ganze Datenbank ersetzt, gehört nicht neben einen, der eine Seite speichert. Für den täglichen Cronjob: <code>php bin/console cms:backup</code>.</p>
<p class="field-help">Zurückspielen geht bewusst nur auf der Konsole: <code>php bin/console cms:restore &lt;name&gt;</code>. Ein Knopf, der die ganze Datenbank ersetzt, gehört nicht neben einen, der eine Seite speichert. Die tägliche Sicherung erledigt der <a href="{{ path('admin_tasks') }}">Aufgabenplan</a> – auch ohne Cronjob.</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>
+131
View File
@@ -0,0 +1,131 @@
{% extends 'admin/shell.html.twig' %}
{% set nav = 'tasks' %}
{% block title %}Aufgaben{% endblock %}
{% block heading %}Wiederkehrende Aufgaben{% endblock %}
{% block lead %}Was regelmäßig laufen soll, wer es anstößt und was zuletzt dabei herauskam. Alle Zeiten in UTC.{% endblock %}
{% block content %}
{# Der wichtigste Satz zuerst, und zwar als Einschränkung und nicht als Werbung: ohne Anfrage
passiert nichts. Wer das nicht weiß, sucht später den Fehler an der falschen Stelle. #}
<div class="card">
<div class="card-head">
<h2>Wer die Arbeit anstößt</h2>
<span class="muted">{{ next is null ? 'Zeitpunkt noch unbekannt' : 'Nächster Lauf: ' ~ next|date('Y-m-d H:i:s', 'UTC') ~ ' UTC' }}</span>
</div>
<div class="card-body">
<p class="muted">Ein Webhosting ohne Cronjob hat nur eines: Anfragen. Deshalb erledigt eine Anfrage die Arbeit – nach der ausgelieferten Antwort, sodass niemand darauf wartet. <strong>Ohne Besuch und ohne geöffnete Verwaltung passiert nichts.</strong> Eine Aufgabe für 03:00 Uhr läuft dann beim ersten Aufruf danach, auf einer stillen Website vielleicht um 07:15 Uhr. Wer es genauer braucht, richtet einen der beiden unteren Wege ein.</p>
<form action="{{ path('admin_tasks_configure') }}" method="post">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('tasks') }}">
<fieldset class="field-set">
<legend>Auslöser in Anfragen</legend>
<label class="check-label"><input type="checkbox" name="traffic" value="1"{{ traffic ? ' checked' : '' }}> Besuche der Website</label>
<p class="field-help">Jeder Seitenaufruf schaut nach, ob etwas fällig ist. Der Normalfall kostet eine gelesene Datei und keine Datenbankabfrage. Ausschalten, wenn ein echter Cronjob die Arbeit übernimmt.</p>
<label class="check-label"><input type="checkbox" name="admin" value="1"{{ admin ? ' checked' : '' }}> Geöffnete Verwaltung</label>
<p class="field-help">Solange dieser Bildschirm offen und sichtbar ist, fragt er jede Minute nach. So läuft der Plan auch auf einer Website, die gerade niemand besucht.</p>
</fieldset>
<div class="section-head"><h2>Der Plan</h2><span class="muted">Abstand in Minuten</span></div>
<table class="table tasks-table">
<thead>
<tr><th scope="col">Aufgabe</th><th scope="col" class="check-cell">An</th><th scope="col">Abstand</th><th scope="col">Nächster Lauf</th><th scope="col">Zuletzt</th><th scope="col"></th></tr>
</thead>
<tbody>
{% for task in tasks %}
<tr>
<td>
<strong>{{ task.label }}</strong>
<p class="field-help">{{ task.explanation }}</p>
<p class="field-help"><code>{{ task.name }}</code></p>
</td>
<td class="check-cell">
<input type="checkbox" name="enabled[{{ task.name }}]" value="1"{{ task.enabled ? ' checked' : '' }} aria-label="{{ task.label }} ausführen">
</td>
<td><input type="number" name="interval[{{ task.name }}]" value="{{ (task.interval / 60)|round }}" min="1" max="43200" step="1" aria-label="Abstand für {{ task.label }} in Minuten"></td>
<td>
{% if not task.enabled %}<span class="pill pill-off">aus</span>
{% elseif task.running %}<span class="pill pill-draft">läuft</span>
{% elseif task.due %}<span class="pill pill-draft">fällig</span>
{% else %}<span class="muted">{{ task.next }}</span>{% endif %}
</td>
<td>
{% if task.status is null %}
<span class="muted">noch nie</span>
{% else %}
<span class="pill {{ task.status == 'ok' ? 'pill-live' : 'pill-off' }}">{{ task.status }}</span>
<p class="field-help">{{ task.last }} · {{ task.ms }} ms{% if task.trigger %} · durch {{ task.trigger }}{% endif %}</p>
<p class="field-help{{ task.status == 'ok' ? '' : ' danger-text' }}">{{ task.message }}</p>
{% if task.failures > 1 %}<p class="field-help danger-text">{{ task.failures }} Fehlschläge nacheinander – der Abstand bis zum nächsten Versuch wächst.</p>{% endif %}
{% endif %}
</td>
<td>
<button class="secondary" type="submit" form="run-{{ task.name }}">Jetzt ausführen</button>
{% if task.running %}<button class="text-button" type="submit" form="release-{{ task.name }}">Sperre aufheben</button>{% endif %}
</td>
</tr>
{% else %}
<tr><td colspan="6">Keine Aufgaben. Fehlt die Migration? Der Bildschirm <a href="{{ path('admin_system') }}">System</a> führt sie aus.</td></tr>
{% endfor %}
</tbody>
</table>
<button class="primary" type="submit">Plan speichern</button>
</form>
{# Die Formulare der Knöpfe stehen außerhalb der Tabelle: ein Formular im Formular gibt es
in HTML nicht, und `form="..."` verbindet Knopf und Formular über die Tabelle hinweg. #}
{% for task in tasks %}
<form id="run-{{ task.name }}" action="{{ path('admin_tasks_run') }}" method="post" hidden>
<input type="hidden" name="_csrf_token" value="{{ csrf_token('tasks') }}">
<input type="hidden" name="name" value="{{ task.name }}">
</form>
{% if task.running %}
<form id="release-{{ task.name }}" action="{{ path('admin_tasks_release') }}" method="post" hidden>
<input type="hidden" name="_csrf_token" value="{{ csrf_token('tasks') }}">
<input type="hidden" name="name" value="{{ task.name }}">
</form>
{% endif %}
{% endfor %}
</div>
</div>
<div class="card">
<div class="card-head"><h2>Der zuverlässige Weg</h2><span class="muted">falls dieses Hosting ihn anbietet</span></div>
<div class="card-body">
<p class="muted">Beide Wege sind unabhängig von den Schaltern oben – sie laufen auch dann, wenn Besuch und Verwaltung ausgeschaltet sind. Wer einen davon einrichtet, sollte den Besuchs-Auslöser abschalten: die Arbeit ist dann schon getan, und die Website antwortet ein wenig schneller.</p>
<dl class="facts">
<dt>URL-Cronjob</dt>
<dd>
<code>{{ cronUrl }}</code>
<p class="field-help">Diese Adresse beim Hoster als Cronjob eintragen, zum Beispiel alle fünf Minuten. Der Schlüssel ist aus dem Anwendungsgeheimnis abgeleitet, steht in keiner Datenbank und ändert sich, wenn das Geheimnis gewechselt wird. Er gehört nicht in eine E-Mail und nicht in ein Ticket.</p>
</dd>
<dt>Konsole</dt>
<dd>
<code>php bin/console cms:tasks:run</code>
<p class="field-help">Für einen echten Cronjob, jede Minute. <code>cms:tasks</code> zeigt denselben Plan wie dieser Bildschirm, <code>cms:tasks:run --loop=60</code> läuft dauerhaft.</p>
</dd>
</dl>
</div>
</div>
<div class="card">
<div class="card-head"><h2>Protokoll</h2><span class="muted">die letzten Läufe, neueste zuerst</span></div>
<div class="card-body">
{% if protocol is empty %}
<p class="muted">Noch nichts gelaufen. Steht in der Spalte „Nächster Lauf“ ein Zeitpunkt, ist alles in Ordnung – der Plan wartet.</p>
{% else %}
<div class="table-wrap">
<table class="table">
<tbody>
{% for line in protocol %}
<tr><td><code>{{ line }}</code></td></tr>
{% endfor %}
</tbody>
</table>
</div>
<p class="field-help">Aus <code>var/tasks.log</code>. Die Datei kürzt sich selbst; die Aufgabe „Aufräumen“ tut es ebenfalls.</p>
{% endif %}
</div>
</div>
{% endblock %}