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:
commit
935c27854b
34 changed files with 4353 additions and 0 deletions
49
.dockerignore
Normal file
49
.dockerignore
Normal 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
82
.env.example
Normal 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
49
.gitignore
vendored
Normal 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
143
DOCKER.md
Normal 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
86
Dockerfile
Normal 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
8
data/config.json
Normal 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
19
data/thresholds.json
Normal 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
73
docker-compose.dev.yml
Normal 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
85
docker-compose.yml
Normal 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
6
nodemon.json
Normal 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
1921
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
22
package.json
Normal file
22
package.json
Normal 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
13
scripts/healthcheck.js
Normal 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
29
src/app.js
Normal 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
37
src/config/index.js
Normal 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
|
||||
}
|
||||
};
|
||||
32
src/middleware/webhookAuth.js
Normal file
32
src/middleware/webhookAuth.js
Normal 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
110
src/routes/api.js
Normal 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
14
src/routes/dashboard.js
Normal 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
14
src/routes/index.js
Normal 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
133
src/server.js
Normal 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 });
|
||||
});
|
||||
});
|
||||
78
src/services/activityLogger.js
Normal file
78
src/services/activityLogger.js
Normal 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
48
src/services/alerts.js
Normal 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
34
src/services/broadcast.js
Normal 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);
|
||||
}
|
||||
220
src/services/queueMonitor.js
Normal file
220
src/services/queueMonitor.js
Normal 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
135
src/services/state.js
Normal 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');
|
||||
}
|
||||
97
src/services/subscription.js
Normal file
97
src/services/subscription.js
Normal 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
318
src/services/thresholds.js
Normal 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
113
src/services/token.js
Normal 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
62
src/services/wxcc.js
Normal 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
43
src/sockets/handler.js
Normal 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
32
src/utils/file.js
Normal 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
29
src/utils/logger.js
Normal 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
203
src/views/dashboard.html
Normal 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
16
src/views/index.js
Normal 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);
|
||||
}
|
||||
Loading…
Reference in a new issue