Skip to content
Merged
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
28 changes: 21 additions & 7 deletions database/seeders/DatabaseSeeder.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions resources/views/components/incident-timeline.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@
</div>

<div class="flex w-full flex-col gap-8">
@forelse ($incidents as $date => $incident)
<x-cachet::incident :date="$date" :incidents="$incident" />
@forelse ($timeline as $date => $day)
<x-cachet::incident :date="$date" :incidents="$day['incidents']" :schedules="$day['schedules']" />
@empty
<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">
{{ __('cachet::incident.timeline.no_incidents_reported_between', ['from' => $from, 'to' => $to]) }}
Expand Down
58 changes: 55 additions & 3 deletions resources/views/components/incident.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
@props([
'date',
'incidents',
'schedules' => [],
])

{{ \Cachet\Facades\CachetView::renderHook(\Cachet\View\RenderHook::STATUS_PAGE_INCIDENTS_BEFORE) }}
Expand All @@ -15,7 +16,7 @@
<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>
</div>

@forelse($incidents as $incident)
@foreach($incidents as $incident)
<div x-data="{ timestamp: new Date(@js($incident->timestamp)) }"
class="group relative rounded-lg bg-white shadow-sm ring-1 ring-zinc-900/10 dark:bg-zinc-900 dark:ring-white/15">
<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>
Expand Down Expand Up @@ -88,12 +89,63 @@ class="text-zinc-400 transition hover:text-zinc-700 dark:text-zinc-500 dark:hove
</div>
@endif
</div>
@empty
@endforeach

@foreach($schedules as $schedule)
<div x-data="{ timestamp: new Date(@js($schedule->completed_at)) }"
class="group relative rounded-lg bg-white shadow-sm ring-1 ring-zinc-900/10 dark:bg-zinc-900 dark:ring-white/15">
<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>

<div @class([
'flex flex-col gap-2 p-4 sm:p-6',
'border-b border-zinc-900/10 dark:border-white/15' => $schedule->updates->isNotEmpty(),
])>
@if ($schedule->components->isNotEmpty())
<div class="text-[11px] font-medium uppercase tracking-[0.08em] text-zinc-500 dark:text-zinc-400">
{{ $schedule->components->pluck('name')->join(', ', ' and ') }}
</div>
@endif

<div class="flex flex-col-reverse items-start justify-between gap-3 sm:flex-row sm:items-center">
<div class="flex flex-1 flex-col gap-1">
<h3 class="max-w-full break-words text-base font-semibold tracking-tight text-zinc-900 dark:text-zinc-100 sm:text-lg">
{{ $schedule->name }}
</h3>
<span class="text-xs text-zinc-500 dark:text-zinc-400">
{{ $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>
</span>
</div>
<div class="flex justify-start sm:justify-end">
<x-cachet::badge :status="$schedule->status" />
</div>
</div>

@if ($schedule->updates->isEmpty() && $schedule->formattedMessage())
<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>
@endif
</div>

@if ($schedule->updates->isNotEmpty())
<div class="flex flex-col divide-y divide-zinc-900/10 px-4 dark:divide-white/15 sm:px-6">
@foreach ($schedule->updates as $update)
<div class="relative py-5" x-data="{ timestamp: new Date(@js($update->created_at)) }">
<span class="text-xs text-zinc-500 dark:text-zinc-400">
{{ $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>
</span>
<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>
</div>
@endforeach
</div>
@endif
</div>
@endforeach

@if (count($incidents) === 0 && count($schedules) === 0)
<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">
<div class="prose-sm md:prose prose-zinc dark:prose-invert prose-a:text-accent-content prose-a:underline prose-p:leading-normal">
{{ __('cachet::incident.no_incidents_reported') }}
</div>
</div>
@endforelse
@endif
</div>
{{ \Cachet\Facades\CachetView::renderHook(\Cachet\View\RenderHook::STATUS_PAGE_INCIDENTS_AFTER) }}
2 changes: 2 additions & 0 deletions src/QueryBuilders/ScheduleBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
* @method static \Cachet\QueryBuilders\ScheduleBuilder inTheFuture()
* @method static \Cachet\QueryBuilders\ScheduleBuilder inThePast()
*
* @extends Builder<Schedule>
*
* @mixin Schedule
*/
class ScheduleBuilder extends Builder
Expand Down
70 changes: 59 additions & 11 deletions src/View/Components/IncidentTimeline.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -45,11 +46,39 @@ public function render(): View
]);
}

/**
* Build the timeline of incidents and completed maintenance, grouped by day.
*
* @return Collection<string, array{incidents: Collection<int, Incident>, schedules: Collection<int, Schedule>}>
*/
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<string, Collection<int, Incident>>
*/
private function incidents(Carbon $startDate, Carbon $endDate, bool $onlyDisruptedDays = false): Collection
private function incidents(Carbon $startDate, Carbon $endDate): Collection
{
return Incident::query()
->with([
Expand Down Expand Up @@ -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<string, Collection<int, Schedule>>
*/
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());
}
}
26 changes: 26 additions & 0 deletions tests/Feature/StatusPage/StatusPageTest.php
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
<?php

use Cachet\Models\Schedule;

it('renders the status page', function () {
$this->get(route('cachet.status-page'))
->assertOk();
Expand All @@ -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');
});
Loading