netanalyzer/services/meraki.js
Joseph McQueen 0a46d25bd6 chore: project cleanup — dead code, logging, tests, WS hardening
- Delete unused dataMerger, formatter, Device model, dead service exports,
  and the empty agents/ + .gitkeep placeholders.
- Extract STORE_MODES and MDM device-type filters into a shared constants.js.
- Anchor bot regexes (^help|^store|^analyze) so "analyze store 305" no
  longer fires both handlers; replace catch-all noise.
- Hoist inline require() calls in integrations to top-of-file imports.
- Harden WebSocket server: Authorization header support, single-agent
  enforcement, bounded pending requests, server-level error handler,
  coalesced cache refresh in Meraki client.
- Wrap Meraki/MDM network calls with withRetry; add request timeouts.
- Migrate all console.* calls onto utils/logger.js (LOG_LEVEL aware);
  drive Webex framework logLevel from env.
- Refactor storeDetail.js: shared renderClientLine + buildMdmSection
  helpers cut duplication roughly in half.
- Refresh README structure, document LOG_LEVEL, add npm run agent script,
  add jest testMatch + new tests (handlers, HealthReport, Store, ws).

Verified: npm run lint clean, 7 suites / 31 tests passing.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 17:21:22 -04:00

144 lines
4 KiB
JavaScript

const axios = require('axios');
const config = require('../config');
const { parseStoreNumber } = require('../utils/validate');
const { withRetry } = require('../utils/retry');
const logger = require('../utils/logger');
const merakiAxios = axios.create({
baseURL: config.meraki.baseUrl,
headers: {
'X-Cisco-Meraki-API-Key': config.meraki.apiKey,
'Content-Type': 'application/json',
},
timeout: 20000,
});
// In-memory cache
let cachedNetworks = [];
let lastCacheTime = 0;
let inFlightRefresh = null;
const CACHE_TTL_MS = 4 * 60 * 60 * 1000; // 4 hours
const RETRY_OPTS = { retries: 2, initialDelayMs: 500 };
async function refreshMerakiNetworksCache() {
// Coalesce concurrent callers onto a single in-flight refresh.
if (inFlightRefresh) return inFlightRefresh;
const start = Date.now();
logger.info('Refreshing Meraki networks cache');
inFlightRefresh = (async () => {
try {
const url = `/organizations/${config.meraki.orgId}/networks?perPage=5000`;
const res = await withRetry(() => merakiAxios.get(url), RETRY_OPTS);
cachedNetworks = res.data || [];
lastCacheTime = Date.now();
logger.info('Meraki networks cache refreshed', {
count: cachedNetworks.length,
durationMs: Date.now() - start,
});
} catch (err) {
logger.error('Failed to refresh Meraki networks cache', { error: err.message });
// Keep the (possibly stale) cache and let callers decide what to do.
} finally {
inFlightRefresh = null;
}
})();
return inFlightRefresh;
}
async function getMerakiNetworks(forceRefresh = false) {
const now = Date.now();
if (forceRefresh || cachedNetworks.length === 0 || now - lastCacheTime > CACHE_TTL_MS) {
await refreshMerakiNetworksCache();
}
return cachedNetworks;
}
async function findMerakiNetwork(storeNum) {
const normalized = parseStoreNumber(storeNum);
if (!normalized) return null;
// Many store networks in Meraki are named with a 5-digit padded store number.
const searchTerm = normalized.padStart(5, '0').slice(-5);
const networks = await getMerakiNetworks();
logger.debug('Searching Meraki networks', { storeNum: normalized, searchTerm });
let bestMatch = null;
let bestScore = -1;
for (const net of networks) {
const name = (net.name || '').toLowerCase();
const term = searchTerm.toLowerCase();
if (!name.includes(term)) continue;
const score =
name.includes(` ${term}`) || name.includes(`-${term}`) || name.includes(`${term} `)
? 100
: 50;
if (score > bestScore) {
bestScore = score;
bestMatch = net;
}
}
if (bestMatch) {
logger.info('Best Meraki network match', { name: bestMatch.name, id: bestMatch.id });
} else {
logger.info('No Meraki network match', { storeNum });
}
return bestMatch;
}
async function getMerakiDeviceAvailabilities(networkId) {
if (!networkId || !config.meraki.orgId) return [];
try {
const res = await withRetry(
() =>
merakiAxios.get(`/organizations/${config.meraki.orgId}/devices/availabilities`, {
params: { networkIds: [networkId] },
}),
RETRY_OPTS
);
return res.data || [];
} catch (err) {
logger.error('Device availabilities failed', { error: err.message });
return [];
}
}
async function getMerakiClients(networkId) {
if (!networkId) return [];
try {
// 7-day window catches registers and printers that only check in occasionally.
const timespanSeconds = 7 * 24 * 60 * 60;
const res = await withRetry(
() =>
merakiAxios.get(`/networks/${networkId}/clients`, {
params: { perPage: 1000, timespan: timespanSeconds },
}),
RETRY_OPTS
);
const clients = res.data || [];
logger.info('Fetched Meraki clients', { count: clients.length, networkId });
return clients;
} catch (err) {
logger.error('Failed to fetch Meraki clients', { error: err.message, networkId });
return [];
}
}
module.exports = {
findMerakiNetwork,
getMerakiClients,
getMerakiDeviceAvailabilities,
refreshMerakiNetworksCache,
};