node-red-contrib-event-calc 3.3.3 → 3.3.15
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 +127 -55
- package/examples/alarm-simulation-example.json +288 -0
- package/examples/change-detection-example.json +448 -0
- package/examples/isa88-batch-example.json +278 -0
- package/examples/opcua-alarm-example.json +516 -0
- package/examples/test-flows.json +723 -0
- package/examples/throughput-test.json +293 -0
- package/nodes/event-alarm.html +252 -0
- package/nodes/event-alarm.js +292 -0
- package/nodes/event-cache.js +84 -33
- package/nodes/event-calc.js +132 -77
- package/nodes/event-flatten.html +53 -0
- package/nodes/event-flatten.js +25 -0
- package/nodes/event-frame.html +192 -0
- package/nodes/event-frame.js +246 -0
- package/nodes/simple-frame.html +125 -0
- package/nodes/simple-frame.js +126 -0
- package/package.json +6 -2
- package/node-red-contrib-event-calc-3.3.2.tgz +0 -0
- package/nul +0 -0
- package/playwright.config.js +0 -22
- package/tests/external-trigger.spec.js +0 -141
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* event-alarm - Alarm management node with ISA-18.2 lifecycle
|
|
3
|
+
*
|
|
4
|
+
* Alarm states:
|
|
5
|
+
* UNACK_ALM - Active + Unacknowledged (just raised)
|
|
6
|
+
* ACK_ALM - Active + Acknowledged (operator acked, condition still true)
|
|
7
|
+
* UNACK_RTN - Inactive + Unacknowledged (condition cleared, not yet acked)
|
|
8
|
+
* NORM - Normal (fully resolved, removed from state)
|
|
9
|
+
*
|
|
10
|
+
* Lifecycle transitions:
|
|
11
|
+
* NORM → condition true → UNACK_ALM
|
|
12
|
+
* UNACK_ALM → ack → ACK_ALM
|
|
13
|
+
* ACK_ALM → condition false → NORM (resolved)
|
|
14
|
+
* UNACK_ALM → condition false → UNACK_RTN
|
|
15
|
+
* UNACK_RTN → ack → NORM (resolved)
|
|
16
|
+
*
|
|
17
|
+
* Outputs (one per state):
|
|
18
|
+
* 0: Raised (UNACK_ALM)
|
|
19
|
+
* 1: Acknowledged (ACK_ALM)
|
|
20
|
+
* 2: Cleared (UNACK_RTN)
|
|
21
|
+
* 3: Resolved (NORM - lifecycle complete)
|
|
22
|
+
*/
|
|
23
|
+
module.exports = function(RED) {
|
|
24
|
+
|
|
25
|
+
function EventAlarmNode(config) {
|
|
26
|
+
RED.nodes.createNode(this, config);
|
|
27
|
+
const node = this;
|
|
28
|
+
|
|
29
|
+
node.cacheConfig = RED.nodes.getNode(config.cache);
|
|
30
|
+
node.inputMappings = config.inputMappings || [];
|
|
31
|
+
node.condition = config.condition || '';
|
|
32
|
+
node.conditionId = config.conditionId || node.id;
|
|
33
|
+
node.conditionName = config.conditionName || 'Alarm';
|
|
34
|
+
node.severity = parseInt(config.severity) || 500;
|
|
35
|
+
node.outputTopic = config.outputTopic || 'alarm/event';
|
|
36
|
+
|
|
37
|
+
const subscriptionIds = [];
|
|
38
|
+
|
|
39
|
+
if (!node.cacheConfig) {
|
|
40
|
+
node.status({ fill: "red", shape: "ring", text: "no cache configured" });
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (node.inputMappings.length === 0) {
|
|
45
|
+
node.status({ fill: "yellow", shape: "ring", text: "no inputs defined" });
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (!node.condition) {
|
|
50
|
+
node.status({ fill: "yellow", shape: "ring", text: "no condition" });
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Alarm state dictionary: Map<source, alarmRecord>
|
|
55
|
+
// Stored in node context for persistence across restarts
|
|
56
|
+
const nodeContext = node.context();
|
|
57
|
+
let alarms = new Map();
|
|
58
|
+
|
|
59
|
+
// Restore from context
|
|
60
|
+
const stored = nodeContext.get('alarms');
|
|
61
|
+
if (stored && typeof stored === 'object') {
|
|
62
|
+
for (const [key, val] of Object.entries(stored)) {
|
|
63
|
+
alarms.set(key, val);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function saveAlarms() {
|
|
68
|
+
const obj = {};
|
|
69
|
+
for (const [key, val] of alarms) {
|
|
70
|
+
obj[key] = val;
|
|
71
|
+
}
|
|
72
|
+
nodeContext.set('alarms', obj);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Track latest values from subscriptions
|
|
76
|
+
const latestValues = new Map();
|
|
77
|
+
const inputNames = node.inputMappings.map(m => m.name);
|
|
78
|
+
|
|
79
|
+
// Compile condition expression
|
|
80
|
+
let compiledFn = null;
|
|
81
|
+
try {
|
|
82
|
+
compiledFn = new Function(...inputNames, `return !!(${node.condition});`);
|
|
83
|
+
} catch (err) {
|
|
84
|
+
node.status({ fill: "red", shape: "ring", text: "compile error" });
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function buildAlarmMsg(record) {
|
|
89
|
+
return {
|
|
90
|
+
topic: node.outputTopic,
|
|
91
|
+
payload: {
|
|
92
|
+
condition_id: node.conditionId,
|
|
93
|
+
condition_name: node.conditionName,
|
|
94
|
+
source: record.source,
|
|
95
|
+
source_node: node.id,
|
|
96
|
+
active_state: record.active_state,
|
|
97
|
+
acked_state: record.acked_state,
|
|
98
|
+
severity: node.severity,
|
|
99
|
+
retain: record.retain,
|
|
100
|
+
ts: record.ts,
|
|
101
|
+
lifecycle: record.lifecycle
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function updateStatus() {
|
|
107
|
+
const active = [...alarms.values()].filter(a => a.active_state === 'Active');
|
|
108
|
+
const unacked = [...alarms.values()].filter(a => a.acked_state === 'Unacknowledged');
|
|
109
|
+
if (alarms.size === 0) {
|
|
110
|
+
node.status({ fill: "green", shape: "dot", text: "normal" });
|
|
111
|
+
} else if (active.length > 0 && unacked.length > 0) {
|
|
112
|
+
node.status({ fill: "red", shape: "dot", text: `${alarms.size} alarm(s), ${unacked.length} unacked` });
|
|
113
|
+
} else if (active.length > 0) {
|
|
114
|
+
node.status({ fill: "yellow", shape: "dot", text: `${active.length} active` });
|
|
115
|
+
} else {
|
|
116
|
+
node.status({ fill: "blue", shape: "dot", text: `${unacked.length} unacked (cleared)` });
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function evaluateCondition(triggerTopic, triggerTs) {
|
|
121
|
+
if (latestValues.size === 0) return;
|
|
122
|
+
|
|
123
|
+
// Build argument values
|
|
124
|
+
const args = inputNames.map(name => {
|
|
125
|
+
const data = latestValues.get(name);
|
|
126
|
+
return data ? data.value : undefined;
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
let isActive;
|
|
130
|
+
try {
|
|
131
|
+
isActive = compiledFn(...args);
|
|
132
|
+
} catch (err) {
|
|
133
|
+
node.status({ fill: "red", shape: "ring", text: "eval error" });
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const source = triggerTopic;
|
|
138
|
+
const now = triggerTs || Date.now();
|
|
139
|
+
const existing = alarms.get(source);
|
|
140
|
+
|
|
141
|
+
if (isActive && !existing) {
|
|
142
|
+
// NORM → UNACK_ALM: New alarm raised
|
|
143
|
+
const record = {
|
|
144
|
+
source: source,
|
|
145
|
+
active_state: 'Active',
|
|
146
|
+
acked_state: 'Unacknowledged',
|
|
147
|
+
retain: true,
|
|
148
|
+
ts: now,
|
|
149
|
+
lifecycle: {
|
|
150
|
+
raised_ts: now
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
alarms.set(source, record);
|
|
154
|
+
saveAlarms();
|
|
155
|
+
updateStatus();
|
|
156
|
+
node.send([buildAlarmMsg(record), null, null, null]);
|
|
157
|
+
|
|
158
|
+
} else if (!isActive && existing && existing.active_state === 'Active') {
|
|
159
|
+
if (existing.acked_state === 'Acknowledged') {
|
|
160
|
+
// ACK_ALM → NORM: Condition cleared after ack → fully resolved
|
|
161
|
+
existing.active_state = 'Inactive';
|
|
162
|
+
existing.retain = false;
|
|
163
|
+
existing.ts = now;
|
|
164
|
+
existing.lifecycle.resolved_ts = now;
|
|
165
|
+
const msg = buildAlarmMsg(existing);
|
|
166
|
+
alarms.delete(source);
|
|
167
|
+
saveAlarms();
|
|
168
|
+
updateStatus();
|
|
169
|
+
node.send([null, null, null, msg]);
|
|
170
|
+
} else {
|
|
171
|
+
// UNACK_ALM → UNACK_RTN: Condition cleared but not yet acked
|
|
172
|
+
existing.active_state = 'Inactive';
|
|
173
|
+
existing.retain = true;
|
|
174
|
+
existing.ts = now;
|
|
175
|
+
existing.lifecycle.cleared_ts = now;
|
|
176
|
+
saveAlarms();
|
|
177
|
+
updateStatus();
|
|
178
|
+
node.send([null, null, buildAlarmMsg(existing), null]);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
} else if (isActive && existing && existing.active_state === 'Inactive') {
|
|
182
|
+
// UNACK_RTN → UNACK_ALM: Condition re-activated before ack
|
|
183
|
+
existing.active_state = 'Active';
|
|
184
|
+
existing.retain = true;
|
|
185
|
+
existing.ts = now;
|
|
186
|
+
existing.lifecycle.reraised_ts = now;
|
|
187
|
+
saveAlarms();
|
|
188
|
+
updateStatus();
|
|
189
|
+
node.send([buildAlarmMsg(existing), null, null, null]);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function handleAck(source) {
|
|
194
|
+
const existing = alarms.get(source);
|
|
195
|
+
if (!existing || existing.acked_state === 'Acknowledged') return;
|
|
196
|
+
|
|
197
|
+
const now = Date.now();
|
|
198
|
+
existing.acked_state = 'Acknowledged';
|
|
199
|
+
existing.ts = now;
|
|
200
|
+
existing.lifecycle.acked_ts = now;
|
|
201
|
+
|
|
202
|
+
if (existing.active_state === 'Inactive') {
|
|
203
|
+
// UNACK_RTN → NORM: Ack on cleared alarm → fully resolved
|
|
204
|
+
existing.retain = false;
|
|
205
|
+
existing.lifecycle.resolved_ts = now;
|
|
206
|
+
const msg = buildAlarmMsg(existing);
|
|
207
|
+
alarms.delete(source);
|
|
208
|
+
saveAlarms();
|
|
209
|
+
updateStatus();
|
|
210
|
+
node.send([null, null, null, msg]);
|
|
211
|
+
} else {
|
|
212
|
+
// UNACK_ALM → ACK_ALM: Ack on active alarm
|
|
213
|
+
saveAlarms();
|
|
214
|
+
updateStatus();
|
|
215
|
+
node.send([null, buildAlarmMsg(existing), null, null]);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Subscribe to input topics
|
|
220
|
+
for (const input of node.inputMappings) {
|
|
221
|
+
const topicName = input.topic || input.pattern;
|
|
222
|
+
if (!input.name || !topicName) continue;
|
|
223
|
+
|
|
224
|
+
const subId = node.cacheConfig.subscribe(topicName, (topic, entry) => {
|
|
225
|
+
latestValues.set(input.name, {
|
|
226
|
+
topic: topic,
|
|
227
|
+
value: entry.value,
|
|
228
|
+
ts: entry.ts
|
|
229
|
+
});
|
|
230
|
+
evaluateCondition(topic, entry.ts);
|
|
231
|
+
});
|
|
232
|
+
subscriptionIds.push(subId);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
updateStatus();
|
|
236
|
+
|
|
237
|
+
// Handle input messages (ack, ack_all, clear)
|
|
238
|
+
node.on('input', function(msg, send, done) {
|
|
239
|
+
send = send || function() { node.send.apply(node, arguments); };
|
|
240
|
+
done = done || function(err) { if (err) node.error(err, msg); };
|
|
241
|
+
|
|
242
|
+
const action = (msg.payload && msg.payload.action) || msg.action;
|
|
243
|
+
|
|
244
|
+
if (action === 'ack') {
|
|
245
|
+
const source = (msg.payload && msg.payload.source) || msg.source;
|
|
246
|
+
if (source) {
|
|
247
|
+
handleAck(source);
|
|
248
|
+
} else {
|
|
249
|
+
node.warn("ack requires a source");
|
|
250
|
+
}
|
|
251
|
+
} else if (action === 'ack_all') {
|
|
252
|
+
const sources = [...alarms.keys()];
|
|
253
|
+
for (const source of sources) {
|
|
254
|
+
handleAck(source);
|
|
255
|
+
}
|
|
256
|
+
} else if (action === 'list') {
|
|
257
|
+
const list = [];
|
|
258
|
+
for (const [source, record] of alarms) {
|
|
259
|
+
list.push({ ...record });
|
|
260
|
+
}
|
|
261
|
+
send([null, null, null, { topic: node.outputTopic + '/list', payload: list }]);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
done();
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
node.on('close', function(done) {
|
|
268
|
+
for (const subId of subscriptionIds) {
|
|
269
|
+
if (node.cacheConfig) {
|
|
270
|
+
node.cacheConfig.unsubscribe(subId);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
subscriptionIds.length = 0;
|
|
274
|
+
saveAlarms();
|
|
275
|
+
done();
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
RED.nodes.registerType("event-alarm", EventAlarmNode);
|
|
280
|
+
|
|
281
|
+
// HTTP endpoint to get active alarms
|
|
282
|
+
RED.httpAdmin.get("/event-alarm/:id/alarms", function(req, res) {
|
|
283
|
+
const node = RED.nodes.getNode(req.params.id);
|
|
284
|
+
if (node) {
|
|
285
|
+
const ctx = node.context();
|
|
286
|
+
const stored = ctx.get('alarms') || {};
|
|
287
|
+
res.json(stored);
|
|
288
|
+
} else {
|
|
289
|
+
res.sendStatus(404);
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
};
|
package/nodes/event-cache.js
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
* event-cache - Config node providing central cache and event bus
|
|
3
3
|
*
|
|
4
4
|
* Features:
|
|
5
|
-
* - Map<topic, {value, ts, metadata}> for caching latest values
|
|
6
|
-
* -
|
|
5
|
+
* - Map<topic, {value, ts, metadata, previous}> for caching latest values
|
|
6
|
+
* - In-memory primary store with debounced sync to global context for sidebar
|
|
7
7
|
* - EventEmitter for notifying subscribers on updates
|
|
8
8
|
* - Exact topic matching for subscriptions
|
|
9
9
|
* - LRU eviction when maxEntries exceeded
|
|
@@ -35,7 +35,10 @@ module.exports = function(RED) {
|
|
|
35
35
|
// Subscription storage: Map<topic, Map<subId, callback>> for O(1) exact match
|
|
36
36
|
subscriptions: new Map(),
|
|
37
37
|
users: 0,
|
|
38
|
-
subscriptionCounter: 0
|
|
38
|
+
subscriptionCounter: 0,
|
|
39
|
+
// In-memory primary cache store (Map for O(1) access)
|
|
40
|
+
cache: new Map(),
|
|
41
|
+
entryCount: 0
|
|
39
42
|
});
|
|
40
43
|
}
|
|
41
44
|
|
|
@@ -43,9 +46,39 @@ module.exports = function(RED) {
|
|
|
43
46
|
instance.users++;
|
|
44
47
|
instance.emitter.setMaxListeners(100); // Allow many subscribers
|
|
45
48
|
|
|
46
|
-
//
|
|
47
|
-
|
|
48
|
-
|
|
49
|
+
// Reference to the in-memory cache
|
|
50
|
+
const cache = instance.cache;
|
|
51
|
+
|
|
52
|
+
// Initialize from global context if cache is empty (e.g. after restart)
|
|
53
|
+
if (cache.size === 0) {
|
|
54
|
+
const stored = globalContext.get(contextKey);
|
|
55
|
+
if (stored && typeof stored === 'object') {
|
|
56
|
+
for (const [topic, entry] of Object.entries(stored)) {
|
|
57
|
+
cache.set(topic, entry);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
instance.entryCount = cache.size;
|
|
62
|
+
|
|
63
|
+
// Debounced sync to global context for sidebar visibility
|
|
64
|
+
let syncPending = false;
|
|
65
|
+
const SYNC_INTERVAL = 500; // ms
|
|
66
|
+
|
|
67
|
+
function syncToContext() {
|
|
68
|
+
if (!syncPending) return;
|
|
69
|
+
syncPending = false;
|
|
70
|
+
const obj = {};
|
|
71
|
+
for (const [topic, entry] of cache) {
|
|
72
|
+
obj[topic] = entry;
|
|
73
|
+
}
|
|
74
|
+
globalContext.set(contextKey, obj);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function scheduleSyncToContext() {
|
|
78
|
+
if (!syncPending) {
|
|
79
|
+
syncPending = true;
|
|
80
|
+
setTimeout(syncToContext, SYNC_INTERVAL);
|
|
81
|
+
}
|
|
49
82
|
}
|
|
50
83
|
|
|
51
84
|
// TTL cleanup interval
|
|
@@ -53,16 +86,16 @@ module.exports = function(RED) {
|
|
|
53
86
|
if (node.ttl > 0) {
|
|
54
87
|
ttlInterval = setInterval(() => {
|
|
55
88
|
const now = Date.now();
|
|
56
|
-
const cache = globalContext.get(contextKey) || {};
|
|
57
89
|
let changed = false;
|
|
58
|
-
for (const topic of
|
|
59
|
-
if (now -
|
|
60
|
-
delete
|
|
90
|
+
for (const [topic, entry] of cache) {
|
|
91
|
+
if (now - entry.ts > node.ttl) {
|
|
92
|
+
cache.delete(topic);
|
|
61
93
|
changed = true;
|
|
62
94
|
}
|
|
63
95
|
}
|
|
64
96
|
if (changed) {
|
|
65
|
-
|
|
97
|
+
instance.entryCount = cache.size;
|
|
98
|
+
scheduleSyncToContext();
|
|
66
99
|
}
|
|
67
100
|
}, Math.min(node.ttl, 60000)); // Check at most every minute
|
|
68
101
|
}
|
|
@@ -73,32 +106,38 @@ module.exports = function(RED) {
|
|
|
73
106
|
* @param {any} value - The value to store
|
|
74
107
|
* @param {object} metadata - Optional metadata
|
|
75
108
|
*/
|
|
76
|
-
node.setValue = function(topic, value, metadata
|
|
109
|
+
node.setValue = function(topic, value, metadata) {
|
|
110
|
+
const existing = cache.get(topic);
|
|
111
|
+
const ts = Date.now();
|
|
112
|
+
|
|
77
113
|
const entry = {
|
|
78
114
|
value: value,
|
|
79
|
-
ts:
|
|
80
|
-
metadata: metadata
|
|
115
|
+
ts: ts,
|
|
116
|
+
metadata: metadata || {},
|
|
117
|
+
previous: existing
|
|
118
|
+
? { value: existing.value, ts: existing.ts, metadata: existing.metadata }
|
|
119
|
+
: { value: value, ts: ts, metadata: metadata || {} }
|
|
81
120
|
};
|
|
82
121
|
|
|
83
|
-
|
|
84
|
-
cache[topic] = entry;
|
|
122
|
+
cache.set(topic, entry);
|
|
85
123
|
|
|
86
124
|
// Enforce max entries (LRU eviction - remove oldest)
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
if (cache[key].ts < oldestTs) {
|
|
94
|
-
oldestTs = cache[key].ts;
|
|
125
|
+
if (cache.size > node.maxEntries) {
|
|
126
|
+
let oldestKey = null;
|
|
127
|
+
let oldestTs = Infinity;
|
|
128
|
+
for (const [key, e] of cache) {
|
|
129
|
+
if (e.ts < oldestTs) {
|
|
130
|
+
oldestTs = e.ts;
|
|
95
131
|
oldestKey = key;
|
|
96
132
|
}
|
|
97
133
|
}
|
|
98
|
-
|
|
134
|
+
if (oldestKey !== null) {
|
|
135
|
+
cache.delete(oldestKey);
|
|
136
|
+
}
|
|
99
137
|
}
|
|
100
138
|
|
|
101
|
-
|
|
139
|
+
// Debounced sync to context (for sidebar visibility)
|
|
140
|
+
scheduleSyncToContext();
|
|
102
141
|
|
|
103
142
|
// Emit topic-specific update event
|
|
104
143
|
instance.emitter.emit('update', topic, entry);
|
|
@@ -107,11 +146,20 @@ module.exports = function(RED) {
|
|
|
107
146
|
/**
|
|
108
147
|
* Get a value from the cache
|
|
109
148
|
* @param {string} topic - The topic key
|
|
110
|
-
* @returns {object|undefined} - The cached entry {value, ts, metadata} or undefined
|
|
149
|
+
* @returns {object|undefined} - The cached entry {value, ts, metadata, previous} or undefined
|
|
111
150
|
*/
|
|
112
151
|
node.getValue = function(topic) {
|
|
113
|
-
|
|
114
|
-
|
|
152
|
+
return cache.get(topic);
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Get the previous value for a topic
|
|
157
|
+
* @param {string} topic - The topic key
|
|
158
|
+
* @returns {object|undefined} - The previous entry {value, ts, metadata} or undefined
|
|
159
|
+
*/
|
|
160
|
+
node.getPrevious = function(topic) {
|
|
161
|
+
const entry = cache.get(topic);
|
|
162
|
+
return entry ? entry.previous : undefined;
|
|
115
163
|
};
|
|
116
164
|
|
|
117
165
|
/**
|
|
@@ -152,8 +200,7 @@ module.exports = function(RED) {
|
|
|
152
200
|
* @returns {string[]} - Array of all topic keys
|
|
153
201
|
*/
|
|
154
202
|
node.getTopics = function() {
|
|
155
|
-
|
|
156
|
-
return Object.keys(cache);
|
|
203
|
+
return Array.from(cache.keys());
|
|
157
204
|
};
|
|
158
205
|
|
|
159
206
|
/**
|
|
@@ -161,14 +208,14 @@ module.exports = function(RED) {
|
|
|
161
208
|
* @returns {number} - Cache size
|
|
162
209
|
*/
|
|
163
210
|
node.size = function() {
|
|
164
|
-
|
|
165
|
-
return Object.keys(cache).length;
|
|
211
|
+
return cache.size;
|
|
166
212
|
};
|
|
167
213
|
|
|
168
214
|
/**
|
|
169
215
|
* Clear all entries from cache
|
|
170
216
|
*/
|
|
171
217
|
node.clear = function() {
|
|
218
|
+
cache.clear();
|
|
172
219
|
globalContext.set(contextKey, {});
|
|
173
220
|
};
|
|
174
221
|
|
|
@@ -193,6 +240,10 @@ module.exports = function(RED) {
|
|
|
193
240
|
clearInterval(ttlInterval);
|
|
194
241
|
}
|
|
195
242
|
|
|
243
|
+
// Final sync to context before closing
|
|
244
|
+
syncPending = true;
|
|
245
|
+
syncToContext();
|
|
246
|
+
|
|
196
247
|
instance.users--;
|
|
197
248
|
if (instance.users <= 0) {
|
|
198
249
|
// Don't clear the context cache - let it persist
|