feat: dashboard widget shows the import status

Replaces the TYPO3 10 system information entry "Letzer Produktdaten-Import":
for travel data, snow reports and groups.swiss the widget shows the data
state, the last and next scheduler run and the last error, plus MyE&P's
last export. Tasks are found by their command, not by uid. A task whose
next run is more than an hour overdue means scheduler:run is no longer
called.

New dashboards get an "Importe" tab; it can also be added via the wizard.
Editors need the Dashboard module and the widget enabled in the office
group (backend, after the deploy).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
frommeandClaude Opus 5.5 committed 2026-10-28 17:34:50 +01:00
1 parent 863dd9de1a
commit 9aabff6b5b
5 files changed
+242

No files matched your search

@@ -0,0 +1,146 @@
<?php
declare(strict_types=1);
namespace EP\EpProducts\Dashboard;
use EP\EpProducts\MyEP\ApiClient;
use EP\EpProducts\Service\ApiImportSource;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\View\BackendViewFactory;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Dashboard\Widgets\RequestAwareWidgetInterface;
use TYPO3\CMS\Dashboard\Widgets\WidgetConfigurationInterface;
use TYPO3\CMS\Dashboard\Widgets\WidgetInterface;
/**
* When the imports last ran and what data they brought, replacing the TYPO3 10 system information entry
* "Letzer Produktdaten-Import"
*/
final class ImportStatusWidget implements WidgetInterface, RequestAwareWidgetInterface
{
/**
* Command => label; the scheduler tasks are found by the command they run, not by uid
*/
private const array COMMANDS = [
'ep:import-dates' => 'Reisedaten (MyE&P)',
'ep:import-snowreport' => 'Schneehöhen',
'ep:groups-swiss' => 'groups.swiss',
];
/**
* A task whose next run is this far in the past means scheduler:run is not called any more
*/
private const int OVERDUE_SECONDS = 3600;
private ServerRequestInterface $request;
public function __construct(
private readonly WidgetConfigurationInterface $configuration,
private readonly BackendViewFactory $backendViewFactory,
private readonly ConnectionPool $connectionPool,
private readonly ApiImportSource $importSource,
private readonly ApiClient $apiClient,
private readonly array $options = [],
) {
}
public function setRequest(ServerRequestInterface $request): void
{
$this->request = $request;
}
public function renderWidgetContent(): string
{
$tasks = $this->findTasks();
$rows = [];
foreach (self::COMMANDS as $command => $label) {
$rows[$command] = ['label' => $label, 'dataAt' => null, 'task' => $tasks[$command] ?? null];
}
$rows['ep:import-dates']['dataAt'] = $this->importSource->getImportedTimestamp()?->getTimestamp();
$rows['ep:import-snowreport']['dataAt'] = $this->findLastSnowreportUpdate();
$myEpLastUpdate = null;
$myEpUnreachable = false;
try {
$myEpLastUpdate = $this->apiClient->getLastUpdateAt()?->getTimestamp();
} catch (\Throwable) {
$myEpUnreachable = true;
}
$view = $this->backendViewFactory->create($this->request, ['typo3/cms-dashboard', 'ep/ep-products']);
$view->assignMultiple([
'rows' => $rows,
'myEpLastUpdate' => $myEpLastUpdate,
'myEpUnreachable' => $myEpUnreachable,
'importPending' => null !== $myEpLastUpdate && $myEpLastUpdate > (int)$rows['ep:import-dates']['dataAt'],
'configuration' => $this->configuration,
]);
return $view->render('Widget/ImportStatusWidget');
}
public function getOptions(): array
{
return $this->options;
}
/**
* @return array<string, array{uid: int, disabled: bool, lastRun: int, nextRun: int, overdue: bool, failure: ?string}>
*/
private function findTasks(): array
{
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('tx_scheduler_task');
$result = $queryBuilder
->select('uid', 'disable', 'lastexecution_time', 'lastexecution_failure', 'nextexecution', 'serialized_task_object')
->from('tx_scheduler_task')
->where($queryBuilder->expr()->eq('deleted', 0))
->orderBy('uid')
->executeQuery();
$tasks = [];
while ($row = $result->fetchAssociative()) {
foreach (array_keys(self::COMMANDS) as $command) {
// The command identifier appears in the serialized ExecuteSchedulableCommandTask exactly as
// serialize() writes the string; matching it avoids unserializing task objects
if (!isset($tasks[$command]) && str_contains((string)$row['serialized_task_object'], serialize($command))) {
$tasks[$command] = [
'uid' => (int)$row['uid'],
'disabled' => (bool)$row['disable'],
'lastRun' => (int)$row['lastexecution_time'],
'nextRun' => (int)$row['nextexecution'],
'overdue' => !$row['disable'] && 0 < (int)$row['nextexecution']
&& (int)$row['nextexecution'] < time() - self::OVERDUE_SECONDS,
'failure' => $this->failureMessage((string)$row['lastexecution_failure']),
];
}
}
}
return $tasks;
}
private function failureMessage(string $failure): ?string
{
if ('' === $failure) {
return null;
}
// Written by the scheduler as serialize(['code' => …, 'message' => …, …])
$data = @unserialize($failure, ['allowed_classes' => false]);
return is_array($data) && '' !== (string)($data['message'] ?? '') ? (string)$data['message'] : 'Unbekannter Fehler';
}
private function findLastSnowreportUpdate(): ?int
{
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('tx_epproducts_domain_model_snowreport');
$queryBuilder->getRestrictions()->removeAll();
$tstamp = $queryBuilder
->selectLiteral('MAX(tstamp)')
->from('tx_epproducts_domain_model_snowreport')
->executeQuery()
->fetchOne();
return $tstamp ? (int)$tstamp : null;
}
}
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
return [
'epImports' => [
'title' => 'Importe',
'description' => 'Import-Status von Reisedaten, Schneehöhen und groups.swiss',
'iconIdentifier' => 'ep-logo',
'defaultWidgets' => ['epImportStatus'],
'showInWizard' => true,
],
];
@@ -72,3 +72,14 @@ services:
$hotelImageService: '@ep_products.image_service.hotel'
$productImageService: '@ep_products.image_service.product'
$regionImageService: '@ep_products.image_service.region'
EP\EpProducts\Dashboard\ImportStatusWidget:
tags:
- name: dashboard.widget
identifier: 'epImportStatus'
groupNames: 'systemInfo'
title: 'Import-Status'
description: 'Wann Reisedaten, Schneehöhen und groups.swiss zuletzt gelaufen sind und welchen Stand die Daten haben'
iconIdentifier: 'ep-logo'
height: 'medium'
width: 'large'
@@ -0,0 +1 @@
options.dashboard.dashboardPresetsForNewUsers = default, epImports
@@ -0,0 +1,71 @@
<html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers" data-namespace-typo3-fluid="true">
<f:layout name="Widget/Widget" />
<f:section name="main">
<table class="table table-sm table-striped mb-0">
<thead>
<tr>
<th>Import</th>
<th>Datenstand</th>
<th>Letzter Lauf</th>
<th>Nächster Lauf</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<f:for each="{rows}" as="row">
<tr>
<td>{row.label}</td>
<td>
<f:if condition="{row.dataAt}" then="{row.dataAt -> f:format.date(format: 'd.m.Y H:i')}" else="–" />
</td>
<f:if condition="{row.task}">
<f:then>
<td>
<f:if condition="{row.task.lastRun}" then="{row.task.lastRun -> f:format.date(format: 'd.m.Y H:i')}" else="–" />
</td>
<td>
<f:if condition="{row.task.disabled}" then="–" else="{row.task.nextRun -> f:format.date(format: 'd.m.Y H:i')}" />
</td>
<td>
<f:if condition="{row.task.disabled}">
<f:then><span class="badge badge-default">deaktiviert</span></f:then>
<f:else if="{row.task.failure}">
<span class="badge badge-danger" title="{row.task.failure}">Fehler</span>
<div class="text-danger small">{row.task.failure}</div>
</f:else>
<f:else if="{row.task.overdue}">
<span class="badge badge-warning">überfällig</span>
<div class="small">Der Scheduler läuft nicht.</div>
</f:else>
<f:else><span class="badge badge-success">OK</span></f:else>
</f:if>
</td>
</f:then>
<f:else>
<td>–</td>
<td>–</td>
<td><span class="badge badge-warning">kein Scheduler-Task</span></td>
</f:else>
</f:if>
</tr>
</f:for>
</tbody>
</table>
<p class="mt-3 mb-0">
Letzter Export in MyE&amp;P:
<f:if condition="{myEpUnreachable}">
<f:then><span class="badge badge-danger">MyE&amp;P nicht erreichbar</span></f:then>
<f:else if="{myEpLastUpdate}">
{myEpLastUpdate -> f:format.date(format: 'd.m.Y H:i')}
<f:if condition="{importPending}">
<span class="badge badge-info">wird mit dem nächsten Lauf importiert</span>
</f:if>
</f:else>
<f:else>–</f:else>
</f:if>
</p>
</f:section>
</html>