AkurAI Build
Menu

Public repository / 9a35bd9c668fae6e3383420387d1a0b1b4b3f524

AkurAI-Monitor / ui/app.js

Latest change 6bb03beff5ca893c4caf66f9aa5fa00769e53cd3 - Remove pi-bun by Ólafur Búi Ólafsson

const $ = (s) => document.querySelector(s);
const app = $('#app');

// View + live state
let rangeHours = 24;
let currentView = 'dashboard';
let currentSpecs = [];
let renderedBadgeKey = '';
let chartTimer = null;
let chartController = null;
let latestMetrics = [];
let dashboardHostId = null;
let dashboardCategory = 'All';
let dashboardHosts = null;
let liveLogs = [];
const LOG_CAP = 1000;
let logRangeHours = 1;
let logSearch = '';
let logExcluded = new Set();
let logExcludedCats = new Set();
let logLevel = 'all'; // 'all' | 'warn' | 'error'
let logSourceKey = '';
let logHostId = null;
let logProcess = '';
let connState = 'connecting'; // 'live' | 'polling' | 'connecting'

async function manageApi(path, init) {
  const response = await fetch(path, {
    ...init,
    headers: { 'content-type': 'application/json', ...(init?.headers || {}) },
  });
  const body = await response.json();
  if (!response.ok) throw new Error(body.error || `Request failed (${response.status})`);
  return body;
}

function showToast(message, kind = 'success') {
  let tray = document.querySelector('.toast-tray');
  if (!tray) {
    tray = document.createElement('div');
    tray.className = 'toast-tray';
    tray.setAttribute('aria-live', 'polite');
    document.body.appendChild(tray);
  }
  const toast = document.createElement('div');
  toast.className = `toast ${kind}`;
  toast.textContent = message;
  tray.appendChild(toast);
  setTimeout(() => toast.remove(), 4000);
}

function confirmAction(title, message, actionLabel) {
  return new Promise((resolve) => {
    const modal = document.createElement('div');
    modal.className = 'modal-layer';
    modal.innerHTML = `<div class="confirm-modal" role="dialog" aria-modal="true" aria-labelledby="confirm-title">
      <h3 id="confirm-title">${escapeHtml(title)}</h3>
      <p>${escapeHtml(message)}</p>
      <div class="modal-actions"><button class="quiet" data-cancel>Cancel</button><button class="danger" data-confirm>${escapeHtml(actionLabel)}</button></div>
    </div>`;
    document.body.appendChild(modal);
    const finish = (answer) => { modal.remove(); resolve(answer); };
    modal.querySelector('[data-cancel]').addEventListener('click', () => finish(false));
    modal.querySelector('[data-confirm]').addEventListener('click', () => finish(true));
    modal.addEventListener('click', (event) => { if (event.target === modal) finish(false); });
    modal.querySelector('[data-cancel]').focus();
  });
}

async function api(path, init) {
  const res = await fetch(path, init);
  return res.json();
}

// Host inventory is shared by the dashboard sidebar, cards, and chart legends.
let hostNames = new Map();
async function loadDashboardHosts() {
  if (dashboardHosts) return dashboardHosts;
  try {
    const data = await manageApi('/api/manage/hosts');
    dashboardHosts = (data.items || []).sort((a, b) => a.name.localeCompare(b.name));
    hostNames = new Map(dashboardHosts.map((host) => [host.id, host.name]));
  } catch {
    dashboardHosts = [];
  }
  return dashboardHosts;
}
function hostName(hostId) {
  if (hostId == null) return '—';
  return hostNames.get(hostId) || `Host ${hostId}`;
}

function ts(epoch) {
  return new Date(epoch * 1000).toLocaleString();
}

function cssVar(name) {
  return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || '#60a5fa';
}

function severity(name, value) {
  if (name.includes('used_pct') || name === 'cpu.usage' || name === 'mem.used_pct') {
    if (value >= 90) return 'crit';
    if (value >= 75) return 'warn';
  }
  if (name === 'systemd.units.failed' && value > 0) return 'crit';
  if (name.endsWith('.active') && value < 1) return 'crit';
  return 'ok';
}

function formatValue(name, value) {
  if (name.includes('_kb')) return formatBytes(value * 1024);
  if (name.includes('_gb')) return value.toFixed(1) + ' GB';
  if (name.includes('_pct') || name === 'cpu.usage') return value.toFixed(1) + '%';
  if (name.includes('bytes')) return formatBytes(value) + '/s';
  if (name === 'uptime.seconds') return formatDuration(value);
  if (name.startsWith('load.')) return value.toFixed(2);
  if (name === 'dns.qps') return value.toFixed(2) + '/s';
  if (name.startsWith('dns.latency_us.')) return formatMicros(value);
  if (name.startsWith('dns.')) return value.toFixed(0);
  return value.toFixed(0);
}

function formatMicros(us) {
  if (us < 1000) return us.toFixed(0) + ' µs';
  return (us / 1000).toFixed(1) + ' ms';
}

function formatBytes(b) {
  if (b < 1024) return b.toFixed(0) + ' B';
  if (b < 1048576) return (b / 1024).toFixed(1) + ' KB';
  if (b < 1073741824) return (b / 1048576).toFixed(1) + ' MB';
  return (b / 1073741824).toFixed(1) + ' GB';
}

function formatDuration(secs) {
  const d = Math.floor(secs / 86400);
  const h = Math.floor((secs % 86400) / 3600);
  return d > 0 ? `${d}d ${h}h` : `${h}h`;
}

function metricLabel(name) {
  const labels = {
    'cpu.usage': 'CPU Usage',
    'mem.used_pct': 'Memory',
    'mem.total_kb': 'Total RAM',
    'mem.used_kb': 'Used RAM',
    'mem.available_kb': 'Available RAM',
    'load.1m': 'Load 1m',
    'load.5m': 'Load 5m',
    'load.15m': 'Load 15m',
    'uptime.seconds': 'Uptime',
    'dns.qps': 'DNS Queries/s',
    'dns.latency_us.p95': 'DNS Latency p95',
    'systemd.units.failed': 'Failed services',
    'docker.containers.running': 'Running containers',
  };
  if (labels[name]) return labels[name];
  if (name.startsWith('disk.')) return name.replace('disk.', 'Disk ').replace('.used_pct', ' Usage').replace('.total_gb', ' Total').replace('.used_gb', ' Used');
  if (name.startsWith('net.')) return name.replace('net.', 'Net ').replace('.rx_bytes', ' RX').replace('.tx_bytes', ' TX');
  if (name.startsWith('proc.')) return name.replace('proc.', '').replace('.mem_kb', ' memory').replace('.count', ' processes').replaceAll('_', ' ');
  if (name.startsWith('systemd.')) return name.replace('systemd.', '').replace('.mem_kb', ' memory').replace('.restarts', ' restarts').replace('.active', ' active').replaceAll('_', ' ');
  if (name.startsWith('docker.')) return name.replace('docker.', '').replace('.mem_kb', ' memory').replaceAll('_', ' ');
  if (name === 'dns.queries') return 'Queries';
  if (name.startsWith('dns.proto.')) return `${name.slice('dns.proto.'.length).toUpperCase()} queries`;
  if (name.startsWith('dns.rcode.')) return name.slice('dns.rcode.'.length).toUpperCase();
  if (name.startsWith('dns.qtype.')) return name.slice('dns.qtype.'.length).toUpperCase();
  if (name.startsWith('dns.latency_us.')) return name.slice('dns.latency_us.'.length);
  return name;
}

