Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions resources/lang/en/incident.php
Original file line number Diff line number Diff line change
Expand Up @@ -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' => [
Expand Down
27 changes: 25 additions & 2 deletions src/Actions/Update/CreateUpdate.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
});

Expand All @@ -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.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,38 @@
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
{
public function __construct(
public readonly IncidentStatusEnum $status,
public readonly string $message,
/** @var array<int, IncidentComponentRequestData> */
#[DataCollectionOf(IncidentComponentRequestData::class)]
public readonly array $components = [],
) {}

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<int, array{id: int, status: int}>
*
* @example [{"id": 1, "status": 3}]
*/
'components' => ['array'],
'components.*.id' => ['required', 'int', 'distinct', 'exists:components,id'],
'components.*.status' => ['required', Rule::enum(ComponentStatusEnum::class)],
];
}
}
26 changes: 26 additions & 0 deletions src/Filament/Resources/Incidents/IncidentResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(),
]);
}

Expand Down
41 changes: 41 additions & 0 deletions tests/Feature/Api/IncidentUpdateTest.php
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
<?php

use Cachet\Enums\ComponentStatusEnum;
use Cachet\Enums\IncidentStatusEnum;
use Cachet\Enums\ResourceVisibilityEnum;
use Cachet\Models\Component;
use Cachet\Models\Incident;
use Cachet\Models\Update;
use Illuminate\Database\Eloquent\Relations\Relation;
Expand Down Expand Up @@ -191,6 +193,45 @@
]);
});

it('can create an incident update that moves its component statuses', function () {
Sanctum::actingAs(User::factory()->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();

Expand Down
37 changes: 37 additions & 0 deletions tests/Feature/Filament/Resources/IncidentResourceTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
63 changes: 63 additions & 0 deletions tests/Unit/Actions/Update/CreateUpdateTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
Loading