Server: HTML dashboard at /dashboard showing all sites + session history
Browsable at /dashboard — authenticates via ?key=<api_key> once per browser session (stored in a PHP session cookie). Lists all sites ordered by most- recently-active, with session counts and in-progress badge. Click any site to see its full healthcheck history with dates and durations. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
217
server/src/Controllers/Dashboard.php
Normal file
217
server/src/Controllers/Dashboard.php
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace AttHc\Server\Controllers;
|
||||||
|
|
||||||
|
use AttHc\Server\Config;
|
||||||
|
use AttHc\Server\Store;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Browser-facing HTML dashboard.
|
||||||
|
*
|
||||||
|
* Auth: ?key=<api_key> 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', '<p class="error">No API key configured on server.</p>');
|
||||||
|
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 !== '' ? '<p class="error">' . htmlspecialchars($error) . '</p>' : '';
|
||||||
|
self::html('Sign in', '
|
||||||
|
<div class="login-box">
|
||||||
|
<h2>Site Healthcheck — History</h2>
|
||||||
|
' . $err . '
|
||||||
|
<form method="get" action="">
|
||||||
|
<label for="key">API key</label>
|
||||||
|
<input type="password" id="key" name="key" autofocus autocomplete="current-password">
|
||||||
|
<button type="submit">Sign in</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Renderers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static function renderSiteList(array $sites): string {
|
||||||
|
if (empty($sites)) {
|
||||||
|
return '<p class="muted">No healthchecks recorded yet.</p>';
|
||||||
|
}
|
||||||
|
$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']);
|
||||||
|
$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']);
|
||||||
|
$rows .= '<tr>
|
||||||
|
<td><a href="' . $url . '">' . htmlspecialchars($s['site_key']) . '</a></td>
|
||||||
|
<td>' . $badge . '</td>
|
||||||
|
<td>' . $lastDate . '</td>
|
||||||
|
<td>' . $sessionStr . '</td>
|
||||||
|
<td class="muted small">' . htmlspecialchars($s['last_reporting_url']) . '</td>
|
||||||
|
</tr>';
|
||||||
|
}
|
||||||
|
return '
|
||||||
|
<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>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>' . $rows . '</tbody>
|
||||||
|
</table>';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function renderSiteDetail(string $siteKey, array $hcs): string {
|
||||||
|
$back = '<p><a href="/dashboard">← All sites</a></p>';
|
||||||
|
if (empty($hcs)) {
|
||||||
|
return $back . '<p class="muted">No sessions found for this site.</p>';
|
||||||
|
}
|
||||||
|
$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
|
||||||
|
? '<span class="badge in-progress">in progress</span>'
|
||||||
|
: '<span class="badge done">complete</span>';
|
||||||
|
$rows .= '<tr>
|
||||||
|
<td>' . $started . '</td>
|
||||||
|
<td>' . $badge . '</td>
|
||||||
|
<td>' . ($finished ?? '<span class="muted">—</span>') . '</td>
|
||||||
|
<td>' . ($duration !== '' ? $duration : '<span class="muted">—</span>') . '</td>
|
||||||
|
<td class="muted small">' . htmlspecialchars((string) $hc['reporting_url']) . '</td>
|
||||||
|
</tr>';
|
||||||
|
}
|
||||||
|
return $back . '
|
||||||
|
<h2>' . htmlspecialchars($siteKey) . '</h2>
|
||||||
|
<table>
|
||||||
|
<thead><tr>
|
||||||
|
<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 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 '<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>' . htmlspecialchars($title) . ' — Site Healthcheck</title>
|
||||||
|
<style>
|
||||||
|
*, *::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; }
|
||||||
|
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; }
|
||||||
|
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; }
|
||||||
|
.muted { color: #646970; }
|
||||||
|
.small { font-size: .85em; }
|
||||||
|
.error { color: #b32d2e; }
|
||||||
|
/* login */
|
||||||
|
.login-box { background: #fff; border: 1px solid #c3c4c7; border-radius: 6px;
|
||||||
|
max-width: 360px; margin: 4rem auto; padding: 1.5rem; }
|
||||||
|
.login-box h2 { margin: 0 0 1rem; font-size: 1.1rem; }
|
||||||
|
.login-box label { display: block; font-weight: 600; margin-bottom: .3rem; font-size: .9em; }
|
||||||
|
.login-box input { width: 100%; padding: .45rem .6rem; border: 1px solid #8c8f94;
|
||||||
|
border-radius: 4px; font-size: 1rem; margin-bottom: .75rem; }
|
||||||
|
.login-box button { background: #2271b1; color: #fff; border: none; border-radius: 4px;
|
||||||
|
padding: .5rem 1rem; font-size: 1rem; cursor: pointer; }
|
||||||
|
.login-box button:hover { background: #135e96; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
<header>
|
||||||
|
<h1>Site Healthcheck — History</h1>
|
||||||
|
<a href="/dashboard">All sites</a>
|
||||||
|
</header>
|
||||||
|
' . $body . '
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -176,4 +176,42 @@ final class Store {
|
|||||||
$rows = $stmt->fetchAll();
|
$rows = $stmt->fetchAll();
|
||||||
return array_map(static fn($r) => ['site_key' => $r['site_key'], 'last_seen' => (int) $r['last_seen']], $rows);
|
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<int, array{site_key:string, total:int, finished:int, last_started_at:int, last_finished_at:int|null, last_reporting_url:string}>
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
use AttHc\Server\Config;
|
use AttHc\Server\Config;
|
||||||
|
use AttHc\Server\Controllers\Dashboard;
|
||||||
use AttHc\Server\Controllers\Healthchecks;
|
use AttHc\Server\Controllers\Healthchecks;
|
||||||
use AttHc\Server\Controllers\Sites;
|
use AttHc\Server\Controllers\Sites;
|
||||||
use AttHc\Server\Controllers\Steps;
|
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', '/healthchecks/steps/{step_id}', [Steps::class, 'history']);
|
||||||
$router->add('GET', '/step-counts', [Steps::class, 'counts']);
|
$router->add('GET', '/step-counts', [Steps::class, 'counts']);
|
||||||
$router->add('GET', '/sites', [Sites::class, 'recent']);
|
$router->add('GET', '/sites', [Sites::class, 'recent']);
|
||||||
|
$router->add('GET', '/dashboard', [Dashboard::class, 'index'], requiresAuth: false);
|
||||||
|
|
||||||
return $router;
|
return $router;
|
||||||
|
|||||||
Reference in New Issue
Block a user