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:
158
server/src/Store.php
Normal file
158
server/src/Store.php
Normal file
@@ -0,0 +1,158 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace AttHc\Server;
|
||||
|
||||
/**
|
||||
* Thin DB access layer. All methods return plain arrays, ready to JSON-encode.
|
||||
* Timestamps stored as unix seconds (UTC).
|
||||
*/
|
||||
final class Store {
|
||||
public static function insertHealthcheck(array $row): void {
|
||||
$now = time();
|
||||
$stmt = Db::pdo()->prepare('INSERT INTO healthchecks
|
||||
(id, site_key, started_at, finished_at, technician_id, reporting_url, wp_version, php_version, created_at, updated_at)
|
||||
VALUES (:id, :site_key, :started_at, NULL, :technician_id, :reporting_url, :wp_version, :php_version, :created_at, :updated_at)');
|
||||
$stmt->execute([
|
||||
':id' => $row['id'],
|
||||
':site_key' => $row['site_key'],
|
||||
':started_at' => $row['started_at'],
|
||||
':technician_id' => $row['technician_id'] ?? null,
|
||||
':reporting_url' => $row['reporting_url'],
|
||||
':wp_version' => $row['wp_version'] ?? null,
|
||||
':php_version' => $row['php_version'] ?? null,
|
||||
':created_at' => $now,
|
||||
':updated_at' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
public static function getHealthcheck(string $id): ?array {
|
||||
$stmt = Db::pdo()->prepare('SELECT * FROM healthchecks WHERE id = ?');
|
||||
$stmt->execute([$id]);
|
||||
$row = $stmt->fetch();
|
||||
return $row ?: null;
|
||||
}
|
||||
|
||||
public static function updateHealthcheck(string $id, ?int $finishedAt): bool {
|
||||
$stmt = Db::pdo()->prepare('UPDATE healthchecks SET finished_at = :finished_at, updated_at = :updated_at WHERE id = :id');
|
||||
$stmt->execute([
|
||||
':id' => $id,
|
||||
':finished_at' => $finishedAt,
|
||||
':updated_at' => time(),
|
||||
]);
|
||||
return $stmt->rowCount() > 0;
|
||||
}
|
||||
|
||||
public static function upsertStep(string $healthcheckId, string $stepId, array $row): void {
|
||||
$stmt = Db::pdo()->prepare('INSERT INTO step_updates
|
||||
(healthcheck_id, step_id, status, notes, autocheck_json, reporting_url, updated_at)
|
||||
VALUES (:hc, :sid, :status, :notes, :autocheck, :reporting_url, :updated_at)
|
||||
ON CONFLICT (healthcheck_id, step_id) DO UPDATE SET
|
||||
status = excluded.status,
|
||||
notes = excluded.notes,
|
||||
autocheck_json = excluded.autocheck_json,
|
||||
reporting_url = excluded.reporting_url,
|
||||
updated_at = excluded.updated_at');
|
||||
$stmt->execute([
|
||||
':hc' => $healthcheckId,
|
||||
':sid' => $stepId,
|
||||
':status' => $row['status'],
|
||||
':notes' => $row['notes'] ?? '',
|
||||
':autocheck' => isset($row['autocheck']) ? json_encode($row['autocheck']) : null,
|
||||
':reporting_url' => $row['reporting_url'],
|
||||
':updated_at' => time(),
|
||||
]);
|
||||
// Bump parent's updated_at so list views sort sensibly.
|
||||
Db::pdo()->prepare('UPDATE healthchecks SET updated_at = ? WHERE id = ?')
|
||||
->execute([time(), $healthcheckId]);
|
||||
}
|
||||
|
||||
/** @return array<int, array> */
|
||||
public static function stepsFor(string $healthcheckId): array {
|
||||
$stmt = Db::pdo()->prepare('SELECT step_id, status, notes, autocheck_json, reporting_url, updated_at
|
||||
FROM step_updates WHERE healthcheck_id = ? ORDER BY updated_at ASC');
|
||||
$stmt->execute([$healthcheckId]);
|
||||
$rows = $stmt->fetchAll();
|
||||
foreach ($rows as &$r) {
|
||||
$r['autocheck'] = $r['autocheck_json'] !== null ? json_decode($r['autocheck_json'], true) : null;
|
||||
unset($r['autocheck_json']);
|
||||
}
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/** @return array<int, array> */
|
||||
public static function listHealthchecksForSite(string $siteKey, int $limit = 50): array {
|
||||
$stmt = Db::pdo()->prepare('SELECT * FROM healthchecks WHERE site_key = ? ORDER BY started_at DESC LIMIT ?');
|
||||
$stmt->bindValue(1, $siteKey);
|
||||
$stmt->bindValue(2, $limit, \PDO::PARAM_INT);
|
||||
$stmt->execute();
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Notes for one step across the last N sessions for a site.
|
||||
* @return array<int, array>
|
||||
*/
|
||||
public static function stepHistory(string $stepId, string $siteKey, int $limit = 5, ?string $excludeId = null): array {
|
||||
$sql = 'SELECT h.id AS healthcheck_id, h.started_at, h.finished_at, h.reporting_url AS session_reporting_url,
|
||||
s.status, s.notes, s.autocheck_json, s.reporting_url AS step_reporting_url, s.updated_at
|
||||
FROM step_updates s
|
||||
JOIN healthchecks h ON h.id = s.healthcheck_id
|
||||
WHERE s.step_id = ? AND h.site_key = ?';
|
||||
$params = [$stepId, $siteKey];
|
||||
if ($excludeId !== null && $excludeId !== '') {
|
||||
$sql .= ' AND h.id != ?';
|
||||
$params[] = $excludeId;
|
||||
}
|
||||
$sql .= ' ORDER BY h.started_at DESC LIMIT ?';
|
||||
|
||||
$stmt = Db::pdo()->prepare($sql);
|
||||
$i = 1;
|
||||
foreach ($params as $p) {
|
||||
$stmt->bindValue($i++, $p);
|
||||
}
|
||||
$stmt->bindValue($i, $limit, \PDO::PARAM_INT);
|
||||
$stmt->execute();
|
||||
$rows = $stmt->fetchAll();
|
||||
foreach ($rows as &$r) {
|
||||
$r['autocheck'] = $r['autocheck_json'] !== null ? json_decode($r['autocheck_json'], true) : null;
|
||||
unset($r['autocheck_json']);
|
||||
}
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-step note count for every step that has at least one row for the site.
|
||||
* Drives the "Previous notes (N)" disclosure on each step card.
|
||||
* @return array<string, int> step_id => count
|
||||
*/
|
||||
public static function stepCountsForSite(string $siteKey, ?string $excludeId = null): array {
|
||||
$sql = 'SELECT s.step_id, COUNT(*) AS c
|
||||
FROM step_updates s
|
||||
JOIN healthchecks h ON h.id = s.healthcheck_id
|
||||
WHERE h.site_key = ?';
|
||||
$params = [$siteKey];
|
||||
if ($excludeId !== null && $excludeId !== '') {
|
||||
$sql .= ' AND h.id != ?';
|
||||
$params[] = $excludeId;
|
||||
}
|
||||
$sql .= ' GROUP BY s.step_id';
|
||||
$stmt = Db::pdo()->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
$out = [];
|
||||
foreach ($stmt->fetchAll() as $row) {
|
||||
$out[(string) $row['step_id']] = (int) $row['c'];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** @return array<int, string> */
|
||||
public static function recentSiteKeys(int $limit = 20): array {
|
||||
$stmt = Db::pdo()->prepare('SELECT site_key, MAX(updated_at) AS last_seen
|
||||
FROM healthchecks GROUP BY site_key ORDER BY last_seen DESC LIMIT ?');
|
||||
$stmt->bindValue(1, $limit, \PDO::PARAM_INT);
|
||||
$stmt->execute();
|
||||
$rows = $stmt->fetchAll();
|
||||
return array_map(static fn($r) => ['site_key' => $r['site_key'], 'last_seen' => (int) $r['last_seen']], $rows);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user