collabSupport/services/enrichment/merakiEnrichment.js
jmcqueen d12723d010 Voicediag: store voice standards + port-hygiene checks + apply-all card
Refactor every /voicediag check to declare a top-level `standards`
object so the desired state is legible without reading run() logic
and can drive a documented reference table. Upgrade callForwarding
to error severity, tighten voicemail with three send-to-VM error
paths + a `stop_sending_to_voicemail` remediation, and add a
`disable_hoteling` remediation.

Add a port-hygiene check bucket under services/voiceDiag/checks/port
(portType, portVlan, portPoe, portEnabled) that reuses the phone-
status snapshot to enforce switchport standards. Configurable via
VOICE_STANDARD_PHONE_VLAN (default 102) and VOICE_STANDARD_ENABLED
(kill-switch). Preserve Meraki `portType`/`voiceVlan`/`dataVlan`
through the enrichment chain so the checks have clean data to read.

Add an "apply all N fixes" combined card that shows up when 2+
remediations are available. New confirm_voicediag_all /
cancel_voicediag_all actions run each fix in sequence (readable
audit trail, no per-person write-throttle stacking), accumulate
individual failures into a summary rather than aborting.

Adds regression tests asserting every check exposes .standards,
plus coverage for port checks, kill-switch, and combined-card
iteration. 63 tests in the checks file, 188 total, all green.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 14:14:38 -04:00

494 lines
20 KiB
JavaScript

