diff --git a/app/services/anomaly.py b/app/services/anomaly.py
index 4e525fe..4dbc21b 100644
--- a/app/services/anomaly.py
+++ b/app/services/anomaly.py
@@ -20,6 +20,7 @@ from __future__ import annotations
import logging
import os
+import time
import uuid
from dataclasses import dataclass
from datetime import datetime, timezone
@@ -230,10 +231,19 @@ def score_unscored(
if label in _ANOMALOUS_LABELS and pred["score"] >= threshold:
detection_rows.append(enriched)
- with get_conn(db_path) as conn:
- _write_scores(conn, scored_rows, scored_at)
- det_count = _insert_detections(conn, detection_rows, tenant_id, scored_at)
- conn.commit()
+ for _attempt in range(4):
+ try:
+ with get_conn(db_path) as conn:
+ _write_scores(conn, scored_rows, scored_at)
+ det_count = _insert_detections(conn, detection_rows, tenant_id, scored_at)
+ conn.commit()
+ break
+ except Exception as exc:
+ if "database is locked" in str(exc).lower() and _attempt < 3:
+ logger.warning("DB locked, retrying write in 10s (attempt %d/4)", _attempt + 1)
+ time.sleep(10)
+ else:
+ raise
total_scored += len(scored_rows)
total_detections += det_count
diff --git a/app/services/search.py b/app/services/search.py
index 47a74e9..90ad4d7 100644
--- a/app/services/search.py
+++ b/app/services/search.py
@@ -40,44 +40,42 @@ def build_fts_index(db_path: Path) -> None:
if BACKEND == Backend.POSTGRES:
return
- raw = sqlite3.connect(str(db_path), timeout=30.0)
- raw.execute("PRAGMA journal_mode=WAL")
+ with get_conn(db_path) as conn:
+ needs_rebuild = False
+ try:
+ conn.execute("SELECT sequence FROM log_fts LIMIT 0")
+ except Exception:
+ needs_rebuild = True
- needs_rebuild = False
- try:
- raw.execute("SELECT sequence FROM log_fts LIMIT 0")
- except sqlite3.OperationalError:
- needs_rebuild = True
+ if needs_rebuild:
+ conn.execute("DROP TABLE IF EXISTS log_fts")
+ conn.commit()
- if needs_rebuild:
- raw.execute("DROP TABLE IF EXISTS log_fts")
-
- raw.executescript("""
- CREATE VIRTUAL TABLE IF NOT EXISTS log_fts USING fts5(
- text,
- entry_id UNINDEXED,
- source_id UNINDEXED,
- sequence UNINDEXED,
- severity UNINDEXED,
- timestamp_iso UNINDEXED,
- matched_patterns UNINDEXED,
- repeat_count UNINDEXED,
- out_of_order UNINDEXED,
- tokenize = 'porter ascii'
- );
- """)
- raw.execute("""
- INSERT INTO log_fts(text, entry_id, source_id, sequence, severity,
- timestamp_iso, matched_patterns,
- repeat_count, out_of_order)
- SELECT e.text, e.id, e.source_id, e.sequence, e.severity,
- e.timestamp_iso, e.matched_patterns,
- e.repeat_count, e.out_of_order
- FROM log_entries e
- WHERE e.id NOT IN (SELECT entry_id FROM log_fts WHERE entry_id IS NOT NULL)
- """)
- raw.commit()
- raw.close()
+ conn.execute("""
+ CREATE VIRTUAL TABLE IF NOT EXISTS log_fts USING fts5(
+ text,
+ entry_id UNINDEXED,
+ source_id UNINDEXED,
+ sequence UNINDEXED,
+ severity UNINDEXED,
+ timestamp_iso UNINDEXED,
+ matched_patterns UNINDEXED,
+ repeat_count UNINDEXED,
+ out_of_order UNINDEXED,
+ tokenize = 'porter ascii'
+ )
+ """)
+ conn.execute("""
+ INSERT INTO log_fts(text, entry_id, source_id, sequence, severity,
+ timestamp_iso, matched_patterns,
+ repeat_count, out_of_order)
+ SELECT e.text, e.id, e.source_id, e.sequence, e.severity,
+ e.timestamp_iso, e.matched_patterns,
+ e.repeat_count, e.out_of_order
+ FROM log_entries e
+ WHERE e.id NOT IN (SELECT entry_id FROM log_fts WHERE entry_id IS NOT NULL)
+ """)
+ conn.commit()
def _sanitize_fts_query(raw: str, or_mode: bool = False) -> str:
@@ -659,6 +657,21 @@ def stats_summary(db_path: Path, window_hours: int = 24, severity_overrides: lis
(tid,),
).fetchall()
+ timeline_rows = conn.execute(
+ """
+ SELECT id as entry_id, source_id, timestamp_iso, severity, text
+ FROM log_entries
+ WHERE severity IN ('CRITICAL','ERROR','WARN','WARNING','EMERGENCY','ALERT')
+ AND timestamp_iso >= ?
+ AND timestamp_iso IS NOT NULL
+ AND repeat_count = 1
+ AND (tenant_id = ? OR tenant_id = '')
+ ORDER BY timestamp_iso DESC
+ LIMIT 300
+ """,
+ (since_iso, tid),
+ ).fetchall()
+
last_row = conn.execute(
"SELECT MAX(ingest_time) AS t FROM log_entries WHERE (tenant_id = ? OR tenant_id = '')",
(tid,),
@@ -691,6 +704,17 @@ def stats_summary(db_path: Path, window_hours: int = 24, severity_overrides: lis
else:
suppressed += 1
+ timeline_events = [
+ {
+ "entry_id": r["entry_id"],
+ "source_id": r["source_id"],
+ "timestamp_iso": r["timestamp_iso"],
+ "severity": r["severity"],
+ "text": r["text"],
+ }
+ for r in timeline_rows
+ ]
+
last_gleaned: str | None = last_row["t"] if last_row else None
return {
@@ -702,6 +726,7 @@ def stats_summary(db_path: Path, window_hours: int = 24, severity_overrides: lis
"recent_criticals": recent_criticals,
"suppressed_criticals": suppressed,
"last_gleaned": last_gleaned,
+ "timeline_events": timeline_events,
}
diff --git a/web/src/components/IncidentTimeline.vue b/web/src/components/IncidentTimeline.vue
index 43ab564..f6ed925 100644
--- a/web/src/components/IncidentTimeline.vue
+++ b/web/src/components/IncidentTimeline.vue
@@ -8,13 +8,19 @@
-
+
-
+
@@ -86,7 +107,8 @@
{{ sev.label }}
- {{ entries.length }} events
+ drag to filter
+ {{ entries.length }} events
@@ -106,12 +128,22 @@ const props = defineProps<{
entries: Entry[]
startedAt?: string | null
endedAt?: string | null
+ brushable?: boolean
}>()
-defineEmits<{
+const emit = defineEmits<{
'select-entry': [index: number]
+ 'select-range': [range: { from: string; to: string } | null]
}>()
+// ── brush state ─────────────────────────────────────────────────────────────
+const isDragging = ref(false)
+const brushAnchor = ref(0) // SVG-space X where drag started
+const brushCursor = ref(0) // SVG-space X of current mouse position
+
+const brushLeft = computed(() => Math.min(brushAnchor.value, brushCursor.value))
+const brushW = computed(() => Math.abs(brushCursor.value - brushAnchor.value))
+
// SVG logical dimensions
const W = 1000
const H = 64
@@ -249,13 +281,37 @@ interface Tooltip {
const tooltip = ref
(null)
-function onMouseMove(e: MouseEvent) {
+function _svgX(e: MouseEvent): number {
const svg = e.currentTarget as SVGElement
const rect = svg.getBoundingClientRect()
- const relX = (e.clientX - rect.left) / rect.width // 0..1
+ return ((e.clientX - rect.left) / rect.width) * W
+}
+
+function _pxX(e: MouseEvent): number {
+ const svg = e.currentTarget as SVGElement
+ return e.clientX - svg.getBoundingClientRect().left
+}
+
+function onMouseDown(e: MouseEvent) {
+ if (!props.brushable) return
+ const x = _svgX(e)
+ isDragging.value = true
+ brushAnchor.value = x
+ brushCursor.value = x
+ e.preventDefault()
+}
+
+function onMouseMove(e: MouseEvent) {
+ if (props.brushable && isDragging.value) {
+ brushCursor.value = Math.max(0, Math.min(W, _svgX(e)))
+ return
+ }
+
+ const svg = e.currentTarget as SVGElement
+ const rect = svg.getBoundingClientRect()
+ const relX = (e.clientX - rect.left) / rect.width
const ms = tMin.value + relX * span.value
- // Find nearest entry
let nearest = timed.value[0]
let nearestDist = Infinity
for (const entry of timed.value) {
@@ -264,13 +320,12 @@ function onMouseMove(e: MouseEvent) {
}
if (!nearest) return
- // Only show if within ~3% of span
if (nearestDist > span.value * 0.03 + 5000) {
tooltip.value = null
return
}
- const px = e.clientX - rect.left
+ const px = _pxX(e)
tooltip.value = {
px,
flip: px > rect.width * 0.7,
@@ -280,6 +335,36 @@ function onMouseMove(e: MouseEvent) {
}
}
+function onMouseUp(e: MouseEvent) {
+ if (!props.brushable || !isDragging.value) return
+ isDragging.value = false
+ const dragW = Math.abs(brushCursor.value - brushAnchor.value)
+ if (dragW < 8) {
+ // Click without meaningful drag — clear selection
+ brushAnchor.value = 0
+ brushCursor.value = 0
+ emit('select-range', null)
+ return
+ }
+ const x0 = Math.min(brushAnchor.value, brushCursor.value)
+ const x1 = Math.max(brushAnchor.value, brushCursor.value)
+ const fromMs = tMin.value + (x0 / W) * span.value
+ const toMs = tMin.value + (x1 / W) * span.value
+ emit('select-range', {
+ from: new Date(fromMs).toISOString(),
+ to: new Date(toMs).toISOString(),
+ })
+}
+
+function onMouseLeave() {
+ tooltip.value = null
+ if (isDragging.value) {
+ isDragging.value = false
+ brushAnchor.value = 0
+ brushCursor.value = 0
+ }
+}
+
function severityClass(sev: string | null): string {
return {
ERROR: 'text-sev-error', CRITICAL: 'text-sev-critical',
diff --git a/web/src/components/QuickCapture.vue b/web/src/components/QuickCapture.vue
index fc96e49..a71bd42 100644
--- a/web/src/components/QuickCapture.vue
+++ b/web/src/components/QuickCapture.vue
@@ -33,15 +33,29 @@
-
-
Scoped to:
-
{{ sourceScope }}
-
+
+
+ Scoped to:
+ {{ sourceScope }}
+
+
+
+ Window:
+
+ {{ _fmtTs(timeFrom) }} → {{ timeTo ? _fmtTs(timeTo) : 'now' }}
+
+
+
@@ -192,6 +206,8 @@ interface Summary {
const query = ref('')
const sourceScope = ref
(null)
+const timeFrom = ref(null)
+const timeTo = ref(null)
const entries = ref([])
const summary = ref(null)
const reasoning = ref(null)
@@ -210,9 +226,19 @@ const severityFilter = ref(null)
let capturedSince: string | null = null
let capturedUntil: string | null = null
+function _fmtTs(iso: string): string {
+ try {
+ return new Date(iso).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
+ } catch { return iso }
+}
+
onMounted(async () => {
const s = route.query.source
if (typeof s === 'string' && s.trim()) sourceScope.value = s
+ const f = route.query.from
+ const t = route.query.to
+ if (typeof f === 'string' && f) timeFrom.value = f
+ if (typeof t === 'string' && t) timeTo.value = t
const q = route.query.q
if (typeof q === 'string' && q.trim()) {
query.value = q
@@ -258,7 +284,12 @@ async function run() {
const res = await fetch(`${BASE}/api/diagnose/stream`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ query: query.value, source: sourceScope.value }),
+ body: JSON.stringify({
+ query: query.value,
+ source: sourceScope.value,
+ since: timeFrom.value || undefined,
+ until: timeTo.value || undefined,
+ }),
})
if (!res.ok) throw new Error(`API returned ${res.status}`)
if (!res.body) throw new Error('No response body')
diff --git a/web/src/style/theme.css b/web/src/style/theme.css
index c29e315..04e84c0 100644
--- a/web/src/style/theme.css
+++ b/web/src/style/theme.css
@@ -65,3 +65,27 @@ button {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
+
+/* Loading skeleton shimmer */
+@keyframes shimmer {
+ 0% { background-position: -200% 0; }
+ 100% { background-position: 200% 0; }
+}
+
+.loading-shimmer {
+ background: linear-gradient(
+ 90deg,
+ var(--color-surface-raised) 25%,
+ var(--color-surface-border) 50%,
+ var(--color-surface-raised) 75%
+ );
+ background-size: 200% 100%;
+ animation: shimmer 1.4s ease-in-out infinite;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .loading-shimmer {
+ animation: none;
+ background: var(--color-surface-raised);
+ }
+}
diff --git a/web/src/views/DashboardView.vue b/web/src/views/DashboardView.vue
index 3f3175f..a18da5a 100644
--- a/web/src/views/DashboardView.vue
+++ b/web/src/views/DashboardView.vue
@@ -1,6 +1,22 @@
+
+
+ Filtered:
+ {{ shortTs(timelineRange.from) }}
+ →
+ {{ shortTs(timelineRange.to) }}
+
+
+
@@ -29,8 +45,9 @@
Criticals (24h)
-
- {{ loading ? '…' : (stats?.criticals_24h ?? 0) }}
+
+
+ {{ stats?.criticals_24h ?? 0 }}
{{ stats.suppressed_criticals }} suppressed by overrides
@@ -38,8 +55,9 @@
Errors (24h)
-
- {{ loading ? '…' : (stats?.errors_24h ?? 0) }}
+
+
+ {{ stats?.errors_24h ?? 0 }}
Active Incidents
-
- {{ incidentsLoading ? '…' : activeIncidents }}
+
+
+ {{ activeIncidents }}
Unreviewed Alerts
-
- {{ alertsLoading ? '…' : unackedAlerts }}
+
+
+ {{ unackedAlerts }}
+
+
+
Activity Timeline — Last 24 Hours
+
+
+
Source Health — Last 24 Hours
-
Loading…
+
No log entries in the last 24 hours.
@@ -117,7 +156,7 @@
class="text-text-dim hover:text-accent text-xs px-2 py-1 rounded hover:bg-surface transition-colors"
@click="diagnoseSource(src.source_id)"
:aria-label="`Diagnose ${src.source_id}`"
- >diagnose
+ >diagnose ↗
@@ -133,14 +172,52 @@
-
-
CRITICAL
-
{{ entry.source_id }}
-
{{ shortTs(entry.timestamp_iso) }}
+
+
+
+
+ CRITICAL
+ {{ entry.source_id }}
+ {{ shortTs(entry.timestamp_iso) }}
+
+
{{ entry.text }}
+
+
+ {{ expandedEntryId === entry.entry_id ? '▲' : '▼' }}
+
-
{{ entry.text }}
+
+
+
+
+
+
+ Analysing surrounding logs…
+
+
+ {{ entryExplanations[entry.entry_id] }}
+
+
+
+
+
+
@@ -164,6 +241,7 @@
+
+
diff --git a/web/src/views/SecurityAlertsView.vue b/web/src/views/SecurityAlertsView.vue
index 46cf19b..717d1ad 100644
--- a/web/src/views/SecurityAlertsView.vue
+++ b/web/src/views/SecurityAlertsView.vue
@@ -154,10 +154,34 @@
+
+
+
-
Loading…
+
@@ -205,6 +229,11 @@
v-if="det.scorer === 'cybersec'"
class="text-xs px-1.5 py-0.5 rounded bg-surface-raised border border-surface-border text-text-dim font-mono"
>cybersec
+ ×{{ det.count }}
@@ -330,6 +359,7 @@ interface Detection {
acknowledged_at: string | null
notes: string
scorer: string
+ count?: number
}
interface ScorerStatus {
@@ -376,6 +406,7 @@ const drawer = ref(null)
const activeTab = ref<'all' | 'unacked'>('all')
const labelFilter = ref('')
const scorerFilter = ref('')
+const collapseDupes = ref(true)
const tabRefs = ref<(HTMLElement | null)[]>([])
const anomalyLabels = [
@@ -401,11 +432,26 @@ const tabs = computed(() => [
{ value: 'unacked', label: 'Unacknowledged', count: unackedCount.value },
])
-const filteredDetections = computed(() =>
- activeTab.value === 'unacked'
+const filteredDetections = computed(() => {
+ const base = activeTab.value === 'unacked'
? detections.value.filter(d => !d.acknowledged)
: detections.value
-)
+ if (!collapseDupes.value) return base
+ const groups = new Map()
+ const counts = new Map()
+ for (const d of base) {
+ const key = d.anomaly_label + '|' + d.text.slice(0, 100)
+ const existing = groups.get(key)
+ if (!existing || d.anomaly_score > existing.anomaly_score) {
+ groups.set(key, d)
+ }
+ counts.set(key, (counts.get(key) ?? 0) + 1)
+ }
+ return Array.from(groups.values()).map(d => ({
+ ...d,
+ count: counts.get(d.anomaly_label + '|' + d.text.slice(0, 100)) ?? 1,
+ }))
+})
// ── Data loading ─────────────────────────────────────────────────────────────
|