Phase 1 MVP: drop-in step registry + session + Markdown report
Plugin skeleton, drop-in step registry, option-backed session, single-page checklist UI, downloadable Markdown report. Steps live as one file each under includes/steps/ — adding/removing one is a single file change. Step IDs are stable strings so renaming files preserves session data. Architecture (hc-5ix.3): WPH_Steps singleton globs includes/steps/*.php, natsort-orders by filename, requires each file (which returns a WPH_Step instance), then applies a 'wph_steps' filter so installs can drop steps. Session (hc-5ix.4): option-backed (per decision — plugin is installed per-engagement, so DB-resident history would be lost on uninstall). Single in-progress session per site; finished sessions render a report that the user downloads/copies. Recovery bootstrap (hc-5ix.1): detects whether wp-site-recovery is installed + active, surfaces state on the start panel and in every active session. Manual install for now; private update channel deferred to hc-5ix.27. Smoke-tested on testsite: registry discovery (13 steps in correct order), start → update_step → progress count → finish → 8KB Markdown report → discard cycle.
This commit is contained in:
275
includes/admin-page.php
Normal file
275
includes/admin-page.php
Normal file
@@ -0,0 +1,275 @@
|
||||
<?php
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
add_action('admin_menu', 'wph_register_menu');
|
||||
add_action('admin_post_wph_start', 'wph_handle_start');
|
||||
add_action('admin_post_wph_save_step', 'wph_handle_save_step');
|
||||
add_action('admin_post_wph_finish', 'wph_handle_finish');
|
||||
add_action('admin_post_wph_discard', 'wph_handle_discard');
|
||||
add_action('admin_post_wph_download_report', 'wph_handle_download_report');
|
||||
add_action('admin_enqueue_scripts', 'wph_enqueue_assets');
|
||||
|
||||
function wph_register_menu(): void {
|
||||
add_management_page(
|
||||
'Site Healthcheck',
|
||||
'Site Healthcheck',
|
||||
'manage_options',
|
||||
'site-healthcheck',
|
||||
'wph_render_admin_page'
|
||||
);
|
||||
}
|
||||
|
||||
function wph_enqueue_assets($hook): void {
|
||||
if ($hook !== 'tools_page_site-healthcheck') return;
|
||||
wp_register_style('wph-admin', false);
|
||||
wp_enqueue_style('wph-admin');
|
||||
wp_add_inline_style('wph-admin', wph_inline_css());
|
||||
}
|
||||
|
||||
function wph_inline_css(): string {
|
||||
return '
|
||||
.wph-card { background:#fff; border:1px solid #c3c4c7; border-radius:6px; padding:1rem 1.25rem; margin-bottom:1rem; }
|
||||
.wph-card h2 { margin-top:0; }
|
||||
.wph-step-status { display:inline-block; padding:.1rem .55rem; border-radius:10px; font-size:11px; font-weight:600; text-transform:uppercase; letter-spacing:.04em; }
|
||||
.wph-status-not_started { background:#f0f0f1; color:#646970; }
|
||||
.wph-status-done { background:#def7e3; color:#155724; }
|
||||
.wph-status-skipped { background:#fff3cd; color:#856404; }
|
||||
.wph-status-blocked { background:#fbeae8; color:#721c24; }
|
||||
.wph-status-n_a { background:#e2e3e5; color:#41464b; }
|
||||
.wph-step { padding:1rem 1.25rem; border:1px solid #dcdcde; border-radius:6px; margin-bottom:.75rem; background:#fff; }
|
||||
.wph-step header { display:flex; justify-content:space-between; align-items:center; gap:1rem; margin-bottom:.5rem; }
|
||||
.wph-step header h2 { margin:0; font-size:1.1rem; }
|
||||
.wph-sub-items { margin:.5rem 0 .75rem 1.25rem; padding:0; }
|
||||
.wph-sub-items li { margin:.15rem 0; }
|
||||
.wph-watch-outs { background:#fff8e1; border-left:3px solid #f5b800; padding:.4rem .8rem; margin:.5rem 0; font-size:.92em; }
|
||||
.wph-watch-outs strong { display:block; margin-bottom:.2rem; }
|
||||
.wph-escalation { background:#fbeae8; border-left:3px solid #c0392b; padding:.4rem .8rem; margin:.5rem 0; font-weight:500; }
|
||||
.wph-step textarea { width:100%; min-height:60px; }
|
||||
.wph-progress { font-weight:600; }
|
||||
.wph-ok { color:#155724; }
|
||||
.wph-warn { color:#856404; }
|
||||
.wph-bad { color:#721c24; }
|
||||
.wph-bootstrap-panel { padding:.6rem 1rem; background:#f6f7f7; border:1px solid #dcdcde; border-radius:6px; margin-bottom:.75rem; }
|
||||
.wph-bootstrap-panel h3 { margin:0 0 .35rem; font-size:1rem; }
|
||||
.wph-actions { display:flex; gap:.5rem; align-items:center; margin-top:.4rem; }
|
||||
';
|
||||
}
|
||||
|
||||
function wph_render_admin_page(): void {
|
||||
if (!current_user_can('manage_options')) wp_die('Forbidden');
|
||||
|
||||
$session = WPH_Session::current();
|
||||
|
||||
echo '<div class="wrap">';
|
||||
echo '<h1>Site Healthcheck</h1>';
|
||||
|
||||
if (!$session) {
|
||||
wph_render_start_panel();
|
||||
echo '</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
if ($session->is_finished()) {
|
||||
wph_render_finished_panel($session);
|
||||
echo '</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
wph_render_active_session($session);
|
||||
echo '</div>';
|
||||
}
|
||||
|
||||
function wph_render_start_panel(): void {
|
||||
?>
|
||||
<div class="wph-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>
|
||||
<form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
|
||||
<?php wp_nonce_field('wph_start'); ?>
|
||||
<input type="hidden" name="action" value="wph_start">
|
||||
<button class="button button-primary">Start new healthcheck</button>
|
||||
</form>
|
||||
</div>
|
||||
<?php
|
||||
WPH_Recovery_Bootstrap::render_status();
|
||||
}
|
||||
|
||||
function wph_render_active_session(WPH_Session $session): void {
|
||||
$progress = $session->progress();
|
||||
$tech = get_userdata($session->technician_id());
|
||||
?>
|
||||
<div class="wph-card">
|
||||
<p>
|
||||
<strong>Session:</strong> <code><?php echo esc_html($session->id()); ?></code> ·
|
||||
<strong>Started:</strong> <?php echo esc_html(date('Y-m-d H:i', $session->started_at())); ?> ·
|
||||
<strong>Technician:</strong> <?php echo esc_html($tech ? $tech->display_name : '#' . $session->technician_id()); ?> ·
|
||||
<span class="wph-progress"><?php echo (int) $progress['done']; ?> / <?php echo (int) $progress['total']; ?> steps</span>
|
||||
</p>
|
||||
<p>WP <code><?php echo esc_html($session->wp_version()); ?></code> · PHP <code><?php echo esc_html($session->php_version()); ?></code> · Site <code><?php echo esc_html($session->site_url()); ?></code></p>
|
||||
<div class="wph-actions">
|
||||
<form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>" onsubmit="return confirm('Mark this healthcheck as finished?');" style="display:inline">
|
||||
<?php wp_nonce_field('wph_finish'); ?>
|
||||
<input type="hidden" name="action" value="wph_finish">
|
||||
<button class="button button-primary">Finish & generate report</button>
|
||||
</form>
|
||||
<form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>" onsubmit="return confirm('Discard this in-progress session? All notes will be lost.');" style="display:inline">
|
||||
<?php wp_nonce_field('wph_discard'); ?>
|
||||
<input type="hidden" name="action" value="wph_discard">
|
||||
<button class="button button-link-delete">Discard</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<?php
|
||||
|
||||
WPH_Recovery_Bootstrap::render_status();
|
||||
|
||||
foreach (WPH_Steps::instance()->all() as $step) {
|
||||
wph_render_step_card($session, $step);
|
||||
}
|
||||
}
|
||||
|
||||
function wph_render_step_card(WPH_Session $session, WPH_Step $step): void {
|
||||
$state = $session->step_state($step->id());
|
||||
$status = $state['status'];
|
||||
$notes = $state['notes'];
|
||||
?>
|
||||
<form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>" class="wph-step" id="step-<?php echo esc_attr($step->id()); ?>">
|
||||
<?php wp_nonce_field('wph_save_step_' . $step->id()); ?>
|
||||
<input type="hidden" name="action" value="wph_save_step">
|
||||
<input type="hidden" name="step" value="<?php echo esc_attr($step->id()); ?>">
|
||||
<header>
|
||||
<h2><?php echo esc_html($step->title()); ?></h2>
|
||||
<span class="wph-step-status wph-status-<?php echo esc_attr($status); ?>"><?php echo esc_html(str_replace('_', ' ', $status)); ?></span>
|
||||
</header>
|
||||
<?php if ($blurb = $step->blurb()): ?>
|
||||
<p><?php echo esc_html($blurb); ?></p>
|
||||
<?php endif; ?>
|
||||
<?php if ($items = $step->sub_items()): ?>
|
||||
<ul class="wph-sub-items">
|
||||
<?php foreach ($items as $item): ?>
|
||||
<li><?php echo esc_html($item); ?></li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php endif; ?>
|
||||
<?php if ($watch = $step->watch_outs()): ?>
|
||||
<div class="wph-watch-outs">
|
||||
<strong>Watch out for:</strong>
|
||||
<ul style="margin:.2rem 0 0 1rem;">
|
||||
<?php foreach ($watch as $w): ?>
|
||||
<li><?php echo esc_html($w); ?></li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php if ($status === WPH_Session::STATUS_BLOCKED && ($esc = $step->escalation())): ?>
|
||||
<div class="wph-escalation"><?php echo esc_html($esc); ?></div>
|
||||
<?php endif; ?>
|
||||
<p>
|
||||
<label>
|
||||
<strong>Status:</strong>
|
||||
<select name="status">
|
||||
<?php foreach (WPH_Session::VALID_STATUSES as $s): ?>
|
||||
<option value="<?php echo esc_attr($s); ?>" <?php selected($status, $s); ?>><?php echo esc_html(str_replace('_', ' ', $s)); ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</label>
|
||||
</p>
|
||||
<p>
|
||||
<label><strong>Notes:</strong></label>
|
||||
<textarea name="notes" placeholder="What did you check, find, fix, or flag?"><?php echo esc_textarea($notes); ?></textarea>
|
||||
</p>
|
||||
<p>
|
||||
<button class="button">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; ?>
|
||||
</p>
|
||||
</form>
|
||||
<?php
|
||||
}
|
||||
|
||||
function wph_render_finished_panel(WPH_Session $session): void {
|
||||
$report = wph_build_markdown_report($session);
|
||||
?>
|
||||
<div class="wph-card">
|
||||
<h2>Healthcheck finished</h2>
|
||||
<p>Started <?php echo esc_html(date('Y-m-d H:i', $session->started_at())); ?> · Finished <?php echo esc_html(date('Y-m-d H:i', (int) $session->finished_at())); ?></p>
|
||||
<div class="wph-actions">
|
||||
<form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
|
||||
<?php wp_nonce_field('wph_download_report'); ?>
|
||||
<input type="hidden" name="action" value="wph_download_report">
|
||||
<button class="button button-primary">Download Markdown report</button>
|
||||
</form>
|
||||
<button class="button" onclick="navigator.clipboard.writeText(document.getElementById('wph-report-md').textContent);this.textContent='Copied!';setTimeout(()=>this.textContent='Copy to clipboard',2000)">Copy to clipboard</button>
|
||||
<form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>" onsubmit="return confirm('Discard this finished session?');" style="display:inline">
|
||||
<?php wp_nonce_field('wph_discard'); ?>
|
||||
<input type="hidden" name="action" value="wph_discard">
|
||||
<button class="button button-link-delete">Discard & start a new one</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wph-card">
|
||||
<h2>Report preview</h2>
|
||||
<pre id="wph-report-md" style="white-space:pre-wrap;background:#f6f7f7;padding:1rem;border-radius:6px;max-height:30em;overflow:auto"><?php echo esc_html($report); ?></pre>
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
|
||||
// --- Handlers ----------------------------------------------------------------
|
||||
|
||||
function wph_handle_start(): void {
|
||||
if (!current_user_can('manage_options')) wp_die('Forbidden');
|
||||
check_admin_referer('wph_start');
|
||||
WPH_Session::start(get_current_user_id());
|
||||
wp_safe_redirect(admin_url('tools.php?page=site-healthcheck'));
|
||||
exit;
|
||||
}
|
||||
|
||||
function wph_handle_save_step(): void {
|
||||
if (!current_user_can('manage_options')) wp_die('Forbidden');
|
||||
$step_id = isset($_POST['step']) ? sanitize_key((string) $_POST['step']) : '';
|
||||
check_admin_referer('wph_save_step_' . $step_id);
|
||||
$session = WPH_Session::current();
|
||||
if (!$session || $session->is_finished()) wp_die('No active session.');
|
||||
if (!WPH_Steps::instance()->get($step_id)) wp_die('Unknown step.');
|
||||
$status = isset($_POST['status']) ? sanitize_key((string) $_POST['status']) : WPH_Session::STATUS_NOT_STARTED;
|
||||
$notes = isset($_POST['notes']) ? wp_unslash((string) $_POST['notes']) : '';
|
||||
$session->update_step($step_id, $status, $notes);
|
||||
wp_safe_redirect(admin_url('tools.php?page=site-healthcheck#step-' . rawurlencode($step_id)));
|
||||
exit;
|
||||
}
|
||||
|
||||
function wph_handle_finish(): void {
|
||||
if (!current_user_can('manage_options')) wp_die('Forbidden');
|
||||
check_admin_referer('wph_finish');
|
||||
$session = WPH_Session::current();
|
||||
if (!$session) wp_die('No active session.');
|
||||
$session->finish();
|
||||
wp_safe_redirect(admin_url('tools.php?page=site-healthcheck'));
|
||||
exit;
|
||||
}
|
||||
|
||||
function wph_handle_discard(): void {
|
||||
if (!current_user_can('manage_options')) wp_die('Forbidden');
|
||||
check_admin_referer('wph_discard');
|
||||
WPH_Session::discard();
|
||||
wp_safe_redirect(admin_url('tools.php?page=site-healthcheck'));
|
||||
exit;
|
||||
}
|
||||
|
||||
function wph_handle_download_report(): void {
|
||||
if (!current_user_can('manage_options')) wp_die('Forbidden');
|
||||
check_admin_referer('wph_download_report');
|
||||
$session = WPH_Session::current();
|
||||
if (!$session) wp_die('No session.');
|
||||
$report = wph_build_markdown_report($session);
|
||||
$host = parse_url(get_site_url(), PHP_URL_HOST) ?: 'site';
|
||||
$host = preg_replace('/[^a-z0-9.-]/i', '', (string) $host);
|
||||
$stamp = date('Ymd', $session->started_at() ?: time());
|
||||
$filename = 'wph-report-' . $host . '-' . $stamp . '.md';
|
||||
nocache_headers();
|
||||
header('Content-Type: text/markdown; charset=UTF-8');
|
||||
header('Content-Disposition: attachment; filename="' . $filename . '"');
|
||||
echo $report;
|
||||
exit;
|
||||
}
|
||||
105
includes/class-wph-session.php
Normal file
105
includes/class-wph-session.php
Normal file
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
/**
|
||||
* Healthcheck session. Option-backed (single in-progress session per site).
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
final class WPH_Session {
|
||||
|
||||
public const STATUS_NOT_STARTED = 'not_started';
|
||||
public const STATUS_DONE = 'done';
|
||||
public const STATUS_SKIPPED = 'skipped';
|
||||
public const STATUS_BLOCKED = 'blocked';
|
||||
public const STATUS_NA = 'n_a';
|
||||
|
||||
public const VALID_STATUSES = [
|
||||
self::STATUS_NOT_STARTED,
|
||||
self::STATUS_DONE,
|
||||
self::STATUS_SKIPPED,
|
||||
self::STATUS_BLOCKED,
|
||||
self::STATUS_NA,
|
||||
];
|
||||
|
||||
private array $data;
|
||||
|
||||
private function __construct(array $data) {
|
||||
$this->data = $data;
|
||||
}
|
||||
|
||||
public static function current(): ?self {
|
||||
$raw = get_option(WPH_OPT_SESSION);
|
||||
if (!is_array($raw) || empty($raw['id'])) return null;
|
||||
return new self($raw);
|
||||
}
|
||||
|
||||
public static function start(int $technician_id): self {
|
||||
$data = [
|
||||
'id' => uniqid('wph_', 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' => ...]
|
||||
];
|
||||
update_option(WPH_OPT_SESSION, $data, false);
|
||||
return new self($data);
|
||||
}
|
||||
|
||||
public static function discard(): void {
|
||||
delete_option(WPH_OPT_SESSION);
|
||||
}
|
||||
|
||||
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 wp_version(): string { return (string) ($this->data['wp_version'] ?? ''); }
|
||||
public function php_version(): string { return (string) ($this->data['php_version'] ?? ''); }
|
||||
public function data(): array { return $this->data; }
|
||||
|
||||
public function step_state(string $step_id): array {
|
||||
return $this->data['steps'][$step_id] ?? [
|
||||
'status' => self::STATUS_NOT_STARTED,
|
||||
'notes' => '',
|
||||
'updated_at' => null,
|
||||
];
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
$this->data['steps'][$step_id] = [
|
||||
'status' => $status,
|
||||
'notes' => $notes,
|
||||
'updated_at' => time(),
|
||||
];
|
||||
update_option(WPH_OPT_SESSION, $this->data, false);
|
||||
}
|
||||
|
||||
public function finish(): void {
|
||||
$this->data['finished_at'] = time();
|
||||
update_option(WPH_OPT_SESSION, $this->data, false);
|
||||
}
|
||||
|
||||
public function progress(): array {
|
||||
$steps = WPH_Steps::instance()->all();
|
||||
$total = count($steps);
|
||||
$done = 0;
|
||||
foreach ($steps as $step) {
|
||||
$st = $this->step_state($step->id());
|
||||
if (in_array($st['status'], [self::STATUS_DONE, self::STATUS_SKIPPED, self::STATUS_NA], true)) {
|
||||
$done++;
|
||||
}
|
||||
}
|
||||
return ['done' => $done, 'total' => $total];
|
||||
}
|
||||
}
|
||||
45
includes/class-wph-step.php
Normal file
45
includes/class-wph-step.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
/**
|
||||
* Base class for a healthcheck step.
|
||||
*
|
||||
* Each step lives in its own file under includes/steps/ with a numeric prefix
|
||||
* (e.g. 10-backup.php). The file returns an instance of a subclass of WPH_Step.
|
||||
* Drop a new file in that directory and it shows up; delete a file and it
|
||||
* disappears. The numeric prefix controls order so re-ordering is a rename.
|
||||
*
|
||||
* Stable string IDs (returned by id()) are stored in the session, so renaming
|
||||
* the file does NOT lose data as long as id() stays the same.
|
||||
*/
|
||||
abstract class WPH_Step {
|
||||
|
||||
/** Stable identifier (lowercase slug). NEVER change once shipped. */
|
||||
abstract public function id(): string;
|
||||
|
||||
/** Human-readable title shown in the UI and report. */
|
||||
abstract public function title(): string;
|
||||
|
||||
/** Intro paragraph shown under the title (plain text or simple inline HTML). */
|
||||
public function blurb(): string { return ''; }
|
||||
|
||||
/** Sub-items the technician should tick through (plain strings). */
|
||||
public function sub_items(): array { return []; }
|
||||
|
||||
/** "Watch out for" callouts (plain strings). */
|
||||
public function watch_outs(): array { return []; }
|
||||
|
||||
/**
|
||||
* If this step has a stop/escalate condition (e.g. backup failed → stop),
|
||||
* return the guidance string. Rendered as a banner when status=blocked.
|
||||
*/
|
||||
public function escalation(): ?string { return null; }
|
||||
|
||||
/**
|
||||
* Phase 3 hook — return structured findings (php version, plugin update
|
||||
* intel, etc.) for the technician to verify. Phase 1 returns nothing.
|
||||
*
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function autocheck(array $session_state): array { return []; }
|
||||
}
|
||||
55
includes/class-wph-steps.php
Normal file
55
includes/class-wph-steps.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
/**
|
||||
* Step registry. Singleton.
|
||||
*
|
||||
* discover() globs a directory for *.php files in sort order and requires each.
|
||||
* Each file MUST return a WPH_Step instance. The numeric filename prefix
|
||||
* (00, 10, 20...) controls the visual order.
|
||||
*
|
||||
* After discovery, the 'wph_steps' filter lets installation-specific code
|
||||
* remove/replace steps (return the array keyed by step id).
|
||||
*/
|
||||
final class WPH_Steps {
|
||||
|
||||
private static ?WPH_Steps $instance = null;
|
||||
|
||||
/** @var array<string,WPH_Step> */
|
||||
private array $steps = [];
|
||||
|
||||
private bool $discovered = false;
|
||||
|
||||
public static function instance(): WPH_Steps {
|
||||
return self::$instance ??= new self();
|
||||
}
|
||||
|
||||
public function discover(string $dir): void {
|
||||
if ($this->discovered) return;
|
||||
$files = glob(rtrim($dir, '/') . '/*.php') ?: [];
|
||||
// Natural sort so 100-* comes after 20-* (not after 10-*).
|
||||
natsort($files);
|
||||
$files = array_values($files);
|
||||
foreach ($files as $file) {
|
||||
$obj = require $file;
|
||||
if ($obj instanceof WPH_Step) {
|
||||
$this->steps[$obj->id()] = $obj;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Filter the loaded steps. Return an array keyed by step id.
|
||||
* To drop a step on a particular install: unset($steps['backup']).
|
||||
*/
|
||||
$this->steps = apply_filters('wph_steps', $this->steps);
|
||||
$this->discovered = true;
|
||||
}
|
||||
|
||||
/** @return array<string,WPH_Step> */
|
||||
public function all(): array {
|
||||
return $this->steps;
|
||||
}
|
||||
|
||||
public function get(string $id): ?WPH_Step {
|
||||
return $this->steps[$id] ?? null;
|
||||
}
|
||||
}
|
||||
58
includes/recovery-bootstrap.php
Normal file
58
includes/recovery-bootstrap.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
/**
|
||||
* Step-0 bootstrap: ensure wp-site-recovery is installed + active before the
|
||||
* stepper runs (bead hc-5ix.1, references decision hc-5ix.26).
|
||||
*
|
||||
* Detection is by plugin folder slug, not by main-file path, because the slug
|
||||
* is stable across releases. If we ever publish a private update channel for
|
||||
* the recovery plugin, this is also where we'd offer "fetch latest".
|
||||
*/
|
||||
final class WPH_Recovery_Bootstrap {
|
||||
|
||||
public const RECOVERY_SLUG = 'site-recovery';
|
||||
public const RECOVERY_MAIN = 'site-recovery/site-recovery.php';
|
||||
|
||||
public static function is_installed(): bool {
|
||||
if (!function_exists('get_plugins')) {
|
||||
require_once ABSPATH . 'wp-admin/includes/plugin.php';
|
||||
}
|
||||
return array_key_exists(self::RECOVERY_MAIN, get_plugins());
|
||||
}
|
||||
|
||||
public static function is_active(): bool {
|
||||
if (!function_exists('is_plugin_active')) {
|
||||
require_once ABSPATH . 'wp-admin/includes/plugin.php';
|
||||
}
|
||||
return is_plugin_active(self::RECOVERY_MAIN);
|
||||
}
|
||||
|
||||
public static function recovery_admin_url(): string {
|
||||
return admin_url('tools.php?page=site-recovery');
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a status panel that the admin page calls from the "Before you start"
|
||||
* section. Phase-1: report state + link to manage. Phase-3 may add a
|
||||
* one-click installer from a private URL.
|
||||
*/
|
||||
public static function render_status(): void {
|
||||
$installed = self::is_installed();
|
||||
$active = $installed && self::is_active();
|
||||
?>
|
||||
<div class="wph-bootstrap-panel">
|
||||
<h3>Recovery plugin status</h3>
|
||||
<?php if ($active): ?>
|
||||
<p class="wph-ok">✓ <strong>Site Recovery</strong> is installed and active.
|
||||
<a href="<?php echo esc_url(self::recovery_admin_url()); ?>">Open recovery settings</a> to copy the URL + password into the client record.</p>
|
||||
<?php elseif ($installed): ?>
|
||||
<p class="wph-warn">⚠ <strong>Site Recovery</strong> is installed but inactive. <a href="<?php echo esc_url(admin_url('plugins.php')); ?>">Activate it</a> before starting work.</p>
|
||||
<?php else: ?>
|
||||
<p class="wph-bad">✗ <strong>Site Recovery</strong> is not installed. Install it before starting the healthcheck — it's the safety net while we work on the site.</p>
|
||||
<p class="description">Phase-1: install manually via <em>Plugins → Add New → Upload Plugin</em>. A private update channel for one-click install lands in hc-5ix.27.</p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
}
|
||||
91
includes/report.php
Normal file
91
includes/report.php
Normal file
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
/**
|
||||
* Build a Markdown report for a healthcheck session.
|
||||
* Per bead hc-5ix.7: pure Markdown, downloadable. No DB-side report storage
|
||||
* (plugin may be uninstalled at the end of the engagement).
|
||||
*/
|
||||
function wph_build_markdown_report(WPH_Session $session): string {
|
||||
$tech = get_userdata($session->technician_id());
|
||||
$tech_name = $tech ? $tech->display_name : '#' . $session->technician_id();
|
||||
|
||||
$lines = [];
|
||||
$lines[] = '# Site Healthcheck — ' . $session->site_url();
|
||||
$lines[] = '';
|
||||
$lines[] = '- **Started:** ' . date('Y-m-d H:i', $session->started_at());
|
||||
if ($session->is_finished()) {
|
||||
$lines[] = '- **Finished:** ' . date('Y-m-d H:i', (int) $session->finished_at());
|
||||
$minutes = max(1, (int) round(((int) $session->finished_at() - $session->started_at()) / 60));
|
||||
$lines[] = '- **Duration:** ~' . $minutes . ' minute(s)';
|
||||
}
|
||||
$lines[] = '- **Technician:** ' . $tech_name;
|
||||
$lines[] = '- **Site:** ' . $session->site_url();
|
||||
$lines[] = '- **WordPress:** ' . $session->wp_version();
|
||||
$lines[] = '- **PHP:** ' . $session->php_version();
|
||||
$lines[] = '- **Session ID:** ' . $session->id();
|
||||
$lines[] = '';
|
||||
|
||||
// Summary table
|
||||
$lines[] = '## Summary';
|
||||
$lines[] = '';
|
||||
$lines[] = '| Step | Status |';
|
||||
$lines[] = '|---|---|';
|
||||
foreach (WPH_Steps::instance()->all() as $step) {
|
||||
$state = $session->step_state($step->id());
|
||||
$lines[] = '| ' . $step->title() . ' | ' . wph_status_label($state['status']) . ' |';
|
||||
}
|
||||
$lines[] = '';
|
||||
|
||||
// Per-step detail
|
||||
$lines[] = '## Detail';
|
||||
$lines[] = '';
|
||||
foreach (WPH_Steps::instance()->all() as $step) {
|
||||
$state = $session->step_state($step->id());
|
||||
$lines[] = '### ' . $step->title();
|
||||
$lines[] = '';
|
||||
$lines[] = '_Status: ' . wph_status_label($state['status']) . '_';
|
||||
if ($state['updated_at']) {
|
||||
$lines[] = '_Saved: ' . date('Y-m-d H:i', $state['updated_at']) . '_';
|
||||
}
|
||||
if ($blurb = $step->blurb()) {
|
||||
$lines[] = '';
|
||||
$lines[] = $blurb;
|
||||
}
|
||||
if ($items = $step->sub_items()) {
|
||||
$lines[] = '';
|
||||
foreach ($items as $item) {
|
||||
$lines[] = '- [ ] ' . $item;
|
||||
}
|
||||
}
|
||||
if (!empty($state['notes'])) {
|
||||
$lines[] = '';
|
||||
$lines[] = '**Notes:**';
|
||||
$lines[] = '';
|
||||
foreach (preg_split('/\R/', (string) $state['notes']) as $nl) {
|
||||
$lines[] = '> ' . $nl;
|
||||
}
|
||||
}
|
||||
if ($state['status'] === WPH_Session::STATUS_BLOCKED && ($esc = $step->escalation())) {
|
||||
$lines[] = '';
|
||||
$lines[] = '> ⚠ **Escalation:** ' . $esc;
|
||||
}
|
||||
$lines[] = '';
|
||||
}
|
||||
|
||||
$lines[] = '---';
|
||||
$lines[] = '_Generated by Site Healthcheck plugin v' . WPH_VERSION . '_';
|
||||
|
||||
return implode("\n", $lines) . "\n";
|
||||
}
|
||||
|
||||
function wph_status_label(string $status): string {
|
||||
switch ($status) {
|
||||
case WPH_Session::STATUS_DONE: return '✅ Done';
|
||||
case WPH_Session::STATUS_SKIPPED: return '⏭ Skipped';
|
||||
case WPH_Session::STATUS_BLOCKED: return '🛑 Blocked';
|
||||
case WPH_Session::STATUS_NA: return '— N/A';
|
||||
case WPH_Session::STATUS_NOT_STARTED:
|
||||
default: return '◻ Not started';
|
||||
}
|
||||
}
|
||||
19
includes/steps/00-before.php
Normal file
19
includes/steps/00-before.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
return new class extends WPH_Step {
|
||||
public function id(): string { return 'before'; }
|
||||
public function title(): string { return 'Before You Start'; }
|
||||
public function blurb(): string {
|
||||
return 'Confirm prerequisites before touching anything.';
|
||||
}
|
||||
public function sub_items(): array {
|
||||
return [
|
||||
'Confirm you have admin access to the WordPress dashboard and hosting control panel',
|
||||
'Check the client record for any known issues, recent changes, or flags from the previous healthcheck',
|
||||
'Note the current WordPress version, PHP version, and active theme before touching anything',
|
||||
'Install our recovery plugin (Site Recovery) — done via the button below',
|
||||
'Make a note of the recovery path in case there is a problem',
|
||||
];
|
||||
}
|
||||
};
|
||||
20
includes/steps/10-backup.php
Normal file
20
includes/steps/10-backup.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
return new class extends WPH_Step {
|
||||
public function id(): string { return 'backup'; }
|
||||
public function title(): string { return 'Step 1 — Take a Full Backup'; }
|
||||
public function blurb(): string {
|
||||
return 'Before any work takes place, take a complete backup manually. Do not rely on the most recent automated backup.';
|
||||
}
|
||||
public function sub_items(): array {
|
||||
return [
|
||||
'Back up both the database and all files (wp-content, wp-config.php, .htaccess)',
|
||||
'Confirm the backup has completed and is accessible/downloadable',
|
||||
'Note the backup location and timestamp in the client record',
|
||||
];
|
||||
}
|
||||
public function escalation(): ?string {
|
||||
return 'If the backup fails or cannot be confirmed, stop. Do not proceed until you have a verified backup.';
|
||||
}
|
||||
};
|
||||
13
includes/steps/100-uptime.php
Normal file
13
includes/steps/100-uptime.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
return new class extends WPH_Step {
|
||||
public function id(): string { return 'uptime'; }
|
||||
public function title(): string { return 'Step 10 — Uptime and Availability'; }
|
||||
public function sub_items(): array {
|
||||
return [
|
||||
'Check uptime monitoring logs if available — note any downtime incidents since the last healthcheck and flag to client if significant',
|
||||
'Confirm the site is resolving correctly on both www and non-www',
|
||||
];
|
||||
}
|
||||
};
|
||||
19
includes/steps/110-small-fixes.php
Normal file
19
includes/steps/110-small-fixes.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
return new class extends WPH_Step {
|
||||
public function id(): string { return 'small_fixes'; }
|
||||
public function title(): string { return 'Step 11 — Small Fixes'; }
|
||||
public function blurb(): string {
|
||||
return 'Address any small issues found during the check that fall within the ~15 minute threshold. Anything longer goes on the quote list.';
|
||||
}
|
||||
public function sub_items(): array {
|
||||
return [
|
||||
'Broken internal links on key pages',
|
||||
'Missing alt text on homepage images',
|
||||
'Obvious content errors noticed in passing (broken shortcodes, missing widgets)',
|
||||
'Reactivating a deactivated-but-needed plugin',
|
||||
'Clearing accumulated spam comments',
|
||||
];
|
||||
}
|
||||
};
|
||||
16
includes/steps/120-wrap-up.php
Normal file
16
includes/steps/120-wrap-up.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
return new class extends WPH_Step {
|
||||
public function id(): string { return 'wrap_up'; }
|
||||
public function title(): string { return 'Step 12 — Wrap Up and Document'; }
|
||||
public function sub_items(): array {
|
||||
return [
|
||||
'Log everything done in the client record: versions before and after, any issues found, any fixes applied, anything flagged for follow-up',
|
||||
'Note the date, time taken, and technician',
|
||||
'If using ManageWP or WP Umbrella, generate the client report and review it before sending — make sure it accurately reflects what was done',
|
||||
'Send the client report or file it according to your process',
|
||||
'If anything was flagged that needs a separate quote or client decision, send that communication now rather than leaving it',
|
||||
];
|
||||
}
|
||||
};
|
||||
19
includes/steps/20-environment.php
Normal file
19
includes/steps/20-environment.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
return new class extends WPH_Step {
|
||||
public function id(): string { return 'environment'; }
|
||||
public function title(): string { return 'Step 2 — Environment Check'; }
|
||||
public function blurb(): string {
|
||||
return 'Before touching updates, review the hosting environment.';
|
||||
}
|
||||
public function sub_items(): array {
|
||||
return [
|
||||
'PHP version — check against WordPress requirements and plugin compatibility. Flag if below 8.1. Note if EOL.',
|
||||
'Disk usage — flag if over 80% used',
|
||||
'Error logs — check the server error log and WordPress debug log if enabled. Note any recurring errors, 500s, or deprecated function warnings',
|
||||
'wp-config.php — confirm WP_DEBUG is off on production',
|
||||
'File permissions — spot check wp-config.php (should be 640 or 600), wp-content (755), uploads (755)',
|
||||
];
|
||||
}
|
||||
};
|
||||
21
includes/steps/30-core.php
Normal file
21
includes/steps/30-core.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
return new class extends WPH_Step {
|
||||
public function id(): string { return 'core'; }
|
||||
public function title(): string { return 'Step 3 — WordPress Core Update'; }
|
||||
public function sub_items(): array {
|
||||
return [
|
||||
'Check current version against latest stable release',
|
||||
'If an update is available, apply it',
|
||||
'After update, load the site front-end and wp-admin and confirm both are functioning',
|
||||
'Check the database upgrade prompt — if WordPress prompts to upgrade the database, run it',
|
||||
'Note the version you updated from and to',
|
||||
];
|
||||
}
|
||||
public function watch_outs(): array {
|
||||
return [
|
||||
'White screen of death post-update, admin redirect loops, missing admin menu items — these usually indicate a theme or plugin conflict with the new core version.',
|
||||
];
|
||||
}
|
||||
};
|
||||
25
includes/steps/40-plugins.php
Normal file
25
includes/steps/40-plugins.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
return new class extends WPH_Step {
|
||||
public function id(): string { return 'plugins'; }
|
||||
public function title(): string { return 'Step 4 — Plugin Updates'; }
|
||||
public function sub_items(): array {
|
||||
return [
|
||||
'Go to Dashboard → Updates and review all pending plugin updates',
|
||||
'Before updating, note which plugins have updates and what versions they are moving to',
|
||||
'Update plugins one at a time if the site is complex or has many interdependencies; batch update is acceptable for straightforward sites',
|
||||
'After each update (or after a batch), check the front-end and any key functional areas (forms, checkout, membership, etc.)',
|
||||
'Check for any plugins that have been deactivated but not deleted — flag these to the client',
|
||||
];
|
||||
}
|
||||
public function watch_outs(): array {
|
||||
return [
|
||||
'WooCommerce updates — always treat these as high-risk, test checkout flow afterwards',
|
||||
'Page builder updates (Elementor, Divi, Beaver Builder) — can affect layout rendering',
|
||||
'Security plugin updates — confirm they reactivate and are still reporting clean',
|
||||
'Plugins that haven\'t been updated by their developer in over 12 months — flag as a risk',
|
||||
'Plugins showing "Update unavailable" or removed from the WordPress repository — flag immediately, these can indicate abandoned or compromised plugins',
|
||||
];
|
||||
}
|
||||
};
|
||||
20
includes/steps/50-theme.php
Normal file
20
includes/steps/50-theme.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
return new class extends WPH_Step {
|
||||
public function id(): string { return 'theme'; }
|
||||
public function title(): string { return 'Step 5 — Theme Updates'; }
|
||||
public function sub_items(): array {
|
||||
return [
|
||||
'Update the active theme if an update is available',
|
||||
'If a child theme is in use (correct practice), the parent theme can be updated safely — confirm child theme is active',
|
||||
'If no child theme is in use and the parent theme has been customised directly, do not update without flagging to the client first — the update will overwrite customisations',
|
||||
'Update inactive themes only if they are legitimate fallback themes (e.g. a default Twenty* theme). Unused themes that serve no purpose should be flagged for removal',
|
||||
];
|
||||
}
|
||||
public function watch_outs(): array {
|
||||
return [
|
||||
'Layout changes post-theme update, broken header/footer, missing custom fonts or colours — indicates customisation was done directly in the parent theme.',
|
||||
];
|
||||
}
|
||||
};
|
||||
23
includes/steps/60-visual.php
Normal file
23
includes/steps/60-visual.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
return new class extends WPH_Step {
|
||||
public function id(): string { return 'visual'; }
|
||||
public function title(): string { return 'Step 6 — Visual and Functional Check'; }
|
||||
public function blurb(): string {
|
||||
return 'Do a manual walkthrough of the site.';
|
||||
}
|
||||
public function sub_items(): array {
|
||||
return [
|
||||
'Homepage — load and visually inspect. Check for broken images, layout issues, console errors (open browser dev tools)',
|
||||
'Navigation — click through the main menu. Confirm all links resolve correctly, no 404s on primary pages',
|
||||
'Key pages — About, Contact, Services or equivalent. Check content renders correctly',
|
||||
'Contact form — submit a test entry and confirm it delivers (check spam folder if no delivery). Note which form plugin is in use',
|
||||
'If WooCommerce — check shop page loads, a product page loads, add to basket works. Do not need to complete a full test purchase every time unless flagged',
|
||||
'If membership/login — confirm login page loads and (if test credentials available) login works',
|
||||
'Mobile view — check the homepage and one internal page on a mobile viewport in browser dev tools',
|
||||
'HTTPS — confirm the padlock is showing and there are no mixed content warnings',
|
||||
'Redirects — confirm www/non-www and HTTP/HTTPS are redirecting correctly to the canonical URL',
|
||||
];
|
||||
}
|
||||
};
|
||||
16
includes/steps/70-performance.php
Normal file
16
includes/steps/70-performance.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
return new class extends WPH_Step {
|
||||
public function id(): string { return 'performance'; }
|
||||
public function title(): string { return 'Step 7 — Performance Check'; }
|
||||
public function sub_items(): array {
|
||||
return [
|
||||
'Run a quick PageSpeed Insights check on the homepage',
|
||||
'Note the scores (mobile and desktop) in the client record',
|
||||
'Flag if mobile score has dropped significantly since last check (more than 10 points)',
|
||||
'Check that caching is active — if using a caching plugin, confirm it is enabled and not throwing errors',
|
||||
'Check image sizes on the homepage — flag if uncompressed images over 500KB are being served',
|
||||
];
|
||||
}
|
||||
};
|
||||
17
includes/steps/80-security.php
Normal file
17
includes/steps/80-security.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
return new class extends WPH_Step {
|
||||
public function id(): string { return 'security'; }
|
||||
public function title(): string { return 'Step 8 — Security Check'; }
|
||||
public function sub_items(): array {
|
||||
return [
|
||||
'Confirm the SSL certificate is valid and not expiring within 30 days — flag if so',
|
||||
'Check the WordPress user list — flag any unfamiliar admin accounts',
|
||||
'Check for any recently modified core files if you have file change monitoring in place',
|
||||
'Confirm the login URL is not the default /wp-admin if security hardening was previously applied',
|
||||
'If a security plugin is active, review its dashboard for any flagged issues',
|
||||
'Check that xmlrpc.php is disabled or restricted if not in use',
|
||||
];
|
||||
}
|
||||
};
|
||||
14
includes/steps/90-database.php
Normal file
14
includes/steps/90-database.php
Normal file
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
return new class extends WPH_Step {
|
||||
public function id(): string { return 'database'; }
|
||||
public function title(): string { return 'Step 9 — Database'; }
|
||||
public function sub_items(): array {
|
||||
return [
|
||||
'Run a database optimisation (via WP-CLI: wp db optimize, or via a plugin such as WP-Optimize)',
|
||||
'Check for and remove any spam comments if comment moderation hasn\'t been keeping up',
|
||||
'Check post revisions — if excessive (thousands), note for client discussion on whether a revision limit should be set',
|
||||
];
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user