Central history server + plugin write-through sync (epic hc-0p1)

Adds a PHP/SQLite history server in server/ and refactors the plugin to
write every session change through it. Healthcheck history now survives
plugin uninstall and groups across dev + live URLs for the same engagement
via an editable site_key (defaults to the normalised host).

Server (server/):
- Front controller + hand-rolled autoloader, no framework, no composer
- SQLite default DSN; swap to MySQL by changing config.php
- Schema: healthchecks (PK id, UNIQUE (site_key, started_at)) + step_updates
  (PK (healthcheck_id, step_id)) + request_log; auto-migration runner
- 8 endpoints: POST/GET/PUT healthchecks, PUT/GET step rows, GET step history
  with exclude_id, GET /sites (recent), GET /step-counts (badge data)
- Bearer auth via hash_equals; HTTPS expected (plugin enforces client-side)
- DEPLOY.md with Apache/nginx vhosts, Let's Encrypt, SQLite backup cron,
  and the /home/www/ perm gotcha
- dev-router.php works around PHP -S 405-ing dotted uniqid paths

Plugin:
- ATT_HC_Api HTTP client reads ATT_HC_API_URL/ATT_HC_API_KEY constants
  from wp-config.php; refuses non-HTTPS with a loopback dev exception
- ATT_HC_Session is now write-through: every start/update_step/finish/
  set_autocheck POSTs or PUTs to the server first, then updates the local
  WP option cache. No drift possible — failures throw ATT_HC_Api_Exception
- previous() now reads from /healthchecks?include=steps and reconstructs;
  the old att_hc_previous_session local option is gone
- ATT_HC_Session::resume(id) hydrates a server session into the local cache
- Start screen: editable site_key (defaults to normalise_site_url()),
  datalist of recent engagements, table of in-progress sessions for the
  chosen key with Resume buttons. Double-click guard on start + resume
  handlers short-circuits if a session is already active
- Per-step <details> disclosure shows "Previous notes (N)" badge from
  /step-counts; lazy-loads detail rows on first expand via admin-ajax,
  caches via data-loaded, resets on error so user can retry
- All admin handlers catch ATT_HC_Api_Exception and surface via
  att_hc_api_error transient → admin notice
- Hard config-error gate at the top of the admin page blocks the UI when
  ATT_HC_API_URL/ATT_HC_API_KEY are missing or malformed

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-29 12:43:24 +01:00
parent 6dd050ea1d
commit 1782e52504
26 changed files with 1548 additions and 37 deletions

View File

