// 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} 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, }; }