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/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 1d6c96ac..3e5dd542 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([ @@ -86,15 +115,34 @@ private function incidents(Carbon $startDate, Carbon $endDate, bool $onlyDisrupt }); }) ->get() + ->toBase() ->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() + ->toBase() + ->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'); +});