From: Pawel Kromplewski Date: Wed, 5 Dec 2018 15:13:43 +0000 (+0100) Subject: Merge branch 'master' into 14452-my-account X-Git-Tag: 1.4.0~92^2~3 X-Git-Url: https://git.arvados.org/arvados-workbench2.git/commitdiff_plain/a1e2b8ba77e4a7273940a3fc542bc42e282618a7?hp=e8e0182d65a74b1a222127eb8b36f31a906b14c8 Merge branch 'master' into 14452-my-account refs #14452 Arvados-DCO-1.1-Signed-off-by: Pawel Kromplewski --- diff --git a/README.md b/README.md index ea9bc02f..e8d77701 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,10 @@ Currently this configuration schema is supported: } ``` +#### VOCABULARY_URL +Local path, or any URL that allows cross-origin requests. See +[Vocabulary JSON file example](public/vocabulary-example.json). + ### Licensing Arvados is Free Software. See COPYING for information about Arvados Free diff --git a/package.json b/package.json index 64a24dca..623b86d3 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "classnames": "2.2.6", "cwlts": "1.15.29", "debounce": "1.2.0", + "is-image": "2.0.0", "js-yaml": "3.12.0", "lodash": "4.17.11", "react": "16.5.2", diff --git a/public/vocabulary-example.json b/public/vocabulary-example.json new file mode 100644 index 00000000..b227dc23 --- /dev/null +++ b/public/vocabulary-example.json @@ -0,0 +1,32 @@ +{ + "strict": false, + "tags": { + "fruit": { + "values": ["pineapple", "tomato", "orange", "banana", "advocado", "lemon", "apple", "peach", "strawberry"], + "strict": true + }, + "animal": { + "values": ["human", "dog", "elephant", "eagle"], + "strict": false + }, + "color": { + "values": ["yellow", "red", "magenta", "green"], + "strict": false + }, + "text": {}, + "category": { + "values": ["experimental", "development", "production"] + }, + "comments": {}, + "importance": { + "values": ["critical", "important", "low priority"] + }, + "size": { + "values": ["x-small", "small", "medium", "large", "x-large"] + }, + "country": { + "values": ["Afghanistan","Åland Islands","Albania","Algeria","American Samoa","AndorrA","Angola","Anguilla","Antarctica","Antigua and Barbuda","Argentina","Armenia","Aruba","Australia","Austria","Azerbaijan","Bahamas","Bahrain","Bangladesh","Barbados","Belarus","Belgium","Belize","Benin","Bermuda","Bhutan","Bolivia","Bosnia and Herzegovina","Botswana","Bouvet Island","Brazil","British Indian Ocean Territory","Brunei Darussalam","Bulgaria","Burkina Faso","Burundi","Cambodia","Cameroon","Canada","Cape Verde","Cayman Islands","Central African Republic","Chad","Chile","China","Christmas Island","Cocos (Keeling) Islands","Colombia","Comoros","Congo","Congo, The Democratic Republic of the","Cook Islands","Costa Rica","Cote D'Ivoire","Croatia","Cuba","Cyprus","Czech Republic","Denmark","Djibouti","Dominica","Dominican Republic","Ecuador","Egypt","El Salvador","Equatorial Guinea","Eritrea","Estonia","Ethiopia","Falkland Islands (Malvinas)","Faroe Islands","Fiji","Finland","France","French Guiana","French Polynesia","French Southern Territories","Gabon","Gambia","Georgia","Germany","Ghana","Gibraltar","Greece","Greenland","Grenada","Guadeloupe","Guam","Guatemala","Guernsey","Guinea","Guinea-Bissau","Guyana","Haiti","Heard Island and Mcdonald Islands","Holy See (Vatican City State)","Honduras","Hong Kong","Hungary","Iceland","India","Indonesia","Iran, Islamic Republic Of","Iraq","Ireland","Isle of Man","Israel","Italy","Jamaica","Japan","Jersey","Jordan","Kazakhstan","Kenya","Kiribati","Korea, Democratic People'S Republic of","Korea, Republic of","Kuwait","Kyrgyzstan","Lao People'S Democratic Republic","Latvia","Lebanon","Lesotho","Liberia","Libyan Arab Jamahiriya","Liechtenstein","Lithuania","Luxembourg","Macao","Macedonia, The Former Yugoslav Republic of","Madagascar","Malawi","Malaysia","Maldives","Mali","Malta","Marshall Islands","Martinique","Mauritania","Mauritius","Mayotte","Mexico","Micronesia, Federated States of","Moldova, Republic of","Monaco","Mongolia","Montserrat","Morocco","Mozambique","Myanmar","Namibia","Nauru","Nepal","Netherlands","Netherlands Antilles","New Caledonia","New Zealand","Nicaragua","Niger","Nigeria","Niue","Norfolk Island","Northern Mariana Islands","Norway","Oman","Pakistan","Palau","Palestinian Territory, Occupied","Panama","Papua New Guinea","Paraguay","Peru","Philippines","Pitcairn","Poland","Portugal","Puerto Rico","Qatar","Reunion","Romania","Russian Federation","RWANDA","Saint Helena","Saint Kitts and Nevis","Saint Lucia","Saint Pierre and Miquelon","Saint Vincent and the Grenadines","Samoa","San Marino","Sao Tome and Principe","Saudi Arabia","Senegal","Serbia and Montenegro","Seychelles","Sierra Leone","Singapore","Slovakia","Slovenia","Solomon Islands","Somalia","South Africa","South Georgia and the South Sandwich Islands","Spain","Sri Lanka","Sudan","Suriname","Svalbard and Jan Mayen","Swaziland","Sweden","Switzerland","Syrian Arab Republic","Taiwan, Province of China","Tajikistan","Tanzania, United Republic of","Thailand","Timor-Leste","Togo","Tokelau","Tonga","Trinidad and Tobago","Tunisia","Turkey","Turkmenistan","Turks and Caicos Islands","Tuvalu","Uganda","Ukraine","United Arab Emirates","United Kingdom","United States","United States Minor Outlying Islands","Uruguay","Uzbekistan","Vanuatu","Venezuela","Viet Nam","Virgin Islands, British","Virgin Islands, U.S.","Wallis and Futuna","Western Sahara","Yemen","Zambia","Zimbabwe"], + "strict": true + } + } +} \ No newline at end of file diff --git a/src/common/config.ts b/src/common/config.ts index c74277e4..b7b89bd9 100644 --- a/src/common/config.ts +++ b/src/common/config.ts @@ -60,7 +60,8 @@ export const fetchConfig = () => { .then(config => Axios .get(getDiscoveryURL(config.API_HOST)) .then(response => ({ - config: {...response.data, vocabularyUrl: config.VOCABULARY_URL }, + // TODO: After tests delete `|| '/vocabulary-example.json'` + config: {...response.data, vocabularyUrl: config.VOCABULARY_URL || '/vocabulary-example.json' }, apiHost: config.API_HOST, }))); diff --git a/src/components/file-tree/file-thumbnail.tsx b/src/components/file-tree/file-thumbnail.tsx new file mode 100644 index 00000000..e1a0d5e0 --- /dev/null +++ b/src/components/file-tree/file-thumbnail.tsx @@ -0,0 +1,36 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +import * as React from "react"; +import isImage from 'is-image'; +import { withStyles, WithStyles } from '@material-ui/core'; +import { FileTreeData } from '~/components/file-tree/file-tree-data'; +import { CollectionFileType } from '~/models/collection-file'; + +export interface FileThumbnailProps { + file: FileTreeData; +} + +export const FileThumbnail = + ({ file }: FileThumbnailProps) => + file.type === CollectionFileType.FILE && isImage(file.name) + ? + : null; + +type ImageFileThumbnailCssRules = 'thumbnail'; + +const imageFileThumbnailStyle = withStyles(theme => ({ + thumbnail: { + maxWidth: 250, + margin: `${theme.spacing.unit}px 0`, + } +})); + +const ImageFileThumbnail = imageFileThumbnailStyle( + ({ classes, file }: WithStyles & FileThumbnailProps) => + {file.name} +); diff --git a/src/components/file-tree/file-tree-data.ts b/src/components/file-tree/file-tree-data.ts index 4be4ace8..41546113 100644 --- a/src/components/file-tree/file-tree-data.ts +++ b/src/components/file-tree/file-tree-data.ts @@ -5,5 +5,6 @@ export interface FileTreeData { name: string; type: string; + url: string; size?: number; } diff --git a/src/components/file-tree/file-tree-item.tsx b/src/components/file-tree/file-tree-item.tsx index 89bf43c6..0e8c92e2 100644 --- a/src/components/file-tree/file-tree-item.tsx +++ b/src/components/file-tree/file-tree-item.tsx @@ -9,6 +9,7 @@ import { Typography, IconButton, StyleRulesCallback, withStyles, WithStyles, Too import { formatFileSize } from "~/common/formatters"; import { ListItemTextIcon } from "../list-item-text-icon/list-item-text-icon"; import { FileTreeData } from "./file-tree-data"; +import { FileThumbnail } from '~/components/file-tree/file-thumbnail'; type CssRules = "root" | "spacer" | "sizeInfo" | "button" | "moreOptions"; @@ -42,22 +43,25 @@ export const FileTreeItem = withStyles(fileTreeItemStyle)( class extends React.Component> { render() { const { classes, item } = this.props; - return
- -
- {formatFileSize(item.data.size)} - - - - - -
; + return <> +
+ +
+ {formatFileSize(item.data.size)} + + + + + +
+ + ; } handleClick = (event: React.MouseEvent) => { diff --git a/src/index.tsx b/src/index.tsx index 801a56a1..fbd6c9a8 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -50,7 +50,10 @@ import HTML5Backend from 'react-dnd-html5-backend'; import { initAdvanceFormProjectsTree } from '~/store/search-bar/search-bar-actions'; import { repositoryActionSet } from '~/views-components/context-menu/action-sets/repository-action-set'; import { sshKeyActionSet } from '~/views-components/context-menu/action-sets/ssh-key-action-set'; +import { keepServiceActionSet } from '~/views-components/context-menu/action-sets/keep-service-action-set'; import { loadVocabulary } from '~/store/vocabulary/vocabulary-actions'; +import { virtualMachineActionSet } from '~/views-components/context-menu/action-sets/virtual-machine-action-set'; +import { computeNodeActionSet } from '~/views-components/context-menu/action-sets/compute-node-action-set'; console.log(`Starting arvados [${getBuildInfo()}]`); @@ -69,6 +72,9 @@ addMenuActionSet(ContextMenuKind.PROCESS_RESOURCE, processResourceActionSet); addMenuActionSet(ContextMenuKind.TRASH, trashActionSet); addMenuActionSet(ContextMenuKind.REPOSITORY, repositoryActionSet); addMenuActionSet(ContextMenuKind.SSH_KEY, sshKeyActionSet); +addMenuActionSet(ContextMenuKind.VIRTUAL_MACHINE, virtualMachineActionSet); +addMenuActionSet(ContextMenuKind.KEEP_SERVICE, keepServiceActionSet); +addMenuActionSet(ContextMenuKind.NODE, computeNodeActionSet); fetchConfig() .then(({ config, apiHost }) => { diff --git a/src/models/keep.ts b/src/models/keep-services.ts similarity index 61% rename from src/models/keep.ts rename to src/models/keep-services.ts index f6b5ef2a..d99943c6 100644 --- a/src/models/keep.ts +++ b/src/models/keep-services.ts @@ -1,12 +1,13 @@ -// Copyright (C) The Arvados Authors. All rights reserved. -// -// SPDX-License-Identifier: AGPL-3.0 - -import { Resource } from "./resource"; - -export interface KeepResource extends Resource { - serviceHost: string; - servicePort: number; - serviceSslFlag: boolean; - serviceType: string; -} +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +import { Resource } from '~/models/resource'; + +export interface KeepServiceResource extends Resource { + serviceHost: string; + servicePort: number; + serviceSslFlag: boolean; + serviceType: string; + readOnly: boolean; +} \ No newline at end of file diff --git a/src/models/node.ts b/src/models/node.ts new file mode 100644 index 00000000..87238115 --- /dev/null +++ b/src/models/node.ts @@ -0,0 +1,37 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +import { Resource } from '~/models/resource'; + +export interface NodeResource extends Resource { + slotNumber: number; + hostname: string; + domain: string; + ipAddress: string; + jobUuid: string; + firstPingAt: string; + lastPingAt: string; + status: string; + info: NodeInfo; + properties: NodeProperties; +} + +export interface NodeInfo { + lastAction: string; + pingSecret: string; + ec2InstanceId: string; + slurmState?: string; +} + +export interface NodeProperties { + cloudNode: CloudNode; + totalRamMb: number; + totalCpuCores: number; + totalScratchMb: number; +} + +interface CloudNode { + size: string; + price: number; +} \ No newline at end of file diff --git a/src/models/resource.ts b/src/models/resource.ts index 7e2127b2..4d2d92e0 100644 --- a/src/models/resource.ts +++ b/src/models/resource.ts @@ -26,10 +26,12 @@ export enum ResourceKind { CONTAINER_REQUEST = "arvados#containerRequest", GROUP = "arvados#group", LOG = "arvados#log", + NODE = "arvados#node", PROCESS = "arvados#containerRequest", PROJECT = "arvados#group", REPOSITORY = "arvados#repository", SSH_KEY = "arvados#authorizedKeys", + KEEP_SERVICE = "arvados#keepService", USER = "arvados#user", VIRTUAL_MACHINE = "arvados#virtualMachine", WORKFLOW = "arvados#workflow", @@ -46,7 +48,9 @@ export enum ResourceObjectType { USER = 'tpzed', VIRTUAL_MACHINE = '2x53u', WORKFLOW = '7fd4e', - SSH_KEY = 'fngyi' + SSH_KEY = 'fngyi', + KEEP_SERVICE = 'bi6l4', + NODE = '7ekkf' } export const RESOURCE_UUID_PATTERN = '.{5}-.{5}-.{15}'; @@ -85,6 +89,10 @@ export const extractUuidKind = (uuid: string = '') => { return ResourceKind.REPOSITORY; case ResourceObjectType.SSH_KEY: return ResourceKind.SSH_KEY; + case ResourceObjectType.KEEP_SERVICE: + return ResourceKind.KEEP_SERVICE; + case ResourceObjectType.NODE: + return ResourceKind.NODE; default: return undefined; } diff --git a/src/models/virtual-machines.ts b/src/models/virtual-machines.ts index 0652c350..85d0a565 100644 --- a/src/models/virtual-machines.ts +++ b/src/models/virtual-machines.ts @@ -8,11 +8,16 @@ export interface VirtualMachinesResource extends Resource { hostname: string; } -export interface VirtualMachinesLoginsResource { +export interface VirtualMachinesLoginsItems { hostname: string; username: string; public_key: string; - user_uuid: string; - virtual_machine_uuid: string; - authorized_key_uuid: string; + userUuid: string; + virtualMachineUuid: string; + authorizedKeyUuid: string; +} + +export interface VirtualMachineLogins { + kind: string; + items: VirtualMachinesLoginsItems[]; } \ No newline at end of file diff --git a/src/routes/route-change-handlers.ts b/src/routes/route-change-handlers.ts index 400866e3..68de3107 100644 --- a/src/routes/route-change-handlers.ts +++ b/src/routes/route-change-handlers.ts @@ -4,10 +4,9 @@ import { History, Location } from 'history'; import { RootStore } from '~/store/store'; -import { matchProcessRoute, matchProcessLogRoute, matchProjectRoute, matchCollectionRoute, matchFavoritesRoute, matchTrashRoute, matchRootRoute, matchSharedWithMeRoute, matchRunProcessRoute, matchWorkflowRoute, matchSearchResultsRoute, matchSshKeysRoute, matchRepositoriesRoute, matchMyAccountRoute, matchVirtualMachineRoute } from './routes'; -import { loadProject, loadCollection, loadFavorites, loadTrash, loadProcess, loadProcessLog, loadSshKeys, loadRepositories, loadMyAccount, loadVirtualMachines } from '~/store/workbench/workbench-actions'; +import * as Routes from '~/routes/routes'; +import * as WorkbenchActions from '~/store/workbench/workbench-actions'; import { navigateToRootProject } from '~/store/navigation/navigation-action'; -import { loadSharedWithMe, loadRunProcess, loadWorkflow, loadSearchResults } from '~//store/workbench/workbench-actions'; export const addRouteChangeHandlers = (history: History, store: RootStore) => { const handler = handleLocationChange(store); @@ -16,51 +15,57 @@ export const addRouteChangeHandlers = (history: History, store: RootStore) => { }; const handleLocationChange = (store: RootStore) => ({ pathname }: Location) => { - const rootMatch = matchRootRoute(pathname); - const projectMatch = matchProjectRoute(pathname); - const collectionMatch = matchCollectionRoute(pathname); - const favoriteMatch = matchFavoritesRoute(pathname); - const trashMatch = matchTrashRoute(pathname); - const processMatch = matchProcessRoute(pathname); - const processLogMatch = matchProcessLogRoute(pathname); - const repositoryMatch = matchRepositoriesRoute(pathname); - const searchResultsMatch = matchSearchResultsRoute(pathname); - const sharedWithMeMatch = matchSharedWithMeRoute(pathname); - const runProcessMatch = matchRunProcessRoute(pathname); - const virtualMachineMatch = matchVirtualMachineRoute(pathname); - const workflowMatch = matchWorkflowRoute(pathname); - const sshKeysMatch = matchSshKeysRoute(pathname); - const myAccountMatch = matchMyAccountRoute(pathname); + const rootMatch = Routes.matchRootRoute(pathname); + const projectMatch = Routes.matchProjectRoute(pathname); + const collectionMatch = Routes.matchCollectionRoute(pathname); + const favoriteMatch = Routes.matchFavoritesRoute(pathname); + const trashMatch = Routes.matchTrashRoute(pathname); + const processMatch = Routes.matchProcessRoute(pathname); + const processLogMatch = Routes.matchProcessLogRoute(pathname); + const repositoryMatch = Routes.matchRepositoriesRoute(pathname); + const searchResultsMatch = Routes.matchSearchResultsRoute(pathname); + const sharedWithMeMatch = Routes.matchSharedWithMeRoute(pathname); + const runProcessMatch = Routes.matchRunProcessRoute(pathname); + const virtualMachineMatch = Routes.matchVirtualMachineRoute(pathname); + const workflowMatch = Routes.matchWorkflowRoute(pathname); + const sshKeysMatch = Routes.matchSshKeysRoute(pathname); + const keepServicesMatch = Routes.matchKeepServicesRoute(pathname); + const computeNodesMatch = Routes.matchComputeNodesRoute(pathname); + const myAccountMatch = Routes.matchMyAccountRoute(pathname); if (projectMatch) { - store.dispatch(loadProject(projectMatch.params.id)); + store.dispatch(WorkbenchActions.loadProject(projectMatch.params.id)); } else if (collectionMatch) { - store.dispatch(loadCollection(collectionMatch.params.id)); + store.dispatch(WorkbenchActions.loadCollection(collectionMatch.params.id)); } else if (favoriteMatch) { - store.dispatch(loadFavorites()); + store.dispatch(WorkbenchActions.loadFavorites()); } else if (trashMatch) { - store.dispatch(loadTrash()); + store.dispatch(WorkbenchActions.loadTrash()); } else if (processMatch) { - store.dispatch(loadProcess(processMatch.params.id)); + store.dispatch(WorkbenchActions.loadProcess(processMatch.params.id)); } else if (processLogMatch) { - store.dispatch(loadProcessLog(processLogMatch.params.id)); + store.dispatch(WorkbenchActions.loadProcessLog(processLogMatch.params.id)); } else if (rootMatch) { store.dispatch(navigateToRootProject); } else if (sharedWithMeMatch) { - store.dispatch(loadSharedWithMe); + store.dispatch(WorkbenchActions.loadSharedWithMe); } else if (runProcessMatch) { - store.dispatch(loadRunProcess); + store.dispatch(WorkbenchActions.loadRunProcess); } else if (workflowMatch) { - store.dispatch(loadWorkflow); + store.dispatch(WorkbenchActions.loadWorkflow); } else if (searchResultsMatch) { - store.dispatch(loadSearchResults); + store.dispatch(WorkbenchActions.loadSearchResults); } else if (virtualMachineMatch) { - store.dispatch(loadVirtualMachines); + store.dispatch(WorkbenchActions.loadVirtualMachines); } else if(repositoryMatch) { - store.dispatch(loadRepositories); + store.dispatch(WorkbenchActions.loadRepositories); } else if (sshKeysMatch) { - store.dispatch(loadSshKeys); + store.dispatch(WorkbenchActions.loadSshKeys); + } else if (keepServicesMatch) { + store.dispatch(WorkbenchActions.loadKeepServices); + } else if (computeNodesMatch) { + store.dispatch(WorkbenchActions.loadComputeNodes); } else if (myAccountMatch) { - store.dispatch(loadMyAccount); + store.dispatch(WorkbenchActions.loadMyAccount); } }; diff --git a/src/routes/routes.ts b/src/routes/routes.ts index a27b4274..71d920ab 100644 --- a/src/routes/routes.ts +++ b/src/routes/routes.ts @@ -23,7 +23,9 @@ export const Routes = { WORKFLOWS: '/workflows', SEARCH_RESULTS: '/search-results', SSH_KEYS: `/ssh-keys`, - MY_ACCOUNT: '/my-account' + MY_ACCOUNT: '/my-account', + KEEP_SERVICES: `/keep-services`, + COMPUTE_NODES: `/nodes` }; export const getResourceUrl = (uuid: string) => { @@ -92,3 +94,9 @@ export const matchSshKeysRoute = (route: string) => export const matchMyAccountRoute = (route: string) => matchPath(route, { path: Routes.MY_ACCOUNT }); + +export const matchKeepServicesRoute = (route: string) => + matchPath(route, { path: Routes.KEEP_SERVICES }); + +export const matchComputeNodesRoute = (route: string) => + matchPath(route, { path: Routes.COMPUTE_NODES }); \ No newline at end of file diff --git a/src/services/auth-service/auth-service.ts b/src/services/auth-service/auth-service.ts index 6faaf99e..8c2ad5ca 100644 --- a/src/services/auth-service/auth-service.ts +++ b/src/services/auth-service/auth-service.ts @@ -4,7 +4,7 @@ import { User, userPrefs } from "~/models/user"; import { AxiosInstance } from "axios"; -import { ApiActions, ProgressFn } from "~/services/api/api-actions"; +import { ApiActions } from "~/services/api/api-actions"; import * as uuid from "uuid/v4"; export const API_TOKEN_KEY = 'apiToken'; @@ -56,7 +56,7 @@ export class AuthService { } public getIsAdmin(): boolean { - return !!localStorage.getItem(USER_IS_ADMIN); + return localStorage.getItem(USER_IS_ADMIN) === 'true'; } public getUser(): User | undefined { diff --git a/src/services/keep-service/keep-service.ts b/src/services/keep-service/keep-service.ts index 17ee522e..5a89ba57 100644 --- a/src/services/keep-service/keep-service.ts +++ b/src/services/keep-service/keep-service.ts @@ -4,11 +4,11 @@ import { CommonResourceService } from "~/services/common-service/common-resource-service"; import { AxiosInstance } from "axios"; -import { KeepResource } from "~/models/keep"; +import { KeepServiceResource } from "~/models/keep-services"; import { ApiActions } from "~/services/api/api-actions"; -export class KeepService extends CommonResourceService { +export class KeepService extends CommonResourceService { constructor(serverApi: AxiosInstance, actions: ApiActions) { super(serverApi, "keep_services", actions); } -} +} \ No newline at end of file diff --git a/src/services/node-service/node-service.ts b/src/services/node-service/node-service.ts new file mode 100644 index 00000000..97f22645 --- /dev/null +++ b/src/services/node-service/node-service.ts @@ -0,0 +1,14 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +import { AxiosInstance } from "axios"; +import { CommonResourceService } from "~/services/common-service/common-resource-service"; +import { NodeResource } from '~/models/node'; +import { ApiActions } from '~/services/api/api-actions'; + +export class NodeService extends CommonResourceService { + constructor(serverApi: AxiosInstance, actions: ApiActions) { + super(serverApi, "nodes", actions); + } +} \ No newline at end of file diff --git a/src/services/services.ts b/src/services/services.ts index b24b1d99..d524405f 100644 --- a/src/services/services.ts +++ b/src/services/services.ts @@ -28,6 +28,7 @@ import { VirtualMachinesService } from "~/services/virtual-machines-service/virt import { RepositoriesService } from '~/services/repositories-service/repositories-service'; import { AuthorizedKeysService } from '~/services/authorized-keys-service/authorized-keys-service'; import { VocabularyService } from '~/services/vocabulary-service/vocabulary-service'; +import { NodeService } from '~/services/node-service/node-service'; export type ServiceRepository = ReturnType; @@ -45,6 +46,7 @@ export const createServices = (config: Config, actions: ApiActions) => { const keepService = new KeepService(apiClient, actions); const linkService = new LinkService(apiClient, actions); const logService = new LogService(apiClient, actions); + const nodeService = new NodeService(apiClient, actions); const permissionService = new PermissionService(apiClient, actions); const projectService = new ProjectService(apiClient, actions); const repositoriesService = new RepositoriesService(apiClient, actions); @@ -75,6 +77,7 @@ export const createServices = (config: Config, actions: ApiActions) => { keepService, linkService, logService, + nodeService, permissionService, projectService, repositoriesService, diff --git a/src/store/advanced-tab/advanced-tab.ts b/src/store/advanced-tab/advanced-tab.ts index b3c5164c..6b20f8b3 100644 --- a/src/store/advanced-tab/advanced-tab.ts +++ b/src/store/advanced-tab/advanced-tab.ts @@ -16,13 +16,19 @@ import { ServiceRepository } from '~/services/services'; import { FilterBuilder } from '~/services/api/filter-builder'; import { RepositoryResource } from '~/models/repositories'; import { SshKeyResource } from '~/models/ssh-key'; +import { VirtualMachinesResource } from '~/models/virtual-machines'; +import { UserResource } from '~/models/user'; +import { ListResults } from '~/services/common-service/common-resource-service'; +import { LinkResource } from '~/models/link'; +import { KeepServiceResource } from '~/models/keep-services'; +import { NodeResource } from '~/models/node'; export const ADVANCED_TAB_DIALOG = 'advancedTabDialog'; interface AdvancedTabDialogData { apiResponse: any; - metadata: any; - user: string; + metadata: ListResults | string; + user: UserResource | string; pythonHeader: string; pythonExample: string; cliGetHeader: string; @@ -54,42 +60,161 @@ enum RepositoryData { } enum SshKeyData { - SSH_KEY = 'authorized_keys', + SSH_KEY = 'authorized_key', CREATED_AT = 'created_at' } -type AdvanceResourceKind = CollectionData | ProcessData | ProjectData | RepositoryData | SshKeyData; -type AdvanceResourcePrefix = GroupContentsResourcePrefix | 'repositories' | 'authorized_keys'; +enum VirtualMachineData { + VIRTUAL_MACHINE = 'virtual_machine', + CREATED_AT = 'created_at' +} + +enum ResourcePrefix { + REPOSITORIES = 'repositories', + AUTORIZED_KEYS = 'authorized_keys', + VIRTUAL_MACHINES = 'virtual_machines', + KEEP_SERVICES = 'keep_services', + COMPUTE_NODES = 'nodes' +} + +enum KeepServiceData { + KEEP_SERVICE = 'keep_services', + CREATED_AT = 'created_at' +} + +enum ComputeNodeData { + COMPUTE_NODE = 'node', + PROPERTIES = 'properties' +} -export const openAdvancedTabDialog = (uuid: string, index?: number) => +type AdvanceResourceKind = CollectionData | ProcessData | ProjectData | RepositoryData | SshKeyData | VirtualMachineData | KeepServiceData | ComputeNodeData; +type AdvanceResourcePrefix = GroupContentsResourcePrefix | ResourcePrefix; +type AdvanceResponseData = ContainerRequestResource | ProjectResource | CollectionResource | RepositoryResource | SshKeyResource | VirtualMachinesResource | KeepServiceResource | NodeResource | undefined; + +export const openAdvancedTabDialog = (uuid: string) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { const kind = extractUuidKind(uuid); switch (kind) { case ResourceKind.COLLECTION: const { data: dataCollection, metadata: metaCollection, user: userCollection } = await dispatch(getDataForAdvancedTab(uuid)); - const advanceDataCollection: AdvancedTabDialogData = advancedTabData(uuid, metaCollection, userCollection, collectionApiResponse, dataCollection, CollectionData.COLLECTION, GroupContentsResourcePrefix.COLLECTION, CollectionData.STORAGE_CLASSES_CONFIRMED, dataCollection.storageClassesConfirmed); + const advanceDataCollection = advancedTabData({ + uuid, + metadata: metaCollection, + user: userCollection, + apiResponseKind: collectionApiResponse, + data: dataCollection, + resourceKind: CollectionData.COLLECTION, + resourcePrefix: GroupContentsResourcePrefix.COLLECTION, + resourceKindProperty: CollectionData.STORAGE_CLASSES_CONFIRMED, + property: dataCollection.storageClassesConfirmed + }); dispatch(initAdvancedTabDialog(advanceDataCollection)); break; case ResourceKind.PROCESS: const { data: dataProcess, metadata: metaProcess, user: userProcess } = await dispatch(getDataForAdvancedTab(uuid)); - const advancedDataProcess: AdvancedTabDialogData = advancedTabData(uuid, metaProcess, userProcess, containerRequestApiResponse, dataProcess, ProcessData.CONTAINER_REQUEST, GroupContentsResourcePrefix.PROCESS, ProcessData.OUTPUT_NAME, dataProcess.outputName); + const advancedDataProcess = advancedTabData({ + uuid, + metadata: metaProcess, + user: userProcess, + apiResponseKind: containerRequestApiResponse, + data: dataProcess, + resourceKind: ProcessData.CONTAINER_REQUEST, + resourcePrefix: GroupContentsResourcePrefix.PROCESS, + resourceKindProperty: ProcessData.OUTPUT_NAME, + property: dataProcess.outputName + }); dispatch(initAdvancedTabDialog(advancedDataProcess)); break; case ResourceKind.PROJECT: const { data: dataProject, metadata: metaProject, user: userProject } = await dispatch(getDataForAdvancedTab(uuid)); - const advanceDataProject: AdvancedTabDialogData = advancedTabData(uuid, metaProject, userProject, groupRequestApiResponse, dataProject, ProjectData.GROUP, GroupContentsResourcePrefix.PROJECT, ProjectData.DELETE_AT, dataProject.deleteAt); + const advanceDataProject = advancedTabData({ + uuid, + metadata: metaProject, + user: userProject, + apiResponseKind: groupRequestApiResponse, + data: dataProject, + resourceKind: ProjectData.GROUP, + resourcePrefix: GroupContentsResourcePrefix.PROJECT, + resourceKindProperty: ProjectData.DELETE_AT, + property: dataProject.deleteAt + }); dispatch(initAdvancedTabDialog(advanceDataProject)); break; case ResourceKind.REPOSITORY: - const dataRepository = getState().repositories.items[index!]; - const advanceDataRepository: AdvancedTabDialogData = advancedTabData(uuid, '', '', repositoryApiResponse, dataRepository, RepositoryData.REPOSITORY, 'repositories', RepositoryData.CREATED_AT, dataRepository.createdAt); + const dataRepository = getState().repositories.items.find(it => it.uuid === uuid); + const advanceDataRepository = advancedTabData({ + uuid, + metadata: '', + user: '', + apiResponseKind: repositoryApiResponse, + data: dataRepository, + resourceKind: RepositoryData.REPOSITORY, + resourcePrefix: ResourcePrefix.REPOSITORIES, + resourceKindProperty: RepositoryData.CREATED_AT, + property: dataRepository!.createdAt + }); dispatch(initAdvancedTabDialog(advanceDataRepository)); break; case ResourceKind.SSH_KEY: - const dataSshKey = getState().auth.sshKeys[index!]; - const advanceDataSshKey: AdvancedTabDialogData = advancedTabData(uuid, '', '', sshKeyApiResponse, dataSshKey, SshKeyData.SSH_KEY, 'authorized_keys', SshKeyData.CREATED_AT, dataSshKey.createdAt); + const dataSshKey = getState().auth.sshKeys.find(it => it.uuid === uuid); + const advanceDataSshKey = advancedTabData({ + uuid, + metadata: '', + user: '', + apiResponseKind: sshKeyApiResponse, + data: dataSshKey, + resourceKind: SshKeyData.SSH_KEY, + resourcePrefix: ResourcePrefix.AUTORIZED_KEYS, + resourceKindProperty: SshKeyData.CREATED_AT, + property: dataSshKey!.createdAt + }); dispatch(initAdvancedTabDialog(advanceDataSshKey)); break; + case ResourceKind.VIRTUAL_MACHINE: + const dataVirtualMachine = getState().virtualMachines.virtualMachines.items.find(it => it.uuid === uuid); + const advanceDataVirtualMachine = advancedTabData({ + uuid, + metadata: '', + user: '', + apiResponseKind: virtualMachineApiResponse, + data: dataVirtualMachine, + resourceKind: VirtualMachineData.VIRTUAL_MACHINE, + resourcePrefix: ResourcePrefix.VIRTUAL_MACHINES, + resourceKindProperty: VirtualMachineData.CREATED_AT, + property: dataVirtualMachine.createdAt + }); + dispatch(initAdvancedTabDialog(advanceDataVirtualMachine)); + break; + case ResourceKind.KEEP_SERVICE: + const dataKeepService = getState().keepServices.find(it => it.uuid === uuid); + const advanceDataKeepService = advancedTabData({ + uuid, + metadata: '', + user: '', + apiResponseKind: keepServiceApiResponse, + data: dataKeepService, + resourceKind: KeepServiceData.KEEP_SERVICE, + resourcePrefix: ResourcePrefix.KEEP_SERVICES, + resourceKindProperty: KeepServiceData.CREATED_AT, + property: dataKeepService!.createdAt + }); + dispatch(initAdvancedTabDialog(advanceDataKeepService)); + break; + case ResourceKind.NODE: + const dataComputeNode = getState().computeNodes.find(node => node.uuid === uuid); + const advanceDataComputeNode = advancedTabData({ + uuid, + metadata: '', + user: '', + apiResponseKind: computeNodeApiResponse, + data: dataComputeNode, + resourceKind: ComputeNodeData.COMPUTE_NODE, + resourcePrefix: ResourcePrefix.COMPUTE_NODES, + resourceKindProperty: ComputeNodeData.PROPERTIES, + property: dataComputeNode!.properties + }); + dispatch(initAdvancedTabDialog(advanceDataComputeNode)); + break; default: dispatch(snackbarActions.OPEN_SNACKBAR({ message: "Could not open advanced tab for this resource.", hideDuration: 2000, kind: SnackbarKind.ERROR })); } @@ -110,8 +235,19 @@ const getDataForAdvancedTab = (uuid: string) => const initAdvancedTabDialog = (data: AdvancedTabDialogData) => dialogActions.OPEN_DIALOG({ id: ADVANCED_TAB_DIALOG, data }); -const advancedTabData = (uuid: string, metadata: any, user: any, apiResponseKind: any, data: any, resourceKind: AdvanceResourceKind, - resourcePrefix: AdvanceResourcePrefix, resourceKindProperty: AdvanceResourceKind, property: any) => { +interface AdvancedTabData { + uuid: string; + metadata: ListResults | string; + user: UserResource | string; + apiResponseKind: (apiResponse: AdvanceResponseData) => string; + data: AdvanceResponseData; + resourceKind: AdvanceResourceKind; + resourcePrefix: AdvanceResourcePrefix; + resourceKindProperty: AdvanceResourceKind; + property: any; +} + +const advancedTabData = ({ uuid, user, metadata, apiResponseKind, data, resourceKind, resourcePrefix, resourceKindProperty, property }: AdvancedTabData) => { return { uuid, user, @@ -155,7 +291,7 @@ const cliUpdateHeader = (resourceKind: string, resourceName: string) => const cliUpdateExample = (uuid: string, resourceKind: string, resource: string | string[], resourceName: string) => { const CLIUpdateCollectionExample = `arv ${resourceKind} update \\ --uuid ${uuid} \\ - --${resourceKind} '{"${resourceName}":${resource}}'`; + --${resourceKind} '{"${resourceName}":${JSON.stringify(resource)}}'`; return CLIUpdateCollectionExample; }; @@ -170,7 +306,7 @@ const curlExample = (uuid: string, resourcePrefix: string, resource: string | st https://$ARVADOS_API_HOST/arvados/v1/${resourcePrefix}/${uuid} \\ < { "name": ${stringify(name)}, "created_at": "${createdAt}", "expires_at": "${expiresAt}"`; + return response; +}; + +const virtualMachineApiResponse = (apiResponse: VirtualMachinesResource) => { + const { uuid, ownerUuid, createdAt, modifiedAt, modifiedByClientUuid, modifiedByUserUuid, hostname } = apiResponse; + const response = `"hostname": ${stringify(hostname)}, +"uuid": "${uuid}", +"owner_uuid": "${ownerUuid}", +"modified_by_client_uuid": ${stringify(modifiedByClientUuid)}, +"modified_by_user_uuid": ${stringify(modifiedByUserUuid)}, +"modified_at": ${stringify(modifiedAt)}, +"modified_at": ${stringify(modifiedAt)}, +"created_at": "${createdAt}"`; + + return response; +}; + +const keepServiceApiResponse = (apiResponse: KeepServiceResource) => { + const { + uuid, readOnly, serviceHost, servicePort, serviceSslFlag, serviceType, + ownerUuid, createdAt, modifiedAt, modifiedByClientUuid, modifiedByUserUuid + } = apiResponse; + const response = `"uuid": "${uuid}", +"owner_uuid": "${ownerUuid}", +"modified_by_client_uuid": ${stringify(modifiedByClientUuid)}, +"modified_by_user_uuid": ${stringify(modifiedByUserUuid)}, +"modified_at": ${stringify(modifiedAt)}, +"service_host": "${serviceHost}", +"service_port": "${servicePort}", +"service_ssl_flag": "${stringify(serviceSslFlag)}", +"service_type": "${serviceType}", +"created_at": "${createdAt}", +"read_only": "${stringify(readOnly)}"`; + + return response; +}; + +const computeNodeApiResponse = (apiResponse: NodeResource) => { + const { + uuid, slotNumber, hostname, domain, ipAddress, firstPingAt, lastPingAt, jobUuid, + ownerUuid, createdAt, modifiedAt, modifiedByClientUuid, modifiedByUserUuid, + properties, info + } = apiResponse; + const response = `"uuid": "${uuid}", +"owner_uuid": "${ownerUuid}", +"modified_by_client_uuid": ${stringify(modifiedByClientUuid)}, +"modified_by_user_uuid": ${stringify(modifiedByUserUuid)}, +"modified_at": ${stringify(modifiedAt)}, +"created_at": "${createdAt}", +"slot_number": "${stringify(slotNumber)}", +"hostname": "${stringify(hostname)}", +"domain": "${stringify(domain)}", +"ip_address": "${stringify(ipAddress)}", +"first_ping_at": "${stringify(firstPingAt)}", +"last_ping_at": "${stringify(lastPingAt)}", +"job_uuid": "${stringify(jobUuid)}", +"properties": "${JSON.stringify(properties, null, 4)}", +"info": "${JSON.stringify(info, null, 4)}"`; + return response; }; \ No newline at end of file diff --git a/src/store/auth/auth-action.ts b/src/store/auth/auth-action.ts index a2046f33..0ec39ebc 100644 --- a/src/store/auth/auth-action.ts +++ b/src/store/auth/auth-action.ts @@ -94,9 +94,9 @@ export const openSshKeyCreateDialog = () => dialogActions.OPEN_DIALOG({ id: SSH_ export const openPublicKeyDialog = (name: string, publicKey: string) => dialogActions.OPEN_DIALOG({ id: SSH_KEY_PUBLIC_KEY_DIALOG, data: { name, publicKey } }); -export const openSshKeyAttributesDialog = (index: number) => +export const openSshKeyAttributesDialog = (uuid: string) => (dispatch: Dispatch, getState: () => RootState) => { - const sshKey = getState().auth.sshKeys[index]; + const sshKey = getState().auth.sshKeys.find(it => it.uuid === uuid); dispatch(dialogActions.OPEN_DIALOG({ id: SSH_KEY_ATTRIBUTES_DIALOG, data: { sshKey } })); }; diff --git a/src/store/auth/auth-actions.test.ts b/src/store/auth/auth-actions.test.ts index 3d6913a6..aeee2b3b 100644 --- a/src/store/auth/auth-actions.test.ts +++ b/src/store/auth/auth-actions.test.ts @@ -43,7 +43,7 @@ describe('auth-actions', () => { localStorage.setItem(USER_LAST_NAME_KEY, "Doe"); localStorage.setItem(USER_UUID_KEY, "uuid"); localStorage.setItem(USER_OWNER_UUID_KEY, "ownerUuid"); - localStorage.setItem(USER_IS_ADMIN, JSON.stringify(false)); + localStorage.setItem(USER_IS_ADMIN, JSON.stringify("false")); store.dispatch(initAuth()); diff --git a/src/store/compute-nodes/compute-nodes-actions.ts b/src/store/compute-nodes/compute-nodes-actions.ts new file mode 100644 index 00000000..659b1e86 --- /dev/null +++ b/src/store/compute-nodes/compute-nodes-actions.ts @@ -0,0 +1,71 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +import { Dispatch } from "redux"; +import { unionize, ofType, UnionOf } from "~/common/unionize"; +import { RootState } from '~/store/store'; +import { setBreadcrumbs } from '~/store/breadcrumbs/breadcrumbs-actions'; +import { ServiceRepository } from "~/services/services"; +import { NodeResource } from '~/models/node'; +import { dialogActions } from '~/store/dialog/dialog-actions'; +import { snackbarActions } from '~/store/snackbar/snackbar-actions'; +import { navigateToRootProject } from '~/store/navigation/navigation-action'; + +export const computeNodesActions = unionize({ + SET_COMPUTE_NODES: ofType(), + REMOVE_COMPUTE_NODE: ofType() +}); + +export type ComputeNodesActions = UnionOf; + +export const COMPUTE_NODE_REMOVE_DIALOG = 'computeNodeRemoveDialog'; +export const COMPUTE_NODE_ATTRIBUTES_DIALOG = 'computeNodeAttributesDialog'; + +export const loadComputeNodesPanel = () => + async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { + const user = getState().auth.user; + if (user && user.isAdmin) { + try { + dispatch(setBreadcrumbs([{ label: 'Compute Nodes' }])); + const response = await services.nodeService.list(); + dispatch(computeNodesActions.SET_COMPUTE_NODES(response.items)); + } catch (e) { + return; + } + } else { + dispatch(navigateToRootProject); + dispatch(snackbarActions.OPEN_SNACKBAR({ message: "You don't have permissions to view this page", hideDuration: 2000 })); + } + }; + +export const openComputeNodeAttributesDialog = (uuid: string) => + (dispatch: Dispatch, getState: () => RootState) => { + const computeNode = getState().computeNodes.find(node => node.uuid === uuid); + dispatch(dialogActions.OPEN_DIALOG({ id: COMPUTE_NODE_ATTRIBUTES_DIALOG, data: { computeNode } })); + }; + +export const openComputeNodeRemoveDialog = (uuid: string) => + (dispatch: Dispatch, getState: () => RootState) => { + dispatch(dialogActions.OPEN_DIALOG({ + id: COMPUTE_NODE_REMOVE_DIALOG, + data: { + title: 'Remove compute node', + text: 'Are you sure you want to remove this compute node?', + confirmButtonLabel: 'Remove', + uuid + } + })); + }; + +export const removeComputeNode = (uuid: string) => + async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { + dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Removing ...' })); + try { + await services.nodeService.delete(uuid); + dispatch(computeNodesActions.REMOVE_COMPUTE_NODE(uuid)); + dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Compute node has been successfully removed.', hideDuration: 2000 })); + } catch (e) { + return; + } + }; \ No newline at end of file diff --git a/src/store/compute-nodes/compute-nodes-reducer.ts b/src/store/compute-nodes/compute-nodes-reducer.ts new file mode 100644 index 00000000..44a37807 --- /dev/null +++ b/src/store/compute-nodes/compute-nodes-reducer.ts @@ -0,0 +1,17 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +import { computeNodesActions, ComputeNodesActions } from '~/store/compute-nodes/compute-nodes-actions'; +import { NodeResource } from '~/models/node'; + +export type ComputeNodesState = NodeResource[]; + +const initialState: ComputeNodesState = []; + +export const computeNodesReducer = (state: ComputeNodesState = initialState, action: ComputeNodesActions): ComputeNodesState => + computeNodesActions.match(action, { + SET_COMPUTE_NODES: nodes => nodes, + REMOVE_COMPUTE_NODE: (uuid: string) => state.filter((computeNode) => computeNode.uuid !== uuid), + default: () => state + }); \ No newline at end of file diff --git a/src/store/context-menu/context-menu-actions.ts b/src/store/context-menu/context-menu-actions.ts index 5631a5e8..65ddcff2 100644 --- a/src/store/context-menu/context-menu-actions.ts +++ b/src/store/context-menu/context-menu-actions.ts @@ -15,6 +15,9 @@ import { extractUuidKind, ResourceKind } from '~/models/resource'; import { Process } from '~/store/processes/process'; import { RepositoryResource } from '~/models/repositories'; import { SshKeyResource } from '~/models/ssh-key'; +import { VirtualMachinesResource } from '~/models/virtual-machines'; +import { KeepServiceResource } from '~/models/keep-services'; +import { NodeResource } from '~/models/node'; export const contextMenuActions = unionize({ OPEN_CONTEXT_MENU: ofType<{ position: ContextMenuPosition, resource: ContextMenuResource }>(), @@ -33,8 +36,9 @@ export type ContextMenuResource = { isTrashed?: boolean; index?: number }; -export const isKeyboardClick = (event: React.MouseEvent) => - event.nativeEvent.detail === 0; + +export const isKeyboardClick = (event: React.MouseEvent) => event.nativeEvent.detail === 0; + export const openContextMenu = (event: React.MouseEvent, resource: ContextMenuResource) => (dispatch: Dispatch) => { event.preventDefault(); @@ -62,27 +66,58 @@ export const openCollectionFilesContextMenu = (event: React.MouseEvent, index: number, repository: RepositoryResource) => +export const openRepositoryContextMenu = (event: React.MouseEvent, repository: RepositoryResource) => (dispatch: Dispatch, getState: () => RootState) => { - dispatch(openContextMenu(event, { - name: '', - uuid: repository.uuid, - ownerUuid: repository.ownerUuid, - kind: ResourceKind.REPOSITORY, - menuKind: ContextMenuKind.REPOSITORY, - index - })); + dispatch(openContextMenu(event, { + name: '', + uuid: repository.uuid, + ownerUuid: repository.ownerUuid, + kind: ResourceKind.REPOSITORY, + menuKind: ContextMenuKind.REPOSITORY + })); + }; + +export const openVirtualMachinesContextMenu = (event: React.MouseEvent, repository: VirtualMachinesResource) => + (dispatch: Dispatch, getState: () => RootState) => { + dispatch(openContextMenu(event, { + name: '', + uuid: repository.uuid, + ownerUuid: repository.ownerUuid, + kind: ResourceKind.VIRTUAL_MACHINE, + menuKind: ContextMenuKind.VIRTUAL_MACHINE + })); }; -export const openSshKeyContextMenu = (event: React.MouseEvent, index: number, sshKey: SshKeyResource) => +export const openSshKeyContextMenu = (event: React.MouseEvent, sshKey: SshKeyResource) => (dispatch: Dispatch) => { dispatch(openContextMenu(event, { name: '', uuid: sshKey.uuid, ownerUuid: sshKey.ownerUuid, kind: ResourceKind.SSH_KEY, - menuKind: ContextMenuKind.SSH_KEY, - index + menuKind: ContextMenuKind.SSH_KEY + })); + }; + +export const openKeepServiceContextMenu = (event: React.MouseEvent, keepService: KeepServiceResource) => + (dispatch: Dispatch) => { + dispatch(openContextMenu(event, { + name: '', + uuid: keepService.uuid, + ownerUuid: keepService.ownerUuid, + kind: ResourceKind.KEEP_SERVICE, + menuKind: ContextMenuKind.KEEP_SERVICE + })); + }; + +export const openComputeNodeContextMenu = (event: React.MouseEvent, computeNode: NodeResource) => + (dispatch: Dispatch) => { + dispatch(openContextMenu(event, { + name: '', + uuid: computeNode.uuid, + ownerUuid: computeNode.ownerUuid, + kind: ResourceKind.NODE, + menuKind: ContextMenuKind.NODE })); }; diff --git a/src/store/keep-services/keep-services-actions.ts b/src/store/keep-services/keep-services-actions.ts new file mode 100644 index 00000000..54a7c3fe --- /dev/null +++ b/src/store/keep-services/keep-services-actions.ts @@ -0,0 +1,71 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +import { Dispatch } from "redux"; +import { unionize, ofType, UnionOf } from "~/common/unionize"; +import { RootState } from '~/store/store'; +import { setBreadcrumbs } from '~/store/breadcrumbs/breadcrumbs-actions'; +import { ServiceRepository } from "~/services/services"; +import { KeepServiceResource } from '~/models/keep-services'; +import { dialogActions } from '~/store/dialog/dialog-actions'; +import { snackbarActions } from '~/store/snackbar/snackbar-actions'; +import { navigateToRootProject } from '~/store/navigation/navigation-action'; + +export const keepServicesActions = unionize({ + SET_KEEP_SERVICES: ofType(), + REMOVE_KEEP_SERVICE: ofType() +}); + +export type KeepServicesActions = UnionOf; + +export const KEEP_SERVICE_REMOVE_DIALOG = 'keepServiceRemoveDialog'; +export const KEEP_SERVICE_ATTRIBUTES_DIALOG = 'keepServiceAttributesDialog'; + +export const loadKeepServicesPanel = () => + async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { + const user = getState().auth.user; + if(user && user.isAdmin) { + try { + dispatch(setBreadcrumbs([{ label: 'Keep Services' }])); + const response = await services.keepService.list(); + dispatch(keepServicesActions.SET_KEEP_SERVICES(response.items)); + } catch (e) { + return; + } + } else { + dispatch(navigateToRootProject); + dispatch(snackbarActions.OPEN_SNACKBAR({ message: "You don't have permissions to view this page", hideDuration: 2000 })); + } + }; + +export const openKeepServiceAttributesDialog = (uuid: string) => + (dispatch: Dispatch, getState: () => RootState) => { + const keepService = getState().keepServices.find(it => it.uuid === uuid); + dispatch(dialogActions.OPEN_DIALOG({ id: KEEP_SERVICE_ATTRIBUTES_DIALOG, data: { keepService } })); + }; + +export const openKeepServiceRemoveDialog = (uuid: string) => + (dispatch: Dispatch, getState: () => RootState) => { + dispatch(dialogActions.OPEN_DIALOG({ + id: KEEP_SERVICE_REMOVE_DIALOG, + data: { + title: 'Remove keep service', + text: 'Are you sure you want to remove this keep service?', + confirmButtonLabel: 'Remove', + uuid + } + })); + }; + +export const removeKeepService = (uuid: string) => + async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { + dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Removing ...' })); + try { + await services.keepService.delete(uuid); + dispatch(keepServicesActions.REMOVE_KEEP_SERVICE(uuid)); + dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Keep service has been successfully removed.', hideDuration: 2000 })); + } catch (e) { + return; + } + }; \ No newline at end of file diff --git a/src/store/keep-services/keep-services-reducer.ts b/src/store/keep-services/keep-services-reducer.ts new file mode 100644 index 00000000..043c010a --- /dev/null +++ b/src/store/keep-services/keep-services-reducer.ts @@ -0,0 +1,17 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +import { keepServicesActions, KeepServicesActions } from '~/store/keep-services/keep-services-actions'; +import { KeepServiceResource } from '~/models/keep-services'; + +export type KeepSericesState = KeepServiceResource[]; + +const initialState: KeepSericesState = []; + +export const keepServicesReducer = (state: KeepSericesState = initialState, action: KeepServicesActions): KeepSericesState => + keepServicesActions.match(action, { + SET_KEEP_SERVICES: items => items, + REMOVE_KEEP_SERVICE: (uuid: string) => state.filter((keepService) => keepService.uuid !== uuid), + default: () => state + }); \ No newline at end of file diff --git a/src/store/navigation/navigation-action.ts b/src/store/navigation/navigation-action.ts index 80a7f213..a3652726 100644 --- a/src/store/navigation/navigation-action.ts +++ b/src/store/navigation/navigation-action.ts @@ -69,3 +69,7 @@ export const navigateToRepositories = push(Routes.REPOSITORIES); export const navigateToSshKeys= push(Routes.SSH_KEYS); export const navigateToMyAccount = push(Routes.MY_ACCOUNT); + +export const navigateToKeepServices = push(Routes.KEEP_SERVICES); + +export const navigateToComputeNodes = push(Routes.COMPUTE_NODES); \ No newline at end of file diff --git a/src/store/repositories/repositories-actions.ts b/src/store/repositories/repositories-actions.ts index a672738f..61caa769 100644 --- a/src/store/repositories/repositories-actions.ts +++ b/src/store/repositories/repositories-actions.ts @@ -32,9 +32,9 @@ export const openRepositoriesSampleGitDialog = () => dispatch(dialogActions.OPEN_DIALOG({ id: REPOSITORIES_SAMPLE_GIT_DIALOG, data: { uuidPrefix } })); }; -export const openRepositoryAttributes = (index: number) => +export const openRepositoryAttributes = (uuid: string) => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { - const repositoryData = getState().repositories.items[index]; + const repositoryData = getState().repositories.items.find(it => it.uuid === uuid); dispatch(dialogActions.OPEN_DIALOG({ id: REPOSITORY_ATTRIBUTES_DIALOG, data: { repositoryData } })); }; @@ -84,7 +84,7 @@ export const removeRepository = (uuid: string) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Removing ...' })); await services.repositoriesService.delete(uuid); - dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Removed.', hideDuration: 2000 })); + dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Removed.', hideDuration: 2000, kind: SnackbarKind.SUCCESS })); dispatch(loadRepositoriesData()); }; diff --git a/src/store/store.ts b/src/store/store.ts index 4ab0918e..321a19b6 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -45,6 +45,8 @@ import { SearchResultsMiddlewareService } from './search-results-panel/search-re import { resourcesDataReducer } from "~/store/resources-data/resources-data-reducer"; import { virtualMachinesReducer } from "~/store/virtual-machines/virtual-machines-reducer"; import { repositoriesReducer } from '~/store/repositories/repositories-reducer'; +import { keepServicesReducer } from '~/store/keep-services/keep-services-reducer'; +import { computeNodesReducer } from '~/store/compute-nodes/compute-nodes-reducer'; const composeEnhancers = (process.env.NODE_ENV === 'development' && @@ -115,5 +117,7 @@ const createRootReducer = (services: ServiceRepository) => combineReducers({ appInfo: appInfoReducer, searchBar: searchBarReducer, virtualMachines: virtualMachinesReducer, - repositories: repositoriesReducer + repositories: repositoriesReducer, + keepServices: keepServicesReducer, + computeNodes: computeNodesReducer }); diff --git a/src/store/virtual-machines/virtual-machines-actions.ts b/src/store/virtual-machines/virtual-machines-actions.ts index 9bd79884..c95277b3 100644 --- a/src/store/virtual-machines/virtual-machines-actions.ts +++ b/src/store/virtual-machines/virtual-machines-actions.ts @@ -9,33 +9,42 @@ import { navigateToVirtualMachines } from "../navigation/navigation-action"; import { bindDataExplorerActions } from '~/store/data-explorer/data-explorer-action'; import { formatDate } from "~/common/formatters"; import { unionize, ofType, UnionOf } from "~/common/unionize"; -import { VirtualMachinesLoginsResource } from '~/models/virtual-machines'; +import { VirtualMachineLogins } from '~/models/virtual-machines'; import { FilterBuilder } from "~/services/api/filter-builder"; import { ListResults } from "~/services/common-service/common-resource-service"; +import { dialogActions } from '~/store/dialog/dialog-actions'; +import { snackbarActions, SnackbarKind } from '~/store/snackbar/snackbar-actions'; export const virtualMachinesActions = unionize({ SET_REQUESTED_DATE: ofType(), SET_VIRTUAL_MACHINES: ofType>(), - SET_LOGINS: ofType(), + SET_LOGINS: ofType(), SET_LINKS: ofType>() }); export type VirtualMachineActions = UnionOf; export const VIRTUAL_MACHINES_PANEL = 'virtualMachinesPanel'; +export const VIRTUAL_MACHINE_ATTRIBUTES_DIALOG = 'virtualMachineAttributesDialog'; +export const VIRTUAL_MACHINE_REMOVE_DIALOG = 'virtualMachineRemoveDialog'; export const openVirtualMachines = () => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { dispatch(navigateToVirtualMachines); }; +export const openVirtualMachineAttributes = (uuid: string) => + async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { + const virtualMachineData = getState().virtualMachines.virtualMachines.items.find(it => it.uuid === uuid); + dispatch(dialogActions.OPEN_DIALOG({ id: VIRTUAL_MACHINE_ATTRIBUTES_DIALOG, data: { virtualMachineData } })); + }; + const loadRequestedDate = () => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { const date = services.virtualMachineService.getRequestedDate(); dispatch(virtualMachinesActions.SET_REQUESTED_DATE(date)); }; - export const loadVirtualMachinesData = () => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { dispatch(loadRequestedDate()); @@ -48,6 +57,8 @@ export const loadVirtualMachinesData = () => }); dispatch(virtualMachinesActions.SET_VIRTUAL_MACHINES(virtualMachines)); dispatch(virtualMachinesActions.SET_LINKS(links)); + const getAllLogins = await services.virtualMachineService.getAllLogins(); + dispatch(virtualMachinesActions.SET_LOGINS(getAllLogins)); }; export const saveRequestedDate = () => @@ -57,6 +68,27 @@ export const saveRequestedDate = () => dispatch(loadRequestedDate()); }; +export const openRemoveVirtualMachineDialog = (uuid: string) => + (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { + dispatch(dialogActions.OPEN_DIALOG({ + id: VIRTUAL_MACHINE_REMOVE_DIALOG, + data: { + title: 'Remove virtual machine', + text: 'Are you sure you want to remove this virtual machine?', + confirmButtonLabel: 'Remove', + uuid + } + })); + }; + +export const removeVirtualMachine = (uuid: string) => + async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { + dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Removing ...' })); + await services.virtualMachineService.delete(uuid); + dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Removed.', hideDuration: 2000, kind: SnackbarKind.SUCCESS })); + dispatch(loadVirtualMachinesData()); + }; + const virtualMachinesBindedActions = bindDataExplorerActions(VIRTUAL_MACHINES_PANEL); export const loadVirtualMachinesPanel = () => diff --git a/src/store/virtual-machines/virtual-machines-reducer.ts b/src/store/virtual-machines/virtual-machines-reducer.ts index fa28417e..475ad752 100644 --- a/src/store/virtual-machines/virtual-machines-reducer.ts +++ b/src/store/virtual-machines/virtual-machines-reducer.ts @@ -4,12 +4,12 @@ import { virtualMachinesActions, VirtualMachineActions } from '~/store/virtual-machines/virtual-machines-actions'; import { ListResults } from '~/services/common-service/common-resource-service'; -import { VirtualMachinesLoginsResource } from '~/models/virtual-machines'; +import { VirtualMachineLogins } from '~/models/virtual-machines'; interface VirtualMachines { date: string; virtualMachines: ListResults; - logins: VirtualMachinesLoginsResource[]; + logins: VirtualMachineLogins; links: ListResults; } @@ -22,7 +22,10 @@ const initialState: VirtualMachines = { itemsAvailable: 0, items: [] }, - logins: [], + logins: { + kind: '', + items: [] + }, links: { kind: '', offset: 0, diff --git a/src/store/workbench/workbench-actions.ts b/src/store/workbench/workbench-actions.ts index 091a8ccc..9d0140f3 100644 --- a/src/store/workbench/workbench-actions.ts +++ b/src/store/workbench/workbench-actions.ts @@ -57,6 +57,8 @@ import { searchResultsPanelActions, loadSearchResultsPanel } from '~/store/searc import { searchResultsPanelColumns } from '~/views/search-results-panel/search-results-panel-view'; import { loadVirtualMachinesPanel } from '~/store/virtual-machines/virtual-machines-actions'; import { loadRepositoriesPanel } from '~/store/repositories/repositories-actions'; +import { loadKeepServicesPanel } from '~/store/keep-services/keep-services-actions'; +import { loadComputeNodesPanel } from '~/store/compute-nodes/compute-nodes-actions'; export const WORKBENCH_LOADING_SCREEN = 'workbenchLoadingScreen'; @@ -416,6 +418,16 @@ export const loadMyAccount = handleFirstTimeLoad( await dispatch(loadMyAccountPanel()); }); +export const loadKeepServices = handleFirstTimeLoad( + async (dispatch: Dispatch) => { + await dispatch(loadKeepServicesPanel()); + }); + +export const loadComputeNodes = handleFirstTimeLoad( + async (dispatch: Dispatch) => { + await dispatch(loadComputeNodesPanel()); + }); + const finishLoadingProject = (project: GroupContentsResource | string) => async (dispatch: Dispatch) => { const uuid = typeof project === 'string' ? project : project.uuid; diff --git a/src/views-components/collection-panel-files/collection-panel-files.ts b/src/views-components/collection-panel-files/collection-panel-files.ts index 294bd6d5..d912ac13 100644 --- a/src/views-components/collection-panel-files/collection-panel-files.ts +++ b/src/views-components/collection-panel-files/collection-panel-files.ts @@ -18,8 +18,8 @@ import { FileTreeData } from "~/components/file-tree/file-tree-data"; import { Dispatch } from "redux"; import { collectionPanelFilesAction } from "~/store/collection-panel/collection-panel-files/collection-panel-files-actions"; import { ContextMenuKind } from "../context-menu/context-menu"; -import { getNode, getNodeChildrenIds, Tree } from "~/models/tree"; -import { CollectionFileType } from "~/models/collection-file"; +import { getNode, getNodeChildrenIds, Tree, TreeNode, initTreeNode } from "~/models/tree"; +import { CollectionFileType, createCollectionDirectory } from "~/models/collection-file"; import { openContextMenu, openCollectionFilesContextMenu } from '~/store/context-menu/context-menu-actions'; import { openUploadCollectionFilesDialog } from '~/store/collections/collection-upload-actions'; import { ResourceKind } from "~/models/resource"; @@ -63,23 +63,22 @@ export const CollectionPanelFiles = connect(memoizedMapStateToProps(), mapDispat const collectionItemToTreeItem = (tree: Tree) => (id: string): TreeItem => { - const node = getNode(id)(tree) || { + const node: TreeNode = getNode(id)(tree) || initTreeNode({ id: '', - children: [], parent: '', value: { - name: 'Invalid node', - type: CollectionFileType.DIRECTORY, + ...createCollectionDirectory({ name: 'Invalid file' }), selected: false, collapsed: true } - }; + }); return { active: false, data: { name: node.value.name, size: node.value.type === CollectionFileType.FILE ? node.value.size : undefined, - type: node.value.type + type: node.value.type, + url: node.value.url, }, id: node.id, items: getNodeChildrenIds(node.id)(tree) diff --git a/src/views-components/compute-nodes-dialog/attributes-dialog.tsx b/src/views-components/compute-nodes-dialog/attributes-dialog.tsx new file mode 100644 index 00000000..3959909c --- /dev/null +++ b/src/views-components/compute-nodes-dialog/attributes-dialog.tsx @@ -0,0 +1,115 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +import * as React from "react"; +import { compose } from 'redux'; +import { + withStyles, Dialog, DialogTitle, DialogContent, DialogActions, + Button, StyleRulesCallback, WithStyles, Grid +} from '@material-ui/core'; +import { WithDialogProps, withDialog } from "~/store/dialog/with-dialog"; +import { COMPUTE_NODE_ATTRIBUTES_DIALOG } from '~/store/compute-nodes/compute-nodes-actions'; +import { ArvadosTheme } from '~/common/custom-theme'; +import { NodeResource, NodeProperties, NodeInfo } from '~/models/node'; +import * as classnames from "classnames"; + +type CssRules = 'root' | 'grid'; + +const styles: StyleRulesCallback = (theme: ArvadosTheme) => ({ + root: { + fontSize: '0.875rem', + '& div:nth-child(odd):not(.nestedRoot)': { + textAlign: 'right', + color: theme.palette.grey["500"] + }, + '& div:nth-child(even)': { + overflowWrap: 'break-word' + } + }, + grid: { + padding: '8px 0 0 0' + } +}); + +interface AttributesComputeNodeDialogDataProps { + computeNode: NodeResource; +} + +export const AttributesComputeNodeDialog = compose( + withDialog(COMPUTE_NODE_ATTRIBUTES_DIALOG), + withStyles(styles))( + ({ open, closeDialog, data, classes }: WithDialogProps & WithStyles) => + + Attributes + + {data.computeNode &&
+ {renderPrimaryInfo(data.computeNode, classes)} + {renderInfo(data.computeNode.info, classes)} + {renderProperties(data.computeNode.properties, classes)} +
} +
+ + + +
+ ); + +const renderPrimaryInfo = (computeNode: NodeResource, classes: any) => { + const { uuid, ownerUuid, createdAt, modifiedAt, modifiedByClientUuid, modifiedByUserUuid } = computeNode; + return ( + + UUID + {uuid} + Owner uuid + {ownerUuid} + Created at + {createdAt} + Modified at + {modifiedAt} + Modified by user uuid + {modifiedByUserUuid} + Modified by client uuid + {modifiedByClientUuid || '(none)'} + + ); +}; + +const renderInfo = (info: NodeInfo, classes: any) => { + const { lastAction, pingSecret, ec2InstanceId, slurmState } = info; + return ( + + Info - Last action + {lastAction || '(none)'} + Info - Ping secret + {pingSecret || '(none)'} + Info - ec2 instance id + {ec2InstanceId || '(none)'} + Info - Slurm state + {slurmState || '(none)'} + + ); +}; + +const renderProperties = (properties: NodeProperties, classes: any) => { + const { totalRamMb, totalCpuCores, totalScratchMb, cloudNode } = properties; + return ( + + Properties - Total ram mb + {totalRamMb || '(none)'} + Properties - Total scratch mb + {totalScratchMb || '(none)'} + Properties - Total cpu cores + {totalCpuCores || '(none)'} + Properties - Cloud node size + {cloudNode ? cloudNode.size : '(none)'} + Properties - Cloud node price + {cloudNode ? cloudNode.price : '(none)'} + + ); +}; \ No newline at end of file diff --git a/src/views-components/compute-nodes-dialog/remove-dialog.tsx b/src/views-components/compute-nodes-dialog/remove-dialog.tsx new file mode 100644 index 00000000..2233974c --- /dev/null +++ b/src/views-components/compute-nodes-dialog/remove-dialog.tsx @@ -0,0 +1,21 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +import { Dispatch, compose } from 'redux'; +import { connect } from "react-redux"; +import { ConfirmationDialog } from "~/components/confirmation-dialog/confirmation-dialog"; +import { withDialog, WithDialogProps } from "~/store/dialog/with-dialog"; +import { COMPUTE_NODE_REMOVE_DIALOG, removeComputeNode } from '~/store/compute-nodes/compute-nodes-actions'; + +const mapDispatchToProps = (dispatch: Dispatch, props: WithDialogProps) => ({ + onConfirm: () => { + props.closeDialog(); + dispatch(removeComputeNode(props.data.uuid)); + } +}); + +export const RemoveComputeNodeDialog = compose( + withDialog(COMPUTE_NODE_REMOVE_DIALOG), + connect(null, mapDispatchToProps) +)(ConfirmationDialog); \ No newline at end of file diff --git a/src/views-components/context-menu/action-sets/compute-node-action-set.ts b/src/views-components/context-menu/action-sets/compute-node-action-set.ts new file mode 100644 index 00000000..cfb90b6e --- /dev/null +++ b/src/views-components/context-menu/action-sets/compute-node-action-set.ts @@ -0,0 +1,28 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +import { openComputeNodeRemoveDialog, openComputeNodeAttributesDialog } from '~/store/compute-nodes/compute-nodes-actions'; +import { openAdvancedTabDialog } from '~/store/advanced-tab/advanced-tab'; +import { ContextMenuActionSet } from "~/views-components/context-menu/context-menu-action-set"; +import { AdvancedIcon, RemoveIcon, AttributesIcon } from "~/components/icon/icon"; + +export const computeNodeActionSet: ContextMenuActionSet = [[{ + name: "Attributes", + icon: AttributesIcon, + execute: (dispatch, { uuid }) => { + dispatch(openComputeNodeAttributesDialog(uuid)); + } +}, { + name: "Advanced", + icon: AdvancedIcon, + execute: (dispatch, { uuid }) => { + dispatch(openAdvancedTabDialog(uuid)); + } +}, { + name: "Remove", + icon: RemoveIcon, + execute: (dispatch, { uuid }) => { + dispatch(openComputeNodeRemoveDialog(uuid)); + } +}]]; diff --git a/src/views-components/context-menu/action-sets/keep-service-action-set.ts b/src/views-components/context-menu/action-sets/keep-service-action-set.ts new file mode 100644 index 00000000..807a3abf --- /dev/null +++ b/src/views-components/context-menu/action-sets/keep-service-action-set.ts @@ -0,0 +1,28 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +import { openKeepServiceAttributesDialog, openKeepServiceRemoveDialog } from '~/store/keep-services/keep-services-actions'; +import { openAdvancedTabDialog } from '~/store/advanced-tab/advanced-tab'; +import { ContextMenuActionSet } from "~/views-components/context-menu/context-menu-action-set"; +import { AdvancedIcon, RemoveIcon, AttributesIcon } from "~/components/icon/icon"; + +export const keepServiceActionSet: ContextMenuActionSet = [[{ + name: "Attributes", + icon: AttributesIcon, + execute: (dispatch, { uuid }) => { + dispatch(openKeepServiceAttributesDialog(uuid)); + } +}, { + name: "Advanced", + icon: AdvancedIcon, + execute: (dispatch, { uuid }) => { + dispatch(openAdvancedTabDialog(uuid)); + } +}, { + name: "Remove", + icon: RemoveIcon, + execute: (dispatch, { uuid }) => { + dispatch(openKeepServiceRemoveDialog(uuid)); + } +}]]; diff --git a/src/views-components/context-menu/action-sets/repository-action-set.ts b/src/views-components/context-menu/action-sets/repository-action-set.ts index 22f6bee1..82c2f2b8 100644 --- a/src/views-components/context-menu/action-sets/repository-action-set.ts +++ b/src/views-components/context-menu/action-sets/repository-action-set.ts @@ -11,8 +11,8 @@ import { openSharingDialog } from "~/store/sharing-dialog/sharing-dialog-actions export const repositoryActionSet: ContextMenuActionSet = [[{ name: "Attributes", icon: AttributesIcon, - execute: (dispatch, { index }) => { - dispatch(openRepositoryAttributes(index!)); + execute: (dispatch, { uuid }) => { + dispatch(openRepositoryAttributes(uuid)); } }, { name: "Share", @@ -24,7 +24,7 @@ export const repositoryActionSet: ContextMenuActionSet = [[{ name: "Advanced", icon: AdvancedIcon, execute: (dispatch, resource) => { - dispatch(openAdvancedTabDialog(resource.uuid, resource.index)); + dispatch(openAdvancedTabDialog(resource.uuid)); } }, { name: "Remove", diff --git a/src/views-components/context-menu/action-sets/ssh-key-action-set.ts b/src/views-components/context-menu/action-sets/ssh-key-action-set.ts index 6e86b2bc..0ce0c431 100644 --- a/src/views-components/context-menu/action-sets/ssh-key-action-set.ts +++ b/src/views-components/context-menu/action-sets/ssh-key-action-set.ts @@ -10,14 +10,14 @@ import { openAdvancedTabDialog } from '~/store/advanced-tab/advanced-tab'; export const sshKeyActionSet: ContextMenuActionSet = [[{ name: "Attributes", icon: AttributesIcon, - execute: (dispatch, { index }) => { - dispatch(openSshKeyAttributesDialog(index!)); + execute: (dispatch, { uuid }) => { + dispatch(openSshKeyAttributesDialog(uuid)); } }, { name: "Advanced", icon: AdvancedIcon, - execute: (dispatch, { uuid, index }) => { - dispatch(openAdvancedTabDialog(uuid, index)); + execute: (dispatch, { uuid }) => { + dispatch(openAdvancedTabDialog(uuid)); } }, { name: "Remove", diff --git a/src/views-components/context-menu/action-sets/virtual-machine-action-set.ts b/src/views-components/context-menu/action-sets/virtual-machine-action-set.ts new file mode 100644 index 00000000..ea274af1 --- /dev/null +++ b/src/views-components/context-menu/action-sets/virtual-machine-action-set.ts @@ -0,0 +1,28 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +import { ContextMenuActionSet } from "~/views-components/context-menu/context-menu-action-set"; +import { AdvancedIcon, RemoveIcon, AttributesIcon } from "~/components/icon/icon"; +import { openAdvancedTabDialog } from '~/store/advanced-tab/advanced-tab'; +import { openVirtualMachineAttributes, openRemoveVirtualMachineDialog } from "~/store/virtual-machines/virtual-machines-actions"; + +export const virtualMachineActionSet: ContextMenuActionSet = [[{ + name: "Attributes", + icon: AttributesIcon, + execute: (dispatch, { uuid }) => { + dispatch(openVirtualMachineAttributes(uuid)); + } +}, { + name: "Advanced", + icon: AdvancedIcon, + execute: (dispatch, { uuid }) => { + dispatch(openAdvancedTabDialog(uuid)); + } +}, { + name: "Remove", + icon: RemoveIcon, + execute: (dispatch, { uuid }) => { + dispatch(openRemoveVirtualMachineDialog(uuid)); + } +}]]; diff --git a/src/views-components/context-menu/context-menu.tsx b/src/views-components/context-menu/context-menu.tsx index af5aaa92..3fa1ab30 100644 --- a/src/views-components/context-menu/context-menu.tsx +++ b/src/views-components/context-menu/context-menu.tsx @@ -70,5 +70,8 @@ export enum ContextMenuKind { PROCESS_RESOURCE = 'ProcessResource', PROCESS_LOGS = "ProcessLogs", REPOSITORY = "Repository", - SSH_KEY = "SshKey" + SSH_KEY = "SshKey", + VIRTUAL_MACHINE = "VirtualMachine", + KEEP_SERVICE = "KeepService", + NODE = "Node" } diff --git a/src/views-components/keep-services-dialog/attributes-dialog.tsx b/src/views-components/keep-services-dialog/attributes-dialog.tsx new file mode 100644 index 00000000..113d191b --- /dev/null +++ b/src/views-components/keep-services-dialog/attributes-dialog.tsx @@ -0,0 +1,73 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +import * as React from "react"; +import { compose } from 'redux'; +import { + withStyles, Dialog, DialogTitle, DialogContent, DialogActions, + Button, StyleRulesCallback, WithStyles, Grid +} from '@material-ui/core'; +import { WithDialogProps, withDialog } from "~/store/dialog/with-dialog"; +import { KEEP_SERVICE_ATTRIBUTES_DIALOG } from '~/store/keep-services/keep-services-actions'; +import { ArvadosTheme } from '~/common/custom-theme'; +import { KeepServiceResource } from '~/models/keep-services'; + +type CssRules = 'root'; + +const styles: StyleRulesCallback = (theme: ArvadosTheme) => ({ + root: { + fontSize: '0.875rem', + '& div:nth-child(odd)': { + textAlign: 'right', + color: theme.palette.grey["500"] + } + } +}); + +interface AttributesKeepServiceDialogDataProps { + keepService: KeepServiceResource; +} + +export const AttributesKeepServiceDialog = compose( + withDialog(KEEP_SERVICE_ATTRIBUTES_DIALOG), + withStyles(styles))( + ({ open, closeDialog, data, classes }: WithDialogProps & WithStyles) => + + Attributes + + {data.keepService && + UUID + {data.keepService.uuid} + Read only + {JSON.stringify(data.keepService.readOnly)} + Service host + {data.keepService.serviceHost} + Service port + {data.keepService.servicePort} + Service SSL flag + {JSON.stringify(data.keepService.serviceSslFlag)} + Service type + {data.keepService.serviceType} + Owner uuid + {data.keepService.ownerUuid} + Created at + {data.keepService.createdAt} + Modified at + {data.keepService.modifiedAt} + Modified by user uuid + {data.keepService.modifiedByUserUuid} + Modified by client uuid + {data.keepService.modifiedByClientUuid} + } + + + + + + ); \ No newline at end of file diff --git a/src/views-components/keep-services-dialog/remove-dialog.tsx b/src/views-components/keep-services-dialog/remove-dialog.tsx new file mode 100644 index 00000000..7e398509 --- /dev/null +++ b/src/views-components/keep-services-dialog/remove-dialog.tsx @@ -0,0 +1,20 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 +import { Dispatch, compose } from 'redux'; +import { connect } from "react-redux"; +import { ConfirmationDialog } from "~/components/confirmation-dialog/confirmation-dialog"; +import { withDialog, WithDialogProps } from "~/store/dialog/with-dialog"; +import { KEEP_SERVICE_REMOVE_DIALOG, removeKeepService } from '~/store/keep-services/keep-services-actions'; + +const mapDispatchToProps = (dispatch: Dispatch, props: WithDialogProps) => ({ + onConfirm: () => { + props.closeDialog(); + dispatch(removeKeepService(props.data.uuid)); + } +}); + +export const RemoveKeepServiceDialog = compose( + withDialog(KEEP_SERVICE_REMOVE_DIALOG), + connect(null, mapDispatchToProps) +)(ConfirmationDialog); \ No newline at end of file diff --git a/src/views-components/main-app-bar/account-menu.tsx b/src/views-components/main-app-bar/account-menu.tsx index 0aedee90..ee726f3d 100644 --- a/src/views-components/main-app-bar/account-menu.tsx +++ b/src/views-components/main-app-bar/account-menu.tsx @@ -12,7 +12,7 @@ import { logout } from '~/store/auth/auth-action'; import { RootState } from "~/store/store"; import { openCurrentTokenDialog } from '~/store/current-token-dialog/current-token-dialog-actions'; import { openRepositoriesPanel } from "~/store/repositories/repositories-actions"; -import { navigateToSshKeys, navigateToMyAccount } from '~/store/navigation/navigation-action'; +import { navigateToSshKeys, navigateToKeepServices, navigateToComputeNodes, navigateToMyAccount } from '~/store/navigation/navigation-action'; import { openVirtualMachines } from "~/store/virtual-machines/virtual-machines-actions"; interface AccountMenuProps { @@ -37,6 +37,8 @@ export const AccountMenu = connect(mapStateToProps)( dispatch(openRepositoriesPanel())}>Repositories dispatch(openCurrentTokenDialog)}>Current token dispatch(navigateToSshKeys)}>Ssh Keys + { user.isAdmin && dispatch(navigateToKeepServices)}>Keep Services } + { user.isAdmin && dispatch(navigateToComputeNodes)}>Compute Nodes } dispatch(navigateToMyAccount)}>My account dispatch(logout())}>Logout diff --git a/src/views-components/main-content-bar/main-content-bar.tsx b/src/views-components/main-content-bar/main-content-bar.tsx index 6b84bde2..78b79a8e 100644 --- a/src/views-components/main-content-bar/main-content-bar.tsx +++ b/src/views-components/main-content-bar/main-content-bar.tsx @@ -8,7 +8,7 @@ import { DetailsIcon } from "~/components/icon/icon"; import { Breadcrumbs } from "~/views-components/breadcrumbs/breadcrumbs"; import { connect } from 'react-redux'; import { RootState } from '~/store/store'; -import { matchWorkflowRoute, matchSshKeysRoute, matchRepositoriesRoute, matchVirtualMachineRoute } from '~/routes/routes'; +import * as Routes from '~/routes/routes'; import { toggleDetailsPanel } from '~/store/details-panel/details-panel-action'; interface MainContentBarProps { @@ -16,32 +16,15 @@ interface MainContentBarProps { buttonVisible: boolean; } -const isWorkflowPath = ({ router }: RootState) => { +const isButtonVisible = ({ router }: RootState) => { const pathname = router.location ? router.location.pathname : ''; - const match = matchWorkflowRoute(pathname); - return !!match; -}; - -const isVirtualMachinePath = ({ router }: RootState) => { - const pathname = router.location ? router.location.pathname : ''; - const match = matchVirtualMachineRoute(pathname); - return !!match; -}; - -const isRepositoriesPath = ({ router }: RootState) => { - const pathname = router.location ? router.location.pathname : ''; - const match = matchRepositoriesRoute(pathname); - return !!match; -}; - -const isSshKeysPath = ({ router }: RootState) => { - const pathname = router.location ? router.location.pathname : ''; - const match = matchSshKeysRoute(pathname); - return !!match; + return !Routes.matchWorkflowRoute(pathname) && !Routes.matchVirtualMachineRoute(pathname) && + !Routes.matchRepositoriesRoute(pathname) && !Routes.matchSshKeysRoute(pathname) && + !Routes.matchKeepServicesRoute(pathname) && !Routes.matchComputeNodesRoute(pathname); }; export const MainContentBar = connect((state: RootState) => ({ - buttonVisible: !isWorkflowPath(state) && !isSshKeysPath(state) && !isRepositoriesPath(state) && !isVirtualMachinePath(state) + buttonVisible: isButtonVisible(state) }), { onDetailsPanelToggle: toggleDetailsPanel })((props: MainContentBarProps) => diff --git a/src/views-components/repository-remove-dialog/repository-remove-dialog.ts b/src/views-components/repository-remove-dialog/repository-remove-dialog.ts index 148e78bd..ca51c849 100644 --- a/src/views-components/repository-remove-dialog/repository-remove-dialog.ts +++ b/src/views-components/repository-remove-dialog/repository-remove-dialog.ts @@ -1,20 +1,21 @@ // Copyright (C) The Arvados Authors. All rights reserved. // // SPDX-License-Identifier: AGPL-3.0 + import { Dispatch, compose } from 'redux'; import { connect } from "react-redux"; import { ConfirmationDialog } from "~/components/confirmation-dialog/confirmation-dialog"; import { withDialog, WithDialogProps } from "~/store/dialog/with-dialog"; import { removeRepository, REPOSITORY_REMOVE_DIALOG } from '~/store/repositories/repositories-actions'; - const mapDispatchToProps = (dispatch: Dispatch, props: WithDialogProps) => ({ +const mapDispatchToProps = (dispatch: Dispatch, props: WithDialogProps) => ({ onConfirm: () => { props.closeDialog(); dispatch(removeRepository(props.data.uuid)); } }); - export const RemoveRepositoryDialog = compose( +export const RemoveRepositoryDialog = compose( withDialog(REPOSITORY_REMOVE_DIALOG), connect(null, mapDispatchToProps) )(ConfirmationDialog); \ No newline at end of file diff --git a/src/views-components/virtual-machines-dialog/attributes-dialog.tsx b/src/views-components/virtual-machines-dialog/attributes-dialog.tsx new file mode 100644 index 00000000..05c1b0a0 --- /dev/null +++ b/src/views-components/virtual-machines-dialog/attributes-dialog.tsx @@ -0,0 +1,89 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +import * as React from "react"; +import { Dialog, DialogTitle, DialogContent, DialogActions, Button, Typography, Grid } from "@material-ui/core"; +import { WithDialogProps } from "~/store/dialog/with-dialog"; +import { withDialog } from '~/store/dialog/with-dialog'; +import { VIRTUAL_MACHINE_ATTRIBUTES_DIALOG } from "~/store/virtual-machines/virtual-machines-actions"; +import { WithStyles, withStyles } from '@material-ui/core/styles'; +import { ArvadosTheme } from '~/common/custom-theme'; +import { compose } from "redux"; +import { VirtualMachinesResource } from "~/models/virtual-machines"; + +type CssRules = 'rightContainer' | 'leftContainer' | 'spacing'; + +const styles = withStyles((theme: ArvadosTheme) => ({ + rightContainer: { + textAlign: 'right', + paddingRight: theme.spacing.unit * 2, + color: theme.palette.grey["500"] + }, + leftContainer: { + textAlign: 'left', + paddingLeft: theme.spacing.unit * 2 + }, + spacing: { + paddingTop: theme.spacing.unit * 2 + }, +})); + +interface VirtualMachineAttributesDataProps { + virtualMachineData: VirtualMachinesResource; +} + +type VirtualMachineAttributesProps = VirtualMachineAttributesDataProps & WithStyles; + +export const VirtualMachineAttributesDialog = compose( + withDialog(VIRTUAL_MACHINE_ATTRIBUTES_DIALOG), + styles)( + (props: WithDialogProps & VirtualMachineAttributesProps) => + + Attributes + + + {props.data.virtualMachineData && attributes(props.data.virtualMachineData, props.classes)} + + + + + + + ); + +const attributes = (virtualMachine: VirtualMachinesResource, classes: any) => { + const { uuid, ownerUuid, createdAt, modifiedAt, modifiedByClientUuid, modifiedByUserUuid, hostname } = virtualMachine; + return ( + + + + Hostname + Owner uuid + Created at + Modified at + Modified by user uuid + Modified by client uuid + uuid + + + {hostname} + {ownerUuid} + {createdAt} + {modifiedAt} + {modifiedByUserUuid} + {modifiedByClientUuid} + {uuid} + + + + ); +}; diff --git a/src/views-components/virtual-machines-dialog/remove-dialog.tsx b/src/views-components/virtual-machines-dialog/remove-dialog.tsx new file mode 100644 index 00000000..11ab9c43 --- /dev/null +++ b/src/views-components/virtual-machines-dialog/remove-dialog.tsx @@ -0,0 +1,21 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +import { Dispatch, compose } from 'redux'; +import { connect } from "react-redux"; +import { ConfirmationDialog } from "~/components/confirmation-dialog/confirmation-dialog"; +import { withDialog, WithDialogProps } from "~/store/dialog/with-dialog"; +import { VIRTUAL_MACHINE_REMOVE_DIALOG, removeVirtualMachine } from '~/store/virtual-machines/virtual-machines-actions'; + +const mapDispatchToProps = (dispatch: Dispatch, props: WithDialogProps) => ({ + onConfirm: () => { + props.closeDialog(); + dispatch(removeVirtualMachine(props.data.uuid)); + } +}); + +export const RemoveVirtualMachineDialog = compose( + withDialog(VIRTUAL_MACHINE_REMOVE_DIALOG), + connect(null, mapDispatchToProps) +)(ConfirmationDialog); \ No newline at end of file diff --git a/src/views/compute-node-panel/compute-node-panel-root.tsx b/src/views/compute-node-panel/compute-node-panel-root.tsx new file mode 100644 index 00000000..be3627b8 --- /dev/null +++ b/src/views/compute-node-panel/compute-node-panel-root.tsx @@ -0,0 +1,85 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +import * as React from 'react'; +import { + StyleRulesCallback, WithStyles, withStyles, Card, CardContent, Grid, Table, + TableHead, TableRow, TableCell, TableBody, Tooltip, IconButton +} from '@material-ui/core'; +import { ArvadosTheme } from '~/common/custom-theme'; +import { MoreOptionsIcon } from '~/components/icon/icon'; +import { NodeResource } from '~/models/node'; +import { formatDate } from '~/common/formatters'; + +type CssRules = 'root' | 'tableRow'; + +const styles: StyleRulesCallback = (theme: ArvadosTheme) => ({ + root: { + width: '100%', + overflow: 'auto' + }, + tableRow: { + '& td, th': { + whiteSpace: 'nowrap' + } + } +}); + +export interface ComputeNodePanelRootActionProps { + openRowOptions: (event: React.MouseEvent, computeNode: NodeResource) => void; +} + +export interface ComputeNodePanelRootDataProps { + computeNodes: NodeResource[]; + hasComputeNodes: boolean; +} + +type ComputeNodePanelRootProps = ComputeNodePanelRootActionProps & ComputeNodePanelRootDataProps & WithStyles; + +export const ComputeNodePanelRoot = withStyles(styles)( + ({ classes, hasComputeNodes, computeNodes, openRowOptions }: ComputeNodePanelRootProps) => + + + {hasComputeNodes && + + + + + Info + UUID + Domain + First ping at + Hostname + IP Address + Job + Last ping at + + + + + {computeNodes.map((computeNode, index) => + + {computeNode.uuid} + {computeNode.uuid} + {computeNode.domain} + {formatDate(computeNode.firstPingAt) || '(none)'} + {computeNode.hostname || '(none)'} + {computeNode.ipAddress || '(none)'} + {computeNode.jobUuid || '(none)'} + {formatDate(computeNode.lastPingAt) || '(none)'} + + + openRowOptions(event, computeNode)}> + + + + + )} + +
+
+
} +
+
+); \ No newline at end of file diff --git a/src/views/compute-node-panel/compute-node-panel.tsx b/src/views/compute-node-panel/compute-node-panel.tsx new file mode 100644 index 00000000..a4f22c80 --- /dev/null +++ b/src/views/compute-node-panel/compute-node-panel.tsx @@ -0,0 +1,29 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +import { RootState } from '~/store/store'; +import { Dispatch } from 'redux'; +import { connect } from 'react-redux'; +import { } from '~/store/compute-nodes/compute-nodes-actions'; +import { + ComputeNodePanelRoot, + ComputeNodePanelRootDataProps, + ComputeNodePanelRootActionProps +} from '~/views/compute-node-panel/compute-node-panel-root'; +import { openComputeNodeContextMenu } from '~/store/context-menu/context-menu-actions'; + +const mapStateToProps = (state: RootState): ComputeNodePanelRootDataProps => { + return { + computeNodes: state.computeNodes, + hasComputeNodes: state.computeNodes.length > 0 + }; +}; + +const mapDispatchToProps = (dispatch: Dispatch): ComputeNodePanelRootActionProps => ({ + openRowOptions: (event, computeNode) => { + dispatch(openComputeNodeContextMenu(event, computeNode)); + } +}); + +export const ComputeNodePanel = connect(mapStateToProps, mapDispatchToProps)(ComputeNodePanelRoot); \ No newline at end of file diff --git a/src/views/keep-service-panel/keep-service-panel-root.tsx b/src/views/keep-service-panel/keep-service-panel-root.tsx new file mode 100644 index 00000000..8c266b61 --- /dev/null +++ b/src/views/keep-service-panel/keep-service-panel-root.tsx @@ -0,0 +1,87 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +import * as React from 'react'; +import { StyleRulesCallback, WithStyles, withStyles, Card, CardContent, Button, Typography, Grid, Table, TableHead, TableRow, TableCell, TableBody, Tooltip, IconButton, Checkbox } from '@material-ui/core'; +import { ArvadosTheme } from '~/common/custom-theme'; +import { MoreOptionsIcon } from '~/components/icon/icon'; +import { KeepServiceResource } from '~/models/keep-services'; + +type CssRules = 'root' | 'tableRow'; + +const styles: StyleRulesCallback = (theme: ArvadosTheme) => ({ + root: { + width: '100%', + overflow: 'auto' + }, + tableRow: { + '& td, th': { + whiteSpace: 'nowrap' + } + } +}); + +export interface KeepServicePanelRootActionProps { + openRowOptions: (event: React.MouseEvent, keepService: KeepServiceResource) => void; +} + +export interface KeepServicePanelRootDataProps { + keepServices: KeepServiceResource[]; + hasKeepSerices: boolean; +} + +type KeepServicePanelRootProps = KeepServicePanelRootActionProps & KeepServicePanelRootDataProps & WithStyles; + +export const KeepServicePanelRoot = withStyles(styles)( + ({ classes, hasKeepSerices, keepServices, openRowOptions }: KeepServicePanelRootProps) => + + + {hasKeepSerices && + + + + + UUID + Read only + Service host + Service port + Service SSL flag + Service type + + + + + {keepServices.map((keepService, index) => + + {keepService.uuid} + + + + {keepService.serviceHost} + {keepService.servicePort} + + + + {keepService.serviceType} + + + openRowOptions(event, keepService)}> + + + + + )} + +
+
+
} +
+
+); \ No newline at end of file diff --git a/src/views/keep-service-panel/keep-service-panel.tsx b/src/views/keep-service-panel/keep-service-panel.tsx new file mode 100644 index 00000000..369b7c21 --- /dev/null +++ b/src/views/keep-service-panel/keep-service-panel.tsx @@ -0,0 +1,28 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +import { RootState } from '~/store/store'; +import { Dispatch } from 'redux'; +import { connect } from 'react-redux'; +import { + KeepServicePanelRoot, + KeepServicePanelRootDataProps, + KeepServicePanelRootActionProps +} from '~/views/keep-service-panel/keep-service-panel-root'; +import { openKeepServiceContextMenu } from '~/store/context-menu/context-menu-actions'; + +const mapStateToProps = (state: RootState): KeepServicePanelRootDataProps => { + return { + keepServices: state.keepServices, + hasKeepSerices: state.keepServices.length > 0 + }; +}; + +const mapDispatchToProps = (dispatch: Dispatch): KeepServicePanelRootActionProps => ({ + openRowOptions: (event, keepService) => { + dispatch(openKeepServiceContextMenu(event, keepService)); + } +}); + +export const KeepServicePanel = connect(mapStateToProps, mapDispatchToProps)(KeepServicePanelRoot); \ No newline at end of file diff --git a/src/views/repositories-panel/repositories-panel.tsx b/src/views/repositories-panel/repositories-panel.tsx index cfe59f0d..c7016f62 100644 --- a/src/views/repositories-panel/repositories-panel.tsx +++ b/src/views/repositories-panel/repositories-panel.tsx @@ -66,8 +66,8 @@ const mapStateToProps = (state: RootState) => { const mapDispatchToProps = (dispatch: Dispatch): Pick => ({ loadRepositories: () => dispatch(loadRepositoriesData()), - onOptionsMenuOpen: (event, index, repository) => { - dispatch(openRepositoryContextMenu(event, index, repository)); + onOptionsMenuOpen: (event, repository) => { + dispatch(openRepositoryContextMenu(event, repository)); }, openRepositoriesSampleGitDialog: () => dispatch(openRepositoriesSampleGitDialog()), openRepositoryCreateDialog: () => dispatch(openRepositoryCreateDialog()) @@ -75,7 +75,7 @@ const mapDispatchToProps = (dispatch: Dispatch): Pick void; - onOptionsMenuOpen: (event: React.MouseEvent, index: number, repository: RepositoryResource) => void; + onOptionsMenuOpen: (event: React.MouseEvent, repository: RepositoryResource) => void; openRepositoriesSampleGitDialog: () => void; openRepositoryCreateDialog: () => void; } @@ -137,7 +137,7 @@ export const RepositoriesPanel = compose( {repository.cloneUrls.join("\n")} - onOptionsMenuOpen(event, index, repository)} className={classes.moreOptionsButton}> + onOptionsMenuOpen(event, repository)} className={classes.moreOptionsButton}> diff --git a/src/views/run-process-panel/inputs/boolean-input.tsx b/src/views/run-process-panel/inputs/boolean-input.tsx index 5da54742..6a214e9d 100644 --- a/src/views/run-process-panel/inputs/boolean-input.tsx +++ b/src/views/run-process-panel/inputs/boolean-input.tsx @@ -3,6 +3,7 @@ // SPDX-License-Identifier: AGPL-3.0 import * as React from 'react'; +import { memoize } from 'lodash/fp'; import { BooleanCommandInputParameter } from '~/models/workflow'; import { Field } from 'redux-form'; import { Switch } from '@material-ui/core'; @@ -16,17 +17,23 @@ export const BooleanInput = ({ input }: BooleanInputProps) => name={input.id} commandInput={input} component={BooleanInputComponent} - normalize={(value, prevValue) => !prevValue} + normalize={normalize} />; +const normalize = (_: any, prevValue: boolean) => !prevValue; + const BooleanInputComponent = (props: GenericInputProps) => ; -const Input = (props: GenericInputProps) => +const Input = ({ input, commandInput }: GenericInputProps) => props.input.onChange(props.input.value)} - disabled={props.commandInput.disabled} />; \ No newline at end of file + checked={input.value} + onChange={handleChange(input.onChange, input.value)} + disabled={commandInput.disabled} />; + +const handleChange = memoize( + (onChange: (value: string) => void, value: string) => () => onChange(value) +); diff --git a/src/views/run-process-panel/inputs/directory-input.tsx b/src/views/run-process-panel/inputs/directory-input.tsx index aa25fefc..29ccd6e0 100644 --- a/src/views/run-process-panel/inputs/directory-input.tsx +++ b/src/views/run-process-panel/inputs/directory-input.tsx @@ -3,23 +3,24 @@ // SPDX-License-Identifier: AGPL-3.0 import * as React from 'react'; +import { connect, DispatchProp } from 'react-redux'; +import { memoize } from 'lodash/fp'; +import { Field } from 'redux-form'; +import { Input, Dialog, DialogTitle, DialogContent, DialogActions, Button } from '@material-ui/core'; import { isRequiredInput, DirectoryCommandInputParameter, CWLType, Directory } from '~/models/workflow'; -import { Field } from 'redux-form'; -import { Input, Dialog, DialogTitle, DialogContent, DialogActions, Button } from '@material-ui/core'; import { GenericInputProps, GenericInput } from './generic-input'; import { ProjectsTreePicker } from '~/views-components/projects-tree-picker/projects-tree-picker'; -import { connect, DispatchProp } from 'react-redux'; import { initProjectsTreePicker } from '~/store/tree-picker/tree-picker-actions'; import { TreeItem } from '~/components/tree/tree'; import { ProjectsTreePickerItem } from '~/views-components/projects-tree-picker/generic-projects-tree-picker'; import { CollectionResource } from '~/models/collection'; import { ResourceKind } from '~/models/resource'; -import { ERROR_MESSAGE } from '../../../validators/require'; +import { ERROR_MESSAGE } from '~/validators/require'; export interface DirectoryInputProps { input: DirectoryCommandInputParameter; @@ -29,18 +30,25 @@ export const DirectoryInput = ({ input }: DirectoryInputProps) => name={input.id} commandInput={input} component={DirectoryInputComponent} - format={(value?: Directory) => value ? value.basename : ''} - parse={(directory: CollectionResource): Directory => ({ - class: CWLType.DIRECTORY, - location: `keep:${directory.portableDataHash}`, - basename: directory.name, - })} - validate={[ - isRequiredInput(input) - ? (directory?: Directory) => directory ? undefined : ERROR_MESSAGE - : () => undefined, - ]} />; - + format={format} + parse={parse} + validate={getValidation(input)} />; + +const format = (value?: Directory) => value ? value.basename : ''; + +const parse = (directory: CollectionResource): Directory => ({ + class: CWLType.DIRECTORY, + location: `keep:${directory.portableDataHash}`, + basename: directory.name, +}); + +const getValidation = memoize( + (input: DirectoryCommandInputParameter) => ([ + isRequiredInput(input) + ? (directory?: Directory) => directory ? undefined : ERROR_MESSAGE + : () => undefined, + ]) +); interface DirectoryInputComponentState { open: boolean; @@ -78,7 +86,7 @@ const DirectoryInputComponent = connect()( this.props.input.onChange(this.state.directory); } - setDirectory = (event: React.MouseEvent, { data }: TreeItem, pickerId: string) => { + setDirectory = (_: {}, { data }: TreeItem) => { if ('kind' in data && data.kind === ResourceKind.COLLECTION) { this.setState({ directory: data }); } else { diff --git a/src/views/run-process-panel/inputs/enum-input.tsx b/src/views/run-process-panel/inputs/enum-input.tsx index 86ff6fb1..3b0289e7 100644 --- a/src/views/run-process-panel/inputs/enum-input.tsx +++ b/src/views/run-process-panel/inputs/enum-input.tsx @@ -3,9 +3,9 @@ // SPDX-License-Identifier: AGPL-3.0 import * as React from 'react'; -import { EnumCommandInputParameter, CommandInputEnumSchema } from '~/models/workflow'; import { Field } from 'redux-form'; import { Select, MenuItem } from '@material-ui/core'; +import { EnumCommandInputParameter, CommandInputEnumSchema } from '~/models/workflow'; import { GenericInputProps, GenericInput } from './generic-input'; export interface EnumInputProps { @@ -30,8 +30,20 @@ const Input = (props: GenericInputProps) => { onChange={props.input.onChange} disabled={props.commandInput.disabled} > {type.symbols.map(symbol => - - {symbol.split('/').pop()} + + {extractValue(symbol)} )} ; -}; \ No newline at end of file +}; + +/** + * Values in workflow definition have an absolute form, for example: + * + * ```#input_collector.cwl/enum_type/Pathway table``` + * + * We want a value that is in form accepted by backend. + * According to the example above, the correct value is: + * + * ```Pathway table``` + */ +const extractValue = (symbol: string) => symbol.split('/').pop(); diff --git a/src/views/run-process-panel/inputs/file-input.tsx b/src/views/run-process-panel/inputs/file-input.tsx index 7e0925e8..06111007 100644 --- a/src/views/run-process-panel/inputs/file-input.tsx +++ b/src/views/run-process-panel/inputs/file-input.tsx @@ -3,6 +3,7 @@ // SPDX-License-Identifier: AGPL-3.0 import * as React from 'react'; +import { memoize } from 'lodash/fp'; import { isRequiredInput, FileCommandInputParameter, @@ -28,18 +29,24 @@ export const FileInput = ({ input }: FileInputProps) => name={input.id} commandInput={input} component={FileInputComponent} - format={(value?: File) => value ? value.basename : ''} - parse={(file: CollectionFile): File => ({ - class: CWLType.FILE, - location: `keep:${file.id}`, - basename: file.name, - })} - validate={[ - isRequiredInput(input) - ? (file?: File) => file ? undefined : ERROR_MESSAGE - : () => undefined, - ]} />; + format={format} + parse={parse} + validate={getValidation(input)} />; +const format = (value?: File) => value ? value.basename : ''; + +const parse = (file: CollectionFile): File => ({ + class: CWLType.FILE, + location: `keep:${file.id}`, + basename: file.name, +}); + +const getValidation = memoize( + (input: FileCommandInputParameter) => ([ + isRequiredInput(input) + ? (file?: File) => file ? undefined : ERROR_MESSAGE + : () => undefined, + ])); interface FileInputComponentState { open: boolean; @@ -77,7 +84,7 @@ const FileInputComponent = connect()( this.props.input.onChange(this.state.file); } - setFile = (event: React.MouseEvent, { data }: TreeItem, pickerId: string) => { + setFile = (_: {}, { data }: TreeItem) => { if ('type' in data && data.type === CollectionFileType.FILE) { this.setState({ file: data }); } else { diff --git a/src/views/run-process-panel/inputs/float-input.tsx b/src/views/run-process-panel/inputs/float-input.tsx index 56a58012..a5905dc5 100644 --- a/src/views/run-process-panel/inputs/float-input.tsx +++ b/src/views/run-process-panel/inputs/float-input.tsx @@ -3,6 +3,7 @@ // SPDX-License-Identifier: AGPL-3.0 import * as React from 'react'; +import { memoize } from 'lodash/fp'; import { FloatCommandInputParameter, isRequiredInput } from '~/models/workflow'; import { Field } from 'redux-form'; import { isNumber } from '~/validators/is-number'; @@ -17,11 +18,17 @@ export const FloatInput = ({ input }: FloatInputProps) => commandInput={input} component={Input} parse={parseFloat} - format={value => isNaN(value) ? '' : JSON.stringify(value)} - validate={[ - isRequiredInput(input) - ? isNumber - : () => undefined,]} />; + format={format} + validate={getValidation(input)} />; + +const format = (value: any) => isNaN(value) ? '' : JSON.stringify(value); + +const getValidation = memoize( + (input: FloatCommandInputParameter) => ([ + isRequiredInput(input) + ? isNumber + : () => undefined,]) +); const Input = (props: GenericInputProps) => name={input.id} commandInput={input} component={InputComponent} - parse={value => parseInt(value, 10)} - format={value => isNaN(value) ? '' : JSON.stringify(value)} - validate={[ - isRequiredInput(input) - ? isInteger - : () => undefined, - ]} />; + parse={parse} + format={format} + validate={getValidation(input)} />; + +const parse = (value: any) => parseInt(value, 10); + +const format = (value: any) => isNaN(value) ? '' : JSON.stringify(value); + +const getValidation = memoize( + (input: IntCommandInputParameter) => ([ + isRequiredInput(input) + ? isInteger + : () => undefined, + ])); const InputComponent = (props: GenericInputProps) => name={input.id} commandInput={input} component={StringInputComponent} - validate={[ - isRequiredInput(input) - ? require - : () => undefined, - ]} />; + validate={getValidation(input)} />; + +const getValidation = memoize( + (input: StringCommandInputParameter) => ([ + isRequiredInput(input) + ? require + : () => undefined, + ])); const StringInputComponent = (props: GenericInputProps) => = (theme: ArvadosTheme) => ({ export interface SshKeyPanelRootActionProps { openSshKeyCreateDialog: () => void; - openRowOptions: (event: React.MouseEvent, index: number, sshKey: SshKeyResource) => void; + openRowOptions: (event: React.MouseEvent, sshKey: SshKeyResource) => void; openPublicKeyDialog: (name: string, publicKey: string) => void; } @@ -102,7 +102,7 @@ export const SshKeyPanelRoot = withStyles(styles)( - openRowOptions(event, index, sshKey)}> + openRowOptions(event, sshKey)}> diff --git a/src/views/ssh-key-panel/ssh-key-panel.tsx b/src/views/ssh-key-panel/ssh-key-panel.tsx index c7e3516e..4e800296 100644 --- a/src/views/ssh-key-panel/ssh-key-panel.tsx +++ b/src/views/ssh-key-panel/ssh-key-panel.tsx @@ -20,8 +20,8 @@ const mapDispatchToProps = (dispatch: Dispatch): SshKeyPanelRootActionProps => ( openSshKeyCreateDialog: () => { dispatch(openSshKeyCreateDialog()); }, - openRowOptions: (event, index, sshKey) => { - dispatch(openSshKeyContextMenu(event, index, sshKey)); + openRowOptions: (event, sshKey) => { + dispatch(openSshKeyContextMenu(event, sshKey)); }, openPublicKeyDialog: (name: string, publicKey: string) => { dispatch(openPublicKeyDialog(name, publicKey)); diff --git a/src/views/virtual-machine-panel/virtual-machine-panel.tsx b/src/views/virtual-machine-panel/virtual-machine-panel.tsx index c94c3a74..5dbd3f09 100644 --- a/src/views/virtual-machine-panel/virtual-machine-panel.tsx +++ b/src/views/virtual-machine-panel/virtual-machine-panel.tsx @@ -4,20 +4,21 @@ import * as React from 'react'; import { connect } from 'react-redux'; -import { Grid, Typography, Button, Card, CardContent, TableBody, TableCell, TableHead, TableRow, Table, Tooltip } from '@material-ui/core'; +import { Grid, Typography, Button, Card, CardContent, TableBody, TableCell, TableHead, TableRow, Table, Tooltip, IconButton } from '@material-ui/core'; import { StyleRulesCallback, WithStyles, withStyles } from '@material-ui/core/styles'; import { ArvadosTheme } from '~/common/custom-theme'; import { DefaultCodeSnippet } from '~/components/default-code-snippet/default-code-snippet'; import { Link } from 'react-router-dom'; -import { Dispatch, compose } from 'redux'; +import { compose, Dispatch } from 'redux'; import { saveRequestedDate, loadVirtualMachinesData } from '~/store/virtual-machines/virtual-machines-actions'; import { RootState } from '~/store/store'; import { ListResults } from '~/services/common-service/common-resource-service'; -import { HelpIcon } from '~/components/icon/icon'; -import { VirtualMachinesLoginsResource, VirtualMachinesResource } from '~/models/virtual-machines'; +import { HelpIcon, MoreOptionsIcon } from '~/components/icon/icon'; +import { VirtualMachineLogins, VirtualMachinesResource } from '~/models/virtual-machines'; import { Routes } from '~/routes/routes'; +import { openVirtualMachinesContextMenu } from '~/store/context-menu/context-menu-actions'; -type CssRules = 'button' | 'codeSnippet' | 'link' | 'linkIcon' | 'rightAlign' | 'cardWithoutMachines' | 'icon'; +type CssRules = 'button' | 'codeSnippet' | 'link' | 'linkIcon' | 'rightAlign' | 'cardWithoutMachines' | 'icon' | 'moreOptionsButton' | 'moreOptions'; const styles: StyleRulesCallback = (theme: ArvadosTheme) => ({ button: { @@ -55,31 +56,47 @@ const styles: StyleRulesCallback = (theme: ArvadosTheme) => ({ icon: { textAlign: "right", marginTop: theme.spacing.unit - } + }, + moreOptionsButton: { + padding: 0 + }, + moreOptions: { + textAlign: 'right', + '&:last-child': { + paddingRight: 0 + } + }, }); -const mapStateToProps = ({ virtualMachines }: RootState) => { +const mapStateToProps = ({ virtualMachines, auth }: RootState) => { return { requestedDate: virtualMachines.date, + isAdmin: auth.user!.isAdmin, + logins: virtualMachines.logins, ...virtualMachines }; }; -const mapDispatchToProps = { - saveRequestedDate, - loadVirtualMachinesData -}; +const mapDispatchToProps = (dispatch: Dispatch): Pick => ({ + saveRequestedDate: () => dispatch(saveRequestedDate()), + loadVirtualMachinesData: () => dispatch(loadVirtualMachinesData()), + onOptionsMenuOpen: (event, virtualMachine) => { + dispatch(openVirtualMachinesContextMenu(event, virtualMachine)); + }, +}); interface VirtualMachinesPanelDataProps { requestedDate: string; virtualMachines: ListResults; - logins: VirtualMachinesLoginsResource[]; + logins: VirtualMachineLogins; links: ListResults; + isAdmin: boolean; } interface VirtualMachinesPanelActionProps { saveRequestedDate: () => void; loadVirtualMachinesData: () => string; + onOptionsMenuOpen: (event: React.MouseEvent, virtualMachine: VirtualMachinesResource) => void; } type VirtualMachineProps = VirtualMachinesPanelActionProps & VirtualMachinesPanelDataProps & WithStyles; @@ -93,12 +110,12 @@ export const VirtualMachinePanel = compose( } render() { - const { virtualMachines, links } = this.props; + const { virtualMachines, links, isAdmin } = this.props; return ( - {virtualMachines.itemsAvailable === 0 && } + {!isAdmin && virtualMachines.itemsAvailable > 0 && } {virtualMachines.itemsAvailable > 0 && links.itemsAvailable > 0 && } - {} + {!isAdmin && } ); } @@ -131,53 +148,87 @@ const CardContentWithVirtualMachines = (props: VirtualMachineProps) => -
- - {props.requestedDate && - - A request for shell access was sent on {props.requestedDate} - } -
- - - - - Host name - Login name - Command line - Web shell - - - - {props.virtualMachines.items.map((it, index) => - - {it.hostname} - {getUsername(props.links, it)} - ssh {getUsername(props.links, it)}@shell.arvados - - - Log in as {getUsername(props.links, it)} - - - - )} - -
+ {props.isAdmin ? {adminVirtualMachinesTable(props)} + : +
+ + {props.requestedDate && + + A request for shell access was sent on {props.requestedDate} + } +
+ + {userVirtualMachinesTable(props)} +
+ }
; -const getUsername = (links: ListResults, virtualMachine: VirtualMachinesResource) => { - const link = links.items.find((item: any) => item.headUuid === virtualMachine.uuid); - return link.properties.username || undefined; +const userVirtualMachinesTable = (props: VirtualMachineProps) => + + + + Host name + Login name + Command line + Web shell + + + + {props.virtualMachines.items.map((it, index) => + + {it.hostname} + {getUsername(props.links)} + ssh {getUsername(props.links)}@{it.hostname}.arvados + + + Log in as {getUsername(props.links)} + + + + )} + +
; + +const adminVirtualMachinesTable = (props: VirtualMachineProps) => + + + + Uuid + Host name + Logins + + + + + {props.logins.items.length > 0 && props.virtualMachines.items.map((it, index) => + + {it.uuid} + {it.hostname} + ["{props.logins.items[0].username}"] + + + props.onOptionsMenuOpen(event, it)} className={props.classes.moreOptionsButton}> + + + + + + )} + +
; + +const getUsername = (links: ListResults) => { + return links.items[0].properties.username; }; const CardSSHSection = (props: VirtualMachineProps) => diff --git a/src/views/workbench/workbench.tsx b/src/views/workbench/workbench.tsx index 2cff4317..5efffa19 100644 --- a/src/views/workbench/workbench.tsx +++ b/src/views/workbench/workbench.tsx @@ -52,14 +52,22 @@ import { ProcessInputDialog } from '~/views-components/process-input-dialog/proc import { VirtualMachinePanel } from '~/views/virtual-machine-panel/virtual-machine-panel'; import { ProjectPropertiesDialog } from '~/views-components/project-properties-dialog/project-properties-dialog'; import { RepositoriesPanel } from '~/views/repositories-panel/repositories-panel'; +import { KeepServicePanel } from '~/views/keep-service-panel/keep-service-panel'; +import { ComputeNodePanel } from '~/views/compute-node-panel/compute-node-panel'; import { RepositoriesSampleGitDialog } from '~/views-components/repositories-sample-git-dialog/repositories-sample-git-dialog'; import { RepositoryAttributesDialog } from '~/views-components/repository-attributes-dialog/repository-attributes-dialog'; import { CreateRepositoryDialog } from '~/views-components/dialog-forms/create-repository-dialog'; import { RemoveRepositoryDialog } from '~/views-components/repository-remove-dialog/repository-remove-dialog'; import { CreateSshKeyDialog } from '~/views-components/dialog-forms/create-ssh-key-dialog'; import { PublicKeyDialog } from '~/views-components/ssh-keys-dialog/public-key-dialog'; +import { RemoveComputeNodeDialog } from '~/views-components/compute-nodes-dialog/remove-dialog'; +import { RemoveKeepServiceDialog } from '~/views-components/keep-services-dialog/remove-dialog'; import { RemoveSshKeyDialog } from '~/views-components/ssh-keys-dialog/remove-dialog'; +import { RemoveVirtualMachineDialog } from '~/views-components/virtual-machines-dialog/remove-dialog'; +import { AttributesComputeNodeDialog } from '~/views-components/compute-nodes-dialog/attributes-dialog'; +import { AttributesKeepServiceDialog } from '~/views-components/keep-services-dialog/attributes-dialog'; import { AttributesSshKeyDialog } from '~/views-components/ssh-keys-dialog/attributes-dialog'; +import { VirtualMachineAttributesDialog } from '~/views-components/virtual-machines-dialog/attributes-dialog'; type CssRules = 'root' | 'container' | 'splitter' | 'asidePanel' | 'contentWrapper' | 'content'; @@ -132,6 +140,8 @@ export const WorkbenchPanel = + + @@ -142,6 +152,8 @@ export const WorkbenchPanel = + + @@ -163,9 +175,12 @@ export const WorkbenchPanel = + + + @@ -175,5 +190,6 @@ export const WorkbenchPanel = + ); \ No newline at end of file diff --git a/typings/global.d.ts b/typings/global.d.ts index b9f1cc62..93aa3cf1 100644 --- a/typings/global.d.ts +++ b/typings/global.d.ts @@ -13,4 +13,8 @@ declare interface System { declare var System: System; declare module 'react-splitter-layout'; -declare module 'react-rte'; \ No newline at end of file +declare module 'react-rte'; + +declare module 'is-image' { + export default function isImage(value: string): boolean; +} \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 1eaa15f8..f08c370f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4329,6 +4329,11 @@ ignore-walk@^3.0.1: dependencies: minimatch "^3.0.4" +image-extensions@^1.0.1: + version "1.1.0" + resolved "https://registry.yarnpkg.com/image-extensions/-/image-extensions-1.1.0.tgz#b8e6bf6039df0056e333502a00b6637a3105d894" + integrity sha1-uOa/YDnfAFbjM1AqALZjejEF2JQ= + immutable@^3.8.1: version "3.8.2" resolved "https://registry.yarnpkg.com/immutable/-/immutable-3.8.2.tgz#c2439951455bb39913daf281376f1530e104adf3" @@ -4644,6 +4649,13 @@ is-glob@^4.0.0: dependencies: is-extglob "^2.1.1" +is-image@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/is-image/-/is-image-2.0.0.tgz#454c9569578de31869371fbfaea4958f461b3e0c" + integrity sha1-RUyVaVeN4xhpNx+/rqSVj0YbPgw= + dependencies: + image-extensions "^1.0.1" + is-in-browser@^1.0.2, is-in-browser@^1.1.3: version "1.1.3" resolved "https://registry.yarnpkg.com/is-in-browser/-/is-in-browser-1.1.3.tgz#56ff4db683a078c6082eb95dad7dc62e1d04f835"