@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.
- package/CHANGELOG.md +113 -0
- package/README.md +167 -120
- package/bin/thinr.js +3 -58
- package/commands/_shared.js +1 -0
- package/commands/{device/update.js → agent/device.js} +10 -26
- package/commands/agent/fleet.js +254 -0
- package/commands/agent/index.js +79 -0
- package/commands/device/console.js +1 -1
- package/commands/device/exec.js +1 -1
- package/commands/device/fs.js +6 -6
- package/commands/device/index.js +4 -6
- package/commands/device/logs.js +1 -1
- package/commands/device/proxy.js +15 -6
- package/commands/device/status.js +1 -1
- package/commands/device/transfer.js +2 -2
- package/commands/product/exec.js +1 -1
- package/commands/product/fs.js +3 -3
- package/commands/product/index.js +5 -4
- package/commands/product/logs.js +35 -36
- package/commands/product/metric.js +24 -14
- package/commands/product/playbook.js +10 -5
- package/commands/product/property.js +4 -14
- package/commands/product/push.js +1 -1
- package/commands/product/resource.js +56 -56
- package/commands/product/status.js +1 -1
- package/docs/guides/thinremote-metrics-logs.md +21 -22
- package/docs/guides/thinremote-playbooks.md +15 -11
- package/docs/guides/thinremote-scripts.md +11 -10
- package/docs/guides/thinremote.md +53 -42
- package/lib/agent-releases.js +1 -1
- package/lib/cli.js +172 -0
- package/lib/constants.js +1 -1
- package/lib/guides.js +1 -1
- package/lib/identity.js +8 -6
- package/lib/mcp/{tools-exec.js → device-exec.js} +84 -55
- package/lib/mcp/helpers.js +2 -2
- package/lib/mcp/resource-hooks.js +104 -6
- package/lib/mcp/resource-types.js +114 -227
- package/lib/playbook/actions.js +1 -1
- package/lib/playbook/schema.js +1 -1
- package/lib/product/log-presets.js +1 -1
- package/lib/product.js +26 -0
- package/package.json +2 -2
- package/commands/fleet/index.js +0 -17
- package/commands/fleet/upgrade.js +0 -275
- package/lib/mcp/registry.js +0 -12
package/commands/product/fs.js
CHANGED
|
@@ -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: '<
|
|
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: '<
|
|
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: '<
|
|
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 <
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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);
|
package/commands/product/logs.js
CHANGED
|
@@ -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
|
-
.
|
|
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
|
|
77
|
-
logs
|
|
78
|
-
.
|
|
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(
|
|
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)
|
|
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
|
|
132
|
-
logs
|
|
133
|
-
.
|
|
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
|
-
.
|
|
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
|
|
204
|
-
logs
|
|
205
|
-
.
|
|
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(
|
|
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
|
-
.
|
|
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('
|
|
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
|
-
|
|
285
|
-
|
|
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
|
|
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
|
-
|
|
52
|
-
.command('
|
|
53
|
-
.helpGroup('
|
|
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
|
-
|
|
100
|
-
.command('
|
|
101
|
-
.helpGroup('
|
|
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 <
|
|
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 <
|
|
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 <
|
|
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 <
|
|
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 <
|
|
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 <
|
|
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
|
}
|
package/commands/product/push.js
CHANGED
|
@@ -28,7 +28,7 @@ import {
|
|
|
28
28
|
|
|
29
29
|
export function registerProductPushCommand(product) {
|
|
30
30
|
product
|
|
31
|
-
.command('push <
|
|
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
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
38
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
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 <
|
|
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
|
-
`
|
|
9
|
-
|
|
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
|
|
18
|
-
|
|
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, `_`, `-`)
|
|
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 `
|
|
37
|
-
then
|
|
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
|
|
46
|
-
`product_log_source`, parent the product, id the source's name.
|
|
47
|
+
journalctl. Its value is `{"sources": [...], "default"?: "<name>"}`.
|
|
47
48
|
|
|
48
|
-
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
`
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
-
|
|
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.
|