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:
@@ -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\', \'<shared secret>\');</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 { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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;
|
||||
}
|
||||
|
||||
17
includes/class-att-hc-api-exception.php
Normal file
17
includes/class-att-hc-api-exception.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
/**
|
||||
* Thrown by ATT_HC_Api when the central server returns a non-2xx or is unreachable.
|
||||
* Callers should catch and surface the message via admin notices — never silently swallow.
|
||||
*/
|
||||
final class ATT_HC_Api_Exception extends Exception {
|
||||
public string $error_code;
|
||||
public int $http_status;
|
||||
|
||||
public function __construct(string $message, string $error_code = 'unknown', int $http_status = 0) {
|
||||
parent::__construct($message);
|
||||
$this->error_code = $error_code;
|
||||
$this->http_status = $http_status;
|
||||
}
|
||||
}
|
||||
155
includes/class-att-hc-api.php
Normal file
155
includes/class-att-hc-api.php
Normal file
@@ -0,0 +1,155 @@
|
||||
<?php
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
/**
|
||||
* HTTP client for the central healthcheck history server.
|
||||
*
|
||||
* Config comes from constants defined in wp-config.php:
|
||||
* define('ATT_HC_API_URL', 'https://healthcheck-history.example.com');
|
||||
* define('ATT_HC_API_KEY', '<long random string>');
|
||||
*
|
||||
* Constants (not options) on purpose: the plugin gets uninstalled per engagement,
|
||||
* but the constants survive in wp-config.php so the next install on the same site
|
||||
* still talks to the same server.
|
||||
*
|
||||
* Every method throws ATT_HC_Api_Exception on failure. Callers must catch and surface.
|
||||
*/
|
||||
final class ATT_HC_Api {
|
||||
|
||||
private const TIMEOUT_SECONDS = 15;
|
||||
|
||||
/** Returns true if the plugin is configured to talk to a server. */
|
||||
public static function is_configured(): bool {
|
||||
return defined('ATT_HC_API_URL') && defined('ATT_HC_API_KEY')
|
||||
&& is_string(ATT_HC_API_URL) && is_string(ATT_HC_API_KEY)
|
||||
&& ATT_HC_API_URL !== '' && ATT_HC_API_KEY !== '';
|
||||
}
|
||||
|
||||
/**
|
||||
* If config is missing or broken, returns a human message; otherwise null.
|
||||
* Used by the admin page to block the UI with a clear error.
|
||||
*/
|
||||
public static function config_error(): ?string {
|
||||
if (!defined('ATT_HC_API_URL') || !defined('ATT_HC_API_KEY')) {
|
||||
return 'Central history server is not configured. Add ATT_HC_API_URL and ATT_HC_API_KEY constants to wp-config.php.';
|
||||
}
|
||||
if (!is_string(ATT_HC_API_URL) || !is_string(ATT_HC_API_KEY) || ATT_HC_API_URL === '' || ATT_HC_API_KEY === '') {
|
||||
return 'ATT_HC_API_URL or ATT_HC_API_KEY in wp-config.php is empty.';
|
||||
}
|
||||
if (stripos(ATT_HC_API_URL, 'https://') !== 0 && !self::is_loopback(ATT_HC_API_URL)) {
|
||||
return 'ATT_HC_API_URL must start with https:// — refusing to send credentials over plain HTTP.';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static function is_loopback(string $url): bool {
|
||||
$host = parse_url($url, PHP_URL_HOST) ?: '';
|
||||
return in_array($host, ['localhost', '127.0.0.1', '::1'], true);
|
||||
}
|
||||
|
||||
/** GET / — verifies reachability + auth-free heartbeat. */
|
||||
public static function ping(): array {
|
||||
return self::request('GET', '/', null, requires_auth: false);
|
||||
}
|
||||
|
||||
public static function create_healthcheck(array $payload): array {
|
||||
return self::request('POST', '/healthchecks', $payload);
|
||||
}
|
||||
|
||||
public static function update_healthcheck(string $id, array $payload): array {
|
||||
return self::request('PUT', '/healthchecks/' . rawurlencode($id), $payload);
|
||||
}
|
||||
|
||||
public static function get_healthcheck(string $id): array {
|
||||
return self::request('GET', '/healthchecks/' . rawurlencode($id));
|
||||
}
|
||||
|
||||
public static function list_healthchecks(string $site_key, bool $include_steps = false, int $limit = 50): array {
|
||||
$query = ['site_key' => $site_key, 'limit' => $limit];
|
||||
if ($include_steps) $query['include'] = 'steps';
|
||||
return self::request('GET', '/healthchecks?' . http_build_query($query));
|
||||
}
|
||||
|
||||
public static function upsert_step(string $healthcheck_id, string $step_id, array $payload): array {
|
||||
return self::request(
|
||||
'PUT',
|
||||
'/healthchecks/' . rawurlencode($healthcheck_id) . '/steps/' . rawurlencode($step_id),
|
||||
$payload
|
||||
);
|
||||
}
|
||||
|
||||
public static function step_history(string $step_id, string $site_key, int $limit = 5, ?string $exclude_id = null): array {
|
||||
$query = ['site_key' => $site_key, 'limit' => $limit];
|
||||
if ($exclude_id !== null && $exclude_id !== '') $query['exclude_id'] = $exclude_id;
|
||||
return self::request(
|
||||
'GET',
|
||||
'/healthchecks/steps/' . rawurlencode($step_id) . '?' . http_build_query($query)
|
||||
);
|
||||
}
|
||||
|
||||
public static function recent_sites(int $limit = 20): array {
|
||||
return self::request('GET', '/sites?' . http_build_query(['limit' => $limit]));
|
||||
}
|
||||
|
||||
/** Returns ['counts' => ['step_id' => int, ...]] — used to render the "(N)" badge. */
|
||||
public static function step_counts(string $site_key, ?string $exclude_id = null): array {
|
||||
$query = ['site_key' => $site_key];
|
||||
if ($exclude_id !== null && $exclude_id !== '') $query['exclude_id'] = $exclude_id;
|
||||
return self::request('GET', '/step-counts?' . http_build_query($query));
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ATT_HC_Api_Exception
|
||||
*/
|
||||
private static function request(string $method, string $path, ?array $body = null, bool $requires_auth = true): array {
|
||||
$err = self::config_error();
|
||||
if ($err !== null) {
|
||||
throw new ATT_HC_Api_Exception($err, 'no_config');
|
||||
}
|
||||
|
||||
$url = rtrim(ATT_HC_API_URL, '/') . $path;
|
||||
|
||||
$args = [
|
||||
'method' => $method,
|
||||
'timeout' => self::TIMEOUT_SECONDS,
|
||||
'redirection' => 0,
|
||||
'headers' => [
|
||||
'Accept' => 'application/json',
|
||||
],
|
||||
];
|
||||
if ($requires_auth) {
|
||||
$args['headers']['Authorization'] = 'Bearer ' . ATT_HC_API_KEY;
|
||||
}
|
||||
if ($body !== null) {
|
||||
$args['headers']['Content-Type'] = 'application/json';
|
||||
$args['body'] = wp_json_encode($body);
|
||||
}
|
||||
|
||||
$response = wp_remote_request($url, $args);
|
||||
|
||||
if (is_wp_error($response)) {
|
||||
throw new ATT_HC_Api_Exception(
|
||||
'Central history server unreachable: ' . $response->get_error_message(),
|
||||
'unreachable'
|
||||
);
|
||||
}
|
||||
|
||||
$status = (int) wp_remote_retrieve_response_code($response);
|
||||
$raw = (string) wp_remote_retrieve_body($response);
|
||||
$data = $raw !== '' ? json_decode($raw, true) : [];
|
||||
if (!is_array($data)) $data = [];
|
||||
|
||||
if ($status < 200 || $status >= 300) {
|
||||
$code = isset($data['code']) && is_string($data['code']) ? $data['code'] : 'http_' . $status;
|
||||
$message = isset($data['error']) && is_string($data['error'])
|
||||
? $data['error']
|
||||
: 'central server returned HTTP ' . $status;
|
||||
if ($status === 401) {
|
||||
$message = 'Central server rejected our credentials. Check ATT_HC_API_KEY in wp-config.php matches the server config.';
|
||||
}
|
||||
throw new ATT_HC_Api_Exception($message, $code, $status);
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,19 @@
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
/**
|
||||
* Healthcheck session. Option-backed (single in-progress session per site).
|
||||
* Healthcheck session — server is the source of truth.
|
||||
*
|
||||
* Per beads decision hc-5ix.4: option, not CPT. The plugin is installed per
|
||||
* engagement, so per-site history living in the DB would die on uninstall.
|
||||
* Reports are exported as Markdown instead — see report.php.
|
||||
* The local WP option is a thin cache of the *active* session so page renders
|
||||
* are fast and don't fetch on every request. Every write goes to the central
|
||||
* history server first; only on a successful server response does the local
|
||||
* cache get updated. If the server is unreachable or rejects the write, the
|
||||
* caller sees an ATT_HC_Api_Exception and surfaces it via an admin notice —
|
||||
* we never let the cache drift from the server.
|
||||
*
|
||||
* Cross-engagement history (previous-session diff, per-step prior notes) is
|
||||
* fetched live from the server. The old local `att_hc_previous_session`
|
||||
* option is gone — the server has that data, and the plugin gets uninstalled
|
||||
* per engagement anyway.
|
||||
*/
|
||||
final class ATT_HC_Session {
|
||||
|
||||
@@ -36,38 +44,106 @@ final class ATT_HC_Session {
|
||||
return new self($raw);
|
||||
}
|
||||
|
||||
public static function start(int $technician_id): self {
|
||||
/**
|
||||
* Start a new session. POSTs to the central server first; on success
|
||||
* caches the session locally so subsequent renders don't refetch.
|
||||
*
|
||||
* @param int $technician_id WP user id
|
||||
* @param string|null $site_key logical engagement identifier. Falls back to
|
||||
* normalised get_site_url() when null — the
|
||||
* start-screen UI (hc-m1a) lets the tech edit it.
|
||||
* @throws ATT_HC_Api_Exception when the server rejects or is unreachable.
|
||||
*/
|
||||
public static function start(int $technician_id, ?string $site_key = null): self {
|
||||
$reporting_url = get_site_url();
|
||||
$site_key = $site_key !== null && $site_key !== '' ? $site_key : self::normalise_site_url($reporting_url);
|
||||
|
||||
$data = [
|
||||
'id' => uniqid('att_hc_', true),
|
||||
'started_at' => time(),
|
||||
'finished_at' => null,
|
||||
'technician_id'=> $technician_id,
|
||||
'site_url' => get_site_url(),
|
||||
'wp_version' => get_bloginfo('version'),
|
||||
'php_version' => PHP_VERSION,
|
||||
'steps' => [], // keyed by step id → ['status' => ..., 'notes' => ..., 'updated_at' => ...]
|
||||
'id' => uniqid('att_hc_', true),
|
||||
'started_at' => time(),
|
||||
'finished_at' => null,
|
||||
'technician_id' => $technician_id,
|
||||
'site_key' => $site_key,
|
||||
'reporting_url' => $reporting_url,
|
||||
// Kept as a display-only echo of where this install reports from.
|
||||
// Pre-existing report templates still read `site_url` — keep populated.
|
||||
'site_url' => $reporting_url,
|
||||
'wp_version' => get_bloginfo('version'),
|
||||
'php_version' => PHP_VERSION,
|
||||
'steps' => [],
|
||||
];
|
||||
|
||||
ATT_HC_Api::create_healthcheck([
|
||||
'id' => $data['id'],
|
||||
'site_key' => $data['site_key'],
|
||||
'started_at' => $data['started_at'],
|
||||
'reporting_url' => $data['reporting_url'],
|
||||
'technician_id' => $data['technician_id'],
|
||||
'wp_version' => $data['wp_version'],
|
||||
'php_version' => $data['php_version'],
|
||||
]);
|
||||
|
||||
update_option(ATT_HC_OPT_SESSION, $data, false);
|
||||
return new self($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume an existing server-side session by id. Pulls the full state down
|
||||
* (including step history) and writes it into the local cache as the active
|
||||
* session. Used by the start-screen "Resume" button (hc-m1a).
|
||||
*
|
||||
* @throws ATT_HC_Api_Exception
|
||||
*/
|
||||
public static function resume(string $id): self {
|
||||
$remote = ATT_HC_Api::get_healthcheck($id);
|
||||
$data = self::hydrate_from_remote($remote);
|
||||
update_option(ATT_HC_OPT_SESSION, $data, false);
|
||||
return new self($data);
|
||||
}
|
||||
|
||||
/** Drop the local cache. The server keeps the record — history is the point. */
|
||||
public static function discard(): void {
|
||||
delete_option(ATT_HC_OPT_SESSION);
|
||||
}
|
||||
|
||||
/** The previous finished session (for diffing). Stored on finish(). */
|
||||
public static function previous(): ?self {
|
||||
$raw = get_option('att_hc_previous_session');
|
||||
if (!is_array($raw) || empty($raw['id'])) return null;
|
||||
return new self($raw);
|
||||
/**
|
||||
* The most recent FINISHED server-side session for this site, excluding the
|
||||
* active one. Used for the existing diff/review-items panel.
|
||||
*
|
||||
* Returns null when there's no prior history (or when the server is
|
||||
* unreachable — we degrade silently here because the diff is a nice-to-have,
|
||||
* not a blocker for completing a healthcheck).
|
||||
*/
|
||||
public static function previous(?string $site_key = null, ?string $exclude_id = null): ?self {
|
||||
if ($site_key === null) {
|
||||
$current = self::current();
|
||||
if (!$current) return null;
|
||||
$site_key = $current->site_key();
|
||||
$exclude_id = $exclude_id ?? $current->id();
|
||||
}
|
||||
|
||||
try {
|
||||
$response = ATT_HC_Api::list_healthchecks($site_key, include_steps: true, limit: 10);
|
||||
} catch (ATT_HC_Api_Exception $e) {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach ($response['healthchecks'] ?? [] as $hc) {
|
||||
if (!empty($hc['finished_at']) && ($exclude_id === null || $hc['id'] !== $exclude_id)) {
|
||||
return new self(self::hydrate_from_remote($hc));
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public function id(): string { return (string) $this->data['id']; }
|
||||
public function started_at(): int { return (int) $this->data['started_at']; }
|
||||
public function finished_at(): ?int { return isset($this->data['finished_at']) ? (int) $this->data['finished_at'] : null; }
|
||||
public function is_finished(): bool { return $this->finished_at() !== null; }
|
||||
public function technician_id(): int { return (int) $this->data['technician_id']; }
|
||||
public function site_url(): string { return (string) ($this->data['site_url'] ?? get_site_url()); }
|
||||
public function technician_id(): int { return (int) ($this->data['technician_id'] ?? 0); }
|
||||
public function site_url(): string { return (string) ($this->data['site_url'] ?? $this->data['reporting_url'] ?? get_site_url()); }
|
||||
public function site_key(): string { return (string) ($this->data['site_key'] ?? self::normalise_site_url(get_site_url())); }
|
||||
public function reporting_url(): string { return (string) ($this->data['reporting_url'] ?? get_site_url()); }
|
||||
public function wp_version(): string { return (string) ($this->data['wp_version'] ?? ''); }
|
||||
public function php_version(): string { return (string) ($this->data['php_version'] ?? ''); }
|
||||
public function data(): array { return $this->data; }
|
||||
@@ -80,10 +156,24 @@ final class ATT_HC_Session {
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Write-through step update. Sends the full step state (status + notes +
|
||||
* any prior autocheck) to the server first, then updates the local cache.
|
||||
*
|
||||
* @throws ATT_HC_Api_Exception
|
||||
*/
|
||||
public function update_step(string $step_id, string $status, string $notes): void {
|
||||
if (!in_array($status, self::VALID_STATUSES, true)) {
|
||||
$status = self::STATUS_NOT_STARTED;
|
||||
}
|
||||
$autocheck = $this->data['autocheck'][$step_id] ?? null;
|
||||
ATT_HC_Api::upsert_step($this->id(), $step_id, [
|
||||
'status' => $status,
|
||||
'notes' => $notes,
|
||||
'reporting_url' => $this->reporting_url(),
|
||||
'autocheck' => $autocheck,
|
||||
]);
|
||||
|
||||
$this->data['steps'][$step_id] = [
|
||||
'status' => $status,
|
||||
'notes' => $notes,
|
||||
@@ -92,19 +182,37 @@ final class ATT_HC_Session {
|
||||
update_option(ATT_HC_OPT_SESSION, $this->data, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the session finished. Server PUT first, then cache update.
|
||||
*
|
||||
* @throws ATT_HC_Api_Exception
|
||||
*/
|
||||
public function finish(): void {
|
||||
$this->data['finished_at'] = time();
|
||||
$finished_at = time();
|
||||
ATT_HC_Api::update_healthcheck($this->id(), ['finished_at' => $finished_at]);
|
||||
$this->data['finished_at'] = $finished_at;
|
||||
update_option(ATT_HC_OPT_SESSION, $this->data, false);
|
||||
// Snapshot for next-session diff. One slot, overwritten each finish.
|
||||
update_option('att_hc_previous_session', $this->data, false);
|
||||
}
|
||||
|
||||
/** Store the result of running autocheck() on a step. */
|
||||
/**
|
||||
* Store the result of running autocheck() on a step. Write-through:
|
||||
* we PUT the step row (preserving any existing status + notes) with the
|
||||
* new autocheck blob attached.
|
||||
*
|
||||
* @throws ATT_HC_Api_Exception
|
||||
*/
|
||||
public function set_autocheck(string $step_id, array $findings): void {
|
||||
$this->data['autocheck'][$step_id] = [
|
||||
'checked_at' => time(),
|
||||
'findings' => $findings,
|
||||
];
|
||||
$blob = ['checked_at' => time(), 'findings' => $findings];
|
||||
$state = $this->step_state($step_id);
|
||||
|
||||
ATT_HC_Api::upsert_step($this->id(), $step_id, [
|
||||
'status' => $state['status'],
|
||||
'notes' => (string) $state['notes'],
|
||||
'reporting_url' => $this->reporting_url(),
|
||||
'autocheck' => $blob,
|
||||
]);
|
||||
|
||||
$this->data['autocheck'][$step_id] = $blob;
|
||||
update_option(ATT_HC_OPT_SESSION, $this->data, false);
|
||||
}
|
||||
|
||||
@@ -125,4 +233,49 @@ final class ATT_HC_Session {
|
||||
}
|
||||
return ['done' => $done, 'total' => $total];
|
||||
}
|
||||
|
||||
/**
|
||||
* lowercase, strip scheme + leading www., trim trailing slash. The default
|
||||
* site_key — the tech can override at start time (hc-m1a).
|
||||
*/
|
||||
public static function normalise_site_url(string $url): string {
|
||||
$host = parse_url($url, PHP_URL_HOST) ?: $url;
|
||||
$host = strtolower($host);
|
||||
if (str_starts_with($host, 'www.')) $host = substr($host, 4);
|
||||
$path = parse_url($url, PHP_URL_PATH) ?: '';
|
||||
$path = rtrim($path, '/');
|
||||
return $host . $path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn a server `GET /healthchecks/{id}` (or a `?include=steps` list row)
|
||||
* into the local cache shape.
|
||||
*/
|
||||
private static function hydrate_from_remote(array $remote): array {
|
||||
$data = [
|
||||
'id' => (string) $remote['id'],
|
||||
'started_at' => (int) $remote['started_at'],
|
||||
'finished_at' => isset($remote['finished_at']) && $remote['finished_at'] !== null ? (int) $remote['finished_at'] : null,
|
||||
'technician_id' => isset($remote['technician_id']) ? (int) $remote['technician_id'] : 0,
|
||||
'site_key' => (string) $remote['site_key'],
|
||||
'reporting_url' => (string) $remote['reporting_url'],
|
||||
'site_url' => (string) $remote['reporting_url'],
|
||||
'wp_version' => (string) ($remote['wp_version'] ?? ''),
|
||||
'php_version' => (string) ($remote['php_version'] ?? ''),
|
||||
'steps' => [],
|
||||
'autocheck' => [],
|
||||
];
|
||||
foreach ($remote['steps'] ?? [] as $step_row) {
|
||||
$sid = (string) $step_row['step_id'];
|
||||
$data['steps'][$sid] = [
|
||||
'status' => (string) $step_row['status'],
|
||||
'notes' => (string) ($step_row['notes'] ?? ''),
|
||||
'updated_at' => (int) $step_row['updated_at'],
|
||||
];
|
||||
if (!empty($step_row['autocheck']) && is_array($step_row['autocheck'])) {
|
||||
$data['autocheck'][$sid] = $step_row['autocheck'];
|
||||
}
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user