From 5dc1248f73d41962a8beb3ac65e93a23fb974a7f Mon Sep 17 00:00:00 2001 From: Jennings Zhang Date: Sun, 15 Sep 2024 21:20:32 -0400 Subject: [PATCH 01/41] Add LonkClient --- package-lock.json | 27 ++++++- package.json | 3 +- src/api/fp/chrisapi.ts | 84 ++++++++++----------- src/api/fp/fpFileBrowserFile.ts | 65 ---------------- src/api/fp/helpers.test.ts | 41 ++++++++++ src/api/lonk/client.test.ts | 106 ++++++++++++++++++++++++++ src/api/lonk/client.ts | 129 ++++++++++++++++++++++++++++++++ src/api/lonk/de.ts | 53 +++++++++++++ src/api/lonk/index.ts | 11 +++ src/api/lonk/seriesMap.test.ts | 11 +++ src/api/lonk/seriesMap.ts | 34 +++++++++ src/api/lonk/types.ts | 92 +++++++++++++++++++++++ 12 files changed, 544 insertions(+), 112 deletions(-) delete mode 100644 src/api/fp/fpFileBrowserFile.ts create mode 100644 src/api/fp/helpers.test.ts create mode 100644 src/api/lonk/client.test.ts create mode 100644 src/api/lonk/client.ts create mode 100644 src/api/lonk/de.ts create mode 100644 src/api/lonk/index.ts create mode 100644 src/api/lonk/seriesMap.test.ts create mode 100644 src/api/lonk/seriesMap.ts create mode 100644 src/api/lonk/types.ts diff --git a/package-lock.json b/package-lock.json index a080cbab4..5db811a7b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -94,7 +94,8 @@ "rollup-plugin-node-builtins": "^2.1.2", "vite": "^5.3.2", "vite-plugin-istanbul": "^6.0.2", - "vitest": "^2.0.5" + "vitest": "^2.0.5", + "vitest-websocket-mock": "^0.4.0" } }, "node_modules/@ampproject/remapping": { @@ -6469,6 +6470,16 @@ "integrity": "sha512-r6lj77KlwqLhIUku9UWYes7KJtsczvolZkzp8hbaDPPaE24OmWl5s539Mytlj22siEQKosZ26qCBgda2PKwoJw==", "license": "MIT" }, + "node_modules/mock-socket": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/mock-socket/-/mock-socket-9.3.1.tgz", + "integrity": "sha512-qxBgB7Qa2sEQgHFjj0dSigq7fX4k6Saisd5Nelwp2q8mlbAFh5dHV9JTTlF8viYJLSSWgMCZFUom8PJcMNBoJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, "node_modules/mri": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", @@ -9875,6 +9886,20 @@ } } }, + "node_modules/vitest-websocket-mock": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/vitest-websocket-mock/-/vitest-websocket-mock-0.4.0.tgz", + "integrity": "sha512-tGnOwE2nC8jfioQXDrX+lZ8EVrF+IO2NVqe1vV9h945W/hlR0S6ZYbMqCJGG3Nyd//c5XSe1IGLD2ZgE2D1I7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "^2.0.3", + "mock-socket": "^9.2.1" + }, + "peerDependencies": { + "vitest": ">=2" + } + }, "node_modules/warning": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", diff --git a/package.json b/package.json index 7cf98cf7e..87c6be441 100644 --- a/package.json +++ b/package.json @@ -124,7 +124,8 @@ "rollup-plugin-node-builtins": "^2.1.2", "vite": "^5.3.2", "vite-plugin-istanbul": "^6.0.2", - "vitest": "^2.0.5" + "vitest": "^2.0.5", + "vitest-websocket-mock": "^0.4.0" }, "type": "module" } diff --git a/src/api/fp/chrisapi.ts b/src/api/fp/chrisapi.ts index df3ad7fa0..21c7fc1df 100644 --- a/src/api/fp/chrisapi.ts +++ b/src/api/fp/chrisapi.ts @@ -1,17 +1,15 @@ import Client, { AllPluginInstanceList, + DownloadToken, Feed, FeedPluginInstanceList, - FileBrowserPath, - FileBrowserPathFileList, PluginInstance, PublicFeedList, } from "@fnndsc/chrisapi"; import * as TE from "fp-ts/TaskEither"; import * as E from "fp-ts/Either"; -import * as Console from "fp-ts/Console"; import { pipe } from "fp-ts/function"; -import FpFileBrowserFile from "./fpFileBrowserFile"; +import LonkClient, { LonkHandlers } from "../lonk"; /** * fp-ts friendly wrapper for @fnndsc/chrisapi @@ -27,7 +25,7 @@ class FpClient { ...params: Parameters ): TE.TaskEither { return TE.tryCatch( - () => this.client.getPluginInstance(...params), + () => this.client.getPluginInstance(...params).then(notNull), E.toError, ); } @@ -48,6 +46,7 @@ class FpClient { }, }; if (this.client.auth.token) { + // @ts-ignore options.headers.Authorization = `Token ${this.client.auth.token}`; } return pipe( @@ -115,55 +114,50 @@ class FpClient { return TE.tryCatch(() => feed.getPluginInstances(...params), E.toError); } + public createDownloadToken( + ...params: Parameters + ): TE.TaskEither { + return TE.tryCatch( + () => this.client.createDownloadToken(...params).then(notNull), + E.toError, + ); + } + /** - * A wrapper which calles `getFileBrowserPath` then `getFiles`, - * and processes the returned objects to have a more sane type. - * - * Pretty much gives you back what CUBE would return from - * `api/v1/filebrowser-files/.../` with HTTP header `Accept: application/json` + * Create a WebSockets connection to the LONK-WS endpoint. * - * Pagination is not implemented, hence the name "get **few** files under"... + * https://chrisproject.org/docs/oxidicom/lonk-ws */ - public getFewFilesUnder( - ...args: Parameters - ): TE.TaskEither> { + public connectPacsNotifications({ + onDone, + onProgress, + onError, + timeout, + }: LonkHandlers & { timeout?: number }): TE.TaskEither { return pipe( - this.getFileBrowserPath(...args), - TE.flatMap((fb) => FpClient.filebrowserGetFiles(fb, { limit: 100 })), - TE.tapIO((list) => { - if (list.hasNextPage) { - return Console.warn( - `Not all elements from ${list.url} were fetched, ` + - "and pagination not implemented.", - ); - } - return () => undefined; + this.createDownloadToken(timeout), + TE.map((downloadToken) => { + const url = getWebsocketUrl(downloadToken); + const ws = new WebSocket(url); + return new LonkClient({ ws, onDone, onProgress, onError }); }), - TE.map(saneReturnOfFileBrowserPathFileList), - ); - } - - public getFileBrowserPath( - ...args: Parameters - ): TE.TaskEither { - return TE.tryCatch( - () => this.client.getFileBrowserPath(...args), - E.toError, ); } +} - public static filebrowserGetFiles( - fbp: FileBrowserPath, - ...params: Parameters - ): TE.TaskEither { - return TE.tryCatch(() => fbp.getFiles(...params), E.toError); - } +function getWebsocketUrl(downloadTokenResponse: DownloadToken): string { + const token = downloadTokenResponse.data.token; + return downloadTokenResponse.url + .replace(/^http(s?):\/\//, (_match, s) => `ws${s}://`) + .replace(/v1\/downloadtokens\/\d+\//, `v1/pacs/progress/?token=${token}`); } -function saneReturnOfFileBrowserPathFileList( - fbpfl: FileBrowserPathFileList, -): ReadonlyArray { - return fbpfl.getItems()!.map((file) => new FpFileBrowserFile(file)); +function notNull(x: T | null): T { + if (x === null) { + throw Error(); + } + return x; } -export { FpClient, saneReturnOfFileBrowserPathFileList, FpFileBrowserFile }; +export default FpClient; +export { FpClient, getWebsocketUrl }; diff --git a/src/api/fp/fpFileBrowserFile.ts b/src/api/fp/fpFileBrowserFile.ts deleted file mode 100644 index 856517bcd..000000000 --- a/src/api/fp/fpFileBrowserFile.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { Collection, FileBrowserPathFile } from "@fnndsc/chrisapi"; -import * as TE from "fp-ts/TaskEither"; -import * as E from "fp-ts/Either"; -import { pipe } from "fp-ts/function"; - -/** - * A type-safe wrapper for `FileBrowserPathFile` which also handles - * the annoyances of Collection+JSON. - */ -class FpFileBrowserFile { - private readonly file: FileBrowserPathFile; - - constructor(file: FileBrowserPathFile) { - this.file = file; - } - - get creation_date(): string { - return this.file.data.creation_date; - } - - get fname(): string { - return this.file.data.fname; - } - - get fsize(): number { - return this.file.data.fsize; - } - - get file_resource() { - return Collection.getLinkRelationUrls( - this.file.collection.items[0], - "file_resource", - )[0]; - } - - get url() { - return Collection.getLinkRelationUrls( - this.file.collection.items[0], - "url", - )[0]; - } - - getFileBlob( - ...args: Parameters - ): TE.TaskEither { - return TE.tryCatch(() => this.file.getFileBlob(...args), E.toError); - } - - /** - * Get the data of this file, assuming it is UTF-8 plaintext. - */ - getAsText( - ...args: Parameters - ): TE.TaskEither { - return pipe( - this.getFileBlob(...args), - TE.flatMap((blob) => { - const task = () => blob.text(); - return TE.rightTask(task); - }), - ); - } -} - -export default FpFileBrowserFile; diff --git a/src/api/fp/helpers.test.ts b/src/api/fp/helpers.test.ts new file mode 100644 index 000000000..c3f662c0a --- /dev/null +++ b/src/api/fp/helpers.test.ts @@ -0,0 +1,41 @@ +import { test, expect } from "vitest"; +import { getWebsocketUrl } from "./chrisapi.ts"; + +test.each([ + [ + { + url: "http://example.com/api/v1/downloadtokens/9/", + auth: { + token: "fakeauthtoken", + }, + contentType: "application/vnd.collection+json", + data: { + id: 9, + creation_date: "2024-08-27T17:17:28.580683-04:00", + token: "nota.real.jwttoken", + owner_username: "chris", + }, + }, + "ws://example.com/api/v1/pacs/progress/?token=nota.real.jwttoken", + ], + [ + { + url: "https://example.com/api/v1/downloadtokens/9/", + auth: { + token: "fakeauthtoken", + }, + contentType: "application/vnd.collection+json", + data: { + id: 9, + creation_date: "2024-08-27T17:17:28.580683-04:00", + token: "stillnota.real.jwttoken", + owner_username: "chris", + }, + }, + "wss://example.com/api/v1/pacs/progress/?token=stillnota.real.jwttoken", + ], +])("getWebsocketUrl(%o, %s) -> %s", (downloadTokenResponse, expected) => { + // @ts-ignore + let actual = getWebsocketUrl(downloadTokenResponse); + expect(actual).toBe(expected); +}); diff --git a/src/api/lonk/client.test.ts b/src/api/lonk/client.test.ts new file mode 100644 index 000000000..ec6d828b2 --- /dev/null +++ b/src/api/lonk/client.test.ts @@ -0,0 +1,106 @@ +import { test, expect, vi } from "vitest"; +import LonkClient, { LonkHandlers } from "./client.ts"; +import WS from "vitest-websocket-mock"; + +test("LonkClient", async () => { + const handlers: LonkHandlers = { + onDone: vi.fn(), + onProgress: vi.fn(), + onError: vi.fn(), + }; + const [server, client] = await createMockubeWs(handlers); + const SeriesInstanceUID = "1.234.56789"; + const pacs_name = "MyPACS"; + + const subscriptionReceiveAndRespond = async () => { + await expect(server).toReceiveMessage({ + pacs_name, + SeriesInstanceUID, + action: "subscribe", + }); + server.send({ + pacs_name, + SeriesInstanceUID, + message: { subscribed: true }, + }); + }; + + await Promise.all([ + subscriptionReceiveAndRespond(), + client.subscribe(pacs_name, SeriesInstanceUID), + ]); + + server.send({ + pacs_name, + SeriesInstanceUID, + message: { + ndicom: 48, + }, + }); + expect(handlers.onProgress).toHaveBeenCalledOnce(); + expect(handlers.onProgress).toHaveBeenLastCalledWith( + pacs_name, + SeriesInstanceUID, + 48, + ); + + server.send({ + pacs_name, + SeriesInstanceUID, + message: { + ndicom: 88, + }, + }); + expect(handlers.onProgress).toHaveBeenCalledTimes(2); + expect(handlers.onProgress).toHaveBeenLastCalledWith( + pacs_name, + SeriesInstanceUID, + 88, + ); + + server.send({ + pacs_name, + SeriesInstanceUID, + message: { + error: "stuck in chimney", + }, + }); + expect(handlers.onError).toHaveBeenCalledOnce(); + expect(handlers.onError).toHaveBeenLastCalledWith( + pacs_name, + SeriesInstanceUID, + "stuck in chimney", + ); + + server.send({ + pacs_name, + SeriesInstanceUID, + message: { + done: true, + }, + }); + expect(handlers.onDone).toHaveBeenCalledOnce(); + expect(handlers.onDone).toHaveBeenLastCalledWith( + pacs_name, + SeriesInstanceUID, + ); +}); + +/** + * Create a mock WebSockets server and client. + */ +async function createMockubeWs( + handlers: LonkHandlers, +): Promise<[WS, LonkClient]> { + const url = "ws://localhost:32585"; + const server = new WS(url, { jsonProtocol: true }); + const ws = new WebSocket(url); + const client = new LonkClient({ ws, ...handlers }); + + let callback: null | (([server, client]: [WS, LonkClient]) => void) = null; + const promise: Promise<[WS, LonkClient]> = new Promise((resolve) => { + callback = resolve; + }); + ws.onopen = () => callback && callback([server, client]); + return promise; +} diff --git a/src/api/lonk/client.ts b/src/api/lonk/client.ts new file mode 100644 index 000000000..1c5a4f0ba --- /dev/null +++ b/src/api/lonk/client.ts @@ -0,0 +1,129 @@ +import { + Lonk, + LonkDone, + LonkError, + LonkHandlers, + LonkProgress, + LonkSubscription, + SeriesKey, +} from "./types.ts"; +import deserialize from "./de.ts"; +import { pipe } from "fp-ts/function"; +import * as E from "fp-ts/Either"; +import * as T from "fp-ts/Task"; +import SeriesMap from "./seriesMap.ts"; + +/** + * `LonkClient` wraps a {@link WebSocket}, routing incoming JSON messages + * to corresponding functions of {@link LonkHandlers}. + */ +class LonkClient { + private readonly ws: WebSocket; + private readonly pendingSubscriptions: SeriesMap SeriesKey)>; + + public constructor({ + ws, + onDone, + onProgress, + onError, + }: LonkHandlers & { ws: WebSocket }) { + this.pendingSubscriptions = new SeriesMap(); + this.ws = ws; + this.ws.onmessage = (msg) => { + pipe( + msg.data, + deserialize, + E.map((data) => + this.routeMessage({ data, onDone, onProgress, onError }), + ), + ); + }; + } + + /** + * Subscribe to notifications for a series. + */ + public subscribe( + pacs_name: string, + SeriesInstanceUID: string, + ): Promise { + let callback = null; + const callbackTask: Promise = new Promise((resolve) => { + callback = () => resolve({ SeriesInstanceUID, pacs_name }); + }); + this.pendingSubscriptions.set(pacs_name, SeriesInstanceUID, callback); + const data = { + SeriesInstanceUID, + pacs_name, + action: "subscribe", + }; + this.ws.send(JSON.stringify(data)); + return callbackTask; + } + + private routeMessage({ + data, + onDone, + onProgress, + onError, + }: LonkHandlers & { data: Lonk }) { + const { SeriesInstanceUID, pacs_name, message } = data; + // note: for performance reasons, this if-else chain is in + // descending order of case frequency. + if (isProgress(message)) { + onProgress(pacs_name, SeriesInstanceUID, message.ndicom); + } else if (isDone(message)) { + onDone(pacs_name, SeriesInstanceUID); + } else if (isSubscribed(message)) { + this.handleSubscriptionSuccess(pacs_name, SeriesInstanceUID); + } else if (isError(message)) { + onError(pacs_name, SeriesInstanceUID, message.error); + } else { + console.warn(`Unrecognized message: ${JSON.stringify(message)}`); + } + } + + private handleSubscriptionSuccess( + pacs_name: string, + SeriesInstanceUID: string, + ) { + const callback = this.pendingSubscriptions.pop( + pacs_name, + SeriesInstanceUID, + ); + if (callback === null) { + console.warn( + "Got subscription confirmation, but never requested subscription", + { pacs_name, SeriesInstanceUID }, + ); + } else { + callback(); + } + } + + /** + * Close the websocket. + */ + public close() { + this.ws.close(); + } +} + +function isSubscribed(msg: { [key: string]: any }): msg is LonkSubscription { + return "subscribed" in msg && msg.subscribed === true; +} + +function isDone(msg: { [key: string]: any }): msg is LonkDone { + return "done" in msg && msg.done === true; +} + +function isProgress(msg: { [key: string]: any }): msg is LonkProgress { + return "ndicom" in msg && Number.isInteger(msg.ndicom); +} + +function isError(msg: { [key: string]: any }): msg is LonkError { + return "error" in msg; +} + +export default LonkClient; +export type { LonkHandlers }; diff --git a/src/api/lonk/de.ts b/src/api/lonk/de.ts new file mode 100644 index 000000000..eeecb8427 --- /dev/null +++ b/src/api/lonk/de.ts @@ -0,0 +1,53 @@ +/** + * LONK data deserialization using fp-ts. + */ + +import * as E from "fp-ts/Either"; +import { Lonk } from "./types.ts"; +import { pipe } from "fp-ts/function"; +import * as J from "fp-ts/Json"; + +function deserialize(data: any): E.Either> { + return pipe( + data, + J.parse, + E.mapLeft(() => "Could not parse message as JSON"), + E.flatMap(validateRecord), + E.flatMap(validateLonk), + ); +} + +function validateLonk(obj: J.JsonRecord): E.Either> { + if (typeof obj.pacs_name !== "string") { + return E.left(`Missing or invalid 'pacs_name' in ${JSON.stringify(obj)}`); + } + if (typeof obj.SeriesInstanceUID !== "string") { + return E.left( + `Missing or invalid 'SeriesInstanceUID' in ${JSON.stringify(obj)}`, + ); + } + if (typeof obj.message !== "object" || jIsArray(obj)) { + return E.left(`Missing or invalid 'message' in ${JSON.stringify(obj)}`); + } + // @ts-ignore proper JSON deserialization is too tedious in TypeScript + return E.right(obj); +} + +function validateRecord(obj: J.Json): E.Either { + if (obj === null) { + return E.left("obj is null"); + } + if (typeof obj !== "object") { + return E.left("not an object"); + } + if (jIsArray(obj)) { + return E.left("is an array, expected a JsonRecord"); + } + return E.right(obj); +} + +function jIsArray(obj: J.JsonArray | any): obj is J.JsonArray { + return Array.isArray(obj); +} + +export default deserialize; diff --git a/src/api/lonk/index.ts b/src/api/lonk/index.ts new file mode 100644 index 000000000..cb3914749 --- /dev/null +++ b/src/api/lonk/index.ts @@ -0,0 +1,11 @@ +/** + * "Light Oxidicom NotifiKations" over WebSockets (LONK-WS) client. + * + * https://chrisproject.org/docs/oxidicom/lonk-ws + */ + +import LonkClient from "./client.ts"; +import { LonkHandlers } from "./types.ts"; + +export default LonkClient; +export type { LonkHandlers }; diff --git a/src/api/lonk/seriesMap.test.ts b/src/api/lonk/seriesMap.test.ts new file mode 100644 index 000000000..5e276bca4 --- /dev/null +++ b/src/api/lonk/seriesMap.test.ts @@ -0,0 +1,11 @@ +import { test, expect } from "vitest"; +import SeriesMap from "./seriesMap.ts"; + +test("SeriesMap", () => { + const map = new SeriesMap(); + expect(map.pop("MyPACS", "12345")).toBeNull(); + const data = {}; + map.set("MyPACS", "12345", data); + expect(map.pop("MyPACS", "12345")).toBe(data); + expect(map.pop("MyPACS", "12345")).toBeNull(); +}); diff --git a/src/api/lonk/seriesMap.ts b/src/api/lonk/seriesMap.ts new file mode 100644 index 000000000..cfc3ed7d8 --- /dev/null +++ b/src/api/lonk/seriesMap.ts @@ -0,0 +1,34 @@ +/** + * A wrapper around {@link Map} where the key is (pacs_name, SeriesInstanceUID). + */ +class SeriesMap { + private readonly map: Map; + + public constructor() { + this.map = new Map(); + } + + /** + * Set a value for a DICOM series. + */ + public set(pacs_name: string, SeriesInstanceUID: string, value: T) { + const key = this.keyOf(SeriesInstanceUID, pacs_name); + this.map.set(key, value); + } + + /** + * Get and remove a value for a DICOM series. + */ + public pop(pacs_name: string, SeriesInstanceUID: string): T | null { + const key = this.keyOf(SeriesInstanceUID, pacs_name); + const value = this.map.get(key); + this.map.delete(key); + return value || null; + } + + private keyOf(pacs_name: string, SeriesInstanceUID: string): string { + return JSON.stringify({ SeriesInstanceUID, pacs_name }); + } +} + +export default SeriesMap; diff --git a/src/api/lonk/types.ts b/src/api/lonk/types.ts new file mode 100644 index 000000000..5aaeb666e --- /dev/null +++ b/src/api/lonk/types.ts @@ -0,0 +1,92 @@ +/** + * LONK-WS JSON types. + * + * Documentation: https://chrisproject.org/docs/oxidicom/lonk-ws#messages + * + * Reference implementation: + * https://github.com/FNNDSC/ChRIS_ultron_backEnd/blob/cf95993886c22530190c23807b57d525f9d51f99/chris_backend/pacsfiles/lonk.py#L45-L102 + */ + +/** + * The metadata which uniquely identifies a DICOM series. + */ +type SeriesKey = { + SeriesInstanceUID: string; + pacs_name: string; +}; + +/** + * LONK "progress" message. + */ +type LonkProgress = { + /** + * Number of DICOM files stored by *oxidicom* so far. + */ + ndicom: number; +}; + +/** + * LONK "error" message. + */ +type LonkError = { + /** + * Error message originating from *oxidicom*. + */ + error: string; +}; + +/** + * LONK "done" message. + */ +type LonkDone = { + done: true; +}; + +/** + * LONK-WS "subscription" message. + */ +type LonkSubscription = { + subscribed: true; +}; + +/** + * Oxidicom notification message data. + */ +type LonkMessageData = LonkDone | LonkProgress | LonkError | LonkSubscription; + +/** + * Notification from oxidicom about a DICOM series. + */ +type Lonk = { + SeriesInstanceUID: string; + pacs_name: string; + message: T; +}; + +/** + * Handler functions for the various types of LONK protocol messages. + */ +type LonkHandlers = { + onDone: (pacs_name: string, SeriesInstanceUID: string) => void; + onProgress: ( + pacs_name: string, + SeriesInstanceUID: string, + ndicom: number, + ) => void; + onError: ( + pacs_name: string, + SeriesInstanceUID: string, + error: string, + ) => void; +}; + +export type { + LonkDone, + LonkProgress, + LonkError, + LonkSubscription, + LonkMessageData, + Lonk, + LonkHandlers, + SeriesKey, +}; From 288aea3a61d85d4bbfb58a67980a06300cef9656 Mon Sep 17 00:00:00 2001 From: Jennings Zhang Date: Mon, 16 Sep 2024 04:48:13 -0400 Subject: [PATCH 02/41] Add new fp-ts PfdcmClient --- .env | 5 +- README.md | 11 + biome.json | 2 +- src/api/pfdcm/client.ts | 246 ++++++++++ .../pfdcm/generated/.openapi-generator-ignore | 23 + .../pfdcm/generated/.openapi-generator/FILES | 49 ++ .../generated/.openapi-generator/VERSION | 1 + src/api/pfdcm/generated/apis/DicomApi.ts | 76 ++++ .../apis/ListenerSubsystemServicesApi.ts | 303 +++++++++++++ .../pfdcm/generated/apis/PACSQRServicesApi.ts | 127 ++++++ .../generated/apis/PACSSetupServicesApi.ts | 198 ++++++++ .../apis/PfdcmEnvironmentalDetailApi.ts | 100 ++++ .../generated/apis/SMDBSetupServicesApi.ts | 305 +++++++++++++ src/api/pfdcm/generated/apis/index.ts | 8 + src/api/pfdcm/generated/index.ts | 5 + src/api/pfdcm/generated/models/AboutModel.ts | 76 ++++ .../BodyPACSPypxApiV1PACSSyncPypxPost.ts | 92 ++++ ...CSServiceHandlerApiV1PACSThreadPypxPost.ts | 92 ++++ ...CSobjPortUpdateApiV1PACSservicePortPost.ts | 77 ++++ src/api/pfdcm/generated/models/DcmtkCore.ts | 106 +++++ .../pfdcm/generated/models/DcmtkDBPutModel.ts | 68 +++ .../generated/models/DcmtkDBReturnModel.ts | 101 +++++ src/api/pfdcm/generated/models/Dicom.ts | 60 +++ src/api/pfdcm/generated/models/EchoModel.ts | 61 +++ .../generated/models/HTTPValidationError.ts | 67 +++ src/api/pfdcm/generated/models/HelloModel.ts | 97 ++++ .../generated/models/ListenerDBreturnModel.ts | 83 ++++ .../generated/models/ListenerHandlerStatus.ts | 61 +++ .../pfdcm/generated/models/LocationInner.ts | 42 ++ .../models/ModelsListenerModelTime.ts | 61 +++ .../models/ModelsListenerModelValueStr.ts | 60 +++ .../models/ModelsPacsQRmodelValueStr.ts | 60 +++ .../models/ModelsPacsSetupModelTime.ts | 61 +++ .../models/ModelsPacsSetupModelValueStr.ts | 60 +++ .../models/ModelsSmdbSetupModelValueStr.ts | 60 +++ src/api/pfdcm/generated/models/PACSasync.ts | 87 ++++ .../pfdcm/generated/models/PACSdbPutModel.ts | 68 +++ .../generated/models/PACSdbReturnModel.ts | 101 +++++ .../pfdcm/generated/models/PACSqueryCore.ts | 252 +++++++++++ .../pfdcm/generated/models/PACSsetupCore.ts | 92 ++++ .../pfdcm/generated/models/SMDBFsConfig.ts | 83 ++++ src/api/pfdcm/generated/models/SMDBFsCore.ts | 61 +++ .../generated/models/SMDBFsReturnModel.ts | 85 ++++ .../pfdcm/generated/models/SMDBcubeConfig.ts | 83 ++++ .../pfdcm/generated/models/SMDBcubeCore.ts | 79 ++++ .../generated/models/SMDBcubeReturnModel.ts | 85 ++++ .../pfdcm/generated/models/SMDBswiftConfig.ts | 83 ++++ .../pfdcm/generated/models/SMDBswiftCore.ts | 79 ++++ .../pfdcm/generated/models/SysInfoModel.ts | 148 ++++++ .../pfdcm/generated/models/ValidationError.ts | 86 ++++ src/api/pfdcm/generated/models/XinetdCore.ts | 124 +++++ .../generated/models/XinetdDBPutModel.ts | 68 +++ .../generated/models/XinetdDBReturnModel.ts | 101 +++++ src/api/pfdcm/generated/models/index.ts | 40 ++ src/api/pfdcm/generated/runtime.ts | 426 ++++++++++++++++++ src/api/pfdcm/index.test.ts | 38 ++ src/api/pfdcm/index.ts | 3 + src/api/pfdcm/models.ts | 88 ++++ src/components/Pacs/pfdcmClient.tsx | 2 +- 59 files changed, 5262 insertions(+), 4 deletions(-) create mode 100644 src/api/pfdcm/client.ts create mode 100644 src/api/pfdcm/generated/.openapi-generator-ignore create mode 100644 src/api/pfdcm/generated/.openapi-generator/FILES create mode 100644 src/api/pfdcm/generated/.openapi-generator/VERSION create mode 100644 src/api/pfdcm/generated/apis/DicomApi.ts create mode 100644 src/api/pfdcm/generated/apis/ListenerSubsystemServicesApi.ts create mode 100644 src/api/pfdcm/generated/apis/PACSQRServicesApi.ts create mode 100644 src/api/pfdcm/generated/apis/PACSSetupServicesApi.ts create mode 100644 src/api/pfdcm/generated/apis/PfdcmEnvironmentalDetailApi.ts create mode 100644 src/api/pfdcm/generated/apis/SMDBSetupServicesApi.ts create mode 100644 src/api/pfdcm/generated/apis/index.ts create mode 100644 src/api/pfdcm/generated/index.ts create mode 100644 src/api/pfdcm/generated/models/AboutModel.ts create mode 100644 src/api/pfdcm/generated/models/BodyPACSPypxApiV1PACSSyncPypxPost.ts create mode 100644 src/api/pfdcm/generated/models/BodyPACSServiceHandlerApiV1PACSThreadPypxPost.ts create mode 100644 src/api/pfdcm/generated/models/BodyPACSobjPortUpdateApiV1PACSservicePortPost.ts create mode 100644 src/api/pfdcm/generated/models/DcmtkCore.ts create mode 100644 src/api/pfdcm/generated/models/DcmtkDBPutModel.ts create mode 100644 src/api/pfdcm/generated/models/DcmtkDBReturnModel.ts create mode 100644 src/api/pfdcm/generated/models/Dicom.ts create mode 100644 src/api/pfdcm/generated/models/EchoModel.ts create mode 100644 src/api/pfdcm/generated/models/HTTPValidationError.ts create mode 100644 src/api/pfdcm/generated/models/HelloModel.ts create mode 100644 src/api/pfdcm/generated/models/ListenerDBreturnModel.ts create mode 100644 src/api/pfdcm/generated/models/ListenerHandlerStatus.ts create mode 100644 src/api/pfdcm/generated/models/LocationInner.ts create mode 100644 src/api/pfdcm/generated/models/ModelsListenerModelTime.ts create mode 100644 src/api/pfdcm/generated/models/ModelsListenerModelValueStr.ts create mode 100644 src/api/pfdcm/generated/models/ModelsPacsQRmodelValueStr.ts create mode 100644 src/api/pfdcm/generated/models/ModelsPacsSetupModelTime.ts create mode 100644 src/api/pfdcm/generated/models/ModelsPacsSetupModelValueStr.ts create mode 100644 src/api/pfdcm/generated/models/ModelsSmdbSetupModelValueStr.ts create mode 100644 src/api/pfdcm/generated/models/PACSasync.ts create mode 100644 src/api/pfdcm/generated/models/PACSdbPutModel.ts create mode 100644 src/api/pfdcm/generated/models/PACSdbReturnModel.ts create mode 100644 src/api/pfdcm/generated/models/PACSqueryCore.ts create mode 100644 src/api/pfdcm/generated/models/PACSsetupCore.ts create mode 100644 src/api/pfdcm/generated/models/SMDBFsConfig.ts create mode 100644 src/api/pfdcm/generated/models/SMDBFsCore.ts create mode 100644 src/api/pfdcm/generated/models/SMDBFsReturnModel.ts create mode 100644 src/api/pfdcm/generated/models/SMDBcubeConfig.ts create mode 100644 src/api/pfdcm/generated/models/SMDBcubeCore.ts create mode 100644 src/api/pfdcm/generated/models/SMDBcubeReturnModel.ts create mode 100644 src/api/pfdcm/generated/models/SMDBswiftConfig.ts create mode 100644 src/api/pfdcm/generated/models/SMDBswiftCore.ts create mode 100644 src/api/pfdcm/generated/models/SysInfoModel.ts create mode 100644 src/api/pfdcm/generated/models/ValidationError.ts create mode 100644 src/api/pfdcm/generated/models/XinetdCore.ts create mode 100644 src/api/pfdcm/generated/models/XinetdDBPutModel.ts create mode 100644 src/api/pfdcm/generated/models/XinetdDBReturnModel.ts create mode 100644 src/api/pfdcm/generated/models/index.ts create mode 100644 src/api/pfdcm/generated/runtime.ts create mode 100644 src/api/pfdcm/index.test.ts create mode 100644 src/api/pfdcm/index.ts create mode 100644 src/api/pfdcm/models.ts diff --git a/.env b/.env index 1a5679867..684c225fb 100644 --- a/.env +++ b/.env @@ -8,7 +8,8 @@ VITE_CHRIS_UI_AUTH_URL="http://localhost:8000/api/v1/auth-token/" VITE_ALPHA_FEATURES='production' # Set PFDCM_URL to the root url of the running pfdcm instance -VITE_PFDCM_URL="http://localhost:4005/" +# note: must *not* have trailing slash +VITE_PFDCM_URL="http://localhost:4005" # (optional) set URL of an OHIF browser which has the same studies as the PFDCM # VITE_OHIF_URL="http://localhost:8042/ohif/" @@ -18,4 +19,4 @@ VITE_PFDCM_SWIFTKEY="local" VITE_SOURCEMAP='false' # Set URL for the store if you want to see it in the sidebar -VITE_CHRIS_STORE_URL= "http://rc-live.tch.harvard.edu:32222/api/v1/" \ No newline at end of file +VITE_CHRIS_STORE_URL= "http://rc-live.tch.harvard.edu:32222/api/v1/" diff --git a/README.md b/README.md index ff14c6d2d..5dd705fc6 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,17 @@ npm run test:e2e:local # run tests using "local" backend For more information, consult the wiki: https://github.com/FNNDSC/ChRIS_ui/wiki/E2E-Testing-with-Playwright +## Pfdcm Client + +The code in `src/api/pfdcm/generated` were automatically generated using the [OpenAPI generator](https://openapi-generator.tech). + +```shell +docker run --rm --net=host -u "$(id -u):$(id -g)" \ + -v "$(npm prefix)/src:/src" \ + docker.io/openapitools/openapi-generator-cli:v7.8.0 \ + generate -g typescript-fetch -i http://localhost:4005/openapi.json -o /src/api/pfdcm/generated +``` + [license-badge]: https://img.shields.io/github/license/fnndsc/chris_ui.svg diff --git a/biome.json b/biome.json index 667c21416..ac80de028 100644 --- a/biome.json +++ b/biome.json @@ -10,7 +10,7 @@ "tsconfig*.json", "testing/*.mjs" ], - "ignore": ["package-lock.json"] + "ignore": ["package-lock.json", "pfdcm/generated"] }, "organizeImports": { "enabled": true diff --git a/src/api/pfdcm/client.ts b/src/api/pfdcm/client.ts new file mode 100644 index 000000000..0214a63e5 --- /dev/null +++ b/src/api/pfdcm/client.ts @@ -0,0 +1,246 @@ +/** + * Wrapper for the OpenAPI-generated client, providing better typing + * and a fp-ts API. + */ + +import * as TE from "fp-ts/TaskEither"; +import * as E from "fp-ts/Either"; +import { + Configuration, + PACSPypxApiV1PACSSyncPypxPostRequest, + PACSQRServicesApi, + PACSSetupServicesApi, + PACSqueryCore, + PACSServiceHandlerApiV1PACSThreadPypxPostRequest, + PACSasync, +} from "./generated"; +import { pipe } from "fp-ts/function"; +import { PypxFind, PypxTag, Series, StudyAndSeries } from "./models.ts"; +import { parse as parseDate } from "date-fns"; + +/** + * PFDCM client. + */ +class PfdcmClient { + private readonly servicesClient: PACSSetupServicesApi; + private readonly qrClient: PACSQRServicesApi; + constructor(configuration?: Configuration) { + this.servicesClient = new PACSSetupServicesApi(configuration); + this.qrClient = new PACSQRServicesApi(configuration); + } + + /** + * Get list of PACS services which this PFDCM is configured to speak with. + */ + public getPacsServices(): TE.TaskEither> { + return TE.tryCatch( + () => this.servicesClient.serviceListGetApiV1PACSserviceListGet(), + E.toError, + ); + } + + private find( + service: string, + query: PACSqueryCore, + ): TE.TaskEither { + const params: PACSPypxApiV1PACSSyncPypxPostRequest = { + bodyPACSPypxApiV1PACSSyncPypxPost: { + pACSservice: { + value: service, + }, + listenerService: { + value: "default", + }, + pACSdirective: query, + }, + }; + return pipe( + TE.tryCatch( + () => this.qrClient.pACSPypxApiV1PACSSyncPypxPost(params), + E.toError, + ), + TE.flatMap(validateFindResponseData), + TE.flatMap(validateStatusIsTrue), + ); + } + + /** + * Search for PACS data. + * @param service which PACS service to search for. See {@link PfdcmClient.getPacsServices} + * @param query PACS query + */ + public query( + service: string, + query: PACSqueryCore, + ): TE.TaskEither> { + return pipe(this.find(service, query), TE.map(simplifyResponse)); + } + + public retrieve( + service: string, + query: PACSqueryCore, + ): TE.TaskEither { + const params: PACSServiceHandlerApiV1PACSThreadPypxPostRequest = { + bodyPACSServiceHandlerApiV1PACSThreadPypxPost: { + pACSservice: { + value: service, + }, + listenerService: { + value: "default", + }, + pACSdirective: { + ...query, + withFeedBack: true, + then: "retrieve", + }, + }, + }; + return pipe( + TE.tryCatch( + () => this.qrClient.pACSServiceHandlerApiV1PACSThreadPypxPost(params), + E.toError, + ), + TE.flatMap((data) => { + // @ts-ignore OpenAPI spec of PFDCM is incomplete + if (data.response.job.status) { + return TE.right(data); + } + const error = new Error("PYPX job status is missing or false"); + return TE.left(error); + }), + ); + } +} + +function validateFindResponseData(data: any): TE.TaskEither { + if (isFindResponseData(data)) { + return TE.right(data); + } + const error = new Error("Invalid response from PFDCM"); + return TE.left(error); +} + +function isFindResponseData(data: any): data is PypxFind { + return ( + typeof data.status === "boolean" && "message" in data && "pypx" in data + ); +} + +/** + * Validate that all the "status" fields are `true` + * (this is a convention that Rudolph uses for error handling + * instead of HTTP status codes, exceptions, and/or monads). + */ +function validateStatusIsTrue(data: PypxFind): TE.TaskEither { + if (!data.status) { + const error = new Error("PFDCM response status=false"); + return TE.left(error); + } + if (data.pypx.status !== "success") { + const error = new Error("PFDCM response pypx.status=false"); + return TE.left(error); + } + for (const study of data.pypx.data) { + if (!Array.isArray(study.series)) { + continue; + } + for (const series of study.series) { + if (series.status.value !== "success") { + const error = new Error( + `PFDCM response pypx...status is false for SeriesInstanceUID=${series?.SeriesInstanceUID?.value}`, + ); + return TE.left(error); + } + } + } + return TE.right(data); +} + +/** + * Re-organizes the data from pypx's response. + */ +function simplifyResponse(data: PypxFind): ReadonlyArray { + return data.pypx.data.map(simplifyPypxStudyData); +} + +function simplifyPypxStudyData(data: { + [key: string]: PypxTag | ReadonlyArray<{ [key: string]: PypxTag }>; +}): StudyAndSeries { + const study = { + SpecificCharacterSet: getValue(data, "SpecificCharacterSet"), + StudyDate: getValue(data, "StudyDate"), + AccessionNumber: getValue(data, "AccessionNumber"), + RetrieveAETitle: getValue(data, "RetrieveAETitle"), + ModalitiesInStudy: getValue(data, "ModalitiesInStudy"), + StudyDescription: getValue(data, "StudyDescription"), + PatientName: getValue(data, "PatientName"), + PatientID: getValue(data, "PatientID"), + PatientBirthDate: + "value" in data.PatientBirthDate + ? parseDicomDate(data.PatientBirthDate) + : null, + PatientSex: getValue(data, "PatientSex"), + PatientAge: parseFloat(getValue(data, "PatientAge")), + ProtocolName: getValue(data, "ProtocolName"), + AcquisitionProtocolName: getValue(data, "AcquisitionProtocolName"), + AcquisitionProtocolDescription: getValue( + data, + "AcquisitionProtocolDescription", + ), + StudyInstanceUID: getValue(data, "StudyInstanceUID"), + NumberOfStudyRelatedSeries: getValue(data, "NumberOfStudyRelatedSeries"), + PerformedStationAETitle: getValue(data, "PerformedStationAETitle"), + }; + const series = Array.isArray(data.series) + ? data.series.map(simplifyPypxSeriesData) + : []; + return { study, series }; +} + +function getValue( + data: { [key: string]: PypxTag | ReadonlyArray<{ [key: string]: PypxTag }> }, + name: string, +): string { + if ("value" in data[name]) { + return "" + data[name].value; + } + return ""; +} + +function simplifyPypxSeriesData(data: { [key: string]: PypxTag }): Series { + return { + SpecificCharacterSet: "" + data.SpecificCharacterSet.value, + StudyDate: "" + data.StudyDate.value, + SeriesDate: "" + data.SeriesDate.value, + AccessionNumber: "" + data.AccessionNumber.value, + RetrieveAETitle: "" + data.RetrieveAETitle.value, + Modality: "" + data.Modality.value, + StudyDescription: "" + data.StudyDescription.value, + SeriesDescription: "" + data.SeriesDescription.value, + PatientName: "" + data.PatientName.value, + PatientID: "" + data.PatientID.value, + PatientBirthDate: parseDicomDate(data.PatientBirthDate), + PatientSex: "" + data.PatientSex.value, + PatientAge: parseFloat("" + data.PatientAge.value), + ProtocolName: "" + data.ProtocolName.value, + AcquisitionProtocolName: "" + data.AcquisitionProtocolName.value, + AcquisitionProtocolDescription: + "" + data.AcquisitionProtocolDescription.value, + StudyInstanceUID: "" + data.StudyInstanceUID.value, + SeriesInstanceUID: "" + data.SeriesInstanceUID.value, + NumberOfSeriesRelatedInstances: + "" + data.NumberOfSeriesRelatedInstances.value, + PerformedStationAETitle: "" + data.PerformedStationAETitle.value, + }; +} + +/** + * Parse a DICOM DateString (DS), which is in YYYYMMDD format. + * + * https://dicom.nema.org/medical/dicom/current/output/chtml/part05/sect_6.2.html + */ +function parseDicomDate(tag: PypxTag): Date { + return parseDate("" + tag.value, "yyyyMMdd", new Date()); +} + +export { PfdcmClient }; diff --git a/src/api/pfdcm/generated/.openapi-generator-ignore b/src/api/pfdcm/generated/.openapi-generator-ignore new file mode 100644 index 000000000..7484ee590 --- /dev/null +++ b/src/api/pfdcm/generated/.openapi-generator-ignore @@ -0,0 +1,23 @@ +# OpenAPI Generator Ignore +# Generated by openapi-generator https://github.com/openapitools/openapi-generator + +# Use this file to prevent files from being overwritten by the generator. +# The patterns follow closely to .gitignore or .dockerignore. + +# As an example, the C# client generator defines ApiClient.cs. +# You can make changes and tell OpenAPI Generator to ignore just this file by uncommenting the following line: +#ApiClient.cs + +# You can match any string of characters against a directory, file or extension with a single asterisk (*): +#foo/*/qux +# The above matches foo/bar/qux and foo/baz/qux, but not foo/bar/baz/qux + +# You can recursively match patterns against a directory, file or extension with a double asterisk (**): +#foo/**/qux +# This matches foo/bar/qux, foo/baz/qux, and foo/bar/baz/qux + +# You can also negate patterns with an exclamation (!). +# For example, you can ignore all files in a docs folder with the file extension .md: +#docs/*.md +# Then explicitly reverse the ignore rule for a single file: +#!docs/README.md diff --git a/src/api/pfdcm/generated/.openapi-generator/FILES b/src/api/pfdcm/generated/.openapi-generator/FILES new file mode 100644 index 000000000..f0703c692 --- /dev/null +++ b/src/api/pfdcm/generated/.openapi-generator/FILES @@ -0,0 +1,49 @@ +.openapi-generator-ignore +apis/DicomApi.ts +apis/ListenerSubsystemServicesApi.ts +apis/PACSQRServicesApi.ts +apis/PACSSetupServicesApi.ts +apis/PfdcmEnvironmentalDetailApi.ts +apis/SMDBSetupServicesApi.ts +apis/index.ts +index.ts +models/AboutModel.ts +models/BodyPACSPypxApiV1PACSSyncPypxPost.ts +models/BodyPACSServiceHandlerApiV1PACSThreadPypxPost.ts +models/BodyPACSobjPortUpdateApiV1PACSservicePortPost.ts +models/DcmtkCore.ts +models/DcmtkDBPutModel.ts +models/DcmtkDBReturnModel.ts +models/Dicom.ts +models/EchoModel.ts +models/HTTPValidationError.ts +models/HelloModel.ts +models/ListenerDBreturnModel.ts +models/ListenerHandlerStatus.ts +models/LocationInner.ts +models/ModelsListenerModelTime.ts +models/ModelsListenerModelValueStr.ts +models/ModelsPacsQRmodelValueStr.ts +models/ModelsPacsSetupModelTime.ts +models/ModelsPacsSetupModelValueStr.ts +models/ModelsSmdbSetupModelValueStr.ts +models/PACSasync.ts +models/PACSdbPutModel.ts +models/PACSdbReturnModel.ts +models/PACSqueryCore.ts +models/PACSsetupCore.ts +models/SMDBFsConfig.ts +models/SMDBFsCore.ts +models/SMDBFsReturnModel.ts +models/SMDBcubeConfig.ts +models/SMDBcubeCore.ts +models/SMDBcubeReturnModel.ts +models/SMDBswiftConfig.ts +models/SMDBswiftCore.ts +models/SysInfoModel.ts +models/ValidationError.ts +models/XinetdCore.ts +models/XinetdDBPutModel.ts +models/XinetdDBReturnModel.ts +models/index.ts +runtime.ts diff --git a/src/api/pfdcm/generated/.openapi-generator/VERSION b/src/api/pfdcm/generated/.openapi-generator/VERSION new file mode 100644 index 000000000..09a6d3084 --- /dev/null +++ b/src/api/pfdcm/generated/.openapi-generator/VERSION @@ -0,0 +1 @@ +7.8.0 diff --git a/src/api/pfdcm/generated/apis/DicomApi.ts b/src/api/pfdcm/generated/apis/DicomApi.ts new file mode 100644 index 000000000..bdca8314f --- /dev/null +++ b/src/api/pfdcm/generated/apis/DicomApi.ts @@ -0,0 +1,76 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * pfdcm + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 3.1.2 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +import * as runtime from '../runtime'; +import type { + Dicom, + HTTPValidationError, +} from '../models/index'; +import { + DicomFromJSON, + DicomToJSON, + HTTPValidationErrorFromJSON, + HTTPValidationErrorToJSON, +} from '../models/index'; + +export interface ReadDicomApiV1DicomGetRequest { + mrn: number; +} + +/** + * + */ +export class DicomApi extends runtime.BaseAPI { + + /** + * Fake meaningless response + * Get dicom images for a patient. + */ + async readDicomApiV1DicomGetRaw(requestParameters: ReadDicomApiV1DicomGetRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise> { + if (requestParameters['mrn'] == null) { + throw new runtime.RequiredError( + 'mrn', + 'Required parameter "mrn" was null or undefined when calling readDicomApiV1DicomGet().' + ); + } + + const queryParameters: any = {}; + + if (requestParameters['mrn'] != null) { + queryParameters['mrn'] = requestParameters['mrn']; + } + + const headerParameters: runtime.HTTPHeaders = {}; + + const response = await this.request({ + path: `/api/v1/dicom/`, + method: 'GET', + headers: headerParameters, + query: queryParameters, + }, initOverrides); + + return new runtime.JSONApiResponse(response, (jsonValue) => DicomFromJSON(jsonValue)); + } + + /** + * Fake meaningless response + * Get dicom images for a patient. + */ + async readDicomApiV1DicomGet(requestParameters: ReadDicomApiV1DicomGetRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise { + const response = await this.readDicomApiV1DicomGetRaw(requestParameters, initOverrides); + return await response.value(); + } + +} diff --git a/src/api/pfdcm/generated/apis/ListenerSubsystemServicesApi.ts b/src/api/pfdcm/generated/apis/ListenerSubsystemServicesApi.ts new file mode 100644 index 000000000..f65195e4d --- /dev/null +++ b/src/api/pfdcm/generated/apis/ListenerSubsystemServicesApi.ts @@ -0,0 +1,303 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * pfdcm + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 3.1.2 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +import * as runtime from '../runtime'; +import type { + DcmtkDBPutModel, + DcmtkDBReturnModel, + HTTPValidationError, + ListenerDBreturnModel, + ListenerHandlerStatus, + ModelsListenerModelValueStr, + XinetdDBPutModel, + XinetdDBReturnModel, +} from '../models/index'; +import { + DcmtkDBPutModelFromJSON, + DcmtkDBPutModelToJSON, + DcmtkDBReturnModelFromJSON, + DcmtkDBReturnModelToJSON, + HTTPValidationErrorFromJSON, + HTTPValidationErrorToJSON, + ListenerDBreturnModelFromJSON, + ListenerDBreturnModelToJSON, + ListenerHandlerStatusFromJSON, + ListenerHandlerStatusToJSON, + ModelsListenerModelValueStrFromJSON, + ModelsListenerModelValueStrToJSON, + XinetdDBPutModelFromJSON, + XinetdDBPutModelToJSON, + XinetdDBReturnModelFromJSON, + XinetdDBReturnModelToJSON, +} from '../models/index'; + +export interface ItemPutDcmtkApiV1ListenerListenerObjNameDcmtkPutRequest { + listenerObjName: string; + dcmtkDBPutModel: DcmtkDBPutModel; +} + +export interface ItemPutXinetdApiV1ListenerListenerObjNameXinetdPutRequest { + listenerObjName: string; + xinetdDBPutModel: XinetdDBPutModel; +} + +export interface ListenerGetApiV1ListenerListenerObjNameGetRequest { + listenerObjName: string; +} + +export interface ListenerInitializeApiV1ListenerInitializePostRequest { + modelsListenerModelValueStr: ModelsListenerModelValueStr; +} + +export interface ListenerStatusGetApiV1ListenerStatusListenerObjNameGetRequest { + listenerObjName: string; +} + +/** + * + */ +export class ListenerSubsystemServicesApi extends runtime.BaseAPI { + + /** + * PUT an entire dcmtk object. If the object already exists, overwrite. If it does not exist, append to the space of available objects. Note that overwriting an existing object will replace ALL the `info` fields, thus leaving a default of `\"string\"` will literally put the text `string` for a specific field. Parameters ---------- - `listenerObjName` : internal name of (new) object - `dcmtkInfo` : new values for object internals + * PUT a dcmtk update + */ + async itemPutDcmtkApiV1ListenerListenerObjNameDcmtkPutRaw(requestParameters: ItemPutDcmtkApiV1ListenerListenerObjNameDcmtkPutRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise> { + if (requestParameters['listenerObjName'] == null) { + throw new runtime.RequiredError( + 'listenerObjName', + 'Required parameter "listenerObjName" was null or undefined when calling itemPutDcmtkApiV1ListenerListenerObjNameDcmtkPut().' + ); + } + + if (requestParameters['dcmtkDBPutModel'] == null) { + throw new runtime.RequiredError( + 'dcmtkDBPutModel', + 'Required parameter "dcmtkDBPutModel" was null or undefined when calling itemPutDcmtkApiV1ListenerListenerObjNameDcmtkPut().' + ); + } + + const queryParameters: any = {}; + + const headerParameters: runtime.HTTPHeaders = {}; + + headerParameters['Content-Type'] = 'application/json'; + + const response = await this.request({ + path: `/api/v1/listener/{listenerObjName}/dcmtk/`.replace(`{${"listenerObjName"}}`, encodeURIComponent(String(requestParameters['listenerObjName']))), + method: 'PUT', + headers: headerParameters, + query: queryParameters, + body: DcmtkDBPutModelToJSON(requestParameters['dcmtkDBPutModel']), + }, initOverrides); + + return new runtime.JSONApiResponse(response, (jsonValue) => DcmtkDBReturnModelFromJSON(jsonValue)); + } + + /** + * PUT an entire dcmtk object. If the object already exists, overwrite. If it does not exist, append to the space of available objects. Note that overwriting an existing object will replace ALL the `info` fields, thus leaving a default of `\"string\"` will literally put the text `string` for a specific field. Parameters ---------- - `listenerObjName` : internal name of (new) object - `dcmtkInfo` : new values for object internals + * PUT a dcmtk update + */ + async itemPutDcmtkApiV1ListenerListenerObjNameDcmtkPut(requestParameters: ItemPutDcmtkApiV1ListenerListenerObjNameDcmtkPutRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise { + const response = await this.itemPutDcmtkApiV1ListenerListenerObjNameDcmtkPutRaw(requestParameters, initOverrides); + return await response.value(); + } + + /** + * PUT an entire xinetd object. If the object already exists, overwrite. If it does not exist, append to the space of available objects. Note that overwriting an existing object will replace ALL the `info` fields, thus leaving a default of `\"string\"` will literally put the text `string` for a specific field. Parameters ---------- - `listenerObjName` : internal name of (new) object - `xinetdInfo` : new values for object internals + * PUT an xinetd update + */ + async itemPutXinetdApiV1ListenerListenerObjNameXinetdPutRaw(requestParameters: ItemPutXinetdApiV1ListenerListenerObjNameXinetdPutRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise> { + if (requestParameters['listenerObjName'] == null) { + throw new runtime.RequiredError( + 'listenerObjName', + 'Required parameter "listenerObjName" was null or undefined when calling itemPutXinetdApiV1ListenerListenerObjNameXinetdPut().' + ); + } + + if (requestParameters['xinetdDBPutModel'] == null) { + throw new runtime.RequiredError( + 'xinetdDBPutModel', + 'Required parameter "xinetdDBPutModel" was null or undefined when calling itemPutXinetdApiV1ListenerListenerObjNameXinetdPut().' + ); + } + + const queryParameters: any = {}; + + const headerParameters: runtime.HTTPHeaders = {}; + + headerParameters['Content-Type'] = 'application/json'; + + const response = await this.request({ + path: `/api/v1/listener/{listenerObjName}/xinetd/`.replace(`{${"listenerObjName"}}`, encodeURIComponent(String(requestParameters['listenerObjName']))), + method: 'PUT', + headers: headerParameters, + query: queryParameters, + body: XinetdDBPutModelToJSON(requestParameters['xinetdDBPutModel']), + }, initOverrides); + + return new runtime.JSONApiResponse(response, (jsonValue) => XinetdDBReturnModelFromJSON(jsonValue)); + } + + /** + * PUT an entire xinetd object. If the object already exists, overwrite. If it does not exist, append to the space of available objects. Note that overwriting an existing object will replace ALL the `info` fields, thus leaving a default of `\"string\"` will literally put the text `string` for a specific field. Parameters ---------- - `listenerObjName` : internal name of (new) object - `xinetdInfo` : new values for object internals + * PUT an xinetd update + */ + async itemPutXinetdApiV1ListenerListenerObjNameXinetdPut(requestParameters: ItemPutXinetdApiV1ListenerListenerObjNameXinetdPutRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise { + const response = await this.itemPutXinetdApiV1ListenerListenerObjNameXinetdPutRaw(requestParameters, initOverrides); + return await response.value(); + } + + /** + * GET the information pertinent to a `listenerObjName` (for a list of valid `listenerObjName` GET the `serviceList`) + * GET information for a given listener object + */ + async listenerGetApiV1ListenerListenerObjNameGetRaw(requestParameters: ListenerGetApiV1ListenerListenerObjNameGetRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise> { + if (requestParameters['listenerObjName'] == null) { + throw new runtime.RequiredError( + 'listenerObjName', + 'Required parameter "listenerObjName" was null or undefined when calling listenerGetApiV1ListenerListenerObjNameGet().' + ); + } + + const queryParameters: any = {}; + + const headerParameters: runtime.HTTPHeaders = {}; + + const response = await this.request({ + path: `/api/v1/listener/{listenerObjName}/`.replace(`{${"listenerObjName"}}`, encodeURIComponent(String(requestParameters['listenerObjName']))), + method: 'GET', + headers: headerParameters, + query: queryParameters, + }, initOverrides); + + return new runtime.JSONApiResponse(response, (jsonValue) => ListenerDBreturnModelFromJSON(jsonValue)); + } + + /** + * GET the information pertinent to a `listenerObjName` (for a list of valid `listenerObjName` GET the `serviceList`) + * GET information for a given listener object + */ + async listenerGetApiV1ListenerListenerObjNameGet(requestParameters: ListenerGetApiV1ListenerListenerObjNameGetRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise { + const response = await this.listenerGetApiV1ListenerListenerObjNameGetRaw(requestParameters, initOverrides); + return await response.value(); + } + + /** + * Initialize the listener service for the object __objToInitialize__. Parameters ---------- - `objToInitialize`: name of the listener object to initialize Return ------ - dictionary response from the initialization process NOTE: A return / response model is not specified since the return from the call is variable. + * POST a signal to the listener `objToInitialize`, triggering a self initialization + */ + async listenerInitializeApiV1ListenerInitializePostRaw(requestParameters: ListenerInitializeApiV1ListenerInitializePostRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise> { + if (requestParameters['modelsListenerModelValueStr'] == null) { + throw new runtime.RequiredError( + 'modelsListenerModelValueStr', + 'Required parameter "modelsListenerModelValueStr" was null or undefined when calling listenerInitializeApiV1ListenerInitializePost().' + ); + } + + const queryParameters: any = {}; + + const headerParameters: runtime.HTTPHeaders = {}; + + headerParameters['Content-Type'] = 'application/json'; + + const response = await this.request({ + path: `/api/v1/listener/initialize/`, + method: 'POST', + headers: headerParameters, + query: queryParameters, + body: ModelsListenerModelValueStrToJSON(requestParameters['modelsListenerModelValueStr']), + }, initOverrides); + + if (this.isJsonMime(response.headers.get('content-type'))) { + return new runtime.JSONApiResponse(response); + } else { + return new runtime.TextApiResponse(response) as any; + } + } + + /** + * Initialize the listener service for the object __objToInitialize__. Parameters ---------- - `objToInitialize`: name of the listener object to initialize Return ------ - dictionary response from the initialization process NOTE: A return / response model is not specified since the return from the call is variable. + * POST a signal to the listener `objToInitialize`, triggering a self initialization + */ + async listenerInitializeApiV1ListenerInitializePost(requestParameters: ListenerInitializeApiV1ListenerInitializePostRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise { + const response = await this.listenerInitializeApiV1ListenerInitializePostRaw(requestParameters, initOverrides); + return await response.value(); + } + + /** + * GET the listener susystem information pertinent to a `listenerObjName`. This information indicates if the subsystem has been initialized and therefore if it is ready to accept incoming data. (for a list of valid `listenerObjName` GET the `serviceList`) + * GET the listener subsystem status of a given listener object + */ + async listenerStatusGetApiV1ListenerStatusListenerObjNameGetRaw(requestParameters: ListenerStatusGetApiV1ListenerStatusListenerObjNameGetRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise> { + if (requestParameters['listenerObjName'] == null) { + throw new runtime.RequiredError( + 'listenerObjName', + 'Required parameter "listenerObjName" was null or undefined when calling listenerStatusGetApiV1ListenerStatusListenerObjNameGet().' + ); + } + + const queryParameters: any = {}; + + const headerParameters: runtime.HTTPHeaders = {}; + + const response = await this.request({ + path: `/api/v1/listener/status/{listenerObjName}/`.replace(`{${"listenerObjName"}}`, encodeURIComponent(String(requestParameters['listenerObjName']))), + method: 'GET', + headers: headerParameters, + query: queryParameters, + }, initOverrides); + + return new runtime.JSONApiResponse(response, (jsonValue) => ListenerHandlerStatusFromJSON(jsonValue)); + } + + /** + * GET the listener susystem information pertinent to a `listenerObjName`. This information indicates if the subsystem has been initialized and therefore if it is ready to accept incoming data. (for a list of valid `listenerObjName` GET the `serviceList`) + * GET the listener subsystem status of a given listener object + */ + async listenerStatusGetApiV1ListenerStatusListenerObjNameGet(requestParameters: ListenerStatusGetApiV1ListenerStatusListenerObjNameGetRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise { + const response = await this.listenerStatusGetApiV1ListenerStatusListenerObjNameGetRaw(requestParameters, initOverrides); + return await response.value(); + } + + /** + * GET the list of configured PACS services + * GET the list of configured listener services + */ + async serviceListGetApiV1ListenerListGetRaw(initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise>> { + const queryParameters: any = {}; + + const headerParameters: runtime.HTTPHeaders = {}; + + const response = await this.request({ + path: `/api/v1/listener/list/`, + method: 'GET', + headers: headerParameters, + query: queryParameters, + }, initOverrides); + + return new runtime.JSONApiResponse(response); + } + + /** + * GET the list of configured PACS services + * GET the list of configured listener services + */ + async serviceListGetApiV1ListenerListGet(initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise> { + const response = await this.serviceListGetApiV1ListenerListGetRaw(initOverrides); + return await response.value(); + } + +} diff --git a/src/api/pfdcm/generated/apis/PACSQRServicesApi.ts b/src/api/pfdcm/generated/apis/PACSQRServicesApi.ts new file mode 100644 index 000000000..3fc64594c --- /dev/null +++ b/src/api/pfdcm/generated/apis/PACSQRServicesApi.ts @@ -0,0 +1,127 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * pfdcm + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 3.1.2 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +import * as runtime from '../runtime'; +import type { + BodyPACSPypxApiV1PACSSyncPypxPost, + BodyPACSServiceHandlerApiV1PACSThreadPypxPost, + HTTPValidationError, + PACSasync, +} from '../models/index'; +import { + BodyPACSPypxApiV1PACSSyncPypxPostFromJSON, + BodyPACSPypxApiV1PACSSyncPypxPostToJSON, + BodyPACSServiceHandlerApiV1PACSThreadPypxPostFromJSON, + BodyPACSServiceHandlerApiV1PACSThreadPypxPostToJSON, + HTTPValidationErrorFromJSON, + HTTPValidationErrorToJSON, + PACSasyncFromJSON, + PACSasyncToJSON, +} from '../models/index'; + +export interface PACSPypxApiV1PACSSyncPypxPostRequest { + bodyPACSPypxApiV1PACSSyncPypxPost: BodyPACSPypxApiV1PACSSyncPypxPost; +} + +export interface PACSServiceHandlerApiV1PACSThreadPypxPostRequest { + bodyPACSServiceHandlerApiV1PACSThreadPypxPost: BodyPACSServiceHandlerApiV1PACSThreadPypxPost; +} + +/** + * + */ +export class PACSQRServicesApi extends runtime.BaseAPI { + + /** + * POST a retrieve to the `PACSservice`, and capture return communication using the `listenerService`. The client will only receive a return payload when the PACSdirective has completed its remote execution. Parameters ---------- - `PACSservice`: name of the internal PACS service to query - `listenerService`: name of the listener service to use locally - `PACSdirective`: the pypx directive object Return ------ - PACSqueryReturnModel + * Use this API route for STATUS operations and any others that block but which are \"short lived\". Since this is a synchronous operation, the call will only return on successful completion of the remote directive. + */ + async pACSPypxApiV1PACSSyncPypxPostRaw(requestParameters: PACSPypxApiV1PACSSyncPypxPostRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise> { + if (requestParameters['bodyPACSPypxApiV1PACSSyncPypxPost'] == null) { + throw new runtime.RequiredError( + 'bodyPACSPypxApiV1PACSSyncPypxPost', + 'Required parameter "bodyPACSPypxApiV1PACSSyncPypxPost" was null or undefined when calling pACSPypxApiV1PACSSyncPypxPost().' + ); + } + + const queryParameters: any = {}; + + const headerParameters: runtime.HTTPHeaders = {}; + + headerParameters['Content-Type'] = 'application/json'; + + const response = await this.request({ + path: `/api/v1/PACS/sync/pypx/`, + method: 'POST', + headers: headerParameters, + query: queryParameters, + body: BodyPACSPypxApiV1PACSSyncPypxPostToJSON(requestParameters['bodyPACSPypxApiV1PACSSyncPypxPost']), + }, initOverrides); + + if (this.isJsonMime(response.headers.get('content-type'))) { + return new runtime.JSONApiResponse(response); + } else { + return new runtime.TextApiResponse(response) as any; + } + } + + /** + * POST a retrieve to the `PACSservice`, and capture return communication using the `listenerService`. The client will only receive a return payload when the PACSdirective has completed its remote execution. Parameters ---------- - `PACSservice`: name of the internal PACS service to query - `listenerService`: name of the listener service to use locally - `PACSdirective`: the pypx directive object Return ------ - PACSqueryReturnModel + * Use this API route for STATUS operations and any others that block but which are \"short lived\". Since this is a synchronous operation, the call will only return on successful completion of the remote directive. + */ + async pACSPypxApiV1PACSSyncPypxPost(requestParameters: PACSPypxApiV1PACSSyncPypxPostRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise { + const response = await this.pACSPypxApiV1PACSSyncPypxPostRaw(requestParameters, initOverrides); + return await response.value(); + } + + /** + * Handler into PACS calls for long-lived compute (retrieve/push/register) This is very thin and simple dispatching service that will either use the find module API, or will call the find module script. Anectodal testing has shown that the API calls might fail, possibly due to thread pool exhaustion? At time of writing, the CLI calls seem more reliable since they introduce a single-queue concept by explicitly waiting for a CLI px-find process to finish. While this means that status calls are somewhat blocked when a RPR job is in flight, for multiple series pulls, the retrieve/push/register workflow proceeds correctly. Args: PACSservice (pacsQRmodel.ValueStr): The PACS with which to communicate listenerService (pacsQRmodel.ValueStr): The listener service that receives PACS comms PACSdirective (pacsQRmodel.PACSqueryCore): The instructions to the PACS + * Use this API route for RETRIEVE, PUSH, REGISTER operations and any others that might be possibly \"long lived\". The actual processing is dispatched to a separate thread so that the client receives a return immediately. Clients should use a STATUS request on the same payload to determine realtime status of the operation. + */ + async pACSServiceHandlerApiV1PACSThreadPypxPostRaw(requestParameters: PACSServiceHandlerApiV1PACSThreadPypxPostRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise> { + if (requestParameters['bodyPACSServiceHandlerApiV1PACSThreadPypxPost'] == null) { + throw new runtime.RequiredError( + 'bodyPACSServiceHandlerApiV1PACSThreadPypxPost', + 'Required parameter "bodyPACSServiceHandlerApiV1PACSThreadPypxPost" was null or undefined when calling pACSServiceHandlerApiV1PACSThreadPypxPost().' + ); + } + + const queryParameters: any = {}; + + const headerParameters: runtime.HTTPHeaders = {}; + + headerParameters['Content-Type'] = 'application/json'; + + const response = await this.request({ + path: `/api/v1/PACS/thread/pypx/`, + method: 'POST', + headers: headerParameters, + query: queryParameters, + body: BodyPACSServiceHandlerApiV1PACSThreadPypxPostToJSON(requestParameters['bodyPACSServiceHandlerApiV1PACSThreadPypxPost']), + }, initOverrides); + + return new runtime.JSONApiResponse(response, (jsonValue) => PACSasyncFromJSON(jsonValue)); + } + + /** + * Handler into PACS calls for long-lived compute (retrieve/push/register) This is very thin and simple dispatching service that will either use the find module API, or will call the find module script. Anectodal testing has shown that the API calls might fail, possibly due to thread pool exhaustion? At time of writing, the CLI calls seem more reliable since they introduce a single-queue concept by explicitly waiting for a CLI px-find process to finish. While this means that status calls are somewhat blocked when a RPR job is in flight, for multiple series pulls, the retrieve/push/register workflow proceeds correctly. Args: PACSservice (pacsQRmodel.ValueStr): The PACS with which to communicate listenerService (pacsQRmodel.ValueStr): The listener service that receives PACS comms PACSdirective (pacsQRmodel.PACSqueryCore): The instructions to the PACS + * Use this API route for RETRIEVE, PUSH, REGISTER operations and any others that might be possibly \"long lived\". The actual processing is dispatched to a separate thread so that the client receives a return immediately. Clients should use a STATUS request on the same payload to determine realtime status of the operation. + */ + async pACSServiceHandlerApiV1PACSThreadPypxPost(requestParameters: PACSServiceHandlerApiV1PACSThreadPypxPostRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise { + const response = await this.pACSServiceHandlerApiV1PACSThreadPypxPostRaw(requestParameters, initOverrides); + return await response.value(); + } + +} diff --git a/src/api/pfdcm/generated/apis/PACSSetupServicesApi.ts b/src/api/pfdcm/generated/apis/PACSSetupServicesApi.ts new file mode 100644 index 000000000..0d463fc33 --- /dev/null +++ b/src/api/pfdcm/generated/apis/PACSSetupServicesApi.ts @@ -0,0 +1,198 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * pfdcm + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 3.1.2 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +import * as runtime from '../runtime'; +import type { + BodyPACSobjPortUpdateApiV1PACSservicePortPost, + HTTPValidationError, + PACSdbPutModel, + PACSdbReturnModel, +} from '../models/index'; +import { + BodyPACSobjPortUpdateApiV1PACSservicePortPostFromJSON, + BodyPACSobjPortUpdateApiV1PACSservicePortPostToJSON, + HTTPValidationErrorFromJSON, + HTTPValidationErrorToJSON, + PACSdbPutModelFromJSON, + PACSdbPutModelToJSON, + PACSdbReturnModelFromJSON, + PACSdbReturnModelToJSON, +} from '../models/index'; + +export interface PACSobjPortUpdateApiV1PACSservicePortPostRequest { + bodyPACSobjPortUpdateApiV1PACSservicePortPost: BodyPACSobjPortUpdateApiV1PACSservicePortPost; +} + +export interface PacsSetupGetApiV1PACSservicePACSobjNameGetRequest { + pACSobjName: string; +} + +export interface PacsSetupPutApiV1PACSservicePACSobjNamePutRequest { + pACSobjName: string; + pACSdbPutModel: PACSdbPutModel; +} + +/** + * + */ +export class PACSSetupServicesApi extends runtime.BaseAPI { + + /** + * Update the `server_port` of a given __objToUpdate__. This method is more exemplar than actually useful. Parameters ---------- - `objToUpdate`: name of the internal PACS object to update - `newPort`: port value string to re-assign in the internal object Return ------ - updated model of the `objToUpdate` + * POST a change to the listener `port` of the PACS `objToUpdate` + */ + async pACSobjPortUpdateApiV1PACSservicePortPostRaw(requestParameters: PACSobjPortUpdateApiV1PACSservicePortPostRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise> { + if (requestParameters['bodyPACSobjPortUpdateApiV1PACSservicePortPost'] == null) { + throw new runtime.RequiredError( + 'bodyPACSobjPortUpdateApiV1PACSservicePortPost', + 'Required parameter "bodyPACSobjPortUpdateApiV1PACSservicePortPost" was null or undefined when calling pACSobjPortUpdateApiV1PACSservicePortPost().' + ); + } + + const queryParameters: any = {}; + + const headerParameters: runtime.HTTPHeaders = {}; + + headerParameters['Content-Type'] = 'application/json'; + + const response = await this.request({ + path: `/api/v1/PACSservice/port/`, + method: 'POST', + headers: headerParameters, + query: queryParameters, + body: BodyPACSobjPortUpdateApiV1PACSservicePortPostToJSON(requestParameters['bodyPACSobjPortUpdateApiV1PACSservicePortPost']), + }, initOverrides); + + return new runtime.JSONApiResponse(response, (jsonValue) => PACSdbReturnModelFromJSON(jsonValue)); + } + + /** + * Update the `server_port` of a given __objToUpdate__. This method is more exemplar than actually useful. Parameters ---------- - `objToUpdate`: name of the internal PACS object to update - `newPort`: port value string to re-assign in the internal object Return ------ - updated model of the `objToUpdate` + * POST a change to the listener `port` of the PACS `objToUpdate` + */ + async pACSobjPortUpdateApiV1PACSservicePortPost(requestParameters: PACSobjPortUpdateApiV1PACSservicePortPostRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise { + const response = await this.pACSobjPortUpdateApiV1PACSservicePortPostRaw(requestParameters, initOverrides); + return await response.value(); + } + + /** + * GET the setup info pertinent to a `pacsSetup` + * GET the information for a given PACS + */ + async pacsSetupGetApiV1PACSservicePACSobjNameGetRaw(requestParameters: PacsSetupGetApiV1PACSservicePACSobjNameGetRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise> { + if (requestParameters['pACSobjName'] == null) { + throw new runtime.RequiredError( + 'pACSobjName', + 'Required parameter "pACSobjName" was null or undefined when calling pacsSetupGetApiV1PACSservicePACSobjNameGet().' + ); + } + + const queryParameters: any = {}; + + const headerParameters: runtime.HTTPHeaders = {}; + + const response = await this.request({ + path: `/api/v1/PACSservice/{PACSobjName}/`.replace(`{${"PACSobjName"}}`, encodeURIComponent(String(requestParameters['pACSobjName']))), + method: 'GET', + headers: headerParameters, + query: queryParameters, + }, initOverrides); + + return new runtime.JSONApiResponse(response, (jsonValue) => PACSdbReturnModelFromJSON(jsonValue)); + } + + /** + * GET the setup info pertinent to a `pacsSetup` + * GET the information for a given PACS + */ + async pacsSetupGetApiV1PACSservicePACSobjNameGet(requestParameters: PacsSetupGetApiV1PACSservicePACSobjNameGetRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise { + const response = await this.pacsSetupGetApiV1PACSservicePACSobjNameGetRaw(requestParameters, initOverrides); + return await response.value(); + } + + /** + * PUT an entire object. If the object already exists, overwrite. If it does not exist, append to the space of available objects. Note that overwriting an existing object will replace ALL the `info` fields, thus leaving a default of `\"string\"` will literally put the text `string` for a specific field. Parameters ---------- - `PACSobjName` : internal name of (new) object - `PACSsetupData` : new values for object internals + * PUT information to a (possibly new) PACS object + */ + async pacsSetupPutApiV1PACSservicePACSobjNamePutRaw(requestParameters: PacsSetupPutApiV1PACSservicePACSobjNamePutRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise> { + if (requestParameters['pACSobjName'] == null) { + throw new runtime.RequiredError( + 'pACSobjName', + 'Required parameter "pACSobjName" was null or undefined when calling pacsSetupPutApiV1PACSservicePACSobjNamePut().' + ); + } + + if (requestParameters['pACSdbPutModel'] == null) { + throw new runtime.RequiredError( + 'pACSdbPutModel', + 'Required parameter "pACSdbPutModel" was null or undefined when calling pacsSetupPutApiV1PACSservicePACSobjNamePut().' + ); + } + + const queryParameters: any = {}; + + const headerParameters: runtime.HTTPHeaders = {}; + + headerParameters['Content-Type'] = 'application/json'; + + const response = await this.request({ + path: `/api/v1/PACSservice/{PACSobjName}/`.replace(`{${"PACSobjName"}}`, encodeURIComponent(String(requestParameters['pACSobjName']))), + method: 'PUT', + headers: headerParameters, + query: queryParameters, + body: PACSdbPutModelToJSON(requestParameters['pACSdbPutModel']), + }, initOverrides); + + return new runtime.JSONApiResponse(response, (jsonValue) => PACSdbReturnModelFromJSON(jsonValue)); + } + + /** + * PUT an entire object. If the object already exists, overwrite. If it does not exist, append to the space of available objects. Note that overwriting an existing object will replace ALL the `info` fields, thus leaving a default of `\"string\"` will literally put the text `string` for a specific field. Parameters ---------- - `PACSobjName` : internal name of (new) object - `PACSsetupData` : new values for object internals + * PUT information to a (possibly new) PACS object + */ + async pacsSetupPutApiV1PACSservicePACSobjNamePut(requestParameters: PacsSetupPutApiV1PACSservicePACSobjNamePutRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise { + const response = await this.pacsSetupPutApiV1PACSservicePACSobjNamePutRaw(requestParameters, initOverrides); + return await response.value(); + } + + /** + * GET the list of configured PACS services + * GET the list of configured PACS services + */ + async serviceListGetApiV1PACSserviceListGetRaw(initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise>> { + const queryParameters: any = {}; + + const headerParameters: runtime.HTTPHeaders = {}; + + const response = await this.request({ + path: `/api/v1/PACSservice/list/`, + method: 'GET', + headers: headerParameters, + query: queryParameters, + }, initOverrides); + + return new runtime.JSONApiResponse(response); + } + + /** + * GET the list of configured PACS services + * GET the list of configured PACS services + */ + async serviceListGetApiV1PACSserviceListGet(initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise> { + const response = await this.serviceListGetApiV1PACSserviceListGetRaw(initOverrides); + return await response.value(); + } + +} diff --git a/src/api/pfdcm/generated/apis/PfdcmEnvironmentalDetailApi.ts b/src/api/pfdcm/generated/apis/PfdcmEnvironmentalDetailApi.ts new file mode 100644 index 000000000..7d001e6b4 --- /dev/null +++ b/src/api/pfdcm/generated/apis/PfdcmEnvironmentalDetailApi.ts @@ -0,0 +1,100 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * pfdcm + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 3.1.2 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +import * as runtime from '../runtime'; +import type { + AboutModel, + HTTPValidationError, + HelloModel, +} from '../models/index'; +import { + AboutModelFromJSON, + AboutModelToJSON, + HTTPValidationErrorFromJSON, + HTTPValidationErrorToJSON, + HelloModelFromJSON, + HelloModelToJSON, +} from '../models/index'; + +export interface ReadHelloApiV1HelloGetRequest { + echoBack?: string; +} + +/** + * + */ +export class PfdcmEnvironmentalDetailApi extends runtime.BaseAPI { + + /** + * A description of this service. + * Read About + */ + async readAboutApiV1AboutGetRaw(initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise> { + const queryParameters: any = {}; + + const headerParameters: runtime.HTTPHeaders = {}; + + const response = await this.request({ + path: `/api/v1/about/`, + method: 'GET', + headers: headerParameters, + query: queryParameters, + }, initOverrides); + + return new runtime.JSONApiResponse(response, (jsonValue) => AboutModelFromJSON(jsonValue)); + } + + /** + * A description of this service. + * Read About + */ + async readAboutApiV1AboutGet(initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise { + const response = await this.readAboutApiV1AboutGetRaw(initOverrides); + return await response.value(); + } + + /** + * Produce some information like the OG pfcon + * Read Hello + */ + async readHelloApiV1HelloGetRaw(requestParameters: ReadHelloApiV1HelloGetRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise> { + const queryParameters: any = {}; + + if (requestParameters['echoBack'] != null) { + queryParameters['echoBack'] = requestParameters['echoBack']; + } + + const headerParameters: runtime.HTTPHeaders = {}; + + const response = await this.request({ + path: `/api/v1/hello/`, + method: 'GET', + headers: headerParameters, + query: queryParameters, + }, initOverrides); + + return new runtime.JSONApiResponse(response, (jsonValue) => HelloModelFromJSON(jsonValue)); + } + + /** + * Produce some information like the OG pfcon + * Read Hello + */ + async readHelloApiV1HelloGet(requestParameters: ReadHelloApiV1HelloGetRequest = {}, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise { + const response = await this.readHelloApiV1HelloGetRaw(requestParameters, initOverrides); + return await response.value(); + } + +} diff --git a/src/api/pfdcm/generated/apis/SMDBSetupServicesApi.ts b/src/api/pfdcm/generated/apis/SMDBSetupServicesApi.ts new file mode 100644 index 000000000..555438937 --- /dev/null +++ b/src/api/pfdcm/generated/apis/SMDBSetupServicesApi.ts @@ -0,0 +1,305 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * pfdcm + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 3.1.2 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +import * as runtime from '../runtime'; +import type { + HTTPValidationError, + SMDBFsConfig, + SMDBFsReturnModel, + SMDBcubeConfig, + SMDBcubeReturnModel, + SMDBswiftConfig, +} from '../models/index'; +import { + HTTPValidationErrorFromJSON, + HTTPValidationErrorToJSON, + SMDBFsConfigFromJSON, + SMDBFsConfigToJSON, + SMDBFsReturnModelFromJSON, + SMDBFsReturnModelToJSON, + SMDBcubeConfigFromJSON, + SMDBcubeConfigToJSON, + SMDBcubeReturnModelFromJSON, + SMDBcubeReturnModelToJSON, + SMDBswiftConfigFromJSON, + SMDBswiftConfigToJSON, +} from '../models/index'; + +export interface CubeResourceGetApiV1SMDBCUBECubeResourceGetRequest { + cubeResource: string; +} + +export interface SMDBobjCubeUpdateApiV1SMDBCUBEPostRequest { + sMDBcubeConfig: SMDBcubeConfig; +} + +export interface SMDBobjFsUpdateApiV1SMDBFSPostRequest { + sMDBFsConfig: SMDBFsConfig; +} + +export interface SMDBobjSwiftUpdateApiV1SMDBSwiftPostRequest { + sMDBswiftConfig: SMDBswiftConfig; +} + +export interface StorageResourceGetApiV1SMDBStorageStorageResourceGetRequest { + storageResource: string; +} + +/** + * + */ +export class SMDBSetupServicesApi extends runtime.BaseAPI { + + /** + * GET the list of configured SMDB CUBE services + * GET the list of configured SMDB CUBE services + */ + async cubeListGetApiV1SMDBCUBEListGetRaw(initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise>> { + const queryParameters: any = {}; + + const headerParameters: runtime.HTTPHeaders = {}; + + const response = await this.request({ + path: `/api/v1/SMDB/CUBE/list/`, + method: 'GET', + headers: headerParameters, + query: queryParameters, + }, initOverrides); + + return new runtime.JSONApiResponse(response); + } + + /** + * GET the list of configured SMDB CUBE services + * GET the list of configured SMDB CUBE services + */ + async cubeListGetApiV1SMDBCUBEListGet(initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise> { + const response = await this.cubeListGetApiV1SMDBCUBEListGetRaw(initOverrides); + return await response.value(); + } + + /** + * GET detail info on a given SMDB CUBE resource + * GET detail on a specific CUBE resource + */ + async cubeResourceGetApiV1SMDBCUBECubeResourceGetRaw(requestParameters: CubeResourceGetApiV1SMDBCUBECubeResourceGetRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise> { + if (requestParameters['cubeResource'] == null) { + throw new runtime.RequiredError( + 'cubeResource', + 'Required parameter "cubeResource" was null or undefined when calling cubeResourceGetApiV1SMDBCUBECubeResourceGet().' + ); + } + + const queryParameters: any = {}; + + const headerParameters: runtime.HTTPHeaders = {}; + + const response = await this.request({ + path: `/api/v1/SMDB/CUBE/{cubeResource}/`.replace(`{${"cubeResource"}}`, encodeURIComponent(String(requestParameters['cubeResource']))), + method: 'GET', + headers: headerParameters, + query: queryParameters, + }, initOverrides); + + return new runtime.JSONApiResponse(response, (jsonValue) => SMDBcubeReturnModelFromJSON(jsonValue)); + } + + /** + * GET detail info on a given SMDB CUBE resource + * GET detail on a specific CUBE resource + */ + async cubeResourceGetApiV1SMDBCUBECubeResourceGet(requestParameters: CubeResourceGetApiV1SMDBCUBECubeResourceGetRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise { + const response = await this.cubeResourceGetApiV1SMDBCUBECubeResourceGetRaw(requestParameters, initOverrides); + return await response.value(); + } + + /** + * Update a CUBE resource within the SMDB module. Parameters ---------- - `swiftData`: an object with a name that defines a CUBE resource Return ------ - updated `CUBEdata` + * POST an update to a CUBE resource in the pypx SMDB object + */ + async sMDBobjCubeUpdateApiV1SMDBCUBEPostRaw(requestParameters: SMDBobjCubeUpdateApiV1SMDBCUBEPostRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise> { + if (requestParameters['sMDBcubeConfig'] == null) { + throw new runtime.RequiredError( + 'sMDBcubeConfig', + 'Required parameter "sMDBcubeConfig" was null or undefined when calling sMDBobjCubeUpdateApiV1SMDBCUBEPost().' + ); + } + + const queryParameters: any = {}; + + const headerParameters: runtime.HTTPHeaders = {}; + + headerParameters['Content-Type'] = 'application/json'; + + const response = await this.request({ + path: `/api/v1/SMDB/CUBE/`, + method: 'POST', + headers: headerParameters, + query: queryParameters, + body: SMDBcubeConfigToJSON(requestParameters['sMDBcubeConfig']), + }, initOverrides); + + return new runtime.JSONApiResponse(response, (jsonValue) => SMDBcubeReturnModelFromJSON(jsonValue)); + } + + /** + * Update a CUBE resource within the SMDB module. Parameters ---------- - `swiftData`: an object with a name that defines a CUBE resource Return ------ - updated `CUBEdata` + * POST an update to a CUBE resource in the pypx SMDB object + */ + async sMDBobjCubeUpdateApiV1SMDBCUBEPost(requestParameters: SMDBobjCubeUpdateApiV1SMDBCUBEPostRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise { + const response = await this.sMDBobjCubeUpdateApiV1SMDBCUBEPostRaw(requestParameters, initOverrides); + return await response.value(); + } + + /** + * Update a FS storage resource within the SMDB module. Parameters ---------- - `FSData`: an object with a name that defines a swift resource Return ------ - updated `FSData` + * POST an update to a FS storage resource in the pypx SMDB object + */ + async sMDBobjFsUpdateApiV1SMDBFSPostRaw(requestParameters: SMDBobjFsUpdateApiV1SMDBFSPostRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise> { + if (requestParameters['sMDBFsConfig'] == null) { + throw new runtime.RequiredError( + 'sMDBFsConfig', + 'Required parameter "sMDBFsConfig" was null or undefined when calling sMDBobjFsUpdateApiV1SMDBFSPost().' + ); + } + + const queryParameters: any = {}; + + const headerParameters: runtime.HTTPHeaders = {}; + + headerParameters['Content-Type'] = 'application/json'; + + const response = await this.request({ + path: `/api/v1/SMDB/FS/`, + method: 'POST', + headers: headerParameters, + query: queryParameters, + body: SMDBFsConfigToJSON(requestParameters['sMDBFsConfig']), + }, initOverrides); + + return new runtime.JSONApiResponse(response, (jsonValue) => SMDBFsReturnModelFromJSON(jsonValue)); + } + + /** + * Update a FS storage resource within the SMDB module. Parameters ---------- - `FSData`: an object with a name that defines a swift resource Return ------ - updated `FSData` + * POST an update to a FS storage resource in the pypx SMDB object + */ + async sMDBobjFsUpdateApiV1SMDBFSPost(requestParameters: SMDBobjFsUpdateApiV1SMDBFSPostRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise { + const response = await this.sMDBobjFsUpdateApiV1SMDBFSPostRaw(requestParameters, initOverrides); + return await response.value(); + } + + /** + * Update a swift resource within the SMDB module. Parameters ---------- - `swiftData`: an object with a name that defines a swift resource Return ------ - updated `swiftData` + * POST an update to a swift resource in the pypx SMDB object + */ + async sMDBobjSwiftUpdateApiV1SMDBSwiftPostRaw(requestParameters: SMDBobjSwiftUpdateApiV1SMDBSwiftPostRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise> { + if (requestParameters['sMDBswiftConfig'] == null) { + throw new runtime.RequiredError( + 'sMDBswiftConfig', + 'Required parameter "sMDBswiftConfig" was null or undefined when calling sMDBobjSwiftUpdateApiV1SMDBSwiftPost().' + ); + } + + const queryParameters: any = {}; + + const headerParameters: runtime.HTTPHeaders = {}; + + headerParameters['Content-Type'] = 'application/json'; + + const response = await this.request({ + path: `/api/v1/SMDB/swift/`, + method: 'POST', + headers: headerParameters, + query: queryParameters, + body: SMDBswiftConfigToJSON(requestParameters['sMDBswiftConfig']), + }, initOverrides); + + return new runtime.JSONApiResponse(response); + } + + /** + * Update a swift resource within the SMDB module. Parameters ---------- - `swiftData`: an object with a name that defines a swift resource Return ------ - updated `swiftData` + * POST an update to a swift resource in the pypx SMDB object + */ + async sMDBobjSwiftUpdateApiV1SMDBSwiftPost(requestParameters: SMDBobjSwiftUpdateApiV1SMDBSwiftPostRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise { + const response = await this.sMDBobjSwiftUpdateApiV1SMDBSwiftPostRaw(requestParameters, initOverrides); + return await response.value(); + } + + /** + * GET the list of configured SMDB storage services + * GET the list of configured SMDB storage services + */ + async storageListGetApiV1SMDBStorageListGetRaw(initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise>> { + const queryParameters: any = {}; + + const headerParameters: runtime.HTTPHeaders = {}; + + const response = await this.request({ + path: `/api/v1/SMDB/storage/list/`, + method: 'GET', + headers: headerParameters, + query: queryParameters, + }, initOverrides); + + return new runtime.JSONApiResponse(response); + } + + /** + * GET the list of configured SMDB storage services + * GET the list of configured SMDB storage services + */ + async storageListGetApiV1SMDBStorageListGet(initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise> { + const response = await this.storageListGetApiV1SMDBStorageListGetRaw(initOverrides); + return await response.value(); + } + + /** + * GET detail info on a given SMDB storage resource + * GET detail on a specific storage resource + */ + async storageResourceGetApiV1SMDBStorageStorageResourceGetRaw(requestParameters: StorageResourceGetApiV1SMDBStorageStorageResourceGetRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise> { + if (requestParameters['storageResource'] == null) { + throw new runtime.RequiredError( + 'storageResource', + 'Required parameter "storageResource" was null or undefined when calling storageResourceGetApiV1SMDBStorageStorageResourceGet().' + ); + } + + const queryParameters: any = {}; + + const headerParameters: runtime.HTTPHeaders = {}; + + const response = await this.request({ + path: `/api/v1/SMDB/storage/{storageResource}/`.replace(`{${"storageResource"}}`, encodeURIComponent(String(requestParameters['storageResource']))), + method: 'GET', + headers: headerParameters, + query: queryParameters, + }, initOverrides); + + return new runtime.JSONApiResponse(response); + } + + /** + * GET detail info on a given SMDB storage resource + * GET detail on a specific storage resource + */ + async storageResourceGetApiV1SMDBStorageStorageResourceGet(requestParameters: StorageResourceGetApiV1SMDBStorageStorageResourceGetRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise { + const response = await this.storageResourceGetApiV1SMDBStorageStorageResourceGetRaw(requestParameters, initOverrides); + return await response.value(); + } + +} diff --git a/src/api/pfdcm/generated/apis/index.ts b/src/api/pfdcm/generated/apis/index.ts new file mode 100644 index 000000000..4c1bb7d95 --- /dev/null +++ b/src/api/pfdcm/generated/apis/index.ts @@ -0,0 +1,8 @@ +/* tslint:disable */ +/* eslint-disable */ +export * from './DicomApi'; +export * from './ListenerSubsystemServicesApi'; +export * from './PACSQRServicesApi'; +export * from './PACSSetupServicesApi'; +export * from './PfdcmEnvironmentalDetailApi'; +export * from './SMDBSetupServicesApi'; diff --git a/src/api/pfdcm/generated/index.ts b/src/api/pfdcm/generated/index.ts new file mode 100644 index 000000000..bebe8bbbe --- /dev/null +++ b/src/api/pfdcm/generated/index.ts @@ -0,0 +1,5 @@ +/* tslint:disable */ +/* eslint-disable */ +export * from './runtime'; +export * from './apis/index'; +export * from './models/index'; diff --git a/src/api/pfdcm/generated/models/AboutModel.ts b/src/api/pfdcm/generated/models/AboutModel.ts new file mode 100644 index 000000000..cc372b602 --- /dev/null +++ b/src/api/pfdcm/generated/models/AboutModel.ts @@ -0,0 +1,76 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * pfdcm + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 3.1.2 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { mapValues } from '../runtime'; +/** + * + * @export + * @interface AboutModel + */ +export interface AboutModel { + /** + * + * @type {string} + * @memberof AboutModel + */ + name?: string; + /** + * + * @type {string} + * @memberof AboutModel + */ + about?: string; + /** + * + * @type {string} + * @memberof AboutModel + */ + version?: string; +} + +/** + * Check if a given object implements the AboutModel interface. + */ +export function instanceOfAboutModel(value: object): value is AboutModel { + return true; +} + +export function AboutModelFromJSON(json: any): AboutModel { + return AboutModelFromJSONTyped(json, false); +} + +export function AboutModelFromJSONTyped(json: any, ignoreDiscriminator: boolean): AboutModel { + if (json == null) { + return json; + } + return { + + 'name': json['name'] == null ? undefined : json['name'], + 'about': json['about'] == null ? undefined : json['about'], + 'version': json['version'] == null ? undefined : json['version'], + }; +} + +export function AboutModelToJSON(value?: AboutModel | null): any { + if (value == null) { + return value; + } + return { + + 'name': value['name'], + 'about': value['about'], + 'version': value['version'], + }; +} + diff --git a/src/api/pfdcm/generated/models/BodyPACSPypxApiV1PACSSyncPypxPost.ts b/src/api/pfdcm/generated/models/BodyPACSPypxApiV1PACSSyncPypxPost.ts new file mode 100644 index 000000000..fc5ef707a --- /dev/null +++ b/src/api/pfdcm/generated/models/BodyPACSPypxApiV1PACSSyncPypxPost.ts @@ -0,0 +1,92 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * pfdcm + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 3.1.2 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { mapValues } from '../runtime'; +import type { PACSqueryCore } from './PACSqueryCore'; +import { + PACSqueryCoreFromJSON, + PACSqueryCoreFromJSONTyped, + PACSqueryCoreToJSON, +} from './PACSqueryCore'; +import type { ModelsPacsQRmodelValueStr } from './ModelsPacsQRmodelValueStr'; +import { + ModelsPacsQRmodelValueStrFromJSON, + ModelsPacsQRmodelValueStrFromJSONTyped, + ModelsPacsQRmodelValueStrToJSON, +} from './ModelsPacsQRmodelValueStr'; + +/** + * + * @export + * @interface BodyPACSPypxApiV1PACSSyncPypxPost + */ +export interface BodyPACSPypxApiV1PACSSyncPypxPost { + /** + * + * @type {ModelsPacsQRmodelValueStr} + * @memberof BodyPACSPypxApiV1PACSSyncPypxPost + */ + pACSservice: ModelsPacsQRmodelValueStr; + /** + * + * @type {ModelsPacsQRmodelValueStr} + * @memberof BodyPACSPypxApiV1PACSSyncPypxPost + */ + listenerService: ModelsPacsQRmodelValueStr; + /** + * + * @type {PACSqueryCore} + * @memberof BodyPACSPypxApiV1PACSSyncPypxPost + */ + pACSdirective: PACSqueryCore; +} + +/** + * Check if a given object implements the BodyPACSPypxApiV1PACSSyncPypxPost interface. + */ +export function instanceOfBodyPACSPypxApiV1PACSSyncPypxPost(value: object): value is BodyPACSPypxApiV1PACSSyncPypxPost { + if (!('pACSservice' in value) || value['pACSservice'] === undefined) return false; + if (!('listenerService' in value) || value['listenerService'] === undefined) return false; + if (!('pACSdirective' in value) || value['pACSdirective'] === undefined) return false; + return true; +} + +export function BodyPACSPypxApiV1PACSSyncPypxPostFromJSON(json: any): BodyPACSPypxApiV1PACSSyncPypxPost { + return BodyPACSPypxApiV1PACSSyncPypxPostFromJSONTyped(json, false); +} + +export function BodyPACSPypxApiV1PACSSyncPypxPostFromJSONTyped(json: any, ignoreDiscriminator: boolean): BodyPACSPypxApiV1PACSSyncPypxPost { + if (json == null) { + return json; + } + return { + + 'pACSservice': ModelsPacsQRmodelValueStrFromJSON(json['PACSservice']), + 'listenerService': ModelsPacsQRmodelValueStrFromJSON(json['listenerService']), + 'pACSdirective': PACSqueryCoreFromJSON(json['PACSdirective']), + }; +} + +export function BodyPACSPypxApiV1PACSSyncPypxPostToJSON(value?: BodyPACSPypxApiV1PACSSyncPypxPost | null): any { + if (value == null) { + return value; + } + return { + + 'PACSservice': ModelsPacsQRmodelValueStrToJSON(value['pACSservice']), + 'listenerService': ModelsPacsQRmodelValueStrToJSON(value['listenerService']), + 'PACSdirective': PACSqueryCoreToJSON(value['pACSdirective']), + }; +} + diff --git a/src/api/pfdcm/generated/models/BodyPACSServiceHandlerApiV1PACSThreadPypxPost.ts b/src/api/pfdcm/generated/models/BodyPACSServiceHandlerApiV1PACSThreadPypxPost.ts new file mode 100644 index 000000000..dd9b1085f --- /dev/null +++ b/src/api/pfdcm/generated/models/BodyPACSServiceHandlerApiV1PACSThreadPypxPost.ts @@ -0,0 +1,92 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * pfdcm + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 3.1.2 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { mapValues } from '../runtime'; +import type { PACSqueryCore } from './PACSqueryCore'; +import { + PACSqueryCoreFromJSON, + PACSqueryCoreFromJSONTyped, + PACSqueryCoreToJSON, +} from './PACSqueryCore'; +import type { ModelsPacsQRmodelValueStr } from './ModelsPacsQRmodelValueStr'; +import { + ModelsPacsQRmodelValueStrFromJSON, + ModelsPacsQRmodelValueStrFromJSONTyped, + ModelsPacsQRmodelValueStrToJSON, +} from './ModelsPacsQRmodelValueStr'; + +/** + * + * @export + * @interface BodyPACSServiceHandlerApiV1PACSThreadPypxPost + */ +export interface BodyPACSServiceHandlerApiV1PACSThreadPypxPost { + /** + * + * @type {ModelsPacsQRmodelValueStr} + * @memberof BodyPACSServiceHandlerApiV1PACSThreadPypxPost + */ + pACSservice: ModelsPacsQRmodelValueStr; + /** + * + * @type {ModelsPacsQRmodelValueStr} + * @memberof BodyPACSServiceHandlerApiV1PACSThreadPypxPost + */ + listenerService: ModelsPacsQRmodelValueStr; + /** + * + * @type {PACSqueryCore} + * @memberof BodyPACSServiceHandlerApiV1PACSThreadPypxPost + */ + pACSdirective: PACSqueryCore; +} + +/** + * Check if a given object implements the BodyPACSServiceHandlerApiV1PACSThreadPypxPost interface. + */ +export function instanceOfBodyPACSServiceHandlerApiV1PACSThreadPypxPost(value: object): value is BodyPACSServiceHandlerApiV1PACSThreadPypxPost { + if (!('pACSservice' in value) || value['pACSservice'] === undefined) return false; + if (!('listenerService' in value) || value['listenerService'] === undefined) return false; + if (!('pACSdirective' in value) || value['pACSdirective'] === undefined) return false; + return true; +} + +export function BodyPACSServiceHandlerApiV1PACSThreadPypxPostFromJSON(json: any): BodyPACSServiceHandlerApiV1PACSThreadPypxPost { + return BodyPACSServiceHandlerApiV1PACSThreadPypxPostFromJSONTyped(json, false); +} + +export function BodyPACSServiceHandlerApiV1PACSThreadPypxPostFromJSONTyped(json: any, ignoreDiscriminator: boolean): BodyPACSServiceHandlerApiV1PACSThreadPypxPost { + if (json == null) { + return json; + } + return { + + 'pACSservice': ModelsPacsQRmodelValueStrFromJSON(json['PACSservice']), + 'listenerService': ModelsPacsQRmodelValueStrFromJSON(json['listenerService']), + 'pACSdirective': PACSqueryCoreFromJSON(json['PACSdirective']), + }; +} + +export function BodyPACSServiceHandlerApiV1PACSThreadPypxPostToJSON(value?: BodyPACSServiceHandlerApiV1PACSThreadPypxPost | null): any { + if (value == null) { + return value; + } + return { + + 'PACSservice': ModelsPacsQRmodelValueStrToJSON(value['pACSservice']), + 'listenerService': ModelsPacsQRmodelValueStrToJSON(value['listenerService']), + 'PACSdirective': PACSqueryCoreToJSON(value['pACSdirective']), + }; +} + diff --git a/src/api/pfdcm/generated/models/BodyPACSobjPortUpdateApiV1PACSservicePortPost.ts b/src/api/pfdcm/generated/models/BodyPACSobjPortUpdateApiV1PACSservicePortPost.ts new file mode 100644 index 000000000..b139ee347 --- /dev/null +++ b/src/api/pfdcm/generated/models/BodyPACSobjPortUpdateApiV1PACSservicePortPost.ts @@ -0,0 +1,77 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * pfdcm + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 3.1.2 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { mapValues } from '../runtime'; +import type { ModelsPacsSetupModelValueStr } from './ModelsPacsSetupModelValueStr'; +import { + ModelsPacsSetupModelValueStrFromJSON, + ModelsPacsSetupModelValueStrFromJSONTyped, + ModelsPacsSetupModelValueStrToJSON, +} from './ModelsPacsSetupModelValueStr'; + +/** + * + * @export + * @interface BodyPACSobjPortUpdateApiV1PACSservicePortPost + */ +export interface BodyPACSobjPortUpdateApiV1PACSservicePortPost { + /** + * + * @type {ModelsPacsSetupModelValueStr} + * @memberof BodyPACSobjPortUpdateApiV1PACSservicePortPost + */ + objToUpdate: ModelsPacsSetupModelValueStr; + /** + * + * @type {ModelsPacsSetupModelValueStr} + * @memberof BodyPACSobjPortUpdateApiV1PACSservicePortPost + */ + newPort: ModelsPacsSetupModelValueStr; +} + +/** + * Check if a given object implements the BodyPACSobjPortUpdateApiV1PACSservicePortPost interface. + */ +export function instanceOfBodyPACSobjPortUpdateApiV1PACSservicePortPost(value: object): value is BodyPACSobjPortUpdateApiV1PACSservicePortPost { + if (!('objToUpdate' in value) || value['objToUpdate'] === undefined) return false; + if (!('newPort' in value) || value['newPort'] === undefined) return false; + return true; +} + +export function BodyPACSobjPortUpdateApiV1PACSservicePortPostFromJSON(json: any): BodyPACSobjPortUpdateApiV1PACSservicePortPost { + return BodyPACSobjPortUpdateApiV1PACSservicePortPostFromJSONTyped(json, false); +} + +export function BodyPACSobjPortUpdateApiV1PACSservicePortPostFromJSONTyped(json: any, ignoreDiscriminator: boolean): BodyPACSobjPortUpdateApiV1PACSservicePortPost { + if (json == null) { + return json; + } + return { + + 'objToUpdate': ModelsPacsSetupModelValueStrFromJSON(json['objToUpdate']), + 'newPort': ModelsPacsSetupModelValueStrFromJSON(json['newPort']), + }; +} + +export function BodyPACSobjPortUpdateApiV1PACSservicePortPostToJSON(value?: BodyPACSobjPortUpdateApiV1PACSservicePortPost | null): any { + if (value == null) { + return value; + } + return { + + 'objToUpdate': ModelsPacsSetupModelValueStrToJSON(value['objToUpdate']), + 'newPort': ModelsPacsSetupModelValueStrToJSON(value['newPort']), + }; +} + diff --git a/src/api/pfdcm/generated/models/DcmtkCore.ts b/src/api/pfdcm/generated/models/DcmtkCore.ts new file mode 100644 index 000000000..dfc5011f1 --- /dev/null +++ b/src/api/pfdcm/generated/models/DcmtkCore.ts @@ -0,0 +1,106 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * pfdcm + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 3.1.2 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { mapValues } from '../runtime'; +/** + * + * @export + * @interface DcmtkCore + */ +export interface DcmtkCore { + /** + * + * @type {string} + * @memberof DcmtkCore + */ + storescu: string; + /** + * + * @type {string} + * @memberof DcmtkCore + */ + storescp: string; + /** + * + * @type {string} + * @memberof DcmtkCore + */ + findscu: string; + /** + * + * @type {string} + * @memberof DcmtkCore + */ + movescu: string; + /** + * + * @type {string} + * @memberof DcmtkCore + */ + echoscu: string; + /** + * + * @type {string} + * @memberof DcmtkCore + */ + receiver: string; +} + +/** + * Check if a given object implements the DcmtkCore interface. + */ +export function instanceOfDcmtkCore(value: object): value is DcmtkCore { + if (!('storescu' in value) || value['storescu'] === undefined) return false; + if (!('storescp' in value) || value['storescp'] === undefined) return false; + if (!('findscu' in value) || value['findscu'] === undefined) return false; + if (!('movescu' in value) || value['movescu'] === undefined) return false; + if (!('echoscu' in value) || value['echoscu'] === undefined) return false; + if (!('receiver' in value) || value['receiver'] === undefined) return false; + return true; +} + +export function DcmtkCoreFromJSON(json: any): DcmtkCore { + return DcmtkCoreFromJSONTyped(json, false); +} + +export function DcmtkCoreFromJSONTyped(json: any, ignoreDiscriminator: boolean): DcmtkCore { + if (json == null) { + return json; + } + return { + + 'storescu': json['storescu'], + 'storescp': json['storescp'], + 'findscu': json['findscu'], + 'movescu': json['movescu'], + 'echoscu': json['echoscu'], + 'receiver': json['receiver'], + }; +} + +export function DcmtkCoreToJSON(value?: DcmtkCore | null): any { + if (value == null) { + return value; + } + return { + + 'storescu': value['storescu'], + 'storescp': value['storescp'], + 'findscu': value['findscu'], + 'movescu': value['movescu'], + 'echoscu': value['echoscu'], + 'receiver': value['receiver'], + }; +} + diff --git a/src/api/pfdcm/generated/models/DcmtkDBPutModel.ts b/src/api/pfdcm/generated/models/DcmtkDBPutModel.ts new file mode 100644 index 000000000..5f3dd82ae --- /dev/null +++ b/src/api/pfdcm/generated/models/DcmtkDBPutModel.ts @@ -0,0 +1,68 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * pfdcm + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 3.1.2 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { mapValues } from '../runtime'; +import type { DcmtkCore } from './DcmtkCore'; +import { + DcmtkCoreFromJSON, + DcmtkCoreFromJSONTyped, + DcmtkCoreToJSON, +} from './DcmtkCore'; + +/** + * + * @export + * @interface DcmtkDBPutModel + */ +export interface DcmtkDBPutModel { + /** + * + * @type {DcmtkCore} + * @memberof DcmtkDBPutModel + */ + info: DcmtkCore; +} + +/** + * Check if a given object implements the DcmtkDBPutModel interface. + */ +export function instanceOfDcmtkDBPutModel(value: object): value is DcmtkDBPutModel { + if (!('info' in value) || value['info'] === undefined) return false; + return true; +} + +export function DcmtkDBPutModelFromJSON(json: any): DcmtkDBPutModel { + return DcmtkDBPutModelFromJSONTyped(json, false); +} + +export function DcmtkDBPutModelFromJSONTyped(json: any, ignoreDiscriminator: boolean): DcmtkDBPutModel { + if (json == null) { + return json; + } + return { + + 'info': DcmtkCoreFromJSON(json['info']), + }; +} + +export function DcmtkDBPutModelToJSON(value?: DcmtkDBPutModel | null): any { + if (value == null) { + return value; + } + return { + + 'info': DcmtkCoreToJSON(value['info']), + }; +} + diff --git a/src/api/pfdcm/generated/models/DcmtkDBReturnModel.ts b/src/api/pfdcm/generated/models/DcmtkDBReturnModel.ts new file mode 100644 index 000000000..ca92687ef --- /dev/null +++ b/src/api/pfdcm/generated/models/DcmtkDBReturnModel.ts @@ -0,0 +1,101 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * pfdcm + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 3.1.2 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { mapValues } from '../runtime'; +import type { ModelsListenerModelTime } from './ModelsListenerModelTime'; +import { + ModelsListenerModelTimeFromJSON, + ModelsListenerModelTimeFromJSONTyped, + ModelsListenerModelTimeToJSON, +} from './ModelsListenerModelTime'; +import type { DcmtkCore } from './DcmtkCore'; +import { + DcmtkCoreFromJSON, + DcmtkCoreFromJSONTyped, + DcmtkCoreToJSON, +} from './DcmtkCore'; + +/** + * + * @export + * @interface DcmtkDBReturnModel + */ +export interface DcmtkDBReturnModel { + /** + * + * @type {DcmtkCore} + * @memberof DcmtkDBReturnModel + */ + info: DcmtkCore; + /** + * + * @type {ModelsListenerModelTime} + * @memberof DcmtkDBReturnModel + */ + timeCreated: ModelsListenerModelTime; + /** + * + * @type {ModelsListenerModelTime} + * @memberof DcmtkDBReturnModel + */ + timeModified: ModelsListenerModelTime; + /** + * + * @type {string} + * @memberof DcmtkDBReturnModel + */ + message: string; +} + +/** + * Check if a given object implements the DcmtkDBReturnModel interface. + */ +export function instanceOfDcmtkDBReturnModel(value: object): value is DcmtkDBReturnModel { + if (!('info' in value) || value['info'] === undefined) return false; + if (!('timeCreated' in value) || value['timeCreated'] === undefined) return false; + if (!('timeModified' in value) || value['timeModified'] === undefined) return false; + if (!('message' in value) || value['message'] === undefined) return false; + return true; +} + +export function DcmtkDBReturnModelFromJSON(json: any): DcmtkDBReturnModel { + return DcmtkDBReturnModelFromJSONTyped(json, false); +} + +export function DcmtkDBReturnModelFromJSONTyped(json: any, ignoreDiscriminator: boolean): DcmtkDBReturnModel { + if (json == null) { + return json; + } + return { + + 'info': DcmtkCoreFromJSON(json['info']), + 'timeCreated': ModelsListenerModelTimeFromJSON(json['time_created']), + 'timeModified': ModelsListenerModelTimeFromJSON(json['time_modified']), + 'message': json['message'], + }; +} + +export function DcmtkDBReturnModelToJSON(value?: DcmtkDBReturnModel | null): any { + if (value == null) { + return value; + } + return { + + 'info': DcmtkCoreToJSON(value['info']), + 'time_created': ModelsListenerModelTimeToJSON(value['timeCreated']), + 'time_modified': ModelsListenerModelTimeToJSON(value['timeModified']), + 'message': value['message'], + }; +} + diff --git a/src/api/pfdcm/generated/models/Dicom.ts b/src/api/pfdcm/generated/models/Dicom.ts new file mode 100644 index 000000000..8e1c840ae --- /dev/null +++ b/src/api/pfdcm/generated/models/Dicom.ts @@ -0,0 +1,60 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * pfdcm + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 3.1.2 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { mapValues } from '../runtime'; +/** + * Not actually a DICOM, just some JSON. + * @export + * @interface Dicom + */ +export interface Dicom { + /** + * A longer sentence about nothing much + * @type {string} + * @memberof Dicom + */ + aCoolPicture?: string; +} + +/** + * Check if a given object implements the Dicom interface. + */ +export function instanceOfDicom(value: object): value is Dicom { + return true; +} + +export function DicomFromJSON(json: any): Dicom { + return DicomFromJSONTyped(json, false); +} + +export function DicomFromJSONTyped(json: any, ignoreDiscriminator: boolean): Dicom { + if (json == null) { + return json; + } + return { + + 'aCoolPicture': json['a_cool_picture'] == null ? undefined : json['a_cool_picture'], + }; +} + +export function DicomToJSON(value?: Dicom | null): any { + if (value == null) { + return value; + } + return { + + 'a_cool_picture': value['aCoolPicture'], + }; +} + diff --git a/src/api/pfdcm/generated/models/EchoModel.ts b/src/api/pfdcm/generated/models/EchoModel.ts new file mode 100644 index 000000000..78c2f39d1 --- /dev/null +++ b/src/api/pfdcm/generated/models/EchoModel.ts @@ -0,0 +1,61 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * pfdcm + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 3.1.2 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { mapValues } from '../runtime'; +/** + * Simply echo back whatever is POSTed to this API endpoing + * @export + * @interface EchoModel + */ +export interface EchoModel { + /** + * + * @type {string} + * @memberof EchoModel + */ + msg: string; +} + +/** + * Check if a given object implements the EchoModel interface. + */ +export function instanceOfEchoModel(value: object): value is EchoModel { + if (!('msg' in value) || value['msg'] === undefined) return false; + return true; +} + +export function EchoModelFromJSON(json: any): EchoModel { + return EchoModelFromJSONTyped(json, false); +} + +export function EchoModelFromJSONTyped(json: any, ignoreDiscriminator: boolean): EchoModel { + if (json == null) { + return json; + } + return { + + 'msg': json['msg'], + }; +} + +export function EchoModelToJSON(value?: EchoModel | null): any { + if (value == null) { + return value; + } + return { + + 'msg': value['msg'], + }; +} + diff --git a/src/api/pfdcm/generated/models/HTTPValidationError.ts b/src/api/pfdcm/generated/models/HTTPValidationError.ts new file mode 100644 index 000000000..3547980fc --- /dev/null +++ b/src/api/pfdcm/generated/models/HTTPValidationError.ts @@ -0,0 +1,67 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * pfdcm + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 3.1.2 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { mapValues } from '../runtime'; +import type { ValidationError } from './ValidationError'; +import { + ValidationErrorFromJSON, + ValidationErrorFromJSONTyped, + ValidationErrorToJSON, +} from './ValidationError'; + +/** + * + * @export + * @interface HTTPValidationError + */ +export interface HTTPValidationError { + /** + * + * @type {Array} + * @memberof HTTPValidationError + */ + detail?: Array; +} + +/** + * Check if a given object implements the HTTPValidationError interface. + */ +export function instanceOfHTTPValidationError(value: object): value is HTTPValidationError { + return true; +} + +export function HTTPValidationErrorFromJSON(json: any): HTTPValidationError { + return HTTPValidationErrorFromJSONTyped(json, false); +} + +export function HTTPValidationErrorFromJSONTyped(json: any, ignoreDiscriminator: boolean): HTTPValidationError { + if (json == null) { + return json; + } + return { + + 'detail': json['detail'] == null ? undefined : ((json['detail'] as Array).map(ValidationErrorFromJSON)), + }; +} + +export function HTTPValidationErrorToJSON(value?: HTTPValidationError | null): any { + if (value == null) { + return value; + } + return { + + 'detail': value['detail'] == null ? undefined : ((value['detail'] as Array).map(ValidationErrorToJSON)), + }; +} + diff --git a/src/api/pfdcm/generated/models/HelloModel.ts b/src/api/pfdcm/generated/models/HelloModel.ts new file mode 100644 index 000000000..b3af2d260 --- /dev/null +++ b/src/api/pfdcm/generated/models/HelloModel.ts @@ -0,0 +1,97 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * pfdcm + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 3.1.2 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { mapValues } from '../runtime'; +import type { SysInfoModel } from './SysInfoModel'; +import { + SysInfoModelFromJSON, + SysInfoModelFromJSONTyped, + SysInfoModelToJSON, +} from './SysInfoModel'; +import type { EchoModel } from './EchoModel'; +import { + EchoModelFromJSON, + EchoModelFromJSONTyped, + EchoModelToJSON, +} from './EchoModel'; + +/** + * The model describing the relevant "hello" data + * @export + * @interface HelloModel + */ +export interface HelloModel { + /** + * + * @type {string} + * @memberof HelloModel + */ + name?: string; + /** + * + * @type {string} + * @memberof HelloModel + */ + version?: string; + /** + * + * @type {SysInfoModel} + * @memberof HelloModel + */ + sysinfo?: SysInfoModel; + /** + * + * @type {EchoModel} + * @memberof HelloModel + */ + echoBack?: EchoModel; +} + +/** + * Check if a given object implements the HelloModel interface. + */ +export function instanceOfHelloModel(value: object): value is HelloModel { + return true; +} + +export function HelloModelFromJSON(json: any): HelloModel { + return HelloModelFromJSONTyped(json, false); +} + +export function HelloModelFromJSONTyped(json: any, ignoreDiscriminator: boolean): HelloModel { + if (json == null) { + return json; + } + return { + + 'name': json['name'] == null ? undefined : json['name'], + 'version': json['version'] == null ? undefined : json['version'], + 'sysinfo': json['sysinfo'] == null ? undefined : SysInfoModelFromJSON(json['sysinfo']), + 'echoBack': json['echoBack'] == null ? undefined : EchoModelFromJSON(json['echoBack']), + }; +} + +export function HelloModelToJSON(value?: HelloModel | null): any { + if (value == null) { + return value; + } + return { + + 'name': value['name'], + 'version': value['version'], + 'sysinfo': SysInfoModelToJSON(value['sysinfo']), + 'echoBack': EchoModelToJSON(value['echoBack']), + }; +} + diff --git a/src/api/pfdcm/generated/models/ListenerDBreturnModel.ts b/src/api/pfdcm/generated/models/ListenerDBreturnModel.ts new file mode 100644 index 000000000..dd2ed6429 --- /dev/null +++ b/src/api/pfdcm/generated/models/ListenerDBreturnModel.ts @@ -0,0 +1,83 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * pfdcm + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 3.1.2 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { mapValues } from '../runtime'; +import type { XinetdDBReturnModel } from './XinetdDBReturnModel'; +import { + XinetdDBReturnModelFromJSON, + XinetdDBReturnModelFromJSONTyped, + XinetdDBReturnModelToJSON, +} from './XinetdDBReturnModel'; +import type { DcmtkDBReturnModel } from './DcmtkDBReturnModel'; +import { + DcmtkDBReturnModelFromJSON, + DcmtkDBReturnModelFromJSONTyped, + DcmtkDBReturnModelToJSON, +} from './DcmtkDBReturnModel'; + +/** + * A full model that is returned from a call to the DB + * @export + * @interface ListenerDBreturnModel + */ +export interface ListenerDBreturnModel { + /** + * + * @type {XinetdDBReturnModel} + * @memberof ListenerDBreturnModel + */ + xinetd: XinetdDBReturnModel; + /** + * + * @type {DcmtkDBReturnModel} + * @memberof ListenerDBreturnModel + */ + dcmtk: DcmtkDBReturnModel; +} + +/** + * Check if a given object implements the ListenerDBreturnModel interface. + */ +export function instanceOfListenerDBreturnModel(value: object): value is ListenerDBreturnModel { + if (!('xinetd' in value) || value['xinetd'] === undefined) return false; + if (!('dcmtk' in value) || value['dcmtk'] === undefined) return false; + return true; +} + +export function ListenerDBreturnModelFromJSON(json: any): ListenerDBreturnModel { + return ListenerDBreturnModelFromJSONTyped(json, false); +} + +export function ListenerDBreturnModelFromJSONTyped(json: any, ignoreDiscriminator: boolean): ListenerDBreturnModel { + if (json == null) { + return json; + } + return { + + 'xinetd': XinetdDBReturnModelFromJSON(json['xinetd']), + 'dcmtk': DcmtkDBReturnModelFromJSON(json['dcmtk']), + }; +} + +export function ListenerDBreturnModelToJSON(value?: ListenerDBreturnModel | null): any { + if (value == null) { + return value; + } + return { + + 'xinetd': XinetdDBReturnModelToJSON(value['xinetd']), + 'dcmtk': DcmtkDBReturnModelToJSON(value['dcmtk']), + }; +} + diff --git a/src/api/pfdcm/generated/models/ListenerHandlerStatus.ts b/src/api/pfdcm/generated/models/ListenerHandlerStatus.ts new file mode 100644 index 000000000..5e5e5d5d0 --- /dev/null +++ b/src/api/pfdcm/generated/models/ListenerHandlerStatus.ts @@ -0,0 +1,61 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * pfdcm + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 3.1.2 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { mapValues } from '../runtime'; +/** + * + * @export + * @interface ListenerHandlerStatus + */ +export interface ListenerHandlerStatus { + /** + * + * @type {boolean} + * @memberof ListenerHandlerStatus + */ + status: boolean; +} + +/** + * Check if a given object implements the ListenerHandlerStatus interface. + */ +export function instanceOfListenerHandlerStatus(value: object): value is ListenerHandlerStatus { + if (!('status' in value) || value['status'] === undefined) return false; + return true; +} + +export function ListenerHandlerStatusFromJSON(json: any): ListenerHandlerStatus { + return ListenerHandlerStatusFromJSONTyped(json, false); +} + +export function ListenerHandlerStatusFromJSONTyped(json: any, ignoreDiscriminator: boolean): ListenerHandlerStatus { + if (json == null) { + return json; + } + return { + + 'status': json['status'], + }; +} + +export function ListenerHandlerStatusToJSON(value?: ListenerHandlerStatus | null): any { + if (value == null) { + return value; + } + return { + + 'status': value['status'], + }; +} + diff --git a/src/api/pfdcm/generated/models/LocationInner.ts b/src/api/pfdcm/generated/models/LocationInner.ts new file mode 100644 index 000000000..769423a85 --- /dev/null +++ b/src/api/pfdcm/generated/models/LocationInner.ts @@ -0,0 +1,42 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * pfdcm + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 3.1.2 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { mapValues } from '../runtime'; +/** + * + * @export + * @interface LocationInner + */ +export interface LocationInner { +} + +/** + * Check if a given object implements the LocationInner interface. + */ +export function instanceOfLocationInner(value: object): value is LocationInner { + return true; +} + +export function LocationInnerFromJSON(json: any): LocationInner { + return LocationInnerFromJSONTyped(json, false); +} + +export function LocationInnerFromJSONTyped(json: any, ignoreDiscriminator: boolean): LocationInner { + return json; +} + +export function LocationInnerToJSON(value?: LocationInner | null): any { + return value; +} + diff --git a/src/api/pfdcm/generated/models/ModelsListenerModelTime.ts b/src/api/pfdcm/generated/models/ModelsListenerModelTime.ts new file mode 100644 index 000000000..65d0d5d87 --- /dev/null +++ b/src/api/pfdcm/generated/models/ModelsListenerModelTime.ts @@ -0,0 +1,61 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * pfdcm + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 3.1.2 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { mapValues } from '../runtime'; +/** + * A simple model that has a time string field + * @export + * @interface ModelsListenerModelTime + */ +export interface ModelsListenerModelTime { + /** + * + * @type {string} + * @memberof ModelsListenerModelTime + */ + time: string; +} + +/** + * Check if a given object implements the ModelsListenerModelTime interface. + */ +export function instanceOfModelsListenerModelTime(value: object): value is ModelsListenerModelTime { + if (!('time' in value) || value['time'] === undefined) return false; + return true; +} + +export function ModelsListenerModelTimeFromJSON(json: any): ModelsListenerModelTime { + return ModelsListenerModelTimeFromJSONTyped(json, false); +} + +export function ModelsListenerModelTimeFromJSONTyped(json: any, ignoreDiscriminator: boolean): ModelsListenerModelTime { + if (json == null) { + return json; + } + return { + + 'time': json['time'], + }; +} + +export function ModelsListenerModelTimeToJSON(value?: ModelsListenerModelTime | null): any { + if (value == null) { + return value; + } + return { + + 'time': value['time'], + }; +} + diff --git a/src/api/pfdcm/generated/models/ModelsListenerModelValueStr.ts b/src/api/pfdcm/generated/models/ModelsListenerModelValueStr.ts new file mode 100644 index 000000000..38aa2a83a --- /dev/null +++ b/src/api/pfdcm/generated/models/ModelsListenerModelValueStr.ts @@ -0,0 +1,60 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * pfdcm + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 3.1.2 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { mapValues } from '../runtime'; +/** + * + * @export + * @interface ModelsListenerModelValueStr + */ +export interface ModelsListenerModelValueStr { + /** + * + * @type {string} + * @memberof ModelsListenerModelValueStr + */ + value?: string; +} + +/** + * Check if a given object implements the ModelsListenerModelValueStr interface. + */ +export function instanceOfModelsListenerModelValueStr(value: object): value is ModelsListenerModelValueStr { + return true; +} + +export function ModelsListenerModelValueStrFromJSON(json: any): ModelsListenerModelValueStr { + return ModelsListenerModelValueStrFromJSONTyped(json, false); +} + +export function ModelsListenerModelValueStrFromJSONTyped(json: any, ignoreDiscriminator: boolean): ModelsListenerModelValueStr { + if (json == null) { + return json; + } + return { + + 'value': json['value'] == null ? undefined : json['value'], + }; +} + +export function ModelsListenerModelValueStrToJSON(value?: ModelsListenerModelValueStr | null): any { + if (value == null) { + return value; + } + return { + + 'value': value['value'], + }; +} + diff --git a/src/api/pfdcm/generated/models/ModelsPacsQRmodelValueStr.ts b/src/api/pfdcm/generated/models/ModelsPacsQRmodelValueStr.ts new file mode 100644 index 000000000..19f0bb8a9 --- /dev/null +++ b/src/api/pfdcm/generated/models/ModelsPacsQRmodelValueStr.ts @@ -0,0 +1,60 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * pfdcm + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 3.1.2 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { mapValues } from '../runtime'; +/** + * + * @export + * @interface ModelsPacsQRmodelValueStr + */ +export interface ModelsPacsQRmodelValueStr { + /** + * + * @type {string} + * @memberof ModelsPacsQRmodelValueStr + */ + value?: string; +} + +/** + * Check if a given object implements the ModelsPacsQRmodelValueStr interface. + */ +export function instanceOfModelsPacsQRmodelValueStr(value: object): value is ModelsPacsQRmodelValueStr { + return true; +} + +export function ModelsPacsQRmodelValueStrFromJSON(json: any): ModelsPacsQRmodelValueStr { + return ModelsPacsQRmodelValueStrFromJSONTyped(json, false); +} + +export function ModelsPacsQRmodelValueStrFromJSONTyped(json: any, ignoreDiscriminator: boolean): ModelsPacsQRmodelValueStr { + if (json == null) { + return json; + } + return { + + 'value': json['value'] == null ? undefined : json['value'], + }; +} + +export function ModelsPacsQRmodelValueStrToJSON(value?: ModelsPacsQRmodelValueStr | null): any { + if (value == null) { + return value; + } + return { + + 'value': value['value'], + }; +} + diff --git a/src/api/pfdcm/generated/models/ModelsPacsSetupModelTime.ts b/src/api/pfdcm/generated/models/ModelsPacsSetupModelTime.ts new file mode 100644 index 000000000..ab5e686aa --- /dev/null +++ b/src/api/pfdcm/generated/models/ModelsPacsSetupModelTime.ts @@ -0,0 +1,61 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * pfdcm + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 3.1.2 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { mapValues } from '../runtime'; +/** + * A simple model that has a time string field + * @export + * @interface ModelsPacsSetupModelTime + */ +export interface ModelsPacsSetupModelTime { + /** + * + * @type {string} + * @memberof ModelsPacsSetupModelTime + */ + time: string; +} + +/** + * Check if a given object implements the ModelsPacsSetupModelTime interface. + */ +export function instanceOfModelsPacsSetupModelTime(value: object): value is ModelsPacsSetupModelTime { + if (!('time' in value) || value['time'] === undefined) return false; + return true; +} + +export function ModelsPacsSetupModelTimeFromJSON(json: any): ModelsPacsSetupModelTime { + return ModelsPacsSetupModelTimeFromJSONTyped(json, false); +} + +export function ModelsPacsSetupModelTimeFromJSONTyped(json: any, ignoreDiscriminator: boolean): ModelsPacsSetupModelTime { + if (json == null) { + return json; + } + return { + + 'time': json['time'], + }; +} + +export function ModelsPacsSetupModelTimeToJSON(value?: ModelsPacsSetupModelTime | null): any { + if (value == null) { + return value; + } + return { + + 'time': value['time'], + }; +} + diff --git a/src/api/pfdcm/generated/models/ModelsPacsSetupModelValueStr.ts b/src/api/pfdcm/generated/models/ModelsPacsSetupModelValueStr.ts new file mode 100644 index 000000000..ba3f1ff20 --- /dev/null +++ b/src/api/pfdcm/generated/models/ModelsPacsSetupModelValueStr.ts @@ -0,0 +1,60 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * pfdcm + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 3.1.2 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { mapValues } from '../runtime'; +/** + * + * @export + * @interface ModelsPacsSetupModelValueStr + */ +export interface ModelsPacsSetupModelValueStr { + /** + * + * @type {string} + * @memberof ModelsPacsSetupModelValueStr + */ + value?: string; +} + +/** + * Check if a given object implements the ModelsPacsSetupModelValueStr interface. + */ +export function instanceOfModelsPacsSetupModelValueStr(value: object): value is ModelsPacsSetupModelValueStr { + return true; +} + +export function ModelsPacsSetupModelValueStrFromJSON(json: any): ModelsPacsSetupModelValueStr { + return ModelsPacsSetupModelValueStrFromJSONTyped(json, false); +} + +export function ModelsPacsSetupModelValueStrFromJSONTyped(json: any, ignoreDiscriminator: boolean): ModelsPacsSetupModelValueStr { + if (json == null) { + return json; + } + return { + + 'value': json['value'] == null ? undefined : json['value'], + }; +} + +export function ModelsPacsSetupModelValueStrToJSON(value?: ModelsPacsSetupModelValueStr | null): any { + if (value == null) { + return value; + } + return { + + 'value': value['value'], + }; +} + diff --git a/src/api/pfdcm/generated/models/ModelsSmdbSetupModelValueStr.ts b/src/api/pfdcm/generated/models/ModelsSmdbSetupModelValueStr.ts new file mode 100644 index 000000000..169f10f83 --- /dev/null +++ b/src/api/pfdcm/generated/models/ModelsSmdbSetupModelValueStr.ts @@ -0,0 +1,60 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * pfdcm + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 3.1.2 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { mapValues } from '../runtime'; +/** + * + * @export + * @interface ModelsSmdbSetupModelValueStr + */ +export interface ModelsSmdbSetupModelValueStr { + /** + * + * @type {string} + * @memberof ModelsSmdbSetupModelValueStr + */ + value?: string; +} + +/** + * Check if a given object implements the ModelsSmdbSetupModelValueStr interface. + */ +export function instanceOfModelsSmdbSetupModelValueStr(value: object): value is ModelsSmdbSetupModelValueStr { + return true; +} + +export function ModelsSmdbSetupModelValueStrFromJSON(json: any): ModelsSmdbSetupModelValueStr { + return ModelsSmdbSetupModelValueStrFromJSONTyped(json, false); +} + +export function ModelsSmdbSetupModelValueStrFromJSONTyped(json: any, ignoreDiscriminator: boolean): ModelsSmdbSetupModelValueStr { + if (json == null) { + return json; + } + return { + + 'value': json['value'] == null ? undefined : json['value'], + }; +} + +export function ModelsSmdbSetupModelValueStrToJSON(value?: ModelsSmdbSetupModelValueStr | null): any { + if (value == null) { + return value; + } + return { + + 'value': value['value'], + }; +} + diff --git a/src/api/pfdcm/generated/models/PACSasync.ts b/src/api/pfdcm/generated/models/PACSasync.ts new file mode 100644 index 000000000..ba5a89467 --- /dev/null +++ b/src/api/pfdcm/generated/models/PACSasync.ts @@ -0,0 +1,87 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * pfdcm + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 3.1.2 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { mapValues } from '../runtime'; +/** + * A model returned when an async PACS directive is indicated + * @export + * @interface PACSasync + */ +export interface PACSasync { + /** + * + * @type {string} + * @memberof PACSasync + */ + directiveType?: string; + /** + * + * @type {object} + * @memberof PACSasync + */ + response: object; + /** + * + * @type {string} + * @memberof PACSasync + */ + timestamp: string; + /** + * + * @type {object} + * @memberof PACSasync + */ + pACSdirective: object; +} + +/** + * Check if a given object implements the PACSasync interface. + */ +export function instanceOfPACSasync(value: object): value is PACSasync { + if (!('response' in value) || value['response'] === undefined) return false; + if (!('timestamp' in value) || value['timestamp'] === undefined) return false; + if (!('pACSdirective' in value) || value['pACSdirective'] === undefined) return false; + return true; +} + +export function PACSasyncFromJSON(json: any): PACSasync { + return PACSasyncFromJSONTyped(json, false); +} + +export function PACSasyncFromJSONTyped(json: any, ignoreDiscriminator: boolean): PACSasync { + if (json == null) { + return json; + } + return { + + 'directiveType': json['directiveType'] == null ? undefined : json['directiveType'], + 'response': json['response'], + 'timestamp': json['timestamp'], + 'pACSdirective': json['PACSdirective'], + }; +} + +export function PACSasyncToJSON(value?: PACSasync | null): any { + if (value == null) { + return value; + } + return { + + 'directiveType': value['directiveType'], + 'response': value['response'], + 'timestamp': value['timestamp'], + 'PACSdirective': value['pACSdirective'], + }; +} + diff --git a/src/api/pfdcm/generated/models/PACSdbPutModel.ts b/src/api/pfdcm/generated/models/PACSdbPutModel.ts new file mode 100644 index 000000000..27b7d39d0 --- /dev/null +++ b/src/api/pfdcm/generated/models/PACSdbPutModel.ts @@ -0,0 +1,68 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * pfdcm + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 3.1.2 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { mapValues } from '../runtime'; +import type { PACSsetupCore } from './PACSsetupCore'; +import { + PACSsetupCoreFromJSON, + PACSsetupCoreFromJSONTyped, + PACSsetupCoreToJSON, +} from './PACSsetupCore'; + +/** + * Model that illustrates what to PUT to the DB + * @export + * @interface PACSdbPutModel + */ +export interface PACSdbPutModel { + /** + * + * @type {PACSsetupCore} + * @memberof PACSdbPutModel + */ + info: PACSsetupCore; +} + +/** + * Check if a given object implements the PACSdbPutModel interface. + */ +export function instanceOfPACSdbPutModel(value: object): value is PACSdbPutModel { + if (!('info' in value) || value['info'] === undefined) return false; + return true; +} + +export function PACSdbPutModelFromJSON(json: any): PACSdbPutModel { + return PACSdbPutModelFromJSONTyped(json, false); +} + +export function PACSdbPutModelFromJSONTyped(json: any, ignoreDiscriminator: boolean): PACSdbPutModel { + if (json == null) { + return json; + } + return { + + 'info': PACSsetupCoreFromJSON(json['info']), + }; +} + +export function PACSdbPutModelToJSON(value?: PACSdbPutModel | null): any { + if (value == null) { + return value; + } + return { + + 'info': PACSsetupCoreToJSON(value['info']), + }; +} + diff --git a/src/api/pfdcm/generated/models/PACSdbReturnModel.ts b/src/api/pfdcm/generated/models/PACSdbReturnModel.ts new file mode 100644 index 000000000..3e3b9de65 --- /dev/null +++ b/src/api/pfdcm/generated/models/PACSdbReturnModel.ts @@ -0,0 +1,101 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * pfdcm + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 3.1.2 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { mapValues } from '../runtime'; +import type { ModelsPacsSetupModelTime } from './ModelsPacsSetupModelTime'; +import { + ModelsPacsSetupModelTimeFromJSON, + ModelsPacsSetupModelTimeFromJSONTyped, + ModelsPacsSetupModelTimeToJSON, +} from './ModelsPacsSetupModelTime'; +import type { PACSsetupCore } from './PACSsetupCore'; +import { + PACSsetupCoreFromJSON, + PACSsetupCoreFromJSONTyped, + PACSsetupCoreToJSON, +} from './PACSsetupCore'; + +/** + * A full model that is returned from a call to the DB + * @export + * @interface PACSdbReturnModel + */ +export interface PACSdbReturnModel { + /** + * + * @type {PACSsetupCore} + * @memberof PACSdbReturnModel + */ + info: PACSsetupCore; + /** + * + * @type {ModelsPacsSetupModelTime} + * @memberof PACSdbReturnModel + */ + timeCreated: ModelsPacsSetupModelTime; + /** + * + * @type {ModelsPacsSetupModelTime} + * @memberof PACSdbReturnModel + */ + timeModified: ModelsPacsSetupModelTime; + /** + * + * @type {string} + * @memberof PACSdbReturnModel + */ + message: string; +} + +/** + * Check if a given object implements the PACSdbReturnModel interface. + */ +export function instanceOfPACSdbReturnModel(value: object): value is PACSdbReturnModel { + if (!('info' in value) || value['info'] === undefined) return false; + if (!('timeCreated' in value) || value['timeCreated'] === undefined) return false; + if (!('timeModified' in value) || value['timeModified'] === undefined) return false; + if (!('message' in value) || value['message'] === undefined) return false; + return true; +} + +export function PACSdbReturnModelFromJSON(json: any): PACSdbReturnModel { + return PACSdbReturnModelFromJSONTyped(json, false); +} + +export function PACSdbReturnModelFromJSONTyped(json: any, ignoreDiscriminator: boolean): PACSdbReturnModel { + if (json == null) { + return json; + } + return { + + 'info': PACSsetupCoreFromJSON(json['info']), + 'timeCreated': ModelsPacsSetupModelTimeFromJSON(json['time_created']), + 'timeModified': ModelsPacsSetupModelTimeFromJSON(json['time_modified']), + 'message': json['message'], + }; +} + +export function PACSdbReturnModelToJSON(value?: PACSdbReturnModel | null): any { + if (value == null) { + return value; + } + return { + + 'info': PACSsetupCoreToJSON(value['info']), + 'time_created': ModelsPacsSetupModelTimeToJSON(value['timeCreated']), + 'time_modified': ModelsPacsSetupModelTimeToJSON(value['timeModified']), + 'message': value['message'], + }; +} + diff --git a/src/api/pfdcm/generated/models/PACSqueryCore.ts b/src/api/pfdcm/generated/models/PACSqueryCore.ts new file mode 100644 index 000000000..aa894839f --- /dev/null +++ b/src/api/pfdcm/generated/models/PACSqueryCore.ts @@ -0,0 +1,252 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * pfdcm + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 3.1.2 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { mapValues } from '../runtime'; +/** + * The PACS Query model + * @export + * @interface PACSqueryCore + */ +export interface PACSqueryCore { + /** + * + * @type {string} + * @memberof PACSqueryCore + */ + accessionNumber?: string; + /** + * + * @type {string} + * @memberof PACSqueryCore + */ + patientID?: string; + /** + * + * @type {string} + * @memberof PACSqueryCore + */ + patientName?: string; + /** + * + * @type {string} + * @memberof PACSqueryCore + */ + patientBirthDate?: string; + /** + * + * @type {string} + * @memberof PACSqueryCore + */ + patientAge?: string; + /** + * + * @type {string} + * @memberof PACSqueryCore + */ + patientSex?: string; + /** + * + * @type {string} + * @memberof PACSqueryCore + */ + studyDate?: string; + /** + * + * @type {string} + * @memberof PACSqueryCore + */ + studyDescription?: string; + /** + * + * @type {string} + * @memberof PACSqueryCore + */ + studyInstanceUID?: string; + /** + * + * @type {string} + * @memberof PACSqueryCore + */ + modality?: string; + /** + * + * @type {string} + * @memberof PACSqueryCore + */ + modalitiesInStudy?: string; + /** + * + * @type {string} + * @memberof PACSqueryCore + */ + performedStationAETitle?: string; + /** + * + * @type {string} + * @memberof PACSqueryCore + */ + numberOfSeriesRelatedInstances?: string; + /** + * + * @type {string} + * @memberof PACSqueryCore + */ + instanceNumber?: string; + /** + * + * @type {string} + * @memberof PACSqueryCore + */ + seriesDate?: string; + /** + * + * @type {string} + * @memberof PACSqueryCore + */ + seriesDescription?: string; + /** + * + * @type {string} + * @memberof PACSqueryCore + */ + seriesInstanceUID?: string; + /** + * + * @type {string} + * @memberof PACSqueryCore + */ + protocolName?: string; + /** + * + * @type {string} + * @memberof PACSqueryCore + */ + acquisitionProtocolDescription?: string; + /** + * + * @type {string} + * @memberof PACSqueryCore + */ + acquisitionProtocolName?: string; + /** + * + * @type {boolean} + * @memberof PACSqueryCore + */ + withFeedBack?: boolean; + /** + * + * @type {string} + * @memberof PACSqueryCore + */ + then?: string; + /** + * + * @type {string} + * @memberof PACSqueryCore + */ + thenArgs?: string; + /** + * + * @type {string} + * @memberof PACSqueryCore + */ + dblogbasepath?: string; + /** + * + * @type {boolean} + * @memberof PACSqueryCore + */ + jsonResponse?: boolean; +} + +/** + * Check if a given object implements the PACSqueryCore interface. + */ +export function instanceOfPACSqueryCore(value: object): value is PACSqueryCore { + return true; +} + +export function PACSqueryCoreFromJSON(json: any): PACSqueryCore { + return PACSqueryCoreFromJSONTyped(json, false); +} + +export function PACSqueryCoreFromJSONTyped(json: any, ignoreDiscriminator: boolean): PACSqueryCore { + if (json == null) { + return json; + } + return { + + 'accessionNumber': json['AccessionNumber'] == null ? undefined : json['AccessionNumber'], + 'patientID': json['PatientID'] == null ? undefined : json['PatientID'], + 'patientName': json['PatientName'] == null ? undefined : json['PatientName'], + 'patientBirthDate': json['PatientBirthDate'] == null ? undefined : json['PatientBirthDate'], + 'patientAge': json['PatientAge'] == null ? undefined : json['PatientAge'], + 'patientSex': json['PatientSex'] == null ? undefined : json['PatientSex'], + 'studyDate': json['StudyDate'] == null ? undefined : json['StudyDate'], + 'studyDescription': json['StudyDescription'] == null ? undefined : json['StudyDescription'], + 'studyInstanceUID': json['StudyInstanceUID'] == null ? undefined : json['StudyInstanceUID'], + 'modality': json['Modality'] == null ? undefined : json['Modality'], + 'modalitiesInStudy': json['ModalitiesInStudy'] == null ? undefined : json['ModalitiesInStudy'], + 'performedStationAETitle': json['PerformedStationAETitle'] == null ? undefined : json['PerformedStationAETitle'], + 'numberOfSeriesRelatedInstances': json['NumberOfSeriesRelatedInstances'] == null ? undefined : json['NumberOfSeriesRelatedInstances'], + 'instanceNumber': json['InstanceNumber'] == null ? undefined : json['InstanceNumber'], + 'seriesDate': json['SeriesDate'] == null ? undefined : json['SeriesDate'], + 'seriesDescription': json['SeriesDescription'] == null ? undefined : json['SeriesDescription'], + 'seriesInstanceUID': json['SeriesInstanceUID'] == null ? undefined : json['SeriesInstanceUID'], + 'protocolName': json['ProtocolName'] == null ? undefined : json['ProtocolName'], + 'acquisitionProtocolDescription': json['AcquisitionProtocolDescription'] == null ? undefined : json['AcquisitionProtocolDescription'], + 'acquisitionProtocolName': json['AcquisitionProtocolName'] == null ? undefined : json['AcquisitionProtocolName'], + 'withFeedBack': json['withFeedBack'] == null ? undefined : json['withFeedBack'], + 'then': json['then'] == null ? undefined : json['then'], + 'thenArgs': json['thenArgs'] == null ? undefined : json['thenArgs'], + 'dblogbasepath': json['dblogbasepath'] == null ? undefined : json['dblogbasepath'], + 'jsonResponse': json['json_response'] == null ? undefined : json['json_response'], + }; +} + +export function PACSqueryCoreToJSON(value?: PACSqueryCore | null): any { + if (value == null) { + return value; + } + return { + + 'AccessionNumber': value['accessionNumber'], + 'PatientID': value['patientID'], + 'PatientName': value['patientName'], + 'PatientBirthDate': value['patientBirthDate'], + 'PatientAge': value['patientAge'], + 'PatientSex': value['patientSex'], + 'StudyDate': value['studyDate'], + 'StudyDescription': value['studyDescription'], + 'StudyInstanceUID': value['studyInstanceUID'], + 'Modality': value['modality'], + 'ModalitiesInStudy': value['modalitiesInStudy'], + 'PerformedStationAETitle': value['performedStationAETitle'], + 'NumberOfSeriesRelatedInstances': value['numberOfSeriesRelatedInstances'], + 'InstanceNumber': value['instanceNumber'], + 'SeriesDate': value['seriesDate'], + 'SeriesDescription': value['seriesDescription'], + 'SeriesInstanceUID': value['seriesInstanceUID'], + 'ProtocolName': value['protocolName'], + 'AcquisitionProtocolDescription': value['acquisitionProtocolDescription'], + 'AcquisitionProtocolName': value['acquisitionProtocolName'], + 'withFeedBack': value['withFeedBack'], + 'then': value['then'], + 'thenArgs': value['thenArgs'], + 'dblogbasepath': value['dblogbasepath'], + 'json_response': value['jsonResponse'], + }; +} + diff --git a/src/api/pfdcm/generated/models/PACSsetupCore.ts b/src/api/pfdcm/generated/models/PACSsetupCore.ts new file mode 100644 index 000000000..60f529b21 --- /dev/null +++ b/src/api/pfdcm/generated/models/PACSsetupCore.ts @@ -0,0 +1,92 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * pfdcm + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 3.1.2 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { mapValues } from '../runtime'; +/** + * The PACS service model + * @export + * @interface PACSsetupCore + */ +export interface PACSsetupCore { + /** + * + * @type {string} + * @memberof PACSsetupCore + */ + aet?: string; + /** + * + * @type {string} + * @memberof PACSsetupCore + */ + aetListener?: string; + /** + * + * @type {string} + * @memberof PACSsetupCore + */ + aec?: string; + /** + * + * @type {string} + * @memberof PACSsetupCore + */ + serverIP?: string; + /** + * + * @type {string} + * @memberof PACSsetupCore + */ + serverPort?: string; +} + +/** + * Check if a given object implements the PACSsetupCore interface. + */ +export function instanceOfPACSsetupCore(value: object): value is PACSsetupCore { + return true; +} + +export function PACSsetupCoreFromJSON(json: any): PACSsetupCore { + return PACSsetupCoreFromJSONTyped(json, false); +} + +export function PACSsetupCoreFromJSONTyped(json: any, ignoreDiscriminator: boolean): PACSsetupCore { + if (json == null) { + return json; + } + return { + + 'aet': json['aet'] == null ? undefined : json['aet'], + 'aetListener': json['aet_listener'] == null ? undefined : json['aet_listener'], + 'aec': json['aec'] == null ? undefined : json['aec'], + 'serverIP': json['serverIP'] == null ? undefined : json['serverIP'], + 'serverPort': json['serverPort'] == null ? undefined : json['serverPort'], + }; +} + +export function PACSsetupCoreToJSON(value?: PACSsetupCore | null): any { + if (value == null) { + return value; + } + return { + + 'aet': value['aet'], + 'aet_listener': value['aetListener'], + 'aec': value['aec'], + 'serverIP': value['serverIP'], + 'serverPort': value['serverPort'], + }; +} + diff --git a/src/api/pfdcm/generated/models/SMDBFsConfig.ts b/src/api/pfdcm/generated/models/SMDBFsConfig.ts new file mode 100644 index 000000000..fe2f0aab1 --- /dev/null +++ b/src/api/pfdcm/generated/models/SMDBFsConfig.ts @@ -0,0 +1,83 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * pfdcm + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 3.1.2 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { mapValues } from '../runtime'; +import type { ModelsSmdbSetupModelValueStr } from './ModelsSmdbSetupModelValueStr'; +import { + ModelsSmdbSetupModelValueStrFromJSON, + ModelsSmdbSetupModelValueStrFromJSONTyped, + ModelsSmdbSetupModelValueStrToJSON, +} from './ModelsSmdbSetupModelValueStr'; +import type { SMDBFsCore } from './SMDBFsCore'; +import { + SMDBFsCoreFromJSON, + SMDBFsCoreFromJSONTyped, + SMDBFsCoreToJSON, +} from './SMDBFsCore'; + +/** + * The SMDB FS key config model + * @export + * @interface SMDBFsConfig + */ +export interface SMDBFsConfig { + /** + * + * @type {ModelsSmdbSetupModelValueStr} + * @memberof SMDBFsConfig + */ + fsKeyName: ModelsSmdbSetupModelValueStr; + /** + * + * @type {SMDBFsCore} + * @memberof SMDBFsConfig + */ + fsInfo: SMDBFsCore; +} + +/** + * Check if a given object implements the SMDBFsConfig interface. + */ +export function instanceOfSMDBFsConfig(value: object): value is SMDBFsConfig { + if (!('fsKeyName' in value) || value['fsKeyName'] === undefined) return false; + if (!('fsInfo' in value) || value['fsInfo'] === undefined) return false; + return true; +} + +export function SMDBFsConfigFromJSON(json: any): SMDBFsConfig { + return SMDBFsConfigFromJSONTyped(json, false); +} + +export function SMDBFsConfigFromJSONTyped(json: any, ignoreDiscriminator: boolean): SMDBFsConfig { + if (json == null) { + return json; + } + return { + + 'fsKeyName': ModelsSmdbSetupModelValueStrFromJSON(json['fsKeyName']), + 'fsInfo': SMDBFsCoreFromJSON(json['fsInfo']), + }; +} + +export function SMDBFsConfigToJSON(value?: SMDBFsConfig | null): any { + if (value == null) { + return value; + } + return { + + 'fsKeyName': ModelsSmdbSetupModelValueStrToJSON(value['fsKeyName']), + 'fsInfo': SMDBFsCoreToJSON(value['fsInfo']), + }; +} + diff --git a/src/api/pfdcm/generated/models/SMDBFsCore.ts b/src/api/pfdcm/generated/models/SMDBFsCore.ts new file mode 100644 index 000000000..485db73db --- /dev/null +++ b/src/api/pfdcm/generated/models/SMDBFsCore.ts @@ -0,0 +1,61 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * pfdcm + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 3.1.2 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { mapValues } from '../runtime'; +/** + * The SMDB file system service model + * @export + * @interface SMDBFsCore + */ +export interface SMDBFsCore { + /** + * + * @type {string} + * @memberof SMDBFsCore + */ + storepath: string; +} + +/** + * Check if a given object implements the SMDBFsCore interface. + */ +export function instanceOfSMDBFsCore(value: object): value is SMDBFsCore { + if (!('storepath' in value) || value['storepath'] === undefined) return false; + return true; +} + +export function SMDBFsCoreFromJSON(json: any): SMDBFsCore { + return SMDBFsCoreFromJSONTyped(json, false); +} + +export function SMDBFsCoreFromJSONTyped(json: any, ignoreDiscriminator: boolean): SMDBFsCore { + if (json == null) { + return json; + } + return { + + 'storepath': json['storepath'], + }; +} + +export function SMDBFsCoreToJSON(value?: SMDBFsCore | null): any { + if (value == null) { + return value; + } + return { + + 'storepath': value['storepath'], + }; +} + diff --git a/src/api/pfdcm/generated/models/SMDBFsReturnModel.ts b/src/api/pfdcm/generated/models/SMDBFsReturnModel.ts new file mode 100644 index 000000000..4e248dca8 --- /dev/null +++ b/src/api/pfdcm/generated/models/SMDBFsReturnModel.ts @@ -0,0 +1,85 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * pfdcm + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 3.1.2 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { mapValues } from '../runtime'; +import type { SMDBFsCore } from './SMDBFsCore'; +import { + SMDBFsCoreFromJSON, + SMDBFsCoreFromJSONTyped, + SMDBFsCoreToJSON, +} from './SMDBFsCore'; + +/** + * A full model that is returned from a call to the DB + * @export + * @interface SMDBFsReturnModel + */ +export interface SMDBFsReturnModel { + /** + * + * @type {boolean} + * @memberof SMDBFsReturnModel + */ + status?: boolean; + /** + * + * @type {string} + * @memberof SMDBFsReturnModel + */ + fsKeyName: string; + /** + * + * @type {SMDBFsCore} + * @memberof SMDBFsReturnModel + */ + fsInfo: SMDBFsCore; +} + +/** + * Check if a given object implements the SMDBFsReturnModel interface. + */ +export function instanceOfSMDBFsReturnModel(value: object): value is SMDBFsReturnModel { + if (!('fsKeyName' in value) || value['fsKeyName'] === undefined) return false; + if (!('fsInfo' in value) || value['fsInfo'] === undefined) return false; + return true; +} + +export function SMDBFsReturnModelFromJSON(json: any): SMDBFsReturnModel { + return SMDBFsReturnModelFromJSONTyped(json, false); +} + +export function SMDBFsReturnModelFromJSONTyped(json: any, ignoreDiscriminator: boolean): SMDBFsReturnModel { + if (json == null) { + return json; + } + return { + + 'status': json['status'] == null ? undefined : json['status'], + 'fsKeyName': json['fsKeyName'], + 'fsInfo': SMDBFsCoreFromJSON(json['fsInfo']), + }; +} + +export function SMDBFsReturnModelToJSON(value?: SMDBFsReturnModel | null): any { + if (value == null) { + return value; + } + return { + + 'status': value['status'], + 'fsKeyName': value['fsKeyName'], + 'fsInfo': SMDBFsCoreToJSON(value['fsInfo']), + }; +} + diff --git a/src/api/pfdcm/generated/models/SMDBcubeConfig.ts b/src/api/pfdcm/generated/models/SMDBcubeConfig.ts new file mode 100644 index 000000000..5668d959a --- /dev/null +++ b/src/api/pfdcm/generated/models/SMDBcubeConfig.ts @@ -0,0 +1,83 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * pfdcm + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 3.1.2 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { mapValues } from '../runtime'; +import type { ModelsSmdbSetupModelValueStr } from './ModelsSmdbSetupModelValueStr'; +import { + ModelsSmdbSetupModelValueStrFromJSON, + ModelsSmdbSetupModelValueStrFromJSONTyped, + ModelsSmdbSetupModelValueStrToJSON, +} from './ModelsSmdbSetupModelValueStr'; +import type { SMDBcubeCore } from './SMDBcubeCore'; +import { + SMDBcubeCoreFromJSON, + SMDBcubeCoreFromJSONTyped, + SMDBcubeCoreToJSON, +} from './SMDBcubeCore'; + +/** + * The SMDB cube key config model + * @export + * @interface SMDBcubeConfig + */ +export interface SMDBcubeConfig { + /** + * + * @type {ModelsSmdbSetupModelValueStr} + * @memberof SMDBcubeConfig + */ + cubeKeyName: ModelsSmdbSetupModelValueStr; + /** + * + * @type {SMDBcubeCore} + * @memberof SMDBcubeConfig + */ + cubeInfo: SMDBcubeCore; +} + +/** + * Check if a given object implements the SMDBcubeConfig interface. + */ +export function instanceOfSMDBcubeConfig(value: object): value is SMDBcubeConfig { + if (!('cubeKeyName' in value) || value['cubeKeyName'] === undefined) return false; + if (!('cubeInfo' in value) || value['cubeInfo'] === undefined) return false; + return true; +} + +export function SMDBcubeConfigFromJSON(json: any): SMDBcubeConfig { + return SMDBcubeConfigFromJSONTyped(json, false); +} + +export function SMDBcubeConfigFromJSONTyped(json: any, ignoreDiscriminator: boolean): SMDBcubeConfig { + if (json == null) { + return json; + } + return { + + 'cubeKeyName': ModelsSmdbSetupModelValueStrFromJSON(json['cubeKeyName']), + 'cubeInfo': SMDBcubeCoreFromJSON(json['cubeInfo']), + }; +} + +export function SMDBcubeConfigToJSON(value?: SMDBcubeConfig | null): any { + if (value == null) { + return value; + } + return { + + 'cubeKeyName': ModelsSmdbSetupModelValueStrToJSON(value['cubeKeyName']), + 'cubeInfo': SMDBcubeCoreToJSON(value['cubeInfo']), + }; +} + diff --git a/src/api/pfdcm/generated/models/SMDBcubeCore.ts b/src/api/pfdcm/generated/models/SMDBcubeCore.ts new file mode 100644 index 000000000..845ebd857 --- /dev/null +++ b/src/api/pfdcm/generated/models/SMDBcubeCore.ts @@ -0,0 +1,79 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * pfdcm + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 3.1.2 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { mapValues } from '../runtime'; +/** + * The SMDB cube service model + * @export + * @interface SMDBcubeCore + */ +export interface SMDBcubeCore { + /** + * + * @type {string} + * @memberof SMDBcubeCore + */ + url: string; + /** + * + * @type {string} + * @memberof SMDBcubeCore + */ + username: string; + /** + * + * @type {string} + * @memberof SMDBcubeCore + */ + password: string; +} + +/** + * Check if a given object implements the SMDBcubeCore interface. + */ +export function instanceOfSMDBcubeCore(value: object): value is SMDBcubeCore { + if (!('url' in value) || value['url'] === undefined) return false; + if (!('username' in value) || value['username'] === undefined) return false; + if (!('password' in value) || value['password'] === undefined) return false; + return true; +} + +export function SMDBcubeCoreFromJSON(json: any): SMDBcubeCore { + return SMDBcubeCoreFromJSONTyped(json, false); +} + +export function SMDBcubeCoreFromJSONTyped(json: any, ignoreDiscriminator: boolean): SMDBcubeCore { + if (json == null) { + return json; + } + return { + + 'url': json['url'], + 'username': json['username'], + 'password': json['password'], + }; +} + +export function SMDBcubeCoreToJSON(value?: SMDBcubeCore | null): any { + if (value == null) { + return value; + } + return { + + 'url': value['url'], + 'username': value['username'], + 'password': value['password'], + }; +} + diff --git a/src/api/pfdcm/generated/models/SMDBcubeReturnModel.ts b/src/api/pfdcm/generated/models/SMDBcubeReturnModel.ts new file mode 100644 index 000000000..a09dc5543 --- /dev/null +++ b/src/api/pfdcm/generated/models/SMDBcubeReturnModel.ts @@ -0,0 +1,85 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * pfdcm + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 3.1.2 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { mapValues } from '../runtime'; +import type { SMDBcubeCore } from './SMDBcubeCore'; +import { + SMDBcubeCoreFromJSON, + SMDBcubeCoreFromJSONTyped, + SMDBcubeCoreToJSON, +} from './SMDBcubeCore'; + +/** + * A full model that is returned from a call to the DB + * @export + * @interface SMDBcubeReturnModel + */ +export interface SMDBcubeReturnModel { + /** + * + * @type {boolean} + * @memberof SMDBcubeReturnModel + */ + status?: boolean; + /** + * + * @type {string} + * @memberof SMDBcubeReturnModel + */ + cubeKeyName: string; + /** + * + * @type {SMDBcubeCore} + * @memberof SMDBcubeReturnModel + */ + cubeInfo: SMDBcubeCore; +} + +/** + * Check if a given object implements the SMDBcubeReturnModel interface. + */ +export function instanceOfSMDBcubeReturnModel(value: object): value is SMDBcubeReturnModel { + if (!('cubeKeyName' in value) || value['cubeKeyName'] === undefined) return false; + if (!('cubeInfo' in value) || value['cubeInfo'] === undefined) return false; + return true; +} + +export function SMDBcubeReturnModelFromJSON(json: any): SMDBcubeReturnModel { + return SMDBcubeReturnModelFromJSONTyped(json, false); +} + +export function SMDBcubeReturnModelFromJSONTyped(json: any, ignoreDiscriminator: boolean): SMDBcubeReturnModel { + if (json == null) { + return json; + } + return { + + 'status': json['status'] == null ? undefined : json['status'], + 'cubeKeyName': json['cubeKeyName'], + 'cubeInfo': SMDBcubeCoreFromJSON(json['cubeInfo']), + }; +} + +export function SMDBcubeReturnModelToJSON(value?: SMDBcubeReturnModel | null): any { + if (value == null) { + return value; + } + return { + + 'status': value['status'], + 'cubeKeyName': value['cubeKeyName'], + 'cubeInfo': SMDBcubeCoreToJSON(value['cubeInfo']), + }; +} + diff --git a/src/api/pfdcm/generated/models/SMDBswiftConfig.ts b/src/api/pfdcm/generated/models/SMDBswiftConfig.ts new file mode 100644 index 000000000..59ee1a641 --- /dev/null +++ b/src/api/pfdcm/generated/models/SMDBswiftConfig.ts @@ -0,0 +1,83 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * pfdcm + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 3.1.2 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { mapValues } from '../runtime'; +import type { ModelsSmdbSetupModelValueStr } from './ModelsSmdbSetupModelValueStr'; +import { + ModelsSmdbSetupModelValueStrFromJSON, + ModelsSmdbSetupModelValueStrFromJSONTyped, + ModelsSmdbSetupModelValueStrToJSON, +} from './ModelsSmdbSetupModelValueStr'; +import type { SMDBswiftCore } from './SMDBswiftCore'; +import { + SMDBswiftCoreFromJSON, + SMDBswiftCoreFromJSONTyped, + SMDBswiftCoreToJSON, +} from './SMDBswiftCore'; + +/** + * The SMDB swift key config model + * @export + * @interface SMDBswiftConfig + */ +export interface SMDBswiftConfig { + /** + * + * @type {ModelsSmdbSetupModelValueStr} + * @memberof SMDBswiftConfig + */ + swiftKeyName: ModelsSmdbSetupModelValueStr; + /** + * + * @type {SMDBswiftCore} + * @memberof SMDBswiftConfig + */ + swiftInfo: SMDBswiftCore; +} + +/** + * Check if a given object implements the SMDBswiftConfig interface. + */ +export function instanceOfSMDBswiftConfig(value: object): value is SMDBswiftConfig { + if (!('swiftKeyName' in value) || value['swiftKeyName'] === undefined) return false; + if (!('swiftInfo' in value) || value['swiftInfo'] === undefined) return false; + return true; +} + +export function SMDBswiftConfigFromJSON(json: any): SMDBswiftConfig { + return SMDBswiftConfigFromJSONTyped(json, false); +} + +export function SMDBswiftConfigFromJSONTyped(json: any, ignoreDiscriminator: boolean): SMDBswiftConfig { + if (json == null) { + return json; + } + return { + + 'swiftKeyName': ModelsSmdbSetupModelValueStrFromJSON(json['swiftKeyName']), + 'swiftInfo': SMDBswiftCoreFromJSON(json['swiftInfo']), + }; +} + +export function SMDBswiftConfigToJSON(value?: SMDBswiftConfig | null): any { + if (value == null) { + return value; + } + return { + + 'swiftKeyName': ModelsSmdbSetupModelValueStrToJSON(value['swiftKeyName']), + 'swiftInfo': SMDBswiftCoreToJSON(value['swiftInfo']), + }; +} + diff --git a/src/api/pfdcm/generated/models/SMDBswiftCore.ts b/src/api/pfdcm/generated/models/SMDBswiftCore.ts new file mode 100644 index 000000000..cd8dca4ae --- /dev/null +++ b/src/api/pfdcm/generated/models/SMDBswiftCore.ts @@ -0,0 +1,79 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * pfdcm + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 3.1.2 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { mapValues } from '../runtime'; +/** + * The SMDB swift service model + * @export + * @interface SMDBswiftCore + */ +export interface SMDBswiftCore { + /** + * + * @type {string} + * @memberof SMDBswiftCore + */ + ip: string; + /** + * + * @type {string} + * @memberof SMDBswiftCore + */ + port: string; + /** + * + * @type {string} + * @memberof SMDBswiftCore + */ + login: string; +} + +/** + * Check if a given object implements the SMDBswiftCore interface. + */ +export function instanceOfSMDBswiftCore(value: object): value is SMDBswiftCore { + if (!('ip' in value) || value['ip'] === undefined) return false; + if (!('port' in value) || value['port'] === undefined) return false; + if (!('login' in value) || value['login'] === undefined) return false; + return true; +} + +export function SMDBswiftCoreFromJSON(json: any): SMDBswiftCore { + return SMDBswiftCoreFromJSONTyped(json, false); +} + +export function SMDBswiftCoreFromJSONTyped(json: any, ignoreDiscriminator: boolean): SMDBswiftCore { + if (json == null) { + return json; + } + return { + + 'ip': json['ip'], + 'port': json['port'], + 'login': json['login'], + }; +} + +export function SMDBswiftCoreToJSON(value?: SMDBswiftCore | null): any { + if (value == null) { + return value; + } + return { + + 'ip': value['ip'], + 'port': value['port'], + 'login': value['login'], + }; +} + diff --git a/src/api/pfdcm/generated/models/SysInfoModel.ts b/src/api/pfdcm/generated/models/SysInfoModel.ts new file mode 100644 index 000000000..0411adf6b --- /dev/null +++ b/src/api/pfdcm/generated/models/SysInfoModel.ts @@ -0,0 +1,148 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * pfdcm + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 3.1.2 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { mapValues } from '../runtime'; +/** + * For the most part, copied from + * https://github.com/FNNDSC/pfcon/blob/87f5da953be7c2cc80542bef0e67727dda1b4958/pfcon/pfcon.py#L601-611 + * + * Provides information about the environment in which the service + * is currently running. + * @export + * @interface SysInfoModel + */ +export interface SysInfoModel { + /** + * + * @type {string} + * @memberof SysInfoModel + */ + system?: string; + /** + * + * @type {string} + * @memberof SysInfoModel + */ + machine?: string; + /** + * Uname output, converted from object to list + * @type {Array} + * @memberof SysInfoModel + */ + uname?: Array; + /** + * + * @type {string} + * @memberof SysInfoModel + */ + platform?: string; + /** + * + * @type {string} + * @memberof SysInfoModel + */ + version: string; + /** + * Actually a NamedTuple but I'm not typing it out + * @type {Array} + * @memberof SysInfoModel + */ + memory: Array; + /** + * + * @type {number} + * @memberof SysInfoModel + */ + cpucount?: number; + /** + * Average system load over last 1, 5, and 15 minutes + * @type {Array} + * @memberof SysInfoModel + */ + loadavg: Array; + /** + * + * @type {number} + * @memberof SysInfoModel + */ + cpuPercent: number; + /** + * + * @type {string} + * @memberof SysInfoModel + */ + hostname?: string; + /** + * + * @type {string} + * @memberof SysInfoModel + */ + inet?: string; +} + +/** + * Check if a given object implements the SysInfoModel interface. + */ +export function instanceOfSysInfoModel(value: object): value is SysInfoModel { + if (!('version' in value) || value['version'] === undefined) return false; + if (!('memory' in value) || value['memory'] === undefined) return false; + if (!('loadavg' in value) || value['loadavg'] === undefined) return false; + if (!('cpuPercent' in value) || value['cpuPercent'] === undefined) return false; + return true; +} + +export function SysInfoModelFromJSON(json: any): SysInfoModel { + return SysInfoModelFromJSONTyped(json, false); +} + +export function SysInfoModelFromJSONTyped(json: any, ignoreDiscriminator: boolean): SysInfoModel { + if (json == null) { + return json; + } + return { + + 'system': json['system'] == null ? undefined : json['system'], + 'machine': json['machine'] == null ? undefined : json['machine'], + 'uname': json['uname'] == null ? undefined : json['uname'], + 'platform': json['platform'] == null ? undefined : json['platform'], + 'version': json['version'], + 'memory': json['memory'], + 'cpucount': json['cpucount'] == null ? undefined : json['cpucount'], + 'loadavg': json['loadavg'], + 'cpuPercent': json['cpu_percent'], + 'hostname': json['hostname'] == null ? undefined : json['hostname'], + 'inet': json['inet'] == null ? undefined : json['inet'], + }; +} + +export function SysInfoModelToJSON(value?: SysInfoModel | null): any { + if (value == null) { + return value; + } + return { + + 'system': value['system'], + 'machine': value['machine'], + 'uname': value['uname'], + 'platform': value['platform'], + 'version': value['version'], + 'memory': value['memory'], + 'cpucount': value['cpucount'], + 'loadavg': value['loadavg'], + 'cpu_percent': value['cpuPercent'], + 'hostname': value['hostname'], + 'inet': value['inet'], + }; +} + diff --git a/src/api/pfdcm/generated/models/ValidationError.ts b/src/api/pfdcm/generated/models/ValidationError.ts new file mode 100644 index 000000000..2355a36ea --- /dev/null +++ b/src/api/pfdcm/generated/models/ValidationError.ts @@ -0,0 +1,86 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * pfdcm + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 3.1.2 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { mapValues } from '../runtime'; +import type { LocationInner } from './LocationInner'; +import { + LocationInnerFromJSON, + LocationInnerFromJSONTyped, + LocationInnerToJSON, +} from './LocationInner'; + +/** + * + * @export + * @interface ValidationError + */ +export interface ValidationError { + /** + * + * @type {Array} + * @memberof ValidationError + */ + loc: Array; + /** + * + * @type {string} + * @memberof ValidationError + */ + msg: string; + /** + * + * @type {string} + * @memberof ValidationError + */ + type: string; +} + +/** + * Check if a given object implements the ValidationError interface. + */ +export function instanceOfValidationError(value: object): value is ValidationError { + if (!('loc' in value) || value['loc'] === undefined) return false; + if (!('msg' in value) || value['msg'] === undefined) return false; + if (!('type' in value) || value['type'] === undefined) return false; + return true; +} + +export function ValidationErrorFromJSON(json: any): ValidationError { + return ValidationErrorFromJSONTyped(json, false); +} + +export function ValidationErrorFromJSONTyped(json: any, ignoreDiscriminator: boolean): ValidationError { + if (json == null) { + return json; + } + return { + + 'loc': ((json['loc'] as Array).map(LocationInnerFromJSON)), + 'msg': json['msg'], + 'type': json['type'], + }; +} + +export function ValidationErrorToJSON(value?: ValidationError | null): any { + if (value == null) { + return value; + } + return { + + 'loc': ((value['loc'] as Array).map(LocationInnerToJSON)), + 'msg': value['msg'], + 'type': value['type'], + }; +} + diff --git a/src/api/pfdcm/generated/models/XinetdCore.ts b/src/api/pfdcm/generated/models/XinetdCore.ts new file mode 100644 index 000000000..bb66f4c72 --- /dev/null +++ b/src/api/pfdcm/generated/models/XinetdCore.ts @@ -0,0 +1,124 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * pfdcm + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 3.1.2 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { mapValues } from '../runtime'; +/** + * The core data model + * @export + * @interface XinetdCore + */ +export interface XinetdCore { + /** + * + * @type {string} + * @memberof XinetdCore + */ + servicePort: string; + /** + * + * @type {string} + * @memberof XinetdCore + */ + tmpDir: string; + /** + * + * @type {string} + * @memberof XinetdCore + */ + logDir: string; + /** + * + * @type {string} + * @memberof XinetdCore + */ + dataDir: string; + /** + * + * @type {string} + * @memberof XinetdCore + */ + listener: string; + /** + * + * @type {string} + * @memberof XinetdCore + */ + patientMapDir: string; + /** + * + * @type {string} + * @memberof XinetdCore + */ + studyMapDir: string; + /** + * + * @type {string} + * @memberof XinetdCore + */ + seriesMapDir: string; +} + +/** + * Check if a given object implements the XinetdCore interface. + */ +export function instanceOfXinetdCore(value: object): value is XinetdCore { + if (!('servicePort' in value) || value['servicePort'] === undefined) return false; + if (!('tmpDir' in value) || value['tmpDir'] === undefined) return false; + if (!('logDir' in value) || value['logDir'] === undefined) return false; + if (!('dataDir' in value) || value['dataDir'] === undefined) return false; + if (!('listener' in value) || value['listener'] === undefined) return false; + if (!('patientMapDir' in value) || value['patientMapDir'] === undefined) return false; + if (!('studyMapDir' in value) || value['studyMapDir'] === undefined) return false; + if (!('seriesMapDir' in value) || value['seriesMapDir'] === undefined) return false; + return true; +} + +export function XinetdCoreFromJSON(json: any): XinetdCore { + return XinetdCoreFromJSONTyped(json, false); +} + +export function XinetdCoreFromJSONTyped(json: any, ignoreDiscriminator: boolean): XinetdCore { + if (json == null) { + return json; + } + return { + + 'servicePort': json['servicePort'], + 'tmpDir': json['tmpDir'], + 'logDir': json['logDir'], + 'dataDir': json['dataDir'], + 'listener': json['listener'], + 'patientMapDir': json['patient_mapDir'], + 'studyMapDir': json['study_mapDir'], + 'seriesMapDir': json['series_mapDir'], + }; +} + +export function XinetdCoreToJSON(value?: XinetdCore | null): any { + if (value == null) { + return value; + } + return { + + 'servicePort': value['servicePort'], + 'tmpDir': value['tmpDir'], + 'logDir': value['logDir'], + 'dataDir': value['dataDir'], + 'listener': value['listener'], + 'patient_mapDir': value['patientMapDir'], + 'study_mapDir': value['studyMapDir'], + 'series_mapDir': value['seriesMapDir'], + }; +} + diff --git a/src/api/pfdcm/generated/models/XinetdDBPutModel.ts b/src/api/pfdcm/generated/models/XinetdDBPutModel.ts new file mode 100644 index 000000000..42df069aa --- /dev/null +++ b/src/api/pfdcm/generated/models/XinetdDBPutModel.ts @@ -0,0 +1,68 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * pfdcm + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 3.1.2 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { mapValues } from '../runtime'; +import type { XinetdCore } from './XinetdCore'; +import { + XinetdCoreFromJSON, + XinetdCoreFromJSONTyped, + XinetdCoreToJSON, +} from './XinetdCore'; + +/** + * + * @export + * @interface XinetdDBPutModel + */ +export interface XinetdDBPutModel { + /** + * + * @type {XinetdCore} + * @memberof XinetdDBPutModel + */ + info: XinetdCore; +} + +/** + * Check if a given object implements the XinetdDBPutModel interface. + */ +export function instanceOfXinetdDBPutModel(value: object): value is XinetdDBPutModel { + if (!('info' in value) || value['info'] === undefined) return false; + return true; +} + +export function XinetdDBPutModelFromJSON(json: any): XinetdDBPutModel { + return XinetdDBPutModelFromJSONTyped(json, false); +} + +export function XinetdDBPutModelFromJSONTyped(json: any, ignoreDiscriminator: boolean): XinetdDBPutModel { + if (json == null) { + return json; + } + return { + + 'info': XinetdCoreFromJSON(json['info']), + }; +} + +export function XinetdDBPutModelToJSON(value?: XinetdDBPutModel | null): any { + if (value == null) { + return value; + } + return { + + 'info': XinetdCoreToJSON(value['info']), + }; +} + diff --git a/src/api/pfdcm/generated/models/XinetdDBReturnModel.ts b/src/api/pfdcm/generated/models/XinetdDBReturnModel.ts new file mode 100644 index 000000000..5a1e54384 --- /dev/null +++ b/src/api/pfdcm/generated/models/XinetdDBReturnModel.ts @@ -0,0 +1,101 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * pfdcm + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 3.1.2 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { mapValues } from '../runtime'; +import type { ModelsListenerModelTime } from './ModelsListenerModelTime'; +import { + ModelsListenerModelTimeFromJSON, + ModelsListenerModelTimeFromJSONTyped, + ModelsListenerModelTimeToJSON, +} from './ModelsListenerModelTime'; +import type { XinetdCore } from './XinetdCore'; +import { + XinetdCoreFromJSON, + XinetdCoreFromJSONTyped, + XinetdCoreToJSON, +} from './XinetdCore'; + +/** + * + * @export + * @interface XinetdDBReturnModel + */ +export interface XinetdDBReturnModel { + /** + * + * @type {XinetdCore} + * @memberof XinetdDBReturnModel + */ + info: XinetdCore; + /** + * + * @type {ModelsListenerModelTime} + * @memberof XinetdDBReturnModel + */ + timeCreated: ModelsListenerModelTime; + /** + * + * @type {ModelsListenerModelTime} + * @memberof XinetdDBReturnModel + */ + timeModified: ModelsListenerModelTime; + /** + * + * @type {string} + * @memberof XinetdDBReturnModel + */ + message: string; +} + +/** + * Check if a given object implements the XinetdDBReturnModel interface. + */ +export function instanceOfXinetdDBReturnModel(value: object): value is XinetdDBReturnModel { + if (!('info' in value) || value['info'] === undefined) return false; + if (!('timeCreated' in value) || value['timeCreated'] === undefined) return false; + if (!('timeModified' in value) || value['timeModified'] === undefined) return false; + if (!('message' in value) || value['message'] === undefined) return false; + return true; +} + +export function XinetdDBReturnModelFromJSON(json: any): XinetdDBReturnModel { + return XinetdDBReturnModelFromJSONTyped(json, false); +} + +export function XinetdDBReturnModelFromJSONTyped(json: any, ignoreDiscriminator: boolean): XinetdDBReturnModel { + if (json == null) { + return json; + } + return { + + 'info': XinetdCoreFromJSON(json['info']), + 'timeCreated': ModelsListenerModelTimeFromJSON(json['time_created']), + 'timeModified': ModelsListenerModelTimeFromJSON(json['time_modified']), + 'message': json['message'], + }; +} + +export function XinetdDBReturnModelToJSON(value?: XinetdDBReturnModel | null): any { + if (value == null) { + return value; + } + return { + + 'info': XinetdCoreToJSON(value['info']), + 'time_created': ModelsListenerModelTimeToJSON(value['timeCreated']), + 'time_modified': ModelsListenerModelTimeToJSON(value['timeModified']), + 'message': value['message'], + }; +} + diff --git a/src/api/pfdcm/generated/models/index.ts b/src/api/pfdcm/generated/models/index.ts new file mode 100644 index 000000000..e1fb462d3 --- /dev/null +++ b/src/api/pfdcm/generated/models/index.ts @@ -0,0 +1,40 @@ +/* tslint:disable */ +/* eslint-disable */ +export * from './AboutModel'; +export * from './BodyPACSPypxApiV1PACSSyncPypxPost'; +export * from './BodyPACSServiceHandlerApiV1PACSThreadPypxPost'; +export * from './BodyPACSobjPortUpdateApiV1PACSservicePortPost'; +export * from './DcmtkCore'; +export * from './DcmtkDBPutModel'; +export * from './DcmtkDBReturnModel'; +export * from './Dicom'; +export * from './EchoModel'; +export * from './HTTPValidationError'; +export * from './HelloModel'; +export * from './ListenerDBreturnModel'; +export * from './ListenerHandlerStatus'; +export * from './LocationInner'; +export * from './ModelsListenerModelTime'; +export * from './ModelsListenerModelValueStr'; +export * from './ModelsPacsQRmodelValueStr'; +export * from './ModelsPacsSetupModelTime'; +export * from './ModelsPacsSetupModelValueStr'; +export * from './ModelsSmdbSetupModelValueStr'; +export * from './PACSasync'; +export * from './PACSdbPutModel'; +export * from './PACSdbReturnModel'; +export * from './PACSqueryCore'; +export * from './PACSsetupCore'; +export * from './SMDBFsConfig'; +export * from './SMDBFsCore'; +export * from './SMDBFsReturnModel'; +export * from './SMDBcubeConfig'; +export * from './SMDBcubeCore'; +export * from './SMDBcubeReturnModel'; +export * from './SMDBswiftConfig'; +export * from './SMDBswiftCore'; +export * from './SysInfoModel'; +export * from './ValidationError'; +export * from './XinetdCore'; +export * from './XinetdDBPutModel'; +export * from './XinetdDBReturnModel'; diff --git a/src/api/pfdcm/generated/runtime.ts b/src/api/pfdcm/generated/runtime.ts new file mode 100644 index 000000000..1e066c3d4 --- /dev/null +++ b/src/api/pfdcm/generated/runtime.ts @@ -0,0 +1,426 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * pfdcm + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 3.1.2 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +export const BASE_PATH = "http://localhost".replace(/\/+$/, ""); + +export interface ConfigurationParameters { + basePath?: string; // override base path + fetchApi?: FetchAPI; // override for fetch implementation + middleware?: Middleware[]; // middleware to apply before/after fetch requests + queryParamsStringify?: (params: HTTPQuery) => string; // stringify function for query strings + username?: string; // parameter for basic security + password?: string; // parameter for basic security + apiKey?: string | Promise | ((name: string) => string | Promise); // parameter for apiKey security + accessToken?: string | Promise | ((name?: string, scopes?: string[]) => string | Promise); // parameter for oauth2 security + headers?: HTTPHeaders; //header params we want to use on every request + credentials?: RequestCredentials; //value for the credentials param we want to use on each request +} + +export class Configuration { + constructor(private configuration: ConfigurationParameters = {}) {} + + set config(configuration: Configuration) { + this.configuration = configuration; + } + + get basePath(): string { + return this.configuration.basePath != null ? this.configuration.basePath : BASE_PATH; + } + + get fetchApi(): FetchAPI | undefined { + return this.configuration.fetchApi; + } + + get middleware(): Middleware[] { + return this.configuration.middleware || []; + } + + get queryParamsStringify(): (params: HTTPQuery) => string { + return this.configuration.queryParamsStringify || querystring; + } + + get username(): string | undefined { + return this.configuration.username; + } + + get password(): string | undefined { + return this.configuration.password; + } + + get apiKey(): ((name: string) => string | Promise) | undefined { + const apiKey = this.configuration.apiKey; + if (apiKey) { + return typeof apiKey === 'function' ? apiKey : () => apiKey; + } + return undefined; + } + + get accessToken(): ((name?: string, scopes?: string[]) => string | Promise) | undefined { + const accessToken = this.configuration.accessToken; + if (accessToken) { + return typeof accessToken === 'function' ? accessToken : async () => accessToken; + } + return undefined; + } + + get headers(): HTTPHeaders | undefined { + return this.configuration.headers; + } + + get credentials(): RequestCredentials | undefined { + return this.configuration.credentials; + } +} + +export const DefaultConfig = new Configuration(); + +/** + * This is the base class for all generated API classes. + */ +export class BaseAPI { + + private static readonly jsonRegex = new RegExp('^(:?application\/json|[^;/ \t]+\/[^;/ \t]+[+]json)[ \t]*(:?;.*)?$', 'i'); + private middleware: Middleware[]; + + constructor(protected configuration = DefaultConfig) { + this.middleware = configuration.middleware; + } + + withMiddleware(this: T, ...middlewares: Middleware[]) { + const next = this.clone(); + next.middleware = next.middleware.concat(...middlewares); + return next; + } + + withPreMiddleware(this: T, ...preMiddlewares: Array) { + const middlewares = preMiddlewares.map((pre) => ({ pre })); + return this.withMiddleware(...middlewares); + } + + withPostMiddleware(this: T, ...postMiddlewares: Array) { + const middlewares = postMiddlewares.map((post) => ({ post })); + return this.withMiddleware(...middlewares); + } + + /** + * Check if the given MIME is a JSON MIME. + * JSON MIME examples: + * application/json + * application/json; charset=UTF8 + * APPLICATION/JSON + * application/vnd.company+json + * @param mime - MIME (Multipurpose Internet Mail Extensions) + * @return True if the given MIME is JSON, false otherwise. + */ + protected isJsonMime(mime: string | null | undefined): boolean { + if (!mime) { + return false; + } + return BaseAPI.jsonRegex.test(mime); + } + + protected async request(context: RequestOpts, initOverrides?: RequestInit | InitOverrideFunction): Promise { + const { url, init } = await this.createFetchParams(context, initOverrides); + const response = await this.fetchApi(url, init); + if (response && (response.status >= 200 && response.status < 300)) { + return response; + } + throw new ResponseError(response, 'Response returned an error code'); + } + + private async createFetchParams(context: RequestOpts, initOverrides?: RequestInit | InitOverrideFunction) { + let url = this.configuration.basePath + context.path; + if (context.query !== undefined && Object.keys(context.query).length !== 0) { + // only add the querystring to the URL if there are query parameters. + // this is done to avoid urls ending with a "?" character which buggy webservers + // do not handle correctly sometimes. + url += '?' + this.configuration.queryParamsStringify(context.query); + } + + const headers = Object.assign({}, this.configuration.headers, context.headers); + Object.keys(headers).forEach(key => headers[key] === undefined ? delete headers[key] : {}); + + const initOverrideFn = + typeof initOverrides === "function" + ? initOverrides + : async () => initOverrides; + + const initParams = { + method: context.method, + headers, + body: context.body, + credentials: this.configuration.credentials, + }; + + const overriddenInit: RequestInit = { + ...initParams, + ...(await initOverrideFn({ + init: initParams, + context, + })) + }; + + let body: any; + if (isFormData(overriddenInit.body) + || (overriddenInit.body instanceof URLSearchParams) + || isBlob(overriddenInit.body)) { + body = overriddenInit.body; + } else if (this.isJsonMime(headers['Content-Type'])) { + body = JSON.stringify(overriddenInit.body); + } else { + body = overriddenInit.body; + } + + const init: RequestInit = { + ...overriddenInit, + body + }; + + return { url, init }; + } + + private fetchApi = async (url: string, init: RequestInit) => { + let fetchParams = { url, init }; + for (const middleware of this.middleware) { + if (middleware.pre) { + fetchParams = await middleware.pre({ + fetch: this.fetchApi, + ...fetchParams, + }) || fetchParams; + } + } + let response: Response | undefined = undefined; + try { + response = await (this.configuration.fetchApi || fetch)(fetchParams.url, fetchParams.init); + } catch (e) { + for (const middleware of this.middleware) { + if (middleware.onError) { + response = await middleware.onError({ + fetch: this.fetchApi, + url: fetchParams.url, + init: fetchParams.init, + error: e, + response: response ? response.clone() : undefined, + }) || response; + } + } + if (response === undefined) { + if (e instanceof Error) { + throw new FetchError(e, 'The request failed and the interceptors did not return an alternative response'); + } else { + throw e; + } + } + } + for (const middleware of this.middleware) { + if (middleware.post) { + response = await middleware.post({ + fetch: this.fetchApi, + url: fetchParams.url, + init: fetchParams.init, + response: response.clone(), + }) || response; + } + } + return response; + } + + /** + * Create a shallow clone of `this` by constructing a new instance + * and then shallow cloning data members. + */ + private clone(this: T): T { + const constructor = this.constructor as any; + const next = new constructor(this.configuration); + next.middleware = this.middleware.slice(); + return next; + } +}; + +function isBlob(value: any): value is Blob { + return typeof Blob !== 'undefined' && value instanceof Blob; +} + +function isFormData(value: any): value is FormData { + return typeof FormData !== "undefined" && value instanceof FormData; +} + +export class ResponseError extends Error { + override name: "ResponseError" = "ResponseError"; + constructor(public response: Response, msg?: string) { + super(msg); + } +} + +export class FetchError extends Error { + override name: "FetchError" = "FetchError"; + constructor(public cause: Error, msg?: string) { + super(msg); + } +} + +export class RequiredError extends Error { + override name: "RequiredError" = "RequiredError"; + constructor(public field: string, msg?: string) { + super(msg); + } +} + +export const COLLECTION_FORMATS = { + csv: ",", + ssv: " ", + tsv: "\t", + pipes: "|", +}; + +export type FetchAPI = WindowOrWorkerGlobalScope['fetch']; + +export type Json = any; +export type HTTPMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'OPTIONS' | 'HEAD'; +export type HTTPHeaders = { [key: string]: string }; +export type HTTPQuery = { [key: string]: string | number | null | boolean | Array | Set | HTTPQuery }; +export type HTTPBody = Json | FormData | URLSearchParams; +export type HTTPRequestInit = { headers?: HTTPHeaders; method: HTTPMethod; credentials?: RequestCredentials; body?: HTTPBody }; +export type ModelPropertyNaming = 'camelCase' | 'snake_case' | 'PascalCase' | 'original'; + +export type InitOverrideFunction = (requestContext: { init: HTTPRequestInit, context: RequestOpts }) => Promise + +export interface FetchParams { + url: string; + init: RequestInit; +} + +export interface RequestOpts { + path: string; + method: HTTPMethod; + headers: HTTPHeaders; + query?: HTTPQuery; + body?: HTTPBody; +} + +export function querystring(params: HTTPQuery, prefix: string = ''): string { + return Object.keys(params) + .map(key => querystringSingleKey(key, params[key], prefix)) + .filter(part => part.length > 0) + .join('&'); +} + +function querystringSingleKey(key: string, value: string | number | null | undefined | boolean | Array | Set | HTTPQuery, keyPrefix: string = ''): string { + const fullKey = keyPrefix + (keyPrefix.length ? `[${key}]` : key); + if (value instanceof Array) { + const multiValue = value.map(singleValue => encodeURIComponent(String(singleValue))) + .join(`&${encodeURIComponent(fullKey)}=`); + return `${encodeURIComponent(fullKey)}=${multiValue}`; + } + if (value instanceof Set) { + const valueAsArray = Array.from(value); + return querystringSingleKey(key, valueAsArray, keyPrefix); + } + if (value instanceof Date) { + return `${encodeURIComponent(fullKey)}=${encodeURIComponent(value.toISOString())}`; + } + if (value instanceof Object) { + return querystring(value as HTTPQuery, fullKey); + } + return `${encodeURIComponent(fullKey)}=${encodeURIComponent(String(value))}`; +} + +export function mapValues(data: any, fn: (item: any) => any) { + return Object.keys(data).reduce( + (acc, key) => ({ ...acc, [key]: fn(data[key]) }), + {} + ); +} + +export function canConsumeForm(consumes: Consume[]): boolean { + for (const consume of consumes) { + if ('multipart/form-data' === consume.contentType) { + return true; + } + } + return false; +} + +export interface Consume { + contentType: string; +} + +export interface RequestContext { + fetch: FetchAPI; + url: string; + init: RequestInit; +} + +export interface ResponseContext { + fetch: FetchAPI; + url: string; + init: RequestInit; + response: Response; +} + +export interface ErrorContext { + fetch: FetchAPI; + url: string; + init: RequestInit; + error: unknown; + response?: Response; +} + +export interface Middleware { + pre?(context: RequestContext): Promise; + post?(context: ResponseContext): Promise; + onError?(context: ErrorContext): Promise; +} + +export interface ApiResponse { + raw: Response; + value(): Promise; +} + +export interface ResponseTransformer { + (json: any): T; +} + +export class JSONApiResponse { + constructor(public raw: Response, private transformer: ResponseTransformer = (jsonValue: any) => jsonValue) {} + + async value(): Promise { + return this.transformer(await this.raw.json()); + } +} + +export class VoidApiResponse { + constructor(public raw: Response) {} + + async value(): Promise { + return undefined; + } +} + +export class BlobApiResponse { + constructor(public raw: Response) {} + + async value(): Promise { + return await this.raw.blob(); + }; +} + +export class TextApiResponse { + constructor(public raw: Response) {} + + async value(): Promise { + return await this.raw.text(); + }; +} diff --git a/src/api/pfdcm/index.test.ts b/src/api/pfdcm/index.test.ts new file mode 100644 index 000000000..3fb28ea70 --- /dev/null +++ b/src/api/pfdcm/index.test.ts @@ -0,0 +1,38 @@ +import { test, expect } from "vitest"; +import { PfdcmClient, Configuration } from "./index.ts"; +import { pipe } from "fp-ts/function"; +import * as TE from "fp-ts/TaskEither"; +import { PfdcmEnvironmentalDetailApi } from "./generated"; + +test("PfdcmClient", async (context) => { + const url = process.env.VITE_PFDCM_URL; + // const url = 'http://localhost:8088' + const configuration = new Configuration({ basePath: url }); + try { + const pfdcmDetailClient = new PfdcmEnvironmentalDetailApi(configuration); + const hello = await pfdcmDetailClient.readHelloApiV1HelloGet(); + expect(hello.name).toBe("pfdcm_hello"); + } catch (e) { + // pfdcm 'hello' endpoint not working, is pfdcm online? + context.skip(); + } + + const client = new PfdcmClient(configuration); + const getServices = pipe(client.getPacsServices(), TE.toUnion); + expect(await getServices()).toContain("MINICHRISORTHANC"); + + const query = { patientID: "1449c1d" }; + const queryAndAssertStudies = pipe( + client.query("MINICHRISORTHANC", query), + TE.mapLeft((e) => expect(e).toBeNull()), + TE.map((list) => { + const { study, series } = list[0]; + expect(study.PatientName).toBe("anonymized"); + expect(study.PatientBirthDate).toStrictEqual(new Date(2009, 6, 1)); + expect(series.map((s) => s.SeriesDescription.trim())).toContain( + "SAG MPRAGE 220 FOV", + ); + }), + ); + await queryAndAssertStudies(); +}); diff --git a/src/api/pfdcm/index.ts b/src/api/pfdcm/index.ts new file mode 100644 index 000000000..9f75cddcd --- /dev/null +++ b/src/api/pfdcm/index.ts @@ -0,0 +1,3 @@ +export { Configuration } from "./generated"; +export { PfdcmClient } from "./client"; +export type { PACSqueryCore } from "./generated"; diff --git a/src/api/pfdcm/models.ts b/src/api/pfdcm/models.ts new file mode 100644 index 000000000..abd937ff2 --- /dev/null +++ b/src/api/pfdcm/models.ts @@ -0,0 +1,88 @@ +import { PACSqueryCore } from "./generated"; + +type PypxTag = { + tag: 0 | string; + value: 0 | string; + label: string; +}; + +type Pypx = { + status: "success" | "error"; + command: string; + data: ReadonlyArray<{ + [key: string]: PypxTag | ReadonlyArray<{ [key: string]: PypxTag }>; + }>; + args: PACSqueryCore; +}; + +/** + * PFDCM "find" endpoint response, in its unprocessed and ugly original form. + * + * See https://github.com/FNNDSC/pfdcm/blob/3.1.22/pfdcm/controllers/pacsQRcontroller.py#L171-L176 + */ +type PypxFind = { + status: boolean; + find: {}; + message: string; + PACSdirective: PACSqueryCore; + pypx: Pypx; +}; + +/** + * DICOM study metadata. + */ +type Study = { + SpecificCharacterSet: string; + StudyDate: string; + AccessionNumber: string; + RetrieveAETitle: string; + ModalitiesInStudy: string; + StudyDescription: string; + PatientName: string; + PatientID: string; + PatientBirthDate: Date | null; + PatientSex: string; + PatientAge: number | null; + ProtocolName: string; + AcquisitionProtocolName: string; + AcquisitionProtocolDescription: string; + StudyInstanceUID: string; + NumberOfStudyRelatedSeries: string; + PerformedStationAETitle: string; +}; + +/** + * DICOM series metadata. + */ +type Series = { + SpecificCharacterSet: string; + StudyDate: string; + SeriesDate: string; + AccessionNumber: string; + RetrieveAETitle: string; + Modality: string; + StudyDescription: string; + SeriesDescription: string; + PatientName: string; + PatientID: string; + PatientBirthDate: Date | null; + PatientSex: string; + PatientAge: number | null; + ProtocolName: string; + AcquisitionProtocolName: string; + AcquisitionProtocolDescription: string; + StudyInstanceUID: string; + SeriesInstanceUID: string; + NumberOfSeriesRelatedInstances: string; + PerformedStationAETitle: string; +}; + +/** + * PACS query response data. + */ +type StudyAndSeries = { + study: Study; + series: ReadonlyArray; +}; + +export type { PypxFind, Pypx, PypxTag, Study, Series, StudyAndSeries }; diff --git a/src/components/Pacs/pfdcmClient.tsx b/src/components/Pacs/pfdcmClient.tsx index a8e39c8e6..bb5c50f9f 100644 --- a/src/components/Pacs/pfdcmClient.tsx +++ b/src/components/Pacs/pfdcmClient.tsx @@ -19,7 +19,7 @@ class PfdcmClient { private readonly url: string; constructor() { - this.url = import.meta.env.VITE_PFDCM_URL || ""; + this.url = import.meta.env.VITE_PFDCM_URL + "/" || ""; } async getPacsServices(): Promise> { From 86e791c181f1cf82922d0e7ab6a0dc97c9d0d7c8 Mon Sep 17 00:00:00 2001 From: Jennings Zhang Date: Mon, 16 Sep 2024 19:28:04 -0400 Subject: [PATCH 03/41] Begin rewriting Pacs --- .env | 3 + package-lock.json | 566 ++++++++++------ package.json | 5 +- src/App.tsx | 8 +- src/api/fp/chrisapi.ts | 29 +- src/api/fp/helpers.test.ts | 4 +- .../NiivueDatasetViewer.tsx | 2 +- .../components/SettingsTab.tsx | 2 +- .../NiivueDatasetViewer/content/footer.tsx | 2 +- src/components/Pacs/app.test.tsx | 86 +++ src/components/Pacs/app.tsx | 156 +++++ .../Pacs/components/ErrorScreen.tsx | 33 + .../Pacs/components/PatientCard.tsx | 153 ----- src/components/Pacs/components/SeriesCard.tsx | 629 ------------------ .../Pacs/components/SettingsComponents.tsx | 215 ------ src/components/Pacs/components/StudyCard.tsx | 366 ---------- src/components/Pacs/components/input.test.ts | 12 + src/components/Pacs/components/input.tsx | 192 ++++++ src/components/Pacs/components/loading.tsx | 22 + .../Pacs/components/usePullStudyHook.tsx | 124 ---- src/components/Pacs/components/utils.ts | 36 - src/components/Pacs/index.tsx | 580 +--------------- src/components/Pacs/pacs-copy.css | 123 ---- src/components/Pacs/pacs.tsx | 48 ++ src/components/Pacs/pfdcmClient.tsx | 100 --- src/components/Pacs/useSettings.tsx | 79 --- src/components/Wrapper/TitleComponent.tsx | 17 +- .../NiivueDatasetViewer => }/cssUtils.ts | 0 vitest.config.ts | 11 + vitest.setup.ts | 10 + 30 files changed, 1025 insertions(+), 2588 deletions(-) create mode 100644 src/components/Pacs/app.test.tsx create mode 100644 src/components/Pacs/app.tsx create mode 100644 src/components/Pacs/components/ErrorScreen.tsx delete mode 100644 src/components/Pacs/components/PatientCard.tsx delete mode 100644 src/components/Pacs/components/SeriesCard.tsx delete mode 100644 src/components/Pacs/components/SettingsComponents.tsx delete mode 100644 src/components/Pacs/components/StudyCard.tsx create mode 100644 src/components/Pacs/components/input.test.ts create mode 100644 src/components/Pacs/components/input.tsx create mode 100644 src/components/Pacs/components/loading.tsx delete mode 100644 src/components/Pacs/components/usePullStudyHook.tsx delete mode 100644 src/components/Pacs/components/utils.ts delete mode 100644 src/components/Pacs/pacs-copy.css create mode 100644 src/components/Pacs/pacs.tsx delete mode 100644 src/components/Pacs/pfdcmClient.tsx delete mode 100644 src/components/Pacs/useSettings.tsx rename src/{components/NiivueDatasetViewer => }/cssUtils.ts (100%) create mode 100644 vitest.setup.ts diff --git a/.env b/.env index 684c225fb..8d1ac18f1 100644 --- a/.env +++ b/.env @@ -18,5 +18,8 @@ VITE_PFDCM_CUBEKEY="local" VITE_PFDCM_SWIFTKEY="local" VITE_SOURCEMAP='false' +# URI for support requests +VITE_SUPPORT_URL='mailto:dev@babyMRI.org' + # Set URL for the store if you want to see it in the sidebar VITE_CHRIS_STORE_URL= "http://rc-live.tch.harvard.edu:32222/api/v1/" diff --git a/package-lock.json b/package-lock.json index 5db811a7b..725c15f65 100644 --- a/package-lock.json +++ b/package-lock.json @@ -75,6 +75,9 @@ "@biomejs/biome": "1.8.3", "@faker-js/faker": "^8.4.0", "@playwright/test": "^1.41.2", + "@testing-library/dom": "^10.4.0", + "@testing-library/jest-dom": "^6.5.0", + "@testing-library/react": "^16.0.1", "@types/d3-hierarchy": "^1.1.7", "@types/d3-selection": "^1.4.3", "@types/lodash": "^4.14.202", @@ -94,10 +97,17 @@ "rollup-plugin-node-builtins": "^2.1.2", "vite": "^5.3.2", "vite-plugin-istanbul": "^6.0.2", - "vitest": "^2.0.5", + "vitest": "^2.1.1", "vitest-websocket-mock": "^0.4.0" } }, + "node_modules/@adobe/css-tools": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.0.tgz", + "integrity": "sha512-Ff9+ksdQQB3rMncgqDK78uLznstjyfIf2Arnh22pW8kBpLs6rpKDwgnZT46hin5Hl1WzazzK64DOrhSwYpS7bQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@ampproject/remapping": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", @@ -1801,12 +1811,198 @@ "react": "^18 || ^19" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz", + "integrity": "sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "chalk": "^4.1.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/dom/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@testing-library/dom/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@testing-library/dom/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/@testing-library/dom/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.5.0.tgz", + "integrity": "sha512-xGGHpBXYSHUUr6XsKBfs85TWlYKpTc37cSBBVrXcib2MkHLboWlkClhWF37JKlDb9KEq3dHs+f2xR7XJEWGBxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "chalk": "^3.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "lodash": "^4.17.21", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/chalk": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", + "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.0.1.tgz", + "integrity": "sha512-dSmwJVtJXmku+iocRhWOUFbrERC76TX2Mnf0ATODz8brzAZrMBbzLwQixlBSanZxR6LddK3eiwpSFZgDET1URg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0", + "@types/react-dom": "^18.0.0", + "react": "^18.0.0", + "react-dom": "^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@tweenjs/tween.js": { "version": "23.1.3", "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", "license": "MIT" }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -2212,14 +2408,14 @@ } }, "node_modules/@vitest/expect": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.0.5.tgz", - "integrity": "sha512-yHZtwuP7JZivj65Gxoi8upUN2OzHTi3zVfjwdpu2WrvCZPLwsJ2Ey5ILIPccoW23dd/zQBlJ4/dhi7DWNyXCpA==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.1.tgz", + "integrity": "sha512-YeueunS0HiHiQxk+KEOnq/QMzlUuOzbU1Go+PgAsHvvv3tUkJPm9xWt+6ITNTlzsMXUjmgm5T+U7KBPK2qQV6w==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "2.0.5", - "@vitest/utils": "2.0.5", + "@vitest/spy": "2.1.1", + "@vitest/utils": "2.1.1", "chai": "^5.1.1", "tinyrainbow": "^1.2.0" }, @@ -2227,10 +2423,38 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@vitest/mocker": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.1.tgz", + "integrity": "sha512-LNN5VwOEdJqCmJ/2XJBywB11DLlkbY0ooDJW3uRX5cZyYCrc4PI/ePX0iQhE3BiEGiQmK4GE7Q/PqCkkaiPnrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "^2.1.0-beta.1", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.11" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/spy": "2.1.1", + "msw": "^2.3.5", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, "node_modules/@vitest/pretty-format": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.0.5.tgz", - "integrity": "sha512-h8k+1oWHfwTkyTkb9egzwNMfJAEx4veaPSnMeKbVSjp4euqGSbQlm5+6VHwTr7u4FJslVVsUG5nopCaAYdOmSQ==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.1.tgz", + "integrity": "sha512-SjxPFOtuINDUW8/UkElJYQSFtnWX7tMksSGW0vfjxMneFqxVr8YJ979QpMbDW7g+BIiq88RAGDjf7en6rvLPPQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2241,13 +2465,13 @@ } }, "node_modules/@vitest/runner": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.0.5.tgz", - "integrity": "sha512-TfRfZa6Bkk9ky4tW0z20WKXFEwwvWhRY+84CnSEtq4+3ZvDlJyY32oNTJtM7AW9ihW90tX/1Q78cb6FjoAs+ig==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.1.tgz", + "integrity": "sha512-uTPuY6PWOYitIkLPidaY5L3t0JJITdGTSwBtwMjKzo5O6RCOEncz9PUN+0pDidX8kTHYjO0EwUIvhlGpnGpxmA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "2.0.5", + "@vitest/utils": "2.1.1", "pathe": "^1.1.2" }, "funding": { @@ -2255,14 +2479,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.0.5.tgz", - "integrity": "sha512-SgCPUeDFLaM0mIUHfaArq8fD2WbaXG/zVXjRupthYfYGzc8ztbFbu6dUNOblBG7XLMR1kEhS/DNnfCZ2IhdDew==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.1.tgz", + "integrity": "sha512-BnSku1WFy7r4mm96ha2FzN99AZJgpZOWrAhtQfoxjUU5YMRpq1zmHRq7a5K9/NjqonebO7iVDla+VvZS8BOWMw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "2.0.5", - "magic-string": "^0.30.10", + "@vitest/pretty-format": "2.1.1", + "magic-string": "^0.30.11", "pathe": "^1.1.2" }, "funding": { @@ -2270,9 +2494,9 @@ } }, "node_modules/@vitest/spy": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.0.5.tgz", - "integrity": "sha512-c/jdthAhvJdpfVuaexSrnawxZz6pywlTPe84LUB2m/4t3rl2fTo9NFGBG4oWgaD+FTgDDV8hJ/nibT7IfH3JfA==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.1.tgz", + "integrity": "sha512-ZM39BnZ9t/xZ/nF4UwRH5il0Sw93QnZXd9NAZGRpIgj0yvVwPpLd702s/Cx955rGaMlyBQkZJ2Ir7qyY48VZ+g==", "dev": true, "license": "MIT", "dependencies": { @@ -2283,14 +2507,13 @@ } }, "node_modules/@vitest/utils": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.0.5.tgz", - "integrity": "sha512-d8HKbqIcya+GR67mkZbrzhS5kKhtp8dQLcmRZLGTscGVg7yImT82cIrhtn2L8+VujWcy6KZweApgNmPsTAO/UQ==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.1.tgz", + "integrity": "sha512-Y6Q9TsI+qJ2CC0ZKj6VBb+T8UPz593N113nnUykqwANqhgf3QkZeHFlusgKLTqrnVHbj/XDKZcDHol+dxVT+rQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "2.0.5", - "estree-walker": "^3.0.3", + "@vitest/pretty-format": "2.1.1", "loupe": "^3.1.1", "tinyrainbow": "^1.2.0" }, @@ -2534,6 +2757,16 @@ "sprintf-js": "~1.0.2" } }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, "node_modules/array-equal": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/array-equal/-/array-equal-1.0.2.tgz", @@ -3342,6 +3575,13 @@ "node": "*" } }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/cssfilter": { "version": "0.0.10", "resolved": "https://registry.npmjs.org/cssfilter/-/cssfilter-0.0.10.tgz", @@ -3858,6 +4098,13 @@ "dev": true, "license": "MIT" }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, "node_modules/dom-helpers": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", @@ -4173,56 +4420,6 @@ "safe-buffer": "^5.1.1" } }, - "node_modules/execa": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", - "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cross-spawn": "^7.0.3", - "get-stream": "^8.0.1", - "human-signals": "^5.0.0", - "is-stream": "^3.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^5.1.0", - "onetime": "^6.0.0", - "signal-exit": "^4.1.0", - "strip-final-newline": "^3.0.0" - }, - "engines": { - "node": ">=16.17" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, - "node_modules/execa/node_modules/is-stream": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", - "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/execa/node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -4605,19 +4802,6 @@ "node": ">=4" } }, - "node_modules/get-stream": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", - "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/gl-matrix": { "version": "3.4.3", "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.3.tgz", @@ -4859,16 +5043,6 @@ "entities": "^4.4.0" } }, - "node_modules/human-signals": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", - "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=16.17.0" - } - }, "node_modules/idb-wrapper": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/idb-wrapper/-/idb-wrapper-1.7.2.tgz", @@ -5748,6 +5922,16 @@ "dev": true, "license": "MIT" }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.11", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.11.tgz", @@ -5816,13 +6000,6 @@ "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==", "license": "MIT" }, - "node_modules/merge-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "dev": true, - "license": "MIT" - }, "node_modules/micromark": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.0.tgz", @@ -6406,17 +6583,14 @@ "node": ">= 0.6" } }, - "node_modules/mimic-fn": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", - "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", "dev": true, "license": "MIT", "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=4" } }, "node_modules/minimalistic-assert": { @@ -6581,35 +6755,6 @@ "integrity": "sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==", "license": "MIT" }, - "node_modules/npm-run-path": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", - "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^4.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/npm-run-path/node_modules/path-key": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", - "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/nyc": { "version": "17.0.0", "resolved": "https://registry.npmjs.org/nyc/-/nyc-17.0.0.tgz", @@ -6727,22 +6872,6 @@ "wrappy": "1" } }, - "node_modules/onetime": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", - "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "mimic-fn": "^4.0.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/p-limit": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", @@ -7093,6 +7222,41 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, "node_modules/preval.macro": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/preval.macro/-/preval.macro-5.0.0.tgz", @@ -8260,6 +8424,20 @@ "node": ">= 0.10" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/redux": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", @@ -8957,17 +9135,17 @@ "node": ">=8" } }, - "node_modules/strip-final-newline": { + "node_modules/strip-indent": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", - "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", "dev": true, "license": "MIT", - "engines": { - "node": ">=12" + "dependencies": { + "min-indent": "^1.0.0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "engines": { + "node": ">=8" } }, "node_modules/stylis": { @@ -9058,6 +9236,13 @@ "node": ">=4" } }, + "node_modules/tinyexec": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.0.tgz", + "integrity": "sha512-tVGE0mVJPGb0chKhqmsoosjsS+qUnJVGJpZgsHYQcGoPlG3B51R3PouqTgEGH2Dc9jjFyOqOpix6ZHNMXp1FZg==", + "dev": true, + "license": "MIT" + }, "node_modules/tinypool": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.0.1.tgz", @@ -9079,9 +9264,9 @@ } }, "node_modules/tinyspy": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.0.tgz", - "integrity": "sha512-q5nmENpTHgiPVd1cJDDc9cVoYN5x4vCvwT3FMilvKPKneCBZAxn2YWQjDF0UMcE9k0Cay1gBiDfTMU0g+mPMQA==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", "dev": true, "license": "MIT", "engines": { @@ -9752,16 +9937,15 @@ } }, "node_modules/vite-node": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.0.5.tgz", - "integrity": "sha512-LdsW4pxj0Ot69FAoXZ1yTnA9bjGohr2yNBU7QKRxpz8ITSkhuDl6h3zS/tvgz4qrNjeRnvrWeXQ8ZF7Um4W00Q==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.1.tgz", + "integrity": "sha512-N/mGckI1suG/5wQI35XeR9rsMsPqKXzq1CdUndzVstBj/HvyxxGctwnK6WX43NGt5L3Z5tcRf83g4TITKJhPrA==", "dev": true, "license": "MIT", "dependencies": { "cac": "^6.7.14", - "debug": "^4.3.5", + "debug": "^4.3.6", "pathe": "^1.1.2", - "tinyrainbow": "^1.2.0", "vite": "^5.0.0" }, "bin": { @@ -9822,30 +10006,30 @@ } }, "node_modules/vitest": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.0.5.tgz", - "integrity": "sha512-8GUxONfauuIdeSl5f9GTgVEpg5BTOlplET4WEDaeY2QBiN8wSm68vxN/tb5z405OwppfoCavnwXafiaYBC/xOA==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.1.tgz", + "integrity": "sha512-97We7/VC0e9X5zBVkvt7SGQMGrRtn3KtySFQG5fpaMlS+l62eeXRQO633AYhSTC3z7IMebnPPNjGXVGNRFlxBA==", "dev": true, "license": "MIT", "dependencies": { - "@ampproject/remapping": "^2.3.0", - "@vitest/expect": "2.0.5", - "@vitest/pretty-format": "^2.0.5", - "@vitest/runner": "2.0.5", - "@vitest/snapshot": "2.0.5", - "@vitest/spy": "2.0.5", - "@vitest/utils": "2.0.5", + "@vitest/expect": "2.1.1", + "@vitest/mocker": "2.1.1", + "@vitest/pretty-format": "^2.1.1", + "@vitest/runner": "2.1.1", + "@vitest/snapshot": "2.1.1", + "@vitest/spy": "2.1.1", + "@vitest/utils": "2.1.1", "chai": "^5.1.1", - "debug": "^4.3.5", - "execa": "^8.0.1", - "magic-string": "^0.30.10", + "debug": "^4.3.6", + "magic-string": "^0.30.11", "pathe": "^1.1.2", "std-env": "^3.7.0", - "tinybench": "^2.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.0", "tinypool": "^1.0.0", "tinyrainbow": "^1.2.0", "vite": "^5.0.0", - "vite-node": "2.0.5", + "vite-node": "2.1.1", "why-is-node-running": "^2.3.0" }, "bin": { @@ -9860,8 +10044,8 @@ "peerDependencies": { "@edge-runtime/vm": "*", "@types/node": "^18.0.0 || >=20.0.0", - "@vitest/browser": "2.0.5", - "@vitest/ui": "2.0.5", + "@vitest/browser": "2.1.1", + "@vitest/ui": "2.1.1", "happy-dom": "*", "jsdom": "*" }, diff --git a/package.json b/package.json index 87c6be441..fa6df4e19 100644 --- a/package.json +++ b/package.json @@ -105,6 +105,9 @@ "@biomejs/biome": "1.8.3", "@faker-js/faker": "^8.4.0", "@playwright/test": "^1.41.2", + "@testing-library/dom": "^10.4.0", + "@testing-library/jest-dom": "^6.5.0", + "@testing-library/react": "^16.0.1", "@types/d3-hierarchy": "^1.1.7", "@types/d3-selection": "^1.4.3", "@types/lodash": "^4.14.202", @@ -124,7 +127,7 @@ "rollup-plugin-node-builtins": "^2.1.2", "vite": "^5.3.2", "vite-plugin-istanbul": "^6.0.2", - "vitest": "^2.0.5", + "vitest": "^2.1.1", "vitest-websocket-mock": "^0.4.0" }, "type": "module" diff --git a/src/App.tsx b/src/App.tsx index 0f98b3133..64dfe0630 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,6 @@ import "@patternfly/react-core/dist/styles/base.css"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { ConfigProvider, theme } from "antd"; +import { ConfigProvider, App as AntdApp, theme } from "antd"; import { useContext } from "react"; import { CookiesProvider } from "react-cookie"; import { Provider } from "react-redux"; @@ -58,8 +58,10 @@ function App(props: AllProps) { : theme.defaultAlgorithm, }} > - - + + + + diff --git a/src/api/fp/chrisapi.ts b/src/api/fp/chrisapi.ts index 21c7fc1df..b860d1651 100644 --- a/src/api/fp/chrisapi.ts +++ b/src/api/fp/chrisapi.ts @@ -124,7 +124,7 @@ class FpClient { } /** - * Create a WebSockets connection to the LONK-WS endpoint. + * Connect a WebSocket to the LONK-WS endpoint. * * https://chrisproject.org/docs/oxidicom/lonk-ws */ @@ -136,10 +136,31 @@ class FpClient { }: LonkHandlers & { timeout?: number }): TE.TaskEither { return pipe( this.createDownloadToken(timeout), - TE.map((downloadToken) => { + TE.flatMap((downloadToken) => { const url = getWebsocketUrl(downloadToken); + let callback: ((c: E.Either) => void) | null = null; + let promise: Promise> = new Promise( + (resolve) => (callback = resolve), + ); const ws = new WebSocket(url); - return new LonkClient({ ws, onDone, onProgress, onError }); + ws.onopen = () => + callback && + callback( + E.right(new LonkClient({ ws, onDone, onProgress, onError })), + ); + ws.onerror = (_ev) => + callback && + callback( + E.left( + new Error( + `There was an error connecting to the WebSocket at ${url}`, + ), + ), + ); + ws.onclose = () => + callback && + callback(E.left(new Error(`CUBE unexpectedly closed the WebSocket`))); + return () => promise; }), ); } @@ -149,7 +170,7 @@ function getWebsocketUrl(downloadTokenResponse: DownloadToken): string { const token = downloadTokenResponse.data.token; return downloadTokenResponse.url .replace(/^http(s?):\/\//, (_match, s) => `ws${s}://`) - .replace(/v1\/downloadtokens\/\d+\//, `v1/pacs/progress/?token=${token}`); + .replace(/v1\/downloadtokens\/\d+\//, `v1/pacs/ws/?token=${token}`); } function notNull(x: T | null): T { diff --git a/src/api/fp/helpers.test.ts b/src/api/fp/helpers.test.ts index c3f662c0a..cc7181e96 100644 --- a/src/api/fp/helpers.test.ts +++ b/src/api/fp/helpers.test.ts @@ -16,7 +16,7 @@ test.each([ owner_username: "chris", }, }, - "ws://example.com/api/v1/pacs/progress/?token=nota.real.jwttoken", + "ws://example.com/api/v1/pacs/ws/?token=nota.real.jwttoken", ], [ { @@ -32,7 +32,7 @@ test.each([ owner_username: "chris", }, }, - "wss://example.com/api/v1/pacs/progress/?token=stillnota.real.jwttoken", + "wss://example.com/api/v1/pacs/ws/?token=stillnota.real.jwttoken", ], ])("getWebsocketUrl(%o, %s) -> %s", (downloadTokenResponse, expected) => { // @ts-ignore diff --git a/src/components/NiivueDatasetViewer/NiivueDatasetViewer.tsx b/src/components/NiivueDatasetViewer/NiivueDatasetViewer.tsx index 9fba2fbb2..483b65fc2 100644 --- a/src/components/NiivueDatasetViewer/NiivueDatasetViewer.tsx +++ b/src/components/NiivueDatasetViewer/NiivueDatasetViewer.tsx @@ -17,7 +17,7 @@ import { getDataset, getPreClient, } from "./client"; -import { flexRowSpaceBetween, hideOnMobile } from "./cssUtils"; +import { flexRowSpaceBetween, hideOnMobile } from "../../cssUtils.ts"; import { FpClient } from "../../api/fp/chrisapi"; import { pipe } from "fp-ts/function"; import * as TE from "fp-ts/TaskEither"; diff --git a/src/components/NiivueDatasetViewer/components/SettingsTab.tsx b/src/components/NiivueDatasetViewer/components/SettingsTab.tsx index eea783d71..65c529909 100644 --- a/src/components/NiivueDatasetViewer/components/SettingsTab.tsx +++ b/src/components/NiivueDatasetViewer/components/SettingsTab.tsx @@ -11,7 +11,7 @@ import { Switch, } from "@patternfly/react-core"; import RadiologcalConventionToggle from "./RadiologcalConventionToggle"; -import { hideOnDesktop } from "../cssUtils"; +import { hideOnDesktop } from "../../../cssUtils"; import Spacing from "@patternfly/react-styles/css/utilities/Spacing/spacing"; import { css } from "@patternfly/react-styles"; import SliceTypeButton from "./SliceTypeButton"; diff --git a/src/components/NiivueDatasetViewer/content/footer.tsx b/src/components/NiivueDatasetViewer/content/footer.tsx index 3cfa11d5d..355fc295d 100644 --- a/src/components/NiivueDatasetViewer/content/footer.tsx +++ b/src/components/NiivueDatasetViewer/content/footer.tsx @@ -1,4 +1,4 @@ -import { hideOnDesktop, hideOnMobileInline } from "../cssUtils.ts"; +import { hideOnDesktop, hideOnMobileInline } from "../../../cssUtils.ts"; import { Chip, Popover, TextContent } from "@patternfly/react-core"; import BUILD_VERSION from "../../../getBuildVersion.ts"; import { css } from "@patternfly/react-styles"; diff --git a/src/components/Pacs/app.test.tsx b/src/components/Pacs/app.test.tsx new file mode 100644 index 000000000..7ace09342 --- /dev/null +++ b/src/components/Pacs/app.test.tsx @@ -0,0 +1,86 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { expect, test, vi } from "vitest"; +import PacsQRApp from "./app.tsx"; +import * as TE from "fp-ts/TaskEither"; +import { Configuration as PfdcmConfig, PfdcmClient } from "../../api/pfdcm"; +import ChrisClient, { DownloadToken } from "@fnndsc/chrisapi"; +import WS from "vitest-websocket-mock"; + +test("PACS Q/R page can bootstrap", async () => { + const pfdcmClient = createPfdcmMock(TE.right(["BCH", "MGH", "BWH"])); + const [chrisClient, ws] = createWorkingMockPacsWs(32584); + + const getClientMocks = { + getChrisClient: vi.fn(() => chrisClient), + getPfdcmClient: vi.fn(() => pfdcmClient), + }; + render(); + + await ws.connected; + + await expect + .poll(() => screen.getByPlaceholderText("Search for DICOM studies by MRN")) + .toBeInTheDocument(); + + // First PACS service (besides 'default') should be automatically selected. + expect(screen.getByTitle("PACS service")).toHaveTextContent("BCH"); + + // component should close WebSocket connection when unmounted + cleanup(); + await ws.closed; +}); + +test("Shows error screen if PFDCM is offline", async () => { + const pfdcmClient = createPfdcmMock( + TE.left(new Error("I am an expected error")), + ); + const [chrisClient, _ws] = createWorkingMockPacsWs(32583); + + const getClientMocks = { + getChrisClient: vi.fn(() => chrisClient), + getPfdcmClient: vi.fn(() => pfdcmClient), + }; + render(); + + await expect + .poll(() => screen.getByText("I am an expected error")) + .toBeInTheDocument(); + expect( + screen.getByText(/PACS Q\/R application is currently unavailable/), + ).toBeInTheDocument(); +}); + +function createWorkingMockPacsWs( + port: number, + id: number = 55, +): [ChrisClient, WS] { + const fakeChrisHost = `localhost:${port}`; + const fakeChrisUrl = `http://${fakeChrisHost}/api/v1/`; + const fakeChrisAuth = { token: "12345" }; + vi.spyOn(DownloadToken.prototype, "data", "get").mockReturnValue({ + token: "abcdefgnotarealjwt", + }); + const fakeDownloadToken = new DownloadToken( + `${fakeChrisUrl}downloadtokens/${id}/`, + fakeChrisAuth, + ); + + const client = new ChrisClient(fakeChrisUrl, fakeChrisAuth); + client.createDownloadToken = vi.fn(async () => fakeDownloadToken); + + const ws = new WS( + `ws://${fakeChrisHost}/api/v1/pacs/ws/?token=abcdefgnotarealjwt`, + { jsonProtocol: true }, + ); + + return [client, ws]; +} + +function createPfdcmMock( + servicesReturn: ReturnType, +) { + const pfdcmConfig = new PfdcmConfig({ basePath: "https://example.com" }); + const pfdcmClient = new PfdcmClient(pfdcmConfig); + pfdcmClient.getPacsServices = vi.fn(() => servicesReturn); + return pfdcmClient; +} diff --git a/src/components/Pacs/app.tsx b/src/components/Pacs/app.tsx new file mode 100644 index 000000000..bb6fceeaa --- /dev/null +++ b/src/components/Pacs/app.tsx @@ -0,0 +1,156 @@ +/** + * The primary PACS Q/R UI code is found in ./pacs.tsx. This file defines a + * component which wraps the default export from ./pacs.tsx, bootstrapping + * the client objects it needs e.g. + * + * 1. Making an initial connection to PFDCM + * 2. Connecting to the PACS receive progress WebSocket, `api/v1/pacs/ws/` + * + * During bootstrapping, a loading screen is shown. + * If bootstrapping fails, an error screen is shown. + */ + +import React from "react"; +import { PfdcmClient } from "../../api/pfdcm"; +import Client from "@fnndsc/chrisapi"; +import LonkClient from "../../api/lonk"; +import { App, Typography } from "antd"; +import FpClient from "../../api/fp/chrisapi.ts"; +import * as TE from "fp-ts/TaskEither"; +import { pipe } from "fp-ts/function"; +import { PageSection } from "@patternfly/react-core"; +import PacsQR from "./pacs.tsx"; +import PacsLoadingScreen from "./components/loading.tsx"; +import ErrorScreen from "./components/ErrorScreen.tsx"; + +/** + * A title and paragraph. + */ +const ErrorNotificationBody: React.FC< + React.PropsWithChildren<{ title: string }> +> = ({ title, children }) => ( + + {title} + {children} + +); + +/** + * ChRIS_ui PACS Query and Retrieve application. + */ +const PacsQRApp: React.FC<{ + getPfdcmClient: () => PfdcmClient; + getChrisClient: () => Client; +}> = ({ getChrisClient, getPfdcmClient }) => { + const [services, setServices] = React.useState>([]); + const [service, setService] = React.useState(null); + const [lonkClient, setLonkClient] = React.useState(null); + const [error, setError] = React.useState(null); + + const { message, notification, modal } = App.useApp(); + + const pushError = React.useMemo(() => { + return (title: string) => { + return (e: Error) => { + notification.error({ + message: ( + + {e.message} + + ), + }); + }; + }; + }, [notification]); + + const pfdcmClient = React.useMemo(getPfdcmClient, [getPfdcmClient]); + const chrisClient = React.useMemo(getChrisClient, [getChrisClient]); + + const fpClient = React.useMemo(() => { + return new FpClient(chrisClient); + }, [chrisClient]); + + /** + * Show an error screen with the error's message. + * + * Used to handle errors during necessary bootstrapping. + */ + const failWithError = TE.mapLeft((e: Error) => setError(e.message)); + + React.useEffect(() => { + document.title = "ChRIS PACS"; + }, []); + + React.useEffect(() => { + const getServicesPipeline = pipe( + pfdcmClient.getPacsServices(), + failWithError, + TE.map((services) => { + setServices(services); + const defaultService = getDefaultPacsService(services); + defaultService && setService(defaultService); + }), + ); + getServicesPipeline(); + }, [pfdcmClient, pushError]); + + React.useEffect(() => { + let lonkClientRef: LonkClient | null = null; + + const onDone = () => {}; // TODO + const onProgress = () => {}; // TODO + const onError = () => {}; // TODO + const connectWsPipeline = pipe( + fpClient.connectPacsNotifications({ onDone, onProgress, onError }), + failWithError, + TE.map((client) => (lonkClientRef = client)), + TE.map(setLonkClient), + ); + connectWsPipeline(); + + return () => lonkClientRef?.close(); + }, [fpClient, pushError]); + + return ( + + {error !== null ? ( + {error} + ) : services && service && lonkClient ? ( + + ) : ( + + )} + + ); +}; + +/** + * Selects the default PACS service (which is usually not the PACS service literally called "default"). + * + * 1. Selects the hard-coded "PACSDCM" + * 2. Attempts to select the first value which is not "default" (a useless, legacy pfdcm behavior) + * 3. Selects the first value + */ +function getDefaultPacsService(services: ReadonlyArray): string | null { + if (services.includes("PACSDCM")) { + return "PACSDCM"; + } + for (const service of services) { + if (service !== "default") { + return service; + } + } + if (services) { + return services[0]; + } + return null; +} + +export default PacsQRApp; diff --git a/src/components/Pacs/components/ErrorScreen.tsx b/src/components/Pacs/components/ErrorScreen.tsx new file mode 100644 index 000000000..c69d06171 --- /dev/null +++ b/src/components/Pacs/components/ErrorScreen.tsx @@ -0,0 +1,33 @@ +import React from "react"; +import { + EmptyState, + EmptyStateBody, + EmptyStateHeader, + EmptyStateIcon, + Text, + TextContent, + TextVariants, +} from "@patternfly/react-core"; +import { ExclamationCircleIcon } from "../../Icons"; + +const ErrorScreen: React.FC> = ({ children }) => ( + + } + /> + + + {children} + + The ChRIS PACS Q/R application is currently unavailable. + Please contact your ChRIS admin by clicking{" "} + here. + + + + +); + +export default ErrorScreen; diff --git a/src/components/Pacs/components/PatientCard.tsx b/src/components/Pacs/components/PatientCard.tsx deleted file mode 100644 index 66e9b4855..000000000 --- a/src/components/Pacs/components/PatientCard.tsx +++ /dev/null @@ -1,153 +0,0 @@ -import { - Card, - CardHeader, - Grid, - GridItem, - Skeleton, - Tooltip, -} from "@patternfly/react-core"; -import { notification } from "../../Antd"; -import { format, parse } from "date-fns"; -import { useContext, useEffect, useState } from "react"; -import { PacsQueryContext } from "../context"; -import useSettings from "../useSettings"; -import { CardHeaderComponent } from "./SettingsComponents"; -import StudyCard from "./StudyCard"; - -function getPatientDetails(patientDetails: any) { - return { - PatientName: patientDetails.PatientName.value, - PatientID: patientDetails.PatientID.value, - PatientBirthDate: patientDetails.PatientBirthDate.value, - PatientSex: patientDetails.PatientSex.value, - }; -} - -const PatientCard = ({ queryResult }: { queryResult: any }) => { - const [api, contextHolder] = notification.useNotification(); - const { data, isLoading, error, isError } = useSettings(); - const { state } = useContext(PacsQueryContext); - const patient = queryResult[0]; - const patientDetails = getPatientDetails(patient); - const [isPatientExpanded, setIsPatientExpanded] = useState( - state.shouldDefaultExpanded || false, - ); - const { PatientID, PatientName, PatientBirthDate, PatientSex } = - patientDetails; - - useEffect(() => { - if (isError) { - api.error({ - message: error.message, - description: "Failed to load user preferences", - }); - } - }, [isError]); - - const parsedDate = parse(PatientBirthDate, "yyyyMMdd", new Date()); - - const formattedDate = Number.isNaN( - parsedDate.getTime(), - ) /* Check if parsedDate is a valid date */ - ? PatientBirthDate - : format(parsedDate, "MMMM d, yyyy"); - - const LatestDate = (dateStrings: string[]) => { - let latestStudy = parse(dateStrings[0], "yyyyMMdd", new Date()); - - for (const dateString of dateStrings) { - const currentDate = parse(dateString, "yyyyMMdd", new Date()); - - if (currentDate > latestStudy) { - latestStudy = currentDate; - } - } - - return latestStudy; - }; - - const userPreferences = data?.patient; - const userPreferencesArray = userPreferences && Object.keys(userPreferences); - - return ( - <> - - {contextHolder} - , - }} - onExpand={() => setIsPatientExpanded(!isPatientExpanded)} - > - - {isLoading ? ( - - - - ) : !isError && - userPreferences && - userPreferencesArray && - userPreferencesArray.length > 0 ? ( - userPreferencesArray.map((key: string) => ( - -
{key}
- -
- {patient[key] ? patient[key].value : "N/A"} -
-
-
- )) - ) : ( - <> - -
- Patient Name: {PatientName.split("^").reverse().join(" ")} -
-
Patient MRN: {PatientID}
-
- -
Patient Sex: {PatientSex}
-
Patient Birth Date: {formattedDate}
-
- - -
- {queryResult.length}{" "} - {queryResult.length === 1 ? "study" : "studies"} -
-
- Latest Study Date:{" "} - {LatestDate( - queryResult.map((s: any) => s.StudyDate.value), - ).toDateString()} -
-
- - )} -
-
-
- {isPatientExpanded && - queryResult.map((result: any) => { - return ( -
- -
- ); - })} - - ); -}; - -export default PatientCard; diff --git a/src/components/Pacs/components/SeriesCard.tsx b/src/components/Pacs/components/SeriesCard.tsx deleted file mode 100644 index 7e5d72bab..000000000 --- a/src/components/Pacs/components/SeriesCard.tsx +++ /dev/null @@ -1,629 +0,0 @@ -import type { PACSFile } from "@fnndsc/chrisapi"; -import { - Badge, - Button, - Card, - CardBody, - CardHeader, - HelperText, - HelperTextItem, - Modal, - ModalVariant, - Progress, - ProgressMeasureLocation, - ProgressSize, - ProgressVariant, - Skeleton, - Tooltip, - pluralize, -} from "@patternfly/react-core"; -import { useMutation, useQuery } from "@tanstack/react-query"; -import { Alert } from "../../Antd"; -import axios from "axios"; -import PQueue from "p-queue"; -import { useContext, useEffect, useState } from "react"; -import { useNavigate } from "react-router"; -import ChrisAPIClient from "../../../api/chrisapiclient"; -import { MainRouterContext } from "../../../routes"; -import { DotsIndicator } from "../../Common"; -import { - CodeBranchIcon, - DownloadIcon, - LibraryIcon, - PreviewIcon, -} from "../../Icons"; -import FileDetailView from "../../Preview/FileDetailView"; -import { PacsQueryContext, Types } from "../context"; -import PFDCMClient, { type DataFetchQuery } from "../pfdcmClient"; -import useSettings from "../useSettings"; -import { CardHeaderComponent } from "./SettingsComponents"; - -async function getPacsFile(file: PACSFile["data"]) { - const { id } = file; - const client = ChrisAPIClient.getClient(); - try { - const pacs_file = await client.getPACSFile(id); - return pacs_file; - } catch (e) { - if (e instanceof Error) { - throw new Error(e.message); - } - } -} - -async function getTestData( - pacsIdentifier: string, - pullQuery: DataFetchQuery, - protocolName?: string, - offset?: number, -) { - const cubeClient = ChrisAPIClient.getClient(); - try { - let url = `${ - import.meta.env.VITE_CHRIS_UI_URL - }pacsfiles/search/?pacs_identifier=${pacsIdentifier}&StudyInstanceUID=${ - pullQuery.StudyInstanceUID - }&SeriesInstanceUID=${pullQuery.SeriesInstanceUID}`; - - if (protocolName) { - url += `&ProtocolName=${protocolName}`; - } - if (offset) { - url += `&offset=${offset}`; - } - - const response = await axios.get(url, { - headers: { - Authorization: `Token ${cubeClient.auth.token}`, - }, - }); - - return response.data; - } catch (error) { - if (axios.isAxiosError(error)) { - if (error.response) { - throw new Error( - `Error: ${error.response.status} - ${error.response.data}`, - ); - } - if (error.request) { - throw new Error("Error: No response received from server."); - } - // Something else happened while setting up the request - - throw new Error(`Error: ${error.message}`); - } - - throw new Error("An unexpected error occurred."); - } -} - -function getLatestPACSFile(pacsFiles: PACSFile["data"][]) { - return pacsFiles.reduce((latestFile, currentFile) => { - const latestDate = new Date(latestFile.creation_date); - const currentDate = new Date(currentFile.creation_date); - - return currentDate > latestDate ? currentFile : latestFile; - }); -} - -/** - * The browser places a limit on the number of concurrent connections to the same domain, which can result in requests being blocked if too many are fired simultaneously. - * To manage this, we implement a queue system to control the concurrency of your async requests. - */ - -const queue = new PQueue({ concurrency: 10 }); // Set concurrency limit here - -const SeriesCardCopy = ({ series }: { series: any }) => { - const navigate = useNavigate(); - const { state, dispatch } = useContext(PacsQueryContext); - // Load user Preference Data - const { - data: userPreferenceData, - isLoading: userDataLoading, - isError: userDataError, - } = useSettings(); - const userPreferences = userPreferenceData?.series; - const userPreferencesArray = userPreferences && Object.keys(userPreferences); - - const createFeed = useContext(MainRouterContext).actions.createFeedWithData; - const { selectedPacsService, pullStudy, preview } = state; - const client = new PFDCMClient(); - const { - SeriesInstanceUID, - StudyInstanceUID, - NumberOfSeriesRelatedInstances, - AccessionNumber, - } = series; - const seriesInstances = +NumberOfSeriesRelatedInstances.value; - const studyInstanceUID = StudyInstanceUID.value; - const seriesInstanceUID = SeriesInstanceUID.value; - const accessionNumber = AccessionNumber.value; - - // disable the card completely in this case - const isDisabled = seriesInstances === 0; - // This flag controls the start/stop for polling cube for files and display progress indicators - const [isFetching, setIsFetching] = useState(false); - const [openSeriesPreview, setOpenSeriesPreview] = useState(false); - const [isPreviewFileAvailable, setIsPreviewFileAvailable] = useState(false); - const [filePreviewForViewer, setFilePreviewForViewer] = - useState(null); - const [pacsFileError, setPacsFileError] = useState(""); - - const pullQuery: DataFetchQuery = { - StudyInstanceUID: studyInstanceUID, - SeriesInstanceUID: seriesInstanceUID, - }; - - // Handle Retrieve Request - const handleRetrieveMutation = useMutation({ - mutationFn: async () => { - try { - await client.findRetrieve(selectedPacsService, pullQuery); - setIsFetching(true); - } catch (e) { - // Don't poll if the request fails - // biome-ignore lint/complexity/noUselessCatch: - throw e; - } - }, - }); - - const { - isPending: retrieveLoading, - isError: retrieveFetchError, - error: retrieveErrorMessage, - } = handleRetrieveMutation; - - // Polling cube files after a successful retrieve request from pfdcm; - - async function fetchCubeFiles() { - try { - if (isDisabled) { - // Cancel polling for files that have zero number of series instances - setIsFetching(false); - return { - fileToPreview: null, - totalCount: 0, - }; - } - - const middleValue = Math.floor(seriesInstances / 2); - - // Perform these three requests in parallel - const [response, seriesRelatedInstance, pushCountInstance] = - await Promise.all([ - queue.add(() => - getTestData( - selectedPacsService, - pullQuery, - "", - isPreviewFileAvailable ? middleValue : 0, - ), - ), - queue.add(() => - getTestData( - "org.fnndsc.oxidicom", - pullQuery, - "NumberOfSeriesRelatedInstances", - ), - ), - queue.add(() => - getTestData( - "org.fnndsc.oxidicom", - pullQuery, - "OxidicomAttemptedPushCount", - ), - ), - ]); - - // Process the response - const fileItems = response.results; - let fileToPreview: PACSFile | null = null; - if (fileItems.length > 0) { - fileToPreview = fileItems[0]; - } - - const totalFilesCount = response.count; - - if (totalFilesCount >= middleValue) { - // Pick the middle image of the stack for preview. Set to true if that file is available - setIsPreviewFileAvailable(true); - } - - // Get the series related instance in cube - const seriesRelatedInstanceList = seriesRelatedInstance.results; - const pushCountInstanceList = pushCountInstance.results; - - if (seriesRelatedInstanceList.length > 0) { - const seriesCountLatest = getLatestPACSFile(seriesRelatedInstanceList); - const seriesCount = +seriesCountLatest.SeriesDescription; - if (seriesCount !== seriesInstances) { - throw new Error( - "The number of series related instances in cube does not match the number in pfdcm.", - ); - } - } - - let pushCount = 0; - if (pushCountInstanceList.length > 0) { - const pushCountLatest = getLatestPACSFile(pushCountInstanceList); - pushCount = +pushCountLatest.SeriesDescription; - - if (pushCount > 0 && pushCount === totalFilesCount && isFetching) { - // This means oxidicom is done pushing as the push count file is available - // Cancel polling - setIsFetching(false); - } - } - - // Setting the study instance tracker if pull study is clicked - if (pullStudy?.[accessionNumber]) { - dispatch({ - type: Types.SET_STUDY_PULL_TRACKER, - payload: { - seriesInstanceUID: SeriesInstanceUID.value, - studyInstanceUID: accessionNumber, - currentProgress: !!( - seriesInstances === 0 || totalFilesCount === pushCount - ), - }, - }); - } - - return { - fileToPreview, - totalFilesCount, - }; - } catch (error) { - setIsFetching(false); - throw error; - } - } - - const { - data, - isPending: filesLoading, - isError: filesError, - error: filesErrorMessage, - } = useQuery({ - queryKey: [SeriesInstanceUID.value, StudyInstanceUID.value], - queryFn: fetchCubeFiles, - refetchInterval: () => { - // Only fetch after a successfull response from pfdcm - // Decrease polling frequency to avoid overwhelming cube with network requests - if (isFetching) return 1500; - return false; - }, - refetchOnMount: true, - }); - - // Retrieve this series if the pull study is clicked and the series is not already being retrieved. - useEffect(() => { - if (pullStudy[accessionNumber] && !isFetching) { - setIsFetching(true); - } - }, [pullStudy[accessionNumber]]); - - // Start polling from where the user left off in case the user refreshed the screen. - useEffect(() => { - if ( - data && - data.totalFilesCount > 0 && - data.totalFilesCount !== seriesInstances && - !isFetching - ) { - setIsFetching(true); - } - }, [data]); - - useEffect(() => { - // This is the preview all mode clicked on the study card - - async function fetchPacsFile() { - try { - const file = await getPacsFile(data?.fileToPreview); - - if (file) { - setFilePreviewForViewer(file); - } - } catch (e) { - //handle error - if (e instanceof Error) { - setPacsFileError(e.message); - } - } - } - - preview && data?.fileToPreview && fetchPacsFile(); - }, [preview]); - - // Error and loading state indicators for retrieving from pfdcm and polling cube for files. - const isResourceBeingFetched = filesLoading || retrieveLoading; - const resourceErrorFound = filesError || retrieveFetchError; - const errorMessages = filesErrorMessage - ? filesErrorMessage.message - : retrieveErrorMessage - ? retrieveErrorMessage.message - : ""; - - const helperText = ( - - {errorMessages} - - ); - - // This is opened when the 'preview' button is clicked - const largeFilePreview = filePreviewForViewer && ( - setOpenSeriesPreview(false)} - > - - - ); - - const filePreviewButton = ( -
- -
- ); - - const filePreviewLayout = ( - - -
-
- - {series.SeriesDescription.value} - - {filePreviewButton} -
-
-
- ); - - const retrieveButton = ( - - - - - ); -}; diff --git a/src/components/Pacs/components/StudyCard.tsx b/src/components/Pacs/components/StudyCard.tsx deleted file mode 100644 index b2436df17..000000000 --- a/src/components/Pacs/components/StudyCard.tsx +++ /dev/null @@ -1,366 +0,0 @@ -import { - Badge, - Button, - Card, - CardHeader, - Grid, - GridItem, - Skeleton, - Tooltip, -} from "@patternfly/react-core"; -import { notification } from "antd"; -import { format, parse } from "date-fns"; -import { useContext, useEffect, useState } from "react"; -import { DotsIndicator } from "../../Common"; -import { - DownloadIcon, - PreviewIcon, - QuestionCircleIcon, - RetryIcon, - ThLargeIcon, -} from "../../Icons"; -import { PacsQueryContext, Types } from "../context"; -import PfdcmClient from "../pfdcmClient"; -import useSettings from "../useSettings"; -import SeriesCard from "./SeriesCard"; -import { CardHeaderComponent } from "./SettingsComponents"; -import usePullStudyHook from "./usePullStudyHook"; - -const StudyCardCopy = ({ study }: { study: any }) => { - const [api, contextHolder] = notification.useNotification(); - const { writeStatus, getStatus } = usePullStudyHook(); - const { data, isLoading, isError } = useSettings(); - const { state, dispatch } = useContext(PacsQueryContext); - const [isStudyExpanded, setIsStudyExpanded] = useState(false); - const [startPullStudy, setStartStudying] = useState(false); - const { preview, pullStudy, studyPullTracker, selectedPacsService } = state; - const userPreferences = data?.study; - const userPreferencesArray = userPreferences && Object.keys(userPreferences); - const accessionNumber = study.AccessionNumber.value; - const studyDate = study.StudyDate.value; - const parsedDate = parse(studyDate, "yyyyMMdd", new Date()); - const formattedDate = Number.isNaN( - parsedDate.getTime(), - ) /* Check if parsedDate is a valid date */ - ? studyDate - : format(parsedDate, "MMMM d, yyyy"); - - const clearState = async () => { - dispatch({ - type: Types.SET_PULL_STUDY, - payload: { - studyInstanceUID: accessionNumber, - status: false, - }, - }); - // stop tracking this status as an active pull - await writeStatus(accessionNumber, false); - }; - - useEffect(() => { - async function setUpStatus() { - if (studyPullTracker[accessionNumber] && pullStudy[accessionNumber]) { - const studyBeingTracked = studyPullTracker[accessionNumber]; - if (studyBeingTracked) { - let allSeriesBeingTracked = true; - for (const series in studyBeingTracked) { - const isSeriesDone = studyBeingTracked[series]; - if (!isSeriesDone) { - allSeriesBeingTracked = false; - break; - } - } - - // All series are being tracked and are complete - if ( - allSeriesBeingTracked && - study.series.length === Object.keys(studyBeingTracked).length - ) { - await clearState(); - } - } - } - } - - setUpStatus(); - }, [studyPullTracker[accessionNumber], pullStudy[accessionNumber]]); - - useEffect(() => { - async function fetchStatus() { - const status = await getStatus(accessionNumber); - if (status?.[accessionNumber]) { - setIsStudyExpanded(true); - dispatch({ - type: Types.SET_PULL_STUDY, - payload: { - studyInstanceUID: accessionNumber, - status: true, - }, - }); - } - } - // Fetch Status - fetchStatus(); - }, []); - - const retrieveStudy = async () => { - setStartStudying(true); - await writeStatus(study.AccessionNumber.value, true); - const client = new PfdcmClient(); - await client.findRetrieve(selectedPacsService, { - AccessionNumber: study.AccessionNumber.value, - }); - dispatch({ - type: Types.SET_PULL_STUDY, - payload: { - studyInstanceUID: accessionNumber, - status: true, - }, - }); - setStartStudying(false); - setIsStudyExpanded(true); - }; - - return ( - <> - {contextHolder} - - , - }} - className="flex-studies-container" - onExpand={() => setIsStudyExpanded(!isStudyExpanded)} - > - <> - {isLoading ? ( -
- -
- ) : ( - <> - {!isError && - userPreferences && - userPreferencesArray && - userPreferencesArray.length > 0 ? ( - userPreferencesArray.map((key: string) => ( -
-
{key}
- -
- - {study[key].value && study[key].value} - {" "} -
-
-
- )) - ) : ( - <> -
- -
- - {study.StudyDescription.value && - study.StudyDescription.value} - {" "} -
-
-
- {study.NumberOfStudyRelatedSeries.value && - study.NumberOfStudyRelatedSeries.value}{" "} - series, {formattedDate} -
-
-
-
- Modalities in Study -
-
- {study.ModalitiesInStudy.value - ?.split("\\") - .map((m: string, index: number) => ( - - {m} - - ))} -
-
-
-
Accession Number
- {study.AccessionNumber.value?.startsWith("no value") ? ( - - - - ) : ( -
{study.AccessionNumber.value}
- )} -
-
-
Station
- {study.PerformedStationAETitle.value?.startsWith( - "no value", - ) ? ( - - - - ) : ( -
{study.PerformedStationAETitle.value}
- )} -
- - )} - - )} -
- {import.meta.env.VITE_OHIF_URL && ( - - - ), - duration: 4, - }); - setIsStudyExpanded(true); - } else { - await retrieveStudy(); - } - }} - variant="tertiary" - className="button-with-margin" - size="sm" - icon={} - /> - - )} -
- -
-
- {isStudyExpanded && ( -
- - {study.series.map((series: any) => { - return ( - - - - ); - })} - -
- )} - - ); -}; - -export default StudyCardCopy; diff --git a/src/components/Pacs/components/input.test.ts b/src/components/Pacs/components/input.test.ts new file mode 100644 index 000000000..6c2510d04 --- /dev/null +++ b/src/components/Pacs/components/input.test.ts @@ -0,0 +1,12 @@ +import { test, expect } from "vitest"; +import { isQueryEmpty } from "./input.tsx"; + +test.each([ + [null, true], + [{}, true], + [{ patientID: "" }, true], + [{ patientID: "", AccessionNumber: "" }, true], + [{ patientID: "bob" }, false], +])("isQueryEmpty(%o) -> %b", (query, expected) => { + expect(isQueryEmpty(query)).toBe(expected); +}); diff --git a/src/components/Pacs/components/input.tsx b/src/components/Pacs/components/input.tsx new file mode 100644 index 000000000..ba4906fc0 --- /dev/null +++ b/src/components/Pacs/components/input.tsx @@ -0,0 +1,192 @@ +import React from "react"; +import { PACSqueryCore } from "../../../api/pfdcm"; +import { + Button, + Dropdown, + DropdownItem, + DropdownList, + Grid, + GridItem, + MenuToggle, + MenuToggleElement, + TextInputGroup, + TextInputGroupMain, + ToggleGroup, + ToggleGroupItem, +} from "@patternfly/react-core"; +import { hideOnDesktop, hideOnMobile } from "../../../cssUtils.ts"; +import { SearchIcon, TimesIcon } from "@patternfly/react-icons"; + +type InputFieldProps = { + setQuery: (query: PACSqueryCore) => void; + query: PACSqueryCore; + id?: string; + "aria-label"?: string; +}; + +type PacsInputProps = InputFieldProps & { + service: string; + services: ReadonlyArray; + setService: (service: string) => void; +}; + +/** + * An input field for searching in PACS by MRN + */ +const MrnInput: React.FC = ({ query, setQuery, ...props }) => { + const clearInput = () => setQuery({}); + + return ( + + } + value={query.patientID || ""} + onChange={(_event, value) => + setQuery({ + patientID: value, + }) + } + name="mrnSearchInput" + placeholder="Search for DICOM studies by MRN" + /> + {!isQueryEmpty(query) && ( + + )} + + ); +}; + +/** + * An advanced search input field for searching in PACS by PatientID, AccessionNumber, ... + */ +const AdvancedInput: React.FC = ({ + query, + setQuery, + ...props +}) => { + return ( + <> +

Advanced search not implemented.

+ + ); +}; + +type ServiceDropdownProps = { + service: string; + setService: (service: string) => void; + services: ReadonlyArray; +}; + +const ServiceDropdown: React.FC = ({ + service, + services, + setService, +}) => { + const [isOpen, setIsOpen] = React.useState(false); + return ( + { + typeof value === "string" && setService(value); + setIsOpen(false); + }} + onOpenChange={(isOpen: boolean) => setIsOpen(isOpen)} + toggle={(toggleRef: React.Ref) => ( + setIsOpen((open) => !open)} + isExpanded={isOpen} + isFullWidth + title="PACS service" + > + {service} + + )} + > + + {services.map((service) => ( + + {service} + + ))} + + + ); +}; + +/** + * A `` which shows different text on mobile vs desktop layouts. + */ +const ScreenSizeSpan: React.FC<{ + mobile: React.ReactNode; + desktop: React.ReactNode; +}> = ({ mobile, desktop }) => ( + <> + {mobile} + {desktop} + +); + +const PacsInput: React.FC = ({ + service, + services, + setService, + ...props +}) => { + const [advancedSearch, setAdvancedSearch] = React.useState(false); + + const InputElement = advancedSearch ? AdvancedInput : MrnInput; + + const advancedSearchToggle = ( + + } + isSelected={!advancedSearch} + onChange={() => setAdvancedSearch(false)} + /> + } + isSelected={advancedSearch} + onChange={() => setAdvancedSearch(true)} + /> + + ); + + const serviceDropdown = ( + + ); + + return ( + + + {advancedSearchToggle} + + + {serviceDropdown} + + + + + + ); +}; + +function isQueryEmpty(query: { [key: string]: any } | null): boolean { + return ( + query === null || + Object.values(query).findIndex((value) => `${value}`.length > 0) === -1 + ); +} + +export default PacsInput; +export { isQueryEmpty }; diff --git a/src/components/Pacs/components/loading.tsx b/src/components/Pacs/components/loading.tsx new file mode 100644 index 000000000..40677f75e --- /dev/null +++ b/src/components/Pacs/components/loading.tsx @@ -0,0 +1,22 @@ +import { Skeleton } from "@patternfly/react-core"; +import Spacing from "@patternfly/react-styles/css/utilities/Spacing/spacing"; + +const PacsLoadingScreen = () => ( + <> + + + +
+ + +
+ + + +); + +export default PacsLoadingScreen; diff --git a/src/components/Pacs/components/usePullStudyHook.tsx b/src/components/Pacs/components/usePullStudyHook.tsx deleted file mode 100644 index 21506d3d8..000000000 --- a/src/components/Pacs/components/usePullStudyHook.tsx +++ /dev/null @@ -1,124 +0,0 @@ -import ChrisAPIClient from "../../../api/chrisapiclient"; -import axios from "axios"; -import { useTypedSelector } from "../../../store/hooks"; -import { catchError } from "../../../api/common"; - -const usePullStudyHook = () => { - const userName = useTypedSelector((state) => state.user.username); - const url = `${import.meta.env.VITE_CHRIS_UI_URL}uploadedfiles/`; - const client = ChrisAPIClient.getClient(); - const token = client.auth.token; - - const writeStatus = async (accessionNumber: string, type: boolean) => { - try { - const status = await getStatus(accessionNumber); - // delete this file - await deleteFile(accessionNumber); - // delete this file if it already exists - const client = ChrisAPIClient.getClient(); - const path = `${userName}/uploads/pacs/${accessionNumber}`; - const fileName = "pacsStatus.json"; - const formData = new FormData(); - - const data = JSON.stringify({ - ...status, - [accessionNumber]: type, - }); - formData.append("upload_path", `${path}/${fileName}`); - formData.append( - "fname", - new Blob([data], { - type: "application/json", - }), - ); - - const config = { - headers: { - Authorization: `Token ${client.auth.token}`, - }, - }; - - const response = await axios.post(url, formData, config); - return response; - } catch (error) { - const error_message = catchError(error).error_message; - throw new Error(error_message); - } - }; - - const deleteFile = async (accessionNumber: string) => { - const file = await getFile(accessionNumber); - if (file) { - const url = file.url; - await axios.delete(url, { - headers: { - Authorization: `Token ${token}`, - }, - }); - } - }; - - const getFile = async (accessionNumber: string) => { - const client = ChrisAPIClient.getClient(); - const path = `${userName}/uploads/pacs/${accessionNumber}/pacsStatus.json`; - const url = `${ - import.meta.env.VITE_CHRIS_UI_URL - }uploadedfiles/search?fname_exact=${path}`; - - try { - const response = await axios.get(url, { - headers: { - "Content-Type": "application/json", - Authorization: `Token ${client.auth.token}`, - }, - }); - - // If there is more than file don't write. Something has corrupted - if (response.data.results.length <= 1) { - const file = response.data.results[0]; - return file; - } - throw new Error("Failed to fetch the file..."); - } catch (error) { - if (error instanceof Error) { - throw new Error(error.message); - } - const error_message = catchError(error).error_message; - throw new Error(error_message); - } - }; - - const getStatus = async (accessionNumber: string) => { - try { - const client = ChrisAPIClient.getClient(); - // Get this file first - const file = await getFile(accessionNumber); - if (file) { - // file already exists; - try { - const response = await axios.get(file.file_resource, { - headers: { - "Content-Type": "blob", - Authorization: `Token ${client.auth.token}`, - }, - }); - - if (response?.data) { - return response.data; - } - } catch (error) { - if (error instanceof Error) throw new Error(error.message); - } - } - } catch (error) { - if (error instanceof Error) throw new Error(error.message); - } - }; - - return { - writeStatus, - getStatus, - deleteFile, - }; -}; -export default usePullStudyHook; diff --git a/src/components/Pacs/components/utils.ts b/src/components/Pacs/components/utils.ts deleted file mode 100644 index 87c242bef..000000000 --- a/src/components/Pacs/components/utils.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { parse, format } from "date-fns"; - -export const formatStudyDate = (studyDateString: string) => { - // Parse the input string to a Date object - const parsedDate = parse(studyDateString, "yyyyMMdd", new Date()); - - // Format the Date object to 'MMMM d yyyy' format (e.g., 'December 6 2011') - const formattedDate = format(parsedDate, "MMMM d yyyy"); - - // Determine the day part of the formatted date - const day: any = format(parsedDate, "d"); - - // Add 'st', 'nd', 'rd', or 'th' to the day part of the formatted date - const dayWithSuffix = getDayWithSuffix(day); - - return formattedDate.replace(day, dayWithSuffix); -}; - -const getDayWithSuffix = (day: number) => { - if (day >= 11 && day <= 13) { - return `${day}th`; - } - - const lastDigit = day % 10; - - switch (lastDigit) { - case 1: - return `${day}st`; - case 2: - return `${day}nd`; - case 3: - return `${day}rd`; - default: - return `${day}th`; - } -}; diff --git a/src/components/Pacs/index.tsx b/src/components/Pacs/index.tsx index 294865ac2..21a0f1da0 100644 --- a/src/components/Pacs/index.tsx +++ b/src/components/Pacs/index.tsx @@ -1,563 +1,27 @@ -import { - Button, - Dropdown, - DropdownItem, - DropdownList, - Grid, - GridItem, - MenuToggle, - PageSection, - Split, - SplitItem, - TextInput, -} from "@patternfly/react-core"; -import SearchIcon from "@patternfly/react-icons/dist/esm/icons/search-icon"; -import { Alert, Spin } from "../Antd"; -import * as React from "react"; -import { useNavigate } from "react-router"; -import { useSearchParams } from "react-router-dom"; -import { pluralize } from "../../api/common"; -import { EmptyStateComponent, SpinContainer } from "../Common"; -import WrapperConnect from "../Wrapper"; -import PatientCard from "./components/PatientCard"; -import { PacsQueryContext, PacsQueryProvider, Types } from "./context"; -import "./pacs-copy.css"; -import PfdcmClient from "./pfdcmClient"; -import { DownloadIcon } from "../Icons"; +import Wrapper from "../Wrapper"; +import { Configuration as PfdcmConfig, PfdcmClient } from "../../api/pfdcm"; -const dropdownMatch: { [key: string]: string } = { - PatientID: "Patient MRN", - PatientName: "Patient Name", - AccessionNumber: "Accession Number", -}; - -const PacsCopy = () => { - React.useEffect(() => { - document.title = "My Library"; - }, []); - - return ( - - - - - - - - - ); -}; - -export default PacsCopy; - -const client = new PfdcmClient(); -const actions = ["Patient MRN", "Patient Name", "Accession Number"]; - -const QueryBuilder = () => { - const navigate = useNavigate(); - const [searchParams] = useSearchParams(); - - const { state, dispatch } = React.useContext(PacsQueryContext); - const [queryOpen, setQueryOpen] = React.useState(false); - const [value, setValue] = React.useState(""); - const [pacsListOpen, setPacsListOpen] = React.useState(false); - const [errorState, setErrorState] = React.useState(""); - - const { pacsServices, selectedPacsService, currentQueryType, queryResult } = - state; - - const service = searchParams.get("service"); - const queryType = searchParams.get("queryType"); - const searchValue = searchParams.get("value"); - - const responseCache = React.useRef(new Map()); - - const handleSubmitQuery = React.useCallback( - async ( - navigateToDifferentRoute: boolean, - currentQueryType: string, - value: string, - selectedPacsService = "default", - ) => { - const cacheKey = `${currentQueryType}-${value}-${selectedPacsService}`; - // Check if the response for the query is already cached - if (responseCache.current.has(cacheKey)) { - return; - } - // Cache the response - responseCache.current.set(cacheKey, 1); - - if (value.length > 0 && currentQueryType) { - // Reset Search Results in the state first to avoid duplication if search button is hit twice or if the page is refreshed - // and the ui is trying to construct the page with url search params - dispatch({ - type: Types.RESET_SEARCH_RESULTS, - payload: null, - }); - - const csv = value.trim().split(/[,\s]+/); - - dispatch({ - type: Types.SET_LOADING_SPINNER, - payload: { - status: true, - text: `Fetching ${csv.length} ${pluralize("result", csv.length)} `, - }, - }); - - const responses = []; - - for (const value of csv) { - try { - const response = await client.find( - { - [currentQueryType]: value.trimStart().trimEnd(), - }, - selectedPacsService, - ); - response && responses.push(response); - dispatch({ - type: Types.SET_LOADING_SPINNER, - payload: { - status: true, - text: `Completed ${responses.length} of ${csv.length} searches`, - }, - }); - - dispatch({ - type: Types.SET_SEARCH_RESULT, - payload: { - queryResult: response, - }, - }); - } catch (error: any) { - setErrorState(error.message); - dispatch({ - type: Types.SET_LOADING_SPINNER, - payload: { - status: true, - text: `Completed ${responses.length} of ${csv.length} searches. Found an error for value ${value}`, - }, - }); - } - } - - dispatch({ - type: Types.SET_LOADING_SPINNER, - payload: { - status: false, - text: "Search Complete", - }, - }); - - if (navigateToDifferentRoute) { - navigate( - `/pacs?queryType=${currentQueryType}&value=${value}&service=${selectedPacsService}`, - ); - } - } else { - setErrorState( - "Please ensure PACS service, Search Value, and the Query Type are all selected.", - ); - } - }, - [dispatch, navigate], - ); - - React.useEffect(() => { - client - .getPacsServices() - .then((list) => { - if (list) { - dispatch({ - type: Types.SET_LIST_PACS_SERVICES, - payload: { - pacsServices: list, - }, - }); - - const selectedPacsService = getDefaultPacsService(list); - - if (!service && selectedPacsService) { - dispatch({ - type: Types.SET_SELECTED_PACS_SERVICE, - payload: { selectedPacsService }, - }); - } - } - }) - .catch((error: any) => { - setErrorState(error.message); - }); - }, [dispatch, service]); - - React.useEffect(() => { - const fetchData = async () => { - if (queryType && searchValue && service) { - dispatch({ - type: Types.SET_SELECTED_PACS_SERVICE, - payload: { selectedPacsService: service }, - }); - - dispatch({ - type: Types.SET_CURRENT_QUERY_TYPE, - payload: { currentQueryType: queryType }, - }); - - if (queryResult.length === 0) { - setValue(searchValue); - await handleSubmitQuery(false, queryType, searchValue, service); - } - } - }; - - fetchData(); - }, []); - - const onToggle = () => { - setQueryOpen(!queryOpen); - }; - - const onTogglePacsList = () => { - setPacsListOpen(!pacsListOpen); - }; - - const queryBy = (action: string) => { - onToggle(); - switch (action) { - case "Patient MRN": { - dispatch({ - type: Types.SET_CURRENT_QUERY_TYPE, - payload: { - currentQueryType: "PatientID", - }, - }); - break; - } - case "Patient Name": { - dispatch({ - type: Types.SET_CURRENT_QUERY_TYPE, - payload: { - currentQueryType: "PatientName", - }, - }); - break; - } - - case "Accession Number": { - dispatch({ - type: Types.SET_CURRENT_QUERY_TYPE, - payload: { - currentQueryType: "AccessionNumber", - }, - }); - break; - } - default: - return; - } - }; - - return ( - - - - - { - return ( - -
- {currentQueryType - ? dropdownMatch[currentQueryType] - : "Query By"} -
-
- ); - }} - > - - {actions.map((action) => { - return ( - queryBy(action)} key={action}> - {action} - - ); - })} - -
-
- - } - value={value} - aria-label="Query" - onKeyDown={(e) => { - e.key === "Enter" && - handleSubmitQuery( - true, - currentQueryType, - value, - selectedPacsService, - ); - }} - onChange={(_event, value) => setValue(value)} - /> - - - { - return ( - -
- {selectedPacsService - ? selectedPacsService - : "Select a PACS Service"} -
-
- ); - }} - > - - {pacsServices ? ( - pacsServices.map((service: string) => { - return ( - { - dispatch({ - type: Types.SET_SELECTED_PACS_SERVICE, - payload: { - selectedPacsService: service, - }, - }); - onTogglePacsList(); - }} - > - {service} - - ); - }) - ) : ( - No service available - )} - -
-
- - - -
-
- {errorState && ( - - { - setErrorState(""); - }} - closable - type="error" - description={errorState} - /> - - )} -
- ); -}; - -// Utility function to flatten a nested object, focusing on 'label' and 'value' -const flattenObject = (obj: any, _parent = "", res: any = {}): any => { - for (const key in obj) { - // biome-ignore lint/suspicious/noPrototypeBuiltins: - if (obj.hasOwnProperty(key)) { - if ( - typeof obj[key] === "object" && - obj[key] !== null && - !Array.isArray(obj[key]) - ) { - if (obj[key].label && obj[key].value !== undefined) { - res[obj[key].label] = obj[key].value; - } else { - flattenObject(obj[key], key, res); - } - } else if (Array.isArray(obj[key])) { - obj[key].forEach((item: any, index: number) => { - flattenObject(item, `${key}[${index}]`, res); - }); - } else { - res[key] = obj[key]; - } - } - } - return res; -}; - -// Utility function to convert JSON array to CSV -const jsonToCSV = (jsonArray: any[]): string | null => { - if (!jsonArray || !jsonArray.length) { - return null; - } - - // Flatten each JSON object in the array - const flattenedArray = jsonArray.map((item) => flattenObject(item)); - - // Extract keys (header row) - const keys = Object.keys(flattenedArray[0]); - const csvRows: string[] = []; - - // Add the header row - csvRows.push(keys.join(",")); - - // Add the data rows - flattenedArray.forEach((item) => { - const values = keys.map( - (key) => `"${item[key] !== undefined ? item[key] : ""}"`, - ); - csvRows.push(values.join(",")); - }); - - return csvRows.join("\n"); -}; - -const Results: React.FC = () => { - const { state } = React.useContext(PacsQueryContext); - const [exporting, setExporting] = React.useState(false); - - const { queryResult, fetchingResults } = state; - - const exportAsCSV = (): void => { - setExporting(true); - // Flatten the data arrays from all query results, including empty data results - const allData = queryResult.flatMap((result: any) => { - if (result.data.length > 0) { - return result.data; - } - // Include empty result with a message and relevant patient info - return [ - { - PatientID: result.args.PatientID, - PatientName: result.args.PatientName, - AccessionNumber: result.args.AccessionNumber, - Message: "No results found", - }, - ]; - }); - - const csvData = jsonToCSV(allData); - setExporting(false); - - if (csvData) { - // Create a blob - const blob = new Blob([csvData], { type: "text/csv;charset=utf-8;" }); - - // Create a link element - const link = document.createElement("a"); - link.href = URL.createObjectURL(blob); - link.download = "search_results.csv"; - - // Append the link to the document body and click it to trigger download - document.body.appendChild(link); - link.click(); - - // Remove the link element from the document - document.body.removeChild(link); - } - }; - - return ( -
- {fetchingResults.status && } - {queryResult.length > 0 && ( -
- -
- )} - {queryResult.length > 0 && - queryResult.map((result: any, index: number) => { - if (result && result.data.length > 0) { - return ( -
- index - }`} - className="result-grid" - > - -
- ); - } - return ( - - index - }`} - title={`No results found for : ${result.args.PatientID} ${result.args.PatientName} ${result.args.AccessionNumber}`} - /> - ); - })} - {!fetchingResults.status && queryResult.length === 0 && ( - - )} -
- ); -}; +import ChrisAPIClient from "../../api/chrisapiclient.ts"; +import PacsQRApp from "./app.tsx"; /** - * Selects the default PACS service (which is usually not the PACS service literally called "default"). - * - * 1. Selects the hard-coded "PACSDCM" - * 2. Attempts to select the first value which is not "default" (a useless, legacy pfdcm behavior) - * 3. Selects the first value + * Get a PFDCM client for the URL specified by the environment variable + * `VITE_PFDCM_URL`. */ -function getDefaultPacsService(services: ReadonlyArray): string | null { - if (services.includes("PACSDCM")) { - return "PACSDCM"; - } - for (const service of services) { - if (service !== "default") { - return service; - } - } - if (services) { - return services[0]; - } - return null; +function getEnvPfdcmClient(): PfdcmClient { + const config = new PfdcmConfig({ + basePath: import.meta.env.VITE_PFDCM_URL, + }); + return new PfdcmClient(config); } + +const WrappedPacsQRApp = () => ( + + + +); + +export default WrappedPacsQRApp; diff --git a/src/components/Pacs/pacs-copy.css b/src/components/Pacs/pacs-copy.css deleted file mode 100644 index 5aa12075d..000000000 --- a/src/components/Pacs/pacs-copy.css +++ /dev/null @@ -1,123 +0,0 @@ -.result-grid, -.patient-studies, -.patient-series { - margin-top: 1em; -} - - - -.patient-studies, -.patient-series { - padding: 1em 2em; - border-right: 0.5px solid lightgray; - border-left: 0.5px solid lightgray; -} - -.pf-v5-c-card__header-main { - flex: 1; - display: flex; - flex-flow: wrap; - justify-content: space-between; -} - -@media (min-width: 769px) { - .last-item-align { - text-align: right; - } -} - -.series-grid { - margin-top: 1rem; -} - -.flex-studies-item { - width: 18%; - margin-left: 0.5em; -} - -.button-with-margin { - margin-top: 0.25em; -} - -.flex-series-item { - width: 15%; - margin-left: 1em; -} - -.steps-container { - flex: 2; -} - -.flex-series-item.button-container { - flex: 1; - margin-left: 1.5em; -} - -@media (max-width: 600px) { - .flex-series-item { - width: 100%; /* Set full width for all elements on smaller screens */ - } - - .flex-series-item.steps-container { - flex: 1; /* Adjust the flex value for steps container on smaller screens */ - } - - .flex-series-item.button-container { - flex: 1; /* Adjust the flex value for button container on smaller screens */ - } -} - -.hide-content { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - - -.retrieve-progress { - .pf-v5-c-progress__description { - display:none !important; - } -} - - -.series-actions { - z-index:1; - position: absolute; - top: 0; - left: 0; - bottom: 0; - right: 0; - display: flex; - justify-content: center; - align-items: center; - opacity: 0; - visibility: hiddem; - transition: opacity 0.3s, visibility 0.3s; -} - -.series-actions:hover { - z-index: 1; - opacity: 1; - visibility: visible; -} - -.action-button-container { - display: flex; - align-items: center; - flex-direction:column; -} - -.progress-active { - .pf-v5-c-progress__indicator{ - background-color: #BEE1F4 - } -} - -.progress-success{ - .pf-v5-c-progress__indicator{ - background-color:#0066CC; - } -} - - diff --git a/src/components/Pacs/pacs.tsx b/src/components/Pacs/pacs.tsx new file mode 100644 index 000000000..e4736692d --- /dev/null +++ b/src/components/Pacs/pacs.tsx @@ -0,0 +1,48 @@ +import LonkClient from "../../api/lonk"; +import FpClient from "../../api/fp/chrisapi.ts"; +import React from "react"; +import { PACSqueryCore } from "../../api/pfdcm"; +import PacsInput from "./components/input.tsx"; + +type PacsQRProps = { + lonkClient: LonkClient; + fpClient: FpClient; + services: ReadonlyArray; + service: string; + setService: (service: string) => void; + pushError: (title: string) => (e: Error) => void; +}; + +/** + * PACS Query and Retrieve component. + * + * This component has a text input field for specifying a PACS query, + * and provides functionality for: + * + * - searching for DICOM studies and series + * - pulling DICOM series data into *ChRIS* + */ +const PacsQR: React.FC = ({ + lonkClient, + fpClient, + services, + service, + setService, +}) => { + const [query, setQuery] = React.useState({}); + + return ( + <> + + + ); +}; + +export default PacsQR; diff --git a/src/components/Pacs/pfdcmClient.tsx b/src/components/Pacs/pfdcmClient.tsx deleted file mode 100644 index bb5c50f9f..000000000 --- a/src/components/Pacs/pfdcmClient.tsx +++ /dev/null @@ -1,100 +0,0 @@ -import type { AxiosRequestConfig } from "axios"; -import axios from "axios"; -import React from "react"; - -export interface ImageStatusType { - title: string; - description: string; - status: string; - icon?: React.ReactNode; -} - -export interface DataFetchQuery { - SeriesInstanceUID?: string; - StudyInstanceUID?: string; - AccessionNumber?: string; -} - -class PfdcmClient { - private readonly url: string; - - constructor() { - this.url = import.meta.env.VITE_PFDCM_URL + "/" || ""; - } - - async getPacsServices(): Promise> { - try { - if (!this.url) { - throw new Error( - "Failed to find a PFDCM Service. Please use the Pacs Query Retrieve at this link: http://chris-next.tch.harvard.edu:2222", - ); - } - - const url = `${this.url}api/v1/PACSservice/list/`; - const response = await axios.get(url); - return response.data; - // setting error as unknown for better type safety - } catch (error: unknown) { - throw error; - } - } - - async find(query: any, selectedPacsService: string) { - const RequestConfig: AxiosRequestConfig = { - url: `${this.url}api/v1/PACS/sync/pypx/`, - method: "POST", - headers: { - Accept: "application/json", - "Content-Type": "application/json", - }, - data: { - PACSservice: { value: selectedPacsService }, - listenerService: { value: "default" }, - PACSdirective: query, - }, - }; - - try { - const response = (await axios(RequestConfig)).data; - - const { pypx, status } = response; - if (status) { - return pypx; - } - } catch (error: unknown) { - throw error; - } - } - - async findRetrieve(pacsService: string, query: DataFetchQuery) { - const RequestConfig: AxiosRequestConfig = { - url: `${this.url}api/v1/PACS/thread/pypx/`, - method: "POST", - timeout: 10000, //10s - timeoutErrorMessage: "Error Request Timeout out", - headers: { - Accept: "application/json", - "Content-Type": "application/json", - }, - data: { - PACSservice: { - value: pacsService, - }, - listenerService: { value: "default" }, - PACSdirective: { - ...query, - withFeedBack: true, - then: "retrieve", - }, - }, - }; - - try { - const response = await axios(RequestConfig); - return response.data.timestamp; - } catch (error: unknown) { - throw error; - } - } -} -export default PfdcmClient; diff --git a/src/components/Pacs/useSettings.tsx b/src/components/Pacs/useSettings.tsx deleted file mode 100644 index 261a95668..000000000 --- a/src/components/Pacs/useSettings.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; -import ChrisAPIClient from "../../api/chrisapiclient"; -import { useTypedSelector } from "../../store/hooks"; - -const useSettings = () => { - async function fetchData(username?: string | null) { - const client = ChrisAPIClient.getClient(); - const path = `${username}/uploads/config`; - const pathList = await client.getFileBrowserPath(path); - - if (!pathList) { - return null; - } - - const files = await pathList.getFiles(); - const fileItems = files.getItems(); - - if (!fileItems) { - return null; - } - - try { - // Use Promise.all to wait for all async operations to complete - const fileContents = await Promise.all( - fileItems.map(async (_file) => { - const blob = await _file.getFileBlob(); - const reader = new FileReader(); - - // Use a Promise to wait for the reader.onload to complete - const readPromise = new Promise((resolve, reject) => { - reader.onload = (e) => { - try { - const value = e.target ? e.target.result : ("{}" as any); - const contents = JSON.parse(value); - resolve(contents); - } catch (parseError: any) { - // Handle JSON parsing error - reject(new Error(`Error parsing JSON: ${parseError.message}`)); - } - }; - }); - - reader.readAsText(blob); - - // Wait for the reader.onload to complete before moving to the next file - return await readPromise; - }), - ); - - return fileContents[0]; - } catch (error: any) { - throw new Error( - error.message || "An error occurred while processing files", - ); - } - } - - const username = useTypedSelector((state) => state.user.username); - - const { - isLoading, - data, - error, - isError, - }: { - isLoading: boolean; - data?: { - [key: string]: Record; - }; - error: any; - isError: boolean; - } = useQuery({ - queryKey: ["metadata"], - queryFn: async () => await fetchData(username), - }); - - return { data, isLoading, error, isError }; -}; -export default useSettings; diff --git a/src/components/Wrapper/TitleComponent.tsx b/src/components/Wrapper/TitleComponent.tsx index 152624ba1..212993640 100644 --- a/src/components/Wrapper/TitleComponent.tsx +++ b/src/components/Wrapper/TitleComponent.tsx @@ -11,6 +11,7 @@ import { import { useFetchFeed } from "../Feeds/useFetchFeed"; import { useTypedSelector } from "../../store/hooks"; import { CodeBranchIcon } from "../Icons"; +import React from "react"; const FeedsNameComponent = () => { const { feedCount, loadingFeedState } = useFeedListData(); @@ -52,6 +53,19 @@ const FeedsDetailComponent = ({ id }: { id?: string }) => { ); }; +const PacsNameComponent = () => { + return ( + + PACS Query and Retrieve + + ); +}; + +const TITLE_COMPONENTS: { [key: string]: () => React.ReactElement } = { + "/feeds": () => , + "/pacs": () => , +}; + const TitleComponent = () => { const location = useLocation(); @@ -62,7 +76,8 @@ const TitleComponent = () => { return ; } - return location.pathname === "/feeds" ? : null; + const titleFn = TITLE_COMPONENTS[location.pathname]; + return titleFn && titleFn(); }; export default TitleComponent; diff --git a/src/components/NiivueDatasetViewer/cssUtils.ts b/src/cssUtils.ts similarity index 100% rename from src/components/NiivueDatasetViewer/cssUtils.ts rename to src/cssUtils.ts diff --git a/vitest.config.ts b/vitest.config.ts index dcd990ac3..23ac111d4 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -7,6 +7,7 @@ export default mergeConfig( test: { include: ["src/**/*.{test,spec}.?(c|m)[jt]s?(x)"], environment: "happy-dom", + setupFiles: ["./vitest.setup.ts"], restoreMocks: true, // coverage for unit testing not enabled, because we have none! @@ -15,6 +16,16 @@ export default mergeConfig( // include: ["src/**"], // reportsDirectory: "./coverage-vitest", // }, + + server: { + deps: { + inline: [ + // workaround for 'Unknown file extension ".css"' + // See https://github.com/vitest-dev/vitest/discussions/6138 + "@patternfly/react-styles", + ], + }, + }, }, }), ); diff --git a/vitest.setup.ts b/vitest.setup.ts new file mode 100644 index 000000000..7c342c0ee --- /dev/null +++ b/vitest.setup.ts @@ -0,0 +1,10 @@ +// Copied from +// https://github.com/vitest-dev/vitest/blob/7d028cb37d3e964a37899559b640bcb3a13acda7/examples/react/vitest.setup.ts + +import '@testing-library/jest-dom/vitest' +import { cleanup } from '@testing-library/react' +import { afterEach } from 'vitest' + +afterEach(() => { + cleanup() +}) From 5991eda30ad60a4e6f6f869f5712afdf0651e2ed Mon Sep 17 00:00:00 2001 From: Jennings Zhang Date: Tue, 17 Sep 2024 17:39:51 -0400 Subject: [PATCH 04/41] Fix font styling conflict by antd App component --- src/App.tsx | 6 ++++-- src/app.css | 9 ++++++++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 64dfe0630..3ddd573d1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -59,8 +59,10 @@ function App(props: AllProps) { }} > - - +
+ + +
diff --git a/src/app.css b/src/app.css index 825e3930a..80e1c8506 100644 --- a/src/app.css +++ b/src/app.css @@ -67,7 +67,7 @@ width: 20px; height: 20px; font-size:12px; - + } .large-button { @@ -83,3 +83,10 @@ justify-content: center !important; align-items: center !important; } + +.patternfly-font { + font-family: var(--pf-v5-global--FontFamily--text); + font-size: var(--pf-v5-global--FontSize--md); + line-height: var(--pf-v5-global--LineHeight--md); + font-weight: var(--pf-v5-global--FontWeight--normal); +} From 0419bb7a7d2a94439ab7c21dc41bfb3de32684a7 Mon Sep 17 00:00:00 2001 From: Jennings Zhang Date: Fri, 20 Sep 2024 21:50:28 -0400 Subject: [PATCH 05/41] Change LonkClient to accept handlers via init --- src/api/fp/chrisapi.ts | 19 ++---- src/api/lonk/client.test.ts | 12 ++-- src/api/lonk/client.ts | 48 +++++++++----- src/components/Pacs/app.tsx | 66 +++++++------------ .../Pacs/components/PacsStudies.tsx | 13 ++++ src/components/Pacs/components/input.tsx | 3 +- src/components/Pacs/pacs.tsx | 45 ++++++++++--- 7 files changed, 116 insertions(+), 90 deletions(-) create mode 100644 src/components/Pacs/components/PacsStudies.tsx diff --git a/src/api/fp/chrisapi.ts b/src/api/fp/chrisapi.ts index b860d1651..93d6a1b7f 100644 --- a/src/api/fp/chrisapi.ts +++ b/src/api/fp/chrisapi.ts @@ -9,7 +9,7 @@ import Client, { import * as TE from "fp-ts/TaskEither"; import * as E from "fp-ts/Either"; import { pipe } from "fp-ts/function"; -import LonkClient, { LonkHandlers } from "../lonk"; +import LonkClient from "../lonk"; /** * fp-ts friendly wrapper for @fnndsc/chrisapi @@ -128,14 +128,11 @@ class FpClient { * * https://chrisproject.org/docs/oxidicom/lonk-ws */ - public connectPacsNotifications({ - onDone, - onProgress, - onError, - timeout, - }: LonkHandlers & { timeout?: number }): TE.TaskEither { + public connectPacsNotifications( + ...args: Parameters + ): TE.TaskEither { return pipe( - this.createDownloadToken(timeout), + this.createDownloadToken(...args), TE.flatMap((downloadToken) => { const url = getWebsocketUrl(downloadToken); let callback: ((c: E.Either) => void) | null = null; @@ -143,11 +140,7 @@ class FpClient { (resolve) => (callback = resolve), ); const ws = new WebSocket(url); - ws.onopen = () => - callback && - callback( - E.right(new LonkClient({ ws, onDone, onProgress, onError })), - ); + ws.onopen = () => callback && callback(E.right(new LonkClient(ws))); ws.onerror = (_ev) => callback && callback( diff --git a/src/api/lonk/client.test.ts b/src/api/lonk/client.test.ts index ec6d828b2..e6f746f35 100644 --- a/src/api/lonk/client.test.ts +++ b/src/api/lonk/client.test.ts @@ -8,7 +8,9 @@ test("LonkClient", async () => { onProgress: vi.fn(), onError: vi.fn(), }; - const [server, client] = await createMockubeWs(handlers); + const [server, client] = await createMockubeWs(32585); + client.init(handlers); + const SeriesInstanceUID = "1.234.56789"; const pacs_name = "MyPACS"; @@ -89,13 +91,11 @@ test("LonkClient", async () => { /** * Create a mock WebSockets server and client. */ -async function createMockubeWs( - handlers: LonkHandlers, -): Promise<[WS, LonkClient]> { - const url = "ws://localhost:32585"; +async function createMockubeWs(port: number): Promise<[WS, LonkClient]> { + const url = `ws://localhost:${port}`; const server = new WS(url, { jsonProtocol: true }); const ws = new WebSocket(url); - const client = new LonkClient({ ws, ...handlers }); + const client = new LonkClient(ws); let callback: null | (([server, client]: [WS, LonkClient]) => void) = null; const promise: Promise<[WS, LonkClient]> = new Promise((resolve) => { diff --git a/src/api/lonk/client.ts b/src/api/lonk/client.ts index 1c5a4f0ba..0784b36f0 100644 --- a/src/api/lonk/client.ts +++ b/src/api/lonk/client.ts @@ -10,7 +10,6 @@ import { import deserialize from "./de.ts"; import { pipe } from "fp-ts/function"; import * as E from "fp-ts/Either"; -import * as T from "fp-ts/Task"; import SeriesMap from "./seriesMap.ts"; /** @@ -20,26 +19,33 @@ import SeriesMap from "./seriesMap.ts"; class LonkClient { private readonly ws: WebSocket; private readonly pendingSubscriptions: SeriesMap SeriesKey)>; + private handlers: LonkHandlers | null; - public constructor({ - ws, - onDone, - onProgress, - onError, - }: LonkHandlers & { ws: WebSocket }) { + public constructor(ws: WebSocket) { + this.handlers = null; this.pendingSubscriptions = new SeriesMap(); this.ws = ws; this.ws.onmessage = (msg) => { pipe( msg.data, deserialize, - E.map((data) => - this.routeMessage({ data, onDone, onProgress, onError }), - ), + E.map((data) => this.routeMessage(data)), ); }; } + /** + * Configure this client with event handler functions. + * `init` must be called exactly once. + */ + public init(handlers: LonkHandlers): LonkClient { + if (this.handlers) { + throw new Error("LonkClient.init called more than once."); + } + this.handlers = handlers; + return this; + } + /** * Subscribe to notifications for a series. */ @@ -61,12 +67,11 @@ class LonkClient { return callbackTask; } - private routeMessage({ - data, - onDone, - onProgress, - onError, - }: LonkHandlers & { data: Lonk }) { + private routeMessage(data: Lonk) { + if (this.handlers === null) { + throw new Error("LonkClient.init has not been called yet."); + } + const { onProgress, onDone, onError } = this.handlers; const { SeriesInstanceUID, pacs_name, message } = data; // note: for performance reasons, this if-else chain is in // descending order of case frequency. @@ -102,11 +107,20 @@ class LonkClient { } /** - * Close the websocket. + * Close the WebSocket. */ public close() { this.ws.close(); } + + /** + * Set the WebSocket's `close` event listener. + * + * https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/close_event + */ + public set onclose(onclose: WebSocket["onclose"]) { + this.ws.onclose = onclose; + } } function isSubscribed(msg: { [key: string]: any }): msg is LonkSubscription { diff --git a/src/components/Pacs/app.tsx b/src/components/Pacs/app.tsx index bb6fceeaa..472bcac9a 100644 --- a/src/components/Pacs/app.tsx +++ b/src/components/Pacs/app.tsx @@ -42,13 +42,18 @@ const PacsQRApp: React.FC<{ getPfdcmClient: () => PfdcmClient; getChrisClient: () => Client; }> = ({ getChrisClient, getPfdcmClient }) => { + /** + * List of PACS server names which can be queried. + */ const [services, setServices] = React.useState>([]); - const [service, setService] = React.useState(null); const [lonkClient, setLonkClient] = React.useState(null); const [error, setError] = React.useState(null); - const { message, notification, modal } = App.useApp(); + const { notification } = App.useApp(); + /** + * Show a notification for an error message. + */ const pushError = React.useMemo(() => { return (title: string) => { return (e: Error) => { @@ -63,13 +68,6 @@ const PacsQRApp: React.FC<{ }; }, [notification]); - const pfdcmClient = React.useMemo(getPfdcmClient, [getPfdcmClient]); - const chrisClient = React.useMemo(getChrisClient, [getChrisClient]); - - const fpClient = React.useMemo(() => { - return new FpClient(chrisClient); - }, [chrisClient]); - /** * Show an error screen with the error's message. * @@ -77,6 +75,13 @@ const PacsQRApp: React.FC<{ */ const failWithError = TE.mapLeft((e: Error) => setError(e.message)); + const pfdcmClient = React.useMemo(getPfdcmClient, [getPfdcmClient]); + const chrisClient = React.useMemo(getChrisClient, [getChrisClient]); + const fpClient = React.useMemo( + () => new FpClient(chrisClient), + [chrisClient], + ); + React.useEffect(() => { document.title = "ChRIS PACS"; }, []); @@ -85,25 +90,22 @@ const PacsQRApp: React.FC<{ const getServicesPipeline = pipe( pfdcmClient.getPacsServices(), failWithError, - TE.map((services) => { - setServices(services); - const defaultService = getDefaultPacsService(services); - defaultService && setService(defaultService); - }), + TE.map(setServices), ); getServicesPipeline(); }, [pfdcmClient, pushError]); React.useEffect(() => { let lonkClientRef: LonkClient | null = null; - - const onDone = () => {}; // TODO - const onProgress = () => {}; // TODO - const onError = () => {}; // TODO const connectWsPipeline = pipe( - fpClient.connectPacsNotifications({ onDone, onProgress, onError }), + fpClient.connectPacsNotifications(), failWithError, TE.map((client) => (lonkClientRef = client)), + TE.map((client) => { + client.onclose = () => + setError("WebSocket closed, please refresh the page."); + return client; + }), TE.map(setLonkClient), ); connectWsPipeline(); @@ -115,13 +117,11 @@ const PacsQRApp: React.FC<{ {error !== null ? ( {error} - ) : services && service && lonkClient ? ( + ) : services && lonkClient ? ( ) : ( @@ -131,26 +131,4 @@ const PacsQRApp: React.FC<{ ); }; -/** - * Selects the default PACS service (which is usually not the PACS service literally called "default"). - * - * 1. Selects the hard-coded "PACSDCM" - * 2. Attempts to select the first value which is not "default" (a useless, legacy pfdcm behavior) - * 3. Selects the first value - */ -function getDefaultPacsService(services: ReadonlyArray): string | null { - if (services.includes("PACSDCM")) { - return "PACSDCM"; - } - for (const service of services) { - if (service !== "default") { - return service; - } - } - if (services) { - return services[0]; - } - return null; -} - export default PacsQRApp; diff --git a/src/components/Pacs/components/PacsStudies.tsx b/src/components/Pacs/components/PacsStudies.tsx new file mode 100644 index 000000000..17cf41314 --- /dev/null +++ b/src/components/Pacs/components/PacsStudies.tsx @@ -0,0 +1,13 @@ +import { PacsStudy } from "../types.ts"; + +type PacsStudiesDisplayProps = { + studies: ReadonlyArray; + onSeriesPull: (pacs_name: string, SeriesInstanceUID: string) => void; + onStudyPull: (pacs_name: string, SeriesInstanceUID: string) => void; +}; + +const PacsStudiesDisplay: React.FC = ({}) => { + return <>hello, world; +}; + +export default PacsStudiesDisplay; diff --git a/src/components/Pacs/components/input.tsx b/src/components/Pacs/components/input.tsx index ba4906fc0..c0f8b0cd9 100644 --- a/src/components/Pacs/components/input.tsx +++ b/src/components/Pacs/components/input.tsx @@ -48,6 +48,7 @@ const MrnInput: React.FC = ({ query, setQuery, ...props }) => { } name="mrnSearchInput" placeholder="Search for DICOM studies by MRN" + {...props} /> {!isQueryEmpty(query) && ( - )} - + submitMrn(e.currentTarget.value)} + onSearch={submitMrn} + enterButton={true} + /> ); }; /** * An advanced search input field for searching in PACS by PatientID, AccessionNumber, ... */ -const AdvancedInput: React.FC = ({ - query, - setQuery, - ...props -}) => { +const AdvancedInput: React.FC = ({ onSubmit }) => { return ( <> -

Advanced search not implemented.

+

Advanced search not implemented.

); }; -type ServiceDropdownProps = { - service: string; - setService: (service: string) => void; - services: ReadonlyArray; -}; - -const ServiceDropdown: React.FC = ({ - service, - services, - setService, -}) => { - const [isOpen, setIsOpen] = React.useState(false); - return ( - { - typeof value === "string" && setService(value); - setIsOpen(false); - }} - onOpenChange={(isOpen: boolean) => setIsOpen(isOpen)} - toggle={(toggleRef: React.Ref) => ( - setIsOpen((open) => !open)} - isExpanded={isOpen} - isFullWidth - title="PACS service" - > - {service} - - )} - > - - {services.map((service) => ( - - {service} - - ))} - - - ); -}; - /** * A `` which shows different text on mobile vs desktop layouts. */ const ScreenSizeSpan: React.FC<{ mobile: React.ReactNode; desktop: React.ReactNode; -}> = ({ mobile, desktop }) => ( - <> - {mobile} - {desktop} - -); +}> = ({ mobile, desktop }) => { + const screens = Grid.useBreakpoint(); + return screens.md ? desktop : mobile; +}; + +const PacsInput: React.FC = ({ onSubmit, services }) => { + const searchParamHooks = useSearchParams(); + const [searchParams, setSearchParams] = searchParamHooks; + const [isAdvancedSearch, setIsAdvancedSearch] = useBooleanSearchParam( + searchParamHooks, + "advancedSearch", + ); -const PacsInput: React.FC = ({ - service, - services, - setService, - ...props -}) => { - const [advancedSearch, setAdvancedSearch] = React.useState(false); + const defaultService = React.useMemo( + () => getDefaultPacsService(services), + [services], + ); - const InputElement = advancedSearch ? AdvancedInput : MrnInput; + const service = searchParams.get("service") || defaultService; + const setService = (service: string) => + setSearchParams((searchParams) => { + searchParams.set("service", service); + return searchParams; + }); + const curriedOnSubmit = React.useMemo( + () => (query: PACSqueryCore) => onSubmit(service, query), + [service, onSubmit], + ); + const input = React.useMemo( + () => + isAdvancedSearch ? ( + + ) : ( + + ), + [isAdvancedSearch, curriedOnSubmit], + ); const advancedSearchToggle = ( - - } - isSelected={!advancedSearch} - onChange={() => setAdvancedSearch(false)} - /> - } - isSelected={advancedSearch} - onChange={() => setAdvancedSearch(true)} - /> - + , + value: false, + }, + { + label: , + value: true, + }, + ]} + value={isAdvancedSearch} + onChange={(e) => setIsAdvancedSearch(e.target.value)} + /> ); const serviceDropdown = ( - +
+ setPacsName(e.target.value)} + /> + setSeriesInstanceUID(e.target.value)} + /> + + +
{subscribedPacsName}
+
+ {subscribedSeriesUid} +
+ +
{unsubscribed}
+ + ); +}; + +test("LonkSubscriber", async () => { + const [client, server] = createMockCubePacsWs(32525); + const props = { + getClient: vi.fn(() => client), + onDone: vi.fn(), + onProgress: vi.fn(), + onError: vi.fn(), + onMessageError: vi.fn(), + }; + render(); + await server.connected; + expect(screen.getByTestId("readyState")).toHaveTextContent( + "" + ReadyState.OPEN, + ); + + const SeriesInstanceUID = "1.234.56789"; + const pacs_name = "MyPACS"; + + const subscriptionReceiveAndRespond = async () => { + await expect(server).toReceiveMessage({ + pacs_name, + SeriesInstanceUID, + action: "subscribe", + }); + server.send({ + pacs_name, + SeriesInstanceUID, + message: { subscribed: true }, + }); + }; + + const subscriptionPromise = subscriptionReceiveAndRespond(); + const pacsNameInput = screen.getByTestId("pacs_name"); + const seriesUidInput = screen.getByTestId("SeriesInstanceUID"); + const subscribeForm = screen.getByTestId("subscribe"); + fireEvent.change(pacsNameInput, { target: { value: pacs_name } }); + fireEvent.change(seriesUidInput, { target: { value: SeriesInstanceUID } }); + fireEvent.submit(subscribeForm); + await subscriptionPromise; + await expect + .poll(() => screen.getByTestId("subscribed-pacs_name")) + .toHaveTextContent(pacs_name); + await expect + .poll(() => screen.getByTestId("subscribed-SeriesInstanceUID")) + .toHaveTextContent(SeriesInstanceUID); + + server.send({ + pacs_name, + SeriesInstanceUID, + message: { + ndicom: 48, + }, + }); + expect(props.onProgress).toHaveBeenCalledOnce(); + expect(props.onProgress).toHaveBeenLastCalledWith( + pacs_name, + SeriesInstanceUID, + 48, + ); + + server.send({ + pacs_name, + SeriesInstanceUID, + message: { + ndicom: 88, + }, + }); + expect(props.onProgress).toHaveBeenCalledTimes(2); + expect(props.onProgress).toHaveBeenLastCalledWith( + pacs_name, + SeriesInstanceUID, + 88, + ); + + server.send({ + pacs_name, + SeriesInstanceUID, + message: { + error: "stuck in chimney", + }, + }); + expect(props.onError).toHaveBeenCalledOnce(); + expect(props.onError).toHaveBeenLastCalledWith( + pacs_name, + SeriesInstanceUID, + "stuck in chimney", + ); + + server.send({ + pacs_name, + SeriesInstanceUID, + message: { + done: true, + }, + }); + expect(props.onDone).toHaveBeenCalledOnce(); + expect(props.onDone).toHaveBeenLastCalledWith(pacs_name, SeriesInstanceUID); + + const bogusData = { bogus: "data" }; + server.send(bogusData); + expect(props.onMessageError).toHaveBeenCalledOnce(); + expect(props.onMessageError).toHaveBeenCalledWith( + JSON.stringify(bogusData), + `Missing or invalid 'message' in ${JSON.stringify(bogusData)}`, + ); + + fireEvent.click(screen.getByTestId("unsubscribe")); + await expect(server).toReceiveMessage({ + action: "unsubscribe", + }); + server.send({ message: { subscribed: false } }); + await expect + .poll(() => screen.getByTestId("unsubscribed")) + .toHaveTextContent("true"); + cleanup(); + await server.closed; +}); + +test.each([ + [ + { + url: "http://example.com/api/v1/downloadtokens/9/", + auth: { + token: "fakeauthtoken", + }, + contentType: "application/vnd.collection+json", + data: { + id: 9, + creation_date: "2024-08-27T17:17:28.580683-04:00", + token: "nota.real.jwttoken", + owner_username: "chris", + }, + }, + "ws://example.com/api/v1/pacs/ws/?token=nota.real.jwttoken", + ], + [ + { + url: "https://example.com/api/v1/downloadtokens/9/", + auth: { + token: "fakeauthtoken", + }, + contentType: "application/vnd.collection+json", + data: { + id: 9, + creation_date: "2024-08-27T17:17:28.580683-04:00", + token: "stillnota.real.jwttoken", + owner_username: "chris", + }, + }, + "wss://example.com/api/v1/pacs/ws/?token=stillnota.real.jwttoken", + ], +])("getWebsocketUrl(%o, %s) -> %s", (downloadTokenResponse, expected) => { + // @ts-ignore + let actual = getWebsocketUrl(downloadTokenResponse); + expect(actual).toBe(expected); +}); diff --git a/src/api/lonk/useLonk.ts b/src/api/lonk/useLonk.ts new file mode 100644 index 000000000..9d358c261 --- /dev/null +++ b/src/api/lonk/useLonk.ts @@ -0,0 +1,103 @@ +import Client, { DownloadToken } from "@fnndsc/chrisapi"; +import useWebSocket, { Options, ReadyState } from "react-use-websocket"; +import { LonkHandlers, SeriesKey } from "./types.ts"; +import React from "react"; +import LonkSubscriber from "./LonkSubscriber.ts"; + +/** + * A subset of the options which are passed through to {@link useWebSocket}. + */ +type AllowedOptions = Pick< + Options, + | "onOpen" + | "onClose" + | "onReconnectStop" + | "shouldReconnect" + | "reconnectInterval" + | "reconnectAttempts" + | "retryOnError" +>; + +type UseLonkParams = LonkHandlers & + AllowedOptions & { + client: Client; + onWebsocketError?: Options["onError"]; + }; + +type UseLonkHook = ReturnType & { + /** + * Subscribe to a DICOM series for receive progress notifications. + */ + subscribe: ( + pacs_name: string, + SeriesInstanceUID: string, + ) => Promise; + /** + * Unsubscribe from all notifications. + */ + unsubscribeAll: () => Promise; +}; + +/** + * Implementation of LONK-WS consumer as a React.js hook, based on + * {@link useWebSocket}. + * + * https://chrisproject.org/docs/oxidicom/lonk-ws + */ +function useLonk({ + client, + onDone, + onProgress, + onError, + onMessageError, + onWebsocketError, + ...options +}: UseLonkParams): UseLonkHook { + const getLonkUrl = React.useCallback(async () => { + const downloadToken = await client.createDownloadToken(); + return getWebsocketUrl(downloadToken); + }, [client, getWebsocketUrl]); + const handlers = { onDone, onProgress, onError, onMessageError }; + const [subscriber, _setSubscriber] = React.useState( + new LonkSubscriber(handlers), + ); + const onMessage = React.useCallback( + (event: MessageEvent) => { + subscriber.handle(event.data); + }, + [onProgress, onDone, onError], + ); + const hook = useWebSocket(getLonkUrl, { + ...options, + onError: onWebsocketError, + onMessage, + }); + + const subscribe = React.useCallback( + (pacs_name: string, SeriesInstanceUID: string) => + subscriber.subscribe(pacs_name, SeriesInstanceUID, hook), + [subscriber, hook], + ); + + const unsubscribeAll = React.useCallback( + () => subscriber.unsubscribeAll(hook), + [subscriber, hook], + ); + + return { + ...hook, + subscribe, + unsubscribeAll, + }; +} + +function getWebsocketUrl(downloadTokenResponse: DownloadToken): string { + const token = downloadTokenResponse.data.token; + return downloadTokenResponse.url + .replace(/^http(s?):\/\//, (_match, s) => `ws${s}://`) + .replace(/v1\/downloadtokens\/\d+\//, `v1/pacs/ws/?token=${token}`); +} + +export type { UseLonkParams }; +export { getWebsocketUrl }; +export default useLonk; diff --git a/src/api/testHelpers.ts b/src/api/testHelpers.ts new file mode 100644 index 000000000..cdc3c37e6 --- /dev/null +++ b/src/api/testHelpers.ts @@ -0,0 +1,34 @@ +import { vi } from "vitest"; +import ChrisClient, { DownloadToken } from "@fnndsc/chrisapi"; +import WS from "vitest-websocket-mock"; + +/** + * Helper function for mocking LONK-WS. + */ +function createMockCubePacsWs( + port: number, + id: number = 55, +): [ChrisClient, WS] { + const fakeChrisHost = `localhost:${port}`; + const fakeChrisUrl = `http://${fakeChrisHost}/api/v1/`; + const fakeChrisAuth = { token: "12345" }; + vi.spyOn(DownloadToken.prototype, "data", "get").mockReturnValue({ + token: "abcdefgnotarealjwt", + }); + const fakeDownloadToken = new DownloadToken( + `${fakeChrisUrl}downloadtokens/${id}/`, + fakeChrisAuth, + ); + + const client = new ChrisClient(fakeChrisUrl, fakeChrisAuth); + client.createDownloadToken = vi.fn(async () => fakeDownloadToken); + + const ws = new WS( + `ws://${fakeChrisHost}/api/v1/pacs/ws/?token=abcdefgnotarealjwt`, + { jsonProtocol: true }, + ); + + return [client, ws]; +} + +export { createMockCubePacsWs }; diff --git a/src/components/Pacs/PacsController.test.tsx b/src/components/Pacs/PacsController.test.tsx index a5d0ffaa8..86c0f5df4 100644 --- a/src/components/Pacs/PacsController.test.tsx +++ b/src/components/Pacs/PacsController.test.tsx @@ -1,15 +1,13 @@ import { cleanup, screen } from "@testing-library/react"; import { expect, test, vi } from "vitest"; import PacsQRApp from "./PacsController.tsx"; -import * as TE from "fp-ts/TaskEither"; import { Configuration as PfdcmConfig, PfdcmClient } from "../../api/pfdcm"; -import ChrisClient, { DownloadToken } from "@fnndsc/chrisapi"; -import WS from "vitest-websocket-mock"; import { renderWithProviders } from "../../store/testHelpers.tsx"; +import { createMockCubePacsWs } from "../../api/testHelpers.ts"; test("PACS Q/R page can bootstrap", async () => { - const pfdcmClient = createPfdcmMock(TE.right(["BCH", "MGH", "BWH"])); - const [chrisClient, ws] = createWorkingMockPacsWs(32584); + const pfdcmClient = createPfdcmMock(async () => ["BCH", "MGH", "BWH"]); + const [chrisClient, ws] = createMockCubePacsWs(32584); const getClientMocks = { getChrisClient: vi.fn(() => chrisClient), @@ -25,17 +23,13 @@ test("PACS Q/R page can bootstrap", async () => { // First PACS service (besides 'default') should be automatically selected. expect(screen.getByTitle("PACS service")).toHaveTextContent("BCH"); - - // component should close WebSocket connection when unmounted - cleanup(); - await ws.closed; }); test("Shows error screen if PFDCM is offline", async () => { - const pfdcmClient = createPfdcmMock( - TE.left(new Error("I am an expected error")), - ); - const [chrisClient, _ws] = createWorkingMockPacsWs(32583); + const pfdcmClient = createPfdcmMock(async () => { + throw new Error("I am an expected error"); + }); + const [chrisClient, _ws] = createMockCubePacsWs(32583); const getClientMocks = { getChrisClient: vi.fn(() => chrisClient), @@ -51,37 +45,9 @@ test("Shows error screen if PFDCM is offline", async () => { ).toBeInTheDocument(); }); -function createWorkingMockPacsWs( - port: number, - id: number = 55, -): [ChrisClient, WS] { - const fakeChrisHost = `localhost:${port}`; - const fakeChrisUrl = `http://${fakeChrisHost}/api/v1/`; - const fakeChrisAuth = { token: "12345" }; - vi.spyOn(DownloadToken.prototype, "data", "get").mockReturnValue({ - token: "abcdefgnotarealjwt", - }); - const fakeDownloadToken = new DownloadToken( - `${fakeChrisUrl}downloadtokens/${id}/`, - fakeChrisAuth, - ); - - const client = new ChrisClient(fakeChrisUrl, fakeChrisAuth); - client.createDownloadToken = vi.fn(async () => fakeDownloadToken); - - const ws = new WS( - `ws://${fakeChrisHost}/api/v1/pacs/ws/?token=abcdefgnotarealjwt`, - { jsonProtocol: true }, - ); - - return [client, ws]; -} - -function createPfdcmMock( - servicesReturn: ReturnType, -) { +function createPfdcmMock(getPacsServices: PfdcmClient["getPacsServices"]) { const pfdcmConfig = new PfdcmConfig({ basePath: "https://example.com" }); const pfdcmClient = new PfdcmClient(pfdcmConfig); - pfdcmClient.getPacsServices = vi.fn(() => servicesReturn); + pfdcmClient.getPacsServices = vi.fn(getPacsServices); return pfdcmClient; } diff --git a/src/components/Pacs/PacsController.tsx b/src/components/Pacs/PacsController.tsx index 7f2171831..f7d866c01 100644 --- a/src/components/Pacs/PacsController.tsx +++ b/src/components/Pacs/PacsController.tsx @@ -16,11 +16,7 @@ import React from "react"; import { PACSqueryCore, PfdcmClient } from "../../api/pfdcm"; import Client, { PACSSeries } from "@fnndsc/chrisapi"; -import LonkSubscriber from "../../api/lonk"; import { App } from "antd"; -import FpClient from "../../api/fp/chrisapi.ts"; -import * as TE from "fp-ts/TaskEither"; -import { pipe } from "fp-ts/function"; import { PageSection } from "@patternfly/react-core"; import PacsView from "./PacsView.tsx"; import PacsLoadingScreen from "./components/PacsLoadingScreen.tsx"; @@ -28,15 +24,18 @@ import ErrorScreen from "./components/ErrorScreen.tsx"; import { skipToken, useQueries, useQuery } from "@tanstack/react-query"; import joinStates, { SeriesQueryZip } from "./joinStates.ts"; import { + DEFAULT_RECEIVE_STATE, IPacsState, - SeriesReceiveState, ReceiveState, + SeriesReceiveState, StudyKey, } from "./types.ts"; import { DEFAULT_PREFERENCES } from "./defaultPreferences.ts"; import { zipPacsNameAndSeriesUids } from "./helpers.ts"; import { useImmer } from "use-immer"; import SeriesMap from "../../api/lonk/seriesMap.ts"; +import { useLonk } from "../../api/lonk"; +import { produce, WritableDraft } from "immer"; type PacsControllerProps = { getPfdcmClient: () => PfdcmClient; @@ -58,10 +57,6 @@ const PacsController: React.FC = ({ const pfdcmClient = React.useMemo(getPfdcmClient, [getPfdcmClient]); const chrisClient = React.useMemo(getChrisClient, [getChrisClient]); - const fpClient = React.useMemo( - () => new FpClient(chrisClient), - [chrisClient], - ); // ======================================== // STATE @@ -71,7 +66,11 @@ const PacsController: React.FC = ({ service?: string; query?: PACSqueryCore; }>({}); - const [wsError, setWsError] = React.useState(null); + + /** + * Indicates a fatal error with the WebSocket. + */ + const [wsError, setWsError] = React.useState(null); // TODO create a settings component for changing preferences const [preferences, setPreferences] = React.useState(DEFAULT_PREFERENCES); @@ -146,10 +145,100 @@ const PacsController: React.FC = ({ }, [preferences, studies]); const error = React.useMemo( - () => wsError || pfdcmServices.error, + () => wsError || pfdcmServices.error?.message, [wsError, pfdcmServices.error], ); + // ======================================== + // LONK WEBSOCKET + // ======================================== + + const getSeriesDescriptionOr = React.useCallback( + (pacs_name: string, SeriesInstanceUID: string) => { + if (!pfdcmStudies.data) { + return SeriesInstanceUID; + } + const series = pfdcmStudies.data + .flatMap((s) => s.series) + .find( + (s) => + s.SeriesInstanceUID === SeriesInstanceUID && + s.RetrieveAETitle === pacs_name, + ); + if (!series) { + return SeriesInstanceUID; + } + return series.SeriesDescription; + }, + [pfdcmStudies.data], + ); + + /** + * Update (or insert) the state of a series' reception. + */ + const updateReceiveState = React.useCallback( + ( + pacs_name: string, + SeriesInstanceUID: string, + recipe: (draft: WritableDraft) => void, + ) => + setReceiveState((draft) => { + const prevState = + draft.get(pacs_name, SeriesInstanceUID) || DEFAULT_RECEIVE_STATE; + const nextState = produce(prevState, recipe); + draft.set(pacs_name, SeriesInstanceUID, nextState); + }), + [setReceiveState], + ); + + const lonk = useLonk({ + client: chrisClient, + onDone(pacs_name: string, SeriesInstanceUID: string) { + updateReceiveState(pacs_name, SeriesInstanceUID, (draft) => { + draft.done = true; + }); + }, + onProgress(pacs_name: string, SeriesInstanceUID: string, ndicom: number) { + updateReceiveState(pacs_name, SeriesInstanceUID, (draft) => { + draft.receivedCount = ndicom; + }); + }, + onError(pacs_name: string, SeriesInstanceUID: string, error: string) { + updateReceiveState(pacs_name, SeriesInstanceUID, (draft) => { + draft.errors.push(error); + }); + const desc = getSeriesDescriptionOr(pacs_name, SeriesInstanceUID); + message.error( + <>There was an error while receiving the series "{desc}", + ); + }, + onMessageError(data: any, error: string) { + console.error("LONK message error", error, data); + message.error( + <> + A LONK error occurred, please check the console. + , + ); + }, + retryOnError: true, + reconnectAttempts: 3, + reconnectInterval: 3000, + shouldReconnect(e) { + return e.code < 400 || e.code > 499; + }, + onReconnectStop() { + setWsError(<>The WebSocket is disconnected.); + }, + onWebsocketError() { + message.error( + <>There was an error with the WebSocket. Reconnecting…, + ); + }, + onClose() { + message.error(<>The WebSocket was closed. Reconnecting…); + }, + }); + // ======================================== // CALLBACKS // ======================================== @@ -198,27 +287,20 @@ const PacsController: React.FC = ({ }; }, []); - // Connect to PACS progress websocket and respond to updates. + // Subscribe to all expanded series React.useEffect(() => { - let subscriber: LonkSubscriber | null = null; - const connectWsPipeline = pipe( - fpClient.connectPacsNotifications(), - TE.mapLeft(setWsError), - TE.map((s) => (subscriber = s)), - TE.map((s) => { - s.onclose = () => - message.error(<>WebSocket closed, please refresh the page.); - s.init({ - // TODO - onError: () => {}, - onDone: () => {}, - onProgress: () => {}, + for (const { pacs_name, SeriesInstanceUID } of expandedSeries) { + lonk + .subscribe(pacs_name, SeriesInstanceUID) + .then(({ pacs_name, SeriesInstanceUID }) => { + updateReceiveState(pacs_name, SeriesInstanceUID, (draft) => { + draft.subscribed = true; + }); }); - }), - ); - connectWsPipeline(); - return () => subscriber?.close(); - }, [fpClient, setWsError, message]); + } + // Note: we are subscribing to series, but never unsubscribing. + // This is mostly harmless. + }, [expandedSeries]); // ======================================== // RENDER @@ -227,7 +309,7 @@ const PacsController: React.FC = ({ return ( {error ? ( - {error.message} + {error} ) : pfdcmServices.data ? ( >>, -// ) { -// state.studies = pipe( -// action.payload, -// E.map((studies) => studies.map(newStudyState)), -// ); -// }, -// }, -// }); -// -// function newStudyState({ study, series }: StudyAndSeries): PacsStudyState { -// return { -// info: study, -// series: series.map((info) => { -// return { -// info, -// receivedCount: 0, -// error: [], -// pullState: SeriesPullState.READY, -// inCube: null, -// }; -// }), -// }; -// } -// -// export { context }; -// export default context.reducer; diff --git a/src/components/Pacs/joinStates.test.ts b/src/components/Pacs/joinStates.test.ts new file mode 100644 index 000000000..d6e283618 --- /dev/null +++ b/src/components/Pacs/joinStates.test.ts @@ -0,0 +1,104 @@ +import { expect, test } from "vitest"; +import { pullStateOf } from "./joinStates.ts"; +import { + DEFAULT_RECEIVE_STATE, + SeriesPullState, + SeriesReceiveState, +} from "./types.ts"; + +test.each(< + [ + SeriesReceiveState, + { isLoading: boolean; data: any } | undefined, + SeriesPullState, + ][] +>[ + [DEFAULT_RECEIVE_STATE, undefined, SeriesPullState.NOT_CHECKED], + [ + { + subscribed: true, + requested: false, + done: false, + receivedCount: 0, + errors: [], + }, + { + isLoading: true, + data: undefined, + }, + SeriesPullState.CHECKING, + ], + [ + { + subscribed: false, + requested: false, + done: false, + receivedCount: 0, + errors: [], + }, + { + isLoading: false, + data: null, + }, + SeriesPullState.CHECKING, + ], + [ + { + subscribed: true, + requested: false, + done: false, + receivedCount: 0, + errors: [], + }, + { + isLoading: false, + data: null, + }, + SeriesPullState.READY, + ], + [ + { + subscribed: true, + requested: true, + done: false, + receivedCount: 0, + errors: [], + }, + { + isLoading: false, + data: null, + }, + SeriesPullState.PULLING, + ], + [ + { + subscribed: true, + requested: true, + done: true, + receivedCount: 10, + errors: [], + }, + { + isLoading: false, + data: null, + }, + SeriesPullState.WAITING_OR_COMPLETE, + ], + [ + { + subscribed: true, + requested: true, + done: true, + receivedCount: 10, + errors: [], + }, + { + isLoading: false, + data: "some", + }, + SeriesPullState.WAITING_OR_COMPLETE, + ], +])("pullStateOf(%o, %o) -> %o", (state, result, expected) => { + const actual = pullStateOf(state, result); + expect(actual).toStrictEqual(expected); +}); diff --git a/src/components/Pacs/joinStates.ts b/src/components/Pacs/joinStates.ts index 15d991191..9fe48f198 100644 --- a/src/components/Pacs/joinStates.ts +++ b/src/components/Pacs/joinStates.ts @@ -58,21 +58,48 @@ function joinStates( }); } +/** + * State coalescence. + * + * It is assumed that ChRIS has the following behavior for each DICOM series: + * + * 1. ChRIS_ui subscribes to a series' notifications via LONK + * 2. ChRIS_ui checks CUBE whether a series exists in CUBE + * 3. When both subscription and existence check is complete, + * and the series does not exist in CUBE, ChRIS_ui is ready + * to pull the DICOM series. + * 4. During the reception of a DICOM series, `status.done === false` + * 5. After the reception of a DICOM series, ChRIS enters a "waiting" + * state while the task to register the DICOM series is enqueued + * or running. + * 6. The DICOM series will appear in CUBE after being registered. + */ function pullStateOf( state: SeriesReceiveState, - result?: PACSSeriesQueryResult, + result?: { isLoading: boolean; data: any }, ): SeriesPullState { if (!result) { + // request to check CUBE whether series exists has not been initiated return SeriesPullState.NOT_CHECKED; } - if (result.isLoading) { + if (!state.subscribed || result.isLoading) { + // either not subscribed yet, or request to check CUBE whether series + // exists is pending return SeriesPullState.CHECKING; } if (result.data === null) { + // checked, series DOES NOT exist in CUBE + if (state.done) { + // finished receiving by oxidicom, waiting for CUBE to register + return SeriesPullState.WAITING_OR_COMPLETE; + } + // either pulling or ready to pull return state.requested ? SeriesPullState.PULLING : SeriesPullState.READY; } + // checked, series DOES exist in CUBE. It is complete. return SeriesPullState.WAITING_OR_COMPLETE; } export type { SeriesQueryZip }; +export { pullStateOf }; export default joinStates; diff --git a/src/components/Pacs/types.ts b/src/components/Pacs/types.ts index 0fd163455..e3b7f5289 100644 --- a/src/components/Pacs/types.ts +++ b/src/components/Pacs/types.ts @@ -42,6 +42,10 @@ enum SeriesPullState { * The state of a DICOM series retrieval. */ type SeriesReceiveState = { + /** + * Whether this series has been subscribed to via LONK. + */ + subscribed: boolean; /** * Whether this series has been requested by PFDCM. */ @@ -61,6 +65,7 @@ type SeriesReceiveState = { }; const DEFAULT_RECEIVE_STATE: SeriesReceiveState = { + subscribed: false, requested: false, done: false, receivedCount: 0, diff --git a/src/store/testHelpers.tsx b/src/store/testHelpers.tsx index 2e392756b..0881e3a6e 100644 --- a/src/store/testHelpers.tsx +++ b/src/store/testHelpers.tsx @@ -7,6 +7,8 @@ import { ApplicationState } from "./root/applicationState.ts"; import { Provider } from "react-redux"; import { MemoryRouter } from "react-router"; import React from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { App } from "antd"; // This type interface extends the default options for render from RTL, as well // as allows the user to specify other things such as initialState, store. @@ -29,9 +31,24 @@ export function renderWithProviders( ...renderOptions } = extendedRenderOptions; + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: false, // default: true + refetchOnMount: false, + retry: false, + staleTime: 0, + }, + }, + }); + const Wrapper = ({ children }: React.PropsWithChildren) => ( - {children} + + + {children} + + ); From 1a66b2aaa890c0a9ef1705faa3020c215b4b8670 Mon Sep 17 00:00:00 2001 From: Jennings Zhang Date: Tue, 24 Sep 2024 11:51:29 -0400 Subject: [PATCH 21/41] Rename things + more documentation --- biome.json | 1 + src/components/Pacs/PacsController.test.tsx | 2 +- src/components/Pacs/PacsController.tsx | 61 +++++++++++++------ src/components/Pacs/joinStates.test.ts | 2 +- .../Pacs/{joinStates.ts => mergeStates.ts} | 30 +++------ vitest.setup.ts | 16 +++-- 6 files changed, 66 insertions(+), 46 deletions(-) rename src/components/Pacs/{joinStates.ts => mergeStates.ts} (72%) diff --git a/biome.json b/biome.json index ac80de028..4e93c86e1 100644 --- a/biome.json +++ b/biome.json @@ -7,6 +7,7 @@ "package.json", "biome.json", "*.config.ts", + "vitest.*.ts", "tsconfig*.json", "testing/*.mjs" ], diff --git a/src/components/Pacs/PacsController.test.tsx b/src/components/Pacs/PacsController.test.tsx index 86c0f5df4..5b9cc465e 100644 --- a/src/components/Pacs/PacsController.test.tsx +++ b/src/components/Pacs/PacsController.test.tsx @@ -1,4 +1,4 @@ -import { cleanup, screen } from "@testing-library/react"; +import { screen } from "@testing-library/react"; import { expect, test, vi } from "vitest"; import PacsQRApp from "./PacsController.tsx"; import { Configuration as PfdcmConfig, PfdcmClient } from "../../api/pfdcm"; diff --git a/src/components/Pacs/PacsController.tsx b/src/components/Pacs/PacsController.tsx index f7d866c01..5dc574baa 100644 --- a/src/components/Pacs/PacsController.tsx +++ b/src/components/Pacs/PacsController.tsx @@ -1,16 +1,7 @@ /** * The primary PACS Q/R UI code is found in ./PacsView.tsx. This file defines - * a component which wraps the default export from ./PacsView.tsx, which: - * - * 1. "bootstraps" the client objects it needs (see below) - * - * The PACS Q/R application needs to be "bootstrapped" which means: - * - * 1. Making an initial connection to PFDCM - * 2. Connecting to the PACS receive progress WebSocket, `api/v1/pacs/ws/` - * - * During bootstrapping, a loading screen is shown. - * If bootstrapping fails, an error screen is shown. + * a component which wraps the default export from ./PacsView.tsx, which + * manages effects and state. */ import React from "react"; @@ -22,7 +13,7 @@ import PacsView from "./PacsView.tsx"; import PacsLoadingScreen from "./components/PacsLoadingScreen.tsx"; import ErrorScreen from "./components/ErrorScreen.tsx"; import { skipToken, useQueries, useQuery } from "@tanstack/react-query"; -import joinStates, { SeriesQueryZip } from "./joinStates.ts"; +import mergeStates, { SeriesQueryZip } from "./mergeStates.ts"; import { DEFAULT_RECEIVE_STATE, IPacsState, @@ -43,7 +34,44 @@ type PacsControllerProps = { }; /** - * ChRIS_ui PACS Query and Retrieve controller + view. + * ChRIS_ui "PACS Query and Retrieve" controller + view. + * + * ## Purpose + * + * This component handles all the state and effects for {@link PacsView}, + * which includes: + * + * - Managing the CUBE and PFDCM client objects and making requests with them + * - Connecting to the progress notifications WebSocket at `api/v1/pacs/ws/` + * - Knowing the state of which DICOM series are received, receiving, or + * ready to receive. + * - Dispatching requests for querying and retrieving DICOM from PACS via PFDCM. + * + * ## Bootstrapping + * + * The PACS Q/R application needs to be "bootstrapped" which means: + * + * 1. Making an initial connection to PFDCM + * 2. Connecting to the PACS receive progress WebSocket, `api/v1/pacs/ws/` + * + * During bootstrapping, a loading screen is shown. If bootstrapping fails, + * an error screen is shown. + * + * ## PACS Retrieve Workflow + * + * ChRIS_ui and the rest of CUBE work together to implement the following + * behavior for each DICOM series: + * + * 1. ChRIS_ui subscribes to a series' notifications via LONK + * 2. ChRIS_ui checks CUBE whether a series exists in CUBE + * 3. When both subscription and existence check is complete, + * and the series does not exist in CUBE, ChRIS_ui is ready + * to pull the DICOM series. + * 4. During the reception of a DICOM series, `status.done === false` + * 5. After the reception of a DICOM series, ChRIS enters a "waiting" + * state while the task to register the DICOM series is enqueued + * or running. + * 6. The DICOM series will appear in CUBE after being registered. */ const PacsController: React.FC = ({ getChrisClient, @@ -137,8 +165,8 @@ const PacsController: React.FC = ({ if (!pfdcmStudies.data) { return null; } - return joinStates(pfdcmStudies.data, cubeSeriesQueryZip, receiveState); - }, [joinStates, pfdcmStudies, cubeSeriesQueryZip, receiveState]); + return mergeStates(pfdcmStudies.data, cubeSeriesQueryZip, receiveState); + }, [mergeStates, pfdcmStudies, cubeSeriesQueryZip, receiveState]); const state: IPacsState = React.useMemo(() => { return { preferences, studies }; @@ -234,9 +262,6 @@ const PacsController: React.FC = ({ <>There was an error with the WebSocket. Reconnecting…, ); }, - onClose() { - message.error(<>The WebSocket was closed. Reconnecting…); - }, }); // ======================================== diff --git a/src/components/Pacs/joinStates.test.ts b/src/components/Pacs/joinStates.test.ts index d6e283618..d81e160b6 100644 --- a/src/components/Pacs/joinStates.test.ts +++ b/src/components/Pacs/joinStates.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "vitest"; -import { pullStateOf } from "./joinStates.ts"; +import { pullStateOf } from "./mergeStates.ts"; import { DEFAULT_RECEIVE_STATE, SeriesPullState, diff --git a/src/components/Pacs/joinStates.ts b/src/components/Pacs/mergeStates.ts similarity index 72% rename from src/components/Pacs/joinStates.ts rename to src/components/Pacs/mergeStates.ts index 9fe48f198..de63edd2c 100644 --- a/src/components/Pacs/joinStates.ts +++ b/src/components/Pacs/mergeStates.ts @@ -21,9 +21,9 @@ type SeriesQueryZip = { * Fragments of the state of a DICOM series exists remotely in three places: * PFDCM, CUBE, and LONK. * - * Join the states from those places into one mega-object. + * Merge the states from those places into one mega-object. */ -function joinStates( +function mergeStates( pfdcm: ReadonlyArray, cubeSeriesQuery: ReadonlyArray, receiveState: ReceiveState, @@ -59,35 +59,23 @@ function joinStates( } /** - * State coalescence. - * - * It is assumed that ChRIS has the following behavior for each DICOM series: - * - * 1. ChRIS_ui subscribes to a series' notifications via LONK - * 2. ChRIS_ui checks CUBE whether a series exists in CUBE - * 3. When both subscription and existence check is complete, - * and the series does not exist in CUBE, ChRIS_ui is ready - * to pull the DICOM series. - * 4. During the reception of a DICOM series, `status.done === false` - * 5. After the reception of a DICOM series, ChRIS enters a "waiting" - * state while the task to register the DICOM series is enqueued - * or running. - * 6. The DICOM series will appear in CUBE after being registered. + * State coalescence for the "PACS Retrieve Workflow" described in the + * tsdoc for {@link PacsController}. */ function pullStateOf( state: SeriesReceiveState, - result?: { isLoading: boolean; data: any }, + cubeQueryResult?: { isLoading: boolean; data: any }, ): SeriesPullState { - if (!result) { + if (!cubeQueryResult) { // request to check CUBE whether series exists has not been initiated return SeriesPullState.NOT_CHECKED; } - if (!state.subscribed || result.isLoading) { + if (!state.subscribed || cubeQueryResult.isLoading) { // either not subscribed yet, or request to check CUBE whether series // exists is pending return SeriesPullState.CHECKING; } - if (result.data === null) { + if (cubeQueryResult.data === null) { // checked, series DOES NOT exist in CUBE if (state.done) { // finished receiving by oxidicom, waiting for CUBE to register @@ -102,4 +90,4 @@ function pullStateOf( export type { SeriesQueryZip }; export { pullStateOf }; -export default joinStates; +export default mergeStates; diff --git a/vitest.setup.ts b/vitest.setup.ts index 7c342c0ee..9582bad03 100644 --- a/vitest.setup.ts +++ b/vitest.setup.ts @@ -1,10 +1,16 @@ // Copied from // https://github.com/vitest-dev/vitest/blob/7d028cb37d3e964a37899559b640bcb3a13acda7/examples/react/vitest.setup.ts -import '@testing-library/jest-dom/vitest' -import { cleanup } from '@testing-library/react' -import { afterEach } from 'vitest' +import "@testing-library/jest-dom/vitest"; +import { cleanup } from "@testing-library/react"; +import { afterEach } from "vitest"; afterEach(() => { - cleanup() -}) + /* + * Note: when `cleanup` is called, antd might throw false positive warnings: + * + * > Warning: [antd: Message] You are calling notice in render which will + * > break in React 18 concurrent mode. Please trigger in effect instead. + */ + cleanup(); +}); From 0811d4efbe3f81e272b36238c5645c5d467d091b Mon Sep 17 00:00:00 2001 From: Jennings Zhang Date: Wed, 25 Sep 2024 11:11:46 -0400 Subject: [PATCH 22/41] PACS pull is working, progress messages aren't though. --- src/components/Pacs/PacsController.tsx | 225 ++++++++++++++++-- src/components/Pacs/components/SeriesList.tsx | 13 +- .../Pacs/components/StudyButtons.tsx | 1 + src/components/Pacs/joinStates.test.ts | 67 +++++- src/components/Pacs/mergeStates.ts | 51 +++- src/components/Pacs/types.ts | 32 ++- src/main.tsx | 2 + tsconfig.app.json | 4 +- tsconfig.json | 4 +- 9 files changed, 348 insertions(+), 51 deletions(-) diff --git a/src/components/Pacs/PacsController.tsx b/src/components/Pacs/PacsController.tsx index 5dc574baa..e9ff6110b 100644 --- a/src/components/Pacs/PacsController.tsx +++ b/src/components/Pacs/PacsController.tsx @@ -12,12 +12,20 @@ import { PageSection } from "@patternfly/react-core"; import PacsView from "./PacsView.tsx"; import PacsLoadingScreen from "./components/PacsLoadingScreen.tsx"; import ErrorScreen from "./components/ErrorScreen.tsx"; -import { skipToken, useQueries, useQuery } from "@tanstack/react-query"; +import { + skipToken, + useMutation, + useQueries, + useQuery, +} from "@tanstack/react-query"; import mergeStates, { SeriesQueryZip } from "./mergeStates.ts"; import { DEFAULT_RECEIVE_STATE, IPacsState, + PacsPullRequestState, ReceiveState, + RequestState, + SeriesPullState, SeriesReceiveState, StudyKey, } from "./types.ts"; @@ -60,18 +68,21 @@ type PacsControllerProps = { * ## PACS Retrieve Workflow * * ChRIS_ui and the rest of CUBE work together to implement the following - * behavior for each DICOM series: + * behavior: * - * 1. ChRIS_ui subscribes to a series' notifications via LONK - * 2. ChRIS_ui checks CUBE whether a series exists in CUBE - * 3. When both subscription and existence check is complete, + * 1. User queries for data, which is resolved by PFDCM. + * 2. User clicks to expand a DICOM study, showing a list of series. + * 3. For each series... + * 4. ChRIS_ui subscribes to a series' notifications via LONK + * 5. ChRIS_ui checks CUBE whether a series exists in CUBE + * 6. When both subscription and existence check is complete, * and the series does not exist in CUBE, ChRIS_ui is ready * to pull the DICOM series. - * 4. During the reception of a DICOM series, `status.done === false` - * 5. After the reception of a DICOM series, ChRIS enters a "waiting" + * 7. During the reception of a DICOM series, `status.done === false` + * 8. After the reception of a DICOM series, ChRIS enters a "waiting" * state while the task to register the DICOM series is enqueued * or running. - * 6. The DICOM series will appear in CUBE after being registered. + * 9. The DICOM series will appear in CUBE after being registered. */ const PacsController: React.FC = ({ getChrisClient, @@ -99,41 +110,93 @@ const PacsController: React.FC = ({ * Indicates a fatal error with the WebSocket. */ const [wsError, setWsError] = React.useState(null); - // TODO create a settings component for changing preferences const [preferences, setPreferences] = React.useState(DEFAULT_PREFERENCES); - + /** + * The state of DICOM series, according to LONK. + */ const [receiveState, setReceiveState] = useImmer( new SeriesMap(), ); - + /** + * Studies which have their series visible on-screen. + */ const [expandedStudies, setExpandedStudies] = React.useState< ReadonlyArray >([]); + /** + * List of PACS queries which the user wants to pull. + */ + const [pullRequests, setPullRequests] = useImmer< + ReadonlyArray + >([]); + + /** + * Update the state of a pull request. + */ + const updatePullRequestState = React.useCallback( + ( + service: string, + query: PACSqueryCore, + delta: Partial>, + ) => + setPullRequests((draft) => { + const i = draft.findLastIndex( + (pr) => + pr.service === service && + (pr.query.studyInstanceUID + ? pr.query.studyInstanceUID === query.studyInstanceUID + : true) && + (pr.query.seriesInstanceUID + ? pr.query.seriesInstanceUID === query.seriesInstanceUID + : true), + ); + if (i === -1) { + throw new Error( + "pullFromPacs mutation called on unknown pull request: " + + `service=${service}, query=${JSON.stringify(query)}`, + ); + } + draft[i] = { ...draft[i], ...delta }; + }), + [setPullRequests], + ); + // ======================================== // QUERIES AND DATA // ======================================== + /** + * List of PACS servers which PFDCM can talk to. + */ const pfdcmServices = useQuery({ queryKey: ["pfdcmServices"], queryFn: () => pfdcmClient.getPacsServices(), }); - const pfdcmStudiesQueryKey = React.useMemo( - () => ["pfdcmStudies", service, query], - [service, query], - ); + /** + * The state of DICOM studies and series in PFDCM. + */ const pfdcmStudies = useQuery({ - queryKey: pfdcmStudiesQueryKey, + queryKey: React.useMemo( + () => ["pfdcmStudies", service, query], + [service, query], + ), queryFn: service && query ? () => pfdcmClient.query(service, query) : skipToken, }); + /** + * List of series which are currently visible on-screen. + */ const expandedSeries = React.useMemo( () => zipPacsNameAndSeriesUids(expandedStudies, pfdcmStudies.data), [expandedStudies, pfdcmStudies.data], ); + /** + * Check whether CUBE has any of the series that are expanded. + */ const cubeSeriesQuery = useQueries({ queries: expandedSeries.map((series) => ({ queryKey: ["cubeSeries", chrisClient.url, series], @@ -152,6 +215,10 @@ const PacsController: React.FC = ({ })), }); + /** + * Zip together elements of `cubeSeriesQuery` and the parameters used for + * each element. + */ const cubeSeriesQueryZip: ReadonlyArray = React.useMemo( () => expandedSeries.map((search, i) => ({ @@ -161,13 +228,24 @@ const PacsController: React.FC = ({ [expandedSeries, cubeSeriesQuery], ); + /** + * Combined states of PFDCM, LONK, and CUBE into one object. + */ const studies = React.useMemo(() => { if (!pfdcmStudies.data) { return null; } - return mergeStates(pfdcmStudies.data, cubeSeriesQueryZip, receiveState); + return mergeStates( + pfdcmStudies.data, + pullRequests, + cubeSeriesQueryZip, + receiveState, + ); }, [mergeStates, pfdcmStudies, cubeSeriesQueryZip, receiveState]); + /** + * Entire state of the Pacs Q/R application. + */ const state: IPacsState = React.useMemo(() => { return { preferences, studies }; }, [preferences, studies]); @@ -280,7 +358,6 @@ const PacsController: React.FC = ({ const onStudyExpand = React.useCallback( (pacs_name: string, StudyInstanceUIDs: ReadonlyArray) => { - console.log(`onStudyExpand`); setExpandedStudies( StudyInstanceUIDs.map((StudyInstanceUID) => ({ StudyInstanceUID, @@ -291,14 +368,120 @@ const PacsController: React.FC = ({ [setExpandedStudies], ); - // TODO onRetrieve + // ======================================== + // PACS RETRIEVAL + // ======================================== + const onRetrieve = React.useCallback( (service: string, query: PACSqueryCore) => { - console.log(`onRetrieve`); + setPullRequests((draft) => { + // indicate that the user requests for something to be retrieved. + draft.push({ + state: RequestState.NOT_REQUESTED, + query, + service, + }); + }); }, - [], + [setPullRequests], + ); + + /** + * @returns true if the study does not contain any series which are `NOT_CHECKED` or `CHECKING`. + */ + const shouldPullStudy = React.useCallback( + (pacs_name: string, StudyInstanceUID: string) => + (studies ?? []) + .filter( + ({ info }) => + info.StudyInstanceUID === StudyInstanceUID && + info.RetrieveAETitle === pacs_name, + ) + .flatMap((study) => study.series) + .findIndex( + ({ pullState }) => + pullState === SeriesPullState.NOT_CHECKED || + pullState == SeriesPullState.CHECKING, + ) === -1, + [studies], ); + /** + * All series states. + */ + const allSeries = React.useMemo( + () => (studies ?? []).flatMap((s) => s.series), + [studies], + ); + + /** + * @returns true if the series is ready to pull. + */ + const shouldPullSeries = React.useCallback( + (pacs_name: string, SeriesInstanceUID: string) => + allSeries.findIndex( + ({ info, pullState }) => + info.RetrieveAETitle === pacs_name && + info.SeriesInstanceUID === SeriesInstanceUID && + pullState === SeriesPullState.READY, + ) !== -1, + [allSeries], + ); + + /** + * Whether we should send the pull request (where the pull request + * may be for either a DICOM study or series). + */ + const shouldSendPullRequest = React.useCallback( + (pullRequest: PacsPullRequestState): boolean => { + if (pullRequest.state !== RequestState.NOT_REQUESTED) { + return false; + } + if (!studies) { + return false; + } + if ( + pullRequest.query.studyInstanceUID && + !pullRequest.query.seriesInstanceUID + ) { + return shouldPullStudy( + pullRequest.service, + pullRequest.query.studyInstanceUID, + ); + } + if (pullRequest.query.seriesInstanceUID) { + return shouldPullSeries( + pullRequest.service, + pullRequest.query.seriesInstanceUID, + ); + } + return false; + }, + [studies], + ); + + /** + * Send request to PFDCM to pull from PACS. + */ + const pullFromPacs = useMutation({ + mutationFn: ({ service, query }: PacsPullRequestState) => + pfdcmClient.retrieve(service, query), + onMutate: ({ service, query }: PacsPullRequestState) => + updatePullRequestState(service, query, { + state: RequestState.REQUESTING, + }), + onError: (error, { service, query }) => + updatePullRequestState(service, query, { error: error }), + onSuccess: (_, { service, query }) => + updatePullRequestState(service, query, { state: RequestState.REQUESTED }), + }); + + React.useEffect(() => { + pullRequests + .filter(shouldSendPullRequest) + .forEach((pr) => pullFromPacs.mutate(pr)); + }, [pullRequests]); + // ======================================== // EFFECTS // ======================================== diff --git a/src/components/Pacs/components/SeriesList.tsx b/src/components/Pacs/components/SeriesList.tsx index 3dc1a540a..e1d6ff5e0 100644 --- a/src/components/Pacs/components/SeriesList.tsx +++ b/src/components/Pacs/components/SeriesList.tsx @@ -106,7 +106,10 @@ const SeriesRow: React.FC = ({
@@ -117,10 +120,12 @@ const SeriesRow: React.FC = ({ disabled={pullState !== SeriesPullState.READY} color={buttonColor} > - {isLoading || errors.length === 0 ? ( - - ) : ( + {errors.length > 1 ? ( + ) : isLoading ? ( + <> + ) : ( + )} diff --git a/src/components/Pacs/components/StudyButtons.tsx b/src/components/Pacs/components/StudyButtons.tsx index 3f00e0a72..75089573e 100644 --- a/src/components/Pacs/components/StudyButtons.tsx +++ b/src/components/Pacs/components/StudyButtons.tsx @@ -41,6 +41,7 @@ const StudyButtons: React.FC = ({ disabled={isPulled} onClick={onRetrieve} > + {/* FIXME CLICKING THIS BUTTON SHOULD SET EXPANDED STATE TO TRUE, INSTEAD OF TOGGLING EXPANDED STATE. */} {isLoading || } diff --git a/src/components/Pacs/joinStates.test.ts b/src/components/Pacs/joinStates.test.ts index d81e160b6..e57d0bab2 100644 --- a/src/components/Pacs/joinStates.test.ts +++ b/src/components/Pacs/joinStates.test.ts @@ -2,6 +2,7 @@ import { expect, test } from "vitest"; import { pullStateOf } from "./mergeStates.ts"; import { DEFAULT_RECEIVE_STATE, + RequestState, SeriesPullState, SeriesReceiveState, } from "./types.ts"; @@ -9,96 +10,140 @@ import { test.each(< [ SeriesReceiveState, + RequestState | undefined, { isLoading: boolean; data: any } | undefined, SeriesPullState, + string, ][] >[ - [DEFAULT_RECEIVE_STATE, undefined, SeriesPullState.NOT_CHECKED], + [ + DEFAULT_RECEIVE_STATE, + undefined, + undefined, + SeriesPullState.NOT_CHECKED, + "base case", + ], [ { subscribed: true, - requested: false, done: false, receivedCount: 0, errors: [], }, + undefined, + undefined, + SeriesPullState.NOT_CHECKED, + "subscribed, but have not checked whether series exists in CUBE", + ], + [ + { + subscribed: true, + done: false, + receivedCount: 0, + errors: [], + }, + undefined, { isLoading: true, data: undefined, }, SeriesPullState.CHECKING, + "pending check on series' existence in CUBE", ], [ { subscribed: false, - requested: false, done: false, receivedCount: 0, errors: [], }, + undefined, { isLoading: false, data: null, }, SeriesPullState.CHECKING, + "checked and does not exist in CUBE, but not yet subscribed", ], [ { subscribed: true, - requested: false, done: false, receivedCount: 0, errors: [], }, + undefined, { isLoading: false, data: null, }, SeriesPullState.READY, + "subscribed and ready to pull", ], [ { subscribed: true, - requested: true, done: false, receivedCount: 0, errors: [], }, + RequestState.NOT_REQUESTED, + { + isLoading: false, + data: null, + }, + SeriesPullState.PULLING, + "user clicked a button to retrieve", + ], + [ + { + subscribed: true, + done: false, + receivedCount: 20, + errors: [], + }, + RequestState.REQUESTED, { isLoading: false, data: null, }, SeriesPullState.PULLING, + "pulling, some files received", ], [ { subscribed: true, - requested: true, done: true, receivedCount: 10, errors: [], }, + RequestState.REQUESTED, { isLoading: false, data: null, }, SeriesPullState.WAITING_OR_COMPLETE, + "pulled, not yet registered by CUBE", ], [ { subscribed: true, - requested: true, done: true, receivedCount: 10, errors: [], }, + RequestState.REQUESTED, { isLoading: false, data: "some", }, SeriesPullState.WAITING_OR_COMPLETE, + "pulled and in CUBE", ], -])("pullStateOf(%o, %o) -> %o", (state, result, expected) => { - const actual = pullStateOf(state, result); - expect(actual).toStrictEqual(expected); -}); +])( + "pullStateOf(%o, %o, %o) -> %o // %s", + (state, pacsRequest, result, expected) => { + const actual = pullStateOf(state, pacsRequest, result); + expect(actual).toStrictEqual(expected); + }, +); diff --git a/src/components/Pacs/mergeStates.ts b/src/components/Pacs/mergeStates.ts index de63edd2c..1f1eace37 100644 --- a/src/components/Pacs/mergeStates.ts +++ b/src/components/Pacs/mergeStates.ts @@ -1,12 +1,14 @@ import { DEFAULT_RECEIVE_STATE, + PacsPullRequestState, PacsStudyState, ReceiveState, + RequestState, SeriesKey, SeriesPullState, SeriesReceiveState, } from "./types.ts"; -import { StudyAndSeries } from "../../api/pfdcm/models.ts"; +import { Series, StudyAndSeries } from "../../api/pfdcm/models.ts"; import { UseQueryResult } from "@tanstack/react-query"; import { PACSSeries } from "@fnndsc/chrisapi"; @@ -25,6 +27,7 @@ type SeriesQueryZip = { */ function mergeStates( pfdcm: ReadonlyArray, + pullRequests: ReadonlyArray, cubeSeriesQuery: ReadonlyArray, receiveState: ReceiveState, ): PacsStudyState[] { @@ -46,11 +49,18 @@ function mergeStates( const state = receiveState.get(info.RetrieveAETitle, info.SeriesInstanceUID) || DEFAULT_RECEIVE_STATE; + const pullRequestsForSeries = pullRequests.findLast((pr) => + isRequestFor(pr, info), + ); return { info, receivedCount: state.receivedCount, errors: state.errors.concat(cubeErrors), - pullState: pullStateOf(state, cubeQueryResult), + pullState: pullStateOf( + state, + pullRequestsForSeries?.state, + cubeQueryResult, + ), inCube: cubeQueryResult?.data || null, }; }), @@ -58,12 +68,35 @@ function mergeStates( }); } +/** + * @returns `true` if the query matches the series. + */ +function isRequestFor( + { query, service }: PacsPullRequestState, + series: Series, +): boolean { + if (service !== series.RetrieveAETitle) { + return false; + } + if (query.seriesInstanceUID) { + return query.seriesInstanceUID === series.SeriesInstanceUID; + } + if (query.studyInstanceUID) { + return query.studyInstanceUID === series.StudyInstanceUID; + } + if (query.accessionNumber) { + return query.accessionNumber === series.AccessionNumber; + } + return false; +} + /** * State coalescence for the "PACS Retrieve Workflow" described in the * tsdoc for {@link PacsController}. */ function pullStateOf( state: SeriesReceiveState, + pacsRequest?: RequestState, cubeQueryResult?: { isLoading: boolean; data: any }, ): SeriesPullState { if (!cubeQueryResult) { @@ -81,8 +114,18 @@ function pullStateOf( // finished receiving by oxidicom, waiting for CUBE to register return SeriesPullState.WAITING_OR_COMPLETE; } - // either pulling or ready to pull - return state.requested ? SeriesPullState.PULLING : SeriesPullState.READY; + if (state.receivedCount > 0) { + // DICOM series is being received, even though it wasn't requested. + // It was probably requested in another window, or by another user, + // or pushed to us without a MOVE-SCU request. + return SeriesPullState.PULLING; + } + if (pacsRequest === undefined) { + // Series is not requested nor pulled and ready to be pulled. + return SeriesPullState.READY; + } + // Request to retrieve was sent to PFDCM, but no files received yet. + return SeriesPullState.PULLING; } // checked, series DOES exist in CUBE. It is complete. return SeriesPullState.WAITING_OR_COMPLETE; diff --git a/src/components/Pacs/types.ts b/src/components/Pacs/types.ts index e3b7f5289..79dadafe4 100644 --- a/src/components/Pacs/types.ts +++ b/src/components/Pacs/types.ts @@ -1,6 +1,7 @@ import { Series, Study } from "../../api/pfdcm/models.ts"; import { PACSSeries } from "@fnndsc/chrisapi"; import SeriesMap from "../../api/lonk/seriesMap.ts"; +import { PACSqueryCore } from "../../api/pfdcm"; type StudyKey = { pacs_name: string; @@ -38,6 +39,25 @@ enum SeriesPullState { WAITING_OR_COMPLETE, } +/** + * The states a request can be in. + */ +enum RequestState { + NOT_REQUESTED, + REQUESTING, + REQUESTED, +} + +/** + * The state of a PACS pull request. + */ +type PacsPullRequestState = { + state: RequestState; + error?: Error; + query: PACSqueryCore; + service: string; +}; + /** * The state of a DICOM series retrieval. */ @@ -46,10 +66,6 @@ type SeriesReceiveState = { * Whether this series has been subscribed to via LONK. */ subscribed: boolean; - /** - * Whether this series has been requested by PFDCM. - */ - requested: boolean; /** * Whether this series was reported as "done" by LONK. */ @@ -66,7 +82,6 @@ type SeriesReceiveState = { const DEFAULT_RECEIVE_STATE: SeriesReceiveState = { subscribed: false, - requested: false, done: false, receivedCount: 0, errors: [], @@ -82,9 +97,11 @@ type ReceiveState = SeriesMap; /** * The combined state of a DICOM series in PFDCM, CUBE, and LONK. */ -type PacsSeriesState = Pick & { +type PacsSeriesState = Pick & { + errors: ReadonlyArray; info: Series; inCube: PACSSeries | null; + pullState: SeriesPullState; }; /** @@ -118,7 +135,7 @@ interface IPacsState { studies: PacsStudyState[] | null; } -export { SeriesPullState, DEFAULT_RECEIVE_STATE }; +export { SeriesPullState, RequestState, DEFAULT_RECEIVE_STATE }; export type { StudyKey, SeriesKey, @@ -128,4 +145,5 @@ export type { PacsSeriesState, PacsStudyState, PacsPreferences, + PacsPullRequestState, }; diff --git a/src/main.tsx b/src/main.tsx index 124f67825..c9cbca60a 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -4,7 +4,9 @@ import App from "./App.tsx"; import { setupStore } from "./store/configureStore.ts"; import { ThemeContextProvider } from "./components/DarkTheme/useTheme.tsx"; import "@fontsource/inter/400.css"; // Defaults to weight 400. +import { enableMapSet } from "immer"; +enableMapSet(); const store = setupStore(); ReactDOM.createRoot(document.getElementById("root")!).render( diff --git a/tsconfig.app.json b/tsconfig.app.json index 1540cf3c9..c518cdc04 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -1,8 +1,8 @@ { "compilerOptions": { - "target": "ES2020", + "target": "ES2023", "useDefineForClassFields": true, - "lib": ["ES2020", "DOM", "DOM.Iterable"], + "lib": ["ES2023", "DOM", "DOM.Iterable"], "module": "ESNext", "skipLibCheck": true, diff --git a/tsconfig.json b/tsconfig.json index ef4a75c57..be2f44a7b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,8 +1,8 @@ { "compilerOptions": { - "target": "ES2021", + "target": "ES2023", "useDefineForClassFields": true, - "lib": ["ES2021", "DOM", "DOM.Iterable"], + "lib": ["ES2023", "DOM", "DOM.Iterable"], "module": "ESNext", "skipLibCheck": true, From 76c5a750350ae2f425347104c71a85c5a4ab6a6d Mon Sep 17 00:00:00 2001 From: Jennings Zhang Date: Wed, 25 Sep 2024 15:48:56 -0400 Subject: [PATCH 23/41] Pull study works, except for the last part. --- src/api/lonk/seriesMap.ts | 11 ++ src/components/Pacs/PacsController.tsx | 169 ++++++++++++++---- src/components/Pacs/PacsView.tsx | 25 +-- .../Pacs/components/PacsStudiesView.tsx | 6 + src/components/Pacs/components/SeriesList.tsx | 21 ++- .../Pacs/components/StudyButtons.tsx | 16 +- src/components/Pacs/curry.ts | 26 +++ src/components/Pacs/helpers.ts | 13 +- testing/miniChRIS | 2 +- 9 files changed, 232 insertions(+), 57 deletions(-) create mode 100644 src/components/Pacs/curry.ts diff --git a/src/api/lonk/seriesMap.ts b/src/api/lonk/seriesMap.ts index 7aa559003..55a2b0e79 100644 --- a/src/api/lonk/seriesMap.ts +++ b/src/api/lonk/seriesMap.ts @@ -41,6 +41,17 @@ class SeriesMap { private keyOf(pacs_name: string, SeriesInstanceUID: string): string { return JSON.stringify({ SeriesInstanceUID, pacs_name }); } + + /** + * Get the entries `[pacs_name, SeriesInstanceUID, value]` + */ + public entries(): [string, string, T][] { + // when we upgrade to TS 5.9, use Iterator.map instead of Array.map + return Array.from(this.map.entries()).map(([key, value]) => { + const { pacs_name, SeriesInstanceUID } = JSON.parse(key); + return [pacs_name, SeriesInstanceUID, value]; + }); + } } export default SeriesMap; diff --git a/src/components/Pacs/PacsController.tsx b/src/components/Pacs/PacsController.tsx index e9ff6110b..f408eb4a9 100644 --- a/src/components/Pacs/PacsController.tsx +++ b/src/components/Pacs/PacsController.tsx @@ -30,11 +30,17 @@ import { StudyKey, } from "./types.ts"; import { DEFAULT_PREFERENCES } from "./defaultPreferences.ts"; -import { zipPacsNameAndSeriesUids } from "./helpers.ts"; +import { toStudyKey, zipPacsNameAndSeriesUids } from "./helpers.ts"; import { useImmer } from "use-immer"; import SeriesMap from "../../api/lonk/seriesMap.ts"; import { useLonk } from "../../api/lonk"; import { produce, WritableDraft } from "immer"; +import { + isFromPacs, + sameSeriesInstanceUidAs, + sameStudyInstanceUidAs, +} from "./curry.ts"; +import { Study } from "../../api/pfdcm/models.ts"; type PacsControllerProps = { getPfdcmClient: () => PfdcmClient; @@ -101,29 +107,19 @@ const PacsController: React.FC = ({ // STATE // ======================================== - const [{ service, query }, setPacsQuery] = React.useState<{ - service?: string; - query?: PACSqueryCore; - }>({}); - /** * Indicates a fatal error with the WebSocket. */ const [wsError, setWsError] = React.useState(null); // TODO create a settings component for changing preferences const [preferences, setPreferences] = React.useState(DEFAULT_PREFERENCES); + /** * The state of DICOM series, according to LONK. */ const [receiveState, setReceiveState] = useImmer( new SeriesMap(), ); - /** - * Studies which have their series visible on-screen. - */ - const [expandedStudies, setExpandedStudies] = React.useState< - ReadonlyArray - >([]); /** * List of PACS queries which the user wants to pull. @@ -164,9 +160,14 @@ const PacsController: React.FC = ({ ); // ======================================== - // QUERIES AND DATA + // PFDCM QUERIES AND DATA // ======================================== + const [{ service, query }, setPacsQuery] = React.useState<{ + service?: string; + query?: PACSqueryCore; + }>({}); + /** * List of PACS servers which PFDCM can talk to. */ @@ -186,6 +187,26 @@ const PacsController: React.FC = ({ queryFn: service && query ? () => pfdcmClient.query(service, query) : skipToken, }); + + // ======================================== + // EXPANDED STUDIES AND SERIES STATE + // ======================================== + + /** + * Studies which have their series visible on-screen. + */ + const [expandedStudies, setExpandedStudies] = useImmer< + ReadonlyArray + >([]); + + /** + * The StudyInstanceUIDs of all expanded studies. + */ + const expandedStudyUids = React.useMemo( + () => expandedStudies.map((s) => s.StudyInstanceUID), + [expandedStudies], + ); + /** * List of series which are currently visible on-screen. */ @@ -194,6 +215,61 @@ const PacsController: React.FC = ({ [expandedStudies, pfdcmStudies.data], ); + const changeExpandedStudies = React.useCallback( + (pacs_name: string, StudyInstanceUIDs: ReadonlyArray) => { + setExpandedStudies( + StudyInstanceUIDs.map((StudyInstanceUID) => ({ + StudyInstanceUID, + pacs_name, + })), + ); + }, + [setExpandedStudies], + ); + + const appendExpandedStudies = React.useCallback( + (studies: Pick[]) => + setExpandedStudies((draft) => { + draft.push(...studies.map(toStudyKey)); + }), + [setExpandedStudies], + ); + + /** + * Expand the studies of the query. + */ + const expandStudiesFor = React.useCallback( + (pacs_name: string, query: PACSqueryCore) => { + if (!pfdcmStudies.data) { + throw new Error( + "Expanding studies is not currently possible because we do not " + + "have data from PFDCM yet.", + ); + } + if (query.seriesInstanceUID) { + const studies = pfdcmStudies.data + .filter(isFromPacs(pacs_name)) + .flatMap((study) => study.series) + .filter(sameSeriesInstanceUidAs(query)); + appendExpandedStudies(studies); + return; + } + if (!query.seriesInstanceUID && query.studyInstanceUID) { + const studies = pfdcmStudies.data + .filter(isFromPacs(pacs_name)) + .map((s) => s.study) + .filter(sameStudyInstanceUidAs(query)); + appendExpandedStudies(studies); + return; + } + }, + [pfdcmStudies.data, appendExpandedStudies], + ); + + // ======================================== + // CUBE QUERIES AND DATA + // ======================================== + /** * Check whether CUBE has any of the series that are expanded. */ @@ -227,6 +303,37 @@ const PacsController: React.FC = ({ })), [expandedSeries, cubeSeriesQuery], ); + // + // /** + // * Poll CUBE for the existence of DICOM series which have been reported as + // * "done" by LONK. It is necessary to poll CUBE because there will be a delay + // * between when LONK reports the series as "done" and when CUBE will run the + // * celery task of finally registering the series. + // */ + // const finalCheckNeedingSeries = useQueries({ + // queries: React.useMemo( + // () => + // receiveState.entries().map(([pacs_name, SeriesInstanceUID, state]) => ({ + // queryKey: [ + // "finalCheckNeedingSeries", + // pacs_name, + // SeriesInstanceUID, + // state, + // ], + // queryFn: async () => { + // const search = { pacs_name, SeriesInstanceUID, limit: 1 }; + // const list = await chrisClient.getPACSSeriesList(search); + // const items = list.getItems() as ReadonlyArray; + // if (items.length === 0) { + // throw Error("not found"); + // } + // return items[0]; + // }, + // enabled: state.done, // TODO CANCEL POLLING ONCE WE FOUND THE FILE. + // })), + // [receiveState], + // ), + // }); /** * Combined states of PFDCM, LONK, and CUBE into one object. @@ -356,24 +463,13 @@ const PacsController: React.FC = ({ [setPacsQuery], ); - const onStudyExpand = React.useCallback( - (pacs_name: string, StudyInstanceUIDs: ReadonlyArray) => { - setExpandedStudies( - StudyInstanceUIDs.map((StudyInstanceUID) => ({ - StudyInstanceUID, - pacs_name, - })), - ); - }, - [setExpandedStudies], - ); - // ======================================== // PACS RETRIEVAL // ======================================== const onRetrieve = React.useCallback( (service: string, query: PACSqueryCore) => { + expandStudiesFor(service, query); setPullRequests((draft) => { // indicate that the user requests for something to be retrieved. draft.push({ @@ -383,7 +479,7 @@ const PacsController: React.FC = ({ }); }); }, - [setPullRequests], + [setPullRequests, expandStudiesFor], ); /** @@ -437,12 +533,9 @@ const PacsController: React.FC = ({ if (pullRequest.state !== RequestState.NOT_REQUESTED) { return false; } - if (!studies) { - return false; - } if ( pullRequest.query.studyInstanceUID && - !pullRequest.query.seriesInstanceUID + !("seriesInstanceUID" in pullRequest.query) ) { return shouldPullStudy( pullRequest.service, @@ -457,7 +550,7 @@ const PacsController: React.FC = ({ } return false; }, - [studies], + [shouldPullStudy, shouldPullSeries], ); /** @@ -474,13 +567,22 @@ const PacsController: React.FC = ({ updatePullRequestState(service, query, { error: error }), onSuccess: (_, { service, query }) => updatePullRequestState(service, query, { state: RequestState.REQUESTED }), + onSettled: (data, error, variables, context) => { + console.dir({ + event: "settled", + data, + error, + variables, + context, + }); + }, }); React.useEffect(() => { pullRequests .filter(shouldSendPullRequest) .forEach((pr) => pullFromPacs.mutate(pr)); - }, [pullRequests]); + }, [pullRequests, shouldSendPullRequest]); // ======================================== // EFFECTS @@ -524,7 +626,8 @@ const PacsController: React.FC = ({ services={pfdcmServices.data} onSubmit={onSubmit} onRetrieve={onRetrieve} - onStudyExpand={onStudyExpand} + expandedStudyUids={expandedStudyUids} + onStudyExpand={changeExpandedStudies} isLoadingStudies={pfdcmStudies.isLoading} /> ) : ( diff --git a/src/components/Pacs/PacsView.tsx b/src/components/Pacs/PacsView.tsx index 0912673bb..046f8f1c1 100644 --- a/src/components/Pacs/PacsView.tsx +++ b/src/components/Pacs/PacsView.tsx @@ -1,21 +1,24 @@ import React from "react"; import PacsInput, { PacsInputProps } from "./components/PacsInput.tsx"; -import PacsStudiesView from "./components/PacsStudiesView.tsx"; +import PacsStudiesView, { + PacsStudiesViewProps, +} from "./components/PacsStudiesView.tsx"; import { getDefaultPacsService } from "./components/helpers.ts"; import { useSearchParams } from "react-router-dom"; import { PACSqueryCore } from "../../api/pfdcm"; import { Empty, Flex, Spin } from "antd"; import { IPacsState } from "./types.ts"; -type PacsViewProps = Pick & { - onRetrieve: (service: string, query: PACSqueryCore) => void; - onStudyExpand: ( - service: string, - StudyInstanceUIDs: ReadonlyArray, - ) => void; - state: IPacsState; - isLoadingStudies?: boolean; -}; +type PacsViewProps = Pick & + Pick & { + onRetrieve: (service: string, query: PACSqueryCore) => void; + onStudyExpand: ( + service: string, + StudyInstanceUIDs: ReadonlyArray, + ) => void; + state: IPacsState; + isLoadingStudies?: boolean; + }; /** * PACS Query and Retrieve view component. @@ -28,6 +31,7 @@ const PacsView: React.FC = ({ services, onSubmit, onRetrieve, + expandedStudyUids, onStudyExpand, isLoadingStudies, }) => { @@ -69,6 +73,7 @@ const PacsView: React.FC = ({ preferences={preferences} studies={studies} onRetrieve={curriedOnRetrieve} + expandedStudyUids={expandedStudyUids} onStudyExpand={curriedOnStudyExpand} /> diff --git a/src/components/Pacs/components/PacsStudiesView.tsx b/src/components/Pacs/components/PacsStudiesView.tsx index 501fb9fa9..5c4e929ca 100644 --- a/src/components/Pacs/components/PacsStudiesView.tsx +++ b/src/components/Pacs/components/PacsStudiesView.tsx @@ -10,12 +10,17 @@ type PacsStudiesViewProps = { preferences: PacsPreferences; studies: PacsStudyState[]; onRetrieve: (query: PACSqueryCore) => void; + /** + * List of StudyInstanceUIDs which should be expanded. + */ + expandedStudyUids?: string[]; onStudyExpand?: (StudyInstanceUIDs: ReadonlyArray) => void; }; const PacsStudiesView: React.FC = ({ studies, onRetrieve, + expandedStudyUids, onStudyExpand, preferences, }) => { @@ -66,6 +71,7 @@ const PacsStudiesView: React.FC = ({ studies.length === 1 ? [studies[0].info.StudyInstanceUID] : [] } onChange={onChange} + activeKey={expandedStudyUids} /> {numPatients === 1 ? "1 patient, " : `${numPatients} patients, `} diff --git a/src/components/Pacs/components/SeriesList.tsx b/src/components/Pacs/components/SeriesList.tsx index e1d6ff5e0..16d73b019 100644 --- a/src/components/Pacs/components/SeriesList.tsx +++ b/src/components/Pacs/components/SeriesList.tsx @@ -78,6 +78,18 @@ const SeriesRow: React.FC = ({ return "default"; }, [errors, pullState]); + const percentDone = React.useMemo(() => { + if (inCube) { + return 100; + } + if (pullState === SeriesPullState.WAITING_OR_COMPLETE) { + return 99; + } + return ( + (99 * receivedCount) / (info.NumberOfSeriesRelatedInstances || Infinity) + ); + }, [inCube, pullState, receivedCount, info.NumberOfSeriesRelatedInstances]); + return ( = ({
`${Math.round(n ?? 0)}%`} + percent={percentDone} + status={errors.length > 0 ? "exception" : undefined} />
diff --git a/src/components/Pacs/components/StudyButtons.tsx b/src/components/Pacs/components/StudyButtons.tsx index 75089573e..91d4500d7 100644 --- a/src/components/Pacs/components/StudyButtons.tsx +++ b/src/components/Pacs/components/StudyButtons.tsx @@ -16,12 +16,12 @@ const StudyButtons: React.FC = ({ tooltipPlacement = "left", onRetrieve, }) => ( - // TODO add "Create feed" button + // NOTE: buttons should call event.stopPropagation() Checking availability... + <>Working… ) : isPulled ? ( <> This study is already pulled in ChRIS. @@ -39,15 +39,21 @@ const StudyButtons: React.FC = ({ type="primary" loading={isLoading} disabled={isPulled} - onClick={onRetrieve} + onClick={(event) => { + event.stopPropagation(); + onRetrieve && onRetrieve(); + }} > - {/* FIXME CLICKING THIS BUTTON SHOULD SET EXPANDED STATE TO TRUE, INSTEAD OF TOGGLING EXPANDED STATE. */} {isLoading || } {ohifUrl && ( - diff --git a/src/components/Pacs/curry.ts b/src/components/Pacs/curry.ts new file mode 100644 index 000000000..2246c1ee3 --- /dev/null +++ b/src/components/Pacs/curry.ts @@ -0,0 +1,26 @@ +/** + * Some trivial curried functions for making array map/filter code more legible. + */ + +import { Series, Study } from "../../api/pfdcm/models.ts"; +import { PACSqueryCore } from "../../api/pfdcm"; + +function isFromPacs( + pacs_name: string, +): (s: { study: Pick }) => boolean { + return (s) => s.study.RetrieveAETitle === pacs_name; +} + +function sameSeriesInstanceUidAs( + query: Pick, +): (s: Pick) => boolean { + return (s) => s.SeriesInstanceUID === query.seriesInstanceUID; +} + +function sameStudyInstanceUidAs( + query: Pick, +): (s: Pick) => boolean { + return (s) => s.StudyInstanceUID === query.studyInstanceUID; +} + +export { isFromPacs, sameSeriesInstanceUidAs, sameStudyInstanceUidAs }; diff --git a/src/components/Pacs/helpers.ts b/src/components/Pacs/helpers.ts index 2d4051c3a..91915f268 100644 --- a/src/components/Pacs/helpers.ts +++ b/src/components/Pacs/helpers.ts @@ -1,5 +1,5 @@ import { SeriesKey, StudyKey } from "./types.ts"; -import { StudyAndSeries } from "../../api/pfdcm/models.ts"; +import { Study, StudyAndSeries } from "../../api/pfdcm/models.ts"; /** * A type subset of {@link StudyAndSeries}. @@ -43,5 +43,14 @@ function zipPacsNameAndSeriesUids( ); } +function toStudyKey( + s: Pick, +): StudyKey { + return { + StudyInstanceUID: s.StudyInstanceUID, + pacs_name: s.RetrieveAETitle, + }; +} + export type { StudyAndSeriesUidOnly }; -export { zipPacsNameAndSeriesUids }; +export { zipPacsNameAndSeriesUids, toStudyKey }; diff --git a/testing/miniChRIS b/testing/miniChRIS index a70318e5c..d5d2a28ad 160000 --- a/testing/miniChRIS +++ b/testing/miniChRIS @@ -1 +1 @@ -Subproject commit a70318e5c6c6ce3d5e9bba82302ba67d490bd0f8 +Subproject commit d5d2a28adc09e5a42df96c4e5fcd198f63c90260 From ad7fba4ff2701faec3f5c19435eb870d3c4b0a23 Mon Sep 17 00:00:00 2001 From: Jennings Zhang Date: Wed, 25 Sep 2024 23:38:04 -0400 Subject: [PATCH 24/41] Everything working! I think... --- src/api/lonk/seriesMap.ts | 8 +- src/components/Pacs/PacsController.tsx | 134 ++++++++++-------- .../Pacs/components/PacsStudiesView.test.tsx | 0 .../Pacs/components/PacsStudiesView.tsx | 2 +- ...joinStates.test.ts => mergeStates.test.ts} | 41 +++++- src/components/Pacs/mergeStates.ts | 76 +++++++--- src/components/Pacs/types.ts | 20 ++- testing/miniChRIS | 2 +- 8 files changed, 193 insertions(+), 90 deletions(-) create mode 100644 src/components/Pacs/components/PacsStudiesView.test.tsx rename src/components/Pacs/{joinStates.test.ts => mergeStates.test.ts} (69%) diff --git a/src/api/lonk/seriesMap.ts b/src/api/lonk/seriesMap.ts index 55a2b0e79..da00743d0 100644 --- a/src/api/lonk/seriesMap.ts +++ b/src/api/lonk/seriesMap.ts @@ -16,7 +16,7 @@ class SeriesMap { * Get a value for a DICOM series. */ public get(pacs_name: string, SeriesInstanceUID: string): T | undefined { - const key = this.keyOf(SeriesInstanceUID, pacs_name); + const key = this.keyOf(pacs_name, SeriesInstanceUID); return this.map.get(key); } @@ -24,7 +24,7 @@ class SeriesMap { * Set a value for a DICOM series. */ public set(pacs_name: string, SeriesInstanceUID: string, value: T) { - const key = this.keyOf(SeriesInstanceUID, pacs_name); + const key = this.keyOf(pacs_name, SeriesInstanceUID); this.map.set(key, value); } @@ -32,14 +32,14 @@ class SeriesMap { * Get and remove a value for a DICOM series. */ public pop(pacs_name: string, SeriesInstanceUID: string): T | null { - const key = this.keyOf(SeriesInstanceUID, pacs_name); + const key = this.keyOf(pacs_name, SeriesInstanceUID); const value = this.map.get(key); this.map.delete(key); return value || null; } private keyOf(pacs_name: string, SeriesInstanceUID: string): string { - return JSON.stringify({ SeriesInstanceUID, pacs_name }); + return JSON.stringify({ pacs_name, SeriesInstanceUID }); } /** diff --git a/src/components/Pacs/PacsController.tsx b/src/components/Pacs/PacsController.tsx index f408eb4a9..33c77f4fb 100644 --- a/src/components/Pacs/PacsController.tsx +++ b/src/components/Pacs/PacsController.tsx @@ -18,13 +18,14 @@ import { useQueries, useQuery, } from "@tanstack/react-query"; -import mergeStates, { SeriesQueryZip } from "./mergeStates.ts"; +import { mergeStates, createCubeSeriesQueryUidMap } from "./mergeStates.ts"; import { DEFAULT_RECEIVE_STATE, IPacsState, PacsPullRequestState, ReceiveState, RequestState, + SeriesNotRegisteredError, SeriesPullState, SeriesReceiveState, StudyKey, @@ -270,10 +271,18 @@ const PacsController: React.FC = ({ // CUBE QUERIES AND DATA // ======================================== + // We have two instances of `useQueries` for checking whether DICOM series + // exists in CUBE: + // + // `cubeSeriesQueries`: initial check for existence when series is expanded, + // runs once. + // `lastCheckQueries`: final check for existence when series is done being + // received by oxidicom, polls repeatedly until found. + /** * Check whether CUBE has any of the series that are expanded. */ - const cubeSeriesQuery = useQueries({ + const cubeSeriesQueries = useQueries({ queries: expandedSeries.map((series) => ({ queryKey: ["cubeSeries", chrisClient.url, series], queryFn: async () => { @@ -281,59 +290,69 @@ const PacsController: React.FC = ({ limit: 1, ...series, }); - const items: PACSSeries[] | null = list.getItems(); + const items = list.getItems() as PACSSeries[]; // https://github.com/FNNDSC/fnndsc/issues/101 - if (items === null) { - return null; - } - return items[0] || null; + return items[0] ?? null; }, })), }); /** - * Zip together elements of `cubeSeriesQuery` and the parameters used for - * each element. + * Poll CUBE for the existence of DICOM series which have been reported as + * "done" by LONK. It is necessary to poll CUBE because there will be a delay + * between when LONK reports the series as "done" and when CUBE will run the + * celery task of finally registering the series. */ - const cubeSeriesQueryZip: ReadonlyArray = React.useMemo( - () => - expandedSeries.map((search, i) => ({ - search, - result: cubeSeriesQuery[i], - })), - [expandedSeries, cubeSeriesQuery], - ); - // - // /** - // * Poll CUBE for the existence of DICOM series which have been reported as - // * "done" by LONK. It is necessary to poll CUBE because there will be a delay - // * between when LONK reports the series as "done" and when CUBE will run the - // * celery task of finally registering the series. - // */ - // const finalCheckNeedingSeries = useQueries({ - // queries: React.useMemo( - // () => - // receiveState.entries().map(([pacs_name, SeriesInstanceUID, state]) => ({ - // queryKey: [ - // "finalCheckNeedingSeries", - // pacs_name, - // SeriesInstanceUID, - // state, - // ], - // queryFn: async () => { - // const search = { pacs_name, SeriesInstanceUID, limit: 1 }; - // const list = await chrisClient.getPACSSeriesList(search); - // const items = list.getItems() as ReadonlyArray; - // if (items.length === 0) { - // throw Error("not found"); - // } - // return items[0]; - // }, - // enabled: state.done, // TODO CANCEL POLLING ONCE WE FOUND THE FILE. - // })), - // [receiveState], - // ), - // }); + const lastCheckQueries = useQueries({ + queries: React.useMemo( + () => + receiveState.entries().map(([pacs_name, SeriesInstanceUID, state]) => ({ + queryKey: [ + "lastCheckCubeSeriesRegistration", + pacs_name, + SeriesInstanceUID, + ], + queryFn: async () => { + const search = { pacs_name, SeriesInstanceUID, limit: 1 }; + const list = await chrisClient.getPACSSeriesList(search); + const items = list.getItems() as ReadonlyArray; + if (items.length === 0) { + throw new SeriesNotRegisteredError(pacs_name, SeriesInstanceUID); + } + return items[0]; + }, + enabled: state.done, + retry: 300, + retryDelay: 2000, // TODO use environment variable + })), + [receiveState], + ), + }); + + /** + * Map for all the CUBE queries for PACSSeries existence. + */ + const allCubeSeriesQueryMap = React.useMemo(() => { + const lastCheckParams = receiveState + .entries() + .map(([pacs_name, SeriesInstanceUID]) => ({ + pacs_name, + SeriesInstanceUID, + })); + const lastCheckQueriesMap = createCubeSeriesQueryUidMap( + lastCheckParams, + lastCheckQueries, + ); + const firstCheckQueriesMap = createCubeSeriesQueryUidMap( + expandedSeries, + cubeSeriesQueries, + ); + return new Map([...firstCheckQueriesMap, ...lastCheckQueriesMap]); + }, [receiveState, lastCheckQueries, expandedSeries, cubeSeriesQueries]); + + // ======================================== + // COMBINED STATE OF EVERYTHING + // ======================================== /** * Combined states of PFDCM, LONK, and CUBE into one object. @@ -345,10 +364,16 @@ const PacsController: React.FC = ({ return mergeStates( pfdcmStudies.data, pullRequests, - cubeSeriesQueryZip, + allCubeSeriesQueryMap, receiveState, ); - }, [mergeStates, pfdcmStudies, cubeSeriesQueryZip, receiveState]); + }, [ + mergeStates, + pfdcmStudies.data, + pullRequests, + allCubeSeriesQueryMap, + receiveState, + ]); /** * Entire state of the Pacs Q/R application. @@ -567,15 +592,6 @@ const PacsController: React.FC = ({ updatePullRequestState(service, query, { error: error }), onSuccess: (_, { service, query }) => updatePullRequestState(service, query, { state: RequestState.REQUESTED }), - onSettled: (data, error, variables, context) => { - console.dir({ - event: "settled", - data, - error, - variables, - context, - }); - }, }); React.useEffect(() => { diff --git a/src/components/Pacs/components/PacsStudiesView.test.tsx b/src/components/Pacs/components/PacsStudiesView.test.tsx new file mode 100644 index 000000000..e69de29bb diff --git a/src/components/Pacs/components/PacsStudiesView.tsx b/src/components/Pacs/components/PacsStudiesView.tsx index 5c4e929ca..8d81a12d6 100644 --- a/src/components/Pacs/components/PacsStudiesView.tsx +++ b/src/components/Pacs/components/PacsStudiesView.tsx @@ -34,7 +34,7 @@ const PacsStudiesView: React.FC = ({ isPulled={ series.length === 0 ? true - : !series.find(({ inCube }) => inCube === null) + : series.every(({ inCube }) => inCube !== null) } isLoading={ series.length === 0 ? false : !!series.find(isSeriesLoading) diff --git a/src/components/Pacs/joinStates.test.ts b/src/components/Pacs/mergeStates.test.ts similarity index 69% rename from src/components/Pacs/joinStates.test.ts rename to src/components/Pacs/mergeStates.test.ts index e57d0bab2..c07afa1a9 100644 --- a/src/components/Pacs/joinStates.test.ts +++ b/src/components/Pacs/mergeStates.test.ts @@ -1,8 +1,14 @@ import { expect, test } from "vitest"; -import { pullStateOf } from "./mergeStates.ts"; +import { + createCubeSeriesQueryUidMap, + pullStateOf, + UseQueryResultLike, +} from "./mergeStates.ts"; import { DEFAULT_RECEIVE_STATE, RequestState, + SeriesKey, + SeriesNotRegisteredError, SeriesPullState, SeriesReceiveState, } from "./types.ts"; @@ -147,3 +153,36 @@ test.each(< expect(actual).toStrictEqual(expected); }, ); + +test("createCubeSeriesQueryUidMap", () => { + const params: ReadonlyArray = [ + { pacs_name: "MyPACS", SeriesInstanceUID: "1.2.345" }, + { pacs_name: "MyPACS", SeriesInstanceUID: "1.2.678" }, + { pacs_name: "MyPACS", SeriesInstanceUID: "1.2.910" }, + { pacs_name: "MyPACS", SeriesInstanceUID: "1.2.123" }, + ]; + const queries: ReadonlyArray = [ + { + isError: false, + error: null, + }, + { + isError: true, + error: new Error("i am some other error"), + }, + { + isError: true, + error: new SeriesNotRegisteredError("MyPACS", "1.2.910"), + }, + { + isError: false, + error: null, + }, + ]; + const actual = createCubeSeriesQueryUidMap(params, queries); + expect(actual.get(params[0].SeriesInstanceUID)).toBe(queries[0]); + expect(actual.get(params[1].SeriesInstanceUID)).toBe(queries[1]); + expect(actual.get(params[3].SeriesInstanceUID)).toBe(queries[3]); + expect(actual.size).toBe(3); + expect(actual.values()).not.toContain(queries[2]); +}); diff --git a/src/components/Pacs/mergeStates.ts b/src/components/Pacs/mergeStates.ts index 1f1eace37..ddfbef958 100644 --- a/src/components/Pacs/mergeStates.ts +++ b/src/components/Pacs/mergeStates.ts @@ -5,49 +5,46 @@ import { ReceiveState, RequestState, SeriesKey, + SeriesNotRegisteredError, SeriesPullState, SeriesReceiveState, } from "./types.ts"; import { Series, StudyAndSeries } from "../../api/pfdcm/models.ts"; -import { UseQueryResult } from "@tanstack/react-query"; +import { useQuery, UseQueryResult } from "@tanstack/react-query"; import { PACSSeries } from "@fnndsc/chrisapi"; -type PACSSeriesQueryResult = UseQueryResult; - -type SeriesQueryZip = { - search: SeriesKey; - result: PACSSeriesQueryResult; -}; +type UseQueryResultLike = Partial< + Pick +>; /** * Fragments of the state of a DICOM series exists remotely in three places: * PFDCM, CUBE, and LONK. * * Merge the states from those places into one mega-object. + * + * @param pfdcm PACS query response from PFDCM + * @param pullRequests state of the PACS pull requests to PFDCM + * @param cubeQueryMap mapping of `SeriesInstanceUID` to queries for respective + * {@link PACSSeries} in CUBE (hint: call + * {@link createCubeSeriesQueryUidMap}) + * @param receiveState state of DICOM receive operation conveyed via LONK */ function mergeStates( pfdcm: ReadonlyArray, pullRequests: ReadonlyArray, - cubeSeriesQuery: ReadonlyArray, + cubeQueryMap: Map>, receiveState: ReceiveState, ): PacsStudyState[] { - const cubeSeriesMap = new Map( - cubeSeriesQuery.map(({ search, result }) => [ - search.SeriesInstanceUID, - result, - ]), - ); return pfdcm.map(({ study, series }) => { return { info: study, series: series.map((info) => { - const cubeQueryResult = cubeSeriesMap.get(info.SeriesInstanceUID); - const cubeErrors = - cubeQueryResult && cubeQueryResult.error - ? [cubeQueryResult.error.message] - : []; + const cubeQueryResult = cubeQueryMap.get(info.SeriesInstanceUID); + const cubeError = cubeQueryResult?.error; + const cubeErrors = cubeError ? [cubeError.message] : []; const state = - receiveState.get(info.RetrieveAETitle, info.SeriesInstanceUID) || + receiveState.get(info.RetrieveAETitle, info.SeriesInstanceUID) ?? DEFAULT_RECEIVE_STATE; const pullRequestsForSeries = pullRequests.findLast((pr) => isRequestFor(pr, info), @@ -68,6 +65,40 @@ function mergeStates( }); } +/** + * Reshape queries and the parameters used for each query to a map + * where the key is `SeriesInstanceUID`. + * + * Also removes entries where the query is pending or the error is + * {@link SeriesNotRegisteredError}. + * + * @param params {@link useQuery} parameters + * @param queries the React "hook" returned by {@link useQuery} + */ +function createCubeSeriesQueryUidMap( + params: ReadonlyArray, + queries: ReadonlyArray, +): Map { + const entries = zipArray(params, queries) + .filter(([_, query]) => !query.isPending && !isNotRegisteredError(query)) + .map(([p, q]): [string, T] => [p.SeriesInstanceUID, q]); + return new Map(entries); +} + +function zipArray( + a: ReadonlyArray, + b: ReadonlyArray, +): ReadonlyArray<[A, B]> { + if (a.length !== b.length) { + throw new Error(`Array lengths are different (${a.length} != ${b.length})`); + } + return a.map((item, i) => [item, b[i]]); +} + +function isNotRegisteredError(q: UseQueryResultLike): boolean { + return Boolean(q.isError) && q.error instanceof SeriesNotRegisteredError; +} + /** * @returns `true` if the query matches the series. */ @@ -131,6 +162,5 @@ function pullStateOf( return SeriesPullState.WAITING_OR_COMPLETE; } -export type { SeriesQueryZip }; -export { pullStateOf }; -export default mergeStates; +export type { UseQueryResultLike }; +export { mergeStates, pullStateOf, createCubeSeriesQueryUidMap }; diff --git a/src/components/Pacs/types.ts b/src/components/Pacs/types.ts index 79dadafe4..1a39e010a 100644 --- a/src/components/Pacs/types.ts +++ b/src/components/Pacs/types.ts @@ -13,6 +13,19 @@ type SeriesKey = { SeriesInstanceUID: string; }; +/** + * Indicates DICOM series has not yet been registered by CUBE. + */ +class SeriesNotRegisteredError extends Error { + public readonly pacs_name: string; + public readonly SeriesInstanceUID: string; + public constructor(pacs_name: string, SeriesInstanceUID: string) { + super(); + this.pacs_name = pacs_name; + this.SeriesInstanceUID = SeriesInstanceUID; + } +} + /** * The states which a DICOM series can be in. */ @@ -135,7 +148,12 @@ interface IPacsState { studies: PacsStudyState[] | null; } -export { SeriesPullState, RequestState, DEFAULT_RECEIVE_STATE }; +export { + SeriesPullState, + RequestState, + DEFAULT_RECEIVE_STATE, + SeriesNotRegisteredError, +}; export type { StudyKey, SeriesKey, diff --git a/testing/miniChRIS b/testing/miniChRIS index d5d2a28ad..f71bfc274 160000 --- a/testing/miniChRIS +++ b/testing/miniChRIS @@ -1 +1 @@ -Subproject commit d5d2a28adc09e5a42df96c4e5fcd198f63c90260 +Subproject commit f71bfc27455cd87802208a3041647b1bc31b5ede From a74ae257711d7e37f6e80b807d20cef95df870ac Mon Sep 17 00:00:00 2001 From: Jennings Zhang Date: Thu, 26 Sep 2024 00:09:07 -0400 Subject: [PATCH 25/41] Customize progress bar theme --- src/App.tsx | 10 ++++++++++ src/components/Pacs/components/SeriesList.tsx | 6 +++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/App.tsx b/src/App.tsx index f316263e5..0c579847d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -60,6 +60,16 @@ function App(props: AllProps) { algorithm: isDarkTheme ? theme.darkAlgorithm : theme.defaultAlgorithm, + token: { + // var(--pf-v5-global--primary-color--200) + colorSuccess: "#004080", + }, + components: { + Progress: { + // var(--pf-v5-global--primary-color--100) + defaultColor: "#0066CC", + }, + }, }} > diff --git a/src/components/Pacs/components/SeriesList.tsx b/src/components/Pacs/components/SeriesList.tsx index 16d73b019..084276bdf 100644 --- a/src/components/Pacs/components/SeriesList.tsx +++ b/src/components/Pacs/components/SeriesList.tsx @@ -115,11 +115,14 @@ const SeriesRow: React.FC = ({
+ {/* TODO Progress 100% text color should be changed from dark blue */} `${Math.round(n ?? 0)}%`} percent={percentDone} - status={errors.length > 0 ? "exception" : undefined} + status={ + errors.length > 0 ? "exception" : inCube ? "success" : "normal" + } />
@@ -129,6 +132,7 @@ const SeriesRow: React.FC = ({ disabled={pullState !== SeriesPullState.READY} color={buttonColor} > + {/* TODO Button width is different if isLoading */} {errors.length > 1 ? ( ) : isLoading ? ( From 2df2851611811480cf20ef37cfc466e3b5b04721 Mon Sep 17 00:00:00 2001 From: Jennings Zhang Date: Thu, 26 Sep 2024 00:28:32 -0400 Subject: [PATCH 26/41] Implement series pull --- src/components/Pacs/PacsController.tsx | 2 +- .../Pacs/components/PacsStudiesView.tsx | 13 +++++- src/components/Pacs/components/SeriesList.tsx | 40 ++++++++++++------- src/components/Pacs/types.ts | 10 ++--- 4 files changed, 43 insertions(+), 22 deletions(-) diff --git a/src/components/Pacs/PacsController.tsx b/src/components/Pacs/PacsController.tsx index 33c77f4fb..705858005 100644 --- a/src/components/Pacs/PacsController.tsx +++ b/src/components/Pacs/PacsController.tsx @@ -544,7 +544,7 @@ const PacsController: React.FC = ({ ({ info, pullState }) => info.RetrieveAETitle === pacs_name && info.SeriesInstanceUID === SeriesInstanceUID && - pullState === SeriesPullState.READY, + pullState === SeriesPullState.PULLING, ) !== -1, [allSeries], ); diff --git a/src/components/Pacs/components/PacsStudiesView.tsx b/src/components/Pacs/components/PacsStudiesView.tsx index 8d81a12d6..b4ba3bace 100644 --- a/src/components/Pacs/components/PacsStudiesView.tsx +++ b/src/components/Pacs/components/PacsStudiesView.tsx @@ -47,7 +47,18 @@ const PacsStudiesView: React.FC = ({ } > ), - children: , + children: ( + + onRetrieve({ + patientID: info.PatientID, + seriesInstanceUID: info.SeriesInstanceUID, + }) + } + /> + ), }; }); }, [studies]); diff --git a/src/components/Pacs/components/SeriesList.tsx b/src/components/Pacs/components/SeriesList.tsx index 084276bdf..61d741950 100644 --- a/src/components/Pacs/components/SeriesList.tsx +++ b/src/components/Pacs/components/SeriesList.tsx @@ -16,13 +16,15 @@ import styles from "./SeriesList.module.css"; import React from "react"; import { isSeriesLoading } from "./helpers.ts"; -type SeriesTableProps = { +type SeriesListProps = { states: PacsSeriesState[]; showUid?: boolean; + onRetrieve?: (state: PacsSeriesState) => void; }; type SeriesRowProps = PacsSeriesState & { showUid?: boolean; + onRetrieve?: () => void; }; const SeriesRow: React.FC = ({ @@ -32,6 +34,7 @@ const SeriesRow: React.FC = ({ inCube, receivedCount, showUid, + onRetrieve, }) => { const isLoading = React.useMemo( () => isSeriesLoading({ pullState, inCube }), @@ -131,6 +134,7 @@ const SeriesRow: React.FC = ({ loading={isLoading} disabled={pullState !== SeriesPullState.READY} color={buttonColor} + onClick={onRetrieve} > {/* TODO Button width is different if isLoading */} {errors.length > 1 ? ( @@ -154,19 +158,25 @@ const SeriesRow: React.FC = ({ ); }; -const SeriesList: React.FC = ({ states, showUid }) => { - return ( - ( - - - - )} - rowKey={(state) => state.info.SeriesInstanceUID} - size="small" - /> - ); -}; +const SeriesList: React.FC = ({ + states, + showUid, + onRetrieve, +}) => ( + ( + + onRetrieve && onRetrieve(s)} + {...s} + /> + + )} + rowKey={(state) => state.info.SeriesInstanceUID} + size="small" + /> +); export default SeriesList; diff --git a/src/components/Pacs/types.ts b/src/components/Pacs/types.ts index 1a39e010a..64d54b6c8 100644 --- a/src/components/Pacs/types.ts +++ b/src/components/Pacs/types.ts @@ -33,23 +33,23 @@ enum SeriesPullState { /** * Unknown whether series is available in CUBE. */ - NOT_CHECKED, + NOT_CHECKED = "not checked", /** * Currently checking for availability in CUBE. */ - CHECKING, + CHECKING = "checking", /** * Ready to be pulled. */ - READY, + READY = "ready", /** * Being pulled by oxidicom. */ - PULLING, + PULLING = "pulling", /** * Done being received by oxidicom, but may or not yet ready in CUBE. */ - WAITING_OR_COMPLETE, + WAITING_OR_COMPLETE = "waiting or complete", } /** From e8ddeef454abf74905fa4ab4d28cc5e345c8270b Mon Sep 17 00:00:00 2001 From: Jennings Zhang Date: Thu, 26 Sep 2024 00:44:35 -0400 Subject: [PATCH 27/41] Fix crash on invalid StudyDate --- src/components/Pacs/components/StudyDetails.tsx | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/components/Pacs/components/StudyDetails.tsx b/src/components/Pacs/components/StudyDetails.tsx index 2c1b68d72..e0dc62383 100644 --- a/src/components/Pacs/components/StudyDetails.tsx +++ b/src/components/Pacs/components/StudyDetails.tsx @@ -14,7 +14,7 @@ const StudyDetails: React.FC<{ Study "{study.StudyDescription}"{" "} - on {study.StudyDate ? format(study.StudyDate, dateFormat) : "unknown"}{" "} + on {formatDate(study.StudyDate, dateFormat)}{" "} {study.AccessionNumber && !study.AccessionNumber.includes("no value provided") && ( <>(AccessionNumber: {study.AccessionNumber}) @@ -72,4 +72,16 @@ function formatPypxSex(dicomSex: string) { } } +function formatDate(date: Date | undefined | null, dateFormat: string) { + if (!date) { + return "unknown date"; + } + try { + return format(date, dateFormat); + } catch (_e) { + // invalid date + return "unknown date"; + } +} + export default StudyDetails; From 27db1fc2aa42329d2be6d275dedf54e209863717 Mon Sep 17 00:00:00 2001 From: Jennings Zhang Date: Thu, 26 Sep 2024 00:57:24 -0400 Subject: [PATCH 28/41] Add test stub --- src/components/Pacs/components/PacsStudiesView.test.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/components/Pacs/components/PacsStudiesView.test.tsx b/src/components/Pacs/components/PacsStudiesView.test.tsx index e69de29bb..2df7e20e2 100644 --- a/src/components/Pacs/components/PacsStudiesView.test.tsx +++ b/src/components/Pacs/components/PacsStudiesView.test.tsx @@ -0,0 +1,3 @@ +import { test, expect } from "vitest"; + +test.skip("PacsStudiesView", () => {}); From 37f08390794c35bd02cd1256d609c4a53977bd32 Mon Sep 17 00:00:00 2001 From: Jennings Zhang Date: Tue, 8 Oct 2024 23:50:03 -0400 Subject: [PATCH 29/41] Hacky workaround for double firing of pull request --- src/components/Pacs/PacsController.tsx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/components/Pacs/PacsController.tsx b/src/components/Pacs/PacsController.tsx index 705858005..f4ec534d2 100644 --- a/src/components/Pacs/PacsController.tsx +++ b/src/components/Pacs/PacsController.tsx @@ -495,6 +495,8 @@ const PacsController: React.FC = ({ const onRetrieve = React.useCallback( (service: string, query: PACSqueryCore) => { expandStudiesFor(service, query); + // N.B.: immer bug here + // https://github.com/immerjs/use-immer/issues/139 setPullRequests((draft) => { // indicate that the user requests for something to be retrieved. draft.push({ @@ -594,10 +596,19 @@ const PacsController: React.FC = ({ updatePullRequestState(service, query, { state: RequestState.REQUESTED }), }); + // FIXME idk why the effect is firing twice... + const badWorkaroundToPreventDuplicatePull = React.useRef< + Set + >(new Set()); + React.useEffect(() => { pullRequests .filter(shouldSendPullRequest) - .forEach((pr) => pullFromPacs.mutate(pr)); + .filter((pr) => !badWorkaroundToPreventDuplicatePull.current.has(pr)) + .forEach((pr) => { + badWorkaroundToPreventDuplicatePull.current.add(pr); + pullFromPacs.mutate(pr); + }); }, [pullRequests, shouldSendPullRequest]); // ======================================== From 08e52803dfee98424fb2505d4f7a9c0323591d00 Mon Sep 17 00:00:00 2001 From: Jennings Zhang Date: Thu, 10 Oct 2024 10:32:32 -0400 Subject: [PATCH 30/41] Revert "Hacky workaround for double firing of pull request" This reverts commit 37f08390794c35bd02cd1256d609c4a53977bd32. --- src/components/Pacs/PacsController.tsx | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/src/components/Pacs/PacsController.tsx b/src/components/Pacs/PacsController.tsx index f4ec534d2..705858005 100644 --- a/src/components/Pacs/PacsController.tsx +++ b/src/components/Pacs/PacsController.tsx @@ -495,8 +495,6 @@ const PacsController: React.FC = ({ const onRetrieve = React.useCallback( (service: string, query: PACSqueryCore) => { expandStudiesFor(service, query); - // N.B.: immer bug here - // https://github.com/immerjs/use-immer/issues/139 setPullRequests((draft) => { // indicate that the user requests for something to be retrieved. draft.push({ @@ -596,19 +594,10 @@ const PacsController: React.FC = ({ updatePullRequestState(service, query, { state: RequestState.REQUESTED }), }); - // FIXME idk why the effect is firing twice... - const badWorkaroundToPreventDuplicatePull = React.useRef< - Set - >(new Set()); - React.useEffect(() => { pullRequests .filter(shouldSendPullRequest) - .filter((pr) => !badWorkaroundToPreventDuplicatePull.current.has(pr)) - .forEach((pr) => { - badWorkaroundToPreventDuplicatePull.current.add(pr); - pullFromPacs.mutate(pr); - }); + .forEach((pr) => pullFromPacs.mutate(pr)); }, [pullRequests, shouldSendPullRequest]); // ======================================== From ef2cc3adc6d715c755f0ef32aa13ec6befe31bbe Mon Sep 17 00:00:00 2001 From: Jennings Zhang Date: Thu, 10 Oct 2024 10:48:29 -0400 Subject: [PATCH 31/41] Small changes --- src/components/Pacs/PacsController.tsx | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/src/components/Pacs/PacsController.tsx b/src/components/Pacs/PacsController.tsx index 705858005..9794b6d27 100644 --- a/src/components/Pacs/PacsController.tsx +++ b/src/components/Pacs/PacsController.tsx @@ -528,7 +528,7 @@ const PacsController: React.FC = ({ ); /** - * All series states. + * All DICOM series states. */ const allSeries = React.useMemo( () => (studies ?? []).flatMap((s) => s.series), @@ -558,21 +558,19 @@ const PacsController: React.FC = ({ if (pullRequest.state !== RequestState.NOT_REQUESTED) { return false; } - if ( - pullRequest.query.studyInstanceUID && - !("seriesInstanceUID" in pullRequest.query) - ) { - return shouldPullStudy( - pullRequest.service, - pullRequest.query.studyInstanceUID, - ); - } if (pullRequest.query.seriesInstanceUID) { return shouldPullSeries( pullRequest.service, pullRequest.query.seriesInstanceUID, ); } + if (pullRequest.query.studyInstanceUID) { + return shouldPullStudy( + pullRequest.service, + pullRequest.query.studyInstanceUID, + ); + } + return false; }, [shouldPullStudy, shouldPullSeries], From e174158bc68d19139731362b5a2a06cdb9778d7b Mon Sep 17 00:00:00 2001 From: Jennings Zhang Date: Thu, 10 Oct 2024 12:17:56 -0400 Subject: [PATCH 32/41] pnpm run fmt --- src/components/Feeds/Feeds.css | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/src/components/Feeds/Feeds.css b/src/components/Feeds/Feeds.css index 3eec65139..aabfcb178 100644 --- a/src/components/Feeds/Feeds.css +++ b/src/components/Feeds/Feeds.css @@ -42,20 +42,16 @@ vertical-align: middle !important; } - - .custom-panel { background-color: var(--pf-v5-c-page__main-section--BackgroundColor); border-right: 1px solid var(--pf-global--BorderColor--dark); } - - /* Common styles for both handles */ .ResizeHandle, .ResizeHandleVertical, .ResizeHandleCollapsed { - background-color:#4F5255; /* Default background color */ + background-color: #4f5255; /* Default background color */ transition: background-color 250ms linear; } @@ -80,7 +76,7 @@ .ResizeHandleVertical[data-resize-handle-active], .ResizeHandleCollapsed:hover, .ResizeHandleCollapsed[data-resize-handle-active] { - background-color:#8A8D90 ; /* PatternFly blue */ + background-color: #8a8d90; /* PatternFly blue */ } /* Adjust for touch devices */ @@ -92,6 +88,3 @@ height: 1rem; } } - - - From 8e169bca7930d79b44012f6f9a246cb1da1dd70e Mon Sep 17 00:00:00 2001 From: Jennings Zhang Date: Thu, 10 Oct 2024 12:30:03 -0400 Subject: [PATCH 33/41] Change type of pullRequests from ReadonlyArray to PullRequestStates --- src/components/Pacs/PacsController.tsx | 84 ++++++++++---------------- src/components/Pacs/mergeStates.ts | 31 +++++----- src/components/Pacs/types.ts | 19 +++++- 3 files changed, 64 insertions(+), 70 deletions(-) diff --git a/src/components/Pacs/PacsController.tsx b/src/components/Pacs/PacsController.tsx index 9794b6d27..0005efe5a 100644 --- a/src/components/Pacs/PacsController.tsx +++ b/src/components/Pacs/PacsController.tsx @@ -18,16 +18,18 @@ import { useQueries, useQuery, } from "@tanstack/react-query"; -import { mergeStates, createCubeSeriesQueryUidMap } from "./mergeStates.ts"; +import { createCubeSeriesQueryUidMap, mergeStates } from "./mergeStates.ts"; import { DEFAULT_RECEIVE_STATE, IPacsState, PacsPullRequestState, + PullRequestStates, ReceiveState, RequestState, SeriesNotRegisteredError, SeriesPullState, SeriesReceiveState, + SpecificDicomQuery, StudyKey, } from "./types.ts"; import { DEFAULT_PREFERENCES } from "./defaultPreferences.ts"; @@ -125,37 +127,24 @@ const PacsController: React.FC = ({ /** * List of PACS queries which the user wants to pull. */ - const [pullRequests, setPullRequests] = useImmer< - ReadonlyArray - >([]); + const [pullRequests, setPullRequests] = useImmer( + new Map(), + ); /** * Update the state of a pull request. */ const updatePullRequestState = React.useCallback( - ( - service: string, - query: PACSqueryCore, - delta: Partial>, - ) => + (query: SpecificDicomQuery, delta: Partial) => setPullRequests((draft) => { - const i = draft.findLastIndex( - (pr) => - pr.service === service && - (pr.query.studyInstanceUID - ? pr.query.studyInstanceUID === query.studyInstanceUID - : true) && - (pr.query.seriesInstanceUID - ? pr.query.seriesInstanceUID === query.seriesInstanceUID - : true), - ); - if (i === -1) { + const prev = draft.get(query); + if (!prev) { throw new Error( "pullFromPacs mutation called on unknown pull request: " + `service=${service}, query=${JSON.stringify(query)}`, ); } - draft[i] = { ...draft[i], ...delta }; + draft.set(query, { ...prev, ...delta }); }), [setPullRequests], ); @@ -496,12 +485,9 @@ const PacsController: React.FC = ({ (service: string, query: PACSqueryCore) => { expandStudiesFor(service, query); setPullRequests((draft) => { + const key = { service, query }; // indicate that the user requests for something to be retrieved. - draft.push({ - state: RequestState.NOT_REQUESTED, - query, - service, - }); + draft.set(key, { state: RequestState.NOT_REQUESTED }); }); }, [setPullRequests, expandStudiesFor], @@ -554,23 +540,20 @@ const PacsController: React.FC = ({ * may be for either a DICOM study or series). */ const shouldSendPullRequest = React.useCallback( - (pullRequest: PacsPullRequestState): boolean => { - if (pullRequest.state !== RequestState.NOT_REQUESTED) { + ({ + service, + query, + state, + }: SpecificDicomQuery & Pick): boolean => { + if (state !== RequestState.NOT_REQUESTED) { return false; } - if (pullRequest.query.seriesInstanceUID) { - return shouldPullSeries( - pullRequest.service, - pullRequest.query.seriesInstanceUID, - ); + if (query.seriesInstanceUID) { + return shouldPullSeries(service, query.seriesInstanceUID); } - if (pullRequest.query.studyInstanceUID) { - return shouldPullStudy( - pullRequest.service, - pullRequest.query.studyInstanceUID, - ); + if (query.studyInstanceUID) { + return shouldPullStudy(service, query.studyInstanceUID); } - return false; }, [shouldPullStudy, shouldPullSeries], @@ -580,22 +563,21 @@ const PacsController: React.FC = ({ * Send request to PFDCM to pull from PACS. */ const pullFromPacs = useMutation({ - mutationFn: ({ service, query }: PacsPullRequestState) => + mutationFn: ({ service, query }: SpecificDicomQuery) => pfdcmClient.retrieve(service, query), - onMutate: ({ service, query }: PacsPullRequestState) => - updatePullRequestState(service, query, { - state: RequestState.REQUESTING, - }), - onError: (error, { service, query }) => - updatePullRequestState(service, query, { error: error }), - onSuccess: (_, { service, query }) => - updatePullRequestState(service, query, { state: RequestState.REQUESTED }), + onMutate: (query: SpecificDicomQuery) => + updatePullRequestState(query, { state: RequestState.REQUESTING }), + onError: (error, query) => updatePullRequestState(query, { error: error }), + onSuccess: (_, query) => + updatePullRequestState(query, { state: RequestState.REQUESTED }), }); React.useEffect(() => { - pullRequests - .filter(shouldSendPullRequest) - .forEach((pr) => pullFromPacs.mutate(pr)); + [...pullRequests.entries()] + .filter(([query, { state }]) => + shouldSendPullRequest({ ...query, state }), + ) + .forEach(([query, _]) => pullFromPacs.mutate(query)); }, [pullRequests, shouldSendPullRequest]); // ======================================== diff --git a/src/components/Pacs/mergeStates.ts b/src/components/Pacs/mergeStates.ts index ddfbef958..237faa4db 100644 --- a/src/components/Pacs/mergeStates.ts +++ b/src/components/Pacs/mergeStates.ts @@ -1,13 +1,14 @@ import { DEFAULT_RECEIVE_STATE, - PacsPullRequestState, PacsStudyState, + PullRequestStates, ReceiveState, RequestState, SeriesKey, SeriesNotRegisteredError, SeriesPullState, SeriesReceiveState, + SpecificDicomQuery, } from "./types.ts"; import { Series, StudyAndSeries } from "../../api/pfdcm/models.ts"; import { useQuery, UseQueryResult } from "@tanstack/react-query"; @@ -28,13 +29,13 @@ type UseQueryResultLike = Partial< * @param cubeQueryMap mapping of `SeriesInstanceUID` to queries for respective * {@link PACSSeries} in CUBE (hint: call * {@link createCubeSeriesQueryUidMap}) - * @param receiveState state of DICOM receive operation conveyed via LONK + * @param receiveStates state of DICOM receive operation conveyed via LONK */ function mergeStates( pfdcm: ReadonlyArray, - pullRequests: ReadonlyArray, + pullRequests: PullRequestStates, cubeQueryMap: Map>, - receiveState: ReceiveState, + receiveStates: ReceiveState, ): PacsStudyState[] { return pfdcm.map(({ study, series }) => { return { @@ -43,21 +44,19 @@ function mergeStates( const cubeQueryResult = cubeQueryMap.get(info.SeriesInstanceUID); const cubeError = cubeQueryResult?.error; const cubeErrors = cubeError ? [cubeError.message] : []; - const state = - receiveState.get(info.RetrieveAETitle, info.SeriesInstanceUID) ?? + const rxState = + receiveStates.get(info.RetrieveAETitle, info.SeriesInstanceUID) ?? DEFAULT_RECEIVE_STATE; - const pullRequestsForSeries = pullRequests.findLast((pr) => - isRequestFor(pr, info), + const prEntry = [...pullRequests.entries()].find(([query]) => + isRequestFor(query, info), ); + const prState = prEntry?.[1].state; + return { info, - receivedCount: state.receivedCount, - errors: state.errors.concat(cubeErrors), - pullState: pullStateOf( - state, - pullRequestsForSeries?.state, - cubeQueryResult, - ), + receivedCount: rxState.receivedCount, + errors: rxState.errors.concat(cubeErrors), + pullState: pullStateOf(rxState, prState, cubeQueryResult), inCube: cubeQueryResult?.data || null, }; }), @@ -103,7 +102,7 @@ function isNotRegisteredError(q: UseQueryResultLike): boolean { * @returns `true` if the query matches the series. */ function isRequestFor( - { query, service }: PacsPullRequestState, + { query, service }: SpecificDicomQuery, series: Series, ): boolean { if (service !== series.RetrieveAETitle) { diff --git a/src/components/Pacs/types.ts b/src/components/Pacs/types.ts index 64d54b6c8..b21c9be80 100644 --- a/src/components/Pacs/types.ts +++ b/src/components/Pacs/types.ts @@ -62,15 +62,26 @@ enum RequestState { } /** - * The state of a PACS pull request. + * A {@link PACSqueryCore} for a specified PACS. + */ +type SpecificDicomQuery = { + service: string; + query: PACSqueryCore; +}; + +/** + * The state of a request for a {@link SpecificDicomQuery}. */ type PacsPullRequestState = { state: RequestState; error?: Error; - query: PACSqueryCore; - service: string; }; +/** + * The state of requests to PFDCM to pull DICOM study/series. + */ +type PullRequestStates = Map; + /** * The state of a DICOM series retrieval. */ @@ -163,5 +174,7 @@ export type { PacsSeriesState, PacsStudyState, PacsPreferences, + SpecificDicomQuery, PacsPullRequestState, + PullRequestStates, }; From f7cf25a0b7eb2fc4f7246d89de2a9de2f57cd563 Mon Sep 17 00:00:00 2001 From: Jennings Zhang Date: Thu, 10 Oct 2024 13:45:24 -0400 Subject: [PATCH 34/41] terribleStrictModeWorkaround --- src/components/Pacs/PacsController.tsx | 5 ++++ .../terribleStrictModeWorkaround.test.tsx | 30 +++++++++++++++++++ .../Pacs/terribleStrictModeWorkaround.ts | 21 +++++++++++++ 3 files changed, 56 insertions(+) create mode 100644 src/components/Pacs/terribleStrictModeWorkaround.test.tsx create mode 100644 src/components/Pacs/terribleStrictModeWorkaround.ts diff --git a/src/components/Pacs/PacsController.tsx b/src/components/Pacs/PacsController.tsx index 0005efe5a..aeb516735 100644 --- a/src/components/Pacs/PacsController.tsx +++ b/src/components/Pacs/PacsController.tsx @@ -44,6 +44,7 @@ import { sameStudyInstanceUidAs, } from "./curry.ts"; import { Study } from "../../api/pfdcm/models.ts"; +import terribleStrictModeWorkaround from "./terribleStrictModeWorkaround.ts"; type PacsControllerProps = { getPfdcmClient: () => PfdcmClient; @@ -572,8 +573,12 @@ const PacsController: React.FC = ({ updatePullRequestState(query, { state: RequestState.REQUESTED }), }); + const terribleDoNotCallTwice = + terribleStrictModeWorkaround<[SpecificDicomQuery, PacsPullRequestState]>(); + React.useEffect(() => { [...pullRequests.entries()] + .filter(terribleDoNotCallTwice) .filter(([query, { state }]) => shouldSendPullRequest({ ...query, state }), ) diff --git a/src/components/Pacs/terribleStrictModeWorkaround.test.tsx b/src/components/Pacs/terribleStrictModeWorkaround.test.tsx new file mode 100644 index 000000000..52891544c --- /dev/null +++ b/src/components/Pacs/terribleStrictModeWorkaround.test.tsx @@ -0,0 +1,30 @@ +import { test, expect, vi } from "vitest"; +import React from "react"; +import terribleStrictModeWorkaround from "./terribleStrictModeWorkaround.ts"; +import { render, screen } from "@testing-library/react"; + +type ExampleProps = { + obj: T; + callback: (calledBefore: boolean) => void; +}; + +const ExampleComponent = ({ obj, callback }: ExampleProps) => { + const workaroundFn = terribleStrictModeWorkaround(); + const [state, setState] = React.useState>([]); + React.useEffect(() => { + state.forEach((s) => callback(workaroundFn(s))); + }, [state]); + return ; +}; + +test("terribleStrictModeWorkaround", async () => { + const callback = vi.fn(); + render(); + expect(callback).not.toHaveBeenCalled(); + screen.getByText("click me").click(); + await expect.poll(() => callback).toHaveBeenCalledOnce(); + expect(callback).toHaveBeenLastCalledWith(true); + screen.getByText("click me").click(); + await expect.poll(() => callback).toHaveBeenCalledTimes(2); + expect(callback).toHaveBeenLastCalledWith(false); +}); diff --git a/src/components/Pacs/terribleStrictModeWorkaround.ts b/src/components/Pacs/terribleStrictModeWorkaround.ts new file mode 100644 index 000000000..4f43f74d3 --- /dev/null +++ b/src/components/Pacs/terribleStrictModeWorkaround.ts @@ -0,0 +1,21 @@ +import React from "react"; + +/** + * A wrapper around {@link React.useRef} to detect when something is called + * more than once. Useful as a workaround to how {@link React.StrictMode} + * makes effects run twice. + * + * @returns an impure predicate, returning `true` the first time `x` is given, + * and `false` for every subsequent call on the same `x`. + */ +export default function terribleStrictModeWorkaround(): (x: T) => boolean { + const set = React.useRef(new Set()); + return React.useCallback( + (x) => { + const has = set.current.has(x); + set.current.add(x); + return !has; + }, + [set.current], + ); +} From 5e8505e2ba1a185bedd39695930f05912bd24604 Mon Sep 17 00:00:00 2001 From: Jennings Zhang Date: Thu, 10 Oct 2024 14:49:31 -0400 Subject: [PATCH 35/41] Fix all lints Note: lint/complexity/noUselessFragments is disabled because of https://github.com/biomejs/biome/issues/4247 Also follow https://github.com/biomejs/biome/issues/4248 --- biome.json | 6 ++- src/components/Pacs/PacsController.tsx | 48 ++++++++++--------- src/components/Pacs/PacsView.tsx | 12 ++--- .../Pacs/components/ErrorScreen.tsx | 4 +- src/components/Pacs/components/PacsInput.tsx | 4 +- .../Pacs/components/PacsStudiesView.tsx | 12 ++--- src/components/Pacs/components/SeriesList.tsx | 9 ++-- .../Pacs/components/StudyButtons.tsx | 4 +- src/components/Pacs/components/StudyCard.tsx | 8 ++-- .../Pacs/components/StudyDetails.tsx | 4 +- .../Pacs/components/helpers.test.ts | 2 +- src/components/Pacs/components/helpers.ts | 6 +-- src/components/Pacs/curry.ts | 4 +- src/components/Pacs/defaultPreferences.ts | 2 +- src/components/Pacs/helpers.test.ts | 7 ++- src/components/Pacs/helpers.ts | 4 +- src/components/Pacs/mergeStates.test.ts | 6 +-- src/components/Pacs/mergeStates.ts | 20 ++++---- .../terribleStrictModeWorkaround.test.tsx | 8 +++- .../Pacs/terribleStrictModeWorkaround.ts | 13 ++--- src/components/Pacs/types.ts | 14 +++--- 21 files changed, 103 insertions(+), 94 deletions(-) diff --git a/biome.json b/biome.json index c9fa1e2e0..61ad6df07 100644 --- a/biome.json +++ b/biome.json @@ -25,10 +25,12 @@ "rules": { "recommended": true, "style": { - "noNonNullAssertion": "off" + "noNonNullAssertion": "off", + "useTemplate": "off" }, "complexity": { - "noForEach": "off" + "noForEach": "off", + "noUselessFragments": "off" }, "correctness": { "useExhaustiveDependencies": "warn" diff --git a/src/components/Pacs/PacsController.tsx b/src/components/Pacs/PacsController.tsx index aeb516735..f7862bb36 100644 --- a/src/components/Pacs/PacsController.tsx +++ b/src/components/Pacs/PacsController.tsx @@ -5,8 +5,9 @@ */ import React from "react"; -import { PACSqueryCore, PfdcmClient } from "../../api/pfdcm"; -import Client, { PACSSeries } from "@fnndsc/chrisapi"; +import type { PACSqueryCore, PfdcmClient } from "../../api/pfdcm"; +import type Client from "@fnndsc/chrisapi"; +import type { PACSSeries } from "@fnndsc/chrisapi"; import { App } from "antd"; import { PageSection } from "@patternfly/react-core"; import PacsView from "./PacsView.tsx"; @@ -21,29 +22,29 @@ import { import { createCubeSeriesQueryUidMap, mergeStates } from "./mergeStates.ts"; import { DEFAULT_RECEIVE_STATE, - IPacsState, - PacsPullRequestState, - PullRequestStates, - ReceiveState, + type IPacsState, + type PacsPullRequestState, + type PullRequestStates, + type ReceiveState, RequestState, SeriesNotRegisteredError, SeriesPullState, - SeriesReceiveState, - SpecificDicomQuery, - StudyKey, + type SeriesReceiveState, + type SpecificDicomQuery, + type StudyKey, } from "./types.ts"; import { DEFAULT_PREFERENCES } from "./defaultPreferences.ts"; import { toStudyKey, zipPacsNameAndSeriesUids } from "./helpers.ts"; import { useImmer } from "use-immer"; import SeriesMap from "../../api/lonk/seriesMap.ts"; import { useLonk } from "../../api/lonk"; -import { produce, WritableDraft } from "immer"; +import { produce, type WritableDraft } from "immer"; import { isFromPacs, sameSeriesInstanceUidAs, sameStudyInstanceUidAs, } from "./curry.ts"; -import { Study } from "../../api/pfdcm/models.ts"; +import type { Study } from "../../api/pfdcm/models.ts"; import terribleStrictModeWorkaround from "./terribleStrictModeWorkaround.ts"; type PacsControllerProps = { @@ -104,7 +105,9 @@ const PacsController: React.FC = ({ const { message } = App.useApp(); + // biome-ignore lint/correctness/useExhaustiveDependencies: https://github.com/biomejs/biome/issues/4248 const pfdcmClient = React.useMemo(getPfdcmClient, [getPfdcmClient]); + // biome-ignore lint/correctness/useExhaustiveDependencies: https://github.com/biomejs/biome/issues/4248 const chrisClient = React.useMemo(getChrisClient, [getChrisClient]); // ======================================== @@ -315,7 +318,7 @@ const PacsController: React.FC = ({ retry: 300, retryDelay: 2000, // TODO use environment variable })), - [receiveState], + [receiveState, chrisClient.getPACSSeriesList], ), }); @@ -357,13 +360,7 @@ const PacsController: React.FC = ({ allCubeSeriesQueryMap, receiveState, ); - }, [ - mergeStates, - pfdcmStudies.data, - pullRequests, - allCubeSeriesQueryMap, - receiveState, - ]); + }, [pfdcmStudies.data, pullRequests, allCubeSeriesQueryMap, receiveState]); /** * Entire state of the Pacs Q/R application. @@ -475,7 +472,7 @@ const PacsController: React.FC = ({ (service: string, query: PACSqueryCore) => { setPacsQuery({ service, query }); }, - [setPacsQuery], + [], ); // ======================================== @@ -509,7 +506,7 @@ const PacsController: React.FC = ({ .findIndex( ({ pullState }) => pullState === SeriesPullState.NOT_CHECKED || - pullState == SeriesPullState.CHECKING, + pullState === SeriesPullState.CHECKING, ) === -1, [studies], ); @@ -583,7 +580,12 @@ const PacsController: React.FC = ({ shouldSendPullRequest({ ...query, state }), ) .forEach(([query, _]) => pullFromPacs.mutate(query)); - }, [pullRequests, shouldSendPullRequest]); + }, [ + pullRequests, + shouldSendPullRequest, + pullFromPacs.mutate, + terribleDoNotCallTwice, + ]); // ======================================== // EFFECTS @@ -611,7 +613,7 @@ const PacsController: React.FC = ({ } // Note: we are subscribing to series, but never unsubscribing. // This is mostly harmless. - }, [expandedSeries]); + }, [lonk.subscribe, expandedSeries, updateReceiveState]); // ======================================== // RENDER diff --git a/src/components/Pacs/PacsView.tsx b/src/components/Pacs/PacsView.tsx index 046f8f1c1..79b9bf78a 100644 --- a/src/components/Pacs/PacsView.tsx +++ b/src/components/Pacs/PacsView.tsx @@ -1,13 +1,13 @@ import React from "react"; -import PacsInput, { PacsInputProps } from "./components/PacsInput.tsx"; +import PacsInput, { type PacsInputProps } from "./components/PacsInput.tsx"; import PacsStudiesView, { - PacsStudiesViewProps, + type PacsStudiesViewProps, } from "./components/PacsStudiesView.tsx"; import { getDefaultPacsService } from "./components/helpers.ts"; import { useSearchParams } from "react-router-dom"; -import { PACSqueryCore } from "../../api/pfdcm"; +import type { PACSqueryCore } from "../../api/pfdcm"; import { Empty, Flex, Spin } from "antd"; -import { IPacsState } from "./types.ts"; +import type { IPacsState } from "./types.ts"; type PacsViewProps = Pick & Pick & { @@ -49,13 +49,13 @@ const PacsView: React.FC = ({ const curriedOnRetrieve = React.useCallback( (query: PACSqueryCore) => onRetrieve(service, query), - [onRetrieve], + [onRetrieve, service], ); const curriedOnStudyExpand = React.useCallback( (StudyInstanceUIDS: ReadonlyArray) => onStudyExpand(service, StudyInstanceUIDS), - [onStudyExpand], + [onStudyExpand, service], ); return ( diff --git a/src/components/Pacs/components/ErrorScreen.tsx b/src/components/Pacs/components/ErrorScreen.tsx index c69d06171..f46929ee3 100644 --- a/src/components/Pacs/components/ErrorScreen.tsx +++ b/src/components/Pacs/components/ErrorScreen.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import type React from "react"; import { EmptyState, EmptyStateBody, @@ -10,7 +10,7 @@ import { } from "@patternfly/react-core"; import { ExclamationCircleIcon } from "../../Icons"; -const ErrorScreen: React.FC> = ({ children }) => ( +const ErrorScreen: React.FC = ({ children }) => ( void; diff --git a/src/components/Pacs/components/PacsStudiesView.tsx b/src/components/Pacs/components/PacsStudiesView.tsx index b4ba3bace..4e7c8bc88 100644 --- a/src/components/Pacs/components/PacsStudiesView.tsx +++ b/src/components/Pacs/components/PacsStudiesView.tsx @@ -1,7 +1,7 @@ -import { PacsPreferences, PacsStudyState } from "../types.ts"; -import { PACSqueryCore } from "../../../api/pfdcm"; +import type { PacsPreferences, PacsStudyState } from "../types.ts"; +import type { PACSqueryCore } from "../../../api/pfdcm"; import StudyCard from "./StudyCard.tsx"; -import { Collapse, CollapseProps, Space, Typography } from "antd"; +import { Collapse, type CollapseProps, Space, Typography } from "antd"; import React from "react"; import SeriesList from "./SeriesList.tsx"; import { isSeriesLoading } from "./helpers.ts"; @@ -45,7 +45,7 @@ const PacsStudiesView: React.FC = ({ studyInstanceUID: info.StudyInstanceUID, }) } - > + /> ), children: ( = ({ ), }; }); - }, [studies]); + }, [studies, onRetrieve, preferences.showUid]); const numPatients = React.useMemo(() => { return studies .map((study) => study.info.PatientID) @@ -71,7 +71,7 @@ const PacsStudiesView: React.FC = ({ ).length; }, [studies]); const onChange = React.useCallback( - (studyUids: string[]) => onStudyExpand && onStudyExpand(studyUids), + (studyUids: string[]) => onStudyExpand?.(studyUids), [onStudyExpand], ); return ( diff --git a/src/components/Pacs/components/SeriesList.tsx b/src/components/Pacs/components/SeriesList.tsx index 61d741950..476c80c9e 100644 --- a/src/components/Pacs/components/SeriesList.tsx +++ b/src/components/Pacs/components/SeriesList.tsx @@ -1,6 +1,6 @@ import { Button, - ButtonProps, + type ButtonProps, Descriptions, Flex, Grid, @@ -9,7 +9,7 @@ import { Tooltip, Typography, } from "antd"; -import { PacsSeriesState, SeriesPullState } from "../types.ts"; +import { type PacsSeriesState, SeriesPullState } from "../types.ts"; import ModalityBadges from "./ModalityBadges.tsx"; import { ImportOutlined, WarningFilled } from "@ant-design/icons"; import styles from "./SeriesList.module.css"; @@ -89,7 +89,8 @@ const SeriesRow: React.FC = ({ return 99; } return ( - (99 * receivedCount) / (info.NumberOfSeriesRelatedInstances || Infinity) + (99 * receivedCount) / + (info.NumberOfSeriesRelatedInstances || Number.POSITIVE_INFINITY) ); }, [inCube, pullState, receivedCount, info.NumberOfSeriesRelatedInstances]); @@ -169,7 +170,7 @@ const SeriesList: React.FC = ({ onRetrieve && onRetrieve(s)} + onRetrieve={() => onRetrieve?.(s)} {...s} /> diff --git a/src/components/Pacs/components/StudyButtons.tsx b/src/components/Pacs/components/StudyButtons.tsx index 91d4500d7..03c9efda0 100644 --- a/src/components/Pacs/components/StudyButtons.tsx +++ b/src/components/Pacs/components/StudyButtons.tsx @@ -1,4 +1,4 @@ -import { Flex, Button, Tooltip, TooltipProps } from "antd"; +import { Flex, Button, Tooltip, type TooltipProps } from "antd"; import { AppstoreOutlined, ImportOutlined } from "@ant-design/icons"; type StudyButtonsProps = { @@ -41,7 +41,7 @@ const StudyButtons: React.FC = ({ disabled={isPulled} onClick={(event) => { event.stopPropagation(); - onRetrieve && onRetrieve(); + onRetrieve?.(); }} > {isLoading || } diff --git a/src/components/Pacs/components/StudyCard.tsx b/src/components/Pacs/components/StudyCard.tsx index e18fb518b..2e94afd8c 100644 --- a/src/components/Pacs/components/StudyCard.tsx +++ b/src/components/Pacs/components/StudyCard.tsx @@ -1,9 +1,9 @@ -import { Study } from "../../../api/pfdcm/models.ts"; -import React from "react"; +import type { Study } from "../../../api/pfdcm/models.ts"; +import type React from "react"; import { Row, Col } from "antd"; import StudyDetails from "./StudyDetails.tsx"; import StudyButtons from "./StudyButtons.tsx"; -import { PacsPreferences } from "../types.ts"; +import type { PacsPreferences } from "../types.ts"; import { DEFAULT_PREFERENCES } from "../defaultPreferences.ts"; type StudyCardProps = { @@ -37,7 +37,7 @@ const StudyCard: React.FC = ({ `${ohifUrl}viewer?StudyInstanceUIDs=${study.StudyInstanceUID}` } onRetrieve={onRetrieve} - > + /> ); diff --git a/src/components/Pacs/components/StudyDetails.tsx b/src/components/Pacs/components/StudyDetails.tsx index e0dc62383..637e0d028 100644 --- a/src/components/Pacs/components/StudyDetails.tsx +++ b/src/components/Pacs/components/StudyDetails.tsx @@ -1,8 +1,8 @@ import { format } from "date-fns"; import { Descriptions } from "antd"; import ModalityBadges from "./ModalityBadges.tsx"; -import React from "react"; -import { Study } from "../../../api/pfdcm/models.ts"; +import type React from "react"; +import type { Study } from "../../../api/pfdcm/models.ts"; const StudyDetails: React.FC<{ study: Study; diff --git a/src/components/Pacs/components/helpers.test.ts b/src/components/Pacs/components/helpers.test.ts index 5d8aa91e5..7e4699370 100644 --- a/src/components/Pacs/components/helpers.test.ts +++ b/src/components/Pacs/components/helpers.test.ts @@ -1,5 +1,5 @@ import { describe, it, vi, expect } from "vitest"; -import { createSearchParams, URLSearchParamsInit } from "react-router-dom"; +import { createSearchParams, type URLSearchParamsInit } from "react-router-dom"; import { useBooleanSearchParam } from "./helpers.ts"; describe("useBooleanSearchParam", () => { diff --git a/src/components/Pacs/components/helpers.ts b/src/components/Pacs/components/helpers.ts index 898da7695..261d6fd82 100644 --- a/src/components/Pacs/components/helpers.ts +++ b/src/components/Pacs/components/helpers.ts @@ -1,9 +1,9 @@ -import { useSearchParams } from "react-router-dom"; +import type { useSearchParams } from "react-router-dom"; import { - ReadonlyNonEmptyArray, + type ReadonlyNonEmptyArray, extract as extractFromNonEmpty, } from "fp-ts/ReadonlyNonEmptyArray"; -import { PacsSeriesState, SeriesPullState } from "../types.ts"; +import { type PacsSeriesState, SeriesPullState } from "../types.ts"; /** * Adapt {@link useSearchParams} to work like `React.useState(false)` diff --git a/src/components/Pacs/curry.ts b/src/components/Pacs/curry.ts index 2246c1ee3..26a0ce8c0 100644 --- a/src/components/Pacs/curry.ts +++ b/src/components/Pacs/curry.ts @@ -2,8 +2,8 @@ * Some trivial curried functions for making array map/filter code more legible. */ -import { Series, Study } from "../../api/pfdcm/models.ts"; -import { PACSqueryCore } from "../../api/pfdcm"; +import type { Series, Study } from "../../api/pfdcm/models.ts"; +import type { PACSqueryCore } from "../../api/pfdcm"; function isFromPacs( pacs_name: string, diff --git a/src/components/Pacs/defaultPreferences.ts b/src/components/Pacs/defaultPreferences.ts index 0af96189e..b07dc2abc 100644 --- a/src/components/Pacs/defaultPreferences.ts +++ b/src/components/Pacs/defaultPreferences.ts @@ -1,4 +1,4 @@ -import { PacsPreferences } from "./types.ts"; +import type { PacsPreferences } from "./types.ts"; const DEFAULT_PREFERENCES: PacsPreferences = { showUid: false, diff --git a/src/components/Pacs/helpers.test.ts b/src/components/Pacs/helpers.test.ts index 2728d60e8..fd175cb42 100644 --- a/src/components/Pacs/helpers.test.ts +++ b/src/components/Pacs/helpers.test.ts @@ -1,6 +1,9 @@ import { test, expect } from "vitest"; -import { StudyAndSeriesUidOnly, zipPacsNameAndSeriesUids } from "./helpers.ts"; -import { SeriesKey, StudyKey } from "./types.ts"; +import { + type StudyAndSeriesUidOnly, + zipPacsNameAndSeriesUids, +} from "./helpers.ts"; +import type { SeriesKey, StudyKey } from "./types.ts"; test.each(< [ diff --git a/src/components/Pacs/helpers.ts b/src/components/Pacs/helpers.ts index 91915f268..27b81a7d6 100644 --- a/src/components/Pacs/helpers.ts +++ b/src/components/Pacs/helpers.ts @@ -1,5 +1,5 @@ -import { SeriesKey, StudyKey } from "./types.ts"; -import { Study, StudyAndSeries } from "../../api/pfdcm/models.ts"; +import type { SeriesKey, StudyKey } from "./types.ts"; +import { type Study, StudyAndSeries } from "../../api/pfdcm/models.ts"; /** * A type subset of {@link StudyAndSeries}. diff --git a/src/components/Pacs/mergeStates.test.ts b/src/components/Pacs/mergeStates.test.ts index c07afa1a9..0928fccbd 100644 --- a/src/components/Pacs/mergeStates.test.ts +++ b/src/components/Pacs/mergeStates.test.ts @@ -2,15 +2,15 @@ import { expect, test } from "vitest"; import { createCubeSeriesQueryUidMap, pullStateOf, - UseQueryResultLike, + type UseQueryResultLike, } from "./mergeStates.ts"; import { DEFAULT_RECEIVE_STATE, RequestState, - SeriesKey, + type SeriesKey, SeriesNotRegisteredError, SeriesPullState, - SeriesReceiveState, + type SeriesReceiveState, } from "./types.ts"; test.each(< diff --git a/src/components/Pacs/mergeStates.ts b/src/components/Pacs/mergeStates.ts index 237faa4db..3307911ff 100644 --- a/src/components/Pacs/mergeStates.ts +++ b/src/components/Pacs/mergeStates.ts @@ -1,18 +1,18 @@ import { DEFAULT_RECEIVE_STATE, - PacsStudyState, - PullRequestStates, - ReceiveState, - RequestState, - SeriesKey, + type PacsStudyState, + type PullRequestStates, + type ReceiveState, + type RequestState, + type SeriesKey, SeriesNotRegisteredError, SeriesPullState, - SeriesReceiveState, - SpecificDicomQuery, + type SeriesReceiveState, + type SpecificDicomQuery, } from "./types.ts"; -import { Series, StudyAndSeries } from "../../api/pfdcm/models.ts"; -import { useQuery, UseQueryResult } from "@tanstack/react-query"; -import { PACSSeries } from "@fnndsc/chrisapi"; +import type { Series, StudyAndSeries } from "../../api/pfdcm/models.ts"; +import { useQuery, type UseQueryResult } from "@tanstack/react-query"; +import type { PACSSeries } from "@fnndsc/chrisapi"; type UseQueryResultLike = Partial< Pick diff --git a/src/components/Pacs/terribleStrictModeWorkaround.test.tsx b/src/components/Pacs/terribleStrictModeWorkaround.test.tsx index 52891544c..6ce2838b1 100644 --- a/src/components/Pacs/terribleStrictModeWorkaround.test.tsx +++ b/src/components/Pacs/terribleStrictModeWorkaround.test.tsx @@ -13,8 +13,12 @@ const ExampleComponent = ({ obj, callback }: ExampleProps) => { const [state, setState] = React.useState>([]); React.useEffect(() => { state.forEach((s) => callback(workaroundFn(s))); - }, [state]); - return ; + }, [state, callback, workaroundFn]); + return ( + + ); }; test("terribleStrictModeWorkaround", async () => { diff --git a/src/components/Pacs/terribleStrictModeWorkaround.ts b/src/components/Pacs/terribleStrictModeWorkaround.ts index 4f43f74d3..113c4f6f0 100644 --- a/src/components/Pacs/terribleStrictModeWorkaround.ts +++ b/src/components/Pacs/terribleStrictModeWorkaround.ts @@ -10,12 +10,9 @@ import React from "react"; */ export default function terribleStrictModeWorkaround(): (x: T) => boolean { const set = React.useRef(new Set()); - return React.useCallback( - (x) => { - const has = set.current.has(x); - set.current.add(x); - return !has; - }, - [set.current], - ); + return React.useCallback((x) => { + const has = set.current.has(x); + set.current.add(x); + return !has; + }, []); } diff --git a/src/components/Pacs/types.ts b/src/components/Pacs/types.ts index b21c9be80..82d2fa32d 100644 --- a/src/components/Pacs/types.ts +++ b/src/components/Pacs/types.ts @@ -1,7 +1,7 @@ -import { Series, Study } from "../../api/pfdcm/models.ts"; -import { PACSSeries } from "@fnndsc/chrisapi"; -import SeriesMap from "../../api/lonk/seriesMap.ts"; -import { PACSqueryCore } from "../../api/pfdcm"; +import type { Series, Study } from "../../api/pfdcm/models.ts"; +import type { PACSSeries } from "@fnndsc/chrisapi"; +import type SeriesMap from "../../api/lonk/seriesMap.ts"; +import type { PACSqueryCore } from "../../api/pfdcm"; type StudyKey = { pacs_name: string; @@ -56,9 +56,9 @@ enum SeriesPullState { * The states a request can be in. */ enum RequestState { - NOT_REQUESTED, - REQUESTING, - REQUESTED, + NOT_REQUESTED = 0, + REQUESTING = 1, + REQUESTED = 2, } /** From 43d9fd033370b755a53d021cc3972ee9eb06c584 Mon Sep 17 00:00:00 2001 From: Jennings Zhang Date: Thu, 10 Oct 2024 19:40:13 -0400 Subject: [PATCH 36/41] Fix some pypx types --- src/api/pfdcm/client.test.ts | 16 ++++++++++++++++ src/api/pfdcm/client.ts | 35 ++++++++++++++++++++++------------- src/api/pfdcm/models.ts | 6 +++--- 3 files changed, 41 insertions(+), 16 deletions(-) create mode 100644 src/api/pfdcm/client.test.ts diff --git a/src/api/pfdcm/client.test.ts b/src/api/pfdcm/client.test.ts new file mode 100644 index 000000000..6ce066ad5 --- /dev/null +++ b/src/api/pfdcm/client.test.ts @@ -0,0 +1,16 @@ +import { test, expect } from "vitest"; +import { parsePypxDicomDate } from "./client.ts"; + +test.each([ + ["20200203", new Date(2020, 1, 3)], + ["2020-02-03", new Date(2020, 1, 3)], + ["not-a-date", new Date(NaN)], +])("parseDicomDate(%s) -> %o", (value, expected) => { + const pypxTag = { + tag: "0008,0020", + label: "StudyDate", + value, + }; + const actual = parsePypxDicomDate(pypxTag); + expect(actual).toStrictEqual(expected); +}); diff --git a/src/api/pfdcm/client.ts b/src/api/pfdcm/client.ts index 7c1bce08e..6e0f6a029 100644 --- a/src/api/pfdcm/client.ts +++ b/src/api/pfdcm/client.ts @@ -164,18 +164,14 @@ function simplifyPypxStudyData(data: { }): StudyAndSeries { const study = { SpecificCharacterSet: getValue(data, "SpecificCharacterSet"), - StudyDate: - "value" in data.StudyDate ? parseDicomDate(data.StudyDate) : null, + StudyDate: parsePypxDicomDate(data.StudyDate), AccessionNumber: getValue(data, "AccessionNumber"), RetrieveAETitle: getValue(data, "RetrieveAETitle"), ModalitiesInStudy: getValue(data, "ModalitiesInStudy"), StudyDescription: getValue(data, "StudyDescription"), PatientName: getValue(data, "PatientName"), PatientID: getValue(data, "PatientID"), - PatientBirthDate: - "value" in data.PatientBirthDate - ? parseDicomDate(data.PatientBirthDate) - : null, + PatientBirthDate: parsePypxDicomDate(data.PatientBirthDate), PatientSex: getValue(data, "PatientSex"), PatientAge: getValue(data, "PatientAge"), ProtocolName: getValue(data, "ProtocolName"), @@ -185,7 +181,11 @@ function simplifyPypxStudyData(data: { "AcquisitionProtocolDescription", ), StudyInstanceUID: getValue(data, "StudyInstanceUID"), - NumberOfStudyRelatedSeries: getValue(data, "NumberOfStudyRelatedSeries"), + NumberOfStudyRelatedSeries: + "value" in data.NumberOfStudyRelatedSeries && + data.NumberOfStudyRelatedSeries.value !== 0 + ? parseInt(data.NumberOfStudyRelatedSeries.value) + : NaN, PerformedStationAETitle: getValue(data, "PerformedStationAETitle"), }; const series = Array.isArray(data.series) @@ -214,8 +214,8 @@ function simplifyPypxSeriesData(data: { [key: string]: PypxTag }): Series { : parsedNumInstances; return { SpecificCharacterSet: "" + data.SpecificCharacterSet.value, - StudyDate: "" + data.StudyDate.value, - SeriesDate: "" + data.SeriesDate.value, + StudyDate: parsePypxDicomDate(data.StudyDate), + SeriesDate: parsePypxDicomDate(data.SeriesDate), AccessionNumber: "" + data.AccessionNumber.value, RetrieveAETitle: "" + data.RetrieveAETitle.value, Modality: "" + data.Modality.value, @@ -223,7 +223,7 @@ function simplifyPypxSeriesData(data: { [key: string]: PypxTag }): Series { SeriesDescription: "" + data.SeriesDescription.value, PatientName: "" + data.PatientName.value, PatientID: "" + data.PatientID.value, - PatientBirthDate: parseDicomDate(data.PatientBirthDate), + PatientBirthDate: parsePypxDicomDate(data.PatientBirthDate), PatientSex: "" + data.PatientSex.value, PatientAge: "" + data.PatientAge.value, ProtocolName: "" + data.ProtocolName.value, @@ -240,10 +240,19 @@ function simplifyPypxSeriesData(data: { [key: string]: PypxTag }): Series { /** * Parse a DICOM DateString (DS), which is in YYYYMMDD format. * + * The invalid format "YYYY-MM-DD" is also accepted. + * * https://dicom.nema.org/medical/dicom/current/output/chtml/part05/sect_6.2.html */ -function parseDicomDate(tag: PypxTag): Date { - return parseDate("" + tag.value, "yyyyMMdd", new Date()); +function parsePypxDicomDate(tag: PypxTag | object | undefined): Date | null { + if (!tag || !("value" in tag) || tag.value === 0) { + return null; + } + const parsed = parseDate("" + tag.value, "yyyyMMdd", new Date()); + if (!Number.isNaN(parsed.getFullYear())) { + return parsed; + } + return parseDate("" + tag.value, "yyyy-MM-dd", new Date()); } -export { PfdcmClient }; +export { PfdcmClient, parsePypxDicomDate }; diff --git a/src/api/pfdcm/models.ts b/src/api/pfdcm/models.ts index 3b1818f9d..517ca03f6 100644 --- a/src/api/pfdcm/models.ts +++ b/src/api/pfdcm/models.ts @@ -47,7 +47,7 @@ type Study = { AcquisitionProtocolName: string; AcquisitionProtocolDescription: string; StudyInstanceUID: string; - NumberOfStudyRelatedSeries: string; + NumberOfStudyRelatedSeries: number; PerformedStationAETitle: string; }; @@ -56,8 +56,8 @@ type Study = { */ type Series = { SpecificCharacterSet: string; - StudyDate: string; - SeriesDate: string; + StudyDate: Date | null; + SeriesDate: Date | null; AccessionNumber: string; RetrieveAETitle: string; Modality: string; From d0d2bd053a139af2713303ec4bc4858416aa3cdd Mon Sep 17 00:00:00 2001 From: Jennings Zhang Date: Thu, 10 Oct 2024 19:41:35 -0400 Subject: [PATCH 37/41] Automatically expand if only one study found --- .../Pacs/components/PacsStudiesView.test.tsx | 42 ++++++++++++++++- .../Pacs/components/PacsStudiesView.tsx | 7 +++ .../Pacs/components/testData/dai.ts | 46 +++++++++++++++++++ 3 files changed, 93 insertions(+), 2 deletions(-) create mode 100644 src/components/Pacs/components/testData/dai.ts diff --git a/src/components/Pacs/components/PacsStudiesView.test.tsx b/src/components/Pacs/components/PacsStudiesView.test.tsx index 2df7e20e2..a04fa754b 100644 --- a/src/components/Pacs/components/PacsStudiesView.test.tsx +++ b/src/components/Pacs/components/PacsStudiesView.test.tsx @@ -1,3 +1,41 @@ -import { test, expect } from "vitest"; +import { describe, it, vi, expect } from "vitest"; +import { PacsStudyState, SeriesPullState } from "../types"; +import { DAI_SERIES, DAI_STUDY } from "./testData/dai.ts"; +import { render } from "@testing-library/react"; +import PacsStudiesView from "./PacsStudiesView.tsx"; +import { DEFAULT_PREFERENCES } from "../defaultPreferences.ts"; -test.skip("PacsStudiesView", () => {}); +describe("'Pull Study' button", () => { + it("expands first study if there is only one study", async () => { + const studies: PacsStudyState[] = [ + { + info: DAI_STUDY, + series: [ + { + errors: [], + info: DAI_SERIES, + receivedCount: 0, + inCube: null, + pullState: SeriesPullState.NOT_CHECKED, + }, + ], + }, + ]; + const onRetrieve = vi.fn(); + const onStudyExpand = vi.fn(); + + render( + , + ); + expect(onStudyExpand).toHaveBeenCalledOnce(); + expect(onStudyExpand.mock.lastCall?.[0]).toStrictEqual([ + studies[0].info.StudyInstanceUID, + ]); + }); +}); diff --git a/src/components/Pacs/components/PacsStudiesView.tsx b/src/components/Pacs/components/PacsStudiesView.tsx index 4e7c8bc88..beea71572 100644 --- a/src/components/Pacs/components/PacsStudiesView.tsx +++ b/src/components/Pacs/components/PacsStudiesView.tsx @@ -24,6 +24,13 @@ const PacsStudiesView: React.FC = ({ onStudyExpand, preferences, }) => { + React.useEffect(() => { + // Automatically expand first study if there is only one study + if (studies.length === 1 && expandedStudyUids?.length === 0) { + onStudyExpand?.([studies[0].info.StudyInstanceUID]); + } + }, [studies]); + const items: CollapseProps["items"] = React.useMemo(() => { return studies.map(({ info, series }) => { return { diff --git a/src/components/Pacs/components/testData/dai.ts b/src/components/Pacs/components/testData/dai.ts new file mode 100644 index 000000000..59d626996 --- /dev/null +++ b/src/components/Pacs/components/testData/dai.ts @@ -0,0 +1,46 @@ +import { Study, Series } from "../../../../api/pfdcm/models"; + +const DAI_STUDY: Study = { + SpecificCharacterSet: "ISO_IR 100", + StudyDate: null, + AccessionNumber: "123abc", + RetrieveAETitle: "HOSPITALNAME", + ModalitiesInStudy: "CR", + StudyDescription: "Chest X-ray for COVID-19 Screening", + PatientName: "George Smith", + PatientID: "DAI000290", + PatientBirthDate: null, + PatientSex: "M", + PatientAge: "71", + ProtocolName: "no value provided for 0018,1030", + AcquisitionProtocolName: "no value provided for 0018,9423", + AcquisitionProtocolDescription: "no value provided for 0018,9424", + StudyInstanceUID: "1.2.276.0.7230010.3.1.2.8323329.8519.1517874337.873082", + NumberOfStudyRelatedSeries: 1, + PerformedStationAETitle: "no value provided for 0040,0241", +}; + +const DAI_SERIES: Series = { + SpecificCharacterSet: "ISO_IR 100", + StudyDate: DAI_STUDY.StudyDate, + SeriesDate: DAI_STUDY.StudyDate, + AccessionNumber: "123abc", + RetrieveAETitle: "HOSPITALNAME", + Modality: "CR", + StudyDescription: "Chest X-ray for COVID-19 Screening", + SeriesDescription: "Series Description: Unknown", + PatientName: "George Smith", + PatientID: "DAI000290", + PatientBirthDate: new Date(1950, 2, 7), + PatientSex: "M", + PatientAge: "71", + ProtocolName: "no value provided for 0018,1030", + AcquisitionProtocolName: "no value provided for 0018,9423", + AcquisitionProtocolDescription: "no value provided for 0018,9424", + StudyInstanceUID: "1.2.276.0.7230010.3.1.2.8323329.8519.1517874337.873082", + SeriesInstanceUID: "1.2.276.0.7230010.3.1.3.8323329.8519.1517874337.873097", + NumberOfSeriesRelatedInstances: 1, + PerformedStationAETitle: "no value provided for 0040,0241", +}; + +export { DAI_STUDY, DAI_SERIES }; From 8a8aedb874131ab676f6727c59ebca5de3e09fc5 Mon Sep 17 00:00:00 2001 From: Jennings Zhang Date: Thu, 10 Oct 2024 22:25:49 -0400 Subject: [PATCH 38/41] Fix infinite useEffects calling lonk.subscribe --- src/api/lonk/useLonk.test.tsx | 8 ++- src/api/lonk/useLonk.ts | 25 +++++-- src/components/Pacs/PacsController.tsx | 91 +++++++++++++++----------- 3 files changed, 79 insertions(+), 45 deletions(-) diff --git a/src/api/lonk/useLonk.test.tsx b/src/api/lonk/useLonk.test.tsx index a3cadb76a..cb9ea0756 100644 --- a/src/api/lonk/useLonk.test.tsx +++ b/src/api/lonk/useLonk.test.tsx @@ -28,7 +28,13 @@ const TestLonkComponent: React.FC = ({ setSubscribedPacsName(result.pacs_name); setSubscribedSeriesUid(result.SeriesInstanceUID); }, - [lonk.subscribe, setSubscribedPacsName, setSubscribedSeriesUid], + [ + lonk.subscribe, + pacs_name, + SeriesInstanceUID, + setSubscribedPacsName, + setSubscribedSeriesUid, + ], ); const unsubscribe = React.useCallback(async () => { await lonk.unsubscribeAll(); diff --git a/src/api/lonk/useLonk.ts b/src/api/lonk/useLonk.ts index 9d358c261..a6035d589 100644 --- a/src/api/lonk/useLonk.ts +++ b/src/api/lonk/useLonk.ts @@ -1,5 +1,5 @@ import Client, { DownloadToken } from "@fnndsc/chrisapi"; -import useWebSocket, { Options, ReadyState } from "react-use-websocket"; +import useWebSocket, { Options } from "react-use-websocket"; import { LonkHandlers, SeriesKey } from "./types.ts"; import React from "react"; import LonkSubscriber from "./LonkSubscriber.ts"; @@ -56,19 +56,30 @@ function useLonk({ const getLonkUrl = React.useCallback(async () => { const downloadToken = await client.createDownloadToken(); return getWebsocketUrl(downloadToken); - }, [client, getWebsocketUrl]); + }, [client.createDownloadToken]); const handlers = { onDone, onProgress, onError, onMessageError }; - const [subscriber, _setSubscriber] = React.useState( + const [subscriber, setSubscriber] = React.useState( new LonkSubscriber(handlers), ); const onMessage = React.useCallback( (event: MessageEvent) => { subscriber.handle(event.data); }, - [onProgress, onDone, onError], + [subscriber.handle], + ); + const onOpen = React.useCallback( + (event: WebSocketEventMap["open"]) => { + // when the websocket connection (re-)opens, (re-)initialize the + // LonkSubscriber instance so that React.useEffect which specify + // the subscriber in their depdencency arrays get (re-)triggered. + setSubscriber(new LonkSubscriber(handlers)); + options.onOpen?.(event); + }, + [options.onOpen], ); const hook = useWebSocket(getLonkUrl, { ...options, + onOpen, onError: onWebsocketError, onMessage, }); @@ -76,12 +87,14 @@ function useLonk({ const subscribe = React.useCallback( (pacs_name: string, SeriesInstanceUID: string) => subscriber.subscribe(pacs_name, SeriesInstanceUID, hook), - [subscriber, hook], + // N.B.: hook must not be in the dependency array, because it changes + // each time the websocket sends/receives data. + [subscriber.subscribe], ); const unsubscribeAll = React.useCallback( () => subscriber.unsubscribeAll(hook), - [subscriber, hook], + [subscriber.unsubscribeAll], ); return { diff --git a/src/components/Pacs/PacsController.tsx b/src/components/Pacs/PacsController.tsx index f7862bb36..6354d42c0 100644 --- a/src/components/Pacs/PacsController.tsx +++ b/src/components/Pacs/PacsController.tsx @@ -418,47 +418,58 @@ const PacsController: React.FC = ({ const lonk = useLonk({ client: chrisClient, - onDone(pacs_name: string, SeriesInstanceUID: string) { - updateReceiveState(pacs_name, SeriesInstanceUID, (draft) => { - draft.done = true; - }); - }, - onProgress(pacs_name: string, SeriesInstanceUID: string, ndicom: number) { - updateReceiveState(pacs_name, SeriesInstanceUID, (draft) => { - draft.receivedCount = ndicom; - }); - }, - onError(pacs_name: string, SeriesInstanceUID: string, error: string) { - updateReceiveState(pacs_name, SeriesInstanceUID, (draft) => { - draft.errors.push(error); - }); - const desc = getSeriesDescriptionOr(pacs_name, SeriesInstanceUID); - message.error( - <>There was an error while receiving the series "{desc}", - ); - }, - onMessageError(data: any, error: string) { - console.error("LONK message error", error, data); - message.error( - <> - A LONK error occurred, please check the console. - , - ); - }, + onDone: React.useCallback( + (pacs_name: string, SeriesInstanceUID: string) => + updateReceiveState(pacs_name, SeriesInstanceUID, (draft) => { + draft.done = true; + }), + [updateReceiveState], + ), + onProgress: React.useCallback( + (pacs_name: string, SeriesInstanceUID: string, ndicom: number) => + updateReceiveState(pacs_name, SeriesInstanceUID, (draft) => { + draft.receivedCount = ndicom; + }), + [updateReceiveState], + ), + onError: React.useCallback( + (pacs_name: string, SeriesInstanceUID: string, error: string) => { + updateReceiveState(pacs_name, SeriesInstanceUID, (draft) => { + draft.errors.push(error); + }); + const desc = getSeriesDescriptionOr(pacs_name, SeriesInstanceUID); + message.error( + <>There was an error while receiving the series "{desc}", + ); + }, + [updateReceiveState, getSeriesDescriptionOr, message.error], + ), + onMessageError: React.useCallback( + (data: any, error: string) => { + console.error("LONK message error", error, data); + message.error( + <> + A LONK error occurred, please check the console. + , + ); + }, + [message.error], + ), retryOnError: true, reconnectAttempts: 3, reconnectInterval: 3000, - shouldReconnect(e) { - return e.code < 400 || e.code > 499; - }, - onReconnectStop() { - setWsError(<>The WebSocket is disconnected.); - }, - onWebsocketError() { - message.error( - <>There was an error with the WebSocket. Reconnecting…, - ); - }, + shouldReconnect: errorCodeIs4xx, + onReconnectStop: React.useCallback( + () => setWsError(<>The WebSocket is disconnected.), + [setWsError], + ), + onWebsocketError: React.useCallback( + () => + message.error( + <>There was an error with the WebSocket. Reconnecting…, + ), + [message.error], + ), }); // ======================================== @@ -640,5 +651,9 @@ const PacsController: React.FC = ({ ); }; +function errorCodeIs4xx(e: { code: number }) { + return e.code < 400 || e.code > 499; +} + export type { PacsControllerProps }; export default PacsController; From c0001cd00ce2489eae22afb0b06d64e62e9e6661 Mon Sep 17 00:00:00 2001 From: Jennings Zhang Date: Thu, 10 Oct 2024 23:33:16 -0400 Subject: [PATCH 39/41] Add tests --- .../Pacs/components/PacsStudiesView.test.tsx | 156 +++++++-- .../Pacs/components/PacsStudiesView.tsx | 11 +- .../Pacs/components/SeriesList.test.tsx | 22 ++ src/components/Pacs/components/SeriesList.tsx | 156 +-------- .../Pacs/components/SeriesRow.test.tsx | 78 +++++ src/components/Pacs/components/SeriesRow.tsx | 155 +++++++++ .../Pacs/components/testData/remind.ts | 318 ++++++++++++++++++ 7 files changed, 715 insertions(+), 181 deletions(-) create mode 100644 src/components/Pacs/components/SeriesList.test.tsx create mode 100644 src/components/Pacs/components/SeriesRow.test.tsx create mode 100644 src/components/Pacs/components/SeriesRow.tsx create mode 100644 src/components/Pacs/components/testData/remind.ts diff --git a/src/components/Pacs/components/PacsStudiesView.test.tsx b/src/components/Pacs/components/PacsStudiesView.test.tsx index a04fa754b..eda3ca2fc 100644 --- a/src/components/Pacs/components/PacsStudiesView.test.tsx +++ b/src/components/Pacs/components/PacsStudiesView.test.tsx @@ -1,41 +1,147 @@ -import { describe, it, vi, expect } from "vitest"; -import { PacsStudyState, SeriesPullState } from "../types"; +import { describe, expect, it, test, vi } from "vitest"; +import { PacsSeriesState, PacsStudyState, SeriesPullState } from "../types"; import { DAI_SERIES, DAI_STUDY } from "./testData/dai.ts"; -import { render } from "@testing-library/react"; +import { render, screen } from "@testing-library/react"; import PacsStudiesView from "./PacsStudiesView.tsx"; import { DEFAULT_PREFERENCES } from "../defaultPreferences.ts"; +import REMIND_STUDIES from "./testData/remind.ts"; + +test("First study should be expanded if there is only one study", async () => { + const studies: PacsStudyState[] = [ + { + info: DAI_STUDY, + series: [ + { + errors: [], + info: DAI_SERIES, + receivedCount: 0, + inCube: null, + pullState: SeriesPullState.NOT_CHECKED, + }, + ], + }, + ]; + const onRetrieve = vi.fn(); + const onStudyExpand = vi.fn(); + + render( + , + ); + expect(onStudyExpand).toHaveBeenCalledOnce(); + expect(onStudyExpand.mock.lastCall?.[0]).toStrictEqual([ + studies[0].info.StudyInstanceUID, + ]); +}); describe("'Pull Study' button", () => { - it("expands first study if there is only one study", async () => { - const studies: PacsStudyState[] = [ - { - info: DAI_STUDY, - series: [ - { - errors: [], - info: DAI_SERIES, - receivedCount: 0, - inCube: null, - pullState: SeriesPullState.NOT_CHECKED, - }, - ], - }, - ]; + it("should be clickable if not expanded, and should call both `onRetrieve` and `onStudyExpand` when clicked", async () => { + const studies: PacsStudyState[] = remindStartingState(); + // Start off with the first study expanded and all of their series + // pending check for existence in CUBE. + const initiallyExpandedStudyUids = [studies[0].info.StudyInstanceUID]; + studies[0].series = studies[0].series.map((state) => ({ + ...state, + pullState: SeriesPullState.CHECKING, + })); const onRetrieve = vi.fn(); const onStudyExpand = vi.fn(); - render( , ); - expect(onStudyExpand).toHaveBeenCalledOnce(); - expect(onStudyExpand.mock.lastCall?.[0]).toStrictEqual([ - studies[0].info.StudyInstanceUID, - ]); + const pullStudyButtons = screen.getAllByTitle("Pull study"); + expect(pullStudyButtons).toHaveLength(studies.length); + const loadingIcons = screen.getAllByLabelText("loading"); + expect( + // First "Pull Study" button should contain a loading indicator, + // because its series are currently `SeriesPullState.CHECKING` + loadingIcons.find((ele) => pullStudyButtons[0].contains(ele)), + ).toBeTruthy(); + expect( + // Second "Pull Study" button should not contain a loading indicator, + // because it is not expanded. + loadingIcons.find((ele) => pullStudyButtons[1].contains(ele)), + ).toBeUndefined(); + pullStudyButtons[1].click(); + await expect.poll(() => onRetrieve).toHaveBeenCalledOnce(); + expect(onRetrieve).toHaveBeenCalledWith({ + patientID: studies[1].info.PatientID, + studyInstanceUID: studies[1].info.StudyInstanceUID, + }); + await expect.poll(() => onStudyExpand).toHaveBeenCalledOnce(); + expect(onStudyExpand).toHaveBeenCalledWith( + initiallyExpandedStudyUids.concat([studies[1].info.StudyInstanceUID]), + ); }); + + it.each([ + [ + { + pullState: SeriesPullState.CHECKING, + }, + ], + [ + { + pullState: SeriesPullState.WAITING_OR_COMPLETE, + inCube: null, + }, + ], + [ + { + pullState: SeriesPullState.PULLING, + }, + ], + [ + { + pullState: SeriesPullState.CHECKING, + }, + ], + ])( + "should be loading when any series has partial state %o", + async (state: Partial>) => { + const studies = remindStartingState(); + studies[0].series[2] = { ...studies[0].series[2], ...state }; + const onRetrieve = vi.fn(); + render( + , + ); + const pullStudyButtons = screen.getAllByTitle("Pull study"); + const pullStudyButton = pullStudyButtons[0]; + const loadingIcons = screen.getAllByLabelText("loading"); + expect( + // First "Pull Study" button should contain a loading indicator, + loadingIcons.find((ele) => pullStudyButton.contains(ele)), + ).toBeTruthy(); + }, + ); }); + +function remindStartingState(): PacsStudyState[] { + return REMIND_STUDIES.map(({ study, series }) => ({ + info: study, + series: series.map((info) => ({ + info, + errors: [], + receivedCount: 0, + pullState: SeriesPullState.NOT_CHECKED, + inCube: null, + })), + })); +} diff --git a/src/components/Pacs/components/PacsStudiesView.tsx b/src/components/Pacs/components/PacsStudiesView.tsx index beea71572..cf46f5219 100644 --- a/src/components/Pacs/components/PacsStudiesView.tsx +++ b/src/components/Pacs/components/PacsStudiesView.tsx @@ -46,12 +46,17 @@ const PacsStudiesView: React.FC = ({ isLoading={ series.length === 0 ? false : !!series.find(isSeriesLoading) } - onRetrieve={() => + onRetrieve={() => { + // When a study is retrieved, we want to call both + // onStudyExpand and onRetrieve + if (expandedStudyUids && onStudyExpand) { + onStudyExpand(expandedStudyUids.concat(info.StudyInstanceUID)); + } onRetrieve({ patientID: info.PatientID, studyInstanceUID: info.StudyInstanceUID, - }) - } + }); + }} /> ), children: ( diff --git a/src/components/Pacs/components/SeriesList.test.tsx b/src/components/Pacs/components/SeriesList.test.tsx new file mode 100644 index 000000000..44f2f8bfb --- /dev/null +++ b/src/components/Pacs/components/SeriesList.test.tsx @@ -0,0 +1,22 @@ +import { expect, test, vi } from "vitest"; +import { SeriesPullState } from "../types.ts"; +import { render, screen } from "@testing-library/react"; +import REMIND_STUDIES from "./testData/remind.ts"; +import SeriesList from "./SeriesList.tsx"; + +test("DICOM series should be ready and then fire onRetrieve when clicked", async () => { + const onRetrieve = vi.fn(); + const states = REMIND_STUDIES[0].series.map((info) => ({ + info, + errors: [], + inCube: null, + pullState: SeriesPullState.READY, + receivedCount: 0, + })); + render(); + const buttons = screen.getAllByRole("button"); + const thirdButton = buttons[2]; + thirdButton.click(); + await expect.poll(() => onRetrieve).toHaveBeenCalledOnce(); + expect(onRetrieve).toHaveBeenCalledWith(states[2]); +}); diff --git a/src/components/Pacs/components/SeriesList.tsx b/src/components/Pacs/components/SeriesList.tsx index 476c80c9e..3c26db067 100644 --- a/src/components/Pacs/components/SeriesList.tsx +++ b/src/components/Pacs/components/SeriesList.tsx @@ -1,20 +1,7 @@ -import { - Button, - type ButtonProps, - Descriptions, - Flex, - Grid, - List, - Progress, - Tooltip, - Typography, -} from "antd"; -import { type PacsSeriesState, SeriesPullState } from "../types.ts"; -import ModalityBadges from "./ModalityBadges.tsx"; -import { ImportOutlined, WarningFilled } from "@ant-design/icons"; -import styles from "./SeriesList.module.css"; +import { List } from "antd"; +import type { PacsSeriesState } from "../types.ts"; import React from "react"; -import { isSeriesLoading } from "./helpers.ts"; +import SeriesRow from "./SeriesRow.tsx"; type SeriesListProps = { states: PacsSeriesState[]; @@ -22,143 +9,6 @@ type SeriesListProps = { onRetrieve?: (state: PacsSeriesState) => void; }; -type SeriesRowProps = PacsSeriesState & { - showUid?: boolean; - onRetrieve?: () => void; -}; - -const SeriesRow: React.FC = ({ - info, - errors, - pullState, - inCube, - receivedCount, - showUid, - onRetrieve, -}) => { - const isLoading = React.useMemo( - () => isSeriesLoading({ pullState, inCube }), - [pullState, inCube], - ); - - const tooltipTitle = React.useMemo(() => { - if (errors.length > 0) { - return <>Error: {errors[0]}; - } - if (pullState === SeriesPullState.NOT_CHECKED) { - return <>Not ready.; - } - if (pullState === SeriesPullState.CHECKING) { - return <>Checking availability…; - } - if (pullState === SeriesPullState.READY) { - return ( - <> - Pull "{info.SeriesDescription}" into ChRIS. - - ); - } - if (pullState === SeriesPullState.PULLING) { - return <>Receiving…; - } - if (inCube === null) { - return <>Waiting...; - } - return ( - <> - This series is available in ChRIS. - - ); - }, [errors, info, pullState, inCube]); - - const buttonColor = React.useMemo((): ButtonProps["color"] => { - if (errors.length > 0) { - return "danger"; - } - if (pullState === SeriesPullState.READY) { - return "primary"; - } - return "default"; - }, [errors, pullState]); - - const percentDone = React.useMemo(() => { - if (inCube) { - return 100; - } - if (pullState === SeriesPullState.WAITING_OR_COMPLETE) { - return 99; - } - return ( - (99 * receivedCount) / - (info.NumberOfSeriesRelatedInstances || Number.POSITIVE_INFINITY) - ); - }, [inCube, pullState, receivedCount, info.NumberOfSeriesRelatedInstances]); - - return ( - -
- -
-
- - {info.SeriesDescription.trim()} - -
-
- - {info.NumberOfSeriesRelatedInstances === 1 - ? "1 file" - : `${info.NumberOfSeriesRelatedInstances === null ? "?" : info.NumberOfSeriesRelatedInstances} files`} - -
-
- {/* TODO Progress 100% text color should be changed from dark blue */} - `${Math.round(n ?? 0)}%`} - percent={percentDone} - status={ - errors.length > 0 ? "exception" : inCube ? "success" : "normal" - } - /> -
-
- - - -
- {showUid && ( - - - {info.SeriesInstanceUID} - - - )} -
- ); -}; - const SeriesList: React.FC = ({ states, showUid, diff --git a/src/components/Pacs/components/SeriesRow.test.tsx b/src/components/Pacs/components/SeriesRow.test.tsx new file mode 100644 index 000000000..ca802e2a1 --- /dev/null +++ b/src/components/Pacs/components/SeriesRow.test.tsx @@ -0,0 +1,78 @@ +import { describe, it, expect, vi } from "vitest"; +import SeriesRow, { type SeriesRowProps } from "./SeriesRow.tsx"; +import { render, screen } from "@testing-library/react"; +import { DAI_SERIES } from "./testData/dai.ts"; +import { type PacsSeriesState, SeriesPullState } from "../types.ts"; +import { PACSSeries } from "@fnndsc/chrisapi"; + +const DEFAULT_STATE: PacsSeriesState = { + info: DAI_SERIES, + errors: [], + pullState: SeriesPullState.NOT_CHECKED, + inCube: null, + receivedCount: 0, +}; + +describe("'Pull Series' button", () => { + it.each([ + [ + { + pullState: SeriesPullState.CHECKING, + }, + ], + [ + { + pullState: SeriesPullState.PULLING, + }, + ], + [ + { + pullState: SeriesPullState.WAITING_OR_COMPLETE, + inCube: null, + }, + ], + ])( + "should be loading", + async ( + state: Partial>, + ) => { + const props = { ...DEFAULT_STATE, ...state }; + render(); + const button = screen.getByRole("button"); + expect(button.getAttribute("color")).toBe("default"); + const loadingIcon = screen.getByLabelText("loading"); + expect(button.contains(loadingIcon)).toBe(true); + }, + ); + + it("should be done pulling", async () => { + const props = { + ...DEFAULT_STATE, + pullState: SeriesPullState.WAITING_OR_COMPLETE, + inCube: new PACSSeries("https://example.com/api/v1/pacs/series/5/", { + token: "abc123", + }), + receivedCount: DEFAULT_STATE.info + .NumberOfSeriesRelatedInstances as number, + }; + render(); + const button = screen.getByRole("button"); + expect(button.getAttribute("color")).toBe("default"); + }); + + it("should be waiting at 99% while CUBE task to register the DICOM series is pending", async () => { + const props = { + ...DEFAULT_STATE, + pullState: SeriesPullState.WAITING_OR_COMPLETE, + inCube: null, + receivedCount: DEFAULT_STATE.info + .NumberOfSeriesRelatedInstances as number, + }; + render(); + const button = screen.getByRole("button"); + expect(button.getAttribute("color")).toBe("default"); + const loadingIcon = screen.getByLabelText("loading"); + expect(button.contains(loadingIcon)).toBe(true); + expect(screen.getByText("99%")).toBeInTheDocument(); + }); +}); diff --git a/src/components/Pacs/components/SeriesRow.tsx b/src/components/Pacs/components/SeriesRow.tsx new file mode 100644 index 000000000..f35b6ddf9 --- /dev/null +++ b/src/components/Pacs/components/SeriesRow.tsx @@ -0,0 +1,155 @@ +import { PacsSeriesState, SeriesPullState } from "../types.ts"; +import React from "react"; +import { isSeriesLoading } from "./helpers.ts"; +import { + Button, + ButtonProps, + Descriptions, + Flex, + Grid, + Progress, + Tooltip, + Typography, +} from "antd"; +import styles from "./SeriesList.module.css"; +import ModalityBadges from "./ModalityBadges.tsx"; +import { ImportOutlined, WarningFilled } from "@ant-design/icons"; + +type SeriesRowProps = PacsSeriesState & { + showUid?: boolean; + onRetrieve?: () => void; +}; + +const SeriesRow: React.FC = ({ + info, + errors, + pullState, + inCube, + receivedCount, + showUid, + onRetrieve, +}) => { + const isLoading = React.useMemo( + () => isSeriesLoading({ pullState, inCube }), + [pullState, inCube], + ); + + const tooltipTitle = React.useMemo(() => { + if (errors.length > 0) { + return <>Error: {errors[0]}; + } + if (pullState === SeriesPullState.NOT_CHECKED) { + return <>Not ready.; + } + if (pullState === SeriesPullState.CHECKING) { + return <>Checking availability…; + } + if (pullState === SeriesPullState.READY) { + return ( + <> + Pull "{info.SeriesDescription}" into ChRIS. + + ); + } + if (pullState === SeriesPullState.PULLING) { + return <>Receiving…; + } + if (inCube === null) { + return <>Waiting...; + } + return ( + <> + This series is available in ChRIS. + + ); + }, [errors, info, pullState, inCube]); + + const buttonColor = React.useMemo((): ButtonProps["color"] => { + if (errors.length > 0) { + return "danger"; + } + if (pullState === SeriesPullState.READY) { + return "primary"; + } + return "default"; + }, [errors, pullState]); + + const percentDone = React.useMemo(() => { + if (inCube) { + return 100; + } + if (pullState === SeriesPullState.WAITING_OR_COMPLETE) { + return 99; + } + return ( + (99 * receivedCount) / + (info.NumberOfSeriesRelatedInstances || Number.POSITIVE_INFINITY) + ); + }, [inCube, pullState, receivedCount, info.NumberOfSeriesRelatedInstances]); + + return ( + +
+ +
+
+ + {info.SeriesDescription.trim()} + +
+
+ + {info.NumberOfSeriesRelatedInstances === 1 + ? "1 file" + : `${info.NumberOfSeriesRelatedInstances === null ? "?" : info.NumberOfSeriesRelatedInstances} files`} + +
+
+ `${Math.round(n ?? 0)}%`} + percent={percentDone} + status={ + errors.length > 0 ? "exception" : inCube ? "success" : "normal" + } + /> +
+
+ + + +
+ {showUid && ( + + + {info.SeriesInstanceUID} + + + )} +
+ ); +}; + +export type { SeriesRowProps }; +export default SeriesRow; diff --git a/src/components/Pacs/components/testData/remind.ts b/src/components/Pacs/components/testData/remind.ts new file mode 100644 index 000000000..94de5de7d --- /dev/null +++ b/src/components/Pacs/components/testData/remind.ts @@ -0,0 +1,318 @@ +import { StudyAndSeries } from "../../../../api/pfdcm/models.ts"; + +const REMIND_STUDIES: ReadonlyArray = [ + { + study: { + SpecificCharacterSet: "ISO_IR 100", + StudyDate: new Date(1982, 11, 25), + AccessionNumber: "no value provided for 0008,0050", + RetrieveAETitle: "TCIA", + ModalitiesInStudy: "MR\\SEG\\US", + StudyDescription: "Intraop", + PatientName: "ReMIND-042", + PatientID: "ReMIND-042", + PatientBirthDate: null, + PatientSex: "no value provided for 0010,0040", + PatientAge: "no value provided for 0010,1010", + ProtocolName: "no value provided for 0018,1030", + AcquisitionProtocolName: "no value provided for 0018,9423", + AcquisitionProtocolDescription: "no value provided for 0018,9424", + StudyInstanceUID: + "1.3.6.1.4.1.14519.5.2.1.296457429224646492865587946336300319226", + NumberOfStudyRelatedSeries: 7, + PerformedStationAETitle: "no value provided for 0040,0241", + }, + series: [ + { + SpecificCharacterSet: "ISO_IR 100", + StudyDate: new Date(1982, 11, 25), + SeriesDate: new Date(2007, 2, 8), + AccessionNumber: "no value provided for 0008,0050", + RetrieveAETitle: "TCIA", + Modality: "MR", + StudyDescription: "Intraop", + SeriesDescription: "3D_AX_T1_precontrast", + PatientName: "ReMIND-042", + PatientID: "ReMIND-042", + PatientBirthDate: null, + PatientSex: "no value provided for 0010,0040", + PatientAge: "no value provided for 0010,1010", + ProtocolName: "no value provided for 0018,1030", + AcquisitionProtocolName: "no value provided for 0018,9423", + AcquisitionProtocolDescription: "no value provided for 0018,9424", + StudyInstanceUID: + "1.3.6.1.4.1.14519.5.2.1.296457429224646492865587946336300319226", + SeriesInstanceUID: + "1.3.6.1.4.1.14519.5.2.1.196263483769985307058907929198160856331", + NumberOfSeriesRelatedInstances: 176, + PerformedStationAETitle: "no value provided for 0040,0241", + }, + { + SpecificCharacterSet: "ISO_IR 100", + StudyDate: new Date(1982, 11, 25), + SeriesDate: new Date(2007, 2, 8), + AccessionNumber: "no value provided for 0008,0050", + RetrieveAETitle: "TCIA", + Modality: "MR", + StudyDescription: "Intraop", + SeriesDescription: "2D_AX_T2_FLAIR", + PatientName: "ReMIND-042", + PatientID: "ReMIND-042", + PatientBirthDate: null, + PatientSex: "no value provided for 0010,0040", + PatientAge: "no value provided for 0010,1010", + ProtocolName: "no value provided for 0018,1030", + AcquisitionProtocolName: "no value provided for 0018,9423", + AcquisitionProtocolDescription: "no value provided for 0018,9424", + StudyInstanceUID: + "1.3.6.1.4.1.14519.5.2.1.296457429224646492865587946336300319226", + SeriesInstanceUID: + "1.3.6.1.4.1.14519.5.2.1.128134097836608447545606883174310323519", + NumberOfSeriesRelatedInstances: 74, + PerformedStationAETitle: "no value provided for 0040,0241", + }, + { + SpecificCharacterSet: "ISO_IR 100", + StudyDate: new Date(1982, 11, 25), + SeriesDate: new Date(2007, 2, 8), + AccessionNumber: "no value provided for 0008,0050", + RetrieveAETitle: "TCIA", + Modality: "US", + StudyDescription: "Intraop", + SeriesDescription: "US_pre_dura", + PatientName: "ReMIND-042", + PatientID: "ReMIND-042", + PatientBirthDate: null, + PatientSex: "no value provided for 0010,0040", + PatientAge: "no value provided for 0010,1010", + ProtocolName: "no value provided for 0018,1030", + AcquisitionProtocolName: "no value provided for 0018,9423", + AcquisitionProtocolDescription: "no value provided for 0018,9424", + StudyInstanceUID: + "1.3.6.1.4.1.14519.5.2.1.296457429224646492865587946336300319226", + SeriesInstanceUID: + "1.3.6.1.4.1.14519.5.2.1.95314485457128412592248919688824142597", + NumberOfSeriesRelatedInstances: 1, + PerformedStationAETitle: "no value provided for 0040,0241", + }, + { + SpecificCharacterSet: "ISO_IR 100", + StudyDate: new Date(1982, 11, 25), + SeriesDate: new Date(2007, 2, 8), + AccessionNumber: "no value provided for 0008,0050", + RetrieveAETitle: "TCIA", + Modality: "SEG", + StudyDescription: "Intraop", + SeriesDescription: "tumor_residual seg - MR ref: 2D_AX_T2_FLAIR", + PatientName: "ReMIND-042", + PatientID: "ReMIND-042", + PatientBirthDate: null, + PatientSex: "no value provided for 0010,0040", + PatientAge: "no value provided for 0010,1010", + ProtocolName: "no value provided for 0018,1030", + AcquisitionProtocolName: "no value provided for 0018,9423", + AcquisitionProtocolDescription: "no value provided for 0018,9424", + StudyInstanceUID: + "1.3.6.1.4.1.14519.5.2.1.296457429224646492865587946336300319226", + SeriesInstanceUID: + "1.3.6.1.4.1.14519.5.2.1.206657195335953324371356600554221913476", + NumberOfSeriesRelatedInstances: 1, + PerformedStationAETitle: "no value provided for 0040,0241", + }, + { + SpecificCharacterSet: "ISO_IR 100", + StudyDate: new Date(1982, 11, 25), + SeriesDate: new Date(2007, 2, 8), + AccessionNumber: "no value provided for 0008,0050", + RetrieveAETitle: "TCIA", + Modality: "MR", + StudyDescription: "Intraop", + SeriesDescription: "2D_AX_T2_BLADE", + PatientName: "ReMIND-042", + PatientID: "ReMIND-042", + PatientBirthDate: null, + PatientSex: "no value provided for 0010,0040", + PatientAge: "no value provided for 0010,1010", + ProtocolName: "no value provided for 0018,1030", + AcquisitionProtocolName: "no value provided for 0018,9423", + AcquisitionProtocolDescription: "no value provided for 0018,9424", + StudyInstanceUID: + "1.3.6.1.4.1.14519.5.2.1.296457429224646492865587946336300319226", + SeriesInstanceUID: + "1.3.6.1.4.1.14519.5.2.1.150626935733334738403360637524565939970", + NumberOfSeriesRelatedInstances: 70, + PerformedStationAETitle: "no value provided for 0040,0241", + }, + { + SpecificCharacterSet: "ISO_IR 100", + StudyDate: new Date(1982, 11, 25), + SeriesDate: new Date(2007, 2, 8), + AccessionNumber: "no value provided for 0008,0050", + RetrieveAETitle: "TCIA", + Modality: "US", + StudyDescription: "Intraop", + SeriesDescription: "US_pre_imri", + PatientName: "ReMIND-042", + PatientID: "ReMIND-042", + PatientBirthDate: null, + PatientSex: "no value provided for 0010,0040", + PatientAge: "no value provided for 0010,1010", + ProtocolName: "no value provided for 0018,1030", + AcquisitionProtocolName: "no value provided for 0018,9423", + AcquisitionProtocolDescription: "no value provided for 0018,9424", + StudyInstanceUID: + "1.3.6.1.4.1.14519.5.2.1.296457429224646492865587946336300319226", + SeriesInstanceUID: + "1.3.6.1.4.1.14519.5.2.1.190917077277426833412602956837317094217", + NumberOfSeriesRelatedInstances: 1, + PerformedStationAETitle: "no value provided for 0040,0241", + }, + { + SpecificCharacterSet: "ISO_IR 100", + StudyDate: new Date(1982, 11, 25), + SeriesDate: new Date(2007, 2, 8), + AccessionNumber: "no value provided for 0008,0050", + RetrieveAETitle: "TCIA", + Modality: "US", + StudyDescription: "Intraop", + SeriesDescription: "US_post_dura", + PatientName: "ReMIND-042", + PatientID: "ReMIND-042", + PatientBirthDate: null, + PatientSex: "no value provided for 0010,0040", + PatientAge: "no value provided for 0010,1010", + ProtocolName: "no value provided for 0018,1030", + AcquisitionProtocolName: "no value provided for 0018,9423", + AcquisitionProtocolDescription: "no value provided for 0018,9424", + StudyInstanceUID: + "1.3.6.1.4.1.14519.5.2.1.296457429224646492865587946336300319226", + SeriesInstanceUID: + "1.3.6.1.4.1.14519.5.2.1.339448507477226772617616220052572153109", + NumberOfSeriesRelatedInstances: 1, + PerformedStationAETitle: "no value provided for 0040,0241", + }, + ], + }, + { + study: { + SpecificCharacterSet: "ISO_IR 100", + StudyDate: new Date(1982, 11, 25), + AccessionNumber: "no value provided for 0008,0050", + RetrieveAETitle: "TCIA", + ModalitiesInStudy: "MR\\SEG", + StudyDescription: "Preop", + PatientName: "ReMIND-042", + PatientID: "ReMIND-042", + PatientBirthDate: null, + PatientSex: "no value provided for 0010,0040", + PatientAge: "no value provided for 0010,1010", + ProtocolName: "no value provided for 0018,1030", + AcquisitionProtocolName: "no value provided for 0018,9423", + AcquisitionProtocolDescription: "no value provided for 0018,9424", + StudyInstanceUID: + "1.3.6.1.4.1.14519.5.2.1.18473209367186901845307845336434276052", + NumberOfStudyRelatedSeries: 4, + PerformedStationAETitle: "no value provided for 0040,0241", + }, + series: [ + { + SpecificCharacterSet: "ISO_IR 100", + StudyDate: new Date(1982, 11, 25), + SeriesDate: new Date(2007, 2, 8), + AccessionNumber: "no value provided for 0008,0050", + RetrieveAETitle: "TCIA", + Modality: "MR", + StudyDescription: "Preop", + SeriesDescription: "3D_AX_T1_postcontrast", + PatientName: "ReMIND-042", + PatientID: "ReMIND-042", + PatientBirthDate: null, + PatientSex: "no value provided for 0010,0040", + PatientAge: "no value provided for 0010,1010", + ProtocolName: "no value provided for 0018,1030", + AcquisitionProtocolName: "no value provided for 0018,9423", + AcquisitionProtocolDescription: "no value provided for 0018,9424", + StudyInstanceUID: + "1.3.6.1.4.1.14519.5.2.1.18473209367186901845307845336434276052", + SeriesInstanceUID: + "1.3.6.1.4.1.14519.5.2.1.43670003352813769538878303243732046742", + NumberOfSeriesRelatedInstances: 176, + PerformedStationAETitle: "no value provided for 0040,0241", + }, + { + SpecificCharacterSet: "ISO_IR 100", + StudyDate: new Date(1982, 11, 25), + SeriesDate: new Date(2007, 2, 8), + AccessionNumber: "no value provided for 0008,0050", + RetrieveAETitle: "TCIA", + Modality: "SEG", + StudyDescription: "Preop", + SeriesDescription: "tumor seg - MR ref: 2D_AX_T2_FLAIR", + PatientName: "ReMIND-042", + PatientID: "ReMIND-042", + PatientBirthDate: null, + PatientSex: "no value provided for 0010,0040", + PatientAge: "no value provided for 0010,1010", + ProtocolName: "no value provided for 0018,1030", + AcquisitionProtocolName: "no value provided for 0018,9423", + AcquisitionProtocolDescription: "no value provided for 0018,9424", + StudyInstanceUID: + "1.3.6.1.4.1.14519.5.2.1.18473209367186901845307845336434276052", + SeriesInstanceUID: + "1.3.6.1.4.1.14519.5.2.1.73475684818739340078664911389232338988", + NumberOfSeriesRelatedInstances: 1, + PerformedStationAETitle: "no value provided for 0040,0241", + }, + { + SpecificCharacterSet: "ISO_IR 100", + StudyDate: new Date(1982, 11, 25), + SeriesDate: new Date(2007, 2, 8), + AccessionNumber: "no value provided for 0008,0050", + RetrieveAETitle: "TCIA", + Modality: "MR", + StudyDescription: "Preop", + SeriesDescription: "3D_AX_T2_SPACE", + PatientName: "ReMIND-042", + PatientID: "ReMIND-042", + PatientBirthDate: null, + PatientSex: "no value provided for 0010,0040", + PatientAge: "no value provided for 0010,1010", + ProtocolName: "no value provided for 0018,1030", + AcquisitionProtocolName: "no value provided for 0018,9423", + AcquisitionProtocolDescription: "no value provided for 0018,9424", + StudyInstanceUID: + "1.3.6.1.4.1.14519.5.2.1.18473209367186901845307845336434276052", + SeriesInstanceUID: + "1.3.6.1.4.1.14519.5.2.1.300117359631342126173918428968995787084", + NumberOfSeriesRelatedInstances: 192, + PerformedStationAETitle: "no value provided for 0040,0241", + }, + { + SpecificCharacterSet: "ISO_IR 100", + StudyDate: new Date(1982, 11, 25), + SeriesDate: new Date(2007, 2, 8), + AccessionNumber: "no value provided for 0008,0050", + RetrieveAETitle: "TCIA", + Modality: "MR", + StudyDescription: "Preop", + SeriesDescription: "2D_AX_T2_FLAIR", + PatientName: "ReMIND-042", + PatientID: "ReMIND-042", + PatientBirthDate: null, + PatientSex: "no value provided for 0010,0040", + PatientAge: "no value provided for 0010,1010", + ProtocolName: "no value provided for 0018,1030", + AcquisitionProtocolName: "no value provided for 0018,9423", + AcquisitionProtocolDescription: "no value provided for 0018,9424", + StudyInstanceUID: + "1.3.6.1.4.1.14519.5.2.1.18473209367186901845307845336434276052", + SeriesInstanceUID: + "1.3.6.1.4.1.14519.5.2.1.43182747523173116841423691014813896790", + NumberOfSeriesRelatedInstances: 32, + PerformedStationAETitle: "no value provided for 0040,0241", + }, + ], + }, +]; + +export default REMIND_STUDIES; From 4ede58bf71a90d4bbddfe3212ff24b3e689d9e3e Mon Sep 17 00:00:00 2001 From: Jennings Zhang Date: Thu, 10 Oct 2024 23:35:29 -0400 Subject: [PATCH 40/41] Delete example.test.ts --- src/example.test.ts | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 src/example.test.ts diff --git a/src/example.test.ts b/src/example.test.ts deleted file mode 100644 index f38c31d12..000000000 --- a/src/example.test.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { test, expect } from "vitest"; - -test("example test, delete me when a real test is added", () => { - expect(1).toBe(1); -}); From 2ca52275f54db545835eb5e71d2faa098159f20e Mon Sep 17 00:00:00 2001 From: Jennings Zhang Date: Thu, 10 Oct 2024 23:42:03 -0400 Subject: [PATCH 41/41] Make CUBE_POLL_INTERVAL_MS configurable via environment variable --- .env | 3 +++ .env.production | 2 ++ docker-entrypoint.sh | 2 ++ src/components/Pacs/PacsController.tsx | 3 ++- 4 files changed, 9 insertions(+), 1 deletion(-) diff --git a/.env b/.env index 778314434..cfc768867 100644 --- a/.env +++ b/.env @@ -35,6 +35,9 @@ VITE_PFDCM_URL="http://localhost:4005" # VITE_ACKEE_SERVER="http://localhost:3050" # VITE_ACKEE_DOMAIN_ID="lol-lol-lol" +# How often to poll CUBE, in milliseconds. +VITE_CUBE_POLL_INTERVAL_MS=2000 + VITE_SOURCEMAP='false' # URI for support requests diff --git a/.env.production b/.env.production index 57e1e9353..d391e4ddd 100644 --- a/.env.production +++ b/.env.production @@ -13,6 +13,8 @@ VITE_OHIF_URL="ea1cf042-73f0-43a1-93da-86ff93e5ac19" VITE_ACKEE_SERVER="79a96963-2e17-405e-94bd-4f2433e5cce8" VITE_ACKEE_DOMAIN_ID="e8fe722b-986c-4aaf-ba04-c10d21e4aca1" +VITE_CUBE_POLL_INTERVAL_MS="ca4bef0b-95c9-4a02-abda-590b51b9b07e" + VITE_ALPHA_FEATURES='production' VITE_SOURCEMAP='false' diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index eb717cb1d..204de02e4 100755 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -45,6 +45,7 @@ find -type d -exec mkdir -p "$target/{}" \; # set default values CHRIS_STORE_URL="${CHRIS_STORE_URL-https://cube.chrisproject.org/api/v1/}" +CUBE_POLL_INTERVAL_MS="${CUBE_POLL_INTERVAL_MS-2000}" # OHIF_URL, ACKEE_SERVER, ACKEE_DOMAIN_ID default values are empty # required values @@ -66,6 +67,7 @@ find -type f -exec sh -c "cat '{}' \ | sed 's#$VITE_OHIF_URL#$OHIF_URL#g' \ | sed 's#$VITE_ACKEE_SERVER#$ACKEE_SERVER#g' \ | sed 's#$VITE_ACKEE_DOMAIN_ID#$ACKEE_DOMAIN_ID#g' \ + | sed 's#$VITE_CUBE_POLL_INTERVAL_MS#$CUBE_POLL_INTERVAL_MS#g' \ > $target/{}" \; # run specified command diff --git a/src/components/Pacs/PacsController.tsx b/src/components/Pacs/PacsController.tsx index 6354d42c0..5d93388ba 100644 --- a/src/components/Pacs/PacsController.tsx +++ b/src/components/Pacs/PacsController.tsx @@ -316,7 +316,8 @@ const PacsController: React.FC = ({ }, enabled: state.done, retry: 300, - retryDelay: 2000, // TODO use environment variable + retryDelay: + parseInt(import.meta.env.VITE_CUBE_POLL_INTERVAL_MS) || 2000, })), [receiveState, chrisClient.getPACSSeriesList], ),