@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,93 @@
1
+ // @ts-check
2
+ import { createWriteStream, mkdirSync } from 'fs';
3
+ import { homedir } from 'os';
4
+ import { join } from 'path';
5
+
6
+ // Opt-in JSONL logger for diagnosing dashboard backend load. Activates only
7
+ // when `THINR_DEBUG` is set to a truthy string. Writes to
8
+ // `~/.thinr/dashboard-debug.log` so it never collides with the Ink alt-screen.
9
+ //
10
+ // API:
11
+ // debugLog(category, msg, data?) — one line per event
12
+ // debugCount(key, n = 1) — bump a counter; flushed every 5s in a
13
+ // `summary` line and then reset, so the
14
+ // log shows event rates without one row
15
+ // per high-frequency frame.
16
+ // isDebugEnabled()
17
+ //
18
+ // Each line is JSON:
19
+ // { t: <iso>, c: <category>, m: <msg>, d?: <data> }
20
+ // { t: <iso>, c: 'summary', d: { '<key>': <count>, ... } }
21
+
22
+ const SUMMARY_MS = 5_000;
23
+
24
+ let stream = null;
25
+ let enabled = null;
26
+ let counters = new Map();
27
+ let summaryTimer = null;
28
+
29
+ function init() {
30
+ if (enabled !== null) return;
31
+ enabled = !!process.env.THINR_DEBUG;
32
+ if (!enabled) return;
33
+ const dir = join(homedir(), '.thinr');
34
+ try {
35
+ mkdirSync(dir, { recursive: true });
36
+ } catch {
37
+ // best-effort; if creation fails the createWriteStream below will
38
+ // also fail and we silently disable.
39
+ }
40
+ const path = join(dir, 'dashboard-debug.log');
41
+ try {
42
+ stream = createWriteStream(path, { flags: 'w' });
43
+ } catch {
44
+ enabled = false;
45
+ return;
46
+ }
47
+ write({
48
+ t: new Date().toISOString(),
49
+ c: 'init',
50
+ m: 'log open',
51
+ d: { pid: process.pid, mode: process.env.THINR_DEBUG },
52
+ });
53
+ summaryTimer = setInterval(flushSummary, SUMMARY_MS);
54
+ summaryTimer.unref?.();
55
+ }
56
+
57
+ function write(obj) {
58
+ if (!stream) return;
59
+ try {
60
+ stream.write(JSON.stringify(obj) + '\n');
61
+ } catch {
62
+ // ignore — better to drop a line than crash the dashboard.
63
+ }
64
+ }
65
+
66
+ function flushSummary() {
67
+ if (!enabled || counters.size === 0) return;
68
+ const d = Object.fromEntries(counters);
69
+ counters = new Map();
70
+ write({ t: new Date().toISOString(), c: 'summary', d });
71
+ }
72
+
73
+ export function debugLog(category, msg, data) {
74
+ init();
75
+ if (!enabled) return;
76
+ write({
77
+ t: new Date().toISOString(),
78
+ c: category,
79
+ m: msg,
80
+ ...(data !== undefined ? { d: data } : {}),
81
+ });
82
+ }
83
+
84
+ export function debugCount(key, n = 1) {
85
+ init();
86
+ if (!enabled) return;
87
+ counters.set(key, (counters.get(key) || 0) + n);
88
+ }
89
+
90
+ export function isDebugEnabled() {
91
+ init();
92
+ return !!enabled;
93
+ }
package/lib/device-api.js CHANGED
@@ -1,42 +1,39 @@
1
1
  import api from './api.js';
2
2
  import { requireConfig } from './config.js';
