From 246c445893714ae5fe8e924e0cb160ab0b6e4290 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marti=CC=81n=20Go=CC=81mez?= Date: Fri, 23 Oct 2026 00:00:26 +0200 Subject: [PATCH 1/2] Include public incidents in the status page last updated timestamp Status::lastUpdated() only looked at enabled components. Linking a component to an incident is stored on incident_components and does not touch the component, so the status bar kept showing the last time a component was edited ("Last updated 1 year ago") after incidents had been opened, updated and resolved. Take the most recent of enabled components, public incidents and the updates of public incidents. --- src/Status.php | 26 +++++++++++++--- tests/Unit/StatusTest.php | 64 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 5 deletions(-) diff --git a/src/Status.php b/src/Status.php index 32c0f795..7fc1c4c0 100644 --- a/src/Status.php +++ b/src/Status.php @@ -7,6 +7,7 @@ use Cachet\Enums\SystemStatusEnum; use Cachet\Models\Component; use Cachet\Models\Incident; +use Cachet\Models\Update; use Cachet\Settings\AppSettings; use Carbon\CarbonInterface; use Illuminate\Database\Eloquent\Builder; @@ -104,14 +105,29 @@ public function incidents(): object } /** - * Get the most recent update timestamp across enabled components. + * Get the most recent update timestamp across enabled components and public incidents. + * + * Linking a component to an incident does not touch the component itself, so + * incidents and their updates are considered too. Otherwise the status page + * keeps showing the last time a component was edited, even after incidents + * have been opened and resolved since. */ public function lastUpdated(): ?CarbonInterface { - return Component::query() - ->enabled() - ->latest('updated_at') - ->first(['updated_at'])?->updated_at; + return collect([ + Component::query() + ->enabled() + ->latest('updated_at') + ->value('updated_at'), + Incident::query() + ->viewableBy(false) + ->latest('updated_at') + ->value('updated_at'), + Update::query() + ->whereHasMorph('updateable', [Incident::class], fn (Builder $query) => $query->viewableBy(false)) + ->latest('updated_at') + ->value('updated_at'), + ])->filter()->max(); } /** diff --git a/tests/Unit/StatusTest.php b/tests/Unit/StatusTest.php index fbcd06ce..ebc97960 100644 --- a/tests/Unit/StatusTest.php +++ b/tests/Unit/StatusTest.php @@ -121,6 +121,70 @@ ->toEqual($component->updated_at); }); +it('includes public incidents in the last updated timestamp', function () { + Component::factory()->create([ + 'enabled' => true, + 'updated_at' => now()->subYear(), + ]); + + $this->travel(-1)->hours(); + $incident = Incident::factory()->create([ + 'visible' => ResourceVisibilityEnum::guest, + ]); + $this->travelBack(); + + expect((new Status)->lastUpdated())->toEqual($incident->updated_at); +}); + +it('includes updates to public incidents in the last updated timestamp', function () { + Component::factory()->create([ + 'enabled' => true, + 'updated_at' => now()->subYear(), + ]); + + $this->travel(-1)->days(); + $incident = Incident::factory()->create([ + 'status' => IncidentStatusEnum::identified->value, + 'visible' => ResourceVisibilityEnum::guest, + ]); + $this->travelBack(); + + $this->travel(-1)->hours(); + $update = Update::factory()->forIncident($incident)->create([ + 'status' => IncidentStatusEnum::identified->value, + ]); + $this->travelBack(); + + expect((new Status)->lastUpdated())->toEqual($update->updated_at); +}); + +it('excludes non-public incidents and their updates from the last updated timestamp', function (ResourceVisibilityEnum $visibility) { + $component = Component::factory()->create([ + 'enabled' => true, + 'updated_at' => now()->subYear(), + ]); + + $incident = Incident::factory()->create([ + 'visible' => $visibility, + ]); + Update::factory()->forIncident($incident)->create(); + + expect((new Status)->lastUpdated())->toEqual($component->updated_at); +})->with([ResourceVisibilityEnum::authenticated, ResourceVisibilityEnum::hidden]); + +it('excludes unpublished incidents from the last updated timestamp', function () { + $component = Component::factory()->create([ + 'enabled' => true, + 'updated_at' => now()->subYear(), + ]); + + Incident::factory()->scheduled()->create([ + 'visible' => ResourceVisibilityEnum::guest, + ]); + + expect((new Status)->lastUpdated())->toEqual($component->updated_at); +}); + it('excludes disabled components from component overview', function () { Component::factory()->create([ 'status' => ComponentStatusEnum::operational->value, From f688ce234e214646482ecad92bcb2dc30a68cc9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marti=CC=81n=20Go=CC=81mez?= Date: Fri, 23 Oct 2026 00:17:29 +0200 Subject: [PATCH 2/2] Query incident updates without a morph closure Larastan with prefer-lowest dependencies types the whereHasMorph closure builder as Builder, so viewableBy() was reported as undefined. Filter on updateable_type and a subquery of public incident ids instead, as SlackRssController already does. --- src/Status.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Status.php b/src/Status.php index 7fc1c4c0..d2af932e 100644 --- a/src/Status.php +++ b/src/Status.php @@ -124,7 +124,8 @@ public function lastUpdated(): ?CarbonInterface ->latest('updated_at') ->value('updated_at'), Update::query() - ->whereHasMorph('updateable', [Incident::class], fn (Builder $query) => $query->viewableBy(false)) + ->where('updateable_type', (new Incident)->getMorphClass()) + ->whereIn('updateable_id', Incident::query()->viewableBy(false)->select('id')) ->latest('updated_at') ->value('updated_at'), ])->filter()->max();