<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Susumu Takahashi</title>
    <description>The latest articles on DEV Community by Susumu Takahashi (@susumun).</description>
    <link>https://dev.to/susumun</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3961116%2F87a59747-8eb8-43eb-9db6-c160d3592934.JPG</url>
      <title>DEV Community: Susumu Takahashi</title>
      <link>https://dev.to/susumun</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/susumun"/>
    <language>en</language>
    <item>
      <title>The day a user told me "the running site looks weak" — rebuilding the three-state visual hierarchy with background color</title>
      <dc:creator>Susumu Takahashi</dc:creator>
      <pubDate>Sat, 11 Jul 2026 04:48:47 +0000</pubDate>
      <link>https://dev.to/susumun/the-day-a-user-told-me-the-running-site-looks-weak-rebuilding-the-three-state-visual-hierarchy-49kp</link>
      <guid>https://dev.to/susumun/the-day-a-user-told-me-the-running-site-looks-weak-rebuilding-the-three-state-visual-hierarchy-49kp</guid>
      <description>&lt;p&gt;After we'd shipped &lt;a href="https://en.wpmm.jp/blog/maintenance-status-visualization/" rel="noopener noreferrer"&gt;the colored borders for in-maintenance and completed sites on the multi-site list&lt;/a&gt;, real-world usage produced an unexpected report: "&lt;strong&gt;the running site somehow looks weak&lt;/strong&gt;." Followed by: "&lt;strong&gt;the green border that's supposed to stick for 24 hours doesn't show up&lt;/strong&gt;."&lt;/p&gt;

&lt;p&gt;The first one was surprising. The running site has a pulsing blue border — it should be obvious. But when I looked at the screen again, yes, it &lt;em&gt;did&lt;/em&gt; look weak relative to its neighbors. The problem turned out not to be the running-state color itself, but &lt;strong&gt;a hierarchy among the three states that had quietly inverted&lt;/strong&gt;. This post walks through that visual-hierarchy collapse and rebuild, along with the "design oscillation" we'd accidentally introduced one round earlier.&lt;/p&gt;

&lt;h2&gt;
  
  
  At a glance, "running" looked weaker than "pending"
&lt;/h2&gt;

&lt;p&gt;The multi-site list has three states:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pending&lt;/strong&gt; — sites queued up to be processed next&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Running&lt;/strong&gt; — the site currently being processed&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Completed&lt;/strong&gt; — sites finished within the last 24h&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The old CSS painted them like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="c"&gt;/* Old: no background for running, faint background for pending */&lt;/span&gt;
&lt;span class="nc"&gt;.site-running&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;border-color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#2563eb&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="c"&gt;/* no background-color */&lt;/span&gt;
  &lt;span class="nl"&gt;animation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;pulse&lt;/span&gt; &lt;span class="m"&gt;2.2s&lt;/span&gt; &lt;span class="p"&gt;...;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nc"&gt;.site-pending&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;border&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1px&lt;/span&gt; &lt;span class="nb"&gt;dashed&lt;/span&gt; &lt;span class="m"&gt;#2563eb&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;background-color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;rgba&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;37&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;99&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;235&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0.02&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;"Running has a pulse, so it doesn't need a background. Pending gets a subtle fill." Sounds reasonable on paper. In actual use it produced the &lt;strong&gt;opposite&lt;/strong&gt; effect.&lt;/p&gt;

&lt;p&gt;Pending sites had a dashed border + a faint blue background — making their &lt;strong&gt;whole area&lt;/strong&gt; speak. Running sites had no fill and asserted only through their &lt;strong&gt;border line&lt;/strong&gt;. &lt;strong&gt;Filled areas are louder than lines&lt;/strong&gt;, so the visual hierarchy was actually &lt;code&gt;pending &amp;gt; running&lt;/code&gt;. "The running site looks weak" wasn't a perception bug. It was a design bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  Express the hierarchy through background alpha
&lt;/h2&gt;

&lt;p&gt;The fix consolidated everything onto a single principle: &lt;strong&gt;assign a background color to all three states, and use alpha values to encode the hierarchy&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.site-running&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;border-color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#2563eb&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;background-color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;rgba&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;37&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;99&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;235&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0.08&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c"&gt;/* loudest */&lt;/span&gt;
  &lt;span class="nl"&gt;animation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;pulse&lt;/span&gt; &lt;span class="m"&gt;2.2s&lt;/span&gt; &lt;span class="n"&gt;ease-in-out&lt;/span&gt; &lt;span class="n"&gt;infinite&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;box-shadow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;6px&lt;/span&gt; &lt;span class="n"&gt;rgba&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;37&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;99&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;235&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c"&gt;/* pulse a touch stronger */&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nc"&gt;.site-completed&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;border-color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#10b981&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;background-color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;rgba&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;16&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;185&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;129&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0.05&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c"&gt;/* middle */&lt;/span&gt;
  &lt;span class="nl"&gt;box-shadow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;inset&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;1px&lt;/span&gt; &lt;span class="n"&gt;rgba&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;16&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;185&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;129&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0.25&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nc"&gt;.site-pending&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;border&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1px&lt;/span&gt; &lt;span class="nb"&gt;dashed&lt;/span&gt; &lt;span class="m"&gt;#2563eb&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;background-color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;rgba&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;37&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;99&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;235&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0.02&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;    &lt;span class="c"&gt;/* faintest */&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The three alphas line up &lt;code&gt;0.08 &amp;gt; 0.05 &amp;gt; 0.02&lt;/code&gt;. That makes the &lt;code&gt;running &amp;gt; completed &amp;gt; pending&lt;/code&gt; visual hierarchy &lt;strong&gt;numerically guaranteed&lt;/strong&gt;. The regression test reads exactly that:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// test sketch&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;test_running_alpha_is_higher_than_pending&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getComputedAlpha&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.site-running&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getComputedAlpha&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.site-pending&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;running should be louder than pending&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's tempting to say "as long as the tones feel right, that's fine." But &lt;strong&gt;encoding the hierarchy in numeric alpha values pulls visual-design intent into a form a machine can verify&lt;/strong&gt;. Someone swapping the values later? CI catches it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The second report — "the 24h green border doesn't show up"
&lt;/h2&gt;

&lt;p&gt;The same feedback included "&lt;strong&gt;the completion green border isn't appearing&lt;/strong&gt;." I thought it was a separate bug, but it turned out to be a side effect from the previous round.&lt;/p&gt;

&lt;p&gt;As written up in &lt;a href="https://en.wpmm.jp/blog/detect-running-site-from-streaming-logs/" rel="noopener noreferrer"&gt;Detecting the running site from streaming logs&lt;/a&gt;, to fix the log-rewind false-detection bug, we'd &lt;strong&gt;abolished the "mark previous done on site switch"&lt;/strong&gt; entirely and moved completion marking to a single batch at run-end. The false positives went away — but so did &lt;strong&gt;the green flash on the previous site during normal forward progression&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Normal forward run: when the system goes from site A to site B, the user expects "site A turns green right at that moment." With our previous fix, nothing turned green until the whole run finished. The blue pulse on the running site was the only visual signal in between.&lt;/p&gt;

&lt;h3&gt;
  
  
  Fix: "mark complete only on forward moves"
&lt;/h3&gt;

&lt;p&gt;We wanted to keep the rewind defense but also bring back the mid-run green marking. The two looked mutually exclusive, but &lt;strong&gt;distinguishing forward moves via the execution-order index&lt;/strong&gt; made both possible.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;_setRunningSiteId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;siteId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;newIdx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;_runningSiteIdOrder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;indexOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;siteId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;curIdx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;_runningSiteIdIndex&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;isForwardMove&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;newIdx&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;newIdx&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;curIdx&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;isForwardMove&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;_runningSiteId&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;_runningSiteId&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;siteId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;_markSiteCompleted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;_runningSiteId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// mark previous done only on forward moves&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="c1"&gt;// Rewinds (newIdx &amp;lt; curIdx): do nothing (keep the previous-round defense)&lt;/span&gt;

  &lt;span class="nx"&gt;_runningSiteIdIndex&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;newIdx&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;_runningSiteId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;siteId&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For site IDs not in the planned order (partial runs, unexpected sites), &lt;code&gt;newIdx === -1&lt;/code&gt; falls back to "treat as forward." The result:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Normal forward run (A → B): A turns green ✅&lt;/li&gt;
&lt;li&gt;Log rewind (A reappears): ignored ✅&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both goals satisfied. "Rewind defense" and "restore forward-move state changes" had looked binary at first — adding &lt;strong&gt;one line for movement-direction detection&lt;/strong&gt; is what reconciled them. Clean convergence for what had felt like an oscillating design.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reflection — don't treat "design oscillation" as failure
&lt;/h2&gt;

&lt;p&gt;Looking at the timeline:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;a href="https://en.wpmm.jp/blog/maintenance-status-visualization/" rel="noopener noreferrer"&gt;V33 initial cut&lt;/a&gt;: mark previous done on every switch&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://en.wpmm.jp/blog/detect-running-site-from-streaming-logs/" rel="noopener noreferrer"&gt;V43's fix&lt;/a&gt;: kill switch-time marking to stop log-rewind false positives → batch-mark at run-end&lt;/li&gt;
&lt;li&gt;This round: "the 24h green disappears" side-effect surfaced → &lt;strong&gt;restore forward-only marking&lt;/strong&gt; to satisfy both&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;You could say "we should have nailed it from the start." But the other framing is that &lt;strong&gt;minimal fixes for real-world problems, then minimal fixes for the side effects of those fixes&lt;/strong&gt; is a healthy progression. Designing the complete "rewind defense + forward-detection" combo upfront — before hearing actual usage feedback — is close to impossible.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons — verify visual hierarchy with alpha values
&lt;/h2&gt;

&lt;p&gt;Three principles to keep:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Filled area beats line in visual loudness.&lt;/strong&gt; What looked like a confident pulsing border on the running state got out-shouted by a pending state with a quiet background fill. In a UI with three states, the most important state needs the strongest fill — not just the strongest border&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Express visual hierarchy with numeric alphas — then test those numbers.&lt;/strong&gt; Write &lt;code&gt;0.08 &amp;gt; 0.05 &amp;gt; 0.02&lt;/code&gt; into the CSS, assert &lt;code&gt;running &amp;gt; completed &amp;gt; pending&lt;/code&gt; in a test, and any future change that flips the values fails CI. Translate design intent into a form machines can guard&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Design oscillation" is a healthy progression, not failure.&lt;/strong&gt; Side effects from a fix get fixed by the next round. Choices that initially look binary (rewind defense vs forward-move marking) often reconcile with one extra distinguishing axis. Chasing the perfect version upfront is less responsive to real usage than oscillating toward it&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Reports of the form "&lt;strong&gt;X should stand out but doesn't&lt;/strong&gt;" usually trace back to &lt;strong&gt;the relationship between X and its neighbors&lt;/strong&gt;, not to X's color itself. When you're building a UI that puts three states side by side, deciding upfront — and encoding numerically — which one should be loudest saves the rebuilding later.&lt;/p&gt;

</description>
      <category>python</category>
      <category>webdev</category>
      <category>programming</category>
    </item>
    <item>
      <title>Visualizing maintenance status on the site list — blue pulsing border for running, green solid for done</title>
      <dc:creator>Susumu Takahashi</dc:creator>
      <pubDate>Fri, 10 Jul 2026 00:35:23 +0000</pubDate>
      <link>https://dev.to/susumun/visualizing-maintenance-status-on-the-site-list-blue-pulsing-border-for-running-green-solid-for-l5o</link>
      <guid>https://dev.to/susumun/visualizing-maintenance-status-on-the-site-list-blue-pulsing-border-for-running-green-solid-for-l5o</guid>
      <description>&lt;p&gt;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: "&lt;strong&gt;Make it visually obvious in the list which sites are in maintenance and which are finished.&lt;/strong&gt;"&lt;/p&gt;

&lt;p&gt;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 — &lt;strong&gt;can we ship this without touching the backend?&lt;/strong&gt; This post walks through those four calls and the minimal frontend-only implementation we landed on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Color picking — "red flashing" was the first thing we ruled out
&lt;/h2&gt;

&lt;p&gt;How do you make the running site stand out? The intuitive answer is "blinking red," but that got cut early. &lt;strong&gt;Multi-site maintenance runs are long&lt;/strong&gt;. Having something blink red somewhere on screen the whole time is a fatigue source.&lt;/p&gt;

&lt;p&gt;We went with "a gentle blue pulse + a solid green border" instead:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Running&lt;/strong&gt;: blue &lt;code&gt;#2563eb&lt;/code&gt; border + a soft pulsing box-shadow (2.2s ease-in-out)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Done (within 24h)&lt;/strong&gt;: green &lt;code&gt;#10b981&lt;/code&gt; solid border + a faint inset shadow
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="k"&gt;@keyframes&lt;/span&gt; &lt;span class="n"&gt;site-running-pulse&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="err"&gt;0&lt;/span&gt;&lt;span class="o"&gt;%,&lt;/span&gt; &lt;span class="err"&gt;100&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;box-shadow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="n"&gt;rgba&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;37&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;99&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;235&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0.4&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="err"&gt;50&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;box-shadow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;6px&lt;/span&gt; &lt;span class="n"&gt;rgba&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;37&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;99&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;235&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nc"&gt;.site-running&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;border-color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#2563eb&lt;/span&gt; &lt;span class="cp"&gt;!important&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;animation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;site-running-pulse&lt;/span&gt; &lt;span class="m"&gt;2.2s&lt;/span&gt; &lt;span class="n"&gt;ease-in-out&lt;/span&gt; &lt;span class="n"&gt;infinite&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;@media&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prefers-reduced-motion&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nc"&gt;.site-running&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;animation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;none&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;   &lt;span class="c"&gt;/* respect OS-level reduced motion */&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nc"&gt;.site-completed&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;border-color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#10b981&lt;/span&gt; &lt;span class="cp"&gt;!important&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;box-shadow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;inset&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;1px&lt;/span&gt; &lt;span class="n"&gt;rgba&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;16&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;185&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;129&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0.25&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;prefers-reduced-motion: reduce&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Zero backend changes — reuse the existing log stream
&lt;/h2&gt;

