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:
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&P:
|
||||
<f:if condition="{myEpUnreachable}">
|
||||
<f:then><span class="badge badge-danger">MyE&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>
|
||||
Reference in new issue
Block a user