3
- import WebSocket from 'ws';
4
- import { randomUUID } from 'crypto';
3
+ import { BYTES, TIMEOUTS } from './constants.js';
4
+ import { CommandStream } from './command-stream.js';
5
+ import { Readable } from 'node:stream';
6
+
7
+ // Directory endpoints on the agent expect a trailing slash so it can
8
+ // distinguish "list this directory" from "read a file with this name".
9
+ const withTrailingSlash = (p) => (p.endsWith('/') ? p : p + '/');
10
+
11
+ // The $fs/hash resource is mounted under the resource path, which is
12
+ // already an absolute URL path; the caller's relative `foo/bar` would
13
+ // otherwise produce `…/resources/$fs/hashfoo/bar`. Prepend the slash.
14
+ const withLeadingSlash = (p) => (p.startsWith('/') ? p : `/${p}`);
5
15
 
6
- /**
7
- * Create a device API client for filesystem and command operations
8
- * @param {string} deviceId - The device ID
9
- * @param {Object} [options] - Options
10
- * @param {string} [options.user] - Override API user (admin impersonation)
11
- * @returns {Object} API client with fs and exec methods
12
- */
13
16
  export function createDeviceAPI(deviceId, options = {}) {
14
17
  const config = requireConfig();
15
18
  const apiUser = options.user || config.username;
16
19
  const resourcePath = `/v3/users/${apiUser}/devices/${deviceId}/resources`;
17
20
  const filesPath = `/v3/users/${apiUser}/devices/${deviceId}/files`;
18
21
 
19
- /**
20
- * Call a device resource (for cmd, etc.)
21
- */
22
22
  async function callResource(resource, payload = null, opts = {}) {
23
23
  const url = `${resourcePath}/${resource}`;
24
24
  const response = await api.post(url, payload, {
25
25
  responseType: opts.responseType || 'json',
26
26
  params: opts.params || {},
27
27
  headers: opts.headers || {},
28
- timeout: opts.timeout || 30000,
28
+ timeout: opts.timeout || TIMEOUTS.DEVICE_RESOURCE_CALL_MS,
29
29
  });
30
30
  return response;
31
31
  }
32
32
 
33
33
  return {
34
- /**
35
- * List directory contents
36
- * GET /v3/users/{user}/devices/{device}/files/{path}/
37
- */
34
+ // GET /v3/users/{user}/devices/{device}/files/{path}/
38
35
  async listDir(dirPath, includeHidden = false) {
39
- const normalized = dirPath.endsWith('/') ? dirPath : dirPath + '/';
36
+ const normalized = withTrailingSlash(dirPath);
40
37
  const params = {};
41
38
  if (includeHidden) params.hidden = true;
42
39
  const response = await api.get(`${filesPath}${normalized}`, { params });
@@ -44,34 +41,73 @@ export function createDeviceAPI(deviceId, options = {}) {
44
41
  },
45
42
 
46
43
  /**
47
- * Read file contents
44
+ * Read file contents. Pass `opts.onProgress` to observe the
45
+ * download byte-by-byte (axios ProgressEvent: `{ loaded, total,
46
+ * progress, rate, ... }`). File transfers run with no HTTP
47
+ * timeout by default because a large pull can easily outlast
48
+ * the default 10s axios timeout.
49
+ *
48
50
  * GET /v3/users/{user}/devices/{device}/files/{path}
49
51
  */
50
- async readFile(filePath) {
52
+ async readFile(filePath, opts = {}) {
51
53
  const response = await api.get(`${filesPath}${filePath}`, {
52
54
  responseType: 'arraybuffer',
55
+ timeout: opts.timeout ?? 0,
56
+ onDownloadProgress: opts.onProgress,
53
57
  });
54
58
  return Buffer.from(response.data);
55
59
  },
56
60
 
57
61
  /**
58
- * Write file contents
62
+ * Write file contents. Pass `opts.onProgress` to observe the
63
+ * upload byte-by-byte (axios ProgressEvent: `{ loaded, total,
64
+ * progress, rate, ... }`). Buffers are wrapped in a chunked
65
+ * Readable so the progress callback fires per chunk — axios
66
+ * otherwise flushes the whole body in one socket write and
67
+ * progress ends up binary (0% → 100%). Timeout defaults to
68
+ * unbounded; a 10s default kills any transfer bigger than
69
+ * a handful of megabytes.
70
+ *
59
71
  * PUT /v3/users/{user}/devices/{device}/files/{path}
60
72
  */
61
- async writeFile(filePath, content, overwrite = true) {
73
+ async writeFile(filePath, content, overwrite = true, opts = {}) {
62
74
  const params = {};
63
75
  if (!overwrite) params.overwrite = false;
64
- const response = await api.put(`${filesPath}${filePath}`, content, {
65
- headers: { 'Content-Type': 'application/octet-stream' },
76
+ const isBuffer = Buffer.isBuffer(content);
77
+ // Streams: the caller gets granular `onUploadProgress` events
78
+ // per socket write. Buffers: axios flushes the whole body in
79
+ // a single write, so the callback only fires at 0% and 100%
80
+ // — callers that care about progress should pass a stream.
81
+ const body = isBuffer
82
+ ? Readable.from(content, { highWaterMark: BYTES.STREAM_CHUNK })
83
+ : content;
84
+ const headers = { 'Content-Type': 'application/octet-stream' };
85
+ if (isBuffer) {
86
+ headers['Content-Length'] = String(content.byteLength);
87
+ } else if (opts.contentLength != null) {
88
+ headers['Content-Length'] = String(opts.contentLength);
89
+ }
90
+ const response = await api.put(`${filesPath}${filePath}`, body, {
91
+ headers,
66
92
  params,
93
+ timeout: opts.timeout ?? 0,
94
+ maxBodyLength: Infinity,
95
+ maxContentLength: Infinity,
96
+ // `follow-redirects` (axios' default HTTP client)
97
+ // buffers the request body in memory to be able to
98
+ // replay it on a 3xx. That defeats the upstream
99
+ // backpressure that the CLI's progress meter relies
100
+ // on — bytes get "sent" to RAM instantly, then the
101
+ // real socket drain happens out-of-band. The
102
+ // /v3/…/files/ endpoint doesn't redirect, so opting
103
+ // out is free and restores true streaming.
104
+ maxRedirects: 0,
105
+ onUploadProgress: opts.onProgress,
67
106
  });
68
107
  return response.data;
69
108
  },
70
109
 
71
- /**
72
- * Get file/directory info
73
- * GET /v3/users/{user}/devices/{device}/files/{path}?info=true
74
- */
110
+ // GET /v3/users/{user}/devices/{device}/files/{path}?info=true
75
111
  async info(filePath) {
76
112
  const response = await api.get(`${filesPath}${filePath}`, {
77
113
  params: { info: true },
@@ -80,9 +116,23 @@ export function createDeviceAPI(deviceId, options = {}) {
80
116
  },
81
117
 
82
118
  /**
83
- * Delete file or directory
84
- * DELETE /v3/users/{user}/devices/{device}/files/{path}
119
+ * Hash the remote file with the given algorithm.
120
+ * GET /v3/users/{user}/devices/{device}/resources/$fs/hash/{path}
121
+ *
122
+ * Returns `{ path, algorithm, hash, size, mtime }`. Throws axios
123
+ * errors (404 for missing file, 400 for non-regular path, 403
124
+ * when the path is outside the agent's allowed base).
85
125
  */
126
+ async hashFile(filePath, algorithm = 'sha256') {
127
+ const normalized = withLeadingSlash(filePath);
128
+ const response = await api.get(`${resourcePath}/$fs/hash${normalized}`, {
129
+ params: algorithm && algorithm !== 'sha256' ? { algorithm } : {},
130
+ timeout: TIMEOUTS.DEVICE_RESOURCE_CALL_MS,
131
+ });
132
+ return response.data;
133
+ },
134
+
135
+ // DELETE /v3/users/{user}/devices/{device}/files/{path}
86
136
  async delete(filePath, recursive = true) {
87
137
  const params = {};
88
138
  if (recursive) params.recursive = true;
@@ -90,10 +140,7 @@ export function createDeviceAPI(deviceId, options = {}) {
90
140
  return response.data;
91
141
  },
92
142
 
93
- /**
94
- * Move/rename file or directory
95
- * PATCH /v3/users/{user}/devices/{device}/files/{sourcePath}
96
- */
143
+ // PATCH /v3/users/{user}/devices/{device}/files/{sourcePath}
97
144
  async move(source, destination, overwrite = false) {
98
145
  const response = await api.patch(`${filesPath}${source}`, {
99
146
  path: destination,
@@ -102,43 +149,32 @@ export function createDeviceAPI(deviceId, options = {}) {
102
149
  return response.data;
103
150
  },
104
151
 
105
- /**
106
- * Create directory
107
- * PUT /v3/users/{user}/devices/{device}/files/{path}/
108
- */
152
+ // PUT /v3/users/{user}/devices/{device}/files/{path}/
109
153
  async mkdir(dirPath) {
110
- const normalized = dirPath.endsWith('/') ? dirPath : dirPath + '/';
154
+ const normalized = withTrailingSlash(dirPath);
111
155
  const response = await api.put(`${filesPath}${normalized}`, null, {
112
156
  headers: { 'Content-Length': '0' },
113
157
  });
114
158
  return response.data;
115
159
  },
116
160
 
117
- /**
118
- * Read a device resource property (GET)
119
- * GET /v3/users/{user}/devices/{device}/resources/{resource}
120
- */
161
+ // GET /v3/users/{user}/devices/{device}/resources/{resource}
121
162
  async getResource(resource) {
122
163
  const url = `${resourcePath}/${resource}`;
123
- const response = await api.get(url, { timeout: 15000 });
164
+ const response = await api.get(url, { timeout: TIMEOUTS.DEVICE_RESOURCE_GET_MS });
124
165
  return response.data;
125
166
  },
126
167
 
127
- /**
128
- * Call a device resource action (POST)
129
- * POST /v3/users/{user}/devices/{device}/resources/{resource}
130
- */
131
- async callResource(resource, payload = null, { timeout = 30000 } = {}) {
168
+ // POST /v3/users/{user}/devices/{device}/resources/{resource}
169
+ async callResource(resource, payload = null, { timeout = TIMEOUTS.DEVICE_RESOURCE_CALL_MS } = {}) {
132
170
  const url = `${resourcePath}/${resource}`;
133
171
  const response = await api.post(url, payload, { timeout });
134
172
  return response.data;
135
173
  },
136
174
 
137
- /**
138
- * Execute a command on the device
139
- * POST /v3/users/{user}/devices/{device}/resources/cmd
140
- */
141
- async exec(cmd, timeout = 30) {
175
+ // Legacy one-shot exec via POST /resources/cmd. Prefer execStream
176
+ // for anything interactive or longer than a few seconds.
177
+ async exec(cmd, timeout = TIMEOUTS.DEFAULT_EXEC_SECONDS) {
142
178
  const response = await callResource(
143
179
  'cmd',
144
180
  {
@@ -146,7 +182,7 @@ export function createDeviceAPI(deviceId, options = {}) {
146
182
  timeout,
147
183
  mode: 'api',
148
184
  },
149
- { timeout: (timeout + 5) * 1000 },
185
+ { timeout: timeout * 1000 + TIMEOUTS.EXEC_EXTRA_GRACE_MS },
150
186
  );
151
187
  return response.data;
152
188
  },
@@ -154,104 +190,30 @@ export function createDeviceAPI(deviceId, options = {}) {
154
190
  /**
155
191
  * Execute a command on the device using the WebSocket stream. Incoming
156
192
  * frames are dispatched to onStdout/onStderr as they arrive. Resolves
157
- * with { exitCode, timedOut } when the remote command completes or the
158
- * connection closes. Returns a `cancel()` function via the optional
159
- * onCancel hook so callers (e.g. SIGINT handlers) can tear it down.
193
+ * with { exitCode, timedOut, cancelled } when the remote command
194
+ * completes, the connection closes, the safety timer fires, or the
195
+ * caller invokes the `cancel()` function exposed via onCancel.
160
196
  *
161
197
  * Preferred path for the CLI: the server detects WS close and stops
162
- * the device-side stream automatically.
198
+ * the device-side stream automatically. Framing, timeout and
199
+ * cancellation live in `CommandStream` (lib/command-stream.js).
163
200
  */
164
201
  execStream(cmd, { timeout = 0, onStdout, onStderr, onCancel, stdin } = {}) {
165
- const sessionId = randomUUID();
166
- const resource = `$cmd/${sessionId}`;
167
- const params = new URLSearchParams({ cmd });
168
- if (timeout > 0) params.set('timeout', String(timeout));
169
202
  const isLocal = config.server === 'localhost' || config.server === '127.0.0.1';
170
203
  const insecure = isLocal || process.env.THINR_INSECURE === '1';
171
- const wsUrl = `wss://${config.server}/v3/users/${apiUser}/devices/${deviceId}/resources/${resource}?${params}`;
172
- const ws = new WebSocket(wsUrl, {
173
- headers: { Authorization: `Bearer ${config.token}` },
174
- rejectUnauthorized: !insecure,
175
- });
176
-
177
- let exitCode = null;
178
- let timedOut = false;
179
- let cancelled = false;
180
-
181
- const cancel = () => {
182
- cancelled = true;
183
- if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
184
- ws.close();
185
- }
186
- };
187
- onCancel?.(cancel);
188
-
189
- const decodeBytes = (field) => {
190
- if (typeof field === 'string') return field;
191
- if (field && Array.isArray(field.bytes)) {
192
- return Buffer.from(field.bytes).toString('utf8');
193
- }
194
- return '';
195
- };
196
-
197
- return new Promise((resolve, reject) => {
198
- let resolved = false;
199
- const done = () => {
200
- if (resolved) return;
201
- resolved = true;
202
- resolve({ exitCode, timedOut, cancelled });
203
- // Tear down the socket without blocking on the server's
204
- // close handshake.
205
- if (
206
- ws.readyState === WebSocket.OPEN ||
207
- ws.readyState === WebSocket.CONNECTING
208
- ) {
209
- ws.terminate();
210
- }
211
- };
212
-
213
- ws.on('open', () => {
214
- // If the caller supplied a stdin stream, forward each
215
- // chunk to the device as a stream frame. Payload is sent
216
- // as a UTF-8 string under the standard {resource, payload}
217
- // envelope that the server's stream handler expects.
218
- if (stdin && typeof stdin.on === 'function') {
219
- stdin.on('data', (chunk) => {
220
- if (ws.readyState !== WebSocket.OPEN) return;
221
- const text = Buffer.isBuffer(chunk)
222
- ? chunk.toString('utf8')
223
- : String(chunk);
224
- ws.send(JSON.stringify({ resource, payload: text }));
225
- });
226
- }
227
- });
228
- ws.on('error', (err) => {
229
- if (cancelled) return;
230
- reject(err);
231
- });
232
- ws.on('message', (raw) => {
233
- let evt;
234
- try {
235
- evt = JSON.parse(raw.toString('utf8'));
236
- } catch {
237
- return;
238
- }
239
- if (evt.signal && evt.signal !== 'data') return;
240
- const p = evt.payload;
241
- if (!p || typeof p !== 'object') return;
242
- if (p.out !== undefined) onStdout?.(decodeBytes(p.out));
243
- else if (p.err !== undefined) onStderr?.(decodeBytes(p.err));
244
- else if (typeof p.exit === 'number') {
245
- exitCode = p.exit;
246
- if (p.timeout === true) timedOut = true;
247
- // We have the full result — resolve immediately and
248
- // tear down the WS without waiting for the server's
249
- // stop cascade, which can add several seconds.
250
- done();
251
- }
252
- });
253
- ws.on('close', () => done());
254
- });
204
+ return new CommandStream({
205
+ server: config.server,
206
+ token: config.token,
207
+ apiUser,
208
+ deviceId,
209
+ insecure,
210
+ cmd,
211
+ timeout,
212
+ onStdout,
213
+ onStderr,
214
+ onCancel,
215
+ stdin,
216
+ }).run();
255
217
  },
256
218
  };
257
219
  }
package/lib/devices.js CHANGED
@@ -2,11 +2,6 @@ import { requireConfig } from './config.js';
2
2
  import api from './api.js';
3
3
  import { apiError } from './errors.js';
4
4
 
5
- /**
6
- * Get devices belonging to a product
7
- * @param {Object} filter - Filters to query devices
8
- * @return {Promise<Array>} List of devices
9
- */
10
5
  export async function getDevices(filter, user = null) {
11
6
  const config = requireConfig();
12
7
  const apiUser = user || config.username;
@@ -32,22 +27,15 @@ export async function getDevices(filter, user = null) {
32
27
 
33
28
  return devices;
34
29
  } catch (error) {
35
- const product = filter && filter.productId;
30
+ const product = filter && filter.product;
36
31
  const notFound = product ? `Product not found: ${product}` : 'Devices not found';
37
32
  throw apiError(error, { notFound });
38
33
  }
39
34
  }
40
35
 
41
- /**
42
- * Fetch a single device record (metadata only — assigned product,
43
- * description, asset group, connection state, …). Use this when you
44
- * need the side-channel info that doesn't come from the device's own
45
- * agent (system_info, monitoring, …) but from the server's record.
46
- *
47
- * @param {string} deviceId
48
- * @param {string} [user] Admin impersonation; defaults to the active profile.
49
- * @returns {Promise<Object>} Raw API record.
50
- */
36
+ // Server-side metadata (assigned product, description, asset group,
37
+ // connection state…). For device-side data — system_info, monitoring —
38
+ // hit the agent resources directly instead.
51
39
  export async function getDevice(deviceId, user = null) {
52
40
  const config = requireConfig();
53
41
  const apiUser = user || config.username;
@@ -59,11 +47,20 @@ export async function getDevice(deviceId, user = null) {
59
47
  }
60
48
  }
61
49
 
62
- /**
63
- * Filter active devices from a list of devices
64
- * @param {Array} devices - List of devices to filter
65
- * @return {Array} List of active devices
66
- */
50
+ // Idempotent: returns true if the device record was removed, false if it
51
+ // was already absent. Other failures (auth, network) propagate as apiError.
52
+ export async function deleteDevice(deviceId, user = null) {
53
+ const config = requireConfig();
54
+ const apiUser = user || config.username;
55
+ try {
56
+ await api.delete(`/v1/users/${apiUser}/devices/${deviceId}`);
57
+ return true;
58
+ } catch (error) {
59
+ if (error.response?.status === 404) return false;
60
+ throw apiError(error);
61
+ }
62
+ }
63
+
67
64
  export function filterActiveDevices(devices) {
68
65
  return devices.filter((device) => device.connection && device.connection.active === true);
69
66
  }
@@ -0,0 +1,106 @@
1
+ import { createDeviceAPI } from './device-api.js';
2
+ import { TIMEOUTS } from './constants.js';
3
+
4
+ /**
5
+ * Roll a fleet-wide agent upgrade. Strategy:
6
+ * 1. If `canary` is set and there's more than one device, upgrade one
7
+ * device in isolation first. A canary failure always aborts — the
8
+ * point of the canary is to catch arch / channel / connectivity
9
+ * mistakes before we touch the rest.
10
+ * 2. The remainder is upgraded in parallel batches of `batchSize`. If
11
+ * `abortOnFailure` is set, any failure inside a batch stops the run
12
+ * after that batch completes (we never leave requests dangling).
13
+ * 3. Results come back as { ok, failed, aborted } plus the per-device
14
+ * records. Progress is reported through `onProgress` so the UI can
15
+ * show `updating N/M…` live.
16
+ *
17
+ * We intentionally treat any HTTP rejection as a failure and any resolved
18
+ * call as success. The agent's `update` resource doesn't expose a stable
19
+ * machine-readable status across every "nothing to do" case, so finer
20
+ * classification would be fragile here — a resolved call means the agent
21
+ * accepted the request; whether it then swapped binaries is visible in
22
+ * the next sample's version field.
23
+ */
24
+ export async function runFleetUpgrade({
25
+ deviceIds,
26
+ channel = 'latest',
27
+ user,
28
+ canary = true,
29
+ abortOnFailure = true,
30
+ batchSize = 5,
31
+ onProgress,
32
+ onDeviceResult,
33
+ signal,
34
+ }) {
35
+ const queue = [...deviceIds];
36
+ const total = queue.length;
37
+ const results = [];
38
+ let done = 0;
39
+
40
+ const emitProgress = (phase) => {
41
+ onProgress?.({ done, total, phase });
42
+ };
43
+
44
+ async function upgradeOne(id) {
45
+ if (signal?.aborted) {
46
+ const r = { deviceId: id, ok: false, error: 'cancelled' };
47
+ results.push(r);
48
+ onDeviceResult?.(r);
49
+ return r;
50
+ }
51
+ let r;
52
+ try {
53
+ const api = createDeviceAPI(id, user ? { user } : {});
54
+ const result = await api.callResource(
55
+ 'update',
56
+ { action: 'apply', channel },
57
+ { timeout: TIMEOUTS.DEVICE_UPDATE_APPLY_MS },
58
+ );
59
+ r = { deviceId: id, ok: true, result };
60
+ } catch (e) {
61
+ r = {
62
+ deviceId: id,
63
+ ok: false,
64
+ error: e?.message || String(e),
65
+ };
66
+ }
67
+ results.push(r);
68
+ done += 1;
69
+ onDeviceResult?.(r);
70
+ emitProgress('running');
71
+ return r;
72
+ }
73
+
74
+ emitProgress('running');
75
+
76
+ // Canary first — a single device, synchronously. Treat failure as
77
+ // terminal regardless of `abortOnFailure`: if the canary fails we have
78
+ // no signal that a wider rollout would succeed, and blindly firing at
79
+ // 95 more devices would only amplify whatever went wrong.
80
+ if (canary && queue.length > 1) {
81
+ const canaryId = queue.shift();
82
+ const r = await upgradeOne(canaryId);
83
+ if (!r.ok) {
84
+ return { results, aborted: true, reason: 'canary-failed' };
85
+ }
86
+ }
87
+
88
+ while (queue.length > 0) {
89
+ if (signal?.aborted) {
90
+ return { results, aborted: true, reason: 'cancelled' };
91
+ }
92
+ const batch = queue.splice(0, batchSize);
93
+ const batchResults = await Promise.all(batch.map(upgradeOne));
94
+ if (abortOnFailure && batchResults.some((r) => !r.ok)) {
95
+ return { results, aborted: true, reason: 'batch-failed' };
96
+ }
97
+ }
98
+
99
+ return { results, aborted: false };
100
+ }
101
+
102
+ export function summarize(results) {
103
+ const ok = results.filter((r) => r.ok).length;
104
+ const failed = results.length - ok;
105
+ return { ok, failed, total: results.length };
106
+ }