Fix #139: Ereignisanzeige aktualisiert sich automatisch (Ein-/Aus-Button)

This commit is contained in:
fds2610-bot 2026-07-26 16:35:36 +02:00
parent b6cea7caff
commit 87ccb00f95
7 changed files with 193 additions and 32 deletions

View file

@ -2,6 +2,15 @@
---
## 2026-07-26 – Feature: Ereignisanzeige aktualisiert sich automatisch (Issue #139)
- Die Ereignisanzeige (`/ereignisse/nextcloud-import`) war statisch – neu geloggte Ereignisse erschienen erst nach manuellem Neuladen der Seite.
- **`src/Controllers/EventController.php`:** Neue Methode `nextcloudImportData()` liefert die aktuellen Ereignisse als JSON (Anzahl + serverseitig gerendertes HTML-Fragment), damit initiale Seite und Auto-Reload denselben Rendering-Code (`ereignisse/_nextcloud_import_body.php`) verwenden.
- **`src/Views/ereignisse/_nextcloud_import_body.php`:** Tabellen-/Leer-Markup aus `nextcloud_import.php` in ein Partial ausgelagert, das von initialer Seite und Datenendpunkt gemeinsam genutzt wird.
- **`src/Views/ereignisse/nextcloud_import.php`:** Neuer Ein-/Aus-Button oben rechts in der Kartenkopfzeile schaltet automatisches Neuladen per `setInterval` (alle 60s, ohne externe Bibliothek) ein/aus; Zustand wird per `localStorage` gemerkt. Ein echter Push-Mechanismus (z. B. Server-Sent Events) hätte dauerhafte PHP-FPM-Worker gebunden – bei diesem Anwendungsfall (Log-Anzeige) ist periodisches Polling die robustere Lösung.
- **`public/index.php`:** Neue Route `GET /ereignisse/nextcloud-import/data`.
- **`tests/58_EreignisAutoReloadTest.php`:** Neuer Test prüft Button, Polling-Code, Partial-Wiederverwendung und Routen-Registrierung.
## 2026-07-21 – Fix: Zwei dauerhaft fehlschlagende Tests (Session-Cookie-Härtung, Nextcloud-SSRF)
- **Test 54 (Session-Cookie-Härtung):** Der Test rief echtes `Auth::startSession()` auf. Da `tests/run.php` Ergebnisse laufend per `echo` ausgibt und Test 54 der einzige Test ist, der Sessions anfasst, war zum Testzeitpunkt bereits Output gesendet – PHP lehnt `session_set_cookie_params()`/`session_start()` danach mit „headers already sent" ab (Warnung + wirkungslos). Ein nachträglicher Output-Buffer im Test half nicht, da bereits vor dem Test unbuffered Output geflossen war.

View file

@ -16,6 +16,7 @@ Vollständige Kontenliste: [kontenrahmen.md](kontenrahmen.md)
- KI-gestützter Kontierungsvorschlag (optional, siehe [konfiguration.md](konfiguration.md))
- Nextcloud-Import via WebDAV (optional)
- Belege können nicht gelöscht werden, solange Buchungen darauf verweisen (GoBD)
- **Ereignisanzeige** (`/ereignisse/nextcloud-import`): Log der letzten 500 Import-Ereignisse (importiert/übersprungen/Fehler); aktualisiert sich per Ein-/Aus-Button oben rechts automatisch alle 60 Sekunden (Zustand wird im Browser gemerkt)
---

View file

@ -188,6 +188,7 @@ $router->post('/belege/{belegId}/vorlage/{vorlageId}/buchen', fn($p) =
// ── Ereignisse ───────────────────────────────────────────────
$router->get('/ereignisse', fn($p) => (new EventController())->index($p));
$router->get('/ereignisse/nextcloud-import', fn($p) => (new EventController())->nextcloudImport($p));
$router->get('/ereignisse/nextcloud-import/data', fn($p) => (new EventController())->nextcloudImportData($p));
// ── Login / Logout ────────────────────────────────────────────
$router->get('/login', fn($p) => (new LoginController())->showLogin($p));

View file

@ -23,7 +23,23 @@ class EventController extends BaseController
'pageTitle' => 'Ereignisse – Nextcloud-Import',
'content' => $this->renderView('ereignisse/nextcloud_import', [
'events' => $events,
'eventsHtml' => $this->renderView('ereignisse/_nextcloud_import_body', [
'events' => $events,
]),
]),
]);
}
public function nextcloudImportData(array $params): void
{
$events = EventLog::findByChannel('nextcloud-import', 500);
header('Content-Type: application/json');
echo json_encode([
'count' => count($events),
'html' => $this->renderView('ereignisse/_nextcloud_import_body', [
'events' => $events,
]),
], JSON_UNESCAPED_UNICODE);
}
}

