collabSupport/services/voiceDiag/checks/wan/wanAlarms.js
jmcqueen 90a56c4640 Fix Prisma overlay tunnel discovery with site-scoped query_params.
Use topology/links and anynetlinks filters with eq/in operators so
store WAN follow-ups return real peer tunnels instead of unscoped dumps.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 09:01:40 -04:00

217 lines
7.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// src/services/voiceDiag/checks/wan/wanAlarms.js
//
// Recent Prisma alarms surface. Not a threshold check — the
// severity is a direct pass-through of what Prisma raised:
//
// - critical → error
// - major → warn
// - minor → info-only (stays 'ok', but details show the count)
//
// The window matches whatever `collectSdwanForStore` used — same
// value the /voicediag "WAN window" banner shows. An "active
// incident" that Prisma sees is materially more urgent than a
// snapshot LQM anomaly, so this deliberately runs even when the
// per-metric checks are all green — a critical alarm 45 min ago
// is still the operator's problem.
//
// The message field DOES NOT dump raw JSON. Prisma populates each
// alarm's `info` field with a nested object (e.g. `{ vpn_reasons:
// [{ code: 'NETWORK_VPNLINK_DOWN', vpnlink_id: '...' }, …] }`) that
// is useful to a person reading the Prisma UI but noise in a chat
// message. Instead we roll samples up by (code + severity), humanize
// the code, and mention the newest occurrence with an "N min ago"
// style relative timestamp.
//
// When alarms fire but every physical Link State check is green,
// the message calls out the mismatch and explains it (usually the
// overlay/VPN mesh is flapping while the physical circuits stay up).
// This addresses the "looks green but 20 alarms" cognitive dissonance
// operators would otherwise have to reason through themselves.
import { maybeSkippedByKillSwitch } from './_helpers.js';
import {
categorizeAlarm,
humanizeAlarmCode,
rollupAlarms,
countByCategory,
humanizeAge,
humanizeWindow,
} from '../../../enrichment/alarmSemantics.js';
export const WAN_ALARMS_STANDARDS = Object.freeze({
criticalAllowed: 0,
majorAllowed: 0,
});
export const wanAlarmsCheck = {
id: 'wanAlarms',
// Label is intentionally generic (no window suffix) — the effective
// window is dynamic (comes from ctx.sdwanData.window.alarmMinutes)
// and gets baked into the message rather than the label, since
// labels are static across the process lifetime.
label: 'SD-WAN Alarms',
requires: ['sdwanSite'],
scope: null,
standards: WAN_ALARMS_STANDARDS,
async run(ctx) {
const skip = maybeSkippedByKillSwitch(wanAlarmsCheck);
if (skip) return skip;
const alarms = ctx.sdwanData?.alarms;
if (!alarms) {
return {
status: 'skipped',
message: 'Alarms feed not available from Prisma (partial fetch).',
details: null,
remediation: null,
};
}
const { critical = 0, major = 0, minor = 0 } = alarms.last1h || {};
const samples = Array.isArray(alarms.samples) ? alarms.samples : [];
const alarmWindowMin = ctx.sdwanData?.window?.alarmMinutes || 60;
const windowLabel = humanizeWindow(alarmWindowMin);
// Roll up samples so 20 identical NETWORK_ANYNETLINK_DOWN alarms
// become 1 line with "×20" — same shape the WAN follow-up renderer
// uses, kept consistent by pulling the helper from a shared module.
const rollups = rollupAlarms(samples);
const byCategory = countByCategory(samples);
// Physical vs overlay heuristic. Prefer structured tunnels when
// present; fall back to link-state-only.
const linksAllUp = allPhysicalLinksUp(ctx?.sdwanData?.links);
const tunnels = Array.isArray(ctx?.sdwanData?.tunnels) ? ctx.sdwanData.tunnels : [];
const tunnelsDown = tunnels.filter((t) => t.up === false).length;
const details = {
critical, major, minor,
window: windowLabel,
byCategory,
tunnelsDown,
recentSamples: rollups.slice(0, 3).map((r) => ({
code: r.code,
humanized: humanizeAlarmCode(r.code),
category: categorizeAlarm(r.code),
severity: r.severity,
count: r.count,
age: humanizeAge(r.newestTs),
ts: r.newestTs,
})),
};
if (critical > 0) {
return {
status: 'error',
message: buildMessage({
count: critical, severityLabel: 'critical',
windowLabel, rollups, byCategory, linksAllUp, tunnelsDown,
}),
details,
remediation: null,
};
}
if (major > 0) {
return {
status: 'warn',
message: buildMessage({
count: major, severityLabel: 'major',
windowLabel, rollups, byCategory, linksAllUp, tunnelsDown,
}),
details,
remediation: null,
};
}
if (minor > 0) {
return {
status: 'ok',
message:
`${minor} minor alarm${minor === 1 ? '' : 's'} in the last ` +
`${windowLabel}, no critical or major.`,
details,
remediation: null,
};
}
return {
status: 'ok',
message: `No alarms raised in the last ${windowLabel}.`,
details,
remediation: null,
};
},
};
/**
* Build the WARN / ERROR message body. Structure:
* <N> <sev> alarm(s) in last <window> — most common: <humanized code>
* (×N, latest <N> min ago). <optional physical-vs-overlay hint>.
*/
function buildMessage({
count, severityLabel, windowLabel, rollups, byCategory, linksAllUp, tunnelsDown = 0,
}) {
// Top rollup entry drives the human summary. Preserves severity
// ordering (critical > major > minor) so the "most notable" alarm
// is what we cite even if a lower-severity code has a higher count.
const top = rollups[0];
const parts = [
`${count} ${severityLabel} alarm${count === 1 ? '' : 's'} in the last ${windowLabel}`,
];
if (top) {
const label = humanizeAlarmCode(top.code);
const suffix = [];
if (top.count > 1) suffix.push(`×${top.count}`);
if (top.newestTs) suffix.push(`latest ${humanizeAge(top.newestTs)}`);
const suffixStr = suffix.length > 0 ? ` (${suffix.join(', ')})` : '';
parts.push(` — most common: ${label}${suffixStr}`);
}
// Physical-vs-overlay clarifier — the most common source of
// "looks green but 20 alarms" confusion. Only emit when we
// can confidently distinguish the two cases.
const overlayHeavy = byCategory.overlay > 0
&& byCategory.overlay >= byCategory.physical;
if (overlayHeavy && linksAllUp === true) {
const tunnelBit = tunnelsDown > 0
? ` ${tunnelsDown} overlay tunnel(s) currently report DOWN.`
: '';
parts.push(
'. These affect SD-WAN overlay/VPN tunnels between sites; ' +
'physical WAN paths are all up per the Link State check.' +
tunnelBit,
);
} else if (byCategory.physical > 0 && linksAllUp === false) {
parts.push(
'. Physical WAN interfaces are affected — see the Link State ' +
'check for the impacted path(s).',
);
} else if (byCategory.device > 0) {
parts.push('. Device / ION appliance affected.');
} else {
parts.push('.');
}
return parts.join('');
}
/**
* True iff every physical link on the site reports up. Returns
* `null` when the link array is missing or empty so the caller can
* decide whether to emit the physical-vs-overlay hint at all.
*/
function allPhysicalLinksUp(links) {
if (!Array.isArray(links) || links.length === 0) return null;
let seen = 0;
for (const l of links) {
// Unknown state → treat as "cannot confirm" and short-circuit
// rather than lie about the state.
if (l?.up === null || l?.up === undefined) continue;
seen += 1;
if (l.up === false) return false;
}
return seen > 0 ? true : null;
}