live: AMI-fed live state, /api/live WebSocket, dashboard and status dots
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:
1 parent
1cfc13ead3
commit
c0c4036887
31 files changed
+4810
-28
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
@@ -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' });
|
||||
};
|
||||
}
|
||||
@@ -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 ?? ''} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user