Dashboard: archive healthchecks + Archived section on homepage (hc-b8r)
Some audits are no longer needed. Add an archive toggle so they can be filed
away without deleting the history.
- Migration 0003 adds healthchecks.archived (INTEGER 0/1, NOT NULL DEFAULT 0
— portable across MySQL/SQLite).
- Per-site page (/dashboard?site=X): each session row gets an Archive /
Unarchive button and archived rows carry a muted "archived" badge.
- Home page: archived healthchecks are gathered under an "Archived" heading
at the foot, each with an Unarchive button.
- POST /dashboard/healthchecks/{id}/archive, mirroring the reopen flow:
session-cookie auth + CSRF token. archived=1 archives, else un-archives.
The redirect-back target is validated to a same-origin /dashboard path
(no open-redirect / header injection).
- setArchived() deliberately leaves updated_at alone, so filing a session
doesn't jump its site to the top of the dashboard ordering.
- Archived items are NOT hidden from their normal listings, per the request.
Dashboard-only; not exposed through the plugin API. 7 new tests (49 total).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
-- Archive flag for healthchecks the technician no longer needs surfaced.
|
||||
--
|
||||
-- Boolean stored as INTEGER 0/1 (portable: MySQL has no native BOOLEAN, it's a
|
||||
-- TINYINT alias, and SQLite has no boolean type at all — both store 0/1 in an
|
||||
-- INTEGER column). NOT NULL DEFAULT 0 so every existing row is "not archived"
|
||||
-- and SQLite's ALTER TABLE ADD COLUMN is satisfied (it requires a default for a
|
||||
-- NOT NULL column).
|
||||
--
|
||||
-- Archived rows are NOT hidden from their normal listings for now; the dashboard
|
||||
-- just additionally gathers them under an "Archived" heading on the home page.
|
||||
|
||||
ALTER TABLE healthchecks ADD COLUMN archived INTEGER NOT NULL DEFAULT 0;
|
||||
@@ -38,6 +38,39 @@ final class Dashboard {
|
||||
exit;
|
||||
}
|
||||
|
||||
public static function archive(array $params): void {
|
||||
self::requireAuth();
|
||||
self::requireCsrf();
|
||||
|
||||
$hcId = (string) ($params['id'] ?? '');
|
||||
$hc = Store::getHealthcheck($hcId);
|
||||
if ($hc === null) {
|
||||
self::html('Not found', '<p class="error">Session not found.</p>');
|
||||
return;
|
||||
}
|
||||
|
||||
// archived=1 archives, anything else un-archives — so the same endpoint
|
||||
// drives both the "Archive" and "Unarchive" buttons.
|
||||
$archived = isset($_POST['archived']) && $_POST['archived'] === '1';
|
||||
Store::setArchived($hcId, $archived);
|
||||
|
||||
header('Location: ' . self::safeRedirect($_POST['redirect'] ?? null, (string) $hc['site_key']));
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* A local dashboard path to redirect back to, or the site page as a fallback.
|
||||
* Only same-origin /dashboard paths are allowed — never an absolute URL or a
|
||||
* value carrying CR/LF — so this can't be turned into an open redirect or
|
||||
* header-injection vector.
|
||||
*/
|
||||
private static function safeRedirect($candidate, string $siteKey): string {
|
||||
if (is_string($candidate) && preg_match('#^/dashboard(\?[^\s]*)?$#', $candidate)) {
|
||||
return $candidate;
|
||||
}
|
||||
return '/dashboard?site=' . rawurlencode($siteKey);
|
||||
}
|
||||
|
||||
public static function index(array $params = []): void {
|
||||
self::requireAuth();
|
||||
|
||||
@@ -55,8 +88,9 @@ final class Dashboard {
|
||||
$hcs = Store::listHealthchecksForSite($siteKey, 100);
|
||||
self::html(htmlspecialchars($siteKey), self::renderSiteDetail($siteKey, $hcs));
|
||||
} else {
|
||||
$sites = Store::allSitesSummary();
|
||||
self::html('All sites', self::renderSiteList($sites));
|
||||
$sites = Store::allSitesSummary();
|
||||
$archived = Store::archivedHealthchecks();
|
||||
self::html('All sites', self::renderSiteList($sites) . self::renderArchivedSection($archived));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,6 +205,7 @@ final class Dashboard {
|
||||
if (empty($hcs)) {
|
||||
return $back . '<p class="muted">No sessions found for this site.</p>';
|
||||
}
|
||||
$siteUrl = '/dashboard?site=' . rawurlencode($siteKey);
|
||||
$rows = '';
|
||||
foreach ($hcs as $hc) {
|
||||
$started = date('Y-m-d H:i', (int) $hc['started_at']);
|
||||
@@ -179,17 +214,22 @@ final class Dashboard {
|
||||
if ($finished !== null) {
|
||||
$duration = self::humanDuration((int) $hc['finished_at'] - (int) $hc['started_at']);
|
||||
}
|
||||
$archived = (int) ($hc['archived'] ?? 0) === 1;
|
||||
$badge = $finished === null
|
||||
? '<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>
|
||||
if ($archived) {
|
||||
$badge .= ' <span class="badge status-archived">archived</span>';
|
||||
}
|
||||
$url = $siteUrl . '&hc=' . rawurlencode((string) $hc['id']);
|
||||
$rows .= '<tr' . ($archived ? ' class="archived-row"' : '') . '>
|
||||
<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>
|
||||
<td>' . self::nextDueCell(isset($hc['next_due']) ? (string) $hc['next_due'] : null) . '</td>
|
||||
<td class="muted small">' . htmlspecialchars((string) $hc['reporting_url']) . '</td>
|
||||
<td>' . self::archiveButton($hc, $siteUrl) . '</td>
|
||||
</tr>';
|
||||
}
|
||||
return $back . '
|
||||
@@ -198,7 +238,57 @@ final class Dashboard {
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>Started</th><th>Status</th><th>Finished</th>
|
||||
<th>Duration</th><th>Next due</th><th>Reporting URL</th>
|
||||
<th>Duration</th><th>Next due</th><th>Reporting URL</th><th></th>
|
||||
</tr></thead>
|
||||
<tbody>' . $rows . '</tbody>
|
||||
</table>';
|
||||
}
|
||||
|
||||
/**
|
||||
* A one-button form that toggles a healthcheck's archived state. $redirect is
|
||||
* where to return afterwards (validated server-side by safeRedirect()).
|
||||
*/
|
||||
private static function archiveButton(array $hc, string $redirect): string {
|
||||
$archived = (int) ($hc['archived'] ?? 0) === 1;
|
||||
$target = $archived ? '0' : '1';
|
||||
$label = $archived ? 'Unarchive' : 'Archive';
|
||||
return '<form method="post" action="/dashboard/healthchecks/' . rawurlencode((string) $hc['id']) . '/archive" class="inline-action">'
|
||||
. '<input type="hidden" name="csrf" value="' . htmlspecialchars(self::csrfToken(), ENT_QUOTES) . '">'
|
||||
. '<input type="hidden" name="archived" value="' . $target . '">'
|
||||
. '<input type="hidden" name="redirect" value="' . htmlspecialchars($redirect, ENT_QUOTES) . '">'
|
||||
. '<button type="submit" class="btn-archive">' . $label . '</button>'
|
||||
. '</form>';
|
||||
}
|
||||
|
||||
/** The "Archived" section shown at the foot of the home page. Empty string when nothing is archived. */
|
||||
private static function renderArchivedSection(array $archived): string {
|
||||
if (empty($archived)) {
|
||||
return '';
|
||||
}
|
||||
$rows = '';
|
||||
foreach ($archived as $hc) {
|
||||
$siteKey = (string) $hc['site_key'];
|
||||
$started = date('Y-m-d H:i', (int) $hc['started_at']);
|
||||
$badge = $hc['finished_at'] !== null
|
||||
? '<span class="badge status-done">complete</span>'
|
||||
: '<span class="badge status-in-progress">in progress</span>';
|
||||
$siteUrl = '/dashboard?site=' . rawurlencode($siteKey);
|
||||
$sessionUrl = $siteUrl . '&hc=' . rawurlencode((string) $hc['id']);
|
||||
$rows .= '<tr>
|
||||
<td><a href="' . $siteUrl . '">' . htmlspecialchars($siteKey) . '</a></td>
|
||||
<td><a href="' . $sessionUrl . '">' . $started . '</a></td>
|
||||
<td>' . $badge . '</td>
|
||||
<td>' . self::nextDueCell(isset($hc['next_due']) ? (string) $hc['next_due'] : null) . '</td>
|
||||
<td class="muted small">' . htmlspecialchars((string) $hc['reporting_url']) . '</td>
|
||||
<td>' . self::archiveButton($hc, '/dashboard') . '</td>
|
||||
</tr>';
|
||||
}
|
||||
return '
|
||||
<h2 class="archived-heading">Archived <span class="muted small">(' . count($archived) . ')</span></h2>
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>Site key</th><th>Session started</th><th>Status</th>
|
||||
<th>Next due</th><th>Reporting URL</th><th></th>
|
||||
</tr></thead>
|
||||
<tbody>' . $rows . '</tbody>
|
||||
</table>';
|
||||
@@ -391,6 +481,15 @@ pre.step-notes { margin: 0; font-family: inherit; white-space: pre-wrap;
|
||||
border-radius: 4px; padding: .25rem .7rem; font-size: .85em;
|
||||
cursor: pointer; font-weight: 500; }
|
||||
.btn-reopen:hover { background: #2271b1; color: #fff; }
|
||||
/* archive */
|
||||
.inline-action { margin: 0; display: inline; }
|
||||
.btn-archive { background: #fff; color: #646970; border: 1px solid #c3c4c7;
|
||||
border-radius: 4px; padding: .25rem .7rem; font-size: .85em;
|
||||
cursor: pointer; }
|
||||
.btn-archive:hover { background: #f6f7f7; color: #1d2327; border-color: #8c8f94; }
|
||||
.badge.status-archived { background: #ede9fe; color: #5b21b6; }
|
||||
.archived-row td { opacity: .65; }
|
||||
.archived-heading { margin-top: 2.5rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -60,6 +60,31 @@ final class Store {
|
||||
return $stmt->rowCount() > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive or un-archive one healthcheck.
|
||||
*
|
||||
* Deliberately does not touch updated_at: archiving is a filing action, not
|
||||
* audit activity, and bumping it would jump the site to the top of the
|
||||
* dashboard's most-recently-active ordering.
|
||||
*/
|
||||
public static function setArchived(string $id, bool $archived): bool {
|
||||
$stmt = Db::pdo()->prepare('UPDATE healthchecks SET archived = :archived WHERE id = :id');
|
||||
$stmt->execute([':archived' => $archived ? 1 : 0, ':id' => $id]);
|
||||
return $stmt->rowCount() > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every archived healthcheck, most recent first, across all sites — for the
|
||||
* dashboard's "Archived" section.
|
||||
* @return array<int, array>
|
||||
*/
|
||||
public static function archivedHealthchecks(int $limit = 500): array {
|
||||
$stmt = Db::pdo()->prepare('SELECT * FROM healthchecks WHERE archived = 1 ORDER BY started_at DESC LIMIT ?');
|
||||
$stmt->bindValue(1, $limit, \PDO::PARAM_INT);
|
||||
$stmt->execute();
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
public static function upsertStep(string $healthcheckId, string $stepId, array $row): void {
|
||||
$pdo = Db::pdo();
|
||||
$driver = $pdo->getAttribute(\PDO::ATTR_DRIVER_NAME);
|
||||
|
||||
@@ -30,5 +30,6 @@ $router->add('GET', '/step-counts', [Steps::class
|
||||
$router->add('GET', '/sites', [Sites::class, 'recent']);
|
||||
$router->add('GET', '/dashboard', [Dashboard::class, 'index'], requiresAuth: false);
|
||||
$router->add('POST', '/dashboard/healthchecks/{id}/reopen', [Dashboard::class, 'reopen'], requiresAuth: false);
|
||||
$router->add('POST', '/dashboard/healthchecks/{id}/archive', [Dashboard::class, 'archive'], requiresAuth: false);
|
||||
|
||||
return $router;
|
||||
|
||||
Reference in New Issue
Block a user