From f7cda8f0c1f99b0a7db64a5f54327e2d21fd1793 Mon Sep 17 00:00:00 2001 From: jmcqueen Date: Wed, 1 Jul 2026 17:56:11 -0400 Subject: [PATCH] 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 --- .env.example | 8 +-- .gitea/workflows/ci.yml | 83 +++++++++++++++++++++++++ README.md | 87 +++++++++++++++++---------- index.js | 38 ++++++------ package-lock.json | 4 +- package.json | 5 +- query-offline.js | 130 ---------------------------------------- 7 files changed, 168 insertions(+), 187 deletions(-) create mode 100644 .gitea/workflows/ci.yml delete mode 100644 query-offline.js diff --git a/.env.example b/.env.example index 49b67bc..ec00392 100644 --- a/.env.example +++ b/.env.example @@ -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. diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..a0a91fd --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -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 diff --git a/README.md b/README.md index e59e64d..bc2971f 100644 --- a/README.md +++ b/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 ` | 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 ` | 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. diff --git a/index.js b/index.js index f8c85bd..1a7e3a7 100644 --- a/index.js +++ b/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) { - setTimeout(() => { - logger.error('⏱️ Graceful shutdown timed out. Forcing exit.'); - exit(1); - }, 8000); - } + // 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); + }, 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)'); diff --git a/package-lock.json b/package-lock.json index 988b080..8acaa4e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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": { diff --git a/package.json b/package.json index f530f0c..7f78888 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/query-offline.js b/query-offline.js deleted file mode 100644 index 1b39593..0000000 --- a/query-offline.js +++ /dev/null @@ -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(); \ No newline at end of file