← back to Agentabrams Viewer

public/index.html

443 lines

<!DOCTYPE html>
<html lang="en">
<head>
<script>
// Credential-safe fetch guard (fleet drop-in) — if this page is opened with
// credentials in the URL (a saved bookmark / Chrome-remembered basic-auth:
// https://user:pass@host/…), the browser poisons document.baseURI, so any
// relative or root-relative fetch('/api/…') throws "Request cannot be constructed
// from a URL that includes credentials" and callers silently fall to an empty
// state. Resolve every non-absolute request URL against the credential-free
// location instead of baseURI. Placed first so it wraps window.fetch before any
// app script runs. Ref: creds-in-url-fetch-guard-fleet-pattern.
(function () {
  var _fetch = window.fetch.bind(window);
  var cleanBase = function () { return location.origin + location.pathname; };
  window.fetch = function (input, init) {
    try {
      if (typeof input === 'string' && !/^[a-z]+:\/\//i.test(input) && input.indexOf('//') !== 0) {
        input = new URL(input, cleanBase()).href;
      } else if (input instanceof Request && !/^[a-z]+:\/\//i.test(input.url)) {
        input = new Request(new URL(input.url, cleanBase()).href, input);
      }
    } catch (_) { /* fall through to native */ }
    return _fetch(input, init);
  };
})();
</script>
<meta charset="utf-8">
<title>agentabrams.com — Domain Viewer</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Crect width='16' height='16' rx='3' fill='%2328412f'/%3E%3Ctext x='8' y='12' text-anchor='middle' font-family='Georgia,serif' font-size='11' fill='%23f7f1e6'%3Ea%3C/text%3E%3C/svg%3E">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
  :root {
    --rail: 300px;
    --bg:#0e1116; --panel:#161b22; --line:#2b313b; --txt:#dfe6ee; --dim:#8b95a3; --accent:#3fb6a8;
    --row-py: 7px; --row-px: 9px; --row-fs: 14px;
    --rail-tile-min: 120px;
  }
  * { box-sizing: border-box; }
  body { margin:0; height:100vh; display:flex; font:14px/1.45 -apple-system, "SF Pro Text", Helvetica, sans-serif; background:var(--bg); color:var(--txt); }

  /* ── Rail ── */
  #rail { width:var(--rail); min-width:230px; max-width:480px; display:flex; flex-direction:column; border-right:1px solid var(--line); background:var(--panel); }
  #rail header { padding:14px 14px 10px; border-bottom:1px solid var(--line); }
  #rail h1 { margin:0 0 8px; font-size:15px; font-weight:700; letter-spacing:.02em; }
  #rail h1 small { color:var(--dim); font-weight:400; }
  #q { width:100%; padding:7px 10px; border-radius:7px; border:1px solid var(--line); background:var(--bg); color:var(--txt); outline:none; }
  #q:focus { border-color:var(--accent); }

  /* ── Controls bar (lives inside rail header, below search) ── */
  #ctrlbar {
    display:flex; align-items:center; gap:6px; flex-wrap:wrap;
    margin-top:8px; padding-top:8px; border-top:1px solid var(--line);
  }
  /* Sort select */
  #sortSel {
    flex:1 1 auto; min-width:0;
    padding:5px 6px; border-radius:6px; border:1px solid var(--line);
    background:var(--bg); color:var(--txt); font-size:11.5px; cursor:pointer;
    outline:none;
  }
  #sortSel:focus { border-color:var(--accent); }
  /* List / Grid seg */
  .seg { display:flex; border:1px solid var(--line); border-radius:6px; overflow:hidden; flex:none; }
  .seg button {
    padding:5px 9px; background:var(--bg); border:none; font-size:11px;
    letter-spacing:.06em; text-transform:uppercase; cursor:pointer; color:var(--dim);
  }
  .seg button.on { background:var(--accent); color:#0e1116; font-weight:600; }
  .seg button:hover:not(.on) { color:var(--accent); }
  /* Density slider */
  .dens-wrap { display:flex; align-items:center; gap:5px; flex:none; }
  .dens-wrap label { font-size:10px; letter-spacing:.1em; text-transform:uppercase; color:var(--dim); white-space:nowrap; }
  #densSlider { accent-color:var(--accent); width:60px; }
  /* Fields menu */
  .fieldmenu { position:relative; flex:none; }
  #fieldsBtn {
    padding:5px 9px; border-radius:6px; border:1px solid var(--line);
    background:var(--bg); color:var(--dim); font-size:11px;
    letter-spacing:.06em; text-transform:uppercase; cursor:pointer;
  }
  #fieldsBtn:hover, #fieldsBtn[aria-expanded="true"] { border-color:var(--accent); color:var(--accent); }
  .fpop {
    position:absolute; top:calc(100% + 6px); right:0;
    background:var(--panel); border:1px solid var(--line); border-radius:8px;
    box-shadow:0 14px 34px rgba(0,0,0,.45); padding:6px 4px; z-index:50; min-width:160px;
  }
  .fpop[hidden] { display:none; }
  .fpop label {
    display:flex; align-items:center; gap:8px; padding:6px 12px;
    font-size:11.5px; color:var(--txt); cursor:pointer; white-space:nowrap; border-radius:5px;
  }
  .fpop label:hover { background:#1e2530; }
  .fpop input[type=checkbox] { accent-color:var(--accent); }

  /* ── List (default) ── */
  #list { flex:1; overflow-y:auto; padding:6px; }
  .item {
    display:flex; align-items:center; gap:8px;
    padding:var(--row-py) var(--row-px); border-radius:7px; cursor:pointer;
    color:var(--txt); user-select:none; font-size:var(--row-fs);
  }
  .item:hover { background:#1e2530; }
  .item.active { background:#213132; outline:1px solid var(--accent); }
  .dot { width:8px; height:8px; border-radius:50%; flex:none; background:#555; }
  .dot.up { background:#3fbf6f; }
  .dot.auth { background:#d9a53a; }
  .dot.login { background:#4a90e2; }
  .dot.down { background:#d95555; }
  .name { flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
  .name b { color:var(--accent); font-weight:600; }
  .ms { color:var(--dim); font-size:11px; flex:none; }

  /* ── Grid (tile) mode on #list ── */
  #list.grid {
    display:flex; flex-wrap:wrap; gap:6px; padding:8px 6px; align-content:flex-start;
  }
  #list.grid .item {
    flex-direction:column; align-items:center; justify-content:center;
    min-width:var(--rail-tile-min); flex:1 1 var(--rail-tile-min);
    padding:10px 8px; text-align:center; gap:4px; border:1px solid var(--line);
    border-radius:8px; background:#1a1f27;
  }
  #list.grid .item.active { background:#213132; }
  #list.grid .item:hover { background:#1e2530; border-color:var(--accent); }
  #list.grid .name { font-size:11px; white-space:normal; word-break:break-all; line-height:1.3; }
  #list.grid .ms { font-size:10px; }

  /* ── Per-field body-class hide switches ── */
  body.hf-dot  [data-f="dot"]  { display:none; }
  body.hf-name [data-f="name"] { display:none; }
  body.hf-ms   [data-f="ms"]   { display:none; }

  /* ── Foot / status legend ── */
  #foot { padding:8px 14px; border-top:1px solid var(--line); color:var(--dim); font-size:11.5px; }

  /* ── Right main pane ── */
  #main { flex:1; display:flex; flex-direction:column; min-width:0; }
  #bar { display:flex; align-items:center; gap:10px; padding:8px 12px; border-bottom:1px solid var(--line); background:var(--panel); }
  #cur { font-weight:600; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
  #cur a { color:var(--accent); text-decoration:none; }
  #bar .sp { flex:1; }
  button { padding:6px 12px; border-radius:7px; border:1px solid var(--line); background:var(--bg); color:var(--txt); cursor:pointer; font-size:13px; }
  button:hover { border-color:var(--accent); color:var(--accent); }
  #frame { flex:1; border:0; width:100%; background:#fff; }
  #empty { flex:1; display:flex; align-items:center; justify-content:center; color:var(--dim); flex-direction:column; gap:8px; }
  #empty .big { font-size:40px; }
</style>
</head>
<body>
  <div id="rail">
    <header>
      <h1>agentabrams.com <small id="count"></small></h1>
      <input id="q" type="search" placeholder="Filter domains…" autocomplete="off">

      <!-- Controls bar: sort + view toggle + density + fields -->
      <div id="ctrlbar">
        <select id="sortSel" aria-label="Sort domains">
          <option value="az">Name A→Z</option>
          <option value="za">Name Z→A</option>
          <option value="up_first">Status: Up first</option>
          <option value="down_first">Status: Down first</option>
          <option value="auth_type">Auth type (grouped)</option>
          <option value="ms_asc">Response time ↑ (fastest)</option>
          <option value="ms_desc">Response time ↓ (slowest)</option>
        </select>
        <div class="seg" role="tablist" aria-label="View mode">
          <button id="vList" class="on" title="List view">☰</button>
          <button id="vGrid" title="Grid / tile view">⊞</button>
        </div>
        <div class="dens-wrap" title="Adjust density">
          <label for="densSlider">▤</label>
          <input type="range" id="densSlider" min="1" max="10" step="1" value="5" aria-label="Density">
        </div>
        <div class="fieldmenu">
          <button id="fieldsBtn" aria-haspopup="true" aria-expanded="false">Fields ▾</button>
          <div class="fpop" id="fpop" hidden role="menu"></div>
        </div>
      </div>
    </header>
    <div id="list"></div>
    <div id="foot">click a domain → loads in the right panel (unified admin pw auto-applied) · dot: <span style="color:#d9a53a">Basic un/pw</span> / <span style="color:#4a90e2">login·SSO</span> / <span style="color:#3fbf6f">open (no auth)</span> / <span style="color:#d95555">down or pw rejected</span></div>
  </div>
  <div id="main">
    <div id="bar">
      <span id="cur">— pick a domain</span>
      <span class="sp"></span>
      <button id="reload" title="Reload iframe">⟳ Reload</button>
      <button id="newtab" title="Open in a new browser tab">↗ New Tab</button>
    </div>
    <div id="empty"><div class="big">🌐</div><div>Select a domain on the left to view the live site here.</div></div>
    <iframe id="frame" style="display:none"></iframe>
  </div>

<script>
// ── Escape untrusted text before it touches innerHTML ──
const esc = s => String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));

// ── State ──────────────────────────────────────────────
// Every control persists under the existing aav- prefix.
let domains = [], status = {}, current = localStorage.getItem('aav-current') || '';

const FIELDS = [
  ['dot',  'Status dot', true],
  ['name', 'Domain',     true],
  ['ms',   'Response ms', true],
];

function loadFields() {
  const def = {}; FIELDS.forEach(([k,,d]) => def[k] = d);
  try { return Object.assign(def, JSON.parse(localStorage.getItem('aav-fields') || '{}')); }
  catch(e) { return def; }
}

const ctrlState = {
  sort:    localStorage.getItem('aav-sort')    || 'az',
  view:    localStorage.getItem('aav-view')    || 'list',
  density: parseInt(localStorage.getItem('aav-density') || '5', 10),
  fields:  loadFields(),
};

// ── Element refs ───────────────────────────────────────
const list     = document.getElementById('list');
const q        = document.getElementById('q');
const frame    = document.getElementById('frame');
const empty    = document.getElementById('empty');
const cur      = document.getElementById('cur');
const count    = document.getElementById('count');
const sortSel  = document.getElementById('sortSel');
const densSlider = document.getElementById('densSlider');
const vListBtn = document.getElementById('vList');
const vGridBtn = document.getElementById('vGrid');
const fieldsBtn = document.getElementById('fieldsBtn');
const fpop     = document.getElementById('fpop');

// ── Sorters ────────────────────────────────────────────
// authType priority for grouping: open → basic → login → down → unknown
const authOrder = t => ({ open:0, basic:1, login:2, down:3 }[t] ?? 4);

function statusScore(s) {
  // up-first: 0=up, 1=down; for down_first flip
  if (!s) return 2;
  if (s.authFail || s.authType === 'down' || !s.up) return 1;
  return 0;
}

const SORTERS = {
  az:        (a, b) => a.localeCompare(b),
  za:        (a, b) => b.localeCompare(a),
  up_first:  (a, b) => statusScore(status[a]) - statusScore(status[b]) || a.localeCompare(b),
  down_first:(a, b) => statusScore(status[b]) - statusScore(status[a]) || a.localeCompare(b),
  auth_type: (a, b) => {
    const sa = status[a], sb = status[b];
    const oa = authOrder(sa ? sa.authType : null);
    const ob = authOrder(sb ? sb.authType : null);
    return oa - ob || a.localeCompare(b);
  },
  ms_asc:    (a, b) => {
    const ma = status[a] && status[a].up ? status[a].ms : Infinity;
    const mb = status[b] && status[b].up ? status[b].ms : Infinity;
    return ma - mb || a.localeCompare(b);
  },
  ms_desc:   (a, b) => {
    const ma = status[a] && status[a].up ? status[a].ms : -Infinity;
    const mb = status[b] && status[b].up ? status[b].ms : -Infinity;
    return mb - ma || a.localeCompare(b);
  },
};

// ── View + density helpers ─────────────────────────────
function applyDensity(v) {
  // List mode: remap 1-10 → generous padding (1=compact, 10=loose)
  // Density 5 = baseline (7px/9px padding, 14px font — the original values)
  const py = Math.round(3 + (v - 1) * 1.2) + 'px';   // 3px … 14px
  const px = Math.round(5 + (v - 1) * 1.4) + 'px';   // 5px … 18px
  const fs = Math.round(11 + (v - 1) * 0.5) + 'px';  // 11px … 16px
  document.documentElement.style.setProperty('--row-py', py);
  document.documentElement.style.setProperty('--row-px', px);
  document.documentElement.style.setProperty('--row-fs', fs);
  // Grid mode: tile min-width 80px (compact) → 180px (large)
  const tileMin = Math.round(80 + (v - 1) * 11) + 'px'; // 80px … 179px
  document.documentElement.style.setProperty('--rail-tile-min', tileMin);
}

function setView() {
  const isGrid = ctrlState.view === 'grid';
  list.classList.toggle('grid', isGrid);
  vListBtn.classList.toggle('on', !isGrid);
  vGridBtn.classList.toggle('on', isGrid);
}

function applyFields() {
  FIELDS.forEach(([k]) => document.body.classList.toggle('hf-' + k, !ctrlState.fields[k]));
}

function buildFieldMenu() {
  fpop.innerHTML = FIELDS.map(([k, label]) =>
    `<label><input type="checkbox" data-field="${esc(k)}"${ctrlState.fields[k] ? ' checked' : ''}> ${esc(label)}</label>`
  ).join('');
  fpop.querySelectorAll('input').forEach(cb => cb.addEventListener('change', () => {
    ctrlState.fields[cb.dataset.field] = cb.checked;
    localStorage.setItem('aav-fields', JSON.stringify(ctrlState.fields));
    applyFields();
  }));
}

// ── Render ─────────────────────────────────────────────
function render() {
  const f = q.value.trim().toLowerCase();
  let shown = domains.filter(d => d.includes(f));

  // Sort
  const sorter = SORTERS[ctrlState.sort] || SORTERS.az;
  shown = [...shown].sort(sorter);

  count.textContent = `(${shown.length}/${domains.length})`;
  list.innerHTML = '';
  for (const d of shown) {
    const s = status[d];
    const row = document.createElement('div');
    row.className = 'item' + (d === current ? ' active' : '');

    // authType: basic (amber) | login (blue) | open (green) | down/authFail (red).
    // Falls back to the old gated/up logic if authType is absent.
    const dotCls = !s ? '' : s.authFail ? 'down'
      : s.authType === 'basic' ? 'auth'
      : s.authType === 'login' ? 'login'
      : s.authType === 'down'  ? 'down'
      : s.authType === 'open'  ? 'up'
      : s.gated ? 'auth' : s.up ? 'up' : 'down';
    const tip = !s ? '' : s.authFail ? 'auth wall — unified pw REJECTED'
      : s.authType === 'basic' ? 'HTTP Basic un/pw wall'
      : s.authType === 'login' ? 'app login / SSO (redirect or password form)'
      : s.authType === 'open'  ? 'OPEN — no auth'
      : s.authType === 'down'  ? 'unreachable' : '';
    const ed = esc(d);
    const label = f ? ed.replace(esc(f), m => `<b>${m}</b>`) : ed;

    // Each sub-element carries data-f so field-hide CSS rules apply.
    row.innerHTML =
      `<span class="dot ${esc(dotCls)}" data-f="dot" title="${esc(tip)}"></span>` +
      `<span class="name" data-f="name">${label}</span>` +
      (s && s.up
        ? `<span class="ms" data-f="ms">${s.ms}ms</span>`
        : s ? `<span class="ms" data-f="ms">—</span>` : '');
    row.onclick = () => select(d);
    list.appendChild(row);
  }
}

// ── Select (iframe load) — core behavior preserved ─────
function select(d) {
  current = d;
  localStorage.setItem('aav-current', d);
  cur.innerHTML = `<a href="https://${esc(d)}" target="_blank" rel="noopener noreferrer">${esc(d)}</a>`;
  // Load through the server-side proxy — it injects the unified admin credential
  // and strips X-Frame-Options, so every site renders in-panel without prompting.
  empty.style.display = 'none';
  frame.style.display = 'block';
  frame.src = '/site/' + d + '/';
  render();
}

// ── Wire controls ──────────────────────────────────────
document.getElementById('reload').onclick = () => { if (current) frame.src = '/site/' + current + '/'; };
document.getElementById('newtab').onclick = () => { if (current) window.open('https://' + current, '_blank', 'noopener'); };
q.oninput = render;

// Sort
sortSel.value = ctrlState.sort;
sortSel.addEventListener('change', () => {
  ctrlState.sort = sortSel.value;
  localStorage.setItem('aav-sort', ctrlState.sort);
  render();
});

// View toggle
vListBtn.onclick = () => {
  ctrlState.view = 'list';
  localStorage.setItem('aav-view', 'list');
  setView();
};
vGridBtn.onclick = () => {
  ctrlState.view = 'grid';
  localStorage.setItem('aav-view', 'grid');
  setView();
};

// Density
densSlider.value = ctrlState.density;
applyDensity(ctrlState.density);
densSlider.addEventListener('input', () => {
  ctrlState.density = parseInt(densSlider.value, 10);
  localStorage.setItem('aav-density', String(ctrlState.density));
  applyDensity(ctrlState.density);
});

// Fields menu
buildFieldMenu();
applyFields();
fieldsBtn.addEventListener('click', (e) => {
  e.stopPropagation();
  const open = fpop.hidden;
  fpop.hidden = !open;
  fieldsBtn.setAttribute('aria-expanded', String(open));
});
document.addEventListener('click', (e) => {
  if (!fpop.hidden && !e.target.closest('.fieldmenu')) {
    fpop.hidden = true;
    fieldsBtn.setAttribute('aria-expanded', 'false');
  }
});

// Apply initial view + density
setView();

// ── Data fetch ─────────────────────────────────────────
(async () => {
  try {
    const dres = await fetch('/api/domains');
    if (!dres.ok) throw new Error('domains HTTP ' + dres.status);
    domains = await dres.json();
    render();
    if (current && domains.includes(current)) select(current);
  } catch (e) {
    count.textContent = '(load failed — reload)';
    return;
  }
  // Health dots load after the list — probes take ~30s for 47 domains. A fetch
  // aborted mid-flight (tab closed, server restarted) must not surface as an
  // unhandled rejection; the list already rendered, dots are best-effort.
  try {
    const sres = await fetch('/api/status');
    if (!sres.ok) throw new Error('status HTTP ' + sres.status);
    const st = await sres.json();
    for (const s of st) status[s.domain] = s;
    render();
  } catch (e) { /* dots stay grey; next reload retries */ }
})();
</script>
</body>
</html>