netanalyzer/integrations/storeDetail.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

730 lines
23 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const {
getStoreLocation,
getStoreGeneral,
getStoreRegisters,
getStorePrinters,
getStorePaymentTerminals,
} = require('../services/siw');
const {
findMerakiNetwork,
getMerakiDeviceAvailabilities,
getMerakiClients,
} = require('../services/meraki');
const { getMDMDevices } = require('../services/mdm');
const { collectPhoneStatus } = require('../services/webexPhone');
const { collectAvStatus } = require('../services/avService');
const { createStore } = require('../models/Store');
const {
findMatchingClient,
getClientStatus,
formatLastSeen,
buildMerakiClientLink,
extractHostname,
} = require('../utils/merakiMatcher');
const {
STORE_MODES,
MDM_DEVICE_TYPES,
AV_CATEGORIES,
AV_FRIENDLY_NAME_PATTERN,
filterMdmByType,
classifyMdmAvDevice,
mdmDeviceName,
} = require('../constants');
const logger = require('../utils/logger');
/**
* What each mode needs from the upstream services. Used to gate parallel
* fetches so we don't hit MDM/Meraki for an INFO-only request.
*/
const MODE_FETCH_PLAN = {
[STORE_MODES.INFO]: {
siw: false,
header: true,
meraki: false,
mdm: false,
phone: false,
av: false,
},
[STORE_MODES.NETWORK]: {
siw: false,
header: false,
meraki: true,
mdm: true,
phone: false,
av: false,
},
[STORE_MODES.POS]: {
siw: true,
header: false,
meraki: true,
mdm: true,
phone: false,
av: false,
},
[STORE_MODES.IOS]: {
siw: false,
header: false,
meraki: true,
mdm: true,
phone: false,
av: false,
},
// PHONE needs Meraki clients (for MAC matching) + the Webex Service App
// phone service.
[STORE_MODES.PHONE]: {
siw: false,
header: false,
meraki: true,
mdm: false,
phone: true,
av: false,
},
// AV pulls Atlas (AMPs) AND MDM (Apple TVs, video walls, music, LED),
// then MAC-matches each into Meraki for the where-connected line.
[STORE_MODES.AV]: {
siw: false,
header: false,
meraki: true,
mdm: true,
phone: false,
av: true,
},
};
const PLACEHOLDER_MESSAGES = {};
async function getStoreDetail(storeNumber, mode = STORE_MODES.INFO) {
logger.info('Starting store analysis', { storeNumber, mode });
// Future-mode placeholders short-circuit before any upstream calls.
if (PLACEHOLDER_MESSAGES[mode]) {
return PLACEHOLDER_MESSAGES[mode];
}
const plan = MODE_FETCH_PLAN[mode] || MODE_FETCH_PLAN[STORE_MODES.INFO];
let locationData = null,
generalData = null,
merakiNetwork = null,
registers = [],
printers = [],
paymentTerminals = [],
merakiClients = [],
mdmDevices = [],
phoneData = null,
avData = null;
try {
// Phase 1: header data (location + general) and Meraki network discovery
// so phase 2 has a network ID to query clients against.
[locationData, generalData, merakiNetwork] = await Promise.all([
plan.header ? getStoreLocation(storeNumber) : Promise.resolve(null),
plan.header ? getStoreGeneral(storeNumber) : Promise.resolve(null),
plan.meraki ? findMerakiNetwork(storeNumber) : Promise.resolve(null),
]);
// Phase 2: heavy data, fanned out in parallel.
const dataPromises = [];
if (plan.siw) {
dataPromises.push(
getStoreRegisters(storeNumber),
getStorePrinters(storeNumber),
getStorePaymentTerminals(storeNumber)
);
} else {
dataPromises.push(Promise.resolve([]), Promise.resolve([]), Promise.resolve([]));
}
dataPromises.push(
merakiNetwork && plan.meraki ? getMerakiClients(merakiNetwork.id) : Promise.resolve([])
);
dataPromises.push(plan.mdm ? getMDMDevices(storeNumber) : Promise.resolve([]));
// Webex phone data is independent of SIW/MDM — collectPhoneStatus catches
// its own errors and yields { unavailable: true, reason } on failure.
dataPromises.push(
plan.phone
? collectPhoneStatus(storeNumber).catch(err => ({
unavailable: true,
reason: err.message,
}))
: Promise.resolve(null)
);
// Atlas AV data is similarly independent — collectAvStatus already
// returns { unavailable, reason } on failure, but we still defend
// against unexpected throws so a transport hiccup can't take the whole
// report down (MDM-side AV devices should still render).
dataPromises.push(
plan.av
? collectAvStatus(storeNumber).catch(err => ({
devices: [],
unavailable: true,
reason: err.message,
}))
: Promise.resolve(null)
);
const [reg, prn, pay, clients, mdm, phone, av] = await Promise.all(dataPromises);
registers = reg || [];
printers = prn || [];
paymentTerminals = pay || [];
merakiClients = clients || [];
mdmDevices = mdm || [];
phoneData = phone || null;
avData = av || null;
logger.info('Store data fetched', { mode });
} catch (err) {
logger.error('Error fetching store data', { error: err.message });
}
let report = '';
if (mode === STORE_MODES.INFO) {
// INFO needs location to render anything — fetch StoreGeneral too for
// brand/status/environment. The Store model handles missing data
// gracefully.
report += createStore(locationData, storeNumber, { general: generalData }).toSummary();
} else if (mode === STORE_MODES.NETWORK) {
report += await buildActiveNetworkDevices(merakiNetwork);
report += buildStoreServers(mdmDevices, merakiClients, merakiNetwork);
} else if (mode === STORE_MODES.POS) {
report += buildStoreServers(mdmDevices, merakiClients, merakiNetwork);
report += buildRegisters(registers, merakiClients, merakiNetwork);
report += buildMobileRegisters(mdmDevices, merakiClients, merakiNetwork);
report += buildCustomerDisplays(mdmDevices, merakiClients, merakiNetwork);
report += buildPrinters(printers, merakiClients, merakiNetwork);
report += buildPaymentTerminals(paymentTerminals, merakiClients, merakiNetwork);
} else if (mode === STORE_MODES.IOS) {
report += buildIOSDevices(mdmDevices, merakiClients, merakiNetwork);
} else if (mode === STORE_MODES.PHONE) {
report += buildPhoneReport(phoneData, merakiClients, merakiNetwork);
} else if (mode === STORE_MODES.AV) {
report += buildAvReport(avData, mdmDevices, merakiClients, merakiNetwork);
}
return report.trim() || '_No matching data for this view._';
}
// === Section Builders ===
/**
* Render a single "device → matched Meraki client" line.
* `prefixParts` is rendered before the status; `identifiers` is passed
* straight through to findMatchingClient.
*/
function renderClientLine({ prefixParts, identifiers, merakiClients, merakiNetwork }) {
const match = findMatchingClient(merakiClients, identifiers);
const status = getClientStatus(match);
const lastSeen = formatLastSeen(match?.lastSeen);
const recentDevice = match?.recentDeviceName ? ` via ${match.recentDeviceName}` : '';
const connection = match?.recentDeviceConnection ? ` (${match.recentDeviceConnection})` : '';
const link = match ? ` → [Meraki Client](${buildMerakiClientLink(merakiNetwork, match)})` : '';
const prefix = prefixParts.filter(Boolean).join(' — ');
return `${prefix}${status}${lastSeen}${recentDevice}${connection}${link}\n`;
}
async function buildActiveNetworkDevices(merakiNetwork) {
if (!merakiNetwork) return '\n\n⚠ No matching Meraki network found.\n';
// Leading \n\n forces the section splitter (\n\n followed by **) to break
// this header onto its own message instead of gluing it to the previous
// section's last line.
let out = `\n\n**🌐 [${merakiNetwork.name}](${merakiNetwork.url})**\n\n`;
let activeDevices = [];
try {
const availabilities = await getMerakiDeviceAvailabilities(merakiNetwork.id);
activeDevices = availabilities.filter(d => d.status !== 'dormant');
} catch (e) {
logger.error('Device availabilities failed', { error: e.message });
}
activeDevices.sort((a, b) => {
const nameA = (a.name || '').trim().toUpperCase();
const nameB = (b.name || '').trim().toUpperCase();
if (nameA.includes('SWR') || /SW.*R\d?$/.test(nameA)) return -1;
if (nameB.includes('SWR') || /SW.*R\d?$/.test(nameB)) return 1;
if (nameA.includes('SWF') || /SW.*F\d/.test(nameA)) return -1;
if (nameB.includes('SWF') || /SW.*F\d/.test(nameB)) return 1;
return nameA.localeCompare(nameB);
});
out += `**🛠️ Active Network Devices (${activeDevices.length})**\n`;
activeDevices.forEach(dev => {
const statusEmoji = dev.status === 'online' ? '✅' : '⚠️';
const deviceName = (dev.name || dev.serial || 'Unknown').trim();
out += `**${deviceName}** — ${statusEmoji} ${dev.status}`;
if (dev.serial && merakiNetwork.url) {
const urlParts = merakiNetwork.url.split('/n/');
const networkCode = urlParts[1] ? urlParts[1].split('/')[0] : merakiNetwork.id;
let link = `${urlParts[0]}/n/${networkCode}`;
if (dev.productType === 'switch') link += `/manage/switches/${dev.serial}/summary`;
else if (dev.productType === 'wireless')
link += `/manage/access_points/${dev.serial}/summary`;
else link += `/manage/${dev.serial}/summary`;
out += ` → [Dashboard](${link})`;
}
out += `\n`;
});
return out;
}
function buildMdmSection({
devices,
marker,
title,
fallbackName,
merakiClients,
merakiNetwork,
formatPrefix,
}) {
const filtered = filterMdmByType(devices, marker);
if (filtered.length === 0) return '';
let out = `\n**${title} (${filtered.length})**\n`;
filtered.forEach(dev => {
const name = dev.UserName || dev.DeviceFriendlyName || fallbackName;
out += renderClientLine({
prefixParts: formatPrefix ? formatPrefix(name, dev) : [`**${name}**`],
identifiers: {
UserName: dev.UserName,
DeviceFriendlyName: dev.DeviceFriendlyName,
name,
},
merakiClients,
merakiNetwork,
});
});
return out;
}
function buildStoreServers(mdmDevices, merakiClients, merakiNetwork) {
return buildMdmSection({
devices: mdmDevices,
marker: MDM_DEVICE_TYPES.SERVER,
title: '🖥️ Store Servers',
fallbackName: 'Unknown Server',
merakiClients,
merakiNetwork,
});
}
function buildMobileRegisters(mdmDevices, merakiClients, merakiNetwork) {
return buildMdmSection({
devices: mdmDevices,
marker: MDM_DEVICE_TYPES.MOBILE_REGISTER,
title: '📱 Mobile Registers',
fallbackName: 'Unknown Mobile Register',
merakiClients,
merakiNetwork,
});
}
// Customer-facing displays (MDM naming convention: `US<store>CD##`,
// e.g. `US000782CD01`). Meraki advertises them with the same short hostname
// as the client description, so the default name-based match path works.
function buildCustomerDisplays(mdmDevices, merakiClients, merakiNetwork) {
return buildMdmSection({
devices: mdmDevices,
marker: MDM_DEVICE_TYPES.CUSTOMER_DISPLAY,
title: '📟 Customer Displays',
fallbackName: 'Unknown Customer Display',
merakiClients,
merakiNetwork,
});
}
function buildIOSDevices(mdmDevices, merakiClients, merakiNetwork) {
return buildMdmSection({
devices: mdmDevices,
marker: MDM_DEVICE_TYPES.IPHONE,
title: '📱 Store iPhones',
fallbackName: 'Unknown iPhone',
merakiClients,
merakiNetwork,
});
}
function buildRegisters(registers, merakiClients, merakiNetwork) {
if (!registers || registers.length === 0) return '';
let out = `\n**🖥️ Store Registers (${registers.length})**\n`;
registers.forEach(reg => {
const regNum = reg.register_number ? `Register ${reg.register_number}` : 'Register';
const brand = reg.brand_display_name || 'Unknown';
const type = reg.register_type_name || 'Unknown';
const name = (reg.register_display_name || reg.printer_name || 'Unknown').trim();
out += renderClientLine({
prefixParts: [`**${regNum}**`, brand, type],
identifiers: { name, register_display_name: name },
merakiClients,
merakiNetwork,
});
});
return out;
}
function buildPrinters(printers, merakiClients, merakiNetwork) {
const filtered = (printers || []).filter(
p => (p.connection_type_name || '').toLowerCase() !== 'usb'
);
if (filtered.length === 0) return '';
let out = `\n**🖨️ Store Printers (${filtered.length})**\n`;
filtered.forEach(printer => {
const name = (printer.printer_name || 'Unknown').trim();
const model = printer.printer_model_name || 'N/A';
out += renderClientLine({
prefixParts: [`**${name}**`, model],
identifiers: { name, printer_name: name },
merakiClients,
merakiNetwork,
});
});
return out;
}
function buildPaymentTerminals(paymentTerminals, merakiClients, merakiNetwork) {
if (!paymentTerminals || paymentTerminals.length === 0) return '';
let out = `\n**💳 Payment Terminals (${paymentTerminals.length})**\n`;
paymentTerminals.forEach(term => {
const deviceName = term.device_name || 'Unknown Terminal';
const adyenName = term.adyen_device_name || 'N/A';
const model = term.device_model_name || 'N/A';
const type = term.device_type_name || 'N/A';
// SIW stores the FQDN in `ip_address`; the hostname before the first dot
// is what Meraki uses as the client description (e.g. "VFI-807-005-168").
const hostname = extractHostname(term.ip_address);
out += renderClientLine({
prefixParts: [`**${deviceName}**`, adyenName, model, type],
identifiers: {
name: hostname,
deviceName,
adyenName,
ip_address: term.ip_address,
},
merakiClients,
merakiNetwork,
});
});
return out;
}
// === Phone (Webex Service App) ===
const WEBEX_UNAVAILABLE_BANNER = reason =>
'\n\n**⚠️ Webex phone data unavailable**\n\n' +
`_${reason || 'Service App is not configured or tokens are missing.'}_\n\n` +
'Run `npm run webex:seed` on the host to bootstrap or re-seed the Service ' +
'App tokens, then try again.\n';
/**
* Build the full PHONE-mode report: a header (Webex location + store DID),
* wired 78xx phones, DECT basestations with their currently-registered
* handsets nested underneath, and a trailing "Unregistered Handsets" section
* for anything that doesn't have a recent registration or doesn't map to a
* known base.
*/
function buildPhoneReport(phoneData, merakiClients, merakiNetwork) {
if (!phoneData) {
return WEBEX_UNAVAILABLE_BANNER('Webex Service App not configured.');
}
if (phoneData.unavailable) {
return WEBEX_UNAVAILABLE_BANNER(phoneData.reason);
}
let out = '';
out += buildPhoneHeader(phoneData);
out += buildWiredPhones(phoneData.phones, merakiClients, merakiNetwork);
out += buildDectSection(phoneData, merakiClients, merakiNetwork);
if (!out.trim()) {
return '\n\n_No Webex phones, DECT basestations, or handsets registered for this store._';
}
return out;
}
function buildPhoneHeader(phoneData) {
const locationName = phoneData?.dectNetwork?.locationName;
const mainNumber = phoneData?.locationMainNumber;
if (!locationName && !mainNumber) return '';
const parts = [];
if (locationName) parts.push(`**📍 ${locationName}**`);
if (mainNumber) parts.push(`📞 Main: **${mainNumber}**`);
return `\n\n${parts.join(' — ')}\n`;
}
function buildWiredPhones(phones, merakiClients, merakiNetwork) {
if (!phones || phones.length === 0) return '';
let out = `\n\n**📞 Wired Phones (${phones.length})**\n`;
phones.forEach(p => {
const model = p.model || 'Cisco IP Phone';
const extPart = p.extension ? `ext ${p.extension}` : null;
const prefixParts = [`**${p.name}**`, model];
if (extPart) prefixParts.push(extPart);
out += renderClientLine({
prefixParts,
identifiers: { mac: p.mac, name: p.name },
merakiClients,
merakiNetwork,
});
});
return out;
}
// Handsets don't have an IP presence Webex can poll — they're DECT radio
// devices that only register through their basestation. So `handset.status`
// from the Webex list endpoint is unreliable (frequently empty/"unknown").
// Instead, we treat the line's `lastRegistrationTime` as the health signal:
// a fresh registration means the handset is talking to its base right now.
const HANDSET_FRESH_REG_WINDOW_MS = 24 * 60 * 60 * 1000; // 24h
function isHandsetRegistered(h) {
if (!h.lastRegistrationTime) return false;
const lastRegMs = new Date(h.lastRegistrationTime).getTime();
return Number.isFinite(lastRegMs) && Date.now() - lastRegMs <= HANDSET_FRESH_REG_WINDOW_MS;
}
/**
* Compose the user-facing handset name. Webex stores the meaningful slot
* index separately from the extension/access-code, so we render the
* "<index>-<extension>" form operators recognise (e.g. "1-50782", "2-50782")
* when both are present, falling back to whatever displayName we got.
*/
function formatHandsetName(h) {
if (h.index != null && h.extension) {
return `${h.index}-${h.extension}`;
}
if (h.extension) return String(h.extension);
return h.name || `Handset ${h.index ?? '?'}`;
}
function buildDectSection(phoneData, merakiClients, merakiNetwork) {
const bases = phoneData.basestations || [];
const handsets = phoneData.handsets || [];
if (bases.length === 0 && handsets.length === 0) return '';
let out = `\n\n**📡 DECT Network**`;
if (phoneData.dectNetwork?.name) {
out += `${phoneData.dectNetwork.name}`;
}
out += `\n`;
// Partition handsets:
// - "registered + assigned to a known base" → nested under that base.
// - everything else (stale registration, no registration data, or a
// baseStationId that doesn't map to a current base) → trailing
// "Unregistered Handsets" section.
const baseIds = new Set(bases.map(b => b.id));
const handsetsByBase = new Map();
const unregisteredHandsets = [];
handsets.forEach(h => {
if (isHandsetRegistered(h) && h.baseStationId && baseIds.has(h.baseStationId)) {
const list = handsetsByBase.get(h.baseStationId) || [];
list.push(h);
handsetsByBase.set(h.baseStationId, list);
} else {
unregisteredHandsets.push(h);
}
});
bases.forEach(base => {
const baseHandsets = handsetsByBase.get(base.id) || [];
const firmware = base.firmware ? ` fw ${base.firmware}` : '';
const lines = base.linesRegistered ? `${base.linesRegistered} lines registered` : '';
out += '\n';
out += renderClientLine({
prefixParts: [`**🛰️ ${base.name}**`, `${base.model || 'DECT Base'}${firmware}${lines}`],
identifiers: { mac: base.mac, name: base.name },
merakiClients,
merakiNetwork,
});
if (baseHandsets.length === 0) {
out += ` _no registered handsets_\n`;
} else {
baseHandsets.forEach(h => {
out += ` - ${formatHandsetLine(h)}\n`;
});
}
});
if (unregisteredHandsets.length > 0) {
out += `\n**📵 Unregistered Handsets (${unregisteredHandsets.length})**\n`;
unregisteredHandsets.forEach(h => {
out += `- ${formatHandsetLine(h)}\n`;
});
}
return out;
}
function formatHandsetLine(h) {
const displayName = formatHandsetName(h);
const lastReg = h.lastRegistrationTime ? new Date(h.lastRegistrationTime) : null;
const ago = lastReg ? formatLastSeen(lastReg) : null;
let presence;
if (!lastReg) {
presence = '❓ No registration data';
} else if (Date.now() - lastReg.getTime() <= HANDSET_FRESH_REG_WINDOW_MS) {
presence = `✅ Registered — last sync ${ago}`;
} else {
presence = `⚠️ Last registered ${ago}`;
}
return `**${displayName}** — ${presence}`;
}
// === AV (Atlas + MDM hardware) ===
// Ordered so the report always renders Atlas first, then the MDM categories
// in a consistent (and visually grouped) sequence. Keep this aligned with
// `AV_SUBSECTIONS` below.
const AV_SUBSECTIONS = [
{ category: AV_CATEGORIES.APPLE_TV, title: '📺 Apple TVs', fallbackName: 'Unknown Apple TV' },
{
category: AV_CATEGORIES.VIDEO_WALL,
title: '🖼️ Video Walls',
fallbackName: 'Unknown Video Wall',
},
{
category: AV_CATEGORIES.MUSIC,
title: '🎵 Music Players',
fallbackName: 'Unknown Music Player',
},
{ category: AV_CATEGORIES.LED, title: '💡 LED Displays', fallbackName: 'Unknown LED Display' },
];
const ATLAS_UNAVAILABLE_BANNER = reason =>
'\n\n**⚠️ Atlas AV data unavailable**\n\n' +
`_${reason || 'Atlas is not configured (set ATLAS_AUTH_KEY).'}_\n`;
function buildAvReport(avData, mdmDevices, merakiClients, merakiNetwork) {
let out = '';
// Atlas section first (with inline banner on failure so MDM hardware
// below still renders). Treat a missing avData payload as "Atlas wasn't
// attempted" — defensive in case the plan fetch shape changes.
if (avData && avData.unavailable) {
out += ATLAS_UNAVAILABLE_BANNER(avData.reason);
} else if (avData && Array.isArray(avData.devices) && avData.devices.length > 0) {
out += buildAtlasAmpSection(avData.devices, merakiClients, merakiNetwork);
}
// Then each MDM-derived AV category.
for (const sub of AV_SUBSECTIONS) {
out += buildMdmAvSection({
mdmDevices,
category: sub.category,
title: sub.title,
fallbackName: sub.fallbackName,
merakiClients,
merakiNetwork,
});
}
if (!out.trim()) {
return '\n\n_No AV hardware registered for this store._';
}
return out;
}
function buildAtlasAmpSection(devices, merakiClients, merakiNetwork) {
let out = `\n\n**📡 Atlas AMP (${devices.length})**\n`;
devices.forEach(dev => {
const prefixParts = [`**${dev.name}**`];
if (dev.model) prefixParts.push(dev.model);
prefixParts.push(formatAtlasPresence(dev));
out += renderClientLine({
prefixParts,
identifiers: { mac: dev.mac, name: dev.name, ip_address: dev.ip },
merakiClients,
merakiNetwork,
});
});
return out;
}
function formatAtlasPresence(dev) {
const lastSeen = dev.lastSeen ? new Date(dev.lastSeen) : null;
const lastSeenStr = lastSeen ? formatLastSeen(lastSeen) : null;
const suffix = lastSeenStr ? ` — last seen ${lastSeenStr}` : '';
if (dev.online === true) return `✅ Online${suffix}`;
if (dev.online === false) return `⚠️ Offline${suffix}`;
return `❓ Unknown${suffix}`;
}
function buildMdmAvSection({
mdmDevices,
category,
title,
fallbackName,
merakiClients,
merakiNetwork,
}) {
const filtered = (mdmDevices || []).filter(d => {
const name = mdmDeviceName(d);
return AV_FRIENDLY_NAME_PATTERN.test(name) && classifyMdmAvDevice(name) === category;
});
if (filtered.length === 0) return '';
let out = `\n\n**${title} (${filtered.length})**\n`;
filtered.forEach(dev => {
const name = dev.DeviceFriendlyName || dev.UserName || fallbackName;
const model = dev.Model || dev.ModelId || null;
const prefixParts = [`**${name}**`];
if (model) prefixParts.push(model);
out += renderClientLine({
prefixParts,
identifiers: {
UserName: dev.UserName,
DeviceFriendlyName: dev.DeviceFriendlyName,
mac: dev.MacAddress,
name,
},
merakiClients,
merakiNetwork,
});
});
return out;
}
module.exports = { getStoreDetail };