[object Object]

← back to Dw Collections Viewer

Probe live HTTP status; flag redirected/parked sites in the grid

aa01c4577c794617300ab8223ab0f1c53b2f746d · 2026-05-06 17:34:02 -0700 · SteveStudio2

Steve noticed several site cards showed wrong heroes — they're nginx 301/302
redirects to consolidation domains, and the admin's screenshot probe was
showing the redirect target's hero. Misleading.

This change adds a HEAD probe + banner so consolidated sites are honest:

- New POST /api/sites/probe — HEAD-checks every site over HTTPS, parallelism 8,
  caches { http_status, redirect_target, probed_at } in data/site-status.json
- listSites() merges the probe cache into each site row
- index.html: new 📡 Probe live button in the toolbar; cards with 3xx render
  a gold redirect banner overlaying the hero ('→ <target> (301)') AND fade
  to 0.55 opacity so the redirect chain stops looking like a bug
- Cards with HTTP 0/5xx render a red 'offline' banner

Verified live: 46 sites probed in one click, 43 ok / 2 redirects (string→
linen, embroidered→fabric) / 1 offline. Banners render correctly.

Files touched

Diff

commit aa01c4577c794617300ab8223ab0f1c53b2f746d
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Wed May 6 17:34:02 2026 -0700

    Probe live HTTP status; flag redirected/parked sites in the grid
    
    Steve noticed several site cards showed wrong heroes — they're nginx 301/302
    redirects to consolidation domains, and the admin's screenshot probe was
    showing the redirect target's hero. Misleading.
    
    This change adds a HEAD probe + banner so consolidated sites are honest:
    
    - New POST /api/sites/probe — HEAD-checks every site over HTTPS, parallelism 8,
      caches { http_status, redirect_target, probed_at } in data/site-status.json
    - listSites() merges the probe cache into each site row
    - index.html: new 📡 Probe live button in the toolbar; cards with 3xx render
      a gold redirect banner overlaying the hero ('→ <target> (301)') AND fade
      to 0.55 opacity so the redirect chain stops looking like a bug
    - Cards with HTTP 0/5xx render a red 'offline' banner
    
    Verified live: 46 sites probed in one click, 43 ok / 2 redirects (string→
    linen, embroidered→fabric) / 1 offline. Banners render correctly.
---
 public/index.html | 41 +++++++++++++++++++++++++++++-
 server.js         | 75 +++++++++++++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 115 insertions(+), 1 deletion(-)

diff --git a/public/index.html b/public/index.html
index 97036bf..4d584df 100644
--- a/public/index.html
+++ b/public/index.html
@@ -27,6 +27,17 @@ main { flex: 1; display: grid; grid-template-columns: 1fr 480px; min-height: 0 }
   display: flex; flex-direction: column; transition: border-color .2s, transform .15s }
 .card:hover { border-color: var(--gold); transform: translateY(-1px) }
 .card.active { border-color: var(--gold); box-shadow: 0 0 0 1px var(--gold) }
