@vitormnm/node-red-simple-opcua 1.3.1 → 1.4.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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @vitormnm/node-red-simple-opcua
2
2
  OPC UA client and server with a simple graphical interface for Node-RED.
3
- fully parameterized via JSON.
3
+ Fully parameterized in JSON.
4
4
 
5
5
  It supports the following OPC UA items on only 3 nodes.
6
6
 
@@ -9,6 +9,8 @@ It supports the following OPC UA items on only 3 nodes.
9
9
  - events read and write tags in server(See which tags are being written to or read from the client directly on the server in a simple workflow)
10
10
  - methods(write methods in node-red flow)
11
11
  - variables
12
+ - variables arrays
13
+ - description and displayname nodes
12
14
  - objects
13
15
  - simple objectsType
14
16
  - custom namespace
@@ -21,8 +23,9 @@ It supports the following OPC UA items on only 3 nodes.
21
23
  **Client editor**
22
24
  ![node-red-si](/resources/editorClient.PNG)
23
25
 
24
- example json server config
25
26
 
27
+
28
+ example json server config
26
29
  ```
27
30
  {
28
31
  "objects": [],
@@ -124,5 +127,6 @@ example json server config
124
127
  ]
125
128
  }
126
129
  ```
127
-
130
+ Disclaimer
131
+ This node was only used in simulation and testing environments.
128
132
 
@@ -24,17 +24,56 @@ async function browseNode(session, root) {
24
24
  resultMask: 63
25
25
  });
26
26
 
27
- const references = browseResult && Array.isArray(browseResult.references)
28
- ? browseResult.references
29
- : [];
27
+ const references = browseResult?.references ?? [];
28
+ if (!references.length) return result;
29
+
30
+ // Monta lista de todos os atributos de todos os nós de uma vez
31
+ const nodeIds = references.map(ref => normalizeNodeId(ref.nodeId));
32
+ const attributesToRead = nodeIds.flatMap(nodeId => [
33
+ { nodeId, attributeId: AttributeIds.Description },
34
+ { nodeId, attributeId: AttributeIds.DataType },
35
+ { nodeId, attributeId: AttributeIds.Value },
36
+ ]);
37
+
38
+ // UMA única chamada para todos os nós e atributos
39
+ const dataValues = await session.read(attributesToRead);
40
+
41
+ // Distribui os resultados por nó (3 atributos por nó)
42
+ result.browse = await Promise.all(references.map(async (reference, i) => {
43
+ const childNodeId = nodeIds[i];
44
+ const nodeClass = resolveNodeClassName(reference.nodeClass);
45
+ const browseName = extractBrowseName(reference.browseName, childNodeId);
46
+ const displayName = extractDisplayName(reference.displayName, browseName);
47
+
48
+ const descValue = dataValues[i * 3]?.value?.value;
49
+ const description = typeof descValue === "string"
50
+ ? descValue
51
+ : (descValue?.text ?? "");
52
+
53
+ const item = { nodeID: childNodeId, nodeClass, browseName, displayName, description };
54
+
55
+ if (nodeClass === "Variable") {
56
+ const dataTypeValue = dataValues[i * 3 + 1]?.value?.value;
57
+ const rawValue = dataValues[i * 3 + 2]?.value?.value;
58
+
59
+ item.dataType = dataTypeValue?.namespace === 0 && typeof dataTypeValue?.value === "number"
60
+ ? (DataType[dataTypeValue.value] || dataTypeValue.toString())
61
+ : (dataTypeValue?.toString() ?? "");
62
+
63
+ item.value = rawValue ?? "";
64
+ }
30
65
 
31
- for (const reference of references) {
32
- result.browse.push(await mapReference(session, reference));
33
- }
66
+ if (nodeClass === "Method") {
67
+ const definition = await readMethodArguments(session, childNodeId);
68
+ item.inputArguments = definition.inputArguments;
69
+ item.outputArguments = definition.outputArguments;
70
+ }
71
+
72
+ return item;
73
+ }));
34
74
 
35
75
  return result;
36
76
  }
