node-red-contrib-i3x 0.0.2 → 0.0.4

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.
@@ -7,7 +7,8 @@
7
7
  "Bash(npm test:*)",
8
8
  "Bash(node-red:*)",
9
9
  "Bash(npx node-red:*)",
10
- "Bash(docker compose:*)"
10
+ "Bash(docker compose:*)",
11
+ "Bash(ls /home/la/private/node-red-contrib-i3x/docker-compose* /home/la/private/node-red-contrib-i3x/Dockerfile* 2>/dev/null)"
11
12
  ]
12
13
  }
13
14
  }
package/CHANGELOG.md CHANGED
@@ -1,5 +1,60 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.0.4 (2026-04-12)
4
+
5
+ Migration to i3X API 1.0-Beta specification with enhanced query capabilities and improved API alignment.
6
+
7
+ ### Added
8
+
9
+ - **Server Info Endpoint** – New `getInfo()` method retrieves server metadata (spec version, server version, capabilities) from `GET /info` endpoint with TTL caching
10
+ - **Single-Object History Query** – New `getHistory(elementId, options)` method for `GET /objects/{elementId}/history` endpoint
11
+ - **Partial Response Handling** – History queries now detect HTTP 206 status and set `_partial: true` flag on results when server returns incomplete data
12
+ - **Root Objects Filter** – `getObjects()` now supports `root: true` parameter to retrieve only root-level objects
13
+ - **Enhanced Subscribe Options** – Subscribe node now supports `maxDepth`, `includeMetadata`, and `returnMode` parameters for fine-grained control
14
+
15
+ ### Changed
16
+
17
+ - **API Version Update** – Updated from i3X v0.0.1 to 1.0-Beta specification
18
+ - **API Documentation URL** – Changed from `https://i3x.cesmii.net/docs` to `https://api.i3x.dev/v1/docs`
19
+ - **Parameter Naming** – `typeId` parameter renamed to `typeElementId` in `getObjects()` (legacy `typeId` still supported as alias)
20
+ - **Relationship Type Parameter** – Fixed casing from `relationshiptype` to `relationshipType` in `getRelatedObjects()`
21
+ - **History Query Implementation** – `getHistoryBulk()` now uses `_requestRaw()` to access HTTP status codes for partial response detection
22
+
23
+ ### Tests
24
+
25
+ - Updated all test cases to reflect 1.0-Beta API changes
26
+ - Added tests for new `getInfo()` endpoint
27
+ - Added tests for single-object history query
28
+ - Added tests for partial response handling (HTTP 206)
29
+ - Updated integration tests for new parameter names
30
+
31
+ ## 0.0.3 (2026-03-10)
32
+
33
+ Hardening, security improvements, and new browser widget features.
34
+
35
+ ### Added
36
+
37
+ - **Live Values in Browser Widget** – The tree view now displays the current value, quality, and timestamp next to each element. Values are fetched automatically when expanding types or children, and on search results. Hover to see full value with quality and timestamp.
38
+ - **Test Connection Button** – Server config panel now includes a "Test Connection" button to verify connectivity without deploying
39
+ - **New admin endpoint** `POST /i3x-server/:id/browse/values` – Batch-reads live values for up to 50 elements (used by browser widget)
40
+ - **`statusError()` utility** – Smarter error message truncation (48 chars with `...`) replacing the hard `substring(0, 32)` cut across all nodes
41
+ - **`clampMaxDepth()` utility** – Validates and clamps `maxDepth` to 0–100 range, preventing negative or excessively large values
42
+
43
+ ### Security
44
+
45
+ - **HTTPS warning** – Nodes now warn at startup when credentials are sent over plain HTTP to non-localhost servers
46
+ - **Error response sanitization** – `_wrapError()` strips sensitive fields (`token`, `password`, `apiKey`, `secret`) from API error response bodies to prevent accidental credential leakage in logs
47
+
48
+ ### Fixed
49
+
50
+ - **SSE auth header duplication** – `streamSubscription()` now copies all configured headers cleanly via spread instead of manually duplicating individual auth headers
51
+ - **Poll interval minimum** – Increased from 500ms to 1000ms to prevent accidental API overload
52
+
53
+ ### Changed
54
+
55
+ - Error status messages across all nodes now show up to 48 characters (was 32)
56
+ - `maxDepth` is validated on all nodes that accept it (read, history, subscribe)
57
+
3
58
  ## 0.0.2 (2026-03-05)
