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>
217 lines
7.3 KiB
JavaScript
217 lines
7.3 KiB
JavaScript
// 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;
|
||
}
|