Central history server + plugin write-through sync (epic hc-0p1)

Adds a PHP/SQLite history server in server/ and refactors the plugin to
write every session change through it. Healthcheck history now survives
plugin uninstall and groups across dev + live URLs for the same engagement
via an editable site_key (defaults to the normalised host).

Server (server/):
- Front controller + hand-rolled autoloader, no framework, no composer
- SQLite default DSN; swap to MySQL by changing config.php
- Schema: healthchecks (PK id, UNIQUE (site_key, started_at)) + step_updates
  (PK (healthcheck_id, step_id)) + request_log; auto-migration runner
- 8 endpoints: POST/GET/PUT healthchecks, PUT/GET step rows, GET step history
  with exclude_id, GET /sites (recent), GET /step-counts (badge data)
- Bearer auth via hash_equals; HTTPS expected (plugin enforces client-side)
- DEPLOY.md with Apache/nginx vhosts, Let's Encrypt, SQLite backup cron,
  and the /home/www/ perm gotcha
- dev-router.php works around PHP -S 405-ing dotted uniqid paths

Plugin:
- ATT_HC_Api HTTP client reads ATT_HC_API_URL/ATT_HC_API_KEY constants
  from wp-config.php; refuses non-HTTPS with a loopback dev exception
- ATT_HC_Session is now write-through: every start/update_step/finish/
  set_autocheck POSTs or PUTs to the server first, then updates the local
  WP option cache. No drift possible — failures throw ATT_HC_Api_Exception
- previous() now reads from /healthchecks?include=steps and reconstructs;
  the old att_hc_previous_session local option is gone
- ATT_HC_Session::resume(id) hydrates a server session into the local cache
- Start screen: editable site_key (defaults to normalise_site_url()),
  datalist of recent engagements, table of in-progress sessions for the
  chosen key with Resume buttons. Double-click guard on start + resume
  handlers short-circuits if a session is already active
- Per-step <details> disclosure shows "Previous notes (N)" badge from
  /step-counts; lazy-loads detail rows on first expand via admin-ajax,
  caches via data-loaded, resets on error so user can retry
- All admin handlers catch ATT_HC_Api_Exception and surface via
  att_hc_api_error transient → admin notice
- Hard config-error gate at the top of the admin page blocks the UI when
  ATT_HC_API_URL/ATT_HC_API_KEY are missing or malformed

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-29 12:43:24 +01:00
parent 6dd050ea1d
commit 1782e52504
26 changed files with 1548 additions and 37 deletions

38
server/src/Auth.php Normal file
View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace AttHc\Server;
final class Auth {
/** Halts with 401 if Authorization header doesn't match the configured key. */
public static function require(): void {
$expected = (string) Config::get('api_key', '');
if ($expected === '' || $expected === 'REPLACE_WITH_A_LONG_RANDOM_STRING') {
Http::error(500, 'no_api_key', 'server has no api_key configured');
exit;
}
$header = self::headerValue();
if ($header === null || !str_starts_with($header, 'Bearer ')) {
Http::error(401, 'unauthorized', 'missing bearer token');
exit;
}
$given = substr($header, 7);
if (!hash_equals($expected, $given)) {
Http::error(401, 'unauthorized', 'invalid credentials');
exit;
}
}
private static function headerValue(): ?string {
if (!empty($_SERVER['HTTP_AUTHORIZATION'])) return (string) $_SERVER['HTTP_AUTHORIZATION'];
// Apache strips Authorization unless rewrite copies it; check both forms.
if (!empty($_SERVER['REDIRECT_HTTP_AUTHORIZATION'])) return (string) $_SERVER['REDIRECT_HTTP_AUTHORIZATION'];
if (function_exists('getallheaders')) {
$headers = getallheaders();
foreach ($headers as $name => $value) {
if (strcasecmp($name, 'Authorization') === 0) return (string) $value;
}
}
return null;
}
}

16
server/src/Config.php Normal file
View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace AttHc\Server;
final class Config {
private static array $data = [];
public static function load(array $data): void {
self::$data = $data;
}
public static function get(string $key, mixed $default = null): mixed {
return self::$data[$key] ?? $default;
}
}

View File

