Server: HTML dashboard at /dashboard showing all sites + session history

Browsable at /dashboard — authenticates via ?key=<api_key> once per browser
session (stored in a PHP session cookie). Lists all sites ordered by most-
recently-active, with session counts and in-progress badge. Click any site
to see its full healthcheck history with dates and durations.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 09:01:05 +01:00
parent 3d67b94896
commit 9f13d871bf
3 changed files with 257 additions and 0 deletions

View File

@@ -0,0 +1,217 @@
<?php
declare(strict_types=1);
namespace AttHc\Server\Controllers;
use AttHc\Server\Config;
use AttHc\Server\Store;
/**
* Browser-facing HTML dashboard.
*
* Auth: ?key=<api_key> query param — sent once, stored in a session cookie
* so the tech only has to paste the key once per browser session.
*/
final class Dashboard {
public static function index(array $params = []): void {
self::requireAuth();
$siteKey = isset($_GET['site']) && is_string($_GET['site']) ? $_GET['site'] : '';
if ($siteKey !== '') {
$hcs = Store::listHealthchecksForSite($siteKey, 100);
self::html(htmlspecialchars($siteKey), self::renderSiteDetail($siteKey, $hcs));
} else {
$sites = Store::allSitesSummary();
self::html('All sites', self::renderSiteList($sites));
}
}
// ── Auth ─────────────────────────────────────────────────────────────────
private static function requireAuth(): void {
$expected = (string) Config::get('api_key', '');
if ($expected === '' || $expected === 'REPLACE_WITH_A_LONG_RANDOM_STRING') {
self::html('Configuration error', '<p class="error">No API key configured on server.</p>');
exit;
}
session_start();
// Accept ?key= to set/verify.
if (isset($_GET['key']) && is_string($_GET['key'])) {
if (hash_equals($expected, $_GET['key'])) {
$_SESSION['att_hc_authed'] = true;
// Redirect to clean URL (drop key from address bar).
$clean = strtok((string) ($_SERVER['REQUEST_URI'] ?? '/dashboard'), '?');
header('Location: ' . $clean);
exit;
}
self::loginPage('Incorrect key — please try again.');
exit;
}
if (empty($_SESSION['att_hc_authed'])) {
self::loginPage();
exit;
}
}
private static function loginPage(string $error = ''): void {
$err = $error !== '' ? '<p class="error">' . htmlspecialchars($error) . '</p>' : '';
self::html('Sign in', '
<div class="login-box">
<h2>Site Healthcheck — History</h2>
' . $err . '
<form method="get" action="">
<label for="key">API key</label>
<input type="password" id="key" name="key" autofocus autocomplete="current-password">
<button type="submit">Sign in</button>
</form>
</div>
');
}
// ── Renderers ─────────────────────────────────────────────────────────────
private static function renderSiteList(array $sites): string {
if (empty($sites)) {
return '<p class="muted">No healthchecks recorded yet.</p>';
}
$rows = '';
foreach ($sites as $s) {
$inProgress = $s['last_finished_at'] === null;
$badge = $inProgress
? '<span class="badge in-progress">in progress</span>'
: '<span class="badge done">complete</span>';
$lastDate = date('Y-m-d H:i', $s['last_started_at']);
$unfinished = $s['total'] - $s['finished'];
$sessionStr = $s['total'] . ' session' . ($s['total'] !== 1 ? 's' : '');
if ($unfinished > 0) {
$sessionStr .= ' <span class="muted">(' . $unfinished . ' open)</span>';
}
$url = '/dashboard?site=' . rawurlencode($s['site_key']);
$rows .= '<tr>
<td><a href="' . $url . '">' . htmlspecialchars($s['site_key']) . '</a></td>
<td>' . $badge . '</td>
<td>' . $lastDate . '</td>
<td>' . $sessionStr . '</td>
<td class="muted small">' . htmlspecialchars($s['last_reporting_url']) . '</td>
</tr>';
}
return '
<h2>All sites <span class="muted small">(' . count($sites) . ')</span></h2>
<table>
<thead><tr>
<th>Site key</th>
<th>Latest</th>
<th>Last session started</th>
<th>Sessions</th>
<th>Last reporting URL</th>
</tr></thead>
<tbody>' . $rows . '</tbody>
</table>';
}
private static function renderSiteDetail(string $siteKey, array $hcs): string {
$back = '<p><a href="/dashboard">&larr; All sites</a></p>';
if (empty($hcs)) {
return $back . '<p class="muted">No sessions found for this site.</p>';
}
$rows = '';
foreach ($hcs as $hc) {
$started = date('Y-m-d H:i', (int) $hc['started_at']);
$finished = $hc['finished_at'] !== null ? date('Y-m-d H:i', (int) $hc['finished_at']) : null;
$duration = '';
if ($finished !== null) {
$secs = (int) $hc['finished_at'] - (int) $hc['started_at'];
$duration = self::humanDuration($secs);
}
$badge = $finished === null
? '<span class="badge in-progress">in progress</span>'
: '<span class="badge done">complete</span>';
$rows .= '<tr>
<td>' . $started . '</td>
<td>' . $badge . '</td>
<td>' . ($finished ?? '<span class="muted">—</span>') . '</td>
<td>' . ($duration !== '' ? $duration : '<span class="muted">—</span>') . '</td>
<td class="muted small">' . htmlspecialchars((string) $hc['reporting_url']) . '</td>
</tr>';
}
return $back . '
<h2>' . htmlspecialchars($siteKey) . '</h2>
<table>
<thead><tr>
<th>Started</th>
<th>Status</th>
<th>Finished</th>
<th>Duration</th>
<th>Reporting URL</th>
</tr></thead>
<tbody>' . $rows . '</tbody>
</table>';
}
private static function humanDuration(int $seconds): string {
if ($seconds < 60) return $seconds . 's';
if ($seconds < 3600) return round($seconds / 60) . 'min';
return round($seconds / 3600, 1) . 'h';
}
// ── HTML shell ────────────────────────────────────────────────────────────
private static function html(string $title, string $body): void {
header('Content-Type: text/html; charset=utf-8');
echo '<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>' . htmlspecialchars($title) . ' — Site Healthcheck</title>
<style>
*, *::before, *::after { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; font-size: 15px; line-height: 1.5;
color: #1d2327; background: #f0f0f1; margin: 0; padding: 1rem; }
.wrap { max-width: 1100px; margin: 0 auto; }
header { background: #1d2327; color: #fff; padding: .6rem 1rem; border-radius: 6px;
margin-bottom: 1.5rem; display: flex; align-items: center; gap: 1rem; }
header h1 { margin: 0; font-size: 1rem; font-weight: 600; }
header a { color: #72aee6; text-decoration: none; font-size: .9em; }
table { width: 100%; border-collapse: collapse; background: #fff;
border: 1px solid #c3c4c7; border-radius: 6px; overflow: hidden; }
th, td { text-align: left; padding: .55rem .85rem; border-bottom: 1px solid #dcdcde; }
th { background: #f6f7f7; font-weight: 600; font-size: .9em; color: #3c434a; }
tr:last-child td { border-bottom: 0; }
tr:hover td { background: #f6f7f7; }
a { color: #2271b1; text-decoration: none; }
a:hover { color: #135e96; text-decoration: underline; }
.badge { display: inline-block; padding: .15em .55em; border-radius: 3px;
font-size: .8em; font-weight: 600; }
.badge.done { background: #d8f3dc; color: #1a6632; }
.badge.in-progress { background: #fef9e7; color: #8a6d01; }
.muted { color: #646970; }
.small { font-size: .85em; }
.error { color: #b32d2e; }
/* login */
.login-box { background: #fff; border: 1px solid #c3c4c7; border-radius: 6px;
max-width: 360px; margin: 4rem auto; padding: 1.5rem; }
.login-box h2 { margin: 0 0 1rem; font-size: 1.1rem; }
.login-box label { display: block; font-weight: 600; margin-bottom: .3rem; font-size: .9em; }
.login-box input { width: 100%; padding: .45rem .6rem; border: 1px solid #8c8f94;
border-radius: 4px; font-size: 1rem; margin-bottom: .75rem; }
.login-box button { background: #2271b1; color: #fff; border: none; border-radius: 4px;
padding: .5rem 1rem; font-size: 1rem; cursor: pointer; }
.login-box button:hover { background: #135e96; }
</style>
</head>
<body>
<div class="wrap">
<header>
<h1>Site Healthcheck — History</h1>
<a href="/dashboard">All sites</a>
</header>
' . $body . '
</div>
</body>
</html>';
}
}

View File

@@ -176,4 +176,42 @@ final class Store {
$rows = $stmt->fetchAll();
return array_map(static fn($r) => ['site_key' => $r['site_key'], 'last_seen' => (int) $r['last_seen']], $rows);
}
/**
* Summary of all sites for the dashboard: one row per site_key,
* ordered by most-recently-active first.
* @return array<int, array{site_key:string, total:int, finished:int, last_started_at:int, last_finished_at:int|null, last_reporting_url:string}>
*/
public static function allSitesSummary(): array {
$stmt = Db::pdo()->query(
'SELECT
site_key,
COUNT(*) AS total,
SUM(CASE WHEN finished_at IS NOT NULL THEN 1 ELSE 0 END) AS finished,
MAX(started_at) AS last_started_at
FROM healthchecks
GROUP BY site_key
ORDER BY MAX(updated_at) DESC'
);
$rows = $stmt->fetchAll();
// Fetch the reporting_url and finished_at from the most recent session per site.
$out = [];
foreach ($rows as $r) {
$detail = Db::pdo()->prepare(
'SELECT reporting_url, finished_at FROM healthchecks
WHERE site_key = ? ORDER BY started_at DESC LIMIT 1'
);
$detail->execute([$r['site_key']]);
$last = $detail->fetch() ?: [];
$out[] = [
'site_key' => (string) $r['site_key'],
'total' => (int) $r['total'],
'finished' => (int) $r['finished'],
'last_started_at' => (int) $r['last_started_at'],
'last_finished_at' => isset($last['finished_at']) && $last['finished_at'] !== null ? (int) $last['finished_at'] : null,
'last_reporting_url' => (string) ($last['reporting_url'] ?? ''),
];
}
return $out;
}
}

View File

@@ -2,6 +2,7 @@
declare(strict_types=1);
use AttHc\Server\Config;
use AttHc\Server\Controllers\Dashboard;
use AttHc\Server\Controllers\Healthchecks;
use AttHc\Server\Controllers\Sites;
use AttHc\Server\Controllers\Steps;
@@ -27,5 +28,6 @@ $router->add('PUT', '/healthchecks/{id}/steps/{step_id}', [Steps::class
$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']);
$router->add('GET', '/dashboard', [Dashboard::class, 'index'], requiresAuth: false);
return $router;