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>
213 lines
7.7 KiB
JavaScript
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,
|
|
};
|
|
}
|