diff --git a/.gitignore b/.gitignore index fd5b69a3..8d38db3c 100644 --- a/.gitignore +++ b/.gitignore @@ -139,6 +139,8 @@ orthanc-db *.pyc *settings.json *.seq +*.dcm +*.mrd */build /infrastructure/monai/.md/* .examples diff --git a/README.md b/README.md index ab5efd95..7504a99f 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,6 @@

- ScanHub + ScanHub +

ScanHub

@@ -15,9 +16,9 @@

---- -# Scanhub + +# About ScanHub is intended to be a multi modal acquisition software, which allows individualizable, modular and cloud-based processing of functional and anatomical medical images. It seamlessly merges the acquisition with the processing of complex data on a single platform. diff --git a/docker-compose.yml b/docker-compose.yml index c117bb1c..bb019470 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,6 @@ x-dagster-common: &dagster_common image: scanhub-orchestration-engine:latest - pull_policy: never # Don't pull the image, use the local build + pull_policy: never # Don't pull the image, use the local build build: context: ./services/orchestration-engine args: @@ -27,18 +27,19 @@ services: # Workflow manager, device manager and exam manager create data tables in postgres DB workflow-manager: container_name: workflow-manager - build: + build: context: ./services/workflow-manager - args: + args: - BASE_IMG=${SCANHUB_BASE_IMAGE} volumes: - ./services/workflow-manager/app/:/app/ - ${DATA_LAKE_DIR}:/data environment: DATA_LAKE_DIRECTORY: /data + SCANHUB_ALLOWED_ORIGINS: ${SCANHUB_ALLOWED_ORIGINS} healthcheck: # TODO: host.docker.internal is only working with docker desktop, must be replaced in product - test: ["CMD", "curl", "http://workflow-manager:8000/api/v1/workflowmanager/health/readiness"] + test: [ "CMD", "curl", "http://workflow-manager:8000/api/v1/workflowmanager/health/readiness" ] interval: 5s timeout: 5s retries: 10 @@ -54,16 +55,17 @@ services: container_name: device-manager build: context: ./services/device-manager - args: + args: - BASE_IMG=${SCANHUB_BASE_IMAGE} volumes: - ./services/device-manager/app/:/app/ - ${DATA_LAKE_DIR}:/data environment: DATA_LAKE_DIRECTORY: /data + SCANHUB_ALLOWED_ORIGINS: ${SCANHUB_ALLOWED_ORIGINS} healthcheck: # TODO: host.docker.internal is only working with docker desktop, must be replaced in product - test: ["CMD", "curl", "http://device-manager:8000/api/v1/device/health/readiness"] + test: [ "CMD", "curl", "http://device-manager:8000/api/v1/device/health/readiness" ] interval: 5s timeout: 5s retries: 10 @@ -77,16 +79,24 @@ services: exam-manager: container_name: exam-manager - build: + build: context: ./services/exam-manager - args: + args: - BASE_IMG=${SCANHUB_BASE_IMAGE} volumes: - ./services/exam-manager/app/:/app/ - ${DATA_LAKE_DIR}:/data + environment: + SCANHUB_ALLOWED_ORIGINS: ${SCANHUB_ALLOWED_ORIGINS} + XNAT_HOST: ${XNAT_HOST:-http://host.docker.internal:8081} + XNAT_USER: ${XNAT_USER:-admin} + XNAT_PASSWORD: ${XNAT_PASSWORD:-admin} + XNAT_PROJECT_ID: ${XNAT_PROJECT_ID:-A4IM} + extra_hosts: + - 'host.docker.internal:host-gateway' healthcheck: # TODO: host.docker.internal is only working with docker desktop, must be replaced in product - test: ["CMD", "curl", "http://exam-manager:8000/api/v1/exam/health/readiness"] + test: [ "CMD", "curl", "http://exam-manager:8000/api/v1/exam/health/readiness" ] interval: 5s timeout: 5s retries: 10 @@ -108,15 +118,17 @@ services: patient-manager: container_name: patient-manager - build: + build: context: ./services/patient-manager - args: - - BASE_IMG=${SCANHUB_BASE_IMAGE} + args: + - BASE_IMG=${SCANHUB_BASE_IMAGE} volumes: - ./services/patient-manager/app:/app/ + environment: + SCANHUB_ALLOWED_ORIGINS: ${SCANHUB_ALLOWED_ORIGINS} healthcheck: # TODO: host.docker.internal is only working with docker desktop, must be replaced in product - test: ["CMD", "curl", "http://patient-manager:8100/api/v1/patient/health/readiness"] + test: [ "CMD", "curl", "http://patient-manager:8100/api/v1/patient/health/readiness" ] interval: 5s timeout: 5s retries: 10 @@ -130,15 +142,17 @@ services: user-login-manager: container_name: user-login-manager - build: + build: context: ./services/user-login-manager - args: + args: - BASE_IMG=${SCANHUB_BASE_IMAGE} volumes: - ./services/user-login-manager/app/:/app/ + environment: + SCANHUB_ALLOWED_ORIGINS: ${SCANHUB_ALLOWED_ORIGINS} healthcheck: # TODO: host.docker.internal is only working with docker desktop, must be replaced in product - test: ["CMD", "curl", "http://user-login-manager:8000/api/v1/userlogin/health/readiness"] + test: [ "CMD", "curl", "http://user-login-manager:8000/api/v1/userlogin/health/readiness" ] interval: 5s timeout: 5s retries: 10 @@ -179,13 +193,13 @@ services: POSTGRES_USER_FILE: /run/secrets/scanhub_database_postgres_user POSTGRES_PASSWORD_FILE: /run/secrets/scanhub_database_postgres_password POSTGRES_DB_FILE: /run/secrets/scanhub_database_postgres_db_name - healthcheck: + healthcheck: # Workflow, device and exam microservices depend on healthy postgres database - test: ["CMD-SHELL", "pg_isready"] + test: [ "CMD-SHELL", "pg_isready" ] interval: 5s timeout: 5s retries: 10 - start_period: 5s + start_period: 5s secrets: - scanhub_database_postgres_user - scanhub_database_postgres_password @@ -202,9 +216,9 @@ services: POSTGRES_USER_FILE: /run/secrets/patient_database_postgres_user POSTGRES_PASSWORD_FILE: /run/secrets/patient_database_postgres_password POSTGRES_DB_FILE: /run/secrets/patient_database_postgres_db_name - healthcheck: + healthcheck: # init-db container is started, after health check passed - test: ["CMD-SHELL", "pg_isready", "-d", "db_prod"] + test: [ "CMD-SHELL", "pg_isready", "-d", "db_prod" ] interval: 10s timeout: 5s retries: 5 @@ -214,7 +228,6 @@ services: - patient_database_postgres_password - patient_database_postgres_db_name - # MRI Sequence mongo database: Contains (pulseq) mri sequence files sequence-database: container_name: db-sequence @@ -235,7 +248,6 @@ services: - sequence_database_root_username - sequence_database_root_password - # API Gateway nginx: container_name: api-gateway @@ -250,7 +262,7 @@ services: volumes: - ./infrastructure/nginx_config.conf:/etc/nginx/conf.d/default.conf healthcheck: - test: ["CMD", "curl", "http://api-gateway/health"] + test: [ "CMD", "curl", "http://api-gateway/health" ] interval: 5s timeout: 5s retries: 10 @@ -259,7 +271,7 @@ services: condition: service_healthy workflow-manager: condition: service_healthy - exam-manager: + exam-manager: condition: service_healthy patient-manager: condition: service_healthy @@ -269,37 +281,37 @@ services: condition: service_healthy # scanhub-ui: # condition: service_healthy - extra_hosts: # somehow needed to find host.docker.internal under linux - - 'host.docker.internal:host-gateway' # for the access to host.docker.internal to work the firewall of the host needs to allow incoming connections + extra_hosts: + # somehow needed to find host.docker.internal under linux + - 'host.docker.internal:host-gateway' # for the access to host.docker.internal to work the firewall of the host needs to allow incoming connections secrets: - certificate - privatekey - # FRONTEND/SCANHUB-UI - scanhub-ui: - container_name: scanhub-ui - build: - context: ./scanhub-ui - dockerfile: ./Dockerfile - args: - NODE_ENV: "production" # or "development" - volumes: - - ./scanhub-ui/src:/app/src - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:3000/"] - interval: 10s - timeout: 5s - retries: 10 - start_period: 30s + # scanhub-ui: + # container_name: scanhub-ui + # build: + # context: ./scanhub-ui + # dockerfile: ./Dockerfile + # args: + # NODE_ENV: "production" # or "development" + # volumes: + # - ./scanhub-ui/src:/app/src + # healthcheck: + # test: ["CMD", "curl", "-f", "http://localhost:3000/"] + # interval: 10s + # timeout: 5s + # retries: 10 + # start_period: 30s # DAGSTER dagster-webserver: <<: *dagster_common container_name: dagster-webserver - command: ["dagster-webserver", "-h", "0.0.0.0", "-p", "3000", "--path-prefix", "/dagster", "-w", "/opt/dagster/app/workspace.yaml"] + command: [ "dagster-webserver", "-h", "0.0.0.0", "-p", "3000", "--path-prefix", "/dagster", "-w", "/opt/dagster/app/workspace.yaml" ] healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:3000/dagster/server-status"] + test: [ "CMD", "curl", "-f", "http://localhost:3000/dagster/server-status" ] interval: 10s timeout: 5s retries: 5 @@ -311,14 +323,13 @@ services: <<: *dagster_common container_name: dagster-daemon user: "0:0" - entrypoint: ["dagster-daemon", "run", "-w", "/opt/dagster/app/workspace.yaml"] + entrypoint: [ "dagster-daemon", "run", "-w", "/opt/dagster/app/workspace.yaml" ] healthcheck: - test: ["CMD", "pgrep", "-f", "dagster-daemon"] + test: [ "CMD", "pgrep", "-f", "dagster-daemon" ] interval: 10s timeout: 5s retries: 5 - volumes: # data_lake: # Use bind-mount to inspect files, make sure data_lake/ exists at project root mongodb_data_sequence: @@ -347,4 +358,4 @@ secrets: certificate: file: secrets/certificate.pem privatekey: - file: secrets/privatekey.pem \ No newline at end of file + file: secrets/privatekey.pem diff --git a/scanhub-ui/package.json b/scanhub-ui/package.json index 4d283bd6..2e0c0636 100644 --- a/scanhub-ui/package.json +++ b/scanhub-ui/package.json @@ -7,16 +7,17 @@ "source": "index.html", "dependencies": { "@cornerstonejs/codec-charls": "^1.2.3", - "@cornerstonejs/codec-libjpeg-turbo-8bit": "1.2.2", + "@cornerstonejs/codec-libjpeg-turbo-8bit": "^1.2.2", "@cornerstonejs/codec-openjpeg": "^1.2.4", "@cornerstonejs/codec-openjph": "^2.4.7", - "@cornerstonejs/core": "^3.32.10", - "@cornerstonejs/dicom-image-loader": "^3.32.10", - "@cornerstonejs/tools": "^3.32.10", + "@cornerstonejs/core": "^4", + "@cornerstonejs/dicom-image-loader": "^4", + "@cornerstonejs/tools": "^4", "@emotion/react": "^11.11.1", "@emotion/styled": "^11.11.0", "@fontsource/roboto": "^5.0.5", "@kitware/vtk.js": "32.12.1", + "@monaco-editor/react": "^4.7.0", "@mui/icons-material": "^7.1.1", "@mui/joy": "^5.0.0-beta.52", "@mui/material": "^7.1.1", diff --git a/scanhub-ui/src/Routes.tsx b/scanhub-ui/src/Routes.tsx index 254e3ea2..c93aa7f8 100644 --- a/scanhub-ui/src/Routes.tsx +++ b/scanhub-ui/src/Routes.tsx @@ -26,6 +26,7 @@ import TemplatesView from './pages/TemplatesView' import UserManagementView from './pages/UserManagementView' import SequenceView from './pages/SequenceView' import DeviceView from './pages/DeviceView' +import LibraryView from './pages/Library'; const materialTheme = materialExtendTheme() @@ -46,16 +47,18 @@ export function RouteConfiguration() { '--Form-maxWidth': '700px', '--Transition-duration': '0.4s', // set to `none` to disable transition '--Sidebar-width': '360px', - '--Navigation-height': '60px', + '--Navigation-height': '50px', '--Status-height': '22px', + '--IconFontSize': '22px', }, }} /> - : }> + : }> } /> } /> + } /> } /> } /> } /> diff --git a/scanhub-ui/src/api.tsx b/scanhub-ui/src/api.tsx index f0b12c94..736650d0 100644 --- a/scanhub-ui/src/api.tsx +++ b/scanhub-ui/src/api.tsx @@ -5,7 +5,7 @@ * api.tsx contains instances of the different apis. */ import { PatientsApi, HealthApi as PatientManagerHealthApi } from './openapi/generated-client/patient' -import { ExamsApi, WorkflowsApi, TasksApi, ResultsApi, MriSequencesApi, HealthApi as ExamManagerHealthApi } from './openapi/generated-client/exam' +import { ExamsApi, WorkflowsApi, TasksApi, ResultsApi, MriSequencesApi, DataApi, HealthApi as ExamManagerHealthApi } from './openapi/generated-client/exam' import { WorkflowManagerApi, HealthApi as WorkflowManagerHealthApi } from './openapi/generated-client/workflowmanager' import { UserApi, LoginApi, HealthApi as UserLoginManagerHealthApi } from './openapi/generated-client/userlogin' import { DevicesApi, HealthApi as DeviceManagerHealthApi } from './openapi/generated-client/device' @@ -20,6 +20,7 @@ export const workflowsApi = new WorkflowsApi(undefined, baseUrls.examService) export const taskApi = new TasksApi(undefined, baseUrls.examService) export const resultApi = new ResultsApi(undefined, baseUrls.examService) export const sequenceApi = new MriSequencesApi(undefined, baseUrls.examService) +export const dataApi = new DataApi(undefined, baseUrls.examService) export const examManagerHealthApi = new ExamManagerHealthApi(undefined, baseUrls.examService) export const workflowManagerApi = new WorkflowManagerApi(undefined, baseUrls.workflowManagerService) diff --git a/scanhub-ui/src/components/DeviceStatus.tsx b/scanhub-ui/src/components/DeviceStatus.tsx new file mode 100644 index 00000000..abf8029a --- /dev/null +++ b/scanhub-ui/src/components/DeviceStatus.tsx @@ -0,0 +1,143 @@ +/** + * Copyright (C) 2024, BRAIN-LINK UG (haftungsbeschränkt). All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-ScanHub-Commercial + * + * DeviceStatus.tsx is responsible for rendering the device status bar. + */ +import React from 'react' + +import Box from '@mui/joy/Box' +import Chip from '@mui/joy/Chip' +import TripOriginRoundedIcon from '@mui/icons-material/TripOriginRounded'; +import IconButton from '@mui/joy/IconButton' +import Typography from '@mui/joy/Typography' +import Tooltip from '@mui/joy/Tooltip' +import { ClickAwayListener } from '@mui/base'; +import CircularProgress from '@mui/joy/CircularProgress' +import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'; + +import { useQuery } from '@tanstack/react-query' +import { deviceApi } from '../api' +import { DeviceOut, DeviceStatus as DeviceStatusEnum } from '../openapi/generated-client/device/api' + + +function DeviceStatusInfo({ items }: { items: Array }) { + // Mapping device status to Chip color + const getStatusColor = (status?: string) => { + switch (status) { + case DeviceStatusEnum.Online: + return 'success'; + case DeviceStatusEnum.Busy: + return 'warning'; + case DeviceStatusEnum.Offline: + return 'neutral'; + case DeviceStatusEnum.Error: + return 'danger'; + default: + return 'neutral'; + } + } + + return ( + + { + items.length > 0 ? (items.map((device) => { + return ( + + {device.name} + + {(device.status as unknown as string) || 'Unknown'} + + + ); + }) + ) : ( + No devices + ) + } + + ) +} + + +export default function DeviceStatus() { + + const [tooltipHovered, setTooltipHovered] = React.useState(false); + const [tooltipPinned, setTooltipPinned] = React.useState(false); + const tooltipOpen = tooltipHovered || tooltipPinned + + const { data: devices, isLoading, isError } = useQuery({ + queryKey: ['devices'], + queryFn: async () => { + const result = await deviceApi.getDevicesApiV1DeviceGet() + return result.data + }, + // Poll every 5 seconds to keep status more or less fresh + refetchInterval: 5000, + }) + + // Sort by latest activity and take top 5 + // We need to handle potential undefined datetime_updated, fall back to datetime_created or 0 + const sortedDevices = React.useMemo(() => { + if (!devices) return []; + return [...devices].sort((a, b) => { + const dateA = new Date((a.datetime_updated || a.datetime_created) as any).getTime(); + const dateB = new Date((b.datetime_updated || b.datetime_created) as any).getTime(); + return dateB - dateA; + }).slice(0, 5); + }, [devices]); + + const anyError = sortedDevices.some(d => (d.status as unknown as string) === DeviceStatusEnum.Error); + + if (isLoading || isError || sortedDevices.length === 0) { + // If loading or error, or no devices, we might want to hide it or show a neutral state. + // ManagerStatus shows even if loading (with spinner). + // Here we definitely want to show something if we are fetching. + // If no devices, maybe just disable or show generic icon? + // Let's render the button anyway. + } + + return ( + { if (tooltipPinned) setTooltipPinned(false) }}> + : } + open={tooltipOpen} + modifiers={[ + { name: 'preventOverflow', options: { padding: 10 } }, + ]} + > + setTooltipPinned(prev => !prev)} + onMouseEnter={() => setTooltipHovered(true)} + onMouseLeave={() => setTooltipHovered(false)} + > + { + anyError ? : + + } + + + + ) +} diff --git a/scanhub-ui/src/components/ManagerStatus.tsx b/scanhub-ui/src/components/ManagerStatus.tsx new file mode 100644 index 00000000..ee495536 --- /dev/null +++ b/scanhub-ui/src/components/ManagerStatus.tsx @@ -0,0 +1,134 @@ +/** + * Copyright (C) 2024, BRAIN-LINK UG (haftungsbeschränkt). All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-ScanHub-Commercial + * + * ManagerStatus.tsx is responsible for rendering the manager status bar. + */ +import React from 'react' + +import Box from '@mui/joy/Box' +import Chip from '@mui/joy/Chip' +import LinkIcon from '@mui/icons-material/Link'; +import IconButton from '@mui/joy/IconButton' +import Typography from '@mui/joy/Typography' +import Tooltip from '@mui/joy/Tooltip' +import { ClickAwayListener } from '@mui/base'; + +import { useManagerHealthCheck } from '../hooks/useManagerHealthCheck' +import { + patientManagerHealthApi, + examManagerHealthApi, + workflowManagerHealthApi, + userLoginManagerHealthApi, + deviceManagerHealthApi +} from '../api' +import CircularProgress from '@mui/joy/CircularProgress' +import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'; + + + +// Device all health checks +const healthChecks = [ + { + name: 'Patient Manager', + key: 'patientManagerHealthCheck', + queryFn: () => patientManagerHealthApi.readinessApiV1PatientHealthReadinessGet({ timeout: 1000 }).then(r => r.data), + }, + { + name: 'Exam Manager', + key: 'examManagerHealthCheck', + queryFn: () => examManagerHealthApi.readinessApiV1ExamHealthReadinessGet({ timeout: 1000 }).then(r => r.data), + }, + { + name: 'Workflow Manager', + key: 'workflowManagerHealthCheck', + queryFn: () => workflowManagerHealthApi.readinessApiV1WorkflowmanagerHealthReadinessGet({ timeout: 1000 }).then(r => r.data), + }, + { + name: 'User Login Manager', + key: 'userLoginManagerHealthCheck', + queryFn: () => userLoginManagerHealthApi.readinessApiV1UserloginHealthReadinessGet({ timeout: 1000 }).then(r => r.data), + }, + { + name: 'Device Manager', + key: 'deviceManagerHealth', + queryFn: () => deviceManagerHealthApi.readinessApiV1DeviceHealthReadinessGet({ timeout: 1000 }).then(r => r.data), + } +] + +function ManagerStatusInfo({ items }: { items: Array<{ name: string; isHealthy: boolean; isLoading: boolean }> }) { + return ( + + { + items.map(({ name, isHealthy, isLoading }, index) => { + return ( + + {name} + { + isLoading ? : + + {isHealthy ? 'Healthy' : 'Unhealthy'} + + } + + ); + }) + } + + ) +} + + +export default function StatusBar() { + + const [tooltipHovered, setTooltipHovered] = React.useState(false); + const [tooltipPinned, setTooltipPinned] = React.useState(false); + const tooltipOpen = tooltipHovered || tooltipPinned + + const healthItems = healthChecks.map(cfg => ({ name: cfg.name, ...useManagerHealthCheck(cfg.key, cfg.queryFn, cfg.name) })); + const anyUnhealthy = healthItems.some(i => !i.isLoading && !i.isHealthy); + + return ( + { if (tooltipPinned) setTooltipPinned(false) }}> + } + open={tooltipOpen} + modifiers={[ + { name: 'preventOverflow', options: { padding: 10 } }, // skidding=8 (down), distance=20 (further right) + ]} + > + setTooltipPinned(prev => !prev)} + onMouseEnter={() => setTooltipHovered(true)} + onMouseLeave={() => setTooltipHovered(false)} + > + { + anyUnhealthy ? : + + } + + + + ) +} diff --git a/scanhub-ui/src/components/Navigation.tsx b/scanhub-ui/src/components/Navigation.tsx index ea30ddc6..180662d0 100644 --- a/scanhub-ui/src/components/Navigation.tsx +++ b/scanhub-ui/src/components/Navigation.tsx @@ -9,17 +9,16 @@ import { useQueryClient } from '@tanstack/react-query' import { Link as RouterLink, useLocation, useNavigate } from 'react-router-dom' import AdminPanelSettingsSharpIcon from '@mui/icons-material/AdminPanelSettingsSharp' -import ListAltIcon from '@mui/icons-material/ListAlt' -import TripOriginRoundedIcon from '@mui/icons-material/TripOriginRounded'; -import LineStyleIcon from '@mui/icons-material/LineStyle' +import AccountCircleIcon from '@mui/icons-material/AccountCircle'; +import TaskAltIcon from '@mui/icons-material/TaskAlt'; +import DesignServicesIcon from '@mui/icons-material/DesignServices'; +import ManageAccountsIcon from '@mui/icons-material/ManageAccounts'; +import Divider from '@mui/joy/Divider'; import DarkModeRoundedIcon from '@mui/icons-material/DarkModeRounded' import LightModeRoundedIcon from '@mui/icons-material/LightModeRounded' import BrightnessAutoIcon from '@mui/icons-material/BrightnessAuto'; import LogoutSharpIcon from '@mui/icons-material/LogoutSharp' -import Person2SharpIcon from '@mui/icons-material/Person2Sharp' import PersonSharpIcon from '@mui/icons-material/PersonSharp' -import RecentActorsSharpIcon from '@mui/icons-material/RecentActorsSharp' -import Avatar from '@mui/joy/Avatar' import Box from '@mui/joy/Box' import Chip from '@mui/joy/Chip' import Button from '@mui/joy/Button' @@ -36,9 +35,11 @@ import { useColorScheme as useMaterialColorScheme } from '@mui/material/styles' import LoginContext from '../LoginContext' import { UserRole } from '../openapi/generated-client/userlogin' -import ScanhubLogo from '../media/ScanhubLogo.png' +import logo from '../media/logo.png' import { loginApi } from '../api' import { version } from '../utils/Versions' +import ManagerStatus from './ManagerStatus' +import DeviceStatus from './DeviceStatus' import PasswordModal from './PasswordModal' @@ -46,13 +47,13 @@ function ColorSchemeToggle() { const { mode, setMode } = useColorScheme() const { setMode: setMuiMode } = useMaterialColorScheme() - let modeicon = + let modeicon = if (mode === 'light') { - modeicon = + modeicon = } else if (mode === 'dark') { - modeicon = + modeicon = } - + return ( }, - { id: 1, text: 'Templates', link: '/templates', icon: }, - { id: 2, text: 'Devices', link: '/devices', icon: }, - { id: 3, text: 'Sequences', link: '/sequences', icon: }, + // { id: 0, text: 'Patients', link: '/', icon: }, + { id: 0, text: 'Patients', link: '/', icon: }, + { id: 1, text: 'Library', link: '/library', icon: }, + // { id: 1, text: 'Templates', link: '/templates', icon: }, + // { id: 2, text: 'Devices', link: '/devices', icon: }, + // { id: 3, text: 'Sequences', link: '/sequences', icon: }, ] if (user && user.role == UserRole.Admin) { - menuItems.push({ id: 4, text: 'Users', link: '/users', icon: }) + menuItems.push({ id: 4, text: 'Users', link: '/users', icon: }) } return ( @@ -117,17 +120,17 @@ export default function Navigation() { }} > - + - + ScanHub @@ -144,30 +147,36 @@ export default function Navigation() { key={item.id} sx={{ display: 'inline-flex' }} > - {item.text} + {item.text} ))} - + + + + - {'v' + version + ' not for clinical use!'} + {'v' + version + ' not for clinical use!'} {/* User menu */} - + {/* */} + {user?.username} @@ -196,7 +205,7 @@ export default function Navigation() { }} > - + {' '} Profile @@ -207,7 +216,7 @@ export default function Navigation() { }} > - + {' '} Set Password @@ -226,16 +235,16 @@ export default function Navigation() { }} > - + {' '} Logout - {}} - isOpen={passwordModalOpen} + { }} + isOpen={passwordModalOpen} setOpen={setPasswordModalOpen} modalType={'modify'} item={user?.username ? user.username : ''} diff --git a/scanhub-ui/src/components/StatusBar.tsx b/scanhub-ui/src/components/StatusBar.tsx deleted file mode 100644 index 95340ac6..00000000 --- a/scanhub-ui/src/components/StatusBar.tsx +++ /dev/null @@ -1,239 +0,0 @@ -/** - * Copyright (C) 2024, BRAIN-LINK UG (haftungsbeschränkt). All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-ScanHub-Commercial - * - * Navigation.tsx is responsible for rendering the navigation bar at the top of the page. - */ -import React from 'react' -import { useQuery, useQueryClient } from '@tanstack/react-query' - -import Box from '@mui/joy/Box' -import Chip from '@mui/joy/Chip' -import LinkIcon from '@mui/icons-material/Link'; -import IconButton from '@mui/joy/IconButton' -import Select from '@mui/joy/Select'; -import Option from '@mui/joy/Option'; -import Typography from '@mui/joy/Typography' -import Tooltip from '@mui/joy/Tooltip' -import { ClickAwayListener } from '@mui/base'; - -import { deviceApi } from '../api' -import { DeviceOut } from '../openapi/generated-client/device/api' -import Sheet from '@mui/joy/Sheet' -import { useManagerHealthCheck } from '../hooks/useManagerHealthCheck' -import { - patientManagerHealthApi, - examManagerHealthApi, - workflowManagerHealthApi, - userLoginManagerHealthApi, - deviceManagerHealthApi -} from '../api' -import CircularProgress from '@mui/joy/CircularProgress' -import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'; - - - -// Device all health checks -const healthChecks = [ - { - name: 'Patient Manager', - key: 'patientManagerHealthCheck', - queryFn: () => patientManagerHealthApi.readinessApiV1PatientHealthReadinessGet({ timeout: 1000 }).then(r => r.data), - }, - { - name: 'Exam Manager', - key: 'examManagerHealthCheck', - queryFn: () => examManagerHealthApi.readinessApiV1ExamHealthReadinessGet({ timeout: 1000 }).then(r => r.data), - }, - { - name: 'Workflow Manager', - key: 'workflowManagerHealthCheck', - queryFn: () => workflowManagerHealthApi.readinessApiV1WorkflowmanagerHealthReadinessGet({ timeout: 1000 }).then(r => r.data), - }, - { - name: 'User Login Manager', - key: 'userLoginManagerHealthCheck', - queryFn: () => userLoginManagerHealthApi.readinessApiV1UserloginHealthReadinessGet({ timeout: 1000 }).then(r => r.data), - }, - { - name: 'Device Manager', - key: 'deviceManagerHealth', - queryFn: () => deviceManagerHealthApi.readinessApiV1DeviceHealthReadinessGet({ timeout: 1000 }).then(r => r.data), - } -] - -function ManagerStatus({ items }: { items: Array<{ name: string; isHealthy: boolean; isLoading: boolean }>}) { - return ( - - { - items.map(({ name, isHealthy, isLoading }, index) => { - return ( - - {name} - { - isLoading ? : - - {isHealthy ? 'Healthy' : 'Unhealthy'} - - } - - ); - }) - } - - ) -} - - -export default function StatusBar() { - - const queryClient = useQueryClient() - - const [tooltipHovered, setTooltipHovered] = React.useState(false); - const [tooltipPinned, setTooltipPinned] = React.useState(false); - const tooltipOpen = tooltipHovered || tooltipPinned - - const healthItems = healthChecks.map(cfg => ({ name: cfg.name, ...useManagerHealthCheck(cfg.key, cfg.queryFn, cfg.name) })); - const anyUnhealthy = healthItems.some(i => !i.isLoading && !i.isHealthy); - - const selectedDeviceId = queryClient.getQueryData(['selectedDeviceId']) - const setSelectedDeviceId = (id: string | null) => queryClient.setQueryData(['selectedDeviceId'], id) - - // Get list of all devices - const { data: devices } = useQuery({ - queryKey: ['devices'], - queryFn: async () => { - const result = await deviceApi.getDevicesApiV1DeviceGet() - if (selectedDeviceId === null && result.data.length > 0) { - setSelectedDeviceId(result.data[0].id) - } - return result.data - }, - }) - - // Poll only the selected device, but more frequently to get updated device status - const { data: deviceStatus, refetch } = useQuery({ - queryKey: ['deviceStatus', 'selectedDeviceId'], // depends on selected device - - queryFn: async () => { - if (!selectedDeviceId) throw new Error('No device selected') - // Call your API to get this device’s latest status - const result = await deviceApi.getDeviceApiV1DeviceDeviceIdGet(selectedDeviceId) - return result.data.status ? result.data.status as string : undefined - }, - enabled: !!selectedDeviceId, // only run when a device is selected - refetchInterval: 1000, // poll every second - refetchIntervalInBackground: true, - }) - - return ( - - { - devices && devices?.length > 0 && - } - - { if (tooltipPinned) setTooltipPinned(false) }}> - } - open={tooltipOpen} - modifiers={[ - { name: 'preventOverflow', options: { padding: 10 } }, // skidding=8 (down), distance=20 (further right) - ]} - > - setTooltipPinned(prev => !prev)} - onMouseEnter={() => setTooltipHovered(true)} - onMouseLeave={() => setTooltipHovered(false)} - > - { - anyUnhealthy ? : - - } - - - - - ) -} diff --git a/scanhub-ui/src/components/TaskInfo.tsx b/scanhub-ui/src/components/TaskInfo.tsx index 663ba6be..599f71c1 100644 --- a/scanhub-ui/src/components/TaskInfo.tsx +++ b/scanhub-ui/src/components/TaskInfo.tsx @@ -10,7 +10,7 @@ import Box from '@mui/joy/Box' import Stack from '@mui/joy/Stack' import Typography from '@mui/joy/Typography' -import { AcquisitionTaskOut, DAGTaskOut, TaskType } from '../openapi/generated-client/exam' +import { AcquisitionTaskOut, DAGTaskOut, TaskType, CalibrationType } from '../openapi/generated-client/exam' function capitalize(str: string){ @@ -25,6 +25,19 @@ function TaskInfo({ data: task }: { data: AcquisitionTaskOut | DAGTaskOut }) { const datetime_created = new Date(task.datetime_created) const datetime_updated = task.datetime_updated ? new Date(String(task.datetime_updated)) : undefined + const getCalibrationSequenceString = (sequence: CalibrationType[]) => { + // Mapping each enum value to its display string + const labels = (sequence ?? []).map((item) => { + if (item === CalibrationType.Frequency) return 'Freq.'; + if (item === CalibrationType.FlipAngle) return 'Power'; + if (item === CalibrationType.Shims) return 'Shims'; + return item; + }); + + // Join them with a comma and a space + return labels.join(' > '); + } + return ( } + { + task.task_type === TaskType.Acquisition && 'calibration' in task && task.calibration && + <> + Calibration + + { getCalibrationSequenceString(task.calibration) } + + + } + { task.task_type === TaskType.Acquisition && 'acquisition_parameter' in task && task.acquisition_parameter && <> diff --git a/scanhub-ui/src/components/TaskModal.tsx b/scanhub-ui/src/components/TaskModal.tsx index 5d808573..6a4c5de2 100644 --- a/scanhub-ui/src/components/TaskModal.tsx +++ b/scanhub-ui/src/components/TaskModal.tsx @@ -21,12 +21,17 @@ import Stack from '@mui/joy/Stack' import Grid from '@mui/joy/Grid' import Textarea from '@mui/joy/Textarea' import Typography from '@mui/joy/Typography' +import Sheet from '@mui/joy/Sheet'; +import Chip from '@mui/joy/Chip'; +import ChipDelete from '@mui/joy/ChipDelete'; +// Icons +import AddIcon from '@mui/icons-material/Add'; import React from 'react' import { useMutation, useQuery } from '@tanstack/react-query' import TaskInfo from './TaskInfo'; import { deviceApi, sequenceApi, taskApi, workflowManagerApi } from '../api' -import { +import { MRISequenceOut, BaseAcquisitionTask, AcquisitionTaskOut, @@ -34,7 +39,8 @@ import { ItemStatus, AcquisitionParameter, DAGTaskOut, - BaseDAGTask + BaseDAGTask, + CalibrationType } from '../openapi/generated-client/exam' import { DeviceOut } from '../openapi/generated-client/device/api' @@ -42,8 +48,7 @@ import { ModalPropsCreate, ModalPropsModify } from '../interfaces/components.int import NotificationContext from '../NotificationContext' -function AcquisitionTaskForm(props: ModalPropsCreate | ModalPropsModify) -{ +function AcquisitionTaskForm(props: ModalPropsCreate | ModalPropsModify) { // The form is in this separate component to make sure that the state is reset after closing the modal const [, showNotification] = React.useContext(NotificationContext) @@ -52,45 +57,46 @@ function AcquisitionTaskForm(props: ModalPropsCreate | ModalPropsModify { await taskApi.updateTaskApiV1ExamTaskTaskIdPut(props.item.id, task) - .then(() => { - props.onSubmit() - showNotification({message: 'Updated acquisition task.', type: 'success'}) - }) + .then(() => { + props.onSubmit() + showNotification({ message: 'Updated acquisition task.', type: 'success' }) + }) } }) - : + : useMutation({ mutationFn: async () => { await taskApi.createTaskApiV1ExamTaskNewPost(task) - .then(() => { - console.log('Created task', task) - props.onSubmit() - showNotification({message: 'Created acquisition ask.', type: 'success'}) - }) + .then(() => { + console.log('Created task', task) + props.onSubmit() + showNotification({ message: 'Created acquisition ask.', type: 'success' }) + }) } }) @@ -127,42 +133,76 @@ function AcquisitionTaskForm(props: ModalPropsCreate | ModalPropsModify { + setTask((prev) => ({ + ...prev, + calibration: [...(prev.calibration ?? []), type], + })); + }; + + // Helper: Remove item by index + const removeCalibration = (index: number) => { + setTask((prev) => ({ ...prev, calibration: (prev.calibration ?? []).filter((_, i) => i !== index) })); + }; + + // Helper: Handle Drag & Drop Reordering + const handleDragStart = (e: React.DragEvent, index: number) => { + e.dataTransfer.setData('draggedIndex', index.toString()); + }; + + const getCalibrationName = (type: CalibrationType) => { + if (type === CalibrationType.FlipAngle) { return 'Power calibration' } + if (type === CalibrationType.Frequency) { return 'Frequency calibration' } + if (type === CalibrationType.Shims) { return 'Shim calibration' } + }; + + const handleDrop = (e: React.DragEvent, targetIndex: number) => { + const draggedIndex = parseInt(e.dataTransfer.getData('draggedIndex')); + if (draggedIndex === targetIndex) return; + + const newCalibration = [...(task.calibration ?? [])]; + const [draggedItem] = newCalibration.splice(draggedIndex, 1); + newCalibration.splice(targetIndex, 0, draggedItem); + + setTask({ ...task, calibration: newCalibration }); + }; + + const handleDragOver = (e: React.DragEvent) => e.preventDefault(); + + + return ( <> {title} - - - - Name - setTask({ ...task, [e.target.name]: e.target.value })} - value={task.name} - /> - - - - Description -