Initial commit with Docker deployment support.

Containerize the monitor with production and dev compose stacks, fix healthchecks and port handling, and make the dashboard base path configurable for direct or proxied access.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jmcqueen 2026-07-23 08:04:02 -04:00
commit 935c27854b
34 changed files with 4353 additions and 0 deletions

49
.dockerignore Normal file
View file

@ -0,0 +1,49 @@
# Dependencies
node_modules/
npm-debug.log
yarn-debug.log
pnpm-debug.log
# Environment & Secrets
.env
.env.*
.env.local
.env.*.local
# Runtime generated / sensitive data
data/
logs/
*.log
activeThresholdAlerts.json
# OS / Editor
.DS_Store
Thumbs.db
*.swp
*.swo
*~
.vscode/
.idea/
# Git
.git/
.gitignore
# Docker (prevent recursive copies)
Dockerfile*
docker-compose*
.docker/
# Test / build artifacts
coverage/
.nyc_output/
dist/
build/
# Misc
README.md
*.md
LICENSE
*.log
tmp/
temp/

82
.env.example Normal file
View file

@ -0,0 +1,82 @@
# =============================================================================
# wxcc-monitor Environment Configuration
#
# Copy this file and rename it (examples below) then fill in real values.
#
# Recommended usage with Docker:
# - Development (native or dev container): cp .env.example .env
# - Production container: cp .env.example .env.prod
#
# Then run:
# Native dev: npm run dev
# Prod container: docker compose -f docker-compose.yml --env-file .env.prod up -d
# Dev container test: docker compose -f docker-compose.dev.yml --env-file .env up
# =============================================================================
# -----------------------------------------------------------------------------
# Server
# -----------------------------------------------------------------------------
# Native dev: use your preferred host port (e.g. 1912).
# Docker: docker-compose.yml overrides this to 3000 inside the container.
PORT=3000
# -----------------------------------------------------------------------------
# Dashboard base path
# -----------------------------------------------------------------------------
# Leave empty for direct access (e.g. http://127.0.0.1:1912).
# Set to /test when the dashboard is served behind a reverse proxy that adds that prefix.
BASE_PATH=
# -----------------------------------------------------------------------------
# WebSocket Authentication (used by the dashboard)
# -----------------------------------------------------------------------------
WS_AUTH_TOKEN=your-super-secret-ws-token-here
# -----------------------------------------------------------------------------
# Webex Contact Center / Integration OAuth Credentials
# -----------------------------------------------------------------------------
WXCC_ORG_ID=your-org-id
WEBEX_CLIENT_ID=your-client-id
WEBEX_CLIENT_SECRET=your-client-secret
WEBEX_INTEGRATION_USER_ID=your-integration-user-id
# -----------------------------------------------------------------------------
# Webex Alerting (for long break / long call threshold alerts)
# -----------------------------------------------------------------------------
# Get this from https://web.webex.com or the /rooms API
WEBEX_ALERT_ROOM_ID=Y2lzY29zcGFyazovL3VzL1JPT00v...
# -----------------------------------------------------------------------------
# Webhook Configuration (CRITICAL FOR AGENT STATE CHANGES)
# -----------------------------------------------------------------------------
# The public URL that WxCC will POST to when agent state changes.
# This MUST be reachable from the internet and must eventually reach this app.
#
# When running in Docker:
# - This URL should point at your reverse proxy / load balancer.
# - Your reverse proxy then forwards to the container (usually port 3000 inside).
# - Do NOT point it directly at a Docker Desktop port on your laptop.
#
# Example: https://wxcc-monitor.yourcompany.com/webhook
WXCC_WEBHOOK_URL=
# Shared secret that must be present on every inbound webhook
# (header: x-wxcc-webhook-secret). Generate with: openssl rand -hex 32
WXCC_WEBHOOK_SECRET=your-strong-random-webhook-secret
# =============================================================================
# Docker-Specific Notes
# =============================================================================
#
# 1. data/ and logs/ must be bind-mounted when running containers (see compose files).
# The app writes many files here (tokens, agentStates, active alerts, etc.).
#
# 2. The container serves the application at the root path (/).
# Any "/test" prefix you see in the dashboard HTML is added by your
# external reverse proxy. This is the intended deployment model.
#
# 3. WEBHOOK_URL is the most common source of problems in Docker setups.
# It must be publicly reachable and correctly routed to the container.
#
# 4. Never commit real .env, .env.prod, or .env.dev files.
# =============================================================================

49
.gitignore vendored Normal file
View file

@ -0,0 +1,49 @@
# Dependencies
node_modules/
# Environment & Secrets (CRITICAL - never commit these)
.env
.env.local
.env.*.local
.env.prod
.env.production
.env.dev
.env.*.prod
.env.*.dev
# Sensitive runtime data (contain tokens, live state, PII)
data/tokens.json
data/agentStates.json
data/dailyQueueTotals.json
data/activeThresholdAlerts.json
data/agents.json
data/auxCodes.json
data/contactQueues.json
data/teams.json
# Logs
logs/
*.log
# OS / Editor
.DS_Store
Thumbs.db
*.swp
*.swo
*~
.vscode/
.idea/
# Test / coverage
coverage/
.nyc_output/
# Misc
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# Docker Compose local overrides (optional, never commit)
docker-compose.override.yml
docker-compose.*.override.yml

143
DOCKER.md Normal file
View file

@ -0,0 +1,143 @@
# Docker Deployment Guide for wxcc-monitor
This document explains how to run the application using Docker and Docker Compose.
## Important Architectural Note: The `/test` Prefix
The container **always** serves the application at the root path (`/`, `/api`, `/socket.io`, etc.).
The paths containing `/test` that you see in `src/views/dashboard.html` and the client-side JavaScript are **intentional** for the current deployment model. An external reverse proxy (managed centrally by your team) adds the `/test` prefix for the dev instance and strips it before forwarding to the container.
**Do not** try to make the container aware of the `/test` prefix. The image and compose files are deliberately prefix-agnostic.
See the excellent comment at the top of `src/server.js` for the original explanation.
## Quick Start
### 1. Production (Recommended)
```bash
# 1. Create your production env file (never commit it)
cp .env.example .env.prod
# Edit .env.prod with real values
# 2. Start the production stack
docker compose -f docker-compose.yml --env-file .env.prod up -d
# 3. Check health
docker compose -f docker-compose.yml ps
docker compose -f docker-compose.yml logs -f
```
Required bind mounts (defined in `docker-compose.yml`):
- `./data``/app/data`
- `./logs``/app/logs`
### 2. Native Development (Primary Workflow)
```bash
npm run dev
```
This remains the recommended way to do day-to-day development.
### 3. Containerized Development Testing (Occasional Use)
```bash
# Use a separate env file or the regular .env
docker compose -f docker-compose.dev.yml --env-file .env up
# Or with a dedicated dev env:
# docker compose -f docker-compose.dev.yml --env-file .env.dev up
```
This starts the app inside Docker with:
- Your local source code mounted (live editing)
- `nodemon` running inside the container (hot reload on file changes)
- Separate container name (`wxcc-monitor-dev`) and recommended host port `3001`
You can run the dev compose and production compose at the same time because they use different service names and files.
## File Overview
| File | Purpose | When to Use |
|-------------------------|----------------------------------------------|------------------------------|
| `Dockerfile` | Single multi-stage production image | Always (builds both) |
| `docker-compose.yml` | Production deployment | Production containers |
| `docker-compose.dev.yml`| Containerized dev testing with live reload | Occasional container testing |
| `.env.example` | Template for all required variables | Starting point for all envs |
## Environment Files Strategy
Because you often want to run dev (native) and prod (container) at the same time, we recommend:
- `.env` or `.env.dev` → Native dev + dev container testing
- `.env.prod` → Production container
Pass the correct file using `--env-file` when starting Compose.
## Required Bind Mounts
The application writes many files at runtime:
- `data/tokens.json`
- `data/agentStates.json`
- `data/activeThresholdAlerts.json`
- `data/*.json` (reference data refreshes)
- `logs/*.log` (Winston daily rotation + activity logs)
**You must** bind-mount the host `./data` and `./logs` directories when running containers. The compose files do this for you.
On first run the app will create any missing files and directories inside those mounts.
## Health Checks
Both the Dockerfile and production compose file include health checks against the existing endpoints:
- `GET /health`
- `GET /api/healthCheck`
These are lightweight and safe to call frequently.
## Common Operations
```bash
# View logs
docker compose -f docker-compose.yml logs -f app
# Restart after env change
docker compose -f docker-compose.yml --env-file .env.prod down
docker compose -f docker-compose.yml --env-file .env.prod up -d
# Rebuild image
docker compose -f docker-compose.yml build --no-cache
# Clean shutdown (triggers graceful save of agent state + alerts)
docker compose -f docker-compose.yml down
```
## Security Notes
- The production image runs as the non-root `node` user.
- Never put real secrets in any file tracked by git.
- `WEBHOOK_SECRET` should be a strong random value (minimum 32 bytes).
## Troubleshooting
**Dashboard or WebSocket not working when accessing the container directly?**
This is expected if you are not going through the reverse proxy that adds the `/test` prefix. The client-side code in the dashboard expects the proxied paths.
**Files disappearing on container restart?**
You are not mounting `./data` and `./logs`. Add the bind mounts.
**Webhook not being delivered?**
`WXCC_WEBHOOK_URL` must be publicly reachable and must route through your reverse proxy down to the container. This is the #1 issue when moving to Docker.
## Further Reading
- `src/server.js` (Socket.IO path + proxy comments)
- `src/views/dashboard.html` (client-side paths)
- The health endpoints in `src/app.js` and `src/routes/api.js`
---
Maintained as part of the wxcc-monitor project. Update this file when Docker usage patterns change.

