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:
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\SiteStatusService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class CheckServiceHealth extends Command
|
||||
{
|
||||
protected $signature = 'nntmux:check-service-health
|
||||
{--days=30 : Rolling window in days for uptime calculation}';
|
||||
|
||||
protected $description = 'Ping service endpoints, auto-create/resolve incidents, and recalculate uptime';
|
||||
|
||||
public function handle(SiteStatusService $statusService): int
|
||||
{
|
||||
$days = (int) $this->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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\SiteStatusService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class CheckServiceUptime extends Command
|
||||
{
|
||||
protected $signature = 'nntmux:check-service-uptime
|
||||
{--days=30 : Rolling window in days for uptime calculation}';
|
||||
|
||||
protected $description = 'Recalculate uptime percentages for all enabled services based on incident history';
|
||||
|
||||
public function handle(SiteStatusService $statusService): int
|
||||
{
|
||||
$days = (int) $this->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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum IncidentImpactEnum: string
|
||||
{
|
||||
case None = 'none';
|
||||
case Minor = 'minor';
|
||||
case Major = 'major';
|
||||
case Critical = 'critical';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::None => 'None',
|
||||
self::Minor => 'Minor',
|
||||
self::Major => 'Major',
|
||||
self::Critical => 'Critical',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function values(): array
|
||||
{
|
||||
return array_column(self::cases(), 'value');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum IncidentStatusEnum: string
|
||||
{
|
||||
case Investigating = 'investigating';
|
||||
case Identified = 'identified';
|
||||
case Monitoring = 'monitoring';
|
||||
case Resolved = 'resolved';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Investigating => 'Investigating',
|
||||
self::Identified => 'Identified',
|
||||
self::Monitoring => 'Monitoring',
|
||||
self::Resolved => 'Resolved',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function values(): array
|
||||
{
|
||||
return array_column(self::cases(), 'value');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
/**
|
||||
* Current health of a monitored service (stored in service_statuses.status).
|
||||
*/
|
||||
enum ServiceStatusEnum: string
|
||||
{
|
||||
case Operational = 'operational';
|
||||
case Degraded = 'degraded';
|
||||
case PartialOutage = 'partial_outage';
|
||||
case MajorOutage = 'major_outage';
|
||||
case Maintenance = 'maintenance';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Operational => '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<int, string>
|
||||
*/
|
||||
public static function values(): array
|
||||
{
|
||||
return array_column(self::cases(), 'value');
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
]));
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Enums\IncidentStatusEnum;
|
||||
use App\Enums\ServiceStatusEnum;
|
||||
use App\Http\Controllers\BasePageController;
|
||||
use App\Http\Requests\Admin\StoreIncidentRequest;
|
||||
use App\Http\Requests\Admin\UpdateIncidentRequest;
|
||||
use App\Http\Requests\Admin\UpdateServiceHealthRequest;
|
||||
use App\Models\ServiceIncident;
|
||||
use App\Models\ServiceStatus;
|
||||
use App\Services\SiteStatusService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class AdminStatusController extends BasePageController
|
||||
{
|
||||
public function __construct(
|
||||
protected SiteStatusService $siteStatusService
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function index(): View
|
||||
{
|
||||
$this->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.');
|
||||
}
|
||||
}
|
||||
@@ -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 () {
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Services\SiteStatusService;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class StatusPageController extends BasePageController
|
||||
{
|
||||
public function __construct(
|
||||
protected SiteStatusService $siteStatusService
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* Public site status page (no auth required; method name is in BasePageController auth except list).
|
||||
*/
|
||||
public function showStatusPage(): View
|
||||
{
|
||||
$services = $this->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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\Admin;
|
||||
|
||||
use App\Enums\IncidentImpactEnum;
|
||||
use App\Enums\IncidentStatusEnum;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class StoreIncidentRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\Admin;
|
||||
|
||||
use App\Enums\IncidentImpactEnum;
|
||||
use App\Enums\IncidentStatusEnum;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class UpdateIncidentRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\Admin;
|
||||
|
||||
use App\Enums\ServiceStatusEnum;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class UpdateServiceHealthRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'status' => ['required', 'string', Rule::in(ServiceStatusEnum::values())],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Models\ServiceIncident;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class IncidentDetected extends Mailable
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
private readonly ServiceIncident $incident,
|
||||
private readonly bool $resolved = false
|
||||
) {}
|
||||
|
||||
public function build(): static
|
||||
{
|
||||
$site = config('app.name');
|
||||
$status = $this->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'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\IncidentImpactEnum;
|
||||
use App\Enums\IncidentStatusEnum;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property string $title
|
||||
* @property string $description
|
||||
* @property IncidentStatusEnum $status
|
||||
* @property IncidentImpactEnum $impact
|
||||
* @property Carbon $started_at
|
||||
* @property Carbon|null $resolved_at
|
||||
* @property int|null $created_by
|
||||
* @property bool $is_auto
|
||||
*/
|
||||
class ServiceIncident extends Model
|
||||
{
|
||||
/**
|
||||
* @var list<string>
|
||||
*/
|
||||
protected $fillable = [
|
||||
'title',
|
||||
'description',
|
||||
'status',
|
||||
'impact',
|
||||
'started_at',
|
||||
'resolved_at',
|
||||
'created_by',
|
||||
'is_auto',
|
||||
];
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'status' => IncidentStatusEnum::class,
|
||||
'impact' => IncidentImpactEnum::class,
|
||||
'started_at' => 'datetime',
|
||||
'resolved_at' => 'datetime',
|
||||
'is_auto' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsToMany<ServiceStatus, $this>
|
||||
*/
|
||||
public function services(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(ServiceStatus::class, 'service_incident_service_status')
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<User, $this>
|
||||
*/
|
||||
public function creator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
public function isResolved(): bool
|
||||
{
|
||||
return $this->status === IncidentStatusEnum::Resolved;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\ServiceStatusEnum;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property string $name
|
||||
* @property string $slug
|
||||
* @property string|null $endpoint_url
|
||||
* @property ServiceStatusEnum $status
|
||||
* @property Carbon|null $last_checked_at
|
||||
* @property string $uptime_percentage
|
||||
* @property int|null $response_time_ms
|
||||
* @property bool $is_enabled
|
||||
* @property int $sort_order
|
||||
*/
|
||||
class ServiceStatus extends Model
|
||||
{
|
||||
protected $table = 'service_statuses';
|
||||
|
||||
/**
|
||||
* @var list<string>
|
||||
*/
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'slug',
|
||||
'endpoint_url',
|
||||
'status',
|
||||
'last_checked_at',
|
||||
'uptime_percentage',
|
||||
'response_time_ms',
|
||||
'is_enabled',
|
||||
'sort_order',
|
||||
];
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'status' => ServiceStatusEnum::class,
|
||||
'last_checked_at' => 'datetime',
|
||||
'is_enabled' => 'boolean',
|
||||
'uptime_percentage' => 'decimal:2',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsToMany<ServiceIncident, $this>
|
||||
*/
|
||||
public function incidents(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(ServiceIncident::class, 'service_incident_service_status')
|
||||
->withTimestamps();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,502 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Enums\IncidentImpactEnum;
|
||||
use App\Enums\IncidentStatusEnum;
|
||||
use App\Enums\ServiceStatusEnum;
|
||||
use App\Mail\IncidentDetected;
|
||||
use App\Models\ServiceIncident;
|
||||
use App\Models\ServiceStatus;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
class SiteStatusService
|
||||
{
|
||||
/**
|
||||
* @return \Illuminate\Database\Eloquent\Collection<int, ServiceStatus>
|
||||
*/
|
||||
public function getAllStatuses(): \Illuminate\Database\Eloquent\Collection
|
||||
{
|
||||
return $this->getEnabledServices();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return \Illuminate\Database\Eloquent\Collection<int, ServiceStatus>
|
||||
*/
|
||||
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<int, ServiceStatus>
|
||||
*/
|
||||
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<int, ServiceIncident>
|
||||
*/
|
||||
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<int, ServiceIncident>
|
||||
*/
|
||||
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<array{0: int, 1: int}> $intervals [[start, end], ...]
|
||||
* @return list<array{0: int, 1: int}>
|
||||
*/
|
||||
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<int, array{date: string, uptime: float}>
|
||||
*/
|
||||
public function getUptimeHistory(ServiceStatus $service, int $days = 90): Collection
|
||||
{
|
||||
return collect();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $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<string, mixed> $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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('service_statuses', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('service_incidents', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('service_incident_service_status', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('service_statuses', static function (Blueprint $table): void {
|
||||
$table->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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('service_incidents', static function (Blueprint $table): void {
|
||||
$table->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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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
|
||||
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user