Skip to content

Commit 5a7ce80

Browse files
fix: Address runtime server selection for partial url (#375)
* Address runtime server selection for partial url * Address missing series description (worklist)
1 parent 894f63f commit 5a7ce80

6 files changed

Lines changed: 88 additions & 41 deletions

File tree

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,15 @@ Please refer to the [AppConfig.d.ts](src/AppConfig.d.ts) file for configuration
135135

136136
The configuration can be changed at build-time using the `REACT_APP_CONFIG` environment variable.
137137

138+
#### Runtime Server Selection
139+
140+
When `enableServerSelection` is enabled in config, users can switch the active DICOMweb server at runtime via the header.
141+
142+
- **Full URLs**: Paste the complete server URL (e.g. `https://healthcare.googleapis.com/v1/projects/.../dicomWeb`).
143+
- **Path-only (GCP Healthcare)**: Paste a GCP DICOM store path without the domain (e.g. `/projects/my-project/locations/us-central1/datasets/my-dataset/dicomStores/my-store/dicomWeb`). The app prepends `https://healthcare.googleapis.com/v1` automatically.
144+
145+
Authorization is re-applied when switching servers, so a page reload is not needed after changing the active server.
146+
138147
### Handling Mixed Content and HTTPS
139148

140149
When deploying SLIM with HTTPS, you may encounter mixed content scenarios where your PACS/VNA server returns HTTP URLs in its responses. This commonly occurs when:

src/App.tsx

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ import NotificationMiddleware, {
2727
NotificationMiddlewareContext,
2828
} from './services/NotificationMiddleware'
2929
import { CustomError, errorTypes } from './utils/CustomError'
30-
import { joinUrl } from './utils/url'
30+
import { joinUrl, normalizeServerUrl } from './utils/url'
3131

3232
function ParametrizedCaseViewer({
3333
clients,
@@ -275,8 +275,6 @@ class App extends React.Component<AppProps, AppState> {
275275
)
276276
}
277277

278-
this.handleServerSelection = this.handleServerSelection.bind(this)
279-
280278
message.config({ duration: 5 })
281279
App.addGcpSecondaryAnnotationServer(props.config)
282280

@@ -323,7 +321,7 @@ class App extends React.Component<AppProps, AppState> {
323321
}
324322
}
325323

326-
handleServerSelection({ url }: { url: string }): void {
324+
handleServerSelection = async ({ url }: { url: string }): Promise<void> => {
327325
const trimmedUrl = url.trim()
328326
console.info('select DICOMweb server: ', trimmedUrl)
329327
if (
@@ -333,20 +331,28 @@ class App extends React.Component<AppProps, AppState> {
333331
this.setState({ clients: this.state.defaultClients })
334332
return
335333
}
336-
window.localStorage.setItem('slim_selected_server', trimmedUrl)
334+
const resolvedUrl = normalizeServerUrl(trimmedUrl)
335+
window.localStorage.setItem('slim_selected_server', resolvedUrl)
337336
const tmpClient = new DicomWebManager({
338337
baseUri: '',
339338
settings: [
340339
{
341340
id: 'tmp',
342-
url: trimmedUrl,
341+
url: resolvedUrl,
343342
read: true,
344343
write: false,
345344
},
346345
],
347346
onError: this.handleDICOMwebError,
348347
})
349348
tmpClient.updateHeaders(this.state.clients.default.headers)
349+
// Re-apply auth so the new client has the current token (avoids 401 when switching mid-session)
350+
if (this.auth != null && this.state.user != null) {
351+
const token = await this.auth.getAuthorization()
352+
if (token != null) {
353+
tmpClient.updateHeaders({ Authorization: `Bearer ${token}` })
354+
}
355+
}
350356
/**
351357
* Use the newly created client for all storage classes. We may want to
352358
* make this more sophisticated in the future to allow users to override

src/components/Header.tsx

Lines changed: 27 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import NotificationMiddleware, {
3535
} from '../services/NotificationMiddleware'
3636
import type { CustomError } from '../utils/CustomError'
3737
import { type RouteComponentProps, withRouter } from '../utils/router'
38+
import { normalizeServerUrl } from '../utils/url'
3839
import Button from './Button'
3940
import DicomTagBrowser from './DicomTagBrowser/DicomTagBrowser'
4041

@@ -202,12 +203,21 @@ class Header extends React.Component<HeaderProps, HeaderState> {
202203
if (trimmedUrl === '') {
203204
return false
204205
}
205-
try {
206-
const urlObj = new URL(trimmedUrl)
207-
return urlObj.protocol.startsWith('http') && urlObj.pathname.length > 0
208-
} catch (_TypeError) {
209-
return false
206+
if (trimmedUrl.startsWith('http://') || trimmedUrl.startsWith('https://')) {
207+
try {
208+
const urlObj = new URL(trimmedUrl)
209+
return urlObj.protocol.startsWith('http') && urlObj.pathname.length > 0
210+
} catch (_TypeError) {
211+
return false
212+
}
210213
}
214+
const pathNorm = trimmedUrl.startsWith('/') ? trimmedUrl : `/${trimmedUrl}`
215+
return (
216+
pathNorm.includes('/projects/') &&
217+
pathNorm.includes('/locations/') &&
218+
pathNorm.includes('/datasets/') &&
219+
pathNorm.includes('/dicomStores/')
220+
)
211221
}
212222

213223
static handleUserMenuButtonClick(e: React.SyntheticEvent): void {
@@ -538,15 +548,21 @@ class Header extends React.Component<HeaderProps, HeaderState> {
538548

539549
const url = this.state.selectedServerUrl?.trim()
540550
let closeModal = false
551+
let resolvedUrl: string | undefined
541552
if (url !== null && url !== undefined && url !== '') {
542-
if (url.startsWith('http://') || url.startsWith('https://')) {
543-
this.props.onServerSelection({ url })
553+
if (this.isValidServerUrl(url)) {
554+
resolvedUrl = normalizeServerUrl(url)
555+
this.props.onServerSelection({ url: resolvedUrl })
544556
closeModal = true
545557
}
546558
}
547559
this.setState({
548560
isServerSelectionModalVisible: !closeModal,
549561
isServerSelectionDisabled: !closeModal,
562+
...(closeModal &&
563+
resolvedUrl !== undefined && {
564+
selectedServerUrl: resolvedUrl,
565+
}),
550566
})
551567
}
552568

@@ -636,10 +652,9 @@ class Header extends React.Component<HeaderProps, HeaderState> {
636652
const logoUrl = `${process.env.PUBLIC_URL}/logo.svg`
637653

638654
const selectedServerUrl =
639-
this.state.serverSelectionMode === 'custom'
640-
? this.state.selectedServerUrl?.trim()
641-
: (this.props.clients?.default?.baseURL ??
642-
this.props.defaultClients?.default?.baseURL)
655+
this.props.clients?.default?.baseURL ??
656+
this.props.defaultClients?.default?.baseURL ??
657+
this.state.selectedServerUrl?.trim()
643658

644659
const urlInfo =
645660
selectedServerUrl !== null &&
@@ -710,7 +725,7 @@ class Header extends React.Component<HeaderProps, HeaderState> {
710725
{this.state.serverSelectionMode === 'custom' && (
711726
<Tooltip title={this.state.selectedServerUrl?.trim()}>
712727
<Input
713-
placeholder="Enter base URL of DICOMweb Study Service"
728+
placeholder="Full URL or GCP path (e.g. /projects/.../dicomStores/.../dicomWeb)"
714729
value={this.state.selectedServerUrl}
715730
onChange={this.handleServerSelectionInput}
716731
onPressEnter={this.handleServerSelection}

src/components/SlideItem.tsx

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -94,16 +94,13 @@ class SlideItem extends React.Component<SlideItemProps, SlideItemState> {
9494

9595
const attributes = []
9696
const description = this.props.slide.description
97-
if (
98-
description !== null &&
99-
description !== undefined &&
100-
description !== ''
101-
) {
102-
attributes.push({
103-
name: 'Description',
104-
value: description,
105-
})
106-
}
97+
attributes.push({
98+
name: 'Description',
99+
value:
100+
description !== null && description !== undefined && description !== ''
101+
? description
102+
: '\u2014',
103+
})
107104

108105
if (this.state.isLoading) {
109106
return <FaSpinner />

src/components/Worklist.tsx

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -230,67 +230,72 @@ class Worklist extends React.Component<WorklistProps, WorklistState> {
230230
return () => this.handleReset(clearFilters)
231231
}
232232

233+
static orNbsp(s: string): string {
234+
return s !== '' ? s : '\u00A0'
235+
}
236+
233237
render(): React.ReactNode {
238+
const orNbsp = Worklist.orNbsp
234239
const columns: ColumnsType<dmv.metadata.Study> = [
235240
{
236241
title: 'Accession Number',
237242
dataIndex: 'AccessionNumber',
243+
render: (v: string) => orNbsp(String(v ?? '')),
238244
...this.getColumnSearchProps('AccessionNumber'),
239245
},
240246
{
241247
title: 'Study ID',
242248
dataIndex: 'StudyID',
249+
render: (v: string) => orNbsp(String(v ?? '')),
243250
...this.getColumnSearchProps('StudyID'),
244251
},
245252
{
246253
title: 'Study Date',
247254
dataIndex: 'StudyDate',
248-
render: (value: string): string => parseDate(value),
255+
render: (value: string): string => orNbsp(parseDate(value)),
249256
},
250257
{
251258
title: 'Study Time',
252259
dataIndex: 'StudyTime',
253-
render: (value: string): string => parseTime(value),
260+
render: (value: string): string => orNbsp(parseTime(value)),
254261
},
255262
{
256263
title: 'Patient ID',
257264
dataIndex: 'PatientID',
265+
render: (v: string) => orNbsp(String(v ?? '')),
258266
...this.getColumnSearchProps('PatientID'),
259267
},
260268
{
261269
title: "Patient's Name",
262270
dataIndex: 'PatientName',
263-
render: (value: dmv.metadata.PersonName): string => parseName(value),
271+
render: (value: dmv.metadata.PersonName): string =>
272+
orNbsp(parseName(value)),
264273
...this.getColumnSearchProps('PatientName'),
265274
},
266275
{
267276
title: "Patient's Sex",
268277
dataIndex: 'PatientSex',
269-
render: (value: string): string => parseSex(value),
278+
render: (value: string): string => orNbsp(parseSex(value)),
270279
},
271280
{
272281
title: "Patient's Birthdate",
273282
dataIndex: 'PatientBirthDate',
274-
render: (value: string): string => parseDate(value),
283+
render: (value: string): string => orNbsp(parseDate(value)),
275284
},
276285
{
277286
title: "Referring Physician's Name",
278287
dataIndex: 'ReferringPhysicianName',
279-
render: (value: dmv.metadata.PersonName): string => parseName(value),
288+
render: (value: dmv.metadata.PersonName): string =>
289+
orNbsp(parseName(value)),
280290
},
281291
{
282292
title: 'Modalities in Study',
283293
dataIndex: 'ModalitiesInStudy',
284294
render: (value: string[] | string): string => {
285295
if (value === undefined) {
286-
/*
287-
* This should not happen, since the attribute is required.
288-
* However, some origin servers don't include it.
289-
*/
290-
return ''
291-
} else {
292-
return String(value)
296+
return '\u00A0'
293297
}
298+
return orNbsp(String(value))
294299
},
295300
},
296301
]

src/utils/url.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,18 @@
1+
export const GCP_HEALTHCARE_V1_BASE = 'https://healthcare.googleapis.com/v1'
2+
3+
/**
4+
* Normalize server URL. Path-only input (no domain) is prepended with GCP Healthcare v1 base
5+
* so users can paste GCP DICOM store paths without the full domain.
6+
*/
7+
export const normalizeServerUrl = (input: string): string => {
8+
const trimmed = input.trim()
9+
if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
10+
return trimmed
11+
}
12+
const path = trimmed.startsWith('/') ? trimmed : `/${trimmed}`
13+
return `${GCP_HEALTHCARE_V1_BASE}${path}`
14+
}
15+
116
/**
217
* Join a URI with a path to form a full URL.
318
*

0 commit comments

Comments
 (0)