/** * Device matching and Meraki client utilities. * Centralizes the fragile name/description matching logic used across reports. */ /** * Find the best matching Meraki client for a device using multiple strategies. * * identifiers can contain: mac, name, deviceName, adyenName, UserName, * DeviceFriendlyName, ip / ip_address. MAC, when present, is checked first * (and wins outright) because it's deterministic — useful for phones/DECT * basestations whose display names rarely line up with Meraki descriptions. */ function findMatchingClient(merakiClients = [], identifiers = {}) { if (!merakiClients.length) return null; // Strategy 0: MAC. Deterministic; runs ahead of any name/IP heuristic. const targetMac = normalizeMac(identifiers.mac); if (targetMac) { for (const client of merakiClients) { if (normalizeMac(client?.mac) === targetMac) return client; } } const names = [ identifiers.name, identifiers.deviceName, identifiers.adyenName, identifiers.UserName, identifiers.DeviceFriendlyName, identifiers.printer_name, identifiers.register_display_name, ] .filter(Boolean) .map(n => String(n).toLowerCase().trim()); // SIW often stores an FQDN in the `ip_address` field // (e.g. "VFI-807-005-168.us000782.stores.ae.com"). The hostname portion // before the first dot is what Meraki uses as its client `description`. const ipPrefix = extractHostname(identifiers.ip_address || identifiers.ip); for (const client of merakiClients) { if (!client?.description) continue; const desc = client.description.toLowerCase().trim(); // Strategy 1: exact or substring match on any name if (names.some(n => n && (desc === n || desc.includes(n) || n.includes(desc)))) { return client; } // Strategy 2: FQDN hostname / IP prefix fallback if (ipPrefix && desc.includes(ipPrefix)) { return client; } // Strategy 3: check client.user field if (client.user) { const user = client.user.toLowerCase(); if (names.some(n => n && user.includes(n))) { return client; } } } return null; } /** * Normalise a MAC address for equality comparison: strip every non-hex * character, lowercase, then validate length === 12. Returns null on bad * input so callers can safely use the result as a Map key. */ function normalizeMac(value) { if (!value) return null; const stripped = String(value) .replace(/[^0-9a-fA-F]/g, '') .toLowerCase(); return stripped.length === 12 ? stripped : null; } /** * Pull the hostname out of an "ip_address" value. SIW endpoints frequently * return an FQDN (e.g. "VFI-807-005-168.us000782.stores.ae.com") in the * ip_address field; the portion before the first dot is the device's short * hostname, which is what Meraki advertises as the client description. * Returns null for empty/missing input. */ function extractHostname(value) { if (!value) return null; return String(value).split('.')[0].toLowerCase(); } function getClientStatus(client) { if (!client) return '❓ Unknown'; return client.status === 'Online' ? '✅ Online' : '❌ Offline'; } function formatLastSeen(lastSeen) { if (!lastSeen) return 'N/A'; const now = new Date(); const seen = new Date(lastSeen); const diffMs = now - seen; const diffMin = Math.floor(diffMs / 60000); if (diffMin < 1) return 'Just now'; if (diffMin < 60) return `${diffMin} min ago`; const hours = Math.floor(diffMin / 60); return `${hours} hr ago`; } /** * Build a best-effort link to the Meraki client detail page. * Note: This logic is environment-specific (dashboard URLs). */ function buildMerakiClientLink(merakiNetwork, client) { if (!client || !merakiNetwork?.url) return ''; try { const networkCode = merakiNetwork.url.split('/n/')[1]?.split('/')[0] || merakiNetwork.id; const base = merakiNetwork.url.split('/n/')[0] || 'https://n976.dashboard.meraki.com'; // Different sections for switches vs APs vs clients // Default to clients view return `${base}/${merakiNetwork.name.replace(/ /g, '-')}/n/${networkCode}/manage/clients/${client.id}/overview`; } catch (_e) { return ''; } } module.exports = { findMatchingClient, getClientStatus, formatLastSeen, buildMerakiClientLink, extractHostname, normalizeMac, };