The wanAlarms check was pasting raw Prisma `info` JSON blobs (nested
`vpn_reasons` arrays with element/site/vpnlink ids) into the chat
message field. On a store with 20 NETWORK_ANYNETLINK_DOWN flaps this
produced a wall of unreadable stringified JSON where the actual
signal ("SD-WAN overlay tunnels are flapping") was lost.
Introduces a shared alarmSemantics module that:
- Buckets each code into overlay / physical / device / other so
the check + renderer stay consistent
- Humanizes codes (NETWORK_ANYNETLINK_DOWN → "SD-WAN overlay
tunnel down") with a Title-Cased fallback for unknown codes
- Rolls up (code + severity) tuples so 20 identical alarms show as
a single line with ×20 and a "just now / Nm / Nh / Nd" age
Rewrites wanAlarms.run() to use those helpers + cross-reference the
site's physical link state so operators aren't left wondering why 20
alarms fired while every metric shows green: overlay flaps get a
"physical WAN paths are all up per Link State" clarifier, and
physical alarms point back at the Link State check. The label loses
its hardcoded "(last 1h)" suffix since the alarm window is now
dynamic (defaults to 24h to match the WAN window).
The follow-up renderer used by /phonestatus imports the same helpers
so the two surfaces cannot drift.
Co-authored-by: Cursor <cursoragent@cursor.com>
236 lines
9.8 KiB
JavaScript
236 lines
9.8 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';
|
||
|
||
// 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`);
|
||
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) {
|
||
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 '⚪';
|
||
}
|
||
|
||
// 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;
|
||
}
|