From 7dc326c40455cc805ca87bdd513bef2e4f515f5a Mon Sep 17 00:00:00 2001 From: jmcqueen Date: Wed, 29 Jul 2026 13:55:41 -0400 Subject: [PATCH] Split voice commands into status vs diag and redesign MPP phone output. Replace /phonestatus follow-ups with /voicestatus, /wanstatus, /phonediag, and /dectdiag; extend /voicediag with relay probes and section-based MPP diagnostics. Co-authored-by: Cursor --- commands/{dectStatus.js => dectDiag.js} | 68 ++--- commands/help.js | 92 ++++-- commands/igmpFix.js | 6 +- commands/phoneDiag.js | 84 +++++ commands/phoneStatus.js | 289 ------------------ commands/registry.js | 16 +- commands/voiceDiag.js | 32 +- commands/voiceStatus.js | 103 +++++++ commands/wanStatus.js | 77 +++++ index.js | 4 +- .../cisco-mpp-phone/downloadStatusJson.js | 30 +- services/dectStatus/buildHandsetContext.js | 2 +- services/enrichment/sdwanEnrichment.js | 4 +- services/jiraPoller/README.md | 4 +- services/jiraPoller/enrichmentRules.js | 6 +- services/jiraPoller/runEnrichment.js | 4 +- services/jiraPollerService.js | 2 +- services/phoneDeviceBuilder.js | 2 +- services/renderers/dectStatusRenderer.js | 3 +- .../renderers/mppPhoneDiagnosticsRenderer.js | 231 +++++++------- services/renderers/mppPhoneFormatters.js | 133 ++++++++ services/renderers/phoneStatusRenderer.js | 134 +------- services/renderers/wanDiagnosticsRenderer.js | 2 +- services/voiceDiag/checks/dectBaseRelay.js | 105 +++++++ services/voiceDiag/checks/index.js | 4 + .../voiceDiag/checks/mppDeskPhoneRelay.js | 105 +++++++ services/voiceDiag/checks/wan/_helpers.js | 2 +- services/voiceDiag/voiceDiagService.js | 35 +++ tests/ciscoMppPhone.downloadStatus.test.js | 2 +- tests/jiraPoller.enrichmentRules.test.js | 14 +- tests/jiraPoller.runEnrichment.test.js | 2 +- tests/mppPhoneDiagnosticsRenderer.test.js | 71 ++++- tests/renderers.test.js | 171 +---------- tests/voiceDiag.relay.test.js | 68 +++++ tests/wanStatus.test.js | 11 + utils/markdownToAdf.js | 2 +- utils/windowArgs.js | 37 +++ 37 files changed, 1097 insertions(+), 860 deletions(-) rename commands/{dectStatus.js => dectDiag.js} (79%) create mode 100644 commands/phoneDiag.js delete mode 100644 commands/phoneStatus.js create mode 100644 commands/voiceStatus.js create mode 100644 commands/wanStatus.js create mode 100644 services/renderers/mppPhoneFormatters.js create mode 100644 services/voiceDiag/checks/dectBaseRelay.js create mode 100644 services/voiceDiag/checks/mppDeskPhoneRelay.js create mode 100644 tests/voiceDiag.relay.test.js create mode 100644 tests/wanStatus.test.js create mode 100644 utils/windowArgs.js diff --git a/commands/dectStatus.js b/commands/dectDiag.js similarity index 79% rename from commands/dectStatus.js rename to commands/dectDiag.js index 72a1ede..78dc2b9 100644 --- a/commands/dectStatus.js +++ b/commands/dectDiag.js @@ -1,27 +1,9 @@ -// src/commands/dectStatus.js +// src/commands/dectDiag.js // -// /dectstatus — full DECT basestation diagnostics via the -// on-prem relay agent. Complements the compact -// follow-up that /phonestatus already posts: -// this is the full status.xml dump (device, -// firmware, reboot log, network, RTP) plus chat-only action cards -// for reboot / force-reboot / factory-reset. -// -// Chat surface -// 1. Markdown full dump for every discovered 10.x base. -// 2. One adaptive card per reachable base with Reboot / Force Reboot / -// Factory Reset buttons. Each button opens a confirm card; confirm -// runs execAction() through the relay and audits the outcome. -// -// HTTP surface -// Same dump via ?storeNum=. No action cards (mutating UI is chat- -// only). Non-mutating for registry purposes — card submits never hit -// the HTTP path. -// -// Relay dependency -// Requires DECT_RELAY_AGENT_TOKEN + a live dect-relay-agent. When the -// relay is offline we still run discovery and report the offline -// state explicitly rather than failing the whole command. +// /dectdiag — full DECT basestation diagnostics via the +// on-prem relay agent: status.xml dump (device, +// firmware, reboot log, network, RTP) plus chat-only +// action cards for reboot / force-reboot / factory-reset. import { randomUUID } from 'node:crypto'; import { collectPhoneStatus } from '../services/phoneService.js'; @@ -71,7 +53,7 @@ export const DECT_STATUS_CARD_ACTIONS = new Set([ CANCEL_ACTION, ]); -export async function handleDectStatus(bot, trigger) { +export async function handleDectDiag(bot, trigger) { const query = trigger.query || {}; const args = trigger.args || []; const storeNum = (args[0]?.trim() || query.storeNum || query.store || query.s || '').trim(); @@ -80,33 +62,31 @@ export async function handleDectStatus(bot, trigger) { await bot.say( 'markdown', 'Please provide a 2–4 digit store number.\n' + - 'Example: `/dectstatus 782`\n' + + 'Example: `/dectdiag 782`\n' + 'HTTP: `?storeNum=782`', ); return; } - // Optional single-base filter: second arg as IP or MAC. const filterRaw = (args[1] || query.base || query.ip || query.mac || '').trim(); - logger('dect:status', `Collecting DECT status for store ${storeNum}` + + logger('dect:diag', `Collecting DECT diagnostics for store ${storeNum}` + (filterRaw ? ` (filter=${filterRaw})` : '')); - // Relay status snapshot for the header — cheap, no I/O. let relayStatus = null; try { if (process.env.DECT_RELAY_AGENT_TOKEN) { relayStatus = getDectRelayHub().status(); } } catch (err) { - logger('dect:status', `Relay status unavailable: ${err.message}`, 'warn'); + logger('dect:diag', `Relay status unavailable: ${err.message}`, 'warn'); } if (!process.env.DECT_RELAY_AGENT_TOKEN) { await bot.say( 'markdown', '⚠️ DECT relay is not configured on this bot (`DECT_RELAY_AGENT_TOKEN` unset). ' + - 'Set the token and run dect-relay-agent in the data center to enable `/dectstatus`.', + 'Set the token and run dect-relay-agent in the data center to enable `/dectdiag`.', ); return; } @@ -115,7 +95,7 @@ export async function handleDectStatus(bot, trigger) { try { phoneData = await collectPhoneStatus(storeNum); } catch (err) { - logger('dect:status', `collectPhoneStatus failed for store ${storeNum}: ${err.message}`, 'error'); + logger('dect:diag', `collectPhoneStatus failed for store ${storeNum}: ${err.message}`, 'error'); await bot.say('markdown', `❌ Failed to look up store ${storeNum}: ${err.message}`); return; } @@ -139,7 +119,7 @@ export async function handleDectStatus(bot, trigger) { await bot.say( 'markdown', targets.length - ? `⏳ Collecting status from **${targets.length}** DECT base(s) at store ${storeNum}…` + ? `⏳ Collecting diagnostics from **${targets.length}** DECT base(s) at store ${storeNum}…` : `Looking up DECT bases for store ${storeNum}…`, ); @@ -153,7 +133,6 @@ export async function handleDectStatus(bot, trigger) { }); await bot.say('markdown', md || 'No data available.'); - // Action cards are chat-only (need adaptive-card UX + room). if (trigger.person) { const okBases = results.filter((r) => r.ok && r.base?.ip); for (const r of okBases) { @@ -168,7 +147,7 @@ export async function handleDectStatus(bot, trigger) { }); } catch (err) { logger( - 'dect:status', + 'dect:diag', `Failed to post action card for ${r.base?.ip}: ${err.message}`, 'warn', ); @@ -192,7 +171,7 @@ export async function handleDectStatusAction(bot, trigger) { const dectAction = inputs.dectAction; const meta = DECT_STATUS_ACTIONS[dectAction]; if (!meta) { - logger('dect:status:action', `Unknown dectAction "${dectAction}" — ignoring`, 'warn'); + logger('dect:diag:action', `Unknown dectAction "${dectAction}" — ignoring`, 'warn'); return; } const storeNum = String(inputs.storeNum || '').trim(); @@ -200,7 +179,7 @@ export async function handleDectStatusAction(bot, trigger) { const baseMac = String(inputs.baseMac || '').trim(); const baseName = String(inputs.baseName || baseIp || 'base').trim(); if (!storeNum || !baseIp) { - logger('dect:status:action', 'request missing storeNum/baseIp — ignoring', 'warn'); + logger('dect:diag:action', 'request missing storeNum/baseIp — ignoring', 'warn'); return; } @@ -240,17 +219,17 @@ export async function handleDectStatusAction(bot, trigger) { if (actionType === CONFIRM_ACTION || actionType === CANCEL_ACTION) { const { cardId } = inputs; if (!cardId) { - logger('dect:status:action', `Missing cardId on ${actionType} — ignoring`); + logger('dect:diag:action', `Missing cardId on ${actionType} — ignoring`); return; } if (!pendingDectActions.has(cardId)) { - logger('dect:status:action', `Card ${cardId} is expired or unknown`); - await bot.say('markdown', '⚠️ That action card has expired. Re-run `/dectstatus` and try again.'); + logger('dect:diag:action', `Card ${cardId} is expired or unknown`); + await bot.say('markdown', '⚠️ That action card has expired. Re-run `/dectdiag` and try again.'); return; } const data = pendingDectActions.get(cardId); - pendingDectActions.delete(cardId); // one-shot + pendingDectActions.delete(cardId); if (actionType === CANCEL_ACTION) { await bot.say( @@ -265,7 +244,6 @@ export async function handleDectStatusAction(bot, trigger) { return; } - // CONFIRM const meta = DECT_STATUS_ACTIONS[data.dectAction] || { id: data.dectAction, label: data.dectAction, @@ -290,7 +268,7 @@ export async function handleDectStatusAction(bot, trigger) { 'markdown', `✅ **${meta.label}** issued on **${data.baseName}** (${data.baseIp})` + (result.elapsedMs != null ? ` in ${result.elapsedMs}ms` : '') + - `.\n_Re-run \`/dectstatus ${data.storeNum}\` in a minute to confirm the base is back._`, + `.\n_Re-run \`/dectdiag ${data.storeNum}\` in a minute to confirm the base is back._`, ); logger( 'dect:audit', @@ -312,12 +290,10 @@ export async function handleDectStatusAction(bot, trigger) { return; } - logger('dect:status:action', `Ignoring unhandled action type ${actionType}`, 'debug'); + logger('dect:diag:action', `Ignoring unhandled action type ${actionType}`, 'debug'); void roomId; } -// ─── Cards ────────────────────────────────────────────────────────── - function buildBaseActionCard({ storeNum, base }) { const actions = Object.values(DECT_STATUS_ACTIONS).map((meta) => ({ type: 'Action.Submit', @@ -402,8 +378,6 @@ function buildConfirmCard({ cardId, storeNum, baseName, baseIp, meta }) { }; } -// ─── Helpers ──────────────────────────────────────────────────────── - function filterBases(bases, raw) { const needle = String(raw).trim().toLowerCase(); const needleMac = needle.replace(/[^0-9a-f]/g, ''); diff --git a/commands/help.js b/commands/help.js index 5ae5b6b..3b9b308 100644 --- a/commands/help.js +++ b/commands/help.js @@ -18,9 +18,11 @@ const SHORT_HELP = { // AV / phones avstatus: 'AV / device status for a store (alias: /wostatus)', - phonestatus: 'DECT + IP phone status for a store (plus 7d WAN follow-up)', - dectstatus: 'Full DECT base dump via relay (handsets, RSSI, reboot cards)', - voicediag: 'Deep voice diagnostic: Webex Calling features + SD-WAN quality with fix cards', + voicestatus: 'Quick DECT + IP phone status for a store', + wanstatus: 'Prisma SD-WAN health + voice traffic quality', + phonediag: 'MPP desk phone relay diagnostics (CP-7841)', + dectdiag: 'Full DECT base dump via relay (handsets, RSSI, reboot cards)', + voicediag: 'Deep voice diagnostic: features + WAN + relay probes with fix cards', callreport: 'Daily call digest: correlated CDR calls + per-call Prisma WAN (store, email, or phone)', calltest: 'Twilio voice path test (store AA or direct dial, 60s listen)', @@ -53,41 +55,70 @@ const LONG_HELP = { 'Includes Meraki deep links per device. For interactive topology, open `/av-store-dashboard.html`.', ], }, - phonestatus: { - title: '/phonestatus', + voicestatus: { + title: '/voicestatus', usage: [ - '/phonestatus ', - '/phonestatus detailed', - '/phonestatus verbose', - '/phonestatus detailed verbose', + '/voicestatus ', + '/voicestatus detailed', ], examples: [ - '/phonestatus 782', - '/phonestatus 782 detailed', - '/phonestatus 782 verbose', - '/phonestatus 782 debug', + '/voicestatus 782', + '/voicestatus 782 detailed', ], notes: [ - 'Shows DECT basestations + IP phones with Meraki links.', - 'Detailed mode adds firmware, serial, SIP details and errors (main message only).', - 'When MPP desk phones are discovered on 10.x, a follow-up **MPP Desk Phone Diagnostics** message arrives via the relay (registration, switch port, provisioning history). Pass `verbose` or `debug` for extension/debug counters.', - 'When the store is Prisma SD-WAN managed (site name `CG` padded to 5 digits), a follow-up **WAN Diagnostics** message arrives with per-path latency/jitter/loss/MOS, site healthscore, and any alarms — averaged over the last 7 days by default (widened from 24h so sporadic Webex Calling stores get enough call samples; override via `WAN_STANDARD_WINDOW_MINUTES` or pass `--window 24h` on /voicediag).', - 'If `PRISMA_APP_ID_VOICE` is configured (e.g. pointing at `Webex_Calling_RTP` for Webex Calling shops), the follow-up also includes a **Voice Traffic Quality** section with real DPI-measured MOS / packet loss / jitter for that app — surfaces transient degradation the 7d link-probe averages smooth away.', - 'For the full DECT base dump + reboot/factory-reset controls, use `/dectstatus `.', + 'Shows DECT basestations + IP phones with Meraki links — quick scan only, no follow-up messages.', + 'Detailed mode adds firmware, serial, SIP details and errors.', + 'For MPP desk phone relay diagnostics, use `/phonediag `.', + 'For Prisma SD-WAN metrics, use `/wanstatus `.', + 'For full DECT base dump + reboot/factory-reset controls, use `/dectdiag `.', 'For an in-depth voice diagnostic with per-user Webex Calling checks + fix cards, use `/voicediag `.', 'Web dashboard: `/phone-store-dashboard.html`.', ], }, - dectstatus: { - title: '/dectstatus', + wanstatus: { + title: '/wanstatus', usage: [ - '/dectstatus ', - '/dectstatus ', + '/wanstatus ', + '/wanstatus --window 24h', ], examples: [ - '/dectstatus 782', - '/dectstatus 782 10.12.34.56', - '/dectstatus 782 6c:ab:05:12:34:56', + '/wanstatus 782', + '/wanstatus 782 --window 7d', + ], + notes: [ + 'Prisma SD-WAN diagnostics for stores managed under site name `CG` (padded to 5 digits).', + 'Shows healthscore, per-path latency/jitter/loss/MOS, active alarms — default window 7 days.', + 'When `PRISMA_APP_ID_VOICE` is configured, includes **Voice Traffic Quality** (DPI-measured MOS / loss / jitter).', + 'Override window via `--window 24h`, `7d`, etc. HTTP: `?storeNum=&window=24h`.', + ], + }, + phonediag: { + title: '/phonediag', + usage: [ + '/phonediag ', + '/phonediag verbose', + ], + examples: [ + '/phonediag 782', + '/phonediag 782 verbose', + '/phonediag 782 debug', + ], + notes: [ + 'MPP desk phone relay diagnostics for CP-7841 phones on 10.x: registration, switch LLDP, provisioning, issues.', + 'Pass `verbose` or `debug` for SIP counters, extra extensions, and probe path table.', + 'Requires `DECT_RELAY_AGENT_TOKEN` and a live dect-relay-agent.', + ], + }, + dectdiag: { + title: '/dectdiag', + usage: [ + '/dectdiag ', + '/dectdiag ', + ], + examples: [ + '/dectdiag 782', + '/dectdiag 782 10.12.34.56', + '/dectdiag 782 6c:ab:05:12:34:56', ], notes: [ 'Aliases: `/dect`.', @@ -95,9 +126,8 @@ const LONG_HELP = { 'Also shows **Webex handset registrations** per base (extension, last registration) and **RF signal (RSSI)** from the base when handsets are registered.', 'Optional second arg filters to one base by IP, MAC, or name substring.', 'Chat-only action cards per reachable base: **Reboot**, **Force Reboot**, **Factory Reset**. Each requires a confirm click; outcomes are audited under `dect:audit`.', - 'Requires `DECT_RELAY_AGENT_TOKEN` on the bot and a live `dect-relay-agent` in the data center. When the relay is offline the command still reports discovery results and the offline state.', + 'Requires `DECT_RELAY_AGENT_TOKEN` on the bot and a live `dect-relay-agent` in the data center.', 'HTTP equivalent: `?storeNum=[&base=]` — markdown dump only (no action cards).', - 'The compact exception-only DECT follow-up after `/phonestatus` is separate; this command is the full dump that follow-up footer points at.', ], }, voicediag: { @@ -119,7 +149,7 @@ const LONG_HELP = { '/voicediag list-checks', ], notes: [ - 'Runs a full battery of per-user Webex Calling checks (DND, call forwarding, voicemail, call intercept, call waiting, outgoing permission, etc.) plus eleven SD-WAN checks (site, healthscore, link state, latency, jitter, loss, MOS, per-app voice MOS/loss/jitter, alarms) against the store\'s Prisma tenant.', + 'Runs per-user Webex Calling checks, SD-WAN checks, and relay probes (MPP desk phones + DECT bases when `DECT_RELAY_AGENT_TOKEN` is set).', 'The per-app checks measure REAL voice-traffic quality via Prisma DPI (worst 5-minute window over the configured look-back, default 7d), which catches transient degradation the link-probe averages smooth away. Feature-gated on `PRISMA_APP_ID_VOICE` env (set to a Prisma app id like `Webex_Calling_RTP` or `rtp-base`) — checks return skipped with an explanation when not configured.', 'Default view hides OK checks and highlights errors/warnings/skipped. Pass `detail` (or `detailed`) to also see OK checks with expanded per-link tables + thresholds + roll-ups.', 'Fixable issues (e.g. DND on, forwarding to wrong number) post a per-issue confirmation card. A single "apply all" card lets you fix everything at once after reviewing.', @@ -260,7 +290,7 @@ const LONG_HELP = { examples: ['/jirapoll', '/jirapoll prime'], notes: [ 'Triggers the same Jira poller that normally runs at the top of every hour. Enriches any unlabeled matching tickets with a phone/av snapshot comment and labels them `bot-enriched`. Tickets the AI classifier decides are out-of-scope get labeled `bot-skipped` so they aren\'t re-classified every hour.', - '**Communication Services** tickets mentioning call quality (garbled, static, can\'t connect, …) get **phonestatus + callreport (today)**. Spam/robocall tickets get **callreport** only. See `services/jiraPoller/README.md` for adding more rules.', + '**Communication Services** tickets mentioning call quality (garbled, static, can\'t connect, …) get **voicestatus + callreport (today)**. Spam/robocall tickets get **callreport** only. See `services/jiraPoller/README.md` for adding more rules.', 'Idempotent — labels + JQL prevent double-processing, so running multiple times in a row is safe.', '`/jirapoll prime` labels every matching ticket without enriching or notifying. Use once after adopting the poller to skip enriching the existing backlog. Same as `JIRA_POLLER_PRIME_ON_START=true` at startup.', 'A summary of enriched tickets goes to the configured `JIRA_POLLER_ROOM_ID`. The invoking chat also gets a compact result line.', @@ -322,7 +352,7 @@ const LONG_HELP = { const GROUPS = [ { title: 'Work orders', keys: ['wohistory', 'wosummary', 'woattachments'] }, - { title: 'AV & phones', keys: ['avstatus', 'phonestatus', 'dectstatus', 'voicediag', 'callreport', 'calltest'] }, + { title: 'AV & phones', keys: ['avstatus', 'voicestatus', 'wanstatus', 'phonediag', 'dectdiag', 'voicediag', 'callreport', 'calltest'] }, { title: 'Jira', keys: ['jirahistory', 'jiraticket', 'jirapoll'] }, { title: 'Provisioning', keys: ['provision-dect', 'provision-vc', 'vcmonitor'] }, { title: 'Admin & bulk', keys: ['offboarduser', 'webexhost', 'bulkavstatuscsv', 'bulkavswitchcsv', 'devicesbymodel'] }, diff --git a/commands/igmpFix.js b/commands/igmpFix.js index 0d1a4f6..5296cc1 100644 --- a/commands/igmpFix.js +++ b/commands/igmpFix.js @@ -2,7 +2,7 @@ // // Adaptive-card inline remediation for Meraki switch multicast policy // on a store's network. Not a standalone chat command — the card is -// emitted by `/phonestatus` (see commands/phoneStatus.js) whenever the +// emitted by `/voicestatus` (see commands/voiceStatus.js) whenever the // collector reports `data.multicast.needsFix`. Confirm and Cancel // flow through the framework's `attachmentAction` event dispatched // from index.js, which follows the same pending-card / one-shot / @@ -34,7 +34,7 @@ export const DECT_SAFE_MULTICAST_PAYLOAD = Object.freeze({ }); /** - * Build the adaptive-card object shown in-chat under a /phonestatus + * Build the adaptive-card object shown in-chat under a /voicestatus * reply when multicast policy deviates from DECT-safe defaults. The * card renders whatever the summarizer flagged as deviating so the * clicker knows exactly what will change. @@ -150,7 +150,7 @@ export async function applyDectSafeMulticast(bot, data, _roomId, requester) { 'markdown', `✅ Multicast pinned to DECT-safe defaults on **${networkName || `store ${storeNum}`}**: ` + `IGMP snoop=OFF, flood-unknown=ON, all switch overrides cleared. ` + - `Re-run \`/phonestatus ${storeNum}\` to verify.`, + `Re-run \`/voicestatus ${storeNum}\` to verify.`, ); logger( 'igmp:audit', diff --git a/commands/phoneDiag.js b/commands/phoneDiag.js new file mode 100644 index 0000000..e858be9 --- /dev/null +++ b/commands/phoneDiag.js @@ -0,0 +1,84 @@ +// src/commands/phoneDiag.js +// +// /phonediag [verbose|debug] +// +// MPP desk phone relay diagnostics (CP-7841 etc.): registration, +// switch LLDP, provisioning history, and formatted issues. + +import { collectPhoneStatus } from '../services/phoneService.js'; +import { discoverDeskPhones } from '../services/phoneDiscovery.js'; +import { probeAll } from '../services/phoneCollectorService.js'; +import { renderMppPhoneDiagnosticsMarkdown } from '../services/renderers/mppPhoneDiagnosticsRenderer.js'; +import { logger } from '../utils/logger.js'; + +export async function handlePhoneDiag(bot, trigger) { + const query = trigger.query || {}; + const args = trigger.args || []; + const storeNum = (args[0]?.trim() || query.storeNum || query.store || query.s || '').trim(); + + const isVerbose = argIncludes(args, 'verbose') || argIncludes(args, 'debug') + || query.verbose === 'true' || query.verbose === true + || query.debug === 'true' || query.debug === true; + + if (!storeNum || !/^\d{2,4}$/.test(storeNum)) { + await bot.say( + 'markdown', + 'Please provide a 2–4 digit store number.\n' + + 'Examples:\n' + + '- `/phonediag 782`\n' + + '- `/phonediag 782 verbose`', + ); + return; + } + + if (!process.env.DECT_RELAY_AGENT_TOKEN) { + await bot.say( + 'markdown', + '⚠️ Phone relay is not configured on this bot (`DECT_RELAY_AGENT_TOKEN` unset). ' + + 'Set the token and run dect-relay-agent in the data center to enable `/phonediag`.', + ); + return; + } + + logger('phone:diag', `Collecting MPP phone diagnostics for store ${storeNum}`); + + let phoneData; + try { + phoneData = await collectPhoneStatus(storeNum); + } catch (err) { + logger('phone:diag', `collectPhoneStatus failed for store ${storeNum}: ${err.message}`, 'error'); + await bot.say('markdown', `❌ Failed to look up store ${storeNum}: ${err.message}`); + return; + } + + const { phones, warnings } = discoverDeskPhones(phoneData || {}); + if (warnings.length > 0) { + logger( + 'phone:diag', + `MPP discovery warnings for store ${storeNum}: ${warnings.map((w) => w.reason).join('; ')}`, + 'warn', + ); + } + + if (phones.length === 0) { + await bot.say( + 'markdown', + `No MPP desk phones discovered on 10.x for store ${storeNum}.`, + ); + return; + } + + await bot.say( + 'markdown', + `⏳ Probing **${phones.length}** MPP desk phone(s) at store ${storeNum} via relay…`, + ); + + const results = await probeAll(phones); + const md = renderMppPhoneDiagnosticsMarkdown(results, { storeNum, verbose: isVerbose }); + await bot.say('markdown', md || 'No data available.'); +} + +function argIncludes(args, token) { + const needle = String(token).toLowerCase(); + return (args || []).some((a) => String(a).toLowerCase() === needle); +} diff --git a/commands/phoneStatus.js b/commands/phoneStatus.js deleted file mode 100644 index a71348d..0000000 --- a/commands/phoneStatus.js +++ /dev/null @@ -1,289 +0,0 @@ -// src/commands/phoneStatus.js -// -// Chat + HTTP entry point for /phonestatus. The heavy rendering lives in -// services/renderers/phoneStatusRenderer.js so the Jira poller can emit -// the same markdown (see services/jiraPollerService.js). This handler -// stays thin: parse args, call the collector, hand data to the renderer, -// respond. - -import { randomUUID } from 'node:crypto'; -import { collectPhoneStatus } from '../services/phoneService.js'; -import { - renderPhoneStatusMarkdown, - renderDectDiagnosticsMarkdown, -} from '../services/renderers/phoneStatusRenderer.js'; -import { renderWanDiagnosticsMarkdown } from '../services/renderers/wanDiagnosticsRenderer.js'; -import { renderMppPhoneDiagnosticsMarkdown } from '../services/renderers/mppPhoneDiagnosticsRenderer.js'; -import { buildIgmpFixCard } from './igmpFix.js'; -import { pendingIgmpFixes } from '../utils/pendingIgmpFixes.js'; -import { extractRequester } from '../utils/requester.js'; -import { logger } from '../utils/logger.js'; -import { discoverDectBases } from '../services/dectDiscovery.js'; -import { discoverDeskPhones } from '../services/phoneDiscovery.js'; -import { collectAll } from '../services/dectCollectorService.js'; -import { probeAll } from '../services/phoneCollectorService.js'; -import { siteNameForStore, findSdwanSiteForStore } from '../integrations/paloalto/sites.js'; -import { collectSdwanForStore } from '../services/enrichment/sdwanEnrichment.js'; - -export async function handlePhoneStatus(bot, trigger) { - logger('phone:status', 'Handler entered', 'debug'); - - // Support both Webex (args) and HTTP (query) calls - const query = trigger.query || {}; - const args = trigger.args || []; - - let storeNum = args[0]?.trim() || query.storeNum || query.store || query.s; - - const isDetailed = argIncludes(args, 'detailed') - || (query.mode === 'detailed') - || (query.detailed === 'true' || query.detailed === true); - - const isVerbose = argIncludes(args, 'verbose') || argIncludes(args, 'debug') - || query.verbose === 'true' || query.verbose === true - || query.debug === 'true' || query.debug === true; - - if (!storeNum || !/^\d{2,4}$/.test(storeNum)) { - const errorMsg = 'Please provide a 2–4 digit store number.\n' + - 'Example: `/phonestatus 782` (or `detailed` / `?detailed=true` for more; includes Meraki links) or `https://.../phonestatus?storeNum=782`'; - await bot.say('markdown', errorMsg); - return; - } - - logger('phone:status', `Collecting phone status for store ${storeNum}`, 'debug'); - - try { - const data = await collectPhoneStatus(storeNum); - if (!data) throw new Error('collectPhoneStatus returned undefined'); - - // JSON alt-output path (kept in the handler because it bypasses - // markdown rendering entirely — no shared renderer applies). - if (query.format === 'json' || (args[1] && args[1].toLowerCase() === 'json')) { - const jsonPayload = { - store: storeNum, - mainNumber: data.locationMainNumber, - timezone: (data.telephonyProfile && data.telephonyProfile.timeZone) || null, - person: data.person ? { displayName: data.person.displayName, phoneNumbers: data.person.phoneNumbers } : null, - timestamp: new Date().toISOString(), - }; - await bot.say('markdown', '```json\n' + JSON.stringify(jsonPayload, null, 2) + '\n```'); - return; - } - - // Discover reachable DECT bases BEFORE rendering so we can tell - // the renderer how many bases the follow-up will cover. Discovery - // is a pure filter over what phoneService already fetched — no - // network calls, so it doesn't slow the main output. Only chat - // triggers get a follow-up; HTTP callers keep the single-message - // contract they had before. - const dectFollowUpEnabled = !!trigger.person; - const { bases: reachableBases, warnings: discoveryWarnings } = dectFollowUpEnabled - ? discoverDectBases(data) - : { bases: [], warnings: [] }; - const { phones: mppPhones, warnings: mppDiscoveryWarnings } = dectFollowUpEnabled - ? discoverDeskPhones(data) - : { phones: [], warnings: [] }; - if (discoveryWarnings.length > 0) { - logger( - 'phone:status', - `DECT discovery warnings for store ${storeNum}: ${discoveryWarnings.map((w) => w.reason).join('; ')}`, - 'warn', - ); - } - if (mppDiscoveryWarnings.length > 0) { - logger( - 'phone:status', - `MPP discovery warnings for store ${storeNum}: ${mppDiscoveryWarnings.map((w) => w.reason).join('; ')}`, - 'warn', - ); - } - if (dectFollowUpEnabled) { - logger( - 'phone:status', - `MPP discovery for store ${storeNum}: ` + - `${mppPhones.length > 0 - ? `${mppPhones.length} desk phone(s) selected — follow-up scheduled (${mppPhones.map((p) => p.ip).join(', ')})` - : 'no MPP desk phones selected — no follow-up'}`, - ); - } - - // WAN follow-up discovery. Cheap Prisma-side check: hits the - // 4-hour-cached sites list to confirm this store is Prisma- - // managed before we promise a follow-up. Anything that throws - // (missing env, auth failure, network error) is swallowed and - // treated as "no site" so a broken Prisma integration cannot - // break /phonestatus. Chat-only, same as DECT. - let wanFollowUpEnabled = false; - if (trigger.person) { - const expectedSite = safeSiteName(storeNum); - try { - const site = await findSdwanSiteForStore(storeNum); - wanFollowUpEnabled = !!site; - // Info-level breadcrumb so operators can see the discovery - // outcome without cranking LOG_LEVEL=debug. The success case - // is also logged inside findSdwanSiteForStore; this line - // provides the "why /phonestatus did / didn't schedule a - // WAN follow-up" answer at the phone:status scope. - logger( - 'phone:status', - `WAN discovery for store ${storeNum} (expected ${expectedSite}): ` + - `${wanFollowUpEnabled ? 'MATCHED — follow-up scheduled' : 'no match — no follow-up'}`, - ); - } catch (err) { - logger( - 'phone:status', - `WAN discovery skipped for store ${storeNum} (expected ${expectedSite}): ${err.message}`, - 'warn', - ); - } - } - - const reply = renderPhoneStatusMarkdown(data, { - storeNum, - detailed: isDetailed, - footer: true, - dectFollowUpBaseCount: reachableBases.length, - mppFollowUpPhoneCount: mppPhones.length, - wanFollowUpEnabled, - }); - await bot.say('markdown', reply || 'No data available.'); - - // Kick off DECT follow-up. Fire-and-forget from this handler's - // perspective — the awaits inside runDectFollowUp() are just so - // failures get logged with a stable scope, they don't propagate - // back to the user's original /phonestatus call. If the relay is - // offline or a base is unreachable we still post the follow-up - // (with per-base error lines) so the user isn't left wondering - // where the promised diagnostics went. - if (dectFollowUpEnabled && reachableBases.length > 0) { - runDectFollowUp(bot, storeNum, reachableBases).catch((err) => { - logger('phone:status', `DECT follow-up failed for store ${storeNum}: ${err.message}`, 'error'); - }); - } - - if (dectFollowUpEnabled && mppPhones.length > 0) { - runMppPhoneFollowUp(bot, storeNum, mppPhones, { verbose: isVerbose }).catch((err) => { - logger('phone:status', `MPP phone follow-up failed for store ${storeNum}: ${err.message}`, 'error'); - }); - } - - // WAN follow-up (mirrors the DECT pattern). Only kicked when - // discovery above already confirmed we have a Prisma site for - // this store. Fire-and-forget with a stable log scope. - if (wanFollowUpEnabled) { - runWanFollowUp(bot, storeNum).catch((err) => { - logger('phone:status', `WAN follow-up failed for store ${storeNum}: ${err.message}`, 'error'); - }); - } - - // IGMP-snooping remediation card — only when (a) the multicast - // summary flagged deviation AND (b) we know the networkId (can't - // fix what we can't address) AND (c) the invocation came from - // chat, not HTTP. HTTP callers don't have adaptive-card UX; the - // remediation surface for them is a future gated POST endpoint. - // `trigger.person` is populated by the framework for chat triggers - // and absent for HTTP triggers (see index.js command dispatch). - if (data.multicast?.needsFix && data.multicast.networkId && trigger.person) { - const cardId = randomUUID(); - const requester = extractRequester(trigger); - pendingIgmpFixes.set(cardId, { - networkId: data.multicast.networkId, - networkName: data.multicast.networkName, - storeNum, - requester, - // Only the summary is stashed. The fix payload is a constant, - // so we don't need the raw snapshot — keeps the pending-store - // memory footprint tiny and avoids the temptation to - // read-then-mutate at PUT time. - summary: { - defaultSnoopOn: data.multicast.defaultSnoopOn, - defaultFloodOff: data.multicast.defaultFloodOff, - deviatingOverrides: data.multicast.deviatingOverrides || [], - }, - }); - const card = buildIgmpFixCard({ - storeNum, - networkName: data.multicast.networkName, - summary: { - defaultSnoopOn: data.multicast.defaultSnoopOn, - defaultFloodOff: data.multicast.defaultFloodOff, - deviatingOverrides: data.multicast.deviatingOverrides || [], - }, - cardId, - }); - await bot.say({ - markdown: `Multicast policy on store ${storeNum}'s network deviates from DECT-safe defaults. Review and confirm:`, - attachments: [{ - contentType: 'application/vnd.microsoft.card.adaptive', - content: card, - }], - }); - } - - } catch (err) { - logger('phone:status', `Error collecting phone status for store ${storeNum}: ${err.message}`, 'error'); - await bot.say('markdown', `Error collecting phone status: ${err.message}`); - } -} - -/** - * Run the DECT-diagnostics follow-up as a separate message in the - * same room. Only invoked from chat triggers. Errors are logged - * (never thrown up) — the /phonestatus main output has already been - * sent by the time we get here, so a follow-up crash shouldn't leave - * the user with a broken chat experience. - * - * Renderer emits an empty string only when the results list is empty - * — which shouldn't happen because we already checked reachableBases - * .length > 0 at the call site, but we still guard against it here. - */ -async function runDectFollowUp(bot, storeNum, bases) { - const results = await collectAll(bases); - const md = renderDectDiagnosticsMarkdown(results, { storeNum }); - if (!md) return; - await bot.say('markdown', md); -} - -async function runMppPhoneFollowUp(bot, storeNum, phones, { verbose = false } = {}) { - const results = await probeAll(phones); - const md = renderMppPhoneDiagnosticsMarkdown(results, { storeNum, verbose }); - if (!md) return; - await bot.say('markdown', md); -} - -/** - * Prisma SD-WAN follow-up. Runs the enrichment composer (which - * hydrates site + elements + healthscore + per-path LQM + alarms in - * parallel), hands the result to the pure WAN renderer, and posts. - * - * The composer never throws — every metric failure is preserved in - * `data.errors[]` and rendered as an inline "partial fetch" warning - * so the operator can see WHAT failed rather than getting silence. - * A missing site (unexpected here since we pre-discovered) short- - * circuits with an empty markdown string, and we no-op. - */ -async function runWanFollowUp(bot, storeNum) { - const data = await collectSdwanForStore(storeNum); - const md = renderWanDiagnosticsMarkdown(data, { storeNum }); - if (!md) return; - await bot.say('markdown', md); -} - -/** - * Small helper for a log line that runs before we've committed to - * a site lookup — used inside the catch branch of WAN discovery - * where we want to show the expected site name even if the lookup - * failed. Isolated in a function so the try/catch is single-line - * and the intent is obvious. - */ -function safeSiteName(storeNum) { - try { - return siteNameForStore(storeNum); - } catch { - return ''; - } -} - -function argIncludes(args, token) { - const needle = String(token).toLowerCase(); - return (args || []).some((a) => String(a).toLowerCase() === needle); -} diff --git a/commands/registry.js b/commands/registry.js index 99aebd1..287fc92 100644 --- a/commands/registry.js +++ b/commands/registry.js @@ -13,8 +13,10 @@ import { handleHelp } from './help.js'; import { handleAvStatus } from './avStatus.js'; -import { handlePhoneStatus } from './phoneStatus.js'; -import { handleDectStatus } from './dectStatus.js'; +import { handleVoiceStatus } from './voiceStatus.js'; +import { handleWanStatus } from './wanStatus.js'; +import { handlePhoneDiag } from './phoneDiag.js'; +import { handleDectDiag } from './dectDiag.js'; import { handleProvisionDect } from './provisionDect.js'; import { handleWoHistory } from './woHistory.js'; import { handleWoSummary } from './woSummary.js'; @@ -46,11 +48,13 @@ export const commands = [ { name: 'help', handler: handleHelp, mutating: false, http: false }, { name: 'avstatus', aliases: ['wostatus'], handler: handleAvStatus, mutating: false }, - { name: 'phonestatus', handler: handlePhoneStatus, mutating: false }, - // /dectstatus — full DECT base dump via the on-prem relay + chat-only + { name: 'voicestatus', handler: handleVoiceStatus, mutating: false }, + { name: 'wanstatus', handler: handleWanStatus, mutating: false }, + { name: 'phonediag', handler: handlePhoneDiag, mutating: false }, + // /dectdiag — full DECT base dump via the on-prem relay + chat-only // reboot/factory-reset cards. Read path is non-mutating; card submits - // only fire over Webex (see commands/dectStatus.js). - { name: 'dectstatus', aliases: ['dect'], handler: handleDectStatus, mutating: false }, + // only fire over Webex (see commands/dectDiag.js). + { name: 'dectdiag', aliases: ['dect'], handler: handleDectDiag, mutating: false }, // /voicediag reads per-user Webex Calling features via // /v1/people/{id}/features/* and offers per-issue adaptive-card // remediation. Reads are non-mutating but the confirm buttons on diff --git a/commands/voiceDiag.js b/commands/voiceDiag.js index ad78aad..e8cdc2e 100644 --- a/commands/voiceDiag.js +++ b/commands/voiceDiag.js @@ -33,6 +33,9 @@ import { buildRemediationRegistry, CHECKS, } from '../services/voiceDiag/voiceDiagService.js'; +import { pickWindowArg, parseWindowMinutes } from '../utils/windowArgs.js'; + +export { parseWindowMinutes }; // Built once at module load. `buildRemediationRegistry` throws on // duplicate action ids, so any collision surfaces at import time — @@ -58,7 +61,7 @@ export async function handleVoiceDiag(bot, trigger) { let storeNum = args[0]?.trim() || query.storeNum || query.store || query.s; // Accept every reasonable variant so the footer hint ("pass - // `detailed`") and the /phonestatus muscle memory ("`detailed`" as + // `detailed`") and the /voicestatus muscle memory ("`detailed`" as // the second positional) both work, alongside the flag forms // (`--detail`, `--detailed`) and the `?detailed=true` query param. const detailed = argIncludes(args, 'detail') @@ -145,7 +148,7 @@ export async function handleVoiceDiag(bot, trigger) { // Adaptive-card remediation is chat-only. HTTP callers get the // markdown snapshot but no interactive cards (same rule the - // /phonestatus + IGMP-fix inline card follows). `trigger.person` + // /voicestatus + IGMP-fix inline card follows). `trigger.person` // is populated for chat, absent for the HTTP fake trigger. if (!trigger.person) return; @@ -348,10 +351,6 @@ function pickOnlyArg(args) { return pickFlagValue(args, '--only'); } -function pickWindowArg(args) { - return pickFlagValue(args, '--window'); -} - // Generic `--flag=value` / `--flag value` extractor. function pickFlagValue(args, flag) { const prefix = `${flag.toLowerCase()}=`; @@ -363,27 +362,6 @@ function pickFlagValue(args, flag) { return null; } -/** - * Parse a window shorthand (`15m`, `1h`, `24h`, `1d`, `1440`) into - * minutes. Returns null on unrecognised input so the caller can - * decide whether to surface a friendly error or fall back to the - * env default. - */ -export function parseWindowMinutes(raw) { - if (raw === null || raw === undefined || raw === '') return undefined; - const s = String(raw).trim().toLowerCase(); - // Bare integer → treat as minutes. - if (/^\d+$/.test(s)) return Math.max(1, parseInt(s, 10)); - const m = s.match(/^(\d+)\s*(m|min|mins|h|hr|hrs|hour|hours|d|day|days)$/); - if (!m) return null; - const n = parseInt(m[1], 10); - const unit = m[2]; - if (['m', 'min', 'mins'].includes(unit)) return n; - if (['h', 'hr', 'hrs', 'hour', 'hours'].includes(unit)) return n * 60; - if (['d', 'day', 'days'].includes(unit)) return n * 60 * 24; - return null; -} - function renderListChecks() { let md = '**/voicediag registered checks**\n\n'; for (const c of CHECKS) { diff --git a/commands/voiceStatus.js b/commands/voiceStatus.js new file mode 100644 index 0000000..d8d80e2 --- /dev/null +++ b/commands/voiceStatus.js @@ -0,0 +1,103 @@ +// src/commands/voiceStatus.js +// +// Chat + HTTP entry point for /voicestatus. The heavy rendering lives in +// services/renderers/phoneStatusRenderer.js so the Jira poller can emit +// the same markdown (see services/jiraPollerService.js). This handler +// stays thin: parse args, call the collector, hand data to the renderer, +// respond. + +import { randomUUID } from 'node:crypto'; +import { collectPhoneStatus } from '../services/phoneService.js'; +import { renderPhoneStatusMarkdown } from '../services/renderers/phoneStatusRenderer.js'; +import { buildIgmpFixCard } from './igmpFix.js'; +import { pendingIgmpFixes } from '../utils/pendingIgmpFixes.js'; +import { extractRequester } from '../utils/requester.js'; +import { logger } from '../utils/logger.js'; + +export async function handleVoiceStatus(bot, trigger) { + logger('voice:status', 'Handler entered', 'debug'); + + const query = trigger.query || {}; + const args = trigger.args || []; + + let storeNum = args[0]?.trim() || query.storeNum || query.store || query.s; + + const isDetailed = argIncludes(args, 'detailed') + || (query.mode === 'detailed') + || (query.detailed === 'true' || query.detailed === true); + + if (!storeNum || !/^\d{2,4}$/.test(storeNum)) { + const errorMsg = 'Please provide a 2–4 digit store number.\n' + + 'Example: `/voicestatus 782` (or `detailed` / `?detailed=true` for more; includes Meraki links) or `https://.../voicestatus?storeNum=782`'; + await bot.say('markdown', errorMsg); + return; + } + + logger('voice:status', `Collecting voice status for store ${storeNum}`, 'debug'); + + try { + const data = await collectPhoneStatus(storeNum); + if (!data) throw new Error('collectPhoneStatus returned undefined'); + + if (query.format === 'json' || (args[1] && args[1].toLowerCase() === 'json')) { + const jsonPayload = { + store: storeNum, + mainNumber: data.locationMainNumber, + timezone: (data.telephonyProfile && data.telephonyProfile.timeZone) || null, + person: data.person ? { displayName: data.person.displayName, phoneNumbers: data.person.phoneNumbers } : null, + timestamp: new Date().toISOString(), + }; + await bot.say('markdown', '```json\n' + JSON.stringify(jsonPayload, null, 2) + '\n```'); + return; + } + + const reply = renderPhoneStatusMarkdown(data, { + storeNum, + detailed: isDetailed, + footer: true, + }); + await bot.say('markdown', reply || 'No data available.'); + + if (data.multicast?.needsFix && data.multicast.networkId && trigger.person) { + const cardId = randomUUID(); + const requester = extractRequester(trigger); + pendingIgmpFixes.set(cardId, { + networkId: data.multicast.networkId, + networkName: data.multicast.networkName, + storeNum, + requester, + summary: { + defaultSnoopOn: data.multicast.defaultSnoopOn, + defaultFloodOff: data.multicast.defaultFloodOff, + deviatingOverrides: data.multicast.deviatingOverrides || [], + }, + }); + const card = buildIgmpFixCard({ + storeNum, + networkName: data.multicast.networkName, + summary: { + defaultSnoopOn: data.multicast.defaultSnoopOn, + defaultFloodOff: data.multicast.defaultFloodOff, + deviatingOverrides: data.multicast.deviatingOverrides || [], + }, + cardId, + }); + await bot.say({ + markdown: `Multicast policy on store ${storeNum}'s network deviates from DECT-safe defaults. Review and confirm:`, + attachments: [{ + contentType: 'application/vnd.microsoft.card.adaptive', + content: card, + }], + }); + } + + } catch (err) { + logger('voice:status', `Error collecting voice status for store ${storeNum}: ${err.message}`, 'error'); + await bot.say('markdown', `Error collecting voice status: ${err.message}`); + } +} + +function argIncludes(args, token) { + const needle = String(token).toLowerCase(); + return (args || []).some((a) => String(a).toLowerCase() === needle); +} diff --git a/commands/wanStatus.js b/commands/wanStatus.js new file mode 100644 index 0000000..32052ac --- /dev/null +++ b/commands/wanStatus.js @@ -0,0 +1,77 @@ +// src/commands/wanStatus.js +// +// /wanstatus [--window 24h] +// +// Prisma SD-WAN diagnostics for a store: healthscore, per-path LQM, +// voice traffic quality (when configured), and active alarms. + +import { collectSdwanForStore } from '../services/enrichment/sdwanEnrichment.js'; +import { renderWanDiagnosticsMarkdown } from '../services/renderers/wanDiagnosticsRenderer.js'; +import { findSdwanSiteForStore, siteNameForStore } from '../integrations/paloalto/sites.js'; +import { pickWindowArg, parseWindowMinutes } from '../utils/windowArgs.js'; +import { logger } from '../utils/logger.js'; + +export async function handleWanStatus(bot, trigger) { + const query = trigger.query || {}; + const args = trigger.args || []; + const storeNum = (args[0]?.trim() || query.storeNum || query.store || query.s || '').trim(); + + const windowRaw = pickWindowArg(args) ?? query.window ?? null; + const windowMinutes = parseWindowMinutes(windowRaw); + if (windowRaw && windowMinutes == null) { + await bot.say( + 'markdown', + `Unrecognised window \`${windowRaw}\`. Use \`15m\`, \`1h\`, \`6h\`, \`24h\`, \`1d\`, or \`7d\`.`, + ); + return; + } + + if (!storeNum || !/^\d{2,4}$/.test(storeNum)) { + await bot.say( + 'markdown', + 'Please provide a 2–4 digit store number.\n' + + 'Examples:\n' + + '- `/wanstatus 782`\n' + + '- `/wanstatus 782 --window 24h`\n' + + 'HTTP: `?storeNum=782&window=7d`', + ); + return; + } + + logger('wan:status', `Collecting WAN status for store ${storeNum}`); + + let site; + try { + site = await findSdwanSiteForStore(storeNum); + } catch (err) { + logger('wan:status', `Site lookup failed for store ${storeNum}: ${err.message}`, 'warn'); + await bot.say( + 'markdown', + `❌ WAN lookup failed for store ${storeNum}: ${err.message}`, + ); + return; + } + + if (!site) { + const expected = safeSiteName(storeNum); + await bot.say( + 'markdown', + `Store ${storeNum} is not Prisma SD-WAN managed (expected site \`${expected}\`).`, + ); + return; + } + + await bot.say('markdown', `⏳ Collecting WAN metrics for store ${storeNum}…`); + + const data = await collectSdwanForStore(storeNum, { windowMinutes }); + const md = renderWanDiagnosticsMarkdown(data, { storeNum }); + await bot.say('markdown', md || 'No WAN data available.'); +} + +function safeSiteName(storeNum) { + try { + return siteNameForStore(storeNum); + } catch { + return ''; + } +} diff --git a/index.js b/index.js index 2aaef41..0e3ac82 100644 --- a/index.js +++ b/index.js @@ -23,7 +23,7 @@ import { handleDectProvisionAction } from './commands/provisionDect.js'; import { handleDectStatusAction, DECT_STATUS_CARD_ACTIONS, -} from './commands/dectStatus.js'; +} from './commands/dectDiag.js'; import { applyOffboardConfirmation, cancelOffboardCard, @@ -840,7 +840,7 @@ if (process.env.DECT_RELAY_AGENT_TOKEN) { } else { logger( 'startup', - 'DECT relay disabled — set DECT_RELAY_AGENT_TOKEN in .env (and share the same value with dect-relay-agent) to enable /phonestatus DECT follow-up + /dectstatus', + 'DECT relay disabled — set DECT_RELAY_AGENT_TOKEN in .env (and share the same value with dect-relay-agent) to enable /phonediag, /dectdiag, and /voicediag relay probes', 'warn', ); } diff --git a/integrations/cisco-mpp-phone/downloadStatusJson.js b/integrations/cisco-mpp-phone/downloadStatusJson.js index 6c6fca7..7bf94a8 100644 --- a/integrations/cisco-mpp-phone/downloadStatusJson.js +++ b/integrations/cisco-mpp-phone/downloadStatusJson.js @@ -78,13 +78,37 @@ export function downloadStatusWarnings(download) { const warnings = []; if (!download) return warnings; if (download.micCert?.failed) { - warnings.push(`MIC cert: ${download.micCert.provisioningStatus}`); + warnings.push('mic_cert_failed'); } if (download.latestProvisioning?.failed) { - warnings.push(`provisioning: ${download.latestProvisioning.result}`); + warnings.push('provisioning_failed'); } if (download.latestFirmwareUpgrade?.failed) { - warnings.push(`firmware upgrade: ${download.latestFirmwareUpgrade.result}`); + warnings.push('firmware_upgrade_failed'); } return warnings; } + +/** + * Human-readable MIC cert issue for chat rendering (no raw URLs). + * @param {{ provisioningStatus?: string|null, info?: string|null, failed?: boolean }|null} micCert + * @returns {string|null} + */ +export function formatMicCertForDisplay(micCert) { + if (!micCert?.failed && !micCert?.provisioningStatus) return null; + const entry = parseHistoryEntry(micCert.provisioningStatus || micCert.info); + const at = entry?.timestamp || null; + const reason = shortenMicReason(entry?.result || micCert.provisioningStatus || 'download failed'); + const atSuffix = at ? ` (${at})` : ''; + return `MIC cert download failed — ${reason}${atSuffix}`; +} + +function shortenMicReason(raw) { + if (!raw || typeof raw !== 'string') return 'unknown error'; + const reasonMatch = raw.match(/Reason:\s*(.+?)\.?$/i); + if (reasonMatch) return reasonMatch[1].trim(); + const failedMatch = raw.match(/MIC Cert Download Failed\.?\s*(.*)$/i); + if (failedMatch && failedMatch[1].trim()) return failedMatch[1].replace(/^Reason:\s*/i, '').trim(); + if (/fail/i.test(raw)) return 'download failed'; + return raw.length > 80 ? `${raw.slice(0, 77)}…` : raw; +} diff --git a/services/dectStatus/buildHandsetContext.js b/services/dectStatus/buildHandsetContext.js index 24f6bc5..5d93717 100644 --- a/services/dectStatus/buildHandsetContext.js +++ b/services/dectStatus/buildHandsetContext.js @@ -1,6 +1,6 @@ // services/dectStatus/buildHandsetContext.js // -// Group Webex DECT handset inventory for /dectstatus rendering. +// Group Webex DECT handset inventory for /dectdiag rendering. /** * @param {object|null} phoneData collectPhoneStatus() output diff --git a/services/enrichment/sdwanEnrichment.js b/services/enrichment/sdwanEnrichment.js index 9afb11c..1e6f051 100644 --- a/services/enrichment/sdwanEnrichment.js +++ b/services/enrichment/sdwanEnrichment.js @@ -1,6 +1,6 @@ // src/services/enrichment/sdwanEnrichment.js // -// One-stop composer for Prisma SD-WAN data used by /phonestatus's +// One-stop composer for Prisma SD-WAN data used by /wanstatus and // WAN follow-up and /voicediag's WAN check bucket. Same "fire the // fetches in parallel + normalise into a stable shape + preserve // per-metric failures in errors[]" pattern used by the Meraki @@ -80,7 +80,7 @@ import { buildAppDetailsUrl } from '../../integrations/paloalto/urls.js'; import { coerceAlarmTsMs } from './alarmSemantics.js'; /** - * Compose everything /voicediag + /phonestatus care about for a + * Compose everything /voicediag + /wanstatus care about for a * store's Prisma SD-WAN posture. Never throws — always returns the * shape documented at the top of this file. * diff --git a/services/jiraPoller/README.md b/services/jiraPoller/README.md index 4e93b20..cee3293 100644 --- a/services/jiraPoller/README.md +++ b/services/jiraPoller/README.md @@ -22,9 +22,9 @@ For tickets with component **Communication Services** and a resolvable store: | Symptom keywords | Checks run | |------------------|------------| -| Call quality / connectivity (garbled, static, can't connect, …) | `phonestatus` + `callreport` | +| Call quality / connectivity (garbled, static, can't connect, …) | `voicestatus` + `callreport` | | Spam / robocall / nuisance | `callreport` only | -| Neither | Default: `phone` → phonestatus, `av` → avstatus | +| Neither | Default: `phone` → voicestatus, `av` → avstatus | `callreport` uses the **today** business window (9am local → now minus 5 min). diff --git a/services/jiraPoller/enrichmentRules.js b/services/jiraPoller/enrichmentRules.js index 193de59..a0521f3 100644 --- a/services/jiraPoller/enrichmentRules.js +++ b/services/jiraPoller/enrichmentRules.js @@ -4,7 +4,7 @@ export const COMM_SERVICES_COMPONENT = 'Communication Services'; export const CHECK_IDS = { - phonestatus: 'phonestatus', + voicestatus: 'voicestatus', callreport: 'callreport', avstatus: 'avstatus', }; @@ -109,7 +109,7 @@ export function resolveEnrichmentPlan(ticket, classification) { } const checks = []; if (callQuality) { - checks.push(CHECK_IDS.phonestatus, CHECK_IDS.callreport); + checks.push(CHECK_IDS.voicestatus, CHECK_IDS.callreport); } else if (spam) { checks.push(CHECK_IDS.callreport); } @@ -128,7 +128,7 @@ export function resolveEnrichmentPlan(ticket, classification) { if (classification.kind === 'phone') { return { - checks: [CHECK_IDS.phonestatus], + checks: [CHECK_IDS.voicestatus], matchedRules: ['default-phone'], storeNum, skip: false, diff --git a/services/jiraPoller/runEnrichment.js b/services/jiraPoller/runEnrichment.js index b5a0c68..311bc20 100644 --- a/services/jiraPoller/runEnrichment.js +++ b/services/jiraPoller/runEnrichment.js @@ -14,10 +14,10 @@ import { formatEnrichmentBody } from './formatBody.js'; const LOG_SCOPE = 'jira:poller:enrich'; const CHECK_RUNNERS = { - [CHECK_IDS.phonestatus]: async (storeNum) => { + [CHECK_IDS.voicestatus]: async (storeNum) => { const data = await collectPhoneStatus(storeNum); return { - title: 'Phone status', + title: 'Voice status', markdown: renderPhoneStatusMarkdown(data, { storeNum, detailed: true, diff --git a/services/jiraPollerService.js b/services/jiraPollerService.js index 1a97582..036f449 100644 --- a/services/jiraPollerService.js +++ b/services/jiraPollerService.js @@ -27,7 +27,7 @@ // line — so future non-store enrichment tooling can pick it up later. // // Comment format — Jira Cloud v3 requires ADF. We emit an italic -// header paragraph followed by the same markdown the /phonestatus or +// header paragraph followed by the same markdown the /voicestatus or // /avstatus chat command would produce, converted to ADF paragraphs // via utils/markdownToAdf. This preserves clickable Meraki links, // bold device names, and paragraph structure that the previous diff --git a/services/phoneDeviceBuilder.js b/services/phoneDeviceBuilder.js index ab98898..d984f76 100644 --- a/services/phoneDeviceBuilder.js +++ b/services/phoneDeviceBuilder.js @@ -3,7 +3,7 @@ // Thin wrapper for phone data rich/build shape for the phone store dashboard. // Delegates to collectPhoneStatus (which already does Webex phones/DECT + Meraki attach + relevant ports). // Adds optional Meraki topology for viz mirroring the AV dashboard. -// Keeps backward compat for the /phonestatus command (which uses collect directly). +// Keeps backward compat for the /voicestatus command (which uses collect directly). import { collectPhoneStatus } from './phoneService.js'; import { getMerakiTopology } from '../integrations/meraki/devices.js'; diff --git a/services/renderers/dectStatusRenderer.js b/services/renderers/dectStatusRenderer.js index 8f9b81c..46a8a56 100644 --- a/services/renderers/dectStatusRenderer.js +++ b/services/renderers/dectStatusRenderer.js @@ -1,7 +1,6 @@ // src/services/renderers/dectStatusRenderer.js // -// Full CLI-style DECT base dump for `/dectstatus`. Complements the -// compact follow-up from renderDectDiagnosticsMarkdown (phonestatus). +// Full CLI-style DECT base dump for `/dectdiag`. import { formatDisplayTime, simpleTimeAgo } from '../../utils/time.js'; import { handsetsForBase } from '../dectStatus/buildHandsetContext.js'; diff --git a/services/renderers/mppPhoneDiagnosticsRenderer.js b/services/renderers/mppPhoneDiagnosticsRenderer.js index c3dd58b..522c04c 100644 --- a/services/renderers/mppPhoneDiagnosticsRenderer.js +++ b/services/renderers/mppPhoneDiagnosticsRenderer.js @@ -1,8 +1,18 @@ // services/renderers/mppPhoneDiagnosticsRenderer.js // -// MPP desk phone diagnostics follow-up for /phonestatus (via relay). +// MPP desk phone diagnostics for /phonediag (via relay). import { formatDisplayTime } from '../../utils/time.js'; +import { + shortenFirmware, + formatHeaderStatusLine, + headerIconForLine, + isLineRegistered, + formatRebootEntry, + formatProvisioningResync, + formatFirmwareUpgrade, + collectIssues, +} from './mppPhoneFormatters.js'; /** * @param {Array} results probeAll() output @@ -40,165 +50,148 @@ function renderOnePhone(r, { verbose }) { const ip = r.phone?.ip || r.parsed?.network?.ipv4 || '?'; if (!r.ok) { - return `⚠️ **${label}** (${ip}) — probe failed: ${r.error?.message || 'unknown error'}` + - (r.error?.hint ? `\n _${r.error.hint}_` : ''); + return `⚠️ **${label}** · \`${ip}\` — probe failed: ${r.error?.message || 'unknown error'}` + + (r.error?.hint ? `\n_${r.error.hint}_` : ''); } const mpp = r.mpp || {}; const parsed = mpp.parsed || r.parsed || {}; - const verdict = mpp.verdict || r.verdict || {}; const device = parsed.device || {}; - const network = parsed.network || {}; const phoneStatus = parsed.phoneStatus || {}; const line1 = parsed.lines?.find((l) => l.index === 1) || parsed.lines?.[0]; const neighbor = mpp.networkNeighbor || {}; const download = mpp.download || {}; - const system = mpp.system || {}; + const issues = collectIssues(mpp, parsed); - const icon = verdict.healthy ? '✅' : '⚠️'; - const fwShort = shortenFirmware(device.firmware); - let out = `${icon} **${label}** (${ip})\n`; - out += ` ${device.product || '?'} · ${device.mac || '?'} · ${fwShort}\n`; - - if (line1?.registrationState) { - out += ` Ext ${line1.index}: ${line1.registrationState}`; - if (line1.lastRegistrationIp) out += ` → ${line1.lastRegistrationIp}`; - if (line1.lastRegistrationAt) out += ` (last ${line1.lastRegistrationAt})`; - out += '\n'; - } - - const uptime = phoneStatus.elapsedTime || '?'; - const vlan = network.vlan ? `VLAN ${network.vlan}` : null; - const link = phoneStatus.linkSpeed || neighbor.portSpeed || null; - const netBits = [uptime !== '?' ? `uptime ${uptime}` : null, vlan, link].filter(Boolean); - if (netBits.length) out += ` ${netBits.join(' · ')}\n`; - - if (neighbor.switchDevice) { - const swIp = neighbor.switchIp ? ` (${neighbor.switchIp})` : ''; - out += ` Switch: ${neighbor.switchDevice} ${neighbor.switchPort || ''}${swIp}\n`; - } - - if (parsed.rebootHistory?.length) { - out += ` Last reboot: ${formatReboot(parsed.rebootHistory[0])}\n`; - } - - if (download.latestProvisioning) { - const prov = download.latestProvisioning; - const host = prov.url ? hostFromUrl(prov.url) : '?'; - const result = prov.succeeded ? 'OK' : (prov.failed ? 'FAILED' : (prov.result || '?')); - out += ` Last resync: ${host} · ${result}\n`; - } - - if (download.latestFirmwareUpgrade) { - const fw = download.latestFirmwareUpgrade; - if (fw.succeeded) { - out += ` Firmware upgrade: OK\n`; - } else if (fw.failed) { - out += ` Firmware upgrade: ${fw.result}\n`; - } - } - - if (phoneStatus.dot1xStatus) { - out += ` 802.1X: ${phoneStatus.dot1xStatus}${phoneStatus.dot1xProtocol ? ` (${phoneStatus.dot1xProtocol})` : ''}\n`; - } - - if (phoneStatus.ledLine1Color) { - out += ` Line 1 LED: ${phoneStatus.ledLine1Color}${phoneStatus.ledLine1Cadence ? ` ${phoneStatus.ledLine1Cadence}` : ''}\n`; - } - - if (parsed.sip?.messagesSent || parsed.sip?.messagesRecv) { - out += ` SIP msgs: ${parsed.sip.messagesSent || 0} sent / ${parsed.sip.messagesRecv || 0} recv`; - if (phoneStatus.sipBytesSent) out += ` (${phoneStatus.sipBytesSent}B / ${phoneStatus.sipBytesRecv || 0}B)`; - out += '\n'; - } - - if (system.webServer) { - out += ` Web server: ${system.webServer}`; - if (system.proxyMode) out += ` · proxy ${system.proxyMode}`; - out += '\n'; - } - - if (line1?.messageWaiting && line1.messageWaiting !== 'No') { - out += ` Message waiting: ${line1.messageWaiting}\n`; - } - if (line1?.hotelingState && line1.hotelingState !== 'Disabled') { - out += ` Hoteling: ${line1.hotelingState}\n`; - } - - if (Array.isArray(verdict.warnings) && verdict.warnings.length > 0) { - for (const w of verdict.warnings) { - out += ` ⚠️ ${w}\n`; - } - } + let out = renderPhoneHeader({ label, ip, device, phoneStatus, neighbor, line1 }); + out += renderRegistrationSection(line1, phoneStatus); + out += renderSwitchSection(neighbor); + out += renderProvisioningSection(parsed, download); + out += renderIssuesSection(issues); if (verbose) { - out += renderVerboseTier(r, parsed, neighbor, mpp.probes); + out += renderVerboseTier(r, parsed, neighbor, mpp.probes, line1); } return out; } -function renderVerboseTier(r, parsed, neighbor, probes) { +function renderPhoneHeader({ label, ip, device, phoneStatus, neighbor, line1 }) { + const icon = headerIconForLine(line1); + const fwShort = shortenFirmware(device.firmware); + let out = `${icon} **${label}** · \`${ip}\`\n`; + out += `_${device.product || '?'} · ${device.mac || '?'} · ${fwShort}_\n`; + const statusLine = formatHeaderStatusLine(phoneStatus, neighbor); + if (statusLine) out += `_${statusLine}_\n`; + return `${out}\n`; +} + +function renderRegistrationSection(line1, phoneStatus) { + const bullets = []; + if (line1?.registrationState) { + let reg = `Ext ${line1.index}: ${line1.registrationState}`; + if (line1.lastRegistrationIp) reg += ` → ${line1.lastRegistrationIp}`; + if (line1.lastRegistrationAt) reg += ` (last ${line1.lastRegistrationAt})`; + bullets.push(reg); + } + + if (line1 && !isLineRegistered(line1) && phoneStatus.ledLine1Color) { + const led = `${phoneStatus.ledLine1Color}${phoneStatus.ledLine1Cadence ? ` ${phoneStatus.ledLine1Cadence}` : ''}`; + bullets.push(`Line 1 LED: ${led}`); + } + + if (line1?.messageWaiting && line1.messageWaiting !== 'No') { + bullets.push(`Message waiting: ${line1.messageWaiting}`); + } + if (line1?.hotelingState && line1.hotelingState !== 'Disabled') { + bullets.push(`Hoteling: ${line1.hotelingState}`); + } + + if (bullets.length === 0) return ''; + return `**Registration**\n${bullets.map((b) => `• ${b}`).join('\n')}\n\n`; +} + +function renderSwitchSection(neighbor) { + if (!neighbor?.switchDevice) return ''; + const swIp = neighbor.switchIp ? ` · \`${neighbor.switchIp}\`` : ''; + const port = neighbor.switchPort || ''; + return `**Switch**\n• ${neighbor.switchDevice} · ${port}${swIp}\n\n`; +} + +function renderProvisioningSection(parsed, download) { + const bullets = []; + if (parsed.rebootHistory?.length) { + const reboot = formatRebootEntry(parsed.rebootHistory[0]); + if (reboot) bullets.push(`Last reboot: ${reboot}`); + } + const resync = formatProvisioningResync(download.latestProvisioning); + if (resync) bullets.push(resync); + const fw = formatFirmwareUpgrade(download.latestFirmwareUpgrade); + if (fw) bullets.push(fw); + + if (bullets.length === 0) return ''; + return `**Provisioning**\n${bullets.map((b) => `• ${b}`).join('\n')}\n\n`; +} + +function renderIssuesSection(issues) { + if (!Array.isArray(issues) || issues.length === 0) return ''; + return `**Issues**\n${issues.map((i) => `• ${i}`).join('\n')}\n\n`; +} + +function renderVerboseTier(r, parsed, neighbor, probes, line1) { let out = ''; const otherLines = (parsed.lines || []).filter((l) => l.index !== 1); - for (const line of otherLines) { - out += ` Ext ${line.index}: ${line.registrationState || '?'}\n`; + if (otherLines.length > 0) { + out += '**Extensions**\n'; + for (const line of otherLines) { + out += `• Ext ${line.index}: ${line.registrationState || '?'}\n`; + } + out += '\n'; + } + + if (parsed.sip?.messagesSent || parsed.sip?.messagesRecv) { + const ps = parsed.phoneStatus || {}; + out += `**SIP counters**\n• ${parsed.sip.messagesSent || 0} sent / ${parsed.sip.messagesRecv || 0} recv`; + if (ps.sipBytesSent) out += ` (${ps.sipBytesSent}B / ${ps.sipBytesRecv || 0}B)`; + out += '\n\n'; + } + + if (isLineRegistered(line1) && line1 && parsed.phoneStatus?.ledLine1Color) { + const ps = parsed.phoneStatus; + const led = `${ps.ledLine1Color}${ps.ledLine1Cadence ? ` ${ps.ledLine1Cadence}` : ''}`; + out += `**LED**\n• Line 1: ${led}\n\n`; } const ps = parsed.phoneStatus || {}; if (ps.ipv6Status || ps.ipv6Address) { - out += ` IPv6: ${ps.ipv6Status || '?'} ${ps.ipv6Address || ''}\n`.trimEnd() + '\n'; + out += `**IPv6**\n• ${ps.ipv6Status || '?'} ${ps.ipv6Address || ''}\n\n`; } - if (ps.tr069Feature) out += ` TR-069: ${ps.tr069Feature}\n`; + if (ps.tr069Feature) out += `**TR-069**\n• ${ps.tr069Feature}\n\n`; if (ps.multicastRx != null || ps.multicastTx != null) { - out += ` Paging multicast: rx ${ps.multicastRx || 0} / tx ${ps.multicastTx || 0}\n`; + out += `**Paging multicast**\n• rx ${ps.multicastRx || 0} / tx ${ps.multicastTx || 0}\n\n`; } - if (ps.streamingRx != null) out += ` XML streaming rx: ${ps.streamingRx}\n`; + if (ps.streamingRx != null) out += `**XML streaming**\n• rx ${ps.streamingRx}\n\n`; const counters = neighbor.errorCounters || {}; const counterKeys = Object.keys(counters); if (counterKeys.length > 0) { - out += ' Ethernet errors:\n'; + out += '**Ethernet errors**\n'; for (const k of counterKeys.slice(0, 8)) { - out += ` ${k}: ${counters[k]}\n`; + out += `• ${k}: ${counters[k]}\n`; } + out += '\n'; } const probeList = Array.isArray(probes) ? probes : []; if (probeList.length > 0) { - out += ' Probe paths:\n'; - out += ' ```\n'; - out += ' STATUS BYTES PATH\n'; + out += '**Probe paths**\n```\nSTATUS BYTES PATH\n'; for (const p of probeList) { const status = p.status == null ? 'ERR' : String(p.status); - out += ` ${status.padEnd(6)} ${String(p.sizeBytes || 0).padEnd(6)} ${p.path}\n`; + out += `${status.padEnd(6)} ${String(p.sizeBytes || 0).padEnd(6)} ${p.path}\n`; } - out += ' ```\n'; + out += '```\n'; } return out; } - -function shortenFirmware(fw) { - if (!fw || typeof fw !== 'string') return '?'; - const m = fw.match(/(sip78xx[^\s]+)/i); - return m ? m[1] : (fw.length > 36 ? `${fw.slice(0, 36)}…` : fw); -} - -function formatReboot(raw) { - if (!raw) return '?'; - const m = raw.match(/^(\w+)\(([^)]+)\)/); - return m ? `${m[1]} ${m[2]}` : raw; -} - -function hostFromUrl(url) { - if (!url || typeof url !== 'string') return '?'; - try { - const normalized = url.startsWith('http') ? url : `https://${url}`; - const u = new URL(normalized); - return u.hostname; - } catch { - return url.length > 30 ? `${url.slice(0, 30)}…` : url; - } -} diff --git a/services/renderers/mppPhoneFormatters.js b/services/renderers/mppPhoneFormatters.js new file mode 100644 index 0000000..692186a --- /dev/null +++ b/services/renderers/mppPhoneFormatters.js @@ -0,0 +1,133 @@ +// services/renderers/mppPhoneFormatters.js +// +// Display helpers for MPP phone diagnostics follow-up messages. + +import { formatMicCertForDisplay } from '../../integrations/cisco-mpp-phone/downloadStatusJson.js'; + +export function shortenFirmware(fw) { + if (!fw || typeof fw !== 'string') return '?'; + const m = fw.match(/(sip78xx[^\s]+)/i); + return m ? m[1] : (fw.length > 36 ? `${fw.slice(0, 36)}…` : fw); +} + +/** @param {string|null|undefined} elapsedTime */ +export function formatUptime(elapsedTime) { + if (!elapsedTime || typeof elapsedTime !== 'string') return null; + const trimmed = elapsedTime.trim(); + if (!trimmed) return null; + + const dayHour = trimmed.match(/(\d+)\s+days?\s+and\s+(\d+):(\d+):(\d+)/i); + if (dayHour) { + const days = Number(dayHour[1]); + const hours = Number(dayHour[2]); + if (days > 0) return `${days}d ${hours}h`; + if (hours > 0) return `${hours}h`; + return `${Number(dayHour[3])}m`; + } + + const hms = trimmed.match(/^(\d+):(\d+):(\d+)$/); + if (hms) { + const hours = Number(hms[1]); + const mins = Number(hms[2]); + if (hours > 0) return `${hours}h ${mins}m`; + return `${mins}m`; + } + + return trimmed.length > 24 ? `${trimmed.slice(0, 24)}…` : trimmed; +} + +/** @param {object} phoneStatus @param {object} neighbor */ +export function formatLinkSpeed(phoneStatus = {}, neighbor = {}) { + const fromStatus = phoneStatus.linkSpeed || phoneStatus.linkConfig || null; + if (fromStatus && fromStatus !== 'Disabled') return String(fromStatus).trim(); + const fromLldp = neighbor.portSpeed ? String(neighbor.portSpeed).trim() : null; + if (fromLldp) { + const compact = fromLldp.replace(/\s+/g, ' '); + if (/^\d+F$/i.test(compact)) return compact.replace(/F$/i, 'M Full'); + return compact; + } + return null; +} + +export function formatHeaderStatusLine(phoneStatus, neighbor) { + const uptime = formatUptime(phoneStatus?.elapsedTime); + const link = formatLinkSpeed(phoneStatus, neighbor); + const parts = []; + if (uptime) parts.push(`Uptime ${uptime}`); + if (link) parts.push(link); + return parts.length ? parts.join(' · ') : null; +} + +export function isLineRegistered(line) { + const state = String(line?.registrationState || '').toLowerCase(); + if (!state) return false; + if (state.includes('not registered') || state.includes('fail')) return false; + return state.includes('registered'); +} + +export function headerIconForLine(line) { + if (!line?.registrationState) return '⚠️'; + return isLineRegistered(line) ? '✅' : '❌'; +} + +export function formatRebootEntry(raw) { + if (!raw) return null; + const m = String(raw).match(/^(\w+)\(([^)]+)\)/); + if (m) return `${m[1]} @ ${m[2]}`; + return String(raw); +} + +export function hostFromUrl(url) { + if (!url || typeof url !== 'string') return '?'; + try { + const normalized = url.startsWith('http') ? url : `https://${url}`; + return new URL(normalized).hostname; + } catch { + return url.length > 30 ? `${url.slice(0, 30)}…` : url; + } +} + +export function formatProvisioningResync(entry) { + if (!entry) return null; + const host = entry.url ? hostFromUrl(entry.url) : '?'; + const result = entry.succeeded ? 'OK' : (entry.failed ? 'FAILED' : (entry.result || '?')); + return `Resync: ${host} · ${result}`; +} + +export function formatFirmwareUpgrade(entry) { + if (!entry) return null; + if (entry.succeeded) return 'Firmware upgrade: OK'; + if (entry.failed) return `Firmware upgrade: ${entry.result || 'FAILED'}`; + return null; +} + +/** + * @param {object} mpp buildMppProbeView output + * @param {object} parsed + * @returns {string[]} + */ +export function collectIssues(mpp, parsed) { + const issues = []; + const download = mpp?.download; + + const mic = formatMicCertForDisplay(download?.micCert); + if (mic) issues.push(mic); + + if (download?.latestProvisioning?.failed) { + const host = download.latestProvisioning.url + ? hostFromUrl(download.latestProvisioning.url) + : '?'; + issues.push(`Provisioning resync failed — ${host}`); + } + + if (download?.latestFirmwareUpgrade?.failed) { + issues.push(`Firmware upgrade failed — ${download.latestFirmwareUpgrade.result || 'unknown'}`); + } + + const line1 = parsed?.lines?.find((l) => l.index === 1) || parsed?.lines?.[0]; + if (line1?.registrationState && !isLineRegistered(line1)) { + issues.push(`Ext ${line1.index}: ${line1.registrationState}`); + } + + return issues; +} diff --git a/services/renderers/phoneStatusRenderer.js b/services/renderers/phoneStatusRenderer.js index dfd71e8..475d861 100644 --- a/services/renderers/phoneStatusRenderer.js +++ b/services/renderers/phoneStatusRenderer.js @@ -1,9 +1,8 @@ // src/services/renderers/phoneStatusRenderer.js // -// Extracted from commands/phoneStatus.js so the same output can drive +// Extracted from commands/voiceStatus.js so the same output can drive // BOTH the chat reply (`bot.say('markdown', md)`) AND the Jira poller -// comment (via utils/markdownToAdf). Byte-for-byte identical to what -// the chat command used to emit for a given input. +// comment (via utils/markdownToAdf). // // Options // storeNum (required) header string uses it @@ -29,20 +28,6 @@ import { simpleTimeAgo, formatBytes, formatDisplayTime } from '../../utils/time. * @param {string} opts.storeNum 2-6 digit store id (header text) * @param {boolean} [opts.detailed=false] * @param {boolean} [opts.footer=true] - * @param {number} [opts.dectFollowUpBaseCount=0] - * When > 0, emits an "⏳ DECT base data loading for N base(s)…" - * line inside the DECT Basestations section. Signals to the reader - * that a follow-up message with base-station diagnostics is on the - * way. Chat handler passes this after the base count comes back - * from discoverDectBases(); poller and HTTP callers pass 0. - * @param {number} [opts.mppFollowUpPhoneCount=0] - * When > 0, emits MPP desk-phone relay diagnostics loading hint. - * @param {boolean} [opts.wanFollowUpEnabled=false] - * When true, emits a "⏳ WAN metrics loading…" line just above the - * footer. Same rationale as `dectFollowUpBaseCount` — chat handler - * sets this only when the Prisma SD-WAN site resolves and a - * follow-up is genuinely in-flight; HTTP / Jira surfaces pass - * false and never see the line. * @returns {string} markdown, whitespace-trimmed and ready to send. */ export function renderPhoneStatusMarkdown(data, opts = {}) { @@ -50,12 +35,9 @@ export function renderPhoneStatusMarkdown(data, opts = {}) { storeNum, detailed = false, footer = true, - dectFollowUpBaseCount = 0, - mppFollowUpPhoneCount = 0, - wanFollowUpEnabled = false, } = opts; - let reply = `**Phone Status - Store ${storeNum}**\n\n`; + let reply = `**Voice Status - Store ${storeNum}**\n\n`; const phones = data.phones?.data || []; const dectBasestations = data.dectBasestations || []; @@ -141,11 +123,6 @@ export function renderPhoneStatusMarkdown(data, opts = {}) { } reply += '\n'; }); - - if (mppFollowUpPhoneCount > 0) { - const n = mppFollowUpPhoneCount; - reply += `_⏳ MPP phone diagnostics loading for ${n} desk phone${n === 1 ? '' : 's'} via relay — a follow-up message will arrive shortly._\n\n`; - } } // DECT Basestations @@ -216,29 +193,12 @@ export function renderPhoneStatusMarkdown(data, opts = {}) { }); reply += '\n'; } - - // DECT relay follow-up notice. Only shown when the caller has - // told us a follow-up is actually in-flight (chat handler, after - // discoverDectBases returned a non-empty list). Silent for HTTP / - // Jira surfaces where a follow-up doesn't happen. - if (dectFollowUpBaseCount > 0) { - const n = dectFollowUpBaseCount; - reply += `_⏳ Base-station diagnostics loading for ${n} base${n === 1 ? '' : 's'} — a follow-up message will arrive shortly._\n\n`; - } } if (detailed) { reply += `_Detailed mode — additional fields above (use without ?detailed=true for compact view)_\n`; } - // WAN follow-up hint — same "silent for HTTP / Jira" rule as the - // DECT hint above. Sits at the bottom because WAN metrics are the - // last section chronologically (arrives after the DECT follow-up - // in most stores). - if (wanFollowUpEnabled) { - reply += `\n_⏳ WAN metrics loading from Prisma SD-WAN — a follow-up message will arrive shortly._\n`; - } - if (footer) { reply += `\n*Last checked: ${formatDisplayTime()}*`; } @@ -246,91 +206,3 @@ export function renderPhoneStatusMarkdown(data, opts = {}) { return reply.trim(); } -// ─── DECT base-station diagnostics (follow-up message) ────────────── -// -// Separate exported renderer for the follow-up message that arrives -// ~10-30s after the main /phonestatus output. Input is the array -// returned by services/dectCollectorService.collectAll(): per-base -// { ok, data (parsed status), verdict, error } records. -// -// Chat surface stays compact — most operators only need to see the -// exceptional stuff (warnings, recent power-loss reboots). Firmware -// / emergency numbers / detailed reboot log stay behind the future -// /dectstatus command where the full CLI-style dump makes more sense. - -/** - * @param {Array} results collectAll() output - * @param {object} opts - * @param {string} opts.storeNum - * @param {boolean} [opts.footer=true] - * @returns {string} markdown, whitespace-trimmed. Empty string when - * the input list is empty (caller shouldn't send a message - * in that case). - */ -export function renderDectDiagnosticsMarkdown(results, opts = {}) { - const { storeNum, footer = true } = opts; - const list = Array.isArray(results) ? results : []; - if (list.length === 0) return ''; - - let out = `**DECT Base Station Diagnostics — Store ${storeNum}**\n\n`; - - for (const r of list) { - out += renderOneBase(r); - out += '\n'; - } - - if (footer) { - out += `\n*Base diagnostics pulled at ${formatDisplayTime()} via the DECT relay. Use \`/dectstatus ${storeNum}\` for full details and reboot/factory-reset controls.*`; - } - return out.trim(); -} - -function renderOneBase(r) { - const label = r.base?.name || `Basestation ${r.base?.mac || '?'}`; - const ip = r.base?.ip || '?'; - if (!r.ok) { - return `⚠️ **${label}** (${ip}) — collect failed: ${r.error?.message || 'unknown error'}` + - (r.error?.hint ? `\n _${r.error.hint}_\n` : '\n'); - } - - const data = r.data || {}; - const verdict = r.verdict || {}; - const uptimeText = data.time?.operatingTime || '?'; - const fw = data.firmware?.version || '?'; - const conflict = data.conflictInfo && data.conflictInfo !== 'No Conflict' - ? ` • RF conflict: ${data.conflictInfo}` : ''; - const role = data.multiCell?.role ? ` • role: ${data.multiCell.role}` : ''; - - // Header line uses a checkmark or warning depending on verdict. - const icon = verdict.healthy ? '✅' : '⚠️'; - let out = `${icon} **${label}** (${ip}) — uptime ${uptimeText} • fw ${fw}${role}${conflict}\n`; - - // Most-recent Power Loss reboot (if any in the last-6 log) is the - // highest-signal thing we can surface here. Anything else falls - // under "warnings" below. - const powerLoss = (data.rebootLog || []).find((entry) => entry.reasonCode === 80); - if (powerLoss) { - out += ` ⚡ Recent power loss: ${powerLoss.at} (reboot #${powerLoss.sequence})\n`; - } - - // Warnings from summarizeBaseHealth() are already user-facing - // strings; render as a bulleted list under the header. - if (Array.isArray(verdict.warnings) && verdict.warnings.length > 0) { - for (const w of verdict.warnings) { - // Skip the power-loss warning if we already surfaced the - // structured line above — avoids duplication. - if (powerLoss && /power.?loss/i.test(w)) continue; - out += ` ⚠️ ${w}\n`; - } - } - - // RTP: only show if there's an active session — usually the - // diagnostic reader cares whether a call is up right now, not - // that this base has served 2 total calls since boot. - if ((data.rtp?.current || 0) > 0) { - out += ` 📞 ${data.rtp.current} active RTP session(s)\n`; - } - - return out; -} - diff --git a/services/renderers/wanDiagnosticsRenderer.js b/services/renderers/wanDiagnosticsRenderer.js index 500792b..145e585 100644 --- a/services/renderers/wanDiagnosticsRenderer.js +++ b/services/renderers/wanDiagnosticsRenderer.js @@ -1,6 +1,6 @@ // src/services/renderers/wanDiagnosticsRenderer.js // -// Pure markdown renderer for the /phonestatus WAN follow-up message. +// Pure markdown renderer for the /wanstatus command. // Takes a `collectSdwanForStore(storeNum)` result and produces the // section that shows healthscore + per-path LQM + active alarms. // diff --git a/services/voiceDiag/checks/dectBaseRelay.js b/services/voiceDiag/checks/dectBaseRelay.js new file mode 100644 index 0000000..714ec7c --- /dev/null +++ b/services/voiceDiag/checks/dectBaseRelay.js @@ -0,0 +1,105 @@ +// services/voiceDiag/checks/dectBaseRelay.js +// +// Summarises DECT base relay collect results for /voicediag. + +export const DECT_BASE_RELAY_STANDARDS = Object.freeze({ + healthy: true, +}); + +export const dectBaseRelayCheck = { + id: 'dectBaseRelay', + label: 'DECT Base (relay)', + requires: ['phoneStatus'], + scope: null, + standards: DECT_BASE_RELAY_STANDARDS, + + async run(ctx) { + if (!process.env.DECT_RELAY_AGENT_TOKEN) { + return { + status: 'skipped', + message: 'Relay not configured (`DECT_RELAY_AGENT_TOKEN` unset) — run `/dectdiag` when relay is available.', + details: null, + remediation: null, + }; + } + + const results = ctx.dectRelayResults; + if (!Array.isArray(results)) { + return { + status: 'skipped', + message: 'DECT relay collect was not run (no bases discovered on 10.x).', + details: null, + remediation: null, + }; + } + + if (results.length === 0) { + return { + status: 'skipped', + message: 'No DECT bases discovered on 10.x for relay collect.', + details: null, + remediation: null, + }; + } + + const bases = []; + let errorCount = 0; + + for (const r of results) { + const label = r.base?.name || r.base?.mac || '?'; + const ip = r.base?.ip || '?'; + + if (!r.ok) { + errorCount += 1; + bases.push({ + label, + ip, + ok: false, + error: r.error?.message || 'collect failed', + }); + continue; + } + + const verdict = r.verdict || {}; + const data = r.data || {}; + const warnings = Array.isArray(verdict.warnings) ? verdict.warnings : []; + const powerLoss = (data.rebootLog || []).find((entry) => entry.reasonCode === 80); + const unhealthy = !verdict.healthy; + + if (unhealthy) errorCount += 1; + + bases.push({ + label, + ip, + ok: true, + healthy: verdict.healthy !== false, + uptime: data.time?.operatingTime || null, + warnings, + powerLoss: powerLoss ? { at: powerLoss.at, sequence: powerLoss.sequence } : null, + activeRtp: data.rtp?.current || 0, + }); + } + + const summary = + `${results.length} DECT base(s) collected via relay. ` + + `${errorCount === 0 ? 'All bases healthy.' : `${errorCount} base(s) need attention.`}`; + + const details = { bases, collected: results.length, attentionCount: errorCount }; + + if (errorCount > 0) { + return { + status: 'error', + message: summary + ' Run `/dectdiag` for full dump and reboot controls.', + details, + remediation: null, + }; + } + + return { + status: 'ok', + message: summary, + details, + remediation: null, + }; + }, +}; diff --git a/services/voiceDiag/checks/index.js b/services/voiceDiag/checks/index.js index e6ece00..8b88a22 100644 --- a/services/voiceDiag/checks/index.js +++ b/services/voiceDiag/checks/index.js @@ -55,6 +55,8 @@ import { wanAppRtpLossCheck } from './wan/wanAppRtpLoss.js'; import { wanAppRtpJitterCheck } from './wan/wanAppRtpJitter.js'; import { wanAlarmsCheck } from './wan/wanAlarms.js'; import { wanTunnelsCheck } from './wan/wanTunnels.js'; +import { mppDeskPhoneRelayCheck } from './mppDeskPhoneRelay.js'; +import { dectBaseRelayCheck } from './dectBaseRelay.js'; // Order: user-facing feature signals first (things an operator can // see from the phone UI), then LAN-side port hygiene, then the WAN @@ -92,6 +94,8 @@ export const CHECKS = [ wanAppRtpLossCheck, wanAppRtpJitterCheck, wanAlarmsCheck, + mppDeskPhoneRelayCheck, + dectBaseRelayCheck, phoneOnlineCheck, ]; diff --git a/services/voiceDiag/checks/mppDeskPhoneRelay.js b/services/voiceDiag/checks/mppDeskPhoneRelay.js new file mode 100644 index 0000000..a3cb099 --- /dev/null +++ b/services/voiceDiag/checks/mppDeskPhoneRelay.js @@ -0,0 +1,105 @@ +// services/voiceDiag/checks/mppDeskPhoneRelay.js +// +// Summarises MPP desk-phone relay probe results for /voicediag. + +import { collectIssues } from '../../renderers/mppPhoneFormatters.js'; +import { isLineRegistered } from '../../renderers/mppPhoneFormatters.js'; + +export const MPP_DESK_PHONE_RELAY_STANDARDS = Object.freeze({ + registered: true, + noIssues: true, +}); + +export const mppDeskPhoneRelayCheck = { + id: 'mppDeskPhoneRelay', + label: 'MPP Desk Phone (relay)', + requires: ['phoneStatus'], + scope: null, + standards: MPP_DESK_PHONE_RELAY_STANDARDS, + + async run(ctx) { + if (!process.env.DECT_RELAY_AGENT_TOKEN) { + return { + status: 'skipped', + message: 'Relay not configured (`DECT_RELAY_AGENT_TOKEN` unset) — run `/phonediag` when relay is available.', + details: null, + remediation: null, + }; + } + + const results = ctx.mppRelayResults; + if (!Array.isArray(results)) { + return { + status: 'skipped', + message: 'MPP relay probe was not run (no desk phones discovered on 10.x).', + details: null, + remediation: null, + }; + } + + if (results.length === 0) { + return { + status: 'skipped', + message: 'No MPP desk phones discovered on 10.x for relay probe.', + details: null, + remediation: null, + }; + } + + const phones = []; + let errorCount = 0; + + for (const r of results) { + const label = r.phone?.name || r.phone?.product || r.phone?.mac || '?'; + const ip = r.phone?.ip || '?'; + + if (!r.ok) { + errorCount += 1; + phones.push({ + label, + ip, + ok: false, + error: r.error?.message || 'probe failed', + }); + continue; + } + + const parsed = r.mpp?.parsed || r.parsed || {}; + const line1 = parsed.lines?.find((l) => l.index === 1) || parsed.lines?.[0]; + const issues = collectIssues(r.mpp || {}, parsed); + const registered = line1 ? isLineRegistered(line1) : null; + + if (!registered || issues.length > 0) errorCount += 1; + + phones.push({ + label, + ip, + ok: true, + registered: line1?.registrationState || null, + issues, + }); + } + + const summary = + `${results.length} MPP phone(s) probed via relay. ` + + `${errorCount === 0 ? 'All registered with no issues.' : `${errorCount} phone(s) need attention.`}`; + + const details = { phones, probed: results.length, attentionCount: errorCount }; + + if (errorCount > 0) { + return { + status: 'error', + message: summary + ' Run `/phonediag` for full section layout.', + details, + remediation: null, + }; + } + + return { + status: 'ok', + message: summary, + details, + remediation: null, + }; + }, +}; diff --git a/services/voiceDiag/checks/wan/_helpers.js b/services/voiceDiag/checks/wan/_helpers.js index f659964..187e5a4 100644 --- a/services/voiceDiag/checks/wan/_helpers.js +++ b/services/voiceDiag/checks/wan/_helpers.js @@ -46,7 +46,7 @@ export function maybeSkippedByKillSwitch(check) { // Defaults match the ITU-T G.114 / RFC 3550 references for voice // quality. Any of these can be overridden per-tenant via env; the // renderer + the checks read from the same accessors so the icon -// in the phonestatus follow-up always agrees with the verdict in +// in the /wanstatus renderer always agrees with the verdict in // voicediag. function num(envKey, fallback) { diff --git a/services/voiceDiag/voiceDiagService.js b/services/voiceDiag/voiceDiagService.js index 7546a8f..9e939a8 100644 --- a/services/voiceDiag/voiceDiagService.js +++ b/services/voiceDiag/voiceDiagService.js @@ -120,6 +120,39 @@ export async function buildContext(storeNum, opts = {}) { const phoneStatus = phoneStatusRes.status === 'fulfilled' ? phoneStatusRes.value : null; const sdwanData = sdwanRes.status === 'fulfilled' ? sdwanRes.value : null; + let dectRelayResults = null; + let mppRelayResults = null; + if (phoneStatus && process.env.DECT_RELAY_AGENT_TOKEN) { + try { + const { discoverDectBases } = await import('../dectDiscovery.js'); + const { discoverDeskPhones } = await import('../phoneDiscovery.js'); + const { collectAll } = await import('../dectCollectorService.js'); + const { probeAll } = await import('../phoneCollectorService.js'); + + const { bases } = discoverDectBases(phoneStatus); + const { phones } = discoverDeskPhones(phoneStatus); + + const [dectRes, mppRes] = await Promise.allSettled([ + bases.length > 0 ? collectAll(bases) : Promise.resolve([]), + phones.length > 0 ? probeAll(phones) : Promise.resolve([]), + ]); + + dectRelayResults = dectRes.status === 'fulfilled' ? dectRes.value : []; + mppRelayResults = mppRes.status === 'fulfilled' ? mppRes.value : []; + + if (dectRes.status === 'rejected') { + logger('voicediag', `DECT relay collect soft-failed: ${dectRes.reason?.message}`, 'warn'); + } + if (mppRes.status === 'rejected') { + logger('voicediag', `MPP relay probe soft-failed: ${mppRes.reason?.message}`, 'warn'); + } + } catch (err) { + logger('voicediag', `Relay probe setup failed: ${err.message}`, 'warn'); + dectRelayResults = []; + mppRelayResults = []; + } + } + const personLabel = person?.displayName || email; return { @@ -143,6 +176,8 @@ export async function buildContext(storeNum, opts = {}) { // (or when the Prisma integration hasn't been configured). sdwanData, sdwanSite: sdwanData?.site || null, + dectRelayResults, + mppRelayResults, }; } diff --git a/tests/ciscoMppPhone.downloadStatus.test.js b/tests/ciscoMppPhone.downloadStatus.test.js index 40632a6..b946198 100644 --- a/tests/ciscoMppPhone.downloadStatus.test.js +++ b/tests/ciscoMppPhone.downloadStatus.test.js @@ -20,6 +20,6 @@ test('parseDownloadStatusJson: extracts provisioning and firmware history', () = test('downloadStatusWarnings: flags MIC cert failure', () => { const parsed = parseDownloadStatusJson(FIXTURE); const warnings = downloadStatusWarnings(parsed); - assert.ok(warnings.some((w) => /MIC cert/i.test(w))); + assert.ok(warnings.includes('mic_cert_failed')); assert.equal(parsed.micCert.failed, true); }); diff --git a/tests/jiraPoller.enrichmentRules.test.js b/tests/jiraPoller.enrichmentRules.test.js index 76b8a9a..f76b2fc 100644 --- a/tests/jiraPoller.enrichmentRules.test.js +++ b/tests/jiraPoller.enrichmentRules.test.js @@ -18,7 +18,7 @@ test('extractStoreFromSummary parses Store NNNN', () => { assert.equal(extractStoreFromSummary('no store here'), null); }); -test('comm call quality runs phonestatus and callreport', () => { +test('comm call quality runs voicestatus and callreport', () => { const plan = resolveEnrichmentPlan( { summary: 'Store 2477 - garbled voice on inbound calls', @@ -28,7 +28,7 @@ test('comm call quality runs phonestatus and callreport', () => { { kind: 'phone', storeNum: '2477', reason: 'desk phone issue' }, ); assert.equal(plan.skip, false); - assert.deepEqual(plan.checks, [CHECK_IDS.phonestatus, CHECK_IDS.callreport]); + assert.deepEqual(plan.checks, [CHECK_IDS.voicestatus, CHECK_IDS.callreport]); assert.ok(plan.matchedRules.includes('comm-call-quality')); }); @@ -45,7 +45,7 @@ test('comm spam runs callreport only', () => { assert.ok(plan.matchedRules.includes('comm-spam')); }); -test('comm spam with both symptoms runs phonestatus and callreport', () => { +test('comm spam with both symptoms runs voicestatus and callreport', () => { const plan = resolveEnrichmentPlan( { summary: 'Store 100 - spam and garbled audio', @@ -53,7 +53,7 @@ test('comm spam with both symptoms runs phonestatus and callreport', () => { }, { kind: 'phone', storeNum: '100', reason: 'x' }, ); - assert.deepEqual(plan.checks, [CHECK_IDS.phonestatus, CHECK_IDS.callreport]); + assert.deepEqual(plan.checks, [CHECK_IDS.voicestatus, CHECK_IDS.callreport]); }); test('mobility spam does not trigger comm spam rule', () => { @@ -64,11 +64,11 @@ test('mobility spam does not trigger comm spam rule', () => { }, { kind: 'phone', storeNum: '500', reason: 'phone' }, ); - assert.deepEqual(plan.checks, [CHECK_IDS.phonestatus]); + assert.deepEqual(plan.checks, [CHECK_IDS.voicestatus]); assert.deepEqual(plan.matchedRules, ['default-phone']); }); -test('default phone kind runs phonestatus only', () => { +test('default phone kind runs voicestatus only', () => { const plan = resolveEnrichmentPlan( { summary: 'Store 300 - phone not registering', @@ -76,7 +76,7 @@ test('default phone kind runs phonestatus only', () => { }, { kind: 'phone', storeNum: '300', reason: 'registration' }, ); - assert.deepEqual(plan.checks, [CHECK_IDS.phonestatus]); + assert.deepEqual(plan.checks, [CHECK_IDS.voicestatus]); assert.deepEqual(plan.matchedRules, ['default-phone']); }); diff --git a/tests/jiraPoller.runEnrichment.test.js b/tests/jiraPoller.runEnrichment.test.js index b7f3c33..45cb04b 100644 --- a/tests/jiraPoller.runEnrichment.test.js +++ b/tests/jiraPoller.runEnrichment.test.js @@ -16,7 +16,7 @@ test('formatEnrichmentBody merges sections with headings', () => { }); test('CHECK_IDS covers expected checks', () => { - assert.equal(CHECK_IDS.phonestatus, 'phonestatus'); + assert.equal(CHECK_IDS.voicestatus, 'voicestatus'); assert.equal(CHECK_IDS.callreport, 'callreport'); assert.equal(CHECK_IDS.avstatus, 'avstatus'); }); diff --git a/tests/mppPhoneDiagnosticsRenderer.test.js b/tests/mppPhoneDiagnosticsRenderer.test.js index bab7efa..d1cc1ad 100644 --- a/tests/mppPhoneDiagnosticsRenderer.test.js +++ b/tests/mppPhoneDiagnosticsRenderer.test.js @@ -5,6 +5,14 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { buildMppProbeView } from '../integrations/cisco-mpp-phone/aggregateProbe.js'; +import { formatMicCertForDisplay } from '../integrations/cisco-mpp-phone/downloadStatusJson.js'; +import { parseDownloadStatusJson, downloadStatusWarnings } from '../integrations/cisco-mpp-phone/downloadStatusJson.js'; +import { + formatUptime, + formatHeaderStatusLine, + headerIconForLine, + isLineRegistered, +} from '../services/renderers/mppPhoneFormatters.js'; import { renderMppPhoneDiagnosticsMarkdown } from '../services/renderers/mppPhoneDiagnosticsRenderer.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -34,22 +42,63 @@ function okResult() { }; } -test('renderMppPhoneDiagnosticsMarkdown: tier 1+2 default output', () => { +test('formatMicCertForDisplay: short message without URL', () => { + const parsed = parseDownloadStatusJson(DOWNLOAD); + const line = formatMicCertForDisplay(parsed.micCert); + assert.match(line, /MIC cert download failed — .*File not found/i); + assert.doesNotMatch(line, /sudirenewal\.cisco\.com/); + assert.match(line, /07\/28\/2026/); +}); + +test('formatUptime: compresses day-hour strings', () => { + assert.equal(formatUptime('22 days and 07:20:35'), '22d 7h'); + assert.equal(formatUptime('2 days and 12:01:51'), '2d 12h'); +}); + +test('headerIconForLine: registered vs not', () => { + assert.equal(headerIconForLine({ registrationState: 'Registered' }), '✅'); + assert.equal(headerIconForLine({ registrationState: 'Not Registered' }), '❌'); + assert.equal(isLineRegistered({ registrationState: 'Registered' }), true); +}); + +test('renderMppPhoneDiagnosticsMarkdown: section layout default tier', () => { const md = renderMppPhoneDiagnosticsMarkdown([okResult()], { storeNum: '782', footer: false }); assert.match(md, /\*\*MPP Desk Phone Diagnostics — Store 782\*\*/); + assert.match(md, /\*\*Registration\*\*/); + assert.match(md, /\*\*Switch\*\*/); + assert.match(md, /\*\*Provisioning\*\*/); + assert.match(md, /\*\*Issues\*\*/); + assert.doesNotMatch(md, /\*\*Network\*\*/); + assert.match(md, /_Uptime 22d 7h · 100M Full_/); + assert.match(md, /✅ \*\*Store 00782 CP-7841\*\*/); assert.match(md, /Registered/); assert.match(md, /SW00782R/); assert.match(md, /Port 45/); - assert.match(md, /cisco\.sipflash\.com/); - assert.match(md, /MIC cert/i); - assert.doesNotMatch(md, /Probe paths:/); + assert.match(md, /Resync: cisco\.sipflash\.com · OK/); + assert.match(md, /MIC cert download failed — .*File not found/i); + assert.doesNotMatch(md, /sudirenewal\.cisco\.com/); + assert.doesNotMatch(md, /802\.1X/i); + assert.doesNotMatch(md, /Web server/i); + assert.doesNotMatch(md, /VLAN/i); + assert.doesNotMatch(md, /proxy/i); + assert.doesNotMatch(md, /Probe paths/); + assert.doesNotMatch(md, /SIP counters/); }); -test('renderMppPhoneDiagnosticsMarkdown: verbose includes probe table', () => { +test('renderMppPhoneDiagnosticsMarkdown: verbose includes probe table and SIP', () => { const md = renderMppPhoneDiagnosticsMarkdown([okResult()], { storeNum: '782', verbose: true, footer: false }); - assert.match(md, /Probe paths:/); + assert.match(md, /\*\*Probe paths\*\*/); assert.match(md, /\/Status\.json/); + assert.match(md, /\*\*Extensions\*\*/); assert.match(md, /Ext 2:/); + assert.match(md, /SIP counters/); +}); + +test('renderMppPhoneDiagnosticsMarkdown: unregistered shows X header', () => { + const r = okResult(); + r.mpp.parsed.lines[0].registrationState = 'Not Registered'; + const md = renderMppPhoneDiagnosticsMarkdown([r], { storeNum: '782', footer: false }); + assert.match(md, /❌ \*\*Store 00782 CP-7841\*\*/); }); test('renderMppPhoneDiagnosticsMarkdown: relay failure line', () => { @@ -69,5 +118,13 @@ test('buildMppProbeView: merges MIC warning into verdict', () => { nsJson: NS, }); assert.equal(mpp.verdict.healthy, false); - assert.ok(mpp.verdict.warnings.some((w) => /MIC cert/i.test(w))); + assert.ok(mpp.verdict.warnings.includes('mic_cert_failed')); +}); + +test('formatHeaderStatusLine: uptime and link from phone status', () => { + const line = formatHeaderStatusLine( + { elapsedTime: '22 days and 07:20:35', linkSpeed: '100M Full' }, + {}, + ); + assert.equal(line, 'Uptime 22d 7h · 100M Full'); }); diff --git a/tests/renderers.test.js b/tests/renderers.test.js index 01d9bc2..e643480 100644 --- a/tests/renderers.test.js +++ b/tests/renderers.test.js @@ -12,7 +12,6 @@ import assert from 'node:assert/strict'; import { renderPhoneStatusMarkdown, - renderDectDiagnosticsMarkdown, } from '../services/renderers/phoneStatusRenderer.js'; import { renderAvStatusMarkdown } from '../services/renderers/avStatusRenderer.js'; import { renderDectStatusMarkdown } from '../services/renderers/dectStatusRenderer.js'; @@ -24,7 +23,7 @@ const threeHrAgo = () => new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString() test('phone renderer: header and empty-store message', () => { const md = renderPhoneStatusMarkdown({}, { storeNum: '782' }); - assert.match(md, /^\*\*Phone Status - Store 782\*\*/); + assert.match(md, /^\*\*Voice Status - Store 782\*\*/); assert.match(md, /No phones or DECT basestations found/); }); @@ -255,173 +254,7 @@ test('av renderer: Atlas AMP with vitals renders temps + fan + amps', () => { }); // ───────────────────────────────────────────────────────────── -// DECT follow-up "loading" hint (main /phonestatus output) -// ───────────────────────────────────────────────────────────── - -test('phone renderer: dectFollowUpBaseCount > 0 emits a loading hint inside the DECT section', () => { - const data = { - dectBasestations: [ - { mac: 'aa:bb:cc:dd:ee:01', meraki: { status: 'Online' } }, - { mac: 'aa:bb:cc:dd:ee:02', meraki: { status: 'Online' } }, - ], - dectHandsets: [], - }; - const md = renderPhoneStatusMarkdown(data, { - storeNum: '782', footer: false, dectFollowUpBaseCount: 2, - }); - assert.match(md, /Base-station diagnostics loading for 2 bases/); -}); - -test('phone renderer: dectFollowUpBaseCount === 1 uses singular "base"', () => { - const data = { - dectBasestations: [{ mac: 'aa:bb:cc:dd:ee:01', meraki: { status: 'Online' } }], - dectHandsets: [], - }; - const md = renderPhoneStatusMarkdown(data, { - storeNum: '782', footer: false, dectFollowUpBaseCount: 1, - }); - assert.match(md, /loading for 1 base —/); -}); - -test('phone renderer: dectFollowUpBaseCount === 0 emits no loading hint (default state)', () => { - const data = { - dectBasestations: [{ mac: 'aa:bb:cc:dd:ee:01', meraki: { status: 'Online' } }], - dectHandsets: [], - }; - const md = renderPhoneStatusMarkdown(data, { storeNum: '782', footer: false }); - assert.doesNotMatch(md, /diagnostics loading/); -}); - -test('phone renderer: mppFollowUpPhoneCount > 0 emits MPP loading hint', () => { - const data = { - phones: { data: [{ name: 'Store CP-7841', status: 'connected', lastSeen: new Date().toISOString() }] }, - dectBasestations: [], - dectHandsets: [], - }; - const md = renderPhoneStatusMarkdown(data, { - storeNum: '782', footer: false, mppFollowUpPhoneCount: 1, - }); - assert.match(md, /MPP phone diagnostics loading for 1 desk phone/); -}); - -// ───────────────────────────────────────────────────────────── -// DECT follow-up message (renderDectDiagnosticsMarkdown) -// ───────────────────────────────────────────────────────────── - -const okResult = (overrides = {}) => ({ - base: { mac: '6c:ab:05:f6:28:19', ip: '10.4.11.87', name: 'Basestation A' }, - ok: true, - data: { - time: { operatingTime: '02:15:00 (H:M:S)' }, - firmware: { version: '05-01-03-0101-09' }, - multiCell: { role: 'primary' }, - conflictInfo: 'No Conflict', - rebootLog: [], - rtp: { current: 0 }, - }, - verdict: { healthy: true, warnings: [], info: [] }, - elapsedMs: 812, - ...overrides, -}); - -test('dect diagnostics renderer: empty input returns empty string (caller should not send)', () => { - assert.equal(renderDectDiagnosticsMarkdown([], { storeNum: '782' }), ''); - assert.equal(renderDectDiagnosticsMarkdown(null, { storeNum: '782' }), ''); -}); - -test('dect diagnostics renderer: healthy base renders check + uptime + firmware', () => { - const md = renderDectDiagnosticsMarkdown([okResult()], { storeNum: '782', footer: false }); - assert.match(md, /\*\*DECT Base Station Diagnostics — Store 782\*\*/); - assert.match(md, /✅ \*\*Basestation A\*\* \(10\.4\.11\.87\)/); - assert.match(md, /uptime 02:15:00/); - assert.match(md, /fw 05-01-03-0101-09/); - assert.match(md, /role: primary/); -}); - -test('dect diagnostics renderer: warnings from verdict are surfaced under the header', () => { - const md = renderDectDiagnosticsMarkdown([ - okResult({ - verdict: { - healthy: false, - warnings: ['Rx errors: 42 since last boot'], - info: [], - }, - }), - ], { storeNum: '782', footer: false }); - assert.match(md, /⚠️ \*\*Basestation A\*\*/); - assert.match(md, /⚠️ Rx errors: 42 since last boot/); -}); - -test('dect diagnostics renderer: recent Power Loss reboot gets its own bolt line + suppresses duplicate warning', () => { - const md = renderDectDiagnosticsMarkdown([ - okResult({ - data: { - time: { operatingTime: '02:15:00' }, - firmware: { version: '05-01-03-0101-09' }, - multiCell: { role: 'primary' }, - conflictInfo: 'No Conflict', - rebootLog: [ - { sequence: 164, at: '2026-07-02T12:54:12', reasonName: 'Power Loss', reasonCode: 80 }, - ], - rtp: { current: 0 }, - }, - verdict: { - healthy: false, - warnings: ['1 recent power-loss reboot(s); most recent at 2026-07-02T12:54:12'], - info: [], - }, - }), - ], { storeNum: '782', footer: false }); - - // The structured line survives … - assert.match(md, /⚡ Recent power loss: 2026-07-02T12:54:12 \(reboot #164\)/); - // … but the summary warning about power-loss is filtered out to - // avoid duplication under the same header. - assert.doesNotMatch(md, /⚠️ 1 recent power-loss/); -}); - -test('dect diagnostics renderer: active RTP session gets a call icon', () => { - const md = renderDectDiagnosticsMarkdown([ - okResult({ - data: { - time: { operatingTime: '02:15:00' }, - firmware: { version: '05-01-03-0101-09' }, - multiCell: { role: 'primary' }, - conflictInfo: 'No Conflict', - rebootLog: [], - rtp: { current: 2 }, - }, - }), - ], { storeNum: '782', footer: false }); - assert.match(md, /📞 2 active RTP session/); -}); - -test('dect diagnostics renderer: base with error renders remediation hint', () => { - const md = renderDectDiagnosticsMarkdown([ - { - base: { mac: '6c:ab:05:f6:28:19', ip: '10.4.11.87', name: 'Basestation A' }, - ok: false, - data: null, - verdict: null, - elapsedMs: 15003, - error: { - code: 'RELAY_RPC_TIMEOUT', - message: 'timed out after 15000ms', - hint: 'Relay accepted the request but the base did not respond in time.', - }, - }, - ], { storeNum: '782', footer: false }); - assert.match(md, /⚠️ \*\*Basestation A\*\* \(10\.4\.11\.87\) — collect failed: timed out after 15000ms/); - assert.match(md, /Relay accepted the request but the base did not respond in time\./); -}); - -test('dect diagnostics renderer: footer references /dectstatus command by store', () => { - const md = renderDectDiagnosticsMarkdown([okResult()], { storeNum: '782' }); - assert.match(md, /Use `\/dectstatus 782`/); -}); - -// ───────────────────────────────────────────────────────────── -// Full /dectstatus dump (renderDectStatusMarkdown) +// Full /dectdiag dump (renderDectStatusMarkdown) // ───────────────────────────────────────────────────────────── const fullOkResult = () => ({ diff --git a/tests/voiceDiag.relay.test.js b/tests/voiceDiag.relay.test.js new file mode 100644 index 0000000..3f5aeeb --- /dev/null +++ b/tests/voiceDiag.relay.test.js @@ -0,0 +1,68 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { mppDeskPhoneRelayCheck } from '../services/voiceDiag/checks/mppDeskPhoneRelay.js'; +import { dectBaseRelayCheck } from '../services/voiceDiag/checks/dectBaseRelay.js'; + +const origToken = process.env.DECT_RELAY_AGENT_TOKEN; + +test('mppDeskPhoneRelayCheck: skipped when relay token unset', async () => { + delete process.env.DECT_RELAY_AGENT_TOKEN; + const result = await mppDeskPhoneRelayCheck.run({ phoneStatus: {} }); + assert.equal(result.status, 'skipped'); + assert.match(result.message, /Relay not configured/i); +}); + +test('mppDeskPhoneRelayCheck: error when phone has registration issues', async () => { + process.env.DECT_RELAY_AGENT_TOKEN = 'test-token'; + const result = await mppDeskPhoneRelayCheck.run({ + phoneStatus: {}, + mppRelayResults: [{ + ok: true, + phone: { name: 'Store CP-7841', ip: '10.1.1.1' }, + mpp: { + parsed: { + lines: [{ index: 1, registrationState: 'Not Registered' }], + }, + }, + }], + }); + assert.equal(result.status, 'error'); + assert.match(result.message, /need attention/i); + if (origToken === undefined) delete process.env.DECT_RELAY_AGENT_TOKEN; + else process.env.DECT_RELAY_AGENT_TOKEN = origToken; +}); + +test('dectBaseRelayCheck: ok when all bases healthy', async () => { + process.env.DECT_RELAY_AGENT_TOKEN = 'test-token'; + const result = await dectBaseRelayCheck.run({ + phoneStatus: {}, + dectRelayResults: [{ + ok: true, + base: { name: 'Base A', ip: '10.2.2.2' }, + data: { time: { operatingTime: '1d' }, rebootLog: [], rtp: { current: 0 } }, + verdict: { healthy: true, warnings: [] }, + }], + }); + assert.equal(result.status, 'ok'); + assert.match(result.message, /All bases healthy/i); + if (origToken === undefined) delete process.env.DECT_RELAY_AGENT_TOKEN; + else process.env.DECT_RELAY_AGENT_TOKEN = origToken; +}); + +test('dectBaseRelayCheck: error when base unhealthy', async () => { + process.env.DECT_RELAY_AGENT_TOKEN = 'test-token'; + const result = await dectBaseRelayCheck.run({ + phoneStatus: {}, + dectRelayResults: [{ + ok: true, + base: { name: 'Base A', ip: '10.2.2.2' }, + data: { time: { operatingTime: '1d' }, rebootLog: [], rtp: { current: 0 } }, + verdict: { healthy: false, warnings: ['Rx errors'] }, + }], + }); + assert.equal(result.status, 'error'); + assert.match(result.message, /need attention/i); + if (origToken === undefined) delete process.env.DECT_RELAY_AGENT_TOKEN; + else process.env.DECT_RELAY_AGENT_TOKEN = origToken; +}); diff --git a/tests/wanStatus.test.js b/tests/wanStatus.test.js new file mode 100644 index 0000000..aeb3e9a --- /dev/null +++ b/tests/wanStatus.test.js @@ -0,0 +1,11 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { parseWindowMinutes } from '../utils/windowArgs.js'; + +test('parseWindowMinutes: shared util resolves durations', () => { + assert.equal(parseWindowMinutes('24h'), 1440); + assert.equal(parseWindowMinutes('7d'), 10080); + assert.equal(parseWindowMinutes(null), undefined); + assert.equal(parseWindowMinutes('bogus'), null); +}); diff --git a/utils/markdownToAdf.js b/utils/markdownToAdf.js index 004d0f7..cc11232 100644 --- a/utils/markdownToAdf.js +++ b/utils/markdownToAdf.js @@ -3,7 +3,7 @@ // Narrow, hand-rolled markdown → Atlassian Document Format converter. // // Grammar supported (deliberately minimal, matches what our chat -// renderers actually emit — see commands/phoneStatus.js and +// renderers actually emit — see commands/voiceStatus.js and // commands/avStatus.js): // // **text** → { type:'text', text, marks:[{type:'strong'}] } diff --git a/utils/windowArgs.js b/utils/windowArgs.js new file mode 100644 index 0000000..099cb7a --- /dev/null +++ b/utils/windowArgs.js @@ -0,0 +1,37 @@ +// utils/windowArgs.js +// +// Shared `--window` parsing for /wanstatus and /voicediag. + +export function pickWindowArg(args) { + return pickFlagValue(args, '--window'); +} + +function pickFlagValue(args, flag) { + const prefix = `${flag.toLowerCase()}=`; + for (let i = 0; i < args.length; i += 1) { + const a = String(args[i] || '').toLowerCase(); + if (a.startsWith(prefix)) return String(args[i]).slice(prefix.length); + if (a === flag.toLowerCase() && i + 1 < args.length) return args[i + 1]; + } + return null; +} + +/** + * Parse a window shorthand (`15m`, `1h`, `24h`, `1d`, `1440`) into + * minutes. Returns null on unrecognised input so the caller can + * decide whether to surface a friendly error or fall back to the + * env default. + */ +export function parseWindowMinutes(raw) { + if (raw === null || raw === undefined || raw === '') return undefined; + const s = String(raw).trim().toLowerCase(); + if (/^\d+$/.test(s)) return Math.max(1, parseInt(s, 10)); + const m = s.match(/^(\d+)\s*(m|min|mins|h|hr|hrs|hour|hours|d|day|days)$/); + if (!m) return null; + const n = parseInt(m[1], 10); + const unit = m[2]; + if (['m', 'min', 'mins'].includes(unit)) return n; + if (['h', 'hr', 'hrs', 'hour', 'hours'].includes(unit)) return n * 60; + if (['d', 'day', 'days'].includes(unit)) return n * 60 * 24; + return null; +}