collabSupport/services/renderers/voiceDiagRenderer.js
jmcqueen 21fa8f1436 Clean up SD-WAN alarm rendering + explain overlay vs physical
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>
2026-07-09 10:19:15 -04:00

346 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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/voiceDiagRenderer.js
//
// Renders the /voicediag CheckResult array into Webex-friendly
// markdown. Pure function — no I/O — so it's usable from both the
// chat command and (later) HTTP surfaces without recomputing.
//
// Layout:
//
// **Voice Diagnostic - Store 12345** (user: ae12345@ae.com — Store 12345)
//
// Errors (1) Warnings (2) Skipped (1) OK (5)
//
// **ERRORS**
// - **Call Intercept**: Call intercept is ACTIVE — inbound calls...
// • incomingType: INTERCEPT_ALL, outgoingType: ALLOW_ALL
//
// **WARNINGS**
// - **Do Not Disturb**: DND is enabled — incoming calls silenced.
// - **Call Forwarding**: 1 forwarding variant is active: ...
//
// **SKIPPED**
// - **Call Waiting**: 403 on Call Waiting — likely missing scope ...
//
// **OK** (5 — hidden; pass `detailed` to see them)
//
// Fixable issues (2) — see confirmation cards below.
//
// Options
// storeNum (required) header text
// detailed (default false) — expands OK checks + shows the
// `details` block under every check
// emitFooter (default true) — trailing italic timestamp
//
// The renderer never emits an adaptive card itself. The caller
// (commands/voiceDiag.js) walks the same results array to post cards.
const SEVERITY_ORDER = ['error', 'warn', 'skipped', 'ok'];
const SEVERITY_LABEL = {
error: 'ERRORS',
warn: 'WARNINGS',
skipped: 'SKIPPED',
ok: 'OK',
};
/**
* @param {Array<{id, label, status, message, details, remediation}>} results
* @param {{
* storeNum: string,
* personLabel?: string,
* email?: string,
* detailed?: boolean,
* emitFooter?: boolean,
* wanWindowMinutes?: number,
* }} [opts]
* @returns {string} markdown, whitespace-trimmed
*/
export function renderVoiceDiagMarkdown(results, opts = {}) {
const {
storeNum,
personLabel = null,
email = null,
detailed = false,
emitFooter = true,
wanWindowMinutes = null,
} = opts;
const list = Array.isArray(results) ? results : [];
let reply = `**Voice Diagnostic - Store ${storeNum}**`;
if (personLabel || email) {
const who = [personLabel, email].filter(Boolean).join(' — ');
reply += ` (user: ${who})`;
}
reply += '\n\n';
if (list.length === 0) {
reply += '_No checks were executed. Verify the store number and re-run._\n';
return reply.trim();
}
const counts = countBySeverity(list);
reply += summaryLine(counts) + '\n';
const hasWanCheck = list.some((r) => /^wan/i.test(r?.id || ''));
if (hasWanCheck && Number.isFinite(wanWindowMinutes)) {
reply += `_WAN window: ${humanWindow(wanWindowMinutes)}_\n`;
}
reply += '\n';
for (const severity of SEVERITY_ORDER) {
const bucket = list.filter((r) => r.status === severity);
if (bucket.length === 0) continue;
// Hide the OK bucket from the console body unless detailed —
// keeps the default output focused on what's actionable.
if (severity === 'ok' && !detailed) continue;
reply += `**${SEVERITY_LABEL[severity]}**\n`;
for (const r of bucket) {
reply += `- **${r.label}**: ${r.message}\n`;
if (detailed && r.details && Object.keys(r.details).length > 0) {
const detailBlock = renderDetails(r.details);
if (detailBlock) reply += detailBlock + '\n';
}
}
reply += '\n';
}
const fixable = list.filter((r) => r.status !== 'ok' && r.remediation);
if (fixable.length > 0) {
reply +=
`**Fixable issues (${fixable.length})** — see confirmation cards below.\n\n` +
fixable.map((r) => `- ${r.label}: ${r.remediation.title}`).join('\n') +
'\n\n';
}
if (!detailed && counts.ok > 0) {
reply += `_${counts.ok} OK check(s) hidden — pass \`detailed\` to include them._\n\n`;
}
if (emitFooter) {
const now = new Date();
reply += `_Last checked: ${now.toISOString()}_\n`;
}
return reply.trim();
}
function countBySeverity(list) {
const counts = { error: 0, warn: 0, skipped: 0, ok: 0 };
for (const r of list) {
if (Object.prototype.hasOwnProperty.call(counts, r.status)) {
counts[r.status] += 1;
}
}
return counts;
}
function summaryLine({ error, warn, skipped, ok }) {
return `Errors (${error}) · Warnings (${warn}) · Skipped (${skipped}) · OK (${ok})`;
}
/**
* Human-friendly window label: 15m / 1h / 6h / 24h.
*/
function humanWindow(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`;
}
/**
* Detail rendering. Recognises common check-result shapes and
* produces multiline markdown with icons/tables instead of a raw
* `key: value` dump — which for WAN checks in particular looks
* like an unreadable stringified JSON blob.
*
* Falls back to the compact key:value renderer for shapes we
* don't know how to format specially. Returns null when the
* details object contains nothing user-visible after formatting
* (e.g. WAN threshold constants that are already in the message).
*
* The shape detectors are ordered from most-specific to
* least-specific. Each returns a multiline string (with leading
* indent to nest under the parent bullet) or null to fall through.
*/
function renderDetails(details) {
if (details === null || details === undefined) return null;
if (typeof details !== 'object') return ` - ${String(details)}`;
// ── Shape-aware formatters (best-fit wins) ────────────────────
if (Array.isArray(details.perLink)) {
return renderPerLinkDetails(details);
}
if ('up' in details && 'down' in details && 'unknown' in details) {
return renderLinkStateDetails(details);
}
if ('critical' in details && 'major' in details && 'minor' in details) {
return renderAlarmsDetails(details);
}
if ('siteId' in details && 'siteName' in details) {
return renderSiteDetails(details);
}
if ('value' in details && 'warnThresh' in details && 'errorThresh' in details) {
return renderThresholdDetails(details);
}
// ── Fallback: compact key:value dump ─────────────────────────
const parts = [];
for (const [k, v] of Object.entries(details)) {
parts.push(`${k}: ${formatValue(v)}`);
}
return parts.length > 0 ? ` - ${parts.join(', ')}` : null;
}
const VERDICT_ICON = { ok: '✅', warn: '⚠️', error: '❌', unknown: '❓' };
function verdictIcon(v) {
return VERDICT_ICON[v] || '·';
}
/**
* WAN latency/jitter/loss/mos etc. Produces:
*
* - Threshold: warn > 150ms, error > 400ms
* - Per link:
* - ✅ Inet1-00782: 22.2 ms
* - ✅ Inet2-00782: 13.5 ms
* - ⚠️ 5G-LTE-00782: 165 ms
* - Roll-up: 3 total · 2 ok · 1 warn · 0 error
*/
function renderPerLinkDetails(details) {
const { perLink, warnThresh, errorThresh, standardLabel, total, ok, warn, error } = details;
const lines = [];
if (standardLabel) {
lines.push(` - Threshold: ${standardLabel}`);
} else if (Number.isFinite(warnThresh) && Number.isFinite(errorThresh)) {
lines.push(` - Threshold: warn @ ${warnThresh}, error @ ${errorThresh}`);
}
if (Array.isArray(perLink) && perLink.length > 0) {
lines.push(' - Per link:');
for (const p of perLink) {
const val = p?.value == null || Number.isNaN(p.value) ? '—' : String(p.value);
lines.push(` - ${verdictIcon(p?.verdict)} ${p?.link}: ${val}`);
}
}
const rollup = [`${total ?? '?'} total`];
if (Number.isFinite(ok)) rollup.push(`${ok} ok`);
if (Number.isFinite(warn)) rollup.push(`${warn} warn`);
if (Number.isFinite(error)) rollup.push(`${error} error`);
if (rollup.length > 1) lines.push(` - Roll-up: ${rollup.join(' · ')}`);
return lines.length > 0 ? lines.join('\n') : null;
}
/**
* WAN Link State — {total, up, down, unknown, offenders, unknownLabels}.
*/
function renderLinkStateDetails(details) {
const { total, up, down, unknown, offenders = [], unknownLabels = [] } = details;
const lines = [` - Roll-up: ${total ?? '?'} total · ${up ?? 0} up · ${down ?? 0} down · ${unknown ?? 0} unknown`];
if (offenders.length > 0) {
lines.push(` - Down: ${offenders.join(', ')}`);
}
if (unknownLabels.length > 0) {
lines.push(` - Unknown: ${unknownLabels.join(', ')}`);
}
return lines.join('\n');
}
/**
* Alarm counts + optional category breakdown + rolled-up recent
* samples. Handles both the old shape (bare `critical/major/minor +
* recentSamples` array of raw events) and the new shape emitted by
* wanAlarmsCheck (`byCategory`, rollups with `humanized`, `count`,
* `age`).
*/
function renderAlarmsDetails(details) {
const {
critical = 0, major = 0, minor = 0,
byCategory, recentSamples = [],
} = details;
const lines = [
` - Counts: 🔴 ${critical} critical · 🟠 ${major} major · 🟡 ${minor} minor`,
];
if (byCategory && typeof byCategory === 'object') {
const parts = [];
if (byCategory.overlay > 0) parts.push(`${byCategory.overlay} overlay/VPN`);
if (byCategory.physical > 0) parts.push(`${byCategory.physical} physical WAN`);
if (byCategory.device > 0) parts.push(`${byCategory.device} device`);
if (byCategory.other > 0) parts.push(`${byCategory.other} other`);
if (parts.length > 0) lines.push(` - Category: ${parts.join(' · ')}`);
}
if (Array.isArray(recentSamples) && recentSamples.length > 0) {
lines.push(' - Recent:');
for (const a of recentSamples) {
// Prefer humanized label + code; fall back gracefully for
// pre-humanized (raw event) shapes.
const label = a?.humanized || a?.type || a?.code || a?.alarm_type || 'unknown';
const code = a?.code && a?.humanized ? ` \`${a.code}\`` : '';
const sev = a?.severity ? ` (${a.severity})` : '';
const count = Number.isFinite(a?.count) && a.count > 1 ? ` ×${a.count}` : '';
const age = a?.age ? `${a.age}` : '';
lines.push(` - ${label}${code}${sev}${count}${age}`);
}
}
return lines.join('\n');
}
/**
* WAN Site — {siteId, siteName, elementCount, connectedElementCount, linkCount}.
* Rendered as a compact one-liner since siteName is usually already
* in the message.
*/
function renderSiteDetails(details) {
const { siteId, elementCount, connectedElementCount, linkCount } = details;
const parts = [];
if (siteId) parts.push(`id: \`${siteId}\``);
if (Number.isFinite(elementCount)) {
parts.push(
`${elementCount} element(s)` +
(Number.isFinite(connectedElementCount)
? ` (${connectedElementCount} connected)`
: ''),
);
}
if (Number.isFinite(linkCount)) parts.push(`${linkCount} link(s)`);
return parts.length > 0 ? ` - ${parts.join(' · ')}` : null;
}
/**
* Single value + thresholds — {value, warnThresh, errorThresh, breakdown}.
* The message already contains value + thresholds, so the details
* block just shows the sub-score breakdown (if any) and skips the
* redundant info.
*/
function renderThresholdDetails(details) {
const { breakdown } = details;
if (breakdown && typeof breakdown === 'object' && Object.keys(breakdown).length > 0) {
const parts = Object.entries(breakdown).map(([k, v]) => `${k}: ${v}`);
return ` - Breakdown: ${parts.join(' · ')}`;
}
return null;
}
function formatValue(v) {
if (v === null || v === undefined) return '—';
if (typeof v === 'boolean' || typeof v === 'number' || typeof v === 'string') {
return String(v);
}
if (Array.isArray(v)) {
if (v.length === 0) return '[]';
if (v.length <= 3) return `[${v.map(formatValue).join(', ')}]`;
return `[${v.slice(0, 3).map(formatValue).join(', ')}, …+${v.length - 3}]`;
}
// Fall through: nested object — collapse to a JSON snippet.
try {
const s = JSON.stringify(v);
return s.length > 120 ? `${s.slice(0, 117)}` : s;
} catch {
return '[object]';
}
}