Skip to content

Commit 11edce6

Browse files
authored
feat(sources): show human-readable names for WebSocket devices (#88)
* feat(sources): show human-readable names for WebSocket devices WebSocket device sources appear as cryptic refs like "ws.3d3e48a1-1185-2fe3-c494-1c1a9ee6f41f" across the Dashboard, Data Browser and Source Priorities. The device's registration description is already in the security config, just never surfaced. Expose a read-only GET /sourceNames map (ws device descriptions merged with manual aliases) that every authenticated client can read, so non-admin users see the names too. The map is cached server-side and rebuilt only when devices or aliases change, never on the per-delta path. Source-name editing stays admin-only. * fix(sources): guard /sourceNames and keep map fresh Address review feedback on the WebSocket device-names change: - Require read access for GET /sourceNames (authenticated, anonymous read-only enabled, or security disabled) so device descriptions are not exposed to anonymous clients. - Broadcast SOURCENAMES on the general serverevent channel instead of serverAdminEvent so non-admin clients receive live updates, not just the initial fetch. - Refresh the cached map when /removeSource deletes a source alias. - Default the /sourceNames fetch response to an empty object so a null/empty body cannot break source-label rendering.
1 parent 74beff5 commit 11edce6

13 files changed

Lines changed: 256 additions & 13 deletions

File tree

packages/server-admin-ui/src/dataFetching.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,9 @@ export async function fetchAllData(): Promise<void> {
118118
state.setPriorityDefaultsFromServer(data.defaults || {})
119119
}),
120120
fetchAndSet('/sourceAliases', state.setSourceAliases),
121+
fetchAndSet('/sourceNames', (data) =>
122+
state.setSourceNames((data ?? {}) as Record<string, string>)
123+
),
121124
fetchAndSet('/ignoredInstanceConflicts', state.setIgnoredInstanceConflicts),
122125
fetchAndSet('/n2kDeviceStatus', state.setN2kDeviceStatus),
123126
fetchAndSet('/livePreferredSources', state.setLivePreferredSources),