&lt;p&gt;To tell the list UI "this site is being processed now," you need state from the server. The straightforward path would be a new &lt;code&gt;/api/maintenance/status&lt;/code&gt; endpoint — but the maintenance logs were already being streamed to the frontend. The familiar &lt;code&gt;[Site name] message&lt;/code&gt; format lines.&lt;/p&gt;

&lt;p&gt;I realized that &lt;strong&gt;parsing those lines on the frontend&lt;/strong&gt; would give us "the currently processing site" without touching the backend. No new endpoint.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;_detectRunningSiteFromLog&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;logText&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Logs look like "2026-05-28 09:40:13 - [INFO] - [Site name] message"&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;lines&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;logText&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;  &lt;span class="c1"&gt;// scan from the tail&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;matches&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;matchAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\[([^\]]&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;)\]&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;)];&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;matches&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;candidate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
      &lt;span class="c1"&gt;// exclude log-level brackets&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;INFO&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;WARNING&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ERROR&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;DEBUG&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;TRACE&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;candidate&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="c1"&gt;// match against allSites by site_name&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;site&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;allSites&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;site_name&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;candidate&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;site&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;site&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;_id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Scanning from the tail picks up &lt;strong&gt;the most recent site name&lt;/strong&gt;. &lt;code&gt;[INFO]&lt;/code&gt;-style log-level brackets also match &lt;code&gt;[...]&lt;/code&gt;, so we exclude them explicitly. To handle lines that contain both &lt;code&gt;[INFO]&lt;/code&gt; and &lt;code&gt;[Site name]&lt;/code&gt;, we walk all brackets in order.&lt;/p&gt;

&lt;h2&gt;
  
  
  On site switch, mark the previous one done
&lt;/h2&gt;

&lt;p&gt;If &lt;code&gt;_runningSiteId&lt;/code&gt; changes, marking the previous site as done gives you the natural "running → done" flow in the UI.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;_setRunningSiteId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;siteId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;_runningSiteId&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;_runningSiteId&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;siteId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;_markSiteCompleted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;_runningSiteId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// previous site = done&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;_runningSiteId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;siteId&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nf"&gt;filterSites&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;                          &lt;span class="c1"&gt;// re-render the list to swap border colors&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;At this point in time, this implementation is "naive."&lt;/strong&gt; 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 &lt;strong&gt;three rounds of fixes&lt;/strong&gt;, which I wrote up separately in &lt;a href="https://en.wpmm.jp/blog/detect-running-site-from-streaming-logs/" rel="noopener noreferrer"&gt;Detecting the running site from streaming logs — why log-order inference broke&lt;/a&gt;. The short version: the "switch implies previous done" rule is fragile against log ordering, and we ended up rewriting it as a &lt;strong&gt;marker approach&lt;/strong&gt; (detect only one specific line type).&lt;/p&gt;

&lt;p&gt;The thing I missed in the naive first cut was that &lt;strong&gt;I'd implicitly assumed logs always advance monotonically&lt;/strong&gt;. When you bolt visualization onto an async data source (like streaming logs), you need to think about the source's quirks from the start.&lt;/p&gt;

&lt;h2&gt;
  
  
  Auto-expire the done mark after 24h
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The compromise: &lt;strong&gt;auto-expire after 24 hours&lt;/strong&gt;. We save completion timestamps in &lt;code&gt;localStorage&lt;/code&gt; and filter out entries older than 24h on load.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;_loadCompletedSites&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;KEY_COMPLETED_SITES&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{};&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;parsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;parsed&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;object&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;parsed&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{};&lt;/span&gt;                                       &lt;span class="c1"&gt;// reject bad types&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cutoff&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;filtered&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{};&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;siteId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ts&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;ts&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;number&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;ts&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;cutoff&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;     &lt;span class="c1"&gt;// keep only within 24h&lt;/span&gt;
        &lt;span class="nx"&gt;filtered&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;siteId&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ts&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;filtered&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{};&lt;/span&gt;                                         &lt;span class="c1"&gt;// bad JSON, no localStorage, etc.&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three layers of data defense: &lt;strong&gt;JSON parse failure, type mismatch, and "too old"&lt;/strong&gt; all fall back to an empty dict. The whole thing is wrapped in &lt;code&gt;try/catch&lt;/code&gt; so private-browsing or disabled-&lt;code&gt;localStorage&lt;/code&gt; doesn't crash startup. "Nice-to-have" caches should never block boot if they're broken.&lt;/p&gt;

&lt;p&gt;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 &lt;a href="https://en.wpmm.jp/blog/dashboard-cache-lifetime-pitfalls/" rel="noopener noreferrer"&gt;Three pitfalls in a dashboard cache lifetime&lt;/a&gt;. 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing — "build the minimum, let production shake it"
&lt;/h2&gt;

&lt;p&gt;Three principles from this round:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Before adding a new API, check whether an existing stream can be reused.&lt;/strong&gt; 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&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Accessibility is an automatic prerequisite when adding animation.&lt;/strong&gt; &lt;code&gt;prefers-reduced-motion: reduce&lt;/code&gt; support is mandatory if you're using motion to draw attention. The same reasoning ruled out "blinking red" upfront&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build "the naive first cut" expecting it'll be shaken in production.&lt;/strong&gt; "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 &lt;strong&gt;revealing the actual problems faster than upfront over-engineering&lt;/strong&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Visualization is often dismissed as "nice to have," but in practice it &lt;strong&gt;quietly reduces operator fatigue&lt;/strong&gt;. 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.&lt;/p&gt;

</description>
      <category>python</category>
      <category>webdev</category>
      <category>programming</category>
    </item>
    <item>
      <title>Three pitfalls in a dashboard cache lifetime — boot-time restore, TTL, and partial invalidation</title>
      <dc:creator>Susumu Takahashi</dc:creator>
      <pubDate>Thu, 09 Jul 2026 01:43:04 +0000</pubDate>
      <link>https://dev.to/susumun/three-pitfalls-in-a-dashboard-cache-lifetime-boot-time-restore-ttl-and-partial-invalidation-42pp</link>
      <guid>https://dev.to/susumun/three-pitfalls-in-a-dashboard-cache-lifetime-boot-time-restore-ttl-and-partial-invalidation-42pp</guid>
      <description>&lt;p&gt;We added a &lt;a href="https://en.wpmm.jp/blog/cache-first-dashboard-ux/" rel="noopener noreferrer"&gt;cache-first design to the cross-site updates dashboard&lt;/a&gt;, then wired &lt;a href="https://en.wpmm.jp/blog/pending-plugin-count-badge/" rel="noopener noreferrer"&gt;the site-list badge&lt;/a&gt; to read from the same cache. Both ships went well.&lt;/p&gt;

&lt;p&gt;But as soon as it hit real usage, we hit &lt;strong&gt;three distinct pitfalls around the cache's "lifetime"&lt;/strong&gt; in quick succession: "the badges all disappear on refresh," "the 7-day TTL is too short," "running maintenance on one site clears all the badges." Each is a small spec call in isolation, but from the user's side, &lt;strong&gt;all three feel like the same symptom: "badges aren't sticking around the way I expect."&lt;/strong&gt; This post walks through each fix and what we got wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitfall 1 — All badges disappear on refresh
&lt;/h2&gt;

&lt;p&gt;The first report was blunt: "&lt;strong&gt;Is it intentional that the pending-plugin badges all vanish when I refresh?&lt;/strong&gt;"&lt;/p&gt;

&lt;p&gt;Not intentional. It was a bug. &lt;code&gt;_updatesDashState&lt;/code&gt; was designed as a localStorage-backed persistent cache — but &lt;strong&gt;the restoration call only ran the moment the dashboard was opened&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Old: restored only when the dashboard opens&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;openUpdatesDashboard&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;_loadUpdatesDashStateFromLocalStorage&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;   &lt;span class="c1"&gt;// ← the only place this got called&lt;/span&gt;
  &lt;span class="c1"&gt;// ... render ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Through this path:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;App boot: in-memory &lt;code&gt;_updatesDashState&lt;/code&gt; sits at its initial empty value&lt;/li&gt;
&lt;li&gt;Site-list render: &lt;code&gt;_getPendingPluginCountForSite&lt;/code&gt; sees the empty array, returns &lt;code&gt;null&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Result: &lt;strong&gt;every badge hidden&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Restoration only happened when the user explicitly opened the dashboard. In the original designer's head, "the cache belongs to the dashboard" — but &lt;strong&gt;a later consumer (the site-list badge) rendered before the dashboard ever opened&lt;/strong&gt;, and that path wasn't accounted for.&lt;/p&gt;

&lt;p&gt;The fix was one line, in &lt;code&gt;DOMContentLoaded&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;DOMContentLoaded&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;_completedSiteTimestamps&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_loadCompletedSites&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;_loadUpdatesDashStateFromLocalStorage&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;  &lt;span class="c1"&gt;// ← restore at boot, always&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Don't block startup if localStorage is unavailable&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="c1"&gt;// ... rest of init&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;try/catch&lt;/code&gt; is there because some browser environments (private mode, etc.) disallow &lt;code&gt;localStorage&lt;/code&gt;. &lt;strong&gt;Restoration is "nice to have" — if it fails, don't take down the app.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Lesson 1 — Tie cache restoration to the app's lifecycle, not its consumer
&lt;/h3&gt;

&lt;p&gt;Putting restoration at "the first screen that uses the cache" was the mistake. &lt;strong&gt;If another screen might consume the same cache earlier, restoration belongs at app boot, once.&lt;/strong&gt; Adding more readers later doesn't require touching the restoration logic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitfall 2 — A 7-day TTL was too short
&lt;/h2&gt;

&lt;p&gt;The next report: "&lt;strong&gt;Does the pending-plugin display in the list disappear after 7 days?&lt;/strong&gt;"&lt;/p&gt;

&lt;p&gt;The 7-day figure was a feel-based guess at implementation time. We'd assumed a workflow of "open the dashboard regularly." But real usage looks more like "&lt;strong&gt;glance at the dashboard occasionally, then work site-by-site referring to the badges&lt;/strong&gt;." Under that pattern, 7 days expires before the next visit.&lt;/p&gt;

&lt;p&gt;The client's ask was clean: "&lt;strong&gt;I want it visible until the next check.&lt;/strong&gt;" So we bumped TTL to 30 days:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;_DASH_CACHE_MAX_AGE_MS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// 7→30 days&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But 30 days is long, so without showing &lt;strong&gt;how old the visible number is&lt;/strong&gt;, users risk treating stale data as fresh. We added an age suffix to the tooltip:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;_formatPendingPluginCountAgeSuffix&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;loaded&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;_updatesDashState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;loadedAt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;loaded&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;days&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_daysSinceTimestamp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;loaded&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;_formatDaysAgoText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;days&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;（最終取得: ${days}）&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="c1"&gt;// → e.g. "(last fetched: 5 days ago)"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;_formatDaysAgoText&lt;/code&gt; is the helper from &lt;a href="https://en.wpmm.jp/blog/days-since-last-maintenance-badge/" rel="noopener noreferrer"&gt;the elapsed-days badge&lt;/a&gt;, reused as-is. &lt;strong&gt;Keeping the "N days ago" phrasing in one place&lt;/strong&gt; makes small reuses like this easy.&lt;/p&gt;

&lt;h3&gt;
  
  
  Lesson 2 — Pair longer TTLs with explicit freshness
&lt;/h3&gt;

&lt;p&gt;Stretching a TTL increases the risk of staring at stale data. Rather than cutting it shorter to compensate, &lt;strong&gt;show the user how stale and let them decide&lt;/strong&gt;. 30 days + "5 days ago" is more informative than a hard 7-day cutoff.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitfall 3 — One site's maintenance wiped every badge
&lt;/h2&gt;

&lt;p&gt;The third pitfall surfaced in real usage: "&lt;strong&gt;I ran maintenance on one site, but the badges for other sites are gone too. Is that right?&lt;/strong&gt;"&lt;/p&gt;

&lt;p&gt;The old behavior was "clear the entire dashboard cache on a successful maintenance run":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Old: wipe everything&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;_invalidateUpdatesDashCache&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;_updatesDashState&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;sites&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="na"&gt;total_pending_count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;loadedAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="nf"&gt;_saveUpdatesDashStateToLocalStorage&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This was the same trap &lt;a href="https://en.wpmm.jp/blog/detect-running-site-from-streaming-logs/" rel="noopener noreferrer"&gt;we hit in the streaming-log story&lt;/a&gt;: &lt;strong&gt;if a state clear isn't scoped to the execution, it sweeps up unrelated state&lt;/strong&gt;. Maintaining one site shouldn't blow away the other nine sites' badges; that's worse than not clearing at all.&lt;/p&gt;

&lt;p&gt;The fix takes a list of executed site IDs and does a &lt;strong&gt;partial&lt;/strong&gt; invalidation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;_invalidatePendingPluginCacheForSiteIds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;siteIds&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;siteIds&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;siteIds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;idSet&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;siteIds&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;_updatesDashState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sites&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;_updatesDashState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sites&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;idSet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;site_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;_updatesDashState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;total_pending_count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="nx"&gt;_updatesDashState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sites&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;sum&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;plugins&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;_saveUpdatesDashStateToLocalStorage&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="c1"&gt;// Leave loadedAt alone — this is a partial update, the "fetch time" is still valid&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The caller pulls the executed &lt;code&gt;_id&lt;/code&gt;s out of &lt;code&gt;payload.sites&lt;/code&gt; and hands them over. When &lt;code&gt;payload.sites === null&lt;/code&gt; (a full-site run), we fall back to full clear — so we didn't &lt;em&gt;remove&lt;/em&gt; full-clear, we &lt;strong&gt;split "explicit clear-all" from "clear-only-these"&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The old &lt;code&gt;_invalidateUpdatesDashCache()&lt;/code&gt; is kept around (not removed). There may be a legitimate "wipe everything" caller in the future (a manual "clear cache" button, say), and keeping the explicit option around is safer than deleting it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Lesson 3 — Always scope state-clears to the execution
&lt;/h3&gt;

