Files
marcredhat-siem-toolkit-pat…/frontend/index.html
T

2198 lines
118 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SIEM Toolkit</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-slate-950 text-gray-100 h-screen flex overflow-hidden font-sans">
<aside class="w-56 shrink-0 bg-gradient-to-b from-slate-900 to-gray-900 border-r border-white/5 flex flex-col">
<div class="p-4 border-b border-white/5">
<div class="flex items-center gap-2">
<div class="w-6 h-6 rounded bg-gradient-to-br from-purple-500 to-purple-700 flex items-center justify-center text-xs font-bold text-white shadow-lg shadow-purple-900/40">S1</div>
<span class="font-semibold text-sm text-white tracking-tight">SIEM Toolkit</span>
</div>
<p class="text-xs text-slate-500 mt-1">demo.sentinelone.net</p>
</div>
<nav class="flex-1 p-3 space-y-0.5">
<a href="#/" data-page="home" class="nav-link flex items-center px-3 py-2 rounded-lg text-sm cursor-pointer">Overview</a>
<a href="#/coverage" data-page="coverage" class="nav-link flex items-center px-3 py-2 rounded-lg text-sm cursor-pointer">Parser Coverage</a>
<a href="#/ingest" data-page="ingest" class="nav-link flex items-center px-3 py-2 rounded-lg text-sm cursor-pointer">Ingest Dashboard</a>
<a href="#/quality" data-page="quality" class="nav-link flex items-center px-3 py-2 rounded-lg text-sm cursor-pointer">Parser Quality</a>
<a href="#/onboarding" data-page="onboarding" class="nav-link flex items-center px-3 py-2 rounded-lg text-sm cursor-pointer">Onboarding</a>
<a href="#/query" data-page="query" class="nav-link flex items-center gap-2 px-3 py-2 rounded-lg text-sm cursor-pointer">
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" rx="2"/><path stroke-linecap="round" stroke-linejoin="round" d="M8 9l3 3-3 3M13 15h3"/></svg>
Query
</a>
<a href="#/threat" data-page="threat" class="nav-link flex items-center px-3 py-2 rounded-lg text-sm cursor-pointer">Threat Coverage</a>
</nav>
<div class="p-3 border-t border-white/5">
<a href="#/settings" data-page="settings" class="nav-link flex items-center gap-2 px-3 py-2 rounded-lg text-sm cursor-pointer text-slate-400 hover:bg-white/5 hover:text-gray-100 transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><circle cx="12" cy="12" r="3"/></svg>
Settings
</a>
</div>
</aside>
<main class="flex-1 overflow-auto bg-gradient-to-br from-slate-950 to-gray-900" id="main"></main>
<script>
const API = 'http://localhost:8001'
// ── Utilities ──────────────────────────────────────────────────────────────
async function apiFetch(path, opts = {}) {
const res = await fetch(API + path, opts)
if (!res.ok) throw new Error(await res.text())
return res.json()
}
const apiGet = path => apiFetch(path)
const apiPost = (path, body) => apiFetch(path, { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body) })
const apiForm = (path, form) => apiFetch(path, { method:'POST', body:form })
function esc(s) { return String(s ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;') }
function statCard(label, value, color = 'text-gray-200', sub = '') {
return `<div class="bg-gradient-to-b from-gray-900 to-gray-950 ring-1 ring-white/5 rounded-xl p-4 text-center shadow-sm">
<div class="text-2xl font-bold ${color}">${esc(String(value))}</div>
<div class="text-xs text-slate-500 mt-1">${esc(label)}${sub ? `<span class="ml-1 text-amber-600">(${esc(sub)})</span>` : ''}</div>
</div>`
}
function errBox(msg) {
return msg ? `<div class="p-3 bg-red-950/60 ring-1 ring-red-700/50 rounded-xl text-sm text-red-300 mb-4">${esc(msg)}</div>` : ''
}
function setBtn(id, loading, label) {
const b = document.getElementById(id)
if (!b) return
b.disabled = loading
b.textContent = loading ? 'Loading…' : label
}
// ── Simple SVG bar chart ───────────────────────────────────────────────────
function barChart(rows, labelKey, valueKey) {
if (!rows?.length) return '<p class="text-gray-600 text-sm h-32 flex items-center justify-center">No data</p>'
const vals = rows.map(r => r[valueKey] || 0)
const max = Math.max(...vals, 1)
const W = 680, H = 160, padL = 52, padR = 10, padT = 24, padB = 28
const chartW = W - padL - padR
const chartH = H - padT - padB
const bw = Math.max(8, Math.floor(chartW / rows.length) - 4)
const defs = `<defs>
<linearGradient id="barGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#c084fc"/>
<stop offset="100%" stop-color="#6d28d9"/>
</linearGradient>
</defs>`
// Y-axis ticks (4 lines)
const ticks = [0, 0.25, 0.5, 0.75, 1].map(t => {
const val = Math.round(max * t)
const y = padT + chartH - Math.floor(t * chartH)
const label = val >= 1000 ? (val/1000).toFixed(1)+'k' : val
return `<line x1="${padL}" y1="${y}" x2="${W - padR}" y2="${y}" stroke="#1f2937" stroke-width="1"/>
<text x="${padL - 4}" y="${y + 4}" text-anchor="end" fill="#4b5563" font-size="9">${label}</text>`
}).join('')
const bars = rows.map((r, i) => {
const val = r[valueKey] || 0
const bh = Math.max(2, Math.floor((val / max) * chartH))
const x = padL + i * (chartW / rows.length) + (chartW / rows.length - bw) / 2
const y = padT + chartH - bh
// If label looks like a date (YYYY-MM-DD), show MM/DD; otherwise truncate to 10 chars
const rawLbl = String(r[labelKey] || '')
const lbl = esc(/^\d{4}-\d{2}-\d{2}$/.test(rawLbl) ? rawLbl.slice(5, 10) : rawLbl.slice(0, 10))
// value label on top of bar
const valLbl = val >= 1000 ? (val/1000).toFixed(1)+'k' : val
return `<rect x="${x}" y="${y}" width="${bw}" height="${bh}" fill="url(#barGrad)" rx="2"/>
<text x="${x + bw/2}" y="${y - 4}" text-anchor="middle" fill="#c084fc" font-size="9" font-weight="500">${valLbl}</text>
<text x="${x + bw/2}" y="${H - 6}" text-anchor="middle" fill="#6b7280" font-size="9">${lbl}</text>`
}).join('')
return `<svg viewBox="0 0 ${W} ${H}" class="w-full">${defs}${ticks}${bars}</svg>`
}
// ── Home ──────────────────────────────────────────────────────────────────
function renderHome() {
set(`<div class="p-8 max-w-5xl">
<div class="mb-6">
<h1 class="text-2xl font-extrabold text-white tracking-tight">SIEM Engineering Toolkit</h1>
<p class="text-slate-400 mt-1 text-sm">SentinelOne AI-SIEM · demo.sentinelone.net</p>
</div>
<!-- Health Score Card -->
<div id="health-score-card" class="mb-8">
<div class="bg-gradient-to-b from-gray-900 to-gray-950 ring-1 ring-white/5 rounded-xl p-6 shadow-sm border-t-2 border-t-purple-600">
<div class="flex flex-col md:flex-row md:items-center gap-6">
<div class="text-center md:text-left">
<div class="text-xs text-slate-500 uppercase tracking-widest mb-1 font-medium">Tenant Health Score</div>
<div id="health-score-value" class="text-6xl font-black text-slate-600 leading-none">—</div>
<div id="health-score-delta" class="text-sm mt-2 text-slate-600"></div>
</div>
<div class="flex-1 grid grid-cols-3 gap-3" id="health-components">
<div class="bg-slate-800/50 rounded-lg p-3 text-center animate-pulse"><div class="h-4 bg-slate-700 rounded mb-1"></div><div class="h-3 bg-slate-700/60 rounded"></div></div>
<div class="bg-slate-800/50 rounded-lg p-3 text-center animate-pulse"><div class="h-4 bg-slate-700 rounded mb-1"></div><div class="h-3 bg-slate-700/60 rounded"></div></div>
<div class="bg-slate-800/50 rounded-lg p-3 text-center animate-pulse"><div class="h-4 bg-slate-700 rounded mb-1"></div><div class="h-3 bg-slate-700/60 rounded"></div></div>
</div>
<div class="flex flex-col items-center gap-1">
<div class="text-xs text-slate-600 uppercase tracking-wide mb-1">30-day trend</div>
<div id="health-sparkline"><svg viewBox="0 0 200 40" class="w-48 h-10"><line x1="0" y1="20" x2="200" y2="20" stroke="#374151" stroke-width="1" stroke-dasharray="4"/></svg></div>
</div>
</div>
</div>
</div>
<div id="home-stats" class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
<div class="bg-gradient-to-b from-gray-900 to-gray-950 ring-1 ring-white/5 rounded-xl p-4 text-center animate-pulse">
<div class="h-7 w-16 bg-gray-800/80 rounded mx-auto mb-1"></div>
<div class="h-3 w-20 bg-gray-800/60 rounded mx-auto"></div>
</div>
<div class="bg-gradient-to-b from-gray-900 to-gray-950 ring-1 ring-white/5 rounded-xl p-4 text-center animate-pulse">
<div class="h-7 w-16 bg-gray-800/80 rounded mx-auto mb-1"></div>
<div class="h-3 w-20 bg-gray-800/60 rounded mx-auto"></div>
</div>
<div class="bg-gradient-to-b from-gray-900 to-gray-950 ring-1 ring-white/5 rounded-xl p-4 text-center animate-pulse">
<div class="h-7 w-16 bg-gray-800/80 rounded mx-auto mb-1"></div>
<div class="h-3 w-20 bg-gray-800/60 rounded mx-auto"></div>
</div>
<div class="bg-gradient-to-b from-gray-900 to-gray-950 ring-1 ring-white/5 rounded-xl p-4 text-center animate-pulse">
<div class="h-7 w-16 bg-gray-800/80 rounded mx-auto mb-1"></div>
<div class="h-3 w-20 bg-gray-800/60 rounded mx-auto"></div>
</div>
</div>
<div id="home-uncovered" class="hidden mb-8"></div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
${homeCard('#/coverage','Parser Coverage Map','See which active data sources have a parser running and which need one.','Open Coverage Map','from-purple-700 to-purple-900')}
${homeCard('#/ingest','Ingest Dashboard','Visualize event volume by source and type. Simulate exclusion filters before applying them.','Open Dashboard','from-blue-700 to-blue-900')}
${homeCard('#/quality','Parser Quality','Sample live events, measure field population rates, and test parser patterns against raw log lines.','Open Quality Tools','from-amber-700 to-amber-900')}
${homeCard('#/onboarding','Onboarding Accelerator','Step-by-step guide for onboarding a new log source using Claude Code directly.','View Guide','from-emerald-700 to-emerald-900')}
</div>
</div>`)
homeLoadStats()
loadHealthScore()
}
const EXCLUDED_SOURCES = new Set([
'SentinelOne','asset','alert','vulnerability','ActivityFeed',
'indicator','misconfiguration','SentinelOne Ranger AD'
])
async function homeLoadStats() {
try {
const r = await apiGet('/api/coverage/map')
const sources = (r.sources || []).filter(s => !(EXCLUDED_SOURCES.has(s.source_name) && !s.unlabelled))
const total = sources.length
const covered = sources.filter(s => s.status === 'covered').length
const needed = sources.filter(s => s.status === 'parser_needed').length
const pct = total ? Math.round(covered / total * 100) : 0
const pctColor = pct >= 80 ? 'text-emerald-400' : pct >= 50 ? 'text-amber-400' : 'text-red-400'
document.getElementById('home-stats').innerHTML = `
${homeStat(pct + '%', 'Parser Coverage', pctColor)}
${homeStat(total.toLocaleString(), 'Active Sources', 'text-blue-400')}
${homeStat(covered.toLocaleString(), 'Covered', 'text-emerald-400')}
${homeStat(needed.toLocaleString(), 'Need Parser', needed > 0 ? 'text-red-400' : 'text-gray-500')}`
// Top uncovered sources by volume
const uncovered = sources
.filter(s => s.status === 'parser_needed')
.sort((a, b) => (b.event_count || 0) - (a.event_count || 0))
.slice(0, 5)
if (uncovered.length) {
const rows = uncovered.map((s, i) => `
<tr class="${i % 2 === 1 ? 'bg-white/[0.02]' : ''} border-b border-white/5 hover:bg-white/[0.04] transition-colors">
<td class="py-2 pr-4 font-mono text-xs text-gray-200">
<a href="#/quality" onclick="queueQualitySource('${esc(s.source_name)}')" class="hover:text-purple-400 cursor-pointer transition-colors">${esc(s.source_name)}</a>
</td>
<td class="py-2 text-xs text-slate-400">${(s.event_count || 0).toLocaleString()} events</td>
</tr>`).join('')
document.getElementById('home-uncovered').classList.remove('hidden')
document.getElementById('home-uncovered').innerHTML = `
<div class="bg-gradient-to-b from-gray-900 to-gray-950 ring-1 ring-red-900/30 rounded-xl p-5 border-t-2 border-t-red-600/40">
<h2 class="text-sm font-semibold text-white mb-1">Top Sources Needing a Parser</h2>
<p class="text-xs text-slate-500 mb-3">Highest-volume sources with no parser running — click to inspect in Parser Quality.</p>
<table class="w-full">
<thead><tr class="text-left text-slate-500 border-b border-white/5">
<th class="pb-2 pr-4 text-xs font-medium">Source</th>
<th class="pb-2 text-xs font-medium">Volume</th>
</tr></thead>
<tbody>${rows}</tbody>
</table>
</div>`
}
} catch(e) {
document.getElementById('home-stats').innerHTML = `
${homeStat('—', 'Parser Coverage', 'text-gray-600')}
${homeStat('—', 'Active Sources', 'text-gray-600')}
${homeStat('—', 'Covered', 'text-gray-600')}
${homeStat('—', 'Need Parser', 'text-gray-600')}`
}
}
function homeStat(value, label, valueClass) {
const accent = valueClass.includes('emerald') ? 'border-t-2 border-t-emerald-500'
: valueClass.includes('red') ? 'border-t-2 border-t-red-500'
: valueClass.includes('purple') ? 'border-t-2 border-t-purple-500'
: valueClass.includes('blue') ? 'border-t-2 border-t-blue-500'
: 'border-t-2 border-t-slate-700'
return `<div class="bg-gradient-to-b from-gray-900 to-gray-950 ring-1 ring-white/5 rounded-xl p-4 text-center ${accent} shadow-sm">
<div class="text-2xl font-bold ${valueClass} mb-1">${value}</div>
<div class="text-xs text-slate-500">${label}</div>
</div>`
}
function homeCard(href, title, desc, cta, grad) {
return `<div class="bg-gradient-to-b from-gray-900 to-gray-950 ring-1 ring-white/5 rounded-xl p-6 flex flex-col gap-4 hover:ring-white/10 transition-all">
<div class="w-10 h-10 rounded-lg bg-gradient-to-br ${grad} shadow-lg"></div>
<div><h2 class="font-semibold text-white">${esc(title)}</h2>
<p class="text-sm text-slate-400 mt-1 leading-relaxed">${esc(desc)}</p></div>
<a href="${href}" class="mt-auto text-sm text-purple-400 hover:text-purple-300 font-medium transition-colors">${esc(cta)} →</a>
</div>`
}
async function loadHealthScore() {
try {
const [h, snaps] = await Promise.all([
apiGet('/api/coverage/health'),
apiGet('/api/coverage/snapshots?limit=30'),
])
const score = h.health_score ?? 0
const scoreColor = score >= 80 ? 'text-emerald-400' : score >= 60 ? 'text-amber-400' : 'text-red-400'
const scoreEl = document.getElementById('health-score-value')
if (scoreEl) {
scoreEl.textContent = score
scoreEl.className = `text-6xl font-black leading-none ${scoreColor}`
}
const deltaEl = document.getElementById('health-score-delta')
if (deltaEl && h.delta_from_previous != null) {
const d = h.delta_from_previous
const dColor = d >= 0 ? 'text-emerald-400' : 'text-red-400'
const dSign = d >= 0 ? '+' : ''
deltaEl.innerHTML = `<span class="${dColor} font-semibold">${dSign}${d}</span> <span class="text-slate-600">from last sync</span>`
} else if (deltaEl) {
deltaEl.innerHTML = '<span class="text-slate-600 text-xs">No previous snapshot</span>'
}
const compEl = document.getElementById('health-components')
if (compEl && h.components) {
const comps = h.components
const compCard = (label, value, weight) => {
const v = value ?? null
const pct = v != null ? v.toFixed(1) : '—'
const pctColor = v == null ? 'text-slate-500' : v >= 80 ? 'text-emerald-400' : v >= 60 ? 'text-amber-400' : 'text-red-400'
return `<div class="bg-slate-800/50 rounded-lg p-3 text-center">
<div class="text-xl font-bold ${pctColor}">${pct}${v != null ? '%' : ''}</div>
<div class="text-xs text-slate-500 mt-1">${esc(label)}</div>
<div class="text-xs text-slate-700 mt-0.5">${(weight * 100).toFixed(0)}% weight</div>
</div>`
}
compEl.innerHTML =
compCard('Parser Coverage', comps.parser_coverage.value, comps.parser_coverage.weight) +
compCard('MITRE Coverage', comps.mitre_coverage.value, comps.mitre_coverage.weight) +
compCard('Rule Firing Rate', comps.rule_firing.value, comps.rule_firing.weight)
}
// Sparkline SVG
const spEl = document.getElementById('health-sparkline')
if (spEl && snaps.snapshots?.length >= 2) {
const pts = snaps.snapshots.map(s => s.health_score)
const W = 200, H = 40, pad = 2
const minV = 0, maxV = 100
const xs = pts.map((_, i) => pad + i * (W - 2 * pad) / (pts.length - 1))
const ys = pts.map(v => H - pad - ((v - minV) / (maxV - minV)) * (H - 2 * pad))
const points = xs.map((x, i) => `${x.toFixed(1)},${ys[i].toFixed(1)}`).join(' ')
spEl.innerHTML = `<svg viewBox="0 0 ${W} ${H}" class="w-48 h-10">
<polyline points="${points}" fill="none" stroke="rgb(139,92,246)" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round"/>
</svg>`
}
} catch(e) {
// Health score load failure is non-critical — leave placeholder visible
}
}
// Queue a source to be pre-selected when Quality page loads
let _pendingQualitySource = null
function queueQualitySource(source) {
_pendingQualitySource = source
}
// ── Coverage ──────────────────────────────────────────────────────────────
let cvFilter = 'all', cvData = null
function renderCoverage() {
set(`<div class="p-8 max-w-6xl">
<div class="flex items-start justify-between mb-2">
<h1 class="text-xl font-extrabold text-white tracking-tight">Parser Coverage Map</h1>
<div class="flex gap-2 flex-wrap justify-end">
<select id="sync-days" class="bg-slate-800/80 border border-white/10 rounded-lg px-2 py-1.5 text-sm text-gray-300 focus:outline-none focus:border-purple-500 transition-colors">
<option value="1" selected>1d</option>
<option value="3">3d</option>
<option value="7">7d</option>
</select>
<button id="btn-sync-all" onclick="cvSyncAll()" class="px-3 py-1.5 text-sm bg-emerald-700 hover:bg-emerald-600 rounded-lg text-white font-medium transition-colors shadow-sm">Sync All</button>
<button id="btn-sync" onclick="cvSyncSources()" class="px-3 py-1.5 text-sm bg-blue-700 hover:bg-blue-600 rounded-lg text-white font-medium transition-colors shadow-sm">Sync Live Sources</button>
<button id="btn-sync-library" onclick="syncLibrary()" class="px-3 py-1.5 text-sm bg-blue-700 hover:bg-blue-600 rounded-lg text-white font-medium transition-colors shadow-sm">Sync Detection Library</button>
<button id="btn-sdl-parsers" onclick="loadSDLParsers()" class="px-3 py-1.5 text-sm bg-purple-700 hover:bg-purple-600 rounded-lg text-white font-medium transition-colors shadow-sm">Sync SDL Parsers</button>
<button onclick="document.getElementById('f-parser').click()" class="px-3 py-1.5 text-sm bg-slate-700 hover:bg-slate-600 rounded-lg text-white font-medium transition-colors shadow-sm">Upload Parser</button>
<button onclick="cvReset()" class="px-3 py-1.5 text-sm bg-red-950/60 hover:bg-red-900/80 ring-1 ring-red-800/50 rounded-lg text-red-400 font-medium transition-colors">Reset</button>
</div>
</div>
<p class="text-sm text-slate-400 mb-6">Click <strong class="text-slate-300">Sync Live Sources</strong> to pull current dataSource.names from the data lake, then <strong class="text-slate-300">Sync SDL Parsers</strong> to fetch parsers from the console and see coverage. Requires <strong class="text-slate-300">SDL_CONFIG_READ_KEY</strong> in Settings (needs "Manage config files" permission).</p>
<input type="file" id="f-parser" accept=".json" class="hidden" onchange="cvUploadParser(this.files[0])">
<div id="cv-err"></div>
<div id="cv-stats"></div>
<div id="cv-filters" class="flex gap-2 mb-4 hidden"></div>
<div id="cv-table"></div>
</div>`)
cvLoad()
}
async function syncLibrary() {
setBtn('btn-sync-library', true)
const errEl = document.getElementById('cv-err')
if (errEl) errEl.innerHTML = ''
try {
const r = await apiPost('/api/coverage/load-detections', {})
if (errEl) {
errEl.innerHTML = `<div class="p-3 bg-emerald-950/60 ring-1 ring-emerald-700/50 rounded-xl text-sm text-emerald-300 mb-4">✓ ${r.loaded} detection rules synced from ${r.source === 'api' ? 'S1 API' : 'local file'}</div>`
setTimeout(() => { errEl.innerHTML = '' }, 4000)
}
cvLoad()
} catch(e) {
if (errEl) errEl.innerHTML = errBox(e.message)
} finally { setBtn('btn-sync-library', false, 'Sync Detection Library') }
}
async function loadSDLParsers() {
setBtn('btn-sdl-parsers', true)
const errEl = document.getElementById('cv-err')
if (errEl) errEl.innerHTML = ''
try {
const res = await apiPost('/api/coverage/load-parsers-from-sdl', {})
const cf = res.console_fetch || {}
const dbg = cf.debug || {}
let parts = []
if (cf.fetched > 0) {
parts.push(`fetched ${cf.fetched} from console`)
} else if (cf.skipped) {
parts.push(`console sync skipped (${cf.skipped})`)
}
parts.push(`${res.loaded} parser${res.loaded !== 1 ? 's' : ''} loaded`)
if (cf.failed?.length) parts.push(`${cf.failed.length} console fetch errors`)
const msg = `${parts.join(' · ')}`
const hasErrors = res.errors?.length || cf.failed?.length
// Build debug detail block showing what SDL returned
const pathsListed = dbg.paths_listed || []
const debugHtml = pathsListed.length > 0 ? `
<details class="mt-2">
<summary class="text-xs text-gray-400 cursor-pointer hover:text-gray-200">
SDL returned ${dbg.paths_found ?? pathsListed.length} path${pathsListed.length !== 1 ? 's' : ''} — click to inspect
</summary>
<div class="mt-1 max-h-48 overflow-y-auto bg-slate-950 rounded-lg p-2 text-xs font-mono text-gray-300 space-y-0.5">
${pathsListed.map(p => `<div class="truncate">${esc(p)}</div>`).join('')}
</div>
</details>` : (dbg.paths_found === 0 ? `<div class="text-xs text-amber-400 mt-1">⚠ SDL returned 0 paths — check SDL_CONFIG_READ_KEY permissions</div>` : '')
if (hasErrors) {
const detail = [
...(res.errors || []).map(e => `index: ${e.parser}`),
...(cf.failed || []).map(e => `fetch: ${e.path?.split('/').pop()}${e.error}`),
].join('<br>')
if (errEl) errEl.innerHTML = `<div class="p-3 bg-red-950/60 ring-1 ring-red-700/50 rounded-xl text-sm text-red-300 mb-4">${msg}<br><span class="text-xs opacity-80">${detail}</span>${debugHtml}</div>`
} else {
if (errEl) errEl.innerHTML = `<div class="p-3 bg-emerald-950/60 ring-1 ring-emerald-700/50 rounded-xl text-sm text-emerald-300 mb-4">${msg}${debugHtml}</div>`
if (!pathsListed.length) setTimeout(() => { if (errEl) errEl.innerHTML = '' }, 5000)
}
cvLoad()
} catch(e) {
if (errEl) errEl.innerHTML = errBox(e.message)
} finally {
setBtn('btn-sdl-parsers', false, 'Sync SDL Parsers')
}
}
function cvToggleMissing(id) {
const el = document.getElementById(id)
const chevron = document.getElementById(id + '-chevron')
if (!el) return
const open = el.classList.toggle('hidden')
if (chevron) chevron.textContent = open ? '▶' : '▼'
}
async function cvUploadSigma(files) {
const form = new FormData()
Array.from(files).forEach(f => form.append('files', f))
try { await apiForm('/api/coverage/upload-sigma', form); cvLoad() }
catch(e) { document.getElementById('cv-err').innerHTML = errBox(e.message) }
}
async function cvUploadParser(file) {
const form = new FormData(); form.append('file', file)
try { await apiForm('/api/coverage/upload-parser', form); cvLoad() }
catch(e) { document.getElementById('cv-err').innerHTML = errBox(e.message) }
}
async function cvReset() {
await apiFetch('/api/coverage/reset', { method: 'DELETE' }); cvData = null; cvLoad()
}
async function cvSampleUnlabelled() {
setBtn('btn-unlabelled', true)
const el = document.getElementById('cv-unlabelled-sample')
el.innerHTML = '<p class="text-gray-500 text-sm animate-pulse mb-4">Sampling unlabelled events…</p>'
try {
const syncDays = +document.getElementById('sync-days')?.value || 1
const hours = syncDays * 24
const r = await apiPost('/api/quality/sample-unlabelled', { source: '', limit: 20, hours, filter_mode: 'broad' })
if (!r.events?.length) {
el.innerHTML = '<p class="text-gray-500 text-sm mb-4">No unlabelled events found in last 24h.</p>'
return
}
const _rawMsgKeys = ['message', 'unmapped.message', 'unmapped.message_']
const getRaw = ev => {
for (const k of _rawMsgKeys) { const v = ev[k]; if (v != null && v !== '' && v !== 'null') return v }
const rest = Object.fromEntries(Object.entries(ev).filter(([k]) => !_rawMsgKeys.includes(k)))
return JSON.stringify(rest)
}
const rows = r.events.map((ev, i) => {
const raw = getRaw(ev)
return `<tr class="border-b border-white/5 hover:bg-white/[0.03] transition-colors">
<td class="py-2 px-2">
<div class="flex items-start gap-2">
<button onclick="navigator.clipboard.writeText(this.dataset.msg)" data-msg="${esc(raw).replace(/"/g,'&quot;')}"
class="shrink-0 mt-0.5 px-1.5 py-0.5 rounded-md text-slate-600 hover:text-gray-200 hover:bg-white/10 text-xs transition-colors">⎘</button>
<span class="font-mono text-xs text-gray-300 break-all">${esc(raw.slice(0,300))}${raw.length>300?'<span class="text-slate-600">…</span>':''}</span>
</div>
</td>
</tr>`
}).join('')
// Swap prompt for count once queried
if (r.total != null) {
const countEl = document.getElementById('cv-unlabelled-count')
const promptEl = document.getElementById('cv-unlabelled-prompt')
if (promptEl) promptEl.classList.add('hidden')
if (countEl) {
countEl.classList.remove('hidden')
countEl.innerHTML =
`<span class="text-orange-300 font-semibold text-sm">${r.total.toLocaleString()} events with no dataSource.name</span>` +
`<span class="text-orange-500/70 text-xs ml-2">ingested but unidentified, these need parsers</span>`
}
}
el.innerHTML = `
<div class="mb-4 rounded-xl ring-1 ring-orange-800/30 overflow-hidden">
<div class="px-3 py-2 bg-orange-950/30 border-b border-orange-800/30 flex items-center justify-between">
<span class="text-xs text-orange-400 font-medium">Sample of unlabelled events — no dataSource.name set</span>
<span class="text-xs text-gray-500">${r.count} of ${r.total?.toLocaleString() ?? '?'} returned · last ${syncDays}d${r.columns_seen?.length ? ` · fields: ${r.columns_seen.join(', ')}` : ''}</span>
</div>
<div class="max-h-72 overflow-y-auto">
<table class="text-xs w-full"><tbody>${rows}</tbody></table>
</div>
</div>`
} catch(e) {
el.innerHTML = `<p class="text-red-400 text-sm mb-4">${esc(e.message)}</p>`
} finally { setBtn('btn-unlabelled', false, 'Sample Events') }
}
async function cvSyncAll() {
setBtn('btn-sync-all', true, 'Syncing…')
const errEl = document.getElementById('cv-err')
if (errEl) errEl.innerHTML = ''
try {
// 1. SDL Parsers
setBtn('btn-sync-all', true, 'Syncing Parsers…')
await loadSDLParsers()
// 2. Detection Library
setBtn('btn-sync-all', true, 'Syncing Library…')
await syncLibrary()
// 3. Live Sources
setBtn('btn-sync-all', true, 'Syncing Sources…')
await cvSyncSources()
} finally {
setBtn('btn-sync-all', false, 'Sync All')
}
}
async function cvSyncSources() {
setBtn('btn-sync', true)
document.getElementById('cv-err').innerHTML = ''
try {
const days = document.getElementById('sync-days')?.value || 7
const r = await apiPost(`/api/coverage/sync-sources?days=${days}`, {})
await cvLoad()
} catch(e) {
document.getElementById('cv-err').innerHTML = errBox(e.message)
} finally { setBtn('btn-sync', false, 'Sync Live Sources') }
}
async function cvLoad() {
try {
cvData = await apiGet('/api/coverage/map')
const s = cvData.summary
const unlabelled = s.unlabelled_events // -1 = not yet queried, 0 = none, >0 = count
const unlabelledQueried = unlabelled >= 0
const unlabelledCount = unlabelled > 0 ? unlabelled.toLocaleString() : '—'
document.getElementById('cv-stats').innerHTML = `
<div class="grid grid-cols-4 gap-3 mb-4">
<div class="bg-gradient-to-b from-gray-900 to-gray-950 ring-1 ring-white/5 rounded-xl p-4 text-center border-t-2 border-t-blue-500 shadow-sm">
<div class="text-2xl font-bold text-gray-200">${s.active_sources}</div>
<div class="text-xs text-slate-500 mt-1">Active Sources</div>
</div>
<div class="bg-gradient-to-b from-gray-900 to-gray-950 ring-1 ring-white/5 rounded-xl p-4 text-center border-t-2 border-t-emerald-500 shadow-sm">
<div class="text-2xl font-bold text-emerald-400">${s.covered}</div>
<div class="text-xs text-slate-500 mt-1">Covered</div>
</div>
<div class="bg-gradient-to-b from-gray-900 to-gray-950 ring-1 ring-white/5 rounded-xl p-4 text-center border-t-2 border-t-red-500 shadow-sm">
<div class="text-2xl font-bold text-red-400">${s.parser_needed}</div>
<div class="text-xs text-slate-500 mt-1">Incomplete Parser${s.stub_parsers > 0 ? `<span class="ml-1 text-amber-600">(${s.stub_parsers} Missing Attributes)</span>` : ''}</div>
</div>
<div class="bg-gradient-to-b from-gray-900 to-gray-950 ring-1 ring-white/5 rounded-xl p-4 text-center border-t-2 border-t-purple-500 shadow-sm">
<div class="text-2xl font-bold text-purple-400">${s.parsers_loaded}</div>
<div class="text-xs text-slate-500 mt-1">Parsers Loaded</div>
</div>
</div>
<div class="flex items-center justify-between bg-orange-950/50 ring-1 ring-orange-800/40 rounded-xl px-4 py-3 mb-4">
<div id="cv-unlabelled-count" class="${unlabelledQueried ? '' : 'hidden'}">
<span class="text-orange-300 font-semibold text-sm">${unlabelledCount} events with no dataSource.name</span>
<span class="text-orange-600 text-xs ml-2">ingested but unidentified, these need parsers</span>
</div>
<div id="cv-unlabelled-prompt" class="${unlabelledQueried ? 'hidden' : ''}">
<span class="text-orange-600 text-sm">Sample events to find data with no dataSource.name — ingested but unidentified</span>
</div>
<div class="flex items-center gap-2">
<button onclick="cvSampleUnlabelled()" id="btn-unlabelled"
class="px-3 py-1 text-xs bg-orange-800 hover:bg-orange-700 rounded text-orange-100">Sample Events</button>
</div>
</div>
<div id="cv-unlabelled-sample"></div>`
if (!cvData.has_sources) {
document.getElementById('cv-filters').classList.add('hidden')
document.getElementById('cv-table').innerHTML = `
<div class="bg-gradient-to-b from-gray-900/50 to-gray-950/50 ring-1 ring-white/5 rounded-xl p-8 text-center text-sm text-slate-500">
No active sources synced yet.
</div>`
return
}
const filtersEl = document.getElementById('cv-filters')
filtersEl.classList.remove('hidden')
filtersEl.innerHTML = [
['all', 'All'],
['covered', 'Complete Parser'],
['stub_parser', 'Attributes Missing'],
].map(([f,l]) => `<button onclick="cvSetFilter('${f}')" id="cvf-${f}"
class="px-3 py-1 text-xs rounded-full border border-white/10 text-slate-400 hover:border-white/20 hover:text-gray-200 transition-colors">${l}</button>`).join('')
if (cvData.synced_at) {
filtersEl.innerHTML += `<span class="text-xs text-gray-600 self-center ml-2">Synced ${new Date(cvData.synced_at).toLocaleTimeString()}</span>`
}
cvSetFilter(cvFilter)
} catch(e) {
document.getElementById('cv-table').innerHTML = '<p class="text-gray-600 text-sm">Failed to load coverage data.</p>'
}
}
function cvSetFilter(f) {
cvFilter = f
document.querySelectorAll('[id^="cvf-"]').forEach(b => {
const on = b.id === `cvf-${f}`
b.className = `px-3 py-1 text-xs rounded-full border transition-colors ${on ? 'bg-purple-700/80 border-purple-500 text-white shadow-sm shadow-purple-900/30' : 'border-white/10 text-slate-400 hover:border-white/20 hover:text-gray-200 cursor-pointer'}`
})
if (!cvData?.sources) return
const LABELS = { covered: 'Covered', parser_needed: 'Incomplete Parser', stub_parser: 'Attributes Missing', unlabelled: 'No dataSource.name' }
const STYLES = { covered: 'bg-emerald-900/50 text-emerald-300 border-emerald-700', parser_needed: 'bg-red-900/50 text-red-300 border-red-700', stub_parser: 'bg-amber-900/50 text-amber-300 border-amber-700', unlabelled: 'bg-orange-900/50 text-orange-300 border-orange-700' }
const sources = cvData.sources.filter(s => {
if ((EXCLUDED_SOURCES.has(s.source_name) && !s.unlabelled)) return false
if (f === 'all') return true
if (f === 'parser_needed') return s.status === 'parser_needed'
if (f === 'stub_parser') return s.status === 'stub_parser'
if (f === 'unlabelled') return s.unlabelled === true
if (f === 'covered') return s.status === 'covered'
return true
})
function missingFieldsCell(s) {
if (!s.missing_fields?.length) {
return s.rule_count
? `<span class="text-emerald-600 text-xs">✓ All fields covered</span>`
: `<span class="text-gray-700 text-xs">—</span>`
}
const id = 'mf-' + s.source_name.replace(/[^a-z0-9]/gi, '_')
const chips = s.missing_fields.map(f =>
`<span class="px-1.5 py-0.5 bg-red-900/40 border border-red-800/60 rounded text-xs font-mono text-red-300">${esc(f)}</span>`
).join(' ')
return `<div>
<button onclick="cvToggleMissing('${id}')"
class="flex items-center gap-1.5 text-xs text-red-400 hover:text-red-300 transition-colors">
<span class="px-1.5 py-0.5 bg-red-900/40 border border-red-800/60 rounded font-semibold">${s.missing_fields.length}</span>
<span>field${s.missing_fields.length !== 1 ? 's' : ''} missing</span>
<span id="${id}-chevron" class="text-gray-600">▶</span>
</button>
<div id="${id}" class="hidden mt-1.5 flex flex-wrap gap-1">${chips}</div>
</div>`
}
function detectionsCell(s) {
const firingPopulated = cvData?.summary?.firing_cache_populated === true
if (s.rule_count) {
if (firingPopulated) {
const ruleItems = (s.rules || []).map(r => {
const alerts = r.alert_count || 0
if (alerts > 0) {
return `<span class="text-purple-400 font-mono text-xs" title="${esc(r.rule)}${alerts} alerts">${esc(r.rule.length > 40 ? r.rule.slice(0, 40) + '…' : r.rule)} <span class="text-emerald-500">(${alerts})</span></span>`
} else {
return `<span class="text-amber-400 font-mono text-xs" title="${esc(r.rule)} — no alerts in period">&#9888; ${esc(r.rule.length > 40 ? r.rule.slice(0, 40) + '…' : r.rule)}</span>`
}
})
return `<div class="space-y-0.5">${ruleItems.join('')}</div>`
}
return `<span class="text-purple-400 font-medium">${s.rule_count}</span> rule${s.rule_count !== 1 ? 's' : ''}`
}
if (s.close_matches && s.close_matches.length) {
const hints = s.close_matches.map(m =>
`<span class="text-amber-400">${esc(m.library_name)}</span> <span class="text-gray-600">(${m.rule_count} rules)</span>`
).join(', ')
return `<span class="text-gray-700">—</span> <span class="text-amber-600 text-xs" title="dataSource.name mismatch?">⚠ similar: ${hints}</span>`
}
return `<span class="text-gray-700">—</span>`
}
function parserCell(s) {
if (s.status === 'covered') {
return `<span class="text-emerald-400 font-medium">✓ Parsed</span>`
}
if (s.status === 'stub_parser') {
const fix = s.stub_suggested_ds_name
? `<span class="ml-2 font-mono text-xs text-gray-500 select-all" title="Add this to your parser's attributes block">"dataSource.name": "${esc(s.stub_suggested_ds_name)}"</span>`
: ''
return `<span class="text-amber-400 font-medium" title="Parser file exists but is missing dataSource.name — add the attribute shown to wire it up">⚠ ${esc(s.parser)} — no dataSource.name</span>${fix}`
}
if (s.unlabelled) {
return `<span class="text-orange-400 font-medium" title="These events arrived with no dataSource.name — the parser sending them is missing the dataSource.name attribute">⚠ Events have no dataSource.name</span>`
}
return `<span class="text-red-400 font-medium">✗ Not Parsed</span>`
}
document.getElementById('cv-table').innerHTML = sources.length === 0
? '<p class="text-slate-600 text-sm">No sources match this filter.</p>'
: `<div class="overflow-x-auto rounded-xl ring-1 ring-white/5"><table class="w-full text-sm">
<thead><tr class="text-left text-slate-500 bg-slate-900/60 border-b border-white/5">
<th class="py-3 px-4 font-medium text-xs uppercase tracking-wide">Data Source</th>
<th class="py-3 px-4 font-medium text-xs uppercase tracking-wide">Events (7d)</th>
<th class="py-3 px-4 font-medium text-xs uppercase tracking-wide">Status</th>
<th class="py-3 px-4 font-medium text-xs uppercase tracking-wide">Parser</th>
<th class="py-3 px-4 font-medium text-xs uppercase tracking-wide">Detections</th>
<th class="py-3 px-4 font-medium text-xs uppercase tracking-wide">Fields Missing</th>
</tr></thead>
<tbody>${sources.map((s, idx) => `
<tr class="${idx % 2 === 1 ? 'bg-white/[0.015]' : 'bg-transparent'} border-b border-white/5 hover:bg-white/[0.04] transition-colors">
<td class="py-2.5 px-4 font-mono text-xs">
<a href="#/quality" onclick="queueQualitySource('${esc(s.source_name)}')"
class="text-gray-200 hover:text-purple-400 cursor-pointer transition-colors"
title="Open in Parser Quality">${esc(s.source_name)}</a>
</td>
<td class="py-2.5 px-4 text-xs text-slate-400">${(s.event_count||0).toLocaleString()}</td>
<td class="py-2.5 px-4"><span class="px-2.5 py-1 rounded-full text-xs font-medium border ${STYLES[s.status]||''}">${LABELS[s.status]||s.status}</span></td>
<td class="py-2.5 px-4 text-xs">${parserCell(s)}</td>
<td class="py-2.5 px-4 text-xs text-slate-400">${detectionsCell(s)}</td>
<td class="py-2.5 px-4 text-xs">${missingFieldsCell(s)}</td>
</tr>`).join('')}
</tbody></table></div>`
}
// ── Ingest ────────────────────────────────────────────────────────────────
let igDays = 3
let igHours = 1 // default to 1h view
function renderIngest() {
const btns = [
{label:'1h', onclick:"igSetHours(1)", id:'ig-h1'},
...([3,5,7].map(d => ({label:`${d}d`, onclick:`igSetDays(${d})`, id:`ig-d${d}`})))
]
set(`<div class="p-8 max-w-6xl">
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-xl font-extrabold text-white tracking-tight">Ingest Dashboard</h1>
<p class="text-sm text-slate-400 mt-1">Event volume · cost projection · filter simulator</p>
</div>
<div class="flex gap-2">
${btns.map(b=>`<button onclick="${b.onclick}" id="${b.id}"
class="px-3 py-1.5 text-xs rounded-lg border border-white/10 text-slate-400 hover:border-white/20 hover:text-gray-200 transition-colors">${b.label}</button>`).join('')}
</div>
</div>
<div id="ig-err"></div>
<div class="bg-gradient-to-b from-gray-900 to-gray-950 ring-1 ring-white/5 rounded-xl p-5 mb-5 shadow-sm">
<div class="flex items-baseline gap-2 mb-4">
<h2 class="text-sm font-semibold text-white" id="ig-chart-title">Daily Event Volume</h2>
<span class="text-xs text-slate-500" id="ig-chart-sub">events ingested per day</span>
</div>
<div id="ig-chart"><p class="text-slate-500 text-sm">Loading…</p></div>
</div>
<div class="bg-gradient-to-b from-gray-900 to-gray-950 ring-1 ring-white/5 rounded-xl p-5 mb-5 shadow-sm">
<h2 class="text-sm font-semibold text-white mb-4">Top Sources</h2>
<div id="ig-sources"><p class="text-slate-500 text-sm">Loading…</p></div>
</div>
<div class="bg-gradient-to-b from-gray-900 to-gray-950 ring-1 ring-white/5 rounded-xl p-5 shadow-sm">
<div class="flex items-start justify-between gap-4 mb-3">
<div>
<h2 class="text-sm font-semibold text-white">Filter Simulator</h2>
<p class="text-xs text-slate-500 mt-0.5">Before writing an SDL exclusion filter, use this to see how many events and GB it would remove.</p>
</div>
<button onclick="toggleSimHelp()" class="shrink-0 text-xs text-purple-400 hover:text-purple-300 transition-colors">How does this work?</button>
</div>
<div id="sim-help" class="hidden mb-4 bg-slate-800/50 ring-1 ring-white/5 rounded-xl p-4 text-xs text-slate-400 space-y-2">
<p><span class="text-gray-200 font-medium">What it does:</span> Runs a live PowerQuery against your data lake to count how many events match a source and/or event type, then projects that number into estimated GB and monthly cost savings.</p>
<p><span class="text-gray-200 font-medium">When to use it:</span> You've spotted a noisy source in the Top Sources table above (e.g. a source sending millions of low-value events). Enter it here to quantify how much ingest you'd save by filtering it out before committing the change in the SentinelOne console.</p>
<p><span class="text-gray-200 font-medium">Source name:</span> Paste the exact value from the <em>dataSource.name</em> column in the Top Sources table — e.g. <code class="bg-gray-900 px-1 rounded">ActivityFeed</code>.</p>
<p><span class="text-gray-200 font-medium">Event type:</span> Optional. Narrow the filter to a specific event category (e.g. <code class="bg-gray-900 px-1 rounded">dns</code>, <code class="bg-gray-900 px-1 rounded">heartbeat</code>). Leave blank to simulate dropping the entire source.</p>
<p><span class="text-gray-200 font-medium">GB estimate:</span> Based on 0.5 GB per million events — adjust in code if your actual ratio differs.</p>
<p class="text-amber-400/80">⚠ This is a read-only simulation — no filters are created or applied automatically. Use the results to inform an exclusion filter you create manually in the SentinelOne console.</p>
</div>
<div class="flex gap-3 flex-wrap mb-4">
<input id="ig-src" placeholder="Source name — e.g. ActivityFeed"
class="flex-1 min-w-48 bg-slate-800/80 border border-white/10 rounded-lg px-3 py-2 text-sm text-gray-200 placeholder-slate-600 focus:outline-none focus:border-purple-500 focus:ring-1 focus:ring-purple-500/30 transition-colors">
<input id="ig-evt" placeholder="Event type (optional)"
class="flex-1 min-w-48 bg-slate-800/80 border border-white/10 rounded-lg px-3 py-2 text-sm text-gray-200 placeholder-slate-600 focus:outline-none focus:border-purple-500 focus:ring-1 focus:ring-purple-500/30 transition-colors">
<button onclick="igSimulate()" id="btn-sim"
class="px-4 py-2 text-sm bg-purple-700 hover:bg-purple-600 rounded-lg text-white font-medium transition-colors shadow-sm">Simulate</button>
</div>
<div id="ig-sim-result"></div>
</div>
</div>`)
igUpdateButtons()
igLoad()
}
function igUpdateButtons() {
const active = igHours ? `ig-h${igHours}` : `ig-d${igDays}`
;[{id:'ig-h1'},{id:'ig-d3'},{id:'ig-d5'},{id:'ig-d7'}].forEach(({id}) => {
const b = document.getElementById(id)
if (!b) return
b.className = `px-3 py-1.5 text-xs rounded-lg border transition-colors ${id===active ? 'bg-purple-700/80 border-purple-500 text-white shadow-sm' : 'border-white/10 text-slate-400 hover:border-white/20 hover:text-gray-200'}`
})
}
function igSetDays(d) {
igDays = d; igHours = null
igUpdateButtons()
igLoad()
}
function igSetHours(h) {
igHours = h
igUpdateButtons()
igLoad()
}
async function igLoad() {
const spinner = `<p class="text-gray-500 text-sm animate-pulse">Querying data lake…</p>`
document.getElementById('ig-chart').innerHTML = spinner
document.getElementById('ig-sources').innerHTML = spinner
const sourcesUrl = igHours
? `/api/ingest/top-sources?hours=${igHours}`
: `/api/ingest/top-sources?days=${igDays}`
const [dailyResult, sourcesResult] = await Promise.allSettled([
igHours ? Promise.resolve(null) : apiGet(`/api/ingest/daily-volume?days=${igDays}`),
apiGet(sourcesUrl)
])
// Update chart title based on mode
const titleEl = document.getElementById('ig-chart-title')
const subEl = document.getElementById('ig-chart-sub')
if (titleEl) titleEl.textContent = igHours ? 'Events by Source (Last 1h)' : 'Daily Event Volume'
if (subEl) subEl.textContent = igHours ? 'top sources · last hour' : 'events ingested per day'
// Volume chart
if (igHours) {
// For 1h mode, render top-sources data as a by-source bar chart
if (sourcesResult.status === 'fulfilled') {
const data = sourcesResult.value?.data ?? []
const chartRows = data.slice(0, 12).map(r => ({
label: r['dataSource.name'] || r.name || 'unknown',
events: r.events || 0
}))
document.getElementById('ig-chart').innerHTML = chartRows.length
? barChart(chartRows, 'label', 'events')
: `<p class="text-gray-500 text-sm">No data in this period.</p>`
} else {
document.getElementById('ig-chart').innerHTML = `<p class="text-red-400 text-sm">${esc(sourcesResult.reason?.message ?? 'Error')}</p>`
}
} else if (dailyResult.status === 'fulfilled') {
document.getElementById('ig-chart').innerHTML = barChart(dailyResult.value, 'date', 'events')
} else {
document.getElementById('ig-chart').innerHTML = `<p class="text-red-400 text-sm">${esc(dailyResult.reason?.message ?? 'Error')}</p>`
}
// Top sources table
if (sourcesResult.status === 'fulfilled') {
const data = sourcesResult.value?.data ?? []
const rows = data.map((r, i) => {
const name = r['dataSource.name'] || r.name || 'unknown'
const evts = r.events || 0
return `<tr class="${i % 2 === 1 ? 'bg-white/[0.015]' : ''} border-b border-white/5 hover:bg-white/[0.04] transition-colors">
<td class="py-2.5 px-1 font-mono text-xs text-gray-200">${esc(name)}</td>
<td class="py-2.5 px-1 text-right text-gray-300 tabular-nums">${evts.toLocaleString()}</td>
<td class="py-2.5 px-1 text-right text-slate-400 tabular-nums">${(evts/1e6*0.5).toFixed(3)}</td>
</tr>`
})
document.getElementById('ig-sources').innerHTML = rows.length ? `
<table class="w-full text-sm">
<thead><tr class="text-left text-slate-500 border-b border-white/5">
<th class="pb-2.5 font-medium text-xs uppercase tracking-wide">Source</th>
<th class="pb-2.5 text-right font-medium text-xs uppercase tracking-wide">Events</th>
<th class="pb-2.5 text-right font-medium text-xs uppercase tracking-wide">Est. GB</th>
</tr></thead>
<tbody>${rows.join('')}</tbody>
</table>` : `<p class="text-slate-500 text-sm">No data in this period.</p>`
} else {
document.getElementById('ig-sources').innerHTML = `<p class="text-red-400 text-sm">${esc(sourcesResult.reason?.message ?? 'Error')}</p>`
}
}
function toggleSimHelp() {
const el = document.getElementById('sim-help')
el.classList.toggle('hidden')
}
async function igSimulate() {
setBtn('btn-sim', true)
try {
const r = await apiPost('/api/ingest/simulate-filter', {
source: document.getElementById('ig-src').value,
event_type: document.getElementById('ig-evt').value,
days: igDays, gb_per_million_events: 0.5
})
document.getElementById('ig-sim-result').innerHTML = `
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
${statCard('Matched Events', (r.matched_events||0).toLocaleString(), 'text-purple-300')}
${statCard(`Est. GB (${igDays}d)`, r.estimated_gb_period||0, 'text-purple-300')}
${statCard('Proj. Monthly Events', (r.projected_monthly_events||0).toLocaleString(), 'text-purple-300')}
${statCard('Proj. Monthly GB', r.projected_monthly_gb||0, 'text-purple-300')}
</div>`
} catch(e) {
document.getElementById('ig-sim-result').innerHTML = `<p class="text-red-400 text-sm">${esc(e.message)}</p>`
} finally { setBtn('btn-sim', false, 'Simulate') }
}
// ── Onboarding ────────────────────────────────────────────────────────────
const PROMPT = `Onboard this log source for SentinelOne SDL. Please generate:
1. An SDL parser skeleton in augmented-JSON format (/logParsers/ format)
2. Field mappings from raw fields to the SDL common schema
3. 23 starter STAR detection rules for common threats from this source type
4. 5 parser test assertions (input line → expected field → expected value)
Log source: [describe source, e.g. "Palo Alto PAN-OS firewall"]
Raw log sample:
[paste your log lines here]`
function renderOnboarding() {
set(`<div class="p-8 max-w-3xl space-y-8">
<div>
<h1 class="text-xl font-extrabold text-white tracking-tight">Onboarding Accelerator</h1>
<p class="text-sm text-slate-400 mt-1">Use Claude Code directly — no API key required</p>
</div>
<!-- Source Onboarding Pipeline -->
<div class="bg-gradient-to-b from-gray-900 to-gray-950 ring-1 ring-white/5 rounded-xl p-5 shadow-sm">
<div class="flex items-start justify-between mb-4">
<div>
<h2 class="text-base font-semibold text-white">Source Onboarding Pipeline</h2>
<p class="text-xs text-slate-500 mt-0.5">6-stage lifecycle tracker for every active data source</p>
</div>
<button onclick="obTogglePipeline()" id="btn-ob-pipeline-toggle"
class="text-xs text-slate-400 hover:text-gray-200 px-3 py-1.5 rounded-lg bg-slate-800/60 hover:bg-slate-700/60 transition-colors">
Show Pipeline
</button>
</div>
<div id="ob-pipeline-stats" class="flex flex-wrap gap-3">
<div class="px-3 py-1.5 rounded-full bg-slate-800/60 text-xs text-slate-500 animate-pulse">Loading…</div>
</div>
<div id="ob-pipeline-table" class="hidden mt-4"></div>
</div>
<!-- Steps -->
<div class="space-y-4">
${obStep('1. Grab a log sample','Copy 1050 representative lines from the new log source. Include edge cases — errors, different event types, varying field presence.')}
${obStep('2. Paste into Claude Code','Open Claude Code and say "Onboard this log source for SentinelOne SDL" then paste the sample. Mention the source type if known.')}
${obStep('3. Get your artefacts','Claude returns an SDL parser (augmented-JSON), field mappings to the SDL schema, starter STAR detection rules, and parser test assertions.')}
${obStep('4. Deploy','Drop the parser JSON into your /logParsers/ path. Paste the STAR rules into the AI-SIEM rule editor. Run the test assertions to validate extraction.')}
</div>
<!-- Prompt template -->
<div class="bg-gradient-to-b from-gray-900 to-gray-950 ring-1 ring-white/5 rounded-xl overflow-hidden shadow-sm">
<div class="px-4 py-3 border-b border-white/5 flex items-center justify-between bg-slate-900/40">
<span class="text-xs font-medium text-slate-400 uppercase tracking-wide">Prompt template</span>
<button onclick="obCopy()" id="btn-copy" class="px-2.5 py-1 text-xs text-slate-400 hover:text-gray-200 hover:bg-white/5 rounded-lg transition-colors">Copy</button>
</div>
<pre class="p-4 text-xs text-gray-300 font-mono leading-relaxed whitespace-pre-wrap">${esc(PROMPT)}</pre>
</div>
</div>`)
loadOnboardingPipeline()
}
function obStep(title, desc) {
return `<div class="flex gap-4 bg-gradient-to-b from-gray-900 to-gray-950 ring-1 ring-white/5 rounded-xl p-4 hover:ring-white/10 transition-all">
<div class="w-8 h-8 shrink-0 rounded-lg bg-purple-900/50 ring-1 ring-purple-700/40 flex items-center justify-center text-purple-300 text-xs mt-0.5">→</div>
<div>
<div class="text-sm font-semibold text-white">${esc(title)}</div>
<div class="text-sm text-slate-400 mt-1">${esc(desc)}</div>
</div>
</div>`
}
function obCopy() {
navigator.clipboard.writeText(PROMPT)
const b = document.getElementById('btn-copy')
if (b) { b.textContent = 'Copied!'; setTimeout(() => b.textContent = 'Copy', 1500) }
}
// ── Onboarding Pipeline ───────────────────────────────────────────────────
let _obPipelineData = null
async function loadOnboardingPipeline() {
const statsEl = document.getElementById('ob-pipeline-stats')
const tableEl = document.getElementById('ob-pipeline-table')
if (!statsEl || !tableEl) return
try {
const data = await apiGet('/api/coverage/onboarding-status')
_obPipelineData = data
const sources = data.sources || []
statsEl.innerHTML = `
<span class="px-3 py-1.5 rounded-full bg-emerald-900/40 ring-1 ring-emerald-700/40 text-xs text-emerald-300">✓ Fully Onboarded: ${data.fully_onboarded}</span>
<span class="px-3 py-1.5 rounded-full bg-amber-900/40 ring-1 ring-amber-700/40 text-xs text-amber-300">⟳ In Progress: ${data.in_progress}</span>
<span class="px-3 py-1.5 rounded-full bg-slate-800/60 ring-1 ring-white/5 text-xs text-slate-400">○ Not Started: ${data.not_started}</span>`
const STAGE_ICONS = ['📥','📄','⚙️','🏷️','🔍','🔔']
const STAGE_LABELS = ['Data Received','Parser File','Parser Active','Source Labeled','Detection Rules','Rules Firing']
// Split into two groups
const withRules = sources.filter(s => s.has_detection_rules)
const withoutRules = sources.filter(s => !s.has_detection_rules)
function renderRow(s, i) {
const stageDots = s.stages.map((st, si) => {
if (st.na) return `<span class="cursor-default text-base text-slate-800" title="${esc(st.stage)} — N/A (no detection rules mapped)">—</span>`
return `<span class="cursor-default text-base ${st.done ? 'text-emerald-400' : 'text-slate-700'}" title="${esc(st.stage)}">${STAGE_ICONS[si] || '●'}</span>`
}).join('')
const pct = s.pct
const barColor = pct === 100 ? 'bg-emerald-500' : pct >= 50 ? 'bg-amber-500' : 'bg-red-500'
return `<tr class="${i % 2 === 1 ? 'bg-white/[0.015]' : ''} border-b border-white/5 hover:bg-white/[0.04] transition-colors">
<td class="py-2.5 px-4 font-mono text-xs text-gray-200 max-w-xs truncate" title="${esc(s.source)}">${esc(s.source)}</td>
<td class="py-2.5 px-4">
<div class="flex items-center gap-1.5">${stageDots}</div>
<div class="h-1 mt-1.5 bg-slate-800 rounded-full overflow-hidden w-24">
<div class="h-full ${barColor} rounded-full" style="width:${pct}%"></div>
</div>
</td>
<td class="py-2.5 px-4 text-xs text-slate-500 tabular-nums">${s.completed}/${s.total}</td>
<td class="py-2.5 px-4 text-xs text-slate-500 tabular-nums">${(s.event_count||0).toLocaleString()}</td>
</tr>`
}
function renderSection(title, desc, sectionSources, idPrefix) {
if (sectionSources.length === 0) return ''
const incomplete = sectionSources.filter(s => s.completed < s.total)
const complete = sectionSources.filter(s => s.completed === s.total)
const toggleBtn = complete.length > 0
? `<tr id="${idPrefix}-toggle-row" class="border-b border-white/5">
<td colspan="4" class="py-2 px-4">
<button onclick="obToggleSection('${idPrefix}')" class="text-xs text-slate-500 hover:text-gray-300 transition-colors">
<span id="${idPrefix}-toggle-label">Show completed (${complete.length})</span>
</button>
</td>
</tr>` : ''
const completeSection = `<tbody id="${idPrefix}-complete-rows" class="hidden">${complete.map((s,i)=>renderRow(s,i)).join('')}</tbody>`
return `
<div class="mb-5">
<div class="flex items-center gap-2 mb-2">
<span class="text-xs font-semibold text-slate-300 uppercase tracking-wide">${esc(title)}</span>
<span class="text-xs text-slate-600">${esc(desc)}</span>
</div>
<div class="overflow-x-auto rounded-xl ring-1 ring-white/5">
<table class="w-full text-sm">
<thead><tr class="text-left text-slate-500 bg-slate-900/60 border-b border-white/5">
<th class="py-3 px-4 font-medium text-xs uppercase tracking-wide">Source</th>
<th class="py-3 px-4 font-medium text-xs uppercase tracking-wide">Pipeline Stages</th>
<th class="py-3 px-4 font-medium text-xs uppercase tracking-wide">Progress</th>
<th class="py-3 px-4 font-medium text-xs uppercase tracking-wide">Events</th>
</tr></thead>
<tbody>${incomplete.map((s,i)=>renderRow(s,i)).join('')}</tbody>
${toggleBtn}
${completeSection}
</table>
</div>
</div>`
}
tableEl.innerHTML = `
<div class="text-xs text-slate-600 mb-3 flex flex-wrap gap-3">
${STAGE_ICONS.map((icon,i) => `<span>${icon} ${STAGE_LABELS[i]}</span>`).join('')}
</div>
${renderSection('Full Pipeline', `${withRules.length} sources — detection rules mapped (all 6 stages)`, withRules, 'ob-with')}
${renderSection('Partial Pipeline', `${withoutRules.length} sources — no detection rules mapped (stages 56 N/A)`, withoutRules, 'ob-without')}
${sources.length === 0 ? '<p class="text-slate-600 text-sm text-center py-4">No active sources found — sync sources on the Coverage Map first.</p>' : ''}`
} catch(e) {
if (tableEl) tableEl.innerHTML = `<p class="text-red-400 text-sm">${esc(e.message)}</p>`
}
}
let _obPipelineVisible = false
function obTogglePipeline() {
_obPipelineVisible = !_obPipelineVisible
const tableEl = document.getElementById('ob-pipeline-table')
const btn = document.getElementById('btn-ob-pipeline-toggle')
if (tableEl) tableEl.classList.toggle('hidden', !_obPipelineVisible)
if (btn) btn.textContent = _obPipelineVisible ? 'Hide Pipeline' : 'Show Pipeline'
}
function obToggleSection(idPrefix) {
const rows = document.getElementById(`${idPrefix}-complete-rows`)
const label = document.getElementById(`${idPrefix}-toggle-label`)
if (!rows) return
const hidden = rows.classList.toggle('hidden')
if (label) {
const count = rows.querySelectorAll('tr').length
label.textContent = (hidden ? 'Show' : 'Hide') + ` completed (${count})`
}
}
// ── Settings ──────────────────────────────────────────────────────────────
async function renderSettings() {
set(`<div class="p-8 max-w-2xl mx-auto space-y-6">
<h1 class="text-xl font-extrabold text-white tracking-tight">Settings</h1>
<div id="st-content"><p class="text-slate-500 text-sm">Loading…</p></div>
</div>`)
try {
const { fields, env_file_exists, env_file_path } = await apiGet('/api/settings/config')
renderSettingsForm(fields, env_file_exists, env_file_path)
} catch(e) {
document.getElementById('st-content').innerHTML = `<p class="text-red-400 text-sm">${esc(e.message)}</p>`
}
}
function renderSettingsForm(fields, envExists, envPath) {
const setupBanner = !envExists ? `
<div class="bg-blue-950/50 ring-1 ring-blue-700/40 rounded-xl p-4 text-sm mb-4">
<p class="font-semibold text-blue-300 mb-2">First-time setup</p>
<p class="text-blue-200/80 mb-3">No <code class="bg-slate-900 px-1 rounded">.env</code> file found. Create one from the template to get started:</p>
<div class="bg-slate-950 rounded-lg p-3 font-mono text-xs text-green-300 space-y-1">
<div><span class="text-slate-500"># in your project directory:</span></div>
<div>cp .env.example .env</div>
<div><span class="text-slate-500"># edit .env with your credentials, then:</span></div>
<div>docker-compose up -d --build</div>
</div>
</div>` : ''
const fieldRows = fields.map(f => `
<div class="space-y-1">
<label class="block text-xs font-medium text-gray-400 uppercase tracking-wide">${esc(f.label)}</label>
<div class="flex gap-2">
${f.type === 'select' ? `
<select id="st-${f.key}" data-key="${f.key}" data-secret="false"
class="flex-1 bg-slate-800/80 border border-white/10 rounded-lg px-3 py-2 text-sm text-gray-100 focus:outline-none focus:border-purple-500 focus:ring-1 focus:ring-purple-500/30 transition-colors">
${(f.options||[]).map(o => `<option value="${esc(o)}" ${f.value===o?'selected':''}>${esc(o)}</option>`).join('')}
</select>` : `
<input
id="st-${f.key}"
type="${f.secret ? 'password' : 'text'}"
placeholder="${esc(f.value || f.placeholder)}"
value=""
data-key="${f.key}"
data-secret="${f.secret}"
class="flex-1 bg-slate-800/80 border border-white/10 rounded-lg px-3 py-2 text-sm text-gray-100 placeholder-slate-600 focus:outline-none focus:border-purple-500 focus:ring-1 focus:ring-purple-500/30 transition-colors font-${f.secret ? 'mono' : 'sans'}"
>
${f.secret ? `<button onclick="toggleSecret('st-${f.key}')" class="px-3 py-2 rounded-lg border border-white/10 text-slate-400 hover:text-gray-100 text-xs hover:border-white/20 transition-colors">Show</button>` : ''}`}
</div>
${f.hint ? `<p class="text-xs text-gray-600">${esc(f.hint)}</p>` : ''}
${f.type !== 'select' ? (f.set ? `<p class="text-xs text-gray-600">Currently set${f.secret ? ' (masked)' : ': ' + esc(f.value)}</p>` : `<p class="text-xs text-amber-600">Not configured</p>`) : ''}
</div>`).join('')
document.getElementById('st-content').innerHTML = `
${setupBanner}
<div class="bg-gradient-to-b from-gray-900 to-gray-950 ring-1 ring-white/5 rounded-xl p-5 space-y-5 shadow-sm">
<div class="flex items-center justify-between">
<h2 class="font-semibold text-white text-sm">Environment Configuration</h2>
<span class="text-xs text-slate-500 font-mono">${esc(envPath)}</span>
</div>
<p class="text-xs text-slate-500">Leave a field blank to keep its current value. Changes are written to <code class="bg-slate-800 px-1 rounded">.env</code> and take effect after restarting the backend (<code class="bg-slate-800 px-1 rounded">docker-compose up -d --build backend</code>).</p>
<div class="space-y-4">${fieldRows}</div>
<div class="flex items-center gap-3 pt-2">
<button onclick="saveSettings()" id="st-save" class="px-4 py-2 bg-purple-700 hover:bg-purple-600 rounded-lg text-sm font-medium text-white transition-colors shadow-sm">Save to .env</button>
<span id="st-msg" class="text-sm"></span>
</div>
</div>
<div class="bg-gradient-to-b from-gray-900 to-gray-950 ring-1 ring-white/5 rounded-xl p-5 space-y-3 shadow-sm">
<h2 class="font-semibold text-white text-sm">Manual Setup</h2>
<p class="text-xs text-slate-400">Prefer editing the file directly? Copy the template and fill in your credentials:</p>
<div class="bg-slate-950 rounded-lg p-4 font-mono text-xs text-gray-300 space-y-1">
<div><span class="text-slate-600"># 1. Copy the template</span></div>
<div class="text-green-400">cp .env.example .env</div>
<div class="mt-2"><span class="text-slate-600"># 2. Open and edit</span></div>
<div class="text-green-400">nano .env</div>
<div class="mt-2"><span class="text-slate-600"># 3. Rebuild & restart</span></div>
<div class="text-green-400">docker-compose up -d --build</div>
</div>
<div class="pt-1 space-y-1 text-xs text-gray-500">
<p><span class="text-gray-400 font-medium">S1_BASE_URL / S1_API_TOKEN</span> — SentinelOne console URL and service user API token. Generate the token at <span class="text-gray-400">Settings → Users → Service Users</span>.</p>
<p><span class="text-gray-400 font-medium">SDL_XDR_URL / SDL_LOG_READ_KEY</span> — Singularity Data Lake credentials for PowerQuery. Found at <span class="text-gray-400">Settings → Integrations → Data Lake API Keys</span>.</p>
<p><span class="text-gray-400 font-medium">SDL_CONFIG_READ_KEY</span> — SDL API key with <em>Manage config files</em> permission. Required for <strong class="text-gray-400">Sync SDL Parsers</strong> to auto-fetch parsers from <code>/logParsers/</code> in the console.</p>
<p><span class="text-gray-400 font-medium">ANTHROPIC_API_KEY</span> — Optional. Required only for the Onboarding AI assistant. Get it at <span class="text-gray-400">console.anthropic.com</span>.</p>
</div>
</div>`
}
function toggleSecret(id) {
const el = document.getElementById(id)
const btn = el.nextElementSibling
if (el.type === 'password') { el.type = 'text'; btn.textContent = 'Hide' }
else { el.type = 'password'; btn.textContent = 'Show' }
}
async function saveSettings() {
const updates = {}
document.querySelectorAll('[data-key]').forEach(el => {
// Always save select fields; only save text/password inputs when non-empty
if (el.tagName === 'SELECT' || el.value.trim()) updates[el.dataset.key] = el.value.trim()
})
if (!Object.keys(updates).length) {
document.getElementById('st-msg').innerHTML = '<span class="text-gray-400">Nothing to save — fill in at least one field.</span>'
return
}
setBtn('st-save', true)
try {
const r = await apiPost('/api/settings/config', { updates })
document.getElementById('st-msg').innerHTML =
`<span class="text-green-400">✓ Saved ${r.saved.length} value${r.saved.length!==1?'s':''}. Restart the backend to apply: <code class="bg-gray-800 px-1 rounded text-xs">docker-compose up -d --build backend</code></span>`
document.querySelectorAll('[data-key]').forEach(el => el.value = '')
} catch(e) {
document.getElementById('st-msg').innerHTML = `<span class="text-red-400">${esc(e.message)}</span>`
} finally { setBtn('st-save', false, 'Save to .env') }
}
// ── Parser Quality ────────────────────────────────────────────────────────
function renderQuality() {
set(`<div class="p-8 max-w-5xl space-y-6">
<div>
<h1 class="text-xl font-extrabold text-white tracking-tight">Parser Quality</h1>
<p class="text-sm text-slate-400 mt-1">Sample live events from any source · measure field population rates · test parser patterns against raw logs · identify parsers with missing attributes</p>
</div>
<!-- Live Event Sampler -->
<div class="bg-gradient-to-b from-gray-900 to-gray-950 ring-1 ring-white/5 rounded-xl p-5 shadow-sm">
<h2 class="text-sm font-semibold text-white mb-1">Live Event Sampler</h2>
<p class="text-xs text-slate-500 mb-4">Pull recent raw events from a source and see exactly which fields landed — and which are missing.</p>
<div class="flex gap-3 flex-wrap mb-4">
<select id="qs-source" class="flex-1 min-w-60 bg-slate-800/80 border border-white/10 rounded-lg px-3 py-2 text-sm text-gray-300 focus:outline-none focus:border-purple-500 focus:ring-1 focus:ring-purple-500/30 transition-colors">
<option value="">— loading sources… —</option>
</select>
<select id="qs-hours" class="bg-slate-800/80 border border-white/10 rounded-lg px-3 py-2 text-sm text-gray-300 focus:outline-none focus:border-purple-500 transition-colors">
<option value="1">Last 1h</option>
<option value="6">Last 6h</option>
<option value="24" selected>Last 24h</option>
<option value="72">Last 3d</option>
<option value="168">Last 7d</option>
<option value="336">Last 14d</option>
<option value="720">Last 30d</option>
</select>
<select id="qs-limit" class="bg-slate-800/80 border border-white/10 rounded-lg px-3 py-2 text-sm text-gray-300 focus:outline-none focus:border-purple-500 transition-colors">
<option value="10" selected>10 events</option>
<option value="20">20 events</option>
<option value="50">50 events</option>
</select>
<button onclick="qsSample()" id="btn-qs"
class="px-4 py-2 text-sm bg-purple-700 hover:bg-purple-600 rounded-lg text-white font-medium transition-colors shadow-sm">Sample</button>
</div>
<div id="qs-result"></div>
</div>
<!-- Field Population Rate -->
<div class="bg-gradient-to-b from-gray-900 to-gray-950 ring-1 ring-white/5 rounded-xl p-5 shadow-sm">
<h2 class="text-sm font-semibold text-white mb-1">Field Population Rate</h2>
<p class="text-xs text-slate-500 mb-4">Sample up to 500 events and measure what % have each key field populated. Low rates flag parser extraction failures.</p>
<div class="flex gap-3 flex-wrap mb-3">
<select id="qp-source" onchange="qpDiscoverFields()" class="flex-1 min-w-60 bg-slate-800/80 border border-white/10 rounded-lg px-3 py-2 text-sm text-gray-300 focus:outline-none focus:border-purple-500 focus:ring-1 focus:ring-purple-500/30 transition-colors">
<option value="">— loading sources… —</option>
</select>
<select id="qp-hours" onchange="qpDiscoverFields()" class="bg-slate-800/80 border border-white/10 rounded-lg px-3 py-2 text-sm text-gray-300 focus:outline-none focus:border-purple-500 transition-colors">
<option value="1">Last 1h</option>
<option value="6">Last 6h</option>
<option value="24" selected>Last 24h</option>
<option value="72">Last 3d</option>
<option value="168">Last 7d</option>
<option value="336">Last 14d</option>
<option value="720">Last 30d</option>
</select>
<button onclick="qpAnalyze()" id="btn-qp"
class="px-4 py-2 text-sm bg-purple-700 hover:bg-purple-600 rounded-lg text-white font-medium transition-colors shadow-sm">Analyze</button>
</div>
<div class="mb-3">
<label class="text-xs text-slate-500 block mb-1">Fields to check <span class="text-slate-600">(comma-separated)</span></label>
<input id="qp-fields" value="src.ip,dst.ip,user.name,event.type,src.process.name,src.process.cmdline,tgt.file.path,network.direction"
class="w-full bg-slate-800/80 border border-white/10 rounded-lg px-3 py-2 text-xs text-gray-300 font-mono focus:outline-none focus:border-purple-500 focus:ring-1 focus:ring-purple-500/30 transition-colors">
</div>
<div id="qp-result"></div>
</div>
<!-- Parser Test Runner -->
<div class="bg-gradient-to-b from-gray-900 to-gray-950 ring-1 ring-white/5 rounded-xl p-5 shadow-sm">
<h2 class="text-sm font-semibold text-white mb-1">Parser Test Runner</h2>
<p class="text-xs text-slate-500 mb-4">Paste a raw log line and pick a loaded parser — see which fields the format patterns would extract without deploying anything.</p>
<div class="flex gap-3 flex-wrap mb-3">
<select id="qt-parser" class="flex-1 bg-slate-800/80 border border-white/10 rounded-lg px-3 py-2 text-sm text-gray-300 focus:outline-none focus:border-purple-500 focus:ring-1 focus:ring-purple-500/30 transition-colors">
<option value="">— select parser —</option>
</select>
<button onclick="qtTest()" id="btn-qt"
class="px-4 py-2 text-sm bg-purple-700 hover:bg-purple-600 rounded-lg text-white font-medium transition-colors shadow-sm">Test</button>
</div>
<textarea id="qt-log" rows="3" placeholder="Paste raw log line here…"
class="w-full bg-slate-800/80 border border-white/10 rounded-lg px-3 py-2 text-xs text-gray-300 font-mono placeholder-slate-600 focus:outline-none focus:border-purple-500 focus:ring-1 focus:ring-purple-500/30 transition-colors mb-3"></textarea>
<div id="qt-result"></div>
</div>
<!-- Attributes Missing -->
<div class="bg-gradient-to-b from-gray-900 to-gray-950 ring-1 ring-amber-800/30 rounded-xl p-5 shadow-sm border-t-2 border-t-amber-700/30">
<h2 class="text-sm font-semibold text-white mb-1">Attributes Missing</h2>
<p class="text-xs text-slate-500 mb-4">Parser files loaded from SDL that are missing the <code class="bg-slate-800 px-1 rounded">dataSource.name</code> attribute — add it to the parser's <code class="bg-slate-800 px-1 rounded">attributes</code> block to enable full coverage tracking.</p>
<div id="qa-stubs-result">
<button onclick="qaLoadStubs()" class="px-4 py-2 text-sm bg-amber-700 hover:bg-amber-600 rounded-lg text-white font-medium transition-colors shadow-sm">Load Attributes Missing</button>
</div>
</div>
</div>`)
qtLoadParsers().then(() => {
// Pre-select source if navigated from Coverage Map or Overview
if (_pendingQualitySource) {
const src = _pendingQualitySource
_pendingQualitySource = null
const qsSel = document.getElementById('qs-source')
const qpSel = document.getElementById('qp-source')
if (qsSel) qsSel.value = src
if (qpSel) { qpSel.value = src; qpDiscoverFields() }
}
})
}
// ── Live Event Sampler ─────────────────────────────────────────────────────
let _qsEvents = [] // last sampled events, keyed by index
async function qsSample() {
const source = document.getElementById('qs-source').value
if (!source) { document.getElementById('qs-result').innerHTML = errBox('Select a source.'); return }
setBtn('btn-qs', true)
document.getElementById('qs-result').innerHTML = '<p class="text-gray-500 text-sm animate-pulse">Querying data lake…</p>'
try {
const r = await apiPost('/api/quality/sample-events', {
source,
limit: +document.getElementById('qs-limit').value,
hours: +document.getElementById('qs-hours').value,
})
if (!r.events?.length) {
document.getElementById('qs-result').innerHTML = '<p class="text-gray-500 text-sm">No events found for this source in the selected window.</p>'
return
}
_qsEvents = r.events
// Build a raw-log string for each event:
// If 'message' exists use it (or unmapped.message / unmapped.message_ fallbacks);
// otherwise reconstruct JSON from all fields.
const _rawMsgKeys = ['message', 'unmapped.message', 'unmapped.message_']
const getRaw = ev => {
for (const k of _rawMsgKeys) {
const v = ev[k]
if (v != null && v !== '' && v !== 'null') return v
}
const rest = Object.fromEntries(Object.entries(ev).filter(([k]) => !_rawMsgKeys.includes(k)))
return JSON.stringify(rest)
}
// Other fields (everything except message variants) for the detail columns
const otherFields = [...new Set(r.events.flatMap(e => Object.keys(e)))]
.filter(f => !_rawMsgKeys.includes(f)).sort()
const hasParsedFields = otherFields.length > 0
const rows = r.events.map((ev, ri) => {
const raw = getRaw(ev)
const msgCell = `<td class="py-2 px-2 w-full">
<div class="flex items-start gap-2">
<button onclick="qsCopyMsg(this, ${ri})" data-msg="${esc(raw).replace(/"/g, '&quot;')}"
class="shrink-0 mt-0.5 px-1.5 py-0.5 rounded-md text-slate-600 hover:text-gray-200 hover:bg-white/10 transition-colors text-xs leading-none"
title="Copy raw log">⎘</button>
<span class="font-mono text-xs text-gray-300 break-all" title="${esc(raw)}">${esc(raw.slice(0, 200))}${raw.length > 200 ? '<span class="text-gray-600">…</span>' : ''}</span>
</div>
</td>`
return `<tr class="border-b border-white/5 hover:bg-white/[0.03] transition-colors">${msgCell}</tr>`
}).join('')
// Parsed-fields summary (collapsible pill list, not a wide table)
const fieldPills = hasParsedFields
? `<details class="mt-3">
<summary class="text-xs text-gray-600 cursor-pointer hover:text-gray-400">${otherFields.length} parsed fields available in sample ▸</summary>
<div class="flex flex-wrap gap-1 mt-2">${otherFields.map(f =>
`<span class="px-1.5 py-0.5 rounded-md bg-slate-800/80 ring-1 ring-white/5 text-slate-500 text-xs font-mono">${esc(f)}</span>`
).join('')}</div>
</details>` : ''
document.getElementById('qs-result').innerHTML = `
<div class="flex items-center justify-between mb-2">
<p class="text-xs text-slate-500">${r.count} events · ${r.hours}h window</p>
<button onclick="qsCopyAll(this)" class="px-2 py-1 text-xs bg-slate-700 hover:bg-slate-600 ring-1 ring-white/10 rounded-lg text-gray-300 flex items-center gap-1 transition-colors">⎘ Copy All</button>
</div>
<div class="max-h-72 overflow-y-auto rounded-xl ring-1 ring-white/5">
<table class="text-xs w-full">
<thead class="sticky top-0 bg-slate-900 text-slate-500 border-b border-white/5">
<tr><th class="pb-2 px-2 pt-2 text-left font-medium text-xs uppercase tracking-wide">Raw Log</th></tr>
</thead>
<tbody>${rows}</tbody>
</table>
</div>
${fieldPills}`
} catch(e) {
document.getElementById('qs-result').innerHTML = errBox(e.message)
} finally { setBtn('btn-qs', false, 'Sample') }
}
function qsCopyMsg(btn, idx) {
const msg = btn.dataset.msg
navigator.clipboard.writeText(msg).then(() => {
const orig = btn.textContent
btn.textContent = '✓'
btn.classList.add('text-emerald-400')
setTimeout(() => { btn.textContent = orig; btn.classList.remove('text-emerald-400') }, 1500)
})
}
function qsCopyAll(btn) {
const _rawMsgKeys = ['message', 'unmapped.message', 'unmapped.message_']
const msgs = _qsEvents
.map(ev => {
for (const k of _rawMsgKeys) {
const v = ev[k]
if (v != null && v !== '' && v !== 'null') return v
}
const rest = Object.fromEntries(Object.entries(ev).filter(([k]) => !_rawMsgKeys.includes(k)))
return JSON.stringify(rest)
})
.filter(Boolean)
.join('\n')
navigator.clipboard.writeText(msgs).then(() => {
const orig = btn.innerHTML
btn.innerHTML = '✓ Copied!'
btn.classList.add('text-emerald-400')
setTimeout(() => { btn.innerHTML = orig; btn.classList.remove('text-emerald-400') }, 1500)
})
}
// ── Field Population Rate ──────────────────────────────────────────────────
const QP_SDL_DEFAULTS = [
'src.ip','dst.ip','src.port','dst.port','user.name',
'event.type','src.process.name','src.process.cmdline',
'tgt.file.path','network.direction'
]
async function qpDiscoverFields() {
const source = document.getElementById('qp-source').value
if (!source) return
const hours = +document.getElementById('qp-hours').value || 24
const fieldsEl = document.getElementById('qp-fields')
fieldsEl.placeholder = 'Discovering fields…'
fieldsEl.value = ''
try {
const r = await apiPost('/api/quality/sample-events', { source, limit: 20, hours })
const seen = [...new Set(r.events.flatMap(e => Object.keys(e)))]
.filter(f => f !== 'timestamp')
.sort()
// Merge log fields with SDL defaults, log fields first
const merged = [...new Set([...seen, ...QP_SDL_DEFAULTS])]
fieldsEl.value = merged.join(', ')
} catch {
fieldsEl.value = QP_SDL_DEFAULTS.join(', ')
}
fieldsEl.placeholder = ''
}
async function qpAnalyze() {
const source = document.getElementById('qp-source').value
if (!source) { document.getElementById('qp-result').innerHTML = errBox('Select a source.'); return }
setBtn('btn-qp', true)
document.getElementById('qp-result').innerHTML = '<p class="text-gray-500 text-sm animate-pulse">Sampling events…</p>'
try {
const fieldsRaw = document.getElementById('qp-fields').value
const fields = fieldsRaw.split(',').map(f => f.trim()).filter(Boolean)
const r = await apiPost('/api/quality/field-population', {
source, hours: +document.getElementById('qp-hours').value, fields
})
if (r.total_sampled === 0) {
document.getElementById('qp-result').innerHTML = `<p class="text-gray-500 text-sm">${esc(r.message || 'No events found in the selected window.')}</p>`
return
}
const rows = r.fields.map(f => {
const pct = f.rate
const color = pct >= 80 ? 'bg-emerald-500' : pct >= 40 ? 'bg-amber-500' : 'bg-red-500'
const textColor = pct >= 80 ? 'text-emerald-400' : pct >= 40 ? 'text-amber-400' : 'text-red-400'
return `<tr class="border-b border-white/5 hover:bg-white/[0.02] transition-colors">
<td class="py-2.5 pr-4 font-mono text-xs text-gray-200">${esc(f.field)}</td>
<td class="py-2.5 pr-4 text-xs ${textColor} font-semibold w-16 tabular-nums">${pct}%</td>
<td class="py-2.5 pr-4 w-48">
<div class="h-1.5 bg-slate-800 rounded-full overflow-hidden">
<div class="h-full ${color} rounded-full transition-all" style="width:${pct}%"></div>
</div>
</td>
<td class="py-2.5 text-xs text-slate-600 tabular-nums">${f.populated.toLocaleString()} / ${f.total.toLocaleString()}</td>
</tr>`
}).join('')
document.getElementById('qp-result').innerHTML = `
<p class="text-xs text-slate-500 mb-3">${r.total_sampled} events sampled · ${r.hours}h window — sorted by best coverage first</p>
<table class="w-full mb-4">
<thead><tr class="text-left text-slate-500 border-b border-white/5">
<th class="pb-2 pr-4 text-xs font-medium uppercase tracking-wide">Field</th>
<th class="pb-2 pr-4 text-xs font-medium uppercase tracking-wide">Rate</th>
<th class="pb-2 pr-4 text-xs font-medium uppercase tracking-wide">Coverage</th>
<th class="pb-2 text-xs font-medium uppercase tracking-wide">Events</th>
</tr></thead>
<tbody>${rows}</tbody>
</table>
${r.fields_seen_in_sample?.length ? `
<div class="border-t border-white/5 pt-3">
<p class="text-xs text-slate-500 mb-1">Fields actually present in sample <span class="text-slate-600">(${r.fields_seen_in_sample.length} total)</span></p>
<div class="flex flex-wrap gap-1">${r.fields_seen_in_sample.map(f =>
`<span class="px-2 py-0.5 bg-slate-800/80 ring-1 ring-white/5 rounded-lg text-xs font-mono text-slate-400">${esc(f)}</span>`).join('')}
</div>
</div>` : ''}`
} catch(e) {
document.getElementById('qp-result').innerHTML = errBox(e.message)
} finally { setBtn('btn-qp', false, 'Analyze') }
}
// ── Parser Test Runner ─────────────────────────────────────────────────────
async function qtLoadParsers() {
try {
const r = await apiGet('/api/coverage/map')
const sources = (r.sources || []).sort((a, b) => b.event_count - a.event_count)
const parserNames = [...new Set(sources.map(s => s.parser).filter(p => p && p !== 'detected in data'))].sort()
// Populate source dropdowns — exclude internal S1 sources not relevant for parser quality
const sourcePlaceholder = '<option value="">— select a source —</option>'
const sourceOptions = sources
.filter(s => !(EXCLUDED_SOURCES.has(s.source_name) && !s.unlabelled))
.map(s =>
`<option value="${esc(s.source_name)}">${esc(s.source_name)} (${(s.event_count||0).toLocaleString()} events)</option>`
).join('')
const qsSel = document.getElementById('qs-source')
const qpSel = document.getElementById('qp-source')
if (qsSel) qsSel.innerHTML = sourcePlaceholder + sourceOptions
if (qpSel) qpSel.innerHTML = sourcePlaceholder + sourceOptions
// Populate parser dropdown from /app/parsers/ directory (not from coverage map)
const qtSel = document.getElementById('qt-parser')
if (qtSel) {
try {
const p = await apiGet('/api/quality/parsers')
qtSel.innerHTML = '<option value="">— select parser —</option>'
;(p.parsers || []).forEach(n => {
const o = document.createElement('option'); o.value = n; o.textContent = n; qtSel.appendChild(o)
})
if (!p.parsers || p.parsers.length === 0) {
qtSel.innerHTML = '<option value="">— no parser files in /app/parsers — use "Sync SDL Parsers" on the Coverage tab or upload manually —</option>'
}
} catch (err) {
qtSel.innerHTML = '<option value="">— could not load parsers: ' + esc(err.message || err) + ' —</option>'
}
}
} catch(e) {
// If no sources synced yet, fall back to empty state with hint
const hint = '<option value="">— sync sources on Coverage Map first —</option>'
const qsSel = document.getElementById('qs-source')
const qpSel = document.getElementById('qp-source')
if (qsSel) qsSel.innerHTML = hint
if (qpSel) qpSel.innerHTML = hint
}
}
async function qaLoadStubs() {
const el = document.getElementById('qa-stubs-result')
el.innerHTML = '<p class="text-gray-500 text-sm animate-pulse">Loading…</p>'
try {
const r = await apiGet('/api/coverage/stub-parsers')
const stubs = r.stubs || []
if (!stubs.length) {
el.innerHTML = '<p class="text-gray-500 text-sm">No parsers with missing attributes found — all loaded parsers have a dataSource.name set.</p>'
return
}
const rows = stubs.map((s, i) => `
<tr class="${i % 2 === 1 ? 'bg-white/[0.015]' : ''} border-b border-white/5 hover:bg-white/[0.04] transition-colors">
<td class="py-2.5 pr-4 font-mono text-xs text-amber-300">${esc(s.parser_name)}</td>
<td class="py-2.5 text-xs text-slate-500">
Add <code class="bg-slate-800 px-1 rounded-md text-amber-400">"dataSource.name": "…"</code> to the parser's attributes block
</td>
</tr>`).join('')
el.innerHTML = `
<p class="text-xs text-slate-500 mb-3">${stubs.length} parser${stubs.length !== 1 ? 's' : ''} missing dataSource.name</p>
<table class="w-full text-sm">
<thead><tr class="text-left text-slate-500 border-b border-white/5">
<th class="pb-2 pr-4 text-xs font-medium uppercase tracking-wide">Parser</th>
<th class="pb-2 text-xs font-medium uppercase tracking-wide">Fix</th>
</tr></thead>
<tbody>${rows}</tbody>
</table>`
} catch(e) {
el.innerHTML = `<p class="text-red-400 text-sm">${esc(e.message)}</p>`
}
}
async function qtTest() {
const parser = document.getElementById('qt-parser').value
const log = document.getElementById('qt-log').value.trim()
if (!parser) { document.getElementById('qt-result').innerHTML = errBox('Select a parser.'); return }
if (!log) { document.getElementById('qt-result').innerHTML = errBox('Paste a log line.'); return }
setBtn('btn-qt', true)
document.getElementById('qt-result').innerHTML = '<p class="text-gray-500 text-sm animate-pulse">Testing…</p>'
try {
const r = await apiPost('/api/quality/test-parser', { parser_name: parser, log_line: log })
if (!r.matched) {
document.getElementById('qt-result').innerHTML = `
<div class="p-3 bg-amber-900/30 border border-amber-700/50 rounded-lg text-sm text-amber-300">
${esc(r.message || 'No format pattern matched this log line.')}
<p class="text-xs text-amber-500 mt-1">The parser's format strings didn't produce a match. Check that the log sample matches the expected format, or that the parser uses grok/dottedJson which aren't tested here.</p>
</div>`
return
}
const extracts = (r.fields || []).filter(f => f.source !== 'rewrite')
const rewrites = (r.fields || []).filter(f => f.source === 'rewrite')
const rowsExtract = extracts.map((f, i) => `<tr class="${i % 2 === 1 ? 'bg-white/[0.015]' : ''} border-b border-white/5 hover:bg-white/[0.03] transition-colors">
<td class="py-1.5 pr-4 font-mono text-xs text-purple-300">${esc(f.field)}</td>
<td class="py-1.5 font-mono text-xs text-gray-200">${esc(String(f.value))}</td>
</tr>`).join('')
const rowsRewrite = rewrites.map((f, i) => `<tr class="${i % 2 === 1 ? 'bg-white/[0.015]' : ''} border-b border-white/5 hover:bg-white/[0.03] transition-colors">
<td class="py-1.5 pr-4 font-mono text-xs text-emerald-300">${esc(f.field)}</td>
<td class="py-1.5 font-mono text-xs text-gray-200">${esc(String(f.value))}</td>
</tr>`).join('')
const modeBadge = r.mode === 'json'
? '<span class="px-2 py-0.5 ml-2 text-xs rounded bg-purple-900/60 border border-purple-700 text-purple-300">JSON auto-extract</span>'
: '<span class="px-2 py-0.5 ml-2 text-xs rounded bg-blue-900/60 border border-blue-700 text-blue-300">regex format</span>'
const counts = r.mode === 'json'
? `<span class="text-gray-500">${r.extracted_count} extracted · ${r.derived_count} rewritten` +
(r.payload_count > 1 ? ` · showing payload ${r.showing_payload}/${r.payload_count}` : '') +
`</span>` : ''
const parseWarn = (r.parse_errors && r.parse_errors.length)
? `<div class="mt-2 p-2 bg-amber-900/30 border border-amber-700/50 rounded text-xs text-amber-300">
${r.parse_errors.length} line(s) skipped: ${r.parse_errors.slice(0,3).map(esc).join(' | ')}${r.parse_errors.length>3?' …':''}
</div>` : ''
document.getElementById('qt-result').innerHTML = `
<div class="mb-3 p-3 bg-slate-800/50 ring-1 ring-white/5 rounded-xl text-xs text-slate-500 font-mono break-all">
<span class="text-slate-600">Matched format: </span>${esc(r.format_matched)} ${modeBadge}
<div class="mt-1">${counts}</div>
${parseWarn}
</div>
<table class="w-full mb-4">
<thead><tr class="text-left text-slate-500 border-b border-white/5">
<th class="pb-2 pr-4 text-xs font-medium uppercase tracking-wide">Extracted Field</th>
<th class="pb-2 text-xs font-medium uppercase tracking-wide">Value</th>
</tr></thead>
<tbody>${rowsExtract}</tbody>
</table>
${rewrites.length ? `
<h4 class="text-xs font-semibold text-emerald-300 mb-2">Derived (rewrites applied — ${rewrites.length})</h4>
<table class="w-full">
<thead><tr class="text-left text-slate-500 border-b border-white/5">
<th class="pb-2 pr-4 text-xs font-medium uppercase tracking-wide">Output Field</th>
<th class="pb-2 text-xs font-medium uppercase tracking-wide">Value</th>
</tr></thead>
<tbody>${rowsRewrite}</tbody>
</table>` : ''}`
} catch(e) {
document.getElementById('qt-result').innerHTML = errBox(e.message)
} finally { setBtn('btn-qt', false, 'Test') }
}
// ── PowerQuery Playground ─────────────────────────────────────────────────
let _pqResults = [] // last query results for CSV export
let _pqHistory = [] // localStorage history
function _pqLoadHistory() {
try { _pqHistory = JSON.parse(localStorage.getItem('pq_history') || '[]') } catch { _pqHistory = [] }
}
function _pqSaveHistory(q) {
_pqHistory = [q, ..._pqHistory.filter(h => h !== q)].slice(0, 10)
try { localStorage.setItem('pq_history', JSON.stringify(_pqHistory)) } catch {}
}
function _pqRenderHistory() {
const el = document.getElementById('pq-history')
if (!el) return
if (!_pqHistory.length) { el.innerHTML = ''; return }
el.innerHTML = `<div class="flex flex-wrap gap-1.5 mb-2">
${_pqHistory.map((q, i) => `
<button onclick="pqFillQuery(${i})"
class="px-2.5 py-1 text-xs bg-slate-800/80 hover:bg-slate-700/80 ring-1 ring-white/5 rounded-lg text-slate-400 hover:text-gray-200 transition-colors font-mono truncate max-w-xs"
title="${esc(q)}">${esc(q.slice(0, 60))}${q.length > 60 ? '…' : ''}</button>`).join('')}
</div>`
}
function pqFillQuery(idx) {
const ta = document.getElementById('pq-query')
if (ta) ta.value = _pqHistory[idx] || ''
}
async function renderQuery() {
_pqLoadHistory()
set(`<div class="p-8 max-w-5xl space-y-6">
<div>
<h1 class="text-xl font-extrabold text-white tracking-tight">PowerQuery Playground</h1>
<p class="text-sm text-slate-400 mt-1">Run Scalyr PowerQueries directly against the Singularity Data Lake</p>
</div>
<!-- Controls row -->
<div class="bg-gradient-to-b from-gray-900 to-gray-950 ring-1 ring-white/5 rounded-xl p-5 shadow-sm">
<div class="flex flex-wrap items-center gap-3 mb-4">
<span class="text-xs text-slate-500 uppercase tracking-wide shrink-0">Time range</span>
${[['1h','hours',1],['6h','hours',6],['24h','hours',24],['7d','days',7],['30d','days',30]].map(([lbl,unit,val]) =>
`<button onclick="pqSetRange('${unit}',${val})" id="pq-range-${unit}-${val}"
class="px-3 py-1.5 text-xs rounded-lg border border-white/10 text-slate-400 hover:border-white/20 hover:text-gray-200 transition-colors">${lbl}</button>`
).join('')}
<div class="flex items-center gap-2 ml-auto">
<label class="text-xs text-slate-500">Max results</label>
<input id="pq-max" type="number" value="1000" min="1" max="10000"
class="w-24 bg-slate-800/80 border border-white/10 rounded-lg px-2 py-1.5 text-xs text-gray-300 focus:outline-none focus:border-purple-500 transition-colors">
</div>
</div>
<!-- Preset queries -->
<div id="pq-presets" class="flex flex-wrap gap-1.5 mb-4">
<span class="text-xs text-slate-600 self-center">Loading presets…</span>
</div>
<!-- Query history -->
<div id="pq-history" class="mb-2"></div>
<!-- Query textarea -->
<textarea id="pq-query" rows="8" placeholder="| group events=count() by dataSource.name | sort -events | limit 25"
class="w-full bg-slate-900/80 border border-white/10 rounded-lg px-3 py-2 text-sm text-gray-200 font-mono placeholder-slate-600 focus:outline-none focus:border-purple-500 focus:ring-1 focus:ring-purple-500/30 transition-colors mb-3 resize-y"></textarea>
<div class="flex items-center gap-3">
<button onclick="pqRun()" id="btn-pq-run"
class="px-5 py-2 text-sm bg-purple-700 hover:bg-purple-600 rounded-lg text-white font-medium transition-colors shadow-sm">Run Query</button>
<button onclick="pqExportCSV()" id="btn-pq-csv"
class="px-4 py-2 text-sm bg-slate-700 hover:bg-slate-600 ring-1 ring-white/10 rounded-lg text-gray-300 font-medium transition-colors hidden">Export CSV</button>
<span id="pq-row-info" class="text-xs text-slate-500"></span>
</div>
</div>
<!-- Results -->
<div id="pq-results"></div>
</div>`)
// Active time-range: default 24h
window._pqRangeUnit = 'hours'
window._pqRangeVal = 24
pqHighlightRange()
_pqRenderHistory()
// Load presets
try {
const p = await apiGet('/api/query/presets')
const presetsEl = document.getElementById('pq-presets')
if (presetsEl && p.presets?.length) {
presetsEl.innerHTML = p.presets.map((pr, i) =>
`<button onclick="pqUsePreset(${i})"
class="px-2.5 py-1 text-xs bg-purple-900/40 hover:bg-purple-800/60 ring-1 ring-purple-700/40 rounded-lg text-purple-300 hover:text-purple-200 transition-colors"
data-query="${esc(pr.query)}">${esc(pr.label)}</button>`
).join('')
window._pqPresets = p.presets
}
} catch {}
}
function pqHighlightRange() {
document.querySelectorAll('[id^="pq-range-"]').forEach(b => {
const id = b.id.replace('pq-range-', '')
const [unit, val] = id.split('-')
const active = unit === window._pqRangeUnit && +val === window._pqRangeVal
b.className = `px-3 py-1.5 text-xs rounded-lg border transition-colors ${active ? 'bg-purple-700/80 border-purple-500 text-white shadow-sm' : 'border-white/10 text-slate-400 hover:border-white/20 hover:text-gray-200'}`
})
}
function pqSetRange(unit, val) {
window._pqRangeUnit = unit
window._pqRangeVal = val
pqHighlightRange()
}
function pqUsePreset(idx) {
const preset = (window._pqPresets || [])[idx]
if (!preset) return
const ta = document.getElementById('pq-query')
if (ta) ta.value = preset.query
}
async function pqRun() {
const query = document.getElementById('pq-query')?.value?.trim()
if (!query) return
setBtn('btn-pq-run', true, 'Running…')
document.getElementById('pq-results').innerHTML = '<p class="text-slate-500 text-sm animate-pulse">Querying data lake…</p>'
document.getElementById('btn-pq-csv')?.classList.add('hidden')
document.getElementById('pq-row-info').textContent = ''
try {
const body = {
query,
max_count: Math.min(+(document.getElementById('pq-max')?.value || 1000), 10000),
}
if (window._pqRangeUnit === 'hours') body.hours = window._pqRangeVal
else body.days = window._pqRangeVal
const r = await apiPost('/api/query/run', body)
_pqResults = r.events || []
_pqSaveHistory(query)
_pqRenderHistory()
const rowInfoEl = document.getElementById('pq-row-info')
if (rowInfoEl) rowInfoEl.textContent = `${r.rows} row${r.rows !== 1 ? 's' : ''} · ${r.from?.slice(0,10)}${r.to?.slice(0,10)}`
if (!_pqResults.length) {
document.getElementById('pq-results').innerHTML = '<p class="text-slate-500 text-sm">No results returned.</p>'
return
}
const cols = r.columns || []
const MAX_ROWS = 500
const displayRows = _pqResults.slice(0, MAX_ROWS)
const headers = cols.map(c => `<th class="py-2 px-3 font-medium text-xs uppercase tracking-wide text-left whitespace-nowrap">${esc(c)}</th>`).join('')
const rows = displayRows.map((ev, i) =>
`<tr class="${i % 2 === 1 ? 'bg-white/[0.015]' : ''} border-b border-white/5 hover:bg-white/[0.04] transition-colors">
${cols.map(c => `<td class="py-2 px-3 text-xs text-gray-300 font-mono max-w-xs truncate" title="${esc(String(ev[c] ?? ''))}">${esc(String(ev[c] ?? ''))}</td>`).join('')}
</tr>`
).join('')
document.getElementById('pq-results').innerHTML = `
<div class="bg-gradient-to-b from-gray-900 to-gray-950 ring-1 ring-white/5 rounded-xl overflow-hidden shadow-sm">
${_pqResults.length > MAX_ROWS ? `<div class="px-4 py-2 bg-amber-950/40 border-b border-amber-800/30 text-xs text-amber-400">Showing first ${MAX_ROWS} of ${_pqResults.length} rows</div>` : ''}
<div class="overflow-x-auto max-h-[480px] overflow-y-auto">
<table class="w-full text-sm">
<thead class="sticky top-0 bg-slate-900 border-b border-white/5 text-slate-500">
<tr>${headers}</tr>
</thead>
<tbody>${rows}</tbody>
</table>
</div>
</div>`
document.getElementById('btn-pq-csv')?.classList.remove('hidden')
} catch(e) {
document.getElementById('pq-results').innerHTML = `<div class="p-3 bg-red-950/60 ring-1 ring-red-700/50 rounded-xl text-sm text-red-300">${esc(e.message)}</div>`
} finally {
setBtn('btn-pq-run', false, 'Run Query')
}
}
function pqExportCSV() {
if (!_pqResults.length) return
const cols = Object.keys(_pqResults[0])
const header = cols.map(c => JSON.stringify(c)).join(',')
const rows = _pqResults.map(row => cols.map(c => JSON.stringify(row[c] ?? '')).join(',')).join('\n')
const csv = header + '\n' + rows
const url = URL.createObjectURL(new Blob([csv], { type: 'text/csv' }))
const a = document.createElement('a')
a.href = url; a.download = 'pq_results.csv'; a.click()
URL.revokeObjectURL(url)
}
// ── Threat Coverage ───────────────────────────────────────────────────────
function renderThreat() {
set(`<div class="p-8 max-w-6xl space-y-8">
<div>
<h1 class="text-xl font-extrabold text-white tracking-tight">Threat Coverage</h1>
<p class="text-sm text-slate-400 mt-1">MITRE ATT&amp;CK heatmap and detection rule firing status</p>
</div>
<!-- MITRE ATT&CK Heatmap -->
<div>
<div class="flex items-start justify-between mb-4">
<div>
<h2 class="text-base font-semibold text-white">MITRE ATT&amp;CK Coverage</h2>
<p class="text-xs text-slate-500 mt-0.5">Detection rules mapped to ATT&amp;CK tactics and techniques</p>
</div>
<button id="btn-sync-library-threat" onclick="syncLibraryThreat()"
class="px-3 py-1.5 text-sm bg-blue-700 hover:bg-blue-600 rounded-lg text-white font-medium transition-colors shadow-sm">
Sync Detection Library
</button>
</div>
<div id="mitre-stats" class="grid grid-cols-2 md:grid-cols-4 gap-3 mb-6"></div>
<div id="mitre-err"></div>
<div id="mitre-grid" class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
<div class="col-span-full text-slate-600 text-sm text-center py-8">Loading MITRE coverage…</div>
</div>
</div>
<!-- Rule Firing Status -->
<div>
<div class="flex items-start justify-between mb-4">
<div>
<h2 class="text-base font-semibold text-white">Rule Firing Status</h2>
<p class="text-xs text-slate-500 mt-0.5">Rules that have never triggered an alert may be misconfigured or require data sources not yet active.</p>
</div>
<div class="flex items-center gap-2">
<select id="firing-period" class="bg-slate-800/80 border border-white/10 rounded-lg px-2 py-1.5 text-sm text-gray-300 focus:outline-none focus:border-purple-500 transition-colors">
<option value="7">Last 7d</option>
<option value="14">Last 14d</option>
<option value="30" selected>Last 30d</option>
<option value="60">Last 60d</option>
<option value="90">Last 90d</option>
</select>
<button id="btn-sync-firing" onclick="syncRuleFiring()"
class="px-3 py-1.5 text-sm bg-emerald-700 hover:bg-emerald-600 rounded-lg text-white font-medium transition-colors shadow-sm">
Sync Alert Firing Status
</button>
</div>
</div>
<div id="firing-err"></div>
<div id="firing-summary"></div>
<div id="firing-table"></div>
</div>
<!-- Rule Dependency Map -->
<div>
<div class="flex items-start justify-between mb-4">
<div>
<h2 class="text-base font-semibold text-white">Rule Dependency Map</h2>
<p class="text-xs text-slate-500 mt-0.5">Which data sources each detection rule requires — flags rules whose sources are missing or have no parser.</p>
</div>
<label class="flex items-center gap-2 text-xs text-slate-400 cursor-pointer select-none">
<input type="checkbox" id="depmap-at-risk-only" onchange="depMapRender()" class="rounded border-white/20 bg-slate-800 text-purple-500 focus:ring-purple-500/30">
Show At-Risk Only
</label>
</div>
<div id="depmap-stats" class="grid grid-cols-3 gap-3 mb-4"></div>
<div id="depmap-table"><p class="text-slate-600 text-sm text-center py-8 animate-pulse">Loading dependency map…</p></div>
</div>
</div>`)
loadMitre()
loadFiringStatus()
loadDependencyMap()
}
async function syncLibraryThreat() {
setBtn('btn-sync-library-threat', true)
const errEl = document.getElementById('mitre-err')
if (errEl) errEl.innerHTML = ''
try {
const r = await apiPost('/api/coverage/load-detections', {})
if (errEl) {
errEl.innerHTML = `<div class="p-3 bg-emerald-950/60 ring-1 ring-emerald-700/50 rounded-xl text-sm text-emerald-300 mb-4">&#x2713; ${r.loaded} detection rules synced from ${r.source === 'api' ? 'S1 API' : 'local file'}</div>`
setTimeout(() => { errEl.innerHTML = '' }, 4000)
}
loadMitre()
} catch(e) {
if (errEl) errEl.innerHTML = errBox(e.message)
} finally { setBtn('btn-sync-library-threat', false, 'Sync Detection Library') }
}
async function loadMitre() {
const gridEl = document.getElementById('mitre-grid')
const statsEl = document.getElementById('mitre-stats')
if (!gridEl) return
gridEl.innerHTML = '<div class="col-span-full text-slate-600 text-sm text-center py-8 animate-pulse">Loading MITRE coverage…</div>'
try {
const data = await apiGet('/api/coverage/mitre')
// Stats row
if (statsEl) {
statsEl.innerHTML = `
${statCard('Total Library Rules', data.total_rules, 'text-gray-200')}
${statCard('Rules with MITRE Mapping', data.rules_with_mitre, 'text-purple-400')}
${statCard('Tactics Covered', data.tactic_count, 'text-blue-400')}
${statCard('Techniques Covered', data.total_techniques, 'text-emerald-400')}`
}
if (!data.tactics || data.tactics.length === 0) {
gridEl.innerHTML = `<div class="col-span-full bg-gradient-to-b from-gray-900 to-gray-950 ring-1 ring-white/5 rounded-xl p-8 text-center text-slate-500 text-sm">
No MITRE data found. Sync the Detection Library to populate MITRE mappings.
</div>`
return
}
gridEl.innerHTML = data.tactics.map(t => {
const borderColor = t.rule_count >= 20 ? 'border-l-purple-500' : t.rule_count >= 5 ? 'border-l-blue-500' : 'border-l-slate-600'
const badgeColor = t.rule_count >= 20 ? 'bg-purple-900/60 text-purple-300 border-purple-700' : t.rule_count >= 5 ? 'bg-blue-900/60 text-blue-300 border-blue-700' : 'bg-slate-800/60 text-slate-400 border-slate-700'
const bodyId = 'mitre-body-' + t.tactic.replace(/[^a-z0-9]/gi, '_')
const chevId = 'mitre-chev-' + t.tactic.replace(/[^a-z0-9]/gi, '_')
const MAX_SHOWN = 12
const chips = t.techniques.slice(0, MAX_SHOWN).map(tech =>
`<span class="inline-flex items-center px-1.5 py-0.5 rounded-full text-xs bg-slate-800/80 ring-1 ring-white/5 text-slate-400 font-mono" title="${esc(tech.name)}">${esc(tech.id || tech.name)}</span>`
).join('')
const expandId = 'mitre-expand-' + t.tactic.replace(/[^a-z0-9]/gi, '_')
const moreChips = t.techniques.slice(MAX_SHOWN).map(tech =>
`<span class="inline-flex items-center px-1.5 py-0.5 rounded-full text-xs bg-slate-800/80 ring-1 ring-white/5 text-slate-400 font-mono" title="${esc(tech.name)}">${esc(tech.id || tech.name)}</span>`
).join('')
const moreSection = t.techniques.length > MAX_SHOWN ? `
<div id="${expandId}" class="hidden flex flex-wrap gap-1 mt-1">${moreChips}</div>
<button onclick="mitreToggleMore('${expandId}', this)"
class="mt-1.5 text-xs text-purple-500 hover:text-purple-400 transition-colors">+${t.techniques.length - MAX_SHOWN} more</button>` : ''
const techCount = t.techniques.length
const bodyContent = techCount > 0
? `<div class="flex flex-wrap gap-1">${chips}</div>${moreSection}`
: `<p class="text-xs text-slate-600">No technique IDs mapped</p>`
return `<div class="bg-gradient-to-b from-gray-900 to-gray-950 ring-1 ring-white/5 rounded-xl border-l-4 ${borderColor} shadow-sm overflow-hidden">
<button onclick="mitreToggleTactic('${bodyId}','${chevId}')" class="w-full flex items-center justify-between px-4 py-3 hover:bg-white/[0.03] transition-colors text-left">
<div class="flex items-center gap-2.5">
<span class="font-semibold text-white text-sm">${esc(t.tactic)}</span>
${techCount > 0 ? `<span class="text-xs text-slate-500">${techCount} technique${techCount !== 1 ? 's' : ''}</span>` : ''}
</div>
<div class="flex items-center gap-2 shrink-0">
<span class="px-2 py-0.5 rounded-full text-xs font-medium border ${badgeColor}">${t.rule_count} rule${t.rule_count !== 1 ? 's' : ''}</span>
<svg id="${chevId}" class="w-3.5 h-3.5 text-slate-500 transition-transform duration-200" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</div>
</button>
<div id="${bodyId}" class="hidden px-4 pb-3 pt-1 border-t border-white/5">${bodyContent}</div>
</div>`
}).join('')
} catch(e) {
if (gridEl) gridEl.innerHTML = `<div class="col-span-full text-red-400 text-sm">${esc(e.message)}</div>`
}
}
function mitreToggleTactic(bodyId, chevId) {
const body = document.getElementById(bodyId)
const chev = document.getElementById(chevId)
if (!body) return
const hidden = body.classList.toggle('hidden')
if (chev) chev.style.transform = hidden ? '' : 'rotate(180deg)'
}
function mitreToggleMore(id, btn) {
const el = document.getElementById(id)
if (!el) return
const hidden = el.classList.toggle('hidden')
btn.textContent = hidden ? '+' + btn.textContent.replace(/^[^ ]+\s*/, '') + ' more' : 'Show less'
// Re-calculate count when hiding
if (hidden) {
const count = el.querySelectorAll('span').length
btn.textContent = '+' + count + ' more'
}
}
async function syncRuleFiring() {
setBtn('btn-sync-firing', true)
const errEl = document.getElementById('firing-err')
if (errEl) errEl.innerHTML = ''
try {
const period = +document.getElementById('firing-period').value || 30
const r = await apiPost(`/api/coverage/sync-rule-firing?period_days=${period}`, {})
if (r.message) {
if (errEl) errEl.innerHTML = `<div class="p-3 bg-amber-950/60 ring-1 ring-amber-700/50 rounded-xl text-sm text-amber-300 mb-4">&#9888; ${esc(r.message)}</div>`
} else {
if (errEl) {
errEl.innerHTML = `<div class="p-3 bg-emerald-950/60 ring-1 ring-emerald-700/50 rounded-xl text-sm text-emerald-300 mb-4">&#x2713; Synced ${r.synced} rules over ${r.period_days}d</div>`
setTimeout(() => { errEl.innerHTML = '' }, 4000)
}
loadFiringStatus()
}
} catch(e) {
if (errEl) errEl.innerHTML = errBox(e.message)
} finally { setBtn('btn-sync-firing', false, 'Sync Alert Firing Status') }
}
async function loadFiringStatus() {
const summaryEl = document.getElementById('firing-summary')
const tableEl = document.getElementById('firing-table')
if (!summaryEl || !tableEl) return
try {
const data = await apiGet('/api/coverage/rule-firing-cache')
const s = data.summary
if (!data.rules || data.rules.length === 0) {
summaryEl.innerHTML = ''
tableEl.innerHTML = `<div class="bg-gradient-to-b from-gray-900 to-gray-950 ring-1 ring-white/5 rounded-xl p-6 text-center text-sm text-slate-500">
No firing data cached yet. Click <strong class="text-slate-300">Sync Alert Firing Status</strong> to query the SDL.
</div>`
return
}
summaryEl.innerHTML = `
<div class="grid grid-cols-3 gap-3 mb-4">
${statCard('Rules Monitored', s.rules_monitored, 'text-gray-200')}
${statCard(`Fired in ${s.period_days}d`, s.fired_in_period, 'text-emerald-400')}
${statCard('Never Fired', s.never_fired, s.never_fired > 0 ? 'text-amber-400' : 'text-gray-500')}
</div>`
const top20 = data.rules.slice(0, 20)
const rows = top20.map((r, i) => {
const badge = r.alert_count > 0
? `<span class="px-2 py-0.5 rounded-full text-xs font-medium border bg-emerald-900/50 text-emerald-300 border-emerald-700">Active</span>`
: `<span class="px-2 py-0.5 rounded-full text-xs font-medium border bg-amber-900/50 text-amber-300 border-amber-700">Silent</span>`
return `<tr class="${i % 2 === 1 ? 'bg-white/[0.015]' : ''} border-b border-white/5 hover:bg-white/[0.04] transition-colors">
<td class="py-2.5 px-4 font-mono text-xs text-gray-200">${esc(r.rule_name)}</td>
<td class="py-2.5 px-4 text-xs text-slate-300 tabular-nums">${r.alert_count.toLocaleString()}</td>
<td class="py-2.5 px-4">${badge}</td>
</tr>`
}).join('')
tableEl.innerHTML = `
<div class="overflow-x-auto rounded-xl ring-1 ring-white/5">
<table class="w-full text-sm">
<thead><tr class="text-left text-slate-500 bg-slate-900/60 border-b border-white/5">
<th class="py-3 px-4 font-medium text-xs uppercase tracking-wide">Rule Name</th>
<th class="py-3 px-4 font-medium text-xs uppercase tracking-wide">Alerts (${s.period_days}d)</th>
<th class="py-3 px-4 font-medium text-xs uppercase tracking-wide">Status</th>
</tr></thead>
<tbody>${rows}</tbody>
</table>
</div>
${s.checked_at ? `<p class="text-xs text-slate-600 mt-2">Last synced: ${new Date(s.checked_at).toLocaleString()}</p>` : ''}`
} catch(e) {
if (tableEl) tableEl.innerHTML = `<p class="text-red-400 text-sm">${esc(e.message)}</p>`
}
}
// ── Dependency Map ────────────────────────────────────────────────────────
let _depMapData = null
async function loadDependencyMap() {
const statsEl = document.getElementById('depmap-stats')
const tableEl = document.getElementById('depmap-table')
if (!statsEl || !tableEl) return
tableEl.innerHTML = '<p class="text-slate-600 text-sm text-center py-8 animate-pulse">Loading dependency map…</p>'
try {
_depMapData = await apiGet('/api/coverage/dependency-map')
if (statsEl) {
statsEl.innerHTML = `
${statCard('At Risk', _depMapData.at_risk, _depMapData.at_risk > 0 ? 'text-red-400' : 'text-gray-500')}
${statCard('Healthy', _depMapData.healthy, 'text-emerald-400')}
${statCard('No Source Requirements', _depMapData.no_source_requirements, 'text-slate-400')}`
}
depMapRender()
} catch(e) {
if (tableEl) tableEl.innerHTML = `<p class="text-red-400 text-sm">${esc(e.message)}</p>`
}
}
function depMapRender() {
const tableEl = document.getElementById('depmap-table')
if (!tableEl || !_depMapData) return
const atRiskOnly = document.getElementById('depmap-at-risk-only')?.checked ?? false
let rules = _depMapData.rules || []
if (atRiskOnly) rules = rules.filter(r => r.at_risk)
// When filtering, also hide no-source rules unless explicitly showing all
const display = atRiskOnly ? rules : rules.filter(r => !r.no_sources).concat(rules.filter(r => r.no_sources))
if (!display.length) {
tableEl.innerHTML = '<p class="text-slate-600 text-sm text-center py-8">No rules match the current filter.</p>'
return
}
const SOURCE_STATUS_STYLE = {
healthy: 'bg-emerald-900/50 text-emerald-300 border-emerald-700',
inactive: 'bg-red-900/50 text-red-300 border-red-700',
no_parser:'bg-amber-900/50 text-amber-300 border-amber-700',
}
const rows = display.map((r, i) => {
const statusBadge = r.at_risk
? `<span class="px-2 py-0.5 rounded-full text-xs font-medium border bg-red-900/50 text-red-300 border-red-700">⚠ At Risk</span>`
: `<span class="px-2 py-0.5 rounded-full text-xs font-medium border bg-emerald-900/50 text-emerald-300 border-emerald-700">✓ Covered</span>`
const sourceBadges = r.no_sources
? `<span class="text-xs text-slate-600">—</span>`
: r.sources.map(s =>
`<span class="px-1.5 py-0.5 rounded border text-xs font-mono ${SOURCE_STATUS_STYLE[s.status] || ''}" title="${s.status}">${esc(s.source)}</span>`
).join(' ')
const alerts = r.generated_alerts != null
? `<span class="text-xs tabular-nums ${r.generated_alerts > 0 ? 'text-emerald-400' : 'text-slate-600'}">${r.generated_alerts.toLocaleString()}</span>`
: `<span class="text-xs text-slate-700">—</span>`
return `<tr class="${i % 2 === 1 ? 'bg-white/[0.015]' : ''} border-b border-white/5 hover:bg-white/[0.04] transition-colors">
<td class="py-2.5 px-4 text-xs text-gray-200 font-medium max-w-xs">${esc(r.rule)}</td>
<td class="py-2.5 px-4 text-xs"><div class="flex flex-wrap gap-1">${sourceBadges}</div></td>
<td class="py-2.5 px-4">${statusBadge}</td>
<td class="py-2.5 px-4">${alerts}</td>
</tr>`
}).join('')
tableEl.innerHTML = `
<div class="overflow-x-auto rounded-xl ring-1 ring-white/5">
<table class="w-full text-sm">
<thead><tr class="text-left text-slate-500 bg-slate-900/60 border-b border-white/5">
<th class="py-3 px-4 font-medium text-xs uppercase tracking-wide">Rule Name</th>
<th class="py-3 px-4 font-medium text-xs uppercase tracking-wide">Required Sources</th>
<th class="py-3 px-4 font-medium text-xs uppercase tracking-wide">Status</th>
<th class="py-3 px-4 font-medium text-xs uppercase tracking-wide">Alerts</th>
</tr></thead>
<tbody>${rows}</tbody>
</table>
</div>
<p class="text-xs text-slate-600 mt-2">Source badges: <span class="text-emerald-400">green</span> = healthy · <span class="text-red-400">red</span> = inactive · <span class="text-amber-400">amber</span> = no parser</p>`
}
// ── Router ────────────────────────────────────────────────────────────────
function set(html) { document.getElementById('main').innerHTML = html }
function updateNav(page) {
document.querySelectorAll('.nav-link').forEach(el => {
const on = el.dataset.page === page
el.className = `nav-link flex items-center px-3 py-2 rounded-lg text-sm cursor-pointer transition-colors ${on ? 'bg-purple-700/20 text-purple-300 border-l-2 border-purple-500 pl-[10px] font-medium' : 'text-slate-400 hover:bg-white/5 hover:text-gray-100 border-l-2 border-transparent pl-[10px]'}`
})
}
function route() {
const h = location.hash || '#/'
if (h === '#/coverage') { updateNav('coverage'); renderCoverage() }
else if (h === '#/ingest') { updateNav('ingest'); renderIngest() }
else if (h === '#/quality') { updateNav('quality'); renderQuality() }
else if (h === '#/onboarding') { updateNav('onboarding'); renderOnboarding() }
else if (h === '#/query') { updateNav('query'); renderQuery() }
else if (h === '#/threat') { updateNav('threat'); renderThreat() }
else if (h === '#/settings') { updateNav('settings'); renderSettings() }
else { updateNav('home'); renderHome() }
}
window.addEventListener('hashchange', route)
route()
</script>
</body>
</html>