240 lines
8.9 KiB
PHP
240 lines
8.9 KiB
PHP
<?php
|
|
|
|
namespace App\Livewire\Admin;
|
|
|
|
use App\Mail\MaintenanceAnnouncementMail;
|
|
use App\Mail\MaintenanceCancelledMail;
|
|
use App\Models\Customer;
|
|
use App\Models\Datacenter;
|
|
use App\Models\Host;
|
|
use App\Models\MaintenanceNotification;
|
|
use App\Models\MaintenanceWindow;
|
|
use Illuminate\Mail\Mailable;
|
|
use Illuminate\Support\Carbon;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Mail;
|
|
use Livewire\Attributes\Layout;
|
|
use Livewire\Attributes\Validate;
|
|
use Livewire\Component;
|
|
|
|
#[Layout('layouts.admin')]
|
|
class Maintenance extends Component
|
|
{
|
|
/** A pending notification is presumed failed (retryable) after this long. */
|
|
private const RETRY_AFTER_MIN = 60;
|
|
|
|
#[Validate('required|string|max:255')]
|
|
public string $title = '';
|
|
|
|
#[Validate('nullable|string|max:2000')]
|
|
public string $publicDescription = '';
|
|
|
|
#[Validate('nullable|string|max:2000')]
|
|
public string $internalNotes = '';
|
|
|
|
#[Validate('required|date')]
|
|
public string $startsAt = '';
|
|
|
|
#[Validate('required|date')]
|
|
public string $endsAt = '';
|
|
|
|
/** @var array<int> */
|
|
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) {
|
|
$this->sendAnnouncements($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;
|
|
}
|
|
if ($state === 'scheduled') {
|
|
if (empty($this->hostIds)) {
|
|
$this->addError('hostIds', __('maintenance.need_host'));
|
|
|
|
return null;
|
|
}
|
|
if ($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()]);
|
|
$this->sendAnnouncements($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;
|
|
}
|
|
$this->sendAnnouncements($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 must be told it's cancelled.
|
|
if ($wasPublished) {
|
|
$notified = MaintenanceNotification::query()
|
|
->where('maintenance_window_id', $window->id)
|
|
->where('event', 'announcement')
|
|
->pluck('customer_id');
|
|
foreach (Customer::query()->whereIn('id', $notified)->get() as $customer) {
|
|
$this->deliverOnce($window, $customer, 'cancelled', new MaintenanceCancelledMail($window, $customer));
|
|
}
|
|
}
|
|
|
|
$this->dispatch('notify', message: __('maintenance.cancelled'));
|
|
}
|
|
|
|
/** Announce to each affected customer exactly once (ledger-guarded). */
|
|
private function sendAnnouncements(MaintenanceWindow $window): void
|
|
{
|
|
foreach ($window->affectedCustomers() as $customer) {
|
|
$this->deliverOnce($window, $customer, 'announcement', new MaintenanceAnnouncementMail($window, $customer));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Queue one mail per (window, customer, event). The ledger row is the
|
|
* idempotency marker; if dispatch itself fails, drop the row so it retries.
|
|
* sent_at stays null until real delivery — we never claim delivery on dispatch.
|
|
*/
|
|
private function deliverOnce(MaintenanceWindow $window, Customer $customer, string $event, Mailable $mail): void
|
|
{
|
|
$delivery = MaintenanceNotification::query()->firstOrCreate(
|
|
['maintenance_window_id' => $window->id, 'customer_id' => $customer->id, 'event' => $event],
|
|
['email' => $customer->email],
|
|
);
|
|
|
|
// Delivered rows (sent_at set by the MessageSent listener) are final.
|
|
if ($delivery->sent_at !== null) {
|
|
return;
|
|
}
|
|
// Otherwise the row is pending. Re-queue only when this is a fresh claim
|
|
// or the previous attempt is stale enough to be presumed failed — so a
|
|
// resend while the job is still in flight does not duplicate the email.
|
|
// updated_at is the last-attempt marker (touched on every dispatch).
|
|
if (! $delivery->wasRecentlyCreated && $delivery->updated_at->gt(now()->subMinutes(self::RETRY_AFTER_MIN))) {
|
|
return;
|
|
}
|
|
|
|
$delivery->forceFill(['updated_at' => now()])->save();
|
|
$mail->notificationId = $delivery->id;
|
|
Mail::to($customer->email)
|
|
->locale($customer->locale ?: config('app.locale'))
|
|
->queue($mail);
|
|
}
|
|
|
|
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(),
|
|
]);
|
|
}
|
|
}
|