[ARVADOS-WORKBENCH2] created: 1.3.0-17-ge032ee6

Git user git at public.curoverse.com
Wed Dec 5 06:31:24 EST 2018


        at  e032ee615c409d084d84e35ae6d572999105dd22 (commit)


commit e032ee615c409d084d84e35ae6d572999105dd22
Author: Janicki Artur <artur.janicki at contractors.roche.com>
Date:   Wed Dec 5 12:31:06 2018 +0100

    add compute nodes with store, service and all dialogs
    
    Feature #14502_admin_compute_nodes
    
    Arvados-DCO-1.1-Signed-off-by: Janicki Artur <artur.janicki at contractors.roche.com>

diff --git a/src/index.tsx b/src/index.tsx
index d838596..fbd6c9a 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 0000000..8723811
--- /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 ee90174..4d2d92e 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 fdc4211..f2304ac 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 5cd3e55..8f8fa06 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 0000000..97f2264
--- /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<NodeResource> {
+    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 b24b1d9..d524405 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<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,
diff --git a/src/store/advanced-tab/advanced-tab.ts b/src/store/advanced-tab/advanced-tab.ts
index 92d14d7..6b20f8b 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<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`;
 
@@ -441,4 +463,29 @@ const keepServiceApiResponse = (apiResponse: KeepServiceResource) => {
 "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 0000000..659b1e8
--- /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<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
index 0000000..44a3780
--- /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 d56a3fb..65ddcff 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<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);
diff --git a/src/store/navigation/navigation-action.ts b/src/store/navigation/navigation-action.ts
index d452710..50cfd88 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 f8bdcc2..321a19b 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 667f1c8..e3f96a9 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<any>) => {
+        await dispatch(loadComputeNodesPanel());
+    });
+
 const finishLoadingProject = (project: GroupContentsResource | string) =>
     async (dispatch: Dispatch<any>) => {
         const uuid = typeof project === 'string' ? project : project.uuid;
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 0000000..3959909
--- /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<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
index 0000000..2233974
--- /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<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
index 0000000..cfb90b6
--- /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<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));
+    }
+}]];
diff --git a/src/views-components/context-menu/context-menu.tsx b/src/views-components/context-menu/context-menu.tsx
index 5f321bf..3fa1ab3 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 075aa69..f4232a1 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)(
                 <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>
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 66d7cab..78b79a8 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 0000000..be3627b
--- /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<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
index 0000000..a4f22c8
--- /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<any>(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 a11cee0..369b7c2 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 2d17fad..92c2438 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 =
                                 <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 />

-----------------------------------------------------------------------


hooks/post-receive
-- 




More information about the arvados-commits mailing list