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
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,15 @@ Please refer to the [AppConfig.d.ts](src/AppConfig.d.ts) file for configuration

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

#### Runtime Server Selection

When `enableServerSelection` is enabled in config, users can switch the active DICOMweb server at runtime via the header.

- **Full URLs**: Paste the complete server URL (e.g. `https://healthcare.googleapis.com/v1/projects/.../dicomWeb`).
- **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.

Authorization is re-applied when switching servers, so a page reload is not needed after changing the active server.

### Handling Mixed Content and HTTPS

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:
Expand Down
18 changes: 12 additions & 6 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import NotificationMiddleware, {
NotificationMiddlewareContext,
} from './services/NotificationMiddleware'
import { CustomError, errorTypes } from './utils/CustomError'
import { joinUrl } from './utils/url'
import { joinUrl, normalizeServerUrl } from './utils/url'

function ParametrizedCaseViewer({
clients,
Expand Down Expand Up @@ -275,8 +275,6 @@ class App extends React.Component<AppProps, AppState> {
)
}

this.handleServerSelection = this.handleServerSelection.bind(this)

message.config({ duration: 5 })
App.addGcpSecondaryAnnotationServer(props.config)

Expand Down Expand Up @@ -323,7 +321,7 @@ class App extends React.Component<AppProps, AppState> {
}
}

