@thinremote/thinr-cli 1.1.0 → 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.
- package/CHANGELOG.md +71 -0
- package/README.md +230 -16
- package/bin/thinr.js +23 -24
- package/commands/_shared.js +154 -0
- package/commands/dashboard.js +17 -0
- package/commands/device/_shared.js +17 -34
- package/commands/device/console.js +1 -0
- package/commands/device/delete.js +53 -0
- package/commands/device/exec.js +7 -6
- package/commands/device/fs.js +193 -0
- package/commands/device/index.js +9 -0
- package/commands/device/list.js +35 -23
- package/commands/device/logs.js +178 -0
- package/commands/device/property.js +30 -28
- package/commands/device/proxy.js +3 -0
- package/commands/device/resource.js +30 -29
- package/commands/device/status.js +35 -14
- package/commands/device/transfer.js +236 -0
- package/commands/device/update.js +15 -10
- package/commands/fleet/index.js +17 -0
- package/commands/fleet/upgrade.js +275 -0
- package/commands/logout.js +10 -11
- package/commands/playbook/_shared.js +12 -0
- package/commands/playbook/index.js +18 -0
- package/commands/playbook/run.js +302 -0
- package/commands/playbook/validate.js +38 -0
- package/commands/product/_shared.js +8 -0
- package/commands/product/exec.js +243 -0
- package/commands/product/fs.js +218 -0
- package/commands/product/index.js +35 -0
- package/commands/product/list.js +86 -0
- package/commands/product/logs.js +289 -0
- package/commands/product/metric.js +124 -0
- package/commands/product/playbook.js +911 -0
- package/commands/product/property.js +76 -0
- package/commands/product/props.js +125 -0
- package/commands/product/push.js +184 -0
- package/commands/product/resource.js +76 -0
- package/commands/product/status.js +213 -0
- package/commands/profile.js +76 -8
- package/dist/dashboard.js +5644 -0
- package/lib/agent-releases.js +59 -0
- package/lib/agent-versions.js +73 -0
- package/lib/alarms.js +252 -0
- package/lib/api.js +8 -2
- package/lib/auth.js +30 -17
- package/lib/bucket.js +87 -0
- package/lib/command-stream.js +199 -0
- package/lib/concurrency.js +52 -0
- package/lib/config.js +5 -3
- package/lib/console.js +12 -4
- package/lib/constants.js +76 -0
- package/lib/dashboard/event-stream.js +239 -0
- package/lib/dashboard/metric-aggregate.js +64 -0
- package/lib/dashboard/metric-filter.js +39 -0
- package/lib/debug-log.js +93 -0
- package/lib/device-api.js +109 -147
- package/lib/devices.js +18 -21
- package/lib/fleet-upgrade.js +106 -0
- package/lib/format.js +114 -0
- package/lib/mcp/registry.js +14 -0
- package/lib/mcp/tools-alarms.js +510 -0
- package/lib/mcp/tools-buckets.js +103 -0
- package/lib/mcp/tools-devices.js +33 -3
- package/lib/mcp/tools-exec.js +30 -17
- package/lib/mcp/tools-filesystem.js +102 -6
- package/lib/mcp/tools-install.js +153 -0
- package/lib/mcp/tools-logs.js +225 -0
- package/lib/mcp/tools-playbook.js +211 -0
- package/lib/mcp/tools-products.js +1343 -10
- package/lib/mcp/tools-profile.js +634 -0
- package/lib/mcp/tools-tokens.js +371 -0
- package/lib/monitoring.js +26 -1
- package/lib/playbook/actions.js +447 -0
- package/lib/playbook/expression.js +302 -0
- package/lib/playbook/fleet.js +175 -0
- package/lib/playbook/loader.js +247 -0
- package/lib/playbook/runner.js +300 -0
- package/lib/playbook/schema.js +209 -0
- package/lib/playbook/vars.js +209 -0
- package/lib/product/log-presets.js +196 -0
- package/lib/product/logs.js +485 -0
- package/lib/product-orchestrator.js +124 -0
- package/lib/product.js +523 -20
- package/lib/profile.js +665 -0
- package/lib/property.js +7 -40
- package/lib/proxy.js +9 -8
- package/lib/resource.js +10 -14
- package/lib/status.js +78 -37
- package/lib/tokens.js +374 -0
- package/package.json +15 -3
- package/commands/product.js +0 -167
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Run `worker(item, index)` over `items` with at most `limit` invocations
|
|
5
|
+
* in flight at any time. Returns an array parallel to `items` describing
|
|
6
|
+
* the outcome of each task as `{ ok: true, value }` or `{ ok: false, error }`.
|
|
7
|
+
*
|
|
8
|
+
* By default, a rejection from one worker does not stop the others — the
|
|
9
|
+
* caller inspects the results array. Set `failFast: true` to stop dequeueing
|
|
10
|
+
* new work as soon as any worker rejects; in-flight tasks still finish and
|
|
11
|
+
* their results are included. The first rejection is rethrown after the
|
|
12
|
+
* in-flight tasks settle.
|
|
13
|
+
*
|
|
14
|
+
* @template T, R
|
|
15
|
+
* @param {T[]} items
|
|
16
|
+
* @param {number} limit
|
|
17
|
+
* @param {(item: T, index: number) => Promise<R>} worker
|
|
18
|
+
* @param {{ failFast?: boolean }} [opts]
|
|
19
|
+
* @returns {Promise<Array<{ ok: true, value: R } | { ok: false, error: unknown }>>}
|
|
20
|
+
*/
|
|
21
|
+
export async function runPool(items, limit, worker, { failFast = false } = {}) {
|
|
22
|
+
const results = new Array(items.length);
|
|
23
|
+
let nextIndex = 0;
|
|
24
|
+
let aborted = false;
|
|
25
|
+
/** @type {unknown} */
|
|
26
|
+
let abortError = null;
|
|
27
|
+
|
|
28
|
+
const effectiveLimit = Math.max(1, Math.min(limit, items.length));
|
|
29
|
+
|
|
30
|
+
async function runner() {
|
|
31
|
+
while (!aborted) {
|
|
32
|
+
const i = nextIndex++;
|
|
33
|
+
if (i >= items.length) return;
|
|
34
|
+
try {
|
|
35
|
+
results[i] = { ok: true, value: await worker(items[i], i) };
|
|
36
|
+
} catch (error) {
|
|
37
|
+
results[i] = { ok: false, error };
|
|
38
|
+
if (failFast && !aborted) {
|
|
39
|
+
aborted = true;
|
|
40
|
+
abortError = error;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const runners = [];
|
|
47
|
+
for (let i = 0; i < effectiveLimit; i++) runners.push(runner());
|
|
48
|
+
await Promise.all(runners);
|
|
49
|
+
|
|
50
|
+
if (aborted && abortError) throw abortError;
|
|
51
|
+
return results;
|
|
52
|
+
}
|
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
|
-
*
|
|
180
|
-
*
|
|
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(
|
|
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(
|
|
144
|
+
console.error(errorStyle(`Error updating terminal size: ${error.message}`));
|
|
137
145
|
}
|
|
138
146
|
}
|
|
139
147
|
|
package/lib/constants.js
ADDED
|
@@ -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
|
+
}
|