Store Health Dashboard
Find a file
Joseph McQueen 74b3a9fcb6 fix(docker): actually cross-build linux/amd64 image on Apple Silicon
The default buildx builder (docker driver) is bound to the daemon's native
platform, so on an Apple Silicon Mac `--platform linux/amd64 --load` was
silently producing an arm64 image. The bundle then failed on the linux/amd64
target host with the "exec format error" that install.sh's arch sanity
check now surfaces as "Image architecture (arm64) does not match this host
(amd64)".

package.sh now:
- Creates a dedicated `sha-remote-agent-builder` (docker-container driver)
  on first run so cross-arch builds actually work.
- Best-effort installs tonistiigi/binfmt QEMU handlers when the target
  platform differs from the host.
- Uses `--output type=docker,dest=...` instead of `--load` + `docker save`,
  bypassing the local daemon's cross-arch storage limits entirely.
- Verifies the produced image's Architecture against --platform after the
  build and aborts if they disagree, so a broken ZIP can never leave the
  build host.
- Only re-tags :latest when the built platform matches the host, to avoid
  leaving a broken cross-arch :latest in the local daemon.

README documents the trap and the mitigations.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 10:02:43 -04:00
bot feat: st command suite, Webex phone + Atlas AV integrations, dockerized remote agent 2026-07-06 09:54:41 -04:00
config feat: st command suite, Webex phone + Atlas AV integrations, dockerized remote agent 2026-07-06 09:54:41 -04:00
docker/remote-agent fix(docker): actually cross-build linux/amd64 image on Apple Silicon 2026-07-06 10:02:43 -04:00
integrations feat: st command suite, Webex phone + Atlas AV integrations, dockerized remote agent 2026-07-06 09:54:41 -04:00
models feat: st command suite, Webex phone + Atlas AV integrations, dockerized remote agent 2026-07-06 09:54:41 -04:00
scripts feat: st command suite, Webex phone + Atlas AV integrations, dockerized remote agent 2026-07-06 09:54:41 -04:00
services feat: st command suite, Webex phone + Atlas AV integrations, dockerized remote agent 2026-07-06 09:54:41 -04:00
tests feat: st command suite, Webex phone + Atlas AV integrations, dockerized remote agent 2026-07-06 09:54:41 -04:00
utils feat: st command suite, Webex phone + Atlas AV integrations, dockerized remote agent 2026-07-06 09:54:41 -04:00
.dockerignore feat: st command suite, Webex phone + Atlas AV integrations, dockerized remote agent 2026-07-06 09:54:41 -04:00
.env.example feat: st command suite, Webex phone + Atlas AV integrations, dockerized remote agent 2026-07-06 09:54:41 -04:00
.gitignore feat: st command suite, Webex phone + Atlas AV integrations, dockerized remote agent 2026-07-06 09:54:41 -04:00
.prettierignore chore: project cleanup — dead code, logging, tests, WS hardening 2026-06-24 17:21:22 -04:00
.prettierrc.json chore: project cleanup — dead code, logging, tests, WS hardening 2026-06-24 17:21:22 -04:00
constants.js feat: st command suite, Webex phone + Atlas AV integrations, dockerized remote agent 2026-07-06 09:54:41 -04:00
eslint.config.js chore: project cleanup — dead code, logging, tests, WS hardening 2026-06-24 17:21:22 -04:00
nodemon.json feat: st command suite, Webex phone + Atlas AV integrations, dockerized remote agent 2026-07-06 09:54:41 -04:00
package-lock.json chore: project cleanup — dead code, logging, tests, WS hardening 2026-06-24 17:21:22 -04:00
package.json feat: st command suite, Webex phone + Atlas AV integrations, dockerized remote agent 2026-07-06 09:54:41 -04:00
README.md feat: st command suite, Webex phone + Atlas AV integrations, dockerized remote agent 2026-07-06 09:54:41 -04:00
remoteAgent.js feat: st command suite, Webex phone + Atlas AV integrations, dockerized remote agent 2026-07-06 09:54:41 -04:00
server.js feat: st command suite, Webex phone + Atlas AV integrations, dockerized remote agent 2026-07-06 09:54:41 -04:00

StoreHealthAnalyzer

Webex bot that provides store-level network and device health analysis by correlating data from Meraki, SIW (Store Information Warehouse), and Workspace ONE MDM.

What it does

  • st [number] — Store info pulled from SIW (location, brand, status, environment). No upstream checks.
  • st [number] network — Switch, AP, and store-server health (Meraki + MDM).
  • st [number] pos — POS devices: registers, mobile registers, printers, payment terminals.
  • st [number] ios — iOS devices (mainly iPhones).
  • st [number] phone — wired Cisco 78xx IP phones (Meraki-matched by MAC) and DECT basestations + handsets via Webex Service App.
  • st [number] av — A/V hardware snapshot: Atlas (Xyte) AMPs plus MDM-tracked Apple TVs, video walls (*VW*), music players (*MSC*), and LED displays (*LED*). Each row is MAC-matched into Meraki for the where-it's-connected line.

The bot helps operations teams quickly understand the connectivity and device state of a retail location.

Architecture

┌─────────────────┐          ┌─────────────────────┐
│  Webex Bot      │◄────────►│ StoreHealthAnalyzer │
│  (Webex rooms)  │  Webex   │  (server.js)        │
└─────────────────┘          └─────────┬───────────┘
                                       │
                                       │ WebSocket (authenticated)
                                       ▼
                              ┌─────────────────────┐
                              │  Remote Agent       │◄──► SIW API (Basic Auth)
                              │  (remoteAgent.js)   │◄──► Workspace ONE MDM
                              └─────────────────────┘
                                       │
                                       ▼
                              Meraki API (direct from server)

Why the remote agent?
SIW and some MDM systems are only reachable from specific internal networks. The remote agent runs in that environment and proxies requests back to the main StoreHealthAnalyzer server over an authenticated WebSocket.

Prerequisites

  • Node.js >= 18
  • A Webex bot account with access token
  • Meraki API key with organization access
  • Access to your organization's SIW API and Workspace ONE (AirWatch) environment
  • Ability to run the remote agent on a machine that can reach SIW/MDM

Setup

  1. Clone and install

    git clone <repo>
    cd storehealthanalyzer
    npm install
    
  2. Configure environment

    cp .env.example .env
    # Edit .env with your real credentials
    

    Required values are documented in .env.example.

  3. (Optional but recommended) Rotate all secrets If you previously had credentials in the repository, rotate:

    • Webex bot token
    • Meraki API key
    • SIW credentials
    • Workspace ONE client secret + tenant code
    • WS_TOKEN

Running

Main server + bot (where the Webex connection lives)

npm start
# or for development with auto-reload
npm run dev

This starts:

  • The Webex bot framework (listens for messages in Webex spaces)
  • The WebSocket server on the port defined in WS_PORT (default 8080)

Remote agent (run on a machine that can reach internal systems)

Two options — pick whichever fits your host.

Option A: run directly with Node

# On the internal machine
node remoteAgent.js

Make sure WS_URL in its environment points to the main server with the correct WS_TOKEN.

Option B: run as a Docker container (build + run on the same host)

Ship the agent as a standalone container instead of installing Node on the host. Full instructions live in docker/remote-agent/README.md; the short version:

# Build (from the repo root)
docker build -f docker/remote-agent/Dockerfile -t sha-remote-agent:latest .

# Configure
cp docker/remote-agent/.env.example docker/remote-agent/.env
# Edit WS_URL + WS_TOKEN

# Run (compose is the easy path)
docker compose -f docker/remote-agent/docker-compose.yml up -d --build

The container is small (~6080 MB), ships only remoteAgent.js + its three runtime deps (ws, axios, dotenv), and runs as the unprivileged node user. tini is PID 1 so docker stop closes the websocket cleanly.

Option C: package as a portable ZIP for offline / manual transfer

If the remote host can't reach a registry (or you just want a one-file drop), package everything into a self-contained ZIP:

# On the dev machine (from the repo root)
npm run agent:package
# → docker/remote-agent/dist/sha-remote-agent-<version>.zip

Then copy the ZIP to the remote host and:

unzip sha-remote-agent-<version>.zip
cd sha-remote-agent-<version>
./install.sh    # loads the image, seeds .env, starts the container

The bundle contains the Docker image tarball, a runtime docker-compose.yml, a SHA-256 checksum, and an installer that verifies + loads + starts. Details in docker/remote-agent/deploy/README.md (also included inside the ZIP as its top-level README.md).

Webex Service App (only needed for st [number] phone)

The phone command talks to Webex APIs (people, devices, telephony) as a Service App, separate from the bot identity. Cisco rotates the refresh token on every call, so the only manual step is seeding the initial tokens once.

  1. Set WEBEX_CLIENT_ID and WEBEX_CLIENT_SECRET in .env (from the Service App registration in the Webex Developer Portal).

  2. Seed the tokens file:

    # Default: imports the tokens JSON from collabFinder, verifies it via one immediate refresh
    npm run webex:seed
    
    # Or import a tokens JSON from a specific path
    npm run webex:seed -- --from-file /path/to/webex-service-tokens.json
    
    # Or seed with a raw refresh token string
    npm run webex:seed -- --refresh-token <refresh-token-value>
    

    The seed step performs one grant_type=refresh_token call against https://webexapis.com/v1/access_token and writes the rotated pair to WEBEX_TOKENS_PATH (default ./tokens/webex-service-tokens.json, gitignored).

  3. From then on, the auth singleton keeps the token fresh automatically (5-minute safety buffer ahead of the stated expiry). If the refresh token is ever revoked, st [number] phone degrades to a single warning banner ("Webex phone data unavailable") instead of erroring — re-seed and try again.

Atlas / Xyte (only needed for st [number] av)

The AV command pulls hardware data from two places:

  • Atlas (hub.xyte.io) — long-lived API key. Add ATLAS_AUTH_KEY (and optionally ATLAS_BASE_URL) to .env. The Atlas client maintains a 1-hour in-process cache of the org-wide device list, then filters by zero-padded store number (e.g. store 782 → US000782*) to find that store's AMPs.
  • MDM (Workspace ONE) — uses the existing WebSocket-proxied MDM connection. The AV view filters MDM devices whose friendly name matches /(VW|MSC|LED|AppleTV)/i, mirroring the collabFinder strict-AV filter.

If ATLAS_AUTH_KEY is unset (or the Atlas API is unreachable), the AV report still renders the MDM-tracked devices and shows an inline "Atlas AV data unavailable" banner above them. Likewise, an MDM outage doesn't suppress the Atlas section. Both sources empty → _No AV hardware registered for this store._.

Bot Commands

In any Webex space where the bot is a member:

  • st 782 — store info (location, brand, status, environment)
  • st 782 network — switches, APs, store server(s)
  • st 782 pos — POS devices (registers, mobile registers, customer displays, printers, payment terminals)
  • st 782 ios — iOS devices (iPhones)
  • st 782 phone — wired (78xx) + DECT bases & handsets via Webex
  • st 782 av — Atlas AMPs + MDM-tracked Apple TVs / video walls / music / LED displays
  • help / help st — show this list inside Webex.

The bot answers single-message replies by default and only splits them into multiple messages when the output exceeds ~7000 characters (Webex's safe message-size cap).

Project Structure

.
├── bot/
│   └── handlers.js          # Webex command handlers (store / analyze / help)
├── config/
│   └── index.js             # Centralized env-driven configuration + validation
├── integrations/
│   ├── storeDetail.js       # Per-mode store report builder (Meraki + SIW + MDM + Webex phones + Atlas AV)
│   ├── atlas/
│   │   ├── atlasClient.js          # Atlas (hub.xyte.io) axios wrapper + AtlasUnavailableError
│   │   └── atlasDevices.js         # Paginated org-device fetcher + 1h in-memory cache
│   └── webex/
│       └── WebexServiceAppAuth.js  # Service App OAuth singleton w/ auto-rotating refresh
├── models/
│   └── Store.js             # Store domain model (normalizes SIW location + general)
├── scripts/
│   ├── cleanupWebexDevices.js
│   └── seedWebexTokens.js   # One-shot Webex Service App token seed
├── services/
│   ├── meraki.js            # Meraki API client (cached, retried)
│   ├── siw.js               # SIW calls (proxied via the remote agent)
│   ├── mdm.js               # Workspace ONE MDM client (retried)
│   ├── webexService.js      # Webex API axios wrapper (auto-refresh on 401)
│   ├── webexPhone.js        # Phone discovery: 78xx + DECT bases + handsets
│   ├── avService.js         # AV (Atlas) device shaper for `st [number] av`
│   └── websocket.js         # WS server + proxyRequest helper
├── tests/
│   ├── *.test.js            # Unit tests
│   ├── integration/         # Integration tests using mocks
│   └── mocks/               # Service mocks for tests
├── utils/
│   ├── chunkReport.js       # Split markdown into <7000-char Webex messages
│   ├── logger.js            # Structured JSON logger (LOG_LEVEL aware)
│   ├── merakiMatcher.js     # Device ↔ Meraki client matching
│   ├── retry.js             # withRetry wrapper (exponential backoff)
│   └── validate.js          # Input parsers
├── docker/
│   └── remote-agent/        # Standalone Docker packaging for remoteAgent.js
│       ├── Dockerfile
│       ├── docker-compose.yml    # Local build/run
│       ├── package.json          # Minimal deps: ws + axios + dotenv
│       ├── package.sh            # Build + save + zip → dist/
│       ├── .env.example
│       ├── README.md
│       ├── deploy/               # Files bundled into the deploy ZIP
│       │   ├── docker-compose.yml    # Runtime-only (references loaded image)
│       │   ├── install.sh            # docker load + .env bootstrap + up
│       │   └── README.md             # Remote-host instructions (also in the ZIP)
│       └── dist/                 # Generated ZIPs (gitignored)
├── constants.js             # Shared constants (STORE_MODES, MDM device types)
├── remoteAgent.js           # Lightweight proxy client (run on internal host)
├── server.js                # Main entry point (bot + WS server)
├── package.json
├── eslint.config.js
├── .prettierrc.json
├── .dockerignore
└── .env.example

Logging

The app emits single-line JSON to stdout/stderr via utils/logger.js. Set LOG_LEVEL in .env to one of debug | info | warn | error (default: info).

Security Notes

  • Never commit .env (it is gitignored, along with .env.bak.*).
  • The WebSocket connection between server and remote agent is protected by a shared WS_TOKEN. The remote agent now sends the token in an Authorization: Bearer header (the legacy ?token=... query parameter still works for older deployments but should be migrated).
  • Only one remote agent may be connected at a time; a newer connection replaces the older one and any in-flight proxy requests are rejected (rather than silently hanging).
  • All SIW communication uses Basic Auth and is only performed through the remote agent.
  • Meraki and MDM calls use tokens that should be scoped to the minimum necessary permissions.

Development

npm run lint          # eslint + prettier check
npm run lint:fix      # auto-fix lint issues
npm run format        # prettier write
npm test              # jest (unit + integration tests using mocks)
npm run test:watch

License

ISC