Compare commits

...

3 Commits

Author SHA1 Message Date
5be5ad2398 Release 0.1.7 — sidebar step dots track status live (hc-l0j)
After an AJAX status save, repaint the matching sidebar step-list dot instead
of leaving it stale until the next page load. Recolouring the dot's class is
enough for the link text too: the existing `li:has(.dot-done) a` rule turns it
green when done and back to the default blue otherwise.

- Sidebar dots gained a data-step attribute so the save script can find them.
- applyStatus() now also sets the sidebar dot class alongside the header badge
  and escalation banner it already updated.

Plugin-only. Bumps header, ATT_HC_VERSION and updates.json.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-27 08:42:16 +01:00
bf226e4174 Release 0.1.6 — fix AJAX step-save listeners never attaching (hc-5yy)
The 0.1.5 auto-save never fired: the inline <script> is emitted before the
step cards in the markup, so document.querySelectorAll('form.att-hc-step-save')
ran at parse time against a DOM with no forms yet and attached no listeners —
changing status or blurring notes produced no request at all.

The pre-existing step-history loader had the identical latent defect (its
"Previous notes" expander was also dead on live for the same reason).

Fix: both inline scripts now defer their querySelectorAll wiring to
DOMContentLoaded, so it runs after the cards are parsed. No reordering of the
markup, so the <style> blocks stay put and there's no flash of unstyled UI.

Bumps header, ATT_HC_VERSION and updates.json so PUC offers it to the sites
already on the broken 0.1.5. Plugin-only; server untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-27 08:37:07 +01:00
a3d2a7807f Release 0.1.5 — AJAX auto-save on the reporting page (hc-66c)
The step cards saved via a full-page POST, and the reload broke the
technician's flow. Save in place instead:

- Status saves on change; notes save on blur (only when actually edited).
- Inline per-card feedback (Saving… / Saved ✓ / error), no reload.
- On a status change the header badge updates live and the escalation
  banner shows/hides itself, matching a full server render.

Progressive enhancement: a new wp_ajax_att_hc_save_step_ajax handler reuses
the same capability + nonce + step validation + write-through as the form
handler, which is kept as the no-JS fallback. Mirrors the existing
step-history AJAX loader.

Bumps the plugin header, ATT_HC_VERSION and updates.json so PUC offers this
to installed sites. Server-side unaffected — this is plugin-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-27 08:27:34 +01:00
4 changed files with 185 additions and 11 deletions

View File

