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.
|
* 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.
|
* 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 {
|
final class Dashboard {
|
||||||
|
|
||||||
public static function index(array $params = []): void {
|
public static function index(array $params = []): void {
|
||||||
self::requireAuth();
|
self::requireAuth();
|
||||||
|
|
||||||
$siteKey = isset($_GET['site']) && is_string($_GET['site']) ? $_GET['site'] : '';
|
$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);
|
$hcs = Store::listHealthchecksForSite($siteKey, 100);
|
||||||
self::html(htmlspecialchars($siteKey), self::renderSiteDetail($siteKey, $hcs));
|
self::html(htmlspecialchars($siteKey), self::renderSiteDetail($siteKey, $hcs));
|
||||||
} else {
|
} else {
|
||||||
@@ -37,7 +52,6 @@ final class Dashboard {
|
|||||||
|
|
||||||
session_start();
|
session_start();
|
||||||
|
|
||||||
// Accept ?key= to set/verify.
|
|
||||||
if (isset($_GET['key']) && is_string($_GET['key'])) {
|
if (isset($_GET['key']) && is_string($_GET['key'])) {
|
||||||
if (hash_equals($expected, $_GET['key'])) {
|
if (hash_equals($expected, $_GET['key'])) {
|
||||||
$_SESSION['att_hc_authed'] = true;
|
$_SESSION['att_hc_authed'] = true;
|
||||||
@@ -80,16 +94,16 @@ final class Dashboard {
|
|||||||
$rows = '';
|
$rows = '';
|
||||||
foreach ($sites as $s) {
|
foreach ($sites as $s) {
|
||||||
$inProgress = $s['last_finished_at'] === null;
|
$inProgress = $s['last_finished_at'] === null;
|
||||||
$badge = $inProgress
|
$badge = $inProgress
|
||||||
? '<span class="badge in-progress">in progress</span>'
|
? '<span class="badge status-in-progress">in progress</span>'
|
||||||
: '<span class="badge done">complete</span>';
|
: '<span class="badge status-done">complete</span>';
|
||||||
$lastDate = date('Y-m-d H:i', $s['last_started_at']);
|
$lastDate = date('Y-m-d H:i', $s['last_started_at']);
|
||||||
$unfinished = $s['total'] - $s['finished'];
|
$unfinished = $s['total'] - $s['finished'];
|
||||||
$sessionStr = $s['total'] . ' session' . ($s['total'] !== 1 ? 's' : '');
|
$sessionStr = $s['total'] . ' session' . ($s['total'] !== 1 ? 's' : '');
|
||||||
if ($unfinished > 0) {
|
if ($unfinished > 0) {
|
||||||
$sessionStr .= ' <span class="muted">(' . $unfinished . ' open)</span>';
|
$sessionStr .= ' <span class="muted">(' . $unfinished . ' open)</span>';
|
||||||
}
|
}
|
||||||
$url = '/dashboard?site=' . rawurlencode($s['site_key']);
|
$url = '/dashboard?site=' . rawurlencode($s['site_key']);
|
||||||
$rows .= '<tr>
|
$rows .= '<tr>
|
||||||
<td><a href="' . $url . '">' . htmlspecialchars($s['site_key']) . '</a></td>
|
<td><a href="' . $url . '">' . htmlspecialchars($s['site_key']) . '</a></td>
|
||||||
<td>' . $badge . '</td>
|
<td>' . $badge . '</td>
|
||||||
@@ -102,11 +116,8 @@ final class Dashboard {
|
|||||||
<h2>All sites <span class="muted small">(' . count($sites) . ')</span></h2>
|
<h2>All sites <span class="muted small">(' . count($sites) . ')</span></h2>
|
||||||
<table>
|
<table>
|
||||||
<thead><tr>
|
<thead><tr>
|
||||||
<th>Site key</th>
|
<th>Site key</th><th>Latest</th><th>Last session started</th>
|
||||||
<th>Latest</th>
|
<th>Sessions</th><th>Last reporting URL</th>
|
||||||
<th>Last session started</th>
|
|
||||||
<th>Sessions</th>
|
|
||||||
<th>Last reporting URL</th>
|
|
||||||
</tr></thead>
|
</tr></thead>
|
||||||
<tbody>' . $rows . '</tbody>
|
<tbody>' . $rows . '</tbody>
|
||||||
</table>';
|
</table>';
|
||||||
@@ -123,14 +134,14 @@ final class Dashboard {
|
|||||||
$finished = $hc['finished_at'] !== null ? date('Y-m-d H:i', (int) $hc['finished_at']) : null;
|
$finished = $hc['finished_at'] !== null ? date('Y-m-d H:i', (int) $hc['finished_at']) : null;
|
||||||
$duration = '';
|
$duration = '';
|
||||||
if ($finished !== null) {
|
if ($finished !== null) {
|
||||||
$secs = (int) $hc['finished_at'] - (int) $hc['started_at'];
|
$duration = self::humanDuration((int) $hc['finished_at'] - (int) $hc['started_at']);
|
||||||
$duration = self::humanDuration($secs);
|
|
||||||
}
|
}
|
||||||
$badge = $finished === null
|
$badge = $finished === null
|
||||||
? '<span class="badge in-progress">in progress</span>'
|
? '<span class="badge status-in-progress">in progress</span>'
|
||||||
: '<span class="badge done">complete</span>';
|
: '<span class="badge status-done">complete</span>';
|
||||||
|
$url = '/dashboard?site=' . rawurlencode($siteKey) . '&hc=' . rawurlencode((string) $hc['id']);
|
||||||
$rows .= '<tr>
|
$rows .= '<tr>
|
||||||
<td>' . $started . '</td>
|
<td><a href="' . $url . '">' . $started . '</a></td>
|
||||||
<td>' . $badge . '</td>
|
<td>' . $badge . '</td>
|
||||||
<td>' . ($finished ?? '<span class="muted">—</span>') . '</td>
|
<td>' . ($finished ?? '<span class="muted">—</span>') . '</td>
|
||||||
<td>' . ($duration !== '' ? $duration : '<span class="muted">—</span>') . '</td>
|
<td>' . ($duration !== '' ? $duration : '<span class="muted">—</span>') . '</td>
|
||||||
@@ -139,18 +150,93 @@ final class Dashboard {
|
|||||||
}
|
}
|
||||||
return $back . '
|
return $back . '
|
||||||
<h2>' . htmlspecialchars($siteKey) . '</h2>
|
<h2>' . htmlspecialchars($siteKey) . '</h2>
|
||||||
|
<p class="muted small">Click a session to see step detail.</p>
|
||||||
<table>
|
<table>
|
||||||
<thead><tr>
|
<thead><tr>
|
||||||
<th>Started</th>
|
<th>Started</th><th>Status</th><th>Finished</th>
|
||||||
<th>Status</th>
|
<th>Duration</th><th>Reporting URL</th>
|
||||||
<th>Finished</th>
|
|
||||||
<th>Duration</th>
|
|
||||||
<th>Reporting URL</th>
|
|
||||||
</tr></thead>
|
</tr></thead>
|
||||||
<tbody>' . $rows . '</tbody>
|
<tbody>' . $rows . '</tbody>
|
||||||
</table>';
|
</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 {
|
private static function humanDuration(int $seconds): string {
|
||||||
if ($seconds < 60) return $seconds . 's';
|
if ($seconds < 60) return $seconds . 's';
|
||||||
if ($seconds < 3600) return round($seconds / 60) . 'min';
|
if ($seconds < 3600) return round($seconds / 60) . 'min';
|
||||||
@@ -171,26 +257,39 @@ final class Dashboard {
|
|||||||
*, *::before, *::after { box-sizing: border-box; }
|
*, *::before, *::after { box-sizing: border-box; }
|
||||||
body { font-family: system-ui, sans-serif; font-size: 15px; line-height: 1.5;
|
body { font-family: system-ui, sans-serif; font-size: 15px; line-height: 1.5;
|
||||||
color: #1d2327; background: #f0f0f1; margin: 0; padding: 1rem; }
|
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;
|
header { background: #1d2327; color: #fff; padding: .6rem 1rem; border-radius: 6px;
|
||||||
margin-bottom: 1.5rem; display: flex; align-items: center; gap: 1rem; }
|
margin-bottom: 1.5rem; display: flex; align-items: center; gap: 1rem; }
|
||||||
header h1 { margin: 0; font-size: 1rem; font-weight: 600; }
|
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;
|
table { width: 100%; border-collapse: collapse; background: #fff;
|
||||||
border: 1px solid #c3c4c7; border-radius: 6px; overflow: hidden; }
|
border: 1px solid #c3c4c7; border-radius: 6px; overflow: hidden;
|
||||||
th, td { text-align: left; padding: .55rem .85rem; border-bottom: 1px solid #dcdcde; }
|
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; }
|
th { background: #f6f7f7; font-weight: 600; font-size: .9em; color: #3c434a; }
|
||||||
tr:last-child td { border-bottom: 0; }
|
tr:last-child td { border-bottom: 0; }
|
||||||
tr:hover td { background: #f6f7f7; }
|
tr:hover td { background: #f6f7f7; }
|
||||||
a { color: #2271b1; text-decoration: none; }
|
a { color: #2271b1; text-decoration: none; }
|
||||||
a:hover { color: #135e96; text-decoration: underline; }
|
a:hover { color: #135e96; text-decoration: underline; }
|
||||||
.badge { display: inline-block; padding: .15em .55em; border-radius: 3px;
|
.badge { display: inline-block; padding: .15em .55em; border-radius: 3px;
|
||||||
font-size: .8em; font-weight: 600; }
|
font-size: .8em; font-weight: 600; white-space: nowrap; }
|
||||||
.badge.done { background: #d8f3dc; color: #1a6632; }
|
.status-done { background: #d8f3dc; color: #1a6632; }
|
||||||
.badge.in-progress { background: #fef9e7; color: #8a6d01; }
|
.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; }
|
.muted { color: #646970; }
|
||||||
.small { font-size: .85em; }
|
.small { font-size: .85em; }
|
||||||
.error { color: #b32d2e; }
|
.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 */
|
||||||
.login-box { background: #fff; border: 1px solid #c3c4c7; border-radius: 6px;
|
.login-box { background: #fff; border: 1px solid #c3c4c7; border-radius: 6px;
|
||||||
max-width: 360px; margin: 4rem auto; padding: 1.5rem; }
|
max-width: 360px; margin: 4rem auto; padding: 1.5rem; }
|
||||||
|
|||||||
Reference in New Issue
Block a user