From 79ab602f515479a0c1d2fea4647331e19388f934 Mon Sep 17 00:00:00 2001 From: Igor Octaviano Date: Thu, 26 Feb 2026 16:07:37 -0300 Subject: [PATCH 1/2] Address runtime server selection for partial url --- README.md | 9 +++++++++ src/App.tsx | 18 ++++++++++++------ src/components/Header.tsx | 39 +++++++++++++++++++++++++++------------ src/utils/url.tsx | 15 +++++++++++++++ 4 files changed, 63 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 97f45173..751df223 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/src/App.tsx b/src/App.tsx index 2962f194..2fb963ab 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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, @@ -275,8 +275,6 @@ class App extends React.Component { ) } - this.handleServerSelection = this.handleServerSelection.bind(this) - message.config({ duration: 5 }) App.addGcpSecondaryAnnotationServer(props.config) @@ -323,7 +321,7 @@ class App extends React.Component { } } - handleServerSelection({ url }: { url: string }): void { + handleServerSelection = async ({ url }: { url: string }): Promise => { const trimmedUrl = url.trim() console.info('select DICOMweb server: ', trimmedUrl) if ( @@ -333,13 +331,14 @@ class App extends React.Component { 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, }, @@ -347,6 +346,13 @@ class App extends React.Component { 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 diff --git a/src/components/Header.tsx b/src/components/Header.tsx index c6607c0e..29011681 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -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' @@ -202,12 +203,21 @@ class Header extends React.Component { 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 { @@ -538,15 +548,21 @@ class Header extends React.Component { 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, + }), }) } @@ -636,10 +652,9 @@ class Header extends React.Component { 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 && @@ -710,7 +725,7 @@ class Header extends React.Component { {this.state.serverSelectionMode === 'custom' && ( { + 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. * From b9e46e520b58dd0eec09d548bee41f434f8f15bf Mon Sep 17 00:00:00 2001 From: Igor Octaviano Date: Thu, 26 Feb 2026 16:10:50 -0300 Subject: [PATCH 2/2] Address missing series description (worklist) --- src/components/SlideItem.tsx | 17 +++++++---------- src/components/Worklist.tsx | 31 ++++++++++++++++++------------- 2 files changed, 25 insertions(+), 23 deletions(-) diff --git a/src/components/SlideItem.tsx b/src/components/SlideItem.tsx index 42d2820b..8a2efbbd 100644 --- a/src/components/SlideItem.tsx +++ b/src/components/SlideItem.tsx @@ -94,16 +94,13 @@ class SlideItem extends React.Component { 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 diff --git a/src/components/Worklist.tsx b/src/components/Worklist.tsx index 710251e1..11f25d1f 100644 --- a/src/components/Worklist.tsx +++ b/src/components/Worklist.tsx @@ -230,67 +230,72 @@ class Worklist extends React.Component { return () => this.handleReset(clearFilters) } + static orNbsp(s: string): string { + return s !== '' ? s : '\u00A0' + } + render(): React.ReactNode { + const orNbsp = Worklist.orNbsp const columns: ColumnsType = [ { 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)) }, }, ]