*/ public array $hostIds = []; /** Toggle every host of one datacenter (select-all / deselect-all). */ public function selectDatacenter(string $code): void { $this->authorize('maintenance.manage'); $ids = Host::query()->where('datacenter', $code)->pluck('id')->map(fn ($id) => (string) $id)->all(); $current = array_map('strval', $this->hostIds); $this->hostIds = empty(array_diff($ids, $current)) ? array_values(array_diff($current, $ids)) // all selected → clear them : array_values(array_unique([...$current, ...$ids])); } /** * Set the end from the start. Typing a full timestamp by hand is the * fiddliest part of this form, and the end is almost always "start plus a * round number of minutes". */ public function setDuration(int $minutes): void { $this->authorize('maintenance.manage'); $start = $this->parsed($this->startsAt); if ($start === null) { // No start yet: assume the next half hour, which is what someone // scheduling a window in a hurry means anyway. $start = now()->addMinutes(30 - (now()->minute % 30))->startOfMinute(); $this->startsAt = LocalTime::toField($start); } $this->endsAt = LocalTime::toField($start->copy()->addMinutes($minutes)); } /** Minutes between start and end, or null while either is unusable. */ public function durationMinutes(): ?int { $start = $this->parsed($this->startsAt); $end = $this->parsed($this->endsAt); if ($start === null || $end === null || $end->lessThanOrEqualTo($start)) { return null; } return (int) $start->diffInMinutes($end); } /** What the operator typed, as UTC. Half-typed input is normal here. */ private function parsed(string $value): ?\Illuminate\Support\Carbon { return LocalTime::fromField($value); } 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 = LocalTime::fromField($data['startsAt']); $ends = LocalTime::fromField($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::guard('operator')->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(), 'durationMinutes' => $this->durationMinutes(), ]); } }