Phase 3 v2: all step automations + cross-cutting UI

Step automations (six more):
- Step 3 (Core): current vs latest WP version, db upgrade flag, auto-
  update policy, safe-update sequence reminder.
- Step 5 (Theme): parent/child detection, customisation warning when
  non-default theme without child, theme update available, inactive
  theme list.
- Step 6 (Visual): mShots screenshot URL, key-page HEAD checks (home,
  login, posts page, WC shop/cart/checkout), mixed-content scan.
- Step 7 (Performance): keyless PageSpeed Insights v5 API (mobile +
  desktop, cached 12h, skipped on .local), caching plugin detection,
  heavy-image scan (>500KB).
- Step 10 (Uptime): monitoring plugin detection (ManageWP, MainWP,
  Jetpack, WP Umbrella, UptimeRobot), www/non-www canonical check.
- Step 12 (Wrap-up): cross-step rollup — bad/warn counts, blocked
  steps, top examples for the technician's final glance.

Cross-cutting:
- Sticky step-index sidebar with status dots per step (the linear-
  stepper alternative that keeps the overview).
- 'Stop & escalate' summary card at top listing blocked steps with
  escalation guidance and notes.
- Previous-session snapshot stored on finish; diff banner on the next
  session shows new/resolved/changed counts.
- HTML report builder (printable, inline-styled). Download HTML,
  Download Markdown, Copy, and Email actions on the finish panel.
  Email uses wp_mail with text/html.

Smoke-tested on testsite: all 12 steps return findings (5/9/4 by level
on a fresh local install), admin page renders with all UI markers,
HTML report is 26KB, Markdown report is 13KB, prev-session diff banner
appears on second session.

Deferred:
- hc-5ix.27 self-hosted update channel — needs hosting infra.
- Full PDF report — would need vendoring Dompdf.
- Step 3 safe-mode update wizard — worth its own bead.
This commit is contained in:
2026-06-11 16:13:15 +01:00
parent 0d51fc3b59
commit cf3007ec37
10 changed files with 706 additions and 20 deletions

View File

