Show overall tunnel status with downs only, list the last five alarms with times, and note when overlay/physical alarms may explain bad voice DPI. Co-authored-by: Cursor <cursoragent@cursor.com>
265 lines
11 KiB
JavaScript
265 lines
11 KiB
JavaScript
// src/services/enrichment/alarmSemantics.js
|
||
//
|
||
// Shared alarm-code semantics for Prisma SD-WAN events. Two things
|
||
// live here so both the check runner (services/voiceDiag/checks/wan
|
||
// /wanAlarms.js) and the follow-up renderer (services/renderers/
|
||
// wanDiagnosticsRenderer.js) present the same story:
|
||
//
|
||
// 1. categorizeAlarm(code) — buckets a raw alarm code into one of:
|
||
// - 'overlay' SD-WAN VPN tunnels between peer elements. These
|
||
// DO NOT correspond to the physical WAN interfaces
|
||
// shown in the Link State / LQM checks; a store
|
||
// can have all physical circuits green while the
|
||
// overlay flaps repeatedly.
|
||
// - 'physical' A specific WAN circuit / interface is affected.
|
||
// Should generally line up with what Link State
|
||
// reports.
|
||
// - 'device' Element / ION appliance itself (heartbeat, boot).
|
||
// - 'other' Everything else (LAN, DHCP, config, etc.).
|
||
//
|
||
// 2. humanizeAlarmCode(code) — one-line English label so the check
|
||
// message and the recent-samples list don't dump
|
||
// raw Prisma constants at the operator.
|
||
//
|
||
// 3. rollupAlarms(samples) — groups near-identical alarm samples
|
||
// by (code + severity) so 20 successive
|
||
// NETWORK_ANYNETLINK_DOWN flaps show as a single
|
||
// line with "×20" instead of a 20-line wall.
|
||
// Sorted critical → major → minor, then by count.
|
||
//
|
||
// The category / humanizer tables err on the side of "unknown code
|
||
// falls through to a sensible default" — Prisma introduces new codes
|
||
// with tenant updates and we do NOT want a new code to blow up the
|
||
// diagnostic.
|
||
|
||
/**
|
||
* Alarm category → operator meaning.
|
||
*
|
||
* overlay → SD-WAN VPN link between peer elements; will NOT
|
||
* appear as a physical link-down in the Link State
|
||
* check. Common when a hub site or a peer is temporarily
|
||
* unreachable.
|
||
* physical → An actual WAN circuit / interface is down or
|
||
* degraded. Should correlate with Link State / LQM.
|
||
* device → Element itself (ION appliance) missed heartbeats,
|
||
* rebooted, or entered a bad state.
|
||
* other → LAN / DHCP / configuration / anything else.
|
||
*/
|
||
const CATEGORY_TABLE = {
|
||
// ── overlay (VPN tunnel / SD-WAN mesh) ──────────────────────────
|
||
NETWORK_ANYNETLINK_DOWN: 'overlay',
|
||
NETWORK_VPNLINK_DOWN: 'overlay',
|
||
NETWORK_VPNLINK_FLAP: 'overlay',
|
||
NETWORK_VPNLINK_UNREACHABLE: 'overlay',
|
||
NETWORK_DIRECTPRIVATE_DOWN: 'overlay',
|
||
NETWORK_STANDBY_LINK_DOWN: 'overlay',
|
||
NETWORK_SITE_UNREACHABLE: 'overlay',
|
||
SITE_CONNECTIVITY_DEGRADED: 'overlay',
|
||
NETWORK_SITE_CONNECTIVITY_DEGRADED: 'overlay',
|
||
|
||
// ── physical (WAN circuit / interface) ──────────────────────────
|
||
NETWORK_INTERNET_DOWN: 'physical',
|
||
NETWORK_PUBLICWAN_UNREACHABLE: 'physical',
|
||
NETWORK_PRIVATEWAN_UNREACHABLE: 'physical',
|
||
NETWORK_DIRECTINTERNET_UNREACHABLE: 'physical',
|
||
DEVICE_INTERFACE_STATE_CHANGED: 'physical',
|
||
DEVICE_INTERFACE_DOWN: 'physical',
|
||
DEVICE_INTERFACE_FLAP: 'physical',
|
||
NETWORK_LTE_LINK_DOWN: 'physical',
|
||
|
||
// ── device (element / ION itself) ───────────────────────────────
|
||
DEVICE_HB_MISSED: 'device',
|
||
DEVICE_UNREACHABLE: 'device',
|
||
DEVICE_REBOOT: 'device',
|
||
DEVICE_HIGH_CPU: 'device',
|
||
DEVICE_HIGH_MEMORY: 'device',
|
||
DEVICE_DISK_UTILIZATION_HIGH: 'device',
|
||
DEVICE_SOFTWARE_UPGRADE_FAILED: 'device',
|
||
DEVICE_HA_STATE_CHANGE: 'device',
|
||
|
||
// ── other (LAN / DHCP / config) ─────────────────────────────────
|
||
DHCP_POOL_UTILIZATION_HIGH: 'other',
|
||
DHCP_FAILURE: 'other',
|
||
CONFIG_SYNC_FAILED: 'other',
|
||
LAN_INTERFACE_DOWN: 'other',
|
||
BGP_PEER_DOWN: 'other',
|
||
};
|
||
|
||
/**
|
||
* Human-readable one-liner per alarm code. Kept short — operators
|
||
* scan the message field, they don't need a paragraph. Falls back to
|
||
* a Title-Cased version of the raw code when we don't know it.
|
||
*/
|
||
const HUMANIZE_TABLE = {
|
||
NETWORK_ANYNETLINK_DOWN: 'SD-WAN overlay tunnel down',
|
||
NETWORK_VPNLINK_DOWN: 'VPN link to peer site down',
|
||
NETWORK_VPNLINK_FLAP: 'VPN link flapping',
|
||
NETWORK_VPNLINK_UNREACHABLE: 'VPN link unreachable',
|
||
NETWORK_DIRECTPRIVATE_DOWN: 'Direct private link down',
|
||
NETWORK_STANDBY_LINK_DOWN: 'Standby link down',
|
||
NETWORK_SITE_UNREACHABLE: 'Peer site unreachable',
|
||
SITE_CONNECTIVITY_DEGRADED: 'Site connectivity degraded',
|
||
NETWORK_SITE_CONNECTIVITY_DEGRADED: 'Site connectivity degraded',
|
||
NETWORK_INTERNET_DOWN: 'Internet WAN circuit down',
|
||
NETWORK_PUBLICWAN_UNREACHABLE: 'Public WAN unreachable',
|
||
NETWORK_PRIVATEWAN_UNREACHABLE: 'Private WAN unreachable',
|
||
NETWORK_DIRECTINTERNET_UNREACHABLE: 'Direct internet unreachable',
|
||
NETWORK_LTE_LINK_DOWN: 'LTE link down',
|
||
DEVICE_INTERFACE_STATE_CHANGED: 'Physical interface state change',
|
||
DEVICE_INTERFACE_DOWN: 'Physical interface down',
|
||
DEVICE_INTERFACE_FLAP: 'Physical interface flap',
|
||
DEVICE_HB_MISSED: 'ION heartbeat missed',
|
||
DEVICE_UNREACHABLE: 'ION unreachable',
|
||
DEVICE_REBOOT: 'ION reboot',
|
||
DEVICE_HIGH_CPU: 'ION high CPU',
|
||
DEVICE_HIGH_MEMORY: 'ION high memory',
|
||
DEVICE_DISK_UTILIZATION_HIGH: 'ION disk full',
|
||
DEVICE_SOFTWARE_UPGRADE_FAILED: 'Software upgrade failed',
|
||
DEVICE_HA_STATE_CHANGE: 'HA state change',
|
||
DHCP_POOL_UTILIZATION_HIGH: 'DHCP pool nearly full',
|
||
DHCP_FAILURE: 'DHCP failure',
|
||
CONFIG_SYNC_FAILED: 'Configuration sync failed',
|
||
LAN_INTERFACE_DOWN: 'LAN interface down',
|
||
BGP_PEER_DOWN: 'BGP peer down',
|
||
};
|
||
|
||
/**
|
||
* Return one of 'overlay' | 'physical' | 'device' | 'other' for the
|
||
* given raw alarm code. Case-insensitive on the code. Unknown codes
|
||
* bucket to 'other' — Prisma adds new codes over time and we prefer
|
||
* a benign fallback to a hard failure.
|
||
*/
|
||
export function categorizeAlarm(code) {
|
||
if (!code) return 'other';
|
||
return CATEGORY_TABLE[String(code).toUpperCase()] || 'other';
|
||
}
|
||
|
||
/**
|
||
* Return a short human label for the alarm code. Falls back to a
|
||
* Title-Cased split of the raw constant when we don't recognise it,
|
||
* so `SOMETHING_NEW_DOWN` becomes "Something New Down" instead of
|
||
* being dumped raw.
|
||
*/
|
||
export function humanizeAlarmCode(code) {
|
||
if (!code) return 'Unknown alarm';
|
||
const key = String(code).toUpperCase();
|
||
if (HUMANIZE_TABLE[key]) return HUMANIZE_TABLE[key];
|
||
return key
|
||
.toLowerCase()
|
||
.split('_')
|
||
.filter(Boolean)
|
||
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
||
.join(' ');
|
||
}
|
||
|
||
/**
|
||
* Roll alarm samples up by (code + severity). Preserves the newest
|
||
* timestamp per rollup and orders results critical → major → minor,
|
||
* then by count descending. This turns a wall of 20 near-identical
|
||
* NETWORK_ANYNETLINK_DOWN JSON blobs into one scannable line.
|
||
*
|
||
* Shape of the returned rollups:
|
||
* { code, severity, count, newestTs }
|
||
*
|
||
* (Kept minimal — the caller decorates with humanized label + category
|
||
* so we don't force every downstream to import both tables.)
|
||
*/
|
||
export function rollupAlarms(samples) {
|
||
if (!Array.isArray(samples)) return [];
|
||
const byKey = new Map();
|
||
for (const s of samples) {
|
||
if (!s) continue;
|
||
const key = `${s.severity}::${s.code}`;
|
||
const prev = byKey.get(key);
|
||
if (prev) {
|
||
prev.count += 1;
|
||
if (!prev.newestTs || (s.ts && String(s.ts) > String(prev.newestTs))) {
|
||
prev.newestTs = s.ts;
|
||
}
|
||
} else {
|
||
byKey.set(key, {
|
||
code: s.code,
|
||
severity: s.severity,
|
||
count: 1,
|
||
newestTs: s.ts,
|
||
});
|
||
}
|
||
}
|
||
const sevRank = { critical: 0, major: 1, minor: 2, unknown: 3 };
|
||
return [...byKey.values()].sort((a, b) => {
|
||
const ra = sevRank[a.severity] ?? 9;
|
||
const rb = sevRank[b.severity] ?? 9;
|
||
if (ra !== rb) return ra - rb;
|
||
return b.count - a.count;
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Overlay / physical / device alarms are the ones most likely to
|
||
* explain poor voice quality. LAN/DHCP/config usually do not.
|
||
*/
|
||
export function isVoiceRelevantAlarm(code) {
|
||
const cat = categorizeAlarm(code);
|
||
return cat === 'overlay' || cat === 'physical' || cat === 'device';
|
||
}
|
||
|
||
/**
|
||
* Count alarms by category from a set of samples.
|
||
* Returns `{ overlay, physical, device, other, total }`.
|
||
*/
|
||
export function countByCategory(samples) {
|
||
const counts = { overlay: 0, physical: 0, device: 0, other: 0, total: 0 };
|
||
if (!Array.isArray(samples)) return counts;
|
||
for (const s of samples) {
|
||
if (!s) continue;
|
||
counts.total += 1;
|
||
counts[categorizeAlarm(s.code)] += 1;
|
||
}
|
||
return counts;
|
||
}
|
||
|
||
/**
|
||
* Coerce Prisma alarm time (ISO string or epoch ms) to ms since epoch.
|
||
* CloudGenix sometimes stores µs-scale ids (>1e14) — those are scaled down.
|
||
*/
|
||
export function coerceAlarmTsMs(ts) {
|
||
if (ts == null || ts === '') return null;
|
||
if (typeof ts === 'number' && Number.isFinite(ts)) {
|
||
return ts > 1e14 ? Math.floor(ts / 1000) : ts;
|
||
}
|
||
const asStr = String(ts).trim();
|
||
if (/^\d+$/.test(asStr)) {
|
||
const n = Number(asStr);
|
||
return n > 1e14 ? Math.floor(n / 1000) : n;
|
||
}
|
||
const parsed = Date.parse(asStr);
|
||
return Number.isFinite(parsed) ? parsed : null;
|
||
}
|
||
|
||
/**
|
||
* Given an alarm timestamp (ISO string), return a coarse "how long
|
||
* ago" label. Handles ms drift on both sides — "now", "5m ago",
|
||
* "2h ago", "3d ago". Falls back to the raw ts on parse failure.
|
||
*/
|
||
export function humanizeAge(ts, now = Date.now()) {
|
||
if (!ts) return '';
|
||
const ms = coerceAlarmTsMs(ts);
|
||
if (ms == null) return '';
|
||
const deltaSec = Math.max(0, Math.floor((now - ms) / 1000));
|
||
if (deltaSec < 60) return 'just now';
|
||
if (deltaSec < 3600) return `${Math.floor(deltaSec / 60)}m ago`;
|
||
if (deltaSec < 86_400) return `${Math.floor(deltaSec / 3600)}h ago`;
|
||
return `${Math.floor(deltaSec / 86_400)}d ago`;
|
||
}
|
||
|
||
/**
|
||
* Human-friendly minutes label (mirrors the renderer helpers).
|
||
* Exported so the alarm check can label windows consistently without
|
||
* a separate helper import.
|
||
*/
|
||
export function humanizeWindow(minutes) {
|
||
if (!Number.isFinite(minutes) || minutes <= 0) return `${minutes}m`;
|
||
if (minutes >= 1440 && minutes % 1440 === 0) return `${minutes / 1440}d`;
|
||
if (minutes >= 60 && minutes % 60 === 0) return `${minutes / 60}h`;
|
||
return `${minutes}m`;
|
||
}
|