@flowfuse/node-red-dashboard 1.31.0 → 1.31.1-0abe89c-202609211346.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.
Files changed (87) hide show
  1. package/nodes/config/ui_base.js +15 -1
  2. package/nodes/store/clients.js +71 -0
  3. package/nodes/utils/index.js +8 -1
  4. package/nodes/widgets/locales/en-US/ui_control.json +2 -1
  5. package/nodes/widgets/ui_chart.js +7 -3
  6. package/nodes/widgets/ui_control.html +1 -1
  7. package/nodes/widgets/ui_control.js +28 -0
  8. package/package.json +4 -4
  9. package/dist/apple-touch-icon.png +0 -0
  10. package/dist/assets/abnfDiagram-N423BO3Z-BOzAedUe.js +0 -1
  11. package/dist/assets/architectureDiagram-T3A2C74G-zEM8gtfO.js +0 -36
  12. package/dist/assets/blockDiagram-VBNYF7ZC-eKuDT4u2.js +0 -132
  13. package/dist/assets/c4Diagram-5PPSVZJV-CRPvpwR2.js +0 -10
  14. package/dist/assets/chunk-2GRJ4B5K-BUP9p1Ar.js +0 -1
  15. package/dist/assets/chunk-2Q5K7J3B-DASHHxaC.js +0 -1
  16. package/dist/assets/chunk-5RXB4S5H-C76llYJF.js +0 -231
  17. package/dist/assets/chunk-5VM5RSS4-DnmzQEFn.js +0 -15
  18. package/dist/assets/chunk-6Q2QTUOP-DpGB6o5S.js +0 -88
  19. package/dist/assets/chunk-GF5L2VYU-Bhzwn3VL.js +0 -206
  20. package/dist/assets/chunk-JWPE2WC7-UNqcBwu_.js +0 -1
  21. package/dist/assets/chunk-KBJHAD2P-C38IW_UU.js +0 -1
  22. package/dist/assets/chunk-RYQCIY6F-COKxTTcO.js +0 -1
  23. package/dist/assets/chunk-XXDRQBXY-BS0ojEzX.js +0 -1
  24. package/dist/assets/classDiagram-JCYQIIEL-CxMqqBtQ.js +0 -1
  25. package/dist/assets/classDiagram-v2-OCEON4UE-CxMqqBtQ.js +0 -1
  26. package/dist/assets/cose-bilkent-JH36ORCC-9jRlXuji.js +0 -1
  27. package/dist/assets/cynefin-VYW2F7L2-BDPznSFQ.js +0 -178
  28. package/dist/assets/cynefinDiagram-MW4NZA55-lSqlEkCZ.js +0 -62
  29. package/dist/assets/cytoscape.esm-D_LviqZs.js +0 -331
  30. package/dist/assets/dagre-VZM6K2ZE-Dfoe_jp9.js +0 -4
  31. package/dist/assets/diagram-7IWD3JNH-Df2T__dV.js +0 -30
  32. package/dist/assets/diagram-B4RE2ZJO-DoGtC0h7.js +0 -3
  33. package/dist/assets/diagram-LBJQPF4R-cH8DVJ6C.js +0 -24
  34. package/dist/assets/diagram-Q27KOJAE-8JJAGHRF.js +0 -24
  35. package/dist/assets/diagram-UB23O5K3-BGCDqdpG.js +0 -41
  36. package/dist/assets/disconnected-BuxohaUu.png +0 -0
  37. package/dist/assets/ebnfDiagram-BXEA7PRR-r2bgA64b.js +0 -1
  38. package/dist/assets/echarts-BB8VCqSB.js +0 -73
  39. package/dist/assets/erDiagram-JOGREHBK-BExdsKTd.js +0 -85
  40. package/dist/assets/flowDiagram-UKHOOZJN-B8wwUqLz.js +0 -156
  41. package/dist/assets/ganttDiagram-PKOTCBZU-SViiTCQN.js +0 -292
  42. package/dist/assets/gitGraphDiagram-DS77QQ5N-CmktWysd.js +0 -106
  43. package/dist/assets/graph-C9eacEi8.js +0 -1
  44. package/dist/assets/index-CizlQbSv.css +0 -13
  45. package/dist/assets/index-CrLoQPpq.js +0 -124
  46. package/dist/assets/infoDiagram-6WML65LV-BwUFVmXD.js +0 -2
  47. package/dist/assets/ishikawaDiagram-WSZJBQD7-Df6P2A6T.js +0 -70
  48. package/dist/assets/journeyDiagram-NVQOT4AX-Cjk4fifu.js +0 -139
  49. package/dist/assets/kanban-definition-27J2QSJJ-BcoyAznM.js +0 -89
  50. package/dist/assets/katex-C5jXJg4s.js +0 -257
  51. package/dist/assets/layout-DEXfKzaS.js +0 -1
  52. package/dist/assets/logo-DIAzbBuw.png +0 -0
  53. package/dist/assets/map-Czzmt4hB.js +0 -1
  54. package/dist/assets/materialdesignicons-webfont-B7mPwVP_.ttf +0 -0
  55. package/dist/assets/materialdesignicons-webfont-CSr8KVlo.eot +0 -0
  56. package/dist/assets/materialdesignicons-webfont-Dp5v-WZN.woff2 +0 -0
  57. package/dist/assets/materialdesignicons-webfont-PXm3-2wK.woff +0 -0
  58. package/dist/assets/mermaid-CX4mRs5N.js +0 -314
  59. package/dist/assets/mindmap-definition-FAOFIHXS-D6njotiS.js +0 -96
  60. package/dist/assets/pegDiagram-VL7TDLO6-CaA_iXxT.js +0 -1
  61. package/dist/assets/pieDiagram-7S7Q4E2Y-aYh5694F.js +0 -39
  62. package/dist/assets/quadrantDiagram-CIZ2JOQS-CaWyb4Jw.js +0 -7
  63. package/dist/assets/railroadDiagram-AXF67PYL-Bd_vMLsE.js +0 -1
  64. package/dist/assets/requirementDiagram-LRYGKXZP-CMxISoyd.js +0 -84
  65. package/dist/assets/sankeyDiagram-W5VNT64P-DLbFAyXe.js +0 -40
  66. package/dist/assets/sequenceDiagram-SI44F4Z6-CzdhMpvP.js +0 -162
  67. package/dist/assets/sizeCapture-X5ZJPWSS-CnezfYZ6.js +0 -1
  68. package/dist/assets/stateDiagram-OKZ733FA-5R9RLDEM.js +0 -1
  69. package/dist/assets/stateDiagram-v2-UEYNNEHI-Ck4l-gcT.js +0 -1
  70. package/dist/assets/swimlanes-SLNWSIFB-CeEnGZwJ.js +0 -2
  71. package/dist/assets/swimlanesDiagram-ULZ7WXOC-DSebamlh.js +0 -8
  72. package/dist/assets/timeline-definition-Z64GVDOM-ZMWCENQJ.js +0 -120
  73. package/dist/assets/vennDiagram-T6HMQDX7-DVr1jz1I.js +0 -34
  74. package/dist/assets/vue-vendor-Bmy9n80X.js +0 -49
  75. package/dist/assets/wardleyDiagram-T6FBY63Y-DNp6yzs5.js +0 -78
  76. package/dist/assets/workbox-window.prod.es5-BIl4cyR9.js +0 -2
  77. package/dist/assets/xychartDiagram-ELKLHX3M-DC4NZzl1.js +0 -7
  78. package/dist/favicon.ico +0 -0
  79. package/dist/favicon.svg +0 -482
  80. package/dist/index.html +0 -24
  81. package/dist/logo-512x512.png +0 -0
  82. package/dist/logo.svg +0 -482
  83. package/dist/maskable-icon-512x512.png +0 -0
  84. package/dist/pwa-192x192.png +0 -0
  85. package/dist/pwa-512x512.png +0 -0
  86. package/dist/pwa-64x64.png +0 -0
  87. package/dist/sw.js +0 -2
