admin: System page with restart steps, Record calls on the Calling tab
ci / check (push) Successful in 55s
ci / images (push) Successful in 23s

System shows whether telephony runs and needs a restart, and how to restart it
(docker compose restart asterisk); the dashboard's restart warning, the Network hint and
the public IP email point there. The user drawer's Calling tab gets the Record calls
select the spec listed (recordMode, already in the dialplan). e2e m11-system.mjs.

Task: M11-T7
This commit is contained in:
iamdoubz committed 2026-10-20 09:58:11 -05:00
1 parent 324f9d9fe2
commit a1525088dc
13 files changed
+322 -30

No files matched your search

+1 -1
View File
@@ -154,7 +154,7 @@ export function createJobs(d: JobDeps) {
d, d,
`Public IP changed to ${ip}`, `Public IP changed to ${ip}`,
`The phone system's public IP changed from ${old ?? 'none'} to ${ip}.\n` + `The phone system's public IP changed from ${old ?? 'none'} to ${ip}.\n` +
'Restart telephony from the dashboard so calls use the new address (it drops calls in progress).\n' + 'Restart telephony so calls use the new address: Admin → System says how (it drops calls in progress).\n' +
'Phones and the provider may need the new IP if they were set up with the old one.', 'Phones and the provider may need the new IP if they were set up with the old one.',
); );
}; };
+3
View File
@@ -27,6 +27,7 @@ import { BlockedPage } from './pages/admin/BlockedPage.tsx';
import { CallsPage } from './pages/admin/CallsPage.tsx'; import { CallsPage } from './pages/admin/CallsPage.tsx';
import { RecordingsPage } from './pages/admin/RecordingsPage.tsx'; import { RecordingsPage } from './pages/admin/RecordingsPage.tsx';
import { ReportsPage } from './pages/admin/ReportsPage.tsx'; import { ReportsPage } from './pages/admin/ReportsPage.tsx';
import { SystemPage } from './pages/admin/SystemPage.tsx';
import { MePage } from './pages/user/MePage.tsx'; import { MePage } from './pages/user/MePage.tsx';
import { SetupPage } from './pages/SetupPage.tsx'; import { SetupPage } from './pages/SetupPage.tsx';
import './index.css'; import './index.css';
@@ -78,6 +79,8 @@ const router = createBrowserRouter([
<RecordingsPage /> <RecordingsPage />
) : p.path === 'reports' ? ( ) : p.path === 'reports' ? (
<ReportsPage /> <ReportsPage />
) : p.path === 'system' ? (
<SystemPage />
) : ( ) : (
<ComingSoon label={p.label} milestone={p.milestone ?? ''} /> <ComingSoon label={p.label} milestone={p.milestone ?? ''} />
), ),
+1 -1
View File
@@ -30,7 +30,7 @@ export const ADMIN_PAGES = [
{ path: 'sms', label: 'SMS', milestone: 'M9' }, { path: 'sms', label: 'SMS', milestone: 'M9' },
{ path: 'blocked', label: 'Blocked numbers', milestone: null }, { path: 'blocked', label: 'Blocked numbers', milestone: null },
{ path: 'settings', label: 'Settings', milestone: null }, { path: 'settings', label: 'Settings', milestone: null },
{ path: 'system', label: 'System', milestone: 'M11' }, { path: 'system', label: 'System', milestone: null },
] as const; ] as const;
export function AdminLayout({ user }: { user: AuthUser }) { export function AdminLayout({ user }: { user: AuthUser }) {
+1 -4
View File
@@ -426,10 +426,7 @@ function NetworkTab({ s }: { s: SettingsDto }) {
save.mutate({ 'network.publicIp': ip.trim() || null, 'network.publicIpWatch': watch }); save.mutate({ 'network.publicIp': ip.trim() || null, 'network.publicIpWatch': watch });
}} }}
> >
<Field <Field label="Public IP" hint="Changing it needs a telephony restart (see System)">
label="Public IP"
hint="Changing it needs a telephony restart (the dashboard offers one)"
>
{(p) => ( {(p) => (
<div className="flex gap-2"> <div className="flex gap-2">
<input <input
+70
View File
@@ -0,0 +1,70 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// System (05 §2): telephony status and how to restart it.
// ponytail: the spec's generator runs, generated files, CLI views and audit log aren't here yet;
// the admin guide's troubleshooting page covers them from the server's shell.
import { useQuery } from '@tanstack/react-query';
import { systemApi } from '../../api/client.ts';
import { Alert } from '../../components/ui.tsx';
const RESTART = 'docker compose restart asterisk';
export function SystemPage() {
const status = useQuery({
queryKey: ['system-status'],
queryFn: systemApi.status,
refetchInterval: 10_000,
});
const s = status.data;
return (
<section className="flex max-w-2xl flex-col gap-6">
<h1 className="text-xl font-semibold">System</h1>
{status.error && <Alert>{status.error.message}</Alert>}
{s && (
<dl aria-label="Status" className="grid grid-cols-2 gap-3 text-sm">
<div className="rounded-lg border border-zinc-200 p-3 dark:border-zinc-800">
<dt className="text-zinc-500">Telephony (Asterisk)</dt>
<dd className="font-semibold">{s.ami.connected ? 'Running' : 'Not reachable'}</dd>
</div>
<div className="rounded-lg border border-zinc-200 p-3 dark:border-zinc-800">
<dt className="text-zinc-500">Restart</dt>
<dd className="font-semibold">{s.restartRequired ? 'Needed' : 'Not needed'}</dd>
</div>
</dl>
)}
{s?.restartRequired && (
<p
role="status"
className="rounded-md bg-amber-50 p-3 text-sm text-amber-900 dark:bg-amber-950 dark:text-amber-100"
>
The public IP, SIP ports or TLS changed. Telephony keeps using the old ones until it
restarts.
</p>
)}
<div className="flex flex-col gap-2 text-sm">
<h2 className="text-base font-medium">Restarting telephony</h2>
<p>
On the server, in the folder SelfPhoneNG is installed in (where <code>.env</code> is),
run:
</p>
<pre
aria-label="Restart command"
className="overflow-x-auto rounded-md bg-zinc-100 p-3 font-mono dark:bg-zinc-900"
>
{RESTART}
</pre>
<ul className="list-disc pl-5 text-zinc-600 dark:text-zinc-400">
<li>
<strong>Calls in progress drop</strong>, so do it when nobody is on the phone.
</li>
<li>
Phones and the trunk sign in again by themselves within a minute. This page shows
&ldquo;Running&rdquo; and &ldquo;Not needed&rdquo; once it&apos;s back.
</li>
<li>The web app, voicemail and call history aren&apos;t affected.</li>
</ul>
</div>
</section>
);
}
+15
View File
@@ -458,6 +458,7 @@ function CallingTab({ ext, onSaved }: { ext: ExtensionDto; onSaved: () => void }
defaultValues: { defaultValues: {
outboundClass: ext.outboundClass, outboundClass: ext.outboundClass,
outboundCallerIdDidId: ext.outboundCallerIdDidId ?? '', outboundCallerIdDidId: ext.outboundCallerIdDidId ?? '',
recordMode: ext.recordMode,
}, },
}); });
const onSubmit = form.handleSubmit(async (v) => { const onSubmit = form.handleSubmit(async (v) => {
@@ -466,6 +467,7 @@ function CallingTab({ ext, onSaved }: { ext: ExtensionDto; onSaved: () => void }
await extensionsApi.update(ext.id, { await extensionsApi.update(ext.id, {
outboundClass: v.outboundClass, outboundClass: v.outboundClass,
outboundCallerIdDidId: v.outboundCallerIdDidId || null, outboundCallerIdDidId: v.outboundCallerIdDidId || null,
recordMode: v.recordMode,
}); });
await qc.invalidateQueries({ queryKey: ['extension', ext.id] }); await qc.invalidateQueries({ queryKey: ['extension', ext.id] });
onSaved(); onSaved();
@@ -506,6 +508,19 @@ function CallingTab({ ext, onSaved }: { ext: ExtensionDto; onSaved: () => void }
</select> </select>
)} )}
</Field> </Field>
<Field
label="Record calls"
hint="Calls between this person and outside numbers; calls inside the company are never recorded. Check the consent rules where you and your callers are (Settings → Recording)."
>
{(a) => (
<select {...a} {...form.register('recordMode')} className={inputClass}>
<option value="never">Never</option>
<option value="inbound">Calls from outside</option>
<option value="outbound">Calls to outside</option>
<option value="all">All outside calls</option>
</select>
)}
</Field>
</form> </form>
); );
} }
+8 -4
View File
@@ -73,14 +73,18 @@ Recording calls without the right consent can be a crime. In the US:
- **Nothing is recorded unless you turn it on.** You can record: - **Nothing is recorded unless you turn it on.** You can record:
- every call to a number (**Phone numbers → Record every call**); - every call to a number (**Phone numbers → Record every call**);
- a queue's calls (the queue's **Record calls** switch). - a queue's calls (the queue's **Record calls** switch);
- one person's calls with outside numbers (Users & Extensions → the user → **Calling** →
**Record calls**: calls from outside, calls to outside, or both).
The setup wizard never turns recording on. The setup wizard never turns recording on.
- **Settings → Recording → "Notice played before a recorded call"** plays a sound you upload - **Settings → Recording → "Notice played before a recorded call"** plays a sound you upload
(e.g. "This call may be recorded for quality and training") to everyone who calls a number set (e.g. "This call may be recorded for quality and training") to everyone who calls a number set
to record, before anything else. Upload it on **Sounds & music**. to record, before anything else. Upload it on **Sounds & music**.
- **Queue recordings play no notice of their own.** If you record a queue, send its callers - **Queue and per-person recordings play no notice of their own.** Only numbers set to record
through a number that plays the notice, or through an announcement that says it play it. If you record a queue or a person, make sure the callers still hear it: send them through
(**Sounds & music → Announcements**: play a sound, then go to the queue). a number that plays the notice, or through an announcement that says it (**Sounds & music →
Announcements**: play a sound, then go on). For calls a person makes to the outside, tell them to
say the call is recorded.
- **Retention.** Recordings and voicemail are deleted after the number of days you set there. - **Retention.** Recordings and voicemail are deleted after the number of days you set there.
0 keeps them. 0 keeps them.
+1 -1
View File
@@ -30,7 +30,7 @@ the wrong place.
("Public IP changed to …"). ("Public IP changed to …").
- **Then restart telephony** so Asterisk uses the new address: on the server, run - **Then restart telephony** so Asterisk uses the new address: on the server, run
`docker compose restart asterisk` (calls in progress drop). The dashboard shows "SIP port or `docker compose restart asterisk` (calls in progress drop). The dashboard shows "SIP port or
transport changes need a telephony restart" until you do. transport changes need a telephony restart" until you do, and **System** shows the command.
![Settings → Network](img/settings-network.png) ![Settings → Network](img/settings-network.png)
+2 -1
View File
@@ -128,7 +128,8 @@ nobody is on a call:
docker compose restart asterisk docker compose restart asterisk
``` ```
The banner goes away a few seconds after Asterisk is back. The admin page **System** shows the same command. It says "Restart: Not needed" and the banner
goes away a few seconds after Asterisk is back.
## Locked out ## Locked out
+2 -2
View File
@@ -28,7 +28,7 @@ Left sidebar navigation, in this order:
| Page | Path | Contents | | Page | Path | Contents |
|---|---|---| |---|---|---|
| Dashboard | `/admin` | Cards: trunks (registered?), extensions online/total, active calls, calls today, missed today, voicemails waiting, disk free. Live call list (from, to, state, duration ticking, recording dot; admin buttons Hang up / Transfer / Listen). Queue tiles (waiting, longest wait, agents available). Warnings banner (restart required, SMTP not set, E911 caller ID not set, trunk down) | | Dashboard | `/admin` | Cards: trunks (registered?), extensions online/total, active calls, calls today, missed today, voicemails waiting, disk free. Live call list (from, to, state, duration ticking, recording dot; admin buttons Hang up / Transfer / Listen). Queue tiles (waiting, longest wait, agents available). Warnings banner (restart required, SMTP not set, E911 caller ID not set, trunk down) |
| Users & Extensions | `/admin/users` | Table: number, name, email, role, online (contacts count + state dot), DND, devices. Create/edit drawer with tabs: **General** (name, email, role, number with "next free" button), **Phone** (ring seconds, webrtc on/off, pickup group, BLF numbers multiselect, language, MOH class), **Voicemail** (enabled, PIN, email options), **Calling** (outbound class, outbound caller ID DID, recording mode), **Emergency** (E911 caller ID DID, location text), **Credentials** (SIP username/server/password reveal, regenerate, softphone QR), **Forwarding** (current DND/CFU/CFB/CFNA, editable) | | Users & Extensions | `/admin/users` | Table: number, name, email, role, online (contacts count + state dot), DND, devices. Create/edit drawer with tabs: **General** (name, email, role, number with "next free" button), **Phone** (ring seconds, webrtc on/off, pickup group, BLF numbers multiselect, language, MOH class), **Voicemail** (enabled, PIN, email options), **Calling** (outbound class, outbound caller ID DID, recording mode), **Emergency** (E911 caller ID DID, location text), **Credentials** (SIP username/server/password reveal, regenerate, softphone QR), **Forwarding** (current DND/CFU/CFB/CFNA, editable). As built (owner follow-up to M11-T7, 2026-10-20): the Calling tab's recording mode was missing until then; it is a **Record calls** select (Never, Calls from outside, Calls to outside, All outside calls = `recordMode` never/inbound/outbound/all). Calls inside the company are never recorded, and no recording notice plays for it (only numbers set to record play one) |
| Trunks | `/admin/trunks` | List with status pill. Form: provider (voip.ms / generic). Choosing voip.ms pre-fills port, transport, codecs and shows a POP server dropdown (06 §2) plus the voip.ms-side checklist. Test button. API credentials (optional) + "Import DIDs" | | Trunks | `/admin/trunks` | List with status pill. Form: provider (voip.ms / generic). Choosing voip.ms pre-fills port, transport, codecs and shows a POP server dropdown (06 §2) plus the voip.ms-side checklist. Test button. API credentials (optional) + "Import DIDs" |
| Phone numbers (DIDs) | `/admin/numbers` | Table: number, label, trunk, destination summary ("→ IVR Main menu"), SMS on/off. Form: number, label, trunk, **DestinationPicker**, caller-name prefix, record inbound, SMS enabled + users allowed | | Phone numbers (DIDs) | `/admin/numbers` | Table: number, label, trunk, destination summary ("→ IVR Main menu"), SMS on/off. Form: number, label, trunk, **DestinationPicker**, caller-name prefix, record inbound, SMS enabled + users allowed |
| Outbound rules | `/admin/outbound` | Sortable list (drag to reorder). Form: name, patterns (rows of match/strip/prepend with a pattern help popover), required class, trunk order (multi-select with order), caller ID override. **Test box**: pick an extension + type a number → shows the matching route, final dialed number, trunk, caller ID, or the reason it's denied | | Outbound rules | `/admin/outbound` | Sortable list (drag to reorder). Form: name, patterns (rows of match/strip/prepend with a pattern help popover), required class, trunk order (multi-select with order), caller ID override. **Test box**: pick an extension + type a number → shows the matching route, final dialed number, trunk, caller ID, or the reason it's denied |
@@ -48,7 +48,7 @@ Left sidebar navigation, in this order:
| SMS | `/admin/sms` | Same UI as the user one, with all DIDs | | SMS | `/admin/sms` | Same UI as the user one, with all DIDs |
| Blocked numbers | `/admin/blocked` | | | Blocked numbers | `/admin/blocked` | |
| Settings | `/admin/settings` | Tabs: General, Network (public IP detect, LAN IP, local nets, STUN/TURN), SIP (ports, TCP/TLS), Numbering ranges, Email (SMTP + test), Voicemail, Recording (retention, announcement sound + **legal notice text, 08 §5**), Emergency (default E911 caller ID, notify emails/extensions + a **test notification** button that does NOT dial 911), Backups (schedule, keep, include recordings, list + download + "Back up now"), Webhooks, API tokens | | Settings | `/admin/settings` | Tabs: General, Network (public IP detect, LAN IP, local nets, STUN/TURN), SIP (ports, TCP/TLS), Numbering ranges, Email (SMTP + test), Voicemail, Recording (retention, announcement sound + **legal notice text, 08 §5**), Emergency (default E911 caller ID, notify emails/extensions + a **test notification** button that does NOT dial 911), Backups (schedule, keep, include recordings, list + download + "Back up now"), Webhooks, API tokens |
| System | `/admin/system` | Status JSON pretty-printed, Asterisk version, generator runs list, "Apply config now", "Rollback", "Restart telephony", generated-files viewer (read-only, masked), allowlisted CLI dropdown (04 §7.3) with output panel, audit log | | System | `/admin/system` | Status JSON pretty-printed, Asterisk version, generator runs list, "Apply config now", "Rollback", "Restart telephony", generated-files viewer (read-only, masked), allowlisted CLI dropdown (04 §7.3) with output panel, audit log. As built (owner follow-up to M11-T7, 2026-10-20): only telephony status (AMI connected, restart needed) and how to restart (`docker compose restart asterisk` on the server; no restart button). The dashboard's `restart_required` warning links here. The rest of this row isn't built; the admin guide's troubleshooting page covers it from the shell |
### 2.1 First-run setup wizard (`/setup`) ### 2.1 First-run setup wizard (`/setup`)
+8
View File
@@ -258,3 +258,11 @@ phone signs in to extension 270 with the wrong password; fail2ban's stock `aster
IP, and CrowdSec's `crowdsecurity/asterisk` collection parses them (`cscli explain`). It then takes IP, and CrowdSec's `crowdsecurity/asterisk` collection parses them (`cscli explain`). It then takes
the guide's screenshots into the given folder (run it with `docs/admin-guide/img`) and checks every the guide's screenshots into the given folder (run it with `docs/admin-guide/img`) and checks every
relative link, image and `#anchor` in the guide. About 4 minutes. relative link, image and `#anchor` in the guide. About 4 minutes.
`m11-system.mjs` (m2-live with sounds and recordings dirs, api with `RECORDINGS_DIR`, after `m1-t4.mjs`)
checks the owner's follow-ups to M11-T7. The user drawer's Calling → "Record calls" set to "Calls
from outside" records a real inbound call to 241 (MixMonitor, a recording in call history); set back
to "Never", the next call isn't recorded. A public IP change on Settings → Network raises the
dashboard's restart warning, whose link opens System ("Restart: Needed", the command); restarting
the Asterisk container brings it to "Running" / "Not needed", the new IP is in the transport and the
warning is gone. About 3 minutes.
+210
View File
@@ -0,0 +1,210 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// The owner's follow-ups to M11-T7 (2026-10-20):
// - Users & Extensions → user → Calling → "Record calls": "Calls from outside" records a real
// inbound call to that person (MixMonitor, a recording in call history); back to "Never", the
// next call isn't recorded.
// - System page: a public IP change in Settings → Network raises the dashboard warning, its link
// opens System ("Restart: Needed" and the command); running what it says (here `docker restart`
// on the m2-live container, which is what `docker compose restart asterisk` does) brings it back
// to "Running" and "Not needed", and the warning goes away.
// Runs on m2-live with a recordings dir (api with RECORDINGS_DIR), after m1-t4.mjs.
// Usage: node scripts/e2e/m11-system.mjs <baseUrl> <adminEmail> <adminPassword> <shotsDir>
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { originate } from './ami.mjs';
import { launch } from './cdp.mjs';
import { startPhones } from './phones.mjs';
const NET = 'sp-live';
const AST = 'sp-live-ast';
const DID = '13125558899';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const cli = (cmd) =>
spawnSync('docker', ['exec', AST, 'asterisk', '-rx', cmd], { encoding: 'utf8' }).stdout ?? '';
// eslint-disable-next-line no-control-regex -- strips Asterisk's log colors
const ANSI = /\x1b\[[0-9;]*m/g;
const astLog = () =>
(spawnSync('docker', ['logs', AST], { encoding: 'utf8' }).stdout ?? '').replace(ANSI, '');
const [base, email, pw, shotsDir] = process.argv.slice(2);
const until = async (what, ms, fn) => {
const end = Date.now() + ms;
for (;;) {
const v = await fn();
if (v) return v;
assert.ok(Date.now() < end, `timed out: ${what}`);
await sleep(500);
}
};
const login = await fetch(`${base}/api/auth/login`, {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-requested-with': 'selfphoneng' },
body: JSON.stringify({ email, password: pw }),
});
const cookie = login.headers
.getSetCookie()
.map((c) => c.split(';')[0])
.join('; ');
const api = async (method, url, body) => {
const r = await fetch(`${base}${url}`, {
method,
headers: { 'content-type': 'application/json', 'x-requested-with': 'selfphoneng', cookie },
body: body && JSON.stringify(body),
});
const text = await r.text();
assert.ok(r.ok, `${method} ${url}: ${r.status} ${text}`);
return text ? JSON.parse(text) : null;
};
const { user } = await api('POST', '/api/users', {
email: 'rita@example.com',
displayName: 'Rita Recorded',
});
const ext = await api('POST', '/api/extensions', {
number: '241',
name: 'Rita Recorded',
userId: user.id,
});
const pass = (await api('GET', `/api/extensions/${ext.id}/sip-credentials`)).password;
const trunk = await api('POST', '/api/trunks', {
name: 'Recording test',
serverHost: '127.0.0.1',
username: 'm11rec',
password: 'unused-password-1',
});
await api('POST', '/api/dids', {
number: DID,
trunkId: trunk.id,
label: 'Rita direct',
destination: { type: 'user', id: ext.id },
});
const phones = startPhones({
name: 'sp-live-phones-m11sys',
net: NET,
ast: AST,
phones: [{ ext: '241', password: pass, answer: 'auto' }],
});
/** One call from outside to Rita's number; true when Asterisk started recording it. */
const callIn = async (n) => {
const at = astLog().length;
await originate({
Channel: `Local/${DID}@sp-from-trunk-${trunk.id}`,
CallerID: `"Outside" <${n}>`,
Application: 'Wait',
Data: '20',
});
await until('241 rings', 20_000, () => /Called PJSIP\/241\//.test(astLog().slice(at)));
await sleep(5000);
cli('channel request hangup all');
await sleep(2000);
return /MixMonitor\(/.test(astLog().slice(at));
};
const p = await launch({ shotsDir });
try {
await until('phone 241 registered', 180_000, () =>
/241\/sip:.*Avail/.test(cli('pjsip show contacts')),
);
await p.goto(`${base}/login`);
await p.waitFor(`!!document.querySelector('input[type=email]')`);
await p.fill('Email', email);
await p.fill('Password', pw);
await p.click('Sign in');
await p.waitFor(`location.pathname.startsWith('/admin')`, 'signed in');
// Record calls, set in the user drawer.
const setRecord = async (value) => {
await p.goto(`${base}/admin/users`);
await p.waitFor(`document.body.innerText.includes('rita@example.com')`, 'users list');
await p.click('Edit Rita Recorded');
await p.waitFor(
`[...document.querySelectorAll('[role=tab]')].some(t => t.textContent === 'Calling' && !t.disabled)`,
'Calling tab',
);
await p.click('Calling');
await p.waitFor(`!!document.querySelector('#user-calling-form')`, 'Calling form');
await p.fill('Record calls', value);
if (value !== 'never') await p.shot('m11-system-01-record-calls');
await p.evaluate(
`document.querySelector('dialog button[form=user-calling-form]').click(), true`,
);
await p.waitFor(`!document.querySelector('dialog[open]')`, 'drawer closed');
await until(
'record mode saved',
10_000,
async () => (await api('GET', `/api/extensions/${ext.id}`)).recordMode === value,
);
await until(
`dialplan has ${value}`,
30_000,
() =>
cli('dialplan show 241@sp-local-numbers').includes(`(241,`) &&
cli('dialplan show 241@sp-local-numbers').includes(`,${value}))`),
);
};
await setRecord('inbound');
assert.ok(await callIn('3125550781'), 'the call was recorded');
const call = await until('call with its recording in history', 60_000, async () => {
const c = (await api('GET', '/api/calls?q=5558899')).items[0];
return c?.recordingId ? c : null;
});
console.log(`record calls "Calls from outside": recorded (${call.talkSeconds} s, in history)`);
await setRecord('never');
assert.equal(await callIn('3125550782'), false, 'not recorded after "Never"');
console.log('record calls "Never": the next call was not recorded');
// A public IP change → warning → System page → restart → cleared.
await p.goto(`${base}/admin/settings/network`);
await p.waitFor(`!!document.querySelector('input')`, 'network tab');
await p.fill('Public IP', '203.0.113.77');
await p.click('Save');
await p.waitFor(`document.body.innerText.includes('Saved.')`, 'saved');
await until(
'restart required',
30_000,
async () => (await api('GET', '/api/system/status')).restartRequired,
);
await p.goto(`${base}/admin`);
await p.waitFor(
`document.querySelector('[aria-label=Warnings]')?.innerText.includes('need a telephony restart')`,
'dashboard warning',
);
await p.evaluate(
`[...document.querySelectorAll('[aria-label=Warnings] li')].find(li => li.innerText.includes('telephony restart')).querySelector('a').click(), true`,
);
await p.waitFor(`location.pathname === '/admin/system'`, 'System page from the warning');
await p.waitFor(
`document.querySelector('[aria-label=Status]')?.innerText.includes('Needed')`,
'restart needed',
);
const command = await p.evaluate(
`document.querySelector('[aria-label="Restart command"]').innerText.trim()`,
);
assert.equal(command, 'docker compose restart asterisk');
await p.shot('m11-system-02-needed');
console.log(`system page: "Restart: Needed", tells to run: ${command}`);
spawnSync('docker', ['restart', AST]);
// Reloaded rather than waiting for the page's own 10 s refresh: headless tabs count as hidden,
// and React Query pauses intervals in hidden tabs.
await until('running and not needed after the restart', 90_000, async () => {
await p.goto(`${base}/admin/system`);
await p.waitFor(`!!document.querySelector('[aria-label=Status]')`, 'status');
return /Running[\s\S]*Not needed/.test(
await p.evaluate(`document.querySelector('[aria-label=Status]').innerText`),
);
});
await p.shot('m11-system-03-restarted');
assert.match(
cli('pjsip show transport transport-udp'),
/203\.0\.113\.77/,
'new public IP in use',
);
const warnings = (await api('GET', '/api/system/warnings')).items.map((w) => w.code);
assert.ok(!warnings.includes('restart_required'), `warnings: ${warnings}`);
console.log('after the restart: "Running", "Not needed", the new IP in use, no warning');
console.log('M11 system page and record switch: PASS');
} finally {
await p.close();
phones.stop();
}
-16
View File
@@ -1,16 +0,0 @@
#!/bin/sh
# SPDX-License-Identifier: AGPL-3.0-or-later
# Update to the latest code (08 §3.1). Migrations run when the api starts. Calls in progress drop
# when Asterisk's container is recreated, so run it when the phones are quiet.
# Usage: scripts/update.sh (run from anywhere)
set -eu
cd "$(dirname "$0")/.."
git pull --ff-only
profile=$(docker ps -q --filter label=com.docker.compose.project=selfphoneng --filter label=com.docker.compose.service=caddy | grep -q . && echo '--profile caddy' || true)
# shellcheck disable=SC2086 # empty or one flag
docker compose $profile pull --ignore-buildable # postgres, caddy
# shellcheck disable=SC2086
docker compose $profile build --pull
# shellcheck disable=SC2086
docker compose $profile up -d
echo "Updated to $(sed -n 's/^ "version": "\(.*\)",$/\1/p' package.json) ($(git rev-parse --short HEAD))"