From 8dc31c53f2b06677a7f5b5a9f410b07d9814e8a6 Mon Sep 17 00:00:00 2001 From: Steve Hanlon Date: Fri, 10 Jul 2026 09:17:07 +0100 Subject: [PATCH] Dashboard: add session step-detail drill-down (?site=X&hc=Y) Session list rows are now clickable links. Clicking a session shows all its steps with status badge, notes, and last-updated time. Step IDs are formatted into human-readable labels. Status badges cover all five states (done, skipped, blocked, n/a, not started). Co-Authored-By: Claude Sonnet 4.6 --- server/src/Controllers/Dashboard.php | 159 ++++++++++++++++++++++----- 1 file changed, 129 insertions(+), 30 deletions(-) diff --git a/server/src/Controllers/Dashboard.php b/server/src/Controllers/Dashboard.php index 754798e..45282dc 100644 --- a/server/src/Controllers/Dashboard.php +++ b/server/src/Controllers/Dashboard.php @@ -9,15 +9,30 @@ use AttHc\Server\Store; /** * Browser-facing HTML dashboard. * - * Auth: ?key= query param — sent once, stored in a session cookie + * Auth: ?key= query param — sent once, stored in a PHP session cookie * so the tech only has to paste the key once per browser session. + * + * Three views, all on GET /dashboard: + * /dashboard — all sites + * /dashboard?site=X — session list for one site + * /dashboard?site=X&hc=Y — step detail for one 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 !== '') { + $hcId = isset($_GET['hc']) && is_string($_GET['hc']) ? $_GET['hc'] : ''; + + if ($siteKey !== '' && $hcId !== '') { + $hc = Store::getHealthcheck($hcId); + $steps = $hc ? Store::stepsFor($hcId) : []; + self::html( + htmlspecialchars($siteKey) . ' — session', + self::renderSessionDetail($siteKey, $hcId, $hc, $steps) + ); + } elseif ($siteKey !== '') { $hcs = Store::listHealthchecksForSite($siteKey, 100); self::html(htmlspecialchars($siteKey), self::renderSiteDetail($siteKey, $hcs)); } else { @@ -37,7 +52,6 @@ final class Dashboard { 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; @@ -80,16 +94,16 @@ final class Dashboard { $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']); + $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']); + $url = '/dashboard?site=' . rawurlencode($s['site_key']); $rows .= ' ' . htmlspecialchars($s['site_key']) . ' ' . $badge . ' @@ -102,11 +116,8 @@ final class Dashboard {

All sites (' . count($sites) . ')

- - - - - + + ' . $rows . '
Site keyLatestLast session startedSessionsLast reporting URLSite keyLatestLast session startedSessionsLast reporting URL
'; @@ -123,14 +134,14 @@ final class Dashboard { $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); + $duration = self::humanDuration((int) $hc['finished_at'] - (int) $hc['started_at']); } $badge = $finished === null - ? 'in progress' - : 'complete'; + ? 'in progress' + : 'complete'; + $url = '/dashboard?site=' . rawurlencode($siteKey) . '&hc=' . rawurlencode((string) $hc['id']); $rows .= ' - ' . $started . ' + ' . $started . ' ' . $badge . ' ' . ($finished ?? '') . ' ' . ($duration !== '' ? $duration : '') . ' @@ -139,18 +150,93 @@ final class Dashboard { } return $back . '

' . htmlspecialchars($siteKey) . '

+

Click a session to see step detail.

- - - - - + + ' . $rows . '
StartedStatusFinishedDurationReporting URLStartedStatusFinishedDurationReporting URL
'; } + private static function renderSessionDetail(string $siteKey, string $hcId, ?array $hc, array $steps): string { + $siteUrl = '/dashboard?site=' . rawurlencode($siteKey); + $back = '

← All sites  /  ' . htmlspecialchars($siteKey) . '

'; + + if ($hc === null) { + return $back . '

Session not found.

'; + } + + $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 = $finished !== null ? self::humanDuration((int) $hc['finished_at'] - (int) $hc['started_at']) : null; + $badge = $finished === null + ? 'in progress' + : 'complete'; + + $meta = '
+ ' . $badge . ' + Started: ' . $started . '' + . ($finished !== null ? 'Finished: ' . $finished . '' : '') + . ($duration !== null ? 'Duration: ' . $duration . '' : '') + . 'ID: ' . htmlspecialchars($hcId) . ' +
'; + + if (empty($steps)) { + return $back . '

' . htmlspecialchars($siteKey) . ' — ' . $started . '

' + . $meta . '

No steps recorded for this session.

'; + } + + $rows = ''; + foreach ($steps as $s) { + $stepLabel = self::formatStepId((string) $s['step_id']); + $stepBadge = self::stepBadge((string) $s['status']); + $notes = trim((string) ($s['notes'] ?? '')); + $notesHtml = $notes !== '' + ? '
' . htmlspecialchars($notes) . '
' + : ''; + $updatedAt = isset($s['updated_at']) ? date('Y-m-d H:i', (int) $s['updated_at']) : ''; + $rows .= ' + ' . htmlspecialchars($stepLabel) . '
' . htmlspecialchars((string) $s['step_id']) . ' + ' . $stepBadge . ' + ' . $notesHtml . ' + ' . $updatedAt . ' + '; + } + + return $back . ' +

' . htmlspecialchars($siteKey) . ' — ' . $started . '

' + . $meta . ' + + + + + + + + ' . $rows . ' +
StepStatusNotesUpdated
'; + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private static function stepBadge(string $status): string { + $map = [ + 'done' => ['status-done', 'Done'], + 'skipped' => ['status-skipped', 'Skipped'], + 'blocked' => ['status-blocked', 'Blocked'], + 'n_a' => ['status-na', 'N/A'], + 'not_started' => ['status-not-started', 'Not started'], + ]; + [$cls, $label] = $map[$status] ?? ['status-not-started', htmlspecialchars($status)]; + return '' . $label . ''; + } + + private static function formatStepId(string $id): string { + return ucwords(str_replace(['-', '_'], ' ', $id)); + } + private static function humanDuration(int $seconds): string { if ($seconds < 60) return $seconds . 's'; if ($seconds < 3600) return round($seconds / 60) . 'min'; @@ -171,26 +257,39 @@ final class Dashboard { *, *::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; } +.wrap { max-width: 1200px; 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; } +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; } + border: 1px solid #c3c4c7; border-radius: 6px; overflow: hidden; + margin-bottom: 1.5rem; } +th, td { text-align: left; padding: .55rem .85rem; border-bottom: 1px solid #dcdcde; + vertical-align: top; } 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; } + font-size: .8em; font-weight: 600; white-space: nowrap; } +.status-done { background: #d8f3dc; color: #1a6632; } +.status-in-progress { background: #fef9e7; color: #8a6d01; } +.status-skipped { background: #f0f0f1; color: #646970; } +.status-blocked { background: #fce8e8; color: #8c2020; } +.status-na { background: #f0f0f1; color: #646970; } +.status-not-started { background: #f6f7f7; color: #8c8f94; border: 1px solid #dcdcde; } .muted { color: #646970; } .small { font-size: .85em; } .error { color: #b32d2e; } +.session-meta { display: flex; flex-wrap: wrap; gap: .5rem 1.5rem; align-items: center; + margin: .5rem 0 1rem; padding: .6rem .85rem; + background: #fff; border: 1px solid #c3c4c7; border-radius: 6px; + font-size: .9em; } +.step-name { white-space: nowrap; } +pre.step-notes { margin: 0; font-family: inherit; white-space: pre-wrap; + font-size: .9em; line-height: 1.45; } /* login */ .login-box { background: #fff; border: 1px solid #c3c4c7; border-radius: 6px; max-width: 360px; margin: 4rem auto; padding: 1.5rem; }