@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,76 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { formatDeviceProperty, getDeviceProperty } from '../../lib/property.js';
|
|
3
|
+
import { filterActiveDevices } from '../../lib/devices.js';
|
|
4
|
+
import { error as errorStyle, info } from '../../lib/format.js';
|
|
5
|
+
import {
|
|
6
|
+
isJsonMode,
|
|
7
|
+
printOk,
|
|
8
|
+
printErr,
|
|
9
|
+
createSpinner,
|
|
10
|
+
classifyError,
|
|
11
|
+
} from '../../lib/output.js';
|
|
12
|
+
import {
|
|
13
|
+
applyJsonFlag,
|
|
14
|
+
ensureConfigured,
|
|
15
|
+
extractField,
|
|
16
|
+
getGlobalUser,
|
|
17
|
+
} from '../_shared.js';
|
|
18
|
+
import { fetchProductDevices } from './_shared.js';
|
|
19
|
+
|
|
20
|
+
export function registerProductPropertyCommand(product) {
|
|
21
|
+
product
|
|
22
|
+
.command('property <productId> <propertyId>')
|
|
23
|
+
.helpGroup('Fan-out:')
|
|
24
|
+
.description('Read a property on every device of the product')
|
|
25
|
+
.option('-j, --json', 'Output as JSON')
|
|
26
|
+
.option('-f, --field <field>', 'Extract a sub-field from each property (dot path)')
|
|
27
|
+
.option('-a, --all', 'Include offline devices (default: only active)')
|
|
28
|
+
.option('-g, --group <group>', 'Filter devices by asset group')
|
|
29
|
+
.action(async (productId, propertyId, opts, cmd) => {
|
|
30
|
+
applyJsonFlag(opts);
|
|
31
|
+
ensureConfigured();
|
|
32
|
+
const user = getGlobalUser(cmd);
|
|
33
|
+
const spinner = createSpinner(`Retrieving devices for product ${productId}...`).start();
|
|
34
|
+
let devices;
|
|
35
|
+
try {
|
|
36
|
+
devices = await fetchProductDevices(productId, opts.group, user);
|
|
37
|
+
spinner.succeed(`Devices for product ${productId} retrieved successfully`);
|
|
38
|
+
} catch (error) {
|
|
39
|
+
spinner.fail(`Failed to retrieve devices for product ${productId}`);
|
|
40
|
+
const { message, code } = classifyError(error);
|
|
41
|
+
printErr(message, { code });
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (!opts.all) devices = filterActiveDevices(devices);
|
|
45
|
+
|
|
46
|
+
const results = [];
|
|
47
|
+
for (const device of devices) {
|
|
48
|
+
try {
|
|
49
|
+
const property = await getDeviceProperty(device.device, propertyId);
|
|
50
|
+
const value = extractField(property, opts.field);
|
|
51
|
+
results.push({ device: device.device, ok: true, data: value });
|
|
52
|
+
if (!isJsonMode()) {
|
|
53
|
+
console.log(
|
|
54
|
+
'Device',
|
|
55
|
+
info(device.device),
|
|
56
|
+
'property',
|
|
57
|
+
info(propertyId),
|
|
58
|
+
);
|
|
59
|
+
if (opts.field) console.log(value);
|
|
60
|
+
else console.log(formatDeviceProperty(device.device, propertyId, property));
|
|
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 retrieving property ${propertyId} for device ${device.device}: ${message}`,
|
|
69
|
+
),
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (isJsonMode()) printOk({ product: productId, property: propertyId, results });
|
|
75
|
+
});
|
|
76
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import {
|
|
3
|
+
getProductProperties,
|
|
4
|
+
getProductProperty,
|
|
5
|
+
setProductProperty,
|
|
6
|
+
deleteProductProperty,
|
|
7
|
+
} from '../../lib/product.js';
|
|
8
|
+
import { info, success } from '../../lib/format.js';
|
|
9
|
+
import { isJsonMode, printOk, printErr, classifyError } from '../../lib/output.js';
|
|
10
|
+
import { applyJsonFlag, ensureConfigured, getGlobalUser } from '../_shared.js';
|
|
11
|
+
|
|
12
|
+
function parseJsonValue(raw) {
|
|
13
|
+
if (raw == null) return raw;
|
|
14
|
+
try {
|
|
15
|
+
return JSON.parse(raw);
|
|
16
|
+
} catch {
|
|
17
|
+
// Fall back to raw string so `thinr product property-set p x foo` still works
|
|
18
|
+
// for trivial scalar writes without shell-escaping quotes.
|
|
19
|
+
return raw;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function registerProductPropertyAdminCommands(product) {
|
|
24
|
+
product
|
|
25
|
+
.command('property-list <productId>')
|
|
26
|
+
.helpGroup('Product config:')
|
|
27
|
+
.description('List the names of properties attached to a product')
|
|
28
|
+
.option('-j, --json', 'Output as JSON')
|
|
29
|
+
.action(async (productId, opts, cmd) => {
|
|
30
|
+
applyJsonFlag(opts);
|
|
31
|
+
ensureConfigured();
|
|
32
|
+
const user = getGlobalUser(cmd);
|
|
33
|
+
try {
|
|
34
|
+
const list = await getProductProperties(productId, user);
|
|
35
|
+
const names = Array.isArray(list) ? list.map((p) => p.property).filter(Boolean) : [];
|
|
36
|
+
if (isJsonMode()) {
|
|
37
|
+
printOk({ product: productId, properties: names });
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (names.length === 0) {
|
|
41
|
+
console.log(`No properties on product ${info(productId)}`);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
console.log(`Properties of product ${info(productId)}:`);
|
|
45
|
+
for (const n of names) console.log(`- ${n}`);
|
|
46
|
+
} catch (error) {
|
|
47
|
+
const { message, code } = classifyError(error);
|
|
48
|
+
printErr(message, { code });
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
product
|
|
53
|
+
.command('property-get <productId> <propertyId>')
|
|
54
|
+
.helpGroup('Product config:')
|
|
55
|
+
.description('Read a property attached to a product')
|
|
56
|
+
.option('-j, --json', 'Output as JSON')
|
|
57
|
+
.action(async (productId, propertyId, opts, cmd) => {
|
|
58
|
+
applyJsonFlag(opts);
|
|
59
|
+
ensureConfigured();
|
|
60
|
+
const user = getGlobalUser(cmd);
|
|
61
|
+
try {
|
|
62
|
+
const value = await getProductProperty(productId, propertyId, user);
|
|
63
|
+
if (isJsonMode()) {
|
|
64
|
+
printOk({ product: productId, property: propertyId, value });
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
console.log(`Value of ${info(propertyId)} on product ${info(productId)}:`);
|
|
68
|
+
console.log(JSON.stringify(value, null, 2));
|
|
69
|
+
} catch (error) {
|
|
70
|
+
const { message, code } = classifyError(error);
|
|
71
|
+
printErr(message, { code });
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
product
|
|
76
|
+
.command('property-set <productId> <propertyId> <value>')
|
|
77
|
+
.helpGroup('Product config:')
|
|
78
|
+
.description('Create or overwrite a product property (value parsed as JSON)')
|
|
79
|
+
.option('-j, --json', 'Output as JSON')
|
|
80
|
+
.action(async (productId, propertyId, value, opts, cmd) => {
|
|
81
|
+
applyJsonFlag(opts);
|
|
82
|
+
ensureConfigured();
|
|
83
|
+
const user = getGlobalUser(cmd);
|
|
84
|
+
const parsed = parseJsonValue(value);
|
|
85
|
+
try {
|
|
86
|
+
const saved = await setProductProperty(productId, propertyId, parsed, user);
|
|
87
|
+
if (isJsonMode()) {
|
|
88
|
+
printOk({ product: productId, property: propertyId, value: saved.value });
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
console.log(
|
|
92
|
+
`Saved property ${info(propertyId)} on product ${info(productId)} → ${success(JSON.stringify(saved.value))}`,
|
|
93
|
+
);
|
|
94
|
+
} catch (error) {
|
|
95
|
+
const { message, code } = classifyError(error);
|
|
96
|
+
printErr(message, { code });
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
product
|
|
101
|
+
.command('property-delete <productId> <propertyId>')
|
|
102
|
+
.helpGroup('Product config:')
|
|
103
|
+
.description('Delete a product property (idempotent)')
|
|
104
|
+
.option('-j, --json', 'Output as JSON')
|
|
105
|
+
.action(async (productId, propertyId, opts, cmd) => {
|
|
106
|
+
applyJsonFlag(opts);
|
|
107
|
+
ensureConfigured();
|
|
108
|
+
const user = getGlobalUser(cmd);
|
|
109
|
+
try {
|
|
110
|
+
const removed = await deleteProductProperty(productId, propertyId, user);
|
|
111
|
+
if (isJsonMode()) {
|
|
112
|
+
printOk({ product: productId, property: propertyId, removed });
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
console.log(
|
|
116
|
+
removed
|
|
117
|
+
? `Deleted ${info(propertyId)} from ${info(productId)}`
|
|
118
|
+
: `Property ${info(propertyId)} was not set on ${info(productId)}`,
|
|
119
|
+
);
|
|
120
|
+
} catch (error) {
|
|
121
|
+
const { message, code } = classifyError(error);
|
|
122
|
+
printErr(message, { code });
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { readFile as readLocalFile } from 'fs/promises';
|
|
3
|
+
import Table from 'cli-table3';
|
|
4
|
+
import { createDeviceAPI } from '../../lib/device-api.js';
|
|
5
|
+
import { runProductFanOut } from '../../lib/product-orchestrator.js';
|
|
6
|
+
import {
|
|
7
|
+
label,
|
|
8
|
+
muted,
|
|
9
|
+
hint,
|
|
10
|
+
success,
|
|
11
|
+
error as errorStyle,
|
|
12
|
+
warning,
|
|
13
|
+
} from '../../lib/format.js';
|
|
14
|
+
import {
|
|
15
|
+
isJsonMode,
|
|
16
|
+
printOk,
|
|
17
|
+
printErr,
|
|
18
|
+
createSpinner,
|
|
19
|
+
classifyError,
|
|
20
|
+
} from '../../lib/output.js';
|
|
21
|
+
import {
|
|
22
|
+
applyJsonFlag,
|
|
23
|
+
ensureConfigured,
|
|
24
|
+
getGlobalUser,
|
|
25
|
+
parsePositiveInt,
|
|
26
|
+
ProgressSpinner,
|
|
27
|
+
} from '../_shared.js';
|
|
28
|
+
|
|
29
|
+
export function registerProductPushCommand(product) {
|
|
30
|
+
product
|
|
31
|
+
.command('push <productId> <localPath> <remotePath>')
|
|
32
|
+
.helpGroup('Filesystem:')
|
|
33
|
+
.description('Upload a local file to every active device of a product, in parallel')
|
|
34
|
+
.option('-j, --json', 'Output as JSON')
|
|
35
|
+
.option('-g, --group <group>', 'Filter devices by asset group')
|
|
36
|
+
.option(
|
|
37
|
+
'-c, --concurrency <n>',
|
|
38
|
+
'Max parallel uploads (default: 10)',
|
|
39
|
+
parsePositiveInt('concurrency'),
|
|
40
|
+
10,
|
|
41
|
+
)
|
|
42
|
+
.option('--fail-fast', 'Stop dequeueing new devices on the first failure')
|
|
43
|
+
.option('-a, --all', 'Include offline devices (by default, only active)')
|
|
44
|
+
.action(async (productId, localPath, remotePath, opts, cmd) => {
|
|
45
|
+
applyJsonFlag(opts);
|
|
46
|
+
ensureConfigured();
|
|
47
|
+
const user = getGlobalUser(cmd);
|
|
48
|
+
|
|
49
|
+
let content;
|
|
50
|
+
try {
|
|
51
|
+
content = await readLocalFile(localPath);
|
|
52
|
+
} catch (err) {
|
|
53
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
54
|
+
printErr(`Cannot read local file: ${msg}`, { code: 'input_error' });
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const fetchSpinner = createSpinner(
|
|
59
|
+
`Retrieving devices for product ${productId}...`,
|
|
60
|
+
).start();
|
|
61
|
+
/** @type {ProgressSpinner | null} */
|
|
62
|
+
let progress = null;
|
|
63
|
+
|
|
64
|
+
let result;
|
|
65
|
+
try {
|
|
66
|
+
result = await runProductFanOut({
|
|
67
|
+
product: productId,
|
|
68
|
+
group: opts.group,
|
|
69
|
+
includeOffline: !!opts.all,
|
|
70
|
+
user,
|
|
71
|
+
concurrency: opts.concurrency,
|
|
72
|
+
failFast: !!opts.failFast,
|
|
73
|
+
worker: async (device) => {
|
|
74
|
+
const api = createDeviceAPI(device.device, { user });
|
|
75
|
+
const perStart = Date.now();
|
|
76
|
+
try {
|
|
77
|
+
await api.writeFile(remotePath, content);
|
|
78
|
+
return {
|
|
79
|
+
device: device.device,
|
|
80
|
+
ok: true,
|
|
81
|
+
bytes: content.byteLength,
|
|
82
|
+
durationMs: Date.now() - perStart,
|
|
83
|
+
};
|
|
84
|
+
} catch (err) {
|
|
85
|
+
return {
|
|
86
|
+
device: device.device,
|
|
87
|
+
ok: false,
|
|
88
|
+
error: err instanceof Error ? err.message : String(err),
|
|
89
|
+
durationMs: Date.now() - perStart,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
skipped: (device, firstFailure) => ({
|
|
94
|
+
device: device.device,
|
|
95
|
+
ok: false,
|
|
96
|
+
error: `skipped (fail-fast after ${firstFailure})`,
|
|
97
|
+
durationMs: 0,
|
|
98
|
+
}),
|
|
99
|
+
isFailure: (entry) => !entry.ok,
|
|
100
|
+
onDevicesResolved: (devices) => {
|
|
101
|
+
fetchSpinner.succeed(
|
|
102
|
+
`Found ${devices.length} device(s) in product ${productId}`,
|
|
103
|
+
);
|
|
104
|
+
if (devices.length > 0 && !isJsonMode()) {
|
|
105
|
+
progress = new ProgressSpinner(
|
|
106
|
+
createSpinner('').start(),
|
|
107
|
+
devices.length,
|
|
108
|
+
({ done, total, inFlight }) =>
|
|
109
|
+
`Uploading ${localPath} → ${remotePath} on ${total} device(s) — ` +
|
|
110
|
+
`${done}/${total} done · ${inFlight} in flight` +
|
|
111
|
+
(opts.failFast ? ' · fail-fast' : ''),
|
|
112
|
+
);
|
|
113
|
+
progress.render();
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
onItemStart: () => progress?.startItem(),
|
|
117
|
+
onItemFinish: () => progress?.finishItem(),
|
|
118
|
+
});
|
|
119
|
+
} catch (error) {
|
|
120
|
+
fetchSpinner.fail(`Failed to retrieve devices for product ${productId}`);
|
|
121
|
+
const { message, code } = classifyError(error);
|
|
122
|
+
printErr(message, { code });
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
progress?.stop();
|
|
126
|
+
|
|
127
|
+
const { devices, entries, durationMs } = result;
|
|
128
|
+
|
|
129
|
+
if (devices.length === 0) {
|
|
130
|
+
if (isJsonMode()) {
|
|
131
|
+
printOk({
|
|
132
|
+
product: productId,
|
|
133
|
+
local: localPath,
|
|
134
|
+
remote: remotePath,
|
|
135
|
+
summary: { total: 0, ok: 0, failed: 0 },
|
|
136
|
+
results: [],
|
|
137
|
+
});
|
|
138
|
+
} else {
|
|
139
|
+
console.log(warning('No devices to upload to.'));
|
|
140
|
+
}
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const okCount = entries.filter((e) => e.ok).length;
|
|
145
|
+
const failCount = entries.length - okCount;
|
|
146
|
+
|
|
147
|
+
if (isJsonMode()) {
|
|
148
|
+
printOk({
|
|
149
|
+
product: productId,
|
|
150
|
+
local: localPath,
|
|
151
|
+
remote: remotePath,
|
|
152
|
+
summary: {
|
|
153
|
+
total: entries.length,
|
|
154
|
+
ok: okCount,
|
|
155
|
+
failed: failCount,
|
|
156
|
+
bytes: content.byteLength,
|
|
157
|
+
durationMs,
|
|
158
|
+
},
|
|
159
|
+
results: entries,
|
|
160
|
+
});
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const table = new Table({
|
|
165
|
+
head: ['Device', 'Status', 'Detail', 'Duration'].map((h) => label(h)),
|
|
166
|
+
style: { head: [], border: ['gray'] },
|
|
167
|
+
colAligns: ['left', 'left', 'left', 'right'],
|
|
168
|
+
});
|
|
169
|
+
for (const e of entries) {
|
|
170
|
+
table.push([
|
|
171
|
+
e.device,
|
|
172
|
+
e.ok ? success('ok') : errorStyle('fail'),
|
|
173
|
+
e.ok ? muted(`${e.bytes} bytes`) : e.error || '',
|
|
174
|
+
`${e.durationMs}ms`,
|
|
175
|
+
]);
|
|
176
|
+
}
|
|
177
|
+
console.log(table.toString());
|
|
178
|
+
console.log(
|
|
179
|
+
hint(
|
|
180
|
+
`${entries.length} total · ${okCount} ok · ${failCount} failed · ${content.byteLength} bytes · ${durationMs}ms`,
|
|
181
|
+
),
|
|
182
|
+
);
|
|
183
|
+
});
|
|
184
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { callDeviceResource } from '../../lib/resource.js';
|
|
3
|
+
import { filterActiveDevices } from '../../lib/devices.js';
|
|
4
|
+
import { error as errorStyle, info } from '../../lib/format.js';
|
|
5
|
+
import {
|
|
6
|
+
isJsonMode,
|
|
7
|
+
printOk,
|
|
8
|
+
printErr,
|
|
9
|
+
createSpinner,
|
|
10
|
+
classifyError,
|
|
11
|
+
} from '../../lib/output.js';
|
|
12
|
+
import {
|
|
13
|
+
applyJsonFlag,
|
|
14
|
+
collectInput,
|
|
15
|
+
ensureConfigured,
|
|
16
|
+
extractField,
|
|
17
|
+
getGlobalUser,
|
|
18
|
+
} from '../_shared.js';
|
|
19
|
+
import { fetchProductDevices } from './_shared.js';
|
|
20
|
+
|
|
21
|
+
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;
|
|
36
|
+
try {
|
|
37
|
+
devices = await fetchProductDevices(productId, opts.group, user);
|
|
38
|
+
spinner.succeed(`Devices for product ${productId} retrieved successfully`);
|
|
39
|
+
} catch (error) {
|
|
40
|
+
spinner.fail(`Failed to retrieve devices for product ${productId}`);
|
|
41
|
+
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
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (isJsonMode()) printOk({ product: productId, resource, results });
|
|
75
|
+
});
|
|
76
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import Table from 'cli-table3';
|
|
3
|
+
import { InvalidArgumentError } from 'commander';
|
|
4
|
+
import { runPool } from '../../lib/concurrency.js';
|
|
5
|
+
import { getMonitoringData } from '../../lib/monitoring.js';
|
|
6
|
+
import {
|
|
7
|
+
formatUptime,
|
|
8
|
+
colorPct,
|
|
9
|
+
label,
|
|
10
|
+
muted,
|
|
11
|
+
hint,
|
|
12
|
+
success,
|
|
13
|
+
error as errorStyle,
|
|
14
|
+
} from '../../lib/format.js';
|
|
15
|
+
import {
|
|
16
|
+
isJsonMode,
|
|
17
|
+
printOk,
|
|
18
|
+
printErr,
|
|
19
|
+
createSpinner,
|
|
20
|
+
classifyError,
|
|
21
|
+
} from '../../lib/output.js';
|
|
22
|
+
import {
|
|
23
|
+
applyJsonFlag,
|
|
24
|
+
ensureConfigured,
|
|
25
|
+
getGlobalUser,
|
|
26
|
+
parsePositiveInt,
|
|
27
|
+
} from '../_shared.js';
|
|
28
|
+
import { fetchProductDevices } from './_shared.js';
|
|
29
|
+
|
|
30
|
+
export function registerProductStatusCommand(product) {
|
|
31
|
+
product
|
|
32
|
+
.command('status <productId>')
|
|
33
|
+
.helpGroup('Discovery:')
|
|
34
|
+
.description('Snapshot of every device in the product (status + monitoring)')
|
|
35
|
+
.option('-j, --json', 'Output as JSON')
|
|
36
|
+
.option('-g, --group <group>', 'Filter devices by asset group')
|
|
37
|
+
.option(
|
|
38
|
+
'-c, --concurrency <n>',
|
|
39
|
+
'Max parallel monitoring queries (default: 10)',
|
|
40
|
+
parsePositiveInt('concurrency'),
|
|
41
|
+
10,
|
|
42
|
+
)
|
|
43
|
+
.option(
|
|
44
|
+
'-w, --watch [seconds]',
|
|
45
|
+
'Refresh continuously every N seconds (default: 5)',
|
|
46
|
+
)
|
|
47
|
+
.action(async (productId, opts, cmd) => {
|
|
48
|
+
applyJsonFlag(opts);
|
|
49
|
+
ensureConfigured();
|
|
50
|
+
const user = getGlobalUser(cmd);
|
|
51
|
+
|
|
52
|
+
// Parse the optional value of --watch. Commander delivers `true`
|
|
53
|
+
// when the flag is present without a value, the string value
|
|
54
|
+
// when one is given, and `undefined` when absent.
|
|
55
|
+
let watchSeconds = 0;
|
|
56
|
+
if (opts.watch !== undefined) {
|
|
57
|
+
if (opts.watch === true) watchSeconds = 5;
|
|
58
|
+
else {
|
|
59
|
+
const n = Number(opts.watch);
|
|
60
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
61
|
+
throw new InvalidArgumentError(
|
|
62
|
+
'watch interval must be a positive integer (seconds)',
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
watchSeconds = n;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Snapshot a single pass: fetch devices, pull latest monitoring
|
|
70
|
+
// for each, and return parallel arrays the renderer can consume.
|
|
71
|
+
const collect = async () => {
|
|
72
|
+
const devices = await fetchProductDevices(productId, opts.group, user);
|
|
73
|
+
const monResults = await runPool(devices, opts.concurrency, async (device) => {
|
|
74
|
+
if (!device.connection?.active) return null;
|
|
75
|
+
try {
|
|
76
|
+
const data = await getMonitoringData({
|
|
77
|
+
device: device.device,
|
|
78
|
+
user,
|
|
79
|
+
items: 1,
|
|
80
|
+
sort: 'desc',
|
|
81
|
+
});
|
|
82
|
+
return Array.isArray(data) && data.length > 0 ? data[0] : null;
|
|
83
|
+
} catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
return devices.map((device, i) => {
|
|
88
|
+
const mon = monResults[i] && monResults[i].ok ? monResults[i].value : null;
|
|
89
|
+
return { device, mon };
|
|
90
|
+
});
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const render = (rows) => {
|
|
94
|
+
if (isJsonMode()) {
|
|
95
|
+
printOk({
|
|
96
|
+
product: productId,
|
|
97
|
+
ts: Date.now(),
|
|
98
|
+
devices: rows.map(({ device, mon }) => ({
|
|
99
|
+
device: device.device,
|
|
100
|
+
name: device.name || null,
|
|
101
|
+
online: !!device.connection?.active,
|
|
102
|
+
ip: device.connection?.ip_address || null,
|
|
103
|
+
monitoring: mon,
|
|
104
|
+
})),
|
|
105
|
+
});
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
const table = new Table({
|
|
109
|
+
head: ['Device', 'Status', 'CPU', 'Mem', 'Disk', 'Load', 'Uptime'].map((h) =>
|
|
110
|
+
label(h),
|
|
111
|
+
),
|
|
112
|
+
style: { head: [], border: ['gray'] },
|
|
113
|
+
colAligns: ['left', 'left', 'right', 'right', 'right', 'right', 'right'],
|
|
114
|
+
});
|
|
115
|
+
for (const { device, mon } of rows) {
|
|
116
|
+
const online = !!device.connection?.active;
|
|
117
|
+
const status = online ? success('online') : muted('offline');
|
|
118
|
+
if (online && mon) {
|
|
119
|
+
table.push([
|
|
120
|
+
device.device,
|
|
121
|
+
status,
|
|
122
|
+
colorPct(mon.cpu?.usage),
|
|
123
|
+
colorPct(mon.memory?.usage),
|
|
124
|
+
colorPct(mon.disk?.root?.usage),
|
|
125
|
+
mon.load?.['1m'] != null
|
|
126
|
+
? mon.load['1m'].toFixed(2)
|
|
127
|
+
: muted('—'),
|
|
128
|
+
formatUptime(mon.uptime),
|
|
129
|
+
]);
|
|
130
|
+
} else {
|
|
131
|
+
const dim = muted('—');
|
|
132
|
+
table.push([device.device, status, dim, dim, dim, dim, dim]);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
const header = watchSeconds
|
|
136
|
+
? hint(
|
|
137
|
+
`${productId} · refreshed ${new Date().toLocaleTimeString()} · every ${watchSeconds}s · Ctrl+C to stop`,
|
|
138
|
+
)
|
|
139
|
+
: hint(`${productId} · ${new Date().toLocaleTimeString()}`);
|
|
140
|
+
console.log(header);
|
|
141
|
+
console.log(table.toString());
|
|
142
|
+
const onlineCount = rows.filter((r) => r.device.connection?.active).length;
|
|
143
|
+
console.log(
|
|
144
|
+
hint(`${rows.length} total · ${onlineCount} online · ${rows.length - onlineCount} offline`),
|
|
145
|
+
);
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
// In watch mode + TTY, switch to the terminal's alternate
|
|
149
|
+
// screen buffer (`\x1b[?1049h`) so updates redraw in place
|
|
150
|
+
// without piling up in the scrollback. On exit we restore
|
|
151
|
+
// the main buffer (`\x1b[?1049l`) and the user's prompt
|
|
152
|
+
// reappears with their previous history intact — same
|
|
153
|
+
// pattern `htop`, `vim` and `less` use.
|
|
154
|
+
const isTty = !!process.stdout.isTTY;
|
|
155
|
+
const useAltScreen = !!watchSeconds && isTty && !isJsonMode();
|
|
156
|
+
const enterAltScreen = () => process.stdout.write('\x1b[?1049h\x1b[H');
|
|
157
|
+
const exitAltScreen = () => process.stdout.write('\x1b[?1049l');
|
|
158
|
+
const clearScreen = () => process.stdout.write('\x1b[H\x1b[2J');
|
|
159
|
+
|
|
160
|
+
// First pass — always. The spinner runs in the main buffer so
|
|
161
|
+
// the user sees progress while we fetch devices + monitoring
|
|
162
|
+
// in parallel; once data is ready, we (optionally) switch to
|
|
163
|
+
// the alternate screen and draw the table from there.
|
|
164
|
+
let rows;
|
|
165
|
+
const spinner = createSpinner(
|
|
166
|
+
`Fetching status for devices in product ${productId}...`,
|
|
167
|
+
).start();
|
|
168
|
+
try {
|
|
169
|
+
rows = await collect();
|
|
170
|
+
spinner.stop();
|
|
171
|
+
} catch (error) {
|
|
172
|
+
spinner.fail(`Failed to fetch status for product ${productId}`);
|
|
173
|
+
const { message, code } = classifyError(error);
|
|
174
|
+
printErr(message, { code });
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (useAltScreen) {
|
|
179
|
+
enterAltScreen();
|
|
180
|
+
// Restore the main screen on any path out, including
|
|
181
|
+
// uncaught errors. `once` avoids double-emitting the
|
|
182
|
+
// escape sequence if the process exits multiple times.
|
|
183
|
+
process.once('exit', exitAltScreen);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
render(rows);
|
|
187
|
+
|
|
188
|
+
if (!watchSeconds) return;
|
|
189
|
+
|
|
190
|
+
// Watch loop. Redraw on each tick; clear in-buffer when
|
|
191
|
+
// we're on the alternate screen, and just append when
|
|
192
|
+
// stdout isn't a TTY (piped) — useful for logging.
|
|
193
|
+
let stopped = false;
|
|
194
|
+
process.on('SIGINT', () => {
|
|
195
|
+
stopped = true;
|
|
196
|
+
if (useAltScreen) exitAltScreen();
|
|
197
|
+
console.log('Watch stopped.');
|
|
198
|
+
process.exit(0);
|
|
199
|
+
});
|
|
200
|
+
while (!stopped) {
|
|
201
|
+
await new Promise((r) => setTimeout(r, watchSeconds * 1000));
|
|
202
|
+
if (stopped) break;
|
|
203
|
+
try {
|
|
204
|
+
const rows = await collect();
|
|
205
|
+
if (useAltScreen) clearScreen();
|
|
206
|
+
render(rows);
|
|
207
|
+
} catch (error) {
|
|
208
|
+
const { message, code } = classifyError(error);
|
|
209
|
+
console.error(errorStyle(`[watch] ${code}: ${message}`));
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
}
|