node-red-contrib-i3x 0.0.1 → 0.0.3
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/.claude/settings.local.json +14 -0
- package/CHANGELOG.md +51 -2
- package/README.md +19 -2
- package/lib/i3x-client.js +132 -9
- package/lib/node-utils.js +27 -1
- package/nodes/i3x-browse.html +13 -0
- package/nodes/i3x-browse.js +14 -19
- package/nodes/i3x-history.html +14 -0
- package/nodes/i3x-history.js +6 -6
- package/nodes/i3x-read.html +15 -0
- package/nodes/i3x-read.js +6 -6
- package/nodes/i3x-server.html +926 -0
- package/nodes/i3x-server.js +122 -0
- package/nodes/i3x-subscribe.html +14 -1
- package/nodes/i3x-subscribe.js +9 -6
- package/nodes/i3x-write.html +15 -2
- package/nodes/i3x-write.js +3 -3
- package/package.json +3 -3
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"permissions": {
|
|
3
|
+
"allow": [
|
|
4
|
+
"WebFetch(domain:www.i3x.dev)",
|
|
5
|
+
"WebFetch(domain:api.i3x.dev)",
|
|
6
|
+
"Bash(npx mocha:*)",
|
|
7
|
+
"Bash(npm test:*)",
|
|
8
|
+
"Bash(node-red:*)",
|
|
9
|
+
"Bash(npx node-red:*)",
|
|
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)"
|
|
12
|
+
]
|
|
13
|
+
}
|
|
14
|
+
}
|
package/CHANGELOG.md
CHANGED
|
@@ -1,8 +1,57 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.0.3 (2026-03-10)
|
|
4
|
+
|
|
5
|
+
Hardening, security improvements, and new browser widget features.
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **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.
|
|
10
|
+
- **Test Connection Button** – Server config panel now includes a "Test Connection" button to verify connectivity without deploying
|
|
11
|
+
- **New admin endpoint** `POST /i3x-server/:id/browse/values` – Batch-reads live values for up to 50 elements (used by browser widget)
|
|
12
|
+
- **`statusError()` utility** – Smarter error message truncation (48 chars with `...`) replacing the hard `substring(0, 32)` cut across all nodes
|
|
13
|
+
- **`clampMaxDepth()` utility** – Validates and clamps `maxDepth` to 0–100 range, preventing negative or excessively large values
|
|
14
|
+
|
|
15
|
+
### Security
|
|
16
|
+
|
|
17
|
+
- **HTTPS warning** – Nodes now warn at startup when credentials are sent over plain HTTP to non-localhost servers
|
|
18
|
+
- **Error response sanitization** – `_wrapError()` strips sensitive fields (`token`, `password`, `apiKey`, `secret`) from API error response bodies to prevent accidental credential leakage in logs
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- **SSE auth header duplication** – `streamSubscription()` now copies all configured headers cleanly via spread instead of manually duplicating individual auth headers
|
|
23
|
+
- **Poll interval minimum** – Increased from 500ms to 1000ms to prevent accidental API overload
|
|
24
|
+
|
|
25
|
+
### Changed
|
|
26
|
+
|
|
27
|
+
- Error status messages across all nodes now show up to 48 characters (was 32)
|
|
28
|
+
- `maxDepth` is validated on all nodes that accept it (read, history, subscribe)
|
|
29
|
+
|
|
30
|
+
## 0.0.2 (2026-03-05)
|
|
31
|
+
|
|
32
|
+
Compliance improvements based on [i3X Client Developer Guidelines](https://www.i3x.dev/sdk/category/client-developers).
|
|
33
|
+
|
|
34
|
+
### Added
|
|
35
|
+
|
|
36
|
+
- **TTL Caching** – Namespace and object type responses are cached for 60 seconds to reduce API load
|
|
37
|
+
- **Rate Limiting** – Client-side sliding-window throttle (100 requests per 60-second window) to proactively stay within API limits
|
|
38
|
+
- **Retry-After Header Support** – Respects server-provided `Retry-After` headers (both seconds and HTTP-date formats) instead of only using fixed exponential backoff
|
|
39
|
+
- **Input Sanitization** – Allowlist validation on write payloads (`writeValue`, `writeHistory`) to prevent injection of unexpected fields
|
|
40
|
+
- **New tests** – 12 additional unit tests covering caching, Retry-After, input sanitization, and rate limiting (75 tests total)
|
|
41
|
+
|
|
42
|
+
### Fixed
|
|
43
|
+
|
|
44
|
+
- **`testConnection()` bypassed cache** – Health check now performs a real HTTP round-trip instead of returning stale cached data
|
|
45
|
+
- **SSE stream missing auth headers** – Bearer tokens and API keys are now explicitly propagated to SSE stream requests (previously only `headers.common` was copied, which could miss auth headers)
|
|
46
|
+
- **TLS `rejectUnauthorized` default** – Now defaults to `true` per i3X security guidelines; can still be overridden via TLS config node
|
|
47
|
+
|
|
48
|
+
### Changed
|
|
49
|
+
|
|
50
|
+
- Updated API docs URL from `https://i3x.cesmii.net/docs` to `https://api.i3x.dev/v0/docs`
|
|
51
|
+
|
|
3
52
|
## 0.0.1 (2026-03-03)
|
|
4
53
|
|
|
5
|
-
Initial pre-alpha release targeting the [i3X API Prototype v0.0.1](https://i3x.
|
|
54
|
+
Initial pre-alpha release targeting the [i3X API Prototype v0.0.1](https://api.i3x.dev/v0/docs).
|
|
6
55
|
|
|
7
56
|
### Nodes
|
|
8
57
|
|
|
@@ -19,5 +68,5 @@ Initial pre-alpha release targeting the [i3X API Prototype v0.0.1](https://i3x.c
|
|
|
19
68
|
- Shared HTTP client (`lib/i3x-client.js`) with retry logic, error wrapping, and SSE reconnection
|
|
20
69
|
- Dynamic configuration via `msg` properties (all node settings can be overridden at runtime)
|
|
21
70
|
- Example flow demonstrating all features against the public CESMII demo server
|
|
22
|
-
- Unit tests
|
|
71
|
+
- Unit tests and integration tests against the live API
|
|
23
72
|
- Docker Compose setup for testing and local Node-RED development
|
package/README.md
CHANGED
|
@@ -90,9 +90,25 @@ Subscribe to value changes via SSE streaming or polling.
|
|
|
90
90
|
- **Fallback:** If SSE fails, the node automatically falls back to polling
|
|
91
91
|
- **Lifecycle:** Subscriptions are created on deploy and deleted on stop/re-deploy
|
|
92
92
|
|
|
93
|
+
## Built-in Resilience & Best Practices
|
|
94
|
+
|
|
95
|
+
The shared HTTP client (`lib/i3x-client.js`) implements all [i3X Client Developer Best Practices](https://www.i3x.dev/sdk/category/client-developers):
|
|
96
|
+
|
|
97
|
+
| Feature | Description |
|
|
98
|
+
| ------- | ----------- |
|
|
99
|
+
| **Retry with Exponential Backoff** | Automatic retries on 429, 502, 503, 504 with exponential delay |
|
|
100
|
+
| **Retry-After Header** | Respects server-provided `Retry-After` headers (seconds and HTTP-date formats) |
|
|
101
|
+
| **TTL Caching** | Namespaces and object types are cached for 60 seconds to reduce API load |
|
|
102
|
+
| **Rate Limiting** | Client-side sliding-window throttle (100 requests per 60-second window) |
|
|
103
|
+
| **Input Sanitization** | Allowlist validation on write payloads to prevent injection of unexpected fields |
|
|
104
|
+
| **TLS Certificate Validation** | `rejectUnauthorized: true` by default; overridable via TLS config node |
|
|
105
|
+
| **SSE Reconnection** | Automatic reconnection with exponential backoff (up to 5 attempts, max 30s delay) |
|
|
106
|
+
| **SSE → Polling Fallback** | Automatic fallback to polling if SSE stream setup fails |
|
|
107
|
+
| **Subscription Cleanup** | Subscriptions are deleted server-side on node stop/re-deploy |
|
|
108
|
+
|
|
93
109
|
## API Endpoints Used
|
|
94
110
|
|
|
95
|
-
This package targets the [i3X API Prototype v0.0.1](https://i3x.
|
|
111
|
+
This package targets the [i3X API Prototype v0.0.1](https://api.i3x.dev/v0/docs):
|
|
96
112
|
|
|
97
113
|
| Category | Method | Endpoint |
|
|
98
114
|
| --------- | ------ | -------------------------------------------- |
|
|
@@ -208,7 +224,8 @@ node-red
|
|
|
208
224
|
|
|
209
225
|
## References
|
|
210
226
|
|
|
211
|
-
- [i3X API Documentation](https://i3x.
|
|
227
|
+
- [i3X API Documentation](https://api.i3x.dev/v0/docs)
|
|
228
|
+
- [i3X Client Developer Guide](https://www.i3x.dev/sdk/category/client-developers)
|
|
212
229
|
- [i3X Specification & RFC](https://github.com/cesmii/i3X)
|
|
213
230
|
- [i3X SDK Documentation](https://www.i3x.dev/sdk)
|
|
214
231
|
- [CESMII](https://www.cesmii.org)
|
package/lib/i3x-client.js
CHANGED
|
@@ -15,6 +15,55 @@ const RETRY_STATUS_CODES = new Set([429, 502, 503, 504]);
|
|
|
15
15
|
const MAX_RETRIES = 3;
|
|
16
16
|
const RETRY_DELAY_MS = 1000;
|
|
17
17
|
|
|
18
|
+
const DEFAULT_CACHE_TTL_MS = 60000; // 1 minute
|
|
19
|
+
const RATE_LIMIT_WINDOW_MS = 60000; // 60 seconds
|
|
20
|
+
const RATE_LIMIT_MAX_REQUESTS = 100;
|
|
21
|
+
|
|
22
|
+
class TTLCache {
|
|
23
|
+
constructor(ttl = DEFAULT_CACHE_TTL_MS) {
|
|
24
|
+
this._ttl = ttl;
|
|
25
|
+
this._store = new Map();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
get(key) {
|
|
29
|
+
const entry = this._store.get(key);
|
|
30
|
+
if (!entry) return undefined;
|
|
31
|
+
if (Date.now() > entry.expires) {
|
|
32
|
+
this._store.delete(key);
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
return entry.value;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
set(key, value) {
|
|
39
|
+
this._store.set(key, { value, expires: Date.now() + this._ttl });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
clear() {
|
|
43
|
+
this._store.clear();
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
class RateLimiter {
|
|
48
|
+
constructor(maxRequests = RATE_LIMIT_MAX_REQUESTS, windowMs = RATE_LIMIT_WINDOW_MS) {
|
|
49
|
+
this._maxRequests = maxRequests;
|
|
50
|
+
this._windowMs = windowMs;
|
|
51
|
+
this._timestamps = [];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async acquire() {
|
|
55
|
+
const now = Date.now();
|
|
56
|
+
this._timestamps = this._timestamps.filter((t) => now - t < this._windowMs);
|
|
57
|
+
if (this._timestamps.length >= this._maxRequests) {
|
|
58
|
+
const oldest = this._timestamps[0];
|
|
59
|
+
const waitMs = this._windowMs - (now - oldest);
|
|
60
|
+
await new Promise((r) => setTimeout(r, waitMs));
|
|
61
|
+
return this.acquire();
|
|
62
|
+
}
|
|
63
|
+
this._timestamps.push(Date.now());
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
18
67
|
class I3XClient extends EventEmitter {
|
|
19
68
|
/**
|
|
20
69
|
* @param {object} config
|
|
@@ -35,6 +84,14 @@ class I3XClient extends EventEmitter {
|
|
|
35
84
|
this.authType = config.authType || "none";
|
|
36
85
|
this.timeout = config.timeout || 10000;
|
|
37
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
|
+
|
|
38
95
|
const axiosConfig = {
|
|
39
96
|
baseURL: this._prefix(),
|
|
40
97
|
timeout: this.timeout,
|
|
@@ -51,18 +108,26 @@ class I3XClient extends EventEmitter {
|
|
|
51
108
|
|
|
52
109
|
if (config.tlsOptions) {
|
|
53
110
|
const https = require("https");
|
|
54
|
-
|
|
111
|
+
const tlsOpts = { rejectUnauthorized: true, ...config.tlsOptions };
|
|
112
|
+
axiosConfig.httpsAgent = new https.Agent(tlsOpts);
|
|
55
113
|
}
|
|
56
114
|
|
|
57
115
|
this.http = axios.create(axiosConfig);
|
|
58
116
|
this._activeSubscriptions = new Map();
|
|
117
|
+
this._cache = new TTLCache();
|
|
118
|
+
this._rateLimiter = new RateLimiter();
|
|
59
119
|
}
|
|
60
120
|
|
|
61
121
|
// ── Explore ────────────────────────────────────────────────────────
|
|
62
122
|
|
|
63
123
|
/** @returns {Promise<Array<{uri:string, displayName:string}>>} */
|
|
64
124
|
async getNamespaces() {
|
|
65
|
-
|
|
125
|
+
const cacheKey = "namespaces";
|
|
126
|
+
const cached = this._cache.get(cacheKey);
|
|
127
|
+
if (cached) return cached;
|
|
128
|
+
const result = await this._get("/namespaces");
|
|
129
|
+
this._cache.set(cacheKey, result);
|
|
130
|
+
return result;
|
|
66
131
|
}
|
|
67
132
|
|
|
68
133
|
/**
|
|
@@ -72,7 +137,12 @@ class I3XClient extends EventEmitter {
|
|
|
72
137
|
async getObjectTypes(options = {}) {
|
|
73
138
|
const params = {};
|
|
74
139
|
if (options.namespaceUri) params.namespaceUri = options.namespaceUri;
|
|
75
|
-
|
|
140
|
+
const cacheKey = "objecttypes:" + (options.namespaceUri || "");
|
|
141
|
+
const cached = this._cache.get(cacheKey);
|
|
142
|
+
if (cached) return cached;
|
|
143
|
+
const result = await this._get("/objecttypes", params);
|
|
144
|
+
this._cache.set(cacheKey, result);
|
|
145
|
+
return result;
|
|
76
146
|
}
|
|
77
147
|
|
|
78
148
|
/** @param {string[]} elementIds */
|
|
@@ -166,6 +236,7 @@ class I3XClient extends EventEmitter {
|
|
|
166
236
|
* @param {*} value
|
|
167
237
|
*/
|
|
168
238
|
async writeValue(elementId, value) {
|
|
239
|
+
I3XClient._validateWritePayload(value);
|
|
169
240
|
return this._put(`/objects/${encodeURIComponent(elementId)}/value`, value);
|
|
170
241
|
}
|
|
171
242
|
|
|
@@ -174,9 +245,28 @@ class I3XClient extends EventEmitter {
|
|
|
174
245
|
* @param {*} data – historical data payload
|
|
175
246
|
*/
|
|
176
247
|
async writeHistory(elementId, data) {
|
|
248
|
+
I3XClient._validateWritePayload(data);
|
|
177
249
|
return this._put(`/objects/${encodeURIComponent(elementId)}/history`, data);
|
|
178
250
|
}
|
|
179
251
|
|
|
252
|
+
static _WRITE_ALLOWED_FIELDS = new Set([
|
|
253
|
+
"value", "timestamp", "quality", "displayName", "attributes", "metadata",
|
|
254
|
+
"startTime", "endTime", "values", "elementId", "status",
|
|
255
|
+
]);
|
|
256
|
+
|
|
257
|
+
static _validateWritePayload(payload) {
|
|
258
|
+
if (payload === null || payload === undefined) {
|
|
259
|
+
throw new Error("Write payload must not be null or undefined");
|
|
260
|
+
}
|
|
261
|
+
if (typeof payload === "object" && !Array.isArray(payload)) {
|
|
262
|
+
const keys = Object.keys(payload);
|
|
263
|
+
const disallowed = keys.filter((k) => !I3XClient._WRITE_ALLOWED_FIELDS.has(k));
|
|
264
|
+
if (disallowed.length > 0) {
|
|
265
|
+
throw new Error("Disallowed fields in write payload: " + disallowed.join(", "));
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
180
270
|
// ── Subscribe ──────────────────────────────────────────────────────
|
|
181
271
|
|
|
182
272
|
async listSubscriptions() {
|
|
@@ -246,7 +336,19 @@ class I3XClient extends EventEmitter {
|
|
|
246
336
|
let closed = false;
|
|
247
337
|
let reconnectCount = 0;
|
|
248
338
|
|
|
249
|
-
const headers = {
|
|
339
|
+
const headers = {
|
|
340
|
+
...this.http.defaults.headers.common,
|
|
341
|
+
...this.http.defaults.headers,
|
|
342
|
+
Accept: "text/event-stream",
|
|
343
|
+
};
|
|
344
|
+
// Remove non-header axios defaults that got spread in
|
|
345
|
+
delete headers.common;
|
|
346
|
+
delete headers.get;
|
|
347
|
+
delete headers.post;
|
|
348
|
+
delete headers.put;
|
|
349
|
+
delete headers.delete;
|
|
350
|
+
delete headers.patch;
|
|
351
|
+
delete headers.head;
|
|
250
352
|
if (this.http.defaults.auth) {
|
|
251
353
|
const b64 = Buffer.from(
|
|
252
354
|
`${this.http.defaults.auth.username}:${this.http.defaults.auth.password}`
|
|
@@ -336,10 +438,10 @@ class I3XClient extends EventEmitter {
|
|
|
336
438
|
|
|
337
439
|
/**
|
|
338
440
|
* Lightweight connectivity test.
|
|
339
|
-
*
|
|
441
|
+
* Bypasses cache to ensure a real round-trip to the server.
|
|
340
442
|
*/
|
|
341
443
|
async testConnection() {
|
|
342
|
-
await this.
|
|
444
|
+
await this._request("get", "/namespaces", {});
|
|
343
445
|
return true;
|
|
344
446
|
}
|
|
345
447
|
|
|
@@ -349,6 +451,7 @@ class I3XClient extends EventEmitter {
|
|
|
349
451
|
handle.close();
|
|
350
452
|
}
|
|
351
453
|
this._activeSubscriptions.clear();
|
|
454
|
+
this._cache.clear();
|
|
352
455
|
}
|
|
353
456
|
|
|
354
457
|
// ── Internal helpers ───────────────────────────────────────────────
|
|
@@ -380,10 +483,11 @@ class I3XClient extends EventEmitter {
|
|
|
380
483
|
}
|
|
381
484
|
|
|
382
485
|
/**
|
|
383
|
-
* Central request dispatcher with retry logic.
|
|
486
|
+
* Central request dispatcher with retry logic, Retry-After support, and rate limiting.
|
|
384
487
|
* @private
|
|
385
488
|
*/
|
|
386
489
|
async _request(method, path, opts = {}) {
|
|
490
|
+
await this._rateLimiter.acquire();
|
|
387
491
|
let lastErr;
|
|
388
492
|
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
389
493
|
try {
|
|
@@ -393,7 +497,16 @@ class I3XClient extends EventEmitter {
|
|
|
393
497
|
lastErr = err;
|
|
394
498
|
const status = err.response && err.response.status;
|
|
395
499
|
if (status && RETRY_STATUS_CODES.has(status) && attempt < MAX_RETRIES) {
|
|
396
|
-
const
|
|
500
|
+
const retryAfter = err.response.headers && err.response.headers["retry-after"];
|
|
501
|
+
let delay;
|
|
502
|
+
if (retryAfter) {
|
|
503
|
+
const seconds = parseInt(retryAfter, 10);
|
|
504
|
+
delay = isNaN(seconds)
|
|
505
|
+
? Math.max(0, new Date(retryAfter).getTime() - Date.now())
|
|
506
|
+
: seconds * 1000;
|
|
507
|
+
} else {
|
|
508
|
+
delay = RETRY_DELAY_MS * Math.pow(2, attempt);
|
|
509
|
+
}
|
|
397
510
|
await new Promise((r) => setTimeout(r, delay));
|
|
398
511
|
continue;
|
|
399
512
|
}
|
|
@@ -414,7 +527,17 @@ class I3XClient extends EventEmitter {
|
|
|
414
527
|
if (err.response) {
|
|
415
528
|
wrapped.statusCode = err.response.status;
|
|
416
529
|
wrapped.statusText = err.response.statusText;
|
|
417
|
-
|
|
530
|
+
// Sanitize response body to avoid leaking auth details
|
|
531
|
+
const body = err.response.data;
|
|
532
|
+
if (body && typeof body === "object") {
|
|
533
|
+
const sanitized = { ...body };
|
|
534
|
+
for (const key of ["authorization", "token", "apiKey", "api_key", "password", "secret"]) {
|
|
535
|
+
delete sanitized[key];
|
|
536
|
+
}
|
|
537
|
+
wrapped.body = sanitized;
|
|
538
|
+
} else {
|
|
539
|
+
wrapped.body = body;
|
|
540
|
+
}
|
|
418
541
|
} else if (err.code) {
|
|
419
542
|
wrapped.code = err.code;
|
|
420
543
|
}
|
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
|
-
|
|
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 };
|
package/nodes/i3x-browse.html
CHANGED
|
@@ -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>
|
package/nodes/i3x-browse.js
CHANGED
|
@@ -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
|
|
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: "
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (!
|
|
63
|
+
if (!ids.length) {
|
|
67
64
|
throw new Error("elementId is required for related objects query");
|
|
68
65
|
}
|
|
69
|
-
{
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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: "
|
|
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
|
|
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
|
});
|
package/nodes/i3x-history.html
CHANGED
|
@@ -11,6 +11,9 @@
|
|
|
11
11
|
<label for="node-input-elementIds"><i class="fa fa-list"></i> Element IDs</label>
|
|
12
12
|
<input type="text" id="node-input-elementIds" placeholder="comma-separated element IDs">
|
|
13
13
|
</div>
|
|
14
|
+
<div class="form-row">
|
|
15
|
+
<div id="i3x-history-browser"></div>
|
|
16
|
+
</div>
|
|
14
17
|
<div class="form-row">
|
|
15
18
|
<label for="node-input-startTime"><i class="fa fa-clock-o"></i> Start Time</label>
|
|
16
19
|
<input type="text" id="node-input-startTime" placeholder="ISO 8601 or relative (e.g. -1h, -7d)">
|
|
@@ -50,6 +53,7 @@
|
|
|
50
53
|
<p>Uses <code>POST /objects/history</code>. Time values support both ISO 8601 and
|
|
51
54
|
relative notation: <code>-30s</code> (seconds), <code>-5m</code> (minutes), <code>-1h</code> (hours),
|
|
52
55
|
<code>-7d</code> (days), <code>-1w</code> (weeks).</p>
|
|
56
|
+
<p>Use the <b>Browse</b> button to visually select elements from the server.</p>
|
|
53
57
|
</script>
|
|
54
58
|
|
|
55
59
|
<script type="text/javascript">
|
|
@@ -71,5 +75,15 @@
|
|
|
71
75
|
label: function () {
|
|
72
76
|
return this.name || this.elementIds || "i3x history";
|
|
73
77
|
},
|
|
78
|
+
oneditprepare: function () {
|
|
79
|
+
if (window.I3XBrowser) {
|
|
80
|
+
this._browser = I3XBrowser.create({
|
|
81
|
+
container: "#i3x-history-browser",
|
|
82
|
+
serverField: "#node-input-server",
|
|
83
|
+
targetField: "#node-input-elementIds",
|
|
84
|
+
mode: "multi",
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
},
|
|
74
88
|
});
|
|
75
89
|
</script>
|
package/nodes/i3x-history.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
"use strict";
|
|
5
5
|
|
|
6
|
-
const { bindServer, parseIds, safeSend } = require("../lib/node-utils");
|
|
6
|
+
const { bindServer, parseIds, safeSend, statusError, clampMaxDepth } = require("../lib/node-utils");
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* Resolve relative time strings like "-1h", "-7d", "-30m" to ISO 8601.
|
|
@@ -28,8 +28,7 @@ module.exports = function (RED) {
|
|
|
28
28
|
node.elementIds = config.elementIds || "";
|
|
29
29
|
node.startTime = config.startTime || "";
|
|
30
30
|
node.endTime = config.endTime || "";
|
|
31
|
-
node.maxDepth =
|
|
32
|
-
if (isNaN(node.maxDepth)) node.maxDepth = 1;
|
|
31
|
+
node.maxDepth = clampMaxDepth(config.maxDepth);
|
|
33
32
|
|
|
34
33
|
if (!bindServer(node, RED, config.server)) return;
|
|
35
34
|
|
|
@@ -46,18 +45,19 @@ module.exports = function (RED) {
|
|
|
46
45
|
|
|
47
46
|
const startTime = resolveTime(msg.startTime || node.startTime);
|
|
48
47
|
const endTime = resolveTime(msg.endTime || node.endTime);
|
|
49
|
-
const maxDepth = msg.maxDepth !== undefined ?
|
|
48
|
+
const maxDepth = msg.maxDepth !== undefined ? clampMaxDepth(msg.maxDepth) : node.maxDepth;
|
|
50
49
|
|
|
51
50
|
node.status({ fill: "blue", shape: "dot", text: "querying..." });
|
|
52
51
|
|
|
53
52
|
try {
|
|
54
53
|
const result = await client.readHistory(ids, { startTime, endTime, maxDepth });
|
|
55
54
|
msg.payload = result;
|
|
56
|
-
|
|
55
|
+
const count = Array.isArray(result) ? result.length : 1;
|
|
56
|
+
node.status({ fill: "green", shape: "dot", text: count + " record" + (count !== 1 ? "s" : "") });
|
|
57
57
|
send(msg);
|
|
58
58
|
if (done) done();
|
|
59
59
|
} catch (err) {
|
|
60
|
-
node.status({ fill: "red", shape: "ring", text: err.message
|
|
60
|
+
node.status({ fill: "red", shape: "ring", text: statusError(err.message) });
|
|
61
61
|
if (done) done(err); else node.error(err, msg);
|
|
62
62
|
}
|
|
63
63
|
});
|
package/nodes/i3x-read.html
CHANGED
|
@@ -11,6 +11,9 @@
|
|
|
11
11
|
<label for="node-input-elementIds"><i class="fa fa-list"></i> Element IDs</label>
|
|
12
12
|
<input type="text" id="node-input-elementIds" placeholder="comma-separated element IDs">
|
|
13
13
|
</div>
|
|
14
|
+
<div class="form-row">
|
|
15
|
+
<div id="i3x-read-browser"></div>
|
|
16
|
+
</div>
|
|
14
17
|
<div class="form-row">
|
|
15
18
|
<label for="node-input-maxDepth"><i class="fa fa-level-down"></i> Max Depth</label>
|
|
16
19
|
<input type="number" id="node-input-maxDepth" placeholder="1" min="0" step="1">
|
|
@@ -37,6 +40,8 @@
|
|
|
37
40
|
<h3>Details</h3>
|
|
38
41
|
<p>Uses <code>POST /objects/value</code>. The <code>maxDepth</code> parameter controls whether
|
|
39
42
|
child component values are included recursively.</p>
|
|
43
|
+
<p>Use the <b>Browse</b> button to visually select elements from the server,
|
|
44
|
+
or provide element IDs via <code>msg.elementIds</code> at runtime.</p>
|
|
40
45
|
</script>
|
|
41
46
|
|
|
42
47
|
<script type="text/javascript">
|
|
@@ -56,5 +61,15 @@
|
|
|
56
61
|
label: function () {
|
|
57
62
|
return this.name || this.elementIds || "i3x read";
|
|
58
63
|
},
|
|
64
|
+
oneditprepare: function () {
|
|
65
|
+
if (window.I3XBrowser) {
|
|
66
|
+
this._browser = I3XBrowser.create({
|
|
67
|
+
container: "#i3x-read-browser",
|
|
68
|
+
serverField: "#node-input-server",
|
|
69
|
+
targetField: "#node-input-elementIds",
|
|
70
|
+
mode: "multi",
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
},
|
|
59
74
|
});
|
|
60
75
|
</script>
|
package/nodes/i3x-read.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
"use strict";
|
|
5
5
|
|
|
6
|
-
const { bindServer, parseIds, safeSend } = require("../lib/node-utils");
|
|
6
|
+
const { bindServer, parseIds, safeSend, statusError, clampMaxDepth } = require("../lib/node-utils");
|
|
7
7
|
|
|
8
8
|
module.exports = function (RED) {
|
|
9
9
|
function I3XReadNode(config) {
|
|
@@ -11,8 +11,7 @@ module.exports = function (RED) {
|
|
|
11
11
|
const node = this;
|
|
12
12
|
|
|
13
13
|
node.elementIds = config.elementIds || "";
|
|
14
|
-
node.maxDepth =
|
|
15
|
-
if (isNaN(node.maxDepth)) node.maxDepth = 1;
|
|
14
|
+
node.maxDepth = clampMaxDepth(config.maxDepth);
|
|
16
15
|
|
|
17
16
|
if (!bindServer(node, RED, config.server)) return;
|
|
18
17
|
|
|
@@ -27,18 +26,19 @@ module.exports = function (RED) {
|
|
|
27
26
|
return;
|
|
28
27
|
}
|
|
29
28
|
|
|
30
|
-
const maxDepth = msg.maxDepth !== undefined ?
|
|
29
|
+
const maxDepth = msg.maxDepth !== undefined ? clampMaxDepth(msg.maxDepth) : node.maxDepth;
|
|
31
30
|
|
|
32
31
|
node.status({ fill: "blue", shape: "dot", text: "requesting..." });
|
|
33
32
|
|
|
34
33
|
try {
|
|
35
34
|
const result = await client.readValues(ids, { maxDepth });
|
|
36
35
|
msg.payload = result;
|
|
37
|
-
|
|
36
|
+
const count = Array.isArray(result) ? result.length : 1;
|
|
37
|
+
node.status({ fill: "green", shape: "dot", text: count + " value" + (count !== 1 ? "s" : "") });
|
|
38
38
|
send(msg);
|
|
39
39
|
if (done) done();
|
|
40
40
|
} catch (err) {
|
|
41
|
-
node.status({ fill: "red", shape: "ring", text: err.message
|
|
41
|
+
node.status({ fill: "red", shape: "ring", text: statusError(err.message) });
|
|
42
42
|
if (done) done(err); else node.error(err, msg);
|
|
43
43
|
}
|
|
44
44
|
});
|