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') + }) +})