collabSupport/services/renderers/wanDiagnosticsRenderer.js
jmcqueen 7dc326c404 Split voice commands into status vs diag and redesign MPP phone output.
Replace /phonestatus follow-ups with /voicestatus, /wanstatus, /phonediag, and /dectdiag; extend /voicediag with relay probes and section-based MPP diagnostics.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 13:55:41 -04:00

488 lines
20 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 /wanstatus command.
// 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.
import {
categorizeAlarm,
humanizeAlarmCode,
rollupAlarms as sharedRollupAlarms,
humanizeAge,
isVoiceRelevantAlarm,
coerceAlarmTsMs,
} from '../enrichment/alarmSemantics.js';
import { formatDisplayTime } from '../../utils/time.js';
// 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`;
}
// Overlay / VPN tunnels — overall status; list downs only.
out += renderTunnelsSection(data.tunnels, data.links);
// Per-app "Application Path Details" section — real voice-quality
// signal from DPI on actual RTP traffic. Which app is measured
// depends on the tenant's configured voice app (Webex_Calling_RTP,
// rtp-base, MS_Teams_RTP, etc.). Shown BEFORE alarms because when
// it fires, it's usually more actionable than an overlay alarm:
// it's "your calls sounded bad at these times", not "a tunnel
// bounced but recovered". Feature-gated on PRISMA_APP_ID_VOICE
// (backwards-compat: PRISMA_APP_ID_RTP_BASE) — omitted entirely
// when not configured so this doesn't add empty sections for
// tenants that opted out.
if (data.appAudio) {
out += renderAppAudioSection(data.appAudio, data.window);
}
// Alarms: severity totals + code rollup + last 5 with timestamps.
// When voice DPI looks bad and recent alarms are overlay/physical/
// device, call out the correlation (not proof of causality).
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`);
const alarmWindowLabel = data.window?.alarmMinutes
? humanWindowLabel(data.window.alarmMinutes)
: '1h';
out += `\n🚨 Alarms (last ${alarmWindowLabel}): ${parts.join(', ')}\n`;
const samples = data.alarms.samples || [];
const rollups = sharedRollupAlarms(samples);
for (const r of rollups.slice(0, 5)) {
const label = humanizeAlarmCode(r.code);
const category = categorizeAlarm(r.code);
const age = r.newestTs ? humanizeAge(r.newestTs) : '';
const count = r.count > 1 ? ` ×${r.count}` : '';
out +=
` - ${sevIcon(r.severity)} **${label}**${count} \`${r.code}\`` +
` _(${category}${age ? `, ${age}` : ''})_\n`;
}
if (rollups.length > 5) {
out += ` - _+${rollups.length - 5} more alarm code${rollups.length - 5 === 1 ? '' : 's'}._\n`;
}
const recent = samples.slice(0, 5);
if (recent.length > 0) {
out += ` _Most recent:_\n`;
for (const s of recent) {
const label = humanizeAlarmCode(s.code);
const when = formatAlarmWhen(s.ts);
const voiceBit = isVoiceRelevantAlarm(s.code) ? ' · may affect voice' : '';
out +=
` - ${sevIcon(s.severity)} **${label}**` +
`${when ? `${when}` : ''}${voiceBit}\n`;
}
}
const voiceCorr = voiceAlarmCorrelationNote(data.appAudio, samples);
if (voiceCorr) out += ` _${voiceCorr}_\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) {
const store = storeNum || data.storeNum;
out += `\n*WAN metrics pulled at ${formatDisplayTime()} from Prisma SD-WAN. ` +
`Use \`/voicediag ${store} --only wanLatency,wanJitter,wanLoss,wanMos,wanHealthscore,wanLinkState,wanAlarms\` for link-probe breakdowns, ` +
`or \`/voicediag ${store} --only wanAppRtpMos,wanAppRtpLoss,wanAppRtpJitter\` for per-app RTP quality.*`;
}
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`;
}
/**
* Render the "Voice Traffic Quality" section — real DPI measurements
* on actual RTP frames rather than synthetic link probes. Shows
* worst-window / avg / % of samples degraded per metric, with icons
* keyed off the WORST-window value (that's the signal that actually
* correlates with "the operator got a bad-calls ticket").
*
* Section header includes the configured voice-app name (e.g.
* "Webex_Calling_RTP", "rtp-base") so the operator knows which DPI
* signature was measured — different apps have very different
* traffic patterns and one may show issues the other misses.
*
* @param {object} appAudio ctx.sdwanData.appAudio
* @param {object} window ctx.sdwanData.window
*/
function renderAppAudioSection(appAudio, window) {
if (!appAudio) return '';
const { mos, loss, jitter, bandwidth, appName, detailsUrl } = appAudio;
// If literally every metric is null/empty, skip entirely — usually
// means the tenant has the env var set but this site has no RTP
// traffic yet.
const anyData = [mos, loss, jitter, bandwidth].some(
(s) => s && s.validSamples > 0,
);
if (!anyData) return '';
const winLabel = window?.minutes ? humanWindowLabel(window.minutes) : '7d';
// Deep-link into the Strata Cloud Manager "Application Path
// Details" page for this site+app. Only rendered when a URL was
// computed (detailsUrl is null if either id is missing).
const linkSuffix = detailsUrl
? ` — [View in Prisma UI](${detailsUrl})`
: '';
let out = `\n**Voice Traffic Quality (${appName || 'voice'}, last ${winLabel})**${linkSuffix}\n`;
out += `_Measured on real RTP frames via Prisma DPI — worst-window matters more than avg for voice._\n`;
const mosLine = fmtAppMetricLine('MOS', mos, '', 3.5, 4.0, /* lowIsBad */ true);
const lossLine = fmtAppMetricLine('Loss', loss, '%', 15, 5, /* lowIsBad */ false);
const jitterLine = fmtAppMetricLine('Jitter', jitter, 'ms', 50, 30, /* lowIsBad */ false);
const bwLine = fmtAppMetricLine('Bandwidth', bandwidth, 'Mbps', null, null, /* lowIsBad */ false);
for (const line of [mosLine, lossLine, jitterLine, bwLine].filter(Boolean)) {
out += `- ${line}\n`;
}
// Phase 1: per-path_type breakout (VPN vs DirectInternet vs …).
const byPt = appAudio.byPathType || {};
const ptKeys = Object.keys(byPt);
if (ptKeys.length > 0) {
out += `_By path type:_\n`;
for (const pt of ptKeys) {
const row = byPt[pt];
const bits = [];
if (row.loss?.validSamples > 0) {
bits.push(`loss worst ${row.loss.max}%`);
}
if (row.jitter?.validSamples > 0) {
bits.push(`jitter worst ${row.jitter.max}ms`);
}
if (row.bandwidth?.validSamples > 0) {
bits.push(`bw avg ${row.bandwidth.avg}Mbps`);
}
if (bits.length) out += `- **${pt}:** ${bits.join(' · ')}\n`;
}
}
// Phase 2: per-circuit attribution when enabled.
const byPath = appAudio.byPath || {};
const pathKeys = Object.keys(byPath);
if (pathKeys.length > 0) {
out += `_By WAN circuit (path attribution):_\n`;
for (const pathId of pathKeys) {
const row = byPath[pathId];
const bits = [];
if (row.loss?.validSamples > 0) bits.push(`loss worst ${row.loss.max}%`);
if (row.jitter?.validSamples > 0) bits.push(`jitter worst ${row.jitter.max}ms`);
if (bits.length) out += `- \`${pathId}\`: ${bits.join(' · ')}\n`;
}
}
return out;
}
function renderTunnelsSection(tunnels, links) {
const list = Array.isArray(tunnels) ? tunnels : [];
if (list.length === 0) return '';
const physicalUp = Array.isArray(links)
&& links.length > 0
&& links.every((l) => l.up !== false);
const down = list.filter((t) => t.up === false);
const up = list.filter((t) => t.up === true);
const unknown = list.filter((t) => t.up == null);
const total = list.length;
let summary;
if (down.length === 0 && unknown.length === 0) {
summary = `✅ all ${total} up`;
} else if (down.length === 0 && up.length === 0) {
summary = `${total} inventoried (status unknown)`;
} else if (down.length === 0) {
summary = `${up.length}/${total} up` +
(unknown.length ? ` · ${unknown.length} unknown` : '');
} else {
summary = `${down.length} down / ${total}` +
(up.length ? ` · ${up.length} up` : '') +
(unknown.length ? ` · ${unknown.length} unknown` : '');
}
let out = `\n**Overlay tunnels** — ${summary}\n`;
// All-unknown: one inventory note, no peer spam.
if (down.length === 0 && up.length === 0 && unknown.length > 0) {
out += `- ❓ Tunnel inventory returned ${total} link(s) but peer/status fields were empty — check Prisma UI topology for this site.\n`;
return out;
}
// Only list downs. Healthy tunnels stay in the summary line.
const toShow = [...down].sort((a, b) => {
if (a.recentAlarm !== b.recentAlarm) return a.recentAlarm ? -1 : 1;
return String(a.peerLabel || '').localeCompare(String(b.peerLabel || ''));
}).slice(0, 8);
for (const t of toShow) {
const peer = (t.peerLabel && t.peerLabel !== 'peer')
? t.peerLabel
: (t.peerSiteId || 'peer');
const circuitBit = t.circuitName ? ` · ${t.circuitName}` : '';
const ifBit = t.relatedInterfaceId ? ` · if ${t.relatedInterfaceId}` : '';
const alarmBit = t.recentAlarm ? ' · ⚠️ recent alarm' : '';
out += `- ❌ **${peer}** — DOWN${circuitBit}${ifBit}${alarmBit}\n`;
}
if (down.length > toShow.length) {
out += `- _+${down.length - toShow.length} more down tunnel(s)._\n`;
}
if (down.length > 0 && physicalUp) {
out += `_Note: overlay down while physical WAN paths look up — voice may still be impacted._\n`;
}
return out;
}
/**
* Format an alarm timestamp for chat: wall clock + relative age.
*/
function formatAlarmWhen(ts) {
const ms = coerceAlarmTsMs(ts);
if (ms == null) return humanizeAge(ts) || '';
const wall = formatDisplayTime(new Date(ms));
const age = humanizeAge(new Date(ms).toISOString());
return age ? `${wall} (${age})` : wall;
}
/**
* Heuristic: when voice DPI is degraded and recent alarms are
* overlay/physical/device, note possible correlation (not causation).
*/
function voiceAlarmCorrelationNote(appAudio, samples) {
if (!appAudio || !Array.isArray(samples) || samples.length === 0) return '';
if (!voiceQualityLooksDegraded(appAudio)) return '';
const relevant = samples.filter((s) => isVoiceRelevantAlarm(s.code));
if (relevant.length === 0) return '';
const cats = new Set(relevant.map((s) => categorizeAlarm(s.code)));
const catList = [...cats].join('/');
return (
`Voice DPI looks degraded in this window and ${relevant.length} ` +
`${catList} alarm(s) may help explain it (correlation, not proof).`
);
}
function voiceQualityLooksDegraded(appAudio) {
const mosBad = appAudio.mos?.validSamples > 0 && appAudio.mos.min != null && appAudio.mos.min < 3.5;
const lossBad = appAudio.loss?.validSamples > 0 && appAudio.loss.max != null && appAudio.loss.max > 5;
const jitterBad = appAudio.jitter?.validSamples > 0 && appAudio.jitter.max != null && appAudio.jitter.max > 50;
return mosBad || lossBad || jitterBad;
}
/**
* One row of the voice-traffic-quality section.
*
* @param {string} label
* @param {object|null} summary {avg, min, max, samples, validSamples, ...}
* @param {string} unit display unit ("%", "ms", "", "Mbps")
* @param {number|null} errThresh
* @param {number|null} warnThresh
* @param {boolean} lowIsBad true for MOS (lower is worse)
*/
function fmtAppMetricLine(label, summary, unit, errThresh, warnThresh, lowIsBad) {
if (!summary) return '';
if (summary.validSamples === 0) {
return `❓ **${label}** — no data (${summary.samples || 0} samples, all null)`;
}
const worst = lowIsBad ? summary.min : summary.max;
let icon;
if (errThresh == null && warnThresh == null) {
icon = ''; // no threshold → context-only (bandwidth)
} else {
icon = iconFromNumeric(worst, errThresh, warnThresh, lowIsBad);
}
const worstLabel = lowIsBad ? 'worst (lowest)' : 'worst';
return (
`${icon} **${label}** — ${worstLabel}: **${worst}${unit}** • ` +
`avg: ${summary.avg}${unit}${summary.validSamples}/${summary.samples} samples ` +
`@ ${summary.interval || '5min'}`
);
}
function renderOneLink(link, t) {
const nameLabel = link.interfaceName || link.interfaceId;
const transport = link.transportType ? ` [${link.transportType}]` : '';
const net = link.networkName ? ` · ${link.networkName}` : '';
const upIcon = link.up === null ? '❓' : (link.up ? '✅' : '❌');
let upText = link.up === null ? 'unknown' : (link.up ? 'up' : 'DOWN');
if (link.operationalUp != null || link.adminUp != null) {
const op = link.operationalUp == null ? '?' : (link.operationalUp ? 'op-up' : 'op-down');
const ad = link.adminUp == null ? '?' : (link.adminUp ? 'admin-up' : 'admin-down');
upText = `${upText} (${op}/${ad})`;
}
let out = `- ${upIcon} **${nameLabel}**${transport}${net}${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 '⚪';
}
// rollupAlarms is now shared: services/enrichment/alarmSemantics.js
// (imported as sharedRollupAlarms at the top of this file so both
// this renderer and the wanAlarms check ship the same rollup shape
// + severity ordering).
/**
* 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;
}