Fix #165: Ereignisse zeigen Fehler und Warnungen aus dem ganzen System
This commit is contained in:
parent
5df6c2c9dc
commit
c9afc3e33f
13 changed files with 485 additions and 64 deletions
18
migrations/091_system_events_level.sql
Normal file
18
migrations/091_system_events_level.sql
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
-- Ereignisse führen künftig nicht nur den Nextcloud-Import, sondern auch
|
||||
-- Fehlermeldungen und Warnungen aus dem übrigen System (Issue #165).
|
||||
-- Dafür bekommt jeder Eintrag einen Schweregrad.
|
||||
|
||||
ALTER TABLE system_events
|
||||
ADD COLUMN IF NOT EXISTS level ENUM('info','warning','error') NOT NULL DEFAULT 'info' AFTER channel;
|
||||
|
||||
ALTER TABLE system_events
|
||||
ADD INDEX IF NOT EXISTS idx_created (created_at);
|
||||
|
||||
ALTER TABLE system_events
|
||||
ADD INDEX IF NOT EXISTS idx_level_created (level, created_at);
|
||||
|
||||
-- Bestehende Nextcloud-Einträge nachträglich einstufen. Bis hierher stand der
|
||||
-- Schweregrad nur im Text; die Zuordnung folgt denselben Präfixen, nach denen
|
||||
-- die Ereignisliste die Zeilen bisher eingefärbt hat.
|
||||
UPDATE system_events SET level = 'error' WHERE level = 'info' AND message LIKE 'FEHLER%';
|
||||
UPDATE system_events SET level = 'warning' WHERE level = 'info' AND message LIKE 'Duplikat%';
|
||||
|
|
@ -26,7 +26,17 @@ set_error_handler(static function (int $severity, string $message, string $file,
|
|||
throw new \ErrorException($message, 0, $severity, $file, $line);
|
||||
});
|
||||
|
||||
set_exception_handler(function (\Throwable $e) use ($steuerkramShowGenericError): void {
|
||||
// Fehler landen zusätzlich in der Ereignisliste, damit sie ohne Zugriff auf
|
||||
// das Server-Log sichtbar sind (Issue #165). Der Aufruf darf nie selbst
|
||||
// scheitern – ist die Datenbank die Fehlerursache, bleibt es beim error_log.
|
||||
$steuerkramLogEvent = static function (string $channel, string $message, string $level): void {
|
||||
if (!class_exists(\Steuerkram\Models\EventLog::class, true)) {
|
||||
return;
|
||||
}
|
||||
\Steuerkram\Models\EventLog::tryAdd($channel, $message, $level);
|
||||
};
|
||||
|
||||
set_exception_handler(function (\Throwable $e) use ($steuerkramShowGenericError, $steuerkramLogEvent): void {
|
||||
error_log(sprintf(
|
||||
'Unhandled exception: %s in %s:%d%s%s',
|
||||
$e->getMessage(),
|
||||
|
|
@ -35,13 +45,30 @@ set_exception_handler(function (\Throwable $e) use ($steuerkramShowGenericError)
|
|||
PHP_EOL,
|
||||
$e->getTraceAsString()
|
||||
));
|
||||
$steuerkramLogEvent('system', sprintf(
|
||||
'%s bei %s %s: %s (%s:%d)',
|
||||
(new \ReflectionClass($e))->getShortName(),
|
||||
$_SERVER['REQUEST_METHOD'] ?? 'CLI',
|
||||
parse_url($_SERVER['REQUEST_URI'] ?? '-', PHP_URL_PATH) ?: '-',
|
||||
$e->getMessage(),
|
||||
basename($e->getFile()),
|
||||
$e->getLine()
|
||||
), \Steuerkram\Models\EventLog::LEVEL_ERROR);
|
||||
$steuerkramShowGenericError();
|
||||
});
|
||||
|
||||
register_shutdown_function(function () use ($steuerkramShowGenericError): void {
|
||||
register_shutdown_function(function () use ($steuerkramShowGenericError, $steuerkramLogEvent): void {
|
||||
$error = error_get_last();
|
||||
if ($error !== null && in_array($error['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR], true)) {
|
||||
error_log(sprintf('Fatal error: %s in %s:%d', $error['message'], $error['file'], $error['line']));
|
||||
$steuerkramLogEvent('system', sprintf(
|
||||
'Schwerer Fehler bei %s %s: %s (%s:%d)',
|
||||
$_SERVER['REQUEST_METHOD'] ?? 'CLI',
|
||||
parse_url($_SERVER['REQUEST_URI'] ?? '-', PHP_URL_PATH) ?: '-',
|
||||
$error['message'],
|
||||
basename($error['file']),
|
||||
$error['line']
|
||||
), \Steuerkram\Models\EventLog::LEVEL_ERROR);
|
||||
$steuerkramShowGenericError();
|
||||
}
|
||||
});
|
||||
|
|
@ -235,6 +262,7 @@ $router->post('/belege/{belegId}/vorlage/{vorlageId}/buchen', fn($p) =
|
|||
|
||||
// ── Ereignisse ───────────────────────────────────────────────
|
||||
$router->get('/ereignisse', fn($p) => (new EventController())->index($p));
|
||||
$router->get('/ereignisse/data', fn($p) => (new EventController())->indexData($p));
|
||||
$router->get('/ereignisse/nextcloud-import', fn($p) => (new EventController())->nextcloudImport($p));
|
||||
$router->get('/ereignisse/nextcloud-import/data', fn($p) => (new EventController())->nextcloudImportData($p));
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ use Steuerkram\Models\Beleg;
|
|||
use Steuerkram\Models\Booking;
|
||||
use Steuerkram\Models\Account;
|
||||
use Steuerkram\Models\Asset;
|
||||
use Steuerkram\Models\EventLog;
|
||||
use Steuerkram\Models\Supplier;
|
||||
use Steuerkram\View;
|
||||
use Steuerkram\Services\ImageCropService;
|
||||
|
|
@ -216,6 +217,15 @@ class BelegController extends BaseController
|
|||
*/
|
||||
private function renderUploadErrors(array $errors): void
|
||||
{
|
||||
$dateiname = isset($_FILES['beleg_file']['name']) && $_FILES['beleg_file']['name'] !== ''
|
||||
? pathinfo((string)$_FILES['beleg_file']['name'], PATHINFO_BASENAME)
|
||||
: 'ohne Dateiname';
|
||||
EventLog::tryAdd(
|
||||
'beleg-upload',
|
||||
"Upload fehlgeschlagen ({$dateiname}): " . implode(' ', $errors),
|
||||
EventLog::LEVEL_ERROR
|
||||
);
|
||||
|
||||
if (($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '') === 'XMLHttpRequest') {
|
||||
http_response_code(422);
|
||||
header('Content-Type: application/json');
|
||||
|
|
|
|||
|
|
@ -9,37 +9,76 @@ use Steuerkram\View;
|
|||
|
||||
class EventController extends BaseController
|
||||
{
|
||||
private const LIMIT = 500;
|
||||
|
||||
public function index(array $params): void
|
||||
{
|
||||
View::redirect('/ereignisse/nextcloud-import');
|
||||
}
|
||||
|
||||
public function nextcloudImport(array $params): void
|
||||
{
|
||||
$events = EventLog::findByChannel('nextcloud-import', 500);
|
||||
$filter = $this->filterFromRequest();
|
||||
$events = EventLog::find($filter, self::LIMIT);
|
||||
|
||||
$view = new View();
|
||||
$view->render('layout/base.php', [
|
||||
'pageTitle' => 'Ereignisse – Nextcloud-Import',
|
||||
'content' => $this->renderView('ereignisse/nextcloud_import', [
|
||||
'pageTitle' => 'Ereignisse',
|
||||
'content' => $this->renderView('ereignisse/index', [
|
||||
'events' => $events,
|
||||
'eventsHtml' => $this->renderView('ereignisse/_nextcloud_import_body', [
|
||||
'filter' => $filter,
|
||||
'channels' => EventLog::channels(),
|
||||
'eventsHtml' => $this->renderView('ereignisse/_body', [
|
||||
'events' => $events,
|
||||
]),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
public function indexData(array $params): void
|
||||
{
|
||||
$this->respondWithEvents($this->filterFromRequest());
|
||||
}
|
||||
|
||||
public function nextcloudImport(array $params): void
|
||||
{
|
||||
View::redirect('/ereignisse?channel=nextcloud-import');
|
||||
}
|
||||
|
||||
/** Datenquelle der bis Issue #165 eigenständigen Nextcloud-Ereignisseite. */
|
||||
public function nextcloudImportData(array $params): void
|
||||
{
|
||||
$events = EventLog::findByChannel('nextcloud-import', 500);
|
||||
$this->respondWithEvents(['channel' => 'nextcloud-import']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{channel?:string,level?:string} $filter
|
||||
*/
|
||||
private function respondWithEvents(array $filter): void
|
||||
{
|
||||
$events = EventLog::find($filter, self::LIMIT);
|
||||
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode([
|
||||
'count' => count($events),
|
||||
'html' => $this->renderView('ereignisse/_nextcloud_import_body', [
|
||||
'html' => $this->renderView('ereignisse/_body', [
|
||||
'events' => $events,
|
||||
]),
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{channel?:string,level?:string}
|
||||
*/
|
||||
private function filterFromRequest(): array
|
||||
{
|
||||
$filter = [];
|
||||
|
||||
$channel = trim((string)($_GET['channel'] ?? ''));
|
||||
if ($channel !== '') {
|
||||
$filter['channel'] = $channel;
|
||||
}
|
||||
|
||||
$level = trim((string)($_GET['level'] ?? ''));
|
||||
if (in_array($level, EventLog::LEVELS, true)) {
|
||||
$filter['level'] = $level;
|
||||
}
|
||||
|
||||
return $filter;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,23 +8,94 @@ use Steuerkram\Database;
|
|||
|
||||
class EventLog
|
||||
{
|
||||
public static function add(string $channel, string $message): void
|
||||
public const LEVEL_INFO = 'info';
|
||||
public const LEVEL_WARNING = 'warning';
|
||||
public const LEVEL_ERROR = 'error';
|
||||
|
||||
public const LEVELS = [self::LEVEL_INFO, self::LEVEL_WARNING, self::LEVEL_ERROR];
|
||||
|
||||
public static function add(string $channel, string $message, string $level = self::LEVEL_INFO): void
|
||||
{
|
||||
if (!in_array($level, self::LEVELS, true)) {
|
||||
$level = self::LEVEL_INFO;
|
||||
}
|
||||
|
||||
Database::execute(
|
||||
'INSERT INTO system_events (channel, message) VALUES (?, ?)',
|
||||
[$channel, $message]
|
||||
'INSERT INTO system_events (channel, level, message) VALUES (?, ?, ?)',
|
||||
[$channel, $level, $message]
|
||||
);
|
||||
}
|
||||
|
||||
public static function warning(string $channel, string $message): void
|
||||
{
|
||||
self::add($channel, $message, self::LEVEL_WARNING);
|
||||
}
|
||||
|
||||
public static function error(string $channel, string $message): void
|
||||
{
|
||||
self::add($channel, $message, self::LEVEL_ERROR);
|
||||
}
|
||||
|
||||
/**
|
||||
* Schreibt ein Ereignis, ohne selbst einen Fehler auszulösen. Für
|
||||
* Aufrufer, die bereits einen Fehler behandeln – dort darf ein nicht
|
||||
* erreichbares Datenbankprotokoll die eigentliche Fehlerausgabe nicht
|
||||
* verdrängen.
|
||||
*/
|
||||
public static function tryAdd(string $channel, string $message, string $level = self::LEVEL_INFO): void
|
||||
{
|
||||
try {
|
||||
self::add($channel, $message, $level);
|
||||
} catch (\Throwable $e) {
|
||||
error_log('EventLog: Ereignis konnte nicht gespeichert werden: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public static function findByChannel(string $channel, int $limit = 500): array
|
||||
{
|
||||
return self::find(['channel' => $channel], $limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{channel?:string,level?:string} $filter
|
||||
*/
|
||||
public static function find(array $filter = [], int $limit = 500): array
|
||||
{
|
||||
$where = [];
|
||||
$params = [];
|
||||
|
||||
if (!empty($filter['channel'])) {
|
||||
$where[] = 'channel = ?';
|
||||
$params[] = $filter['channel'];
|
||||
}
|
||||
|
||||
if (!empty($filter['level']) && in_array($filter['level'], self::LEVELS, true)) {
|
||||
$where[] = 'level = ?';
|
||||
$params[] = $filter['level'];
|
||||
}
|
||||
|
||||
$params[] = $limit;
|
||||
|
||||
return Database::fetchAll(
|
||||
'SELECT id, channel, message, created_at
|
||||
FROM system_events
|
||||
WHERE channel = ?
|
||||
ORDER BY created_at DESC, id DESC
|
||||
'SELECT id, channel, level, message, created_at
|
||||
FROM system_events'
|
||||
. ($where ? ' WHERE ' . implode(' AND ', $where) : '')
|
||||
. ' ORDER BY created_at DESC, id DESC
|
||||
LIMIT ?',
|
||||
[$channel, $limit]
|
||||
$params
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Alle bisher benutzten Kanäle – Grundlage für den Filter in der Ansicht.
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
public static function channels(): array
|
||||
{
|
||||
return array_column(
|
||||
Database::fetchAll('SELECT DISTINCT channel FROM system_events ORDER BY channel'),
|
||||
'channel'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
27
src/View.php
27
src/View.php
|
|
@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||
namespace Steuerkram;
|
||||
|
||||
use Steuerkram\Auth;
|
||||
use Steuerkram\Models\EventLog;
|
||||
|
||||
class View
|
||||
{
|
||||
|
|
@ -37,11 +38,37 @@ class View
|
|||
Auth::startSession();
|
||||
if ($message !== '') {
|
||||
$_SESSION['flash'][] = ['type' => $type, 'message' => $message];
|
||||
self::logFlash($type, $message);
|
||||
}
|
||||
header('Location: ' . $path);
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fehler- und Warnhinweise, die dem Nutzer angezeigt werden, wandern
|
||||
* zusätzlich in die Ereignisliste – dort sind sie auch dann noch
|
||||
* nachvollziehbar, wenn die Meldung längst weggeklickt ist (Issue #165).
|
||||
*/
|
||||
private static function logFlash(string $type, string $message): void
|
||||
{
|
||||
$level = match ($type) {
|
||||
'error' => EventLog::LEVEL_ERROR,
|
||||
'warning' => EventLog::LEVEL_WARNING,
|
||||
default => null,
|
||||
};
|
||||
|
||||
if ($level === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$pfad = parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH);
|
||||
EventLog::tryAdd(
|
||||
'meldungen',
|
||||
$pfad ? "{$message} ({$pfad})" : $message,
|
||||
$level
|
||||
);
|
||||
}
|
||||
|
||||
public static function h(mixed $value): string
|
||||
{
|
||||
return htmlspecialchars((string)$value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||
|
|
|
|||
37
src/Views/ereignisse/_body.php
Normal file
37
src/Views/ereignisse/_body.php
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
<?php
|
||||
use Steuerkram\View;
|
||||
use Steuerkram\Models\EventLog;
|
||||
$h = fn($v) => View::h($v);
|
||||
|
||||
$levelStyles = [
|
||||
EventLog::LEVEL_ERROR => ['Fehler', 'var(--color-danger,#dc2626)'],
|
||||
EventLog::LEVEL_WARNING => ['Warnung', 'var(--color-warning,#d97706)'],
|
||||
EventLog::LEVEL_INFO => ['Info', 'var(--color-muted,#64748b)'],
|
||||
];
|
||||
?>
|
||||
<?php if (empty($events)): ?>
|
||||
<div style="padding:2rem;text-align:center" class="text-muted">Keine Ereignisse vorhanden.</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
|
||||
$level = $e['level'] ?? EventLog::LEVEL_INFO;
|
||||
[$levelLabel, $color] = $levelStyles[$level] ?? $levelStyles[EventLog::LEVEL_INFO];
|
||||
// Erfolgsmeldungen des Imports bleiben grün wie bisher.
|
||||
if ($level === EventLog::LEVEL_INFO && str_starts_with($e['message'], 'Importiert')) {
|
||||
$color = 'var(--color-success,#16a34a)';
|
||||
}
|
||||
?>
|
||||
<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;font-family:monospace;color:<?= $color ?>"><?= $h($levelLabel) ?></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($e['message']) ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
<?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; ?>
|
||||
|
|
@ -1,3 +1,17 @@
|
|||
<?php
|
||||
use Steuerkram\View;
|
||||
use Steuerkram\Models\EventLog;
|
||||
$h = fn($v) => View::h($v);
|
||||
|
||||
$aktiverKanal = $filter['channel'] ?? '';
|
||||
$aktivesLevel = $filter['level'] ?? '';
|
||||
|
||||
$levelLabels = [
|
||||
EventLog::LEVEL_ERROR => 'Nur Fehler',
|
||||
EventLog::LEVEL_WARNING => 'Nur Warnungen',
|
||||
EventLog::LEVEL_INFO => 'Nur Infos',
|
||||
];
|
||||
?>
|
||||
<style>
|
||||
.switch { position: relative; display: inline-block; width: 42px; height: 24px; flex: 0 0 auto; }
|
||||
.switch input { opacity: 0; width: 0; height: 0; }
|
||||
|
|
@ -15,8 +29,24 @@
|
|||
</style>
|
||||
|
||||
<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>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;gap:1rem;flex-wrap:wrap;padding:.75rem 1rem;border-bottom:1px solid var(--color-border)">
|
||||
<form method="get" action="/ereignisse" id="events-filter" style="display:flex;align-items:center;gap:.5rem;flex-wrap:wrap">
|
||||
<select name="channel" onchange="this.form.submit()">
|
||||
<option value="">Alle Bereiche</option>
|
||||
<?php foreach ($channels as $c): ?>
|
||||
<option value="<?= $h($c) ?>"<?= $c === $aktiverKanal ? ' selected' : '' ?>><?= $h($c) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<select name="level" onchange="this.form.submit()">
|
||||
<option value="">Alle Schweregrade</option>
|
||||
<?php foreach ($levelLabels as $value => $label): ?>
|
||||
<option value="<?= $h($value) ?>"<?= $value === $aktivesLevel ? ' selected' : '' ?>><?= $h($label) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<?php if ($aktiverKanal !== '' || $aktivesLevel !== ''): ?>
|
||||
<a href="/ereignisse" class="btn btn-secondary btn-sm">Filter zurücksetzen</a>
|
||||
<?php endif; ?>
|
||||
</form>
|
||||
<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>
|
||||
<label class="switch" title="Automatische Aktualisierung">
|
||||
|
|
@ -39,6 +69,7 @@
|
|||
var count = document.getElementById('events-count');
|
||||
var timer = null;
|
||||
var lastHtml = body.innerHTML;
|
||||
var dataUrl = '/ereignisse/data' + window.location.search;
|
||||
|
||||
function isEnabled() {
|
||||
return localStorage.getItem(STORAGE_KEY) !== 'off';
|
||||
|
|
@ -49,7 +80,7 @@
|
|||
}
|
||||
|
||||
function poll() {
|
||||
fetch('/ereignisse/nextcloud-import/data', { headers: { 'X-Requested-With': 'XMLHttpRequest' } })
|
||||
fetch(dataUrl, { headers: { 'X-Requested-With': 'XMLHttpRequest' } })
|
||||
.then(function (res) { return res.json(); })
|
||||
.then(function (data) {
|
||||
if (data.html !== lastHtml) {
|
||||
|
|
@ -89,7 +89,11 @@ function navActive(string $prefix, string $current): string {
|
|||
Ereignisse
|
||||
</a>
|
||||
<?php if (str_starts_with($path, '/ereignisse')): ?>
|
||||
<a href="/ereignisse/nextcloud-import" class="<?= navActive('/ereignisse/nextcloud-import', $path) ?>" style="padding-left:2.25rem;font-size:.875rem">
|
||||
<a href="/ereignisse?level=error" class="<?= ($_GET['level'] ?? '') === 'error' ? 'active' : '' ?>" style="padding-left:2.25rem;font-size:.875rem">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
|
||||
Nur Fehler
|
||||
</a>
|
||||
<a href="/ereignisse?channel=nextcloud-import" class="<?= ($_GET['channel'] ?? '') === 'nextcloud-import' ? 'active' : '' ?>" style="padding-left:2.25rem;font-size:.875rem">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 16.92V20a2 2 0 0 1-2.18 2A19.79 19.79 0 0 1 11.39 19a19.5 19.5 0 0 1-4.9-4.9 19.79 19.79 0 0 1-3-9.43A2 2 0 0 1 5.32 3H8.4a2 2 0 0 1 2 1.72c.13.96.36 1.9.7 2.81a2 2 0 0 1-.45 2.11L9.4 10.9a16 16 0 0 0 3.71 3.71l1.26-1.26a2 2 0 0 1 2.11-.45c.91.34 1.85.57 2.81.7A2 2 0 0 1 22 16.92z"/></svg>
|
||||
Nextcloud-Import
|
||||
</a>
|
||||
|
|
|
|||
|
|
@ -3,11 +3,14 @@
|
|||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Test 58: Auto-Reload der Ereignisanzeige (Nextcloud-Import)
|
||||
* Test 58: Auto-Reload der Ereignisanzeige
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* Seit Issue #165 gibt es statt der eigenen Nextcloud-Seite eine Übersicht
|
||||
* über alle Bereiche; der Nextcloud-Import ist dort ein Filter.
|
||||
*/
|
||||
class EreignisAutoReloadTest extends TestCase
|
||||
{
|
||||
|
|
@ -15,9 +18,9 @@ class EreignisAutoReloadTest extends TestCase
|
|||
|
||||
public function run(): void
|
||||
{
|
||||
$viewFile = __DIR__ . '/../src/Views/ereignisse/nextcloud_import.php';
|
||||
$viewFile = __DIR__ . '/../src/Views/ereignisse/index.php';
|
||||
$view = file_get_contents($viewFile);
|
||||
$this->assert($view !== false, 'View-Datei ereignisse/nextcloud_import.php ist lesbar');
|
||||
$this->assert($view !== false, 'View-Datei ereignisse/index.php ist lesbar');
|
||||
|
||||
$this->assert(
|
||||
str_contains($view, 'id="events-autoreload-toggle"'),
|
||||
|
|
@ -30,10 +33,15 @@ class EreignisAutoReloadTest extends TestCase
|
|||
);
|
||||
|
||||
$this->assert(
|
||||
str_contains($view, 'setInterval') && str_contains($view, "'/ereignisse/nextcloud-import/data'"),
|
||||
str_contains($view, 'setInterval') && str_contains($view, "'/ereignisse/data'"),
|
||||
'JavaScript pollt periodisch den Daten-Endpunkt'
|
||||
);
|
||||
|
||||
$this->assert(
|
||||
str_contains($view, 'window.location.search'),
|
||||
'Der Poll übernimmt den eingestellten Filter'
|
||||
);
|
||||
|
||||
$this->assert(
|
||||
str_contains($view, "toggle.addEventListener('change'"),
|
||||
'Toggle-Switch schaltet Auto-Reload per Umlegen ein/aus'
|
||||
|
|
@ -46,25 +54,29 @@ class EreignisAutoReloadTest extends TestCase
|
|||
|
||||
// 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');
|
||||
$partialFile = __DIR__ . '/../src/Views/ereignisse/_body.php';
|
||||
$this->assert(file_exists($partialFile), 'Partial _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'
|
||||
str_contains($controller, 'function indexData'),
|
||||
'EventController::indexData() ist implementiert'
|
||||
);
|
||||
$this->assert(
|
||||
str_contains($controller, "'ereignisse/_nextcloud_import_body'"),
|
||||
substr_count($controller, "'ereignisse/_body'") >= 2,
|
||||
'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'
|
||||
str_contains($routes, "'/ereignisse/data'") && str_contains($routes, 'indexData'),
|
||||
'Route /ereignisse/data ist registriert'
|
||||
);
|
||||
$this->assert(
|
||||
str_contains($routes, "'/ereignisse/nextcloud-import/data'"),
|
||||
'Der bisherige Nextcloud-Endpunkt bleibt erreichbar'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
109
tests/81_EreignisseAlleFehlerTest.php
Normal file
109
tests/81_EreignisseAlleFehlerTest.php
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Steuerkram\Database;
|
||||
use Steuerkram\Models\EventLog;
|
||||
|
||||
/**
|
||||
* Test 81: Ereignisse umfassen alle Fehler und Warnungen (Issue #165)
|
||||
*
|
||||
* Bis dahin protokollierte nur der Nextcloud-Import in die Ereignisliste. Jetzt
|
||||
* hat jeder Eintrag einen Schweregrad, die Liste lässt sich nach Bereich und
|
||||
* Schweregrad filtern, und Fehlermeldungen aus dem übrigen System – etwa ein
|
||||
* fehlgeschlagener Beleg-Upload – landen ebenfalls dort.
|
||||
*/
|
||||
class EreignisseAlleFehlerTest extends TestCase
|
||||
{
|
||||
protected string $testName = 'Test 81: Ereignisliste zeigt Fehler und Warnungen aus dem ganzen System';
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
// ── Schweregrad wird gespeichert ─────────────────────────────
|
||||
$spalte = Database::fetchOne("SHOW COLUMNS FROM system_events LIKE 'level'");
|
||||
$this->assertNotEmpty($spalte, 'Tabelle system_events hat eine Spalte level');
|
||||
|
||||
$kanal = 'test-' . uniqid();
|
||||
EventLog::add($kanal, 'Alles in Ordnung');
|
||||
EventLog::warning($kanal, 'Etwas ist ungewöhnlich');
|
||||
EventLog::error($kanal, 'Etwas ist schiefgelaufen');
|
||||
|
||||
$alle = EventLog::findByChannel($kanal);
|
||||
$this->assertEquals(3, count($alle), 'Drei Einträge im Testkanal');
|
||||
$this->assertEquals('error', $alle[0]['level'], 'Fehler wird als error gespeichert');
|
||||
$this->assertEquals('warning', $alle[1]['level'], 'Warnung wird als warning gespeichert');
|
||||
$this->assertEquals('info', $alle[2]['level'], 'Ohne Angabe gilt info');
|
||||
|
||||
// Ungültiger Schweregrad fällt auf info zurück
|
||||
EventLog::add($kanal, 'Unbekannter Grad', 'katastrophe');
|
||||
$this->assertEquals('info', EventLog::findByChannel($kanal)[0]['level'], 'Ungültiger Schweregrad wird zu info');
|
||||
|
||||
// ── Filter nach Schweregrad und Bereich ──────────────────────
|
||||
$nurFehler = EventLog::find(['channel' => $kanal, 'level' => 'error']);
|
||||
$this->assertEquals(1, count($nurFehler), 'Filter nach Schweregrad liefert nur den Fehler');
|
||||
$this->assertEquals('Etwas ist schiefgelaufen', $nurFehler[0]['message'], 'Der richtige Eintrag wird geliefert');
|
||||
|
||||
$andererKanal = 'test-' . uniqid();
|
||||
EventLog::error($andererKanal, 'Fehler im anderen Bereich');
|
||||
$kanalgefiltert = EventLog::find(['channel' => $kanal]);
|
||||
$this->assertEquals(4, count($kanalgefiltert), 'Bereichsfilter blendet fremde Kanäle aus');
|
||||
|
||||
$ohneFilter = EventLog::find([], 500);
|
||||
$this->assert(count($ohneFilter) >= 5, 'Ohne Filter erscheinen die Einträge aller Bereiche');
|
||||
|
||||
$kanaele = EventLog::channels();
|
||||
$this->assert(in_array($kanal, $kanaele, true), 'Der Bereich taucht in der Kanalliste auf');
|
||||
$this->assert(in_array($andererKanal, $kanaele, true), 'Auch der zweite Bereich wird gelistet');
|
||||
|
||||
// Ein unbekannter Schweregrad im Filter wird ignoriert statt zu filtern
|
||||
$this->assertEquals(
|
||||
4,
|
||||
count(EventLog::find(['channel' => $kanal, 'level' => 'quatsch'])),
|
||||
'Unbekannter Schweregrad im Filter wird ignoriert'
|
||||
);
|
||||
|
||||
// ── tryAdd schluckt Fehler statt sie weiterzureichen ─────────
|
||||
EventLog::tryAdd($kanal, str_repeat('x', 100), EventLog::LEVEL_ERROR);
|
||||
$this->assertEquals(5, count(EventLog::findByChannel($kanal)), 'tryAdd schreibt den Eintrag');
|
||||
|
||||
// ── Fehlerquellen sind angebunden ────────────────────────────
|
||||
$view = (string)file_get_contents(__DIR__ . '/../src/View.php');
|
||||
$this->assert(
|
||||
str_contains($view, 'logFlash') && str_contains($view, 'EventLog::tryAdd'),
|
||||
'Fehler- und Warnhinweise aus View::redirect() werden protokolliert'
|
||||
);
|
||||
|
||||
$beleg = (string)file_get_contents(__DIR__ . '/../src/Controllers/BelegController.php');
|
||||
$this->assert(
|
||||
str_contains($beleg, "EventLog::tryAdd(\n 'beleg-upload'"),
|
||||
'Fehlgeschlagene Beleg-Uploads werden protokolliert'
|
||||
);
|
||||
|
||||
$front = (string)file_get_contents(__DIR__ . '/../public/index.php');
|
||||
$this->assert(
|
||||
str_contains($front, 'steuerkramLogEvent') && substr_count($front, "'system'") >= 2,
|
||||
'Unbehandelte Ausnahmen und schwere Fehler landen im Bereich system'
|
||||
);
|
||||
|
||||
// ── Ansicht und Routen ───────────────────────────────────────
|
||||
$controller = (string)file_get_contents(__DIR__ . '/../src/Controllers/EventController.php');
|
||||
$this->assert(str_contains($controller, 'function indexData'), 'EventController::indexData() existiert');
|
||||
$this->assert(
|
||||
str_contains($controller, 'filterFromRequest'),
|
||||
'Bereich und Schweregrad werden aus der Anfrage übernommen'
|
||||
);
|
||||
|
||||
$routes = (string)file_get_contents(__DIR__ . '/../public/index.php');
|
||||
$this->assert(str_contains($routes, "'/ereignisse/data'"), 'Route /ereignisse/data ist registriert');
|
||||
|
||||
$index = (string)file_get_contents(__DIR__ . '/../src/Views/ereignisse/index.php');
|
||||
$this->assert(
|
||||
str_contains($index, 'name="channel"') && str_contains($index, 'name="level"'),
|
||||
'Die Ansicht bietet Filter für Bereich und Schweregrad'
|
||||
);
|
||||
$this->assert(
|
||||
str_contains($index, 'id="events-autoreload-toggle"') && str_contains($index, 'setInterval'),
|
||||
'Der automatische Neuaufbau bleibt erhalten'
|
||||
);
|
||||
}
|
||||
}
|
||||
63
tests/e2e/specs/ereignisse.spec.js
Normal file
63
tests/e2e/specs/ereignisse.spec.js
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
// @ts-check
|
||||
// Hotspot: Ereignisliste über alle Bereiche (Issue #165).
|
||||
//
|
||||
// Die Liste zeigt nicht mehr nur den Nextcloud-Import, sondern auch Fehler und
|
||||
// Warnungen aus dem übrigen System. Der Weg „Fehlermeldung im Browser → Eintrag
|
||||
// in der Ereignisliste“ führt über eine Weiterleitung mit Flash-Meldung und ist
|
||||
// deshalb nur mit Browser prüfbar; die Speicherlogik selbst deckt
|
||||
// tests/81_EreignisseAlleFehlerTest.php ab.
|
||||
//
|
||||
// Die Tests legen keine Geschäftsdaten an. Ein Aufruf einer ungültigen URL
|
||||
// erzeugt einen Protokolleintrag – genau das ist der geprüfte Effekt.
|
||||
|
||||
const { test, expect } = require('@playwright/test')
|
||||
const { gotoOk } = require('../helpers/app')
|
||||
|
||||
test.describe('Ereignisse', () => {
|
||||
test('Übersicht listet alle Bereiche und bietet Filter', async ({ page }) => {
|
||||
await gotoOk(page, '/ereignisse')
|
||||
|
||||
await expect(page.locator('select[name="channel"]')).toHaveCount(1)
|
||||
await expect(page.locator('select[name="level"]')).toHaveCount(1)
|
||||
await expect(page.locator('#events-autoreload-toggle')).toHaveCount(1)
|
||||
await expect(page.locator('select[name="channel"] option[value=""]')).toContainText('Alle Bereiche')
|
||||
})
|
||||
|
||||
test('Der alte Nextcloud-Pfad landet auf der gefilterten Übersicht', async ({ page }) => {
|
||||
await page.goto('/ereignisse/nextcloud-import')
|
||||
const url = new URL(page.url())
|
||||
expect(url.pathname).toBe('/ereignisse')
|
||||
expect(url.searchParams.get('channel')).toBe('nextcloud-import')
|
||||
await expect(page.locator('select[name="channel"]')).toHaveValue('nextcloud-import')
|
||||
})
|
||||
|
||||
test('Eine Fehlermeldung aus dem Browser erscheint in der Ereignisliste', async ({ page }) => {
|
||||
// Erzeugt garantiert eine Fehlermeldung samt Weiterleitung (Issue #167).
|
||||
await page.goto('/dashboard/buchungen/unsinn/2026')
|
||||
await expect(page.locator('body')).toContainText('Unbekannte Auswertung.')
|
||||
|
||||
await gotoOk(page, '/ereignisse?level=error')
|
||||
await expect(page.locator('body')).toContainText('Unbekannte Auswertung.')
|
||||
await expect(page.locator('body')).toContainText('[meldungen]')
|
||||
})
|
||||
|
||||
test('Filter nach Schweregrad blendet Infos aus', async ({ page }) => {
|
||||
await gotoOk(page, '/ereignisse?level=error')
|
||||
await expect(page.locator('select[name="level"]')).toHaveValue('error')
|
||||
|
||||
const zeilen = page.locator('#events-body table tbody tr')
|
||||
const anzahl = await zeilen.count()
|
||||
for (let i = 0; i < Math.min(anzahl, 10); i++) {
|
||||
await expect(zeilen.nth(i)).toContainText('Fehler')
|
||||
}
|
||||
})
|
||||
|
||||
test('Der Daten-Endpunkt liefert dieselbe gefilterte Liste als JSON', async ({ page }) => {
|
||||
const antwort = await page.request.get('/ereignisse/data?level=error')
|
||||
expect(antwort.status()).toBe(200)
|
||||
|
||||
const daten = await antwort.json()
|
||||
expect(typeof daten.count).toBe('number')
|
||||
expect(typeof daten.html).toBe('string')
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Reference in a new issue