Files
marcredhat-siem-toolkit-pat…/frontend/index.html
T
Mick 735e364b71 Fix Ingest Dashboard timeout causing failed to fetch
- daily-volume: run per-day PowerQueries in parallel with asyncio.gather
  instead of sequentially with sleeps — 3 days now completes in ~16s vs 140s+
- Default view changed from 7d to 3d; day buttons updated to [3, 5, 7]
- igLoad: fire daily-volume and top-sources simultaneously with Promise.allSettled
  so both panels load in parallel rather than one after the other
- Each panel shows "Querying data lake…" spinner while loading
- Each panel renders independently — one failure doesn't block the other

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 11:53:37 -04:00

548 lines
29 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 max = Math.max(...rows.map(r => r[valueKey] || 0), 1)
const W = 680, H = 140, padL = 10, padB = 24
const bw = Math.max(4, Math.floor((W - padL) / rows.length) - 3)
const bars = rows.map((r, i) => {
const bh = Math.floor(((r[valueKey] || 0) / max) * (H - padB - 4))
const x = padL + i * (bw + 3)
const y = H - padB - bh
const lbl = esc(String(r[labelKey] || '').slice(0, 10))
return `<rect x="${x}" y="${y}" width="${bw}" height="${bh}" fill="#7c3aed" rx="2"/>
<text x="${x + bw/2}" y="${H - 4}" text-anchor="middle" fill="#6b7280" font-size="8">${lbl}</text>`
}).join('')
return `<svg viewBox="0 0 ${W} ${H}" class="w-full">${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">Cross-reference SDL parser fields against STAR / Sigma rule fields</p>
</div>
<div class="flex gap-2 flex-wrap justify-end">
<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-sigma').click()" class="px-3 py-1.5 text-sm bg-gray-700 hover:bg-gray-600 rounded-lg text-white">Upload Sigma Rules</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-sigma" accept=".yml,.yaml" multiple class="hidden" onchange="cvUploadSigma(this.files)">
<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 cvLoad() {
try {
cvData = await apiGet('/api/coverage/map')
const s = cvData.summary
document.getElementById('cv-stats').innerHTML = `
<div class="grid grid-cols-5 gap-3 mb-6">
${statCard('Parser Fields', s.total_parser_fields)}
${statCard('Rule Fields', s.total_rule_fields)}
${statCard('Covered', s.covered, 'text-emerald-400')}
${statCard('Parsed Unused', s.parsed_but_unused, 'text-yellow-400')}
${statCard('Missing Parser', s.rules_missing_parser, 'text-red-400')}
</div>`
const filtersEl = document.getElementById('cv-filters')
filtersEl.classList.remove('hidden')
filtersEl.innerHTML = [['all','All'],['covered','Covered'],['unused','Parsed Unused'],['missing_parser','Missing Parser']]
.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('')
cvSetFilter(cvFilter)
} catch {
document.getElementById('cv-table').innerHTML = '<p class="text-gray-600 text-sm">Load STAR rules or upload parsers to begin.</p>'
}
}
function suggestParser(field, dataSources) {
if (dataSources && dataSources.length) {
return 'Parser needed for: ' + dataSources.join(', ')
}
// Fallback if no dataSource.name found in rule queries
const f = field.toLowerCase()
if (f.startsWith('wineventlog')) return 'Windows Event Log (WEL) parser'
if (f.startsWith('event.')) return 'Event normalisation parser'
if (f.includes('dns')) return 'DNS log parser'
if (f.includes('process')) return 'Endpoint process parser'
return 'Custom parser needed'
}
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) return
const LABELS = { covered:'Covered', unused:'Parsed Unused', missing_parser:'Missing Parser' }
const STYLES = { covered:'bg-emerald-900/50 text-emerald-300 border-emerald-700', unused:'bg-yellow-900/50 text-yellow-300 border-yellow-700', missing_parser:'bg-red-900/50 text-red-300 border-red-700' }
const fields = Object.entries(cvData.fields).filter(([,d]) => f === 'all' || d.status === f)
const showSuggest = f === 'missing_parser' || f === 'all'
document.getElementById('cv-table').innerHTML = fields.length === 0
? '<p class="text-gray-600 text-sm">No fields 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">Field</th>
<th class="pb-2 pr-4 font-medium">Status</th>
<th class="pb-2 pr-4 font-medium">Parser / Suggestion</th>
<th class="pb-2 font-medium">Blocked rules</th>
</tr></thead>
<tbody>${fields.map(([field, d]) => `
<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(field)}</td>
<td class="py-2 pr-4"><span class="px-2 py-0.5 rounded text-xs border ${STYLES[d.status]||''}">${LABELS[d.status]||d.status}</span></td>
<td class="py-2 pr-4 text-xs ${d.status === 'missing_parser' ? 'text-amber-400 italic' : 'text-gray-400'}">
${d.status === 'missing_parser' ? '⚠ ' + esc(suggestParser(field, d.data_sources)) : esc(d.parser_name || '—')}
</td>
<td class="py-2 text-xs text-gray-400">${d.rules?.length ? d.rules.map(r=>esc(r.rule)).join(', ') : '—'}</td>
</tr>`).join('')}
</tbody></table></div>`
}
// ── Ingest ────────────────────────────────────────────────────────────────
let igDays = 3
function renderIngest() {
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">
${[3,5,7].map(d=>`<button onclick="igSetDays(${d})" id="ig-d${d}"
class="px-3 py-1.5 text-xs rounded-lg border border-gray-700 text-gray-400 hover:border-gray-500">${d}d</button>`).join('')}
</div>
</div>
<div id="ig-err"></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">Daily Event Volume</h2>
<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">
<h2 class="text-sm font-medium text-gray-300 mb-1">Filter Simulator</h2>
<p class="text-xs text-gray-500 mb-4">Estimate events and GB eliminated by dropping a source + event type combination.</p>
<div class="flex gap-3 flex-wrap mb-4">
<input id="ig-src" placeholder="Source name (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">
<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">Simulate</button>
</div>
<div id="ig-sim-result"></div>
</div>
</div>`)
igSetDays(igDays)
}
function igSetDays(d) {
igDays = d
;[3,5,7].forEach(n => {
const b = document.getElementById(`ig-d${n}`)
if (b) b.className = `px-3 py-1.5 text-xs rounded-lg border transition-colors ${n===d ? 'bg-purple-700 border-purple-600 text-white' : 'border-gray-700 text-gray-400 hover:border-gray-500'}`
})
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 [dailyResult, sourcesResult] = await Promise.allSettled([
apiGet(`/api/ingest/daily-volume?days=${igDays}`),
apiGet(`/api/ingest/top-sources?days=${igDays}`)
])
// Daily volume chart
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>`
}
}
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>