37
-
38
77
  function normalizeBrowseRoots(payload) {
39
78
  if (payload === undefined || payload === null) {
40
79
  return [{ name: "RootFolder", nodeID: ROOT_NODE_ID }];
@@ -1,53 +1,147 @@
1
1
  "use strict";
2
2
 
3
- const { DataType, coerceNodeId } = require("node-opcua");
3
+ const { AttributeIds, DataType, coerceNodeId } = require("node-opcua");
4
4
  const {
5
5
  buildVariantFromItem,
6
- dataValueToItemResult,
7
6
  normalizeTypeName,
8
7
  resolveNodeId,
9
8
  statusCodeToString
10
9
  } = require("../opcua-client-utils");
11
10
 
11
+ // Máximo de tags por chamada session.write (ajuste conforme limite do servidor)
12
+ const WRITE_BATCH_SIZE = 100;
13
+
14
+ // Batches em paralelo simultâneos
15
+ const CONCURRENCY = 5;
16
+
17
+ // Cede o event loop a cada N itens para não travar o Node-RED
18
+ const YIELD_EVERY = 50;
19
+
12
20
  class OpcUaClientWriteService {
13
21
  async execute(node, msg, session, itemsResolver) {
14
22
  const items = itemsResolver.ensureWriteItems(node, msg);
15
- const results = [];
16
-
17
- for (const item of items) {
18
- const nodeId = resolveNodeId(item);
19
-
20
- try {
21
- const explicitType = normalizeTypeName(item.type);
22
- const builtInType = await session.getBuiltInDataType(coerceNodeId(nodeId));
23
- const typeName = explicitType || DataType[builtInType];
24
- const variant = buildVariantFromItem(item, typeName);
25
- const statusCode = await session.writeSingleNode(nodeId, variant);
26
- const dataValue = await session.readVariableValue(nodeId);
27
- const result = dataValueToItemResult(item, dataValue);
28
-
29
- if (statusCode && statusCode.name && statusCode.name !== "Good") {
30
- result.status = statusCodeToString(statusCode);
31
- }
32
-
33
- results.push(result);
34
- } catch (itemError) {
35
- results.push({
36
- name: item.name || nodeId,
37
- nodeID: nodeId,
38
- value: item.value,
39
- type: normalizeTypeName(item.type) || null,
40
- status: itemError.message,
41
- sourceTimestamp: null,
42
- serverTimestamp: null
43
- });
23
+
24
+ // 1. Resolve variantes (tipo + valor) — consulta servidor só para quem não tem tipo explícito
25
+ const variants = await resolveVariants(session, items);
26
+
27
+ // 2. Escreve todos os nós em batches paralelos
28
+ const statusCodes = await writeBatches(session, items, variants);
29
+
30
+ // 3. Monta resultados — statusCode Good já confirma a escrita, sem round-trip extra
31
+ return buildResults(items, variants, statusCodes);
32
+ }
33
+ }
34
+
35
+ // ─── Resolução de tipos ──────────────────────────────────────────────────────
36
+
37
+ async function resolveVariants(session, items) {
38
+ // Separa quais itens precisam consultar o tipo no servidor
39
+ const needsLookup = items
40
+ .map((item, index) => ({ item, index }))
41
+ .filter(({ item }) => !normalizeTypeName(item.type));
42
+
43
+ // Busca tipos desconhecidos em paralelo
44
+ const resolvedTypes = new Map();
45
+
46
+ await mapConcurrent(needsLookup, CONCURRENCY * 2, async ({ item, index }) => {
47
+ try {
48
+ const builtInType = await session.getBuiltInDataType(coerceNodeId(resolveNodeId(item)));
49
+ resolvedTypes.set(index, DataType[builtInType]);
50
+ } catch {
51
+ resolvedTypes.set(index, "String");
52
+ }
53
+ });
54
+
55
+ return items.map((item, index) => {
56
+ const typeName = normalizeTypeName(item.type) || resolvedTypes.get(index) || "String";
57
+ return buildVariantFromItem(item, typeName);
58
+ });
59
+ }
60
+
61
+ // ─── Escrita em batches paralelos ────────────────────────────────────────────
62
+
63
+ async function writeBatches(session, items, variants) {
64
+ const allStatusCodes = new Array(items.length);
65
+
66
+ // Divide em batches de WRITE_BATCH_SIZE
67
+ const batches = [];
68
+ for (let i = 0; i < items.length; i += WRITE_BATCH_SIZE) {
69
+ batches.push({ start: i, end: Math.min(i + WRITE_BATCH_SIZE, items.length) });
70
+ }
71
+
72
+ let processed = 0;
73
+
74
+ await mapConcurrent(batches, CONCURRENCY, async ({ start, end }) => {
75
+ const nodesToWrite = items.slice(start, end).map((item, i) => ({
76
+ nodeId: coerceNodeId(resolveNodeId(item)),
77
+ attributeId: AttributeIds.Value,
78
+ value: { value: variants[start + i] }
79
+ }));
80
+
81
+ try {
82
+ const statusCodes = await session.write(nodesToWrite);
83
+ statusCodes.forEach((sc, i) => {
84
+ allStatusCodes[start + i] = sc;
85
+ });
86
+ } catch (batchError) {
87
+ // Se o batch falhar por completo, marca todos com erro
88
+ for (let i = start; i < end; i++) {
89
+ allStatusCodes[i] = { name: batchError.message, value: -1 };
44
90
  }
45
91
  }
46
92
 
47
- return results;
93
+ // Cede o event loop a cada YIELD_EVERY itens para não travar o Node-RED
94
+ processed += end - start;
95
+ if (processed % YIELD_EVERY === 0) {
96
+ await yieldEventLoop();
97
+ }
98
+ });
99
+
100
+ return allStatusCodes;
101
+ }
102
+
103
+ // ─── Montagem dos resultados ─────────────────────────────────────────────────
104
+
105
+ function buildResults(items, variants, statusCodes) {
106
+ return items.map((item, index) => {
107
+ const nodeId = resolveNodeId(item);
108
+ const sc = statusCodes[index];
109
+ const scName = sc && sc.name ? sc.name : "Good";
110
+ const typeName = DataType[variants[index].dataType] || null;
111
+
112
+ return {
113
+ name: item.name || nodeId,
114
+ nodeID: nodeId,
115
+ value: variants[index].value,
116
+ type: typeName,
117
+ status: scName,
118
+ sourceTimestamp: null,
119
+ serverTimestamp: null
120
+ };
121
+ });
122
+ }
123
+
124
+ // ─── Utilitários ─────────────────────────────────────────────────────────────
125
+
126
+ async function mapConcurrent(items, concurrency, fn) {
127
+ let index = 0;
128
+
129
+ async function worker() {
130
+ while (index < items.length) {
131
+ const i = index++;
132
+ await fn(items[i], i);
133
+ }
48
134
  }
135
+
136
+ await Promise.all(
137
+ Array.from({ length: Math.min(concurrency, items.length) }, worker)
138
+ );
139
+ }
140
+
141
+ function yieldEventLoop() {
142
+ return new Promise(resolve => setImmediate(resolve));
49
143
  }
50
144
 
51
145
  module.exports = {
52
146
  OpcUaClientWriteService
53
- };
147
+ };
@@ -245,12 +245,30 @@
245
245
  <script type="text/javascript">
246
246
  (function () {
247
247
  var selectedItemsState = [];
248
+ var selectedNodeIdSet = {}; // fast O(1) lookup: nodeId → index
248
249
  var browseState = null;
249
250
  var expansionState = {};
250
251
  var browseSearchValue = "";
251
252
  var browseSearchTerm = "";
252
253
  var contextMenuPath = "";
253
254
  var browseSelectedPath = "";
255
+ var renderPending = false; // debounce flag for renderBrowseTree
256
+
257
+ function debounce(fn, delay) {
258
+ var timer;
259
+ return function () {
260
+ var ctx = this, args = arguments;
261
+ clearTimeout(timer);
262
+ timer = setTimeout(function () { fn.apply(ctx, args); }, delay);
263
+ };
264
+ }
265
+
266
+ function rebuildNodeIdIndex() {
267
+ selectedNodeIdSet = {};
268
+ selectedItemsState.forEach(function (item, i) {
269
+ if (item.nodeID) selectedNodeIdSet[item.nodeID] = i;
270
+ });
271
+ }
254
272
 
255
273
  function openBrowseModal() { $("#node-input-browse-modal").show(); $("body").addClass("opcua-tree-modal-open"); }
256
274
  function closeBrowseModal() {
@@ -456,6 +474,7 @@
456
474
  }
457
475
 
458
476
  function syncSelectedItems() {
477
+ rebuildNodeIdIndex();
459
478
  updateSelectedItemsField();
460
479
  renderSelectedTags();
461
480
  renderBrowseTree();
@@ -470,9 +489,8 @@
470
489
  }
471
490
 
472
491
  function selectedIndexByNodeId(nodeId) {
473
- return selectedItemsState.findIndex(function (item) {
474
- return item.nodeID === nodeId;
475
- });
492
+ var idx = selectedNodeIdSet[nodeId];
493
+ return (idx !== undefined) ? idx : -1;
476
494
  }
477
495
 
478
496
  function canExpand(item) {
@@ -501,24 +519,47 @@
501
519
  }
502
520
 
503
521
  function renderBrowseTree() {
522
+ if (renderPending) return;
523
+ renderPending = true;
524
+ setTimeout(function () {
525
+ renderPending = false;
526
+ _doRenderBrowseTree();
527
+ }, 0);
528
+ }
529
+
530
+ function _doRenderBrowseTree() {
504
531
  var container = $("#node-input-browse-tree");
505
- container.empty();
532
+ var frag = document.createDocumentFragment();
506
533
 
507
534
  if (!browseState) {
508
- container.append('<div class="opcua-tree-empty">Click Browse to load the server tree.</div>');
535
+ var empty = document.createElement("div");
536
+ empty.className = "opcua-tree-empty";
537
+ empty.textContent = "Click Browse to load the server tree.";
538
+ frag.appendChild(empty);
539
+ container[0].innerHTML = "";
540
+ container[0].appendChild(frag);
509
541
  return;
510
542
  }
511
543
 
512
544
  if (browseSearchTerm) {
513
545
  if (!branchHasSearchMatch(browseState, browseSearchTerm)) {
514
- container.append('<div class="opcua-tree-empty">No items found in the already explored items.</div>');
546
+ var noMatch = document.createElement("div");
547
+ noMatch.className = "opcua-tree-empty";
548
+ noMatch.textContent = "No items found in the already explored items.";
549
+ frag.appendChild(noMatch);
550
+ container[0].innerHTML = "";
551
+ container[0].appendChild(frag);
515
552
  return;
516
553
  }
517
- renderBrowseRootFiltered(browseState, "root", 0, container, browseSearchTerm);
554
+ renderBrowseRootFiltered(browseState, "root", 0, frag, browseSearchTerm);
555
+ container[0].innerHTML = "";
556
+ container[0].appendChild(frag);
518
557
  return;
519
558
  }
520
559
 
521
- renderBrowseRoot(browseState, "root", 0, container);
560
+ renderBrowseRoot(browseState, "root", 0, frag);
561
+ container[0].innerHTML = "";
562
+ container[0].appendChild(frag);
522
563
  }
523
564
 
524
565
  function isFolderNode(item) {
@@ -549,92 +590,111 @@
549
590
  return "fa-tag";
550
591
  }
551
592
 
552
- function renderBrowseRoot(root, path, depth, container) {
593
+ function makeEl(tag, className, html) {
594
+ var el = document.createElement(tag);
595
+ if (className) el.className = className;
596
+ if (html !== undefined) el.innerHTML = html;
597
+ return el;
598
+ }
599
+
600
+ function makeTreeRow(path, extraClass) {
601
+ var row = document.createElement("div");
602
+ row.className = "opcua-tree-row" + (extraClass ? " " + extraClass : "");
603
+ row.setAttribute("data-path", path);
604
+ return row;
605
+ }
606
+
607
+ function renderBrowseRoot(root, path, depth, frag) {
553
608
  var expanded = isExpanded(path, true);
554
- var row = $('<div class="opcua-tree-row"></div>').attr("data-path", path);
555
- row.append('<span class="opcua-tree-indent"></span>');
556
- row.append('<span class="opcua-tree-twisty opcua-client-toggle-tree" data-path="' + path + '">' + (expanded ? '<i class="fa fa-caret-down"></i>' : '<i class="fa fa-caret-right"></i>') + '</span>');
557
- row.append('<span class="opcua-tree-icon"><i class="fa fa-sitemap"></i></span>');
558
- row.append('<span class="opcua-tree-label">' + escapeHtml(root.name || root.nodeID || "RootFolder") + '</span>');
559
- row.append('<span class="opcua-tree-type">' + escapeHtml(root.nodeID || "") + '</span>');
560
- container.append(row);
609
+ var row = makeTreeRow(path);
610
+ row.innerHTML = '<span class="opcua-tree-indent"></span>'
611
+ + '<span class="opcua-tree-twisty opcua-client-toggle-tree" data-path="' + escapeHtml(path) + '">'
612
+ + (expanded ? '<i class="fa fa-caret-down"></i>' : '<i class="fa fa-caret-right"></i>') + '</span>'
613
+ + '<span class="opcua-tree-icon"><i class="fa fa-sitemap"></i></span>'
614
+ + '<span class="opcua-tree-label">' + escapeHtml(root.name || root.nodeID || "RootFolder") + '</span>'
615
+ + '<span class="opcua-tree-type">' + escapeHtml(root.nodeID || "") + '</span>';
616
+ frag.appendChild(row);
561
617
 
562
618
  if (!expanded) return;
563
619
 
564
620
  if (!Array.isArray(root.browse) || !root.browse.length) {
565
- container.append('<div class="opcua-tree-empty">No items found..</div>');
621
+ frag.appendChild(makeEl("div", "opcua-tree-empty", "No items found.."));
566
622
  } else {
567
623
  root.browse.forEach(function (item, index) {
568
- renderBrowseItem(item, path + ".browse." + index, depth + 1, container);
624
+ renderBrowseItem(item, path + ".browse." + index, depth + 1, frag);
569
625
  });
570
626
  }
571
627
  }
572
628
 
573
- function renderBrowseItem(item, path, depth, container) {
629
+ function renderBrowseItem(item, path, depth, frag) {
574
630
  var expanded = isExpanded(path, false);
575
631
  var nodeId = nodeIdOf(item);
576
632
  var selectedIndex = selectedIndexByNodeId(nodeId);
577
633
  var hasChildren = canExpand(item);
578
- var row = $('<div class="opcua-tree-row"></div>').attr("data-path", path);
579
- if (selectedIndex >= 0) row.addClass("is-selected");
634
+ var row = makeTreeRow(path, selectedIndex >= 0 ? "is-selected" : "");
580
635
 
581
- for (var i = 0; i < depth; i += 1) {
582
- row.append('<span class="opcua-tree-indent"></span>');
583
- }
584
- row.append('<span class="opcua-tree-twisty' + (hasChildren ? ' opcua-client-toggle-tree' : '') + '" data-path="' + path + '">' + (hasChildren ? '<i class="fa ' + (expanded ? 'fa-caret-down' : 'fa-caret-right') + '"></i>' : '') + '</span>');
585
- row.append('<span class="opcua-tree-icon"><i class="fa ' + browseIconFor(item) + '"></i></span>');
586
- row.append('<span class="opcua-tree-label">' + escapeHtml(item.displayName || item.browseName || item.nodeID) + '</span>');
587
- row.append('<span class="opcua-tree-type">' + escapeHtml(item.nodeClass || "") + (item.dataType ? " | " + escapeHtml(item.dataType) : "") + '</span>');
588
- row.append('<span class="opcua-client-nodeid-label">' + escapeHtml(nodeId) + '</span>');
589
-
590
- if (nodeId) {
591
- var actionLabel = selectedIndex >= 0 ? "Remove" : "Add";
592
- var actionIcon = selectedIndex >= 0 ? "fa-minus" : "fa-plus";
593
- row.append('<div class="opcua-tree-actions"><a href="#" class="editor-button editor-button-small opcua-client-toggle-tag" data-nodeid="' + escapeHtml(nodeId) + '" data-path="' + path + '"><i class="fa ' + actionIcon + '"></i> ' + actionLabel + '</a></div>');
594
- }
595
- container.append(row);
636
+ var indents = "";
637
+ for (var i = 0; i < depth; i += 1) indents += '<span class="opcua-tree-indent"></span>';
638
+
639
+ var twisty = '<span class="opcua-tree-twisty' + (hasChildren ? ' opcua-client-toggle-tree' : '') + '" data-path="' + escapeHtml(path) + '">'
640
+ + (hasChildren ? '<i class="fa ' + (expanded ? 'fa-caret-down' : 'fa-caret-right') + '"></i>' : '') + '</span>';
641
+
642
+ var actions = nodeId
643
+ ? '<div class="opcua-tree-actions"><a href="#" class="editor-button editor-button-small opcua-client-toggle-tag" data-nodeid="' + escapeHtml(nodeId) + '" data-path="' + escapeHtml(path) + '"><i class="fa ' + (selectedIndex >= 0 ? 'fa-minus' : 'fa-plus') + '"></i> ' + (selectedIndex >= 0 ? 'Remove' : 'Add') + '</a></div>'
644
+ : '';
645
+
646
+ row.innerHTML = indents + twisty
647
+ + '<span class="opcua-tree-icon"><i class="fa ' + browseIconFor(item) + '"></i></span>'
648
+ + '<span class="opcua-tree-label">' + escapeHtml(item.displayName || item.browseName || item.nodeID) + '</span>'
649
+ + '<span class="opcua-tree-type">' + escapeHtml(item.nodeClass || "") + (item.dataType ? " | " + escapeHtml(item.dataType) : "") + '</span>'
650
+ + '<span class="opcua-client-nodeid-label">' + escapeHtml(nodeId) + '</span>'
651
+ + actions;
652
+ frag.appendChild(row);
596
653
 
597
654
  if (item.description) {
598
- container.append('<div class="opcua-client-description" style="padding: 0 10px 8px ' + String((depth + 2) * 14) + 'px;">' + escapeHtml(item.description) + '</div>');
655
+ var desc = makeEl("div", "opcua-client-description");
656
+ desc.style.padding = "0 10px 8px " + String((depth + 2) * 14) + "px";
657
+ desc.textContent = item.description;
658
+ frag.appendChild(desc);
599
659
  }
600
660
 
601
661
  if (expanded && hasChildren) {
602
662
  if (Array.isArray(item.browse)) {
603
663
  if (!item.browse.length) {
604
- container.append('<div class="opcua-tree-empty">No children found..</div>');
664
+ frag.appendChild(makeEl("div", "opcua-tree-empty", "No children found.."));
605
665
  } else {
606
666
  item.browse.forEach(function (child, index) {
607
- renderBrowseItem(child, path + ".browse." + index, depth + 1, container);
667
+ renderBrowseItem(child, path + ".browse." + index, depth + 1, frag);
608
668
  });
609
669
  }
610
670
  } else {
611
- container.append('<div class="opcua-tree-empty">Searching for items...</div>');
671
+ frag.appendChild(makeEl("div", "opcua-tree-empty", "Searching for items..."));
612
672
  }
613
673
  }
614
674
  }
615
675
 
616
- function renderBrowseRootFiltered(root, path, depth, container, term) {
617
- var row = $('<div class="opcua-tree-row"></div>').attr("data-path", path);
618
- row.append('<span class="opcua-tree-indent"></span>');
619
- row.append('<span class="opcua-tree-twisty"><i class="fa fa-caret-down"></i></span>');
620
- row.append('<span class="opcua-tree-icon"><i class="fa fa-sitemap"></i></span>');
621
- row.append('<span class="opcua-tree-label">' + escapeHtml(root.name || root.nodeID || "RootFolder") + '</span>');
622
- row.append('<span class="opcua-tree-type">' + escapeHtml(root.nodeID || "") + '</span>');
623
- container.append(row);
676
+ function renderBrowseRootFiltered(root, path, depth, frag, term) {
677
+ var row = makeTreeRow(path);
678
+ row.innerHTML = '<span class="opcua-tree-indent"></span>'
679
+ + '<span class="opcua-tree-twisty"><i class="fa fa-caret-down"></i></span>'
680
+ + '<span class="opcua-tree-icon"><i class="fa fa-sitemap"></i></span>'
681
+ + '<span class="opcua-tree-label">' + escapeHtml(root.name || root.nodeID || "RootFolder") + '</span>'
682
+ + '<span class="opcua-tree-type">' + escapeHtml(root.nodeID || "") + '</span>';
683
+ frag.appendChild(row);
624
684
 
625
685
  if (!Array.isArray(root.browse) || !root.browse.length) {
626
- container.append('<div class="opcua-tree-empty">Nenhum item encontrado.</div>');
686
+ frag.appendChild(makeEl("div", "opcua-tree-empty", "Nenhum item encontrado."));
627
687
  return;
628
688
  }
629
689
 
630
690
  root.browse.forEach(function (item, index) {
631
691
  if (branchHasSearchMatch(item, term)) {
632
- renderBrowseItemFiltered(item, path + ".browse." + index, depth + 1, container, term, false);
692
+ renderBrowseItemFiltered(item, path + ".browse." + index, depth + 1, frag, term, false);
633
693
  }
634
694
  });
635
695
  }
636
696
 
637
- function renderBrowseItemFiltered(item, path, depth, container, term, ancestorMatched) {
697
+ function renderBrowseItemFiltered(item, path, depth, frag, term, ancestorMatched) {
638
698
  if (!branchHasSearchMatch(item, term)) return;
639
699
 
640
700
  var nodeId = nodeIdOf(item);
@@ -648,40 +708,47 @@
648
708
  var expanded = hasChildren && (hasExplicitExpansion
649
709
  ? !!expansionState[path]
650
710
  : ((subtreeVisible && Array.isArray(item.browse)) || hasMatchingLoadedChild));
651
- var row = $('<div class="opcua-tree-row"></div>').attr("data-path", path);
652
- if (selectedIndex >= 0) row.addClass("is-selected");
653
-
654
- for (var i = 0; i < depth; i += 1) row.append('<span class="opcua-tree-indent"></span>');
655
- row.append('<span class="opcua-tree-twisty' + (hasChildren ? ' opcua-client-toggle-tree' : '') + '" data-path="' + path + '">' + (hasChildren ? '<i class="fa ' + (expanded ? 'fa-caret-down' : 'fa-caret-right') + '"></i>' : '') + '</span>');
656
- row.append('<span class="opcua-tree-icon"><i class="fa ' + browseIconFor(item) + '"></i></span>');
657
- row.append('<span class="opcua-tree-label">' + escapeHtml(item.displayName || item.browseName || item.nodeID) + '</span>');
658
- row.append('<span class="opcua-tree-type">' + escapeHtml(item.nodeClass || "") + (item.dataType ? " | " + escapeHtml(item.dataType) : "") + '</span>');
659
- row.append('<span class="opcua-client-nodeid-label">' + escapeHtml(nodeId) + '</span>');
660
-
661
- if (nodeId) {
662
- var actionLabel = selectedIndex >= 0 ? "Remove" : "Add";
663
- var actionIcon = selectedIndex >= 0 ? "fa-minus" : "fa-plus";
664
- row.append('<div class="opcua-tree-actions"><a href="#" class="editor-button editor-button-small opcua-client-toggle-tag" data-nodeid="' + escapeHtml(nodeId) + '" data-path="' + path + '"><i class="fa ' + actionIcon + '"></i> ' + actionLabel + '</a></div>');
665
- }
666
- container.append(row);
711
+
712
+ var row = makeTreeRow(path, selectedIndex >= 0 ? "is-selected" : "");
713
+
714
+ var indents = "";
715
+ for (var i = 0; i < depth; i += 1) indents += '<span class="opcua-tree-indent"></span>';
716
+
717
+ var twisty = '<span class="opcua-tree-twisty' + (hasChildren ? ' opcua-client-toggle-tree' : '') + '" data-path="' + escapeHtml(path) + '">'
718
+ + (hasChildren ? '<i class="fa ' + (expanded ? 'fa-caret-down' : 'fa-caret-right') + '"></i>' : '') + '</span>';
719
+
720
+ var actions = nodeId
721
+ ? '<div class="opcua-tree-actions"><a href="#" class="editor-button editor-button-small opcua-client-toggle-tag" data-nodeid="' + escapeHtml(nodeId) + '" data-path="' + escapeHtml(path) + '"><i class="fa ' + (selectedIndex >= 0 ? 'fa-minus' : 'fa-plus') + '"></i> ' + (selectedIndex >= 0 ? 'Remove' : 'Add') + '</a></div>'
722
+ : '';
723
+
724
+ row.innerHTML = indents + twisty
725
+ + '<span class="opcua-tree-icon"><i class="fa ' + browseIconFor(item) + '"></i></span>'
726
+ + '<span class="opcua-tree-label">' + escapeHtml(item.displayName || item.browseName || item.nodeID) + '</span>'
727
+ + '<span class="opcua-tree-type">' + escapeHtml(item.nodeClass || "") + (item.dataType ? " | " + escapeHtml(item.dataType) : "") + '</span>'
728
+ + '<span class="opcua-client-nodeid-label">' + escapeHtml(nodeId) + '</span>'
729
+ + actions;
730
+ frag.appendChild(row);
667
731
 
668
732
  if (item.description) {
669
- container.append('<div class="opcua-client-description" style="padding: 0 10px 8px ' + String((depth + 2) * 14) + 'px;">' + escapeHtml(item.description) + '</div>');
733
+ var desc = makeEl("div", "opcua-client-description");
734
+ desc.style.padding = "0 10px 8px " + String((depth + 2) * 14) + "px";
735
+ desc.textContent = item.description;
736
+ frag.appendChild(desc);
670
737
  }
671
738
 
672
739
  if (expanded && hasChildren) {
673
740
  if (Array.isArray(item.browse)) {
674
741
  if (!item.browse.length) {
675
- container.append('<div class="opcua-tree-empty">Nenhum filho encontrado.</div>');
742
+ frag.appendChild(makeEl("div", "opcua-tree-empty", "Nenhum filho encontrado."));
676
743
  } else {
677
744
  item.browse.forEach(function (child, index) {
678
745
  if (subtreeVisible || branchHasSearchMatch(child, term)) {
679
- renderBrowseItemFiltered(child, path + ".browse." + index, depth + 1, container, term, subtreeVisible);
746
+ renderBrowseItemFiltered(child, path + ".browse." + index, depth + 1, frag, term, subtreeVisible);
680
747
  }
681
748
  });
682
749
  }
683
750
  } else {
684
- container.append('<div class="opcua-tree-empty">Expandindo...</div>');
751
+ frag.appendChild(makeEl("div", "opcua-tree-empty", "Expandindo..."));
685
752
  }
686
753
  }
687
754
  }
@@ -938,6 +1005,7 @@
938
1005
  });
939
1006
 
940
1007
  selectedItemsState = parseSelectedItems(this.selectedItems);
1008
+ rebuildNodeIdIndex();
941
1009
 
942
1010
  browseState = null;
943
1011
  expansionState = {};
@@ -967,12 +1035,12 @@
967
1035
  hideTreeContextMenu();
968
1036
  if (event.target === this) closeBrowseModal();
969
1037
  });
970
- $("#node-input-browse-search").off("input").on("input", function () {
1038
+ $("#node-input-browse-search").off("input").on("input", debounce(function () {
971
1039
  browseSearchValue = $(this).val();
972
1040
  browseSearchTerm = normalizeSearchTerm(browseSearchValue);
973
1041
  $("#node-input-browse-search-clear").toggle(!!browseSearchTerm);
974
1042
  renderBrowseTree();
975
- });
1043
+ }, 200));
976
1044
  $("#node-input-browse-search-clear").off("click").on("click", function (event) {
977
1045
  event.preventDefault();
978
1046
  browseSearchValue = "";
@@ -1222,4 +1290,4 @@
1222
1290
  ]</code></pre>
1223
1291
  <h3>Subscription</h3>
1224
1292
  <p>In subscription mode the node emits one message per value change.</p>
1225
- </script>
1293
+ </script>