← back to Ventura Claw

server/public/admin-connectors.html

193 lines

<!doctype html>
<html lang="en"><head>
<meta charset="utf-8" /><meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Connectors — VenturaClaw</title>
<link rel="stylesheet" href="/static/style.css" />
<style>
  .tile { border: 1px solid var(--rule); transition: border-color 200ms ease, box-shadow 200ms ease; }
  .tile.live    { border-color: var(--good); box-shadow: 0 0 0 1px var(--good) inset; }
  .tile.offline { border-color: var(--bad);  box-shadow: 0 0 0 1px var(--bad)  inset; }
  .tile.mock    { border-color: var(--rule); border-style: dashed; opacity: 0.55; }
  .tile .stat-line { font-family: var(--mono); font-size: 9px; letter-spacing: .12em; text-transform: uppercase; margin-top: 6px; }
  .tile.live    .stat-line { color: var(--good); }
  .tile.offline .stat-line { color: var(--bad);  }
  .tile.mock    .stat-line { color: var(--ink-faint); }
  .stats-bar { display: flex; gap: 14px; align-items: center; font-family: var(--mono); font-size: 10px; letter-spacing: .14em; text-transform: uppercase; }
  .stat-pill { display: inline-flex; gap: 6px; align-items: center; padding: 5px 10px; border: 1px solid var(--rule); border-radius: 2px; }
  .stat-pill .dot { width: 7px; height: 7px; border-radius: 50%; }
  .stat-pill.live    { border-color: var(--good); color: var(--good); }
  .stat-pill.live    .dot { background: var(--good); box-shadow: 0 0 6px var(--good); }
  .stat-pill.offline { border-color: var(--bad);  color: var(--bad);  }
  .stat-pill.offline .dot { background: var(--bad); }
  .stat-pill.mock    { color: var(--ink-mute); }
  .stat-pill.mock    .dot { background: var(--ink-faint); }
</style>
</head><body>
<header class="topbar">
  <div class="brand"><div class="logo-dot"></div><div><div class="brand-name">Ventura<em style="font-style:italic;color:var(--gold)">Claw</em></div><div class="brand-sub">Connectors</div></div></div>
  <nav class="nav" style="display:flex;gap:6px;font-family:var(--mono);font-size:10px;letter-spacing:.14em;text-transform:uppercase">
    <a href="/admin" style="padding:8px 14px;color:var(--ink-soft)">Dashboard</a>
    <a href="/admin/users" style="padding:8px 14px;color:var(--ink-soft)">Users</a>
    <a href="/admin/connectors" style="padding:8px 14px;color:var(--gold);border-bottom:1px solid var(--gold)">Connectors</a>
    <a href="/admin/oauth-setup" style="padding:8px 14px;color:var(--gold);background:var(--gold-soft);border:1px solid var(--gold);border-radius:2px">⚡ OAuth Setup</a>
    <a href="/connections/import" style="padding:8px 14px;color:var(--gold);background:var(--gold-soft);border:1px solid var(--gold);border-radius:2px">↓ Import</a>
    <a href="/admin/approvals" style="padding:8px 14px;color:var(--ink-soft)">Approvals</a>
    <a href="/admin/audit" style="padding:8px 14px;color:var(--ink-soft)">Audit</a>
    <a href="/chat" style="padding:8px 14px;color:var(--ink-soft)">Chat</a>
    <a href="#" id="logout" style="padding:8px 14px;color:var(--ink-soft)">Sign out</a>
  </nav>
