Building a dashboard with service stats
This guide is for partners who build their own queue (service) dashboards, wall boards or reports on the live stats of Wildix services. It explains what the API gives you, how to read each number, how to derive the usual dashboard metrics from it, and how to keep a dashboard live. Working code is in section 8, and every file can be downloaded.
1. Two ways to get the stats
| HTTP | WebSocket | |
|---|---|---|
| How | ListServices and GetServiceStats requests | Subscribe to the services topic |
| You get | Stats of the services you ask for, when you ask | A snapshot first, then every change as it happens |
| Updates | As often as you poll. The stats change at most every 5 seconds, so polling faster only repeats | Stats updates at most every 5 seconds per service; calls and agents change instantly |
| Calls and agents | Counts only (callsWaiting, agentsAvailable, ...) | Counts, and the list of waiting calls and of agents with their status |
| Good for | Reports, back-end collectors, simple pages | Wall boards and live consoles |
Both return the same stats block. You can combine them: load the first view over HTTP, then switch to the websocket.
2. Before you start
Service ids. A service is identified by a full id of the form <company>_<pbx>_<number>, for example
it_acme_22110000697c_9. ListServices returns the ids of the services that are live now; a service that has
ended is no longer listed.
Access.
- HTTP: the token needs the
streamscopes forListServicesandGetServiceStats(read). A token of a user, a PBX or an API key carries the company. A service-to-service token must pass the company as thecompanyquery parameter, otherwise the request is rejected with a validation error. - WebSocket: the token must belong to a user with a Premium license. Any other user gets an
errormessage with code403in answer to the subscribe.
Limits.
| Limit | Value |
|---|---|
Service ids per GetServiceStats request | 20 (fewer with includeAgentStats and a long window, see below) |
Services per subscribe request | 50 |
| Subscriptions per websocket connection | 500 |
periodMinutes | omitted or 15, 30, 45, 60 |
periodStartAt | omitted, or epoch milliseconds on a UTC hour boundary, not in the future |
periodStart | omitted or today |
utcOffsetMinutes | with periodStart: a whole number of hours from -720 to 840, default 0 |
serviceLevelSeconds | omitted (20) or one of 5, 10, 15, 20, 25, 30 |
Only one of periodMinutes, periodStartAt and periodStart may be set. Anything else outside these limits is rejected with a validation error.
GetServiceStats does not return agentStats unless you send "includeAgentStats": true. Per-agent data is the
largest part of the answer, so ask for it only when you show it. With includeAgentStats, one request may carry at
most 20 ids for windows up to 12 hours, falling to 15 ids at 16 hours and 11 ids at 24 hours. A request over the limit is
rejected with a validation error that states the limit. The websocket always includes agentStats.
No history. The platform keeps the stats of the last 25 hours and serves at most the last 24 hours. It does not store a time series. A dashboard that draws a trend samples the stats itself while it is open.
3. How the numbers work
Windows
Counters are kept per 15-minute UTC bucket (10:00, 10:15, ...) for the last hour, and per hourly UTC
bucket (10:00, 11:00, ...) for the last 24 hours. The stats you receive are the sum of whole buckets:
periodMinutes | Buckets summed |
|---|---|
omitted or 15 | The current 15-minute bucket only |
30, 45, 60 | The 15-minute buckets overlapping the last 30, 45 or 60 minutes |
periodStartAt or periodStart | The hourly buckets from the start time up to now |
Longer windows: since a start time
periodMinutes covers up to an hour. For a longer window, say where it starts, using one of two fields instead
of periodMinutes. Both read hourly buckets and give an exact window: windowStartedAt is the start itself and no
earlier bucket is counted.
periodStart: "today" is the simple option for "stats since midnight". Add utcOffsetMinutes to say where the day
starts (120 for UTC+2, -300 for UTC-5; UTC when omitted). The window moves to the new day by itself at midnight,
so a websocket subscription never needs renewing. The first stats of a new day arrive with the next
service_stats_update event, at most 15 minutes after midnight for a quiet service.
{ "action": "subscribe", "topic": "services", "periodStart": "today", "utcOffsetMinutes": 120 }
periodStartAt is the epoch-millisecond start you compute yourself, for example "since my shift started at
08:00". Compute it in the time zone you care about, so daylight saving time is handled on your side:
const shiftStart = new Date(); shiftStart.setHours(8, 0, 0, 0);
{ "action": "subscribe", "topic": "services", "periodStartAt": shiftStart.getTime() }
Rules that follow from the hourly buckets:
periodStartAtmust be on a UTC hour boundary: an 08:00 or midnight start qualifies in every time zone with a whole-hour offset, but a start such as 08:30 is rejected with a validation error. It must not be in the future.utcOffsetMinutesmust be a whole number of hours. For a zone such as UTC+5:30 useperiodStartAtwith the previous full UTC hour and accept the wider window.periodStartAtdoes not slide: the covered span grows until you subscribe again, so subscribe again with a new start when the shift ends or the day rolls over. A subscribe with the same filter replaces the previous one.utcOffsetMinutesis fixed for the subscription, so subscribe again when daylight saving time changes it.- Stats are kept for 24 hours. A start older than that is clamped to the oldest hour kept;
windowStartedAttells you what you got.
So a rolling window is usually a little longer than you asked for, by up to 15 minutes. Two fields tell you what you really got:
windowStartedAt: the start of the oldest bucket included, in epoch milliseconds.periodSeconds: the seconds fromwindowStartedAtup toasOf. Use this, not the requested minutes, as the denominator of every rate.
At the start of a bucket the "current 15 minutes" view is nearly empty, because the previous bucket has just
dropped out. That is expected. If you want a smoother view, ask for 30 or 60.
Window counters and live values
The stats mix two kinds of values:
- Window counters (
offered,answered,answeredWaitSumMs, ...) accumulate over the window. They only grow within a bucket. - Live values are a snapshot of right now and do not depend on
periodMinutes:callsWaiting,callsActive,longestWaitStartedAt, and theagents*counts.
When a call is counted
A call moves through the counters as it progresses. Every counter of a call goes into the 15-minute bucket in which
the call entered the queue, also when the answer or the end comes later. A call that enters at 10:14:50 and is
answered at 10:15:10 is answered in the 10:00 bucket, so "last 15 minutes" counts the calls that entered in those
minutes, not the calls answered in them. Agent state times (availableMs, busyMs, pausedMs, ...) and acwSumMs
are not call counters: they are split over the buckets by the time they actually passed. When each counter is added:
| Step | Counted |
|---|---|
| The call enters the queue | offered, waitCount |
| An agent answers | answered, its wait in answeredWaitSumMs, maxWaitMs and the wait histogram (answeredWithinSl) |
| The call ends | Everything else: abandoned (with shortAbandoned), unanswered, unknown, transferred, talkSumMs, talkCount, the wait of calls that were never answered, and the per-agent call counters |
This is why some counters lag: the talk time of a call is added when it ends, and the wait of an abandoned call is added when it ends.
Freshness and ordering
- Calls and agents (the list of who is waiting, who is talking, agent statuses) arrive on the websocket the moment they change.
- Stats are recalculated by the platform after those events. A new stats value is sent at most once every 5 seconds per service, when something changed, and once more at every 15-minute bucket roll-over so that the window keeps sliding for a quiet queue. Expect the stats to trail a call event by a few seconds. Windows of 2 hours and longer are updated less often, so they can trail by up to a minute.
GetServiceStatsmay answer from a cache of a few seconds, so its counters can be up to about 5 seconds older than the latest update. Calls, agents and the other live values are always current.- Each stats update has a
versionthat grows with every update of a service. Updates can arrive out of order, so a client must ignore an update whose version is not higher than the one it already applied (see section 7).
4. Stats reference
All fields can be missing: treat a missing counter as 0.
Window counters
| Field | Meaning |
|---|---|
offered | Every call that entered the queue, counted when it enters. Not the same as the per-agent offered, which counts the calls rung to that agent |
outbound | Outbound queue calls received |
answered | Calls answered by an agent |
unanswered | Calls that ended without an answer and without the caller hanging up (for example a queue timeout) |
unknown | Calls that ended without an outcome: nobody answered and the call never reported an end, so it was closed after going silent. They are not in the service-level base. With no call waiting, offered = (answered - transferred) + transferred + abandoned + unanswered + unknown, where answered includes the transferred calls |
abandoned | Calls where the caller hung up before an answer, including the short abandons |
shortAbandoned | The part of abandoned where the caller hung up within 5 seconds (usually a misdial; most dashboards leave them out of the service level). Not in addition to abandoned |
transferred | Answered calls that the agent passed on: a transfer (blind or attended) or a forward. A call nobody answered is never counted, a pick-up is not a transfer. Counted when the call ends |
answeredWaitSumMs, abandonedWaitSumMs, unansweredWaitSumMs | Total wait of the answered, abandoned and unanswered (not abandoned, including unknown outcome) calls. Answered grows when the call is answered, the other two when the call ends |
waitCount | Number of waits. Counts a call when it enters the queue |
maxWaitMs | Longest wait among waits that have ended. A call that is still waiting is not in it |
talkSumMs, talkCount | Total talk time and number of answered calls that have ended |
ringSumMs, ringCount | Ring time of the service's calls and the number of calls counted. Per call: from the first ring of any agent to the answer, or to the end of the call when nobody answered; re-rings and pauses included. Added when the call completes |
acwSumMs | After-call work (wrap-up) time of all agents. A wrap-up is added once it has ended, so one in progress is not included |
answeredWithinSl | Answered calls that waited no longer than serviceLevelSeconds. The numerator of the service level, see section 5 for the base |
serviceLevelSeconds | The service-level target answeredWithinSl was counted against |
Window position
| Field | Meaning |
|---|---|
windowStartedAt | Start of the oldest bucket in the window (epoch ms) |
periodSeconds | Seconds the counters really cover |
asOf | When these stats were refreshed (epoch ms) |
Live values (right now, independent of the window)
| Field | Meaning |
|---|---|
callsWaiting | Calls in the queue that have not been answered |
callsActive | Calls answered and not ended |
longestWaitStartedAt | When the longest-waiting call entered the queue (epoch ms). Missing when nobody waits. Compute the wait yourself as now - longestWaitStartedAt and tick it locally |
agentsTotal | Agents of the service (business and premium licenses) |
agentsAvailable | Ready for a call |
agentsTalking | On a call |
agentsPaused | Paused, wrap-up not included |
agentsWrapUp | In wrap-up (after-call work) |
agentsLoggedOff | Logged off or unavailable |
Every agent is in exactly one state, so the five state counts add up to agentsTotal. The state of an agent is
decided in this order: logged off (status offline, unavailable, loggedoff, logged_off, logged-out,
logout, unregistered), wrap-up (paused with the reason "Wrap-up time"), paused, talking (on an answered call, or a
busy status), available. A status that is not one of the known ones counts as logged off.
Per agent: agentStats[agentId]
GetServiceStats returns this only with includeAgentStats: true; the websocket always includes it.
| Field | Meaning |
|---|---|
offered | Calls offered (rung) to the agent. A call counts once however many times it rang |
answered | Calls the agent answered |
missed | Calls offered to the agent that another agent answered. A caller who hung up is not a miss |
talkSumMs, talkCount | Talk time and number of ended calls the agent answered |
ringToAnswerSumMs, ringToAnswerCount | Time from the first ring of the agent to the answer, re-rings and pauses included, for the calls the agent answered |
answeredRingSumMs, answeredRingCount | How long the agent's phone actually rang, the sum of its rings, for the calls the agent answered. Smaller than ringToAnswerSumMs when the agent was rung, paused and rung again |
availableMs, busyMs, pausedMs, wrapUpMs | Time the agent spent in each state, while logged in. pausedMs does not include wrap-up |
loggedInMs | Total logged-in time: the four above added up |
An agent's current state is projected up to asOf, so these times include the state the agent is in right now.
5. Deriving dashboard metrics
The API sends counters, not ratios, because a ratio depends on the window and on your own definition. The functions
below are in deriveMetrics.ts, dependency-free and unit tested.
| Metric | Formula | Notes |
|---|---|---|
| Service level | answeredWithinSl / (answered + (abandoned - shortAbandoned) + unanswered) | Calls with a known outcome; calls still waiting are not in the base. abandoned includes the short abandons, so they are subtracted: a misdial does not hurt the service level. The target is the serviceLevelSeconds you subscribed with |
| Abandon rate | abandoned / offered | Short abandons are included. To match the service level, take shortAbandoned out of both the numerator and the denominator. Pick one definition and use it everywhere |
| Average speed of answer (ASA) | answeredWaitSumMs / answered | Answered calls only |
| Average wait | (answeredWaitSumMs + abandonedWaitSumMs + unansweredWaitSumMs) / (waitCount - callsWaiting) | Leaves out the calls whose wait has not ended. Without the subtraction the average is too low while people wait. Use plain waitCount if the longest wait started before windowStartedAt |
| Average talk | talkSumMs / talkCount | Ended answered calls only |
| Longest wait now | now - longestWaitStartedAt | Tick it locally, once a second |
| Max wait | max(maxWaitMs, now - longestWaitStartedAt) | maxWaitMs alone ignores the caller who has waited longest and is still waiting |
| Calls per hour | offered / periodSeconds * 3600 | Use periodSeconds, not the requested window |
| Agents by state | agentsAvailable, agentsTalking, ... | Read them as sent; do not recompute from agent statuses |
| Agent occupancy | (busyMs + wrapUpMs) / (busyMs + wrapUpMs + availableMs) | Time working over time ready or working; breaks (pausedMs) do not lower it |
| Agent average talk | talkSumMs / talkCount | |
| Average ring time | ringSumMs / ringCount | Per call, from the first ring of any agent to the answer or to the end |
| Agent average ring to answer | ringToAnswerSumMs / ringToAnswerCount | First ring of the agent to the answer, pauses included |
| Agent average ringing | answeredRingSumMs / answeredRingCount | Only the time the agent's phone rang |
| Agent answer rate | answered / offered |
Worked example. Suppose a 10-minute window with offered 20, answered 12, unanswered 1, abandoned 7,
shortAbandoned 4, answeredWithinSl 9, answeredWaitSumMs 60000, abandonedWaitSumMs 50000, unansweredWaitSumMs 10000, waitCount 20, callsWaiting 2,
talkSumMs 600000, talkCount 11, periodSeconds 600:
- Service level:
9 / (12 + (7 - 4) + 1) = 56% - Abandon rate:
7 / 20 = 35%, or(7 - 4) / (20 - 4) = 19%without the short abandons - Average speed of answer:
60 s / 12 = 5 s - Average wait:
120 s / (20 - 2) = 6.7 s - Average talk:
600 s / 11 = 54.5 s - Calls per hour:
20 / 600 * 3600 = 120
The unit tests that ship with the examples check these numbers.
Several services. To show a total over services, add up the counters first and compute the ratio afterwards. Averaging the services' own ratios gives a quiet queue as much weight as a busy one.
Colours and goals. Thresholds such as "service level below 80% is red" are your dashboard's choice; the API has no notion of a goal.
6. HTTP: poll the stats
List the live services
POST /v2/stream/services, optional body {"filter": [{"pbx": "22110000697c"}]} to keep one or more PBXs.
{
"services": [
{"id": "it_acme_22110000697c_9", "name": "Support", "pbx": "22110000697c", "aclGroup": "160365050"}
]
}
Get the stats
POST /v2/stream/services/stats
{
"ids": ["it_acme_22110000697c_9"],
"periodMinutes": 30,
"serviceLevelSeconds": 10,
"includeAgentStats": true
}
{
"services": [
{
"id": "it_acme_22110000697c_9",
"stats": {
"offered": 20, "answered": 12, "unanswered": 1, "abandoned": 7, "shortAbandoned": 4,
"answeredWaitSumMs": 60000, "abandonedWaitSumMs": 50000, "unansweredWaitSumMs": 10000, "waitCount": 20, "talkSumMs": 600000, "talkCount": 11,
"answeredWithinSl": 9, "serviceLevelSeconds": 10, "maxWaitMs": 40000,
"windowStartedAt": 1716215400000, "asOf": 1716216000000, "periodSeconds": 600,
"callsWaiting": 2, "callsActive": 1, "longestWaitStartedAt": 1716215950000,
"agentsTotal": 10, "agentsAvailable": 7, "agentsTalking": 1, "agentsPaused": 1,
"agentsWrapUp": 0, "agentsLoggedOff": 1,
"agentStats": {"sim_agent_101": {"offered": 8, "answered": 6, "missed": 2, "busyMs": 240000}}
}
}
]
}
Unknown ids, ended services and ids of other companies are left out of the answer, so compare the returned ids with the ones you asked for.
With the SDK (@wildix/wda-stream-client) the same calls are ListServicesCommand and GetServiceStatsCommand;
see examples/pollStats.ts, which lists the services, fetches their stats in chunks of 20
and prints the metrics of section 5 every few seconds.
Poll no faster than every few seconds: the stats change at most every 5 seconds. Add a back-off on errors and keep the last good value on screen.
7. WebSocket: follow a service live
The sequence
client server
| {"action":"authorize","token":"…"} -----> |
| <----- {"event":"authorized", …} |
| {"action":"subscribe","topic":"services", |
| "filter":[["<full service id>"]], |
| "periodMinutes":30,"serviceLevelSeconds":10} -->
| <----- {"event":"subscribed","topic":"services", …}
| <----- broadcast: snapshot of the service, with stats
| <----- broadcast: call, agent and service events as they happen
| <----- broadcast: service_stats_update, at most every 5 s
Subscribe options
| Field | Meaning |
|---|---|
topic | services |
filter | Which services. [["<full service id>"]], or [["<number>","<pbx>"]] with the numeric short id. Several entries are allowed (up to 50). Leave it out to follow every service of the company |
stats | true asks for stats with the default window. Stats are opt-in: without stats or one of the fields below, the subscription receives service and call events only, with no stats on the snapshot and no service_stats_update |
periodMinutes, periodStartAt, periodStart, utcOffsetMinutes | The window of the stats, as in section 3. Set at most one of the first three |
serviceLevelSeconds | The service-level target. Like the window fields, it turns stats on |
state | true (the default) answers the subscribe with the snapshot. false skips the snapshot |
Subscribing again with the same filter replaces the earlier settings and sends a fresh snapshot. That is how a dashboard changes its window or service-level target without reconnecting. A connection that follows all services and also one service specifically gets the specific subscription's settings for that service.
What you receive
Every message about a service arrives as
{"event": "broadcast", "topic": "services", "data": { "...record..." }}
data is a record or an array of records, and may nest records under services, items or data. Walk it
rather than assuming one shape (the examples do). A record's event says what it is:
event | What it is | Carries stats |
|---|---|---|
service | The service. On subscribe it is the snapshot: name, agents, the ids of the live calls, and stats. Later it reports a change of the service or of its agents (eventTrigger: service.init, service.update, service.agent_join, service.agent_update, service.agent_leave) | Only on the snapshot |
service_call | A step of a call (eventTrigger: service.call_init, service.call_update, service.call_end) with its action (INIT, WAIT, RING, REJECT, CANCEL, ANSWER, END), startTime, relatedAgents, customer, queuePosition | No |
service_call_complete | A call has ended | No |
service_stats_update | New stats, with a version | Yes |
service_complete | The service has ended | No |
The id of every record is the full service id.
A service_stats_update:
{
"event": "broadcast",
"topic": "services",
"data": {
"event": "service_stats_update",
"id": "it_acme_22110000697c_9",
"pbx": "22110000697c",
"company": "it_acme",
"time": 1716216000000,
"version": 17,
"stats": { "offered": 20, "callsWaiting": 2, "...": "..." }
}
}
Rules for a correct client
- Authorize first, then subscribe. A subscribe on a connection that has not been authorized is answered with an
errormessage with code401. - Apply stats updates by version. Keep the highest
versionyou applied; ignore an update with a version that is not higher. The snapshot's stats have no version: reset your stored version to 0 when you take a snapshot. - Stats are never on call or agent events. Keep the latest stats and update them only from the snapshot and from
service_stats_update. Update the lists of calls and agents from the other events. - Re-subscribe after a reconnect, and expect a fresh snapshot. Reset your state before you subscribe, so that a service that ended while you were away does not linger.
- Re-subscribe now and then. Subscriptions are stored with a time to live (currently two hours). The example re-sends the subscribe every hour, which also refreshes the snapshot.
- Read the
errormessage. A failed subscribe is answered with{"event":"error","message":"…","code":403,"source":{…}}, wheresourceis the request that failed. The codes you meet are401(not authorized),403(no Premium license) and400(invalid request, for example aperiodMinutesthat is not allowed). - Tick locally. Keep a one-second timer that recomputes the waiting times from
longestWaitStartedAtand the callstartTimes; do not wait for the next message.
examples/liveTracker.ts implements rules 2, 3 and 4 and keeps the agents and calls of a
service. examples/liveStats.ts is a complete Node client with authorize, subscribe,
reconnect with back-off and a once-a-second summary.
Many services
Without a filter you receive every service of the company on one connection. Keep one tracker per service id and
route each record by its id. The window and the service-level target are per subscription, so all services share
them.
8. Examples
Each file below is the real source, shown here and available to download. The two Node examples need the packages named in their header.
| File | What it is |
|---|---|
deriveMetrics.ts | Every formula of section 5. No dependencies; copy it into your project |
liveTracker.ts | Applies websocket messages to a per-service state (stats by version, agents, calls) |
pollStats.ts | HTTP polling with the SDK: list services, fetch stats, print the metrics |
liveStats.ts | Websocket client for one service with reconnect and a live summary |
dashboard.html | A single-file live dashboard (no build, no libraries). Save it, open it in a browser, fill in the websocket URL, a token and a service id, and press Connect. The page runs entirely in your browser and talks only to the websocket you give it. Never put a token in a page that other people can open |
# HTTP polling
npm install @wildix/wda-stream-client
WILDIX_TOKEN=<access token> npx tsx pollStats.ts
# Live over the websocket
npm install ws
WILDIX_WEBSOCKET_URL=<websocket url> WILDIX_TOKEN=<access token> SERVICE_ID=<full service id> npx tsx liveStats.ts
Use the API and websocket addresses of your environment.
Metric formulas: deriveMetrics.ts
/**
* Formulas for the metrics a queue dashboard usually shows, computed from the `stats` block of a service.
*
* Everything here is a pure function of the stats (and, where it makes sense, of "now"), with no dependency on
* the Wildix SDK, so it can be copied into any project. Rates and averages are not sent by the API on purpose:
* they depend on the lookback window and on which definition you choose, so each dashboard derives its own.
*/
/** Counters of one agent over the lookback window (`stats.agentStats[agentId]`). */
export interface AgentStatsLike {
offered?: number;
answered?: number;
missed?: number;
talkSumMs?: number;
talkCount?: number;
ringToAnswerSumMs?: number;
ringToAnswerCount?: number;
answeredRingSumMs?: number;
answeredRingCount?: number;
availableMs?: number;
busyMs?: number;
/** Paused time, wrap-up not included. */
pausedMs?: number;
wrapUpMs?: number;
loggedInMs?: number;
}
/** The fields of `ServiceStats` used below. All of them can be missing: treat a missing counter as 0. */
export interface ServiceStatsLike {
offered?: number;
outbound?: number;
answered?: number;
unanswered?: number;
unknown?: number;
abandoned?: number;
shortAbandoned?: number;
answeredWaitSumMs?: number;
abandonedWaitSumMs?: number;
unansweredWaitSumMs?: number;
waitCount?: number;
ringSumMs?: number;
ringCount?: number;
talkSumMs?: number;
talkCount?: number;
answeredWithinSl?: number;
maxWaitMs?: number;
windowStartedAt?: number;
asOf?: number;
periodSeconds?: number;
longestWaitStartedAt?: number;
callsWaiting?: number;
callsActive?: number;
agentsTotal?: number;
agentsAvailable?: number;
agentsTalking?: number;
agentsPaused?: number;
agentsWrapUp?: number;
agentsLoggedOff?: number;
agentStats?: Record<string, AgentStatsLike>;
}
const orZero = (value: number | undefined): number => value ?? 0;
const ratio = (numerator: number, denominator: number): number | null =>
denominator > 0 ? numerator / denominator : null;
/** Abandons that waited more than 5 seconds: `abandoned` includes `shortAbandoned`. */
const longAbandoned = (stats: ServiceStatsLike): number => Math.max(0, orZero(stats.abandoned) - orZero(stats.shortAbandoned));
/**
* Service level, 0..1: calls answered within the service-level target out of the calls with a known outcome
* (answered, abandoned, unanswered). `abandoned` includes the short abandons (the caller hung up within 5 seconds);
* they are taken out of the base, so a misdial does not hurt the service level. Calls still waiting are not in the
* base. The target is the `serviceLevelSeconds` you asked for.
*/
export function serviceLevel(stats: ServiceStatsLike): number | null {
return ratio(
orZero(stats.answeredWithinSl),
orZero(stats.answered) + longAbandoned(stats) + orZero(stats.unanswered),
);
}
/**
* Abandon rate, 0..1: abandoned calls (short ones included) out of all offered calls. With `excludeShortAbandons` the
* short abandons are removed from the numerator and the denominator, which matches how the service level treats them. Pick one definition and
* use it everywhere on the dashboard.
*/
export function abandonRate(stats: ServiceStatsLike, options: {excludeShortAbandons?: boolean} = {}): number | null {
if (options.excludeShortAbandons) {
return ratio(longAbandoned(stats), orZero(stats.offered) - orZero(stats.shortAbandoned));
}
return ratio(orZero(stats.abandoned), orZero(stats.offered));
}
/**
* Average wait in milliseconds of the calls whose wait has ended.
*
* `waitCount` counts a call when it enters the queue, but its wait is added to the wait sums only when the wait ends
* (answer, hang-up, timeout). While calls are waiting `totalWaitMs / waitCount` is therefore too low. Subtracting
* `callsWaiting` removes the calls that are still waiting. That is only valid when every waiting call entered the
* queue inside the lookback window, which holds when the longest wait started at or after `windowStartedAt`;
* otherwise this falls back to `waitCount`.
*/
export function averageWaitMs(stats: ServiceStatsLike): number | null {
const waitingInWindow =
stats.longestWaitStartedAt == null || stats.longestWaitStartedAt >= orZero(stats.windowStartedAt)
? orZero(stats.callsWaiting)
: 0;
const totalWaitMs =
orZero(stats.answeredWaitSumMs) + orZero(stats.abandonedWaitSumMs) + orZero(stats.unansweredWaitSumMs);
return ratio(totalWaitMs, Math.max(0, orZero(stats.waitCount) - waitingInWindow));
}
/** Average speed of answer (ASA) in milliseconds: the average wait of the answered calls only. */
export function averageSpeedOfAnswerMs(stats: ServiceStatsLike): number | null {
return ratio(orZero(stats.answeredWaitSumMs), orZero(stats.answered));
}
/** Average talk time in milliseconds of the answered calls that have ended. */
export function averageTalkMs(stats: ServiceStatsLike): number | null {
return ratio(orZero(stats.talkSumMs), orZero(stats.talkCount));
}
/**
* Wait in milliseconds of the call that has been waiting the longest right now, or null when nobody waits.
* `longestWaitStartedAt` is a point in time: compute the duration yourself and tick it locally between updates.
*/
export function longestWaitMs(stats: ServiceStatsLike, now: number = Date.now()): number | null {
return stats.longestWaitStartedAt == null ? null : Math.max(0, now - stats.longestWaitStartedAt);
}
/**
* Longest wait in the window in milliseconds, including a call that is still waiting. `maxWaitMs` only covers waits
* that have ended, so a dashboard that shows it alone lags behind the caller who has waited the longest.
*/
export function maxWaitIncludingLiveMs(stats: ServiceStatsLike, now: number = Date.now()): number {
return Math.max(orZero(stats.maxWaitMs), longestWaitMs(stats, now) ?? 0);
}
/**
* Calls offered per hour over the span the counters really cover (`periodSeconds`). Counters are summed over whole
* UTC buckets (15 minutes, or an hour from 2 hours), so `periodSeconds` can be longer than the `periodMinutes` you asked for: use it, not the
* requested value, as the denominator of any rate.
*/
export function offeredPerHour(stats: ServiceStatsLike): number | null {
const perSecond = ratio(orZero(stats.offered), orZero(stats.periodSeconds));
return perSecond == null ? null : perSecond * 3600;
}
/** Average talk time in milliseconds of one agent. */
export function agentAverageTalkMs(agent: AgentStatsLike): number | null {
return ratio(orZero(agent.talkSumMs), orZero(agent.talkCount));
}
/** Average time in milliseconds from the first ring to the answer, for the calls the agent answered. */
export function agentAverageRingToAnswerMs(agent: AgentStatsLike): number | null {
return ratio(orZero(agent.ringToAnswerSumMs), orZero(agent.ringToAnswerCount));
}
/** Average time in milliseconds the agent's phone rang, for the calls the agent answered. */
export function agentAverageRingMs(agent: AgentStatsLike): number | null {
return ratio(orZero(agent.answeredRingSumMs), orZero(agent.answeredRingCount));
}
/** Average ring time in milliseconds of the service's calls: first ring of any agent to the answer or the end. */
export function averageRingMs(stats: ServiceStatsLike): number | null {
return ratio(orZero(stats.ringSumMs), orZero(stats.ringCount));
}
/**
* Occupancy of one agent, 0..1: the share of working time spent on calls and wrap-up, working time being the time the
* agent was ready or busy. Paused time is excluded, so a break does not lower the occupancy.
*/
export function agentOccupancy(agent: AgentStatsLike): number | null {
const working = orZero(agent.busyMs) + orZero(agent.wrapUpMs);
return ratio(working, working + orZero(agent.availableMs));
}
/** Share of the calls offered to the agent that the agent answered, 0..1. */
export function agentAnswerRate(agent: AgentStatsLike): number | null {
return ratio(orZero(agent.answered), orZero(agent.offered));
}
/** How the agents of the service are split right now. The values add up to `total`. */
export interface AgentStateCounts {
total: number;
available: number;
talking: number;
paused: number;
wrapUp: number;
loggedOff: number;
}
/** The agent counts of the stats, with 0 for anything missing. Read them as they are, do not recompute them. */
export function agentStateCounts(stats: ServiceStatsLike): AgentStateCounts {
return {
total: orZero(stats.agentsTotal),
available: orZero(stats.agentsAvailable),
talking: orZero(stats.agentsTalking),
paused: orZero(stats.agentsPaused),
wrapUp: orZero(stats.agentsWrapUp),
loggedOff: orZero(stats.agentsLoggedOff),
};
}
/** Formats milliseconds as mm:ss, or h:mm:ss from an hour on. Null renders as a dash. */
export function formatDuration(ms: number | null): string {
if (ms == null) {
return '-';
}
const seconds = Math.max(0, Math.round(ms / 1000));
const pad = (value: number) => String(value).padStart(2, '0');
return seconds >= 3600
? `${Math.floor(seconds / 3600)}:${pad(Math.floor((seconds % 3600) / 60))}:${pad(seconds % 60)}`
: `${pad(Math.floor(seconds / 60))}:${pad(seconds % 60)}`;
}
/** Formats a 0..1 ratio as a whole percent. Null renders as a dash. */
export function formatPercent(value: number | null): string {
return value == null ? '-' : `${Math.round(value * 100)}%`;
}
Live state: liveTracker.ts
import type {ServiceStatsLike} from './deriveMetrics';
/** A call of the service that is in the queue or being talked right now. */
export interface LiveCall {
callId: string;
/** Last action of the call: INIT, WAIT, RING, REJECT, CANCEL, ANSWER or END (the PBX may send other spellings). */
action?: string;
/** When the call entered the queue, in epoch milliseconds. */
startTime?: number;
time?: number;
queuePosition?: number;
relatedAgents?: string[];
customer?: {phone?: string; name?: string};
[key: string]: unknown;
}
/** An agent of the service as the service events describe it. */
export interface LiveAgent {
id: string;
name?: string;
status?: string;
paused?: boolean;
reason?: string;
userLicense?: string;
[key: string]: unknown;
}
type Record_ = Record<string, unknown>;
const WAITING_ACTIONS = new Set(['INIT', 'WAIT', 'RING', 'REJECT']);
const isRecord = (value: unknown): value is Record_ =>
typeof value === 'object' && value !== null && !Array.isArray(value);
const dropNull = (record: Record_): Record_ =>
Object.fromEntries(Object.entries(record).filter(([, value]) => value != null));
/** Calls `visitor` for every record of a `broadcast` payload. The payload is a record or an array of records. */
function visit(node: unknown, visitor: (record: Record_) => void): void {
if (Array.isArray(node)) {
node.forEach((item) => visit(item, visitor));
return;
}
if (!isRecord(node)) {
return;
}
visitor(node);
for (const key of ['services', 'items', 'data']) {
visit(node[key], visitor);
}
}
/**
* Keeps the state of one service up to date from the messages of the `services` websocket topic.
*
* - `stats` is the latest stats block. It arrives on the subscribe snapshot and on `service_stats_update` events,
* never on call or agent events. Updates carry a growing `version`: an update that is not newer than the one
* already applied is ignored, because updates can arrive out of order.
* - `agents` and `calls` follow the service, call and agent events, so a dashboard can list who is waiting or talking.
*
* Call {@link resetForSubscribe} right before every `subscribe` (also on reconnect): the snapshot that answers it
* carries stats without a version, and versions start again from there.
*/
export class ServiceStatsTracker {
stats: ServiceStatsLike | undefined;
/** When the current stats were received, in epoch milliseconds (local clock). */
statsReceivedAt = 0;
/** Version of the last applied `service_stats_update`; 0 until one arrives after a subscribe. */
statsVersion = 0;
readonly agents = new Map<string, LiveAgent>();
readonly calls = new Map<string, LiveCall>();
/** True once the service has ended (`service_complete`). */
ended = false;
private readonly serviceId: string;
constructor(serviceId: string) {
this.serviceId = serviceId;
}
resetForSubscribe(): void {
this.stats = undefined;
this.statsVersion = 0;
}
/** Applies one parsed websocket message. Returns true when anything of the tracked service changed. */
apply(message: unknown): boolean {
if (!isRecord(message) || message.event !== 'broadcast' || message.topic !== 'services') {
return false;
}
let changed = false;
visit(message.data, (record) => {
if (typeof record.id === 'string' && this.matches(record.id) && typeof record.event === 'string') {
changed = this.applyRecord(record) || changed;
}
});
return changed;
}
/** Calls that are in the queue and not answered yet, longest waiting first. */
waitingCalls(): LiveCall[] {
return [...this.calls.values()]
.filter((call) => call.action == null || WAITING_ACTIONS.has(String(call.action).toUpperCase()))
.sort((first, second) => (first.startTime ?? first.time ?? 0) - (second.startTime ?? second.time ?? 0));
}
/** Calls that an agent has answered and that have not ended. */
talkingCalls(): LiveCall[] {
return [...this.calls.values()].filter((call) => String(call.action).toUpperCase() === 'ANSWER');
}
private matches(id: string): boolean {
return id === this.serviceId || id.endsWith(`_${this.serviceId}`) || this.serviceId.endsWith(`_${id}`);
}
private applyRecord(record: Record_): boolean {
switch (record.event) {
case 'service_stats_update':
return this.applyStatsUpdate(record);
case 'service':
return this.applyService(record);
case 'service_call':
return this.applyCall(record);
case 'service_call_complete':
return typeof record.callId === 'string' && this.calls.delete(record.callId);
case 'service_complete':
this.ended = true;
this.calls.clear();
return true;
default:
return false;
}
}
private applyStatsUpdate(record: Record_): boolean {
if (!isRecord(record.stats)) {
return false;
}
if (typeof record.version === 'number') {
if (record.version <= this.statsVersion) {
return false; // older than, or the same as, what is already applied
}
this.statsVersion = record.version;
}
this.stats = record.stats as ServiceStatsLike;
this.statsReceivedAt = Date.now();
return true;
}
/** The service record: the subscribe snapshot (with stats) and later service, agent and call-list updates. */
private applyService(record: Record_): boolean {
const trigger = typeof record.eventTrigger === 'string' ? record.eventTrigger : '';
if (isRecord(record.stats)) {
this.stats = record.stats as ServiceStatsLike;
this.statsReceivedAt = Date.now();
this.statsVersion = 0;
}
if (Array.isArray(record.agents)) {
const agents = record.agents.filter(isRecord).filter((agent): agent is LiveAgent => typeof agent.id === 'string');
if (trigger === 'service.init' || trigger === '') {
const keep = new Set(agents.map((agent) => agent.id));
for (const id of [...this.agents.keys()]) {
if (!keep.has(id)) {
this.agents.delete(id);
}
}
}
for (const agent of agents) {
if (trigger === 'service.agent_leave') {
this.agents.delete(agent.id);
} else {
this.agents.set(agent.id, {...this.agents.get(agent.id), ...dropNull(agent), id: agent.id});
}
}
}
for (const removed of Array.isArray(record.agentsRemoved) ? record.agentsRemoved : []) {
this.agents.delete(typeof removed === 'string' ? removed : String((removed as Record_)?.id));
}
if (Array.isArray(record.calls) && trigger !== 'service.call_init') {
const live = new Set(
record.calls.map((call) => (typeof call === 'string' ? call : String((call as Record_)?.callId))),
);
for (const callId of [...this.calls.keys()]) {
if (!live.has(callId)) {
this.calls.delete(callId);
}
}
}
return true;
}
private applyCall(record: Record_): boolean {
if (typeof record.callId !== 'string') {
return false;
}
const action = String(record.action ?? '').toUpperCase();
if (record.eventTrigger === 'service.call_end' || action === 'END') {
return this.calls.delete(record.callId);
}
// Stats are never part of a call record; the merge keeps what earlier events said (start time, customer).
const update = dropNull(record);
delete update.stats;
this.calls.set(record.callId, {...this.calls.get(record.callId), ...update, callId: record.callId});
return true;
}
}
HTTP polling: pollStats.ts
/**
* Polls the stats of every live service over HTTP and prints one line per service.
*
* Use polling when you only need a refresh every few seconds, when you cannot keep a websocket open, or when a
* back end collects the metrics for other systems. For a wall board that must react to every call use the
* websocket (liveStats.ts): polling cannot see a call that starts and ends between two requests, and the stats are
* refreshed by the platform at most every 5 seconds anyway.
*
* npm install @wildix/wda-stream-client
* WILDIX_TOKEN=<access token> npx tsx pollStats.ts
*
* Optional environment: WILDIX_ENV (default: production), WILDIX_COMPANY (only for service tokens that act for a
* company), PERIOD_MINUTES (15, 30, 45 or 60; default 15) or PERIOD_START=today (the day so far), SERVICE_LEVEL_SECONDS (5, 10, 15, 20,
* 25 or 30; default 20), POLL_SECONDS (default 10).
*/
import {GetServiceStatsCommand, ListServicesCommand, WdaStreamClient} from '@wildix/wda-stream-client';
import {
abandonRate,
averageTalkMs,
averageWaitMs,
formatDuration,
formatPercent,
longestWaitMs,
maxWaitIncludingLiveMs,
serviceLevel,
} from './deriveMetrics';
const company = process.env.WILDIX_COMPANY;
const periodMinutes = Number(process.env.PERIOD_MINUTES ?? 15);
// PERIOD_START=today asks for the stats since midnight of this machine's time zone instead of a rolling lookback.
// The day start must be on a UTC hour, so a time zone with a half-hour offset falls back to UTC.
const localOffsetMinutes = -new Date().getTimezoneOffset();
const periodWindow =
process.env.PERIOD_START === 'today'
? {periodStart: 'today' as const, utcOffsetMinutes: localOffsetMinutes % 60 === 0 ? localOffsetMinutes : 0}
: {periodMinutes};
const serviceLevelSeconds = Number(process.env.SERVICE_LEVEL_SECONDS ?? 20);
const pollSeconds = Number(process.env.POLL_SECONDS ?? 10);
const MAX_IDS_PER_REQUEST = 20;
const client = new WdaStreamClient({
env: process.env.WILDIX_ENV,
token: () => Promise.resolve({token: process.env.WILDIX_TOKEN!}),
});
async function pollOnce(): Promise<void> {
// The services that are live right now. A service that has ended no longer appears here.
const {services} = await client.send(new ListServicesCommand({company}));
const names = new Map(services.map((service) => [service.id, service.name]));
const ids = services.map((service) => service.id);
for (let from = 0; from < ids.length; from += MAX_IDS_PER_REQUEST) {
// Sequential on purpose: one request at a time keeps the load on the API low.
// eslint-disable-next-line no-await-in-loop
const {services: withStats} = await client.send(
new GetServiceStatsCommand({
company,
ids: ids.slice(from, from + MAX_IDS_PER_REQUEST),
...periodWindow,
serviceLevelSeconds,
}),
);
const now = Date.now();
for (const {id, stats} of withStats) {
console.log(
[
`${names.get(id) ?? id}`.padEnd(24),
`SL ${formatPercent(serviceLevel(stats))}`.padEnd(10),
`abandon ${formatPercent(abandonRate(stats))}`.padEnd(15),
`avg wait ${formatDuration(averageWaitMs(stats))}`.padEnd(16),
`avg talk ${formatDuration(averageTalkMs(stats))}`.padEnd(16),
`max wait ${formatDuration(maxWaitIncludingLiveMs(stats, now))}`.padEnd(16),
`waiting ${stats.callsWaiting ?? '-'} (longest ${formatDuration(longestWaitMs(stats, now))})`.padEnd(30),
`talking ${stats.callsActive ?? '-'}`,
].join(' '),
);
}
}
}
async function main(): Promise<void> {
for (;;) {
try {
// One poll at a time, then wait: the loop is meant to be sequential.
// eslint-disable-next-line no-await-in-loop
await pollOnce();
} catch (error) {
// Keep polling: a failed request is usually temporary (network, token about to expire).
console.error('poll failed:', error instanceof Error ? error.message : error);
}
console.log('');
// eslint-disable-next-line no-await-in-loop
await new Promise((resolve) => setTimeout(resolve, pollSeconds * 1000));
}
}
void main();
Websocket client: liveStats.ts
/**
* Follows one service live over the websocket and prints a summary line once per second.
*
* npm install ws
* WILDIX_WEBSOCKET_URL=<websocket url> WILDIX_TOKEN=<access token> SERVICE_ID=<full service id> npx tsx liveStats.ts
*
* SERVICE_ID is the full id from ListServices, for example `<company>_<pbx>_<number>`. Optional environment:
* PERIOD_MINUTES (15, 30, 45 or 60; default 15) or PERIOD_START=today (the day so far), SERVICE_LEVEL_SECONDS (5, 10, 15, 20, 25 or 30;
* default 20).
*
* What the example shows:
* - authorize, then subscribe: the answer to the subscribe is a snapshot with the stats, the agents and the calls;
* - later stats arrive as `service_stats_update` events (at most every 5 seconds per service, when something
* changed, and when a 15-minute bucket rolls over), call and agent events carry none;
* - a stale or repeated stats update is ignored by its version;
* - on a reconnect, and once an hour, the client subscribes again, which also refreshes the snapshot.
*/
import WebSocket from 'ws';
import {
abandonRate,
agentStateCounts,
averageTalkMs,
averageWaitMs,
formatDuration,
formatPercent,
longestWaitMs,
maxWaitIncludingLiveMs,
serviceLevel,
} from './deriveMetrics';
import {ServiceStatsTracker} from './liveTracker';
const url = process.env.WILDIX_WEBSOCKET_URL!;
const token = process.env.WILDIX_TOKEN!;
const serviceId = process.env.SERVICE_ID!;
const periodMinutes = Number(process.env.PERIOD_MINUTES ?? 15);
// PERIOD_START=today asks for the stats since midnight of this machine's time zone instead of a rolling lookback.
// The day start must be on a UTC hour, so a time zone with a half-hour offset falls back to UTC.
const localOffsetMinutes = -new Date().getTimezoneOffset();
const periodWindow =
process.env.PERIOD_START === 'today'
? {periodStart: 'today' as const, utcOffsetMinutes: localOffsetMinutes % 60 === 0 ? localOffsetMinutes : 0}
: {periodMinutes};
const serviceLevelSeconds = Number(process.env.SERVICE_LEVEL_SECONDS ?? 20);
const RESUBSCRIBE_MS = 60 * 60 * 1000;
const tracker = new ServiceStatsTracker(serviceId);
let socket: WebSocket | undefined;
let reconnectDelayMs = 1000;
let resubscribeTimer: NodeJS.Timeout | undefined;
function subscribe(): void {
// Subscribing again with the same filter replaces the earlier settings and answers with a fresh snapshot.
tracker.resetForSubscribe();
socket?.send(
JSON.stringify({action: 'subscribe', topic: 'services', filter: [[serviceId]], ...periodWindow, serviceLevelSeconds}),
);
}
function connect(): void {
socket = new WebSocket(url);
socket.on('open', () => {
socket?.send(JSON.stringify({action: 'authorize', token}));
});
socket.on('message', (raw) => {
const message = JSON.parse(raw.toString()) as {event?: string; message?: string};
switch (message.event) {
case 'authorized':
reconnectDelayMs = 1000;
subscribe();
clearInterval(resubscribeTimer);
resubscribeTimer = setInterval(subscribe, RESUBSCRIBE_MS);
break;
case 'subscribed':
console.log(`subscribed to ${serviceId}`);
break;
case 'error':
console.error('websocket error:', message.message ?? JSON.stringify(message));
break;
default:
tracker.apply(message);
}
});
socket.on('close', () => {
clearInterval(resubscribeTimer);
console.warn(`connection closed, reconnecting in ${reconnectDelayMs / 1000}s`);
setTimeout(connect, reconnectDelayMs);
reconnectDelayMs = Math.min(reconnectDelayMs * 2, 30_000);
});
socket.on('error', (error) => console.error('socket error:', error.message));
}
function printSummary(): void {
const {stats} = tracker;
if (stats == null) {
return;
}
const now = Date.now();
const agents = agentStateCounts(stats);
const staleSeconds = Math.round((now - tracker.statsReceivedAt) / 1000);
console.log(
[
`SL ${formatPercent(serviceLevel(stats))}`,
`abandon ${formatPercent(abandonRate(stats))}`,
`avg wait ${formatDuration(averageWaitMs(stats))}`,
`avg talk ${formatDuration(averageTalkMs(stats))}`,
`max wait ${formatDuration(maxWaitIncludingLiveMs(stats, now))}`,
`| waiting ${tracker.waitingCalls().length} (longest ${formatDuration(longestWaitMs(stats, now))})`,
`talking ${tracker.talkingCalls().length}`,
`| agents ${agents.total}: ${agents.available} free, ${agents.talking} talking, ${agents.paused} paused, ` +
`${agents.wrapUp} wrap-up, ${agents.loggedOff} logged off`,
`| stats ${staleSeconds}s old (v${tracker.statsVersion})`,
].join(' '),
);
}
connect();
setInterval(printSummary, 1000);
Single-file dashboard: dashboard.html
Show the source (214 lines)
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Queue stats demo</title>
<!--
A minimal live queue dashboard in one file, with no build step and no libraries. Open it in a browser, fill in the
websocket url, an access token and the full id of a service, and press Connect.
The formulas and the message handling are compact copies of deriveMetrics.ts and liveTracker.ts in this folder;
read those for the commented versions.
For local trials only: a page that holds an access token must never be served to other people. A real dashboard
gets the token from your own back end or your sign-in flow, and keeps it out of the page source.
-->
<style>
:root { --bg:#f6f7f9; --card:#fff; --text:#1c2430; --muted:#6a7585; --line:#e3e7ee; --good:#1a8f4c; --warn:#b7791f; --bad:#c53030; --accent:#2b6cb0; }
@media (prefers-color-scheme: dark) { :root { --bg:#12161c; --card:#1b212a; --text:#e6eaf0; --muted:#93a0b2; --line:#2a323d; --good:#4cc38a; --warn:#e0a84c; --bad:#f06a6a; --accent:#6aa7e8; } }
* { box-sizing:border-box; }
body { margin:0; padding:16px; background:var(--bg); color:var(--text); font:14px/1.4 system-ui, sans-serif; }
h1 { margin:0 0 12px; font-size:18px; }
form { display:grid; grid-template-columns:repeat(auto-fit,minmax(200px,1fr)); gap:8px; margin-bottom:12px; }
label { display:flex; flex-direction:column; gap:3px; color:var(--muted); font-size:12px; }
input, select, button { font:inherit; padding:7px 9px; border:1px solid var(--line); border-radius:6px; background:var(--card); color:var(--text); }
button { cursor:pointer; background:var(--accent); color:#fff; border-color:var(--accent); align-self:end; }
#status { margin-bottom:12px; color:var(--muted); font-size:12px; }
.grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(150px,1fr)); gap:10px; margin-bottom:12px; }
.tile { background:var(--card); border:1px solid var(--line); border-radius:8px; padding:10px 12px; }
.tile b { display:block; font-size:24px; font-variant-numeric:tabular-nums; }
.tile span { color:var(--muted); font-size:12px; }
.good b { color:var(--good); } .warn b { color:var(--warn); } .bad b { color:var(--bad); }
.panel { background:var(--card); border:1px solid var(--line); border-radius:8px; padding:10px 12px; margin-bottom:12px; overflow-x:auto; }
.panel h2 { margin:0 0 6px; font-size:13px; color:var(--muted); font-weight:600; }
table { width:100%; border-collapse:collapse; font-variant-numeric:tabular-nums; }
th, td { text-align:left; padding:4px 8px 4px 0; border-bottom:1px solid var(--line); white-space:nowrap; }
th { color:var(--muted); font-weight:500; font-size:12px; }
.num { text-align:right; }
</style>
</head>
<body>
<h1>Queue stats demo <span id="serviceName"></span></h1>
<form id="form">
<label>Websocket url<input id="url" placeholder="wss://…" required></label>
<label>Access token<input id="token" type="password" required></label>
<label>Service id (full)<input id="serviceId" placeholder="company_pbx_9" required></label>
<label>Window
<select id="period"><option value="15">Current 15 min</option><option value="30">Last 30 min</option><option value="60">Last hour</option><option value="today">Today</option></select>
</label>
<label>Service level target
<select id="sl"><option>5</option><option selected>10</option><option>15</option><option>20</option><option>25</option><option>30</option></select>
</label>
<button type="submit">Connect</button>
</form>
<div id="status">Not connected.</div>
<div class="grid" id="kpis"></div>
<div class="grid" id="agentTiles"></div>
<div class="panel"><h2>Waiting calls</h2><div id="waiting"></div></div>
<div class="panel"><h2>Agents</h2><div id="agents"></div></div>
<script>
(() => {
'use strict';
// ---- formulas (see deriveMetrics.ts) --------------------------------------------------------------
const n = (v) => v ?? 0;
const ratio = (a, b) => (b > 0 ? a / b : null);
const serviceLevel = (s) => ratio(n(s.answeredWithinSl), n(s.answered) + Math.max(0, n(s.abandoned) - n(s.shortAbandoned)) + n(s.unanswered));
const abandonRate = (s) => ratio(n(s.abandoned), n(s.offered));
const averageWaitMs = (s) => {
const waiting = s.longestWaitStartedAt == null || s.longestWaitStartedAt >= n(s.windowStartedAt) ? n(s.callsWaiting) : 0;
return ratio(n(s.answeredWaitSumMs) + n(s.abandonedWaitSumMs) + n(s.unansweredWaitSumMs), Math.max(0, n(s.waitCount) - waiting));
};
const averageSpeedOfAnswerMs = (s) => ratio(n(s.answeredWaitSumMs), n(s.answered));
const averageTalkMs = (s) => ratio(n(s.talkSumMs), n(s.talkCount));
const longestWaitMs = (s, now) => (s.longestWaitStartedAt == null ? null : Math.max(0, now - s.longestWaitStartedAt));
const maxWaitMs = (s, now) => Math.max(n(s.maxWaitMs), longestWaitMs(s, now) ?? 0);
const occupancy = (a) => { const work = n(a.busyMs) + n(a.wrapUpMs); return ratio(work, work + n(a.availableMs)); };
const pad = (v) => String(v).padStart(2, '0');
const fmtDuration = (ms) => { if (ms == null) return '-'; const s = Math.max(0, Math.round(ms / 1000)); return s >= 3600 ? `${Math.floor(s / 3600)}:${pad(Math.floor((s % 3600) / 60))}:${pad(s % 60)}` : `${pad(Math.floor(s / 60))}:${pad(s % 60)}`; };
const fmtPercent = (v) => (v == null ? '-' : `${Math.round(v * 100)}%`);
const esc = (v) => String(v ?? '').replace(/[&<>"]/g, (c) => ({'&': '&', '<': '<', '>': '>', '"': '"'}[c]));
// ---- state (see liveTracker.ts) -------------------------------------------------------------------
const WAITING = ['INIT', 'WAIT', 'RING', 'REJECT'];
const state = {stats: null, statsVersion: 0, agents: new Map(), calls: new Map(), serviceId: '', ws: null, name: ''};
const isRecord = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
const dropNull = (o) => Object.fromEntries(Object.entries(o).filter(([, v]) => v != null));
const matches = (id) => id === state.serviceId || id.endsWith(`_${state.serviceId}`) || state.serviceId.endsWith(`_${id}`);
function visit(node, fn) {
if (Array.isArray(node)) { node.forEach((x) => visit(x, fn)); return; }
if (!isRecord(node)) return;
fn(node);
for (const key of ['services', 'items', 'data']) visit(node[key], fn);
}
function applyRecord(rec) {
const trigger = typeof rec.eventTrigger === 'string' ? rec.eventTrigger : '';
if (rec.event === 'service_stats_update') {
if (!isRecord(rec.stats)) return;
if (typeof rec.version === 'number') { if (rec.version <= state.statsVersion) return; state.statsVersion = rec.version; }
state.stats = rec.stats;
} else if (rec.event === 'service') {
state.name = rec.name || state.name;
if (isRecord(rec.stats)) { state.stats = rec.stats; state.statsVersion = 0; }
if (Array.isArray(rec.agents)) {
const agents = rec.agents.filter((a) => isRecord(a) && typeof a.id === 'string');
if (trigger === 'service.init' || trigger === '') {
const keep = new Set(agents.map((a) => a.id));
for (const id of [...state.agents.keys()]) if (!keep.has(id)) state.agents.delete(id);
}
for (const a of agents) {
if (trigger === 'service.agent_leave') state.agents.delete(a.id);
else state.agents.set(a.id, {...state.agents.get(a.id), ...dropNull(a)});
}
}
for (const r of Array.isArray(rec.agentsRemoved) ? rec.agentsRemoved : []) state.agents.delete(typeof r === 'string' ? r : r && r.id);
if (Array.isArray(rec.calls) && trigger !== 'service.call_init') {
const live = new Set(rec.calls.map((c) => (typeof c === 'string' ? c : c && c.callId)));
for (const id of [...state.calls.keys()]) if (!live.has(id)) state.calls.delete(id);
}
} else if (rec.event === 'service_call' && typeof rec.callId === 'string') {
const action = String(rec.action || '').toUpperCase();
if (trigger === 'service.call_end' || action === 'END') state.calls.delete(rec.callId);
else { const {stats: _ignored, ...rest} = rec; state.calls.set(rec.callId, {...state.calls.get(rec.callId), ...dropNull(rest)}); }
} else if (rec.event === 'service_call_complete' && typeof rec.callId === 'string') {
state.calls.delete(rec.callId);
} else if (rec.event === 'service_complete') {
state.calls.clear();
$('status').textContent = 'The service has ended.';
}
}
function onMessage(message) {
if (message.event === 'authorized') { $('status').textContent = 'Authorized, subscribing…'; subscribe(); }
else if (message.event === 'subscribed') $('status').textContent = 'Subscribed. Waiting for the snapshot…';
else if (message.event === 'error') $('status').textContent = `Error: ${message.message || JSON.stringify(message)}`;
else if (message.event === 'broadcast' && message.topic === 'services') {
visit(message.data, (rec) => { if (typeof rec.id === 'string' && matches(rec.id) && typeof rec.event === 'string') applyRecord(rec); });
}
}
// ---- connection -----------------------------------------------------------------------------------
const $ = (id) => document.getElementById(id);
const waitingCalls = () => [...state.calls.values()].filter((c) => !c.action || WAITING.includes(String(c.action).toUpperCase()))
.sort((a, b) => (a.startTime || a.time || 0) - (b.startTime || b.time || 0));
function subscribe() {
// Subscribing again with the same filter replaces the settings and answers with a fresh snapshot.
state.stats = null; state.statsVersion = 0;
// "Today" is the day so far in this browser's time zone. The day start must be on a UTC hour, so a time zone
// with a half-hour offset falls back to UTC.
const offset = -new Date().getTimezoneOffset();
const window_ = $('period').value === 'today'
? {periodStart: 'today', utcOffsetMinutes: offset % 60 === 0 ? offset : 0}
: {periodMinutes: Number($('period').value)};
state.ws.send(JSON.stringify({action: 'subscribe', topic: 'services', filter: [[state.serviceId]],
...window_, serviceLevelSeconds: Number($('sl').value)}));
}
function connect(event) {
event.preventDefault();
if (state.ws) { state.ws.onclose = null; state.ws.close(); }
state.serviceId = $('serviceId').value.trim();
state.agents.clear(); state.calls.clear(); state.stats = null; state.statsVersion = 0;
const ws = new WebSocket($('url').value.trim());
state.ws = ws;
ws.onopen = () => ws.send(JSON.stringify({action: 'authorize', token: $('token').value.trim()}));
ws.onmessage = (ev) => onMessage(JSON.parse(ev.data));
ws.onclose = () => { $('status').textContent = 'Disconnected. Press Connect to try again.'; };
}
// ---- rendering ------------------------------------------------------------------------------------
const tile = (value, label, cls = '') => `<div class="tile ${cls}"><b>${esc(value)}</b><span>${esc(label)}</span></div>`;
function render() {
const s = state.stats; const now = Date.now();
$('serviceName').textContent = state.name ? `- ${state.name}` : '';
if (!s) { $('kpis').innerHTML = ''; $('agentTiles').innerHTML = ''; }
else {
const sl = serviceLevel(s); const ab = abandonRate(s);
$('kpis').innerHTML = [
tile(fmtPercent(sl), `Service level (${s.serviceLevelSeconds ?? $('sl').value}s)`, sl == null ? '' : sl >= 0.8 ? 'good' : sl >= 0.6 ? 'warn' : 'bad'),
tile(fmtPercent(ab), 'Abandon rate', ab == null ? '' : ab <= 0.05 ? 'good' : ab <= 0.1 ? 'warn' : 'bad'),
tile(fmtDuration(averageWaitMs(s)), 'Avg wait'),
tile(fmtDuration(averageTalkMs(s)), 'Avg talk'),
tile(fmtDuration(maxWaitMs(s, now)), 'Max wait'),
tile(s.callsWaiting ?? waitingCalls().length, 'Calls waiting'),
tile(fmtDuration(longestWaitMs(s, now)), 'Longest wait now'),
tile(s.callsActive ?? '-', 'Calls active'),
tile(`${n(s.answered)} / ${n(s.offered)}`, 'Answered / offered'),
].join('');
$('agentTiles').innerHTML = [
tile(s.agentsTotal ?? '-', 'Agents'), tile(s.agentsAvailable ?? '-', 'Available', 'good'), tile(s.agentsTalking ?? '-', 'Talking'),
tile(s.agentsPaused ?? '-', 'Paused'), tile(s.agentsWrapUp ?? '-', 'Wrap-up'), tile(s.agentsLoggedOff ?? '-', 'Logged off'),
].join('');
}
const waiting = waitingCalls();
$('waiting').innerHTML = waiting.length
? `<table><tr><th>#</th><th>Caller</th><th class="num">Waiting</th></tr>${waiting.map((c, i) => `<tr><td>${i + 1}</td><td>${esc((c.customer && (c.customer.name || c.customer.phone)) || c.callId)}</td><td class="num">${fmtDuration(now - (c.startTime || c.time || now))}</td></tr>`).join('')}</table>`
: '<span style="color:var(--muted)">Nobody is waiting.</span>';
const perAgent = (s && s.agentStats) || {};
$('agents').innerHTML = state.agents.size
? `<table><tr><th>Agent</th><th>Status</th><th class="num">Offered</th><th class="num">Answered</th><th class="num">Missed</th><th class="num">Occupancy</th></tr>${[...state.agents.values()].map((a) => { const st = perAgent[a.id] || {}; return `<tr><td>${esc(a.name || a.id)}</td><td>${esc(a.paused ? `${a.status || 'paused'}${a.reason ? ` (${a.reason})` : ''}` : a.status || '-')}</td><td class="num">${n(st.offered)}</td><td class="num">${n(st.answered)}</td><td class="num">${n(st.missed)}</td><td class="num">${fmtPercent(occupancy(st))}</td></tr>`; }).join('')}</table>`
: '<span style="color:var(--muted)">No agents yet.</span>';
if (s && state.ws && state.ws.readyState === WebSocket.OPEN) {
$('status').textContent = `Live. Stats as of ${new Date(s.asOf || now).toLocaleTimeString()}, covering ${Math.round(n(s.periodSeconds) / 60)} min.`;
}
}
$('form').addEventListener('submit', connect);
for (const id of ['period', 'sl']) $(id).addEventListener('change', () => { if (state.ws && state.ws.readyState === WebSocket.OPEN) subscribe(); });
setInterval(render, 500); // ticks the waiting times between messages
render();
})();
</script>
</body>
</html>
9. Checklist and troubleshooting
Before you ship
- Every rate uses
periodSeconds, not the requested window. - Average wait leaves out the calls that are still waiting.
- Max wait includes the live wait.
- Stats updates are applied by
version, and the version is reset on every subscribe. - The dashboard recovers by itself: reconnect with back-off, re-subscribe, fresh state.
- A missing counter is treated as 0 and a missing
longestWaitStartedAtas "nobody waits". - Totals over several services add the counters first.
- Waiting times tick locally between messages.
Troubleshooting
| Symptom | Likely cause |
|---|---|
Subscribe answered with error, code 403 | The token's user has no Premium license |
Subscribe answered with error, code 401 | The connection was not authorized first, or the token was not accepted |
| Subscribed, but no records arrive | The filter id is wrong. Use the full id from ListServices, or [number, pbx] with a numeric short id. Without a filter you get every service |
No stats on the snapshot and no service_stats_update | Stats are opt-in. Send "stats": true, or a window or serviceLevelSeconds field, in the subscribe |
| Stats are missing on call events | By design. Stats arrive on the snapshot and on service_stats_update |
| A counter has not moved a few seconds after a call ended | Stats trail the call events by up to a few seconds (the 5-second update interval plus processing) |
| Numbers differ from a PBX report | A rolling periodMinutes window is made of whole 15-minute UTC buckets, so it can be up to 15 minutes longer than requested; a periodStartAt or periodStart window is exact. Compare windowStartedAt and periodSeconds |
| The current 15-minute view is empty right after the quarter hour | The previous bucket has dropped out. Use a 30 or 60 minute window |
GetServiceStats returns fewer services than asked | The others are unknown, ended, or belong to another company |
| Average wait looks too low | Use the formula with callsWaiting; plain sum of the wait sums / waitCount is biased while calls wait |
Not in this feed: history beyond 24 hours, per-interval time series, sentiment, the split between AI and human agents, and listen / whisper / barge-in controls.