function connLabel() {
  if (connState === 'live') return '<span class="live-dot live"></span>Live';
  if (connState === 'polling') return '<span class="live-dot"></span>Polling (10s)';
  return '<span class="live-dot"></span>Connecting…';
}

function updateConnUI() {
  document.querySelectorAll('.conn').forEach((el) => { el.innerHTML = connLabel(); });
}


// ── Dashboard ──────────────────────────────────────────────────────
const cardOrder = ['cpu.usage', 'mem.used_pct', 'load.1m', 'uptime.seconds'];
const RANGES = [[1, '1h'], [6, '6h'], [24, '24h'], [168, '7d']];

function sortMetrics(metrics) {
  return [...metrics].sort((a, b) => {
    const ai = cardOrder.indexOf(a.name);
    const bi = cardOrder.indexOf(b.name);
    if (ai >= 0 && bi >= 0) return ai - bi;
    if (ai >= 0) return -1;
    if (bi >= 0) return 1;
    return a.name.localeCompare(b.name);
  });
}

const CHART_COLORS = ['--accent', '--ok', '--warn', '--danger', '--info', '--muted'];

function entitySeries(names, prefix, suffix) {
  return names
    .filter((name) => name.startsWith(prefix) && name.endsWith(suffix))
    .sort()
    .map((name, index) => ({
      name,
      color: CHART_COLORS[index % CHART_COLORS.length],
      label: metricLabel(name).replace(/ (memory|restarts|active|processes)$/, ''),
    }));
}

function chartSpecs(names) {
  names = [...new Set(names)];
  const has = (name) => names.includes(name);
  const specs = [];

  if (has('cpu.usage')) {
    specs.push({ category: 'System', title: 'CPU Usage', series: [{ name: 'cpu.usage', color: '--accent' }], min: 0, max: 100, fmt: (value) => value.toFixed(0) + '%' });
  }
  if (has('mem.used_pct')) {
    specs.push({ category: 'System', title: 'Memory', series: [{ name: 'mem.used_pct', color: '--ok' }], min: 0, max: 100, fmt: (value) => value.toFixed(0) + '%' });
  }

  const load = [
    { name: 'load.1m', color: '--accent' },
    { name: 'load.5m', color: '--warn' },
    { name: 'load.15m', color: '--muted' },
  ].filter((definition) => has(definition.name));
  if (load.length) {
    specs.push({ category: 'System', title: 'Load Average', series: load, min: 0, fmt: (value) => value.toFixed(1) });
  }

  const ram = [
    { name: 'mem.used_kb', color: '--warn', label: 'Used' },
    { name: 'mem.available_kb', color: '--ok', label: 'Available' },
    { name: 'mem.total_kb', color: '--accent', label: 'Total' },
  ].filter((definition) => has(definition.name));
  if (ram.length) {
    specs.push({ category: 'System', title: 'RAM', series: ram, min: 0, fmt: (value) => formatBytes(value * 1024) });
  }
  if (has('uptime.seconds')) {
    specs.push({ category: 'System', title: 'Uptime', series: [{ name: 'uptime.seconds', color: '--accent' }], min: 0, fmt: formatDuration });
  }

  const disks = [...new Set(names.filter((name) => name.startsWith('disk.')).map((name) => name.split('.')[1]))].sort();
  for (const disk of disks) {
    const usage = `disk.${disk}.used_pct`;
    if (has(usage)) {
      specs.push({ category: 'Storage', title: metricLabel(usage), series: [{ name: usage, color: '--warn' }], min: 0, max: 100, fmt: (value) => value.toFixed(0) + '%' });
    }
    const space = [
      { name: `disk.${disk}.used_gb`, color: '--warn', label: 'Used' },
      { name: `disk.${disk}.total_gb`, color: '--accent', label: 'Total' },
    ].filter((definition) => has(definition.name));
    if (space.length) {
      specs.push({ category: 'Storage', title: `Disk ${disk} space`, series: space, min: 0, fmt: (value) => value.toFixed(1) + ' GB' });
    }
  }

  const ifaces = [...new Set(names.filter((name) => name.startsWith('net.')).map((name) => name.split('.')[1]))].sort();
  for (const iface of ifaces) {
    specs.push({
      category: 'Network',
      title: `Network ${iface}`,
      series: [
        { name: `net.${iface}.rx_bytes`, color: '--ok', label: 'RX' },
        { name: `net.${iface}.tx_bytes`, color: '--accent', label: 'TX' },
      ],
      min: 0,
      fmt: formatBytes,
    });
  }

  const processMemory = entitySeries(names, 'proc.', '.mem_kb');
  if (processMemory.length) {
    specs.push({ category: 'Processes', title: 'Process memory', series: processMemory, min: 0, fmt: (value) => formatBytes(value * 1024), wide: true });
  }
  const processCounts = entitySeries(names, 'proc.', '.count');
  if (processCounts.length) {
    specs.push({ category: 'Processes', title: 'Process counts', series: processCounts, min: 0, fmt: (value) => value.toFixed(0), wide: true });
  }

  const serviceMemory = entitySeries(names, 'systemd.', '.mem_kb');
  if (serviceMemory.length) {
    specs.push({ category: 'Services', title: 'Service memory', series: serviceMemory, min: 0, fmt: (value) => formatBytes(value * 1024), wide: true });
  }
  const serviceRestarts = entitySeries(names, 'systemd.', '.restarts');
  if (serviceRestarts.length) {
    specs.push({ category: 'Services', title: 'Service restarts', series: serviceRestarts, min: 0, fmt: (value) => value.toFixed(0), wide: true });
  }
  const serviceHealth = entitySeries(names, 'systemd.', '.active');
  if (has('systemd.units.failed')) {
    serviceHealth.push({ name: 'systemd.units.failed', color: CHART_COLORS[serviceHealth.length % CHART_COLORS.length], label: 'Failed services' });
  }
  if (serviceHealth.length) {
    specs.push({ category: 'Services', title: 'Service health', series: serviceHealth, min: 0, fmt: (value) => value.toFixed(0), wide: true });
  }

  const containerMemory = entitySeries(names, 'docker.', '.mem_kb');
  if (containerMemory.length) {
    specs.push({ category: 'Containers', title: 'Container memory', series: containerMemory, min: 0, fmt: (value) => formatBytes(value * 1024), wide: true });
  }
  if (has('docker.containers.running')) {
    specs.push({ category: 'Containers', title: 'Running containers', series: [{ name: 'docker.containers.running', color: '--ok' }], min: 0, fmt: (value) => value.toFixed(0) });
  }

  if (has('dns.qps')) {
    specs.push({ category: 'DNS', title: 'DNS Query Rate', series: [{ name: 'dns.qps', color: '--accent', label: 'queries/s' }], min: 0, fmt: (value) => value.toFixed(1) + '/s' });
  }
  const dnsTraffic = [
    ...(has('dns.queries') ? [{ name: 'dns.queries', color: '--accent', label: 'Queries' }] : []),
    ...entitySeries(names, 'dns.proto.', ''),
  ];
  if (dnsTraffic.length) {
    specs.push({ category: 'DNS', title: 'DNS Traffic', series: dnsTraffic, min: 0, fmt: (value) => value.toFixed(0) });
  }
  const responseCodes = entitySeries(names, 'dns.rcode.', '');
  if (responseCodes.length) {
    specs.push({ category: 'DNS', title: 'DNS Response Codes', series: responseCodes, min: 0, fmt: (value) => value.toFixed(0), wide: true });
  }
  const queryTypes = entitySeries(names, 'dns.qtype.', '');
  if (queryTypes.length) {
    specs.push({ category: 'DNS', title: 'DNS Query Types', series: queryTypes, min: 0, fmt: (value) => value.toFixed(0), wide: true });
  }
  const latency = entitySeries(names, 'dns.latency_us.', '');
  if (latency.length) {
    specs.push({ category: 'DNS', title: 'DNS Latency', series: latency, min: 0, fmt: (value) => value < 1000 ? value.toFixed(0) + 'µs' : (value / 1000).toFixed(1) + 'ms' });
  }

  return specs;
}

