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>
494 lines
20 KiB
JavaScript
494 lines
20 KiB
JavaScript
// 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,
|
|
};
|