// src/commands/bulkAvStatusCSV.js // // /bulkavstatuscsv — one-shot bulk AV device status report, delivered as // a CSV attachment to the Webex room the command was invoked from. // // Data flow per invocation: // 1. Pull every device from MDM (no filters). // 2. Bucket devices by store number derived from DeviceFriendlyName. // 3. For each store bucket, in parallel (concurrency STORE_CONCURRENCY): // - Fetch Meraki clients for the store's network. // - Fetch Meraki port config for MS switches in that network. // - For each MDM device, try to match a Meraki client and (if wired) // the specific port it lives on. // 4. Concatenate all rows, ship as one CSV attachment. // // Design notes: // - `bot.say` is used for progress messages (framework-scoped to the // invoking room). Final CSV is posted via `botClient.sendWithAttachment` // because the framework's own attachment helpers require reading from // disk; sending a Buffer is easier through the singleton. // - The command is registered `http: false` — delivery requires a // Webex roomId for the attachment post, and we don't want the HTTP // path to run the full MDM+Meraki pipeline only to discover it can't // deliver the result. // - Store enrichment is resilient: a per-store try/catch means one flaky // store (Meraki 429, network hiccup, missing network) can't kill the // whole report — that store's devices get a "Fetch failed: " row // instead. A single unhandled exception in the outer try still aborts, // but only truly unexpected failures land there. import { logger } from '../utils/logger.js'; import { getMDMDevicesByPlatform } from '../integrations/mdm/client.js'; import { getClientsForStore, getPortsForStore } from '../integrations/meraki/clients.js'; import { findBestMerakiClientMatch } from '../services/enrichment/merakiMatcher.js'; import { normalizePlayerName } from '../utils/normalize.js'; import { simpleTimeAgo } from '../utils/time.js'; import botClient from '../integrations/webex/BotClient.js'; // Post a progress line every N completed stores. Small enough that a slow // enrichment run still shows movement; large enough not to spam the room. const PROGRESS_INTERVAL = 10; // Meraki v1 API rate limit is 10 req/sec/org; each store makes ~2 calls // (clients + ports), and getPortsForStore internally fans out per MS // switch. 5 stores in flight is well within budget and cuts wall-clock // vs. the old fully-sequential loop by roughly Nx. const STORE_CONCURRENCY = 5; // Upper bound on devices fetched from MDM in a single run. This is NOT a // functional limit — it's a runaway guard so a misconfigured MDM query or // a Workspace ONE regression can't pull an unbounded result set into // memory. Bump this if the AV fleet legitimately grows past it; we'll // also emit a warning log if a single run actually hits the cap so it's // visible instead of silently truncating. const MAX_MDM_DEVICES = 20000; // Kept as a single source of truth so the header line and each data row // can't drift out of sync. const CSV_COLUMNS = [ 'Store Number', 'Device Name (Username)', 'Location Group', 'Model', 'Serial Number', 'MDM Last Seen', 'Meraki Connection', 'IP', 'MAC', 'Meraki Last Seen', 'VLAN', 'Port', 'Port Name', 'Switch Name', 'Port Type', 'Port Status', 'Access Policy', 'Sticky MACs', 'POE', 'Errors', ]; // RFC 4180-ish CSV cell serializer: // - null / undefined / '' → visible placeholder so Excel doesn't leave // an empty column // - wraps every value in quotes // - doubles any embedded quotes // - flattens embedded CR/LF to a single space so a wrapped value can't // accidentally split the row (e.g. multi-line port error strings) function csvCell(v) { if (v === null || v === undefined || v === '') return '"—"'; const s = String(v).replace(/\r?\n/g, ' ').replace(/"/g, '""'); return `"${s}"`; } // Extract a store number from an MDM DeviceFriendlyName. Prefers explicit // 6- or 5-digit runs (typical AEO store IDs), falls back to any 2-4 digit // run zero-padded to 5. Returns null if no digits are present — the // caller should skip / count that device rather than crash. export function extractStoreNumber(rawName) { if (!rawName) return null; return ( rawName.match(/(\d{6})/)?.[1] || rawName.match(/(\d{5})/)?.[1] || rawName.match(/\d{2,4}/)?.[0]?.padStart(5, '0') || null ); } // Concurrency-limited async map with a rolling window (not fixed-size // batches). N runners each pull the next index off a shared cursor, so a // slow store never stalls the queue behind it. async function mapWithConcurrency(items, limit, worker) { const results = new Array(items.length); let cursor = 0; const runners = Array.from( { length: Math.min(limit, items.length) }, async () => { while (true) { const i = cursor++; if (i >= items.length) return; results[i] = await worker(items[i], i); } }, ); await Promise.all(runners); return results; } // Build the CSV rows for one store. Never throws — network failures are // absorbed into a per-device "Fetch failed" error cell so a single flaky // store can't discard the entire report. async function buildStoreRows(storeNum, storeDevices) { let clients = []; let ports = []; let fetchError = null; try { ({ clients = [] } = await getClientsForStore(storeNum)); ports = await getPortsForStore(storeNum); } catch (err) { fetchError = err.message || String(err); logger('bulk-av-csv', `Store ${storeNum} enrichment failed: ${fetchError}`, 'warn'); } const rows = []; for (const tv of storeDevices) { const username = tv.UserName || tv.userName || tv.User || 'Unknown'; const locationGroup = tv.LocationGroupName || tv.locationGroup || tv.LocationGroup || '—'; const rawNameForMatching = tv.DeviceFriendlyName || tv.friendlyName || username; const mdmLastSeen = tv.LastSeen ? simpleTimeAgo(tv.LastSeen) : '—'; let connection = 'Unknown', ip = '—', mac = '—', merakiLastSeen = '—', vlan = '—', portNum = '—', portName = '—', switchName = '—', portType = '—', portStatus = '—', accessPolicy = '—', stickyMacs = '0', poe = '—', errors = fetchError ? `Fetch failed: ${fetchError}` : '—'; if (!fetchError) { // Advanced matcher first (better prefix/CA/MAC handling); simple // exact-normalize match as a fallback for compat with the older // matching semantics. const normMdm = normalizePlayerName(rawNameForMatching).toLowerCase().trim(); const matchDevice = { identifier: rawNameForMatching }; const matchingClient = findBestMerakiClientMatch(matchDevice, clients) || clients.find((c) => normalizePlayerName(c.description || '').toLowerCase().trim() === normMdm, ); if (matchingClient) { connection = matchingClient.recentDeviceConnection || 'Unknown'; ip = matchingClient.ip || '—'; mac = matchingClient.mac || '—'; merakiLastSeen = matchingClient.lastSeen ? simpleTimeAgo(matchingClient.lastSeen) : '—'; vlan = matchingClient.vlan || '—'; if (connection.toLowerCase() === 'wired' && matchingClient.recentDeviceSerial && matchingClient.switchport) { const portInfo = ports.find((p) => (p.deviceSerial || p.serial) === matchingClient.recentDeviceSerial && String(p.portId || p.number || p.portNumber || '') === String(matchingClient.switchport), ); if (portInfo) { portNum = portInfo.portId || portInfo.number || matchingClient.switchport; portName = portInfo.name || '—'; switchName = portInfo.deviceName || portInfo.switchName || matchingClient.recentDeviceSerial || '—'; portType = portInfo.type || portInfo.portType || '—'; portStatus = portInfo.status || '—'; accessPolicy = portInfo.accessPolicy || portInfo.accessPolicyType || '—'; stickyMacs = Array.isArray(portInfo.stickyMacAllowList) ? portInfo.stickyMacAllowList.length.toString() : '0'; poe = portInfo.poeEnabled === true ? 'On' : (portInfo.poeEnabled === false ? 'Off' : '—'); errors = Array.isArray(portInfo.errors) && portInfo.errors.length > 0 ? portInfo.errors.join('; ') : 'None'; } } } } rows.push([ storeNum, username, locationGroup, tv.Model, tv.SerialNumber, mdmLastSeen, connection, ip, mac, merakiLastSeen, vlan, portNum, portName, switchName, portType, portStatus, accessPolicy, stickyMacs, poe, errors, ].map(csvCell).join(',')); } return rows; } export async function handleBulkAvStatusCSV(bot, trigger) { const roomId = trigger.roomId || trigger.message?.roomId; if (!roomId) { // Belt-and-suspenders: the registry marks this http:false so the HTTP // dispatcher shouldn't even reach us. If it does (e.g. a future ad-hoc // call path), surface a clear message instead of silently producing // nothing. await bot.say( 'markdown', '❌ `/bulkavstatuscsv` delivers a CSV attachment and needs a Webex ' + 'room to post to. This command is chat-only.', ); return; } const startedAt = Date.now(); await bot.say('markdown', '🔄 Generating full AV Devices report...\nFetching **all devices** from MDM (no filters)...'); try { const allDevices = await getMDMDevicesByPlatform(null, MAX_MDM_DEVICES); logger('bulk-av-csv', `MDM returned ${allDevices.length} total devices`); // Warn (loudly) if we hit the runaway cap — the report is complete // *up to* MAX_MDM_DEVICES but silently truncated everything beyond. // If this fires, raise MAX_MDM_DEVICES and re-run. if (allDevices.length >= MAX_MDM_DEVICES) { logger( 'bulk-av-csv', `⚠️ Hit MAX_MDM_DEVICES cap (${MAX_MDM_DEVICES}) — report may be truncated. ` + `Raise the cap in commands/bulkAvStatusCSV.js and re-run.`, 'warn', ); await bot.say( 'markdown', `⚠️ **Note:** hit the ${MAX_MDM_DEVICES.toLocaleString()}-device safety cap. ` + `The report includes the first ${MAX_MDM_DEVICES.toLocaleString()} devices only. ` + `Ask the bot maintainer to raise \`MAX_MDM_DEVICES\` in \`commands/bulkAvStatusCSV.js\`.`, ); } // Group by store; count devices we couldn't derive a store number for // so the operator sees they were skipped instead of assuming zero // silently. const devicesByStore = new Map(); let unbucketed = 0; for (const tv of allDevices) { const rawName = tv.DeviceFriendlyName || tv.friendlyName || ''; const storeNum = extractStoreNumber(rawName); if (!storeNum) { unbucketed++; continue; } if (!devicesByStore.has(storeNum)) devicesByStore.set(storeNum, []); devicesByStore.get(storeNum).push(tv); } const storeEntries = Array.from(devicesByStore.entries()); const totalStores = storeEntries.length; await bot.say( 'markdown', `📦 Grouped ${allDevices.length} devices into **${totalStores}** stores` + (unbucketed ? ` (${unbucketed} skipped — no store digits in device name)` : '') + `. Running Meraki enrichment with concurrency ${STORE_CONCURRENCY}...`, ); let processedStores = 0; const nested = await mapWithConcurrency(storeEntries, STORE_CONCURRENCY, async ([storeNum, storeDevices]) => { const rows = await buildStoreRows(storeNum, storeDevices); processedStores++; if (processedStores % PROGRESS_INTERVAL === 0 || processedStores === totalStores) { // Fire-and-forget progress post — don't await inside the runner // or a slow bot.say would starve the concurrency window. bot.say('markdown', `✅ Progress: ${processedStores}/${totalStores} stores processed...`) .catch((err) => logger('bulk-av-csv', `Progress post failed: ${err.message}`, 'debug')); } return rows; }); // Build the final CSV in one shot with array-join (linear) instead of // repeated string concat (quadratic). const headerRow = CSV_COLUMNS.map(csvCell).join(','); const csv = [headerRow, ...nested.flat()].join('\n') + '\n'; const buffer = Buffer.from(csv, 'utf8'); const filename = `AV_Devices_${new Date().toISOString().slice(0, 10)}.csv`; const elapsedSec = ((Date.now() - startedAt) / 1000).toFixed(1); logger( 'bulk-av-csv', `Completed in ${elapsedSec}s — ${allDevices.length} devices across ${totalStores} stores` + (unbucketed ? ` (${unbucketed} unbucketed)` : ''), ); await botClient.sendWithAttachment( roomId, buffer, filename, 'text/csv', `✅ **AV Devices Report Complete** — ${elapsedSec}s\n` + `${allDevices.length} total devices from ${totalStores} stores` + (unbucketed ? ` (${unbucketed} devices skipped — no store digits in name)` : '') + `\nDevice Name = Username • Location Group added`, ); } catch (err) { logger('bulk-av-csv', `Error: ${err.message}\n${err.stack}`, 'error'); await bot.say('markdown', `❌ Error: ${err.message}`); } }