live: AMI-fed live state, /api/live WebSocket, dashboard and status dots
ci / check (push) Canceled after 0s
ci / images (push) Canceled after 0s

LiveState loads extension states and registered devices from Asterisk on
every AMI connect and follows ExtensionStatus/ContactStatus; the hub sends
a snapshot then deltas batched every 250 ms (devices for admins only),
pings every 30 s and closes unauthenticated sockets with 4401. The web UI
keeps one reconnecting socket in a zustand store, shows a Reconnecting
banner, a Dashboard (online, on a call, telephony engine) and status dots.

Fix: Asterisk 24 has no 'pjsip reload'; reload with
'module reload res_pjsip.so'. AMI errors now carry the CLI output.

Milestone check, automated: call-test.ts registers two baresip phones and
holds a 30 s call with RTP both ways (pnpm test:asterisk); the recorded
session drives the live-state tests. scripts/e2e/m2-live.mjs runs api +
Asterisk + phones + browser on a private Docker network: a new extension
is live in Asterisk in ~1.3 s, the dashboard shows 2/2 online and red dots
during the call.

Task: M2-T6
This commit is contained in:
iamdoubz committed 2026-10-17 16:49:25 -05:00
1 parent 1cfc13ead3
commit c0c4036887
31 files changed
+4810 -28

No files matched your search

+76
View File
@@ -0,0 +1,76 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Live data from /api/live (05 §1): one WebSocket per tab, reconnecting with backoff.
import { create } from 'zustand';
import type { ExtensionLive, LiveMessage } from '@selfphoneng/shared';
type LiveStore = {
/** WebSocket state: 'connecting' before the first snapshot. */
status: 'connecting' | 'open' | 'reconnecting';
amiConnected: boolean;
extensions: Record<string, ExtensionLive>;
};
export const useLive = create<LiveStore>(() => ({
status: 'connecting',
amiConnected: false,
extensions: {},
}));
function apply(m: LiveMessage) {
useLive.setState((s) => {
switch (m.type) {
case 'snapshot':
return {
status: 'open',
amiConnected: m.data.ami.connected,
extensions: Object.fromEntries(m.data.extensions.map((e) => [e.number, e])),
};
case 'extension.updated':
return { extensions: { ...s.extensions, [m.data.number]: m.data } };
case 'extension.removed': {
const { [m.data.number]: _gone, ...rest } = s.extensions;
return { extensions: rest };
}
case 'ami.status':
return { amiConnected: m.data.connected };
default:
return {};
}
});
}
const BACKOFF = [1, 2, 4, 8, 16, 30].map((s) => s * 1000);
let socket: WebSocket | null = null;
let users = 0;
let attempt = 0;
let retry: ReturnType<typeof setTimeout> | undefined;
function connect() {
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
const ws = new WebSocket(`${proto}://${location.host}/api/live`);
socket = ws;
ws.onmessage = (ev) => {
attempt = 0;
apply(JSON.parse(ev.data as string) as LiveMessage);
};
ws.onclose = (ev) => {
if (socket !== ws || users === 0) return;
socket = null;
if (ev.code === 4401) return; // signed out: the auth gate redirects to /login
useLive.setState({ status: 'reconnecting' });
retry = setTimeout(connect, BACKOFF[Math.min(attempt++, BACKOFF.length - 1)]);
};
}
/** Pages that show live data call this in an effect; the socket closes when nobody needs it. */
export function subscribeLive(): () => void {
if (users++ === 0) connect();
return () => {
if (--users > 0) return;
clearTimeout(retry);
const ws = socket;
socket = null;
ws?.close();
useLive.setState({ status: 'connecting' });
};
}