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,113 @@
|
||||
import type Database from 'better-sqlite3';
|
||||
|
||||
/**
|
||||
* Reachability cadence, the Hub "connection settings" panel rebuilt on the control plane's own
|
||||
* snapshot journal. The heartbeat decides how often every instance is probed; the offline window
|
||||
* decides how long a lost probe is tolerated before the fleet view calls the device offline.
|
||||
*/
|
||||
export interface ConnectionSettings {
|
||||
readonly heartbeatSeconds: number;
|
||||
readonly offlineSeconds: number;
|
||||
}
|
||||
|
||||
export const MIN_HEARTBEAT_SECONDS = 5;
|
||||
export const MAX_HEARTBEAT_SECONDS = 300;
|
||||
export const MAX_OFFLINE_SECONDS = 1_800;
|
||||
|
||||
export const DEFAULT_CONNECTION_SETTINGS: Readonly<ConnectionSettings> = Object.freeze({
|
||||
heartbeatSeconds: 30,
|
||||
offlineSeconds: 90,
|
||||
});
|
||||
|
||||
const SETTING_KEY = 'connection.settings';
|
||||
|
||||
const FIELDS: readonly (keyof ConnectionSettings)[] = ['heartbeatSeconds', 'offlineSeconds'];
|
||||
|
||||
function safeInteger(value: unknown, name: string): number {
|
||||
if (!Number.isSafeInteger(value))
|
||||
throw new RangeError(`${name} must be a whole number of seconds`);
|
||||
return value as number;
|
||||
}
|
||||
|
||||
export function validateConnectionSettings(value: unknown): ConnectionSettings {
|
||||
const source =
|
||||
value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
if (!source) throw new TypeError('The connection settings payload is invalid');
|
||||
const unknown = Object.keys(source).filter(
|
||||
(key) => !FIELDS.includes(key as keyof ConnectionSettings),
|
||||
);
|
||||
if (unknown.length > 0) throw new TypeError(`Unknown connection setting: ${unknown[0]}`);
|
||||
|
||||
const heartbeatSeconds = safeInteger(
|
||||
source.heartbeatSeconds ?? DEFAULT_CONNECTION_SETTINGS.heartbeatSeconds,
|
||||
'heartbeatSeconds',
|
||||
);
|
||||
if (heartbeatSeconds < MIN_HEARTBEAT_SECONDS || heartbeatSeconds > MAX_HEARTBEAT_SECONDS)
|
||||
throw new RangeError('heartbeatSeconds must be between 5 and 300');
|
||||
|
||||
// An omitted window scales with the heartbeat so a partial payload stays valid; an explicit one
|
||||
// is checked strictly below.
|
||||
const fallbackOffline = Math.min(
|
||||
MAX_OFFLINE_SECONDS,
|
||||
Math.max(DEFAULT_CONNECTION_SETTINGS.offlineSeconds, heartbeatSeconds * 2),
|
||||
);
|
||||
const offlineSeconds = safeInteger(source.offlineSeconds ?? fallbackOffline, 'offlineSeconds');
|
||||
if (offlineSeconds < heartbeatSeconds * 2)
|
||||
throw new RangeError('offlineSeconds must be at least twice the heartbeat interval');
|
||||
if (offlineSeconds > MAX_OFFLINE_SECONDS)
|
||||
throw new RangeError(`offlineSeconds must not exceed ${MAX_OFFLINE_SECONDS}`);
|
||||
|
||||
return { heartbeatSeconds, offlineSeconds };
|
||||
}
|
||||
|
||||
interface SettingsRow {
|
||||
readonly value_json?: string | null;
|
||||
}
|
||||
|
||||
export class ConnectionSettingsService {
|
||||
readonly #db: Database.Database;
|
||||
readonly #now: () => Date;
|
||||
|
||||
constructor(options: { readonly db: Database.Database; readonly now?: () => Date }) {
|
||||
this.#db = options.db;
|
||||
this.#now = options.now ?? (() => new Date());
|
||||
}
|
||||
|
||||
get(): ConnectionSettings {
|
||||
const row = this.#db
|
||||
.prepare('SELECT value_json FROM app_settings WHERE key = ?')
|
||||
.get(SETTING_KEY) as SettingsRow | undefined;
|
||||
if (!row?.value_json) return { ...DEFAULT_CONNECTION_SETTINGS };
|
||||
try {
|
||||
return validateConnectionSettings(JSON.parse(row.value_json));
|
||||
} catch {
|
||||
// A hand-edited row must never take the heartbeat loop down.
|
||||
return { ...DEFAULT_CONNECTION_SETTINGS };
|
||||
}
|
||||
}
|
||||
|
||||
update(value: unknown): ConnectionSettings {
|
||||
const settings = validateConnectionSettings(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(SETTING_KEY, JSON.stringify(settings), now, now);
|
||||
return settings;
|
||||
}
|
||||
|
||||
get heartbeatMs(): number {
|
||||
return this.get().heartbeatSeconds * 1_000;
|
||||
}
|
||||
|
||||
/** Snapshot lifetime: a probe stays authoritative for one full offline window. */
|
||||
get snapshotTtlMs(): number {
|
||||
return this.get().offlineSeconds * 1_000;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user