Adds three new SD-WAN checks (wanAppRtpMos/Loss/Jitter) that measure
REAL voice-traffic quality on actual RTP frames via Prisma DPI, not
synthetic link probes. Graded against the WORST 5-minute window so
transient degradation the 24h link-probe averages smooth away
actually surfaces.
Voice-app selection is tenant-configurable via PRISMA_APP_ID_VOICE +
PRISMA_APP_NAME_VOICE (Webex_Calling_RTP recommended for Webex
Calling shops — the Webex-specific DPI signature excludes non-Webex
UDP noise). Legacy PRISMA_APP_ID_RTP_BASE still honored with a
one-time deprecation warning.
Widens the default WAN look-back from 24h to 7 days: per-app metrics
only get datapoints when calls actually happen, so sporadic Webex
Calling stores (3-4 calls/day) need a wider window for worst-window
statistics to be meaningful. Interval picker snaps 7d to 1hour
buckets (168 pts) to keep payloads bounded while preserving
worst-hour granularity. Hard-capped at 7d — beyond that Prisma
downsamples to 1-day buckets and the signal collapses.
Also:
- Client-side concurrency limiter (PRISMA_MAX_INFLIGHT, default 3)
to prevent 429 cascades when /voicediag fans out 10+ parallel
metric fetches
- "View in Prisma UI" deep links in both /phonestatus WAN follow-up
and /voicediag details, threading through a new
integrations/paloalto/urls.js builder
- humanizeMetricUnit maps raw API unit strings ("percentage",
"milliseconds") to display symbols ("%", "ms") to fix
"11.83percentage" leaking to the UI
- getAppAudio envelope distinguishes not-configured / fetch-failed /
no-traffic states so misleading "set env var" messages don't fire
when the real problem is a 429
Co-authored-by: Cursor <cursoragent@cursor.com>
413 lines
14 KiB
JavaScript
413 lines
14 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);
|
||
}
|
||
// Per-app audio MOS/loss/jitter summary — has
|
||
// {avg,min,max,worst,badSampleCount,badSamplePct,appName,samples,
|
||
// validSamples,interval,warnThresh,errorThresh}. Match on the
|
||
// combination unique to that shape.
|
||
if ('appName' in details && 'worst' in details && 'validSamples' in details) {
|
||
return renderAppAudioDetails(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');
|
||
}
|
||
|
||
/**
|
||
* Per-app audio-quality (MOS/loss/jitter) detail formatter.
|
||
* The summary carries: {appName, worst, avg, min, max, p95,
|
||
* samples, validSamples, interval, warnThresh, errorThresh,
|
||
* badSampleCount, badSamplePct, standardLabel, unit}.
|
||
*
|
||
* Output (example, MOS):
|
||
*
|
||
* - App: Webex_Calling_RTP (voice traffic, DPI)
|
||
* - Threshold: warn < 4, error < 3.5
|
||
* - Worst window: 1.85 · Avg: 4.03 · p95: 4.28
|
||
* - Range: 1.85 – 4.41 across 288/288 samples @ 5min
|
||
* - Time in warn/error: 42 samples (15%)
|
||
*/
|
||
function renderAppAudioDetails(details) {
|
||
const {
|
||
appName, worst, avg, min, max, p95,
|
||
samples, validSamples, interval,
|
||
warnThresh, errorThresh, standardLabel, unit,
|
||
badSampleCount, badSamplePct,
|
||
detailsUrl,
|
||
} = details;
|
||
|
||
const u = unit || '';
|
||
const lines = [` - App: ${appName || 'voice'} (voice traffic, DPI)`];
|
||
|
||
if (standardLabel) {
|
||
lines.push(` - Threshold: ${standardLabel}`);
|
||
} else if (Number.isFinite(warnThresh) || Number.isFinite(errorThresh)) {
|
||
lines.push(` - Threshold: warn @ ${warnThresh ?? '?'}, error @ ${errorThresh ?? '?'}`);
|
||
}
|
||
|
||
const worstStr = worst == null ? '—' : `${worst}${u}`;
|
||
const avgStr = avg == null ? '—' : `${avg}${u}`;
|
||
const p95Str = p95 == null ? '—' : `${p95}${u}`;
|
||
lines.push(` - Worst window: **${worstStr}** · Avg: ${avgStr} · p95: ${p95Str}`);
|
||
|
||
if (min != null && max != null) {
|
||
lines.push(` - Range: ${min}${u} – ${max}${u} across ${validSamples}/${samples} samples @ ${interval || '5min'}`);
|
||
}
|
||
|
||
if (Number.isFinite(badSampleCount) && Number.isFinite(badSamplePct)) {
|
||
if (badSampleCount > 0) {
|
||
lines.push(` - Time in warn/error: ${badSampleCount} sample${badSampleCount === 1 ? '' : 's'} (~${badSamplePct}% of window)`);
|
||
} else {
|
||
lines.push(` - Time in warn/error: 0 samples (clean throughout window)`);
|
||
}
|
||
}
|
||
|
||
// SCM UI deep-link — always the last row so it acts as a "next
|
||
// step" for anyone inspecting the details. Only rendered when the
|
||
// enrichment layer was able to build a URL (needs both site.id +
|
||
// appAudio.appId — either missing → null skips this line cleanly).
|
||
if (detailsUrl) {
|
||
lines.push(` - [View in Prisma UI](${detailsUrl})`);
|
||
}
|
||
|
||
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]';
|
||
}
|
||
}
|