diff --git a/server/src/Controllers/Dashboard.php b/server/src/Controllers/Dashboard.php
new file mode 100644
index 0000000..754798e
--- /dev/null
+++ b/server/src/Controllers/Dashboard.php
@@ -0,0 +1,217 @@
+ 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', '
No API key configured on server.
');
+ 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 !== '' ? '' . htmlspecialchars($error) . '
' : '';
+ self::html('Sign in', '
+
+
Site Healthcheck — History
+ ' . $err . '
+
+
+ ');
+ }
+
+ // ── Renderers ─────────────────────────────────────────────────────────────
+
+ private static function renderSiteList(array $sites): string {
+ if (empty($sites)) {
+ return 'No healthchecks recorded yet.
';
+ }
+ $rows = '';
+ foreach ($sites as $s) {
+ $inProgress = $s['last_finished_at'] === null;
+ $badge = $inProgress
+ ? 'in progress'
+ : 'complete';
+ $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 .= ' (' . $unfinished . ' open)';
+ }
+ $url = '/dashboard?site=' . rawurlencode($s['site_key']);
+ $rows .= '
+ | ' . htmlspecialchars($s['site_key']) . ' |
+ ' . $badge . ' |
+ ' . $lastDate . ' |
+ ' . $sessionStr . ' |
+ ' . htmlspecialchars($s['last_reporting_url']) . ' |
+
';
+ }
+ return '
+ All sites (' . count($sites) . ')
+
+
+ | Site key |
+ Latest |
+ Last session started |
+ Sessions |
+ Last reporting URL |
+
+ ' . $rows . '
+
';
+ }
+
+ private static function renderSiteDetail(string $siteKey, array $hcs): string {
+ $back = '← All sites
';
+ if (empty($hcs)) {
+ return $back . 'No sessions found for this site.
';
+ }
+ $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
+ ? 'in progress'
+ : 'complete';
+ $rows .= '
+ | ' . $started . ' |
+ ' . $badge . ' |
+ ' . ($finished ?? '—') . ' |
+ ' . ($duration !== '' ? $duration : '—') . ' |
+ ' . htmlspecialchars((string) $hc['reporting_url']) . ' |
+
';
+ }
+ return $back . '
+ ' . htmlspecialchars($siteKey) . '
+
+
+ | Started |
+ Status |
+ Finished |
+ Duration |
+ Reporting URL |
+
+ ' . $rows . '
+
';
+ }
+
+ 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 '
+
+
+
+
+' . htmlspecialchars($title) . ' — Site Healthcheck
+
+
+
+
+
+';
+ }
+}
diff --git a/server/src/Store.php b/server/src/Store.php
index 41441f0..2c89afa 100644
--- a/server/src/Store.php
+++ b/server/src/Store.php
@@ -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
+ */
+ 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;
+ }
}
diff --git a/server/src/routes.php b/server/src/routes.php
index 0a342b0..45a06ab 100644
--- a/server/src/routes.php
+++ b/server/src/routes.php
@@ -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;