Rebuild the status page as a status page

Asked for after looking at how real ones are built. The shape they all share
is a convention, and a convention is what lets somebody find the answer
without being taught the page: banner, components each with ninety days of
uptime, then the written record underneath. This page had only the banner —
the version that is of no use the morning after, because "Alle Dienste in
Betrieb" is true and says nothing to somebody who was locked out the evening
before.

Three things are new.

The ninety-day bar. It cannot be reconstructed after the fact: the monitoring
table holds the last verdict per instance, not a history. So a sampler
(clupilot:sample-status, every five minutes beside the monitoring sync)
writes counts into one row per component per day, and the page divides them.
A day with no row is drawn as a gap and says "nicht aufgezeichnet" — never
green. Degraded samples count as reachable in the percentage but still colour
the day: folding them into downtime reports a slow morning as an outage,
ignoring them loses the only trace of it.

The incident record. An operator reports a disruption in the console, posts
updates as it develops, and the entry that says "behoben" is the same action
that closes it — two separate steps is how a page ends up with a resolved
note under an incident that still reports as ongoing. There is deliberately
no way to edit an update: it is a statement made at a time, corrections are a
further entry. An open incident may make a component look worse than the
probes found it, never better.

Scheduled maintenance, from the windows the console already keeps rather than
a second list somebody has to remember to fill in.

The measurement moved out of the controller into ServiceHealth, shared with
the sampler, so the banner and the bars cannot disagree about what "healthy"
means.

The page now uses the shared design system and the site's header and footer.
Its self-contained stylesheet was there to survive a broken asset build; it
does not survive one — if the stylesheet cannot be served the application
serving this page is already failing, and mid-deployment every route answers
with the maintenance page. What the exemption bought was a third design
nobody maintained.

Found while building: durationMinutes had its operands the wrong way round,
and diffInMinutes is signed, so the public page printed "Dauer -86 Minuten".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat/plan-marketing v1.3.6
nexxo 2026-07-29 12:45:18 +02:00
parent 3b85e8f1d1
commit b85df4c141
25 changed files with 1737 additions and 273 deletions

View File

@ -1 +1 @@
1.3.5
1.3.6

View File

@ -0,0 +1,78 @@
<?php
namespace App\Console\Commands;
use App\Models\StatusDay;
use App\Services\Status\ServiceHealth;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
/**
* Writes one sample of every public component into today's row.
*
* The ninety-day bar on the status page is the one figure a reader checks
* against their own memory, so it has to come from something that was actually
* recorded at the time. There is no way to reconstruct it afterwards: the
* monitoring table holds the LAST verdict per instance, not a history, and by
* the time anybody asks about last Tuesday that verdict is about today.
*
* Runs on the scheduler beside the monitoring sync it depends on. A missed run
* costs one sample out of roughly three hundred in a day; a missed DAY leaves
* no row at all, and the page draws that as "not recorded" rather than
* inventing a colour for it.
*/
class SampleServiceStatus extends Command
{
protected $signature = 'clupilot:sample-status';
protected $description = 'Record the current state of every public status component into the daily history';
public function handle(ServiceHealth $health): int
{
// The wall clock, not UTC: the bar is labelled with days a reader
// recognises, and a sample taken at 01:30 local belongs to the day they
// would call it (R19).
$day = Carbon::now()->local()->toDateString();
foreach ($health->components() as $component) {
$state = $component['state'];
// One statement, so two schedulers racing on the same minute cannot
// read-modify-write over each other. The counters are increments,
// never assignments — a sample is a thing that happened, and a
// later run must not be able to undo it.
DB::table('status_days')->upsert(
[[
'day' => $day,
'component' => $component['key'],
'samples' => 1,
'operational' => $state === 'operational' ? 1 : 0,
'degraded' => $state === 'degraded' ? 1 : 0,
'down' => $state === 'down' ? 1 : 0,
'unknown' => $state === 'unknown' ? 1 : 0,
'created_at' => now(),
'updated_at' => now(),
]],
['day', 'component'],
[
'samples' => DB::raw('samples + 1'),
'operational' => DB::raw('operational + '.($state === 'operational' ? 1 : 0)),
'degraded' => DB::raw('degraded + '.($state === 'degraded' ? 1 : 0)),
'down' => DB::raw('down + '.($state === 'down' ? 1 : 0)),
'unknown' => DB::raw('unknown + '.($state === 'unknown' ? 1 : 0)),
'updated_at' => DB::raw('CURRENT_TIMESTAMP'),
],
);
}
// Beyond the window the page can draw, the rows are only weight. Kept a
// fortnight longer than the ninety days shown so a change of window
// does not immediately hit an empty table.
StatusDay::query()
->where('day', '<', Carbon::now()->local()->subDays(104)->toDateString())
->delete();
return self::SUCCESS;
}
}

View File