@@ -4,9 +4,10 @@ const path = require('path')
4
4
  const axios = require('axios')
5
5
 
6
6
  const v = require('../../package.json').version
7
+ const { createClientStore } = require('../store/clients.js')
7
8
  const datastore = require('../store/data.js')
8
9
  const statestore = require('../store/state.js')
9
- const { appendTopic, addConnectionCredentials, getThirdPartyWidgets } = require('../utils/index.js')
10
+ const { appendTopic, addConnectionCredentials, normalizeClientId, getThirdPartyWidgets } = require('../utils/index.js')
10
11
 
11
12
  // from: https://stackoverflow.com/a/28592528/3016654
12
13
  function join (...paths) {
@@ -59,6 +60,7 @@ module.exports = function (RED) {
59
60
  ioServer: null,
60
61
  /** @type {Object.<string, Socket>} */
61
62
  connections: {},
63
+ clientStore: createClientStore(),
62
64
  settings: {},
63
65
  contribs: {}
64
66
  }
@@ -413,6 +415,7 @@ module.exports = function (RED) {
413
415
  socket.on('widget-action', onAction.bind(null, socket))
414
416
  socket.on('widget-change', onChange.bind(null, socket))
415
417
  socket.on('widget-load', onLoad.bind(null, socket))
418
+ socket.on('disconnect', () => uiShared.clientStore.disconnect(socket._clientId, socket.id))
416
419
  }
417
420
  }
