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
2 changes: 1 addition & 1 deletion resources/views/components/system-status.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
<div class="divide-y divide-zinc-900/10 dark:divide-white/15">
@foreach ($groups as $group)
{{ \Cachet\Facades\CachetView::renderHook(\Cachet\View\RenderHook::STATUS_PAGE_SYSTEM_STATUS_GROUP_BEFORE) }}
<div data-component="system-status-group" data-component-group-id="{{ $group['model']->getKey() }}" x-data x-disclosure>
<div data-component="system-status-group" data-component-group-id="{{ $group['model']->getKey() }}" x-data x-disclosure @if ($group['expanded']) default-open @endif>
<button data-slot="trigger" x-disclosure:button class="w-full px-5 py-4 text-left transition hover:bg-zinc-50/60 dark:hover:bg-white/[0.02] sm:px-6">
<div class="flex items-center justify-between gap-4">
<span class="flex min-w-0 items-center gap-2">
Expand Down
40 changes: 38 additions & 2 deletions src/View/Components/SystemStatus.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

namespace Cachet\View\Components;

use Cachet\Cachet;
use Cachet\Enums\ComponentGroupVisibilityEnum;
use Cachet\Enums\ComponentStatusEnum;
use Cachet\Enums\IncidentStatusEnum;
use Cachet\Models\Component;
Expand All @@ -24,7 +26,7 @@
* @phpstan-type DayEvent array{label: string, status: ComponentStatusEnum, icon: string}
* @phpstan-type DayEvents Collection<string, list<DayEvent>>
* @phpstan-type ComponentRow array{model: Component, status: ComponentStatusEnum, days: ComponentDays, events: DayEvents, uptime: float|null, available_seconds: float, operational_seconds: float}
* @phpstan-type GroupRow array{model: ComponentGroup, components: Collection<int, covariant ComponentRow>, days: StatusDays, events: DayEvents, uptime: float|null, available_seconds: float, operational_seconds: float}
* @phpstan-type GroupRow array{model: ComponentGroup, expanded: bool, components: Collection<int, covariant ComponentRow>, days: StatusDays, events: DayEvents, uptime: float|null, available_seconds: float, operational_seconds: float}
*/
class SystemStatus extends ViewComponent
{
Expand Down Expand Up @@ -62,20 +64,23 @@ public function render(): View|Closure|string
*/
private function groups(): Collection
{
$componentsWithActiveIncidents = $this->componentsWithActiveIncidents();

$groups = ComponentGroup::query()
->visible(auth()->check())
->orderBy('order')
->when(auth()->check(), fn (Builder $query) => $query->users(), fn ($query) => $query->guests())
->get()
->toBase()
->map(function (ComponentGroup $group): array {
->map(function (ComponentGroup $group) use ($componentsWithActiveIncidents): array {
$components = $this->components(
$group->components()->enabled()->orderBy('order')
);

/** @var GroupRow $row */
$row = [
'model' => $group,
'expanded' => $this->isExpanded($group, $components, $componentsWithActiveIncidents),
'components' => $components,
'days' => $this->aggregateDays($components),
'events' => $this->aggregateEvents($components),
Expand All @@ -91,6 +96,37 @@ private function groups(): Collection
return $groups;
}

/**
* Fetch the IDs of components affected by an unresolved incident the current viewer can see.
*
* @return Collection<int, int>
*/
private function componentsWithActiveIncidents(): Collection
{
$authenticated = Cachet::user() !== null;

return Component::query()
->whereHas('incidents', fn ($query) => $query->whereIn('incidents.id', Incident::query()->unresolved()->viewableBy($authenticated)->select('id')))
->pluck('id')
->toBase();
}

/**
* Determine whether a group renders open, judged only by the components it displays.
*
* @param Collection<int, covariant ComponentRow> $components
* @param Collection<int, int> $componentsWithActiveIncidents
*/
private function isExpanded(ComponentGroup $group, Collection $components, Collection $componentsWithActiveIncidents): bool
{
return match ($group->collapsed) {
ComponentGroupVisibilityEnum::expanded => true,
ComponentGroupVisibilityEnum::collapsed_unless_incident => $components
->contains(fn (array $component): bool => $componentsWithActiveIncidents->contains($component['model']->getKey())),
default => false,
};
}

/**
* Load the history needed to calculate each component's uptime.
*
Expand Down
87 changes: 87 additions & 0 deletions tests/Feature/StatusPage/StatusPageTest.php
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<?php

use Cachet\Enums\ComponentGroupVisibilityEnum;
use Cachet\Enums\ComponentStatusEnum;
use Cachet\Enums\ComponentStatusSourceEnum;
use Cachet\Enums\IncidentStatusEnum;
Expand All @@ -16,6 +17,7 @@
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;
use Workbench\App\User;

it('renders the status page', function () {
$this->get(route('cachet.status-page'))
Expand Down Expand Up @@ -321,6 +323,91 @@
], escape: false);
});

it('opens system status groups by default according to their collapsed setting', function () {
$settings = app(AppSettings::class);
$settings->display_system_status = true;
$settings->save();

$expanded = ComponentGroup::factory()->create(['collapsed' => ComponentGroupVisibilityEnum::expanded]);
$collapsed = ComponentGroup::factory()->create(['collapsed' => ComponentGroupVisibilityEnum::collapsed]);
$collapsedWithIncident = ComponentGroup::factory()->create(['collapsed' => ComponentGroupVisibilityEnum::collapsed]);
Component::factory()->create(['component_group_id' => $expanded->id]);
Component::factory()->create(['component_group_id' => $collapsed->id]);
Incident::factory()->create()->components()->attach(
Component::factory()->create(['component_group_id' => $collapsedWithIncident->id]),
['component_status' => ComponentStatusEnum::major_outage],
);

$page = $this->get(route('cachet.status-page'))->assertOk()->getContent();

expect($page)
->toMatch(systemStatusGroupPattern($expanded, open: true))
->toMatch(systemStatusGroupPattern($collapsed, open: false))
->toMatch(systemStatusGroupPattern($collapsedWithIncident, open: false));
});

it('opens a collapsed unless incident system status group only for an unresolved incident on a displayed component', function (Closure $setup, bool $open) {
$settings = app(AppSettings::class);
$settings->display_system_status = true;
$settings->save();

$group = ComponentGroup::factory()->create(['collapsed' => ComponentGroupVisibilityEnum::collapsed_unless_incident]);
Component::factory()->create(['component_group_id' => $group->id]);
$setup($group);

$page = $this->get(route('cachet.status-page'))->assertOk()->getContent();

expect($page)->toMatch(systemStatusGroupPattern($group, $open));
})->with([
'no incident' => [fn (ComponentGroup $group) => null, false],
'unresolved incident' => [fn (ComponentGroup $group) => Incident::factory()->create()->components()->attach(
$group->components()->first(),
['component_status' => ComponentStatusEnum::major_outage],
), true],
'resolved incident' => [fn (ComponentGroup $group) => Incident::factory()->create(['status' => IncidentStatusEnum::fixed])->components()->attach(
$group->components()->first(),
['component_status' => ComponentStatusEnum::major_outage],
), false],
'unresolved incident on a disabled component' => [fn (ComponentGroup $group) => Incident::factory()->create()->components()->attach(
Component::factory()->disabled()->create(['component_group_id' => $group->id]),
['component_status' => ComponentStatusEnum::major_outage],
), false],
'unpublished incident' => [fn (ComponentGroup $group) => Incident::factory()->scheduled()->create()->components()->attach(
$group->components()->first(),
['component_status' => ComponentStatusEnum::major_outage],
), false],
]);

it('opens a collapsed unless incident system status group for an authenticated-only incident only when signed in', function (bool $authenticated) {
$settings = app(AppSettings::class);
$settings->display_system_status = true;
$settings->save();

$group = ComponentGroup::factory()->create(['collapsed' => ComponentGroupVisibilityEnum::collapsed_unless_incident]);
Incident::factory()->create(['visible' => ResourceVisibilityEnum::authenticated])->components()->attach(
Component::factory()->create(['component_group_id' => $group->id]),
['component_status' => ComponentStatusEnum::major_outage],
);

if ($authenticated) {
$this->actingAs(User::factory()->create());
}

$page = $this->get(route('cachet.status-page'))->assertOk()->getContent();

expect($page)->toMatch(systemStatusGroupPattern($group, open: $authenticated));
})->with([
'guest' => [false],
'signed in' => [true],
]);

function systemStatusGroupPattern(ComponentGroup $group, bool $open): string
{
$tag = '<div data-component="system-status-group" data-component-group-id="'.$group->id.'" x-data x-disclosure';

return '/'.preg_quote($tag, '/').($open ? '\s+default-open\s*>' : '\s*>').'/';
}

it('renders stable theme attributes on the status page', function () {
$settings = app(AppSettings::class);
$settings->about = 'Service status and uptime.';
Expand Down
Loading