4
59
 
5
60
  Compliance improvements based on [i3X Client Developer Guidelines](https://www.i3x.dev/sdk/category/client-developers).
package/README.md CHANGED
@@ -4,7 +4,7 @@ Node-RED nodes for the **i3X** (Industrial Information Interoperability eXchange
4
4
 
5
5
  i3X is an open, vendor-agnostic REST API specification for standardised access to contextualised manufacturing information platforms (Historians, MES, MOM, etc.).
6
6
 
7
- > **Note:** The i3X API is currently in **pre-alpha (v0)**. Response structures may change as the specification evolves.
7
+ > **Note:** The i3X API is currently in **beta (v1.0-Beta)**. Response structures may change as the specification evolves.
8
8
 
9
9
  ## Installation
10
10
 
@@ -108,10 +108,11 @@ The shared HTTP client (`lib/i3x-client.js`) implements all [i3X Client Develope
108
108
 
109
109
  ## API Endpoints Used
110
110
 
111
- This package targets the [i3X API Prototype v0.0.1](https://api.i3x.dev/v0/docs):
111
+ This package targets the [i3X API 1.0-Beta](https://api.i3x.dev/v1/docs):
112
112
 
113
113
  | Category | Method | Endpoint |
114
114
  | --------- | ------ | -------------------------------------------- |
115
+ | Info | GET | `/info` |
115
116
  | Explore | GET | `/namespaces` |
116
117
  | Explore | GET | `/objecttypes` |
117
118
  | Explore | POST | `/objecttypes/query` |
@@ -121,6 +122,7 @@ This package targets the [i3X API Prototype v0.0.1](https://api.i3x.dev/v0/docs)
121
122
  | Explore | POST | `/objects/list` |
122
123
  | Explore | POST | `/objects/related` |
123
124
  | Query | POST | `/objects/value` |
125
+ | Query | GET | `/objects/{elementId}/history` |
124
126
  | Query | POST | `/objects/history` |
125
127
  | Update | PUT | `/objects/{elementId}/value` |
126
128
  | Update | PUT | `/objects/{elementId}/history` |
package/lib/i3x-client.js CHANGED
@@ -1,10 +1,10 @@
1
1
  /**
2
2
  * I3XClient – Shared HTTP client for the i3X (CESMII) API.
3
3
  *
4
- * Wraps all REST endpoints described in the i3X OpenAPI v0.0.1 spec.
4
+ * Wraps all REST endpoints described in the i3X OpenAPI 1.0-Beta spec.
5
5
  * Used by every node-red-contrib-i3x node via the i3x-server config node.
6
6
  *
7
- * @see https://i3x.cesmii.net/docs
7
+ * @see https://api.i3x.dev/v1/docs
8
8
  */
9
9
  "use strict";
10
10
 
@@ -84,6 +84,14 @@ class I3XClient extends EventEmitter {
84
84
  this.authType = config.authType || "none";
85
85
  this.timeout = config.timeout || 10000;
86
86
 
87
+ // Warn if credentials are sent over plain HTTP (not localhost)
88
+ if (this.authType !== "none" && this.baseUrl && !this.baseUrl.startsWith("https://")) {
89
+ const isLocal = /^https?:\/\/(localhost|127\.0\.0\.1|::1)(:|\/|$)/.test(this.baseUrl);
90
+ if (!isLocal) {
91
+ this._httpsWarning = "Credentials sent over plain HTTP – use HTTPS in production";
92
+ }
93
+ }
94
+
87
95
  const axiosConfig = {
88
96
  baseURL: this._prefix(),
89
97
  timeout: this.timeout,
@@ -110,6 +118,21 @@ class I3XClient extends EventEmitter {
110
118
  this._rateLimiter = new RateLimiter();
111
119
  }
112
120
 
121
+ // ── Server Info ──────────────────────────────────────────────────────
122
+
123
+ /**
124
+ * Retrieve server information (no authentication required).
125
+ * @returns {Promise<{specVersion:string, serverVersion:string, serverName:string, capabilities:object}>}
126
+ */
127
+ async getInfo() {
128
+ const cacheKey = "info";
129
+ const cached = this._cache.get(cacheKey);
130
+ if (cached) return cached;
131
+ const result = await this._get("/info");
132
+ this._cache.set(cacheKey, result);
133
+ return result;
134
+ }
135
+
113
136
  // ── Explore ────────────────────────────────────────────────────────
114
137
 
115
138
  /** @returns {Promise<Array<{uri:string, displayName:string}>>} */
@@ -158,14 +181,18 @@ class I3XClient extends EventEmitter {
158
181
  }
159
182
 
160
183
  /**
161
- * @param {object} [options]
162
- * @param {string} [options.typeId]
184
+ * @param {object} [options]
185
+ * @param {string} [options.typeElementId] – filter by type (Beta spec name)
186
+ * @param {string} [options.typeId] – legacy alias for typeElementId
163
187
  * @param {boolean} [options.includeMetadata]
188
+ * @param {boolean} [options.root] – return only root objects
164
189
  */
165
190
  async getObjects(options = {}) {
166
191
  const params = {};
167
- if (options.typeId) params.typeId = options.typeId;
192
+ const typeFilter = options.typeElementId || options.typeId;
193
+ if (typeFilter) params.typeElementId = typeFilter;
168
194
  if (options.includeMetadata) params.includeMetadata = true;
195
+ if (options.root) params.root = true;
169
196
  return this._get("/objects", params);
170
197
  }
171
198
 
@@ -188,7 +215,7 @@ class I3XClient extends EventEmitter {
188
215
  */
189
216
  async getRelatedObjects(elementIds, options = {}) {
190
217
  const body = { elementIds };
191
- if (options.relationshipType) body.relationshiptype = options.relationshipType;
218
+ if (options.relationshipType) body.relationshipType = options.relationshipType;
192
219
  if (options.includeMetadata) body.includeMetadata = true;
193
220
  return this._post("/objects/related", body);
194
221
  }
@@ -207,6 +234,7 @@ class I3XClient extends EventEmitter {
207
234
  }
208
235
 
209
236
  /**
237
+ * Bulk historical values query (POST).
210
238
  * @param {string[]} elementIds
211
239
  * @param {object} [options]
212
240
  * @param {string} [options.startTime] – ISO 8601
@@ -218,7 +246,37 @@ class I3XClient extends EventEmitter {
218
246
  if (options.startTime) body.startTime = options.startTime;
219
247
  if (options.endTime) body.endTime = options.endTime;
220
248
  if (options.maxDepth !== undefined) body.maxDepth = options.maxDepth;
221
- return this._post("/objects/history", body);
249
+ const res = await this._requestRaw("post", "/objects/history", { data: body });
250
+ const result = I3XClient._unwrapEnvelope(res.data);
251
+ if (res.status === 206) {
252
+ result._partial = true;
253
+ }
254
+ return result;
255
+ }
256
+
257
+ /**
258
+ * Single-object historical values query (GET).
259
+ * @param {string} elementId
260
+ * @param {object} [options]
261
+ * @param {string} [options.startTime] – ISO 8601
262
+ * @param {string} [options.endTime] – ISO 8601
263
+ * @param {number} [options.maxDepth]
264
+ */
265
+ async getHistory(elementId, options = {}) {
266
+ const params = {};
267
+ if (options.startTime) params.startTime = options.startTime;
268
+ if (options.endTime) params.endTime = options.endTime;
269
+ if (options.maxDepth !== undefined) params.maxDepth = options.maxDepth;
270
+ const res = await this._requestRaw(
271
+ "get",
272
+ `/objects/${encodeURIComponent(elementId)}/history`,
273
+ { params }
274
+ );
275
+ const result = I3XClient._unwrapEnvelope(res.data);
276
+ if (res.status === 206) {
277
+ result._partial = true;
278
+ }
279
+ return result;
222
280
  }
223
281
 
224
282
  // ── Update ─────────────────────────────────────────────────────────
@@ -259,82 +317,124 @@ class I3XClient extends EventEmitter {
259
317
  }
260
318
  }
261
319
 
262
- // ── Subscribe ──────────────────────────────────────────────────────
263
-
264
- async listSubscriptions() {
265
- return this._get("/subscriptions");
266
- }
320
+ // ── Subscribe (Beta-Spec: body-based endpoints) ──────────────────
267
321
 
268
- /** @returns {Promise<{subscriptionId:string, message:string}>} */
269
- async createSubscription() {
270
- return this._post("/subscriptions", {});
322
+ /**
323
+ * Create a new subscription.
324
+ * @param {object} [options]
325
+ * @param {string} [options.clientId] – optional client identifier
326
+ * @param {string} [options.displayName] – optional human-readable name
327
+ * @returns {Promise<{subscriptionId:string, clientId?:string, displayName?:string}>}
328
+ */
329
+ async createSubscription(options = {}) {
330
+ const body = {};
331
+ if (options.clientId) body.clientId = options.clientId;
332
+ if (options.displayName) body.displayName = options.displayName;
333
+ return this._post("/subscriptions", body);
271
334
  }
272
335
 
273
- /** @param {string} subscriptionId */
274
- async getSubscription(subscriptionId) {
275
- return this._get(`/subscriptions/${encodeURIComponent(subscriptionId)}`);
336
+ /**
337
+ * List subscriptions by IDs.
338
+ * @param {string[]} subscriptionIds
339
+ * @param {object} [options]
340
+ * @param {string} [options.clientId]
341
+ */
342
+ async listSubscriptions(subscriptionIds, options = {}) {
343
+ const body = { subscriptionIds };
344
+ if (options.clientId) body.clientId = options.clientId;
345
+ return this._post("/subscriptions/list", body);
276
346
  }
277
347
 
278
- /** @param {string} subscriptionId */
279
- async deleteSubscription(subscriptionId) {
280
- return this._delete(`/subscriptions/${encodeURIComponent(subscriptionId)}`);
348
+ /**
349
+ * Delete one or more subscriptions.
350
+ * @param {string[]} subscriptionIds
351
+ * @param {object} [options]
352
+ * @param {string} [options.clientId]
353
+ */
354
+ async deleteSubscriptions(subscriptionIds, options = {}) {
355
+ const body = { subscriptionIds };
356
+ if (options.clientId) body.clientId = options.clientId;
357
+ return this._post("/subscriptions/delete", body);
281
358
  }
282
359
 
283
360
  /**
361
+ * Register monitored items on a subscription.
284
362
  * @param {string} subscriptionId
285
363
  * @param {string[]} elementIds
286
- * @param {number} [maxDepth]
364
+ * @param {object} [options]
365
+ * @param {number} [options.maxDepth] – default 1
366
+ * @param {string} [options.clientId]
287
367
  */
288
- async registerMonitoredItems(subscriptionId, elementIds, maxDepth = 1) {
289
- const body = { elementIds, maxDepth };
290
- return this._post(
291
- `/subscriptions/${encodeURIComponent(subscriptionId)}/register`,
292
- body
293
- );
368
+ async registerMonitoredItems(subscriptionId, elementIds, options = {}) {
369
+ // Support legacy positional maxDepth: registerMonitoredItems(id, ids, 2)
370
+ if (typeof options === "number") {
371
+ options = { maxDepth: options };
372
+ }
373
+ const body = { subscriptionId, elementIds, maxDepth: options.maxDepth !== undefined ? options.maxDepth : 1 };
374
+ if (options.clientId) body.clientId = options.clientId;
375
+ return this._post("/subscriptions/register", body);
294
376
  }
295
377
 
296
378
  /**
379
+ * Unregister monitored items from a subscription.
297
380
  * @param {string} subscriptionId
298
381
  * @param {string[]} elementIds
382
+ * @param {object} [options]
383
+ * @param {string} [options.clientId]
299
384
  */
300
- async unregisterMonitoredItems(subscriptionId, elementIds) {
301
- const body = { elementIds };
302
- return this._post(
303
- `/subscriptions/${encodeURIComponent(subscriptionId)}/unregister`,
304
- body
305
- );
385
+ async unregisterMonitoredItems(subscriptionId, elementIds, options = {}) {
386
+ const body = { subscriptionId, elementIds };
387
+ if (options.clientId) body.clientId = options.clientId;
388
+ return this._post("/subscriptions/unregister", body);
306
389
  }
307
390
 
308
391
  /**
309
- * Open an SSE stream for the given subscription.
392
+ * Open an SSE stream for the given subscription (POST).
310
393
  * Supports automatic reconnection on stream errors.
311
394
  *
312
395
  * @param {string} subscriptionId
313
396
  * @param {object} callbacks
314
397
  * @param {function} callbacks.onData – called with each parsed SSE event
315
- * @param {function} [callbacks.onError] – called on stream errors (instead of EventEmitter)
398
+ * @param {function} [callbacks.onError] – called on stream errors
316
399
  * @param {function} [callbacks.onReconnect] – called when a reconnection attempt starts
317
- * @param {number} [maxReconnects=5] – max consecutive reconnection attempts
400
+ * @param {object} [options]
401
+ * @param {string} [options.clientId]
402
+ * @param {number} [options.maxReconnects=5]
318
403
  * @returns {{ close: function }} handle to close the stream
319
404
  */
320
- streamSubscription(subscriptionId, callbacks, maxReconnects = 5) {
405
+ streamSubscription(subscriptionId, callbacks, options = {}) {
321
406
  if (typeof callbacks === "function") {
322
407
  callbacks = { onData: callbacks };
323
408
  }
409
+ // Support legacy positional maxReconnects number
410
+ if (typeof options === "number") {
411
+ options = { maxReconnects: options };
412
+ }
324
413
  const { onData, onError, onReconnect } = callbacks;
414
+ const maxReconnects = options.maxReconnects !== undefined ? options.maxReconnects : 5;
415
+
416
+ const url = `${this._prefix()}/subscriptions/stream`;
417
+ const postBody = { subscriptionId };
418
+ if (options.clientId) postBody.clientId = options.clientId;
325
419
 
326
- const url = `${this._prefix()}/subscriptions/${encodeURIComponent(subscriptionId)}/stream`;
327
420
  let controller = new AbortController();
328
421
  let closed = false;
329
422
  let reconnectCount = 0;
330
423
 
331
- const headers = { ...this.http.defaults.headers.common, Accept: "text/event-stream" };
332
- if (this.http.defaults.headers["Authorization"]) {
333
- headers["Authorization"] = this.http.defaults.headers["Authorization"];
334
- }
335
- if (this.http.defaults.headers["X-API-Key"]) {
336
- headers["X-API-Key"] = this.http.defaults.headers["X-API-Key"];
337
- }
424
+ const headers = {
425
+ ...this.http.defaults.headers.common,
426
+ ...this.http.defaults.headers,
427
+ Accept: "text/event-stream",
428
+ "Content-Type": "application/json",
429
+ };
430
+ // Remove non-header axios defaults that got spread in
431
+ delete headers.common;
432
+ delete headers.get;
433
+ delete headers.post;
434
+ delete headers.put;
435
+ delete headers.delete;
436
+ delete headers.patch;
437
+ delete headers.head;
338
438
  if (this.http.defaults.auth) {
339
439
  const b64 = Buffer.from(
340
440
  `${this.http.defaults.auth.username}:${this.http.defaults.auth.password}`
@@ -347,8 +447,9 @@ class I3XClient extends EventEmitter {
347
447
  try {
348
448
  controller = new AbortController();
349
449
  const response = await axios({
350
- method: "get",
450
+ method: "post",
351
451
  url,
452
+ data: postBody,
352
453
  headers,
353
454
  responseType: "stream",
354
455
  signal: controller.signal,
@@ -410,14 +511,19 @@ class I3XClient extends EventEmitter {
410
511
  }
411
512
 
412
513
  /**
413
- * Poll-based sync: returns and clears queued updates.
514
+ * Poll-based sync: acknowledge previously received updates and return pending ones.
414
515
  * @param {string} subscriptionId
516
+ * @param {object} [options]
517
+ * @param {number} [options.lastSequenceNumber] – acknowledge events up to this sequence number
518
+ * @param {string} [options.clientId]
415
519
  */
416
- async syncSubscription(subscriptionId) {
417
- return this._post(
418
- `/subscriptions/${encodeURIComponent(subscriptionId)}/sync`,
419
- {}
420
- );
520
+ async syncSubscription(subscriptionId, options = {}) {
521
+ const body = { subscriptionId };
522
+ if (options.lastSequenceNumber !== undefined) {
523
+ body.lastSequenceNumber = options.lastSequenceNumber;
524
+ }
525
+ if (options.clientId) body.clientId = options.clientId;
526
+ return this._post("/subscriptions/sync", body);
421
527
  }
422
528
 
423
529
  // ── Utility ────────────────────────────────────────────────────────
@@ -470,15 +576,38 @@ class I3XClient extends EventEmitter {
470
576
 
471
577
  /**
472
578
  * Central request dispatcher with retry logic, Retry-After support, and rate limiting.
579
+ * Returns unwrapped result data. Use _requestRaw for full response access.
473
580
  * @private
474
581
  */
475
582
  async _request(method, path, opts = {}) {
583
+ const res = await this._requestRaw(method, path, opts);
584
+ return I3XClient._unwrapEnvelope(res.data);
585
+ }
586
+
587
+ /**
588
+ * Unwrap the Beta-Spec response envelope if present.
589
+ * SuccessResponse: {success, result} → result
590
+ * BulkResponse: {success, results} → results
591
+ * @private
592
+ */
593
+ static _unwrapEnvelope(data) {
594
+ if (data && typeof data === "object" && data.success === true) {
595
+ if ("result" in data) return data.result;
596
+ if ("results" in data) return data.results;
597
+ }
598
+ return data;
599
+ }
600
+
601
+ /**
602
+ * Central request dispatcher returning the full axios response.
603
+ * @private
604
+ */
605
+ async _requestRaw(method, path, opts = {}) {
476
606
  await this._rateLimiter.acquire();
477
607
  let lastErr;
478
608
  for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
479
609
  try {
480
- const res = await this.http.request({ method, url: path, ...opts });
481
- return res.data;
610
+ return await this.http.request({ method, url: path, ...opts });
482
611
  } catch (err) {
483
612
  lastErr = err;
484
613
  const status = err.response && err.response.status;
@@ -513,7 +642,17 @@ class I3XClient extends EventEmitter {
513
642
  if (err.response) {
514
643
  wrapped.statusCode = err.response.status;
515
644
  wrapped.statusText = err.response.statusText;
516
- wrapped.body = err.response.data;
645
+ // Sanitize response body to avoid leaking auth details
646
+ const body = err.response.data;
647
+ if (body && typeof body === "object") {
648
+ const sanitized = { ...body };
649
+ for (const key of ["authorization", "token", "apiKey", "api_key", "password", "secret"]) {
650
+ delete sanitized[key];
651
+ }
652
+ wrapped.body = sanitized;
653
+ } else {
654
+ wrapped.body = body;
655
+ }
517
656
  } else if (err.code) {
518
657
  wrapped.code = err.code;
519
658
  }
package/lib/node-utils.js CHANGED
@@ -58,4 +58,30 @@ function safeSend(node, send) {
58
58
  return send || function () { node.send.apply(node, arguments); };
59
59
  }
60
60
 
61
- module.exports = { bindServer, parseIds, safeSend };
61
+ /**
62
+ * Truncate an error message for node status display.
63
+ * Keeps up to 48 characters and appends "..." if truncated.
64
+ * @param {string} msg
65
+ * @returns {string}
66
+ */
67
+ function statusError(msg) {
68
+ if (!msg) return "error";
69
+ if (msg.length <= 48) return msg;
70
+ return msg.substring(0, 45) + "...";
71
+ }
72
+
73
+ /**
74
+ * Clamp maxDepth to a valid range (0–100).
75
+ * @param {number} val
76
+ * @param {number} [fallback=1]
77
+ * @returns {number}
78
+ */
79
+ function clampMaxDepth(val, fallback) {
80
+ if (fallback === undefined) fallback = 1;
81
+ var n = parseInt(val, 10);
82
+ if (isNaN(n) || n < 0) return fallback;
83
+ if (n > 100) return 100;
84
+ return n;
85
+ }
86
+
87
+ module.exports = { bindServer, parseIds, safeSend, statusError, clampMaxDepth };
@@ -21,6 +21,9 @@
21
21
  <label for="node-input-elementId"><i class="fa fa-crosshairs"></i> Element ID</label>
22
22
  <input type="text" id="node-input-elementId" placeholder="optional – filter by element ID">
23
23
  </div>
24
+ <div class="form-row i3x-browse-elementId">
25
+ <div id="i3x-browse-browser"></div>
26
+ </div>
24
27
  <div class="form-row i3x-browse-typeId">
25
28
  <label for="node-input-typeId"><i class="fa fa-filter"></i> Type ID</label>
26
29
  <input type="text" id="node-input-typeId" placeholder="optional – filter objects by type">
@@ -66,6 +69,7 @@
66
69
  <h3>Details</h3>
67
70
  <p>This node talks to the <b>Explore</b> endpoints of the i3X API.
68
71
  <code>msg</code> properties override the values configured in the node editor.</p>
72
+ <p>Use the <b>Browse</b> button to visually select an element from the server.</p>
69
73
  </script>
70
74
 
71
75
  <script type="text/javascript">
@@ -101,6 +105,15 @@
101
105
  }
102
106
  target.on("change", toggle);
103
107
  toggle();
108
+
109
+ if (window.I3XBrowser) {
110
+ this._browser = I3XBrowser.create({
111
+ container: "#i3x-browse-browser",
112
+ serverField: "#node-input-server",
113
+ targetField: "#node-input-elementId",
114
+ mode: "single",
115
+ });
116
+ }
104
117
  },
105
118
  });
106
119
  </script>
@@ -3,7 +3,7 @@
3
3
  */
4
4
  "use strict";
5
5
 
6
- const { bindServer, safeSend } = require("../lib/node-utils");
6
+ const { bindServer, parseIds, safeSend, statusError } = require("../lib/node-utils");
7
7
 
8
8
  module.exports = function (RED) {
9
9
  function I3XBrowseNode(config) {
@@ -24,13 +24,13 @@ module.exports = function (RED) {
24
24
  const client = node.server.client;
25
25
 
26
26
  const target = msg.browseTarget || node.browseTarget;
27
- const elementId = msg.elementId || node.elementId;
27
+ const ids = parseIds(msg.elementId || node.elementId);
28
28
  const typeId = msg.typeId || node.typeId;
29
29
  const nsUri = msg.namespaceUri || node.namespaceUri;
30
30
  const inclMeta = msg.includeMetadata !== undefined ? msg.includeMetadata : node.includeMetadata;
31
31
  const relType = msg.relationshipType || node.relationshipType;
32
32
 
33
- node.status({ fill: "blue", shape: "dot", text: "requesting..." });
33
+ node.status({ fill: "blue", shape: "dot", text: "browsing " + target + "..." });
34
34
 
35
35
  try {
36
36
  let result;
@@ -39,51 +39,46 @@ module.exports = function (RED) {
39
39
  result = await client.getNamespaces();
40
40
  break;
41
41
  case "objecttypes":
42
- if (elementId) {
43
- const ids = Array.isArray(elementId) ? elementId : [elementId];
42
+ if (ids.length) {
44
43
  result = await client.queryObjectTypes(ids);
45
44
  } else {
46
45
  result = await client.getObjectTypes({ namespaceUri: nsUri || undefined });
47
46
  }
48
47
  break;
49
48
  case "relationshiptypes":
50
- if (elementId) {
51
- const ids = Array.isArray(elementId) ? elementId : [elementId];
49
+ if (ids.length) {
52
50
  result = await client.queryRelationshipTypes(ids);
53
51
  } else {
54
52
  result = await client.getRelationshipTypes({ namespaceUri: nsUri || undefined });
55
53
  }
56
54
  break;
57
55
  case "objects":
58
- if (elementId) {
59
- const ids = Array.isArray(elementId) ? elementId : [elementId];
56
+ if (ids.length) {
60
57
  result = await client.listObjects(ids, { includeMetadata: inclMeta });
61
58
  } else {
62
59
  result = await client.getObjects({ typeId: typeId || undefined, includeMetadata: inclMeta });
63
60
  }
64
61
  break;
65
62
  case "related":
66
- if (!elementId) {
63
+ if (!ids.length) {
67
64
  throw new Error("elementId is required for related objects query");
68
65
  }
69
- {
70
- const ids = Array.isArray(elementId) ? elementId : [elementId];
71
- result = await client.getRelatedObjects(ids, {
72
- relationshipType: relType || undefined,
73
- includeMetadata: inclMeta,
74
- });
75
- }
66
+ result = await client.getRelatedObjects(ids, {
67
+ relationshipType: relType || undefined,
68
+ includeMetadata: inclMeta,
69
+ });
76
70
  break;
77
71
  default:
78
72
  throw new Error("Unknown browse target: " + target);
79
73
  }
80
74
 
75
+ const count = Array.isArray(result) ? result.length : 1;
81
76
  msg.payload = result;
82
- node.status({ fill: "green", shape: "dot", text: "ok" });
77
+ node.status({ fill: "green", shape: "dot", text: count + " " + target });
83
78
  send(msg);
84
79
  if (done) done();
85
80
  } catch (err) {
86
- node.status({ fill: "red", shape: "ring", text: err.message.substring(0, 32) });
81
+ node.status({ fill: "red", shape: "ring", text: statusError(err.message) });
87
82
  if (done) done(err); else node.error(err, msg);
88
83
  }
89
84
  });