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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions src/Models/Component.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
*/
Expand Down
12 changes: 10 additions & 2 deletions src/Models/Incident.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
});
}

/**
Expand Down
11 changes: 11 additions & 0 deletions src/Models/IncidentComponent.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
*/
Expand Down
4 changes: 4 additions & 0 deletions src/Models/Schedule.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
11 changes: 11 additions & 0 deletions src/Models/ScheduleComponent.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
*
Expand Down
12 changes: 10 additions & 2 deletions src/Models/Update.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
});
}

/**
Expand Down
77 changes: 64 additions & 13 deletions src/Status.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(),
);
}

/**
Expand All @@ -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');
}

/**
Expand Down
79 changes: 79 additions & 0 deletions tests/Unit/StatusTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
Loading