// Render order for chart category sections; unknown categories follow, sorted.
const CATEGORY_ORDER = ['System', 'Storage', 'Network', 'Processes', 'Services', 'Containers', 'DNS'];
function orderedCategories(specs) {
  const present = [...new Set(specs.map((spec) => spec.category || 'Other'))];
  return present.sort((a, b) => {
    const ai = CATEGORY_ORDER.indexOf(a);
    const bi = CATEGORY_ORDER.indexOf(b);
    if (ai >= 0 && bi >= 0) return ai - bi;
    if (ai >= 0) return -1;
    if (bi >= 0) return 1;
    return a.localeCompare(b);
  });
}

const DASHBOARD_CATEGORIES = ['All', ...CATEGORY_ORDER];
function metricCategory(name) {
  if (name.startsWith('disk.')) return 'Storage';
  if (name.startsWith('net.')) return 'Network';
  if (name.startsWith('proc.')) return 'Processes';
  if (name.startsWith('systemd.')) return 'Services';
  if (name.startsWith('docker.')) return 'Containers';
  if (name.startsWith('dns.')) return 'DNS';
  return 'System';
}

function visibleDashboardMetrics(metrics) {
  return metrics.filter((metric) =>
    (dashboardHostId == null || metric.host_id === dashboardHostId)
    && (dashboardCategory === 'All' || metricCategory(metric.name) === dashboardCategory)
  );
}

const SNAPSHOT_METRICS = new Set([
  'cpu.usage',
  'mem.used_pct',
  'load.1m',
  'uptime.seconds',
  'systemd.units.failed',
  'docker.containers.running',
]);

function snapshotMetric(name) {
  return SNAPSHOT_METRICS.has(name) || (name.startsWith('disk.') && name.endsWith('.used_pct'));
}

function renderMetricTag(metric, label) {
  return `<span class="metric-tag">
    <span class="metric-tag-label">${escapeHtml(label)}</span>
    <strong class="metric-tag-value value ${severity(metric.name, metric.value)}" data-badge="${escapeHtml(metricKey(metric))}">${formatValue(metric.name, metric.value)}</strong>
  </span>`;
}

function renderChartValueTags(spec, metrics) {
  const definitions = new Map(spec.series.map((definition) => [definition.name, definition]));
  const tags = metrics
    .filter((metric) => definitions.has(metric.name))
    .map((metric) => {
      const definition = definitions.get(metric.name);
      const metricName = definition.label || metricLabel(metric.name);
      const label = dashboardHostId == null ? `${hostName(metric.host_id)} · ${metricName}` : metricName;
      return renderMetricTag(metric, label);
    });
  return tags.length ? `<div class="chart-current" aria-label="Current values">${tags.join('')}</div>` : '';
}

function metricKey(metric) {
  return `${metric.host_id ?? 'none'}:${metric.name}`;
}

function renderDashboardSidebar(metrics) {
  const latestByHost = new Map();
  for (const metric of metrics) {
    latestByHost.set(metric.host_id, Math.max(latestByHost.get(metric.host_id) || 0, metric.ts || 0));
  }
  const freshness = (hostId) => {
    const latest = latestByHost.get(hostId);
    if (!latest) return 'No data';
    const ageMinutes = Math.max(0, Math.floor((Date.now() / 1000 - latest) / 60));
    return ageMinutes < 1 ? 'Now' : `${ageMinutes}m ago`;
  };
  const hosts = dashboardHosts || [];
  const hostButtons = hosts.map((host) => `<button class="dashboard-filter host-filter${dashboardHostId === host.id ? ' active' : ''}" data-host="${host.id}" aria-pressed="${dashboardHostId === host.id}">
    <span><strong>${escapeHtml(host.name)}</strong><small>${escapeHtml(host.kind || host.address)}</small></span>
    <em>${escapeHtml(freshness(host.id))}</em>
  </button>`).join('');
  const categories = DASHBOARD_CATEGORIES.map((category) => `<button class="dashboard-filter category-filter${dashboardCategory === category ? ' active' : ''}" data-category="${category}" aria-pressed="${dashboardCategory === category}">${category}</button>`).join('');
  return `<aside class="card dashboard-sidebar" aria-label="Dashboard filters">
    <div class="sidebar-group"><div class="field-label">Hosts</div>
      <button class="dashboard-filter host-filter${dashboardHostId == null ? ' active' : ''}" data-host="" aria-pressed="${dashboardHostId == null}">
        <span><strong>All hosts</strong><small>${hosts.length} monitored</small></span>
      </button>
      <div class="host-list">${hostButtons || '<span class="empty-mini">No hosts available</span>'}</div>
    </div>
    <div class="sidebar-group"><div class="field-label">Metrics</div><div class="category-list">${categories}</div></div>
  </aside>`;
}
function processTableMarkup(metrics) {
  const entities = new Map();
  const hostMemory = new Map(metrics
    .filter((metric) => metric.name === 'mem.total_kb')
    .map((metric) => [metric.host_id, metric.value]));

  for (const metric of metrics) {
    if (!metric.name.startsWith('proc.')) continue;
    const suffix = metric.name.endsWith('.mem_kb') ? '.mem_kb' : metric.name.endsWith('.count') ? '.count' : null;
    if (!suffix) continue;
    const name = metric.name.slice(5, -suffix.length);
    const key = `${metric.host_id}:${name}`;
    const entity = entities.get(key) || { hostId: metric.host_id, name, memoryKb: 0, count: 0, ts: 0 };
    if (suffix === '.mem_kb') entity.memoryKb = metric.value;
    else entity.count = metric.value;
    entity.ts = Math.max(entity.ts, metric.ts || 0);
    entities.set(key, entity);
  }

  const rows = [...entities.values()]
    .filter((entity) => dashboardHostId == null || entity.hostId === dashboardHostId)
    .sort((left, right) => right.memoryKb - left.memoryKb || left.name.localeCompare(right.name));
  if (rows.length === 0) return '<section class="card process-overview"><div class="empty-state">No process telemetry matches this host.</div></section>';

  const now = Date.now() / 1000;
  const live = rows.filter((entity) => now - entity.ts <= 180);
  const totalMemoryKb = live.reduce((total, entity) => total + entity.memoryKb, 0);
  const totalInstances = live.reduce((total, entity) => total + entity.count, 0);
  const body = rows.map((entity) => {
    const age = Math.max(0, now - entity.ts);
    const current = age <= 180;
    const averageKb = entity.count > 0 ? entity.memoryKb / entity.count : 0;
    const memoryPct = hostMemory.get(entity.hostId)
      ? `${(entity.memoryKb / hostMemory.get(entity.hostId) * 100).toFixed(1)}%`
      : '—';
    const params = new URLSearchParams({ host: String(entity.hostId), process: entity.name, hours: '24' });
    return `<tr class="${current ? '' : 'process-stale'}">
      <td><a class="process-link" href="#/logs?${params}" aria-label="View ${escapeHtml(entity.name)} logs on ${escapeHtml(hostName(entity.hostId))}"><strong>${escapeHtml(entity.name.replaceAll('_', ' '))}</strong><span>View logs →</span></a></td>
      <td>${escapeHtml(hostName(entity.hostId))}</td>
      <td><span class="badge ${current ? 'badge-ok' : 'badge-warn'}">${current ? 'Reporting' : 'Stale'}</span></td>
      <td class="numeric">${entity.count.toFixed(0)}</td>
      <td class="numeric">${formatBytes(entity.memoryKb * 1024)}</td>
      <td class="numeric">${formatBytes(averageKb * 1024)}</td>
      <td class="numeric">${memoryPct}</td>
      <td><time datetime="${new Date(entity.ts * 1000).toISOString()}">${escapeHtml(ts(entity.ts))}</time></td>
    </tr>`;
  }).join('');

  return `<section class="card process-overview" aria-labelledby="process-overview-title">
    <div class="process-overview-head">
      <div><h2 id="process-overview-title">Process inventory</h2><p>Aggregated by command name. Select a process to inspect its host-scoped logs.</p></div>
      <div class="process-summary" aria-label="Reporting process summary">
        <span><strong>${live.length}</strong> groups</span>
        <span><strong>${totalInstances.toFixed(0)}</strong> instances</span>
        <span><strong>${formatBytes(totalMemoryKb * 1024)}</strong> RSS</span>
        ${rows.length > live.length ? `<span class="process-summary-warn"><strong>${rows.length - live.length}</strong> stale</span>` : ''}
      </div>
    </div>
    <div class="table-wrap process-table-wrap"><table class="data-table process-table">
      <thead><tr><th>Process</th><th>Host</th><th>Status</th><th>Instances</th><th>Total RSS</th><th>RSS / instance</th><th>Host RAM</th><th>Last reported</th></tr></thead>
      <tbody>${body}</tbody>
    </table></div>
  </section>`;
}

