Skip to content

When maintenance ends with an error, the plugin update badge disappears — designing a backend marker and frontend count sync

The WordPress maintenance tool shows a badge on each site card when there are pending plugin updates. Under normal operation, when maintenance completes successfully, the badge clears until the next dashboard scan picks up fresh data. That’s correct behavior.

But a report came in: when maintenance ends with a warning or an SSH error partway through, the badge disappears even if plugins were left unupdated. “If the update didn’t finish, I need to know — but the badge is telling me there’s nothing left to do.”

Why the badge was disappearing

Tracing the bug, the root cause was in the frontend badge-update logic, which was built on a hard-coded assumption: maintenance complete = all plugins updated = zero remaining.

When the frontend received a maintenance-complete event, it deleted the site’s entry from the badge cache (_updatesDashState). “The run is done, so the count must be zero.” That assumption.

The assumption breaks whenever maintenance ends in a “failed” state:

  • A DB backup fails before the plugin update phase is ever reached
  • An SSH disconnect interrupts mid-update
  • An HTTP check detects a site anomaly, triggers rollback, and exits with a warning

In all of these cases, a completion event still fires (the flow always ends, whether cleanly or not). The old design deleted the badge cache on that event — regardless of how many plugins were actually left behind.

The fix: a backend marker line

The redesign is: the backend outputs the real remaining count, and the frontend reads it before deciding what to show the badge. Replace the assumption with observed facts.

The backend (_check_pending_updates_ssh() in maintenance_agent.py) now emits one marker line into the streaming log whenever the post-update residual check completes:

[SiteName] Pending plugins remaining: 2 {akismet, contact-form-7}

In Japanese mode:

[サイト名] 未更新プラグイン: 2 件 {akismet, contact-form-7}

The marker includes the count and also the plugin slugs in {}. The slugs are there so the frontend can populate the badge cache with real plugin names — the same names that appear in the cross-site dashboard.

Key design constraint: the marker is only emitted if the residual check actually runs. If the run failed before reaching the plugin update phase (e.g., a DB backup error aborted early), no marker is output. The frontend treats that as “unknown” and leaves the existing badge untouched.

if plugin_count_known:
    _names_part = '{' + ', '.join(plugin_remaining_names) + '}'
    logger.info(
        f"[{site_name}] Pending plugins remaining: {plugin_remaining_count} {_names_part}"
    )

Three-path frontend logic

The frontend (templates/index.html) scans each streaming log chunk for the marker regex, records site_id → {count, names[]} in _postMaintPluginCount, and applies it at completion time via _applyPendingCountsForSites().

Three paths for each site:

State Badge action
Marker present, N = 0 Delete cache entry (badge hidden — update succeeded)
Marker present, N > 0 Upsert entry with real plugin names (badge shows count)
No marker Do nothing (leave existing badge as-is)

The “no marker” case is deliberately passive. The frontend has no way to know why the residual check never ran. “When information is missing, don’t change anything” is the conservative fallback.

function _applyPendingCountsForSites(siteIds) {
    for (const sid of siteIds) {
        if (Object.prototype.hasOwnProperty.call(_postMaintPluginCount, sid)) {
            _applyPostMaintPluginCountToBadge(sid, _postMaintPluginCount[sid]);
        }
        // no marker → leave badge alone
    }
}

Scoped plugin updates

The tool supports “update only these specific plugins” mode. In this mode, the badge should still show the total pending count for the whole site — not just the count of the selected plugins that were skipped.

If you update one of three pending plugins, two remain. The badge should say two, not “zero selected-scope plugins remaining.” The backend captures the count before applying the scope filter, so the marker always reflects site-wide truth.

Consolidating three call sites

Previously, “update the badge after maintenance” was duplicated in three places:

  1. _refreshCompletedSitesNow() — per-site immediate refresh
  2. Full-run completion event handler
  3. Scoped plugin update completion handler

Each had its own “delete the cache entry” code. The fix replaced all three with calls to _applyPendingCountsForSites(). One function, one place to change next time badge behavior needs to evolve.

Summary

Aspect Detail
Bug Hard-coded “done = zero remaining” deleted the badge cache on every completion event
Root cause Frontend judged by the completion event alone, without reading what the backend actually found
Fix Backend emits a marker line with the real count; frontend parses it and sets the badge accordingly
Three paths N=0 → hide; N>0 → show with real names; no marker → preserve existing
Design principle “If information is missing, don’t change state” — conservative fallback under uncertainty

A badge that shows “updates pending” is only useful if it reflects reality. When maintenance runs clean, clear it. When maintenance fails partway through, keep it — or better, update it with what actually remains.

For the initial badge implementation, see the pending plugin count badge design. For the cache lifetime design that controls how long badge data persists, see dashboard cache lifetime pitfalls.