From 59f25b224c690e957b7fe0edb61f9876aa7747f7 Mon Sep 17 00:00:00 2001 From: James Brooks Date: Thu, 5 Mar 2026 12:29:31 +0000 Subject: [PATCH 1/3] Fix maintenance schedules to show all schedules including completed ones Previously, the status page only showed incomplete schedules, which meant users couldn't access updates for completed maintenance events. Co-Authored-By: Claude Opus 4.6 --- src/Http/Controllers/StatusPage/StatusPageController.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Http/Controllers/StatusPage/StatusPageController.php b/src/Http/Controllers/StatusPage/StatusPageController.php index c9b34438..5377e55c 100644 --- a/src/Http/Controllers/StatusPage/StatusPageController.php +++ b/src/Http/Controllers/StatusPage/StatusPageController.php @@ -39,7 +39,7 @@ public function index(): View ->withCount(['incidents' => fn ($query) => $query->unresolved()]) ->get(), - 'schedules' => Schedule::query()->with(['updates', 'components'])->incomplete()->orderBy('scheduled_at')->get(), + 'schedules' => Schedule::query()->with(['updates', 'components'])->orderBy('scheduled_at')->get(), 'display_graphs' => $this->appSettings->display_graphs, ]); From bc80d8f71e3f7d74876d7ddecd37733288f34985 Mon Sep 17 00:00:00 2001 From: James Brooks Date: Tue, 23 Jun 2026 14:03:46 +0100 Subject: [PATCH 2/3] Move completed maintenance from the maintenance block into the timeline The maintenance block now only shows incomplete (upcoming/in-progress) schedules, while completed maintenance is surfaced in the incident timeline grouped by its completion date. This keeps the planned maintenance section focused on what's still relevant while preserving access to past maintenance and its updates. Co-Authored-By: Claude Opus 4.8 (1M context) --- database/seeders/DatabaseSeeder.php | 28 ++++++-- .../components/incident-timeline.blade.php | 4 +- resources/views/components/incident.blade.php | 58 +++++++++++++++- .../StatusPage/StatusPageController.php | 2 +- src/View/Components/IncidentTimeline.php | 68 ++++++++++++++++--- tests/Feature/StatusPage/StatusPageTest.php | 26 +++++++ 6 files changed, 162 insertions(+), 24 deletions(-) diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index bd5aefa4..b84b59b4 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -55,13 +55,6 @@ public function run(): void 'is_admin' => true, ]); - Schedule::create([ - 'name' => 'Documentation Maintenance', - 'message' => 'We will be conducting maintenance on our documentation servers. Documentation may not be available during this time.', - 'scheduled_at' => now()->subHours(12)->subMinutes(45), - 'completed_at' => now()->subHours(12), - ]); - /** @phpstan-ignore-next-line argument.type */ tap(Schedule::create([ 'name' => 'Documentation Maintenance', @@ -83,6 +76,27 @@ public function run(): void $schedule->updates()->save($update); }); + /** @phpstan-ignore-next-line argument.type */ + tap(Schedule::create([ + 'name' => 'Database Server Upgrade', + 'message' => 'We upgraded our primary database servers to improve performance and reliability.', + 'scheduled_at' => now()->subHours(26), + 'completed_at' => now()->subHours(24), + /** @phpstan-ignore-next-line argument.type */ + ]), function (Schedule $schedule) use ($user) { + $update = new Update([ + 'message' => <<<'EOF' +Maintenance is underway. We are migrating data to the upgraded database servers. +EOF + , + 'user_id' => $user->id, + 'created_at' => $timestamp = $schedule->scheduled_at->addMinutes(30), + 'updated_at' => $timestamp, + ]); + + $schedule->updates()->save($update); + }); + $componentGroup = ComponentGroup::create([ 'name' => 'Cachet', 'collapsed' => ComponentGroupVisibilityEnum::expanded, diff --git a/resources/views/components/incident-timeline.blade.php b/resources/views/components/incident-timeline.blade.php index ca566a70..5ac788e5 100644 --- a/resources/views/components/incident-timeline.blade.php +++ b/resources/views/components/incident-timeline.blade.php @@ -32,8 +32,8 @@
- @forelse ($incidents as $date => $incident) - + @forelse ($timeline as $date => $day) + @empty
{{ __('cachet::incident.timeline.no_incidents_reported_between', ['from' => $from, 'to' => $to]) }} diff --git a/resources/views/components/incident.blade.php b/resources/views/components/incident.blade.php index c1c8aa02..94ce8f00 100644 --- a/resources/views/components/incident.blade.php +++ b/resources/views/components/incident.blade.php @@ -2,6 +2,7 @@ @props([ 'date', 'incidents', + 'schedules' => [], ]) {{ \Cachet\Facades\CachetView::renderHook(\Cachet\View\RenderHook::STATUS_PAGE_INCIDENTS_BEFORE) }} @@ -15,7 +16,7 @@
- @forelse($incidents as $incident) + @foreach($incidents as $incident)
@@ -88,12 +89,63 @@ class="text-zinc-400 transition hover:text-zinc-700 dark:text-zinc-500 dark:hove
@endif
- @empty + @endforeach + + @foreach($schedules as $schedule) +
+ + +
$schedule->updates->isNotEmpty(), + ])> + @if ($schedule->components->isNotEmpty()) +
+ {{ $schedule->components->pluck('name')->join(', ', ' and ') }} +
+ @endif + +
+
+

+ {{ $schedule->name }} +

+ + {{ $schedule->completed_at->diffForHumans() }} · + +
+
+ +
+
+ + @if ($schedule->updates->isEmpty() && $schedule->formattedMessage()) +
{!! $schedule->formattedMessage() !!}
+ @endif +
+ + @if ($schedule->updates->isNotEmpty()) +
+ @foreach ($schedule->updates as $update) +
+ + {{ $update->created_at->diffForHumans() }} · + +
{!! $update->formattedMessage() !!}
+
+ @endforeach +
+ @endif +
+ @endforeach + + @if (count($incidents) === 0 && count($schedules) === 0)
{{ __('cachet::incident.no_incidents_reported') }}
- @endforelse + @endif {{ \Cachet\Facades\CachetView::renderHook(\Cachet\View\RenderHook::STATUS_PAGE_INCIDENTS_AFTER) }} diff --git a/src/Http/Controllers/StatusPage/StatusPageController.php b/src/Http/Controllers/StatusPage/StatusPageController.php index 5377e55c..c9b34438 100644 --- a/src/Http/Controllers/StatusPage/StatusPageController.php +++ b/src/Http/Controllers/StatusPage/StatusPageController.php @@ -39,7 +39,7 @@ public function index(): View ->withCount(['incidents' => fn ($query) => $query->unresolved()]) ->get(), - 'schedules' => Schedule::query()->with(['updates', 'components'])->orderBy('scheduled_at')->get(), + 'schedules' => Schedule::query()->with(['updates', 'components'])->incomplete()->orderBy('scheduled_at')->get(), 'display_graphs' => $this->appSettings->display_graphs, ]); diff --git a/src/View/Components/IncidentTimeline.php b/src/View/Components/IncidentTimeline.php index 1d6c96ac..04f056f8 100644 --- a/src/View/Components/IncidentTimeline.php +++ b/src/View/Components/IncidentTimeline.php @@ -3,6 +3,7 @@ namespace Cachet\View\Components; use Cachet\Models\Incident; +use Cachet\Models\Schedule; use Cachet\Settings\AppSettings; use Illuminate\Contracts\View\View; use Illuminate\Database\Eloquent\Builder; @@ -30,7 +31,7 @@ public function render(): View $endDate = $startDate->clone()->subDays($incidentDays); return view('cachet::components.incident-timeline', [ - 'incidents' => $this->incidents( + 'timeline' => $this->timeline( $startDate, $endDate, $this->appSettings->only_disrupted_days @@ -45,11 +46,39 @@ public function render(): View ]); } + /** + * Build the timeline of incidents and completed maintenance, grouped by day. + * + * @return Collection, schedules: Collection}> + */ + private function timeline(Carbon $startDate, Carbon $endDate, bool $onlyDisruptedDays = false): Collection + { + $incidents = $this->incidents($startDate, $endDate); + $schedules = $this->schedules($startDate, $endDate); + + return collect($endDate->toPeriod($startDate)) + ->keyBy(fn ($period) => $period->toDateString()) + ->map(fn ($period) => collect()) + ->union($incidents) + ->union($schedules) + ->keys() + ->mapWithKeys(fn (string $date) => [$date => [ + 'incidents' => $incidents->get($date, collect()), + 'schedules' => $schedules->get($date, collect()), + ]]) + ->when($onlyDisruptedDays, fn ($collection) => $collection->filter( + fn (array $day) => $day['incidents']->isNotEmpty() || $day['schedules']->isNotEmpty() + )) + ->sortKeysDesc(); + } + /** * Fetch the incidents that occurred between the given start and end date. * Incidents will be grouped by days. + * + * @return Collection> */ - private function incidents(Carbon $startDate, Carbon $endDate, bool $onlyDisruptedDays = false): Collection + private function incidents(Carbon $startDate, Carbon $endDate): Collection { return Incident::query() ->with([ @@ -87,14 +116,31 @@ private function incidents(Carbon $startDate, Carbon $endDate, bool $onlyDisrupt }) ->get() ->sortByDesc(fn (Incident $incident) => $incident->timestamp) - ->groupBy(fn (Incident $incident) => $incident->timestamp->toDateString()) - ->union( - // Back-fill any missing dates... - collect($endDate->toPeriod($startDate)) - ->keyBy(fn ($period) => $period->toDateString()) - ->map(fn ($period) => collect()) - ) - ->when($onlyDisruptedDays, fn ($collection) => $collection->filter(fn ($incidents) => $incidents->isNotEmpty())) - ->sortKeysDesc(); + ->groupBy(fn (Incident $incident) => $incident->timestamp->toDateString()); + } + + /** + * Fetch the completed maintenance that occurred between the given start and end date. + * Schedules will be grouped by the day they completed. + * + * @return Collection> + */ + private function schedules(Carbon $startDate, Carbon $endDate): Collection + { + return Schedule::query() + ->with(['components', 'updates' => fn ($query) => $query->orderByDesc('created_at')]) + ->inThePast() + ->when($this->appSettings->recent_incidents_only, fn ($query) => $query->whereDate( + 'completed_at', + '>', + Carbon::now()->subDays($this->appSettings->recent_incidents_days)->format('Y-m-d') + )) + ->when(! $this->appSettings->recent_incidents_only, fn ($query) => $query->whereBetween('completed_at', [ + $endDate->startOfDay()->toDateTimeString(), + $startDate->endofDay()->toDateTimeString(), + ])) + ->get() + ->sortByDesc(fn (Schedule $schedule) => $schedule->completed_at) + ->groupBy(fn (Schedule $schedule) => $schedule->completed_at->toDateString()); } } diff --git a/tests/Feature/StatusPage/StatusPageTest.php b/tests/Feature/StatusPage/StatusPageTest.php index 7f6e42a0..390f9c7c 100644 --- a/tests/Feature/StatusPage/StatusPageTest.php +++ b/tests/Feature/StatusPage/StatusPageTest.php @@ -1,5 +1,7 @@ get(route('cachet.status-page')) ->assertOk(); @@ -14,3 +16,27 @@ $this->get(route('cachet.status-page', ['from' => 'not-a-date'])) ->assertOk(); }); + +it('shows upcoming and in progress maintenance in the maintenance block', function () { + $upcoming = Schedule::factory()->inTheFuture()->create(['name' => 'Upcoming maintenance']); + $inProgress = Schedule::factory()->inProgress()->create(['name' => 'In progress maintenance']); + $completed = Schedule::factory()->inThePast()->create(['name' => 'Completed maintenance']); + + $response = $this->get(route('cachet.status-page'))->assertOk(); + + $maintenanceBlock = $response->viewData('schedules'); + + expect($maintenanceBlock->pluck('id')) + ->toContain($upcoming->id, $inProgress->id) + ->not->toContain($completed->id); +}); + +it('shows completed maintenance in the timeline instead of the maintenance block', function () { + $completed = Schedule::factory()->completed()->create(['name' => 'Completed maintenance']); + + $response = $this->get(route('cachet.status-page'))->assertOk(); + + expect($response->viewData('schedules')->pluck('id'))->not->toContain($completed->id); + + $response->assertSee('Completed maintenance'); +}); From 143ed575fc1e3eca64432d10c4564549266dc819 Mon Sep 17 00:00:00 2001 From: James Brooks Date: Wed, 24 Jun 2026 10:22:57 +0100 Subject: [PATCH 3/3] Fix PHPStan errors in incident timeline Type ScheduleBuilder against the Schedule model and convert grouped incident/maintenance results to base collections so the return types match their declarations. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/QueryBuilders/ScheduleBuilder.php | 2 ++ src/View/Components/IncidentTimeline.php | 2 ++ 2 files changed, 4 insertions(+) diff --git a/src/QueryBuilders/ScheduleBuilder.php b/src/QueryBuilders/ScheduleBuilder.php index 93f472de..edf4b902 100644 --- a/src/QueryBuilders/ScheduleBuilder.php +++ b/src/QueryBuilders/ScheduleBuilder.php @@ -14,6 +14,8 @@ * @method static \Cachet\QueryBuilders\ScheduleBuilder inTheFuture() * @method static \Cachet\QueryBuilders\ScheduleBuilder inThePast() * + * @extends Builder + * * @mixin Schedule */ class ScheduleBuilder extends Builder diff --git a/src/View/Components/IncidentTimeline.php b/src/View/Components/IncidentTimeline.php index 04f056f8..3e5dd542 100644 --- a/src/View/Components/IncidentTimeline.php +++ b/src/View/Components/IncidentTimeline.php @@ -115,6 +115,7 @@ private function incidents(Carbon $startDate, Carbon $endDate): Collection }); }) ->get() + ->toBase() ->sortByDesc(fn (Incident $incident) => $incident->timestamp) ->groupBy(fn (Incident $incident) => $incident->timestamp->toDateString()); } @@ -140,6 +141,7 @@ private function schedules(Carbon $startDate, Carbon $endDate): Collection $startDate->endofDay()->toDateTimeString(), ])) ->get() + ->toBase() ->sortByDesc(fn (Schedule $schedule) => $schedule->completed_at) ->groupBy(fn (Schedule $schedule) => $schedule->completed_at->toDateString()); }