function updateProcessTable(metrics) {
  const overview = app.querySelector('.process-overview');
  if (!overview || dashboardCategory !== 'Processes') return;
  const wrapper = document.createElement('div');
  wrapper.innerHTML = processTableMarkup(metrics);
  overview.replaceWith(wrapper.firstElementChild);
}


function badgeKey(metrics) {
  return metrics.map(metricKey).sort().join('|');
}

function updateBadges(metrics) {
  latestMetrics = metrics;
  if (currentView !== 'dashboard' || renderedBadgeKey === '') return;
  const visible = visibleDashboardMetrics(metrics);
  if (badgeKey(visible) !== renderedBadgeKey) {
    renderDashboard(metrics); // selected host's metric set changed — rebuild
    return;
  }
  const badges = new Map();
  for (const element of app.querySelectorAll('[data-badge]')) {
    const elements = badges.get(element.dataset.badge) || [];
    elements.push(element);
    badges.set(element.dataset.badge, elements);
  }
  for (const metric of visible) {
    for (const element of badges.get(metricKey(metric)) || []) {
      element.textContent = formatValue(metric.name, metric.value);
      element.classList.remove('ok', 'warn', 'crit');
      element.classList.add(severity(metric.name, metric.value));
    }
  }
  updateProcessTable(metrics);
}

async function refreshAlerts() {
  const el = $('#alerts-section');
  if (!el) return;
  const data = await api(`/api/alerts?hours=24${dashboardHostId == null ? '' : `&host_id=${dashboardHostId}`}`);
  const active = (data.alerts || []).filter((a) => !a.resolved_at);
  if (active.length === 0) { el.innerHTML = ''; return; }
  let html = '<section class="card"><div class="card-header"><div class="card-title">Active alerts</div></div><div class="table-wrap"><table class="data-table"><tr><th>Rule</th><th>Metric</th><th>Value</th><th>Since</th></tr>';
  for (const a of active) {
    html += `<tr class="alert-active"><td>${escapeHtml(a.rule_name)}</td><td>${escapeHtml(a.metric_name)}</td><td>${a.value.toFixed(1)}</td><td>${ts(a.triggered_at)}</td></tr>`;
  }
  html += '</table></div></section>';
  el.innerHTML = html;
}

async function drawCharts() {
  if (currentSpecs.length === 0) return;
  chartController?.abort();
  const controller = new AbortController();
  chartController = controller;
  const specs = currentSpecs;
  const wanted = [...new Set(specs.flatMap((spec) => spec.series.map((series) => series.name)))];
  try {
    const requests = [];
    for (let offset = 0; offset < wanted.length; offset += 80) {
      const params = new URLSearchParams({
        names: wanted.slice(offset, offset + 80).join(','),
        hours: String(rangeHours),
        points: '120',
      });
      if (dashboardHostId != null) params.set('host_id', String(dashboardHostId));
      requests.push(api(`/api/metrics?${params}`, { signal: controller.signal }));
    }
    const responses = await Promise.all(requests);
    if (controller.signal.aborted || currentView !== 'dashboard') return;
    const seriesData = new Map();
    for (const metric of responses.flatMap((response) => response.metrics || [])) {
      const key = `${metric.host_id}\u0000${metric.name}`;
      const host = hostName(metric.host_id);
      const entry = seriesData.get(key) || { host, name: metric.name, points: [] };
      entry.points.push({ ts: metric.ts, value: metric.value });
      seriesData.set(key, entry);
    }
    specs.forEach((spec, index) => {
      const chart = document.getElementById(`chart-${index}`);
      const legend = document.getElementById(`legend-${index}`);
      if (!chart) return;
      const series = spec.series.flatMap((definition) =>
        [...seriesData.values()]
          .filter((entry) => entry.name === definition.name)
          .map((entry) => ({
            color: definition.color,
            label: `${entry.host} · ${definition.label || metricLabel(definition.name)}`,
            points: entry.points,
          }))
      );
      if (series.length > spec.series.length) {
        series.forEach((item, seriesIndex) => { item.color = CHART_COLORS[seriesIndex % CHART_COLORS.length]; });
      }
      if (legend) legend.innerHTML = series.map((item) => `<span class="lg"><i style="background:${cssVar(item.color)}"></i>${escapeHtml(item.label)}</span>`).join('');
      renderTimeSeriesChart(chart, series, { label: spec.title, min: spec.min, max: spec.max, fmt: spec.fmt });
    });
  } catch (error) {
    if (error.name !== 'AbortError') throw error;
  } finally {
    if (chartController === controller) chartController = null;
  }
}

