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>
730 lines
23 KiB
JavaScript
730 lines
23 KiB
JavaScript
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 };
|