collabSupport/services/renderers/voiceDiagRenderer.js
jmcqueen b802383441 Add Prisma SD-WAN voice-quality enrichment for /phonestatus + /voicediag
Introduces a full Palo Alto Prisma SD-WAN integration (dual-mode SASE
OAuth 2.0 / legacy CloudGenix auth, pagination, 429 backoff, session
priming) that surfaces per-path latency/jitter/loss/MOS, site
healthscore, link state, and alarm data for a store. Wired into the
/phonestatus WAN follow-up and eight new /voicediag WAN checks graded
against ITU-T G.114 / RFC 3550 defaults (env-overridable via
WAN_STANDARD_*).

Also adds a shape-aware detail renderer for /voicediag (per-link
tables with verdict icons instead of a stringified JSON dump) and a
--window flag (15m / 1h / 6h / 24h / 1d, env default via
WAN_STANDARD_WINDOW_MINUTES) so operators can widen the look-back
without redeploying. scripts/prismaProbe.js is bundled as a CLI for
schema iteration against a live tenant.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 09:45:29 -04:00

320 lines
11 KiB
JavaScript

// 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 — {critical, major, minor, recentSamples: [...]}.
*/
function renderAlarmsDetails(details) {
const { critical = 0, major = 0, minor = 0, recentSamples = [] } = details;
const lines = [` - Counts: 🔴 ${critical} critical · 🟠 ${major} major · 🟡 ${minor} minor`];
if (Array.isArray(recentSamples) && recentSamples.length > 0) {
lines.push(' - Recent:');
for (const a of recentSamples) {
const type = a?.type || a?.code || a?.alarm_type || 'unknown';
const sev = a?.severity ? ` (${a.severity})` : '';
lines.push(` - ${type}${sev}`);
}
}
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]';
}
}