async function renderDashboard(seedMetrics) {
  await loadDashboardHosts();
  let metrics = seedMetrics || latestMetrics;
  if (metrics.length === 0) {
    const status = await api('/api/status');
    if (currentView !== 'dashboard') return;
    metrics = status.metrics || [];
    latestMetrics = metrics;
  }

  if (dashboardHostId != null && dashboardHosts.length > 0 && !dashboardHosts.some((host) => host.id === dashboardHostId)) {
    dashboardHostId = null;
    syncDashboardUrl();
  }

  const hostMetrics = metrics.filter((metric) => dashboardHostId == null || metric.host_id === dashboardHostId);
  const visible = visibleDashboardMetrics(metrics);
  const snapshot = sortMetrics(visible.filter((metric) => snapshotMetric(metric.name)));
  renderedBadgeKey = badgeKey(visible);
  currentSpecs = chartSpecs(hostMetrics.map((metric) => metric.name))
    .filter((spec) => dashboardCategory === 'All' || spec.category === dashboardCategory);
  const scopeName = dashboardHostId == null ? 'All hosts' : hostName(dashboardHostId);

  let content = `<div class="refresh conn">${connLabel()}</div>`;
  content += `<div class="section-heading"><div><h1>${escapeHtml(scopeName)}</h1><p>Live host telemetry and service health.</p></div></div>`;
  if (snapshot.length > 0) {
    content += '<div class="snapshot-strip" aria-label="Current status">';
    for (const metric of snapshot) {
      const label = dashboardHostId == null ? `${hostName(metric.host_id)} · ${metricLabel(metric.name)}` : metricLabel(metric.name);
      content += renderMetricTag(metric, label);
    }
    content += '</div>';
  }
  if (dashboardCategory === 'Processes') {
    content += processTableMarkup(hostMetrics);
  }

  content += '<div class="trend-head"><div><h2>Trends</h2><p>Historical metrics with current values kept beside their charts.</p></div><div class="ranges">';
  for (const [hours, label] of RANGES) {
    content += `<button class="btn btn-secondary btn-sm range${hours === rangeHours ? ' active' : ''}" data-hours="${hours}">${label}</button>`;
  }
  content += '</div></div>';
  const chartCard = (spec, index) => `<section class="card chart-card${spec.wide ? ' chart-card-wide' : ''}">
    <div class="card-header"><div class="card-title">${escapeHtml(spec.title)}</div></div>
    ${renderChartValueTags(spec, hostMetrics)}
    <div class="legend" id="legend-${index}"></div>
    <div class="chart-host" id="chart-${index}"></div>
  </section>`;
  if (currentSpecs.length === 0) {
    content += '<div class="empty-state card">No trend graphs match these filters.</div>';
  } else {
    for (const category of orderedCategories(currentSpecs)) {
      content += `<h3 class="chart-group">${category}</h3><div class="charts">`;
      currentSpecs.forEach((spec, index) => {
        if ((spec.category || 'Other') === category) content += chartCard(spec, index);
      });
      content += '</div>';
    }
  }
  content += '<div id="alerts-section"></div>';

  app.innerHTML = `<div class="dashboard-layout">${renderDashboardSidebar(metrics)}<div class="dashboard-content">${content}</div></div>`;

  app.querySelectorAll('[data-host]').forEach((button) => {
    button.addEventListener('click', () => {
      dashboardHostId = button.dataset.host ? Number(button.dataset.host) : null;
      syncDashboardUrl();
      renderDashboard(latestMetrics);
    });
  });
  app.querySelectorAll('[data-category]').forEach((button) => {
    button.addEventListener('click', () => {
      dashboardCategory = button.dataset.category;
      syncDashboardUrl();
      renderDashboard(latestMetrics);
    });
  });
  app.querySelectorAll('button.range').forEach((button) => {
    button.addEventListener('click', () => {
      rangeHours = Number(button.dataset.hours);
      app.querySelectorAll('button.range').forEach((item) => item.classList.toggle('active', item === button));
      syncDashboardUrl();
      drawCharts();
    });
  });

  drawCharts();
  refreshAlerts();

  // periodic refresh of history-backed charts + alerts (badges update live via SSE)
  clearInterval(chartTimer);
  chartTimer = setInterval(() => { drawCharts(); refreshAlerts(); }, 60000);
}

// ── Logs ───────────────────────────────────────────────────────────
const LOG_RANGES = [[1, '1h'], [6, '6h'], [24, '24h']];

function logSources() {
  return [...new Set(liveLogs.map((l) => l.source))].sort();
}

