// src/integrations/meraki/clients.js import { fetchAllPages } from './client.js'; import { logger } from '../../utils/logger.js'; import { findMerakiNetwork } from './networks.js'; import { merakiAxios } from './client.js'; import { findBestMerakiClientMatch } from '../../services/enrichment/merakiMatcher.js'; import { attachMerakiClientWithPorts } from '../../services/enrichment/merakiEnrichment.js'; /** * Get all clients from a network */ export async function getMerakiClients(networkId, timespanDays = 7) { logger('meraki:clients', `Fetching clients for network ${networkId} (timespan: ${timespanDays} days)`, 'debug'); if (!networkId) { logger('meraki:clients', 'No networkId provided', 'warn'); return []; } try { const timespanSeconds = timespanDays * 24 * 60 * 60; const url = `/networks/${networkId}/clients?perPage=5000×pan=${timespanSeconds}`; const clients = await fetchAllPages(url); logger('meraki:clients', `Fetched ${clients.length} clients from network ${networkId}`, 'debug'); return clients; } catch (err) { logger('meraki:clients', `Error fetching clients for network ${networkId}: ${err.message}`, 'error'); return []; } } /** * Get clients for a specific store — now returns { clients, network } */ export async function getClientsForStore(storeNumber, timespanDays = 7) { const network = await findMerakiNetwork(storeNumber); // ← full object if (!network) { logger('meraki:clients', `No network found for store ${storeNumber}`); return { clients: [], network: null, networkId: null }; } const clients = await getMerakiClients(network.id, timespanDays); return { clients, network, // ← full network object with .url networkId: network.id }; } /** * Get detailed port status and configuration for a network * Only queries MS switches (Meraki Switch devices) */ export async function getMerakiPorts(networkId) { logger('meraki:ports', `Fetching port configurations for network ${networkId}`, 'debug'); if (!networkId) { logger('meraki:ports', 'No networkId provided', 'warn'); return []; } try { const devicesUrl = `/networks/${networkId}/devices`; const devices = await fetchAllPages(devicesUrl); let allPorts = []; for (const device of devices) { // Only MS switches support switch ports endpoint if (!device.model || !device.model.startsWith('MS')) { logger('meraki:ports', `Skipping non-switch device ${device.serial} (${device.model || 'unknown'})`, 'debug'); continue; } logger('meraki:ports', `Fetching ports for MS switch ${device.serial} (${device.model})`); try { const portsUrl = `/devices/${device.serial}/switch/ports`; const ports = await fetchAllPages(portsUrl); const enrichedPorts = ports.map(port => ({ deviceSerial: device.serial, deviceName: device.name || device.model, model: device.model, portId: port.portId, portNumber: port.number, enabled: port.enabled, status: port.status || 'unknown', poeEnabled: port.poeEnabled, poePower: port.poePower || 0, accessPolicy: port.accessPolicy, stickyMac: port.stickyMac || false, allowedMacs: port.allowedMacs || [], // 'access' | 'trunk' | undefined. Preserved so /voicediag // port-hygiene checks can flag phones that end up on a // trunk uplink (downstream through a non-Meraki switch, // typically a Cisco stack) where per-port policy isn't // visible from our side. portType: port.type || null, voiceVlan: port.voiceVlan, dataVlan: port.vlan, portName: port.name || `Port ${port.number}`, errors: port.errors || [], packetErrors: { rxErrors: port.rxErrors || 0, txErrors: port.txErrors || 0, collisions: port.collisions || 0 }, lastUpdated: port.lastUpdated || null })); allPorts = allPorts.concat(enrichedPorts); } catch (portErr) { logger('meraki:ports', `Failed to fetch ports for device ${device.serial}: ${portErr.message}`, 'warn'); // Continue with other devices } } logger('meraki:ports', `Total ports collected from MS switches: ${allPorts.length}`, 'debug'); return allPorts; } catch (err) { logger('meraki:ports', `Error fetching port data for network ${networkId}: ${err.message}`, 'error'); return []; } } export async function getPortsForStore(storeNumber, relevantSwitchSerials = null) { logger('meraki:ports', `Getting ports for store ${storeNumber}`, 'debug'); const network = await findMerakiNetwork(storeNumber); const networkId = network?.id; if (!networkId) { logger('meraki:ports', `No network found for store ${storeNumber}`, 'warn'); return []; } try { // Get all devices in the network const devices = await fetchAllPages(`/networks/${networkId}/devices`); let msSwitches = devices.filter(d => d.model && d.model.startsWith('MS')); if (relevantSwitchSerials && relevantSwitchSerials.size > 0) { msSwitches = msSwitches.filter(sw => relevantSwitchSerials.has(sw.serial)); logger('meraki:ports', `Filtered to ${msSwitches.length} relevant switches for ports (to reduce rate limits)`); } let allPorts = []; for (const sw of msSwitches) { try { logger('meraki:ports', `Fetching ports for switch ${sw.serial} (${sw.name || sw.model})`, 'debug'); const ports = await fetchAllPages(`/devices/${sw.serial}/switch/ports`); const enrichedPorts = ports.map(p => ({ ...p, deviceSerial: sw.serial, deviceName: sw.name || sw.model || 'Unknown Switch', model: sw.model, // Explicitly map known fields portId: p.portId || p.number, status: p.status || (p.enabled ? 'Enabled' : 'Disabled'), // fallback accessPolicy: p.accessPolicy || null, // may still be missing stickyMac: p.stickyMac || false, allowedMacs: p.allowedMacs || [], })); allPorts = allPorts.concat(enrichedPorts); } catch (err) { logger('meraki:ports', `Error fetching ports for ${sw.serial}: ${err.message}`, 'warn'); } } logger('meraki:ports', `Total ports collected from MS switches: ${allPorts.length}`, 'debug'); return allPorts; } catch (err) { logger('meraki:ports', `Error fetching ports for store ${storeNumber}: ${err.message}`, 'error'); return []; } } /** * Get full Layer-2 topology (nodes + links) from Meraki * Uses the official Topology API you asked about */ export async function getLinkLayerTopology(networkId) { if (!networkId) { logger('meraki:topology', 'No networkId provided', 'warn'); return { nodes: [], links: [], errors: [] }; } try { logger('meraki:topology', `Fetching linkLayer topology for network ${networkId}`); // This endpoint is NOT paginated → single call const url = `/networks/${networkId}/topology/linkLayer`; const response = await merakiAxios.get(url); // uses your existing axios instance const topology = response.data || { nodes: [], links: [], errors: [] }; logger('meraki:topology', `✅ Received ${topology.nodes?.length || 0} nodes and ${topology.links?.length || 0} links`); return topology; } catch (err) { logger('meraki:topology', `Failed to fetch topology: ${err.message}`, 'error'); return { nodes: [], links: [], errors: [err.message] }; } } export async function getWirelessClientConnectionStats(networkId, clientId) { const timespan = 86400; // 24 hours. Use 604800 for full 7 days if you want more history try { const response = await merakiAxios.get( `/networks/${networkId}/wireless/clients/${clientId}/connectionStats?timespan=${timespan}` ); logger('meraki:clients', 'Connection stats response received'); // Extract the nested connectionStats object, or return empty const rawStats = response.data?.connectionStats || {}; return { assoc: rawStats.assoc || 0, auth: rawStats.auth || 0, dhcp: rawStats.dhcp || 0, dns: rawStats.dns || 0, success: rawStats.success || 0 }; } catch (err) { logger('meraki', `Connection stats failed for ${clientId}: ${err.message}`, 'warn'); return { assoc: 0, auth: 0, dhcp: 0, dns: 0, success: 0 }; } } export async function getWirelessClientHealthScores(networkId, clientId) { try { const response = await merakiAxios.get( `/networks/${networkId}/wireless/clients/${clientId}/healthScores` ); logger('meraki:clients', 'Health scores response received'); return response.data || {}; } catch (err) { logger('meraki', `Health scores failed for ${clientId}: ${err.message}`, 'warn'); return {}; } } /** * Get port configuration for a specific switch port */ export async function getSwitchPortConfig(serial, portId) { try { const res = await merakiAxios.get(`/devices/${serial}/switch/ports/${portId}`); return res.data; } catch (err) { logger('meraki:ports', `Port config failed for ${serial}:${portId}`, 'warn'); return null; } } /** * Get port status for a specific switch port (uses cached statuses per switch) * Note: We still use the builder's portStatusCache for now, but this can be moved later if desired. */ export async function getSwitchPortStatus(serial, portId, portStatusCache) { if (!portStatusCache.has(serial)) { try { const res = await merakiAxios.get(`/devices/${serial}/switch/ports/statuses`); portStatusCache.set(serial, res.data || []); } catch (err) { logger('meraki:ports', `Port statuses failed for switch ${serial}`, 'warn'); portStatusCache.set(serial, []); } } const statuses = portStatusCache.get(serial) || []; return statuses.find(p => String(p.portId || p.number) === String(portId)) || null; } /** * Get *all* port statuses for a switch in one call (batch /statuses). * Returns the array directly (for use in switches map for chat path etc). */ export async function getSwitchPortsStatuses(serial) { try { const res = await merakiAxios.get(`/devices/${serial}/switch/ports/statuses`); return res.data || []; } catch (err) { logger('meraki:ports', `Failed to get port statuses for switch ${serial}: ${err.message}`, 'warn'); return []; } } /** * Enrich a single AV device with Meraki client + port data * This centralizes all client matching and port fetching */ export async function enrichDeviceWithMeraki(device, allMerakiClients, portStatusCache) { // Delegate fully to shared (unified) // Note: portStatusCache passed through await attachMerakiClientWithPorts(device, allMerakiClients, [], portStatusCache); // portConfigs empty here, or pass if available // The shared attachMerakiClientWithPorts will set client + ports // We keep wireless null as before if (device.meraki) { device.meraki.wirelessDetails = device.meraki.wirelessDetails || null; device.meraki.wirelessSummary = device.meraki.wirelessSummary || null; } return device; } export default { getMerakiClients, getClientsForStore, getMerakiPorts, getPortsForStore, getWirelessClientConnectionStats, getWirelessClientHealthScores, getSwitchPortConfig, getSwitchPortStatus, getSwitchPortsStatuses, enrichDeviceWithMeraki // new };