@thinremote/thinr-cli 1.2.0-alpha.1 → 1.3.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 +63 -1
- package/README.md +235 -30
- package/bin/thinr.js +43 -25
- 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 +68 -20
- package/lib/bucket.js +87 -0
- package/lib/command-stream.js +213 -0
- package/lib/config.js +5 -3
- package/lib/console.js +19 -5
- package/lib/constants.js +83 -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 +131 -148
- package/lib/devices.js +18 -21
- package/lib/errors.js +12 -0
- 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 +1226 -105
- 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 +14 -3
- package/commands/product.js +0 -407
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { inputError } from '../errors.js';
|
|
3
|
+
import {
|
|
4
|
+
getProductProperty,
|
|
5
|
+
setProductProperty,
|
|
6
|
+
deleteProductProperty,
|
|
7
|
+
} from '../product.js';
|
|
8
|
+
import { LEVEL_SEVERITY, PRESET_NAMES, getPreset, listPresets } from './log-presets.js';
|
|
9
|
+
|
|
10
|
+
export { LEVEL_SEVERITY, getPreset, listPresets };
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Schema, validator and helpers for the per-product `logs` property.
|
|
14
|
+
*
|
|
15
|
+
* A product's `logs` value declares the named log streams the dashboard,
|
|
16
|
+
* CLI and MCP can offer for any device bound to that product. Each
|
|
17
|
+
* source is a `{ name, command }` pair: the agent already knows how to
|
|
18
|
+
* exec-stream an arbitrary shell command, so a "log source" is just a
|
|
19
|
+
* label plus the command the client should send. No agent change, no
|
|
20
|
+
* new IOTMP kind — the command is an opaque string that the client
|
|
21
|
+
* stores and forwards.
|
|
22
|
+
*
|
|
23
|
+
* Fallback: when a product has no `logs` property, consumers should
|
|
24
|
+
* behave as before — surface a single synthetic source named `system`
|
|
25
|
+
* whose command is `FALLBACK_LOGS_COMMAND`, mirroring the journalctl
|
|
26
|
+
* stream the dashboard has used historically.
|
|
27
|
+
*
|
|
28
|
+
* @typedef {{
|
|
29
|
+
* name: string,
|
|
30
|
+
* command: string,
|
|
31
|
+
* pattern?: string,
|
|
32
|
+
* preset?: string,
|
|
33
|
+
* }} LogSource
|
|
34
|
+
* @typedef {{ sources: LogSource[], default?: string }} LogsConfig
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/** Property id under which the config lives on the product. */
|
|
38
|
+
export const LOGS_PROPERTY = 'logs';
|
|
39
|
+
|
|
40
|
+
/** Source name format: slug-ish, ≤ 32 chars, must have at least one char. */
|
|
41
|
+
export const LOG_SOURCE_NAME_RE = /^[A-Za-z0-9_-]{1,32}$/;
|
|
42
|
+
|
|
43
|
+
/** Maximum number of sources a single config may declare. */
|
|
44
|
+
export const MAX_LOG_SOURCES = 32;
|
|
45
|
+
|
|
46
|
+
/** Command used by the synthetic fallback when no `logs` config exists. */
|
|
47
|
+
export const FALLBACK_LOGS_COMMAND = 'journalctl --no-pager --output=short -f';
|
|
48
|
+
|
|
49
|
+
/** Fallback source name for products without a configured `logs` property. */
|
|
50
|
+
export const FALLBACK_LOGS_SOURCE_NAME = 'system';
|
|
51
|
+
|
|
52
|
+
const SOURCE_KEYS = new Set(['name', 'command', 'pattern', 'preset']);
|
|
53
|
+
const ROOT_KEYS = new Set(['sources', 'default']);
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Compile a pattern string into a RegExp, throwing `inputError` when the
|
|
57
|
+
* regex is malformed. We never execute the regex against adversarial
|
|
58
|
+
* input — it's operator-supplied — but we want to reject obvious
|
|
59
|
+
* mistakes (unbalanced parens, bad escapes) at config time so the
|
|
60
|
+
* dashboard never has to handle them at render time.
|
|
61
|
+
*
|
|
62
|
+
* @param {string} pattern
|
|
63
|
+
* @returns {RegExp}
|
|
64
|
+
*/
|
|
65
|
+
export function compileLogPattern(pattern) {
|
|
66
|
+
try {
|
|
67
|
+
return new RegExp(pattern);
|
|
68
|
+
} catch (e) {
|
|
69
|
+
throw inputError(
|
|
70
|
+
`pattern is not a valid regular expression: ${e?.message || String(e)}`,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Validate and normalize a `logs` property value.
|
|
77
|
+
*
|
|
78
|
+
* Accepts an unknown value, returns a clean copy stripped of unknown
|
|
79
|
+
* keys. Throws `inputError` on the first problem so callers can rely
|
|
80
|
+
* on the returned shape being safe to persist or hand off to the UI.
|
|
81
|
+
*
|
|
82
|
+
* @param {unknown} value
|
|
83
|
+
* @returns {LogsConfig}
|
|
84
|
+
*/
|
|
85
|
+
export function validateLogsConfig(value) {
|
|
86
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
87
|
+
throw inputError('logs must be an object with a `sources` array');
|
|
88
|
+
}
|
|
89
|
+
const obj = /** @type {Record<string, unknown>} */ (value);
|
|
90
|
+
|
|
91
|
+
for (const key of Object.keys(obj)) {
|
|
92
|
+
if (!ROOT_KEYS.has(key)) {
|
|
93
|
+
throw inputError(`logs: unknown key "${key}" (allowed: sources, default)`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const rawSources = obj.sources;
|
|
98
|
+
if (!Array.isArray(rawSources)) {
|
|
99
|
+
throw inputError('logs.sources is required and must be an array');
|
|
100
|
+
}
|
|
101
|
+
if (rawSources.length === 0) {
|
|
102
|
+
throw inputError('logs.sources must contain at least one source');
|
|
103
|
+
}
|
|
104
|
+
if (rawSources.length > MAX_LOG_SOURCES) {
|
|
105
|
+
throw inputError(`logs.sources accepts at most ${MAX_LOG_SOURCES} entries`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const sources = [];
|
|
109
|
+
const seen = new Set();
|
|
110
|
+
rawSources.forEach((raw, i) => {
|
|
111
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
112
|
+
throw inputError(`logs.sources[${i}] must be an object with name and command`);
|
|
113
|
+
}
|
|
114
|
+
const entry = /** @type {Record<string, unknown>} */ (raw);
|
|
115
|
+
for (const key of Object.keys(entry)) {
|
|
116
|
+
if (!SOURCE_KEYS.has(key)) {
|
|
117
|
+
throw inputError(
|
|
118
|
+
`logs.sources[${i}]: unknown key "${key}" (allowed: name, command, pattern, preset)`,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
const { name, command, pattern, preset } = entry;
|
|
123
|
+
if (typeof name !== 'string' || !LOG_SOURCE_NAME_RE.test(name)) {
|
|
124
|
+
throw inputError(
|
|
125
|
+
`logs.sources[${i}].name must be a slug (letters, digits, underscore, dash; 1-32 chars)`,
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
if (seen.has(name)) {
|
|
129
|
+
throw inputError(`logs.sources[${i}].name "${name}" is duplicated`);
|
|
130
|
+
}
|
|
131
|
+
if (typeof command !== 'string' || command.trim() === '') {
|
|
132
|
+
throw inputError(`logs.sources[${i}].command must be a non-empty string`);
|
|
133
|
+
}
|
|
134
|
+
if (pattern !== undefined && preset !== undefined) {
|
|
135
|
+
throw inputError(
|
|
136
|
+
`logs.sources[${i}]: "pattern" and "preset" are mutually exclusive`,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
/** @type {LogSource} */
|
|
140
|
+
const out = { name, command };
|
|
141
|
+
if (pattern !== undefined) {
|
|
142
|
+
if (typeof pattern !== 'string' || pattern === '') {
|
|
143
|
+
throw inputError(
|
|
144
|
+
`logs.sources[${i}].pattern must be a non-empty regex string`,
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
try {
|
|
148
|
+
new RegExp(pattern);
|
|
149
|
+
} catch (e) {
|
|
150
|
+
throw inputError(
|
|
151
|
+
`logs.sources[${i}].pattern is not a valid regular expression: ${e?.message || String(e)}`,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
out.pattern = pattern;
|
|
155
|
+
}
|
|
156
|
+
if (preset !== undefined) {
|
|
157
|
+
if (typeof preset !== 'string' || preset === '') {
|
|
158
|
+
throw inputError(
|
|
159
|
+
`logs.sources[${i}].preset must be a non-empty preset name`,
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
if (!getPreset(preset)) {
|
|
163
|
+
throw inputError(
|
|
164
|
+
`logs.sources[${i}].preset "${preset}" is unknown (available: ${PRESET_NAMES.join(', ')})`,
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
out.preset = preset;
|
|
168
|
+
}
|
|
169
|
+
seen.add(name);
|
|
170
|
+
sources.push(out);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
let def;
|
|
174
|
+
if (obj.default !== undefined && obj.default !== null) {
|
|
175
|
+
if (typeof obj.default !== 'string') {
|
|
176
|
+
throw inputError('logs.default must be a string matching a source name');
|
|
177
|
+
}
|
|
178
|
+
if (!seen.has(obj.default)) {
|
|
179
|
+
throw inputError(
|
|
180
|
+
`logs.default "${obj.default}" does not match any source name`,
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
def = obj.default;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** @type {LogsConfig} */
|
|
187
|
+
const out = { sources };
|
|
188
|
+
if (def !== undefined) out.default = def;
|
|
189
|
+
return out;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Resolve the active source from a (validated) config. Returns the
|
|
194
|
+
* entry whose name matches `default`, falling back to the first
|
|
195
|
+
* source. Useful for "what should I show first?" decisions in the UI.
|
|
196
|
+
*
|
|
197
|
+
* @param {LogsConfig} config
|
|
198
|
+
* @returns {LogSource}
|
|
199
|
+
*/
|
|
200
|
+
export function resolveDefaultLogSource(config) {
|
|
201
|
+
if (config.default) {
|
|
202
|
+
const match = config.sources.find((s) => s.name === config.default);
|
|
203
|
+
if (match) return match;
|
|
204
|
+
}
|
|
205
|
+
return config.sources[0];
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Synthetic config returned by `getProductLogs` when the product has
|
|
210
|
+
* no `logs` property set. Keeps consumers compatible with how the
|
|
211
|
+
* dashboard streamed logs before this feature existed.
|
|
212
|
+
*
|
|
213
|
+
* @returns {LogsConfig}
|
|
214
|
+
*/
|
|
215
|
+
export function fallbackLogsConfig() {
|
|
216
|
+
return {
|
|
217
|
+
sources: [{ name: FALLBACK_LOGS_SOURCE_NAME, command: FALLBACK_LOGS_COMMAND }],
|
|
218
|
+
default: FALLBACK_LOGS_SOURCE_NAME,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Read a product's `logs` config, validating the stored value before
|
|
224
|
+
* returning it. When the property is missing, returns the synthetic
|
|
225
|
+
* fallback so callers can render a `system` source unconditionally.
|
|
226
|
+
*
|
|
227
|
+
* The returned object always carries a `__fallback` flag so the UI can
|
|
228
|
+
* tell "operator configured this" from "we made one up". The flag is
|
|
229
|
+
* non-enumerable to keep it out of JSON round-trips.
|
|
230
|
+
*
|
|
231
|
+
* @param {string} productId
|
|
232
|
+
* @param {string} [user]
|
|
233
|
+
* @returns {Promise<LogsConfig & { __fallback?: boolean }>}
|
|
234
|
+
*/
|
|
235
|
+
export async function getProductLogs(productId, user) {
|
|
236
|
+
let raw;
|
|
237
|
+
try {
|
|
238
|
+
raw = await getProductProperty(productId, LOGS_PROPERTY, user);
|
|
239
|
+
} catch (e) {
|
|
240
|
+
if (/Property not found/.test(e?.message || '')) {
|
|
241
|
+
return markFallback(fallbackLogsConfig());
|
|
242
|
+
}
|
|
243
|
+
throw e;
|
|
244
|
+
}
|
|
245
|
+
if (raw === undefined || raw === null) {
|
|
246
|
+
return markFallback(fallbackLogsConfig());
|
|
247
|
+
}
|
|
248
|
+
return validateLogsConfig(raw);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function markFallback(config) {
|
|
252
|
+
Object.defineProperty(config, '__fallback', {
|
|
253
|
+
value: true,
|
|
254
|
+
enumerable: false,
|
|
255
|
+
writable: false,
|
|
256
|
+
});
|
|
257
|
+
return config;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Validate and persist a product's `logs` config. Returns the
|
|
262
|
+
* normalized value that was written.
|
|
263
|
+
*
|
|
264
|
+
* @param {string} productId
|
|
265
|
+
* @param {unknown} value
|
|
266
|
+
* @param {string} [user]
|
|
267
|
+
* @returns {Promise<LogsConfig>}
|
|
268
|
+
*/
|
|
269
|
+
export async function setProductLogs(productId, value, user) {
|
|
270
|
+
const normalized = validateLogsConfig(value);
|
|
271
|
+
await setProductProperty(productId, LOGS_PROPERTY, normalized, user);
|
|
272
|
+
return normalized;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Remove the `logs` property from a product. Returns true when the
|
|
277
|
+
* property existed, false when it was already absent.
|
|
278
|
+
*
|
|
279
|
+
* @param {string} productId
|
|
280
|
+
* @param {string} [user]
|
|
281
|
+
* @returns {Promise<boolean>}
|
|
282
|
+
*/
|
|
283
|
+
export async function deleteProductLogs(productId, user) {
|
|
284
|
+
return deleteProductProperty(productId, LOGS_PROPERTY, user);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Read the raw stored config without applying the synthetic fallback.
|
|
289
|
+
* Returns `null` when the property is missing — callers that need the
|
|
290
|
+
* fallback should reach for `getProductLogs` instead.
|
|
291
|
+
*
|
|
292
|
+
* @param {string} productId
|
|
293
|
+
* @param {string} [user]
|
|
294
|
+
* @returns {Promise<LogsConfig | null>}
|
|
295
|
+
*/
|
|
296
|
+
async function readStoredLogs(productId, user) {
|
|
297
|
+
let raw;
|
|
298
|
+
try {
|
|
299
|
+
raw = await getProductProperty(productId, LOGS_PROPERTY, user);
|
|
300
|
+
} catch (e) {
|
|
301
|
+
if (/Property not found/.test(e?.message || '')) return null;
|
|
302
|
+
throw e;
|
|
303
|
+
}
|
|
304
|
+
if (raw === undefined || raw === null) return null;
|
|
305
|
+
return validateLogsConfig(raw);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Add or replace a source on a product's `logs` config. When the
|
|
310
|
+
* product has no `logs` property yet, the call creates it from scratch
|
|
311
|
+
* with this single source. Pass `makeDefault` to mark the new source
|
|
312
|
+
* as the active default; otherwise the previous default is preserved
|
|
313
|
+
* (or, on a fresh config, no default is set so the first-source rule
|
|
314
|
+
* applies).
|
|
315
|
+
*
|
|
316
|
+
* `pattern` and `preset` are mutually exclusive. Passing either as an
|
|
317
|
+
* empty string clears the corresponding field on an existing source
|
|
318
|
+
* (handy for `--pattern ""` to revert a source to raw rendering).
|
|
319
|
+
*
|
|
320
|
+
* @param {string} productId
|
|
321
|
+
* @param {{
|
|
322
|
+
* name?: string,
|
|
323
|
+
* command?: string,
|
|
324
|
+
* makeDefault?: boolean,
|
|
325
|
+
* pattern?: string,
|
|
326
|
+
* preset?: string,
|
|
327
|
+
* }} [source]
|
|
328
|
+
* @param {string} [user]
|
|
329
|
+
* @returns {Promise<{ config: LogsConfig, action: 'added' | 'updated' }>}
|
|
330
|
+
*/
|
|
331
|
+
export async function addLogSource(productId, source = {}, user) {
|
|
332
|
+
const { name, command, makeDefault, pattern, preset } = source;
|
|
333
|
+
if (typeof name !== 'string' || !LOG_SOURCE_NAME_RE.test(name)) {
|
|
334
|
+
throw inputError(
|
|
335
|
+
'name must be a slug (letters, digits, underscore, dash; 1-32 chars)',
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
if (typeof command !== 'string' || command.trim() === '') {
|
|
339
|
+
throw inputError('command must be a non-empty string');
|
|
340
|
+
}
|
|
341
|
+
const hasPattern = typeof pattern === 'string' && pattern !== '';
|
|
342
|
+
const hasPreset = typeof preset === 'string' && preset !== '';
|
|
343
|
+
if (hasPattern && hasPreset) {
|
|
344
|
+
throw inputError('pattern and preset are mutually exclusive');
|
|
345
|
+
}
|
|
346
|
+
if (hasPreset && !getPreset(preset)) {
|
|
347
|
+
throw inputError(
|
|
348
|
+
`preset "${preset}" is unknown (available: ${PRESET_NAMES.join(', ')})`,
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
if (hasPattern) compileLogPattern(pattern);
|
|
352
|
+
|
|
353
|
+
const stored = (await readStoredLogs(productId, user)) || { sources: [] };
|
|
354
|
+
const nextSources = [...stored.sources];
|
|
355
|
+
const idx = nextSources.findIndex((s) => s.name === name);
|
|
356
|
+
const action = idx >= 0 ? 'updated' : 'added';
|
|
357
|
+
/** @type {LogSource} */
|
|
358
|
+
const entry = { name, command };
|
|
359
|
+
if (hasPattern) entry.pattern = pattern;
|
|
360
|
+
else if (hasPreset) entry.preset = preset;
|
|
361
|
+
if (idx >= 0) nextSources[idx] = entry;
|
|
362
|
+
else nextSources.push(entry);
|
|
363
|
+
|
|
364
|
+
/** @type {LogsConfig} */
|
|
365
|
+
const next = { sources: nextSources };
|
|
366
|
+
if (makeDefault) next.default = name;
|
|
367
|
+
else if (stored.default) next.default = stored.default;
|
|
368
|
+
|
|
369
|
+
const normalized = validateLogsConfig(next);
|
|
370
|
+
await setProductProperty(productId, LOGS_PROPERTY, normalized, user);
|
|
371
|
+
return { config: normalized, action };
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Resolve the regex string a source uses to parse its lines. Returns the
|
|
376
|
+
* literal `pattern` when set, the preset's pattern when `preset` is
|
|
377
|
+
* set, or `null` when the source declares neither (raw rendering).
|
|
378
|
+
*
|
|
379
|
+
* @param {LogSource} source
|
|
380
|
+
* @returns {string | null}
|
|
381
|
+
*/
|
|
382
|
+
export function resolveSourcePattern(source) {
|
|
383
|
+
if (!source) return null;
|
|
384
|
+
if (typeof source.pattern === 'string' && source.pattern !== '') {
|
|
385
|
+
return source.pattern;
|
|
386
|
+
}
|
|
387
|
+
if (typeof source.preset === 'string' && source.preset !== '') {
|
|
388
|
+
const p = getPreset(source.preset);
|
|
389
|
+
if (p) return p.pattern;
|
|
390
|
+
}
|
|
391
|
+
return null;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Remove a source by name from a product's `logs` config. When the
|
|
396
|
+
* removed source was the default, the default is dropped (downstream
|
|
397
|
+
* `resolveDefaultLogSource` will pick the first remaining source).
|
|
398
|
+
* Removing the last source deletes the property entirely so callers
|
|
399
|
+
* fall back to the synthetic `system` config.
|
|
400
|
+
*
|
|
401
|
+
* @param {string} productId
|
|
402
|
+
* @param {string} name
|
|
403
|
+
* @param {string} [user]
|
|
404
|
+
* @returns {Promise<{ removed: boolean, config: LogsConfig | null }>}
|
|
405
|
+
*/
|
|
406
|
+
export async function removeLogSource(productId, name, user) {
|
|
407
|
+
if (typeof name !== 'string' || !name) {
|
|
408
|
+
throw inputError('name is required');
|
|
409
|
+
}
|
|
410
|
+
const stored = await readStoredLogs(productId, user);
|
|
411
|
+
if (!stored) return { removed: false, config: null };
|
|
412
|
+
|
|
413
|
+
const nextSources = stored.sources.filter((s) => s.name !== name);
|
|
414
|
+
if (nextSources.length === stored.sources.length) {
|
|
415
|
+
return { removed: false, config: stored };
|
|
416
|
+
}
|
|
417
|
+
if (nextSources.length === 0) {
|
|
418
|
+
await deleteProductProperty(productId, LOGS_PROPERTY, user);
|
|
419
|
+
return { removed: true, config: null };
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** @type {LogsConfig} */
|
|
423
|
+
const next = { sources: nextSources };
|
|
424
|
+
if (stored.default && stored.default !== name) next.default = stored.default;
|
|
425
|
+
|
|
426
|
+
const normalized = validateLogsConfig(next);
|
|
427
|
+
await setProductProperty(productId, LOGS_PROPERTY, normalized, user);
|
|
428
|
+
return { removed: true, config: normalized };
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Set the active default source on a product's `logs` config. The
|
|
433
|
+
* named source must already exist; passing `null` clears the default
|
|
434
|
+
* so the first-source rule applies.
|
|
435
|
+
*
|
|
436
|
+
* @param {string} productId
|
|
437
|
+
* @param {string | null} name
|
|
438
|
+
* @param {string} [user]
|
|
439
|
+
* @returns {Promise<LogsConfig>}
|
|
440
|
+
*/
|
|
441
|
+
export async function setDefaultLogSource(productId, name, user) {
|
|
442
|
+
const clear = name === null || name === undefined;
|
|
443
|
+
if (!clear && typeof name !== 'string') {
|
|
444
|
+
throw inputError('default must be a string matching a source name');
|
|
445
|
+
}
|
|
446
|
+
const stored = await readStoredLogs(productId, user);
|
|
447
|
+
if (!stored) {
|
|
448
|
+
throw inputError(
|
|
449
|
+
`Product "${productId}" has no logs sources configured. Add one first.`,
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
/** @type {LogsConfig} */
|
|
453
|
+
const next = { sources: stored.sources };
|
|
454
|
+
if (!clear) {
|
|
455
|
+
if (!stored.sources.some((s) => s.name === name)) {
|
|
456
|
+
throw inputError(
|
|
457
|
+
`Source "${name}" is not configured on this product (have: ${stored.sources.map((s) => s.name).join(', ')})`,
|
|
458
|
+
);
|
|
459
|
+
}
|
|
460
|
+
next.default = name;
|
|
461
|
+
}
|
|
462
|
+
const normalized = validateLogsConfig(next);
|
|
463
|
+
await setProductProperty(productId, LOGS_PROPERTY, normalized, user);
|
|
464
|
+
return normalized;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Look up a single source by name on a product's `logs` config. When
|
|
469
|
+
* the property is missing, the synthetic fallback is consulted so
|
|
470
|
+
* `getLogSource(p, "system")` always resolves on products that have
|
|
471
|
+
* not been explicitly configured. Returns `null` when no source
|
|
472
|
+
* matches.
|
|
473
|
+
*
|
|
474
|
+
* @param {string} productId
|
|
475
|
+
* @param {string} name
|
|
476
|
+
* @param {string} [user]
|
|
477
|
+
* @returns {Promise<LogSource | null>}
|
|
478
|
+
*/
|
|
479
|
+
export async function getLogSource(productId, name, user) {
|
|
480
|
+
if (typeof name !== 'string' || !name) {
|
|
481
|
+
throw inputError('name is required');
|
|
482
|
+
}
|
|
483
|
+
const cfg = await getProductLogs(productId, user);
|
|
484
|
+
return cfg.sources.find((s) => s.name === name) || null;
|
|
485
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { getDevices, filterActiveDevices } from './devices.js';
|
|
3
|
+
import { runPool } from './concurrency.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Resolve the target devices of a product (optionally filtered by
|
|
7
|
+
* asset group and active connection), then run `worker(device)` over
|
|
8
|
+
* them with bounded concurrency.
|
|
9
|
+
*
|
|
10
|
+
* The worker is responsible for its own error handling and always
|
|
11
|
+
* returns a fully-populated entry. Any escaping throw is caught and
|
|
12
|
+
* converted via `onInternalError(device, err)` into a normalized
|
|
13
|
+
* failure entry, so the returned `entries` array always has one
|
|
14
|
+
* element per target device in the same order.
|
|
15
|
+
*
|
|
16
|
+
* Fail-fast is "soft": after the first failure (as judged by
|
|
17
|
+
* `isFailure(entry)`) remaining devices receive a `skipped(device,
|
|
18
|
+
* firstFailureDevice)` entry instead of running the worker. That way
|
|
19
|
+
* the per-device report still covers the whole fleet — the caller can
|
|
20
|
+
* show "skipped" in a table column instead of vanishing rows.
|
|
21
|
+
*
|
|
22
|
+
* The shape of `entry` is fully caller-defined: exec returns
|
|
23
|
+
* `{stdout, stderr, exitCode, …}`, push returns `{bytes, …}`,
|
|
24
|
+
* filesystem ops return `{durationMs, …}`. The orchestrator only
|
|
25
|
+
* touches the fields it gets told about via callbacks.
|
|
26
|
+
*
|
|
27
|
+
* @template E
|
|
28
|
+
* @param {{
|
|
29
|
+
* product: string,
|
|
30
|
+
* group?: string,
|
|
31
|
+
* includeOffline?: boolean,
|
|
32
|
+
* user?: string | null,
|
|
33
|
+
* concurrency?: number,
|
|
34
|
+
* failFast?: boolean,
|
|
35
|
+
* worker: (device: any) => Promise<E>,
|
|
36
|
+
* skipped: (device: any, firstFailureDevice: string) => E,
|
|
37
|
+
* isFailure: (entry: E) => boolean,
|
|
38
|
+
* onInternalError?: (device: any, error: unknown) => E,
|
|
39
|
+
* onDevicesResolved?: (devices: any[]) => void,
|
|
40
|
+
* onItemStart?: (device: any) => void,
|
|
41
|
+
* onItemFinish?: (device: any, entry: E) => void,
|
|
42
|
+
* }} opts
|
|
43
|
+
* @returns {Promise<{
|
|
44
|
+
* devices: any[],
|
|
45
|
+
* entries: E[],
|
|
46
|
+
* durationMs: number,
|
|
47
|
+
* }>}
|
|
48
|
+
*/
|
|
49
|
+
export async function runProductFanOut(opts) {
|
|
50
|
+
const {
|
|
51
|
+
product,
|
|
52
|
+
group,
|
|
53
|
+
includeOffline = false,
|
|
54
|
+
user,
|
|
55
|
+
concurrency = 10,
|
|
56
|
+
failFast = false,
|
|
57
|
+
worker,
|
|
58
|
+
skipped,
|
|
59
|
+
isFailure,
|
|
60
|
+
onInternalError,
|
|
61
|
+
onDevicesResolved,
|
|
62
|
+
onItemStart,
|
|
63
|
+
onItemFinish,
|
|
64
|
+
} = opts;
|
|
65
|
+
|
|
66
|
+
const filter = { product };
|
|
67
|
+
if (group) filter.asset_group = group;
|
|
68
|
+
let devices = await getDevices(filter, user || undefined);
|
|
69
|
+
if (!includeOffline) devices = filterActiveDevices(devices);
|
|
70
|
+
|
|
71
|
+
onDevicesResolved?.(devices);
|
|
72
|
+
|
|
73
|
+
if (devices.length === 0) {
|
|
74
|
+
return { devices, entries: [], durationMs: 0 };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const startTs = Date.now();
|
|
78
|
+
let aborted = false;
|
|
79
|
+
/** @type {string | null} */
|
|
80
|
+
let firstFailureDevice = null;
|
|
81
|
+
|
|
82
|
+
const poolResults = await runPool(devices, concurrency, async (device) => {
|
|
83
|
+
if (failFast && aborted && firstFailureDevice !== null) {
|
|
84
|
+
return skipped(device, firstFailureDevice);
|
|
85
|
+
}
|
|
86
|
+
onItemStart?.(device);
|
|
87
|
+
/** @type {E} */
|
|
88
|
+
let entry;
|
|
89
|
+
try {
|
|
90
|
+
entry = await worker(device);
|
|
91
|
+
} catch (err) {
|
|
92
|
+
// Workers should own their errors; this branch is the last
|
|
93
|
+
// line of defence for an uncaught throw (OOM, unexpected
|
|
94
|
+
// library bug…). Callers can customize the fallback shape.
|
|
95
|
+
entry =
|
|
96
|
+
onInternalError?.(device, err) ??
|
|
97
|
+
/** @type {E} */ (
|
|
98
|
+
/** @type {unknown} */ ({
|
|
99
|
+
device: device.device ?? String(device),
|
|
100
|
+
ok: false,
|
|
101
|
+
error: err instanceof Error ? err.message : String(err),
|
|
102
|
+
})
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
onItemFinish?.(device, entry);
|
|
106
|
+
if (failFast && !aborted && isFailure(entry)) {
|
|
107
|
+
aborted = true;
|
|
108
|
+
firstFailureDevice = device.device ?? String(device);
|
|
109
|
+
}
|
|
110
|
+
return entry;
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
/** @type {E[]} */
|
|
114
|
+
const entries = poolResults.map((r, i) => {
|
|
115
|
+
if (r?.ok) return r.value;
|
|
116
|
+
// Shouldn't happen — the inner try/catch already normalized
|
|
117
|
+
// every error into an entry. If it does, fall back to the
|
|
118
|
+
// caller's skipped() with a synthetic reason so the array
|
|
119
|
+
// shape stays consistent.
|
|
120
|
+
return skipped(devices[i], 'internal pool error');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
return { devices, entries, durationMs: Date.now() - startTs };
|
|
124
|
+
}
|