collabSupport/services/renderers/wanDiagnosticsRenderer.js
jmcqueen 90a56c4640 Fix Prisma overlay tunnel discovery with site-scoped query_params.
Use topology/links and anynetlinks filters with eq/in operators so
store WAN follow-ups return real peer tunnels instead of unscoped dumps.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 09:01:40 -04:00

426 lines
18 KiB
JavaScript
Raw 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 /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.
import {
categorizeAlarm,
humanizeAlarmCode,
rollupAlarms as sharedRollupAlarms,
humanizeAge,
} 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 (Phase 1).
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 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`);
const alarmWindowLabel = data.window?.alarmMinutes
? humanWindowLabel(data.window.alarmMinutes)
: '1h';
out += `\n🚨 Alarms (last ${alarmWindowLabel}): ${parts.join(', ')}\n`;
const rollups = sharedRollupAlarms(data.alarms.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`;
}
}
// 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 actionable = list.filter((t) => t.up === false || t.recentAlarm || t.up === true);
const unknownOnly = list.length > 0 && actionable.every((t) => t.up == null)
&& list.every((t) => t.up == null);
let out = `\n**Overlay tunnels** (${list.length})\n`;
// If every tunnel is unparseable unknown, don't spam "? peer — unknown".
if (unknownOnly) {
out += `- ❓ Tunnel inventory returned ${list.length} link(s) but peer/status fields were empty — check Prisma UI topology for this site.\n`;
return out;
}
const ranked = [...list].sort((a, b) => {
const score = (t) => (t.up === false ? 3 : (t.recentAlarm ? 2 : (t.up === true ? 1 : 0)));
return score(b) - score(a);
});
// Prefer showing down / alarmed first; skip pure-unknown fillers when
// we already have actionable rows. Cap hard so we never dump dozens.
const toShow = ranked.filter((t) => t.up !== null || t.recentAlarm || t.peerLabel !== 'peer');
const display = (toShow.length > 0 ? toShow : ranked).slice(0, 6);
for (const t of display) {
const icon = t.up === null ? '❓' : (t.up ? '✅' : '❌');
const state = t.up === null ? (t.state || 'unknown') : (t.up ? 'up' : 'DOWN');
const peer = (t.peerLabel && t.peerLabel !== 'peer')
? t.peerLabel
: (t.peerSiteId || 'peer');
const ifBit = t.relatedInterfaceId ? ` · if ${t.relatedInterfaceId}` : '';
const circuitBit = t.circuitName ? ` · ${t.circuitName}` : '';
const alarmBit = t.recentAlarm ? ' · ⚠️ recent alarm' : '';
out += `- ${icon} **${peer}** — ${state}${circuitBit}${ifBit}${alarmBit}\n`;
}
if (ranked.length > display.length) {
out += `- _+${ranked.length - display.length} more tunnel(s)._\n`;
}
const downCount = list.filter((t) => t.up === false).length;
if (downCount > 0 && physicalUp) {
out += `_Note: ${downCount} overlay tunnel(s) down while physical WAN paths look up — voice may still be impacted._\n`;
}
return out;
}
/**
* 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;
}