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

+2 -1
View File
@@ -17,7 +17,8 @@
"react-dom": "^19.3.1",
"react-hook-form": "^7.90.0",
"react-router": "^8.4.0",
"zod": "^4.6.7"
"zod": "^4.6.7",
"zustand": "^5.0.15"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.3",
+32
View File
@@ -0,0 +1,32 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// 05 §3: idle green, ringing amber pulsing, in use red, on hold blue, unavailable gray, DND purple.
import type { ExtensionLive } from '@selfphoneng/shared';
const LOOK: Record<string, { color: string; label: string }> = {
idle: { color: 'bg-green-500', label: 'Available' },
ringing: { color: 'bg-amber-400 animate-pulse', label: 'Ringing' },
ringinuse: { color: 'bg-amber-400 animate-pulse', label: 'Ringing (on a call)' },
inuse: { color: 'bg-red-500', label: 'On a call' },
busy: { color: 'bg-red-500', label: 'Busy' },
onhold: { color: 'bg-blue-500', label: 'On hold' },
unavailable: { color: 'bg-zinc-400 dark:bg-zinc-600', label: 'Offline' },
dnd: { color: 'bg-purple-500', label: 'Do not disturb' },
};
export function StatusDot({ ext }: { ext: ExtensionLive | undefined }) {
const key = !ext ? 'unavailable' : ext.dnd && ext.state === 'idle' ? 'dnd' : ext.state;
const { color, label } = LOOK[key] ?? LOOK.unavailable!;
return (
<span className="inline-flex items-center" title={label}>
<span aria-hidden className={`inline-block size-2.5 rounded-full ${color}`} />
<span className="sr-only">{label}</span>
</span>
);
}
export const statusLabel = (ext: ExtensionLive | undefined) =>
!ext
? 'Offline'
: ext.dnd && ext.state === 'idle'
? 'Do not disturb'
: (LOOK[ext.state]?.label ?? ext.state);
+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' });
};
}
+4 -1
View File
@@ -9,6 +9,7 @@ import { ChangePasswordPage } from './pages/ChangePasswordPage.tsx';
import { LoginPage } from './pages/LoginPage.tsx';
import { ADMIN_PAGES, AdminLayout, ComingSoon } from './pages/admin/AdminLayout.tsx';
import { UsersPage } from './pages/admin/UsersPage.tsx';
import { DashboardPage } from './pages/admin/DashboardPage.tsx';
import { MePage } from './pages/user/MePage.tsx';
import './index.css';
@@ -21,7 +22,9 @@ const router = createBrowserRouter([
children: ADMIN_PAGES.map((p) => ({
...(p.path ? { path: p.path } : { index: true }),
element:
p.path === 'users' ? (
p.path === '' ? (
<DashboardPage />
) : p.path === 'users' ? (
<RequireAuth>{(user) => <UsersPage currentUserId={user.id} />}</RequireAuth>
) : (
<ComingSoon label={p.label} milestone={p.milestone ?? ''} />
+14 -3
View File
@@ -1,15 +1,16 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Admin shell (05 §2): sidebar in spec order; unbuilt pages say which milestone brings them.
import { useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { NavLink, Outlet, useNavigate } from 'react-router';
import { APP_NAME, type AuthUser } from '@selfphoneng/shared';
import { authApi } from '../../api/client.ts';
import { ThemeToggle } from '../../components/ThemeToggle.tsx';
import { Button } from '../../components/ui.tsx';
import { subscribeLive, useLive } from '../../live/store.ts';
export const ADMIN_PAGES = [
{ path: '', label: 'Dashboard', milestone: 'M2' },
{ path: '', label: 'Dashboard', milestone: null },
{ path: 'users', label: 'Users & Extensions', milestone: null },
{ path: 'trunks', label: 'Trunks', milestone: 'M3' },
{ path: 'numbers', label: 'Phone numbers', milestone: 'M3' },
@@ -29,11 +30,13 @@ export const ADMIN_PAGES = [
{ path: 'sms', label: 'SMS', milestone: 'M9' },
{ path: 'blocked', label: 'Blocked numbers', milestone: 'M6' },
{ path: 'settings', label: 'Settings', milestone: 'M3' },
{ path: 'system', label: 'System', milestone: 'M2' },
{ path: 'system', label: 'System', milestone: 'M11' },
] as const;
export function AdminLayout({ user }: { user: AuthUser }) {
const [navOpen, setNavOpen] = useState(false);
const liveStatus = useLive((s) => s.status);
useEffect(subscribeLive, []);
const qc = useQueryClient();
const nav = useNavigate();
@@ -118,6 +121,14 @@ export function AdminLayout({ user }: { user: AuthUser }) {
</div>
</div>
)}
{liveStatus === 'reconnecting' && (
<p
role="status"
className="bg-amber-100 px-4 py-1 text-center text-xs text-amber-900 dark:bg-amber-950 dark:text-amber-200"
>
Reconnecting…
</p>
)}
<main className="flex-1 p-4 md:p-6">
<Outlet />
</main>
@@ -0,0 +1,70 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Dashboard (05 §2). M2: telephony link and extensions online. Trunks, calls, queues and the
// warnings banner arrive with their features (M3, M7, M8).
import type { ReactNode } from 'react';
import { StatusDot, statusLabel } from '../../components/StatusDot.tsx';
import { useLive } from '../../live/store.ts';
function Card({ title, children }: { title: string; children: ReactNode }) {
return (
<div className="rounded-lg border border-zinc-200 p-4 dark:border-zinc-800">
<h2 className="text-sm text-zinc-500">{title}</h2>
<div className="mt-1 text-2xl font-semibold">{children}</div>
</div>
);
}
export function DashboardPage() {
const { status, amiConnected, extensions } = useLive();
const list = Object.values(extensions).sort((a, b) => Number(a.number) - Number(b.number));
const online = list.filter((e) => e.online).length;
const onCall = list.filter((e) =>
['inuse', 'busy', 'onhold', 'ringinuse'].includes(e.state),
).length;
return (
<section className="flex flex-col gap-6">
<h1 className="text-xl font-semibold">Dashboard</h1>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<Card title="Extensions online">
{status === 'connecting' ? '…' : `${online} / ${list.length}`}
</Card>
<Card title="On a call">{status === 'connecting' ? '…' : onCall}</Card>
<Card title="Telephony engine">
<span
className={
amiConnected ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'
}
>
{status === 'connecting' ? '…' : amiConnected ? 'Connected' : 'Not connected'}
</span>
</Card>
</div>
<div>
<h2 className="mb-2 font-medium">Extensions</h2>
{list.length === 0 ? (
<p className="text-sm text-zinc-500">
No extensions yet. Give a user a number under Users & Extensions.
</p>
) : (
<ul className="divide-y divide-zinc-100 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800">
{list.map((e) => (
<li key={e.number} className="flex items-center gap-3 px-3 py-2 text-sm">
<StatusDot ext={e} />
<span className="w-14 font-mono">{e.number}</span>
<span className="flex-1 truncate">{e.name}</span>
<span className="text-zinc-500">{statusLabel(e)}</span>
{e.contacts && (
<span className="hidden w-24 text-right text-zinc-500 sm:inline">
{e.contacts.length} device{e.contacts.length === 1 ? '' : 's'}
</span>
)}
</li>
))}
</ul>
)}
</div>
</section>
);
}
+14 -6
View File
@@ -7,6 +7,8 @@ import { usersApi } from '../../api/client.ts';
import { ConfirmDialog, Dialog } from '../../components/Dialog.tsx';
import { Alert, Button } from '../../components/ui.tsx';
import { UserDrawer } from './UserDrawer.tsx';
import { StatusDot } from '../../components/StatusDot.tsx';
import { useLive } from '../../live/store.ts';
const fmtDate = (iso: string | null) => (iso ? new Date(iso).toLocaleString() : 'Never');
@@ -126,7 +128,17 @@ function UsersTable({
onEdit: (u: UserDto) => void;
onDelete: (u: UserDto) => void;
}) {
const live = useLive((s) => s.extensions);
if (users.length === 0) return <p className="text-sm text-zinc-500">No users yet.</p>;
const ext = (u: UserDto) =>
u.extension ? (
<span className="inline-flex items-center gap-1.5 font-mono">
<StatusDot ext={live[u.extension.number]} />
{u.extension.number}
</span>
) : (
'–'
);
const badge = (u: UserDto) => (
<span className="flex flex-wrap gap-1">
{u.role === 'admin' && (
@@ -159,11 +171,7 @@ function UsersTable({
<div className="min-w-0">
<p className="font-medium">
{u.displayName}
{u.extension && (
<span className="ml-2 font-mono text-sm text-zinc-500">
{u.extension.number}
</span>
)}
{u.extension && <span className="ml-2 text-sm text-zinc-500">{ext(u)}</span>}
</p>
<p className="truncate text-sm text-zinc-500">{u.email}</p>
</div>
@@ -192,7 +200,7 @@ function UsersTable({
<tbody>
{users.map((u) => (
<tr key={u.id} className="border-b border-zinc-100 dark:border-zinc-800/60">
<td className="py-2 font-mono">{u.extension?.number ?? '–'}</td>
<td className="py-2">{ext(u)}</td>
<td className="py-2 font-medium">{u.displayName}</td>
<td className="py-2">{u.email}</td>
<td className="py-2">{badge(u)}</td>