@thinremote/thinr-cli 1.2.0-alpha.1 → 1.3.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 (92) hide show
  1. package/CHANGELOG.md +63 -1
  2. package/README.md +235 -30
  3. package/bin/thinr.js +43 -25
  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 +68 -20
  47. package/lib/bucket.js +87 -0
  48. package/lib/command-stream.js +213 -0
  49. package/lib/config.js +5 -3
  50. package/lib/console.js +19 -5
  51. package/lib/constants.js +83 -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 +131 -148
  57. package/lib/devices.js +18 -21
  58. package/lib/errors.js +12 -0
  59. package/lib/fleet-upgrade.js +106 -0
  60. package/lib/format.js +114 -0
  61. package/lib/mcp/registry.js +14 -0
  62. package/lib/mcp/tools-alarms.js +510 -0
  63. package/lib/mcp/tools-buckets.js +103 -0
  64. package/lib/mcp/tools-devices.js +33 -3
  65. package/lib/mcp/tools-exec.js +30 -17
  66. package/lib/mcp/tools-filesystem.js +102 -6
  67. package/lib/mcp/tools-install.js +153 -0
  68. package/lib/mcp/tools-logs.js +225 -0
  69. package/lib/mcp/tools-playbook.js +211 -0
  70. package/lib/mcp/tools-products.js +1226 -105
  71. package/lib/mcp/tools-profile.js +634 -0
  72. package/lib/mcp/tools-tokens.js +371 -0
  73. package/lib/monitoring.js +26 -1
  74. package/lib/playbook/actions.js +447 -0
  75. package/lib/playbook/expression.js +302 -0
  76. package/lib/playbook/fleet.js +175 -0
  77. package/lib/playbook/loader.js +247 -0
  78. package/lib/playbook/runner.js +300 -0
  79. package/lib/playbook/schema.js +209 -0
  80. package/lib/playbook/vars.js +209 -0
  81. package/lib/product/log-presets.js +196 -0
  82. package/lib/product/logs.js +485 -0
  83. package/lib/product-orchestrator.js +124 -0
  84. package/lib/product.js +523 -20
  85. package/lib/profile.js +665 -0
  86. package/lib/property.js +7 -40
  87. package/lib/proxy.js +9 -8
  88. package/lib/resource.js +10 -14
  89. package/lib/status.js +78 -37
  90. package/lib/tokens.js +374 -0
  91. package/package.json +14 -3
  92. package/commands/product.js +0 -407
