From fe90a86b30a1c9ccc8782787229c32245e6bc02c Mon Sep 17 00:00:00 2001
From: pyr0ball
Date: Wed, 10 Jun 2026 00:28:15 -0700
Subject: [PATCH] =?UTF-8?q?feat:=20security=20alerts=20tab=20=E2=80=94=20U?=
=?UTF-8?q?I=20view=20for=20anomaly=20detections=20(#11)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
New SecurityAlertsView (/alerts route) surfaces the detections table built
in #10. Features:
- All / Unacknowledged tab filter with live counts
- Label dropdown (SECURITY_ANOMALY, SYSTEM_FAILURE, NETWORK_ANOMALY, etc.)
- Score confidence bar per detection (colour-coded by threshold)
- Acknowledge drawer: full log text, optional notes, in-place row dim on save
- Scorer status badge + manual "Run scorer" button
- Config warning when TURNSTONE_ANOMALY_MODEL is unset
Dashboard: new "Unreviewed Alerts" stat card (red border when > 0) links
to /alerts so alerts surface on the landing page without navigating away.
Closes: https://git.opensourcesolarpunk.com/Circuit-Forge/turnstone/issues/11
---
web/src/App.vue | 1 +
web/src/router/index.ts | 2 +
web/src/views/DashboardView.vue | 22 +-
web/src/views/SecurityAlertsView.vue | 458 +++++++++++++++++++++++++++
4 files changed, 482 insertions(+), 1 deletion(-)
create mode 100644 web/src/views/SecurityAlertsView.vue
diff --git a/web/src/App.vue b/web/src/App.vue
index 914984d..f6a1b48 100644
--- a/web/src/App.vue
+++ b/web/src/App.vue
@@ -76,6 +76,7 @@ const navLinks = [
{ to: '/search', label: 'Search' },
{ to: '/diagnose', label: 'Diagnose' },
{ to: '/incidents', label: 'Incidents' },
+ { to: '/alerts', label: 'Alerts' },
{ to: '/bundles', label: 'Bundles' },
{ to: '/sources', label: 'Sources' },
{ to: '/context', label: 'Context' },
diff --git a/web/src/router/index.ts b/web/src/router/index.ts
index b2c7f97..e5bba57 100644
--- a/web/src/router/index.ts
+++ b/web/src/router/index.ts
@@ -8,6 +8,7 @@ import BundlesView from '@/views/BundlesView.vue'
import SettingsView from '@/views/SettingsView.vue'
import ContextView from '@/views/ContextView.vue'
import BlocklistView from '@/views/BlocklistView.vue'
+import SecurityAlertsView from '@/views/SecurityAlertsView.vue'
export default createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
@@ -17,6 +18,7 @@ export default createRouter({
{ path: '/search', component: LogSearchView },
{ path: '/diagnose', component: DiagnoseView },
{ path: '/incidents', component: IncidentsView },
+ { path: '/alerts', component: SecurityAlertsView },
{ path: '/bundles', component: BundlesView },
{ path: '/sources', component: SourcesView },
{ path: '/context', component: ContextView },
diff --git a/web/src/views/DashboardView.vue b/web/src/views/DashboardView.vue
index 98a9c4f..3d6a73a 100644
--- a/web/src/views/DashboardView.vue
+++ b/web/src/views/DashboardView.vue
@@ -52,6 +52,16 @@
{{ incidentsLoading ? '…' : activeIncidents }}
+
+ Unreviewed Alerts
+
+ {{ alertsLoading ? '…' : unackedAlerts }}
+
+
@@ -201,6 +211,8 @@ const loading = ref(true)
const incidents = ref([])
const incidentsLoading = ref(true)
const watchSources = ref([])
+const unackedAlerts = ref(0)
+const alertsLoading = ref(true)
const activeIncidents = computed(() =>
incidents.value.filter(i => !i.ended_at).length
@@ -217,7 +229,7 @@ const isStale = computed(() => {
})
onMounted(async () => {
- await Promise.all([loadStats(), loadIncidents(), loadWatchStatus()])
+ await Promise.all([loadStats(), loadIncidents(), loadWatchStatus(), loadAlertCount()])
})
async function loadStats() {
@@ -245,6 +257,14 @@ async function loadWatchStatus() {
} catch { /* non-critical */ }
}
+async function loadAlertCount() {
+ try {
+ const res = await fetch(`${BASE}/turnstone/api/anomaly/detections?unacked_only=true&limit=1000`)
+ if (res.ok) unackedAlerts.value = (await res.json()).total ?? 0
+ } catch { /* non-critical — scorer may be disabled */ }
+ finally { alertsLoading.value = false }
+}
+
function healthDot(errors: number, total: number): string {
if (errors === 0) return 'bg-green-500'
const ratio = errors / Math.max(total, 1)
diff --git a/web/src/views/SecurityAlertsView.vue b/web/src/views/SecurityAlertsView.vue
new file mode 100644
index 0000000..7ac5361
--- /dev/null
+++ b/web/src/views/SecurityAlertsView.vue
@@ -0,0 +1,458 @@
+
+
+
+
+
+
+
Security Alerts
+
+ Anomaly detections from the scoring pipeline.
+ Acknowledge entries after review to track your triage state.
+
+
+
+
+
+
+
+ {{ scorerStatus.running ? 'scoring…' : scorerStatus.enabled ? 'scorer ready' : 'scorer off' }}
+
+
+
+
+
+
+
+
+ Anomaly scoring is disabled — set TURNSTONE_ANOMALY_MODEL
+ in your .env and restart Turnstone.
+
+
+
+
+ Total scored: {{ scorerStatus.total_scored ?? '—' }}
+ Total detections: {{ scorerStatus.total_detections ?? '—' }}
+
+ Last run: {{ formatTs(scorerStatus.last_run_at) }}
+
+
+ Last error: {{ scorerStatus.last_error }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Loading…
+
+
+
+
No unacknowledged detections — all clear.
+
Enable anomaly scoring to start detecting.
+
No detections yet. Run the scorer after gleaning to populate this list.
+
+
+
+
+
+
+
+
+ | Sev |
+ Label |
+ Score |
+ Source |
+ Log entry |
+ Detected |
+ |
+
+
+
+
+ |
+
+ {{ det.severity }}
+
+ |
+
+
+ {{ det.anomaly_label }}
+
+ |
+
+
+
+ {{ Math.round(det.anomaly_score * 100) }}%
+
+ |
+ {{ det.source_id }} |
+ {{ det.text }} |
+ {{ formatTs(det.detected_at) }} |
+
+ reviewed
+
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ drawer.severity }}
+
+ {{ drawer.anomaly_label }}
+
+ {{ Math.round(drawer.anomaly_score * 100) }}% confidence
+
+
+ source: {{ drawer.source_id }}
+ · {{ formatTs(drawer.timestamp_iso) }}
+
+
+
+
+
+
+
+ {{ drawer.text }}
+
+
+
+
+
Acknowledged {{ formatTs(drawer.acknowledged_at) }}
+
{{ drawer.notes }}
+
+
+
+
+
+
+
+
+
+ {{ ackError }}
+
+
+
+
+
+
+
+
+
+
+