diff --git a/src/Models/Component.php b/src/Models/Component.php index 697f66cc..c5b766ed 100644 --- a/src/Models/Component.php +++ b/src/Models/Component.php @@ -12,6 +12,7 @@ use Cachet\Events\Components\ComponentDeleted; use Cachet\Events\Components\ComponentUpdated; use Cachet\QueryBuilders\ScheduleBuilder; +use Cachet\Status; use Carbon\Carbon; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Casts\Attribute; @@ -90,6 +91,15 @@ class Component extends Model implements Metable 'updated' => ComponentUpdated::class, ]; + /** + * Keep the cached status-page aggregates in step with component changes. + */ + protected static function booted(): void + { + static::saved(fn () => Status::flush()); + static::deleted(fn () => Status::flush()); + } + /** * Render the Markdown description. */ diff --git a/src/Models/Incident.php b/src/Models/Incident.php index 4c0ff9bb..82c5d65c 100644 --- a/src/Models/Incident.php +++ b/src/Models/Incident.php @@ -14,6 +14,7 @@ use Cachet\Events\Incidents\IncidentDeleted; use Cachet\Events\Incidents\IncidentUpdated; use Cachet\Filament\Resources\Incidents\IncidentResource; +use Cachet\Status; use Carbon\Carbon; use Illuminate\Contracts\Auth\Authenticatable; use Illuminate\Database\Eloquent\Builder; @@ -127,8 +128,15 @@ protected static function boot() } }); - self::saved(fn () => self::forgetRssFeed()); - self::deleted(fn () => self::forgetRssFeed()); + self::saved(function (): void { + self::forgetRssFeed(); + Status::flush(); + }); + + self::deleted(function (): void { + self::forgetRssFeed(); + Status::flush(); + }); } /** diff --git a/src/Models/IncidentComponent.php b/src/Models/IncidentComponent.php index 232f398a..009f9ac1 100644 --- a/src/Models/IncidentComponent.php +++ b/src/Models/IncidentComponent.php @@ -4,6 +4,7 @@ use Cachet\Database\Factories\IncidentComponentFactory; use Cachet\Enums\ComponentStatusEnum; +use Cachet\Status; use Carbon\Carbon; use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -34,6 +35,16 @@ class IncidentComponent extends Pivot 'component_status' => ComponentStatusEnum::class, ]; + /** + * Attaching or detaching a component changes its effective status, so the + * cached status-page aggregates are flushed alongside. + */ + protected static function booted(): void + { + static::saved(fn () => Status::flush()); + static::deleted(fn () => Status::flush()); + } + /** * Get the incident the component is attached to. */ diff --git a/src/Models/Schedule.php b/src/Models/Schedule.php index fec51fb3..3db6ea65 100644 --- a/src/Models/Schedule.php +++ b/src/Models/Schedule.php @@ -11,6 +11,7 @@ use Cachet\Database\Factories\ScheduleFactory; use Cachet\Enums\ScheduleStatusEnum; use Cachet\QueryBuilders\ScheduleBuilder; +use Cachet\Status; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\Factory; @@ -70,6 +71,9 @@ protected static function booted(): void } }); + self::saved(fn () => Status::flush()); + self::deleted(fn () => Status::flush()); + self::updated(function (Schedule $schedule) { if ($schedule->wasChanged('completed_at') && $schedule->status === ScheduleStatusEnum::complete) { app(NotifyScheduleCompletedSubscribers::class)->handle($schedule); diff --git a/src/Models/ScheduleComponent.php b/src/Models/ScheduleComponent.php index 52f1104d..5a234119 100644 --- a/src/Models/ScheduleComponent.php +++ b/src/Models/ScheduleComponent.php @@ -4,6 +4,7 @@ use Cachet\Database\Factories\ScheduleComponentFactory; use Cachet\Enums\ComponentStatusEnum; +use Cachet\Status; use Carbon\Carbon; use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -34,6 +35,16 @@ class ScheduleComponent extends Pivot 'component_status' => ComponentStatusEnum::class, ]; + /** + * Attaching or detaching a component changes its effective status, so the + * cached status-page aggregates are flushed alongside. + */ + protected static function booted(): void + { + static::saved(fn () => Status::flush()); + static::deleted(fn () => Status::flush()); + } + /** * Get the affected component. * diff --git a/src/Models/Update.php b/src/Models/Update.php index f90d957f..26648682 100644 --- a/src/Models/Update.php +++ b/src/Models/Update.php @@ -5,6 +5,7 @@ use Cachet\Cachet; use Cachet\Database\Factories\UpdateFactory; use Cachet\Enums\IncidentStatusEnum; +use Cachet\Status; use Carbon\Carbon; use Illuminate\Contracts\Auth\Authenticatable; use Illuminate\Database\Eloquent\Factories\Factory; @@ -49,8 +50,15 @@ class Update extends Model protected static function booted(): void { - static::saved(fn (Update $update) => $update->forgetRssFeed()); - static::deleted(fn (Update $update) => $update->forgetRssFeed()); + static::saved(function (Update $update): void { + $update->forgetRssFeed(); + Status::flush(); + }); + + static::deleted(function (Update $update): void { + $update->forgetRssFeed(); + Status::flush(); + }); } /** diff --git a/src/Status.php b/src/Status.php index 32c0f795..126fd587 100644 --- a/src/Status.php +++ b/src/Status.php @@ -7,12 +7,28 @@ use Cachet\Enums\SystemStatusEnum; use Cachet\Models\Component; use Cachet\Models\Incident; +use Cachet\Models\Schedule; +use Cachet\Models\Update; use Cachet\Settings\AppSettings; use Carbon\CarbonInterface; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Relations\Relation; +use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\Date; class Status { + /** + * How long a cached aggregate is served without being recalculated, in seconds. + */ + private const CACHE_FRESH = 30; + + /** + * How long a stale aggregate may still be served while it is recalculated + * in the background, in seconds. + */ + private const CACHE_TTL = 60; + protected ?object $components = null; protected ?object $incidents = null; @@ -84,7 +100,11 @@ public function majorOutage(): bool */ public function components(): object { - return $this->components ??= $this->tally(); + return $this->components ??= Cache::flexible( + 'cachet::status:components', + [self::CACHE_FRESH, self::CACHE_TTL], + fn (): object => $this->tally(), + ); } /** @@ -94,24 +114,55 @@ public function components(): object */ public function incidents(): object { - return $this->incidents ??= Incident::query() - ->viewableBy(false) - ->toBase() - ->selectRaw('count(*) as total') - ->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(); + return $this->incidents ??= Cache::flexible( + 'cachet::status:incidents', + [self::CACHE_FRESH, self::CACHE_TTL], + fn (): object => Incident::query() + ->viewableBy(false) + ->toBase() + ->selectRaw('count(*) as total') + ->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(), + ); } /** - * Get the most recent update timestamp across enabled components. + * Get the most recent guest-visible activity timestamp across components, + * incidents, incident updates and schedules. */ public function lastUpdated(): ?CarbonInterface { - return Component::query() - ->enabled() - ->latest('updated_at') - ->first(['updated_at'])?->updated_at; + return Cache::flexible( + 'cachet::status:last-updated', + [self::CACHE_FRESH, self::CACHE_TTL], + fn (): ?CarbonInterface => collect([ + Component::query()->enabled()->max('updated_at'), + Incident::query()->viewableBy(false)->max('updated_at'), + Schedule::query()->published()->max('updated_at'), + Update::query() + ->where('updateable_type', Relation::getMorphAlias(Incident::class)) + ->whereIn('updateable_id', Incident::query()->viewableBy(false)->select('id')) + ->max('updated_at'), + Update::query() + ->where('updateable_type', Relation::getMorphAlias(Schedule::class)) + ->whereIn('updateable_id', Schedule::query()->published()->select('id')) + ->max('updated_at'), + ]) + ->filter() + ->map(fn ($timestamp): CarbonInterface => Date::parse($timestamp)) + ->max(), + ); + } + + /** + * Forget the cached aggregates so the next read recalculates them. + */ + public static function flush(): void + { + Cache::forget('cachet::status:components'); + Cache::forget('cachet::status:incidents'); + Cache::forget('cachet::status:last-updated'); } /** diff --git a/tests/Unit/StatusTest.php b/tests/Unit/StatusTest.php index fbcd06ce..b23b65c8 100644 --- a/tests/Unit/StatusTest.php +++ b/tests/Unit/StatusTest.php @@ -13,6 +13,7 @@ use Cachet\Models\Update; use Cachet\Status; use Carbon\CarbonInterface; +use Illuminate\Support\Facades\Cache; use function PHPUnit\Framework\assertFalse; use function PHPUnit\Framework\assertTrue; @@ -121,6 +122,84 @@ ->toEqual($component->updated_at); }); +it('considers incidents, incident updates and schedules when calculating the last updated timestamp', function () { + expect((new Status)->lastUpdated())->toBeNull(); + + Component::factory()->create([ + 'enabled' => true, + 'updated_at' => now()->subWeek(), + ]); + $incident = Incident::factory()->create([ + 'visible' => ResourceVisibilityEnum::guest, + 'updated_at' => now()->subDay(), + ]); + + expect((new Status)->lastUpdated())->toEqual($incident->updated_at); + + $update = Update::factory()->forIncident($incident)->create([ + 'status' => null, + 'created_at' => now()->subHour(), + 'updated_at' => now()->subHour(), + ]); + + expect((new Status)->lastUpdated())->toEqual($update->updated_at); + + $schedule = Schedule::factory()->create([ + 'updated_at' => now()->subMinute(), + ]); + + expect((new Status)->lastUpdated())->toEqual($schedule->updated_at); +}); + +it('ignores activity hidden from guests when calculating the last updated timestamp', function () { + $component = Component::factory()->create([ + 'enabled' => true, + 'updated_at' => now()->subWeek(), + ]); + $hidden = Incident::factory()->create([ + 'visible' => ResourceVisibilityEnum::authenticated, + 'updated_at' => now()->subMinute(), + ]); + Update::factory()->forIncident($hidden)->create([ + 'status' => null, + 'created_at' => now()->subMinute(), + 'updated_at' => now()->subMinute(), + ]); + Incident::factory()->scheduled()->create([ + 'visible' => ResourceVisibilityEnum::guest, + 'updated_at' => now()->subMinute(), + ]); + Schedule::factory()->scheduled()->create([ + 'updated_at' => now()->subMinute(), + ]); + + expect((new Status)->lastUpdated())->toEqual($component->updated_at); +}); + +it('caches the status aggregates and flushes them when status data changes', function () { + Component::factory()->create([ + 'enabled' => true, + ]); + + $status = new Status; + $status->components(); + $status->incidents(); + $status->lastUpdated(); + + expect(Cache::has('cachet::status:components'))->toBeTrue() + ->and(Cache::has('cachet::status:incidents'))->toBeTrue() + ->and(Cache::has('cachet::status:last-updated'))->toBeTrue(); + + Component::factory()->create([ + 'enabled' => true, + ]); + + expect(Cache::has('cachet::status:components'))->toBeFalse() + ->and(Cache::has('cachet::status:incidents'))->toBeFalse() + ->and(Cache::has('cachet::status:last-updated'))->toBeFalse() + ->and((int) (new Status)->components()->total)->toBe(2); +}); + it('excludes disabled components from component overview', function () { Component::factory()->create([ 'status' => ComponentStatusEnum::operational->value,