@@ -1,3 +1,4 @@
{"_type":"issue","id":"hc-5yy","title":"AJAX step-save listeners never attach (script runs before cards render)","description":"0.1.5 shipped the AJAX auto-save, but the inline script is emitted before the step-card markup, so document.querySelectorAll('form.att-hc-step-save') matches nothing and no change/blur listeners attach — no network request fires. The pre-existing step-history loader shared the same defect. Fixed by deferring both scripts' wiring to DOMContentLoaded. Released in 0.1.6.","status":"closed","priority":1,"issue_type":"bug","owner":"steve@hanlon.co.uk","created_at":"2026-07-27T07:36:54Z","created_by":"Steve Hanlon","updated_at":"2026-07-27T07:37:07Z","closed_at":"2026-07-27T07:37:07Z","close_reason":"Fixed in 0.1.6 — both inline scripts now defer listener wiring to DOMContentLoaded.","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"hc-4m5","title":"Plugin: allow ATT_HC_API_URL / ATT_HC_API_KEY via settings screen (not just wp-config)","description":"Some managed hosts make editing wp-config.php impractical or explicitly forbid it (WPE, some resellers, some clients' own operations teams). Add a settings-screen alternative to the existing constants.\n\nDesign (mirror the pattern already used for the Gitea recovery config on the same settings page):\n\n- Two new WP options: att_hc_api_url and att_hc_api_key (autoload=false on the key).\n- ATT_HC_Api::url() and ATT_HC_Api::key() accessors: return the constant if defined + non-empty, else the option. All existing consumers switch to these accessors instead of the constants directly.\n- config_error() message updates to say 'add to wp-config.php OR set via Tools -\u003e Site Healthcheck -\u003e Settings'.\n- Settings page gets a new 'Central history server' card above the existing Gitea card, with:\n - URL input (type=url) — locked with 'Set via ATT_HC_API_URL constant' description when constant is defined\n - Key input (type=password, autocomplete=new-password) — same lock behaviour\n- Save handler extends the existing att_hc_save_settings to persist the two options.\n- No migration needed — sites using constants keep working untouched.\n\nSecurity note to include in the description on the settings page: the option-stored key is visible to any WP admin (options.php, DB) — the constant path is stronger. Recommend constant when possible.\n\nUser asked for this because some sites can't edit wp-config.php.","status":"closed","priority":1,"issue_type":"feature","assignee":"Steve Hanlon","owner":"steve@hanlon.co.uk","created_at":"2026-07-07T13:51:13Z","created_by":"Steve Hanlon","updated_at":"2026-07-07T13:54:15Z","started_at":"2026-07-07T13:51:24Z","closed_at":"2026-07-07T13:54:15Z","close_reason":"Added a 'Central history server' card at the top of the settings page (Tools → Site Healthcheck → Settings) with URL + API key fields. Storage: att_hc_api_url and att_hc_api_key options (autoload=false on the key). Resolver: ATT_HC_Api::url() / ATT_HC_Api::key() return the constant when defined and non-empty, else the option, else ''. url_from_constant() / key_from_constant() drive the per-field lock on the settings page. All existing consumers (request(), config_error(), is_configured(), the config-error notice) switched to the accessors. Separate action + nonce (att_hc_save_api_settings) so it doesn't tangle with the existing gitea save. Handler ignores submitted values for fields locked by a constant — belt-and-braces with the disabled input. 18 assertions verified: no-config / options-only / http-blocked / loopback-http-ok / constant-wins-over-option. Both PHP 8.3 and PHP 7.4 parse cleanly.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"hc-4m5","title":"Plugin: allow ATT_HC_API_URL / ATT_HC_API_KEY via settings screen (not just wp-config)","description":"Some managed hosts make editing wp-config.php impractical or explicitly forbid it (WPE, some resellers, some clients' own operations teams). Add a settings-screen alternative to the existing constants.\n\nDesign (mirror the pattern already used for the Gitea recovery config on the same settings page):\n\n- Two new WP options: att_hc_api_url and att_hc_api_key (autoload=false on the key).\n- ATT_HC_Api::url() and ATT_HC_Api::key() accessors: return the constant if defined + non-empty, else the option. All existing consumers switch to these accessors instead of the constants directly.\n- config_error() message updates to say 'add to wp-config.php OR set via Tools -\u003e Site Healthcheck -\u003e Settings'.\n- Settings page gets a new 'Central history server' card above the existing Gitea card, with:\n - URL input (type=url) — locked with 'Set via ATT_HC_API_URL constant' description when constant is defined\n - Key input (type=password, autocomplete=new-password) — same lock behaviour\n- Save handler extends the existing att_hc_save_settings to persist the two options.\n- No migration needed — sites using constants keep working untouched.\n\nSecurity note to include in the description on the settings page: the option-stored key is visible to any WP admin (options.php, DB) — the constant path is stronger. Recommend constant when possible.\n\nUser asked for this because some sites can't edit wp-config.php.","status":"closed","priority":1,"issue_type":"feature","assignee":"Steve Hanlon","owner":"steve@hanlon.co.uk","created_at":"2026-07-07T13:51:13Z","created_by":"Steve Hanlon","updated_at":"2026-07-07T13:54:15Z","started_at":"2026-07-07T13:51:24Z","closed_at":"2026-07-07T13:54:15Z","close_reason":"Added a 'Central history server' card at the top of the settings page (Tools → Site Healthcheck → Settings) with URL + API key fields. Storage: att_hc_api_url and att_hc_api_key options (autoload=false on the key). Resolver: ATT_HC_Api::url() / ATT_HC_Api::key() return the constant when defined and non-empty, else the option, else ''. url_from_constant() / key_from_constant() drive the per-field lock on the settings page. All existing consumers (request(), config_error(), is_configured(), the config-error notice) switched to the accessors. Separate action + nonce (att_hc_save_api_settings) so it doesn't tangle with the existing gitea save. Handler ignores submitted values for fields locked by a constant — belt-and-braces with the disabled input. 18 assertions verified: no-config / options-only / http-blocked / loopback-http-ok / constant-wins-over-option. Both PHP 8.3 and PHP 7.4 parse cleanly.","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"hc-eff","title":"Plugin fatals on PHP 7.4 — PHP 8 syntax in central-server work","description":"The central history server work (epic hc-0p1) introduced four PHP 8.0+ syntax sites in the plugin codebase. PHP 7.4 fails to parse the affected files, so on a host running PHP 7.4 (eg one user reported Ubuntu 7.4.3-4ubuntu2.29) the plugin throws a fatal on load.\n\nSites:\n- includes/class-att-hc-session.php:244 — str_starts_with()\n- includes/class-att-hc-api.php:52 — named argument requires_auth: false\n- includes/class-att-hc-session.php:126 — named arguments include_steps:, limit:\n- includes/admin-page.php:165 — named arguments include_steps:, limit:\n\nPlugin main file header still says 'Requires PHP: 7.4', so the expectation is PHP 7.4 support. Backport: replace str_starts_with with substr === comparison; convert named args to positional (signatures accept positional already).\n\nAffects every plugin user on PHP \u003c 8.0.","status":"closed","priority":1,"issue_type":"bug","assignee":"Steve Hanlon","owner":"steve@hanlon.co.uk","created_at":"2026-06-30T08:27:51Z","created_by":"Steve Hanlon","updated_at":"2026-06-30T08:29:22Z","started_at":"2026-06-30T08:27:57Z","closed_at":"2026-06-30T08:29:22Z","close_reason":"Backported four PHP 8.0+ syntax sites to PHP 7.4-compatible equivalents. str_starts_with replaced with substr(...) === literal. Three named-argument call sites converted to positional — signatures already accept positional with the same values, no semantic change. Verified by linting all 16 plugin files (att-site-healthcheck.php + includes/) against PHP 7.4.33 on sjh: no syntax errors, no residual PHP 8+ patterns. Plugin's 'Requires PHP: 7.4' header is now actually true.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"hc-eff","title":"Plugin fatals on PHP 7.4 — PHP 8 syntax in central-server work","description":"The central history server work (epic hc-0p1) introduced four PHP 8.0+ syntax sites in the plugin codebase. PHP 7.4 fails to parse the affected files, so on a host running PHP 7.4 (eg one user reported Ubuntu 7.4.3-4ubuntu2.29) the plugin throws a fatal on load.\n\nSites:\n- includes/class-att-hc-session.php:244 — str_starts_with()\n- includes/class-att-hc-api.php:52 — named argument requires_auth: false\n- includes/class-att-hc-session.php:126 — named arguments include_steps:, limit:\n- includes/admin-page.php:165 — named arguments include_steps:, limit:\n\nPlugin main file header still says 'Requires PHP: 7.4', so the expectation is PHP 7.4 support. Backport: replace str_starts_with with substr === comparison; convert named args to positional (signatures accept positional already).\n\nAffects every plugin user on PHP \u003c 8.0.","status":"closed","priority":1,"issue_type":"bug","assignee":"Steve Hanlon","owner":"steve@hanlon.co.uk","created_at":"2026-06-30T08:27:51Z","created_by":"Steve Hanlon","updated_at":"2026-06-30T08:29:22Z","started_at":"2026-06-30T08:27:57Z","closed_at":"2026-06-30T08:29:22Z","close_reason":"Backported four PHP 8.0+ syntax sites to PHP 7.4-compatible equivalents. str_starts_with replaced with substr(...) === literal. Three named-argument call sites converted to positional — signatures already accept positional with the same values, no semantic change. Verified by linting all 16 plugin files (att-site-healthcheck.php + includes/) against PHP 7.4.33 on sjh: no syntax errors, no residual PHP 8+ patterns. Plugin's 'Requires PHP: 7.4' header is now actually true.","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_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-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}
@@ -15,6 +16,7 @@
{"_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.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.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-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-66c","title":"AJAX step saving on the healthcheck reporting page","description":"The step cards currently save via a full-page POST, which reloads the page and breaks the technician's flow. Switch to AJAX: notes save on blur, status saves on change, with inline save feedback and no reload.","status":"closed","priority":2,"issue_type":"feature","owner":"steve@hanlon.co.uk","created_at":"2026-07-27T07:21:00Z","created_by":"Steve Hanlon","updated_at":"2026-07-27T07:27:23Z","closed_at":"2026-07-27T07:27:23Z","close_reason":"Implemented in 0.1.5: AJAX auto-save on the step cards — status saves on change, notes on blur, inline feedback, no reload. Progressive enhancement; no-JS form POST retained as fallback.","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"hc-fbd","title":"Next healthcheck due date per site","description":"Technician can set 'next healthcheck due' while working a session; stored on the server against the healthcheck row, surfaced in the dashboard site list and session views so it is visible when planning work.","acceptance_criteria":"Plugin UI sets/clears a date on the active session; server persists + validates YYYY-MM-DD; dashboard shows next-due per site (derived from latest session) and per session; report includes it; tests pass.","status":"closed","priority":2,"issue_type":"feature","owner":"steve@hanlon.co.uk","created_at":"2026-07-23T10:50:25Z","created_by":"Steve Hanlon","updated_at":"2026-07-23T11:04:08Z","closed_at":"2026-07-23T11:04:08Z","close_reason":"Implemented and released in 0.1.4: server column + validation + dashboard views, plugin date control, 42-test suite. Commits 6313857, 55990be.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"hc-fbd","title":"Next healthcheck due date per site","description":"Technician can set 'next healthcheck due' while working a session; stored on the server against the healthcheck row, surfaced in the dashboard site list and session views so it is visible when planning work.","acceptance_criteria":"Plugin UI sets/clears a date on the active session; server persists + validates YYYY-MM-DD; dashboard shows next-due per site (derived from latest session) and per session; report includes it; tests pass.","status":"closed","priority":2,"issue_type":"feature","owner":"steve@hanlon.co.uk","created_at":"2026-07-23T10:50:25Z","created_by":"Steve Hanlon","updated_at":"2026-07-23T11:04:08Z","closed_at":"2026-07-23T11:04:08Z","close_reason":"Implemented and released in 0.1.4: server column + validation + dashboard views, plugin date control, 42-test suite. Commits 6313857, 55990be.","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"hc-lh6","title":"Dashboard: reopen a finished healthcheck","description":"Techs sometimes finish a healthcheck and later need to add a note (client came back with something after signoff). Currently there's no way to unfinish — the session is read-only on the dashboard.\n\nAdd a 'Reopen' button on the session-detail dashboard view that clears finished_at on the server, plus tighten the PUT /healthchecks/{id} controller so it doesn't accidentally reopen sessions when a client sends an empty body (latent bug: Validate::optionalInt returns null for missing keys, so an empty PUT currently sets finished_at=NULL).","design":"**Backend:**\n1. Tighten Controllers/Healthchecks::update() to use array_key_exists('finished_at', $body) instead of optionalInt. Missing key = no-op; present-with-null = reopen; present-with-int = finish/update.\n2. Add POST /dashboard/healthchecks/{id}/reopen — session-authenticated (mirrors the GET /dashboard auth pattern), CSRF-protected via a per-session token.\n3. On success, redirect back to the session detail page.\n\n**Frontend (dashboard):**\n4. In renderSessionDetail(), when finished_at !== null, add a small form with the reopen button + hidden CSRF token.\n5. Confirm dialog via inline JS onsubmit so the button isn't a one-click landmine.\n\n**CSRF token pattern:** stored in $_SESSION['att_hc_csrf'], generated on first use with random_bytes(16); verified with hash_equals().\n\n**Not doing (option (b) from the discussion):** preserving original finished_at in a separate column. If we want that later it's a separate migration + Store change.","acceptance_criteria":"- [ ] Empty PUT /healthchecks/{id} does not reopen (returns 200 no-op)\n- [ ] PUT /healthchecks/{id} with {\"finished_at\": null} reopens\n- [ ] PUT /healthchecks/{id} with {\"finished_at\": \u003cint\u003e} still finishes\n- [ ] Dashboard session-detail view shows Reopen button only when finished\n- [ ] Clicking Reopen (with confirm) clears finished_at and reloads the page\n- [ ] Reopen POST rejected without valid CSRF token\n- [ ] Reopen POST rejected without valid dashboard session","status":"closed","priority":2,"issue_type":"feature","assignee":"Steve Hanlon","owner":"steve@hanlon.co.uk","created_at":"2026-07-21T10:50:06Z","created_by":"Steve Hanlon","updated_at":"2026-07-21T10:52:00Z","started_at":"2026-07-21T10:50:12Z","closed_at":"2026-07-21T10:52:00Z","close_reason":"Implemented: dashboard reopen button + CSRF + tightened Healthchecks::update controller so empty PUT no longer accidentally reopens sessions.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"hc-lh6","title":"Dashboard: reopen a finished healthcheck","description":"Techs sometimes finish a healthcheck and later need to add a note (client came back with something after signoff). Currently there's no way to unfinish — the session is read-only on the dashboard.\n\nAdd a 'Reopen' button on the session-detail dashboard view that clears finished_at on the server, plus tighten the PUT /healthchecks/{id} controller so it doesn't accidentally reopen sessions when a client sends an empty body (latent bug: Validate::optionalInt returns null for missing keys, so an empty PUT currently sets finished_at=NULL).","design":"**Backend:**\n1. Tighten Controllers/Healthchecks::update() to use array_key_exists('finished_at', $body) instead of optionalInt. Missing key = no-op; present-with-null = reopen; present-with-int = finish/update.\n2. Add POST /dashboard/healthchecks/{id}/reopen — session-authenticated (mirrors the GET /dashboard auth pattern), CSRF-protected via a per-session token.\n3. On success, redirect back to the session detail page.\n\n**Frontend (dashboard):**\n4. In renderSessionDetail(), when finished_at !== null, add a small form with the reopen button + hidden CSRF token.\n5. Confirm dialog via inline JS onsubmit so the button isn't a one-click landmine.\n\n**CSRF token pattern:** stored in $_SESSION['att_hc_csrf'], generated on first use with random_bytes(16); verified with hash_equals().\n\n**Not doing (option (b) from the discussion):** preserving original finished_at in a separate column. If we want that later it's a separate migration + Store change.","acceptance_criteria":"- [ ] Empty PUT /healthchecks/{id} does not reopen (returns 200 no-op)\n- [ ] PUT /healthchecks/{id} with {\"finished_at\": null} reopens\n- [ ] PUT /healthchecks/{id} with {\"finished_at\": \u003cint\u003e} still finishes\n- [ ] Dashboard session-detail view shows Reopen button only when finished\n- [ ] Clicking Reopen (with confirm) clears finished_at and reloads the page\n- [ ] Reopen POST rejected without valid CSRF token\n- [ ] Reopen POST rejected without valid dashboard session","status":"closed","priority":2,"issue_type":"feature","assignee":"Steve Hanlon","owner":"steve@hanlon.co.uk","created_at":"2026-07-21T10:50:06Z","created_by":"Steve Hanlon","updated_at":"2026-07-21T10:52:00Z","started_at":"2026-07-21T10:50:12Z","closed_at":"2026-07-21T10:52:00Z","close_reason":"Implemented: dashboard reopen button + CSRF + tightened Healthchecks::update controller so empty PUT no longer accidentally reopens sessions.","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"hc-adg","title":"Recovery installer pulls unpinned 'latest' from Gitea + auto-activates without signature check","description":"`recovery-installer.php` (`resolve_latest_ref()` L67-92, install L148) fetches the recovery plugin from Gitea using an unpinned reference (releases → tags → `main` branch archive fallback), then feeds the downloaded ZIP straight to WP's `Plugin_Upgrader::install()` which activates the code.\n\nTrust anchor is Gitea repo integrity: whoever controls the recovery repo (or a leaked Gitea deploy token) controls what runs on every install at next tech click.\n\nTLS verification is on (default `wp_remote_get` behaviour, not disabled anywhere), so MITM is out of scope — but supply-chain compromise of the Gitea repo is not.\n\nNote: this is the same trust model as the new PUC auto-update flow (hc-8gb) — so it's not a new risk introduced by that change, but it's worth naming and mitigating in the same pass.","design":"Options:\n\n**a) Pin to signed tags only.** Require the recovery installer to reject anything that isn't a signed tag (verify via Gitea API's tag object). Only works if we start GPG-signing releases.\n\n**b) Verify a SHA-256 hash from a second source.** Ship an expected-hash constant in the plugin (updated at release time). Installer fetches ZIP + verifies hash before install. Simple, effective, but couples plugin releases to recovery releases.\n\n**c) Detached signature file alongside the ZIP.** e.g. `recovery-1.2.3.zip.sig` verified against a public key baked into the healthcheck plugin. Best long-term option, most setup.\n\n**d) Reduce blast radius via read-only, single-repo Gitea tokens.** Doesn't stop repo compromise but shrinks the credential footprint.\n\nRecommendation: start with **(b)** — quick win. Consider **(c)** if we ever distribute recovery outside the internal network.","acceptance_criteria":"- [ ] Recovery installer refuses to install a ZIP whose SHA-256 doesn't match an expected value shipped in the plugin (or a similar integrity check)\n- [ ] Recovery repo Gitea token is read-only + scoped to just that repo\n- [ ] README documents the release-integrity flow","notes":"Reported in 2026-07-16 security review. Flagged as risk #2 of 3.\n\nRelated: hc-8gb introduced PUC-based auto-updates for THIS plugin with the same trust model. Any signing/hashing scheme we build for recovery should be reusable for the healthcheck plugin's own updates.","status":"open","priority":2,"issue_type":"bug","owner":"steve@hanlon.co.uk","created_at":"2026-07-16T10:14:40Z","created_by":"Steve Hanlon","updated_at":"2026-07-16T10:14:40Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"hc-adg","title":"Recovery installer pulls unpinned 'latest' from Gitea + auto-activates without signature check","description":"`recovery-installer.php` (`resolve_latest_ref()` L67-92, install L148) fetches the recovery plugin from Gitea using an unpinned reference (releases → tags → `main` branch archive fallback), then feeds the downloaded ZIP straight to WP's `Plugin_Upgrader::install()` which activates the code.\n\nTrust anchor is Gitea repo integrity: whoever controls the recovery repo (or a leaked Gitea deploy token) controls what runs on every install at next tech click.\n\nTLS verification is on (default `wp_remote_get` behaviour, not disabled anywhere), so MITM is out of scope — but supply-chain compromise of the Gitea repo is not.\n\nNote: this is the same trust model as the new PUC auto-update flow (hc-8gb) — so it's not a new risk introduced by that change, but it's worth naming and mitigating in the same pass.","design":"Options:\n\n**a) Pin to signed tags only.** Require the recovery installer to reject anything that isn't a signed tag (verify via Gitea API's tag object). Only works if we start GPG-signing releases.\n\n**b) Verify a SHA-256 hash from a second source.** Ship an expected-hash constant in the plugin (updated at release time). Installer fetches ZIP + verifies hash before install. Simple, effective, but couples plugin releases to recovery releases.\n\n**c) Detached signature file alongside the ZIP.** e.g. `recovery-1.2.3.zip.sig` verified against a public key baked into the healthcheck plugin. Best long-term option, most setup.\n\n**d) Reduce blast radius via read-only, single-repo Gitea tokens.** Doesn't stop repo compromise but shrinks the credential footprint.\n\nRecommendation: start with **(b)** — quick win. Consider **(c)** if we ever distribute recovery outside the internal network.","acceptance_criteria":"- [ ] Recovery installer refuses to install a ZIP whose SHA-256 doesn't match an expected value shipped in the plugin (or a similar integrity check)\n- [ ] Recovery repo Gitea token is read-only + scoped to just that repo\n- [ ] README documents the release-integrity flow","notes":"Reported in 2026-07-16 security review. Flagged as risk #2 of 3.\n\nRelated: hc-8gb introduced PUC-based auto-updates for THIS plugin with the same trust model. Any signing/hashing scheme we build for recovery should be reusable for the healthcheck plugin's own updates.","status":"open","priority":2,"issue_type":"bug","owner":"steve@hanlon.co.uk","created_at":"2026-07-16T10:14:40Z","created_by":"Steve Hanlon","updated_at":"2026-07-16T10:14:40Z","dependency_count":0,"dependent_count":0,"comment_count":0}
@@ -27,6 +29,7 @@
{"_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.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} {"_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}
{"_type":"issue","id":"hc-5ix","title":"Build WordPress Healthcheck plugin (stepper through steps.md)","description":"Plugin that walks a technician through the WordPress healthcheck steps documented in steps.md. Independent of the recovery plugin (which it depends on as step 0).","status":"open","priority":2,"issue_type":"epic","owner":"steve@hanlon.co.uk","created_at":"2026-06-11T11:26:32Z","created_by":"Steve Hanlon","updated_at":"2026-06-11T11:26:32Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"hc-5ix","title":"Build WordPress Healthcheck plugin (stepper through steps.md)","description":"Plugin that walks a technician through the WordPress healthcheck steps documented in steps.md. Independent of the recovery plugin (which it depends on as step 0).","status":"open","priority":2,"issue_type":"epic","owner":"steve@hanlon.co.uk","created_at":"2026-06-11T11:26:32Z","created_by":"Steve Hanlon","updated_at":"2026-06-11T11:26:32Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"hc-l0j","title":"Sidebar step dots track status changes live","description":"When a step status is saved via AJAX, recolour the sidebar step-list dot (and, via the existing :has() rule, the link text green/blue) live instead of only on page refresh. Extends applyStatus() in the step-save script; sidebar dots gained a data-step attribute. Released in 0.1.7.","status":"open","priority":3,"issue_type":"feature","owner":"steve@hanlon.co.uk","created_at":"2026-07-27T07:42:04Z","created_by":"Steve Hanlon","updated_at":"2026-07-27T07:42:04Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"hc-ufl","title":"Server: add rate limiting + lockout on API + /dashboard, rotate error_log","description":"Central healthcheck history server has no rate limiting or account lockout:\n\n- `server/src/Auth.php` — unlimited Bearer-key attempts, no delay, no lockout\n- `server/src/Dashboard.php:46-71` — unlimited login attempts on the /dashboard cookie-key form\n\nCombined with the shared-API-key exposure (see companion issue), a leaked or brute-forced key can be abused indefinitely with no signal.\n\nAlso: `server/src/bootstrap.php:25` calls `error_log()` on every uncaught exception with no rotation — the log grows unbounded on a busy or attacked server.","design":"**Rate limiting**: simplest workable option is a small in-DB counter table (key = client IP + endpoint, value = attempt count + window start). Reject when \u003e N attempts / window. No Redis dependency needed at current scale.\n\n**Lockout on /dashboard**: after e.g. 5 failed attempts from one IP within 15 min, return 429 with Retry-After. Keep it IP-scoped (not global) so a random attacker can't lock out real users.\n\n**Auth on API endpoints**: constant-time compare is already correct (`Auth.php:20` uses `hash_equals`). Add: increment failed-attempt counter, refuse to serve after threshold. Consider structured logging of each rejection with IP + user-agent for later review.\n\n**Log rotation**: either delegate to logrotate on the host (document in DEPLOY.md) OR self-rotate via a size check + rename in `bootstrap.php`'s handler.\n\nNice-to-have: session hardening on /dashboard — call `session_regenerate_id(true)` after successful auth (currently missing at `Dashboard.php:53-70`).","acceptance_criteria":"- [ ] API + /dashboard rate-limited per IP with a documented threshold\n- [ ] 429 with Retry-After returned when limit hit\n- [ ] error_log rotation strategy in place (either OS-level via DEPLOY.md docs, or in-app)\n- [ ] session_regenerate_id after /dashboard auth\n- [ ] Rejections logged with enough context to spot abuse patterns","notes":"Reported in 2026-07-16 security review. Flagged as risk #3 of 3 — lower priority than the API-key and recovery-installer issues but worth tackling in the same sweep since it's server-side and cheap.\n\nAlso noted (not required for close): session fixation on /dashboard is theoretically possible but requires attacker to first set a victim's PHPSESSID — high bar. Adding session_regenerate_id closes it cheaply.","status":"open","priority":3,"issue_type":"bug","owner":"steve@hanlon.co.uk","created_at":"2026-07-16T10:14:51Z","created_by":"Steve Hanlon","updated_at":"2026-07-16T10:14:51Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"hc-ufl","title":"Server: add rate limiting + lockout on API + /dashboard, rotate error_log","description":"Central healthcheck history server has no rate limiting or account lockout:\n\n- `server/src/Auth.php` — unlimited Bearer-key attempts, no delay, no lockout\n- `server/src/Dashboard.php:46-71` — unlimited login attempts on the /dashboard cookie-key form\n\nCombined with the shared-API-key exposure (see companion issue), a leaked or brute-forced key can be abused indefinitely with no signal.\n\nAlso: `server/src/bootstrap.php:25` calls `error_log()` on every uncaught exception with no rotation — the log grows unbounded on a busy or attacked server.","design":"**Rate limiting**: simplest workable option is a small in-DB counter table (key = client IP + endpoint, value = attempt count + window start). Reject when \u003e N attempts / window. No Redis dependency needed at current scale.\n\n**Lockout on /dashboard**: after e.g. 5 failed attempts from one IP within 15 min, return 429 with Retry-After. Keep it IP-scoped (not global) so a random attacker can't lock out real users.\n\n**Auth on API endpoints**: constant-time compare is already correct (`Auth.php:20` uses `hash_equals`). Add: increment failed-attempt counter, refuse to serve after threshold. Consider structured logging of each rejection with IP + user-agent for later review.\n\n**Log rotation**: either delegate to logrotate on the host (document in DEPLOY.md) OR self-rotate via a size check + rename in `bootstrap.php`'s handler.\n\nNice-to-have: session hardening on /dashboard — call `session_regenerate_id(true)` after successful auth (currently missing at `Dashboard.php:53-70`).","acceptance_criteria":"- [ ] API + /dashboard rate-limited per IP with a documented threshold\n- [ ] 429 with Retry-After returned when limit hit\n- [ ] error_log rotation strategy in place (either OS-level via DEPLOY.md docs, or in-app)\n- [ ] session_regenerate_id after /dashboard auth\n- [ ] Rejections logged with enough context to spot abuse patterns","notes":"Reported in 2026-07-16 security review. Flagged as risk #3 of 3 — lower priority than the API-key and recovery-installer issues but worth tackling in the same sweep since it's server-side and cheap.\n\nAlso noted (not required for close): session fixation on /dashboard is theoretically possible but requires attacker to first set a victim's PHPSESSID — high bar. Adding session_regenerate_id closes it cheaply.","status":"open","priority":3,"issue_type":"bug","owner":"steve@hanlon.co.uk","created_at":"2026-07-16T10:14:51Z","created_by":"Steve Hanlon","updated_at":"2026-07-16T10:14:51Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"hc-u5c","title":"Add Google Analytics and Search Console healthcheck steps","description":"Add two new healthcheck steps between performance (Step 7) and security (Step 8): a Google Analytics check that sniffs the homepage for GA4/GTM/UA snippets and detects known analytics plugins, and a Google Search Console check that looks for verification meta tags, sitemap reachability, robots.txt Disallow rules, and the WP 'Discourage search engines' setting. Updates steps.md to match.","status":"closed","priority":3,"issue_type":"feature","assignee":"Steve Hanlon","owner":"steve@hanlon.co.uk","created_at":"2026-07-16T08:44:39Z","created_by":"Steve Hanlon","updated_at":"2026-07-16T08:44:56Z","started_at":"2026-07-16T08:44:44Z","closed_at":"2026-07-16T08:44:56Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"hc-u5c","title":"Add Google Analytics and Search Console healthcheck steps","description":"Add two new healthcheck steps between performance (Step 7) and security (Step 8): a Google Analytics check that sniffs the homepage for GA4/GTM/UA snippets and detects known analytics plugins, and a Google Search Console check that looks for verification meta tags, sitemap reachability, robots.txt Disallow rules, and the WP 'Discourage search engines' setting. Updates steps.md to match.","status":"closed","priority":3,"issue_type":"feature","assignee":"Steve Hanlon","owner":"steve@hanlon.co.uk","created_at":"2026-07-16T08:44:39Z","created_by":"Steve Hanlon","updated_at":"2026-07-16T08:44:56Z","started_at":"2026-07-16T08:44:44Z","closed_at":"2026-07-16T08:44:56Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"hc-5ix.27","title":"Self-hosted update channel: plugin checks a private URL for new releases and offers one-click update from wp-admin","description":"Lightweight wrapper around the WP plugin update transient: site_transient_update_plugins filter that adds an entry for ourselves if a newer release exists at a configured URL. URL hosts a JSON manifest + zip. Lets technicians keep clients up to date without manually re-uploading the zip every check.","status":"open","priority":3,"issue_type":"task","owner":"steve@hanlon.co.uk","created_at":"2026-06-11T14:40:30Z","created_by":"Steve Hanlon","updated_at":"2026-06-11T14:40:30Z","labels":["phase-3"],"dependencies":[{"issue_id":"hc-5ix.27","depends_on_id":"hc-5ix","type":"parent-child","created_at":"2026-06-11T15:40:29Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"hc-5ix.27","title":"Self-hosted update channel: plugin checks a private URL for new releases and offers one-click update from wp-admin","description":"Lightweight wrapper around the WP plugin update transient: site_transient_update_plugins filter that adds an entry for ourselves if a newer release exists at a configured URL. URL hosts a JSON manifest + zip. Lets technicians keep clients up to date without manually re-uploading the zip every check.","status":"open","priority":3,"issue_type":"task","owner":"steve@hanlon.co.uk","created_at":"2026-06-11T14:40:30Z","created_by":"Steve Hanlon","updated_at":"2026-06-11T14:40:30Z","labels":["phase-3"],"dependencies":[{"issue_id":"hc-5ix.27","depends_on_id":"hc-5ix","type":"parent-child","created_at":"2026-06-11T15:40:29Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}

View File

@@ -2,7 +2,7 @@
/** /**
* Plugin Name: ATT Site Healthcheck * Plugin Name: ATT Site Healthcheck
* Description: Walks a technician through a structured WordPress site healthcheck. Steps are drop-in PHP files so adding/removing one is a single file change. * Description: Walks a technician through a structured WordPress site healthcheck. Steps are drop-in PHP files so adding/removing one is a single file change.
* Version: 0.1.4 * Version: 0.1.7
* Author: Steve Hanlon * Author: Steve Hanlon
* License: Proprietary * License: Proprietary
* License URI: https://git.h12e.com/steve/wp-healthcheck/raw/branch/main/LICENSE * License URI: https://git.h12e.com/steve/wp-healthcheck/raw/branch/main/LICENSE
@@ -15,7 +15,7 @@ if (!defined('ABSPATH')) {
exit; exit;
} }
define('ATT_HC_VERSION', '0.1.4'); define('ATT_HC_VERSION', '0.1.7');
define('ATT_HC_PLUGIN_FILE', __FILE__); define('ATT_HC_PLUGIN_FILE', __FILE__);
define('ATT_HC_PLUGIN_DIR', plugin_dir_path(__FILE__)); define('ATT_HC_PLUGIN_DIR', plugin_dir_path(__FILE__));
define('ATT_HC_PLUGIN_URL', plugin_dir_url(__FILE__)); define('ATT_HC_PLUGIN_URL', plugin_dir_url(__FILE__));

View File

@@ -5,6 +5,7 @@ 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_start', 'att_hc_handle_start');
add_action('admin_post_att_hc_resume', 'att_hc_handle_resume'); 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('wp_ajax_att_hc_step_history', 'att_hc_handle_step_history_ajax');
add_action('wp_ajax_att_hc_save_step_ajax', 'att_hc_handle_save_step_ajax');
add_action('admin_post_att_hc_save_step', 'att_hc_handle_save_step'); 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_finish', 'att_hc_handle_finish');
add_action('admin_post_att_hc_save_next_due', 'att_hc_handle_save_next_due'); add_action('admin_post_att_hc_save_next_due', 'att_hc_handle_save_next_due');
@@ -296,6 +297,7 @@ function att_hc_render_active_session(ATT_HC_Session $session): void {
} }
att_hc_print_step_history_assets($session); att_hc_print_step_history_assets($session);
att_hc_print_step_save_assets($session);
echo '<div class="att-hc-layout">'; echo '<div class="att-hc-layout">';
att_hc_render_sidebar($session); att_hc_render_sidebar($session);
@@ -385,6 +387,12 @@ function att_hc_print_step_history_assets(ATT_HC_Session $session): void {
'</div>'; '</div>';
}).join(''); }).join('');
} }
// Emitted before the step cards, so defer wiring until the DOM exists.
function ready(fn) {
if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', fn); }
else { fn(); }
}
ready(function () {
document.querySelectorAll('details.att-hc-history').forEach(function (det) { document.querySelectorAll('details.att-hc-history').forEach(function (det) {
det.addEventListener('toggle', function () { det.addEventListener('toggle', function () {
if (!det.open || det.dataset.loaded === 'yes') return; if (!det.open || det.dataset.loaded === 'yes') return;
@@ -412,6 +420,125 @@ function att_hc_print_step_history_assets(ATT_HC_Session $session): void {
}); });
}); });
}); });
});
})();
</script>
<?php
}
/**
* Client-side auto-save for the step cards: status saves on change, notes save
* on blur, in place via AJAX. Progressive enhancement — the server-rendered
* form still works with JS off, and the explicit "Save step" button is only
* hidden once this wires up.
*/
function att_hc_print_step_save_assets(ATT_HC_Session $session): void {
$cfg = [
'ajaxUrl' => admin_url('admin-ajax.php'),
'nonce' => wp_create_nonce('att_hc_save_step_ajax'),
];
?>
<style>
.att-hc-step-save.js-live button[type="submit"],
.att-hc-step-save.js-live button:not([type]) { display: none; }
.att-hc-save-status.is-saving { color: #646970; }
.att-hc-save-status.is-saved { color: #1a8917; font-style: normal; }
.att-hc-save-status.is-error { color: #b32d2e; font-style: normal; }
.att-hc-step-save select.is-saving,
.att-hc-step-save textarea.is-saving { opacity: .6; }
</style>
<script>
(function () {
var cfg = <?php echo wp_json_encode($cfg); ?>;
function statusLabel(s) { return s.replace(/_/g, ' '); }
// These assets are emitted before the step cards in the markup, so the
// forms don't exist yet when this inline script first runs. Wait for the
// DOM before wiring listeners.
function ready(fn) {
if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', fn); }
else { fn(); }
}
ready(function () {
document.querySelectorAll('form.att-hc-step-save').forEach(function (form) {
var stepId = form.dataset.step;
var select = form.querySelector('select[name="status"]');
var notes = form.querySelector('textarea[name="notes"]');
var feedback = form.querySelector('.att-hc-save-status');
var card = form.closest('.att-hc-step');
if (!stepId || !select || !notes || !feedback) return;
// Signals JS is active: CSS hides the now-redundant Save button.
form.classList.add('js-live');
// Last value we've successfully persisted, so a blur with no edit is a no-op.
var savedNotes = notes.value;
function setFeedback(cls, text) {
feedback.className = 'att-hc-save-status description ' + cls;
feedback.textContent = text;
}
function save(field) {
var body = new FormData();
body.append('action', 'att_hc_save_step_ajax');
body.append('nonce', cfg.nonce);
body.append('step', stepId);
body.append('status', select.value);
body.append('notes', notes.value);
field.classList.add('is-saving');
setFeedback('is-saving', 'Saving…');
return fetch(cfg.ajaxUrl, { method: 'POST', credentials: 'same-origin', body: body })
.then(function (r) { return r.json(); })
.then(function (j) {
field.classList.remove('is-saving');
if (!j || !j.success) {
setFeedback('is-error', (j && j.data) || 'Save failed.');
return;
}
savedNotes = notes.value;
applyStatus(j.data.status, j.data.status_label);
setFeedback('is-saved', 'Saved ✓');
})
.catch(function (e) {
field.classList.remove('is-saving');
setFeedback('is-error', 'Network error — not saved: ' + e.message);
});
}
// Reflect a status change in the header badge, escalation banner and
// sidebar dot, matching what a full server render would have produced.
// Recolouring the sidebar dot's class is enough for the link text too:
// the `li:has(.dot-done) a` CSS rule turns it green / back to default.
function applyStatus(status, label) {
if (card) {
var badge = card.querySelector('header .att-hc-step-status');
if (badge) {
badge.className = 'att-hc-step-status att-hc-status-' + status;
badge.textContent = label || statusLabel(status);
}
var esc = card.querySelector('[data-role="escalation"]');
if (esc) esc.hidden = (status !== 'blocked');
}
var dot = document.querySelector('.att-hc-sidebar .dot[data-step="' + stepId + '"]');
if (dot) dot.className = 'dot dot-' + status;
}
select.addEventListener('change', function () { save(select); });
notes.addEventListener('blur', function () {
if (notes.value === savedNotes) return; // nothing changed
save(notes);
});
// Enter / clicking the fallback button (if somehow visible): save in place.
form.addEventListener('submit', function (e) {
e.preventDefault();
save(notes);
});
});
});
})(); })();
</script> </script>
<?php <?php
@@ -423,9 +550,10 @@ function att_hc_render_sidebar(ATT_HC_Session $session): void {
$state = $session->step_state($step->id()); $state = $session->step_state($step->id());
$cls = 'dot-' . $state['status']; $cls = 'dot-' . $state['status'];
printf( printf(
'<li><span class="dot %s"></span><a href="#step-%s">%s</a></li>', '<li><span class="dot %s" data-step="%s"></span><a href="#step-%s">%s</a></li>',
esc_attr($cls), esc_attr($cls),
esc_attr($step->id()), esc_attr($step->id()),
esc_attr($step->id()),
esc_html($step->title()) esc_html($step->title())
); );
} }
@@ -533,8 +661,9 @@ function att_hc_render_step_card(ATT_HC_Session $session, ATT_HC_Step $step, int
</ul> </ul>
</div> </div>
<?php endif; ?> <?php endif; ?>
<?php if ($status === ATT_HC_Session::STATUS_BLOCKED && ($esc = $step->escalation())): ?> <?php if ($esc = $step->escalation()): ?>
<div class="att-hc-escalation"><?php echo esc_html($esc); ?></div> <?php // Rendered always (hidden unless blocked) so an AJAX status change can toggle it without a reload. ?>
<div class="att-hc-escalation" data-role="escalation"<?php echo $status === ATT_HC_Session::STATUS_BLOCKED ? '' : ' hidden'; ?>><?php echo esc_html($esc); ?></div>
<?php endif; ?> <?php endif; ?>
<?php <?php
@@ -545,7 +674,7 @@ function att_hc_render_step_card(ATT_HC_Session $session, ATT_HC_Step $step, int
$step->render_extra($session->data()); $step->render_extra($session->data());
?> ?>
<form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>" class="att-hc-step-save"> <form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>" class="att-hc-step-save" data-step="<?php echo esc_attr($step->id()); ?>">
<?php wp_nonce_field('att_hc_save_step_' . $step->id()); ?> <?php wp_nonce_field('att_hc_save_step_' . $step->id()); ?>
<input type="hidden" name="action" value="att_hc_save_step"> <input type="hidden" name="action" value="att_hc_save_step">
<input type="hidden" name="step" value="<?php echo esc_attr($step->id()); ?>"> <input type="hidden" name="step" value="<?php echo esc_attr($step->id()); ?>">
@@ -564,10 +693,13 @@ function att_hc_render_step_card(ATT_HC_Session $session, ATT_HC_Step $step, int
<textarea name="notes" placeholder="What did you check, find, fix, or flag?"><?php echo esc_textarea($notes); ?></textarea> <textarea name="notes" placeholder="What did you check, find, fix, or flag?"><?php echo esc_textarea($notes); ?></textarea>
</p> </p>
<p> <p>
<?php // JS hides this button (saves happen on change/blur); it stays as the no-JS fallback. ?>
<button class="button button-primary">Save step</button> <button class="button button-primary">Save step</button>
<?php if ($state['updated_at']): ?> <span class="att-hc-save-status description" aria-live="polite"><?php
<span class="description">Last saved <?php echo esc_html(human_time_diff($state['updated_at'], time())); ?> ago</span> if ($state['updated_at']) {
<?php endif; ?> echo 'Last saved ' . esc_html(human_time_diff($state['updated_at'], time())) . ' ago';
}
?></span>
</p> </p>
</form> </form>
</div> </div>
@@ -725,6 +857,45 @@ function att_hc_handle_save_step(): void {
exit; exit;
} }
/**
* AJAX twin of att_hc_handle_save_step(). Same validation and write-through,
* but returns JSON instead of redirecting, so the reporting page can save a
* step in place (on status change / notes blur) without a full reload.
*
* The non-AJAX form POST above is kept as the no-JS fallback.
*/
function att_hc_handle_save_step_ajax(): void {
if (!current_user_can('manage_options')) wp_send_json_error('Forbidden', 403);
if (!check_ajax_referer('att_hc_save_step_ajax', 'nonce', false)) {
wp_send_json_error('Bad nonce — reload the page and try again.', 403);
}
$step_id = isset($_POST['step']) ? sanitize_key((string) $_POST['step']) : '';
if ($step_id === '') wp_send_json_error('Missing step id', 400);
$session = ATT_HC_Session::current();
if (!$session || $session->is_finished()) wp_send_json_error('No active session', 409);
if (!ATT_HC_Steps::instance()->get($step_id)) wp_send_json_error('Unknown step', 400);
$status = isset($_POST['status']) ? sanitize_key((string) $_POST['status']) : ATT_HC_Session::STATUS_NOT_STARTED;
$notes = isset($_POST['notes']) ? wp_unslash((string) $_POST['notes']) : '';
try {
// update_step() re-validates the status and falls back to NOT_STARTED
// for anything unexpected, so we report back whatever was actually stored.
$session->update_step($step_id, $status, $notes);
} catch (ATT_HC_Api_Exception $e) {
wp_send_json_error('Not saved — the central server rejected the write: ' . $e->getMessage(), 502);
}
$state = $session->step_state($step_id);
wp_send_json_success([
'step' => $step_id,
'status' => (string) $state['status'],
'status_label' => str_replace('_', ' ', (string) $state['status']),
'updated_at' => (int) $state['updated_at'],
]);
}
function att_hc_handle_finish(): void { function att_hc_handle_finish(): void {
if (!current_user_can('manage_options')) wp_die('Forbidden'); if (!current_user_can('manage_options')) wp_die('Forbidden');
check_admin_referer('att_hc_finish'); check_admin_referer('att_hc_finish');

View File

@@ -1,12 +1,12 @@
{ {
"name": "ATT Site Healthcheck", "name": "ATT Site Healthcheck",
"slug": "att-site-healthcheck", "slug": "att-site-healthcheck",
"version": "0.1.4", "version": "0.1.7",
"author": "Steve Hanlon", "author": "Steve Hanlon",
"requires": "6.0", "requires": "6.0",
"tested": "6.6", "tested": "6.6",
"requires_php": "7.4", "requires_php": "7.4",
"last_updated": "2026-07-23 12:03:38", "last_updated": "2026-07-27 08:42:03",
"download_url": "https://git.h12e.com/steve/wp-healthcheck/archive/main.zip", "download_url": "https://git.h12e.com/steve/wp-healthcheck/archive/main.zip",
"sections": { "sections": {
"description": "Walks a technician through a structured WordPress site healthcheck. Steps are drop-in PHP files so adding/removing one is a single file change.", "description": "Walks a technician through a structured WordPress site healthcheck. Steps are drop-in PHP files so adding/removing one is a single file change.",