</header>
<main style="padding:24px 28px;max-width:1480px;margin:0 auto">

  <div style="background:var(--gold-soft);border:1px solid var(--gold);border-left:3px solid var(--gold);border-radius:2px;padding:18px 22px;margin-bottom:22px;display:flex;gap:18px;align-items:center;flex-wrap:wrap">
    <div style="flex:1;min-width:280px">
      <div style="font-family:var(--serif);font-style:italic;font-size:22px;color:var(--ink);margin-bottom:4px">Bring your own tokens.</div>
      <div style="font-family:var(--mono);font-size:11px;letter-spacing:.10em;color:var(--ink-soft);line-height:1.5">Paste your <code style="color:var(--gold)">~/.claude.json</code> mcpServers block or your <code style="color:var(--gold)">.env</code> file — connectors auto-light up. 42 keys mapped across 22 services.</div>
    </div>
    <a href="/connections/import" class="btn btn-primary" style="font-family:var(--mono)">↓ Import tokens →</a>
  </div>

  <div class="glass" style="padding:18px;margin-bottom:18px;display:flex;gap:14px;align-items:center;flex-wrap:wrap">
    <input id="filter" placeholder="filter connectors…" style="flex:1;min-width:200px" />
    <select id="cat-filter" style="min-width:160px"><option value="">all categories</option></select>
    <button id="prime-btn" type="button" class="btn" style="font-family:var(--mono);font-size:10px;letter-spacing:.14em;text-transform:uppercase;padding:8px 14px;border:1px solid var(--gold);color:var(--gold);background:transparent;border-radius:2px;cursor:pointer" title="Re-fire the 5 preset cache primes — useful when Mac1 has evicted qwen3 and visitors are eating cold-loads">● Prime cache</button>
    <span id="prime-status" role="status" aria-live="polite" style="font-family:var(--mono);font-size:10px;color:var(--ink-mute);min-width:80px"></span>
    <select id="status-filter" style="min-width:140px">
      <option value="">all status</option>
      <option value="live">live API</option>
      <option value="offline">not connected</option>
      <option value="mock">mock only</option>
    </select>
    <div class="stats-bar">
      <span class="stat-pill live"><span class="dot"></span>live <strong id="cnt-live">0</strong></span>
      <span class="stat-pill offline"><span class="dot"></span>offline <strong id="cnt-off">0</strong></span>
      <span class="stat-pill mock"><span class="dot"></span>mock <strong id="cnt-mock">0</strong></span>
      <span style="color:var(--ink-mute)"><strong id="count">0</strong> shown / <strong id="total">0</strong> total</span>
    </div>
  </div>
  <div class="glass" style="padding:18px"><div class="tile-grid" id="grid" role="region" aria-label="Connector grid" aria-live="polite"></div></div>
</main>
<div class="footer">VenturaClaw</div>
<script>
let CONNECTORS = [];
const HEALTH = {}; // { connector_id: { ok, reason, ... } }

