fix(instances): release the hub binding before forgetting a node
Deletion now follows the published contract: prepare returns the one-time token and the console spends it on DELETE /api/v1/instances/:id with If-Match, where the generic execute endpoint had been rejecting it as an operation the safe transport cannot replay. An online device is told to release its own binding first. A refusal keeps the record and reports UNBIND_FAILED on the job item so the console can say why the node is still there; a node that never answers a heartbeat is forgotten locally.
This commit is contained in:
@@ -1,10 +1,42 @@
|
||||
import type { ConnectionProbe } from './connection-probe.js';
|
||||
import type { ConnectionProbe, ConnectionState } from './connection-probe.js';
|
||||
import type { ConnectionSettingsService } from './connection-settings-service.js';
|
||||
import type { InstanceService } from '../instances/instance-service.js';
|
||||
|
||||
/** Reachability class the console and the notification rules both speak in. */
|
||||
export type FleetObservedState = 'online' | 'auth-required' | 'offline';
|
||||
|
||||
/**
|
||||
* Operator-facing copy shared by the notification templates so every channel reads alike.
|
||||
* `FLEET_STATE_NAMES` describes a steady state, `FLEET_STATE_EVENTS` describes arriving at it.
|
||||
*/
|
||||
export const FLEET_STATE_NAMES: Readonly<Record<FleetObservedState, string>> = Object.freeze({
|
||||
online: '在线',
|
||||
'auth-required': '需要认证',
|
||||
offline: '离线',
|
||||
});
|
||||
|
||||
export const FLEET_STATE_EVENTS: Readonly<Record<FleetObservedState, string>> = Object.freeze({
|
||||
online: '恢复在线',
|
||||
'auth-required': '设备会话已失效',
|
||||
offline: '已离线',
|
||||
});
|
||||
|
||||
export const FLEET_STATE_CODES: Readonly<Record<FleetObservedState, string>> = Object.freeze({
|
||||
online: 'online',
|
||||
'auth-required': 'auth_required',
|
||||
offline: 'offline',
|
||||
});
|
||||
|
||||
export interface FleetStateTransition {
|
||||
readonly instanceId: string;
|
||||
readonly from: FleetObservedState;
|
||||
readonly to: FleetObservedState;
|
||||
}
|
||||
|
||||
export interface FleetHeartbeatSummary {
|
||||
readonly probed: number;
|
||||
readonly failed: number;
|
||||
readonly transitions: readonly FleetStateTransition[];
|
||||
readonly startedAt: string;
|
||||
readonly finishedAt: string;
|
||||
}
|
||||
@@ -17,12 +49,23 @@ export interface FleetHeartbeatOptions {
|
||||
/** Devices touched at the same time; the control plane talks to LAN hosts, not the internet. */
|
||||
readonly concurrency?: number;
|
||||
readonly pageSize?: number;
|
||||
/**
|
||||
* Called once per beat with every state change the beat revealed. Reachability is derived
|
||||
* from snapshot expiry, so a single lost probe never fires anything; a node has to stay
|
||||
* down for the whole offline window before it counts as a transition.
|
||||
*/
|
||||
readonly onTransition?: (transitions: readonly FleetStateTransition[]) => Promise<void>;
|
||||
}
|
||||
|
||||
const DEFAULT_CONCURRENCY = 6;
|
||||
const DEFAULT_PAGE_SIZE = 100;
|
||||
const MAX_PAGES = 100;
|
||||
|
||||
function observedState(state: ConnectionState | undefined): FleetObservedState {
|
||||
if (!state?.reachable) return 'offline';
|
||||
return state.authenticated ? 'online' : 'auth-required';
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
@@ -34,9 +77,14 @@ export class FleetHeartbeatCoordinator {
|
||||
readonly #now: () => Date;
|
||||
readonly #concurrency: number;
|
||||
readonly #pageSize: number;
|
||||
readonly #onTransition:
|
||||
| ((transitions: readonly FleetStateTransition[]) => Promise<void>)
|
||||
| undefined;
|
||||
#timer: ReturnType<typeof setTimeout> | undefined;
|
||||
#running: Promise<FleetHeartbeatSummary> | undefined;
|
||||
#stopped = false;
|
||||
/** States seen by the previous beat; undefined until the first beat establishes a baseline. */
|
||||
#previous: ReadonlyMap<string, FleetObservedState> | undefined;
|
||||
|
||||
constructor(options: FleetHeartbeatOptions) {
|
||||
this.#instances = options.instances;
|
||||
@@ -51,6 +99,7 @@ export class FleetHeartbeatCoordinator {
|
||||
throw new RangeError('pageSize must be between 1 and 100');
|
||||
this.#concurrency = concurrency;
|
||||
this.#pageSize = pageSize;
|
||||
this.#onTransition = options.onTransition;
|
||||
}
|
||||
|
||||
start(): void {
|
||||
@@ -95,6 +144,7 @@ export class FleetHeartbeatCoordinator {
|
||||
for (const instance of current.items) if (!ids.includes(instance.id)) ids.push(instance.id);
|
||||
if (current.items.length < this.#pageSize) break;
|
||||
}
|
||||
const before = this.#previous;
|
||||
let probed = 0;
|
||||
let failed = 0;
|
||||
let cursor = 0;
|
||||
@@ -115,6 +165,39 @@ export class FleetHeartbeatCoordinator {
|
||||
},
|
||||
);
|
||||
await Promise.all(workers);
|
||||
return { probed, failed, startedAt, finishedAt: this.#now().toISOString() };
|
||||
const transitions = this.#diff(before, ids);
|
||||
if (transitions.length > 0) await this.#onTransition?.(transitions);
|
||||
return { probed, failed, transitions, startedAt, finishedAt: this.#now().toISOString() };
|
||||
}
|
||||
|
||||
/** Snapshot of the given instances, including ones that have never been probed. */
|
||||
#states(ids: readonly string[]): ReadonlyMap<string, FleetObservedState> {
|
||||
const reachability = this.#probe.reachability();
|
||||
const states = new Map<string, FleetObservedState>();
|
||||
for (const id of ids) {
|
||||
const state = reachability.get(id);
|
||||
states.set(id, observedState(state));
|
||||
}
|
||||
return states;
|
||||
}
|
||||
|
||||
/**
|
||||
* The first beat only records a baseline. Without one, every instance would start as
|
||||
* "offline" and then announce itself as recovered, so a restart would flood the queue.
|
||||
*/
|
||||
#diff(
|
||||
before: ReadonlyMap<string, FleetObservedState> | undefined,
|
||||
ids: readonly string[],
|
||||
): readonly FleetStateTransition[] {
|
||||
const after = this.#states(ids);
|
||||
this.#previous = after;
|
||||
if (before === undefined) return [];
|
||||
const transitions: FleetStateTransition[] = [];
|
||||
for (const [id, to] of after) {
|
||||
const from = before.get(id);
|
||||
if (from === undefined || from === to) continue;
|
||||
transitions.push({ instanceId: id, from, to });
|
||||
}
|
||||
return transitions;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user