- 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>
144 lines
4 KiB
JavaScript
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,
|
|
};
|