function esc(s) { return String(s == null ? '' : s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;'); }

function logoHTML(c) {
  const tint = String(c.tint || '#888').replace(/[^#0-9a-fA-F]/g,'').slice(0,7);
  const tintNoHash = tint.replace('#','');
  const letter = esc((c.name || '?')[0]);
  if (c.logo) {
    const slug = String(c.logo).replace(/[^a-z0-9-]/gi,'');
    const url = `https://cdn.simpleicons.org/${slug}/${tintNoHash}`;
    return `<img class="logo" src="${esc(url)}" alt="${esc(c.name)}" loading="lazy" data-tint="${esc(tint)}" data-letter="${letter}" onerror="this.outerHTML='&lt;div class=&quot;logo logo-fb&quot; style=&quot;background:'+this.dataset.tint+'&quot;&gt;'+this.dataset.letter+'&lt;/div&gt;'.replace(/&lt;/g,'<').replace(/&gt;/g,'>').replace(/&quot;/g,'\\'')">`;
  }
  return `<div class="logo logo-fb" style="background:${esc(tint)}">${letter}</div>`;
}
function statusOf(c) {
  if (!c.real_impl) return "mock";
  const h = HEALTH[c.id];
  if (h && h.ok) return "live";
  return "offline";
}
function statusLabel(c) {
  const s = statusOf(c);
  const h = HEALTH[c.id] || {};
  if (s === "live")    return `● connected · ${h.team || h.account_id || h.account || h.bot || h.user_id || h.friendly_name || "verified"}`;
  if (s === "offline") return `○ not connected${h.reason ? ` · ${h.reason}` : ''}`;
  return "○ mock — no real impl yet";
}

function draw() {
  const q = filter.value.toLowerCase(), cat = catFilter.value, stat = statusFilter.value;
  const list = CONNECTORS.filter(c =>
    (!q   || c.name.toLowerCase().includes(q)) &&
    (!cat || c.category === cat) &&
    (!stat || statusOf(c) === stat));
  document.getElementById('count').textContent = list.length;
  document.getElementById('total').textContent = CONNECTORS.length;
  let live = 0, off = 0, mock = 0;
  for (const c of CONNECTORS) { const s = statusOf(c); if (s === "live") live++; else if (s === "offline") off++; else mock++; }
  document.getElementById('cnt-live').textContent = live;
  document.getElementById('cnt-off').textContent = off;
  document.getElementById('cnt-mock').textContent = mock;

  document.getElementById('grid').innerHTML = list.map(c => {
    const s = statusOf(c);
    const docsHref = (typeof c.docs === 'string' && /^https?:\/\//.test(c.docs)) ? c.docs : '#';
    return `<div class="tile ${s}">
      <div class="head">${logoHTML(c)}<div><div class="cat">${esc(c.category)}</div><div class="name">${esc(c.name)}</div></div></div>
      <div class="meta">${esc(c.triggers)}t · ${esc(c.actions)}a · ${esc(c.auth)}${c.sensitive?' · ⚠':''}</div>
      <div class="stat-line">${esc(statusLabel(c))}</div>
      <a href="${esc(docsHref)}" target="_blank" rel="noopener" style="font-size:10px;opacity:0.7">docs ↗</a>
    </div>`;
  }).join('');
}
const filter = document.getElementById('filter'),
      catFilter = document.getElementById('cat-filter'),
      statusFilter = document.getElementById('status-filter');

(async () => {
  const d = await (await fetch('/api/connectors')).json();
  CONNECTORS = d.connectors;
  const cats = [...new Set(CONNECTORS.map(c=>c.category))];
  catFilter.innerHTML += cats.map(c => `<option>${c}</option>`).join('');
  draw();

  // Single bulk health probe — server-side fanout + 30s TTL cache.
  // Replaces the prior 56-parallel-fetch N+1 (Steve, perf-engineer audit 2026-05-05).
  try {
    const r = await fetch('/api/connectors/health-all');
    if (r.ok) {
      const { data } = await r.json();
      Object.assign(HEALTH, data);
    } else {
      // Fallback: per-connector probes if the bulk endpoint fails for any reason
      const realIds = CONNECTORS.filter(c => c.real_impl).map(c => c.id);
      await Promise.all(realIds.map(async id => {
        try { HEALTH[id] = await (await fetch(`/api/connectors/${id}/health`)).json(); }
        catch { HEALTH[id] = { ok: false, reason: "probe failed" }; }
      }));
    }
  } catch (e) {
    console.error('health probe failed:', e);
  }
  draw();
})();
filter.addEventListener('input', draw);
catFilter.addEventListener('change', draw);
statusFilter.addEventListener('change', draw);
// Prime cache button — calls POST /api/admin/cache/prime, shows result inline.
document.getElementById('prime-btn').addEventListener('click', async (e) => {
  const btn = e.currentTarget;
  const status = document.getElementById('prime-status');
  const orig = btn.textContent;
  btn.disabled = true; btn.textContent = '● priming…';
  status.textContent = ''; status.style.color = 'var(--ink-mute)';
  try {
    const r = await fetch('/api/admin/cache/prime', { method: 'POST' });
    const d = await r.json();
    if (r.ok) {
      btn.textContent = `✓ primed ${d.primed}/${d.total}`;
      status.textContent = `cache size: ${d.cache_size}`;
      status.style.color = 'var(--good)';
    } else {
      btn.textContent = '✗ failed';
      status.textContent = (d.error || 'unknown error').slice(0, 40);
      status.style.color = 'var(--bad)';
    }
  } catch (err) {
    btn.textContent = '✗ network error';
    status.style.color = 'var(--bad)';
  } finally {
    setTimeout(() => { btn.textContent = orig; btn.disabled = false; }, 5000);
  }
});

document.getElementById('logout').addEventListener('click', async e => {
  e.preventDefault(); await fetch('/api/auth/logout', { method: 'POST' }); location.href = '/login';
});
</script>
</body></html>