From 5a7ce80d9710bc2b7a06c0c30a2a1b8ddd190eba Mon Sep 17 00:00:00 2001 From: Igor Octaviano Date: Wed, 11 Mar 2026 07:51:48 -0400 Subject: [PATCH 1/9] fix: Address runtime server selection for partial url (#375) * Address runtime server selection for partial url * Address missing series description (worklist) --- README.md | 9 +++++++++ src/App.tsx | 18 +++++++++++------ src/components/Header.tsx | 39 +++++++++++++++++++++++++----------- src/components/SlideItem.tsx | 17 +++++++--------- src/components/Worklist.tsx | 31 ++++++++++++++++------------ src/utils/url.tsx | 15 ++++++++++++++ 6 files changed, 88 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index 97f4517..751df22 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 2962f19..2fb963a 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 c6607c0..2901168 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 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 710251e..11f25d1 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)) }, }, ] diff --git a/src/utils/url.tsx b/src/utils/url.tsx index c2fe6ad..497e436 100644 --- a/src/utils/url.tsx +++ b/src/utils/url.tsx @@ -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. * From abfbf67e53ec068716a27a72f6cf3f83612cbd31 Mon Sep 17 00:00:00 2001 From: Igor Octaviano Date: Wed, 11 Mar 2026 07:51:57 -0400 Subject: [PATCH 2/9] Revisit right panel #368 (#374) * Add new ui * Adjustments --- craco.config.js | 1 + src/App.dark.less | 33 ++ src/App.light.less | 33 ++ src/App.tsx | 89 ++--- src/components/ClusteringSettings.tsx | 79 ----- src/components/Header.tsx | 8 +- src/components/SlideItem.tsx | 17 +- src/components/SlideViewer.tsx | 335 ++++++++++++------ src/components/SlideViewer/SettingsPanel.css | 62 ++++ .../SlideViewer/SlideViewerSidebar.css | 1 + .../SlideViewer/SlideViewerSidebar.tsx | 12 +- src/components/SlideViewer/types.ts | 1 + src/contexts/SettingsContext.tsx | 95 +++++ src/data/slides.tsx | 12 + 14 files changed, 543 insertions(+), 235 deletions(-) delete mode 100644 src/components/ClusteringSettings.tsx create mode 100644 src/components/SlideViewer/SettingsPanel.css create mode 100644 src/components/SlideViewer/SlideViewerSidebar.css create mode 100644 src/contexts/SettingsContext.tsx diff --git a/craco.config.js b/craco.config.js index 2078093..1039d72 100644 --- a/craco.config.js +++ b/craco.config.js @@ -11,6 +11,7 @@ module.exports = { modifyVars: { '@layout-header-background': '#007ea3', '@primary-color': '#007ea3', + '@collapse-header-bg': '#e0f2f7', '@processing-color': '#8cb8c6', '@success-color': '#3f9c35', '@warning-color': '#eeaf30', diff --git a/src/App.dark.less b/src/App.dark.less index 5346afc..fa4e6bf 100644 --- a/src/App.dark.less +++ b/src/App.dark.less @@ -28,8 +28,41 @@ overflow: visible; } +.ant-collapse-header { + font-weight: 600; + border-top: 1px solid rgba(0, 126, 163, 0.5); + border-bottom: 1px solid rgba(0, 126, 163, 0.5); +} + +.ant-collapse-item + .ant-collapse-item .ant-collapse-header { + margin-top: -1px; +} + .ant-menu-submenu-title { font-size: 'medium'; + background-color: rgba(0, 126, 163, 0.25); + font-weight: 600; + border-top: 1px solid rgba(0, 126, 163, 0.5); + border-bottom: 1px solid rgba(0, 126, 163, 0.5); +} + +.ant-menu-submenu + .ant-menu-submenu .ant-menu-submenu-title { + margin-top: -1px; +} + +/* First section in sidebar has no top border */ +.ant-layout-sider .ant-menu-inline > .ant-menu-submenu:first-child > .ant-menu-submenu-title { + border-top: none; +} + +/* Settings drawer header border - matches section dividers (darker for contrast) */ +.slim-settings-drawer .ant-drawer-header { + border-bottom: 1px solid rgba(0, 126, 163, 0.5); +} + +/* Remove gap between submenu header and list content */ +.ant-layout-sider .ant-list-item { + padding: 0; } .ol-overviewmap-box { diff --git a/src/App.light.less b/src/App.light.less index 59e618f..8ccddf8 100644 --- a/src/App.light.less +++ b/src/App.light.less @@ -28,8 +28,41 @@ overflow: visible; } +.ant-collapse-header { + font-weight: 600; + border-top: 1px solid rgba(0, 126, 163, 0.3); + border-bottom: 1px solid rgba(0, 126, 163, 0.3); +} + +.ant-collapse-item + .ant-collapse-item .ant-collapse-header { + margin-top: -1px; +} + .ant-menu-submenu-title { font-size: 'medium'; + background-color: #e0f2f7; + font-weight: 600; + border-top: 1px solid rgba(0, 126, 163, 0.3); + border-bottom: 1px solid rgba(0, 126, 163, 0.3); +} + +.ant-menu-submenu + .ant-menu-submenu .ant-menu-submenu-title { + margin-top: -1px; +} + +/* First section in sidebar has no top border */ +.ant-layout-sider .ant-menu-inline > .ant-menu-submenu:first-child > .ant-menu-submenu-title { + border-top: none; +} + +/* Settings drawer header border - matches section dividers */ +.slim-settings-drawer .ant-drawer-header { + border-bottom: 1px solid rgba(0, 126, 163, 0.3); +} + +/* Remove gap between submenu header and list content */ +.ant-layout-sider .ant-list-item { + padding: 0; } .ol-overviewmap-box { diff --git a/src/App.tsx b/src/App.tsx index 2fb963a..70f0cde 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -20,6 +20,7 @@ import Header from './components/Header' import InfoPage from './components/InfoPage' import MemoryFooter from './components/MemoryFooter' import Worklist from './components/Worklist' +import { SettingsProvider } from './contexts/SettingsContext' import { ValidationProvider } from './contexts/ValidationContext' import DicomWebManager from './DicomWebManager' import { StorageClasses } from './data/uids' @@ -564,57 +565,61 @@ class App extends React.Component { -
- - + +
- - {enableMemoryMonitoring && ( - - )} - + + + + {enableMemoryMonitoring && ( + + )} + + } /> -
- - + +
- - {enableMemoryMonitoring && ( - - )} - + + + + {enableMemoryMonitoring && ( + + )} + + } /> void - onThresholdChange: (value: number | null) => void -} - -/** - * Clustering settings menu items for annotation groups. - * Extracted to reduce JSX nesting depth. - */ -const ClusteringSettings = ({ - isClusteringEnabled, - clusteringPixelSizeThreshold, - onClusteringToggle, - onThresholdChange, -}: ClusteringSettingsProps): JSX.Element => { - const toggleStyle = { - display: 'flex', - justifyContent: 'space-between', - alignItems: 'center', - marginBottom: '0.5rem', - } - - const helpTextStyle = { - fontSize: '0.75rem', - color: '#8c8c8c', - marginTop: '0.5rem', - } - - return ( - <> - -
- Enable Clustering - -
-
- - {isClusteringEnabled && ( - -
- Clustering Pixel Size Threshold (mm) -
- -
- When pixel size ≤ threshold, clustering is disabled. Leave empty for - zoom-based detection. -
-
- )} - - ) -} - -export default ClusteringSettings diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 2901168..ae95415 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -1,9 +1,9 @@ import { ApiOutlined, + BugOutlined, CheckOutlined, FileSearchOutlined, InfoOutlined, - SettingOutlined, StopOutlined, UnorderedListOutlined, UserOutlined, @@ -29,6 +29,7 @@ import { NavLink } from 'react-router-dom' import { v4 as uuidv4 } from 'uuid' import appPackageJson from '../../package.json' import type { User } from '../auth' +import { SettingsButton } from '../contexts/SettingsContext' import type DicomWebManager from '../DicomWebManager' import NotificationMiddleware, { NotificationMiddlewareEvents, @@ -618,7 +619,7 @@ class Header extends React.Component { style={{ zIndex: 1001 }} >