@xpufx/paseo-top 0.4.2 → 0.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -12
- package/client/multi-host-view.tsx +177 -0
- package/client/multi-host.ts +98 -0
- package/client/pill.tsx +2 -0
- package/client/surface.tsx +5 -8
- package/client/vendor/paseo-plugin-helper/components/Button.tsx +7 -1
- package/client/vendor/paseo-plugin-helper/components/InlineButton.tsx +3 -1
- package/client/vendor/paseo-plugin-helper/index.ts +1 -0
- package/client/vendor/paseo-plugin-helper/layout/ModalBody.tsx +3 -3
- package/client/vendor/paseo-plugin-helper/layout/ModalContent.tsx +24 -22
- package/client/vendor/paseo-plugin-helper/panel.tsx +11 -3
- package/client/vendor/paseo-plugin-helper/pill.tsx +77 -28
- package/client/vendor/paseo-plugin-helper/styles/index.ts +1 -0
- package/client/vendor/paseo-plugin-helper/styles/recipes.ts +550 -0
- package/client/vendor/paseo-plugin-helper/theme/provider.tsx +3 -39
- package/client/vendor/paseo-plugin-helper/theme/tokens.ts +40 -1
- package/package.json +3 -3
- package/paseo-plugin.json +1 -1
- package/shared/multi-host.ts +341 -0
|
@@ -24,7 +24,8 @@ import {
|
|
|
24
24
|
readHostThemeVariables,
|
|
25
25
|
type HostFontVariables,
|
|
26
26
|
} from "./host-variables";
|
|
27
|
-
import { resolveTypography, type TypographyScale } from "./tokens";
|
|
27
|
+
import { defaultDarkTheme, defaultLightTheme, getDefaultTheme, resolveTypography, type TypographyScale } from "./tokens";
|
|
28
|
+
export { defaultDarkTheme, defaultLightTheme, getDefaultTheme };
|
|
28
29
|
import type { ResponsiveLayout, StatusVariant, ThemeColors } from "../../../../shared/vendor/paseo-plugin-helper/types";
|
|
29
30
|
|
|
30
31
|
export interface PluginThemeContextValue {
|
|
@@ -50,44 +51,7 @@ const defaultLayout: ResponsiveLayout = {
|
|
|
50
51
|
platform: "web",
|
|
51
52
|
};
|
|
52
53
|
|
|
53
|
-
|
|
54
|
-
colors: {
|
|
55
|
-
surface0: "#18181b",
|
|
56
|
-
surface1: "#27272a",
|
|
57
|
-
surface2: "#3f3f46",
|
|
58
|
-
border: "#3f3f46",
|
|
59
|
-
foreground: "#fafafa",
|
|
60
|
-
foregroundMuted: "#a1a1aa",
|
|
61
|
-
accent: "#3b82f6",
|
|
62
|
-
accentForeground: "#ffffff",
|
|
63
|
-
statusSuccess: "#22c55e",
|
|
64
|
-
statusWarning: "#eab308",
|
|
65
|
-
statusDanger: "#ef4444",
|
|
66
|
-
},
|
|
67
|
-
};
|
|
68
|
-
|
|
69
|
-
export const defaultLightTheme: PluginTheme = {
|
|
70
|
-
colors: {
|
|
71
|
-
surface0: "#ffffff",
|
|
72
|
-
surface1: "#f4f4f5",
|
|
73
|
-
surface2: "#e4e4e7",
|
|
74
|
-
border: "#e4e4e7",
|
|
75
|
-
foreground: "#09090b",
|
|
76
|
-
foregroundMuted: "#71717a",
|
|
77
|
-
accent: "#2563eb",
|
|
78
|
-
accentForeground: "#ffffff",
|
|
79
|
-
statusSuccess: "#16a34a",
|
|
80
|
-
statusWarning: "#ca8a04",
|
|
81
|
-
statusDanger: "#dc2626",
|
|
82
|
-
},
|
|
83
|
-
};
|
|
84
|
-
|
|
85
|
-
export function getDefaultTheme(scheme?: string): PluginTheme {
|
|
86
|
-
if (scheme === "light") {
|
|
87
|
-
return defaultLightTheme;
|
|
88
|
-
}
|
|
89
|
-
return defaultDarkTheme;
|
|
90
|
-
}
|
|
54
|
+
// default themes re-exported from tokens.js
|
|
91
55
|
|
|
92
56
|
export function useAppearanceScheme(): [string | undefined, (s: string | undefined) => void] {
|
|
93
57
|
const [scheme, setScheme] = useState<string | undefined>(Appearance.getColorScheme?.() ?? undefined);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { PlatformType } from "../../../../shared/vendor/paseo-plugin-helper/types";
|
|
1
|
+
import type { PlatformType, PluginTheme } from "../../../../shared/vendor/paseo-plugin-helper/types";
|
|
2
2
|
import type { DensityStyle } from "./flair";
|
|
3
3
|
import type { ResponsiveLayout } from "../../../../shared/vendor/paseo-plugin-helper/types";
|
|
4
4
|
import { resolveEffectiveCompact } from "./responsive";
|
|
@@ -159,3 +159,42 @@ export function elevationForPlatform(
|
|
|
159
159
|
}
|
|
160
160
|
return style;
|
|
161
161
|
}
|
|
162
|
+
|
|
163
|
+
export const defaultDarkTheme: PluginTheme = {
|
|
164
|
+
colors: {
|
|
165
|
+
surface0: "#18181b",
|
|
166
|
+
surface1: "#27272a",
|
|
167
|
+
surface2: "#3f3f46",
|
|
168
|
+
border: "#3f3f46",
|
|
169
|
+
foreground: "#fafafa",
|
|
170
|
+
foregroundMuted: "#a1a1aa",
|
|
171
|
+
accent: "#3b82f6",
|
|
172
|
+
accentForeground: "#ffffff",
|
|
173
|
+
statusSuccess: "#22c55e",
|
|
174
|
+
statusWarning: "#eab308",
|
|
175
|
+
statusDanger: "#ef4444",
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
export const defaultLightTheme: PluginTheme = {
|
|
180
|
+
colors: {
|
|
181
|
+
surface0: "#ffffff",
|
|
182
|
+
surface1: "#f4f4f5",
|
|
183
|
+
surface2: "#e4e4e7",
|
|
184
|
+
border: "#e4e4e7",
|
|
185
|
+
foreground: "#09090b",
|
|
186
|
+
foregroundMuted: "#71717a",
|
|
187
|
+
accent: "#2563eb",
|
|
188
|
+
accentForeground: "#ffffff",
|
|
189
|
+
statusSuccess: "#16a34a",
|
|
190
|
+
statusWarning: "#ca8a04",
|
|
191
|
+
statusDanger: "#dc2626",
|
|
192
|
+
},
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
export function getDefaultTheme(scheme?: string): PluginTheme {
|
|
196
|
+
if (scheme === "light") {
|
|
197
|
+
return defaultLightTheme;
|
|
198
|
+
}
|
|
199
|
+
return defaultDarkTheme;
|
|
200
|
+
}
|
package/package.json
CHANGED
|
@@ -2,14 +2,14 @@
|
|
|
2
2
|
"name": "@xpufx/paseo-top",
|
|
3
3
|
"private": false,
|
|
4
4
|
"license": "MIT",
|
|
5
|
-
"version": "0.4.
|
|
5
|
+
"version": "0.4.3",
|
|
6
6
|
"scripts": {
|
|
7
7
|
"stamp": "node -e \"import('./server/vendor/paseo-plugin-helper/version.ts').then(m => m.stampVersion({ targetFile: 'shared/version.ts' }))\"",
|
|
8
8
|
"typecheck": "tsc --noEmit",
|
|
9
|
-
"test": "npm run typecheck && npx tsx --test server/telemetry.test.ts client/pill-labels.test.ts client/turn-counter.test.ts client/telemetry-copy.test.ts"
|
|
9
|
+
"test": "npm run typecheck && npx tsx --test server/telemetry.test.ts client/pill-labels.test.ts client/turn-counter.test.ts client/telemetry-copy.test.ts client/multi-host.test.ts"
|
|
10
10
|
},
|
|
11
11
|
"devDependencies": {
|
|
12
|
-
"@getpaseo/plugin": "0.9.0
|
|
12
|
+
"@getpaseo/plugin": "^0.9.0",
|
|
13
13
|
"@tanstack/react-query": "^5.90.11",
|
|
14
14
|
"@types/react": "^19.0.10",
|
|
15
15
|
"react": "19.1.0",
|
package/paseo-plugin.json
CHANGED
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import { TimeoutError, withTimeout } from "./vendor/paseo-plugin-helper/index";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Bounded multi-host fleet polling state machine.
|
|
5
|
+
*
|
|
6
|
+
* Pure, framework-free logic shared by the client hook and exercised directly
|
|
7
|
+
* by unit tests. It owns the exact contract from #299: at most one in-flight
|
|
8
|
+
* probe per host, a slower cadence than the local five-second poll, a
|
|
9
|
+
* per-host timeout that marks the host `stale` with a timestamp, and
|
|
10
|
+
* dispose/reacquire of the borrowed host client across connection
|
|
11
|
+
* transitions.
|
|
12
|
+
*
|
|
13
|
+
* It deliberately does not model CPU/RAM/disk gauges: the Paseo plugin SDK
|
|
14
|
+
* exposes no host-metrics RPC on a borrowed host client (verified against
|
|
15
|
+
* 0.9.0 and 0.9.2), and fabricating them would be mock data. Only counts are
|
|
16
|
+
* aggregated (never summed/averaged gauges), matching the #299 rule.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export type HostConnectionStatus =
|
|
20
|
+
| "idle"
|
|
21
|
+
| "connecting"
|
|
22
|
+
| "online"
|
|
23
|
+
| "offline"
|
|
24
|
+
| "error";
|
|
25
|
+
|
|
26
|
+
export type FleetHostStatus = "online" | "stale" | "offline" | "error";
|
|
27
|
+
|
|
28
|
+
export interface FleetHostInput {
|
|
29
|
+
serverId: string;
|
|
30
|
+
label: string;
|
|
31
|
+
status: HostConnectionStatus;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Cross-host counts. These are the only fleet-additive metrics. */
|
|
35
|
+
export interface FleetHostCounts {
|
|
36
|
+
agents: number;
|
|
37
|
+
activeAgents: number;
|
|
38
|
+
workspaces: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface FleetHostSnapshot {
|
|
42
|
+
serverId: string;
|
|
43
|
+
label: string;
|
|
44
|
+
connectionStatus: HostConnectionStatus;
|
|
45
|
+
status: FleetHostStatus;
|
|
46
|
+
/** Round-trip latency of the last successful probe, in milliseconds. */
|
|
47
|
+
latencyMs: number | null;
|
|
48
|
+
/** Epoch ms of the last successful probe. */
|
|
49
|
+
lastUpdatedAt: number | null;
|
|
50
|
+
/** Epoch ms when the host was first marked stale; cleared on recovery. */
|
|
51
|
+
staleAt: number | null;
|
|
52
|
+
error: string | null;
|
|
53
|
+
counts: FleetHostCounts | null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* A borrowed host client. Mirrors the structural slice of `PaseoApi` the
|
|
58
|
+
* fleet needs, so `getPaseoClient(serverId)` satisfies it without importing
|
|
59
|
+
* the Paseo SDK types into shared code.
|
|
60
|
+
*/
|
|
61
|
+
export interface FleetHostClient {
|
|
62
|
+
readCounts(): Promise<FleetHostCounts>;
|
|
63
|
+
dispose(): Promise<void> | void;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface MultiHostPollerOptions {
|
|
67
|
+
acquire(serverId: string): FleetHostClient;
|
|
68
|
+
/** Per-host request timeout. Default 4000ms (#299). */
|
|
69
|
+
timeoutMs?: number;
|
|
70
|
+
/** Polling cadence. Default 15000ms, slower than the local 5s poll (#299). */
|
|
71
|
+
intervalMs?: number;
|
|
72
|
+
now?: () => number;
|
|
73
|
+
onUpdate?: (snapshots: FleetHostSnapshot[]) => void;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface FleetAggregate {
|
|
77
|
+
hosts: number;
|
|
78
|
+
online: number;
|
|
79
|
+
stale: number;
|
|
80
|
+
offline: number;
|
|
81
|
+
error: number;
|
|
82
|
+
/** Hosts currently returning real data (online or stale). */
|
|
83
|
+
responsive: number;
|
|
84
|
+
/** Sum of counts across responsive hosts. Gauges are never aggregated. */
|
|
85
|
+
counts: FleetHostCounts;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function emptyCounts(): FleetHostCounts {
|
|
89
|
+
return { agents: 0, activeAgents: 0, workspaces: 0 };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function fleetStatusFor(connection: HostConnectionStatus): FleetHostStatus {
|
|
93
|
+
if (connection === "online") return "online";
|
|
94
|
+
if (connection === "error") return "error";
|
|
95
|
+
return "offline";
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function errorMessage(cause: unknown): string {
|
|
99
|
+
return cause instanceof Error ? cause.message : String(cause);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function sortFleetSnapshots(
|
|
103
|
+
snapshots: readonly FleetHostSnapshot[],
|
|
104
|
+
): FleetHostSnapshot[] {
|
|
105
|
+
return [...snapshots].sort((a, b) => {
|
|
106
|
+
const byLabel = a.label.localeCompare(b.label);
|
|
107
|
+
return byLabel !== 0 ? byLabel : a.serverId.localeCompare(b.serverId);
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Sum counts only; hardware gauges are per-host and never aggregated. */
|
|
112
|
+
export function aggregateFleet(
|
|
113
|
+
snapshots: readonly FleetHostSnapshot[],
|
|
114
|
+
): FleetAggregate {
|
|
115
|
+
const aggregate: FleetAggregate = {
|
|
116
|
+
hosts: snapshots.length,
|
|
117
|
+
online: 0,
|
|
118
|
+
stale: 0,
|
|
119
|
+
offline: 0,
|
|
120
|
+
error: 0,
|
|
121
|
+
responsive: 0,
|
|
122
|
+
counts: emptyCounts(),
|
|
123
|
+
};
|
|
124
|
+
for (const snapshot of snapshots) {
|
|
125
|
+
aggregate[snapshot.status] += 1;
|
|
126
|
+
if (snapshot.counts) {
|
|
127
|
+
aggregate.responsive += 1;
|
|
128
|
+
aggregate.counts.agents += snapshot.counts.agents;
|
|
129
|
+
aggregate.counts.activeAgents += snapshot.counts.activeAgents;
|
|
130
|
+
aggregate.counts.workspaces += snapshot.counts.workspaces;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return aggregate;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export class MultiHostPoller {
|
|
137
|
+
static readonly DEFAULT_INTERVAL_MS = 15_000;
|
|
138
|
+
static readonly DEFAULT_TIMEOUT_MS = 4_000;
|
|
139
|
+
|
|
140
|
+
private readonly options: Required<
|
|
141
|
+
Pick<MultiHostPollerOptions, "timeoutMs" | "intervalMs" | "now">
|
|
142
|
+
> &
|
|
143
|
+
Pick<MultiHostPollerOptions, "acquire" | "onUpdate">;
|
|
144
|
+
private readonly snapshots = new Map<string, FleetHostSnapshot>();
|
|
145
|
+
private readonly clients = new Map<string, FleetHostClient>();
|
|
146
|
+
private readonly inFlight = new Set<string>();
|
|
147
|
+
/** Bumped on any connection transition so in-flight results are discarded. */
|
|
148
|
+
private readonly generation = new Map<string, number>();
|
|
149
|
+
private timer: ReturnType<typeof setInterval> | null = null;
|
|
150
|
+
private running = false;
|
|
151
|
+
private tickInFlight = false;
|
|
152
|
+
private disposed = false;
|
|
153
|
+
|
|
154
|
+
constructor(options: MultiHostPollerOptions) {
|
|
155
|
+
this.options = {
|
|
156
|
+
timeoutMs: options.timeoutMs ?? MultiHostPoller.DEFAULT_TIMEOUT_MS,
|
|
157
|
+
intervalMs: options.intervalMs ?? MultiHostPoller.DEFAULT_INTERVAL_MS,
|
|
158
|
+
now: options.now ?? (() => Date.now()),
|
|
159
|
+
acquire: options.acquire,
|
|
160
|
+
onUpdate: options.onUpdate,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Reconcile the live host list, releasing clients across transitions. */
|
|
165
|
+
setHosts(hosts: readonly FleetHostInput[]): void {
|
|
166
|
+
if (this.disposed) return;
|
|
167
|
+
const next = new Map(hosts.map((host) => [host.serverId, host]));
|
|
168
|
+
|
|
169
|
+
for (const serverId of [...this.snapshots.keys()]) {
|
|
170
|
+
if (!next.has(serverId)) {
|
|
171
|
+
this.bumpGeneration(serverId);
|
|
172
|
+
this.disposeClient(serverId);
|
|
173
|
+
this.snapshots.delete(serverId);
|
|
174
|
+
this.inFlight.delete(serverId);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
for (const host of next.values()) {
|
|
179
|
+
const existing = this.snapshots.get(host.serverId);
|
|
180
|
+
if (!existing) {
|
|
181
|
+
this.snapshots.set(host.serverId, {
|
|
182
|
+
serverId: host.serverId,
|
|
183
|
+
label: host.label,
|
|
184
|
+
connectionStatus: host.status,
|
|
185
|
+
status: fleetStatusFor(host.status),
|
|
186
|
+
latencyMs: null,
|
|
187
|
+
lastUpdatedAt: null,
|
|
188
|
+
staleAt: null,
|
|
189
|
+
error: null,
|
|
190
|
+
counts: null,
|
|
191
|
+
});
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
const transitioned = existing.connectionStatus !== host.status;
|
|
195
|
+
if (transitioned) {
|
|
196
|
+
// A status change (including reconnect/failover) invalidates the
|
|
197
|
+
// borrowed client; reacquire on the next probe.
|
|
198
|
+
this.bumpGeneration(host.serverId);
|
|
199
|
+
this.disposeClient(host.serverId);
|
|
200
|
+
existing.connectionStatus = host.status;
|
|
201
|
+
existing.status = fleetStatusFor(host.status);
|
|
202
|
+
if (host.status !== "online") {
|
|
203
|
+
// Disconnected hosts keep no counts; only a `stale` probe retains
|
|
204
|
+
// its last-known snapshot as a deliberate "unresponsive" state.
|
|
205
|
+
existing.staleAt = null;
|
|
206
|
+
existing.counts = null;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
existing.label = host.label;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
this.emit();
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Start the immediate poll plus the fixed-cadence loop. */
|
|
216
|
+
start(): void {
|
|
217
|
+
if (this.disposed || this.running) return;
|
|
218
|
+
this.running = true;
|
|
219
|
+
void this.pollOnce();
|
|
220
|
+
this.timer = setInterval(() => void this.pollOnce(), this.options.intervalMs);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
stop(): void {
|
|
224
|
+
this.running = false;
|
|
225
|
+
if (this.timer !== null) {
|
|
226
|
+
clearInterval(this.timer);
|
|
227
|
+
this.timer = null;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Run one bounded pass over all hosts. Safe to await in tests. */
|
|
232
|
+
async pollOnce(): Promise<void> {
|
|
233
|
+
if (this.disposed || this.tickInFlight) return;
|
|
234
|
+
this.tickInFlight = true;
|
|
235
|
+
try {
|
|
236
|
+
await Promise.all(
|
|
237
|
+
[...this.snapshots.keys()].map((serverId) => this.probeHost(serverId)),
|
|
238
|
+
);
|
|
239
|
+
} finally {
|
|
240
|
+
this.tickInFlight = false;
|
|
241
|
+
this.emit();
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
getSnapshots(): FleetHostSnapshot[] {
|
|
246
|
+
return sortFleetSnapshots([...this.snapshots.values()]);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
dispose(): void {
|
|
250
|
+
if (this.disposed) return;
|
|
251
|
+
this.disposed = true;
|
|
252
|
+
this.stop();
|
|
253
|
+
for (const serverId of [...this.clients.keys()]) {
|
|
254
|
+
this.disposeClient(serverId);
|
|
255
|
+
}
|
|
256
|
+
this.snapshots.clear();
|
|
257
|
+
this.inFlight.clear();
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
private async probeHost(serverId: string): Promise<void> {
|
|
261
|
+
const snapshot = this.snapshots.get(serverId);
|
|
262
|
+
if (!snapshot) return;
|
|
263
|
+
|
|
264
|
+
if (snapshot.connectionStatus !== "online") {
|
|
265
|
+
snapshot.status = fleetStatusFor(snapshot.connectionStatus);
|
|
266
|
+
snapshot.error =
|
|
267
|
+
snapshot.connectionStatus === "error" ? "Host connection error" : null;
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
if (this.inFlight.has(serverId)) return;
|
|
271
|
+
|
|
272
|
+
this.inFlight.add(serverId);
|
|
273
|
+
const generation = this.currentGeneration(serverId);
|
|
274
|
+
const startedAt = this.options.now();
|
|
275
|
+
let timedOut = false;
|
|
276
|
+
try {
|
|
277
|
+
const client = this.acquireClient(serverId);
|
|
278
|
+
const counts = await withTimeout(
|
|
279
|
+
client.readCounts(),
|
|
280
|
+
this.options.timeoutMs,
|
|
281
|
+
`fleet probe ${snapshot.label}`,
|
|
282
|
+
);
|
|
283
|
+
if (this.currentGeneration(serverId) !== generation) return;
|
|
284
|
+
snapshot.status = "online";
|
|
285
|
+
snapshot.counts = counts;
|
|
286
|
+
snapshot.latencyMs = this.options.now() - startedAt;
|
|
287
|
+
snapshot.lastUpdatedAt = this.options.now();
|
|
288
|
+
snapshot.staleAt = null;
|
|
289
|
+
snapshot.error = null;
|
|
290
|
+
} catch (cause) {
|
|
291
|
+
if (this.currentGeneration(serverId) !== generation) return;
|
|
292
|
+
timedOut = cause instanceof TimeoutError;
|
|
293
|
+
if (timedOut) {
|
|
294
|
+
// Unresponsive: mark stale with a timestamp and keep the borrowed
|
|
295
|
+
// client. The next 15s tick is the only retry.
|
|
296
|
+
snapshot.status = "stale";
|
|
297
|
+
snapshot.staleAt = this.options.now();
|
|
298
|
+
snapshot.error = "Timed out";
|
|
299
|
+
} else {
|
|
300
|
+
// A released/replaced connection surfaces here; drop the client so the
|
|
301
|
+
// next probe reacquires a fresh one.
|
|
302
|
+
snapshot.status = "error";
|
|
303
|
+
snapshot.error = errorMessage(cause);
|
|
304
|
+
this.disposeClient(serverId);
|
|
305
|
+
}
|
|
306
|
+
} finally {
|
|
307
|
+
this.inFlight.delete(serverId);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
private acquireClient(serverId: string): FleetHostClient {
|
|
312
|
+
const existing = this.clients.get(serverId);
|
|
313
|
+
if (existing) return existing;
|
|
314
|
+
const client = this.options.acquire(serverId);
|
|
315
|
+
this.clients.set(serverId, client);
|
|
316
|
+
return client;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
private disposeClient(serverId: string): void {
|
|
320
|
+
const client = this.clients.get(serverId);
|
|
321
|
+
if (!client) return;
|
|
322
|
+
this.clients.delete(serverId);
|
|
323
|
+
try {
|
|
324
|
+
void client.dispose();
|
|
325
|
+
} catch {
|
|
326
|
+
// A released borrowed client may already be disposed; ignore.
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
private currentGeneration(serverId: string): number {
|
|
331
|
+
return this.generation.get(serverId) ?? 0;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
private bumpGeneration(serverId: string): void {
|
|
335
|
+
this.generation.set(serverId, this.currentGeneration(serverId) + 1);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
private emit(): void {
|
|
339
|
+
this.options.onUpdate?.(this.getSnapshots());
|
|
340
|
+
}
|
|
341
|
+
}
|