Skip to content

Commit b531b0e

Browse files
jbrooksukclaude
andauthored
Move completed maintenance into the timeline (#334)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 4e6343d commit b531b0e

6 files changed

Lines changed: 165 additions & 23 deletions

File tree

database/seeders/DatabaseSeeder.php

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -55,13 +55,6 @@ public function run(): void
5555
'is_admin' => true,
5656
]);
5757

58-
Schedule::create([
59-
'name' => 'Documentation Maintenance',
60-
'message' => 'We will be conducting maintenance on our documentation servers. Documentation may not be available during this time.',
61-
'scheduled_at' => now()->subHours(12)->subMinutes(45),
62-
'completed_at' => now()->subHours(12),
63-
]);
64-
6558
/** @phpstan-ignore-next-line argument.type */
6659
tap(Schedule::create([
6760
'name' => 'Documentation Maintenance',
@@ -83,6 +76,27 @@ public function run(): void
8376
$schedule->updates()->save($update);
8477
});
8578

79+
/** @phpstan-ignore-next-line argument.type */
80+
tap(Schedule::create([
81+
'name' => 'Database Server Upgrade',
82+
'message' => 'We upgraded our primary database servers to improve performance and reliability.',
83+
'scheduled_at' => now()->subHours(26),
84+
'completed_at' => now()->subHours(24),
85+
/** @phpstan-ignore-next-line argument.type */
86+
]), function (Schedule $schedule) use ($user) {
87+
$update = new Update([
88+
'message' => <<<'EOF'
89+
Maintenance is underway. We are migrating data to the upgraded database servers.
90+
EOF
91+
,
92+
'user_id' => $user->id,
93+
'created_at' => $timestamp = $schedule->scheduled_at->addMinutes(30),
94+
'updated_at' => $timestamp,
95+
]);
96+
97+
$schedule->updates()->save($update);
98+
});
99+
86100
$componentGroup = ComponentGroup::create([
87101
'name' => 'Cachet',
88102
'collapsed' => ComponentGroupVisibilityEnum::expanded,

resources/views/components/incident-timeline.blade.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,8 @@
3232
</div>
3333

3434
<div class="flex w-full flex-col gap-8">
35-
@forelse ($incidents as $date => $incident)
36-
<x-cachet::incident :date="$date" :incidents="$incident" />
35+
@forelse ($timeline as $date => $day)
36+
<x-cachet::incident :date="$date" :incidents="$day['incidents']" :schedules="$day['schedules']" />
3737
@empty
3838
<div class="rounded-lg bg-white px-5 py-10 text-center text-sm text-zinc-500 shadow-sm ring-1 ring-zinc-900/10 dark:bg-zinc-900 dark:text-zinc-400 dark:ring-white/15">
3939
{{ __('cachet::incident.timeline.no_incidents_reported_between', ['from' => $from, 'to' => $to]) }}

resources/views/components/incident.blade.php

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
@props([
33
'date',
44
'incidents',
5+
'schedules' => [],
56
])
67

78
{{ \Cachet\Facades\CachetView::renderHook(\Cachet\View\RenderHook::STATUS_PAGE_INCIDENTS_BEFORE) }}
@@ -15,7 +16,7 @@
1516
<div aria-hidden="true" class="h-px flex-1 bg-gradient-to-r from-zinc-900/15 via-zinc-900/5 to-transparent dark:from-white/15 dark:via-white/5"></div>
1617
</div>
1718

18-
@forelse($incidents as $incident)
19+
@foreach($incidents as $incident)
1920
<div x-data="{ timestamp: new Date(@js($incident->timestamp)) }"
2021
class="group relative rounded-lg bg-white shadow-sm ring-1 ring-zinc-900/10 dark:bg-zinc-900 dark:ring-white/15">
2122
<div class="pointer-events-none absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-accent/40 to-transparent" aria-hidden="true"></div>
@@ -88,12 +89,63 @@ class="text-zinc-400 transition hover:text-zinc-700 dark:text-zinc-500 dark:hove
8889
</div>
8990
@endif
9091
</div>
91-
@empty
92+
@endforeach
93+
94+
@foreach($schedules as $schedule)
95+
<div x-data="{ timestamp: new Date(@js($schedule->completed_at)) }"
96+
class="group relative rounded-lg bg-white shadow-sm ring-1 ring-zinc-900/10 dark:bg-zinc-900 dark:ring-white/15">
97+
<div class="pointer-events-none absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-accent/40 to-transparent" aria-hidden="true"></div>
98+
99+
<div @class([
100+
'flex flex-col gap-2 p-4 sm:p-6',
101+
'border-b border-zinc-900/10 dark:border-white/15' => $schedule->updates->isNotEmpty(),
102+
])>
103+
@if ($schedule->components->isNotEmpty())
104+
<div class="text-[11px] font-medium uppercase tracking-[0.08em] text-zinc-500 dark:text-zinc-400">
105+
{{ $schedule->components->pluck('name')->join(', ', ' and ') }}
106+
</div>
107+
@endif
108+
109+
<div class="flex flex-col-reverse items-start justify-between gap-3 sm:flex-row sm:items-center">
110+
<div class="flex flex-1 flex-col gap-1">
111+
<h3 class="max-w-full break-words text-base font-semibold tracking-tight text-zinc-900 dark:text-zinc-100 sm:text-lg">
112+
{{ $schedule->name }}
113+
</h3>
114+
<span class="text-xs text-zinc-500 dark:text-zinc-400">
115+
{{ $schedule->completed_at->diffForHumans() }} <span class="text-zinc-300 dark:text-zinc-600">·</span> <time datetime="{{ $schedule->completed_at->toW3cString() }}" x-text="timestamp.toLocaleString(@if($appSettings->timezone !== '-')undefined, {timeZone: '{{$appSettings->timezone}}'}@endif )"></time>
116+
</span>
117+
</div>
118+
<div class="flex justify-start sm:justify-end">
119+
<x-cachet::badge :status="$schedule->status" />
120+
</div>
121+
</div>
122+
123+
@if ($schedule->updates->isEmpty() && $schedule->formattedMessage())
124+
<div class="prose-sm md:prose prose-zinc dark:prose-invert prose-a:text-accent-content prose-a:underline prose-p:leading-normal mt-2">{!! $schedule->formattedMessage() !!}</div>
125+
@endif
126+
</div>
127+
128+
@if ($schedule->updates->isNotEmpty())
129+
<div class="flex flex-col divide-y divide-zinc-900/10 px-4 dark:divide-white/15 sm:px-6">
130+
@foreach ($schedule->updates as $update)
131+
<div class="relative py-5" x-data="{ timestamp: new Date(@js($update->created_at)) }">
132+
<span class="text-xs text-zinc-500 dark:text-zinc-400">
133+
{{ $update->created_at->diffForHumans() }} <span class="text-zinc-300 dark:text-zinc-600">·</span> <time datetime="{{ $update->created_at->toW3cString() }}" x-text="timestamp.toLocaleString(@if($appSettings->timezone !== '-')undefined, {timeZone: '{{$appSettings->timezone}}'}@endif )"></time>
134+
</span>
135+
<div class="prose-sm md:prose prose-zinc dark:prose-invert prose-a:text-accent-content prose-a:underline prose-p:leading-normal mt-2">{!! $update->formattedMessage() !!}</div>
136+
</div>
137+
@endforeach
138+
</div>
139+
@endif
140+
</div>
141+
@endforeach
142+
143+
@if (count($incidents) === 0 && count($schedules) === 0)
92144
<div class="rounded-lg bg-white p-5 shadow-sm ring-1 ring-zinc-900/10 dark:bg-zinc-900 dark:ring-white/15 sm:p-6">
93145
<div class="prose-sm md:prose prose-zinc dark:prose-invert prose-a:text-accent-content prose-a:underline prose-p:leading-normal">
94146
{{ __('cachet::incident.no_incidents_reported') }}
95147
</div>
96148
</div>
97-
@endforelse
149+
@endif
98150
</div>
99151
{{ \Cachet\Facades\CachetView::renderHook(\Cachet\View\RenderHook::STATUS_PAGE_INCIDENTS_AFTER) }}

src/QueryBuilders/ScheduleBuilder.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
* @method static \Cachet\QueryBuilders\ScheduleBuilder inTheFuture()
1515
* @method static \Cachet\QueryBuilders\ScheduleBuilder inThePast()
1616
*
17+
* @extends Builder<Schedule>
18+
*
1719
* @mixin Schedule
1820
*/
1921
class ScheduleBuilder extends Builder

src/View/Components/IncidentTimeline.php

Lines changed: 59 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
namespace Cachet\View\Components;
44

55
use Cachet\Models\Incident;
6+
use Cachet\Models\Schedule;
67
use Cachet\Settings\AppSettings;
78
use Illuminate\Contracts\View\View;
89
use Illuminate\Database\Eloquent\Builder;
@@ -30,7 +31,7 @@ public function render(): View
3031
$endDate = $startDate->clone()->subDays($incidentDays);
3132

3233
return view('cachet::components.incident-timeline', [
33-
'incidents' => $this->incidents(
34+
'timeline' => $this->timeline(
3435
$startDate,
3536
$endDate,
3637
$this->appSettings->only_disrupted_days
@@ -45,11 +46,39 @@ public function render(): View
4546
]);
4647
}
4748

49+
/**
50+
* Build the timeline of incidents and completed maintenance, grouped by day.
51+
*
52+
* @return Collection<string, array{incidents: Collection<int, Incident>, schedules: Collection<int, Schedule>}>
53+
*/
54+
private function timeline(Carbon $startDate, Carbon $endDate, bool $onlyDisruptedDays = false): Collection
55+
{
56+
$incidents = $this->incidents($startDate, $endDate);
57+
$schedules = $this->schedules($startDate, $endDate);
58+
59+
return collect($endDate->toPeriod($startDate))
60+
->keyBy(fn ($period) => $period->toDateString())
61+
->map(fn ($period) => collect())
62+
->union($incidents)
63+
->union($schedules)
64+
->keys()
65+
->mapWithKeys(fn (string $date) => [$date => [
66+
'incidents' => $incidents->get($date, collect()),
67+
'schedules' => $schedules->get($date, collect()),
68+
]])
69+
->when($onlyDisruptedDays, fn ($collection) => $collection->filter(
70+
fn (array $day) => $day['incidents']->isNotEmpty() || $day['schedules']->isNotEmpty()
71+
))
72+
->sortKeysDesc();
73+
}
74+
4875
/**
4976
* Fetch the incidents that occurred between the given start and end date.
5077
* Incidents will be grouped by days.
78+
*
79+
* @return Collection<string, Collection<int, Incident>>
5180
*/
52-
private function incidents(Carbon $startDate, Carbon $endDate, bool $onlyDisruptedDays = false): Collection
81+
private function incidents(Carbon $startDate, Carbon $endDate): Collection
5382
{
5483
return Incident::query()
5584
->with([
@@ -86,15 +115,34 @@ private function incidents(Carbon $startDate, Carbon $endDate, bool $onlyDisrupt
86115
});
87116
})
88117
->get()
118+
->toBase()
89119
->sortByDesc(fn (Incident $incident) => $incident->timestamp)
90-
->groupBy(fn (Incident $incident) => $incident->timestamp->toDateString())
91-
->union(
92-
// Back-fill any missing dates...
93-
collect($endDate->toPeriod($startDate))
94-
->keyBy(fn ($period) => $period->toDateString())
95-
->map(fn ($period) => collect())
96-
)
97-
->when($onlyDisruptedDays, fn ($collection) => $collection->filter(fn ($incidents) => $incidents->isNotEmpty()))
98-
->sortKeysDesc();
120+
->groupBy(fn (Incident $incident) => $incident->timestamp->toDateString());
121+
}
122+
123+
/**
124+
* Fetch the completed maintenance that occurred between the given start and end date.
125+
* Schedules will be grouped by the day they completed.
126+
*
127+
* @return Collection<string, Collection<int, Schedule>>
128+
*/
129+
private function schedules(Carbon $startDate, Carbon $endDate): Collection
130+
{
131+
return Schedule::query()
132+
->with(['components', 'updates' => fn ($query) => $query->orderByDesc('created_at')])
133+
->inThePast()
134+
->when($this->appSettings->recent_incidents_only, fn ($query) => $query->whereDate(
135+
'completed_at',
136+
'>',
137+
Carbon::now()->subDays($this->appSettings->recent_incidents_days)->format('Y-m-d')
138+
))
139+
->when(! $this->appSettings->recent_incidents_only, fn ($query) => $query->whereBetween('completed_at', [
140+
$endDate->startOfDay()->toDateTimeString(),
141+
$startDate->endofDay()->toDateTimeString(),
142+
]))
143+
->get()
144+
->toBase()
145+
->sortByDesc(fn (Schedule $schedule) => $schedule->completed_at)
146+
->groupBy(fn (Schedule $schedule) => $schedule->completed_at->toDateString());
99147
}
100148
}

tests/Feature/StatusPage/StatusPageTest.php

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
<?php
22

3+
use Cachet\Models\Schedule;
4+
35
it('renders the status page', function () {
46
$this->get(route('cachet.status-page'))
57
->assertOk();
@@ -14,3 +16,27 @@
1416
$this->get(route('cachet.status-page', ['from' => 'not-a-date']))
1517
->assertOk();
1618
});
19+
20+
it('shows upcoming and in progress maintenance in the maintenance block', function () {
21+
$upcoming = Schedule::factory()->inTheFuture()->create(['name' => 'Upcoming maintenance']);
22+
$inProgress = Schedule::factory()->inProgress()->create(['name' => 'In progress maintenance']);
23+
$completed = Schedule::factory()->inThePast()->create(['name' => 'Completed maintenance']);
24+
25+
$response = $this->get(route('cachet.status-page'))->assertOk();
26+
27+
$maintenanceBlock = $response->viewData('schedules');
28+
29+
expect($maintenanceBlock->pluck('id'))
30+
->toContain($upcoming->id, $inProgress->id)
31+
->not->toContain($completed->id);
32+
});
33+
34+
it('shows completed maintenance in the timeline instead of the maintenance block', function () {
35+
$completed = Schedule::factory()->completed()->create(['name' => 'Completed maintenance']);
36+
37+
$response = $this->get(route('cachet.status-page'))->assertOk();
38+
39+
expect($response->viewData('schedules')->pluck('id'))->not->toContain($completed->id);
40+
41+
$response->assertSee('Completed maintenance');
42+
});

0 commit comments

Comments
 (0)