&lt;p&gt;Same shape as the &lt;a href="https://en.wpmm.jp/blog/detect-running-site-from-streaming-logs/" rel="noopener noreferrer"&gt;running-site detection trap&lt;/a&gt;. When you write "on maintenance start, reset the cache" — be very careful that the implicit scope isn't "everything." If execution scope varies, take it as an argument and limit the clear to that scope. Small effort, big effect.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing — cache-lifetime design comes down to "your reader's lifecycle" and "your clear's scope"
&lt;/h2&gt;

&lt;p&gt;The three pitfalls came from separate reports, but the underlying principles converge:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Restore caches at app boot, period.&lt;/strong&gt; Tying restoration to the "first consumer" silently breaks when a second consumer is added. &lt;code&gt;DOMContentLoaded&lt;/code&gt; + a one-time restore with &lt;code&gt;try/catch&lt;/code&gt; defense is the safe default&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pair long TTLs with explicit freshness signals.&lt;/strong&gt; 30 days + a "fetched N days ago" tooltip carries more information than a hard 7-day cutoff. Showing the staleness lets the user decide&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scope every state-clear to the execution.&lt;/strong&gt; Don't default to "wipe all." Take the execution-scope ID list as an argument and clear only that. Keep the explicit "wipe all" available as a separate function for cases that legitimately need it&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Caches are about speed, but once you're in production, &lt;strong&gt;the lifetime design ends up determining the UX more than the speed gain does&lt;/strong&gt;. Next time we add a cache, the three patterns above — boot-time restore, freshness visualization, scoped invalidation — go in as a template from the start.&lt;/p&gt;

</description>
      <category>python</category>
      <category>webdev</category>
      <category>programming</category>
    </item>
    <item>
      <title>A pending-plugin-count badge on the 🔌 button — reusing the dashboard cache instead of doubling state</title>
      <dc:creator>Susumu Takahashi</dc:creator>
      <pubDate>Wed, 08 Jul 2026 00:40:22 +0000</pubDate>
      <link>https://dev.to/susumun/a-pending-plugin-count-badge-on-the-button-reusing-the-dashboard-cache-instead-of-doubling-state-20k8</link>
      <guid>https://dev.to/susumun/a-pending-plugin-count-badge-on-the-button-reusing-the-dashboard-cache-instead-of-doubling-state-20k8</guid>
      <description>&lt;p&gt;A client asked: "&lt;strong&gt;After I run a cross-site update check, can each site show — right in the site list — how many plugin updates are still pending?&lt;/strong&gt;" Visually the answer was obvious: a small red badge on the top-right of the 🔌 plugins button, like an unread-notification count. Easy to specify.&lt;/p&gt;

&lt;p&gt;The harder question was &lt;strong&gt;where the data comes from&lt;/strong&gt;. We could have added a fresh API endpoint and a new cache to hold "pending count per site." But doing that would have &lt;strong&gt;doubled state management&lt;/strong&gt;, and we already had a cache that knew this. We routed through the existing one. Here's the reasoning behind that decision.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reuse the dashboard cache as the data source
&lt;/h2&gt;

&lt;p&gt;The cross-site updates dashboard (the one we wrote about in &lt;a href="https://en.wpmm.jp/blog/cache-first-dashboard-ux/" rel="noopener noreferrer"&gt;killing the 24.5-second silence with a cache-first design&lt;/a&gt;) already kept each site's pending plugins in a localStorage-backed state called &lt;code&gt;_updatesDashState&lt;/code&gt;. Its shape:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;_updatesDashState&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;sites&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;site_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;abc...&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;plugins&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="p"&gt;{...},&lt;/span&gt; &lt;span class="p"&gt;{...},&lt;/span&gt; &lt;span class="p"&gt;{...}&lt;/span&gt; &lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;site_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;def...&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;plugins&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;total_pending_count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;loadedAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1748600000000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look up by &lt;code&gt;site_id&lt;/code&gt;, take &lt;code&gt;plugins.length&lt;/code&gt;, and you have the badge's number. &lt;strong&gt;No new API, no new cache.&lt;/strong&gt; The data that powers the cross-site dashboard is also the data that powers the site-list badge.&lt;/p&gt;

&lt;p&gt;The win of not adding state is quiet but real:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;When a maintenance run invalidates &lt;code&gt;_updatesDashState&lt;/code&gt;, the badge &lt;strong&gt;disappears automatically&lt;/strong&gt; (no sync code to write)&lt;/li&gt;
&lt;li&gt;The TTL (originally 7 days; later extended to &lt;a href="https://en.wpmm.jp/blog/dashboard-cache-lifetime-pitfalls/" rel="noopener noreferrer"&gt;30 days with partial invalidation&lt;/a&gt;) inherits from the existing design&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The badge and the underlying count can't drift&lt;/strong&gt; — there's no second copy to fall out of step&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There's always a temptation to spin up a new endpoint for a new UI element. The rule we settled on: if the existing state answers it, don't add more.&lt;/p&gt;

&lt;h2&gt;
  
  
  Attaching the badge — consolidate into helpers
&lt;/h2&gt;

&lt;p&gt;Both the list view and grid view need the same badge on the 🔌 button, so the logic lives in helpers.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;_getPendingPluginCountForSite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;siteId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// null = unchecked (badge not shown); a number = the actual pending count&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;_updatesDashState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sites&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;site_id&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;siteId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;plugins&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;_attachPendingPluginCountBadge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pluginsBtn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;siteId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_getPendingPluginCountForSite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;siteId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;count&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;count&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;        &lt;span class="c1"&gt;// reduce noise&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;display&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;count&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;99&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;99+&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;count&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;badge&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;span&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;badge&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;className&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;plugin-count-badge&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;badge&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;display&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;badge&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_formatPendingPluginCountTooltip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;count&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;pluginsBtn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;appendChild&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;badge&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A small but easy-to-miss detail: the button itself needs &lt;code&gt;position: relative;&lt;/code&gt; so the absolutely-positioned badge doesn't fly off the parent. Without it, the badge ends up in a corner of the screen.&lt;/p&gt;

&lt;h2&gt;
  
  
  Thresholds that cut noise
&lt;/h2&gt;

&lt;p&gt;If we showed a badge on every site — including "zero pending" and "never checked" — the site list would turn into a sea of icons. The two cuts we made:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Don't show 0&lt;/strong&gt; — a healthy site has no badge. Surfacing danger signals beats surfacing reassurance for an inventory view&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't show "unchecked"&lt;/strong&gt; — return &lt;code&gt;null&lt;/code&gt;, no badge attached. "I don't know the count" and "the count is zero" mean different things, and the UI should preserve that distinction&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For three-digit counts, the layout breaks unless you cap. We use &lt;code&gt;99+&lt;/code&gt;, not &lt;code&gt;100+&lt;/code&gt; — it keeps the badge width consistent across rows, and it's the convention readers already know from GitHub-style notification counters.&lt;/p&gt;

&lt;h2&gt;
  
  
  Per-site checks update the badge too
&lt;/h2&gt;

&lt;p&gt;Pulling data only from the cross-site dashboard misses one path: "&lt;strong&gt;I clicked the 🔌 on a single site, looked inside, and now I want the badge updated&lt;/strong&gt;." The request was "either path should refresh the badge," so the per-site check writes back to the same cache.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;_updatePendingPluginCacheForSite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;site&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;plugins&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// From /api/site_plugins, keep only "update available" — exclude must-use / dropin&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pending&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;plugins&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;update&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;available&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
    &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;must-use&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;dropin&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sites&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;_updatesDashState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sites&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;idx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;sites&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findIndex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;site_id&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;site&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pending&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;idx&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;sites&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;splice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;idx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;            &lt;span class="c1"&gt;// drop the entry entirely when count hits zero&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pending&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;site_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;site&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;plugins&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;pending&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;idx&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;sites&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;idx&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="nx"&gt;sites&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;_updatesDashState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;total_pending_count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="nx"&gt;sites&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;sum&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;plugins&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;_saveUpdatesDashStateToLocalStorage&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nf"&gt;filterSites&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;                      &lt;span class="c1"&gt;// immediately re-render the site list&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;must-use&lt;/code&gt; and &lt;code&gt;dropin&lt;/code&gt; exclusions matter — those plugins don't go through the standard WordPress update flow, and counting them would create a "badge says update available, but the update button does nothing" bug.&lt;/p&gt;

&lt;p&gt;The trailing &lt;code&gt;filterSites()&lt;/code&gt; re-renders the site list right then, so the new count is visible before the user even closes the modal. That "yep, it took" feedback is what makes the path feel solid.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons — "if existing state answers it, don't add more"
&lt;/h2&gt;

&lt;p&gt;Three principles to take away:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A new UI element doesn't necessarily need a new data source.&lt;/strong&gt; The badge displayed "pending count," but that count was already known to the cross-site dashboard. Before adding a new API or cache, check whether existing state can answer the question. If it can, don't add more — you'll save yourself sync code and drift risk&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Express noise reduction as thresholds.&lt;/strong&gt; Hiding the zero and unchecked states means "badge present = needs attention." Showing the badge on every site reduces it to background noise&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make both write paths converge on the same cache.&lt;/strong&gt; Whether the user updates via the cross-site dashboard or via a per-site check, the same cache is written. That way "I prefer the dashboard" and "I prefer per-site" workflows both end up with correct badges&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A site-list badge looks like a small feature, but underneath, the lessons that pay off are about &lt;strong&gt;not adding state when you don't have to&lt;/strong&gt;, &lt;strong&gt;picking thresholds that mean something&lt;/strong&gt;, and &lt;strong&gt;converging your update paths&lt;/strong&gt;. Next time something small needs adding, the cheapest move is to ask: is the answer already in something we have?&lt;/p&gt;

&lt;p&gt;One more issue surfaced later: when maintenance ends with an error, the badge disappears even if plugins were left unupdated — because the completion handler assumed "done = zero remaining" and deleted the cache entry. How that assumption broke and the redesign using a backend marker line to sync real counts to the frontend is in &lt;a href="https://en.wpmm.jp/blog/plugin-badge-real-count-after-maintenance/" rel="noopener noreferrer"&gt;When maintenance ends with an error, the plugin update badge disappears&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>php</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>A "days since last maintenance" badge — color-coding staleness across many sites</title>
      <dc:creator>Susumu Takahashi</dc:creator>
      <pubDate>Tue, 07 Jul 2026 00:27:11 +0000</pubDate>
      <link>https://dev.to/susumun/a-days-since-last-maintenance-badge-color-coding-staleness-across-many-sites-3ikb</link>
      <guid>https://dev.to/susumun/a-days-since-last-maintenance-badge-color-coding-staleness-across-many-sites-3ikb</guid>
      <description>&lt;p&gt;When you maintain a number of WordPress sites, showing the "last maintenance date" in the site list is the obvious move. A column of dates like &lt;code&gt;2026-05-21&lt;/code&gt;. But in actual use, that alone falls short.&lt;/p&gt;

&lt;p&gt;A client put it well: "Besides the last maintenance date, it'd help to also show &lt;strong&gt;how many days have passed&lt;/strong&gt;. And it'd be even better if the color changed at 15 / 30 / 60 days so I can see the &lt;strong&gt;risk level&lt;/strong&gt;." This post walks through that step — from "absolute date" to "relative elapsed days + color" — including the small design details.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a date alone isn't enough
&lt;/h2&gt;

&lt;p&gt;An absolute date like &lt;code&gt;2026-05-21&lt;/code&gt; is precise, but it pushes &lt;strong&gt;the "difference from today" calculation onto the user's head&lt;/strong&gt;. Fine for five sites; as the managed set grows, reading "which ones are getting neglected" off a column of dates gets hard.&lt;/p&gt;

&lt;p&gt;The point of a maintenance inventory is to grasp &lt;strong&gt;which sites need attention&lt;/strong&gt; at a glance. If so, what you should surface is less the absolute date and more the relative quantity — "&lt;strong&gt;how many days since the last maintenance&lt;/strong&gt;" — and ideally let color convey "how many days until it's risky." The client's request landed exactly on this "absolute → relative + risk" shift.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four-tier color coding
&lt;/h2&gt;

&lt;p&gt;We went with four tiers by elapsed days. A small badge like &lt;code&gt;(15 days ago)&lt;/code&gt; sits right after the last-maintenance date, and the color changes by threshold.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Elapsed&lt;/th&gt;
&lt;th&gt;tier&lt;/th&gt;
&lt;th&gt;color&lt;/th&gt;
&lt;th&gt;meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0–14 days&lt;/td&gt;
&lt;td&gt;fresh&lt;/td&gt;
&lt;td&gt;green&lt;/td&gt;
&lt;td&gt;recently maintained, fine&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;15–29 days&lt;/td&gt;
&lt;td&gt;normal&lt;/td&gt;
&lt;td&gt;gray&lt;/td&gt;
&lt;td&gt;standard&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;30–59 days&lt;/td&gt;
&lt;td&gt;warn&lt;/td&gt;
&lt;td&gt;amber&lt;/td&gt;
&lt;td&gt;needs attention&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;60+ days&lt;/td&gt;
&lt;td&gt;danger&lt;/td&gt;
&lt;td&gt;red&lt;/td&gt;
&lt;td&gt;needs action&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;green → gray → amber → red — just scrolling the list, "lots of red here" or "a cluster of sites I haven't touched lately" jumps out visually. The badge also gets a hover tooltip ("N days since last maintenance") to back up the number's meaning.&lt;/p&gt;

&lt;h2&gt;
  
  
  Consolidate into helper functions
&lt;/h2&gt;