@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace AttHc\Server\Controllers;
use AttHc\Server\Http;
use AttHc\Server\Store;
use AttHc\Server\Validate;
final class Healthchecks {
public static function create(): void {
$body = Http::readJsonBody();
$row = [
'id' => Validate::requireString($body, 'id', 64),
'site_key' => Validate::requireString($body, 'site_key', 255),
'started_at' => Validate::requireInt($body, 'started_at'),
'reporting_url' => Validate::requireString($body, 'reporting_url', 512),
'technician_id' => Validate::optionalInt($body, 'technician_id'),
'wp_version' => Validate::optionalString($body, 'wp_version', 32),
'php_version' => Validate::optionalString($body, 'php_version', 32),
];
try {
Store::insertHealthcheck($row);
} catch (\PDOException $e) {
if (self::isUniqueViolation($e)) {
Http::error(409, 'duplicate', 'a healthcheck with this id or (site_key, started_at) already exists');
return;
}
throw $e;
}
Http::json(201, ['ok' => true, 'id' => $row['id']]);
}
public static function update(array $params): void {
$id = $params['id'];
if (Store::getHealthcheck($id) === null) {
Http::error(404, 'not_found', 'no healthcheck with that id');
return;
}
$body = Http::readJsonBody();
$finishedAt = Validate::optionalInt($body, 'finished_at');
Store::updateHealthcheck($id, $finishedAt);
Http::json(200, ['ok' => true]);
}
public static function get(array $params): void {
$hc = Store::getHealthcheck($params['id']);
if ($hc === null) {
Http::error(404, 'not_found', 'no healthcheck with that id');
return;
}
$hc['steps'] = Store::stepsFor($hc['id']);
Http::json(200, $hc);
}
public static function list(): void {
$siteKey = $_GET['site_key'] ?? '';
if (!is_string($siteKey) || $siteKey === '') {
Http::error(422, 'invalid', 'site_key query param is required');
return;
}
$limit = max(1, min(200, (int) ($_GET['limit'] ?? 50)));
$rows = Store::listHealthchecksForSite($siteKey, $limit);
$includeSteps = (($_GET['include'] ?? '') === 'steps');
if ($includeSteps) {
foreach ($rows as &$r) $r['steps'] = Store::stepsFor($r['id']);
}
Http::json(200, ['healthchecks' => $rows]);
}
private static function isUniqueViolation(\PDOException $e): bool {
// SQLite: 'UNIQUE constraint failed' / SQLSTATE 23000. MySQL: 1062 / 23000.
$msg = $e->getMessage();
return str_contains($msg, 'UNIQUE constraint failed')
|| str_contains($msg, 'Duplicate entry')
|| ($e->getCode() === '23000');
}
}

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace AttHc\Server\Controllers;
use AttHc\Server\Http;
use AttHc\Server\Store;
final class Sites {
public static function recent(): void {
$limit = max(1, min(100, (int) ($_GET['limit'] ?? 20)));
Http::json(200, ['sites' => Store::recentSiteKeys($limit)]);
}
}

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace AttHc\Server\Controllers;
use AttHc\Server\Http;
use AttHc\Server\Store;
use AttHc\Server\Validate;
final class Steps {
public static function upsert(array $params): void {
$healthcheckId = $params['id'];
$stepId = $params['step_id'];
if (Store::getHealthcheck($healthcheckId) === null) {
Http::error(404, 'not_found', 'no healthcheck with that id');
return;
}
$body = Http::readJsonBody();
$row = [
'status' => Validate::status(Validate::requireString($body, 'status', 32)),
'notes' => $body['notes'] ?? '',
'reporting_url' => Validate::requireString($body, 'reporting_url', 512),
'autocheck' => isset($body['autocheck']) && is_array($body['autocheck']) ? $body['autocheck'] : null,
];
if (!is_string($row['notes'])) Validate::fail('notes must be a string');
Store::upsertStep($healthcheckId, $stepId, $row);
Http::json(200, ['ok' => true]);
}
public static function history(array $params): void {
$stepId = $params['step_id'];
$siteKey = $_GET['site_key'] ?? '';
if (!is_string($siteKey) || $siteKey === '') {
Http::error(422, 'invalid', 'site_key query param is required');
return;
}
$limit = max(1, min(50, (int) ($_GET['limit'] ?? 5)));
$excludeId = isset($_GET['exclude_id']) && is_string($_GET['exclude_id']) ? $_GET['exclude_id'] : null;
Http::json(200, ['history' => Store::stepHistory($stepId, $siteKey, $limit, $excludeId)]);
}
public static function counts(): void {
$siteKey = $_GET['site_key'] ?? '';
if (!is_string($siteKey) || $siteKey === '') {
Http::error(422, 'invalid', 'site_key query param is required');
return;
}
$excludeId = isset($_GET['exclude_id']) && is_string($_GET['exclude_id']) ? $_GET['exclude_id'] : null;
Http::json(200, ['counts' => Store::stepCountsForSite($siteKey, $excludeId)]);
}
}

