@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.
@@ -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
- export const defaultDarkTheme: PluginTheme = {
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.2",
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-beta.2",
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
@@ -1,4 +1,4 @@
1
1
  {
2
2
  "id": "top",
3
- "requirements": { "paseo": ">=0.8.0" }
3
+ "requirements": { "paseo": ">=0.9.0" }
4
4
  }
@@ -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
+ }