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>
137 lines
4.2 KiB
JavaScript
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,
|
|
};
|