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_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
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
|
# 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.
|
||||||
|
|
|
||||||
38
index.js
38
index.js
|
|
@ -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
4
package-lock.json
generated
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
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