219 lines
13 KiB
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>
|