← 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
M public/index.htmlM server.js
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 →