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>
282 lines
12 KiB
PHP
282 lines
12 KiB
PHP
<?php
|
|
if (!defined('ABSPATH')) exit;
|
|
|
|
/**
|
|
* Healthcheck session — server is the source of truth.
|
|
*
|
|
* 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 {
|
|
|
|
public const STATUS_NOT_STARTED = 'not_started';
|
|
public const STATUS_DONE = 'done';
|
|
public const STATUS_SKIPPED = 'skipped';
|
|
public const STATUS_BLOCKED = 'blocked';
|
|
public const STATUS_NA = 'n_a';
|
|
|
|
public const VALID_STATUSES = [
|
|
self::STATUS_NOT_STARTED,
|
|
self::STATUS_DONE,
|
|
self::STATUS_SKIPPED,
|
|
self::STATUS_BLOCKED,
|
|
self::STATUS_NA,
|
|
];
|
|
|
|
private array $data;
|
|
|
|
private function __construct(array $data) {
|
|
$this->data = $data;
|
|
}
|
|
|
|
public static function current(): ?self {
|
|
$raw = get_option(ATT_HC_OPT_SESSION);
|
|
if (!is_array($raw) || empty($raw['id'])) return null;
|
|
return new self($raw);
|
|
}
|
|
|
|
/**
|
|
* 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_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 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'] ?? 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; }
|
|
|
|
public function step_state(string $step_id): array {
|
|
return $this->data['steps'][$step_id] ?? [
|
|
'status' => self::STATUS_NOT_STARTED,
|
|
'notes' => '',
|
|
'updated_at' => null,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
'updated_at' => time(),
|
|
];
|
|
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 {
|
|
$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);
|
|
}
|
|
|
|
/**
|
|
* 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 {
|
|
$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);
|
|
}
|
|
|
|
/** Returns ['checked_at'=>int, 'findings'=>array] or null. */
|
|
public function get_autocheck(string $step_id): ?array {
|
|
return $this->data['autocheck'][$step_id] ?? null;
|
|
}
|
|
|
|
public function progress(): array {
|
|
$steps = ATT_HC_Steps::instance()->all();
|
|
$total = count($steps);
|
|
$done = 0;
|
|
foreach ($steps as $step) {
|
|
$st = $this->step_state($step->id());
|
|
if (in_array($st['status'], [self::STATUS_DONE, self::STATUS_SKIPPED, self::STATUS_NA], true)) {
|
|
$done++;
|
|
}
|
|
}
|
|
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;
|
|
}
|
|
}
|