195 lines
6.8 KiB
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(),
|
|
]);
|
|
}
|
|
}
|