Add health score, coverage trends, dependency map, PowerQuery playground, onboarding tracker

Tenant Health Score:
- CoverageSnapshot table stores daily health metrics (parser %, MITRE %, firing %)
- _compute_health() weighted formula: 40% parser coverage + 35% MITRE + 25% firing
  (reweighted 55/45 when firing cache empty)
- GET /api/coverage/health returns score + delta vs previous snapshot
- GET /api/coverage/snapshots returns chronological history for sparklines
- POST /api/coverage/snapshot for manual recording
- Auto-snapshot recorded at end of every sync-sources call
- Overview dashboard: prominent health score card with color coding, component
  breakdown, delta indicator, and inline SVG sparkline (last 30 points)

Rule Dependency Map:
- GET /api/coverage/dependency-map flips the coverage map — rule → required sources
- Each source flagged healthy/inactive/no_parser; at_risk = any source missing
- New section on Threat Coverage tab with at-risk filter toggle

PowerQuery Playground:
- New query.py router: GET /presets (7 curated queries) + POST /run
- New Query nav tab with time-range pills, preset buttons, localStorage history,
  monospace textarea, auto-column results table, client-side CSV export

Onboarding Tracker:
- GET /api/coverage/onboarding-status returns per-source pipeline progress
  across 6 stages: Data Received → Parser File → Parser Active → Source
  Labeled → Detection Rules → Rules Firing
- New section on Onboarding tab with emoji stage dots, progress bars,
  collapsed completed sources with show/hide toggle

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Mick
2026-05-22 11:09:43 -04:00
parent b4314c07df
commit d0299e0f23
5 changed files with 916 additions and 6 deletions
+498 -3
View File
@@ -22,6 +22,10 @@
<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">
@@ -121,6 +125,29 @@ function renderHome() {
<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>
@@ -148,6 +175,7 @@ function renderHome() {
</div>
</div>`)
homeLoadStats()
loadHealthScore()
}
const EXCLUDED_SOURCES = new Set([
@@ -230,6 +258,69 @@ function homeCard(href, title, desc, cta, grad) {
</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) {
@@ -820,17 +911,35 @@ Raw log sample:
[paste your log lines here]`
function renderOnboarding() {
set(`<div class="p-8 max-w-3xl">
<div class="mb-8">
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>
<div class="space-y-4 mb-8">
<!-- 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>
</div>
<div id="ob-pipeline-stats" class="flex gap-3 mb-4">
<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"><p class="text-slate-600 text-sm animate-pulse text-center py-6">Loading pipeline…</p></div>
</div>
<!-- Steps -->
<div class="space-y-4">
${obStep('1. Grab a log sample','Copy 1050 representative lines from the new log source. Include edge cases — errors, different event types, varying field presence.')}
${obStep('2. Paste into Claude Code','Open Claude Code and say "Onboard this log source for SentinelOne SDL" then paste the sample. Mention the source type if known.')}
${obStep('3. Get your artefacts','Claude returns an SDL parser (augmented-JSON), field mappings to the SDL schema, starter STAR detection rules, and parser test assertions.')}
${obStep('4. Deploy','Drop the parser JSON into your /logParsers/ path. Paste the STAR rules into the AI-SIEM rule editor. Run the test assertions to validate extraction.')}
</div>
<!-- Prompt template -->
<div class="bg-gradient-to-b from-gray-900 to-gray-950 ring-1 ring-white/5 rounded-xl overflow-hidden shadow-sm">
<div class="px-4 py-3 border-b border-white/5 flex items-center justify-between bg-slate-900/40">
<span class="text-xs font-medium text-slate-400 uppercase tracking-wide">Prompt template</span>
@@ -839,6 +948,7 @@ function renderOnboarding() {
<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) {
@@ -857,6 +967,96 @@ function obCopy() {
if (b) { b.textContent = 'Copied!'; setTimeout(() => b.textContent = 'Copy', 1500) }
}
// ── Onboarding Pipeline ───────────────────────────────────────────────────
let _obShowCompleted = false
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')
const sources = data.sources || []
if (statsEl) {
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 incomplete = sources.filter(s => s.completed < s.total)
const complete = sources.filter(s => s.completed === s.total)
function renderRow(s, i) {
const stageDots = s.stages.map((st, si) =>
`<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>`
}
const completeCount = complete.length
const toggleBtn = completeCount > 0
? `<tr id="ob-complete-toggle-row" class="border-b border-white/5">
<td colspan="4" class="py-2 px-4">
<button onclick="obToggleCompleted()" class="text-xs text-slate-500 hover:text-gray-300 transition-colors">
<span id="ob-complete-toggle-label">${_obShowCompleted ? 'Hide' : 'Show'} completed (${completeCount})</span>
</button>
</td>
</tr>`
: ''
const completeRows = complete.map((s, i) => renderRow(s, i))
const completeSection = `<tbody id="ob-complete-rows" class="${_obShowCompleted ? '' : 'hidden'}">${completeRows.join('')}</tbody>`
tableEl.innerHTML = `
<div class="text-xs text-slate-600 mb-2 flex gap-4">
${STAGE_ICONS.map((icon, i) => `<span>${icon} Stage ${i+1}: ${['Data Received','Parser File','Parser Active','Source Labeled','Detection Rules','Rules Firing'][i]}</span>`).join('')}
</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>
${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>`
}
}
function obToggleCompleted() {
_obShowCompleted = !_obShowCompleted
const rows = document.getElementById('ob-complete-rows')
const label = document.getElementById('ob-complete-toggle-label')
if (rows) rows.classList.toggle('hidden', !_obShowCompleted)
if (label) {
const count = rows?.querySelectorAll('tr').length || 0
label.textContent = (_obShowCompleted ? 'Hide' : 'Show') + ` completed (${count})`
}
}
// ── Settings ──────────────────────────────────────────────────────────────
async function renderSettings() {
@@ -1407,6 +1607,201 @@ async function qtTest() {
} 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() {
@@ -1460,9 +1855,26 @@ function renderThreat() {
<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() {
@@ -1623,6 +2035,88 @@ async function loadFiringStatus() {
}
}
// ── 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 }
@@ -1640,6 +2134,7 @@ function route() {
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() }