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);
|
||||
});
|
||||
});
|
||||
@@ -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}`),
|
||||
|
||||
@@ -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> },
|
||||
{
|
||||
|
||||
@@ -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're entitled to the source code of the version
|
||||
running here, changes included. Download it below: it'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'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'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's package.json files, each under its own license.
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'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
|
||||
“Running” and “Not needed” once it's back.
|
||||
</li>
|
||||
<li>The web app, voicemail and call history aren'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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user