netanalyzer/utils/merakiMatcher.js
Joseph McQueen b3c37bd7df feat: st command suite, Webex phone + Atlas AV integrations, dockerized remote agent
Rebrand NetAnalyzer -> StoreHealthAnalyzer and consolidate the store
reporting surface into a single `st [number]` command with focused
sub-modes.

Commands
- st [number]                - general info (SIW + brands + Meraki net link)
- st [number] network        - switches, APs, store server
- st [number] pos            - registers, payment terminals, customer display
- st [number] ios            - MDM-tracked iOS hardware
- st [number] phone          - wired 78xx + DECT basestations/handsets with
                               registration state, extensions and main DID
- st [number] av             - Atlas AMPs + MDM-tracked Apple TVs, video
                               walls, music players, LED displays
- Removed `analyze` in favor of the unified `st` surface

Integrations
- integrations/webex: Service App OAuth with rotating refresh tokens,
  seed + cleanup scripts, tokens/ storage (git-ignored)
- integrations/atlas: Xyte client + cached device discovery keyed on
  zero-padded 6-digit store numbers, cold-cache failure -> unavailable
  banner instead of a misleading empty result
- services/webexPhone, services/webexService, services/avService: shape
  raw upstream data into the report layer's contract
- utils/merakiMatcher: FQDN hostname extraction so payment terminals
  match Meraki descriptions; case-insensitive lookup
- utils/chunkReport: split long markdown replies at 7000-char boundaries

Reliability / ops
- server.js: awaited framework.stop() + 8s hard-kill timer so nodemon /
  Docker restarts don't leak WDM device registrations ("excessive device
  registrations")
- nodemon.json: SIGINT so the graceful path always runs
- scripts/cleanupWebexDevices.js: one-shot WDM cleanup utility
- Group-space routing: hears() regexes tolerate the leading @BotName
  prefix Webex prepends to mentions
- Replaced HTML-unsafe <number> placeholders with [number] in all help
  strings

Remote agent containerization
- docker/remote-agent/: multi-stage node:22-alpine image, non-root user,
  tini for signal handling, minimal deps (ws/axios/dotenv)
- docker/remote-agent/package.sh: docker buildx build defaulting to
  linux/amd64 (with override), saves image + assembles deploy/ + writes
  SHA256 + zips for offline transfer
- docker/remote-agent/deploy/: runtime docker-compose.yml, install.sh
  with platform sanity check, remote-host README
- .dockerignore + .gitignore updates for build artifacts and dist bundles
- npm run agent:package convenience script

Cleanup
- Dropped storeHealth.js / HealthReport.js and their tests/mocks in favor
  of the shared storeDetail pipeline
- Store model handles null SIW records gracefully; toSummary always
  ends with a newline so the Meraki link sits on its own line

Tests
- 144 tests across 14 suites passing; new coverage for atlasClient,
  atlasDevices, avService, avCategory classification, webexPhone,
  webexServiceAppAuth, storeDetail integration, siw, chunkReport and
  the updated meraki matcher

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 09:54:41 -04:00

137 lines
4.2 KiB
JavaScript

/**
* 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,
};