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>
329 lines
No EOL
11 KiB
JavaScript
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×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
|
|
}; |