collabSupport/integrations/meraki/clients.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

329 lines
No EOL
11 KiB
JavaScript

// 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&timespan=${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
};