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