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,213 @@
import { createReadStream } from 'node:fs';
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import type { SystemMaintenanceService } from '../../application/system/system-maintenance-service.js';
import type { ConnectionSettingsService } from '../../application/connections/connection-settings-service.js';
import type { FleetHeartbeatCoordinator } from '../../application/connections/fleet-heartbeat.js';
import {
ComponentBackupError,
type BackupComponentKey,
type ComponentBackupService,
} from '../../application/system/component-backup-service.js';
export interface SystemRoutesOptions {
readonly maintenance: SystemMaintenanceService;
readonly componentBackups: ComponentBackupService;
readonly connectionSettings: ConnectionSettingsService;
/** Optional so a read-only deployment can omit the background beat. */
readonly heartbeat?: FleetHeartbeatCoordinator;
}
function object(value: unknown): Record<string, unknown> {
if (typeof value !== 'object' || value === null || Array.isArray(value))
throw new TypeError('Request body must be an object');
return value as Record<string, unknown>;
}
export function registerSystemRoutes(app: FastifyInstance, options: SystemRoutesOptions): void {
const backups = () => options.componentBackups;
const wrap =
<T>(action: (request: FastifyRequest, reply: FastifyReply) => T | Promise<T>) =>
async (request: FastifyRequest, reply: FastifyReply) => {
try {
return await action(request, reply);
} catch (error) {
if (error instanceof ComponentBackupError) {
const status =
error.code === 'NOT_FOUND'
? 404
: error.code === 'INCOMPATIBLE' || error.code === 'INTEGRITY_FAILED'
? 422
: 400;
return reply
.code(status)
.type('application/problem+json')
.send({
type: 'about:blank',
title:
status === 404
? 'Not Found'
: status === 422
? 'Unprocessable Entity'
: 'Bad Request',
status,
code: `COMPONENT_BACKUP_${error.code}`,
detail: 'The component backup request could not be completed.',
requestId: request.id,
});
}
if (error instanceof TypeError || error instanceof RangeError || error instanceof Error) {
const invalid =
error instanceof TypeError ||
error instanceof RangeError ||
/invalid|unknown|required/iu.test(error.message);
if (!invalid) throw error;
return reply.code(400).type('application/problem+json').send({
type: 'about:blank',
title: 'Bad Request',
status: 400,
code: 'MAINTENANCE_VALIDATION_FAILED',
detail: 'The system maintenance request is invalid.',
requestId: request.id,
});
}
throw error;
}
};
app.get(
'/api/v1/system/maintenance',
wrap(async () => options.maintenance.overview()),
);
app.get(
'/api/v1/system/connection',
wrap(async () => options.connectionSettings.get()),
);
app.put(
'/api/v1/system/connection',
wrap(async (request) => options.connectionSettings.update(request.body)),
);
app.post(
'/api/v1/system/connection/refresh',
wrap(async (request, reply) => {
const beat = options.heartbeat;
if (!beat)
return reply.code(501).type('application/problem+json').send({
type: 'about:blank',
title: 'Not Implemented',
status: 501,
code: 'HEARTBEAT_UNAVAILABLE',
detail: 'The device heartbeat is not enabled.',
requestId: request.id,
});
return beat.runOnce();
}),
);
app.put(
'/api/v1/system/maintenance/retention',
wrap(async (request) => options.maintenance.updateRetention(request.body)),
);
app.post(
'/api/v1/system/maintenance/cleanup',
wrap(async (request) => {
const value = object(request.body).components;
if (!Array.isArray(value) || value.length === 0) throw new TypeError('components is invalid');
return options.maintenance.cleanup(value as never);
}),
);
app.post(
'/api/v1/system/maintenance/optimize',
wrap(async () => options.maintenance.optimize()),
);
app.get(
'/api/v1/system/maintenance/backups',
wrap(async () => ({
items: await options.maintenance.listBackups(),
})),
);
app.post(
'/api/v1/system/maintenance/backups',
wrap(async (_request, reply) => reply.code(201).send(await options.maintenance.createBackup())),
);
app.get(
'/api/v1/system/maintenance/backups/:filename',
wrap(async (request, reply) => {
const backup = await options.maintenance.backupFile(
(request.params as { filename: string }).filename,
);
if (!backup)
return reply.code(404).type('application/problem+json').send({
type: 'about:blank',
title: 'Not Found',
status: 404,
code: 'MAINTENANCE_BACKUP_NOT_FOUND',
detail: 'The requested backup file no longer exists.',
requestId: request.id,
});
reply
.header('content-disposition', `attachment; filename="${backup.filename}"`)
.header('content-length', String(backup.sizeBytes))
.type('application/octet-stream');
return createReadStream(backup.path);
}),
);
app.delete(
'/api/v1/system/maintenance/backups/:filename',
wrap(async (request) =>
options.maintenance.deleteBackup((request.params as { filename: string }).filename),
),
);
app.get(
'/api/v1/system/component-backups/catalog',
wrap(async () => ({ items: backups().catalog() })),
);
app.get(
'/api/v1/system/component-backups/auto/settings',
wrap(async () => backups().autoSettings()),
);
app.put(
'/api/v1/system/component-backups/auto/settings',
{ bodyLimit: 8_192 },
wrap(async (request) => backups().updateAutoSettings(request.body)),
);
app.post(
'/api/v1/system/component-backups/auto/run',
wrap(async () => ({ created: await backups().runDueAutoBackups() })),
);
app.get(
'/api/v1/system/component-backups',
wrap(async () => ({ items: await backups().list() })),
);
app.post(
'/api/v1/system/component-backups',
{ bodyLimit: 8_192 },
wrap(async (request, reply) => {
const body = object(request.body);
const created = await backups().create(
body.components as readonly BackupComponentKey[],
body.note,
);
return reply.code(201).send(created);
}),
);
app.get(
'/api/v1/system/component-backups/:filename/preview',
wrap(async (request) => backups().preview((request.params as { filename: string }).filename)),
);
app.post(
'/api/v1/system/component-backups/:filename/restore',
{ bodyLimit: 8_192 },
wrap(async (request) =>
backups().restore(
(request.params as { filename: string }).filename,
object(request.body).components as readonly BackupComponentKey[],
),
),
);
app.delete(
'/api/v1/system/component-backups/:filename',
wrap(async (request) => backups().remove((request.params as { filename: string }).filename)),
);
}