From: Janicki Artur Date: Wed, 5 Dec 2018 13:27:49 +0000 (+0100) Subject: Merge branch '14502_admin_compute_nodes' X-Git-Tag: 1.4.0~96 X-Git-Url: https://git.arvados.org/arvados-workbench2.git/commitdiff_plain/ceda57340f34d71fb4289b344e6ca839db06f5e7?hp=26e05f295b57de342d4cd13306ead196e2841a21 Merge branch '14502_admin_compute_nodes' refs #14502 Arvados-DCO-1.1-Signed-off-by: Janicki Artur --- 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/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 d8385967..fbd6c9a8 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -53,6 +53,7 @@ import { sshKeyActionSet } from '~/views-components/context-menu/action-sets/ssh 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()}]`); @@ -73,6 +74,7 @@ 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/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 ee901749..4d2d92e0 100644 --- a/src/models/resource.ts +++ b/src/models/resource.ts @@ -26,6 +26,7 @@ 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", @@ -48,7 +49,8 @@ export enum ResourceObjectType { VIRTUAL_MACHINE = '2x53u', WORKFLOW = '7fd4e', SSH_KEY = 'fngyi', - KEEP_SERVICE = 'bi6l4' + KEEP_SERVICE = 'bi6l4', + NODE = '7ekkf' } export const RESOURCE_UUID_PATTERN = '.{5}-.{5}-.{15}'; @@ -89,6 +91,8 @@ export const extractUuidKind = (uuid: string = '') => { 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/routes/route-change-handlers.ts b/src/routes/route-change-handlers.ts index fdc4211f..f2304aca 100644 --- a/src/routes/route-change-handlers.ts +++ b/src/routes/route-change-handlers.ts @@ -4,17 +4,8 @@ import { History, Location } from 'history'; import { RootStore } from '~/store/store'; -import { - matchProcessRoute, matchProcessLogRoute, matchProjectRoute, matchCollectionRoute, matchFavoritesRoute, - matchTrashRoute, matchRootRoute, matchSharedWithMeRoute, matchRunProcessRoute, matchWorkflowRoute, - matchSearchResultsRoute, matchSshKeysRoute, matchRepositoriesRoute, matchVirtualMachineRoute, - matchKeepServicesRoute -} from './routes'; -import { - loadSharedWithMe, loadRunProcess, loadWorkflow, loadSearchResults, - loadProject, loadCollection, loadFavorites, loadTrash, loadProcess, loadProcessLog, - loadSshKeys, loadRepositories, loadVirtualMachines, loadKeepServices -} 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'; export const addRouteChangeHandlers = (history: History, store: RootStore) => { @@ -24,51 +15,54 @@ 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 keepServicesMatch = matchKeepServicesRoute(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); 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(loadKeepServices); + store.dispatch(WorkbenchActions.loadKeepServices); + } else if (computeNodesMatch) { + store.dispatch(WorkbenchActions.loadComputeNodes); } }; diff --git a/src/routes/routes.ts b/src/routes/routes.ts index 5cd3e559..8f8fa06b 100644 --- a/src/routes/routes.ts +++ b/src/routes/routes.ts @@ -23,7 +23,8 @@ export const Routes = { WORKFLOWS: '/workflows', SEARCH_RESULTS: '/search-results', SSH_KEYS: `/ssh-keys`, - KEEP_SERVICES: `/keep-services` + KEEP_SERVICES: `/keep-services`, + COMPUTE_NODES: `/nodes` }; export const getResourceUrl = (uuid: string) => { @@ -92,3 +93,6 @@ export const matchSshKeysRoute = (route: string) => 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/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 92d14d7b..6b20f8b3 100644 --- a/src/store/advanced-tab/advanced-tab.ts +++ b/src/store/advanced-tab/advanced-tab.ts @@ -21,6 +21,7 @@ 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'; @@ -72,7 +73,8 @@ enum ResourcePrefix { REPOSITORIES = 'repositories', AUTORIZED_KEYS = 'authorized_keys', VIRTUAL_MACHINES = 'virtual_machines', - KEEP_SERVICES = 'keep_services' + KEEP_SERVICES = 'keep_services', + COMPUTE_NODES = 'nodes' } enum KeepServiceData { @@ -80,9 +82,14 @@ enum KeepServiceData { CREATED_AT = 'created_at' } -type AdvanceResourceKind = CollectionData | ProcessData | ProjectData | RepositoryData | SshKeyData | VirtualMachineData | KeepServiceData; +enum ComputeNodeData { + COMPUTE_NODE = 'node', + PROPERTIES = 'properties' +} + +type AdvanceResourceKind = CollectionData | ProcessData | ProjectData | RepositoryData | SshKeyData | VirtualMachineData | KeepServiceData | ComputeNodeData; type AdvanceResourcePrefix = GroupContentsResourcePrefix | ResourcePrefix; -type AdvanceResponseData = ContainerRequestResource | ProjectResource | CollectionResource | RepositoryResource | SshKeyResource | VirtualMachinesResource | KeepServiceResource | undefined; +type AdvanceResponseData = ContainerRequestResource | ProjectResource | CollectionResource | RepositoryResource | SshKeyResource | VirtualMachinesResource | KeepServiceResource | NodeResource | undefined; export const openAdvancedTabDialog = (uuid: string) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { @@ -193,6 +200,21 @@ export const openAdvancedTabDialog = (uuid: string) => }); 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 })); } @@ -269,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; }; @@ -284,7 +306,7 @@ const curlExample = (uuid: string, resourcePrefix: string, resource: string | st https://$ARVADOS_API_HOST/arvados/v1/${resourcePrefix}/${uuid} \\ < { "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/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 d56a3fb5..65ddcff2 100644 --- a/src/store/context-menu/context-menu-actions.ts +++ b/src/store/context-menu/context-menu-actions.ts @@ -17,6 +17,7 @@ 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 }>(), @@ -109,6 +110,17 @@ export const openKeepServiceContextMenu = (event: React.MouseEvent, })); }; +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 + })); + }; + export const openRootProjectContextMenu = (event: React.MouseEvent, projectUuid: string) => (dispatch: Dispatch, getState: () => RootState) => { const res = getResource(projectUuid)(getState().resources); diff --git a/src/store/navigation/navigation-action.ts b/src/store/navigation/navigation-action.ts index d452710c..50cfd88d 100644 --- a/src/store/navigation/navigation-action.ts +++ b/src/store/navigation/navigation-action.ts @@ -68,4 +68,6 @@ export const navigateToRepositories = push(Routes.REPOSITORIES); export const navigateToSshKeys= push(Routes.SSH_KEYS); -export const navigateToKeepServices = push(Routes.KEEP_SERVICES); \ No newline at end of file +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/store.ts b/src/store/store.ts index f8bdcc24..321a19b6 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -46,6 +46,7 @@ import { resourcesDataReducer } from "~/store/resources-data/resources-data-redu 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' && @@ -117,5 +118,6 @@ const createRootReducer = (services: ServiceRepository) => combineReducers({ searchBar: searchBarReducer, virtualMachines: virtualMachinesReducer, repositories: repositoriesReducer, - keepServices: keepServicesReducer + keepServices: keepServicesReducer, + computeNodes: computeNodesReducer }); diff --git a/src/store/workbench/workbench-actions.ts b/src/store/workbench/workbench-actions.ts index 667f1c80..e3f96a9c 100644 --- a/src/store/workbench/workbench-actions.ts +++ b/src/store/workbench/workbench-actions.ts @@ -57,6 +57,7 @@ import { searchResultsPanelColumns } from '~/views/search-results-panel/search-r 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 +417,11 @@ export const loadKeepServices = handleFirstTimeLoad( 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/context-menu.tsx b/src/views-components/context-menu/context-menu.tsx index 5f321bfe..3fa1ab30 100644 --- a/src/views-components/context-menu/context-menu.tsx +++ b/src/views-components/context-menu/context-menu.tsx @@ -72,5 +72,6 @@ export enum ContextMenuKind { REPOSITORY = "Repository", SSH_KEY = "SshKey", VIRTUAL_MACHINE = "VirtualMachine", - KEEP_SERVICE = "KeepService" + KEEP_SERVICE = "KeepService", + NODE = "Node" } diff --git a/src/views-components/main-app-bar/account-menu.tsx b/src/views-components/main-app-bar/account-menu.tsx index 075aa69a..f4232a12 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, navigateToKeepServices } from '~/store/navigation/navigation-action'; +import { navigateToSshKeys, navigateToKeepServices, navigateToComputeNodes } from '~/store/navigation/navigation-action'; import { openVirtualMachines } from "~/store/virtual-machines/virtual-machines-actions"; interface AccountMenuProps { @@ -38,6 +38,7 @@ export const AccountMenu = connect(mapStateToProps)( dispatch(openCurrentTokenDialog)}>Current token dispatch(navigateToSshKeys)}>Ssh Keys { user.isAdmin && dispatch(navigateToKeepServices)}>Keep Services } + { user.isAdmin && dispatch(navigateToComputeNodes)}>Compute Nodes } 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 66d7cabc..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, matchKeepServicesRoute } from '~/routes/routes'; +import * as Routes from '~/routes/routes'; import { toggleDetailsPanel } from '~/store/details-panel/details-panel-action'; interface MainContentBarProps { @@ -18,8 +18,9 @@ interface MainContentBarProps { const isButtonVisible = ({ router }: RootState) => { const pathname = router.location ? router.location.pathname : ''; - return !matchWorkflowRoute(pathname) && !matchVirtualMachineRoute(pathname) && - !matchRepositoriesRoute(pathname) && !matchSshKeysRoute(pathname) && !matchKeepServicesRoute(pathname); + 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) => ({ 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.tsx b/src/views/keep-service-panel/keep-service-panel.tsx index a11cee0b..369b7c21 100644 --- a/src/views/keep-service-panel/keep-service-panel.tsx +++ b/src/views/keep-service-panel/keep-service-panel.tsx @@ -5,7 +5,6 @@ import { RootState } from '~/store/store'; import { Dispatch } from 'redux'; import { connect } from 'react-redux'; -import { } from '~/store/keep-services/keep-services-actions'; import { KeepServicePanelRoot, KeepServicePanelRootDataProps, diff --git a/src/views/workbench/workbench.tsx b/src/views/workbench/workbench.tsx index 2d17fad9..92c2438b 100644 --- a/src/views/workbench/workbench.tsx +++ b/src/views/workbench/workbench.tsx @@ -52,18 +52,21 @@ import { VirtualMachinePanel } from '~/views/virtual-machine-panel/virtual-machi 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'; -import { RemoveVirtualMachineDialog } from '~/views-components/virtual-machines-dialog/remove-dialog'; type CssRules = 'root' | 'container' | 'splitter' | 'asidePanel' | 'contentWrapper' | 'content'; @@ -137,6 +140,7 @@ export const WorkbenchPanel = + @@ -146,6 +150,7 @@ export const WorkbenchPanel = + @@ -168,6 +173,7 @@ export const WorkbenchPanel = + 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"