Post-review cleanup: reliability, docs, CI
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:
parent
025b70de56
commit
f7cda8f0c1
7 changed files with 168 additions and 187 deletions
|
|
@ -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_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) ----
|
||||
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)
|
||||
|
|
@ -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_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.)
|
||||
|
||||
# ---- 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
83
.gitea/workflows/ci.yml
Normal 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
|
||||
87
README.md
87
README.md
|
|
@ -1,27 +1,25 @@
|
|||
# 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
|
||||
|
||||
- Listens for Appspace outbound webhooks (`DEVICE.HEALTHSTATUS.*` and `DEVICE.UNREGISTERED`).
|
||||
- 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).
|
||||
- Provides a Webex bot with commands:
|
||||
- `offline [optional filter]` — snapshot of currently offline/lost/failed devices (client-side filter on name or type).
|
||||
- `help`
|
||||
- Provides a Webex bot with commands (see below).
|
||||
|
||||
## Requirements
|
||||
|
||||
- Node 20+
|
||||
- Node 20+ (enforced via `engines` in `package.json`)
|
||||
- Appspace instance with outbound webhook + Application refresh token
|
||||
- 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:
|
||||
```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.
|
||||
|
||||
Direct:
|
||||
Direct (no Docker):
|
||||
```bash
|
||||
npm install
|
||||
npm start
|
||||
|
|
@ -53,6 +51,28 @@ npm start
|
|||
|
||||
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
|
||||
|
||||
See `.env.example` for the full documented list.
|
||||
|
|
@ -60,36 +80,28 @@ See `.env.example` for the full documented list.
|
|||
Key ones:
|
||||
- `WEBEX_BOT_TOKEN`, `WEBEX_ROOM_ID`
|
||||
- `APPSPACE_INSTANCE_URL`, `APPSPACE_SUBJECT_ID`, `APPSPACE_REFRESH_TOKEN`, `APPSPACE_API_BASE_URL`
|
||||
- `WS1_*` (for MDM enrichment)
|
||||
- `WEBHOOK_SECRET` (recommended for the Appspace webhook)
|
||||
- `DEBUG`, `DEBUG_WEBHOOK` (see below)
|
||||
|
||||
## Bot Commands
|
||||
|
||||
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).
|
||||
- `APPSPACE_CONSOLE_BASE_URL` (used to build per-device Appspace links in Webex cards)
|
||||
- `WS1_BASE_URL` (API hostname, e.g. `as1991.awmdm.com`)
|
||||
- `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`
|
||||
- `WEBHOOK_SECRET` (recommended for the Appspace webhook; validated via `x-webhook-secret` header)
|
||||
- `DEBUG`, `DEBUG_WEBHOOK`, `LOG_FORMAT` (see Debugging)
|
||||
|
||||
## Debugging
|
||||
|
||||
- `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).
|
||||
- `LOG_FORMAT=json` (or `NODE_ENV=production`) — output structured JSON logs (ideal for Docker/K8s log collectors).
|
||||
|
||||
You can also set `NODE_ENV=development` for similar verbose behavior.
|
||||
- `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.
|
||||
|
||||
## Production & Docker Notes
|
||||
|
||||
- **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").
|
||||
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.
|
||||
- **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:
|
||||
- `env_file` for compose (dev/staging only)
|
||||
- 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
|
||||
|
||||
- Appspace token uses refresh token + cooldown + safety buffer.
|
||||
- MDM uses 24h serial→ID cache + fresh detail lookup by ID on every alert (for up-to-date compliance/last-seen).
|
||||
- WebSocket mode for the bot avoids restart rate limits.
|
||||
- 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 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 the WebSocket-restart rate limits associated with the webhook mode.
|
||||
- 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
|
||||
|
||||
|
|
@ -111,6 +131,7 @@ Internal tool. Tweak as needed.
|
|||
## TODO / Future
|
||||
|
||||
- Server-side filtering for the devices list when the Appspace API supports it reliably.
|
||||
- Metrics / full structured JSON logging.
|
||||
- Support for more Appspace event types.
|
||||
- Multi-stage Dockerfile for even smaller prod images.
|
||||
- Optional metrics endpoint (`/metrics` in Prometheus format).
|
||||
- Support for more Appspace event types (e.g. content push failures, if useful).
|
||||
- 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.
|
||||
|
|
|
|||
32
index.js
32
index.js
|
|
@ -1,6 +1,5 @@
|
|||
require('dotenv').config();
|
||||
const express = require('express');
|
||||
const bodyParser = require('body-parser');
|
||||
const axios = require('axios');
|
||||
const Framework = require('webex-node-bot-framework');
|
||||
|
||||
|
|
@ -117,13 +116,14 @@ function shutdown(force = false) {
|
|||
exit(0);
|
||||
}
|
||||
|
||||
// Force exit after a timeout (Docker stop timeout is usually 10s)
|
||||
if (!force) {
|
||||
// ALWAYS arm a safety timeout. If any step above hangs (e.g. framework.stop()
|
||||
// never resolves), we still need to exit before Docker's 10s SIGKILL. When called
|
||||
// from a crash handler (`force=true`) the process is already unhealthy, so exit
|
||||
// faster to minimize the window where we're wedged.
|
||||
setTimeout(() => {
|
||||
logger.error('⏱️ Graceful shutdown timed out. Forcing exit.');
|
||||
exit(1);
|
||||
}, 8000);
|
||||
}
|
||||
}, force ? 3000 : 8000).unref();
|
||||
}
|
||||
|
||||
// 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)
|
||||
});
|
||||
|
||||
app.use(bodyParser.json());
|
||||
app.use(express.json());
|
||||
|
||||
// ======================
|
||||
// APPSPACE TOKEN MANAGEMENT (with cooldown + longer timeout)
|
||||
|
|
@ -227,7 +227,9 @@ async function getValidAccessToken() {
|
|||
// ======================
|
||||
/**
|
||||
* 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) {
|
||||
if (!ts) return 'Unknown';
|
||||
|
|
@ -282,8 +284,8 @@ function buildMdmFactsAndLink(mdmDevice) {
|
|||
if (model && model !== 'Unknown') mdmFacts.push({ "title": "Model", "value": model });
|
||||
if (os && os !== 'Unknown') mdmFacts.push({ "title": "OS Version", "value": os });
|
||||
if (compliance && compliance !== 'Unknown') mdmFacts.push({ "title": "Compliance", "value": compliance });
|
||||
if (lastSampleTime && lastSampleTime !== 'Unknown') mdmFacts.push({ "title": "Last Sample (EDT)", "value": lastSampleTime });
|
||||
if (lastSeenDisplay && lastSeenDisplay !== 'Unknown') mdmFacts.push({ "title": "Last Seen (EDT)", "value": lastSeenDisplay });
|
||||
if (lastSampleTime && lastSampleTime !== 'Unknown') mdmFacts.push({ "title": "Last Sample (ET)", "value": lastSampleTime });
|
||||
if (lastSeenDisplay && lastSeenDisplay !== 'Unknown') mdmFacts.push({ "title": "Last Seen (ET)", "value": lastSeenDisplay });
|
||||
|
||||
return { mdmFacts, mdmConsoleLink };
|
||||
}
|
||||
|
|
@ -369,10 +371,6 @@ function isProblemDevice(device) {
|
|||
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
|
||||
// ======================
|
||||
|
|
@ -518,7 +516,13 @@ if (process.env.SMOKE_TEST !== 'true') {
|
|||
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', () => {
|
||||
logger.info('Webex Bot Framework initialized (WebSocket mode)');
|
||||
|
|
|
|||
4
package-lock.json
generated
4
package-lock.json
generated
|
|
@ -9,13 +9,15 @@
|
|||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"axios": "^1.7.2",
|
||||
"body-parser": "^1.20.2",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.19.2",
|
||||
"webex-node-bot-framework": "^2.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.1.14"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
|
|
|
|||
|
|
@ -2,17 +2,18 @@
|
|||
"name": "appspace-webex-alerts",
|
||||
"version": "1.0.0",
|
||||
"main": "index.js",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "node index.js",
|
||||
"dev": "node index.js",
|
||||
"offline:legacy": "node query-offline.js",
|
||||
"docker:dev": "docker compose --profile dev up app-dev",
|
||||
"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"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.7.2",
|
||||
"body-parser": "^1.20.2",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.19.2",
|
||||
"webex-node-bot-framework": "^2.5.1"
|
||||
|
|
|
|||
130
query-offline.js
130
query-offline.js
|
|
@ -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();
|
||||
Loading…
Reference in a new issue