From 26010153b7af09cb1be0214083c285a5a72dc433 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Jul 2026 09:24:40 +0000 Subject: [PATCH 1/5] Make incident status canonical and derive component status from impacts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Incident status had three competing definitions: the column, an accessor that read the newest update, and a banner query that treated an incident as resolved if either said so. They could disagree, so an incident could read as resolved on the banner while its own page still said otherwise. The column is now canonical and every writer keeps it in step through a SyncIncidentStatus action, so creating, editing and deleting an update all leave the incident coherent. latestStatus becomes an alias for it. Component status splits into a baseline and an effective status. The column stays the baseline — what operators and monitoring assert — while incidents and maintenance windows contribute impacts on top of it without ever writing to it. The most severe impact wins rather than the most recent, so a minor incident can no longer mask a major one, and a maintenance window in progress replaces the baseline so expected downtime does not surface as an outage. This also brings schedule_components' long-dormant status column into use with no scheduled job to drive it. Resolution therefore restores nothing, because nothing was overwritten: the impact simply stops counting. The rewrite of every pivot to operational on resolution is gone — it destroyed the record of what an incident had imposed, and only ever ran on one of the paths that resolve an incident. Publication and visibility now go through a single viewableBy scope so no read surface can apply one and forget the other. That closes several leaks: embargoed incidents appeared in the RSS feed, hidden incidents were readable by guid on the status page and flipped the public banner, an unpublished incident's updates were listable by id, and both the component overlay and its incident counts ignored publication entirely. The system status is computed from effective statuses too, so incident impacts finally reach the banner instead of it reporting all clear while every component below it shows red. Also fixes a null scheduled_at fataling the schedule status accessor, and metric charts ordering by their decimal places rather than their order. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0188UuLsNZ39q6Yw8BUBVH3b --- .../views/components/component.blade.php | 6 +- src/Actions/Incident/SyncIncidentStatus.php | 37 +++++++ src/Actions/Update/CreateUpdate.php | 21 +--- src/Actions/Update/DeleteUpdate.php | 13 +++ src/Actions/Update/EditUpdate.php | 13 +++ .../Controllers/Api/IncidentController.php | 4 +- .../Api/IncidentUpdateController.php | 10 +- src/Http/Controllers/RssController.php | 2 +- .../StatusPage/StatusPageController.php | 2 +- src/Models/Component.php | 101 +++++++++++++++++- src/Models/ComponentGroup.php | 5 +- src/Models/Incident.php | 49 +++++++-- src/Models/Schedule.php | 2 +- src/Status.php | 75 +++++++------ src/View/Components/Component.php | 2 +- src/View/Components/ComponentGroups.php | 7 +- src/View/Components/IncidentTimeline.php | 3 +- src/View/Components/Metrics.php | 2 +- tests/Feature/Mcp/Tools/IncidentToolsTest.php | 28 ++++- .../Unit/Actions/Update/CreateUpdateTest.php | 10 +- tests/Unit/Models/IncidentTest.php | 9 +- tests/Unit/StatusTest.php | 5 + 22 files changed, 315 insertions(+), 91 deletions(-) create mode 100644 src/Actions/Incident/SyncIncidentStatus.php diff --git a/resources/views/components/component.blade.php b/resources/views/components/component.blade.php index 6c68ba3d..fa9f0e6b 100644 --- a/resources/views/components/component.blade.php +++ b/resources/views/components/component.blade.php @@ -35,12 +35,12 @@ @focusout="tooltipOpen = false" class="relative shrink-0">
- @if ($component->incidents_count > 0 && $component->latest_unresolved_incident) - + @if ($component->impacting_incident) + {{ $component->latest_status->getLabel() }} @else - {{ $status->getLabel() }} + {{ $component->latest_status->getLabel() }} @endif
diff --git a/src/Actions/Incident/SyncIncidentStatus.php b/src/Actions/Incident/SyncIncidentStatus.php new file mode 100644 index 00000000..07e3a244 --- /dev/null +++ b/src/Actions/Incident/SyncIncidentStatus.php @@ -0,0 +1,37 @@ +updates() + ->whereNotNull('status') + ->orderByDesc('created_at') + ->orderByDesc('id') + ->value('status'); + + if ($status === null) { + return $incident; + } + + $status = $status instanceof IncidentStatusEnum ? $status : IncidentStatusEnum::from((int) $status); + + if ($incident->status !== $status) { + $incident->update(['status' => $status]); + } + + return $incident; + } +} diff --git a/src/Actions/Update/CreateUpdate.php b/src/Actions/Update/CreateUpdate.php index f981248c..cbeeeee5 100644 --- a/src/Actions/Update/CreateUpdate.php +++ b/src/Actions/Update/CreateUpdate.php @@ -2,11 +2,10 @@ namespace Cachet\Actions\Update; +use Cachet\Actions\Incident\SyncIncidentStatus; use Cachet\Actions\Schedule\NotifyScheduleCompletedSubscribers; use Cachet\Data\Requests\IncidentUpdate\CreateIncidentUpdateRequestData; use Cachet\Data\Requests\ScheduleUpdate\CreateScheduleUpdateRequestData; -use Cachet\Enums\ComponentStatusEnum; -use Cachet\Enums\IncidentStatusEnum; use Cachet\Enums\ScheduleStatusEnum; use Cachet\Models\Incident; use Cachet\Models\Schedule; @@ -18,6 +17,7 @@ public function __construct( private NotifyIncidentUpdateSubscribers $notifyIncidentUpdateSubscribers, private NotifyScheduleUpdateSubscribers $notifyScheduleUpdateSubscribers, private NotifyScheduleCompletedSubscribers $notifyScheduleCompletedSubscribers, + private SyncIncidentStatus $syncIncidentStatus, ) { // } @@ -31,9 +31,8 @@ public function handle(Incident|Schedule $resource, CreateIncidentUpdateRequestD $resource->updates()->save($update); - if ($resource instanceof Incident && $data->status === IncidentStatusEnum::fixed) { - $resource->update(['status' => IncidentStatusEnum::fixed]); - $this->updateComponentsToOperational($resource); + if ($resource instanceof Incident) { + $this->syncIncidentStatus->handle($resource); } $this->notifyIncidentUpdateSubscribers->handle($update); @@ -73,16 +72,4 @@ private function completeSchedule(Schedule $schedule, CreateIncidentUpdateReques return false; } - - /** - * Set all linked components back to operational when an incident is fixed. - */ - private function updateComponentsToOperational(Incident $incident): void - { - $incident->components()->each(function ($component) use ($incident) { - $incident->components()->updateExistingPivot($component->id, [ - 'component_status' => ComponentStatusEnum::operational, - ]); - }); - } } diff --git a/src/Actions/Update/DeleteUpdate.php b/src/Actions/Update/DeleteUpdate.php index 8083999f..0e8f07d4 100644 --- a/src/Actions/Update/DeleteUpdate.php +++ b/src/Actions/Update/DeleteUpdate.php @@ -2,15 +2,28 @@ namespace Cachet\Actions\Update; +use Cachet\Actions\Incident\SyncIncidentStatus; +use Cachet\Models\Incident; use Cachet\Models\Update; class DeleteUpdate { + public function __construct(private SyncIncidentStatus $syncIncidentStatus) + { + // + } + /** * Handle the action. */ public function handle(Update $update): void { + $incident = $update->updateable; + $update->delete(); + + if ($incident instanceof Incident) { + $this->syncIncidentStatus->handle($incident); + } } } diff --git a/src/Actions/Update/EditUpdate.php b/src/Actions/Update/EditUpdate.php index 3f8ebe1c..936d27eb 100644 --- a/src/Actions/Update/EditUpdate.php +++ b/src/Actions/Update/EditUpdate.php @@ -2,12 +2,19 @@ namespace Cachet\Actions\Update; +use Cachet\Actions\Incident\SyncIncidentStatus; use Cachet\Data\Requests\IncidentUpdate\EditIncidentUpdateRequestData; use Cachet\Data\Requests\ScheduleUpdate\EditScheduleUpdateRequestData; +use Cachet\Models\Incident; use Cachet\Models\Update; class EditUpdate { + public function __construct(private SyncIncidentStatus $syncIncidentStatus) + { + // + } + /** * Handle the action. */ @@ -15,6 +22,12 @@ public function handle(Update $update, EditIncidentUpdateRequestData|EditSchedul { return tap($update, function (Update $update) use ($data) { $update->update($data->toArray()); + + $incident = $update->updateable; + + if ($incident instanceof Incident) { + $this->syncIncidentStatus->handle($incident); + } }); } } diff --git a/src/Http/Controllers/Api/IncidentController.php b/src/Http/Controllers/Api/IncidentController.php index 7b672b5e..fc9cc876 100644 --- a/src/Http/Controllers/Api/IncidentController.php +++ b/src/Http/Controllers/Api/IncidentController.php @@ -64,8 +64,8 @@ protected function allowedIncludes(): array #[QueryParameter('page', 'Which page to show.', type: 'int', example: 2)] public function index(Request $request) { - $incidents = QueryBuilder::for(Incident::query()->with('updates')->visible($this->isAuthenticated()) - ->when(! $this->tokenCan('incidents.manage'), fn (Builder $query) => $query->published())) + $incidents = QueryBuilder::for(Incident::query()->with('updates') + ->viewableBy($this->isAuthenticated(), $this->tokenCan('incidents.manage'))) ->allowedIncludes($this->allowedIncludes()) ->allowedFilters([ 'name', diff --git a/src/Http/Controllers/Api/IncidentUpdateController.php b/src/Http/Controllers/Api/IncidentUpdateController.php index 024fb48b..fb2b70c2 100644 --- a/src/Http/Controllers/Api/IncidentUpdateController.php +++ b/src/Http/Controllers/Api/IncidentUpdateController.php @@ -83,12 +83,18 @@ public function show(Incident $incident, Update $update) } /** - * Abort with a 404 when the parent incident is not visible to the caller. + * Abort with a 404 when the parent incident is not readable by the caller. + * + * An embargoed incident must not leak its updates either, so publication is + * checked here on exactly the same terms as the incident endpoints. */ protected function ensureIncidentVisible(Incident $incident): void { abort_unless( - Incident::query()->visible($this->isAuthenticated())->whereKey($incident->getKey())->exists(), + Incident::query() + ->viewableBy($this->isAuthenticated(), $this->tokenCan('incidents.manage')) + ->whereKey($incident->getKey()) + ->exists(), Response::HTTP_NOT_FOUND, ); } diff --git a/src/Http/Controllers/RssController.php b/src/Http/Controllers/RssController.php index e236ddae..52137147 100644 --- a/src/Http/Controllers/RssController.php +++ b/src/Http/Controllers/RssController.php @@ -30,7 +30,7 @@ public function __invoke(AppSettings $appSettings): Response 'statusPageName' => $appSettings->name, 'statusAbout' => $appSettings->about, 'incidents' => Incident::query() - ->guests() + ->viewableBy(false) ->with('updates') ->when($appSettings->recent_incidents_only, function ($query) use ($appSettings) { $query->where(function ($query) use ($appSettings) { diff --git a/src/Http/Controllers/StatusPage/StatusPageController.php b/src/Http/Controllers/StatusPage/StatusPageController.php index cdd6f1ce..61f6d381 100644 --- a/src/Http/Controllers/StatusPage/StatusPageController.php +++ b/src/Http/Controllers/StatusPage/StatusPageController.php @@ -34,7 +34,7 @@ public function index(): View */ public function show(Incident $incident): View { - abort_if(! $incident->isPublished() && ! auth()->check(), 404); + abort_unless($incident->isViewableBy(auth()->check(), includeUnpublished: auth()->check()), 404); return view('cachet::status-page.incident', [ 'incident' => $incident->loadMissing([ diff --git a/src/Models/Component.php b/src/Models/Component.php index b70b479c..ec6931e0 100644 --- a/src/Models/Component.php +++ b/src/Models/Component.php @@ -22,6 +22,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; +use Illuminate\Support\Collection as SupportCollection; /** * @property int $id @@ -31,6 +32,7 @@ * @property ?ComponentStatusEnum $status * @property ComponentStatusEnum $latest_status * @property-read ?Incident $latest_unresolved_incident + * @property-read ?Incident $impacting_incident * @property ?int $order * @property ?int $component_group_id * @property ?bool $checked @@ -116,13 +118,30 @@ public function incidents(): BelongsToMany } /** - * Get the unresolved incidents for the component, newest first. + * Get the unresolved incidents publicly affecting the component, newest first. + * + * Embargoed and non-public incidents are excluded: an incident that nobody + * can read must not change what the status page shows. * * @return BelongsToMany */ public function unresolvedIncidents(): BelongsToMany { - return $this->incidents()->unresolved()->latest(); + return $this->incidents()->unresolved()->viewableBy(false)->latest(); + } + + /** + * Get the published maintenance windows currently in progress for the component. + * + * @return BelongsToMany + */ + public function activeMaintenance(): BelongsToMany + { + $relation = $this->schedules()->published(); + + $relation->getQuery()->inProgress(); + + return $relation; } /** @@ -205,11 +224,83 @@ public function latestUnresolvedIncident(): Attribute } /** - * Get the latest status for the component. + * Get the effective status for the component — what the status page shows. + * + * The `status` column is the baseline: what operators and monitoring assert + * about the component itself. Incidents and maintenance windows contribute + * impacts on top of it without ever writing to it, and the most severe of + * those wins. A maintenance window in progress replaces the baseline rather + * than competing with it, so expected downtime during the window does not + * surface as a real outage — an incident raised during it still does. */ public function latestStatus(): Attribute { - return Attribute::get(fn () => $this->latest_unresolved_incident?->pivot->component_status ?? $this->status); + return Attribute::get(function (): ComponentStatusEnum { + $baseline = $this->isUnderMaintenance() + ? ComponentStatusEnum::under_maintenance + : $this->status ?? ComponentStatusEnum::unknown; + + return $this->activeIncidentImpacts() + ->push($baseline) + ->sortByDesc(fn (ComponentStatusEnum $status) => $status->severity()) + ->first(); + })->shouldCache(); + } + + /** + * Get the incident responsible for the component's effective status, if any. + * + * The most severe impact wins and the most recent breaks a tie, so the + * status badge always links to the incident it is actually reporting. + */ + public function impactingIncident(): Attribute + { + return Attribute::get(fn (): ?Incident => $this->activeIncidents() + ->filter(fn (Incident $incident) => $incident->pivot?->component_status !== null) + ->sortByDesc(fn (Incident $incident) => [ + $incident->pivot->component_status->severity(), + $incident->created_at, + ]) + ->first())->shouldCache(); + } + + /** + * Determine whether a published maintenance window is currently in progress. + */ + public function isUnderMaintenance(): bool + { + if ($this->relationLoaded('activeMaintenance')) { + return $this->activeMaintenance->isNotEmpty(); + } + + return $this->activeMaintenance()->exists(); + } + + /** + * The statuses imposed on this component by the incidents affecting it. + * + * @return SupportCollection + */ + protected function activeIncidentImpacts(): SupportCollection + { + return $this->activeIncidents() + ->map(fn (Incident $incident) => $incident->pivot?->component_status) + ->filter() + ->values(); + } + + /** + * The unresolved, publicly visible incidents affecting this component. + * + * Uses the eager-loaded relation when available to avoid a query per component. + * + * @return Collection + */ + protected function activeIncidents(): Collection + { + return $this->relationLoaded('unresolvedIncidents') + ? $this->unresolvedIncidents + : $this->unresolvedIncidents()->get(); } /** @@ -222,7 +313,7 @@ public function orderableBy(ComponentGroup $group): mixed ResourceOrderColumnEnum::LastUpdated => $this->updated_at, ResourceOrderColumnEnum::Name => $this->name, ResourceOrderColumnEnum::Manual => $this->order, - default => $this->status->value, + default => $this->latest_status->severity(), }; } diff --git a/src/Models/ComponentGroup.php b/src/Models/ComponentGroup.php index 376ef113..f0950d8c 100644 --- a/src/Models/ComponentGroup.php +++ b/src/Models/ComponentGroup.php @@ -99,9 +99,7 @@ public function isExpanded(): bool public function worstComponentStatus(): ComponentStatusEnum { return $this->components - ->map(fn (Component $component) => ($component->incidents_count ?? 0) > 0 - ? $component->latest_status - : $component->status) + ->map(fn (Component $component) => $component->latest_status) ->sortByDesc(fn (ComponentStatusEnum $status) => $status->severity()) ->first() ?? ComponentStatusEnum::operational; } @@ -114,6 +112,7 @@ public function hasActiveIncident(): bool return Incident::query() ->unresolved() + ->viewableBy(auth()->check()) ->whereHas('components', fn ($query) => $query->whereIn('components.id', $this->components->pluck('id'))) ->exists(); } diff --git a/src/Models/Incident.php b/src/Models/Incident.php index f3cd4a15..7c3917c4 100644 --- a/src/Models/Incident.php +++ b/src/Models/Incident.php @@ -181,7 +181,41 @@ public function scopeStatus(Builder $query, IncidentStatusEnum $status): void */ public function scopeUnresolved(Builder $query): void { - $query->whereIn('status', IncidentStatusEnum::unresolved()); + $query->whereIn($this->qualifyColumn('status'), IncidentStatusEnum::unresolved()); + } + + /** + * Scope to the incidents a given viewer is allowed to see. + * + * Publication (when it may be seen) and visibility (who may see it) are + * orthogonal, and this is the single place both are decided. Every read + * surface — the API, MCP, RSS, the status page and the system status — + * goes through it so none can forget one of the two. + */ + public function scopeViewableBy(Builder $query, bool $authenticated, bool $includeUnpublished = false): void + { + $query->visible($authenticated) + ->unless($includeUnpublished, fn (Builder $query) => $query->published()); + } + + /** + * Determine whether a given viewer is allowed to see this incident. + * + * The visibility attribute is read through `getAttribute()` because Eloquent + * itself declares a `$visible` property for serialisation, which would + * otherwise shadow the column when read from inside the model. + */ + public function isViewableBy(bool $authenticated, bool $includeUnpublished = false): bool + { + $permitted = $authenticated + ? ResourceVisibilityEnum::visibleToUsers() + : ResourceVisibilityEnum::visibleToGuests(); + + if (! in_array($this->getAttribute('visible'), $permitted, true)) { + return false; + } + + return $includeUnpublished || $this->isPublished(); } /** @@ -218,18 +252,17 @@ protected function timestamp(): Attribute } /** - * Determine the latest status of the incident. + * The incident's status. + * + * Retained for backwards compatibility: the status column is canonical and + * is kept in step with the latest status-bearing update at write time, so + * this is simply an alias for it. * * @return Attribute */ protected function latestStatus(): Attribute { - return Attribute::make( - get: fn () => $this->updates - ->sortByDesc(fn (Update $update) => [$update->created_at, $update->id]) - ->first() - ->status ?? $this->status - ); + return Attribute::make(get: fn (): ?IncidentStatusEnum => $this->status); } /** diff --git a/src/Models/Schedule.php b/src/Models/Schedule.php index 16120ba2..15a5483a 100644 --- a/src/Models/Schedule.php +++ b/src/Models/Schedule.php @@ -118,7 +118,7 @@ protected function status(): Attribute $now = Carbon::now(); return match (true) { - $this->scheduled_at->gte($now) => ScheduleStatusEnum::upcoming, + $this->scheduled_at?->gt($now) === true => ScheduleStatusEnum::upcoming, $this->completed_at === null, $this->completed_at->gte($now) => ScheduleStatusEnum::in_progress, default => ScheduleStatusEnum::complete, diff --git a/src/Status.php b/src/Status.php index 09a53299..33aec7e7 100644 --- a/src/Status.php +++ b/src/Status.php @@ -8,8 +8,7 @@ use Cachet\Models\Component; use Cachet\Models\Incident; use Cachet\Settings\AppSettings; -use Illuminate\Database\Eloquent\Relations\Relation; -use Illuminate\Database\Query\Builder; +use Illuminate\Support\Collection; class Status { @@ -24,14 +23,14 @@ public function current(): SystemStatusEnum { $components = $this->components(); - if ($this->underMaintenance()) { - return SystemStatusEnum::under_maintenance; - } - if ($this->majorOutage()) { return SystemStatusEnum::major_outage; } + if ($this->underMaintenance()) { + return SystemStatusEnum::under_maintenance; + } + if ((int) $components->total - (int) $components->operational === 0) { $incidents = $this->incidents(); @@ -70,49 +69,57 @@ public function majorOutage(): bool } /** - * Get an overview of the components. + * Get an overview of the components, counted by their effective status. + * + * Effective status is resolved per component so that incident impacts and + * maintenance windows reach the system status, rather than the banner + * disagreeing with the component list it sits above. * - * @return object{total: int, operational: int, performance_issues: int, partial_outage: int, major_outage: int} + * @return object{total: int, operational: int, performance_issues: int, partial_outage: int, major_outage: int, under_maintenance: int} */ public function components(): object { - return $this->components ??= Component::query() - ->toBase() - ->where('enabled', true) - ->selectRaw('count(*) as total') - ->selectRaw('sum(case when status = ? then 1 else 0 end) as operational', [ComponentStatusEnum::operational]) - ->selectRaw('sum(case when status = ? then 1 else 0 end) as performance_issues', [ComponentStatusEnum::performance_issues]) - ->selectRaw('sum(case when status = ? then 1 else 0 end) as partial_outage', [ComponentStatusEnum::partial_outage]) - ->selectRaw('sum(case when status = ? then 1 else 0 end) as major_outage', [ComponentStatusEnum::major_outage]) - ->selectRaw('sum(case when status = ? then 1 else 0 end) as under_maintenance', [ComponentStatusEnum::under_maintenance]) - ->first(); + return $this->components ??= $this->countByEffectiveStatus( + Component::query() + ->where('enabled', true) + ->with(['unresolvedIncidents', 'activeMaintenance']) + ->get() + ); } /** - * Get an overview of the incidents. + * Get an overview of the incidents visible to the public. * * @return object{total: int, resolved: int, unresolved: int} */ public function incidents(): object { return $this->incidents ??= Incident::query() - ->published() + ->viewableBy(false) ->toBase() ->selectRaw('count(*) as total') - ->selectRaw('sum(case when ? in (incidents.status, coalesce(latest_update.status, ?)) then 1 else 0 end) as resolved', [IncidentStatusEnum::fixed->value, 0]) - ->selectRaw('sum(case when ? not in (incidents.status, coalesce(latest_update.status, ?)) then 1 else 0 end) as unresolved', [IncidentStatusEnum::fixed->value, 0]) - ->joinSub(function (Builder $query) { - $query - ->select('iu1.updateable_id', 'iu1.status') - ->from('updates', 'iu1') - ->joinSub(function (Builder $query) { - $query->select('updateable_id') - ->selectRaw('max(id) as max_id') - ->from('updates') - ->where('updates.updateable_type', Relation::getMorphAlias(Incident::class)) - ->groupBy('updateable_id'); - }, 'iu2', 'iu1.id', '=', 'iu2.max_id'); - }, 'latest_update', 'latest_update.updateable_id', '=', 'incidents.id', 'left') + ->selectRaw('sum(case when status = ? then 1 else 0 end) as resolved', [IncidentStatusEnum::fixed->value]) + ->selectRaw('sum(case when status is null or status <> ? then 1 else 0 end) as unresolved', [IncidentStatusEnum::fixed->value]) ->first(); } + + /** + * Tally the given components by their effective status. + * + * @param Collection $components + * @return object{total: int, operational: int, performance_issues: int, partial_outage: int, major_outage: int, under_maintenance: int} + */ + private function countByEffectiveStatus(Collection $components): object + { + $statuses = $components->map(fn (Component $component) => $component->latest_status); + + return (object) [ + 'total' => $components->count(), + 'operational' => $statuses->filter(fn (ComponentStatusEnum $status) => $status === ComponentStatusEnum::operational)->count(), + 'performance_issues' => $statuses->filter(fn (ComponentStatusEnum $status) => $status === ComponentStatusEnum::performance_issues)->count(), + 'partial_outage' => $statuses->filter(fn (ComponentStatusEnum $status) => $status === ComponentStatusEnum::partial_outage)->count(), + 'major_outage' => $statuses->filter(fn (ComponentStatusEnum $status) => $status === ComponentStatusEnum::major_outage)->count(), + 'under_maintenance' => $statuses->filter(fn (ComponentStatusEnum $status) => $status === ComponentStatusEnum::under_maintenance)->count(), + ]; + } } diff --git a/src/View/Components/Component.php b/src/View/Components/Component.php index 56ee6bd1..5179b9d0 100644 --- a/src/View/Components/Component.php +++ b/src/View/Components/Component.php @@ -25,7 +25,7 @@ public function __construct( public function render(): View|Closure|string { return view('cachet::components.component', [ - 'status' => $this->component->status, + 'status' => $this->component->latest_status, ]); } } diff --git a/src/View/Components/ComponentGroups.php b/src/View/Components/ComponentGroups.php index 0f8e4ee2..47920d8f 100644 --- a/src/View/Components/ComponentGroups.php +++ b/src/View/Components/ComponentGroups.php @@ -20,8 +20,8 @@ public function render(): View|Closure|string ->enabled() ->whereNull('component_group_id') ->orderBy('order') - ->with('unresolvedIncidents') - ->withCount(['incidents' => fn ($query) => $query->unresolved()]) + ->with(['unresolvedIncidents', 'activeMaintenance']) + ->withCount(['incidents' => fn ($query) => $query->unresolved()->viewableBy(false)]) ->get(), ]); } @@ -33,8 +33,9 @@ private function componentGroups(): Collection { return ComponentGroup::query() ->with([ - 'components' => fn ($query) => $query->enabled()->orderBy('order')->withCount(['incidents' => fn ($query) => $query->unresolved()]), + 'components' => fn ($query) => $query->enabled()->orderBy('order')->withCount(['incidents' => fn ($query) => $query->unresolved()->viewableBy(false)]), 'components.unresolvedIncidents', + 'components.activeMaintenance', ]) ->visible(auth()->check()) ->orderBy('order') diff --git a/src/View/Components/IncidentTimeline.php b/src/View/Components/IncidentTimeline.php index 7d225d75..c92dc7e6 100644 --- a/src/View/Components/IncidentTimeline.php +++ b/src/View/Components/IncidentTimeline.php @@ -85,8 +85,7 @@ private function incidents(Carbon $startDate, Carbon $endDate): Collection 'components', 'updates' => fn ($query) => $query->orderByDesc('created_at')->orderByDesc('id'), ]) - ->visible(auth()->check()) - ->published() + ->viewableBy(auth()->check()) ->when($this->appSettings->recent_incidents_only, function ($query) { $query->where(function ($query) { $query->whereDate( diff --git a/src/View/Components/Metrics.php b/src/View/Components/Metrics.php index b9d3e2de..9e55fdc1 100644 --- a/src/View/Components/Metrics.php +++ b/src/View/Components/Metrics.php @@ -80,7 +80,7 @@ private function metrics(Carbon $startDate): Collection ]) ->where('display_chart', true) ->where(fn (Builder $query) => $query->where('show_when_empty', true)->orWhereHas('metricPoints', fn (Builder $query) => $query->where('created_at', '>=', $startDate))) - ->orderBy('places') + ->orderBy('order') ->get(); } } diff --git a/tests/Feature/Mcp/Tools/IncidentToolsTest.php b/tests/Feature/Mcp/Tools/IncidentToolsTest.php index 655693d3..2f8d60df 100644 --- a/tests/Feature/Mcp/Tools/IncidentToolsTest.php +++ b/tests/Feature/Mcp/Tools/IncidentToolsTest.php @@ -161,7 +161,7 @@ expect($incident->updates()->count())->toBe(1); }); -it('resolves the incident and its components when recording a fixed update', function () { +it('resolves the incident but keeps the impact it recorded when a fixed update lands', function () { Sanctum::actingAs(User::factory()->create(), ['incident-updates.manage']); $component = Component::factory()->create(['status' => ComponentStatusEnum::major_outage]); @@ -175,7 +175,7 @@ ])->assertOk(); expect($incident->fresh()->status)->toBe(IncidentStatusEnum::fixed) - ->and($incident->incidentComponents()->first()->component_status)->toBe(ComponentStatusEnum::operational); + ->and($incident->incidentComponents()->first()->component_status)->toBe(ComponentStatusEnum::major_outage); }); it('edits an incident update', function () { @@ -314,6 +314,7 @@ 'name' => 'API Outage', 'status' => IncidentStatusEnum::identified->value, 'message' => 'The API is down.', + 'visible' => true, 'components' => [ ['id' => $component->id, 'status' => ComponentStatusEnum::major_outage->value], ], @@ -327,6 +328,29 @@ ->etc()); }); +it('keeps an incident nobody can see out of the displayed component status', function () { + Sanctum::actingAs(User::factory()->create(), ['incidents.manage']); + + $component = Component::factory()->create(['status' => ComponentStatusEnum::operational]); + + CachetServer::tool(CreateIncident::class, [ + 'name' => 'Internal Outage', + 'status' => IncidentStatusEnum::identified->value, + 'message' => 'The API is down.', + 'visible' => false, + 'components' => [ + ['id' => $component->id, 'status' => ComponentStatusEnum::major_outage->value], + ], + ])->assertOk(); + + CachetServer::tool(GetComponent::class, ['id' => $component->id]) + ->assertOk() + ->assertStructuredContent(fn (AssertableJson $json) => $json + ->where('data.status.name', 'operational') + ->where('data.latest_status.name', 'operational') + ->etc()); +}); + it('restores the displayed component status when the incident is fixed', function () { Sanctum::actingAs(User::factory()->create(), ['incidents.manage', 'incident-updates.manage']); diff --git a/tests/Unit/Actions/Update/CreateUpdateTest.php b/tests/Unit/Actions/Update/CreateUpdateTest.php index 121fcc80..33bbc80c 100644 --- a/tests/Unit/Actions/Update/CreateUpdateTest.php +++ b/tests/Unit/Actions/Update/CreateUpdateTest.php @@ -5,6 +5,7 @@ use Cachet\Data\Requests\ScheduleUpdate\CreateScheduleUpdateRequestData; use Cachet\Enums\ComponentStatusEnum; use Cachet\Enums\IncidentStatusEnum; +use Cachet\Enums\ResourceVisibilityEnum; use Cachet\Models\Component; use Cachet\Models\Incident; use Cachet\Models\Schedule; @@ -57,7 +58,7 @@ ->status->toEqual(IncidentStatusEnum::fixed); }); -it('does not change parent incident status when incident update status is not fixed', function () { +it('moves parent incident status to any status the update carries', function () { $incident = Incident::factory()->create([ 'status' => IncidentStatusEnum::investigating, ]); @@ -70,12 +71,13 @@ app(CreateUpdate::class)->handle($incident, $data); expect($incident->fresh()) - ->status->toEqual(IncidentStatusEnum::investigating); + ->status->toEqual(IncidentStatusEnum::identified); }); -it('sets linked component status to operational when incident update status is fixed', function () { +it('keeps the impact an incident recorded when its update resolves it', function () { $incident = Incident::factory()->create([ 'status' => IncidentStatusEnum::investigating, + 'visible' => ResourceVisibilityEnum::guest, ]); $component = Component::factory()->create([ @@ -94,6 +96,8 @@ app(CreateUpdate::class)->handle($incident, $data); expect($incident->components()->first()->pivot->component_status) + ->toEqual(ComponentStatusEnum::major_outage) + ->and($component->fresh()->latest_status) ->toEqual(ComponentStatusEnum::operational); }); diff --git a/tests/Unit/Models/IncidentTest.php b/tests/Unit/Models/IncidentTest.php index 1d0c3705..d20e3e0d 100644 --- a/tests/Unit/Models/IncidentTest.php +++ b/tests/Unit/Models/IncidentTest.php @@ -1,5 +1,6 @@ and(Incident::unresolved()->count())->toBe(3); }); -it('resolves the latest status from the newest update when timestamps tie', function () { +it('syncs its status from the newest update when timestamps tie', function () { $incident = Incident::factory()->create(['status' => IncidentStatusEnum::investigating]); $timestamp = now()->startOfMinute(); @@ -78,7 +79,11 @@ $incident->updates()->save($update); } - expect($incident->fresh()->latestStatus)->toBe(IncidentStatusEnum::watching); + app(SyncIncidentStatus::class)->handle($incident); + + expect($incident->fresh()) + ->status->toBe(IncidentStatusEnum::watching) + ->latestStatus->toBe(IncidentStatusEnum::watching); }); it('falls back to its own status without updates', function () { diff --git a/tests/Unit/StatusTest.php b/tests/Unit/StatusTest.php index 1c74ce54..29ab9b8d 100644 --- a/tests/Unit/StatusTest.php +++ b/tests/Unit/StatusTest.php @@ -2,8 +2,10 @@ namespace Tests\Unit; +use Cachet\Actions\Incident\SyncIncidentStatus; use Cachet\Enums\ComponentStatusEnum; use Cachet\Enums\IncidentStatusEnum; +use Cachet\Enums\ResourceVisibilityEnum; use Cachet\Enums\SystemStatusEnum; use Cachet\Models\Component; use Cachet\Models\Incident; @@ -171,6 +173,7 @@ $incident = Incident::factory()->create([ 'status' => IncidentStatusEnum::investigating->value, + 'visible' => ResourceVisibilityEnum::guest, ]); Update::factory()->forIncident($incident)->create([ 'status' => IncidentStatusEnum::identified->value, @@ -179,6 +182,8 @@ 'status' => IncidentStatusEnum::fixed->value, ]); + app(SyncIncidentStatus::class)->handle($incident); + $incidents = (new Status)->incidents(); expect($incidents) From ecf81dce910f93cf5c553250422d1406ee405f13 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Jul 2026 09:40:59 +0000 Subject: [PATCH 2/5] Route every component status change through one recorded command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The component_status_changed webhook only ever fired for API and MCP edits, because the event was dispatched inside the UpdateComponent action and four of the six writers never called it — the dashboard toggle and edit form, the monitor, and the OhDear import all wrote the column directly. They now all go through a ChangeComponentStatus command, so the event fires wherever a status changes and every change leaves a record of what asserted it: manual, monitoring, an import, or the system, with the user who did it and an optional reason. The monitor no longer overwrites a component's status while a maintenance window is in progress. The check is still recorded, but expected downtime during a window is not an outage, and reporting it as one is what the window exists to prevent. Schedules can now say what a component looks like during maintenance. The pivot column has been written since 2016 and read by nothing; the dashboard hardcoded it to operational, which under the new severity rules would never win, so the form now offers a status and defaults it to under maintenance. Both pivots gain a unique constraint on their parent and component, since these rows now decide what the status page displays. Rows that predate it are collapsed to their most severe impact, which is the one that would have been shown anyway. The component API resource gains latest_status alongside status, so consumers can read the effective status without inferring it. The existing status field keeps its meaning as the baseline. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0188UuLsNZ39q6Yw8BUBVH3b --- ..._create_component_status_changes_table.php | 36 +++++ ...02_add_constraints_to_component_pivots.php | 80 +++++++++++ resources/lang/en/component.php | 6 + resources/lang/en/schedule.php | 1 + .../Component/ChangeComponentStatus.php | 49 +++++++ src/Actions/Component/UpdateComponent.php | 20 +-- src/Actions/Integrations/ImportOhDearFeed.php | 14 +- src/Enums/ComponentStatusSourceEnum.php | 18 +++ .../Components/ComponentStatusWasChanged.php | 12 +- .../Components/Pages/EditComponent.php | 30 ++++ .../ComponentsRelationManager.php | 14 +- .../Resources/Schedules/ScheduleResource.php | 8 +- src/Filament/Widgets/Components.php | 9 +- src/Http/Resources/Component.php | 4 + src/Jobs/CheckComponent.php | 22 ++- src/Models/Component.php | 20 ++- src/Models/ComponentStatusChange.php | 62 +++++++++ src/Models/Incident.php | 2 + src/Status.php | 4 +- tests/Feature/Api/ComponentTest.php | 19 +++ .../ComponentPivotConstraintsTest.php | 84 ++++++++++++ .../Publishing/IncidentReadPolicyTest.php | 82 +++++++++++ .../Component/ChangeComponentStatusTest.php | 65 +++++++++ .../Models/ComponentEffectiveStatusTest.php | 128 ++++++++++++++++++ 24 files changed, 757 insertions(+), 32 deletions(-) create mode 100644 database/migrations/2026_07_25_000001_create_component_status_changes_table.php create mode 100644 database/migrations/2026_07_25_000002_add_constraints_to_component_pivots.php create mode 100644 src/Actions/Component/ChangeComponentStatus.php create mode 100644 src/Enums/ComponentStatusSourceEnum.php create mode 100644 src/Models/ComponentStatusChange.php create mode 100644 tests/Feature/Database/ComponentPivotConstraintsTest.php create mode 100644 tests/Feature/Publishing/IncidentReadPolicyTest.php create mode 100644 tests/Unit/Actions/Component/ChangeComponentStatusTest.php create mode 100644 tests/Unit/Models/ComponentEffectiveStatusTest.php diff --git a/database/migrations/2026_07_25_000001_create_component_status_changes_table.php b/database/migrations/2026_07_25_000001_create_component_status_changes_table.php new file mode 100644 index 00000000..1c805758 --- /dev/null +++ b/database/migrations/2026_07_25_000001_create_component_status_changes_table.php @@ -0,0 +1,36 @@ +id(); + $table->unsignedInteger('component_id'); + $table->unsignedTinyInteger('old_status')->nullable(); + $table->unsignedTinyInteger('new_status'); + $table->string('source'); + $table->nullableMorphs('causer'); + $table->text('reason')->nullable(); + $table->timestamps(); + + $table->foreign('component_id')->references('id')->on('components')->cascadeOnDelete(); + $table->index(['component_id', 'created_at']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('component_status_changes'); + } +}; diff --git a/database/migrations/2026_07_25_000002_add_constraints_to_component_pivots.php b/database/migrations/2026_07_25_000002_add_constraints_to_component_pivots.php new file mode 100644 index 00000000..e3399ddd --- /dev/null +++ b/database/migrations/2026_07_25_000002_add_constraints_to_component_pivots.php @@ -0,0 +1,80 @@ + + */ + private const PIVOTS = [ + 'incident_components' => 'incident_id', + 'schedule_components' => 'schedule_id', + ]; + + /** + * Run the migrations. + * + * Now that these rows decide what the status page displays, a component may + * only be attached to a given incident or schedule once. Duplicates that + * predate the constraint are collapsed to their most severe impact, which + * is the one that would have won anyway. + */ + public function up(): void + { + foreach (self::PIVOTS as $table => $parentKey) { + $this->deduplicate($table, $parentKey); + + Schema::table($table, function (Blueprint $table) use ($parentKey) { + $table->unique([$parentKey, 'component_id']); + }); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + foreach (self::PIVOTS as $table => $parentKey) { + Schema::table($table, function (Blueprint $table) use ($parentKey) { + $table->dropUnique([$parentKey, 'component_id']); + }); + } + } + + /** + * Collapse duplicate rows, keeping the most severe impact of each pair. + */ + private function deduplicate(string $table, string $parentKey): void + { + DB::table($table) + ->select($parentKey, 'component_id') + ->groupBy($parentKey, 'component_id') + ->havingRaw('count(*) > 1') + ->get() + ->each(function (object $duplicate) use ($table, $parentKey) { + $keep = DB::table($table) + ->where($parentKey, $duplicate->{$parentKey}) + ->where('component_id', $duplicate->component_id) + ->get() + ->sortByDesc(fn (object $row) => [ + ComponentStatusEnum::tryFrom((int) $row->component_status)?->severity() ?? 0, + $row->id, + ]) + ->first(); + + DB::table($table) + ->where($parentKey, $duplicate->{$parentKey}) + ->where('component_id', $duplicate->component_id) + ->where('id', '!=', $keep->id) + ->delete(); + }); + } +}; diff --git a/resources/lang/en/component.php b/resources/lang/en/component.php index cc6c4af2..17b74976 100644 --- a/resources/lang/en/component.php +++ b/resources/lang/en/component.php @@ -39,6 +39,12 @@ 'under_maintenance' => 'Under maintenance', 'unknown' => 'Unknown', ], + 'status_source' => [ + 'manual' => 'Manual', + 'monitor' => 'Monitoring', + 'import' => 'Import', + 'system' => 'System', + ], 'overview' => [ 'operational_components_label' => 'Operational components', 'operational_components_description' => 'Components that are fully operational.', diff --git a/resources/lang/en/schedule.php b/resources/lang/en/schedule.php index 4c06eb84..26acc7b9 100644 --- a/resources/lang/en/schedule.php +++ b/resources/lang/en/schedule.php @@ -42,6 +42,7 @@ 'action_label' => 'Add component', 'header' => 'Affected components', 'component_label' => 'Component', + 'status_label' => 'Status during maintenance', ], ], 'add_update' => [ diff --git a/src/Actions/Component/ChangeComponentStatus.php b/src/Actions/Component/ChangeComponentStatus.php new file mode 100644 index 00000000..3280b970 --- /dev/null +++ b/src/Actions/Component/ChangeComponentStatus.php @@ -0,0 +1,49 @@ +getAttribute('status'); + + if ($oldStatus === $status) { + return $component; + } + + $component->update(['status' => $status]); + + $component->statusChanges()->create([ + 'old_status' => $oldStatus, + 'new_status' => $status, + 'source' => $source, + 'reason' => $reason, + 'causer_type' => $causer?->getMorphClass(), + 'causer_id' => $causer?->getKey(), + ]); + + ComponentStatusWasChanged::dispatch($component, $oldStatus, $status, $source); + + return $component; + } +} diff --git a/src/Actions/Component/UpdateComponent.php b/src/Actions/Component/UpdateComponent.php index a19612b6..f2643c45 100644 --- a/src/Actions/Component/UpdateComponent.php +++ b/src/Actions/Component/UpdateComponent.php @@ -3,29 +3,33 @@ namespace Cachet\Actions\Component; use Cachet\Data\Requests\Component\UpdateComponentRequestData; -use Cachet\Events\Components\ComponentStatusWasChanged; +use Cachet\Enums\ComponentStatusSourceEnum; use Cachet\Models\Component; class UpdateComponent { + public function __construct(private ChangeComponentStatus $changeComponentStatus) + { + // + } + /** * Handle the action. */ public function handle(Component $component, UpdateComponentRequestData $data): Component { - $oldStatus = $component->status; - - $component->update($data->except('meta')->toArray()); + $component->update($data->except('meta', 'status')->toArray()); if ($data->meta !== null) { $component->syncMeta($data->meta); } - if ($component->wasChanged('status')) { - ComponentStatusWasChanged::dispatch( + if ($data->status !== null) { + $this->changeComponentStatus->handle( $component, - $oldStatus, - $component->status + $data->status, + ComponentStatusSourceEnum::Manual, + auth()->user(), ); } diff --git a/src/Actions/Integrations/ImportOhDearFeed.php b/src/Actions/Integrations/ImportOhDearFeed.php index e7e4c264..6e5b52ba 100644 --- a/src/Actions/Integrations/ImportOhDearFeed.php +++ b/src/Actions/Integrations/ImportOhDearFeed.php @@ -2,7 +2,9 @@ namespace Cachet\Actions\Integrations; +use Cachet\Actions\Component\ChangeComponentStatus; use Cachet\Enums\ComponentStatusEnum; +use Cachet\Enums\ComponentStatusSourceEnum; use Cachet\Enums\ExternalProviderEnum; use Cachet\Models\Component; use Cachet\Models\Incident; @@ -30,14 +32,22 @@ public function __invoke(array $data, bool $importSites, ?int $componentGroupId, private function importSites(array $sites, ?int $componentGroupId): void { foreach ($sites as $site) { - Component::updateOrCreate( + $status = $site['status'] === 'up' ? ComponentStatusEnum::operational : ComponentStatusEnum::partial_outage; + + $component = Component::updateOrCreate( ['link' => $site['url']], [ 'name' => $site['label'], 'component_group_id' => $componentGroupId, - 'status' => $site['status'] === 'up' ? ComponentStatusEnum::operational : ComponentStatusEnum::partial_outage, ] ); + + app(ChangeComponentStatus::class)->handle( + $component, + $status, + ComponentStatusSourceEnum::Import, + reason: ExternalProviderEnum::OhDear->value, + ); } } diff --git a/src/Enums/ComponentStatusSourceEnum.php b/src/Enums/ComponentStatusSourceEnum.php new file mode 100644 index 00000000..a9b04f22 --- /dev/null +++ b/src/Enums/ComponentStatusSourceEnum.php @@ -0,0 +1,18 @@ +value}"); + } +} diff --git a/src/Events/Components/ComponentStatusWasChanged.php b/src/Events/Components/ComponentStatusWasChanged.php index d64fe527..33dbc8ff 100644 --- a/src/Events/Components/ComponentStatusWasChanged.php +++ b/src/Events/Components/ComponentStatusWasChanged.php @@ -4,6 +4,7 @@ use Cachet\Concerns\SendsWebhook; use Cachet\Enums\ComponentStatusEnum; +use Cachet\Enums\ComponentStatusSourceEnum; use Cachet\Enums\WebhookEventEnum; use Cachet\Models\Component; use Illuminate\Broadcasting\Channel; @@ -19,8 +20,12 @@ class ComponentStatusWasChanged /** * Create a new event instance. */ - public function __construct(public Component $component, public ComponentStatusEnum $oldStatus, public ComponentStatusEnum $newStatus) - { + public function __construct( + public Component $component, + public ?ComponentStatusEnum $oldStatus, + public ComponentStatusEnum $newStatus, + public ComponentStatusSourceEnum $source = ComponentStatusSourceEnum::Manual, + ) { // } @@ -40,8 +45,9 @@ public function getWebhookPayload(): array { return [ 'component_id' => $this->component->getKey(), - 'old_status' => $this->oldStatus->value, + 'old_status' => $this->oldStatus?->value, 'new_status' => $this->newStatus->value, + 'source' => $this->source->value, ]; } diff --git a/src/Filament/Resources/Components/Pages/EditComponent.php b/src/Filament/Resources/Components/Pages/EditComponent.php index 0e2700a8..faabe402 100644 --- a/src/Filament/Resources/Components/Pages/EditComponent.php +++ b/src/Filament/Resources/Components/Pages/EditComponent.php @@ -2,10 +2,15 @@ namespace Cachet\Filament\Resources\Components\Pages; +use Cachet\Actions\Component\ChangeComponentStatus; +use Cachet\Enums\ComponentStatusEnum; +use Cachet\Enums\ComponentStatusSourceEnum; use Cachet\Filament\Concerns\InteractsWithMeta; use Cachet\Filament\Resources\Components\ComponentResource; +use Cachet\Models\Component; use Filament\Actions\DeleteAction; use Filament\Resources\Pages\EditRecord; +use Illuminate\Database\Eloquent\Model; class EditComponent extends EditRecord { @@ -30,6 +35,31 @@ protected function mutateFormDataBeforeSave(array $data): array return $this->extractMetaFormData($data); } + /** + * Route a status change through the action so it is recorded and announced + * like every other status change, rather than being saved straight to the + * column by the form. + */ + protected function handleRecordUpdate(Model $record, array $data): Model + { + $status = $data['status'] ?? null; + + unset($data['status']); + + $record = parent::handleRecordUpdate($record, $data); + + if ($status !== null && $record instanceof Component) { + app(ChangeComponentStatus::class)->handle( + $record, + $status instanceof ComponentStatusEnum ? $status : ComponentStatusEnum::from((int) $status), + ComponentStatusSourceEnum::Manual, + auth()->user(), + ); + } + + return $record; + } + protected function afterSave(): void { $this->persistMeta(); diff --git a/src/Filament/Resources/Schedules/RelationManagers/ComponentsRelationManager.php b/src/Filament/Resources/Schedules/RelationManagers/ComponentsRelationManager.php index 348cdd70..49dbc343 100644 --- a/src/Filament/Resources/Schedules/RelationManagers/ComponentsRelationManager.php +++ b/src/Filament/Resources/Schedules/RelationManagers/ComponentsRelationManager.php @@ -51,12 +51,14 @@ public function table(Table $table): Table fn (Select $select) => $select->placeholder(__('Select a component')), ) ->multiple() - ->mutateFormDataUsing(function (array $data): array { - // Set a default component_status value (Operational) - $data['component_status'] = ComponentStatusEnum::operational->value; - - return $data; - }), + ->schema(fn (array $schema): array => [ + ...$schema, + Select::make('component_status') + ->options(ComponentStatusEnum::class) + ->default(ComponentStatusEnum::under_maintenance->value) + ->required() + ->label(__('cachet::schedule.form.add_component.status_label')), + ]), ]) ->recordActions([ DetachAction::make(), diff --git a/src/Filament/Resources/Schedules/ScheduleResource.php b/src/Filament/Resources/Schedules/ScheduleResource.php index 840b8ada..03a150a2 100644 --- a/src/Filament/Resources/Schedules/ScheduleResource.php +++ b/src/Filament/Resources/Schedules/ScheduleResource.php @@ -19,7 +19,6 @@ use Filament\Actions\DeleteBulkAction; use Filament\Actions\EditAction; use Filament\Forms\Components\DateTimePicker; -use Filament\Forms\Components\Hidden; use Filament\Forms\Components\KeyValue; use Filament\Forms\Components\MarkdownEditor; use Filament\Forms\Components\Repeater; @@ -84,8 +83,11 @@ public static function form(Schema $schema): Schema ->relationship('component', 'name') ->disableOptionsWhenSelectedInSiblingRepeaterItems() ->label(__('cachet::schedule.form.add_component.component_label')), - Hidden::make('component_status') - ->default(ComponentStatusEnum::operational->value), + Select::make('component_status') + ->options(ComponentStatusEnum::class) + ->default(ComponentStatusEnum::under_maintenance->value) + ->required() + ->label(__('cachet::schedule.form.add_component.status_label')), ]) ->label(__('cachet::schedule.form.add_component.header')) ->columnSpanFull(), diff --git a/src/Filament/Widgets/Components.php b/src/Filament/Widgets/Components.php index b2612755..60792fce 100644 --- a/src/Filament/Widgets/Components.php +++ b/src/Filament/Widgets/Components.php @@ -2,7 +2,9 @@ namespace Cachet\Filament\Widgets; +use Cachet\Actions\Component\ChangeComponentStatus; use Cachet\Enums\ComponentStatusEnum; +use Cachet\Enums\ComponentStatusSourceEnum; use Cachet\Models\Component; use Cachet\Models\ComponentGroup; use Filament\Forms\Components\ToggleButtons; @@ -82,7 +84,12 @@ protected function buildToggleButton(Component $component): ToggleButtons ->inline() ->live() ->options(ComponentStatusEnum::class) - ->afterStateUpdated(fn (ComponentStatusEnum $state) => $component->update(['status' => $state])); + ->afterStateUpdated(fn (ComponentStatusEnum $state) => app(ChangeComponentStatus::class)->handle( + $component, + $state, + ComponentStatusSourceEnum::Manual, + auth()->user(), + )); } protected function loadComponentGroups(): Collection diff --git a/src/Http/Resources/Component.php b/src/Http/Resources/Component.php index 4ae112fd..643a90d1 100644 --- a/src/Http/Resources/Component.php +++ b/src/Http/Resources/Component.php @@ -20,6 +20,10 @@ public function toAttributes(Request $request): array 'human' => $this->status?->getLabel(), 'value' => $this->status?->value, ], + 'latest_status' => [ + 'human' => $this->latest_status->getLabel(), + 'value' => $this->latest_status->value, + ], 'enabled' => $this->enabled, 'meta' => $this->when( $this->resource->relationLoaded('meta'), diff --git a/src/Jobs/CheckComponent.php b/src/Jobs/CheckComponent.php index f4e74b64..42c96c8d 100644 --- a/src/Jobs/CheckComponent.php +++ b/src/Jobs/CheckComponent.php @@ -2,8 +2,10 @@ namespace Cachet\Jobs; +use Cachet\Actions\Component\ChangeComponentStatus; use Cachet\Cachet; use Cachet\Data\Checks\CheckResult; +use Cachet\Enums\ComponentStatusSourceEnum; use Cachet\Models\Component; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; @@ -28,6 +30,11 @@ public function __construct(public Component $component) {} /** * Execute the job. + * + * The check is always recorded, but the component's baseline status is left + * alone while a maintenance window is in progress: downtime is expected + * then, and reporting it as an outage is exactly what the window exists to + * prevent. */ public function handle(): void { @@ -59,9 +66,16 @@ public function handle(): void 'checked_at' => $checkedAt, ]); - $this->component->update([ - 'status' => $result->status, - 'checked_at' => $checkedAt, - ]); + $this->component->update(['checked_at' => $checkedAt]); + + if ($this->component->isUnderMaintenance()) { + return; + } + + app(ChangeComponentStatus::class)->handle( + $this->component, + $result->status, + ComponentStatusSourceEnum::Monitor, + ); } } diff --git a/src/Models/Component.php b/src/Models/Component.php index ec6931e0..0c5e0160 100644 --- a/src/Models/Component.php +++ b/src/Models/Component.php @@ -11,6 +11,7 @@ use Cachet\Events\Components\ComponentCreated; use Cachet\Events\Components\ComponentDeleted; use Cachet\Events\Components\ComponentUpdated; +use Cachet\QueryBuilders\ScheduleBuilder; use Carbon\Carbon; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Casts\Attribute; @@ -139,7 +140,10 @@ public function activeMaintenance(): BelongsToMany { $relation = $this->schedules()->published(); - $relation->getQuery()->inProgress(); + /** @var ScheduleBuilder $query */ + $query = $relation->getQuery(); + + $query->inProgress(); return $relation; } @@ -166,6 +170,16 @@ public function checks(): HasMany return $this->hasMany(ComponentCheck::class); } + /** + * Get the recorded changes to the component's baseline status. + * + * @return HasMany + */ + public function statusChanges(): HasMany + { + return $this->hasMany(ComponentStatusChange::class); + } + /** * Get the subscribers for this component. */ @@ -256,7 +270,7 @@ public function latestStatus(): Attribute public function impactingIncident(): Attribute { return Attribute::get(fn (): ?Incident => $this->activeIncidents() - ->filter(fn (Incident $incident) => $incident->pivot?->component_status !== null) + ->filter(fn (Incident $incident) => $incident->pivot->component_status !== null) ->sortByDesc(fn (Incident $incident) => [ $incident->pivot->component_status->severity(), $incident->created_at, @@ -284,7 +298,7 @@ public function isUnderMaintenance(): bool protected function activeIncidentImpacts(): SupportCollection { return $this->activeIncidents() - ->map(fn (Incident $incident) => $incident->pivot?->component_status) + ->map(fn (Incident $incident) => $incident->pivot->component_status) ->filter() ->values(); } diff --git a/src/Models/ComponentStatusChange.php b/src/Models/ComponentStatusChange.php new file mode 100644 index 00000000..61b74684 --- /dev/null +++ b/src/Models/ComponentStatusChange.php @@ -0,0 +1,62 @@ + */ + protected $casts = [ + 'old_status' => ComponentStatusEnum::class, + 'new_status' => ComponentStatusEnum::class, + 'source' => ComponentStatusSourceEnum::class, + ]; + + /** @var list */ + protected $fillable = [ + 'component_id', + 'old_status', + 'new_status', + 'source', + 'reason', + 'causer_type', + 'causer_id', + ]; + + /** + * Get the component whose status changed. + * + * @return BelongsTo + */ + public function component(): BelongsTo + { + return $this->belongsTo(Component::class); + } + + /** + * Get whoever or whatever caused the change. + */ + public function causer(): MorphTo + { + return $this->morphTo(); + } +} diff --git a/src/Models/Incident.php b/src/Models/Incident.php index 7c3917c4..893eddce 100644 --- a/src/Models/Incident.php +++ b/src/Models/Incident.php @@ -191,6 +191,8 @@ public function scopeUnresolved(Builder $query): void * orthogonal, and this is the single place both are decided. Every read * surface — the API, MCP, RSS, the status page and the system status — * goes through it so none can forget one of the two. + * + * @param Builder $query */ public function scopeViewableBy(Builder $query, bool $authenticated, bool $includeUnpublished = false): void { diff --git a/src/Status.php b/src/Status.php index 33aec7e7..9efb9fdb 100644 --- a/src/Status.php +++ b/src/Status.php @@ -98,8 +98,8 @@ public function incidents(): object ->viewableBy(false) ->toBase() ->selectRaw('count(*) as total') - ->selectRaw('sum(case when status = ? then 1 else 0 end) as resolved', [IncidentStatusEnum::fixed->value]) - ->selectRaw('sum(case when status is null or status <> ? then 1 else 0 end) as unresolved', [IncidentStatusEnum::fixed->value]) + ->selectRaw('coalesce(sum(case when status = ? then 1 else 0 end), 0) as resolved', [IncidentStatusEnum::fixed->value]) + ->selectRaw('coalesce(sum(case when status is null or status <> ? then 1 else 0 end), 0) as unresolved', [IncidentStatusEnum::fixed->value]) ->first(); } diff --git a/tests/Feature/Api/ComponentTest.php b/tests/Feature/Api/ComponentTest.php index e128b99a..bd0e5b82 100644 --- a/tests/Feature/Api/ComponentTest.php +++ b/tests/Feature/Api/ComponentTest.php @@ -1,6 +1,7 @@ assertOk(); $response->assertJsonCount(1, 'data'); }); + +it('exposes the baseline status alongside the effective status', function () { + $component = Component::factory()->create(['status' => ComponentStatusEnum::operational]); + + $incident = Incident::factory()->create([ + 'status' => IncidentStatusEnum::identified, + 'visible' => ResourceVisibilityEnum::guest, + ]); + + $incident->components()->attach($component->id, [ + 'component_status' => ComponentStatusEnum::major_outage, + ]); + + getJson('/status/api/components/'.$component->id) + ->assertOk() + ->assertJsonPath('data.attributes.status.value', ComponentStatusEnum::operational->value) + ->assertJsonPath('data.attributes.latest_status.value', ComponentStatusEnum::major_outage->value); +}); diff --git a/tests/Feature/Database/ComponentPivotConstraintsTest.php b/tests/Feature/Database/ComponentPivotConstraintsTest.php new file mode 100644 index 00000000..35a1742b --- /dev/null +++ b/tests/Feature/Database/ComponentPivotConstraintsTest.php @@ -0,0 +1,84 @@ +dropUnique(['incident_id', 'component_id']); + }); + + Schema::table('schedule_components', function (Blueprint $table) { + $table->dropUnique(['schedule_id', 'component_id']); + }); +} + +/** + * Run the constraint migration over whatever rows are present. + */ +function applyPivotConstraints(): void +{ + $migration = require __DIR__.'/../../../database/migrations/2026_07_25_000002_add_constraints_to_component_pivots.php'; + + $migration->up(); +} + +it('collapses duplicate impacts to the most severe when the constraint is added', function () { + $component = Component::factory()->create(); + $incident = Incident::factory()->create(); + + dropPivotUniques(); + + foreach ([ComponentStatusEnum::performance_issues, ComponentStatusEnum::major_outage, ComponentStatusEnum::operational] as $status) { + DB::table('incident_components')->insert([ + 'incident_id' => $incident->id, + 'component_id' => $component->id, + 'component_status' => $status->value, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + applyPivotConstraints(); + + $rows = DB::table('incident_components') + ->where('incident_id', $incident->id) + ->where('component_id', $component->id) + ->get(); + + expect($rows)->toHaveCount(1) + ->and((int) $rows->first()->component_status)->toBe(ComponentStatusEnum::major_outage->value); +}); + +it('leaves rows that are already unique alone', function () { + $component = Component::factory()->create(); + $incident = Incident::factory()->create(); + + $incident->components()->attach($component->id, [ + 'component_status' => ComponentStatusEnum::partial_outage, + ]); + + dropPivotUniques(); + applyPivotConstraints(); + + expect(DB::table('incident_components')->count())->toBe(1) + ->and((int) DB::table('incident_components')->first()->component_status) + ->toBe(ComponentStatusEnum::partial_outage->value); +}); + +it('refuses to attach the same component to an incident twice', function () { + $component = Component::factory()->create(); + $incident = Incident::factory()->create(); + + $incident->components()->attach($component->id, ['component_status' => ComponentStatusEnum::major_outage]); + $incident->components()->attach($component->id, ['component_status' => ComponentStatusEnum::partial_outage]); +})->throws(UniqueConstraintViolationException::class); diff --git a/tests/Feature/Publishing/IncidentReadPolicyTest.php b/tests/Feature/Publishing/IncidentReadPolicyTest.php new file mode 100644 index 00000000..13fe747e --- /dev/null +++ b/tests/Feature/Publishing/IncidentReadPolicyTest.php @@ -0,0 +1,82 @@ +create([ + 'name' => 'Embargoed Incident', + 'visible' => ResourceVisibilityEnum::guest, + 'published_at' => now()->addWeek(), + ]); + + $this->get(route('cachet.rss')) + ->assertOk() + ->assertDontSee('Embargoed Incident'); +}); + +it('keeps hidden incidents out of the rss feed', function () { + Incident::factory()->create([ + 'name' => 'Hidden Incident', + 'visible' => ResourceVisibilityEnum::hidden, + ]); + + $this->get(route('cachet.rss')) + ->assertOk() + ->assertDontSee('Hidden Incident'); +}); + +it('hides an incident page from guests when it is not visible to them', function (ResourceVisibilityEnum $visibility) { + $incident = Incident::factory()->create(['visible' => $visibility]); + + $this->get(route('cachet.status-page.incident', $incident))->assertNotFound(); +})->with([ + 'hidden' => [ResourceVisibilityEnum::hidden], + 'authenticated only' => [ResourceVisibilityEnum::authenticated], +]); + +it('keeps an incident nobody can see out of the system status', function (array $attributes) { + Component::factory()->create(['status' => ComponentStatusEnum::operational, 'enabled' => true]); + + Incident::factory()->create([ + 'status' => IncidentStatusEnum::investigating, + ...$attributes, + ]); + + expect((new Status)->incidents()->unresolved)->toBe(0); +})->with([ + 'embargoed' => [['visible' => ResourceVisibilityEnum::guest, 'published_at' => now()->addWeek()]], + 'hidden' => [['visible' => ResourceVisibilityEnum::hidden]], +]); + +it('hides the updates of an embargoed incident from the api', function () { + $incident = Incident::factory()->create([ + 'visible' => ResourceVisibilityEnum::guest, + 'published_at' => now()->addWeek(), + ]); + + $incident->updates()->create(['status' => IncidentStatusEnum::investigating, 'message' => 'Looking into it.']); + + $this->getJson('/status/api/incidents/'.$incident->id.'/updates')->assertNotFound(); +}); + +it('exposes the updates of an embargoed incident to a token that can manage incidents', function () { + Sanctum::actingAs(User::factory()->create(), ['incidents.manage']); + + $incident = Incident::factory()->create([ + 'visible' => ResourceVisibilityEnum::guest, + 'published_at' => now()->addWeek(), + ]); + + $incident->updates()->create(['status' => IncidentStatusEnum::investigating, 'message' => 'Looking into it.']); + + $this->getJson('/status/api/incidents/'.$incident->id.'/updates') + ->assertOk() + ->assertJsonCount(1, 'data'); +}); diff --git a/tests/Unit/Actions/Component/ChangeComponentStatusTest.php b/tests/Unit/Actions/Component/ChangeComponentStatusTest.php new file mode 100644 index 00000000..6c3ddb55 --- /dev/null +++ b/tests/Unit/Actions/Component/ChangeComponentStatusTest.php @@ -0,0 +1,65 @@ +create(['status' => ComponentStatusEnum::operational]); + + app(ChangeComponentStatus::class)->handle( + $component, + ComponentStatusEnum::major_outage, + ComponentStatusSourceEnum::Monitor, + reason: 'Connection refused', + ); + + expect($component->fresh()->status)->toBe(ComponentStatusEnum::major_outage); + + $change = $component->statusChanges()->sole(); + + expect($change) + ->old_status->toBe(ComponentStatusEnum::operational) + ->new_status->toBe(ComponentStatusEnum::major_outage) + ->source->toBe(ComponentStatusSourceEnum::Monitor) + ->reason->toBe('Connection refused'); + + Event::assertDispatched( + ComponentStatusWasChanged::class, + fn (ComponentStatusWasChanged $event) => $event->source === ComponentStatusSourceEnum::Monitor + && $event->oldStatus === ComponentStatusEnum::operational + && $event->newStatus === ComponentStatusEnum::major_outage, + ); +}); + +it('attributes the change to whoever caused it', function () { + $user = User::factory()->create(); + $component = Component::factory()->create(['status' => ComponentStatusEnum::operational]); + + app(ChangeComponentStatus::class)->handle( + $component, + ComponentStatusEnum::partial_outage, + ComponentStatusSourceEnum::Manual, + $user, + ); + + expect($component->statusChanges()->sole()->causer->is($user))->toBeTrue(); +}); + +it('does nothing when the status is unchanged', function () { + Event::fake([ComponentStatusWasChanged::class]); + + $component = Component::factory()->create(['status' => ComponentStatusEnum::operational]); + + app(ChangeComponentStatus::class)->handle($component, ComponentStatusEnum::operational); + + expect($component->statusChanges()->count())->toBe(0); + + Event::assertNotDispatched(ComponentStatusWasChanged::class); +}); diff --git a/tests/Unit/Models/ComponentEffectiveStatusTest.php b/tests/Unit/Models/ComponentEffectiveStatusTest.php new file mode 100644 index 00000000..4f8dcca7 --- /dev/null +++ b/tests/Unit/Models/ComponentEffectiveStatusTest.php @@ -0,0 +1,128 @@ +create([ + 'status' => IncidentStatusEnum::identified, + 'visible' => ResourceVisibilityEnum::guest, + ...$attributes, + ]); + + $incident->components()->attach($component->id, ['component_status' => $impact]); + + return $incident; +} + +it('shows the most severe impact rather than the most recent', function () { + $component = Component::factory()->create(['status' => ComponentStatusEnum::operational]); + + publicIncident(ComponentStatusEnum::major_outage, $component, ['created_at' => now()->subHour()]); + publicIncident(ComponentStatusEnum::performance_issues, $component, ['created_at' => now()]); + + expect($component->fresh()->latest_status)->toBe(ComponentStatusEnum::major_outage); +}); + +it('links the badge to the incident whose impact is being shown', function () { + $component = Component::factory()->create(['status' => ComponentStatusEnum::operational]); + + $worst = publicIncident(ComponentStatusEnum::major_outage, $component, ['created_at' => now()->subHour()]); + publicIncident(ComponentStatusEnum::performance_issues, $component, ['created_at' => now()]); + + expect($component->fresh()->impacting_incident->is($worst))->toBeTrue(); +}); + +it('keeps the baseline when an impact is less severe than it', function () { + $component = Component::factory()->create(['status' => ComponentStatusEnum::major_outage]); + + publicIncident(ComponentStatusEnum::performance_issues, $component); + + expect($component->fresh()->latest_status)->toBe(ComponentStatusEnum::major_outage); +}); + +it('ignores impacts from incidents that are embargoed or hidden', function (array $attributes) { + $component = Component::factory()->create(['status' => ComponentStatusEnum::operational]); + + publicIncident(ComponentStatusEnum::major_outage, $component, $attributes); + + expect($component->fresh()->latest_status)->toBe(ComponentStatusEnum::operational); +})->with([ + 'embargoed' => [['published_at' => now()->addWeek()]], + 'hidden' => [['visible' => ResourceVisibilityEnum::hidden]], + 'authenticated only' => [['visible' => ResourceVisibilityEnum::authenticated]], +]); + +it('ignores impacts from incidents that are resolved', function () { + $component = Component::factory()->create(['status' => ComponentStatusEnum::operational]); + + publicIncident(ComponentStatusEnum::major_outage, $component, ['status' => IncidentStatusEnum::fixed]); + + expect($component->fresh()->latest_status)->toBe(ComponentStatusEnum::operational); +}); + +it('replaces the baseline while maintenance is in progress', function () { + $component = Component::factory()->create(['status' => ComponentStatusEnum::major_outage]); + + $schedule = Schedule::factory()->create([ + 'scheduled_at' => now()->subHour(), + 'completed_at' => now()->addHour(), + ]); + + $schedule->components()->attach($component->id, [ + 'component_status' => ComponentStatusEnum::under_maintenance, + ]); + + expect($component->fresh()->latest_status)->toBe(ComponentStatusEnum::under_maintenance); +}); + +it('still surfaces an incident raised during maintenance', function () { + $component = Component::factory()->create(['status' => ComponentStatusEnum::operational]); + + $schedule = Schedule::factory()->create([ + 'scheduled_at' => now()->subHour(), + 'completed_at' => now()->addHour(), + ]); + + $schedule->components()->attach($component->id, [ + 'component_status' => ComponentStatusEnum::under_maintenance, + ]); + + publicIncident(ComponentStatusEnum::major_outage, $component); + + expect($component->fresh()->latest_status)->toBe(ComponentStatusEnum::major_outage); +}); + +it('ignores maintenance windows that have not started or have finished', function (array $window) { + $component = Component::factory()->create(['status' => ComponentStatusEnum::operational]); + + $schedule = Schedule::factory()->create($window); + + $schedule->components()->attach($component->id, [ + 'component_status' => ComponentStatusEnum::under_maintenance, + ]); + + expect($component->fresh()->latest_status)->toBe(ComponentStatusEnum::operational); +})->with([ + 'upcoming' => [['scheduled_at' => now()->addHour(), 'completed_at' => now()->addHours(2)]], + 'completed' => [['scheduled_at' => now()->subHours(2), 'completed_at' => now()->subHour()]], +]); + +it('resolves the same status whether or not the relations are eager loaded', function () { + $component = Component::factory()->create(['status' => ComponentStatusEnum::operational]); + + publicIncident(ComponentStatusEnum::partial_outage, $component); + + $lazy = Component::query()->find($component->id); + $eager = Component::query()->with(['unresolvedIncidents', 'activeMaintenance'])->find($component->id); + + expect($lazy->latest_status) + ->toBe(ComponentStatusEnum::partial_outage) + ->and($eager->latest_status) + ->toBe(ComponentStatusEnum::partial_outage); +}); From afb60e358c7a50a7e46246dc75868fa28ad9d25c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Jul 2026 13:21:27 +0000 Subject: [PATCH 3/5] Keep the current user out of models and actions, and write atomically Resolving the current user is the caller's job. A model or an action that reaches for auth() couples domain logic to an HTTP session it should know nothing about, and makes the same call answer differently depending on where it runs. ComponentGroup::hasActiveIncident and isExpanded now take the viewer, and CreateUpdate and UpdateComponent take the user who acted, supplied by the controllers, MCP tools, Filament actions and views that are entitled to know it. Passing the viewer also settles a disagreement the group expansion had with itself: the eager-loaded incident count is scoped to guests, so it is only consulted for a guest viewer and anything else is answered with a query scoped to that viewer. Writes that belong together are now wrapped in transactions. A component's status and the record explaining it are written as one, with the event announced only once both have committed; an update and the incident status it drives are likewise written together, so a failure part-way cannot leave an update whose incident disagrees with it. Maintenance windows now use the status each one asked for, rather than assuming every window means under maintenance. The picker added for this was writing a value nothing read, because the component side of the relation never used the ScheduleComponent pivot and so never cast it. The most severe status wins across overlapping windows. Attaching components to a schedule was broken: the attach action's schema closure took an array, but Filament injects a Schema object for that parameter, and overriding the schema without restoring the record select would have removed the component picker even once the type was right. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0188UuLsNZ39q6Yw8BUBVH3b --- CLAUDE.md | 11 ++++++ .../components/component-group.blade.php | 2 +- .../Component/ChangeComponentStatus.php | 29 +++++++++------ src/Actions/Component/UpdateComponent.php | 30 +++++++++------- src/Actions/Update/CreateUpdate.php | 19 ++++++---- .../Resources/Incidents/IncidentResource.php | 3 +- .../ComponentsRelationManager.php | 4 +-- .../Resources/Schedules/ScheduleResource.php | 3 +- .../Controllers/Api/ComponentController.php | 5 +-- .../Api/IncidentUpdateController.php | 5 +-- .../Api/ScheduleUpdateController.php | 5 +-- src/Mcp/Tools/Components/UpdateComponent.php | 5 ++- .../IncidentUpdates/RecordIncidentUpdate.php | 3 +- src/Models/Component.php | 35 ++++++++++++++++--- src/Models/ComponentGroup.php | 20 ++++++++--- src/Models/Schedule.php | 1 + .../Models/ComponentEffectiveStatusTest.php | 28 +++++++++++++++ 17 files changed, 155 insertions(+), 53 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f2ae643a..91aa3e62 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -122,3 +122,14 @@ protected function isAccessible(User $user, ?string $path = null): bool ## HTTP Requests - Never use the `request()` helper. Inject `Illuminate\Http\Request` into the controller method and read input from `$request` (e.g. `$request->integer('per_page', 15)`). + +## The Current User + +- Never call `auth()` inside a model or an action. Resolving the current user is the caller's job, and reaching for it deeper down couples domain code to an HTTP session it should know nothing about. +- Models and actions that need a user accept one as a parameter, typed `?Authenticatable`. +- Controllers, Filament pages, jobs, commands and views are the right places to resolve the user, using `Cachet::user()` where a request-bound guard is needed. + +## Transactions + +- Any action that writes more than once — a model plus its pivots, a change plus its audit record — wraps the writes in `DB::transaction()`, so a failure part-way through cannot leave orphaned or half-populated rows. +- Events describing the completed change are dispatched after the transaction commits, never inside it. diff --git a/resources/views/components/component-group.blade.php b/resources/views/components/component-group.blade.php index bb588112..7870b023 100644 --- a/resources/views/components/component-group.blade.php +++ b/resources/views/components/component-group.blade.php @@ -2,7 +2,7 @@ {{ \Cachet\Facades\CachetView::renderHook(\Cachet\View\RenderHook::STATUS_PAGE_COMPONENT_GROUPS_BEFORE) }} @php($groupStatus = $componentGroup->worstComponentStatus()) -
  • isExpanded()) default-open @endif> +
  • isExpanded(auth()->user())) default-open @endif>