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
+17
View File
@@ -57,6 +57,23 @@ class RuleFiringCache(Base):
checked_at = Column(DateTime, default=datetime.utcnow) checked_at = Column(DateTime, default=datetime.utcnow)
class CoverageSnapshot(Base):
__tablename__ = "coverage_snapshots"
id = Column(Integer, primary_key=True)
recorded_at = Column(DateTime, default=datetime.utcnow, index=True)
health_score = Column(Float, default=0.0)
parser_pct = Column(Float, default=0.0) # % sources with working parser
mitre_pct = Column(Float, default=0.0) # % ATT&CK tactics covered
firing_pct = Column(Float, default=0.0) # % rules that have fired
active_sources = Column(Integer, default=0)
covered_sources = Column(Integer, default=0)
rules_loaded = Column(Integer, default=0)
tactics_covered = Column(Integer, default=0)
techniques_covered = Column(Integer, default=0)
rules_with_mitre = Column(Integer, default=0)
rules_fired = Column(Integer, default=0)
def get_db(): def get_db():
db = SessionLocal() db = SessionLocal()
try: try:
+20 -2
View File
@@ -1,7 +1,7 @@
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from db import engine, Base, get_db, ParsedRule, RuleFiringCache from db import engine, Base, get_db, ParsedRule, RuleFiringCache, CoverageSnapshot
from routers import coverage, ingest, settings, quality from routers import coverage, ingest, settings, quality, query
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
@@ -23,6 +23,23 @@ with engine.connect() as _conn:
"checked_at TIMESTAMP" "checked_at TIMESTAMP"
")" ")"
)) ))
_conn.execute(text(
"CREATE TABLE IF NOT EXISTS coverage_snapshots ("
"id SERIAL PRIMARY KEY, "
"recorded_at TIMESTAMP, "
"health_score FLOAT DEFAULT 0, "
"parser_pct FLOAT DEFAULT 0, "
"mitre_pct FLOAT DEFAULT 0, "
"firing_pct FLOAT DEFAULT 0, "
"active_sources INTEGER DEFAULT 0, "
"covered_sources INTEGER DEFAULT 0, "
"rules_loaded INTEGER DEFAULT 0, "
"tactics_covered INTEGER DEFAULT 0, "
"techniques_covered INTEGER DEFAULT 0, "
"rules_with_mitre INTEGER DEFAULT 0, "
"rules_fired INTEGER DEFAULT 0"
")"
))
_conn.commit() _conn.commit()
app = FastAPI(title="SIEM Toolkit", version="1.0.0") app = FastAPI(title="SIEM Toolkit", version="1.0.0")
@@ -73,6 +90,7 @@ app.include_router(coverage.router, prefix="/api/coverage", tags=["Coverage"])
app.include_router(ingest.router, prefix="/api/ingest", tags=["Ingest"]) app.include_router(ingest.router, prefix="/api/ingest", tags=["Ingest"])
app.include_router(settings.router, prefix="/api/settings", tags=["Settings"]) app.include_router(settings.router, prefix="/api/settings", tags=["Settings"])
app.include_router(quality.router, prefix="/api/quality", tags=["Quality"]) app.include_router(quality.router, prefix="/api/quality", tags=["Quality"])
app.include_router(query.router, prefix="/api/query", tags=["Query"])
@app.get("/health") @app.get("/health")
+308 -1
View File
@@ -4,7 +4,7 @@ from fastapi import APIRouter, UploadFile, File, Depends, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from datetime import datetime from datetime import datetime
from db import get_db, ParsedRule, ParserField, ActiveSource, RuleFiringCache from db import get_db, ParsedRule, ParserField, ActiveSource, RuleFiringCache, CoverageSnapshot
from services import s1_client, rule_parser from services import s1_client, rule_parser
DETECTIONS_FILE = os.environ.get("DETECTIONS_FILE", "/app/data/detections.json") DETECTIONS_FILE = os.environ.get("DETECTIONS_FILE", "/app/data/detections.json")
@@ -571,6 +571,27 @@ async def sync_sources(days: int = 7, db: Session = Depends(get_db)):
db.commit() db.commit()
synced_names = [r["dataSource.name"] for r in rows if r.get("dataSource.name") and r["dataSource.name"] not in _S1_NATIVE_SOURCES] synced_names = [r["dataSource.name"] for r in rows if r.get("dataSource.name") and r["dataSource.name"] not in _S1_NATIVE_SOURCES]
# Auto-record a coverage snapshot after every live-sources sync
try:
h = _compute_health(db)
db.add(CoverageSnapshot(
health_score=h["health_score"],
parser_pct=h["parser_pct"],
mitre_pct=h["mitre_pct"],
firing_pct=h["firing_pct"] or 0.0,
active_sources=h["active_sources"],
covered_sources=h["covered_sources"],
rules_loaded=h["rules_loaded"],
tactics_covered=h["tactics_covered"],
techniques_covered=h["techniques_covered"],
rules_with_mitre=h["rules_with_mitre"],
rules_fired=h["rules_fired"],
))
db.commit()
except Exception:
pass # snapshot failure should never break sync
return {"synced": seen, "sources": synced_names} return {"synced": seen, "sources": synced_names}
@@ -1061,6 +1082,292 @@ def get_rule_firing_cache(db: Session = Depends(get_db)):
} }
def _compute_health(db) -> dict:
"""Compute current health score from DB state.
Weights:
40% parser coverage what % of active sources have a working parser
35% MITRE coverage what % of the 14 standard ATT&CK tactics are covered
25% rule firing what % of library rules have fired (0 if cache empty)
"""
# --- Parser coverage ---
all_sources = db.query(ActiveSource).all()
total_sources = len(all_sources)
# "covered" = parser_detected > 0 (parser running in data lake)
covered_sources = sum(1 for s in all_sources if (s.parser_detected or 0) > 0)
parser_pct = round((covered_sources / total_sources * 100) if total_sources else 0.0, 1)
# --- MITRE coverage ---
TOTAL_TACTICS = 14 # standard ATT&CK Enterprise tactic count
rules = db.query(ParsedRule).filter_by(rule_type="library").all()
total_rules = len(rules)
covered_tactics: set = set()
covered_techniques: set = set()
rules_with_mitre = 0
for rule in rules:
try:
raw = json.loads(rule.raw) if rule.raw else {}
except Exception:
raw = {}
tactics = raw.get("tactics", [])
techniques = raw.get("techniques", [])
if tactics or techniques:
rules_with_mitre += 1
for t in tactics:
if t and t != "Uncategorized":
covered_tactics.add(t)
for tech in techniques:
k = tech.get("id") or tech.get("name")
if k:
covered_techniques.add(k)
tactics_covered = len(covered_tactics)
techniques_covered = len(covered_techniques)
mitre_pct = round((tactics_covered / TOTAL_TACTICS * 100), 1)
# --- Rule firing ---
firing_rows = db.query(RuleFiringCache).all()
cache_populated = len(firing_rows) > 0
rules_fired = sum(1 for r in firing_rows if r.alert_count > 0)
if cache_populated and total_rules > 0:
firing_pct = round(rules_fired / total_rules * 100, 1)
else:
firing_pct = 0.0
# --- Weighted health score ---
if cache_populated:
score = round(0.40 * parser_pct + 0.35 * mitre_pct + 0.25 * firing_pct, 1)
else:
# Without firing data, reweight between parser + MITRE
score = round(0.55 * parser_pct + 0.45 * mitre_pct, 1)
return {
"health_score": score,
"parser_pct": parser_pct,
"mitre_pct": mitre_pct,
"firing_pct": firing_pct if cache_populated else None,
"active_sources": total_sources,
"covered_sources": covered_sources,
"rules_loaded": total_rules,
"tactics_covered": tactics_covered,
"techniques_covered": techniques_covered,
"rules_with_mitre": rules_with_mitre,
"rules_fired": rules_fired,
"firing_cache_populated": cache_populated,
"components": {
"parser_coverage": {"value": parser_pct, "weight": 0.40 if cache_populated else 0.55, "label": "Parser Coverage"},
"mitre_coverage": {"value": mitre_pct, "weight": 0.35 if cache_populated else 0.45, "label": "MITRE Coverage"},
"rule_firing": {"value": firing_pct if cache_populated else None, "weight": 0.25 if cache_populated else 0.0, "label": "Rule Firing Rate"},
}
}
@router.get("/health")
def get_health_score(db: Session = Depends(get_db)):
"""Return the current tenant health score and component breakdown."""
h = _compute_health(db)
# Most recent snapshot for trend comparison
prev = db.query(CoverageSnapshot).order_by(CoverageSnapshot.recorded_at.desc()).offset(1).first()
delta = None
if prev:
delta = round(h["health_score"] - prev.health_score, 1)
h["delta_from_previous"] = delta
return h
@router.post("/snapshot")
def record_snapshot(db: Session = Depends(get_db)):
"""Record a coverage snapshot. Called automatically at end of sync-sources."""
h = _compute_health(db)
snap = CoverageSnapshot(
health_score=h["health_score"],
parser_pct=h["parser_pct"],
mitre_pct=h["mitre_pct"],
firing_pct=h["firing_pct"] or 0.0,
active_sources=h["active_sources"],
covered_sources=h["covered_sources"],
rules_loaded=h["rules_loaded"],
tactics_covered=h["tactics_covered"],
techniques_covered=h["techniques_covered"],
rules_with_mitre=h["rules_with_mitre"],
rules_fired=h["rules_fired"],
)
db.add(snap)
db.commit()
return {"recorded": True, "health_score": h["health_score"]}
@router.get("/snapshots")
def get_snapshots(limit: int = 30, db: Session = Depends(get_db)):
"""Return the last N daily snapshots for sparkline charts."""
rows = (
db.query(CoverageSnapshot)
.order_by(CoverageSnapshot.recorded_at.desc())
.limit(limit)
.all()
)
return {
"snapshots": [
{
"date": r.recorded_at.strftime("%Y-%m-%d"),
"health_score": r.health_score,
"parser_pct": r.parser_pct,
"mitre_pct": r.mitre_pct,
"firing_pct": r.firing_pct,
"active_sources": r.active_sources,
"covered_sources": r.covered_sources,
}
for r in reversed(rows) # chronological order
]
}
@router.get("/dependency-map")
def get_dependency_map(db: Session = Depends(get_db)):
"""
Flip of the coverage map: for each detection library rule, show which
data sources it requires. Flags rules as 'at_risk' if any required
source has no parser or has zero recent events.
"""
rules = db.query(ParsedRule).filter_by(rule_type="library").all()
active_sources = {s.source_name: s for s in db.query(ActiveSource).all()}
ds_index, _ = _build_parser_ds_index()
# Build set of source names that are "healthy" (have events + parser)
healthy_sources: set = set()
for name, src in active_sources.items():
has_parser = name in ds_index or (src.parser_detected or 0) > 0
if has_parser and (src.event_count or 0) > 0:
healthy_sources.add(name)
out = []
for rule in rules:
try:
raw_data = json.loads(rule.raw) if rule.raw else {}
except Exception:
raw_data = {}
data_sources = raw_data.get("data_sources", [])
tactics = raw_data.get("tactics", [])
techniques = raw_data.get("techniques", [])
generated_alerts = raw_data.get("generated_alerts")
source_statuses = []
at_risk = False
for ds in data_sources:
src = active_sources.get(ds)
if src is None:
status = "inactive"
at_risk = True
elif ds not in healthy_sources:
status = "no_parser"
at_risk = True
else:
status = "healthy"
source_statuses.append({"source": ds, "status": status})
# Rules with no source requirements are not "at risk" (platform-wide rules)
if not data_sources:
at_risk = False
out.append({
"rule": rule.name,
"rule_id": rule.rule_id,
"sources": source_statuses,
"source_count": len(data_sources),
"tactics": tactics,
"techniques": [t.get("id", "") for t in techniques if t.get("id")],
"generated_alerts": generated_alerts,
"at_risk": at_risk,
"no_sources": len(data_sources) == 0,
})
# Sort: at-risk first, then by source count desc, then alphabetical
out.sort(key=lambda r: (not r["at_risk"], -r["source_count"], r["rule"]))
at_risk_count = sum(1 for r in out if r["at_risk"])
healthy_count = sum(1 for r in out if not r["at_risk"] and not r["no_sources"])
return {
"rules": out,
"total": len(out),
"at_risk": at_risk_count,
"healthy": healthy_count,
"no_source_requirements": sum(1 for r in out if r["no_sources"]),
}
@router.get("/onboarding-status")
def get_onboarding_status(db: Session = Depends(get_db)):
"""
Pipeline status for each active source across 6 lifecycle stages.
Returns per-source progress for the onboarding tracker view.
"""
import re as _re
active_sources = db.query(ActiveSource).order_by(ActiveSource.event_count.desc()).all()
ds_index, stub_parsers = _build_parser_ds_index()
stub_names = {s["parser_name"] for s in stub_parsers}
firing_cache = {r.rule_name: r.alert_count for r in db.query(RuleFiringCache).all()}
# rule_by_source: source_name → list of rule names
rules = db.query(ParsedRule).filter_by(rule_type="library").all()
rule_by_source: dict = {}
for rule in rules:
try:
raw_data = json.loads(rule.raw) if rule.raw else {}
except Exception:
raw_data = {}
for ds in raw_data.get("data_sources", []):
rule_by_source.setdefault(ds, []).append(rule.name)
def _normalize(s):
return _re.sub(r"[^a-z0-9]", "", s.lower())
def _find_parser(source_name):
if source_name in ds_index:
return ds_index[source_name]
sn = _normalize(source_name)
for ds_name, info in ds_index.items():
if _normalize(ds_name) in sn or sn in _normalize(ds_name):
return info
return None
out = []
for src in active_sources:
parser_info = _find_parser(src.source_name)
parser_active = (src.parser_detected or 0) > 0
has_ds_name = parser_info is not None and parser_info.get("parser_name") not in stub_names
rules_for_src = rule_by_source.get(src.source_name, [])
rules_firing = any(firing_cache.get(r, 0) > 0 for r in rules_for_src)
stages = [
{"stage": "Data Received", "done": (src.event_count or 0) > 0},
{"stage": "Parser File Exists", "done": parser_info is not None},
{"stage": "Parser Active", "done": parser_active},
{"stage": "Source Labeled", "done": has_ds_name and parser_active},
{"stage": "Detection Rules", "done": len(rules_for_src) > 0},
{"stage": "Rules Firing", "done": rules_firing},
]
completed = sum(1 for s in stages if s["done"])
out.append({
"source": src.source_name,
"event_count": src.event_count,
"stages": stages,
"completed": completed,
"total": len(stages),
"pct": round(completed / len(stages) * 100),
})
# Sort: incomplete first, then by event volume
out.sort(key=lambda x: (x["completed"] == x["total"], -x["event_count"]))
return {
"sources": out,
"fully_onboarded": sum(1 for s in out if s["completed"] == s["total"]),
"in_progress": sum(1 for s in out if 0 < s["completed"] < s["total"]),
"not_started": sum(1 for s in out if s["completed"] == 0),
}
@router.delete("/reset") @router.delete("/reset")
def reset_data(db: Session = Depends(get_db)): def reset_data(db: Session = Depends(get_db)):
db.query(ParsedRule).delete() db.query(ParsedRule).delete()
+73
View File
@@ -0,0 +1,73 @@
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from datetime import datetime, timedelta
from services import s1_client
router = APIRouter()
def _date_range(hours: int | None = None, days: int | None = None) -> tuple[str, str]:
now = datetime.utcnow()
if hours:
delta = timedelta(hours=hours)
else:
delta = timedelta(days=days or 1)
return (
(now - delta).strftime("%Y-%m-%dT%H:%M:%S.000Z"),
now.strftime("%Y-%m-%dT%H:%M:%S.000Z"),
)
PRESET_QUERIES = [
{"label": "Top sources by volume", "query": "| group events=count() by dataSource.name | sort -events | limit 25"},
{"label": "Unlabelled events", "query": "!(dataSource.name = *) !(source = 'scalyr') | group events=count() by source | sort -events | limit 25"},
{"label": "Events by type", "query": "| group events=count() by dataSource.name, event.type | sort -events | limit 50"},
{"label": "Failed logins", "query": "| filter event.type = 'Logon' | filter event.outcome = 'FAILED' | group count() by user.name, src.ip | sort -count() | limit 25"},
{"label": "Process executions", "query": "| filter event.type = 'Process Creation' | group count() by src.process.name | sort -count() | limit 25"},
{"label": "Network connections by dest", "query": "| filter event.type = 'IP Connect' | group count() by dst.ip | sort -count() | limit 25"},
{"label": "Rules firing (30d)", "query": "| filter ruleName != '' | group alerts=count() by ruleName | sort -alerts | limit 50"},
]
class QueryRequest(BaseModel):
query: str
hours: int | None = None
days: int | None = None
max_count: int = 1000
@router.get("/presets")
def get_presets():
return {"presets": PRESET_QUERIES}
@router.post("/run")
async def run_query(req: QueryRequest):
"""Run a PowerQuery against the Singularity Data Lake."""
if not req.query.strip():
raise HTTPException(400, "Query cannot be empty")
if req.max_count > 10_000:
req.max_count = 10_000
from_dt, to_dt = _date_range(hours=req.hours, days=req.days)
try:
result = await s1_client.run_powerquery(req.query, from_dt, to_dt, max_count=req.max_count)
except Exception as e:
raise HTTPException(502, f"PowerQuery error: {e}")
err = result.get("error") if isinstance(result, dict) else None
if err:
raise HTTPException(502, f"PowerQuery error: {err}")
events = result.get("events", [])
columns = sorted({k for row in events for k in row.keys()}) if events else []
return {
"rows": len(events),
"columns": columns,
"events": events,
"from": from_dt,
"to": to_dt,
"query": req.query,
}
+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="#/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="#/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="#/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> <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> </nav>
<div class="p-3 border-t border-white/5"> <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> <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> <p class="text-slate-400 mt-1 text-sm">SentinelOne AI-SIEM · demo.sentinelone.net</p>
</div> </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 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="bg-gradient-to-b from-gray-900 to-gray-950 ring-1 ring-white/5 rounded-xl p-4 text-center animate-pulse">
<div class="h-7 w-16 bg-gray-800/80 rounded mx-auto mb-1"></div> <div class="h-7 w-16 bg-gray-800/80 rounded mx-auto mb-1"></div>
@@ -148,6 +175,7 @@ function renderHome() {
</div> </div>
</div>`) </div>`)
homeLoadStats() homeLoadStats()
loadHealthScore()
} }
const EXCLUDED_SOURCES = new Set([ const EXCLUDED_SOURCES = new Set([
@@ -230,6 +258,69 @@ function homeCard(href, title, desc, cta, grad) {
</div>` </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 // Queue a source to be pre-selected when Quality page loads
let _pendingQualitySource = null let _pendingQualitySource = null
function queueQualitySource(source) { function queueQualitySource(source) {
@@ -820,17 +911,35 @@ Raw log sample:
[paste your log lines here]` [paste your log lines here]`
function renderOnboarding() { function renderOnboarding() {
set(`<div class="p-8 max-w-3xl"> set(`<div class="p-8 max-w-3xl space-y-8">
<div class="mb-8"> <div>
<h1 class="text-xl font-extrabold text-white tracking-tight">Onboarding Accelerator</h1> <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> <p class="text-sm text-slate-400 mt-1">Use Claude Code directly — no API key required</p>
</div> </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('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('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('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.')} ${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>
<!-- 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="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"> <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> <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> <pre class="p-4 text-xs text-gray-300 font-mono leading-relaxed whitespace-pre-wrap">${esc(PROMPT)}</pre>
</div> </div>
</div>`) </div>`)
loadOnboardingPipeline()
} }
function obStep(title, desc) { function obStep(title, desc) {
@@ -857,6 +967,96 @@ function obCopy() {
if (b) { b.textContent = 'Copied!'; setTimeout(() => b.textContent = 'Copy', 1500) } 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 ────────────────────────────────────────────────────────────── // ── Settings ──────────────────────────────────────────────────────────────
async function renderSettings() { async function renderSettings() {
@@ -1407,6 +1607,201 @@ async function qtTest() {
} finally { setBtn('btn-qt', false, 'Test') } } 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 ─────────────────────────────────────────────────────── // ── Threat Coverage ───────────────────────────────────────────────────────
function renderThreat() { function renderThreat() {
@@ -1460,9 +1855,26 @@ function renderThreat() {
<div id="firing-summary"></div> <div id="firing-summary"></div>
<div id="firing-table"></div> <div id="firing-table"></div>
</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>`) </div>`)
loadMitre() loadMitre()
loadFiringStatus() loadFiringStatus()
loadDependencyMap()
} }
async function syncLibraryThreat() { 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 ──────────────────────────────────────────────────────────────── // ── Router ────────────────────────────────────────────────────────────────
function set(html) { document.getElementById('main').innerHTML = html } function set(html) { document.getElementById('main').innerHTML = html }
@@ -1640,6 +2134,7 @@ function route() {
else if (h === '#/ingest') { updateNav('ingest'); renderIngest() } else if (h === '#/ingest') { updateNav('ingest'); renderIngest() }
else if (h === '#/quality') { updateNav('quality'); renderQuality() } else if (h === '#/quality') { updateNav('quality'); renderQuality() }
else if (h === '#/onboarding') { updateNav('onboarding'); renderOnboarding() } else if (h === '#/onboarding') { updateNav('onboarding'); renderOnboarding() }
else if (h === '#/query') { updateNav('query'); renderQuery() }
else if (h === '#/threat') { updateNav('threat'); renderThreat() } else if (h === '#/threat') { updateNav('threat'); renderThreat() }
else if (h === '#/settings') { updateNav('settings'); renderSettings() } else if (h === '#/settings') { updateNav('settings'); renderSettings() }
else { updateNav('home'); renderHome() } else { updateNav('home'); renderHome() }