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:
@@ -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;
|
||||
@@ -80,16 +94,16 @@ final class Dashboard {
|
||||
$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']);
|
||||
$badge = $inProgress
|
||||
? '<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' : '');
|
||||
if ($unfinished > 0) {
|
||||
$sessionStr .= ' <span class="muted">(' . $unfinished . ' open)</span>';
|
||||
}
|
||||
$url = '/dashboard?site=' . rawurlencode($s['site_key']);
|
||||
$url = '/dashboard?site=' . rawurlencode($s['site_key']);
|
||||
$rows .= '<tr>
|
||||
<td><a href="' . $url . '">' . htmlspecialchars($s['site_key']) . '</a></td>
|
||||
<td>' . $badge . '</td>
|
||||
@@ -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">← All sites</a> / <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; }
|
||||
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; }
|
||||
|
||||
Reference in New Issue
Block a user