Adds three new SD-WAN checks (wanAppRtpMos/Loss/Jitter) that measure
REAL voice-traffic quality on actual RTP frames via Prisma DPI, not
synthetic link probes. Graded against the WORST 5-minute window so
transient degradation the 24h link-probe averages smooth away
actually surfaces.
Voice-app selection is tenant-configurable via PRISMA_APP_ID_VOICE +
PRISMA_APP_NAME_VOICE (Webex_Calling_RTP recommended for Webex
Calling shops — the Webex-specific DPI signature excludes non-Webex
UDP noise). Legacy PRISMA_APP_ID_RTP_BASE still honored with a
one-time deprecation warning.
Widens the default WAN look-back from 24h to 7 days: per-app metrics
only get datapoints when calls actually happen, so sporadic Webex
Calling stores (3-4 calls/day) need a wider window for worst-window
statistics to be meaningful. Interval picker snaps 7d to 1hour
buckets (168 pts) to keep payloads bounded while preserving
worst-hour granularity. Hard-capped at 7d — beyond that Prisma
downsamples to 1-day buckets and the signal collapses.
Also:
- Client-side concurrency limiter (PRISMA_MAX_INFLIGHT, default 3)
to prevent 429 cascades when /voicediag fans out 10+ parallel
metric fetches
- "View in Prisma UI" deep links in both /phonestatus WAN follow-up
and /voicediag details, threading through a new
integrations/paloalto/urls.js builder
- humanizeMetricUnit maps raw API unit strings ("percentage",
"milliseconds") to display symbols ("%", "ms") to fix
"11.83percentage" leaking to the UI
- getAppAudio envelope distinguishes not-configured / fetch-failed /
no-traffic states so misleading "set env var" messages don't fire
when the real problem is a 429
Co-authored-by: Cursor <cursoragent@cursor.com>
181 lines
6.9 KiB
JavaScript
181 lines
6.9 KiB
JavaScript
// Unit tests for services/enrichment/alarmSemantics.js
|
||
//
|
||
// Coverage targets:
|
||
// - Known codes map to their expected category
|
||
// - Unknown codes fall through to 'other' (never throw)
|
||
// - Humanizer returns a friendly string; unknown codes become
|
||
// Title-Cased words rather than a raw constant
|
||
// - rollupAlarms collapses (code+severity) tuples, preserves the
|
||
// newest timestamp, and orders critical → major → minor
|
||
// - countByCategory sums correctly and defaults missing fields to 0
|
||
// - humanizeAge produces the expected "just now / Nm / Nh / Nd" bands
|
||
// - humanizeWindow formats 15/60/360/1440 minutes as 15m/1h/6h/1d
|
||
|
||
import test from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
|
||
import {
|
||
categorizeAlarm,
|
||
humanizeAlarmCode,
|
||
rollupAlarms,
|
||
countByCategory,
|
||
humanizeAge,
|
||
humanizeWindow,
|
||
} from '../services/enrichment/alarmSemantics.js';
|
||
|
||
// ─── categorizeAlarm ─────────────────────────────────────────────────
|
||
|
||
test('categorizeAlarm: overlay codes bucket as overlay', () => {
|
||
for (const code of [
|
||
'NETWORK_ANYNETLINK_DOWN',
|
||
'NETWORK_VPNLINK_DOWN',
|
||
'NETWORK_VPNLINK_FLAP',
|
||
'NETWORK_SITE_UNREACHABLE',
|
||
'NETWORK_STANDBY_LINK_DOWN',
|
||
]) {
|
||
assert.equal(categorizeAlarm(code), 'overlay', `${code} → overlay`);
|
||
}
|
||
});
|
||
|
||
test('categorizeAlarm: physical WAN codes bucket as physical', () => {
|
||
for (const code of [
|
||
'NETWORK_INTERNET_DOWN',
|
||
'DEVICE_INTERFACE_DOWN',
|
||
'DEVICE_INTERFACE_STATE_CHANGED',
|
||
'NETWORK_LTE_LINK_DOWN',
|
||
]) {
|
||
assert.equal(categorizeAlarm(code), 'physical', `${code} → physical`);
|
||
}
|
||
});
|
||
|
||
test('categorizeAlarm: device / ION codes bucket as device', () => {
|
||
for (const code of [
|
||
'DEVICE_HB_MISSED',
|
||
'DEVICE_UNREACHABLE',
|
||
'DEVICE_HIGH_CPU',
|
||
'DEVICE_REBOOT',
|
||
]) {
|
||
assert.equal(categorizeAlarm(code), 'device');
|
||
}
|
||
});
|
||
|
||
test('categorizeAlarm: unknown / falsy → other (never throws)', () => {
|
||
assert.equal(categorizeAlarm('SOMETHING_BRAND_NEW'), 'other');
|
||
assert.equal(categorizeAlarm(''), 'other');
|
||
assert.equal(categorizeAlarm(null), 'other');
|
||
assert.equal(categorizeAlarm(undefined), 'other');
|
||
});
|
||
|
||
test('categorizeAlarm: case-insensitive on input', () => {
|
||
assert.equal(categorizeAlarm('network_anynetlink_down'), 'overlay');
|
||
});
|
||
|
||
// ─── humanizeAlarmCode ───────────────────────────────────────────────
|
||
|
||
test('humanizeAlarmCode: known codes → friendly labels', () => {
|
||
assert.equal(humanizeAlarmCode('NETWORK_ANYNETLINK_DOWN'), 'SD-WAN overlay tunnel down');
|
||
assert.equal(humanizeAlarmCode('DEVICE_HB_MISSED'), 'ION heartbeat missed');
|
||
assert.equal(humanizeAlarmCode('NETWORK_INTERNET_DOWN'), 'Internet WAN circuit down');
|
||
});
|
||
|
||
test('humanizeAlarmCode: unknown code → Title Cased fallback (not raw constant)', () => {
|
||
assert.equal(humanizeAlarmCode('SOMETHING_BRAND_NEW_DOWN'), 'Something Brand New Down');
|
||
});
|
||
|
||
test('humanizeAlarmCode: falsy → generic fallback', () => {
|
||
assert.equal(humanizeAlarmCode(''), 'Unknown alarm');
|
||
assert.equal(humanizeAlarmCode(null), 'Unknown alarm');
|
||
});
|
||
|
||
// ─── rollupAlarms ────────────────────────────────────────────────────
|
||
|
||
test('rollupAlarms: collapses (code + severity) tuples, keeps newest ts', () => {
|
||
const samples = [
|
||
{ code: 'X', severity: 'major', ts: '2026-07-09T10:00:00Z' },
|
||
{ code: 'X', severity: 'major', ts: '2026-07-09T12:00:00Z' },
|
||
{ code: 'X', severity: 'major', ts: '2026-07-09T09:00:00Z' },
|
||
{ code: 'Y', severity: 'critical', ts: '2026-07-09T08:00:00Z' },
|
||
];
|
||
const rollups = rollupAlarms(samples);
|
||
assert.equal(rollups.length, 2);
|
||
// Critical severity floats to the top regardless of count.
|
||
assert.equal(rollups[0].code, 'Y');
|
||
assert.equal(rollups[0].count, 1);
|
||
assert.equal(rollups[1].code, 'X');
|
||
assert.equal(rollups[1].count, 3);
|
||
// Newest ts survives.
|
||
assert.equal(rollups[1].newestTs, '2026-07-09T12:00:00Z');
|
||
});
|
||
|
||
test('rollupAlarms: sort — critical > major > minor, ties broken by count desc', () => {
|
||
const rollups = rollupAlarms([
|
||
{ code: 'A', severity: 'minor', ts: 't1' },
|
||
{ code: 'B', severity: 'major', ts: 't2' },
|
||
{ code: 'C', severity: 'critical', ts: 't3' },
|
||
{ code: 'D', severity: 'major', ts: 't4' },
|
||
{ code: 'D', severity: 'major', ts: 't5' },
|
||
]);
|
||
assert.deepEqual(
|
||
rollups.map((r) => r.code),
|
||
['C', 'D', 'B', 'A'],
|
||
'critical (C), then major sorted by count (D×2, B×1), then minor',
|
||
);
|
||
});
|
||
|
||
test('rollupAlarms: null/empty → []', () => {
|
||
assert.deepEqual(rollupAlarms(null), []);
|
||
assert.deepEqual(rollupAlarms([]), []);
|
||
assert.deepEqual(rollupAlarms(undefined), []);
|
||
});
|
||
|
||
// ─── countByCategory ─────────────────────────────────────────────────
|
||
|
||
test('countByCategory: counts overlay/physical/device/other + total', () => {
|
||
const c = countByCategory([
|
||
{ code: 'NETWORK_ANYNETLINK_DOWN' },
|
||
{ code: 'NETWORK_ANYNETLINK_DOWN' },
|
||
{ code: 'NETWORK_INTERNET_DOWN' },
|
||
{ code: 'DEVICE_HB_MISSED' },
|
||
{ code: 'UNRECOGNIZED_FOO' },
|
||
]);
|
||
assert.deepEqual(c, {
|
||
overlay: 2, physical: 1, device: 1, other: 1, total: 5,
|
||
});
|
||
});
|
||
|
||
test('countByCategory: empty → all zeros', () => {
|
||
assert.deepEqual(countByCategory([]), {
|
||
overlay: 0, physical: 0, device: 0, other: 0, total: 0,
|
||
});
|
||
});
|
||
|
||
// ─── humanizeAge ─────────────────────────────────────────────────────
|
||
|
||
test('humanizeAge: coarse buckets', () => {
|
||
const now = Date.parse('2026-07-09T14:00:00Z');
|
||
const at = (offsetSec) =>
|
||
humanizeAge(new Date(now - offsetSec * 1000).toISOString(), now);
|
||
assert.equal(at(10), 'just now');
|
||
assert.equal(at(120), '2m ago');
|
||
assert.equal(at(3600 * 2), '2h ago');
|
||
assert.equal(at(86400 * 3), '3d ago');
|
||
});
|
||
|
||
test('humanizeAge: missing / invalid ts → empty string', () => {
|
||
assert.equal(humanizeAge(null), '');
|
||
assert.equal(humanizeAge(undefined), '');
|
||
assert.equal(humanizeAge('not-a-date'), '');
|
||
});
|
||
|
||
// ─── humanizeWindow ──────────────────────────────────────────────────
|
||
|
||
test('humanizeWindow: canonical values', () => {
|
||
assert.equal(humanizeWindow(15), '15m');
|
||
assert.equal(humanizeWindow(45), '45m');
|
||
assert.equal(humanizeWindow(60), '1h');
|
||
assert.equal(humanizeWindow(360), '6h');
|
||
assert.equal(humanizeWindow(1440), '1d');
|
||
assert.equal(humanizeWindow(2880), '2d');
|
||
assert.equal(humanizeWindow(10080), '7d',
|
||
'the new default WAN window must round-trip cleanly');
|
||
});
|