@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,209 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Catalogue of playbook actions the runner knows how to execute.
|
|
5
|
+
*
|
|
6
|
+
* Each entry declares the parameters it accepts (name, type, required
|
|
7
|
+
* flag, human description). The runner uses this for validation, the
|
|
8
|
+
* MCP surface uses it to advertise what playbooks can do, and the CLI
|
|
9
|
+
* uses it for dry-run labels and error messages.
|
|
10
|
+
*
|
|
11
|
+
* Action handlers live in ./actions.js; names must match.
|
|
12
|
+
*
|
|
13
|
+
* @typedef {{ name: string, type: 'string' | 'number' | 'boolean' | 'object', required?: boolean, description: string }} ParamSpec
|
|
14
|
+
* @typedef {{ name: string, description: string, params: ParamSpec[], summary: (params: Record<string, unknown>) => string }} ActionSpec
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** @type {ActionSpec[]} */
|
|
18
|
+
export const ACTIONS = [
|
|
19
|
+
{
|
|
20
|
+
name: 'exec',
|
|
21
|
+
description: 'Run a shell command on the device and fail the step on non-zero exit.',
|
|
22
|
+
params: [
|
|
23
|
+
{ name: 'command', type: 'string', required: true, description: 'Shell command to run.' },
|
|
24
|
+
{ name: 'timeout', type: 'number', description: 'Per-device timeout in seconds (default: 30).' },
|
|
25
|
+
],
|
|
26
|
+
summary: (p) => `exec: ${p.command}`,
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
name: 'sleep',
|
|
30
|
+
description: 'Pause execution for the given number of seconds before the next step.',
|
|
31
|
+
params: [
|
|
32
|
+
{ name: 'seconds', type: 'number', required: true, description: 'Seconds to sleep.' },
|
|
33
|
+
],
|
|
34
|
+
summary: (p) => `sleep ${p.seconds}s`,
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
name: 'write',
|
|
38
|
+
description: 'Write inline content to a file on the device. Use `push` to upload a local file instead.',
|
|
39
|
+
params: [
|
|
40
|
+
{ name: 'path', type: 'string', required: true, description: 'Absolute path on the device.' },
|
|
41
|
+
{ name: 'content', type: 'string', required: true, description: 'Inline content to write.' },
|
|
42
|
+
],
|
|
43
|
+
summary: (p) => `write ${p.path}`,
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
name: 'push',
|
|
47
|
+
description: 'Upload a local file to the device.',
|
|
48
|
+
params: [
|
|
49
|
+
{ name: 'source', type: 'string', required: true, description: 'Local file path (relative paths resolve against the playbook directory).' },
|
|
50
|
+
{ name: 'destination', type: 'string', required: true, description: 'Absolute path on the device.' },
|
|
51
|
+
],
|
|
52
|
+
summary: (p) => `push ${p.source} → ${p.destination}`,
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
name: 'pull',
|
|
56
|
+
description: 'Download a remote file from the device to the local disk.',
|
|
57
|
+
params: [
|
|
58
|
+
{ name: 'source', type: 'string', required: true, description: 'Absolute path on the device.' },
|
|
59
|
+
{ name: 'destination', type: 'string', required: true, description: 'Local file path (relative paths resolve against the playbook directory).' },
|
|
60
|
+
],
|
|
61
|
+
summary: (p) => `pull ${p.source} → ${p.destination}`,
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
name: 'rm',
|
|
65
|
+
description: 'Remove a file or directory on the device.',
|
|
66
|
+
params: [
|
|
67
|
+
{ name: 'path', type: 'string', required: true, description: 'Absolute path on the device.' },
|
|
68
|
+
{ name: 'recursive', type: 'boolean', description: 'Recurse into directories (default: true).' },
|
|
69
|
+
],
|
|
70
|
+
summary: (p) => `rm ${p.path}`,
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
name: 'mkdir',
|
|
74
|
+
description: 'Create a directory on the device.',
|
|
75
|
+
params: [
|
|
76
|
+
{ name: 'path', type: 'string', required: true, description: 'Absolute directory path on the device.' },
|
|
77
|
+
],
|
|
78
|
+
summary: (p) => `mkdir ${p.path}`,
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
name: 'mv',
|
|
82
|
+
description: 'Move or rename a path on the device. Handy for atomic deploys (write to tmp, mv into place).',
|
|
83
|
+
params: [
|
|
84
|
+
{ name: 'source', type: 'string', required: true, description: 'Path to move from.' },
|
|
85
|
+
{ name: 'destination', type: 'string', required: true, description: 'Target path.' },
|
|
86
|
+
{ name: 'overwrite', type: 'boolean', description: 'Overwrite destination if it exists (default: false).' },
|
|
87
|
+
],
|
|
88
|
+
summary: (p) => `mv ${p.source} → ${p.destination}`,
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
name: 'property_set',
|
|
92
|
+
description: 'Create or overwrite a device property.',
|
|
93
|
+
params: [
|
|
94
|
+
{ name: 'property', type: 'string', required: true, description: 'Property name.' },
|
|
95
|
+
{ name: 'value', type: 'object', required: true, description: 'Any JSON-serialisable value.' },
|
|
96
|
+
],
|
|
97
|
+
summary: (p) => `property_set ${p.property}`,
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
name: 'resource',
|
|
101
|
+
description: 'Invoke a device resource. Inputs are forwarded as the resource payload.',
|
|
102
|
+
params: [
|
|
103
|
+
{ name: 'resource', type: 'string', required: true, description: 'Resource name.' },
|
|
104
|
+
{ name: 'inputs', type: 'object', description: 'Resource inputs (omit for read-only resources).' },
|
|
105
|
+
],
|
|
106
|
+
summary: (p) => `resource ${p.resource}`,
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
name: 'update',
|
|
110
|
+
description: 'Check for or apply an agent update.',
|
|
111
|
+
params: [
|
|
112
|
+
{ name: 'op', type: 'string', required: true, description: '"check" or "apply".' },
|
|
113
|
+
{ name: 'channel', type: 'string', description: 'Update channel (default: "latest").' },
|
|
114
|
+
],
|
|
115
|
+
summary: (p) => `update ${p.op}`,
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
name: 'script_install',
|
|
119
|
+
description: 'Install or replace a custom script on the device. Triggers a scripts reload.',
|
|
120
|
+
params: [
|
|
121
|
+
{ name: 'name', type: 'string', required: true, description: 'Script filename (e.g. "battery.sh").' },
|
|
122
|
+
{ name: 'content', type: 'string', description: 'Inline script source.' },
|
|
123
|
+
{ name: 'content_file', type: 'string', description: 'Local file with the script source.' },
|
|
124
|
+
],
|
|
125
|
+
summary: (p) => `script_install ${p.name}`,
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
name: 'script_delete',
|
|
129
|
+
description: 'Remove a custom script from the device.',
|
|
130
|
+
params: [
|
|
131
|
+
{ name: 'name', type: 'string', required: true, description: 'Script filename.' },
|
|
132
|
+
],
|
|
133
|
+
summary: (p) => `script_delete ${p.name}`,
|
|
134
|
+
},
|
|
135
|
+
];
|
|
136
|
+
|
|
137
|
+
/** Map from action name to spec, computed once for O(1) lookup. */
|
|
138
|
+
export const ACTION_BY_NAME = Object.fromEntries(ACTIONS.map((a) => [a.name, a]));
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* JSON-shaped summary of the accepted playbook format. Consumed by the
|
|
142
|
+
* MCP `thinr_playbook_schema` tool so an AI agent can author playbooks
|
|
143
|
+
* that validate on the first try.
|
|
144
|
+
*/
|
|
145
|
+
export function playbookSchema() {
|
|
146
|
+
return {
|
|
147
|
+
version: 1,
|
|
148
|
+
description:
|
|
149
|
+
'ThinR playbook — declarative sequence of agent-backed actions executed in parallel across a fleet.',
|
|
150
|
+
document: {
|
|
151
|
+
name: 'Optional human-readable name.',
|
|
152
|
+
description: 'Optional longer description.',
|
|
153
|
+
target: {
|
|
154
|
+
product: 'Product ID (use instead of `devices`).',
|
|
155
|
+
group: 'Optional asset group filter.',
|
|
156
|
+
devices: 'Optional explicit list of device IDs.',
|
|
157
|
+
concurrency: 'Max parallel devices (default: 10).',
|
|
158
|
+
fail_fast: 'Abort the whole run on the first step failure (default: false).',
|
|
159
|
+
},
|
|
160
|
+
vars: 'Mapping of variable name → value (plain form) or name → metadata object (extended form — see `vars_extended`). Referenced in step params with `{{ var }}`.',
|
|
161
|
+
steps: 'Ordered list of steps, executed sequentially on each device.',
|
|
162
|
+
},
|
|
163
|
+
vars_extended: {
|
|
164
|
+
description:
|
|
165
|
+
'Extended form for a single `vars` entry. Use it when a variable needs a description, an explicit type for override validation, or must be marked non-overridable or required.',
|
|
166
|
+
fields: {
|
|
167
|
+
default: 'Default value. Optional when `required: true`. Must match the declared `type`.',
|
|
168
|
+
description: 'Optional human-readable text shown in the dashboard form.',
|
|
169
|
+
type: 'Expected value type for overrides: "string", "number", "boolean", or "object".',
|
|
170
|
+
overridable:
|
|
171
|
+
'Whether CLI / MCP / dashboard callers may override the value at run time. Defaults to `true`. Set `false` for internal variables.',
|
|
172
|
+
required:
|
|
173
|
+
'Whether a value is mandatory at run time. Defaults to `false`. If `true` and no default is set, an override must be provided.',
|
|
174
|
+
},
|
|
175
|
+
example: {
|
|
176
|
+
port: { default: 8080, description: 'Service listening port.', type: 'number' },
|
|
177
|
+
internal_token: { default: 'fixed', overridable: false },
|
|
178
|
+
release: { type: 'string', required: true, description: 'Release tag to deploy.' },
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
step: {
|
|
182
|
+
name: 'Human-readable label shown in the summary.',
|
|
183
|
+
action: 'Action name — one of the actions below.',
|
|
184
|
+
pause_after: 'Optional seconds to wait after this step completes.',
|
|
185
|
+
when: 'Optional boolean expression. When present and falsy the step is skipped. See `when_expression` below for supported syntax.',
|
|
186
|
+
register: 'Optional identifier. When set, the step outcome is exposed under that name to later `when` expressions and string interpolation as `{ ok, result | error, duration_ms }`.',
|
|
187
|
+
'...': 'Additional fields are the action parameters (see `actions` below).',
|
|
188
|
+
},
|
|
189
|
+
when_expression: {
|
|
190
|
+
syntax:
|
|
191
|
+
'Minimal boolean mini-language: `==`, `!=`, `>`, `<`, `>=`, `<=`, `and`, `or`, `not`, parentheses; literals for strings (single or double quoted), numbers, `true`, `false`, `null`; dotted identifier paths resolve against the current scope.',
|
|
192
|
+
examples: [
|
|
193
|
+
'env == "prod"',
|
|
194
|
+
'check.ok and agent.version != "1.6.0"',
|
|
195
|
+
'not dry_run',
|
|
196
|
+
],
|
|
197
|
+
},
|
|
198
|
+
actions: ACTIONS.map((a) => ({
|
|
199
|
+
name: a.name,
|
|
200
|
+
description: a.description,
|
|
201
|
+
params: a.params,
|
|
202
|
+
})),
|
|
203
|
+
variable_interpolation: {
|
|
204
|
+
syntax: '{{ name }}',
|
|
205
|
+
scope: 'Anything from the top-level `vars` block, plus the implicit `device` (current device ID).',
|
|
206
|
+
applies_to: 'String parameter values. Non-string values are passed through verbatim.',
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Very small `{{ name }}` substitution over string values only. We
|
|
5
|
+
* intentionally don't recreate Jinja2 — conditionals and filters belong
|
|
6
|
+
* in a future iteration with a proper templating library. For now the
|
|
7
|
+
* only syntax supported is a bare variable reference, optionally wrapped
|
|
8
|
+
* in whitespace.
|
|
9
|
+
*
|
|
10
|
+
* `scope` is a flat map of variable names to values. Anything referenced
|
|
11
|
+
* but missing from the scope raises a hard error so typos don't silently
|
|
12
|
+
* produce empty strings.
|
|
13
|
+
*
|
|
14
|
+
* @param {any} value
|
|
15
|
+
* @param {any} scope
|
|
16
|
+
* @returns {any}
|
|
17
|
+
*/
|
|
18
|
+
export function interpolate(value, scope) {
|
|
19
|
+
if (typeof value === 'string') return interpolateString(value, scope);
|
|
20
|
+
if (Array.isArray(value)) return value.map((v) => interpolate(v, scope));
|
|
21
|
+
if (value && typeof value === 'object') {
|
|
22
|
+
const out = {};
|
|
23
|
+
for (const [k, v] of Object.entries(value)) out[k] = interpolate(v, scope);
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
26
|
+
return value;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const TEMPLATE_RE = /\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}/g;
|
|
30
|
+
|
|
31
|
+
function interpolateString(s, scope) {
|
|
32
|
+
return s.replace(TEMPLATE_RE, (_match, name) => {
|
|
33
|
+
if (!(name in scope)) {
|
|
34
|
+
throw new Error(`Undefined playbook variable: ${name}`);
|
|
35
|
+
}
|
|
36
|
+
const v = scope[name];
|
|
37
|
+
return v == null ? '' : String(v);
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Allowed variable types in the extended `vars` form. */
|
|
42
|
+
export const VAR_TYPES = ['string', 'number', 'boolean', 'object'];
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Check that `value` matches the declared playbook var `type`. Object
|
|
46
|
+
* means non-null object or array (any JSON-serialisable composite).
|
|
47
|
+
*
|
|
48
|
+
* @param {unknown} value
|
|
49
|
+
* @param {string} type
|
|
50
|
+
*/
|
|
51
|
+
export function checkVarType(value, type) {
|
|
52
|
+
switch (type) {
|
|
53
|
+
case 'string':
|
|
54
|
+
return typeof value === 'string';
|
|
55
|
+
case 'number':
|
|
56
|
+
return typeof value === 'number' && Number.isFinite(value);
|
|
57
|
+
case 'boolean':
|
|
58
|
+
return typeof value === 'boolean';
|
|
59
|
+
case 'object':
|
|
60
|
+
return value !== null && typeof value === 'object';
|
|
61
|
+
default:
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Infer a declared type from a plain-form default value. Mirrors the
|
|
68
|
+
* rules in {@link checkVarType}.
|
|
69
|
+
*
|
|
70
|
+
* @param {unknown} value
|
|
71
|
+
* @returns {'string' | 'number' | 'boolean' | 'object'}
|
|
72
|
+
*/
|
|
73
|
+
export function inferVarType(value) {
|
|
74
|
+
if (typeof value === 'string') return 'string';
|
|
75
|
+
if (typeof value === 'boolean') return 'boolean';
|
|
76
|
+
if (typeof value === 'number' && Number.isFinite(value)) return 'number';
|
|
77
|
+
return 'object';
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function describeValueType(value) {
|
|
81
|
+
if (value === null) return 'null';
|
|
82
|
+
if (Array.isArray(value)) return 'array';
|
|
83
|
+
return typeof value;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Public view of a playbook's declared variables. Consumed by CLI, MCP
|
|
88
|
+
* and the dashboard to render forms and validate overrides. `default`
|
|
89
|
+
* is omitted entirely when the variable was declared without one.
|
|
90
|
+
*
|
|
91
|
+
* @param {{ variables?: Array<{
|
|
92
|
+
* name: string,
|
|
93
|
+
* description: string | null,
|
|
94
|
+
* type: string,
|
|
95
|
+
* default?: unknown,
|
|
96
|
+
* hasDefault: boolean,
|
|
97
|
+
* overridable: boolean,
|
|
98
|
+
* required: boolean,
|
|
99
|
+
* }> }} pb
|
|
100
|
+
*/
|
|
101
|
+
export function listVariables(pb) {
|
|
102
|
+
const defs = pb?.variables || [];
|
|
103
|
+
return defs.map((d) => {
|
|
104
|
+
const out = {
|
|
105
|
+
name: d.name,
|
|
106
|
+
description: d.description,
|
|
107
|
+
type: d.type,
|
|
108
|
+
overridable: d.overridable,
|
|
109
|
+
required: d.required,
|
|
110
|
+
};
|
|
111
|
+
if (d.hasDefault) out.default = d.default;
|
|
112
|
+
return out;
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Coerce a CLI-provided raw string `--var key=value` into the declared
|
|
118
|
+
* type of the matching variable. Unknown variables are passed through
|
|
119
|
+
* unchanged so {@link resolveVarScope} can produce the canonical error.
|
|
120
|
+
*
|
|
121
|
+
* Accepted string shapes per type:
|
|
122
|
+
* - string → as-is
|
|
123
|
+
* - number → anything parsed by `Number` to a finite value
|
|
124
|
+
* - boolean → the literal strings `"true"` / `"false"`
|
|
125
|
+
* - object → a valid JSON document (object or array)
|
|
126
|
+
*
|
|
127
|
+
* @param {any} pb
|
|
128
|
+
* @param {string} name
|
|
129
|
+
* @param {string} raw Raw CLI value (right-hand side of `key=value`).
|
|
130
|
+
* @returns {unknown}
|
|
131
|
+
*/
|
|
132
|
+
export function coerceCliVarValue(pb, name, raw) {
|
|
133
|
+
const def = (pb?.variables || []).find((d) => d.name === name);
|
|
134
|
+
if (!def) return raw;
|
|
135
|
+
switch (def.type) {
|
|
136
|
+
case 'number': {
|
|
137
|
+
const n = Number(raw);
|
|
138
|
+
if (!Number.isFinite(n)) {
|
|
139
|
+
throw new Error(`Variable "${name}" expects a number, got "${raw}".`);
|
|
140
|
+
}
|
|
141
|
+
return n;
|
|
142
|
+
}
|
|
143
|
+
case 'boolean':
|
|
144
|
+
if (raw === 'true') return true;
|
|
145
|
+
if (raw === 'false') return false;
|
|
146
|
+
throw new Error(`Variable "${name}" expects true or false, got "${raw}".`);
|
|
147
|
+
case 'object':
|
|
148
|
+
try {
|
|
149
|
+
return JSON.parse(raw);
|
|
150
|
+
} catch (err) {
|
|
151
|
+
throw new Error(
|
|
152
|
+
`Variable "${name}" expects a JSON object/array, got "${raw}": ${err.message}`,
|
|
153
|
+
{ cause: err },
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
case 'string':
|
|
157
|
+
default:
|
|
158
|
+
return raw;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Build the final interpolation scope for a playbook run. Seeds with
|
|
164
|
+
* declared defaults, applies validated overrides, enforces `required`
|
|
165
|
+
* and `overridable`, then layers runtime-only `extras` (e.g. the
|
|
166
|
+
* implicit `device`) on top. Throws a descriptive Error on any failure.
|
|
167
|
+
*
|
|
168
|
+
* @param {any} pb
|
|
169
|
+
* @param {Record<string, unknown>} [overrides] External values (CLI / MCP / dashboard).
|
|
170
|
+
* @param {Record<string, unknown>} [extras] Runtime-only scope additions.
|
|
171
|
+
*/
|
|
172
|
+
export function resolveVarScope(pb, overrides = {}, extras = {}) {
|
|
173
|
+
const defs = pb?.variables || [];
|
|
174
|
+
const byName = new Map(defs.map((d) => [d.name, d]));
|
|
175
|
+
|
|
176
|
+
/** @type {Record<string, unknown>} */
|
|
177
|
+
const scope = {};
|
|
178
|
+
for (const d of defs) {
|
|
179
|
+
if (d.hasDefault) scope[d.name] = d.default;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
for (const [name, value] of Object.entries(overrides || {})) {
|
|
183
|
+
const def = byName.get(name);
|
|
184
|
+
if (!def) {
|
|
185
|
+
throw new Error(`Unknown playbook variable: ${name}`);
|
|
186
|
+
}
|
|
187
|
+
if (!def.overridable) {
|
|
188
|
+
throw new Error(`Playbook variable "${name}" is not overridable.`);
|
|
189
|
+
}
|
|
190
|
+
if (!checkVarType(value, def.type)) {
|
|
191
|
+
throw new Error(
|
|
192
|
+
`Playbook variable "${name}" expects type ${def.type}, got ${describeValueType(value)}.`,
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
scope[name] = value;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
for (const d of defs) {
|
|
199
|
+
if (d.required && !(d.name in scope)) {
|
|
200
|
+
throw new Error(`Required playbook variable "${d.name}" has no value.`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
for (const [name, value] of Object.entries(extras || {})) {
|
|
205
|
+
scope[name] = value;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return scope;
|
|
209
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* Catalog of named log line patterns. Each preset is a regex string with
|
|
4
|
+
* named groups out of `time`, `level`, `msg`. The dashboard compiles the
|
|
5
|
+
* resolved regex once per source and uses it to split each line into the
|
|
6
|
+
* three coloured chunks; the CLI and MCP just pass the preset name
|
|
7
|
+
* through and let the renderer resolve it.
|
|
8
|
+
*
|
|
9
|
+
* Patterns are intentionally tolerant — they should match the common
|
|
10
|
+
* shape of each format and silently fall through to "render raw" on
|
|
11
|
+
* lines that don't fit (e.g. multi-line stack traces, banner lines).
|
|
12
|
+
*
|
|
13
|
+
* Adding a preset: keep it small (3-5 fields max), avoid greedy or
|
|
14
|
+
* nested quantifiers (catastrophic backtracking), prefer character
|
|
15
|
+
* classes over `.` where possible.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @typedef {{
|
|
20
|
+
* name: string,
|
|
21
|
+
* pattern: string,
|
|
22
|
+
* description: string,
|
|
23
|
+
* }} LogPreset
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** @type {Record<string, LogPreset>} */
|
|
27
|
+
const PRESETS = {
|
|
28
|
+
journalctl: {
|
|
29
|
+
name: 'journalctl',
|
|
30
|
+
// `journalctl --output=short`: "Mon DD HH:MM:SS host unit[pid]: message".
|
|
31
|
+
// No level field in this output mode — unit stays as part of `msg`
|
|
32
|
+
// so it remains visible in the panel.
|
|
33
|
+
pattern: '^(?<time>\\w{3}\\s+\\d+\\s+\\d{2}:\\d{2}:\\d{2})\\s+\\S+\\s+(?<msg>.*)$',
|
|
34
|
+
description: 'systemd journal "short" output (no level captured)',
|
|
35
|
+
},
|
|
36
|
+
spdlog: {
|
|
37
|
+
name: 'spdlog',
|
|
38
|
+
// Thinger.io / agent flavour: "YYYY-MM-DD HH:MM:SS.mmm (...) [worker thread N] file.cpp:NN LEVEL| msg".
|
|
39
|
+
// `LEVEL` is uppercase. The two-space gap before the level and
|
|
40
|
+
// the trailing pipe are diagnostic enough to avoid matching
|
|
41
|
+
// unrelated lines that happen to start with a timestamp.
|
|
42
|
+
pattern:
|
|
43
|
+
'^(?<time>\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}\\.\\d{3})\\s+\\([^)]*\\)\\s+\\[[^\\]]*\\]\\s+\\S+\\s+(?<level>[A-Z]+)\\|\\s*(?<msg>.*)$',
|
|
44
|
+
description: 'spdlog as emitted by the thinger server / agent (LEVEL| msg)',
|
|
45
|
+
},
|
|
46
|
+
'spdlog-bracket': {
|
|
47
|
+
name: 'spdlog-bracket',
|
|
48
|
+
// Default spdlog pattern: "[YYYY-MM-DD HH:MM:SS.mmm] [logger] [level] msg".
|
|
49
|
+
// The middle bracketed groups are optional so a bare
|
|
50
|
+
// "[time] [level] msg" line still matches.
|
|
51
|
+
pattern:
|
|
52
|
+
'^\\[(?<time>\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}\\.\\d+)\\](?:\\s+\\[[^\\]]+\\])*\\s+\\[(?<level>\\w+)\\]\\s+(?<msg>.*)$',
|
|
53
|
+
description: 'spdlog default bracketed pattern: [time] [logger] [level] msg',
|
|
54
|
+
},
|
|
55
|
+
'nginx-error': {
|
|
56
|
+
name: 'nginx-error',
|
|
57
|
+
// Format: "YYYY/MM/DD HH:MM:SS [level] pid#tid: *cid message".
|
|
58
|
+
pattern:
|
|
59
|
+
'^(?<time>\\d{4}/\\d{2}/\\d{2} \\d{2}:\\d{2}:\\d{2})\\s+\\[(?<level>\\w+)\\]\\s+(?<msg>.*)$',
|
|
60
|
+
description: 'nginx error log: "YYYY/MM/DD HH:MM:SS [level] pid#tid: msg"',
|
|
61
|
+
},
|
|
62
|
+
'nginx-access': {
|
|
63
|
+
name: 'nginx-access',
|
|
64
|
+
// Common Log Format with a few extras. No level field, but the
|
|
65
|
+
// request line + status code carries enough signal that the
|
|
66
|
+
// panel can still split time and msg cleanly.
|
|
67
|
+
pattern:
|
|
68
|
+
'^(?<msg>\\S+\\s+\\S+\\s+\\S+\\s+\\[(?<time>[^\\]]+)\\]\\s+.*)$',
|
|
69
|
+
description: 'nginx access log (CLF): no level captured, time inside brackets',
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Levels captured by patterns are normalized to lowercase before any
|
|
75
|
+
* comparison. This list is the canonical set the renderer recognises;
|
|
76
|
+
* unknown levels still render with the `info` palette but are kept
|
|
77
|
+
* verbatim in the level slot for debugging.
|
|
78
|
+
*/
|
|
79
|
+
export const LOG_LEVELS = /** @type {const} */ ([
|
|
80
|
+
'trace',
|
|
81
|
+
'debug',
|
|
82
|
+
'info',
|
|
83
|
+
'notice',
|
|
84
|
+
'warn',
|
|
85
|
+
'warning',
|
|
86
|
+
'error',
|
|
87
|
+
'critical',
|
|
88
|
+
'crit',
|
|
89
|
+
'fatal',
|
|
90
|
+
]);
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Lookup table for level → severity. Higher number means more severe.
|
|
94
|
+
* Used by the panel filter to test "is this line at or above the
|
|
95
|
+
* configured threshold?".
|
|
96
|
+
*/
|
|
97
|
+
export const LEVEL_SEVERITY = /** @type {Record<string, number>} */ ({
|
|
98
|
+
trace: 0,
|
|
99
|
+
trc: 0,
|
|
100
|
+
debug: 0,
|
|
101
|
+
dbg: 0,
|
|
102
|
+
info: 1,
|
|
103
|
+
inf: 1,
|
|
104
|
+
notice: 2,
|
|
105
|
+
warn: 3,
|
|
106
|
+
warning: 3,
|
|
107
|
+
wrn: 3,
|
|
108
|
+
error: 4,
|
|
109
|
+
err: 4,
|
|
110
|
+
critical: 5,
|
|
111
|
+
crit: 5,
|
|
112
|
+
fatal: 5,
|
|
113
|
+
fat: 5,
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Return all presets as a sorted list. Used by `thinr product logs presets`
|
|
118
|
+
* and by the MCP `thinr_product_logs_presets` tool.
|
|
119
|
+
*
|
|
120
|
+
* @returns {LogPreset[]}
|
|
121
|
+
*/
|
|
122
|
+
export function listPresets() {
|
|
123
|
+
return Object.values(PRESETS)
|
|
124
|
+
.map((p) => ({ ...p }))
|
|
125
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Look up a single preset by name. Returns `null` when the name is not
|
|
130
|
+
* registered so callers can decide whether that's an error or just a
|
|
131
|
+
* "use the literal pattern" path.
|
|
132
|
+
*
|
|
133
|
+
* @param {string} name
|
|
134
|
+
* @returns {LogPreset | null}
|
|
135
|
+
*/
|
|
136
|
+
export function getPreset(name) {
|
|
137
|
+
if (typeof name !== 'string') return null;
|
|
138
|
+
const p = PRESETS[name];
|
|
139
|
+
return p ? { ...p } : null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Set of valid preset names — kept for cheap membership checks in the
|
|
144
|
+
* validator. Returning a fresh array each call would defeat the
|
|
145
|
+
* "validate without allocating" property the schema relies on.
|
|
146
|
+
*/
|
|
147
|
+
export const PRESET_NAMES = Object.freeze(Object.keys(PRESETS).sort());
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Parse a single log line against a (compiled or string) pattern.
|
|
151
|
+
* Returns `{ time, level, msg, level_norm }` when the line matches and
|
|
152
|
+
* the pattern uses any of the known named groups, or `null` when the
|
|
153
|
+
* line doesn't match. `level_norm` is the lowercase form of the
|
|
154
|
+
* captured level — convenient for color and severity lookups.
|
|
155
|
+
*
|
|
156
|
+
* Passing a `null` regex is supported and returns `null` so callers
|
|
157
|
+
* can use the helper unconditionally.
|
|
158
|
+
*
|
|
159
|
+
* @param {string} line
|
|
160
|
+
* @param {RegExp | null} regex
|
|
161
|
+
* @returns {{ time?: string, level?: string, msg?: string, level_norm?: string } | null}
|
|
162
|
+
*/
|
|
163
|
+
export function parseLogLine(line, regex) {
|
|
164
|
+
if (!regex) return null;
|
|
165
|
+
const m = regex.exec(line);
|
|
166
|
+
if (!m || !m.groups) return null;
|
|
167
|
+
const { time, level, msg } = m.groups;
|
|
168
|
+
const out = {};
|
|
169
|
+
if (typeof time === 'string') out.time = time;
|
|
170
|
+
if (typeof level === 'string') {
|
|
171
|
+
out.level = level;
|
|
172
|
+
out.level_norm = level.toLowerCase();
|
|
173
|
+
}
|
|
174
|
+
if (typeof msg === 'string') out.msg = msg;
|
|
175
|
+
return out;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Resolve a level threshold ("all" | "info" | "warn" | "error") against
|
|
180
|
+
* a parsed level. Lines without a captured level pass through (we do
|
|
181
|
+
* not silently hide what we cannot classify). Unknown levels are
|
|
182
|
+
* treated as `info` so debug noise doesn't leak through a `warn+`
|
|
183
|
+
* filter.
|
|
184
|
+
*
|
|
185
|
+
* @param {string | null | undefined} levelNorm
|
|
186
|
+
* @param {'all' | 'info' | 'warn' | 'error'} threshold
|
|
187
|
+
* @returns {boolean}
|
|
188
|
+
*/
|
|
189
|
+
export function levelPassesThreshold(levelNorm, threshold) {
|
|
190
|
+
if (threshold === 'all') return true;
|
|
191
|
+
if (!levelNorm) return true;
|
|
192
|
+
const sev = LEVEL_SEVERITY[levelNorm];
|
|
193
|
+
const min = LEVEL_SEVERITY[threshold];
|
|
194
|
+
if (sev == null) return min <= LEVEL_SEVERITY.info;
|
|
195
|
+
return sev >= min;
|
|
196
|
+
}
|