@@ -8,6 +8,8 @@ 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_post_wph_refresh_checks', 'wph_handle_refresh_checks');
add_action('admin_post_wph_download_html', 'wph_handle_download_html');
add_action('admin_post_wph_email_report', 'wph_handle_email_report');
add_action('admin_enqueue_scripts', 'wph_enqueue_assets');
function wph_register_menu(): void {
@@ -68,6 +70,24 @@ function wph_inline_css(): string {
.wph-autocheck .value { color:#1d1d1f; }
.wph-autocheck .detail { color:#646970; font-size:.9em; }
.wph-checked-at { color:#646970; font-size:.85em; }
.wph-layout { display:grid; grid-template-columns: 220px 1fr; gap:1rem; }
.wph-sidebar { position:sticky; top:36px; align-self:start; max-height: calc(100vh - 60px); overflow:auto; }
.wph-sidebar .wph-card { padding:.75rem 1rem; }
.wph-sidebar h3 { margin:0 0 .4rem; font-size:.9rem; text-transform:uppercase; letter-spacing:.04em; color:#646970; }
.wph-sidebar ol { margin:0; padding:0; list-style:none; }
.wph-sidebar li { padding:.18rem 0; }
.wph-sidebar a { text-decoration:none; }
.wph-sidebar .dot { display:inline-block; width:.6rem; height:.6rem; border-radius:50%; margin-right:.4rem; background:#dcdcde; vertical-align:middle; }
.wph-sidebar .dot-done { background:#1a8917; }
.wph-sidebar .dot-skipped { background:#b07a00; }
.wph-sidebar .dot-blocked { background:#c0392b; }
.wph-sidebar .dot-n_a { background:#646970; }
.wph-diff { background:#eef4fb; border:1px solid #cfe0f3; padding:.6rem 1rem; border-radius:6px; margin:.5rem 0; font-size:.9em; }
.wph-diff strong { display:inline-block; margin-right:.4rem; }
.wph-diff .delta-new { color:#c0392b; }
.wph-diff .delta-resolved { color:#1a8917; }
.wph-diff .delta-changed { color:#b07a00; }
@media (max-width: 980px) { .wph-layout { grid-template-columns: 1fr; } .wph-sidebar { position: static; max-height: none; } }
';
}
@@ -138,10 +158,96 @@ function wph_render_active_session(WPH_Session $session): void {
<?php
WPH_Recovery_Bootstrap::render_status();
wph_render_blocked_summary($session);
wph_render_diff_summary($session);
echo '<div class="wph-layout">';
wph_render_sidebar($session);
echo '<div>';
foreach (WPH_Steps::instance()->all() as $step) {
wph_render_step_card($session, $step);
}
echo '</div></div>';
}
function wph_render_sidebar(WPH_Session $session): void {
echo '<aside class="wph-sidebar"><div class="wph-card"><h3>Steps</h3><ol>';
foreach (WPH_Steps::instance()->all() as $step) {
$state = $session->step_state($step->id());
$cls = 'dot-' . $state['status'];
printf(
'<li><span class="dot %s"></span><a href="#step-%s">%s</a></li>',
esc_attr($cls),
esc_attr($step->id()),
esc_html($step->title())
);
}
echo '</ol></div></aside>';
}
function wph_render_blocked_summary(WPH_Session $session): void {
$blocked = [];
foreach (WPH_Steps::instance()->all() as $step) {
$state = $session->step_state($step->id());
if ($state['status'] === WPH_Session::STATUS_BLOCKED) {
$blocked[] = ['step' => $step, 'state' => $state];
}
}
if (!$blocked) return;
echo '<div class="wph-card" style="border-left:4px solid #c0392b">';
echo '<h2 style="color:#721c24">Stop &amp; escalate</h2>';
echo '<p>The following steps are blocked. Resolve or escalate before continuing:</p>';
echo '<ul style="margin-left:1.25rem">';
foreach ($blocked as $b) {
$esc = $b['step']->escalation();
echo '<li><a href="#step-' . esc_attr($b['step']->id()) . '"><strong>' . esc_html($b['step']->title()) . '</strong></a>';
if ($esc) echo ' — ' . esc_html($esc);
if (!empty($b['state']['notes'])) echo '<br><em>' . esc_html($b['state']['notes']) . '</em>';
echo '</li>';
}
echo '</ul></div>';
}
function wph_render_diff_summary(WPH_Session $session): void {
$prev = WPH_Session::previous();
if (!$prev) return;
// Aggregate findings by step+id from each session
$current_idx = [];
foreach (WPH_Steps::instance()->all() as $step) {
$r = $session->get_autocheck($step->id());
if (!$r) continue;
foreach ($r['findings'] as $f) {
$current_idx[$step->id() . '|' . $f['id']] = $f;
}
}
$prev_idx = [];
foreach (WPH_Steps::instance()->all() as $step) {
$r = $prev->get_autocheck($step->id());
if (!$r) continue;
foreach ($r['findings'] as $f) {
$prev_idx[$step->id() . '|' . $f['id']] = $f;
}
}
$new = $resolved = $changed = [];
foreach ($current_idx as $k => $f) {
if (!isset($prev_idx[$k])) {
if (in_array($f['level'], ['warn', 'bad'], true)) $new[] = $f;
} elseif ($prev_idx[$k]['level'] !== $f['level'] || $prev_idx[$k]['value'] !== $f['value']) {
$changed[] = ['was' => $prev_idx[$k], 'now' => $f];
}
}
foreach ($prev_idx as $k => $f) {
if (!isset($current_idx[$k]) && in_array($f['level'], ['warn', 'bad'], true)) {
$resolved[] = $f;
}
}
if (!$new && !$resolved && !$changed) return;
echo '<div class="wph-diff"><strong>Δ vs. previous session</strong> (finished ' . esc_html(date('Y-m-d', (int) $prev->finished_at())) . ')';
if ($new) echo ' · <span class="delta-new">' . count($new) . ' new issue(s)</span>';
if ($resolved) echo ' · <span class="delta-resolved">' . count($resolved) . ' resolved</span>';
if ($changed) echo ' · <span class="delta-changed">' . count($changed) . ' changed</span>';
echo '</div>';
}
function wph_render_step_card(WPH_Session $session, WPH_Step $step): void {
@@ -207,21 +313,33 @@ function wph_render_step_card(WPH_Session $session, WPH_Step $step): void {
function wph_render_finished_panel(WPH_Session $session): void {
$report = wph_build_markdown_report($session);
$admin_email = get_option('admin_email');
?>
<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')); ?>">
<form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>" style="display:inline">
<?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>
<button class="button button-primary">Download Markdown</button>
</form>
<form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>" style="display:inline">
<?php wp_nonce_field('wph_download_html'); ?>
<input type="hidden" name="action" value="wph_download_html">
<button class="button">Download HTML</button>
</form>
<button class="button" onclick="navigator.clipboard.writeText(document.getElementById('wph-report-md').textContent);this.textContent='Copied!';setTimeout(()=>this.textContent='Copy Markdown',2000)">Copy Markdown</button>
<form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>" style="display:inline">
<?php wp_nonce_field('wph_email_report'); ?>
<input type="hidden" name="action" value="wph_email_report">
<input type="email" name="to" placeholder="recipient@example.com" value="<?php echo esc_attr($admin_email); ?>" required style="min-width:14em">
<button class="button">Email 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>
<button class="button button-link-delete">Discard</button>
</form>
</div>
</div>
@@ -329,14 +447,45 @@ function wph_handle_download_report(): void {
check_admin_referer('wph_download_report');
$session = WPH_Session::current();
if (!$session) wp_die('No session.');
$report = wph_build_markdown_report($session);
wph_stream_report($session, 'md');
}
function wph_handle_download_html(): void {
if (!current_user_can('manage_options')) wp_die('Forbidden');
check_admin_referer('wph_download_html');
$session = WPH_Session::current();
if (!$session) wp_die('No session.');
wph_stream_report($session, 'html');
}
function wph_stream_report(WPH_Session $session, string $format): void {
$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;
if ($format === 'html') {
header('Content-Type: text/html; charset=UTF-8');
header('Content-Disposition: attachment; filename="wph-report-' . $host . '-' . $stamp . '.html"');
echo wph_build_html_report($session);
} else {
header('Content-Type: text/markdown; charset=UTF-8');
header('Content-Disposition: attachment; filename="wph-report-' . $host . '-' . $stamp . '.md"');
echo wph_build_markdown_report($session);
}
exit;
}
function wph_handle_email_report(): void {
if (!current_user_can('manage_options')) wp_die('Forbidden');
check_admin_referer('wph_email_report');
$session = WPH_Session::current();
if (!$session) wp_die('No session.');
$to = isset($_POST['to']) ? sanitize_email((string) wp_unslash($_POST['to'])) : '';
if (!is_email($to)) wp_die('Bad email address.');
$host = parse_url(get_site_url(), PHP_URL_HOST) ?: 'site';
$subject = 'Site Healthcheck — ' . $host . ' — ' . date('Y-m-d', $session->started_at() ?: time());
$html = wph_build_html_report($session);
$ok = wp_mail($to, $subject, $html, ['Content-Type: text/html; charset=UTF-8']);
wp_safe_redirect(admin_url('tools.php?page=site-healthcheck&wph_mail=' . ($ok ? '1' : '0')));
exit;
}