@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
package/lib/profile.js
ADDED
|
@@ -0,0 +1,665 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import api from './api.js';
|
|
3
|
+
import { apiError, inputError } from './errors.js';
|
|
4
|
+
import { requireConfig } from './config.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Helpers for product *profile* configuration.
|
|
8
|
+
*
|
|
9
|
+
* Concept primer
|
|
10
|
+
* ──────────────
|
|
11
|
+
* A product `profile` is the server-side wiring of a product. It bundles
|
|
12
|
+
* the four declarative subsystems the platform consumes when a device of
|
|
13
|
+
* that product emits or receives data:
|
|
14
|
+
*
|
|
15
|
+
* - `profile.api.<name>` — REST API resources callable on every
|
|
16
|
+
* device of the product (or invoked by
|
|
17
|
+
* a script). Pick a `target` and the
|
|
18
|
+
* platform proxies the request there.
|
|
19
|
+
* - `profile.buckets.<name>` — auto-managed time-series buckets fed
|
|
20
|
+
* from a declared source (`resource`,
|
|
21
|
+
* `resource_stream`, `product_stream`,
|
|
22
|
+
* `topic`, `event`).
|
|
23
|
+
* - `profile.properties.<name>` — auto-managed device properties fed
|
|
24
|
+
* from the same family of sources, with
|
|
25
|
+
* optional JSON-merge semantics via
|
|
26
|
+
* `data.patch`.
|
|
27
|
+
* - `profile.code` — script-storage link (handled
|
|
28
|
+
* elsewhere; not exposed here).
|
|
29
|
+
*
|
|
30
|
+
* REST endpoints these helpers wrap:
|
|
31
|
+
* - GET /v1/users/{user}/products/{p}/profile/api
|
|
32
|
+
* - PUT /v1/users/{user}/products/{p}/profile/api/{name}
|
|
33
|
+
* - GET /v1/users/{user}/products/{p}/profile/api/{name}
|
|
34
|
+
* - DELETE /v1/users/{user}/products/{p}/profile/api/{name}
|
|
35
|
+
* - …same shape for /buckets and /properties.
|
|
36
|
+
*
|
|
37
|
+
* IMPORTANT: do NOT confuse `profile/properties/<name>` with
|
|
38
|
+
* `/products/{p}/properties/{name}`. The former is a *handler* that fans
|
|
39
|
+
* incoming source data into device properties; the latter (handled by
|
|
40
|
+
* `getProductProperty`/`setProductProperty` in `product.js`) is a JSON
|
|
41
|
+
* value attached to the product itself. Same word, completely different
|
|
42
|
+
* concept — the helpers in this file consistently use the
|
|
43
|
+
* `getProductProfileProperty` naming to keep callers honest.
|
|
44
|
+
*
|
|
45
|
+
* Canonical end-to-end use case
|
|
46
|
+
* ─────────────────────────────
|
|
47
|
+
* Fan-out a `backup_complete` stream resource → `backups` bucket
|
|
48
|
+
* (history) and `backups` device property (last-known):
|
|
49
|
+
*
|
|
50
|
+
* 1. setProductApiResource(p, 'backup_complete', user, {
|
|
51
|
+
* enabled: true,
|
|
52
|
+
* request: { data: { target: 'resource_stream', resource_stream: 'backup_complete', payload_type: 'source_payload', payload: '' } },
|
|
53
|
+
* response: { data: { payload_type: 'none' } },
|
|
54
|
+
* })
|
|
55
|
+
* 2. setProductBucket(p, 'backups', user, {
|
|
56
|
+
* enabled: true, name: 'Backups', backend: 'mongodb',
|
|
57
|
+
* retention: { period: 1, unit: 'years' },
|
|
58
|
+
* data: { source: 'resource_stream', resource_stream: 'backup_complete', payload_type: 'source_payload', payload: '{{payload}}' },
|
|
59
|
+
* })
|
|
60
|
+
* 3. setProductProfileProperty(p, 'backups', user, {
|
|
61
|
+
* enabled: true, name: 'Backups',
|
|
62
|
+
* data: { source: 'resource_stream', resource_stream: 'backup_complete', payload_type: 'source_payload', payload: '{{payload}}' },
|
|
63
|
+
* })
|
|
64
|
+
*
|
|
65
|
+
* After this, every device of the product can call its own `backup_complete`
|
|
66
|
+
* stream resource. The platform fans the payload out to the bucket (history)
|
|
67
|
+
* and to the device property `backups` (last-known) automatically — no
|
|
68
|
+
* device code change.
|
|
69
|
+
*/
|
|
70
|
+
|
|
71
|
+
function resolveUser(user) {
|
|
72
|
+
return user || requireConfig().username;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function v1(user) {
|
|
76
|
+
return `/v1/users/${resolveUser(user)}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ─── Listener config validators ──────────────────────────────────────
|
|
80
|
+
//
|
|
81
|
+
// Each `data.source` shape (for buckets/properties) and each request
|
|
82
|
+
// `target` (for API resources) demands a matching key in the data block.
|
|
83
|
+
// We enforce these client-side so the caller gets a clear error long
|
|
84
|
+
// before the server returns a generic 400.
|
|
85
|
+
|
|
86
|
+
/** Sources accepted by `profile.buckets[*].data.source` and `profile.properties[*].data.source`. */
|
|
87
|
+
export const PROFILE_DATA_SOURCES = ['resource', 'resource_stream', 'product_stream', 'topic', 'event'];
|
|
88
|
+
|
|
89
|
+
/** Targets accepted by `profile.api[*].request.data.target`. */
|
|
90
|
+
export const PROFILE_API_TARGETS = [
|
|
91
|
+
'resource',
|
|
92
|
+
'resource_stream',
|
|
93
|
+
'product_stream',
|
|
94
|
+
'property',
|
|
95
|
+
'function',
|
|
96
|
+
'endpoint',
|
|
97
|
+
'topic',
|
|
98
|
+
];
|
|
99
|
+
|
|
100
|
+
/** Payload types recognised by the platform on both request and response sides. */
|
|
101
|
+
export const PROFILE_PAYLOAD_TYPES = [
|
|
102
|
+
'template_payload',
|
|
103
|
+
'source_payload',
|
|
104
|
+
'source_event',
|
|
105
|
+
'none',
|
|
106
|
+
];
|
|
107
|
+
|
|
108
|
+
/** Required key in `source_args` for each `data.source` value. */
|
|
109
|
+
const SOURCE_KEY = {
|
|
110
|
+
resource: 'resource',
|
|
111
|
+
resource_stream: 'resource_stream',
|
|
112
|
+
product_stream: 'product_stream',
|
|
113
|
+
topic: 'topic',
|
|
114
|
+
event: 'event',
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
/** Required key in `target_args` for each `request.data.target` value. */
|
|
118
|
+
const TARGET_KEY = {
|
|
119
|
+
resource: 'resource',
|
|
120
|
+
resource_stream: 'resource_stream',
|
|
121
|
+
property: 'property',
|
|
122
|
+
function: 'function',
|
|
123
|
+
endpoint: 'endpoint',
|
|
124
|
+
topic: 'topic',
|
|
125
|
+
// product_stream needs BOTH `product` and `product_stream` — handled inline.
|
|
126
|
+
product_stream: 'product_stream',
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
function assertValidSource(source) {
|
|
130
|
+
if (!PROFILE_DATA_SOURCES.includes(source)) {
|
|
131
|
+
throw inputError(
|
|
132
|
+
`Invalid source "${source}". Allowed: ${PROFILE_DATA_SOURCES.join(', ')}.`,
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function assertValidTarget(target) {
|
|
138
|
+
if (!PROFILE_API_TARGETS.includes(target)) {
|
|
139
|
+
throw inputError(
|
|
140
|
+
`Invalid target "${target}". Allowed: ${PROFILE_API_TARGETS.join(', ')}.`,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function assertValidPayloadType(value, label = 'payload_type') {
|
|
146
|
+
if (value === undefined || value === null) return;
|
|
147
|
+
if (!PROFILE_PAYLOAD_TYPES.includes(value)) {
|
|
148
|
+
throw inputError(
|
|
149
|
+
`Invalid ${label} "${value}". Allowed: ${PROFILE_PAYLOAD_TYPES.join(', ')}.`,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Build the `data` block for a bucket or property listener from the
|
|
156
|
+
* caller's source + source_args + payload bits. Returns a plain object
|
|
157
|
+
* the server will accept verbatim.
|
|
158
|
+
*
|
|
159
|
+
* @param {{
|
|
160
|
+
* source: string,
|
|
161
|
+
* source_args?: Record<string, unknown>,
|
|
162
|
+
* payload_type?: string,
|
|
163
|
+
* payload?: string,
|
|
164
|
+
* payload_function?: string,
|
|
165
|
+
* patch?: boolean,
|
|
166
|
+
* }} opts
|
|
167
|
+
*/
|
|
168
|
+
export function buildListenerDataBlock(opts) {
|
|
169
|
+
const source = opts.source;
|
|
170
|
+
assertValidSource(source);
|
|
171
|
+
const args = opts.source_args || {};
|
|
172
|
+
|
|
173
|
+
const data = { source };
|
|
174
|
+
|
|
175
|
+
const requiredKey = SOURCE_KEY[source];
|
|
176
|
+
if (requiredKey) {
|
|
177
|
+
const value = args[requiredKey];
|
|
178
|
+
if (typeof value !== 'string' || !value) {
|
|
179
|
+
throw inputError(
|
|
180
|
+
`source "${source}" requires source_args.${requiredKey} to be a non-empty string`,
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
data[requiredKey] = value;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (source === 'resource') {
|
|
187
|
+
// `resource` source supports an optional polling cadence. When
|
|
188
|
+
// omitted, the server defaults to event mode (interval = 0).
|
|
189
|
+
const update = args.update;
|
|
190
|
+
if (update !== undefined) {
|
|
191
|
+
if (update !== 'interval' && update !== 'event') {
|
|
192
|
+
throw inputError(
|
|
193
|
+
`source_args.update must be "interval" or "event" (got "${update}")`,
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
data.update = update;
|
|
197
|
+
}
|
|
198
|
+
if (args.interval !== undefined) {
|
|
199
|
+
const n = Number(args.interval);
|
|
200
|
+
if (!Number.isFinite(n) || n < 0) {
|
|
201
|
+
throw inputError('source_args.interval must be a non-negative number');
|
|
202
|
+
}
|
|
203
|
+
data.interval = n;
|
|
204
|
+
}
|
|
205
|
+
if (args.magnitude !== undefined) {
|
|
206
|
+
data.magnitude = String(args.magnitude);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (source === 'product_stream') {
|
|
211
|
+
// The bucket/property listener listens on the *current* product's
|
|
212
|
+
// stream by default. A foreign-product listener is unusual — the
|
|
213
|
+
// server checks `data.product` only when present. Pass it through.
|
|
214
|
+
if (args.product !== undefined) {
|
|
215
|
+
if (typeof args.product !== 'string' || !args.product) {
|
|
216
|
+
throw inputError('source_args.product must be a non-empty string');
|
|
217
|
+
}
|
|
218
|
+
data.product = args.product;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (source === 'event' && args.filter !== undefined) {
|
|
223
|
+
if (typeof args.filter !== 'object' || Array.isArray(args.filter)) {
|
|
224
|
+
throw inputError('source_args.filter must be an object');
|
|
225
|
+
}
|
|
226
|
+
data.filter = args.filter;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Payload shape — common to every listener kind.
|
|
230
|
+
assertValidPayloadType(opts.payload_type);
|
|
231
|
+
if (opts.payload_type !== undefined) data.payload_type = opts.payload_type;
|
|
232
|
+
if (opts.payload !== undefined) {
|
|
233
|
+
if (typeof opts.payload !== 'string') {
|
|
234
|
+
throw inputError('payload must be a string (template or "{{payload}}")');
|
|
235
|
+
}
|
|
236
|
+
data.payload = opts.payload;
|
|
237
|
+
}
|
|
238
|
+
if (opts.payload_function !== undefined) {
|
|
239
|
+
if (typeof opts.payload_function !== 'string') {
|
|
240
|
+
throw inputError('payload_function must be a string');
|
|
241
|
+
}
|
|
242
|
+
data.payload_function = opts.payload_function;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Property handler flag — only meaningful for property listeners but
|
|
246
|
+
// accepted on the base helper for symmetry; the bucket handler simply
|
|
247
|
+
// ignores it.
|
|
248
|
+
if (opts.patch !== undefined) {
|
|
249
|
+
data.patch = !!opts.patch;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return data;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Build the `request.data` block for a profile API resource.
|
|
257
|
+
*
|
|
258
|
+
* @param {{
|
|
259
|
+
* target: string,
|
|
260
|
+
* target_args?: Record<string, unknown>,
|
|
261
|
+
* resource_name?: string,
|
|
262
|
+
* payload_type?: string,
|
|
263
|
+
* payload?: string,
|
|
264
|
+
* payload_function?: string,
|
|
265
|
+
* }} opts
|
|
266
|
+
*/
|
|
267
|
+
export function buildApiRequestData(opts) {
|
|
268
|
+
const target = opts.target;
|
|
269
|
+
assertValidTarget(target);
|
|
270
|
+
const args = opts.target_args || {};
|
|
271
|
+
const data = { target };
|
|
272
|
+
|
|
273
|
+
if (target === 'product_stream') {
|
|
274
|
+
if (typeof args.product !== 'string' || !args.product) {
|
|
275
|
+
throw inputError(
|
|
276
|
+
'target "product_stream" requires target_args.product (the destination product id)',
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
if (typeof args.product_stream !== 'string' || !args.product_stream) {
|
|
280
|
+
throw inputError(
|
|
281
|
+
'target "product_stream" requires target_args.product_stream (the destination stream name)',
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
data.product = args.product;
|
|
285
|
+
data.product_stream = args.product_stream;
|
|
286
|
+
} else if (target === 'resource_stream') {
|
|
287
|
+
// resource_stream defaults to the API resource name itself, so the
|
|
288
|
+
// caller can omit target_args entirely for a self-named stream.
|
|
289
|
+
const stream = args.resource_stream || opts.resource_name;
|
|
290
|
+
if (!stream) {
|
|
291
|
+
throw inputError(
|
|
292
|
+
'target "resource_stream" needs target_args.resource_stream (defaults to the API resource name when omitted)',
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
data.resource_stream = stream;
|
|
296
|
+
} else {
|
|
297
|
+
const requiredKey = TARGET_KEY[target];
|
|
298
|
+
if (requiredKey) {
|
|
299
|
+
const value = args[requiredKey];
|
|
300
|
+
if (typeof value !== 'string' || !value) {
|
|
301
|
+
throw inputError(
|
|
302
|
+
`target "${target}" requires target_args.${requiredKey} to be a non-empty string`,
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
data[requiredKey] = value;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
assertValidPayloadType(opts.payload_type);
|
|
310
|
+
// template_payload is the platform default. The frontend encodes it
|
|
311
|
+
// as an empty string when round-tripping, but we accept the explicit
|
|
312
|
+
// value here too and pass it through unchanged.
|
|
313
|
+
if (opts.payload_type !== undefined) data.payload_type = opts.payload_type;
|
|
314
|
+
if (opts.payload !== undefined) {
|
|
315
|
+
if (typeof opts.payload !== 'string') {
|
|
316
|
+
throw inputError('payload must be a string');
|
|
317
|
+
}
|
|
318
|
+
data.payload = opts.payload;
|
|
319
|
+
}
|
|
320
|
+
if (opts.payload_function !== undefined) {
|
|
321
|
+
if (typeof opts.payload_function !== 'string') {
|
|
322
|
+
throw inputError('payload_function must be a string');
|
|
323
|
+
}
|
|
324
|
+
data.payload_function = opts.payload_function;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return data;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Build the `response.data` block for a profile API resource. `response`
|
|
332
|
+
* is optional on the wire; this helper produces a sensible default of
|
|
333
|
+
* `{ payload_type: 'none' }` when the caller wants a fire-and-forget
|
|
334
|
+
* resource, which matches the stream-style shape used by the
|
|
335
|
+
* `backup_complete` example.
|
|
336
|
+
*
|
|
337
|
+
* @param {{
|
|
338
|
+
* payload_type?: string,
|
|
339
|
+
* source?: string,
|
|
340
|
+
* payload?: string,
|
|
341
|
+
* payload_function?: string,
|
|
342
|
+
* } | undefined | null} response
|
|
343
|
+
*/
|
|
344
|
+
export function buildApiResponseData(response) {
|
|
345
|
+
if (!response) return { payload_type: 'none' };
|
|
346
|
+
const data = {};
|
|
347
|
+
assertValidPayloadType(response.payload_type, 'response.payload_type');
|
|
348
|
+
if (response.payload_type !== undefined) data.payload_type = response.payload_type;
|
|
349
|
+
if (response.source !== undefined) {
|
|
350
|
+
if (typeof response.source !== 'string') {
|
|
351
|
+
throw inputError('response.source must be a string');
|
|
352
|
+
}
|
|
353
|
+
data.source = response.source;
|
|
354
|
+
}
|
|
355
|
+
if (response.payload !== undefined) {
|
|
356
|
+
if (typeof response.payload !== 'string') {
|
|
357
|
+
throw inputError('response.payload must be a string');
|
|
358
|
+
}
|
|
359
|
+
data.payload = response.payload;
|
|
360
|
+
}
|
|
361
|
+
if (response.payload_function !== undefined) {
|
|
362
|
+
if (typeof response.payload_function !== 'string') {
|
|
363
|
+
throw inputError('response.payload_function must be a string');
|
|
364
|
+
}
|
|
365
|
+
data.payload_function = response.payload_function;
|
|
366
|
+
}
|
|
367
|
+
if (Object.keys(data).length === 0) {
|
|
368
|
+
return { payload_type: 'none' };
|
|
369
|
+
}
|
|
370
|
+
return data;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// ─── Low-level: profile/api/<name> ───────────────────────────────────
|
|
374
|
+
|
|
375
|
+
/** Returns the `{ resourceName: definition }` map; `{}` when none. */
|
|
376
|
+
export async function getProductApiResources(productId, user) {
|
|
377
|
+
try {
|
|
378
|
+
const res = await api.get(`${v1(user)}/products/${productId}/profile/api`);
|
|
379
|
+
return res.data || {};
|
|
380
|
+
} catch (e) {
|
|
381
|
+
if (e.response?.status === 404) return {};
|
|
382
|
+
throw apiError(e);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
export async function getProductApiResource(productId, name, user) {
|
|
387
|
+
try {
|
|
388
|
+
const res = await api.get(
|
|
389
|
+
`${v1(user)}/products/${productId}/profile/api/${name}`,
|
|
390
|
+
);
|
|
391
|
+
return res.data || {};
|
|
392
|
+
} catch (e) {
|
|
393
|
+
throw apiError(e, {
|
|
394
|
+
notFound: `API resource not found: ${name} on product ${productId}`,
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Upsert a profile API resource by passing the full `{request, response, ...}`
|
|
401
|
+
* document straight through. Use `setProfileApiResource` (below) for the
|
|
402
|
+
* higher-level builder that takes target/target_args/payload* fields and
|
|
403
|
+
* assembles the document for you.
|
|
404
|
+
*/
|
|
405
|
+
export async function setProductApiResource(productId, name, user, payload) {
|
|
406
|
+
try {
|
|
407
|
+
await api.put(
|
|
408
|
+
`${v1(user)}/products/${productId}/profile/api/${name}`,
|
|
409
|
+
payload,
|
|
410
|
+
);
|
|
411
|
+
} catch (e) {
|
|
412
|
+
throw apiError(e);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
export async function deleteProductApiResource(productId, name, user) {
|
|
417
|
+
try {
|
|
418
|
+
await api.delete(
|
|
419
|
+
`${v1(user)}/products/${productId}/profile/api/${name}`,
|
|
420
|
+
);
|
|
421
|
+
return true;
|
|
422
|
+
} catch (e) {
|
|
423
|
+
if (e.response?.status === 404) return false;
|
|
424
|
+
throw apiError(e);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Upsert a profile API resource using the structured target/payload shape.
|
|
430
|
+
* Lets callers configure any of the platform's dispatch targets without
|
|
431
|
+
* hand-rolling the JSON. Required fields per target are validated client-
|
|
432
|
+
* side: missing `target_args.resource` for `target: "resource"` raises
|
|
433
|
+
* `inputError` instead of waiting for a 400 from the server.
|
|
434
|
+
*
|
|
435
|
+
* @param {string} productId
|
|
436
|
+
* @param {string} name API resource name (becomes `profile.api.<name>`).
|
|
437
|
+
* @param {string|null|undefined} user
|
|
438
|
+
* @param {{
|
|
439
|
+
* target: string,
|
|
440
|
+
* target_args?: Record<string, unknown>,
|
|
441
|
+
* payload_type?: string,
|
|
442
|
+
* payload?: string,
|
|
443
|
+
* payload_function?: string,
|
|
444
|
+
* response?: {
|
|
445
|
+
* payload_type?: string,
|
|
446
|
+
* source?: string,
|
|
447
|
+
* payload?: string,
|
|
448
|
+
* payload_function?: string,
|
|
449
|
+
* } | null,
|
|
450
|
+
* enabled?: boolean,
|
|
451
|
+
* }} opts
|
|
452
|
+
*/
|
|
453
|
+
export async function setProfileApiResource(productId, name, user, opts) {
|
|
454
|
+
if (!productId) throw inputError('productId is required');
|
|
455
|
+
if (!name) throw inputError('name is required');
|
|
456
|
+
const requestData = buildApiRequestData({ ...opts, resource_name: name });
|
|
457
|
+
const responseData = buildApiResponseData(opts.response);
|
|
458
|
+
const payload = {
|
|
459
|
+
enabled: opts.enabled !== false,
|
|
460
|
+
request: { data: requestData },
|
|
461
|
+
response: { data: responseData },
|
|
462
|
+
};
|
|
463
|
+
await setProductApiResource(productId, name, user, payload);
|
|
464
|
+
return payload;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// ─── Low-level: profile/buckets/<name> ───────────────────────────────
|
|
468
|
+
|
|
469
|
+
export async function getProductBuckets(productId, user) {
|
|
470
|
+
try {
|
|
471
|
+
const res = await api.get(`${v1(user)}/products/${productId}/profile/buckets`);
|
|
472
|
+
return res.data || {};
|
|
473
|
+
} catch (e) {
|
|
474
|
+
if (e.response?.status === 404) return {};
|
|
475
|
+
throw apiError(e);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
export async function getProductBucket(productId, name, user) {
|
|
480
|
+
try {
|
|
481
|
+
const res = await api.get(
|
|
482
|
+
`${v1(user)}/products/${productId}/profile/buckets/${name}`,
|
|
483
|
+
);
|
|
484
|
+
return res.data || {};
|
|
485
|
+
} catch (e) {
|
|
486
|
+
throw apiError(e, {
|
|
487
|
+
notFound: `Profile bucket not found: ${name} on product ${productId}`,
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
export async function setProductBucket(productId, name, user, payload) {
|
|
493
|
+
try {
|
|
494
|
+
await api.put(
|
|
495
|
+
`${v1(user)}/products/${productId}/profile/buckets/${name}`,
|
|
496
|
+
payload,
|
|
497
|
+
);
|
|
498
|
+
} catch (e) {
|
|
499
|
+
throw apiError(e);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
export async function deleteProductBucket(productId, name, user) {
|
|
504
|
+
try {
|
|
505
|
+
await api.delete(
|
|
506
|
+
`${v1(user)}/products/${productId}/profile/buckets/${name}`,
|
|
507
|
+
);
|
|
508
|
+
return true;
|
|
509
|
+
} catch (e) {
|
|
510
|
+
if (e.response?.status === 404) return false;
|
|
511
|
+
throw apiError(e);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const RETENTION_UNITS = ['minutes', 'hours', 'days', 'months', 'years'];
|
|
516
|
+
|
|
517
|
+
function buildRetention(retention) {
|
|
518
|
+
if (retention === undefined || retention === null) return undefined;
|
|
519
|
+
if (typeof retention !== 'object' || Array.isArray(retention)) {
|
|
520
|
+
throw inputError('retention must be an object { period, unit }');
|
|
521
|
+
}
|
|
522
|
+
const period = Number(retention.period);
|
|
523
|
+
if (!Number.isFinite(period) || period <= 0) {
|
|
524
|
+
throw inputError('retention.period must be a positive number');
|
|
525
|
+
}
|
|
526
|
+
const unit = retention.unit;
|
|
527
|
+
if (!RETENTION_UNITS.includes(unit)) {
|
|
528
|
+
throw inputError(
|
|
529
|
+
`retention.unit must be one of: ${RETENTION_UNITS.join(', ')}`,
|
|
530
|
+
);
|
|
531
|
+
}
|
|
532
|
+
return { period, unit };
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* Upsert a profile bucket using the structured source/payload shape. The
|
|
537
|
+
* server creates / migrates the underlying time-series bucket lazily on
|
|
538
|
+
* the first event delivery (see `product_bucket_handler::initialize_bucket`).
|
|
539
|
+
*
|
|
540
|
+
* @param {string} productId
|
|
541
|
+
* @param {string} name
|
|
542
|
+
* @param {string|null|undefined} user
|
|
543
|
+
* @param {{
|
|
544
|
+
* source: string,
|
|
545
|
+
* source_args?: Record<string, unknown>,
|
|
546
|
+
* payload_type?: string,
|
|
547
|
+
* payload?: string,
|
|
548
|
+
* payload_function?: string,
|
|
549
|
+
* name_label?: string,
|
|
550
|
+
* description?: string,
|
|
551
|
+
* enabled?: boolean,
|
|
552
|
+
* backend?: string,
|
|
553
|
+
* retention?: { period: number, unit: string },
|
|
554
|
+
* tags?: string[],
|
|
555
|
+
* }} opts
|
|
556
|
+
*/
|
|
557
|
+
export async function setProfileBucket(productId, name, user, opts) {
|
|
558
|
+
if (!productId) throw inputError('productId is required');
|
|
559
|
+
if (!name) throw inputError('name is required');
|
|
560
|
+
|
|
561
|
+
const data = buildListenerDataBlock(opts);
|
|
562
|
+
const payload = {
|
|
563
|
+
enabled: opts.enabled !== false,
|
|
564
|
+
name: opts.name_label || name,
|
|
565
|
+
description: opts.description || `Bucket for ${productId} product`,
|
|
566
|
+
backend: opts.backend || 'mongodb',
|
|
567
|
+
data,
|
|
568
|
+
tags: Array.isArray(opts.tags) ? opts.tags : [],
|
|
569
|
+
};
|
|
570
|
+
const retention = buildRetention(opts.retention);
|
|
571
|
+
if (retention) payload.retention = retention;
|
|
572
|
+
|
|
573
|
+
await setProductBucket(productId, name, user, payload);
|
|
574
|
+
return payload;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// ─── Low-level: profile/properties/<name> ────────────────────────────
|
|
578
|
+
//
|
|
579
|
+
// NOTE: `getProductProfileProperty` ≠ `getProductProperty`. The former
|
|
580
|
+
// lives under `profile/properties/<name>` and configures a *handler* that
|
|
581
|
+
// fans listener output into device properties. The latter (in product.js)
|
|
582
|
+
// reads a JSON value attached to the product itself. See file header.
|
|
583
|
+
|
|
584
|
+
export async function getProductProfileProperties(productId, user) {
|
|
585
|
+
try {
|
|
586
|
+
const res = await api.get(`${v1(user)}/products/${productId}/profile/properties`);
|
|
587
|
+
return res.data || {};
|
|
588
|
+
} catch (e) {
|
|
589
|
+
if (e.response?.status === 404) return {};
|
|
590
|
+
throw apiError(e);
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
export async function getProductProfileProperty(productId, name, user) {
|
|
595
|
+
try {
|
|
596
|
+
const res = await api.get(
|
|
597
|
+
`${v1(user)}/products/${productId}/profile/properties/${name}`,
|
|
598
|
+
);
|
|
599
|
+
return res.data || {};
|
|
600
|
+
} catch (e) {
|
|
601
|
+
throw apiError(e, {
|
|
602
|
+
notFound: `Profile property not found: ${name} on product ${productId}`,
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
export async function setProductProfileProperty(productId, name, user, payload) {
|
|
608
|
+
try {
|
|
609
|
+
await api.put(
|
|
610
|
+
`${v1(user)}/products/${productId}/profile/properties/${name}`,
|
|
611
|
+
payload,
|
|
612
|
+
);
|
|
613
|
+
} catch (e) {
|
|
614
|
+
throw apiError(e);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
export async function deleteProductProfileProperty(productId, name, user) {
|
|
619
|
+
try {
|
|
620
|
+
await api.delete(
|
|
621
|
+
`${v1(user)}/products/${productId}/profile/properties/${name}`,
|
|
622
|
+
);
|
|
623
|
+
return true;
|
|
624
|
+
} catch (e) {
|
|
625
|
+
if (e.response?.status === 404) return false;
|
|
626
|
+
throw apiError(e);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* Upsert a profile property handler using the structured source/payload
|
|
632
|
+
* shape. When `patch: true` is passed, the platform applies the resulting
|
|
633
|
+
* value as a JSON merge-patch on top of the existing device property
|
|
634
|
+
* instead of replacing it wholesale (see
|
|
635
|
+
* `product_property_handler::set_config` for the `data.patch` flag).
|
|
636
|
+
*
|
|
637
|
+
* @param {string} productId
|
|
638
|
+
* @param {string} name
|
|
639
|
+
* @param {string|null|undefined} user
|
|
640
|
+
* @param {{
|
|
641
|
+
* source: string,
|
|
642
|
+
* source_args?: Record<string, unknown>,
|
|
643
|
+
* payload_type?: string,
|
|
644
|
+
* payload?: string,
|
|
645
|
+
* payload_function?: string,
|
|
646
|
+
* name_label?: string,
|
|
647
|
+
* description?: string,
|
|
648
|
+
* enabled?: boolean,
|
|
649
|
+
* patch?: boolean,
|
|
650
|
+
* }} opts
|
|
651
|
+
*/
|
|
652
|
+
export async function setProfileProperty(productId, name, user, opts) {
|
|
653
|
+
if (!productId) throw inputError('productId is required');
|
|
654
|
+
if (!name) throw inputError('name is required');
|
|
655
|
+
|
|
656
|
+
const data = buildListenerDataBlock(opts);
|
|
657
|
+
const payload = {
|
|
658
|
+
enabled: opts.enabled !== false,
|
|
659
|
+
name: opts.name_label || name,
|
|
660
|
+
description: opts.description || `Property handler for ${productId} product`,
|
|
661
|
+
data,
|
|
662
|
+
};
|
|
663
|
+
await setProductProfileProperty(productId, name, user, payload);
|
|
664
|
+
return payload;
|
|
665
|
+
}
|