86
Dockerfile Normal file
View file

@ -0,0 +1,86 @@
# =============================================================================
# wxcc-monitor - Multi-stage Dockerfile (Node 20 Alpine)
# Production-oriented, minimal attack surface, non-root execution.
# =============================================================================
# ---- Stage 1: Dependencies (production only) ----
FROM node:20-alpine AS deps
WORKDIR /app
# Copy only package files for better layer caching
COPY package*.json ./
# Install production dependencies only (no dev deps, no optional)
RUN npm ci --omit=dev --no-audit --no-fund
# ---- Stage 2: Dev dependencies (includes nodemon for docker-compose.dev.yml) ----
FROM node:20-alpine AS dev-deps
WORKDIR /app
COPY package*.json ./
RUN npm ci --no-audit --no-fund
# ---- Stage 3: Final production image ----
FROM node:20-alpine AS final
WORKDIR /app
RUN chown node:node /app
USER node
# Set production environment
ENV NODE_ENV=production
# Copy production node_modules from deps stage
COPY --from=deps --chown=node:node /app/node_modules ./node_modules
# Copy application source
COPY --chown=node:node package*.json ./
COPY --chown=node:node src ./src
COPY --chown=node:node scripts/healthcheck.js ./scripts/healthcheck.js
# Runtime directories (overridden by bind mounts in compose, but ensures local runs work)
RUN mkdir -p data logs
# Expose the application port (internal)
EXPOSE 3000
# Healthcheck using the existing /health endpoint (node:20-alpine has no wget)
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
CMD ["node", "scripts/healthcheck.js"]
# Default command - production
CMD ["npm", "start"]
# ---- Stage 4: Development image (used by docker-compose.dev.yml) ----
FROM node:20-alpine AS dev
WORKDIR /app
RUN chown node:node /app
USER node
ENV NODE_ENV=development
COPY --from=dev-deps --chown=node:node /app/node_modules ./node_modules
COPY --chown=node:node package*.json ./
COPY --chown=node:node nodemon.json ./
COPY --chown=node:node src ./src
RUN mkdir -p data logs
EXPOSE 3000
CMD ["npm", "run", "dev"]
# =============================================================================
# Notes for docker-compose usage:
# - In docker-compose.dev.yml we override the command to "npm run dev"
# and add source code bind mounts for live reload.
# - Always mount host ./data and ./logs when running in production.
# - The container serves the app at root (/). Any /test prefix is handled
# by an external reverse proxy (see server.js comments).
# =============================================================================

8
data/config.json Normal file
View file

@ -0,0 +1,8 @@
{
"queues": [
{ "name": "Support_Center_EP1", "displayName": "Support Center" },
{ "name": "Customer_Service_EP1", "displayName": "Customer Service" },
{ "name": "Todd_Snyder_EP1", "displayName": "Todd Snyder" },
{ "name": "Nest_Support_EP1", "displayName": "Nest Support" }
]
}

19
data/thresholds.json Normal file
View file

@ -0,0 +1,19 @@
{
"db561361-09a3-41fd-9031-f6fa84443cab": {
"connected": {
"minutes": 20,
"message": "On call too long"
},
"wrapup": {
"minutes": 12
},
"idle": {
"minutes": 90
},
"break": {
"minutes": 61,
"idleCodes": ["Break", "Lunch", "Personal Call", "WellbeingBreak", "Meeting", "Coaching", "Project"],
"message": "On break too long"
}
}
}

73
docker-compose.dev.yml Normal file
View file

@ -0,0 +1,73 @@
# =============================================================================
# wxcc-monitor - Development Docker Compose (Containerized Dev Testing)
#
# Purpose:
# Allows you to run the app inside Docker with live source editing + nodemon
# when you want to test the containerized environment without rebuilding.
#
# Primary daily development should still be done natively:
# npm run dev
#
# This file is designed to be run at the same time as docker-compose.yml
# (use different project names or just reference the file directly).
#
# Usage example:
# docker compose -f docker-compose.dev.yml --env-file .env up
# =============================================================================
services:
app-dev:
build:
context: .
dockerfile: Dockerfile
target: dev
container_name: wxcc-monitor-dev
restart: "no" # Dev containers usually don't need aggressive restart
# Use the same .env as native dev (or .env.dev if you prefer separation)
env_file:
- .env
environment:
PORT: "3000"
# Use a different host port so it doesn't conflict with native dev on 3000
ports:
- "3001:3000"
# Option A dev workflow: full source mount + nodemon inside the container
command: npm run dev
volumes:
# Full project source for live editing
- .:/app
# Prevent the host's node_modules from overwriting the container's
# (critical on macOS/Windows where host and container node_modules differ)
- /app/node_modules
# Still persist data and logs via bind mounts (as per requirements)
- ./data:/app/data
- ./logs:/app/logs
# No healthcheck or restart policy needed for short dev sessions
# (keeps output clean)
user: "1000:1000"
# More verbose logging during development
logging:
driver: "json-file"
options:
max-size: "5m"
max-file: "2"
# =============================================================================
# Notes
#
# - This is intended for occasional containerized testing only.
# - Your normal development workflow remains: run `npm run dev` directly on the host.
# - The /test prefix behavior is still controlled by your external reverse proxy.
# - You can run this stack at the same time as the production stack because
# it uses a different service name and (optionally) a different host port.
# =============================================================================

85
docker-compose.yml Normal file
View file

@ -0,0 +1,85 @@
# =============================================================================
# wxcc-monitor - Production Docker Compose
#
# Usage (recommended):
# docker compose -f docker-compose.yml --env-file .env.prod up -d
#
# This file is intentionally minimal and production-focused.
# - Uses bind mounts (not named volumes) for data/ and logs/ as requested.
# - Expects all secrets via --env-file (e.g. .env.prod)
# - Healthcheck + restart policy enabled
# - No dev tools, no source code mounts
# =============================================================================
services:
app:
build:
context: .
dockerfile: Dockerfile
target: final
container_name: wxcc-monitor-prod
restart: unless-stopped
# All secrets and configuration come from an external env file.
# Never commit real .env.prod to git.
env_file:
- .env.prod
# Container always listens on 3000 internally; host mapping below exposes 1912.
# Override PORT from .env.prod (often set to the host port for native dev).
environment:
PORT: "3000"
ports:
# Map host port to container port 3000.
# Change the left side as needed (e.g. "8080:3000").
- "1912:3000"
volumes:
# Bind-mount host directories for persistence (required).
# The application writes tokens, agent states, thresholds, logs, etc.
- ./data:/app/data
- ./logs:/app/logs
# Healthcheck uses the existing /health endpoint defined in the app.
healthcheck:
test: ["CMD", "node", "scripts/healthcheck.js"]
interval: 30s
timeout: 5s
retries: 3
start_period: 30s
# Security: run as the non-root 'node' user defined in the Dockerfile
user: "1000:1000"
# Logging configuration (adjust to taste)
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
# Resource limits (tune for your environment)
# deploy:
# resources:
# limits:
# cpus: '1.0'
# memory: 512M
# =============================================================================
# Important Notes
#
# 1. The container always serves the application at the root path (/).
# Any /test prefix you see in the dashboard is added by your external
# reverse proxy (this is the intended deployment model).
#
# 2. You must mount ./data and ./logs. The app writes many JSON files
# (agentStates, tokens, active alerts, daily totals, etc.).
#
# 3. WEBHOOK_URL must be publicly reachable and point to your reverse proxy,
# which then forwards to this container.
#
# 4. Run with a dedicated .env.prod file so you can keep dev and prod
# configurations completely separate and run both stacks simultaneously
# if desired.
# =============================================================================

6
nodemon.json Normal file
View file

@ -0,0 +1,6 @@
{
"watch": ["src"],
"ignore": ["data/*", "logs/*", "*.log", "data/agentStates.json"],
"ext": "js,json",
"exec": "node src/server.js"
}

1921
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

22
package.json Normal file
View file

@ -0,0 +1,22 @@
{
"name": "wxcc-monitor",
"version": "2.0.0",
"type": "module",
"scripts": {
"start": "node src/server.js",
"dev": "nodemon"
},
"dependencies": {
"cookie-parser": "^1.4.6",
"dotenv": "^16.4.5",
"express": "^4.19.2",
"node-cron": "^3.0.3",
"node-fetch": "^3.3.2",
"socket.io": "^4.7.5",
"winston": "^3.13.0",
"winston-daily-rotate-file": "^5.0.0"
},
"devDependencies": {
"nodemon": "^3.1.0"
}
}

13
scripts/healthcheck.js Normal file
View file

@ -0,0 +1,13 @@
import http from 'http';
const port = Number(process.env.PORT) || 3000;
const req = http.get(`http://127.0.0.1:${port}/health`, (res) => {
process.exit(res.statusCode === 200 ? 0 : 1);
});
req.on('error', () => process.exit(1));
req.setTimeout(4000, () => {
req.destroy();
process.exit(1);
});

