Merge branch '14502_admin_compute_nodes'
authorJanicki Artur <artur.janicki@contractors.roche.com>
Wed, 5 Dec 2018 13:27:49 +0000 (14:27 +0100)
committerJanicki Artur <artur.janicki@contractors.roche.com>
Wed, 5 Dec 2018 13:27:49 +0000 (14:27 +0100)
refs #14502

Arvados-DCO-1.1-Signed-off-by: Janicki Artur <artur.janicki@contractors.roche.com>

31 files changed:
package.json
src/components/file-tree/file-thumbnail.tsx [new file with mode: 0644]
src/components/file-tree/file-tree-data.ts
src/components/file-tree/file-tree-item.tsx
src/index.tsx
src/models/node.ts [new file with mode: 0644]
src/models/resource.ts
src/routes/route-change-handlers.ts
src/routes/routes.ts
src/services/node-service/node-service.ts [new file with mode: 0644]
src/services/services.ts
src/store/advanced-tab/advanced-tab.ts
src/store/compute-nodes/compute-nodes-actions.ts [new file with mode: 0644]
src/store/compute-nodes/compute-nodes-reducer.ts [new file with mode: 0644]
src/store/context-menu/context-menu-actions.ts
src/store/navigation/navigation-action.ts
src/store/store.ts
src/store/workbench/workbench-actions.ts
src/views-components/collection-panel-files/collection-panel-files.ts
src/views-components/compute-nodes-dialog/attributes-dialog.tsx [new file with mode: 0644]
src/views-components/compute-nodes-dialog/remove-dialog.tsx [new file with mode: 0644]
src/views-components/context-menu/action-sets/compute-node-action-set.ts [new file with mode: 0644]
src/views-components/context-menu/context-menu.tsx
src/views-components/main-app-bar/account-menu.tsx
src/views-components/main-content-bar/main-content-bar.tsx
src/views/compute-node-panel/compute-node-panel-root.tsx [new file with mode: 0644]
src/views/compute-node-panel/compute-node-panel.tsx [new file with mode: 0644]
src/views/keep-service-panel/keep-service-panel.tsx
src/views/workbench/workbench.tsx
typings/global.d.ts
yarn.lock

index 64a24dcafcef48a5e245f487d214371c209bd636..623b86d3a58d659bae2105ae06aa0629db71b22f 100644 (file)
@@ -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 (file)
index 0000000..e1a0d5e
--- /dev/null
@@ -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)
+            ? <ImageFileThumbnail file={file} />
+            : null;
+
+type ImageFileThumbnailCssRules = 'thumbnail';
+
+const imageFileThumbnailStyle = withStyles<ImageFileThumbnailCssRules>(theme => ({
+    thumbnail: {
+        maxWidth: 250,
+        margin: `${theme.spacing.unit}px 0`,
+    }
+}));
+
+const ImageFileThumbnail = imageFileThumbnailStyle(
+    ({ classes, file }: WithStyles<ImageFileThumbnailCssRules> & FileThumbnailProps) =>
+        <img
+            className={classes.thumbnail}
+            alt={file.name}
+            src={file.url} />
+);
index 4be4ace854ec08efe52561b2780708042ca514a2..4154611355566862de617abd65e562d4275f18d3 100644 (file)
@@ -5,5 +5,6 @@
 export interface FileTreeData {
     name: string;
     type: string;
+    url: string;
     size?: number;
 }
