Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
3 changes: 3 additions & 0 deletions packages/server-admin-ui/src/dataFetching.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,9 @@ export async function fetchAllData(): Promise<void> {
state.setPriorityDefaultsFromServer(data.defaults || {})
}),
fetchAndSet('/sourceAliases', state.setSourceAliases),
fetchAndSet('/sourceNames', (data) =>
state.setSourceNames((data ?? {}) as Record<string, string>)
),
fetchAndSet('/ignoredInstanceConflicts', state.setIgnoredInstanceConflicts),
fetchAndSet('/n2kDeviceStatus', state.setN2kDeviceStatus),
fetchAndSet('/livePreferredSources', state.setLivePreferredSources),
Expand Down
11 changes: 7 additions & 4 deletions packages/server-admin-ui/src/hooks/useSourceAliases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ function persistAliases(
export function useSourceAliases() {
const aliases = useStore((s) => s.sourceAliases)
const loaded = useStore((s) => s.sourceAliasesLoaded)
const sourceNames = useStore((s) => s.sourceNames)

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

const getDisplayName = useCallback(
(sourceRef: string, sourcesData?: SourcesData | null): string => {
// Local alias first for instant feedback on admin edits before the
// server-merged sourceNames map round-trips.
if (aliases[sourceRef]) return aliases[sourceRef]
return buildSourceLabel(sourceRef, sourcesData ?? null)
return buildSourceLabel(sourceRef, sourcesData ?? null, sourceNames)
},
[aliases]
[aliases, sourceNames]
)

const getDisplayParts = useCallback(
Expand All @@ -147,9 +150,9 @@ export function useSourceAliases() {
if (aliases[sourceRef]) {
return { primary: aliases[sourceRef], secondary: sourceRef }
}
return buildSourceLabelParts(sourceRef, sourcesData ?? null)
return buildSourceLabelParts(sourceRef, sourcesData ?? null, sourceNames)
},
[aliases]
[aliases, sourceNames]
)

return { aliases, setAlias, removeAlias, getDisplayName, getDisplayParts }
Expand Down
5 changes: 5 additions & 0 deletions packages/server-admin-ui/src/services/WebSocketService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,11 @@ export class WebSocketService {
.getState()
.setSourceAliases((data ?? {}) as Record<string, string>)
break
case 'SOURCENAMES':
useStore
.getState()
.setSourceNames((data ?? {}) as Record<string, string>)
break
case 'MULTISOURCEPATHS':
useStore
.getState()
Expand Down
4 changes: 4 additions & 0 deletions packages/server-admin-ui/src/store/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,10 @@ export function useSourceAliasesData() {
return useStore((s) => s.sourceAliases)
}

export function useSourceNames() {
return useStore((s) => s.sourceNames)
}

export function useIgnoredInstanceConflicts() {
return useStore((s) => s.ignoredInstanceConflicts)
}
Expand Down
12 changes: 12 additions & 0 deletions packages/server-admin-ui/src/store/slices/appSlice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,12 @@ export interface AppSliceState {
sourcesData: SourcesData | null
sourceAliases: Record<string, string>
sourceAliasesLoaded: boolean
/**
* Merged human-readable names (ws device descriptions + manual aliases)
* served read-only to all clients via GET /sourceNames. Drives source
* labels for non-admin users, who cannot read the admin-only registry.
*/
sourceNames: Record<string, string>
multiSourcePaths: Record<string, string[]>
/**
* Reconciled priority groups: server-computed view of saved groups
Expand Down Expand Up @@ -138,6 +144,7 @@ export interface AppSliceActions {
setBackpressureWarning: (warning: BackpressureWarning | null) => void
setSourcesData: (data: SourcesData) => void
setSourceAliases: (aliases: Record<string, string>) => void
setSourceNames: (names: Record<string, string>) => void
setIgnoredInstanceConflicts: (conflicts: Record<string, string>) => void
setActiveConflictCount: (count: number) => void
setN2kDeviceStatus: (status: {
Expand Down Expand Up @@ -224,6 +231,7 @@ const initialAppState: AppSliceState = {
sourcesData: null,
sourceAliases: {},
sourceAliasesLoaded: false,
sourceNames: {},
multiSourcePaths: {},
reconciledGroups: [],
livePreferredSources: {},
Expand Down Expand Up @@ -335,6 +343,10 @@ export const createAppSlice: StateCreator<AppSlice, [], [], AppSlice> = (
set({ sourceAliases, sourceAliasesLoaded: true })
},

setSourceNames: (sourceNames) => {
set({ sourceNames })
},

setIgnoredInstanceConflicts: (ignoredInstanceConflicts) => {
set({ ignoredInstanceConflicts })
},
Expand Down
25 changes: 25 additions & 0 deletions packages/server-admin-ui/src/utils/sourceLabels.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,31 @@ describe('buildSourceLabel', () => {
it('returns raw sourceRef for unknown connections', () => {
expect(buildSourceLabel('UNKNOWN.1', sourcesData)).toBe('UNKNOWN.1')
})

it('uses sourceNames for ws device sources', () => {
const sourceNames = {
'ws.3d3e48a1-1185-2fe3-c494-1c1a9ee6f41f': 'sensesp-engines'
}
expect(
buildSourceLabel(
'ws.3d3e48a1-1185-2fe3-c494-1c1a9ee6f41f',
sourcesData,
sourceNames
)
).toBe('sensesp-engines (ws.3d3e48a1-1185-2fe3-c494-1c1a9ee6f41f)')
})

it('falls back to raw ref for ws sources missing from sourceNames', () => {
expect(buildSourceLabel('ws.unknown-device', sourcesData, {})).toBe(
'ws.unknown-device'
)
})

it('lets sourceNames override N2K-derived labels', () => {
expect(
buildSourceLabel('YDEN02.37', sourcesData, { 'YDEN02.37': 'My Charger' })
).toBe('My Charger (YDEN02.37)')
})
})

describe('canonicaliseSourceRef', () => {
Expand Down
19 changes: 16 additions & 3 deletions packages/server-admin-ui/src/utils/sourceLabels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,9 +104,14 @@ export function getDeviceInfo(
*/
export function buildSourceLabel(
sourceRef: string,
sourcesData: SourcesData | null
sourcesData: SourcesData | null,
sourceNames?: Record<string, string> | null
): string {
const { primary, secondary } = buildSourceLabelParts(sourceRef, sourcesData)
const { primary, secondary } = buildSourceLabelParts(
sourceRef,
sourcesData,
sourceNames
)
return secondary ? `${primary} (${secondary})` : primary
}

Expand All @@ -118,8 +123,16 @@ export function buildSourceLabel(
*/
export function buildSourceLabelParts(
sourceRef: string,
sourcesData: SourcesData | null
sourcesData: SourcesData | null,
sourceNames?: Record<string, string> | null
): { primary: string; secondary: string | null } {
// Server-supplied names (WebSocket device descriptions, merged with any
// manual aliases) take precedence over bus-derived labels. This is the
// only path available to non-admin users, who cannot read the device
// registry directly.
const name = sourceNames?.[sourceRef]
if (name) return { primary: name, secondary: sourceRef }

if (!sourcesData) return { primary: sourceRef, secondary: null }

const n2k = getDeviceInfo(sourceRef, sourcesData)
Expand Down
13 changes: 9 additions & 4 deletions packages/server-admin-ui/src/views/Dashboard/Dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export default function Dashboard() {
const serverStatistics = useServerStats()
const websocketStatus = useWsStatus()
const providerStatus = useStore((state) => state.providerStatus) ?? []
const sourceNames = useStore((state) => state.sourceNames)
const navigate = useNavigate()

const deltaRate = serverStatistics?.deltaRate ?? 0
Expand Down Expand Up @@ -79,7 +80,7 @@ export default function Dashboard() {
<span className="title">
{linkType === 'plugin'
? pluginNameLink(providerId)
: providerIdLink(providerId)}
: providerIdLink(providerId, sourceNames[providerId])}
</span>
{(providerStats.writeRate || 0) > 0 && (
<span className="value" style={{ fontWeight: 'normal' }}>
Expand Down Expand Up @@ -140,7 +141,7 @@ export default function Dashboard() {
<td>
{status.statusType === 'plugin'
? pluginNameLink(status.id)
: providerIdLink(status.id)}
: providerIdLink(status.id, sourceNames[status.id])}
</td>
<td>
<p className="text-danger">{lastError}</p>
Expand Down Expand Up @@ -293,11 +294,15 @@ function pluginNameLink(id: string): ReactNode {
return <a href={'#/apps/configuration/' + encodeURIComponent(id)}>{id}</a>
}

function providerIdLink(id: string): ReactNode {
function providerIdLink(id: string, displayName?: string): ReactNode {
if (id === 'defaults') {
return <a href={'#/serverConfiguration/settings'}>{id}</a>
} else if (id.startsWith('ws.')) {
return <a href={'#/security/devices'}>{id}</a>
return (
<a href={'#/security/devices'} title={id}>
{displayName || id}
</a>
)
} else {
return (
<a href={'#/serverConfiguration/connections/' + encodeURIComponent(id)}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ const DataBrowser: React.FC = () => {
const updateMeta = useStore((s) => s.updateMeta)
const getPathData = useStore((s) => s.getPathData)

const sourceNames = useStore((s) => s.sourceNames)
const unitPrefsLoaded = useUnitPrefsLoaded()
const fetchUnitPreferences = useStore((s) => s.fetchUnitPreferences)
const configuredPriorityPaths = useConfiguredPriorityPaths()
Expand Down Expand Up @@ -611,7 +612,7 @@ const DataBrowser: React.FC = () => {
if (!src) return ''
let label = sourceLabels.get(src)
if (label === undefined) {
label = buildSourceLabel(src, rawSourcesData)
label = buildSourceLabel(src, rawSourcesData, sourceNames)
sourceLabels.set(src, label)
}
return label
Expand Down Expand Up @@ -786,7 +787,8 @@ const DataBrowser: React.FC = () => {
liveWinnerForCurrentContext,
skSelf,
collapsedSources,
rawSourcesData
rawSourcesData,
sourceNames
])

// Keep the ref in sync with the current memoised path list so the
Expand Down
10 changes: 10 additions & 0 deletions packages/server-admin-ui/src/views/DataBrowser/SourceLabel.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React, { useState, useRef, useEffect } from 'react'
import { useSourceAliases } from '../../hooks/useSourceAliases'
import { useLoginStatus } from '../../store'
import type { SourcesData } from '../../utils/sourceLabels'

interface SourceLabelProps {
Expand All @@ -12,6 +13,11 @@ const SourceLabel: React.FC<SourceLabelProps> = ({
sourcesData
}) => {
const { aliases, setAlias, getDisplayName } = useSourceAliases()
const loginStatus = useLoginStatus()
// Alias writes go to the admin-only /sourceAliases endpoint; non-admins
// see the resolved name but get no edit affordance.
const isAdmin =
!loginStatus.authenticationRequired || loginStatus.userLevel === 'admin'
const [isEditing, setIsEditing] = useState(false)
const [editValue, setEditValue] = useState('')
const inputRef = useRef<HTMLInputElement>(null)
Expand Down Expand Up @@ -62,6 +68,10 @@ const SourceLabel: React.FC<SourceLabelProps> = ({
const displayName = getDisplayName(sourceRef, sourcesData)
const hasAlias = !!aliases[sourceRef]

if (!isAdmin) {
return <span>{displayName}</span>
}

if (isEditing) {
return (
<span
Expand Down
66 changes: 66 additions & 0 deletions src/serverroutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import {
} from './config/config'
import { resetPriorities } from './config/priorities-file'
import { buildDeviceIdentities } from './deviceIdentities'
import { buildSourceNames } from './sourceNames'
import { SERVERROUTESPREFIX } from './constants'
import { handleAdminUICORSOrigin } from './cors'
import { createDebug, listKnownDebugs } from './debug'
Expand Down Expand Up @@ -544,6 +545,9 @@ module.exports = function (
res.json('Unable to save configuration change')
return
}
// Full-config saves can change device descriptions, so refresh
// the cached source names like the device routes do.
refreshSourceNames()
res.json('security config saved')
})
} else {
Expand All @@ -566,6 +570,10 @@ module.exports = function (
res.status(500).send('Unable to save configuration change')
return
}
// Device add/update/delete and access-request approval all
// funnel through here, so this is the single point that keeps
// the WebSocket device names fresh.
refreshSourceNames()
res.type('text/plain').send(success)
})
} else {
Expand All @@ -574,6 +582,39 @@ module.exports = function (
}
}

// Cached source-name map (ws device descriptions + manual aliases),
// rebuilt only when devices or aliases change — never on the per-delta
// path. Served read-only to all authenticated clients via GET
// /sourceNames so non-admin users see human-readable WebSocket device
// names too.
let cachedSourceNames: Record<string, string> | null = null

const computeSourceNames = (): Record<string, string> => {
const config = getSecurityConfig(app)
const devices =
typeof app.securityStrategy.getDevices === 'function'
? app.securityStrategy.getDevices(config)
: []
return buildSourceNames(devices, app.config.settings.sourceAliases || {})
}

const getSourceNames = (): Record<string, string> => {
if (cachedSourceNames === null) {
cachedSourceNames = computeSourceNames()
}
return cachedSourceNames
}

const refreshSourceNames = (): void => {
cachedSourceNames = computeSourceNames()
// serverevent (not serverAdminEvent) so non-admin clients get live
// updates too — serverAdminEvent is only forwarded to admins.
app.emit('serverevent', {
type: 'SOURCENAMES',
data: cachedSourceNames
})
}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
function checkAllowConfigure(req: Request, res: Response) {
if (app.securityStrategy.allowConfigure(req)) {
return true
Expand Down Expand Up @@ -1649,6 +1690,7 @@ module.exports = function (
type: 'SOURCEALIASES',
data: aliases
})
refreshSourceNames()
respondOk()
})
} else {
Expand Down Expand Up @@ -1726,6 +1768,29 @@ module.exports = function (
}
)

// Read-only and intentionally NOT behind addAdminMiddleware: every
// authenticated client (including read-only users) needs these names to
// render human-readable source labels. Writes stay admin-only via
// /sourceAliases and /security/devices. Access mirrors the data read
// policy: served when security is off, the request is authenticated, or
// anonymous read-only access is enabled — otherwise rejected, so the
// device descriptions are not exposed to anonymous clients.
app.get(
`${SERVERROUTESPREFIX}/sourceNames`,
(req: Request, res: Response) => {
const skReq = req as Request & { skIsAuthenticated?: boolean }
if (
!app.securityStrategy.isDummy() &&
!skReq.skIsAuthenticated &&
!app.securityStrategy.allowReadOnly()
) {
res.status(403).json('Permission denied')
return
}
res.json(getSourceNames())
}
)

app.securityStrategy.addAdminMiddleware(`${SERVERROUTESPREFIX}/sourceAliases`)

app.get(
Expand Down Expand Up @@ -1758,6 +1823,7 @@ module.exports = function (
type: 'SOURCEALIASES',
data: validation.value
})
refreshSourceNames()
res.json({ result: 'ok' })
}
})
Expand Down
Loading
Loading