29
src/app.js Normal file
View file

@ -0,0 +1,29 @@
import express from 'express';
import cookieParser from 'cookie-parser';
import config from './config/index.js';
import logger from './utils/logger.js';
import { loadJson } from './utils/file.js';
import apiRoutes from './routes/api.js';
import dashboardRoutes from './routes/dashboard.js';
const app = express();
app.use(express.json());
app.use(cookieParser());
// Health check must be registered before the dashboard router mounted at '/'
app.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// Mount API routes under /api
app.use('/api', apiRoutes);
// Mount dashboard at root (separate concern from the API router)
app.use('/', dashboardRoutes);
logger.info('Express app configured');
export default app;

37
src/config/index.js Normal file
View file

@ -0,0 +1,37 @@
import dotenv from 'dotenv';
dotenv.config();
import { loadJson } from '../utils/file.js';
import { getCurrentThresholds, reloadThresholds } from '../services/thresholds.js';
export default {
server: {
port: process.env.PORT || 3000,
name: 'WxCC Monitor',
// Empty for direct access (Docker/local). Set to "/test" when behind a reverse proxy.
basePath: (process.env.BASE_PATH || '').replace(/\/$/, '')
},
auth: {
websocket: {
token: process.env.WS_AUTH_TOKEN
},
webex: {
orgId: process.env.WXCC_ORG_ID,
clientId: process.env.WEBEX_CLIENT_ID,
clientSecret: process.env.WEBEX_CLIENT_SECRET,
integrationUserId: process.env.WEBEX_INTEGRATION_USER_ID,
roomId: process.env.WEBEX_ALERT_ROOM_ID || null,
tokens: loadJson('./data/tokens.json', {})
}
},
get thresholds() {
// Live view from the thresholds service (supports hot reload)
return getCurrentThresholds();
},
reloadThresholds,
queues: loadJson('./data/config.json', { queues: [] }).queues || [],
webhook: {
url: process.env.WXCC_WEBHOOK_URL || null,
secret: process.env.WXCC_WEBHOOK_SECRET || null
}
};

View file

@ -0,0 +1,32 @@
import config from '../config/index.js';
import logger from '../utils/logger.js';
// Shared webhook authentication middleware (Option C relaxed mode by default)
export function requireWebhookSecret(req, res, next) {
const enforce = (process.env.ENFORCE_WEBHOOK_SECRET || '').toLowerCase() === 'true';
const configured = config.webhook?.secret;
if (!enforce) {
// Relaxed mode (current default) — allow traffic unless explicitly enforcing
if (configured) {
logger.warn("Webhook secret configured but ENFORCE_WEBHOOK_SECRET is not true — allowing unauthenticated traffic (relaxed mode)");
} else {
logger.warn("WXCC_WEBHOOK_SECRET not configured — webhook endpoint is unauthenticated (relaxed mode)");
}
return next();
}
// Strict enforcement mode
if (!configured) {
logger.error("ENFORCE_WEBHOOK_SECRET=true but WXCC_WEBHOOK_SECRET is not set — rejecting webhook");
return res.status(500).json({ error: "Webhook authentication misconfigured" });
}
const provided = req.get('x-wxcc-webhook-secret') || req.get('X-Wxcc-Webhook-Secret');
if (provided && provided === configured) {
return next();
}
logger.warn("Rejected webhook: missing or invalid x-wxcc-webhook-secret header");
return res.status(401).json({ error: "Unauthorized" });
}

110
src/routes/api.js Normal file
View file

@ -0,0 +1,110 @@
import express from 'express';
import logger from '../utils/logger.js';
import { getAgentStates } from '../services/state.js';
import { getLatestQueueStats, getLatestQueueCounts, getLatestTeamStats } from '../services/queueMonitor.js';
import { processStateChange } from '../services/state.js';
import { referenceData, reloadReferenceData } from '../server.js';
import config from '../config/index.js';
import { broadcastStatus } from '../services/broadcast.js';
import { requireWebhookSecret } from '../middleware/webhookAuth.js';
const router = express.Router();
router.get('/healthCheck', (req, res) => {
res.json({ status: "alive", timestamp: new Date().toISOString() });
});
router.get('/agentStates', (req, res) => res.json(getAgentStates()));
router.get('/status', (req, res) => {
const agentStates = getAgentStates();
const queueStats = getLatestQueueStats(); // ← Updated
const teamStats = getLatestTeamStats();
const formatted = Object.values(agentStates).map(agent => ({
...agent,
timeInStateSeconds: Math.round((Date.now() - new Date(agent.createdTime || Date.now()).getTime()) / 1000),
timeInStateMinutes: Math.round((Date.now() - new Date(agent.createdTime || Date.now()).getTime()) / 60000)
}));
res.json({
timestamp: new Date().toISOString(),
queuedCalls: Object.values(queueStats).reduce((sum, q) => sum + (q.queued || 0), 0),
// Provide both shapes for compatibility:
// - "queues" for the main dashboard (WebSocket shape)
// - "queueStats" for any other consumers
queues: queueStats,
queueStats: queueStats,
teams: teamStats,
agentStates: formatted,
totalAgents: formatted.length
});
});
// New Queues Endpoint
router.get('/queues', (req, res) => {
const queueCounts = getLatestQueueCounts();
const configQueues = config.queues || [];
const displayData = configQueues.map(q => ({
name: q.name,
displayName: q.displayName || q.name,
count: queueCounts[q.name] || 0,
status: (queueCounts[q.name] || 0) >= 10 ? "🔴 High" :
(queueCounts[q.name] || 0) > 0 ? "🟡 Moderate" : "🟢 Normal"
}));
res.json({
timestamp: new Date().toISOString(),
queues: displayData,
totalQueued: Object.values(queueCounts).reduce((sum, count) => sum + count, 0)
});
});
router.get('/test-update', (req, res) => {
broadcastStatus();
res.send("Broadcast sent!");
});
// Agent State Change (protected by shared secret when configured)
router.post('/agentStateChange', requireWebhookSecret, async (req, res) => {
try {
await processStateChange(req.body, referenceData);
// Then just call:
broadcastStatus();
res.status(201).json({ success: true });
} catch (error) {
logger.error("Error processing state change", { error: error.message });
res.status(500).json({ error: error.message });
}
});
// Reloads
router.post('/reloadData', async (req, res) => {
const success = await reloadReferenceData();
res.json({
success,
agentsCount: referenceData.agents.length,
message: success ? "Reference data reloaded" : "Reload failed"
});
});
router.post('/reloadThresholds', async (req, res) => {
try {
// The config object now delegates to the thresholds service
const { default: config } = await import('../config/index.js');
const fresh = config.reloadThresholds();
logger.info("Thresholds reloaded via API", {
teamCount: Object.keys(fresh || {}).length,
activeAlerts: (await import('../services/thresholds.js')).getActiveAlertCount?.() || 0
});
res.json({ success: true, thresholds: fresh });
} catch (err) {
logger.error("Failed to reload thresholds", { error: err.message });
res.status(500).json({ error: err.message });
}
});
export default router;

14
src/routes/dashboard.js Normal file
View file

@ -0,0 +1,14 @@
import express from 'express';
import { getDashboardHtml } from '../views/index.js';
import config from '../config/index.js';
const router = express.Router();
// Real-time dashboard (serves the single-page HTML app)
router.get('/', (req, res) => {
const wsToken = process.env.WS_AUTH_TOKEN || '';
const html = getDashboardHtml(wsToken, config.server.basePath);
res.send(html);
});
export default router;

14
src/routes/index.js Normal file
View file

@ -0,0 +1,14 @@
import express from 'express';
import apiRoutes from './api.js';
import dashboardRoutes from './dashboard.js';
const router = express.Router();
// Mount the pure API endpoints
router.use('/api', apiRoutes);
// Mount the dashboard at the root (separate concern)
router.use('/', dashboardRoutes);
export default router;

133
src/server.js Normal file
View file

