// 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]'; } }