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('