Fix Alerts
This commit is contained in:
@@ -67,6 +67,11 @@ const els = {
|
||||
activePeerChip: $('active-peer-chip'),
|
||||
inviteOut: $('invite-out'),
|
||||
anomalyList: $('anomaly-list'),
|
||||
alertsOpenList: $('alerts-open-list'),
|
||||
alertsEmpty: $('alerts-empty'),
|
||||
alertsOpenCount: $('alerts-open-count'),
|
||||
alertsEventCount: $('alerts-event-count'),
|
||||
alertsRefresh: $('alerts-refresh'),
|
||||
offlineBanner: $('offline-banner'),
|
||||
restoringBanner: $('restoring-banner'),
|
||||
fleetStrip: $('fleet-strip'),
|
||||
@@ -1082,6 +1087,8 @@ async function activatePeer(publicKeyHex) {
|
||||
}
|
||||
await refreshMeta().catch(() => {})
|
||||
await seedHistory().catch(() => redrawAll())
|
||||
// Always seed Alerts tab when activating a peer (boot restore, fleet, connect)
|
||||
await refreshAlertsFromAgent().catch(() => {})
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -1165,21 +1172,13 @@ function onSamples(samples, conn) {
|
||||
scheduleRedrawAll()
|
||||
}
|
||||
|
||||
function prependAnomaly(ev) {
|
||||
if (!els.anomalyList) return
|
||||
const li = document.createElement('li')
|
||||
li.className = ev.severity === 'critical' ? 'crit' : ev.cleared ? 'ok' : 'warn'
|
||||
const score =
|
||||
ev.score != null && !ev.cleared
|
||||
? ` <span class="score">${Number(ev.score).toFixed(2)}</span>`
|
||||
: ''
|
||||
li.innerHTML = `<strong>${escapeHtml(ev.severity || 'event')}</strong>${score} ${escapeHtml(ev.message || '')}
|
||||
<span class="muted">${new Date(ev.ts || Date.now()).toLocaleTimeString()}</span>
|
||||
${ev.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>` : ''}`
|
||||
if (ev.chart) {
|
||||
/**
|
||||
* 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')
|
||||
@@ -1200,8 +1199,131 @@ function prependAnomaly(ev) {
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} ev
|
||||
* @returns {HTMLLIElement}
|
||||
*/
|
||||
function buildAnomalyRow(ev) {
|
||||
const li = document.createElement('li')
|
||||
li.className = ev.severity === 'critical' ? 'crit' : ev.cleared ? 'ok' : 'warn'
|
||||
if (ev.id) li.dataset.anomalyId = String(ev.id)
|
||||
const score =
|
||||
ev.score != null && !ev.cleared
|
||||
? ` <span class="score">${Number(ev.score).toFixed(2)}</span>`
|
||||
: ''
|
||||
li.innerHTML = `<strong>${escapeHtml(ev.severity || 'event')}</strong>${score} ${escapeHtml(ev.message || '')}
|
||||
<span class="muted">${new Date(ev.ts || Date.now()).toLocaleTimeString()}</span>
|
||||
${ev.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, ev)
|
||||
return li
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} alert
|
||||
* @returns {HTMLLIElement}
|
||||
*/
|
||||
function buildOpenAlertRow(alert) {
|
||||
const li = document.createElement('li')
|
||||
const severity =
|
||||
alert.severity ||
|
||||
(String(alert.status || '').toUpperCase() === 'CRITICAL' ? 'critical' : 'warning')
|
||||
li.className = severity === 'critical' ? 'crit' : 'warn'
|
||||
li.dataset.alertId = String(alert.id || '')
|
||||
const label = alert.message || alert.info || alert.name || alert.id || 'alert'
|
||||
const dim = alert.dimension ? `.${alert.dimension}` : ''
|
||||
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)
|
||||
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) {
|
||||
@@ -1483,6 +1605,7 @@ function showView(name) {
|
||||
if (name === 'qvac') qvacView.enter()
|
||||
else qvacView.leave?.()
|
||||
if (name === 'fleet') loadFleetView()
|
||||
if (name === 'alerts') refreshAlertsFromAgent().catch(() => {})
|
||||
if (name === 'settings') {
|
||||
syncSettingsUi()
|
||||
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)}…`)
|
||||
await ensureDesktopNotifyPermission()
|
||||
try {
|
||||
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
|
||||
}
|
||||
// anomaly history + open alerts loaded inside activatePeer → refreshAlertsFromAgent
|
||||
showView('overview')
|
||||
log('Subscribed to live metrics')
|
||||
} catch (err) {
|
||||
@@ -1745,6 +1860,42 @@ manager.on('push', (ev, conn) => {
|
||||
trackAnomaly(ev.data)
|
||||
desktopNotifyAnomaly(ev.data)
|
||||
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) {
|
||||
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', () => {
|
||||
setOnline(true)
|
||||
renderPeers()
|
||||
|
||||
+22
-2
@@ -265,14 +265,34 @@
|
||||
<div>
|
||||
<p class="dash-kicker">Signals</p>
|
||||
<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>
|
||||
</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>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -60,9 +60,14 @@ export async function getHostSnapshot() {
|
||||
const anomalies = getAnomalyEngine()
|
||||
const health = anomalies.getHealth()
|
||||
const recent = anomalies.listRecent?.(20) || []
|
||||
const openAlerts = (listAlerts() || []).filter(
|
||||
(a) => a && !a.acked && !a.silenced && a.severity !== 'cleared'
|
||||
)
|
||||
// Prefer explicit open flag; fall back to status (listAlerts uses status, not severity)
|
||||
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>} */
|
||||
const kpis = {}
|
||||
@@ -654,12 +659,18 @@ function compactAnomaly(a) {
|
||||
|
||||
function compactAlert(a) {
|
||||
if (!a) return a
|
||||
const status = String(a.status || '').toUpperCase()
|
||||
const severity =
|
||||
a.severity ||
|
||||
(status === 'CRITICAL' ? 'critical' : status === 'WARNING' ? 'warning' : 'cleared')
|
||||
return {
|
||||
id: a.id,
|
||||
chart: a.chart,
|
||||
severity: a.severity,
|
||||
message: a.message || a.name,
|
||||
ts: a.ts,
|
||||
dimension: a.dimension,
|
||||
status: status || undefined,
|
||||
severity,
|
||||
message: a.message || a.info || a.name || a.id,
|
||||
silenced: Boolean(a.silenced),
|
||||
open: a.open !== undefined ? Boolean(a.open) : severity === 'critical' || severity === 'warning',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,28 +3,58 @@
|
||||
*/
|
||||
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[]}
|
||||
*/
|
||||
export function listAlerts() {
|
||||
const engine = getAnomalyEngine()
|
||||
const now = Date.now()
|
||||
return engine.listConfigs().map((cfg) => {
|
||||
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 {
|
||||
id: cfg.id,
|
||||
name: cfg.id,
|
||||
chart: cfg.chart,
|
||||
dimension: cfg.dimension,
|
||||
status,
|
||||
/** AI / UI friendly mirror of status */
|
||||
severity,
|
||||
value: null,
|
||||
units: '',
|
||||
info: cfg.info || '',
|
||||
lastStatusChange: Date.now(),
|
||||
info,
|
||||
message: info,
|
||||
lastStatusChange: now,
|
||||
silenced: silenceActive,
|
||||
silencedUntil: silenceActive ? silencedUntil || null : null,
|
||||
acked: false,
|
||||
open,
|
||||
config: cfg,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Open (firing, not silenced) alerts only. */
|
||||
export function listOpenAlerts() {
|
||||
return listAlerts().filter((a) => a.open)
|
||||
}
|
||||
|
||||
export function getAlert(id) {
|
||||
return listAlerts().find((a) => a.id === id) || null
|
||||
}
|
||||
|
||||
@@ -5154,3 +5154,61 @@ html[data-theme='light'] .app-confirm-ok-danger {
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user