system: information panel and event log; about: download this build's source
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:
1 parent
a6ba61756c
commit
0ff0f9292b
29 files changed
+5035
-79
No files matched your search
@@ -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"]
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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)`,
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user