@flowfuse/node-red-dashboard 1.31.1-e3609ab-202609141637.0 → 1.32.1-034c8a5-202610061312.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
@@ -86,6 +86,56 @@ The original [Node-RED Dashboard](https://github.com/node-red/node-red-dashboard
86
86
 
87
87
  FlowFuse Dashboard was re-built from the ground up, learning from the popular features and feedback of Node-RED Dashboard. It will carry the legacy forward, adapting to future needs while keeping the essence of open-source and community-driven development intact. The project will is licensed under Apache 2.0.
88
88
 
89
+ ## Release process
90
+
91
+ In this project, the [Release Please](https://github.com/googleapis/release-please) is used to automatically determine the next release version based on the commit messages in the codebase.
92
+
93
+ By using the [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/), the project adheres to a standardized format for commit messages, which `Release Please` uses to determine whether the next release should be a major, minor, or patch release.
94
+
95
+ ### Components
96
+
97
+ 1. The `Prepare release` GitHub Action workflow:
98
+
99
+ * A Release Please action that analyzes commit messages to determine the type of release required (major, minor, patch) based on the Conventional Commits specification
100
+ * Creates a pre-release pull request with the proposed version bump and changelog
101
+ * Once merged, automatically updates the version number in `package.json` and creates a new release on GitHub with the appropriate changelog
102
+
103
+ 2. The `Lint Pull Request Title` GitHub Action workflow:
104
+
105
+ * A workflow that runs on pull request creation and uses the `amannn/action-semantic-pull-request` action to validate that pull request titles follow the Conventional Commits format
106
+ * Together with adjusted default merge commit message, this ensures that all commits merged into the main branch adhere to the expected format, allowing Release Please to function correctly
107
+
108
+ 3. The `Publish Release` GitHub Action workflow:
109
+
110
+ * A workflow that runs when a new git tag in `v*.*.*` format is pushed, runs the tests, builds the package and publishes the new version to the public npm registry using the `JS-DevTools/npm-publish` action
111
+ * Once package is published, the workflow updates the package version in the Node-RED Flow Library catalogue
112
+
113
+ ### Pull Request Title Format
114
+
115
+ The Conventional Commits preset expects pull request titles to be in the following format:
116
+
117
+ ```
118
+ <type>(<scope>): <subject>
119
+ ```
120
+
121
+ * Type: Describes the category of the commit. Examples include:
122
+ * `feat`: A new feature (triggers a minor version bump).
123
+ * `fix`: A bug fix (triggers a patch version bump).
124
+ * `perf`: A code change that improves performance (triggers a patch version bump).
125
+ * `refactor`: A code change that neither fixes a bug nor adds a feature (does not trigger a release unless it's accompanied by a BREAKING CHANGE).
126
+ * `docs`: Documentation-only changes (does not trigger a release).
127
+ * `chore`: Changes to the build process or auxiliary tools and libraries (does not trigger a release).
128
+ * Scope: An optional part that provides additional context about what was changed (e.g., module, component).
129
+ * Subject: A brief description of the changes.
130
+
131
+ ### Handling Breaking Changes
132
+
133
+ To indicate a breaking change, the exclamation mark `!` should be used immediately after the type/scope:
134
+
135
+ * `feat!:`
136
+ * `fix!:`
137
+ * `refactor!:`
138
+
89
139
  ## License
90
140
 
91
141
  Apache License 2.0
@@ -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, matchesClient, 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
  }
@@ -223,7 +225,6 @@ module.exports = function (RED) {
223
225
  lang: 'en',
224
226
  scope: './',
225
227
  description: config.name,
226
- theme_color: '#ffffff',
227
228
  icons: [
228
229
  { src: hasAppIcon ? config.appIcon : 'pwa-64x64.png', sizes: '64x64', type: 'image/png' },
229
230
  { src: hasAppIcon ? config.appIcon : 'pwa-192x192.png', sizes: '192x192', type: 'image/png' },
@@ -413,6 +414,7 @@ module.exports = function (RED) {
413
414
  socket.on('widget-action', onAction.bind(null, socket))
414
415
  socket.on('widget-change', onChange.bind(null, socket))
415
416
  socket.on('widget-load', onLoad.bind(null, socket))
417
+ socket.on('disconnect', () => uiShared.clientStore.disconnect(socket._clientId, socket.id))
416
418
  }
417
419
  }
418
420
  /** @type {NodeJS.Timeout} */
@@ -467,11 +469,7 @@ module.exports = function (RED) {
467
469
  }
468
470
  }
469
471
  // conduct the core check too
470
- if (msg._client?.socketId) {
471
- // if a particular socketid has been defined,
472
- // we only send comms on the connection that matches that id
473
- checks.push(msg._client?.socketId === conn.id)
474
- }
472
+ checks.push(matchesClient(conn, msg))
475
473
  // ensure all checks validate sending this
476
474
  return !checks.length || !checks.includes(false)
477
475
  }
@@ -591,6 +589,7 @@ module.exports = function (RED) {
591
589
  socket.on('disconnect', reason => {
592
590
  cleanupEventHandlers(socket)
593
591
  delete uiShared.connections[socket.id]
592
+ uiShared.clientStore.disconnect(socket._clientId, socket.id)
594
593
  node.log(`Disconnected ${socket.id} due to ${reason}`)
595
594
  })
596
595
  }
@@ -606,6 +605,9 @@ module.exports = function (RED) {
606
605
  // node.connections[socket.id] = socket // store the connection for later use
607
606
  uiShared.connections[socket.id] = socket // store the connection for later use
608
607
 
608
+ socket._clientId = normalizeClientId(socket.handshake?.query?.clientId)
609
+ uiShared.clientStore.connect(socket._clientId, socket.id)
610
+
609
611
  emitConfig(socket)
610
612
 
611
613
  // clean up then re-register listeners
@@ -846,6 +848,9 @@ module.exports = function (RED) {
846
848
  node.on('close', (removed, done) => {
847
849
  uiShared.ioServer?.off('connection', onConnection)
848
850
  for (const conn of Object.values(uiShared.connections)) {
851
+ if (removed && conn._baseId === node.id) {
852
+ uiShared.clientStore.dropSocket(conn._clientId, conn.id)
853
+ }
849
854
  cleanupEventHandlers(conn)
850
855
  }
851
856
  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 }
@@ -1,3 +1,5 @@
1
+ const { isClientScoped } = require('../utils/index.js')
2
+
1
3
  const data = {}
2
4
 
3
5
  const config = {
@@ -19,7 +21,7 @@ function canSaveInStore (base, node, msg) {
19
21
 
20
22
  if (constrained.includes(node.type)) {
21
23
  // core check
22
- if (msg._client?.socketId) {
24
+ if (isClientScoped(msg)) {
23
25
  // we are in a node type that allows for definition of specific clients,
24
26
  // and a client has been defined
25
27
  checks.push(false)
@@ -55,6 +57,9 @@ const getters = {
55
57
  // given a widget id, return the latest msg received
56
58
  msg (id) {
57
59
  return config.RED.util.cloneMessage(data[id])
60
+ },
61
+ has (id) {
62
+ return !!data[id]
58
63
  }
59
64
  }
60
65
 
@@ -118,15 +123,38 @@ const setters = {
118
123
  data[node.id] = filteredMessages
119
124
  }
120
125
  }
126
+ },
127
+ keepLatestPerTopic (base, node, maxPoints) {
128
+ const currentData = data[node.id]
129
+ if (!Array.isArray(currentData)) {
130
+ return
131
+ }
132
+ const counts = {}
133
+ const keep = []
134
+ let trimmed = false
135
+ for (let i = currentData.length - 1; i >= 0; i--) {
136
+ const topic = currentData[i].topic
137
+ counts[topic] = (counts[topic] || 0) + 1
138
+ if (counts[topic] <= maxPoints) {
139
+ keep[i] = true
140
+ } else {
141
+ trimmed = true
142
+ }
143
+ }
144
+ if (trimmed) {
145
+ setters.filter(base, node, (m, i) => keep[i])
146
+ }
121
147
  }
122
148
  }
123
149
 
124
150
  module.exports = {
125
151
  get: getters.msg,
152
+ has: getters.has,
126
153
  RED: getters.RED,
127
154
  setConfig: setters.setConfig,
128
155
  save: setters.save,
129
156
  append: setters.append,
130
157
  filter: setters.filter,
158
+ keepLatestPerTopic: setters.keepLatestPerTopic,
131
159
  clear: setters.clear
132
160
  }
@@ -1,5 +1,7 @@
1
1
  // Store to manage any dynamic properties set
2
2
 
3
+ const { isClientScoped } = require('../utils/index.js')
4
+
3
5
  const state = {}
4
6
  const config = {
5
7
  RED: null
@@ -18,7 +20,7 @@ function canSaveInStore (base, node, msg) {
18
20
  // core check
19
21
  if (base.acceptsClientConfig.includes(node.type)) {
20
22
  // we are in a node type that allows for definition of specific clients,
21
- if (msg._client?.socketId) {
23
+ if (isClientScoped(msg)) {
22
24
  // and a client has been defined
23
25
  checks.push(false)
24
26
  } else {
@@ -40,6 +40,30 @@ 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
+
48
+ const CLIENT_TARGETS = {
49
+ socketId: (conn) => conn.id,
50
+ clientId: (conn) => conn._clientId
51
+ }
52
+
53
+ function clientTargets (msg) {
54
+ return Object.entries(CLIENT_TARGETS)
55
+ .map(([field, connValue]) => ({ value: normalizeClientId(msg?._client?.[field]), connValue }))
56
+ .filter((target) => target.value !== undefined)
57
+ }
58
+
59
+ function isClientScoped (msg) {
60
+ return clientTargets(msg).length > 0
61
+ }
62
+
63
+ function matchesClient (conn, msg) {
64
+ return clientTargets(msg).every((target) => target.value === target.connValue(conn))
65
+ }
66
+
43
67
  /**
44
68
  * Adds socket/client data to a msg payload, if enabled
45
69
  *
@@ -60,7 +84,8 @@ function addConnectionCredentials (RED, msg, conn, config) {
60
84
  ...item._client,
61
85
  ...{
62
86
  socketId: conn.id,
63
- socketIp: conn.handshake?.address
87
+ socketIp: conn.handshake?.address,
88
+ clientId: normalizeClientId(conn.handshake?.query?.clientId)
64
89
  }
65
90
  }
66
91
  return item
@@ -127,5 +152,8 @@ module.exports = {
127
152
  appendTopic,
128
153
  getTopic,
129
154
  addConnectionCredentials,
155
+ normalizeClientId,
156
+ isClientScoped,
157
+ matchesClient,
130
158
  getThirdPartyWidgets
131
159
  }
@@ -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
  }
@@ -230,7 +230,7 @@ module.exports = function (RED) {
230
230
  },
231
231
  onInput: function (msg, send, done) {
232
232
  // use our own custom onInput in order to store history of msg payloads
233
- if (!datastore.get(node.id)) {
233
+ if (!datastore.has(node.id)) {
234
234
  datastore.save(base, node, [])
235
235
  }
236
236
  // To prevent ui_update messages from deleting old data, skip this section if no msg.payload present
@@ -268,33 +268,7 @@ module.exports = function (RED) {
268
268
  const maxPoints = parseInt(config.removeOlderPoints)
269
269
 
270
270
  if (maxPoints && config.removeOlderPoints) {
271
- // account for multiple lines?
272
- // client-side does this for _each_ line
273
- // remove older points using datastore.filter instead of saving the whole array
274
- const lineCounts = {}
275
- const _msg = datastore.get(node.id) || []
276
-
277
- // determine which message objects to keep (latest maxPoints per label)
278
- const keepIndexes = []
279
- let doFiltering = false
280
- for (let i = _msg.length - 1; i >= 0; i--) {
281
- const m = _msg[i]
282
- const label = m.topic
283
- lineCounts[label] = lineCounts[label] || 0
284
- if (lineCounts[label] < maxPoints) {
285
- keepIndexes[i] = true
286
- lineCounts[label]++
287
- } else {
288
- doFiltering = true
289
- }
290
- }
291
-
292
- // filter the datastore to only keep the selected messages
293
- if (doFiltering) {
294
- datastore.filter(base, node, (m, i) => {
295
- return keepIndexes[i]
296
- })
297
- }
271
+ datastore.keepLatestPerTopic(base, node, maxPoints)
298
272
  }
299
273
 
300
274
  if (config.xAxisType === 'time' && config.removeOlder && config.removeOlderUnit) {
@@ -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)
@@ -10,6 +10,7 @@ module.exports = function (RED) {
10
10
  const group = RED.nodes.getNode(config.group)
11
11
 
12
12
  const evts = {
13
+ onAction: true,
13
14
  beforeSend: async function (msg) {
14
15
  const updates = msg.ui_update
15
16
  if (updates) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flowfuse/node-red-dashboard",
3
- "version": "1.31.1-e3609ab-202609141637.0",
3
+ "version": "1.32.1-034c8a5-202610061312.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,7 +98,7 @@
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",