Files
marcredhat-siem-toolkit-pat…/frontend/index.html
T
2026-05-19 12:46:30 -04:00

665 lines
35 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SIEM Toolkit</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-gray-950 text-gray-100 h-screen flex overflow-hidden font-sans">
<aside class="w-56 shrink-0 bg-gray-900 border-r border-gray-800 flex flex-col">
<div class="p-4 border-b border-gray-800">
<div class="flex items-center gap-2">
<div class="w-6 h-6 rounded bg-purple-600 flex items-center justify-center text-xs font-bold text-white">S1</div>
<span class="font-semibold text-sm text-white">SIEM Toolkit</span>
</div>
<p class="text-xs text-gray-500 mt-1">demo.sentinelone.net</p>
</div>
<nav class="flex-1 p-3 space-y-1">
<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="#/onboarding" data-page="onboarding" class="nav-link flex items-center px-3 py-2 rounded-lg text-sm cursor-pointer">Onboarding</a>
</nav>
<div class="p-3 border-t border-gray-800">
<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-gray-400 hover:bg-gray-800 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" id="main"></main>
<script>
const API = 'http://localhost:8001'
// ── Utilities ──────────────────────────────────────────────────────────────
async function apiFetch(path, opts = {}) {
const res = await fetch(API + path, opts)
if (!res.ok) throw new Error(await res.text())
return res.json()
}
const apiGet = path => apiFetch(path)
const apiPost = (path, body) => apiFetch(path, { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body) })
const apiForm = (path, form) => apiFetch(path, { method:'POST', body:form })
function esc(s) { return String(s ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;') }
function statCard(label, value, color = 'text-gray-200') {
return `<div class="bg-gray-900 border border-gray-800 rounded-lg p-4 text-center">
<div class="text-2xl font-bold ${color}">${esc(value)}</div>
<div class="text-xs text-gray-500 mt-1">${esc(label)}</div>
</div>`
}
function errBox(msg) {
return msg ? `<div class="p-3 bg-red-900/40 border border-red-700 rounded-lg 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-8">
<h1 class="text-2xl font-bold text-white">SIEM Engineering Toolkit</h1>
<p class="text-gray-400 mt-1">SentinelOne AI-SIEM · demo.sentinelone.net</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-5">
${homeCard('#/coverage','Parser Coverage Map','Cross-reference SDL parser fields against STAR and Sigma rule fields. Surface parsed-but-unused fields as reduction candidates.','Open Coverage Map','from-purple-700 to-purple-900')}
${homeCard('#/ingest','Ingest Dashboard','Visualize event volume by source and type. Project monthly GB costs and simulate exclusion filters before applying them.','Open Dashboard','from-blue-700 to-blue-900')}
${homeCard('#/onboarding','Onboarding Accelerator','Step-by-step guide for onboarding a new log source using Claude Code directly — no API key required.','View Guide','from-emerald-700 to-emerald-900')}
</div>
</div>`)
}
function homeCard(href, title, desc, cta, grad) {
return `<div class="bg-gray-900 border border-gray-800 rounded-xl p-6 flex flex-col gap-4">
<div class="w-10 h-10 rounded-lg bg-gradient-to-br ${grad}"></div>
<div><h2 class="font-semibold text-white">${esc(title)}</h2>
<p class="text-sm text-gray-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">${esc(cta)} →</a>
</div>`
}
// ── Coverage ──────────────────────────────────────────────────────────────
let cvFilter = 'all', cvData = null
function renderCoverage() {
set(`<div class="p-8 max-w-6xl">
<div class="flex items-start justify-between mb-6">
<div>
<h1 class="text-xl font-bold text-white">Parser Coverage Map</h1>
<p class="text-sm text-gray-400 mt-1">For each active data source — is a parser loaded?</p>
</div>
<div class="flex gap-2 flex-wrap justify-end">
<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">Sync Live Sources</button>
<button id="btn-star" onclick="loadStar()" class="px-3 py-1.5 text-sm bg-purple-700 hover:bg-purple-600 rounded-lg text-white">Load STAR Rules</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">Load SDL Parsers</button>
<button onclick="document.getElementById('f-parser').click()" class="px-3 py-1.5 text-sm bg-gray-700 hover:bg-gray-600 rounded-lg text-white">Upload Parser</button>
<button onclick="cvReset()" class="px-3 py-1.5 text-sm bg-red-900/60 hover:bg-red-800 rounded-lg text-red-300">Reset</button>
</div>
</div>
<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 loadSDLParsers() {
setBtn('btn-sdl-parsers', true)
document.getElementById('cv-err').innerHTML = ''
try {
const res = await apiPost('/api/coverage/load-parsers-from-sdl', {})
if (res.errors?.length) {
document.getElementById('cv-err').innerHTML = errBox(`${res.errors.length} parser(s) failed to load: ${res.errors.map(e=>e.parser).join(', ')}`)
}
cvLoad()
} catch(e) {
document.getElementById('cv-err').innerHTML = errBox(e.message)
} finally {
setBtn('btn-sdl-parsers', false, 'Load SDL Parsers')
}
}
async function loadStar() {
setBtn('btn-star', true)
document.getElementById('cv-err').innerHTML = ''
try { await apiPost('/api/coverage/load-star-rules', {}); cvLoad() }
catch(e) { document.getElementById('cv-err').innerHTML = errBox(e.message) }
finally { setBtn('btn-star', false, 'Load STAR Rules') }
}
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 apiGet('/api/coverage/reset'); cvData = null; cvLoad()
}
async function cvSyncSources() {
setBtn('btn-sync', true)
document.getElementById('cv-err').innerHTML = ''
try {
const r = await apiPost('/api/coverage/sync-sources?days=7', {})
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
document.getElementById('cv-stats').innerHTML = `
<div class="grid grid-cols-4 gap-3 mb-6">
${statCard('Active Sources', s.active_sources)}
${statCard('Covered', s.covered, 'text-emerald-400')}
${statCard('Parser Needed', s.parser_needed, 'text-red-400')}
${statCard('Parsers Loaded', s.parsers_loaded, 'text-purple-400')}
</div>`
if (!cvData.has_sources) {
document.getElementById('cv-filters').classList.add('hidden')
document.getElementById('cv-table').innerHTML = `
<div class="bg-gray-900/50 border border-gray-800 rounded-lg p-6 text-center text-sm text-gray-500">
<p class="mb-2">No active sources synced yet.</p>
<p>Click <strong class="text-gray-300">Sync Live Sources</strong> to pull current dataSource.names from the data lake, then <strong class="text-gray-300">Load STAR Rules</strong> and <strong class="text-gray-300">Load SDL Parsers</strong> to see coverage.</p>
</div>`
return
}
const filtersEl = document.getElementById('cv-filters')
filtersEl.classList.remove('hidden')
filtersEl.innerHTML = [['all','All'],['covered','Covered'],['parser_needed','Parser Needed']]
.map(([f,l]) => `<button onclick="cvSetFilter('${f}')" id="cvf-${f}"
class="px-3 py-1 text-xs rounded-full border border-gray-700 text-gray-400 hover:border-gray-500">${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 border-purple-600 text-white' : 'border-gray-700 text-gray-400 hover:border-gray-500'}`
})
if (!cvData?.sources) return
const LABELS = { covered: 'Covered', parser_needed: 'Parser Needed' }
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' }
const sources = cvData.sources.filter(s => f === 'all' || s.status === f)
function parserCell(s) {
if (s.status === 'covered') {
return `<span class="text-gray-400">${esc(s.parser)} <span class="text-gray-600">(${s.parser_fields} fields)</span></span>`
}
if (s.parser && s.format_type && s.format_type !== 'custom') {
return `<span class="text-amber-400 italic">⚠ ${esc(s.parser)} <span class="text-amber-600">(${esc(s.format_type)} — needs custom parser)</span></span>`
}
return `<span class="text-red-400 italic">⚠ No parser loaded</span>`
}
document.getElementById('cv-table').innerHTML = sources.length === 0
? '<p class="text-gray-600 text-sm">No sources match this filter.</p>'
: `<div class="overflow-x-auto"><table class="w-full text-sm">
<thead><tr class="text-left text-gray-500 border-b border-gray-800">
<th class="pb-2 pr-4 font-medium">Data Source</th>
<th class="pb-2 pr-4 font-medium">Events (7d)</th>
<th class="pb-2 pr-4 font-medium">Status</th>
<th class="pb-2 pr-4 font-medium">Parser</th>
<th class="pb-2 font-medium">STAR Rules</th>
</tr></thead>
<tbody>${sources.map(s => `
<tr class="border-b border-gray-800/50 hover:bg-gray-900/30">
<td class="py-2 pr-4 font-mono text-xs text-gray-200">${esc(s.source_name)}</td>
<td class="py-2 pr-4 text-xs text-gray-400">${(s.event_count||0).toLocaleString()}</td>
<td class="py-2 pr-4"><span class="px-2 py-0.5 rounded text-xs border ${STYLES[s.status]||''}">${LABELS[s.status]||s.status}</span></td>
<td class="py-2 pr-4 text-xs">${parserCell(s)}</td>
<td class="py-2 text-xs text-gray-400">${s.rules?.length ? s.rules.map(r=>esc(r.rule)).join(', ') : '—'}</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-bold text-white">Ingest Dashboard</h1>
<p class="text-sm text-gray-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-gray-700 text-gray-400 hover:border-gray-500">${b.label}</button>`).join('')}
</div>
</div>
<div id="ig-err"></div>
<div class="bg-gray-900 border border-gray-800 rounded-xl p-5 mb-5">
<div class="flex items-baseline gap-2 mb-4">
<h2 class="text-sm font-medium text-gray-300" id="ig-chart-title">Daily Event Volume</h2>
<span class="text-xs text-gray-600" id="ig-chart-sub">events ingested per day</span>
</div>
<div id="ig-chart"><p class="text-gray-600 text-sm">Loading…</p></div>
</div>
<div class="bg-gray-900 border border-gray-800 rounded-xl p-5 mb-5">
<h2 class="text-sm font-medium text-gray-300 mb-4">Top Sources</h2>
<div id="ig-sources"><p class="text-gray-600 text-sm">Loading…</p></div>
</div>
<div class="bg-gray-900 border border-gray-800 rounded-xl p-5">
<div class="flex items-start justify-between gap-4 mb-3">
<div>
<h2 class="text-sm font-medium text-gray-300">Filter Simulator</h2>
<p class="text-xs text-gray-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-gray-800/60 border border-gray-700/50 rounded-lg p-4 text-xs text-gray-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-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-gray-200 placeholder-gray-600 focus:outline-none focus:border-purple-600">
<input id="ig-evt" placeholder="Event type (optional)"
class="flex-1 min-w-48 bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-gray-200 placeholder-gray-600 focus:outline-none focus:border-purple-600">
<button onclick="igSimulate()" id="btn-sim"
class="px-4 py-2 text-sm bg-purple-700 hover:bg-purple-600 rounded-lg text-white transition-colors">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 border-purple-600 text-white' : 'border-gray-700 text-gray-400 hover:border-gray-500'}`
})
}
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 => {
const name = r['dataSource.name'] || r.name || 'unknown'
const evts = r.events || 0
return `<tr class="border-b border-gray-800/50">
<td class="py-2 font-mono text-xs text-gray-200">${esc(name)}</td>
<td class="py-2 text-right text-gray-300">${evts.toLocaleString()}</td>
<td class="py-2 text-right text-gray-400">${(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-gray-500 border-b border-gray-800">
<th class="pb-2 font-medium">Source</th>
<th class="pb-2 text-right font-medium">Events</th>
<th class="pb-2 text-right font-medium">Est. GB</th>
</tr></thead>
<tbody>${rows.join('')}</tbody>
</table>` : `<p class="text-gray-500 text-sm">No data in this period.</p>`
} else {
document.getElementById('ig-sources').innerHTML = `<p class="text-red-400 text-sm">${esc(sourcesResult.reason?.message ?? 'Error')}</p>`
}
}
function toggleSimHelp() {
const el = document.getElementById('sim-help')
el.classList.toggle('hidden')
}
async function igSimulate() {
setBtn('btn-sim', true)
try {
const r = await apiPost('/api/ingest/simulate-filter', {
source: document.getElementById('ig-src').value,
event_type: document.getElementById('ig-evt').value,
days: igDays, gb_per_million_events: 0.5
})
document.getElementById('ig-sim-result').innerHTML = `
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
${statCard('Matched Events', (r.matched_events||0).toLocaleString(), 'text-purple-300')}
${statCard(`Est. GB (${igDays}d)`, r.estimated_gb_period||0, 'text-purple-300')}
${statCard('Proj. Monthly Events', (r.projected_monthly_events||0).toLocaleString(), 'text-purple-300')}
${statCard('Proj. Monthly GB', r.projected_monthly_gb||0, 'text-purple-300')}
</div>`
} catch(e) {
document.getElementById('ig-sim-result').innerHTML = `<p class="text-red-400 text-sm">${esc(e.message)}</p>`
} finally { setBtn('btn-sim', false, 'Simulate') }
}
// ── Onboarding ────────────────────────────────────────────────────────────
const PROMPT = `Onboard this log source for SentinelOne SDL. Please generate:
1. An SDL parser skeleton in augmented-JSON format (/logParsers/ format)
2. Field mappings from raw fields to the SDL common schema
3. 23 starter STAR detection rules for common threats from this source type
4. 5 parser test assertions (input line → expected field → expected value)
Log source: [describe source, e.g. "Palo Alto PAN-OS firewall"]
Raw log sample:
[paste your log lines here]`
function renderOnboarding() {
set(`<div class="p-8 max-w-3xl">
<div class="mb-8">
<h1 class="text-xl font-bold text-white">Onboarding Accelerator</h1>
<p class="text-sm text-gray-400 mt-1">Use Claude Code directly — no API key required</p>
</div>
<div class="space-y-4 mb-8">
${obStep('1. Grab a log sample','Copy 1050 representative lines from the new log source. Include edge cases — errors, different event types, varying field presence.')}
${obStep('2. Paste into Claude Code','Open Claude Code and say "Onboard this log source for SentinelOne SDL" then paste the sample. Mention the source type if known.')}
${obStep('3. Get your artefacts','Claude returns an SDL parser (augmented-JSON), field mappings to the SDL schema, starter STAR detection rules, and parser test assertions.')}
${obStep('4. Deploy','Drop the parser JSON into your /logParsers/ path. Paste the STAR rules into the AI-SIEM rule editor. Run the test assertions to validate extraction.')}
</div>
<div class="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden">
<div class="px-4 py-2 border-b border-gray-800 flex items-center justify-between">
<span class="text-xs font-medium text-gray-400">Prompt template</span>
<button onclick="obCopy()" id="btn-copy" class="px-2 py-1 text-xs text-gray-400 hover:text-gray-200">Copy</button>
</div>
<pre class="p-4 text-xs text-gray-300 font-mono leading-relaxed whitespace-pre-wrap">${esc(PROMPT)}</pre>
</div>
</div>`)
}
function obStep(title, desc) {
return `<div class="flex gap-4 bg-gray-900 border border-gray-800 rounded-xl p-4">
<div class="w-8 h-8 shrink-0 rounded-lg bg-purple-900/60 flex items-center justify-center text-purple-300 text-xs mt-0.5">→</div>
<div>
<div class="text-sm font-medium text-white">${esc(title)}</div>
<div class="text-sm text-gray-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) }
}
// ── Settings ──────────────────────────────────────────────────────────────
async function renderSettings() {
set(`<div class="p-6 max-w-2xl mx-auto space-y-6">
<h1 class="text-xl font-bold text-white">Settings</h1>
<div id="st-content"><p class="text-gray-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/60 border border-blue-700/50 rounded-lg 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-gray-900 px-1 rounded">.env</code> file found. Create one from the template to get started:</p>
<div class="bg-gray-900 rounded p-3 font-mono text-xs text-green-300 space-y-1">
<div><span class="text-gray-500"># in your project directory:</span></div>
<div>cp .env.example .env</div>
<div><span class="text-gray-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">
<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-gray-900 border border-gray-700 rounded-lg px-3 py-2 text-sm text-gray-100 placeholder-gray-600 focus:outline-none focus:border-purple-500 font-${f.secret ? 'mono' : 'sans'}"
>
${f.secret ? `<button onclick="toggleSecret('st-${f.key}')" class="px-3 py-2 rounded-lg border border-gray-700 text-gray-400 hover:text-gray-100 text-xs hover:border-gray-500 transition-colors">Show</button>` : ''}
</div>
${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-gray-900/50 border border-gray-800 rounded-lg p-5 space-y-5">
<div class="flex items-center justify-between">
<h2 class="font-semibold text-white text-sm">Environment Configuration</h2>
<span class="text-xs text-gray-500 font-mono">${esc(envPath)}</span>
</div>
<p class="text-xs text-gray-500">Leave a field blank to keep its current value. Changes are written to <code class="bg-gray-800 px-1 rounded">.env</code> and take effect after restarting the backend (<code class="bg-gray-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">Save to .env</button>
<span id="st-msg" class="text-sm"></span>
</div>
</div>
<div class="bg-gray-900/50 border border-gray-800 rounded-lg p-5 space-y-3">
<h2 class="font-semibold text-white text-sm">Manual Setup</h2>
<p class="text-xs text-gray-400">Prefer editing the file directly? Copy the template and fill in your credentials:</p>
<div class="bg-gray-950 rounded-lg p-4 font-mono text-xs text-gray-300 space-y-1">
<div><span class="text-gray-600"># 1. Copy the template</span></div>
<div class="text-green-400">cp .env.example .env</div>
<div class="mt-2"><span class="text-gray-600"># 2. Open and edit</span></div>
<div class="text-green-400">nano .env</div>
<div class="mt-2"><span class="text-gray-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">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 => {
if (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') }
}
// ── 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 text-white' : 'text-gray-400 hover:bg-gray-800 hover:text-gray-100'}`
})
}
function route() {
const h = location.hash || '#/'
if (h === '#/coverage') { updateNav('coverage'); renderCoverage() }
else if (h === '#/ingest') { updateNav('ingest'); renderIngest() }
else if (h === '#/onboarding') { updateNav('onboarding'); renderOnboarding() }
else if (h === '#/settings') { updateNav('settings'); renderSettings() }
else { updateNav('home'); renderHome() }
}
window.addEventListener('hashchange', route)
route()
</script>
</body>
</html>