&lt;p&gt;The display logic is called from multiple places (list view, grid view), so scattering inline day calculations would be a DRY violation. We consolidated into a set of helpers.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Returns elapsed days. null for empty/invalid input, future dates clamped to 0&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;_daysSinceLastRun&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lastRunStr&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;lastRunStr&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="c1"&gt;// Safari compat: normalize "YYYY-MM-DD" to "YYYY/MM/DD" before Date&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;normalized&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;lastRunStr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/-/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;normalized&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;isNaN&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getTime&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;diffMs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getTime&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;days&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;diffMs&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;86400000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;days&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;days&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// clamp future dates to 0 (clock-skew defense)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Returns a color object by threshold&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;_daysAgoStyle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;days&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;days&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;14&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;tier&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;fresh&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="na"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#15803d&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;bg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#dcfce7&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;days&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;29&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;tier&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;normal&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#4b5563&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;bg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#f3f4f6&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;days&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;59&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;tier&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;warn&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="na"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#92400e&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;bg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#fef3c7&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;                 &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;tier&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;danger&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#7f1d1d&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;bg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#fee2e2&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three "small but worthwhile" defenses are baked in.&lt;/p&gt;

&lt;h3&gt;
  
  
  Safari-compatible date normalization
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;new Date("2026-05-21")&lt;/code&gt; works in Chrome, but &lt;strong&gt;Safari can return &lt;code&gt;Invalid Date&lt;/code&gt;&lt;/strong&gt; depending on environment — a known quirk where Safari doesn't reliably parse hyphenated &lt;code&gt;YYYY-MM-DD&lt;/code&gt;. Replacing hyphens with slashes to &lt;code&gt;2026/05/21&lt;/code&gt; before passing to &lt;code&gt;Date&lt;/code&gt; parses stably in Safari too. If you're not pulling in a date library, normalizing up front is the safe path for cross-browser date parsing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Future-date clamping (clock-skew defense)
&lt;/h3&gt;

&lt;p&gt;If the server and client clocks disagree, or a maintenance date somehow lands in the future, elapsed days go &lt;strong&gt;negative&lt;/strong&gt;. A &lt;code&gt;(-3 days ago)&lt;/code&gt; display is nonsense, so &lt;code&gt;days &amp;lt; 0 ? 0 : days&lt;/code&gt; clamps to zero — treating it as "today." An edge case, but the kind that screams "this is broken" the moment it shows.&lt;/p&gt;

&lt;h3&gt;
  
  
  i18n fallback
&lt;/h3&gt;

&lt;p&gt;The elapsed-days text generates &lt;code&gt;Today&lt;/code&gt; / &lt;code&gt;1 day ago&lt;/code&gt; / &lt;code&gt;N days ago&lt;/code&gt; through i18n. The trap here: &lt;strong&gt;if rendering runs before the i18n keys load, raw keys like &lt;code&gt;site_list.days_ago_n&lt;/code&gt; show up on screen verbatim.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;_formatDaysAgoText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;days&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Reference _i18n directly, fall back to English if unregistered&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;days&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;_i18n&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;days_ago_today&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Today&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;days&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;_i18n&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;days_ago_one&lt;/span&gt;   &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;1 day ago&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tmpl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;_i18n&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;days_ago_n&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;${days} days ago&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;tmpl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;${days}&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;days&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;?? 'fallback'&lt;/code&gt; guarantees meaningful text regardless of i18n load timing. The lesson: any dynamically generated text needs a fallback that accounts for the i18n initialization race.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lock the color thresholds with regression tests
&lt;/h2&gt;

&lt;p&gt;The thresholds (14 / 29 / 59 / 60+) &lt;em&gt;are&lt;/em&gt; the spec, so we locked them with regression tests. &lt;code&gt;tests/test_days_since_last_run.py&lt;/code&gt; has 18 cases — whether the four-tier boundaries (14 vs 15, 29 vs 30, 59 vs 60) return the right tier, the null / NaN / future-clamp defenses, Safari normalization, that both list and grid views call the helpers, and that no old inline calculation remains (DRY-violation detection).&lt;/p&gt;

&lt;p&gt;The boundary tests matter most. Off-by-one errors like "is 30 days warn or normal" are easy to miss reading the spec alone, and without test-locking they drift silently in a future refactor.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing — "relative quantity + risk" beats "absolute value"
&lt;/h2&gt;

&lt;p&gt;Three principles from this round:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Inventory UIs should show relative quantity over absolute value.&lt;/strong&gt; "(15 days ago)" offloads the user's mental math better than "last maintained 2026-05-21." Add color for risk and the sites that need attention rise out of the list as you scroll&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build cross-browser defenses into date parsing from the start.&lt;/strong&gt; Safari's &lt;code&gt;YYYY-MM-DD&lt;/code&gt; issue and future-date clamping are the kind of thing that, addressed after the fact, leaves you debugging "can't reproduce, environment-dependent." Normalize and clamp from day one&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Always attach an i18n fallback to dynamically generated text.&lt;/strong&gt; The "raw key shows on screen during the i18n load race" bug is structurally prevented by a &lt;code&gt;?? 'fallback'&lt;/code&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Inside the modest feature "show a date" sat several quiet design decisions — relative-time presentation, cross-browser date parsing, the i18n race, boundary tests. For an admin screen spanning many sites, translating "when" into "how many days until it's risky" lowers the effort of spotting what needs attention and makes it easier to head off missed maintenance.&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>php</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Detecting the running site from streaming logs — why log-order inference broke, and how one marker fixed it</title>
      <dc:creator>Susumu Takahashi</dc:creator>
      <pubDate>Mon, 06 Jul 2026 01:40:06 +0000</pubDate>
      <link>https://dev.to/susumun/detecting-the-running-site-from-streaming-logs-why-log-order-inference-broke-and-how-one-marker-5be0</link>
      <guid>https://dev.to/susumun/detecting-the-running-site-from-streaming-logs-why-log-order-inference-broke-and-how-one-marker-5be0</guid>
      <description>&lt;p&gt;In a screen that runs maintenance across multiple sites in sequence, you want to show progress live: a blue border on "the site being processed now," a green border on "completed sites." Common UI.&lt;/p&gt;

&lt;p&gt;The approach we reached for first was "&lt;strong&gt;watch the streaming logs the backend emits, and infer which site is being processed&lt;/strong&gt;." It turned out to be surprisingly tricky — three rounds of fixes before it stabilized. This post records that trial-and-error as a design log.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first design — "site switched, so mark the previous one done"
&lt;/h2&gt;

&lt;p&gt;Logs arrive per-site as lines like &lt;code&gt;[Site name] Starting maintenance&lt;/code&gt;. The first implementation was simple:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Initial implementation (later found to be the root of the bugs)&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;_setRunningSiteId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;siteId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;_runningSiteId&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;_runningSiteId&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;siteId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;_markCompleted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;_runningSiteId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// switched to another site = previous is done&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nx"&gt;_runningSiteId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;siteId&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The inference: "a new site name appeared in the log = the previous site finished." Intuitively plausible.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 1 — log "rewind" marks an unstarted site as done
&lt;/h2&gt;

&lt;p&gt;Real-world testing surfaced this: "select two sites, run, and &lt;strong&gt;the second site that hasn't even started yet turns green (done)&lt;/strong&gt;."&lt;/p&gt;

&lt;p&gt;The cause was that the streaming log sometimes interleaves multiple site names in a &lt;strong&gt;non-monotonic order&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;t1: log shows [Site A]        → _runningSiteId = A
t2: log shows [Site B]        → mark A done + _runningSiteId = B
t3: log shows [Site A] again  → mark B done + _runningSiteId = A
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At t3, B — which hasn't started processing — gets falsely marked "done." The "switched, so previous is done" rule was fragile against the log's non-monotonic ordering.&lt;/p&gt;

&lt;h3&gt;
  
  
  Fix 1 — reject rewind with an execution-order index
&lt;/h3&gt;

&lt;p&gt;On run, we save the "planned site ID array (in execution order)" and &lt;strong&gt;ignore any switch whose index moves backward&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;_runningSiteIdOrder&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;   &lt;span class="c1"&gt;// planned site_id array (execution order)&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;_runningSiteIdIndex&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// current index&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;_setRunningSiteId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;siteId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;newIdx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;_runningSiteIdOrder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;indexOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;siteId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;newIdx&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;_runningSiteIdIndex&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// rewind = ignore (e.g. index=0 log reappears while index=1 is running)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nx"&gt;_runningSiteIdIndex&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;newIdx&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;_runningSiteId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;siteId&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We also moved completion marking off the switch event entirely — completion now gets &lt;strong&gt;applied to the whole execution-order array at the end of the run&lt;/strong&gt;. We separated "inferring what's running" from "confirming what's done."&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 2 — the real root cause was the init loop's pre-emitted logs
&lt;/h2&gt;

&lt;p&gt;Even with rewind defense, another failure recurred: "&lt;strong&gt;A is running, but B shows as running.&lt;/strong&gt;"&lt;/p&gt;

&lt;p&gt;Scrutinizing the logs revealed the real root cause. The backend (&lt;code&gt;maintenance_agent.py&lt;/code&gt;) was emitting init logs for &lt;em&gt;all&lt;/em&gt; target sites &lt;strong&gt;before&lt;/strong&gt; any actual maintenance processing began:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[Site A]   Injecting connection info from SSH profile
[Site B]   Injecting connection info from SSH profile
[Site C]   Injecting connection info from SSH profile
[Site A]   Starting maintenance   ← actual processing begins here
[Site A]   Creating backup
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The old detection logic picked candidates from "every line containing &lt;code&gt;[...]&lt;/code&gt;," so it falsely detected the &lt;strong&gt;last site in the init loop&lt;/strong&gt; (C, the last injection log) as "running." That was the real story behind "A is running but B/C show as running." Rewind defense helped the &lt;em&gt;ordering&lt;/em&gt; problem, but was powerless against "we're reading the wrong lines in the first place."&lt;/p&gt;

&lt;h3&gt;
  
  
  Fix 2 — the marker approach (narrow to one line type)
&lt;/h3&gt;

&lt;p&gt;The fix was almost anticlimactically simple. Detect only &lt;strong&gt;the "Starting maintenance" line, which appears exactly once at the start of each site's real processing&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;_START_MARKER&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/メンテナンスを開始|Starting maintenance/&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;_detectRunningSiteFromLog&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;_START_MARKER&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// ignore non-marker lines&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;siteName&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;extractBracketName&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
        &lt;span class="c1"&gt;// ... match against allSites, then _setRunningSiteId&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Init lines like &lt;code&gt;[Site B] Injecting SSH profile&lt;/code&gt; are now completely ignored; &lt;code&gt;_runningSiteId&lt;/code&gt; only moves the instant &lt;code&gt;[Site A] Starting maintenance&lt;/code&gt; appears. The shift was from "infer state from the whole log" to "&lt;strong&gt;pick one clear marker that represents a state transition, and watch only that.&lt;/strong&gt;"&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 3 — the completion reset was too aggressive
&lt;/h2&gt;

&lt;p&gt;The last one is smaller. We had a "clear all completion marks (green borders) when a maintenance run starts" step, but a user pointed out: "shouldn't sites maintained within the last 24 hours stay green?"&lt;/p&gt;

&lt;p&gt;Right — we should have reset &lt;strong&gt;only the sites in the current run's scope&lt;/strong&gt;, but we were also wiping the green borders of out-of-scope sites (ones recently maintained on a different occasion). Limiting the reset to the execution scope fixed it. A "clear state" step always needs its clear-scope kept in mind, or it sweeps up unrelated state.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons — inferring state from streaming logs
&lt;/h2&gt;

&lt;p&gt;Three principles from three rounds of fixes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Don't infer state from the whole log — pick one clear marker.&lt;/strong&gt; Loose inference like "a name appeared = it's running" is fragile against non-monotonic log output (pre-emitted init logs, interleaved names). A one-to-one "this line means this state" marker removes the inference ambiguity&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Separate "inferring what's running" from "confirming what's done."&lt;/strong&gt; Marking the previous site done at switch time means a mis-detected switch also mis-marks completion. Confirming completion in one batch at run-end keeps it out of the inference jitter&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Always scope your state clears.&lt;/strong&gt; "Reset everything" is easy, but it sweeps up unrelated state. Limit the clear to the execution scope&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Behind the modest request "show progress live" lurked the difficulty of inferring state from an asynchronous data source. When you're wiring up "watch logs, reflect state in the UI," &lt;strong&gt;building with the marker approach from the start&lt;/strong&gt; is the shortcut that avoids three rounds of rework.&lt;/p&gt;

</description>
      <category>python</category>
      <category>webdev</category>
      <category>programming</category>
    </item>
    <item>
      <title>Quieting PHP 8.2+ deprecated noise from older WP-CLI — three layers to keep JSON parse clean</title>
      <dc:creator>Susumu Takahashi</dc:creator>
      <pubDate>Sat, 04 Jul 2026 21:46:16 +0000</pubDate>
      <link>https://dev.to/susumun/quieting-php-82-deprecated-noise-from-older-wp-cli-three-layers-to-keep-json-parse-clean-10e6</link>
      <guid>https://dev.to/susumun/quieting-php-82-deprecated-noise-from-older-wp-cli-three-layers-to-keep-json-parse-clean-10e6</guid>
      <description>&lt;p&gt;Our multi-site maintenance tool fires &lt;code&gt;wp plugin list --format=json&lt;/code&gt; against the sites it manages. One day, against a specific shared host (Xserver in Japan), this call started failing — and the failure mode was unusually subtle.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Both the SSH connection test and the WP-CLI path test (&lt;code&gt;wp --version&lt;/code&gt;) came back green.&lt;/strong&gt; Users saw "all diagnostics pass, but the actual operation fails," a frustrating asymmetry. Tracing it back, the root cause was &lt;strong&gt;PHP Deprecated warnings emitted by older WP-CLI (2.x) under PHP 8.2+&lt;/strong&gt; leaking into the JSON output.&lt;/p&gt;

&lt;p&gt;This post walks through the three-layer defense we used to structurally absorb the noise without losing real failures.&lt;/p&gt;

&lt;h2&gt;
  
  
  What was happening — Deprecated warnings on stdout
&lt;/h2&gt;