@ -0,0 +1,133 @@
import http from 'http';
import { Server } from 'socket.io';
import cron from 'node-cron';
import config from './config/index.js';
import logger from './utils/logger.js';
import { loadJson } from './utils/file.js';
import { setupSockets } from './sockets/handler.js';
import { saveAgentStates } from './services/state.js';
import { checkThresholds, saveActiveAlertsOnShutdown } from './services/thresholds.js';
import { updateWxCCData } from './services/wxcc.js';
import { validateToken, refreshWebexToken } from './services/token.js';
import { registerSubscription, deleteSubscription } from './services/subscription.js';
import { setIo, broadcastStatus } from './services/broadcast.js';
import { generateDailySummary } from './services/activityLogger.js';
import { checkQueue } from './services/queueMonitor.js';
import { getAgentStates } from './services/state.js';
// Import the pre-configured Express app (all middleware + routes live there now)
import app from './app.js';
const server = http.createServer(app);
// IMPORTANT: Because the reverse proxy at /test strips the prefix before forwarding to this backend,
// Socket.IO here must be configured for the path the backend actually receives (/socket.io).
// The public HTML + client connection (which go through the proxy) continue to use /test/socket.io.
const io = new Server(server, {
path: '/socket.io'
});
setIo(io);
// Load reference data
let agents = loadJson('./data/agents.json', []);
let auxCodes = loadJson('./data/auxCodes.json', []);
let contactQueues = loadJson('./data/contactQueues.json', []);
let teams = loadJson('./data/teams.json', []);
export let referenceData = { agents, auxCodes, contactQueues, teams };
// Make reload function available (now uses the statically imported updateWxCCData)
export async function reloadReferenceData() {
try {
const newData = await updateWxCCData();
agents = newData.agents || agents;
auxCodes = newData.auxCodes || auxCodes;
contactQueues = newData.contactQueues || contactQueues;
teams = newData.teams || teams;
referenceData = { agents, auxCodes, contactQueues, teams };
logger.info(`Reference data reloaded: ${agents.length} agents, ${teams.length} teams`);
return true;
} catch (err) {
logger.error("Failed to reload reference data", { error: err.message });
return false;
}
}
// === EXPLICIT INITIALIZATION (replaces top-level side effects) ===
async function initialize() {
logger.info("Initializing WxCC Monitor...");
// Validate (and possibly refresh) the Webex access token
validateToken();
// Register the WxCC webhook subscription (requires valid token + configured URL)
await registerSubscription();
logger.info("Initialization complete");
}
// Setup Sockets (no network I/O)
setupSockets(io, config);
// Cron Jobs (scheduling happens at load time; callbacks run later)
cron.schedule('0 6 * * *', () => {
generateDailySummary();
});
cron.schedule('0 */12 * * *', async () => {
logger.info("Scheduled token refresh check");
await refreshWebexToken();
});
cron.schedule('30 20 4 * * *', async () => {
try {
await updateWxCCData();
logger.info("Daily WxCC data refresh completed");
} catch (e) {
logger.error("Daily WxCC refresh failed", { error: e.message });
}
});
// Run queue check every 15 seconds
cron.schedule('*/15 * * * * *', async () => {
await checkQueue();
broadcastStatus(); // Push update to dashboard
});
// Check agent thresholds every 30 seconds
cron.schedule('*/30 * * * * *', () => {
checkThresholds(getAgentStates());
});
// cron.schedule('*/5 * * * *', () => checkThresholds(getAgentStates())); // uncomment when needed
// Graceful Shutdown (SIGTERM is sent by Docker on `docker compose down`)
async function shutdown(signal) {
logger.info(`Shutting down (${signal})...`);
saveAgentStates();
saveActiveAlertsOnShutdown();
await deleteSubscription();
io.close(() => {
server.close(() => {
logger.info("Server shutdown complete");
process.exit(0);
});
});
}
process.on('SIGINT', () => shutdown('SIGINT'));
process.on('SIGTERM', () => shutdown('SIGTERM'));
// Start listening immediately so Docker healthchecks pass while init runs
server.listen(config.server.port, '0.0.0.0', () => {
logger.info(`${config.server.name} running on port ${config.server.port}`);
initialize().catch((err) => {
logger.error("Initialization failed", { error: err.message });
});
});

View file

@ -0,0 +1,78 @@
import fs from 'fs';
import path from 'path';
import logger from '../utils/logger.js';
import { sendWebexAlert } from './alerts.js';
const LOGS_DIR = './logs';
function getTodayLogFile() {
const date = new Date().toISOString().slice(0, 10);
return path.join(LOGS_DIR, `activity-${date}.json`);
}
export function logAgentActivity(entry) {
const filePath = getTodayLogFile();
const record = {
timestamp: new Date().toISOString(),
...entry
};
try {
if (!fs.existsSync(LOGS_DIR)) fs.mkdirSync(LOGS_DIR, { recursive: true });
const data = fs.existsSync(filePath)
? JSON.parse(fs.readFileSync(filePath, 'utf8'))
: [];
data.push(record);
fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
} catch (err) {
logger.error("Failed to log agent activity", { error: err.message });
}
}
export async function generateDailySummary() {
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
const dateStr = yesterday.toISOString().slice(0, 10);
const filePath = path.join(LOGS_DIR, `activity-${dateStr}.json`);
if (!fs.existsSync(filePath)) {
logger.info(`No activity log for ${dateStr}`);
return;
}
try {
const logs = JSON.parse(fs.readFileSync(filePath, 'utf8'));
// Analyze
const longCalls = logs
.filter(l => l.toState === "connected")
.sort((a, b) => (b.durationSeconds || 0) - (a.durationSeconds || 0))
.slice(0, 5);
const longBreaks = logs
.filter(l => l.toState === "idle" && l.idleCode === "Break")
.sort((a, b) => (b.durationSeconds || 0) - (a.durationSeconds || 0))
.slice(0, 5);
const summary = `
**📊 Daily Agent Activity Summary - ${dateStr}**
**Total State Changes:** ${logs.length}
**Longest Calls:**
${longCalls.map(l => `${l.fullName}: ${Math.round((l.durationSeconds||0)/60)} min`).join('\n') || "None"}
**Longest Breaks:**
${longBreaks.map(l => `${l.fullName}: ${Math.round((l.durationSeconds||0)/60)} min`).join('\n') || "None"}
`.trim();
await sendWebexAlert(summary, true);
logger.info(`Daily summary sent for ${dateStr}`);
} catch (err) {
logger.error("Failed to generate daily summary", { error: err.message });
}
}

48
src/services/alerts.js Normal file
View file

@ -0,0 +1,48 @@
import fetch from 'node-fetch';
import logger from '../utils/logger.js';
import config from '../config/index.js';
import { getAccessToken } from './token.js';
export async function sendWebexAlert(message, isMarkdown = true, parentMessageId = null) {
const roomId = config.auth.webex.roomId;
const accessToken = getAccessToken();
if (!roomId) {
logger.warn("WEBEX_ALERT_ROOM_ID not configured — skipping Webex alert");
return null;
}
if (!accessToken) {
logger.error("No access token available for Webex alert");
return null;
}
const body = {
roomId: roomId,
text: message,
markdown: isMarkdown ? message : undefined,
parentId: parentMessageId || undefined
};
try {
const res = await fetch('https://webexapis.com/v1/messages', {
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(body)
});
if (!res.ok) {
const errText = await res.text().catch(() => '');
logger.error("Webex alert failed", { status: res.status, body: errText });
return null;
}
const data = await res.json();
return data.id;
} catch (e) {
logger.error("Failed to send Webex alert", { error: e.message });
return null;
}
}

34
src/services/broadcast.js Normal file
View file

@ -0,0 +1,34 @@
import { getAgentStates } from './state.js';
import { getLatestQueueStats, getLatestTeamStats, checkQueue } from './queueMonitor.js';
let ioInstance = null;
export function setIo(io) {
ioInstance = io;
console.log("✅ Broadcast service initialized");
}
export function broadcastStatus() {
if (!ioInstance) {
console.warn("⚠️ No ioInstance");
return;
}
const queues = getLatestQueueStats();
const teams = getLatestTeamStats();
const agents = Object.values(getAgentStates());
console.log(`📡 Broadcasting → Queues: ${Object.keys(queues).length}, Teams: ${Object.keys(teams).length}, Agents: ${agents.length}`);
const payload = {
timestamp: new Date().toISOString(),
queues: queues,
teams: teams,
agentStates: agents.map(agent => ({
...agent,
timeInStateMinutes: Math.round((Date.now() - new Date(agent.createdTime || Date.now()).getTime()) / 60000)
}))
};
ioInstance.to('main').emit('statusUpdate', payload);
}

View file

