CluPilotCloud/app/Livewire/Admin/Incidents.php

195 lines
6.8 KiB
PHP

<?php
namespace App\Livewire\Admin;
use App\Models\Incident;
use App\Models\IncidentUpdate;
use App\Services\Status\ServiceHealth;
use App\Support\LocalTime;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Attributes\Validate;
use Livewire\Component;
/**
* Writing down a disruption while it is happening.
*
* The public page can only be as good as what somebody types here, so the form
* is deliberately short: a title, what it touches, how bad it is, and the first
* sentence. Everything else is a further update, which is also the only way to
* change what the record says — an update is a statement made at a time, and
* editing it afterwards is precisely what this record exists to prevent.
*
* Nothing is public until it is published. An operator two minutes into an
* outage should be able to write carefully rather than publish a sentence they
* then have to correct in front of everybody.
*/
#[Layout('layouts.admin')]
class Incidents extends Component
{
#[Validate('required|string|max:160')]
public string $title = '';
#[Validate('required|in:degraded,down,maintenance')]
public string $impact = Incident::IMPACT_DEGRADED;
/** @var array<int, string> */
#[Validate('required|array|min:1')]
public array $components = [];
#[Validate('required|date')]
public string $startedAt = '';
#[Validate('required|string|max:2000')]
public string $firstUpdate = '';
/** The incident an update is being written for, by uuid. */
public ?string $updatingUuid = null;
#[Validate('nullable|in:investigating,identified,monitoring,resolved')]
public string $updateStage = 'identified';
#[Validate('nullable|string|max:2000')]
public string $updateBody = '';
public function mount(): void
{
// Now, in the operator's own time — they are reporting something that
// is happening, not filing a historical record (R19).
$this->startedAt = LocalTime::toField(now());
}
public function create(): void
{
$this->authorize('maintenance.manage');
$this->validateOnly('title');
$this->validateOnly('impact');
$this->validateOnly('components');
$this->validateOnly('startedAt');
$this->validateOnly('firstUpdate');
// One transaction: an incident with no first update is a headline with
// nothing under it, and that is what a reader would see if the second
// insert failed.
DB::transaction(function () {
$incident = Incident::query()->create([
'title' => $this->title,
'impact' => $this->impact,
'components' => array_values(array_intersect(ServiceHealth::COMPONENTS, $this->components)),
'started_at' => LocalTime::fromField($this->startedAt),
'published_at' => now(),
'created_by' => Auth::guard('operator')->user()?->email,
]);
// The first entry is always "wird untersucht" — that is what a
// first report is. The form says so, so nobody looks for a stage
// selector that is not there.
$incident->updates()->create([
'status' => 'investigating',
'body' => $this->firstUpdate,
'created_by' => Auth::guard('operator')->user()?->email,
]);
});
$this->reset(['title', 'impact', 'components', 'firstUpdate']);
$this->startedAt = LocalTime::toField(now());
$this->dispatch('notify', message: __('admin_incidents.created'));
}
/** Open the update box for one incident. */
public function startUpdate(string $uuid): void
{
$this->authorize('maintenance.manage');
$this->updatingUuid = $uuid;
$this->updateStage = 'identified';
$this->updateBody = '';
}
public function cancelUpdate(): void
{
$this->reset(['updatingUuid', 'updateStage', 'updateBody']);
}
public function postUpdate(): void
{
$this->authorize('maintenance.manage');
$this->validate([
'updateStage' => 'required|in:investigating,identified,monitoring,resolved',
'updateBody' => 'required|string|max:2000',
]);
$incident = Incident::query()->where('uuid', $this->updatingUuid)->firstOrFail();
DB::transaction(function () use ($incident) {
$incident->updates()->create([
'status' => $this->updateStage,
'body' => $this->updateBody,
'created_by' => Auth::guard('operator')->user()?->email,
]);
// "Resolved" is the update AND the closing of the incident. Two
// separate actions is how a status page ends up with a resolved
// note under an incident that still reports as ongoing.
if ($this->updateStage === 'resolved' && ! $incident->isResolved()) {
$incident->forceFill(['resolved_at' => now()])->save();
}
});
$this->cancelUpdate();
$this->dispatch('notify', message: __('admin_incidents.update_posted'));
}
/**
* Remove an incident entirely — the whole record, updates and all.
*
* Deliberately different from editing an update, which there is still no
* way to do: an update is a statement made at a time, and rewriting one
* afterwards is what this record exists to prevent. Removing the WHOLE
* incident is a different act. It is how a test entry, or one published by
* mistake against the wrong service, is taken back — and taking it back
* leaves nothing half-standing behind.
*
* Owner-level, and confirmed in a modal (R23), because on a page whose
* whole value is that it is believed, deleting the record is the one action
* that cannot be undone by writing more.
*/
#[On('incident-delete-confirmed')]
public function delete(string $uuid): void
{
$this->authorize('maintenance.manage');
Incident::query()->where('uuid', $uuid)->first()?->delete();
if ($this->updatingUuid === $uuid) {
$this->cancelUpdate();
}
$this->dispatch('notify', message: __('admin_incidents.deleted'));
}
/** @return array<int, string> */
public function componentOptions(): array
{
return ServiceHealth::COMPONENTS;
}
public function render()
{
$this->authorize('maintenance.manage');
return view('livewire.admin.incidents', [
'ongoing' => Incident::query()->ongoing()->with('updates')->orderByDesc('started_at')->get(),
'resolved' => Incident::query()
->whereNotNull('resolved_at')
->with('updates')
->orderByDesc('started_at')
->limit(25)
->get(),
]);
}
}