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:
chick
2026-09-05 19:52:27 +08:00
parent 5016586922
commit 8b7df8cb65
11 changed files with 691 additions and 45 deletions
@@ -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;
}
}