418
421
  /** @type {NodeJS.Timeout} */
@@ -472,6 +475,10 @@ module.exports = function (RED) {
472
475
  // we only send comms on the connection that matches that id
473
476
  checks.push(msg._client?.socketId === conn.id)
474
477
  }
478
+ if (msg._client?.clientId) {
479
+ // clientId is the stable per-client key (spans a client's tabs/reconnects)
480
+ checks.push(normalizeClientId(msg._client.clientId) === conn._clientId)
481
+ }
475
482
  // ensure all checks validate sending this
476
483
  return !checks.length || !checks.includes(false)
477
484
  }
@@ -591,6 +598,7 @@ module.exports = function (RED) {
591
598
  socket.on('disconnect', reason => {
592
599
  cleanupEventHandlers(socket)
593
600
  delete uiShared.connections[socket.id]
601
+ uiShared.clientStore.disconnect(socket._clientId, socket.id)
594
602
  node.log(`Disconnected ${socket.id} due to ${reason}`)
595
603
  })
596
604
  }
@@ -606,6 +614,9 @@ module.exports = function (RED) {
606
614
  // node.connections[socket.id] = socket // store the connection for later use
607
615
  uiShared.connections[socket.id] = socket // store the connection for later use
608
616
 
617
+ socket._clientId = normalizeClientId(socket.handshake?.query?.clientId)
618
+ uiShared.clientStore.connect(socket._clientId, socket.id)
619
+
609
620
  emitConfig(socket)
610
621
 
611
622
  // clean up then re-register listeners
@@ -846,6 +857,9 @@ module.exports = function (RED) {
846
857
  node.on('close', (removed, done) => {
847
858
  uiShared.ioServer?.off('connection', onConnection)
848
859
  for (const conn of Object.values(uiShared.connections)) {
860
+ if (removed && conn._baseId === node.id) {
861
+ uiShared.clientStore.dropSocket(conn._clientId, conn.id)
862
+ }
849
863
  cleanupEventHandlers(conn)
850
864
  }
851
865
  close(node, function (err) {
@@ -0,0 +1,71 @@
1
+ const { EventEmitter } = require('events')
2
+
3
+ const DEFAULT_GRACE_MS = 20000
4
+
5
+ /**
6
+ * Tracks client presence across reconnects (socket.id churns every reconnect; clientId is stable).
7
+ * A client may hold several live sockets (one per tab); events fire on its socket count crossing
8
+ * zero, not per socket. Emits { event, clientId, socketId? }:
9
+ * connected: count 0 -> 1, no grace pending (new client)
10
+ * reconnected: count 0 -> 1 during the grace window (returned before being declared gone)
11
+ * gone: count -> 0 and the grace window elapses
12
+ * Extra tabs (1 -> 2, 2 -> 1) emit nothing; the client is still present.
13
+ */
14
+ function createClientStore ({ graceMs = DEFAULT_GRACE_MS, setTimeoutFn = setTimeout, clearTimeoutFn = clearTimeout } = {}) {
15
+ const clients = {} // clientId -> { sockets: Set<socketId>, graceTO }
16
+ const events = new EventEmitter()
17
+ events.setMaxListeners(0)
18
+
19
+ function connect (clientId, socketId) {
20
+ if (!clientId) { return }
21
+ let entry = clients[clientId]
22
+ const returning = !!(entry && entry.graceTO)
23
+ if (!entry) {
24
+ entry = clients[clientId] = { sockets: new Set(), graceTO: null }
25
+ }
26
+ if (entry.graceTO) {
27
+ clearTimeoutFn(entry.graceTO)
28
+ entry.graceTO = null
29
+ }
30
+ const wasEmpty = entry.sockets.size === 0
31
+ entry.sockets.add(socketId)
32
+ if (wasEmpty) {
33
+ events.emit('client', { event: returning ? 'reconnected' : 'connected', clientId, socketId })
34
+ }
35
+ }
36
+
37
+ function disconnect (clientId, socketId) {
38
+ if (!clientId) { return }
39
+ const entry = clients[clientId]
40
+ if (!entry) { return }
41
+ entry.sockets.delete(socketId)
42
+ if (entry.sockets.size === 0 && !entry.graceTO) {
43
+ entry.graceTO = setTimeoutFn(() => {
44
+ delete clients[clientId]
45
+ events.emit('client', { event: 'gone', clientId })
46
+ }, graceMs)
47
+ if (typeof entry.graceTO?.unref === 'function') { entry.graceTO.unref() }
48
+ }
49
+ }
50
+
51
+ function dropSocket (clientId, socketId) {
52
+ if (!clientId) { return }
53
+ const entry = clients[clientId]
54
+ if (!entry) { return }
55
+ entry.sockets.delete(socketId)
56
+ if (entry.sockets.size === 0) {
57
+ if (entry.graceTO) { clearTimeoutFn(entry.graceTO) }
58
+ delete clients[clientId]
59
+ }
60
+ }
61
+
62
+ function list () {
63
+ return Object.entries(clients)
64
+ .filter(([, entry]) => entry.sockets.size > 0)
65
+ .map(([clientId, entry]) => ({ clientId, socketId: entry.sockets.values().next().value }))
66
+ }
67
+
68
+ return { connect, disconnect, dropSocket, list, events }
69
+ }
70
+
71
+ module.exports = { createClientStore }
@@ -40,6 +40,11 @@ async function getTopic (RED, config, wNode, msg) {
40
40
  return topic
41
41
  }
42
42
 
43
+ function normalizeClientId (raw) {
44
+ if (Array.isArray(raw)) { raw = raw[0] }
45
+ return (raw === undefined || raw === null || raw === '') ? undefined : String(raw)
46
+ }
47
+
43
48
  /**
44
49
  * Adds socket/client data to a msg payload, if enabled
45
50
  *
@@ -60,7 +65,8 @@ function addConnectionCredentials (RED, msg, conn, config) {
60
65
  ...item._client,
61
66
  ...{
62
67
  socketId: conn.id,
63
- socketIp: conn.handshake?.address
68
+ socketIp: conn.handshake?.address,
69
+ clientId: normalizeClientId(conn.handshake?.query?.clientId)
64
70
  }
65
71
  }
66
72
  return item
@@ -127,5 +133,6 @@ module.exports = {
127
133
  appendTopic,
128
134
  getTopic,
129
135
  addConnectionCredentials,
136
+ normalizeClientId,
130
137
  getThirdPartyWidgets
131
138
  }
@@ -7,7 +7,8 @@
7
7
  "events": {
8
8
  "all": "All Events",
9
9
  "change": "Page/Tab Change Events Only",
10
- "connect": "Connection Events Only"
10
+ "connect": "Connection Events Only",
11
+ "clients": "Client Presence Events Only"
11
12
  }
12
13
  }
13
14
  }
@@ -63,10 +63,12 @@ module.exports = function (RED) {
63
63
  const ago = (removeOlder * removeOlderUnit) * 1000 // milliseconds ago
64
64
  const cutOff = (new Date()).getTime() - ago
65
65
  const filterFn = (msg) => {
66
- let timestamp = msg._datapoint.x
66
+ // msg._datapoint may be a single point or, in the case of multiple series in one msg, an array
67
+ // if it is an array then all the elements will have the same timestamp so use the first one
68
+ let timestamp = Array.isArray(msg._datapoint) ? msg._datapoint[0]?.x : msg._datapoint.x
67
69
  // is x already a millisecond timestamp?
68
- if (typeof (msg._datapoint.x) === 'string') {
69
- timestamp = (new Date(msg._datapoint.x)).getTime()
70
+ if (typeof (timestamp) === 'string') {
71
+ timestamp = (new Date(timestamp)).getTime()
70
72
  }
71
73
  return timestamp > cutOff
72
74
  }
@@ -165,6 +167,8 @@ module.exports = function (RED) {
165
167
  // Deep merge new options in with old
166
168
  const mergedOptions = deepMerge(currentOptions, updates.chartOptions)
167
169
  statestore.set(group.getBase(), node, msg, 'chartOptions', mergedOptions)
170
+ // pass the full set to the clients so they don't need to do the merge
171
+ msg.ui_update.chartOptions = mergedOptions
168
172
  }
169
173
  }
170
174
 
@@ -24,7 +24,7 @@
24
24
  oneditprepare: function () {
25
25
  const node = this
26
26
  const sel = $('#node-input-events')
27
- for (const name of ['all', 'change', 'connect']) {
27
+ for (const name of ['all', 'change', 'connect', 'clients']) {
28
28
  const text = c_('events.' + name)
29
29
  $('<option/>').val(name).text(text).appendTo(sel)
30
30
  }
@@ -411,7 +411,35 @@ module.exports = function (RED) {
411
411
  node.error('No UI configured')
412
412
  }
413
413
 
414
+ let onClientEvent = null
415
+ let clientEventStore = null
416
+ if (ui && (config.events === 'all' || config.events === 'clients')) {
417
+ clientEventStore = ui.uiShared?.clientStore
418
+ if (clientEventStore) {
419
+ onClientEvent = (e) => {
420
+ const wNode = RED.nodes.getNode(node.id)
421
+ if (wNode && typeof wNode.send === 'function') {
422
+ const client = { clientId: e.clientId }
423
+ if (e.socketId) { client.socketId = e.socketId }
424
+ let msg = { payload: 'client-' + e.event, _client: client }
425
+ const conn = e.socketId ? ui.uiShared?.connections?.[e.socketId] : null
426
+ if (conn) { msg = addConnectionCredentials(RED, msg, conn, ui) }
427
+ wNode.send(msg)
428
+ }
429
+ }
430
+ clientEventStore.events.on('client', onClientEvent)
431
+ setImmediate(() => {
432
+ for (const c of clientEventStore.list()) {
433
+ onClientEvent({ event: 'connected', clientId: c.clientId, socketId: c.socketId })
434
+ }
435
+ })
436
+ }
437
+ }
438
+
414
439
  node.on('close', function (removed, done) {
440
+ if (onClientEvent && clientEventStore) {
441
+ clientEventStore.events.off('client', onClientEvent)
442
+ }
415
443
  if (removed) {
416
444
  // handle node being removed
417
445
  ui?.deregister(null, null, node)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flowfuse/node-red-dashboard",
3
- "version": "1.31.0",
3
+ "version": "1.31.1-0abe89c-202609211346.0",
4
4
  "description": "FlowFuse Dashboard - A collection of Node-RED nodes that provide functionality to build your own UI applications (inc. forms, buttons, charts).",
5
5
  "keywords": [
6
6
  "node-red"
@@ -98,12 +98,12 @@
98
98
  "marked-highlight": "^2.2.3",
99
99
  "medium-zoom": "^1.1.0",
100
100
  "mermaid": "^11.12.2",
101
- "mocha": "^11.7.5",
101
+ "mocha": "^12.0.1",
102
102
  "node-red": "^4.1.8",
103
103
  "node-red-node-test-helper": "^0.3.5",
104
104
  "npm-run-all": "^4.1.5",
105
105
  "sass": "^1.96.0",
106
- "sharp": "^0.34.5",
106
+ "sharp": "^0.35.4",
107
107
  "should": "^13.2.3",
108
108
  "should-sinon": "^0.0.6",
109
109
  "sinon": "^21.0.0",
@@ -167,7 +167,7 @@
167
167
  },
168
168
  "esbuild": ">=0.25.0",
169
169
  "cross-spawn": "~7.0.5",
170
- "js-yaml": ">=4.1.1",
170
+ "js-yaml": ">=5.4.1",
171
171
  "tar": ">=7.5.4"
172
172
  }
173
173
  }
Binary file
@@ -1 +0,0 @@
1
- import{g as p,r as u,d as a}from"./chunk-6Q2QTUOP-DpGB6o5S.js";import{p as f}from"./chunk-JWPE2WC7-UNqcBwu_.js";import{_ as n,l as o}from"./mermaid-CX4mRs5N.js";import{M as c,b as d}from"./cynefin-VYW2F7L2-BDPznSFQ.js";var v=d().RailroadAbnf.parser.LangiumParser,l=n(e=>{const r=e.alternatives.map(g);return r.length===1?r[0]:{type:"choice",alternatives:r}},"transformAlternation"),g=n(e=>{const r=e.elements.map(y);return r.length===1?r[0]:{type:"sequence",elements:r}},"transformConcatenation"),b=n(e=>{if(e.includes("*")){const[t,s]=e.split("*"),i=t?parseInt(t,10):0,m=s?parseInt(s,10):1/0;return{min:i,max:m}}const r=parseInt(e,10);return{min:r,max:r}},"parseRepeat"),y=n(e=>{const r=A(e.primary);if(!e.repeat)return r;const{min:t,max:s}=b(e.repeat);return t===0&&s===1?{type:"optional",element:r}:{type:"repetition",element:r,min:t,max:s}},"transformElement"),A=n(e=>{switch(e.$type){case"AbnfStringLiteral":return{type:"terminal",value:e.value};case"AbnfNumVal":return{type:"terminal",value:e.value};case"AbnfRuleName":return{type:"nonterminal",name:e.name};case"AbnfGroup":return l(e.element);case"AbnfOptionalGroup":return{type:"optional",element:l(e.element)};default:throw new Error(`Unsupported ABNF primary node: ${e.$type}`)}},"transformPrimary"),P=n(e=>({name:e.name,definition:l(e.definition)}),"transformRule"),h=n(e=>{f(e,a),e.title&&a.setTitle(e.title),e.rules.map(r=>a.addRule(P(r)))},"populateDb"),R={parse:n(e=>{a.clear(),o.debug("[ABNF Parser] Starting Langium parse");const r=v.parse(e);if(r.lexerErrors.length>0||r.parserErrors.length>0)throw new c(r);const t=r.value;o.debug("[ABNF Parser] Parsed rules:",t.rules.length),h(t),o.debug("[ABNF Parser] Parse complete")},"parse"),parser:{yy:a}},w={parser:R,db:a,renderer:u,styles:p};export{w as diagram};