@ -0,0 +1,220 @@
import fetch from 'node-fetch';
import logger from '../utils/logger.js';
import config from '../config/index.js';
import { getAgentStates } from './state.js';
import { loadJson, saveJson } from '../utils/file.js';
import { getAccessToken } from './token.js';
import { sendWebexAlert } from './alerts.js';
// === Queue Depth Alerting (Support Center only) ===
const SUPPORT_CENTER_QUEUE_NAME = 'Support_Center_EP1';
const QUEUE_CLEAR_THRESHOLD = 8;
let currentQueueAlertLevel = 0; // highest threshold we've already alerted on (10,15,20...)
let lastQueueAlertMessageId = null;
// Agent state categorization for team dashboard aggregates
const ON_CALL_STATES = ['ringing', 'connected', 'on-hold', 'consulting', 'conferencing', 'not-responding', 'hold-done', 'consult-done', 'wrapup', 'wrapup-done'];
const BREAK_CODES = ['Break', 'Lunch', 'Personal Call', 'WellbeingBreak', 'Meeting', 'Coaching', 'Project'];
function getCategory(agent) {
const s = (agent.currentState || '').toLowerCase();
if (s === 'available') return 'available';
if (ON_CALL_STATES.includes(s)) return 'onCalls';
if (s === 'idle') {
const code = (agent.idleCode || '').toLowerCase();
const isBreak = BREAK_CODES.some(b => code.includes(b.toLowerCase()));
return isBreak ? 'onBreak' : 'idle';
}
return 'onCalls'; // fallback for unknown occupied states
}
let latestQueueStats = {};
let latestTeamStats = {};
let dailyQueueTotals = loadJson('./data/dailyQueueTotals.json', {}); // persistent
const SEARCH_URL = `https://api.wxcc-us1.cisco.com/search?orgId=${config.auth.webex.orgId}`;
async function searchQuery(graphqlQuery) {
const accessToken = getAccessToken();
if (!accessToken) throw new Error("No access token");
const response = await fetch(SEARCH_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${accessToken}`
},
body: JSON.stringify({ query: graphqlQuery })
});
if (!response.ok) {
const err = await response.text();
throw new Error(`Search failed: ${err}`);
}
return response.json();
}
export async function checkQueue() {
const now = Date.now();
// Safe 4AM ET today
const dayStart = new Date();
dayStart.setHours(4, 0, 0, 0);
if (dayStart.getTime() > now) {
dayStart.setDate(dayStart.getDate() - 1); // go to previous day if before 4AM
}
const dayStartMs = dayStart.getTime();
const graphqlQuery = `
query AllInboundCallsToday {
task(
from: ${dayStartMs}
to: ${now}
timeComparator: createdTime
filter: { direction: { equals: "inbound" } }
) {
tasks {
id
status
createdTime
lastEntryPoint { name }
}
}
}
`;
try {
const result = await searchQuery(graphqlQuery);
const allTasks = result?.data?.task?.tasks || [];
latestQueueStats = {};
allTasks.forEach(t => {
const qName = t.lastEntryPoint?.name || "Unknown";
if (!latestQueueStats[qName]) {
latestQueueStats[qName] = {
queued: 0,
active: 0,
oldestCall: null,
dailyTotal: 0,
queuedWaitSum: 0,
queuedCountForAvg: 0
};
}
latestQueueStats[qName].dailyTotal++;
if (t.status === "parked" || t.status === "ivr-connected") {
latestQueueStats[qName].queued++;
const waitMs = now - t.createdTime;
latestQueueStats[qName].queuedWaitSum += waitMs;
latestQueueStats[qName].queuedCountForAvg++;
if (!latestQueueStats[qName].oldestCall || t.createdTime < latestQueueStats[qName].oldestCall) {
latestQueueStats[qName].oldestCall = t.createdTime;
}
} else if (t.status !== "ended") {
latestQueueStats[qName].active++;
}
});
// Teams - aggregate breakdown by state category
latestTeamStats = {};
const agentStatesList = Object.values(getAgentStates());
agentStatesList.forEach(agent => {
const teamName = agent.team || "Unknown Team";
if (!latestTeamStats[teamName]) {
latestTeamStats[teamName] = { onCalls: 0, available: 0, idle: 0, onBreak: 0, loggedIn: 0 };
}
const cat = getCategory(agent);
latestTeamStats[teamName][cat] = (latestTeamStats[teamName][cat] || 0) + 1;
latestTeamStats[teamName].loggedIn++;
});
// Compute derived fields for the dashboard (oldest call age + average wait for currently queued calls)
Object.keys(latestQueueStats).forEach(qName => {
const q = latestQueueStats[qName];
if (q.oldestCall) {
q.oldestCallAgeSeconds = Math.floor((now - q.oldestCall) / 1000);
} else {
q.oldestCallAgeSeconds = null;
}
if (q.queuedCountForAvg > 0) {
q.avgWaitSeconds = Math.round(q.queuedWaitSum / q.queuedCountForAvg / 1000);
} else {
q.avgWaitSeconds = 0;
}
});
// Queue depth alerting (Support Center only)
checkQueueDepthAlerts();
logger.info("Queues:", latestQueueStats);
logger.info("Teams:", latestTeamStats);
} catch (error) {
logger.error('checkQueue failed', { error: error.message });
}
}
export function getLatestQueueStats() { return latestQueueStats || {}; }
export function getLatestTeamStats() { return latestTeamStats || {}; }
// Simple { queueName: queuedCount } map for the /queues endpoint
function checkQueueDepthAlerts() {
const q = latestQueueStats[SUPPORT_CENTER_QUEUE_NAME];
if (!q) return;
const queued = q.queued || 0;
// Determine current escalation level (10, 15, 20, 25, ...)
let targetLevel = 0;
let level = 10;
while (queued >= level) {
targetLevel = level;
level += 5;
}
if (targetLevel > currentQueueAlertLevel) {
// Escalate to a new level
const message = `⚠️ **Support Center Queue** has reached **${queued} queued calls** (alerting at ${targetLevel}+)`;
sendWebexAlert(message, true).then(msgId => {
lastQueueAlertMessageId = msgId;
currentQueueAlertLevel = targetLevel;
logger.info(`Queue depth alert sent`, {
queue: SUPPORT_CENTER_QUEUE_NAME,
queued,
level: targetLevel
});
}).catch(() => {});
}
else if (queued < QUEUE_CLEAR_THRESHOLD && currentQueueAlertLevel > 0) {
// Dropped below clear threshold
const message = `✅ **Support Center Queue** has dropped below ${QUEUE_CLEAR_THRESHOLD} (currently ${queued} queued calls)`;
if (lastQueueAlertMessageId) {
sendWebexAlert(message, true, lastQueueAlertMessageId);
} else {
sendWebexAlert(message, true);
}
logger.info(`Queue depth alert cleared`, {
queue: SUPPORT_CENTER_QUEUE_NAME,
queued
});
currentQueueAlertLevel = 0;
lastQueueAlertMessageId = null;
}
}
export function getLatestQueueCounts() {
const stats = latestQueueStats || {};
const counts = {};
Object.keys(stats).forEach(name => {
counts[name] = stats[name]?.queued || 0;
});
return counts;
}

135
src/services/state.js Normal file
View file

@ -0,0 +1,135 @@
import logger from '../utils/logger.js';
import { saveJson, loadJson } from '../utils/file.js';
import { checkThresholds } from './thresholds.js';
import { logAgentActivity } from './activityLogger.js';
let agentStates = loadJson('./data/agentStates.json', {});
export const getAgentStates = () => agentStates;
export async function processStateChange(event, referenceData) {
const { agents = [], auxCodes = [], contactQueues = [], teams = [] } = referenceData;
const payload = event?.data || {};
const agentId = payload.agentId;
/*logger.info('Received agentStateChange', {
agentId,
currentState: payload.currentState,
eventType: event?.type || event?.eventType
});*/
if (!agentId) return;
let agent = agents.find(a => a.ciUserId === agentId);
if (!agent && payload.email) {
agent = agents.find(a => a.email?.toLowerCase() === payload.email?.toLowerCase());
}
if (!agent) {
logger.warn(`Agent not found: ${agentId}`);
return;
}
const email = agent.email;
const fullName = `${agent.firstName || ''} ${agent.lastName || ''}`.trim() || agent.email;
// === CLEANUP OLD STATES (older than 12 hours) ===
cleanupStaleStates();
let state = {
email,
fullName,
teamIds: agent.teamIds || [],
team: teams.find(t => agent.teamIds?.includes(t.id))?.name || "Unknown Team",
contactQueue: "—",
currentState: payload.currentState,
createdTime: new Date(payload.createdTime || Date.now()).toISOString()
};
// Override with real-time info
if (payload.queueId) {
const queue = contactQueues.find(q => q.id === payload.queueId);
if (queue) state.contactQueue = queue.name;
}
if (payload.teamId) {
const team = teams.find(t => t.id === payload.teamId);
if (team) state.team = team.name;
}
if (payload.idleCodeId) {
const aux = auxCodes.find(a => a.id === payload.idleCodeId);
if (aux) {
state.currentState = "idle";
state.idleCode = aux.name;
}
}
if (payload.wrapUpAuxCodeId) {
const aux = auxCodes.find(a => a.id === payload.wrapUpAuxCodeId);
if (aux) {
state.currentState = "wrapup-done";
state.wrapUpCode = aux.name;
}
}
if (state.currentState === "logged-out") {
delete agentStates[email];
logger.info(`👋 ${fullName} logged out`);
} else {
agentStates[email] = state;
logger.info(`📊 ${fullName}${state.currentState}${state.idleCode ? ` (${state.idleCode})` : ''}`);
}
// Log activity for historical tracking
const previousState = agentStates[email]?.currentState;
if (previousState && previousState !== state.currentState) {
const duration = previousState ?
Math.round((Date.now() - new Date(agentStates[email].createdTime).getTime()) / 1000) : 0;
logAgentActivity({
agentId: agent.ciUserId,
fullName,
fromState: previousState,
toState: state.currentState,
durationSeconds: duration,
idleCode: state.idleCode,
queue: state.contactQueue,
team: state.team
});
}
debounceSave();
// Threshold evaluation (now handled by the self-contained service)
try {
checkThresholds(agentStates);
} catch (err) {
logger.error("Threshold check failed", { error: err.message });
}
}
// Remove states older than 12 hours
function cleanupStaleStates() {
const now = Date.now();
const maxAge = 12 * 60 * 60 * 1000; // 12 hours
for (const email in agentStates) {
const created = new Date(agentStates[email].createdTime).getTime();
if (now - created > maxAge) {
logger.info(`🧹 Cleaned stale state for ${agentStates[email].fullName}`);
delete agentStates[email];
}
}
}
// Debounced save
let saveTimeout = null;
function debounceSave() {
if (saveTimeout) clearTimeout(saveTimeout);
saveTimeout = setTimeout(() => {
saveJson(agentStates, './data/agentStates.json');
}, 3000);
}
export function saveAgentStates() {
if (saveTimeout) clearTimeout(saveTimeout);
saveJson(agentStates, './data/agentStates.json');
}

View file

@ -0,0 +1,97 @@
import fetch from 'node-fetch';
import logger from '../utils/logger.js';
import config from '../config/index.js';
import { getAccessToken } from './token.js';
let currentSubscriptionId = null;
const SUBSCRIPTION_NAME = "Agent State Monitor";
async function deleteExistingSubscriptions(accessToken) {
try {
const listRes = await fetch('https://api.wxcc-us1.cisco.com/v2/subscriptions', {
headers: { 'Authorization': `Bearer ${accessToken}` }
});
const data = await listRes.json();
const existing = data.data?.filter(sub => sub.name === SUBSCRIPTION_NAME) || [];
for (const sub of existing) {
await fetch(`https://api.wxcc-us1.cisco.com/v2/subscriptions/${sub.id}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${accessToken}` }
});
logger.info(`🗑️ Cleaned old subscription: ${sub.id}`);
}
} catch (e) {
logger.warn("Cleanup failed", { error: e.message });
}
}
export async function registerSubscription() {
const accessToken = getAccessToken();
if (!accessToken) {
logger.error("No access token");
return null;
}
if (!config.webhook?.url) {
logger.error("WXCC_WEBHOOK_URL is not configured. Cannot register subscription.");
return null;
}
await deleteExistingSubscriptions(accessToken);
const payload = {
name: SUBSCRIPTION_NAME,
orgId: config.auth.webex.orgId,
destinationUrl: config.webhook.url,
eventTypes: [
"agent:login",
"agent:logout",
"agent:state_change"
],
resourceVersion: "agent:1.0.0" // ← This is required for state_change
};
logger.info("Sending subscription payload:", payload);
const response = await fetch('https://api.wxcc-us1.cisco.com/v2/subscriptions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(payload)
});
const result = await response.json().catch(() => ({}));
if (!response.ok) {
logger.error("Subscription failed", { status: response.status, body: result });
return null;
}
currentSubscriptionId = result.data?.id || result.id;
logger.info(`✅ Subscription created! ID: ${currentSubscriptionId}`);
logger.info(`Registered events: ${result.data?.eventTypes || result.eventTypes}`);
return currentSubscriptionId;
}
export async function deleteSubscription() {
if (!currentSubscriptionId) return;
const accessToken = getAccessToken();
if (!accessToken) return;
try {
await fetch(`https://api.wxcc-us1.cisco.com/v2/subscriptions/${currentSubscriptionId}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${accessToken}` }
});
logger.info(`🗑️ Subscription deleted: ${currentSubscriptionId}`);
currentSubscriptionId = null;
} catch (err) {
logger.warn("Delete failed", { error: err.message });
}
}

