← back to Vendor Agents Viewer

public/index.html

211 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)}
  /* view toggle */
  .vav-vseg{display:flex;border:1px solid var(--line);border-radius:7px;overflow:hidden}
  .vav-vseg button{padding:5px 11px;background:#12141a;border:none;font-size:12px;color:var(--mut);cursor:pointer;font-family:inherit}
  .vav-vseg button.on{background:var(--accent);color:#0f1115}
  /* fields menu */
  .vav-fieldmenu{position:relative}
  .vav-fbtn{background:#12141a;color:var(--mut);border:1px solid var(--line);border-radius:7px;padding:6px 10px;font-size:12px;cursor:pointer}
  .vav-fbtn:hover{border-color:var(--accent);color:var(--txt)}
  .vav-fpop{position:absolute;top:calc(100% + 6px);right:0;background:#12141a;border:1px solid var(--line);border-radius:9px;box-shadow:0 10px 28px rgba(0,0,0,.6);padding:8px 4px;z-index:30;min-width:175px}
  .vav-fpop[hidden]{display:none}
  .vav-fpop label{display:flex;align-items:center;gap:8px;padding:6px 12px;font-size:12px;color:var(--mut);cursor:pointer;white-space:nowrap}
  .vav-fpop label:hover{color:var(--txt)}
  .vav-fpop input{accent-color:var(--accent);width:13px;height:13px;margin:0;padding:0}
  /* grid view */
  .grid{display:grid;grid-template-columns:repeat(var(--cols),1fr);gap:14px;padding:18px 22px}
  /* list view */
  .grid.list{display:flex;flex-direction:column;gap:6px;padding:14px 22px}
  .grid.list .card{flex-direction:row;align-items:flex-start;border-radius:8px;padding:10px 14px;gap:14px;flex-wrap:wrap}
  .grid.list .card .row1{flex:1;min-width:180px}
  .grid.list .card .desc{flex:2;min-width:160px;min-height:0}
  .grid.list .card .kv{flex:2;min-width:180px}
  .grid.list .card .btns{flex:1;min-width:120px;margin-top:0}
  /* field visibility */
  body.vav-hf-desc [data-f="vav-desc"]{display:none!important}
  body.vav-hf-kv [data-f="vav-kv"]{display:none!important}
  body.vav-hf-when [data-f="vav-when"]{display:none!important}
  body.vav-hf-scraper [data-f="vav-scraper"]{display:none!important}
  body.vav-hf-btns [data-f="vav-btns"]{display:none!important}
  body.vav-hf-badge [data-f="vav-badge"]{display:none!important}
  .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>
    <div class="vav-vseg">
      <button id="vavVGrid" class="on" title="Grid view">⊞ Grid</button>
      <button id="vavVList" title="List view">☰ List</button>
    </div>
    <div class="vav-fieldmenu">
      <button class="vav-fbtn" id="vavFieldsBtn" aria-haspopup="true" aria-expanded="false">Fields ▾</button>
      <div class="vav-fpop" id="vavFpop" hidden role="menu"></div>
    </div>
    <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'}):'—';
// ── href-to-deeper-data primitives (HARD rule) — every displayed data point is an href to its
//    filtered view; filter state is URL-addressable (shareable + deep-link + back-button). ──
const esc0=s=>(s==null?'':String(s)).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/"/g,'&quot;');
const FIL={vendor:''};                                  // vendor is the drill-only facet; status uses #filter, q uses #q
function qstr(over){                                     // build the URL for the current (optionally overridden) filter set
  const f=Object.assign({vendor:FIL.vendor,status:$('#filter').value,q:$('#q').value.trim()},over||{});
  const u=new URLSearchParams();
  if(f.vendor)u.set('vendor',f.vendor);
  if(f.status&&f.status!=='all')u.set('status',f.status);
  if(f.q)u.set('q',f.q);
  const s=u.toString(); return s?('?'+s):location.pathname;
}
// drill(field,val,label) → an <a> whose href IS the filtered view for that value
const drill=(field,val,label,cls)=>{ if(val==null||val==='')return esc0(label!=null?label:val);
  return `<a class="drill ${cls||''}" data-filter="${field}" data-v="${esc0(val)}" href="${esc0(qstr({[field]:String(val)}))}" title="Filter to ${esc0(val)}">${esc0(label!=null?label:val)}</a>`; };
function readURL(){ const u=new URLSearchParams(location.search); FIL.vendor=u.get('vendor')||''; $('#filter').value=u.get('status')||'all'; $('#q').value=u.get('q')||''; }
function writeURL(push){ history[push?'pushState':'replaceState']({},'',qstr()); }
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) && (!FIL.vendor||(x.vendor_name||'')===FIL.vendor));
  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?drill('vendor',x.vendor_name,x.vendor_name):''}${x.agent_port?' · :'+x.agent_port:''}</div></div>
        <span data-f="vav-badge">${drill('status',x.status,x.status,'badge '+x.status)}</span>
      </div>
      <div class="desc" data-f="vav-desc">${x.description||''}</div>
      <div class="kv" data-f="vav-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" data-f="vav-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" data-f="vav-when" title="${x.updated_at||''}">🕓 updated ${fmtDate(x.updated_at)}</div>
      <div class="kv" data-f="vav-scraper"><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" data-f="vav-btns">
        ${x.url?`<a rel="noopener noreferrer" 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();
}
// ── Grid / List toggle + Fields menu ──
const VAV_FIELDS=[['desc','Description'],['kv','Counts (Catalog/Shopify)'],['when','Updated Date'],['scraper','Scraper ID'],['btns','Action Buttons'],['badge','Status Badge']];
const VAV_FIELDS_DEF=Object.fromEntries(VAV_FIELDS.map(([k])=>[k,true]));
let vavFieldVis=Object.assign({},VAV_FIELDS_DEF,JSON.parse(localStorage.getItem('vav_fields')||'{}'));
function applyVavFields(){
  VAV_FIELDS.forEach(([k])=>document.body.classList.toggle('vav-hf-'+k,!vavFieldVis[k]));
  document.querySelectorAll('#vavFpop input[data-field]').forEach(cb=>cb.checked=!!vavFieldVis[cb.dataset.field]);
  localStorage.setItem('vav_fields',JSON.stringify(vavFieldVis));
}
function buildVavFieldMenu(){
  const fp=document.getElementById('vavFpop');
  fp.innerHTML=VAV_FIELDS.map(([k,lbl])=>`<label><input type="checkbox" data-field="${k}"${vavFieldVis[k]?' checked':''}> ${lbl}</label>`).join('');
  fp.querySelectorAll('input').forEach(cb=>cb.addEventListener('change',()=>{vavFieldVis[cb.dataset.field]=cb.checked;localStorage.setItem('vav_fields',JSON.stringify(vavFieldVis));applyVavFields();}));
}
function setVavView(v){
  document.getElementById('grid').classList.toggle('list',v==='list');
  document.getElementById('vavVGrid').classList.toggle('on',v==='grid');
  document.getElementById('vavVList').classList.toggle('on',v==='list');
  localStorage.setItem('vav_view',v);
}
document.getElementById('vavVGrid').onclick=()=>setVavView('grid');
document.getElementById('vavVList').onclick=()=>setVavView('list');
setVavView(localStorage.getItem('vav_view')||'grid');
buildVavFieldMenu();
applyVavFields();
(function(){
  const btn=document.getElementById('vavFieldsBtn'), fp=document.getElementById('vavFpop');
  btn.addEventListener('click',e=>{e.stopPropagation();const h=fp.hidden;fp.hidden=!h;btn.setAttribute('aria-expanded',String(h));});
  document.addEventListener('click',()=>{if(!fp.hidden){fp.hidden=true;btn.setAttribute('aria-expanded','false');}});
  fp.addEventListener('click',e=>e.stopPropagation());
})();
// 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',()=>{writeURL(false);render();});
$('#filter').addEventListener('change',()=>writeURL(true));
// click a data point → filter in place + reflect into the URL (cmd/ctrl/middle-click opens the shareable href in a new tab)
document.addEventListener('click',e=>{ const dr=e.target.closest('.drill'); if(!dr)return; if(e.metaKey||e.ctrlKey||e.shiftKey||e.button===1)return; e.preventDefault();
  const f=dr.dataset.filter, v=dr.dataset.v;
  if(f==='vendor') FIL.vendor = (FIL.vendor===v?'':v);
  else if(f==='status') $('#filter').value = ($('#filter').value===v?'all':v);
  writeURL(true); render(); });
addEventListener('popstate',()=>{ readURL(); render(); });   // back/forward restores filter state
readURL();                                                   // land in the filter state carried by the URL
load();setInterval(load,45000);
</script>
</body>
</html>