// services/enrichment/merakiEnrichment.js
//
// Shared Meraki enrichment helpers for AV devices.
// Extracted to reduce duplication between deviceService (chat/collect) and avDeviceBuilder (rich build).
// avEnricher relies on pre-enriched data from collect.
//
// Includes:
// - attachMerakiClient (basic client match + common fields + url)
// - enrichWirelessDetails (the detailed wireless summary used in builder)
// - enrichMerakiDeviceDetails (switches + APs details, relevant-only in callers)
// - enrichSwitchesAndAPsShared (relevant switches+APs with port statuses for chat + wireless; used by deviceService)
// - Re-exports or helpers for switches/APs if we pull more.
//
// Uses shared matcher.
import { findBestMerakiClientMatch } from './merakiMatcher.js';
import { getMerakiWirelessStatus, getMerakiDeviceDetail } from '../../integrations/meraki/devices.js';
import { findMerakiNetwork } from '../../integrations/meraki/networks.js';
import {
getWirelessClientConnectionStats,
getWirelessClientHealthScores,
getSwitchPortConfig,
getSwitchPortStatus,
getSwitchPortsStatuses
} from '../../integrations/meraki/clients.js';
import {
getWirelessClientSignalQuality,
getWirelessClientLatency,
getWirelessClientFailedConnections
} from '../../integrations/meraki/devices.js';
import { logger } from '../../utils/logger.js';
/**
* Attach basic Meraki client data to a device (using shared matcher).
* Common across paths. Adds client, connectionType, optional clientUrl.
* Port status/config can be added by callers (differs slightly between chat and build).
*
* @param {Object} device - base device with identifier or friendlyName etc.
* @param {Array} allMerakiClients
* @param {string} [networkUrl=''] - for building clientUrl
* @returns {Object} the device (mutated)
*/
export function attachMerakiClient(device, allMerakiClients, networkUrl = '') {
const merakiClient = findBestMerakiClientMatch(device, allMerakiClients);
if (merakiClient) {
device.meraki = {
client: merakiClient,
connectionType: merakiClient.recentDeviceConnection === 'Wireless' ||
merakiClient.ssid ? 'Wireless' : 'Wired',
};
if (networkUrl && merakiClient.id) {
device.meraki.clientUrl = buildMerakiClientUrl(merakiClient.id, { url: networkUrl });
}
} else {
device.meraki = {
client: null,
connectionType: 'Unknown'
};
}
return device;
}
/**
* Full wireless enrichment (extracted and shared from builder).
* Used for wireless clients in rich builds.
*/
export async function enrichWirelessDetails(devices, networkId) {
if (!networkId) return devices;
for (const device of devices) {
const client = device.meraki?.client;
if (device.meraki?.connectionType !== 'Wireless' || !client?.id) {
continue;
}
try {
const [connStats, health, signalQuality, latency, failedConns] = await Promise.allSettled([
getWirelessClientConnectionStats(networkId, client.id),
getWirelessClientHealthScores(networkId, client.id),
getWirelessClientSignalQuality(networkId, client.id),
getWirelessClientLatency(networkId, client.id),
getWirelessClientFailedConnections(networkId, client.id)
]);
let connectionDurationSeconds = null;
if (client.firstSeen) {
const firstSeenTime = new Date(client.firstSeen).getTime();
connectionDurationSeconds = Math.floor((Date.now() - firstSeenTime) / 1000);
}
device.meraki.wirelessSummary = {
ssid: client.ssid || "—",
apName: client.recentDeviceName || "—",
apSerial: client.recentDeviceSerial || "—",
rssi: signalQuality.status === 'fulfilled' ? signalQuality.value.rssi : null,
snr: signalQuality.status === 'fulfilled' ? signalQuality.value.snr : null,
avgLatencyMs: latency.status === 'fulfilled' ? latency.value.avgLatencyMs : null,
txRate: client.txRate || null,
rxRate: client.rxRate || null,
connectionDurationSeconds,
connectionDurationDisplay: connectionDurationSeconds && connectionDurationSeconds < 86400 * 30
? `${Math.floor(connectionDurationSeconds / 3600)}h ${Math.floor((connectionDurationSeconds % 3600) / 60)}m`
: "Long-term (>30 days)",
connectionSuccess: connStats.status === 'fulfilled' ? (connStats.value?.success || 0) : 0,
failedConnectionsCount: failedConns.status === 'fulfilled' ? (failedConns.value?.length || 0) : 0
};
device.meraki.wirelessDetails = {
connectionStats: connStats.status === 'fulfilled' ? connStats.value : null,
healthScores: health.status === 'fulfilled' ? health.value : null,
signalQuality: signalQuality.status === 'fulfilled' ? signalQuality.value : null,
latency: latency.status === 'fulfilled' ? latency.value : null,
failedConnections: failedConns.status === 'fulfilled' ? failedConns.value : []
};
} catch (err) {
logger('av:meraki', `Wireless enrichment failed for ${device.identifier}`, 'warn');
}
}
return devices;
}
/**
* Enrich list of Meraki devices (switches/APs) with details and wireless status.
* Extracted from avDeviceBuilder.
*/
export async function enrichMerakiDeviceDetails(networkId, allMerakiDevicesList) {
if (!networkId || !allMerakiDevicesList?.length) return {};
const detailsMap = {};
logger('av:meraki', `Enriching ${allMerakiDevicesList.length} Meraki devices (switches + APs details${allMerakiDevicesList.length < 10 ? ' (relevant subset)' : ''})`);
for (const dev of allMerakiDevicesList) {
const serial = dev.serial;
if (!serial) continue;
const isAP = (dev.model || dev.productType || '').toUpperCase().startsWith('MR'); // MR = Meraki wireless AP
try {
const calls = [getMerakiDeviceDetail(serial)];
if (isAP) {
calls.push(getMerakiWirelessStatus(serial));
}
const results = await Promise.allSettled(calls);
const basicRes = results[0];
const wirelessRes = isAP ? results[1] : null;
const fullDevice = basicRes.status === 'fulfilled' ? basicRes.value : dev;
if (wirelessRes && wirelessRes.status === 'fulfilled' && wirelessRes.value) {
fullDevice.wirelessStatus = wirelessRes.value;
}
detailsMap[serial] = fullDevice;
} catch (err) {
logger('av:meraki', `Failed enriching device ${serial}: ${err.message}`, 'warn');
detailsMap[serial] = dev;
}
}
return detailsMap;
}
/**
* Shared enrichment for switches + APs details, port statuses (for chat), wireless status (for APs).
* Computes *relevant* serials only from the provided client list (raw or enriched) to avoid rate limits/429s.
* Uses axios-based integrations (consistent, no SDK client needed).
* Returns maps in the shape expected by deviceService's enrichAllMerakiData (for attaching switchDetails/apDetails
* and for bulk port status lookup to dedup /statuses calls).
*
* Can be used by both collect (chat) and build paths.
*/
export async function enrichSwitchesAndAPsShared(networkId, clientList = []) {
const switches = new Map();
const aps = new Map();
if (!networkId) return { switches, aps };
// Collect relevant serials from raw client records (direct fields: recentDeviceSerial + recentDeviceConnection)
// or from enriched (meraki.recent... or top level after some processing).
const switchSerials = new Set();
const apSerials = new Set();
for (const dev of clientList || []) {
const m = dev.meraki || dev; // support raw client (flat recentDevice*) or post-enrich (under meraki or flat)
const recentSerial = m.recentDeviceSerial || m.recentDeviceSerial;
const conn = (m.recentDeviceConnection || m.connectionType || '').toLowerCase();
const isWireless = conn.includes('wireless');
if (m.switchSerial) switchSerials.add(m.switchSerial);
if (m.apSerial) apSerials.add(m.apSerial);
if (recentSerial) {
if (isWireless) {
apSerials.add(recentSerial);
} else {
switchSerials.add(recentSerial);
}
}
}
const allRelevant = new Set([...switchSerials, ...apSerials]);
if (allRelevant.size === 0) {
logger('av:meraki', 'No relevant switches/APs derived from clients (will return empty maps)');
return { switches, aps };
}
logger('av:meraki', `Enriching switches/APs for ${allRelevant.size} relevant serials (switches: ${switchSerials.size}, APs: ${apSerials.size}) from client list`);
// Enrich switches (detail + batch port *statuses* for the map used by chat port lookup)
for (const serial of switchSerials) {
try {
const deviceInfo = await getMerakiDeviceDetail(serial);
const portStatuses = await getSwitchPortsStatuses(serial);
switches.set(serial, {
serial,
name: deviceInfo.name || 'MS Switch',
model: deviceInfo.model,
status: deviceInfo.status || 'offline',
lastReportedAt: deviceInfo.lastReportedAt,
ports: portStatuses || [],
});
} catch (err) {
logger('device:enrich', `Failed to enrich switch ${serial}: ${err.message}`, 'warn');
switches.set(serial, { serial, name: 'Unknown Switch', status: 'offline', ports: [] });
}
}
// Enrich APs (detail + wireless status + channelInfo shape)
for (const serial of apSerials) {
try {
const [deviceInfo, wirelessStatus] = await Promise.allSettled([
getMerakiDeviceDetail(serial),
getMerakiWirelessStatus(serial)
]);
const dev = deviceInfo.status === 'fulfilled' ? deviceInfo.value : { serial };
const ws = wirelessStatus.status === 'fulfilled' ? wirelessStatus.value : null;
aps.set(serial, {
serial,
name: (ws && ws.name) || dev.name || 'MR Access Point',
status: (ws && ws.status) || dev.status || 'offline',
lastSeen: ws && ws.lastSeen,
basicServiceSets: (ws && ws.basicServiceSets) || [],
channelInfo: ((ws && ws.basicServiceSets) || []).map(bss => ({
ssid: bss.ssidName,
channel: bss.channel,
band: bss.band,
power: bss.power,
channelWidth: bss.channelWidth,
bssid: bss.bssid,
})),
});
} catch (err) {
logger('device:enrich', `Failed to enrich AP ${serial}: ${err.message}`, 'warn');
aps.set(serial, { serial, name: 'Unknown AP', status: 'offline', channelInfo: [] });
}
}
return { switches, aps };
}
/**
* Attach port config and status to a Meraki client entry.
* Extracted from enrichMerakiData and legacy enrichDeviceWithMeraki.
* portConfigs come from getPortsForStore result.
* portStatusCache is for getSwitchPortStatus (to batch /statuses fetches).
*/
export async function attachPortConfigAndStatus(client, portConfigs = [], portStatusCache = new Map()) {
const portNum = client.switchport;
const switchSerial = client.recentDeviceSerial;
const isWireless = (client.recentDeviceConnection || '').toLowerCase() === 'wireless';
let portConfig = null;
let portStatus = null;
if (switchSerial && portNum) {
portConfig = portConfigs.find(p =>
p.deviceSerial === switchSerial &&
String(p.portId || p.number) === String(portNum)
);
// Get real port status (async, uses cache)
try {
portStatus = await getSwitchPortStatus(switchSerial, portNum, portStatusCache);
} catch (e) {
logger('meraki:enrich', `Port status attach failed for ${switchSerial}:${portNum}`);
}
}
// Attach common port fields (merged into the client object)
Object.assign(client, {
portNumber: portNum || (isWireless ? '—' : '?'),
portName: portConfig?.portName || portConfig?.name || (isWireless ? 'Wireless (AP)' : '—'),
portEnabled: portConfig?.enabled,
speed: portConfig?.speed,
duplex: portConfig?.duplex,
poeEnabled: portConfig?.poeEnabled,
poePower: portConfig?.poePower || 0,
accessPolicy: portConfig?.accessPolicyType || portConfig?.accessPolicy || '—',
allowedMacs: portConfig?.stickyMacAllowList || portConfig?.allowedMacs || [],
// 'access' | 'trunk' | null. Consumed by /voicediag port-hygiene
// checks — trunk means the phone is downstream through a non-
// Meraki switch and per-port config isn't visible from here.
portType: portConfig?.portType || portConfig?.type || null,
voiceVlan: portConfig?.voiceVlan ?? null,
dataVlan: portConfig?.dataVlan ?? portConfig?.vlan ?? null,
switchportStatus: portStatus,
switchportConfig: portConfig,
switchSerial: switchSerial || '—',
deviceName: client.recentDeviceName || 'SWITCH',
connectionType: isWireless ? 'Wireless' : 'Wired',
});
return client;
}
/**
* Convenience: attach basic Meraki client + ports in one go (for paths that want ports immediately).
* Uses the shared client attach + port attach.
*/
export async function attachMerakiClientWithPorts(device, allMerakiClients, portConfigs = [], portStatusCache = new Map(), networkUrl = '') {
attachMerakiClient(device, allMerakiClients, networkUrl);
const client = device.meraki?.client;
if (client) {
await attachPortConfigAndStatus(client, portConfigs, portStatusCache);
// Merge back if needed (since we mutated client)
device.meraki.client = client;
}
return device;
}
/**
* Build client URL dynamically from networkInfo.url to avoid hardcoding dashboard host (e.g. n976).
*/
export function buildMerakiClientUrl(clientId, networkInfo) {
if (!clientId || !networkInfo?.url) return '';
const baseMatch = networkInfo.url.match(/^(https?:\/\/[^/]+)/);
const base = baseMatch ? baseMatch[1] : 'https://dashboard.meraki.com';
const networkShort = networkInfo.url.match(/dashboard\.meraki\.com\/([^/]+)/i)?.[1] || '';
const dashboardNode = networkInfo.url.match(/\/n\/([^/]+)/i)?.[1] || '';
if (networkShort && dashboardNode) {
return `${base}/${networkShort}/n/${dashboardNode}/manage/clients/${clientId}/overview`;
}
return '';
}
// ====================== CHAT-ORIENTED FULL MERAKI ENRICHMENT (moved from deviceService for core sharing) ======================
// Produces the "meraki" payload used by collectDeviceStatus (all filtered clients as devices + switches/aps maps).
// Used by core orchestrator and deviceService (thin).
async function enrichMerakiData(merakiClientsResult, merakiPortsResult, networkInfo, switchesForStatus = null) {
const clients = Array.isArray(merakiClientsResult.value)
? merakiClientsResult.value
: (merakiClientsResult.value?.clients || []);
const portConfigs = Array.isArray(merakiPortsResult.value) ? merakiPortsResult.value : [];
// Filter out VLAN 900
const filteredClients = clients.filter(c => c.vlan !== 900 && c.vlan !== '900');
// Use lookup from pre-fetched switches (bulk statuses now done in shared enrichSwitchesAndAPsShared) + shared for config
// This avoids extra /ports/statuses calls.
const enrichedClients = filteredClients.map((client) => {
const portNum = client.switchport;
const switchSerial = client.recentDeviceSerial;
const portConfig = portConfigs.find(p =>
p.deviceSerial === switchSerial &&
String(p.portId || p.number) === String(portNum)
);
let portStatus = null;
if (switchSerial && switchesForStatus && switchesForStatus.has(switchSerial)) {
const sw = switchesForStatus.get(switchSerial);
const portsList = sw?.ports || [];
portStatus = portsList.find(p => String(p.portId || p.number) === String(portNum)) || null;
}
const merakiClientUrl = buildMerakiClientUrl(client.id, networkInfo);
return {
...client,
isWireless: (client.recentDeviceConnection || '').toLowerCase() === 'wireless',
ssid: client.ssid || '—',
apName: client.recentDeviceName || '—',
apSerial: client.recentDeviceSerial || '—',
merakiClientUrl,
portNumber: portNum || ( (client.recentDeviceConnection || '').toLowerCase() === 'wireless' ? '—' : '?'),
portName: portConfig?.portName || portConfig?.name || ( (client.recentDeviceConnection || '').toLowerCase() === 'wireless' ? 'Wireless (AP)' : '—'),
portEnabled: portConfig?.enabled,
speed: portConfig?.speed,
duplex: portConfig?.duplex,
poeEnabled: portConfig?.poeEnabled,
poePower: portConfig?.poePower || 0,
accessPolicy: portConfig?.accessPolicyType || portConfig?.accessPolicy || '—',
allowedMacs: portConfig?.stickyMacAllowList || portConfig?.allowedMacs || [],
switchportStatus: portStatus,
switchportConfig: portConfig,
switchSerial: switchSerial || '—',
deviceName: client.recentDeviceName || 'SWITCH',
connectionType: (client.recentDeviceConnection || '').toLowerCase() === 'wireless' ? 'Wireless' : 'Wired',
networkShortName: networkInfo?.url ? networkInfo.url.match(/dashboard\.meraki\.com\/([^/]+)/i)?.[1] || '' : '',
dashboardNodeId: networkInfo?.url ? networkInfo.url.match(/\/n\/([^/]+)/i)?.[1] || '' : ''
};
});
return {
clients: enrichedClients,
networkInfo
};
}
/**
* Main Meraki enrichment for the chat/collect path (all clients + switches/APs maps).
* Now lives in enrichment for use by avEnrichmentCore and deviceService.
*/
export async function enrichAllMerakiData(networkId, merakiClientsResult, merakiPortsResult, networkInfo = null) {
// Use passed networkInfo if provided (from collectDeviceStatus which has storeNum)
// Falls back only for direct calls (no more dummy "2477")
if (!networkInfo || !networkInfo.url) {
networkInfo = { url: '' };
try {
// Fallback lookup (caller should pass to avoid)
const net = await findMerakiNetwork("");
if (net?.url) networkInfo.url = net.url;
} catch (e) {
logger('device:service', 'Could not get networkInfo for client links', 'warn');
}
}
// Switches/APs now use shared (computes relevant serials from *raw* clients directly, fetches only needed details+statuses+wireless).
// This + prior relevantSwitchSerials for ports + relevant device details = big reduction in Meraki calls (key to 429 fixes).
const clientsRaw = Array.isArray(merakiClientsResult.value) ? merakiClientsResult.value : (merakiClientsResult.value?.clients || []);
const portConfigs = Array.isArray(merakiPortsResult.value) ? merakiPortsResult.value : [];
const { switches, aps } = await enrichSwitchesAndAPsShared(networkId, clientsRaw);
// Step 1 (reordered): Client enrichment, with status lookup from switches map (no extra status fetch)
const clientEnriched = await enrichMerakiData(
merakiClientsResult,
merakiPortsResult,
networkInfo,
switches // pass for status lookup to avoid dupe fetches
);
// Step 3: Build final devices with enriched AP/Switch data
const enrichedDevices = (clientEnriched.clients || []).map(client => {
const m = client || {};
const device = {
id: `D${(client.description || client.user || client.mac || '').replace(/[^a-zA-Z0-9]/g, '')}`,
name: client.description || client.user || client.mac || 'Unknown',
meraki: { ...m },
red: client.red || {},
optisigns: client.optisigns || {},
atlas: null
};
if (m.apSerial && aps.has(m.apSerial)) {
device.apDetails = aps.get(m.apSerial);
device.meraki.apEnriched = true;
}
if (m.switchSerial && switches.has(m.switchSerial)) {
device.switchDetails = switches.get(m.switchSerial);
device.meraki.switchEnriched = true;
}
return device;
});
return {
devices: enrichedDevices,
switches: Array.from(switches.values()),
aps: Array.from(aps.values())
};
}
export default {
attachMerakiClient,
enrichWirelessDetails,
enrichMerakiDeviceDetails,
enrichSwitchesAndAPsShared,
enrichAllMerakiData,
attachPortConfigAndStatus,
attachMerakiClientWithPorts,
buildMerakiClientUrl,
};