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) . ')

+ + + + + + + + + ' . $rows . ' +
Site keyLatestLast session startedSessionsLast reporting URL
'; + } + + 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) . '

+ + + + + + + + + ' . $rows . ' +
StartedStatusFinishedDurationReporting URL
'; + } + + 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 + + + +
+
+

Site Healthcheck — History

+ All sites +
+' . $body . ' +
+ +'; + } +} 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;