CluPilotCloud/resources/views/status.blade.php

219 lines
13 KiB
PHP

{{--
The public status page.
Built to the shape every status page a customer has ever read uses, because
that shape is a convention and a convention is what lets somebody find the
answer without being taught the page: banner, components with a ninety-day
bar, then the written record underneath.
It used to be a self-contained page with its own stylesheet, on the theory
that it should survive a broken asset build. It does not buy that: if the
stylesheet cannot be served, the application serving this page is already
failing, and mid-deployment the whole site answers with the maintenance
page. What it did buy was a third design nobody maintained. It draws from
the shared tokens now, and carries the site's own header and footer so the
reader can get back.
Every figure comes from a record. A day nobody sampled is drawn as a gap,
never as green.
--}}
@php
use App\Models\Incident;
$tone = [
'operational' => ['dot' => 'bg-success-bright', 'text' => 'text-success', 'bar' => 'bg-success-bright'],
'degraded' => ['dot' => 'bg-warning', 'text' => 'text-warning', 'bar' => 'bg-warning'],
'down' => ['dot' => 'bg-danger', 'text' => 'text-danger', 'bar' => 'bg-danger'],
'unknown' => ['dot' => 'bg-faint', 'text' => 'text-muted', 'bar' => 'bg-line-strong'],
];
$banner = [
'operational' => ['bg' => 'bg-success-bg border-success-border', 'icon' => 'check', 'text' => 'text-success'],
'degraded' => ['bg' => 'bg-warning-bg border-warning-border', 'icon' => 'alert-triangle', 'text' => 'text-warning'],
'down' => ['bg' => 'bg-danger-bg border-danger-border', 'icon' => 'alert-triangle', 'text' => 'text-danger'],
'unknown' => ['bg' => 'bg-surface-2 border-line-strong', 'icon' => 'alert-triangle', 'text' => 'text-muted'],
][$overall];
@endphp
<x-layouts.site :title="__('status.page.title').' CluPilot Cloud'" robots="noindex">
<div class="mx-auto max-w-[880px] px-5 pb-24 pt-32 sm:px-6 lg:pt-36">
{{-- ── The one sentence somebody came for ───────────────────────────── --}}
<p class="lbl">{{ __('status.page.title') }}</p>
<div class="mt-4 flex items-center gap-4 rounded-xl border {{ $banner['bg'] }} px-6 py-5">
<x-ui.icon name="{{ $banner['icon'] }}" class="size-6 shrink-0 {{ $banner['text'] }}" />
<h1 class="text-xl font-bold tracking-[-0.025em] {{ $banner['text'] }}">{{ __("status.overall.{$overall}") }}</h1>
</div>
<p class="mt-3 font-mono text-xs text-muted">
{{ __('status.page.checked_at', ['time' => $checkedAt->local()->isoFormat('DD.MM.YYYY, HH:mm')]) }}
· {{ __('status.page.timezone', ['zone' => config('app.display_timezone')]) }}
</p>
{{-- ── Announced work ───────────────────────────────────────────────── --}}
@if ($maintenance->isNotEmpty())
<section class="mt-8">
<h2 class="lbl">{{ __('status.maintenance.heading') }}</h2>
<div class="mt-3 space-y-3">
@foreach ($maintenance as $window)
@php $live = $window->derivedState() === 'active'; @endphp
<div class="rounded-lg border border-info-border bg-info-bg px-5 py-4">
<div class="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<b class="font-semibold text-ink">{{ $window->title }}</b>
<span class="rounded-pill bg-info px-2 py-0.5 font-mono text-[10px] font-medium uppercase tracking-[0.07em] text-white">
{{ $live ? __('status.maintenance.active') : __('status.maintenance.upcoming') }}
</span>
</div>
<p class="mt-1 font-mono text-xs text-info">
{{ __('status.maintenance.window', [
'from' => $window->starts_at->local()->isoFormat('DD.MM. HH:mm'),
'to' => $window->ends_at->local()->isoFormat('DD.MM. HH:mm'),
]) }}
</p>
@if ($window->public_description)
<p class="mt-2 text-sm leading-relaxed text-body">{{ $window->public_description }}</p>
@endif
</div>
@endforeach
</div>
</section>
@endif
{{-- ── Whatever is broken right now, before the components ──────────── --}}
@foreach ($ongoing as $incident)
<section class="mt-8 overflow-hidden rounded-xl border border-danger-border bg-surface shadow-xs">
<div class="flex flex-wrap items-center gap-x-3 gap-y-2 border-b border-line bg-danger-bg px-6 py-4">
<span class="lbl !text-danger">{{ __('status.incident.ongoing') }}</span>
<h2 class="w-full text-lg font-bold tracking-[-0.02em] text-ink">{{ $incident->title }}</h2>
</div>
<x-status.timeline :incident="$incident" />
</section>
@endforeach
{{-- ── The components, each with its ninety days ─────────────────────── --}}
<section class="mt-10 overflow-hidden rounded-xl border border-line bg-surface shadow-xs">
@foreach ($components as $component)
@php
$state = $component['state'];
$bars = $history[$component['key']] ?? ['days' => [], 'uptime' => null];
@endphp
<div class="border-b border-line px-6 py-6 last:border-0">
<div class="flex flex-wrap items-center gap-x-3 gap-y-1">
<h3 class="font-semibold text-ink">{{ __("status.component.{$component['key']}") }}</h3>
<span class="ml-auto flex items-center gap-2 text-sm font-medium {{ $tone[$state]['text'] }}">
<span class="size-2 rounded-pill {{ $tone[$state]['dot'] }}"></span>
{{ __("status.state.{$state}") }}
</span>
</div>
<p class="mt-1 text-sm text-muted">
{{ __("status.about.{$component['key']}") }}
@if (! empty($component['detail']))
<span class="{{ $tone[$state]['text'] }}">{{ __("status.detail.{$component['key']}", $component['detail']) }}</span>
@endif
</p>
{{-- The bar. One column per day, oldest on the left, the same
shape a reader has seen on every other status page — which
is the reason to use it. --}}
<div class="mt-4 flex h-9 items-stretch gap-[2px]" role="img"
aria-label="{{ __('status.history.heading', ['days' => $historyDays]) }}">
@foreach ($bars['days'] as $day)
<span
class="flex-1 rounded-[1px] transition-opacity hover:opacity-60 {{ $day['state'] === null ? 'bg-surface-hover' : $tone[$day['state']]['bar'] }}"
title="{{ $day['date']->local()->isoFormat('DD.MM.YYYY') }} —{{ $day['state'] === null ? __('status.history.unmeasured') : __("status.state.{$day['state']}") }}{{ $day['uptime'] !== null ? ' · '.__('status.history.uptime', ['percent' => number_format($day['uptime'], 2, ',', '.')]) : '' }}"
></span>
@endforeach
</div>
<div class="mt-2 flex items-center gap-3 font-mono text-[11px] text-muted">
<span>{{ __('status.history.from', ['days' => $historyDays]) }}</span>
<span class="h-px flex-1 bg-line"></span>
<span>
@if ($bars['uptime'] !== null)
{{ __('status.history.uptime', ['percent' => number_format($bars['uptime'], 2, ',', '.')]) }}
@else
{{ __('status.history.unmeasured') }}
@endif
</span>
<span class="h-px flex-1 bg-line"></span>
<span>{{ __('status.history.today') }}</span>
</div>
</div>
@endforeach
</section>
<p class="mt-3 flex flex-wrap items-center gap-x-5 gap-y-2 font-mono text-[11px] text-muted">
@foreach (['operational', 'degraded', 'down'] as $legend)
<span class="flex items-center gap-1.5"><span class="size-2 rounded-[1px] {{ $tone[$legend]['bar'] }}"></span>{{ __("status.state.{$legend}") }}</span>
@endforeach
<span class="flex items-center gap-1.5"><span class="size-2 rounded-[1px] bg-surface-hover"></span>{{ __('status.history.legend_gap') }}</span>
</p>
{{-- ── The record ───────────────────────────────────────────────────── --}}
<section class="mt-16">
<h2 class="text-lg font-bold tracking-[-0.02em] text-ink">{{ __('status.incident.past_heading') }}</h2>
<p class="mt-1 text-sm text-muted">{{ __('status.page.subscribe') }}</p>
@if ($past->isEmpty())
<div class="mt-6 rounded-xl border border-line bg-surface px-6 py-10 text-center shadow-xs">
<x-ui.icon name="check" class="mx-auto size-6 text-success" />
<p class="mt-3 text-sm text-muted">{{ __('status.incident.none', ['days' => $historyDays]) }}</p>
</div>
@else
<div class="mt-6 space-y-8">
@foreach ($past as $day => $incidents)
<div>
{{-- The day heading comes from an incident's own
timestamp rather than from the group key. The key
is a bare 'Y-m-d' string; parsing it back yields
midnight UTC, and converting THAT to the display
zone lands on the previous evening a heading two
hours wide enough to name the wrong day. --}}
<h3 class="lbl border-b border-line pb-2">
{{ $incidents->first()->started_at->local()->isoFormat('D. MMMM YYYY') }}
</h3>
<div class="mt-4 space-y-4">
@foreach ($incidents as $incident)
<article class="overflow-hidden rounded-xl border border-line bg-surface shadow-xs">
<div class="border-b border-line px-6 py-4">
<div class="flex flex-wrap items-center gap-x-3 gap-y-2">
<h4 class="font-semibold text-ink">{{ $incident->title }}</h4>
<span class="rounded-pill border border-line bg-surface-2 px-2.5 py-0.5 font-mono text-[10px] font-medium uppercase tracking-[0.07em] text-muted">
{{ __("status.incident.impact.{$incident->impact}") }}
</span>
</div>
<dl class="mt-3 flex flex-wrap gap-x-8 gap-y-1 font-mono text-xs text-muted">
<div class="flex gap-2">
<dt>{{ __('status.incident.started') }}</dt>
<dd class="text-ink">{{ $incident->started_at->local()->isoFormat('DD.MM. HH:mm') }}</dd>
</div>
<div class="flex gap-2">
<dt>{{ __('status.incident.resolved') }}</dt>
<dd class="text-ink">{{ $incident->resolved_at->local()->isoFormat('DD.MM. HH:mm') }}</dd>
</div>
<div class="flex gap-2">
<dt>{{ __('status.incident.duration') }}</dt>
<dd class="text-ink"><x-status.duration :minutes="$incident->durationMinutes()" /></dd>
</div>
<div class="flex gap-2">
<dt>{{ __('status.incident.affected') }}</dt>
<dd class="text-ink">{{ collect($incident->components)->map(fn ($c) => __("status.component.{$c}"))->join(', ') }}</dd>
</div>
</dl>
</div>
<x-status.timeline :incident="$incident" />
</article>
@endforeach
</div>
</div>
@endforeach
</div>
@endif
</section>
</div>
</x-layouts.site>