47
server/src/Db.php Normal file
View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace AttHc\Server;
use PDO;
final class Db {
private static ?PDO $pdo = null;
public static function pdo(): PDO {
if (self::$pdo !== null) return self::$pdo;
$dsn = (string) Config::get('db_dsn', '');
$user = Config::get('db_user');
$pass = Config::get('db_pass');
if ($dsn === '') {
Http::error(500, 'no_db_dsn', 'server has no db_dsn configured');
exit;
}
if (str_starts_with($dsn, 'sqlite:')) {
$path = substr($dsn, 7);
if ($path !== '' && $path !== ':memory:') {
$dir = dirname($path);
if (!is_dir($dir)) @mkdir($dir, 0775, true);
}
}
$pdo = new PDO($dsn, $user, $pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
if (str_starts_with($dsn, 'sqlite:')) {
$pdo->exec('PRAGMA foreign_keys = ON');
$pdo->exec('PRAGMA journal_mode = WAL');
$pdo->exec('PRAGMA synchronous = NORMAL');
}
self::$pdo = $pdo;
Migrations::ensureRan($pdo);
return $pdo;
}
}

28
server/src/Http.php Normal file
View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace AttHc\Server;
final class Http {
public static function json(int $status, array $body): void {
http_response_code($status);
header('Content-Type: application/json');
echo json_encode($body, JSON_UNESCAPED_SLASHES);
}
public static function error(int $status, string $code, string $message): void {
self::json($status, ['error' => $message, 'code' => $code]);
}
public static function readJsonBody(): array {
$raw = file_get_contents('php://input') ?: '';
if ($raw === '') return [];
try {
$decoded = json_decode($raw, true, 32, JSON_THROW_ON_ERROR);
} catch (\JsonException $e) {
self::error(400, 'bad_json', 'request body is not valid JSON');
exit;
}
return is_array($decoded) ? $decoded : [];
}
}

51
server/src/Migrations.php Normal file
View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace AttHc\Server;
use PDO;
final class Migrations {
/**
* Runs any *.sql files in server/migrations/ that haven't been applied yet.
* Idempotent: tracks applied filenames in the `migrations` table.
*/
public static function ensureRan(PDO $pdo): void {
$pdo->exec('CREATE TABLE IF NOT EXISTS migrations (
filename TEXT PRIMARY KEY,
applied_at INTEGER NOT NULL
)');
$dir = __DIR__ . '/../migrations';
if (!is_dir($dir)) return;
$files = glob($dir . '/*.sql') ?: [];
sort($files);
$applied = [];
foreach ($pdo->query('SELECT filename FROM migrations')->fetchAll(PDO::FETCH_COLUMN) as $f) {
$applied[$f] = true;
}
foreach ($files as $path) {
$name = basename($path);
if (isset($applied[$name])) continue;
$sql = file_get_contents($path);
if ($sql === false) {
throw new \RuntimeException('failed to read migration: ' . $name);
}
$pdo->beginTransaction();
try {
$pdo->exec($sql);
$stmt = $pdo->prepare('INSERT INTO migrations (filename, applied_at) VALUES (?, ?)');
$stmt->execute([$name, time()]);
$pdo->commit();
} catch (\Throwable $e) {
$pdo->rollBack();
throw new \RuntimeException('migration failed: ' . $name . ' — ' . $e->getMessage(), 0, $e);
}
}
}
}

40
server/src/Router.php Normal file
View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace AttHc\Server;
final class Router {
/** @var array<int, array{0:string,1:string,2:callable,3:bool}> [method, pattern, handler, requires_auth] */
private array $routes = [];
public function add(string $method, string $pattern, callable $handler, bool $requiresAuth = true): void {
$this->routes[] = [strtoupper($method), $pattern, $handler, $requiresAuth];
}
public function dispatch(string $method, string $path): void {
$method = strtoupper($method);
foreach ($this->routes as [$m, $pattern, $handler, $requiresAuth]) {
if ($m !== $method) continue;
$params = $this->match($pattern, $path);
if ($params === null) continue;
if ($requiresAuth) Auth::require();
$handler($params);
return;
}
Http::error(404, 'not_found', 'no route matches ' . $method . ' ' . $path);
}
/** Returns captured params on match, null otherwise. Patterns use {name} placeholders. */
private function match(string $pattern, string $path): ?array {
$regex = preg_replace_callback('#\{([a-zA-Z_][a-zA-Z0-9_]*)\}#', static function ($m): string {
return '(?P<' . $m[1] . '>[^/]+)';
}, $pattern);
$regex = '#^' . $regex . '$#';
if (!preg_match($regex, $path, $matches)) return null;
$params = [];
foreach ($matches as $k => $v) {
if (is_string($k)) $params[$k] = $v;
}
return $params;
}
}

158
server/src/Store.php Normal file
View File

@@ -0,0 +1,158 @@
<?php
declare(strict_types=1);
namespace AttHc\Server;
/**
* Thin DB access layer. All methods return plain arrays, ready to JSON-encode.
* Timestamps stored as unix seconds (UTC).
*/
final class Store {
public static function insertHealthcheck(array $row): void {
$now = time();
$stmt = Db::pdo()->prepare('INSERT INTO healthchecks
(id, site_key, started_at, finished_at, technician_id, reporting_url, wp_version, php_version, created_at, updated_at)
VALUES (:id, :site_key, :started_at, NULL, :technician_id, :reporting_url, :wp_version, :php_version, :created_at, :updated_at)');
$stmt->execute([
':id' => $row['id'],
':site_key' => $row['site_key'],
':started_at' => $row['started_at'],
':technician_id' => $row['technician_id'] ?? null,
':reporting_url' => $row['reporting_url'],
':wp_version' => $row['wp_version'] ?? null,
':php_version' => $row['php_version'] ?? null,
':created_at' => $now,
':updated_at' => $now,
]);
}
public static function getHealthcheck(string $id): ?array {
$stmt = Db::pdo()->prepare('SELECT * FROM healthchecks WHERE id = ?');
$stmt->execute([$id]);
$row = $stmt->fetch();
return $row ?: null;
}
public static function updateHealthcheck(string $id, ?int $finishedAt): bool {
$stmt = Db::pdo()->prepare('UPDATE healthchecks SET finished_at = :finished_at, updated_at = :updated_at WHERE id = :id');
$stmt->execute([
':id' => $id,
':finished_at' => $finishedAt,
':updated_at' => time(),
]);
return $stmt->rowCount() > 0;
}
public static function upsertStep(string $healthcheckId, string $stepId, array $row): void {
$stmt = Db::pdo()->prepare('INSERT INTO step_updates
(healthcheck_id, step_id, status, notes, autocheck_json, reporting_url, updated_at)
VALUES (:hc, :sid, :status, :notes, :autocheck, :reporting_url, :updated_at)
ON CONFLICT (healthcheck_id, step_id) DO UPDATE SET
status = excluded.status,
notes = excluded.notes,
autocheck_json = excluded.autocheck_json,
reporting_url = excluded.reporting_url,
updated_at = excluded.updated_at');
$stmt->execute([
':hc' => $healthcheckId,
':sid' => $stepId,
':status' => $row['status'],
':notes' => $row['notes'] ?? '',
':autocheck' => isset($row['autocheck']) ? json_encode($row['autocheck']) : null,
':reporting_url' => $row['reporting_url'],
':updated_at' => time(),
]);
// Bump parent's updated_at so list views sort sensibly.
Db::pdo()->prepare('UPDATE healthchecks SET updated_at = ? WHERE id = ?')
->execute([time(), $healthcheckId]);
}
/** @return array<int, array> */
public static function stepsFor(string $healthcheckId): array {
$stmt = Db::pdo()->prepare('SELECT step_id, status, notes, autocheck_json, reporting_url, updated_at
FROM step_updates WHERE healthcheck_id = ? ORDER BY updated_at ASC');
$stmt->execute([$healthcheckId]);
$rows = $stmt->fetchAll();
foreach ($rows as &$r) {
$r['autocheck'] = $r['autocheck_json'] !== null ? json_decode($r['autocheck_json'], true) : null;
unset($r['autocheck_json']);
}
return $rows;
}
/** @return array<int, array> */
public static function listHealthchecksForSite(string $siteKey, int $limit = 50): array {
$stmt = Db::pdo()->prepare('SELECT * FROM healthchecks WHERE site_key = ? ORDER BY started_at DESC LIMIT ?');
$stmt->bindValue(1, $siteKey);
$stmt->bindValue(2, $limit, \PDO::PARAM_INT);
$stmt->execute();
return $stmt->fetchAll();
}
/**
* Notes for one step across the last N sessions for a site.
* @return array<int, array>
*/
public static function stepHistory(string $stepId, string $siteKey, int $limit = 5, ?string $excludeId = null): array {
$sql = 'SELECT h.id AS healthcheck_id, h.started_at, h.finished_at, h.reporting_url AS session_reporting_url,
s.status, s.notes, s.autocheck_json, s.reporting_url AS step_reporting_url, s.updated_at
FROM step_updates s
JOIN healthchecks h ON h.id = s.healthcheck_id
WHERE s.step_id = ? AND h.site_key = ?';
$params = [$stepId, $siteKey];
if ($excludeId !== null && $excludeId !== '') {
$sql .= ' AND h.id != ?';
$params[] = $excludeId;
}
$sql .= ' ORDER BY h.started_at DESC LIMIT ?';
$stmt = Db::pdo()->prepare($sql);
$i = 1;
foreach ($params as $p) {
$stmt->bindValue($i++, $p);
}
$stmt->bindValue($i, $limit, \PDO::PARAM_INT);
$stmt->execute();
$rows = $stmt->fetchAll();
foreach ($rows as &$r) {
$r['autocheck'] = $r['autocheck_json'] !== null ? json_decode($r['autocheck_json'], true) : null;
unset($r['autocheck_json']);
}
return $rows;
}
/**
* Per-step note count for every step that has at least one row for the site.
* Drives the "Previous notes (N)" disclosure on each step card.
* @return array<string, int> step_id => count
*/
public static function stepCountsForSite(string $siteKey, ?string $excludeId = null): array {
$sql = 'SELECT s.step_id, COUNT(*) AS c
FROM step_updates s
JOIN healthchecks h ON h.id = s.healthcheck_id
WHERE h.site_key = ?';
$params = [$siteKey];
if ($excludeId !== null && $excludeId !== '') {
$sql .= ' AND h.id != ?';
$params[] = $excludeId;
}
$sql .= ' GROUP BY s.step_id';
$stmt = Db::pdo()->prepare($sql);
$stmt->execute($params);
$out = [];
foreach ($stmt->fetchAll() as $row) {
$out[(string) $row['step_id']] = (int) $row['c'];
}
return $out;
}
/** @return array<int, string> */
public static function recentSiteKeys(int $limit = 20): array {
$stmt = Db::pdo()->prepare('SELECT site_key, MAX(updated_at) AS last_seen
FROM healthchecks GROUP BY site_key ORDER BY last_seen DESC LIMIT ?');
$stmt->bindValue(1, $limit, \PDO::PARAM_INT);
$stmt->execute();
$rows = $stmt->fetchAll();
return array_map(static fn($r) => ['site_key' => $r['site_key'], 'last_seen' => (int) $r['last_seen']], $rows);
}
}

46
server/src/Validate.php Normal file
View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace AttHc\Server;
final class Validate {
/** Hard exit 422 with a message. */
public static function fail(string $message): never {
Http::error(422, 'invalid', $message);
exit;
}
public static function requireString(array $body, string $key, int $maxLen = 1024): string {
if (!isset($body[$key]) || !is_string($body[$key]) || $body[$key] === '') {
self::fail("missing or empty string field: {$key}");
}
if (strlen($body[$key]) > $maxLen) self::fail("{$key} exceeds {$maxLen} chars");
return $body[$key];
}
public static function optionalString(array $body, string $key, int $maxLen = 1024): ?string {
if (!isset($body[$key])) return null;
if (!is_string($body[$key])) self::fail("{$key} must be a string");
if (strlen($body[$key]) > $maxLen) self::fail("{$key} exceeds {$maxLen} chars");
return $body[$key];
}
public static function requireInt(array $body, string $key): int {
if (!isset($body[$key]) || !is_int($body[$key])) {
self::fail("missing or non-int field: {$key}");
}
return $body[$key];
}
public static function optionalInt(array $body, string $key): ?int {
if (!isset($body[$key]) || $body[$key] === null) return null;
if (!is_int($body[$key])) self::fail("{$key} must be an integer");
return $body[$key];
}
public static function status(string $status): string {
$valid = ['not_started', 'done', 'skipped', 'blocked', 'n_a'];
if (!in_array($status, $valid, true)) self::fail('status must be one of ' . implode(', ', $valid));
return $status;
}
}

40
server/src/bootstrap.php Normal file
View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace AttHc\Server;
// Hand-rolled PSR-4-ish autoloader. No composer dependency.
spl_autoload_register(static function (string $class): void {
$prefix = 'AttHc\\Server\\';
if (strncmp($class, $prefix, strlen($prefix)) !== 0) return;
$relative = substr($class, strlen($prefix));
$path = __DIR__ . '/' . str_replace('\\', '/', $relative) . '.php';
if (is_file($path)) require $path;
});
error_reporting(E_ALL);
ini_set('display_errors', '0');
ini_set('log_errors', '1');
set_error_handler(static function (int $severity, string $message, string $file, int $line): bool {
if (!(error_reporting() & $severity)) return false;
throw new \ErrorException($message, 0, $severity, $file, $line);
});
set_exception_handler(static function (\Throwable $e): void {
error_log('[att-hc-server] ' . $e::class . ': ' . $e->getMessage() . ' @ ' . $e->getFile() . ':' . $e->getLine());
if (!headers_sent()) {
http_response_code(500);
header('Content-Type: application/json');
}
echo json_encode(['error' => 'internal server error', 'code' => 'internal']);
});
$configPath = __DIR__ . '/../config.php';
if (!is_file($configPath)) {
http_response_code(500);
header('Content-Type: application/json');
echo json_encode(['error' => 'server not configured: missing config.php', 'code' => 'no_config']);
exit;
}
Config::load(require $configPath);

31
server/src/routes.php Normal file
View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
use AttHc\Server\Config;
use AttHc\Server\Controllers\Healthchecks;
use AttHc\Server\Controllers\Sites;
use AttHc\Server\Controllers\Steps;
use AttHc\Server\Http;
use AttHc\Server\Router;
$router = new Router();
// Health check / version. Public, no auth — used by the plugin to verify reachability before submitting.
$router->add('GET', '/', static function (): void {
Http::json(200, [
'ok' => true,
'service' => 'att-site-healthcheck-server',
'version' => (string) Config::get('version', '0.0.0'),
]);
}, requiresAuth: false);
$router->add('POST', '/healthchecks', [Healthchecks::class, 'create']);
$router->add('GET', '/healthchecks', [Healthchecks::class, 'list']);
$router->add('GET', '/healthchecks/{id}', [Healthchecks::class, 'get']);
$router->add('PUT', '/healthchecks/{id}', [Healthchecks::class, 'update']);
$router->add('PUT', '/healthchecks/{id}/steps/{step_id}', [Steps::class, 'upsert']);
$router->add('GET', '/healthchecks/steps/{step_id}', [Steps::class, 'history']);
$router->add('GET', '/step-counts', [Steps::class, 'counts']);
$router->add('GET', '/sites', [Sites::class, 'recent']);
return $router;