feat(api): integrate identity, notification scope and connection settings into control plane
- Wire identity, metrics and update services into the assembled API. - Add connection authorization modes and scoped console authorization. - Preserve notification group IDs for filtered delivery. - Extend maintenance, backup, resource and module flows with runtime metadata and upstream safety.
This commit is contained in:
@@ -74,6 +74,42 @@ function seed(db: Database.Database, tag: string): void {
|
||||
1,
|
||||
'2026-01-01T00:00:00.000Z',
|
||||
);
|
||||
db.prepare(
|
||||
`INSERT INTO jobs
|
||||
(id,root_job_id,operation_id,risk_level,status,requested_by,request_id,parameters_digest,created_at,updated_at)
|
||||
VALUES (?,?,?,?,'succeeded',?,?,?,?,?)`,
|
||||
).run(
|
||||
`job-${tag}`,
|
||||
`job-${tag}`,
|
||||
'probe',
|
||||
'R0',
|
||||
'operator',
|
||||
`${tag}-req`,
|
||||
'digest',
|
||||
'2026-01-01T00:00:00.000Z',
|
||||
'2026-01-01T00:00:00.000Z',
|
||||
);
|
||||
db.prepare(
|
||||
`INSERT INTO job_items
|
||||
(id,job_id,instance_id,attempt_number,status,created_at,updated_at)
|
||||
VALUES (?,?,?,1,'succeeded',?,?)`,
|
||||
).run(
|
||||
`item-${tag}`,
|
||||
`job-${tag}`,
|
||||
`node-${tag}`,
|
||||
'2026-01-01T00:00:00.000Z',
|
||||
'2026-01-01T00:00:00.000Z',
|
||||
);
|
||||
db.prepare(
|
||||
`INSERT INTO job_attempts (id,job_id,status,started_at,created_at)
|
||||
VALUES (?,?,?,?,?)`,
|
||||
).run(
|
||||
`attempt-${tag}`,
|
||||
`job-${tag}`,
|
||||
'succeeded',
|
||||
'2026-01-01T00:00:00.000Z',
|
||||
'2026-01-01T00:00:00.000Z',
|
||||
);
|
||||
}
|
||||
|
||||
describe('ComponentBackupService', () => {
|
||||
@@ -124,8 +160,14 @@ describe('ComponentBackupService', () => {
|
||||
version: 'test',
|
||||
backupDirectory,
|
||||
});
|
||||
const written = await restoreService.restore(created.filename, ['devices']);
|
||||
expect(written).toEqual({ devices: 8 });
|
||||
const result = await restoreService.restore(created.filename, ['devices']);
|
||||
expect(result.written).toEqual({ devices: 8 });
|
||||
// The state right before the merge is snapshotted first, so the restore stays reversible.
|
||||
expect(result.safetyBackup).not.toBe(created.filename);
|
||||
const safety = await restoreService.preview(result.safetyBackup);
|
||||
expect(safety.note).toBe(`恢复前自动备份:${created.filename}`);
|
||||
expect(safety.automatic).toBe(true);
|
||||
expect(safety.components.map((component) => component.key)).toEqual(['devices']);
|
||||
const instances = target.prepare('SELECT id FROM instances ORDER BY id').all() as {
|
||||
id: string;
|
||||
}[];
|
||||
@@ -188,7 +230,7 @@ describe('ComponentBackupService', () => {
|
||||
service.updateAutoSettings({
|
||||
enabled: true,
|
||||
components: ['devices'],
|
||||
timeOfDay: '03:30',
|
||||
timeOfDay: '02:00',
|
||||
weekday: -1,
|
||||
maximumCount: 2,
|
||||
});
|
||||
@@ -221,7 +263,7 @@ describe('ComponentBackupService', () => {
|
||||
});
|
||||
expect(service.autoSettings()).toMatchObject({
|
||||
enabled: false,
|
||||
timeOfDay: '03:30',
|
||||
timeOfDay: '02:00',
|
||||
components: ['devices', 'notifications', 'automation'],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ export type BackupComponentKey =
|
||||
| 'notificationRecords'
|
||||
| 'automation'
|
||||
| 'automationRecords'
|
||||
| 'jobs'
|
||||
| 'sms'
|
||||
| 'settings'
|
||||
| 'audit';
|
||||
@@ -52,6 +53,11 @@ export const BACKUP_COMPONENTS: Readonly<Record<BackupComponentKey, ComponentSpe
|
||||
description: '每次定时执行的状态与结果。',
|
||||
tables: ['scheduled_runs'],
|
||||
},
|
||||
jobs: {
|
||||
label: '命令历史',
|
||||
description: '集中下发的设备操作命令与逐设备执行明细。',
|
||||
tables: ['jobs', 'job_items', 'job_attempts'],
|
||||
},
|
||||
sms: {
|
||||
label: '短信记录',
|
||||
description: '集中保存的跨设备短信正文与会话。',
|
||||
@@ -125,6 +131,20 @@ export interface AutoBackupSettings {
|
||||
readonly lastRunAt: string | null;
|
||||
}
|
||||
|
||||
export interface RestoreResult {
|
||||
readonly written: Partial<Record<BackupComponentKey, number>>;
|
||||
/** Filename of the snapshot taken just before the merge, so a restore is always reversible. */
|
||||
readonly safetyBackup: string;
|
||||
}
|
||||
|
||||
/** Download metadata without parsing an already validated archive again. */
|
||||
export interface ComponentBackupFile {
|
||||
readonly filename: string;
|
||||
readonly path: string;
|
||||
readonly sizeBytes: number;
|
||||
readonly createdAt: string;
|
||||
}
|
||||
|
||||
export class ComponentBackupError extends Error {
|
||||
constructor(
|
||||
readonly code:
|
||||
@@ -292,11 +312,14 @@ export class ComponentBackupService {
|
||||
async restore(
|
||||
filename: string,
|
||||
components: readonly BackupComponentKey[],
|
||||
): Promise<Partial<Record<BackupComponentKey, number>>> {
|
||||
): Promise<RestoreResult> {
|
||||
const safe = this.#safeName(filename);
|
||||
const keys = normalizeComponents(components);
|
||||
const { document } = await this.#read(safe);
|
||||
if (document.formatVersion > FORMAT_VERSION) throw new ComponentBackupError('INCOMPATIBLE');
|
||||
// Snapshot the units that are about to be overwritten first: a restore must always be
|
||||
// reversible, and the state right before the merge is the only thing worth returning to.
|
||||
const safety = await this.create(keys, `恢复前自动备份:${safe}`, true);
|
||||
const written: Partial<Record<BackupComponentKey, number>> = {};
|
||||
const restore = this.#db.transaction(() => {
|
||||
for (const key of keys) {
|
||||
@@ -314,7 +337,12 @@ export class ComponentBackupService {
|
||||
}
|
||||
});
|
||||
restore();
|
||||
return written;
|
||||
return { written, safetyBackup: safety.filename };
|
||||
}
|
||||
|
||||
/** Where the backup files live, shown so an operator can mirror the folder off-console. */
|
||||
directory(): string {
|
||||
return this.#directory;
|
||||
}
|
||||
|
||||
async remove(filename: string): Promise<{ filename: string }> {
|
||||
@@ -322,6 +350,23 @@ export class ComponentBackupService {
|
||||
return { filename: this.#safeName(filename) };
|
||||
}
|
||||
|
||||
/** Resolves a listed archive to a path that stays inside the backup directory. */
|
||||
async backupFile(filename: string): Promise<ComponentBackupFile | undefined> {
|
||||
const safe = this.#safeName(filename);
|
||||
const path = join(this.#directory, safe);
|
||||
const details = await stat(path).catch((error: NodeJS.ErrnoException) => {
|
||||
if (error.code === 'ENOENT') return undefined;
|
||||
throw error;
|
||||
});
|
||||
if (!details?.isFile()) return undefined;
|
||||
return {
|
||||
filename: safe,
|
||||
path,
|
||||
sizeBytes: details.size,
|
||||
createdAt: details.mtime.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
autoSettings(): AutoBackupSettings {
|
||||
return this.#readAuto();
|
||||
}
|
||||
@@ -536,9 +581,9 @@ export class ComponentBackupService {
|
||||
const fallback = {
|
||||
enabled: false,
|
||||
components: ['devices', 'notifications', 'automation'] as BackupComponentKey[],
|
||||
timeOfDay: '03:30',
|
||||
timeOfDay: '02:00',
|
||||
weekday: -1,
|
||||
maximumCount: 14,
|
||||
maximumCount: 10,
|
||||
};
|
||||
const source: Record<string, unknown> = { ...fallback, ...(stored ?? {}) };
|
||||
let components: BackupComponentKey[] = fallback.components;
|
||||
|
||||
@@ -30,6 +30,8 @@ export interface MaintenanceOverview {
|
||||
};
|
||||
readonly storage: {
|
||||
readonly databaseBytes: number;
|
||||
/** SQLite file path; empty for in-memory databases. */
|
||||
readonly databasePath: string;
|
||||
/** Write-ahead log file size; 0 when the database is not in WAL mode. */
|
||||
readonly walBytes: number;
|
||||
/** Total size of the retained backup files. */
|
||||
@@ -195,6 +197,7 @@ export class SystemMaintenanceService {
|
||||
},
|
||||
storage: {
|
||||
databaseBytes: pageCount * pageSize,
|
||||
databasePath: databaseFile,
|
||||
walBytes,
|
||||
backupBytes: await this.#backupBytes(),
|
||||
reclaimableBytes: freelist * pageSize,
|
||||
|
||||
Reference in New Issue
Block a user