collabSupport/services/voiceDiag/checks/wan/_helpers.js
jmcqueen b802383441 Add Prisma SD-WAN voice-quality enrichment for /phonestatus + /voicediag
Introduces a full Palo Alto Prisma SD-WAN integration (dual-mode SASE
OAuth 2.0 / legacy CloudGenix auth, pagination, 429 backoff, session
priming) that surfaces per-path latency/jitter/loss/MOS, site
healthscore, link state, and alarm data for a store. Wired into the
/phonestatus WAN follow-up and eight new /voicediag WAN checks graded
against ITU-T G.114 / RFC 3550 defaults (env-overridable via
WAN_STANDARD_*).

Also adds a shape-aware detail renderer for /voicediag (per-link
tables with verdict icons instead of a stringified JSON dump) and a
--window flag (15m / 1h / 6h / 24h / 1d, env default via
WAN_STANDARD_WINDOW_MINUTES) so operators can widen the look-back
without redeploying. scripts/prismaProbe.js is bundled as a CLI for
schema iteration against a live tenant.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 09:45:29 -04:00

213 lines
7.7 KiB
JavaScript

// src/services/voiceDiag/checks/wan/_helpers.js
//
// Shared kill-switch + threshold accessors for the /voicediag WAN
// bucket. Mirrors services/voiceDiag/checks/port/_helpers.js in
// intent — one file per check for the actual logic, this file for
// the common env-reading and formatting boilerplate.
//
// Threshold accessors read `process.env` at *call time* rather than
// at import time. That's what lets tests do `process.env.X = '...'`
// + await the check without needing a module cache reset. The
// downside is a couple of extra env reads per run, which is nothing
// against the network work each WAN check does (or doesn't do — the
// checks reuse data already fetched during buildContext()).
/**
* Global kill-switch for the WAN bucket. Env
* `WAN_STANDARD_ENABLED=false` returns a short "WAN checks are
* currently disabled" skip result that every WAN check can return
* verbatim. Feature-config + port-hygiene checks are intentionally
* *not* gated by this — this only silences the SD-WAN bucket while
* the underlying Prisma tenant is being reconfigured or the
* integration is being validated.
*
* Same contract as port/_helpers.js:maybeSkippedByKillSwitch —
* returns null when enabled (caller proceeds) or a full CheckResult
* with `status: 'skipped'` when disabled (caller returns it as-is).
*
* @param {object} check the check descriptor (used for label only)
* @returns {null | {status:'skipped', message:string, details:object, remediation:null}}
*/
export function maybeSkippedByKillSwitch(check) {
const raw = String(process.env.WAN_STANDARD_ENABLED ?? 'true').toLowerCase().trim();
const enabled = !(raw === 'false' || raw === '0' || raw === 'no' || raw === 'off');
if (enabled) return null;
return {
status: 'skipped',
message:
`${check.label} skipped — WAN_STANDARD_ENABLED=false (SD-WAN checks are silenced).`,
details: { killSwitch: 'WAN_STANDARD_ENABLED', value: raw },
remediation: null,
};
}
// ─── Threshold accessors ──────────────────────────────────────────
//
// Defaults match the ITU-T G.114 / RFC 3550 references for voice
// quality. Any of these can be overridden per-tenant via env; the
// renderer + the checks read from the same accessors so the icon
// in the phonestatus follow-up always agrees with the verdict in
// voicediag.
function num(envKey, fallback) {
const raw = Number(process.env[envKey]);
return Number.isFinite(raw) ? raw : fallback;
}
// Latency (one-way estimate, milliseconds)
export const getLatencyWarnMs = () => num('WAN_STANDARD_LATENCY_WARN_MS', 150);
export const getLatencyErrorMs = () => num('WAN_STANDARD_LATENCY_ERROR_MS', 400);
// Jitter (inter-packet arrival variation, milliseconds)
export const getJitterWarnMs = () => num('WAN_STANDARD_JITTER_WARN_MS', 30);
export const getJitterErrorMs = () => num('WAN_STANDARD_JITTER_ERROR_MS', 50);
// Packet loss (percent 0-100)
export const getLossWarnPct = () => num('WAN_STANDARD_LOSS_WARN_PCT', 1);
export const getLossErrorPct = () => num('WAN_STANDARD_LOSS_ERROR_PCT', 3);
// MOS (Mean Opinion Score, 1.0-5.0 — HIGHER is better, so warn/err
// mean "value FALLS BELOW this")
export const getMosWarn = () => num('WAN_STANDARD_MOS_WARN', 4.0);
export const getMosError = () => num('WAN_STANDARD_MOS_ERROR', 3.5);
// Healthscore (Prisma AIOps roll-up, 0-100 — HIGHER is better)
export const getHealthscoreWarn = () => num('WAN_STANDARD_HEALTHSCORE_WARN', 80);
export const getHealthscoreError = () => num('WAN_STANDARD_HEALTHSCORE_ERROR', 60);
// ─── Utilities used by multiple checks ────────────────────────────
/**
* Safe number-of-links accessor. WAN checks that grade the whole
* site (healthscore, alarms) don't care about link count, but the
* per-link checks below use it to short-circuit when there are no
* links at all (returns skipped with an actionable message).
*/
export function getLinks(ctx) {
const raw = ctx?.sdwanData?.links;
return Array.isArray(raw) ? raw : [];
}
/** Label a link for messages. Kept short so aggregate messages
* don't blow past Webex's chat readability. */
export function labelForLink(link) {
const name = link?.interfaceName || link?.interfaceId || 'link';
const el = link?.elementName ? `@${link.elementName}` : '';
const tx = link?.transportType ? ` [${link.transportType}]` : '';
return `${name}${el}${tx}`;
}
/**
* Evaluate a numeric per-link metric against warn/error thresholds
* across every path, returning a CheckResult. Worst path drives
* severity — a single bad link takes precedence over three good
* ones because "the phone routed over the bad path sounds terrible"
* is the ticket we're trying to catch.
*
* Consolidated here so the four LQM checks (latency / jitter /
* loss / mos) don't duplicate 40 lines of comparison logic — they
* pass config in, get a CheckResult out.
*
* @param {object} args
* @param {Array<object>} args.links ctx.sdwanData.links
* @param {string} args.metricKey row property to read (e.g. 'latencyMs')
* @param {string} args.label label for messages (e.g. 'latency')
* @param {string} args.unit display unit (e.g. 'ms', '%', '')
* @param {number} args.warnThresh
* @param {number} args.errorThresh
* @param {boolean} args.lowIsBad true → warn/error when value FALLS BELOW threshold (MOS)
* @param {string} args.standardLabel for the "compliant range" phrasing
*/
export function evaluatePerLinkMetric({
links,
metricKey,
label,
unit,
warnThresh,
errorThresh,
lowIsBad = false,
standardLabel,
}) {
if (!Array.isArray(links) || links.length === 0) {
return {
status: 'skipped',
message: `No WAN paths reported for this site.`,
details: null,
remediation: null,
};
}
const withValue = links.filter((l) => Number.isFinite(l[metricKey]));
if (withValue.length === 0) {
return {
status: 'skipped',
message: `${label} not available from Prisma for any path (partial fetch).`,
details: null,
remediation: null,
};
}
const grade = (v) => {
if (lowIsBad) {
if (v < errorThresh) return 'error';
if (v < warnThresh) return 'warn';
return 'ok';
}
if (v > errorThresh) return 'error';
if (v > warnThresh) return 'warn';
return 'ok';
};
const perLink = withValue.map((l) => ({
link: labelForLink(l),
value: l[metricKey],
interfaceId: l.interfaceId,
verdict: grade(l[metricKey]),
}));
const errors = perLink.filter((r) => r.verdict === 'error');
const warns = perLink.filter((r) => r.verdict === 'warn');
const worstLine = (rows) =>
rows.map((r) => `${r.link} (${r.value}${unit})`).join(', ');
const details = {
total: perLink.length,
ok: perLink.filter((r) => r.verdict === 'ok').length,
warn: warns.length,
error: errors.length,
perLink,
warnThresh,
errorThresh,
standardLabel,
};
if (errors.length > 0) {
return {
status: 'error',
message:
`${errors.length} path(s) with ${label} past error threshold ` +
`(${standardLabel}): ${worstLine(errors)}.`,
details,
remediation: null,
};
}
if (warns.length > 0) {
return {
status: 'warn',
message:
`${warns.length} path(s) with elevated ${label} ` +
`(${standardLabel}): ${worstLine(warns)}.`,
details,
remediation: null,
};
}
return {
status: 'ok',
message: `All ${perLink.length} path(s) with ${label} in the compliant range (${standardLabel}).`,
details,
remediation: null,
};
}