node-red-contrib-event-calc 3.3.6 → 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 +100 -62
- package/examples/alarm-simulation-example.json +288 -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 +72 -37
- package/nodes/event-calc.js +114 -124
- 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 +5 -2
|
@@ -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,37 +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
|
|
77
|
-
const
|
|
78
|
-
const
|
|
109
|
+
node.setValue = function(topic, value, metadata) {
|
|
110
|
+
const existing = cache.get(topic);
|
|
111
|
+
const ts = Date.now();
|
|
79
112
|
|
|
80
113
|
const entry = {
|
|
81
114
|
value: value,
|
|
82
|
-
ts:
|
|
83
|
-
metadata: metadata,
|
|
115
|
+
ts: ts,
|
|
116
|
+
metadata: metadata || {},
|
|
84
117
|
previous: existing
|
|
85
118
|
? { value: existing.value, ts: existing.ts, metadata: existing.metadata }
|
|
86
|
-
: { value: value, ts:
|
|
119
|
+
: { value: value, ts: ts, metadata: metadata || {} }
|
|
87
120
|
};
|
|
88
121
|
|
|
89
|
-
cache
|
|
122
|
+
cache.set(topic, entry);
|
|
90
123
|
|
|
91
124
|
// Enforce max entries (LRU eviction - remove oldest)
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
if (cache[key].ts < oldestTs) {
|
|
99
|
-
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;
|
|
100
131
|
oldestKey = key;
|
|
101
132
|
}
|
|
102
133
|
}
|
|
103
|
-
|
|
134
|
+
if (oldestKey !== null) {
|
|
135
|
+
cache.delete(oldestKey);
|
|
136
|
+
}
|
|
104
137
|
}
|
|
105
138
|
|
|
106
|
-
|
|
139
|
+
// Debounced sync to context (for sidebar visibility)
|
|
140
|
+
scheduleSyncToContext();
|
|
107
141
|
|
|
108
142
|
// Emit topic-specific update event
|
|
109
143
|
instance.emitter.emit('update', topic, entry);
|
|
@@ -112,11 +146,10 @@ module.exports = function(RED) {
|
|
|
112
146
|
/**
|
|
113
147
|
* Get a value from the cache
|
|
114
148
|
* @param {string} topic - The topic key
|
|
115
|
-
* @returns {object|undefined} - The cached entry {value, ts, metadata} or undefined
|
|
149
|
+
* @returns {object|undefined} - The cached entry {value, ts, metadata, previous} or undefined
|
|
116
150
|
*/
|
|
117
151
|
node.getValue = function(topic) {
|
|
118
|
-
|
|
119
|
-
return cache[topic];
|
|
152
|
+
return cache.get(topic);
|
|
120
153
|
};
|
|
121
154
|
|
|
122
155
|
/**
|
|
@@ -125,8 +158,7 @@ module.exports = function(RED) {
|
|
|
125
158
|
* @returns {object|undefined} - The previous entry {value, ts, metadata} or undefined
|
|
126
159
|
*/
|
|
127
160
|
node.getPrevious = function(topic) {
|
|
128
|
-
const
|
|
129
|
-
const entry = cache[topic];
|
|
161
|
+
const entry = cache.get(topic);
|
|
130
162
|
return entry ? entry.previous : undefined;
|
|
131
163
|
};
|
|
132
164
|
|
|
@@ -168,8 +200,7 @@ module.exports = function(RED) {
|
|
|
168
200
|
* @returns {string[]} - Array of all topic keys
|
|
169
201
|
*/
|
|
170
202
|
node.getTopics = function() {
|
|
171
|
-
|
|
172
|
-
return Object.keys(cache);
|
|
203
|
+
return Array.from(cache.keys());
|
|
173
204
|
};
|
|
174
205
|
|
|
175
206
|
/**
|
|
@@ -177,14 +208,14 @@ module.exports = function(RED) {
|
|
|
177
208
|
* @returns {number} - Cache size
|
|
178
209
|
*/
|
|
179
210
|
node.size = function() {
|
|
180
|
-
|
|
181
|
-
return Object.keys(cache).length;
|
|
211
|
+
return cache.size;
|
|
182
212
|
};
|
|
183
213
|
|
|
184
214
|
/**
|
|
185
215
|
* Clear all entries from cache
|
|
186
216
|
*/
|
|
187
217
|
node.clear = function() {
|
|
218
|
+
cache.clear();
|
|
188
219
|
globalContext.set(contextKey, {});
|
|
189
220
|
};
|
|
190
221
|
|
|
@@ -209,6 +240,10 @@ module.exports = function(RED) {
|
|
|
209
240
|
clearInterval(ttlInterval);
|
|
210
241
|
}
|
|
211
242
|
|
|
243
|
+
// Final sync to context before closing
|
|
244
|
+
syncPending = true;
|
|
245
|
+
syncToContext();
|
|
246
|
+
|
|
212
247
|
instance.users--;
|
|
213
248
|
if (instance.users <= 0) {
|
|
214
249
|
// Don't clear the context cache - let it persist
|