*/ public array $hostIds = []; public function saveDraft(): void { $this->authorize('maintenance.manage'); $this->persist('draft'); } public function publish(): void { $this->authorize('maintenance.manage'); $window = $this->persist('scheduled'); if ($window !== null) { app(MaintenanceNotifier::class)->announce($window); } } private function persist(string $state): ?MaintenanceWindow { $data = $this->validate([ 'title' => 'required|string|max:255', 'publicDescription' => 'nullable|string|max:2000', 'internalNotes' => 'nullable|string|max:2000', 'startsAt' => 'required|date', 'endsAt' => 'required|date', 'hostIds' => 'array', 'hostIds.*' => 'integer|exists:hosts,id', ]); $starts = Carbon::parse($data['startsAt']); $ends = Carbon::parse($data['endsAt']); if ($ends->lessThanOrEqualTo($starts)) { $this->addError('endsAt', __('maintenance.end_after_start')); return null; } // A window always needs a host — otherwise a hostless draft can never be // published (there is no edit-hosts action) and is stuck. if (empty($this->hostIds)) { $this->addError('hostIds', __('maintenance.need_host')); return null; } if ($state === 'scheduled' && $ends->isPast()) { $this->addError('endsAt', __('maintenance.end_future')); return null; } // Create the window and attach hosts atomically so a bad id can't leave // an orphaned scheduled window behind. $window = DB::transaction(function () use ($data, $starts, $ends, $state) { $window = MaintenanceWindow::create([ 'title' => $data['title'], 'public_description' => $data['publicDescription'] ?: null, 'internal_notes' => $data['internalNotes'] ?: null, 'starts_at' => $starts, 'ends_at' => $ends, 'state' => $state, 'created_by' => auth()->id(), 'published_at' => $state === 'scheduled' ? now() : null, ]); $window->hosts()->sync(array_map('intval', $data['hostIds'] ?? [])); return $window; }); $this->reset('title', 'publicDescription', 'internalNotes', 'startsAt', 'endsAt', 'hostIds'); $this->dispatch('notify', message: __($state === 'scheduled' ? 'maintenance.published' : 'maintenance.draft_saved')); return $window; } public function publishExisting(string $uuid): void { $this->authorize('maintenance.manage'); $window = MaintenanceWindow::query()->where('uuid', $uuid)->first(); if ($window === null || $window->state !== 'draft') { return; } if ($window->hosts()->count() === 0 || $window->ends_at->isPast()) { $this->dispatch('notify', message: __('maintenance.need_host')); return; } $window->update(['state' => 'scheduled', 'published_at' => now()]); app(MaintenanceNotifier::class)->announce($window); $this->dispatch('notify', message: __('maintenance.published')); } /** * Re-run announcements for a published window. Idempotent (ledger-guarded), * so it only fills gaps — the retry path when a transient queue outage left * some affected customers un-notified. */ public function resend(string $uuid): void { $this->authorize('maintenance.manage'); $window = MaintenanceWindow::query()->where('uuid', $uuid)->first(); // Only announce for a window that has not yet ended (derived state). if ($window === null || ! in_array($window->derivedState(), ['upcoming', 'active'], true)) { return; } app(MaintenanceNotifier::class)->announce($window); $this->dispatch('notify', message: __('maintenance.notified')); } public function cancel(string $uuid): void { $this->authorize('maintenance.manage'); $window = MaintenanceWindow::query()->where('uuid', $uuid)->first(); // Cannot cancel what is already cancelled or has already completed. if ($window === null || in_array($window->derivedState(), ['cancelled', 'completed'], true)) { return; } $wasPublished = $window->state === 'scheduled'; $window->update(['state' => 'cancelled', 'cancelled_at' => now()]); // Customers who received an announcement are told it's cancelled. The // MessageSending listener suppresses still-pending announcements, and the // MessageSent listener catches the race (an announcement that delivers // just as we cancel) by queuing a catch-up cancellation for it. if ($wasPublished) { app(MaintenanceNotifier::class)->notifyCancellation($window); } $this->dispatch('notify', message: __('maintenance.cancelled')); } public function render() { $windows = MaintenanceWindow::query() ->withCount('hosts') ->orderByDesc('starts_at') ->get() ->map(fn (MaintenanceWindow $w) => [ 'uuid' => $w->uuid, 'title' => $w->title, 'starts_at' => $w->starts_at, 'ends_at' => $w->ends_at, 'state' => $w->derivedState(), 'hosts' => $w->hosts_count, 'affected' => $w->affectedCustomers()->count(), 'is_draft' => $w->state === 'draft', 'notifiable' => $w->state === 'scheduled' && in_array($w->derivedState(), ['upcoming', 'active'], true), 'cancellable' => in_array($w->derivedState(), ['draft', 'upcoming', 'active'], true), ]); return view('livewire.admin.maintenance', [ 'windows' => $windows, 'datacenters' => Datacenter::query()->orderBy('name')->get(), 'hosts' => Host::query()->orderBy('datacenter')->orderBy('name')->get(), ]); } }