318
src/services/thresholds.js Normal file
View file

@ -0,0 +1,318 @@
import fs from 'fs';
import path from 'path';
import logger from '../utils/logger.js';
import { sendWebexAlert } from './alerts.js';
import { loadJson, saveJson } from '../utils/file.js';
const ALERTS_STATE_FILE = './data/activeThresholdAlerts.json';
// ---------------------------------------------------------------------------
// Thresholds Service - Redesigned (clean shape, break codes, reliable clears, hot reload)
// ---------------------------------------------------------------------------
/*
Recommended config shape in data/thresholds.json (units are human-friendly):
{
"<teamId>": {
"connected": { "minutes": 30 },
"wrapup": { "minutes": 15 },
"idle": { "minutes": 60 },
"break": {
"minutes": 45,
"idleCodes": ["Break", "Lunch", "Personal Call", "WellbeingBreak"],
"message": "Agent has been on break too long"
}
}
}
- "minutes" (preferred) or "seconds" may be used in any rule.
- Legacy shape using "time" (treated as seconds, per original design) is still supported on load.
- The special "break" rule only applies when currentState === "idle".
If "idleCodes" is present, the agent's idleCode must match one of them.
- A matching "break" rule takes precedence over a general "idle" rule.
*/
let currentThresholds = {}; // teamId -> normalized rules
let activeAlerts = new Map(); // key -> alertRecord (persisted)
/* ----------------------- Normalization helpers ----------------------- */
function normalizeLimit(rule) {
if (!rule || typeof rule !== 'object') return null;
// New preferred shape
if (typeof rule.minutes === 'number') {
return { seconds: Math.round(rule.minutes * 60), display: `${rule.minutes} min` };
}
if (typeof rule.seconds === 'number') {
return { seconds: rule.seconds, display: `${rule.seconds}s` };
}
// Legacy shape: "time" was originally seconds
if (typeof rule.time === 'number') {
const secs = rule.time;
return { seconds: secs, display: `${Math.round(secs / 60)} min` };
}
// Very old nested break shape
if (rule.break && typeof rule.break.time === 'number') {
const secs = rule.break.time;
return { seconds: secs, display: `${Math.round(secs / 60)} min` };
}
return null;
}
function normalizeRule(key, rawRule) {
const limit = normalizeLimit(rawRule);
if (!limit) return null;
return {
key,
limitSeconds: limit.seconds,
limitDisplay: limit.display,
message: rawRule.message || null,
idleCodes: Array.isArray(rawRule.idleCodes)
? rawRule.idleCodes.map(c => c.toLowerCase())
: null
};
}
function loadAndNormalizeThresholds() {
const raw = loadJson('./data/thresholds.json', {});
const normalized = {};
for (const [teamId, teamRules] of Object.entries(raw)) {
normalized[teamId] = {};
for (const [stateKey, rawRule] of Object.entries(teamRules)) {
const rule = normalizeRule(stateKey, rawRule);
if (rule) {
normalized[teamId][stateKey] = rule;
}
}
}
logger.info('Thresholds loaded', {
teams: Object.keys(normalized).length,
rules: Object.values(normalized).reduce((sum, r) => sum + Object.keys(r).length, 0)
});
return normalized;
}
/* ----------------------- Active alerts persistence ----------------------- */
function loadActiveAlerts() {
const data = loadJson(ALERTS_STATE_FILE, {});
const map = new Map();
for (const [key, rec] of Object.entries(data)) {
if (rec && rec.firedAt) {
map.set(key, {
ruleKey: rec.ruleKey,
state: rec.state,
idleCode: rec.idleCode || null,
limitSeconds: rec.limitSeconds,
limitDisplay: rec.limitDisplay || `${rec.limitSeconds}s`,
firedAt: rec.firedAt,
messageId: rec.messageId || null
});
}
}
logger.info(`Loaded ${map.size} active threshold alerts from disk`);
return map;
}
function saveActiveAlerts() {
const obj = {};
for (const [key, rec] of activeAlerts.entries()) {
obj[key] = {
ruleKey: rec.ruleKey,
state: rec.state,
idleCode: rec.idleCode,
limitSeconds: rec.limitSeconds,
limitDisplay: rec.limitDisplay,
firedAt: rec.firedAt,
messageId: rec.messageId
};
}
try {
saveJson(obj, ALERTS_STATE_FILE);
logger.info(`Saved ${activeAlerts.size} active threshold alerts`);
} catch (err) {
logger.error('Failed to save active threshold alerts', { error: err.message });
}
}
/* ----------------------- Core matching logic ----------------------- */
function getApplicableRules(agent, teamRules) {
const rules = [];
const state = (agent.currentState || '').toLowerCase();
const idleCode = (agent.idleCode || '').toLowerCase();
if (!teamRules) return rules;
// Special "break" handling (most specific)
if (state === 'idle' && teamRules.break) {
const br = teamRules.break;
const codeMatch = !br.idleCodes || br.idleCodes.some(c => idleCode.includes(c) || c.includes(idleCode));
if (codeMatch) {
rules.push({ ...br, effectiveKey: 'break' });
}
}
// Direct state match (connected, wrapup, idle, etc.)
if (teamRules[state]) {
rules.push({ ...teamRules[state], effectiveKey: state });
}
return rules;
}
function isViolating(rule, timeInStateSeconds) {
return timeInStateSeconds >= rule.limitSeconds;
}
/* ----------------------- Public API ----------------------- */
export function getCurrentThresholds() {
return currentThresholds;
}
export function reloadThresholds() {
currentThresholds = loadAndNormalizeThresholds();
logger.info('Thresholds reloaded (hot reload)');
return currentThresholds;
}
export function getActiveAlertCount() {
return activeAlerts.size;
}
export function saveActiveAlertsOnShutdown() {
saveActiveAlerts();
}
/**
* Main entry point - call this on state changes or on the 30s cron.
* Now self-contained (does not require passing config).
*/
export function checkThresholds(agentStates) {
const now = Date.now();
const teamsWithRules = Object.keys(currentThresholds);
if (teamsWithRules.length === 0) {
return; // nothing configured
}
Object.values(agentStates).forEach(agent => {
const teamId = agent.teamIds?.[0];
if (!teamId || !currentThresholds[teamId]) return;
const teamRules = currentThresholds[teamId];
const email = agent.email;
const fullName = agent.fullName || email;
const currentState = agent.currentState || 'unknown';
const idleCode = agent.idleCode || null;
const timeInStateMs = now - new Date(agent.createdTime || now).getTime();
const timeInStateSeconds = Math.floor(timeInStateMs / 1000);
const applicableRules = getApplicableRules(agent, teamRules);
// Track which rules we still care about for clearing
const seenKeysThisCheck = new Set();
for (const rule of applicableRules) {
const ruleKey = rule.effectiveKey || rule.key;
const alertKey = `${email}:${ruleKey}`;
seenKeysThisCheck.add(alertKey);
if (isViolating(rule, timeInStateSeconds)) {
if (!activeAlerts.has(alertKey)) {
// NEW violation - send alert
const displayName = rule.message || ruleKey;
const timeHuman = timeInStateSeconds >= 3600
? `${(timeInStateSeconds / 3600).toFixed(1)}h`
: `${Math.round(timeInStateSeconds / 60)}m`;
const message = `⚠️ **${fullName}** has been in **${displayName}** for **${timeHuman}** (limit: ${rule.limitDisplay})`;
sendWebexAlert(message, true).then(msgId => {
activeAlerts.set(alertKey, {
ruleKey,
state: currentState,
idleCode,
limitSeconds: rule.limitSeconds,
limitDisplay: rule.limitDisplay,
firedAt: now,
messageId: msgId
});
logger.info(`Threshold alert sent`, {
agent: fullName,
rule: ruleKey,
limit: rule.limitDisplay,
durationSec: timeInStateSeconds
});
// Persist immediately so a restart doesn't lose it
saveActiveAlerts();
}).catch(() => {});
}
// else: already alerting, do nothing
}
}
// Check for clearances: any alertKey we know about for this agent that is no longer violated
for (const [alertKey, rec] of Array.from(activeAlerts.entries())) {
if (!alertKey.startsWith(`${email}:`)) continue;
if (seenKeysThisCheck.has(alertKey)) continue;
// This rule is no longer active for the agent → send clear
const durationOver = Math.floor((now - rec.firedAt) / 1000);
const durHuman = durationOver >= 3600
? `${(durationOver / 3600).toFixed(1)}h`
: `${Math.round(durationOver / 60)}m`;
const clearMsg = `✅ **${fullName}** cleared **${rec.ruleKey}** state after **${durHuman}** (limit was ${rec.limitDisplay}).`;
if (rec.messageId) {
sendWebexAlert(clearMsg, true, rec.messageId);
} else {
sendWebexAlert(clearMsg, true);
}
logger.info(`Threshold alert cleared`, {
agent: fullName,
rule: rec.ruleKey,
overageDurationSec: durationOver
});
activeAlerts.delete(alertKey);
saveActiveAlerts();
}
});
}
/* ----------------------- Initialization (runs on module load) ----------------------- */
// Initial load happens at the bottom of the helper definitions.
// We call it once here for the very first module evaluation.
if (Object.keys(currentThresholds).length === 0) {
currentThresholds = loadAndNormalizeThresholds();
}
if (activeAlerts.size === 0) {
activeAlerts = loadActiveAlerts();
}
// Ensure we persist the active alert state on shutdown
process.on('SIGINT', () => { saveActiveAlerts(); });
process.on('SIGTERM', () => { saveActiveAlerts(); });
logger.info('Thresholds service initialized');

