@thinremote/thinr-cli 1.5.0 → 1.6.1

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 (46) hide show
  1. package/CHANGELOG.md +113 -0
  2. package/README.md +167 -120
  3. package/bin/thinr.js +3 -58
  4. package/commands/_shared.js +1 -0
  5. package/commands/{device/update.js → agent/device.js} +10 -26
  6. package/commands/agent/fleet.js +254 -0
  7. package/commands/agent/index.js +79 -0
  8. package/commands/device/console.js +1 -1
  9. package/commands/device/exec.js +1 -1
  10. package/commands/device/fs.js +6 -6
  11. package/commands/device/index.js +4 -6
  12. package/commands/device/logs.js +1 -1
  13. package/commands/device/proxy.js +15 -6
  14. package/commands/device/status.js +1 -1
  15. package/commands/device/transfer.js +2 -2
  16. package/commands/product/exec.js +1 -1
  17. package/commands/product/fs.js +3 -3
  18. package/commands/product/index.js +5 -4
  19. package/commands/product/logs.js +35 -36
  20. package/commands/product/metric.js +24 -14
  21. package/commands/product/playbook.js +10 -5
  22. package/commands/product/property.js +4 -14
  23. package/commands/product/push.js +1 -1
  24. package/commands/product/resource.js +56 -56
  25. package/commands/product/status.js +1 -1
  26. package/docs/guides/thinremote-metrics-logs.md +21 -22
  27. package/docs/guides/thinremote-playbooks.md +15 -11
  28. package/docs/guides/thinremote-scripts.md +11 -10
  29. package/docs/guides/thinremote.md +53 -42
  30. package/lib/agent-releases.js +1 -1
  31. package/lib/cli.js +172 -0
  32. package/lib/constants.js +1 -1
  33. package/lib/guides.js +1 -1
  34. package/lib/identity.js +8 -6
  35. package/lib/mcp/{tools-exec.js → device-exec.js} +84 -55
  36. package/lib/mcp/helpers.js +2 -2
  37. package/lib/mcp/resource-hooks.js +104 -6
  38. package/lib/mcp/resource-types.js +114 -227
  39. package/lib/playbook/actions.js +1 -1
  40. package/lib/playbook/schema.js +1 -1
  41. package/lib/product/log-presets.js +1 -1
  42. package/lib/product.js +26 -0
  43. package/package.json +2 -2
  44. package/commands/fleet/index.js +0 -17
  45. package/commands/fleet/upgrade.js +0 -275
  46. package/lib/mcp/registry.js +0 -12