function srcName(source) {
  return source.split('/').pop();
}
function normalizedLogToken(value) {
  return String(value || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
}

function processLogMatches(log, processName = logProcess) {
  if (!processName) return true;
  const needle = normalizedLogToken(processName);
  return [log.source, log.target].some((value) => normalizedLogToken(value).includes(needle));
}


const LEVEL_RANK = { trace: 0, debug: 1, info: 2, warn: 3, error: 4 };

// Deterministic categorisation + level detection for known log formats.
function classifyLog(source, line) {
  const s = source.toLowerCase();
  let category = 'System';
  if (s.includes('nginx')) category = 'HTTP';
  else if (s.includes('auth')) category = 'Auth';
  else if (s.includes('mail') || s.includes('postfix') || s.includes('dovecot')) category = 'Mail';
  else if (s.includes('dns')) category = 'DNS';
  else if (s.includes('idp') || s.includes('monitor') || s.includes('akurai')) category = 'App';

  let level = 'info';

  // nginx access log: "...\"GET /path HTTP/1.1\" 503 ..."
  const http = line.match(/"\s*(?:GET|POST|PUT|DELETE|HEAD|PATCH|OPTIONS)\b[^"]*"\s+(\d{3})/);
  // Rust tracing / many app logs: a bare level token
  const tr = line.match(/\b(TRACE|DEBUG|INFO|WARN|ERROR)\b/);

  if (category === 'HTTP' && http) {
    const code = +http[1];
    category = 'HTTP';
    level = code >= 500 ? 'error' : code >= 400 ? 'warn' : 'info';
  } else if (tr) {
    const t = tr[1];
    level = t === 'ERROR' ? 'error' : t === 'WARN' ? 'warn' : t === 'TRACE' || t === 'DEBUG' ? 'debug' : 'info';
  } else if (/\b(fatal|panic|critical|segfault|err(?:or)?|failed|failure|denied|refused|reject)\b/i.test(line)) {
    level = 'error';
  } else if (/\b(warn(?:ing)?|deprecat\w*|timeout|timed out|retry|retrying|throttl)\b/i.test(line)) {
    level = 'warn';
  }

  // Auth-specific refinements
  if (category === 'Auth') {
    if (/Failed password|authentication failure|invalid user|Failed |Disconnect/i.test(line)) level = 'warn';
    if (/Accepted (?:password|publickey)/i.test(line)) level = 'info';
  }

  return { level, category };
}

function logCategories() {
  return [...new Set(liveLogs.map((l) => classifyLog(l.source, l.line).category))].sort();
}

function renderLogRows() {
  const tbody = document.getElementById('log-rows');
  const countEl = document.getElementById('log-count');
  if (!tbody) return;

  const minRank = logLevel === 'error' ? 4 : logLevel === 'warn' ? 3 : 0;

  const rows = liveLogs
    .map((l) => {
      const c = classifyLog(l.source, l.line);
      // First-class server-provided level wins over heuristic detection.
      return { ...l, category: c.category, level: l.level || c.level };
    })
    .filter(
      (l) =>
        !logExcluded.has(l.source) &&
        !logExcludedCats.has(l.category) &&
        LEVEL_RANK[l.level] >= minRank &&
        processLogMatches(l) &&
        (logSearch === '' || l.line.toLowerCase().includes(logSearch))
    );

  if (countEl) countEl.textContent = `${rows.length} line${rows.length === 1 ? '' : 's'}`;

  if (rows.length === 0) {
    tbody.innerHTML = '<tr><td colspan="4" class="empty">No matching log entries</td></tr>';
    return;
  }
  tbody.innerHTML = rows
    .map(
      (l) =>
        `<tr class="lvl-${l.level}${l.origin === 'client' ? ' origin-client' : ''}"><td class="t">${ts(l.ts)}</td><td class="lv"><span class="badge badge-${l.level === 'error' ? 'danger' : l.level === 'warn' ? 'warn' : l.level === 'info' ? 'ok' : 'muted'}">${l.level}</span></td><td class="s">${escapeHtml(srcName(l.source))}${l.origin === 'client' ? '<span class="cat">client</span>' : ''}</td><td class="line"><span class="cat">${escapeHtml(l.category)}</span>${l.target ? `<span class="cat">${escapeHtml(l.target)}</span>` : ''}${escapeHtml(l.line)}</td></tr>`
    )
    .join('');
}

function renderLogsLayout() {
  const sources = logSources();
  logSourceKey = sources.join('|');

  const counts = {};
  for (const l of liveLogs) counts[l.source] = (counts[l.source] || 0) + 1;

  const cats = logCategories();
  const catCounts = {};
  for (const l of liveLogs) {
    const c = classifyLog(l.source, l.line).category;
    catCounts[c] = (catCounts[c] || 0) + 1;
  }

  let sidebar = '<aside class="card log-filters">';
  if (logHostId != null || logProcess) {
    const scope = [logHostId != null ? hostName(logHostId) : '', logProcess ? logProcess.replaceAll('_', ' ') : ''].filter(Boolean).join(' · ');
    sidebar += `<div class="log-scope"><span>Scoped logs</span><strong>${escapeHtml(scope)}</strong><a href="#/logs">Clear filter</a></div>`;
  }
  sidebar += '<div class="fgroup"><label class="field-label">Range</label><div class="ranges">';
  for (const [h, lbl] of LOG_RANGES) {
    sidebar += `<button class="btn btn-secondary btn-sm range${h === logRangeHours ? ' active' : ''}" data-hours="${h}">${lbl}</button>`;
  }
  sidebar += '</div></div>';
  sidebar += '<div class="fgroup"><label class="field-label">Level</label><div class="ranges">';
  for (const [val, lbl] of [['all', 'All'], ['warn', 'Warn+'], ['error', 'Errors']]) {
    sidebar += `<button class="btn btn-secondary btn-sm range${val === logLevel ? ' active' : ''}" data-level="${val}">${lbl}</button>`;
  }
  sidebar += '</div></div>';
  sidebar += `<div class="fgroup"><label class="field-label" for="log-search">Search</label><input id="log-search" type="search" placeholder="Filter text…" value="${escapeHtml(logSearch)}"></div>`;
  if (cats.length > 0) {
    sidebar += '<div class="fgroup"><label class="field-label">Category</label><div class="sources">';
    for (const c of cats) {
      const checked = logExcludedCats.has(c) ? '' : 'checked';
      sidebar += `<label class="src"><input type="checkbox" data-cat="${escapeHtml(c)}" ${checked}><span>${escapeHtml(c)}</span><em>${catCounts[c] || 0}</em></label>`;
    }
    sidebar += '</div></div>';
  }
  sidebar += '<div class="fgroup"><label class="field-label">Sources</label><div class="sources">';
  if (sources.length === 0) {
    sidebar += '<span class="empty-mini">none yet</span>';
  } else {
    for (const s of sources) {
      const checked = logExcluded.has(s) ? '' : 'checked';
      sidebar += `<label class="src"><input type="checkbox" data-source="${escapeHtml(s)}" ${checked}><span>${escapeHtml(srcName(s))}</span><em>${counts[s] || 0}</em></label>`;
    }
  }
  sidebar += '</div></div></aside>';

  const content =
    '<div class="log-content">' +
    `<div class="log-head"><span class="conn">${connLabel()}</span><span id="log-count" class="log-count"></span></div>` +
    '<div class="log-scroll table-wrap"><table class="data-table logtable"><colgroup><col class="c-t"><col class="c-lv"><col class="c-s"><col class="c-l"></colgroup>' +
    '<thead><tr><th>Time</th><th>Level</th><th>Source</th><th>Line</th></tr></thead>' +
    '<tbody id="log-rows"></tbody></table></div></div>';

  app.innerHTML = `<div class="logs-layout">${sidebar}${content}</div>`;

  app.querySelectorAll('.log-filters button[data-hours]').forEach((btn) => {
    btn.addEventListener('click', () => {
      logRangeHours = Number(btn.dataset.hours);
      renderLogs();
    });
  });
  app.querySelectorAll('.log-filters button[data-level]').forEach((btn) => {
    btn.addEventListener('click', () => {
      logLevel = btn.dataset.level;
      app.querySelectorAll('.log-filters button[data-level]').forEach((b) => b.classList.toggle('active', b === btn));
      renderLogRows();
    });
  });
  const search = document.getElementById('log-search');
  if (search) {
    search.addEventListener('input', () => {
      logSearch = search.value.trim().toLowerCase();
      renderLogRows();
    });
  }
  app.querySelectorAll('.log-filters input[data-source]').forEach((cb) => {
    cb.addEventListener('change', () => {
      const src = cb.dataset.source;
      if (cb.checked) logExcluded.delete(src); else logExcluded.add(src);
      renderLogRows();
    });
  });
  app.querySelectorAll('.log-filters input[data-cat]').forEach((cb) => {
    cb.addEventListener('change', () => {
      const cat = cb.dataset.cat;
      if (cb.checked) logExcludedCats.delete(cat); else logExcludedCats.add(cat);
      renderLogRows();
    });
  });

  renderLogRows();
}

async function renderLogs() {
  await loadDashboardHosts();
  const params = new URLSearchParams({ hours: String(logRangeHours), limit: '300' });
  if (logHostId != null) params.set('host_id', String(logHostId));
  if (logProcess) params.set('process', logProcess);
  const data = await api(`/api/logs?${params}`);
  if (currentView !== 'logs') return;
  liveLogs = data.logs || []; // newest first
  renderLogsLayout();
}

function handleLogBatch(data) {
  if (currentView !== 'logs') return;
  const incoming = data.logs || [];
  if (incoming.length === 0) return;
  // incoming is chronological (oldest first); prepend newest-first
  liveLogs = incoming.slice().reverse().concat(liveLogs).slice(0, LOG_CAP);
  // rebuild the sidebar only if a new source appeared (keeps search focus otherwise)
  if (logSources().join('|') !== logSourceKey) renderLogsLayout();
  else renderLogRows();
}

// ── Alerts (full history view) ─────────────────────────────────────
async function renderAlerts() {
  const data = await api('/api/alerts?hours=72');
  if (currentView !== 'alerts') return;
  const alerts = data.alerts || [];

  let html = `<div class="section-heading"><div><h1>Alerts</h1><p>Last 72 hours · ${data.active || 0} active</p></div></div>`;
  if (alerts.length === 0) {
    html += '<div class="empty-state">No alerts</div>';
  } else {
    html += '<section class="card table-wrap"><table class="data-table"><tr><th>Status</th><th>Rule</th><th>Metric</th><th>Value</th><th>Triggered</th><th>Resolved</th></tr>';
    for (const a of alerts) {
      const cls = a.resolved_at ? 'alert-resolved' : 'alert-active';
      const status = a.resolved_at ? 'Resolved' : 'Active';
      html += `<tr class="${cls}"><td><span class="badge badge-${a.resolved_at ? 'ok' : 'danger'}">${status}</span></td><td>${escapeHtml(a.rule_name)}</td><td>${escapeHtml(a.metric_name)}</td><td>${a.value.toFixed(1)}</td><td>${ts(a.triggered_at)}</td><td>${a.resolved_at ? ts(a.resolved_at) : '-'}</td></tr>`;
    }
    html += '</table></section>';
  }
  app.innerHTML = html;
}

// ── Versions ───────────────────────────────────────────────────────
function compareSemver(left, right) {
  const a = String(left).split('.').map((part) => Number.parseInt(part, 10) || 0);
  const b = String(right).split('.').map((part) => Number.parseInt(part, 10) || 0);
  for (let index = 0; index < Math.max(a.length, b.length); index++) {
    if ((a[index] || 0) !== (b[index] || 0)) return (a[index] || 0) < (b[index] || 0) ? -1 : 1;
  }
  return 0;
}

function reportedAge(epoch) {
  if (!epoch) return 'Never';
  const seconds = Math.max(0, Math.floor(Date.now() / 1000 - epoch));
  if (seconds < 60) return 'Just now';
  if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
  if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
  return `${Math.floor(seconds / 86400)}d ago`;
}

async function renderVersions() {
  app.innerHTML = '<div class="empty-state">Loading version inventory…</div>';
  try {
    const [applicationData, status] = await Promise.all([manageApi('/api/manage/applications'), api('/api/status'), loadDashboardHosts()]);
    if (currentView !== 'versions') return;
    const applications = applicationData.items || applicationData.applications || [];
    const latest = status.framework_latest;
    const stateOf = (item) => item.framework_version
      ? (compareSemver(item.framework_version, latest) < 0 ? 'outdated' : 'current')
      : (item.versions_reported_at ? 'na' : 'unknown');
    const states = applications.map(stateOf);
    const label = { current: 'current', outdated: 'outdated', na: 'n/a', unknown: 'unknown' };
    const badge = { current: 'ok', outdated: 'warn', na: 'muted', unknown: 'muted' };
    let html = `<div class="section-heading"><div><h1>Framework versions</h1><p>Monitor baseline: ${escapeHtml(latest || 'unknown')}</p></div></div><div class="stats-grid">`;
    html += `<div class="stat-card"><div class="stat-value">${applications.length}</div><div class="stat-label">Total apps</div></div>`;
    html += `<div class="stat-card"><div class="stat-value version-current">${states.filter((state) => state === 'current').length}</div><div class="stat-label">On latest</div></div>`;
    html += `<div class="stat-card"><div class="stat-value version-outdated">${states.filter((state) => state === 'outdated').length}</div><div class="stat-label">Outdated</div></div></div>`;
    html += '<section class="card table-wrap"><div class="card-header"><div class="card-title">Application inventory</div></div><table class="data-table"><thead><tr><th>Application</th><th>Host</th><th>App version</th><th>Framework</th><th>Framework version</th><th>Reported</th></tr></thead><tbody>';
    html += applications.map((item) => {
      const state = stateOf(item);
      return `<tr><td>${escapeHtml(item.name || item.application || 'Unknown')}</td><td>${escapeHtml(hostNames?.get(item.host_id) || `Host ${item.host_id}`)}</td><td>${escapeHtml(item.app_version || '—')}</td><td>${escapeHtml(item.framework || '—')}</td><td><span class="badge badge-${badge[state]}">${label[state]}</span> ${escapeHtml(item.framework_version || '—')}</td><td>${reportedAge(item.versions_reported_at)}</td></tr>`;
    }).join('') || '<tr><td colspan="6" class="empty-state">No applications have reported versions.</td></tr>';
    app.innerHTML = html + '</tbody></table></section>';
  } catch (error) {
    if (currentView === 'versions') app.innerHTML = `<div class="empty-state error-text">${escapeHtml(error.message)}</div>`;
  }
}

// ── Receivers ──────────────────────────────────────────────────────
async function renderReceivers() {
  app.innerHTML = '<div class="empty">Loading receiver settings…</div>';
  try {
    const [channelData, recipientData] = await Promise.all([
      manageApi('/api/manage/channels'),
      manageApi('/api/manage/recipients'),
    ]);
    if (currentView !== 'receivers') return;
    const channels = channelData.items || [];
    const recipients = recipientData.items || [];
    const channelRows = channels.map((channel) => `<div class="receiver-row">
        <div><strong>${escapeHtml(channel.name)}</strong><span>${escapeHtml(channel.kind)} · ${escapeHtml(channel.endpoint)}</span></div>
        <span class="badge badge-${channel.enabled ? 'ok' : 'warn'}">${channel.enabled ? 'Enabled' : 'Paused'}</span>
        <div class="receiver-actions"><button class="btn btn-secondary btn-sm" data-test-channel="${channel.id}">Send test</button></div>
      </div>`).join('');
    const recipientRows = recipients.map((recipient) => {
      const channel = channels.find((item) => item.id === recipient.channel_id);
      return `<div class="receiver-row">
        <div><strong>${escapeHtml(recipient.name)}</strong><span>${escapeHtml(channel?.name || 'Unknown')} · ${escapeHtml(recipient.target)}</span></div>
        <span class="badge badge-${recipient.enabled ? 'ok' : 'warn'}">${recipient.enabled ? 'Enabled' : 'Paused'}</span>
        <div class="receiver-actions">
          <button class="btn btn-secondary btn-sm" data-toggle="${recipient.id}" data-enabled="${recipient.enabled ? 0 : 1}">${recipient.enabled ? 'Pause' : 'Enable'}</button>
          <button class="btn btn-ghost btn-sm danger" data-remove="${recipient.id}" data-name="${escapeHtml(recipient.name)}">Remove</button>
        </div>
      </div>`;
    }).join('');
    const channelOptions = channels.map((channel) => `<option value="${channel.id}">${escapeHtml(channel.name)}</option>`).join('');
    app.innerHTML = `<section class="receivers-page">
      <div class="settings-heading"><div><h1>Receiver settings</h1><p>Choose where Monitor sends triggered, resolved, and test alerts. Channels are HTTPS webhooks, created with <code>akurai-monitorctl channels create</code>.</p></div></div>
      <div class="card receiver-list"><div class="card-header"><div class="card-title">Channels <span>${channels.length}</span></div></div>${channelRows || '<div class="empty-state">No channels configured</div>'}</div>
      <div class="card receiver-card">
        <div class="card-header"><div class="card-title">Add receiver</div></div>
        ${channels.length ? `<form class="receiver-form" id="add-receiver">
          <label>Channel<select name="channel_id">${channelOptions}</select></label>
          <label>Name<input name="name" required maxlength="191" placeholder="on-call"></label>
          <label>Target<input name="target" required maxlength="255" placeholder="Passed to the webhook as recipient.target"></label>
          <button class="btn btn-primary" type="submit">Add receiver</button>
        </form>` : '<div class="empty-state">Create a channel first</div>'}
      </div>
      <div class="card receiver-list"><div class="card-header"><div class="card-title">Configured receivers <span>${recipients.length}</span></div></div>${recipientRows || '<div class="empty-state">No receivers configured</div>'}</div>
    </section>`;

    $('#add-receiver')?.addEventListener('submit', async (event) => {
      event.preventDefault();
      const form = new FormData(event.currentTarget);
      const name = String(form.get('name')).trim();
      try {
        await manageApi('/api/manage/recipients', {
          method: 'POST',
          body: JSON.stringify({ channel_id: Number(form.get('channel_id')), name, target: String(form.get('target')).trim(), enabled: 1 }),
        });
        showToast(`Receiver ${name} added`);
        await renderReceivers();
      } catch (error) {
        showToast(error.message, 'error');
      }
    });
    app.querySelectorAll('[data-test-channel]').forEach((button) => button.addEventListener('click', async () => {
      try {
        const result = await manageApi(`/api/manage/channels/${button.dataset.testChannel}/test`, { method: 'POST' });
        showToast(`Test sent to ${result.sent} receiver${result.sent === 1 ? '' : 's'}`);
      } catch (error) {
        showToast(error.message, 'error');
      }
    }));
    app.querySelectorAll('[data-toggle]').forEach((button) => button.addEventListener('click', async () => {
      try {
        await manageApi(`/api/manage/recipients/${button.dataset.toggle}`, {
          method: 'PUT',
          body: JSON.stringify({ enabled: Number(button.dataset.enabled) }),
        });
        showToast(Number(button.dataset.enabled) ? 'Receiver enabled' : 'Receiver paused');
        await renderReceivers();
      } catch (error) {
        showToast(error.message, 'error');
      }
    }));
    app.querySelectorAll('[data-remove]').forEach((button) => button.addEventListener('click', async () => {
      const confirmed = await confirmAction('Remove receiver?', `${button.dataset.name} will stop receiving alerts.`, 'Remove');
      if (!confirmed) return;
      try {
        await manageApi(`/api/manage/recipients/${button.dataset.remove}`, { method: 'DELETE' });
        showToast('Receiver removed');
        await renderReceivers();
      } catch (error) {
        showToast(error.message, 'error');
      }
    }));
  } catch (error) {
    if (currentView === 'receivers') app.innerHTML = `<div class="empty error-text">${escapeHtml(error.message)}</div>`;
  }
}

function escapeHtml(value) {
  return String(value).replace(/[&<>"']/g, (character) => ({
    '&': '&amp;',
    '<': '&lt;',
    '>': '&gt;',
    '"': '&quot;',
    "'": '&#39;',
  })[character]);
}

