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>
426 lines
18 KiB
JavaScript
426 lines
18 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.
|
||
|
||
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;
|
||
}
|