@@ -0,0 +1,213 @@
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 || this.ws.readyState === WebSocket.CONNECTING)
73
+ ) {
74
+ this.ws.close();
75
+ }
76
+ }
77
+
78
+ _buildUrl() {
79
+ const { server, apiUser, deviceId, cmd, timeout } = this.opts;
80
+ const params = new URLSearchParams({ cmd });
81
+ if (timeout && timeout > 0) params.set('timeout', String(timeout));
82
+ return `wss://${server}/v3/users/${apiUser}/devices/${deviceId}/resources/${this.resource}?${params}`;
83
+ }
84
+
85
+ _openSocket() {
86
+ return new WebSocket(this._buildUrl(), {
87
+ headers: { Authorization: `Bearer ${this.opts.token}` },
88
+ rejectUnauthorized: !this.opts.insecure,
89
+ });
90
+ }
91
+
92
+ /**
93
+ * Client-side safety timeout. The `timeout` URL param asks the
94
+ * server/agent to bound the command, but if the agent is hung the
95
+ * server's own timeout can take longer than expected (or not fire
96
+ * for some states) and the WS would otherwise stay open until
97
+ * ping/pong death. Grace period lives in constants.js.
98
+ */
99
+ _armSafety() {
100
+ const { timeout } = this.opts;
101
+ if (!timeout || timeout <= 0) return;
102
+ this.safetyTimer = setTimeout(
103
+ () => {
104
+ if (this.resolved) return;
105
+ this.timedOut = true;
106
+ this._finish();
107
+ },
108
+ timeout * 1000 + TIMEOUTS.EXEC_SAFETY_GRACE_MS,
109
+ );
110
+ }
111
+
112
+ _disarmSafety() {
113
+ if (this.safetyTimer) {
114
+ clearTimeout(this.safetyTimer);
115
+ this.safetyTimer = null;
116
+ }
117
+ }
118
+
119
+ _wireEvents(ws) {
120
+ ws.on('open', () => this._forwardStdin(ws));
121
+ ws.on('unexpected-response', (req, res) => {
122
+ // Handshake rejected before the upgrade (401 expired token,
123
+ // 404 unknown device, ...). Tag the HTTP status so callers
124
+ // can react to it; execStream() refreshes and retries on 401.
125
+ req.destroy();
126
+ if (this.cancelled || this.resolved) return;
127
+ this._disarmSafety();
128
+ const err = /** @type {Error & { statusCode: number }} */ (
129
+ new Error(`Unexpected server response: ${res.statusCode}`)
130
+ );
131
+ err.statusCode = res.statusCode;
132
+ this._reject?.(err);
133
+ });
134
+ ws.on('error', (err) => {
135
+ if (this.cancelled || this.resolved) return;
136
+ this._disarmSafety();
137
+ this._reject?.(err);
138
+ });
139
+ ws.on('message', (raw) => this._handleMessage(raw));
140
+ ws.on('close', () => this._finish());
141
+ }
142
+
143
+ /**
144
+ * If the caller supplied a stdin stream, forward each chunk to the
145
+ * device as a stream frame. Payload is sent as a UTF-8 string
146
+ * under the standard {resource, payload} envelope that the
147
+ * server's stream handler expects.
148
+ */
149
+ _forwardStdin(ws) {
150
+ const { stdin } = this.opts;
151
+ if (!stdin || typeof stdin.on !== 'function') return;
152
+ stdin.on('data', (chunk) => {
153
+ if (ws.readyState !== WebSocket.OPEN) return;
154
+ const text = Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk);
155
+ ws.send(JSON.stringify({ resource: this.resource, payload: text }));
156
+ });
157
+ }
158
+
159
+ _handleMessage(raw) {
160
+ let evt;
161
+ try {
162
+ evt = JSON.parse(raw.toString('utf8'));
163
+ } catch {
164
+ return;
165
+ }
166
+ if (evt.signal && evt.signal !== 'data') return;
167
+ const p = evt.payload;
168
+ if (!p || typeof p !== 'object') return;
169
+ if (p.out !== undefined) {
170
+ this.opts.onStdout?.(decodeBytes(p.out));
171
+ } else if (p.err !== undefined) {
172
+ this.opts.onStderr?.(decodeBytes(p.err));
173
+ } else if (typeof p.exit === 'number') {
174
+ this.exitCode = p.exit;
175
+ if (p.timeout === true) this.timedOut = true;
176
+ // We have the full result — resolve immediately and tear
177
+ // down the WS without waiting for the server's stop
178
+ // cascade, which can add several seconds.
179
+ this._finish();
180
+ }
181
+ }
182
+
183
+ _finish() {
184
+ if (this.resolved) return;
185
+ this.resolved = true;
186
+ this._disarmSafety();
187
+ this._resolve?.({
188
+ exitCode: this.exitCode,
189
+ timedOut: this.timedOut,
190
+ cancelled: this.cancelled,
191
+ });
192
+ if (
193
+ this.ws &&
194
+ (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)
195
+ ) {
196
+ this.ws.terminate();
197
+ }
198
+ }
199
+ }
200
+
201
+ /**
202
+ * Decode a frame field that the server may deliver either as a plain
203
+ * string or as the `{bytes: [...]}` envelope used for binary-safe
204
+ * payloads. Non-strings fall back to empty so downstream handlers
205
+ * don't have to guard.
206
+ */
207
+ function decodeBytes(field) {
208
+ if (typeof field === 'string') return field;
209
+ if (field && Array.isArray(field.bytes)) {
210
+ return Buffer.from(field.bytes).toString('utf8');
211
+ }
212
+ return '';
213
+ }
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,8 @@
1
1
  import WebSocket from 'ws';
2
- import chalk from 'chalk';
3
2
  import { requireConfig } from './config.js';
3
+ import { ensureFreshToken } from './auth.js';
4
4
  import api from './api.js';
5
+ import { warning, error as errorStyle } from './format.js';
5
6
 