index 89bf43c66d84d3c6f9f157adb3e84812866ddde2..0e8c92e2da6c2343f589696fa999d7e7f9fce49a 100644 (file)
@@ -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<FileTreeItemProps & WithStyles<CssRules>> {
         render() {
             const { classes, item } = this.props;
-            return <div className={classes.root}>
-                <ListItemTextIcon
-                    icon={getIcon(item)}
-                    name={item.data.name} />
-                <div className={classes.spacer} />
-                <Typography
-                    className={classes.sizeInfo}
-                    variant="caption">{formatFileSize(item.data.size)}</Typography>
-                <Tooltip title="More options" disableFocusListener>
-                    <IconButton
-                        className={classes.button}
-                        onClick={this.handleClick}>
-                        <MoreOptionsIcon className={classes.moreOptions}/>
-                    </IconButton>
-                </Tooltip>
-            </div >;
+            return <>
+                <div className={classes.root}>
+                    <ListItemTextIcon
+                        icon={getIcon(item)}
+                        name={item.data.name} />
+                    <div className={classes.spacer} />
+                    <Typography
+                        className={classes.sizeInfo}
+                        variant="caption">{formatFileSize(item.data.size)}</Typography>
+                    <Tooltip title="More options" disableFocusListener>
+                        <IconButton
+                            className={classes.button}
+                            onClick={this.handleClick}>
+                            <MoreOptionsIcon className={classes.moreOptions} />
+                        </IconButton>
+                    </Tooltip>
+                </div >
+                <FileThumbnail file={item.data} />
+            </>;
         }
 
         handleClick = (event: React.MouseEvent<any>) => {
index d83859675fc11e6f78b9b4fee37a4314fa1bccf0..fbd6c9a88e1bb8ac18d66bd3ff4c681f38ab89ee 100644 (file)
@@ -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 (file)
index 0000000..8723811
--- /dev/null
@@ -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
index ee90174976d7add988bea61e0731a77bcd558cf3..4d2d92e0155b183763b1445865420ffe84a558bf 100644 (file)
@@ -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;
     }
index fdc4211fd60bacd52020ad84bc3258a1c02defe0..f2304acaa77d2b8d0b69789cafa93626446b47e2 100644 (file)
@@ -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);
     }
 };
index 5cd3e559965202a8e8d87fd0d6d2acc66e702d94..8f8fa06bd0379232d6da87ea6a47dad5673b811d 100644 (file)
@@ -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 (file)
index 0000000..97f2264
--- /dev/null
@@ -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<NodeResource> {
+    constructor(serverApi: AxiosInstance, actions: ApiActions) {
+        super(serverApi, "nodes", actions);
+    }
+} 
\ No newline at end of file
index b24b1d99a181a8086e9da06bd41684cd5969a907..d524405fe62000a889d980520e00ef0b9e771e79 100644 (file)
@@ -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<typeof createServices>;
 
