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:
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user