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,120 @@
|
||||
import type { ConnectionProbe } from './connection-probe.js';
|
||||
import type { ConnectionSettingsService } from './connection-settings-service.js';
|
||||
import type { InstanceService } from '../instances/instance-service.js';
|
||||
|
||||
export interface FleetHeartbeatSummary {
|
||||
readonly probed: number;
|
||||
readonly failed: number;
|
||||
readonly startedAt: string;
|
||||
readonly finishedAt: string;
|
||||
}
|
||||
|
||||
export interface FleetHeartbeatOptions {
|
||||
readonly instances: InstanceService;
|
||||
readonly probe: ConnectionProbe;
|
||||
readonly settings: ConnectionSettingsService;
|
||||
readonly now?: () => Date;
|
||||
/** Devices touched at the same time; the control plane talks to LAN hosts, not the internet. */
|
||||
readonly concurrency?: number;
|
||||
readonly pageSize?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_CONCURRENCY = 6;
|
||||
const DEFAULT_PAGE_SIZE = 100;
|
||||
const MAX_PAGES = 100;
|
||||
|
||||
/**
|
||||
* Keeps device online state honest without waiting for an operator to open a page: every beat
|
||||
* probes each instance once and lets the snapshot journal carry the result until it expires.
|
||||
*/
|
||||
export class FleetHeartbeatCoordinator {
|
||||
readonly #instances: InstanceService;
|
||||
readonly #probe: ConnectionProbe;
|
||||
readonly #settings: ConnectionSettingsService;
|
||||
readonly #now: () => Date;
|
||||
readonly #concurrency: number;
|
||||
readonly #pageSize: number;
|
||||
#timer: ReturnType<typeof setTimeout> | undefined;
|
||||
#running: Promise<FleetHeartbeatSummary> | undefined;
|
||||
#stopped = false;
|
||||
|
||||
constructor(options: FleetHeartbeatOptions) {
|
||||
this.#instances = options.instances;
|
||||
this.#probe = options.probe;
|
||||
this.#settings = options.settings;
|
||||
this.#now = options.now ?? (() => new Date());
|
||||
const concurrency = options.concurrency ?? DEFAULT_CONCURRENCY;
|
||||
if (!Number.isSafeInteger(concurrency) || concurrency < 1 || concurrency > 16)
|
||||
throw new RangeError('concurrency must be between 1 and 16');
|
||||
const pageSize = options.pageSize ?? DEFAULT_PAGE_SIZE;
|
||||
if (!Number.isSafeInteger(pageSize) || pageSize < 1 || pageSize > 100)
|
||||
throw new RangeError('pageSize must be between 1 and 100');
|
||||
this.#concurrency = concurrency;
|
||||
this.#pageSize = pageSize;
|
||||
}
|
||||
|
||||
start(): void {
|
||||
this.#stopped = false;
|
||||
if (this.#timer) return;
|
||||
this.#schedule();
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.#stopped = true;
|
||||
if (this.#timer) clearTimeout(this.#timer);
|
||||
this.#timer = undefined;
|
||||
}
|
||||
|
||||
/** Runs a beat on demand; concurrent callers share the same pass. */
|
||||
runOnce(): Promise<FleetHeartbeatSummary> {
|
||||
if (this.#running) return this.#running;
|
||||
const pending = this.#beat().finally(() => {
|
||||
if (this.#running === pending) this.#running = undefined;
|
||||
});
|
||||
this.#running = pending;
|
||||
return pending;
|
||||
}
|
||||
|
||||
#schedule(): void {
|
||||
if (this.#stopped) return;
|
||||
const delay = this.#settings.heartbeatMs;
|
||||
this.#timer = setTimeout(() => {
|
||||
this.#timer = undefined;
|
||||
void this.runOnce()
|
||||
.catch(() => undefined)
|
||||
.finally(() => this.#schedule());
|
||||
}, delay);
|
||||
this.#timer.unref?.();
|
||||
}
|
||||
|
||||
async #beat(): Promise<FleetHeartbeatSummary> {
|
||||
const startedAt = this.#now().toISOString();
|
||||
const ids: string[] = [];
|
||||
for (let page = 1; page <= MAX_PAGES; page += 1) {
|
||||
const current = await this.#instances.list({ page, pageSize: this.#pageSize });
|
||||
for (const instance of current.items) if (!ids.includes(instance.id)) ids.push(instance.id);
|
||||
if (current.items.length < this.#pageSize) break;
|
||||
}
|
||||
let probed = 0;
|
||||
let failed = 0;
|
||||
let cursor = 0;
|
||||
const workers = Array.from(
|
||||
{ length: Math.min(this.#concurrency, ids.length) },
|
||||
async (): Promise<void> => {
|
||||
for (;;) {
|
||||
const index = cursor;
|
||||
cursor += 1;
|
||||
if (index >= ids.length) return;
|
||||
try {
|
||||
await this.#probe.test(ids[index] as string);
|
||||
probed += 1;
|
||||
} catch {
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
await Promise.all(workers);
|
||||
return { probed, failed, startedAt, finishedAt: this.#now().toISOString() };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user