diff --git a/packages/core/App.tsx b/packages/core/App.tsx index 017538041..ce7b7d5fd 100644 --- a/packages/core/App.tsx +++ b/packages/core/App.tsx @@ -14,7 +14,7 @@ import GlobalActionButtonRow from "./components/GlobalActionButtonRow"; import StatusMessage from "./components/StatusMessage"; import TutorialTooltip from "./components/TutorialTooltip"; import QuerySidebar from "./components/QuerySidebar"; -import { FileExplorerServiceBaseUrl } from "./constants"; +import { Environment } from "./constants"; import { interaction, selection } from "./state"; import useLayoutMeasurements from "./hooks/useLayoutMeasurements"; @@ -39,11 +39,11 @@ interface AppProps { // Localhost: "https://localhost:9081" // Stage: "http://stg-aics-api.corp.alleninstitute.org" // From the web (behind load balancer): "/" - fileExplorerServiceBaseUrl?: string; + environment?: Environment; } export default function App(props: AppProps) { - const { fileExplorerServiceBaseUrl = FileExplorerServiceBaseUrl.PRODUCTION } = props; + const { environment = Environment.PRODUCTION } = props; const dispatch = useDispatch(); const hasQuerySelected = useSelector(selection.selectors.hasQuerySelected); @@ -79,8 +79,12 @@ export default function App(props: AppProps) { // Set data source base urls React.useEffect(() => { - dispatch(interaction.actions.initializeApp(fileExplorerServiceBaseUrl)); - }, [dispatch, fileExplorerServiceBaseUrl]); + dispatch( + interaction.actions.initializeApp({ + environment, + }) + ); + }, [dispatch, environment]); // Respond to screen size changes React.useEffect(() => { diff --git a/packages/core/components/AnnotationFilterForm/test/AnnotationFilterForm.test.tsx b/packages/core/components/AnnotationFilterForm/test/AnnotationFilterForm.test.tsx index 24ecae207..705b24ee7 100644 --- a/packages/core/components/AnnotationFilterForm/test/AnnotationFilterForm.test.tsx +++ b/packages/core/components/AnnotationFilterForm/test/AnnotationFilterForm.test.tsx @@ -10,6 +10,7 @@ import Annotation from "../../../entity/Annotation"; import FileFilter from "../../../entity/FileFilter"; import { initialState, reducer, reduxLogics, interaction, selection } from "../../../state"; import HttpAnnotationService from "../../../services/AnnotationService/HttpAnnotationService"; +import { FESBaseUrl } from "../../../constants"; describe("", () => { const LISTROW_TESTID_PREFIX = "default-button-"; @@ -31,14 +32,14 @@ describe("", () => { it("shows all values as unchecked at first", async () => { // arrange const responseStub = { - when: `test/file-explorer-service/1.0/annotations/${fooAnnotation.name}/values`, + when: `${FESBaseUrl.TEST}/file-explorer-service/1.0/annotations/${fooAnnotation.name}/values`, respondWith: { data: { data: ["a", "b", "c", "d"] }, }, }; const mockHttpClient = createMockHttpClient(responseStub); const annotationService = new HttpAnnotationService({ - baseUrl: "test", + fileExplorerServiceBaseUrl: FESBaseUrl.TEST, httpClient: mockHttpClient, }); sandbox.stub(interaction.selectors, "getAnnotationService").returns(annotationService); @@ -68,14 +69,14 @@ describe("", () => { it("deselects and selects a value", async () => { // arrange const responseStub = { - when: `test/file-explorer-service/1.0/annotations/${fooAnnotation.name}/values`, + when: `${FESBaseUrl.TEST}/file-explorer-service/1.0/annotations/${fooAnnotation.name}/values`, respondWith: { data: { data: ["a", "b", "c", "d"] }, }, }; const mockHttpClient = createMockHttpClient(responseStub); const annotationService = new HttpAnnotationService({ - baseUrl: "test", + fileExplorerServiceBaseUrl: FESBaseUrl.TEST, httpClient: mockHttpClient, }); sandbox.stub(interaction.selectors, "getAnnotationService").returns(annotationService); @@ -124,14 +125,14 @@ describe("", () => { it("naturally sorts values", async () => { // arrange const responseStub = { - when: `test/file-explorer-service/1.0/annotations/${fooAnnotation.name}/values`, + when: `${FESBaseUrl.TEST}/file-explorer-service/1.0/annotations/${fooAnnotation.name}/values`, respondWith: { data: { data: ["AICS-24", "AICS-0", "aics-32", "aICs-2"] }, }, }; const mockHttpClient = createMockHttpClient(responseStub); const annotationService = new HttpAnnotationService({ - baseUrl: "test", + fileExplorerServiceBaseUrl: FESBaseUrl.TEST, httpClient: mockHttpClient, }); sandbox.stub(interaction.selectors, "getAnnotationService").returns(annotationService); @@ -172,14 +173,14 @@ describe("", () => { }); const responseStub = { - when: `test/file-explorer-service/1.0/annotations/${fooAnnotation.name}/values`, + when: `${FESBaseUrl.TEST}/file-explorer-service/1.0/annotations/${fooAnnotation.name}/values`, respondWith: { data: { data: [true, false] }, }, }; const mockHttpClient = createMockHttpClient(responseStub); const annotationService = new HttpAnnotationService({ - baseUrl: "test", + fileExplorerServiceBaseUrl: FESBaseUrl.TEST, httpClient: mockHttpClient, }); @@ -279,14 +280,14 @@ describe("", () => { it("naturally sorts values", async () => { // arrange const responseStub = { - when: `test/file-explorer-service/1.0/annotations/${fooAnnotation.name}/values`, + when: `${FESBaseUrl.TEST}/file-explorer-service/1.0/annotations/${fooAnnotation.name}/values`, respondWith: { data: { data: [5, 8, 6.3, -12, 10000000000, 0] }, }, }; const mockHttpClient = createMockHttpClient(responseStub); const annotationService = new HttpAnnotationService({ - baseUrl: "test", + fileExplorerServiceBaseUrl: FESBaseUrl.TEST, httpClient: mockHttpClient, }); sandbox.stub(interaction.selectors, "getAnnotationService").returns(annotationService); @@ -334,14 +335,14 @@ describe("", () => { it("naturally sorts values", async () => { // arrange const responseStub = { - when: `test/file-explorer-service/1.0/annotations/${fooAnnotation.name}/values`, + when: `${FESBaseUrl.TEST}/file-explorer-service/1.0/annotations/${fooAnnotation.name}/values`, respondWith: { data: { data: [446582220, 125, 10845000, 86400000] }, }, }; const mockHttpClient = createMockHttpClient(responseStub); const annotationService = new HttpAnnotationService({ - baseUrl: "test", + fileExplorerServiceBaseUrl: FESBaseUrl.TEST, httpClient: mockHttpClient, }); sandbox.stub(interaction.selectors, "getAnnotationService").returns(annotationService); diff --git a/packages/core/components/DirectoryTree/test/DirectoryTree.test.tsx b/packages/core/components/DirectoryTree/test/DirectoryTree.test.tsx index d60f8e9d1..5fcb53cd9 100644 --- a/packages/core/components/DirectoryTree/test/DirectoryTree.test.tsx +++ b/packages/core/components/DirectoryTree/test/DirectoryTree.test.tsx @@ -19,7 +19,7 @@ import { import { Provider } from "react-redux"; import { createSandbox } from "sinon"; -import { TOP_LEVEL_FILE_ANNOTATIONS } from "../../../constants"; +import { FESBaseUrl, TOP_LEVEL_FILE_ANNOTATIONS } from "../../../constants"; import Annotation from "../../../entity/Annotation"; import AnnotationName from "../../../entity/Annotation/AnnotationName"; import { FmsFileAnnotation } from "../../../services/FileService"; @@ -53,7 +53,6 @@ describe("", () => { type: "Text", }); - const baseUrl = "http://test-aics.corp.alleninstitute.org"; const baseDisplayAnnotations = TOP_LEVEL_FILE_ANNOTATIONS.filter( (a) => a.name === AnnotationName.FILE_NAME ); @@ -61,9 +60,6 @@ describe("", () => { metadata: { annotations: [...baseDisplayAnnotations, fooAnnotation, barAnnotation], }, - interaction: { - fileExplorerServiceBaseUrl: baseUrl, - }, selection: { annotationHierarchy: [fooAnnotation.name, barAnnotation.name], columns: [...baseDisplayAnnotations, fooAnnotation, barAnnotation].map((a) => ({ @@ -194,9 +190,13 @@ describe("", () => { }, ]; const mockHttpClient = createMockHttpClient(responseStubs); - const annotationService = new HttpAnnotationService({ baseUrl, httpClient: mockHttpClient }); + const fileExplorerServiceBaseUrl = FESBaseUrl.TEST; + const annotationService = new HttpAnnotationService({ + fileExplorerServiceBaseUrl: fileExplorerServiceBaseUrl, + httpClient: mockHttpClient, + }); const fileService = new HttpFileService({ - baseUrl, + fileExplorerServiceBaseUrl: fileExplorerServiceBaseUrl, httpClient: mockHttpClient, downloadService: new FileDownloadServiceNoop(), }); @@ -362,9 +362,6 @@ describe("", () => { metadata: { annotations: [...baseDisplayAnnotations, fooAnnotation, barAnnotation], }, - interaction: { - fileExplorerServiceBaseUrl: baseUrl, - }, selection: { annotationHierarchy: [fooAnnotation.name], columns: [...baseDisplayAnnotations, fooAnnotation, barAnnotation].map((a) => ({ diff --git a/packages/core/components/FileDetails/FileAnnotationList.tsx b/packages/core/components/FileDetails/FileAnnotationList.tsx index abe6d96e2..0b08158a1 100644 --- a/packages/core/components/FileDetails/FileAnnotationList.tsx +++ b/packages/core/components/FileDetails/FileAnnotationList.tsx @@ -37,7 +37,14 @@ export default function FileAnnotationList(props: FileAnnotationListProps) { return; } - const path = await executionEnvService.formatPathForHost(fileDetails.path); + let path; + if (fileDetails.localPath === null) { + // The Local File Path annotation is not defined because the file is not available + // on-premises + path = fileDetails.localPath; + } else { + path = await executionEnvService.formatPathForHost(fileDetails.localPath); + } if (!active) { return; } @@ -65,13 +72,29 @@ export default function FileAnnotationList(props: FileAnnotationListProps) { return accum; } - const annotationValue = annotation.extractFromFile(fileDetails); + let annotationValue = annotation.extractFromFile(fileDetails); if (annotationValue === Annotation.MISSING_VALUE) { // Nothing to show for this annotation -- skip return accum; } - const ret = [ + if (annotation.name === AnnotationName.LOCAL_FILE_PATH) { + if (localPath === null) { + // localPath hasn't loaded yet, but it should eventually because there is an + // annotation named AnnotationName.LOCAL_FILE_PATH + return accum; + } else { + // Use the user's /allen mount point, if known + annotationValue = localPath; + } + } + + if (annotation.name === AnnotationName.FILE_PATH) { + // Display the full http://... URL + annotationValue = fileDetails.cloudPath; + } + + return [ ...accum, , ]; - - // Special case for file paths: we want to display both the "canonical" FMS path - // (i.e. POSIX path held in the database; what we have an annotation for) - // as well as the path at which the file is *actually* accessible on _this_ computer ("local" file path) - if (annotation.name === AnnotationName.FILE_PATH) { - if (fileDetails.path !== fileDetails.cloudPath) { - ret.push( - - ); - } - - // In certain circumstances (i.e., linux), the path at which a file is accessible is === the canonical path - if (localPath && localPath !== annotationValue && !localPath.startsWith("http")) { - ret.splice( - -1, // Insert before the "canonical" path so that it is the first path-like row to be seen - 0, // ...don't delete the "canonical" path - - ); - } - } - - return ret; }, [] as JSX.Element[]); }, [annotations, fileDetails, isLoading, localPath]); diff --git a/packages/core/components/FileDetails/test/FileAnnotationList.test.tsx b/packages/core/components/FileDetails/test/FileAnnotationList.test.tsx index dbf1c8a09..b030e5eb6 100644 --- a/packages/core/components/FileDetails/test/FileAnnotationList.test.tsx +++ b/packages/core/components/FileDetails/test/FileAnnotationList.test.tsx @@ -9,10 +9,12 @@ import FileDetail from "../../../entity/FileDetail"; import ExecutionEnvServiceNoop from "../../../services/ExecutionEnvService/ExecutionEnvServiceNoop"; import { initialState } from "../../../state"; import { TOP_LEVEL_FILE_ANNOTATIONS } from "../../../constants"; +import Annotation from "../../../entity/Annotation"; +import { AnnotationType } from "../../../entity/AnnotationFormatter"; describe("", () => { describe("file path representation", () => { - it("has both canonical file path and file path adjusted to OS & allen mount point", async () => { + it("has both cloud file path and local file path adjusted to OS & allen mount point", async () => { // Arrange const hostMountPoint = "/some/path"; @@ -24,6 +26,17 @@ describe("", () => { const { store } = configureMockStore({ state: mergeState(initialState, { + metadata: { + annotations: [ + ...TOP_LEVEL_FILE_ANNOTATIONS, + new Annotation({ + annotationName: "Local File Path", + annotationDisplayName: "File Path (Local VAST)", + description: "Path to file in on-premises storage.", + type: AnnotationType.STRING, + }), + ], + }, interaction: { platformDependentServices: { executionEnvService: new FakeExecutionEnvService(), @@ -33,19 +46,18 @@ describe("", () => { }); const filePathInsideAllenDrive = "path/to/MyFile.txt"; - - const canonicalFilePath = `/allen/${filePathInsideAllenDrive}`; + const filePath = `production.files.allencell.org/${filePathInsideAllenDrive}`; const fileDetails = new FileDetail({ - file_path: canonicalFilePath, + file_path: filePath, file_id: "abc123", file_name: "MyFile.txt", file_size: 7, uploaded: "01/01/01", - annotations: [], + annotations: [ + { name: "Local File Path", values: [`/allen/${filePathInsideAllenDrive}`] }, + ], }); - const expectedLocalPath = `${hostMountPoint}/${filePathInsideAllenDrive}`; - // Act const { findByText } = render( @@ -54,17 +66,17 @@ describe("", () => { ); // Assert - [ - "File Path (Canonical)", - canonicalFilePath, - "File Path (Local)", - expectedLocalPath, - ].forEach(async (cellText) => { + for (const cellText of [ + "File Path (Cloud)", + `https://s3.us-west-2.amazonaws.com/${filePath}`, + "File Path (Local VAST)", + `${hostMountPoint}/${filePathInsideAllenDrive}`, + ]) { expect(await findByText(cellText)).to.not.be.undefined; - }); + } }); - it("has only canonical file path when no allen mount point is found", () => { + it("has only cloud file path when no allen mount point is found", () => { // Arrange class FakeExecutionEnvService extends ExecutionEnvServiceNoop { public formatPathForHost(posixPath: string): Promise { @@ -86,7 +98,7 @@ describe("", () => { }); const filePathInsideAllenDrive = "path/to/MyFile.txt"; - const filePath = `/allen/${filePathInsideAllenDrive}`; + const filePath = `production.files.allencell.org/${filePathInsideAllenDrive}`; const fileDetails = new FileDetail({ file_path: filePath, file_id: "abc123", @@ -104,10 +116,12 @@ describe("", () => { ); // Assert - expect(() => getByText("File Path (Local)")).to.throw(); - ["File Path (Canonical)", filePath].forEach((cellText) => { - expect(getByText(cellText)).to.not.be.undefined; - }); + expect(() => getByText("File Path (Local VAST)")).to.throw(); + ["File Path (Cloud)", `https://s3.us-west-2.amazonaws.com/${filePath}`].forEach( + (cellText) => { + expect(getByText(cellText)).to.not.be.undefined; + } + ); }); }); }); diff --git a/packages/core/components/Modal/CopyFileManifest/CopyFileManifest.module.css b/packages/core/components/Modal/CopyFileManifest/CopyFileManifest.module.css new file mode 100644 index 000000000..8b6a27341 --- /dev/null +++ b/packages/core/components/Modal/CopyFileManifest/CopyFileManifest.module.css @@ -0,0 +1,93 @@ +.body-container { + position: relative; +} + +.note { + margin-top: 0.5em; + margin-bottom: 1em; +} + +.table-container { + margin-bottom: 2em; +} + +.table-title { + font-size: 16px; + font-weight: 600; + margin: 4px 0 8px 0; +} + +.table-wrapper { + position: relative; +} + +.file-table-container { + max-height: 300px; + overflow-y: auto; + background-color: var(--secondary-background-color); + position: relative; + z-index: 1; +} + +.gradient-overlay { + position: absolute; + bottom: 0; + left: 0; + width: 100%; + height: 50px; + background-image: linear-gradient(transparent, black); + pointer-events: none; + z-index: 2; +} + +.file-table { + width: 100%; + border-collapse: collapse; + position: relative; + z-index: 1; +} + +.file-table th, +.file-table td { + padding: 8px; + text-align: left; + white-space: nowrap; + color: var(--primary-text-color); + background-color: var(--secondary-color); +} + +.file-table th { + background-color: var(--secondary-background-color); + position: sticky; + top: 0; + z-index: 3; +} + +.file-table td:first-child { + border-right: 1px solid var(--border-color); +} + +.footer-buttons { + display: flex; + justify-content: flex-end; + gap: 8px; +} + +.summary { + display: flex; + justify-content: flex-end; + gap: 1em; + margin-top: var(--row-count-margin-top); + color: var(--secondary-text-color); + opacity: 0.8; + font-size: var(--row-count-intrisic-height); + height: var(--row-count-intrisic-height-height); +} + +.total-size { + text-align: right; +} + +.file-count { + text-align: right; +} diff --git a/packages/core/components/Modal/CopyFileManifest/index.tsx b/packages/core/components/Modal/CopyFileManifest/index.tsx new file mode 100644 index 000000000..8deeecd26 --- /dev/null +++ b/packages/core/components/Modal/CopyFileManifest/index.tsx @@ -0,0 +1,162 @@ +import filesize from "filesize"; +import * as React from "react"; +import { useDispatch, useSelector } from "react-redux"; + +import { ModalProps } from ".."; +import BaseModal from "../BaseModal"; +import { PrimaryButton, SecondaryButton } from "../../Buttons"; +import FileDetail from "../../../entity/FileDetail"; +import FileSelection from "../../../entity/FileSelection"; +import { interaction, selection } from "../../../state"; + +import styles from "./CopyFileManifest.module.css"; + +/** + * Table component for rendering file details. + */ +function FileTable({ files, title }: { files: FileDetail[]; title: string }) { + const containerRef = React.useRef(null); + const [hasScroll, setHasScroll] = React.useState(false); + + React.useEffect(() => { + const checkScroll = () => { + if (containerRef.current) { + const isScrollable = + containerRef.current.scrollHeight > containerRef.current.clientHeight; + setHasScroll(isScrollable); + } + }; + checkScroll(); // Initial check + window.addEventListener("resize", checkScroll); + return () => window.removeEventListener("resize", checkScroll); + }, [files]); + + const clipFileName = (filename: string) => { + if (filename.length > 20) { + return filename.slice(0, 9) + "..." + filename.slice(-8); + } + return filename; + }; + + const calculateTotalSize = (files: FileDetail[]) => { + if (files.length === 0) return ""; + const totalBytes = files.reduce((acc, file) => acc + (file.size || 0), 0); + return totalBytes ? filesize(totalBytes) : "Calculating..."; + }; + + return ( +
+

{title}

+
+
+ + + + + + + + + {files.map((file) => ( + + + + + ))} + +
File NameFile Size
{clipFileName(file.name)}{filesize(file.size || 0)}
+
+ {hasScroll &&
} +
+
+ {files.length > 0 && ( + {calculateTotalSize(files)} + )} + {files.length.toLocaleString()} files +
+
+ ); +} + +/** + * Modal overlay for displaying details of selected files for NAS cache (VAST) operations. + */ +export default function CopyFileManifest({ onDismiss }: ModalProps) { + const dispatch = useDispatch(); + const fileSelection = useSelector( + selection.selectors.getFileSelection, + FileSelection.selectionsAreEqual + ); + + const [fileDetails, setFileDetails] = React.useState([]); + + React.useEffect(() => { + async function fetchDetails() { + const details = await fileSelection.fetchAllDetails(); + setFileDetails(details); + } + fetchDetails(); + }, [fileSelection]); + + const onMove = () => { + dispatch(interaction.actions.copyFiles(fileDetails)); + onDismiss(); + }; + + const filesInLocalCache = fileDetails.filter((file) => + file.annotations.some( + (annotation) => + annotation.name === "Should Be in Local Cache" && annotation.values[0] === true + ) + ); + + const filesNotInLocalCache = fileDetails.filter( + (file) => + file.annotations.some( + (annotation) => + annotation.name === "Should Be in Local Cache" && annotation.values[0] === false + ) || + !file.annotations.some((annotation) => annotation.name === "Should Be in Local Cache") + ); + + return ( + +

+ Files copied to the local storage (VAST) are stored with a 180-day + expiration, after which they revert to cloud-only storage. To extend the + expiration, reselect the files and confirm the update. +

+ + +
+ } + footer={ +
+ + +
+ } + onDismiss={onDismiss} + title="Copy files to local storage (VAST)" + /> + ); +} diff --git a/packages/core/components/Modal/MetadataManifest/test/MetadataManifest.test.tsx b/packages/core/components/Modal/MetadataManifest/test/MetadataManifest.test.tsx index fdf28fa6d..c0fed64a2 100644 --- a/packages/core/components/Modal/MetadataManifest/test/MetadataManifest.test.tsx +++ b/packages/core/components/Modal/MetadataManifest/test/MetadataManifest.test.tsx @@ -12,6 +12,7 @@ import { Provider } from "react-redux"; import { createSandbox } from "sinon"; import Modal, { ModalType } from "../.."; +import { FESBaseUrl } from "../../../../constants"; import Annotation from "../../../../entity/Annotation"; import FileFilter from "../../../../entity/FileFilter"; import { initialState, interaction, reduxLogics } from "../../../../state"; @@ -19,10 +20,11 @@ import HttpFileService from "../../../../services/FileService/HttpFileService"; import FileDownloadServiceNoop from "../../../../services/FileDownloadService/FileDownloadServiceNoop"; describe("", () => { - const baseUrl = "test"; + const fileExplorerServiceBaseUrl = FESBaseUrl.TEST; + const environment = "TEST"; const visibleDialogState = mergeState(initialState, { interaction: { - fileExplorerServiceBaseUrl: baseUrl, + environment: environment, visibleModal: ModalType.MetadataManifest, }, }); @@ -35,7 +37,7 @@ describe("", () => { }; const mockHttpClient = createMockHttpClient(responseStub); const fileService = new HttpFileService({ - baseUrl, + fileExplorerServiceBaseUrl, httpClient: mockHttpClient, downloadService: new FileDownloadServiceNoop(), }); diff --git a/packages/core/components/Modal/SmallScreenWarning/test/SmallScreenWarning.test.tsx b/packages/core/components/Modal/SmallScreenWarning/test/SmallScreenWarning.test.tsx index 91b76a105..ce446e63d 100644 --- a/packages/core/components/Modal/SmallScreenWarning/test/SmallScreenWarning.test.tsx +++ b/packages/core/components/Modal/SmallScreenWarning/test/SmallScreenWarning.test.tsx @@ -8,10 +8,8 @@ import Modal, { ModalType } from "../.."; import { initialState, interaction, reduxLogics } from "../../../../state"; describe("", () => { - const baseUrl = "test"; const visibleDialogState = mergeState(initialState, { interaction: { - fileExplorerServiceBaseUrl: baseUrl, visibleModal: ModalType.SmallScreenWarning, }, }); diff --git a/packages/core/components/Modal/index.tsx b/packages/core/components/Modal/index.tsx index e05a4bc65..ef6d44796 100644 --- a/packages/core/components/Modal/index.tsx +++ b/packages/core/components/Modal/index.tsx @@ -6,6 +6,7 @@ import CodeSnippet from "./CodeSnippet"; import DataSource from "./DataSource"; import MetadataManifest from "./MetadataManifest"; import SmallScreenWarning from "./SmallScreenWarning"; +import CopyFileManifest from "./CopyFileManifest"; export interface ModalProps { onDismiss: () => void; @@ -13,9 +14,10 @@ export interface ModalProps { export enum ModalType { CodeSnippet = 1, - DataSource = 2, - MetadataManifest = 3, - SmallScreenWarning = 4, + CopyFileManifest = 2, + DataSource = 3, + MetadataManifest = 4, + SmallScreenWarning = 5, } /** @@ -38,6 +40,8 @@ export default function Modal() { return ; case ModalType.SmallScreenWarning: return ; + case ModalType.CopyFileManifest: + return ; default: return null; } diff --git a/packages/core/constants/index.ts b/packages/core/constants/index.ts index 55a55cd35..ff53eb69d 100644 --- a/packages/core/constants/index.ts +++ b/packages/core/constants/index.ts @@ -5,10 +5,11 @@ import { AnnotationType } from "../entity/AnnotationFormatter"; export const APP_ID = "fms-file-explorer-core"; // Refer to packages/fms-file-explorer-electron/src/main/menu -export enum FileExplorerServiceBaseUrl { - LOCALHOST = "http://localhost:9081", - STAGING = "https://staging.int.allencell.org", - PRODUCTION = "https://production.int.allencell.org", +export enum Environment { + LOCALHOST = "LOCALHOST", + STAGING = "STAGING", + PRODUCTION = "PRODUCTION", + TEST = "TEST", } export const TOP_LEVEL_FILE_ANNOTATIONS = [ @@ -25,9 +26,9 @@ export const TOP_LEVEL_FILE_ANNOTATIONS = [ type: AnnotationType.STRING, }), new Annotation({ - annotationDisplayName: "File Path (Canonical)", + annotationDisplayName: "File Path (Cloud)", annotationName: AnnotationName.FILE_PATH, - description: "Path to file in storage.", + description: "Path to file in the cloud.", type: AnnotationType.STRING, }), new Annotation({ @@ -54,3 +55,24 @@ export const TOP_LEVEL_FILE_ANNOTATIONS = [ export const TOP_LEVEL_FILE_ANNOTATION_NAMES = TOP_LEVEL_FILE_ANNOTATIONS.map((a) => a.name); export const AICS_FMS_DATA_SOURCE_NAME = "AICS FMS"; + +export enum FESBaseUrl { + LOCALHOST = "http://localhost:9081", + STAGING = "https://staging.int.allencell.org", + PRODUCTION = "https://production.int.allencell.org", + TEST = "http://test.int.allencell.org", +} + +export enum MMSBaseUrl { + LOCALHOST = "http://localhost:9060", + STAGING = "http://stg-aics-api", + PRODUCTION = "http://prod-aics-api", + TEST = "http://test-aics-api", +} + +export enum LoadBalancerBaseUrl { + LOCALHOST = "http://localhost:8080", + STAGING = "http://stg-aics.corp.alleninstitute.org", + PRODUCTION = "http://aics.corp.alleninstitute.org", + TEST = "http://test-aics.corp.alleninstitute.org", +} diff --git a/packages/core/entity/Annotation/AnnotationName.ts b/packages/core/entity/Annotation/AnnotationName.ts index 1de12bd4c..f9f328bfa 100644 --- a/packages/core/entity/Annotation/AnnotationName.ts +++ b/packages/core/entity/Annotation/AnnotationName.ts @@ -6,6 +6,7 @@ export default { FILE_NAME: "file_name", // a file attribute (top-level prop on file documents in MongoDb) FILE_SIZE: "file_size", // a file attribute (top-level prop on file documents in MongoDb) FILE_PATH: "file_path", // a file attribute (top-level prop on file documents in MongoDb) + LOCAL_FILE_PATH: "Local File Path", // (optional) annotation for FMS files on the local NAS PLATE_BARCODE: "Plate Barcode", THUMBNAIL_PATH: "thumbnail", // (optional) file attribute (top-level prop on the file documents in MongoDb) TYPE: "Type", // matches an annotation in filemetadata.annotation diff --git a/packages/core/entity/Annotation/mocks.ts b/packages/core/entity/Annotation/mocks.ts index 4c85c304f..46064f073 100644 --- a/packages/core/entity/Annotation/mocks.ts +++ b/packages/core/entity/Annotation/mocks.ts @@ -29,4 +29,10 @@ export const annotationsJson = [ description: "Imaging objective", type: "Number", }, + { + annotationName: "Local File Path", + annotationDisplayName: "Local File Path", + description: "Path to file in on-premises storage.", + type: "Text", + }, ]; diff --git a/packages/core/entity/FileDetail/index.ts b/packages/core/entity/FileDetail/index.ts index 83ddc9f4b..86ec9f957 100644 --- a/packages/core/entity/FileDetail/index.ts +++ b/packages/core/entity/FileDetail/index.ts @@ -1,10 +1,12 @@ import AnnotationName from "../Annotation/AnnotationName"; -import { FileExplorerServiceBaseUrl } from "../../constants"; import { FmsFileAnnotation } from "../../services/FileService"; import { renderZarrThumbnailURL } from "./RenderZarrThumbnailURL"; const RENDERABLE_IMAGE_FORMATS = [".jpg", ".jpeg", ".png", ".gif"]; +const AICS_FMS_S3_BUCKET = "production.files.allencell.org"; +const AICS_FMS_S3_URL_PREFIX = "https://s3.us-west-2.amazonaws.com/"; + /** * Expected JSON response of a file detail returned from the query service. Example: * { @@ -80,7 +82,11 @@ export default class FileDetail { const pathWithoutDrive = path.replace("/allen/programs/allencell/data/proj0", ""); // Should probably record this somewhere we can dynamically adjust to, or perhaps just in the file // document itself, alas for now this will do. - return `https://s3.us-west-2.amazonaws.com/production.files.allencell.org${pathWithoutDrive}`; + return FileDetail.convertAicsS3PathToHttpUrl(`${AICS_FMS_S3_BUCKET}${pathWithoutDrive}`); + } + + private static convertAicsS3PathToHttpUrl(path: string): string { + return `${AICS_FMS_S3_URL_PREFIX}${path}`; } constructor(fileDetail: FmsFile, uniqueId?: string) { @@ -117,26 +123,52 @@ export default class FileDetail { if (path === undefined) { throw new Error("File Path is not defined"); } + + // AICS FMS files have paths like this in fileDetail.file_path: + // staging.files.allencell.org/130/b23/bfe/117/2a4/71b/746/002/064/db4/1a/danny_int_test_4.txt + if (typeof path === "string" && path.startsWith(AICS_FMS_S3_BUCKET)) { + return FileDetail.convertAicsS3PathToHttpUrl(path) as string; + } + + // Otherwise just return the path as is and hope for the best return path as string; } + public get localPath(): string | null { + // REMOVE THIS (BACKWARDS COMPAT) + if (this.path.startsWith("/allen")) { + return this.path; + } + + const localPath = this.getFirstAnnotationValue("Local File Path"); + if (localPath === undefined) { + return null; + } + return localPath as string; + } + public get cloudPath(): string { - // Can retrieve a cloud like path for AICS FMS files + // REMOVE THIS (BACKWARDS COMPAT) if (this.path.startsWith("/allen")) { return FileDetail.convertAicsDrivePathToAicsS3Path(this.path); } - // Otherwise just return the path as is and hope for the best + // AICS FMS files' paths are cloud paths return this.path; } public get downloadPath(): string { - // For AICS files we don't have permission to the bucket nor do we expect to have the /allen - // drive mounted on the client machine. So we use the NGINX server to serve the file. + // REMOVE THIS (BACKWARDS COMPAT) if (this.path.startsWith("/allen")) { return `http://aics.corp.alleninstitute.org/labkey/fmsfiles/image${this.path}`; } + // For AICS files that are available on the Vast, users can use the cloud path, but the + // download will be faster and not incur egress fees if we download via the local network. + if (this.localPath && this.localPath.startsWith("/allen")) { + return `http://aics.corp.alleninstitute.org/labkey/fmsfiles/image${this.localPath}`; + } + // Otherwise just return the path as is and hope for the best return this.path; } @@ -194,21 +226,13 @@ export default class FileDetail { return this.thumbnail; } - public getLinkToPlateUI(baseURL: string): string | undefined { + public getLinkToPlateUI(labkeyHost: string): string | undefined { // Grabbing plate barcode const platebarcode = this.getFirstAnnotationValue(AnnotationName.PLATE_BARCODE); - if (!platebarcode) { return undefined; } - - let labkeyHost = "localhost:9081"; - if (baseURL === FileExplorerServiceBaseUrl.PRODUCTION) { - labkeyHost = "aics.corp.alleninstitute.org"; - } else if (baseURL === FileExplorerServiceBaseUrl.STAGING) { - labkeyHost = "stg-aics.corp.alleninstitute.org"; - } - return `http://${labkeyHost}/labkey/aics_microscopy/AICS/editPlate.view?Barcode=${platebarcode}`; + return `${labkeyHost}/labkey/aics_microscopy/AICS/editPlate.view?Barcode=${platebarcode}`; } public getAnnotationNameToLinkMap(): { [annotationName: string]: string } { diff --git a/packages/core/entity/FileDetail/test/FileDetail.test.ts b/packages/core/entity/FileDetail/test/FileDetail.test.ts new file mode 100644 index 000000000..a9f4f279b --- /dev/null +++ b/packages/core/entity/FileDetail/test/FileDetail.test.ts @@ -0,0 +1,29 @@ +import { expect } from "chai"; + +import FileDetail from ".."; + +describe("FileDetail", () => { + describe("file path representation", () => { + it("creates downloadPath from /allen path", () => { + // Arrange + const relativePath = "path/to/MyFile.txt"; + + // Act + const fileDetail = new FileDetail({ + file_path: `production.files.allencell.org/${relativePath}`, + annotations: [ + { + name: "Local File Path", + values: [`/allen/programs/allencell/data/proj0/${relativePath}`], + }, + ], + }); + + // Assert + expect(fileDetail.downloadPath).to.equal( + // The downloadPath is HTTP, but will get redirected to HTTPS + `http://aics.corp.alleninstitute.org/labkey/fmsfiles/image/allen/programs/allencell/data/proj0/${relativePath}` + ); + }); + }); +}); diff --git a/packages/core/entity/FileSelection/test/FileSelection.test.ts b/packages/core/entity/FileSelection/test/FileSelection.test.ts index 5a12775a8..5076cc425 100644 --- a/packages/core/entity/FileSelection/test/FileSelection.test.ts +++ b/packages/core/entity/FileSelection/test/FileSelection.test.ts @@ -11,6 +11,7 @@ import FileFilter from "../../FileFilter"; import FuzzyFilter from "../../FileFilter/FuzzyFilter"; import IncludeFilter from "../../FileFilter/IncludeFilter"; import ExcludeFilter from "../../FileFilter/ExcludeFilter"; +import { FESBaseUrl } from "../../../constants"; import { IndexError, ValueError } from "../../../errors"; import HttpFileService from "../../../services/FileService/HttpFileService"; import FileDownloadServiceNoop from "../../../services/FileDownloadService/FileDownloadServiceNoop"; @@ -344,7 +345,7 @@ describe("FileSelection", () => { describe("fetchAllDetails", () => { it("returns file details for each selected item", async () => { // Arrange - const baseUrl = "test"; + const fileExplorerServiceBaseUrl = FESBaseUrl.TEST; const queryResult = []; for (let i = 0; i < 31; i++) { queryResult.push(i); @@ -354,13 +355,15 @@ describe("FileSelection", () => { .slice(1, 31) .map((detail) => new FileDetail(detail as any)); const httpClient = createMockHttpClient({ - when: `${baseUrl}/${HttpFileService.BASE_FILES_URL}?from=${0}&limit=${31}`, + when: `${fileExplorerServiceBaseUrl}/${ + HttpFileService.BASE_FILES_URL + }?from=${0}&limit=${31}`, respondWith: { data: { data: queryResult }, }, }); const fileService = new HttpFileService({ - baseUrl, + fileExplorerServiceBaseUrl, httpClient, downloadService: new FileDownloadServiceNoop(), }); diff --git a/packages/core/entity/FileSet/index.ts b/packages/core/entity/FileSet/index.ts index b68fe0b6b..ae744ecf7 100644 --- a/packages/core/entity/FileSet/index.ts +++ b/packages/core/entity/FileSet/index.ts @@ -84,7 +84,7 @@ export default class FileSet { * by using this as the component's `key` attribute. */ public get hash() { - return `${this.toQueryString()}:${this.fileService.baseUrl}`; + return `${this.toQueryString()}:${this.fileService.fileExplorerServiceBaseUrl}`; } public async fetchTotalCount() { @@ -210,7 +210,7 @@ export default class FileSet { public toJSON() { return { queryString: this.toQueryString(), - baseUrl: this.fileService.baseUrl, + fileExplorerServiceBaseUrl: this.fileService.fileExplorerServiceBaseUrl, }; } diff --git a/packages/core/entity/FileSet/test/FileSet.test.ts b/packages/core/entity/FileSet/test/FileSet.test.ts index 94851ae0d..702a73a38 100644 --- a/packages/core/entity/FileSet/test/FileSet.test.ts +++ b/packages/core/entity/FileSet/test/FileSet.test.ts @@ -3,6 +3,7 @@ import { expect } from "chai"; import { createSandbox } from "sinon"; import FileSet from "../"; +import { FESBaseUrl } from "../../../constants"; import FileFilter from "../../FileFilter"; import FileSort, { SortOrder } from "../../FileSort"; import { makeFileDetailMock } from "../../FileDetail/mocks"; @@ -148,40 +149,40 @@ describe("FileSet", () => { }); it("turns indicies for requested data into a properly formed pagination query", async () => { - const baseUrl = "test"; + const fileExplorerServiceBaseUrl = FESBaseUrl.TEST; const spec = [ { - expectedUrl: `${baseUrl}/${HttpFileService.BASE_FILES_URL}?from=1&limit=28`, + expectedUrl: `${fileExplorerServiceBaseUrl}/${HttpFileService.BASE_FILES_URL}?from=1&limit=28`, start: 35, end: 55, }, { - expectedUrl: `${baseUrl}/${HttpFileService.BASE_FILES_URL}?from=11&limit=23`, + expectedUrl: `${fileExplorerServiceBaseUrl}/${HttpFileService.BASE_FILES_URL}?from=11&limit=23`, start: 256, end: 274, }, { - expectedUrl: `${baseUrl}/${HttpFileService.BASE_FILES_URL}?from=0&limit=6`, + expectedUrl: `${fileExplorerServiceBaseUrl}/${HttpFileService.BASE_FILES_URL}?from=0&limit=6`, start: 0, end: 5, }, { - expectedUrl: `${baseUrl}/${HttpFileService.BASE_FILES_URL}?from=1&limit=11`, + expectedUrl: `${fileExplorerServiceBaseUrl}/${HttpFileService.BASE_FILES_URL}?from=1&limit=11`, start: 14, end: 21, }, { - expectedUrl: `${baseUrl}/${HttpFileService.BASE_FILES_URL}?from=0&limit=6`, + expectedUrl: `${fileExplorerServiceBaseUrl}/${HttpFileService.BASE_FILES_URL}?from=0&limit=6`, start: 2, end: 5, }, { - expectedUrl: `${baseUrl}/${HttpFileService.BASE_FILES_URL}?from=3&limit=4`, + expectedUrl: `${fileExplorerServiceBaseUrl}/${HttpFileService.BASE_FILES_URL}?from=3&limit=4`, start: 12, end: 15, }, { - expectedUrl: `${baseUrl}/${HttpFileService.BASE_FILES_URL}?from=0&limit=301`, + expectedUrl: `${fileExplorerServiceBaseUrl}/${HttpFileService.BASE_FILES_URL}?from=0&limit=301`, start: 2, end: 300, }, @@ -203,7 +204,7 @@ describe("FileSet", () => { const fileSet = new FileSet({ fileService: new HttpFileService({ httpClient, - baseUrl, + fileExplorerServiceBaseUrl, downloadService: new FileDownloadServiceNoop(), }), }); diff --git a/packages/core/hooks/useFileAccessContextMenu.ts b/packages/core/hooks/useFileAccessContextMenu.ts index cd79b3bbd..a07c1d571 100644 --- a/packages/core/hooks/useFileAccessContextMenu.ts +++ b/packages/core/hooks/useFileAccessContextMenu.ts @@ -135,6 +135,20 @@ export default (filters?: FileFilter[], onDismiss?: () => void) => { ], }, }, + ...(isQueryingAicsFms && !isOnWeb + ? [ + { + key: "copy-to-cache", + text: "Copy to vast", + title: "Copy selected files to NAS Cache (VAST)", + disabled: !filters && fileSelection.count() === 0, + iconProps: { iconName: "MoveToFolder" }, + onClick() { + dispatch(interaction.actions.showCopyFileManifest()); + }, + }, + ] + : []), { key: "download", text: "Download", diff --git a/packages/core/hooks/useOpenWithMenuItems/index.tsx b/packages/core/hooks/useOpenWithMenuItems/index.tsx index db11f5d02..7090f1b22 100644 --- a/packages/core/hooks/useOpenWithMenuItems/index.tsx +++ b/packages/core/hooks/useOpenWithMenuItems/index.tsx @@ -177,10 +177,9 @@ export default (fileDetails?: FileDetail, filters?: FileFilter[]): IContextualMe const annotationNameToAnnotationMap = useSelector( metadata.selectors.getAnnotationNameToAnnotationMap ); - const fileExplorerServiceBaseUrl = useSelector( - interaction.selectors.getFileExplorerServiceBaseUrl - ); + const loadBalancerBaseUrl = useSelector(interaction.selectors.getLoadBalancerBaseUrl); + const plateLink = fileDetails?.getLinkToPlateUI(loadBalancerBaseUrl); const annotationNameToLinkMap = React.useMemo( () => fileDetails?.annotations @@ -249,7 +248,6 @@ export default (fileDetails?: FileDetail, filters?: FileFilter[]): IContextualMe .filter((app) => supportedApps.every((item) => item.key !== app.key)) .sort((a, b) => (a.text || "").localeCompare(b.text || "")); - const plateLink = fileDetails?.getLinkToPlateUI(fileExplorerServiceBaseUrl); if (plateLink && isAicsEmployee) { supportedApps.push({ key: "open-plate-ui", diff --git a/packages/core/services/AnnotationService/HttpAnnotationService/FMSAnnotationPresentationLayer.ts b/packages/core/services/AnnotationService/HttpAnnotationService/FMSAnnotationPresentationLayer.ts new file mode 100644 index 000000000..e249e644a --- /dev/null +++ b/packages/core/services/AnnotationService/HttpAnnotationService/FMSAnnotationPresentationLayer.ts @@ -0,0 +1,13 @@ +import { AnnotationResponse } from "../../../entity/Annotation"; +import AnnotationName from "../../../entity/Annotation/AnnotationName"; + +export default function renameAnnotation(annotation: AnnotationResponse): AnnotationResponse { + if (annotation.annotationName === AnnotationName.LOCAL_FILE_PATH) { + return { + ...annotation, + annotationDisplayName: "File Path (Local VAST)", + } as AnnotationResponse; + } else { + return annotation; + } +} diff --git a/packages/core/services/AnnotationService/HttpAnnotationService/index.ts b/packages/core/services/AnnotationService/HttpAnnotationService/index.ts index 3a6d7b649..8fbbde906 100644 --- a/packages/core/services/AnnotationService/HttpAnnotationService/index.ts +++ b/packages/core/services/AnnotationService/HttpAnnotationService/index.ts @@ -1,5 +1,6 @@ import { map } from "lodash"; +import renameAnnotation from "./FMSAnnotationPresentationLayer"; import AnnotationService, { AnnotationValue } from ".."; import HttpServiceBase from "../../HttpServiceBase"; import Annotation, { AnnotationResponse } from "../../../entity/Annotation"; @@ -30,12 +31,15 @@ export default class HttpAnnotationService extends HttpServiceBase implements An * Fetch all annotations. */ public async fetchAnnotations(): Promise { - const requestUrl = `${this.baseUrl}/${HttpAnnotationService.BASE_ANNOTATION_URL}${this.pathSuffix}`; + const requestUrl = `${this.fileExplorerServiceBaseUrl}/${HttpAnnotationService.BASE_ANNOTATION_URL}${this.pathSuffix}`; const response = await this.get(requestUrl); return [ ...TOP_LEVEL_FILE_ANNOTATIONS, - ...map(response.data, (annotationResponse) => new Annotation(annotationResponse)), + ...map( + response.data, + (annotationResponse) => new Annotation(renameAnnotation(annotationResponse)) + ), ]; } @@ -45,7 +49,7 @@ export default class HttpAnnotationService extends HttpServiceBase implements An public async fetchValues(annotation: string): Promise { // Encode any special characters in the annotation as necessary const encodedAnnotation = HttpServiceBase.encodeURISection(annotation); - const requestUrl = `${this.baseUrl}/${HttpAnnotationService.BASE_ANNOTATION_URL}/${encodedAnnotation}/values${this.pathSuffix}`; + const requestUrl = `${this.fileExplorerServiceBaseUrl}/${HttpAnnotationService.BASE_ANNOTATION_URL}/${encodedAnnotation}/values${this.pathSuffix}`; const response = await this.get(requestUrl); return response.data; @@ -70,7 +74,7 @@ export default class HttpAnnotationService extends HttpServiceBase implements An .filter((param) => !!param) .join("&"); - const requestUrl = `${this.baseUrl}/${HttpAnnotationService.BASE_ANNOTATION_HIERARCHY_ROOT_URL}${this.pathSuffix}?${queryParams}`; + const requestUrl = `${this.fileExplorerServiceBaseUrl}/${HttpAnnotationService.BASE_ANNOTATION_HIERARCHY_ROOT_URL}${this.pathSuffix}?${queryParams}`; const response = await this.get(requestUrl); return response.data; @@ -91,7 +95,7 @@ export default class HttpAnnotationService extends HttpServiceBase implements An ] .filter((param) => !!param) .join("&"); - const requestUrl = `${this.baseUrl}/${HttpAnnotationService.BASE_ANNOTATION_HIERARCHY_UNDER_PATH_URL}${this.pathSuffix}?${queryParams}`; + const requestUrl = `${this.fileExplorerServiceBaseUrl}/${HttpAnnotationService.BASE_ANNOTATION_HIERARCHY_UNDER_PATH_URL}${this.pathSuffix}?${queryParams}`; const response = await this.get(requestUrl); return response.data; @@ -103,7 +107,7 @@ export default class HttpAnnotationService extends HttpServiceBase implements An */ public async fetchAvailableAnnotationsForHierarchy(annotations: string[]): Promise { const queryParams = this.buildQueryParams(QueryParam.HIERARCHY, [...annotations].sort()); - const requestUrl = `${this.baseUrl}/${HttpAnnotationService.BASE_AVAILABLE_ANNOTATIONS_UNDER_HIERARCHY}${this.pathSuffix}?${queryParams}`; + const requestUrl = `${this.fileExplorerServiceBaseUrl}/${HttpAnnotationService.BASE_AVAILABLE_ANNOTATIONS_UNDER_HIERARCHY}${this.pathSuffix}?${queryParams}`; const response = await this.get(requestUrl); if (!response.data) { diff --git a/packages/core/services/AnnotationService/HttpAnnotationService/test/HttpAnnotationService.test.ts b/packages/core/services/AnnotationService/HttpAnnotationService/test/HttpAnnotationService.test.ts index 5ac7d9111..a5a3c6b20 100644 --- a/packages/core/services/AnnotationService/HttpAnnotationService/test/HttpAnnotationService.test.ts +++ b/packages/core/services/AnnotationService/HttpAnnotationService/test/HttpAnnotationService.test.ts @@ -2,7 +2,7 @@ import { createMockHttpClient } from "@aics/redux-utils"; import { expect } from "chai"; import { spy } from "sinon"; -import { TOP_LEVEL_FILE_ANNOTATION_NAMES } from "../../../../constants"; +import { TOP_LEVEL_FILE_ANNOTATION_NAMES, FESBaseUrl } from "../../../../constants"; import Annotation from "../../../../entity/Annotation"; import { annotationsJson } from "../../../../entity/Annotation/mocks"; import FileFilter from "../../../../entity/FileFilter"; @@ -12,7 +12,7 @@ import HttpAnnotationService from ".."; describe("HttpAnnotationService", () => { describe("fetchAnnotations", () => { const httpClient = createMockHttpClient({ - when: `test/${HttpAnnotationService.BASE_ANNOTATION_URL}`, + when: `${FESBaseUrl.TEST}/${HttpAnnotationService.BASE_ANNOTATION_URL}`, respondWith: { data: { data: annotationsJson, @@ -21,13 +21,26 @@ describe("HttpAnnotationService", () => { }); it("issues request for all available Annotations", async () => { - const annotationService = new HttpAnnotationService({ baseUrl: "test", httpClient }); + const annotationService = new HttpAnnotationService({ + fileExplorerServiceBaseUrl: FESBaseUrl.TEST, + httpClient, + }); const annotations = await annotationService.fetchAnnotations(); expect(annotations.length).to.equal( annotationsJson.length + TOP_LEVEL_FILE_ANNOTATION_NAMES.length ); expect(annotations[0]).to.be.instanceOf(Annotation); }); + + it("renames Local File Path to File Path (Local VAST)", async () => { + const annotationService = new HttpAnnotationService({ + fileExplorerServiceBaseUrl: FESBaseUrl.TEST, + httpClient, + }); + const annotations = await annotationService.fetchAnnotations(); + const localPathAnnotation = annotations.find((a) => a.name === "Local File Path"); + expect(localPathAnnotation?.displayName).to.equal("File Path (Local VAST)"); + }); }); describe("fetchAnnotationValues", () => { @@ -35,7 +48,7 @@ describe("HttpAnnotationService", () => { const annotation = "foo"; const values = ["a", "b", "c"]; const httpClient = createMockHttpClient({ - when: `test/${HttpAnnotationService.BASE_ANNOTATION_URL}/${annotation}/values`, + when: `${FESBaseUrl.TEST}/${HttpAnnotationService.BASE_ANNOTATION_URL}/${annotation}/values`, respondWith: { data: { data: values, @@ -43,7 +56,10 @@ describe("HttpAnnotationService", () => { }, }); - const annotationService = new HttpAnnotationService({ baseUrl: "test", httpClient }); + const annotationService = new HttpAnnotationService({ + fileExplorerServiceBaseUrl: FESBaseUrl.TEST, + httpClient, + }); const actualValues = await annotationService.fetchValues(annotation); expect(actualValues.length).to.equal(values.length); expect(actualValues).to.be.deep.equal(values); @@ -54,7 +70,7 @@ describe("HttpAnnotationService", () => { it("issues a request for annotation values for the first level of the annotation hierarchy", async () => { const expectedValues = ["foo", "bar", "baz"]; const httpClient = createMockHttpClient({ - when: `test/${HttpAnnotationService.BASE_ANNOTATION_HIERARCHY_ROOT_URL}?order=foo`, + when: `${FESBaseUrl.TEST}/${HttpAnnotationService.BASE_ANNOTATION_HIERARCHY_ROOT_URL}?order=foo`, respondWith: { data: { data: expectedValues, @@ -62,7 +78,10 @@ describe("HttpAnnotationService", () => { }, }); - const annotationService = new HttpAnnotationService({ baseUrl: "test", httpClient }); + const annotationService = new HttpAnnotationService({ + fileExplorerServiceBaseUrl: FESBaseUrl.TEST, + httpClient, + }); const values = await annotationService.fetchRootHierarchyValues(["foo"], []); expect(values).to.equal(expectedValues); }); @@ -71,7 +90,7 @@ describe("HttpAnnotationService", () => { const expectedValues = ["foo", "bar", "baz"]; const httpClient = createMockHttpClient({ // note order of query params - when: `test/${HttpAnnotationService.BASE_ANNOTATION_HIERARCHY_ROOT_URL}?order=z&order=a&order=b&order=c`, + when: `${FESBaseUrl.TEST}/${HttpAnnotationService.BASE_ANNOTATION_HIERARCHY_ROOT_URL}?order=z&order=a&order=b&order=c`, respondWith: { data: { data: expectedValues, @@ -80,7 +99,10 @@ describe("HttpAnnotationService", () => { }); const getSpy = spy(httpClient, "get"); - const annotationService = new HttpAnnotationService({ baseUrl: "test", httpClient }); + const annotationService = new HttpAnnotationService({ + fileExplorerServiceBaseUrl: FESBaseUrl.TEST, + httpClient, + }); // first time around const firstCallRet = await annotationService.fetchRootHierarchyValues( @@ -105,7 +127,7 @@ describe("HttpAnnotationService", () => { it("issues a request for annotation values for the first level of the annotation hierarchy with filters", async () => { const expectedValues = ["foo", "barValue", "baz"]; const httpClient = createMockHttpClient({ - when: `test/${HttpAnnotationService.BASE_ANNOTATION_HIERARCHY_ROOT_URL}?order=foo&filter=bar=barValue`, + when: `${FESBaseUrl.TEST}/${HttpAnnotationService.BASE_ANNOTATION_HIERARCHY_ROOT_URL}?order=foo&filter=bar=barValue`, respondWith: { data: { data: expectedValues, @@ -113,7 +135,10 @@ describe("HttpAnnotationService", () => { }, }); - const annotationService = new HttpAnnotationService({ baseUrl: "test", httpClient }); + const annotationService = new HttpAnnotationService({ + fileExplorerServiceBaseUrl: FESBaseUrl.TEST, + httpClient, + }); const filter = new FileFilter("bar", "barValue"); const values = await annotationService.fetchRootHierarchyValues(["foo"], [filter]); expect(values).to.equal(expectedValues); @@ -124,7 +149,7 @@ describe("HttpAnnotationService", () => { it("issues request for hierarchy values under a specific path within the hierarchy", async () => { const expectedValues = [1, 2, 3]; const httpClient = createMockHttpClient({ - when: `test/${HttpAnnotationService.BASE_ANNOTATION_HIERARCHY_UNDER_PATH_URL}?order=foo&order=bar&path=baz`, + when: `${FESBaseUrl.TEST}/${HttpAnnotationService.BASE_ANNOTATION_HIERARCHY_UNDER_PATH_URL}?order=foo&order=bar&path=baz`, respondWith: { data: { data: expectedValues, @@ -132,7 +157,10 @@ describe("HttpAnnotationService", () => { }, }); - const annotationService = new HttpAnnotationService({ baseUrl: "test", httpClient }); + const annotationService = new HttpAnnotationService({ + fileExplorerServiceBaseUrl: FESBaseUrl.TEST, + httpClient, + }); const values = await annotationService.fetchHierarchyValuesUnderPath( ["foo", "bar"], ["baz"], @@ -144,7 +172,7 @@ describe("HttpAnnotationService", () => { it("issues request for hierarchy values under a specific path within the hierarchy with filters", async () => { const expectedValues = [1, "barValue", 3]; const httpClient = createMockHttpClient({ - when: `test/${HttpAnnotationService.BASE_ANNOTATION_HIERARCHY_UNDER_PATH_URL}?order=foo&order=bar&path=baz&filter=bar=barValue`, + when: `${FESBaseUrl.TEST}/${HttpAnnotationService.BASE_ANNOTATION_HIERARCHY_UNDER_PATH_URL}?order=foo&order=bar&path=baz&filter=bar=barValue`, respondWith: { data: { data: expectedValues, @@ -152,7 +180,10 @@ describe("HttpAnnotationService", () => { }, }); - const annotationService = new HttpAnnotationService({ baseUrl: "test", httpClient }); + const annotationService = new HttpAnnotationService({ + fileExplorerServiceBaseUrl: FESBaseUrl.TEST, + httpClient, + }); const filter = new FileFilter("bar", "barValue"); const values = await annotationService.fetchHierarchyValuesUnderPath( ["foo", "bar"], @@ -167,7 +198,7 @@ describe("HttpAnnotationService", () => { it("issues request for annotations that can be combined with current hierarchy", async () => { const annotationsFromServer = ["cell_dead", "date_created"]; const httpClient = createMockHttpClient({ - when: `test/${HttpAnnotationService.BASE_ANNOTATION_URL}/hierarchy/available?hierarchy=cas9&hierarchy=cell_line`, + when: `${FESBaseUrl.TEST}/${HttpAnnotationService.BASE_ANNOTATION_URL}/hierarchy/available?hierarchy=cas9&hierarchy=cell_line`, respondWith: { data: { data: annotationsFromServer, @@ -181,7 +212,10 @@ describe("HttpAnnotationService", () => { ...annotationsFromServer, ...hierarchy, ]; - const annotationService = new HttpAnnotationService({ baseUrl: "test", httpClient }); + const annotationService = new HttpAnnotationService({ + fileExplorerServiceBaseUrl: FESBaseUrl.TEST, + httpClient, + }); const values = await annotationService.fetchAvailableAnnotationsForHierarchy(hierarchy); expect(values.sort()).to.deep.equal(expectedValues.sort()); }); diff --git a/packages/core/services/FileService/HttpFileService/index.ts b/packages/core/services/FileService/HttpFileService/index.ts index f352cfe2e..ba29e972f 100644 --- a/packages/core/services/FileService/HttpFileService/index.ts +++ b/packages/core/services/FileService/HttpFileService/index.ts @@ -20,9 +20,11 @@ interface Config extends ConnectionConfig { * Service responsible for fetching file related metadata. */ export default class HttpFileService extends HttpServiceBase implements FileService { + private static readonly CACHE_ENDPOINT_VERSION = "v3.0"; private static readonly ENDPOINT_VERSION = "3.0"; public static readonly BASE_FILES_URL = `file-explorer-service/${HttpFileService.ENDPOINT_VERSION}/files`; public static readonly BASE_FILE_COUNT_URL = `${HttpFileService.BASE_FILES_URL}/count`; + public static readonly BASE_FILE_CACHE_URL = `fss2/${HttpFileService.CACHE_ENDPOINT_VERSION}/file/cache`; public static readonly SELECTION_AGGREGATE_URL = `${HttpFileService.BASE_FILES_URL}/selection/aggregate`; private static readonly CSV_ENDPOINT_VERSION = "2.0"; public static readonly BASE_CSV_DOWNLOAD_URL = `file-explorer-service/${HttpFileService.CSV_ENDPOINT_VERSION}/files/selection/manifest`; @@ -38,7 +40,9 @@ export default class HttpFileService extends HttpServiceBase implements FileServ */ public async isNetworkAccessible(): Promise { try { - await this.get(`${this.baseUrl}/${HttpFileService.BASE_FILE_COUNT_URL}`); + await this.get( + `${this.fileExplorerServiceBaseUrl}/${HttpFileService.BASE_FILE_COUNT_URL}` + ); return true; } catch (error) { console.error(`Unable to access AICS network ${error}`); @@ -49,7 +53,7 @@ export default class HttpFileService extends HttpServiceBase implements FileServ public async getCountOfMatchingFiles(fileSet: FileSet): Promise { const requestUrl = join( compact([ - `${this.baseUrl}/${HttpFileService.BASE_FILE_COUNT_URL}${this.pathSuffix}`, + `${this.fileExplorerServiceBaseUrl}/${HttpFileService.BASE_FILE_COUNT_URL}${this.pathSuffix}`, fileSet.toQueryString(), ]), "?" @@ -70,7 +74,7 @@ export default class HttpFileService extends HttpServiceBase implements FileServ ): Promise { const selections = fileSelection.toCompactSelectionList(); const postBody: SelectionAggregationRequest = { selections }; - const requestUrl = `${this.baseUrl}/${HttpFileService.SELECTION_AGGREGATE_URL}${this.pathSuffix}`; + const requestUrl = `${this.fileExplorerServiceBaseUrl}/${HttpFileService.SELECTION_AGGREGATE_URL}${this.pathSuffix}`; const response = await this.post( requestUrl, @@ -91,7 +95,7 @@ export default class HttpFileService extends HttpServiceBase implements FileServ public async getFiles(request: GetFilesRequest): Promise { const { from, limit, fileSet } = request; - const base = `${this.baseUrl}/${HttpFileService.BASE_FILES_URL}${this.pathSuffix}?from=${from}&limit=${limit}`; + const base = `${this.fileExplorerServiceBaseUrl}/${HttpFileService.BASE_FILES_URL}${this.pathSuffix}?from=${from}&limit=${limit}`; const requestUrl = join(compact([base, fileSet.toQueryString()]), "&"); const response = await this.get(requestUrl); @@ -113,7 +117,7 @@ export default class HttpFileService extends HttpServiceBase implements FileServ } const postData = JSON.stringify({ annotations, selections }); - const url = `${this.baseUrl}/${HttpFileService.BASE_CSV_DOWNLOAD_URL}${this.pathSuffix}`; + const url = `${this.fileExplorerServiceBaseUrl}/${HttpFileService.BASE_CSV_DOWNLOAD_URL}${this.pathSuffix}`; const manifest = await this.downloadService.prepareHttpResourceForDownload(url, postData); const name = `file-manifest-${new Date()}.csv`; @@ -127,4 +131,29 @@ export default class HttpFileService extends HttpServiceBase implements FileServ uniqueId() ); } + + /** + * Cache a list of files to NAS cache (VAST) by sending their IDs to FSS. + */ + public async cacheFiles( + fileIds: string[], + username?: string + ): Promise<{ cacheFileStatuses: { [fileId: string]: string } }> { + const requestUrl = `${this.loadBalancerBaseUrl}/${HttpFileService.BASE_FILE_CACHE_URL}${this.pathSuffix}`; + const requestBody = JSON.stringify({ fileIds }); + const headers = { + "Content-Type": "application/json", + "X-User-Id": username || "anonymous", + }; + + try { + const cacheStatuses = await this.rawPut<{ + cacheFileStatuses: { [fileId: string]: string }; + }>(requestUrl, requestBody, headers); + return cacheStatuses; + } catch (error) { + console.error("Failed to cache files:", error); + throw new Error("Unable to complete the caching request."); + } + } } diff --git a/packages/core/services/FileService/HttpFileService/test/HttpFileService.test.ts b/packages/core/services/FileService/HttpFileService/test/HttpFileService.test.ts index 4f5691804..adec881d1 100644 --- a/packages/core/services/FileService/HttpFileService/test/HttpFileService.test.ts +++ b/packages/core/services/FileService/HttpFileService/test/HttpFileService.test.ts @@ -2,13 +2,15 @@ import { createMockHttpClient } from "@aics/redux-utils"; import { expect } from "chai"; import HttpFileService from ".."; +import { FESBaseUrl, LoadBalancerBaseUrl } from "../../../../constants"; import FileSelection from "../../../../entity/FileSelection"; import FileSet from "../../../../entity/FileSet"; import NumericRange from "../../../../entity/NumericRange"; import FileDownloadServiceNoop from "../../../FileDownloadService/FileDownloadServiceNoop"; describe("HttpFileService", () => { - const baseUrl = "test"; + const fileExplorerServiceBaseUrl = FESBaseUrl.TEST; + const loadBalancerBaseUrl = LoadBalancerBaseUrl.TEST; const fileIds = ["abc123", "def456", "ghi789", "jkl012"]; const files = fileIds.map((file_id) => ({ file_id, @@ -28,7 +30,7 @@ describe("HttpFileService", () => { it("issues request for files that match given parameters", async () => { const httpFileService = new HttpFileService({ - baseUrl, + fileExplorerServiceBaseUrl: fileExplorerServiceBaseUrl, httpClient, downloadService: new FileDownloadServiceNoop(), }); @@ -48,7 +50,7 @@ describe("HttpFileService", () => { const totalFileSize = 12424114; const totalFileCount = 7; const httpClient = createMockHttpClient({ - when: `${baseUrl}/${HttpFileService.SELECTION_AGGREGATE_URL}`, + when: `${fileExplorerServiceBaseUrl}/${HttpFileService.SELECTION_AGGREGATE_URL}`, respondWith: { data: { data: [{ count: totalFileCount, size: totalFileSize }], @@ -59,7 +61,7 @@ describe("HttpFileService", () => { it("issues request for aggregated information about given files", async () => { // Arrange const fileService = new HttpFileService({ - baseUrl, + fileExplorerServiceBaseUrl, httpClient, downloadService: new FileDownloadServiceNoop(), }); @@ -78,25 +80,39 @@ describe("HttpFileService", () => { }); }); - describe("getCountOfMatchingFiles", () => { + describe("cacheFiles", () => { const httpClient = createMockHttpClient({ - when: `${baseUrl}/${HttpFileService.BASE_FILE_COUNT_URL}`, + when: `${loadBalancerBaseUrl}/${HttpFileService.BASE_FILE_CACHE_URL}`, respondWith: { data: { - data: [2], + cacheFileStatuses: { + abc123: "DOWNLOAD_COMPLETE", + def456: "ERROR", + }, }, }, }); - it("issues request for count of files matching given parameters", async () => { + it("sends file IDs to be cached and returns their statuses", async () => { + // Arrange const fileService = new HttpFileService({ - baseUrl, + loadBalancerBaseUrl: loadBalancerBaseUrl, httpClient, downloadService: new FileDownloadServiceNoop(), }); - const fileSet = new FileSet(); - const count = await fileService.getCountOfMatchingFiles(fileSet); - expect(count).to.equal(2); + const fileIds = ["abc123", "def456"]; + const username = "test.user"; + + // Act + const response = await fileService.cacheFiles(fileIds, username); + + // Assert + expect(response).to.deep.equal({ + cacheFileStatuses: { + abc123: "DOWNLOAD_COMPLETE", + def456: "ERROR", + }, + }); }); }); }); diff --git a/packages/core/services/FileService/index.ts b/packages/core/services/FileService/index.ts index a540f2da4..9b5a53474 100644 --- a/packages/core/services/FileService/index.ts +++ b/packages/core/services/FileService/index.ts @@ -39,7 +39,7 @@ export interface Selection { } export default interface FileService { - baseUrl?: string; + fileExplorerServiceBaseUrl?: string; download( annotations: string[], selections: Selection[], diff --git a/packages/core/services/HttpServiceBase/index.ts b/packages/core/services/HttpServiceBase/index.ts index 1c89ae508..220ec42a9 100644 --- a/packages/core/services/HttpServiceBase/index.ts +++ b/packages/core/services/HttpServiceBase/index.ts @@ -2,20 +2,24 @@ import axios, { AxiosInstance } from "axios"; import { Policy } from "cockatiel"; import LRUCache from "lru-cache"; -import { FileExplorerServiceBaseUrl } from "../../constants"; +import { FESBaseUrl, LoadBalancerBaseUrl, MMSBaseUrl } from "../../constants"; import RestServiceResponse from "../../entity/RestServiceResponse"; export interface ConnectionConfig { applicationVersion?: string; - baseUrl?: string | keyof typeof FileExplorerServiceBaseUrl; + fileExplorerServiceBaseUrl?: FESBaseUrl; httpClient?: AxiosInstance; + loadBalancerBaseUrl?: LoadBalancerBaseUrl; + metadataManagementServiceBaseURl?: MMSBaseUrl; pathSuffix?: string; userName?: string; } export const DEFAULT_CONNECTION_CONFIG = { - baseUrl: FileExplorerServiceBaseUrl.PRODUCTION, + fileExplorerServiceBaseUrl: FESBaseUrl.PRODUCTION, httpClient: axios.create(), + loadBalancerBaseUrl: LoadBalancerBaseUrl.PRODUCTION, + metadataManagementServiceBaseURl: MMSBaseUrl.PRODUCTION, }; const CHARACTER_TO_ENCODING_MAP: { [index: string]: string } = { @@ -45,7 +49,7 @@ const retry = Policy.handleAll() }); /** - * Base class for services that interact with AICS APIs. + * Base class for services that interact with APIs. */ export default class HttpServiceBase { /** @@ -66,7 +70,7 @@ export default class HttpServiceBase { } // encode ampersands that do not separate query string components, so first - // need to separate the query string componenets (which are split by ampersands themselves) + // need to separate the query string components (which are split by ampersands themselves) // handles case like `workflow=R&DExp&cell_line=AICS-46&foo=bar&cTnT%=3.0` const re = /&(?=(?:[^&])+\=)/g; const queryStringComponents = queryString.split(re); @@ -97,8 +101,12 @@ export default class HttpServiceBase { .join(""); } - public baseUrl: string | keyof typeof FileExplorerServiceBaseUrl = - DEFAULT_CONNECTION_CONFIG.baseUrl; + public fileExplorerServiceBaseUrl: string = + DEFAULT_CONNECTION_CONFIG.fileExplorerServiceBaseUrl; + public loadBalancerBaseUrl: string = DEFAULT_CONNECTION_CONFIG.loadBalancerBaseUrl; + public metadataManagementServiceBaseURl: string = + DEFAULT_CONNECTION_CONFIG.metadataManagementServiceBaseURl; + protected httpClient = DEFAULT_CONNECTION_CONFIG.httpClient; private applicationVersion = "NOT SET"; private userName?: string; @@ -114,14 +122,22 @@ export default class HttpServiceBase { this.setUserName(config.userName); } - if (config.baseUrl) { - this.setBaseUrl(config.baseUrl); + if (config.fileExplorerServiceBaseUrl) { + this.setFileExplorerServiceBaseUrl(config.fileExplorerServiceBaseUrl); } if (config.httpClient) { this.setHttpClient(config.httpClient); } + if (config.loadBalancerBaseUrl) { + this.setLoadBalancerBaseUrl(config.loadBalancerBaseUrl); + } + + if (config.metadataManagementServiceBaseURl) { + this.setMetadataManagementServiceBaseURl(config.metadataManagementServiceBaseURl); + } + if (config.pathSuffix) { this.pathSuffix = config.pathSuffix; } @@ -186,6 +202,32 @@ export default class HttpServiceBase { return response.data; } + public async rawPut( + url: string, + body: string, + headers: { [key: string]: string } = {} + ): Promise { + const encodedUrl = HttpServiceBase.encodeURI(url); + const config = { headers: { ...headers } }; + + let response; + try { + // Retry policy wrapped around axios PUT + response = await retry.execute(() => this.httpClient.put(encodedUrl, body, config)); + } catch (err) { + if (axios.isAxiosError(err) && err?.response?.data?.message) { + throw new Error(JSON.stringify(err.response.data.message)); + } + throw err; + } + + if (response.status >= 400 || response.data === undefined) { + throw new Error(`Request for ${encodedUrl} failed`); + } + + return response.data; + } + public async post(url: string, body: string): Promise> { const encodedUrl = HttpServiceBase.encodeURI(url); const config = { headers: { "Content-Type": "application/json" } }; @@ -239,18 +281,13 @@ export default class HttpServiceBase { this.setHeaders(); } - public setUserName(userName: string) { - this.userName = userName; - this.setHeaders(); - } - - public setBaseUrl(baseUrl: string | keyof typeof FileExplorerServiceBaseUrl) { - if (this.baseUrl !== baseUrl) { + public setFileExplorerServiceBaseUrl(fileExplorerServiceBaseUrl: FESBaseUrl) { + if (this.fileExplorerServiceBaseUrl !== fileExplorerServiceBaseUrl) { // bust cache when base url changes this.urlToResponseDataCache.reset(); } - this.baseUrl = baseUrl; + this.fileExplorerServiceBaseUrl = fileExplorerServiceBaseUrl; } public setHttpClient(client: AxiosInstance) { @@ -273,4 +310,27 @@ export default class HttpServiceBase { delete this.httpClient.defaults.headers.common["X-User-Id"]; } } + + public setLoadBalancerBaseUrl(loadBalancerBaseUrl: LoadBalancerBaseUrl) { + if (this.loadBalancerBaseUrl !== loadBalancerBaseUrl) { + // bust cache when base url changes + this.urlToResponseDataCache.reset(); + } + + this.loadBalancerBaseUrl = loadBalancerBaseUrl; + } + + public setMetadataManagementServiceBaseURl(metadataManagementServiceBaseURl: MMSBaseUrl) { + if (this.metadataManagementServiceBaseURl !== metadataManagementServiceBaseURl) { + // bust cache when base url changes + this.urlToResponseDataCache.reset(); + } + + this.metadataManagementServiceBaseURl = metadataManagementServiceBaseURl; + } + + public setUserName(userName: string) { + this.userName = userName; + this.setHeaders(); + } } diff --git a/packages/core/services/PersistentConfigService/index.ts b/packages/core/services/PersistentConfigService/index.ts index e47c81512..5e1c0fb2a 100644 --- a/packages/core/services/PersistentConfigService/index.ts +++ b/packages/core/services/PersistentConfigService/index.ts @@ -1,4 +1,5 @@ import { AnnotationResponse } from "../../entity/Annotation"; +import { Environment } from "../../constants"; import { Column, Query } from "../../state/selection/actions"; /** @@ -14,6 +15,7 @@ export enum PersistedConfigKeys { UserSelectedApplications = "USER_SELECTED_APPLICATIONS", Queries = "QUERIES", RecentAnnotations = "RECENT_ANNOTATIONS", + Environment = "ENVIRONMENT", } export interface UserSelectedApplication { @@ -26,6 +28,7 @@ export interface PersistedConfig { [PersistedConfigKeys.Columns]?: Column[]; [PersistedConfigKeys.CsvColumns]?: string[]; [PersistedConfigKeys.DisplayAnnotations]?: AnnotationResponse[]; + [PersistedConfigKeys.Environment]?: Environment; [PersistedConfigKeys.ImageJExecutable]?: string; // Deprecated [PersistedConfigKeys.HasUsedApplicationBefore]?: boolean; [PersistedConfigKeys.Queries]?: Query[]; diff --git a/packages/core/state/interaction/actions.ts b/packages/core/state/interaction/actions.ts index 441dca1eb..40ac7bec5 100644 --- a/packages/core/state/interaction/actions.ts +++ b/packages/core/state/interaction/actions.ts @@ -260,12 +260,10 @@ export interface InitializeApp { payload: string; } -export function initializeApp(baseUrl: string): InitializeApp { - return { - type: INITIALIZE_APP, - payload: baseUrl, - }; -} +export const initializeApp = (payload: { environment: string }) => ({ + type: INITIALIZE_APP, + payload, +}); /** * PROCESS AND STATUS RELATED ENUMS, INTERFACES, ETC. @@ -679,3 +677,39 @@ export function setSelectedPublicDataset(dataset: PublicDataset): SetSelectedPub type: SET_SELECTED_PUBLIC_DATASET, }; } + +/** + * SHOW_COPY_FILE_MANIFEST + * + * Action to show the Copy File dialog (manifest) for NAS cache operations. + * This modal will allow users to copy files onto the NAS cache. + */ +export const SHOW_COPY_FILE_MANIFEST = makeConstant(STATE_BRANCH_NAME, "show-copy-file-manifest"); + +export interface ShowCopyFileManifestAction { + type: string; +} + +export function showCopyFileManifest(): ShowCopyFileManifestAction { + return { + type: SHOW_COPY_FILE_MANIFEST, + }; +} + +export const COPY_FILES = makeConstant(STATE_BRANCH_NAME, "copy-files"); + +export interface CopyFilesAction { + type: string; + payload: { + fileDetails: FileDetail[]; + }; +} + +export function copyFiles(fileDetails: FileDetail[]): CopyFilesAction { + return { + type: COPY_FILES, + payload: { + fileDetails, + }, + }; +} diff --git a/packages/core/state/interaction/logics.ts b/packages/core/state/interaction/logics.ts index 94ef5e225..a29987f6e 100644 --- a/packages/core/state/interaction/logics.ts +++ b/packages/core/state/interaction/logics.ts @@ -2,7 +2,7 @@ import { isEmpty, sumBy, throttle, uniq, uniqueId } from "lodash"; import { AnyAction } from "redux"; import { createLogic } from "redux-logic"; -import { metadata, ReduxLogicDeps, selection } from "../"; +import { interaction, metadata, ReduxLogicDeps, selection } from "../"; import { DOWNLOAD_MANIFEST, DownloadManifestAction, @@ -31,6 +31,8 @@ import { SetIsSmallScreenAction, setVisibleModal, hideVisibleModal, + CopyFilesAction, + COPY_FILES, } from "./actions"; import * as interactionSelectors from "./selectors"; import { DownloadResolution, FileInfo } from "../../services/FileDownloadService"; @@ -575,6 +577,93 @@ const setIsSmallScreen = createLogic({ type: SET_IS_SMALL_SCREEN, }); +/** + * Interceptor responsible for handling the COPY_FILES action. + * Logs details of files that are being copied to cache. + */ +const copyFilesLogic = createLogic({ + async process({ action, getState }: ReduxLogicDeps, dispatch, done) { + try { + const httpFileService = interactionSelectors.getHttpFileService(getState()); + const username = interactionSelectors.getUserName(getState()); + + const fileDetails = (action as CopyFilesAction).payload.fileDetails; + + // Map file IDs to file names for easy lookup + const fileIdToNameMap = Object.fromEntries( + fileDetails.map((file) => [file.id, file.name]) + ); + + // Extract file IDs + const fileIds = fileDetails.map((file) => file.id); + + const response = await httpFileService.cacheFiles(fileIds, username); + const cacheStatuses = response.cacheFileStatuses; + + // Check if the response is empty. + if (!cacheStatuses || Object.keys(cacheStatuses).length === 0) { + dispatch( + interaction.actions.processError( + "moveFilesNoFilesProcessed", + "No files were processed. Please check the request or try again." + ) + ); + return; + } + + const successfulFiles: string[] = []; + const failedFiles: string[] = []; + + Object.entries(cacheStatuses).forEach(([fileId, status]) => { + if ( + status === "DOWNLOAD_COMPLETE" || + status === "DOWNLOAD_IN_PROGRESS" || + status === "DOWNLOAD_STARTED" + ) { + successfulFiles.push(fileId); + } else { + failedFiles.push(fileId); + } + }); + + // Dispatch net success message. (Assuming some files were successful) + if (successfulFiles.length > 0) { + dispatch( + interaction.actions.processSuccess( + "moveFilesSuccess", + `${successfulFiles.length} out of ${ + Object.keys(cacheStatuses).length + } files were successfully queued for download to NAS (Vast) from cloud. + Files will be available in the NAS after downloads finish asynchronously` + ) + ); + } + + // Dispatch individual errors for each failed file. + failedFiles.forEach((fileId) => { + const fileName = fileIdToNameMap[fileId] || "Unknown File"; + dispatch( + interaction.actions.processError( + `moveFileFailure_${fileId}`, + `File "${fileName}" failed to cache. Status: ${cacheStatuses[fileId]}` + ) + ); + }); + } catch (err) { + // Service call itself fails + dispatch( + interaction.actions.processError( + "moveFilesFailure", + `Failed to cache files, details: ${(err as Error).message}.` + ) + ); + } finally { + done(); + } + }, + type: COPY_FILES, +}); + export default [ initializeApp, downloadManifest, @@ -586,4 +675,5 @@ export default [ showContextMenu, refresh, setIsSmallScreen, + copyFilesLogic, ]; diff --git a/packages/core/state/interaction/reducer.ts b/packages/core/state/interaction/reducer.ts index 4a8905f79..8abb5b9fc 100644 --- a/packages/core/state/interaction/reducer.ts +++ b/packages/core/state/interaction/reducer.ts @@ -15,6 +15,7 @@ import { SHOW_CONTEXT_MENU, SHOW_DATASET_DETAILS_PANEL, SHOW_MANIFEST_DOWNLOAD_DIALOG, + SHOW_COPY_FILE_MANIFEST, StatusUpdate, MARK_AS_USED_APPLICATION_BEFORE, MARK_AS_DISMISSED_SMALL_SCREEN_WARNING, @@ -29,12 +30,12 @@ import { } from "./actions"; import { ContextMenuItem, PositionReference } from "../../components/ContextMenu"; import { ModalType } from "../../components/Modal"; +import { Environment } from "../../constants"; import FileFilter from "../../entity/FileFilter"; import { PlatformDependentServices } from "../../services"; import ApplicationInfoServiceNoop from "../../services/ApplicationInfoService/ApplicationInfoServiceNoop"; import FileDownloadServiceNoop from "../../services/FileDownloadService/FileDownloadServiceNoop"; import FileViewerServiceNoop from "../../services/FileViewerService/FileViewerServiceNoop"; -import { DEFAULT_CONNECTION_CONFIG } from "../../services/HttpServiceBase"; import ExecutionEnvServiceNoop from "../../services/ExecutionEnvService/ExecutionEnvServiceNoop"; import { UserSelectedApplication } from "../../services/PersistentConfigService"; import NotificationServiceNoop from "../../services/NotificationService/NotificationServiceNoop"; @@ -50,9 +51,9 @@ export interface InteractionStateBranch { csvColumns?: string[]; dataSourceInfoForVisibleModal?: DataSourcePromptInfo; datasetDetailsPanelIsVisible: boolean; - fileExplorerServiceBaseUrl: string; fileTypeForVisibleModal: "csv" | "json" | "parquet"; fileFiltersForVisibleModal: FileFilter[]; + environment: "LOCALHOST" | "PRODUCTION" | "STAGING" | "TEST"; hasDismissedSmallScreenWarning: boolean; hasUsedApplicationBefore: boolean; isAicsEmployee?: boolean; @@ -66,6 +67,7 @@ export interface InteractionStateBranch { } export const initialState: InteractionStateBranch = { + environment: Environment.PRODUCTION, contextMenuIsVisible: false, contextMenuItems: [], // Passed to `ContextualMenu` as `target`. From the "@fluentui/react" docs: @@ -74,7 +76,6 @@ export const initialState: InteractionStateBranch = { // If a MouseEvent is given, the origin point of the event will be used." contextMenuPositionReference: null, datasetDetailsPanelIsVisible: false, - fileExplorerServiceBaseUrl: DEFAULT_CONNECTION_CONFIG.baseUrl, fileFiltersForVisibleModal: [], fileTypeForVisibleModal: "csv", hasDismissedSmallScreenWarning: false, @@ -165,7 +166,7 @@ export default makeReducer( }), [INITIALIZE_APP]: (state, action) => ({ ...state, - fileExplorerServiceBaseUrl: action.payload, + environment: action.payload.environment, }), [SET_VISIBLE_MODAL]: (state, action) => ({ ...state, @@ -195,6 +196,10 @@ export default makeReducer( ...state, selectedPublicDataset: action.payload, }), + [SHOW_COPY_FILE_MANIFEST]: (state) => ({ + ...state, + visibleModal: ModalType.CopyFileManifest, + }), }, initialState ); diff --git a/packages/core/state/interaction/selectors.ts b/packages/core/state/interaction/selectors.ts index ec8802285..553de3f46 100644 --- a/packages/core/state/interaction/selectors.ts +++ b/packages/core/state/interaction/selectors.ts @@ -14,9 +14,15 @@ import DatabaseFileService from "../../services/FileService/DatabaseFileService" import HttpAnnotationService from "../../services/AnnotationService/HttpAnnotationService"; import HttpFileService from "../../services/FileService/HttpFileService"; import { ModalType } from "../../components/Modal"; -import { AICS_FMS_DATA_SOURCE_NAME } from "../../constants"; +import { + AICS_FMS_DATA_SOURCE_NAME, + FESBaseUrl, + MMSBaseUrl, + LoadBalancerBaseUrl, +} from "../../constants"; // BASIC SELECTORS +export const getEnvironment = (state: State) => state.interaction.environment; export const getContextMenuVisibility = (state: State) => state.interaction.contextMenuIsVisible; export const getContextMenuItems = (state: State) => state.interaction.contextMenuItems; export const getContextMenuPositionReference = (state: State) => @@ -28,8 +34,6 @@ export const getDataSourceInfoForVisibleModal = (state: State) => export const getDatasetDetailsVisibility = (state: State) => state.interaction.datasetDetailsPanelIsVisible; export const getSelectedPublicDataset = (state: State) => state.interaction.selectedPublicDataset; -export const getFileExplorerServiceBaseUrl = (state: State) => - state.interaction.fileExplorerServiceBaseUrl; export const getFileFiltersForVisibleModal = (state: State) => state.interaction.fileFiltersForVisibleModal; export const getFileTypeForVisibleModal = (state: State) => @@ -48,6 +52,22 @@ export const getUserSelectedApplications = (state: State) => export const getVisibleModal = (state: State) => state.interaction.visibleModal; export const isAicsEmployee = (state: State) => state.interaction.isAicsEmployee; +// URL Mapping Selectors +export const getFileExplorerServiceBaseUrl = createSelector( + [getEnvironment], + (environment) => FESBaseUrl[environment] +); + +export const getLoadBalancerBaseUrl = createSelector( + [getEnvironment], + (environment) => LoadBalancerBaseUrl[environment] +); + +export const getMetadataManagementServiceBaseUrl = createSelector( + [getEnvironment], + (environment) => MMSBaseUrl[environment] +); + // COMPOSED SELECTORS export const getApplicationVersion = createSelector( [getPlatformDependentServices], @@ -101,16 +121,27 @@ export const getUserName = createSelector( export const getHttpFileService = createSelector( [ getApplicationVersion, - getUserName, getFileExplorerServiceBaseUrl, + getLoadBalancerBaseUrl, + getMetadataManagementServiceBaseUrl, + getUserName, getPlatformDependentServices, getRefreshKey, ], - (applicationVersion, userName, fileExplorerBaseUrl, platformDependentServices) => + ( + applicationVersion, + fileExplorerServiceBaseUrl, + loadBalancerBaseUrl, + metadataManagementServiceBaseURL, + userName, + platformDependentServices + ) => new HttpFileService({ applicationVersion, + fileExplorerServiceBaseUrl: fileExplorerServiceBaseUrl, + loadBalancerBaseUrl: loadBalancerBaseUrl, + metadataManagementServiceBaseURl: metadataManagementServiceBaseURL, userName, - baseUrl: fileExplorerBaseUrl, downloadService: platformDependentServices.fileDownloadService, }) ); @@ -161,7 +192,7 @@ export const getAnnotationService = createSelector( ( applicationVersion, userName, - fileExplorerBaseUrl, + fileExplorerServiceBaseUrl, dataSources, platformDependentServices ): AnnotationService => { @@ -174,18 +205,18 @@ export const getAnnotationService = createSelector( return new HttpAnnotationService({ applicationVersion, userName, - baseUrl: fileExplorerBaseUrl, + fileExplorerServiceBaseUrl: fileExplorerServiceBaseUrl, }); } ); export const getDatasetService = createSelector( [getApplicationVersion, getUserName, getFileExplorerServiceBaseUrl, getRefreshKey], - (applicationVersion, userName, fileExplorerBaseUrl) => + (applicationVersion, userName, fileExplorerServiceBaseUrl) => new DatasetService({ applicationVersion, userName, - baseUrl: fileExplorerBaseUrl, + fileExplorerServiceBaseUrl: fileExplorerServiceBaseUrl, }) ); diff --git a/packages/core/state/interaction/test/logics.test.ts b/packages/core/state/interaction/test/logics.test.ts index 688c55321..f9dc58c19 100644 --- a/packages/core/state/interaction/test/logics.test.ts +++ b/packages/core/state/interaction/test/logics.test.ts @@ -24,6 +24,7 @@ import { } from "../../../services/ExecutionEnvService"; import ExecutionEnvServiceNoop from "../../../services/ExecutionEnvService/ExecutionEnvServiceNoop"; import interactionLogics from "../logics"; +import { FESBaseUrl } from "../../../constants"; import Annotation from "../../../entity/Annotation"; import AnnotationName from "../../../entity/Annotation/AnnotationName"; import { AnnotationType } from "../../../entity/AnnotationFormatter"; @@ -206,7 +207,7 @@ describe("Interaction logics", () => { it("doesn't use selected files when given a specific file folder path", async () => { // arrange - const baseUrl = "test"; + const fileExplorerServiceBaseUrl = FESBaseUrl.TEST; const filters = [ new FileFilter("Cell Line", "AICS-12"), new FileFilter("Notes", "Hello"), @@ -215,7 +216,6 @@ describe("Interaction logics", () => { const state = mergeState(initialState, { interaction: { fileFiltersForVisibleModal: filters, - fileExplorerServiceBaseUrl: baseUrl, platformDependentServices: { fileDownloadService: new FileDownloadServiceNoop(), }, @@ -232,7 +232,7 @@ describe("Interaction logics", () => { }; const mockHttpClient = createMockHttpClient(responseStub); const fileService = new HttpFileService({ - baseUrl, + fileExplorerServiceBaseUrl, httpClient: mockHttpClient, downloadService: new FileDownloadServiceNoop(), }); @@ -776,12 +776,12 @@ describe("Interaction logics", () => { describe("refresh", () => { const sandbox = createSandbox(); - const baseUrl = "test"; + const fileExplorerServiceBaseUrl = FESBaseUrl.TEST; const annotations = annotationsJson.map((annotation) => new Annotation(annotation)); const availableAnnotations = [annotations[1].displayName]; const responseStubs = [ { - when: `${baseUrl}/${HttpAnnotationService.BASE_ANNOTATION_URL}`, + when: `${fileExplorerServiceBaseUrl}/${HttpAnnotationService.BASE_ANNOTATION_URL}`, respondWith: { data: { data: annotations }, }, @@ -798,7 +798,7 @@ describe("Interaction logics", () => { ]; const mockHttpClient = createMockHttpClient(responseStubs); const annotationService = new HttpAnnotationService({ - baseUrl, + fileExplorerServiceBaseUrl, httpClient: mockHttpClient, }); @@ -883,7 +883,7 @@ describe("Interaction logics", () => { ], }); } - const baseUrl = "test"; + const fileExplorerServiceBaseUrl = FESBaseUrl.TEST; const responseStub = { when: () => true, respondWith: { @@ -892,7 +892,7 @@ describe("Interaction logics", () => { }; const mockHttpClient = createMockHttpClient(responseStub); const fileService = new HttpFileService({ - baseUrl, + fileExplorerServiceBaseUrl, httpClient: mockHttpClient, downloadService: new FileDownloadServiceNoop(), }); @@ -1099,16 +1099,16 @@ describe("Interaction logics", () => { }); } const files = [...csvFiles, ...pngFiles]; - const baseUrl = "test"; + const fileExplorerServiceBaseUrl = FESBaseUrl.TEST; const responseStub = { - when: `${baseUrl}/${HttpFileService.BASE_FILES_URL}?from=0&limit=101`, + when: `${fileExplorerServiceBaseUrl}/${HttpFileService.BASE_FILES_URL}?from=0&limit=101`, respondWith: { data: { data: files }, }, }; const mockHttpClient = createMockHttpClient(responseStub); const fileService = new HttpFileService({ - baseUrl, + fileExplorerServiceBaseUrl, httpClient: mockHttpClient, downloadService: new FileDownloadServiceNoop(), }); @@ -1214,16 +1214,16 @@ describe("Interaction logics", () => { for (let i = 0; i <= 100; i++) { files.push({ file_path: `/allen/file_${i}.ext` }); } - const baseUrl = "test"; + const fileExplorerServiceBaseUrl = FESBaseUrl.TEST; const responseStub = { - when: `${baseUrl}/${HttpFileService.BASE_FILES_URL}?from=0&limit=101`, + when: `${fileExplorerServiceBaseUrl}/${HttpFileService.BASE_FILES_URL}?from=0&limit=101`, respondWith: { data: { data: files }, }, }; const mockHttpClient = createMockHttpClient(responseStub); const fileService = new HttpFileService({ - baseUrl, + fileExplorerServiceBaseUrl, httpClient: mockHttpClient, downloadService: new FileDownloadServiceNoop(), }); diff --git a/packages/core/state/metadata/test/logics.test.ts b/packages/core/state/metadata/test/logics.test.ts index 9edb3717a..163f2a251 100644 --- a/packages/core/state/metadata/test/logics.test.ts +++ b/packages/core/state/metadata/test/logics.test.ts @@ -19,11 +19,7 @@ describe("Metadata logics", () => { describe("requestAnnotations", () => { it("Fires RECEIVE_ANNOTATIONS action after processing REQUEST_ANNOTATIONS action", async () => { // arrange - const state = mergeState(initialState, { - interaction: { - fileExplorerServiceBaseUrl: "test", - }, - }); + const state = mergeState(initialState, {}); const responseStub = { when: () => true, diff --git a/packages/core/state/selection/test/logics.test.ts b/packages/core/state/selection/test/logics.test.ts index edb8a9412..697ec38a5 100644 --- a/packages/core/state/selection/test/logics.test.ts +++ b/packages/core/state/selection/test/logics.test.ts @@ -27,6 +27,7 @@ import { changeSourceMetadata, } from "../actions"; import { initialState, interaction } from "../../"; +import { FESBaseUrl } from "../../../constants"; import Annotation from "../../../entity/Annotation"; import AnnotationName from "../../../entity/Annotation/AnnotationName"; import FileFilter from "../../../entity/FileFilter"; @@ -330,10 +331,10 @@ describe("Selection logics", () => { }, }, ]; - const baseUrl = "test"; + const fileExplorerServiceBaseUrl = FESBaseUrl.TEST; const mockHttpClient = createMockHttpClient(responseStubs); const fileService = new HttpFileService({ - baseUrl, + fileExplorerServiceBaseUrl, httpClient: mockHttpClient, downloadService: new FileDownloadServiceNoop(), }); @@ -354,9 +355,6 @@ describe("Selection logics", () => { it("selects file above current focused row", async () => { // Arrange const state = mergeState(initialState, { - interaction: { - fileExplorerServiceBaseUrl: baseUrl, - }, selection: { fileSelection: new FileSelection() .select({ @@ -398,9 +396,6 @@ describe("Selection logics", () => { sortOrder: 1, }); const state = mergeState(initialState, { - interaction: { - fileExplorerServiceBaseUrl: baseUrl, - }, selection: { fileSelection: new FileSelection().select({ fileSet, @@ -462,9 +457,6 @@ describe("Selection logics", () => { it("adds a new annotation to the end of the hierarchy", async () => { // setup const state = mergeState(initialState, { - interaction: { - fileExplorerServiceBaseUrl: "test", - }, metadata: { annotations: [...annotations], }, @@ -494,9 +486,6 @@ describe("Selection logics", () => { it("moves an annotation within the hierarchy to a new position", async () => { // setup const state = mergeState(initialState, { - interaction: { - fileExplorerServiceBaseUrl: "test", - }, metadata: { annotations: [...annotations], }, @@ -540,9 +529,6 @@ describe("Selection logics", () => { // ones to test proper comparison using annotationName const annotationHierarchy = annotations.slice(0, 4).map((a) => a.name); const state = mergeState(initialState, { - interaction: { - fileExplorerServiceBaseUrl: "test", - }, metadata: { annotations: [...annotations], }, @@ -580,9 +566,6 @@ describe("Selection logics", () => { new FileFolder(["AICS-0", "false"]), ]; const state = mergeState(initialState, { - interaction: { - fileExplorerServiceBaseUrl: "test", - }, metadata: { annotations: [...annotations], }, @@ -612,9 +595,6 @@ describe("Selection logics", () => { it("determines which paths can still be opened after annotation is added", async () => { // setup const state = mergeState(initialState, { - interaction: { - fileExplorerServiceBaseUrl: "test", - }, metadata: { annotations: [...annotations], }, @@ -647,9 +627,6 @@ describe("Selection logics", () => { it("determines which paths can still be opened after annotation is removed", async () => { // setup const state = mergeState(initialState, { - interaction: { - fileExplorerServiceBaseUrl: "test", - }, metadata: { annotations: [...annotations], }, @@ -711,9 +688,6 @@ describe("Selection logics", () => { it("sets available annotations", async () => { // Arrange const state = mergeState(initialState, { - interaction: { - fileExplorerServiceBaseUrl: "test", - }, metadata: { annotations: [...annotations], }, @@ -752,9 +726,6 @@ describe("Selection logics", () => { it("sets all annotations as available when actual cannot be found", async () => { // Arrange const state = mergeState(initialState, { - interaction: { - fileExplorerServiceBaseUrl: "test", - }, metadata: { annotations: [...annotations], }, diff --git a/packages/core/state/selection/test/reducer.test.ts b/packages/core/state/selection/test/reducer.test.ts index 4dbaa5dcd..32b9ae7e3 100644 --- a/packages/core/state/selection/test/reducer.test.ts +++ b/packages/core/state/selection/test/reducer.test.ts @@ -15,7 +15,9 @@ import { DataSource } from "../../../services/DataSourceService"; describe("Selection reducer", () => { [ selection.actions.setAnnotationHierarchy([]), - interaction.actions.initializeApp("base"), + interaction.actions.initializeApp({ + environment: "TEST", + }), ].forEach((expectedAction) => it(`clears selected file state when ${expectedAction.type} is fired`, () => { // arrange diff --git a/packages/desktop/src/main/global.d.ts b/packages/desktop/src/main/global.d.ts index 5a194e88c..cbf699645 100644 --- a/packages/desktop/src/main/global.d.ts +++ b/packages/desktop/src/main/global.d.ts @@ -1,4 +1,7 @@ /*eslint no-var: "off"*/ -// necessary in order to do: global.fileExplorerServiceBaseUrl = "..." -declare var fileDownloadServiceBaseUrl: string; -declare var fileExplorerServiceBaseUrl: string; +// necessary in order to do: global.environment = "..." +import { Environment } from "./util/constants"; + +declare global { + var environment: Environment; +} diff --git a/packages/desktop/src/main/menu/data-source.ts b/packages/desktop/src/main/menu/data-source.ts index 979463080..930575fa3 100644 --- a/packages/desktop/src/main/menu/data-source.ts +++ b/packages/desktop/src/main/menu/data-source.ts @@ -1,14 +1,9 @@ import { MenuItemConstructorOptions } from "electron"; -import { - GlobalVariableChannels, - FileDownloadServiceBaseUrl, - FileExplorerServiceBaseUrl, -} from "../../util/constants"; +import { GlobalVariableChannels, Environment } from "../../util/constants"; // Least effort state management accessible to both the main and renderer processes. -global.fileDownloadServiceBaseUrl = FileDownloadServiceBaseUrl.PRODUCTION; -global.fileExplorerServiceBaseUrl = FileExplorerServiceBaseUrl.PRODUCTION; +global.environment = Environment.PRODUCTION; const dataSourceMenu: MenuItemConstructorOptions = { label: "Data Source", @@ -16,12 +11,12 @@ const dataSourceMenu: MenuItemConstructorOptions = { { label: "Localhost", type: "radio", - checked: global.fileExplorerServiceBaseUrl === FileExplorerServiceBaseUrl.LOCALHOST, + checked: global.environment === Environment.LOCALHOST, click: (_, focusedWindow) => { if (focusedWindow) { + global.environment = Environment.LOCALHOST; focusedWindow.webContents.send(GlobalVariableChannels.BaseUrl, { - fileExplorerServiceBaseUrl: FileExplorerServiceBaseUrl.LOCALHOST, - fileDownloadServiceBaseUrl: FileDownloadServiceBaseUrl.LOCALHOST, + environment: Environment.LOCALHOST, }); } }, @@ -29,12 +24,12 @@ const dataSourceMenu: MenuItemConstructorOptions = { { label: "Staging", type: "radio", - checked: global.fileExplorerServiceBaseUrl === FileExplorerServiceBaseUrl.STAGING, + checked: global.environment === Environment.STAGING, click: (_, focusedWindow) => { if (focusedWindow) { + global.environment = Environment.STAGING; focusedWindow.webContents.send(GlobalVariableChannels.BaseUrl, { - fileExplorerServiceBaseUrl: FileExplorerServiceBaseUrl.STAGING, - fileDownloadServiceBaseUrl: FileDownloadServiceBaseUrl.STAGING, + environment: Environment.STAGING, }); } }, @@ -42,12 +37,12 @@ const dataSourceMenu: MenuItemConstructorOptions = { { label: "Production", type: "radio", - checked: global.fileExplorerServiceBaseUrl === FileExplorerServiceBaseUrl.PRODUCTION, + checked: global.environment === Environment.PRODUCTION, click: (_, focusedWindow) => { if (focusedWindow) { + global.environment = Environment.PRODUCTION; focusedWindow.webContents.send(GlobalVariableChannels.BaseUrl, { - fileExplorerServiceBaseUrl: FileExplorerServiceBaseUrl.PRODUCTION, - fileDownloadServiceBaseUrl: FileDownloadServiceBaseUrl.PRODUCTION, + environment: Environment.PRODUCTION, }); } }, diff --git a/packages/desktop/src/renderer/index.tsx b/packages/desktop/src/renderer/index.tsx index 7f044d2a7..ca0d81cf5 100644 --- a/packages/desktop/src/renderer/index.tsx +++ b/packages/desktop/src/renderer/index.tsx @@ -34,12 +34,20 @@ const KeyDownHandler: React.FC<{ clearStore: () => void }> = ({ clearStore }) => return null; }; -// Function to clear the persistent store +// Clears the persistent store but retains `Environment` to prevent misalignment +// between the data source and the selected menu item in the app. const clearPersistentStore = () => { + const currentEnvironment = global.environment; persistentConfigService.clear(); + persistentConfigService.persist({ [PersistedConfigKeys.Environment]: currentEnvironment }); window.location.reload(); }; +const initializeEnvironment = () => { + const savedEnvironment = persistentConfigService.get(PersistedConfigKeys.Environment); + global.environment = savedEnvironment || "PRODUCTION"; +}; + // Application analytics/metrics const frontendInsights = new FrontendInsights( { @@ -115,20 +123,19 @@ function renderFmsFileExplorer() { - + , document.getElementById(APP_ID) ); } -ipcRenderer.addListener( - GlobalVariableChannels.BaseUrl, - (_, { fileExplorerServiceBaseUrl, fileDownloadServiceBaseUrl }) => { - global.fileDownloadServiceBaseUrl = fileDownloadServiceBaseUrl; - global.fileExplorerServiceBaseUrl = fileExplorerServiceBaseUrl; - renderFmsFileExplorer(); - } -); +// Listen for IPC updates to global variables +ipcRenderer.addListener(GlobalVariableChannels.BaseUrl, (_, { environment }) => { + global.environment = environment; + persistentConfigService.persist({ [PersistedConfigKeys.Environment]: environment }); + renderFmsFileExplorer(); +}); +initializeEnvironment(); renderFmsFileExplorer(); diff --git a/packages/desktop/src/services/test/PersistentConfigServiceElectron.test.ts b/packages/desktop/src/services/test/PersistentConfigServiceElectron.test.ts index bf4e777f2..084485a53 100644 --- a/packages/desktop/src/services/test/PersistentConfigServiceElectron.test.ts +++ b/packages/desktop/src/services/test/PersistentConfigServiceElectron.test.ts @@ -1,7 +1,7 @@ import { expect } from "chai"; import { PersistedConfigKeys } from "../../../../core/services"; -import { RUN_IN_RENDERER } from "../../util/constants"; +import { Environment, RUN_IN_RENDERER } from "../../util/constants"; import PersistentConfigServiceElectron from "../PersistentConfigServiceElectron"; describe(`${RUN_IN_RENDERER} PersistentConfigServiceElectron`, () => { @@ -61,6 +61,8 @@ describe(`${RUN_IN_RENDERER} PersistentConfigServiceElectron`, () => { units: "string", }, ]; + + const expectedEnvironment = Environment.TEST; const expectedColumns = [{ file_size: 0.4 }, { file_name: 0.6 }]; service.persist(PersistedConfigKeys.AllenMountPoint, expectedAllenMountPoint); @@ -75,6 +77,7 @@ describe(`${RUN_IN_RENDERER} PersistentConfigServiceElectron`, () => { service.persist(PersistedConfigKeys.UserSelectedApplications, expectedUserSelectedApps); service.persist(PersistedConfigKeys.DisplayAnnotations, expectedDisplayAnnotations); service.persist(PersistedConfigKeys.RecentAnnotations, expectedRecentAnnotations); + service.persist(PersistedConfigKeys.Environment, expectedEnvironment); const expectedConfig = { [PersistedConfigKeys.AllenMountPoint]: expectedAllenMountPoint, @@ -86,6 +89,7 @@ describe(`${RUN_IN_RENDERER} PersistentConfigServiceElectron`, () => { [PersistedConfigKeys.UserSelectedApplications]: expectedUserSelectedApps, [PersistedConfigKeys.DisplayAnnotations]: expectedDisplayAnnotations, [PersistedConfigKeys.RecentAnnotations]: expectedRecentAnnotations, + [PersistedConfigKeys.Environment]: expectedEnvironment, }; // Act @@ -127,6 +131,7 @@ describe(`${RUN_IN_RENDERER} PersistentConfigServiceElectron`, () => { units: "string", }, ], + [PersistedConfigKeys.Environment]: Environment.TEST, }; // Act @@ -166,10 +171,13 @@ describe(`${RUN_IN_RENDERER} PersistentConfigServiceElectron`, () => { service.persist(PersistedConfigKeys.CsvColumns, ["Cell Line"]); service.persist(PersistedConfigKeys.CsvColumns, expected); service.persist(PersistedConfigKeys.AllenMountPoint, "/my/path/allen"); + service.persist(PersistedConfigKeys.Environment, Environment.TEST); // Assert const actual = service.get(PersistedConfigKeys.CsvColumns); + const actualEnvironment = service.get(PersistedConfigKeys.Environment); expect(actual).to.be.deep.equal(expected); + expect(actualEnvironment).to.equal(Environment.TEST); }); }); }); diff --git a/packages/desktop/src/util/constants.ts b/packages/desktop/src/util/constants.ts index 2b950921b..bda836ed7 100644 --- a/packages/desktop/src/util/constants.ts +++ b/packages/desktop/src/util/constants.ts @@ -3,16 +3,11 @@ // pattern used in the npm script used to invoke electron-mocha. export const RUN_IN_RENDERER = "@renderer"; -export enum FileDownloadServiceBaseUrl { - LOCALHOST = "http://localhost:8080/labkey/fmsfiles/image", - STAGING = "http://stg-aics.corp.alleninstitute.org/labkey/fmsfiles/image", - PRODUCTION = "http://aics.corp.alleninstitute.org/labkey/fmsfiles/image", -} - -export enum FileExplorerServiceBaseUrl { - LOCALHOST = "http://localhost:9081", - STAGING = "https://staging.int.allencell.org", - PRODUCTION = "https://production.int.allencell.org", +export enum Environment { + LOCALHOST = "LOCALHOST", + STAGING = "STAGING", + PRODUCTION = "PRODUCTION", + TEST = "TEST", } // Channels global variables can be modified on / listen to