mirror of
https://github.com/NNTmux/newznab-tmux.git
synced 2026-08-29 01:08:56 +00:00
Add site status page
This commit is contained in:
@@ -152,6 +152,86 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Site Status & Active Incidents -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm p-6 border border-gray-200 dark:border-gray-700">
|
||||
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-4 flex items-center justify-between">
|
||||
<span>
|
||||
<i class="fas fa-signal mr-2 text-blue-600 dark:text-blue-400"></i>
|
||||
Site Status
|
||||
</span>
|
||||
<a href="{{ route('admin.status.index') }}" class="text-xs text-blue-600 dark:text-blue-400 hover:underline font-normal">Manage →</a>
|
||||
</h3>
|
||||
|
||||
@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
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3 mb-4">
|
||||
@foreach($serviceStatuses as $svc)
|
||||
<div class="flex items-center justify-between p-3 rounded-lg bg-gray-50 dark:bg-gray-900 border border-gray-100 dark:border-gray-700">
|
||||
<div>
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ $svc->name }}</span>
|
||||
<span class="block text-xs text-gray-500 dark:text-gray-400">{{ number_format((float) $svc->uptime_percentage, 2) }}% uptime</span>
|
||||
</div>
|
||||
<span class="{{ $dashBadge($svc->status->value) }}">{{ $svc->status->label() }}</span>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
@if($activeIncidents->isNotEmpty())
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-3">
|
||||
<h4 class="text-sm font-semibold text-red-600 dark:text-red-400 mb-2">
|
||||
<i class="fas fa-exclamation-triangle mr-1"></i>
|
||||
{{ $activeIncidents->count() }} Active {{ Str::plural('Incident', $activeIncidents->count()) }}
|
||||
</h4>
|
||||
<div class="space-y-2">
|
||||
@foreach($activeIncidents->take(5) as $incident)
|
||||
<div class="flex items-start justify-between gap-2 text-sm py-1">
|
||||
<div class="min-w-0">
|
||||
<span class="text-gray-800 dark:text-gray-200 truncate block">{{ Str::limit($incident->title, 50) }}</span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ $incident->services->pluck('name')->join(', ') }}
|
||||
· {{ $incident->started_at->diffForHumans() }}
|
||||
@if($incident->is_auto)
|
||||
<span class="ml-1 px-1 py-0.5 text-[10px] leading-3 font-semibold rounded bg-indigo-100 dark:bg-indigo-900 text-indigo-800 dark:text-indigo-200">Auto</span>
|
||||
@endif
|
||||
</span>
|
||||
</div>
|
||||
<span class="px-2 py-0.5 inline-flex text-xs leading-5 font-semibold rounded-full shrink-0 {{ match($incident->impact->value) {
|
||||
'critical' => 'bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200',
|
||||
'major' => 'bg-orange-100 dark:bg-orange-900 text-orange-800 dark:text-orange-200',
|
||||
'minor' => 'bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200',
|
||||
default => 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200',
|
||||
} }}">{{ $incident->impact->label() }}</span>
|
||||
</div>
|
||||
@endforeach
|
||||
@if($activeIncidents->count() > 5)
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 pt-1">
|
||||
+ {{ $activeIncidents->count() - 5 }} more —
|
||||
<a href="{{ route('admin.status.index') }}" class="text-blue-600 dark:text-blue-400 hover:underline">view all</a>
|
||||
</p>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-3">
|
||||
<p class="text-sm text-green-600 dark:text-green-400">
|
||||
<i class="fas fa-check-circle mr-1"></i>No active incidents
|
||||
</p>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<!-- Registration Status Widget -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm p-6 border border-gray-200 dark:border-gray-700">
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
|
||||
@@ -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
|
||||
|
||||
<div class="space-y-6">
|
||||
<x-admin.card>
|
||||
<x-admin.page-header :title="$title" icon="fas fa-plus" />
|
||||
|
||||
<div class="px-6 py-6">
|
||||
<form action="{{ route('admin.status.store') }}" method="post" class="space-y-6 max-w-2xl">
|
||||
@csrf
|
||||
|
||||
<div>
|
||||
<label for="title" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Title</label>
|
||||
<input type="text" name="title" id="title" value="{{ old('title') }}" required
|
||||
class="w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-primary-500 focus:ring-primary-500">
|
||||
@error('title')
|
||||
<p class="mt-1 text-sm text-red-600 dark:text-red-400">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="description" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Description</label>
|
||||
<textarea name="description" id="description" rows="5" required
|
||||
class="w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-primary-500 focus:ring-primary-500">{{ old('description') }}</textarea>
|
||||
@error('description')
|
||||
<p class="mt-1 text-sm text-red-600 dark:text-red-400">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<fieldset>
|
||||
<legend class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Affected services</legend>
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400 mb-2">Select one or more areas impacted by this incident.</p>
|
||||
<div class="space-y-2 rounded-lg border border-gray-300 dark:border-gray-600 p-3 dark:bg-gray-900/40">
|
||||
@foreach($services as $svc)
|
||||
<label class="flex items-center gap-3 text-sm text-gray-800 dark:text-gray-200 cursor-pointer">
|
||||
<input type="checkbox" name="service_status_ids[]" value="{{ $svc->id }}"
|
||||
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-800"
|
||||
@checked(in_array($svc->id, $oldServiceIds, true))>
|
||||
<span>{{ $svc->name }}</span>
|
||||
</label>
|
||||
@endforeach
|
||||
</div>
|
||||
@error('service_status_ids')
|
||||
<p class="mt-1 text-sm text-red-600 dark:text-red-400">{{ $message }}</p>
|
||||
@enderror
|
||||
</fieldset>
|
||||
|
||||
<div>
|
||||
<label for="impact" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Impact (severity)</label>
|
||||
<select name="impact" id="impact" required class="w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm">
|
||||
@foreach(IncidentImpactEnum::cases() as $opt)
|
||||
<option value="{{ $opt->value }}" @selected(old('impact', IncidentImpactEnum::Minor->value) === $opt->value)>{{ $opt->label() }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@error('impact')
|
||||
<p class="mt-1 text-sm text-red-600 dark:text-red-400">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="status" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Incident status</label>
|
||||
<select name="status" id="status" required class="w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm">
|
||||
@foreach(IncidentStatusEnum::cases() as $opt)
|
||||
<option value="{{ $opt->value }}" @selected(old('status', IncidentStatusEnum::Investigating->value) === $opt->value)>{{ $opt->label() }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@error('status')
|
||||
<p class="mt-1 text-sm text-red-600 dark:text-red-400">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="started_at" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Started at</label>
|
||||
<input type="datetime-local" name="started_at" id="started_at" required
|
||||
value="{{ old('started_at', now()->format('Y-m-d\TH:i')) }}"
|
||||
class="w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm">
|
||||
@error('started_at')
|
||||
<p class="mt-1 text-sm text-red-600 dark:text-red-400">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="resolved_at" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Resolved at (optional)</label>
|
||||
<input type="datetime-local" name="resolved_at" id="resolved_at"
|
||||
value="{{ old('resolved_at') }}"
|
||||
class="w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm">
|
||||
@error('resolved_at')
|
||||
<p class="mt-1 text-sm text-red-600 dark:text-red-400">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button type="submit" class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">Create</button>
|
||||
<a href="{{ route('admin.status.index') }}" class="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-100 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</x-admin.card>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -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
|
||||
|
||||
<div class="space-y-6">
|
||||
<x-admin.card>
|
||||
<x-admin.page-header :title="$title" icon="fas fa-edit" />
|
||||
|
||||
<div class="px-6 py-6">
|
||||
<form action="{{ route('admin.status.update', $incident) }}" method="post" class="space-y-6 max-w-2xl">
|
||||
@csrf
|
||||
@method('PUT')
|
||||
|
||||
<div>
|
||||
<label for="title" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Title</label>
|
||||
<input type="text" name="title" id="title" value="{{ old('title', $incident->title) }}" required
|
||||
class="w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-primary-500 focus:ring-primary-500">
|
||||
@error('title')
|
||||
<p class="mt-1 text-sm text-red-600 dark:text-red-400">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="description" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Description</label>
|
||||
<textarea name="description" id="description" rows="5" required
|
||||
class="w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-primary-500 focus:ring-primary-500">{{ old('description', $incident->description) }}</textarea>
|
||||
@error('description')
|
||||
<p class="mt-1 text-sm text-red-600 dark:text-red-400">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<fieldset>
|
||||
<legend class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Affected services</legend>
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400 mb-2">Select one or more areas impacted by this incident.</p>
|
||||
<div class="space-y-2 rounded-lg border border-gray-300 dark:border-gray-600 p-3 dark:bg-gray-900/40">
|
||||
@foreach($services as $svc)
|
||||
<label class="flex items-center gap-3 text-sm text-gray-800 dark:text-gray-200 cursor-pointer">
|
||||
<input type="checkbox" name="service_status_ids[]" value="{{ $svc->id }}"
|
||||
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-800"
|
||||
@checked(in_array($svc->id, $selectedIds, true))>
|
||||
<span>{{ $svc->name }}</span>
|
||||
</label>
|
||||
@endforeach
|
||||
</div>
|
||||
@error('service_status_ids')
|
||||
<p class="mt-1 text-sm text-red-600 dark:text-red-400">{{ $message }}</p>
|
||||
@enderror
|
||||
</fieldset>
|
||||
|
||||
<div>
|
||||
<label for="impact" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Impact (severity)</label>
|
||||
<select name="impact" id="impact" required class="w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm">
|
||||
@foreach(IncidentImpactEnum::cases() as $opt)
|
||||
<option value="{{ $opt->value }}" @selected(old('impact', $incident->impact->value) === $opt->value)>{{ $opt->label() }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@error('impact')
|
||||
<p class="mt-1 text-sm text-red-600 dark:text-red-400">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="status" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Incident status</label>
|
||||
<select name="status" id="status" required class="w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm">
|
||||
@foreach(IncidentStatusEnum::cases() as $opt)
|
||||
<option value="{{ $opt->value }}" @selected(old('status', $incident->status->value) === $opt->value)>{{ $opt->label() }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@error('status')
|
||||
<p class="mt-1 text-sm text-red-600 dark:text-red-400">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="started_at" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Started at</label>
|
||||
<input type="datetime-local" name="started_at" id="started_at" required
|
||||
value="{{ old('started_at', $incident->started_at->format('Y-m-d\TH:i')) }}"
|
||||
class="w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm">
|
||||
@error('started_at')
|
||||
<p class="mt-1 text-sm text-red-600 dark:text-red-400">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="resolved_at" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Resolved at</label>
|
||||
<input type="datetime-local" name="resolved_at" id="resolved_at"
|
||||
value="{{ old('resolved_at', $incident->resolved_at?->format('Y-m-d\TH:i')) }}"
|
||||
class="w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm">
|
||||
@error('resolved_at')
|
||||
<p class="mt-1 text-sm text-red-600 dark:text-red-400">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<button type="submit" class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">Save</button>
|
||||
<a href="{{ route('admin.status.index') }}" class="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-100 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</x-admin.card>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -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
|
||||
|
||||
<div class="space-y-6">
|
||||
<x-admin.card>
|
||||
<x-admin.page-header :title="$title" icon="fas fa-signal">
|
||||
<x-slot:actions>
|
||||
<a href="{{ route('admin.status.create') }}" class="px-4 py-2 bg-blue-600 dark:bg-blue-700 text-white rounded-lg hover:bg-blue-700 dark:hover:bg-blue-800">
|
||||
<i class="fas fa-plus mr-2"></i>Create incident
|
||||
</a>
|
||||
<a href="{{ route('status') }}" target="_blank" rel="noopener" class="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-100 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600">
|
||||
<i class="fas fa-external-link-alt mr-2"></i>Public page
|
||||
</a>
|
||||
</x-slot:actions>
|
||||
</x-admin.page-header>
|
||||
|
||||
<div class="px-6 py-6 border-t border-gray-200 dark:border-gray-700">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">Service health</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
@foreach($services as $svc)
|
||||
<div class="p-4 rounded-lg border border-gray-200 bg-gray-50 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100">
|
||||
<div class="flex items-start justify-between gap-2 mb-3">
|
||||
<div>
|
||||
<div class="font-semibold text-gray-900 dark:text-gray-100">{{ $svc->name }}</div>
|
||||
<div class="text-xs text-gray-600 dark:text-gray-400 mt-1">Slug: {{ $svc->slug }}</div>
|
||||
</div>
|
||||
<span class="{{ $serviceBadge($svc->status) }} shrink-0">{{ $svc->status->label() }}</span>
|
||||
</div>
|
||||
<form action="{{ route('admin.status.update-service', $svc) }}" method="post" class="space-y-2">
|
||||
@csrf
|
||||
<label for="status-{{ $svc->id }}" class="sr-only">Update status</label>
|
||||
<select id="status-{{ $svc->id }}" name="status" class="w-full rounded-md border border-gray-300 bg-white text-gray-900 text-sm dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500">
|
||||
@foreach(ServiceStatusEnum::cases() as $opt)
|
||||
<option value="{{ $opt->value }}" @selected($svc->status === $opt)>{{ $opt->label() }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<button type="submit" class="w-full py-2 px-3 text-sm font-medium rounded-lg bg-primary-600 text-white hover:bg-primary-700 dark:hover:bg-primary-600">
|
||||
Update
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
</x-admin.card>
|
||||
|
||||
<x-admin.card>
|
||||
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Incidents</h2>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<x-admin.data-table>
|
||||
<x-slot:head>
|
||||
<x-admin.th>ID</x-admin.th>
|
||||
<x-admin.th>Title</x-admin.th>
|
||||
<x-admin.th>Services</x-admin.th>
|
||||
<x-admin.th>Impact</x-admin.th>
|
||||
<x-admin.th>Status</x-admin.th>
|
||||
<x-admin.th>Started</x-admin.th>
|
||||
<x-admin.th class="text-right">Actions</x-admin.th>
|
||||
</x-slot:head>
|
||||
@forelse($incidents as $incident)
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/50">
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">{{ $incident->id }}</td>
|
||||
<td class="px-6 py-4 text-sm text-gray-900 dark:text-gray-100">
|
||||
{{ Str::limit($incident->title, 48) }}
|
||||
@if($incident->is_auto)
|
||||
<span class="ml-1 px-1.5 py-0.5 inline-flex text-[10px] leading-4 font-semibold rounded bg-indigo-100 dark:bg-indigo-900 text-indigo-800 dark:text-indigo-200">Auto</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm text-gray-700 dark:text-gray-300">{{ $incident->services->pluck('name')->join(', ') ?: '—' }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm">
|
||||
<span class="{{ $impactBadge($incident->impact) }}">{{ $incident->impact->label() }}</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm">
|
||||
<span class="{{ $incidentStatusBadge($incident->status) }}">{{ $incident->status->label() }}</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-600 dark:text-gray-400">{{ $incident->started_at->format('Y-m-d H:i') }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm space-x-2">
|
||||
<a href="{{ route('admin.status.edit', $incident) }}" class="text-blue-600 dark:text-blue-400 hover:underline">Edit</a>
|
||||
@if($incident->status !== IncidentStatusEnum::Resolved)
|
||||
<form action="{{ route('admin.status.resolve', $incident) }}" method="post" class="inline">
|
||||
@csrf
|
||||
<button type="submit" class="text-emerald-600 dark:text-emerald-400 hover:underline">Resolve</button>
|
||||
</form>
|
||||
@endif
|
||||
<form action="{{ route('admin.status.destroy', $incident) }}" method="post" class="inline" onsubmit="return confirm('Delete this incident?');">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button type="submit" class="text-red-600 dark:text-red-400 hover:underline">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="7" class="px-6 py-8 text-center text-gray-500 dark:text-gray-400">No incidents yet.</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</x-admin.data-table>
|
||||
</div>
|
||||
@if($incidents->hasPages())
|
||||
<div class="px-6 py-4 border-t border-gray-200 dark:border-gray-700">
|
||||
{{ $incidents->links() }}
|
||||
</div>
|
||||
@endif
|
||||
</x-admin.card>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -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;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -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)
|
||||
<div class="alert-box alert-success">
|
||||
<strong>✅ Resolved</strong> — The following incident has been automatically resolved.
|
||||
</div>
|
||||
@else
|
||||
<div class="alert-box alert-{{ $incident->impact->value === 'critical' ? 'danger' : ($incident->impact->value === 'major' ? 'warning' : 'info') }}">
|
||||
<strong>⚠️ {{ ucfirst($incident->impact->value) }} Impact</strong> — An automated health check has detected a service issue.
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<table class="status-table">
|
||||
<tr>
|
||||
<td class="status-label">Incident</td>
|
||||
<td>{{ $incident->title }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="status-label">Affected Services</td>
|
||||
<td>{{ $services }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="status-label">Impact</td>
|
||||
<td>{{ ucfirst($incident->impact->value) }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="status-label">Status</td>
|
||||
<td>{{ ucfirst($incident->status->value) }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="status-label">Started</td>
|
||||
<td>{{ $incident->started_at->format('M j, Y g:i A T') }}</td>
|
||||
</tr>
|
||||
@if($resolved && $incident->resolved_at)
|
||||
<tr>
|
||||
<td class="status-label">Resolved</td>
|
||||
<td>{{ $incident->resolved_at->format('M j, Y g:i A T') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="status-label">Duration</td>
|
||||
<td>{{ $incident->started_at->diffForHumans($incident->resolved_at, true) }}</td>
|
||||
</tr>
|
||||
@endif
|
||||
</table>
|
||||
|
||||
@if($incident->description)
|
||||
<div class="info-box">
|
||||
<strong>Details:</strong><br>
|
||||
{!! nl2br(e($incident->description)) !!}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<p style="text-align: center; margin-top: 24px;">
|
||||
<a href="{{ $statusUrl }}" class="button">View Status Dashboard</a>
|
||||
</p>
|
||||
|
||||
<div class="signature">
|
||||
<p>— {{ $site }} Monitoring</p>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -24,6 +24,7 @@
|
||||
|
||||
<!-- Styles -->
|
||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||
@stack('meta')
|
||||
@stack('styles')
|
||||
</head>
|
||||
<body class="app-shell bg-gray-100 dark:bg-gray-900 font-sans antialiased">
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
|
||||
<!-- Styles -->
|
||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||
@stack('meta')
|
||||
@stack('styles')
|
||||
</head>
|
||||
<body class="app-shell bg-gray-50 dark:bg-gray-900 font-sans antialiased">
|
||||
|
||||
@@ -311,6 +311,9 @@
|
||||
<a href="{{ url('/admin/site-stats') }}" class="block py-2 px-3 text-gray-400 dark:text-gray-500 hover:text-white dark:hover:text-white hover:bg-white/10 dark:hover:bg-white/5 rounded transition">
|
||||
<i class="fas fa-chart-bar mr-2 text-purple-400"></i>Statistics
|
||||
</a>
|
||||
<a href="{{ route('admin.status.index') }}" class="block py-2 px-3 text-gray-400 dark:text-gray-500 hover:text-white dark:hover:text-white hover:bg-white/10 dark:hover:bg-white/5 rounded transition">
|
||||
<i class="fas fa-signal mr-2 text-emerald-400"></i>Site Status
|
||||
</a>
|
||||
<a href="{{ route('admin.logs.index') }}" class="block py-2 px-3 text-gray-400 dark:text-gray-500 hover:text-white dark:hover:text-white hover:bg-white/10 dark:hover:bg-white/5 rounded transition">
|
||||
<i class="fas fa-file-lines mr-2 text-amber-400"></i>Logs
|
||||
</a>
|
||||
|
||||
@@ -104,6 +104,10 @@
|
||||
<i class="fa fa-hands-helping fa-fw mr-2"></i>
|
||||
<span>API V2</span>
|
||||
</a>
|
||||
<a href="{{ route('status') }}" class="flex items-center px-4 py-2 text-gray-300 hover:text-white hover:bg-white/10 rounded-lg transition">
|
||||
<i class="fa fa-signal fa-fw mr-2"></i>
|
||||
<span>Site status</span>
|
||||
</a>
|
||||
<a href="{{ route('contact-us') }}" class="flex items-center px-4 py-2 text-gray-300 hover:text-white hover:bg-white/10 rounded-lg transition">
|
||||
<i class="fa fa-envelope fa-fw mr-2"></i>
|
||||
<span>Contact Us</span>
|
||||
|
||||
@@ -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
|
||||
|
||||
<div class="w-full max-w-5xl mx-auto">
|
||||
<div class="surface-panel rounded-xl shadow-sm mb-6 overflow-hidden border {{ $overallBanner }}">
|
||||
<div class="px-6 py-5">
|
||||
<h1 class="text-2xl font-bold tracking-tight">Service status</h1>
|
||||
<p class="mt-2 text-sm leading-relaxed">
|
||||
Overall: <strong class="font-semibold">{{ $overallStatus->label() }}</strong>
|
||||
@if($activeIncidents->isNotEmpty())
|
||||
— {{ $activeIncidents->count() }} active {{ Str::plural('incident', $activeIncidents->count()) }}
|
||||
@else
|
||||
— No active incidents
|
||||
@endif
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="surface-panel rounded-xl shadow-sm mb-6">
|
||||
<div class="surface-panel-alt px-6 py-4 border-b rounded-t-lg">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Services</h2>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300 mt-1">Uptime and current health for core endpoints.</p>
|
||||
</div>
|
||||
<div class="px-6 py-6 space-y-4">
|
||||
@forelse($services as $service)
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 p-4 rounded-lg border border-gray-200 dark:border-gray-600 bg-white/50 dark:bg-gray-800/90">
|
||||
<div>
|
||||
<div class="font-semibold text-gray-900 dark:text-gray-100">{{ $service->name }}</div>
|
||||
<div class="text-sm text-gray-700 dark:text-gray-300 mt-1">
|
||||
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
|
||||
</div>
|
||||
</div>
|
||||
<span class="{{ $serviceBadge($service->status) }}">
|
||||
{{ $service->status->label() }}
|
||||
</span>
|
||||
</div>
|
||||
@empty
|
||||
<p class="text-gray-700 dark:text-gray-300">No services are configured for display.</p>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="surface-panel rounded-xl shadow-sm mb-6">
|
||||
<div class="surface-panel-alt px-6 py-4 border-b rounded-t-lg">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Active incidents</h2>
|
||||
</div>
|
||||
<div class="px-6 py-6 space-y-4">
|
||||
@forelse($activeIncidents as $incident)
|
||||
<article class="p-4 rounded-lg border border-gray-200 dark:border-gray-700 bg-white/30 dark:bg-gray-900/20">
|
||||
<div class="flex flex-wrap items-start justify-between gap-2">
|
||||
<h3 class="font-semibold text-gray-900 dark:text-gray-100">{{ $incident->title }}</h3>
|
||||
<span class="{{ $impactBadge($incident->impact) }}">
|
||||
{{ $incident->impact->label() }} impact
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300 mt-2 whitespace-pre-wrap">{{ $incident->description }}</p>
|
||||
<div class="mt-3 text-xs text-gray-600 dark:text-gray-400 flex flex-wrap items-center gap-x-3 gap-y-2">
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<i class="fas fa-layer-group"></i>
|
||||
<span>{{ $incident->services->pluck('name')->join(', ') ?: '—' }}</span>
|
||||
</span>
|
||||
<span class="{{ $incidentStatusBadge($incident->status) }}">
|
||||
<i class="fas fa-flag text-[10px] opacity-90"></i>{{ $incident->status->label() }}
|
||||
</span>
|
||||
<span><i class="fas fa-clock mr-1"></i>Started {{ $incident->started_at->timezone(config('app.timezone'))->format('M j, Y g:i A T') }}</span>
|
||||
</div>
|
||||
</article>
|
||||
@empty
|
||||
<p class="text-gray-700 dark:text-gray-300">There are no active incidents.</p>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="surface-panel rounded-xl shadow-sm mb-6">
|
||||
<div class="surface-panel-alt px-6 py-4 border-b rounded-t-lg">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Recent resolved incidents</h2>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300 mt-1">Last 30 days</p>
|
||||
</div>
|
||||
<div class="px-6 py-6 space-y-6">
|
||||
@forelse($recentResolvedGrouped as $date => $group)
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">{{ $date === 'unknown' ? 'Unknown date' : \Carbon\Carbon::parse($date)->format('F j, Y') }}</h3>
|
||||
<ul class="space-y-3">
|
||||
@foreach($group as $incident)
|
||||
<li class="p-3 rounded-lg bg-gray-50 dark:bg-gray-800/80 border border-gray-100 dark:border-gray-700">
|
||||
<div class="font-medium text-gray-900 dark:text-gray-100">{{ $incident->title }}</div>
|
||||
<div class="text-xs text-gray-600 dark:text-gray-400 mt-1 flex flex-wrap gap-x-2 gap-y-1 items-center">
|
||||
<span class="{{ $impactBadge($incident->impact) }}">{{ $incident->impact->label() }}</span>
|
||||
<span>{{ $incident->services->pluck('name')->join(', ') }} · Resolved {{ $incident->resolved_at?->timezone(config('app.timezone'))->format('M j, g:i A') }}</span>
|
||||
</div>
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@empty
|
||||
<p class="text-gray-700 dark:text-gray-300">No resolved incidents in the last 30 days.</p>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="text-sm text-gray-700 dark:text-gray-300" aria-label="breadcrumb">
|
||||
<ol class="flex flex-wrap gap-2">
|
||||
<li><a href="{{ url($site['home_link'] ?? '/') }}" class="text-primary-600 dark:text-primary-400 hover:underline">Home</a></li>
|
||||
<li aria-hidden="true">/</li>
|
||||
<li class="text-gray-800 dark:text-gray-200">Status</li>
|
||||
</ol>
|
||||
</nav>
|
||||
</div>
|
||||
@endsection
|
||||
Reference in New Issue
Block a user