// ── Live connection (SSE with polling fallback) ────────────────────
function handleStatus(data) {
  const incoming = data.metrics || [];
  if (incoming.every((metric) => metric.host_id != null)) {
    updateBadges(incoming);
    return;
  }
  // Fast SSE snapshots are local-only and omit attribution. Merge them into
  // the latest full status instead of replacing Titan and historical metadata.
  if (latestMetrics.length === 0) return;
  const localHostId = dashboardHosts?.find((host) => host.address === '127.0.0.1')?.id
    ?? latestMetrics.find((metric) => metric.host_id != null)?.host_id;
  if (localHostId == null) return;
  const updates = new Map(incoming.map((metric) => {
    const normalized = { ...metric, host_id: localHostId, ts: metric.ts || data.ts };
    return [metricKey(normalized), normalized];
  }));
  const merged = latestMetrics.map((metric) => updates.get(metricKey(metric)) || metric);
  const existing = new Set(latestMetrics.map(metricKey));
  for (const [key, metric] of updates) {
    if (!existing.has(key)) merged.push(metric);
  }
  updateBadges(merged);
}

let pollTimer = null;
function startPolling() {
  if (pollTimer) return;
  connState = 'polling';
  updateConnUI();
  pollTimer = setInterval(async () => {
    try {
      const s = await api('/api/status');
      handleStatus(s);
      if (currentView === 'logs') await renderLogs();
    } catch { /* ignore */ }
  }, 10000);
}

