trunks: CRUD API, PJSIP trunk config, live registration status, Trunks page
voip.ms-first trunk form with the portal checklist, POP server list and Test button; passwords are write-only and encrypted. Registration and IP-auth trunks render per 03 §5.3 (TCP/TLS add ;transport=). The API refuses a server name that doesn't resolve and transports that are off, because either makes Asterisk drop the trunk. Live state: registration from PJSIPShowRegistrationsOutbound (re-read on every Registry event: two sub-accounts on one server share Username/Domain), reachability from PJSIPShowEndpoint and trunk ContactStatus (static contacts aren't in PJSIPShowContacts). Trunk state is admin-only. Dashboard gets a "Trunks up" card. trunk-test.ts (in pnpm test:asterisk) runs a fake voip.ms (second Asterisk): the good trunk registers, a wrong password is Rejected, qualify reaches the provider; its recorded AMI drives the unit tests. scripts/e2e/m3-t1.mjs does the same through the UI. The acceptance check with a real voip.ms sub-account is still to do. Tests: settle() now awaits an apply instead of a fixed sleep (flaked under full-suite load). Task: M3-T1
This commit is contained in:
1 parent
c0c4036887
commit
1b7f60b5f2
48 files changed
+2749
-33
No files matched your search
@@ -12,7 +12,7 @@ import { subscribeLive, useLive } from '../../live/store.ts';
|
||||
export const ADMIN_PAGES = [
|
||||
{ path: '', label: 'Dashboard', milestone: null },
|
||||
{ path: 'users', label: 'Users & Extensions', milestone: null },
|
||||
{ path: 'trunks', label: 'Trunks', milestone: 'M3' },
|
||||
{ path: 'trunks', label: 'Trunks', milestone: null },
|
||||
{ path: 'numbers', label: 'Phone numbers', milestone: 'M3' },
|
||||
{ path: 'outbound', label: 'Outbound rules', milestone: 'M3' },
|
||||
{ path: 'ring-groups', label: 'Ring groups', milestone: 'M6' },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// 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).
|
||||
// Dashboard (05 §2). Telephony link, trunks and extensions online. Calls, queues and the
|
||||
// warnings banner arrive with their features (M3-T4, M7, M8).
|
||||
import type { ReactNode } from 'react';
|
||||
import { StatusDot, statusLabel } from '../../components/StatusDot.tsx';
|
||||
import { useLive } from '../../live/store.ts';
|
||||
@@ -15,7 +15,9 @@ function Card({ title, children }: { title: string; children: ReactNode }) {
|
||||
}
|
||||
|
||||
export function DashboardPage() {
|
||||
const { status, amiConnected, extensions } = useLive();
|
||||
const { status, amiConnected, extensions, trunks } = useLive();
|
||||
const trunkList = Object.values(trunks);
|
||||
const trunksUp = trunkList.filter((t) => t.registration === 'registered' || t.reachable).length;
|
||||
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) =>
|
||||
@@ -25,10 +27,17 @@ export function DashboardPage() {
|
||||
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">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Card title="Extensions online">
|
||||
{status === 'connecting' ? '…' : `${online} / ${list.length}`}
|
||||
</Card>
|
||||
<Card title="Trunks up">
|
||||
{status === 'connecting'
|
||||
? '…'
|
||||
: trunkList.length
|
||||
? `${trunksUp} / ${trunkList.length}`
|
||||
: 'None'}
|
||||
</Card>
|
||||
<Card title="On a call">{status === 'connecting' ? '…' : onCall}</Card>
|
||||
<Card title="Telephony engine">
|
||||
<span
|
||||
|
||||
@@ -0,0 +1,512 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Trunks (05 §2, 06 §1–§2): list with live status, voip.ms-first form with the portal checklist.
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import { useForm, useWatch } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
CODECS,
|
||||
Hostname,
|
||||
VOIPMS_SERVERS,
|
||||
type TrunkDto,
|
||||
type TrunkLive,
|
||||
type TrunkTestResult,
|
||||
} from '@selfphoneng/shared';
|
||||
import { trunksApi } from '../../api/client.ts';
|
||||
import { ConfirmDialog, Dialog } from '../../components/Dialog.tsx';
|
||||
import { Alert, Button, Field, inputClass } from '../../components/ui.tsx';
|
||||
import { useLive } from '../../live/store.ts';
|
||||
|
||||
export function trunkStatus(t: Pick<TrunkDto, 'enabled' | 'authMode'>, live?: TrunkLive) {
|
||||
if (!t.enabled)
|
||||
return { label: 'Off', color: 'bg-zinc-200 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300' };
|
||||
const ok = 'bg-green-100 text-green-800 dark:bg-green-950 dark:text-green-200';
|
||||
const bad = 'bg-red-100 text-red-800 dark:bg-red-950 dark:text-red-200';
|
||||
const wait = 'bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-200';
|
||||
if (!live) return { label: 'Unknown', color: wait };
|
||||
if (t.authMode === 'ip')
|
||||
return live.reachable
|
||||
? { label: 'Reachable', color: ok }
|
||||
: { label: 'Unreachable', color: bad };
|
||||
if (live.registration === 'registered') return { label: 'Registered', color: ok };
|
||||
if (live.registration === 'rejected') return { label: 'Rejected', color: bad };
|
||||
return { label: 'Not registered', color: wait };
|
||||
}
|
||||
|
||||
function StatusPill({ t }: { t: TrunkDto }) {
|
||||
const live = useLive((s) => s.trunks[t.id]);
|
||||
const s = trunkStatus(t, live);
|
||||
return (
|
||||
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${s.color}`}>{s.label}</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function TrunksPage() {
|
||||
const qc = useQueryClient();
|
||||
const trunks = useQuery({ queryKey: ['trunks'], queryFn: trunksApi.list });
|
||||
const [editing, setEditing] = useState<TrunkDto | 'new' | null>(null);
|
||||
const [deleting, setDeleting] = useState<TrunkDto | null>(null);
|
||||
const refresh = () => qc.invalidateQueries({ queryKey: ['trunks'] });
|
||||
const remove = useMutation({
|
||||
mutationFn: (id: string) => trunksApi.remove(id),
|
||||
onSuccess: () => {
|
||||
setDeleting(null);
|
||||
void refresh();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<section className="flex flex-col gap-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h1 className="text-xl font-semibold">Trunks</h1>
|
||||
<Button variant="primary" onClick={() => setEditing('new')}>
|
||||
New trunk
|
||||
</Button>
|
||||
</div>
|
||||
<p className="max-w-prose text-sm text-zinc-500">
|
||||
A trunk connects the phone system to your phone company (voip.ms or any SIP provider), so
|
||||
calls can go to and come from the outside world.
|
||||
</p>
|
||||
{trunks.isError && <Alert>{trunks.error.message}</Alert>}
|
||||
{trunks.isPending && <p className="text-sm text-zinc-500">Loading…</p>}
|
||||
{trunks.data?.length === 0 && (
|
||||
<p className="text-sm text-zinc-500">No trunks yet. Add your voip.ms sub-account.</p>
|
||||
)}
|
||||
{trunks.data && trunks.data.length > 0 && (
|
||||
<ul className="divide-y divide-zinc-100 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800">
|
||||
{trunks.data.map((t) => (
|
||||
<li key={t.id} className="flex flex-wrap items-center gap-3 px-3 py-2 text-sm">
|
||||
<StatusPill t={t} />
|
||||
<span className="font-medium">{t.name}</span>
|
||||
<span className="flex-1 truncate text-zinc-500">
|
||||
{t.username ? `${t.username}@` : ''}
|
||||
{t.serverHost}
|
||||
</span>
|
||||
<Button onClick={() => setEditing(t)}>Edit</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
remove.reset();
|
||||
setDeleting(t);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{editing && (
|
||||
<TrunkDrawer
|
||||
trunk={editing === 'new' ? null : editing}
|
||||
onClose={() => setEditing(null)}
|
||||
onSaved={(t) => {
|
||||
void refresh();
|
||||
setEditing(t); // stay open so the admin can press Test
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<ConfirmDialog
|
||||
open={deleting !== null}
|
||||
title="Delete trunk"
|
||||
message={
|
||||
<p>
|
||||
Delete <strong>{deleting?.name}</strong>? Calls can no longer use it.
|
||||
</p>
|
||||
}
|
||||
confirmLabel="Delete"
|
||||
busy={remove.isPending}
|
||||
error={remove.error?.message}
|
||||
onCancel={() => setDeleting(null)}
|
||||
onConfirm={() => deleting && remove.mutate(deleting.id)}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const FormSchema = z.object({
|
||||
name: z.string().trim().min(1, 'Required').max(40),
|
||||
provider: z.enum(['voipms', 'generic']),
|
||||
enabled: z.boolean(),
|
||||
serverHost: Hostname,
|
||||
serverPort: z.number({ error: 'A port number' }).int().min(1).max(65535),
|
||||
transport: z.enum(['udp', 'tcp', 'tls']),
|
||||
authMode: z.enum(['registration', 'ip']),
|
||||
username: z.string().trim().max(64),
|
||||
password: z.string().max(128),
|
||||
fromUser: z.string().trim().max(64),
|
||||
fromDomain: z.union([z.literal(''), Hostname]),
|
||||
codecs: z.array(z.enum(CODECS)).min(1, 'Pick at least one codec'),
|
||||
srtp: z.boolean(),
|
||||
maxChannels: z.string().regex(/^(\d{1,4})?$/, 'A number, or empty for no limit'),
|
||||
voipmsApiUsername: z.string().trim().max(254),
|
||||
voipmsApiPassword: z.string().max(128),
|
||||
});
|
||||
type FormValues = z.infer<typeof FormSchema>;
|
||||
|
||||
function TrunkDrawer({
|
||||
trunk,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
trunk: TrunkDto | null;
|
||||
onClose: () => void;
|
||||
onSaved: (t: TrunkDto) => void;
|
||||
}) {
|
||||
const [error, setError] = useState<string>();
|
||||
const [saved, setSaved] = useState(false);
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(FormSchema),
|
||||
defaultValues: {
|
||||
name: trunk?.name ?? 'voip.ms',
|
||||
provider: trunk?.provider ?? 'voipms',
|
||||
enabled: trunk?.enabled ?? true,
|
||||
serverHost: trunk?.serverHost ?? '',
|
||||
serverPort: trunk?.serverPort ?? 5060,
|
||||
transport: trunk?.transport ?? 'udp',
|
||||
authMode: trunk?.authMode ?? 'registration',
|
||||
username: trunk?.username ?? '',
|
||||
password: '',
|
||||
fromUser: trunk?.fromUser ?? '',
|
||||
fromDomain: trunk?.fromDomain ?? '',
|
||||
codecs: trunk?.codecs ?? ['ulaw'],
|
||||
srtp: trunk?.srtp ?? false,
|
||||
maxChannels: trunk?.maxChannels ? String(trunk.maxChannels) : '',
|
||||
voipmsApiUsername: trunk?.voipmsApiUsername ?? '',
|
||||
voipmsApiPassword: '',
|
||||
},
|
||||
});
|
||||
const { errors } = form.formState;
|
||||
const [provider, authMode, transport] = useWatch({
|
||||
control: form.control,
|
||||
name: ['provider', 'authMode', 'transport'],
|
||||
});
|
||||
const voipms = provider === 'voipms';
|
||||
|
||||
function setTransport(t: FormValues['transport']) {
|
||||
form.setValue('transport', t);
|
||||
form.setValue('serverPort', t === 'tls' ? 5061 : 5060);
|
||||
if (t !== 'tls') form.setValue('srtp', false);
|
||||
}
|
||||
|
||||
const onSubmit = form.handleSubmit(async (v) => {
|
||||
setError(undefined);
|
||||
const body = {
|
||||
...v,
|
||||
username: v.username || null,
|
||||
fromUser: v.fromUser || null,
|
||||
fromDomain: v.fromDomain || null,
|
||||
maxChannels: v.maxChannels ? Number(v.maxChannels) : null,
|
||||
voipmsApiUsername: v.voipmsApiUsername || null,
|
||||
// Empty password fields mean "keep the stored one".
|
||||
password: v.password || undefined,
|
||||
voipmsApiPassword: v.voipmsApiPassword || undefined,
|
||||
};
|
||||
try {
|
||||
const t = trunk ? await trunksApi.update(trunk.id, body) : await trunksApi.create(body);
|
||||
form.reset({ ...v, password: '', voipmsApiPassword: '' });
|
||||
setSaved(true);
|
||||
onSaved(t);
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
drawer
|
||||
onClose={onClose}
|
||||
title={trunk ? `Edit ${trunk.name}` : 'New trunk'}
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={onClose}>{saved ? 'Close' : 'Cancel'}</Button>
|
||||
<Button variant="primary" type="submit" form="trunk-form">
|
||||
{trunk ? 'Save' : 'Create trunk'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form id="trunk-form" onSubmit={onSubmit} className="flex flex-col gap-4" noValidate>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
{saved && (
|
||||
<p role="status" className="text-sm text-green-700 dark:text-green-400">
|
||||
Saved. The phone system picks it up within a few seconds.
|
||||
</p>
|
||||
)}
|
||||
{trunk && <TestPanel trunk={trunk} />}
|
||||
<Field label="Provider">
|
||||
{(a) => (
|
||||
<select {...a} {...form.register('provider')} className={inputClass}>
|
||||
<option value="voipms">voip.ms</option>
|
||||
<option value="generic">Other SIP provider</option>
|
||||
</select>
|
||||
)}
|
||||
</Field>
|
||||
{voipms && <VoipmsChecklist />}
|
||||
<Field label="Name" error={errors.name?.message}>
|
||||
{(a) => <input {...a} {...form.register('name')} className={inputClass} />}
|
||||
</Field>
|
||||
<Field
|
||||
label="Server"
|
||||
error={errors.serverHost?.message}
|
||||
hint={voipms ? 'The voip.ms POP your sub-account and numbers use.' : undefined}
|
||||
>
|
||||
{(a) => (
|
||||
<>
|
||||
<input
|
||||
{...a}
|
||||
{...form.register('serverHost')}
|
||||
list={voipms ? 'voipms-servers' : undefined}
|
||||
placeholder={voipms ? 'chicago1.voip.ms' : 'sip.example.com'}
|
||||
autoComplete="off"
|
||||
className={inputClass}
|
||||
/>
|
||||
<datalist id="voipms-servers">
|
||||
{VOIPMS_SERVERS.map((s) => (
|
||||
<option key={s} value={s} />
|
||||
))}
|
||||
</datalist>
|
||||
</>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Sign-in">
|
||||
{(a) => (
|
||||
<select {...a} {...form.register('authMode')} className={inputClass}>
|
||||
<option value="registration">Username and password (registration)</option>
|
||||
<option value="ip">IP address (the provider knows this server's IP)</option>
|
||||
</select>
|
||||
)}
|
||||
</Field>
|
||||
{authMode === 'registration' && (
|
||||
<>
|
||||
<Field
|
||||
label={voipms ? 'Sub-account username' : 'Username'}
|
||||
error={errors.username?.message}
|
||||
hint={voipms ? 'Like 123456_pbx.' : undefined}
|
||||
>
|
||||
{(a) => (
|
||||
<input
|
||||
{...a}
|
||||
{...form.register('username')}
|
||||
autoComplete="off"
|
||||
className={inputClass}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field
|
||||
label="Password"
|
||||
error={errors.password?.message}
|
||||
hint={trunk?.hasPassword ? 'Leave empty to keep the current password.' : undefined}
|
||||
>
|
||||
{(a) => (
|
||||
<input
|
||||
{...a}
|
||||
{...form.register('password')}
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
className={inputClass}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="Transport">
|
||||
{(a) => (
|
||||
<select
|
||||
{...a}
|
||||
value={transport}
|
||||
onChange={(e) => setTransport(e.target.value as FormValues['transport'])}
|
||||
className={inputClass}
|
||||
>
|
||||
<option value="udp">UDP</option>
|
||||
<option value="tcp">TCP</option>
|
||||
<option value="tls">TLS (encrypted)</option>
|
||||
</select>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Port" error={errors.serverPort?.message}>
|
||||
{(a) => (
|
||||
<input
|
||||
{...a}
|
||||
{...form.register('serverPort', { valueAsNumber: true })}
|
||||
type="number"
|
||||
className={inputClass}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
<fieldset className="flex flex-col gap-1">
|
||||
<legend className="text-sm font-medium">Codecs</legend>
|
||||
<div className="flex gap-4">
|
||||
{CODECS.map((c) => (
|
||||
<label key={c} className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" value={c} {...form.register('codecs')} className="size-4" />
|
||||
{c === 'ulaw' ? 'G.711 µ-law' : c === 'alaw' ? 'G.711 A-law' : 'G.722 (HD)'}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{errors.codecs && (
|
||||
<p className="text-xs text-red-600 dark:text-red-400">{errors.codecs.message}</p>
|
||||
)}
|
||||
</fieldset>
|
||||
<Field
|
||||
label="Max simultaneous calls"
|
||||
error={errors.maxChannels?.message}
|
||||
hint="Empty for no limit."
|
||||
>
|
||||
{(a) => (
|
||||
<input
|
||||
{...a}
|
||||
{...form.register('maxChannels')}
|
||||
inputMode="numeric"
|
||||
className={inputClass}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" {...form.register('enabled')} className="size-4" />
|
||||
Enabled
|
||||
</label>
|
||||
|
||||
<details className="rounded-md border border-zinc-200 p-3 text-sm dark:border-zinc-800">
|
||||
<summary className="cursor-pointer font-medium">Advanced</summary>
|
||||
<div className="mt-3 flex flex-col gap-4">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
{...form.register('srtp')}
|
||||
disabled={transport !== 'tls'}
|
||||
className="size-4"
|
||||
/>
|
||||
Encrypt audio (SRTP, needs TLS)
|
||||
</label>
|
||||
<Field
|
||||
label="From user"
|
||||
error={errors.fromUser?.message}
|
||||
hint="Defaults to the username."
|
||||
>
|
||||
{(a) => <input {...a} {...form.register('fromUser')} className={inputClass} />}
|
||||
</Field>
|
||||
<Field
|
||||
label="From domain"
|
||||
error={errors.fromDomain?.message}
|
||||
hint="Defaults to the server."
|
||||
>
|
||||
{(a) => <input {...a} {...form.register('fromDomain')} className={inputClass} />}
|
||||
</Field>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
{voipms && (
|
||||
<details className="rounded-md border border-zinc-200 p-3 text-sm dark:border-zinc-800">
|
||||
<summary className="cursor-pointer font-medium">voip.ms API (optional)</summary>
|
||||
<div className="mt-3 flex flex-col gap-4">
|
||||
<p className="text-zinc-500">
|
||||
Needed for SMS and importing your numbers. In the voip.ms portal: Main Menu → SOAP
|
||||
and REST/JSON API. Enable it, set an API password, and allow this server's public IP
|
||||
address.
|
||||
</p>
|
||||
<Field label="API username (your voip.ms email)">
|
||||
{(a) => (
|
||||
<input
|
||||
{...a}
|
||||
{...form.register('voipmsApiUsername')}
|
||||
type="email"
|
||||
autoComplete="off"
|
||||
className={inputClass}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field
|
||||
label="API password"
|
||||
hint={
|
||||
trunk?.hasApiPassword ? 'Leave empty to keep the current password.' : undefined
|
||||
}
|
||||
>
|
||||
{(a) => (
|
||||
<input
|
||||
{...a}
|
||||
{...form.register('voipmsApiPassword')}
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
className={inputClass}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</form>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function TestPanel({ trunk }: { trunk: TrunkDto }) {
|
||||
const [result, setResult] = useState<TrunkTestResult | { error: string } | null>(null);
|
||||
const test = useMutation({
|
||||
mutationFn: () => trunksApi.test(trunk.id),
|
||||
onSuccess: setResult,
|
||||
onError: (e) => setResult({ error: e.message }),
|
||||
});
|
||||
const live = useLive((s) => s.trunks[trunk.id]);
|
||||
const status = trunkStatus(trunk, live);
|
||||
return (
|
||||
<div className="flex flex-col gap-2 rounded-md bg-zinc-50 p-3 text-sm dark:bg-zinc-900">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${status.color}`}>
|
||||
{status.label}
|
||||
</span>
|
||||
<span className="flex-1" />
|
||||
<Button onClick={() => test.mutate()} disabled={test.isPending || !trunk.enabled}>
|
||||
{test.isPending ? 'Testing…' : 'Test'}
|
||||
</Button>
|
||||
</div>
|
||||
{result && (
|
||||
<p role="status">
|
||||
{'error' in result
|
||||
? result.error
|
||||
: `${result.detail}${result.rttMs !== null ? ` (${result.rttMs} ms)` : ''}`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** What the customer sets in the voip.ms portal (06 §1). */
|
||||
function VoipmsChecklist() {
|
||||
return (
|
||||
<details className="rounded-md border border-sky-200 bg-sky-50 p-3 text-sm dark:border-sky-900 dark:bg-sky-950/40">
|
||||
<summary className="cursor-pointer font-medium">Set up voip.ms first (checklist)</summary>
|
||||
<ol className="mt-2 flex list-decimal flex-col gap-1.5 pl-5">
|
||||
<li>
|
||||
<strong>Create a sub-account</strong> (Main Menu → Sub Accounts → Create), one per phone
|
||||
system, e.g. <code>123456_pbx</code>: authentication <em>User/Password</em>, a strong
|
||||
random password, device type <em>Asterisk, IP PBX, Gateway or VoIP Switch</em>, codec{' '}
|
||||
<em>G.711u</em>, DTMF <em>RFC 2833</em>, NAT <em>Yes</em>, CallerID number = your main
|
||||
number, international calling <em>off</em> unless you need it.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Pick a POP server</strong> close to you (e.g. chicago1.voip.ms). Enter the same
|
||||
one below.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Point each number at the sub-account</strong> (DID Numbers → Manage DIDs → edit):
|
||||
routing <em>SIP/IAX → the sub-account</em>, and the <em>same POP server</em>. A different
|
||||
POP is a common reason incoming calls fail.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Register an E911 address</strong> for every number used as an emergency caller ID
|
||||
(DID → e911).
|
||||
</li>
|
||||
<li>
|
||||
<strong>Set balance alerts</strong> and per-call or daily limits in your voip.ms account
|
||||
settings.
|
||||
</li>
|
||||
</ol>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user