collabSupport/integrations/meraki/clients.js
jmcqueen 351f89a9a4 Initial commit: CollabFinder Webex bot
Multi-integration Webex chat/HTTP bot that unifies phone, AV, and
network status for retail store support. Consolidates data from
Webex Calling, Meraki, Workspace ONE (MDM), Atlas AMP, RED digital
signage, and OptiSigns into rich per-store status commands.

Key surfaces:
- /phonestatus, /avstatus — per-store phone & AV device reports with
  clickable Meraki deep-links and per-port detail.
- /webexhost — check/assign Webex Meetings host licenses via the
  Service App; adaptive-card confirmation flow, HTTP-API-gated.
- /offboarduser — full Webex Admin offboarding (auth revoke, device
  wipe, license removal); adaptive-card confirmation.
- /jirapoll — on-demand trigger for the hourly Jira poller.
- /bulkavstatuscsv — bulk store CSV export with concurrency limits.

Automation:
- Hourly Jira poller (node-cron) with an X.AI (Grok) ticket classifier
  that categorizes unassigned tickets as phone/av/skip, extracts store
  numbers from free-text, and enriches Jira with the same detailed
  markdown the chat commands emit (converted to Jira ADF, preserves
  bold + Meraki links). Idempotent via a `bot-enriched` Jira label.

Architecture:
- Node.js 20+, ESM, Express 5, webex-node-bot-framework.
- Layered integrations (integrations/*), services (services/*),
  commands (commands/*), utils (utils/*).
- Shared markdown renderers (services/renderers/*) feed both chat
  handlers and the Jira poller so the two surfaces stay in sync.
- Hand-rolled markdown-to-ADF converter (utils/markdownToAdf.js) —
  no new npm dependency.
- Node built-in test runner (`node --test tests/*.test.js`), 30 tests
  covering the converter, renderers, and poller ADF assembly.

Docker + docker-compose deployment. Config via .env
(see .env.example for the full option surface).
2026-07-01 16:55:03 -04:00

323 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 || [],
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
};