refs #13828 Merge branch 'origin/13828-trash-view'
authorDaniel Kos <daniel.kos@contractors.roche.com>
Tue, 4 Sep 2018 07:41:44 +0000 (09:41 +0200)
committerDaniel Kos <daniel.kos@contractors.roche.com>
Tue, 4 Sep 2018 07:49:11 +0000 (09:49 +0200)
# Conflicts:
# src/index.tsx
# src/routes/routes.ts
# src/store/context-menu/context-menu-actions.ts
# src/store/store.ts
# src/store/workbench/workbench-actions.ts
# src/views/workbench/workbench.tsx

Arvados-DCO-1.1-Signed-off-by: Daniel Kos <daniel.kos@contractors.roche.com>

19 files changed:
1  2 
src/common/formatters.ts
src/components/data-explorer/data-explorer.test.tsx
src/components/icon/icon.tsx
src/index.tsx
src/models/resource.ts
src/routes/routes.ts
src/services/common-service/common-resource-service.ts
src/services/groups-service/groups-service.test.ts
src/services/log-service/log-service.ts
src/store/context-menu/context-menu-actions.ts
src/store/navigation/navigation-action.ts
src/store/process-logs-panel/process-logs-panel-actions.ts
src/store/processes/processes-actions.ts
src/store/store.ts
src/store/workbench/workbench-actions.ts
src/views-components/context-menu/context-menu.tsx
src/views/workbench/workbench.tsx
src/websocket/websocket-service.ts
src/websocket/websocket.ts

diff --combined src/common/formatters.ts
index 0402f3903763e75ac04ee16c555f05d787cbbc77,b1baee7de912e56a4b22b683cd172dfdea542a1c..e2097878a9f98276ffe1d78cf49ac6ca4468d894
@@@ -2,10 -2,13 +2,13 @@@
  //
  // SPDX-License-Identifier: AGPL-3.0
  
