Fix Alerts

This commit is contained in:
Raven Scott
2026-07-30 18:07:46 -04:00
parent 215f52ff53
commit 1dd8197e05
5 changed files with 319 additions and 45 deletions
+186 -31
View File
@@ -67,6 +67,11 @@ const els = {
activePeerChip: $('active-peer-chip'), activePeerChip: $('active-peer-chip'),
inviteOut: $('invite-out'), inviteOut: $('invite-out'),
anomalyList: $('anomaly-list'), anomalyList: $('anomaly-list'),
alertsOpenList: $('alerts-open-list'),
alertsEmpty: $('alerts-empty'),
alertsOpenCount: $('alerts-open-count'),
alertsEventCount: $('alerts-event-count'),
alertsRefresh: $('alerts-refresh'),
offlineBanner: $('offline-banner'), offlineBanner: $('offline-banner'),
restoringBanner: $('restoring-banner'), restoringBanner: $('restoring-banner'),
fleetStrip: $('fleet-strip'), fleetStrip: $('fleet-strip'),
@@ -1082,6 +1087,8 @@ async function activatePeer(publicKeyHex) {
} }
await refreshMeta().catch(() => {}) await refreshMeta().catch(() => {})
await seedHistory().catch(() => redrawAll()) await seedHistory().catch(() => redrawAll())
// Always seed Alerts tab when activating a peer (boot restore, fleet, connect)
await refreshAlertsFromAgent().catch(() => {})
return true return true
} }
@@ -1165,10 +1172,42 @@ function onSamples(samples, conn) {
scheduleRedrawAll() scheduleRedrawAll()
} }
function prependAnomaly(ev) { /**
if (!els.anomalyList) return * Bind Show / Correlate actions on an anomaly or alert row.
* @param {HTMLElement} li
* @param {{ chart?: string, ts?: number }} ev
*/
function bindAnomalyRowActions(li, ev) {
if (!ev.chart) return
li.querySelector('[data-act="focus"]')?.addEventListener('click', (e) => {
e.stopPropagation()
showView('charts')
metricsDashboard.focusChartAt(ev.chart, ev.ts)
if ([...els.exploreChart.options].some((o) => o.value === ev.chart)) {
els.exploreChart.value = ev.chart
exploreChartId = ev.chart
series.explore = []
seedHistory().catch(() => redrawAll())
}
})
li.querySelector('[data-act="correlate"]')?.addEventListener('click', (e) => {
e.stopPropagation()
showView('charts')
metricsDashboard.correlateAround(ev.ts || Date.now(), {
method: 'anomaly-rate',
halfWindowSec: 60,
})
})
}
/**
* @param {object} ev
* @returns {HTMLLIElement}
*/
function buildAnomalyRow(ev) {
const li = document.createElement('li') const li = document.createElement('li')
li.className = ev.severity === 'critical' ? 'crit' : ev.cleared ? 'ok' : 'warn' li.className = ev.severity === 'critical' ? 'crit' : ev.cleared ? 'ok' : 'warn'
if (ev.id) li.dataset.anomalyId = String(ev.id)
const score = const score =
ev.score != null && !ev.cleared ev.score != null && !ev.cleared
? ` <span class="score">${Number(ev.score).toFixed(2)}</span>` ? ` <span class="score">${Number(ev.score).toFixed(2)}</span>`
@@ -1179,29 +1218,112 @@ function prependAnomaly(ev) {
<button type="button" class="linkish" data-act="focus">Show</button> <button type="button" class="linkish" data-act="focus">Show</button>
<button type="button" class="linkish" data-act="correlate">Correlate</button> <button type="button" class="linkish" data-act="correlate">Correlate</button>
</span>` : ''}` </span>` : ''}`
if (ev.chart) { bindAnomalyRowActions(li, ev)
li.querySelector('[data-act="focus"]')?.addEventListener('click', (e) => { return li
e.stopPropagation() }
showView('charts')
metricsDashboard.focusChartAt(ev.chart, ev.ts) /**
if ([...els.exploreChart.options].some((o) => o.value === ev.chart)) { * @param {object} alert
els.exploreChart.value = ev.chart * @returns {HTMLLIElement}
exploreChartId = ev.chart */
series.explore = [] function buildOpenAlertRow(alert) {
seedHistory().catch(() => redrawAll()) const li = document.createElement('li')
} const severity =
}) alert.severity ||
li.querySelector('[data-act="correlate"]')?.addEventListener('click', (e) => { (String(alert.status || '').toUpperCase() === 'CRITICAL' ? 'critical' : 'warning')
e.stopPropagation() li.className = severity === 'critical' ? 'crit' : 'warn'
showView('charts') li.dataset.alertId = String(alert.id || '')
metricsDashboard.correlateAround(ev.ts || Date.now(), { const label = alert.message || alert.info || alert.name || alert.id || 'alert'
method: 'anomaly-rate', const dim = alert.dimension ? `.${alert.dimension}` : ''
halfWindowSec: 60, li.innerHTML = `<strong>${escapeHtml(severity)}</strong> ${escapeHtml(label)}
}) <span class="muted">${escapeHtml(alert.chart || '')}${escapeHtml(dim)}</span>
}) ${alert.chart ? `<span class="fleet-actions anomaly-actions">
<button type="button" class="linkish" data-act="focus">Show</button>
<button type="button" class="linkish" data-act="correlate">Correlate</button>
</span>` : ''}`
bindAnomalyRowActions(li, { chart: alert.chart, ts: Date.now() })
return li
}
function syncAlertsEmptyState() {
const openN = els.alertsOpenList?.children.length || 0
const eventN = els.anomalyList?.children.length || 0
if (els.alertsOpenCount) els.alertsOpenCount.textContent = String(openN)
if (els.alertsEventCount) els.alertsEventCount.textContent = String(eventN)
if (els.alertsEmpty) {
els.alertsEmpty.classList.toggle('hidden', openN > 0 || eventN > 0)
} }
}
/**
* Prepend a live anomaly event to the Recent events list.
* @param {object} ev
*/
function prependAnomaly(ev) {
if (!els.anomalyList || !ev) return
// Dedupe by id when replaying history + live push / dual anomaly+alert channels
if (ev.id) {
const id = String(ev.id)
for (const child of [...els.anomalyList.children]) {
if (child.dataset?.anomalyId === id) child.remove()
}
}
const li = buildAnomalyRow(ev)
els.anomalyList.prepend(li) els.anomalyList.prepend(li)
while (els.anomalyList.children.length > 40) els.anomalyList.lastChild.remove() while (els.anomalyList.children.length > 40) els.anomalyList.lastChild.remove()
syncAlertsEmptyState()
}
/**
* Render open alert configs (firing thresholds).
* @param {object[]} alerts
*/
function renderOpenAlerts(alerts) {
if (!els.alertsOpenList) return
els.alertsOpenList.innerHTML = ''
const open = (alerts || []).filter((a) => {
if (!a || a.silenced || a.acked) return false
if (a.open === true) return true
if (a.open === false) return false
const st = String(a.status || a.severity || '').toUpperCase()
return st === 'WARNING' || st === 'CRITICAL'
})
for (const a of open) {
els.alertsOpenList.appendChild(buildOpenAlertRow(a))
}
syncAlertsEmptyState()
}
/**
* Pull listAnomalies + listAlerts from the active agent and paint the Alerts tab.
* Called on peer activate and when opening the Alerts view — not only on Connect click.
*/
async function refreshAlertsFromAgent() {
if (!manager.active?.connected) {
if (els.anomalyList) els.anomalyList.innerHTML = ''
if (els.alertsOpenList) els.alertsOpenList.innerHTML = ''
anomalyByChart.clear()
syncAlertsEmptyState()
return
}
try {
const [recent, alertsRes] = await Promise.all([
manager.request(Methods.listAnomalies, { limit: 40 }),
manager.request(Methods.listAlerts, {}).catch(() => ({ alerts: [] })),
])
if (els.anomalyList) els.anomalyList.innerHTML = ''
anomalyByChart.clear()
// listRecent is newest-first; reverse so prepend builds newest-first list
const anomalies = recent?.anomalies || []
for (const ev of [...anomalies].reverse()) {
prependAnomaly(ev)
if (!ev.cleared) trackAnomaly(ev)
}
renderOpenAlerts(alertsRes?.alerts || [])
applyAnomalyHighlights()
} catch (err) {
log(`Alerts refresh failed: ${err.message || err}`)
}
} }
function renderFleetStrip(fleet, desktopPeers) { function renderFleetStrip(fleet, desktopPeers) {
@@ -1483,6 +1605,7 @@ function showView(name) {
if (name === 'qvac') qvacView.enter() if (name === 'qvac') qvacView.enter()
else qvacView.leave?.() else qvacView.leave?.()
if (name === 'fleet') loadFleetView() if (name === 'fleet') loadFleetView()
if (name === 'alerts') refreshAlertsFromAgent().catch(() => {})
if (name === 'settings') { if (name === 'settings') {
syncSettingsUi() syncSettingsUi()
const activeTab = document.querySelector('#settings-tabs .settings-tab.active') const activeTab = document.querySelector('#settings-tabs .settings-tab.active')
@@ -1661,15 +1784,7 @@ els.btnConnect.addEventListener('click', async () => {
}) })
log(`Connected ${conn.publicKeyHex.slice(0, 16)}`) log(`Connected ${conn.publicKeyHex.slice(0, 16)}`)
await ensureDesktopNotifyPermission() await ensureDesktopNotifyPermission()
try { // anomaly history + open alerts loaded inside activatePeer → refreshAlertsFromAgent
const recent = await manager.request(Methods.listAnomalies, { limit: 20 })
for (const ev of (recent.anomalies || []).reverse()) {
prependAnomaly(ev)
if (!ev.cleared) trackAnomaly(ev)
}
} catch {
// ignore
}
showView('overview') showView('overview')
log('Subscribed to live metrics') log('Subscribed to live metrics')
} catch (err) { } catch (err) {
@@ -1745,6 +1860,42 @@ manager.on('push', (ev, conn) => {
trackAnomaly(ev.data) trackAnomaly(ev.data)
desktopNotifyAnomaly(ev.data) desktopNotifyAnomaly(ev.data)
log(`Anomaly: ${ev.data?.message || ''}`) log(`Anomaly: ${ev.data?.message || ''}`)
// Keep open-alerts strip in sync when thresholds fire/clear
if (ev.data && !ev.data.cleared) {
// Soft refresh open list without wiping recent events
manager
.request(Methods.listAlerts, {})
.then((r) => renderOpenAlerts(r?.alerts || []))
.catch(() => {})
} else if (ev.data?.cleared) {
manager
.request(Methods.listAlerts, {})
.then((r) => renderOpenAlerts(r?.alerts || []))
.catch(() => {})
}
}
// pipeline also broadcasts push:alert for non-cleared firings
if (ev.type === Pushes.alert && ev.data) {
const data = ev.data
if (!data.cleared) {
// Normalize alert push into anomaly-row shape if message present
if (data.message || data.severity) {
prependAnomaly({
id: data.id,
chart: data.chart,
severity: data.severity || (data.status === 'CRITICAL' ? 'critical' : 'warning'),
message: data.message || data.info || data.id,
ts: data.ts || Date.now(),
score: data.score,
cleared: false,
})
trackAnomaly(data)
}
manager
.request(Methods.listAlerts, {})
.then((r) => renderOpenAlerts(r?.alerts || []))
.catch(() => {})
}
} }
if (ev.type === Pushes.health) { if (ev.type === Pushes.health) {
els.statHealth.textContent = ev.data?.status || '—' els.statHealth.textContent = ev.data?.status || '—'
@@ -1754,6 +1905,10 @@ manager.on('push', (ev, conn) => {
} }
}) })
els.alertsRefresh?.addEventListener('click', () => {
refreshAlertsFromAgent().catch((err) => log(`Alerts refresh failed: ${err.message}`))
})
manager.on('connected', () => { manager.on('connected', () => {
setOnline(true) setOnline(true)
renderPeers() renderPeers()
+26 -6
View File
@@ -265,14 +265,34 @@
<div> <div>
<p class="dash-kicker">Signals</p> <p class="dash-kicker">Signals</p>
<h1 class="dash-title">Alerts</h1> <h1 class="dash-title">Alerts</h1>
<p class="page-subtitle">Anomalies and threshold events from the agent</p> <p class="page-subtitle">Open thresholds and recent anomaly events from the agent</p>
</div>
<div class="page-actions">
<button type="button" id="alerts-refresh" class="btn btn-ghost" title="Refresh from agent">
Refresh
</button>
<label class="check-row">
<input type="checkbox" id="notify-desktop" checked />
Desktop notifications
</label>
</div> </div>
<label class="check-row">
<input type="checkbox" id="notify-desktop" checked />
Desktop notifications
</label>
</header> </header>
<div class="dash-card"> <p id="alerts-empty" class="alerts-empty muted hidden">
No open alerts and no recent anomaly events. When a threshold fires, it appears here
and the AI can read the same stream via list_alerts / list_anomalies.
</p>
<div class="dash-card alerts-section">
<div class="alerts-section-head">
<h2 class="alerts-section-title">Open alerts</h2>
<span id="alerts-open-count" class="muted alerts-count">0</span>
</div>
<ul id="alerts-open-list" class="event-list"></ul>
</div>
<div class="dash-card alerts-section">
<div class="alerts-section-head">
<h2 class="alerts-section-title">Recent events</h2>
<span id="alerts-event-count" class="muted alerts-count">0</span>
</div>
<ul id="anomaly-list" class="event-list"></ul> <ul id="anomaly-list" class="event-list"></ul>
</div> </div>
</section> </section>
+17 -6
View File
@@ -60,9 +60,14 @@ export async function getHostSnapshot() {
const anomalies = getAnomalyEngine() const anomalies = getAnomalyEngine()
const health = anomalies.getHealth() const health = anomalies.getHealth()
const recent = anomalies.listRecent?.(20) || [] const recent = anomalies.listRecent?.(20) || []
const openAlerts = (listAlerts() || []).filter( // Prefer explicit open flag; fall back to status (listAlerts uses status, not severity)
(a) => a && !a.acked && !a.silenced && a.severity !== 'cleared' const openAlerts = (listAlerts() || []).filter((a) => {
) if (!a || a.acked || a.silenced) return false
if (a.open === true) return true
if (a.open === false) return false
const st = String(a.status || a.severity || '').toUpperCase()
return st === 'WARNING' || st === 'CRITICAL'
})
/** @type {Record<string, { value: number, ts: number, dim: string, chart: string }|null>} */ /** @type {Record<string, { value: number, ts: number, dim: string, chart: string }|null>} */
const kpis = {} const kpis = {}
@@ -654,12 +659,18 @@ function compactAnomaly(a) {
function compactAlert(a) { function compactAlert(a) {
if (!a) return a if (!a) return a
const status = String(a.status || '').toUpperCase()
const severity =
a.severity ||
(status === 'CRITICAL' ? 'critical' : status === 'WARNING' ? 'warning' : 'cleared')
return { return {
id: a.id, id: a.id,
chart: a.chart, chart: a.chart,
severity: a.severity, dimension: a.dimension,
message: a.message || a.name, status: status || undefined,
ts: a.ts, severity,
message: a.message || a.info || a.name || a.id,
silenced: Boolean(a.silenced), silenced: Boolean(a.silenced),
open: a.open !== undefined ? Boolean(a.open) : severity === 'critical' || severity === 'warning',
} }
} }
+32 -2
View File
@@ -3,28 +3,58 @@
*/ */
import { getAnomalyEngine } from './anomaly.js' import { getAnomalyEngine } from './anomaly.js'
/**
* @param {string} status
* @returns {'critical'|'warning'|'cleared'}
*/
function severityFromStatus(status) {
const s = String(status || 'CLEAR').toUpperCase()
if (s === 'CRITICAL') return 'critical'
if (s === 'WARNING') return 'warning'
return 'cleared'
}
/** /**
* @returns {import('../../shared/data-model.js').AlertState[]} * @returns {import('../../shared/data-model.js').AlertState[]}
*/ */
export function listAlerts() { export function listAlerts() {
const engine = getAnomalyEngine() const engine = getAnomalyEngine()
const now = Date.now()
return engine.listConfigs().map((cfg) => { return engine.listConfigs().map((cfg) => {
const status = engine.status.get(cfg.id) || 'CLEAR' const status = engine.status.get(cfg.id) || 'CLEAR'
const silencedUntil = cfg._silencedUntil ? Number(cfg._silencedUntil) : 0
const silenceActive =
cfg.enabled === false && (!silencedUntil || silencedUntil > now)
const severity = severityFromStatus(status)
const open = (status === 'WARNING' || status === 'CRITICAL') && !silenceActive
const info = cfg.info || cfg.id
return { return {
id: cfg.id, id: cfg.id,
name: cfg.id, name: cfg.id,
chart: cfg.chart, chart: cfg.chart,
dimension: cfg.dimension, dimension: cfg.dimension,
status, status,
/** AI / UI friendly mirror of status */
severity,
value: null, value: null,
units: '', units: '',
info: cfg.info || '', info,
lastStatusChange: Date.now(), message: info,
lastStatusChange: now,
silenced: silenceActive,
silencedUntil: silenceActive ? silencedUntil || null : null,
acked: false,
open,
config: cfg, config: cfg,
} }
}) })
} }
/** Open (firing, not silenced) alerts only. */
export function listOpenAlerts() {
return listAlerts().filter((a) => a.open)
}
export function getAlert(id) { export function getAlert(id) {
return listAlerts().find((a) => a.id === id) || null return listAlerts().find((a) => a.id === id) || null
} }
+58
View File
@@ -5154,3 +5154,61 @@ html[data-theme='light'] .app-confirm-ok-danger {
transform: none; transform: none;
} }
} }
/* ── Alerts tab ─────────────────────────────────────────────────────────── */
.alerts-section {
margin-bottom: var(--space);
}
.alerts-section-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
margin-bottom: 8px;
}
.alerts-section-title {
margin: 0;
font-size: 13px;
font-weight: 600;
letter-spacing: 0.02em;
text-transform: uppercase;
color: var(--text-muted);
}
.alerts-count {
font-family: var(--font-mono);
font-size: 12px;
}
.alerts-empty {
margin: 0 0 var(--space);
padding: 14px 16px;
border-radius: var(--border-radius);
border: 1px dashed var(--border-color);
background: color-mix(in srgb, var(--bg-tertiary) 60%, transparent);
font-size: 13.5px;
line-height: 1.45;
}
.alerts-empty.hidden {
display: none;
}
#alerts-open-list:empty::after,
#anomaly-list:empty::after {
content: 'None';
display: block;
padding: 8px 0;
font-size: 13px;
color: var(--text-faint);
}
#alerts-view .page-actions {
display: flex;
align-items: center;
gap: 14px;
flex-wrap: wrap;
}