From b85df4c141f097e82094abba80c187b881ac0cbd Mon Sep 17 00:00:00 2001 From: nexxo Date: Wed, 29 Jul 2026 12:45:18 +0200 Subject: [PATCH] Rebuild the status page as a status page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- VERSION | 2 +- app/Console/Commands/SampleServiceStatus.php | 78 +++++ app/Http/Controllers/StatusController.php | 284 ++++++++------- app/Livewire/Admin/Incidents.php | 162 +++++++++ app/Models/Incident.php | 103 ++++++ app/Models/IncidentUpdate.php | 27 ++ app/Models/StatusDay.php | 59 ++++ app/Services/Status/ServiceHealth.php | 200 +++++++++++ app/Support/Navigation.php | 1 + ...026_07_29_200000_create_status_history.php | 91 +++++ lang/de/admin.php | 1 + lang/de/admin_incidents.php | 29 ++ lang/de/status.php | 48 +++ lang/en/admin.php | 1 + lang/en/admin_incidents.php | 29 ++ lang/en/status.php | 48 +++ .../components/status/duration.blade.php | 12 + .../components/status/timeline.blade.php | 38 +++ .../views/livewire/admin/incidents.blade.php | 141 ++++++++ resources/views/status.blade.php | 322 +++++++++++------- routes/admin.php | 1 + routes/console.php | 10 + tests/Feature/Admin/IncidentConsoleTest.php | 121 +++++++ tests/Feature/SiteDesignSystemTest.php | 27 +- tests/Feature/StatusHistoryTest.php | 175 ++++++++++ 25 files changed, 1737 insertions(+), 273 deletions(-) create mode 100644 app/Console/Commands/SampleServiceStatus.php create mode 100644 app/Livewire/Admin/Incidents.php create mode 100644 app/Models/Incident.php create mode 100644 app/Models/IncidentUpdate.php create mode 100644 app/Models/StatusDay.php create mode 100644 app/Services/Status/ServiceHealth.php create mode 100644 database/migrations/2026_07_29_200000_create_status_history.php create mode 100644 lang/de/admin_incidents.php create mode 100644 lang/en/admin_incidents.php create mode 100644 resources/views/components/status/duration.blade.php create mode 100644 resources/views/components/status/timeline.blade.php create mode 100644 resources/views/livewire/admin/incidents.blade.php create mode 100644 tests/Feature/Admin/IncidentConsoleTest.php create mode 100644 tests/Feature/StatusHistoryTest.php diff --git a/VERSION b/VERSION index 80e78df..95b25ae 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.3.5 +1.3.6 diff --git a/app/Console/Commands/SampleServiceStatus.php b/app/Console/Commands/SampleServiceStatus.php new file mode 100644 index 0000000..a8ab85a --- /dev/null +++ b/app/Console/Commands/SampleServiceStatus.php @@ -0,0 +1,78 @@ +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; + } +} diff --git a/app/Http/Controllers/StatusController.php b/app/Http/Controllers/StatusController.php index f580927..045f89b 100644 --- a/app/Http/Controllers/StatusController.php +++ b/app/Http/Controllers/StatusController.php @@ -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 + * @param array> $components + * @param Collection $ongoing + * @return array> */ - 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 - */ - 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 - */ - 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 + * @return array>, 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> + */ + 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 + */ + 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(); } } diff --git a/app/Livewire/Admin/Incidents.php b/app/Livewire/Admin/Incidents.php new file mode 100644 index 0000000..3ccb58a --- /dev/null +++ b/app/Livewire/Admin/Incidents.php @@ -0,0 +1,162 @@ + */ + #[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 */ + 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(), + ]); + } +} diff --git a/app/Models/Incident.php b/app/Models/Incident.php new file mode 100644 index 0000000..afe5c4a --- /dev/null +++ b/app/Models/Incident.php @@ -0,0 +1,103 @@ + */ + 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); + } +} diff --git a/app/Models/IncidentUpdate.php b/app/Models/IncidentUpdate.php new file mode 100644 index 0000000..34dd2db --- /dev/null +++ b/app/Models/IncidentUpdate.php @@ -0,0 +1,27 @@ + */ + use HasFactory; + + protected $fillable = ['incident_id', 'status', 'body', 'created_by']; + + public function incident(): BelongsTo + { + return $this->belongsTo(Incident::class); + } +} diff --git a/app/Models/StatusDay.php b/app/Models/StatusDay.php new file mode 100644 index 0000000..b5671a2 --- /dev/null +++ b/app/Models/StatusDay.php @@ -0,0 +1,59 @@ + */ + 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', + }; + } +} diff --git a/app/Services/Status/ServiceHealth.php b/app/Services/Status/ServiceHealth.php new file mode 100644 index 0000000..eea6225 --- /dev/null +++ b/app/Services/Status/ServiceHealth.php @@ -0,0 +1,200 @@ +|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 $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 + */ + private function portal(): array + { + return ['key' => 'portal', 'state' => 'operational', 'detail' => null]; + } + + /** + * Customer instances, from what monitoring last saw. + * + * @return array + */ + 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 + */ + 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 + */ + 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, + ]; + } +} diff --git a/app/Support/Navigation.php b/app/Support/Navigation.php index 6c850e0..c7e10dc 100644 --- a/app/Support/Navigation.php +++ b/app/Support/Navigation.php @@ -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 diff --git a/database/migrations/2026_07_29_200000_create_status_history.php b/database/migrations/2026_07_29_200000_create_status_history.php new file mode 100644 index 0000000..2dfade8 --- /dev/null +++ b/database/migrations/2026_07_29_200000_create_status_history.php @@ -0,0 +1,91 @@ +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'); + } +}; diff --git a/lang/de/admin.php b/lang/de/admin.php index 6e0a53a..4e5a194 100644 --- a/lang/de/admin.php +++ b/lang/de/admin.php @@ -19,6 +19,7 @@ return [ 'plans' => 'Pakete', 'provisioning' => 'Provisioning', 'maintenance' => 'Wartungen', + 'incidents' => 'Störungen', 'vpn' => 'VPN', 'finance' => 'Finanzen', 'invoices' => 'Rechnungen', diff --git a/lang/de/admin_incidents.php b/lang/de/admin_incidents.php new file mode 100644 index 0000000..a731af2 --- /dev/null +++ b/lang/de/admin_incidents.php @@ -0,0 +1,29 @@ + '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.', +]; diff --git a/lang/de/status.php b/lang/de/status.php index e5788c1..cec1093 100644 --- a/lang/de/status.php +++ b/lang/de/status.php @@ -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.', + ], ]; diff --git a/lang/en/admin.php b/lang/en/admin.php index b3c3340..af5e3f0 100644 --- a/lang/en/admin.php +++ b/lang/en/admin.php @@ -19,6 +19,7 @@ return [ 'plans' => 'Plans', 'provisioning' => 'Provisioning', 'maintenance' => 'Maintenance', + 'incidents' => 'Incidents', 'vpn' => 'VPN', 'finance' => 'Finance', 'invoices' => 'Invoices', diff --git a/lang/en/admin_incidents.php b/lang/en/admin_incidents.php new file mode 100644 index 0000000..1b3138f --- /dev/null +++ b/lang/en/admin_incidents.php @@ -0,0 +1,29 @@ + '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.', +]; diff --git a/lang/en/status.php b/lang/en/status.php index b086045..4d3504f 100644 --- a/lang/en/status.php +++ b/lang/en/status.php @@ -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.', + ], ]; diff --git a/resources/views/components/status/duration.blade.php b/resources/views/components/status/duration.blade.php new file mode 100644 index 0000000..769c8da --- /dev/null +++ b/resources/views/components/status/duration.blade.php @@ -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 diff --git a/resources/views/components/status/timeline.blade.php b/resources/views/components/status/timeline.blade.php new file mode 100644 index 0000000..9d1f3dd --- /dev/null +++ b/resources/views/components/status/timeline.blade.php @@ -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 +
+ @forelse ($incident->updates as $update) +
+
+ + {{ __("status.incident.stage.{$update->status}") }} + + +
+ {{-- 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. --}} +

