system: information panel and event log; about: download this build's source
ci / check (push) Successful in 1m20s
ci / images (push) Successful in 20s

System page laid out after 3CX's admin dashboard: a System information panel (version,
host name, company, public IP, LAN IPs, ports, telephony, restart needed, copy buttons,
how to restart) and an Event log panel (system_events, 90 days: trunk down/up, Asterisk
down/up, restart needed, config failed, 911, public IP changed, backups, disk) with Load
more and a CSV download.

About (/about, no sign-in, linked from the login page, My phone and System): AGPL-3.0
notice, credits, the repository link, and "Code v<version>", which downloads
selfphone.tar.gz: the api image now tars its own build context (no .env, data,
node_modules, build output or .git) before installing anything.

Task: M11-T7
This commit is contained in:
iamdoubz committed 2026-10-20 11:26:34 -05:00
1 parent a6ba61756c
commit 0ff0f9292b
29 files changed
+5035 -79

No files matched your search

+9 -1
View File
@@ -1,6 +1,14 @@
# Also what selfphone.tar.gz (the source archive in the api image, 08 §4) leaves out:
# nothing private (.env, data/) and nothing built (node_modules, dist).
**/node_modules
**/dist
**/coverage
**/*.tsbuildinfo
.pnpm-store
data
.env
.env.*
!.env.example
*.pid
*.log
.git
asterisk
+8
View File
@@ -3,6 +3,13 @@ FROM node:24-trixie-slim AS build
RUN npm install -g corepack@latest && corepack enable
WORKDIR /src
COPY . .
# The source of exactly this build, before anything is installed or built (AGPL-3.0 §13, 08 §4):
# the build context minus .dockerignore, served at /api/about/source.
RUN v=$(node -p "require('./package.json').version") \
&& echo "$v" > /VERSION \
&& tar --sort=name --owner=0 --group=0 --numeric-owner --transform "s,^\.,selfphoneng-$v," \
-czf /selfphone.tar.gz . \
&& tar -tzf /selfphone.tar.gz > /dev/null
RUN pnpm install --frozen-lockfile \
&& pnpm --filter @selfphoneng/shared build \
&& pnpm --filter @selfphoneng/web build \
@@ -27,6 +34,7 @@ COPY --from=build /src/apps/web/dist ./public
COPY --from=build /src/apps/api/src/db/migrations ./dist/db/migrations
# tsc only emits .js: files the code reads next to itself come over by hand.
COPY --from=build /src/apps/api/src/auth/common-passwords.txt ./dist/auth/common-passwords.txt
COPY --from=build /VERSION /selfphone.tar.gz ./
ENV NODE_ENV=production
ENTRYPOINT ["/usr/bin/tini","--"]
CMD ["node","dist/index.js"]
+2
View File
@@ -38,6 +38,7 @@ import { recordingRoutes } from './routes/recordings.ts';
import { liveCallRoutes } from './routes/livecalls.ts';
import { integrationRoutes } from './routes/integrations.ts';
import { reportRoutes } from './routes/reports.ts';
import { aboutRoutes } from './routes/about.ts';
declare module 'fastify' {
interface FastifyInstance {
@@ -149,6 +150,7 @@ export function buildApp(config: Config, db: Db, publicDir = resolve('public'))
app.register(liveCallRoutes);
app.register(integrationRoutes);
app.register(reportRoutes);
app.register(aboutRoutes);
// Always registered: reply.sendFile also streams voicemail audio (with Range) from the spool.
app.register(fastifyStatic, {
@@ -0,0 +1,11 @@
CREATE TABLE "system_events" (
"id" bigserial PRIMARY KEY NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"severity" text NOT NULL,
"code" text NOT NULL,
"source" text NOT NULL,
"message" text NOT NULL,
CONSTRAINT "system_events_severity_check" CHECK ("system_events"."severity" in ('info', 'warning', 'error'))
);
--> statement-breakpoint
CREATE INDEX "system_events_created_at_index" ON "system_events" USING btree ("created_at");
File diff suppressed because it is too large. Load diff
@@ -29,6 +29,13 @@
"when": 1792439261267,
"tag": "0003_users_totp",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1792512298128,
"tag": "0004_system_events",
"breakpoints": true
}
]
}
+18
View File
@@ -658,6 +658,24 @@ export const emergencyEvents = pgTable('emergency_events', {
...timestamps,
});
/** System page's event log: what the system noticed (trunks, telephony, backups…), not admin edits. */
export const systemEvents = pgTable(
'system_events',
{
id: bigserial({ mode: 'number' }).primaryKey(),
createdAt: createdAt(),
severity: text().notNull(),
/** Stable id of the kind of event, e.g. trunk.down. */
code: text().notNull(),
source: text().notNull(),
message: text().notNull(),
},
(t) => [
index().on(t.createdAt),
check('system_events_severity_check', sql`${t.severity} in ('info', 'warning', 'error')`),
],
);
export const auditLog = pgTable('audit_log', {
id: bigserial({ mode: 'number' }).primaryKey(),
createdAt: createdAt(),
+2
View File
@@ -19,6 +19,7 @@ import { watchQueueCalls } from './services/queuestats.ts';
import { watchPinChanges } from './services/vmpin.ts';
import { startJobs } from './jobs.ts';
import { watchWebhooks } from './services/webhooks.ts';
import { watchEvents } from './services/events.ts';
let config;
try {
@@ -79,6 +80,7 @@ const voicemail = watchVoicemail(
app.decorate('voicemail', voicemail);
app.decorate('live', live);
watchWebhooks(db, live, app.log);
watchEvents(db, live, ami, app.log);
await runMigrations(db);
app.log.info('database migrated');
+29
View File
@@ -10,6 +10,7 @@ import type { Db } from './db/client.ts';
import {
extensions,
sessions,
systemEvents,
trunks,
users,
voicemailMessages,
@@ -21,6 +22,7 @@ import type { LiveState } from './live/state.ts';
import { cleanupRecordings } from './routes/recordings.ts';
import { audit } from './services/audit.ts';
import { backupDue, runBackup } from './services/backup.ts';
import { logEvent } from './services/events.ts';
import { deliverDue } from './services/webhooks.ts';
import type { SendMail } from './services/mail.ts';
import { getSetting, setSetting } from './services/settings.ts';
@@ -101,6 +103,9 @@ export async function retentionCleanup(d: JobDeps) {
await d.db
.delete(webhookDeliveries)
.where(lt(webhookDeliveries.createdAt, sql`now() - interval '30 days'`));
await d.db
.delete(systemEvents)
.where(lt(systemEvents.createdAt, sql`now() - interval '90 days'`));
if (recordings || voicemail) d.log.info({ recordings, voicemail }, 'old messages deleted');
}
@@ -132,7 +137,19 @@ export function createJobs(d: JobDeps) {
try {
const b = await runBackup(d.config, d.db);
d.log.info({ backup: b.name, size: b.size }, 'backup made');
await logEvent(d.db, {
severity: 'info',
code: 'backup.done',
source: 'Backup',
message: `Daily backup ${b.name} made (${Math.round(b.size / 1e6)} MB)`,
});
} catch (err) {
await logEvent(d.db, {
severity: 'error',
code: 'backup.failed',
source: 'Backup',
message: `Daily backup failed: ${(err as Error).message}`,
});
await mailAdmins(d, 'Backup failed', `Today's backup failed: ${(err as Error).message}`);
throw err;
}
@@ -149,6 +166,12 @@ export function createJobs(d: JobDeps) {
summary: `Public IP changed from ${old ?? 'none'} to ${ip}`,
diff: { before: old, after: ip },
});
await logEvent(d.db, {
severity: 'warning',
code: 'network.publicIpChanged',
source: 'Network',
message: `Public IP changed from ${old ?? 'none'} to ${ip}`,
});
await d.generator.apply({ trigger: 'publicIp', force: true }); // sets restartRequired
await mailAdmins(
d,
@@ -198,6 +221,12 @@ export function createJobs(d: JobDeps) {
const today = todayIn(await tz(), now());
if (free >= 0.1 || diskMailedDay === today) return;
diskMailedDay = today;
await logEvent(d.db, {
severity: 'warning',
code: 'disk.low',
source: 'Disk',
message: `Disk almost full: ${Math.round(free * 100)}% free`,
});
await mailAdmins(
d,
`Disk almost full (${Math.round(free * 100)}% free)`,
+46
View File
@@ -0,0 +1,46 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// /api/about (08 §4): no sign-in. AGPL-3.0 §13 says everyone using the system over the network
// must be offered its source, so the exact source each image was built from ships inside it
// (apps/api/Dockerfile writes selfphone.tar.gz from the build context) and downloads from here.
import { createReadStream } from 'node:fs';
import { readFile, stat } from 'node:fs/promises';
import type { FastifyInstance } from 'fastify';
import type { AboutDto } from '@selfphoneng/shared';
import { notFound } from '../errors.ts';
export const REPO_URL = 'https://git.dou.bet/spng/selfphoneng';
/** In the api's working directory (/app in the image), like ./public. */
export const SOURCE_ARCHIVE = 'selfphone.tar.gz';
let version: string | undefined;
/** The image has VERSION (the root package.json's, written at build); a checkout reads that. */
export async function appVersion(): Promise<string> {
version ??=
(await readFile('VERSION', 'utf8').catch(() => null))?.trim() ||
(
JSON.parse(await readFile(new URL('../../../../package.json', import.meta.url), 'utf8')) as {
version: string;
}
).version;
return version;
}
export async function aboutRoutes(app: FastifyInstance) {
app.get('/api/about', async (): Promise<AboutDto> => {
const s = await stat(SOURCE_ARCHIVE).catch(() => null);
return {
version: await appVersion(),
repoUrl: REPO_URL,
source: s ? { bytes: s.size, builtAt: s.mtime.toISOString() } : null,
};
});
app.get('/api/about/source', async (_req, reply) => {
if (!(await stat(SOURCE_ARCHIVE).catch(() => null)))
throw notFound('The source archive is only in builds made with Docker');
return reply
.header('content-type', 'application/gzip')
.header('content-disposition', 'attachment; filename="selfphone.tar.gz"')
.send(createReadStream(SOURCE_ARCHIVE));
});
}
+1 -1
View File
@@ -97,7 +97,7 @@ async function page(db: Db, where: SQL[], limit: number) {
}
/** RFC 4180, and cells that a spreadsheet would run as a formula get a leading quote. */
const csvCell = (v: string | number | null) => {
export const csvCell = (v: string | number | null) => {
let s = v === null ? '' : String(v);
if (/^[=@\t\r]/.test(s) || (/^[-+]/.test(s) && !/^\+?\d+$/.test(s))) s = `'${s}`;
return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
+68 -11
View File
@@ -3,11 +3,16 @@
import { readFile } from 'node:fs/promises';
import { networkInterfaces } from 'node:os';
import { join, resolve } from 'node:path';
import { desc, lt } from 'drizzle-orm';
import { z } from 'zod';
import type { ListResponse, SystemEventDto, SystemStatus } from '@selfphoneng/shared';
import type { FastifyInstance } from 'fastify';
import { requireAdmin } from '../auth/guards.ts';
import { audit } from '../services/audit.ts';
import { getSetting } from '../services/settings.ts';
import { getAllSettings } from '../services/settings.ts';
import { systemEvents } from '../db/schema.ts';
import { appVersion } from './about.ts';
import { csvCell } from './calls.ts';
import { BACKUP_NAME, listBackups, runBackup } from '../services/backup.ts';
import { HttpError, notFound } from '../errors.ts';
import { AmiDisconnectedError } from '../ami/client.ts';
@@ -45,16 +50,68 @@ export async function systemRoutes(app: FastifyInstance) {
return r;
});
// ponytail: the M3 subset of 04 §7.3's status (the wizard needs the LAN IP); M11 adds the rest.
app.get('/api/system/status', async () => ({
ami: { connected: app.ami.isConnected() },
restartRequired: await getSetting(app.db, 'system.restartRequired'),
// With host networking (production) these are the server's own addresses.
lanIps: Object.values(networkInterfaces())
.flat()
.filter((i) => i && i.family === 'IPv4' && !i.internal)
.map((i) => i!.address),
}));
app.get('/api/system/status', async (): Promise<SystemStatus> => {
const s = await getAllSettings(app.db);
return {
version: await appVersion(),
hostname: app.config.PUBLIC_HOSTNAME,
companyName: s['general.companyName'],
publicIp: s['network.publicIp'],
publicIpDynamic: s['network.publicIpWatch'],
// With host networking (production) these are the server's own addresses.
lanIps: Object.values(networkInterfaces())
.flat()
.filter((i) => i && i.family === 'IPv4' && !i.internal)
.map((i) => i!.address),
ports: {
sip: s['sip.udpPort'],
sipTcp: s['sip.tcpEnabled'],
sips: s['sip.tlsEnabled'] ? s['sip.tlsPort'] : null,
rtp: '10000–20000', // asterisk/conf/rtp.conf
https: 443,
},
ami: { connected: app.ami.isConnected() },
restartRequired: s['system.restartRequired'],
};
});
/** The event log, newest first; `before` is the last id of the previous page. */
const EventsQuery = z.object({
before: z.coerce.number().int().positive().optional(),
limit: z.coerce.number().int().min(1).max(200).default(50),
});
const toEvent = (e: typeof systemEvents.$inferSelect): SystemEventDto => ({
id: e.id,
createdAt: e.createdAt.toISOString(),
severity: e.severity as SystemEventDto['severity'],
code: e.code,
source: e.source,
message: e.message,
});
app.get('/api/system/events', async (req): Promise<ListResponse<SystemEventDto>> => {
const q = EventsQuery.parse(req.query);
const rows = await app.db
.select()
.from(systemEvents)
.where(q.before ? lt(systemEvents.id, q.before) : undefined)
.orderBy(desc(systemEvents.id))
.limit(q.limit + 1);
const items = rows.slice(0, q.limit).map(toEvent);
return { items, nextCursor: rows.length > q.limit ? String(items.at(-1)!.id) : null };
});
app.get('/api/system/events.csv', async (_req, reply) => {
// ponytail: everything kept (90 days) in one file; page it if logs ever get big.
const rows = await app.db.select().from(systemEvents).orderBy(desc(systemEvents.id));
const out = ['time,severity,event,source,details'];
for (const e of rows)
out.push(
[e.createdAt.toISOString(), e.severity, e.code, e.source, e.message].map(csvCell).join(','),
);
return reply
.header('content-type', 'text/csv; charset=utf-8')
.header('content-disposition', 'attachment; filename="selfphoneng-events.csv"')
.send(out.join('\r\n') + '\r\n');
});
/** 01 §4.3: transports only change on a restart. `now=true` drops calls; otherwise waits. */
app.post('/api/system/asterisk/restart', async (req) => {
+116
View File
@@ -0,0 +1,116 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// The System page's event log (05 §2): things the system noticed, newest first. Admin edits are
// the audit log's job, not this one's.
import type { EventEmitter } from 'node:events';
import { eq } from 'drizzle-orm';
import type { LiveMessage, SystemEventDto } from '@selfphoneng/shared';
import type { Db } from '../db/client.ts';
import { systemEvents, trunks } from '../db/schema.ts';
export type SystemEvent = Pick<SystemEventDto, 'severity' | 'code' | 'source' | 'message'>;
type Log = { error(o: object, m: string): void };
/** Never throws: an event that can't be written is logged and dropped. */
export async function logEvent(db: Db, e: SystemEvent, log?: Log): Promise<void> {
try {
await db.insert(systemEvents).values(e);
} catch (err) {
log?.error({ err, event: e.code }, 'system event not saved');
}
}
const REGISTRATION: Record<string, string> = {
registered: 'registered',
rejected: 'was rejected (wrong username or password?)',
unregistered: 'is not registered',
unknown: 'is in an unknown state',
};
/** Trunk registration changes, 911 calls, Asterisk going away and coming back, config errors. */
export function watchEvents(
db: Db,
live: { on(e: 'message', fn: (m: LiveMessage) => void): unknown },
ami: Pick<EventEmitter, 'on'> & { isConnected(): boolean },
log: Log,
) {
const add = (e: SystemEvent) => void logEvent(db, e, log);
const trunkState = new Map<string, string>();
let restartRequired = false;
// null until the first connect, so starting up isn't logged as "reachable again".
let amiUp: boolean | null = ami.isConnected() ? true : null;
ami.on('connected', () => {
if (amiUp === false)
add({
severity: 'info',
code: 'telephony.up',
source: 'Telephony',
message: 'Asterisk is reachable again',
});
amiUp = true;
});
ami.on('disconnected', () => {
if (amiUp)
add({
severity: 'error',
code: 'telephony.down',
source: 'Telephony',
message: 'Lost the connection to Asterisk (stopped or restarting)',
});
amiUp = false;
});
live.on('message', (m) => {
if (m.type === 'trunk.updated') {
const prev = trunkState.get(m.data.trunkId);
trunkState.set(m.data.trunkId, m.data.registration);
if (!prev || prev === m.data.registration) return;
const up = m.data.registration === 'registered';
void (async () => {
const [t] = await db
.select({ name: trunks.name })
.from(trunks)
.where(eq(trunks.id, m.data.trunkId));
add({
severity: up ? 'info' : 'warning',
code: up ? 'trunk.up' : 'trunk.down',
source: 'Trunk',
message: `Trunk ${t?.name ?? m.data.trunkId} ${REGISTRATION[m.data.registration]}`,
});
})().catch((err: unknown) => log.error({ err }, 'trunk event failed'));
} else if (m.type === 'emergency.call') {
const who = `${m.data.ext} ${m.data.name}`;
add(
m.data.test
? {
severity: 'info',
code: 'emergency.test',
source: 'Emergency',
message: 'Emergency notifications tested',
}
: {
severity: 'error',
code: 'emergency.call',
source: 'Emergency',
message: `${who} dialed 911 (caller ID ${m.data.callerId}${m.data.location ? `, ${m.data.location}` : ''})`,
},
);
} else if (m.type === 'system.configApplied') {
if (!m.data.ok)
add({
severity: 'error',
code: 'config.failed',
source: 'Configuration',
message: `Writing Asterisk's configuration failed: ${m.data.error ?? 'unknown error'}`,
});
if (m.data.restartRequired && !restartRequired)
add({
severity: 'warning',
code: 'telephony.restartNeeded',
source: 'Configuration',
message: 'SIP ports, transports or the public IP changed: telephony needs a restart',
});
restartRequired = m.data.restartRequired;
}
});
}
+14 -1
View File
@@ -5,12 +5,18 @@ import type { LightMyRequestResponse } from 'fastify';
import { eq } from 'drizzle-orm';
import type { TrunkLive } from '@selfphoneng/shared';
import { createJobs, retentionCleanup, type JobDeps } from '../src/jobs.ts';
import { extensions, sessions, voicemailMessages } from '../src/db/schema.ts';
import { extensions, sessions, systemEvents, voicemailMessages } from '../src/db/schema.ts';
import { getSetting, setSetting } from '../src/services/settings.ts';
import { hasTestDb } from './db.ts';
import { ADMIN, H, createTestApp } from './app.ts';
describe.skipIf(!hasTestDb)('background jobs', () => {
const events = (code: string) =>
t.db
.select({ severity: systemEvents.severity, message: systemEvents.message })
.from(systemEvents)
.where(eq(systemEvents.code, code))
.orderBy(systemEvents.id);
let t: Awaited<ReturnType<typeof createTestApp>>;
let req: (m: string, url: string, payload?: unknown) => Promise<LightMyRequestResponse>;
let trunkId: string;
@@ -159,6 +165,9 @@ describe.skipIf(!hasTestDb)('background jobs', () => {
expect(await getSetting(t.db, 'network.publicIp')).toBe('203.0.113.9');
expect(await getSetting(t.db, 'system.restartRequired')).toBe(true);
expect(t.mails.map((m) => m.subject)).toEqual(['Public IP changed to 203.0.113.9']);
expect(await events('network.publicIpChanged')).toEqual([
{ severity: 'warning', message: 'Public IP changed from 203.0.113.5 to 203.0.113.9' },
]);
await j.publicIpWatch(); // unchanged: nothing
vi.stubGlobal('fetch', async () => new Response('<html>rate limited</html>'));
@@ -187,6 +196,10 @@ describe.skipIf(!hasTestDb)('background jobs', () => {
clock += 86_400_000; // next day: again
await j.diskWatch();
expect(t.mails).toHaveLength(2);
expect(await events('disk.low')).toEqual([
{ severity: 'warning', message: 'Disk almost full: 6% free' },
{ severity: 'warning', message: 'Disk almost full: 6% free' },
]);
});
it('a mail that fails to send is logged, not thrown', async () => {
+2 -2
View File
@@ -106,11 +106,11 @@ describe.skipIf(!hasTestDb)('schema matches spec 02', () => {
beforeAll(async () => ({ db, drop } = await createTestDb()));
afterAll(() => drop?.());
it('creates all 40 tables', async () => {
it('creates all 41 tables', async () => {
const r = await db.execute(
sql`select count(*)::int as n from information_schema.tables where table_schema = 'public'`,
);
expect(r.rows[0]).toEqual({ n: 40 });
expect(r.rows[0]).toEqual({ n: 41 });
});
it.each(Object.keys(EXPECTED))('%s columns', async (table) => {
+166
View File
@@ -0,0 +1,166 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// The System page (05 §2): status, the event log, and /api/about (08 §4, no sign-in).
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import type { LightMyRequestResponse } from 'fastify';
import type { TrunkLive } from '@selfphoneng/shared';
import { encrypt } from '../src/crypto/secrets.ts';
import { systemEvents, trunks } from '../src/db/schema.ts';
import { logEvent, watchEvents } from '../src/services/events.ts';
import { setSetting } from '../src/services/settings.ts';
import { hasTestDb } from './db.ts';
import { ADMIN, H, createTestApp } from './app.ts';
const settle = () => new Promise((r) => setTimeout(r, 200)); // inserts are fire-and-forget
describe.skipIf(!hasTestDb)('system status, event log, about', () => {
let t: Awaited<ReturnType<typeof createTestApp>>;
let get: (url: string, cookie?: string) => Promise<LightMyRequestResponse>;
let admin: string;
beforeAll(async () => {
t = await createTestApp();
await t.clearMustChange(ADMIN.email);
admin = await t.login(ADMIN.email, ADMIN.password);
get = (url, cookie = admin) =>
t.app.inject({ method: 'GET', url, headers: cookie ? { ...H, cookie } : H });
});
afterAll(() => t?.close());
it('status: version, host, IPs, ports, telephony, restart', async () => {
await setSetting(t.db, 'network.publicIp', '203.0.113.5');
await setSetting(t.db, 'sip.tlsEnabled', true);
const s = (await get('/api/system/status')).json();
expect(s).toMatchObject({
version: '0.0.0',
hostname: expect.any(String),
companyName: expect.any(String),
publicIp: '203.0.113.5',
publicIpDynamic: false,
ports: { sip: 5060, sipTcp: true, sips: 5061, rtp: '10000–20000', https: 443 },
ami: { connected: true },
restartRequired: expect.any(Boolean),
});
expect(Array.isArray(s.lanIps)).toBe(true);
});
it('the watcher logs trunk changes, 911, Asterisk going away, config problems', async () => {
watchEvents(t.db, t.live, t.ami, t.app.log);
const [trunk] = await t.db
.insert(trunks)
.values({
name: 'voip.ms',
serverHost: 'x.voip.ms',
username: 'u',
passwordEnc: encrypt('p'),
})
.returning();
const reg = (registration: TrunkLive['registration']) =>
t.live.emit('message', {
type: 'trunk.updated',
data: { trunkId: trunk!.id, registration, reachable: true, lastChange: '' },
});
reg('registered'); // first sight: nothing
reg('registered');
reg('rejected');
reg('registered');
t.ami.events.emit('disconnected');
t.ami.events.emit('connected');
const call = { ext: '101', name: 'Ann', location: '2nd floor', callerId: '13125550100' };
t.live.emit('message', {
type: 'emergency.call',
data: { ...call, at: '', test: false, audienceUserIds: [] },
});
t.live.emit('message', {
type: 'emergency.call',
data: { ...call, at: '', test: true, audienceUserIds: [] },
});
t.live.emit('message', {
type: 'system.configApplied',
data: { ok: true, error: null, restartRequired: true },
});
t.live.emit('message', {
type: 'system.configApplied',
data: { ok: false, error: 'disk full', restartRequired: true }, // already said: once
});
await settle();
// Sorted: trunk events wait for the trunk's name, so they can land after later ones.
const rows = (await t.db.select().from(systemEvents)).sort((x, y) =>
x.code.localeCompare(y.code),
);
expect(rows.map((r) => [r.severity, r.code, r.source, r.message])).toEqual([
[
'error',
'config.failed',
'Configuration',
"Writing Asterisk's configuration failed: disk full",
],
[
'error',
'emergency.call',
'Emergency',
'101 Ann dialed 911 (caller ID 13125550100, 2nd floor)',
],
['info', 'emergency.test', 'Emergency', 'Emergency notifications tested'],
[
'error',
'telephony.down',
'Telephony',
'Lost the connection to Asterisk (stopped or restarting)',
],
[
'warning',
'telephony.restartNeeded',
'Configuration',
'SIP ports, transports or the public IP changed: telephony needs a restart',
],
['info', 'telephony.up', 'Telephony', 'Asterisk is reachable again'],
[
'warning',
'trunk.down',
'Trunk',
'Trunk voip.ms was rejected (wrong username or password?)',
],
['info', 'trunk.up', 'Trunk', 'Trunk voip.ms registered'],
]);
});
it('events: newest first, paged; CSV; admins only', async () => {
await t.db.delete(systemEvents);
for (let i = 1; i <= 5; i++)
await logEvent(t.db, {
severity: 'info',
code: 'test.n',
source: 'Test',
message: i === 5 ? '=cmd|x' : `event ${i}`,
});
const first = (await get('/api/system/events?limit=2')).json();
expect(first.items.map((e: { message: string }) => e.message)).toEqual(['=cmd|x', 'event 4']);
const second = (await get(`/api/system/events?limit=2&before=${first.nextCursor}`)).json();
expect(second.items.map((e: { message: string }) => e.message)).toEqual(['event 3', 'event 2']);
const last = (await get(`/api/system/events?limit=2&before=${second.nextCursor}`)).json();
expect(last).toMatchObject({ items: [{ message: 'event 1' }], nextCursor: null });
const csv = await get('/api/system/events.csv');
expect(csv.headers['content-disposition']).toContain('selfphoneng-events.csv');
const lines = csv.body.trim().split('\r\n');
expect(lines[0]).toBe('time,severity,event,source,details');
expect(lines).toHaveLength(6);
expect(lines[1]).toMatch(/,info,test\.n,Test,'=cmd\|x$/); // no spreadsheet formulas
await t.addUser('pat@example.com', 'pat-password-123');
const pat = await t.login('pat@example.com', 'pat-password-123');
expect((await get('/api/system/events', pat)).statusCode).toBe(403);
expect((await get('/api/system/events', '')).statusCode).toBe(401);
});
it('about needs no sign-in; outside a Docker build there is no source archive', async () => {
const r = await get('/api/about', '');
expect(r.statusCode).toBe(200);
expect(r.json()).toEqual({
version: '0.0.0',
repoUrl: 'https://git.dou.bet/spng/selfphoneng',
source: null,
});
expect((await get('/api/about/source', '')).statusCode).toBe(404);
});
});
+9 -3
View File
@@ -1,6 +1,9 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Typed fetch wrappers (05 §1). Every request sends X-Requested-With (CSRF, 04 §2.2).
import type {
AboutDto,
SystemEventDto,
SystemStatus,
QueueReportRow,
ReportRange,
ReportSummary,
@@ -222,11 +225,14 @@ export const settingsApi = {
warnings: () => api<{ items: Warning[] }>('GET', '/api/system/warnings').then((r) => r.items),
};
export const aboutApi = { get: () => api<AboutDto>('GET', '/api/about') };
export const systemApi = {
status: () =>
api<{ ami: { connected: boolean }; restartRequired: boolean; lanIps: string[] }>(
status: () => api<SystemStatus>('GET', '/api/system/status'),
events: (before?: number) =>
api<ListResponse<SystemEventDto>>(
'GET',
'/api/system/status',
`/api/system/events${before ? `?before=${before}` : ''}`,
),
restartAsterisk: (now: boolean) =>
api<{ ok: true }>('POST', `/api/system/asterisk/restart?now=${now}`),
+2
View File
@@ -7,6 +7,7 @@ import { RouterProvider } from 'react-router/dom';
import { RequireAuth, homeFor } from './auth/RequireAuth.tsx';
import { ChangePasswordPage } from './pages/ChangePasswordPage.tsx';
import { LoginPage } from './pages/LoginPage.tsx';
import { AboutPage } from './pages/AboutPage.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';
@@ -34,6 +35,7 @@ import './index.css';
const router = createBrowserRouter([
{ path: '/login', Component: LoginPage },
{ path: '/about', Component: AboutPage },
{ path: '/change-password', Component: ChangePasswordPage },
{ path: '/setup', element: <RequireAuth role="admin">{() => <SetupPage />}</RequireAuth> },
{
+107
View File
@@ -0,0 +1,107 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// About (08 §4): license, the source of this exact build (AGPL-3.0 §13) and third-party credits.
// No sign-in: everyone who uses the system over the network gets the offer.
import { useQuery } from '@tanstack/react-query';
import { Link } from 'react-router';
import { aboutApi } from '../api/client.ts';
import { Alert, Brand } from '../components/ui.tsx';
const linkClass = 'text-sky-700 underline dark:text-sky-400';
export function AboutPage() {
const about = useQuery({ queryKey: ['about'], queryFn: aboutApi.get });
const a = about.data;
return (
<main className="mx-auto flex min-h-dvh max-w-2xl flex-col gap-6 p-4 text-sm sm:p-8">
<header className="flex items-center justify-between gap-2">
<h1 className="text-xl">
<Brand />
</h1>
<Link to="/" className={linkClass}>
Back
</Link>
</header>
{about.error && <Alert>{about.error.message}</Alert>}
<section aria-labelledby="license-h" className="flex flex-col gap-2">
<h2 id="license-h" className="text-lg font-semibold">
SelfPhoneNG {a && `v${a.version}`}
</h2>
<p>
SelfPhoneNG is free software: you can redistribute it and change it under the terms of the{' '}
<a className={linkClass} href="https://www.gnu.org/licenses/agpl-3.0.html">
GNU Affero General Public License
</a>
, version 3 or (at your option) any later version. It comes with no warranty.
</p>
<p>
Because you use it over a network, you&apos;re entitled to the source code of the version
running here, changes included. Download it below: it&apos;s the exact source this system
was built from.
</p>
{a?.source ? (
<div className="flex flex-wrap items-center gap-3">
<a
href="/api/about/source"
download="selfphone.tar.gz"
className="rounded-md bg-sky-600 px-4 py-2 font-medium text-white hover:bg-sky-700"
>
Code v{a.version}
</a>
<span className="text-zinc-500">
selfphone.tar.gz, {Math.max(1, Math.round(a.source.bytes / 1e6))} MB, built{' '}
{new Date(a.source.builtAt).toLocaleString()}
</span>
</div>
) : (
a && (
<p className="text-zinc-500">
This is a development build: the source archive is only in builds made with Docker.
The project&apos;s repository is below.
</p>
)
)}
{a && (
<p>
Project home (all versions, issues):{' '}
<a className={linkClass} href={a.repoUrl}>
{a.repoUrl.replace(/^https:\/\//, '')}
</a>
</p>
)}
</section>
<section aria-labelledby="credits-h" className="flex flex-col gap-2">
<h2 id="credits-h" className="text-lg font-semibold">
Built with
</h2>
<ul className="list-disc pl-5">
<li>
<a className={linkClass} href="https://www.asterisk.org/">
Asterisk
</a>{' '}
(GPL-2.0), the telephony engine. It runs as a separate program.
</li>
<li>
Asterisk&apos;s voice prompts (core and extra sounds), © Sangoma Technologies, under{' '}
<a className={linkClass} href="https://creativecommons.org/licenses/by-sa/3.0/">
CC BY-SA 3.0
</a>
.
</li>
<li>
Default music on hold from opsound.org, under{' '}
<a className={linkClass} href="https://creativecommons.org/licenses/by-sa/3.0/">
CC BY-SA 3.0
</a>
.
</li>
<li>
PostgreSQL, Node.js, Fastify, React, SIP.js and the other libraries listed in the
source&apos;s package.json files, each under its own license.
</li>
</ul>
</section>
</main>
);
}
+5 -2
View File
@@ -3,7 +3,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { useNavigate, useSearchParams } from 'react-router';
import { Link, useNavigate, useSearchParams } from 'react-router';
import { APP_NAME, LoginSchema } from '@selfphoneng/shared';
import { ApiError, authApi } from '../api/client.ts';
import { homeFor } from '../auth/RequireAuth.tsx';
@@ -11,12 +11,15 @@ import { Alert, Button, Field, inputClass } from '../components/ui.tsx';
export function AuthCard({ title, children }: { title: string; children: React.ReactNode }) {
return (
<main className="flex min-h-dvh items-center justify-center p-4">
<main className="flex min-h-dvh flex-col items-center justify-center p-4">
<div className="w-full max-w-sm rounded-lg border border-zinc-200 bg-white p-6 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<img src="/logo-512.png" alt={APP_NAME} className="mx-auto mb-4 size-32 rounded-xl" />
<h1 className="mb-5 text-xl font-semibold">{title}</h1>
{children}
</div>
<Link to="/about" className="mt-3 text-xs text-zinc-500 hover:underline">
About SelfPhoneNG · source code
</Link>
</main>
);
}
+225 -49
View File
@@ -1,70 +1,246 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// System (05 §2): telephony status and how to restart it.
// System (05 §2): system information, how to restart telephony, and the event log.
// 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 { useInfiniteQuery, useQuery } from '@tanstack/react-query';
import { useState, type ReactNode } from 'react';
import { Link } from 'react-router';
import type { SystemEventDto } from '@selfphoneng/shared';
import { systemApi } from '../../api/client.ts';
import { Alert } from '../../components/ui.tsx';
import { Alert, Button } from '../../components/ui.tsx';
const RESTART = 'docker compose restart asterisk';
const icon = (d: string) => (
<svg viewBox="0 0 24 24" aria-hidden className="size-4 fill-none stroke-current stroke-2">
<path d={d} strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
const INFO = 'M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20zM12 16v-4M12 8h.01';
const LIST = 'M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01';
const DOWNLOAD = 'M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3';
const COPY = 'M8 8h12v12H8zM4 16V4h12';
function Panel({
title,
path,
action,
children,
}: {
title: string;
path: string;
action?: ReactNode;
children: ReactNode;
}) {
return (
<section
aria-label={title}
className="rounded-lg border border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-900/60"
>
<header className="flex items-center gap-2 border-b border-zinc-200 px-4 py-3 dark:border-zinc-800">
<span className="text-sky-600 dark:text-sky-400">{icon(path)}</span>
<h2 className="flex-1 font-medium">{title}</h2>
{action}
</header>
{children}
</section>
);
}
function Copy({ text, label }: { text: string; label: string }) {
const [done, setDone] = useState(false);
return (
<button
type="button"
aria-label={`Copy ${label}`}
title={done ? 'Copied' : `Copy ${label}`}
className="rounded p-0.5 text-sky-600 hover:bg-zinc-100 dark:text-sky-400 dark:hover:bg-zinc-800"
onClick={() =>
void navigator.clipboard.writeText(text).then(() => {
setDone(true);
setTimeout(() => setDone(false), 1500);
})
}
>
{icon(COPY)}
</button>
);
}
/** One "label value" cell of the information grid. */
function Item({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 border-b border-zinc-100 py-2 dark:border-zinc-800">
<dt className="text-zinc-500">{label}</dt>
<dd className="flex items-center gap-1.5 font-semibold">{children}</dd>
</div>
);
}
const SEVERITY: Record<SystemEventDto['severity'], [string, string]> = {
info: ['Info', 'bg-sky-500'],
warning: ['Warning', 'bg-amber-500'],
error: ['Error', 'bg-red-500'],
};
export function SystemPage() {
const status = useQuery({
queryKey: ['system-status'],
queryFn: systemApi.status,
refetchInterval: 10_000,
});
const events = useInfiniteQuery({
queryKey: ['system-events'],
queryFn: ({ pageParam }) => systemApi.events(pageParam),
initialPageParam: undefined as number | undefined,
getNextPageParam: (last) => (last.nextCursor ? Number(last.nextCursor) : undefined),
refetchInterval: 30_000,
});
const s = status.data;
const rows = events.data?.pages.flatMap((p) => p.items) ?? [];
const p = s?.ports;
return (
<section className="flex max-w-2xl flex-col gap-6">
<h1 className="text-xl font-semibold">System</h1>
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-2">
<h1 className="text-xl font-semibold">System</h1>
<Link to="/about" className="text-sm text-sky-700 hover:underline dark:text-sky-400">
About and source code
</Link>
</div>
{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>
<Panel title="System information" path={INFO}>
{s && p && (
<dl
aria-label="Status"
className="grid gap-x-8 px-4 pb-2 text-sm sm:grid-cols-2 xl:grid-cols-3"
>
<Item label="Version">v{s.version}</Item>
<Item label="Host name">
{s.hostname} <Copy text={s.hostname} label="host name" />
</Item>
<Item label="Company">{s.companyName}</Item>
<Item label="Public IP">
{s.publicIp ?? 'not set'}
<span className="font-normal text-zinc-500">
{s.publicIpDynamic ? 'dynamic, re-checked' : 'static'}
</span>
{s.publicIp && <Copy text={s.publicIp} label="public IP" />}
</Item>
<Item label="LAN IP">{s.lanIps.join(', ') || '–'}</Item>
<Item label="Ports">
<span>
SIP {p.sip}
<span className="font-normal text-zinc-500">{p.sipTcp ? ' UDP+TCP' : ' UDP'}</span>
{p.sips && ` · SIPS ${p.sips}`} · Media {p.rtp} · HTTPS {p.https}
</span>
</Item>
<Item label="Telephony">
<span
className={`size-2 rounded-full ${s.ami.connected ? 'bg-green-500' : 'bg-red-500'}`}
/>
{s.ami.connected ? 'Running' : 'Not reachable'}
</Item>
<Item label="Restart">
<span
className={`size-2 rounded-full ${s.restartRequired ? 'bg-amber-500' : 'bg-green-500'}`}
/>
{s.restartRequired ? 'Needed' : 'Not needed'}
</Item>
</dl>
)}
<div className="flex flex-col gap-2 px-4 py-3 text-sm">
{s?.restartRequired && (
<p
role="status"
className="rounded-md bg-amber-50 p-3 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>
)}
<p>
<strong>Restarting telephony:</strong> on the server, in the folder SelfPhoneNG is
installed in (where <code>.env</code> is), run:
</p>
<div className="flex items-center gap-2">
<pre
aria-label="Restart command"
className="flex-1 overflow-x-auto rounded-md bg-zinc-100 px-3 py-2 font-mono dark:bg-zinc-950"
>
{RESTART}
</pre>
<Copy text={RESTART} label="restart command" />
</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>
)}
<p className="text-zinc-500">
Calls in progress drop, so do it when nobody is on the phone. Phones and trunks sign in
again by themselves within a minute; the web app, voicemail and history aren&apos;t
affected.
</p>
</div>
</Panel>
<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>
<Panel
title="Event log"
path={LIST}
action={
<a
href="/api/system/events.csv"
className="flex items-center gap-1.5 rounded-md border border-zinc-300 px-2.5 py-1 text-sm hover:bg-zinc-100 dark:border-zinc-700 dark:hover:bg-zinc-800"
>
{icon(DOWNLOAD)} Download
</a>
}
>
{events.error && <Alert>{events.error.message}</Alert>}
{events.data && !rows.length && (
<p className="px-4 py-3 text-sm text-zinc-500">Nothing logged yet.</p>
)}
{!!rows.length && (
<div className="overflow-x-auto">
<table aria-label="Events" className="w-full text-left text-sm">
<thead className="text-zinc-500">
<tr>
{['Severity', 'Event', 'Time', 'Source', 'Details'].map((h) => (
<th key={h} scope="col" className="px-4 py-2 font-medium whitespace-nowrap">
{h}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-zinc-100 dark:divide-zinc-800">
{rows.map((e) => (
<tr key={e.id}>
<td className="px-4 py-1.5 whitespace-nowrap">
<span className="flex items-center gap-1.5">
<span className={`size-2 rounded-full ${SEVERITY[e.severity][1]}`} />
{SEVERITY[e.severity][0]}
</span>
</td>
<td className="px-4 py-1.5 font-mono text-xs whitespace-nowrap">{e.code}</td>
<td className="px-4 py-1.5 whitespace-nowrap tabular-nums">
{new Date(e.createdAt).toLocaleString()}
</td>
<td className="px-4 py-1.5 whitespace-nowrap">{e.source}</td>
<td className="min-w-64 px-4 py-1.5">{e.message}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{events.hasNextPage && (
<div className="border-t border-zinc-100 py-2 text-center dark:border-zinc-800">
<Button
onClick={() => void events.fetchNextPage()}
disabled={events.isFetchingNextPage}
>
Load more
</Button>
</div>
)}
</Panel>
</div>
);
}
+3
View File
@@ -49,6 +49,9 @@ export function MePage({ user }: { user: AuthUser }) {
Admin
</Link>
)}
<Link className={`${link} text-sm`} to="/about">
About
</Link>
<ThemeToggle />
<Button
variant="ghost"
+3 -1
View File
@@ -97,7 +97,9 @@ curl http://127.0.0.1:8080/healthz # 200 {"ok":true}, or 503 when the datab
From outside, monitor `https://pbx.example.com/healthz`.
**The dashboard** shows trunks, active calls, queues and warnings (emergency setup, certificate,
backups, restart needed) live.
backups, restart needed) live. **System** has the event log: trunks going down and back up,
Asterisk stopping and coming back, restarts needed, 911 calls, public IP changes, backups and a full
disk, kept 90 days (**Download** saves it as CSV).
### Keeping logs small
+12 -1
View File
@@ -8,7 +8,7 @@ Conventions:
- Primary keys: `uuid` with `default gen_random_uuid()` unless stated (built into PostgreSQL 18, so no pgcrypto extension is needed).
- Every table has `created_at timestamptz not null default now()` and
`updated_at timestamptz not null default now()` (left out below to save space,
**but add them**). Exceptions: `cdr`, `audit_log`, `webhook_deliveries`, `generator_runs` (append-only, `created_at` only).
**but add them**). Exceptions: `cdr`, `audit_log`, `system_events`, `webhook_deliveries`, `generator_runs` (append-only, `created_at` only).
- Columns ending in `_enc` hold secrets encrypted with AES-256-GCM using
`APP_SECRET` (format: `base64(iv[12] | tag[16] | ciphertext)`). Only
`crypto/secrets.ts` encrypts and decrypts. API responses never include `_enc`
@@ -504,6 +504,17 @@ create table audit_log (
diff jsonb -- secrets replaced with "********"
);
-- Added 2026-10-20 (owner follow-ups to M11-T7): the System page's event log (05 §2), kept 90 days.
create table system_events (
id bigserial primary key,
created_at timestamptz not null default now(),
severity text not null check (severity in ('info', 'warning', 'error')),
code text not null, -- 'trunk.down', 'telephony.restartNeeded', 'backup.failed', ...
source text not null, -- 'Trunk', 'Telephony', 'Backup', ...
message text not null
);
create index on system_events (created_at);
create table generator_runs (
id bigserial primary key,
created_at timestamptz not null default now(),
+1 -1
View File
@@ -48,7 +48,7 @@ Left sidebar navigation, in this order:
| SMS | `/admin/sms` | Same UI as the user one, with all DIDs |
| 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 |
| 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 |
| 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-ups to M11-T7, 2026-10-20), laid out after 3CX's admin dashboard (not copied): a **System information** panel (version, host name, company, public IP static/dynamic, LAN IPs, ports SIP/SIPS/media/HTTPS, telephony running, restart needed; copy buttons) with how to restart (`docker compose restart asterisk` on the server; no restart button), and an **Event log** panel (severity, event code, time, source, details; Load more; Download = `/api/system/events.csv`). Events (`system_events`, kept 90 days): `trunk.down`/`trunk.up` (registration changes), `telephony.down`/`telephony.up` (AMI), `telephony.restartNeeded`, `config.failed`, `emergency.call`/`emergency.test`, `network.publicIpChanged`, `backup.done`/`backup.failed`, `disk.low`. Admin edits stay in the audit log (not shown yet). The dashboard's `restart_required` warning links here. Generator runs, generated files and CLI views aren't built; the admin guide's troubleshooting page covers them from the shell |
### 2.1 First-run setup wizard (`/setup`)
+7
View File
@@ -111,6 +111,13 @@ how to use `/admin/system` CLI views and `pjsip set logger on` (via `docker comp
## 4. Licensing compliance
- Our code: AGPL-3.0-or-later. Add `LICENSE`, SPDX headers, and an About page (`/admin/system` → About) listing
the license and a link to the source repository (AGPL requires offering source to network users).
As built (owner, 2026-10-20): `/about` needs no sign-in (linked from the login page, My phone and System). The
api image carries the exact source it was built from: `apps/api/Dockerfile` tars the build context (minus
`.dockerignore`: no `.env`, `data/`, `node_modules`, build output or `.git`) into `/app/selfphone.tar.gz`
before installing anything, with `VERSION` from the root package.json. "Code v<version>" downloads it
(`GET /api/about/source`, `selfphone.tar.gz`, top folder `selfphoneng-<version>/`). Outside Docker there is
no archive and the page says so. The repository link is general context. The ringtone is synthesized
(WebAudio), so there's no sound file to credit.
- Asterisk: GPL-2.0, runs as a separate program. We don't link to it, so there's no license conflict.
- Asterisk core/extra sounds and opsound MOH: CC-BY-SA. Credit them on the About page.
- Ringtone: CC0, documented in `apps/web/public/sounds/SOUNDS-LICENSE.md`.
+32
View File
@@ -110,3 +110,35 @@ export type BackupDto = { name: string; size: number; createdAt: string };
/** GET /api/system/tls: the SIP TLS certificate in data/tls (M11-T2). */
export type TlsCert =
{ ok: true; subject: string; names: string[]; notAfter: string } | { ok: false; error: string };
/** GET /api/system/status: the System page's "System information" (05 §2). */
export type SystemStatus = {
version: string;
hostname: string;
companyName: string;
publicIp: string | null;
/** Re-checked every 5 minutes (network.publicIpWatch). */
publicIpDynamic: boolean;
lanIps: string[];
ports: { sip: number; sipTcp: boolean; sips: number | null; rtp: string; https: number };
ami: { connected: boolean };
restartRequired: boolean;
};
/** GET /api/system/events: the System page's event log, newest first. */
export type SystemEventDto = {
id: number;
createdAt: string;
severity: 'info' | 'warning' | 'error';
code: string;
source: string;
message: string;
};
/** GET /api/about (no sign-in): what runs here and where its source is (AGPL, 08 §4). */
export type AboutDto = {
version: string;
repoUrl: string;
/** The source this build was made from (selfphone.tar.gz); null outside a Docker build. */
source: { bytes: number; builtAt: string } | null;
};
+5 -1
View File
@@ -265,4 +265,8 @@ from outside" records a real inbound call to 241 (MixMonitor, a recording in cal
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.
warning is gone. System information shows the IP and ports, the event log and its CSV have the restart
(restart needed, Asterisk down, up), and the page doesn't scroll sideways at 390 px. Before signing
in, the login page's link opens About, whose "Code v<version>" downloads `selfphone.tar.gz` byte for
byte as built: the runner builds the api image and copies its `VERSION` and archive into the api's
working folder (5th argument). About 6 minutes.
+86 -5
View File
@@ -6,11 +6,17 @@
// - 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>
// to "Running" and "Not needed", and the warning goes away. System information shows the IP and
// ports, and the event log (and its CSV) has the restart: restart needed, Asterisk down, up.
// - About (no sign-in, linked from the login page): "Code v<version>" downloads selfphone.tar.gz,
// byte for byte the archive the api image was built with.
// Runs on m2-live with a recordings dir (api with RECORDINGS_DIR), after m1-t4.mjs. The api's working
// directory <apiDir> holds VERSION and selfphone.tar.gz copied out of a built api image.
// Usage: node scripts/e2e/m11-system.mjs <baseUrl> <adminEmail> <adminPassword> <shotsDir> <apiDir>
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { originate } from './ami.mjs';
import { launch } from './cdp.mjs';
import { startPhones } from './phones.mjs';
@@ -25,7 +31,7 @@ const cli = (cmd) =>
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 [base, email, pw, shotsDir, apiDir] = process.argv.slice(2);
const until = async (what, ms, fn) => {
const end = Date.now() + ms;
for (;;) {
@@ -105,6 +111,33 @@ try {
await until('phone 241 registered', 180_000, () =>
/241\/sip:.*Avail/.test(cli('pjsip show contacts')),
);
// About, before signing in: the login page links it, "Code v…" is this build's source.
await p.goto(`${base}/login`);
await p.waitFor(`!!document.querySelector('input[type=email]')`);
await p.evaluate(
`[...document.querySelectorAll('a')].find(a => a.textContent.includes('source code')).click(), true`,
);
await p.waitFor(`location.pathname === '/about'`, 'about page');
await p.waitFor(`document.body.innerText.includes('Code v')`, 'source button');
const dl = await p.evaluate(`(async () => {
const a = [...document.querySelectorAll('a')].find(a => a.textContent.startsWith('Code v'));
const r = await fetch(a.href);
const b = new Uint8Array(await r.arrayBuffer());
return { text: a.textContent, download: a.getAttribute('download'), status: r.status,
disposition: r.headers.get('content-disposition'), bytes: b.length, gzip: b[0] === 0x1f && b[1] === 0x8b };
})()`);
const archive = readFileSync(join(apiDir, 'selfphone.tar.gz'));
assert.deepEqual(dl, {
text: `Code v${readFileSync(join(apiDir, 'VERSION'), 'utf8').trim()}`,
download: 'selfphone.tar.gz',
status: 200,
disposition: 'attachment; filename="selfphone.tar.gz"',
bytes: archive.length,
gzip: true,
});
await p.shot('m11-system-00-about');
console.log(`about (signed out): "${dl.text}" downloads selfphone.tar.gz, ${dl.bytes} bytes`);
await p.goto(`${base}/login`);
await p.waitFor(`!!document.querySelector('input[type=email]')`);
await p.fill('Email', email);
@@ -203,7 +236,55 @@ try {
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');
// System information and the event log: the restart above is in it.
const info = await p.evaluate(`document.querySelector('[aria-label=Status]').innerText`);
for (const want of ['Public IP', '203.0.113.77', 'SIP 5060', 'Media 10000–20000', 'HTTPS 443'])
assert.ok(info.includes(want), `system information lacks ${want}:\n${info}`);
await until('events in the log', 30_000, async () => {
await p.goto(`${base}/admin/system`);
await p.waitFor(`!!document.querySelector('[aria-label="Event log"]')`, 'event log');
await sleep(1000);
const codes = await p.evaluate(
`[...document.querySelectorAll('[aria-label=Events] tbody tr')].map(r => r.cells[1].innerText)`,
);
return ['telephony.restartNeeded', 'telephony.down', 'telephony.up'].every((c) =>
codes.includes(c),
);
});
const csv = await p.evaluate(
`fetch('/api/system/events.csv').then(async r => [r.status, r.headers.get('content-disposition'), await r.text()])`,
);
assert.equal(csv[0], 200);
assert.match(csv[1], /selfphoneng-events\.csv/);
assert.match(csv[2], /^time,severity,event,source,details\r\n/);
assert.match(csv[2], /telephony\.down,Telephony/);
await p.shot('m11-system-04-events');
const narrow = await launch({ width: 390, height: 900, shotsDir });
try {
await narrow.goto(`${base}/login`);
await narrow.waitFor(`!!document.querySelector('input[type=email]')`);
await narrow.fill('Email', email);
await narrow.fill('Password', pw);
await narrow.click('Sign in');
await narrow.waitFor(`location.pathname.startsWith('/admin')`, 'signed in (phone)');
await narrow.goto(`${base}/admin/system`);
await narrow.waitFor(`!!document.querySelector('[aria-label=Events]')`, 'events (phone)');
assert.equal(
await narrow.evaluate(
'document.documentElement.scrollWidth > document.documentElement.clientWidth',
),
false,
'System page scrolls sideways at 390px',
);
await narrow.shot('m11-system-05-phone');
} finally {
await narrow.close();
}
console.log(
'system information shows IP and ports; the event log has the restart (and its CSV); no sideways scroll at 390px',
);
console.log('M11 system page, about and record switch: PASS');
} finally {
await p.close();
phones.stop();