&lt;p&gt;The raw output on a problem host looked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PHP Deprecated:  Creation of dynamic property
WP_CLI\Dispatcher\CompositeCommand::$longdesc is deprecated
in phar:///usr/bin/wp/vendor/wp-cli/wp-cli/php/...
[
  {"name":"akismet","status":"active","update":"none", ...},
  ...
]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Since PHP 8.2, assigning to a dynamic property on a class without &lt;code&gt;#[\AllowDynamicProperties]&lt;/code&gt; emits a Deprecated warning.&lt;/strong&gt; Xserver's &lt;code&gt;/usr/bin/wp&lt;/code&gt; (an older WP-CLI 2.x) leans on dynamic properties internally, so running it on PHP 8.2+ produces a steady stream of those warnings.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Note: PHP 8.2's dynamic-property deprecation is a healthy direction for the language. But during the transition, you get many libraries that "warn but still work" — WP-CLI was one of them.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The actual problem is the host's &lt;code&gt;php.ini&lt;/code&gt;: depending on &lt;code&gt;display_errors&lt;/code&gt;, those warnings end up on &lt;strong&gt;stdout instead of stderr&lt;/strong&gt;. Calling &lt;code&gt;wp plugin list --format=json&lt;/code&gt; returns stdout containing both the warnings &lt;em&gt;and&lt;/em&gt; the JSON, and &lt;code&gt;json_decode()&lt;/code&gt; fails on the mixed input.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why diagnostics stayed green but operations failed
&lt;/h2&gt;

&lt;p&gt;The frustrating asymmetry came from how each test was checking the output:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;SSH connection test&lt;/strong&gt;: runs &lt;code&gt;echo ok&lt;/code&gt; — passes as long as &lt;code&gt;ok&lt;/code&gt; appears somewhere in stdout, extra lines are fine&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;WP-CLI path test&lt;/strong&gt;: runs &lt;code&gt;wp --version&lt;/code&gt; — passes as long as a version string is found&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Real operation&lt;/strong&gt;: runs &lt;code&gt;wp plugin list --format=json&lt;/code&gt; — &lt;strong&gt;the JSON parse step is the only place the noise actually matters&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;To the user it looks like "all my tests are green, but the real call fails." If your diagnostics only check exit code and "did the expected substring appear," anything that surfaces only at the structured-output stage slips through silently. This is the same shape as the trap we hit with &lt;a href="https://en.wpmm.jp/blog/ssh-csh-bash-portability/" rel="noopener noreferrer"&gt;SSH commands failing on csh login shells&lt;/a&gt; — single commands pass while structured workflows break.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three layers of defense
&lt;/h2&gt;

&lt;p&gt;You can try to suppress the warnings entirely, but you can't fully predict every host's &lt;code&gt;php.ini&lt;/code&gt; configuration — so we built &lt;strong&gt;multiple independent layers&lt;/strong&gt; that each catch a different leakage path.&lt;/p&gt;

&lt;h3&gt;
  
  
  Layer 1 — &lt;code&gt;WP_CLI_PHP_ARGS&lt;/code&gt; to silence warnings at the source
&lt;/h3&gt;

&lt;p&gt;WP-CLI exposes an environment variable &lt;code&gt;WP_CLI_PHP_ARGS&lt;/code&gt; that gets forwarded to the underlying PHP invocation. We set it to mask Deprecated entries via &lt;code&gt;error_reporting&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;_WP_CLI_PHP_QUIET_ARGS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-d error_reporting=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;E_ALL &amp;amp; ~E_DEPRECATED &amp;amp; ~E_USER_DEPRECATED&lt;/span&gt;&lt;span class="sh"&gt;'"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_wp_with_quiet_php&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;wp_cli_path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Wrap a WP-CLI call with WP_CLI_PHP_ARGS to suppress Deprecated warnings.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="nf"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;WP_CLI_PHP_ARGS=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;shlex&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;quote&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_WP_CLI_PHP_QUIET_ARGS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;wp_cli_path&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;shlex.quote&lt;/code&gt; keeps the value safely escaped for the shell, and the function plays nicely with our existing per-site WP-CLI path override feature. &lt;strong&gt;Parse and Fatal errors still surface&lt;/strong&gt; — only Deprecated/Notice-level noise gets quieted.&lt;/p&gt;

&lt;h3&gt;
  
  
  Layer 2 — strip noise lines before JSON parse
&lt;/h3&gt;

&lt;p&gt;Layer 1 cleans up most environments, but if the host overrides &lt;code&gt;error_reporting&lt;/code&gt; again at runtime (&lt;code&gt;php.ini&lt;/code&gt; -&amp;gt; &lt;code&gt;ini_set()&lt;/code&gt; chain), warnings can still slip through. As &lt;strong&gt;defense in depth, we strip recognized noise lines from stdout before parsing&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;_PHP_NOISE_LINE_RE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;^\s*PHP\s+(Deprecated|Warning|Notice|Strict Standards):.*$&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MULTILINE&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IGNORECASE&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_strip_php_noise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Remove PHP Deprecated/Warning/Notice/Strict Standards lines from stdout.
    Parse error / Fatal error are NOT stripped — those are real failures
    the user should see.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;_PHP_NOISE_LINE_RE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The deliberate omission of &lt;strong&gt;Parse error and Fatal error&lt;/strong&gt; matters. Those mean the operation actually broke, and the user needs to see them. The regex enumeration of four noise categories draws the line cleanly between "annoyance" and "actual failure."&lt;/p&gt;

&lt;h3&gt;
  
  
  Layer 3 — try JSON even when exit code is nonzero
&lt;/h3&gt;

&lt;p&gt;Layers 1 and 2 catch most cases, but a small number of hosts return &lt;strong&gt;exit code 1 just because of warnings&lt;/strong&gt; while leaving valid JSON in stdout. Fabric (paramiko) reports &lt;code&gt;res.ok = False&lt;/code&gt;, but the parseable data is right there.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;stdout_clean&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_strip_php_noise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stdout&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;''&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;plugins&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;stdout_clean&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;plugins&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stdout_clean&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;JSONDecodeError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;plugins&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;plugins&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# Only here do we conclude "no JSON" — fall to error
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;error_response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stderr&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stdout&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The trick is &lt;strong&gt;try JSON before trusting the exit code&lt;/strong&gt;. If stdout contains a valid structure, treat the call as successful even with a nonzero exit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Spread the fix across all three APIs at once
&lt;/h2&gt;

&lt;p&gt;Same principle as V12 (the csh portability bug): when you find this kind of issue, &lt;strong&gt;grep for the same pattern across the codebase&lt;/strong&gt; and fix everywhere at once. The plugin-list fetch lived in three call sites:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;/api/fetch_plugins&lt;/code&gt; — the cross-site plugin dashboard&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/api/site_plugins&lt;/code&gt; — the per-site plugin list modal&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;_do_fetch_pending_plugins_for_site&lt;/code&gt; — the maintenance-time pending-update scan&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All three were rewritten to use &lt;code&gt;_wp_with_quiet_php&lt;/code&gt; + &lt;code&gt;_strip_php_noise&lt;/code&gt; + JSON-first parsing. Fixing only one would have left the same regression alive on a different path.&lt;/p&gt;

&lt;p&gt;For regression defense, &lt;code&gt;tests/test_wp_cli_php_noise.py&lt;/code&gt; ships with 18 cases (noise-line removal, Parse error preservation, env-var formatting, shlex quoting, compatibility with per-site WP-CLI path override, and presence checks for all three APIs). If anyone later adds a fourth API that calls &lt;code&gt;json_decode&lt;/code&gt; directly on raw &lt;code&gt;c.run&lt;/code&gt; output, the CI fails.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing — "warning-level differences × structured output"
&lt;/h2&gt;

&lt;p&gt;Three principles worth keeping from this round:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Diagnostics green / production red is structurally easy to produce.&lt;/strong&gt; Most tests check "did the command run and produce expected substrings" — structured-output paths see the noise that simple-string paths don't. Add &lt;strong&gt;one structured-output parse step to the diagnostics&lt;/strong&gt; to catch this class of bug early&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Layer noise suppression in independent steps.&lt;/strong&gt; Source suppression (&lt;code&gt;error_reporting&lt;/code&gt;) / line filtering / exit-code bypass are independent defenses. Stack them, and a hosting quirk that defeats one layer still gets caught by another. You can't fully predict host configurations — layering is the answer&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Distinguish noise from real failures by regex.&lt;/strong&gt; Deprecated / Warning / Notice / Strict Standards are safe to strip; Parse error / Fatal error must always pass through. "Just hide all the warnings" hides the real failures too — enumeration with a tight regex draws the right line&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;When you're invoking environment-sensitive CLI tools like PHP / WP-CLI from a maintenance tool, similar bugs are probably sitting under the surface. The "error_reporting suppression + noise-line stripping + JSON-first parse" template is a reusable pattern worth keeping in the toolbox.&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>php</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Implementing a dynamic OGP image generator for our blog — PHP GD, per-post 1200 630 cards</title>
      <dc:creator>Susumu Takahashi</dc:creator>
      <pubDate>Fri, 03 Jul 2026 23:03:58 +0000</pubDate>
      <link>https://dev.to/susumun/implementing-a-dynamic-ogp-image-generator-for-our-blog-php-gd-per-post-1200-630-cards-46al</link>
      <guid>https://dev.to/susumun/implementing-a-dynamic-ogp-image-generator-for-our-blog-php-gd-per-post-1200-630-cards-46al</guid>
      <description>&lt;p&gt;One day on our English X account, I posted a link to a fresh blog article and froze when the OGP card rendered: &lt;strong&gt;the image was the LP sales banner&lt;/strong&gt; — "Stop babysitting updates. Start scaling maintenance revenue." — not anything related to the post itself.&lt;/p&gt;

&lt;p&gt;Going back through the last seven announcement posts, &lt;strong&gt;every single one&lt;/strong&gt; was showing the same LP sales banner. Articles written as technical engineering deep-dives had been quietly flowing through the X timeline for months looking like sales-promo posts.&lt;/p&gt;

&lt;p&gt;This article walks through how we found the structural cause and replaced it with a &lt;strong&gt;dynamic OGP image generation engine&lt;/strong&gt; — using our own blog as the example.&lt;/p&gt;

&lt;h2&gt;
  
  
  The structural cause — "no featured image → LP default"
&lt;/h2&gt;

&lt;p&gt;Digging in, the theme's OGP logic was a classic fallback: "if there's a featured image use it, otherwise the LP default." The problem was that &lt;strong&gt;none of our 21 articles × 2 languages = 42 posts had a featured image&lt;/strong&gt;. Every post was returning the same LP sales banner as &lt;code&gt;og:image&lt;/code&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Note: OGP (Open Graph Protocol) is the metadata SNS clients use to render link previews. X, Facebook, LinkedIn, Slack and others all read &lt;code&gt;og:image&lt;/code&gt; and friends.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Two options — manual artwork vs dynamic generation
&lt;/h2&gt;

&lt;p&gt;Two repair paths were on the table.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option A. Manually set a featured image on all 21 posts.&lt;/strong&gt; Honest answer, but (1) producing 42 images once is heavy, (2) every new article afterward inherits an ongoing artwork cost, and (3) &lt;strong&gt;the "Japanese style" stock photos (PCs, keyboards, abstract code) read as amateur&lt;/strong&gt; to international tech audiences, which works against us&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option B. Build a dynamic OGP image generator.&lt;/strong&gt; The international tech-blog standard pattern. Vercel, PlanetScale, dev.to and friends all generate &lt;strong&gt;black-background + headline-composed&lt;/strong&gt; OGP cards on demand — render once, cache, and every new post automatically inherits a per-article OGP&lt;/p&gt;

&lt;p&gt;We picked &lt;strong&gt;Option B&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;One engine, then 21 posts (and every future post) auto-resolve correctly&lt;/li&gt;
&lt;li&gt;"Black + title" composition is the visual register international tech readers expect&lt;/li&gt;
&lt;li&gt;"Find a stock image for every article" stops being a step&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The core — &lt;code&gt;ogp-generator.php&lt;/code&gt; (PHP GD, ~375 lines)
&lt;/h2&gt;

