collabSupport/services/renderers/phoneStatusRenderer.js
jmcqueen d112e45eb6 Detect and inline-remediate Meraki IGMP snooping in /phonestatus
DECT basestations rely on multicast for handset discovery/registration.
When Meraki switches have IGMP snooping enabled without a querier —
or per-switch overrides that deviate from a DECT-safe policy — those
frames are pruned and DECT handsets silently fail to register.

- integrations/meraki/switches.js: getSwitchMulticastSettings,
  setSwitchMulticastSettings, and pure summarizeMulticast verdict fn.
- services/phoneService.js: fires the multicast fetch as soon as the
  network id is known, overlapping DECT enrichment; attaches
  data.multicast summary to the collector output. Non-fatal on error.
- services/renderers/phoneStatusRenderer.js: emits a single warning
  line inside the DECT Basestations section only when needsFix is
  true, itemizing which parts deviate (default snoop, default flood,
  N overrides).
- commands/igmpFix.js: frozen DECT_SAFE_MULTICAST_PAYLOAD constant
  ({snoop:false, flood:true, overrides:[]}), adaptive-card builder,
  and confirm/cancel handlers.
- commands/phoneStatus.js: appends the adaptive card when needsFix
  and the trigger came from chat (skipped on HTTP path).
- index.js: IGMP_FIX_ACTIONS set + dispatch branch mirroring the
  HOST_ASSIGN pattern (one-shot pending lookup, censorActionCard,
  domain call).
- utils/pendingIgmpFixes.js: 15-min TTL pending-card store.
- tests: 11 summarizer cases (all deviation permutations + malformed
  input), 4 renderer cases (each warning-line shape + silence when
  needsFix false or no DECT), and a frozen-constant regression guard
  on the PUT payload. Full suite: 47/47 passing.
2026-07-02 14:10:33 -04:00

204 lines
9.1 KiB
JavaScript

// 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]
* @returns {string} markdown, whitespace-trimmed and ready to send.
*/
export function renderPhoneStatusMarkdown(data, opts = {}) {
const { storeNum, detailed = false, footer = true } = 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';
}
}
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();
}