Files
wp-healthcheck/includes/admin-page.php
Steve Hanlon 8de7cad0de 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.
2026-06-11 15:49:29 +01:00

276 lines
13 KiB
PHP

<?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 &amp; 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 &amp; 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;
}