From f851fec20179b9d8149e46870d8c8f2d72e02ce6 Mon Sep 17 00:00:00 2001 From: DariusIII Date: Wed, 1 Apr 2026 12:54:16 +0200 Subject: [PATCH] Add site status page --- app/Console/Commands/CheckServiceHealth.php | 65 +++ app/Console/Commands/CheckServiceUptime.php | 37 ++ app/Enums/IncidentImpactEnum.php | 31 ++ app/Enums/IncidentStatusEnum.php | 31 ++ app/Enums/ServiceStatusEnum.php | 50 ++ .../Controllers/Admin/AdminPageController.php | 9 +- .../Admin/AdminStatusController.php | 165 ++++++ app/Http/Controllers/BasePageController.php | 2 +- app/Http/Controllers/StatusPageController.php | 44 ++ .../Requests/Admin/StoreIncidentRequest.php | 35 ++ .../Requests/Admin/UpdateIncidentRequest.php | 35 ++ .../Admin/UpdateServiceHealthRequest.php | 27 + app/Mail/IncidentDetected.php | 38 ++ app/Models/ServiceIncident.php | 76 +++ app/Models/ServiceStatus.php | 64 +++ app/Services/SiteStatusService.php | 502 ++++++++++++++++++ ...1_000000_create_service_statuses_table.php | 82 +++ ..._000001_create_service_incidents_table.php | 44 ++ ...120000_service_incidents_many_services.php | 73 +++ ...0_add_endpoint_url_to_service_statuses.php | 37 ++ ...30001_add_is_auto_to_service_incidents.php | 26 + resources/views/admin/dashboard.blade.php | 80 +++ resources/views/admin/status/create.blade.php | 110 ++++ resources/views/admin/status/edit.blade.php | 111 ++++ resources/views/admin/status/index.blade.php | 145 +++++ resources/views/emails/email_layout.blade.php | 52 ++ .../views/emails/incidentDetected.blade.php | 67 +++ resources/views/layouts/admin.blade.php | 1 + resources/views/layouts/main.blade.php | 1 + resources/views/partials/admin-menu.blade.php | 3 + resources/views/partials/sidebar.blade.php | 4 + resources/views/status/index.blade.php | 160 ++++++ routes/console.php | 1 + routes/web.php | 12 + 34 files changed, 2218 insertions(+), 2 deletions(-) create mode 100644 app/Console/Commands/CheckServiceHealth.php create mode 100644 app/Console/Commands/CheckServiceUptime.php create mode 100644 app/Enums/IncidentImpactEnum.php create mode 100644 app/Enums/IncidentStatusEnum.php create mode 100644 app/Enums/ServiceStatusEnum.php create mode 100644 app/Http/Controllers/Admin/AdminStatusController.php create mode 100644 app/Http/Controllers/StatusPageController.php create mode 100644 app/Http/Requests/Admin/StoreIncidentRequest.php create mode 100644 app/Http/Requests/Admin/UpdateIncidentRequest.php create mode 100644 app/Http/Requests/Admin/UpdateServiceHealthRequest.php create mode 100644 app/Mail/IncidentDetected.php create mode 100644 app/Models/ServiceIncident.php create mode 100644 app/Models/ServiceStatus.php create mode 100644 app/Services/SiteStatusService.php create mode 100644 database/migrations/2026_04_01_000000_create_service_statuses_table.php create mode 100644 database/migrations/2026_04_01_000001_create_service_incidents_table.php create mode 100644 database/migrations/2026_04_01_120000_service_incidents_many_services.php create mode 100644 database/migrations/2026_04_01_130000_add_endpoint_url_to_service_statuses.php create mode 100644 database/migrations/2026_04_01_130001_add_is_auto_to_service_incidents.php create mode 100644 resources/views/admin/status/create.blade.php create mode 100644 resources/views/admin/status/edit.blade.php create mode 100644 resources/views/admin/status/index.blade.php create mode 100644 resources/views/emails/incidentDetected.blade.php create mode 100644 resources/views/status/index.blade.php diff --git a/app/Console/Commands/CheckServiceHealth.php b/app/Console/Commands/CheckServiceHealth.php new file mode 100644 index 000000000..d405a16eb --- /dev/null +++ b/app/Console/Commands/CheckServiceHealth.php @@ -0,0 +1,65 @@ +option('days'); + $appUrl = rtrim((string) config('app.url'), '/'); + + if ($appUrl === '' || ! str_starts_with($appUrl, 'http')) { + $this->components->warn('APP_URL is not set — health checks will be skipped. Set APP_URL in .env to enable endpoint monitoring.'); + } + + $services = $statusService->getEnabledServices(); + + $rows = []; + + foreach ($services as $service) { + if ($service->endpoint_url === null || $service->endpoint_url === '') { + $rows[] = [$service->name, '—', '—', 'Skipped (no endpoint)']; + + continue; + } + + $result = $statusService->checkServiceHealth($service); + $statusService->handleHealthCheckResult($service, $result); + + $rows[] = [ + $service->name, + $result['status_code'] ?: '—', + $result['response_time_ms'] ? $result['response_time_ms'].'ms' : '—', + $result['ok'] ? 'Healthy' : $result['reason'], + ]; + } + + $statusService->refreshAllServiceUptime($days); + + $this->table(['Service', 'HTTP', 'Response', 'Result'], $rows); + + $services = $statusService->getEnabledServices(); + $summary = $services->map(fn ($s) => [ + $s->name, + $s->status->label(), + number_format((float) $s->uptime_percentage, 2).'%', + $s->last_checked_at?->diffForHumans() ?? 'never', + ])->all(); + + $this->newLine(); + $this->table(['Service', 'Status', 'Uptime', 'Checked'], $summary); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/CheckServiceUptime.php b/app/Console/Commands/CheckServiceUptime.php new file mode 100644 index 000000000..190d0e676 --- /dev/null +++ b/app/Console/Commands/CheckServiceUptime.php @@ -0,0 +1,37 @@ +option('days'); + + $this->components->info("Recalculating service uptime over a {$days}-day window…"); + + $statusService->refreshAllServiceUptime($days); + + $services = $statusService->getEnabledServices(); + $rows = $services->map(fn ($s) => [ + $s->name, + $s->status->label(), + number_format((float) $s->uptime_percentage, 2).'%', + $s->last_checked_at?->diffForHumans() ?? 'never', + ])->all(); + + $this->table(['Service', 'Status', 'Uptime', 'Checked'], $rows); + + return self::SUCCESS; + } +} diff --git a/app/Enums/IncidentImpactEnum.php b/app/Enums/IncidentImpactEnum.php new file mode 100644 index 000000000..036dee907 --- /dev/null +++ b/app/Enums/IncidentImpactEnum.php @@ -0,0 +1,31 @@ + 'None', + self::Minor => 'Minor', + self::Major => 'Major', + self::Critical => 'Critical', + }; + } + + /** + * @return array + */ + public static function values(): array + { + return array_column(self::cases(), 'value'); + } +} diff --git a/app/Enums/IncidentStatusEnum.php b/app/Enums/IncidentStatusEnum.php new file mode 100644 index 000000000..6e05e9d06 --- /dev/null +++ b/app/Enums/IncidentStatusEnum.php @@ -0,0 +1,31 @@ + 'Investigating', + self::Identified => 'Identified', + self::Monitoring => 'Monitoring', + self::Resolved => 'Resolved', + }; + } + + /** + * @return array + */ + public static function values(): array + { + return array_column(self::cases(), 'value'); + } +} diff --git a/app/Enums/ServiceStatusEnum.php b/app/Enums/ServiceStatusEnum.php new file mode 100644 index 000000000..744257090 --- /dev/null +++ b/app/Enums/ServiceStatusEnum.php @@ -0,0 +1,50 @@ + 'Operational', + self::Degraded => 'Degraded', + self::PartialOutage => 'Partial outage', + self::MajorOutage => 'Major outage', + self::Maintenance => 'Maintenance', + }; + } + + /** + * Higher value = worse condition for aggregation (worst wins). + */ + public function severity(): int + { + return match ($this) { + self::Operational => 0, + self::Degraded => 1, + self::Maintenance => 2, + self::PartialOutage => 3, + self::MajorOutage => 4, + }; + } + + /** + * @return array + */ + public static function values(): array + { + return array_column(self::cases(), 'value'); + } +} diff --git a/app/Http/Controllers/Admin/AdminPageController.php b/app/Http/Controllers/Admin/AdminPageController.php index 212179078..10beb1e1a 100644 --- a/app/Http/Controllers/Admin/AdminPageController.php +++ b/app/Http/Controllers/Admin/AdminPageController.php @@ -12,6 +12,7 @@ use App\Models\UsenetGroup; use App\Models\User; use App\Models\UserActivity; use App\Services\RegistrationStatusService; +use App\Services\SiteStatusService; use App\Services\SystemMetricsService; use App\Services\UserStatsService; use Illuminate\Http\JsonResponse; @@ -25,15 +26,19 @@ class AdminPageController extends BasePageController protected RegistrationStatusService $registrationStatusService; + protected SiteStatusService $siteStatusService; + public function __construct( UserStatsService $userStatsService, SystemMetricsService $systemMetricsService, - RegistrationStatusService $registrationStatusService + RegistrationStatusService $registrationStatusService, + SiteStatusService $siteStatusService ) { parent::__construct(); $this->userStatsService = $userStatsService; $this->systemMetricsService = $systemMetricsService; $this->registrationStatusService = $registrationStatusService; + $this->siteStatusService = $siteStatusService; } /** @@ -70,6 +75,8 @@ class AdminPageController extends BasePageController 'registrationStatus' => $registrationStatus, 'nextRegistrationPeriod' => $nextRegistrationPeriod, 'recent_activity' => $this->getRecentUserActivity(), + 'serviceStatuses' => $this->siteStatusService->getEnabledServices(), + 'activeIncidents' => $this->siteStatusService->getActiveIncidents(), ])); } diff --git a/app/Http/Controllers/Admin/AdminStatusController.php b/app/Http/Controllers/Admin/AdminStatusController.php new file mode 100644 index 000000000..90f5963fe --- /dev/null +++ b/app/Http/Controllers/Admin/AdminStatusController.php @@ -0,0 +1,165 @@ +setAdminPrefs(); + + $services = $this->siteStatusService->getAllServicesForAdmin(); + $incidents = ServiceIncident::query() + ->with(['services', 'creator']) + ->orderByDesc('started_at') + ->paginate(20) + ->withQueryString(); + + $this->viewData = array_merge($this->viewData, [ + 'services' => $services, + 'incidents' => $incidents, + 'meta_title' => 'Site status', + 'title' => 'Site status', + ]); + + return view('admin.status.index', $this->viewData); + } + + /** + * @throws \Exception + */ + public function create(): View + { + $this->setAdminPrefs(); + + $services = $this->siteStatusService->getAllServicesForAdmin(); + + $this->viewData = array_merge($this->viewData, [ + 'services' => $services, + 'meta_title' => 'Create incident', + 'title' => 'Create incident', + ]); + + return view('admin.status.create', $this->viewData); + } + + public function store(StoreIncidentRequest $request): RedirectResponse + { + $data = $request->validated(); + $data['created_by'] = Auth::id(); + + if ($data['status'] === IncidentStatusEnum::Resolved->value) { + $data['resolved_at'] = $data['resolved_at'] ?? now()->toDateTimeString(); + } + + $this->siteStatusService->createIncident($data); + + return redirect() + ->route('admin.status.index') + ->with('success', 'Incident created successfully.'); + } + + /** + * @throws \Exception + */ + public function edit(ServiceIncident $incident): View + { + $this->setAdminPrefs(); + + $services = $this->siteStatusService->getAllServicesForAdmin(); + + $this->viewData = array_merge($this->viewData, [ + 'incident' => $incident->load(['services', 'creator']), + 'services' => $services, + 'meta_title' => 'Edit incident', + 'title' => 'Edit incident', + ]); + + return view('admin.status.edit', $this->viewData); + } + + public function update(UpdateIncidentRequest $request, ServiceIncident $incident): RedirectResponse + { + $data = $request->validated(); + + if (($data['status'] ?? null) === IncidentStatusEnum::Resolved->value && empty($data['resolved_at'])) { + $data['resolved_at'] = now()->toDateTimeString(); + } + + if (($data['status'] ?? null) !== IncidentStatusEnum::Resolved->value) { + $data['resolved_at'] = null; + } + + $this->siteStatusService->updateIncident($incident, $data); + + return redirect() + ->route('admin.status.index') + ->with('success', 'Incident updated successfully.'); + } + + public function resolve(ServiceIncident $incident): RedirectResponse + { + if ($incident->status === IncidentStatusEnum::Resolved) { + return redirect() + ->route('admin.status.index') + ->with('info', 'Incident was already resolved.'); + } + + $this->siteStatusService->resolveIncident($incident); + + return redirect() + ->route('admin.status.index') + ->with('success', 'Incident marked as resolved.'); + } + + public function destroy(ServiceIncident $incident): RedirectResponse + { + $serviceIds = $incident->services()->pluck('id')->all(); + $incident->delete(); + + foreach ($serviceIds as $serviceId) { + $service = ServiceStatus::query()->find($serviceId); + if ($service instanceof ServiceStatus) { + $this->siteStatusService->recomputeServiceHealth($service); + } + } + + return redirect() + ->route('admin.status.index') + ->with('success', 'Incident deleted.'); + } + + public function updateService(UpdateServiceHealthRequest $request, ServiceStatus $service): RedirectResponse + { + $status = ServiceStatusEnum::from($request->validated('status')); + $this->siteStatusService->updateServiceStatus($service, $status); + + return redirect() + ->route('admin.status.index') + ->with('success', 'Service status updated.'); + } +} diff --git a/app/Http/Controllers/BasePageController.php b/app/Http/Controllers/BasePageController.php index f5af2cc93..dc4f8ad76 100644 --- a/app/Http/Controllers/BasePageController.php +++ b/app/Http/Controllers/BasePageController.php @@ -56,7 +56,7 @@ class BasePageController extends Controller */ public function __construct() { - $this->middleware(['auth', 'web', '2fa'])->except('api', 'contact', 'showContactForm', 'callback', 'btcPayCallback', 'getNzb', 'terms', 'privacyPolicy', 'capabilities', 'movie', 'apiSearch', 'tv', 'details', 'failed', 'showRssDesc', 'fullFeedRss', 'categoryFeedRss', 'cartRss', 'myMoviesRss', 'myShowsRss', 'trendingMoviesRss', 'trendingShowsRss', 'release', 'reset', 'showLinkRequestForm'); + $this->middleware(['auth', 'web', '2fa'])->except('api', 'contact', 'showContactForm', 'callback', 'btcPayCallback', 'getNzb', 'terms', 'privacyPolicy', 'capabilities', 'movie', 'apiSearch', 'tv', 'details', 'failed', 'showRssDesc', 'fullFeedRss', 'categoryFeedRss', 'cartRss', 'myMoviesRss', 'myShowsRss', 'trendingMoviesRss', 'trendingShowsRss', 'release', 'reset', 'showLinkRequestForm', 'showStatusPage'); // Load settings as collection with caching (5 minutes) $this->settings = Cache::remember('site_settings', 300, function () { diff --git a/app/Http/Controllers/StatusPageController.php b/app/Http/Controllers/StatusPageController.php new file mode 100644 index 000000000..3dcdb1922 --- /dev/null +++ b/app/Http/Controllers/StatusPageController.php @@ -0,0 +1,44 @@ +siteStatusService->getAllStatuses(); + $overall = $this->siteStatusService->getOverallStatus(); + $activeIncidents = $this->siteStatusService->getActiveIncidents(); + $recentResolved = $this->siteStatusService->getRecentResolvedIncidents(30); + + $groupedResolved = $recentResolved->groupBy(function ($incident) { + return $incident->resolved_at?->format('Y-m-d') ?? 'unknown'; + }); + + $this->viewData = array_merge($this->viewData, [ + 'meta_title' => 'Service status', + 'meta_description' => 'Current status of site services, incidents, and uptime.', + 'title' => 'Service status', + 'services' => $services, + 'overallStatus' => $overall, + 'activeIncidents' => $activeIncidents, + 'recentResolvedGrouped' => $groupedResolved, + ]); + + return view('status.index', $this->viewData); + } +} diff --git a/app/Http/Requests/Admin/StoreIncidentRequest.php b/app/Http/Requests/Admin/StoreIncidentRequest.php new file mode 100644 index 000000000..ea8400f24 --- /dev/null +++ b/app/Http/Requests/Admin/StoreIncidentRequest.php @@ -0,0 +1,35 @@ + + */ + public function rules(): array + { + return [ + 'title' => ['required', 'string', 'max:255'], + 'description' => ['required', 'string'], + 'service_status_ids' => ['required', 'array', 'min:1'], + 'service_status_ids.*' => ['integer', 'distinct', 'exists:service_statuses,id'], + 'impact' => ['required', 'string', Rule::in(IncidentImpactEnum::values())], + 'status' => ['required', 'string', Rule::in(IncidentStatusEnum::values())], + 'started_at' => ['required', 'date'], + 'resolved_at' => ['nullable', 'date', 'after_or_equal:started_at'], + ]; + } +} diff --git a/app/Http/Requests/Admin/UpdateIncidentRequest.php b/app/Http/Requests/Admin/UpdateIncidentRequest.php new file mode 100644 index 000000000..61035037e --- /dev/null +++ b/app/Http/Requests/Admin/UpdateIncidentRequest.php @@ -0,0 +1,35 @@ + + */ + public function rules(): array + { + return [ + 'title' => ['required', 'string', 'max:255'], + 'description' => ['required', 'string'], + 'service_status_ids' => ['required', 'array', 'min:1'], + 'service_status_ids.*' => ['integer', 'distinct', 'exists:service_statuses,id'], + 'impact' => ['required', 'string', Rule::in(IncidentImpactEnum::values())], + 'status' => ['required', 'string', Rule::in(IncidentStatusEnum::values())], + 'started_at' => ['required', 'date'], + 'resolved_at' => ['nullable', 'date', 'after_or_equal:started_at'], + ]; + } +} diff --git a/app/Http/Requests/Admin/UpdateServiceHealthRequest.php b/app/Http/Requests/Admin/UpdateServiceHealthRequest.php new file mode 100644 index 000000000..7122607e9 --- /dev/null +++ b/app/Http/Requests/Admin/UpdateServiceHealthRequest.php @@ -0,0 +1,27 @@ + + */ + public function rules(): array + { + return [ + 'status' => ['required', 'string', Rule::in(ServiceStatusEnum::values())], + ]; + } +} diff --git a/app/Mail/IncidentDetected.php b/app/Mail/IncidentDetected.php new file mode 100644 index 000000000..e47a5fd36 --- /dev/null +++ b/app/Mail/IncidentDetected.php @@ -0,0 +1,38 @@ +resolved ? 'Resolved' : 'Detected'; + $services = $this->incident->services->pluck('name')->join(', '); + + return $this->from(config('mail.from.address')) + ->subject("[{$site}] Service Incident {$status}: {$services}") + ->view('emails.incidentDetected') + ->with([ + 'incident' => $this->incident, + 'services' => $services, + 'resolved' => $this->resolved, + 'site' => $site, + 'statusUrl' => url('/admin/status'), + ]); + } +} diff --git a/app/Models/ServiceIncident.php b/app/Models/ServiceIncident.php new file mode 100644 index 000000000..d564652ce --- /dev/null +++ b/app/Models/ServiceIncident.php @@ -0,0 +1,76 @@ + + */ + protected $fillable = [ + 'title', + 'description', + 'status', + 'impact', + 'started_at', + 'resolved_at', + 'created_by', + 'is_auto', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'status' => IncidentStatusEnum::class, + 'impact' => IncidentImpactEnum::class, + 'started_at' => 'datetime', + 'resolved_at' => 'datetime', + 'is_auto' => 'boolean', + ]; + } + + /** + * @return BelongsToMany + */ + public function services(): BelongsToMany + { + return $this->belongsToMany(ServiceStatus::class, 'service_incident_service_status') + ->withTimestamps(); + } + + /** + * @return BelongsTo + */ + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + public function isResolved(): bool + { + return $this->status === IncidentStatusEnum::Resolved; + } +} diff --git a/app/Models/ServiceStatus.php b/app/Models/ServiceStatus.php new file mode 100644 index 000000000..0ac65c432 --- /dev/null +++ b/app/Models/ServiceStatus.php @@ -0,0 +1,64 @@ + + */ + protected $fillable = [ + 'name', + 'slug', + 'endpoint_url', + 'status', + 'last_checked_at', + 'uptime_percentage', + 'response_time_ms', + 'is_enabled', + 'sort_order', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'status' => ServiceStatusEnum::class, + 'last_checked_at' => 'datetime', + 'is_enabled' => 'boolean', + 'uptime_percentage' => 'decimal:2', + ]; + } + + /** + * @return BelongsToMany + */ + public function incidents(): BelongsToMany + { + return $this->belongsToMany(ServiceIncident::class, 'service_incident_service_status') + ->withTimestamps(); + } +} diff --git a/app/Services/SiteStatusService.php b/app/Services/SiteStatusService.php new file mode 100644 index 000000000..63dc16447 --- /dev/null +++ b/app/Services/SiteStatusService.php @@ -0,0 +1,502 @@ + + */ + public function getAllStatuses(): \Illuminate\Database\Eloquent\Collection + { + return $this->getEnabledServices(); + } + + /** + * @return \Illuminate\Database\Eloquent\Collection + */ + public function getEnabledServices(): \Illuminate\Database\Eloquent\Collection + { + return ServiceStatus::query() + ->where('is_enabled', true) + ->orderBy('sort_order') + ->orderBy('id') + ->get(); + } + + /** + * @return \Illuminate\Database\Eloquent\Collection + */ + public function getAllServicesForAdmin(): \Illuminate\Database\Eloquent\Collection + { + return ServiceStatus::query() + ->orderBy('sort_order') + ->orderBy('id') + ->get(); + } + + public function getOverallStatus(): ServiceStatusEnum + { + $services = $this->getEnabledServices(); + + if ($services->isEmpty()) { + return ServiceStatusEnum::Operational; + } + + $worst = ServiceStatusEnum::Operational; + + foreach ($services as $service) { + $current = $service->status; + if ($current->severity() > $worst->severity()) { + $worst = $current; + } + } + + return $worst; + } + + /** + * @return \Illuminate\Database\Eloquent\Collection + */ + public function getActiveIncidents(): \Illuminate\Database\Eloquent\Collection + { + return ServiceIncident::query() + ->with('services') + ->where('status', '!=', IncidentStatusEnum::Resolved->value) + ->orderByDesc('started_at') + ->get(); + } + + /** + * @return \Illuminate\Database\Eloquent\Collection + */ + public function getRecentResolvedIncidents(int $days = 30): \Illuminate\Database\Eloquent\Collection + { + $since = Carbon::now()->subDays($days); + + return ServiceIncident::query() + ->with('services') + ->where('status', IncidentStatusEnum::Resolved->value) + ->whereNotNull('resolved_at') + ->where('resolved_at', '>=', $since) + ->orderByDesc('resolved_at') + ->get(); + } + + /** + * Recalculate uptime percentage for a service over a rolling window. + * + * Only Major and Critical incidents count as downtime. The downtime + * for each incident is clamped to the window boundaries and + * overlapping intervals are merged so they're not double-counted. + */ + public function recalculateUptime(ServiceStatus $service, int $days = 30): float + { + $now = Carbon::now(); + $windowStart = $now->copy()->subDays($days); + $totalMinutes = (float) $windowStart->diffInMinutes($now); + + if ($totalMinutes <= 0) { + return 100.0; + } + + $incidents = ServiceIncident::query() + ->whereHas('services', static function ($query) use ($service): void { + $query->where('service_statuses.id', $service->id); + }) + ->whereIn('impact', [IncidentImpactEnum::Major->value, IncidentImpactEnum::Critical->value]) + ->where(static function ($query) use ($windowStart): void { + $query->whereNull('resolved_at') + ->orWhere('resolved_at', '>=', $windowStart); + }) + ->where('started_at', '<=', $now) + ->orderBy('started_at') + ->get(); + + if ($incidents->isEmpty()) { + return 100.0; + } + + $intervals = []; + foreach ($incidents as $incident) { + $start = $incident->started_at->max($windowStart); + $end = $incident->resolved_at?->min($now) ?? $now; + + if ($start->lt($end)) { + $intervals[] = [$start->getTimestamp(), $end->getTimestamp()]; + } + } + + $merged = $this->mergeIntervals($intervals); + + $downtimeMinutes = 0.0; + foreach ($merged as [$s, $e]) { + $downtimeMinutes += ($e - $s) / 60.0; + } + + $uptime = (($totalMinutes - $downtimeMinutes) / $totalMinutes) * 100.0; + + return round(max(0.0, min(100.0, $uptime)), 2); + } + + /** + * Run the periodic uptime check for all enabled services: + * recalculate uptime percentage, recompute health, and stamp last_checked_at. + */ + public function refreshAllServiceUptime(int $days = 30): void + { + $services = $this->getEnabledServices(); + + foreach ($services as $service) { + $uptime = $this->recalculateUptime($service, $days); + + $service->update([ + 'uptime_percentage' => $uptime, + 'last_checked_at' => Carbon::now(), + ]); + + $this->recomputeServiceHealth($service); + } + } + + /** + * Merge overlapping/adjacent timestamp intervals so downtime isn't double-counted. + * + * @param list $intervals [[start, end], ...] + * @return list + */ + private function mergeIntervals(array $intervals): array + { + if ($intervals === []) { + return []; + } + + usort($intervals, static fn (array $a, array $b): int => $a[0] <=> $b[0]); + + $merged = [$intervals[0]]; + + for ($i = 1, $count = \count($intervals); $i < $count; $i++) { + $last = &$merged[\count($merged) - 1]; + if ($intervals[$i][0] <= $last[1]) { + $last[1] = max($last[1], $intervals[$i][1]); + } else { + $merged[] = $intervals[$i]; + } + } + + return $merged; + } + + /** + * Placeholder for per-day uptime history when a dedicated metrics table exists. + * + * @return Collection + */ + public function getUptimeHistory(ServiceStatus $service, int $days = 90): Collection + { + return collect(); + } + + /** + * @param array $data + */ + public function createIncident(array $data): ServiceIncident + { + $serviceIds = []; + if (isset($data['service_status_ids']) && is_array($data['service_status_ids'])) { + $serviceIds = array_values(array_unique(array_map(static fn (mixed $v): int => (int) $v, $data['service_status_ids']))); + } + unset($data['service_status_ids']); + + $incident = ServiceIncident::query()->create($data); + + if ($serviceIds !== []) { + $incident->services()->sync($serviceIds); + } + + foreach ($incident->services as $service) { + $this->recomputeServiceHealth($service); + } + + return $incident->fresh(['services']); + } + + /** + * @param array $data + */ + public function updateIncident(ServiceIncident $incident, array $data): ServiceIncident + { + $serviceIds = null; + if (array_key_exists('service_status_ids', $data)) { + $raw = $data['service_status_ids']; + $serviceIds = is_array($raw) + ? array_values(array_unique(array_map(static fn (mixed $v): int => (int) $v, $raw))) + : []; + } + unset($data['service_status_ids']); + + $oldIds = $incident->services()->pluck('service_statuses.id')->all(); + + $incident->update($data); + + if ($serviceIds !== null) { + $incident->services()->sync($serviceIds); + } + + $incident->load('services'); + $newIds = $incident->services->pluck('id')->all(); + $affectedIds = array_unique(array_merge($oldIds, $newIds)); + + foreach (ServiceStatus::query()->whereIn('id', $affectedIds)->get() as $service) { + $this->recomputeServiceHealth($service); + } + + return $incident->fresh(['services']); + } + + public function resolveIncident(ServiceIncident $incident): ServiceIncident + { + $services = $incident->services()->get(); + + $incident->update([ + 'status' => IncidentStatusEnum::Resolved, + 'resolved_at' => $incident->resolved_at ?? Carbon::now(), + ]); + + foreach ($services as $service) { + $this->recomputeServiceHealth($service); + } + + return $incident->fresh(['services']); + } + + public function updateServiceStatus(ServiceStatus $service, ServiceStatusEnum $status): void + { + $service->update([ + 'status' => $status, + 'last_checked_at' => Carbon::now(), + ]); + } + + public function recomputeServiceHealth(ServiceStatus $service): void + { + $active = ServiceIncident::query() + ->whereHas('services', static function ($query) use ($service): void { + $query->where('service_statuses.id', $service->id); + }) + ->where('status', '!=', IncidentStatusEnum::Resolved->value) + ->get(); + + if ($active->isEmpty()) { + $service->update(['status' => ServiceStatusEnum::Operational]); + + return; + } + + $worst = ServiceStatusEnum::Operational; + foreach ($active as $incident) { + $mapped = $this->mapIncidentImpactToServiceHealth($incident->impact); + if ($mapped->severity() > $worst->severity()) { + $worst = $mapped; + } + } + + $service->update(['status' => $worst]); + } + + private const int SLOW_THRESHOLD_MS = 5000; + + private const string AUTO_INCIDENT_PREFIX = '[Auto]'; + + /** + * Ping a service endpoint and return health check results. + * + * @return array{ok: bool, status_code: int, response_time_ms: int, impact: IncidentImpactEnum|null, reason: string} + */ + public function checkServiceHealth(ServiceStatus $service): array + { + $baseUrl = rtrim((string) config('app.url'), '/'); + + if ($baseUrl === '' || ! str_starts_with($baseUrl, 'http')) { + return ['ok' => true, 'status_code' => 0, 'response_time_ms' => 0, 'impact' => null, 'reason' => 'APP_URL not configured']; + } + + $raw = $service->endpoint_url; + + if ($raw === null || $raw === '') { + return ['ok' => true, 'status_code' => 0, 'response_time_ms' => 0, 'impact' => null, 'reason' => 'No endpoint configured']; + } + + $paths = array_filter(array_map('trim', explode(',', $raw))); + $isRss = $service->slug === 'rss'; + $worstResult = null; + + foreach ($paths as $path) { + $result = $this->pingEndpoint($baseUrl.$path, $isRss); + + if ($result['ok']) { + return $result; + } + + if ($worstResult === null || ($result['impact']?->value ?? '') > ($worstResult['impact']?->value ?? '')) { + $worstResult = $result; + } + } + + return $worstResult ?? ['ok' => false, 'status_code' => 0, 'response_time_ms' => 0, 'impact' => IncidentImpactEnum::Critical, 'reason' => 'All endpoints unreachable']; + } + + /** + * @return array{ok: bool, status_code: int, response_time_ms: int, impact: IncidentImpactEnum|null, reason: string} + */ + private function pingEndpoint(string $url, bool $treat403AsHealthy): array + { + try { + $start = hrtime(true); + $response = Http::timeout(15)->connectTimeout(10)->get($url); + $elapsed = (int) ((hrtime(true) - $start) / 1_000_000); + + $code = $response->status(); + + if ($response->successful() || ($treat403AsHealthy && $code === 403)) { + if ($elapsed > self::SLOW_THRESHOLD_MS) { + return [ + 'ok' => false, + 'status_code' => $code, + 'response_time_ms' => $elapsed, + 'impact' => IncidentImpactEnum::Minor, + 'reason' => "Slow response ({$elapsed}ms)", + ]; + } + + return ['ok' => true, 'status_code' => $code, 'response_time_ms' => $elapsed, 'impact' => null, 'reason' => 'OK']; + } + + if ($response->serverError()) { + return [ + 'ok' => false, + 'status_code' => $code, + 'response_time_ms' => $elapsed, + 'impact' => IncidentImpactEnum::Critical, + 'reason' => "Server error (HTTP {$code})", + ]; + } + + return [ + 'ok' => false, + 'status_code' => $code, + 'response_time_ms' => $elapsed, + 'impact' => IncidentImpactEnum::Major, + 'reason' => "Unexpected response (HTTP {$code})", + ]; + } catch (\Throwable $e) { + Log::warning("Health check failed for {$url}: {$e->getMessage()}"); + + return [ + 'ok' => false, + 'status_code' => 0, + 'response_time_ms' => 0, + 'impact' => IncidentImpactEnum::Critical, + 'reason' => 'Connection failed: '.\Str::limit($e->getMessage(), 120), + ]; + } + } + + /** + * Find the currently open auto-created incident for a service (if any). + */ + public function getOpenAutoIncident(ServiceStatus $service): ?ServiceIncident + { + return ServiceIncident::query() + ->where('is_auto', true) + ->where('status', '!=', IncidentStatusEnum::Resolved->value) + ->whereHas('services', static function ($query) use ($service): void { + $query->where('service_statuses.id', $service->id); + }) + ->latest('started_at') + ->first(); + } + + /** + * Process a health check result: create an auto-incident on failure, or auto-resolve on recovery. + * + * @param array{ok: bool, status_code: int, response_time_ms: int, impact: IncidentImpactEnum|null, reason: string} $result + */ + public function handleHealthCheckResult(ServiceStatus $service, array $result): void + { + $service->update([ + 'response_time_ms' => $result['response_time_ms'] ?: null, + ]); + + $existing = $this->getOpenAutoIncident($service); + + if ($result['ok']) { + if ($existing !== null) { + $this->resolveIncident($existing); + $this->sendIncidentEmail($existing->fresh(['services']), resolved: true); + } + + return; + } + + if ($existing !== null) { + return; + } + + $impact = $result['impact'] ?? IncidentImpactEnum::Major; + + $incident = ServiceIncident::query()->create([ + 'title' => self::AUTO_INCIDENT_PREFIX.' '.$service->name.' — '.$result['reason'], + 'description' => "Automated health check detected an issue with {$service->name}.\n\nEndpoint: {$service->endpoint_url}\nHTTP status: {$result['status_code']}\nResponse time: {$result['response_time_ms']}ms\nReason: {$result['reason']}", + 'status' => IncidentStatusEnum::Investigating, + 'impact' => $impact, + 'started_at' => Carbon::now(), + 'is_auto' => true, + ]); + + $incident->services()->attach($service->id); + $incident->load('services'); + + $this->recomputeServiceHealth($service); + $this->sendIncidentEmail($incident, resolved: false); + } + + private function sendIncidentEmail(ServiceIncident $incident, bool $resolved): void + { + $adminEmail = (string) config('nntmux.admin_email'); + + if ($adminEmail === '' || $adminEmail === 'admin@example.com') { + return; + } + + try { + Mail::to($adminEmail)->send(new IncidentDetected($incident, $resolved)); + } catch (\Throwable $e) { + Log::warning("Failed to send incident email: {$e->getMessage()}"); + } + } + + private function mapIncidentImpactToServiceHealth(IncidentImpactEnum $impact): ServiceStatusEnum + { + return match ($impact) { + IncidentImpactEnum::None => ServiceStatusEnum::Degraded, + IncidentImpactEnum::Minor => ServiceStatusEnum::Degraded, + IncidentImpactEnum::Major => ServiceStatusEnum::PartialOutage, + IncidentImpactEnum::Critical => ServiceStatusEnum::MajorOutage, + }; + } +} diff --git a/database/migrations/2026_04_01_000000_create_service_statuses_table.php b/database/migrations/2026_04_01_000000_create_service_statuses_table.php new file mode 100644 index 000000000..1edc80dc4 --- /dev/null +++ b/database/migrations/2026_04_01_000000_create_service_statuses_table.php @@ -0,0 +1,82 @@ +id(); + $table->string('name'); + $table->string('slug')->unique(); + $table->string('status'); + $table->timestamp('last_checked_at')->nullable(); + $table->decimal('uptime_percentage', 5, 2)->default('100.00'); + $table->unsignedInteger('response_time_ms')->nullable(); + $table->boolean('is_enabled')->default(true); + $table->integer('sort_order')->default(0); + $table->timestamps(); + + $table->index('status'); + $table->index(['is_enabled', 'sort_order']); + }); + + $now = now(); + + DB::table('service_statuses')->insert([ + [ + 'name' => 'API', + 'slug' => 'api', + 'status' => 'operational', + 'last_checked_at' => null, + 'uptime_percentage' => '100.00', + 'response_time_ms' => null, + 'is_enabled' => true, + 'sort_order' => 1, + 'created_at' => $now, + 'updated_at' => $now, + ], + [ + 'name' => 'HTTP', + 'slug' => 'http', + 'status' => 'operational', + 'last_checked_at' => null, + 'uptime_percentage' => '100.00', + 'response_time_ms' => null, + 'is_enabled' => true, + 'sort_order' => 2, + 'created_at' => $now, + 'updated_at' => $now, + ], + [ + 'name' => 'RSS', + 'slug' => 'rss', + 'status' => 'operational', + 'last_checked_at' => null, + 'uptime_percentage' => '100.00', + 'response_time_ms' => null, + 'is_enabled' => true, + 'sort_order' => 3, + 'created_at' => $now, + 'updated_at' => $now, + ], + ]); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('service_statuses'); + } +}; diff --git a/database/migrations/2026_04_01_000001_create_service_incidents_table.php b/database/migrations/2026_04_01_000001_create_service_incidents_table.php new file mode 100644 index 000000000..6655181e8 --- /dev/null +++ b/database/migrations/2026_04_01_000001_create_service_incidents_table.php @@ -0,0 +1,44 @@ +id(); + $table->string('title'); + $table->text('description'); + $table->string('status'); + $table->string('impact'); + $table->foreignId('service_status_id')->constrained('service_statuses')->cascadeOnDelete(); + $table->timestamp('started_at'); + $table->timestamp('resolved_at')->nullable(); + // Match legacy users.id type (typically INT UNSIGNED), not BIGINT + $table->unsignedInteger('created_by')->nullable()->index(); + $table->timestamps(); + + $table->index('status'); + $table->index('impact'); + $table->index('started_at'); + $table->index('resolved_at'); + $table->index(['status', 'started_at']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('service_incidents'); + } +}; diff --git a/database/migrations/2026_04_01_120000_service_incidents_many_services.php b/database/migrations/2026_04_01_120000_service_incidents_many_services.php new file mode 100644 index 000000000..d60ccf7ce --- /dev/null +++ b/database/migrations/2026_04_01_120000_service_incidents_many_services.php @@ -0,0 +1,73 @@ +id(); + $table->foreignId('service_incident_id')->constrained('service_incidents')->cascadeOnDelete(); + $table->foreignId('service_status_id')->constrained('service_statuses')->cascadeOnDelete(); + $table->timestamps(); + + $table->unique(['service_incident_id', 'service_status_id'], 'svc_incident_svc_pair_unique'); + $table->index('service_status_id'); + }); + + $rows = DB::table('service_incidents')->select(['id', 'service_status_id'])->get(); + $now = now(); + foreach ($rows as $row) { + DB::table('service_incident_service_status')->insert([ + 'service_incident_id' => $row->id, + 'service_status_id' => $row->service_status_id, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + + Schema::table('service_incidents', function (Blueprint $table) { + $table->dropForeign(['service_status_id']); + $table->dropColumn('service_status_id'); + }); + } + + /** + * Reverse the migrations. + * + * Restores a single service_status_id per incident (first pivot row only). Multi-service data is lost on rollback. + */ + public function down(): void + { + Schema::table('service_incidents', function (Blueprint $table) { + $table->foreignId('service_status_id')->after('impact')->nullable()->constrained('service_statuses')->cascadeOnDelete(); + }); + + $rows = DB::table('service_incident_service_status') + ->select(['service_incident_id', 'service_status_id']) + ->orderBy('id') + ->get(); + + $seen = []; + foreach ($rows as $row) { + if (isset($seen[$row->service_incident_id])) { + continue; + } + $seen[$row->service_incident_id] = true; + DB::table('service_incidents') + ->where('id', $row->service_incident_id) + ->update(['service_status_id' => $row->service_status_id]); + } + + Schema::dropIfExists('service_incident_service_status'); + } +}; diff --git a/database/migrations/2026_04_01_130000_add_endpoint_url_to_service_statuses.php b/database/migrations/2026_04_01_130000_add_endpoint_url_to_service_statuses.php new file mode 100644 index 000000000..9de1e211b --- /dev/null +++ b/database/migrations/2026_04_01_130000_add_endpoint_url_to_service_statuses.php @@ -0,0 +1,37 @@ +string('endpoint_url')->nullable()->after('slug'); + }); + + $defaults = [ + 'api' => '/api/v2/capabilities,/api/v1/api', + 'http' => '/up', + 'rss' => '/rss/full-feed', + ]; + + foreach ($defaults as $slug => $path) { + DB::table('service_statuses') + ->where('slug', $slug) + ->update(['endpoint_url' => $path]); + } + } + + public function down(): void + { + Schema::table('service_statuses', static function (Blueprint $table): void { + $table->dropColumn('endpoint_url'); + }); + } +}; diff --git a/database/migrations/2026_04_01_130001_add_is_auto_to_service_incidents.php b/database/migrations/2026_04_01_130001_add_is_auto_to_service_incidents.php new file mode 100644 index 000000000..51cb6a5aa --- /dev/null +++ b/database/migrations/2026_04_01_130001_add_is_auto_to_service_incidents.php @@ -0,0 +1,26 @@ +boolean('is_auto')->default(false)->after('created_by'); + $table->index('is_auto'); + }); + } + + public function down(): void + { + Schema::table('service_incidents', static function (Blueprint $table): void { + $table->dropIndex(['is_auto']); + $table->dropColumn('is_auto'); + }); + } +}; diff --git a/resources/views/admin/dashboard.blade.php b/resources/views/admin/dashboard.blade.php index cbacd9e6d..b97df82b5 100644 --- a/resources/views/admin/dashboard.blade.php +++ b/resources/views/admin/dashboard.blade.php @@ -152,6 +152,86 @@ + +
+

+ + + Site Status + + Manage → +

+ + @php + $dashBadge = static function (string $status): string { + $base = 'px-2 py-0.5 inline-flex text-xs leading-5 font-semibold rounded-full'; + return $base.' '.match ($status) { + 'operational' => 'bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200', + 'degraded' => 'bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200', + 'maintenance' => 'bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200', + 'partial_outage' => 'bg-orange-100 dark:bg-orange-900 text-orange-800 dark:text-orange-200', + 'major_outage' => 'bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200', + default => 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200', + }; + }; + @endphp + +
+ @foreach($serviceStatuses as $svc) +
+
+ {{ $svc->name }} + {{ number_format((float) $svc->uptime_percentage, 2) }}% uptime +
+ {{ $svc->status->label() }} +
+ @endforeach +
+ + @if($activeIncidents->isNotEmpty()) +
+

+ + {{ $activeIncidents->count() }} Active {{ Str::plural('Incident', $activeIncidents->count()) }} +

+
+ @foreach($activeIncidents->take(5) as $incident) +
+
+ {{ Str::limit($incident->title, 50) }} + + {{ $incident->services->pluck('name')->join(', ') }} + · {{ $incident->started_at->diffForHumans() }} + @if($incident->is_auto) + Auto + @endif + +
+ {{ $incident->impact->label() }} +
+ @endforeach + @if($activeIncidents->count() > 5) +

+ + {{ $activeIncidents->count() - 5 }} more — + view all +

+ @endif +
+
+ @else +
+

+ No active incidents +

+
+ @endif +
+
diff --git a/resources/views/admin/status/create.blade.php b/resources/views/admin/status/create.blade.php new file mode 100644 index 000000000..03c0baaa0 --- /dev/null +++ b/resources/views/admin/status/create.blade.php @@ -0,0 +1,110 @@ +@extends('layouts.admin') + +@section('content') +@php + use App\Enums\IncidentImpactEnum; + use App\Enums\IncidentStatusEnum; + + $oldServiceIds = array_map( + static fn (mixed $id): int => (int) $id, + (array) old('service_status_ids', []) + ); +@endphp + +
+ + + +
+
+ @csrf + +
+ + + @error('title') +

{{ $message }}

+ @enderror +
+ +
+ + + @error('description') +

{{ $message }}

+ @enderror +
+ +
+ Affected services +

Select one or more areas impacted by this incident.

+
+ @foreach($services as $svc) + + @endforeach +
+ @error('service_status_ids') +

{{ $message }}

+ @enderror +
+ +
+ + + @error('impact') +

{{ $message }}

+ @enderror +
+ +
+ + + @error('status') +

{{ $message }}

+ @enderror +
+ +
+ + + @error('started_at') +

{{ $message }}

+ @enderror +
+ +
+ + + @error('resolved_at') +

{{ $message }}

+ @enderror +
+ +
+ + Cancel +
+
+
+
+
+@endsection diff --git a/resources/views/admin/status/edit.blade.php b/resources/views/admin/status/edit.blade.php new file mode 100644 index 000000000..8cb2d1867 --- /dev/null +++ b/resources/views/admin/status/edit.blade.php @@ -0,0 +1,111 @@ +@extends('layouts.admin') + +@section('content') +@php + use App\Enums\IncidentImpactEnum; + use App\Enums\IncidentStatusEnum; + + $selectedIds = array_map( + static fn (mixed $id): int => (int) $id, + (array) old('service_status_ids', $incident->services->pluck('id')->all()) + ); +@endphp + +
+ + + +
+
+ @csrf + @method('PUT') + +
+ + + @error('title') +

{{ $message }}

+ @enderror +
+ +
+ + + @error('description') +

{{ $message }}

+ @enderror +
+ +
+ Affected services +

Select one or more areas impacted by this incident.

+
+ @foreach($services as $svc) + + @endforeach +
+ @error('service_status_ids') +

{{ $message }}

+ @enderror +
+ +
+ + + @error('impact') +

{{ $message }}

+ @enderror +
+ +
+ + + @error('status') +

{{ $message }}

+ @enderror +
+ +
+ + + @error('started_at') +

{{ $message }}

+ @enderror +
+ +
+ + + @error('resolved_at') +

{{ $message }}

+ @enderror +
+ +
+ + Cancel +
+
+
+
+
+@endsection diff --git a/resources/views/admin/status/index.blade.php b/resources/views/admin/status/index.blade.php new file mode 100644 index 000000000..09589ee4c --- /dev/null +++ b/resources/views/admin/status/index.blade.php @@ -0,0 +1,145 @@ +@extends('layouts.admin') + +@section('content') +@php + use App\Enums\ServiceStatusEnum; + use App\Enums\IncidentImpactEnum; + use App\Enums\IncidentStatusEnum; + + $serviceBadge = static function (ServiceStatusEnum $s): string { + $base = 'px-2 py-0.5 inline-flex text-xs leading-5 font-semibold rounded-full'; + return $base.' '.match ($s) { + ServiceStatusEnum::Operational => 'bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200', + ServiceStatusEnum::Degraded => 'bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200', + ServiceStatusEnum::Maintenance => 'bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200', + ServiceStatusEnum::PartialOutage => 'bg-orange-100 dark:bg-orange-900 text-orange-800 dark:text-orange-200', + ServiceStatusEnum::MajorOutage => 'bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200', + }; + }; + + $impactBadge = static function (IncidentImpactEnum $i): string { + $base = 'px-2 py-0.5 inline-flex text-xs leading-5 font-semibold rounded-full'; + return $base.' '.match ($i) { + IncidentImpactEnum::None => 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200', + IncidentImpactEnum::Minor => 'bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200', + IncidentImpactEnum::Major => 'bg-orange-100 dark:bg-orange-900 text-orange-800 dark:text-orange-200', + IncidentImpactEnum::Critical => 'bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200', + }; + }; + + $incidentStatusBadge = static function (IncidentStatusEnum $st): string { + $base = 'px-2 py-0.5 inline-flex text-xs leading-5 font-semibold rounded-full'; + return $base.' '.match ($st) { + IncidentStatusEnum::Investigating => 'bg-purple-100 dark:bg-purple-900 text-purple-800 dark:text-purple-200', + IncidentStatusEnum::Identified => 'bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200', + IncidentStatusEnum::Monitoring => 'bg-cyan-100 dark:bg-cyan-900 text-cyan-800 dark:text-cyan-200', + IncidentStatusEnum::Resolved => 'bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200', + }; + }; +@endphp + +
+ + + + + Create incident + + + Public page + + + + +
+

Service health

+
+ @foreach($services as $svc) +
+
+
+
{{ $svc->name }}
+
Slug: {{ $svc->slug }}
+
+ {{ $svc->status->label() }} +
+
+ @csrf + + + +
+
+ @endforeach +
+
+
+ + +
+

Incidents

+
+
+ + + ID + Title + Services + Impact + Status + Started + Actions + + @forelse($incidents as $incident) + + {{ $incident->id }} + + {{ Str::limit($incident->title, 48) }} + @if($incident->is_auto) + Auto + @endif + + {{ $incident->services->pluck('name')->join(', ') ?: '—' }} + + {{ $incident->impact->label() }} + + + {{ $incident->status->label() }} + + {{ $incident->started_at->format('Y-m-d H:i') }} + + Edit + @if($incident->status !== IncidentStatusEnum::Resolved) +
+ @csrf + +
+ @endif +
+ @csrf + @method('DELETE') + +
+ + + @empty + + No incidents yet. + + @endforelse +
+
+ @if($incidents->hasPages()) +
+ {{ $incidents->links() }} +
+ @endif +
+
+@endsection diff --git a/resources/views/emails/email_layout.blade.php b/resources/views/emails/email_layout.blade.php index 77bad10db..c08b5e269 100644 --- a/resources/views/emails/email_layout.blade.php +++ b/resources/views/emails/email_layout.blade.php @@ -131,6 +131,58 @@ display: block; margin: 10px 0; } + + .alert-box { + padding: 16px 20px; + margin: 20px 0; + border-radius: 6px; + font-size: 15px; + } + + .alert-danger { + background-color: #fef2f2; + border-left: 4px solid #dc2626; + color: #991b1b; + } + + .alert-warning { + background-color: #fff3cd; + border-left: 4px solid #f59e0b; + color: #92400e; + } + + .alert-info { + background-color: #eff6ff; + border-left: 4px solid #3b82f6; + color: #1e40af; + } + + .alert-success { + background-color: #f0fdf4; + border-left: 4px solid #16a34a; + color: #166534; + } + + .status-table { + width: 100%; + border-collapse: collapse; + margin: 20px 0; + } + + .status-table td { + padding: 10px 12px; + border-bottom: 1px solid #eaeaea; + font-size: 14px; + color: #51545e; + vertical-align: top; + } + + .status-table .status-label { + font-weight: 600; + color: #333333; + width: 140px; + white-space: nowrap; + } diff --git a/resources/views/emails/incidentDetected.blade.php b/resources/views/emails/incidentDetected.blade.php new file mode 100644 index 000000000..293f58726 --- /dev/null +++ b/resources/views/emails/incidentDetected.blade.php @@ -0,0 +1,67 @@ +@extends('emails.email_layout') + +@section('title') + {{ $resolved ? 'Incident Resolved' : 'Incident Detected' }} — {{ $services }} +@endsection + +@section('site_name', $site) + +@section('content') + @if($resolved) +
+ ✅ Resolved — The following incident has been automatically resolved. +
+ @else +
+ ⚠️ {{ ucfirst($incident->impact->value) }} Impact — An automated health check has detected a service issue. +
+ @endif + + + + + + + + + + + + + + + + + + + + + + + @if($resolved && $incident->resolved_at) + + + + + + + + + @endif +
Incident{{ $incident->title }}
Affected Services{{ $services }}
Impact{{ ucfirst($incident->impact->value) }}
Status{{ ucfirst($incident->status->value) }}
Started{{ $incident->started_at->format('M j, Y g:i A T') }}
Resolved{{ $incident->resolved_at->format('M j, Y g:i A T') }}
Duration{{ $incident->started_at->diffForHumans($incident->resolved_at, true) }}
+ + @if($incident->description) +
+ Details:
+ {!! nl2br(e($incident->description)) !!} +
+ @endif + +

+ View Status Dashboard +

+ +
+

— {{ $site }} Monitoring

+
+@endsection diff --git a/resources/views/layouts/admin.blade.php b/resources/views/layouts/admin.blade.php index ac74a4126..ed3799e5b 100644 --- a/resources/views/layouts/admin.blade.php +++ b/resources/views/layouts/admin.blade.php @@ -24,6 +24,7 @@ @vite(['resources/css/app.css', 'resources/js/app.js']) + @stack('meta') @stack('styles') diff --git a/resources/views/layouts/main.blade.php b/resources/views/layouts/main.blade.php index 0af6d178c..d96a38b0e 100644 --- a/resources/views/layouts/main.blade.php +++ b/resources/views/layouts/main.blade.php @@ -29,6 +29,7 @@ @vite(['resources/css/app.css', 'resources/js/app.js']) + @stack('meta') @stack('styles') diff --git a/resources/views/partials/admin-menu.blade.php b/resources/views/partials/admin-menu.blade.php index 35ff5ef68..2a7058593 100644 --- a/resources/views/partials/admin-menu.blade.php +++ b/resources/views/partials/admin-menu.blade.php @@ -311,6 +311,9 @@ Statistics + + Site Status + Logs diff --git a/resources/views/partials/sidebar.blade.php b/resources/views/partials/sidebar.blade.php index 018fec3f6..41a9462a6 100644 --- a/resources/views/partials/sidebar.blade.php +++ b/resources/views/partials/sidebar.blade.php @@ -104,6 +104,10 @@ API V2 + + + Site status + Contact Us diff --git a/resources/views/status/index.blade.php b/resources/views/status/index.blade.php new file mode 100644 index 000000000..7ea426a63 --- /dev/null +++ b/resources/views/status/index.blade.php @@ -0,0 +1,160 @@ +@extends('layouts.main') + +@section('content') +@php + use App\Enums\ServiceStatusEnum; + use App\Enums\IncidentImpactEnum; + use App\Enums\IncidentStatusEnum; + + $overallBanner = match ($overallStatus) { + ServiceStatusEnum::Operational => 'border-green-500/50 bg-green-600 text-white dark:bg-green-900 dark:text-green-200', + ServiceStatusEnum::Degraded => 'border-yellow-500/50 bg-yellow-500 text-yellow-950 dark:bg-yellow-900 dark:text-yellow-200', + ServiceStatusEnum::Maintenance => 'border-blue-500/50 bg-blue-600 text-white dark:bg-blue-900 dark:text-blue-200', + ServiceStatusEnum::PartialOutage => 'border-orange-500/50 bg-orange-600 text-white dark:bg-orange-900 dark:text-orange-200', + ServiceStatusEnum::MajorOutage => 'border-red-500/50 bg-red-600 text-white dark:bg-red-900 dark:text-red-200', + }; + + $serviceBadge = static function (ServiceStatusEnum $s): string { + $base = 'px-3 py-1 inline-flex text-xs leading-5 font-semibold rounded-full'; + return $base.' '.match ($s) { + ServiceStatusEnum::Operational => 'bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200', + ServiceStatusEnum::Degraded => 'bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200', + ServiceStatusEnum::Maintenance => 'bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200', + ServiceStatusEnum::PartialOutage => 'bg-orange-100 dark:bg-orange-900 text-orange-800 dark:text-orange-200', + ServiceStatusEnum::MajorOutage => 'bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200', + }; + }; + + $impactBadge = static function (IncidentImpactEnum $i): string { + $base = 'px-2 py-0.5 inline-flex text-xs leading-5 font-semibold rounded-full'; + return $base.' '.match ($i) { + IncidentImpactEnum::None => 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200', + IncidentImpactEnum::Minor => 'bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200', + IncidentImpactEnum::Major => 'bg-orange-100 dark:bg-orange-900 text-orange-800 dark:text-orange-200', + IncidentImpactEnum::Critical => 'bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200', + }; + }; + + $incidentStatusBadge = static function (IncidentStatusEnum $st): string { + $base = 'px-2 py-0.5 inline-flex text-xs leading-5 font-semibold rounded-full'; + return $base.' '.match ($st) { + IncidentStatusEnum::Investigating => 'bg-purple-100 dark:bg-purple-900 text-purple-800 dark:text-purple-200', + IncidentStatusEnum::Identified => 'bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200', + IncidentStatusEnum::Monitoring => 'bg-cyan-100 dark:bg-cyan-900 text-cyan-800 dark:text-cyan-200', + IncidentStatusEnum::Resolved => 'bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200', + }; + }; +@endphp + +
+
+
+

Service status

+

+ Overall: {{ $overallStatus->label() }} + @if($activeIncidents->isNotEmpty()) + — {{ $activeIncidents->count() }} active {{ Str::plural('incident', $activeIncidents->count()) }} + @else + — No active incidents + @endif +

+
+
+ +
+
+

Services

+

Uptime and current health for core endpoints.

+
+
+ @forelse($services as $service) +
+
+
{{ $service->name }}
+
+ Uptime (reported): {{ number_format((float) $service->uptime_percentage, 2) }}% + @if($service->response_time_ms !== null) + · Response ~{{ $service->response_time_ms }} ms + @endif + @if($service->last_checked_at) + · Checked {{ $service->last_checked_at->diffForHumans() }} + @endif +
+
+ + {{ $service->status->label() }} + +
+ @empty +

No services are configured for display.

+ @endforelse +
+
+ +
+
+

Active incidents

+
+
+ @forelse($activeIncidents as $incident) +
+
+

{{ $incident->title }}

+ + {{ $incident->impact->label() }} impact + +
+

{{ $incident->description }}

+
+ + + {{ $incident->services->pluck('name')->join(', ') ?: '—' }} + + + {{ $incident->status->label() }} + + Started {{ $incident->started_at->timezone(config('app.timezone'))->format('M j, Y g:i A T') }} +
+
+ @empty +

There are no active incidents.

+ @endforelse +
+
+ +
+
+

Recent resolved incidents

+

Last 30 days

+
+
+ @forelse($recentResolvedGrouped as $date => $group) +
+

{{ $date === 'unknown' ? 'Unknown date' : \Carbon\Carbon::parse($date)->format('F j, Y') }}

+
    + @foreach($group as $incident) +
  • +
    {{ $incident->title }}
    +
    + {{ $incident->impact->label() }} + {{ $incident->services->pluck('name')->join(', ') }} · Resolved {{ $incident->resolved_at?->timezone(config('app.timezone'))->format('M j, g:i A') }} +
    +
  • + @endforeach +
+
+ @empty +

No resolved incidents in the last 30 days.

+ @endforelse +
+
+ +
+
+@endsection diff --git a/routes/console.php b/routes/console.php index 168ee8fe6..f972938d0 100644 --- a/routes/console.php +++ b/routes/console.php @@ -59,3 +59,4 @@ Schedule::call(function () { })->name('cleanup-api-request-logs')->hourly()->withoutOverlapping(); // Check tmux health and auto-restart if monitor pane is dead Schedule::command('tmux:health-check --auto-restart')->everyThirtyMinutes()->withoutOverlapping(); +Schedule::command('nntmux:check-service-health')->everyFiveMinutes()->withoutOverlapping(); diff --git a/routes/web.php b/routes/web.php index 92369c95d..e68fe08da 100644 --- a/routes/web.php +++ b/routes/web.php @@ -39,6 +39,7 @@ use App\Http\Controllers\Admin\AdminReleasesController; use App\Http\Controllers\Admin\AdminRoleController; use App\Http\Controllers\Admin\AdminShowsController; use App\Http\Controllers\Admin\AdminSiteController; +use App\Http\Controllers\Admin\AdminStatusController; use App\Http\Controllers\Admin\AdminTmuxController; use App\Http\Controllers\Admin\AdminUserController; use App\Http\Controllers\Admin\AdminUserRoleHistoryController; @@ -80,6 +81,7 @@ use App\Http\Controllers\RssController; use App\Http\Controllers\SearchController; use App\Http\Controllers\SearchSuggestController; use App\Http\Controllers\SeriesController; +use App\Http\Controllers\StatusPageController; use App\Http\Controllers\TermsController; // Serve cover images from storage - Must be public (no auth required) @@ -119,6 +121,8 @@ Route::get('api/search/assist', [SearchSuggestController::class, 'searchAssist'] Route::get('contact-us', [ContactUsController::class, 'showContactForm'])->name('contact-us'); Route::post('contact-us', [ContactUsController::class, 'contact']); +Route::get('status', [StatusPageController::class, 'showStatusPage'])->name('status'); + Route::middleware('isVerified')->group(function () { Route::match(['GET', 'POST'], 'resetpassword', [ResetPasswordController::class, 'reset'])->name('resetpassword'); Route::match(['GET', 'POST'], 'profile', [ProfileController::class, 'show'])->name('profile'); @@ -232,6 +236,14 @@ Route::middleware(['role:Admin', '2fa'])->prefix('admin')->group(function () { Route::post('registrations/periods/{period}/toggle', [AdminRegistrationController::class, 'togglePeriod'])->name('admin.registrations.periods.toggle'); Route::delete('registrations/periods/{period}', [AdminRegistrationController::class, 'destroyPeriod'])->name('admin.registrations.periods.destroy'); Route::match(['GET', 'POST'], 'site-edit', [AdminSiteController::class, 'edit'])->name('admin.site-edit'); + Route::get('status/create', [AdminStatusController::class, 'create'])->name('admin.status.create'); + Route::post('status', [AdminStatusController::class, 'store'])->name('admin.status.store'); + Route::get('status', [AdminStatusController::class, 'index'])->name('admin.status.index'); + Route::get('status/{incident}/edit', [AdminStatusController::class, 'edit'])->name('admin.status.edit')->whereNumber('incident'); + Route::put('status/{incident}', [AdminStatusController::class, 'update'])->name('admin.status.update')->whereNumber('incident'); + Route::post('status/{incident}/resolve', [AdminStatusController::class, 'resolve'])->name('admin.status.resolve')->whereNumber('incident'); + Route::delete('status/{incident}', [AdminStatusController::class, 'destroy'])->name('admin.status.destroy')->whereNumber('incident'); + Route::post('status/service/{service}/update', [AdminStatusController::class, 'updateService'])->name('admin.status.update-service')->whereNumber('service'); Route::get('site-stats', [AdminSiteController::class, 'stats'])->name('admin.site-stats'); Route::get('logs', [AdminLogViewerController::class, 'index'])->name('admin.logs.index'); Route::get('role-list', [AdminRoleController::class, 'index'])->name('admin.role-list');