packages/server-admin-ui/src/hooks/useSourceAliases.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ function persistAliases(
106106
export function useSourceAliases() {
107107
const aliases = useStore((s) => s.sourceAliases)
108108
const loaded = useStore((s) => s.sourceAliasesLoaded)
109+
const sourceNames = useStore((s) => s.sourceNames)
109110

110111
// Migration runs once when the server's aliases have arrived; reading
111112
// current state from the store keeps `aliases` out of the dependency
@@ -133,10 +134,12 @@ export function useSourceAliases() {
133134

134135
const getDisplayName = useCallback(
135136
(sourceRef: string, sourcesData?: SourcesData | null): string => {
137+
// Local alias first for instant feedback on admin edits before the
138+
// server-merged sourceNames map round-trips.
136139
if (aliases[sourceRef]) return aliases[sourceRef]
137-
return buildSourceLabel(sourceRef, sourcesData ?? null)
140+
return buildSourceLabel(sourceRef, sourcesData ?? null, sourceNames)
138141
},
139-
[aliases]
142+
[aliases, sourceNames]
140143
)
141144

142145
const getDisplayParts = useCallback(
@@ -147,9 +150,9 @@ export function useSourceAliases() {
147150
if (aliases[sourceRef]) {
148151
return { primary: aliases[sourceRef], secondary: sourceRef }
149152
}
150-
return buildSourceLabelParts(sourceRef, sourcesData ?? null)
153+
return buildSourceLabelParts(sourceRef, sourcesData ?? null, sourceNames)
151154
},
152-
[aliases]
155+
[aliases, sourceNames]
153156
)
154157

155158
return { aliases, setAlias, removeAlias, getDisplayName, getDisplayParts }

packages/server-admin-ui/src/services/WebSocketService.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,11 @@ export class WebSocketService {
309309
.getState()
310310
.setSourceAliases((data ?? {}) as Record<string, string>)
311311
break
312+
case 'SOURCENAMES':
313+
useStore
314+
.getState()
315+
.setSourceNames((data ?? {}) as Record<string, string>)
316+
break
312317
case 'MULTISOURCEPATHS':
313318
useStore
314319
.getState()

packages/server-admin-ui/src/store/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,10 @@ export function useSourceAliasesData() {
255255
return useStore((s) => s.sourceAliases)
256256
}
257257

258+
export function useSourceNames() {
259+
return useStore((s) => s.sourceNames)
260+
}
261+
258262
export function useIgnoredInstanceConflicts() {
259263
return useStore((s) => s.ignoredInstanceConflicts)
260264
}

packages/server-admin-ui/src/store/slices/appSlice.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,12 @@ export interface AppSliceState {
5858
sourcesData: SourcesData | null
5959
sourceAliases: Record<string, string>
6060
sourceAliasesLoaded: boolean
61+
/**
62+
* Merged human-readable names (ws device descriptions + manual aliases)
63+
* served read-only to all clients via GET /sourceNames. Drives source
64+
* labels for non-admin users, who cannot read the admin-only registry.
65+
*/
66+
sourceNames: Record<string, string>
6167
multiSourcePaths: Record<string, string[]>
6268
/**
6369
* Reconciled priority groups: server-computed view of saved groups
@@ -138,6 +144,7 @@ export interface AppSliceActions {
138144
setBackpressureWarning: (warning: BackpressureWarning | null) => void
139145
setSourcesData: (data: SourcesData) => void
140146
setSourceAliases: (aliases: Record<string, string>) => void
147+
setSourceNames: (names: Record<string, string>) => void
141148
setIgnoredInstanceConflicts: (conflicts: Record<string, string>) => void
142149
setActiveConflictCount: (count: number) => void
143150
setN2kDeviceStatus: (status: {
@@ -224,6 +231,7 @@ const initialAppState: AppSliceState = {
224231
sourcesData: null,
225232
sourceAliases: {},
226233
sourceAliasesLoaded: false,
234+
sourceNames: {},
227235
multiSourcePaths: {},
228236
reconciledGroups: [],
229237
livePreferredSources: {},
@@ -335,6 +343,10 @@ export const createAppSlice: StateCreator<AppSlice, [], [], AppSlice> = (
335343
set({ sourceAliases, sourceAliasesLoaded: true })
336344
},
337345

346+
setSourceNames: (sourceNames) => {
347+
set({ sourceNames })
348+
},
349+
338350
setIgnoredInstanceConflicts: (ignoredInstanceConflicts) => {
339351
set({ ignoredInstanceConflicts })
340352
},

packages/server-admin-ui/src/utils/sourceLabels.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,31 @@ describe('buildSourceLabel', () => {
141141
it('returns raw sourceRef for unknown connections', () => {
142142
expect(buildSourceLabel('UNKNOWN.1', sourcesData)).toBe('UNKNOWN.1')
143143
})
144+
145+
it('uses sourceNames for ws device sources', () => {
146+
const sourceNames = {
147+
'ws.3d3e48a1-1185-2fe3-c494-1c1a9ee6f41f': 'sensesp-engines'
148+
}
149+
expect(
150+
buildSourceLabel(
151+
'ws.3d3e48a1-1185-2fe3-c494-1c1a9ee6f41f',
152+
sourcesData,
153+
sourceNames
154+
)
155+
).toBe('sensesp-engines (ws.3d3e48a1-1185-2fe3-c494-1c1a9ee6f41f)')
156+
})
157+
158+
it('falls back to raw ref for ws sources missing from sourceNames', () => {
159+
expect(buildSourceLabel('ws.unknown-device', sourcesData, {})).toBe(
160+
'ws.unknown-device'
161+
)
162+
})
163+
164+
it('lets sourceNames override N2K-derived labels', () => {
165+
expect(
166+
buildSourceLabel('YDEN02.37', sourcesData, { 'YDEN02.37': 'My Charger' })
167+
).toBe('My Charger (YDEN02.37)')
168+
})
144169
})
145170

146171
describe('canonicaliseSourceRef', () => {

packages/server-admin-ui/src/utils/sourceLabels.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -104,9 +104,14 @@ export function getDeviceInfo(
104104
*/
105105
export function buildSourceLabel(
106106
sourceRef: string,
107-
sourcesData: SourcesData | null
107+
sourcesData: SourcesData | null,
108+
sourceNames?: Record<string, string> | null
108109
): string {
109-
const { primary, secondary } = buildSourceLabelParts(sourceRef, sourcesData)
110+
const { primary, secondary } = buildSourceLabelParts(
111+
sourceRef,
112+
sourcesData,
113+
sourceNames
114+
)
110115
return secondary ? `${primary} (${secondary})` : primary
111116
}
112117

@@ -118,8 +123,16 @@ export function buildSourceLabel(
118123
*/
119124
export function buildSourceLabelParts(
120125
sourceRef: string,
121-
sourcesData: SourcesData | null
126+
sourcesData: SourcesData | null,
127+
sourceNames?: Record<string, string> | null
122128
): { primary: string; secondary: string | null } {
129+
// Server-supplied names (WebSocket device descriptions, merged with any
130+
// manual aliases) take precedence over bus-derived labels. This is the
131+
// only path available to non-admin users, who cannot read the device
132+
// registry directly.
133+
const name = sourceNames?.[sourceRef]
134+
if (name) return { primary: name, secondary: sourceRef }
135+
123136
if (!sourcesData) return { primary: sourceRef, secondary: null }
124137

125138
const n2k = getDeviceInfo(sourceRef, sourcesData)

packages/server-admin-ui/src/views/Dashboard/Dashboard.tsx

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export default function Dashboard() {
1313
const serverStatistics = useServerStats()
1414
const websocketStatus = useWsStatus()
1515
const providerStatus = useStore((state) => state.providerStatus) ?? []
16+
const sourceNames = useStore((state) => state.sourceNames)
1617
const navigate = useNavigate()
1718

1819
const deltaRate = serverStatistics?.deltaRate ?? 0
@@ -79,7 +80,7 @@ export default function Dashboard() {
7980
<span className="title">
8081
{linkType === 'plugin'
8182
? pluginNameLink(providerId)
82-
: providerIdLink(providerId)}
83+
: providerIdLink(providerId, sourceNames[providerId])}
8384
</span>
8485
{(providerStats.writeRate || 0) > 0 && (
8586
<span className="value" style={{ fontWeight: 'normal' }}>
@@ -140,7 +141,7 @@ export default function Dashboard() {
140141
<td>
141142
{status.statusType === 'plugin'
142143
? pluginNameLink(status.id)
143-
: providerIdLink(status.id)}
144+
: providerIdLink(status.id, sourceNames[status.id])}
144145
</td>
145146
<td>
146147
<p className="text-danger">{lastError}</p>
@@ -293,11 +294,15 @@ function pluginNameLink(id: string): ReactNode {
293294
return <a href={'#/apps/configuration/' + encodeURIComponent(id)}>{id}</a>
294295
}
295296

296-
function providerIdLink(id: string): ReactNode {
297+
function providerIdLink(id: string, displayName?: string): ReactNode {
297298
if (id === 'defaults') {
298299
return <a href={'#/serverConfiguration/settings'}>{id}</a>
299300
} else if (id.startsWith('ws.')) {
300-
return <a href={'#/security/devices'}>{id}</a>
301+
return (
302+
<a href={'#/security/devices'} title={id}>
303+
{displayName || id}
304+
</a>
305+
)
301306
} else {
302307
return (
303308
<a href={'#/serverConfiguration/connections/' + encodeURIComponent(id)}>

packages/server-admin-ui/src/views/DataBrowser/DataBrowser.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,7 @@ const DataBrowser: React.FC = () => {
154154
const updateMeta = useStore((s) => s.updateMeta)
155155
const getPathData = useStore((s) => s.getPathData)
156156

157+
const sourceNames = useStore((s) => s.sourceNames)
157158
const unitPrefsLoaded = useUnitPrefsLoaded()
158159
const fetchUnitPreferences = useStore((s) => s.fetchUnitPreferences)
159160
const configuredPriorityPaths = useConfiguredPriorityPaths()
@@ -611,7 +612,7 @@ const DataBrowser: React.FC = () => {
611612
if (!src) return ''
612613
let label = sourceLabels.get(src)
613614
if (label === undefined) {
614-
label = buildSourceLabel(src, rawSourcesData)
615+
label = buildSourceLabel(src, rawSourcesData, sourceNames)
615616
sourceLabels.set(src, label)
616617
}
617618
return label
@@ -786,7 +787,8 @@ const DataBrowser: React.FC = () => {
786787
liveWinnerForCurrentContext,
787788
skSelf,
788789
collapsedSources,
789-
rawSourcesData
790+
rawSourcesData,
791+
sourceNames
790792
])
791793

792794
// Keep the ref in sync with the current memoised path list so the

packages/server-admin-ui/src/views/DataBrowser/SourceLabel.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import React, { useState, useRef, useEffect } from 'react'
22
import { useSourceAliases } from '../../hooks/useSourceAliases'
3+
import { useLoginStatus } from '../../store'
34
import type { SourcesData } from '../../utils/sourceLabels'
45

56
interface SourceLabelProps {
@@ -12,6 +13,11 @@ const SourceLabel: React.FC<SourceLabelProps> = ({
1213
sourcesData
1314
}) => {
1415
const { aliases, setAlias, getDisplayName } = useSourceAliases()
16+
const loginStatus = useLoginStatus()
17+
// Alias writes go to the admin-only /sourceAliases endpoint; non-admins
18+
// see the resolved name but get no edit affordance.
19+
const isAdmin =
20+
!loginStatus.authenticationRequired || loginStatus.userLevel === 'admin'
1521
const [isEditing, setIsEditing] = useState(false)
1622
const [editValue, setEditValue] = useState('')
1723
const inputRef = useRef<HTMLInputElement>(null)
@@ -62,6 +68,10 @@ const SourceLabel: React.FC<SourceLabelProps> = ({
6268
const displayName = getDisplayName(sourceRef, sourcesData)
6369
const hasAlias = !!aliases[sourceRef]
6470

71+
if (!isAdmin) {
72+
return <span>{displayName}</span>
73+
}
74+
6575
if (isEditing) {
6676
return (
6777
<span

0 commit comments

Comments
 (0)