&lt;p&gt;PHP GD (the image-manipulation library shipped with PHP) renders the 1200×630 PNG on demand. Everything sits in one standalone PHP file, &lt;code&gt;ogp-generator.php&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ogp-generator.php (sketch)&lt;/span&gt;
&lt;span class="nv"&gt;$post_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)(&lt;/span&gt;&lt;span class="nv"&gt;$_GET&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'post_id'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$cache_path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;WP_CONTENT_DIR&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s2"&gt;"/uploads/ogp/post-&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$post_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.png"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// 2nd hit onward: serve from disk cache&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;file_exists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$cache_path&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nb"&gt;header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Content-Type: image/png'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nb"&gt;readfile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$cache_path&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// First hit: pull title from WP DB&lt;/span&gt;
&lt;span class="nv"&gt;$post&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$post_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$title&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;post_title&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Render 1200×630 PNG via GD&lt;/span&gt;
&lt;span class="nv"&gt;$im&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;imagecreatetruecolor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;630&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Background #0d1117 (LP main dark)&lt;/span&gt;
&lt;span class="nv"&gt;$bg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;imagecolorallocate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$im&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mh"&gt;0x0d&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mh"&gt;0x11&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mh"&gt;0x17&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nb"&gt;imagefilledrectangle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$im&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;630&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$bg&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Top blue 4px / bottom green 4px (visual continuity with LP)&lt;/span&gt;
&lt;span class="nv"&gt;$blue&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;imagecolorallocate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$im&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mh"&gt;0x2f&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mh"&gt;0x81&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mh"&gt;0xf7&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$green&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;imagecolorallocate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$im&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mh"&gt;0x3f&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mh"&gt;0xb9&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mh"&gt;0x50&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nb"&gt;imagefilledrectangle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$im&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$blue&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nb"&gt;imagefilledrectangle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$im&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;626&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;630&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$green&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Title text (font picked by language)&lt;/span&gt;
&lt;span class="nv"&gt;$font&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;has_japanese&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$title&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="no"&gt;NOTO_BOLD&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="no"&gt;INTER_BOLD&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nv"&gt;$lines&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;wrap_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$font&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;56&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;900&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$y&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;220&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nv"&gt;$white&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;imagecolorallocate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$im&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mh"&gt;0xff&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mh"&gt;0xff&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mh"&gt;0xff&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$lines&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$line&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nb"&gt;imagettftext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$im&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;56&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$white&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$font&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$line&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$y&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;88&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Cache to disk and serve&lt;/span&gt;
&lt;span class="nb"&gt;imagepng&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$im&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$cache_path&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nb"&gt;header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Content-Type: image/png'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nb"&gt;readfile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$cache_path&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing fancy, but the key components are all there. &lt;strong&gt;The first request renders; every request after that streams the cached PNG from disk&lt;/strong&gt; — the GD cost is paid exactly once per post.&lt;/p&gt;

&lt;h2&gt;
  
  
  Japanese titles need a second font — Inter + Noto Sans JP
&lt;/h2&gt;

&lt;p&gt;The first thing that broke was the fact that &lt;strong&gt;Inter has no Japanese glyphs at all&lt;/strong&gt;. It's a Latin-script-only font; the JIS X 0208 range doesn't exist. Passing a Japanese title through &lt;code&gt;imagettftext()&lt;/code&gt; with Inter produces tofu (□□□) or nothing.&lt;/p&gt;

&lt;p&gt;The fix: detect whether the title contains Japanese and swap fonts.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;has_japanese&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$text&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;preg_match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/[ぁ-んァ-ヶ一-龯]/u'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We bundled &lt;strong&gt;Noto Sans JP Bold&lt;/strong&gt; (SIL Open Font License, 5.1 MB) and Inter Bold / Regular (same SIL OFL, ~325 KB each) into the theme assets and rsync them alongside the other theme files.&lt;/p&gt;

&lt;h2&gt;
  
  
  The &lt;code&gt;functions.php&lt;/code&gt; patch — &lt;code&gt;+28 / -2&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;The OGP logic redirects through the dynamic URL when there's no featured image. The diff is small (&lt;code&gt;+28 / -2&lt;/code&gt;), and the existing behavior (posts with a real featured image keep using it) is preserved.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// After: posts without a featured image return the dynamic URL&lt;/span&gt;
&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;wpmm_blog_og_image&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;is_singular&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;has_post_thumbnail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$cache_bust&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'YmdHis'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;home_url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="s2"&gt;"/wp-content/themes/wpmm-blog/ogp-generator.php"&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt;
            &lt;span class="s2"&gt;"?post_id=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="no"&gt;ID&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;v=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$cache_bust&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
        &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="c1"&gt;// Posts with a featured image still take precedence&lt;/span&gt;
    &lt;span class="nv"&gt;$thumb&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_the_post_thumbnail_url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'full'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$thumb&lt;/span&gt; &lt;span class="o"&gt;?:&lt;/span&gt; &lt;span class="no"&gt;LP_DEFAULT_OGP&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;&amp;amp;v=YYYYMMDDHHMMSS&lt;/code&gt; is a cache-busting query string. When we update the theme and change the OGP design, this forces X and Facebook to re-crawl rather than serving their stale cached image.&lt;/p&gt;

&lt;h2&gt;
  
  
  Design decisions and the result
&lt;/h2&gt;

&lt;p&gt;The canvas is 1200×630 (OGP standard, X large-image-card recommended). Background &lt;code&gt;#0d1117&lt;/code&gt;, accent blue &lt;code&gt;#2f81f7&lt;/code&gt; / green &lt;code&gt;#3fb950&lt;/code&gt; are pulled from the LP brand palette; thin 4px blue (top) / green (bottom) bars give visual continuity with the LP. "WP MAINTENANCE MANAGER" top-left, headline center-left, supporting URL bottom-right. The whole composition is intentionally close to what Vercel's &lt;code&gt;og&lt;/code&gt; API and PlanetScale's OGP cards look like — visual signals readers associate with &lt;strong&gt;proper engineering blogs&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;After deploy, &lt;code&gt;curl -I&lt;/code&gt; against all 42 &lt;code&gt;og:image&lt;/code&gt; URLs confirmed every one was now serving the dynamic URL. On X TL the visual flipped from "LP sales banner" to "&lt;strong&gt;dark card with the post title&lt;/strong&gt;." Retroactively re-posting old announcements isn't worth it since they're past their relevance window — instead we'll be tracking engagement deltas between "LP-image era" and "dynamic OGP era" as ongoing signal.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing — four principles for dynamic OGP
&lt;/h2&gt;

&lt;p&gt;Four takeaways worth keeping from this round:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;WP theme OGP defaults can quietly produce "every post has the same image."&lt;/strong&gt; The classic &lt;code&gt;if (no_thumb) return LP_DEFAULT&lt;/code&gt; fallback is well-meaning, but combined with a workflow that doesn't set featured images it becomes one identical sales banner across every post. Audit &lt;code&gt;og:image&lt;/code&gt; across all posts periodically — a &lt;code&gt;curl -I&lt;/code&gt; sweep of 42 URLs takes seconds&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Manual artwork is both a workload tax and a tone hazard.&lt;/strong&gt; Beyond the ongoing labor, "regional visual signals" matter — Japanese-style stock photography reads as amateur to international tech audiences. Dynamic generation sidesteps both&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PHP GD + WP DB + disk cache is a one-file, lightweight stack.&lt;/strong&gt; Lighter than it sounds. First-render GD pass, then static file serving — per-request load is essentially zero after the first hit. You don't need an edge runtime like Vercel's to do this&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Japanese fonts trip you up at the first hour.&lt;/strong&gt; Latin-only fonts like Inter / Roboto turn Japanese titles into tofu. Build language detection + Noto Sans JP into the path from day one&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This article ended up being a meta piece about our own blog's OGP, written for our blog — and naturally &lt;strong&gt;this very post now has a dynamic OGP card generated by the engine it describes&lt;/strong&gt;. Worth checking your own blog's &lt;code&gt;og:image&lt;/code&gt; if you're running one — similar regressions can sit under the surface for months without anyone noticing.&lt;/p&gt;

</description>
      <category>python</category>
      <category>webdev</category>
      <category>programming</category>
    </item>
    <item>
      <title>Diff from the live server, not from your git history — when a local repo has drifted from production</title>
      <dc:creator>Susumu Takahashi</dc:creator>
      <pubDate>Fri, 03 Jul 2026 00:26:38 +0000</pubDate>
      <link>https://dev.to/susumun/diff-from-the-live-server-not-from-your-git-history-when-a-local-repo-has-drifted-from-production-150p</link>
      <guid>https://dev.to/susumun/diff-from-the-live-server-not-from-your-git-history-when-a-local-repo-has-drifted-from-production-150p</guid>
      <description>&lt;p&gt;An investigation agent flagged "the license API PHP returns Japanese-hardcoded messages" and we sat down to fix it. But something felt off the moment we opened the file — &lt;strong&gt;the version running on the production server didn't match the latest commit in the local repo&lt;/strong&gt;. Stranger still, production had &lt;strong&gt;more recent features than our local checkout&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;A bit of digging turned up the truth: months earlier, someone had hot-patched the production file in response to a different user issue, and &lt;strong&gt;that change had never been committed back to git&lt;/strong&gt;. This post walks through how we detected that drift, and the two-stage strategy we used to merge production back into the local repo safely.&lt;/p&gt;

&lt;h2&gt;
  
  
  How this regression silently slips in
&lt;/h2&gt;

&lt;p&gt;If we'd written the fix on top of our local repo and uploaded it to production, here's what would have happened: &lt;strong&gt;all the production-only improvements get overwritten and quietly disappear&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;In our case, the production file had a half-year-old language-handling addition for the "Early Bird Bonus" feature — when a USD customer buys, &lt;code&gt;client_name&lt;/code&gt; is set to &lt;code&gt;'Early Bird Bonus'&lt;/code&gt;; for JPY customers it's &lt;code&gt;'早期利用特典'&lt;/code&gt;. None of that existed in our local git. A normal PR-merge-and-deploy cycle would have &lt;strong&gt;silently rolled back the Early Bird i18n logic&lt;/strong&gt;, regressing English users' display back to Japanese.&lt;/p&gt;

&lt;p&gt;Catching this was half luck. Opening the file to start the fix, I noticed code I didn't recognize, ran &lt;code&gt;git blame&lt;/code&gt;, and &lt;strong&gt;the lines were nowhere in git history&lt;/strong&gt;. That's when alarm bells went off.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two-stage rollforward — make production the source of truth first
&lt;/h2&gt;

&lt;p&gt;The strategy we landed on was a two-stage merge.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stage 1 (rollforward sync)&lt;/strong&gt;: Pull the production file straight into the local repo. Apply the diff in the &lt;strong&gt;"production → local" direction, not the other way&lt;/strong&gt;. After this, the local repo's HEAD matches what's actually running on production.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Pull the production file into the local repo&lt;/span&gt;
scp &lt;span class="nt"&gt;-i&lt;/span&gt; ~/.ssh/key layer2024@host:wpmm.jp/public_html/license/api/register_free.php &lt;span class="se"&gt;\&lt;/span&gt;
    /tmp/register_free_prod.php

&lt;span class="c"&gt;# Record its MD5 first (we'll need this later for upload verification)&lt;/span&gt;
md5 /tmp/register_free_prod.php
&lt;span class="c"&gt;# → 94b9c5a7...&lt;/span&gt;

&lt;span class="c"&gt;# Replace local file with the production copy, then commit&lt;/span&gt;
&lt;span class="nb"&gt;cp&lt;/span&gt; /tmp/register_free_prod.php server/wpmm-license/api/register_free.php
git add server/wpmm-license/api/register_free.php
git commit &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"rollforward sync: pull v1.5.9 hotpatch from production"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key move here is &lt;strong&gt;"commit the production state as a separate commit, before doing any feature work"&lt;/strong&gt; — that single rollforward commit makes every subsequent diff a clean delta against production reality.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stage 2 (additive)&lt;/strong&gt;: With the local repo now in sync, &lt;strong&gt;add only the new functionality&lt;/strong&gt; as a follow-up commit. Don't touch any existing code in the Early Bird section.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Build the stage-2 commit&lt;/span&gt;
&lt;span class="c"&gt;# - Keep the existing Early Bird block (lines 71-111) byte-for-byte&lt;/span&gt;
&lt;span class="c"&gt;# - Add only the new lines (i18n fallback, lang query addition)&lt;/span&gt;
git diff HEAD~1 &lt;span class="nt"&gt;--&lt;/span&gt; server/wpmm-license/api/register_free.php
&lt;span class="c"&gt;# Eyeball the output: confirm zero deletion lines&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;"Zero deleted lines" is a mechanical safety check you can apply. The diff's structure itself guarantees you're not changing production behavior, just extending it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use MD5 to prove byte-for-byte preservation
&lt;/h2&gt;

&lt;p&gt;If you're going to claim "byte-for-byte unchanged," prove it quantitatively. Before and after upload, we &lt;strong&gt;md5-hashed the preserved section&lt;/strong&gt; to be sure.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Hash just the preserved section (Early Bird, lines 71-111)&lt;/span&gt;
&lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s1"&gt;'71,111p'&lt;/span&gt; server/wpmm-license/api/register_free.php | md5
&lt;span class="c"&gt;# Expected: matches the same range in the original production file&lt;/span&gt;

&lt;span class="c"&gt;# Verify again after upload&lt;/span&gt;
ssh user@host &lt;span class="s1"&gt;'sed -n "71,111p" wpmm.jp/.../register_free.php | md5'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;INSERT INTO licenses&lt;/code&gt; statement, bind order, &lt;code&gt;$client_name_label = $is_en ? 'Early Bird Bonus' : '早期利用特典'&lt;/code&gt; ternary, try/catch, period logic — all of it can be confirmed structurally unchanged. The hash check is mechanical proof.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing — "my git history is canonical" is a dangerous assumption
&lt;/h2&gt;

&lt;p&gt;Three principles worth keeping from this round:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Make "diff from the live server" a habit before starting work.&lt;/strong&gt; Your local git is not necessarily the latest truth. Especially in projects with a hot-patch culture, &lt;strong&gt;production may carry changes that aren't in your repo&lt;/strong&gt;. Always &lt;code&gt;scp&lt;/code&gt; the production file and diff against local before touching it&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When you find drift, merge in two stages.&lt;/strong&gt; Stage 1 ("rollforward sync") makes production the source of truth in your repo. Stage 2 ("additive") layers new features on top. Splitting the merge this way structurally prevents "regressing production improvements"&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify preservation with md5, not just words.&lt;/strong&gt; Don't just write "this section is unchanged" — extract the preserved range with &lt;code&gt;sed -n&lt;/code&gt;, hash it, and confirm before/after upload. Combined with the "zero deletion lines" diff check, you get two independent guarantees&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The big lesson from this round was the principle of &lt;strong&gt;"always suspect that the local repo has drifted from production."&lt;/strong&gt; In projects with manual deployment or hot-patch traditions, this is a hard reality to escape. If you've gotten into the habit of treating &lt;strong&gt;git as the latest truth&lt;/strong&gt;, a regression like this one is waiting to happen.&lt;/p&gt;

&lt;p&gt;A one-minute &lt;code&gt;scp&lt;/code&gt; and &lt;code&gt;diff&lt;/code&gt; before starting can save hours of incident response later. Worth building into the muscle memory.&lt;/p&gt;

</description>
      <category>python</category>
      <category>webdev</category>
      <category>programming</category>
    </item>
    <item>
      <title>Multilingual emails from a Stripe webhook — inferring language from currency</title>
      <dc:creator>Susumu Takahashi</dc:creator>
      <pubDate>Wed, 01 Jul 2026 23:39:06 +0000</pubDate>
      <link>https://dev.to/susumun/multilingual-emails-from-a-stripe-webhook-inferring-language-from-currency-i99</link>
      <guid>https://dev.to/susumun/multilingual-emails-from-a-stripe-webhook-inferring-language-from-currency-i99</guid>
      <description>&lt;p&gt;A subtle trap of taking a SaaS international: the &lt;strong&gt;system emails sent from your Stripe webhook&lt;/strong&gt;. Purchase confirmation, renewal success, payment failure, plan change — four kinds of emails triggered by Stripe events. We recently discovered all four had been &lt;strong&gt;hardcoded to Japanese&lt;/strong&gt; for months, sending Japanese receipts and failure notices to English-paying users overseas. The kind of bug that quietly persists forever unless you go looking.&lt;/p&gt;

&lt;p&gt;This post walks through the &lt;strong&gt;currency-based language inference&lt;/strong&gt; design we landed on, plus the small &lt;code&gt;mb_language&lt;/code&gt; trap that nearly ruined the fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where do you get "the user's language" from? — three options
&lt;/h2&gt;

&lt;p&gt;There were essentially three design options for picking the email language inside the webhook:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option A. Keep a &lt;code&gt;language&lt;/code&gt; column in the DB.&lt;/strong&gt; The classical answer — store language at signup, look it up when sending mail. But it requires a DB migration, leaves existing users in "language unknown" until backfill, and you still need fallback inference for the first email anyway&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option B. Pull from the Stripe API.&lt;/strong&gt; &lt;code&gt;stripe.Customer.retrieve()&lt;/code&gt; exposes &lt;code&gt;preferred_locales&lt;/code&gt;. Authoritative in theory, but adds a round trip per send, and not every customer has it set&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option C. Infer from the Stripe event's &lt;code&gt;currency&lt;/code&gt; field.&lt;/strong&gt; Always present in the webhook payload (&lt;code&gt;usd&lt;/code&gt; / &lt;code&gt;jpy&lt;/code&gt; / etc.). &lt;strong&gt;No extra API call, no DB migration, applies to existing users immediately&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;We picked &lt;strong&gt;Option C&lt;/strong&gt;, because:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Currency is a &lt;strong&gt;strong, fixed-at-purchase signal&lt;/strong&gt; — it doesn't change later&lt;/li&gt;
&lt;li&gt;It's already in the payload, so the cost is zero&lt;/li&gt;
&lt;li&gt;Zero DB touches means low regression risk on deploy&lt;/li&gt;
&lt;li&gt;Existing users get the new behavior automatically (no migration script)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Implementing &lt;code&gt;lang_from_currency&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;The whole helper is this one function:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="cd"&gt;/** Infer display language from a Stripe event's currency. */&lt;/span&gt;
&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;lang_from_currency&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$currency&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$en_currencies&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'usd'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;   &lt;span class="c1"&gt;// USD → English user&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;in_array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;strtolower&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$currency&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nv"&gt;$en_currencies&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="s1"&gt;'en'&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'ja'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;USD means English; everything else (including JPY) means Japanese. EUR or GBP can be added to &lt;code&gt;$en_currencies&lt;/code&gt; later when those markets open.&lt;/p&gt;

&lt;p&gt;It's a "good enough" approximation, but it works in practice: there are essentially no English speakers buying in JPY, and the rare Japanese speaker on a USD plan ends up with English emails — which is more correlated to their &lt;strong&gt;explicit purchase intent&lt;/strong&gt; than something like a browser &lt;code&gt;Accept-Language&lt;/code&gt; header.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pulling &lt;code&gt;currency&lt;/code&gt; from the four webhook events
&lt;/h2&gt;

&lt;p&gt;The four Stripe events have slightly different payload shapes, so &lt;code&gt;currency&lt;/code&gt; lives in slightly different places:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// 1. checkout.session.completed — purchase confirmation&lt;/span&gt;
&lt;span class="nv"&gt;$checkout_lang&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;lang_from_currency&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$session&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'currency'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="s1"&gt;'jpy'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nf"&gt;send_license_email&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$client_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$plan&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$period&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$checkout_lang&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// 2. invoice.payment_succeeded — renewal success&lt;/span&gt;
&lt;span class="nv"&gt;$renewal_lang&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;lang_from_currency&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$invoice&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'currency'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="s1"&gt;'jpy'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nf"&gt;send_renewal_email&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$client_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$plan&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$renewal_lang&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// 3. invoice.payment_failed — payment failure&lt;/span&gt;
&lt;span class="nv"&gt;$failed_lang&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;lang_from_currency&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$invoice&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'currency'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="s1"&gt;'jpy'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nf"&gt;send_payment_failed_email&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$client_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$plan&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$failed_lang&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// 4. customer.subscription.updated — plan change&lt;/span&gt;
&lt;span class="c1"&gt;//    (currency reached via the subscription object)&lt;/span&gt;
&lt;span class="nv"&gt;$changed_lang&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;lang_from_currency&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$sub_currency&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nf"&gt;send_plan_changed_email&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$client_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$old_plan&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$new_plan&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$changed_lang&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;?? 'jpy'&lt;/code&gt; fallback is insurance: if a test event somehow lacks &lt;code&gt;currency&lt;/code&gt;, the function returns &lt;code&gt;ja&lt;/code&gt; rather than throwing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The subject-encoding trap — &lt;code&gt;mb_language('uni' or 'Japanese')&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;This is where it gets sneaky. PHP's &lt;code&gt;mb_send_mail()&lt;/code&gt; encodes the subject according to whatever &lt;code&gt;mb_language()&lt;/code&gt; is set to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nb"&gt;mb_language&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$lang&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;'en'&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="s1"&gt;'uni'&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'Japanese'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;mb_language('Japanese')&lt;/code&gt; encodes the subject in &lt;strong&gt;ISO-2022-JP&lt;/strong&gt; via MIME (JIS X 0208-based, Japan-specific)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;mb_language('uni')&lt;/code&gt; encodes the subject in &lt;strong&gt;UTF-8 Base64&lt;/strong&gt; (strict RFC 2047 compliance)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Why does it matter? If you leave &lt;code&gt;mb_language('Japanese')&lt;/code&gt; in place and send an English subject like &lt;code&gt;"Your license key for WP Maintenance Manager"&lt;/code&gt;, the subject gets ISO-2022-JP MIME-encoded — and &lt;strong&gt;Gmail and Outlook's spam scoring goes up&lt;/strong&gt;. Non-Japanese mail clients see a strange-looking encoded subject for what should be plain English text.&lt;/p&gt;

&lt;p&gt;The fix is simple — flip &lt;code&gt;mb_language()&lt;/code&gt; based on &lt;code&gt;$lang&lt;/code&gt; in all four mail functions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;send_license_email&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$client_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$plan&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$period&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$lang&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'ja'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nb"&gt;mb_language&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$lang&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;'en'&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="s1"&gt;'uni'&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'Japanese'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// ... subject / body below branch on $lang&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;mb_language('uni')&lt;/code&gt; is available since PHP 7.2 (RFC 2047 compliant Base64 UTF-8). If you're handling Stripe webhooks and going multilingual, this is more or less mandatory knowledge.&lt;/p&gt;

&lt;h2&gt;
  
  
  The win of a no-DB-migration design
&lt;/h2&gt;

&lt;p&gt;The net of this round: &lt;strong&gt;the database was not touched&lt;/strong&gt;. The entire change lives in one file, &lt;code&gt;webhook.php&lt;/code&gt;, with one new helper function &lt;code&gt;lang_from_currency&lt;/code&gt;. New users get the right language obviously, but &lt;strong&gt;existing users who bought months ago also get their next renewal email in the right language&lt;/strong&gt;, automatically.&lt;/p&gt;

&lt;p&gt;This is what a &lt;strong&gt;stateless event-driven architecture combined with a strong inference signal&lt;/strong&gt; can buy you. A DB-state design would have required a backfill pass over historical users — and rows that got missed during backfill would have stayed wrong forever.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing — three patterns for webhook i18n
&lt;/h2&gt;

&lt;p&gt;Three principles worth keeping from this round:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;If the event payload carries a strong signal, skip the DB.&lt;/strong&gt; Currency is a fixed-at-purchase signal that's already in every Stripe event. Routing logic through that signal beats a DB-state design when the data is already available — fewer moving parts, less maintenance overhead&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Set &lt;code&gt;mb_language('uni')&lt;/code&gt; for non-Japanese subjects.&lt;/strong&gt; ISO-2022-JP MIME encoding of English subject lines is a quiet contributor to spam-score creep. If you support both languages, switch encoding based on the user's language&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;For multilingual coverage, suspect "is anything still hardcoded?" first.&lt;/strong&gt; "Four mail functions were all hardcoded to Japanese" is the kind of bug that only surfaces when someone goes looking. Make every Stripe-event-handling function &lt;strong&gt;take an explicit language parameter&lt;/strong&gt; in its signature, so adding a new event forces the author to think about language&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If your SaaS is international, "we've been sending Japanese emails to English-paying users" is the kind of failure that doesn't surface until you check. Inferring language from currency at the webhook layer is a &lt;strong&gt;one-file, one-helper&lt;/strong&gt; pattern that closes most of those leaks at a remarkably low cost.&lt;/p&gt;

</description>
      <category>python</category>
      <category>webdev</category>
      <category>programming</category>
    </item>
    <item>
      <title>Maintaining WordPress sites behind HTTP Basic auth — Playwright, urllib, and encrypted credentials</title>
      <dc:creator>Susumu Takahashi</dc:creator>
      <pubDate>Wed, 01 Jul 2026 00:56:50 +0000</pubDate>
      <link>https://dev.to/susumun/maintaining-wordpress-sites-behind-http-basic-auth-playwright-urllib-and-encrypted-credentials-5f2f</link>
      <guid>https://dev.to/susumun/maintaining-wordpress-sites-behind-http-basic-auth-playwright-urllib-and-encrypted-credentials-5f2f</guid>
      <description>&lt;p&gt;It's pretty common to throw a layer of HTTP Basic auth on a WordPress site: a staging environment before launch, an internal test instance only employees should see, or any environment that wants an extra gate before the WordPress login screen itself.&lt;/p&gt;

&lt;p&gt;From a maintenance-tool point of view, this setup creates a peculiar &lt;strong&gt;"half-working, half-broken"&lt;/strong&gt; asymmetry. The SSH/WP-CLI side runs fine. But everything HTTP-based — visual checks, thumbnail generation, browser-based fallback updates — hits 401 and dies. This post walks through how we resolved that asymmetry.&lt;/p&gt;

&lt;h2&gt;
  
  
  What was breaking — two parallel paths, both blocked
&lt;/h2&gt;

&lt;p&gt;A maintenance tool actually touches a Basic-auth-protected site through two distinct paths:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Playwright path&lt;/strong&gt;: visual checks, thumbnail capture, browser fallback updates when SSH isn't available. &lt;code&gt;browser.new_context()&lt;/code&gt; → navigation → screenshot&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;urllib path&lt;/strong&gt;: HTTP status checks (pre/post-update 200/5xx/4xx monitoring, rollback decisions)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;With no credentials, both paths see a &lt;strong&gt;401 Unauthorized&lt;/strong&gt; from the protected site.&lt;/p&gt;

&lt;p&gt;The Playwright symptom is the obvious one: the screenshot you save is the browser's "authentication required" dialog. The thumbnail grid fills with dark auth-prompt images, and you start wondering whether anything actually works.&lt;/p&gt;

&lt;p&gt;The urllib symptom is much worse — &lt;strong&gt;it silently breaks rollback decisions&lt;/strong&gt;. A 401 baseline followed by another 401 after the update looks like "nothing changed = healthy." Real failures can hide behind that match, and the rollback that should have fired never does.&lt;/p&gt;

&lt;h2&gt;
  
  
  The design — consolidate credential extraction into one helper
&lt;/h2&gt;

&lt;p&gt;When the same credentials need to flow through multiple code paths, picking them out of the site dict separately at each call site invites format-mismatch and missed-update bugs. So the first thing we did was build a small &lt;code&gt;core/basic_auth_utils.py&lt;/code&gt; module that &lt;strong&gt;owns every form of credential extraction&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# core/basic_auth_utils.py
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_basic_auth_tuple&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;site&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Return (user, password), or None if not configured.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;site&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;site&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;basic_auth_user&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;''&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;pw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;site&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;basic_auth_password&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;''&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;  &lt;span class="c1"&gt;# No user → treat as "no auth"
&lt;/span&gt;    &lt;span class="nf"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pw&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_playwright_http_credentials&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;site&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Returns dict for Playwright&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s new_context(http_credentials=...).&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;auth&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_basic_auth_tuple&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;site&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;username&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;password&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]}&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_basic_auth_header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;site&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Returns {&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Authorization&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;: &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Basic &amp;lt;base64&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;} for urllib.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;auth&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_basic_auth_tuple&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;site&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
    &lt;span class="n"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;encoded&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;base64&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;b64encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;ascii&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Authorization&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Basic &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;encoded&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key idea: &lt;strong&gt;both the Playwright and urllib forms derive from the same &lt;code&gt;get_basic_auth_tuple()&lt;/code&gt; root&lt;/strong&gt;. Format drift between the two callers becomes structurally impossible.&lt;/p&gt;