113
src/services/token.js Normal file
View file

@ -0,0 +1,113 @@
import fetch from 'node-fetch';
import logger from '../utils/logger.js';
import { saveJson } from '../utils/file.js';
import config from '../config/index.js';
const TOKEN_FILE = './data/tokens.json';
// === CENTRALIZED TOKEN ACCESS HELPERS ===
// These are the ONLY functions that should read/write tokens anywhere in the app.
export function getCurrentUserId() {
return config.auth.webex.integrationUserId;
}
export function getTokenEntry(userId = getCurrentUserId()) {
if (!userId) return null;
return config.auth.webex.tokens[userId] || null;
}
export function getAccessToken(userId = getCurrentUserId()) {
return getTokenEntry(userId)?.token?.access_token || null;
}
export function getAllTokenEntries() {
return config.auth.webex.tokens || {};
}
export function saveTokenEntry(userId, entry) {
if (!userId || !entry) {
logger.error("saveTokenEntry called with invalid arguments");
return false;
}
config.auth.webex.tokens[userId] = entry;
try {
saveJson(config.auth.webex.tokens, TOKEN_FILE);
return true;
} catch (err) {
logger.error("Failed to persist token entry", { error: err.message });
return false;
}
}
// === TOKEN LIFECYCLE ===
export async function refreshWebexToken() {
const userId = getCurrentUserId();
const entry = getTokenEntry(userId);
if (!entry?.token?.refresh_token) {
logger.error("No refresh_token found in tokens.json. You need to re-authorize manually.");
return false;
}
try {
logger.info("🔄 Refreshing Webex token using refresh_token...");
const response = await fetch('https://webexapis.com/v1/access_token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'refresh_token',
client_id: config.auth.webex.clientId,
client_secret: config.auth.webex.clientSecret,
refresh_token: entry.token.refresh_token
})
});
if (!response.ok) {
const errText = await response.text();
throw new Error(`Refresh failed ${response.status}: ${errText}`);
}
const newTokenData = await response.json();
const expiresOn = new Date(Date.now() + newTokenData.expires_in * 1000).toISOString();
const updatedEntry = {
created: new Date().toISOString(),
expires: expiresOn,
token: {
...newTokenData,
refresh_token: newTokenData.refresh_token || entry.token.refresh_token
}
};
saveTokenEntry(userId, updatedEntry);
logger.info(`✅ Token refreshed successfully. New expiry: ${expiresOn}`);
return true;
} catch (error) {
logger.error('❌ Token refresh failed', { error: error.message });
return false;
}
}
export function validateToken() {
const userId = getCurrentUserId();
const entry = getTokenEntry(userId);
if (!entry?.token?.access_token) {
logger.warn("⚠️ No access token found.");
return false;
}
const expires = new Date(entry.expires);
if (expires < new Date()) {
logger.warn("⚠️ Token expired. Attempting refresh...");
return refreshWebexToken();
}
logger.info(`✅ Webex access token valid until ${expires.toLocaleString()}`);
return true;
}

62
src/services/wxcc.js Normal file
View file

@ -0,0 +1,62 @@
import fetch from 'node-fetch';
import logger from '../utils/logger.js';
import { saveJson } from '../utils/file.js';
import config from '../config/index.js';
import { getAccessToken } from './token.js';
const BASE_URL = "https://api.wxcc-us1.cisco.com";
async function fetchAllPages(endpoint) {
const accessToken = getAccessToken();
if (!accessToken) {
throw new Error("No valid Webex access token found");
}
const url = new URL(`${BASE_URL}${endpoint}`);
const allItems = [];
let page = 0;
const pageSize = 50;
while (true) {
url.searchParams.set('page', page);
url.searchParams.set('pageSize', pageSize);
const res = await fetch(url, {
headers: { Authorization: `Bearer ${accessToken}` }
});
if (!res.ok) throw new Error(`API ${res.status}: ${await res.text()}`);
const items = await res.json();
allItems.push(...items);
if (items.length < pageSize) break;
page++;
}
return allItems;
}
export async function updateWxCCData() {
try {
const [agents, auxCodes, contactQueues, teams] = await Promise.all([
fetchAllPages(`/organization/${config.auth.webex.orgId}/user`),
fetchAllPages(`/organization/${config.auth.webex.orgId}/auxiliary-code`),
fetchAllPages(`/organization/${config.auth.webex.orgId}/contact-service-queue`),
fetchAllPages(`/organization/${config.auth.webex.orgId}/team`)
]);
await Promise.all([
saveJson(agents, './data/agents.json'),
saveJson(auxCodes, './data/auxCodes.json'),
saveJson(contactQueues, './data/contactQueues.json'),
saveJson(teams, './data/teams.json')
]);
logger.info(`WxCC data updated: ${agents.length} agents, ${auxCodes.length} aux codes`);
return { agents, auxCodes, contactQueues, teams };
} catch (error) {
logger.error('updateWxCCData failed', { error: error.message });
throw error;
}
}

43
src/sockets/handler.js Normal file
View file

@ -0,0 +1,43 @@
import logger from '../utils/logger.js';
import { getCurrentUserId, saveTokenEntry } from '../services/token.js';
export function setupSockets(io, config) {
io.use((socket, next) => {
const token = socket.handshake.auth.token;
if (token === config.auth.websocket.token) {
logger.info(`Client connected: ${socket.id} (${socket.handshake.address})`);
socket.join('main'); // for easier broadcasting
next();
} else {
logger.warn(`Authentication failed for ${socket.id}`);
next(new Error("Authentication failed"));
}
});
io.on('connection', (socket) => {
socket.on('disconnect', () => {
logger.info(`Client disconnected: ${socket.id}`);
});
// Allow an authenticated client (e.g., admin dashboard) to push a fresh token/cert
socket.on('certUpdate', (json) => {
if (!json || !json.access_token) {
logger.warn("certUpdate received without valid token data");
return;
}
const userId = getCurrentUserId();
const expiresOn = new Date(Date.now() + (json.expires_in * 1000)).toISOString();
const entry = {
created: new Date().toISOString(),
expires: expiresOn,
token: json
};
saveTokenEntry(userId, entry);
logger.info(`Manual token update received via socket. Expires: ${expiresOn}`);
});
});
return io;
}

32
src/utils/file.js Normal file
View file

