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 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 09:17:07 +01:00
parent 9f13d871bf
commit 8dc31c53f2

View File

@@ -9,15 +9,30 @@ use AttHc\Server\Store;
/**
* Browser-facing HTML dashboard.
*
* Auth: ?key=<api_key> query param — sent once, stored in a session cookie
* Auth: ?key=<api_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;
@@ -81,8 +95,8 @@ final class Dashboard {
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>';
? '<span class="badge status-in-progress">in progress</span>'
: '<span class="badge status-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' : '');
@@ -102,11 +116,8 @@ final class Dashboard {
<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>
<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>';
@@ -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
? '<span class="badge in-progress">in progress</span>'
: '<span class="badge done">complete</span>';
? '<span class="badge status-in-progress">in progress</span>'
: '<span class="badge status-done">complete</span>';
$url = '/dashboard?site=' . rawurlencode($siteKey) . '&hc=' . rawurlencode((string) $hc['id']);
$rows .= '<tr>
<td>' . $started . '</td>
<td><a href="' . $url . '">' . $started . '</a></td>
<td>' . $badge . '</td>
<td>' . ($finished ?? '<span class="muted">—</span>') . '</td>
<td>' . ($duration !== '' ? $duration : '<span class="muted">—</span>') . '</td>
@@ -139,18 +150,93 @@ final class Dashboard {
}
return $back . '
<h2>' . htmlspecialchars($siteKey) . '</h2>
<p class="muted small">Click a session to see step detail.</p>
<table>
<thead><tr>
<th>Started</th>
<th>Status</th>
<th>Finished</th>
<th>Duration</th>
<th>Reporting URL</th>
<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 renderSessionDetail(string $siteKey, string $hcId, ?array $hc, array $steps): string {
$siteUrl = '/dashboard?site=' . rawurlencode($siteKey);
$back = '<p><a href="/dashboard">&larr; All sites</a> &nbsp;/&nbsp; <a href="' . $siteUrl . '">' . htmlspecialchars($siteKey) . '</a></p>';
if ($hc === null) {
return $back . '<p class="error">Session not found.</p>';
}
$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
? '<span class="badge status-in-progress">in progress</span>'
: '<span class="badge status-done">complete</span>';
$meta = '<div class="session-meta">
<span>' . $badge . '</span>
<span>Started: <strong>' . $started . '</strong></span>'
. ($finished !== null ? '<span>Finished: <strong>' . $finished . '</strong></span>' : '')
. ($duration !== null ? '<span>Duration: <strong>' . $duration . '</strong></span>' : '')
. '<span class="muted small">ID: ' . htmlspecialchars($hcId) . '</span>
</div>';
if (empty($steps)) {
return $back . '<h2>' . htmlspecialchars($siteKey) . ' — ' . $started . '</h2>'
. $meta . '<p class="muted">No steps recorded for this session.</p>';
}
$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 !== ''
? '<pre class="step-notes">' . htmlspecialchars($notes) . '</pre>'
: '<span class="muted">—</span>';
$updatedAt = isset($s['updated_at']) ? date('Y-m-d H:i', (int) $s['updated_at']) : '';
$rows .= '<tr>
<td class="step-name">' . htmlspecialchars($stepLabel) . '<br><span class="muted small">' . htmlspecialchars((string) $s['step_id']) . '</span></td>
<td>' . $stepBadge . '</td>
<td>' . $notesHtml . '</td>
<td class="muted small">' . $updatedAt . '</td>
</tr>';
}
return $back . '
<h2>' . htmlspecialchars($siteKey) . ' — ' . $started . '</h2>'
. $meta . '
<table class="steps-table">
<thead><tr>
<th style="width:18%">Step</th>
<th style="width:12%">Status</th>
<th>Notes</th>
<th style="width:12%">Updated</th>
</tr></thead>
<tbody>' . $rows . '</tbody>
</table>';
}
// ── 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 '<span class="badge ' . $cls . '">' . $label . '</span>';
}
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; }
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; }