mirror of
https://github.com/marcredhat/SIEM-toolkit-patched
synced 2026-06-08 12:33:51 +00:00
a7ebcac9a6
This reverts commit 7620d1fcc8.
2198 lines
118 KiB
HTML
2198 lines
118 KiB
HTML
<!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,'&').replace(/</g,'<').replace(/>/g,'>') }
|
||
|
||
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,'"')}"
|
||
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">⚠ ${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. 2–3 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 10–50 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 5–6 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, '"')}"
|
||
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&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&CK Coverage</h2>
|
||
<p class="text-xs text-slate-500 mt-0.5">Detection rules mapped to ATT&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">✓ ${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">⚠ ${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">✓ 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>
|