@@ -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,
index 92d14d7bc4483b28182358ae2f7497a0c2bd9055..6b20f8b328a2ac555552031d5732834a6912fdc8 100644 (file)
@@ -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<any>, getState: () => RootState, services: ServiceRepository) => {
@@ -193,6 +200,21 @@ export const openAdvancedTabDialog = (uuid: string) =>
                 });
                 dispatch<any>(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<any>(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} \\
   <<EOF
 {
-  "${resourceName}": ${resource}
+  "${resourceName}": ${JSON.stringify(resource, null, 4)}
 }
 EOF`;
 
@@ -440,5 +462,30 @@ const keepServiceApiResponse = (apiResponse: KeepServiceResource) => {
 "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 (file)
index 0000000..659b1e8
--- /dev/null
@@ -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<NodeResource[]>(),
+    REMOVE_COMPUTE_NODE: ofType<string>()
+});
+
+export type ComputeNodesActions = UnionOf<typeof computeNodesActions>;
+
+export const COMPUTE_NODE_REMOVE_DIALOG = 'computeNodeRemoveDialog';
+export const COMPUTE_NODE_ATTRIBUTES_DIALOG = 'computeNodeAttributesDialog';
+
+export const loadComputeNodesPanel = () =>
+    async (dispatch: Dispatch<any>, 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 (file)
index 0000000..44a3780
--- /dev/null
@@ -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
index d56a3fb5ae520bd3bb1309c14e4f9656bf8182d2..65ddcff2c7f1b360baa0e4ea98587cdb9bbbc179 100644 (file)
@@ -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<HTMLElement>,
         }));
     };
 
+export const openComputeNodeContextMenu = (event: React.MouseEvent<HTMLElement>, computeNode: NodeResource) =>
+    (dispatch: Dispatch) => {
+        dispatch<any>(openContextMenu(event, {
+            name: '',
+            uuid: computeNode.uuid,
+            ownerUuid: computeNode.ownerUuid,
+            kind: ResourceKind.NODE,
+            menuKind: ContextMenuKind.NODE
+        }));
+    };
+
 export const openRootProjectContextMenu = (event: React.MouseEvent<HTMLElement>, projectUuid: string) =>
     (dispatch: Dispatch, getState: () => RootState) => {
         const res = getResource<UserResource>(projectUuid)(getState().resources);
index d452710cf561f298e65a8bea2a1c1395392c3738..50cfd88d326e8fe97a4610b6b27b6ab842a8a092 100644 (file)
@@ -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
index f8bdcc24c6efee4f3b6811a05fb48b86c3cb1ee1..321a19b6f25182072d405e29e8b308457b4162df 100644 (file)
@@ -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
 });
index 667f1c8047451853334f508d0737c9bf019fbe4d..e3f96a9c07de620385640dd43445ca566c276f8b 100644 (file)
@@ -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<any>) => {
+        await dispatch(loadComputeNodesPanel());
+    });
+
 const finishLoadingProject = (project: GroupContentsResource | string) =>
     async (dispatch: Dispatch<any>) => {
         const uuid = typeof project === 'string' ? project : project.uuid;
index 294bd6d5d2858c667b3979d6876fb0d3c3e3746e..d912ac1302c9b066e059c9a31dddc1e0602e4a5e 100644 (file)
@@ -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<CollectionPanelDirectory | CollectionPanelFile>) =>
     (id: string): TreeItem<FileTreeData> => {
-        const node = getNode(id)(tree) || {
+        const node: TreeNode<CollectionPanelDirectory | CollectionPanelFile> = 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 (file)
index 0000000..3959909
--- /dev/null
@@ -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<CssRules> = (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<AttributesComputeNodeDialogDataProps> & WithStyles<CssRules>) =>
+            <Dialog open={open} onClose={closeDialog} fullWidth maxWidth='sm'>
+                <DialogTitle>Attributes</DialogTitle>
+                <DialogContent>
+                    {data.computeNode && <div>
+                        {renderPrimaryInfo(data.computeNode, classes)}
+                        {renderInfo(data.computeNode.info, classes)}
+                        {renderProperties(data.computeNode.properties, classes)}
+                    </div>}
+                </DialogContent>
+                <DialogActions>
+                    <Button
+                        variant='flat'
+                        color='primary'
+                        onClick={closeDialog}>
+                        Close
+                    </Button>
+                </DialogActions>
+            </Dialog>
+    );
+
+const renderPrimaryInfo = (computeNode: NodeResource, classes: any) => {
+    const { uuid, ownerUuid, createdAt, modifiedAt, modifiedByClientUuid, modifiedByUserUuid } = computeNode;
+    return (
+        <Grid container direction="row" spacing={16} className={classes.root}>
+            <Grid item xs={5}>UUID</Grid>
+            <Grid item xs={7}>{uuid}</Grid>
+            <Grid item xs={5}>Owner uuid</Grid>
+            <Grid item xs={7}>{ownerUuid}</Grid>
+            <Grid item xs={5}>Created at</Grid>
+            <Grid item xs={7}>{createdAt}</Grid>
+            <Grid item xs={5}>Modified at</Grid>
+            <Grid item xs={7}>{modifiedAt}</Grid>
+            <Grid item xs={5}>Modified by user uuid</Grid>
+            <Grid item xs={7}>{modifiedByUserUuid}</Grid>
+            <Grid item xs={5}>Modified by client uuid</Grid>
+            <Grid item xs={7}>{modifiedByClientUuid || '(none)'}</Grid>
+        </Grid>
+    );
+};
+
+const renderInfo = (info: NodeInfo, classes: any) => {
+    const { lastAction, pingSecret, ec2InstanceId, slurmState } = info;
+    return (
+        <Grid container direction="row" spacing={16} className={classnames([classes.root, classes.grid])}>
+            <Grid item xs={5}>Info - Last action</Grid>
+            <Grid item xs={7}>{lastAction || '(none)'}</Grid>
+            <Grid item xs={5}>Info - Ping secret</Grid>
+            <Grid item xs={7}>{pingSecret || '(none)'}</Grid>
+            <Grid item xs={5}>Info - ec2 instance id</Grid>
+            <Grid item xs={7}>{ec2InstanceId || '(none)'}</Grid>
+            <Grid item xs={5}>Info - Slurm state</Grid>
+            <Grid item xs={7}>{slurmState || '(none)'}</Grid>
+        </Grid>
+    );
+};
+
+const renderProperties = (properties: NodeProperties, classes: any) => {
+    const { totalRamMb, totalCpuCores, totalScratchMb, cloudNode } = properties;
+    return (
+        <Grid container direction="row" spacing={16} className={classnames([classes.root, classes.grid])}>
+            <Grid item xs={5}>Properties - Total ram mb</Grid>
+            <Grid item xs={7}>{totalRamMb || '(none)'}</Grid>
+            <Grid item xs={5}>Properties - Total scratch mb</Grid>
+            <Grid item xs={7}>{totalScratchMb || '(none)'}</Grid>
+            <Grid item xs={5}>Properties - Total cpu cores</Grid>
+            <Grid item xs={7}>{totalCpuCores || '(none)'}</Grid>
+            <Grid item xs={5}>Properties - Cloud node size </Grid>
+            <Grid item xs={7}>{cloudNode ? cloudNode.size : '(none)'}</Grid>
+            <Grid item xs={5}>Properties - Cloud node price</Grid>
+            <Grid item xs={7}>{cloudNode ? cloudNode.price : '(none)'}</Grid>
+        </Grid>
+    );
+};
\ 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 (file)
index 0000000..2233974
--- /dev/null
@@ -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<any>) => ({
+    onConfirm: () => {
+        props.closeDialog();
+        dispatch<any>(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 (file)
index 0000000..cfb90b6
--- /dev/null
@@ -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<any>(openComputeNodeAttributesDialog(uuid));
+    }
+}, {
+    name: "Advanced",
+    icon: AdvancedIcon,
+    execute: (dispatch, { uuid }) => {
+        dispatch<any>(openAdvancedTabDialog(uuid));
+    }
+}, {
+    name: "Remove",
+    icon: RemoveIcon,
+    execute: (dispatch, { uuid }) => {
+        dispatch<any>(openComputeNodeRemoveDialog(uuid));
+    }
+}]];
index 5f321bfe72f9f292542b86225d521a14207234d9..3fa1ab30d83d5d994e7b94bbe49b356deee74009 100644 (file)
@@ -72,5 +72,6 @@ export enum ContextMenuKind {
     REPOSITORY = "Repository",
     SSH_KEY = "SshKey",
     VIRTUAL_MACHINE = "VirtualMachine",
-    KEEP_SERVICE = "KeepService"
+    KEEP_SERVICE = "KeepService",
+    NODE = "Node"
 }
index 075aa69a472347f2abc63c4b491a54fcaecaea85..f4232a129dd3c07b11d64d88f661eab40b79ab9c 100644 (file)
@@ -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)(
                 <MenuItem onClick={() => dispatch(openCurrentTokenDialog)}>Current token</MenuItem>
                 <MenuItem onClick={() => dispatch(navigateToSshKeys)}>Ssh Keys</MenuItem>
                 { user.isAdmin && <MenuItem onClick={() => dispatch(navigateToKeepServices)}>Keep Services</MenuItem> }
+                { user.isAdmin && <MenuItem onClick={() => dispatch(navigateToComputeNodes)}>Compute Nodes</MenuItem> }
                 <MenuItem>My account</MenuItem>
                 <MenuItem onClick={() => dispatch(logout())}>Logout</MenuItem>
             </DropdownMenu>
index 66d7cabc88bd3dfe6bf637cf5a4713d9a81bd759..78b79a8ed46bf0f70737fa412178d421fa0562fb 100644 (file)
@@ -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 (file)
index 0000000..be3627b
--- /dev/null
@@ -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<CssRules> = (theme: ArvadosTheme) => ({
+    root: {
+        width: '100%',
+        overflow: 'auto'
+    },
+    tableRow: {
+        '& td, th': {
+            whiteSpace: 'nowrap'
+        }
+    }
+});
+
+export interface ComputeNodePanelRootActionProps {
+    openRowOptions: (event: React.MouseEvent<HTMLElement>, computeNode: NodeResource) => void;
+}
+
+export interface ComputeNodePanelRootDataProps {
+    computeNodes: NodeResource[];
+    hasComputeNodes: boolean;
+}
+
+type ComputeNodePanelRootProps = ComputeNodePanelRootActionProps & ComputeNodePanelRootDataProps & WithStyles<CssRules>;
+
+export const ComputeNodePanelRoot = withStyles(styles)(
+    ({ classes, hasComputeNodes, computeNodes, openRowOptions }: ComputeNodePanelRootProps) =>
+        <Card className={classes.root}>
+            <CardContent>
+                {hasComputeNodes && <Grid container direction="row">
+                    <Grid item xs={12}>
+                        <Table>
+                            <TableHead>
+                                <TableRow className={classes.tableRow}>
+                                    <TableCell>Info</TableCell>
+                                    <TableCell>UUID</TableCell>
+                                    <TableCell>Domain</TableCell>
+                                    <TableCell>First ping at</TableCell>
+                                    <TableCell>Hostname</TableCell>
+                                    <TableCell>IP Address</TableCell>
+                                    <TableCell>Job</TableCell>
+                                    <TableCell>Last ping at</TableCell>
+                                    <TableCell />
+                                </TableRow>
+                            </TableHead>
+                            <TableBody>
+                                {computeNodes.map((computeNode, index) =>
+                                    <TableRow key={index} className={classes.tableRow}>
+                                        <TableCell>{computeNode.uuid}</TableCell>
+                                        <TableCell>{computeNode.uuid}</TableCell>
+                                        <TableCell>{computeNode.domain}</TableCell>
+                                        <TableCell>{formatDate(computeNode.firstPingAt) || '(none)'}</TableCell>
+                                        <TableCell>{computeNode.hostname || '(none)'}</TableCell>
+                                        <TableCell>{computeNode.ipAddress || '(none)'}</TableCell>
+                                        <TableCell>{computeNode.jobUuid || '(none)'}</TableCell>
+                                        <TableCell>{formatDate(computeNode.lastPingAt) || '(none)'}</TableCell>
+                                        <TableCell>
+                                            <Tooltip title="More options" disableFocusListener>
+                                                <IconButton onClick={event => openRowOptions(event, computeNode)}>
+                                                    <MoreOptionsIcon />
+                                                </IconButton>
+                                            </Tooltip>
+                                        </TableCell>
+                                    </TableRow>)}
+                            </TableBody>
+                        </Table>
+                    </Grid>
+                </Grid>}
+            </CardContent>
+        </Card>
+);
\ 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 (file)
index 0000000..a4f22c8
--- /dev/null
@@ -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<any>(openComputeNodeContextMenu(event, computeNode));
+    }
+});
+
+export const ComputeNodePanel = connect(mapStateToProps, mapDispatchToProps)(ComputeNodePanelRoot);
\ No newline at end of file
index a11cee0b22b7a780edcabfa2656ee8d0160848c9..369b7c213417876994a6859527876141f1769882 100644 (file)
@@ -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, 
index 2d17fad9629d80aabdf7c493155012c33bc38302..92c2438b49f92c3d16bcf741713d76e6972d8643 100644 (file)
@@ -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 =
                                 <Route path={Routes.REPOSITORIES} component={RepositoriesPanel} />
                                 <Route path={Routes.SSH_KEYS} component={SshKeyPanel} />
                                 <Route path={Routes.KEEP_SERVICES} component={KeepServicePanel} />
+                                <Route path={Routes.COMPUTE_NODES} component={ComputeNodePanel} />
                             </Switch>
                         </Grid>
                     </Grid>
@@ -146,6 +150,7 @@ export const WorkbenchPanel =
                 <DetailsPanel />
             </Grid>
             <AdvancedTabDialog />
+            <AttributesComputeNodeDialog />
             <AttributesKeepServiceDialog />
             <AttributesSshKeyDialog />
             <ChangeWorkflowDialog />
@@ -168,6 +173,7 @@ export const WorkbenchPanel =
             <ProcessCommandDialog />
             <ProcessInputDialog />
             <ProjectPropertiesDialog />
+            <RemoveComputeNodeDialog />
             <RemoveKeepServiceDialog />
             <RemoveProcessDialog />
             <RemoveRepositoryDialog />
index b9f1cc627770e4061f08cccbf10db29e40fefbac..93aa3cf108e5788f1c1265b01571db4de7706277 100644 (file)
@@ -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
index 1eaa15f871ffe38692a88a660cb4b20300da648b..f08c370f5d3fb8121d01411f0519fda30590e7ba 100644 (file)
--- 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"