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>
255 lines
10 KiB
JavaScript
255 lines
10 KiB
JavaScript
// 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;
|
||
}
|