- export const formatDate = (isoDate: string) => {
-     const date = new Date(isoDate);
-     const text = date.toLocaleString();
-     return text === 'Invalid Date' ? "" : text;
+ export const formatDate = (isoDate?: string) => {
+     if (isoDate) {
+         const date = new Date(isoDate);
+         const text = date.toLocaleString();
+         return text === 'Invalid Date' ? "" : text;
+     }
+     return "";
  };
  
  export const formatFileSize = (size?: number) => {
      return "";
  };
  
 +export const formatTime = (time: number) => {
 +    const minutes = Math.floor(time / (1000 * 60) % 60).toFixed(0);
 +    const hours = Math.floor(time / (1000 * 60 * 60)).toFixed(0);
 +
 +    return hours + "h " + minutes + "m";
 +};
 +
 +export const getTimeDiff = (endTime: string, startTime: string) => {
 +    return new Date(endTime).getTime() - new Date(startTime).getTime();
 +};
 +
  export const formatProgress = (loaded: number, total: number) => {
      const progress = loaded >= 0 && total > 0 ? loaded * 100 / total : 0;
      return `${progress.toFixed(2)}%`;
index 3e447b4015480091b66e6ee39f5b6d5560ea6cc6,3e447b4015480091b66e6ee39f5b6d5560ea6cc6..882c178be5ef5550629412f5116a7ab3f4e6d50f
@@@ -124,4 -124,4 +124,5 @@@ const mockDataExplorerProps = () => (
      defaultIcon: ProjectIcon,
      onSetColumns: jest.fn(),
      defaultMessages: ['testing'],
++    contextMenuColumn: true
  });
index 8bd9e22772c42ea593767f8614882ccac96fe9da,2af70d840c9bcb9ce8f60a71cf262285f37f6c6b..90861bfed6aca96b1a4fc9e49db749f628e543c0
@@@ -4,7 -4,6 +4,7 @@@
  
  import * as React from 'react';
  import AccessTime from '@material-ui/icons/AccessTime';
 +import ArrowBack from '@material-ui/icons/ArrowBack';
  import ArrowDropDown from '@material-ui/icons/ArrowDropDown';
  import BubbleChart from '@material-ui/icons/BubbleChart';
  import Cached from '@material-ui/icons/Cached';
@@@ -33,6 -32,7 +33,7 @@@ import Person from '@material-ui/icons/
  import PersonAdd from '@material-ui/icons/PersonAdd';
  import PlayArrow from '@material-ui/icons/PlayArrow';
  import RateReview from '@material-ui/icons/RateReview';
+ import RestoreFromTrash from '@material-ui/icons/History';
  import Search from '@material-ui/icons/Search';
  import SettingsApplications from '@material-ui/icons/SettingsApplications';
  import Star from '@material-ui/icons/Star';
@@@ -42,7 -42,6 +43,7 @@@ export type IconType = React.SFC<{ clas
  
  export const AddFavoriteIcon: IconType = (props) => <StarBorder {...props} />;
  export const AdvancedIcon: IconType = (props) => <SettingsApplications {...props} />;
 +export const BackIcon: IconType = (props) => <ArrowBack {...props} />;
  export const CustomizeTableIcon: IconType = (props) => <Menu {...props} />;
  export const CopyIcon: IconType = (props) => <ContentCopy {...props} />;
  export const CollectionIcon: IconType = (props) => <LibraryBooks {...props} />;
@@@ -69,6 -68,7 +70,7 @@@ export const RecentIcon: IconType = (pr
  export const RemoveIcon: IconType = (props) => <Delete {...props} />;
  export const RemoveFavoriteIcon: IconType = (props) => <Star {...props} />;
  export const RenameIcon: IconType = (props) => <Edit {...props} />;
+ export const RestoreFromTrashIcon: IconType = (props) => <RestoreFromTrash {...props} />;
  export const ReRunProcessIcon: IconType = (props) => <Cached {...props} />;
  export const SearchIcon: IconType = (props) => <Search {...props} />;
  export const ShareIcon: IconType = (props) => <PersonAdd {...props} />;
diff --combined src/index.tsx
index 20d2c1e9e1f1fc2bf0ee31023a58583e92dc5988,6137e26dd9319e83d4973280c8dab04b9e209e1d..4ce80d31e9d0f0360e6dd7051b113f9dbfe4d40c
@@@ -31,9 -31,7 +31,10 @@@ import { processActionSet } from './vie
  import { addRouteChangeHandlers } from './routes/routes';
  import { loadWorkbench } from './store/workbench/workbench-actions';
  import { Routes } from '~/routes/routes';
+ import { trashActionSet } from "~/views-components/context-menu/action-sets/trash-action-set";
 +import { ServiceRepository } from '~/services/services';
 +import { initWebSocket } from '~/websocket/websocket';
 +import { Config } from '~/common/config';
  
  const getBuildNumber = () => "BN-" + (process.env.REACT_APP_BUILD_NUMBER || "dev");
  const getGitCommit = () => "GIT-" + (process.env.REACT_APP_GIT_COMMIT || "latest").substr(0, 7);
@@@ -52,6 -50,7 +53,7 @@@ addMenuActionSet(ContextMenuKind.COLLEC
  addMenuActionSet(ContextMenuKind.COLLECTION, collectionActionSet);
  addMenuActionSet(ContextMenuKind.COLLECTION_RESOURCE, collectionResourceActionSet);
  addMenuActionSet(ContextMenuKind.PROCESS, processActionSet);
+ addMenuActionSet(ContextMenuKind.TRASH, trashActionSet);
  
  fetchConfig()
      .then((config) => {
@@@ -59,7 -58,8 +61,7 @@@
          const services = createServices(config);
          const store = configureStore(history, services);
  
 -        store.subscribe(initListener(history, store));
 -
 +        store.subscribe(initListener(history, store, services, config));
          store.dispatch(initAuth());
  
          const TokenComponent = (props: any) => <ApiToken authService={services.authService} {...props} />;
  
      });
  
 -const initListener = (history: History, store: RootStore) => {
 +const initListener = (history: History, store: RootStore, services: ServiceRepository, config: Config) => {
      let initialized = false;
      return async () => {
          const { router, auth } = store.getState();
          if (router.location && auth.user && !initialized) {
              initialized = true;
 +            initWebSocket(config, services.authService, store);
              await store.dispatch(loadWorkbench());
              addRouteChangeHandlers(history, store);
          }
diff --combined src/models/resource.ts
index 3c6c11bc57f81b62669a693362d5c3a75b9e2a24,4d7031a455115a58b13684f364115e749e2915df..698bcf73188ec21c375a349d3f2605953bbd2074
@@@ -10,20 -10,26 +10,27 @@@ export interface Resource 
      modifiedByUserUuid: string;
      modifiedAt: string;
      href: string;
-     kind: string;
+     kind: ResourceKind;
      etag: string;
  }
  
+ export interface TrashableResource extends Resource {
+     trashAt: string;
+     deleteAt: string;
+     isTrashed: boolean;
+ }
  export enum ResourceKind {
      COLLECTION = "arvados#collection",
      CONTAINER = "arvados#container",
      CONTAINER_REQUEST = "arvados#containerRequest",
      GROUP = "arvados#group",
 +    LOG = "arvados#log",
      PROCESS = "arvados#containerRequest",
      PROJECT = "arvados#group",
      USER = "arvados#user",
      WORKFLOW = "arvados#workflow",
+     NONE = "arvados#none"
  }
  
  export enum ResourceObjectType {
@@@ -31,7 -37,6 +38,7 @@@
      CONTAINER = 'dz642',
      CONTAINER_REQUEST = 'xvhdp',
      GROUP = 'j7d0g',
 +    LOG = '57u5n',
      USER = 'tpzed',
  }
  
@@@ -61,8 -66,6 +68,8 @@@ export const extractUuidKind = (uuid: s
              return ResourceKind.CONTAINER_REQUEST;
          case ResourceObjectType.CONTAINER:
              return ResourceKind.CONTAINER;
 +        case ResourceObjectType.LOG:
 +            return ResourceKind.LOG;
          default:
              return undefined;
      }
diff --combined src/routes/routes.ts
index 6901d8755588acb9d5f1600f30cca934ca05566c,d1193218bd24ae4d0491b92bc6efc651954f26ec..05a8ab099ce1db395fb42f67561f330f3adae289
@@@ -6,9 -6,9 +6,9 @@@ import { History, Location } from 'hist
  import { RootStore } from '~/store/store';
  import { matchPath } from 'react-router';
  import { ResourceKind, RESOURCE_UUID_PATTERN, extractUuidKind } from '~/models/resource';
- import { getProjectUrl } from '../models/project';
+ import { getProjectUrl } from '~/models/project';
  import { getCollectionUrl } from '~/models/collection';
- import { loadProject, loadFavorites, loadCollection, loadProcessLog } from '~/store/workbench/workbench-actions';
 -import { loadProject, loadFavorites, loadCollection, loadTrash } from '~/store/workbench/workbench-actions';
++import { loadProject, loadFavorites, loadCollection, loadTrash, loadProcessLog } from '~/store/workbench/workbench-actions';
  import { loadProcess } from '~/store/processes/processes-actions';
  
  export const Routes = {
@@@ -18,7 -18,7 +18,8 @@@
      COLLECTIONS: `/collections/:id(${RESOURCE_UUID_PATTERN})`,
      PROCESSES: `/processes/:id(${RESOURCE_UUID_PATTERN})`,
      FAVORITES: '/favorites',
 -    TRASH: '/trash'
++    TRASH: '/trash',
 +    PROCESS_LOGS: `/process-logs/:id(${RESOURCE_UUID_PATTERN})`
  };
  
  export const getResourceUrl = (uuid: string) => {
  
  export const getProcessUrl = (uuid: string) => `/processes/${uuid}`;
  
 +export const getProcessLogUrl = (uuid: string) => `/process-logs/${uuid}`;
 +
  export const addRouteChangeHandlers = (history: History, store: RootStore) => {
      const handler = handleLocationChange(store);
      handler(history.location);
      history.listen(handler);
  };
  
+ export interface ResourceRouteParams {
+     id: string;
+ }
  export const matchRootRoute = (route: string) =>
      matchPath(route, { path: Routes.ROOT, exact: true });
  
  export const matchFavoritesRoute = (route: string) =>
      matchPath(route, { path: Routes.FAVORITES });
  
- export interface ResourceRouteParams {
-     id: string;
- }
+ export const matchTrashRoute = (route: string) =>
+     matchPath(route, { path: Routes.TRASH });
  
  export const matchProjectRoute = (route: string) =>
      matchPath<ResourceRouteParams>(route, { path: Routes.PROJECTS });
@@@ -62,25 -63,22 +66,28 @@@ export const matchCollectionRoute = (ro
  export const matchProcessRoute = (route: string) =>
      matchPath<ResourceRouteParams>(route, { path: Routes.PROCESSES });
  
 +export const matchProcessLogRoute = (route: string) =>
 +    matchPath<ResourceRouteParams>(route, { path: Routes.PROCESS_LOGS });
  
  const handleLocationChange = (store: RootStore) => ({ pathname }: Location) => {
      const projectMatch = matchProjectRoute(pathname);
      const collectionMatch = matchCollectionRoute(pathname);
      const favoriteMatch = matchFavoritesRoute(pathname);
+     const trashMatch = matchTrashRoute(pathname);
      const processMatch = matchProcessRoute(pathname);
 +    const processLogMatch = matchProcessLogRoute(pathname);
 +    
      if (projectMatch) {
          store.dispatch(loadProject(projectMatch.params.id));
      } else if (collectionMatch) {
          store.dispatch(loadCollection(collectionMatch.params.id));
      } else if (favoriteMatch) {
          store.dispatch(loadFavorites());
+     } else if (trashMatch) {
+         store.dispatch(loadTrash());
      } else if (processMatch) {
          store.dispatch(loadProcess(processMatch.params.id));
 +    } else if (processLogMatch) {
 +        store.dispatch(loadProcessLog(processLogMatch.params.id));
      }
  };
index 8c4b65d14866caf2b31b6479216819645bb299a4,3fb4004688abe3fc7b9c449b1729aa3aa8bbd9db..7b36b71cf42d7ce6ba289712eac1f2955b047ec4
@@@ -4,7 -4,7 +4,7 @@@
  
  import * as _ from "lodash";
  import { AxiosInstance, AxiosPromise } from "axios";
- import { Resource } from "~/models/resource";
+ import { Resource } from "src/models/resource";
  
  export interface ListArguments {
      limit?: number;
@@@ -38,7 -38,7 +38,7 @@@ export enum CommonResourceServiceError 
  
  export class CommonResourceService<T extends Resource> {
  
 -    static mapResponseKeys = (response: any): Promise<any> =>
 +    static mapResponseKeys = (response: { data: any }): Promise<any> =>
          CommonResourceService.mapKeys(_.camelCase)(response.data)
  
      static mapKeys = (mapFn: (key: string) => string) =>
index c3be8bdaa8a6ecfa58a1553b7de57483481ff86b,c3be8bdaa8a6ecfa58a1553b7de57483481ff86b..e1157f4b177e5ca18c9764c9bb249cf1467d7074
@@@ -16,7 -16,7 +16,7 @@@ describe("GroupsService", () => 
  
      it("#contents", async () => {
          axiosMock
--            .onGet("/groups/1/contents/")
++            .onGet("/groups/1/contents")
              .reply(200, {
                  kind: "kind",
                  offset: 2,
index c92475d55db24f77e81c8be0dd221aa6f409b663,0000000000000000000000000000000000000000..8f6c66c8a2ddbf24f9e02fb0fe84874036b23f6c
mode 100644,000000..100644
--- /dev/null
@@@ -1,13 -1,0 +1,13 @@@
- import { CommonResourceService } from "~/common/api/common-resource-service";
 +// Copyright (C) The Arvados Authors. All rights reserved.
 +//
 +// SPDX-License-Identifier: AGPL-3.0
 +
 +import { AxiosInstance } from "axios";
 +import { LogResource } from '~/models/log';
++import { CommonResourceService } from "~/services/common-service/common-resource-service";
 +
 +export class LogService extends CommonResourceService<LogResource> {
 +    constructor(serverApi: AxiosInstance) {
 +        super(serverApi, "logs");
 +    }
 +}
index 3440a3053dad930dd1db230e0b52e9100e690935,85e576112f60cc773aa471e248f30c79acb526c7..2b0e6f8f8bd2aad4192397459c79927862264166
@@@ -3,16 -3,15 +3,15 @@@
  // SPDX-License-Identifier: AGPL-3.0
  
  import { unionize, ofType, UnionOf } from '~/common/unionize';
- import { ContextMenuPosition, ContextMenuResource } from "./context-menu-reducer";
+ import { ContextMenuPosition } from "./context-menu-reducer";
  import { ContextMenuKind } from '~/views-components/context-menu/context-menu';
  import { Dispatch } from 'redux';
  import { RootState } from '~/store/store';
  import { getResource } from '../resources/resources';
  import { ProjectResource } from '~/models/project';
- import { UserResource } from '../../models/user';
+ import { UserResource } from '~/models/user';
  import { isSidePanelTreeCategory } from '~/store/side-panel-tree/side-panel-tree-actions';
- import { extractUuidKind, ResourceKind } from '~/models/resource';
- import { matchProcessRoute } from '~/routes/routes';
+ import { extractUuidKind, ResourceKind, TrashableResource } from '~/models/resource';
  
  export const contextMenuActions = unionize({
      OPEN_CONTEXT_MENU: ofType<{ position: ContextMenuPosition, resource: ContextMenuResource }>(),
  
  export type ContextMenuAction = UnionOf<typeof contextMenuActions>;
  
- export const openContextMenu = (event: React.MouseEvent<HTMLElement>, resource: { name: string; uuid: string; description?: string; kind: ContextMenuKind; }) =>
+ export type ContextMenuResource = {
+     name: string;
+     uuid: string;
+     ownerUuid: string;
+     description?: string;
+     kind: ResourceKind,
+     menuKind: ContextMenuKind;
+     isTrashed?: boolean;
+ };
+ export const openContextMenu = (event: React.MouseEvent<HTMLElement>, resource: ContextMenuResource) =>
      (dispatch: Dispatch) => {
          event.preventDefault();
          dispatch(
  
  export const openRootProjectContextMenu = (event: React.MouseEvent<HTMLElement>, projectUuid: string) =>
      (dispatch: Dispatch, getState: () => RootState) => {
-         const userResource = getResource<UserResource>(projectUuid)(getState().resources);
-         if (userResource) {
+         const res = getResource<UserResource>(projectUuid)(getState().resources);
+         if (res) {
              dispatch<any>(openContextMenu(event, {
                  name: '',
-                 uuid: userResource.uuid,
-                 kind: ContextMenuKind.ROOT_PROJECT
+                 uuid: res.uuid,
+                 ownerUuid: res.uuid,
+                 kind: res.kind,
+                 menuKind: ContextMenuKind.ROOT_PROJECT,
+                 isTrashed: false
              }));
          }
      };
  
  export const openProjectContextMenu = (event: React.MouseEvent<HTMLElement>, projectUuid: string) =>
      (dispatch: Dispatch, getState: () => RootState) => {
-         const projectResource = getResource<ProjectResource>(projectUuid)(getState().resources);
-         if (projectResource) {
+         const res = getResource<ProjectResource>(projectUuid)(getState().resources);
+         if (res) {
              dispatch<any>(openContextMenu(event, {
-                 name: projectResource.name,
-                 uuid: projectResource.uuid,
-                 kind: ContextMenuKind.PROJECT
+                 name: res.name,
+                 uuid: res.uuid,
+                 kind: res.kind,
+                 menuKind: ContextMenuKind.PROJECT,
+                 ownerUuid: res.ownerUuid,
+                 isTrashed: res.isTrashed
              }));
          }
      };
@@@ -70,18 -85,13 +85,20 @@@ export const openSidePanelContextMenu 
  
  export const openProcessContextMenu = (event: React.MouseEvent<HTMLElement>) =>
      (dispatch: Dispatch, getState: () => RootState) => {
 +        const { location } = getState().router;
 +        const pathname = location ? location.pathname : '';
 +        // ToDo: We get error from matchProcessRoute
 +        // const match = matchProcessRoute(pathname); 
 +        // console.log('match: ', match);
 +        // const uuid = match ? match.params.id : '';
 +        const uuid = pathname.split('/').slice(-1)[0];
          const resource = {
-             uuid,
+             uuid: '',
+             ownerUuid: '',
+             kind: ResourceKind.PROCESS,
              name: '',
              description: '',
-             kind: ContextMenuKind.PROCESS
+             menuKind: ContextMenuKind.PROCESS
          };
          dispatch<any>(openContextMenu(event, resource));
      };
index 6298e2511c48d12a77e549e70cc79135a3f24510,2c0f2097e127c883c595c5cb2e822609604ebead..ddb9d29ffdaa05a79f591773412f2723f4500337
@@@ -9,7 -9,7 +9,7 @@@ import { getCollectionUrl } from "~/mod
  import { getProjectUrl } from "~/models/project";
  
  import { SidePanelTreeCategory } from '../side-panel-tree/side-panel-tree-actions';
 -import { Routes, getProcessUrl } from '~/routes/routes';
 +import { Routes, getProcessUrl, getProcessLogUrl } from '~/routes/routes';
  
  export const navigateTo = (uuid: string) =>
      async (dispatch: Dispatch) => {
@@@ -20,7 -20,7 +20,7 @@@
              dispatch<any>(navigateToCollection(uuid));
          } else if (kind === ResourceKind.CONTAINER_REQUEST) {
              dispatch<any>(navigateToProcess(uuid));
 -        }
 +        } 
          if (uuid === SidePanelTreeCategory.FAVORITES) {
              dispatch<any>(navigateToFavorites);
          }
  
  export const navigateToFavorites = push(Routes.FAVORITES);
  
+ export const navigateToTrash = push(Routes.TRASH);
  export const navigateToProject = compose(push, getProjectUrl);
  
  export const navigateToCollection = compose(push, getCollectionUrl);
  
  export const navigateToProcess = compose(push, getProcessUrl);
 +
 +export const navigateToProcessLogs = compose(push, getProcessLogUrl);
index 62c9a25dd34a306b6d2b8395b054cc6a837309c6,0000000000000000000000000000000000000000..79c6434c5bdf23d63a6efa6bfc4c775a4690e950
mode 100644,000000..100644
--- /dev/null
@@@ -1,114 -1,0 +1,114 @@@
- import { FilterBuilder } from '~/common/api/filter-builder';
 +// Copyright (C) The Arvados Authors. All rights reserved.
 +//
 +// SPDX-License-Identifier: AGPL-3.0
 +
 +import { unionize, ofType, UnionOf } from "~/common/unionize";
 +import { ProcessLogs, getProcessLogsPanelCurrentUuid } from './process-logs-panel';
 +import { LogEventType } from '~/models/log';
 +import { RootState } from '~/store/store';
 +import { ServiceRepository } from '~/services/services';
 +import { Dispatch } from 'redux';
- import { OrderBuilder } from '~/common/api/order-builder';
 +import { groupBy } from 'lodash';
 +import { loadProcess } from '~/store/processes/processes-actions';
- import { ResourceEventMessage } from '../../websocket/resource-event-message';
 +import { LogResource } from '~/models/log';
 +import { LogService } from '~/services/log-service/log-service';
++import { ResourceEventMessage } from '~/websocket/resource-event-message';
 +import { getProcess } from '~/store/processes/process';
++import { FilterBuilder } from "~/services/api/filter-builder";
++import { OrderBuilder } from "~/services/api/order-builder";
 +
 +export const processLogsPanelActions = unionize({
 +    RESET_PROCESS_LOGS_PANEL: ofType<{}>(),
 +    INIT_PROCESS_LOGS_PANEL: ofType<{ filters: string[], logs: ProcessLogs }>(),
 +    SET_PROCESS_LOGS_PANEL_FILTER: ofType<string>(),
 +    ADD_PROCESS_LOGS_PANEL_ITEM: ofType<{ logType: string, log: string }>(),
 +});
 +
 +export type ProcessLogsPanelAction = UnionOf<typeof processLogsPanelActions>;
 +
 +export const setProcessLogsPanelFilter = (filter: string) =>
 +    processLogsPanelActions.SET_PROCESS_LOGS_PANEL_FILTER(filter);
 +
 +export const initProcessLogsPanel = (processUuid: string) =>
 +    async (dispatch: Dispatch, getState: () => RootState, { logService }: ServiceRepository) => {
 +        dispatch(processLogsPanelActions.RESET_PROCESS_LOGS_PANEL());
 +        const process = await dispatch<any>(loadProcess(processUuid));
 +        if (process.container) {
 +            const logResources = await loadContainerLogs(process.container.uuid, logService);
 +            const initialState = createInitialLogPanelState(logResources);
 +            dispatch(processLogsPanelActions.INIT_PROCESS_LOGS_PANEL(initialState));
 +        }
 +    };
 +
 +export const addProcessLogsPanelItem = (message: ResourceEventMessage<{ text: string }>) =>
 +    async (dispatch: Dispatch, getState: () => RootState, { logService }: ServiceRepository) => {
 +        if (PROCESS_PANEL_LOG_EVENT_TYPES.indexOf(message.eventType) > -1) {
 +            const uuid = getProcessLogsPanelCurrentUuid(getState());
 +            if (uuid) {
 +                const process = getProcess(uuid)(getState().resources);
 +                if (process) {
 +                    const { containerRequest, container } = process;
 +                    if (message.objectUuid === containerRequest.uuid
 +                        || container && message.objectUuid === container.uuid) {
 +                        dispatch(processLogsPanelActions.ADD_PROCESS_LOGS_PANEL_ITEM({
 +                            logType: SUMMARIZED_FILTER_TYPE,
 +                            log: message.properties.text
 +                        }));
 +                        dispatch(processLogsPanelActions.ADD_PROCESS_LOGS_PANEL_ITEM({
 +                            logType: message.eventType,
 +                            log: message.properties.text
 +                        }));
 +                    }
 +                }
 +            }
 +        }
 +    };
 +
 +const loadContainerLogs = async (containerUuid: string, logService: LogService) => {
 +    const requestFilters = new FilterBuilder()
 +        .addEqual('objectUuid', containerUuid)
 +        .addIn('eventType', PROCESS_PANEL_LOG_EVENT_TYPES)
 +        .getFilters();
 +    const requestOrder = new OrderBuilder<LogResource>()
 +        .addAsc('eventAt')
 +        .getOrder();
 +    const requestParams = {
 +        limit: MAX_AMOUNT_OF_LOGS,
 +        filters: requestFilters,
 +        order: requestOrder,
 +    };
 +    const { items } = await logService.list(requestParams);
 +    return items;
 +};
 +
 +const createInitialLogPanelState = (logResources: LogResource[]) => {
 +    const allLogs = logsToLines(logResources);
 +    const groupedLogResources = groupBy(logResources, log => log.eventType);
 +    const groupedLogs = Object
 +        .keys(groupedLogResources)
 +        .reduce((grouped, key) => ({
 +            ...grouped,
 +            [key]: logsToLines(groupedLogResources[key])
 +        }), {});
 +    const filters = [SUMMARIZED_FILTER_TYPE, ...Object.keys(groupedLogs)];
 +    const logs = { [SUMMARIZED_FILTER_TYPE]: allLogs, ...groupedLogs };
 +    return { filters, logs };
 +};
 +
 +const logsToLines = (logs: LogResource[]) =>
 +    logs.map(({ properties }) => properties.text);
 +
 +const MAX_AMOUNT_OF_LOGS = 10000;
 +
 +const SUMMARIZED_FILTER_TYPE = 'Summarized';
 +
 +const PROCESS_PANEL_LOG_EVENT_TYPES = [
 +    LogEventType.ARV_MOUNT,
 +    LogEventType.CRUNCH_RUN,
 +    LogEventType.CRUNCHSTAT,
 +    LogEventType.DISPATCH,
 +    LogEventType.HOSTSTAT,
 +    LogEventType.NODE_INFO,
 +    LogEventType.STDERR,
 +    LogEventType.STDOUT,
 +];
index d94cc01e4cad412fab9056885eba44efaf2ab018,f026d37ed3b06bae0aed2ebd8bfe24f2883ab2a9..031683a7e8af5a48fbf6067de0455c9ee31f2dd3
@@@ -6,27 -6,23 +6,27 @@@ import { Dispatch } from "redux"
  import { RootState } from '~/store/store';
  import { ServiceRepository } from '~/services/services';
  import { updateResources } from '~/store/resources/resources-actions';
- import { FilterBuilder } from '~/common/api/filter-builder';
+ import { FilterBuilder } from '~/services/api/filter-builder';
  import { ContainerRequestResource } from '../../models/container-request';
 +import { Process } from './process';
  
 -export const loadProcess = (uuid: string) =>
 -    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
 -        const containerRequest = await services.containerRequestService.get(uuid);
 +export const loadProcess = (containerRequestUuid: string) =>
 +    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository): Promise<Process> => {
 +        const containerRequest = await services.containerRequestService.get(containerRequestUuid);
          dispatch<any>(updateResources([containerRequest]));
          if (containerRequest.containerUuid) {
              const container = await services.containerService.get(containerRequest.containerUuid);
              dispatch<any>(updateResources([container]));
 +            await dispatch<any>(loadSubprocesses(containerRequest.containerUuid));
 +            return { containerRequest, container };
          }
 +        return { containerRequest };
      };
  
 -export const loadSubprocesses = (uuid: string) =>
 +export const loadSubprocesses = (containerUuid: string) =>
      async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
          const containerRequests = await dispatch<any>(loadContainerRequests(
 -            new FilterBuilder().addEqual('requestingContainerUuid', uuid).getFilters()
 +            new FilterBuilder().addEqual('requestingContainerUuid', containerUuid).getFilters()
          )) as ContainerRequestResource[];
  
          const containerUuids: string[] = containerRequests.reduce((uuids, { containerUuid }) =>
diff --combined src/store/store.ts
index d0c0dd67b10453d3a11191ecd7418e10dc7b00bc,d2371e831768cb3b41811725709174a7416a9861..01aca598be44d384a052e4d4d5f85c6955e2d9b8
@@@ -28,7 -28,8 +28,9 @@@ import { resourcesReducer } from '~/sto
  import { propertiesReducer } from './properties/properties-reducer';
  import { RootState } from './store';
  import { fileUploaderReducer } from './file-uploader/file-uploader-reducer';
+ import { TrashPanelMiddlewareService } from "~/store/trash-panel/trash-panel-middleware-service";
+ import { TRASH_PANEL_ID } from "~/store/trash-panel/trash-panel-action";
 +import { processLogsPanelReducer } from './process-logs-panel/process-logs-panel-reducer';
  
  const composeEnhancers =
      (process.env.NODE_ENV === 'development' &&
@@@ -48,12 -49,16 +50,16 @@@ export function configureStore(history
      const favoritePanelMiddleware = dataExplorerMiddleware(
          new FavoritePanelMiddlewareService(services, FAVORITE_PANEL_ID)
      );
+     const trashPanelMiddleware = dataExplorerMiddleware(
+         new TrashPanelMiddlewareService(services, TRASH_PANEL_ID)
+     );
  
      const middlewares: Middleware[] = [
          routerMiddleware(history),
          thunkMiddleware.withExtraArgument(services),
          projectPanelMiddleware,
-         favoritePanelMiddleware
+         favoritePanelMiddleware,
+         trashPanelMiddleware
      ];
      const enhancer = composeEnhancers(applyMiddleware(...middlewares));
      return createStore(rootReducer, enhancer);
@@@ -69,7 -74,6 +75,7 @@@ const createRootReducer = (services: Se
      dialog: dialogReducer,
      favorites: favoritesReducer,
      form: formReducer,
 +    processLogsPanel: processLogsPanelReducer,
      properties: propertiesReducer,
      resources: resourcesReducer,
      router: routerReducer,
index 8c7ec9a30f96a25ed699a38d78a0a6c67eb32760,1ff3a5b56b1297d8e9fa66ba2003ade21656b723..80f50fe153744382832c81aa34d63f8976ea2869
@@@ -30,7 -30,8 +30,9 @@@ import * as collectionUpdateActions fro
  import * as collectionMoveActions from '~/store/collections/collection-move-actions';
  import * as processesActions from '../processes/processes-actions';
  import { getProcess } from '../processes/process';
+ import { trashPanelColumns } from "~/views/trash-panel/trash-panel";
+ import { loadTrashPanel, trashPanelActions } from "~/store/trash-panel/trash-panel-action";
 +import { initProcessLogsPanel } from '../process-logs-panel/process-logs-panel-actions';
  
  
  export const loadWorkbench = () =>
@@@ -42,6 -43,7 +44,7 @@@
              if (userResource) {
                  dispatch(projectPanelActions.SET_COLUMNS({ columns: projectPanelColumns }));
                  dispatch(favoritePanelActions.SET_COLUMNS({ columns: favoritePanelColumns }));
+                 dispatch(trashPanelActions.SET_COLUMNS({ columns: trashPanelColumns }));
                  dispatch<any>(initSidePanelTree());
                  if (router.location) {
                      const match = matchRootRoute(router.location.pathname);
@@@ -64,6 -66,12 +67,12 @@@ export const loadFavorites = () =
          dispatch<any>(setSidePanelBreadcrumbs(SidePanelTreeCategory.FAVORITES));
      };
  
+ export const loadTrash = () =>
+     (dispatch: Dispatch) => {
+         dispatch<any>(activateSidePanelTreeItem(SidePanelTreeCategory.TRASH));
+         dispatch<any>(loadTrashPanel());
+         dispatch<any>(setSidePanelBreadcrumbs(SidePanelTreeCategory.TRASH));
+     };
  
  export const loadProject = (uuid: string) =>
      async (dispatch: Dispatch) => {
@@@ -186,11 -194,6 +195,11 @@@ export const loadProcess = (uuid: strin
          }
      };
  
 +export const loadProcessLog = (uuid: string) =>
 +    async (dispatch: Dispatch) => {
 +        dispatch<any>(initProcessLogsPanel(uuid));
 +    };
 +
  export const resourceIsNotLoaded = (uuid: string) =>
      snackbarActions.OPEN_SNACKBAR({
          message: `Resource identified by ${uuid} is not loaded.`
@@@ -204,10 -207,10 +213,10 @@@ export const couldNotLoadUser = snackba
      message: 'Could not load user'
  });
  
- const reloadProjectMatchingUuid = (matchingUuids: string[]) =>
export const reloadProjectMatchingUuid = (matchingUuids: string[]) =>
      async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
          const currentProjectPanelUuid = getProjectPanelCurrentUuid(getState());
          if (currentProjectPanelUuid && matchingUuids.some(uuid => uuid === currentProjectPanelUuid)) {
              dispatch<any>(loadProject(currentProjectPanelUuid));
          }
-     };
+     };
index d92948c84ae27db97540147cee89de4c59e3ba6e,2df776e59e8fe3ac54f41dabf969bd4241d6c40b..a545b2bdddfb447ebe5bf43ee768a841d52d36e8
@@@ -4,10 -4,9 +4,9 @@@
  
  import { connect } from "react-redux";
  import { RootState } from "~/store/store";
- import { contextMenuActions } from "~/store/context-menu/context-menu-actions";
+ import { contextMenuActions, ContextMenuResource } from "~/store/context-menu/context-menu-actions";
  import { ContextMenu as ContextMenuComponent, ContextMenuProps, ContextMenuItem } from "~/components/context-menu/context-menu";
  import { createAnchorAt } from "~/components/popover/helpers";
- import { ContextMenuResource } from "~/store/context-menu/context-menu-reducer";
  import { ContextMenuActionSet, ContextMenuAction } from "./context-menu-action-set";
  import { Dispatch } from "redux";
  
@@@ -52,7 -51,7 +51,7 @@@ export const addMenuActionSet = (name: 
  };
  
  const getMenuActionSet = (resource?: ContextMenuResource): ContextMenuActionSet => {
-     return resource ? menuActionSets.get(resource.kind) || [] : [];
+     return resource ? menuActionSets.get(resource.menuKind) || [] : [];
  };
  
  export enum ContextMenuKind {
      PROJECT = "Project",
      RESOURCE = "Resource",
      FAVORITE = "Favorite",
+     TRASH = "Trash",
      COLLECTION_FILES = "CollectionFiles",
      COLLECTION_FILES_ITEM = "CollectionFilesItem",
      COLLECTION = 'Collection',
      COLLECTION_RESOURCE = 'CollectionResource',
 -    PROCESS = "Process"
 +    PROCESS = "Process",
 +    PROCESS_LOGS = "ProcessLogs"
  }
index 21396d1d491306106bbe7210709ccd43ab69f2ef,68bb970073317f00844fdf04e74a388920d527f2..3c281087c2addf20ad8f3f6e7e31500673a28782
@@@ -27,7 -27,6 +27,7 @@@ import { MultipleFilesRemoveDialog } fr
  import { Routes } from '~/routes/routes';
  import { SidePanel } from '~/views-components/side-panel/side-panel';
  import { ProcessPanel } from '~/views/process-panel/process-panel';
 +import { ProcessLogPanel } from '~/views/process-log-panel/process-log-panel';
  import { Breadcrumbs } from '~/views-components/breadcrumbs/breadcrumbs';
  import { CreateProjectDialog } from '~/views-components/dialog-forms/create-project-dialog';
  import { CreateCollectionDialog } from '~/views-components/dialog-forms/create-collection-dialog';
@@@ -36,9 -35,12 +36,11 @@@ import { UpdateCollectionDialog } from 
  import { UpdateProjectDialog } from '~/views-components/dialog-forms/update-project-dialog';
  import { MoveProjectDialog } from '~/views-components/dialog-forms/move-project-dialog';
  import { MoveCollectionDialog } from '~/views-components/dialog-forms/move-collection-dialog';
 -
  import { FilesUploadCollectionDialog } from '~/views-components/dialog-forms/files-upload-collection-dialog';
  import { PartialCopyCollectionDialog } from '~/views-components/dialog-forms/partial-copy-collection-dialog';
  
+ import { TrashPanel } from "~/views/trash-panel/trash-panel";
  const APP_BAR_HEIGHT = 100;
  
  type CssRules = 'root' | 'appBar' | 'content' | 'contentWrapper';
@@@ -167,7 -169,7 +169,8 @@@ export const Workbench = withStyles(sty
                                      <Route path={Routes.COLLECTIONS} component={CollectionPanel} />
                                      <Route path={Routes.FAVORITES} component={FavoritePanel} />
                                      <Route path={Routes.PROCESSES} component={ProcessPanel} />
+                                     <Route path={Routes.TRASH} component={TrashPanel} />
 +                                    <Route path={Routes.PROCESS_LOGS} component={ProcessLogPanel} />
                                  </Switch>
                              </div>
                              {user && <DetailsPanel />}
index 77c1fd323f9c24f2ff88514712cadaaaef3a4a1d,0000000000000000000000000000000000000000..a5ce13d7982aec92130cae83110fda3e0f5134b9
mode 100644,000000..100644
--- /dev/null
@@@ -1,47 -1,0 +1,47 @@@
- import { CommonResourceService } from '~/common/api/common-resource-service';
 +// Copyright (C) The Arvados Authors. All rights reserved.
 +//
 +// SPDX-License-Identifier: AGPL-3.0
 +
 +import { AuthService } from '~/services/auth-service/auth-service';
 +import { ResourceEventMessage } from './resource-event-message';
 +import { camelCase } from 'lodash';
++import { CommonResourceService } from "~/services/common-service/common-resource-service";
 +
 +type MessageListener = (message: ResourceEventMessage) => void;
 +
 +export class WebSocketService {
 +    private ws: WebSocket;
 +    private messageListener: MessageListener;
 +
 +    constructor(private url: string, private authService: AuthService) { }
 +
 +    connect() {
 +        if (this.ws) {
 +            this.ws.close();
 +        }
 +        this.ws = new WebSocket(this.getUrl());
 +        this.ws.addEventListener('message', this.handleMessage);
 +        this.ws.addEventListener('open', this.handleOpen);
 +    }
 +
 +    setMessageListener = (listener: MessageListener) => {
 +        this.messageListener = listener;
 +    }
 +
 +    private getUrl() {
 +        return `${this.url}?api_token=${this.authService.getApiToken()}`;
 +    }
 +
 +    private handleMessage = (event: MessageEvent) => {
 +        if (this.messageListener) {
 +            const data = JSON.parse(event.data);
 +            const message = CommonResourceService.mapKeys(camelCase)(data);
 +            this.messageListener(message);
 +        }
 +    }
 +
 +    private handleOpen = () => {
 +        this.ws.send('{"method":"subscribe"}');
 +    }
 +
 +}
index 634fa8f979b4387b0acdc8c23740ce33459e4ddc,0000000000000000000000000000000000000000..e3f1e192023e8acda5aa84db991010b48a83d3e4
mode 100644,000000..100644
--- /dev/null
@@@ -1,38 -1,0 +1,38 @@@
- import { loadContainers } from '../store/processes/processes-actions';
- import { FilterBuilder } from '~/common/api/filter-builder';
- import { LogEventType } from '../models/log';
 +// Copyright (C) The Arvados Authors. All rights reserved.
 +//
 +// SPDX-License-Identifier: AGPL-3.0
 +
 +import { RootStore } from '~/store/store';
 +import { AuthService } from '~/services/auth-service/auth-service';
 +import { Config } from '~/common/config';
 +import { WebSocketService } from './websocket-service';
 +import { ResourceEventMessage } from './resource-event-message';
 +import { ResourceKind } from '~/models/resource';
 +import { loadProcess } from '~/store/processes/processes-actions';
++import { loadContainers } from '~/store/processes/processes-actions';
++import { LogEventType } from '~/models/log';
 +import { addProcessLogsPanelItem } from '../store/process-logs-panel/process-logs-panel-actions';
++import { FilterBuilder } from "~/services/api/filter-builder";
 +
 +export const initWebSocket = (config: Config, authService: AuthService, store: RootStore) => {
 +    const webSocketService = new WebSocketService(config.websocketUrl, authService);
 +    webSocketService.setMessageListener(messageListener(store));
 +    webSocketService.connect();
 +};
 +
 +const messageListener = (store: RootStore) => (message: ResourceEventMessage) => {
 +    if (message.eventType === LogEventType.CREATE || message.eventType === LogEventType.UPDATE) {
 +        switch (message.objectKind) {
 +            case ResourceKind.CONTAINER_REQUEST:
 +                return store.dispatch(loadProcess(message.objectUuid));
 +            case ResourceKind.CONTAINER:
 +                return store.dispatch(loadContainers(
 +                    new FilterBuilder().addIn('uuid', [message.objectUuid]).getFilters()
 +                ));
 +            default:
 +                return;
 +        }
 +    } else {
 +        return store.dispatch(addProcessLogsPanelItem(message as ResourceEventMessage<{text: string}>));
 +    }
 +};