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
@@ -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