View file

@ -0,0 +1,28 @@
<?php use Steuerkram\View; $h = fn($v) => View::h($v); ?>
<?php if (empty($events)): ?>
<div style="padding:2rem;text-align:center" class="text-muted">Keine Ereignisse vorhanden. Einträge entstehen, sobald der Nextcloud-Import Dateien importiert, überspringt oder auf Fehler stößt.</div>
<?php else: ?>
<div style="overflow-x:auto">
<table style="width:100%;border-collapse:collapse;font-size:.875rem">
<tbody>
<?php foreach ($events as $e): ?>
<?php
$msg = $e['message'];
$isError = str_starts_with($msg, 'FEHLER');
$isDup = str_starts_with($msg, 'Duplikat');
$isOk = str_starts_with($msg, 'Importiert');
$color = $isError ? 'var(--color-danger,#dc2626)'
: ($isDup ? 'var(--color-warning,#d97706)'
: ($isOk ? 'var(--color-success,#16a34a)'
: 'inherit'));
?>
<tr style="border-bottom:1px solid var(--color-border,#e2e8f0)">
<td style="padding:.35rem 1rem;white-space:nowrap;color:var(--color-muted);font-family:monospace"><?= $h(date('Y-m-d H:i:s', strtotime($e['created_at']))) ?></td>
<td style="padding:.35rem .5rem;white-space:nowrap;color:var(--color-muted);font-family:monospace">[<?= $h($e['channel']) ?>]</td>
<td style="padding:.35rem 1rem;color:<?= $color ?>;word-break:break-word"><?= $h($msg) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>

View file

@ -1,35 +1,76 @@
<?php use Steuerkram\View; $h = fn($v) => View::h($v); ?>
<div class="card">
<div style="display:flex;justify-content:space-between;align-items:center;padding:.75rem 1rem;border-bottom:1px solid var(--color-border)">
<strong>Nextcloud-Import</strong>
<span class="text-muted text-sm"><?= count($events) ?> Einträge (neueste zuerst)</span>
<div style="display:flex;align-items:center;gap:.75rem">
<span id="events-count" class="text-muted text-sm"><?= count($events) ?> Einträge (neueste zuerst)</span>
<button type="button" id="events-autoreload-toggle" class="btn btn-sm btn-secondary"></button>
</div>
<?php if (empty($events)): ?>
<div style="padding:2rem;text-align:center" class="text-muted">Keine Ereignisse vorhanden. Einträge entstehen, sobald der Nextcloud-Import Dateien importiert, überspringt oder auf Fehler stößt.</div>
<?php else: ?>
<div style="overflow-x:auto">
<table style="width:100%;border-collapse:collapse;font-size:.875rem">
<tbody>
<?php foreach ($events as $e): ?>
<?php
$msg = $e['message'];
$isError = str_starts_with($msg, 'FEHLER');
$isDup = str_starts_with($msg, 'Duplikat');
$isOk = str_starts_with($msg, 'Importiert');
$color = $isError ? 'var(--color-danger,#dc2626)'
: ($isDup ? 'var(--color-warning,#d97706)'
: ($isOk ? 'var(--color-success,#16a34a)'
: 'inherit'));
?>
<tr style="border-bottom:1px solid var(--color-border,#e2e8f0)">
<td style="padding:.35rem 1rem;white-space:nowrap;color:var(--color-muted);font-family:monospace"><?= $h(date('Y-m-d H:i:s', strtotime($e['created_at']))) ?></td>
<td style="padding:.35rem .5rem;white-space:nowrap;color:var(--color-muted);font-family:monospace">[<?= $h($e['channel']) ?>]</td>
<td style="padding:.35rem 1rem;color:<?= $color ?>;word-break:break-word"><?= $h($msg) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
<div id="events-body">
<?= $eventsHtml ?>
</div>
</div>
<script>
(function () {
var STORAGE_KEY = 'ereignisse-autoreload';
var POLL_MS = 60000;
var toggle = document.getElementById('events-autoreload-toggle');
var body = document.getElementById('events-body');
var count = document.getElementById('events-count');
var timer = null;
var lastHtml = body.innerHTML;
function isEnabled() {
return localStorage.getItem(STORAGE_KEY) !== 'off';
}
function renderToggle() {
var on = isEnabled();
toggle.textContent = on ? 'Auto-Reload: An' : 'Auto-Reload: Aus';
toggle.classList.toggle('btn-success', on);
toggle.classList.toggle('btn-secondary', !on);
}
function poll() {
fetch('/ereignisse/nextcloud-import/data', { headers: { 'X-Requested-With': 'XMLHttpRequest' } })
.then(function (res) { return res.json(); })
.then(function (data) {
if (data.html !== lastHtml) {
lastHtml = data.html;
body.innerHTML = data.html;
}
count.textContent = data.count + ' Einträge (neueste zuerst)';
})
.catch(function () { /* nächster Versuch beim nächsten Intervall */ });
}
function start() {
stop();
timer = setInterval(poll, POLL_MS);
}
function stop() {
if (timer) {
clearInterval(timer);
timer = null;
}
}
toggle.addEventListener('click', function () {
localStorage.setItem(STORAGE_KEY, isEnabled() ? 'off' : 'on');
renderToggle();
if (isEnabled()) {
poll();
start();
} else {
stop();
}
});
renderToggle();
if (isEnabled()) {
start();
}
})();
</script>

