// 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, 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; }