193 lines
6.7 KiB
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();
|
|
}
|
|
}
|