diff --git a/commands/dectStatus.js b/commands/dectStatus.js new file mode 100644 index 0000000..6cf7715 --- /dev/null +++ b/commands/dectStatus.js @@ -0,0 +1,418 @@ +// src/commands/dectStatus.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, security, +// emergency numbers) 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. + +import { randomUUID } from 'node:crypto'; +import { collectPhoneStatus } from '../services/phoneService.js'; +import { discoverDectBases } from '../services/dectDiscovery.js'; +import { collectAll, execAction } from '../services/dectCollectorService.js'; +import { getDectRelayHub } from '../services/dectRelayHub.js'; +import { renderDectStatusMarkdown } from '../services/renderers/dectStatusRenderer.js'; +import { pendingDectActions } from '../utils/pendingDectActions.js'; +import { extractRequester, describeRequester } from '../utils/requester.js'; +import { logger } from '../utils/logger.js'; + +/** Mutating actions the chat cards expose. Must match agent capabilities. */ +export const DECT_STATUS_ACTIONS = Object.freeze({ + reboot: { + id: 'reboot', + label: 'Reboot', + emoji: 'πŸ”„', + severity: 'warn', + blurb: 'Graceful reboot β€” waits for active calls to end when possible.', + }, + 'force-reboot': { + id: 'force-reboot', + label: 'Force Reboot', + emoji: '⚑', + severity: 'warn', + blurb: 'Forced reboot β€” kills active calls within ~1 minute.', + }, + 'factory-reset': { + id: 'factory-reset', + label: 'Factory Reset', + emoji: 'πŸ’£', + severity: 'danger', + blurb: + 'Wipes the base to factory defaults. It must be re-onboarded from Control Hub afterward.', + }, +}); + +const REQUEST_ACTION = 'dect_status_request'; +const CONFIRM_ACTION = 'dect_status_confirm'; +const CANCEL_ACTION = 'dect_status_cancel'; + +/** Action ids handled by index.js attachmentAction dispatch. */ +export const DECT_STATUS_CARD_ACTIONS = new Set([ + REQUEST_ACTION, + CONFIRM_ACTION, + CANCEL_ACTION, +]); + +export async function handleDectStatus(bot, trigger) { + const query = trigger.query || {}; + const args = trigger.args || []; + const storeNum = (args[0]?.trim() || query.storeNum || query.store || query.s || '').trim(); + + if (!storeNum || !/^\d{2,4}$/.test(storeNum)) { + await bot.say( + 'markdown', + 'Please provide a 2–4 digit store number.\n' + + 'Example: `/dectstatus 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}` + + (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'); + } + + 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`.', + ); + return; + } + + let phoneData; + try { + phoneData = await collectPhoneStatus(storeNum); + } catch (err) { + logger('dect:status', `collectPhoneStatus failed for store ${storeNum}: ${err.message}`, 'error'); + await bot.say('markdown', `❌ Failed to look up store ${storeNum}: ${err.message}`); + return; + } + + const { bases, warnings: discoveryWarnings } = discoverDectBases(phoneData || {}); + let targets = bases; + if (filterRaw) { + targets = filterBases(bases, filterRaw); + if (targets.length === 0) { + await bot.say( + 'markdown', + `No discovered base matched \`${filterRaw}\` for store ${storeNum}.\n` + + (bases.length + ? `Known bases: ${bases.map((b) => `${b.name} (${b.ip})`).join(', ')}` + : '_No bases discovered at all._'), + ); + return; + } + } + + await bot.say( + 'markdown', + targets.length + ? `⏳ Collecting status from **${targets.length}** DECT base(s) at store ${storeNum}…` + : `Looking up DECT bases for store ${storeNum}…`, + ); + + const results = await collectAll(targets); + const md = renderDectStatusMarkdown(results, { + storeNum, + relay: relayStatus, + discoveryWarnings, + }); + 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) { + try { + const card = buildBaseActionCard({ storeNum, base: r.base }); + await bot.say({ + markdown: `Actions for **${r.base.name || r.base.ip}**:`, + attachments: [{ + contentType: 'application/vnd.microsoft.card.adaptive', + content: card, + }], + }); + } catch (err) { + logger( + 'dect:status', + `Failed to post action card for ${r.base?.ip}: ${err.message}`, + 'warn', + ); + } + } + } +} + +/** + * Adaptive-card submit handler. Dispatched from index.js for + * dect_status_* action ids. Handles request β†’ confirm β†’ execute. + */ +export async function handleDectStatusAction(bot, trigger) { + const action = trigger.attachmentAction; + const inputs = action?.inputs || {}; + const actionType = inputs.action; + const roomId = trigger.roomId || action?.roomId; + const requester = extractRequester(trigger); + + if (actionType === REQUEST_ACTION) { + const dectAction = inputs.dectAction; + const meta = DECT_STATUS_ACTIONS[dectAction]; + if (!meta) { + logger('dect:status:action', `Unknown dectAction "${dectAction}" β€” ignoring`, 'warn'); + return; + } + const storeNum = String(inputs.storeNum || '').trim(); + const baseIp = String(inputs.baseIp || '').trim(); + 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'); + return; + } + + const cardId = randomUUID(); + pendingDectActions.set(cardId, { + storeNum, + baseIp, + baseMac, + baseName, + dectAction, + requester, + }); + + const confirmCard = buildConfirmCard({ + cardId, + storeNum, + baseName, + baseIp, + meta, + }); + + await bot.say({ + markdown: + `${meta.emoji} Confirm **${meta.label}** on **${baseName}** (${baseIp}) at store ${storeNum}?`, + attachments: [{ + contentType: 'application/vnd.microsoft.card.adaptive', + content: confirmCard, + }], + }); + logger( + 'dect:audit', + `REQUEST ${dectAction} store=${storeNum} base=${baseIp} by ${describeRequester(requester)} card=${cardId}`, + ); + return; + } + + if (actionType === CONFIRM_ACTION || actionType === CANCEL_ACTION) { + const { cardId } = inputs; + if (!cardId) { + logger('dect:status: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.'); + return; + } + + const data = pendingDectActions.get(cardId); + pendingDectActions.delete(cardId); // one-shot + + if (actionType === CANCEL_ACTION) { + await bot.say( + 'markdown', + `❌ **${DECT_STATUS_ACTIONS[data.dectAction]?.label || data.dectAction}** cancelled ` + + `for **${data.baseName}** (${data.baseIp}). No changes made.`, + ); + logger( + 'dect:audit', + `CANCELLED ${data.dectAction} store=${data.storeNum} base=${data.baseIp} by ${describeRequester(requester)}`, + ); + return; + } + + // CONFIRM + const meta = DECT_STATUS_ACTIONS[data.dectAction] || { + id: data.dectAction, + label: data.dectAction, + emoji: 'πŸ”§', + }; + logger( + 'dect:audit', + `CONFIRMED ${data.dectAction} store=${data.storeNum} base=${data.baseIp} by ${describeRequester(requester)}`, + ); + await bot.say( + 'markdown', + `${meta.emoji} Running **${meta.label}** on **${data.baseName}** (${data.baseIp})…`, + ); + + const result = await execAction( + { ip: data.baseIp, mac: data.baseMac, name: data.baseName }, + data.dectAction, + ); + + if (result.ok) { + await bot.say( + '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._`, + ); + logger( + 'dect:audit', + `COMPLETED ${data.dectAction} store=${data.storeNum} base=${data.baseIp} elapsed=${result.elapsedMs}ms`, + ); + } else { + await bot.say( + 'markdown', + `❌ **${meta.label}** failed on **${data.baseName}** (${data.baseIp}): ` + + `${result.error?.message || 'unknown error'}` + + (result.error?.hint ? `\n_${result.error.hint}_` : ''), + ); + logger( + 'dect:audit', + `FAILED ${data.dectAction} store=${data.storeNum} base=${data.baseIp}: ${result.error?.message}`, + 'error', + ); + } + return; + } + + logger('dect:status: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', + title: `${meta.emoji} ${meta.label}`, + data: { + action: REQUEST_ACTION, + dectAction: meta.id, + storeNum: String(storeNum), + baseIp: base.ip, + baseMac: base.mac || '', + baseName: base.name || base.ip, + }, + })); + + return { + type: 'AdaptiveCard', + $schema: 'http://adaptivecards.io/schemas/adaptive-card.json', + version: '1.3', + body: [ + { + type: 'TextBlock', + size: 'Medium', + weight: 'Bolder', + text: `${base.name || 'Basestation'} (${base.ip})`, + wrap: true, + }, + { + type: 'TextBlock', + text: `MAC \`${base.mac || '?'}\` Β· store ${storeNum}. Pick an action β€” you'll confirm before anything runs.`, + wrap: true, + spacing: 'Small', + isSubtle: true, + }, + ], + actions, + }; +} + +function buildConfirmCard({ cardId, storeNum, baseName, baseIp, meta }) { + const dangerNote = meta.severity === 'danger' + ? ' **This is destructive and cannot be undone.**' + : ''; + return { + type: 'AdaptiveCard', + $schema: 'http://adaptivecards.io/schemas/adaptive-card.json', + version: '1.3', + body: [ + { + type: 'TextBlock', + size: 'Medium', + weight: 'Bolder', + text: `${meta.emoji} Confirm ${meta.label}?`, + wrap: true, + }, + { + type: 'TextBlock', + text: `**${baseName}** (${baseIp}) at store ${storeNum}.${dangerNote}`, + wrap: true, + spacing: 'Small', + }, + { + type: 'TextBlock', + text: meta.blurb, + wrap: true, + spacing: 'Small', + isSubtle: true, + }, + ], + actions: [ + { + type: 'Action.Submit', + title: `βœ… ${meta.label}`, + style: meta.severity === 'danger' ? 'destructive' : 'positive', + data: { action: CONFIRM_ACTION, cardId }, + }, + { + type: 'Action.Submit', + title: '❌ Cancel', + data: { action: CANCEL_ACTION, cardId }, + }, + ], + }; +} + +// ─── Helpers ──────────────────────────────────────────────────────── + +function filterBases(bases, raw) { + const needle = String(raw).trim().toLowerCase(); + const needleMac = needle.replace(/[^0-9a-f]/g, ''); + return bases.filter((b) => { + if (b.ip && b.ip.toLowerCase() === needle) return true; + if (b.mac) { + const macHex = b.mac.replace(/[^0-9a-fA-F]/g, '').toLowerCase(); + if (macHex === needleMac && needleMac.length === 12) return true; + if (b.mac.toLowerCase() === needle) return true; + } + if (b.name && b.name.toLowerCase().includes(needle)) return true; + return false; + }); +} diff --git a/commands/help.js b/commands/help.js index 6d1cddf..00ac8cf 100644 --- a/commands/help.js +++ b/commands/help.js @@ -19,6 +19,7 @@ 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 basestation dump via relay (reboot / factory-reset cards)', voicediag: 'Deep voice diagnostic: Webex Calling features + SD-WAN quality with fix cards', // Jira @@ -59,10 +60,32 @@ const LONG_HELP = { 'Detailed mode adds firmware, serial, SIP details and errors.', '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 `.', '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', + usage: [ + '/dectstatus ', + '/dectstatus ', + ], + examples: [ + '/dectstatus 782', + '/dectstatus 782 10.12.34.56', + '/dectstatus 782 6c:ab:05:12:34:56', + ], + notes: [ + 'Aliases: `/dect`.', + 'Pulls the full `status.xml` dump from every reachable DBS-210 base at the store via the on-prem DECT relay agent (device, firmware, reboot log, network stats, RTP, security, emergency numbers, health verdict).', + '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.', + '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: { title: '/voicediag', usage: [ @@ -230,7 +253,7 @@ const LONG_HELP = { const GROUPS = [ { title: 'Work orders', keys: ['wohistory', 'wosummary', 'woattachments'] }, - { title: 'AV & phones', keys: ['avstatus', 'phonestatus', 'voicediag'] }, + { title: 'AV & phones', keys: ['avstatus', 'phonestatus', 'dectstatus', 'voicediag'] }, { 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/registry.js b/commands/registry.js index 2ca2e08..25f9ff6 100644 --- a/commands/registry.js +++ b/commands/registry.js @@ -14,6 +14,7 @@ import { handleHelp } from './help.js'; import { handleAvStatus } from './avStatus.js'; import { handlePhoneStatus } from './phoneStatus.js'; +import { handleDectStatus } from './dectStatus.js'; import { handleProvisionDect } from './provisionDect.js'; import { handleWoHistory } from './woHistory.js'; import { handleWoSummary } from './woSummary.js'; @@ -44,6 +45,10 @@ export const commands = [ { 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 + // 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 }, // /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/index.js b/index.js index 1e17b71..0494c2a 100644 --- a/index.js +++ b/index.js @@ -20,6 +20,10 @@ import { requireApiToken, requireAuthForAllCommands } from './utils/httpAuth.js' // command fallback. import { handleUnknown } from './commands/unknownCommand.js'; import { handleDectProvisionAction } from './commands/provisionDect.js'; +import { + handleDectStatusAction, + DECT_STATUS_CARD_ACTIONS, +} from './commands/dectStatus.js'; import { applyOffboardConfirmation, cancelOffboardCard, @@ -364,6 +368,23 @@ framework.on('attachmentAction', async (bot, trigger) => { return; } + // ── DECT status (reboot / factory-reset confirm cards) ── + if (DECT_STATUS_CARD_ACTIONS.has(actionType)) { + try { + // Censor the card that was clicked so double-clicks can't re-fire. + // Request cards don't use the pending map until confirm is posted, + // so we always censor here for request + confirm/cancel alike. + await censorActionCard(bot, trigger, 'dect:status:action'); + await handleDectStatusAction(bot, trigger); + } catch (err) { + logger('dect:status', `Dect status action error: ${err.message}`, 'error'); + try { + await bot.say('markdown', `⚠️ Error handling DECT action: ${err.message}`); + } catch { /* ignore secondary say failure */ } + } + return; + } + // ── Offboard confirm / cancel ── if (OFFBOARD_ACTIONS.has(actionType)) { const { cardId } = action.inputs; @@ -568,8 +589,102 @@ framework.on('attachmentAction', async (bot, trigger) => { framework.on("initialized", () => { logger('framework', 'Webex Framework is all fired up! [Press CTRL-C to quit]'); + // Patch @webex/plugin-messages so a 404 on Hydra messages.get during + // mercury event enrichment cannot take down the process. See + // hardenWebexMessagesPlugin() below. + try { + hardenWebexMessagesPlugin(framework.webex); + } catch (err) { + logger('webex:sdk', `Failed to harden messages plugin: ${err.message}`, 'warn'); + } }); +// ────────────────────────────────────────────── +// Webex SDK hardening +// ────────────────────────────────────────────── +// +// @webex/plugin-messages does this for every mercury activity: +// +// this.getMessageEvent(activity, type).then(this.fire(type)); +// +// with NO .catch(). getMessageEvent() calls Hydra messages.get(). When that +// returns 404 (message already deleted, room the bot left, or an +// eventual-consistency race after we post a summary via BotClient REST β€” +// exactly the path the hourly Jira poller takes), the rejection becomes an +// unhandledRejection and our process-level handler would shut the bot down. +// +// Reproduce from logs (2026-07-10 11:00): +// [webex:bot] Sent markdown message to room aa9c1e50... +// [uncaught] Unhandled promise rejection: Unable to get message. (NotFound) +// [shutdown] Shutting down β€” unhandledRejection +// +// We re-bind onWebexApiEvent with an equivalent verbβ†’type map and a .catch() +// so enrichment failures are logged and dropped. The unhandledRejection +// handler below also treats these as non-fatal as a belt-and-suspenders +// guard for other plugins with the same pattern. + +/** + * @param {unknown} reason + * @returns {boolean} + */ +function isBenignWebexSdkRejection(reason) { + if (!reason) return false; + const name = reason.name || ''; + const message = (reason.message != null ? String(reason.message) : String(reason)); + const status = reason.statusCode ?? reason.body?.statusCode ?? reason.status; + if (/unable to get message/i.test(message)) return true; + if ((name === 'NotFound' || status === 404) && /not found|unable to get/i.test(message)) { + return true; + } + return false; +} + +/** + * Replace the floating-promise onWebexApiEvent on the messages plugin. + * @param {object|null|undefined} webex + */ +function hardenWebexMessagesPlugin(webex) { + const plugin = webex?.messages; + if (!plugin || plugin.__collabfinderHardened) return; + if ( + typeof plugin.onWebexApiEvent !== 'function' || + typeof plugin.getMessageEvent !== 'function' || + typeof plugin.fire !== 'function' + ) { + logger('webex:sdk', 'messages plugin missing expected methods β€” skip harden', 'warn'); + return; + } + + // Same mapping as @webex/plugin-messages verbToType (share/post β†’ created, + // delete β†’ deleted). Keep in sync if the SDK adds verbs. + const verbToType = { + share: 'created', + post: 'created', + delete: 'deleted', + }; + + const getMessageEvent = plugin.getMessageEvent.bind(plugin); + const fire = plugin.fire.bind(plugin); + + plugin.onWebexApiEvent = function collabfinderOnWebexApiEvent(event) { + const activity = event?.data?.activity; + if (!activity) return; + const type = verbToType[activity.verb]; + if (!type) return; + getMessageEvent(activity, type) + .then(fire(type)) + .catch((err) => { + logger( + 'webex:sdk', + `Dropped message:${type} event (Hydra enrichment failed): ${err.message}`, + 'warn', + ); + }); + }; + plugin.__collabfinderHardened = true; + logger('webex:sdk', 'Hardened messages plugin event handler (404-safe)'); +} + // ────────────────────────────────────────────── // Command Handler (registry-driven) // ────────────────────────────────────────────── @@ -652,7 +767,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 + future /dectstatus command', + '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', 'warn', ); } @@ -778,6 +893,16 @@ process.on('uncaughtException', (err) => { process.on('unhandledRejection', (reason) => { const msg = reason instanceof Error ? `${reason.message}\n${reason.stack}` : String(reason); + // Webex SDK / framework noise β€” log and keep running. A true app bug + // still takes us down below. + if (isBenignWebexSdkRejection(reason)) { + logger( + 'uncaught', + `Ignoring benign Webex SDK rejection (bot stays up): ${reason?.message || reason}`, + 'warn', + ); + return; + } logger('uncaught', `Unhandled promise rejection: ${msg}`, 'error'); shutdown('unhandledRejection', 1); }); \ No newline at end of file diff --git a/services/renderers/dectStatusRenderer.js b/services/renderers/dectStatusRenderer.js new file mode 100644 index 0000000..7675bf6 --- /dev/null +++ b/services/renderers/dectStatusRenderer.js @@ -0,0 +1,198 @@ +// src/services/renderers/dectStatusRenderer.js +// +// Full CLI-style DECT base dump for `/dectstatus`. Complements the +// compact follow-up from renderDectDiagnosticsMarkdown (phonestatus): +// that one is "exceptions only"; this one is the complete status.xml +// picture (device, firmware, reboot log, network, RTP, security, +// emergency numbers, health verdict). +// +// Input is the same collectAll() result array used by the compact +// renderer. Pure β€” no I/O, no env. + +import { formatDisplayTime } from '../../utils/time.js'; + +/** + * @param {Array} results collectAll() output + * @param {object} opts + * @param {string} opts.storeNum + * @param {boolean} [opts.footer=true] + * @param {object} [opts.relay] optional hub.status() snapshot + * @param {Array} [opts.discoveryWarnings] + * @returns {string} + */ +export function renderDectStatusMarkdown(results, opts = {}) { + const { + storeNum, + footer = true, + relay = null, + discoveryWarnings = [], + } = opts; + const list = Array.isArray(results) ? results : []; + + const lines = [`**DECT Status β€” Store ${storeNum}**`, '']; + + if (relay) { + if (relay.connected) { + const agent = relay.agent?.hostname || relay.agent?.version || 'connected'; + lines.push(`_Relay: online (${agent})_`); + } else { + lines.push('_Relay: **offline** β€” base collect will fail until dect-relay-agent reconnects_'); + } + lines.push(''); + } + + if (list.length === 0) { + lines.push('_No reachable DECT basestations discovered for this store._'); + if (discoveryWarnings.length > 0) { + lines.push(''); + lines.push('**Discovery notes:**'); + for (const w of discoveryWarnings) { + const who = w.mac || w.ip || 'base'; + lines.push(`- ${who}: ${w.reason}`); + } + } + if (footer) { + lines.push(''); + lines.push(`_Pulled at ${formatDisplayTime()}._`); + } + return lines.join('\n').trim(); + } + + for (let i = 0; i < list.length; i++) { + if (i > 0) lines.push('', '---', ''); + lines.push(...renderOneBaseFull(list[i])); + } + + if (discoveryWarnings.length > 0) { + lines.push('', '**Discovery notes (skipped bases):**'); + for (const w of discoveryWarnings) { + const who = w.mac || w.ip || 'base'; + lines.push(`- ${who}: ${w.reason}`); + } + } + + if (footer) { + lines.push(''); + lines.push( + `_Full base dump at ${formatDisplayTime()} via the DECT relay. ` + + `Use the action cards below for reboot / factory-reset (chat only)._`, + ); + } + + return lines.join('\n').trim(); +} + +function renderOneBaseFull(r) { + const label = r.base?.name || `Basestation ${r.base?.mac || '?'}`; + const ip = r.base?.ip || '?'; + const mac = r.base?.mac || '?'; + const lines = []; + + if (!r.ok) { + lines.push(`⚠️ **${label}**`); + lines.push(`- IP: \`${ip}\` Β· MAC: \`${mac}\``); + lines.push(`- Collect failed: ${r.error?.message || 'unknown error'}`); + if (r.error?.hint) lines.push(`- _${r.error.hint}_`); + if (r.elapsedMs != null) lines.push(`- Elapsed: ${r.elapsedMs}ms`); + return lines; + } + + const p = r.data || {}; + const verdict = r.verdict || {}; + const icon = verdict.healthy ? 'βœ…' : '⚠️'; + lines.push(`${icon} **${label}**`); + + // ── Device ── + lines.push('', '**Device**'); + row(lines, 'Model', p.device?.model); + row(lines, 'System type', p.device?.systemType); + row(lines, 'Unit', [p.device?.unitName, p.device?.unitIndex].filter(Boolean).join(' Β· ') || null); + row(lines, 'MAC', p.device?.macAddress || mac); + row(lines, 'IP', p.device?.ipAddress || ip); + row(lines, 'RFPI', p.device?.rfpiAddress); + row(lines, 'RF band', p.device?.rfBand); + row(lines, 'Multi-cell', p.multiCell?.role || p.multiCell?.raw); + row(lines, 'Base status', p.baseStatus); + row(lines, 'Conflict', p.conflictInfo); + + // ── Firmware ── + lines.push('', '**Firmware**'); + row(lines, 'Version', p.firmware?.version); + row(lines, 'Update server', p.firmware?.updateServer); + row(lines, 'Update path', p.firmware?.updatePath); + + // ── Time ── + lines.push('', '**Time / uptime**'); + row(lines, 'Local time', p.time?.currentLocalTime); + row(lines, 'Uptime', p.time?.operatingTime); + if (r.elapsedMs != null) row(lines, 'Collect latency', `${r.elapsedMs}ms`); + + // ── Reboot log ── + lines.push('', '**Reboot log** (newest first)'); + const log = Array.isArray(p.rebootLog) ? p.rebootLog : []; + if (log.length === 0) { + lines.push('- _(none)_'); + } else { + for (const entry of log) { + if (entry.unrecognized) { + lines.push(`- ??? ${entry.raw || ''}`); + continue; + } + const tag = entry.reasonCode === 80 ? '⚑' : 'β€’'; + lines.push( + `- ${tag} #${entry.sequence} ${entry.at} **${entry.reasonName}** (${entry.reasonCode}) fw=${entry.firmwareAtBoot || '?'}`, + ); + } + } + + // ── Network ── + lines.push('', '**Network stats** (since boot)'); + row(lines, 'Tx packets', p.network?.txPackets); + row(lines, 'Tx dropped', p.network?.txDropped); + row(lines, 'Tx errors', p.network?.txErrors); + row(lines, 'Rx packets', p.network?.rxPackets); + row(lines, 'Rx dropped', p.network?.rxDropped); + row(lines, 'Rx errors', p.network?.rxErrors); + row(lines, 'Rx broadcasts', p.network?.rxBroadcasts); + + // ── RTP ── + lines.push('', '**RTP**'); + row(lines, 'Total since boot', p.rtp?.total); + row(lines, 'Current active', p.rtp?.current); + row(lines, 'Current local', p.rtp?.currentLocal); + row(lines, 'Current relay', p.rtp?.currentRelay); + + // ── Security ── + lines.push('', '**Security**'); + row( + lines, + 'Custom CA', + p.security?.customCa?.installed + ? (p.security.customCa.info || 'installed') + : 'Not installed', + ); + row(lines, '802.1X protocol', p.security?.dot1x?.protocol); + row(lines, '802.1X status', p.security?.dot1x?.transactionStatus); + + // ── Emergency ── + const emerg = Array.isArray(p.emergencyNumbers) ? p.emergencyNumbers : []; + lines.push('', '**Emergency numbers**'); + lines.push(emerg.length ? `- ${emerg.join(', ')}` : '- _(none configured)_'); + + // ── Verdict ── + lines.push('', '**Health verdict**'); + lines.push(`- healthy: **${verdict.healthy ? 'YES' : 'NO'}**`); + for (const w of verdict.warnings || []) { + lines.push(`- ⚠️ ${w}`); + } + for (const i of verdict.info || []) { + lines.push(`- ℹ️ ${i}`); + } + + return lines; +} + +function row(lines, label, value) { + if (value == null || value === '') return; + lines.push(`- **${label}:** ${value}`); +} diff --git a/tests/renderers.test.js b/tests/renderers.test.js index 549f3ef..f642de1 100644 --- a/tests/renderers.test.js +++ b/tests/renderers.test.js @@ -15,6 +15,7 @@ import { renderDectDiagnosticsMarkdown, } from '../services/renderers/phoneStatusRenderer.js'; import { renderAvStatusMarkdown } from '../services/renderers/avStatusRenderer.js'; +import { renderDectStatusMarkdown } from '../services/renderers/dectStatusRenderer.js'; // Timestamp exactly 3 hours in the past β€” makes `simpleTimeAgo` // deterministic to "3 hours ago" for the duration of this test run. @@ -405,3 +406,90 @@ 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) +// ───────────────────────────────────────────────────────────── + +const fullOkResult = () => ({ + base: { mac: '6c:ab:05:f6:28:19', ip: '10.4.11.87', name: 'Basestation A' }, + ok: true, + data: { + device: { + model: 'DBS-210-3PC', + macAddress: '6c:ab:05:f6:28:19', + ipAddress: '10.4.11.87', + rfpiAddress: '13508C9C; RPN:00', + }, + firmware: { version: 'IPDECT-V2/05-01-03-0101-09' }, + time: { operatingTime: '02:15:00 (H:M:S)', currentLocalTime: '02-Jul-2026 14:00:36' }, + multiCell: { role: 'primary' }, + conflictInfo: 'No Conflict', + baseStatus: 'ok', + rebootLog: [ + { sequence: 164, at: '2026-07-02T12:54:12', reasonName: 'Power Loss', reasonCode: 80, firmwareAtBoot: '05-01-03-0101-09' }, + { sequence: 163, at: '2026-07-02T12:49:50', reasonName: 'Normal Reboot', reasonCode: 21, firmwareAtBoot: '05-01-03-0101-09' }, + ], + network: { txPackets: 100, rxPackets: 200, rxDropped: 18, rxErrors: 0, txErrors: 0 }, + rtp: { total: 2, current: 0, currentLocal: 0, currentRelay: 0 }, + security: { + customCa: { installed: false }, + dot1x: { protocol: 'N/A', transactionStatus: 'Unavailable' }, + }, + emergencyNumbers: ['911', '1911'], + }, + verdict: { + healthy: false, + warnings: ['1 recent power-loss reboot(s); most recent at 2026-07-02T12:54:12'], + info: ['Rx dropped packets: 18 since last boot'], + }, + elapsedMs: 812, +}); + +test('dect status renderer: full dump includes reboot log, emergency numbers, and verdict', () => { + const md = renderDectStatusMarkdown([fullOkResult()], { + storeNum: '782', + footer: false, + relay: { connected: true, agent: { hostname: 'dc-relay-1' } }, + }); + assert.match(md, /\*\*DECT Status β€” Store 782\*\*/); + assert.match(md, /Relay: online \(dc-relay-1\)/); + assert.match(md, /βœ…|⚠️ \*\*Basestation A\*\*/); + assert.match(md, /\*\*Reboot log\*\*/); + assert.match(md, /⚑ #164/); + assert.match(md, /Power Loss/); + assert.match(md, /911, 1911/); + assert.match(md, /healthy: \*\*NO\*\*/); + assert.match(md, /Rx dropped packets: 18/); +}); + +test('dect status renderer: empty discovery + offline relay still produces a usable message', () => { + const md = renderDectStatusMarkdown([], { + storeNum: '782', + footer: false, + relay: { connected: false }, + discoveryWarnings: [{ mac: 'aa:bb:cc:dd:ee:ff', ip: '192.168.1.5', reason: 'not on 10.x' }], + }); + assert.match(md, /Relay: \*\*offline\*\*/); + assert.match(md, /No reachable DECT basestations/); + assert.match(md, /not on 10\.x/); +}); + +test('dect status renderer: collect failure surfaces error + hint', () => { + const md = renderDectStatusMarkdown([ + { + base: { mac: '6c:ab:05:f6:28:19', ip: '10.4.11.87', name: 'Basestation A' }, + ok: false, + data: null, + verdict: null, + elapsedMs: 15000, + error: { + code: 'NOT_CONNECTED', + message: 'DECT relay agent is not connected', + hint: 'DECT relay agent is not connected. Check that dect-relay-agent is running in the data center.', + }, + }, + ], { storeNum: '782', footer: false }); + assert.match(md, /Collect failed: DECT relay agent is not connected/); + assert.match(md, /dect-relay-agent is running/); +}); diff --git a/utils/pendingDectActions.js b/utils/pendingDectActions.js new file mode 100644 index 0000000..239e671 --- /dev/null +++ b/utils/pendingDectActions.js @@ -0,0 +1,64 @@ +// src/utils/pendingDectActions.js +// +// In-memory store for pending DECT base-station action confirmation +// cards (reboot / force-reboot / factory-reset / etc.). Same TTL +// sweep pattern as pendingIgmpFixes / pendingOffboards. +// +// Entries typically hold: +// { +// storeNum, baseIp, baseMac, baseName, +// dectAction, // 'reboot' | 'force-reboot' | ... +// requester, // from extractRequester(trigger) +// timestamp, // auto-set +// } + +import { logger } from './logger.js'; + +const TTL_MS = 15 * 60 * 1000; +const SWEEP_INTERVAL_MS = 60 * 1000; + +const _store = new Map(); + +export const pendingDectActions = { + set(cardId, data) { + _store.set(cardId, { ...data, timestamp: Date.now() }); + return this; + }, + + get(cardId) { + return _store.get(cardId); + }, + + has(cardId) { + return _store.has(cardId); + }, + + delete(cardId) { + return _store.delete(cardId); + }, + + get size() { + return _store.size; + }, + + entries() { + return _store.entries(); + }, +}; + +const sweepHandle = setInterval(() => { + const now = Date.now(); + for (const [cardId, data] of _store.entries()) { + const stamped = typeof data?.timestamp === 'number' ? data.timestamp : 0; + if (now - stamped > TTL_MS) { + logger( + 'dect:cleanup', + `Expired card ${cardId} for store ${data?.storeNum || 'unknown'} ` + + `base ${data?.baseIp || 'unknown'} action ${data?.dectAction || 'unknown'}`, + ); + _store.delete(cardId); + } + } +}, SWEEP_INTERVAL_MS); + +sweepHandle.unref?.();