View file

@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
/**
* Test 58: Auto-Reload der Ereignisanzeige (Nextcloud-Import)
*
* Prüft, dass die Ereignisanzeige einen Ein-/Aus-Button für automatisches
* Neuladen besitzt, per JavaScript periodisch aktualisiert wird und
* dass der zugehörige JSON-Endpunkt sowie die Route existieren.
*/
class EreignisAutoReloadTest extends TestCase
{
protected string $testName = 'Test 58: Ereignisanzeige aktualisiert sich automatisch (Ein-/Aus-Button)';
public function run(): void
{
$viewFile = __DIR__ . '/../src/Views/ereignisse/nextcloud_import.php';
$view = file_get_contents($viewFile);
$this->assert($view !== false, 'View-Datei ereignisse/nextcloud_import.php ist lesbar');
$this->assert(
str_contains($view, 'id="events-autoreload-toggle"'),
'Ein-/Aus-Button für Auto-Reload ist vorhanden'
);
$this->assert(
str_contains($view, 'localStorage'),
'Zustand des Auto-Reload-Buttons wird persistiert (localStorage)'
);
$this->assert(
str_contains($view, 'setInterval') && str_contains($view, "'/ereignisse/nextcloud-import/data'"),
'JavaScript pollt periodisch den Daten-Endpunkt'
);
$this->assert(
str_contains($view, "toggle.addEventListener('click'"),
'Button schaltet Auto-Reload per Klick ein/aus'
);
// Partial für Tabellen-Body existiert und wird sowohl beim initialen
// Laden als auch für den Daten-Endpunkt verwendet
$partialFile = __DIR__ . '/../src/Views/ereignisse/_nextcloud_import_body.php';
$this->assert(file_exists($partialFile), 'Partial _nextcloud_import_body.php existiert');
$controllerFile = __DIR__ . '/../src/Controllers/EventController.php';
$controller = file_get_contents($controllerFile);
$this->assert(
str_contains($controller, 'function nextcloudImportData'),
'EventController::nextcloudImportData() ist implementiert'
);
$this->assert(
str_contains($controller, "'ereignisse/_nextcloud_import_body'"),
'Daten-Endpunkt rendert dasselbe Partial wie die initiale Seite'
);
$routesFile = __DIR__ . '/../public/index.php';
$routes = file_get_contents($routesFile);
$this->assert(
str_contains($routes, "'/ereignisse/nextcloud-import/data'") && str_contains($routes, 'nextcloudImportData'),
'Route /ereignisse/nextcloud-import/data ist registriert'
);
}
}