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,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([]);
});
});