← back to Vendor Agents Viewer
public/index.html
118 lines
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Vendor Agents — Review · Launch · Restart</title>
<style>
:root{--bg:#0f1115;--card:#1a1d24;--line:#2a2f3a;--txt:#e8eaed;--mut:#9aa0aa;--on:#34d399;--off:#6b7280;--conf:#f59e0b;--accent:#34e2f4;--cols:4}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--txt);font:14px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif}
header{padding:18px 22px;border-bottom:1px solid var(--line);position:sticky;top:0;background:rgba(15,17,21,.96);backdrop-filter:blur(8px);z-index:5}
h1{margin:0 0 4px;font-size:19px;letter-spacing:.3px}
.stats{display:flex;gap:18px;flex-wrap:wrap;color:var(--mut);font-size:13px;margin-top:6px}
.stats b{color:var(--txt)}
.controls{display:flex;gap:14px;align-items:center;flex-wrap:wrap;margin-top:12px}
.controls label{color:var(--mut);font-size:12px;display:flex;gap:6px;align-items:center}
select,input[type=search]{background:#12141a;color:var(--txt);border:1px solid var(--line);border-radius:7px;padding:6px 9px;font-size:13px}
input[type=range]{accent-color:var(--accent)}
.grid{display:grid;grid-template-columns:repeat(var(--cols),1fr);gap:14px;padding:18px 22px}
.card{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:14px;display:flex;flex-direction:column;gap:8px}
.card.on{border-left:3px solid var(--on)} .card.off{border-left:3px solid var(--off)} .card.conflict{border-left:3px solid var(--conf)}
.row1{display:flex;justify-content:space-between;align-items:flex-start;gap:8px}
.nm{font-weight:650;font-size:15px} .ven{color:var(--mut);font-size:12px}
.badge{font-size:11px;padding:2px 8px;border-radius:20px;font-weight:600;white-space:nowrap}
.badge.on{background:rgba(52,211,153,.15);color:var(--on)} .badge.off{background:rgba(107,114,128,.18);color:var(--off)} .badge.conflict{background:rgba(245,158,11,.16);color:var(--conf)}
.desc{color:var(--mut);font-size:12.5px;min-height:34px}
.kv{display:flex;flex-wrap:wrap;gap:6px 14px;font-size:12px}
.kv span{color:var(--mut)} .kv b{color:var(--txt)}
.when{font-size:11px;color:var(--off)}
.btns{display:flex;gap:6px;flex-wrap:wrap;margin-top:4px}
button.act{background:#21262e;color:var(--txt);border:1px solid var(--line);border-radius:7px;padding:5px 10px;font-size:12px;cursor:pointer}
button.act:hover{border-color:var(--accent)} button.act.go{color:var(--accent)}
a.act{ text-decoration:none }
#log{position:fixed;right:14px;bottom:14px;max-width:380px;background:#0b0d11;border:1px solid var(--line);border-radius:10px;padding:10px 12px;font-size:12px;color:var(--mut);display:none}
</style>
</head>
<body>
<header>
<h1>🛰️ Vendor Agents — Review · Launch · Restart</h1>
<div class="stats" id="stats">loading…</div>
<div class="controls">
<input type="search" id="q" placeholder="filter by name / vendor…">
<label>Sort
<select id="sort">
<option value="status">Status (online first)</option>
<option value="port">Agent Port</option>
<option value="vendor">Vendor A→Z</option>
<option value="agent">Agent Name A→Z</option>
<option value="catalog">Catalog Count ↓</option>
<option value="shopify">Shopify Count ↓</option>
<option value="updated">Recently Updated</option>
</select>
</label>
<label>Show <select id="filter"><option value="all">All</option><option value="online">Online</option><option value="offline">Offline</option><option value="conflict">Port Conflict</option></select></label>
<label>Density <input type="range" id="dens" min="2" max="7" value="4"></label>
<button class="act" onclick="load()">↻ Refresh</button>
</div>
</header>
<div class="grid" id="grid"></div>
<div id="log"></div>
<script>
let ALL=[];
const $=s=>document.querySelector(s);
const fmtDate=d=>d?new Date(d).toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}):'—';
function logmsg(t){const l=$('#log');l.style.display='block';l.textContent=t;clearTimeout(l._t);l._t=setTimeout(()=>l.style.display='none',6000);}
async function act(ep,body,label){logmsg(label+'…');try{const r=await fetch('/api/'+ep,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});const j=await r.json();logmsg(label+': '+(j.ok?'ok ':'')+(j.out||j.error||'').slice(0,180));setTimeout(load,2500);}catch(e){logmsg(label+' failed: '+e.message);}}
function render(){
const q=$('#q').value.toLowerCase(), sort=$('#sort').value, filt=$('#filter').value;
let a=ALL.filter(x=>(!q||((x.agent_name||'')+ (x.vendor_name||'')).toLowerCase().includes(q)) && (filt==='all'||x.status===filt));
const num=k=>(x=>(x[k]||0));
const cmp={status:(a,b)=>(a.status==='online'?0:1)-(b.status==='online'?0:1)||(a.agent_port||1e9)-(b.agent_port||1e9),
port:(a,b)=>(a.agent_port||1e9)-(b.agent_port||1e9),
vendor:(a,b)=>(a.vendor_name||'').localeCompare(b.vendor_name||''),
agent:(a,b)=>(a.agent_name||'').localeCompare(b.agent_name||''),
catalog:(a,b)=>(b.catalog_count||0)-(a.catalog_count||0),
shopify:(a,b)=>(b.shopify_count||0)-(a.shopify_count||0),
updated:(a,b)=>new Date(b.updated_at||0)-new Date(a.updated_at||0)}[sort];
a.sort(cmp);
$('#grid').innerHTML=a.map(x=>`
<div class="card ${x.status}">
<div class="row1">
<div><div class="nm">${x.agent_name||'(unnamed)'}</div><div class="ven">${x.vendor_name||''}${x.agent_port?' · :'+x.agent_port:''}</div></div>
<span class="badge ${x.status}">${x.status}</span>
</div>
<div class="desc">${x.description||''}</div>
<div class="kv">
<span>Catalog <b>${(x.catalog_count||0).toLocaleString()}</b></span>
<span>Shopify <b>${(x.shopify_count||0).toLocaleString()}</b></span>
<span>Images <b>${(x.catalog_with_images||0).toLocaleString()}</b></span>
${x.website_score!=null?`<span>Site <b>${x.website_score}</b></span>`:''}
</div>
${x.conflict_owner?`<div class="when" style="color:var(--conf)">\u26a0 port :${x.agent_port} reused by <b>${x.conflict_owner}</b> \u2014 agent not running</div>`:""}
<div class="when" title="${x.updated_at||''}">🕓 updated ${fmtDate(x.updated_at)}</div>
<div class="kv"><span>Scraper <b style="color:${x.launchable?'var(--on)':'var(--off)'}">${x.scraper_id||(x.skip_catalog_scrape?(x.is_private_label?'n/a — private label':'n/a — no source'):'none built')}</b></span></div>
<div class="btns">
${x.url?`<a class="act go" href="${x.url}" target="_blank">Open</a>`:''}
${x.status==='online'
? `<button class="act" onclick='act("restart",{vendor_code:${JSON.stringify(x.vendor_code)}},"restart ${x.agent_name}")'>Restart</button>
<button class="act go" onclick='act("scan",{vendor_code:${JSON.stringify(x.vendor_code)}},"scan ${x.agent_name}")'>Scan</button>`
: `<button class="act go" onclick='act("launch",{vendor_code:${JSON.stringify(x.vendor_code)}},"launch ${x.agent_name}")'>Launch</button>`}
</div>
</div>`).join('')||'<p style="color:var(--mut);padding:20px">No agents match.</p>';
}
async function load(){
const r=await fetch('/api/agents');const j=await r.json();
if(!j.ok){$('#stats').textContent='error: '+j.error;return;}
ALL=j.agents;const s=j.summary;
$('#stats').innerHTML=`<span><b>${s.total}</b> agents</span><span><b style="color:var(--on)">${s.online}</b> online</span><span><b>${s.offline}</b> offline</span><span><b style="color:#f59e0b">${s.conflict||0}</b> port-conflict</span><span><b>${s.totalCatalog.toLocaleString()}</b> catalog SKUs</span><span><b>${s.totalShopify.toLocaleString()}</b> on Shopify</span>`;
render();
}
// persist controls
['sort','filter','dens'].forEach(id=>{const el=$('#'+id);const k='vav_'+id;if(localStorage[k])el.value=localStorage[k];el.addEventListener('input',()=>{localStorage[k]=el.value;if(id==='dens')document.documentElement.style.setProperty('--cols',el.value);else render();});});
document.documentElement.style.setProperty('--cols',$('#dens').value);
$('#q').addEventListener('input',render);
load();setInterval(load,45000);
</script>
</body>
</html>