&lt;h2&gt;
  
  
  Backward compatibility — existing sites don't have these fields
&lt;/h2&gt;

&lt;p&gt;A small but important detail: &lt;strong&gt;the existing site-configuration JSON has no &lt;code&gt;basic_auth_user&lt;/code&gt; / &lt;code&gt;basic_auth_password&lt;/code&gt; keys&lt;/strong&gt;. Naively writing &lt;code&gt;site['basic_auth_user']&lt;/code&gt; would crash with &lt;code&gt;KeyError&lt;/code&gt; the moment someone opens an existing site.&lt;/p&gt;

&lt;p&gt;We went with the &lt;code&gt;site.get('basic_auth_user') or ''&lt;/code&gt; empty-string-fallback pattern. Missing key, empty string, or &lt;code&gt;None&lt;/code&gt; all collapse to "no auth," so existing sites behave exactly as before. Only the sites that actually set Basic auth flip into the authenticated path.&lt;/p&gt;

&lt;p&gt;On top of that, the Basic auth password gets the same treatment as the WordPress admin password: &lt;strong&gt;Fernet-encrypted at rest&lt;/strong&gt;. Adding &lt;code&gt;'basic_auth_password'&lt;/code&gt; to the &lt;code&gt;ENCRYPTED_SITE_KEYS&lt;/code&gt; constant is all it takes — encryption and decryption happen automatically on save and load.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wiring both paths
&lt;/h2&gt;

