- /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>
170 lines
6 KiB
PHP
170 lines
6 KiB
PHP
<?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;
|
||
}
|
||
}
|