When you’re running maintenance across several WordPress sites in sequence, a list view with text-only status doesn’t make “which site is being processed now” or “which ones are already done” easy to spot at a glance. A client put it plainly: “Make it visually obvious in the list which sites are in maintenance and which are finished.“
A colored border is the obvious move, but there are real choices to make. What colors? Where do we get the state from? When does the “done” mark go away? And — can we ship this without touching the backend? This post walks through those four calls and the minimal frontend-only implementation we landed on.
Color picking — “red flashing” was the first thing we ruled out
How do you make the running site stand out? The intuitive answer is “blinking red,” but that got cut early. Multi-site maintenance runs are long. Having something blink red somewhere on screen the whole time is a fatigue source.
We went with “a gentle blue pulse + a solid green border” instead:
- Running: blue
#2563ebborder + a soft pulsing box-shadow (2.2s ease-in-out) - Done (within 24h): green
#10b981solid border + a faint inset shadow
@keyframes site-running-pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(37, 99, 235, 0.4); }
50% { box-shadow: 0 0 0 6px rgba(37, 99, 235, 0); }
}
.site-running {
border-color: #2563eb !important;
animation: site-running-pulse 2.2s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.site-running { animation: none; } /* respect OS-level reduced motion */
}
.site-completed {
border-color: #10b981 !important;
box-shadow: inset 0 0 0 1px rgba(16, 185, 129, 0.25);
}
The prefers-reduced-motion: reduce rule stops the pulse for users who have reduced-motion enabled at the OS level (often people with vestibular sensitivity). If you’re adding motion to grab attention, this is essentially required.
Zero backend changes — reuse the existing log stream
To tell the list UI “this site is being processed now,” you need state from the server. The straightforward path would be a new /api/maintenance/status endpoint — but the maintenance logs were already being streamed to the frontend. The familiar [Site name] message format lines.
I realized that parsing those lines on the frontend would give us “the currently processing site” without touching the backend. No new endpoint.
function _detectRunningSiteFromLog(logText) {
// Logs look like "2026-05-28 09:40:13 - [INFO] - [Site name] message"
const lines = logText.split('\n');
for (let i = lines.length - 1; i >= 0; i--) { // scan from the tail
const matches = [...lines[i].matchAll(/\[([^\]]+)\]/g)];
for (const m of matches) {
const candidate = m[1];
// exclude log-level brackets
if (['INFO', 'WARNING', 'ERROR', 'DEBUG', 'TRACE'].includes(candidate)) continue;
// match against allSites by site_name
const site = allSites.find(s => s.site_name === candidate);
if (site) return site._id;
}
}
return null;
}
Scanning from the tail picks up the most recent site name. [INFO]-style log-level brackets also match [...], so we exclude them explicitly. To handle lines that contain both [INFO] and [Site name], we walk all brackets in order.
On site switch, mark the previous one done
If _runningSiteId changes, marking the previous site as done gives you the natural “running → done” flow in the UI.
function _setRunningSiteId(siteId) {
if (_runningSiteId && _runningSiteId !== siteId) {
_markSiteCompleted(_runningSiteId); // previous site = done
}
_runningSiteId = siteId;
filterSites(); // re-render the list to swap border colors
}
At this point in time, this implementation is “naive.” What I didn’t see coming was the non-monotonic order in which logs actually arrive — multiple site names interleaving back and forth, init loops that emit every site’s name up front. Those gotchas eventually pushed us into three rounds of fixes, which I wrote up separately in Detecting the running site from streaming logs — why log-order inference broke. The short version: the “switch implies previous done” rule is fragile against log ordering, and we ended up rewriting it as a marker approach (detect only one specific line type).
The thing I missed in the naive first cut was that I’d implicitly assumed logs always advance monotonically. When you bolt visualization onto an async data source (like streaming logs), you need to think about the source’s quirks from the start.
Auto-expire the done mark after 24h
If green stays forever, users lose track of “when was this site last maintained?” (especially over multi-day operations). But “vanishes on page reload” feels too flimsy.
The compromise: auto-expire after 24 hours. We save completion timestamps in localStorage and filter out entries older than 24h on load.
function _loadCompletedSites() {
try {
const raw = localStorage.getItem(KEY_COMPLETED_SITES);
if (!raw) return {};
const parsed = JSON.parse(raw);
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
return {}; // reject bad types
}
const cutoff = Date.now() - 24 * 60 * 60 * 1000;
const filtered = {};
for (const [siteId, ts] of Object.entries(parsed)) {
if (typeof ts === 'number' && ts > cutoff) { // keep only within 24h
filtered[siteId] = ts;
}
}
return filtered;
} catch (e) {
return {}; // bad JSON, no localStorage, etc.
}
}
Three layers of data defense: JSON parse failure, type mismatch, and “too old” all fall back to an empty dict. The whole thing is wrapped in try/catch so private-browsing or disabled-localStorage doesn’t crash startup. “Nice-to-have” caches should never block boot if they’re broken.
This 24h expiration ended up being the start of a longer story. We later reworked the lifetime design through several rounds — extending the TTL to 30 days, switching from “wipe all on maintenance” to “wipe only the executed sites,” and so on. That whole arc is in Three pitfalls in a dashboard cache lifetime. The 24h here was “a reasonable-looking initial value” — once we got to real use, both “I’d like it to stay longer” and “please don’t wipe everything when I maintain one site” came in from different angles.
Closing — “build the minimum, let production shake it”
Three principles from this round:
- Before adding a new API, check whether an existing stream can be reused. The log streaming was already live, and parsing it on the frontend was smaller-blast-radius and faster to ship than a new endpoint. Zero backend changes, full state visualization
- Accessibility is an automatic prerequisite when adding animation.
prefers-reduced-motion: reducesupport is mandatory if you’re using motion to draw attention. The same reasoning ruled out “blinking red” upfront - Build “the naive first cut” expecting it’ll be shaken in production. “On site switch, mark previous done” carried an implicit assumption about log ordering that turned out to be fragile. It got rewritten later as a marker approach. Shipping minimal and letting real use surface the failure modes ends up revealing the actual problems faster than upfront over-engineering
Visualization is often dismissed as “nice to have,” but in practice it quietly reduces operator fatigue. The combination of multiple sites × long maintenance windows × near-zero visual signals was, frankly, just a fatigue source on its own. One color can pull more weight than you’d expect.