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>

40 files changed:
src/common/custom-theme.ts
src/common/formatters.ts
src/components/code-snippet/code-snippet.tsx [new file with mode: 0644]
src/components/data-explorer/data-explorer.test.tsx
src/components/default-code-snippet/default-code-snippet.tsx [new file with mode: 0644]
src/components/icon/icon.tsx
src/index.css
src/index.tsx
src/models/log.ts [new file with mode: 0644]
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 [new file with mode: 0644]
src/services/services.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 [new file with mode: 0644]
src/store/process-logs-panel/process-logs-panel-reducer.ts [new file with mode: 0644]
src/store/process-logs-panel/process-logs-panel.ts [new file with mode: 0644]
src/store/processes/process.ts
src/store/processes/processes-actions.ts
src/store/store.ts
src/store/workbench/workbench-actions.ts
src/views-components/context-menu/action-sets/process-action-set.ts
src/views-components/context-menu/context-menu.tsx
src/views/process-log-panel/process-log-code-snippet.tsx [new file with mode: 0644]
src/views/process-log-panel/process-log-form.tsx [new file with mode: 0644]
src/views/process-log-panel/process-log-main-card.tsx [new file with mode: 0644]
src/views/process-log-panel/process-log-panel-root.tsx [new file with mode: 0644]
src/views/process-log-panel/process-log-panel.tsx [new file with mode: 0644]
src/views/process-panel/process-information-card.tsx
src/views/process-panel/process-panel-root.tsx
src/views/process-panel/process-panel.tsx
src/views/process-panel/process-subprocesses-card.tsx [new file with mode: 0644]
src/views/process-panel/process-subprocesses.tsx [new file with mode: 0644]
src/views/workbench/workbench.tsx
src/websocket/resource-event-message.ts [new file with mode: 0644]
src/websocket/websocket-service.ts [new file with mode: 0644]
src/websocket/websocket.ts [new file with mode: 0644]

index 2b0c58918f11270ef786e7d5a99d06f8bd61e001..c4d3dbc35894537d2d66cda9926a2e56f74fb7cc 100644 (file)
@@ -11,7 +11,7 @@ import green from '@material-ui/core/colors/green';
 import yellow from '@material-ui/core/colors/yellow';
 import red from '@material-ui/core/colors/red';
 