6
7
  /**
7
8
  * Creates a WebSocket connection to a device terminal. Returns a
@@ -18,10 +19,15 @@ import api from './api.js';
18
19
  export async function connectToDeviceConsole(deviceId) {
19
20
  const config = requireConfig();
20
21
 
22
+ // WS handshakes lack the refresh-on-401 retry that api.js gives
23
+ // HTTP requests: start from a valid token (no-op for permanent
24
+ // tokens, which carry no expiry).
25
+ const token = (await ensureFreshToken()) || config.token;
26
+
21
27
  const sessionId = createUUID();
22
28
  const wsUrl = `wss://${config.server}/v3/users/${config.username}/devices/${deviceId}/resources/$terminal/${sessionId}?raw=1`;
23
29
  const ws = new WebSocket(wsUrl, {
24
- headers: { Authorization: `Bearer ${config.token}` },
30
+ headers: { Authorization: `Bearer ${token}` },
25
31
  });
26
32
 
27
33
  let terminalRestored = false;
@@ -58,9 +64,17 @@ export async function connectToDeviceConsole(deviceId) {
58
64
  });
59
65
 
60
66
  process.stdin.on('data', (data) => {
61
- // Ctrl+D (EOT) to exit gracefully
67
+ // Ctrl+D (EOT) to exit gracefully. The `ws.close()` below
68
+ // initiates a close handshake and waits for the server to
69
+ // acknowledge; when the server is slow to respond, the
70
+ // client would otherwise hang on keep-alive/TCP timeouts.
71
+ // Watchdog forces the socket down after a short grace so
72
+ // control returns to the shell promptly.
62
73
  if (data.toString() === '\u0004') {
63
74
  ws.close();
75
+ setTimeout(() => {
76
+ if (ws.readyState !== WebSocket.CLOSED) ws.terminate();
77
+ }, 500);
64
78
  return;
65
79
  }
66
80
  // Forward everything else (including Ctrl+C) to the device
@@ -74,7 +88,7 @@ export async function connectToDeviceConsole(deviceId) {
74
88
  });
75
89
 
76
90
  ws.on('close', () => {
77
- console.log(chalk.yellow('\nConnection closed'));
91
+ console.log(warning('\nConnection closed'));
78
92
  restoreTerminal();
79
93
  // Small delay so any final output flushes before the caller exits.
80
94
  setTimeout(() => resolve(0), 100);
@@ -133,7 +147,7 @@ async function updateTerminalSize(config, deviceId, sessionId, cols, rows) {
133
147
  },
134
148
  });
135
149
  } catch (error) {
136
- console.error(chalk.red(`Error updating terminal size: ${error.message}`));
150
+ console.error(errorStyle(`Error updating terminal size: ${error.message}`));
137
151
  }
138
152
  }
139
153
 
@@ -0,0 +1,83 @@
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
+ /**
16
+ * Refresh a JWT access token this close to (or past) its `exp`
17
+ * before opening a WebSocket. WS handshakes lack the
18
+ * refresh-on-401 retry that api.js gives HTTP requests.
19
+ */
20
+ TOKEN_EXPIRY_SLACK_MS: 30_000,
21
+
22
+ /** POSTs to device resources (cmd, update, etc.) — mid-weight calls. */
23
+ DEVICE_RESOURCE_CALL_MS: 30_000,
24
+
25
+ /** GETs that read a device resource value. */
26
+ DEVICE_RESOURCE_GET_MS: 15_000,
27
+
28
+ /** Playbook `update: apply` — agent updates can take minutes. */
29
+ DEVICE_UPDATE_APPLY_MS: 300_000,
30
+
31
+ /**
32
+ * Grace period added client-side on top of the server/agent's exec
33
+ * timeout so a hung agent doesn't keep the WebSocket open forever.
34
+ * Not proportional to the declared timeout — a 5-min command
35
+ * shouldn't wait 5 extra minutes just in case.
36
+ */
37
+ EXEC_SAFETY_GRACE_MS: 2_000,
38
+
39
+ /**
40
+ * Extra headroom on the HTTP call that wraps the legacy `api.exec`
41
+ * resource call, so the agent's own timeout has a chance to fire
42
+ * and return a proper exit code before axios aborts.
43
+ */
44
+ EXEC_EXTRA_GRACE_MS: 5_000,
45
+
46
+ /** Hard ceiling for the OAuth device-code flow — 10 minutes. */
47
+ OAUTH_DEVICE_CODE_MS: 10 * 60 * 1000,
48
+
49
+ /**
50
+ * Default command timeout (in *seconds*) for user-facing exec
51
+ * flows: CLI `device exec`, `product exec`, playbook `exec`, MCP
52
+ * `thinr_exec`. Callers override when they know better.
53
+ */
54
+ DEFAULT_EXEC_SECONDS: 30,
55
+
56
+ /** Chmod after a script install: tiny op, short seconds-level cap. */
57
+ SCRIPT_CHMOD_SECONDS: 10,
58
+ };
59
+
60
+ /** Byte units, so callers don't have to eyeball `1073741824`. */
61
+ export const BYTES = {
62
+ KB: 1024,
63
+ MB: 1024 * 1024,
64
+ GB: 1024 * 1024 * 1024,
65
+
66
+ /**
67
+ * Chunk size used when wrapping in-memory buffers as Readable
68
+ * streams for file uploads, so `onUploadProgress` fires per chunk
69
+ * instead of once at 0% and once at 100%.
70
+ */
71
+ STREAM_CHUNK: 64 * 1024,
72
+ };
73
+
74
+ /** Random-port range used when creating TCP/HTTP proxies. */
75
+ export const PROXY_PORTS = {
76
+ MIN: 50_000,
77
+ MAX: 51_000,
78
+ };
79
+
80
+ /** OAuth device-flow client identity registered for this CLI. */
81
+ export const OAUTH = {
82
+ CLIENT_ID: '3b40164d28730e416cbd',
83
+ };
@@ -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
+ }