{!! nl2br(e($update->body)) !!}

+
+ @empty +

{{ __("status.incident.stage.investigating") }}

+ @endforelse +
diff --git a/resources/views/livewire/admin/incidents.blade.php b/resources/views/livewire/admin/incidents.blade.php new file mode 100644 index 0000000..6fb1f27 --- /dev/null +++ b/resources/views/livewire/admin/incidents.blade.php @@ -0,0 +1,141 @@ +
+
+

{{ __('admin_incidents.title') }}

+

{{ __('admin_incidents.subtitle') }}

+
+ +
+
+ {{-- 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) +
+
+
+ {{ __("status.incident.impact.{$incident->impact}") }} + {{ __('status.incident.stage.'.$incident->stage()) }} + @unless ($incident->isPublished()) + {{ __('admin_incidents.unpublished') }} + @endunless +
+

{{ $incident->title }}

+

+ {{ __('status.incident.started') }} {{ $incident->started_at->local()->isoFormat('DD.MM. HH:mm') }} + · {{ collect($incident->components)->map(fn ($c) => __("status.component.{$c}"))->join(', ') }} +

+
+ + + +
+ @if ($updatingUuid === $incident->uuid) +
+
+ + +

{{ __('admin_incidents.resolved_hint') }}

+
+
+ + + @error('updateBody')

{{ $message }}

@enderror +
+
+ {{ __('admin_incidents.post') }} + {{ __('admin_incidents.cancel') }} +
+
+ @else + + {{ __('admin_incidents.add_update') }} + + @endif +
+
+ @empty +
+ +

{{ __('admin_incidents.none_open') }}

+
+ @endforelse + + @if ($resolved->isNotEmpty()) +
+

{{ __('admin_incidents.closed') }}

+ + + @foreach ($resolved as $incident) + + + + + + @endforeach + +
{{ $incident->title }} + {{ $incident->started_at->local()->isoFormat('DD.MM.YY HH:mm') }} + + +
+
+ @endif +
+ + {{-- Report. A page-level form, not a row editor: it creates a record + rather than editing one, so R20 does not apply. --}} +
+

{{ __('admin_incidents.report') }}

+

{{ __('admin_incidents.report_hint') }}

+ +
+
+ + + @error('title')

{{ $message }}

@enderror +
+ +
+ {{ __('admin_incidents.affects') }} +
+ @foreach ($this->componentOptions() as $component) + + @endforeach +
+ @error('components')

{{ $message }}

@enderror +
+ +
+ + +
+ +
+ + + @error('startedAt')

{{ $message }}

@enderror +
+ +
+ + + @error('firstUpdate')

{{ $message }}

@enderror +
+ + {{ __('admin_incidents.publish') }} +
+
+
+
diff --git a/resources/views/status.blade.php b/resources/views/status.blade.php index 0fd23cb..ae36c83 100644 --- a/resources/views/status.blade.php +++ b/resources/views/status.blade.php @@ -1,132 +1,218 @@ - - - - - -Status — CluPilot Cloud - - - +{{-- + The public status page. -@verbatim - -@endverbatim - - -
-
- CluPilot Cloud -

Betriebsstatus

+ -
- - {{ __('status.overall.'.$overall) }} +
+ + {{-- ── The one sentence somebody came for ───────────────────────────── --}} +

{{ __('status.page.title') }}

+ +
+ +

{{ __("status.overall.{$overall}") }}

-
-
- @foreach ($components as $c) -
-
- {{ __('status.component.'.$c['key']) }} - - @if ($c['detail'] !== null) - {{ __('status.detail.'.$c['key'], $c['detail']) }} - @else - {{ __('status.about.'.$c['key']) }} - @endif - -
- {{ __('status.state.'.$c['state']) }} -
+

+ {{ __('status.page.checked_at', ['time' => $checkedAt->local()->isoFormat('DD.MM.YYYY, HH:mm')]) }} + · {{ __('status.page.timezone', ['zone' => config('app.display_timezone')]) }} +

+ + {{-- ── Announced work ───────────────────────────────────────────────── --}} + @if ($maintenance->isNotEmpty()) +
+

{{ __('status.maintenance.heading') }}

+
+ @foreach ($maintenance as $window) + @php $live = $window->derivedState() === 'active'; @endphp +
+
+ {{ $window->title }} + + {{ $live ? __('status.maintenance.active') : __('status.maintenance.upcoming') }} + +
+

+ {{ __('status.maintenance.window', [ + 'from' => $window->starts_at->local()->isoFormat('DD.MM. HH:mm'), + 'to' => $window->ends_at->local()->isoFormat('DD.MM. HH:mm'), + ]) }} +

+ @if ($window->public_description) +

{{ $window->public_description }}

+ @endif +
+ @endforeach +
+
+ @endif + + {{-- ── Whatever is broken right now, before the components ──────────── --}} + @foreach ($ongoing as $incident) +
+
+ {{ __('status.incident.ongoing') }} +

{{ $incident->title }}

+
+ +
@endforeach -
-

Stand: {{ $checkedAt->local()->format('d.m.Y, H:i') }} Uhr

+ {{-- ── The components, each with its ninety days ─────────────────────── --}} +
+ @foreach ($components as $component) + @php + $state = $component['state']; + $bars = $history[$component['key']] ?? ['days' => [], 'uptime' => null]; + @endphp +
+
+

{{ __("status.component.{$component['key']}") }}

+ + + {{ __("status.state.{$state}") }} + +
-

- 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 - office@clupilot.com — Antwort am selben Werktag. -

+

+ {{ __("status.about.{$component['key']}") }} + @if (! empty($component['detail'])) + {{ __("status.detail.{$component['key']}", $component['detail']) }} + @endif +

- + {{-- 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. --}} + + +
+ {{ __('status.history.from', ['days' => $historyDays]) }} + + + @if ($bars['uptime'] !== null) + {{ __('status.history.uptime', ['percent' => number_format($bars['uptime'], 2, ',', '.')]) }} + @else + {{ __('status.history.unmeasured') }} + @endif + + + {{ __('status.history.today') }} +
+
+ @endforeach +
+ +

+ @foreach (['operational', 'degraded', 'down'] as $legend) + {{ __("status.state.{$legend}") }} + @endforeach + {{ __('status.history.legend_gap') }} +

+ + {{-- ── The record ───────────────────────────────────────────────────── --}} +
+

{{ __('status.incident.past_heading') }}

+

{{ __('status.page.subscribe') }}

+ + @if ($past->isEmpty()) +
+ +

{{ __('status.incident.none', ['days' => $historyDays]) }}

+
+ @else +
+ @foreach ($past as $day => $incidents) +
+ {{-- 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. --}} +

+ {{ $incidents->first()->started_at->local()->isoFormat('D. MMMM YYYY') }} +

+
+ @foreach ($incidents as $incident) +
+
+
+

{{ $incident->title }}

+ + {{ __("status.incident.impact.{$incident->impact}") }} + +
+
+
+
{{ __('status.incident.started') }}
+
{{ $incident->started_at->local()->isoFormat('DD.MM. HH:mm') }}
+
+
+
{{ __('status.incident.resolved') }}
+
{{ $incident->resolved_at->local()->isoFormat('DD.MM. HH:mm') }}
+
+
+
{{ __('status.incident.duration') }}
+
+
+
+
{{ __('status.incident.affected') }}
+
{{ collect($incident->components)->map(fn ($c) => __("status.component.{$c}"))->join(', ') }}
+
+
+
+ +
+ @endforeach +
+
+ @endforeach +
+ @endif +
- - + + diff --git a/routes/admin.php b/routes/admin.php index 04dfd61..5d08486 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -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'); diff --git a/routes/console.php b/routes/console.php index 779a7e8..4fffc70 100644 --- a/routes/console.php +++ b/routes/console.php @@ -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(); diff --git a/tests/Feature/Admin/IncidentConsoleTest.php b/tests/Feature/Admin/IncidentConsoleTest.php new file mode 100644 index 0000000..9899281 --- /dev/null +++ b/tests/Feature/Admin/IncidentConsoleTest.php @@ -0,0 +1,121 @@ +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); +}); diff --git a/tests/Feature/SiteDesignSystemTest.php b/tests/Feature/SiteDesignSystemTest.php index 28528b5..f346b51 100644 --- a/tests/Feature/SiteDesignSystemTest.php +++ b/tests/Feature/SiteDesignSystemTest.php @@ -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('