CluPilotCloud/app/Http/Controllers/StatusController.php

193 lines
6.7 KiB
PHP

<?php
namespace App\Http\Controllers;
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 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.
*
* 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
{
/** How much history the bars show. Ninety days is the convention. */
private const HISTORY_DAYS = 90;
/** How far back the written record goes on this page. */
private const INCIDENT_DAYS = 90;
public function __invoke(ServiceHealth $health): View
{
$components = $health->components();
// 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' => $health->overall($components),
'history' => $this->history(),
'ongoing' => $ongoing,
'past' => $this->past(),
'maintenance' => $this->maintenance(),
'checkedAt' => Carbon::now(),
'historyDays' => self::HISTORY_DAYS,
]);
}
/**
* Let an open incident set the state of the components it names.
*
* 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.
*
* @param array<int, array<string, mixed>> $components
* @param Collection<int, Incident> $ongoing
* @return array<int, array<string, mixed>>
*/
private function applyIncidents(array $components, Collection $ongoing): array
{
$rank = ['operational' => 0, 'unknown' => 1, 'degraded' => 2, 'down' => 3];
foreach ($components as $i => $component) {
foreach ($ongoing as $incident) {
if (! in_array($component['key'], $incident->components ?? [], true)) {
continue;
}
// 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;
}
}
return $components;
}
/**
* The daily bars, one array per component, oldest first.
*
* Every day in the window appears, including the ones nobody measured —
* they come back as null and are drawn as a gap. Filling them in from the
* days around them would be inventing the only number on this page a reader
* can check against their own memory.
*
* @return array<string, array{days: array<int, array<string, mixed>>, uptime: float|null}>
*/
private function history(): array
{
$from = Carbon::now()->local()->startOfDay()->subDays(self::HISTORY_DAYS - 1);
$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,
];
}
return $history;
}
/**
* Resolved incidents, newest first, grouped by the day they started.
*
* @return Collection<string, Collection<int, Incident>>
*/
private function past(): Collection
{
return Incident::query()
->published()
->whereNotNull('resolved_at')
->where('started_at', '>=', Carbon::now()->subDays(self::INCIDENT_DAYS))
->with('updates')
->orderByDesc('started_at')
->get()
->groupBy(fn (Incident $incident) => $incident->started_at->local()->toDateString());
}
/**
* Announced work that has not finished yet.
*
* Taken from the maintenance windows the console already keeps rather than
* from a second list somebody has to remember to fill in. Published ones
* only: a draft is a plan, not an announcement.
*
* @return Collection<int, MaintenanceWindow>
*/
private function maintenance(): Collection
{
return MaintenanceWindow::query()
->whereNotNull('published_at')
->whereNull('cancelled_at')
->where('state', '!=', 'draft')
->where('ends_at', '>=', Carbon::now())
->orderBy('starts_at')
->get();
}
}