@thinremote/thinr-cli 1.2.0-alpha.1 → 1.2.0

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.
Files changed (91) hide show
  1. package/CHANGELOG.md +45 -1
  2. package/README.md +192 -16
  3. package/bin/thinr.js +23 -24
  4. package/commands/_shared.js +154 -0
  5. package/commands/dashboard.js +17 -0
  6. package/commands/device/_shared.js +17 -34
  7. package/commands/device/console.js +1 -0
  8. package/commands/device/delete.js +53 -0
  9. package/commands/device/exec.js +7 -6
  10. package/commands/device/fs.js +193 -0
  11. package/commands/device/index.js +9 -0
  12. package/commands/device/list.js +35 -23
  13. package/commands/device/logs.js +178 -0
  14. package/commands/device/property.js +30 -28
  15. package/commands/device/proxy.js +3 -0
  16. package/commands/device/resource.js +30 -29
  17. package/commands/device/status.js +35 -14
  18. package/commands/device/transfer.js +236 -0
  19. package/commands/device/update.js +15 -10
  20. package/commands/fleet/index.js +17 -0
  21. package/commands/fleet/upgrade.js +275 -0
  22. package/commands/logout.js +10 -11
  23. package/commands/playbook/_shared.js +12 -0
  24. package/commands/playbook/index.js +18 -0
  25. package/commands/playbook/run.js +302 -0
  26. package/commands/playbook/validate.js +38 -0
  27. package/commands/product/_shared.js +8 -0
  28. package/commands/product/exec.js +243 -0
  29. package/commands/product/fs.js +218 -0
  30. package/commands/product/index.js +35 -0
  31. package/commands/product/list.js +86 -0
  32. package/commands/product/logs.js +289 -0
  33. package/commands/product/metric.js +124 -0
  34. package/commands/product/playbook.js +911 -0
  35. package/commands/product/property.js +76 -0
  36. package/commands/product/props.js +125 -0
  37. package/commands/product/push.js +184 -0
  38. package/commands/product/resource.js +76 -0
  39. package/commands/product/status.js +213 -0
  40. package/commands/profile.js +76 -8
  41. package/dist/dashboard.js +5644 -0
  42. package/lib/agent-releases.js +59 -0
  43. package/lib/agent-versions.js +73 -0
  44. package/lib/alarms.js +252 -0
  45. package/lib/api.js +8 -2
  46. package/lib/auth.js +30 -17
  47. package/lib/bucket.js +87 -0
  48. package/lib/command-stream.js +199 -0
  49. package/lib/config.js +5 -3
  50. package/lib/console.js +12 -4
  51. package/lib/constants.js +76 -0
  52. package/lib/dashboard/event-stream.js +239 -0
  53. package/lib/dashboard/metric-aggregate.js +64 -0
  54. package/lib/dashboard/metric-filter.js +39 -0
  55. package/lib/debug-log.js +93 -0
  56. package/lib/device-api.js +109 -147
  57. package/lib/devices.js +18 -21
  58. package/lib/fleet-upgrade.js +106 -0
  59. package/lib/format.js +114 -0
  60. package/lib/mcp/registry.js +14 -0
  61. package/lib/mcp/tools-alarms.js +510 -0
  62. package/lib/mcp/tools-buckets.js +103 -0
  63. package/lib/mcp/tools-devices.js +33 -3
  64. package/lib/mcp/tools-exec.js +30 -17
  65. package/lib/mcp/tools-filesystem.js +102 -6
  66. package/lib/mcp/tools-install.js +153 -0
  67. package/lib/mcp/tools-logs.js +225 -0
  68. package/lib/mcp/tools-playbook.js +211 -0
  69. package/lib/mcp/tools-products.js +1226 -105
  70. package/lib/mcp/tools-profile.js +634 -0
  71. package/lib/mcp/tools-tokens.js +371 -0
  72. package/lib/monitoring.js +26 -1
  73. package/lib/playbook/actions.js +447 -0
  74. package/lib/playbook/expression.js +302 -0
  75. package/lib/playbook/fleet.js +175 -0
  76. package/lib/playbook/loader.js +247 -0
  77. package/lib/playbook/runner.js +300 -0
  78. package/lib/playbook/schema.js +209 -0
  79. package/lib/playbook/vars.js +209 -0
  80. package/lib/product/log-presets.js +196 -0
  81. package/lib/product/logs.js +485 -0
  82. package/lib/product-orchestrator.js +124 -0
  83. package/lib/product.js +523 -20
  84. package/lib/profile.js +665 -0
  85. package/lib/property.js +7 -40
  86. package/lib/proxy.js +9 -8
  87. package/lib/resource.js +10 -14
  88. package/lib/status.js +78 -37
  89. package/lib/tokens.js +374 -0
  90. package/package.json +14 -3
  91. package/commands/product.js +0 -407
