mirror of
https://github.com/marcredhat/SIEM-toolkit-patched
synced 2026-06-08 20:37:12 +00:00
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:
+498
-3
@@ -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 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>
|
||||
@@ -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() }
|
||||
|
||||
Reference in New Issue
Block a user