diff --git a/.gitignore b/.gitignore index dbcc6364ed..bcd4a23c88 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,7 @@ designsafe/templates/react-assets.html designsafe.env mysql.env rabbitmq.env +ngrok.env rabbitmq.conf mysql.cnf diff --git a/.pylintrc b/.pylintrc index d8f0f80226..3458e31246 100644 --- a/.pylintrc +++ b/.pylintrc @@ -52,7 +52,7 @@ ignore=CVS,tests.py # ignore-list. The regex matches against paths and can be in Posix or Windows # format. Because '\\' represents the directory delimiter on Windows systems, # it can't be used as an escape character. -ignore-paths=^.*migrations/.*$,^.*_tests/.*$,^.*unit_test.*$ +ignore-paths=^.*migrations/.*$,^.*_tests/.*$,^.*unit_test.*$,^.*test_.*$ # Files or directories matching the regular expression patterns are skipped. # The regex matches against base names, not paths. The default value ignores diff --git a/Makefile b/Makefile index 0104e5c8b2..dc85626ec1 100644 --- a/Makefile +++ b/Makefile @@ -1,3 +1,9 @@ +NGROK_ENV_FILE = ./conf/env_files/ngrok.env +ifeq ("$(wildcard $(NGROK_ENV_FILE))","") + NGROK_ENV_FILE = ./conf/env_files/ngrok.sample.env +endif + + .PHONY: build build: docker compose -f ./conf/docker/docker-compose.yml build @@ -8,16 +14,16 @@ build-dev: .PHONY: start start: - docker compose -f ./conf/docker/docker-compose-dev.all.debug.yml up + docker compose --env-file $(NGROK_ENV_FILE) -f ./conf/docker/docker-compose-dev.all.debug.yml up .PHONY: stop stop: - docker compose -f ./conf/docker/docker-compose-dev.all.debug.yml down + docker compose --env-file $(NGROK_ENV_FILE) -f ./conf/docker/docker-compose-dev.all.debug.yml down .PHONY: start-m1 start-m1: - docker compose -f ./conf/docker/docker-compose-dev.all.debug.m1.yml up + docker compose --env-file $(NGROK_ENV_FILE) -f ./conf/docker/docker-compose-dev.all.debug.m1.yml up .PHONY: stop-m1 stop-m1: - docker compose -f ./conf/docker/docker-compose-dev.all.debug.m1.yml down + docker compose --env-file $(NGROK_ENV_FILE) -f ./conf/docker/docker-compose-dev.all.debug.m1.yml down diff --git a/README.md b/README.md index b2e91f8b7a..b5de1b005a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,3 @@ -[![Build Status](https://travis-ci.org/DesignSafe-CI/portal.svg?branch=master)](https://travis-ci.org/DesignSafe-CI/portal) -[![codecov](https://codecov.io/gh/DesignSafe-CI/portal/branch/master/graph/badge.svg)](https://codecov.io/gh/DesignSafe-CI/portal) - # DesignSafe-CI Portal ## Prequisites for running the portal application @@ -11,8 +8,8 @@ on. - [Install Docker][3] - [Install Docker Compose][4] -- [Install Make][12] -- [Node.js][13] 16.x +- [Install Make][11] +- [Node.js][12] 20.x If you are on a Mac or a Windows machine, the recommended method is to install [Docker Desktop][5], which will install both Docker and Docker Compose, which is required to run Docker on Mac/Windows hosts. @@ -37,7 +34,7 @@ If you are on a Mac or a Windows machine, the recommended method is to install - `DJANGO_DEBUG`: should be set to `True` for development - `DJANGO_SECRET`: should be changed for production - `TAS_*`: should be set to enable direct access to `django.contrib.admin` - - `AGAVE_*`: should be set to enable Agave API integration (authentication, etc.) + - `TAPIS_*`: should be set to enable Tapis API integration (authentication, etc.) - `RT_*`: should be set to enable ticketing Make a copy of [rabbitmq.sample.env](conf/env_files/rabbitmq.sample.env) @@ -46,7 +43,15 @@ If you are on a Mac or a Windows machine, the recommended method is to install Make a copy of [external_resource_secrets.sample.py](designsafe/settings/external_resource_secrets.sample.py) and rename it to `external_resource_secrets.py`. -3. Build the containers and frontend packages +3. Configure ngrok + + a. Install [ngrok](https://ngrok.com/docs/getting-started/), and create an ngrok account. + + b. Copy [conf/env_files/ngrok.sample.env](conf/env_files/ngrok.sample.env) to `conf/env_files/ngrok.env`. + + c. In `conf/env_files/ngrok.env`, set the `NGROK_AUTHTOKEN` and `NGROK_DOMAIN` variables using your authtoken and static ngrok domain found in your [ngrok dashboard](https://dashboard.ngrok.com/). + +4. Build the containers and frontend packages 1. Containers: ```sh @@ -72,7 +77,7 @@ If you are on a Mac or a Windows machine, the recommended method is to install npm run start ``` -4. Start local containers +5. Start local containers ``` $ make start @@ -89,11 +94,11 @@ If you are on a Mac or a Windows machine, the recommended method is to install $ ./manage.py createsuperuser ``` -5. Setup local access to the portal: +6. Setup local access to the portal: Add a record to your local hosts file for `127.0.0.1 designsafe.dev` ``` - sudo vim /etc/hosts + $ sudo vim /etc/hosts ``` Now you can navigate to [designsafe.dev](designsafe.dev) in your browser. @@ -178,11 +183,10 @@ See the [DesignSafe Styles Reference][7] for style reference and custom CSS docu ### Updating Python dependencies -For simplicity the Dockerfile uses a `requirements.txt` exported from Poetry. To add a new dependency: +This project uses [Python Poetry](https://python-poetry.org/docs/) to manage dependencies. To add a new dependency: 1. Run `poetry add $NEW_DEPENDENCY`. -2. Run `poetry export > requirements.txt --dev --without-hashes` in the repository root. -3. Rebuild the dev image with `docker-compose -f conf/docker/docker-compose.yml build` +2. Rebuild the dev image with `make build-dev` ## Testing @@ -200,16 +204,14 @@ Django tests should be written according to standard [Django testing procedures] You can run Django tests with the following command: ```shell -$ docker exec -it des_django pytest designsafe +$ docker exec -it des_django pytest -ra designsafe ``` ### Frontend tests -Frontend tests are [Jasmine][9] tests executed using the [Karma engine][10]. Testing -guidelines can be found in the [AngularJS Developer Guide on Unit Testing][11]. +Frontend tests are [Vitest][9] tests executed using [Nx][10]. -To run frontend tests, ensure that all scripts and test scripts are configured in -[`karma-conf.js`](karma-conf.js) and then run the command: +To run frontend tests, run the command: ```shell $ npm run test @@ -217,47 +219,31 @@ $ npm run test ## Development setup -Use `docker-compose` to run the portal in development. The default compose file, -[`docker-compose.yml`](docker-compose.yml) runs the main django server in development +Use `docker compose` to run the portal in development. The default compose file, +[`docker-compose.yml`](conf/docker/docker-compose.yml) runs the main django server in development mode with a redis service for websockets support. You can optionally enable the EF sites for testing. ```shell -$ docker-compose -f conf/docker/docker-compose.yml build -$ docker-compose -f conf/docker/docker-compose-dev.all.debug.yml up -$ npm run dev +$ make build-dev +$ make start +$ npm run start +$ docker run -v `pwd`:`pwd` -w `pwd` -it node:16 /bin/bash -c "npm run dev" ``` When using this compose file, your Tapis Client should be configured with a `callback_url` of `http://$DOCKER_HOST_IP:8000/auth/tapis/callback/`. -For developing some services, e.g. Box.com integration, https support is required. To -enable an Nginx http proxy run using the [`docker-compose-http.yml`](docker-compose-http.yml) -file. This file configures the same services as the default compose file, but it also sets -up an Nginx proxy secured by a self-signed certificate. ```shell $ docker-compose -f docker-compose-http.yml build $ docker-compose -f docker-compose-http.yml up ``` -### Agave filesystem setup -1. Delete all of the old metadata objects using this command: - - `metadata-list Q '{"name": "designsafe metadata"}' | while read x; do metadata-delete $x; done;` -2. Run `dsapi/agave/tools/bin/walker.py` to create the metadata objects for the existing files in your FS. - - `python portal/dsapi/agave/tools/bin/walker.py ` - - `base_folder` is your username, if you want to fix everything under your home dir. - - `command`: - - `files`: Walk through the files and print their path. - - `meta`: Walk through the metadata objs in a filesystem-like manner and print their path. - - `files-fix`: Check if there's a meta obj for every file, if not create the meta obj. - - `meta-fix`: Check if there's a file for every meta obj, if not delete the meta obj. ## Production setup -Production deployment is managed by ansible. See https://github.com/designsafe-ci/ansible. +Production deployment is managed by Camino. See https://github.com/TACC/Camino. [1]: https://docs.docker.com/ @@ -267,8 +253,7 @@ Production deployment is managed by ansible. See https://github.com/designsafe-c [5]: https://docs.docker.com/desktop/ [7]: https://github.com/DesignSafe-CI/portal/wiki/CSS-Styles-Reference [8]: https://docs.djangoproject.com/en/dev/topics/testing/ -[9]: http://jasmine.github.io/1.3/introduction.html -[10]: http://karma-runner.github.io/0.12/intro/installation.html -[11]: https://docs.angularjs.org/guide/unit-testing -[12]: https://www.gnu.org/software/make/ -[13]: https://nodejs.org/ +[9]: https://vitest.dev/ +[10]: https://nx.dev/getting-started/intro +[11]: https://www.gnu.org/software/make/ +[12]: https://nodejs.org/ diff --git a/client/modules/_hooks/src/notifications/useNotifications.ts b/client/modules/_hooks/src/notifications/useNotifications.ts index 566a0633ed..cfbcebe900 100644 --- a/client/modules/_hooks/src/notifications/useNotifications.ts +++ b/client/modules/_hooks/src/notifications/useNotifications.ts @@ -6,7 +6,11 @@ import { } from '@tanstack/react-query'; import apiClient from '../apiClient'; -type TPortalEventType = 'data_depot' | 'job' | 'interactive_session_ready'; +type TPortalEventType = + | 'data_depot' + | 'job' + | 'interactive_session_ready' + | 'markAllNotificationsAsRead'; export type TJobStatusNotification = { action_link: string; diff --git a/client/modules/_hooks/src/workspace/types.ts b/client/modules/_hooks/src/workspace/types.ts index 9d73e2ef6a..035d1ec608 100644 --- a/client/modules/_hooks/src/workspace/types.ts +++ b/client/modules/_hooks/src/workspace/types.ts @@ -1,6 +1,7 @@ export type TParameterSetNotes = { isHidden?: boolean; fieldType?: string; + inputType?: string; validator?: { regex: string; message: string; @@ -191,7 +192,7 @@ export type TTapisJob = { stageAppTransactionId?: string; status: string; subscriptions: string; - tags: string[]; + tags: string[] | null; tapisQueue: string; tenant: string; uuid: string; diff --git a/client/modules/datafiles/src/projects/ProjectCitation/ProjectCitation.tsx b/client/modules/datafiles/src/projects/ProjectCitation/ProjectCitation.tsx index 28c9fe72e7..6fd4f87488 100644 --- a/client/modules/datafiles/src/projects/ProjectCitation/ProjectCitation.tsx +++ b/client/modules/datafiles/src/projects/ProjectCitation/ProjectCitation.tsx @@ -14,7 +14,9 @@ export const ProjectCitation: React.FC<{ const { data } = useProjectDetail(projectId); const entityDetails = data?.entities.find((e) => e.uuid === entityUuid); const authors = - entityDetails?.value.authors?.filter((a) => a.fname && a.lname) ?? []; + entityDetails?.value.authors?.filter( + (a) => a.fname && a.lname && a.authorship !== false + ) ?? []; if (!data || !entityDetails) return null; return (
@@ -44,7 +46,8 @@ export const PublishedCitation: React.FC<{ (child) => child.uuid === entityUuid && child.version === version ); - const authors = entityDetails?.value.authors ?? []; + const authors = + entityDetails?.value.authors?.filter((a) => a.authorship !== false) ?? []; if (!data || !entityDetails) return null; const doi = diff --git a/client/modules/datafiles/src/projects/ProjectCollapser/ProjectCollapser.tsx b/client/modules/datafiles/src/projects/ProjectCollapser/ProjectCollapser.tsx index aacdf9250f..aac0067b4c 100644 --- a/client/modules/datafiles/src/projects/ProjectCollapser/ProjectCollapser.tsx +++ b/client/modules/datafiles/src/projects/ProjectCollapser/ProjectCollapser.tsx @@ -17,8 +17,8 @@ export const ProjectCollapse: React.FC< if (header.length > 0) { const headerElement = header[0] as HTMLElement; headerElement.style.backgroundColor = - PROJECT_COLORS[entityName]['fill']; - headerElement.style.border = `1px solid ${PROJECT_COLORS[entityName]['outline']}`; + PROJECT_COLORS[entityName]?.['fill']; + headerElement.style.border = `1px solid ${PROJECT_COLORS[entityName]?.['outline']}`; } } }, @@ -36,7 +36,8 @@ export const ProjectCollapse: React.FC< forceRender: true, label: ( - {DISPLAY_NAMES[entityName]} | {title} + {DISPLAY_NAMES[entityName] ?? 'Collection'} |{' '} + {title} ), children, diff --git a/client/modules/datafiles/src/projects/ProjectTree/ProjectTree.tsx b/client/modules/datafiles/src/projects/ProjectTree/ProjectTree.tsx index 76208dfeee..b05ab19159 100644 --- a/client/modules/datafiles/src/projects/ProjectTree/ProjectTree.tsx +++ b/client/modules/datafiles/src/projects/ProjectTree/ProjectTree.tsx @@ -137,8 +137,8 @@ const RecursiveTree: React.FC<{
{DISPLAY_NAMES[treeData.name]} diff --git a/client/modules/datafiles/src/projects/constants.ts b/client/modules/datafiles/src/projects/constants.ts index bda51640bd..51eb5d2cd0 100644 --- a/client/modules/datafiles/src/projects/constants.ts +++ b/client/modules/datafiles/src/projects/constants.ts @@ -17,6 +17,8 @@ export const SIMULATION_MODEL = 'designsafe.project.simulation.model'; export const SIMULATION_INPUT = 'designsafe.project.simulation.input'; export const SIMULATION_OUTPUT = 'designsafe.project.simulation.output'; // Field Research +export const FIELD_RECON_COLLECTION = + 'designsafe.project.field_recon.collection'; export const FIELD_RECON_MISSION = 'designsafe.project.field_recon.mission'; export const FIELD_RECON_REPORT = 'designsafe.project.field_recon.report'; export const FIELD_RECON_SOCIAL_SCIENCE = @@ -72,6 +74,7 @@ export const PROJECT_COLORS: Record = [HYBRID_SIM_SIM_SUBSTRUCTURE]: { outline: '#BD5717', fill: '#EBCCB9' }, [HYBRID_SIM_SIM_OUTPUT]: { outline: '#B59300', fill: '#ECE4BF' }, + [FIELD_RECON_COLLECTION]: { outline: '#43A59D', fill: '#CAE9E6' }, [FIELD_RECON_REPORT]: { outline: '#cccccc', fill: '#f5f5f5' }, [FIELD_RECON_MISSION]: { outline: '#000000', fill: '#ffffff' }, [FIELD_RECON_GEOSCIENCE]: { outline: '#43A59D', fill: '#CAE9E6' }, @@ -191,6 +194,7 @@ export const DISPLAY_NAMES: Record = { [HYBRID_SIM_SIM_OUTPUT]: 'Simulation Output', // Field Recon [FIELD_RECON_MISSION]: 'Mission', + [FIELD_RECON_COLLECTION]: 'Collection', [FIELD_RECON_GEOSCIENCE]: 'Geoscience Collection', [FIELD_RECON_SOCIAL_SCIENCE]: 'Social Science Collection', [FIELD_RECON_REPORT]: 'Document Collection', diff --git a/client/modules/workspace/src/AppsWizard/AppsFormSchema.ts b/client/modules/workspace/src/AppsWizard/AppsFormSchema.ts index 4a04a58181..8a3fec01c3 100644 --- a/client/modules/workspace/src/AppsWizard/AppsFormSchema.ts +++ b/client/modules/workspace/src/AppsWizard/AppsFormSchema.ts @@ -56,8 +56,7 @@ export type TField = { parameterSet?: string; description?: string; options?: TFieldOptions[]; - tapisFile?: boolean; - tapisFileSelectionMode?: string; + fileSettings?: TAppFileSettings; placeholder?: string; readOnly?: boolean; }; @@ -96,7 +95,7 @@ export type TAppFormSchema = { }; }; -export const inputFileRegex = /^tapis:\/\/(?[^/]+)/; +export const tapisInputFileRegex = /^tapis:\/\/(?[^/]+)/; export const fieldDisplayOrder: Record = { configuration: [ @@ -109,6 +108,14 @@ export const fieldDisplayOrder: Record = { outputs: ['name', 'archiveSystemId', 'archiveSystemDir'], }; +export type TAppFilePathRepresentation = 'FullTapisPath' | 'NameOnly'; +export type TAppFileSelectionMode = 'both' | 'file' | 'directory'; + +export type TAppFileSettings = { + fileNameRepresentation: TAppFilePathRepresentation; + fileSelectionMode: TAppFileSelectionMode; +}; + // See https://github.com/colinhacks/zod/issues/310 for Zod issue const emptyStringToUndefined = z.literal('').transform(() => undefined); function asOptionalField(schema: T) { @@ -316,6 +323,12 @@ const FormSchema = ( name: `parameters.${parameterSet}.${label}`, key: paramId, type: 'text', + ...(param.notes?.inputType === 'fileInput' && { + fileSettings: { + fileNameRepresentation: 'NameOnly', + fileSelectionMode: 'file', + }, + }), }; if (param.notes?.enum_values) { @@ -399,11 +412,14 @@ const FormSchema = ( required: input.inputMode === 'REQUIRED', name: `inputs.${input.name}`, key: `inputs.${input.name}`, - tapisFile: true, type: 'text', placeholder: 'Browse Data Files', readOnly: input.inputMode === 'FIXED', - tapisFileSelectionMode: input.notes?.selectionMode ?? 'both', + fileSettings: { + fileNameRepresentation: 'FullTapisPath', + fileSelectionMode: + (input.notes?.selectionMode as TAppFileSelectionMode) ?? 'both', + }, }; appFields.fileInputs.schema[input.name] = z.string(); diff --git a/client/modules/workspace/src/AppsWizard/FormField.tsx b/client/modules/workspace/src/AppsWizard/FormField.tsx index 1ec10262f0..8979c0b017 100644 --- a/client/modules/workspace/src/AppsWizard/FormField.tsx +++ b/client/modules/workspace/src/AppsWizard/FormField.tsx @@ -2,30 +2,32 @@ import React, { useState, useEffect } from 'react'; import { Button, Form, Input, Select } from 'antd'; import { FormItem } from 'react-hook-form-antd'; import { useFormContext, useWatch } from 'react-hook-form'; -import { TFieldOptions, inputFileRegex } from '../AppsWizard/AppsFormSchema'; +import { + TFieldOptions, + tapisInputFileRegex, + TAppFileSettings, +} from '../AppsWizard/AppsFormSchema'; import { SecondaryButton } from '@client/common-components'; import { SelectModal } from '../SelectModal/SelectModal'; export const FormField: React.FC<{ name: string; - tapisFile?: boolean; parameterSet?: string; description?: string; label: string; required?: boolean; type: string; - tapisFileSelectionMode?: string; + fileSettings?: TAppFileSettings; placeholder?: string; options?: TFieldOptions[]; }> = ({ name, - tapisFile = false, parameterSet = null, description, label, required = false, type, - tapisFileSelectionMode = null, + fileSettings = null, ...props }) => { const { resetField, control, getValues, setValue, trigger } = @@ -39,16 +41,17 @@ export const FormField: React.FC<{ setIsModalOpen(true); }; useEffect(() => { - if (tapisFile) { + setStorageSystem(null); + + if (fileSettings?.fileNameRepresentation === 'FullTapisPath') { const inputFileValue = getValues(name); - const match = inputFileValue?.match(inputFileRegex); - if (match && match.groups) { + const match = inputFileValue?.match(tapisInputFileRegex); + + if (match?.groups) { setStorageSystem(match.groups.storageSystem); - } else { - setStorageSystem(null); } } - }, [tapisFile, name, fieldState]); + }, [fileSettings, name, fieldState]); if (parameterSet) { parameterSetLabel = ( @@ -89,7 +92,7 @@ export const FormField: React.FC<{ /> ) : (
- {tapisFile && ( + {fileSettings && ( Select @@ -133,11 +136,11 @@ export const FormField: React.FC<{ {/* Select Modal has Form and input which cause state sharing with above FormItem So, SelectModal is outside FormItem. */} - {tapisFile && ( + {fileSettings && ( setIsModalOpen(false)} onSelect={(value: string) => { diff --git a/client/modules/workspace/src/JobsListing/JobsListing.tsx b/client/modules/workspace/src/JobsListing/JobsListing.tsx index d2dcf3404d..6aaae7aa54 100644 --- a/client/modules/workspace/src/JobsListing/JobsListing.tsx +++ b/client/modules/workspace/src/JobsListing/JobsListing.tsx @@ -1,4 +1,5 @@ import React, { useMemo, useState, useEffect } from 'react'; +import useWebSocket from 'react-use-websocket'; import { TableProps, Row, Flex, Button as AntButton } from 'antd'; import type { ButtonSize } from 'antd/es/button'; import { useQueryClient } from '@tanstack/react-query'; @@ -96,12 +97,16 @@ export const JobsListing: React.FC> = ({ markRead: false, }); const { mutate: readNotifications } = useReadNotifications(); + const { sendMessage } = useWebSocket( + `wss://${window.location.host}/ws/websockets/` + ); // mark all as read on component mount useEffect(() => { readNotifications({ eventTypes: ['interactive_session_ready', 'job'], }); + sendMessage('markAllNotificationsAsRead'); // update unread count state queryClient.setQueryData( diff --git a/client/modules/workspace/src/JobsListing/JobsListingTable/JobsListingTable.tsx b/client/modules/workspace/src/JobsListing/JobsListingTable/JobsListingTable.tsx index d3d11deb8e..4cffd2b64f 100644 --- a/client/modules/workspace/src/JobsListing/JobsListingTable/JobsListingTable.tsx +++ b/client/modules/workspace/src/JobsListing/JobsListingTable/JobsListingTable.tsx @@ -91,9 +91,11 @@ export const JobsListingTable: React.FC< isLoading, ]); - const lastNotificationJobUUID = lastMessage - ? (JSON.parse(lastMessage.data) as TJobStatusNotification).extra.uuid - : ''; + const lastMessageJSON = lastMessage?.data + ? (JSON.parse(lastMessage.data) as TJobStatusNotification) + : null; + const lastNotificationJobUUID = + lastMessageJSON?.event_type === 'job' ? lastMessageJSON.extra.uuid : ''; const unreadJobUUIDs = unreadNotifs?.notifs.map((x) => x.extra.uuid) ?? []; /* RENDER THE TABLE */ diff --git a/client/modules/workspace/src/SelectModal/SelectModal.tsx b/client/modules/workspace/src/SelectModal/SelectModal.tsx index fbbb4906f2..01a6cdd719 100644 --- a/client/modules/workspace/src/SelectModal/SelectModal.tsx +++ b/client/modules/workspace/src/SelectModal/SelectModal.tsx @@ -23,6 +23,8 @@ import { TFileListing, } from '@client/hooks'; +import { TAppFileSettings } from '../AppsWizard/AppsFormSchema'; + import { BaseFileListingBreadcrumb, FileListingTable, @@ -152,7 +154,7 @@ const getParentFolder = ( function getFilesColumns( api: string, path: string, - selectionMode: string, + appFileSettings: TAppFileSettings, searchTerm: string | null, clearSearchTerm: () => void, selectionCallback: (path: string) => void, @@ -233,9 +235,11 @@ function getFilesColumns( title: '', render: (_, record, index) => { const selectionModeAllowed = - (record.type === 'dir' && selectionMode === 'directory') || - (record.type === 'file' && selectionMode === 'file') || - selectionMode === 'both'; + (record.type === 'dir' && + appFileSettings.fileSelectionMode === 'directory') || + (record.type === 'file' && + appFileSettings.fileSelectionMode === 'file') || + appFileSettings.fileSelectionMode === 'both'; const isNotRoot = index > 0 || record.system.startsWith(projectPrefix) || @@ -244,9 +248,15 @@ function getFilesColumns( return shouldRenderSelectButton ? ( - selectionCallback(`${api}://${record.system}${record.path}`) - } + onClick={() => { + const lastPartOfPath = record.path.split('/').pop() ?? ''; + const filePath = + appFileSettings.fileNameRepresentation === 'FullTapisPath' + ? `${api}://${record.system}${record.path}` + : lastPartOfPath; + + selectionCallback(filePath); + }} > Select @@ -259,11 +269,11 @@ function getFilesColumns( export const SelectModal: React.FC<{ inputLabel: string; system: string | null; - selectionMode: string; + appFileSettings: TAppFileSettings; isOpen: boolean; onClose: () => void; onSelect: (value: string) => void; -}> = ({ inputLabel, system, selectionMode, isOpen, onClose, onSelect }) => { +}> = ({ inputLabel, system, appFileSettings, isOpen, onClose, onSelect }) => { const [isModalOpen, setIsModalOpen] = useState(false); const [searchTerm, setSearchTerm] = useState(null); const [form] = Form.useForm(); @@ -420,7 +430,7 @@ export const SelectModal: React.FC<{ getFilesColumns( selectedApi, selectedPath, - selectionMode, + appFileSettings, searchTerm, clearSearchTerm, (selection: string) => selectCallback(selection), @@ -435,7 +445,7 @@ export const SelectModal: React.FC<{ selectedSystem, selectedPath, systemLabel, - selectionMode, + appFileSettings, selectCallback, ] ); diff --git a/client/modules/workspace/src/Toast/Notifications.module.css b/client/modules/workspace/src/Toast/Notifications.module.css index 44beb6563c..44ca35802c 100644 --- a/client/modules/workspace/src/Toast/Notifications.module.css +++ b/client/modules/workspace/src/Toast/Notifications.module.css @@ -1,3 +1,13 @@ +.root { + cursor: pointer; + background: #f4f4f4; + border: 1px solid #222222; + &:hover { + border-color: #5695c4; + background: #aac7ff; + } +} + .toast-is-error { color: #eb6e6e; } diff --git a/client/modules/workspace/src/Toast/Toast.tsx b/client/modules/workspace/src/Toast/Toast.tsx index 1956cd1102..576476be92 100644 --- a/client/modules/workspace/src/Toast/Toast.tsx +++ b/client/modules/workspace/src/Toast/Toast.tsx @@ -1,10 +1,14 @@ import React, { useEffect } from 'react'; import useWebSocket from 'react-use-websocket'; import { useQueryClient } from '@tanstack/react-query'; -import { notification } from 'antd'; +import { notification, Flex } from 'antd'; +import { RightOutlined } from '@ant-design/icons'; import { useNavigate } from 'react-router-dom'; import { Icon } from '@client/common-components'; -import { TJobStatusNotification } from '@client/hooks'; +import { + TJobStatusNotification, + TGetNotificationsResponse, +} from '@client/hooks'; import { getToastMessage } from '../utils'; import styles from './Notifications.module.css'; @@ -31,19 +35,43 @@ const Notifications = () => { queryKey: ['workspace', 'jobsListing'], }); api.open({ - message: getToastMessage(notification), + message: ( + + {getToastMessage(notification)} + + + ), placement: 'bottomLeft', icon: , className: `${ notification.extra.status === 'FAILED' && styles['toast-is-error'] - }`, + } ${styles.root}`, closeIcon: false, duration: 5, onClick: () => { navigate('/history'); }, - style: { cursor: 'pointer' }, }); + } else if (notification.event_type === 'markAllNotificationsAsRead') { + // update unread count state + queryClient.setQueryData( + [ + 'workspace', + 'notifications', + { + eventTypes: ['interactive_session_ready', 'job'], + read: false, + markRead: false, + }, + ], + (oldData: TGetNotificationsResponse) => { + return { + ...oldData, + notifs: [], + unread: 0, + }; + } + ); } }; diff --git a/client/modules/workspace/src/utils/jobs.ts b/client/modules/workspace/src/utils/jobs.ts index 89b58a00fd..3915fe9923 100644 --- a/client/modules/workspace/src/utils/jobs.ts +++ b/client/modules/workspace/src/utils/jobs.ts @@ -68,7 +68,7 @@ export function getOutputPath(job: TTapisJob) { } export function isInteractiveJob(job: TTapisJob) { - return job.tags.includes('isInteractive'); + return job.tags?.includes('isInteractive'); } export function getJobInteractiveSessionInfo( diff --git a/client/src/workspace/workspaceRouter.tsx b/client/src/workspace/workspaceRouter.tsx index d9b3bf7c95..ad21288298 100644 --- a/client/src/workspace/workspaceRouter.tsx +++ b/client/src/workspace/workspaceRouter.tsx @@ -7,6 +7,13 @@ import { JobsListingLayout } from './layouts/JobsListingLayout'; import { AppsViewLayout } from './layouts/AppsViewLayout'; import { AppsPlaceholderLayout } from './layouts/AppsPlaceholderLayout'; +const getBaseName = () => { + if (window.location.pathname.startsWith('/rw/workspace')) { + return '/rw/workspace'; + } + return '/workspace'; +}; + const workspaceRouter = createBrowserRouter( [ { @@ -44,7 +51,7 @@ const workspaceRouter = createBrowserRouter( ], }, ], - { basename: '/rw/workspace' } + { basename: getBaseName() } ); export default workspaceRouter; diff --git a/conf/docker/docker-compose-dev.all.debug.m1.yml b/conf/docker/docker-compose-dev.all.debug.m1.yml index edbe69b19c..8337c2219f 100644 --- a/conf/docker/docker-compose-dev.all.debug.m1.yml +++ b/conf/docker/docker-compose-dev.all.debug.m1.yml @@ -77,7 +77,10 @@ services: django: image: designsafeci/portal:tapis-v3 - env_file: ../env_files/designsafe.env + env_file: + - path: ../env_files/designsafe.env + - path: ../env_files/ngrok.env + required: false links: - memcached:memcached - rabbitmq:rabbitmq @@ -98,7 +101,10 @@ services: websockets: image: designsafeci/portal:tapis-v3 - env_file: ../env_files/designsafe.env + env_file: + - path: ../env_files/designsafe.env + - path: ../env_files/ngrok.env + required: false volumes: - ../../.:/srv/www/designsafe - ../../data/media:/srv/www/designsafe/media @@ -108,7 +114,10 @@ services: workers: image: designsafeci/portal:tapis-v3 - env_file: ../env_files/designsafe.env + env_file: + - path: ../env_files/designsafe.env + - path: ../env_files/ngrok.env + required: false links: - memcached:memcached - rabbitmq:rabbitmq @@ -125,6 +134,20 @@ services: container_name: des_workers hostname: des_workers + ngrok: + image: ngrok/ngrok:latest + platform: "linux/amd64" + environment: + NGROK_AUTHTOKEN: ${NGROK_AUTHTOKEN} + command: + - "http" + - --url=${NGROK_DOMAIN} + - "https://host.docker.internal:443" + ports: + - 4040:4040 + container_name: des_ngrok + hostname: des_ngrok + volumes: redis_data_v3: des_postgres_data_v3: diff --git a/conf/docker/docker-compose-dev.all.debug.yml b/conf/docker/docker-compose-dev.all.debug.yml index 22d5c62155..9fb100962a 100644 --- a/conf/docker/docker-compose-dev.all.debug.yml +++ b/conf/docker/docker-compose-dev.all.debug.yml @@ -73,7 +73,10 @@ services: django: image: designsafeci/portal:tapis-v3 - env_file: ../env_files/designsafe.env + env_file: + - path: ../env_files/designsafe.env + - path: ../env_files/ngrok.env + required: false links: - memcached:memcached - rabbitmq:rabbitmq @@ -94,7 +97,10 @@ services: websockets: image: designsafeci/portal:tapis-v3 - env_file: ../env_files/designsafe.env + env_file: + - path: ../env_files/designsafe.env + - path: ../env_files/ngrok.env + required: false volumes: - ../../.:/srv/www/designsafe - ../../data/media:/srv/www/designsafe/media @@ -104,7 +110,10 @@ services: workers: image: designsafeci/portal:tapis-v3 - env_file: ../env_files/designsafe.env + env_file: + - path: ../env_files/designsafe.env + - path: ../env_files/ngrok.env + required: false links: - memcached:memcached - rabbitmq:rabbitmq @@ -121,6 +130,19 @@ services: container_name: des_workers hostname: des_workers + ngrok: + image: ngrok/ngrok:latest + environment: + NGROK_AUTHTOKEN: ${NGROK_AUTHTOKEN} + command: + - "http" + - --url=${NGROK_DOMAIN} + - "https://host.docker.internal:443" + ports: + - 4040:4040 + container_name: des_ngrok + hostname: des_ngrok + volumes: redis_data_v3: des_postgres_data_v3: diff --git a/conf/env_files/designsafe.sample.env b/conf/env_files/designsafe.sample.env index 5c13942f68..c9abbb3530 100644 --- a/conf/env_files/designsafe.sample.env +++ b/conf/env_files/designsafe.sample.env @@ -80,7 +80,6 @@ AGAVE_CLIENT_SECRET= AGAVE_SUPER_TOKEN= AGAVE_STORAGE_SYSTEM= -AGAVE_WORKING_SYSTEM= AGAVE_JWT_PUBKEY= AGAVE_JWT_ISSUER= AGAVE_JWT_HEADER= diff --git a/conf/env_files/ngrok.sample.env b/conf/env_files/ngrok.sample.env new file mode 100644 index 0000000000..65ae75620a --- /dev/null +++ b/conf/env_files/ngrok.sample.env @@ -0,0 +1,5 @@ +# Get authtoken from https://dashboard.ngrok.com/get-started/your-authtoken +NGROK_AUTHTOKEN=your_ngrok_authtoken + +# Get static domain from https://dashboard.ngrok.com/domains +NGROK_DOMAIN=your-ngrok-subdomain.ngrok-free.app diff --git a/designsafe/apps/accounts/templates/designsafe/apps/accounts/base.html b/designsafe/apps/accounts/templates/designsafe/apps/accounts/base.html index e0c074ed59..b70f0557f4 100644 --- a/designsafe/apps/accounts/templates/designsafe/apps/accounts/base.html +++ b/designsafe/apps/accounts/templates/designsafe/apps/accounts/base.html @@ -9,7 +9,7 @@

{{title}}