@ -2,193 +2,191 @@
namespace App\Http\Controllers;
use App\Models\Instance;
use App\Models\MonitoringTarget;
use App\Models\ProvisioningRun;
use App\Models\Incident;
use App\Models\MaintenanceWindow;
use App\Models\StatusDay;
use App\Services\Status\ServiceHealth;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
/**
* The public service status page.
*
* It lived at /legal/status, next to the imprint and the terms, which is a
* filing mistake: nothing about the current health of the platform is a legal
* document. It has its own address now.
* It answers three questions, and they are different questions: what is
* happening now, what has happened, and what is about to happen. The page used
* to answer only the first, which is the version of a status page that is of no
* use the morning after "Alle Dienste in Betrieb" is true and says nothing to
* somebody who was locked out the evening before.
*
* Every line on it is derived from a record. Where there is no signal the
* component says "not monitored" rather than "operational" a status page that
* reports green because it has nothing to look at is worse than no status page,
* because someone will believe it.
* The measurement itself lives in ServiceHealth, shared with the sampler that
* writes the history, so the banner and the bars cannot disagree.
*
* Aggregate only: counts, never customer names, instance addresses or host
* names. This page is world-readable and the estate is not public information.
*/
class StatusController extends Controller
{
/** A backup older than this stops counting as current. */
private const BACKUP_STALE_AFTER_HOURS = 48;
/** How much history the bars show. Ninety days is the convention. */
private const HISTORY_DAYS = 90;
/**
* How old a monitoring verdict may be before it stops counting.
*
* The sync job runs every five minutes; four missed runs is a monitoring
* pipeline that has stopped, and a verdict from then says nothing about now.
*/
private const MONITORING_STALE_AFTER_MINUTES = 20;
/** How far back the written record goes on this page. */
private const INCIDENT_DAYS = 90;
/** How far back a provisioning failure still says something about now. */
private const PROVISIONING_WINDOW_HOURS = 24;
public function __invoke(): View
public function __invoke(ServiceHealth $health): View
{
$components = [
$this->portal(),
$this->instances(),
$this->provisioning(),
$this->backups(),
];
$components = $health->components();
// The worst individual state is the state of the whole thing. Averaging
// it would let one outage disappear behind three healthy components.
$overall = match (true) {
in_array('down', array_column($components, 'state'), true) => 'down',
in_array('degraded', array_column($components, 'state'), true) => 'degraded',
in_array('unknown', array_column($components, 'state'), true) => 'unknown',
default => 'operational',
};
// An ongoing incident overrides the measurement. Monitoring can be
// perfectly happy while something is broken in a way it does not look
// at, and in that situation the operator's word beats the probe's.
$ongoing = Incident::query()
->published()->ongoing()
->with('updates')
->orderByDesc('started_at')
->get();
$components = $this->applyIncidents($components, $ongoing);
return view('status', [
'components' => $components,
'overall' => $overall,
'overall' => $health->overall($components),
'history' => $this->history(),
'ongoing' => $ongoing,
'past' => $this->past(),
'maintenance' => $this->maintenance(),
'checkedAt' => Carbon::now(),
'historyDays' => self::HISTORY_DAYS,
]);
}
/**
* The portal and the website.
* Let an open incident set the state of the components it names.
*
* Answering this request is the measurement. There is no honest way for a
* page to report that the server serving it is down.
* Only downwards. An incident cannot make a component look better than the
* probes found it that direction is how a status page ends up being used
* to hide something.
*
* @return array<string, mixed>
* @param array<int, array<string, mixed>> $components
* @param Collection<int, Incident> $ongoing
* @return array<int, array<string, mixed>>
*/
private function portal(): array
private function applyIncidents(array $components, Collection $ongoing): array
{
return [
'key' => 'portal',
'state' => 'operational',
'detail' => null,
];
}
$rank = ['operational' => 0, 'unknown' => 1, 'degraded' => 2, 'down' => 3];
/**
* Customer instances, from what monitoring last saw.
*
* @return array<string, mixed>
*/
private function instances(): array
{
$total = Instance::query()->where('status', 'active')->count();
foreach ($components as $i => $component) {
foreach ($ongoing as $incident) {
if (! in_array($component['key'], $incident->components ?? [], true)) {
continue;
}
if ($total === 0) {
return ['key' => 'instances', 'state' => 'operational', 'detail' => null];
// Maintenance is announced work, not a fault. It shows on the
// component without claiming anything is broken.
$state = $incident->impact === Incident::IMPACT_MAINTENANCE ? 'degraded' : $incident->impact;
if (($rank[$state] ?? 0) > ($rank[$component['state']] ?? 0)) {
$components[$i]['state'] = $state;
$components[$i]['detail'] = null;
}
$components[$i]['incident'] = $incident;
}
}
// Only targets belonging to instances that are actually in service. A
// healthy check on a decommissioned instance says nothing about a live
// one, and counting it would let coverage look complete when it is not.
$onActive = fn ($query) => $query->whereHas(
'instance',
fn ($instance) => $instance->where('status', 'active'),
);
// Only verdicts the sync job has actually refreshed. A row that has
// never been checked, or was last checked long enough ago that the
// answer means nothing, is not evidence of health — and this column was
// for a long time exactly that: written 'up' at provisioning and never
// touched again.
$fresh = Carbon::now()->subMinutes(self::MONITORING_STALE_AFTER_MINUTES);
$checked = fn ($query) => $query->tap($onActive)->where('checked_at', '>=', $fresh);
$watched = MonitoringTarget::query()->tap($checked)->distinct()->count('instance_id');
$down = MonitoringTarget::query()->tap($checked)->where('status', '!=', 'up')->distinct()->count('instance_id');
// Down first, then coverage. An instance nobody is watching is not a
// healthy instance — it is an unanswered question, and reporting the
// absence of a check as the absence of a problem is the one thing this
// page must never do.
return [
'key' => 'instances',
'state' => match (true) {
$down > 0 && $down >= $watched => 'down',
$down > 0 => 'degraded',
$watched < $total => 'unknown',
default => 'operational',
},
'detail' => match (true) {
$down > 0 => ['down' => $down, 'total' => $watched],
$watched < $total => ['down' => $total - $watched, 'total' => $total],
default => null,
},
];
return $components;
}
/**
* Whether new instances are being delivered.
* The daily bars, one array per component, oldest first.
*
* @return array<string, mixed>
*/
private function provisioning(): array
{
$since = Carbon::now()->subHours(self::PROVISIONING_WINDOW_HOURS);
$failed = ProvisioningRun::query()
->where('status', ProvisioningRun::STATUS_FAILED)
->where('updated_at', '>=', $since)
->count();
return [
'key' => 'provisioning',
'state' => $failed === 0 ? 'operational' : 'degraded',
'detail' => $failed > 0 ? ['failed' => $failed] : null,
];
}
/**
* Whether the last backup of every instance is recent.
* Every day in the window appears, including the ones nobody measured
* they come back as null and are drawn as a gap. Filling them in from the
* days around them would be inventing the only number on this page a reader
* can check against their own memory.
*
* @return array<string, mixed>
* @return array<string, array{days: array<int, array<string, mixed>>, uptime: float|null}>
*/
private function backups(): array
private function history(): array
{
// Counted from the INSTANCES that need protecting, not from the backup
// rows that happen to exist. An active instance with no schedule at all
// has no row — so counting rows would leave it out of the arithmetic
// entirely and report the estate as protected because the backups that
// do exist are fine.
$total = Instance::query()->where('status', 'active')->count();
$from = Carbon::now()->local()->startOfDay()->subDays(self::HISTORY_DAYS - 1);
if ($total === 0) {
return ['key' => 'backups', 'state' => 'operational', 'detail' => null];
$rows = StatusDay::query()
->where('day', '>=', $from->toDateString())
->get()
->groupBy('component');
$history = [];
foreach (ServiceHealth::COMPONENTS as $component) {
$byDay = ($rows[$component] ?? collect())->keyBy(fn (StatusDay $d) => $d->day->toDateString());
$days = [];
$measured = 0;
$up = 0;
for ($i = 0; $i < self::HISTORY_DAYS; $i++) {
$date = $from->copy()->addDays($i);
$row = $byDay[$date->toDateString()] ?? null;
$days[] = [
'date' => $date,
'state' => $row?->worst(),
'uptime' => $row?->uptimePercent(),
];
if ($row !== null) {
$measured += $row->samples - $row->unknown;
$up += $row->operational + $row->degraded;
}
}
$history[$component] = [
'days' => $days,
// Over the whole window, not the mean of the daily figures: a
// day with four samples must not weigh the same as a day with
// three hundred.
'uptime' => $measured > 0 ? round($up / $measured * 100, 2) : null,
];
}
$fresh = Carbon::now()->subHours(self::BACKUP_STALE_AFTER_HOURS);
return $history;
}
$protected = Instance::query()
->where('status', 'active')
->whereHas('backups', fn ($backup) => $backup->where('last_ok_at', '>=', $fresh))
->count();
/**
* Resolved incidents, newest first, grouped by the day they started.
*
* @return Collection<string, Collection<int, Incident>>
*/
private function past(): Collection
{
return Incident::query()
->published()
->whereNotNull('resolved_at')
->where('started_at', '>=', Carbon::now()->subDays(self::INCIDENT_DAYS))
->with('updates')
->orderByDesc('started_at')
->get()
->groupBy(fn (Incident $incident) => $incident->started_at->local()->toDateString());
}
$unprotected = $total - $protected;
return [
'key' => 'backups',
'state' => match (true) {
$unprotected === 0 => 'operational',
$unprotected >= $total => 'down',
default => 'degraded',
},
'detail' => $unprotected > 0 ? ['stale' => $unprotected, 'total' => $total] : null,
];
/**
* Announced work that has not finished yet.
*
* Taken from the maintenance windows the console already keeps rather than
* from a second list somebody has to remember to fill in. Published ones
* only: a draft is a plan, not an announcement.
*
* @return Collection<int, MaintenanceWindow>
*/
private function maintenance(): Collection
{
return MaintenanceWindow::query()
->whereNotNull('published_at')
->whereNull('cancelled_at')
->where('state', '!=', 'draft')
->where('ends_at', '>=', Carbon::now())
->orderBy('starts_at')
->get();
}
}

View File

@ -0,0 +1,162 @@
<?php
namespace App\Livewire\Admin;
use App\Models\Incident;
use App\Models\IncidentUpdate;
use App\Services\Status\ServiceHealth;
use App\Support\LocalTime;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Validate;
use Livewire\Component;
/**
* Writing down a disruption while it is happening.
*
* The public page can only be as good as what somebody types here, so the form
* is deliberately short: a title, what it touches, how bad it is, and the first
* sentence. Everything else is a further update, which is also the only way to
* change what the record says an update is a statement made at a time, and
* editing it afterwards is precisely what this record exists to prevent.
*
* Nothing is public until it is published. An operator two minutes into an
* outage should be able to write carefully rather than publish a sentence they
* then have to correct in front of everybody.
*/
#[Layout('layouts.admin')]
class Incidents extends Component
{
#[Validate('required|string|max:160')]
public string $title = '';
#[Validate('required|in:degraded,down,maintenance')]
public string $impact = Incident::IMPACT_DEGRADED;
/** @var array<int, string> */
#[Validate('required|array|min:1')]
public array $components = [];
#[Validate('required|date')]
public string $startedAt = '';
#[Validate('required|string|max:2000')]
public string $firstUpdate = '';
/** The incident an update is being written for, by uuid. */
public ?string $updatingUuid = null;
#[Validate('nullable|in:investigating,identified,monitoring,resolved')]
public string $updateStage = 'identified';
#[Validate('nullable|string|max:2000')]
public string $updateBody = '';
public function mount(): void
{
// Now, in the operator's own time — they are reporting something that
// is happening, not filing a historical record (R19).
$this->startedAt = LocalTime::toField(now());
}
public function create(): void
{
$this->authorize('maintenance.manage');
$this->validateOnly('title');
$this->validateOnly('impact');
$this->validateOnly('components');
$this->validateOnly('startedAt');
$this->validateOnly('firstUpdate');
// One transaction: an incident with no first update is a headline with
// nothing under it, and that is what a reader would see if the second
// insert failed.
DB::transaction(function () {
$incident = Incident::query()->create([
'title' => $this->title,
'impact' => $this->impact,
'components' => array_values(array_intersect(ServiceHealth::COMPONENTS, $this->components)),
'started_at' => LocalTime::fromField($this->startedAt),
'published_at' => now(),
'created_by' => Auth::guard('operator')->user()?->email,
]);
$incident->updates()->create([
'status' => 'investigating',
'body' => $this->firstUpdate,
'created_by' => Auth::guard('operator')->user()?->email,
]);
});
$this->reset(['title', 'impact', 'components', 'firstUpdate']);
$this->startedAt = LocalTime::toField(now());
$this->dispatch('notify', message: __('admin_incidents.created'));
}
/** Open the update box for one incident. */
public function startUpdate(string $uuid): void
{
$this->authorize('maintenance.manage');
$this->updatingUuid = $uuid;
$this->updateStage = 'identified';
$this->updateBody = '';
}
public function cancelUpdate(): void
{
$this->reset(['updatingUuid', 'updateStage', 'updateBody']);
}
public function postUpdate(): void
{
$this->authorize('maintenance.manage');
$this->validate([
'updateStage' => 'required|in:investigating,identified,monitoring,resolved',
'updateBody' => 'required|string|max:2000',
]);
$incident = Incident::query()->where('uuid', $this->updatingUuid)->firstOrFail();
DB::transaction(function () use ($incident) {
$incident->updates()->create([
'status' => $this->updateStage,
'body' => $this->updateBody,
'created_by' => Auth::guard('operator')->user()?->email,
]);
// "Resolved" is the update AND the closing of the incident. Two
// separate actions is how a status page ends up with a resolved
// note under an incident that still reports as ongoing.
if ($this->updateStage === 'resolved' && ! $incident->isResolved()) {
$incident->forceFill(['resolved_at' => now()])->save();
}
});
$this->cancelUpdate();
$this->dispatch('notify', message: __('admin_incidents.update_posted'));
}
/** @return array<int, string> */
public function componentOptions(): array
{
return ServiceHealth::COMPONENTS;
}
public function render()
{
$this->authorize('maintenance.manage');
return view('livewire.admin.incidents', [
'ongoing' => Incident::query()->ongoing()->with('updates')->orderByDesc('started_at')->get(),
'resolved' => Incident::query()
->whereNotNull('resolved_at')
->with('updates')
->orderByDesc('started_at')
->limit(25)
->get(),
]);
}
}

103
app/Models/Incident.php Normal file
View File

@ -0,0 +1,103 @@
<?php
namespace App\Models;
use App\Models\Concerns\HasUuid;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* A disruption, written down.
*
* The point of the record is that it outlives the disruption. "Alle Dienste in
* Betrieb" the morning after an outage is true and useless to somebody who was
* locked out the evening before; what answers them is the timeline when it
* was noticed, what it turned out to be, when it was fixed.
*/
class Incident extends Model
{
/** @use HasFactory<\Database\Factories\IncidentFactory> */
use HasFactory, HasUuid;
/** How the incident colours the components it touches. */
public const IMPACT_DEGRADED = 'degraded';
public const IMPACT_DOWN = 'down';
public const IMPACT_MAINTENANCE = 'maintenance';
/** The stages of an update, in the order they normally happen. */
public const STATUSES = ['investigating', 'identified', 'monitoring', 'resolved'];
protected $fillable = [
'title', 'impact', 'components', 'started_at', 'resolved_at', 'published_at', 'created_by',
];
protected function casts(): array
{
return [
'components' => 'array',
'started_at' => 'datetime',
'resolved_at' => 'datetime',
'published_at' => 'datetime',
];
}
public function updates(): HasMany
{
return $this->hasMany(IncidentUpdate::class)->orderByDesc('created_at')->orderByDesc('id');
}
/** Only what a visitor may see. Drafts are working notes, not statements. */
public function scopePublished(Builder $query): Builder
{
return $query->whereNotNull('published_at');
}
public function scopeOngoing(Builder $query): Builder
{
return $query->whereNull('resolved_at');
}
public function isResolved(): bool
{
return $this->resolved_at !== null;
}
public function isPublished(): bool
{
return $this->published_at !== null;
}
/**
* The stage the incident is at, taken from its newest update.
*
* Derived rather than stored: a stored column and an update log are two
* records of the same fact, and they drift the first time somebody posts
* an update without touching the column.
*/
public function stage(): string
{
if ($this->isResolved()) {
return 'resolved';
}
return $this->updates->first()?->status ?? 'investigating';
}
/**
* How long it lasted, in whole minutes; null while it is still running.
*
* Start first, end second. diffInMinutes is SIGNED written the other way
* round it produced "Dauer -86 Minuten" on the public page, which is the
* kind of detail that costs a status page the only thing it has.
*/
public function durationMinutes(): ?int
{
return $this->resolved_at === null
? null
: (int) $this->started_at->diffInMinutes($this->resolved_at);
}
}

View File

@ -0,0 +1,27 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* One entry in an incident's timeline.
*
* Append-only by intent: an update is a statement made at a time, and editing
* what was said afterwards is exactly what a record like this exists to make
* impossible. Corrections are a further update.
*/
class IncidentUpdate extends Model
{
/** @use HasFactory<\Database\Factories\IncidentUpdateFactory> */
use HasFactory;
protected $fillable = ['incident_id', 'status', 'body', 'created_by'];
public function incident(): BelongsTo
{
return $this->belongsTo(Incident::class);
}
}

59
app/Models/StatusDay.php Normal file
View File

@ -0,0 +1,59 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
/**
* One component, one day, counted.
*
* The row holds sample counts rather than a percentage. A percentage would
* have to be recomputed and rewritten on every sample, and the first rounding
* decision taken during the day becomes a permanent part of the record.
*
* A day with no row is a day nobody measured. That is drawn as its own thing
* never as an operational day, which is the mistake that makes a status page
* worthless the moment somebody checks it against their own memory.
*/
class StatusDay extends Model
{
/** @use HasFactory<\Database\Factories\StatusDayFactory> */
use HasFactory;
protected $fillable = ['day', 'component', 'samples', 'operational', 'degraded', 'down', 'unknown'];
protected function casts(): array
{
return ['day' => 'date'];
}
/**
* The share of samples in which the component answered, as a percentage.
*
* Degraded counts as up: the service was reachable and doing its job less
* well, and folding it into downtime would report a slow morning as an
* outage. It still colours the bar see the view.
*/
public function uptimePercent(): ?float
{
$measured = $this->samples - $this->unknown;
if ($measured <= 0) {
return null;
}
return round(($this->operational + $this->degraded) / $measured * 100, 3);
}
/** The worst thing seen that day — what the bar is coloured by. */
public function worst(): string
{
return match (true) {
$this->down > 0 => 'down',
$this->degraded > 0 => 'degraded',
$this->operational > 0 => 'operational',
default => 'unknown',
};
}
}

View File

@ -0,0 +1,200 @@
<?php
namespace App\Services\Status;
use App\Models\Instance;
use App\Models\MonitoringTarget;
use App\Models\ProvisioningRun;
use Illuminate\Support\Carbon;
/**
* What the four public components are doing right now.
*
* Lifted out of StatusController so that the page and the sampler that writes
* the ninety-day history ask the same question in the same words. Two
* implementations of "is provisioning healthy" would disagree the first time
* one of them was changed, and the disagreement would show up as a green bar
* under a red banner.
*
* Every line is derived from a record. Where there is no signal a component
* says "not monitored" rather than "operational" a status page that reports
* green because it has nothing to look at is worse than no status page,
* because someone will believe it.
*
* Aggregate only: counts, never customer names, instance addresses or host
* names. This is world-readable and the estate is not public information.
*/
class ServiceHealth
{
/** The components, in the order they appear. */
public const COMPONENTS = ['portal', 'instances', 'provisioning', 'backups'];
/** A backup older than this stops counting as current. */
private const BACKUP_STALE_AFTER_HOURS = 48;
/**
* How old a monitoring verdict may be before it stops counting.
*
* The sync job runs every five minutes; four missed runs is a monitoring
* pipeline that has stopped, and a verdict from then says nothing about now.
*/
private const MONITORING_STALE_AFTER_MINUTES = 20;
/** How far back a provisioning failure still says something about now. */
private const PROVISIONING_WINDOW_HOURS = 24;
/**
* @return array<int, array{key: string, state: string, detail: array<string, int>|null}>
*/
public function components(): array
{
return [
$this->portal(),
$this->instances(),
$this->provisioning(),
$this->backups(),
];
}
/**
* The worst individual state is the state of the whole thing. Averaging it
* would let one outage disappear behind three healthy components.
*
* @param array<int, array{state: string}> $components
*/
public function overall(array $components): string
{
$states = array_column($components, 'state');
return match (true) {
in_array('down', $states, true) => 'down',
in_array('degraded', $states, true) => 'degraded',
in_array('unknown', $states, true) => 'unknown',
default => 'operational',
};
}
/**
* The portal and the website.
*
* Answering this request is the measurement. There is no honest way for a
* page to report that the server serving it is down.
*
* @return array<string, mixed>
*/
private function portal(): array
{
return ['key' => 'portal', 'state' => 'operational', 'detail' => null];
}
/**
* Customer instances, from what monitoring last saw.
*
* @return array<string, mixed>
*/
private function instances(): array
{
$total = Instance::query()->where('status', 'active')->count();
if ($total === 0) {
return ['key' => 'instances', 'state' => 'operational', 'detail' => null];
}
// Only targets belonging to instances that are actually in service. A
// healthy check on a decommissioned instance says nothing about a live
// one, and counting it would let coverage look complete when it is not.
$onActive = fn ($query) => $query->whereHas(
'instance',
fn ($instance) => $instance->where('status', 'active'),
);
// Only verdicts the sync job has actually refreshed. A row that has
// never been checked, or was last checked long enough ago that the
// answer means nothing, is not evidence of health — and this column was
// for a long time exactly that: written 'up' at provisioning and never
// touched again.
$fresh = Carbon::now()->subMinutes(self::MONITORING_STALE_AFTER_MINUTES);
$checked = fn ($query) => $query->tap($onActive)->where('checked_at', '>=', $fresh);
$watched = MonitoringTarget::query()->tap($checked)->distinct()->count('instance_id');
$down = MonitoringTarget::query()->tap($checked)->where('status', '!=', 'up')->distinct()->count('instance_id');
// Down first, then coverage. An instance nobody is watching is not a
// healthy instance — it is an unanswered question, and reporting the
// absence of a check as the absence of a problem is the one thing this
// page must never do.
return [
'key' => 'instances',
'state' => match (true) {
$down > 0 && $down >= $watched => 'down',
$down > 0 => 'degraded',
$watched < $total => 'unknown',
default => 'operational',
},
'detail' => match (true) {
$down > 0 => ['down' => $down, 'total' => $watched],
$watched < $total => ['down' => $total - $watched, 'total' => $total],
default => null,
},
];
}
/**
* Whether new instances are being delivered.
*
* @return array<string, mixed>
*/
private function provisioning(): array
{
$since = Carbon::now()->subHours(self::PROVISIONING_WINDOW_HOURS);
$failed = ProvisioningRun::query()
->where('status', ProvisioningRun::STATUS_FAILED)
->where('updated_at', '>=', $since)
->count();
return [
'key' => 'provisioning',
'state' => $failed === 0 ? 'operational' : 'degraded',
'detail' => $failed > 0 ? ['failed' => $failed] : null,
];
}
/**
* Whether the last backup of every instance is recent.
*
* @return array<string, mixed>
*/
private function backups(): array
{
// Counted from the INSTANCES that need protecting, not from the backup
// rows that happen to exist. An active instance with no schedule at all
// has no row — so counting rows would leave it out of the arithmetic
// entirely and report the estate as protected because the backups that
// do exist are fine.
$total = Instance::query()->where('status', 'active')->count();
if ($total === 0) {
return ['key' => 'backups', 'state' => 'operational', 'detail' => null];
}
$fresh = Carbon::now()->subHours(self::BACKUP_STALE_AFTER_HOURS);
$protected = Instance::query()
->where('status', 'active')
->whereHas('backups', fn ($backup) => $backup->where('last_ok_at', '>=', $fresh))
->count();
$unprotected = $total - $protected;
return [
'key' => 'backups',
'state' => match (true) {
$unprotected === 0 => 'operational',
$unprotected >= $total => 'down',
default => 'degraded',
},
'detail' => $unprotected > 0 ? ['stale' => $unprotected, 'total' => $total] : null,
];
}
}

View File

@ -48,6 +48,7 @@ final class Navigation
['label' => __('admin.nav_group.operations'), 'items' => [
['admin.provisioning', 'activity', 'provisioning', null],
['admin.maintenance', 'alert-triangle', 'maintenance', null],
['admin.incidents', 'bell', 'incidents', null],
['admin.vpn', 'shield', 'vpn', null],
['admin.revenue', 'trending-up', 'revenue', null],
// Its own entry, not a section of Settings: what is set here

View File

@ -0,0 +1,91 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* The two things a status page needs beyond "how is it right now".
*
* status_days one row per component per day, written by a sampler. Without
* it the ninety-day bar every real status page carries would have to be
* invented, and an invented uptime figure on the one page whose entire job is
* to be believed is worse than no figure. A day with no samples stays absent
* from the table and is drawn as "not recorded" never as green.
*
* incidents / incident_updates the written record. An outage that is over is
* not an outage that did not happen: the timeline stays, with the times it was
* noticed, identified, and fixed, so a customer asking "what happened on the
* 14th" gets an answer rather than a reassurance.
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('status_days', function (Blueprint $table) {
$table->id();
$table->date('day');
$table->string('component', 32);
// Counts, not a percentage: the sampler adds to them all day and a
// stored percentage would have to be recomputed on every write,
// which is how a rounding error becomes a permanent record.
$table->unsignedInteger('samples')->default(0);
$table->unsignedInteger('operational')->default(0);
$table->unsignedInteger('degraded')->default(0);
$table->unsignedInteger('down')->default(0);
$table->unsignedInteger('unknown')->default(0);
$table->timestamps();
$table->unique(['day', 'component']);
$table->index('day');
});
Schema::create('incidents', function (Blueprint $table) {
$table->id();
$table->uuid()->unique();
$table->string('title');
// What a reader sees on the component, and what colours the day.
$table->string('impact', 16)->default('degraded'); // degraded | down | maintenance
// Which components it touched. JSON rather than a pivot table: the
// component list is a fixed set defined in code, not rows anybody
// can add, so a join table would be a table of foreign keys to
// constants.
$table->json('components');
$table->timestamp('started_at');
$table->timestamp('resolved_at')->nullable();
// Drafts exist so an operator can write the first update carefully
// while the outage is happening rather than publishing a sentence
// they have to correct.
$table->timestamp('published_at')->nullable();
$table->string('created_by')->nullable();
$table->timestamps();
$table->index(['published_at', 'started_at']);
});
Schema::create('incident_updates', function (Blueprint $table) {
$table->id();
$table->foreignId('incident_id')->constrained()->cascadeOnDelete();
$table->string('status', 16); // investigating | identified | monitoring | resolved
$table->text('body');
$table->string('created_by')->nullable();
$table->timestamps();
$table->index(['incident_id', 'created_at']);
});
}
public function down(): void
{
Schema::dropIfExists('incident_updates');
Schema::dropIfExists('incidents');
Schema::dropIfExists('status_days');
}
};

View File

@ -19,6 +19,7 @@ return [
'plans' => 'Pakete',
'provisioning' => 'Provisioning',
'maintenance' => 'Wartungen',
'incidents' => 'Störungen',
'vpn' => 'VPN',
'finance' => 'Finanzen',
'invoices' => 'Rechnungen',

View File

@ -0,0 +1,29 @@
<?php
return [
'title' => 'Störungen',
'subtitle' => 'Was auf der öffentlichen Statusseite steht — und dort dauerhaft nachlesbar bleibt.',
'report' => 'Störung melden',
'report_hint' => 'Sobald Sie veröffentlichen, steht sie auf der Statusseite. Korrekturen erfolgen als weiterer Eintrag, nicht durch Überschreiben.',
'headline' => 'Überschrift',
'affects' => 'Betroffene Dienste',
'impact' => 'Auswirkung',
'started' => 'Beginn',
'first_update' => 'Erste Meldung',
'first_update_placeholder' => 'Was ist aufgefallen, wen betrifft es, was tun wir gerade?',
'publish' => 'Veröffentlichen',
'none_open' => 'Derzeit ist keine Störung offen.',
'unpublished' => 'nicht veröffentlicht',
'closed' => 'Abgeschlossen',
'add_update' => 'Eintrag hinzufügen',
'stage' => 'Stand',
'update_body' => 'Meldung',
'resolved_hint' => '„Behoben“ schließt die Störung zugleich ab.',
'post' => 'Eintragen',
'cancel' => 'Abbrechen',
'created' => 'Störung veröffentlicht.',
'update_posted' => 'Eintrag hinzugefügt.',
];

View File

@ -41,4 +41,52 @@ return [
'provisioning' => ':failed fehlgeschlagene Bereitstellung(en) in den letzten 24 Stunden.',
'backups' => ':stale von :total Instanzen ohne aktuelle Sicherung.',
],
// ── The written record ───────────────────────────────────────────────
'history' => [
'heading' => 'Verfügbarkeit der letzten :days Tage',
'from' => 'vor :days Tagen',
'today' => 'heute',
'uptime' => ':percent % Verfügbarkeit',
'unmeasured' => 'keine Aufzeichnung',
'legend_gap' => 'nicht aufgezeichnet',
'day_tooltip' => ':date — :state',
],
'incident' => [
'ongoing' => 'Laufende Störung',
'past_heading' => 'Vergangene Störungen',
'none' => 'In den letzten :days Tagen gab es keine gemeldete Störung.',
'started' => 'Beginn',
'resolved' => 'Behoben',
'duration' => 'Dauer',
'duration_value' => ':minutes Minuten',
'duration_hours' => ':hours Std. :minutes Min.',
'affected' => 'Betroffen',
'stage' => [
'investigating' => 'Wird untersucht',
'identified' => 'Ursache gefunden',
'monitoring' => 'Wird beobachtet',
'resolved' => 'Behoben',
],
'impact' => [
'degraded' => 'Beeinträchtigung',
'down' => 'Ausfall',
'maintenance' => 'Wartung',
],
],
'maintenance' => [
'heading' => 'Geplante Wartung',
'window' => ':from bis :to',
'upcoming' => 'Angekündigt',
'active' => 'Läuft gerade',
],
'page' => [
'title' => 'Betriebsstatus',
'checked_at' => 'Stand: :time',
'timezone' => 'Alle Zeiten in :zone.',
'subscribe' => 'Bei einer Störung informieren wir betroffene Kunden per E-Mail.',
],
];

View File

@ -19,6 +19,7 @@ return [
'plans' => 'Plans',
'provisioning' => 'Provisioning',
'maintenance' => 'Maintenance',
'incidents' => 'Incidents',
'vpn' => 'VPN',
'finance' => 'Finance',
'invoices' => 'Invoices',

View File

@ -0,0 +1,29 @@
<?php
return [
'title' => 'Incidents',
'subtitle' => 'What the public status page says — and keeps saying afterwards.',
'report' => 'Report an incident',
'report_hint' => 'It is on the status page as soon as you publish. Corrections are a further entry, never an overwrite.',
'headline' => 'Headline',
'affects' => 'Affected services',
'impact' => 'Impact',
'started' => 'Started',
'first_update' => 'First update',
'first_update_placeholder' => 'What was noticed, who is affected, what are we doing right now?',
'publish' => 'Publish',
'none_open' => 'No incident is currently open.',
'unpublished' => 'not published',
'closed' => 'Closed',
'add_update' => 'Add entry',
'stage' => 'Stage',
'update_body' => 'Update',
'resolved_hint' => '“Resolved” also closes the incident.',
'post' => 'Post',
'cancel' => 'Cancel',
'created' => 'Incident published.',
'update_posted' => 'Entry added.',
];

View File

@ -40,4 +40,52 @@ return [
'provisioning' => ':failed failed provisioning run(s) in the last 24 hours.',
'backups' => ':stale of :total instances without a current backup.',
],
// ── The written record ───────────────────────────────────────────────
'history' => [
'heading' => 'Uptime over the last :days days',
'from' => ':days days ago',
'today' => 'today',
'uptime' => ':percent % uptime',
'unmeasured' => 'not recorded',
'legend_gap' => 'not recorded',
'day_tooltip' => ':date — :state',
],
'incident' => [
'ongoing' => 'Ongoing incident',
'past_heading' => 'Past incidents',
'none' => 'No incident was reported in the last :days days.',
'started' => 'Started',
'resolved' => 'Resolved',
'duration' => 'Duration',
'duration_value' => ':minutes minutes',
'duration_hours' => ':hours h :minutes min',
'affected' => 'Affected',
'stage' => [
'investigating' => 'Investigating',
'identified' => 'Identified',
'monitoring' => 'Monitoring',
'resolved' => 'Resolved',
],
'impact' => [
'degraded' => 'Degraded',
'down' => 'Outage',
'maintenance' => 'Maintenance',
],
],
'maintenance' => [
'heading' => 'Scheduled maintenance',
'window' => ':from to :to',
'upcoming' => 'Announced',
'active' => 'In progress',
],
'page' => [
'title' => 'Service status',
'checked_at' => 'As of :time',
'timezone' => 'All times in :zone.',
'subscribe' => 'We notify affected customers by email when an incident occurs.',
],
];

View File

@ -0,0 +1,12 @@
@props(['minutes'])
{{--
"137 Minuten" is a number somebody has to divide in their head. Past an
hour, say it in hours.
--}}
@if ($minutes === null)
@elseif ($minutes < 60)
{{ __('status.incident.duration_value', ['minutes' => $minutes]) }}
@else
{{ __('status.incident.duration_hours', ['hours' => intdiv($minutes, 60), 'minutes' => $minutes % 60]) }}
@endif

View File

@ -0,0 +1,38 @@
@props(['incident'])
{{--
An incident's updates, newest first the convention every status page
follows, because the thing a reader wants first is how it ended.
Shared between the ongoing block at the top of the page and the archive at
the bottom: the same incident should not look like two different kinds of
record depending on whether it is over.
--}}
@php
$stageTone = [
'investigating' => 'bg-warning-bg text-warning border-warning-border',
'identified' => 'bg-info-bg text-info border-info-border',
'monitoring' => 'bg-info-bg text-info border-info-border',
'resolved' => 'bg-success-bg text-success border-success-border',
];
@endphp
<div class="divide-y divide-line">
@forelse ($incident->updates as $update)
<div class="flex flex-col gap-2 px-6 py-4 sm:flex-row sm:gap-5">
<div class="flex shrink-0 items-center gap-3 sm:w-44 sm:flex-col sm:items-start">
<span class="rounded-pill border px-2.5 py-0.5 font-mono text-[10px] font-medium uppercase tracking-[0.07em] {{ $stageTone[$update->status] ?? $stageTone['investigating'] }}">
{{ __("status.incident.stage.{$update->status}") }}
</span>
<time class="font-mono text-xs text-muted" datetime="{{ $update->created_at->toIso8601String() }}">
{{ $update->created_at->local()->isoFormat('DD.MM. HH:mm') }}
</time>
</div>
{{-- nl2br over a plain string, not raw HTML: an update is typed into
a textarea by an operator under time pressure, and the one page
that has to be trustworthy is not the place to start rendering
whatever was pasted into it. --}}
<p class="text-sm leading-relaxed text-body">{!! nl2br(e($update->body)) !!}</p>
</div>
@empty
<p class="px-6 py-4 text-sm text-muted">{{ __("status.incident.stage.investigating") }}</p>
@endforelse
</div>

View File

@ -0,0 +1,141 @@
<div class="space-y-6">
<div class="animate-rise">
<h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('admin_incidents.title') }}</h1>
<p class="mt-1 text-sm text-muted">{{ __('admin_incidents.subtitle') }}</p>
</div>
<div class="grid grid-cols-1 gap-6 lg:grid-cols-[1fr_420px] lg:items-start">
<div class="space-y-6 animate-rise [animation-delay:60ms]">
{{-- Open first. This page is opened while something is wrong, and
the thing an operator needs is the box to type into. --}}
@forelse ($ongoing as $incident)
<div wire:key="open-{{ $incident->uuid }}" class="overflow-hidden rounded-lg border border-danger-border bg-surface shadow-xs">
<div class="border-b border-line bg-danger-bg px-5 py-4">
<div class="flex flex-wrap items-center gap-2">
<x-ui.badge status="suspended">{{ __("status.incident.impact.{$incident->impact}") }}</x-ui.badge>
<span class="font-mono text-xs text-muted">{{ __('status.incident.stage.'.$incident->stage()) }}</span>
@unless ($incident->isPublished())
<span class="font-mono text-xs text-warning">{{ __('admin_incidents.unpublished') }}</span>
@endunless
</div>
<h2 class="mt-2 font-semibold text-ink">{{ $incident->title }}</h2>
<p class="mt-1 font-mono text-xs text-muted">
{{ __('status.incident.started') }} {{ $incident->started_at->local()->isoFormat('DD.MM. HH:mm') }}
· {{ collect($incident->components)->map(fn ($c) => __("status.component.{$c}"))->join(', ') }}
</p>
</div>
<x-status.timeline :incident="$incident" />
<div class="border-t border-line bg-surface-2 px-5 py-4">
@if ($updatingUuid === $incident->uuid)
<div class="space-y-3">
<div>
<label for="stage-{{ $incident->uuid }}" class="mb-1.5 block text-xs font-semibold text-muted">{{ __('admin_incidents.stage') }}</label>
<select id="stage-{{ $incident->uuid }}" wire:model="updateStage" class="min-h-10 w-full rounded border border-line bg-surface px-3 text-sm text-ink">
@foreach (['investigating', 'identified', 'monitoring', 'resolved'] as $stage)
<option value="{{ $stage }}">{{ __("status.incident.stage.{$stage}") }}</option>
@endforeach
</select>
<p class="mt-1.5 text-xs text-muted">{{ __('admin_incidents.resolved_hint') }}</p>
</div>
<div>
<label for="body-{{ $incident->uuid }}" class="mb-1.5 block text-xs font-semibold text-muted">{{ __('admin_incidents.update_body') }}</label>
<textarea id="body-{{ $incident->uuid }}" wire:model="updateBody" rows="3"
class="w-full rounded border border-line bg-surface px-3 py-2 text-sm text-ink"></textarea>
@error('updateBody') <p class="mt-1 text-xs text-danger">{{ $message }}</p> @enderror
</div>
<div class="flex gap-2">
<x-ui.button wire:click="postUpdate" size="sm">{{ __('admin_incidents.post') }}</x-ui.button>
<x-ui.button wire:click="cancelUpdate" variant="secondary" size="sm">{{ __('admin_incidents.cancel') }}</x-ui.button>
</div>
</div>
@else
<x-ui.button wire:click="startUpdate('{{ $incident->uuid }}')" variant="secondary" size="sm">
<x-ui.icon name="pen" class="size-4" />{{ __('admin_incidents.add_update') }}
</x-ui.button>
@endif
</div>
</div>
@empty
<div class="rounded-lg border border-line bg-surface p-8 text-center shadow-xs">
<x-ui.icon name="check" class="mx-auto size-5 text-success" />
<p class="mt-2 text-sm text-muted">{{ __('admin_incidents.none_open') }}</p>
</div>
@endforelse
@if ($resolved->isNotEmpty())
<div class="overflow-hidden rounded-lg border border-line bg-surface shadow-xs">
<h2 class="border-b border-line px-5 py-3.5 text-sm font-semibold text-ink">{{ __('admin_incidents.closed') }}</h2>
<table class="w-full text-sm">
<tbody>
@foreach ($resolved as $incident)
<tr wire:key="done-{{ $incident->uuid }}" class="border-b border-line last:border-0">
<td class="px-5 py-3 font-medium text-ink">{{ $incident->title }}</td>
<td class="whitespace-nowrap px-5 py-3 font-mono text-xs text-muted">
{{ $incident->started_at->local()->isoFormat('DD.MM.YY HH:mm') }}
</td>
<td class="whitespace-nowrap px-5 py-3 text-right font-mono text-xs text-muted">
<x-status.duration :minutes="$incident->durationMinutes()" />
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
</div>
{{-- Report. A page-level form, not a row editor: it creates a record
rather than editing one, so R20 does not apply. --}}
<div class="rounded-lg border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:120ms]">
<h2 class="text-sm font-semibold text-ink">{{ __('admin_incidents.report') }}</h2>
<p class="mt-1 text-xs text-muted">{{ __('admin_incidents.report_hint') }}</p>
<div class="mt-4 space-y-4">
<div>
<label for="inc-title" class="mb-1.5 block text-xs font-semibold text-muted">{{ __('admin_incidents.headline') }}</label>
<input id="inc-title" type="text" wire:model="title" class="min-h-10 w-full rounded border border-line bg-surface px-3 text-sm text-ink">
@error('title') <p class="mt-1 text-xs text-danger">{{ $message }}</p> @enderror
</div>
<div>
<span class="mb-1.5 block text-xs font-semibold text-muted">{{ __('admin_incidents.affects') }}</span>
<div class="space-y-2">
@foreach ($this->componentOptions() as $component)
<label class="flex items-center gap-2.5 text-sm text-body">
<input type="checkbox" wire:model="components" value="{{ $component }}" class="size-4 rounded border-line text-accent-active">
{{ __("status.component.{$component}") }}
</label>
@endforeach
</div>
@error('components') <p class="mt-1 text-xs text-danger">{{ $message }}</p> @enderror
</div>
<div>
<label for="inc-impact" class="mb-1.5 block text-xs font-semibold text-muted">{{ __('admin_incidents.impact') }}</label>
<select id="inc-impact" wire:model="impact" class="min-h-10 w-full rounded border border-line bg-surface px-3 text-sm text-ink">
@foreach (['degraded', 'down', 'maintenance'] as $impact)
<option value="{{ $impact }}">{{ __("status.incident.impact.{$impact}") }}</option>
@endforeach
</select>
</div>
<div>
<label for="inc-start" class="mb-1.5 block text-xs font-semibold text-muted">{{ __('admin_incidents.started') }}</label>
<input id="inc-start" type="datetime-local" wire:model="startedAt" class="min-h-10 w-full rounded border border-line bg-surface px-3 text-sm text-ink">
@error('startedAt') <p class="mt-1 text-xs text-danger">{{ $message }}</p> @enderror
</div>
<div>
<label for="inc-first" class="mb-1.5 block text-xs font-semibold text-muted">{{ __('admin_incidents.first_update') }}</label>
<textarea id="inc-first" wire:model="firstUpdate" rows="3" class="w-full rounded border border-line bg-surface px-3 py-2 text-sm text-ink"
placeholder="{{ __('admin_incidents.first_update_placeholder') }}"></textarea>
@error('firstUpdate') <p class="mt-1 text-xs text-danger">{{ $message }}</p> @enderror
</div>
<x-ui.button wire:click="create" class="w-full">{{ __('admin_incidents.publish') }}</x-ui.button>
</div>
</div>
</div>
</div>

