// src/services/renderers/phoneStatusRenderer.js // // Extracted from commands/phoneStatus.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. // // Options // storeNum (required) header string uses it // detailed default false — mirrors chat's `?detailed=true` toggle: // adds SIP URLs / alt SIPs, more port info, PoE state, etc. // footer default true — appends the `*Last checked: HH:MM*` // italic line. Chat passes true (unchanged). Poller passes // false because a Jira comment already has an authoritative // header timestamp and a Jira-side comment `created` field. // // The renderer is pure (no I/O). All time-derived output goes through // `simpleTimeAgo` — the same helper the chat handler used, so relative // times ("2h ago") stay consistent across chat and Jira surfaces. import { simpleTimeAgo, formatBytes } from '../../utils/time.js'; /** * Render a phone-status markdown snapshot from a `collectPhoneStatus` * result. * * @param {object} data collectPhoneStatus() output * @param {object} opts * @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. * @returns {string} markdown, whitespace-trimmed and ready to send. */ export function renderPhoneStatusMarkdown(data, opts = {}) { const { storeNum, detailed = false, footer = true, dectFollowUpBaseCount = 0 } = opts; let reply = `**Phone Status - Store ${storeNum}**\n\n`; const phones = data.phones?.data || []; const dectBasestations = data.dectBasestations || []; const dectHandsets = data.dectHandsets || []; const dectNet = data.dectNetwork || null; if (phones.length === 0 && dectBasestations.length === 0) { reply += 'No phones or DECT basestations found.\n'; return reply.trim(); } const prof = data.telephonyProfile || {}; const pers = data.person || {}; if (prof.timeZone) { reply += `**Timezone:** ${prof.timeZone}\n`; } if (data.locationMainNumber) { let extPart = ''; if (pers.phoneNumbers && pers.phoneNumbers.length > 0) { const nums = pers.phoneNumbers.map(n => n.value || n).filter(Boolean); if (nums.length > 0) { extPart = ` (${nums.join(', ')})`; } } reply += `**PhoneNumber:** ${data.locationMainNumber}${extPart}\n\n`; } if (detailed && prof.outgoingPermission) { const op = prof.outgoingPermission; const mode = op.useCustomEnabled ? 'custom rules' : 'default'; const ruleCount = op.callingPermissions ? op.callingPermissions.length : 0; reply += `Outgoing: ${mode} (${ruleCount} permission entries)\n\n`; } // Desk Phones if (phones.length > 0) { reply += '**Desk Phones:**\n'; phones.forEach(phone => { const lastSeen = simpleTimeAgo(phone.lastSeen) || 'unknown'; let prefix = '✅ '; if (phone.status !== 'connected') prefix = '⚠️ '; reply += `${prefix}**${phone.displayName || phone.name || 'Unknown Phone'}** (${phone.status}) Last seen: ${lastSeen}\n`; const fw = phone.firmware && phone.firmware !== '—' ? ` FW: ${phone.firmware}` : ''; const ser = phone.serial && phone.serial !== '—' ? ` Serial: ${phone.serial}` : ''; if (fw || ser) { reply += ` ${fw}${ser ? (fw ? ' •' : '') + ser : ''}\n`; } if (detailed && phone.primarySipUrl && phone.primarySipUrl !== '—') { reply += ` SIP: ${phone.primarySipUrl}\n`; } if (detailed && phone.sipUrls && phone.sipUrls.length > 1) { reply += ` Alt SIPs: ${phone.sipUrls.slice(0, 2).join(', ')}${phone.sipUrls.length > 2 ? '…' : ''}\n`; } if (phone.errorCodes && phone.errorCodes.length > 0) { reply += ` ⚠️ Errors: ${phone.errorCodes.join(', ')}\n`; } if (phone.meraki && (phone.meraki.port || phone.meraki.switchName)) { let mPrefix = ''; if (phone.meraki.status !== 'Online') mPrefix = '⚠️ '; reply += ` •${mPrefix}**${phone.meraki.switchName || 'Unknown Switch'}** (${phone.meraki.status || 'unknown'}) ` + `Wired • Port: ${phone.meraki.port || '—'} • VLAN: ${phone.meraki.vlan || '—'} ` + `• IP: ${phone.meraki.ip || '—'} LastSeen: ${simpleTimeAgo(phone.meraki.lastSeen)}${phone.meraki.clientUrl ? ` [Meraki↗](${phone.meraki.clientUrl})` : ''}\n`; const u = phone.meraki.usage; if (u && (u.sent || u.recv || u.total)) { const sent = formatBytes(u.sent || 0); const recv = formatBytes(u.recv || 0); const tot = u.total ? formatBytes(u.total) : ''; reply += ` Data (recent): ${sent} sent / ${recv} recv${tot ? ' (total ' + tot + ')' : ''}\n`; } if (detailed) { const poe = phone.meraki.poeEnabled != null ? (phone.meraki.poeEnabled ? 'PoE on' : 'PoE off') : ''; const spd = phone.meraki.speed ? `${phone.meraki.speed}` : ''; const pol = phone.meraki.portName ? `port ${phone.meraki.portName}` : ''; const extras = [poe, spd, pol].filter(Boolean).join(' • '); if (extras) reply += ` ${extras}\n`; if (phone.meraki.accessPolicy) reply += ` Policy: ${phone.meraki.accessPolicy}\n`; } } else if (detailed) { reply += ` (no recent Meraki client/switch data)\n`; } reply += '\n'; }); } // DECT Basestations if (dectBasestations.length > 0) { reply += '**DECT Basestations:**\n'; // Multicast health signal — DECT relies on multicast for basestation // discovery / handset registration handshakes. When IGMP snooping is // on without a corresponding querier, or when flood-unknown is off, // those frames get dropped and DECT registration silently fails. // Only emitted when the summarizer says `needsFix` — the healthy // state is common and would just add noise. Silent on fetch errors // (summarizer returns needsFix:false + error:...) so a diagnostic // bot doesn't itself become a new source of noise. if (data.multicast?.needsFix) { const mc = data.multicast; const parts = []; if (mc.defaultSnoopOn) parts.push('IGMP snoop=ON default'); if (mc.defaultFloodOff) parts.push('flood-unknown=OFF default'); const overrideCount = mc.deviatingOverrides?.length || 0; if (overrideCount > 0) { parts.push(`${overrideCount} switch override(s) deviate from DECT-safe defaults`); } // parts is always non-empty here: needsFix implies at least one deviation reply += `**Multicast:** ⚠️ ${parts.join(', ')} — may disrupt DECT\n`; } if (dectNet) { reply += `**Network:** ${dectNet.name || '—'} (assigned handsets: ${dectNet.handsetsCount || 0})\n`; } dectBasestations.forEach(base => { let prefix = '✅ '; if (base.meraki?.status !== 'Online') prefix = '⚠️ '; const lines = base.linesRegistered != null ? ` (lines: ${base.linesRegistered})` : ''; reply += `${prefix}**Basestation ${base.mac || 'Unknown'}**${lines}\n`; if (base.meraki && (base.meraki.port || base.meraki.switchName)) { let mPrefix = ''; if (base.meraki.status !== 'Online') mPrefix = '⚠️ '; reply += ` •${mPrefix}**${base.meraki.switchName || 'Unknown Switch'}** (${base.meraki.status || 'unknown'}) ` + `Wired • Port: ${base.meraki.port || '—'} • VLAN: ${base.meraki.vlan || '—'} ` + `• IP: ${base.meraki.ip || '—'} LastSeen: ${simpleTimeAgo(base.meraki.lastSeen)}${base.meraki.clientUrl ? ` [Meraki↗](${base.meraki.clientUrl})` : ''}\n`; if (detailed && base.meraki.usage) { const u = base.meraki.usage; reply += ` Data (recent): ${formatBytes(u.sent || 0)} sent / ${formatBytes(u.recv || 0)} recv\n`; } } else { reply += ` (no recent Meraki client/switch data — possibly offline or not attached to this network)\n`; } const registeredHandsets = dectHandsets.filter(h => h.baseStationId === base.id); if (registeredHandsets.length > 0) { registeredHandsets.forEach(h => { reply += ` • **${h.index}-${h.name || 'Handset'}** (ext ${h.extension || '—'}) Registered: ${simpleTimeAgo(h.lastRegistrationTime)}\n`; }); } else { reply += ` No handsets registered\n`; } reply += '\n'; }); const unregisteredHandsets = dectHandsets.filter(h => !h.baseStationId); if (unregisteredHandsets.length > 0) { reply += '**Unregistered Handsets:**\n'; unregisteredHandsets.forEach(h => { reply += ` • **${h.index}-${h.name || 'Handset'}** (ext ${h.extension || '—'}) Registered: ${simpleTimeAgo(h.lastRegistrationTime)}\n`; }); 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`; } if (footer) { reply += `\n*Last checked: ${new Date().toLocaleTimeString()}*`; } 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 ${new Date().toLocaleTimeString()} 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; }