Live Calls (event-driven) & call display pipeline¶
How the Live Calls page gets its data, why it's event-driven, and the verified Asterisk facts every display heuristic must respect. Shipped via astradial-platform PRs #505/#506 (2026-07-06/07), staging-verified.
Architecture¶
Asterisk ──AMI events──> eventListenerService.amiManager (boot-time, persistent)
│ 'channelEvent' (Newchannel/Newstate/Newexten/
│ NewCallerid/NewConnectedLine/NewAccountCode/
│ Hangup/Bridge*/Rename)
▼
liveCallsService (api/src/services/liveCallsService.js)
• in-memory Map<channel, record> mirroring CoreShowChannel fields
• CoreShowChannels resync every 60s + after AMI rewire
• per-org views: filter → dedup by linkedid → enrich → qm-resolve
│
┌───────────────┼──────────────────────────┐
▼ ▼ ▼
GET /calls/live GET /calls/live/stream (SSE) POST /calls/automation-count
(memory, <1ms; push on change (500ms debounce, (workflow-engine
poll fallback) 15s heartbeat, 15min max age) concurrency gate)
- Passive by design: the service only listens. No Asterisk config, no dialplan, no AMI actions on call paths. Worst-case failure = wrong page, never an affected call.
- Readiness gate: until the first successful snapshot resync, the state is unknown —
/calls/liveand the stream return 503 (clients keep showing existing data),automation-countreturns{count:0}(the old AMI-failure semantics). A 5s maintenance tick retries the first resync and re-wires the event listener ifeventListenerServicerebuilt its AMI manager. Never serve an empty map as "no active calls". - Editor (
live-calls/page.tsx+lib/pbx/client.ts liveStream): fetch-based SSE (Bearer auth; EventSource can't send headers), backoff reconnect, falls back to 10s polling only on 404/405/501 or two consecutive silent connects (buffering proxy); hidden tabs tear the stream down; durations tick client-side.
Verified Asterisk facts (AMI capture, staging, 2026-07-07)¶
Every one of these was learned the hard way — display heuristics that guessed differently shipped wrong From/To/CallerID:
- Trunk endpoints are NOT named "trunk". Generated names:
org_<prefix>_tata,org_<prefix>_trunk<ts>, sharedtata_gateway. Any channel-name test must matchtrunk|tata|gateway(TRUNK_LEG_REin liveCallsService;trunkDial()inapi/src/lib/callDirection.js; pollCdr'sTRUNK_NAME). New provider slug ⇒ extend all three (long-term: source names from the SipTrunk table). - The trunk leg's CallerIDNum is the DIALED party. The presented DID is stamped on the caller leg via
NewCallerida few ms after creation. Reading the trunk leg's CID displays "CallerID == To". Newextenreports the channel's BASE context (org_x__internal) even while executing the included__outboundcontext. Context can never detect outbound on extension legs — use the creation-time exten (external-length number ⇒ outbound).- Inbound gateway legs are org-invisible at ring time (shared context
tata-inbound, empty accountcode).Set(CDR(accountcode)=org.id)emitsNewAccountCode~2ms later (Asterisk 20) — org attribution must acceptAccountCode === org.id, not just name-prefix matching. - The DID a caller dialed = the channel's creation-time exten (
_firstExten); the liveExtenmutates every dialplan step (s,qm<hex>tokens). Only DID-shaped values (7+ digits) qualify. Dial(...@target): the @-target is a trunk only when it's a trunk NAME. Local-channel dials target dialplan contexts (…@org_x__internal) — a bare%@%outbound classifier misfires on them (90-day CDR audit: 117 such rows, 0 false positives with name-based patterns).
Debug method that works (use it before changing display logic)¶
- Passive AMI capture on staging: connect a second AMI client (creds in
/opt/astrapbx/.env,AMI_USER/AMI_SECRET), sendEvents: EventMask: on, dump timestamped blocks to a file. - Reproduce the call while capturing.
- Replay the captured events through
liveCallsService._applyEvent()in a local harness (stub-models fixture) and printgetOrgView()after each event — this deterministically reproduces exactly what the dashboard rendered, for the deployed version AND the candidate fix. - For Call Logs (CDR pipeline): run the classifier CASE from
callDirection.jsdirectly againstasterisk_cdron the known linkedids.
Related¶
- Call Logs From/To/Direction share the same trunk-naming facts — classifier lives in
api/src/lib/callDirection.js(single source of truth for/calls,/export,/history,/stats; pollCdr has an aligned JS copy feeding webhooks/auto-tickets). - Troubleshooting entries: Error 86 (Live Calls display), Error 87 (Call Logs display) in troubleshooting.