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.
This commit is contained in:
parent
c15a471959
commit
d112e45eb6
10 changed files with 755 additions and 1 deletions
176
commands/igmpFix.js
Normal file
176
commands/igmpFix.js
Normal file
|
|
@ -0,0 +1,176 @@
|
||||||
|
// src/commands/igmpFix.js
|
||||||
|
//
|
||||||
|
// 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
|
||||||
|
// 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 /
|
||||||
|
// censor pattern as /webexhost and /offboarduser.
|
||||||
|
//
|
||||||
|
// End-state target (per plan): every store network's switch multicast
|
||||||
|
// settings pinned to:
|
||||||
|
// defaultSettings.igmpSnoopingEnabled = false
|
||||||
|
// defaultSettings.floodUnknownMulticastTrafficEnabled = true
|
||||||
|
// overrides = []
|
||||||
|
//
|
||||||
|
// The target is a hardcoded constant. We do NOT read-then-mutate the
|
||||||
|
// current snapshot at PUT time — the multicast object schema has only
|
||||||
|
// these two fields, so there's nothing else to preserve, and a
|
||||||
|
// constant PUT sidesteps read/write races.
|
||||||
|
|
||||||
|
import { logger } from '../utils/logger.js';
|
||||||
|
import { describeRequester } from '../utils/requester.js';
|
||||||
|
import { setSwitchMulticastSettings } from '../integrations/meraki/switches.js';
|
||||||
|
|
||||||
|
// Exported so tests can regression-guard the payload shape and so the
|
||||||
|
// applyDectSafeMulticast function has a single source of truth.
|
||||||
|
export const DECT_SAFE_MULTICAST_PAYLOAD = Object.freeze({
|
||||||
|
defaultSettings: {
|
||||||
|
igmpSnoopingEnabled: false,
|
||||||
|
floodUnknownMulticastTrafficEnabled: true,
|
||||||
|
},
|
||||||
|
overrides: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the adaptive-card object shown in-chat under a /phonestatus
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
* @param {object} args
|
||||||
|
* @param {string} args.storeNum
|
||||||
|
* @param {string} args.networkName
|
||||||
|
* @param {object} args.summary data.multicast summary shape
|
||||||
|
* @param {string} args.cardId uuid stashed in pendingIgmpFixes
|
||||||
|
* @returns {object} Adaptive Card 1.3 JSON.
|
||||||
|
*/
|
||||||
|
export function buildIgmpFixCard({ storeNum, networkName, summary, cardId }) {
|
||||||
|
const facts = [];
|
||||||
|
if (summary.defaultSnoopOn) {
|
||||||
|
facts.push({ title: 'Default IGMP snooping', value: 'ON → will change to OFF' });
|
||||||
|
} else {
|
||||||
|
facts.push({ title: 'Default IGMP snooping', value: 'OFF (no change)' });
|
||||||
|
}
|
||||||
|
if (summary.defaultFloodOff) {
|
||||||
|
facts.push({ title: 'Default flood-unknown multicast', value: 'OFF → will change to ON' });
|
||||||
|
} else {
|
||||||
|
facts.push({ title: 'Default flood-unknown multicast', value: 'ON (no change)' });
|
||||||
|
}
|
||||||
|
const overrideCount = summary.deviatingOverrides?.length || 0;
|
||||||
|
facts.push({
|
||||||
|
title: 'Switch/stack overrides',
|
||||||
|
value: overrideCount > 0
|
||||||
|
? `${overrideCount} deviating → all overrides cleared`
|
||||||
|
: 'none (no change)',
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'AdaptiveCard',
|
||||||
|
$schema: 'http://adaptivecards.io/schemas/adaptive-card.json',
|
||||||
|
version: '1.3',
|
||||||
|
body: [
|
||||||
|
{
|
||||||
|
type: 'TextBlock',
|
||||||
|
size: 'Medium',
|
||||||
|
weight: 'Bolder',
|
||||||
|
text: `Apply DECT-safe multicast on ${networkName || `store ${storeNum}`}?`,
|
||||||
|
wrap: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'TextBlock',
|
||||||
|
text:
|
||||||
|
`This will PUT the Meraki switch multicast settings for this network so IGMP snooping is OFF, ` +
|
||||||
|
`flood-unknown-multicast is ON, and all per-switch overrides are cleared. Intended to unblock DECT ` +
|
||||||
|
`handset registration for store ${storeNum}.`,
|
||||||
|
wrap: true,
|
||||||
|
spacing: 'Small',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'FactSet',
|
||||||
|
spacing: 'Medium',
|
||||||
|
facts,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
type: 'Action.Submit',
|
||||||
|
title: '✅ Apply DECT-safe defaults',
|
||||||
|
data: { action: 'confirm_igmp_fix', cardId },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'Action.Submit',
|
||||||
|
title: '❌ Cancel',
|
||||||
|
data: { action: 'cancel_igmp_fix', cardId },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confirm handler — PUTs the constant DECT-safe payload to the
|
||||||
|
* network's multicast endpoint. Called from index.js after the
|
||||||
|
* pending-card lookup + one-shot delete + card censor.
|
||||||
|
*
|
||||||
|
* Signature matches applyHostAssignConfirmation from /webexhost so the
|
||||||
|
* dispatch in index.js stays uniform across all inline-card actions.
|
||||||
|
*
|
||||||
|
* @param {object} bot bot-framework per-room bot
|
||||||
|
* @param {object} data pending-card payload (see pendingIgmpFixes)
|
||||||
|
* @param {string} _roomId unused; bot is already room-scoped
|
||||||
|
* @param {object} requester from extractRequester(trigger)
|
||||||
|
*/
|
||||||
|
export async function applyDectSafeMulticast(bot, data, _roomId, requester) {
|
||||||
|
const { networkId, networkName, storeNum, summary } = data;
|
||||||
|
|
||||||
|
logger(
|
||||||
|
'igmp:audit',
|
||||||
|
`CONFIRMED DECT-safe multicast on ${networkName} (${networkId}) for store ${storeNum} ` +
|
||||||
|
`by ${describeRequester(requester)} — was: snoop=${summary?.defaultSnoopOn ? 'on' : 'off'}, ` +
|
||||||
|
`flood=${summary?.defaultFloodOff ? 'off' : 'on'}, overrides=${summary?.deviatingOverrides?.length ?? 0}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await setSwitchMulticastSettings(networkId, DECT_SAFE_MULTICAST_PAYLOAD);
|
||||||
|
} catch (err) {
|
||||||
|
logger(
|
||||||
|
'igmp:audit',
|
||||||
|
`FAILED DECT-safe multicast on ${networkName} for store ${storeNum}: ${err.message}`,
|
||||||
|
'error',
|
||||||
|
);
|
||||||
|
await bot.say(
|
||||||
|
'markdown',
|
||||||
|
`❌ Failed to update multicast config on **${networkName || `store ${storeNum}`}**: ${err.message}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await bot.say(
|
||||||
|
'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.`,
|
||||||
|
);
|
||||||
|
logger(
|
||||||
|
'igmp:audit',
|
||||||
|
`COMPLETED DECT-safe multicast on ${networkName} for store ${storeNum}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel handler — logs the cancel and posts a friendly line. No
|
||||||
|
* mutation. Signature matches cancelHostAssignCard for uniform
|
||||||
|
* dispatch in index.js.
|
||||||
|
*/
|
||||||
|
export async function cancelIgmpFixCard(bot, data, _roomId, requester) {
|
||||||
|
const { networkName, storeNum } = data;
|
||||||
|
await bot.say(
|
||||||
|
'markdown',
|
||||||
|
`❌ Multicast change cancelled for **${networkName || `store ${storeNum}`}**. No changes were made.`,
|
||||||
|
);
|
||||||
|
logger(
|
||||||
|
'igmp:audit',
|
||||||
|
`CANCELLED DECT-safe multicast on ${networkName} for store ${storeNum} by ${describeRequester(requester)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -6,8 +6,12 @@
|
||||||
// stays thin: parse args, call the collector, hand data to the renderer,
|
// stays thin: parse args, call the collector, hand data to the renderer,
|
||||||
// respond.
|
// respond.
|
||||||
|
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
import { collectPhoneStatus } from '../services/phoneService.js';
|
import { collectPhoneStatus } from '../services/phoneService.js';
|
||||||
import { renderPhoneStatusMarkdown } from '../services/renderers/phoneStatusRenderer.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';
|
import { logger } from '../utils/logger.js';
|
||||||
|
|
||||||
export async function handlePhoneStatus(bot, trigger) {
|
export async function handlePhoneStatus(bot, trigger) {
|
||||||
|
|
@ -57,6 +61,50 @@ export async function handlePhoneStatus(bot, trigger) {
|
||||||
});
|
});
|
||||||
await bot.say('markdown', reply || 'No data available.');
|
await bot.say('markdown', reply || 'No data available.');
|
||||||
|
|
||||||
|
// 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) {
|
} catch (err) {
|
||||||
logger('phone:status', `Error collecting phone status for store ${storeNum}: ${err.message}`, 'error');
|
logger('phone:status', `Error collecting phone status for store ${storeNum}: ${err.message}`, 'error');
|
||||||
await bot.say('markdown', `Error collecting phone status: ${err.message}`);
|
await bot.say('markdown', `Error collecting phone status: ${err.message}`);
|
||||||
|
|
|
||||||
52
index.js
52
index.js
|
|
@ -29,6 +29,11 @@ import {
|
||||||
cancelHostAssignCard,
|
cancelHostAssignCard,
|
||||||
} from './commands/webexHost.js';
|
} from './commands/webexHost.js';
|
||||||
import { pendingHostAssigns } from './utils/pendingHostAssigns.js';
|
import { pendingHostAssigns } from './utils/pendingHostAssigns.js';
|
||||||
|
import {
|
||||||
|
applyDectSafeMulticast,
|
||||||
|
cancelIgmpFixCard,
|
||||||
|
} from './commands/igmpFix.js';
|
||||||
|
import { pendingIgmpFixes } from './utils/pendingIgmpFixes.js';
|
||||||
import { extractRequester } from './utils/requester.js';
|
import { extractRequester } from './utils/requester.js';
|
||||||
import {
|
import {
|
||||||
getCommand,
|
getCommand,
|
||||||
|
|
@ -291,6 +296,7 @@ const DECT_ACTIONS = new Set([
|
||||||
]);
|
]);
|
||||||
const OFFBOARD_ACTIONS = new Set(['confirm_offboard', 'cancel_offboard']);
|
const OFFBOARD_ACTIONS = new Set(['confirm_offboard', 'cancel_offboard']);
|
||||||
const HOST_ASSIGN_ACTIONS = new Set(['confirm_host_assign', 'cancel_host_assign']);
|
const HOST_ASSIGN_ACTIONS = new Set(['confirm_host_assign', 'cancel_host_assign']);
|
||||||
|
const IGMP_FIX_ACTIONS = new Set(['confirm_igmp_fix', 'cancel_igmp_fix']);
|
||||||
|
|
||||||
// Best-effort delete of the adaptive-card message that fired this action.
|
// Best-effort delete of the adaptive-card message that fired this action.
|
||||||
// Removing the card prevents users from clicking Confirm/Cancel a second time
|
// Removing the card prevents users from clicking Confirm/Cancel a second time
|
||||||
|
|
@ -430,6 +436,52 @@ framework.on('attachmentAction', async (bot, trigger) => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── IGMP-snooping / DECT-safe multicast confirm / cancel ──
|
||||||
|
// Mirrors the HOST_ASSIGN branch above: look up the pending card,
|
||||||
|
// one-shot delete, censor the card in the room, then dispatch to
|
||||||
|
// the domain function with the pending payload + resolved requester.
|
||||||
|
if (IGMP_FIX_ACTIONS.has(actionType)) {
|
||||||
|
const { cardId } = action.inputs;
|
||||||
|
const roomId = trigger.roomId || action.roomId;
|
||||||
|
|
||||||
|
if (!cardId) {
|
||||||
|
logger('igmp:action', `Missing cardId on ${actionType} — ignoring`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!pendingIgmpFixes.has(cardId)) {
|
||||||
|
logger('igmp:action', `Card ${cardId} is expired or unknown`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const igmpData = pendingIgmpFixes.get(cardId);
|
||||||
|
pendingIgmpFixes.delete(cardId); // one-shot
|
||||||
|
logger(
|
||||||
|
'igmp:action',
|
||||||
|
`Received ${actionType} for card ${cardId} (network: ${igmpData.networkName}, store: ${igmpData.storeNum})`,
|
||||||
|
);
|
||||||
|
|
||||||
|
await censorActionCard(bot, trigger, 'igmp:action');
|
||||||
|
|
||||||
|
const requester = extractRequester(trigger);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (actionType === 'confirm_igmp_fix') {
|
||||||
|
await applyDectSafeMulticast(bot, igmpData, roomId, requester);
|
||||||
|
} else {
|
||||||
|
await cancelIgmpFixCard(bot, igmpData, roomId, requester);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger(
|
||||||
|
'igmp:action',
|
||||||
|
`Error processing ${actionType} for network ${igmpData.networkName}: ${err.message}`,
|
||||||
|
'error',
|
||||||
|
);
|
||||||
|
// bot is already room-scoped — do not pass roomId as a 3rd positional arg.
|
||||||
|
await bot.say('markdown', `⚠️ Error during multicast update: ${err.message}`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
logger('action', `Ignoring unhandled attachmentAction: ${actionType}`, 'debug');
|
logger('action', `Ignoring unhandled attachmentAction: ${actionType}`, 'debug');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
115
integrations/meraki/switches.js
Normal file
115
integrations/meraki/switches.js
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
// src/integrations/meraki/switches.js
|
||||||
|
//
|
||||||
|
// Switch-level Meraki calls. Currently scoped to the network-wide
|
||||||
|
// multicast (IGMP snooping / flood-unknown-multicast) settings —
|
||||||
|
// added to support DECT troubleshooting where snooping-without-a-
|
||||||
|
// querier prunes multicast traffic and breaks handset registration.
|
||||||
|
//
|
||||||
|
// The Meraki endpoint is:
|
||||||
|
// GET/PUT /networks/{networkId}/switch/routing/multicast
|
||||||
|
//
|
||||||
|
// Payload shape:
|
||||||
|
// {
|
||||||
|
// defaultSettings: {
|
||||||
|
// igmpSnoopingEnabled: boolean,
|
||||||
|
// floodUnknownMulticastTrafficEnabled: boolean,
|
||||||
|
// },
|
||||||
|
// overrides: [
|
||||||
|
// { switches?: [serial, ...], stacks?: [stackId, ...], igmpSnoopingEnabled, floodUnknownMulticastTrafficEnabled },
|
||||||
|
// ...
|
||||||
|
// ],
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// Target end-state for DECT stores (from the plan): network default
|
||||||
|
// snoop=false + flood=true, and NO overrides at all (network-level
|
||||||
|
// policy, every switch agrees by convention). That target is exported
|
||||||
|
// as a constant from commands/igmpFix.js and used verbatim by the fix
|
||||||
|
// path — this module intentionally stays policy-free.
|
||||||
|
|
||||||
|
import { merakiAxios } from './client.js';
|
||||||
|
import { logger } from '../../utils/logger.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch the current switch multicast settings for a network.
|
||||||
|
* Returns the raw API response on success, or throws on network /
|
||||||
|
* auth failure so the caller can decide how to degrade.
|
||||||
|
*/
|
||||||
|
export async function getSwitchMulticastSettings(networkId) {
|
||||||
|
if (!networkId) {
|
||||||
|
throw new Error('getSwitchMulticastSettings: networkId is required');
|
||||||
|
}
|
||||||
|
const res = await merakiAxios.get(`/networks/${networkId}/switch/routing/multicast`);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT the full multicast payload for a network. The Meraki endpoint
|
||||||
|
* treats this as a full replacement — whatever `overrides` array you
|
||||||
|
* send becomes the new authoritative list. That's why the fix path
|
||||||
|
* sends `overrides: []` to wipe everything rather than trying to
|
||||||
|
* patch individual entries.
|
||||||
|
*/
|
||||||
|
export async function setSwitchMulticastSettings(networkId, payload) {
|
||||||
|
if (!networkId) {
|
||||||
|
throw new Error('setSwitchMulticastSettings: networkId is required');
|
||||||
|
}
|
||||||
|
const res = await merakiAxios.put(
|
||||||
|
`/networks/${networkId}/switch/routing/multicast`,
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
logger('meraki:switches', `Updated multicast settings for network ${networkId}`, 'debug');
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure summarizer — turns a raw multicast settings payload into a
|
||||||
|
* "needs fix?" verdict for DECT-safe policy. Kept side-effect-free
|
||||||
|
* so the unit test can call it with fixture data (no Meraki mock).
|
||||||
|
*
|
||||||
|
* "Needs fix" is true when:
|
||||||
|
* - Network default `igmpSnoopingEnabled !== false`, OR
|
||||||
|
* - Network default `floodUnknownMulticastTrafficEnabled !== true`, OR
|
||||||
|
* - Any override deviates on either setting.
|
||||||
|
*
|
||||||
|
* An override that happens to match the target defaults is redundant
|
||||||
|
* (not deviating) — the multicast endpoint only stores these two
|
||||||
|
* fields per override, so a matching override serves no purpose but
|
||||||
|
* doesn't break anything either. We treat it as non-deviating so we
|
||||||
|
* don't nag operators about harmless historical config.
|
||||||
|
*
|
||||||
|
* Malformed / missing input returns `needsFix: false` with both
|
||||||
|
* boolean flags false — the caller stores `data.multicast.error` in
|
||||||
|
* that case and the renderer stays silent, per the "diagnostic bot
|
||||||
|
* shouldn't become a new source of noise" rule.
|
||||||
|
*
|
||||||
|
* @param {object} raw Raw response from `getSwitchMulticastSettings`.
|
||||||
|
* @returns {{
|
||||||
|
* defaultOk: boolean,
|
||||||
|
* defaultSnoopOn: boolean,
|
||||||
|
* defaultFloodOff: boolean,
|
||||||
|
* deviatingOverrides: object[],
|
||||||
|
* needsFix: boolean,
|
||||||
|
* raw: object,
|
||||||
|
* }}
|
||||||
|
*/
|
||||||
|
export function summarizeMulticast(raw) {
|
||||||
|
const def = raw?.defaultSettings || {};
|
||||||
|
const defaultSnoopOn = def.igmpSnoopingEnabled === true;
|
||||||
|
const defaultFloodOff = def.floodUnknownMulticastTrafficEnabled === false;
|
||||||
|
const defaultOk = !defaultSnoopOn && !defaultFloodOff;
|
||||||
|
|
||||||
|
const overrides = Array.isArray(raw?.overrides) ? raw.overrides : [];
|
||||||
|
const deviatingOverrides = overrides.filter((o) =>
|
||||||
|
o?.igmpSnoopingEnabled !== false ||
|
||||||
|
o?.floodUnknownMulticastTrafficEnabled !== true
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
defaultOk,
|
||||||
|
defaultSnoopOn,
|
||||||
|
defaultFloodOff,
|
||||||
|
deviatingOverrides,
|
||||||
|
needsFix: !defaultOk || deviatingOverrides.length > 0,
|
||||||
|
raw,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,10 @@ import {
|
||||||
getClientsForStore,
|
getClientsForStore,
|
||||||
getPortsForStore
|
getPortsForStore
|
||||||
} from '../integrations/meraki/clients.js';
|
} from '../integrations/meraki/clients.js';
|
||||||
|
import {
|
||||||
|
getSwitchMulticastSettings,
|
||||||
|
summarizeMulticast,
|
||||||
|
} from '../integrations/meraki/switches.js';
|
||||||
|
|
||||||
import { logger } from '../utils/logger.js';
|
import { logger } from '../utils/logger.js';
|
||||||
import { normalizeMac } from './enrichment/normalizers.js';
|
import { normalizeMac } from './enrichment/normalizers.js';
|
||||||
|
|
@ -57,6 +61,24 @@ export async function collectPhoneStatus(storeNumber) {
|
||||||
const clients = Array.isArray(clientsData) ? clientsData : (clientsData.clients || []);
|
const clients = Array.isArray(clientsData) ? clientsData : (clientsData.clients || []);
|
||||||
const networkUrl = clientsData.network?.url || '';
|
const networkUrl = clientsData.network?.url || '';
|
||||||
|
|
||||||
|
// Kick off the switch multicast fetch as soon as we know the network id.
|
||||||
|
// Overlaps with the DECT detail fetches below so it's essentially free
|
||||||
|
// wall-clock time. Wrapped in a promise that never rejects — the phone
|
||||||
|
// report should still render even if multicast lookup fails (missing
|
||||||
|
// API scope, non-switch network, etc.). Renderer stays silent when
|
||||||
|
// `needsFix` is false, so an error-only summary produces no noise.
|
||||||
|
const networkIdForMulticast = clientsData.network?.id
|
||||||
|
|| clientsData.networkId
|
||||||
|
|| null;
|
||||||
|
const multicastPromise = networkIdForMulticast
|
||||||
|
? getSwitchMulticastSettings(networkIdForMulticast)
|
||||||
|
.then((raw) => ({ ...summarizeMulticast(raw), networkId: networkIdForMulticast, networkName: clientsData.network?.name || null }))
|
||||||
|
.catch((err) => {
|
||||||
|
logger('phone:service', `Multicast settings fetch failed for network ${networkIdForMulticast}: ${err.message}`, 'warn');
|
||||||
|
return { needsFix: false, error: err.message, networkId: networkIdForMulticast, networkName: clientsData.network?.name || null };
|
||||||
|
})
|
||||||
|
: Promise.resolve({ needsFix: false, error: 'no networkId available', networkId: null, networkName: null });
|
||||||
|
|
||||||
const personDetails = personDetailsRes.status === 'fulfilled' ? personDetailsRes.value : null;
|
const personDetails = personDetailsRes.status === 'fulfilled' ? personDetailsRes.value : null;
|
||||||
const telephonyProfile = telephonyProfileRes.status === 'fulfilled' ? telephonyProfileRes.value : {};
|
const telephonyProfile = telephonyProfileRes.status === 'fulfilled' ? telephonyProfileRes.value : {};
|
||||||
|
|
||||||
|
|
@ -217,6 +239,12 @@ export async function collectPhoneStatus(storeNumber) {
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Resolve the multicast promise we fired earlier. Awaited here so it
|
||||||
|
// overlaps with all the DECT/Meraki enrichment above (essentially
|
||||||
|
// free wall-clock time). The promise catches its own errors so this
|
||||||
|
// await never rejects.
|
||||||
|
const multicast = await multicastPromise;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
phones: {
|
phones: {
|
||||||
status: phonesRes.status === 'fulfilled' ? 'success' : 'failed',
|
status: phonesRes.status === 'fulfilled' ? 'success' : 'failed',
|
||||||
|
|
@ -233,7 +261,8 @@ export async function collectPhoneStatus(storeNumber) {
|
||||||
data: clients || [],
|
data: clients || [],
|
||||||
network: clientsData.network || null,
|
network: clientsData.network || null,
|
||||||
networkId: clientsData.networkId || (clientsData.network && clientsData.network.id) || null
|
networkId: clientsData.networkId || (clientsData.network && clientsData.network.id) || null
|
||||||
}
|
},
|
||||||
|
multicast
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -125,6 +125,28 @@ export function renderPhoneStatusMarkdown(data, opts = {}) {
|
||||||
// DECT Basestations
|
// DECT Basestations
|
||||||
if (dectBasestations.length > 0) {
|
if (dectBasestations.length > 0) {
|
||||||
reply += '**DECT Basestations:**\n';
|
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) {
|
if (dectNet) {
|
||||||
reply += `**Network:** ${dectNet.name || '—'} (assigned handsets: ${dectNet.handsetsCount || 0})\n`;
|
reply += `**Network:** ${dectNet.name || '—'} (assigned handsets: ${dectNet.handsetsCount || 0})\n`;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
27
tests/igmpFixPayload.test.js
Normal file
27
tests/igmpFixPayload.test.js
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
// Regression guard for the DECT-safe multicast payload constant.
|
||||||
|
//
|
||||||
|
// The whole IGMP-fix design is predicated on a single hardcoded PUT
|
||||||
|
// body — no read-then-mutate, no per-store branching. If someone
|
||||||
|
// accidentally flips one of these booleans (or adds an override to
|
||||||
|
// the array), every remediation click starts silently corrupting
|
||||||
|
// production Meraki config. This one test makes that impossible to
|
||||||
|
// merge without also updating the test, which forces a review.
|
||||||
|
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
import { DECT_SAFE_MULTICAST_PAYLOAD } from '../commands/igmpFix.js';
|
||||||
|
|
||||||
|
test('DECT_SAFE_MULTICAST_PAYLOAD has the exact expected shape', () => {
|
||||||
|
assert.deepEqual(DECT_SAFE_MULTICAST_PAYLOAD, {
|
||||||
|
defaultSettings: {
|
||||||
|
igmpSnoopingEnabled: false,
|
||||||
|
floodUnknownMulticastTrafficEnabled: true,
|
||||||
|
},
|
||||||
|
overrides: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('DECT_SAFE_MULTICAST_PAYLOAD is frozen so nothing can mutate it at runtime', () => {
|
||||||
|
assert.equal(Object.isFrozen(DECT_SAFE_MULTICAST_PAYLOAD), true);
|
||||||
|
});
|
||||||
135
tests/igmpSummarizer.test.js
Normal file
135
tests/igmpSummarizer.test.js
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
// Unit tests for summarizeMulticast — the pure verdict function
|
||||||
|
// under integrations/meraki/switches.js. No Meraki API needed; the
|
||||||
|
// summarizer takes a fixture-shaped response and computes flags.
|
||||||
|
//
|
||||||
|
// The target end-state (per the plan) is:
|
||||||
|
// defaultSettings.igmpSnoopingEnabled = false
|
||||||
|
// defaultSettings.floodUnknownMulticastTrafficEnabled = true
|
||||||
|
// overrides = [] (no per-switch drift)
|
||||||
|
//
|
||||||
|
// `needsFix` is true if any layer deviates from that.
|
||||||
|
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
import { summarizeMulticast } from '../integrations/meraki/switches.js';
|
||||||
|
|
||||||
|
test('target state → needsFix: false', () => {
|
||||||
|
const s = summarizeMulticast({
|
||||||
|
defaultSettings: { igmpSnoopingEnabled: false, floodUnknownMulticastTrafficEnabled: true },
|
||||||
|
overrides: [],
|
||||||
|
});
|
||||||
|
assert.equal(s.needsFix, false);
|
||||||
|
assert.equal(s.defaultOk, true);
|
||||||
|
assert.equal(s.defaultSnoopOn, false);
|
||||||
|
assert.equal(s.defaultFloodOff, false);
|
||||||
|
assert.equal(s.deviatingOverrides.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('snoop on at default → needsFix: true', () => {
|
||||||
|
const s = summarizeMulticast({
|
||||||
|
defaultSettings: { igmpSnoopingEnabled: true, floodUnknownMulticastTrafficEnabled: true },
|
||||||
|
overrides: [],
|
||||||
|
});
|
||||||
|
assert.equal(s.needsFix, true);
|
||||||
|
assert.equal(s.defaultSnoopOn, true);
|
||||||
|
assert.equal(s.defaultFloodOff, false);
|
||||||
|
assert.equal(s.deviatingOverrides.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('flood off at default → needsFix: true', () => {
|
||||||
|
const s = summarizeMulticast({
|
||||||
|
defaultSettings: { igmpSnoopingEnabled: false, floodUnknownMulticastTrafficEnabled: false },
|
||||||
|
overrides: [],
|
||||||
|
});
|
||||||
|
assert.equal(s.needsFix, true);
|
||||||
|
assert.equal(s.defaultSnoopOn, false);
|
||||||
|
assert.equal(s.defaultFloodOff, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('both default settings wrong → both flags true', () => {
|
||||||
|
const s = summarizeMulticast({
|
||||||
|
defaultSettings: { igmpSnoopingEnabled: true, floodUnknownMulticastTrafficEnabled: false },
|
||||||
|
overrides: [],
|
||||||
|
});
|
||||||
|
assert.equal(s.needsFix, true);
|
||||||
|
assert.equal(s.defaultSnoopOn, true);
|
||||||
|
assert.equal(s.defaultFloodOff, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('default correct + one override with snoop=true → needsFix: true', () => {
|
||||||
|
const s = summarizeMulticast({
|
||||||
|
defaultSettings: { igmpSnoopingEnabled: false, floodUnknownMulticastTrafficEnabled: true },
|
||||||
|
overrides: [
|
||||||
|
{ switches: ['Q234-ABCD-0001'], igmpSnoopingEnabled: true, floodUnknownMulticastTrafficEnabled: true },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
assert.equal(s.needsFix, true);
|
||||||
|
assert.equal(s.defaultOk, true);
|
||||||
|
assert.equal(s.deviatingOverrides.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('default correct + override that matches target exactly → needsFix: false', () => {
|
||||||
|
// An override that duplicates the default is redundant but harmless.
|
||||||
|
// We don't nag the operator about it because there's no user-visible
|
||||||
|
// impact — the switch behaves identically to no override.
|
||||||
|
const s = summarizeMulticast({
|
||||||
|
defaultSettings: { igmpSnoopingEnabled: false, floodUnknownMulticastTrafficEnabled: true },
|
||||||
|
overrides: [
|
||||||
|
{ switches: ['Q234-ABCD-0001'], igmpSnoopingEnabled: false, floodUnknownMulticastTrafficEnabled: true },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
assert.equal(s.needsFix, false);
|
||||||
|
assert.equal(s.deviatingOverrides.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('default correct + override with flood=false only → needsFix: true', () => {
|
||||||
|
const s = summarizeMulticast({
|
||||||
|
defaultSettings: { igmpSnoopingEnabled: false, floodUnknownMulticastTrafficEnabled: true },
|
||||||
|
overrides: [
|
||||||
|
{ switches: ['Q234-ABCD-0001'], igmpSnoopingEnabled: false, floodUnknownMulticastTrafficEnabled: false },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
assert.equal(s.needsFix, true);
|
||||||
|
assert.equal(s.deviatingOverrides.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('default correct + multiple overrides mixed → deviatingOverrides only counts the bad ones', () => {
|
||||||
|
const s = summarizeMulticast({
|
||||||
|
defaultSettings: { igmpSnoopingEnabled: false, floodUnknownMulticastTrafficEnabled: true },
|
||||||
|
overrides: [
|
||||||
|
{ switches: ['A'], igmpSnoopingEnabled: false, floodUnknownMulticastTrafficEnabled: true }, // OK
|
||||||
|
{ switches: ['B'], igmpSnoopingEnabled: true, floodUnknownMulticastTrafficEnabled: true }, // bad
|
||||||
|
{ stacks: ['1'], igmpSnoopingEnabled: false, floodUnknownMulticastTrafficEnabled: false }, // bad
|
||||||
|
],
|
||||||
|
});
|
||||||
|
assert.equal(s.needsFix, true);
|
||||||
|
assert.equal(s.deviatingOverrides.length, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('malformed / missing defaultSettings → needsFix: false, raw preserved', () => {
|
||||||
|
// Renderer stays silent on this state — the caller sets
|
||||||
|
// data.multicast.error separately, so a broken read doesn't produce
|
||||||
|
// a bogus "everything is fine" warning line.
|
||||||
|
const s = summarizeMulticast({});
|
||||||
|
assert.equal(s.needsFix, false);
|
||||||
|
assert.equal(s.defaultSnoopOn, false);
|
||||||
|
assert.equal(s.defaultFloodOff, false);
|
||||||
|
assert.equal(s.deviatingOverrides.length, 0);
|
||||||
|
assert.deepEqual(s.raw, {});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('null / undefined input → does not throw, needsFix: false', () => {
|
||||||
|
assert.doesNotThrow(() => summarizeMulticast(null));
|
||||||
|
assert.doesNotThrow(() => summarizeMulticast(undefined));
|
||||||
|
assert.equal(summarizeMulticast(null).needsFix, false);
|
||||||
|
assert.equal(summarizeMulticast(undefined).needsFix, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('overrides field missing entirely → treated as empty', () => {
|
||||||
|
const s = summarizeMulticast({
|
||||||
|
defaultSettings: { igmpSnoopingEnabled: false, floodUnknownMulticastTrafficEnabled: true },
|
||||||
|
});
|
||||||
|
assert.equal(s.needsFix, false);
|
||||||
|
assert.equal(s.deviatingOverrides.length, 0);
|
||||||
|
});
|
||||||
|
|
@ -96,6 +96,89 @@ test('phone renderer: detailed mode reveals SIP details', () => {
|
||||||
assert.match(detailed, /Alt SIPs: sip:a@x, sip:b@x…/);
|
assert.match(detailed, /Alt SIPs: sip:a@x, sip:b@x…/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// IGMP snooping / DECT-safe multicast warning line
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('phone renderer: multicast warning line includes every deviation kind', () => {
|
||||||
|
const data = {
|
||||||
|
dectBasestations: [
|
||||||
|
{ mac: 'aa:bb:cc:dd:ee:ff', meraki: { status: 'Online' } },
|
||||||
|
],
|
||||||
|
dectHandsets: [],
|
||||||
|
multicast: {
|
||||||
|
needsFix: true,
|
||||||
|
defaultSnoopOn: true,
|
||||||
|
defaultFloodOff: true,
|
||||||
|
deviatingOverrides: [{}, {}],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const md = renderPhoneStatusMarkdown(data, { storeNum: '782', footer: false });
|
||||||
|
assert.match(md, /\*\*Multicast:\*\* ⚠️/);
|
||||||
|
assert.match(md, /IGMP snoop=ON default/);
|
||||||
|
assert.match(md, /flood-unknown=OFF default/);
|
||||||
|
assert.match(md, /2 switch override\(s\) deviate/);
|
||||||
|
assert.match(md, /may disrupt DECT/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('phone renderer: multicast line names only the snoop deviation when that is the only problem', () => {
|
||||||
|
const data = {
|
||||||
|
dectBasestations: [
|
||||||
|
{ mac: 'aa:bb:cc:dd:ee:ff', meraki: { status: 'Online' } },
|
||||||
|
],
|
||||||
|
dectHandsets: [],
|
||||||
|
multicast: {
|
||||||
|
needsFix: true,
|
||||||
|
defaultSnoopOn: true,
|
||||||
|
defaultFloodOff: false,
|
||||||
|
deviatingOverrides: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const md = renderPhoneStatusMarkdown(data, { storeNum: '782', footer: false });
|
||||||
|
assert.match(md, /\*\*Multicast:\*\* ⚠️ IGMP snoop=ON default — may disrupt DECT/);
|
||||||
|
assert.doesNotMatch(md, /flood-unknown/);
|
||||||
|
assert.doesNotMatch(md, /switch override/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('phone renderer: multicast line is ABSENT when needsFix is false', () => {
|
||||||
|
const data = {
|
||||||
|
dectBasestations: [
|
||||||
|
{ mac: 'aa:bb:cc:dd:ee:ff', meraki: { status: 'Online' } },
|
||||||
|
],
|
||||||
|
dectHandsets: [],
|
||||||
|
multicast: {
|
||||||
|
needsFix: false,
|
||||||
|
defaultSnoopOn: false,
|
||||||
|
defaultFloodOff: false,
|
||||||
|
deviatingOverrides: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const md = renderPhoneStatusMarkdown(data, { storeNum: '782', footer: false });
|
||||||
|
assert.doesNotMatch(md, /\*\*Multicast:\*\*/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('phone renderer: multicast line is ABSENT when there are no DECT basestations', () => {
|
||||||
|
// Even if needsFix is true, the warning lives INSIDE the DECT section
|
||||||
|
// which itself is gated on dectBasestations.length > 0. Stores with
|
||||||
|
// no DECT get no multicast noise — it's simply not their problem.
|
||||||
|
const data = {
|
||||||
|
phones: {
|
||||||
|
data: [{ displayName: 'DESK', status: 'connected', lastSeen: threeHrAgo() }],
|
||||||
|
},
|
||||||
|
dectBasestations: [],
|
||||||
|
multicast: {
|
||||||
|
needsFix: true,
|
||||||
|
defaultSnoopOn: true,
|
||||||
|
defaultFloodOff: true,
|
||||||
|
deviatingOverrides: [{}],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const md = renderPhoneStatusMarkdown(data, { storeNum: '782', footer: false });
|
||||||
|
assert.doesNotMatch(md, /\*\*Multicast:\*\*/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
test('av renderer: header always says (Mode: detailed)', () => {
|
test('av renderer: header always says (Mode: detailed)', () => {
|
||||||
const md = renderAvStatusMarkdown({}, { storeNum: '782', footer: false });
|
const md = renderAvStatusMarkdown({}, { storeNum: '782', footer: false });
|
||||||
assert.match(md, /^\*\*Device Status - Store 782\*\* \(Mode: detailed\)/);
|
assert.match(md, /^\*\*Device Status - Store 782\*\* \(Mode: detailed\)/);
|
||||||
|
|
|
||||||
67
utils/pendingIgmpFixes.js
Normal file
67
utils/pendingIgmpFixes.js
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
// src/utils/pendingIgmpFixes.js
|
||||||
|
//
|
||||||
|
// In-memory store for pending IGMP-snooping / multicast-fix
|
||||||
|
// confirmation cards. Identical shape to pendingHostAssigns — every
|
||||||
|
// entry is stamped with a `timestamp` on insert, and a background
|
||||||
|
// sweep expires un-acted cards after TTL_MS so the map never leaks.
|
||||||
|
//
|
||||||
|
// Entries typically hold:
|
||||||
|
// {
|
||||||
|
// networkId, networkName, storeNum,
|
||||||
|
// requester, // from extractRequester(trigger)
|
||||||
|
// summary, // { defaultSnoopOn, defaultFloodOff, deviatingOverrides.length }
|
||||||
|
// timestamp, // auto-set
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// TTL is deliberately longer than pendingHostAssigns because a
|
||||||
|
// multicast change often gets a "hold on, let me check with the
|
||||||
|
// store first" pause before someone actually clicks. 15 minutes is
|
||||||
|
// enough for a normal handoff without the card staying live long
|
||||||
|
// enough for a stale click to fire against a since-fixed network.
|
||||||
|
|
||||||
|
import { logger } from './logger.js';
|
||||||
|
|
||||||
|
const TTL_MS = 15 * 60 * 1000; // expire un-acted cards after 15 min
|
||||||
|
const SWEEP_INTERVAL_MS = 60 * 1000; // check once a minute
|
||||||
|
|
||||||
|
const _store = new Map();
|
||||||
|
|
||||||
|
export const pendingIgmpFixes = {
|
||||||
|
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('igmp:cleanup', `Expired card ${cardId} for store ${data?.storeNum || 'unknown'} network ${data?.networkName || 'unknown'}`);
|
||||||
|
_store.delete(cardId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, SWEEP_INTERVAL_MS);
|
||||||
|
|
||||||
|
sweepHandle.unref?.();
|
||||||
Loading…
Reference in a new issue