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

177 lines
5.3 KiB
JavaScript

/**
* Atlas (Xyte) device discovery.
*
* The Atlas org-wide device list endpoint is paginated and not free, so we
* maintain a 1-hour in-process cache (same TTL the collabFinder reference
* uses). `getAtlasDeviceList()` lazily populates the cache; everything else
* filters on top of it.
*
* Store matching mirrors the collabFinder convention: device names follow
* `US<6-digit padded store>` (e.g. `US000782AMP`), so we zero-pad the store
* number before doing a case-insensitive substring match. Padding is
* deliberate — a raw "782" would also substring-match unrelated devices like
* `US007820AMP`.
*/
const { atlasGet, AtlasUnavailableError } = require('./atlasClient');
const logger = require('../../utils/logger');
const PAGE_SIZE = 100;
const PAGE_THROTTLE_MS = 80;
const HARD_PAGE_CAP = 100;
const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
let _cachedDevices = [];
let _lastCacheTime = 0;
function resetCacheForTests() {
_cachedDevices = [];
_lastCacheTime = 0;
}
/**
* Walk the paginated `/organization/devices` endpoint and replace the cache.
* Bails out early on auth/transport failure and preserves the previous cache
* (so a single bad refresh doesn't blank the bot's data view).
*/
async function refreshAtlasDevicesCache() {
const start = Date.now();
logger.debug('Refreshing Atlas device cache');
let allItems = [];
let page = 1;
while (page <= HARD_PAGE_CAP) {
let data;
try {
data = await atlasGet('organization/devices', { page, per_page: PAGE_SIZE });
} catch (err) {
if (err instanceof AtlasUnavailableError) throw err;
logger.error('Atlas device cache refresh failed mid-pagination', {
page,
error: err.message,
});
// If we have a previously populated cache, keep serving it so a
// transient outage doesn't blank the AV view. With nothing cached, the
// caller has no fallback — re-throw so getAtlasDevicesForStore can
// surface the unavailable banner.
if (_cachedDevices.length === 0) throw err;
return;
}
const items = Array.isArray(data?.items) ? data.items : [];
allItems = allItems.concat(items);
const nextPage = data?.next_page;
// Stop on a short page or a missing/falsy `next_page`. Either signal
// means we've exhausted the list.
if (!nextPage || items.length < PAGE_SIZE) break;
page = Number(nextPage) || page + 1;
await new Promise(r => setTimeout(r, PAGE_THROTTLE_MS));
}
_cachedDevices = allItems;
_lastCacheTime = Date.now();
logger.info('Atlas device cache refreshed', {
count: _cachedDevices.length,
elapsedMs: Date.now() - start,
});
}
/**
* Return the cached device list, refreshing on first call or TTL expiry.
* Pass `forceRefresh: true` to ignore the TTL.
*/
async function getAtlasDeviceList(forceRefresh = false) {
const stale = Date.now() - _lastCacheTime > CACHE_TTL_MS;
if (forceRefresh || _cachedDevices.length === 0 || stale) {
await refreshAtlasDevicesCache();
}
return _cachedDevices;
}
/**
* Find devices whose name contains the zero-padded store number.
* Padding to 6 digits matches Atlas's `US<NNNNNN>` naming.
*/
async function findAtlasDevicesForStore(storeNumber) {
const padded = String(storeNumber).trim().padStart(6, '0');
const devices = await getAtlasDeviceList();
const matches = devices.filter(dev =>
String(dev.name || '')
.toUpperCase()
.includes(padded)
);
logger.debug('Atlas devices matched for store', {
storeNumber,
padded,
matched: matches.length,
});
return matches;
}
/**
* Fetch detail for a single device. Returns null on failure rather than
* throwing — most callers iterate a small list and want best-effort enrichment.
*/
async function getAtlasDeviceDetail(deviceId) {
if (!deviceId) return null;
try {
return await atlasGet(`organization/devices/${deviceId}`);
} catch (err) {
if (err instanceof AtlasUnavailableError) throw err;
logger.warn('Atlas device detail fetch failed', { deviceId, error: err.message });
return null;
}
}
/**
* Convenience: find devices by store and fetch detail for each in parallel.
* Returns `{ devices: [...], unavailable, reason }` so the caller has the
* same shape phone uses.
*/
async function getAtlasDevicesForStore(storeNumber) {
let candidates;
try {
candidates = await findAtlasDevicesForStore(storeNumber);
} catch (err) {
if (err instanceof AtlasUnavailableError) {
return { devices: [], unavailable: true, reason: err.message };
}
return {
devices: [],
unavailable: true,
reason: `Atlas lookup failed: ${err.message}`,
};
}
if (candidates.length === 0) {
return { devices: [] };
}
const detailResults = await Promise.allSettled(candidates.map(c => getAtlasDeviceDetail(c.id)));
// Stitch detail back over the list summary so we don't lose the name when
// detail returned null. Detail wins for everything it does provide.
const devices = candidates.map((summary, idx) => {
const detail = detailResults[idx];
const detailValue = detail.status === 'fulfilled' ? detail.value : null;
return { ...summary, ...(detailValue || {}) };
});
return { devices };
}
module.exports = {
refreshAtlasDevicesCache,
getAtlasDeviceList,
findAtlasDevicesForStore,
getAtlasDeviceDetail,
getAtlasDevicesForStore,
resetCacheForTests,
PAGE_SIZE,
HARD_PAGE_CAP,
CACHE_TTL_MS,
};