From fbfed01f9288eaa7296dd8747fb809f3f4bc24d8 Mon Sep 17 00:00:00 2001 From: Tony Bentley Date: Thu, 19 Jun 2025 12:03:05 -0700 Subject: [PATCH 01/12] Add Active Clients feature to Security section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add getActiveClients() method to WebSocket interface - Add /security/devices/active API endpoint with device name resolution - Add Active Clients navigation menu and page component - Display connected clients with friendly names instead of raw IDs 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .../src/components/Sidebar/Sidebar.js | 4 + .../src/containers/Full/Full.js | 5 + .../src/views/security/ActiveClients.js | 198 ++++++++++++++++++ src/interfaces/ws.js | 17 ++ src/serverroutes.ts | 62 ++++++ 5 files changed, 286 insertions(+) create mode 100644 packages/server-admin-ui/src/views/security/ActiveClients.js diff --git a/packages/server-admin-ui/src/components/Sidebar/Sidebar.js b/packages/server-admin-ui/src/components/Sidebar/Sidebar.js index 2249ad348..454f8b0e9 100644 --- a/packages/server-admin-ui/src/components/Sidebar/Sidebar.js +++ b/packages/server-admin-ui/src/components/Sidebar/Sidebar.js @@ -286,6 +286,10 @@ const mapStateToProps = (state) => { name: 'Devices', url: '/security/devices' }) + security.children.push({ + name: 'Active Clients', + url: '/security/clients' + }) } if ( state.loginStatus.allowNewUserRegistration || diff --git a/packages/server-admin-ui/src/containers/Full/Full.js b/packages/server-admin-ui/src/containers/Full/Full.js index 12cee92dd..9032419ab 100644 --- a/packages/server-admin-ui/src/containers/Full/Full.js +++ b/packages/server-admin-ui/src/containers/Full/Full.js @@ -19,6 +19,7 @@ import Login from '../../views/security/Login' import SecuritySettings from '../../views/security/Settings' import Users from '../../views/security/Users' import Devices from '../../views/security/Devices' +import ActiveClients from '../../views/security/ActiveClients' import Register from '../../views/security/Register' import AccessRequests from '../../views/security/AccessRequests' import ProvidersConfiguration from '../../views/ServerConfig/ProvidersConfiguration' @@ -115,6 +116,10 @@ class Full extends Component { path="/security/devices" component={loginOrOriginal(Devices)} /> + { + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`) + } + return response.json() + }) + .then((data) => { + this.setState({ + activeClients: data || [], + loading: false, + error: null, + }) + }) + .catch((error) => { + console.error('Error fetching active clients:', error) + this.setState({ + loading: false, + error: error.message + }) + }) + } + + refreshData() { + this.setState({ loading: true }) + this.fetchActiveClients() + } + + formatConnectedTime(connectedAt) { + if (!connectedAt) return 'Unknown' + const now = new Date() + const connected = new Date(connectedAt) + const diffMs = now - connected + const diffMins = Math.floor(diffMs / 60000) + const diffHours = Math.floor(diffMins / 60) + const diffDays = Math.floor(diffHours / 24) + + if (diffDays > 0) return `${diffDays}d ${diffHours % 24}h ago` + if (diffHours > 0) return `${diffHours}h ${diffMins % 60}m ago` + if (diffMins > 0) return `${diffMins}m ago` + return 'Just now' + } + + render() { + if (!this.props.loginStatus.authenticationRequired) { + return + } + + const { activeClients, loading, error } = this.state + + return ( +
+ + + Active WebSocket Clients +
+ +
+
+ + {error && ( +
+ Error loading active clients: {error} +
+ )} + + {loading && ( +
+ Loading... +
+ )} + + {!loading && !error && ( + <> +
+ + {activeClients.length} active client{activeClients.length !== 1 ? 's' : ''} + + + Updates automatically every 5 seconds + +
+ + {activeClients.length === 0 ? ( +
+ +

No active WebSocket clients connected

+
+ ) : ( + + + + + + + + + + + + {activeClients.map((client) => ( + + + + + + + + ))} + +
Device NameClient IDRemote AddressConnectedStatus
+ + {client.description !== client.clientId + ? client.description + : Unnamed Client + } + + {client.userAgent && client.description !== client.clientId && ( +
+ + {client.userAgent.length > 50 ? client.userAgent.substring(0, 50) + '...' : client.userAgent} + + )} +
+ {client.clientId} + + {client.remoteAddress} + + {this.formatConnectedTime(client.connectedAt)} + + + Active + +
+ )} + + )} +
+
+
+ ) + } +} + +function mapStateToProps(state) { + return { + loginStatus: state.loginStatus, + } +} + +export default connect(mapStateToProps)(ActiveClients) \ No newline at end of file diff --git a/src/interfaces/ws.js b/src/interfaces/ws.js index 4a6f095c4..09508dc1e 100644 --- a/src/interfaces/ws.js +++ b/src/interfaces/ws.js @@ -54,6 +54,23 @@ module.exports = function (app) { return count } + api.getActiveClients = function () { + const clients = [] + primuses.forEach((primus) => + primus.forEach((spark) => { + clients.push({ + id: spark.id, + skPrincipal: spark.request.skPrincipal, + remoteAddress: spark.request.headers['x-forwarded-for'] || + spark.request.connection.remoteAddress, + userAgent: spark.request.headers['user-agent'], + connectedAt: spark.request.connectedAt || new Date().toISOString() + }) + }) + ) + return clients + } + api.canHandlePut = function (path, source) { const sources = pathSources[path] return sources && (!source || sources[source]) diff --git a/src/serverroutes.ts b/src/serverroutes.ts index 3cd58da08..ebd69ece6 100644 --- a/src/serverroutes.ts +++ b/src/serverroutes.ts @@ -352,6 +352,68 @@ module.exports = function ( } ) + app.get( + `${SERVERROUTESPREFIX}/security/devices/active`, + (req: Request, res: Response) => { + if (checkAllowConfigure(req, res)) { + const activeClients: any[] = [] + const devices = app.securityStrategy.getDevices(getSecurityConfig(app)) + + // Get active WebSocket clients from the WS interface + const anyApp = app as any + if (anyApp.interfaces && anyApp.interfaces.ws) { + anyApp.interfaces.ws.getActiveClients().forEach((client: any) => { + const clientId = client.skPrincipal?.identifier + if (clientId) { + // Find matching device info + const device = devices.find((d: any) => d.clientId === clientId) + + // Build user-friendly display name with priority: + // 1. Device description from registry + // 2. Principal name from authentication + // 3. User agent (shortened) + // 4. Fall back to client ID + let displayName = device?.description + + if (!displayName && client.skPrincipal?.name) { + displayName = client.skPrincipal.name + } + + if (!displayName && client.userAgent) { + // Extract meaningful parts from user agent + const ua = client.userAgent + if (ua.includes('SensESP')) { + displayName = 'SensESP Device' + } else if (ua.includes('SignalK')) { + displayName = 'SignalK Client' + } else if (ua.includes('OpenCPN')) { + displayName = 'OpenCPN' + } else if (ua.includes('Chrome') || ua.includes('Firefox') || ua.includes('Safari')) { + displayName = 'Web Browser' + } else { + // Take first meaningful part of user agent + const parts = ua.split(/[\s\/\(]/) + displayName = parts[0] || 'Unknown Client' + } + } + + activeClients.push({ + clientId, + description: displayName || clientId, + remoteAddress: client.remoteAddress, + userAgent: client.userAgent, + connectedAt: client.connectedAt, + isActive: true + }) + } + }) + } + + res.json(activeClients) + } + } + ) + app.get( `${SERVERROUTESPREFIX}/security/users`, (req: Request, res: Response) => { From bc03a96c44de5d13db7835dc482a6694579f01dc Mon Sep 17 00:00:00 2001 From: Tony Bentley Date: Thu, 19 Jun 2025 12:20:58 -0700 Subject: [PATCH 02/12] Add unit tests for Active Clients API endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Test /skServer/security/devices/active endpoint functionality - Verify endpoint returns connected WebSocket clients with device info - Test authentication requirements (admin access only) - Validate response structure includes clientId, description, remoteAddress, etc. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- test/security.js | 69 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/test/security.js b/test/security.js index e00a80f22..0c0b99b26 100644 --- a/test/security.js +++ b/test/security.js @@ -518,4 +518,73 @@ describe('Security', () => { json = await result.json() json.length.should.equal(1) }) + + it('Active devices endpoint returns connected clients with device info', async function () { + // First create a WebSocket connection to simulate an active client + const ws = new WebSocket(`ws://0.0.0.0:${port}/signalk/v1/stream?subscribe=all`) + + await new Promise((resolve) => { + ws.on('open', resolve) + }) + + // Wait a moment for the connection to be registered + await new Promise(resolve => setTimeout(resolve, 100)) + + try { + // Test the active devices endpoint + const result = await fetch(`${url}/skServer/security/devices/active`, { + headers: { + Cookie: `JAUTHENTICATION=${adminToken}` + } + }) + + result.status.should.equal(200) + const json = await result.json() + + // Should return an array + json.should.be.an('array') + + // Should have at least one active client (our WebSocket connection) + json.length.should.be.at.least(1) + + // Each active client should have the expected structure + const client = json[0] + client.should.have.property('clientId') + client.should.have.property('description') + client.should.have.property('remoteAddress') + client.should.have.property('connectedAt') + client.should.have.property('isActive') + client.isActive.should.equal(true) + + // The description should be user-friendly (not just the raw client ID) + client.description.should.be.a('string') + client.description.length.should.be.greaterThan(0) + + } finally { + // Clean up the WebSocket connection + ws.close() + } + }) + + it('Active devices endpoint requires admin authentication', async function () { + // Test without authentication + let result = await fetch(`${url}/skServer/security/devices/active`) + result.status.should.equal(401) + + // Test with read-only token (should fail) + result = await fetch(`${url}/skServer/security/devices/active`, { + headers: { + Cookie: `JAUTHENTICATION=${readToken}` + } + }) + result.status.should.equal(403) + + // Test with admin token (should succeed) + result = await fetch(`${url}/skServer/security/devices/active`, { + headers: { + Cookie: `JAUTHENTICATION=${adminToken}` + } + }) + result.status.should.equal(200) + }) }) From 4b9d98c5c5b7acd8cf0c96bd90de86e2d8424f4b Mon Sep 17 00:00:00 2001 From: Tony Bentley Date: Thu, 19 Jun 2025 12:22:56 -0700 Subject: [PATCH 03/12] Revert "Add unit tests for Active Clients API endpoint" This reverts commit bc03a96c44de5d13db7835dc482a6694579f01dc. --- test/security.js | 69 ------------------------------------------------ 1 file changed, 69 deletions(-) diff --git a/test/security.js b/test/security.js index 0c0b99b26..e00a80f22 100644 --- a/test/security.js +++ b/test/security.js @@ -518,73 +518,4 @@ describe('Security', () => { json = await result.json() json.length.should.equal(1) }) - - it('Active devices endpoint returns connected clients with device info', async function () { - // First create a WebSocket connection to simulate an active client - const ws = new WebSocket(`ws://0.0.0.0:${port}/signalk/v1/stream?subscribe=all`) - - await new Promise((resolve) => { - ws.on('open', resolve) - }) - - // Wait a moment for the connection to be registered - await new Promise(resolve => setTimeout(resolve, 100)) - - try { - // Test the active devices endpoint - const result = await fetch(`${url}/skServer/security/devices/active`, { - headers: { - Cookie: `JAUTHENTICATION=${adminToken}` - } - }) - - result.status.should.equal(200) - const json = await result.json() - - // Should return an array - json.should.be.an('array') - - // Should have at least one active client (our WebSocket connection) - json.length.should.be.at.least(1) - - // Each active client should have the expected structure - const client = json[0] - client.should.have.property('clientId') - client.should.have.property('description') - client.should.have.property('remoteAddress') - client.should.have.property('connectedAt') - client.should.have.property('isActive') - client.isActive.should.equal(true) - - // The description should be user-friendly (not just the raw client ID) - client.description.should.be.a('string') - client.description.length.should.be.greaterThan(0) - - } finally { - // Clean up the WebSocket connection - ws.close() - } - }) - - it('Active devices endpoint requires admin authentication', async function () { - // Test without authentication - let result = await fetch(`${url}/skServer/security/devices/active`) - result.status.should.equal(401) - - // Test with read-only token (should fail) - result = await fetch(`${url}/skServer/security/devices/active`, { - headers: { - Cookie: `JAUTHENTICATION=${readToken}` - } - }) - result.status.should.equal(403) - - // Test with admin token (should succeed) - result = await fetch(`${url}/skServer/security/devices/active`, { - headers: { - Cookie: `JAUTHENTICATION=${adminToken}` - } - }) - result.status.should.equal(200) - }) }) From 5f9d8cc44f9888d0036958c62eba9f8b1c408778 Mon Sep 17 00:00:00 2001 From: Tony Bentley Date: Thu, 19 Jun 2025 12:51:20 -0700 Subject: [PATCH 04/12] test --- test/security.js | 117 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 117 insertions(+) diff --git a/test/security.js b/test/security.js index e00a80f22..d830d32f4 100644 --- a/test/security.js +++ b/test/security.js @@ -518,4 +518,121 @@ describe('Security', () => { json = await result.json() json.length.should.equal(1) }) + + it('Active clients endpoint requires authentication', async function () { + const result = await fetch(`${url}/skServer/security/devices/active`) + result.status.should.equal(401) + }) + + it('Active clients endpoint returns array of active clients', async function () { + const result = await fetch(`${url}/skServer/security/devices/active`, { + headers: { + Cookie: `JAUTHENTICATION=${adminToken}` + } + }) + result.status.should.equal(200) + const json = await result.json() + json.should.be.an('array') + // Array length will vary based on connected test clients + }) + + it('Active clients endpoint returns connected WebSocket clients', async function () { + // Connect a WebSocket client + const ws = new WebSocket(`ws://localhost:${port}/signalk/v1/stream?subscribe=none`) + + await new Promise((resolve, reject) => { + ws.on('open', resolve) + ws.on('error', reject) + }) + + // Give a moment for the client to be registered + await new Promise(resolve => setTimeout(resolve, 100)) + + const result = await fetch(`${url}/skServer/security/devices/active`, { + headers: { + Cookie: `JAUTHENTICATION=${adminToken}` + } + }) + result.status.should.equal(200) + const json = await result.json() + json.should.be.an('array') + json.length.should.be.greaterThan(0) + + // Check client structure if any clients are present + if (json.length > 0) { + const client = json[0] + client.should.have.property('clientId') + client.should.have.property('connectedAt') + client.should.have.property('remoteAddress') + client.should.have.property('description') + client.should.have.property('isActive') + + // userAgent may or may not be present depending on client + if (client.userAgent !== undefined) { + client.userAgent.should.be.a('string') + } + + // isActive should be true for active clients + client.isActive.should.equal(true) + } + + ws.close() + }) + + it('Active clients endpoint includes device names from registry when available', async function () { + // First, create a device in the registry via access request + let result = await fetch(`${url}/signalk/v1/access/requests`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + clientId: 'test-device-123', + description: 'Test Navigation Device', + permissions: 'read' + }) + }) + result.status.should.equal(202) + const requestJson = await result.json() + + // Approve the device + result = await fetch( + `${url}/skServer/security/access/requests/test-device-123/approved`, + { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Cookie: `JAUTHENTICATION=${adminToken}` + }, + body: JSON.stringify({ + expiration: '1y', + permissions: 'read' + }) + } + ) + result.status.should.equal(200) + + // Connect a WebSocket client with the registered device ID + // Note: This is a simplified test - in reality the client would authenticate + const ws = new WebSocket(`ws://localhost:${port}/signalk/v1/stream?subscribe=none`) + + await new Promise((resolve, reject) => { + ws.on('open', resolve) + ws.on('error', reject) + }) + + await new Promise(resolve => setTimeout(resolve, 100)) + + result = await fetch(`${url}/skServer/security/devices/active`, { + headers: { + Cookie: `JAUTHENTICATION=${adminToken}` + } + }) + result.status.should.equal(200) + const json = await result.json() + json.should.be.an('array') + json.length.should.be.greaterThan(0) + + ws.close() + }) }) From 949f661f47b76fc1573e773042e7a6b18b465d17 Mon Sep 17 00:00:00 2001 From: Tony Bentley Date: Thu, 19 Jun 2025 12:54:37 -0700 Subject: [PATCH 05/12] remove timers --- test/security.js | 5 ----- 1 file changed, 5 deletions(-) diff --git a/test/security.js b/test/security.js index d830d32f4..e96b1cc35 100644 --- a/test/security.js +++ b/test/security.js @@ -545,9 +545,6 @@ describe('Security', () => { ws.on('error', reject) }) - // Give a moment for the client to be registered - await new Promise(resolve => setTimeout(resolve, 100)) - const result = await fetch(`${url}/skServer/security/devices/active`, { headers: { Cookie: `JAUTHENTICATION=${adminToken}` @@ -621,8 +618,6 @@ describe('Security', () => { ws.on('error', reject) }) - await new Promise(resolve => setTimeout(resolve, 100)) - result = await fetch(`${url}/skServer/security/devices/active`, { headers: { Cookie: `JAUTHENTICATION=${adminToken}` From 4330fbe63c14c3bd745d69c3e3d254ec2089c7b8 Mon Sep 17 00:00:00 2001 From: Tony Bentley Date: Sat, 21 Jun 2025 19:04:08 -0700 Subject: [PATCH 06/12] publish v1 --- .../src/views/security/ActiveClients.js | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/server-admin-ui/src/views/security/ActiveClients.js b/packages/server-admin-ui/src/views/security/ActiveClients.js index da515bb5e..5e81016c7 100644 --- a/packages/server-admin-ui/src/views/security/ActiveClients.js +++ b/packages/server-admin-ui/src/views/security/ActiveClients.js @@ -155,10 +155,12 @@ class ActiveClients extends Component { } {client.userAgent && client.description !== client.clientId && ( -
- - {client.userAgent.length > 50 ? client.userAgent.substring(0, 50) + '...' : client.userAgent} - + <> +
+ + {client.userAgent.length > 50 ? client.userAgent.substring(0, 50) + '...' : client.userAgent} + + )} From 261b1336e433395b21df67d0451b1ed3ee7afbc7 Mon Sep 17 00:00:00 2001 From: Tony Bentley Date: Sun, 22 Jun 2025 12:54:29 -0700 Subject: [PATCH 07/12] feat: Show device names instead of WebSocket IDs in Dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add device name resolution utility from Active Clients feature - Update deltastats to include display names for WebSocket providers - Modify Dashboard to show friendly device names with ID fallback - Remove Active Clients page, route, menu item and API endpoint - Address PR #2017 review feedback to reduce UI complexity - Resolves issue #1342 for WebSocket device identification This change improves the Dashboard by showing meaningful device names (e.g., 'OpenCPN', 'SensESP Device') instead of auto-generated IDs (e.g., 'ws.c3f60a8f-c123-4b45-8de7') in the connections activity list. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .../src/components/Sidebar/Sidebar.js | 4 - .../src/containers/Full/Full.js | 5 - .../src/views/Dashboard/Dashboard.js | 11 +- .../src/views/security/ActiveClients.js | 200 ------------------ src/deltastats.ts | 25 +++ src/deviceNameResolver.ts | 52 +++++ src/serverroutes.ts | 62 ------ test/security.js | 113 +--------- 8 files changed, 84 insertions(+), 388 deletions(-) delete mode 100644 packages/server-admin-ui/src/views/security/ActiveClients.js create mode 100644 src/deviceNameResolver.ts diff --git a/packages/server-admin-ui/src/components/Sidebar/Sidebar.js b/packages/server-admin-ui/src/components/Sidebar/Sidebar.js index 454f8b0e9..2249ad348 100644 --- a/packages/server-admin-ui/src/components/Sidebar/Sidebar.js +++ b/packages/server-admin-ui/src/components/Sidebar/Sidebar.js @@ -286,10 +286,6 @@ const mapStateToProps = (state) => { name: 'Devices', url: '/security/devices' }) - security.children.push({ - name: 'Active Clients', - url: '/security/clients' - }) } if ( state.loginStatus.allowNewUserRegistration || diff --git a/packages/server-admin-ui/src/containers/Full/Full.js b/packages/server-admin-ui/src/containers/Full/Full.js index 9032419ab..12cee92dd 100644 --- a/packages/server-admin-ui/src/containers/Full/Full.js +++ b/packages/server-admin-ui/src/containers/Full/Full.js @@ -19,7 +19,6 @@ import Login from '../../views/security/Login' import SecuritySettings from '../../views/security/Settings' import Users from '../../views/security/Users' import Devices from '../../views/security/Devices' -import ActiveClients from '../../views/security/ActiveClients' import Register from '../../views/security/Register' import AccessRequests from '../../views/security/AccessRequests' import ProvidersConfiguration from '../../views/ServerConfig/ProvidersConfiguration' @@ -116,10 +115,6 @@ class Full extends Component { path="/security/devices" component={loginOrOriginal(Devices)} /> - { {linkType === 'plugin' ? pluginNameLink(providerId) - : providerIdLink(providerId)} + : providerIdLink(providerId, providerStats.displayName)} {providerStats.writeRate > 0 && ( @@ -285,13 +285,14 @@ function pluginNameLink(id) { return {id} } -function providerIdLink(id) { +function providerIdLink(id, displayName) { + const linkText = displayName || id if (id === 'defaults') { - return {id} + return {linkText} } else if (id.startsWith('ws.')) { - return {id} + return {linkText} } else { - return {id} + return {linkText} } } diff --git a/packages/server-admin-ui/src/views/security/ActiveClients.js b/packages/server-admin-ui/src/views/security/ActiveClients.js deleted file mode 100644 index 5e81016c7..000000000 --- a/packages/server-admin-ui/src/views/security/ActiveClients.js +++ /dev/null @@ -1,200 +0,0 @@ -import React, { Component } from 'react' -import { connect } from 'react-redux' -import { - Card, - CardHeader, - CardBody, - Table, - Badge, -} from 'reactstrap' -import EnableSecurity from './EnableSecurity' - -class ActiveClients extends Component { - constructor(props) { - super(props) - this.state = { - activeClients: [], - loading: true, - error: null, - } - - this.fetchActiveClients = this.fetchActiveClients.bind(this) - this.refreshData = this.refreshData.bind(this) - } - - componentDidMount() { - if (this.props.loginStatus.authenticationRequired) { - this.fetchActiveClients() - // Refresh every 5 seconds - this.interval = setInterval(this.fetchActiveClients, 5000) - } - } - - componentWillUnmount() { - if (this.interval) { - clearInterval(this.interval) - } - } - - fetchActiveClients() { - fetch(`${window.serverRoutesPrefix}/security/devices/active`, { - credentials: 'include', - }) - .then((response) => { - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`) - } - return response.json() - }) - .then((data) => { - this.setState({ - activeClients: data || [], - loading: false, - error: null, - }) - }) - .catch((error) => { - console.error('Error fetching active clients:', error) - this.setState({ - loading: false, - error: error.message - }) - }) - } - - refreshData() { - this.setState({ loading: true }) - this.fetchActiveClients() - } - - formatConnectedTime(connectedAt) { - if (!connectedAt) return 'Unknown' - const now = new Date() - const connected = new Date(connectedAt) - const diffMs = now - connected - const diffMins = Math.floor(diffMs / 60000) - const diffHours = Math.floor(diffMins / 60) - const diffDays = Math.floor(diffHours / 24) - - if (diffDays > 0) return `${diffDays}d ${diffHours % 24}h ago` - if (diffHours > 0) return `${diffHours}h ${diffMins % 60}m ago` - if (diffMins > 0) return `${diffMins}m ago` - return 'Just now' - } - - render() { - if (!this.props.loginStatus.authenticationRequired) { - return - } - - const { activeClients, loading, error } = this.state - - return ( -
- - - Active WebSocket Clients -
- -
-
- - {error && ( -
- Error loading active clients: {error} -
- )} - - {loading && ( -
- Loading... -
- )} - - {!loading && !error && ( - <> -
- - {activeClients.length} active client{activeClients.length !== 1 ? 's' : ''} - - - Updates automatically every 5 seconds - -
- - {activeClients.length === 0 ? ( -
- -

No active WebSocket clients connected

-
- ) : ( - - - - - - - - - - - - {activeClients.map((client) => ( - - - - - - - - ))} - -
Device NameClient IDRemote AddressConnectedStatus
- - {client.description !== client.clientId - ? client.description - : Unnamed Client - } - - {client.userAgent && client.description !== client.clientId && ( - <> -
- - {client.userAgent.length > 50 ? client.userAgent.substring(0, 50) + '...' : client.userAgent} - - - )} -
- {client.clientId} - - {client.remoteAddress} - - {this.formatConnectedTime(client.connectedAt)} - - - Active - -
- )} - - )} -
-
-
- ) - } -} - -function mapStateToProps(state) { - return { - loginStatus: state.loginStatus, - } -} - -export default connect(mapStateToProps)(ActiveClients) \ No newline at end of file diff --git a/src/deltastats.ts b/src/deltastats.ts index d59ede8da..0fa049ada 100644 --- a/src/deltastats.ts +++ b/src/deltastats.ts @@ -17,6 +17,7 @@ import { isUndefined, values } from 'lodash' import { EventEmitter } from 'node:events' +import { resolveDeviceName } from './deviceNameResolver' const STATS_UPDATE_INTERVAL_SECONDS = 5 export const CONNECTION_WRITE_EVENT_NAME = 'connectionwrite' @@ -33,6 +34,7 @@ class ProviderStats { deltaRate: number deltaCount: number lastIntervalDeltaCount: number + displayName?: string constructor() { this.writeRate = this.writeCount = @@ -73,6 +75,29 @@ export function startDeltaStatistics( return setInterval(() => { updateProviderPeriodStats(app) const anyApp = app as any + + // Add display names for WebSocket connections + if (anyApp.securityStrategy?.getDevices && anyApp.interfaces?.ws && anyApp.getSecurityConfig) { + const securityConfig = anyApp.getSecurityConfig(anyApp) + const devices = anyApp.securityStrategy.getDevices(securityConfig) + const activeClients = anyApp.interfaces.ws.getActiveClients() + + Object.keys(app.providerStatistics).forEach((providerId) => { + if (providerId.startsWith('ws.')) { + const clientId = providerId.substring(3).replace(/_/g, '.') + const clientInfo = activeClients.find((c: any) => c.skPrincipal?.identifier === clientId) + + if (clientInfo) { + app.providerStatistics[providerId].displayName = resolveDeviceName( + clientId, + devices || [], + clientInfo + ) + } + } + }) + } + app.emit('serverevent', { type: 'SERVERSTATISTICS', from: 'signalk-server', diff --git a/src/deviceNameResolver.ts b/src/deviceNameResolver.ts new file mode 100644 index 000000000..fb45e682a --- /dev/null +++ b/src/deviceNameResolver.ts @@ -0,0 +1,52 @@ +/* + * Device Name Resolution Utility + * Resolves WebSocket client IDs to user-friendly display names + */ + +interface Device { + clientId: string + description?: string +} + +interface ClientInfo { + skPrincipal?: { name?: string } + userAgent?: string +} + +export function resolveDeviceName( + clientId: string, + devices: Device[], + clientInfo?: ClientInfo +): string { + // 1. Device description from registry + const device = devices.find((d) => d.clientId === clientId) + if (device?.description) { + return device.description + } + + // 2. Principal name from authentication + if (clientInfo?.skPrincipal?.name) { + return clientInfo.skPrincipal.name + } + + // 3. User agent (shortened) + if (clientInfo?.userAgent) { + const ua = clientInfo.userAgent + if (ua.includes('SensESP')) { + return 'SensESP Device' + } else if (ua.includes('SignalK')) { + return 'SignalK Client' + } else if (ua.includes('OpenCPN')) { + return 'OpenCPN' + } else if (ua.includes('Chrome') || ua.includes('Firefox') || ua.includes('Safari')) { + return 'Web Browser' + } else { + // Take first meaningful part of user agent + const parts = ua.split(/[\s\/\(]/) + return parts[0] || 'Unknown Client' + } + } + + // 4. Fall back to client ID + return clientId +} \ No newline at end of file diff --git a/src/serverroutes.ts b/src/serverroutes.ts index ebd69ece6..3cd58da08 100644 --- a/src/serverroutes.ts +++ b/src/serverroutes.ts @@ -352,68 +352,6 @@ module.exports = function ( } ) - app.get( - `${SERVERROUTESPREFIX}/security/devices/active`, - (req: Request, res: Response) => { - if (checkAllowConfigure(req, res)) { - const activeClients: any[] = [] - const devices = app.securityStrategy.getDevices(getSecurityConfig(app)) - - // Get active WebSocket clients from the WS interface - const anyApp = app as any - if (anyApp.interfaces && anyApp.interfaces.ws) { - anyApp.interfaces.ws.getActiveClients().forEach((client: any) => { - const clientId = client.skPrincipal?.identifier - if (clientId) { - // Find matching device info - const device = devices.find((d: any) => d.clientId === clientId) - - // Build user-friendly display name with priority: - // 1. Device description from registry - // 2. Principal name from authentication - // 3. User agent (shortened) - // 4. Fall back to client ID - let displayName = device?.description - - if (!displayName && client.skPrincipal?.name) { - displayName = client.skPrincipal.name - } - - if (!displayName && client.userAgent) { - // Extract meaningful parts from user agent - const ua = client.userAgent - if (ua.includes('SensESP')) { - displayName = 'SensESP Device' - } else if (ua.includes('SignalK')) { - displayName = 'SignalK Client' - } else if (ua.includes('OpenCPN')) { - displayName = 'OpenCPN' - } else if (ua.includes('Chrome') || ua.includes('Firefox') || ua.includes('Safari')) { - displayName = 'Web Browser' - } else { - // Take first meaningful part of user agent - const parts = ua.split(/[\s\/\(]/) - displayName = parts[0] || 'Unknown Client' - } - } - - activeClients.push({ - clientId, - description: displayName || clientId, - remoteAddress: client.remoteAddress, - userAgent: client.userAgent, - connectedAt: client.connectedAt, - isActive: true - }) - } - }) - } - - res.json(activeClients) - } - } - ) - app.get( `${SERVERROUTESPREFIX}/security/users`, (req: Request, res: Response) => { diff --git a/test/security.js b/test/security.js index e96b1cc35..10affee85 100644 --- a/test/security.js +++ b/test/security.js @@ -518,116 +518,5 @@ describe('Security', () => { json = await result.json() json.length.should.equal(1) }) - - it('Active clients endpoint requires authentication', async function () { - const result = await fetch(`${url}/skServer/security/devices/active`) - result.status.should.equal(401) - }) - - it('Active clients endpoint returns array of active clients', async function () { - const result = await fetch(`${url}/skServer/security/devices/active`, { - headers: { - Cookie: `JAUTHENTICATION=${adminToken}` - } - }) - result.status.should.equal(200) - const json = await result.json() - json.should.be.an('array') - // Array length will vary based on connected test clients - }) - - it('Active clients endpoint returns connected WebSocket clients', async function () { - // Connect a WebSocket client - const ws = new WebSocket(`ws://localhost:${port}/signalk/v1/stream?subscribe=none`) - - await new Promise((resolve, reject) => { - ws.on('open', resolve) - ws.on('error', reject) - }) - - const result = await fetch(`${url}/skServer/security/devices/active`, { - headers: { - Cookie: `JAUTHENTICATION=${adminToken}` - } - }) - result.status.should.equal(200) - const json = await result.json() - json.should.be.an('array') - json.length.should.be.greaterThan(0) - - // Check client structure if any clients are present - if (json.length > 0) { - const client = json[0] - client.should.have.property('clientId') - client.should.have.property('connectedAt') - client.should.have.property('remoteAddress') - client.should.have.property('description') - client.should.have.property('isActive') - - // userAgent may or may not be present depending on client - if (client.userAgent !== undefined) { - client.userAgent.should.be.a('string') - } - - // isActive should be true for active clients - client.isActive.should.equal(true) - } - - ws.close() - }) - - it('Active clients endpoint includes device names from registry when available', async function () { - // First, create a device in the registry via access request - let result = await fetch(`${url}/signalk/v1/access/requests`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - clientId: 'test-device-123', - description: 'Test Navigation Device', - permissions: 'read' - }) - }) - result.status.should.equal(202) - const requestJson = await result.json() - - // Approve the device - result = await fetch( - `${url}/skServer/security/access/requests/test-device-123/approved`, - { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - Cookie: `JAUTHENTICATION=${adminToken}` - }, - body: JSON.stringify({ - expiration: '1y', - permissions: 'read' - }) - } - ) - result.status.should.equal(200) - - // Connect a WebSocket client with the registered device ID - // Note: This is a simplified test - in reality the client would authenticate - const ws = new WebSocket(`ws://localhost:${port}/signalk/v1/stream?subscribe=none`) - - await new Promise((resolve, reject) => { - ws.on('open', resolve) - ws.on('error', reject) - }) - - result = await fetch(`${url}/skServer/security/devices/active`, { - headers: { - Cookie: `JAUTHENTICATION=${adminToken}` - } - }) - result.status.should.equal(200) - const json = await result.json() - json.should.be.an('array') - json.length.should.be.greaterThan(0) - - ws.close() - }) }) + From 32a5793ed5970622a82a183dc5ec29db571ebf14 Mon Sep 17 00:00:00 2001 From: Tony Bentley Date: Sun, 22 Jun 2025 14:27:51 -0700 Subject: [PATCH 08/12] feat(device name display) Implemented a device name display feature --- src/deltastats.ts | 62 +++++++++++++++--- src/deviceRegistryCache.ts | 112 +++++++++++++++++++++++++++++++++ src/deviceRegistryCacheInit.ts | 70 +++++++++++++++++++++ src/index.ts | 4 ++ src/interfaces/ws.js | 16 ++++- src/serverroutes.ts | 37 +++++++---- test/deviceNameResolver.js | 104 ++++++++++++++++++++++++++++++ 7 files changed, 383 insertions(+), 22 deletions(-) create mode 100644 src/deviceRegistryCache.ts create mode 100644 src/deviceRegistryCacheInit.ts create mode 100644 test/deviceNameResolver.js diff --git a/src/deltastats.ts b/src/deltastats.ts index 0fa049ada..ea07fa018 100644 --- a/src/deltastats.ts +++ b/src/deltastats.ts @@ -18,6 +18,10 @@ import { isUndefined, values } from 'lodash' import { EventEmitter } from 'node:events' import { resolveDeviceName } from './deviceNameResolver' +import { deviceRegistryCache } from './deviceRegistryCache' +import { createDebug } from './debug' + +const debug = createDebug('signalk-server:deltastats') const STATS_UPDATE_INTERVAL_SECONDS = 5 export const CONNECTION_WRITE_EVENT_NAME = 'connectionwrite' @@ -77,22 +81,64 @@ export function startDeltaStatistics( const anyApp = app as any // Add display names for WebSocket connections - if (anyApp.securityStrategy?.getDevices && anyApp.interfaces?.ws && anyApp.getSecurityConfig) { - const securityConfig = anyApp.getSecurityConfig(anyApp) - const devices = anyApp.securityStrategy.getDevices(securityConfig) + if (anyApp.interfaces?.ws) { + // Use device registry cache for background stats generation + const devices = deviceRegistryCache.getAllDevices() const activeClients = anyApp.interfaces.ws.getActiveClients() + debug('Active WebSocket clients:', activeClients.length) + debug('Cached devices:', devices.map(d => ({ clientId: d.clientId, description: d.description }))) Object.keys(app.providerStatistics).forEach((providerId) => { if (providerId.startsWith('ws.')) { - const clientId = providerId.substring(3).replace(/_/g, '.') - const clientInfo = activeClients.find((c: any) => c.skPrincipal?.identifier === clientId) + debug('Processing WebSocket provider:', providerId) + + // Find matching client by various ID formats + const clientInfo = activeClients.find((c: any) => { + // Try direct ID match first + if (`ws.${c.id}` === providerId) { + debug('Found matching client by direct ID:', c.id) + return true + } + + // Try principal identifier match + if (c.skPrincipal?.identifier && `ws.${c.skPrincipal.identifier.replace(/\./g, '_')}` === providerId) { + debug('Found matching client by principal:', c.skPrincipal.identifier) + return true + } + + return false + }) if (clientInfo) { - app.providerStatistics[providerId].displayName = resolveDeviceName( - clientId, - devices || [], + debug('Client info:', { + id: clientInfo.id, + principal: clientInfo.skPrincipal?.identifier, + userAgent: clientInfo.userAgent + }) + + // Use device registry cache for name resolution + // Try multiple ID formats for device lookup + let deviceId = clientInfo.id + + // If we have a principal identifier, it might be the device ID + if (clientInfo.skPrincipal?.identifier) { + // Check if any device matches the principal identifier + const deviceByPrincipal = devices.find(d => d.clientId === clientInfo.skPrincipal.identifier) + if (deviceByPrincipal) { + deviceId = clientInfo.skPrincipal.identifier + debug('Found device by principal identifier:', deviceId) + } + } + + const displayName = resolveDeviceName( + deviceId, + devices, clientInfo ) + app.providerStatistics[providerId].displayName = displayName + debug('Resolved display name:', displayName, 'for', providerId, 'using device ID:', deviceId) + } else { + debug('No matching client found for', providerId) } } }) diff --git a/src/deviceRegistryCache.ts b/src/deviceRegistryCache.ts new file mode 100644 index 000000000..302988ced --- /dev/null +++ b/src/deviceRegistryCache.ts @@ -0,0 +1,112 @@ +/* + * Device Registry Cache Manager + * Maintains an in-memory cache of device registry data with event-driven updates + */ + +import { EventEmitter } from 'events' +import { createDebug } from './debug' + +const debug = createDebug('signalk-server:device-registry-cache') + +export interface Device { + clientId: string + permissions: string + description?: string + config?: Record +} + +interface CacheEventEmitter extends EventEmitter { + on(event: 'updated', listener: () => void): this + emit(event: 'updated'): boolean +} + +export class DeviceRegistryCache { + private devices: Map = new Map() + private events: CacheEventEmitter = new EventEmitter() + + constructor() { + debug('Device registry cache initialized') + } + + /** + * Initialize cache with device data + */ + initialize(devices: Device[]): void { + this.devices.clear() + devices.forEach(device => { + this.devices.set(device.clientId, device) + }) + debug(`Cache initialized with ${this.devices.size} devices`) + this.events.emit('updated') + } + + /** + * Update cache with new device data + */ + update(devices: Device[]): void { + const previousSize = this.devices.size + this.initialize(devices) + if (previousSize !== this.devices.size) { + debug(`Cache updated: ${previousSize} -> ${this.devices.size} devices`) + } + } + + /** + * Get device by client ID + */ + getDevice(clientId: string): Device | undefined { + return this.devices.get(clientId) + } + + /** + * Get all devices + */ + getAllDevices(): Device[] { + return Array.from(this.devices.values()) + } + + /** + * Add or update a single device + */ + setDevice(device: Device): void { + const isNew = !this.devices.has(device.clientId) + this.devices.set(device.clientId, device) + debug(`Device ${isNew ? 'added' : 'updated'}: ${device.clientId}`) + this.events.emit('updated') + } + + /** + * Remove a device + */ + removeDevice(clientId: string): boolean { + const removed = this.devices.delete(clientId) + if (removed) { + debug(`Device removed: ${clientId}`) + this.events.emit('updated') + } + return removed + } + + /** + * Subscribe to cache updates + */ + onUpdate(listener: () => void): () => void { + this.events.on('updated', listener) + return () => { + this.events.removeListener('updated', listener) + } + } + + /** + * Get cache statistics + */ + getStats(): { deviceCount: number; cacheSize: number } { + return { + deviceCount: this.devices.size, + cacheSize: JSON.stringify(Array.from(this.devices.values())).length + } + } +} + +// Singleton instance +export const deviceRegistryCache = new DeviceRegistryCache() \ No newline at end of file diff --git a/src/deviceRegistryCacheInit.ts b/src/deviceRegistryCacheInit.ts new file mode 100644 index 000000000..027bfad4e --- /dev/null +++ b/src/deviceRegistryCacheInit.ts @@ -0,0 +1,70 @@ +/* + * Device Registry Cache Initialization + * Initializes and maintains the device registry cache with security config updates + */ + +import { deviceRegistryCache, Device } from './deviceRegistryCache' +import { WithSecurityStrategy } from './security' +import { createDebug } from './debug' +import { EventEmitter } from 'events' + +const debug = createDebug('signalk-server:device-registry-cache-init') + +interface AppWithEvents extends WithSecurityStrategy, EventEmitter { + securityStrategy: WithSecurityStrategy['securityStrategy'] & { + getDevices?: (config: Record) => Device[] + getConfiguration?: () => Record + } +} + +export function initializeDeviceRegistryCache(app: AppWithEvents) { + debug('Initializing device registry cache') + + // Initial load of devices + loadDevices(app) + + // Listen for security configuration changes + if (app.on) { + app.on('securityConfigChange', () => { + debug('Security config changed, updating device registry cache') + loadDevices(app) + }) + } + + // Also listen for specific device updates if available + if (app.on) { + app.on('deviceAdded', (device: Device) => { + debug('Device added:', device.clientId) + deviceRegistryCache.setDevice(device) + }) + + app.on('deviceUpdated', (device: Device) => { + debug('Device updated:', device.clientId) + deviceRegistryCache.setDevice(device) + }) + + app.on('deviceRemoved', (clientId: string) => { + debug('Device removed:', clientId) + deviceRegistryCache.removeDevice(clientId) + }) + } +} + +function loadDevices(app: AppWithEvents) { + try { + if (app.securityStrategy && typeof app.securityStrategy.getDevices === 'function') { + // Get the current configuration + const config = app.securityStrategy.getConfiguration ? + app.securityStrategy.getConfiguration() : + {} + + const devices = app.securityStrategy.getDevices(config) + debug(`Loading ${devices.length} devices into cache`) + deviceRegistryCache.initialize(devices) + } else { + debug('Security strategy does not support getDevices') + } + } catch (error) { + console.error('Error loading devices into cache:', error) + } +} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index d14286e5f..70949dd2b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -100,6 +100,10 @@ class Server { setupCors(app, getSecurityConfig(app)) startSecurity(app, opts ? opts.securityConfig : null) + + // Initialize device registry cache after security is set up + const { initializeDeviceRegistryCache } = require('./deviceRegistryCacheInit') + initializeDeviceRegistryCache(app) require('./serverroutes')(app, saveSecurityConfig, getSecurityConfig) require('./put').start(app) diff --git a/src/interfaces/ws.js b/src/interfaces/ws.js index 09508dc1e..b85b86efe 100644 --- a/src/interfaces/ws.js +++ b/src/interfaces/ws.js @@ -54,18 +54,28 @@ module.exports = function (app) { return count } - api.getActiveClients = function () { + api.getActiveClients = function (securityContext) { const clients = [] primuses.forEach((primus) => primus.forEach((spark) => { - clients.push({ + const clientInfo = { id: spark.id, skPrincipal: spark.request.skPrincipal, remoteAddress: spark.request.headers['x-forwarded-for'] || spark.request.connection.remoteAddress, userAgent: spark.request.headers['user-agent'], connectedAt: spark.request.connectedAt || new Date().toISOString() - }) + } + + // If security context is provided, enhance with device description + if (securityContext && securityContext.getDevice) { + const device = securityContext.getDevice(spark.id) + if (device && device.description) { + clientInfo.deviceDescription = device.description + } + } + + clients.push(clientInfo) }) ) return clients diff --git a/src/serverroutes.ts b/src/serverroutes.ts index 3cd58da08..373573a0d 100644 --- a/src/serverroutes.ts +++ b/src/serverroutes.ts @@ -324,11 +324,20 @@ module.exports = function ( config, req.params.uuid, req.body, - getConfigSavingCallback( - 'Device updated', - 'Unable to update device', - res - ) + (err: any, updatedConfig: any) => { + if (!err && updatedConfig) { + // Find the updated device + const updatedDevice = updatedConfig.devices?.find((d: any) => d.clientId === req.params.uuid) + if (updatedDevice) { + app.emit('deviceUpdated', updatedDevice) + } + } + getConfigSavingCallback( + 'Device updated', + 'Unable to update device', + res + )(err, updatedConfig) + } ) } } @@ -339,14 +348,20 @@ module.exports = function ( (req: Request, res: Response) => { if (checkAllowConfigure(req, res)) { const config = getSecurityConfig(app) + const deviceId = req.params.uuid app.securityStrategy.deleteDevice( config, - req.params.uuid, - getConfigSavingCallback( - 'Device deleted', - 'Unable to delete device', - res - ) + deviceId, + (err: any, updatedConfig: any) => { + if (!err && updatedConfig) { + app.emit('deviceRemoved', deviceId) + } + getConfigSavingCallback( + 'Device deleted', + 'Unable to delete device', + res + )(err, updatedConfig) + } ) } } diff --git a/test/deviceNameResolver.js b/test/deviceNameResolver.js new file mode 100644 index 000000000..44a249fcd --- /dev/null +++ b/test/deviceNameResolver.js @@ -0,0 +1,104 @@ +const chai = require('chai') +chai.Should() +const { resolveDeviceName } = require('../src/deviceNameResolver') + +describe('Device Name Resolution', () => { + it('returns device description as first priority', () => { + const devices = [ + { clientId: 'test-client', description: 'Test Device Description' } + ] + const clientInfo = { + skPrincipal: { name: 'Test User' }, + userAgent: 'Mozilla/5.0' + } + + const result = resolveDeviceName('test-client', devices, clientInfo) + result.should.equal('Test Device Description') + }) + + it('returns principal name as second priority', () => { + const devices = [] + const clientInfo = { + skPrincipal: { name: 'Test User' }, + userAgent: 'Mozilla/5.0' + } + + const result = resolveDeviceName('test-client', devices, clientInfo) + result.should.equal('Test User') + }) + + it('returns parsed user agent as third priority', () => { + const devices = [] + const clientInfo = { + userAgent: 'SensESP/2.0' + } + + const result = resolveDeviceName('test-client', devices, clientInfo) + result.should.equal('SensESP Device') + }) + + it('correctly identifies OpenCPN', () => { + const devices = [] + const clientInfo = { + userAgent: 'OpenCPN/5.6.2' + } + + const result = resolveDeviceName('test-client', devices, clientInfo) + result.should.equal('OpenCPN') + }) + + it('correctly identifies SignalK clients', () => { + const devices = [] + const clientInfo = { + userAgent: 'SignalK/1.0' + } + + const result = resolveDeviceName('test-client', devices, clientInfo) + result.should.equal('SignalK Client') + }) + + it('correctly identifies web browsers', () => { + const devices = [] + const clientInfo = { + userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36' + } + + const result = resolveDeviceName('test-client', devices, clientInfo) + result.should.equal('Web Browser') + }) + + it('extracts first part of unknown user agents', () => { + const devices = [] + const clientInfo = { + userAgent: 'ESP32-Device/1.0 (Custom Firmware)' + } + + const result = resolveDeviceName('test-client', devices, clientInfo) + result.should.equal('ESP32-Device') + }) + + it('returns client ID as fallback', () => { + const devices = [] + const clientInfo = {} + + const result = resolveDeviceName('test-client', devices, clientInfo) + result.should.equal('test-client') + }) + + it('handles missing clientInfo gracefully', () => { + const devices = [] + + const result = resolveDeviceName('test-client', devices) + result.should.equal('test-client') + }) + + it('handles empty user agent gracefully', () => { + const devices = [] + const clientInfo = { + userAgent: '' + } + + const result = resolveDeviceName('test-client', devices, clientInfo) + result.should.equal('test-client') + }) +}) \ No newline at end of file From d754921a84d9f4c2c83d0ac756585e2f4e7a65d4 Mon Sep 17 00:00:00 2001 From: Tony Bentley Date: Sun, 22 Jun 2025 14:39:04 -0700 Subject: [PATCH 09/12] formatting for lint and ts --- src/deltastats.ts | 64 ++++++++++++++++++++++------------ src/deviceRegistryCache.ts | 6 ++-- src/deviceRegistryCacheInit.ts | 25 +++++++------ src/serverroutes.ts | 11 ++++-- 4 files changed, 66 insertions(+), 40 deletions(-) diff --git a/src/deltastats.ts b/src/deltastats.ts index ea07fa018..d9381d503 100644 --- a/src/deltastats.ts +++ b/src/deltastats.ts @@ -79,19 +79,25 @@ export function startDeltaStatistics( return setInterval(() => { updateProviderPeriodStats(app) const anyApp = app as any - + // Add display names for WebSocket connections if (anyApp.interfaces?.ws) { // Use device registry cache for background stats generation const devices = deviceRegistryCache.getAllDevices() const activeClients = anyApp.interfaces.ws.getActiveClients() debug('Active WebSocket clients:', activeClients.length) - debug('Cached devices:', devices.map(d => ({ clientId: d.clientId, description: d.description }))) - + debug( + 'Cached devices:', + devices.map((d) => ({ + clientId: d.clientId, + description: d.description + })) + ) + Object.keys(app.providerStatistics).forEach((providerId) => { if (providerId.startsWith('ws.')) { debug('Processing WebSocket provider:', providerId) - + // Find matching client by various ID formats const clientInfo = activeClients.find((c: any) => { // Try direct ID match first @@ -99,51 +105,63 @@ export function startDeltaStatistics( debug('Found matching client by direct ID:', c.id) return true } - + // Try principal identifier match - if (c.skPrincipal?.identifier && `ws.${c.skPrincipal.identifier.replace(/\./g, '_')}` === providerId) { - debug('Found matching client by principal:', c.skPrincipal.identifier) + if ( + c.skPrincipal?.identifier && + `ws.${c.skPrincipal.identifier.replace(/\./g, '_')}` === + providerId + ) { + debug( + 'Found matching client by principal:', + c.skPrincipal.identifier + ) return true } - + return false }) - + if (clientInfo) { - debug('Client info:', { - id: clientInfo.id, + debug('Client info:', { + id: clientInfo.id, principal: clientInfo.skPrincipal?.identifier, - userAgent: clientInfo.userAgent + userAgent: clientInfo.userAgent }) - + // Use device registry cache for name resolution // Try multiple ID formats for device lookup let deviceId = clientInfo.id - + // If we have a principal identifier, it might be the device ID if (clientInfo.skPrincipal?.identifier) { // Check if any device matches the principal identifier - const deviceByPrincipal = devices.find(d => d.clientId === clientInfo.skPrincipal.identifier) + const deviceByPrincipal = devices.find( + (d) => d.clientId === clientInfo.skPrincipal.identifier + ) if (deviceByPrincipal) { deviceId = clientInfo.skPrincipal.identifier debug('Found device by principal identifier:', deviceId) } } - - const displayName = resolveDeviceName( - deviceId, - devices, - clientInfo - ) + + const displayName = resolveDeviceName(deviceId, devices, clientInfo) app.providerStatistics[providerId].displayName = displayName - debug('Resolved display name:', displayName, 'for', providerId, 'using device ID:', deviceId) + debug( + 'Resolved display name:', + displayName, + 'for', + providerId, + 'using device ID:', + deviceId + ) } else { debug('No matching client found for', providerId) } } }) } - + app.emit('serverevent', { type: 'SERVERSTATISTICS', from: 'signalk-server', diff --git a/src/deviceRegistryCache.ts b/src/deviceRegistryCache.ts index 302988ced..1be9f081b 100644 --- a/src/deviceRegistryCache.ts +++ b/src/deviceRegistryCache.ts @@ -23,7 +23,7 @@ interface CacheEventEmitter extends EventEmitter { export class DeviceRegistryCache { private devices: Map = new Map() private events: CacheEventEmitter = new EventEmitter() - + constructor() { debug('Device registry cache initialized') } @@ -33,7 +33,7 @@ export class DeviceRegistryCache { */ initialize(devices: Device[]): void { this.devices.clear() - devices.forEach(device => { + devices.forEach((device) => { this.devices.set(device.clientId, device) }) debug(`Cache initialized with ${this.devices.size} devices`) @@ -109,4 +109,4 @@ export class DeviceRegistryCache { } // Singleton instance -export const deviceRegistryCache = new DeviceRegistryCache() \ No newline at end of file +export const deviceRegistryCache = new DeviceRegistryCache() diff --git a/src/deviceRegistryCacheInit.ts b/src/deviceRegistryCacheInit.ts index 027bfad4e..320ed068d 100644 --- a/src/deviceRegistryCacheInit.ts +++ b/src/deviceRegistryCacheInit.ts @@ -19,10 +19,10 @@ interface AppWithEvents extends WithSecurityStrategy, EventEmitter { export function initializeDeviceRegistryCache(app: AppWithEvents) { debug('Initializing device registry cache') - + // Initial load of devices loadDevices(app) - + // Listen for security configuration changes if (app.on) { app.on('securityConfigChange', () => { @@ -30,19 +30,19 @@ export function initializeDeviceRegistryCache(app: AppWithEvents) { loadDevices(app) }) } - + // Also listen for specific device updates if available if (app.on) { app.on('deviceAdded', (device: Device) => { debug('Device added:', device.clientId) deviceRegistryCache.setDevice(device) }) - + app.on('deviceUpdated', (device: Device) => { debug('Device updated:', device.clientId) deviceRegistryCache.setDevice(device) }) - + app.on('deviceRemoved', (clientId: string) => { debug('Device removed:', clientId) deviceRegistryCache.removeDevice(clientId) @@ -52,12 +52,15 @@ export function initializeDeviceRegistryCache(app: AppWithEvents) { function loadDevices(app: AppWithEvents) { try { - if (app.securityStrategy && typeof app.securityStrategy.getDevices === 'function') { + if ( + app.securityStrategy && + typeof app.securityStrategy.getDevices === 'function' + ) { // Get the current configuration - const config = app.securityStrategy.getConfiguration ? - app.securityStrategy.getConfiguration() : - {} - + const config = app.securityStrategy.getConfiguration + ? app.securityStrategy.getConfiguration() + : {} + const devices = app.securityStrategy.getDevices(config) debug(`Loading ${devices.length} devices into cache`) deviceRegistryCache.initialize(devices) @@ -67,4 +70,4 @@ function loadDevices(app: AppWithEvents) { } catch (error) { console.error('Error loading devices into cache:', error) } -} \ No newline at end of file +} diff --git a/src/serverroutes.ts b/src/serverroutes.ts index 373573a0d..fe830d8cc 100644 --- a/src/serverroutes.ts +++ b/src/serverroutes.ts @@ -324,10 +324,15 @@ module.exports = function ( config, req.params.uuid, req.body, - (err: any, updatedConfig: any) => { + (err: unknown, updatedConfig: unknown) => { if (!err && updatedConfig) { // Find the updated device - const updatedDevice = updatedConfig.devices?.find((d: any) => d.clientId === req.params.uuid) + const config = updatedConfig as { + devices?: Array<{ clientId: string }> + } + const updatedDevice = config.devices?.find( + (d) => d.clientId === req.params.uuid + ) if (updatedDevice) { app.emit('deviceUpdated', updatedDevice) } @@ -352,7 +357,7 @@ module.exports = function ( app.securityStrategy.deleteDevice( config, deviceId, - (err: any, updatedConfig: any) => { + (err: unknown, updatedConfig: unknown) => { if (!err && updatedConfig) { app.emit('deviceRemoved', deviceId) } From 8103ab35252ec770d5ca58cd61a1aaf9696a0001 Mon Sep 17 00:00:00 2001 From: Tony Bentley Date: Sun, 22 Jun 2025 14:56:13 -0700 Subject: [PATCH 10/12] pr comments --- src/deviceNameResolver.ts | 40 ++++++++++++++++++++++++++++++++++ src/deviceRegistryCacheInit.ts | 23 +++++++++++++++++++ src/index.ts | 2 +- 3 files changed, 64 insertions(+), 1 deletion(-) diff --git a/src/deviceNameResolver.ts b/src/deviceNameResolver.ts index fb45e682a..228425017 100644 --- a/src/deviceNameResolver.ts +++ b/src/deviceNameResolver.ts @@ -13,6 +13,46 @@ interface ClientInfo { userAgent?: string } +/** + * Resolves a WebSocket client ID to a user-friendly display name using a 4-level priority system. + * + * This function attempts to find the most descriptive name for a connected client by checking + * multiple sources in order of preference. The goal is to provide meaningful device names + * in the Dashboard instead of cryptic WebSocket IDs like "ws.85d5c860-d34f-42ba-b9f1-b4ba78de8e95". + * + * Resolution Priority (first match wins): + * 1. **Device Description from Registry** - If the device is registered in the security system, + * use its configured description (e.g., "SensESP device: esp32-wireless") + * 2. **Principal Name from Authentication** - If the client is authenticated, use the principal's + * name from the authentication context + * 3. **Parsed User Agent** - Extract a meaningful name from the User-Agent header: + * - "SensESP" → "SensESP Device" + * - "SignalK" → "SignalK Client" + * - "OpenCPN" → "OpenCPN" + * - Browser agents → "Web Browser" + * - Other agents → First meaningful part of the UA string + * 4. **Client ID Fallback** - If no other information is available, return the original client ID + * + * @param clientId - The WebSocket client ID to resolve (e.g., "ws.123e4567-e89b-12d3-a456-426614174000") + * @param devices - Array of registered devices from the device registry cache + * @param clientInfo - Optional client information including authentication principal and user agent + * @returns A user-friendly display name for the client + * + * @example + * // With a registered device + * resolveDeviceName('esp32-001', devices, clientInfo) + * // Returns: "SensESP device: esp32-wireless" + * + * @example + * // With only user agent + * resolveDeviceName('ws.abc123', [], { userAgent: 'OpenCPN/5.6.2' }) + * // Returns: "OpenCPN" + * + * @example + * // Fallback case + * resolveDeviceName('ws.xyz789', [], {}) + * // Returns: "ws.xyz789" + */ export function resolveDeviceName( clientId: string, devices: Device[], diff --git a/src/deviceRegistryCacheInit.ts b/src/deviceRegistryCacheInit.ts index 320ed068d..de5ab444c 100644 --- a/src/deviceRegistryCacheInit.ts +++ b/src/deviceRegistryCacheInit.ts @@ -17,6 +17,29 @@ interface AppWithEvents extends WithSecurityStrategy, EventEmitter { } } +/** + * Initializes the device registry cache and sets up event listeners for real-time updates. + * + * This function performs the following tasks: + * 1. Loads all existing devices from the security configuration into the cache + * 2. Sets up event listeners for security configuration changes to reload all devices + * 3. Sets up event listeners for individual device operations (add/update/remove) for real-time updates + * + * The cache is used by the delta statistics module to resolve WebSocket client IDs to user-friendly + * device names that are displayed in the Dashboard. This provides a better user experience by showing + * descriptive names instead of cryptic WebSocket IDs. + * + * @param app - The SignalK server application instance with security strategy and event emitter capabilities + * + * @example + * // Called during server initialization after security is set up + * initializeDeviceRegistryCache(app) + * + * @remarks + * - Requires the security strategy to be initialized before calling + * - Gracefully handles cases where security is not enabled or devices are not supported + * - All errors are caught and logged to prevent server startup failures + */ export function initializeDeviceRegistryCache(app: AppWithEvents) { debug('Initializing device registry cache') diff --git a/src/index.ts b/src/index.ts index 70949dd2b..777e761cb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -62,6 +62,7 @@ import { setupCors } from './cors' import SubscriptionManager from './subscriptionmanager' import { PluginId, PluginManager } from './interfaces/plugins' import { OpenApiDescription, OpenApiRecord } from './api/swagger' +import { initializeDeviceRegistryCache } from './deviceRegistryCacheInit' import { WithProviderStatistics } from './deltastats' import { pipedProviders } from './pipedproviders' import { EventsActorId, WithWrappedEmitter, wrapEmitter } from './events' @@ -102,7 +103,6 @@ class Server { startSecurity(app, opts ? opts.securityConfig : null) // Initialize device registry cache after security is set up - const { initializeDeviceRegistryCache } = require('./deviceRegistryCacheInit') initializeDeviceRegistryCache(app) require('./serverroutes')(app, saveSecurityConfig, getSecurityConfig) From b850731672580d17533c7017c7ee1bd1e7171168 Mon Sep 17 00:00:00 2001 From: Tony Bentley Date: Sun, 22 Jun 2025 15:04:02 -0700 Subject: [PATCH 11/12] lint prettier --- src/deviceNameResolver.ts | 20 ++++++++++++-------- src/deviceRegistryCacheInit.ts | 10 +++++----- src/index.ts | 2 +- 3 files changed, 18 insertions(+), 14 deletions(-) diff --git a/src/deviceNameResolver.ts b/src/deviceNameResolver.ts index 228425017..7f6f05844 100644 --- a/src/deviceNameResolver.ts +++ b/src/deviceNameResolver.ts @@ -15,11 +15,11 @@ interface ClientInfo { /** * Resolves a WebSocket client ID to a user-friendly display name using a 4-level priority system. - * + * * This function attempts to find the most descriptive name for a connected client by checking * multiple sources in order of preference. The goal is to provide meaningful device names * in the Dashboard instead of cryptic WebSocket IDs like "ws.85d5c860-d34f-42ba-b9f1-b4ba78de8e95". - * + * * Resolution Priority (first match wins): * 1. **Device Description from Registry** - If the device is registered in the security system, * use its configured description (e.g., "SensESP device: esp32-wireless") @@ -32,22 +32,22 @@ interface ClientInfo { * - Browser agents → "Web Browser" * - Other agents → First meaningful part of the UA string * 4. **Client ID Fallback** - If no other information is available, return the original client ID - * + * * @param clientId - The WebSocket client ID to resolve (e.g., "ws.123e4567-e89b-12d3-a456-426614174000") * @param devices - Array of registered devices from the device registry cache * @param clientInfo - Optional client information including authentication principal and user agent * @returns A user-friendly display name for the client - * + * * @example * // With a registered device * resolveDeviceName('esp32-001', devices, clientInfo) * // Returns: "SensESP device: esp32-wireless" - * + * * @example * // With only user agent * resolveDeviceName('ws.abc123', [], { userAgent: 'OpenCPN/5.6.2' }) * // Returns: "OpenCPN" - * + * * @example * // Fallback case * resolveDeviceName('ws.xyz789', [], {}) @@ -78,7 +78,11 @@ export function resolveDeviceName( return 'SignalK Client' } else if (ua.includes('OpenCPN')) { return 'OpenCPN' - } else if (ua.includes('Chrome') || ua.includes('Firefox') || ua.includes('Safari')) { + } else if ( + ua.includes('Chrome') || + ua.includes('Firefox') || + ua.includes('Safari') + ) { return 'Web Browser' } else { // Take first meaningful part of user agent @@ -89,4 +93,4 @@ export function resolveDeviceName( // 4. Fall back to client ID return clientId -} \ No newline at end of file +} diff --git a/src/deviceRegistryCacheInit.ts b/src/deviceRegistryCacheInit.ts index de5ab444c..2bca6681e 100644 --- a/src/deviceRegistryCacheInit.ts +++ b/src/deviceRegistryCacheInit.ts @@ -19,22 +19,22 @@ interface AppWithEvents extends WithSecurityStrategy, EventEmitter { /** * Initializes the device registry cache and sets up event listeners for real-time updates. - * + * * This function performs the following tasks: * 1. Loads all existing devices from the security configuration into the cache * 2. Sets up event listeners for security configuration changes to reload all devices * 3. Sets up event listeners for individual device operations (add/update/remove) for real-time updates - * + * * The cache is used by the delta statistics module to resolve WebSocket client IDs to user-friendly * device names that are displayed in the Dashboard. This provides a better user experience by showing * descriptive names instead of cryptic WebSocket IDs. - * + * * @param app - The SignalK server application instance with security strategy and event emitter capabilities - * + * * @example * // Called during server initialization after security is set up * initializeDeviceRegistryCache(app) - * + * * @remarks * - Requires the security strategy to be initialized before calling * - Gracefully handles cases where security is not enabled or devices are not supported diff --git a/src/index.ts b/src/index.ts index 777e761cb..3d6e5664c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -101,7 +101,7 @@ class Server { setupCors(app, getSecurityConfig(app)) startSecurity(app, opts ? opts.securityConfig : null) - + // Initialize device registry cache after security is set up initializeDeviceRegistryCache(app) From 3bd29f79b26957df7f3453e7322b6dc63d75a942 Mon Sep 17 00:00:00 2001 From: Tony Bentley Date: Sun, 22 Jun 2025 15:12:13 -0700 Subject: [PATCH 12/12] prettier --- src/interfaces/ws.js | 9 ++++---- test/deviceNameResolver.js | 43 +++++++++++++++++++------------------- test/security.js | 1 - 3 files changed, 27 insertions(+), 26 deletions(-) diff --git a/src/interfaces/ws.js b/src/interfaces/ws.js index b85b86efe..da8c90c7f 100644 --- a/src/interfaces/ws.js +++ b/src/interfaces/ws.js @@ -61,12 +61,13 @@ module.exports = function (app) { const clientInfo = { id: spark.id, skPrincipal: spark.request.skPrincipal, - remoteAddress: spark.request.headers['x-forwarded-for'] || - spark.request.connection.remoteAddress, + remoteAddress: + spark.request.headers['x-forwarded-for'] || + spark.request.connection.remoteAddress, userAgent: spark.request.headers['user-agent'], connectedAt: spark.request.connectedAt || new Date().toISOString() } - + // If security context is provided, enhance with device description if (securityContext && securityContext.getDevice) { const device = securityContext.getDevice(spark.id) @@ -74,7 +75,7 @@ module.exports = function (app) { clientInfo.deviceDescription = device.description } } - + clients.push(clientInfo) }) ) diff --git a/test/deviceNameResolver.js b/test/deviceNameResolver.js index 44a249fcd..3202a6622 100644 --- a/test/deviceNameResolver.js +++ b/test/deviceNameResolver.js @@ -11,94 +11,95 @@ describe('Device Name Resolution', () => { skPrincipal: { name: 'Test User' }, userAgent: 'Mozilla/5.0' } - + const result = resolveDeviceName('test-client', devices, clientInfo) result.should.equal('Test Device Description') }) - + it('returns principal name as second priority', () => { const devices = [] const clientInfo = { skPrincipal: { name: 'Test User' }, userAgent: 'Mozilla/5.0' } - + const result = resolveDeviceName('test-client', devices, clientInfo) result.should.equal('Test User') }) - + it('returns parsed user agent as third priority', () => { const devices = [] const clientInfo = { userAgent: 'SensESP/2.0' } - + const result = resolveDeviceName('test-client', devices, clientInfo) result.should.equal('SensESP Device') }) - + it('correctly identifies OpenCPN', () => { const devices = [] const clientInfo = { userAgent: 'OpenCPN/5.6.2' } - + const result = resolveDeviceName('test-client', devices, clientInfo) result.should.equal('OpenCPN') }) - + it('correctly identifies SignalK clients', () => { const devices = [] const clientInfo = { userAgent: 'SignalK/1.0' } - + const result = resolveDeviceName('test-client', devices, clientInfo) result.should.equal('SignalK Client') }) - + it('correctly identifies web browsers', () => { const devices = [] const clientInfo = { - userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36' + userAgent: + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36' } - + const result = resolveDeviceName('test-client', devices, clientInfo) result.should.equal('Web Browser') }) - + it('extracts first part of unknown user agents', () => { const devices = [] const clientInfo = { userAgent: 'ESP32-Device/1.0 (Custom Firmware)' } - + const result = resolveDeviceName('test-client', devices, clientInfo) result.should.equal('ESP32-Device') }) - + it('returns client ID as fallback', () => { const devices = [] const clientInfo = {} - + const result = resolveDeviceName('test-client', devices, clientInfo) result.should.equal('test-client') }) - + it('handles missing clientInfo gracefully', () => { const devices = [] - + const result = resolveDeviceName('test-client', devices) result.should.equal('test-client') }) - + it('handles empty user agent gracefully', () => { const devices = [] const clientInfo = { userAgent: '' } - + const result = resolveDeviceName('test-client', devices, clientInfo) result.should.equal('test-client') }) -}) \ No newline at end of file +}) diff --git a/test/security.js b/test/security.js index 10affee85..e00a80f22 100644 --- a/test/security.js +++ b/test/security.js @@ -519,4 +519,3 @@ describe('Security', () => { json.length.should.equal(1) }) }) -