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>
This commit is contained in:
@@ -15,6 +15,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.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-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-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}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
/**
|
||||
* 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.
|
||||
* Version: 0.1.4
|
||||
* Version: 0.1.5
|
||||
* Author: Steve Hanlon
|
||||
* License: Proprietary
|
||||
* License URI: https://git.h12e.com/steve/wp-healthcheck/raw/branch/main/LICENSE
|
||||
@@ -15,7 +15,7 @@ if (!defined('ABSPATH')) {
|
||||
exit;
|
||||
}
|
||||
|
||||
define('ATT_HC_VERSION', '0.1.4');
|
||||
define('ATT_HC_VERSION', '0.1.5');
|
||||
define('ATT_HC_PLUGIN_FILE', __FILE__);
|
||||
define('ATT_HC_PLUGIN_DIR', plugin_dir_path(__FILE__));
|
||||
define('ATT_HC_PLUGIN_URL', plugin_dir_url(__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_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_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_finish', 'att_hc_handle_finish');
|
||||
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_save_assets($session);
|
||||
|
||||
echo '<div class="att-hc-layout">';
|
||||
att_hc_render_sidebar($session);
|
||||
@@ -417,6 +419,111 @@ function att_hc_print_step_history_assets(ATT_HC_Session $session): void {
|
||||
<?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, ' '); }
|
||||
|
||||
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,
|
||||
// matching what a full server render would have produced.
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
<?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) {
|
||||
@@ -533,8 +640,9 @@ function att_hc_render_step_card(ATT_HC_Session $session, ATT_HC_Step $step, int
|
||||
</ul>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php if ($status === ATT_HC_Session::STATUS_BLOCKED && ($esc = $step->escalation())): ?>
|
||||
<div class="att-hc-escalation"><?php echo esc_html($esc); ?></div>
|
||||
<?php if ($esc = $step->escalation()): ?>
|
||||
<?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
|
||||
@@ -545,7 +653,7 @@ function att_hc_render_step_card(ATT_HC_Session $session, ATT_HC_Step $step, int
|
||||
$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()); ?>
|
||||
<input type="hidden" name="action" value="att_hc_save_step">
|
||||
<input type="hidden" name="step" value="<?php echo esc_attr($step->id()); ?>">
|
||||
@@ -564,10 +672,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>
|
||||
</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>
|
||||
<?php if ($state['updated_at']): ?>
|
||||
<span class="description">Last saved <?php echo esc_html(human_time_diff($state['updated_at'], time())); ?> ago</span>
|
||||
<?php endif; ?>
|
||||
<span class="att-hc-save-status description" aria-live="polite"><?php
|
||||
if ($state['updated_at']) {
|
||||
echo 'Last saved ' . esc_html(human_time_diff($state['updated_at'], time())) . ' ago';
|
||||
}
|
||||
?></span>
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
@@ -725,6 +836,45 @@ function att_hc_handle_save_step(): void {
|
||||
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 {
|
||||
if (!current_user_can('manage_options')) wp_die('Forbidden');
|
||||
check_admin_referer('att_hc_finish');
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "ATT Site Healthcheck",
|
||||
"slug": "att-site-healthcheck",
|
||||
"version": "0.1.4",
|
||||
"version": "0.1.5",
|
||||
"author": "Steve Hanlon",
|
||||
"requires": "6.0",
|
||||
"tested": "6.6",
|
||||
"requires_php": "7.4",
|
||||
"last_updated": "2026-07-23 12:03:38",
|
||||
"last_updated": "2026-07-27 08:27:04",
|
||||
"download_url": "https://git.h12e.com/steve/wp-healthcheck/archive/main.zip",
|
||||
"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.",
|
||||
|
||||
Reference in New Issue
Block a user