collabSupport/services/renderers/wanDiagnosticsRenderer.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

255 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// src/services/renderers/wanDiagnosticsRenderer.js
//
// Pure markdown renderer for the /phonestatus WAN follow-up message.
// Takes a `collectSdwanForStore(storeNum)` result and produces the
// section that shows healthscore + per-path LQM + active alarms.
//
// Design mirrors renderDectDiagnosticsMarkdown in
// services/renderers/phoneStatusRenderer.js:
// - Pure function, no I/O.
// - Returns '' when there's genuinely nothing to say (caller
// no-ops on empty string).
// - Bullet lists only — Webex markdown doesn't render tables
// reliably, and the port-hygiene checks already prove bullets
// scan fine for per-device drilldowns.
// - Threshold-based icons (✅ good, ⚠️ warn, ❌ error, ❓ unknown)
// so a scanning operator can locate the bad link visually
// without reading numbers.
// Thresholds are read from env at render time so the rendered
// icons stay in sync with the check bucket verdicts. Same defaults
// as services/voiceDiag/checks/wan/_helpers.js — kept in sync by
// convention (there's a regression test that pins them together).
function readThresholds() {
const num = (k, fallback) => {
const raw = Number(process.env[k]);
return Number.isFinite(raw) ? raw : fallback;
};
return {
latencyWarn: num('WAN_STANDARD_LATENCY_WARN_MS', 150),
latencyError: num('WAN_STANDARD_LATENCY_ERROR_MS', 400),
jitterWarn: num('WAN_STANDARD_JITTER_WARN_MS', 30),
jitterError: num('WAN_STANDARD_JITTER_ERROR_MS', 50),
lossWarn: num('WAN_STANDARD_LOSS_WARN_PCT', 1),
lossError: num('WAN_STANDARD_LOSS_ERROR_PCT', 3),
mosWarn: num('WAN_STANDARD_MOS_WARN', 4.0),
mosError: num('WAN_STANDARD_MOS_ERROR', 3.5),
hsWarn: num('WAN_STANDARD_HEALTHSCORE_WARN', 80),
hsError: num('WAN_STANDARD_HEALTHSCORE_ERROR', 60),
};
}
/**
* Render a Prisma SD-WAN follow-up message.
*
* @param {object} data Result from collectSdwanForStore(storeNum).
* @param {object} [opts]
* @param {string} [opts.storeNum] used in the section header
* @param {boolean} [opts.footer=true] emit the "pulled at HH:MM:SS" footer
* @returns {string} markdown (or '' when there's nothing to render)
*/
export function renderWanDiagnosticsMarkdown(data, opts = {}) {
const { storeNum, footer = true } = opts;
if (!data || typeof data !== 'object') return '';
// No-site case: this is the "not a Prisma-managed store" happy
// path. We deliberately DON'T show a header for it — the follow-up
// caller only kicks the runner when discovery said yes, so seeing
// this branch here means something raced. Return ''.
if (!data.site) return '';
const t = readThresholds();
let out = `**WAN Diagnostics (Prisma SD-WAN) — Store ${storeNum || data.storeNum || '?'}**\n\n`;
// Site + healthscore header line.
const hs = data.healthscore;
const hsIcon = hs?.value == null ? '❓' : iconFromNumeric(hs.value, t.hsError, t.hsWarn, /* lowIsBad */ true);
const hsText = hs?.value == null ? 'n/a' : `${hs.value}/100`;
const elCount = Array.isArray(data.elements) ? data.elements.length : 0;
const linkCount = Array.isArray(data.links) ? data.links.length : 0;
const windowLabel = data.window?.minutes ? ` • Window: ${humanWindowLabel(data.window.minutes)}` : '';
out +=
`Site: **${data.site.name}** (${elCount} element${elCount === 1 ? '' : 's'}, ` +
`${linkCount} WAN path${linkCount === 1 ? '' : 's'}) • Healthscore: ${hsIcon} ${hsText}` +
`${windowLabel}\n\n`;
// Per-path bullet list. Ordered by "worst path first" so a
// scanning operator sees the offender at the top.
if (linkCount > 0) {
const ranked = [...data.links].sort((a, b) => rankLink(b, t) - rankLink(a, t));
for (const link of ranked) {
out += renderOneLink(link, t);
}
} else {
out += `_No WAN path metrics available for this site._\n`;
}
// Alarms summary (only if any are active). Samples are rolled up
// by (code + severity) so 20 identical NETWORK_ANYNETLINK_DOWN
// events show as one line with "×20" instead of pasting 20 nearly-
// identical JSON blobs into chat. Full alarm detail belongs in the
// Prisma UI — this is a summary surface.
const totalAlarms =
(data.alarms?.last1h?.critical || 0) +
(data.alarms?.last1h?.major || 0) +
(data.alarms?.last1h?.minor || 0);
if (totalAlarms > 0) {
const { critical = 0, major = 0, minor = 0 } = data.alarms.last1h;
const parts = [];
if (critical > 0) parts.push(`${critical} critical`);
if (major > 0) parts.push(`${major} major`);
if (minor > 0) parts.push(`${minor} minor`);
out += `\n🚨 Alarms (last 1h): ${parts.join(', ')}\n`;
const rollups = rollupAlarms(data.alarms.samples || []);
for (const r of rollups.slice(0, 5)) {
const when = r.newestTs ? new Date(r.newestTs).toLocaleTimeString() : '';
const count = r.count > 1 ? ` ×${r.count}` : '';
out +=
` - ${sevIcon(r.severity)} \`${r.code}\`${count}` +
(when ? ` (most recent ${when})` : '') +
`\n`;
}
if (rollups.length > 5) {
out += ` - _+${rollups.length - 5} more alarm code${rollups.length - 5 === 1 ? '' : 's'}._\n`;
}
}
// Per-metric fetch failures shown as small warnings so the operator
// knows the display is incomplete rather than "all clear".
if (Array.isArray(data.errors) && data.errors.length > 0) {
out += `\n_Partial fetch:_\n`;
for (const e of data.errors) {
out += ` - \`${e.scope}\` failed: ${e.message}\n`;
}
}
if (footer) {
out += `\n*WAN metrics pulled at ${new Date().toLocaleTimeString()} from Prisma SD-WAN. Use \`/voicediag ${storeNum || data.storeNum} --only wanLatency,wanJitter,wanLoss,wanMos,wanHealthscore,wanLinkState,wanAlarms\` for pass/warn/error breakdowns.*`;
}
return out.trim();
}
// ─── internals ─────────────────────────────────────────────────────
/**
* Human-friendly window label: 15m / 1h / 6h / 24h.
* Mirrored from voiceDiagRenderer.js — kept in-file to avoid a
* tiny shared-utils import for a 5-line helper.
*/
function humanWindowLabel(minutes) {
if (!Number.isFinite(minutes) || minutes <= 0) return `${minutes}m`;
if (minutes >= 1440 && minutes % 1440 === 0) return `${minutes / 1440}d`;
if (minutes >= 60 && minutes % 60 === 0) return `${minutes / 60}h`;
return `${minutes}m`;
}
function renderOneLink(link, t) {
const nameLabel = link.interfaceName || link.interfaceId;
const transport = link.transportType ? ` [${link.transportType}]` : '';
const upIcon = link.up === null ? '❓' : (link.up ? '✅' : '❌');
const upText = link.up === null ? 'unknown' : (link.up ? 'up' : 'DOWN');
let out = `- ${upIcon} **${nameLabel}**${transport}${upText}`;
const parts = [];
parts.push(fmtMetric('latency', link.latencyMs, 'ms', t.latencyError, t.latencyWarn));
parts.push(fmtMetric('jitter', link.jitterMs, 'ms', t.jitterError, t.jitterWarn));
parts.push(fmtMetric('loss', link.lossPct, '%', t.lossError, t.lossWarn));
parts.push(fmtMetric('MOS', link.mos, '', t.mosError, t.mosWarn, /* highIsGood */ true));
const filled = parts.filter(Boolean);
if (filled.length > 0) {
out += `\n ${filled.join(' • ')}`;
}
out += '\n';
return out;
}
function fmtMetric(label, value, unit, errThresh, warnThresh, highIsGood = false) {
if (value === null || value === undefined) return '';
const icon = iconFromNumeric(value, errThresh, warnThresh, /* lowIsBad */ highIsGood);
return `${icon} ${label} ${value}${unit}`;
}
/**
* Icon selector.
*
* For most WAN metrics (latency/jitter/loss), HIGHER is worse. For
* MOS + healthscore, LOWER is worse. `lowIsBad` flips the sense.
*
* @param {number} value
* @param {number} errThresh numeric threshold for error severity
* @param {number} warnThresh numeric threshold for warn severity
* @param {boolean} lowIsBad true → low values trigger warn/error
*/
function iconFromNumeric(value, errThresh, warnThresh, lowIsBad = false) {
if (lowIsBad) {
if (value < errThresh) return '❌';
if (value < warnThresh) return '⚠️';
return '✅';
}
if (value > errThresh) return '❌';
if (value > warnThresh) return '⚠️';
return '✅';
}
function sevIcon(sev) {
if (sev === 'critical') return '🔴';
if (sev === 'major') return '🟠';
if (sev === 'minor') return '🟡';
return '⚪';
}
/**
* Roll alarm samples up by (code + severity). Preserves the newest
* timestamp per rollup and orders results critical → major → minor,
* then by count descending. This turns a wall of 20 near-identical
* NETWORK_ANYNETLINK_DOWN JSON blobs into one scannable line.
*/
function rollupAlarms(samples) {
const byKey = new Map();
for (const s of samples) {
if (!s) continue;
const key = `${s.severity}::${s.code}`;
const prev = byKey.get(key);
if (prev) {
prev.count += 1;
if (!prev.newestTs || (s.ts && String(s.ts) > String(prev.newestTs))) {
prev.newestTs = s.ts;
}
} else {
byKey.set(key, {
code: s.code,
severity: s.severity,
count: 1,
newestTs: s.ts,
});
}
}
const sevRank = { critical: 0, major: 1, minor: 2, unknown: 3 };
return [...byKey.values()].sort((a, b) => {
const ra = sevRank[a.severity] ?? 9;
const rb = sevRank[b.severity] ?? 9;
if (ra !== rb) return ra - rb;
return b.count - a.count;
});
}
/**
* Rank a link on a 0-100 badness scale so the worst path floats to
* the top of the display. Adds contributions from each metric
* according to how far past the warn/error thresholds it is.
*/
function rankLink(link, t) {
let score = 0;
if (link.up === false) score += 100;
if (link.latencyMs != null && link.latencyMs > t.latencyWarn) score += link.latencyMs > t.latencyError ? 30 : 10;
if (link.jitterMs != null && link.jitterMs > t.jitterWarn) score += link.jitterMs > t.jitterError ? 20 : 5;
if (link.lossPct != null && link.lossPct > t.lossWarn) score += link.lossPct > t.lossError ? 25 : 8;
if (link.mos != null && link.mos < t.mosWarn) score += link.mos < t.mosError ? 25 : 8;
return score;
}