diff --git a/migrations/091_system_events_level.sql b/migrations/091_system_events_level.sql
new file mode 100644
index 0000000..5e7fffd
--- /dev/null
+++ b/migrations/091_system_events_level.sql
@@ -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%';
diff --git a/public/index.php b/public/index.php
index 7bd028c..2ba4d83 100644
--- a/public/index.php
+++ b/public/index.php
@@ -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));
diff --git a/src/Controllers/BelegController.php b/src/Controllers/BelegController.php
index aa6a92f..f7a17dc 100644
--- a/src/Controllers/BelegController.php
+++ b/src/Controllers/BelegController.php
@@ -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');
diff --git a/src/Controllers/EventController.php b/src/Controllers/EventController.php
index c077783..08eadbc 100644
--- a/src/Controllers/EventController.php
+++ b/src/Controllers/EventController.php
@@ -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;
+ }
}
diff --git a/src/Models/EventLog.php b/src/Models/EventLog.php
index 6899012..dabb8ec 100644
--- a/src/Models/EventLog.php
+++ b/src/Models/EventLog.php
@@ -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'
);
}
}
diff --git a/src/View.php b/src/View.php
index 0ee0849..ec0d51c 100644
--- a/src/View.php
+++ b/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');
diff --git a/src/Views/ereignisse/_body.php b/src/Views/ereignisse/_body.php
new file mode 100644
index 0000000..025a26b
--- /dev/null
+++ b/src/Views/ereignisse/_body.php
@@ -0,0 +1,37 @@
+ 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)'],
+];
+?>
+
+
Keine Ereignisse vorhanden.
+
+
+
+
+
+
+
+ | = $h(date('Y-m-d H:i:s', strtotime($e['created_at']))) ?> |
+ = $h($levelLabel) ?> |
+ [= $h($e['channel']) ?>] |
+ = $h($e['message']) ?> |
+
+
+
+
+
+
diff --git a/src/Views/ereignisse/_nextcloud_import_body.php b/src/Views/ereignisse/_nextcloud_import_body.php
deleted file mode 100644
index 2e19645..0000000
--- a/src/Views/ereignisse/_nextcloud_import_body.php
+++ /dev/null
@@ -1,28 +0,0 @@
- View::h($v); ?>
-
-Keine Ereignisse vorhanden. Einträge entstehen, sobald der Nextcloud-Import Dateien importiert, überspringt oder auf Fehler stößt.
-
-
-
-
-
-
-
- | = $h(date('Y-m-d H:i:s', strtotime($e['created_at']))) ?> |
- [= $h($e['channel']) ?>] |
- = $h($msg) ?> |
-
-
-
-
-
-
diff --git a/src/Views/ereignisse/nextcloud_import.php b/src/Views/ereignisse/index.php
similarity index 63%
rename from src/Views/ereignisse/nextcloud_import.php
rename to src/Views/ereignisse/index.php
index e5a6048..e9d078c 100644
--- a/src/Views/ereignisse/nextcloud_import.php
+++ b/src/Views/ereignisse/index.php
@@ -1,3 +1,17 @@
+ 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',
+];
+?>
-
-
Nextcloud-Import
+
+
= count($events) ?> Einträge (neueste zuerst)