// 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, };