diff --git a/packages/server-admin-ui/src/views/Dashboard/Dashboard.js b/packages/server-admin-ui/src/views/Dashboard/Dashboard.js
index 403322a22..929ae95ee 100644
--- a/packages/server-admin-ui/src/views/Dashboard/Dashboard.js
+++ b/packages/server-admin-ui/src/views/Dashboard/Dashboard.js
@@ -84,7 +84,7 @@ const Dashboard = (props) => {
{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/src/deltastats.ts b/src/deltastats.ts
index d59ede8da..d9381d503 100644
--- a/src/deltastats.ts
+++ b/src/deltastats.ts
@@ -17,6 +17,11 @@
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'
@@ -33,6 +38,7 @@ class ProviderStats {
deltaRate: number
deltaCount: number
lastIntervalDeltaCount: number
+ displayName?: string
constructor() {
this.writeRate =
this.writeCount =
@@ -73,6 +79,89 @@ 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
+ }))
+ )
+
+ 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
+ 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) {
+ 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)
+ }
+ }
+ })
+ }
+
app.emit('serverevent', {
type: 'SERVERSTATISTICS',
from: 'signalk-server',
diff --git a/src/deviceNameResolver.ts b/src/deviceNameResolver.ts
new file mode 100644
index 000000000..7f6f05844
--- /dev/null
+++ b/src/deviceNameResolver.ts
@@ -0,0 +1,96 @@
+/*
+ * 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
+}
+
+/**
+ * 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[],
+ 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
+}
diff --git a/src/deviceRegistryCache.ts b/src/deviceRegistryCache.ts
new file mode 100644
index 000000000..1be9f081b
--- /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()
diff --git a/src/deviceRegistryCacheInit.ts b/src/deviceRegistryCacheInit.ts
new file mode 100644
index 000000000..2bca6681e
--- /dev/null
+++ b/src/deviceRegistryCacheInit.ts
@@ -0,0 +1,96 @@
+/*
+ * 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
+ }
+}
+
+/**
+ * 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')
+
+ // 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)
+ }
+}
diff --git a/src/index.ts b/src/index.ts
index d14286e5f..3d6e5664c 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'
@@ -101,6 +102,9 @@ class Server {
setupCors(app, getSecurityConfig(app))
startSecurity(app, opts ? opts.securityConfig : null)
+ // Initialize device registry cache after security is set up
+ initializeDeviceRegistryCache(app)
+
require('./serverroutes')(app, saveSecurityConfig, getSecurityConfig)
require('./put').start(app)
diff --git a/src/interfaces/ws.js b/src/interfaces/ws.js
index 4a6f095c4..da8c90c7f 100644
--- a/src/interfaces/ws.js
+++ b/src/interfaces/ws.js
@@ -54,6 +54,34 @@ module.exports = function (app) {
return count
}
+ api.getActiveClients = function (securityContext) {
+ const clients = []
+ primuses.forEach((primus) =>
+ primus.forEach((spark) => {
+ 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
+ }
+
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..fe830d8cc 100644
--- a/src/serverroutes.ts
+++ b/src/serverroutes.ts
@@ -324,11 +324,25 @@ module.exports = function (
config,
req.params.uuid,
req.body,
- getConfigSavingCallback(
- 'Device updated',
- 'Unable to update device',
- res
- )
+ (err: unknown, updatedConfig: unknown) => {
+ if (!err && updatedConfig) {
+ // Find the updated device
+ 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)
+ }
+ }
+ getConfigSavingCallback(
+ 'Device updated',
+ 'Unable to update device',
+ res
+ )(err, updatedConfig)
+ }
)
}
}
@@ -339,14 +353,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: unknown, updatedConfig: unknown) => {
+ 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..3202a6622
--- /dev/null
+++ b/test/deviceNameResolver.js
@@ -0,0 +1,105 @@
+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')
+ })
+})