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:
@@ -1,3 +1,10 @@
|
||||
{"_type":"issue","id":"hc-cc8","title":"Plugin: per-step 'view previous notes' panel","description":"Each step card gets a small link/disclosure: 'Previous notes for this step (N)' where N is the count from the server.\n\nOn expand: render a list of past notes from GET /healthchecks/steps/{step_id}?site_key=\u003ccurrent\u003e\u0026limit=5. Each entry shows:\n- started_at (formatted)\n- status badge\n- notes (or 'no notes')\n- reporting_url if different from the current session\n\nLoaded lazily on first expand (one request per step) to avoid hammering the server on page load. Cache result for the page lifetime.","status":"closed","priority":1,"issue_type":"feature","assignee":"Steve Hanlon","owner":"steve@hanlon.co.uk","created_at":"2026-06-29T11:10:17Z","created_by":"Steve Hanlon","updated_at":"2026-06-29T11:41:06Z","started_at":"2026-06-29T11:36:10Z","closed_at":"2026-06-29T11:41:06Z","close_reason":"Per-step history disclosure shipped end-to-end.\n\nServer:\n- New GET /step-counts?site_key=...\u0026exclude_id=... returns {counts: {step_id: int}} for the (N) badge in one round-trip\n- stepHistory + stepCountsForSite both grew an exclude_id param so the active session doesn't appear in its own 'previous notes' panel\n- Routes: /step-counts moved to top-level path to avoid the /healthchecks/{id} pattern claiming 'step-counts' as an id\n\nPlugin:\n- ATT_HC_Api::step_counts() and step_history() pass exclude_id when available\n- att_hc_render_active_session pre-fetches step counts once and passes per-step count to the card renderer\n- Step card now renders a \u003cdetails class=att-hc-history data-step-id=…\u003e with summary 'Previous notes for this step (N)' when N\u003e0\n- New att_hc_print_step_history_assets prints the inline CSS + vanilla JS that hooks the details.toggle event: lazy-fetches on first expand via admin-ajax (action=att_hc_step_history), renders entries with date + status badge + notes + 'Reported from \u003curl\u003e' when different from current reporting_url. Marks data-loaded=yes to cache for page lifetime; resets to no on error so user can retry.\n- New wp_ajax_att_hc_step_history admin-ajax handler returns wp_send_json_success/error with nonce verification (att_hc_step_history nonce)\n\nVerified end-to-end against live server: counts correct with + without exclude_id (4 vs 3 for active vs prior), step_history filters active session, empty step / unknown site return cleanly, reordered routes still work.","dependencies":[{"issue_id":"hc-cc8","depends_on_id":"hc-0p1","type":"parent-child","created_at":"2026-06-29T12:13:24Z","created_by":"Steve Hanlon","metadata":"{}"},{"issue_id":"hc-cc8","depends_on_id":"hc-9jl","type":"blocks","created_at":"2026-06-29T12:10:37Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"hc-m1a","title":"Plugin: start screen — site_key field + recent dropdown + resume","description":"When no active local session exists, the start screen shows:\n\n1. Text input 'Site / engagement key' — defaults to normalised get_site_url() (lowercase, strip scheme + leading www., trim trailing slash). Editable. Help text: 'Used to group runs that span dev + live for the same engagement. Pick from recent engagements below if continuing one.'\n2. Dropdown 'Recent engagements' populated from GET /sites?limit=20. Selecting one fills the input.\n3. After site_key is set, GET /healthchecks?site_key=...\u0026include=steps. If any UNFINISHED sessions exist, list them with started_at and progress, each with a [Resume] button. Plus a separate [Start fresh] button.\n\nResume: load the chosen session into the local option (no new POST). Start fresh: new POST /healthchecks.","status":"closed","priority":1,"issue_type":"task","assignee":"Steve Hanlon","owner":"steve@hanlon.co.uk","created_at":"2026-06-29T11:10:05Z","created_by":"Steve Hanlon","updated_at":"2026-06-29T11:36:09Z","started_at":"2026-06-29T11:33:33Z","closed_at":"2026-06-29T11:36:09Z","close_reason":"Start screen rewritten:\n- Editable 'Site / engagement key' input, defaults to ATT_HC_Session::normalise_site_url(get_site_url()).\n- datalist-backed autocomplete from GET /sites?limit=20 (recent engagements). Picking one fills the input via native browser UX.\n- 'Look up engagement' submits as GET with site_key in the URL, so re-rendering picks it up and re-queries incompletes.\n- Table of in-progress sessions for the selected site_key with started_at, last-activity, reporting_url, and a [Resume] button per row. Each Resume button posts to a new att_hc_handle_resume handler which calls ATT_HC_Session::resume($id).\n- Separate Start button below labelled differently depending on whether incompletes exist.\n\nDouble-click guard: att_hc_handle_start (and att_hc_handle_resume) early-return to the main page if ATT_HC_Session::current() is non-null, so a stray repeat click can't 409 against the (site_key, started_at) unique constraint with a confusing duplicate error.\n\nEnd-to-end verified: /sites populates the datalist, incomplete filter shows 2 alpha + 1 beta + 0 unknown correctly, resume() repopulates cache and restores step notes, finish() removes from incomplete list. Lookup-err and recent-sites-err paths both surface as inline warnings without blocking the Start button.","dependencies":[{"issue_id":"hc-m1a","depends_on_id":"hc-0p1","type":"parent-child","created_at":"2026-06-29T12:13:23Z","created_by":"Steve Hanlon","metadata":"{}"},{"issue_id":"hc-m1a","depends_on_id":"hc-9jl","type":"blocks","created_at":"2026-06-29T12:10:36Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"hc-9jl","title":"Plugin: refactor ATT_HC_Session to write-through","description":"Session is now server-of-truth. Local WP option becomes a thin cache of the *active* session for fast page loads.\n\nChanges:\n- start(): generates id locally, POSTs to /healthchecks, stores response in option. site_key + reporting_url are new fields.\n- update_step(): updates option AND PUTs to /healthchecks/{id}/steps/{step_id}. If server call fails, the option is NOT updated and the caller sees the error (no drift).\n- finish(): PUT /healthchecks/{id} with finished_at, then update option.\n- set_autocheck(): same write-through pattern.\n- previous(): replace local 'att_hc_previous_session' option with a call to GET /healthchecks?site_key=...\u0026include=steps\u0026limit=2, return the most recent FINISHED one before the current session.\n- discard(): delete option only. Server keeps the record (intentional; history is the point).\n\nRemove the att_hc_previous_session option entirely (data is on the server now).","status":"closed","priority":1,"issue_type":"task","assignee":"Steve Hanlon","owner":"steve@hanlon.co.uk","created_at":"2026-06-29T11:09:55Z","created_by":"Steve Hanlon","updated_at":"2026-06-29T11:30:29Z","started_at":"2026-06-29T11:25:28Z","closed_at":"2026-06-29T11:30:29Z","close_reason":"ATT_HC_Session refactored to write-through. Every mutating method (start / update_step / finish / set_autocheck) POSTs or PUTs to the central server first; only on success does the local WP option cache get updated. ATT_HC_Api_Exception bubbles to callers — admin handlers (start / save_step / finish / refresh_checks / step_action) now wrap calls in try/catch and surface via att_hc_api_error transient → admin notice. Added: site_key field (defaults to normalise_site_url(get_site_url())), reporting_url field, ATT_HC_Session::resume(id) for hc-m1a, ATT_HC_Session::normalise_site_url(). previous() now fetches from server (list_healthchecks ?include=steps) and reconstructs into the local session shape; degrades silently on server unreachable since diff is a nice-to-have. Removed att_hc_previous_session option entirely. Also added a config-error gate at the top of the admin page that blocks the UI with a clear notice when wp-config.php constants are missing or wrong. Added server/dev-router.php to work around PHP -S 405-ing dotted uniqid paths (production Apache/nginx unaffected). End-to-end verified against live server: 13 assertions covering happy path, autocheck preservation across upserts, multi-session-per-site, previous()/resume() reconstruction, discard-preserves-server-record, and error bubbling.","dependencies":[{"issue_id":"hc-9jl","depends_on_id":"hc-0p1","type":"parent-child","created_at":"2026-06-29T12:13:23Z","created_by":"Steve Hanlon","metadata":"{}"},{"issue_id":"hc-9jl","depends_on_id":"hc-0rr","type":"blocks","created_at":"2026-06-29T12:10:36Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0}
|
||||
{"_type":"issue","id":"hc-0rr","title":"Plugin: HTTP client + config constants","description":"New class ATT_HC_Api in includes/class-att-hc-api.php.\n\nReads ATT_HC_API_URL and ATT_HC_API_KEY from wp-config.php constants. If either missing or ATT_HC_API_URL is not https://, plugin shows a blocking admin notice on Tools→Site Healthcheck and refuses to start sessions.\n\nMethods mirror server endpoints. wp_remote_post/get with timeout=15, Authorization header injected. On non-2xx, throws ATT_HC_Api_Exception with the server's error.code and human message. Caller (admin page) catches and surfaces.\n\nAcceptance:\n- Missing constants: clear notice, no PHP fatals\n- Wrong key (401): notice says 'server rejected our credentials'\n- Server down: notice says 'central history server unreachable — retry'","status":"closed","priority":1,"issue_type":"task","assignee":"Steve Hanlon","owner":"steve@hanlon.co.uk","created_at":"2026-06-29T11:09:45Z","created_by":"Steve Hanlon","updated_at":"2026-06-29T11:23:23Z","started_at":"2026-06-29T11:21:00Z","closed_at":"2026-06-29T11:23:23Z","close_reason":"ATT_HC_Api + ATT_HC_Api_Exception added and wired into the plugin bootstrap. Reads ATT_HC_API_URL / ATT_HC_API_KEY constants from wp-config.php. Methods: ping, create_healthcheck, update_healthcheck, get_healthcheck, list_healthchecks (with include=steps), upsert_step, step_history, recent_sites. Throws ATT_HC_Api_Exception with error_code + http_status on non-2xx or transport failure. Refuses non-HTTPS endpoints (loopback exception for dev). Verified end-to-end against live server: all 9 success paths round-trip, 409 + 404 error paths throw with correct code/message. No existing plugin behaviour changed yet — wiring in happens in hc-9jl.","dependencies":[{"issue_id":"hc-0rr","depends_on_id":"hc-0p1","type":"parent-child","created_at":"2026-06-29T12:13:22Z","created_by":"Steve Hanlon","metadata":"{}"},{"issue_id":"hc-0rr","depends_on_id":"hc-rdo","type":"blocks","created_at":"2026-06-29T12:10:36Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0}
|
||||
{"_type":"issue","id":"hc-rdo","title":"Server: HTTP endpoints","description":"All routes require Authorization: Bearer \u003cATT_HC_API_KEY\u003e. JSON in/out. UTC unix timestamps.\n\nPOST /healthchecks Register new session. Body: {id, site_key, started_at, reporting_url, technician_id?, wp_version, php_version}. 201 on success, 409 if (site_key, started_at) collides.\nPUT /healthchecks/{id} Update session metadata. Body: {finished_at?}. 200 on success.\nPUT /healthchecks/{id}/steps/{step_id} Upsert step state. Body: {status, notes, autocheck?, reporting_url}. 200 on success.\nGET /healthchecks?site_key=...\u0026include=steps List sessions for a site, newest first. include=steps inlines step_updates.\nGET /healthchecks/{id} Full session including steps. For Resume flow.\nGET /healthchecks/steps/{step_id}?site_key=...\u0026limit=5 Notes for one step across last N sessions for this site. Newest first.\nGET /sites?limit=20 Recent distinct site_keys for the start-screen dropdown.\n\nErrors: {error: 'human message', code: 'machine_code'}. 401 for bad auth, 404 for unknown ids, 422 for validation, 500 for unexpected.","status":"closed","priority":1,"issue_type":"task","assignee":"Steve Hanlon","owner":"steve@hanlon.co.uk","created_at":"2026-06-29T11:09:35Z","created_by":"Steve Hanlon","updated_at":"2026-06-29T11:20:54Z","started_at":"2026-06-29T11:17:58Z","closed_at":"2026-06-29T11:20:54Z","close_reason":"All 7 endpoints implemented and smoke-tested end-to-end with curl: POST /healthchecks (201/409/422), GET /healthchecks/{id} (200/404), PUT /healthchecks/{id} (finish), PUT step (upsert with autocheck, 404/422), GET list (with optional include=steps, ordered DESC by started_at), GET step history across sessions, GET /sites recent keys. Multi-session per site_key works (different started_at). All 18 test cases passed including the dev-vs-live reporting_url tracking.","dependencies":[{"issue_id":"hc-rdo","depends_on_id":"hc-0p1","type":"parent-child","created_at":"2026-06-29T12:13:22Z","created_by":"Steve Hanlon","metadata":"{}"},{"issue_id":"hc-rdo","depends_on_id":"hc-7qm","type":"blocks","created_at":"2026-06-29T12:10:35Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0}
|
||||
{"_type":"issue","id":"hc-7qm","title":"Server: schema for healthchecks + step_updates + audit","description":"Tables:\n\nhealthchecks\n id TEXT PRIMARY KEY -- client-generated (uniqid from plugin)\n site_key TEXT NOT NULL -- composite key with started_at\n started_at INTEGER NOT NULL -- unix seconds\n finished_at INTEGER NULL\n technician_id INTEGER NULL -- WP user id, informational\n reporting_url TEXT NOT NULL -- get_site_url() at start\n wp_version TEXT\n php_version TEXT\n created_at INTEGER NOT NULL\n updated_at INTEGER NOT NULL\n UNIQUE (site_key, started_at)\n\nstep_updates\n healthcheck_id TEXT NOT NULL REFERENCES healthchecks(id) ON DELETE CASCADE\n step_id TEXT NOT NULL\n status TEXT NOT NULL -- not_started|done|skipped|blocked|n_a\n notes TEXT NOT NULL DEFAULT ''\n autocheck_json TEXT NULL -- JSON blob if step ran autocheck\n updated_at INTEGER NOT NULL\n reporting_url TEXT NOT NULL\n PRIMARY KEY (healthcheck_id, step_id)\n\nrequest_log (audit, simple)\n id INTEGER PRIMARY KEY AUTOINCREMENT\n ts INTEGER NOT NULL\n method TEXT\n path TEXT\n status INTEGER\n ip TEXT\n bytes_in INTEGER\n bytes_out INTEGER\n\nIndexes:\n- healthchecks(site_key, started_at DESC)\n- step_updates(step_id, updated_at DESC) -- for cross-session step history","status":"closed","priority":1,"issue_type":"task","assignee":"Steve Hanlon","owner":"steve@hanlon.co.uk","created_at":"2026-06-29T11:09:24Z","created_by":"Steve Hanlon","updated_at":"2026-06-29T11:17:57Z","started_at":"2026-06-29T11:17:02Z","closed_at":"2026-06-29T11:17:57Z","close_reason":"Schema + auto-migration runner in place. 0001_initial.sql creates healthchecks (unique (site_key, started_at)), step_updates (PK (healthcheck_id, step_id), FK cascades), request_log + indexes. Migrations table tracks applied files. Verified: cold start creates all 4 tables; second run is a no-op (1 row in migrations table). SQLite WAL + foreign_keys enabled in Db.php.","dependencies":[{"issue_id":"hc-7qm","depends_on_id":"hc-0p1","type":"parent-child","created_at":"2026-06-29T12:13:22Z","created_by":"Steve Hanlon","metadata":"{}"},{"issue_id":"hc-7qm","depends_on_id":"hc-r5r","type":"blocks","created_at":"2026-06-29T12:13:24Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0}
|
||||
{"_type":"issue","id":"hc-r5r","title":"Server: scaffold PHP/SQLite app in server/","description":"Single-file router (index.php) + PDO + SQLite. No framework.\n\nLayout:\n- server/public/index.php — front controller, routing\n- server/src/ — Router, Auth, Store, controllers\n- server/migrations/ — SQL files run on boot if schema missing\n- server/config.php.example — copy to config.php on deploy, holds API key + DSN\n- server/.htaccess — route all to public/index.php\n\nAcceptance:\n- 'php -S localhost:8000 -t server/public' starts a working dev server\n- GET / returns {ok:true, version:...}\n- Missing/invalid Authorization header returns 401","status":"closed","priority":1,"issue_type":"task","assignee":"Steve Hanlon","owner":"steve@hanlon.co.uk","created_at":"2026-06-29T11:09:11Z","created_by":"Steve Hanlon","updated_at":"2026-06-29T11:13:25Z","started_at":"2026-06-29T11:10:44Z","closed_at":"2026-06-29T11:13:25Z","close_reason":"Scaffold complete: front controller + autoloader + Config + Http + Auth + Router + GET /. Dev server boots, GET / returns ok, Auth::require returns 401 for missing/invalid bearer and passes for valid.","dependencies":[{"issue_id":"hc-r5r","depends_on_id":"hc-0p1","type":"parent-child","created_at":"2026-06-29T12:13:21Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0}
|
||||
{"_type":"issue","id":"hc-5ix.26","title":"Decision: distribution model — internal-only, no WP.org listing","description":"This is an internal/agency tool, not a public plugin. Distributed as a built ZIP (GitHub release artefact). Technicians install via wp-admin → Plugins → Upload, or via wp-cli (`wp plugin install \u003curl\u003e --activate`).\n\n**Why:** lets us release fast, keeps client-specific text/checks private, no review process.\n\n**How to apply:**\n- Don't add WP.org boilerplate (readme.txt, screenshots, banner.png).\n- Plugin header version = source of truth for what's installed.\n- Phase 3 considers a self-hosted update channel (hc-5ix.NEW).","status":"open","priority":1,"issue_type":"decision","owner":"steve@hanlon.co.uk","created_at":"2026-06-11T14:40:29Z","created_by":"Steve Hanlon","updated_at":"2026-06-11T14:40:29Z","labels":["phase-1"],"dependencies":[{"issue_id":"hc-5ix.26","depends_on_id":"hc-5ix","type":"parent-child","created_at":"2026-06-11T15:40:28Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"hc-5ix.6","title":"Session lifecycle: Start/Resume/Finish actions","description":"Start: creates new session, snapshots site URL + WP/PHP versions + technician. Resume: continues the current in-progress session. Finish: stamps finished_at, locks notes, opens report view. If a session is already in-progress, Start asks to discard or resume.","notes":"Built in phase-1 scaffold; passing lint + WP-eval end-to-end smoke test on testsite.","status":"closed","priority":1,"issue_type":"task","owner":"steve@hanlon.co.uk","created_at":"2026-06-11T14:36:13Z","created_by":"Steve Hanlon","updated_at":"2026-06-11T14:49:28Z","started_at":"2026-06-11T14:41:28Z","closed_at":"2026-06-11T14:49:28Z","labels":["phase-1"],"dependencies":[{"issue_id":"hc-5ix.6","depends_on_id":"hc-5ix","type":"parent-child","created_at":"2026-06-11T15:36:12Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"hc-5ix.7","title":"Report generation: Markdown export + copy to clipboard + save to private CPT","description":"Render the finished session as Markdown (technician, site URL, started/finished, then per-step status + notes + sub-items). 'Copy' button and 'Save as report post' (private CPT 'wph_report') so reports are retrievable per site. Plain text fallback. No PDF in phase 1.","notes":"Built in phase-1 scaffold; passing lint + WP-eval end-to-end smoke test on testsite.","status":"closed","priority":1,"issue_type":"task","owner":"steve@hanlon.co.uk","created_at":"2026-06-11T14:36:13Z","created_by":"Steve Hanlon","updated_at":"2026-06-11T14:49:28Z","started_at":"2026-06-11T14:41:29Z","closed_at":"2026-06-11T14:49:28Z","labels":["phase-1"],"dependencies":[{"issue_id":"hc-5ix.7","depends_on_id":"hc-5ix","type":"parent-child","created_at":"2026-06-11T15:36:13Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
@@ -6,6 +13,8 @@
|
||||
{"_type":"issue","id":"hc-5ix.4","title":"Session data model: option-backed in-progress healthcheck record","description":"One in-progress session per site at a time. Stored in a custom option (or a CPT — pick during implementation). Shape: id, started_at, finished_at, technician_id, site_url_snapshot, per_step_state { status: not_started|in_progress|done|skipped|blocked|n_a, notes, completed_at }. Designed so phase-3 automation can attach structured findings later.","notes":"Built in phase-1 scaffold; passing lint + WP-eval end-to-end smoke test on testsite.","status":"closed","priority":1,"issue_type":"task","owner":"steve@hanlon.co.uk","created_at":"2026-06-11T14:36:11Z","created_by":"Steve Hanlon","updated_at":"2026-06-11T14:49:27Z","started_at":"2026-06-11T14:41:27Z","closed_at":"2026-06-11T14:49:27Z","labels":["phase-1"],"dependencies":[{"issue_id":"hc-5ix.4","depends_on_id":"hc-5ix","type":"parent-child","created_at":"2026-06-11T15:36:11Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"hc-5ix.2","title":"Plugin skeleton: header, activation hook, deactivation hook, admin menu (Tools → Site Healthcheck), capability gate","notes":"Built in phase-1 scaffold; passing lint + WP-eval end-to-end smoke test on testsite.","status":"closed","priority":1,"issue_type":"task","owner":"steve@hanlon.co.uk","created_at":"2026-06-11T14:36:10Z","created_by":"Steve Hanlon","updated_at":"2026-06-11T14:49:26Z","started_at":"2026-06-11T14:41:27Z","closed_at":"2026-06-11T14:49:26Z","labels":["phase-1"],"dependencies":[{"issue_id":"hc-5ix.2","depends_on_id":"hc-5ix","type":"parent-child","created_at":"2026-06-11T15:36:09Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"hc-5ix.1","title":"Bootstrap: trigger install of wp-site-recovery plugin as step 0","description":"Healthcheck plugin should check on activation if site-recovery is installed; if not, fetch and install from a known URL/ZIP and activate it. Block stepper from starting until recovery is in place.","notes":"Detection half done (status panel + manual link). Auto-install of recovery plugin from a private URL is the wp-site-recovery side of hc-5ix.27 — closing this as 'detection complete'.","status":"closed","priority":1,"issue_type":"task","owner":"steve@hanlon.co.uk","created_at":"2026-06-11T11:26:40Z","created_by":"Steve Hanlon","updated_at":"2026-06-11T15:03:02Z","closed_at":"2026-06-11T15:03:02Z","labels":["phase-1"],"dependencies":[{"issue_id":"hc-5ix.1","depends_on_id":"hc-5ix","type":"parent-child","created_at":"2026-06-11T12:26:39Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"hc-3y0","title":"Server: deploy notes for VPS","description":"Document deployment to a PHP VPS:\n- PHP version requirements\n- Apache/nginx rewrite to public/index.php\n- File perms for SQLite db file\n- Suggested location (/var/www/att-hc-server/)\n- Generating + setting ATT_HC_API_KEY\n- Backup strategy for the SQLite file (cron + scp/rsync)\n- HTTPS via Let's Encrypt\n\nLives at server/DEPLOY.md.","status":"closed","priority":2,"issue_type":"task","assignee":"Steve Hanlon","owner":"steve@hanlon.co.uk","created_at":"2026-06-29T11:10:27Z","created_by":"Steve Hanlon","updated_at":"2026-06-29T11:23:22Z","started_at":"2026-06-29T11:21:00Z","closed_at":"2026-06-29T11:23:22Z","close_reason":"DEPLOY.md written: requirements, layout, first-time install (rsync + key generation + perms), Apache + nginx vhost templates with Authorization header forwarding, Let's Encrypt, plugin-side wp-config.php constants, SQLite online backup cron, update procedure, health-check curl.","dependencies":[{"issue_id":"hc-3y0","depends_on_id":"hc-0p1","type":"parent-child","created_at":"2026-06-29T12:13:24Z","created_by":"Steve Hanlon","metadata":"{}"},{"issue_id":"hc-3y0","depends_on_id":"hc-rdo","type":"blocks","created_at":"2026-06-29T12:10:37Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"hc-0p1","title":"[epic] Central history server + plugin sync","description":"Replace the local-only session model with a required central server that stores healthcheck history across engagements.\n\nMotivation:\n- Plugin is installed per engagement and uninstalled at the end, so today's local 'previous session' diff dies with it\n- Healthchecks differ between sites; cross-engagement history lets us see prior notes per step and review what's drifted\n- Engagements often span dev + live (different URLs, same logical site), and may run over days/weeks — sometimes never completed\n\nScope:\n- New PHP/SQLite server in server/ (same repo), deployed to a VPS\n- Plugin becomes a write-through client: server is source of truth\n- DB key = (site_key, started_at); site_key defaults to normalised get_site_url() but is editable on start\n- Multiple incomplete sessions per site allowed; on start, tech can Resume or Start fresh\n- Each step card gets a 'view previous notes' link that pulls history from the server\n- Auth: single shared secret as ATT_HC_API_KEY constant in wp-config.php\n- HTTPS required; plain HTTP rejected client-side\n- Server unreachable = Start/Save/Finish block with retry. No offline queue v1.","status":"open","priority":2,"issue_type":"feature","owner":"steve@hanlon.co.uk","created_at":"2026-06-29T11:09:02Z","created_by":"Steve Hanlon","updated_at":"2026-06-29T11:09:02Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"hc-5ix.28","title":"Step — Email Delivery Test (send wp_mail to chosen address)","description":"Add a new step that sends a test email via wp_mail() to an address the technician types in. Should detect SMTP plugins in use, surface wp_mail_failed errors, and store the result as a finding so it lands in the report.\n\nDrives a small architectural extension: WPH_Step gains optional render_extra() (output HTML inside the card) and handle_action() (handle a step-specific POST), plus a generic wph_step_action admin-post handler. Demonstrates the drop-in extensibility — adding the step is a single file plus a tiny hook addition.","notes":"Added in commit (next). New file includes/steps/115-email.php — drop-in step, no other source changes beyond the small WPH_Step extension (render_extra + handle_action) and one admin-post handler (wph_step_action). Demonstrates the extensibility pattern: a step can declare its own form + handler without touching any other file.\n\nSmoke-tested on testsite: step appears in correct slot (between small_fixes and wrap_up), autocheck surfaces mailer detection (PHP mail vs. 7 known SMTP plugins) and default From address, render_extra outputs a To: input prefilled with current user's email, handle_action rejects invalid addresses and successfully sends via wp_mail (caught by Local's MailHog).","status":"closed","priority":2,"issue_type":"task","owner":"steve@hanlon.co.uk","created_at":"2026-06-11T17:43:34Z","created_by":"Steve Hanlon","updated_at":"2026-06-11T17:45:47Z","closed_at":"2026-06-11T17:45:47Z","labels":["phase-1"],"dependencies":[{"issue_id":"hc-5ix.28","depends_on_id":"hc-5ix","type":"parent-child","created_at":"2026-06-11T18:43:33Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"hc-5ix.9","title":"Smoke test on testsite: full end-to-end run through the checklist","notes":"Built in phase-1 scaffold; passing lint + WP-eval end-to-end smoke test on testsite.","status":"closed","priority":2,"issue_type":"task","owner":"steve@hanlon.co.uk","created_at":"2026-06-11T14:36:15Z","created_by":"Steve Hanlon","updated_at":"2026-06-11T14:49:29Z","closed_at":"2026-06-11T14:49:29Z","labels":["phase-1"],"dependencies":[{"issue_id":"hc-5ix.9","depends_on_id":"hc-5ix","type":"parent-child","created_at":"2026-06-11T15:36:14Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"hc-5ix.8","title":"README + install instructions + screenshots placeholder","notes":"Built in phase-1 scaffold; passing lint + WP-eval end-to-end smoke test on testsite.","status":"closed","priority":2,"issue_type":"chore","owner":"steve@hanlon.co.uk","created_at":"2026-06-11T14:36:14Z","created_by":"Steve Hanlon","updated_at":"2026-06-11T14:49:29Z","closed_at":"2026-06-11T14:49:29Z","labels":["phase-1"],"dependencies":[{"issue_id":"hc-5ix.8","depends_on_id":"hc-5ix","type":"parent-child","created_at":"2026-06-11T15:36:14Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
|
||||
@@ -22,6 +22,8 @@ define('ATT_HC_OPT_SESSION', 'att_hc_session');
|
||||
|
||||
require_once ATT_HC_PLUGIN_DIR . 'includes/class-att-hc-step.php';
|
||||
require_once ATT_HC_PLUGIN_DIR . 'includes/class-att-hc-steps.php';
|
||||
require_once ATT_HC_PLUGIN_DIR . 'includes/class-att-hc-api-exception.php';
|
||||
require_once ATT_HC_PLUGIN_DIR . 'includes/class-att-hc-api.php';
|
||||
require_once ATT_HC_PLUGIN_DIR . 'includes/class-att-hc-session.php';
|
||||
require_once ATT_HC_PLUGIN_DIR . 'includes/recovery-bootstrap.php';
|
||||
require_once ATT_HC_PLUGIN_DIR . 'includes/recovery-installer.php';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
9
server/.gitignore
vendored
Normal file
9
server/.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
# Anchored to server/ root — the unanchored pattern was matching server/src/Config.php
|
||||
# on case-insensitive filesystems (macOS, Windows).
|
||||
/config.php
|
||||
/data/
|
||||
*.sqlite
|
||||
*.sqlite-journal
|
||||
*.sqlite-wal
|
||||
*.sqlite-shm
|
||||
|
||||
149
server/DEPLOY.md
Normal file
149
server/DEPLOY.md
Normal file
@@ -0,0 +1,149 @@
|
||||
# Deploying the ATT Healthcheck Server
|
||||
|
||||
Single-tenant PHP/SQLite app. Tiny — runs comfortably on the smallest VPS tier.
|
||||
|
||||
## Requirements
|
||||
|
||||
- PHP **8.1+** with `pdo`, `pdo_sqlite`, `json`, `mbstring` (all standard).
|
||||
- Apache + `mod_rewrite`, or nginx with a `try_files` fallback.
|
||||
- HTTPS terminating in front of PHP. Plain HTTP is technically accepted, but the plugin client will refuse to talk to non-`https://` endpoints.
|
||||
|
||||
## Layout on the server
|
||||
|
||||
Anywhere works — every path inside the app is resolved relative to its own files.
|
||||
Example using `/home/www/healthcheck`:
|
||||
|
||||
```
|
||||
/home/www/healthcheck/
|
||||
├── public/ ← docroot
|
||||
│ ├── .htaccess
|
||||
│ └── index.php
|
||||
├── src/
|
||||
├── migrations/
|
||||
├── data/ ← SQLite file lives here (writable by web user)
|
||||
├── config.php ← deployment-specific, NOT in git
|
||||
└── config.php.example
|
||||
```
|
||||
|
||||
> **Perm gotcha for `/home/...` locations:** home directories often default to
|
||||
> `750` or `700`, which blocks the web user from traversing into the app.
|
||||
> `sudo chmod 755 /home/www` once is usually enough. `/var/www` doesn't have
|
||||
> this problem (always world-readable by default).
|
||||
|
||||
## First-time install
|
||||
|
||||
```sh
|
||||
# As you, on the server:
|
||||
sudo mkdir -p /home/www/healthcheck
|
||||
sudo chown $USER:www-data /home/www/healthcheck
|
||||
sudo chmod 755 /home/www # see perm gotcha above
|
||||
|
||||
# From your dev box:
|
||||
rsync -avz --exclude='data/' --exclude='config.php' \
|
||||
~/dev/att-site-healthcheck/server/ \
|
||||
user@vps:/home/www/healthcheck/
|
||||
|
||||
# Back on the server:
|
||||
cd /home/www/healthcheck
|
||||
cp config.php.example config.php
|
||||
php -r 'echo bin2hex(random_bytes(32)), PHP_EOL;' # generate api_key
|
||||
$EDITOR config.php # paste it in
|
||||
|
||||
mkdir -p data
|
||||
sudo chown -R www-data:www-data data/ config.php
|
||||
sudo chmod 640 config.php
|
||||
sudo chmod 770 data/
|
||||
```
|
||||
|
||||
## Apache vhost (example)
|
||||
|
||||
```apache
|
||||
<VirtualHost *:443>
|
||||
ServerName healthcheck-history.example.com
|
||||
DocumentRoot /home/www/healthcheck/public
|
||||
|
||||
<Directory /home/www/healthcheck/public>
|
||||
AllowOverride All
|
||||
Require all granted
|
||||
</Directory>
|
||||
|
||||
# Pass Authorization header through to PHP — Apache strips it by default with FPM.
|
||||
SetEnvIf Authorization "(.*)" HTTP_AUTHORIZATION=$1
|
||||
|
||||
SSLEngine on
|
||||
SSLCertificateFile /etc/letsencrypt/live/healthcheck-history.example.com/fullchain.pem
|
||||
SSLCertificateKeyFile /etc/letsencrypt/live/healthcheck-history.example.com/privkey.pem
|
||||
</VirtualHost>
|
||||
```
|
||||
|
||||
## nginx (example)
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name healthcheck-history.example.com;
|
||||
root /home/www/healthcheck/public;
|
||||
index index.php;
|
||||
|
||||
location / {
|
||||
try_files $uri /index.php?$query_string;
|
||||
}
|
||||
|
||||
location ~ \.php$ {
|
||||
include fastcgi_params;
|
||||
fastcgi_pass unix:/run/php/php8.2-fpm.sock;
|
||||
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
||||
fastcgi_param HTTP_AUTHORIZATION $http_authorization;
|
||||
}
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/healthcheck-history.example.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/healthcheck-history.example.com/privkey.pem;
|
||||
}
|
||||
```
|
||||
|
||||
## TLS
|
||||
|
||||
Use Let's Encrypt + certbot. Example:
|
||||
|
||||
```sh
|
||||
sudo certbot --apache -d healthcheck-history.example.com
|
||||
# or for nginx:
|
||||
sudo certbot --nginx -d healthcheck-history.example.com
|
||||
```
|
||||
|
||||
## Configure the plugin to talk to it
|
||||
|
||||
In `wp-config.php` on each WordPress install:
|
||||
|
||||
```php
|
||||
define('ATT_HC_API_URL', 'https://healthcheck-history.example.com');
|
||||
define('ATT_HC_API_KEY', '<the long random string from config.php>');
|
||||
```
|
||||
|
||||
## Backups
|
||||
|
||||
The SQLite DB is a single file. Snapshot it on a cron:
|
||||
|
||||
```cron
|
||||
# Daily at 03:15, rotate 14 days. SQLite-safe online backup.
|
||||
15 3 * * * /usr/bin/sqlite3 /home/www/healthcheck/data/att_hc.sqlite ".backup '/var/backups/att-hc/att_hc-$(date +\%F).sqlite'" && find /var/backups/att-hc -name 'att_hc-*.sqlite' -mtime +14 -delete
|
||||
```
|
||||
|
||||
Pull those backups offsite with `rsync` / `rclone` to whatever you already use.
|
||||
|
||||
## Updating
|
||||
|
||||
Just rsync the source again — migrations run on first request after deploy.
|
||||
|
||||
```sh
|
||||
rsync -avz --delete --exclude='data/' --exclude='config.php' \
|
||||
~/dev/att-site-healthcheck/server/ \
|
||||
user@vps:/home/www/healthcheck/
|
||||
```
|
||||
|
||||
## Quick health check
|
||||
|
||||
```sh
|
||||
curl https://healthcheck-history.example.com/
|
||||
# → {"ok":true,"service":"att-site-healthcheck-server","version":"..."}
|
||||
```
|
||||
19
server/config.php.example
Normal file
19
server/config.php.example
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
// Copy to config.php on each deploy. Never commit config.php.
|
||||
//
|
||||
// Generate a fresh API key with:
|
||||
// php -r 'echo bin2hex(random_bytes(32)), PHP_EOL;'
|
||||
|
||||
return [
|
||||
// Shared secret. Plugin sends this as `Authorization: Bearer <key>`.
|
||||
'api_key' => 'REPLACE_WITH_A_LONG_RANDOM_STRING',
|
||||
|
||||
// PDO DSN. SQLite default — file path is resolved relative to server/.
|
||||
// For MySQL: 'mysql:host=localhost;dbname=att_hc;charset=utf8mb4'
|
||||
'db_dsn' => 'sqlite:' . __DIR__ . '/data/att_hc.sqlite',
|
||||
'db_user' => null,
|
||||
'db_pass' => null,
|
||||
|
||||
// Server version, surfaced on GET /. Bump on deploy.
|
||||
'version' => '0.1.0',
|
||||
];
|
||||
15
server/dev-router.php
Normal file
15
server/dev-router.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
// Router for `php -S 127.0.0.1:8765 server/dev-router.php`.
|
||||
// The built-in dev server otherwise 404/405s URLs with dots in path segments
|
||||
// (e.g. the dotted uniqid the plugin generates) because it thinks they're
|
||||
// static file requests. Apache/nginx in production handle this via .htaccess.
|
||||
|
||||
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
|
||||
$file = __DIR__ . '/public' . $path;
|
||||
|
||||
// Real file inside public/ — serve as-is.
|
||||
if ($path !== '/' && is_file($file)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
require __DIR__ . '/public/index.php';
|
||||
46
server/migrations/0001_initial.sql
Normal file
46
server/migrations/0001_initial.sql
Normal file
@@ -0,0 +1,46 @@
|
||||
CREATE TABLE IF NOT EXISTS healthchecks (
|
||||
id TEXT PRIMARY KEY,
|
||||
site_key TEXT NOT NULL,
|
||||
started_at INTEGER NOT NULL,
|
||||
finished_at INTEGER NULL,
|
||||
technician_id INTEGER NULL,
|
||||
reporting_url TEXT NOT NULL,
|
||||
wp_version TEXT NULL,
|
||||
php_version TEXT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
UNIQUE (site_key, started_at)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS healthchecks_site_started_idx
|
||||
ON healthchecks (site_key, started_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS healthchecks_site_finished_idx
|
||||
ON healthchecks (site_key, finished_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS step_updates (
|
||||
healthcheck_id TEXT NOT NULL REFERENCES healthchecks(id) ON DELETE CASCADE,
|
||||
step_id TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
notes TEXT NOT NULL DEFAULT '',
|
||||
autocheck_json TEXT NULL,
|
||||
reporting_url TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (healthcheck_id, step_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS step_updates_step_updated_idx
|
||||
ON step_updates (step_id, updated_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS request_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ts INTEGER NOT NULL,
|
||||
method TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
status INTEGER NOT NULL,
|
||||
ip TEXT NULL,
|
||||
bytes_in INTEGER NULL,
|
||||
bytes_out INTEGER NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS request_log_ts_idx ON request_log (ts DESC);
|
||||
6
server/public/.htaccess
Normal file
6
server/public/.htaccess
Normal file
@@ -0,0 +1,6 @@
|
||||
<IfModule mod_rewrite.c>
|
||||
RewriteEngine On
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteRule ^ index.php [QSA,L]
|
||||
</IfModule>
|
||||
22
server/public/index.php
Normal file
22
server/public/index.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Front controller for the ATT Site Healthcheck history server.
|
||||
*
|
||||
* Routes every request through here. .htaccess (Apache) or the nginx config
|
||||
* should rewrite all non-existing paths to /index.php. For dev:
|
||||
* php -S localhost:8000 -t server/public
|
||||
*/
|
||||
|
||||
require __DIR__ . '/../src/bootstrap.php';
|
||||
|
||||
use AttHc\Server\Router;
|
||||
|
||||
$router = require __DIR__ . '/../src/routes.php';
|
||||
assert($router instanceof Router);
|
||||
|
||||
$router->dispatch(
|
||||
$_SERVER['REQUEST_METHOD'] ?? 'GET',
|
||||
parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?? '/'
|
||||
);
|
||||
38
server/src/Auth.php
Normal file
38
server/src/Auth.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace AttHc\Server;
|
||||
|
||||
final class Auth {
|
||||
/** Halts with 401 if Authorization header doesn't match the configured key. */
|
||||
public static function require(): void {
|
||||
$expected = (string) Config::get('api_key', '');
|
||||
if ($expected === '' || $expected === 'REPLACE_WITH_A_LONG_RANDOM_STRING') {
|
||||
Http::error(500, 'no_api_key', 'server has no api_key configured');
|
||||
exit;
|
||||
}
|
||||
$header = self::headerValue();
|
||||
if ($header === null || !str_starts_with($header, 'Bearer ')) {
|
||||
Http::error(401, 'unauthorized', 'missing bearer token');
|
||||
exit;
|
||||
}
|
||||
$given = substr($header, 7);
|
||||
if (!hash_equals($expected, $given)) {
|
||||
Http::error(401, 'unauthorized', 'invalid credentials');
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
private static function headerValue(): ?string {
|
||||
if (!empty($_SERVER['HTTP_AUTHORIZATION'])) return (string) $_SERVER['HTTP_AUTHORIZATION'];
|
||||
// Apache strips Authorization unless rewrite copies it; check both forms.
|
||||
if (!empty($_SERVER['REDIRECT_HTTP_AUTHORIZATION'])) return (string) $_SERVER['REDIRECT_HTTP_AUTHORIZATION'];
|
||||
if (function_exists('getallheaders')) {
|
||||
$headers = getallheaders();
|
||||
foreach ($headers as $name => $value) {
|
||||
if (strcasecmp($name, 'Authorization') === 0) return (string) $value;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
16
server/src/Config.php
Normal file
16
server/src/Config.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace AttHc\Server;
|
||||
|
||||
final class Config {
|
||||
private static array $data = [];
|
||||
|
||||
public static function load(array $data): void {
|
||||
self::$data = $data;
|
||||
}
|
||||
|
||||
public static function get(string $key, mixed $default = null): mixed {
|
||||
return self::$data[$key] ?? $default;
|
||||
}
|
||||
}
|
||||
83
server/src/Controllers/Healthchecks.php
Normal file
83
server/src/Controllers/Healthchecks.php
Normal file
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace AttHc\Server\Controllers;
|
||||
|
||||
use AttHc\Server\Http;
|
||||
use AttHc\Server\Store;
|
||||
use AttHc\Server\Validate;
|
||||
|
||||
final class Healthchecks {
|
||||
public static function create(): void {
|
||||
$body = Http::readJsonBody();
|
||||
|
||||
$row = [
|
||||
'id' => Validate::requireString($body, 'id', 64),
|
||||
'site_key' => Validate::requireString($body, 'site_key', 255),
|
||||
'started_at' => Validate::requireInt($body, 'started_at'),
|
||||
'reporting_url' => Validate::requireString($body, 'reporting_url', 512),
|
||||
'technician_id' => Validate::optionalInt($body, 'technician_id'),
|
||||
'wp_version' => Validate::optionalString($body, 'wp_version', 32),
|
||||
'php_version' => Validate::optionalString($body, 'php_version', 32),
|
||||
];
|
||||
|
||||
try {
|
||||
Store::insertHealthcheck($row);
|
||||
} catch (\PDOException $e) {
|
||||
if (self::isUniqueViolation($e)) {
|
||||
Http::error(409, 'duplicate', 'a healthcheck with this id or (site_key, started_at) already exists');
|
||||
return;
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
|
||||
Http::json(201, ['ok' => true, 'id' => $row['id']]);
|
||||
}
|
||||
|
||||
public static function update(array $params): void {
|
||||
$id = $params['id'];
|
||||
if (Store::getHealthcheck($id) === null) {
|
||||
Http::error(404, 'not_found', 'no healthcheck with that id');
|
||||
return;
|
||||
}
|
||||
$body = Http::readJsonBody();
|
||||
$finishedAt = Validate::optionalInt($body, 'finished_at');
|
||||
Store::updateHealthcheck($id, $finishedAt);
|
||||
Http::json(200, ['ok' => true]);
|
||||
}
|
||||
|
||||
public static function get(array $params): void {
|
||||
$hc = Store::getHealthcheck($params['id']);
|
||||
if ($hc === null) {
|
||||
Http::error(404, 'not_found', 'no healthcheck with that id');
|
||||
return;
|
||||
}
|
||||
$hc['steps'] = Store::stepsFor($hc['id']);
|
||||
Http::json(200, $hc);
|
||||
}
|
||||
|
||||
public static function list(): void {
|
||||
$siteKey = $_GET['site_key'] ?? '';
|
||||
if (!is_string($siteKey) || $siteKey === '') {
|
||||
Http::error(422, 'invalid', 'site_key query param is required');
|
||||
return;
|
||||
}
|
||||
$limit = max(1, min(200, (int) ($_GET['limit'] ?? 50)));
|
||||
$rows = Store::listHealthchecksForSite($siteKey, $limit);
|
||||
|
||||
$includeSteps = (($_GET['include'] ?? '') === 'steps');
|
||||
if ($includeSteps) {
|
||||
foreach ($rows as &$r) $r['steps'] = Store::stepsFor($r['id']);
|
||||
}
|
||||
|
||||
Http::json(200, ['healthchecks' => $rows]);
|
||||
}
|
||||
|
||||
private static function isUniqueViolation(\PDOException $e): bool {
|
||||
// SQLite: 'UNIQUE constraint failed' / SQLSTATE 23000. MySQL: 1062 / 23000.
|
||||
$msg = $e->getMessage();
|
||||
return str_contains($msg, 'UNIQUE constraint failed')
|
||||
|| str_contains($msg, 'Duplicate entry')
|
||||
|| ($e->getCode() === '23000');
|
||||
}
|
||||
}
|
||||
14
server/src/Controllers/Sites.php
Normal file
14
server/src/Controllers/Sites.php
Normal file
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace AttHc\Server\Controllers;
|
||||
|
||||
use AttHc\Server\Http;
|
||||
use AttHc\Server\Store;
|
||||
|
||||
final class Sites {
|
||||
public static function recent(): void {
|
||||
$limit = max(1, min(100, (int) ($_GET['limit'] ?? 20)));
|
||||
Http::json(200, ['sites' => Store::recentSiteKeys($limit)]);
|
||||
}
|
||||
}
|
||||
54
server/src/Controllers/Steps.php
Normal file
54
server/src/Controllers/Steps.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace AttHc\Server\Controllers;
|
||||
|
||||
use AttHc\Server\Http;
|
||||
use AttHc\Server\Store;
|
||||
use AttHc\Server\Validate;
|
||||
|
||||
final class Steps {
|
||||
public static function upsert(array $params): void {
|
||||
$healthcheckId = $params['id'];
|
||||
$stepId = $params['step_id'];
|
||||
|
||||
if (Store::getHealthcheck($healthcheckId) === null) {
|
||||
Http::error(404, 'not_found', 'no healthcheck with that id');
|
||||
return;
|
||||
}
|
||||
|
||||
$body = Http::readJsonBody();
|
||||
$row = [
|
||||
'status' => Validate::status(Validate::requireString($body, 'status', 32)),
|
||||
'notes' => $body['notes'] ?? '',
|
||||
'reporting_url' => Validate::requireString($body, 'reporting_url', 512),
|
||||
'autocheck' => isset($body['autocheck']) && is_array($body['autocheck']) ? $body['autocheck'] : null,
|
||||
];
|
||||
if (!is_string($row['notes'])) Validate::fail('notes must be a string');
|
||||
|
||||
Store::upsertStep($healthcheckId, $stepId, $row);
|
||||
Http::json(200, ['ok' => true]);
|
||||
}
|
||||
|
||||
public static function history(array $params): void {
|
||||
$stepId = $params['step_id'];
|
||||
$siteKey = $_GET['site_key'] ?? '';
|
||||
if (!is_string($siteKey) || $siteKey === '') {
|
||||
Http::error(422, 'invalid', 'site_key query param is required');
|
||||
return;
|
||||
}
|
||||
$limit = max(1, min(50, (int) ($_GET['limit'] ?? 5)));
|
||||
$excludeId = isset($_GET['exclude_id']) && is_string($_GET['exclude_id']) ? $_GET['exclude_id'] : null;
|
||||
Http::json(200, ['history' => Store::stepHistory($stepId, $siteKey, $limit, $excludeId)]);
|
||||
}
|
||||
|
||||
public static function counts(): void {
|
||||
$siteKey = $_GET['site_key'] ?? '';
|
||||
if (!is_string($siteKey) || $siteKey === '') {
|
||||
Http::error(422, 'invalid', 'site_key query param is required');
|
||||
return;
|
||||
}
|
||||
$excludeId = isset($_GET['exclude_id']) && is_string($_GET['exclude_id']) ? $_GET['exclude_id'] : null;
|
||||
Http::json(200, ['counts' => Store::stepCountsForSite($siteKey, $excludeId)]);
|
||||
}
|
||||
}
|
||||
47
server/src/Db.php
Normal file
47
server/src/Db.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace AttHc\Server;
|
||||
|
||||
use PDO;
|
||||
|
||||
final class Db {
|
||||
private static ?PDO $pdo = null;
|
||||
|
||||
public static function pdo(): PDO {
|
||||
if (self::$pdo !== null) return self::$pdo;
|
||||
|
||||
$dsn = (string) Config::get('db_dsn', '');
|
||||
$user = Config::get('db_user');
|
||||
$pass = Config::get('db_pass');
|
||||
|
||||
if ($dsn === '') {
|
||||
Http::error(500, 'no_db_dsn', 'server has no db_dsn configured');
|
||||
exit;
|
||||
}
|
||||
|
||||
if (str_starts_with($dsn, 'sqlite:')) {
|
||||
$path = substr($dsn, 7);
|
||||
if ($path !== '' && $path !== ':memory:') {
|
||||
$dir = dirname($path);
|
||||
if (!is_dir($dir)) @mkdir($dir, 0775, true);
|
||||
}
|
||||
}
|
||||
|
||||
$pdo = new PDO($dsn, $user, $pass, [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
PDO::ATTR_EMULATE_PREPARES => false,
|
||||
]);
|
||||
|
||||
if (str_starts_with($dsn, 'sqlite:')) {
|
||||
$pdo->exec('PRAGMA foreign_keys = ON');
|
||||
$pdo->exec('PRAGMA journal_mode = WAL');
|
||||
$pdo->exec('PRAGMA synchronous = NORMAL');
|
||||
}
|
||||
|
||||
self::$pdo = $pdo;
|
||||
Migrations::ensureRan($pdo);
|
||||
return $pdo;
|
||||
}
|
||||
}
|
||||
28
server/src/Http.php
Normal file
28
server/src/Http.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace AttHc\Server;
|
||||
|
||||
final class Http {
|
||||
public static function json(int $status, array $body): void {
|
||||
http_response_code($status);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode($body, JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
|
||||
public static function error(int $status, string $code, string $message): void {
|
||||
self::json($status, ['error' => $message, 'code' => $code]);
|
||||
}
|
||||
|
||||
public static function readJsonBody(): array {
|
||||
$raw = file_get_contents('php://input') ?: '';
|
||||
if ($raw === '') return [];
|
||||
try {
|
||||
$decoded = json_decode($raw, true, 32, JSON_THROW_ON_ERROR);
|
||||
} catch (\JsonException $e) {
|
||||
self::error(400, 'bad_json', 'request body is not valid JSON');
|
||||
exit;
|
||||
}
|
||||
return is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
}
|
||||
51
server/src/Migrations.php
Normal file
51
server/src/Migrations.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace AttHc\Server;
|
||||
|
||||
use PDO;
|
||||
|
||||
final class Migrations {
|
||||
/**
|
||||
* Runs any *.sql files in server/migrations/ that haven't been applied yet.
|
||||
* Idempotent: tracks applied filenames in the `migrations` table.
|
||||
*/
|
||||
public static function ensureRan(PDO $pdo): void {
|
||||
$pdo->exec('CREATE TABLE IF NOT EXISTS migrations (
|
||||
filename TEXT PRIMARY KEY,
|
||||
applied_at INTEGER NOT NULL
|
||||
)');
|
||||
|
||||
$dir = __DIR__ . '/../migrations';
|
||||
if (!is_dir($dir)) return;
|
||||
|
||||
$files = glob($dir . '/*.sql') ?: [];
|
||||
sort($files);
|
||||
|
||||
$applied = [];
|
||||
foreach ($pdo->query('SELECT filename FROM migrations')->fetchAll(PDO::FETCH_COLUMN) as $f) {
|
||||
$applied[$f] = true;
|
||||
}
|
||||
|
||||
foreach ($files as $path) {
|
||||
$name = basename($path);
|
||||
if (isset($applied[$name])) continue;
|
||||
|
||||
$sql = file_get_contents($path);
|
||||
if ($sql === false) {
|
||||
throw new \RuntimeException('failed to read migration: ' . $name);
|
||||
}
|
||||
|
||||
$pdo->beginTransaction();
|
||||
try {
|
||||
$pdo->exec($sql);
|
||||
$stmt = $pdo->prepare('INSERT INTO migrations (filename, applied_at) VALUES (?, ?)');
|
||||
$stmt->execute([$name, time()]);
|
||||
$pdo->commit();
|
||||
} catch (\Throwable $e) {
|
||||
$pdo->rollBack();
|
||||
throw new \RuntimeException('migration failed: ' . $name . ' — ' . $e->getMessage(), 0, $e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
40
server/src/Router.php
Normal file
40
server/src/Router.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace AttHc\Server;
|
||||
|
||||
final class Router {
|
||||
/** @var array<int, array{0:string,1:string,2:callable,3:bool}> [method, pattern, handler, requires_auth] */
|
||||
private array $routes = [];
|
||||
|
||||
public function add(string $method, string $pattern, callable $handler, bool $requiresAuth = true): void {
|
||||
$this->routes[] = [strtoupper($method), $pattern, $handler, $requiresAuth];
|
||||
}
|
||||
|
||||
public function dispatch(string $method, string $path): void {
|
||||
$method = strtoupper($method);
|
||||
foreach ($this->routes as [$m, $pattern, $handler, $requiresAuth]) {
|
||||
if ($m !== $method) continue;
|
||||
$params = $this->match($pattern, $path);
|
||||
if ($params === null) continue;
|
||||
if ($requiresAuth) Auth::require();
|
||||
$handler($params);
|
||||
return;
|
||||
}
|
||||
Http::error(404, 'not_found', 'no route matches ' . $method . ' ' . $path);
|
||||
}
|
||||
|
||||
/** Returns captured params on match, null otherwise. Patterns use {name} placeholders. */
|
||||
private function match(string $pattern, string $path): ?array {
|
||||
$regex = preg_replace_callback('#\{([a-zA-Z_][a-zA-Z0-9_]*)\}#', static function ($m): string {
|
||||
return '(?P<' . $m[1] . '>[^/]+)';
|
||||
}, $pattern);
|
||||
$regex = '#^' . $regex . '$#';
|
||||
if (!preg_match($regex, $path, $matches)) return null;
|
||||
$params = [];
|
||||
foreach ($matches as $k => $v) {
|
||||
if (is_string($k)) $params[$k] = $v;
|
||||
}
|
||||
return $params;
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
46
server/src/Validate.php
Normal file
46
server/src/Validate.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace AttHc\Server;
|
||||
|
||||
final class Validate {
|
||||
/** Hard exit 422 with a message. */
|
||||
public static function fail(string $message): never {
|
||||
Http::error(422, 'invalid', $message);
|
||||
exit;
|
||||
}
|
||||
|
||||
public static function requireString(array $body, string $key, int $maxLen = 1024): string {
|
||||
if (!isset($body[$key]) || !is_string($body[$key]) || $body[$key] === '') {
|
||||
self::fail("missing or empty string field: {$key}");
|
||||
}
|
||||
if (strlen($body[$key]) > $maxLen) self::fail("{$key} exceeds {$maxLen} chars");
|
||||
return $body[$key];
|
||||
}
|
||||
|
||||
public static function optionalString(array $body, string $key, int $maxLen = 1024): ?string {
|
||||
if (!isset($body[$key])) return null;
|
||||
if (!is_string($body[$key])) self::fail("{$key} must be a string");
|
||||
if (strlen($body[$key]) > $maxLen) self::fail("{$key} exceeds {$maxLen} chars");
|
||||
return $body[$key];
|
||||
}
|
||||
|
||||
public static function requireInt(array $body, string $key): int {
|
||||
if (!isset($body[$key]) || !is_int($body[$key])) {
|
||||
self::fail("missing or non-int field: {$key}");
|
||||
}
|
||||
return $body[$key];
|
||||
}
|
||||
|
||||
public static function optionalInt(array $body, string $key): ?int {
|
||||
if (!isset($body[$key]) || $body[$key] === null) return null;
|
||||
if (!is_int($body[$key])) self::fail("{$key} must be an integer");
|
||||
return $body[$key];
|
||||
}
|
||||
|
||||
public static function status(string $status): string {
|
||||
$valid = ['not_started', 'done', 'skipped', 'blocked', 'n_a'];
|
||||
if (!in_array($status, $valid, true)) self::fail('status must be one of ' . implode(', ', $valid));
|
||||
return $status;
|
||||
}
|
||||
}
|
||||
40
server/src/bootstrap.php
Normal file
40
server/src/bootstrap.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace AttHc\Server;
|
||||
|
||||
// Hand-rolled PSR-4-ish autoloader. No composer dependency.
|
||||
spl_autoload_register(static function (string $class): void {
|
||||
$prefix = 'AttHc\\Server\\';
|
||||
if (strncmp($class, $prefix, strlen($prefix)) !== 0) return;
|
||||
$relative = substr($class, strlen($prefix));
|
||||
$path = __DIR__ . '/' . str_replace('\\', '/', $relative) . '.php';
|
||||
if (is_file($path)) require $path;
|
||||
});
|
||||
|
||||
error_reporting(E_ALL);
|
||||
ini_set('display_errors', '0');
|
||||
ini_set('log_errors', '1');
|
||||
|
||||
set_error_handler(static function (int $severity, string $message, string $file, int $line): bool {
|
||||
if (!(error_reporting() & $severity)) return false;
|
||||
throw new \ErrorException($message, 0, $severity, $file, $line);
|
||||
});
|
||||
|
||||
set_exception_handler(static function (\Throwable $e): void {
|
||||
error_log('[att-hc-server] ' . $e::class . ': ' . $e->getMessage() . ' @ ' . $e->getFile() . ':' . $e->getLine());
|
||||
if (!headers_sent()) {
|
||||
http_response_code(500);
|
||||
header('Content-Type: application/json');
|
||||
}
|
||||
echo json_encode(['error' => 'internal server error', 'code' => 'internal']);
|
||||
});
|
||||
|
||||
$configPath = __DIR__ . '/../config.php';
|
||||
if (!is_file($configPath)) {
|
||||
http_response_code(500);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['error' => 'server not configured: missing config.php', 'code' => 'no_config']);
|
||||
exit;
|
||||
}
|
||||
Config::load(require $configPath);
|
||||
31
server/src/routes.php
Normal file
31
server/src/routes.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
use AttHc\Server\Config;
|
||||
use AttHc\Server\Controllers\Healthchecks;
|
||||
use AttHc\Server\Controllers\Sites;
|
||||
use AttHc\Server\Controllers\Steps;
|
||||
use AttHc\Server\Http;
|
||||
use AttHc\Server\Router;
|
||||
|
||||
$router = new Router();
|
||||
|
||||
// Health check / version. Public, no auth — used by the plugin to verify reachability before submitting.
|
||||
$router->add('GET', '/', static function (): void {
|
||||
Http::json(200, [
|
||||
'ok' => true,
|
||||
'service' => 'att-site-healthcheck-server',
|
||||
'version' => (string) Config::get('version', '0.0.0'),
|
||||
]);
|
||||
}, requiresAuth: false);
|
||||
|
||||
$router->add('POST', '/healthchecks', [Healthchecks::class, 'create']);
|
||||
$router->add('GET', '/healthchecks', [Healthchecks::class, 'list']);
|
||||
$router->add('GET', '/healthchecks/{id}', [Healthchecks::class, 'get']);
|
||||
$router->add('PUT', '/healthchecks/{id}', [Healthchecks::class, 'update']);
|
||||
$router->add('PUT', '/healthchecks/{id}/steps/{step_id}', [Steps::class, 'upsert']);
|
||||
$router->add('GET', '/healthchecks/steps/{step_id}', [Steps::class, 'history']);
|
||||
$router->add('GET', '/step-counts', [Steps::class, 'counts']);
|
||||
$router->add('GET', '/sites', [Sites::class, 'recent']);
|
||||
|
||||
return $router;
|
||||
Reference in New Issue
Block a user