{% extends "base.html" %}
{% block title %}Activity — Admin{% endblock %}

{% block content %}
<div class="obs-page page-shell">

  <!-- TOP BAR --------------------------------------------------------- -->
  <header class="obs-topbar">
    <div class="obs-topbar-left">
      {% set page_hero_eyebrow = "Activity Center" %}
      {% set page_hero_title = "Audit log" %}
      {% set page_hero_subtitle = "Every admin and user-mutating action — RBAC writes, sync triggers, marketplace edits, …" %}
      {% include "_page_hero.html" %}
      <div class="obs-narrative" id="obs-narrative">Loading…</div>
    </div>
    <div class="obs-topbar-right">
      <label class="obs-window">
        <span class="obs-label">Time range</span>
        <select id="obs-window">
          <option value="60">Last 1h</option>
          <option value="360">Last 6h</option>
          <option value="1440" selected>Last 24h</option>
          <option value="10080">Last 7d</option>
          <option value="43200">Last 30d</option>
        </select>
      </label>
      <label class="obs-live" title="Re-fetches KPIs + table every 30s. Independent of the time range — the range still controls what counts as 'recent'.">
        <input type="checkbox" id="obs-live" />
        <span>Auto-refresh (30s)</span>
        <span class="obs-live-dot" id="obs-live-dot" aria-hidden="true"></span>
      </label>
    </div>
  </header>

  <!-- KPI CARDS ------------------------------------------------------- -->
  <section class="obs-kpis" aria-label="Key metrics">
    <button class="obs-kpi" data-filter="" type="button" aria-label="All events in window — click to reset filters">
      <span class="obs-kpi-label">Events</span>
      <span class="obs-kpi-value" id="kpi-events">—</span>
    </button>
    <button class="obs-kpi" data-filter="users" type="button" aria-label="Active users — click to focus user filter">
      <span class="obs-kpi-label">Active users</span>
      <span class="obs-kpi-value" id="kpi-users">—</span>
    </button>
    <button class="obs-kpi" data-filter="errors" type="button" aria-label="Errors — click to filter to errors only">
      <span class="obs-kpi-label">Error rate</span>
      <span class="obs-kpi-value" id="kpi-errors">—</span>
      <span class="obs-kpi-sub" id="kpi-errors-sub">—</span>
    </button>
    <button class="obs-kpi" data-filter="slow" type="button" aria-label="Latency p95 — click to sort table by duration desc">
      <span class="obs-kpi-label">p95 latency</span>
      <span class="obs-kpi-value" id="kpi-p95">—</span>
    </button>
  </section>

  <!-- FILTER BAR ------------------------------------------------------ -->
  <section class="obs-filters" aria-label="Filter audit log">
    <div class="obs-filter">
      <label for="f-user">User</label>
      <select id="f-user" class="obs-select"><option value="">Any</option></select>
    </div>
    <div class="obs-filter">
      <label for="f-action">Action</label>
      <select id="f-action" class="obs-select"><option value="">Any</option></select>
    </div>
    <div class="obs-filter">
      <label for="f-result">Result</label>
      <select id="f-result" class="obs-select">
        <option value="">Any</option>
        <option value="success">success only</option>
        <option value="error%">errors only</option>
      </select>
    </div>
    <div class="obs-filter">
      <label for="f-source">Source</label>
      <select id="f-source" class="obs-select"><option value="">Any</option></select>
    </div>
    <div class="obs-filter obs-filter-grow">
      <label for="f-q">Search params</label>
      <input id="f-q" type="search" placeholder="token, table id, correlation…" />
    </div>
    <div class="obs-filter-actions">
      <button id="f-reset" class="obs-btn" type="button">Reset</button>
    </div>
  </section>

  <!-- AUDIT TABLE ----------------------------------------------------- -->
  <section class="obs-table-wrap">
    <table class="obs-table" id="obs-table">
      <thead>
        <tr>
          {# Sort is page-local: server returns rows ordered by timestamp DESC
             for keyset pagination; clicking another header re-sorts only the
             rows currently loaded. Adding a `Load all` step would let the
             user sort across the full result set but isn't wired yet. #}
          <th data-sort="timestamp" aria-sort="descending"><button type="button">Time <span class="obs-sort-arrow"></span></button></th>
          <th data-sort="user"><button type="button">User <span class="obs-sort-arrow"></span></button></th>
          <th data-sort="source"><button type="button">Source <span class="obs-sort-arrow"></span></button></th>
          <th data-sort="action"><button type="button">Action <span class="obs-sort-arrow"></span></button></th>
          <th data-sort="resource"><button type="button">Resource <span class="obs-sort-arrow"></span></button></th>
          <th data-sort="duration_ms"><button type="button">Duration <span class="obs-sort-arrow"></span></button></th>
          <th data-sort="result"><button type="button">Result <span class="obs-sort-arrow"></span></button></th>
        </tr>
      </thead>
      <tbody id="obs-rows">
        <tr><td colspan="7" class="obs-empty">Loading…</td></tr>
      </tbody>
    </table>
    <div class="obs-table-footer">
      <span id="obs-row-count" class="obs-row-count">—</span>
      <button id="obs-loadmore" class="obs-btn" type="button" hidden>Load more</button>
    </div>
  </section>

</div>

<!-- DETAIL SIDE PANEL --------------------------------------------------- -->
<aside class="obs-panel" id="obs-panel" hidden aria-label="Event detail">
  <header class="obs-panel-head">
    <h2 id="obs-panel-title">Event</h2>
    <button class="obs-btn" id="obs-panel-close" type="button" aria-label="Close">✕</button>
  </header>
  <dl class="obs-panel-meta" id="obs-panel-meta"></dl>
  <h3>Params</h3>
  <pre class="obs-panel-pre" id="obs-panel-params">—</pre>
  <h3 class="obs-panel-result-h">Result</h3>
  <pre class="obs-panel-pre" id="obs-panel-result">—</pre>
  <div class="obs-panel-actions">
    <button class="obs-btn" id="obs-panel-filter-user" type="button">Filter to this user</button>
    <button class="obs-btn" id="obs-panel-filter-action" type="button">Filter to this action</button>
  </div>
</aside>


<style>
:root {
  --obs-bg:        var(--surface, #fff);
  --obs-fg:        var(--text-primary, #111827);
  --obs-muted:     var(--text-secondary, #6b7280);
  --obs-border:    var(--border, #e5e7eb);
  --obs-soft:      var(--border-light, #f3f4f6);
  --obs-ok:        #065f46;
  --obs-ok-bg:     #d1fae5;
  --obs-err:       #991b1b;
  --obs-err-bg:    #fee2e2;
  --obs-warn:      #92400e;
  --obs-warn-bg:   #fef3c7;
  --obs-link:      #2563eb;
}

.obs-page { font: 14px/1.5 var(--font-sans, system-ui); color: var(--obs-fg); }

/* Top bar -------------------------------------------------------------- */
.obs-topbar {
  display: flex; align-items: flex-start; justify-content: space-between;
  gap: 24px; padding: 12px 0 20px;
  border-bottom: 1px solid var(--obs-border);
  margin-bottom: 16px;
}
.obs-topbar-left { flex: 1; min-width: 0; }
.obs-narrative { font-size: 16px; color: var(--obs-fg); line-height: 1.45; }
.obs-narrative b { font-weight: 600; }
.obs-narrative a { color: var(--obs-link); text-decoration: none; border-bottom: 1px dashed transparent; cursor: pointer; }
.obs-narrative a:hover { border-bottom-color: var(--obs-link); }

.obs-topbar-right { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.obs-label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.4px;
  color: var(--obs-muted); display: block; margin-bottom: 2px; }
.obs-window select { padding: 6px 8px; border: 1px solid var(--obs-border); border-radius: 6px; background: var(--obs-bg); font: inherit; }
.obs-live { display: flex; align-items: center; gap: 6px; font-size: 13px; color: var(--obs-muted); cursor: pointer; }
.obs-live-dot { width: 8px; height: 8px; border-radius: 50%; background: #d1d5db; transition: background 0.2s; }
.obs-live input:checked ~ .obs-live-dot { background: #10b981; animation: obs-pulse 2s ease-in-out infinite; }
@keyframes obs-pulse {
  0%, 100% { box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.6); }
  50%      { box-shadow: 0 0 0 4px rgba(16, 185, 129, 0); }
}

/* Saved views dropdown -------------------------------------------------- */
.obs-views { position: relative; }
.obs-btn {
  border: 1px solid var(--obs-border); background: var(--obs-bg); color: var(--obs-fg);
  padding: 6px 12px; border-radius: 6px; font: inherit; cursor: pointer;
}
.obs-btn:hover { background: var(--obs-soft); }
.obs-btn-primary { background: var(--obs-link); color: #fff; border-color: var(--obs-link); }
.obs-btn-primary:hover { filter: brightness(1.07); background: var(--obs-link); }
.obs-chev { font-size: 10px; color: var(--obs-muted); }
.obs-views-panel {
  position: absolute; right: 0; top: calc(100% + 6px);
  background: var(--obs-bg); border: 1px solid var(--obs-border); border-radius: 8px;
  box-shadow: 0 8px 24px rgba(0,0,0,0.08);
  width: 280px; padding: 8px; z-index: 50;
}
.obs-views-panel hr { border: 0; border-top: 1px solid var(--obs-soft); margin: 6px 0; }
.obs-views-list { max-height: 280px; overflow-y: auto; }
.obs-view-item {
  display: flex; align-items: center; justify-content: space-between;
  padding: 6px 8px; border-radius: 4px; cursor: pointer;
}
.obs-view-item:hover { background: var(--obs-soft); }
.obs-view-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.obs-view-del { opacity: 0; transition: opacity 0.15s; color: var(--obs-err); border: 0; background: none; cursor: pointer; padding: 2px 6px; }
.obs-view-item:hover .obs-view-del { opacity: 1; }
.obs-views-empty { padding: 12px 8px; color: var(--obs-muted); font-size: 13px; text-align: center; }

/* KPI cards ------------------------------------------------------------ */
.obs-kpis { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-bottom: 16px; }
.obs-kpi {
  text-align: left; padding: 14px 16px; border: 1px solid var(--obs-border); border-radius: 8px;
  background: var(--obs-bg); cursor: pointer; transition: border-color 0.15s, background 0.15s;
  display: flex; flex-direction: column; gap: 4px;
}
.obs-kpi:hover { border-color: var(--obs-link); background: var(--obs-soft); }
.obs-kpi-label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.4px; color: var(--obs-muted); }
.obs-kpi-value { font-size: 22px; font-weight: 600; font-variant-numeric: tabular-nums; color: var(--obs-fg); }
.obs-kpi-sub { font-size: 12px; color: var(--obs-muted); font-variant-numeric: tabular-nums; }

/* Filter bar ----------------------------------------------------------- */
.obs-filters {
  display: flex; gap: 12px; align-items: flex-end; flex-wrap: wrap;
  padding: 12px 14px; background: var(--obs-soft); border-radius: 8px;
  margin-bottom: 12px;
}
.obs-filter { display: flex; flex-direction: column; gap: 4px; min-width: 140px; }
.obs-filter-grow { flex: 1; min-width: 200px; }
.obs-filter label {
  font-size: 11px; text-transform: uppercase; letter-spacing: 0.4px; color: var(--obs-muted);
}
.obs-select, .obs-filter input {
  padding: 6px 8px; border: 1px solid var(--obs-border); border-radius: 6px; background: var(--obs-bg); font: inherit;
}
.obs-filter input { min-width: 200px; }
.obs-filter-actions { display: flex; align-items: flex-end; }

/* Audit table ---------------------------------------------------------- */
.obs-table-wrap {
  border: 1px solid var(--obs-border); border-radius: 8px; overflow: hidden; background: var(--obs-bg);
}
.obs-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.obs-table thead th {
  text-align: left; padding: 10px 12px; background: var(--obs-soft);
  border-bottom: 1px solid var(--obs-border);
  font-weight: 600; color: var(--obs-muted); font-size: 11px;
  text-transform: uppercase; letter-spacing: 0.4px; white-space: nowrap;
}
.obs-table thead th button {
  background: none; border: 0; padding: 0; font: inherit; color: inherit; cursor: pointer;
  display: inline-flex; align-items: center; gap: 4px; text-transform: inherit; letter-spacing: inherit;
}
.obs-table thead th[aria-sort="ascending"] .obs-sort-arrow::before { content: "▲"; }
.obs-table thead th[aria-sort="descending"] .obs-sort-arrow::before { content: "▼"; }
.obs-sort-arrow { font-size: 9px; color: var(--obs-muted); }
.obs-table tbody td {
  padding: 8px 12px; border-bottom: 1px solid var(--obs-soft); vertical-align: top;
}
.obs-table tbody tr:last-child td { border-bottom: none; }
.obs-table tbody tr { cursor: pointer; }
.obs-table tbody tr:hover { background: var(--obs-soft); }
.obs-table tbody tr.is-selected { background: #eff6ff; }
.obs-time { white-space: nowrap; font-variant-numeric: tabular-nums; color: var(--obs-muted); }
.obs-user-primary { color: var(--obs-fg); }
.obs-user-id { color: var(--obs-muted); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11px; }
.obs-action-cell { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }
.obs-dur-cell { text-align: right; font-variant-numeric: tabular-nums; color: var(--obs-muted); }
.obs-result-ok { color: var(--obs-ok); background: var(--obs-ok-bg); padding: 2px 6px; border-radius: 4px; font-size: 11px; }
.obs-result-err { color: var(--obs-err); background: var(--obs-err-bg); padding: 2px 6px; border-radius: 4px; font-size: 11px; }
.obs-source-pill { display: inline-block; padding: 2px 6px; border-radius: 4px; font-size: 11px;
  text-transform: lowercase; letter-spacing: 0.2px; background: var(--obs-soft); color: var(--obs-muted); }
.obs-source-pill.is-scheduler { background: #ede9fe; color: #5b21b6; }
.obs-source-pill.is-cli       { background: #dbeafe; color: #1e40af; }
.obs-source-pill.is-web       { background: #dcfce7; color: #166534; }
.obs-empty { padding: 40px 16px; text-align: center; color: var(--obs-muted); font-size: 13px; }
.obs-table-footer {
  display: flex; justify-content: space-between; align-items: center;
  padding: 10px 14px; border-top: 1px solid var(--obs-border); background: var(--obs-soft);
}
.obs-row-count { color: var(--obs-muted); font-size: 12px; }

/* Detail side panel ---------------------------------------------------- */
.obs-panel {
  position: fixed; top: 0; right: 0; bottom: 0; width: min(520px, 50vw);
  background: var(--obs-bg); border-left: 1px solid var(--obs-border);
  box-shadow: -8px 0 24px rgba(0,0,0,0.06); padding: 20px; overflow-y: auto;
  z-index: 100;
}
.obs-panel-head { display: flex; align-items: center; justify-content: space-between; margin: 0 0 16px; }
.obs-panel-head h2 { margin: 0; font-size: 16px; font-weight: 600; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.obs-panel-meta { margin: 0 0 16px; display: grid; grid-template-columns: 110px 1fr; gap: 6px 12px; font-size: 13px; }
.obs-panel-meta dt { color: var(--obs-muted); }
.obs-panel-meta dd { margin: 0; word-break: break-word; }
.obs-panel h3 { font-size: 11px; text-transform: uppercase; letter-spacing: 0.4px; color: var(--obs-muted); margin: 16px 0 6px; }
.obs-panel-pre {
  background: var(--obs-soft); border-radius: 6px; padding: 10px 12px;
  margin: 0; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px;
  white-space: pre-wrap; word-break: break-word; max-height: 280px; overflow: auto;
}
.obs-panel-actions { display: flex; gap: 8px; margin-top: 16px; flex-wrap: wrap; }

/* Save-view modal ------------------------------------------------------ */
/* `display: flex` would override the [hidden] attribute (UA stylesheet
   only sets `display: none` at lower specificity than author CSS), so the
   modal would render on page load. Gate display on the absence of the
   attribute. */
.obs-modal {
  position: fixed; inset: 0; background: rgba(17, 24, 39, 0.4);
  align-items: center; justify-content: center; z-index: 200;
}
.obs-modal:not([hidden]) { display: flex; }
.obs-modal[hidden]       { display: none; }
/* Same trap on the side panel + saved-views dropdown, prophylactically. */
.obs-panel[hidden],
.obs-views-panel[hidden] { display: none; }
.obs-modal-body {
  background: var(--obs-bg); border-radius: 12px; padding: 24px; width: min(420px, 90vw);
  box-shadow: 0 24px 48px rgba(0,0,0,0.18);
}
.obs-modal-body h2 { margin: 0 0 8px; font-size: 18px; }
.obs-modal-help { margin: 0 0 12px; color: var(--obs-muted); font-size: 13px; }
.obs-modal-body label { display: block; font-size: 12px; color: var(--obs-muted); margin-bottom: 4px; }
.obs-modal-body input[type=text] {
  width: 100%; padding: 8px 10px; border: 1px solid var(--obs-border); border-radius: 6px; font: inherit;
  box-sizing: border-box;
}
.obs-modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; }
</style>

<script>
(function() {
  // ---------------------------------------------------------------------
  // State + URL sync
  // ---------------------------------------------------------------------
  const state = readUrl();
  let nextCursor = null;
  let livePollId = null;

  function readUrl() {
    const p = new URLSearchParams(location.search);
    return {
      window:        parseInt(p.get('window'),  10) || 1440,
      user_id:       p.get('user_id') || '',
      action_prefix: p.get('action_prefix') || '',
      result:        p.get('result') || '',
      source:        p.get('source') || '',
      q:             p.get('q') || '',
      sort:          p.get('sort') || 'timestamp:desc',
      live:          p.get('live') === '1',
    };
  }
  function writeUrl() {
    const p = new URLSearchParams();
    if (state.window !== 1440) p.set('window', state.window);
    if (state.user_id) p.set('user_id', state.user_id);
    if (state.action_prefix) p.set('action_prefix', state.action_prefix);
    if (state.result) p.set('result', state.result);
    if (state.source) p.set('source', state.source);
    if (state.q) p.set('q', state.q);
    if (state.sort && state.sort !== 'timestamp:desc') p.set('sort', state.sort);
    if (state.live) p.set('live', '1');
    const qs = p.toString();
    history.replaceState(null, '', qs ? '?' + qs : location.pathname);
  }

  // ---------------------------------------------------------------------
  // DOM refs
  // ---------------------------------------------------------------------
  const $ = id => document.getElementById(id);
  const winSel = $('obs-window');
  const live   = $('obs-live');
  const fUser  = $('f-user');
  const fAct   = $('f-action');
  const fRes   = $('f-result');
  const fSrc   = $('f-source');
  const fQ     = $('f-q');
  const fReset = $('f-reset');
  const tbody  = $('obs-rows');
  const more   = $('obs-loadmore');
  const rowCount = $('obs-row-count');
  const narrative = $('obs-narrative');

  // Apply state -> controls
  winSel.value = String(state.window);
  live.checked = state.live;
  fRes.value = state.result;
  fQ.value = state.q;

  // ---------------------------------------------------------------------
  // Fetch helpers
  // ---------------------------------------------------------------------
  async function get(url) {
    const r = await fetch(url, { credentials: 'same-origin' });
    if (!r.ok) throw new Error(r.status + ' ' + r.statusText);
    return r.json();
  }

  function buildTimelineUrl(cursor) {
    const p = new URLSearchParams();
    p.set('since_minutes', state.window);
    if (state.user_id)       p.set('user_id', state.user_id);
    if (state.action_prefix) p.set('action_prefix', state.action_prefix);
    if (state.result) {
      const v = state.result === 'success' ? 'success' : state.result; // server uses LIKE
      p.set('result_pattern', v);
    }
    if (state.q) p.set('q', state.q);
    if (cursor) { p.set('cursor_ts', cursor.ts); p.set('cursor_id', cursor.id); }
    p.set('limit', '50');
    return '/api/admin/activity?' + p.toString();
  }

  // ---------------------------------------------------------------------
  // Facets — populate dropdowns
  // ---------------------------------------------------------------------
  async function loadFacets() {
    const d = await get('/api/admin/observability/facets?since_minutes=' + state.window);

    fillSelect(fUser, d.users || [], state.user_id, u => ({
      value: u.id, label: (u.label || u.id) + ' (' + u.count + ')',
    }));
    fillSelect(fAct, (d.actions || []).map(a => a.value), state.action_prefix, v => ({
      value: v, label: v + ' (' + (d.actions.find(a => a.value === v) || {}).count + ')',
    }));
    fillSelect(fSrc, (d.sources || []).map(s => s.value), state.source, v => ({
      value: v, label: v + ' (' + (d.sources.find(s => s.value === v) || {}).count + ')',
    }));
  }
  function fillSelect(sel, items, current, mk) {
    while (sel.options.length > 1) sel.remove(1);
    items.forEach(it => {
      const m = typeof it === 'string' ? mk(it) : mk(it);
      const o = document.createElement('option');
      o.value = m.value; o.textContent = m.label;
      sel.appendChild(o);
    });
    if (current) sel.value = current;
  }

  // ---------------------------------------------------------------------
  // KPIs
  // ---------------------------------------------------------------------
  async function loadKpis() {
    const d = await get('/api/admin/observability/kpis?since_minutes=' + state.window);
    $('kpi-events').textContent = d.events_total.toLocaleString();
    $('kpi-users').textContent  = d.active_users.toLocaleString();
    $('kpi-errors').textContent = (d.error_rate * 100).toFixed(2) + '%';
    $('kpi-errors-sub').textContent = d.errors.toLocaleString() + ' errors';
    $('kpi-p95').textContent = d.p95_duration_ms == null ? '—' : d.p95_duration_ms + ' ms';
    renderNarrative(d);
  }
  function renderNarrative(d) {
    const win = winLabel(state.window);
    const filterBits = [];
    if (state.user_id)       filterBits.push('user filter');
    if (state.action_prefix) filterBits.push('action: ' + escapeHtml(state.action_prefix));
    if (state.source)        filterBits.push('source: ' + escapeHtml(state.source));
    if (state.result)        filterBits.push('result: ' + escapeHtml(state.result));
    if (state.q)             filterBits.push('search: "' + escapeHtml(state.q) + '"');
    const filtered = filterBits.length ? ' · filters: ' + filterBits.join(', ') : '';
    if (d.events_total === 0) {
      narrative.innerHTML = '<b>No activity</b> in ' + win + filtered + '. Lower the time window or reset filters.';
      return;
    }
    narrative.innerHTML =
      '<b>' + d.events_total.toLocaleString() + '</b> events in ' + win +
      ' · <b>' + d.active_users + '</b> users' +
      ' · <b>' + (d.error_rate * 100).toFixed(2) + '%</b> errors' +
      (d.p95_duration_ms != null ? ' · p95 <b>' + d.p95_duration_ms + ' ms</b>' : '') +
      filtered + '.';
  }

  function winLabel(m) {
    if (m <= 60)     return 'last 1h';
    if (m <= 360)    return 'last 6h';
    if (m <= 1440)   return 'last 24h';
    if (m <= 10080)  return 'last 7d';
    return 'last 30d';
  }

  // ---------------------------------------------------------------------
  // Table
  // ---------------------------------------------------------------------
  async function loadTable(append) {
    if (!append) {
      tbody.innerHTML = '<tr><td colspan="7" class="obs-empty">Loading…</td></tr>';
      nextCursor = null;
    }
    const d = await get(buildTimelineUrl(append ? nextCursor : null));
    const rows = d.rows || [];

    // Client-side source classification + sort.
    rows.forEach(r => { r._source = classifySource(r); });

    // result filter: 'error%' or 'success' — already done server-side via
    // result_pattern, but 'error%' is a LIKE wildcard; trust the server.
    // source filter is client-side because the API doesn't know about
    // our union rule yet.
    let filtered = rows;
    if (state.source) filtered = filtered.filter(r => r._source === state.source);
    if (state.action_prefix && fAct.value && !state.action_prefix.endsWith('%')) {
      // exact-match dropdown selection: prefer ==
      filtered = filtered.filter(r => (r.action || '').startsWith(state.action_prefix));
    }

    // Sort (client side for the current page; server is already DESC by time).
    // Sort key 'user' / 'source' map to derived display values; the rest read
    // straight off the row.
    const [sortKey, sortDir] = (state.sort || 'timestamp:desc').split(':');
    const keyFor = r => {
      if (sortKey === 'user')   return (r.user_email || r.user_name || r.user_id || '').toLowerCase();
      if (sortKey === 'source') return (r._source || '').toLowerCase();
      const v = r[sortKey];
      return (typeof v === 'string') ? v.toLowerCase() : v;
    };
    filtered.sort((a, b) => {
      let av = keyFor(a), bv = keyFor(b);
      if (av == null) av = '';
      if (bv == null) bv = '';
      if (av < bv) return sortDir === 'asc' ? -1 : 1;
      if (av > bv) return sortDir === 'asc' ?  1 : -1;
      return 0;
    });

    if (!append) tbody.innerHTML = '';
    if (filtered.length === 0 && !append) {
      tbody.innerHTML = '<tr><td colspan="7" class="obs-empty">No events match the current filters.</td></tr>';
      more.hidden = true;
      rowCount.textContent = '0 rows';
      return;
    }

    const frag = document.createDocumentFragment();
    filtered.forEach(r => frag.appendChild(renderRow(r)));
    tbody.appendChild(frag);

    nextCursor = d.next_cursor;
    more.hidden = !nextCursor;
    rowCount.textContent = tbody.children.length + ' rows shown' + (nextCursor ? ' (more available)' : '');
  }

  function classifySource(r) {
    if (r.client_kind) return r.client_kind;
    const SCHED = ['run_session_collector','run_verification_detector','run_corporate_memory','marketplace.sync_all'];
    if (SCHED.indexOf(r.action) !== -1) return 'scheduler';
    if (!r.user_id) return 'system';
    return 'other';
  }

  function renderRow(r) {
    const tr = document.createElement('tr');
    tr.dataset.rowJson = JSON.stringify(r);

    const td = (cls, html) => {
      const t = document.createElement('td');
      if (cls) t.className = cls;
      if (html != null) t.innerHTML = html;
      return t;
    };
    tr.appendChild(td('obs-time', escapeHtml(formatTime(r.timestamp))));

    // User cell: prefer "name (id-prefix)" or "email (id-prefix)" when the
    // server enriched the row from the users table; fall back to a truncated
    // id when the user has been deleted or the row belongs to a system actor.
    let userHtml = '—';
    if (r.user_id) {
      const idPrefix = r.user_id.slice(0, 8) + '…';
      const primary = r.user_name || r.user_email;
      if (primary) {
        userHtml = '<span class="obs-user-primary">' + escapeHtml(primary)
                 + '</span> <span class="obs-user-id">(' + escapeHtml(idPrefix) + ')</span>';
      } else {
        userHtml = '<code title="' + escapeHtml(r.user_id) + '">' + escapeHtml(idPrefix) + '</code>';
      }
    }
    tr.appendChild(td('', userHtml));

    const src = r._source || 'other';
    tr.appendChild(td('', '<span class="obs-source-pill is-' + src + '">' + escapeHtml(src) + '</span>'));

    tr.appendChild(td('obs-action-cell', escapeHtml(r.action || '')));
    tr.appendChild(td('', escapeHtml(r.resource || '—')));
    tr.appendChild(td('obs-dur-cell', r.duration_ms != null ? escapeHtml(r.duration_ms + ' ms') : '—'));

    const result = r.result || '';
    let resultHtml = '—';
    if (result) {
      const isErr = result.toLowerCase().startsWith('error');
      resultHtml = '<span class="' + (isErr ? 'obs-result-err' : 'obs-result-ok') + '">' + escapeHtml(result) + '</span>';
    }
    tr.appendChild(td('', resultHtml));

    tr.addEventListener('click', () => openPanel(r));
    return tr;
  }

  function formatTime(ts) {
    if (!ts) return '';
    const d = new Date(ts);
    if (isNaN(d.getTime())) return ts;
    // Compact: HH:MM:SS for today, DD.MM HH:MM otherwise.
    const now = new Date();
    if (d.toDateString() === now.toDateString()) {
      return d.toTimeString().slice(0, 8);
    }
    return d.toLocaleString(undefined, { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' });
  }
  function truncMiddle(s, n) {
    if (!s || s.length <= n) return s;
    const half = Math.floor((n - 1) / 2);
    return s.slice(0, half) + '…' + s.slice(-half);
  }
  function escapeHtml(s) {
    if (s == null) return '';
    return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
  }

  // ---------------------------------------------------------------------
  // Detail panel
  // ---------------------------------------------------------------------
  const panel = $('obs-panel');
  const panelTitle = $('obs-panel-title');
  const panelMeta  = $('obs-panel-meta');
  const panelP     = $('obs-panel-params');
  const panelR     = $('obs-panel-result');

  function openPanel(r) {
    panelTitle.textContent = r.action || 'event';
    panelMeta.innerHTML = [
      meta('When',    formatTime(r.timestamp)),
      meta('User',    r.user_id || '—'),
      meta('Source',  r._source || '—'),
      meta('Resource', r.resource || '—'),
      meta('Duration', r.duration_ms != null ? r.duration_ms + ' ms' : '—'),
      meta('Result',   r.result || '—'),
      r.client_ip       ? meta('Client IP', r.client_ip) : '',
      r.correlation_id  ? meta('Correlation', r.correlation_id) : '',
    ].join('');
    panelP.textContent = prettyJson(r.params);
    panelR.textContent = prettyJson(r.result_payload || r.params_before || '—');
    panel.hidden = false;

    $('obs-panel-filter-user').onclick = () => {
      state.user_id = r.user_id || '';
      fUser.value = state.user_id;
      panel.hidden = true; writeUrl(); refreshAll();
    };
    $('obs-panel-filter-action').onclick = () => {
      state.action_prefix = r.action || '';
      fAct.value = state.action_prefix;
      panel.hidden = true; writeUrl(); refreshAll();
    };

    document.querySelectorAll('.obs-table tbody tr.is-selected').forEach(t => t.classList.remove('is-selected'));
    event.currentTarget.classList.add('is-selected');
  }
  function meta(label, value) {
    return '<dt>' + escapeHtml(label) + '</dt><dd>' + escapeHtml(value || '—') + '</dd>';
  }
  function prettyJson(v) {
    if (v == null) return '—';
    if (typeof v === 'string') {
      try { return JSON.stringify(JSON.parse(v), null, 2); } catch (_) { return v; }
    }
    try { return JSON.stringify(v, null, 2); } catch (_) { return String(v); }
  }
  $('obs-panel-close').addEventListener('click', () => panel.hidden = true);

  // Saved-views UI was removed on user feedback (the modal pop-open on load
  // bug was the trigger; the underlying value-add wasn't there). The
  // /api/admin/observability/views CRUD + DuckDB table remain in case we
  // bring it back behind an opt-in toggle. See PR thread.

  // ---------------------------------------------------------------------
  // KPI quick-filter buttons
  // ---------------------------------------------------------------------
  document.querySelectorAll('.obs-kpi').forEach(b => {
    b.addEventListener('click', () => {
      const k = b.dataset.filter;
      if (k === 'errors') {
        state.result = 'error%'; fRes.value = 'error%';
      } else if (k === 'users') {
        fUser.focus();
        return;
      } else if (k === 'slow') {
        state.sort = 'duration_ms:desc';
        updateSortIndicators();
      } else if (k === '') {
        Object.assign(state, { user_id:'', action_prefix:'', result:'', source:'', q:'' });
        fUser.value=''; fAct.value=''; fRes.value=''; fSrc.value=''; fQ.value='';
      }
      writeUrl(); refreshAll();
    });
  });

  // ---------------------------------------------------------------------
  // Sort
  // ---------------------------------------------------------------------
  document.querySelectorAll('.obs-table thead th[data-sort]').forEach(th => {
    th.addEventListener('click', () => {
      const key = th.dataset.sort;
      const [curKey, curDir] = (state.sort || 'timestamp:desc').split(':');
      const newDir = (curKey === key && curDir === 'desc') ? 'asc' : 'desc';
      state.sort = key + ':' + newDir;
      updateSortIndicators();
      writeUrl();
      loadTable(false);
    });
  });
  function updateSortIndicators() {
    const [k, dir] = (state.sort || 'timestamp:desc').split(':');
    document.querySelectorAll('.obs-table thead th[data-sort]').forEach(th => {
      const isActive = th.dataset.sort === k;
      th.setAttribute('aria-sort', isActive ? (dir === 'asc' ? 'ascending' : 'descending') : 'none');
    });
  }
  updateSortIndicators();

  // ---------------------------------------------------------------------
  // Wiring
  // ---------------------------------------------------------------------
  winSel.addEventListener('change', () => {
    state.window = parseInt(winSel.value, 10);
    writeUrl(); refreshAll();
  });
  fUser.addEventListener('change', () => { state.user_id = fUser.value; writeUrl(); refreshOnFilter(); });
  fAct.addEventListener('change',  () => { state.action_prefix = fAct.value; writeUrl(); refreshOnFilter(); });
  fRes.addEventListener('change',  () => { state.result = fRes.value; writeUrl(); refreshOnFilter(); });
  fSrc.addEventListener('change',  () => { state.source = fSrc.value; writeUrl(); refreshOnFilter(); });
  let qDebounce;
  fQ.addEventListener('input', () => {
    clearTimeout(qDebounce);
    qDebounce = setTimeout(() => { state.q = fQ.value; writeUrl(); refreshOnFilter(); }, 250);
  });
  fReset.addEventListener('click', () => {
    Object.assign(state, { user_id:'', action_prefix:'', result:'', source:'', q:'' });
    fUser.value=''; fAct.value=''; fRes.value=''; fSrc.value=''; fQ.value='';
    writeUrl(); refreshAll();
  });
  more.addEventListener('click', () => loadTable(true));

  live.addEventListener('change', () => {
    state.live = live.checked; writeUrl();
    if (state.live) { livePollId = setInterval(refreshLight, 30000); }
    else { clearInterval(livePollId); livePollId = null; }
  });

  function refreshLight() {
    // Only KPIs + table. Facets don't change every 30s usefully.
    loadKpis().catch(()=>{});
    loadTable(false).catch(()=>{});
  }
  function refreshOnFilter() {
    loadKpis().catch(()=>{});
    loadTable(false).catch(()=>{});
  }
  function refreshAll() {
    loadKpis().catch(()=>{});
    loadFacets().catch(()=>{});
    loadTable(false).catch(()=>{});
  }

  // boot
  refreshAll();
  if (state.live) { livePollId = setInterval(refreshLight, 30000); }
})();
</script>
{% endblock %}
