diff --git a/resources/lang/en/incident.php b/resources/lang/en/incident.php index 66ccd095..3937c4bf 100644 --- a/resources/lang/en/incident.php +++ b/resources/lang/en/incident.php @@ -93,6 +93,9 @@ 'status_label' => 'Status', 'user_label' => 'User', 'user_helper' => 'The user who reported the incident.', + 'components_label' => 'Components', + 'components_helper' => 'Update the status of the components affected by this incident.', + 'components_add_action_label' => 'Add a component status', ], ], 'overview' => [ diff --git a/src/Actions/Update/CreateUpdate.php b/src/Actions/Update/CreateUpdate.php index 181d9fb2..19a4e44d 100644 --- a/src/Actions/Update/CreateUpdate.php +++ b/src/Actions/Update/CreateUpdate.php @@ -4,6 +4,7 @@ use Cachet\Actions\Incident\SyncIncidentStatus; use Cachet\Actions\Schedule\NotifyScheduleCompletedSubscribers; +use Cachet\Data\Requests\Incident\IncidentComponentRequestData; use Cachet\Data\Requests\IncidentUpdate\CreateIncidentUpdateRequestData; use Cachet\Data\Requests\ScheduleUpdate\CreateScheduleUpdateRequestData; use Cachet\Enums\ScheduleStatusEnum; @@ -31,14 +32,15 @@ public function handle(Incident|Schedule $resource, CreateIncidentUpdateRequestD { $update = new Update(array_merge( ['user_id' => $user?->getAuthIdentifier()], - $data->except('completedAt')->toArray() + $data->except('completedAt', 'components')->toArray() )); - DB::transaction(function () use ($resource, $update): void { + DB::transaction(function () use ($resource, $update, $data): void { $resource->updates()->save($update); if ($resource instanceof Incident) { $this->syncIncidentStatus->handle($resource); + $this->syncIncidentComponents($resource, $data); } }); @@ -55,6 +57,27 @@ public function handle(Incident|Schedule $resource, CreateIncidentUpdateRequestD return $update; } + /** + * Apply the component statuses carried by an incident update. + * + * Recording an update is often how operators move the affected components + * on, so the update can carry new statuses for them. Components not already + * impacted by the incident are attached to it. + */ + private function syncIncidentComponents(Incident $incident, CreateIncidentUpdateRequestData|CreateScheduleUpdateRequestData $data): void + { + if (! $data instanceof CreateIncidentUpdateRequestData || $data->components === []) { + return; + } + + $components = collect($data->components) + ->mapWithKeys(fn (IncidentComponentRequestData $component): array => [ + $component->id => ['component_status' => $component->status->value], + ]); + + $incident->components()->syncWithoutDetaching($components->all()); + } + /** * Complete the schedule when the update provides a completion time. * diff --git a/src/Data/Requests/IncidentUpdate/CreateIncidentUpdateRequestData.php b/src/Data/Requests/IncidentUpdate/CreateIncidentUpdateRequestData.php index ef14c9a5..8238b3dc 100644 --- a/src/Data/Requests/IncidentUpdate/CreateIncidentUpdateRequestData.php +++ b/src/Data/Requests/IncidentUpdate/CreateIncidentUpdateRequestData.php @@ -3,8 +3,11 @@ namespace Cachet\Data\Requests\IncidentUpdate; use Cachet\Data\BaseData; +use Cachet\Data\Requests\Incident\IncidentComponentRequestData; +use Cachet\Enums\ComponentStatusEnum; use Cachet\Enums\IncidentStatusEnum; use Illuminate\Validation\Rule; +use Spatie\LaravelData\Attributes\DataCollectionOf; use Spatie\LaravelData\Support\Validation\ValidationContext; final class CreateIncidentUpdateRequestData extends BaseData @@ -12,6 +15,9 @@ final class CreateIncidentUpdateRequestData extends BaseData public function __construct( public readonly IncidentStatusEnum $status, public readonly string $message, + /** @var array */ + #[DataCollectionOf(IncidentComponentRequestData::class)] + public readonly array $components = [], ) {} public static function rules(ValidationContext $context): array @@ -19,6 +25,16 @@ public static function rules(ValidationContext $context): array return [ 'status' => ['required', Rule::enum(IncidentStatusEnum::class)], 'message' => ['required', 'string'], + /** + * The components impacted by the incident, with the status to move them to. + * + * @var array + * + * @example [{"id": 1, "status": 3}] + */ + 'components' => ['array'], + 'components.*.id' => ['required', 'int', 'distinct', 'exists:components,id'], + 'components.*.status' => ['required', Rule::enum(ComponentStatusEnum::class)], ]; } } diff --git a/src/Filament/Resources/Incidents/IncidentResource.php b/src/Filament/Resources/Incidents/IncidentResource.php index 3002956f..a2b3c5e9 100644 --- a/src/Filament/Resources/Incidents/IncidentResource.php +++ b/src/Filament/Resources/Incidents/IncidentResource.php @@ -14,6 +14,7 @@ use Cachet\Filament\Resources\Incidents\Pages\ListIncidents; use Cachet\Filament\Resources\Incidents\RelationManagers\ComponentsRelationManager; use Cachet\Filament\Resources\Updates\RelationManagers\UpdatesRelationManager; +use Cachet\Models\Component; use Cachet\Models\Incident; use Cachet\Settings\MailSettings; use Cachet\Status; @@ -268,6 +269,31 @@ public static function recordUpdateAction(): Action ->default(fn (Incident $record) => $record->user_id ?? auth()->id()) ->searchable() ->preload(), + Repeater::make('components') + ->label(__('cachet::incident.record_update.form.components_label')) + ->helperText(__('cachet::incident.record_update.form.components_helper')) + ->addActionLabel(__('cachet::incident.record_update.form.components_add_action_label')) + ->default(fn (Incident $record): array => $record->components + ->map(fn (Component $component): array => [ + 'id' => $component->id, + 'status' => $component->pivot->component_status ?? $component->status, + ]) + ->values() + ->all()) + ->schema([ + Select::make('id') + ->label(__('cachet::incident.form.add_component.component_label')) + ->preload() + ->required() + ->options(fn (): array => ComponentOptions::forSelect()) + ->disableOptionsWhenSelectedInSiblingRepeaterItems(), + ToggleButtons::make('status') + ->label(__('cachet::incident.form.add_component.status_label')) + ->inline() + ->options(ComponentStatusEnum::class) + ->required(), + ]) + ->columnSpanFull(), ]); } diff --git a/tests/Feature/Api/IncidentUpdateTest.php b/tests/Feature/Api/IncidentUpdateTest.php index 25f63ad4..b3637e92 100644 --- a/tests/Feature/Api/IncidentUpdateTest.php +++ b/tests/Feature/Api/IncidentUpdateTest.php @@ -1,7 +1,9 @@ create(), ['incident-updates.manage']); + + $incident = Incident::factory()->create(); + $component = Component::factory()->create(); + $incident->components()->attach($component->id, [ + 'component_status' => ComponentStatusEnum::major_outage, + ]); + + $response = postJson("/status/api/incidents/{$incident->id}/updates", [ + 'status' => IncidentStatusEnum::identified->value, + 'message' => 'The API is recovering.', + 'components' => [ + ['id' => $component->id, 'status' => ComponentStatusEnum::performance_issues->value], + ], + ]); + + $response->assertCreated(); + + expect($incident->components()->first()->pivot->component_status) + ->toEqual(ComponentStatusEnum::performance_issues); +}); + +it('rejects an incident update with an unknown component', function () { + Sanctum::actingAs(User::factory()->create(), ['incident-updates.manage']); + + $incident = Incident::factory()->create(); + + $response = postJson("/status/api/incidents/{$incident->id}/updates", [ + 'status' => IncidentStatusEnum::identified->value, + 'message' => 'The API is recovering.', + 'components' => [ + ['id' => 999999, 'status' => ComponentStatusEnum::operational->value], + ], + ]); + + $response->assertUnprocessable(); +}); + it('cannot update an incident update if not authenticated', function () { $incidentUpdate = Update::factory()->forIncident()->create(); diff --git a/tests/Feature/Filament/Resources/IncidentResourceTest.php b/tests/Feature/Filament/Resources/IncidentResourceTest.php index 0412b5b4..5642b29e 100644 --- a/tests/Feature/Filament/Resources/IncidentResourceTest.php +++ b/tests/Feature/Filament/Resources/IncidentResourceTest.php @@ -151,3 +151,40 @@ expect(collect($options)->flatten()->all())->not->toContain('Already Attached'); }); + +it('preloads the impacted components into the record update action', function () { + $incident = Incident::factory()->create(); + $component = Component::factory()->create(['name' => 'API']); + $incident->components()->attach($component->id, ['component_status' => ComponentStatusEnum::major_outage]); + + livewire(EditIncident::class, ['record' => $incident->getKey()]) + ->mountAction('add-update') + ->assertActionDataSet(fn (array $data): bool => collect($data['components'] ?? []) + ->contains(function (array $row) use ($component): bool { + $status = $row['status'] instanceof ComponentStatusEnum + ? $row['status'] + : ComponentStatusEnum::from((int) $row['status']); + + return $row['id'] === $component->id + && $status === ComponentStatusEnum::major_outage; + })); +}); + +it('updates the incident component statuses when recording an update', function () { + $incident = Incident::factory()->create(['status' => IncidentStatusEnum::investigating]); + $component = Component::factory()->create(); + $incident->components()->attach($component->id, ['component_status' => ComponentStatusEnum::major_outage]); + + livewire(EditIncident::class, ['record' => $incident->getKey()]) + ->callAction('add-update', data: [ + 'message' => 'The API is recovering.', + 'status' => IncidentStatusEnum::identified, + 'components' => [ + ['id' => $component->id, 'status' => ComponentStatusEnum::performance_issues->value], + ], + ]) + ->assertHasNoActionErrors(); + + expect($incident->components()->first()->pivot->component_status) + ->toEqual(ComponentStatusEnum::performance_issues); +}); diff --git a/tests/Unit/Actions/Update/CreateUpdateTest.php b/tests/Unit/Actions/Update/CreateUpdateTest.php index 33bbc80c..f8e99bf1 100644 --- a/tests/Unit/Actions/Update/CreateUpdateTest.php +++ b/tests/Unit/Actions/Update/CreateUpdateTest.php @@ -101,6 +101,69 @@ ->toEqual(ComponentStatusEnum::operational); }); +it('updates the impacted components when the update carries component statuses', function () { + $incident = Incident::factory()->create([ + 'status' => IncidentStatusEnum::investigating, + ]); + + $component = Component::factory()->create(); + + $incident->components()->attach($component->id, [ + 'component_status' => ComponentStatusEnum::major_outage, + ]); + + $data = CreateIncidentUpdateRequestData::from([ + 'message' => 'The API is recovering.', + 'status' => IncidentStatusEnum::identified, + 'components' => [ + ['id' => $component->id, 'status' => ComponentStatusEnum::performance_issues], + ], + ]); + + app(CreateUpdate::class)->handle($incident, $data); + + expect($incident->components()->first()->pivot->component_status) + ->toEqual(ComponentStatusEnum::performance_issues); +}); + +it('attaches components that are not yet impacted by the incident', function () { + $incident = Incident::factory()->create(); + $component = Component::factory()->create(); + + $data = CreateIncidentUpdateRequestData::from([ + 'message' => 'A new component is impacted.', + 'status' => IncidentStatusEnum::investigating, + 'components' => [ + ['id' => $component->id, 'status' => ComponentStatusEnum::partial_outage], + ], + ]); + + app(CreateUpdate::class)->handle($incident, $data); + + expect($incident->components()->first()) + ->id->toEqual($component->id) + ->pivot->component_status->toEqual(ComponentStatusEnum::partial_outage); +}); + +it('leaves the incident components untouched when the update carries none', function () { + $incident = Incident::factory()->create(); + $component = Component::factory()->create(); + + $incident->components()->attach($component->id, [ + 'component_status' => ComponentStatusEnum::major_outage, + ]); + + $data = CreateIncidentUpdateRequestData::from([ + 'message' => 'Still investigating.', + 'status' => IncidentStatusEnum::investigating, + ]); + + app(CreateUpdate::class)->handle($incident, $data); + + expect($incident->components()->first()->pivot->component_status) + ->toEqual(ComponentStatusEnum::major_outage); +}); + it('can create a schedule update', function () { $schedule = Schedule::factory()->create();