@@ -3,6 +3,8 @@ if (!defined('ABSPATH')) exit;
add_action('admin_menu', 'att_hc_register_menu');
add_action('admin_post_att_hc_start', 'att_hc_handle_start');
add_action('admin_post_att_hc_resume', 'att_hc_handle_resume');
add_action('wp_ajax_att_hc_step_history', 'att_hc_handle_step_history_ajax');
add_action('admin_post_att_hc_save_step', 'att_hc_handle_save_step');
add_action('admin_post_att_hc_finish', 'att_hc_handle_finish');
add_action('admin_post_att_hc_discard', 'att_hc_handle_discard');
@@ -111,11 +113,24 @@ function att_hc_render_admin_page(): void {
echo '<div class="wrap">';
echo '<h1>Site Healthcheck</h1>';
if ($cfg_err = ATT_HC_Api::config_error()) {
echo '<div class="notice notice-error"><p><strong>Central history server not usable:</strong> ' . esc_html($cfg_err) . '</p>';
echo '<p>Add the following to <code>wp-config.php</code> and reload:</p>';
echo '<pre>define(\'ATT_HC_API_URL\', \'https://your-history-server.example.com\');' . "\n" . 'define(\'ATT_HC_API_KEY\', \'&lt;shared secret&gt;\');</pre></div>';
echo '</div>';
return;
}
if ($msg = get_transient('att_hc_install_message')) {
delete_transient('att_hc_install_message');
echo '<div class="notice notice-success is-dismissible"><p>' . esc_html($msg) . '</p></div>';
}
if ($err = get_transient('att_hc_api_error')) {
delete_transient('att_hc_api_error');
echo '<div class="notice notice-error"><p><strong>Central history server:</strong> ' . esc_html($err) . '</p></div>';
}
if (!$session) {
att_hc_render_start_panel();
echo '</div>';
@@ -133,14 +148,93 @@ function att_hc_render_admin_page(): void {
}
function att_hc_render_start_panel(): void {
// Site key for the lookup is either user-specified (?site_key=...) or
// the normalised current site URL. The technician can override on submit.
$default_key = ATT_HC_Session::normalise_site_url(get_site_url());
$site_key = isset($_GET['site_key']) && is_string($_GET['site_key']) && $_GET['site_key'] !== ''
? sanitize_text_field(wp_unslash((string) $_GET['site_key']))
: $default_key;
// Best-effort fetches — surface a notice on failure but still let the
// tech start a fresh session. (Resume needs a successful list call to
// know which session to resume.)
$incomplete = [];
$recent = [];
$lookup_err = null;
try {
$resp = ATT_HC_Api::list_healthchecks($site_key, include_steps: false, limit: 20);
foreach ($resp['healthchecks'] ?? [] as $hc) {
if (empty($hc['finished_at'])) $incomplete[] = $hc;
}
} catch (ATT_HC_Api_Exception $e) {
$lookup_err = $e->getMessage();
}
try {
$sites = ATT_HC_Api::recent_sites(20);
$recent = $sites['sites'] ?? [];
} catch (ATT_HC_Api_Exception $e) {
// No-op — the recent dropdown is just a convenience.
}
?>
<div class="att-hc-card">
<h2>Start a healthcheck</h2>
<p>This will create a new in-progress session for <code><?php echo esc_html(get_site_url()); ?></code>. One session per site at a time.</p>
<?php if ($lookup_err): ?>
<div class="notice notice-warning inline"><p>Could not look up history for this engagement: <?php echo esc_html($lookup_err); ?>. You can still start a new session.</p></div>
<?php endif; ?>
<form method="get" action="<?php echo esc_url(admin_url('tools.php')); ?>" style="margin-bottom:1rem">
<input type="hidden" name="page" value="att-site-healthcheck">
<p>
<label for="att-hc-site-key"><strong>Site / engagement key</strong></label><br>
<input id="att-hc-site-key" type="text" name="site_key" value="<?php echo esc_attr($site_key); ?>" list="att-hc-recent-sites" class="regular-text" required>
<button class="button" type="submit">Look up engagement</button>
<?php if ($site_key !== $default_key): ?>
<a class="button button-link" href="<?php echo esc_url(admin_url('tools.php?page=att-site-healthcheck')); ?>">Reset to this site</a>
<?php endif; ?>
</p>
<?php if ($recent): ?>
<datalist id="att-hc-recent-sites">
<?php foreach ($recent as $r): ?>
<option value="<?php echo esc_attr($r['site_key']); ?>">
<?php endforeach; ?>
</datalist>
<?php endif; ?>
<p class="description">
Used to group runs that span dev + live for the same engagement. Defaults to the host of this site.
<?php if ($recent): ?>Type to autocomplete from <?php echo count($recent); ?> recent engagement(s).<?php endif; ?>
</p>
</form>
<?php if ($incomplete): ?>
<h3>In-progress healthchecks for <code><?php echo esc_html($site_key); ?></code></h3>
<p>Pick up where a previous session left off, or start a fresh one below.</p>
<table class="widefat striped" style="margin-bottom:1rem">
<thead><tr><th>Started</th><th>Last activity</th><th>Reporting URL</th><th>Action</th></tr></thead>
<tbody>
<?php foreach ($incomplete as $hc): ?>
<tr>
<td><?php echo esc_html(date('Y-m-d H:i', (int) $hc['started_at'])); ?></td>
<td><?php echo esc_html(human_time_diff((int) $hc['updated_at'], time())); ?> ago</td>
<td><code><?php echo esc_html((string) $hc['reporting_url']); ?></code></td>
<td>
<form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>" style="display:inline">
<?php wp_nonce_field('att_hc_resume_' . $hc['id']); ?>
<input type="hidden" name="action" value="att_hc_resume">
<input type="hidden" name="id" value="<?php echo esc_attr($hc['id']); ?>">
<button class="button">Resume</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
<form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
<?php wp_nonce_field('att_hc_start'); ?>
<input type="hidden" name="action" value="att_hc_start">
<button class="button button-primary">Start new healthcheck</button>
<input type="hidden" name="site_key" value="<?php echo esc_attr($site_key); ?>">
<button class="button button-primary"><?php echo $incomplete ? 'Start a fresh healthcheck for this engagement' : 'Start new healthcheck'; ?></button>
</form>
</div>
<?php
@@ -178,15 +272,114 @@ function att_hc_render_active_session(ATT_HC_Session $session): void {
att_hc_render_blocked_summary($session);
att_hc_render_diff_summary($session);
// One round-trip up-front so each step card knows how many prior notes
// exist (the "(N)" badge). Detail rows are lazy-loaded on expand.
$step_counts = [];
try {
$resp = ATT_HC_Api::step_counts($session->site_key(), $session->id());
$step_counts = $resp['counts'] ?? [];
} catch (ATT_HC_Api_Exception $e) {
// History badges are a nice-to-have — don't block the page.
}
att_hc_print_step_history_assets($session);
echo '<div class="att-hc-layout">';
att_hc_render_sidebar($session);
echo '<div>';
foreach (ATT_HC_Steps::instance()->all() as $step) {
att_hc_render_step_card($session, $step);
att_hc_render_step_card($session, $step, (int) ($step_counts[$step->id()] ?? 0));
}
echo '</div></div>';
}
function att_hc_print_step_history_assets(ATT_HC_Session $session): void {
$cfg = [
'ajaxUrl' => admin_url('admin-ajax.php'),
'nonce' => wp_create_nonce('att_hc_step_history'),
'reportingUrl' => $session->reporting_url(),
];
?>
<style>
.att-hc-history { margin: .6rem 0; }
.att-hc-history > summary { cursor: pointer; color: #2271b1; font-size: .9em; padding: .15rem 0; }
.att-hc-history > summary:hover { color: #135e96; }
.att-hc-history[open] > summary { font-weight: 600; }
.att-hc-history-body { margin-top: .5rem; padding: .5rem .75rem; background: #f6f7f7; border: 1px solid #dcdcde; border-radius: 4px; font-size: .92em; }
.att-hc-history-entry { padding: .4rem 0; border-bottom: 1px dashed #dcdcde; }
.att-hc-history-entry:last-child { border-bottom: 0; }
.att-hc-history-meta { color: #646970; font-size: .9em; margin-bottom: .15rem; }
.att-hc-history-notes { white-space: pre-wrap; margin: .2rem 0 0; }
.att-hc-history-notes.empty { color: #8c8f94; font-style: italic; }
.att-hc-history-reporting { color: #646970; font-size: .85em; margin-top: .15rem; }
.att-hc-history-err { color: #b32d2e; }
</style>
<script>
(function () {
var cfg = <?php echo wp_json_encode($cfg); ?>;
function statusLabel(s) { return s.replace(/_/g, ' '); }
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, function (c) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
});
}
function fmtDate(unix) {
var d = new Date(unix * 1000);
return d.toISOString().slice(0, 10) + ' ' + d.toTimeString().slice(0, 5);
}
function renderRows(rows) {
if (!rows.length) return '<p>No prior notes for this step.</p>';
return rows.map(function (r) {
var notes = (r.notes || '').trim();
var notesHtml = notes
? '<p class="att-hc-history-notes">' + escapeHtml(notes) + '</p>'
: '<p class="att-hc-history-notes empty">(no notes)</p>';
var diffUrl = r.step_reporting_url && r.step_reporting_url !== cfg.reportingUrl
? '<p class="att-hc-history-reporting">Reported from <code>' + escapeHtml(r.step_reporting_url) + '</code></p>'
: '';
return '<div class="att-hc-history-entry">' +
'<div class="att-hc-history-meta">' +
fmtDate(r.started_at) +
' · <span class="att-hc-step-status att-hc-status-' + escapeHtml(r.status) + '">' + escapeHtml(statusLabel(r.status)) + '</span>' +
(r.finished_at ? '' : ' · <em>still in progress</em>') +
'</div>' +
notesHtml +
diffUrl +
'</div>';
}).join('');
}
document.querySelectorAll('details.att-hc-history').forEach(function (det) {
det.addEventListener('toggle', function () {
if (!det.open || det.dataset.loaded === 'yes') return;
det.dataset.loaded = 'yes';
var body = det.querySelector('.att-hc-history-body');
var stepId = det.dataset.stepId;
body.textContent = 'Loading…';
var form = new FormData();
form.append('action', 'att_hc_step_history');
form.append('step_id', stepId);
form.append('nonce', cfg.nonce);
fetch(cfg.ajaxUrl, { method: 'POST', credentials: 'same-origin', body: form })
.then(function (r) { return r.json(); })
.then(function (j) {
if (!j || !j.success) {
body.innerHTML = '<p class="att-hc-history-err">Could not load: ' + escapeHtml((j && j.data) || 'unknown error') + '</p>';
det.dataset.loaded = 'no'; // allow retry on next toggle
return;
}
body.innerHTML = renderRows(j.data.history || []);
})
.catch(function (e) {
body.innerHTML = '<p class="att-hc-history-err">Network error: ' + escapeHtml(e.message) + '</p>';
det.dataset.loaded = 'no';
});
});
});
})();
</script>
<?php
}
function att_hc_render_sidebar(ATT_HC_Session $session): void {
echo '<aside class="att-hc-sidebar"><div class="att-hc-card"><h3>Steps</h3><ol>';
foreach (ATT_HC_Steps::instance()->all() as $step) {
@@ -267,7 +460,7 @@ function att_hc_render_diff_summary(ATT_HC_Session $session): void {
echo '</div>';
}
function att_hc_render_step_card(ATT_HC_Session $session, ATT_HC_Step $step): void {
function att_hc_render_step_card(ATT_HC_Session $session, ATT_HC_Step $step, int $history_count = 0): void {
$state = $session->step_state($step->id());
$status = $state['status'];
$notes = $state['notes'];
@@ -277,6 +470,12 @@ function att_hc_render_step_card(ATT_HC_Session $session, ATT_HC_Step $step): vo
<h2><?php echo esc_html($step->title()); ?></h2>
<span class="att-hc-step-status att-hc-status-<?php echo esc_attr($status); ?>"><?php echo esc_html(str_replace('_', ' ', $status)); ?></span>
</header>
<?php if ($history_count > 0): ?>
<details class="att-hc-history" data-step-id="<?php echo esc_attr($step->id()); ?>" data-loaded="no">
<summary>Previous notes for this step (<?php echo (int) $history_count; ?>)</summary>
<div class="att-hc-history-body"></div>
</details>
<?php endif; ?>
<?php if ($blurb = $step->blurb()): ?>
<p><?php echo esc_html($blurb); ?></p>
<?php endif; ?>
@@ -382,7 +581,55 @@ function att_hc_render_finished_panel(ATT_HC_Session $session): void {
function att_hc_handle_start(): void {
if (!current_user_can('manage_options')) wp_die('Forbidden');
check_admin_referer('att_hc_start');
ATT_HC_Session::start(get_current_user_id());
// Double-submit / double-click guard: if an active session already exists,
// ignore the second start and just send the user to it. Otherwise we'd POST
// again, hit the (site_key, started_at) unique constraint, and surface a
// confusing "duplicate" error.
if (ATT_HC_Session::current()) {
wp_safe_redirect(admin_url('tools.php?page=att-site-healthcheck'));
exit;
}
$site_key = isset($_POST['site_key']) ? sanitize_text_field(wp_unslash((string) $_POST['site_key'])) : null;
try {
ATT_HC_Session::start(get_current_user_id(), $site_key);
} catch (ATT_HC_Api_Exception $e) {
set_transient('att_hc_api_error', 'Could not register the session with the central server: ' . $e->getMessage(), 60);
}
wp_safe_redirect(admin_url('tools.php?page=att-site-healthcheck'));
exit;
}
function att_hc_handle_step_history_ajax(): void {
if (!current_user_can('manage_options')) wp_send_json_error('Forbidden', 403);
if (!check_ajax_referer('att_hc_step_history', 'nonce', false)) {
wp_send_json_error('Bad nonce', 403);
}
$step_id = isset($_POST['step_id']) ? sanitize_key((string) $_POST['step_id']) : '';
if ($step_id === '') wp_send_json_error('Missing step_id', 400);
$session = ATT_HC_Session::current();
if (!$session) wp_send_json_error('No active session', 400);
try {
$resp = ATT_HC_Api::step_history($step_id, $session->site_key(), 5, $session->id());
wp_send_json_success($resp);
} catch (ATT_HC_Api_Exception $e) {
wp_send_json_error($e->getMessage(), 502);
}
}
function att_hc_handle_resume(): void {
if (!current_user_can('manage_options')) wp_die('Forbidden');
$id = isset($_POST['id']) ? sanitize_text_field(wp_unslash((string) $_POST['id'])) : '';
check_admin_referer('att_hc_resume_' . $id);
if (ATT_HC_Session::current()) {
// Same guard as start — don't clobber an active session on a stray click.
wp_safe_redirect(admin_url('tools.php?page=att-site-healthcheck'));
exit;
}
try {
ATT_HC_Session::resume($id);
} catch (ATT_HC_Api_Exception $e) {
set_transient('att_hc_api_error', 'Could not resume that session: ' . $e->getMessage(), 60);
}
wp_safe_redirect(admin_url('tools.php?page=att-site-healthcheck'));
exit;
}
@@ -396,7 +643,11 @@ function att_hc_handle_save_step(): void {
if (!ATT_HC_Steps::instance()->get($step_id)) wp_die('Unknown step.');
$status = isset($_POST['status']) ? sanitize_key((string) $_POST['status']) : ATT_HC_Session::STATUS_NOT_STARTED;
$notes = isset($_POST['notes']) ? wp_unslash((string) $_POST['notes']) : '';
$session->update_step($step_id, $status, $notes);
try {
$session->update_step($step_id, $status, $notes);
} catch (ATT_HC_Api_Exception $e) {
set_transient('att_hc_api_error', 'Step not saved (central server rejected the write): ' . $e->getMessage(), 60);
}
wp_safe_redirect(admin_url('tools.php?page=att-site-healthcheck#step-' . rawurlencode($step_id)));
exit;
}
@@ -406,7 +657,11 @@ function att_hc_handle_finish(): void {
check_admin_referer('att_hc_finish');
$session = ATT_HC_Session::current();
if (!$session) wp_die('No active session.');
$session->finish();
try {
$session->finish();
} catch (ATT_HC_Api_Exception $e) {
set_transient('att_hc_api_error', 'Could not mark session finished on the central server: ' . $e->getMessage(), 60);
}
wp_safe_redirect(admin_url('tools.php?page=att-site-healthcheck'));
exit;
}
@@ -579,7 +834,11 @@ function att_hc_handle_step_action(): void {
}
}
if (!$replaced) $findings[] = $finding;
$session->set_autocheck($step_id, $findings);
try {
$session->set_autocheck($step_id, $findings);
} catch (ATT_HC_Api_Exception $e) {
set_transient('att_hc_api_error', 'Step action ran but the result could not be saved to the central server: ' . $e->getMessage(), 60);
}
}
wp_safe_redirect(admin_url('tools.php?page=att-site-healthcheck#step-' . rawurlencode($step_id)));
@@ -596,7 +855,11 @@ function att_hc_handle_refresh_checks(): void {
if (!$step) wp_die('Unknown step.');
@set_time_limit(60);
$findings = $step->autocheck($session->data());
$session->set_autocheck($step_id, $findings);
try {
$session->set_autocheck($step_id, $findings);
} catch (ATT_HC_Api_Exception $e) {
set_transient('att_hc_api_error', 'Autocheck ran but the result could not be saved to the central server: ' . $e->getMessage(), 60);
}
wp_safe_redirect(admin_url('tools.php?page=att-site-healthcheck#step-' . rawurlencode($step_id)));
exit;
}