collabSupport/services/renderers/voiceDiagRenderer.js
Joseph McQueen 1117be40cc Format chat footers in DISPLAY_TIMEZONE instead of UTC.
Docker hosts default to UTC, so bare toLocaleTimeString() showed
wrong "Last checked" times in avstatus and other commands. Add
formatDisplayTime() (default America/New_York, overridable via
DISPLAY_TIMEZONE) and use it across renderers and command footers.
2026-07-21 14:41:26 -04:00

414 lines
14 KiB
JavaScript
Raw Permalink 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.
import { formatDisplayTime } from '../../utils/time.js';
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) {
reply += `_Last checked: ${formatDisplayTime()}_\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]';
}
}