← back to Whatsmystyle

public/admin-drifts.html

99 lines

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <meta name="robots" content="noindex,nofollow" />
  <title>WhatsMyStyle — Embedding-drift ledger</title>
  <link rel="stylesheet" href="/css/app.css" />
  <style>
    body { font-family: ui-sans-serif, system-ui, -apple-system, sans-serif; background: #faf7f2; color: #1d1d1f; max-width: 1100px; margin: 40px auto; padding: 0 24px; }
    h1 { font-size: 32px; font-weight: 600; letter-spacing: -0.02em; margin: 0 0 8px; }
    .sub { color: #707070; margin-bottom: 32px; font-size: 14px; }
    .card { background: #fff; border: 1px solid #e6e1d8; border-radius: 16px; padding: 18px 22px; margin: 14px 0; }
    table { width: 100%; border-collapse: collapse; font-size: 13px; }
    th, td { padding: 8px 10px; text-align: left; border-bottom: 1px solid #f0eadf; }
    th { font-weight: 600; color: #707070; font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; }
    .nav { margin-bottom: 24px; }
    .nav a { color: #707070; text-decoration: none; font-size: 14px; }
    .nav a:hover { color: #1d1d1f; }
    .thumb { width: 36px; height: 44px; object-fit: cover; border-radius: 6px; border: 1px solid #e6e1d8; }
    .sim { font-variant-numeric: tabular-nums; }
    .sim-low { color: #b87a13; font-weight: 600; }
    .sim-very-low { color: #b91c1c; font-weight: 700; }
    .muted { color: #707070; }
    .pill { display: inline-block; background: #faf7f2; border: 1px solid #e6e1d8; padding: 3px 10px; border-radius: 999px; font-size: 11px; color: #555; }
    .empty { text-align: center; padding: 60px 20px; color: #707070; font-style: italic; }
    h2 { font-size: 18px; font-weight: 600; margin: 0 0 14px; letter-spacing: -0.01em; }
  </style>
</head>
<body>
  <div class="nav"><a href="/admin-config">← admin config</a> · <a href="/">app home</a></div>
  <h1>Embedding-drift ledger</h1>
  <p class="sub">Every catalog item whose llava-projected vector deviated &gt;20% cosine from its stored value. Populated by the 24h drift cron + manual drift sweeps.</p>

  <div class="card">
    <h2>Summary</h2>
    <p><strong id="total">0</strong> drift events recorded · most-drifted item: <span id="top-item" class="muted">none yet</span></p>
  </div>

  <div class="card">
    <h2>Most-drifted items (top 50 by drift count)</h2>
    <table id="per-item-tbl">
      <thead><tr><th></th><th>Item</th><th>Drift count</th><th>Min similarity</th><th>Last seen</th></tr></thead>
      <tbody></tbody>
    </table>
  </div>

  <div class="card">
    <h2>Recent events (last 500)</h2>
    <table id="recent-tbl">
      <thead><tr><th></th><th>Item</th><th>Similarity</th><th>Checked at</th></tr></thead>
      <tbody></tbody>
    </table>
  </div>

  <script>
    const $ = (s) => document.querySelector(s);
    function escapeHtml(s) { return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }
    function simClass(s) {
      if (s == null) return '';
      if (s < 0.5) return 'sim-very-low';
      if (s < 0.7) return 'sim-low';
      return '';
    }
    async function load() {
      const r = await fetch('/api/admin/drifts');
      if (!r.ok) { document.body.innerHTML = '<p class="empty">admin gate failed</p>'; return; }
      const j = await r.json();
      $('#total').textContent = j.total;
      if (j.total === 0) {
        $('#per-item-tbl tbody').innerHTML = '<tr><td colspan="5" class="empty">No drift events yet — the 24h cron writes rows when similarity drops below 0.80.</td></tr>';
        $('#recent-tbl tbody').innerHTML = '';
        return;
      }
      $('#top-item').innerHTML = j.per_item.length ? `item #${j.per_item[0].item_id} (${j.per_item[0].c} drifts)` : 'none';
      $('#per-item-tbl tbody').innerHTML = j.per_item.map(r => `
        <tr>
          <td></td>
          <td><strong>#${r.item_id}</strong></td>
          <td>${r.c}</td>
          <td class="sim ${simClass(r.min_sim)}">${(r.min_sim ?? 0).toFixed(3)}</td>
          <td class="muted">${escapeHtml(r.last_seen)}</td>
        </tr>
      `).join('');
      $('#recent-tbl tbody').innerHTML = j.recent.map(r => `
        <tr>
          <td>${r.image_url ? `<img class="thumb" src="${escapeHtml(r.image_url)}" loading="lazy" alt="">` : ''}</td>
          <td>${r.product_url ? `<a href="${escapeHtml(r.product_url)}" target="_blank" rel="noopener">${escapeHtml(r.title || ('item #' + r.item_id))}</a>` : escapeHtml(r.title || ('item #' + r.item_id))}<br><span class="pill">${escapeHtml(r.brand || r.source || '')}</span></td>
          <td class="sim ${simClass(r.similarity)}">${(r.similarity ?? 0).toFixed(3)}</td>
          <td class="muted">${escapeHtml(r.checked_at)}</td>
        </tr>
      `).join('');
    }
    load();
    setInterval(load, 60 * 1000);
  </script>
</body>
</html>