-interface ArvadosThemeOptions extends ThemeOptions {
+export interface ArvadosThemeOptions extends ThemeOptions {
     customs: any;
 }
 
@@ -24,6 +24,9 @@ export interface ArvadosTheme extends Theme {
 interface Colors {
     green700: string;
     yellow700: string;
+    red900: string;
+    blue500: string;
+    grey500: string;
 }
 
 const red900 = red["900"];
@@ -36,11 +39,14 @@ const grey700 = grey["700"];
 const grey900 = grey["900"];
 const rocheBlue = '#06C';
 
-const themeOptions: ArvadosThemeOptions = {
+export const themeOptions: ArvadosThemeOptions = {
     customs: {
         colors: {
             green700: green["700"],
-            yellow700: yellow["700"]
+            yellow700: yellow["700"],
+            red900: red['900'],
+            blue500: blue['500'],
+            grey500,
         }
     },
     overrides: {
index b1baee7de912e56a4b22b683cd172dfdea542a1c..e2097878a9f98276ffe1d78cf49ac6ca4468d894 100644 (file)
@@ -22,6 +22,17 @@ 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)}%`;
diff --git a/src/components/code-snippet/code-snippet.tsx b/src/components/code-snippet/code-snippet.tsx
new file mode 100644 (file)
index 0000000..b622210
--- /dev/null
@@ -0,0 +1,37 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import { StyleRulesCallback, WithStyles, Typography, withStyles, Theme } from '@material-ui/core';
+import { ArvadosTheme } from '~/common/custom-theme';
+
+type CssRules = 'root';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    root: {
+        boxSizing: 'border-box',
+        width: '100%',
+        height: 'auto',
+        maxHeight: '550px',
+        overflow: 'scroll',
+        padding: theme.spacing.unit
+    }
+});
+
+export interface CodeSnippetDataProps {
+    lines: string[];
+}
+
+type CodeSnippetProps = CodeSnippetDataProps & WithStyles<CssRules>;
+
+export const CodeSnippet = withStyles(styles)(
+    ({ classes, lines }: CodeSnippetProps) =>
+        <Typography component="div" className={classes.root}>
+            {
+                lines.map((line: string, index: number) => {
+                    return <Typography key={index} component="pre">{line}</Typography>;
+                })
+            }
+        </Typography>
+    );
\ No newline at end of file
index 3e447b4015480091b66e6ee39f5b6d5560ea6cc6..882c178be5ef5550629412f5116a7ab3f4e6d50f 100644 (file)
@@ -124,4 +124,5 @@ const mockDataExplorerProps = () => ({
     defaultIcon: ProjectIcon,
     onSetColumns: jest.fn(),
     defaultMessages: ['testing'],
+    contextMenuColumn: true
 });
diff --git a/src/components/default-code-snippet/default-code-snippet.tsx b/src/components/default-code-snippet/default-code-snippet.tsx
new file mode 100644 (file)
index 0000000..541f390
--- /dev/null
@@ -0,0 +1,31 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import { MuiThemeProvider, createMuiTheme } from '@material-ui/core/styles';
+import { CodeSnippet, CodeSnippetDataProps } from '~/components/code-snippet/code-snippet';
+import grey from '@material-ui/core/colors/grey';
+
+const theme = createMuiTheme({
+    overrides: {
+        MuiTypography: {
+            body1: {
+                color: grey["900"]
+            },
+            root: {
+                backgroundColor: grey["200"]
+            }
+        }
+    },
+    typography: {
+        fontFamily: 'VT323'
+    }
+});
+
+type DefaultCodeSnippet = CodeSnippetDataProps;
+
+export const DefaultCodeSnippet = (props: DefaultCodeSnippet) => 
+    <MuiThemeProvider theme={theme}>
+        <CodeSnippet lines={props.lines} />
+    </MuiThemeProvider>;
\ No newline at end of file
index 2af70d840c9bcb9ce8f60a71cf262285f37f6c6b..90861bfed6aca96b1a4fc9e49db749f628e543c0 100644 (file)
@@ -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';
@@ -42,6 +43,7 @@ export type IconType = React.SFC<{ className?: string }>;
 
 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} />;
index abbd2b21270f17a5b5948580a96ae78e5b6ecb4a..0172d68bcc5c9385ebdbf29d7a06a7022ef08c96 100644 (file)
@@ -1,7 +1,7 @@
 body {
     margin: 0;
     padding: 0;
-    font-family: sans-serif;
+    font-family: 'Roboto', "Helvetica", "Arial", sans-serif;
     width: 100vw;
     height: 100vh;
 }
index 6137e26dd9319e83d4973280c8dab04b9e209e1d..4ce80d31e9d0f0360e6dd7051b113f9dbfe4d40c 100644 (file)
@@ -32,6 +32,9 @@ 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);
@@ -58,8 +61,7 @@ fetchConfig()
         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} />;
@@ -85,12 +87,13 @@ fetchConfig()
 
     });
 
-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 --git a/src/models/log.ts b/src/models/log.ts
new file mode 100644 (file)
index 0000000..2988656
--- /dev/null
@@ -0,0 +1,28 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Resource } from "./resource";
+import { ResourceKind } from '~/models/resource';
+
+export enum LogEventType {
+    CREATE = 'create',
+    UPDATE = 'update',
+    DISPATCH = 'dispatch',
+    CRUNCH_RUN = 'crunch-run',
+    CRUNCHSTAT = 'crunchstat',
+    HOSTSTAT = 'hoststat',
+    NODE_INFO = 'node-info',
+    ARV_MOUNT = 'arv-mount',
+    STDOUT = 'stdout',
+    STDERR = 'stderr',
+}
+
+export interface LogResource extends Resource {
+    kind: ResourceKind.LOG;
+    objectUuid: string;
+    eventAt: string;
+    eventType: string;
+    summary: string;
+    properties: any;
+}
index 4d7031a455115a58b13684f364115e749e2915df..698bcf73188ec21c375a349d3f2605953bbd2074 100644 (file)
@@ -25,6 +25,7 @@ export enum ResourceKind {
     CONTAINER = "arvados#container",
     CONTAINER_REQUEST = "arvados#containerRequest",
     GROUP = "arvados#group",
+    LOG = "arvados#log",
     PROCESS = "arvados#containerRequest",
     PROJECT = "arvados#group",
     USER = "arvados#user",
@@ -37,6 +38,7 @@ export enum ResourceObjectType {
     CONTAINER = 'dz642',
     CONTAINER_REQUEST = 'xvhdp',
     GROUP = 'j7d0g',
+    LOG = '57u5n',
     USER = 'tpzed',
 }
 
@@ -66,6 +68,8 @@ export const extractUuidKind = (uuid: string = '') => {
             return ResourceKind.CONTAINER_REQUEST;
         case ResourceObjectType.CONTAINER:
             return ResourceKind.CONTAINER;
+        case ResourceObjectType.LOG:
+            return ResourceKind.LOG;
         default:
             return undefined;
     }
index d1193218bd24ae4d0491b92bc6efc651954f26ec..05a8ab099ce1db395fb42f67561f330f3adae289 100644 (file)
@@ -8,7 +8,7 @@ import { matchPath } from 'react-router';
 import { ResourceKind, RESOURCE_UUID_PATTERN, extractUuidKind } from '~/models/resource';
 import { getProjectUrl } from '~/models/project';
 import { getCollectionUrl } from '~/models/collection';
-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,8 @@ export const Routes = {
     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) => {
@@ -35,6 +36,8 @@ 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);
@@ -63,6 +66,8 @@ export const matchCollectionRoute = (route: string) =>
 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);
@@ -70,6 +75,8 @@ const handleLocationChange = (store: RootStore) => ({ pathname }: Location) => {
     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) {
@@ -80,5 +87,7 @@ const handleLocationChange = (store: RootStore) => ({ pathname }: Location) => {
         store.dispatch(loadTrash());
     } else if (processMatch) {
         store.dispatch(loadProcess(processMatch.params.id));
+    } else if (processLogMatch) {
+        store.dispatch(loadProcessLog(processLogMatch.params.id));
     }
 };
index 3fb4004688abe3fc7b9c449b1729aa3aa8bbd9db..7b36b71cf42d7ce6ba289712eac1f2955b047ec4 100644 (file)
@@ -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..e1157f4b177e5ca18c9764c9bb249cf1467d7074 100644 (file)
@@ -16,7 +16,7 @@ describe("GroupsService", () => {
 
     it("#contents", async () => {
         axiosMock
-            .onGet("/groups/1/contents/")
+            .onGet("/groups/1/contents")
             .reply(200, {
                 kind: "kind",
                 offset: 2,
diff --git a/src/services/log-service/log-service.ts b/src/services/log-service/log-service.ts
new file mode 100644 (file)
index 0000000..8f6c66c
--- /dev/null
@@ -0,0 +1,13 @@
+// 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 32e7bd18b2ff9bd54ee1305af28e132e5da4cfe9..53721dd301b64399d77775c3ec09b65240a6c564 100644 (file)
@@ -19,6 +19,7 @@ import { AncestorService } from "~/services/ancestors-service/ancestors-service"
 import { ResourceKind } from "~/models/resource";
 import { ContainerRequestService } from './container-request-service/container-request-service';
 import { ContainerService } from './container-service/container-service';
+import { LogService } from './log-service/log-service';
 
 export type ServiceRepository = ReturnType<typeof createServices>;
 
@@ -29,13 +30,14 @@ export const createServices = (config: Config) => {
     const webdavClient = new WebDAV();
     webdavClient.defaults.baseURL = config.keepWebServiceUrl;
 
+    const containerRequestService = new ContainerRequestService(apiClient);
+    const containerService = new ContainerService(apiClient);
     const groupsService = new GroupsService(apiClient);
     const keepService = new KeepService(apiClient);
     const linkService = new LinkService(apiClient);
+    const logService = new LogService(apiClient);
     const projectService = new ProjectService(apiClient);
     const userService = new UserService(apiClient);
-    const containerRequestService = new ContainerRequestService(apiClient);
-    const containerService = new ContainerService(apiClient);
     
     const ancestorsService = new AncestorService(groupsService, userService);
     const authService = new AuthService(apiClient, config.rootUrl);
@@ -56,6 +58,7 @@ export const createServices = (config: Config) => {
         groupsService,
         keepService,
         linkService,
+        logService,
         projectService,
         tagService,
         userService,
index 85e576112f60cc773aa471e248f30c79acb526c7..2b0e6f8f8bd2aad4192397459c79927862264166 100644 (file)
@@ -85,6 +85,13 @@ export const openSidePanelContextMenu = (event: React.MouseEvent<HTMLElement>, i
 
 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: '',
             ownerUuid: '',
index 2c0f2097e127c883c595c5cb2e822609604ebead..ddb9d29ffdaa05a79f591773412f2723f4500337 100644 (file)
@@ -9,7 +9,7 @@ import { getCollectionUrl } from "~/models/collection";
 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 @@ export const navigateTo = (uuid: string) =>
             dispatch<any>(navigateToCollection(uuid));
         } else if (kind === ResourceKind.CONTAINER_REQUEST) {
             dispatch<any>(navigateToProcess(uuid));
-        }
+        } 
         if (uuid === SidePanelTreeCategory.FAVORITES) {
             dispatch<any>(navigateToFavorites);
         }
@@ -35,3 +35,5 @@ export const navigateToProject = compose(push, getProjectUrl);
 export const navigateToCollection = compose(push, getCollectionUrl);
 
 export const navigateToProcess = compose(push, getProcessUrl);
+
+export const navigateToProcessLogs = compose(push, getProcessLogUrl);
\ No newline at end of file
diff --git a/src/store/process-logs-panel/process-logs-panel-actions.ts b/src/store/process-logs-panel/process-logs-panel-actions.ts
new file mode 100644 (file)
index 0000000..79c6434
--- /dev/null
@@ -0,0 +1,114 @@
+// 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 { groupBy } from 'lodash';
+import { loadProcess } from '~/store/processes/processes-actions';
+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,
+];
diff --git a/src/store/process-logs-panel/process-logs-panel-reducer.ts b/src/store/process-logs-panel/process-logs-panel-reducer.ts
new file mode 100644 (file)
index 0000000..38a3136
--- /dev/null
@@ -0,0 +1,34 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { ProcessLogsPanel } from './process-logs-panel';
+import { RootState } from '~/store/store';
+import { ProcessLogsPanelAction, processLogsPanelActions } from './process-logs-panel-actions';
+
+const initialState: ProcessLogsPanel = {
+    filters: [],
+    selectedFilter: '',
+    logs: { '': [] },
+};
+
+export const processLogsPanelReducer = (state = initialState, action: ProcessLogsPanelAction): ProcessLogsPanel =>
+    processLogsPanelActions.match(action, {
+        RESET_PROCESS_LOGS_PANEL: () => initialState,
+        INIT_PROCESS_LOGS_PANEL: ({ filters, logs }) => ({
+            filters,
+            logs,
+            selectedFilter: filters[0] || '',
+        }),
+        SET_PROCESS_LOGS_PANEL_FILTER: selectedFilter => ({
+            ...state,
+            selectedFilter
+        }),
+        ADD_PROCESS_LOGS_PANEL_ITEM: ({ logType, log }) => {
+            const currentLogs = state.logs[logType] || [];
+            const logsOfType = [...currentLogs, log];
+            const logs = { ...state.logs, [logType]: logsOfType };
+            return { ...state, logs };
+        },
+        default: () => state,
+    });
diff --git a/src/store/process-logs-panel/process-logs-panel.ts b/src/store/process-logs-panel/process-logs-panel.ts
new file mode 100644 (file)
index 0000000..e0753cd
--- /dev/null
@@ -0,0 +1,25 @@
+import { RootState } from '../store';
+import { matchProcessLogRoute } from '~/routes/routes';
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+export interface ProcessLogsPanel {
+    filters: string[];
+    selectedFilter: string;
+    logs: ProcessLogs;
+}
+
+export interface ProcessLogs {
+    [logType: string]: string[];
+}
+
+export const getProcessPanelLogs = ({ selectedFilter, logs }: ProcessLogsPanel) => {
+    return logs[selectedFilter];
+};
+
+export const getProcessLogsPanelCurrentUuid = ({ router }: RootState) => {
+    const pathname = router.location ? router.location.pathname : '';
+    const match = matchProcessLogRoute(pathname);
+    return match ? match.params.id : undefined;
+};
index 46d8a257172ec011e3fd163a61d96309cf3f7b37..467fc8a92ef83a7c7dd3358ee3f9d860ff3e56fa 100644 (file)
@@ -7,6 +7,9 @@ import { ContainerResource } from '../../models/container';
 import { ResourcesState, getResource } from '~/store/resources/resources';
 import { filterResources } from '../resources/resources';
 import { ResourceKind, Resource } from '~/models/resource';
+import { getTimeDiff } from '~/common/formatters';
+import { SubprocessesStatus } from '~/views/process-panel/process-subprocesses-card';
+import { ArvadosTheme } from '~/common/custom-theme';
 
 export interface Process {
     containerRequest: ContainerRequestResource;
@@ -28,13 +31,39 @@ export const getProcess = (uuid: string) => (resources: ResourcesState): Process
 };
 
 export const getSubprocesses = (uuid: string) => (resources: ResourcesState) => {
-    const containerRequests = filterResources(isSubprocess(uuid))(resources) as ContainerRequestResource[];
-    return containerRequests.reduce((subprocesses, { uuid }) => {
-        const process = getProcess(uuid)(resources);
-        return process
-            ? [...subprocesses, process]
-            : subprocesses;
-    }, []);
+    const process = getProcess(uuid)(resources);
+    if (process && process.container) {
+        const containerRequests = filterResources(isSubprocess(process.container.uuid))(resources) as ContainerRequestResource[];
+        return containerRequests.reduce((subprocesses, { uuid }) => {
+            const process = getProcess(uuid)(resources);
+            return process
+                ? [...subprocesses, process]
+                : subprocesses;
+        }, []);
+    }
+    return [];
+};
+
+export const getProcessRuntime = ({ container }: Process) =>
+    container
+        ? getTimeDiff(container.finishedAt || '', container.startedAt || '')
+        : 0;
+
+export const getProcessStatusColor = (status: string, { customs }: ArvadosTheme) => {
+    switch (status) {
+        case SubprocessesStatus.COMPLETED:
+            return customs.colors.green700;
+        case SubprocessesStatus.CANCELED:
+            return customs.colors.red900;
+        case SubprocessesStatus.QUEUED:
+            return customs.colors.grey500;
+        case SubprocessesStatus.FAILED:
+            return customs.colors.red900;
+        case SubprocessesStatus.ACTIVE:
+            return customs.colors.blue500;
+        default:
+            return customs.colors.grey500;
+    }
 };
 
 export const getProcessStatus = (process: Process) =>
@@ -42,6 +71,7 @@ export const getProcessStatus = (process: Process) =>
         ? process.container.state
         : process.containerRequest.state;
 
-const isSubprocess = (uuid: string) => (resource: Resource) =>
+const isSubprocess = (containerUuid: string) => (resource: Resource) =>
     resource.kind === ResourceKind.CONTAINER_REQUEST
-    && (resource as ContainerRequestResource).requestingContainerUuid === uuid;
+    && (resource as ContainerRequestResource).requestingContainerUuid === containerUuid;
+
index f026d37ed3b06bae0aed2ebd8bfe24f2883ab2a9..031683a7e8af5a48fbf6067de0455c9ee31f2dd3 100644 (file)
@@ -8,21 +8,25 @@ import { ServiceRepository } from '~/services/services';
 import { updateResources } from '~/store/resources/resources-actions';
 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 }) =>
index d2371e831768cb3b41811725709174a7416a9861..01aca598be44d384a052e4d4d5f85c6955e2d9b8 100644 (file)
@@ -30,6 +30,7 @@ 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' &&
@@ -74,6 +75,7 @@ const createRootReducer = (services: ServiceRepository) => combineReducers({
     dialog: dialogReducer,
     favorites: favoritesReducer,
     form: formReducer,
+    processLogsPanel: processLogsPanelReducer,
     properties: propertiesReducer,
     resources: resourcesReducer,
     router: routerReducer,
index 1ff3a5b56b1297d8e9fa66ba2003ade21656b723..80f50fe153744382832c81aa34d63f8976ea2869 100644 (file)
@@ -32,6 +32,7 @@ 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 = () =>
@@ -194,6 +195,11 @@ export const loadProcess = (uuid: string) =>
         }
     };
 
+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.`
index 5d679f52b04e4e89d845acfe61ecbda504c4dea6..cc4f8e8fd8732eeb7884b12acccf240dcd387499 100644 (file)
@@ -10,6 +10,7 @@ import {
     AdvancedIcon, RemoveIcon, ReRunProcessIcon, LogIcon
 } from "~/components/icon/icon";
 import { favoritePanelActions } from "~/store/favorite-panel/favorite-panel-action";
+import { navigateToProcessLogs } from '~/store/navigation/navigation-action';
 
 export const processActionSet: ContextMenuActionSet = [[
     {
@@ -84,7 +85,7 @@ export const processActionSet: ContextMenuActionSet = [[
         icon: LogIcon,
         name: "Log",
         execute: (dispatch, resource) => {
-            // add code
+            dispatch<any>(navigateToProcessLogs(resource.uuid));
         }
     },
     {
index 2df776e59e8fe3ac54f41dabf969bd4241d6c40b..a545b2bdddfb447ebe5bf43ee768a841d52d36e8 100644 (file)
@@ -64,5 +64,6 @@ export enum ContextMenuKind {
     COLLECTION_FILES_ITEM = "CollectionFilesItem",
     COLLECTION = 'Collection',
     COLLECTION_RESOURCE = 'CollectionResource',
-    PROCESS = "Process"
+    PROCESS = "Process",
+    PROCESS_LOGS = "ProcessLogs"
 }
diff --git a/src/views/process-log-panel/process-log-code-snippet.tsx b/src/views/process-log-panel/process-log-code-snippet.tsx
new file mode 100644 (file)
index 0000000..ff6320e
--- /dev/null
@@ -0,0 +1,31 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import { MuiThemeProvider, createMuiTheme } from '@material-ui/core/styles';
+import { CodeSnippet, CodeSnippetDataProps } from '~/components/code-snippet/code-snippet';
+import grey from '@material-ui/core/colors/grey';
+
+const theme = createMuiTheme({
+    overrides: {
+        MuiTypography: {
+            body1: {
+                color: grey["200"]
+            },
+            root: {
+                backgroundColor: '#000'
+            }
+        }
+    },
+    typography: {
+        fontFamily: 'monospace'
+    }
+});
+
+type ProcessLogCodeSnippet = CodeSnippetDataProps;
+
+export const ProcessLogCodeSnippet = (props: ProcessLogCodeSnippet) => 
+    <MuiThemeProvider theme={theme}>
+        <CodeSnippet lines={props.lines} />
+    </MuiThemeProvider>;
\ No newline at end of file
diff --git a/src/views/process-log-panel/process-log-form.tsx b/src/views/process-log-panel/process-log-form.tsx
new file mode 100644 (file)
index 0000000..eab9fe1
--- /dev/null
@@ -0,0 +1,49 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import { withStyles, WithStyles, StyleRulesCallback, FormControl, InputLabel, Select, MenuItem, Input } from '@material-ui/core';
+import { ArvadosTheme } from '~/common/custom-theme';
+import { FilterOption } from './process-log-panel';
+
+type CssRules = 'formControl';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    formControl: {
+        minWidth: 200
+    }
+});
+
+export interface ProcessLogFormDataProps {
+    selectedFilter: FilterOption;
+    filters: FilterOption[];
+}
+
+export interface ProcessLogFormActionProps {
+    onChange: (filter: FilterOption) => void;
+}
+
+type ProcessLogFormProps = ProcessLogFormDataProps & ProcessLogFormActionProps & WithStyles<CssRules>;
+
+export const ProcessLogForm = withStyles(styles)(
+    ({ classes, selectedFilter, onChange, filters }: ProcessLogFormProps) =>
+        <form autoComplete="off">
+            <FormControl className={classes.formControl}>
+                <InputLabel shrink htmlFor="log-label-placeholder">
+                    Event Type
+                </InputLabel>
+                <Select
+                    value={selectedFilter.value}
+                    onChange={({ target }) => onChange({ label: target.innerText, value: target.value })}
+                    input={<Input name="eventType" id="log-label-placeholder" />}
+                    name="eventType">
+                    {
+                        filters.map(option =>
+                            <MenuItem key={option.value} value={option.value}>{option.label}</MenuItem>
+                        )
+                    }
+                </Select>
+            </FormControl>
+        </form>
+);
\ No newline at end of file
diff --git a/src/views/process-log-panel/process-log-main-card.tsx b/src/views/process-log-panel/process-log-main-card.tsx
new file mode 100644 (file)
index 0000000..29fd4ae
--- /dev/null
@@ -0,0 +1,104 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import { Link } from 'react-router-dom';
+import {
+    StyleRulesCallback, WithStyles, withStyles, Card,
+    CardHeader, IconButton, CardContent, Grid, Typography, Tooltip
+} from '@material-ui/core';
+import { Process } from '~/store/processes/process';
+import { ProcessLogCodeSnippet } from '~/views/process-log-panel/process-log-code-snippet';
+import { ProcessLogForm, ProcessLogFormDataProps, ProcessLogFormActionProps } from '~/views/process-log-panel/process-log-form';
+import { MoreOptionsIcon, ProcessIcon } from '~/components/icon/icon';
+import { ArvadosTheme } from '~/common/custom-theme';
+import { CodeSnippetDataProps } from '~/components/code-snippet/code-snippet';
+import { BackIcon } from '~/components/icon/icon';
+import { DefaultView } from '~/components/default-view/default-view';
+
+type CssRules = 'backLink' | 'backIcon' | 'card' | 'title' | 'iconHeader' | 'link';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    backLink: {
+        fontSize: '1rem',
+        fontWeight: 600,
+        display: 'flex',
+        alignItems: 'center',
+        textDecoration: 'none',
+        padding: theme.spacing.unit,
+        color: theme.palette.grey["700"],
+    },
+    backIcon: {
+        marginRight: theme.spacing.unit
+    },
+    card: {
+        width: '100%'
+    },
+    title: {
+        color: theme.palette.grey["700"]
+    },
+    iconHeader: {
+        fontSize: '1.875rem',
+        color: theme.customs.colors.green700
+    },
+    link: {
+        alignSelf: 'flex-end',
+        textAlign: 'right'
+    }
+});
+
+
+interface ProcessLogMainCardDataProps {
+    process: Process;
+}
+
+export type ProcessLogMainCardProps = ProcessLogMainCardDataProps & CodeSnippetDataProps & ProcessLogFormDataProps & ProcessLogFormActionProps;
+
+export const ProcessLogMainCard = withStyles(styles)(
+    ({ classes, process, selectedFilter, filters, onChange, lines }: ProcessLogMainCardProps & WithStyles<CssRules>) =>
+        <Grid item xs={12}>
+            <Link to={`/processes/${process.containerRequest.uuid}`} className={classes.backLink}>
+                <BackIcon className={classes.backIcon} /> Back
+            </Link>
+            <Card className={classes.card}>
+                <CardHeader
+                    avatar={<ProcessIcon className={classes.iconHeader} />}
+                    action={
+                        <div>
+                            <IconButton aria-label="More options">
+                                <MoreOptionsIcon />
+                            </IconButton>
+                        </div>
+                    }
+                    title={
+                        <Tooltip title={process.containerRequest.name} placement="bottom-start">
+                            <Typography noWrap variant="title" className={classes.title}>
+                                {process.containerRequest.name}
+                            </Typography>
+                        </Tooltip>
+                    }
+                    subheader={process.containerRequest.description} />
+                <CardContent>
+                    {lines.length > 0
+                        ? < Grid container spacing={24} alignItems='center'>
+                            <Grid item xs={6}>
+                                <ProcessLogForm selectedFilter={selectedFilter} filters={filters} onChange={onChange} />
+                            </Grid>
+                            <Grid item xs={6} className={classes.link}>
+                                <Typography component='div'>
+                                    Go to Log collection
+                                </Typography>
+                            </Grid>
+                            <Grid item xs={12}>
+                                <ProcessLogCodeSnippet lines={lines} />
+                            </Grid>
+                        </Grid>
+                        : <DefaultView
+                            icon={ProcessIcon}
+                            messages={['No logs yet']} />
+                    }
+                </CardContent>
+            </Card>
+        </Grid >
+);
\ No newline at end of file
diff --git a/src/views/process-log-panel/process-log-panel-root.tsx b/src/views/process-log-panel/process-log-panel-root.tsx
new file mode 100644 (file)
index 0000000..0845a41
--- /dev/null
@@ -0,0 +1,37 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import { Grid } from '@material-ui/core';
+import { Process } from '~/store/processes/process';
+import { ProcessLogMainCard } from '~/views/process-log-panel/process-log-main-card';
+import { ProcessLogFormDataProps, ProcessLogFormActionProps } from '~/views/process-log-panel/process-log-form';
+import { DefaultView } from '~/components/default-view/default-view';
+import { ProcessIcon } from '~/components/icon/icon';
+import { CodeSnippetDataProps } from '~/components/code-snippet/code-snippet';
+
+export type ProcessLogPanelRootDataProps = {
+    process?: Process;
+} & ProcessLogFormDataProps & CodeSnippetDataProps;
+
+export type ProcessLogPanelRootActionProps = {
+    onContextMenu: (event: React.MouseEvent<HTMLElement>) => void;
+} & ProcessLogFormActionProps;
+
+export type ProcessLogPanelRootProps = ProcessLogPanelRootDataProps & ProcessLogPanelRootActionProps;
+
+export const ProcessLogPanelRoot = (props: ProcessLogPanelRootProps) =>
+    props.process
+        ? <Grid container spacing={16}>
+            <ProcessLogMainCard 
+                process={props.process} 
+                {...props} />
+        </Grid> 
+        : <Grid container
+            alignItems='center'
+            justify='center'>
+            <DefaultView
+                icon={ProcessIcon}
+                messages={['Process Log not found']} />
+        </Grid>;
diff --git a/src/views/process-log-panel/process-log-panel.tsx b/src/views/process-log-panel/process-log-panel.tsx
new file mode 100644 (file)
index 0000000..2b2d684
--- /dev/null
@@ -0,0 +1,50 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import { RootState } from '~/store/store';
+import { connect } from 'react-redux';
+import { getProcess } from '~/store/processes/process';
+import { Dispatch } from 'redux';
+import { openProcessContextMenu } from '~/store/context-menu/context-menu-actions';
+import { matchProcessLogRoute } from '~/routes/routes';
+import { ProcessLogPanelRootDataProps, ProcessLogPanelRootActionProps, ProcessLogPanelRoot } from './process-log-panel-root';
+import { getProcessPanelLogs } from '~/store/process-logs-panel/process-logs-panel';
+import { setProcessLogsPanelFilter } from '~/store/process-logs-panel/process-logs-panel-actions';
+import { getProcessLogsPanelCurrentUuid } from '../../store/process-logs-panel/process-logs-panel';
+
+export interface Log {
+    object_uuid: string;
+    event_at: string;
+    event_type: string;
+    summary: string;
+    properties: any;
+}
+
+export interface FilterOption {
+    label: string;
+    value: string;
+}
+
+const mapStateToProps = (state: RootState): ProcessLogPanelRootDataProps => {
+    const { resources, processLogsPanel } = state;
+    const uuid = getProcessLogsPanelCurrentUuid(state) || '';
+    return {
+        process: getProcess(uuid)(resources),
+        selectedFilter: { label: processLogsPanel.selectedFilter, value: processLogsPanel.selectedFilter },
+        filters: processLogsPanel.filters.map(filter => ({ label: filter, value: filter })),
+        lines: getProcessPanelLogs(processLogsPanel)
+    };
+};
+
+const mapDispatchToProps = (dispatch: Dispatch): ProcessLogPanelRootActionProps => ({
+    onContextMenu: (event: React.MouseEvent<HTMLElement>) => {
+        dispatch<any>(openProcessContextMenu(event));
+    },
+    onChange: (filter: FilterOption) => {
+        dispatch(setProcessLogsPanelFilter(filter.value));
+    }
+});
+
+export const ProcessLogPanel = connect(mapStateToProps, mapDispatchToProps)(ProcessLogPanelRoot);
index 53c56ca2807e9b2585a5aa5aac6275041b4675fe..f2379ed8dd697af03cd227f84d55c1a80e28e3f4 100644 (file)
@@ -10,10 +10,11 @@ import {
 import { ArvadosTheme } from '~/common/custom-theme';
 import { MoreOptionsIcon, ProcessIcon } from '~/components/icon/icon';
 import { DetailsAttribute } from '~/components/details-attribute/details-attribute';
-import { Process } from '~/store/processes/process';
-import { getProcessStatus } from '../../store/processes/process';
+import { Process, getProcessStatusColor } from '~/store/processes/process';
+import { getProcessStatus } from '~/store/processes/process';
 
-type CssRules = 'card' | 'iconHeader' | 'label' | 'value' | 'chip' | 'headerText' | 'link' | 'content' | 'title' | 'avatar';
+
+type CssRules = 'card' | 'iconHeader' | 'label' | 'value' | 'chip' | 'link' | 'content' | 'title' | 'avatar';
 
 const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     card: {
@@ -24,7 +25,8 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
         color: theme.customs.colors.green700,
     },
     avatar: {
-        alignSelf: 'flex-start'
+        alignSelf: 'flex-start',
+        paddingTop: theme.spacing.unit * 0.5
     },
     label: {
         display: 'flex',
@@ -47,15 +49,10 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     chip: {
         height: theme.spacing.unit * 3,
         width: theme.spacing.unit * 12,
-        backgroundColor: theme.customs.colors.green700,
         color: theme.palette.common.white,
         fontSize: '0.875rem',
         borderRadius: theme.spacing.unit * 0.625,
     },
-    headerText: {
-        fontSize: '0.875rem',
-        marginLeft: theme.spacing.unit * 3,
-    },
     content: {
         '&:last-child': {
             paddingBottom: theme.spacing.unit * 2,
@@ -63,7 +60,8 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
         }
     },
     title: {
-        overflow: 'hidden'
+        overflow: 'hidden',
+        paddingTop: theme.spacing.unit * 0.5
     }
 });
 
@@ -74,8 +72,8 @@ export interface ProcessInformationCardDataProps {
 
 type ProcessInformationCardProps = ProcessInformationCardDataProps & WithStyles<CssRules>;
 
-export const ProcessInformationCard = withStyles(styles)(
-    ({ classes, process, onContextMenu }: ProcessInformationCardProps) =>
+export const ProcessInformationCard = withStyles(styles, { withTheme: true })(
+    ({ classes, process, onContextMenu, theme }: ProcessInformationCardProps) =>
         <Card className={classes.card}>
             <CardHeader
                 classes={{
@@ -85,7 +83,9 @@ export const ProcessInformationCard = withStyles(styles)(
                 avatar={<ProcessIcon className={classes.iconHeader} />}
                 action={
                     <div>
-                        <Chip label={getProcessStatus(process)} className={classes.chip} />
+                        <Chip label={getProcessStatus(process)}
+                            className={classes.chip} 
+                            style={{ backgroundColor: getProcessStatusColor(getProcessStatus(process), theme as ArvadosTheme) }}/>
                         <IconButton
                             aria-label="More options"
                             onClick={event => onContextMenu(event)}>
@@ -94,7 +94,7 @@ export const ProcessInformationCard = withStyles(styles)(
                     </div>
                 }
                 title={
-                    <Tooltip title={process.containerRequest.name}>
+                    <Tooltip title={process.containerRequest.name} placement="bottom-start" color='inherit'>
                         <Typography noWrap variant="title">
                            {process.containerRequest.name}
                         </Typography>
index feada3acb3c8231266365de6641a982c498f4b43..8e78f564f288de0220f84ed82753cc0f3cb5bc33 100644 (file)
@@ -9,9 +9,14 @@ import { DefaultView } from '~/components/default-view/default-view';
 import { ProcessIcon } from '~/components/icon/icon';
 import { Process } from '~/store/processes/process';
 import { SubprocessesCard } from './subprocesses-card';
+import { ProcessSubprocesses } from '~/views/process-panel/process-subprocesses';
+import { SubprocessesStatus } from '~/views/process-panel/process-subprocesses-card';
+
+type CssRules = 'headerActive' | 'headerCompleted' | 'headerQueued' | 'headerFailed' | 'headerCanceled';
 
 export interface ProcessPanelRootDataProps {
     process?: Process;
+    subprocesses: Array<Process>;
 }
 
 export interface ProcessPanelRootActionProps {
@@ -59,10 +64,16 @@ export const ProcessPanelRoot = (props: ProcessPanelRootProps) =>
                     onToggle={() => { return; }}
                 />
             </Grid>
+            <Grid item xs={12}>
+                <ProcessSubprocesses
+                    subprocesses={props.subprocesses}
+                    onContextMenu={props.onContextMenu} />
+            </Grid>
         </Grid>
         : <Grid container
             alignItems='center'
-            justify='center'>
+            justify='center'
+            style={{ minHeight: '100%' }}>
             <DefaultView
                 icon={ProcessIcon}
                 messages={['Process not found']} />
index 421945fed7f1756c1a6f8794b6e5e018d2a27b42..9f1d0adb7a2fae5314e2b2b55136624060519613 100644 (file)
@@ -5,7 +5,7 @@
 import * as React from 'react';
 import { RootState } from '~/store/store';
 import { connect } from 'react-redux';
-import { getProcess } from '~/store/processes/process';
+import { getProcess, getSubprocesses } from '~/store/processes/process';
 import { Dispatch } from 'redux';
 import { openProcessContextMenu } from '~/store/context-menu/context-menu-actions';
 import { matchProcessRoute } from '~/routes/routes';
@@ -16,7 +16,8 @@ const mapStateToProps = ({ router, resources }: RootState): ProcessPanelRootData
     const match = matchProcessRoute(pathname);
     const uuid = match ? match.params.id : '';
     return {
-        process: getProcess(uuid)(resources)
+        process: getProcess(uuid)(resources),
+        subprocesses: getSubprocesses(uuid)(resources)
     };
 };
 
diff --git a/src/views/process-panel/process-subprocesses-card.tsx b/src/views/process-panel/process-subprocesses-card.tsx
new file mode 100644 (file)
index 0000000..57c127a
--- /dev/null
@@ -0,0 +1,114 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import {
+    StyleRulesCallback, WithStyles, withStyles, Card,
+    CardHeader, IconButton, CardContent, Typography, Tooltip
+} from '@material-ui/core';
+import { ArvadosTheme } from '~/common/custom-theme';
+import { MoreOptionsIcon } from '~/components/icon/icon';
+import { DetailsAttribute } from '~/components/details-attribute/details-attribute';
+import { Process, getProcessStatus, getProcessRuntime } from '~/store/processes/process';
+import { formatTime } from '~/common/formatters';
+import { getProcessStatusColor } from '~/store/processes/process';
+
+export type CssRules = 'label' | 'value' | 'title' | 'content' | 'action' | 'options' | 'status' | 'rightSideHeader' | 'titleHeader'| 'header';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    label: {
+        fontSize: '0.875rem',
+    },
+    value: {
+        textTransform: 'none',
+        fontSize: '0.875rem',
+    },
+    title: {
+        overflow: 'hidden'
+    },
+    content: {
+        paddingTop: theme.spacing.unit * 0.5,
+        '&:last-child': {
+            paddingBottom: 0
+        }
+    },
+    action: {
+        marginTop: 0
+    },
+    options: {
+        width: theme.spacing.unit * 4,
+        height: theme.spacing.unit * 4,
+        color: theme.palette.common.white,
+    },
+    status: {
+        paddingTop: theme.spacing.unit * 0.5,
+        color: theme.palette.common.white,
+    },
+    rightSideHeader: {
+        display: 'flex'
+    },
+    titleHeader: {
+        color: theme.palette.common.white,
+        fontWeight: 600
+    },
+    header: {
+        paddingTop: 0,
+        paddingBottom: 0,
+    },
+});
+
+export enum SubprocessesStatus {
+    ACTIVE = 'Active',
+    COMPLETED = 'Completed',
+    QUEUED = 'Queued',
+    FAILED = 'Failed',
+    CANCELED = 'Canceled'
+}
+
+export interface SubprocessItemProps {
+    title: string;
+    status: string;
+    runtime?: string;
+}
+
+export interface ProcessSubprocessesCardDataProps {
+    onContextMenu: (event: React.MouseEvent<HTMLElement>) => void;
+    subprocess: Process;
+}
+
+type ProcessSubprocessesCardProps = ProcessSubprocessesCardDataProps & WithStyles<CssRules>;
+
+export const ProcessSubprocessesCard = withStyles(styles, { withTheme: true })(
+    ({ classes, onContextMenu, subprocess, theme }: ProcessSubprocessesCardProps) => {
+        return <Card>
+            <CardHeader
+                className={classes.header}
+                style={{ backgroundColor: getProcessStatusColor(getProcessStatus(subprocess), theme as ArvadosTheme) }}
+                classes={{ content: classes.title, action: classes.action }}
+                action={
+                    <div className={classes.rightSideHeader}>
+                        <Typography noWrap variant="body2" className={classes.status}>
+                            {getProcessStatus(subprocess)}
+                        </Typography>
+                        <IconButton
+                            className={classes.options}
+                            aria-label="More options"
+                            onClick={onContextMenu}>
+                            <MoreOptionsIcon />
+                        </IconButton>
+                    </div>
+                }
+                title={
+                    <Tooltip title={subprocess.containerRequest.name}>
+                        <Typography noWrap variant="body2" className={classes.titleHeader}>
+                            {subprocess.containerRequest.name}
+                        </Typography>
+                    </Tooltip>
+                } />
+            <CardContent className={classes.content}>
+                <DetailsAttribute classLabel={classes.label} classValue={classes.value}
+                    label="Runtime" value={formatTime(getProcessRuntime(subprocess))} />
+            </CardContent>
+        </Card>;
+    });
\ No newline at end of file
diff --git a/src/views/process-panel/process-subprocesses.tsx b/src/views/process-panel/process-subprocesses.tsx
new file mode 100644 (file)
index 0000000..cfd517c
--- /dev/null
@@ -0,0 +1,23 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import { Grid } from '@material-ui/core';
+import { ProcessSubprocessesCard } from '~/views/process-panel/process-subprocesses-card';
+import { Process } from '~/store/processes/process';
+
+export interface ProcessSubprocessesDataProps {
+    subprocesses: Array<Process>;
+    onContextMenu: (event: React.MouseEvent<HTMLElement>) => void;
+}
+
+export const ProcessSubprocesses = ({ onContextMenu, subprocesses }: ProcessSubprocessesDataProps) => {
+    return <Grid container spacing={16}>
+        {subprocesses.map(subprocess =>
+            <Grid item xs={2} key={subprocess.containerRequest.uuid}>
+                <ProcessSubprocessesCard onContextMenu={onContextMenu} subprocess={subprocess}/>
+            </Grid>
+        )}
+    </Grid>;
+};
index 68bb970073317f00844fdf04e74a388920d527f2..3c281087c2addf20ad8f3f6e7e31500673a28782 100644 (file)
@@ -27,6 +27,7 @@ import { MultipleFilesRemoveDialog } from '~/views-components/file-remove-dialog
 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';
@@ -35,7 +36,6 @@ import { UpdateCollectionDialog } from '~/views-components/dialog-forms/update-c
 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';
 
@@ -170,6 +170,7 @@ export const Workbench = withStyles(styles)(
                                     <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 />}
diff --git a/src/websocket/resource-event-message.ts b/src/websocket/resource-event-message.ts
new file mode 100644 (file)
index 0000000..420f25a
--- /dev/null
@@ -0,0 +1,16 @@
+import { LogEventType } from '../models/log';
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+export interface ResourceEventMessage<Properties = {}> {
+    eventAt: string;
+    eventType: LogEventType;
+    id: string;
+    msgID: string;
+    objectKind: string;
+    objectOwnerUuid: string;
+    objectUuid: string;
+    properties: Properties;
+    uuid: string;
+}
diff --git a/src/websocket/websocket-service.ts b/src/websocket/websocket-service.ts
new file mode 100644 (file)
index 0000000..a5ce13d
--- /dev/null
@@ -0,0 +1,47 @@
+// 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"}');
+    }
+
+}
diff --git a/src/websocket/websocket.ts b/src/websocket/websocket.ts
new file mode 100644 (file)
index 0000000..e3f1e19
--- /dev/null
@@ -0,0 +1,38 @@
+// 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}>));
+    }
+};