+/* Visually de-emphasize parked / redirected sites in the grid */
+.card.is-redirect { opacity: 0.55 }
+.card.is-redirect:hover { opacity: 0.85 }
+.card .hero .status-banner {
+  position: absolute; left: 0; right: 0; bottom: 0; padding: 4px 8px;
+  font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 9px;
+  letter-spacing: 0.10em; text-transform: uppercase; line-height: 1.3;
+  background: rgba(12,10,8,0.82); backdrop-filter: blur(2px);
+}
+.card .hero .status-banner.redirect { color: var(--gold); border-top: 1px solid var(--gold) }
+.card .hero .status-banner.error    { color: var(--red, #c87a6e); border-top: 1px solid var(--red, #c87a6e) }
 .card .hero { aspect-ratio: 4/3; background: #000; position: relative; overflow: hidden }
 .card .hero img { width: 100%; height: 100%; object-fit: cover; display: block }
 .card .hero .empty { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
@@ -232,6 +243,7 @@ body.bulk .card.selected { border-color: var(--gold) }
       <button class="mode-toggle" id="bulkToggle" onclick="toggleBulk()">☐ Bulk select</button>
       <button class="mode-toggle" onclick="autoSeed(true)" title="Preview without saving">★ Auto-seed (preview)</button>
       <button class="mode-toggle" onclick="autoSeed(false)" title="Assign top-suggested collection to every site that has zero">★ Auto-seed (apply)</button>
+      <button class="mode-toggle" id="probeBtn" onclick="probeSites()" title="HEAD-probe every site to flag redirects / offline">📡 Probe live</button>
     </div>
     <div class="bulk-bar" id="bulkBar" style="display:none">
       <span><span id="bulkCount">0</span> selected</span>
@@ -295,12 +307,22 @@ function renderGrid() {
     const cls = s.product_count === 0 ? ' zero' : '';
     const sel = BULK_SELECTED.has(slug) ? ' selected' : '';
     const pinned = s.hero_sku ? ' · 📌' : '';
+    // Surface the live HTTP status — parked / 301-302 redirected sites get a banner
+    // so the misleading hero (pulled from the redirect target) is no longer confusing.
+    const isRedirect = s.http_status >= 300 && s.http_status < 400;
+    const isError    = s.http_status === 0 || (s.http_status >= 500 && s.http_status < 600);
+    const statusBanner = isRedirect
+      ? `<div class="status-banner redirect">→ ${esc(s.redirect_target || '?')} (${s.http_status})</div>`
+      : isError
+      ? `<div class="status-banner error">offline · ${esc(s.redirect_target || s.http_status || '?')}</div>`
+      : '';
     return `
-      <div class="card${CURRENT === slug ? ' active' : ''}${sel}" data-slug="${slug}" onclick="cardClick(event,'${slug}')">
+      <div class="card${CURRENT === slug ? ' active' : ''}${sel}${isRedirect ? ' is-redirect' : ''}" data-slug="${slug}" onclick="cardClick(event,'${slug}')">
         <div class="check">${BULK_SELECTED.has(slug) ? '✓' : ''}</div>
         <div class="hero" data-slug="${slug}">
           ${s.cover_image_url || s.hero ? `<img src="${esc(s.cover_image_url || s.hero.image_url)}" alt="${esc((s.hero && s.hero.title) || slug)}" loading="lazy" onerror="this.style.opacity=0.15">` : `<div class="empty">no hero</div>`}
           ${s.cover_image_url ? `<div class="cover-dot" title="custom cover"></div>` : ''}
+          ${statusBanner}
         </div>
         <div class="info">
           <div class="name">${esc(slug)}${pinned}</div>
@@ -348,6 +370,23 @@ function selectByCount(mode) {
   document.getElementById('bulkCount').textContent = BULK_SELECTED.size;
   renderGrid();
 }
+async function probeSites() {
+  const btn = document.getElementById('probeBtn');
+  const orig = btn.textContent;
+  btn.disabled = true; btn.textContent = '📡 probing…';
+  try {
+    const r = await fetch('api/sites/probe', { method: 'POST' });
+    const j = await r.json();
+    if (!r.ok) throw new Error(j.error || 'probe failed');
+    toast(`Probed ${j.total} sites · ${j.counts.ok} ok · ${j.counts.redirect} redirects · ${j.counts.error} offline`, true);
+    await loadSites();
+  } catch (e) {
+    toast('probe failed: ' + e.message, false);
+  } finally {
+    btn.disabled = false; btn.textContent = orig;
+  }
+}
+
 async function autoSeed(dryRun) {
   const verb = dryRun ? 'Preview auto-seed' : 'Apply auto-seed';
   if (!dryRun && !confirm('Apply auto-seed across the fleet?\n\nThis assigns the top-scoring DW Shopify collection to every site that currently has zero collections. Sites with no high-confidence match are skipped (gaps reported).')) return;
diff --git a/server.js b/server.js
index 30f6c8d..2a768eb 100644
--- a/server.js
+++ b/server.js
@@ -110,14 +110,22 @@ async function fetchCollectionProducts(handle, max = 600) {
 }
 
 // ─── Site enumeration ────────────────────────────────────────────────────────
+// Cache of HTTP status + redirect chain per site, keyed by slug.
+// Populated by POST /api/sites/probe and merged into listSites() output.
+const SITE_STATUS_PATH = path.join(CACHE_DIR, 'site-status.json');
+function loadSiteStatus() { return readJsonSafe(SITE_STATUS_PATH) || {}; }
+function saveSiteStatus(obj) { fs.writeFileSync(SITE_STATUS_PATH, JSON.stringify(obj, null, 2)); }
+
 function listSites() {
   const rules = loadRules();
+  const status = loadSiteStatus();
   const sites = {};
   for (const slug of Object.keys(rules.sites)) {
     const rule = rules.sites[slug];
     const fp = path.join(HOME, 'Projects', slug, 'data', 'products.json');
     const products = readJsonSafe(fp) || [];
     const hero = products[0] || {};
+    const st = status[slug] || {};
     sites[slug] = {
       slug,
       domain: rule.domain,
@@ -134,11 +142,62 @@ function listSites() {
         sku: hero.sku || '',
       } : null,
       hero_history: rule.hero_history || [],  // for visual-diff (most-recent first)
+      http_status: st.http_status || null,
+      redirect_target: st.redirect_target || null,
+      probed_at: st.probed_at || null,
     };
   }
   return sites;
 }
 
+// Probe one domain — capture http_status + (if redirect) redirect_target chain end.
+function probeOne(domain, timeoutMs = 7000) {
+  return new Promise(resolve => {
+    if (!domain) return resolve({ http_status: null, redirect_target: null });
+    const url = new URL(`https://${domain}/`);
+    const req = https.request({
+      method: 'HEAD', hostname: url.hostname, path: url.pathname,
+      headers: { 'User-Agent': 'dw-collections-viewer/1.0', Accept: '*/*' },
+      timeout: timeoutMs,
+    }, res => {
+      const code = res.statusCode;
+      const loc = res.headers.location;
+      // Don't follow — surface the immediate redirect target so the UI can show "→ X"
+      let redirect_target = null;
+      if (loc && code >= 300 && code < 400) {
+        try { redirect_target = new URL(loc, `https://${domain}/`).hostname; } catch { redirect_target = String(loc).slice(0, 200); }
+      }
+      resolve({ http_status: code, redirect_target, probed_at: new Date().toISOString() });
+    });
+    req.on('error', () => resolve({ http_status: 0, redirect_target: null, probed_at: new Date().toISOString() }));
+    req.on('timeout', () => { req.destroy(); resolve({ http_status: 0, redirect_target: 'timeout', probed_at: new Date().toISOString() }); });
+    req.end();
+  });
+}
+
+async function probeAllSites(concurrency = 8) {
+  const rules = loadRules();
+  const slugs = Object.keys(rules.sites);
+  const status = loadSiteStatus();
+  let inFlight = 0, idx = 0;
+  await new Promise(done => {
+    const next = () => {
+      while (inFlight < concurrency && idx < slugs.length) {
+        const slug = slugs[idx++];
+        const domain = rules.sites[slug].domain;
+        inFlight++;
+        probeOne(domain).then(r => { status[slug] = r; }).catch(() => {}).finally(() => {
+          inFlight--;
+          if (idx >= slugs.length && inFlight === 0) done(); else next();
+        });
+      }
+    };
+    next();
+  });
+  saveSiteStatus(status);
+  return status;
+}
+
 // Heuristic seed: suggest collection handles that look related to a slug.
 // Called by the viewer when a site has zero assigned collections.
 function suggestCollections(slug, allColls) {
@@ -267,6 +326,22 @@ app.use(express.json({ limit: '12mb' }));
 
 app.get('/api/sites', (req, res) => res.json({ sites: listSites() }));
 
+// Re-probe every site over HTTPS and cache the immediate redirect target.
+// Surfaces parked / consolidated domains so the admin grid can flag them
+// instead of pulling the redirect-target's hero and looking like a bug.
+app.post('/api/sites/probe', async (_req, res) => {
+  try {
+    const status = await probeAllSites(8);
+    const counts = { ok: 0, redirect: 0, error: 0 };
+    for (const v of Object.values(status)) {
+      if (v.http_status >= 200 && v.http_status < 300) counts.ok++;
+      else if (v.http_status >= 300 && v.http_status < 400) counts.redirect++;
+      else counts.error++;
+    }
+    res.json({ ok: true, counts, total: Object.keys(status).length });
+  } catch (e) { res.status(500).json({ error: e.message }); }
+});
+
 app.get('/api/collections', async (req, res) => {
   try {
     const cs = await fetchCollectionsCatalog();

← 3a36c5f Drag product → cover image + sort by color/style/SKU + grid-  ·  back to Dw Collections Viewer  ·  save+deploy: sort selector (textures/newest/title/sku/priceU 71f7337 →