&lt;p&gt;With the helpers in place, the call sites get rewired.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Playwright path&lt;/strong&gt;: one shared helper &lt;code&gt;_new_context_with_auth(browser, site)&lt;/code&gt; replaces three &lt;code&gt;new_context()&lt;/code&gt; call sites (visual check, thumbnail capture, browser residual update) at once.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_new_context_with_auth&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;site&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;http_credentials&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_playwright_http_credentials&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;site&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;http_credentials&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new_context&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;http_credentials&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;http_credentials&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new_context&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;urllib path&lt;/strong&gt;: &lt;code&gt;_http_status_check(url, basic_auth=None)&lt;/code&gt; gains a &lt;code&gt;basic_auth&lt;/code&gt; parameter and sends the Authorization header internally. The "baseline 401 → post-update 401" false negative disappears — the rollback decision now sees the real status code after authentication (200 / 5xx / etc.) and fires correctly.&lt;/p&gt;

&lt;p&gt;Inside the maintenance main loop &lt;code&gt;run_ssh_maintenance&lt;/code&gt;, we &lt;strong&gt;extract &lt;code&gt;_basic_auth&lt;/code&gt; from the site dict exactly once&lt;/strong&gt; and pass it into all five &lt;code&gt;_http_status_check_stable()&lt;/code&gt; calls. Pulling it out from the dict at every call site invites "this one place forgot," so a local variable is the safer move.&lt;/p&gt;

&lt;h2&gt;
  
  
  UI — keep the rare case out of the way
&lt;/h2&gt;

&lt;p&gt;From a user perspective, Basic-auth-protected sites are the minority. Putting two always-visible input fields in the site-add modal would clutter the UI for the 99% of sites that don't need them.&lt;/p&gt;

&lt;p&gt;So we put the credentials behind a &lt;code&gt;&amp;lt;details&amp;gt;&lt;/code&gt; element — &lt;strong&gt;a collapsed-by-default "🔐 Basic Auth (optional)" section&lt;/strong&gt; at the end of the WordPress info group in the site-add/edit modal.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;details&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;summary&amp;gt;&lt;/span&gt;🔐 Basic Auth (optional)&lt;span class="nt"&gt;&amp;lt;/summary&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;input&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"basic_auth_user"&lt;/span&gt; &lt;span class="na"&gt;placeholder=&lt;/span&gt;&lt;span class="s"&gt;"Auth username"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;input&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"password"&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"basic_auth_password"&lt;/span&gt;
         &lt;span class="na"&gt;placeholder=&lt;/span&gt;&lt;span class="s"&gt;"Auth password"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/details&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Users with Basic-auth-protected sites expand and fill it in; the rest see no change. Saved passwords go through Fernet with the &lt;code&gt;ENC:&lt;/code&gt; prefix.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing — patterns for "same credentials, multiple paths"
&lt;/h2&gt;

&lt;p&gt;Three principles worth keeping from this round:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;When multiple paths need the same credentials, consolidate extraction into one helper.&lt;/strong&gt; Playwright form, urllib form, boolean check — each consumer wants a different shape, but they should all derive from a single &lt;code&gt;(user, password)&lt;/code&gt; root function. That keeps format drift from happening&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;For new fields on existing data, lean on empty-fallback patterns.&lt;/strong&gt; &lt;code&gt;site.get(key) or ''&lt;/code&gt; treats missing keys as "feature off," letting you add functionality without touching existing data. No migration needed&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hide rare features behind disclosure widgets.&lt;/strong&gt; A field used by 1% of sites doesn't deserve top-billing visual space. A &lt;code&gt;&amp;lt;details&amp;gt;&lt;/code&gt; collapse with a small icon expresses "optional feature" cleanly, and gets out of the way for everyone else&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Staging environments behind Basic auth aren't rare in the WordPress world. If your automation tool is "half working" against those sites, teams quickly fall back to manual maintenance just for that subset. Designing a clean credential path through both the Playwright and urllib sides — once — is worth the up-front investment.&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>php</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>When paramiko's defaults silently get your IP banned — the look_for_keys and allow_agent trap</title>
      <dc:creator>Susumu Takahashi</dc:creator>
      <pubDate>Tue, 30 Jun 2026 00:23:38 +0000</pubDate>
      <link>https://dev.to/susumun/when-paramikos-defaults-silently-get-your-ip-banned-the-lookforkeys-and-allowagent-trap-4j81</link>
      <guid>https://dev.to/susumun/when-paramikos-defaults-silently-get-your-ip-banned-the-lookforkeys-and-allowagent-trap-4j81</guid>
      <description>&lt;p&gt;One day a multi-site administrator reported a strange bug: "After running the app's SSH connection test 2-3 times, &lt;strong&gt;my IP can't reach SSH on that server for a long while&lt;/strong&gt;." The errors came back as &lt;code&gt;Connection refused&lt;/code&gt; or &lt;code&gt;Connection closed by ...&lt;/code&gt;. The server wasn't down, and SSH from a different IP worked fine. &lt;strong&gt;The source IP was being temporarily banned at the server.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Two external investigation reports gave the cause: server-side protection mechanisms (&lt;strong&gt;fail2ban&lt;/strong&gt; or &lt;strong&gt;PerSourcePenalties&lt;/strong&gt; in OpenSSH 25+) detect short-windowed authentication failure spikes and temporarily ban the source IP. But the user had only clicked the test button 2-3 times — why were failures "spiking"?&lt;/p&gt;

&lt;p&gt;The answer turned out to be &lt;strong&gt;paramiko's default behavior&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  paramiko's default — trying many keys per connection
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;paramiko.SSHClient.connect()&lt;/code&gt; defaults two options to &lt;code&gt;True&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;host&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;pkey&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;my_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="c1"&gt;# The following are True by default:
&lt;/span&gt;    &lt;span class="c1"&gt;# look_for_keys=True,   # also try ~/.ssh/id_* files
&lt;/span&gt;    &lt;span class="c1"&gt;# allow_agent=True,     # also try ssh-agent registered keys
&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When the explicitly passed &lt;code&gt;pkey&lt;/code&gt; fails, paramiko falls back through &lt;strong&gt;ssh-agent registered keys → &lt;code&gt;~/.ssh/id_*&lt;/code&gt; files → password auth&lt;/strong&gt; in order. Convenient for developers with a single key. Disastrous for a multi-site administrator:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The SSH agent has multiple per-site keys registered&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;~/.ssh/&lt;/code&gt; holds several &lt;code&gt;id_rsa&lt;/code&gt; / &lt;code&gt;id_ed25519&lt;/code&gt; files&lt;/li&gt;
&lt;li&gt;A single connect call ends up trying &lt;strong&gt;5-10 keys in sequence&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;That blows past the server's &lt;code&gt;MaxAuthTries&lt;/code&gt; (default 6) on &lt;strong&gt;a single connection&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So what looked to the user like "one connection test" was being seen by the server as "&lt;strong&gt;a suspicious IP racking up 5-10 auth failures in a row&lt;/strong&gt;." Repeat that 2-3 times and the protection mechanism declares the IP "exceeded threshold" and bans it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix — &lt;code&gt;look_for_keys=False&lt;/code&gt; and &lt;code&gt;allow_agent=False&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;paramiko exposes options to scope key trial. We set them explicitly in &lt;code&gt;connect_kwargs&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;connect_kwargs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;pkey&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;my_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;look_for_keys&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;# don't try ~/.ssh/id_*
&lt;/span&gt;    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;allow_agent&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="c1"&gt;# don't try ssh-agent keys
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;host&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;connect_kwargs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now "the explicitly passed &lt;code&gt;pkey&lt;/code&gt; and nothing else" gets tried — failure ends in one attempt per connection. The &lt;code&gt;MaxAuthTries&lt;/code&gt;-overrun path is gone.&lt;/p&gt;

&lt;p&gt;Backward compatibility is preserved. Existing &lt;code&gt;pkey&lt;/code&gt; / &lt;code&gt;key_filename&lt;/code&gt; users see no change, and password-auth users are unaffected.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lesson from V12: fix the same bug in 10 places at once
&lt;/h2&gt;

&lt;p&gt;This is where the principle from &lt;a href="https://en.wpmm.jp/blog/ssh-csh-bash-portability/" rel="noopener noreferrer"&gt;the csh / bash-syntax SSH command bug&lt;/a&gt; paid off: &lt;strong&gt;the moment you find this kind of bug, grep for the same pattern everywhere&lt;/strong&gt;. A full sweep of &lt;code&gt;Connection(...)&lt;/code&gt; calls found &lt;strong&gt;10 sites&lt;/strong&gt; with missing or empty &lt;code&gt;connect_kwargs&lt;/code&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Location&lt;/th&gt;
&lt;th&gt;Role&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;core/ssh_utils.py::get_ssh_connection&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Maintenance main path&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;save_server_profile&lt;/code&gt; WP-CLI auto-detect&lt;/td&gt;
&lt;td&gt;Profile save&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;test_ssh_profile&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Connection test&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;discover_server_paths&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Path discovery&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;test_wpcli&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;WP-CLI test&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;install_wpcli&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;WP-CLI install&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;diagnose_server&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Server diagnosis&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;fetch_plugins&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Plugin list fetch&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;fetch_pending_plugins_for_site&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Pending plugin fetch&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;save_site&lt;/code&gt; WP-CLI auto-detect&lt;/td&gt;
&lt;td&gt;Site save&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;All 10 got a comment explaining the IP-block prevention rationale. If we'd patched only one, the same bug would have come back through a different code path.&lt;/p&gt;

&lt;h2&gt;
  
  
  We also removed a warning UI — automatic retries made it worse
&lt;/h2&gt;

&lt;p&gt;In an earlier round we'd actually tried a different approach: &lt;strong&gt;diagnose private-key permissions before the connection test → show a warning + "Fix and connect" button&lt;/strong&gt;. Well intentioned, but in this exact scenario it backfired completely.&lt;/p&gt;

&lt;p&gt;The user-experienced sequence was:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Warning: "Your SSH key permissions are loose. Want to fix them?"&lt;/li&gt;
&lt;li&gt;User clicks "Fix and connect" → internal &lt;code&gt;chmod 600&lt;/code&gt; → connection test auto-retries&lt;/li&gt;
&lt;li&gt;Retry hits the same multi-key trial → fails&lt;/li&gt;
&lt;li&gt;"Authentication failed" → try another key → fail again&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Failures spike → IP ban triggers&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;"Warn the user, auto-fix, auto-retry" turns out to be a UX pattern that &lt;strong&gt;amplifies failure counts when the root cause lies elsewhere&lt;/strong&gt;. The auto-retry hides multiple attempts behind a single user click.&lt;/p&gt;

&lt;p&gt;Worse, &lt;strong&gt;paramiko doesn't enforce OpenSSH's StrictModes check&lt;/strong&gt;, so for this app's purposes the key works fine even with loose permissions. The "helpful" preventive warning was actually overreach in our specific context.&lt;/p&gt;

&lt;p&gt;We removed the warning UI in the same round (&lt;code&gt;-120 / +31 = net 89 lines deleted&lt;/code&gt;). The &lt;code&gt;_diagnoseAndOfferFix()&lt;/code&gt; function shell is kept for backward-compatible signatures, but its body is now a no-op.&lt;/p&gt;

&lt;h2&gt;
  
  
  Regression defense — an AST test that forbids empty &lt;code&gt;connect_kwargs&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Same two-layer defense pattern as V12: a regression test that fails the build if the bug ever comes back. &lt;code&gt;tests/test_ssh_connection_isolation.py&lt;/code&gt; ships with 6 tests:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Sketch
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ast&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_all_connect_kwargs_have_look_for_keys_false&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Verify that every connect_kwargs / ck initialization
       contains &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;look_for_keys&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;: False and &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;allow_agent&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;: False.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="nb"&gt;file&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;CORE_SSH_UTILS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;SITE_MANAGER_WEB&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;assign&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;find_connect_kwargs_assignments&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;keys&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;extract_dict_keys&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;assign&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;look_for_keys&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; \
                &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;assign&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lineno&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; missing look_for_keys=False&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
            &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;allow_agent&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; \
                &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;assign&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lineno&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; missing allow_agent=False&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If someone later adds a new SSH API and leaves &lt;code&gt;connect_kwargs = {}&lt;/code&gt;, the build fails. Reading &lt;code&gt;assign.value&lt;/code&gt; directly from the AST keeps comment / docstring strings from causing false positives.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing — three principles
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Library defaults aren't always "correct" for your environment.&lt;/strong&gt; paramiko's &lt;code&gt;look_for_keys=True&lt;/code&gt; / &lt;code&gt;allow_agent=True&lt;/code&gt; is reasonable fallback behavior for single-key users, but dangerous in multi-key environments. Re-read library docs &lt;strong&gt;through the lens of your actual deployment environment&lt;/strong&gt; before trusting defaults&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Warning UI + auto-retry can amplify the problem.&lt;/strong&gt; "Warn the user, auto-fix, auto-retry" is well-intentioned but when the root cause lies somewhere else, the auto-retry compounds the failure count. UX-layer "helpfulness" can become operational debt&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Find a same-shaped bug? Grep + AST regression test on the spot.&lt;/strong&gt; Third entry in this pattern. The &lt;code&gt;/bin/sh -c&lt;/code&gt; wrap / &lt;code&gt;_safe_run&lt;/code&gt; helper (V12) and now the &lt;code&gt;connect_kwargs&lt;/code&gt; scoping share the same shape: a fix needs to apply across every SSH path in the codebase. Cross-grep + AST regression test as a standard step changes how confident you can be about "did we get them all?"&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;SSH-related code tends to be exactly where "library defaults break under our environment" and "the same pattern scatters across many call sites" collide. If you're building a multi-site administrator tool on top of paramiko, defaulting &lt;code&gt;look_for_keys&lt;/code&gt; and &lt;code&gt;allow_agent&lt;/code&gt; to &lt;code&gt;False&lt;/code&gt; from the start is the safer bet — that's what this round taught us.&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>php</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
