Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions packages/server-admin-ui/src/views/Dashboard/Dashboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ const Dashboard = (props) => {
<span className="title">
{linkType === 'plugin'
? pluginNameLink(providerId)
: providerIdLink(providerId)}
: providerIdLink(providerId, providerStats.displayName)}
</span>
{providerStats.writeRate > 0 && (
<span className="value">
Expand Down Expand Up @@ -285,13 +285,14 @@ function pluginNameLink(id) {
return <a href={'#/serverConfiguration/plugins/' + id}>{id}</a>
}

function providerIdLink(id) {
function providerIdLink(id, displayName) {
const linkText = displayName || id
if (id === 'defaults') {
return <a href={'#/serverConfiguration/settings'}>{id}</a>
return <a href={'#/serverConfiguration/settings'}>{linkText}</a>
} else if (id.startsWith('ws.')) {
return <a href={'#/security/devices'}>{id}</a>
return <a href={'#/security/devices'}>{linkText}</a>
} else {
return <a href={'#/serverConfiguration/connections/' + id}>{id}</a>
return <a href={'#/serverConfiguration/connections/' + id}>{linkText}</a>
}
}

Expand Down
89 changes: 89 additions & 0 deletions src/deltastats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -33,6 +38,7 @@ class ProviderStats {
deltaRate: number
deltaCount: number
lastIntervalDeltaCount: number
displayName?: string
constructor() {
this.writeRate =
this.writeCount =
Expand Down Expand Up @@ -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',
Expand Down
96 changes: 96 additions & 0 deletions src/deviceNameResolver.ts
Original file line number Diff line number Diff line change
@@ -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
}
112 changes: 112 additions & 0 deletions src/deviceRegistryCache.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>
}

interface CacheEventEmitter extends EventEmitter {
on(event: 'updated', listener: () => void): this
emit(event: 'updated'): boolean
}

export class DeviceRegistryCache {
private devices: Map<string, Device> = 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()
Loading