feat(api): absorb the Hub control plane into the local instance model

Add central notification channels, rules, queue and delivery logs, fleet
organization groups and tags, device discovery, the device action catalog,
instance module reads, the log centre, connection settings and system
maintenance as native /api/v1 routes backed by the existing secret store,
audit trail and pinned upstream transport.
This commit is contained in:
chick
2026-09-05 18:53:04 +08:00
parent f11877f13e
commit f9186bd851
97 changed files with 18646 additions and 104 deletions
@@ -0,0 +1,228 @@
import Database from 'better-sqlite3';
import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { afterEach, describe, expect, it } from 'vitest';
import { migrateDatabase } from '../../infrastructure/database/migrations.js';
import {
BACKUP_COMPONENT_KEYS,
ComponentBackupError,
ComponentBackupService,
} from './component-backup-service.js';
const roots: string[] = [];
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
function database(): Database.Database {
const db = new Database(':memory:');
db.pragma('foreign_keys = ON');
migrateDatabase(db);
return db;
}
async function directory(): Promise<string> {
const root = await mkdtemp(join(tmpdir(), 'component-backup-'));
roots.push(root);
return root;
}
function seed(db: Database.Database, tag: string): void {
db.prepare('INSERT INTO device_groups (id,name,created_at,updated_at) VALUES (?,?,?,?)').run(
`group-${tag}`,
`机房 ${tag}`,
'2026-01-01T00:00:00.000Z',
'2026-01-01T00:00:00.000Z',
);
db.prepare(
`INSERT INTO instances (id,name,base_url,auth_mode,enabled,config_revision,created_at,updated_at)
VALUES (?,?,?,?,?,?,?,?)`,
).run(
`node-${tag}`,
`节点 ${tag}`,
`http://192.168.1.${tag}:8080`,
'password',
1,
1,
'2026-01-01T00:00:00.000Z',
'2026-01-01T00:00:00.000Z',
);
db.prepare('INSERT INTO instance_tags (instance_id,tag,created_at) VALUES (?,?,?)').run(
`node-${tag}`,
`tag-${tag}`,
'2026-01-01T00:00:00.000Z',
);
db.prepare('INSERT INTO tag_registry (tag,created_at,updated_at) VALUES (?,?,?)').run(
`tag-${tag}`,
'2026-01-01T00:00:00.000Z',
'2026-01-01T00:00:00.000Z',
);
db.prepare(
`INSERT INTO audit_events
(id,actor,operation_id,risk_level,request_id,parameters_summary_json,result_code,duration_ms,created_at)
VALUES (?,?,?,?,?,?,?,?,?)`,
).run(
`audit-${tag}`,
'operator',
'probe',
'R0',
`${tag}-req`,
'[]',
'succeeded',
1,
'2026-01-01T00:00:00.000Z',
);
}
describe('ComponentBackupService', () => {
it('lists every catalog unit with a live row count', async () => {
const db = database();
seed(db, '1');
const service = new ComponentBackupService(db, {
version: 'test',
backupDirectory: await directory(),
});
const catalog = service.catalog();
expect(catalog.map((entry) => entry.key)).toEqual([...BACKUP_COMPONENT_KEYS]);
const devices = catalog.find((entry) => entry.key === 'devices');
expect(devices?.rows).toBe(4);
expect(devices?.label).toBe('设备与分组');
});
it('creates a component backup that the list endpoint can verify', async () => {
const db = database();
seed(db, '1');
const backupDirectory = await directory();
const service = new ComponentBackupService(db, { version: 'test', backupDirectory });
const created = await service.create(['devices', 'audit'], '升级前快照');
expect(created.integrity).toBe('ok');
expect(created.note).toBe('升级前快照');
expect(created.components.map((entry) => entry.key)).toEqual(['devices', 'audit']);
expect((await readdir(backupDirectory)).length).toBe(1);
const listed = await service.list();
expect(listed).toHaveLength(1);
expect(listed[0]?.filename).toBe(created.filename);
expect(listed[0]?.integrity).toBe('ok');
expect(listed[0]?.compatible).toBe(true);
expect(listed[0]?.components.find((entry) => entry.key === 'audit')?.rows).toBe(1);
});
it('merges a selected component into a fresh database and leaves the rest alone', async () => {
const source = database();
seed(source, '1');
seed(source, '2');
const backupDirectory = await directory();
const service = new ComponentBackupService(source, { version: 'test', backupDirectory });
const created = await service.create(['devices', 'audit']);
const target = database();
seed(target, '9');
const restoreService = new ComponentBackupService(target, {
version: 'test',
backupDirectory,
});
const written = await restoreService.restore(created.filename, ['devices']);
expect(written).toEqual({ devices: 8 });
const instances = target.prepare('SELECT id FROM instances ORDER BY id').all() as {
id: string;
}[];
expect(instances.map((entry) => entry.id)).toEqual(['node-1', 'node-2', 'node-9']);
// The audit component was not selected, so the target keeps only its own row.
const audits = target.prepare('SELECT id FROM audit_events ORDER BY id').all() as {
id: string;
}[];
expect(audits.map((entry) => entry.id)).toEqual(['audit-9']);
});
it('refuses a tampered archive', async () => {
const db = database();
seed(db, '1');
const backupDirectory = await directory();
const service = new ComponentBackupService(db, { version: 'test', backupDirectory });
const created = await service.create(['audit']);
const path = join(backupDirectory, created.filename);
const document = JSON.parse(await readFile(path, 'utf8')) as {
components: { audit: { audit_events: { rows: unknown[][] } } };
};
const row = document.components.audit.audit_events.rows[0];
if (!row) throw new Error('expected a serialized audit row');
row[3] = 'R3';
await writeFile(path, JSON.stringify(document), 'utf8');
await expect(service.preview(created.filename)).rejects.toThrowError(ComponentBackupError);
await expect(service.restore(created.filename, ['audit'])).rejects.toMatchObject({
code: 'INTEGRITY_FAILED',
});
});
it('rejects unknown component keys and unsafe filenames', async () => {
const db = database();
const service = new ComponentBackupService(db, {
version: 'test',
backupDirectory: await directory(),
});
await expect(service.create(['nope' as never])).rejects.toMatchObject({
code: 'VALIDATION_FAILED',
});
await expect(service.preview('../secret.json')).rejects.toMatchObject({
code: 'VALIDATION_FAILED',
});
await expect(service.preview('multi-simadmin-components-missing.json')).rejects.toMatchObject({
code: 'NOT_FOUND',
});
});
it('runs one scheduled backup per period and prunes to the retained count', async () => {
const db = database();
seed(db, '1');
const backupDirectory = await directory();
let clock = new Date('2026-03-01T03:35:00.000Z');
const service = new ComponentBackupService(db, {
version: 'test',
backupDirectory,
now: () => clock,
});
service.updateAutoSettings({
enabled: true,
components: ['devices'],
timeOfDay: '03:30',
weekday: -1,
maximumCount: 2,
});
expect(await service.runDueAutoBackups()).toMatchObject({ automatic: true });
expect(await service.runDueAutoBackups()).toBeNull();
clock = new Date('2026-03-02T03:31:00.000Z');
expect(await service.runDueAutoBackups()).not.toBeNull();
clock = new Date('2026-03-03T03:31:00.000Z');
expect(await service.runDueAutoBackups()).not.toBeNull();
const files = (await readdir(backupDirectory)).filter((name) => name.includes('-auto-'));
expect(files).toHaveLength(2);
expect(service.autoSettings()).toMatchObject({ enabled: true, maximumCount: 2, weekday: -1 });
expect(service.autoSettings().lastRunAt).toBe('2026-03-03T03:31:00.000Z');
});
it('keeps the auto settings readable when the stored payload is damaged', async () => {
const db = database();
db.prepare(
'INSERT INTO app_settings (key,value_json,created_at,updated_at) VALUES (?,?,?,?)',
).run(
'system.component_backup.auto',
'{not json',
'2026-01-01T00:00:00.000Z',
'2026-01-01T00:00:00.000Z',
);
const service = new ComponentBackupService(db, {
version: 'test',
backupDirectory: await directory(),
});
expect(service.autoSettings()).toMatchObject({
enabled: false,
timeOfDay: '03:30',
components: ['devices', 'notifications', 'automation'],
});
});
});
@@ -0,0 +1,604 @@
import { createHash } from 'node:crypto';
import { mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises';
import { basename, join } from 'node:path';
import type { SqliteDatabase } from '../../infrastructure/database/database.js';
/**
* Backup units the console can export and merge back. Each unit is closed under its own foreign
* keys, so restoring one never leaves a dangling reference behind.
*/
export type BackupComponentKey =
| 'devices'
| 'notifications'
| 'notificationRecords'
| 'automation'
| 'automationRecords'
| 'sms'
| 'settings'
| 'audit';
interface ComponentSpec {
readonly label: string;
readonly description: string;
/** Parent tables first so an upsert never writes a child before its owner exists. */
readonly tables: readonly string[];
}
export const BACKUP_COMPONENTS: Readonly<Record<BackupComponentKey, ComponentSpec>> = Object.freeze(
{
devices: {
label: '设备与分组',
description: '节点地址、分组、标签与能力快照;密钥材料不会被写入备份文件。',
tables: ['device_groups', 'tag_registry', 'instances', 'instance_tags', 'capabilities'],
},
notifications: {
label: '通知配置',
description: '通知渠道与转发规则。',
tables: ['notification_channels', 'notification_rules'],
},
notificationRecords: {
label: '通知记录',
description: '待办队列与历史投递结果。',
tables: ['notification_queue', 'notification_deliveries'],
},
automation: {
label: '自动化配置',
description: '定时任务定义与执行计划。',
tables: ['scheduled_tasks'],
},
automationRecords: {
label: '自动化记录',
description: '每次定时执行的状态与结果。',
tables: ['scheduled_runs'],
},
sms: {
label: '短信记录',
description: '集中保存的跨设备短信正文与会话。',
tables: ['sms_messages'],
},
settings: {
label: '系统设置',
description: '保留策略、备份计划与控制台安全配置。',
tables: ['app_settings', 'console_auth_config'],
},
audit: {
label: '操作审计',
description: '设备操作审计账本。',
tables: ['audit_events'],
},
} as const,
);
export const BACKUP_COMPONENT_KEYS = Object.freeze(
Object.keys(BACKUP_COMPONENTS) as BackupComponentKey[],
);
const FORMAT = 'multi-simadmin-component-backup';
const FORMAT_VERSION = 1;
const AUTO_SETTINGS_KEY = 'system.component_backup.auto';
const MAX_NOTE_LENGTH = 120;
const MAX_FILE_BYTES = 256 * 1024 * 1024;
const BACKUP_FILENAME = /^multi-simadmin-components-(?:auto-)?[A-Za-z0-9._-]{1,120}\.json$/u;
interface TableSnapshot {
readonly columns: readonly string[];
readonly rows: readonly (readonly unknown[])[];
}
interface BackupDocument {
readonly format: string;
readonly formatVersion: number;
readonly appVersion: string;
readonly createdAt: string;
readonly note: string;
readonly components: Readonly<Partial<Record<BackupComponentKey, Record<string, TableSnapshot>>>>;
readonly digest: string;
}
export interface BackupComponentSummary {
readonly key: BackupComponentKey;
readonly label: string;
readonly description: string;
readonly rows: number;
}
export interface BackupSummary {
readonly filename: string;
readonly createdAt: string;
readonly sizeBytes: number;
readonly appVersion: string;
readonly formatVersion: number;
readonly note: string;
readonly automatic: boolean;
readonly integrity: 'ok' | 'failed';
readonly compatible: boolean;
readonly components: readonly BackupComponentSummary[];
}
export interface AutoBackupSettings {
readonly enabled: boolean;
readonly components: readonly BackupComponentKey[];
readonly timeOfDay: string;
readonly weekday: number;
readonly maximumCount: number;
readonly lastRunAt: string | null;
}
export class ComponentBackupError extends Error {
constructor(
readonly code:
| 'VALIDATION_FAILED'
| 'NOT_FOUND'
| 'INTEGRITY_FAILED'
| 'INCOMPATIBLE'
| 'TOO_LARGE',
) {
super(code);
this.name = 'ComponentBackupError';
}
}
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value);
function digestOf(value: unknown): string {
return createHash('sha256').update(JSON.stringify(value), 'utf8').digest('hex');
}
function tableExists(db: SqliteDatabase, table: string): boolean {
return (
(db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?").get(table) as
| { name: string }
| undefined) !== undefined
);
}
function primaryKeyOf(db: SqliteDatabase, table: string): readonly string[] {
const info = db.prepare(`PRAGMA table_info(${table})`).all() as { name: string; pk: number }[];
return info.filter((column) => column.pk > 0).map((column) => column.name);
}
function normalizeComponents(value: unknown): BackupComponentKey[] {
if (!Array.isArray(value) || value.length === 0)
throw new ComponentBackupError('VALIDATION_FAILED');
const seen = new Set<BackupComponentKey>();
for (const entry of value) {
if (typeof entry !== 'string' || !(entry in BACKUP_COMPONENTS))
throw new ComponentBackupError('VALIDATION_FAILED');
seen.add(entry as BackupComponentKey);
}
// Emit in catalog order so the serialized document, and therefore its digest, is stable.
return BACKUP_COMPONENT_KEYS.filter((key) => seen.has(key));
}
function normalizeNote(value: unknown): string {
if (value === undefined || value === null) return '';
if (typeof value !== 'string') throw new ComponentBackupError('VALIDATION_FAILED');
const trimmed = value.trim();
if (trimmed.length > MAX_NOTE_LENGTH) throw new ComponentBackupError('VALIDATION_FAILED');
return trimmed;
}
function normalizeTimeOfDay(value: unknown): string {
if (typeof value !== 'string' || !/^([01]\d|2[0-3]):[0-5]\d$/u.test(value))
throw new ComponentBackupError('VALIDATION_FAILED');
return value;
}
export interface ComponentBackupOptions {
readonly version: string;
readonly backupDirectory: string;
readonly now?: () => Date;
}
/** Component-scoped export and merge, mirroring the Hub backup centre. */
export class ComponentBackupService {
readonly #db: SqliteDatabase;
readonly #version: string;
readonly #directory: string;
readonly #now: () => Date;
constructor(db: SqliteDatabase, options: ComponentBackupOptions) {
this.#db = db;
this.#version = options.version;
this.#directory = options.backupDirectory;
this.#now = options.now ?? (() => new Date());
}
/** Live row counts for every unit the console knows how to back up. */
catalog(): readonly BackupComponentSummary[] {
return BACKUP_COMPONENT_KEYS.map((key) => ({
key,
label: BACKUP_COMPONENTS[key].label,
description: BACKUP_COMPONENTS[key].description,
rows: this.#countComponent(key),
}));
}
async create(
components: readonly BackupComponentKey[],
note: unknown = '',
automatic = false,
): Promise<BackupSummary> {
const keys = normalizeComponents(components);
const comment = normalizeNote(note);
const createdAt = this.#now().toISOString();
const payload: Record<string, Record<string, TableSnapshot>> = {};
for (const key of keys) {
const tables: Record<string, TableSnapshot> = {};
for (const table of BACKUP_COMPONENTS[key].tables) {
const snapshot = this.#snapshot(table);
if (snapshot) tables[table] = snapshot;
}
payload[key] = tables;
}
const body = { format: FORMAT, formatVersion: FORMAT_VERSION, createdAt, components: payload };
const document: BackupDocument = {
format: FORMAT,
formatVersion: FORMAT_VERSION,
appVersion: this.#version,
createdAt,
note: comment,
components: payload,
digest: digestOf(body),
};
const serialized = JSON.stringify(document);
if (Buffer.byteLength(serialized, 'utf8') > MAX_FILE_BYTES)
throw new ComponentBackupError('TOO_LARGE');
await mkdir(this.#directory, { recursive: true });
const filename = `multi-simadmin-components-${automatic ? 'auto-' : ''}${createdAt.replaceAll(/[:.]/gu, '-')}.json`;
await writeFile(join(this.#directory, filename), serialized, { mode: 0o600 });
return this.#summarize(filename, document, Buffer.byteLength(serialized, 'utf8'));
}
async list(): Promise<readonly BackupSummary[]> {
await mkdir(this.#directory, { recursive: true });
const names = (await readdir(this.#directory))
.filter((name) => BACKUP_FILENAME.test(name))
.sort()
.reverse();
const items: BackupSummary[] = [];
for (const filename of names) {
const parsed = await this.#read(filename).catch(() => undefined);
const details = await stat(join(this.#directory, filename)).catch(() => undefined);
if (!details?.isFile()) continue;
items.push(
parsed
? await this.#summarize(filename, parsed.document, details.size)
: {
filename,
createdAt: details.mtime.toISOString(),
sizeBytes: details.size,
appVersion: '',
formatVersion: 0,
note: '',
automatic: filename.includes('-auto-'),
integrity: 'failed',
compatible: false,
components: [],
},
);
}
return items;
}
async preview(filename: string): Promise<BackupSummary> {
const { document, sizeBytes } = await this.#read(this.#safeName(filename));
return this.#summarize(this.#safeName(filename), document, sizeBytes);
}
/** Merges the selected units back in; untouched components and rows stay exactly as they were. */
async restore(
filename: string,
components: readonly BackupComponentKey[],
): Promise<Partial<Record<BackupComponentKey, number>>> {
const safe = this.#safeName(filename);
const keys = normalizeComponents(components);
const { document } = await this.#read(safe);
if (document.formatVersion > FORMAT_VERSION) throw new ComponentBackupError('INCOMPATIBLE');
const written: Partial<Record<BackupComponentKey, number>> = {};
const restore = this.#db.transaction(() => {
for (const key of keys) {
const tables = isRecord(document.components[key])
? (document.components[key] as Record<string, TableSnapshot>)
: {};
let count = 0;
for (const table of BACKUP_COMPONENTS[key].tables) {
const snapshot = tables[table];
if (!snapshot || !Array.isArray(snapshot.columns) || !Array.isArray(snapshot.rows))
continue;
count += this.#merge(table, snapshot);
}
written[key] = count;
}
});
restore();
return written;
}
async remove(filename: string): Promise<{ filename: string }> {
await rm(join(this.#directory, this.#safeName(filename)), { force: true });
return { filename: this.#safeName(filename) };
}
autoSettings(): AutoBackupSettings {
return this.#readAuto();
}
updateAutoSettings(value: unknown): AutoBackupSettings {
const source = isRecord(value) ? value : {};
if (typeof source.enabled !== 'boolean') throw new ComponentBackupError('VALIDATION_FAILED');
const weekday = source.weekday;
if (
typeof weekday !== 'number' ||
!Number.isSafeInteger(weekday) ||
weekday < -1 ||
weekday > 6
)
throw new ComponentBackupError('VALIDATION_FAILED');
const maximumCount = source.maximumCount;
if (
typeof maximumCount !== 'number' ||
!Number.isSafeInteger(maximumCount) ||
maximumCount < 1 ||
maximumCount > 500
)
throw new ComponentBackupError('VALIDATION_FAILED');
const settings: AutoBackupSettings = {
enabled: source.enabled,
components: normalizeComponents(source.components),
timeOfDay: normalizeTimeOfDay(source.timeOfDay),
weekday,
maximumCount,
lastRunAt: this.#readAuto().lastRunAt,
};
const now = this.#now().toISOString();
this.#db
.prepare(
`INSERT INTO app_settings (key, value_json, created_at, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value_json = excluded.value_json, updated_at = excluded.updated_at`,
)
.run(AUTO_SETTINGS_KEY, JSON.stringify({ ...settings, lastRunAt: undefined }), now, now);
return settings;
}
/** Creates the scheduled backup when the current period has not run yet. */
async runDueAutoBackups(): Promise<BackupSummary | null> {
const settings = this.#readAuto();
if (!settings.enabled || settings.components.length === 0) return null;
const dueAt = this.#mostRecentOccurrence(settings.timeOfDay, settings.weekday);
if (settings.lastRunAt && Date.parse(settings.lastRunAt) >= dueAt.getTime()) return null;
const summary = await this.create(settings.components, '定时自动备份', true);
const stamped = this.#now().toISOString();
this.#db
.prepare(
`INSERT INTO app_settings (key, value_json, created_at, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value_json = excluded.value_json, updated_at = excluded.updated_at`,
)
.run(
AUTO_SETTINGS_KEY,
JSON.stringify({ ...settings, lastRunAt: stamped }),
stamped,
stamped,
);
await this.#prune(settings.maximumCount);
return summary;
}
#countComponent(key: BackupComponentKey): number {
let total = 0;
for (const table of BACKUP_COMPONENTS[key].tables) {
if (!tableExists(this.#db, table)) continue;
const row = this.#db.prepare(`SELECT COUNT(*) AS count FROM ${table}`).get() as
| { count: number }
| undefined;
total += Number(row?.count ?? 0);
}
return total;
}
#snapshot(table: string): TableSnapshot | undefined {
if (!tableExists(this.#db, table)) return undefined;
const info = this.#db.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[];
const columns = info.map((column) => column.name);
const rows = this.#db.prepare(`SELECT * FROM ${table} ORDER BY rowid`).all() as Record<
string,
unknown
>[];
return {
columns,
rows: rows.map((row) => columns.map((column) => row[column] ?? null)),
};
}
/** Upserts one table; a conflict updates every non-key column instead of replacing the row. */
#merge(table: string, snapshot: TableSnapshot): number {
if (!tableExists(this.#db, table)) return 0;
const columns = snapshot.columns.filter((column) => typeof column === 'string');
if (columns.length === 0) return 0;
const keys = primaryKeyOf(this.#db, table);
const placeholders = columns.map(() => '?').join(', ');
const quoted = columns.map((column) => `"${column.replaceAll('"', '""')}"`).join(', ');
let sql = `INSERT INTO ${table} (${quoted}) VALUES (${placeholders})`;
const updates = columns.filter((column) => !keys.includes(column));
if (keys.length > 0 && updates.length > 0)
sql += ` ON CONFLICT(${keys.map((key) => `"${key}"`).join(', ')}) DO UPDATE SET ${updates
.map((column) => `"${column}" = excluded."${column}"`)
.join(', ')}`;
else if (keys.length > 0)
sql += ` ON CONFLICT(${keys.map((key) => `"${key}"`).join(', ')}) DO NOTHING`;
const statement = this.#db.prepare(sql);
let written = 0;
for (const row of snapshot.rows) {
if (!Array.isArray(row) || row.length !== columns.length) continue;
statement.run(...row.map((value) => (typeof value === 'boolean' ? Number(value) : value)));
written += 1;
}
return written;
}
#safeName(filename: string): string {
if (!BACKUP_FILENAME.test(filename) || basename(filename) !== filename)
throw new ComponentBackupError('VALIDATION_FAILED');
return filename;
}
async #read(filename: string): Promise<{ document: BackupDocument; sizeBytes: number }> {
const path = join(this.#directory, filename);
const content = await readFile(path).catch((error: NodeJS.ErrnoException) => {
if (error.code === 'ENOENT') throw new ComponentBackupError('NOT_FOUND');
throw error;
});
if (content.byteLength > MAX_FILE_BYTES) throw new ComponentBackupError('TOO_LARGE');
let parsed: unknown;
try {
parsed = JSON.parse(content.toString('utf8'));
} catch {
throw new ComponentBackupError('INTEGRITY_FAILED');
}
const document = this.#validate(parsed);
return { document, sizeBytes: content.byteLength };
}
#validate(value: unknown): BackupDocument {
if (!isRecord(value) || value.format !== FORMAT)
throw new ComponentBackupError('INTEGRITY_FAILED');
const formatVersion = Number(value.formatVersion);
const components = isRecord(value.components) ? value.components : {};
const document: BackupDocument = {
format: FORMAT,
formatVersion: Number.isSafeInteger(formatVersion) ? formatVersion : 0,
appVersion: typeof value.appVersion === 'string' ? value.appVersion : '',
createdAt: typeof value.createdAt === 'string' ? value.createdAt : '',
note: typeof value.note === 'string' ? value.note : '',
components: components as BackupDocument['components'],
digest: typeof value.digest === 'string' ? value.digest : '',
};
const expected = digestOf({
format: document.format,
formatVersion: document.formatVersion,
createdAt: document.createdAt,
components: document.components,
});
if (document.digest !== expected) throw new ComponentBackupError('INTEGRITY_FAILED');
return document;
}
async #summarize(
filename: string,
document: BackupDocument,
sizeBytes: number,
): Promise<BackupSummary> {
const components: BackupComponentSummary[] = [];
for (const key of BACKUP_COMPONENT_KEYS) {
const tables = isRecord(document.components[key])
? (document.components[key] as Record<string, TableSnapshot>)
: undefined;
if (!tables) continue;
let rows = 0;
for (const snapshot of Object.values(tables))
rows += Array.isArray(snapshot?.rows) ? snapshot.rows.length : 0;
components.push({
key,
label: BACKUP_COMPONENTS[key].label,
description: BACKUP_COMPONENTS[key].description,
rows,
});
}
return {
filename,
createdAt: document.createdAt,
sizeBytes,
appVersion: document.appVersion,
formatVersion: document.formatVersion,
note: document.note,
automatic: filename.includes('-auto-'),
integrity: 'ok',
compatible: document.formatVersion <= FORMAT_VERSION,
components,
};
}
#readAuto(): AutoBackupSettings {
const row = this.#db
.prepare('SELECT value_json FROM app_settings WHERE key = ?')
.get(AUTO_SETTINGS_KEY) as { value_json: string } | undefined;
let stored: Record<string, unknown> | undefined;
try {
const parsed: unknown = row ? JSON.parse(row.value_json) : undefined;
stored = isRecord(parsed) ? parsed : undefined;
} catch {
stored = undefined;
}
const fallback = {
enabled: false,
components: ['devices', 'notifications', 'automation'] as BackupComponentKey[],
timeOfDay: '03:30',
weekday: -1,
maximumCount: 14,
};
const source: Record<string, unknown> = { ...fallback, ...(stored ?? {}) };
let components: BackupComponentKey[] = fallback.components;
try {
components = normalizeComponents(source.components);
} catch {
components = fallback.components;
}
let timeOfDay = fallback.timeOfDay;
try {
timeOfDay = normalizeTimeOfDay(source.timeOfDay);
} catch {
timeOfDay = fallback.timeOfDay;
}
const weekday =
typeof source.weekday === 'number' && Number.isSafeInteger(source.weekday)
? Math.min(6, Math.max(-1, source.weekday))
: fallback.weekday;
const maximumCount =
typeof source.maximumCount === 'number' && Number.isSafeInteger(source.maximumCount)
? Math.min(500, Math.max(1, source.maximumCount))
: fallback.maximumCount;
return {
enabled: source.enabled === true,
components,
timeOfDay,
weekday,
maximumCount,
lastRunAt: typeof source.lastRunAt === 'string' ? source.lastRunAt : null,
};
}
#mostRecentOccurrence(timeOfDay: string, weekday: number): Date {
const [hour, minute] = timeOfDay.split(':').map((part) => Number(part));
const now = this.#now();
const candidate = new Date(now);
candidate.setHours(hour ?? 0, minute ?? 0, 0, 0);
if (candidate.getTime() > now.getTime()) candidate.setDate(candidate.getDate() - 1);
if (weekday >= 0) {
let guard = 0;
while (candidate.getDay() !== weekday && guard < 8) {
candidate.setDate(candidate.getDate() - 1);
guard += 1;
}
}
return candidate;
}
/** Keeps the newest `limit` scheduled backups; a limit of zero leaves manual files alone. */
async #prune(limit: number): Promise<number> {
if (limit <= 0) return 0;
const names = (await readdir(this.#directory))
.filter((name) => name.startsWith('multi-simadmin-components-auto-'))
.sort()
.reverse();
let removed = 0;
for (const filename of names.slice(limit)) {
await rm(join(this.#directory, filename), { force: true });
removed += 1;
}
return removed;
}
}
@@ -0,0 +1,179 @@
import Database from 'better-sqlite3';
import { afterEach, describe, expect, it } from 'vitest';
import { migrateDatabase } from '../../infrastructure/database/migrations.js';
import { ConnectionLogService } from './connection-log-service.js';
const databases: Database.Database[] = [];
function fixture(maximumRows?: number) {
const db = new Database(':memory:');
db.pragma('foreign_keys = ON');
migrateDatabase(db);
databases.push(db);
db.prepare(
`INSERT INTO instances (id,name,base_url,enabled,config_revision,created_at,updated_at)
VALUES ('node-a','Node A','http://node-a.local',1,1,'2026-09-01T00:00:00.000Z','2026-09-01T00:00:00.000Z'),
('node-b','Node B','http://node-b.local',1,1,'2026-09-01T00:00:00.000Z','2026-09-01T00:00:00.000Z')`,
).run();
let counter = 0;
const service = new ConnectionLogService({
db,
idFactory: () => `log-${++counter}`,
...(maximumRows === undefined ? {} : { maximumRows }),
});
return { db, service };
}
afterEach(() => {
for (const db of databases.splice(0)) db.close();
});
describe('ConnectionLogService', () => {
it('records probes and lists them newest first with paging metadata', () => {
const { service } = fixture();
service.record({
instanceId: 'node-a',
outcome: 'success',
state: 'fresh',
errorCode: null,
httpStatus: 200,
durationMs: 12,
observedAt: '2026-09-02T00:00:00.000Z',
});
service.record({
instanceId: 'node-b',
outcome: 'failed',
state: 'unknown',
errorCode: 'ECONNREFUSED',
httpStatus: null,
durationMs: 30,
observedAt: '2026-09-03T00:00:00.000Z',
});
const page = service.list({ pageSize: 1 });
expect(page.page).toEqual({ page: 1, pageSize: 1, total: 2 });
expect(page.items.map((item) => item.instanceId)).toEqual(['node-b']);
expect(page.items[0]).toMatchObject({ errorCode: 'ECONNREFUSED', outcome: 'failed' });
});
it('filters by outcome and free-text search', () => {
const { service } = fixture();
service.record({
instanceId: 'node-a',
outcome: 'stale',
state: 'stale',
errorCode: 'AUTH_REQUIRED',
httpStatus: 401,
durationMs: 8,
observedAt: '2026-09-02T00:00:00.000Z',
});
service.record({
instanceId: 'node-a',
outcome: 'success',
state: 'fresh',
errorCode: null,
httpStatus: 200,
durationMs: 9,
observedAt: '2026-09-02T01:00:00.000Z',
});
expect(service.list({ outcome: 'stale' }).page.total).toBe(1);
expect(service.list({ search: 'auth' }).page.total).toBe(1);
expect(service.list({ search: 'node-a', outcome: 'success' }).page.total).toBe(1);
});
it('summarises availability per instance', () => {
const { service } = fixture();
for (const [index, outcome] of (
['success', 'success', 'success', 'failed'] as const
).entries()) {
service.record({
instanceId: 'node-a',
outcome,
state: outcome === 'success' ? 'fresh' : 'unknown',
errorCode: outcome === 'success' ? null : 'UPSTREAM_TIMEOUT',
httpStatus: outcome === 'success' ? 200 : null,
durationMs: 10 + index,
observedAt: `2026-09-0${index + 1}T00:00:00.000Z`,
});
}
const [summary] = service.summarize(['node-a']);
expect(summary).toMatchObject({
instanceId: 'node-a',
total: 4,
success: 3,
failed: 1,
availabilityPercent: 75,
lastErrorCode: 'UPSTREAM_TIMEOUT',
lastObservedAt: '2026-09-04T00:00:00.000Z',
});
expect(service.summarize()).toHaveLength(1);
});
it('drops the oldest rows once the rolling cap is reached', () => {
const { db, service } = fixture(100);
for (let index = 0; index < 105; index += 1) {
service.record({
instanceId: 'node-a',
outcome: 'success',
state: 'fresh',
errorCode: null,
httpStatus: 200,
durationMs: index,
observedAt: new Date(Date.UTC(2026, 0, 1, 0, 0, index)).toISOString(),
});
}
const total = (
db.prepare('SELECT COUNT(*) AS count FROM connection_logs').get() as {
count: number;
}
).count;
expect(total).toBe(100);
expect(service.list({ pageSize: 100 }).items.at(-1)).toMatchObject({ durationMs: 5 });
});
it('ignores malformed drafts instead of throwing', () => {
const { service } = fixture();
expect(
service.record({
instanceId: '',
outcome: 'success',
state: 'fresh',
durationMs: 1,
observedAt: '2026-09-02T00:00:00.000Z',
}),
).toBeUndefined();
expect(
service.record({
instanceId: 'node-a',
outcome: 'nope' as never,
state: 'fresh',
durationMs: 1,
observedAt: '2026-09-02T00:00:00.000Z',
}),
).toBeUndefined();
expect(service.list().page.total).toBe(0);
});
it('prunes by cutoff and refuses an unbounded delete', () => {
const { service } = fixture();
service.record({
instanceId: 'node-a',
outcome: 'success',
state: 'fresh',
durationMs: 1,
observedAt: '2026-01-01T00:00:00.000Z',
});
service.record({
instanceId: 'node-a',
outcome: 'success',
state: 'fresh',
durationMs: 1,
observedAt: '2026-09-01T00:00:00.000Z',
});
expect(() => service.prune({})).toThrow(/before or instanceId is required/u);
expect(service.prune({ before: '2026-06-01T00:00:00.000Z' })).toBe(1);
expect(service.list().page.total).toBe(1);
expect(service.prune({ instanceId: 'node-a' })).toBe(1);
});
});
@@ -0,0 +1,305 @@
import { randomUUID } from 'node:crypto';
import type Database from 'better-sqlite3';
export type ConnectionOutcome = 'success' | 'stale' | 'failed' | 'unsupported';
/** Mirrors the status_snapshots state domain, which also covers an expired snapshot. */
export type ProbeState = 'fresh' | 'stale' | 'expired' | 'unknown';
const OUTCOMES: readonly ConnectionOutcome[] = ['success', 'stale', 'failed', 'unsupported'];
const STATES: readonly ProbeState[] = ['fresh', 'stale', 'expired', 'unknown'];
export interface ConnectionLogEntry {
readonly id: string;
readonly instanceId: string;
readonly outcome: ConnectionOutcome;
readonly state: ProbeState;
readonly errorCode: string | null;
readonly httpStatus: number | null;
readonly durationMs: number;
readonly observedAt: string;
}
/** A probe outcome as produced by the status snapshot service on every health refresh. */
export interface ConnectionLogDraft {
readonly instanceId: string;
readonly outcome: ConnectionOutcome;
readonly state: ProbeState;
readonly errorCode?: string | null;
readonly httpStatus?: number | null;
readonly durationMs: number;
readonly observedAt: string;
}
export interface ConnectionLogQuery {
readonly page?: number;
readonly pageSize?: number;
readonly instanceId?: string | undefined;
readonly outcome?: ConnectionOutcome | undefined;
readonly search?: string | undefined;
readonly from?: string | undefined;
readonly to?: string | undefined;
}
export interface ConnectionLogPage {
readonly items: readonly ConnectionLogEntry[];
readonly page: { readonly page: number; readonly pageSize: number; readonly total: number };
}
export interface ConnectionSummary {
readonly instanceId: string;
readonly total: number;
readonly success: number;
readonly failed: number;
readonly averageDurationMs: number;
readonly lastObservedAt: string | null;
readonly lastErrorCode: string | null;
readonly availabilityPercent: number;
}
export type ConnectionLogErrorCode = 'VALIDATION_FAILED';
export class ConnectionLogError extends Error {
constructor(
readonly code: ConnectionLogErrorCode,
message: string,
) {
super(message);
this.name = 'ConnectionLogError';
}
}
export interface ConnectionLogOptions {
readonly db: Database.Database;
readonly idFactory?: () => string;
/** Rolling cap so the journal can never outgrow the rest of the control-plane database. */
readonly maximumRows?: number;
}
const DEFAULT_MAXIMUM_ROWS = 20_000;
const MAX_PAGE_SIZE = 200;
function invalid(message: string): never {
throw new ConnectionLogError('VALIDATION_FAILED', message);
}
function timestamp(value: string): number {
const parsed = Date.parse(value);
return Number.isFinite(parsed) ? parsed : Number.NaN;
}
export class ConnectionLogService {
readonly #db: Database.Database;
readonly #id: () => string;
readonly #maximumRows: number;
constructor(options: ConnectionLogOptions) {
this.#db = options.db;
this.#id = options.idFactory ?? randomUUID;
this.#maximumRows = Math.max(100, options.maximumRows ?? DEFAULT_MAXIMUM_ROWS);
}
/** Best-effort write: a failed log insert must never break a health probe. */
record(draft: ConnectionLogDraft): ConnectionLogEntry | undefined {
if (typeof draft.instanceId !== 'string' || draft.instanceId.length === 0) return undefined;
if (!OUTCOMES.includes(draft.outcome) || !STATES.includes(draft.state)) return undefined;
const observedAt =
typeof draft.observedAt === 'string' && Number.isFinite(timestamp(draft.observedAt))
? draft.observedAt
: new Date().toISOString();
const entry: ConnectionLogEntry = {
id: this.#id(),
instanceId: draft.instanceId,
outcome: draft.outcome,
state: draft.state,
errorCode: typeof draft.errorCode === 'string' ? draft.errorCode.slice(0, 64) : null,
httpStatus:
Number.isSafeInteger(draft.httpStatus) && (draft.httpStatus ?? 0) >= 100
? (draft.httpStatus as number)
: null,
durationMs: Number.isSafeInteger(draft.durationMs)
? Math.max(0, Math.min(Number.MAX_SAFE_INTEGER, draft.durationMs))
: 0,
observedAt,
};
try {
this.#db
.prepare(
`INSERT INTO connection_logs
(id,instance_id,outcome,state,error_code,http_status,duration_ms,observed_at)
VALUES (@id,@instanceId,@outcome,@state,@errorCode,@httpStatus,@durationMs,@observedAt)`,
)
.run(entry);
this.#enforceCap();
} catch {
return undefined;
}
return entry;
}
list(query: ConnectionLogQuery = {}): ConnectionLogPage {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 50;
if (!Number.isSafeInteger(page) || page < 1) invalid('page must be a positive integer');
if (!Number.isSafeInteger(pageSize) || pageSize < 1 || pageSize > MAX_PAGE_SIZE)
invalid(`pageSize must be between 1 and ${MAX_PAGE_SIZE}`);
if (query.outcome !== undefined && !OUTCOMES.includes(query.outcome))
invalid('outcome is invalid');
if (query.instanceId !== undefined && (query.instanceId.trim() === '' || !query.instanceId))
invalid('instanceId is invalid');
for (const key of ['from', 'to'] as const) {
const value = query[key];
if (value !== undefined && !Number.isFinite(timestamp(value))) invalid(`${key} is invalid`);
}
if (query.search !== undefined && query.search.length > 200) invalid('search is too long');
const where: string[] = [];
const parameters: Record<string, unknown> = {};
if (query.instanceId) {
where.push('instance_id = @instanceId');
parameters.instanceId = query.instanceId;
}
if (query.outcome) {
where.push('outcome = @outcome');
parameters.outcome = query.outcome;
}
if (query.from) {
where.push('observed_at >= @from');
parameters.from = new Date(timestamp(query.from)).toISOString();
}
if (query.to) {
where.push('observed_at <= @to');
parameters.to = new Date(timestamp(query.to)).toISOString();
}
if (query.search) {
const needle = query.search.trim().toLowerCase();
if (needle !== '') {
where.push(
`(lower(instance_id) LIKE @search OR lower(COALESCE(error_code,'')) LIKE @search
OR lower(outcome) LIKE @search OR lower(state) LIKE @search)`,
);
parameters.search = `%${needle}%`;
}
}
const clause = where.length > 0 ? ` WHERE ${where.join(' AND ')}` : '';
const total = (
this.#db
.prepare(`SELECT COUNT(*) AS count FROM connection_logs${clause}`)
.get(parameters) as { count: number }
).count;
const rows = this.#db
.prepare(
`SELECT id,instance_id,outcome,state,error_code,http_status,duration_ms,observed_at
FROM connection_logs${clause}
ORDER BY observed_at DESC, rowid DESC LIMIT @limit OFFSET @offset`,
)
.all({ ...parameters, limit: pageSize, offset: (page - 1) * pageSize }) as Array<
Record<string, unknown>
>;
return Object.freeze({
items: Object.freeze(rows.map((row) => this.#entry(row))),
page: Object.freeze({ page, pageSize, total }),
});
}
/** Availability rollup over the retained window, newest probe first. */
summarize(instanceIds?: readonly string[]): readonly ConnectionSummary[] {
const ids = instanceIds ? [...instanceIds] : undefined;
// better-sqlite3 cannot expand an array binding, so the IN list is built positionally.
const placeholders = ids ? ids.map((_id, index) => `@id${index}`).join(', ') : '';
const clause = ids ? ` WHERE instance_id IN (${placeholders})` : '';
const parameters: Record<string, unknown> = {};
if (ids) ids.forEach((id, index) => (parameters[`id${index}`] = id));
const rows = this.#db
.prepare(
`SELECT instance_id,
COUNT(*) AS total,
SUM(CASE WHEN outcome = 'success' THEN 1 ELSE 0 END) AS success,
SUM(CASE WHEN outcome IN ('failed','unsupported') THEN 1 ELSE 0 END) AS failed,
AVG(duration_ms) AS average_duration,
MAX(observed_at) AS last_observed_at
FROM connection_logs${clause}
GROUP BY instance_id`,
)
.all(parameters) as Array<Record<string, unknown>>;
const summaries = rows.map((row) => {
const instanceId = String(row.instance_id ?? '');
const total = Number(row.total ?? 0);
const success = Number(row.success ?? 0);
const lastObservedAt = row.last_observed_at === null ? null : String(row.last_observed_at);
const last = this.#db
.prepare(
`SELECT error_code FROM connection_logs
WHERE instance_id = ? ORDER BY observed_at DESC, rowid DESC LIMIT 1`,
)
.get(instanceId) as { error_code: string | null } | undefined;
return Object.freeze({
instanceId,
total,
success,
failed: Number(row.failed ?? 0),
averageDurationMs: Math.round(Number(row.average_duration ?? 0)),
lastObservedAt,
lastErrorCode: last?.error_code ?? null,
availabilityPercent: total > 0 ? Math.round((success / total) * 1000) / 10 : 0,
}) satisfies ConnectionSummary;
});
return Object.freeze(summaries);
}
prune(input: {
readonly before?: string | undefined;
readonly instanceId?: string | undefined;
}): number {
const where: string[] = [];
const parameters: Record<string, unknown> = {};
if (input.before) {
const cutoff = timestamp(input.before);
if (!Number.isFinite(cutoff)) invalid('before is invalid');
where.push('observed_at < @before');
parameters.before = new Date(cutoff).toISOString();
}
if (input.instanceId) {
where.push('instance_id = @instanceId');
parameters.instanceId = input.instanceId;
}
if (where.length === 0) invalid('before or instanceId is required');
return Number(
this.#db.prepare(`DELETE FROM connection_logs WHERE ${where.join(' AND ')}`).run(parameters)
.changes,
);
}
#enforceCap(): void {
const total = (
this.#db.prepare('SELECT COUNT(*) AS count FROM connection_logs').get() as {
count: number;
}
).count;
if (total <= this.#maximumRows) return;
this.#db
.prepare(
`DELETE FROM connection_logs WHERE rowid IN (
SELECT rowid FROM connection_logs ORDER BY observed_at DESC, rowid DESC
LIMIT -1 OFFSET @keep
)`,
)
.run({ keep: this.#maximumRows });
}
#entry(row: Record<string, unknown>): ConnectionLogEntry {
return Object.freeze({
id: String(row.id),
instanceId: String(row.instance_id),
outcome: row.outcome as ConnectionOutcome,
state: row.state as ProbeState,
errorCode:
row.error_code === null || row.error_code === undefined ? null : String(row.error_code),
httpStatus:
row.http_status === null || row.http_status === undefined ? null : Number(row.http_status),
durationMs: Number(row.duration_ms ?? 0),
observedAt: String(row.observed_at),
});
}
}
@@ -0,0 +1,189 @@
import Database from 'better-sqlite3';
import { afterEach, describe, expect, it } from 'vitest';
import { migrateDatabase } from '../../infrastructure/database/migrations.js';
import { ConnectionLogService } from './connection-log-service.js';
import { LogCenterService } from './log-center-service.js';
const databases: Database.Database[] = [];
function fixture() {
const db = new Database(':memory:');
db.pragma('foreign_keys = ON');
migrateDatabase(db);
databases.push(db);
db.prepare(
`INSERT INTO instances (id,name,base_url,enabled,config_revision,created_at,updated_at)
VALUES ('node-a','Node A','http://node-a.local',1,3,'2026-09-01T00:00:00.000Z','2026-09-01T00:00:00.000Z')`,
).run();
db.prepare(
`INSERT INTO instance_tags (instance_id,tag,created_at) VALUES ('node-a','lab','2026-09-01T00:00:00.000Z')`,
).run();
const connections = new ConnectionLogService({ db });
return { db, connections, logs: new LogCenterService({ db, connections }) };
}
afterEach(() => {
for (const db of databases.splice(0)) db.close();
});
function seed(db: Database.Database): void {
db.prepare(
`INSERT INTO event_journal (public_id,envelope_json)
VALUES ('evt-1',@envelope)`,
).run({
envelope: JSON.stringify({
id: 'evt-1',
kind: 'instance.updated',
instanceId: 'node-a',
occurredAt: '2026-09-04T02:00:00.000Z',
}),
});
db.prepare(
`INSERT INTO audit_events
(id,instance_id,actor,operation_id,risk_level,request_id,parameters_summary_json,result_code,duration_ms,created_at)
VALUES ('aud-1','node-a','console','restart-service','R1','req-1','{}','failed',42,'2026-09-04T03:00:00.000Z')`,
).run();
db.prepare(
`INSERT INTO scheduled_tasks
(id,name,operation_type,enabled,version,cron_expression,timezone,target_selector_json,
misfire_policy,overlap_policy,retry_policy_json,created_by,updated_by,created_at,updated_at)
VALUES ('task-1','夜间重启','restart-service',1,1,'0 3 * * *','Asia/Shanghai','{}',
'skip','skip','{"maximumAttempts":1}','console','console','2026-09-01T00:00:00.000Z','2026-09-01T00:00:00.000Z')`,
).run();
db.prepare(
`INSERT INTO scheduled_runs
(id,scheduled_task_id,schedule_version,task_snapshot_json,due_at,claimed_at,started_at,finished_at,
target_snapshot_json,outcome,reason,job_ids_json,trigger_source,attempt)
VALUES ('run-1','task-1',1,'{"name":"夜间重启"}','2026-09-04T04:00:00.000Z','2026-09-04T04:00:00.000Z',
'2026-09-04T04:00:00.000Z','2026-09-04T04:00:05.000Z','[]','failed','UPSTREAM_TIMEOUT','[]','scheduled',1)`,
).run();
db.prepare(
`INSERT INTO notification_deliveries
(id,rule_id,channel_id,instance_id,event_type,status,detail,created_at,sent_at)
VALUES ('del-1',NULL,NULL,'node-a','sms','failed','smtp refused','2026-09-04T05:00:00.000Z',NULL)`,
).run();
}
describe('LogCenterService', () => {
it('merges every persisted activity source into one timeline', () => {
const { db, logs } = fixture();
seed(db);
const page = logs.listRuntimeLogs();
expect(page.page.total).toBe(4);
expect(page.items.map((item) => item.source)).toEqual([
'delivery',
'schedule',
'audit',
'event',
]);
expect(page.counts).toEqual({ event: 1, audit: 1, schedule: 1, delivery: 1 });
expect(page.items[1]).toMatchObject({ level: 'error', code: 'failed' });
});
it('filters the timeline by level, source and search text', () => {
const { db, logs } = fixture();
seed(db);
expect(logs.listRuntimeLogs({ level: 'error' }).items.map((item) => item.source)).toEqual([
'delivery',
'schedule',
'audit',
]);
expect(logs.listRuntimeLogs({ source: 'event' }).page.total).toBe(1);
expect(logs.listRuntimeLogs({ search: 'smtp' }).page.total).toBe(1);
expect(logs.listRuntimeLogs({ from: '2026-09-04T04:30:00.000Z' }).page.total).toBe(1);
expect(logs.listRuntimeLogs({ instanceId: 'node-a' }).page.total).toBe(3);
});
it('rejects malformed pagination and unknown enum values', () => {
const { logs } = fixture();
expect(() => logs.listRuntimeLogs({ page: 0 })).toThrow(/positive integer/u);
expect(() => logs.listRuntimeLogs({ pageSize: 500 })).toThrow(/between 1 and 200/u);
expect(() => logs.listRuntimeLogs({ source: 'nope' as never })).toThrow(/source is invalid/u);
expect(() => logs.listRuntimeLogs({ from: 'yesterday' })).toThrow(/from is invalid/u);
});
it('reports connection snapshots as device health for the live probe path', () => {
const { db, connections, logs } = fixture();
db.prepare(
`INSERT INTO status_snapshots
(id,instance_id,category,state,payload_json,observed_at,expires_at,created_at)
VALUES ('snap-1','node-a','connection','fresh','{"reachable":true,"authenticated":false}',
'2026-09-04T06:00:00.000Z','2026-09-04T06:00:30.000Z','2026-09-04T06:00:00.000Z')`,
).run();
db.prepare(
`INSERT INTO capabilities
(instance_id,operation_id,state,observed_at,created_at,updated_at)
VALUES ('node-a','restart-service','supported','2026-09-04T06:00:00.000Z',
'2026-09-04T06:00:00.000Z','2026-09-04T06:00:00.000Z'),
('node-a','reboot-system','unsupported','2026-09-04T06:00:00.000Z',
'2026-09-04T06:00:00.000Z','2026-09-04T06:00:00.000Z')`,
).run();
connections.record({
instanceId: 'node-a',
outcome: 'stale',
state: 'stale',
errorCode: 'AUTH_REQUIRED',
httpStatus: 401,
durationMs: 15,
observedAt: '2026-09-04T06:00:00.000Z',
});
const [device] = logs.diagnostics();
expect(device).toMatchObject({
instanceId: 'node-a',
name: 'Node A',
enabled: true,
revision: 3,
tags: ['lab'],
capabilities: { total: 2, supported: 1, unsupported: 1 },
});
expect(device?.health).toMatchObject({
category: 'connection',
status: 'reachable',
authenticated: false,
errorCode: 'AUTH_REQUIRED',
});
expect(device?.connections).toMatchObject({ total: 1, availabilityPercent: 0, failed: 0 });
});
it('prefers a full health envelope when both snapshot categories exist', () => {
const { db, logs } = fixture();
for (const [category, payload] of [
['connection', '{"reachable":true,"authenticated":true}'],
[
'health',
'{"schemaVersion":1,"data":{"status":"ok","version":"1.9.6","platform":"linux"},' +
'"errorCode":null,"httpStatus":200,"fetchedAt":"2026-09-04T06:00:00.000Z","durationMs":21,' +
'"freshness":"fresh","supported":true,"dataFetchedAt":null}',
],
] as const) {
db.prepare(
`INSERT INTO status_snapshots
(id,instance_id,category,state,payload_json,observed_at,expires_at,created_at)
VALUES (?, 'node-a', ?, 'fresh', ?, '2026-09-04T06:00:00.000Z', NULL, '2026-09-04T06:00:00.000Z')`,
).run(`snap-${category}`, category, payload);
}
const [device] = logs.diagnostics();
expect(device?.health).toMatchObject({
category: 'health',
version: '1.9.6',
platform: 'linux',
status: 'ok',
durationMs: 21,
httpStatus: 200,
});
expect(device?.snapshots.map((snapshot) => snapshot.category)).toEqual([
'connection',
'health',
]);
});
it('surfaces the most recent failed operations per device', () => {
const { db, logs } = fixture();
seed(db);
const [device] = logs.diagnostics();
expect(device?.recentFailures).toEqual([
{ occurredAt: '2026-09-04T03:00:00.000Z', code: 'failed', message: 'restart-service' },
]);
});
});
@@ -0,0 +1,445 @@
import type Database from 'better-sqlite3';
import type { ConnectionLogService, ConnectionSummary } from './connection-log-service.js';
export type LogSource = 'event' | 'audit' | 'schedule' | 'delivery';
export type LogLevel = 'info' | 'warning' | 'error';
const SOURCES: readonly LogSource[] = ['event', 'audit', 'schedule', 'delivery'];
const LEVELS: readonly LogLevel[] = ['info', 'warning', 'error'];
export interface RuntimeLogEntry {
readonly id: string;
readonly source: LogSource;
readonly level: LogLevel;
readonly occurredAt: string;
readonly instanceId: string | null;
readonly message: string;
readonly code: string | null;
readonly actor: string | null;
readonly durationMs: number | null;
}
export interface RuntimeLogQuery {
readonly page?: number;
readonly pageSize?: number;
readonly source?: LogSource | undefined;
readonly level?: LogLevel | undefined;
readonly instanceId?: string | undefined;
readonly search?: string | undefined;
readonly from?: string | undefined;
readonly to?: string | undefined;
}
export interface RuntimeLogPage {
readonly items: readonly RuntimeLogEntry[];
readonly page: { readonly page: number; readonly pageSize: number; readonly total: number };
readonly counts: Readonly<Record<LogSource, number>>;
}
export interface InstanceDiagnostics {
readonly instanceId: string;
readonly name: string;
readonly origin: string;
readonly enabled: boolean;
readonly revision: number;
readonly tags: readonly string[];
readonly health: {
readonly category: 'health' | 'connection';
readonly state: string;
readonly observedAt: string;
readonly errorCode: string | null;
readonly httpStatus: number | null;
readonly durationMs: number | null;
readonly version: string | null;
readonly platform: string | null;
readonly status: string | null;
readonly authenticated: boolean | null;
} | null;
readonly snapshots: readonly {
readonly category: string;
readonly state: string;
readonly observedAt: string;
}[];
readonly capabilities: {
readonly total: number;
readonly supported: number;
readonly unsupported: number;
readonly authRequired: number;
readonly degraded: number;
readonly unknown: number;
};
readonly connections: ConnectionSummary | null;
readonly recentFailures: readonly {
readonly occurredAt: string;
readonly code: string | null;
readonly message: string;
}[];
}
export type LogCenterErrorCode = 'VALIDATION_FAILED';
export class LogCenterError extends Error {
constructor(
readonly code: LogCenterErrorCode,
message: string,
) {
super(message);
this.name = 'LogCenterError';
}
}
export interface LogCenterOptions {
readonly db: Database.Database;
readonly connections: ConnectionLogService;
}
const MAX_PAGE_SIZE = 200;
function invalid(message: string): never {
throw new LogCenterError('VALIDATION_FAILED', message);
}
function instant(value: string): string | undefined {
const parsed = Date.parse(value);
return Number.isFinite(parsed) ? new Date(parsed).toISOString() : undefined;
}
/**
* One UNION over the tables that already persist control-plane activity. Nothing new is
* written here, so the timeline stays consistent with the pages that own each source.
*/
const TIMELINE_SQL = `
SELECT id, source, level, occurred_at, instance_id, message, code, actor, duration_ms FROM (
SELECT e.public_id || ':' || e.sequence AS id,
'event' AS source,
'info' AS level,
json_extract(e.envelope_json,'$.occurredAt') AS occurred_at,
json_extract(e.envelope_json,'$.instanceId') AS instance_id,
'事件 ' || json_extract(e.envelope_json,'$.kind') || ' ' || json_extract(e.envelope_json,'$.id')
AS message,
json_extract(e.envelope_json,'$.kind') AS code,
NULL AS actor,
NULL AS duration_ms
FROM event_journal e
UNION ALL
SELECT a.id,
'audit',
CASE WHEN a.result_code = 'success' THEN 'info' ELSE 'error' END,
a.created_at,
a.instance_id,
'操作 ' || a.operation_id || ' 结果 ' || a.result_code,
a.result_code,
a.actor,
a.duration_ms
FROM audit_events a
UNION ALL
SELECT r.id,
'schedule',
CASE
WHEN r.outcome IN ('failed','needs-attention') THEN 'error'
WHEN r.outcome IN ('partially-succeeded','skipped','no-targets') THEN 'warning'
ELSE 'info'
END,
COALESCE(r.finished_at, r.due_at),
NULL,
'定时任务 ' || COALESCE(json_extract(r.task_snapshot_json,'$.name'), r.scheduled_task_id)
|| ' 结果 ' || COALESCE(r.outcome, 'running')
|| COALESCE(' 原因 ' || r.reason, ''),
COALESCE(r.outcome, 'running'),
NULL,
CAST((julianday(COALESCE(r.finished_at, r.claimed_at)) - julianday(r.started_at))
* 86400000 AS INTEGER)
FROM scheduled_runs r
UNION ALL
SELECT d.id,
'delivery',
CASE
WHEN d.status = 'failed' THEN 'error'
WHEN d.status IN ('unmatched','no_available_channel','quiet_hours','rate_limited')
THEN 'warning'
ELSE 'info'
END,
d.created_at,
d.instance_id,
'通知 ' || d.event_type || ' 状态 ' || d.status
|| COALESCE(' 详情 ' || d.detail, ''),
d.status,
NULL,
NULL
FROM notification_deliveries d
)`;
export class LogCenterService {
readonly #db: Database.Database;
readonly #connections: ConnectionLogService;
constructor(options: LogCenterOptions) {
this.#db = options.db;
this.#connections = options.connections;
}
listRuntimeLogs(query: RuntimeLogQuery = {}): RuntimeLogPage {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 50;
if (!Number.isSafeInteger(page) || page < 1) invalid('page must be a positive integer');
if (!Number.isSafeInteger(pageSize) || pageSize < 1 || pageSize > MAX_PAGE_SIZE)
invalid(`pageSize must be between 1 and ${MAX_PAGE_SIZE}`);
if (query.source !== undefined && !SOURCES.includes(query.source)) invalid('source is invalid');
if (query.level !== undefined && !LEVELS.includes(query.level)) invalid('level is invalid');
if (query.search !== undefined && query.search.length > 200) invalid('search is too long');
if (query.instanceId !== undefined && query.instanceId.trim() === '')
invalid('instanceId is invalid');
const bounds: Record<string, string> = {};
for (const key of ['from', 'to'] as const) {
const value = query[key];
if (value === undefined) continue;
const normalized = instant(value);
if (!normalized) invalid(`${key} is invalid`);
bounds[key] = normalized;
}
const where: string[] = [];
const parameters: Record<string, unknown> = {};
if (query.source) {
where.push('source = @source');
parameters.source = query.source;
}
if (query.level) {
where.push('level = @level');
parameters.level = query.level;
}
if (query.instanceId) {
where.push('instance_id = @instanceId');
parameters.instanceId = query.instanceId;
}
if (bounds.from) {
where.push('occurred_at >= @from');
parameters.from = bounds.from;
}
if (bounds.to) {
where.push('occurred_at <= @to');
parameters.to = bounds.to;
}
const needle = query.search?.trim().toLowerCase() ?? '';
if (needle !== '') {
where.push(
`(lower(message) LIKE @search OR lower(COALESCE(code,'')) LIKE @search
OR lower(COALESCE(actor,'')) LIKE @search OR lower(COALESCE(instance_id,'')) LIKE @search)`,
);
parameters.search = `%${needle}%`;
}
const clause = where.length > 0 ? ` WHERE ${where.join(' AND ')}` : '';
const total = (
this.#db
.prepare(`SELECT COUNT(*) AS count FROM (${TIMELINE_SQL})${clause}`)
.get(parameters) as { count: number }
).count;
const rows = this.#db
.prepare(
`SELECT id, source, level, occurred_at, instance_id, message, code, actor, duration_ms
FROM (${TIMELINE_SQL})${clause}
ORDER BY occurred_at DESC LIMIT @limit OFFSET @offset`,
)
.all({ ...parameters, limit: pageSize, offset: (page - 1) * pageSize }) as Array<
Record<string, unknown>
>;
const counts = Object.fromEntries(SOURCES.map((source) => [source, 0])) as Record<
LogSource,
number
>;
const grouped = this.#db
.prepare(`SELECT source, COUNT(*) AS count FROM (${TIMELINE_SQL}) GROUP BY source`)
.all() as Array<{ source: string; count: number }>;
for (const row of grouped) {
if (SOURCES.includes(row.source as LogSource)) counts[row.source as LogSource] = row.count;
}
return Object.freeze({
items: Object.freeze(rows.map((row) => this.#entry(row))),
page: Object.freeze({ page, pageSize, total }),
counts: Object.freeze(counts),
});
}
/** Per-instance diagnostics for the whole fleet, newest failure surfaced first. */
diagnostics(): readonly InstanceDiagnostics[] {
const instances = this.#db
.prepare(
`SELECT id,name,base_url,enabled,config_revision FROM instances ORDER BY name COLLATE NOCASE`,
)
.all() as Array<Record<string, unknown>>;
const tagRows = this.#db.prepare('SELECT instance_id, tag FROM instance_tags').all() as Array<{
instance_id: string;
tag: string;
}>;
const tagsByInstance = new Map<string, string[]>();
for (const row of tagRows) {
const bucket = tagsByInstance.get(row.instance_id) ?? [];
bucket.push(row.tag);
tagsByInstance.set(row.instance_id, bucket);
}
const summaries = new Map(
this.#connections.summarize().map((summary) => [summary.instanceId, summary]),
);
return Object.freeze(
instances.map((instance) =>
Object.freeze(this.#diagnostics(instance, tagsByInstance, summaries)),
),
);
}
#diagnostics(
instance: Record<string, unknown>,
tagsByInstance: ReadonlyMap<string, readonly string[]>,
summaries: ReadonlyMap<string, ConnectionSummary>,
): InstanceDiagnostics {
const instanceId = String(instance.id);
// The control plane persists 'health' envelopes only when the snapshot service runs; the
// live reachability path writes 'connection' rows, so diagnostics reads whichever exists.
const snapshot = this.#db
.prepare(
`SELECT category, state, payload_json, observed_at FROM status_snapshots
WHERE instance_id = ? AND category IN ('health','connection')
ORDER BY CASE WHEN category = 'health' THEN 0 ELSE 1 END LIMIT 1`,
)
.get(instanceId) as
| { category: string; state: string; payload_json: string; observed_at: string }
| undefined;
const envelope = parseRecord(snapshot?.payload_json);
const data = parseRecord(envelope?.data) ?? envelope;
const capability = this.#db
.prepare(
`SELECT
COUNT(*) AS total,
SUM(CASE WHEN state = 'supported' THEN 1 ELSE 0 END) AS supported,
SUM(CASE WHEN state = 'unsupported' THEN 1 ELSE 0 END) AS unsupported,
SUM(CASE WHEN state = 'auth-required' THEN 1 ELSE 0 END) AS auth_required,
SUM(CASE WHEN state = 'degraded' THEN 1 ELSE 0 END) AS degraded,
SUM(CASE WHEN state = 'unknown' THEN 1 ELSE 0 END) AS unknown_state
FROM capabilities WHERE instance_id = ?`,
)
.get(instanceId) as Record<string, number | null> | undefined;
const snapshots = this.#db
.prepare(
`SELECT category, state, observed_at FROM status_snapshots
WHERE instance_id = ? ORDER BY category`,
)
.all(instanceId) as Array<{ category: string; state: string; observed_at: string }>;
const failures = this.#db
.prepare(
`SELECT id, operation_id, result_code, created_at FROM audit_events
WHERE instance_id = ? AND result_code <> 'success'
ORDER BY created_at DESC LIMIT 5`,
)
.all(instanceId) as Array<{
id: string;
operation_id: string;
result_code: string;
created_at: string;
}>;
return {
instanceId,
name: String(instance.name ?? instanceId),
origin: String(instance.base_url ?? ''),
enabled: Number(instance.enabled ?? 0) === 1,
revision: Number(instance.config_revision ?? 1),
tags: Object.freeze([...(tagsByInstance.get(instanceId) ?? [])]),
health: snapshot ? Object.freeze(this.#health(snapshot, envelope, data)) : null,
snapshots: Object.freeze(
snapshots.map((row) =>
Object.freeze({ category: row.category, state: row.state, observedAt: row.observed_at }),
),
),
capabilities: Object.freeze({
total: Number(capability?.total ?? 0),
supported: Number(capability?.supported ?? 0),
unsupported: Number(capability?.unsupported ?? 0),
authRequired: Number(capability?.auth_required ?? 0),
degraded: Number(capability?.degraded ?? 0),
unknown: Number(capability?.unknown_state ?? 0),
}),
connections: summaries.get(instanceId) ?? null,
recentFailures: Object.freeze(
failures.map((row) =>
Object.freeze({
occurredAt: row.created_at,
code: row.result_code,
message: row.operation_id,
}),
),
),
};
}
#entry(row: Record<string, unknown>): RuntimeLogEntry {
return Object.freeze({
id: String(row.id),
source: row.source as LogSource,
level: row.level as LogLevel,
occurredAt: String(row.occurred_at ?? ''),
instanceId:
row.instance_id === null || row.instance_id === undefined ? null : String(row.instance_id),
message: String(row.message ?? ''),
code: row.code === null || row.code === undefined ? null : String(row.code),
actor: row.actor === null || row.actor === undefined ? null : String(row.actor),
durationMs:
row.duration_ms === null || row.duration_ms === undefined ? null : Number(row.duration_ms),
});
}
#health(
snapshot: { category: string; state: string; observed_at: string },
envelope: Record<string, unknown> | undefined,
data: Record<string, unknown> | undefined,
): NonNullable<InstanceDiagnostics['health']> {
const category = snapshot.category === 'connection' ? 'connection' : 'health';
if (category === 'connection') {
const authenticated =
typeof envelope?.authenticated === 'boolean' ? envelope.authenticated : null;
const reachable = typeof envelope?.reachable === 'boolean' ? envelope.reachable : null;
return Object.freeze({
category,
state: snapshot.state,
observedAt: snapshot.observed_at,
errorCode: authenticated === false ? 'AUTH_REQUIRED' : null,
httpStatus: null,
durationMs: null,
version: null,
platform: null,
status: reachable === null ? null : reachable ? 'reachable' : 'unreachable',
authenticated,
});
}
return Object.freeze({
category,
state: snapshot.state,
observedAt: snapshot.observed_at,
errorCode: typeof envelope?.errorCode === 'string' ? envelope.errorCode : null,
httpStatus: typeof envelope?.httpStatus === 'number' ? envelope.httpStatus : null,
durationMs: typeof envelope?.durationMs === 'number' ? envelope.durationMs : null,
version: typeof data?.version === 'string' ? data.version : null,
platform: typeof data?.platform === 'string' ? data.platform : null,
status: typeof data?.status === 'string' ? data.status : null,
authenticated: null,
});
}
}
/** Accepts either a JSON string or an already-decoded object so both snapshot shapes work. */
function parseRecord(value: unknown): Record<string, unknown> | undefined {
if (value !== null && typeof value === 'object' && !Array.isArray(value))
return value as Record<string, unknown>;
if (typeof value !== 'string' || value === '') return undefined;
try {
const parsed: unknown = JSON.parse(value);
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return undefined;
return parsed as Record<string, unknown>;
} catch {
return undefined;
}
}
@@ -0,0 +1,243 @@
import Database from 'better-sqlite3';
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { afterEach, describe, expect, it } from 'vitest';
import { migrateDatabase } from '../../infrastructure/database/migrations.js';
import { SystemMaintenanceService } from './system-maintenance-service.js';
const roots: string[] = [];
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
function database(): Database.Database {
const db = new Database(':memory:');
db.pragma('foreign_keys = ON');
migrateDatabase(db);
return db;
}
function insertAudit(db: Database.Database, id: string, createdAt: string): void {
db.prepare(
`INSERT INTO audit_events
(id,actor,operation_id,risk_level,request_id,parameters_summary_json,result_code,duration_ms,created_at)
VALUES (?,?,?,?,?,?,?,?,?)`,
).run(id, 'operator', 'probe', 'R0', `${id}-request`, '{}', 'success', 1, createdAt);
}
function insertJob(db: Database.Database, id: string, createdAt: string): void {
db.prepare(
`INSERT INTO jobs
(id,operation_id,risk_level,status,requested_by,request_id,parameters_digest,created_at,updated_at)
VALUES (?,?,?,?,?,?,?,?,?)`,
).run(
id,
'probe',
'R0',
'succeeded',
'operator',
`${id}-request`,
'0'.repeat(64),
createdAt,
createdAt,
);
}
function insertEvent(db: Database.Database, publicId: string, envelope: unknown): void {
db.prepare('INSERT INTO event_journal (public_id,envelope_json) VALUES (?,?)').run(
publicId,
JSON.stringify(envelope),
);
}
describe('SystemMaintenanceService', () => {
it('reports runtime storage, component counts, and Hub-compatible default retention', async () => {
const db = database();
insertAudit(db, 'audit-1', '2026-01-01T00:00:00.000Z');
insertJob(db, 'job-1', '2026-01-01T00:00:00.000Z');
insertEvent(db, 'event-1', { kind: 'test' });
const service = new SystemMaintenanceService(db, { version: '1.9.9' });
const overview = await service.overview();
expect(overview.runtime).toMatchObject({
version: '1.9.9',
platform: process.platform,
arch: process.arch,
});
expect(overview.runtime.uptimeSeconds).toBeGreaterThanOrEqual(0);
expect(overview.storage.databaseBytes).toBeGreaterThan(0);
expect(
Object.fromEntries(overview.storage.components.map((item) => [item.key, item.count])),
).toEqual({
instances: 0,
statusSnapshots: 0,
jobs: 1,
auditEvents: 1,
scheduledRuns: 0,
notificationQueue: 0,
smsMessages: 0,
eventJournal: 1,
connectionLogs: 0,
});
expect(overview.retention.auditEvents).toEqual({
enabled: true,
days: 180,
maximumCount: 50_000,
});
expect(overview.retention.jobs).toEqual({
enabled: true,
days: 90,
maximumCount: 5_000,
});
});
it('persists validated retention policies and cleans selected components', async () => {
const db = database();
const task = {
name: 'Night restart',
operationType: 'restart-service' as const,
cronExpression: '0 2 * * *',
timezone: 'Asia/Shanghai' as const,
targetSelector: { mode: 'fixed' as const, instanceIds: ['instance-a'] },
misfirePolicy: 'skip' as const,
overlapPolicy: 'skip' as const,
retryPolicy: { maxRetries: 0, intervalSeconds: 60 },
enabled: false,
};
db.prepare(
`INSERT INTO scheduled_tasks
(id,name,operation_type,enabled,version,cron_expression,timezone,target_selector_json,
misfire_policy,overlap_policy,retry_policy_json,created_by,updated_by,created_at,updated_at)
VALUES ('task-1',?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
).run(
task.name,
task.operationType,
0,
1,
task.cronExpression,
task.timezone,
JSON.stringify(task.targetSelector),
task.misfirePolicy,
task.overlapPolicy,
JSON.stringify(task.retryPolicy),
'operator',
'operator',
'2026-01-01T00:00:00.000Z',
'2026-01-01T00:00:00.000Z',
);
db.prepare(
`INSERT INTO scheduled_runs
(id,scheduled_task_id,schedule_version,task_snapshot_json,due_at,claimed_at,
target_snapshot_json,job_ids_json,trigger_source,attempt)
VALUES ('run-old','task-1',1,?,'2026-01-01T02:00:00.000Z','2026-01-01T02:00:00.000Z','[]','[]','scheduled',1)`,
).run(JSON.stringify(task));
insertAudit(db, 'audit-old', '2026-01-01T00:00:00.000Z');
insertAudit(db, 'audit-new', '2026-09-01T00:00:00.000Z');
insertJob(db, 'job-old', '2026-01-01T00:00:00.000Z');
insertEvent(db, 'event-old', { kind: 'old' });
const service = new SystemMaintenanceService(db, {
version: '1.9.9',
now: () => new Date('2026-09-03T00:00:00.000Z'),
});
await service.updateRetention({
auditEvents: { enabled: true, days: 30, maximumCount: 1 },
jobs: { enabled: true, days: 1, maximumCount: 100 },
eventJournal: { enabled: true, days: 1, maximumCount: 100 },
});
const result = await service.cleanup(['auditEvents', 'jobs', 'eventJournal', 'scheduledRuns']);
expect(result).toEqual({
auditEvents: 1,
jobs: 1,
eventJournal: 1,
scheduledRuns: 1,
});
const counts = (table: string) =>
(db.prepare(`SELECT COUNT(*) AS count FROM ${table}`).get() as { count: number }).count;
expect(counts('audit_events')).toBe(1);
expect(counts('jobs')).toBe(0);
expect(counts('event_journal')).toBe(0);
expect(counts('scheduled_runs')).toBe(0);
expect((await service.overview()).retention.auditEvents).toEqual({
enabled: true,
days: 30,
maximumCount: 1,
});
});
it('rejects unknown components and invalid retention values without touching settings', async () => {
const db = database();
const service = new SystemMaintenanceService(db, { version: '1.9.9' });
await expect(service.cleanup(['unknown' as never])).rejects.toThrow('Unknown data component');
await expect(
service.updateRetention({ auditEvents: { enabled: true, days: 0, maximumCount: 1 } }),
).rejects.toThrow('Retention days must be between');
expect(await service.getRetention()).toEqual(await service.defaultRetention());
});
it('optimizes SQLite and creates validated, listed backups with retention pruning', async () => {
const db = database();
const backupRoot = await mkdtemp(join(tmpdir(), 'multi-simadmin-backup-'));
roots.push(backupRoot);
const service = new SystemMaintenanceService(db, {
version: '1.9.9',
backupDirectory: backupRoot,
maximumBackups: 2,
});
expect(await service.optimize()).toEqual({
checkpointed: true,
vacuumed: true,
analyzed: true,
});
const first = await service.createBackup();
const second = await service.createBackup();
const third = await service.createBackup();
const backups = await service.listBackups();
expect(backups).toHaveLength(2);
expect(backups.map((item) => item.filename)).toEqual([third.filename, second.filename]);
expect(first.sha256).toMatch(/^[a-f0-9]{64}$/u);
// Retention is enforced on disk, not just in the listing.
await expect(readFile(first.path)).rejects.toThrow();
expect(await readFile(second.path)).toBeTruthy();
expect(backups[0]).toEqual(
expect.objectContaining({
filename: third.filename,
sha256: third.sha256,
sizeBytes: expect.any(Number),
createdAt: expect.any(String),
}),
);
});
it('serves and deletes single backups and refuses names outside the directory', async () => {
const db = database();
const backupRoot = await mkdtemp(join(tmpdir(), 'multi-simadmin-backup-'));
roots.push(backupRoot);
const service = new SystemMaintenanceService(db, {
version: '1.9.9',
backupDirectory: backupRoot,
});
const backup = await service.createBackup();
expect(await service.backupFile(backup.filename)).toEqual({
filename: backup.filename,
path: backup.path,
sizeBytes: backup.sizeBytes,
createdAt: expect.any(String),
});
expect(await service.backupFile('multi-simadmin-1970-01-01T00-00-00-000.db')).toBeUndefined();
await expect(service.backupFile('multi-simadmin-../secret.db')).rejects.toThrow();
await expect(service.backupFile('multi-simadmin-notes.txt')).rejects.toThrow();
await expect(service.deleteBackup('multi-simadmin-%00.db')).rejects.toThrow();
expect(await service.deleteBackup(backup.filename)).toEqual({ filename: backup.filename });
expect(await service.listBackups()).toEqual([]);
});
});
@@ -0,0 +1,384 @@
import { createHash } from 'node:crypto';
import { chmod, mkdir, readdir, readFile, rm, stat } from 'node:fs/promises';
import { basename, join } from 'node:path';
import type { SqliteDatabase } from '../../infrastructure/database/database.js';
export type DataComponentKey =
| 'instances'
| 'statusSnapshots'
| 'jobs'
| 'auditEvents'
| 'scheduledRuns'
| 'notificationQueue'
| 'smsMessages'
| 'eventJournal'
| 'connectionLogs';
export interface RetentionPolicy {
readonly enabled: boolean;
readonly days: number;
readonly maximumCount: number;
}
export interface MaintenanceOverview {
readonly runtime: {
readonly version: string;
readonly platform: NodeJS.Platform;
readonly arch: string;
readonly uptimeSeconds: number;
};
readonly storage: {
readonly databaseBytes: number;
/** Write-ahead log file size; 0 when the database is not in WAL mode. */
readonly walBytes: number;
/** Total size of the retained backup files. */
readonly backupBytes: number;
/** Free pages that "整理空间" can hand back to the filesystem. */
readonly reclaimableBytes: number;
readonly components: readonly {
readonly key: DataComponentKey;
readonly count: number;
readonly bytes: number;
}[];
};
readonly retention: Readonly<Record<DataComponentKey, RetentionPolicy>>;
}
export interface MaintenanceBackup {
readonly filename: string;
readonly path: string;
readonly sizeBytes: number;
readonly sha256: string;
readonly createdAt: string;
}
/** Download metadata without the full-file hash so large backups stay cheap to serve. */
export interface MaintenanceBackupFile {
readonly filename: string;
readonly path: string;
readonly sizeBytes: number;
readonly createdAt: string;
}
interface ServiceOptions {
readonly version: string;
readonly now?: () => Date;
readonly backupDirectory?: string;
readonly maximumBackups?: number;
}
const COMPONENT_TABLES: Readonly<Record<DataComponentKey, string>> = Object.freeze({
instances: 'instances',
statusSnapshots: 'status_snapshots',
jobs: 'jobs',
auditEvents: 'audit_events',
scheduledRuns: 'scheduled_runs',
notificationQueue: 'notification_queue',
smsMessages: 'sms_messages',
eventJournal: 'event_journal',
connectionLogs: 'connection_logs',
});
const RETENTION_SETTING_KEY = 'system.retention';
const DEFAULT_RETENTION: Readonly<Record<DataComponentKey, RetentionPolicy>> = Object.freeze({
instances: Object.freeze({ enabled: false, days: 365, maximumCount: 0 }),
statusSnapshots: Object.freeze({ enabled: false, days: 30, maximumCount: 0 }),
jobs: Object.freeze({ enabled: true, days: 90, maximumCount: 5_000 }),
auditEvents: Object.freeze({ enabled: true, days: 180, maximumCount: 50_000 }),
scheduledRuns: Object.freeze({ enabled: true, days: 180, maximumCount: 5_000 }),
notificationQueue: Object.freeze({ enabled: true, days: 180, maximumCount: 10_000 }),
smsMessages: Object.freeze({ enabled: false, days: 365, maximumCount: 0 }),
eventJournal: Object.freeze({ enabled: true, days: 30, maximumCount: 10_000 }),
// The probe journal already self-caps, so retention here is opt-in by day window.
connectionLogs: Object.freeze({ enabled: true, days: 30, maximumCount: 20_000 }),
});
function cloneRetention(
value: Readonly<Record<DataComponentKey, RetentionPolicy>>,
): Record<DataComponentKey, RetentionPolicy> {
return Object.fromEntries(
Object.entries(value).map(([key, policy]) => [key, { ...policy }]),
) as Record<DataComponentKey, RetentionPolicy>;
}
function validateRetention(value: unknown): Record<DataComponentKey, RetentionPolicy> {
const source =
value !== null && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
const merged = cloneRetention(DEFAULT_RETENTION);
for (const key of Object.keys(DEFAULT_RETENTION) as DataComponentKey[]) {
const current = source[key];
if (current === undefined) continue;
const policy = current as Partial<RetentionPolicy>;
if (
typeof policy.enabled !== 'boolean' ||
!Number.isSafeInteger(policy.days) ||
(policy.days ?? 0) < 1 ||
(policy.days ?? 0) > 3_650 ||
!Number.isSafeInteger(policy.maximumCount) ||
(policy.maximumCount ?? 0) < 0
)
throw new RangeError('Retention days must be between 1 and 3650 and limits must be valid');
merged[key] = {
enabled: policy.enabled,
days: policy.days!,
maximumCount: policy.maximumCount!,
};
}
if (Object.keys(source).some((key) => !(key in DEFAULT_RETENTION)))
throw new TypeError('Unknown data component');
return merged;
}
function tableExists(db: SqliteDatabase, table: string): boolean {
return Boolean(
db.prepare('SELECT 1 FROM sqlite_master WHERE type = ? AND name = ?').get('table', table),
);
}
function countTable(db: SqliteDatabase, table: string): number {
if (!tableExists(db, table)) return 0;
return Number(
(db.prepare(`SELECT COUNT(*) AS count FROM "${table}"`).get() as { count?: number } | undefined)
?.count ?? 0,
);
}
/** Per-table page usage from the dbstat virtual table; 0 when SQLite was built without it. */
function tableBytes(db: SqliteDatabase, table: string): number {
if (!tableExists(db, table)) return 0;
try {
const row = db
.prepare('SELECT COALESCE(SUM(pgsize), 0) AS bytes FROM dbstat WHERE name = ?')
.get(table) as { bytes?: number } | undefined;
return Number(row?.bytes ?? 0);
} catch {
return 0;
}
}
export class SystemMaintenanceService {
readonly #db: SqliteDatabase;
readonly #version: string;
readonly #now: () => Date;
readonly #backupDirectory: string;
readonly #maximumBackups: number;
constructor(db: SqliteDatabase, options: ServiceOptions) {
this.#db = db;
this.#version = options.version;
this.#now = options.now ?? (() => new Date());
this.#backupDirectory = options.backupDirectory ?? './data/backups';
this.#maximumBackups = Math.max(1, options.maximumBackups ?? 10);
}
async overview(): Promise<MaintenanceOverview> {
const pageCount = Number(this.#db.pragma('page_count', { simple: true }));
const pageSize = Number(this.#db.pragma('page_size', { simple: true }));
const databaseFile = String(
(this.#db.prepare('PRAGMA database_list').get() as { file?: string } | undefined)?.file ?? '',
);
const walBytes = databaseFile
? await stat(`${databaseFile}-wal`)
.then((details) => (details.isFile() ? details.size : 0))
.catch(() => 0)
: 0;
const freelist = Number(this.#db.pragma('freelist_count', { simple: true }));
return {
runtime: {
version: this.#version,
platform: process.platform,
arch: process.arch,
uptimeSeconds: Math.floor(process.uptime()),
},
storage: {
databaseBytes: pageCount * pageSize,
walBytes,
backupBytes: await this.#backupBytes(),
reclaimableBytes: freelist * pageSize,
components: (Object.keys(COMPONENT_TABLES) as DataComponentKey[]).map((key) => ({
key,
count: countTable(this.#db, COMPONENT_TABLES[key]),
bytes: tableBytes(this.#db, COMPONENT_TABLES[key]),
})),
},
retention: await this.getRetention(),
};
}
async #backupBytes(): Promise<number> {
const names = await readdir(this.#backupDirectory).catch(() => [] as string[]);
let total = 0;
for (const name of names.filter((entry) => entry.startsWith('multi-simadmin-'))) {
const details = await stat(join(this.#backupDirectory, name)).catch(() => undefined);
if (details?.isFile()) total += details.size;
}
return total;
}
defaultRetention(): Readonly<Record<DataComponentKey, RetentionPolicy>> {
return cloneRetention(DEFAULT_RETENTION);
}
async getRetention(): Promise<Readonly<Record<DataComponentKey, RetentionPolicy>>> {
const row = this.#db
.prepare('SELECT value_json FROM app_settings WHERE key = ?')
.get(RETENTION_SETTING_KEY) as { value_json?: string } | undefined;
if (!row?.value_json) return this.defaultRetention();
return validateRetention(JSON.parse(row.value_json));
}
async updateRetention(
value: unknown,
): Promise<Readonly<Record<DataComponentKey, RetentionPolicy>>> {
const policy = validateRetention(value);
const now = this.#now().toISOString();
this.#db
.prepare(
`INSERT INTO app_settings (key,value_json,created_at,updated_at)
VALUES (?,?,?,?)
ON CONFLICT(key) DO UPDATE SET value_json = excluded.value_json,
updated_at = excluded.updated_at`,
)
.run(RETENTION_SETTING_KEY, JSON.stringify(policy), now, now);
return policy;
}
async cleanup(
components: readonly DataComponentKey[],
): Promise<Partial<Record<DataComponentKey, number>>> {
const known = new Set(Object.keys(COMPONENT_TABLES));
if (components.some((component) => !known.has(component)))
throw new Error('Unknown data component');
const retention = await this.getRetention();
const result: Partial<Record<DataComponentKey, number>> = {};
for (const component of components) {
const policy = retention[component];
const table = COMPONENT_TABLES[component];
if (!policy.enabled || !tableExists(this.#db, table)) continue;
const cutoff = new Date(this.#now().getTime() - policy.days * 86_400_000).toISOString();
if (component === 'auditEvents')
result[component] = this.#delete(`DELETE FROM ${table} WHERE created_at < ?`, cutoff);
else if (component === 'jobs')
result[component] = this.#delete(
`DELETE FROM ${table} WHERE created_at < ? AND status NOT IN ('running','pending')`,
cutoff,
);
else if (component === 'scheduledRuns')
result[component] = this.#delete(`DELETE FROM ${table} WHERE due_at < ?`, cutoff);
else if (component === 'eventJournal')
result[component] = this.#delete(`DELETE FROM ${table}`);
else if (component === 'connectionLogs')
result[component] = this.#delete(`DELETE FROM ${table} WHERE observed_at < ?`, cutoff);
else if (policy.maximumCount > 0)
result[component] = this.#delete(
`DELETE FROM ${table} WHERE rowid IN (
SELECT rowid FROM ${table} LIMIT ?
)`,
Math.max(0, countTable(this.#db, table) - policy.maximumCount),
);
}
return result;
}
async optimize(): Promise<{ checkpointed: boolean; vacuumed: boolean; analyzed: boolean }> {
this.#db.pragma('wal_checkpoint(TRUNCATE)');
this.#db.exec('VACUUM');
this.#db.exec('ANALYZE');
return { checkpointed: true, vacuumed: true, analyzed: true };
}
async createBackup(): Promise<MaintenanceBackup> {
await mkdir(this.#backupDirectory, { recursive: true });
const filename = `multi-simadmin-${this.#now().toISOString().replaceAll(/[:.]/gu, '-')}.db`;
const path = join(this.#backupDirectory, filename);
await this.#db.backup(path);
await chmod(path, 0o600);
const details = await stat(path);
await this.#pruneBackups();
return {
filename,
path,
sizeBytes: details.size,
sha256: await readFile(path).then((content) =>
createHash('sha256').update(content).digest('hex'),
),
createdAt: this.#now().toISOString(),
};
}
async listBackups(): Promise<readonly MaintenanceBackup[]> {
await mkdir(this.#backupDirectory, { recursive: true });
const names = (await readdir(this.#backupDirectory))
.filter((name) => name.startsWith('multi-simadmin-') && name.endsWith('.db'))
.sort()
.reverse();
const backups: MaintenanceBackup[] = [];
for (const filename of names.slice(0, this.#maximumBackups)) {
const path = join(this.#backupDirectory, filename);
const details = await stat(path);
if (!details.isFile()) continue;
backups.push({
filename,
path,
sizeBytes: details.size,
sha256: createHash('sha256')
.update(await readFile(path))
.digest('hex'),
createdAt: details.mtime.toISOString(),
});
}
return backups;
}
/** Resolves a listed backup name to a path that stays inside the backup directory. */
#backupPath(filename: string): string {
if (
!/^multi-simadmin-[A-Za-z0-9._-]{1,120}\.db$/u.test(filename) ||
basename(filename) !== filename
)
throw new TypeError('Backup filename is invalid');
return join(this.#backupDirectory, filename);
}
async backupFile(filename: string): Promise<MaintenanceBackupFile | undefined> {
const path = this.#backupPath(filename);
const details = await stat(path).catch((error: unknown) => {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined;
throw error;
});
if (!details?.isFile()) return undefined;
return {
filename,
path,
sizeBytes: details.size,
createdAt: details.mtime.toISOString(),
};
}
async deleteBackup(filename: string): Promise<{ filename: string }> {
await rm(this.#backupPath(filename), { force: true });
return { filename };
}
/** Names sort newest-first because they embed an ISO timestamp. */
async #pruneBackups(): Promise<number> {
const names = (await readdir(this.#backupDirectory))
.filter((name) => name.startsWith('multi-simadmin-') && name.endsWith('.db'))
.sort()
.reverse();
let removed = 0;
for (const filename of names.slice(this.#maximumBackups)) {
await rm(join(this.#backupDirectory, filename), { force: true });
removed += 1;
}
return removed;
}
#delete(statement: string, ...parameters: readonly unknown[]): number {
return Number(this.#db.prepare(statement).run(...parameters).changes);
}
}