Admin: Monitoring-Seite (Systemstatus) und Navigation neu sortiert
- /admin/monitoring (nur Admin): Prüfungen wie /health, Version und Deploy-Zeit, geplante Aufgaben mit nächstem Lauf, letzte Fehler aus dem Log (ohne E-Mail-Adressen), Alarme, fehlgeschlagene Jobs, Links zu GlitchTip/Uptime Kuma (MONITORING_STATUS_URL), Test-Alarm - Navigation: CMS, Cabinet, Produkte, Regionen, Admin, ganz unten Monitoring Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
8ddc85422e
commit
061f004013
6 changed files with 525 additions and 42 deletions
170
app/Services/Monitoring/SystemStatus.php
Normal file
170
app/Services/Monitoring/SystemStatus.php
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Monitoring;
|
||||
|
||||
use App\Support\Monitoring\ErrorReportScrubber;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Daten für die Monitoring-Seite im Admin (09.10.2026): dieselben Prüfungen wie `/health`, dazu
|
||||
* Version, geplante Aufgaben, fehlgeschlagene Jobs und die letzten Fehler und Alarme aus den
|
||||
* Logs. Liest nur, ändert nichts; Logs nur das Ende der Datei, damit die Seite schnell bleibt.
|
||||
*/
|
||||
class SystemStatus
|
||||
{
|
||||
/** Zeitzone für die Anzeige – die App rechnet in UTC. */
|
||||
public const DisplayTimezone = 'Europe/Berlin';
|
||||
|
||||
private const LogTailBytes = 262144;
|
||||
|
||||
private const LogLinePattern = '/^\[(\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2})[^\]]*\] \w+\.(\w+): (.*)$/';
|
||||
|
||||
public function __construct(private readonly HealthProbe $probe) {}
|
||||
|
||||
/**
|
||||
* @return array{status: string, checks: list<array{key: string, label: string, status: string, detail: string}>}
|
||||
*/
|
||||
public function health(): array
|
||||
{
|
||||
$checks = $this->probe->checks();
|
||||
|
||||
return ['status' => HealthProbe::worstStatus($checks), 'checks' => $checks];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{version: string, environment: string, debug: bool, php: string, laravel: string, deployed_at: ?Carbon}
|
||||
*/
|
||||
public function application(): array
|
||||
{
|
||||
$release = base_path('RELEASE');
|
||||
|
||||
return [
|
||||
'version' => (string) (config('app.version') ?? '—'),
|
||||
'environment' => app()->environment(),
|
||||
'debug' => (bool) config('app.debug'),
|
||||
'php' => PHP_VERSION,
|
||||
'laravel' => app()->version(),
|
||||
'deployed_at' => is_file($release) ? Carbon::createFromTimestamp((int) filemtime($release))->timezone(self::DisplayTimezone) : null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Geplante Aufgaben mit nächstem Lauf (über `schedule:list`, das die Konsolen-Routen lädt).
|
||||
*
|
||||
* @return list<array{command: string, expression: string, next: ?Carbon, description: string}>
|
||||
*/
|
||||
public function scheduledTasks(): array
|
||||
{
|
||||
try {
|
||||
Artisan::call('schedule:list', ['--json' => true]);
|
||||
$tasks = json_decode(Artisan::output(), true);
|
||||
} catch (Throwable) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return collect(is_array($tasks) ? $tasks : [])
|
||||
->map(fn (array $task): array => [
|
||||
'command' => trim((string) preg_replace('/^.*?artisan\s+/', '', (string) ($task['command'] ?? ''))),
|
||||
'expression' => (string) ($task['expression'] ?? ''),
|
||||
'next' => filled($task['next_due_date'] ?? null) ? Carbon::parse($task['next_due_date'])->timezone(self::DisplayTimezone) : null,
|
||||
'description' => (string) ($task['description'] ?? ''),
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{count: int, latest: list<array{job: string, queue: string, failed_at: Carbon, error: string}>}
|
||||
*/
|
||||
public function failedJobs(int $limit = 5): array
|
||||
{
|
||||
try {
|
||||
$table = DB::table((string) config('queue.failed.table', 'failed_jobs'));
|
||||
|
||||
return [
|
||||
'count' => $table->count(),
|
||||
'latest' => $table->orderByDesc('failed_at')->limit($limit)->get()
|
||||
->map(fn (object $job): array => [
|
||||
'job' => (string) (json_decode((string) $job->payload, true)['displayName'] ?? 'Job'),
|
||||
'queue' => (string) $job->queue,
|
||||
'failed_at' => Carbon::parse($job->failed_at)->timezone(self::DisplayTimezone),
|
||||
'error' => ErrorReportScrubber::scrubText(Str::limit(strtok((string) $job->exception, "\n") ?: '', 180)),
|
||||
])
|
||||
->all(),
|
||||
];
|
||||
} catch (Throwable) {
|
||||
return ['count' => 0, 'latest' => []];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Letzte Einträge ab einer Stufe aus dem jüngsten Log einer Art (`laravel`, `monitoring`).
|
||||
*
|
||||
* @param list<string> $levels
|
||||
* @return list<array{at: Carbon, level: string, message: string}>
|
||||
*/
|
||||
public function recentLogEntries(string $prefix, array $levels, int $limit = 10): array
|
||||
{
|
||||
$files = glob(storage_path("logs/{$prefix}*.log")) ?: [];
|
||||
usort($files, fn (string $a, string $b): int => filemtime($b) <=> filemtime($a));
|
||||
$file = $files[0] ?? null;
|
||||
|
||||
if ($file === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$handle = @fopen($file, 'rb');
|
||||
|
||||
if ($handle === false) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$size = (int) filesize($file);
|
||||
fseek($handle, max(0, $size - self::LogTailBytes));
|
||||
$tail = (string) stream_get_contents($handle);
|
||||
fclose($handle);
|
||||
|
||||
$entries = [];
|
||||
|
||||
foreach (preg_split('/\R/', $tail) ?: [] as $line) {
|
||||
if (preg_match(self::LogLinePattern, $line, $match) !== 1 || ! in_array(strtoupper($match[2]), $levels, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$message = (string) preg_replace('/\s*\{"(exception|userId)".*$/s', '', $match[3]);
|
||||
|
||||
$entries[] = [
|
||||
'at' => Carbon::parse($match[1])->timezone(self::DisplayTimezone),
|
||||
'level' => strtolower($match[2]),
|
||||
'message' => ErrorReportScrubber::scrubText(Str::limit($message, 220)),
|
||||
];
|
||||
}
|
||||
|
||||
return array_slice(array_reverse($entries), 0, $limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Links zu den Diensten außerhalb der App, soweit konfiguriert.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function links(): array
|
||||
{
|
||||
$links = [];
|
||||
$dsn = (string) config('sentry.dsn');
|
||||
|
||||
if (preg_match('#^https?://[^@]+@([^/]+)/(\d+)#', $dsn, $match) === 1) {
|
||||
$links['GlitchTip'] = "https://{$match[1]}/";
|
||||
}
|
||||
|
||||
if (filled(config('monitoring.status_url'))) {
|
||||
$links['Uptime Kuma'] = (string) config('monitoring.status_url');
|
||||
}
|
||||
|
||||
return $links;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue