Post-review cleanup: reliability, docs, CI
Some checks failed
CI / Syntax check (push) Has been cancelled
CI / Docker build + healthcheck smoke test (push) Has been cancelled

Reliability / correctness:
- Always arm the graceful-shutdown safety timeout. Previously
  `shutdown(force=true)` (called from uncaughtException) skipped the
  timeout entirely, so a hung `framework.stop()` after a crash would
  wedge the process until Docker's SIGKILL. Now uses 3s when forced,
  8s otherwise, and .unref()s so it never blocks a clean exit.
- Attach a `.catch()` to `framework.start()` so a bad Webex token or
  WebSocket handshake failure produces a clear "Webex framework failed
  to start" error line instead of a bare Unhandled Rejection while the
  bot silently stays dead.
- Rename MDM timestamp labels from "(EDT)" to "(ET)" since the
  formatter uses DST-aware America/New_York (half the year it's EST).

Cleanup:
- Drop `body-parser` in favor of the built-in `express.json()`
  (Express 4.16+). Removes one direct dep; still present as a
  transitive dep of express itself.
- Remove orphaned JSDoc block referring to a helper that no longer
  exists.
- Delete legacy `query-offline.js` (marked deprecated since the bot
  `offline` command shipped) and remove its `APPSPACE_API_TOKEN` /
  `APPSPACE_BASE_URL` env vars from `.env.example` and the
  `offline:legacy` npm script from `package.json`.

Config / metadata:
- Add `"engines": { "node": ">=20" }` to package.json so npm warns on
  the wrong Node version instead of just the README saying so.
- Document `SMOKE_TEST=true` in `.env.example`.

Docs:
- Rewrite README to document the `restart-offline` command (iOS
  Supervised requirement, 50-device cap, concurrency, audit log
  fields, fresh-at-execute semantics), the ET-not-EDT labeling,
  structured error logging, character-budget rendering, and the
  new CI workflow. Refresh the TODO section to reflect what has
  actually shipped.

CI:
- Add `.gitea/workflows/ci.yml` with two jobs: syntax check
  (`node --check` on index.js and mdm.js) and a Docker smoke test
  that builds the production image, boots it with dummy credentials
  + SMOKE_TEST=true, and waits up to 30s for the container's
  built-in healthcheck to reach `healthy`. Dumps container logs
  on failure.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jmcqueen 2026-07-01 17:56:11 -04:00
parent 025b70de56
commit f7cda8f0c1
7 changed files with 168 additions and 187 deletions

View file

@ -23,10 +23,6 @@ APPSPACE_REFRESH_TOKEN=your-long-lived-refresh-token
APPSPACE_API_BASE_URL=https://api.cloud.appspace.com # or your regional API base APPSPACE_API_BASE_URL=https://api.cloud.appspace.com # or your regional API base
APPSPACE_CONSOLE_BASE_URL=https://app3.cloud.appspace.com APPSPACE_CONSOLE_BASE_URL=https://app3.cloud.appspace.com
# (Legacy / query-offline.js still references this static token style)
APPSPACE_API_TOKEN=your-static-token-if-needed
APPSPACE_BASE_URL=https://api.cloud.appspace.com
# ---- Workspace ONE MDM (for enrichment) ---- # ---- Workspace ONE MDM (for enrichment) ----
WS1_BASE_URL=https://as1991.awmdm.com # your WS1 server URL (used for API calls in mdm.js) WS1_BASE_URL=https://as1991.awmdm.com # your WS1 server URL (used for API calls in mdm.js)
WS1_CONSOLE_BASE_URL=https://cn1896.awmdm.com # console base for per-device links (MUST be the console hostname like cn1896, NOT the API hostname like as1896 — links will be broken otherwise) WS1_CONSOLE_BASE_URL=https://cn1896.awmdm.com # console base for per-device links (MUST be the console hostname like cn1896, NOT the API hostname like as1896 — links will be broken otherwise)
@ -38,3 +34,7 @@ WS1_TENANT_CODE=your-tenant-code
DEBUG=false # enables verbose per-request / per-lookup logs (mdm lookups, ignores, command receipts, etc.) DEBUG=false # enables verbose per-request / per-lookup logs (mdm lookups, ignores, command receipts, etc.)
DEBUG_WEBHOOK=false # set to "true" to log *full* Appspace webhook JSON payloads (avoid in prod - may contain sensitive data) DEBUG_WEBHOOK=false # set to "true" to log *full* Appspace webhook JSON payloads (avoid in prod - may contain sensitive data)
LOG_FORMAT=json # set to "json" (or NODE_ENV=production) for structured JSON logs suitable for Docker log aggregation (Loki, CloudWatch, etc.) LOG_FORMAT=json # set to "json" (or NODE_ENV=production) for structured JSON logs suitable for Docker log aggregation (Loki, CloudWatch, etc.)
# ---- CI / test-only ----
# SMOKE_TEST=true # when set, skips Webex bot framework startup so the container can boot to a healthy /health
# with dummy credentials. Used by `npm run docker:smoke` and the CI workflow. Do NOT set in prod.

83
.gitea/workflows/ci.yml Normal file
View file

@ -0,0 +1,83 @@
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
syntax:
name: Syntax check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Node --check on source files
run: |
set -e
node --check index.js
node --check mdm.js
docker-smoke:
name: Docker build + healthcheck smoke test
runs-on: ubuntu-latest
needs: syntax
steps:
- uses: actions/checkout@v4
- name: Build production image
run: docker build -t appspace-webex:ci .
# Boot the container with dummy credentials + SMOKE_TEST=true so the Webex
# bot framework is skipped. The Dockerfile's built-in HEALTHCHECK hits /health;
# we poll `docker inspect` for the health status.
- name: Start container with dummy credentials
run: |
docker run -d --name smoke \
-e PORT=3000 \
-e NODE_ENV=production \
-e SMOKE_TEST=true \
-e WEBEX_BOT_TOKEN=smoke-test-bot-token \
-e WEBEX_ROOM_ID=smoke-test-room-id \
-e APPSPACE_INSTANCE_URL=https://smoke.example.com \
-e APPSPACE_SUBJECT_ID=smoke-subject \
-e APPSPACE_REFRESH_TOKEN=smoke-refresh-token \
-e APPSPACE_API_BASE_URL=https://smoke.example.com \
appspace-webex:ci
- name: Wait for container to become healthy
run: |
set -e
for i in $(seq 1 30); do
status=$(docker inspect --format='{{.State.Health.Status}}' smoke 2>/dev/null || echo none)
echo "attempt $i: status=$status"
if [ "$status" = "healthy" ]; then
echo "Container reached healthy"
exit 0
fi
sleep 1
done
echo "Container failed to reach healthy within 30s"
docker logs smoke
exit 1
- name: Container logs on success (for reference)
if: success()
run: docker logs smoke
- name: Container logs on failure
if: failure()
run: docker logs smoke || true
- name: Cleanup
if: always()
run: docker rm -f smoke || true

View file

@ -1,27 +1,25 @@
# Appspace Webex Alerts # Appspace Webex Alerts
Lightweight Node/Express service that bridges **Appspace** device health events to **Cisco Webex** via Adaptive Cards, with optional enrichment from **Workspace ONE MDM**. Lightweight Node/Express service that bridges **Appspace** device health events to **Cisco Webex** via Adaptive Cards, with optional enrichment (and remediation) from **Workspace ONE MDM**.
It also runs an interactive Webex bot (WebSocket mode) for on-demand queries. It also runs an interactive Webex bot (WebSocket mode) for on-demand queries and actions.
## What it does ## What it does
- Listens for Appspace outbound webhooks (`DEVICE.HEALTHSTATUS.*` and `DEVICE.UNREGISTERED`). - Listens for Appspace outbound webhooks (`DEVICE.HEALTHSTATUS.*` and `DEVICE.UNREGISTERED`).
- Ignores PWA devices. - Ignores PWA devices.
- Enriches alerts with current MDM data (model, OS version, compliance, last sample/seen timestamps in EDT). - Enriches alerts with current MDM data (model, OS version, compliance, last sample/seen timestamps in Eastern Time — DST-aware).
- Posts formatted Adaptive Cards to a configured Webex room (with direct links to both consoles). - Posts formatted Adaptive Cards to a configured Webex room (with direct links to both consoles).
- Provides a Webex bot with commands: - Provides a Webex bot with commands (see below).
- `offline [optional filter]` — snapshot of currently offline/lost/failed devices (client-side filter on name or type).
- `help`
## Requirements ## Requirements
- Node 20+ - Node 20+ (enforced via `engines` in `package.json`)
- Appspace instance with outbound webhook + Application refresh token - Appspace instance with outbound webhook + Application refresh token
- Webex bot token + room ID - Webex bot token + room ID
- (Optional but recommended) Workspace ONE MDM OAuth client for enrichment - (Optional but recommended) Workspace ONE MDM OAuth client for enrichment and reboot
## Quick Start (Docker - recommended) ## Quick Start (Docker recommended)
1. Copy env: 1. Copy env:
```bash ```bash
@ -45,7 +43,7 @@ It also runs an interactive Webex bot (WebSocket mode) for on-demand queries.
**Port mapping notes**: The container always listens internally on port 3000 (hardened default). Host port 1889 is used for both dev (`docker compose --profile dev up -d app-dev`) and prod (`docker compose up -d`). The `PORT` env inside the container is forced to 3000 via compose. You cannot run both profiles at the same time due to the shared host port. **Port mapping notes**: The container always listens internally on port 3000 (hardened default). Host port 1889 is used for both dev (`docker compose --profile dev up -d app-dev`) and prod (`docker compose up -d`). The `PORT` env inside the container is forced to 3000 via compose. You cannot run both profiles at the same time due to the shared host port.
Direct: Direct (no Docker):
```bash ```bash
npm install npm install
npm start npm start
@ -53,6 +51,28 @@ npm start
Health check: `GET /health` Health check: `GET /health`
## Bot Commands
In the Webex space where the bot is added — either as a DM to the bot, or `@mention` in a group space:
| Command | What it does |
| --- | --- |
| `offline` | Snapshot of all currently offline / lost / failed Appspace devices, grouped by location, enriched with per-device MDM facts and clickable Appspace + Workspace ONE console links. |
| `offline <filter>` | Same, narrowed to devices whose `deviceType` or name contains the filter substring. E.g. `offline ios`, `offline lobby`. |
| `restart-offline` | Sends a Workspace ONE **SoftReset** (reboot) to every currently-offline device that maps to a WS1 record by serial. Capped at **50** devices per invocation. Skipped devices (no WS1 record) are reported separately. |
| `restart-offline <filter>` | Same, narrowed to devices matching the filter. Use this to get under the 50-device cap for large fleets (e.g. `restart-offline ios`). |
| `help` | Print the command list. |
Notes on `restart-offline`:
- **Fresh at execute time.** The command re-queries Appspace at the moment of execution, so devices that came back online after you last looked are automatically excluded.
- **No confirmation prompt.** Configured for immediate execution per project preference — narrow with a filter if you want to limit scope.
- **iOS Supervised requirement.** WS1's SoftReset only actually reboots iOS devices that are Supervised (DEP-enrolled). Non-Supervised devices will surface a clean WS1 error in the failure list; they will not silently appear to succeed.
- **Rate-limited.** Reboots are sent with concurrency = 3 to avoid hammering the WS1 API.
- **Auditable.** Every invocation emits a JSON log line with `user`, `filter`, `matched`, `withMdm`, `withoutMdm`, `succeeded`, and `failed` counts.
The bot runs in WebSocket mode (no public webhook required).
## Environment Variables ## Environment Variables
See `.env.example` for the full documented list. See `.env.example` for the full documented list.
@ -60,36 +80,28 @@ See `.env.example` for the full documented list.
Key ones: Key ones:
- `WEBEX_BOT_TOKEN`, `WEBEX_ROOM_ID` - `WEBEX_BOT_TOKEN`, `WEBEX_ROOM_ID`
- `APPSPACE_INSTANCE_URL`, `APPSPACE_SUBJECT_ID`, `APPSPACE_REFRESH_TOKEN`, `APPSPACE_API_BASE_URL` - `APPSPACE_INSTANCE_URL`, `APPSPACE_SUBJECT_ID`, `APPSPACE_REFRESH_TOKEN`, `APPSPACE_API_BASE_URL`
- `WS1_*` (for MDM enrichment) - `APPSPACE_CONSOLE_BASE_URL` (used to build per-device Appspace links in Webex cards)
- `WEBHOOK_SECRET` (recommended for the Appspace webhook) - `WS1_BASE_URL` (API hostname, e.g. `as1991.awmdm.com`)
- `DEBUG`, `DEBUG_WEBHOOK` (see below) - `WS1_CONSOLE_BASE_URL` (console hostname, e.g. `cn1896.awmdm.com` — different from the API host; used for clickable console links)
- `WS1_CLIENT_ID`, `WS1_CLIENT_SECRET`, `WS1_TENANT_CODE`
## Bot Commands - `WEBHOOK_SECRET` (recommended for the Appspace webhook; validated via `x-webhook-secret` header)
- `DEBUG`, `DEBUG_WEBHOOK`, `LOG_FORMAT` (see Debugging)
In the Webex space where the bot is added:
- `offline` — current problematic devices
- `offline tablet` — filter to devices whose name or type contains "tablet"
- `help`
The bot runs in WebSocket mode (no public webhook required).
## Debugging ## Debugging
- `DEBUG=true` — verbose logging for MDM lookups, ignored events, command handling, etc. (very useful in dev, noisy in prod). - `DEBUG=true` — verbose logging for MDM lookups, ignored events, command handling, etc. (very useful in dev, noisy in prod).
- `DEBUG_WEBHOOK=true` — log the **full** incoming Appspace webhook payload (contains device details; do **not** leave on in production). - `DEBUG_WEBHOOK=true` — log the **full** incoming Appspace webhook payload (contains device details; do **not** leave on in production).
- `LOG_FORMAT=json` (or `NODE_ENV=production`) — output structured JSON logs (ideal for Docker/K8s log collectors). - `LOG_FORMAT=json` (or `NODE_ENV=production`) — output structured JSON logs (ideal for Docker/K8s log collectors).
- `SMOKE_TEST=true` — skips Webex bot framework startup so the container can reach healthy status with dummy credentials. Used only by the smoke test and CI; do not set in prod.
You can also set `NODE_ENV=development` for similar verbose behavior.
## Production & Docker Notes ## Production & Docker Notes
- **Graceful shutdown**: The service handles `SIGTERM` (used by `docker stop`, Kubernetes, etc.) and `SIGINT`. It will: - **Graceful shutdown**: The service handles `SIGTERM` (used by `docker stop`, Kubernetes, etc.) and `SIGINT`. It will:
1. Stop the Webex WebSocket framework (important to avoid "excessive device registrations"). 1. Stop the Webex WebSocket framework (important to avoid "excessive device registrations").
2. Close the HTTP server. 2. Close the HTTP server.
3. Exit cleanly. A hard timeout forces exit after ~8s. 3. Exit cleanly. A hard safety timeout forces exit after ~8s (or ~3s from a crash handler).
- **Healthcheck**: `/health` returns 200 with basic status. Used by Docker and orchestrators. - **Healthcheck**: `/health` returns 200 with basic status. Used by Docker and orchestrators.
- **Logging**: Logs go to stdout/stderr (12-factor / Docker friendly). Use `LOG_FORMAT=json` or `NODE_ENV=production` for structured JSON. Use `DEBUG=true` in non-prod for detail. Pipe to a collector (Loki, CloudWatch, etc.) as needed. - **Logging**: Logs go to stdout/stderr (12-factor / Docker friendly). Use `LOG_FORMAT=json` or `NODE_ENV=production` for structured JSON. Use `DEBUG=true` in non-prod for detail. Pipe to a collector (Loki, CloudWatch, etc.) as needed. Errors (including axios failures) are serialized with `message`, `stack`, `code`, `responseStatus`, and `responseData` so failures are actually visible in logs.
- **Secrets**: Never bake secrets into the image. Use: - **Secrets**: Never bake secrets into the image. Use:
- `env_file` for compose (dev/staging only) - `env_file` for compose (dev/staging only)
- Docker secrets, Kubernetes Secrets, or a secrets manager (Vault, AWS Secrets Manager) for production. - Docker secrets, Kubernetes Secrets, or a secrets manager (Vault, AWS Secrets Manager) for production.
@ -99,10 +111,18 @@ You can also set `NODE_ENV=development` for similar verbose behavior.
## Architecture Notes ## Architecture Notes
- Appspace token uses refresh token + cooldown + safety buffer. - Appspace token uses refresh-token flow with cooldown, safety buffer, and in-flight promise coalescing so burst traffic doesn't stampede the token endpoint.
- MDM uses 24h serial→ID cache + fresh detail lookup by ID on every alert (for up-to-date compliance/last-seen). - MDM uses a 24h serial→Id cache + fresh detail lookup by Id on every alert (for up-to-date compliance/last-seen). Cache refresh and OAuth token fetch are also coalesced.
- WebSocket mode for the bot avoids restart rate limits. - WebSocket mode for the bot avoids the WebSocket-restart rate limits associated with the webhook mode.
- All enrichment is best-effort; alerts are never blocked by MDM or token issues. - All enrichment is best-effort; alerts are never blocked by MDM or token issues.
- Webex message rendering respects the 7439-character pre-encryption limit by building bodies incrementally against a character budget, with accurate "N more not shown" truncation notes.
- Bot command matching uses string phrases so the framework's `(^| )phrase($| )` wrapper handles group-space `@mentions` correctly. Filter parsing works identically in DMs and mentioned messages via a shared helper.
## CI
`.gitea/workflows/ci.yml` builds the Docker image on every push to `main` and every pull request, then boots the container with dummy credentials (`SMOKE_TEST=true`) and verifies the built-in healthcheck reaches `healthy`. Fails the run and dumps container logs if it doesn't.
You can reproduce the same check locally with `npm run docker:smoke`.
## License / Support ## License / Support
@ -111,6 +131,7 @@ Internal tool. Tweak as needed.
## TODO / Future ## TODO / Future
- Server-side filtering for the devices list when the Appspace API supports it reliably. - Server-side filtering for the devices list when the Appspace API supports it reliably.
- Metrics / full structured JSON logging. - Optional metrics endpoint (`/metrics` in Prometheus format).
- Support for more Appspace event types. - Support for more Appspace event types (e.g. content push failures, if useful).
- Multi-stage Dockerfile for even smaller prod images. - Multi-stage Dockerfile for even smaller prod images (current image is already Alpine + prod-only npm deps).
- Optional email allowlist for `restart-offline` (env-driven), if the current "any user in the bot's space can invoke it" policy becomes too permissive.

View file

@ -1,6 +1,5 @@
require('dotenv').config(); require('dotenv').config();
const express = require('express'); const express = require('express');
const bodyParser = require('body-parser');
const axios = require('axios'); const axios = require('axios');
const Framework = require('webex-node-bot-framework'); const Framework = require('webex-node-bot-framework');
@ -117,13 +116,14 @@ function shutdown(force = false) {
exit(0); exit(0);
} }
// Force exit after a timeout (Docker stop timeout is usually 10s) // ALWAYS arm a safety timeout. If any step above hangs (e.g. framework.stop()
if (!force) { // never resolves), we still need to exit before Docker's 10s SIGKILL. When called
setTimeout(() => { // from a crash handler (`force=true`) the process is already unhealthy, so exit
logger.error('⏱️ Graceful shutdown timed out. Forcing exit.'); // faster to minimize the window where we're wedged.
exit(1); setTimeout(() => {
}, 8000); logger.error('⏱️ Graceful shutdown timed out. Forcing exit.');
} exit(1);
}, force ? 3000 : 8000).unref();
} }
// Basic crash handlers for container environments (Docker/K8s will restart on non-zero exit) // Basic crash handlers for container environments (Docker/K8s will restart on non-zero exit)
@ -144,7 +144,7 @@ process.on('unhandledRejection', (reason) => {
// Do not call shutdown - keep the HTTP server running (e.g. Webex bot errors shouldn't kill webhook path) // Do not call shutdown - keep the HTTP server running (e.g. Webex bot errors shouldn't kill webhook path)
}); });
app.use(bodyParser.json()); app.use(express.json());
// ====================== // ======================
// APPSPACE TOKEN MANAGEMENT (with cooldown + longer timeout) // APPSPACE TOKEN MANAGEMENT (with cooldown + longer timeout)
@ -227,7 +227,9 @@ async function getValidAccessToken() {
// ====================== // ======================
/** /**
* Normalize and format MDM timestamps (LastSystemSampleTime, LastSeen, etc.) * Normalize and format MDM timestamps (LastSystemSampleTime, LastSeen, etc.)
* Handles missing 'Z' suffix from some WS1/Appspace responses and formats in EDT. * Handles missing 'Z' suffix from some WS1/Appspace responses and formats in
* Eastern Time (America/New_York, DST-aware so it renders as EST or EDT
* automatically depending on the date).
*/ */
function formatMdmTimestamp(ts) { function formatMdmTimestamp(ts) {
if (!ts) return 'Unknown'; if (!ts) return 'Unknown';
@ -282,8 +284,8 @@ function buildMdmFactsAndLink(mdmDevice) {
if (model && model !== 'Unknown') mdmFacts.push({ "title": "Model", "value": model }); if (model && model !== 'Unknown') mdmFacts.push({ "title": "Model", "value": model });
if (os && os !== 'Unknown') mdmFacts.push({ "title": "OS Version", "value": os }); if (os && os !== 'Unknown') mdmFacts.push({ "title": "OS Version", "value": os });
if (compliance && compliance !== 'Unknown') mdmFacts.push({ "title": "Compliance", "value": compliance }); if (compliance && compliance !== 'Unknown') mdmFacts.push({ "title": "Compliance", "value": compliance });
if (lastSampleTime && lastSampleTime !== 'Unknown') mdmFacts.push({ "title": "Last Sample (EDT)", "value": lastSampleTime }); if (lastSampleTime && lastSampleTime !== 'Unknown') mdmFacts.push({ "title": "Last Sample (ET)", "value": lastSampleTime });
if (lastSeenDisplay && lastSeenDisplay !== 'Unknown') mdmFacts.push({ "title": "Last Seen (EDT)", "value": lastSeenDisplay }); if (lastSeenDisplay && lastSeenDisplay !== 'Unknown') mdmFacts.push({ "title": "Last Seen (ET)", "value": lastSeenDisplay });
return { mdmFacts, mdmConsoleLink }; return { mdmFacts, mdmConsoleLink };
} }
@ -369,10 +371,6 @@ function isProblemDevice(device) {
return ['OFFLINE', 'LOSTCOMMUNICATION', 'FAILED'].includes(status); return ['OFFLINE', 'LOSTCOMMUNICATION', 'FAILED'].includes(status);
} }
/**
* Build a clean Markdown table for offline device list.
* Much more reliable than fixed-width ASCII in Webex (and other clients).
*/
// ====================== // ======================
// HEALTHCHECK // HEALTHCHECK
// ====================== // ======================
@ -518,7 +516,13 @@ if (process.env.SMOKE_TEST !== 'true') {
token: process.env.WEBEX_BOT_TOKEN, token: process.env.WEBEX_BOT_TOKEN,
}); });
framework.start(); // If the framework fails to start (bad token, WebSocket handshake failure,
// Webex-side outage) the rejection would otherwise be swallowed by the global
// unhandledRejection handler and the bot would silently stay dead while
// Express keeps serving. Surface it explicitly so the failure is obvious.
framework.start().catch((err) => {
logger.error('Webex framework failed to start (bot will be unresponsive; HTTP server continues)', err);
});
framework.on('initialized', () => { framework.on('initialized', () => {
logger.info('Webex Bot Framework initialized (WebSocket mode)'); logger.info('Webex Bot Framework initialized (WebSocket mode)');

4
package-lock.json generated
View file

@ -9,13 +9,15 @@
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"axios": "^1.7.2", "axios": "^1.7.2",
"body-parser": "^1.20.2",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"express": "^4.19.2", "express": "^4.19.2",
"webex-node-bot-framework": "^2.5.1" "webex-node-bot-framework": "^2.5.1"
}, },
"devDependencies": { "devDependencies": {
"nodemon": "^3.1.14" "nodemon": "^3.1.14"
},
"engines": {
"node": ">=20"
} }
}, },
"node_modules/@babel/code-frame": { "node_modules/@babel/code-frame": {

View file

@ -2,17 +2,18 @@
"name": "appspace-webex-alerts", "name": "appspace-webex-alerts",
"version": "1.0.0", "version": "1.0.0",
"main": "index.js", "main": "index.js",
"engines": {
"node": ">=20"
},
"scripts": { "scripts": {
"start": "node index.js", "start": "node index.js",
"dev": "node index.js", "dev": "node index.js",
"offline:legacy": "node query-offline.js",
"docker:dev": "docker compose --profile dev up app-dev", "docker:dev": "docker compose --profile dev up app-dev",
"docker:prod": "docker compose up -d", "docker:prod": "docker compose up -d",
"docker:smoke": "echo '🚀 Running Docker smoke test (build + health check)...' && docker compose --profile smoke build smoke-test && docker compose --profile smoke up -d smoke-test && (for i in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30; do status=$(docker inspect --format='{{.State.Health.Status}}' appspace-smoke-test 2>/dev/null || echo 'none'); if [ \"$status\" = 'healthy' ]; then echo '✅ Smoke test PASSED: container healthcheck is healthy'; success=1; break; fi; sleep 1; done; if [ -z \"$success\" ]; then echo \"❌ Smoke test FAILED: final health status was $status\"; docker compose --profile smoke logs --tail=100 smoke-test; false; fi) ; docker compose --profile smoke down --remove-orphans smoke-test 2>/dev/null || true" "docker:smoke": "echo '🚀 Running Docker smoke test (build + health check)...' && docker compose --profile smoke build smoke-test && docker compose --profile smoke up -d smoke-test && (for i in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30; do status=$(docker inspect --format='{{.State.Health.Status}}' appspace-smoke-test 2>/dev/null || echo 'none'); if [ \"$status\" = 'healthy' ]; then echo '✅ Smoke test PASSED: container healthcheck is healthy'; success=1; break; fi; sleep 1; done; if [ -z \"$success\" ]; then echo \"❌ Smoke test FAILED: final health status was $status\"; docker compose --profile smoke logs --tail=100 smoke-test; false; fi) ; docker compose --profile smoke down --remove-orphans smoke-test 2>/dev/null || true"
}, },
"dependencies": { "dependencies": {
"axios": "^1.7.2", "axios": "^1.7.2",
"body-parser": "^1.20.2",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"express": "^4.19.2", "express": "^4.19.2",
"webex-node-bot-framework": "^2.5.1" "webex-node-bot-framework": "^2.5.1"

View file

@ -1,130 +0,0 @@
/**
* DEPRECATED / LEGACY
*
* This standalone script is no longer the recommended way to query offline devices.
*
* Use the Webex bot command instead:
* In your Webex space: type `offline` (or `offline some-filter`)
*
* The main server (index.js) now provides a much better implementation:
* - Uses the Appspace refresh token flow (not static APPSPACE_API_TOKEN)
* - Shares token management with the webhook path
* - Has filtering, better table output, and MDM enrichment in other flows
*
* This file is kept only for emergency/audit use. It uses older env var conventions
* (APPSPACE_BASE_URL + APPSPACE_API_TOKEN) and performs a one-shot query then exits.
*
* Consider removing this file once the bot command has been validated in production.
*/
require('dotenv').config();
const axios = require('axios');
const WEBEX_BOT_TOKEN = process.env.WEBEX_BOT_TOKEN;
const WEBEX_ROOM_ID = process.env.WEBEX_ROOM_ID;
const APPSPACE_API_TOKEN = process.env.APPSPACE_API_TOKEN; // Legacy static token
const APPSPACE_API_BASE_URL = process.env.APPSPACE_BASE_URL || 'https://api.cloud.appspace.com';
if (!WEBEX_BOT_TOKEN || !WEBEX_ROOM_ID || !APPSPACE_API_TOKEN) {
console.error('❌ Missing required env vars: WEBEX_BOT_TOKEN, WEBEX_ROOM_ID, APPSPACE_API_TOKEN');
process.exit(1);
}
console.warn('⚠️ Running DEPRECATED query-offline.js script. Prefer the "offline" command via the Webex bot.');
async function queryOfflineDevices() {
try {
console.log('🔍 Querying Appspace for offline / lost devices...');
// Example API call - adjust endpoint based on your exact API docs
// Common pattern: GET /devices with filters for health status
const response = await axios.get(`${APPSPACE_API_BASE_URL}/api/v3/devices`, {
headers: {
Authorization: `Bearer ${APPSPACE_API_TOKEN}`,
'Content-Type': 'application/json'
},
params: {
// Filter examples (test and refine in Postman first)
healthStatus: 'LostCommunication,Offline,Failed', // or use separate calls if needed
limit: 200,
// locationId: 'optional-filter',
// include: 'location,group'
}
});
const devices = response.data.items || response.data; // adjust based on actual response shape
const offlineDevices = devices.filter(d =>
['LostCommunication', 'Offline', 'Failed'].includes(d.healthStatus || d.status)
);
if (offlineDevices.length === 0) {
await sendToWebex('✅ **All devices are currently online or in sync.** No offline devices detected.');
console.log('✅ No offline devices');
return;
}
// Build a nice Adaptive Card summary
const facts = offlineDevices.map(d => ({
title: d.name || d.deviceName || 'Unknown',
value: `${d.healthStatus || d.status}${d.locationName || 'No location'} • IP: ${d.ipAddress || 'N/A'}`
}));
const adaptiveCard = {
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
"type": "AdaptiveCard",
"version": "1.3",
"body": [
{
"type": "Container",
"style": "attention",
"bleed": true,
"items": [
{ "type": "TextBlock", "text": `📊 Offline Devices Snapshot (${offlineDevices.length})`, "weight": "bolder", "size": "medium" }
]
},
{
"type": "FactSet",
"facts": facts
}
],
"actions": [
{
"type": "Action.OpenUrl",
"title": "🔗 Open Devices in Appspace Console",
"url": "https://app3.cloud.appspace.com/console/#!/devices"
}
]
};
await sendToWebex(null, adaptiveCard);
console.log(`✅ Sent ${offlineDevices.length} offline devices to Webex`);
} catch (err) {
console.error('❌ Error querying offline devices:', err.response?.data || err.message);
await sendToWebex('⚠️ Failed to query offline devices. Check API token and console.');
}
}
async function sendToWebex(text, card = null) {
const payload = {
roomId: WEBEX_ROOM_ID,
text: text || 'Appspace Offline Devices Report'
};
if (card) {
payload.attachments = [{
contentType: "application/vnd.microsoft.card.adaptive",
content: card
}];
}
await axios.post('https://webexapis.com/v1/messages', payload, {
headers: {
Authorization: `Bearer ${WEBEX_BOT_TOKEN}`,
'Content-Type': 'application/json'
}
});
}
queryOfflineDevices();