@@ -0,0 +1,199 @@
1
+ // @ts-check
2
+ import WebSocket from 'ws';
3
+ import { randomUUID } from 'crypto';
4
+ import { TIMEOUTS } from './constants.js';
5
+
6
+ /**
7
+ * One-shot execution of a shell command on a device over the IOTMP
8
+ * WebSocket stream. The class exists to keep the four concerns of
9
+ * streaming exec — URL/auth, framing, safety timeout, cancellation —
10
+ * in visibly separate methods instead of braided together inside one
11
+ * big promise executor.
12
+ *
13
+ * Lifecycle:
14
+ * new CommandStream({...}).run() → Promise<{exitCode, timedOut, cancelled}>
15
+ *
16
+ * `run()` opens the socket, resolves when the server sends the exit
17
+ * frame (or the socket closes, or the safety timer fires, or `cancel()`
18
+ * is called), and tears the WS down without waiting for the server's
19
+ * stop cascade.
20
+ */
21
+ export class CommandStream {
22
+ /**
23
+ * @param {{
24
+ * server: string,
25
+ * token: string,
26
+ * apiUser: string,
27
+ * deviceId: string,
28
+ * insecure: boolean,
29
+ * cmd: string,
30
+ * timeout?: number,
31
+ * onStdout?: (s: string) => void,
32
+ * onStderr?: (s: string) => void,
33
+ * onCancel?: (cancel: () => void) => void,
34
+ * stdin?: NodeJS.ReadableStream,
35
+ * }} opts
36
+ */
37
+ constructor(opts) {
38
+ this.opts = opts;
39
+ this.sessionId = randomUUID();
40
+ this.resource = `$cmd/${this.sessionId}`;
41
+
42
+ this.exitCode = null;
43
+ this.timedOut = false;
44
+ this.cancelled = false;
45
+
46
+ /** @type {WebSocket | null} */
47
+ this.ws = null;
48
+ this.resolved = false;
49
+ /** @type {NodeJS.Timeout | null} */
50
+ this.safetyTimer = null;
51
+ /** @type {((value: {exitCode: number | null, timedOut: boolean, cancelled: boolean}) => void) | null} */
52
+ this._resolve = null;
53
+ /** @type {((reason?: any) => void) | null} */
54
+ this._reject = null;
55
+ }
56
+
57
+ run() {
58
+ this.opts.onCancel?.(() => this.cancel());
59
+ return new Promise((resolve, reject) => {
60
+ this._resolve = resolve;
61
+ this._reject = reject;
62
+ this.ws = this._openSocket();
63
+ this._armSafety();
64
+ this._wireEvents(this.ws);
65
+ });
66
+ }
67
+
68
+ cancel() {
69
+ this.cancelled = true;
70
+ if (
71
+ this.ws &&
72
+ (this.ws.readyState === WebSocket.OPEN ||
73
+ this.ws.readyState === WebSocket.CONNECTING)
74
+ ) {
75
+ this.ws.close();
76
+ }
77
+ }
78
+
79
+ _buildUrl() {
80
+ const { server, apiUser, deviceId, cmd, timeout } = this.opts;
81
+ const params = new URLSearchParams({ cmd });
82
+ if (timeout && timeout > 0) params.set('timeout', String(timeout));
83
+ return `wss://${server}/v3/users/${apiUser}/devices/${deviceId}/resources/${this.resource}?${params}`;
84
+ }
85
+
86
+ _openSocket() {
87
+ return new WebSocket(this._buildUrl(), {
88
+ headers: { Authorization: `Bearer ${this.opts.token}` },
89
+ rejectUnauthorized: !this.opts.insecure,
90
+ });
91
+ }
92
+
93
+ /**
94
+ * Client-side safety timeout. The `timeout` URL param asks the
95
+ * server/agent to bound the command, but if the agent is hung the
96
+ * server's own timeout can take longer than expected (or not fire
97
+ * for some states) and the WS would otherwise stay open until
98
+ * ping/pong death. Grace period lives in constants.js.
99
+ */
100
+ _armSafety() {
101
+ const { timeout } = this.opts;
102
+ if (!timeout || timeout <= 0) return;
103
+ this.safetyTimer = setTimeout(() => {
104
+ if (this.resolved) return;
105
+ this.timedOut = true;
106
+ this._finish();
107
+ }, timeout * 1000 + TIMEOUTS.EXEC_SAFETY_GRACE_MS);
108
+ }
109
+
110
+ _disarmSafety() {
111
+ if (this.safetyTimer) {
112
+ clearTimeout(this.safetyTimer);
113
+ this.safetyTimer = null;
114
+ }
115
+ }
116
+
117
+ _wireEvents(ws) {
118
+ ws.on('open', () => this._forwardStdin(ws));
119
+ ws.on('error', (err) => {
120
+ if (this.cancelled || this.resolved) return;
121
+ this._disarmSafety();
122
+ this._reject?.(err);
123
+ });
124
+ ws.on('message', (raw) => this._handleMessage(raw));
125
+ ws.on('close', () => this._finish());
126
+ }
127
+
128
+ /**
129
+ * If the caller supplied a stdin stream, forward each chunk to the
130
+ * device as a stream frame. Payload is sent as a UTF-8 string
131
+ * under the standard {resource, payload} envelope that the
132
+ * server's stream handler expects.
133
+ */
134
+ _forwardStdin(ws) {
135
+ const { stdin } = this.opts;
136
+ if (!stdin || typeof stdin.on !== 'function') return;
137
+ stdin.on('data', (chunk) => {
138
+ if (ws.readyState !== WebSocket.OPEN) return;
139
+ const text = Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk);
140
+ ws.send(JSON.stringify({ resource: this.resource, payload: text }));
141
+ });
142
+ }
143
+
144
+ _handleMessage(raw) {
145
+ let evt;
146
+ try {
147
+ evt = JSON.parse(raw.toString('utf8'));
148
+ } catch {
149
+ return;
150
+ }
151
+ if (evt.signal && evt.signal !== 'data') return;
152
+ const p = evt.payload;
153
+ if (!p || typeof p !== 'object') return;
154
+ if (p.out !== undefined) {
155
+ this.opts.onStdout?.(decodeBytes(p.out));
156
+ } else if (p.err !== undefined) {
157
+ this.opts.onStderr?.(decodeBytes(p.err));
158
+ } else if (typeof p.exit === 'number') {
159
+ this.exitCode = p.exit;
160
+ if (p.timeout === true) this.timedOut = true;
161
+ // We have the full result — resolve immediately and tear
162
+ // down the WS without waiting for the server's stop
163
+ // cascade, which can add several seconds.
164
+ this._finish();
165
+ }
166
+ }
167
+
168
+ _finish() {
169
+ if (this.resolved) return;
170
+ this.resolved = true;
171
+ this._disarmSafety();
172
+ this._resolve?.({
173
+ exitCode: this.exitCode,
174
+ timedOut: this.timedOut,
175
+ cancelled: this.cancelled,
176
+ });
177
+ if (
178
+ this.ws &&
179
+ (this.ws.readyState === WebSocket.OPEN ||
180
+ this.ws.readyState === WebSocket.CONNECTING)
181
+ ) {
182
+ this.ws.terminate();
183
+ }
184
+ }
185
+ }
186
+
187
+ /**
188
+ * Decode a frame field that the server may deliver either as a plain
189
+ * string or as the `{bytes: [...]}` envelope used for binary-safe
190
+ * payloads. Non-strings fall back to empty so downstream handlers
191
+ * don't have to guard.
192
+ */
193
+ function decodeBytes(field) {
194
+ if (typeof field === 'string') return field;
195
+ if (field && Array.isArray(field.bytes)) {
196
+ return Buffer.from(field.bytes).toString('utf8');
197
+ }
198
+ return '';
199
+ }
package/lib/config.js CHANGED
@@ -176,12 +176,14 @@ export function requireConfig() {
176
176
 
177
177
  /**
178
178
  * Write a fresh profile from the supplied data. The profile name is
179
- * derived from the supplied `server`, or from the active profile name
180
- * if no server is provided. Becomes the default if there isn't one yet.
179
+ * picked (in order): an explicit override set via setActiveProfile()
180
+ * (used by `profile add` to scope the write to a named profile), the
181
+ * supplied `server`, or the currently active profile name. Becomes the
182
+ * default if there isn't one yet.
181
183
  */
182
184
  export function writeConfig(config) {
183
185
  const store = readStore();
184
- const name = (config && config.server) || getActiveProfile();
186
+ const name = activeProfileOverride || (config && config.server) || getActiveProfile();
185
187
  if (!name) {
186
188
  throw new Error('Cannot write config without a server / profile name');
187
189
  }
package/lib/console.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import WebSocket from 'ws';
2
- import chalk from 'chalk';
3
2
  import { requireConfig } from './config.js';
4
3
  import api from './api.js';
4
+ import { warning, error as errorStyle } from './format.js';
5
5
 
6
6
  /**
7
7
  * Creates a WebSocket connection to a device terminal. Returns a
@@ -58,9 +58,17 @@ export async function connectToDeviceConsole(deviceId) {
58
58
  });
59
59
 
60
60
  process.stdin.on('data', (data) => {
61
- // Ctrl+D (EOT) to exit gracefully
61
+ // Ctrl+D (EOT) to exit gracefully. The `ws.close()` below
62
+ // initiates a close handshake and waits for the server to
63
+ // acknowledge; when the server is slow to respond, the
64
+ // client would otherwise hang on keep-alive/TCP timeouts.
65
+ // Watchdog forces the socket down after a short grace so
66
+ // control returns to the shell promptly.
62
67
  if (data.toString() === '\u0004') {
63
68
  ws.close();
69
+ setTimeout(() => {
70
+ if (ws.readyState !== WebSocket.CLOSED) ws.terminate();
71
+ }, 500);
64
72
  return;
65
73
  }
66
74
  // Forward everything else (including Ctrl+C) to the device
@@ -74,7 +82,7 @@ export async function connectToDeviceConsole(deviceId) {
74
82
  });
75
83
 
76
84
  ws.on('close', () => {
77
- console.log(chalk.yellow('\nConnection closed'));
85
+ console.log(warning('\nConnection closed'));
78
86
  restoreTerminal();
79
87
  // Small delay so any final output flushes before the caller exits.
80
88
  setTimeout(() => resolve(0), 100);
@@ -133,7 +141,7 @@ async function updateTerminalSize(config, deviceId, sessionId, cols, rows) {
133
141
  },
134
142
  });
135
143
  } catch (error) {
136
- console.error(chalk.red(`Error updating terminal size: ${error.message}`));
144
+ console.error(errorStyle(`Error updating terminal size: ${error.message}`));
137
145
  }
138
146
  }
139
147
 
@@ -0,0 +1,76 @@
1
+ // @ts-check
2
+
3
+ /**
4
+ * Central home for the numeric knobs the CLI scatters otherwise.
5
+ * Keep every "magic number" that has meaning beyond its immediate call
6
+ * site here so we can tune them in one place (and grep for them when
7
+ * debugging a stuck transfer or a missed OAuth poll).
8
+ */
9
+
10
+ /** Timeouts. All values in ms unless the constant name ends in `_SECONDS`. */
11
+ export const TIMEOUTS = {
12
+ /** Default axios request timeout for control-plane HTTP calls. */
13
+ API_DEFAULT_MS: 10_000,
14
+
15
+ /** POSTs to device resources (cmd, update, etc.) — mid-weight calls. */
16
+ DEVICE_RESOURCE_CALL_MS: 30_000,
17
+
18
+ /** GETs that read a device resource value. */
19
+ DEVICE_RESOURCE_GET_MS: 15_000,
20
+
21
+ /** Playbook `update: apply` — agent updates can take minutes. */
22
+ DEVICE_UPDATE_APPLY_MS: 300_000,
23
+
24
+ /**
25
+ * Grace period added client-side on top of the server/agent's exec
26
+ * timeout so a hung agent doesn't keep the WebSocket open forever.
27
+ * Not proportional to the declared timeout — a 5-min command
28
+ * shouldn't wait 5 extra minutes just in case.
29
+ */
30
+ EXEC_SAFETY_GRACE_MS: 2_000,
31
+
32
+ /**
33
+ * Extra headroom on the HTTP call that wraps the legacy `api.exec`
34
+ * resource call, so the agent's own timeout has a chance to fire
35
+ * and return a proper exit code before axios aborts.
36
+ */
37
+ EXEC_EXTRA_GRACE_MS: 5_000,
38
+
39
+ /** Hard ceiling for the OAuth device-code flow — 10 minutes. */
40
+ OAUTH_DEVICE_CODE_MS: 10 * 60 * 1000,
41
+
42
+ /**
43
+ * Default command timeout (in *seconds*) for user-facing exec
44
+ * flows: CLI `device exec`, `product exec`, playbook `exec`, MCP
45
+ * `thinr_exec`. Callers override when they know better.
46
+ */
47
+ DEFAULT_EXEC_SECONDS: 30,
48
+
49
+ /** Chmod after a script install: tiny op, short seconds-level cap. */
50
+ SCRIPT_CHMOD_SECONDS: 10,
51
+ };
52
+
53
+ /** Byte units, so callers don't have to eyeball `1073741824`. */
54
+ export const BYTES = {
55
+ KB: 1024,
56
+ MB: 1024 * 1024,
57
+ GB: 1024 * 1024 * 1024,
58
+
59
+ /**
60
+ * Chunk size used when wrapping in-memory buffers as Readable
61
+ * streams for file uploads, so `onUploadProgress` fires per chunk
62
+ * instead of once at 0% and once at 100%.
63
+ */
64
+ STREAM_CHUNK: 64 * 1024,
65
+ };
66
+
67
+ /** Random-port range used when creating TCP/HTTP proxies. */
68
+ export const PROXY_PORTS = {
69
+ MIN: 50_000,
70
+ MAX: 51_000,
71
+ };
72
+
73
+ /** OAuth device-flow client identity registered for this CLI. */
74
+ export const OAUTH = {
75
+ CLIENT_ID: '3b40164d28730e416cbd',
76
+ };
@@ -0,0 +1,239 @@
1
+ // @ts-check
2
+ import WebSocket from 'ws';
3
+ import { readConfig } from '../config.js';
4
+ import { refreshToken } from '../auth.js';
5
+ import { debugCount, debugLog } from '../debug-log.js';
6
+
7
+ // Single shared websocket to /v2/users/{user}/events. Both fleet
8
+ // monitoring and product metrics live on the same endpoint with the
9
+ // same auth, so opening two sockets per dashboard session is just
10
+ // extra load on both ends.
11
+ //
12
+ // Use:
13
+ // const off = eventStream.on('bucket_write', (frame) => {...});
14
+ // eventStream.subscribe({ event: 'bucket_write', filters: { bucket } });
15
+ // eventStream.connect(); // ref-counted; first call opens
16
+ // ...
17
+ // off(); // stop receiving frames
18
+ // eventStream.disconnect(); // ref-counted; last call closes
19
+ //
20
+ // `subscribe` is additive and idempotent — the server has no
21
+ // unsubscribe verb in this protocol, so we can't take a filter back.
22
+ // What we *can* do is dedupe locally so the same filter isn't sent
23
+ // twice per session, and replay every active subscription whenever
24
+ // the socket reconnects.
25
+
26
+ const MAX_BACKOFF_MS = 30_000;
27
+
28
+ class EventStream {
29
+ constructor() {
30
+ /** @type {WebSocket | null} */
31
+ this.ws = null;
32
+ this.refCount = 0;
33
+ /** @type {Map<string, Set<(frame: any) => void>>} */
34
+ this.handlers = new Map();
35
+ /** @type {Map<string, any>} */
36
+ this.subs = new Map();
37
+ this.reconnectAttempts = 0;
38
+ /** @type {NodeJS.Timeout | null} */
39
+ this.reconnectTimer = null;
40
+ this.disposed = true;
41
+ // Used by `onReconnect` to skip the very first open — that one is the
42
+ // initial connection, not a reconnect, and consumers already seed
43
+ // their state via the REST snapshot at mount time.
44
+ this.hasOpenedOnce = false;
45
+ /** @type {Set<() => void>} */
46
+ this.reconnectHandlers = new Set();
47
+ }
48
+
49
+ // Fired when the socket reopens after a previous disconnection. Use it
50
+ // to re-fetch any REST snapshot that backs an event-driven view, since
51
+ // events emitted while we were offline are gone for good.
52
+ onReconnect(handler) {
53
+ this.reconnectHandlers.add(handler);
54
+ return () => this.reconnectHandlers.delete(handler);
55
+ }
56
+
57
+ connect() {
58
+ this.refCount++;
59
+ this.disposed = false;
60
+ if (this.ws == null && this.reconnectTimer == null) this._open();
61
+ }
62
+
63
+ disconnect() {
64
+ this.refCount = Math.max(0, this.refCount - 1);
65
+ if (this.refCount === 0) {
66
+ this.disposed = true;
67
+ this._teardown();
68
+ }
69
+ }
70
+
71
+ on(eventName, handler) {
72
+ let set = this.handlers.get(eventName);
73
+ if (!set) {
74
+ set = new Set();
75
+ this.handlers.set(eventName, set);
76
+ }
77
+ set.add(handler);
78
+ return () => {
79
+ const cur = this.handlers.get(eventName);
80
+ if (cur) {
81
+ cur.delete(handler);
82
+ if (cur.size === 0) this.handlers.delete(eventName);
83
+ }
84
+ };
85
+ }
86
+
87
+ subscribe(message) {
88
+ const key = JSON.stringify(message);
89
+ if (this.subs.has(key)) return;
90
+ this.subs.set(key, message);
91
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
92
+ debugLog('ws:events', 'subscribe', message);
93
+ this.ws.send(key);
94
+ }
95
+ }
96
+
97
+ async _open() {
98
+ // If a previous handshake hit 401 we trigger a token refresh in the
99
+ // background; await it here so the next attempt uses the fresh
100
+ // token instead of racing the HTTP interceptor's refresh.
101
+ if (this._refreshing) {
102
+ try { await this._refreshing; } catch { /* fall through; readConfig may still have a usable token */ }
103
+ }
104
+ if (this.disposed) return;
105
+ const config = readConfig();
106
+ if (!config?.server || !config?.token || !config?.username) return;
107
+ const url = `wss://${config.server}/v2/users/${config.username}/events?authorization=${config.token}`;
108
+ debugLog('ws:events', 'connecting', {
109
+ endpoint: `wss://${config.server}/v2/users/${config.username}/events`,
110
+ pending_subs: this.subs.size,
111
+ });
112
+ const ws = new WebSocket(url);
113
+ this.ws = ws;
114
+
115
+ ws.on('open', () => {
116
+ if (this.disposed) {
117
+ ws.close();
118
+ return;
119
+ }
120
+ const isReconnect = this.hasOpenedOnce;
121
+ this.hasOpenedOnce = true;
122
+ this.reconnectAttempts = 0;
123
+ debugLog('ws:events', 'open', {
124
+ sub_replay: this.subs.size,
125
+ reconnect: isReconnect,
126
+ });
127
+ for (const [key, msg] of this.subs.entries()) {
128
+ debugLog('ws:events', 'subscribe', msg);
129
+ ws.send(key);
130
+ }
131
+ // Notify after replaying subs so reconnect handlers run with a
132
+ // fully resubscribed socket — they can rely on new events landing.
133
+ if (isReconnect) {
134
+ for (const h of this.reconnectHandlers) {
135
+ try { h(); } catch (err) {
136
+ debugLog('ws:events', 'reconnect-handler-error', {
137
+ error: err?.message || String(err),
138
+ });
139
+ }
140
+ }
141
+ }
142
+ });
143
+
144
+ ws.on('message', (raw) => {
145
+ const bytes = raw?.length ?? 0;
146
+ debugCount('ws:events:bytes', bytes);
147
+ debugCount('ws:events:frames');
148
+ let frame;
149
+ try {
150
+ frame = JSON.parse(raw.toString('utf8'));
151
+ } catch {
152
+ return;
153
+ }
154
+ if (frame?.registered || frame?.success === false) {
155
+ debugLog('ws:events', 'ack', frame);
156
+ return;
157
+ }
158
+ const ev = frame?.event;
159
+ if (!ev) return;
160
+ debugCount(`ws:events:${ev}`);
161
+ const set = this.handlers.get(ev);
162
+ if (!set || set.size === 0) return;
163
+ for (const h of set) {
164
+ try {
165
+ h(frame);
166
+ } catch (err) {
167
+ debugLog('ws:events', 'handler-error', {
168
+ event: ev,
169
+ error: err?.message || String(err),
170
+ });
171
+ }
172
+ }
173
+ });
174
+
175
+ ws.on('error', (err) => {
176
+ debugLog('ws:events', 'error', { error: err?.message || String(err) });
177
+ });
178
+
179
+ // Server rejects expired tokens at the WS handshake with HTTP 401.
180
+ // Without this, we'd reconnect with the same stale token until the
181
+ // exponential backoff outlasted the HTTP interceptor's refresh,
182
+ // which leaves the dashboard empty for up to a minute on the first
183
+ // open after a token expires. Trigger an explicit refresh; _open
184
+ // awaits the in-flight promise on the next reconnect attempt.
185
+ ws.on('unexpected-response', (req, res) => {
186
+ debugLog('ws:events', 'unexpected-response', {
187
+ status: res?.statusCode,
188
+ pending_refresh: !!this._refreshing,
189
+ });
190
+ if (res?.statusCode === 401 && !this._refreshing) {
191
+ this._refreshing = refreshToken()
192
+ .then(() => {
193
+ debugLog('ws:events', 'token-refreshed');
194
+ })
195
+ .catch((err) => {
196
+ debugLog('ws:events', 'token-refresh-failed', {
197
+ error: err?.message || String(err),
198
+ });
199
+ })
200
+ .finally(() => {
201
+ this._refreshing = null;
202
+ });
203
+ }
204
+ });
205
+
206
+ ws.on('close', (code, reason) => {
207
+ this.ws = null;
208
+ debugLog('ws:events', 'close', { code, reason: reason?.toString?.() });
209
+ if (this.disposed) return;
210
+ const attempt = this.reconnectAttempts++;
211
+ const delay = Math.min(2 ** attempt * 1000, MAX_BACKOFF_MS);
212
+ debugLog('ws:events', 'reconnect-scheduled', { attempt, delay_ms: delay });
213
+ this.reconnectTimer = setTimeout(() => {
214
+ this.reconnectTimer = null;
215
+ if (!this.disposed) this._open();
216
+ }, delay);
217
+ });
218
+ }
219
+
220
+ _teardown() {
221
+ if (this.reconnectTimer) {
222
+ clearTimeout(this.reconnectTimer);
223
+ this.reconnectTimer = null;
224
+ }
225
+ if (this.ws) {
226
+ try {
227
+ this.ws.close();
228
+ } catch {
229
+ // ignore
230
+ }
231
+ this.ws = null;
232
+ }
233
+ this.subs.clear();
234
+ this.handlers.clear();
235
+ this.reconnectAttempts = 0;
236
+ }
237
+ }
238
+
239
+ export const eventStream = new EventStream();
@@ -0,0 +1,64 @@
1
+ // @ts-check
2
+
3
+ // Pure helpers for product-metric aggregation. Extracted from
4
+ // `useProductMetrics` so the dashboard hook stays minimal and the
5
+ // reducers can be unit-tested without dragging React or the websocket
6
+ // singleton into the test runtime.
7
+
8
+ /**
9
+ * Walk a dotted path into a payload. Returns `obj` itself when no path
10
+ * is supplied, and `undefined` when any intermediate hop is null.
11
+ *
12
+ * @param {*} obj
13
+ * @param {string | undefined} dotted
14
+ */
15
+ export function getPath(obj, dotted) {
16
+ if (!dotted) return obj;
17
+ return dotted.split('.').reduce((acc, key) => (acc == null ? acc : acc[key]), obj);
18
+ }
19
+
20
+ /**
21
+ * Combine the latest values reported by every device of a product into
22
+ * a single panel value. Numeric reducers ignore non-numeric / null
23
+ * samples; categorical reducers operate on the raw values.
24
+ *
25
+ * distribution → Record<string, number> (value coerced to string → device count)
26
+ * count → number of devices reporting any non-null value
27
+ * none → the raw values array, untouched
28
+ * sum/avg/max/min → number, or null when no numeric samples are present
29
+ *
30
+ * @param {readonly unknown[]} values
31
+ * @param {string | undefined} mode
32
+ */
33
+ export function aggregate(values, mode) {
34
+ if (mode === 'distribution') {
35
+ /** @type {Record<string, number>} */
36
+ const buckets = {};
37
+ for (const v of values) {
38
+ if (v == null) continue;
39
+ const key = String(v);
40
+ if (key === '') continue;
41
+ buckets[key] = (buckets[key] || 0) + 1;
42
+ }
43
+ return buckets;
44
+ }
45
+ if (mode === 'count') {
46
+ return values.filter((v) => v != null).length;
47
+ }
48
+ if (mode === 'none') {
49
+ return values;
50
+ }
51
+ const nums = values.filter((v) => Number.isFinite(Number(v))).map(Number);
52
+ if (nums.length === 0) return null;
53
+ switch (mode) {
54
+ case 'avg':
55
+ return nums.reduce((a, b) => a + b, 0) / nums.length;
56
+ case 'max':
57
+ return Math.max(...nums);
58
+ case 'min':
59
+ return Math.min(...nums);
60
+ case 'sum':
61
+ default:
62
+ return nums.reduce((a, b) => a + b, 0);
63
+ }
64
+ }
@@ -0,0 +1,39 @@
1
+ // @ts-check
2
+
3
+ // Pure helper for the "click a bucket → filter devices" interaction.
4
+ //
5
+ // `valuesByDevice` mirrors the per-device latest sample map maintained
6
+ // inside `useProductMetrics`: keyed by metricKey (`<product>:<name>`),
7
+ // the value is a map of deviceId → latest value reported for that
8
+ // metric. We compare reported values to the bucket key after coercing
9
+ // both sides to string, matching the behaviour of the `distribution`
10
+ // aggregator (which also bins by `String(v)`).
11
+
12
+ /**
13
+ * @typedef {{ device: string, [k: string]: any }} DeviceLike
14
+ * @typedef {{ metricKey: string, bucket: string, [k: string]: any } | null | undefined} MetricFilter
15
+ */
16
+
17
+ /**
18
+ * Filter a fleet view down to the devices whose latest sample for the
19
+ * filter's metric equals the chosen bucket. Devices missing a sample for
20
+ * that metric (offline or never-reported) drop out — they belong to the
21
+ * implicit "no data" bucket, which the panel doesn't surface.
22
+ *
23
+ * @template {DeviceLike} D
24
+ * @param {readonly D[]} devices
25
+ * @param {Record<string, Record<string, any>> | null | undefined} valuesByDevice
26
+ * @param {MetricFilter} metricFilter
27
+ * @returns {D[]}
28
+ */
29
+ export function filterDevicesByMetric(devices, valuesByDevice, metricFilter) {
30
+ if (!metricFilter || metricFilter.bucket == null) return [...devices];
31
+ const map = valuesByDevice?.[metricFilter.metricKey];
32
+ if (!map) return [];
33
+ const target = String(metricFilter.bucket);
34
+ return devices.filter((d) => {
35
+ const v = map[d.device];
36
+ if (v == null) return false;
37
+ return String(v) === target;
38
+ });
39
+ }