handleServerSelection({ url }: { url: string }): void {
handleServerSelection = async ({ url }: { url: string }): Promise<void> => {
const trimmedUrl = url.trim()
console.info('select DICOMweb server: ', trimmedUrl)
if (
Expand All @@ -333,20 +331,28 @@ class App extends React.Component<AppProps, AppState> {
this.setState({ clients: this.state.defaultClients })
return
}
window.localStorage.setItem('slim_selected_server', trimmedUrl)
const resolvedUrl = normalizeServerUrl(trimmedUrl)
window.localStorage.setItem('slim_selected_server', resolvedUrl)
const tmpClient = new DicomWebManager({
baseUri: '',
settings: [
{
id: 'tmp',
url: trimmedUrl,
url: resolvedUrl,
read: true,
write: false,
},
],
onError: this.handleDICOMwebError,
})
tmpClient.updateHeaders(this.state.clients.default.headers)
// Re-apply auth so the new client has the current token (avoids 401 when switching mid-session)
if (this.auth != null && this.state.user != null) {
const token = await this.auth.getAuthorization()
if (token != null) {
tmpClient.updateHeaders({ Authorization: `Bearer ${token}` })
}
}
/**
* Use the newly created client for all storage classes. We may want to
* make this more sophisticated in the future to allow users to override
Expand Down
39 changes: 27 additions & 12 deletions src/components/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import NotificationMiddleware, {
} from '../services/NotificationMiddleware'
import type { CustomError } from '../utils/CustomError'
import { type RouteComponentProps, withRouter } from '../utils/router'
import { normalizeServerUrl } from '../utils/url'
import Button from './Button'
import DicomTagBrowser from './DicomTagBrowser/DicomTagBrowser'

Expand Down Expand Up @@ -202,12 +203,21 @@ class Header extends React.Component<HeaderProps, HeaderState> {
if (trimmedUrl === '') {
return false
}
try {
const urlObj = new URL(trimmedUrl)
return urlObj.protocol.startsWith('http') && urlObj.pathname.length > 0
} catch (_TypeError) {
return false
if (trimmedUrl.startsWith('http://') || trimmedUrl.startsWith('https://')) {
try {
const urlObj = new URL(trimmedUrl)
return urlObj.protocol.startsWith('http') && urlObj.pathname.length > 0
} catch (_TypeError) {
return false
}
}
const pathNorm = trimmedUrl.startsWith('/') ? trimmedUrl : `/${trimmedUrl}`
return (
pathNorm.includes('/projects/') &&
pathNorm.includes('/locations/') &&
pathNorm.includes('/datasets/') &&
pathNorm.includes('/dicomStores/')
)
}

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

const url = this.state.selectedServerUrl?.trim()
let closeModal = false
let resolvedUrl: string | undefined
if (url !== null && url !== undefined && url !== '') {
if (url.startsWith('http://') || url.startsWith('https://')) {
this.props.onServerSelection({ url })
if (this.isValidServerUrl(url)) {
resolvedUrl = normalizeServerUrl(url)
this.props.onServerSelection({ url: resolvedUrl })
closeModal = true
}
}
this.setState({
isServerSelectionModalVisible: !closeModal,
isServerSelectionDisabled: !closeModal,
...(closeModal &&
resolvedUrl !== undefined && {
selectedServerUrl: resolvedUrl,
}),
})
}

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

const selectedServerUrl =
this.state.serverSelectionMode === 'custom'
? this.state.selectedServerUrl?.trim()
: (this.props.clients?.default?.baseURL ??
this.props.defaultClients?.default?.baseURL)
this.props.clients?.default?.baseURL ??
this.props.defaultClients?.default?.baseURL ??
this.state.selectedServerUrl?.trim()

const urlInfo =
selectedServerUrl !== null &&
Expand Down Expand Up @@ -710,7 +725,7 @@ class Header extends React.Component<HeaderProps, HeaderState> {
{this.state.serverSelectionMode === 'custom' && (
<Tooltip title={this.state.selectedServerUrl?.trim()}>
<Input
placeholder="Enter base URL of DICOMweb Study Service"
placeholder="Full URL or GCP path (e.g. /projects/.../dicomStores/.../dicomWeb)"
value={this.state.selectedServerUrl}
onChange={this.handleServerSelectionInput}
onPressEnter={this.handleServerSelection}
Expand Down
17 changes: 7 additions & 10 deletions src/components/SlideItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,16 +94,13 @@ class SlideItem extends React.Component<SlideItemProps, SlideItemState> {

const attributes = []
const description = this.props.slide.description
if (
description !== null &&
description !== undefined &&
description !== ''
) {
attributes.push({
name: 'Description',
value: description,
})
}
attributes.push({
name: 'Description',
value:
description !== null && description !== undefined && description !== ''
? description
: '\u2014',
})

if (this.state.isLoading) {
return <FaSpinner />
Expand Down
31 changes: 18 additions & 13 deletions src/components/Worklist.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -230,67 +230,72 @@ class Worklist extends React.Component<WorklistProps, WorklistState> {
return () => this.handleReset(clearFilters)
}

static orNbsp(s: string): string {
return s !== '' ? s : '\u00A0'
}

render(): React.ReactNode {
const orNbsp = Worklist.orNbsp
const columns: ColumnsType<dmv.metadata.Study> = [
{
title: 'Accession Number',
dataIndex: 'AccessionNumber',
render: (v: string) => orNbsp(String(v ?? '')),
...this.getColumnSearchProps('AccessionNumber'),
},
{
title: 'Study ID',
dataIndex: 'StudyID',
render: (v: string) => orNbsp(String(v ?? '')),
...this.getColumnSearchProps('StudyID'),
},
{
title: 'Study Date',
dataIndex: 'StudyDate',
render: (value: string): string => parseDate(value),
render: (value: string): string => orNbsp(parseDate(value)),
},
{
title: 'Study Time',
dataIndex: 'StudyTime',
render: (value: string): string => parseTime(value),
render: (value: string): string => orNbsp(parseTime(value)),
},
{
title: 'Patient ID',
dataIndex: 'PatientID',
render: (v: string) => orNbsp(String(v ?? '')),
...this.getColumnSearchProps('PatientID'),
},
{
title: "Patient's Name",
dataIndex: 'PatientName',
render: (value: dmv.metadata.PersonName): string => parseName(value),
render: (value: dmv.metadata.PersonName): string =>
orNbsp(parseName(value)),
...this.getColumnSearchProps('PatientName'),
},
{
title: "Patient's Sex",
dataIndex: 'PatientSex',
render: (value: string): string => parseSex(value),
render: (value: string): string => orNbsp(parseSex(value)),
},
{
title: "Patient's Birthdate",
dataIndex: 'PatientBirthDate',
render: (value: string): string => parseDate(value),
render: (value: string): string => orNbsp(parseDate(value)),
},
{
title: "Referring Physician's Name",
dataIndex: 'ReferringPhysicianName',
render: (value: dmv.metadata.PersonName): string => parseName(value),
render: (value: dmv.metadata.PersonName): string =>
orNbsp(parseName(value)),
},
{
title: 'Modalities in Study',
dataIndex: 'ModalitiesInStudy',
render: (value: string[] | string): string => {
if (value === undefined) {
/*
* This should not happen, since the attribute is required.
* However, some origin servers don't include it.
*/
return ''
} else {
return String(value)
return '\u00A0'
}
return orNbsp(String(value))
},
},
]
Expand Down
15 changes: 15 additions & 0 deletions src/utils/url.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,18 @@
export const GCP_HEALTHCARE_V1_BASE = 'https://healthcare.googleapis.com/v1'

/**
* Normalize server URL. Path-only input (no domain) is prepended with GCP Healthcare v1 base
* so users can paste GCP DICOM store paths without the full domain.
*/
export const normalizeServerUrl = (input: string): string => {
const trimmed = input.trim()
if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
return trimmed
}
const path = trimmed.startsWith('/') ? trimmed : `/${trimmed}`
return `${GCP_HEALTHCARE_V1_BASE}${path}`
}

/**
* Join a URI with a path to form a full URL.
*
Expand Down