function startLive() {
  if (typeof EventSource === 'undefined') { startPolling(); return; }
  let opened = false;
  const es = new EventSource('/api/stream');
  es.onopen = () => { opened = true; connState = 'live'; updateConnUI(); };
  es.addEventListener('status', (e) => { try { handleStatus(JSON.parse(e.data)); } catch {} });
  es.addEventListener('log', (e) => { try { handleLogBatch(JSON.parse(e.data)); } catch {} });
  es.onerror = () => {
    if (!opened) { es.close(); startPolling(); } // stream blocked → fall back
    else { connState = 'connecting'; updateConnUI(); } // transient: let it auto-reconnect
  };
}

// ── Routing ────────────────────────────────────────────────────────
function readDashboardFilters(hash) {
  const params = new URLSearchParams(hash.split('?')[1] || '');
  const host = params.get('host');
  dashboardHostId = host && /^\d+$/.test(host) ? Number(host) : null;
  const category = params.get('category');
  dashboardCategory = DASHBOARD_CATEGORIES.includes(category) ? category : 'All';
  const hours = Number(params.get('hours'));
  rangeHours = RANGES.some(([value]) => value === hours) ? hours : 24;
}
function readLogFilters(hash) {
  const params = new URLSearchParams(hash.split('?')[1] || '');
  const host = params.get('host');
  logHostId = host && /^\d+$/.test(host) ? Number(host) : null;
  logProcess = (params.get('process') || '').trim();
  const hours = Number(params.get('hours'));
  logRangeHours = LOG_RANGES.some(([value]) => value === hours) ? hours : 1;
  logExcluded.clear();
  logExcludedCats.clear();
}


function syncDashboardUrl() {
  if (currentView !== 'dashboard') return;
  const params = new URLSearchParams();
  if (dashboardHostId != null) params.set('host', String(dashboardHostId));
  if (dashboardCategory !== 'All') params.set('category', dashboardCategory);
  if (rangeHours !== 24) params.set('hours', String(rangeHours));
  history.replaceState(null, '', `#/${params.size ? `?${params}` : ''}`);
}

function route() {
  const hash = location.hash || '#/';
  const routePath = hash.split('?')[0];
  document.querySelectorAll('nav a').forEach((a) => {
    a.classList.toggle('active', a.getAttribute('href') === routePath);
  });

  clearInterval(chartTimer);
  chartController?.abort();
  chartController = null;


  if (hash.startsWith('#/logs')) {
    readLogFilters(hash);
    currentView = 'logs';
    renderLogs();
  } else if (hash.startsWith('#/alerts')) {
    currentView = 'alerts';
    renderAlerts();
  } else if (hash.startsWith('#/versions')) {
    currentView = 'versions';
    renderVersions();
  } else if (hash.startsWith('#/receivers')) {
    currentView = 'receivers';
    renderReceivers();
  } else {
    readDashboardFilters(hash);
    currentView = 'dashboard';
    renderDashboard();
  }
}

window.addEventListener('hashchange', route);
let resizeTimer;
window.addEventListener('resize', () => {
  clearTimeout(resizeTimer);
  resizeTimer = setTimeout(() => { if (currentView === 'dashboard') drawCharts(); }, 200);
});

route();
startLive();