View File

@ -1,132 +1,218 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Status CluPilot Cloud</title>
<meta name="description" content="Aktueller Betriebszustand der CluPilot Cloud: Portal, Kundeninstanzen, Bereitstellung und Sicherungen.">
<meta name="robots" content="noindex">
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
{{--
The public status page.
@verbatim
<style>
@font-face{font-family:"Plex Sans";src:url("/fonts/ibm-plex-sans-latin-400-normal.woff2") format("woff2");font-weight:400;font-style:normal;font-display:swap}
@font-face{font-family:"Plex Sans";src:url("/fonts/ibm-plex-sans-latin-500-normal.woff2") format("woff2");font-weight:500;font-style:normal;font-display:swap}
@font-face{font-family:"Plex Mono";src:url("/fonts/ibm-plex-mono-latin-400-normal.woff2") format("woff2");font-weight:400;font-style:normal;font-display:swap}
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.
:root{
--paper:#f6f6f8; --paper-2:#fafafb; --card:#ffffff;
--ink:#17171c; --ink-soft:#43434e; --muted:#6e6e7a; --faint:#a39a8c;
--rule:#e9e9ee; --rule-mid:#dcdce4;
--accent:#f97316; --accent-text:#b8500a;
--ok:#2f7d4f; --ok-bg:#eef7f1;
--warn:#a2600c; --warn-bg:#fdf5e7;
--bad:#a4372a; --bad-bg:#fdf1ef;
--unknown:#6c6459; --unknown-bg:#f1eee8;
--sans:"Plex Sans",-apple-system,BlinkMacSystemFont,sans-serif;
--mono:"Plex Mono",ui-monospace,monospace;
--r:16px;
}
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:var(--sans);background:var(--paper);color:var(--ink-soft);font-size:16px;line-height:1.6;-webkit-font-smoothing:antialiased}
a{color:inherit;text-decoration:none}
:focus-visible{outline:2px solid var(--accent-text);outline-offset:3px}
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.
.wrap{max-width:760px;margin:0 auto;padding:0 clamp(20px,5vw,32px)}
header{padding:clamp(48px,8vw,80px) 0 0}
.mark{font-weight:700;font-size:1.12rem;letter-spacing:-.02em;color:var(--ink)}
.mark i{font-style:normal;color:var(--accent-text)}
h1{font-weight:700;font-size:clamp(2rem,4.5vw,2.8rem);letter-spacing:-.028em;line-height:1.1;color:var(--ink);margin-top:28px}
Every figure comes from a record. A day nobody sampled is drawn as a gap,
never as green.
--}}
@php
use App\Models\Incident;
.overall{
display:flex;align-items:center;gap:13px;margin-top:22px;padding:16px 20px;
border:1px solid var(--rule-mid);border-radius:var(--r);background:var(--card);
}
.dot{width:10px;height:10px;border-radius:50%;flex:none}
.overall b{font-weight:500;color:var(--ink)}
.s-operational .dot{background:var(--ok)} .s-operational b{color:var(--ok)}
.s-degraded .dot{background:var(--warn)} .s-degraded b{color:var(--warn)}
.s-down .dot{background:var(--bad)} .s-down b{color:var(--bad)}
.s-unknown .dot{background:var(--unknown)} .s-unknown b{color:var(--unknown)}
$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'],
];
.list{margin-top:34px;border:1px solid var(--rule-mid);border-radius:var(--r);background:var(--card);overflow:hidden}
.row{display:flex;align-items:flex-start;gap:16px;padding:18px 20px;border-bottom:1px solid var(--rule)}
.row:last-child{border-bottom:0}
.row .name{flex:1;min-width:0}
.row .name b{display:block;font-weight:500;color:var(--ink);font-size:.98rem}
.row .name small{display:block;color:var(--muted);font-size:.85rem;margin-top:2px;line-height:1.5}
.badge{
display:inline-flex;align-items:center;gap:8px;flex:none;
font-family:var(--mono);font-size:.72rem;text-transform:uppercase;letter-spacing:.1em;
padding:5px 11px;border-radius:99px;border:1px solid transparent;white-space:nowrap;
}
.badge.s-operational{background:var(--ok-bg);color:var(--ok);border-color:#c9e4d4}
.badge.s-degraded{background:var(--warn-bg);color:var(--warn);border-color:#f0dcb4}
.badge.s-down{background:var(--bad-bg);color:var(--bad);border-color:#f2cdc7}
.badge.s-unknown{background:var(--unknown-bg);color:var(--unknown);border-color:var(--rule-mid)}
$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
.meta{margin-top:20px;font-family:var(--mono);font-size:.74rem;color:var(--muted)}
.note{margin-top:26px;padding-top:22px;border-top:1px solid var(--rule);font-size:.88rem;color:var(--muted);line-height:1.65}
.note a{color:var(--accent-text);font-weight:500}
footer{margin-top:clamp(48px,7vw,72px);padding:24px 0 40px;border-top:1px solid var(--rule)}
.fo{display:flex;flex-wrap:wrap;gap:12px 22px;font-family:var(--mono);font-size:.73rem;color:var(--muted)}
.fo .right{margin-left:auto;display:flex;flex-wrap:wrap;gap:18px}
.fo a:hover{color:var(--ink)}
@media(max-width:520px){.row{flex-wrap:wrap}.badge{order:-1}}
</style>
@endverbatim
</head>
<body>
<div class="wrap">
<header>
<a class="mark" href="{{ route('home') }}">Clu<i>Pilot</i> Cloud</a>
<h1>Betriebsstatus</h1>
<x-layouts.site :title="__('status.page.title').' — CluPilot Cloud'" robots="noindex">
<div class="overall s-{{ $overall }}">
<span class="dot" aria-hidden="true"></span>
<b>{{ __('status.overall.'.$overall) }}</b>
<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>
</header>
<div class="list">
@foreach ($components as $c)
<div class="row">
<div class="name">
<b>{{ __('status.component.'.$c['key']) }}</b>
<small>
@if ($c['detail'] !== null)
{{ __('status.detail.'.$c['key'], $c['detail']) }}
@else
{{ __('status.about.'.$c['key']) }}
@endif
</small>
</div>
<span class="badge s-{{ $c['state'] }}">{{ __('status.state.'.$c['state']) }}</span>
</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
</div>
<p class="meta">Stand: {{ $checkedAt->local()->format('d.m.Y, H:i') }} Uhr</p>
{{-- ── 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="note">
Jede Zeile wird bei jedem Aufruf neu gemessen aus Überwachung, Sicherungsprotokoll und
Bereitstellungsläufen. Wo gerade nichts gemessen wird, steht „unbekannt“ und nicht „in Betrieb“:
eine fehlende Messung ist kein Nachweis, dass alles läuft.
Eine Störung, die Sie hier nicht sehen, melden Sie bitte an
<a href="mailto:office&#64;clupilot.com">office&#64;clupilot.com</a> — Antwort am selben Werktag.
</p>
<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>
<footer>
<div class="fo">
<span>© {{ date('Y') }} CluPilot Cloud · Österreich</span>
<div class="right">
<a href="{{ route('home') }}">Startseite</a>
<a href="{{ route('legal.impressum') }}">Impressum</a>
<a href="{{ route('legal.datenschutz') }}">Datenschutz</a>
<a href="{{ route('legal.agb') }}">AGB</a>
</div>
</div>
</footer>
{{-- 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>
</body>
</html>
</x-layouts.site>

View File

@ -36,6 +36,7 @@ Route::get('/plans/{uuid}', Admin\PlanVersions::class)->name('plans.versions');
Route::post('/impersonate/{customer}', [ImpersonationController::class, 'start'])->name('impersonate');
Route::get('/provisioning', Admin\Provisioning::class)->name('provisioning');
Route::get('/maintenance', Admin\Maintenance::class)->name('maintenance');
Route::get('/incidents', Admin\Incidents::class)->name('incidents');
Route::get('/vpn', Admin\Vpn::class)->name('vpn');
Route::get('/revenue', Admin\Revenue::class)->name('revenue');
Route::get('/finance', Admin\Finance::class)->name('finance');

View File

@ -63,3 +63,13 @@ Schedule::command('clupilot:archive-invoices')
Schedule::command('clupilot:prune-exports')
->dailyAt('03:20')
->withoutOverlapping();
// One sample of every public status component, into today's row.
//
// The ninety-day bar on the status page is the one figure a reader checks
// against their own memory, so it has to come from something recorded at the
// time — there is no way to reconstruct it later. Beside the monitoring sync it
// reads from, at the same cadence.
Schedule::command('clupilot:sample-status')
->everyFiveMinutes()
->withoutOverlapping();

View File

@ -0,0 +1,121 @@
<?php
use App\Livewire\Admin\Incidents as IncidentConsole;
use App\Models\Incident;
use App\Support\LocalTime;
use Livewire\Livewire;
/**
* Writing the record.
*
* The public page can only be as honest as what is typed here, so these tests
* are about the two ways the console could make it dishonest: letting somebody
* revise what was already said, and letting the incident and its timeline drift
* apart.
*/
it('publishes an incident together with its first update, or not at all', function () {
Livewire::actingAs(operator('Owner'), 'operator')
->test(IncidentConsole::class)
->set('title', 'Instanzen nicht erreichbar')
->set('components', ['instances'])
->set('impact', 'down')
->set('startedAt', LocalTime::toField(now()->subMinutes(20)))
->set('firstUpdate', 'Mehrere Instanzen antworten nicht.')
->call('create')
->assertHasNoErrors();
$incident = Incident::query()->firstOrFail();
// A headline with nothing under it is what a reader would see if the
// second insert were allowed to fail on its own.
expect($incident->updates)->toHaveCount(1)
->and($incident->updates->first()->status)->toBe('investigating')
->and($incident->isPublished())->toBeTrue()
->and($incident->components)->toBe(['instances']);
});
it('refuses an incident that names no service', function () {
Livewire::actingAs(operator('Owner'), 'operator')
->test(IncidentConsole::class)
->set('title', 'Irgendwas')
->set('components', [])
->set('firstUpdate', 'Text')
->call('create')
->assertHasErrors('components');
expect(Incident::query()->count())->toBe(0);
});
it('closes the incident with the same action that says it is resolved', function () {
// Two separate actions is how a status page ends up with a "behoben" note
// under an incident that still reports as ongoing.
$incident = Incident::query()->create([
'title' => 'x', 'impact' => 'down', 'components' => ['portal'],
'started_at' => now()->subHour(), 'published_at' => now()->subHour(),
]);
Livewire::actingAs(operator('Owner'), 'operator')
->test(IncidentConsole::class)
->call('startUpdate', $incident->uuid)
->set('updateStage', 'resolved')
->set('updateBody', 'Seit 20:51 stabil.')
->call('postUpdate')
->assertHasNoErrors();
expect($incident->fresh()->isResolved())->toBeTrue()
->and($incident->fresh()->stage())->toBe('resolved');
});
it('takes a correction as a further entry, never as an edit', function () {
// An update is a statement made at a time. There is deliberately no way
// here to change one after the fact.
$incident = Incident::query()->create([
'title' => 'x', 'impact' => 'degraded', 'components' => ['backups'],
'started_at' => now()->subHour(), 'published_at' => now()->subHour(),
]);
$incident->updates()->create(['status' => 'investigating', 'body' => 'Erste Einschätzung.']);
Livewire::actingAs(operator('Owner'), 'operator')
->test(IncidentConsole::class)
->call('startUpdate', $incident->uuid)
->set('updateStage', 'identified')
->set('updateBody', 'Korrektur: es betrifft nur einen Host.')
->call('postUpdate');
$bodies = $incident->fresh()->updates->pluck('body');
expect($bodies)->toHaveCount(2)
->and($bodies)->toContain('Erste Einschätzung.');
expect(class_exists(App\Livewire\Admin\Incidents::class))->toBeTrue();
expect(method_exists(App\Livewire\Admin\Incidents::class, 'editUpdate'))->toBeFalse();
});
it('needs the capability, not merely an operator account', function () {
// Asserted on the PAGE rather than on the action: render() authorizes too,
// so an operator without the capability never gets far enough to press a
// button — and a Livewire::test() against a component that refuses to
// render fails with a snapshot error that says nothing about permissions.
$this->actingAs(operator('Support'), 'operator')
->get(route('admin.incidents'))
->assertForbidden();
});
it('records the operator who wrote each line', function () {
// "Nachvollziehbar" includes who said it. Not shown publicly — this is for
// the operator looking back at their own record months later.
$owner = operator('Owner');
Livewire::actingAs($owner, 'operator')
->test(IncidentConsole::class)
->set('title', 'x')
->set('components', ['portal'])
->set('startedAt', LocalTime::toField(now()))
->set('firstUpdate', 'Text')
->call('create');
$incident = Incident::query()->firstOrFail();
expect($incident->created_by)->toBe($owner->email)
->and($incident->updates->first()->created_by)->toBe($owner->email);
});

View File

@ -21,6 +21,7 @@ function sitePages(): array
return [
'landing.blade.php',
'legal.blade.php',
'status.blade.php',
'components/layouts/site.blade.php',
];
}
@ -53,20 +54,24 @@ it('serves the website from the same stylesheet as the portal', function () {
expect($head)->toContain("@vite(['resources/css/app.css'");
});
it('keeps the two deliberately self-contained pages self-contained', function () {
// The exceptions, named rather than forgotten. Both are shown when the
it('keeps the deliberately self-contained page self-contained', function () {
// The exception, named rather than forgotten. coming-soon is shown when the
// application may not be able to serve a built stylesheet at all — mid
// deploy, or freshly installed — and @vite would throw where the whole
// point is to render something reassuring.
foreach (['coming-soon.blade.php', 'status.blade.php'] as $page) {
$source = File::get(resource_path('views/'.$page));
//
// The status page used to be on this list on the same theory and does not
// belong: if the stylesheet cannot be served, the application serving the
// status page is already failing, and mid-deployment every route answers
// with the maintenance page. What the exemption actually bought was a third
// design nobody maintained.
$source = File::get(resource_path('views/coming-soon.blade.php'));
// The CALL, not the word: coming-soon.blade.php explains in a comment
// why it does not use @vite, and a plain substring match reads that
// explanation as the offence it warns about.
expect($source)->not->toMatch('/@vite\s*\(/')
->and($source)->toContain('<style>');
}
// The CALL, not the word: the file explains in a comment why it does not
// use @vite, and a plain substring match reads that explanation as the
// offence it warns about.
expect($source)->not->toMatch('/@vite\s*\(/')
->and($source)->toContain('<style>');
});
it('does not leave the old marketing stylesheet behind anywhere', function () {
@ -74,7 +79,7 @@ it('does not leave the old marketing stylesheet behind anywhere', function () {
// a partial nobody opened, it would come back the next time somebody
// copied a section.
foreach (File::allFiles(resource_path('views')) as $file) {
if (in_array($file->getFilename(), ['coming-soon.blade.php', 'status.blade.php'], true)) {
if ($file->getFilename() === 'coming-soon.blade.php') {
continue;
}

View File

@ -0,0 +1,175 @@
<?php
use App\Models\Incident;
use App\Models\Instance;
use App\Models\StatusDay;
use Illuminate\Support\Carbon;
/**
* The written record behind the status page.
*
* A status page is the one page in a product whose entire value is that people
* believe it. Every test here is about a way it could quietly lie: a day nobody
* measured drawn as a good day, an uptime figure that averages a four-sample
* day against a three-hundred-sample day, a resolved incident quietly vanishing
* the morning after, an unpublished draft appearing in public.
*/
it('never draws a day nobody measured as a good day', function () {
// The single most tempting shortcut on a page like this: no row means
// nothing was wrong. It means nothing was looked at.
StatusDay::query()->create([
'day' => Carbon::now()->local()->subDay()->toDateString(),
'component' => 'portal',
'samples' => 288, 'operational' => 288, 'degraded' => 0, 'down' => 0, 'unknown' => 0,
]);
$history = $this->get('/status')->viewData('history')['portal']['days'];
$yesterday = collect($history)->firstWhere(fn ($d) => $d['date']->isYesterday());
$ninetyDaysAgo = collect($history)->first();
expect($yesterday['state'])->toBe('operational')
->and($ninetyDaysAgo['state'])->toBeNull()
->and($ninetyDaysAgo['uptime'])->toBeNull();
});
it('weights the uptime figure by samples, not by days', function () {
// A day the sampler only managed four runs on must not count as much as a
// full day. Averaging the daily percentages is the version of this that
// looks right and reports 50 % for one bad hour in three months.
$day = fn (int $ago, int $samples, int $down) => StatusDay::query()->create([
'day' => Carbon::now()->local()->subDays($ago)->toDateString(),
'component' => 'portal',
'samples' => $samples, 'operational' => $samples - $down,
'degraded' => 0, 'down' => $down, 'unknown' => 0,
]);
$day(1, 288, 0);
$day(2, 4, 2); // half of a tiny day was down
$uptime = $this->get('/status')->viewData('history')['portal']['uptime'];
// 290 up out of 292 measured, not the mean of 100 % and 50 % — which would
// report 75 % for two minutes of downtime in a quarter.
expect($uptime)->toBe(round(290 / 292 * 100, 2))
->and($uptime)->toBeGreaterThan(99.0);
});
it('counts a degraded sample as reachable but still colours the day', function () {
// Folding "slower than usual" into downtime reports a bad morning as an
// outage; ignoring it entirely loses the only trace of it on the page.
$row = StatusDay::query()->create([
'day' => Carbon::now()->local()->toDateString(),
'component' => 'portal',
'samples' => 100, 'operational' => 90, 'degraded' => 10, 'down' => 0, 'unknown' => 0,
]);
expect($row->uptimePercent())->toBe(100.0)
->and($row->worst())->toBe('degraded');
});
it('leaves unknown samples out of the arithmetic entirely', function () {
// "We could not tell" is not uptime and it is not downtime. Counting it as
// either invents a measurement that was never taken.
$row = StatusDay::query()->create([
'day' => Carbon::now()->local()->toDateString(),
'component' => 'instances',
'samples' => 100, 'operational' => 50, 'degraded' => 0, 'down' => 0, 'unknown' => 50,
]);
expect($row->uptimePercent())->toBe(100.0);
});
it('adds to today rather than replacing it, however often the sampler runs', function () {
Instance::query()->delete();
$this->artisan('clupilot:sample-status')->assertSuccessful();
$this->artisan('clupilot:sample-status')->assertSuccessful();
$this->artisan('clupilot:sample-status')->assertSuccessful();
$row = StatusDay::query()->where('component', 'portal')->firstOrFail();
// Three samples, not one overwritten three times — the counters are what
// the ninety-day figure is computed from.
expect($row->samples)->toBe(3)->and($row->operational)->toBe(3);
});
it('reports a duration forwards in time', function () {
// It printed "Dauer -86 Minuten" on the public page: diffInMinutes is
// signed and the operands were the wrong way round.
$incident = Incident::query()->create([
'title' => 'x', 'impact' => 'down', 'components' => ['portal'],
'started_at' => Carbon::parse('2026-07-15 19:42'),
'resolved_at' => Carbon::parse('2026-07-15 21:08'),
'published_at' => now(),
]);
expect($incident->durationMinutes())->toBe(86);
});
it('keeps a resolved incident on the page after it is over', function () {
// The whole point. "Alle Dienste in Betrieb" the morning after is true and
// useless to somebody who was locked out the evening before.
$incident = Incident::query()->create([
'title' => 'Instanzen zeitweise nicht erreichbar',
'impact' => 'down', 'components' => ['instances'],
'started_at' => now()->subDays(3),
'resolved_at' => now()->subDays(3)->addHour(),
'published_at' => now()->subDays(3),
]);
$incident->updates()->create(['status' => 'resolved', 'body' => 'Seit 20:51 stabil.']);
$this->get('/status')
->assertOk()
->assertSee('Instanzen zeitweise nicht erreichbar')
->assertSee('Seit 20:51 stabil.')
->assertSee(__('status.incident.stage.resolved'));
});
it('does not publish a draft', function () {
// Drafts exist so an operator can write carefully during an outage instead
// of publishing a sentence they then correct in front of everybody.
Incident::query()->create([
'title' => 'Interner Entwurf',
'impact' => 'down', 'components' => ['portal'],
'started_at' => now()->subHour(),
'published_at' => null,
]);
$this->get('/status')->assertOk()->assertDontSee('Interner Entwurf');
});
it('lets an open incident make a component look worse, never better', function () {
// The direction matters. An incident that could improve a component's
// reported state is a switch for hiding an outage.
Instance::factory()->create(['status' => 'active']); // → instances is 'unknown'
Incident::query()->create([
'title' => 'Ausfall', 'impact' => 'down', 'components' => ['instances', 'portal'],
'started_at' => now()->subHour(), 'published_at' => now()->subHour(),
])->updates()->create(['status' => 'investigating', 'body' => 'Wir untersuchen das.']);
$components = collect($this->get('/status')->viewData('components'))->keyBy('key');
expect($components['instances']['state'])->toBe('down')
// The portal answered this very request; an incident naming it cannot
// turn a measurement into a claim, but it may still darken it.
->and($components['portal']['state'])->toBe('down');
// And the banner follows the worst component.
expect($this->get('/status')->viewData('overall'))->toBe('down');
});
it('shows an incident that is not over at the top, not filed under history', function () {
Incident::query()->create([
'title' => 'Läuft noch', 'impact' => 'degraded', 'components' => ['backups'],
'started_at' => now()->subHour(), 'published_at' => now()->subHour(),
])->updates()->create(['status' => 'investigating', 'body' => 'Wird untersucht.']);
$page = $this->get('/status');
expect($page->viewData('ongoing'))->toHaveCount(1)
->and($page->viewData('past'))->toBeEmpty();
$page->assertSee(__('status.incident.ongoing'));
});