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:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user