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

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