@ -0,0 +1,32 @@
import fs from 'fs';
import path from 'path';
export function saveJson(data, filePath) {
const dir = path.dirname(filePath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
}
export function loadJson(filePath, defaultValue = {}) {
try {
if (!fs.existsSync(filePath)) {
saveJson(defaultValue, filePath); // Create file with default if missing
return defaultValue;
}
const content = fs.readFileSync(filePath, 'utf8').trim();
if (content === '' || content === 'null') {
saveJson(defaultValue, filePath);
return defaultValue;
}
return JSON.parse(content);
} catch (err) {
console.error(`⚠️ Failed to load ${filePath} - creating new file`, err.message);
saveJson(defaultValue, filePath);
return defaultValue;
}
}

29
src/utils/logger.js Normal file
View file

@ -0,0 +1,29 @@
import winston from 'winston';
import DailyRotateFile from 'winston-daily-rotate-file';
import fs from 'fs';
const logsDir = './logs';
if (!fs.existsSync(logsDir)) fs.mkdirSync(logsDir, { recursive: true });
const logger = winston.createLogger({
level: 'info',
format: winston.format.combine(
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
winston.format.printf(({ timestamp, level, message, ...meta }) => {
return `${timestamp} [${level.toUpperCase()}] ${message} ${Object.keys(meta).length ? JSON.stringify(meta) : ''}`;
})
),
transports: [
new winston.transports.Console(),
new DailyRotateFile({
dirname: logsDir,
filename: '%DATE%.log',
datePattern: 'YYYY-MM-DD',
zippedArchive: true,
maxSize: '20m',
maxFiles: '30d'
})
]
});
export default logger;

203
src/views/dashboard.html Normal file
View file

@ -0,0 +1,203 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WxCC Live Monitor</title>
<style>
body { font-family: Arial, sans-serif; margin: 20px; background: #f8f9fa; }
h1 { color: #1e3a8a; }
.card { background: white; padding: 20px; margin: 15px 0; border-radius: 10px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
table { width: 100%; border-collapse: collapse; }
th, td { padding: 12px; text-align: left; border-bottom: 1px solid #ddd; }
th { background: #e0e7ff; }
#teamTable td:nth-child(n+2), #teamTable th:nth-child(n+2) { text-align: center; }
.high { color: #dc2626; font-weight: bold; }
.moderate { color: #ea580c; }
.normal { color: #16a34a; }
/* Visual grouping for Agents table by Team */
#agentTable tr.team-group-start {
border-top: 3px solid #c7d2fe; /* stronger indigo separator between teams */
}
#agentTable td.team-cell {
font-weight: 600;
color: #3730a3;
}
#agentTable td.team-cell.empty {
color: #cbd5e1;
font-weight: 400;
font-size: 0.85em;
}
</style>
</head>
<body>
<h1>🕵️ WxCC Live Monitor</h1>
<p>Last Updated: <span id="lastUpdated">just now</span></p>
<div class="card">
<h2>📊 Queues (Current Activity)</h2>
<table id="queueTable"></table>
</div>
<div class="card">
<h2>👥 Teams (Agent Status)</h2>
<table id="teamTable"></table>
</div>
<div class="card">
<h2>👤 Agents <span style="font-size:0.8em; font-weight:normal; color:#64748b;">(grouped by Team)</span> (<span id="agentCount">0</span>)</h2>
<table id="agentTable">
<thead><tr><th>Agent</th><th>State</th><th>Time</th><th>Queue</th><th>Team</th></tr></thead>
<tbody></tbody>
</table>
</div>
<script src="{{BASE_PATH}}/socket.io/socket.io.js"></script>
<script>
let socket;
let pollInterval;
function formatDuration(seconds) {
if (seconds == null || isNaN(seconds) || seconds <= 0) return '—';
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return m > 0 ? `${m}m ${s}s` : `${s}s`;
}
function connectSocket() {
socket = io({
path: '{{BASE_PATH}}/socket.io',
auth: { token: "{{WS_TOKEN}}" },
reconnection: true,
reconnectionAttempts: 5,
timeout: 10000
});
socket.on('connect', () => {
console.log('✅ WebSocket connected');
// Stop any polling that was started during a disconnect
if (pollInterval) {
clearInterval(pollInterval);
pollInterval = null;
}
});
socket.on('statusUpdate', updateDashboard);
socket.on('disconnect', () => {
console.log('WebSocket disconnected - will retry connection before falling back to polling');
// Give Socket.IO a chance to reconnect before starting polling.
// Many proxies kill idle WS connections after ~15-20s.
setTimeout(() => {
// Only start polling if we're still disconnected
if (!socket.connected) {
console.log('Still disconnected after delay - falling back to polling');
startPolling();
}
}, 3000);
});
}
function startPolling() {
if (pollInterval) clearInterval(pollInterval);
pollInterval = setInterval(() => {
fetch('{{BASE_PATH}}/api/status')
.then(r => r.json())
.then(updateDashboard)
.catch(err => console.error('Polling failed', err));
}, 5000); // poll every 5 seconds
}
function updateDashboard(data) {
document.getElementById('lastUpdated').textContent = new Date(data.timestamp).toLocaleTimeString();
// Normalize data shape: support both WebSocket (queues) and polling (/status uses queueStats)
const queueData = data.queues || data.queueStats || {};
// Queues — sorted by dailyTotal (most → least)
let qHtml = '<tr><th>Queue</th><th>Queued</th><th>Active</th><th>Daily</th><th>Oldest</th><th>Avg Wait</th><th>Status</th></tr>';
const sortedQueues = Object.entries(queueData)
.sort((a, b) => (b[1].dailyTotal || 0) - (a[1].dailyTotal || 0));
sortedQueues.forEach(([name, stats]) => {
const queued = stats.queued || 0;
const active = stats.active || 0;
const daily = stats.dailyTotal || 0;
const status = queued >= 10 ? '🔴 High' : queued > 0 ? '🟡 Moderate' : '🟢 Normal';
const oldest = stats.oldestCallAgeSeconds ? formatDuration(stats.oldestCallAgeSeconds) : '—';
const avgWait = stats.avgWaitSeconds ? formatDuration(stats.avgWaitSeconds) : '—';
qHtml += '<tr><td><strong>' + name + '</strong></td><td><strong>' + queued + '</strong></td><td>' + active + '</td><td>' + daily + '</td><td>' + oldest + '</td><td>' + avgWait + '</td><td>' + status + '</td></tr>';
});
// Only update the queue table if we received actual queue data.
// This prevents blanking the table during brief polling fallbacks or transient disconnects.
if (Object.keys(queueData).length > 0) {
document.getElementById('queueTable').innerHTML = qHtml;
} else {
console.warn('updateDashboard: No queue data received, keeping previous table content');
}
// Teams - state breakdown aggregates
const teamData = data.teams || {};
let tHtml = '<tr><th>Team</th><th>On Calls</th><th>Available</th><th>Idle</th><th>On Break</th><th>Total</th></tr>';
Object.entries(teamData).forEach(([name, stats]) => {
const oc = (stats && stats.onCalls) || 0;
const av = (stats && stats.available) || 0;
const id = (stats && stats.idle) || 0;
const br = (stats && stats.onBreak) || 0;
const tot = (stats && stats.loggedIn) || (oc + av + id + br);
tHtml += '<tr><td><strong>' + name + '</strong></td><td>' + oc + '</td><td>' + av + '</td><td>' + id + '</td><td>' + br + '</td><td><strong>' + tot + '</strong></td></tr>';
});
if (Object.keys(teamData).length > 0) {
document.getElementById('teamTable').innerHTML = tHtml;
}
// Agents — sorted by Team, with visual grouping
const agents = [...(data.agentStates || [])];
agents.sort((a, b) => {
const ta = (a.team || '').toLowerCase();
const tb = (b.team || '').toLowerCase();
if (ta !== tb) return ta.localeCompare(tb);
return (a.fullName || '').localeCompare(b.fullName || '');
});
let tbody = '';
let prevTeam = null;
agents.forEach(a => {
const team = a.team || '—';
const isNewGroup = team !== prevTeam;
prevTeam = team;
// Only show team name on the first row of each group
const teamHtml = isNewGroup
? `<td class="team-cell"><strong>${team}</strong></td>`
: `<td class="team-cell empty"></td>`;
const rowClass = isNewGroup ? 'team-group-start' : '';
tbody += '<tr class="' + rowClass + '">' +
'<td><strong>' + (a.fullName || 'Unknown') + '</strong></td><td>' +
(a.idleCode ? a.currentState + ' (' + a.idleCode + ')' : a.currentState || '—') + '</td><td>' +
(a.timeInStateMinutes || 0) + ' min</td><td>' + (a.contactQueue || '—') + '</td>' +
teamHtml + '</tr>';
});
const agentData = data.agentStates || [];
if (agentData.length > 0) {
document.querySelector('#agentTable tbody').innerHTML = tbody;
document.getElementById('agentCount').textContent = agentData.length;
} else {
console.warn('updateDashboard: No agent data received, keeping previous table content');
}
}
connectSocket();
// Initial load
fetch('{{BASE_PATH}}/api/status').then(r => r.json()).then(updateDashboard);
</script>
</body>
</html>

16
src/views/index.js Normal file
View file

@ -0,0 +1,16 @@
import fs from 'fs';
import path from 'path';
const __dirname = path.dirname(new URL(import.meta.url).pathname);
const templatePath = path.join(__dirname, 'dashboard.html');
let cachedTemplate = null;
export function getDashboardHtml(wsToken = '', basePath = '') {
if (!cachedTemplate) {
cachedTemplate = fs.readFileSync(templatePath, 'utf8');
}
return cachedTemplate
.replace(/\{\{WS_TOKEN\}\}/g, wsToken)
.replace(/\{\{BASE_PATH\}\}/g, basePath);
}