@@ -184,7 +184,7 @@ export function registerProductFsCommands(product) {
184
184
  registerProductFsCommand(product, {
185
185
  name: 'mkdir',
186
186
  description: 'Create a directory on every active device of the product',
187
- args: '<productId> <path>',
187
+ args: '<product> <path>',
188
188
  perDeviceLabel: ([path]) => `mkdir ${path}`,
189
189
  build: async (api, [path]) => {
190
190
  await api.mkdir(path);
@@ -195,7 +195,7 @@ export function registerProductFsCommands(product) {
195
195
  name: 'rm',
196
196
  description:
197
197
  'Delete a file or directory on every active device of the product (DESTRUCTIVE — double-check the path).',
198
- args: '<productId> <path>',
198
+ args: '<product> <path>',
199
199
  perDeviceLabel: ([path]) => `rm ${path}`,
200
200
  build: async (api, [path], opts) => {
201
201
  await api.delete(path, opts.recursive !== false);
@@ -209,7 +209,7 @@ export function registerProductFsCommands(product) {
209
209
  registerProductFsCommand(product, {
210
210
  name: 'mv',
211
211
  description: 'Move or rename a path on every active device of the product',
212
- args: '<productId> <source> <destination>',
212
+ args: '<product> <source> <destination>',
213
213
  perDeviceLabel: ([src, dst]) => `mv ${src} → ${dst}`,
214
214
  build: async (api, [src, dst], opts) => {
215
215
  await api.move(src, dst, !!opts.force);
@@ -10,10 +10,11 @@ import { registerProductPlaybookCommand } from './playbook.js';
10
10
  import { registerProductLogsCommand } from './logs.js';
11
11
 
12
12
  /**
13
- * `thinr product <subcommand> <productId> …` — fan-out commands across
14
- * every device that belongs to a product, plus metrics, scripts and
15
- * playbooks, added to the generic `product` group of @thinger-io/cli-core
16
- * (list, info, create, delete, property-list/get/set/delete).
13
+ * `thinr product <verb> <product> …` — fan-out commands across every device
14
+ * that belongs to a product (status, exec, files, device-property,
15
+ * device-resource), plus its metrics, log sources and playbooks, added to
16
+ * the generic `product` group of @thinger-io/cli-core (records,
17
+ * properties, profile, export, apply).
17
18
  */
18
19
  export function registerProductCommands(product) {
19
20
  registerProductStatusCommand(product);
@@ -9,7 +9,7 @@ import {
9
9
  } from '../../lib/product/logs.js';
10
10
  import { hint, info, label, muted, success } from '@thinger-io/cli-core/format';
11
11
  import { classifyError, isJsonMode, printErr, printOk } from '@thinger-io/cli-core/output';
12
- import { applyJsonFlag, ensureConfigured, getGlobalUser } from '../_shared.js';
12
+ import { applyJsonFlag, confirmDestructive, ensureConfigured, getGlobalUser } from '../_shared.js';
13
13
 
14
14
  function truncate(value, max = 60) {
15
15
  if (typeof value !== 'string') return '';
@@ -17,9 +17,8 @@ function truncate(value, max = 60) {
17
17
  }
18
18
 
19
19
  function registerList(logs) {
20
- logs
21
- .command('list <productId>')
22
- .helpGroup('Log sources:')
20
+ logs.command('list <product>')
21
+ .helpGroup('Records:')
23
22
  .description('List the log sources configured on a product')
24
23
  .option('-j, --json', 'Output as JSON')
25
24
  .action(async (productId, opts, cmd) => {
@@ -73,12 +72,14 @@ function registerList(logs) {
73
72
  });
74
73
  }
75
74
 
76
- function registerAdd(logs) {
77
- logs
78
- .command('add <productId> <name>')
79
- .helpGroup('Log sources:')
75
+ function registerCreate(logs) {
76
+ logs.command('create <product> <name>')
77
+ .helpGroup('Records:')
80
78
  .description('Add or replace a log source on a product')
81
- .requiredOption('-c, --command <cmd>', 'Shell command the agent should exec-stream for this source')
79
+ .requiredOption(
80
+ '-c, --command <cmd>',
81
+ 'Shell command the agent should exec-stream for this source',
82
+ )
82
83
  .option('--default', 'Mark this source as the active default')
83
84
  .option(
84
85
  '--pattern <regex>',
@@ -115,7 +116,8 @@ function registerAdd(logs) {
115
116
  );
116
117
  console.log(` ${muted('command:')} ${truncate(opts.command, 80)}`);
117
118
  if (opts.preset) console.log(` ${muted('preset:')} ${opts.preset}`);
118
- if (opts.pattern) console.log(` ${muted('pattern:')} ${truncate(opts.pattern, 80)}`);
119
+ if (opts.pattern)
120
+ console.log(` ${muted('pattern:')} ${truncate(opts.pattern, 80)}`);
119
121
  console.log(
120
122
  ` ${muted('total sources:')} ${config.sources.length}${
121
123
  config.default ? ` ${muted('default:')} ${config.default}` : ''
@@ -128,17 +130,19 @@ function registerAdd(logs) {
128
130
  });
129
131
  }
130
132
 
131
- function registerRemove(logs) {
132
- logs
133
- .command('remove <productId> <name>')
134
- .alias('rm')
135
- .helpGroup('Log sources:')
133
+ function registerDelete(logs) {
134
+ logs.command('delete <product> <name>')
135
+ .helpGroup('Records:')
136
136
  .description('Remove a log source from a product (idempotent)')
137
+ .option('-y, --yes', 'Do not ask for confirmation')
137
138
  .option('-j, --json', 'Output as JSON')
138
139
  .action(async (productId, name, opts, cmd) => {
139
140
  applyJsonFlag(opts);
140
141
  ensureConfigured();
141
142
  const user = getGlobalUser(cmd);
143
+ if (!(await confirmDestructive(opts, `Remove log source ${name} from ${productId}?`))) {
144
+ return;
145
+ }
142
146
  try {
143
147
  const { removed, config } = await removeLogSource(productId, name, user);
144
148
  if (isJsonMode()) {
@@ -152,9 +156,7 @@ function registerRemove(logs) {
152
156
  return;
153
157
  }
154
158
  if (!removed) {
155
- console.log(
156
- `Source ${info(name)} was not configured on ${info(productId)}.`,
157
- );
159
+ console.log(`Source ${info(name)} was not configured on ${info(productId)}.`);
158
160
  return;
159
161
  }
160
162
  if (config === null) {
@@ -175,9 +177,8 @@ function registerRemove(logs) {
175
177
  }
176
178
 
177
179
  function registerSetDefault(logs) {
178
- logs
179
- .command('set-default <productId> <name>')
180
- .helpGroup('Log sources:')
180
+ logs.command('set-default <product> <name>')
181
+ .helpGroup('Actions:')
181
182
  .description('Set the active default source on a product')
182
183
  .option('-j, --json', 'Output as JSON')
183
184
  .action(async (productId, name, opts, cmd) => {
@@ -200,10 +201,9 @@ function registerSetDefault(logs) {
200
201
  });
201
202
  }
202
203
 
203
- function registerShow(logs) {
204
- logs
205
- .command('show <productId> <name>')
206
- .helpGroup('Log sources:')
204
+ function registerGet(logs) {
205
+ logs.command('get <product> <name>')
206
+ .helpGroup('Records:')
207
207
  .description('Show a single log source (full command, untruncated)')
208
208
  .option('-j, --json', 'Output as JSON')
209
209
  .action(async (productId, name, opts, cmd) => {
@@ -235,7 +235,9 @@ function registerShow(logs) {
235
235
  });
236
236
  return;
237
237
  }
238
- console.log(`${label('Source')}: ${info(source.name)}${isDefault ? ` ${muted('(default)')}` : ''}`);
238
+ console.log(
239
+ `${label('Source')}: ${info(source.name)}${isDefault ? ` ${muted('(default)')}` : ''}`,
240
+ );
239
241
  console.log(`${label('Command')}: ${source.command}`);
240
242
  if (source.preset) console.log(`${label('Preset')}: ${source.preset}`);
241
243
  if (source.pattern) console.log(`${label('Pattern')}: ${source.pattern}`);
@@ -248,9 +250,8 @@ function registerShow(logs) {
248
250
  }
249
251
 
250
252
  function registerPresets(logs) {
251
- logs
252
- .command('presets')
253
- .helpGroup('Log sources:')
253
+ logs.command('presets')
254
+ .helpGroup('Records:')
254
255
  .description('List the named log line patterns shipped with the CLI')
255
256
  .option('-j, --json', 'Output as JSON')
256
257
  .action(async (opts) => {
@@ -275,15 +276,13 @@ function registerPresets(logs) {
275
276
  export function registerProductLogsCommand(product) {
276
277
  const logs = product
277
278
  .command('logs')
278
- .helpGroup('Log sources:')
279
- .description(
280
- `Manage log sources stored on a product. ${hint('Subcommands: list, add, remove, set-default, show, presets.')}`,
281
- );
279
+ .helpGroup('Product config:')
280
+ .description('Log sources of a product: the commands `thinr device logs` streams');
282
281
 
283
282
  registerList(logs);
284
- registerAdd(logs);
285
- registerRemove(logs);
283
+ registerGet(logs);
284
+ registerCreate(logs);
285
+ registerDelete(logs);
286
286
  registerSetDefault(logs);
287
- registerShow(logs);
288
287
  registerPresets(logs);
289
288
  }
@@ -6,7 +6,7 @@ import {
6
6
  } from '../../lib/product.js';
7
7
  import { info, success } from '@thinger-io/cli-core/format';
8
8
  import { isJsonMode, printOk, printErr, classifyError } from '@thinger-io/cli-core/output';
9
- import { applyJsonFlag, ensureConfigured, getGlobalUser } from '../_shared.js';
9
+ import { applyJsonFlag, confirmDestructive, ensureConfigured, getGlobalUser } from '../_shared.js';
10
10
 
11
11
  function stringifyMetricLine(m) {
12
12
  const parts = [
@@ -20,10 +20,19 @@ function stringifyMetricLine(m) {
20
20
  return `${m.name} ${parts.join(' ')}${trail}`;
21
21
  }
22
22
 
23
+ /**
24
+ * `thinr product metric list|set|delete` — the metrics thinr's terminal
25
+ * dashboard shows for a product (its `dashboard_metrics` property).
26
+ */
23
27
  export function registerProductMetricCommands(product) {
24
- product
25
- .command('metric-list <productId>')
28
+ const metric = product
29
+ .command('metric')
26
30
  .helpGroup('Product config:')
31
+ .description("Dashboard metrics of a product (thinr's terminal dashboard)");
32
+
33
+ metric
34
+ .command('list <product>')
35
+ .helpGroup('Records:')
27
36
  .description('List dashboard metrics configured on a product')
28
37
  .option('-j, --json', 'Output as JSON')
29
38
  .action(async (productId, opts, cmd) => {
@@ -48,9 +57,9 @@ export function registerProductMetricCommands(product) {
48
57
  }
49
58
  });
50
59
 
51
- product
52
- .command('metric-set <productId> <name>')
53
- .helpGroup('Product config:')
60
+ metric
61
+ .command('set <product> <name>')
62
+ .helpGroup('Records:')
54
63
  .description('Add or update a dashboard metric on a product')
55
64
  .requiredOption('-r, --resource <resource>', 'Product API resource the dashboard invokes')
56
65
  .option('-l, --label <label>', 'Human-readable label for the metric')
@@ -59,13 +68,10 @@ export function registerProductMetricCommands(product) {
59
68
  '-a, --aggregation <agg>',
60
69
  'Aggregation across the fleet (sum, avg, max, min, count, top, none, distribution)',
61
70
  )
62
- .option(
63
- '-v, --visualization <viz>',
64
- 'Dashboard rendering hint (kpi, bar, sparkline, list)',
65
- )
71
+ .option('-v, --visualization <viz>', 'Dashboard rendering hint (kpi, bar, sparkline, list)')
66
72
  .option('-i, --interval <seconds>', 'Dashboard refresh interval in seconds', Number)
67
73
  .option('--unit <unit>', 'Unit suffix for display (e.g. "%", "devices")')
68
- .option('--json', 'Output as JSON')
74
+ .option('-j, --json', 'Output as JSON')
69
75
  .action(async (productId, name, opts, cmd) => {
70
76
  applyJsonFlag(opts);
71
77
  ensureConfigured();
@@ -96,15 +102,19 @@ export function registerProductMetricCommands(product) {
96
102
  }
97
103
  });
98
104
 
99
- product
100
- .command('metric-delete <productId> <name>')
101
- .helpGroup('Product config:')
105
+ metric
106
+ .command('delete <product> <name>')
107
+ .helpGroup('Records:')
102
108
  .description('Remove a dashboard metric from a product')
109
+ .option('-y, --yes', 'Do not ask for confirmation')
103
110
  .option('-j, --json', 'Output as JSON')
104
111
  .action(async (productId, name, opts, cmd) => {
105
112
  applyJsonFlag(opts);
106
113
  ensureConfigured();
107
114
  const user = getGlobalUser(cmd);
115
+ if (!(await confirmDestructive(opts, `Remove metric ${name} from ${productId}?`))) {
116
+ return;
117
+ }
108
118
  try {
109
119
  const { action, count } = await removeDashboardMetric(productId, name, user);
110
120
  if (isJsonMode()) {
@@ -32,6 +32,7 @@ import { classifyError, createSpinner, isJsonMode, printErr, printOk } from '@th
32
32
  import {
33
33
  applyJsonFlag,
34
34
  collectKeyValue,
35
+ confirmDestructive,
35
36
  ensureConfigured,
36
37
  getGlobalUser,
37
38
  parsePositiveInt,
@@ -47,7 +48,7 @@ async function readAllStdin() {
47
48
 
48
49
  function registerList(playbook) {
49
50
  playbook
50
- .command('list <productId>')
51
+ .command('list <product>')
51
52
  .helpGroup('Playbooks:')
52
53
  .description('List playbooks registered on a product')
53
54
  .option('-j, --json', 'Output as JSON')
@@ -83,7 +84,7 @@ function registerList(playbook) {
83
84
 
84
85
  function registerUpload(playbook) {
85
86
  playbook
86
- .command('upload <productId> <name> [file]')
87
+ .command('upload <product> <name> [file]')
87
88
  .helpGroup('Playbooks:')
88
89
  .description(
89
90
  'Upload a playbook YAML to a product (reads stdin when <file> is omitted or "-")',
@@ -153,7 +154,7 @@ function registerUpload(playbook) {
153
154
 
154
155
  function registerDownload(playbook) {
155
156
  playbook
156
- .command('download <productId> <name>')
157
+ .command('download <product> <name>')
157
158
  .helpGroup('Playbooks:')
158
159
  .description('Download a playbook YAML by name (writes to stdout by default)')
159
160
  .option('-o, --output <file>', 'Write to a local file instead of stdout')
@@ -200,14 +201,18 @@ function registerDownload(playbook) {
200
201
 
201
202
  function registerDelete(playbook) {
202
203
  playbook
203
- .command('delete <productId> <name>')
204
+ .command('delete <product> <name>')
204
205
  .helpGroup('Playbooks:')
205
206
  .description('Delete a playbook from a product (idempotent)')
207
+ .option('-y, --yes', 'Do not ask for confirmation')
206
208
  .option('-j, --json', 'Output as JSON')
207
209
  .action(async (productId, name, opts, cmd) => {
208
210
  applyJsonFlag(opts);
209
211
  ensureConfigured();
210
212
  const user = getGlobalUser(cmd);
213
+ if (!(await confirmDestructive(opts, `Delete playbook ${name} from ${productId}?`))) {
214
+ return;
215
+ }
211
216
  const spinner = createSpinner(`Deleting playbook ${name} from ${productId}...`).start();
212
217
  try {
213
218
  const result = await deleteProductPlaybook({ product: productId, name, user });
@@ -772,7 +777,7 @@ async function runFleet({ productId, name, pb, overrides, resolvedScope, user, o
772
777
 
773
778
  function registerRun(playbook) {
774
779
  playbook
775
- .command('run <productId> <name>')
780
+ .command('run <product> <name>')
776
781
  .helpGroup('Playbooks:')
777
782
  .description('Run a product playbook against one device or the whole fleet (progressive rollout)')
778
783
  .option('-d, --device <id>', 'Target device ID (single-device mode)')
@@ -9,17 +9,12 @@ import {
9
9
  createSpinner,
10
10
  classifyError,
11
11
  } from '@thinger-io/cli-core/output';
12
- import {
13
- applyJsonFlag,
14
- ensureConfigured,
15
- extractField,
16
- getGlobalUser,
17
- } from '../_shared.js';
12
+ import { applyJsonFlag, ensureConfigured, extractField, getGlobalUser } from '../_shared.js';
18
13
  import { fetchProductDevices } from './_shared.js';
19
14
 
20
15
  export function registerProductPropertyCommand(product) {
21
16
  product
22
- .command('property <productId> <propertyId>')
17
+ .command('device-property <product> <property>')
23
18
  .helpGroup('Fan-out:')
24
19
  .description('Read a property on every device of the product')
25
20
  .option('-j, --json', 'Output as JSON')
@@ -46,16 +41,11 @@ export function registerProductPropertyCommand(product) {
46
41
  const results = [];
47
42
  for (const device of devices) {
48
43
  try {
49
- const property = await getDeviceProperty(device.device, propertyId);
44
+ const property = await getDeviceProperty(device.device, propertyId, user);
50
45
  const value = extractField(property, opts.field);
51
46
  results.push({ device: device.device, ok: true, data: value });
52
47
  if (!isJsonMode()) {
53
- console.log(
54
- 'Device',
55
- info(device.device),
56
- 'property',
57
- info(propertyId),
58
- );
48
+ console.log('Device', info(device.device), 'property', info(propertyId));
59
49
  if (opts.field) console.log(value);
60
50
  else console.log(formatDeviceProperty(device.device, propertyId, property));
61
51
  }
@@ -28,7 +28,7 @@ import {
28
28
 
29
29
  export function registerProductPushCommand(product) {
30
30
  product
31
- .command('push <productId> <localPath> <remotePath>')
31
+ .command('push <product> <local-path> <remote-path>')
32
32
  .helpGroup('Filesystem:')
33
33
  .description('Upload a local file to every active device of a product, in parallel')
34
34
  .option('-j, --json', 'Output as JSON')
@@ -9,68 +9,68 @@ import {
9
9
  createSpinner,
10
10
  classifyError,
11
11
  } from '@thinger-io/cli-core/output';
12
- import {
13
- applyJsonFlag,
14
- collectInput,
15
- ensureConfigured,
16
- extractField,
17
- getGlobalUser,
18
- } from '../_shared.js';
12
+ import { bodyFromOptions, bodyOptions } from '@thinger-io/cli-core/commands/_shared';
13
+ import { applyJsonFlag, ensureConfigured, extractField, getGlobalUser } from '../_shared.js';
19
14
  import { fetchProductDevices } from './_shared.js';
20
15
 
21
16
  export function registerProductResourceCommand(product) {
22
- product
23
- .command('resource <productId> <resource>')
24
- .helpGroup('Fan-out:')
25
- .description('Call a resource on every active device of the product')
26
- .option('-j, --json', 'Output as JSON')
27
- .option('-f, --field <field>', 'Extract a sub-field from each result (dot path)')
28
- .option('-g, --group <group>', 'Filter devices by asset group')
29
- .option('-i, --input <key=value>', 'Resource input (repeatable)', collectInput, {})
30
- .action(async (productId, resource, opts, cmd) => {
31
- applyJsonFlag(opts);
32
- ensureConfigured();
33
- const user = getGlobalUser(cmd);
34
- const spinner = createSpinner(`Retrieving devices for product ${productId}...`).start();
35
- let devices;
17
+ bodyOptions(
18
+ product
19
+ .command('device-resource <product> <resource>')
20
+ .helpGroup('Fan-out:')
21
+ .description(
22
+ 'Call a resource on every active device of the product: with --from or --set it runs it with that input; without, it reads its output',
23
+ )
24
+ .option('-j, --json', 'Output as JSON')
25
+ .option('-f, --field <field>', 'Extract a sub-field from each result (dot path)')
26
+ .option('-g, --group <group>', 'Filter devices by asset group'),
27
+ ).action(async (productId, resource, opts, cmd) => {
28
+ applyJsonFlag(opts);
29
+ ensureConfigured();
30
+ const user = getGlobalUser(cmd);
31
+ let input;
32
+ try {
33
+ input = await bodyFromOptions(opts);
34
+ } catch (error) {
35
+ const { message, code } = classifyError(error);
36
+ printErr(message, { code });
37
+ return;
38
+ }
39
+ const spinner = createSpinner(`Retrieving devices for product ${productId}...`).start();
40
+ let devices;
41
+ try {
42
+ devices = await fetchProductDevices(productId, opts.group, user);
43
+ spinner.succeed(`Devices for product ${productId} retrieved successfully`);
44
+ } catch (error) {
45
+ spinner.fail(`Failed to retrieve devices for product ${productId}`);
46
+ const { message, code } = classifyError(error);
47
+ printErr(message, { code });
48
+ return;
49
+ }
50
+ devices = filterActiveDevices(devices);
51
+
52
+ const results = [];
53
+ for (const device of devices) {
36
54
  try {
37
- devices = await fetchProductDevices(productId, opts.group, user);
38
- spinner.succeed(`Devices for product ${productId} retrieved successfully`);
55
+ const result = await callDeviceResource(device.device, resource, input, { user });
56
+ const value = extractField(result, opts.field);
57
+ results.push({ device: device.device, ok: true, data: value });
58
+ if (!isJsonMode()) {
59
+ console.log('Device', info(device.device), 'resource', info(resource));
60
+ console.log(value);
61
+ }
39
62
  } catch (error) {
40
- spinner.fail(`Failed to retrieve devices for product ${productId}`);
41
63
  const { message, code } = classifyError(error);
42
- printErr(message, { code });
43
- return;
44
- }
45
- devices = filterActiveDevices(devices);
46
-
47
- const results = [];
48
- for (const device of devices) {
49
- try {
50
- const result = await callDeviceResource(device.device, resource, opts.input);
51
- const value = extractField(result, opts.field);
52
- results.push({ device: device.device, ok: true, data: value });
53
- if (!isJsonMode()) {
54
- console.log(
55
- 'Device',
56
- info(device.device),
57
- 'resource',
58
- info(resource),
59
- );
60
- console.log(value);
61
- }
62
- } catch (error) {
63
- const { message, code } = classifyError(error);
64
- results.push({ device: device.device, ok: false, error: { message, code } });
65
- if (!isJsonMode()) {
66
- console.error(
67
- errorStyle(
68
- `Error executing resource ${resource} for device ${device.device}: ${message}`,
69
- ),
70
- );
71
- }
64
+ results.push({ device: device.device, ok: false, error: { message, code } });
65
+ if (!isJsonMode()) {
66
+ console.error(
67
+ errorStyle(
68
+ `Error executing resource ${resource} for device ${device.device}: ${message}`,
69
+ ),
70
+ );
72
71
  }
73
72
  }
74
- if (isJsonMode()) printOk({ product: productId, resource, results });
75
- });
73
+ }
74
+ if (isJsonMode()) printOk({ product: productId, resource, results });
75
+ });
76
76
  }
@@ -29,7 +29,7 @@ import { fetchProductDevices } from './_shared.js';
29
29
 
30
30
  export function registerProductStatusCommand(product) {
31
31
  product
32
- .command('status <productId>')
32
+ .command('status <product>')
33
33
  .helpGroup('Discovery:')
34
34
  .description('Snapshot of every device in the product (status + monitoring)')
35
35
  .option('-j, --json', 'Output as JSON')
@@ -5,8 +5,12 @@ its terminal dashboard (`thinr` with no arguments) shows for a product, and
5
5
  the log sources an operator can stream from a device (in that dashboard or
6
6
  with `thinr device logs`). Today only thinr reads them; they are not the web
7
7
  console's dashboards (for those, see the server's `dashboard-sdk` guide and
8
- `thinr_dashboard_publish`). Both live on the server as product properties,
9
- so the devices need nothing and other clients could reuse them.
8
+ the `dashboard` action `publish`). Both live on the server as product
9
+ properties (type `product_property`, ids `{"product", "property"}`), so the
10
+ devices need nothing and other clients could reuse them. Each is written
11
+ whole: read it, change it, `resource_update` it with the whole `value`
12
+ (not with `merge`). It is checked before it is saved, and
13
+ `resource_describe` type `product_property` shows both shapes.
10
14
 
11
15
  Tool names below omit the `thinr_` prefix.
12
16
 
@@ -14,16 +18,14 @@ Tool names below omit the `thinr_` prefix.
14
18
 
15
19
  A metric is a value thinr's dashboard asks every device of the product for,
16
20
  and combines across the fleet: free disk, connected clients, service version…
17
- They live in the `dashboard_metrics` product property; edit them as the
18
- resource type `product_metric` (parent the product, id the metric's name:
19
- `resource_list`, `_get`, `_create`, `_update`, `_delete`), not by writing the
20
- property by hand.
21
+ They live in the `dashboard_metrics` product property, an array of
22
+ metrics with unique names (create the property with the first one).
21
23
 
22
24
  Fields of a metric:
23
25
 
24
26
  | Field | Meaning |
25
27
  | --------------- | -------------------------------------------------------------------------------------------------------- |
26
- | `name` | Stable id (letters, digits, `_`, `-`): the resource id |
28
+ | `name` | Stable id (letters, digits, `_`, `-`), unique in the array |
27
29
  | `label` | Title in the panel |
28
30
  | `resource` | Product API resource called on each device, usually a product script |
29
31
  | `field` | Dot path to the value in the response (`disk.used_pct`); omit for scalar responses |
@@ -33,8 +35,8 @@ Fields of a metric:
33
35
  | `unit` | Display suffix (`%`, `devices`) |
34
36
 
35
37
  Recipe: write a product script that prints the value as JSON (see
36
- `thinremote-scripts`), check it with `device_resource_call` on one device,
37
- then register the metric pointing at the script's resource name.
38
+ `thinremote-scripts`), check it with the `device_resource` action `call` on
39
+ one device, then add the metric pointing at the script's resource name.
38
40
 
39
41
  ## Log sources
40
42
 
@@ -42,18 +44,15 @@ A log source is a named shell command the agent streams when an operator
42
44
  opens a device's logs in thinr (`journalctl -f`, `docker logs -f app`,
43
45
  `tail -F /var/log/nginx/access.log`). They live in the `logs` product
44
46
  property; without it, products show a single `system` source running
45
- journalctl (listed with `fallback: true`). They are the resource type
46
- `product_log_source`, parent the product, id the source's name.
47
+ journalctl. Its value is `{"sources": [...], "default"?: "<name>"}`.
47
48
 
48
- - `resource_create`: a source by `name` (≤ 32 chars, letters, digits,
49
- `_`, `-`; up to 32 sources). The `command` is stored and run as is.
50
- `default: true` makes it the one shown first (and the one
51
- `thinr device logs` uses without `--source`).
49
+ - A source has a `name` (≤ 32 chars, letters, digits, `_`, `-`; unique; up
50
+ to 32 sources) and a `command`, stored and run as is. `default` names the
51
+ one shown first (and the one `thinr device logs` uses without
52
+ `--source`).
52
53
  - Structured rendering: add a `preset` (`resource_describe` type
53
- `product_log_source` lists them:
54
- journalctl, spdlog, nginx…) or your own `pattern`, a regex with named
55
- groups `time`, `level` and `msg`. With a `level` group, operators can filter
56
- by severity. `preset` and `pattern` are mutually exclusive.
57
- - `resource_update` with `{"default": true}` picks the default;
58
- `resource_delete` drops a source (removing the last one deletes the
59
- property, back to the fallback).
54
+ `product_property` lists them: journalctl, spdlog, nginx…) or your own
55
+ `pattern`, a regex with named groups `time`, `level` and `msg`. With a
56
+ `level` group, operators can filter by severity. `preset` and `pattern`
57
+ are mutually exclusive.
58
+ - Deleting the property (`resource_delete`) brings back the fallback.