21067: Better handling of missing output/logs on process panel.
authorPeter Amstutz <peter.amstutz@curii.com>
Thu, 26 Oct 2023 20:56:28 +0000 (16:56 -0400)
committerPeter Amstutz <peter.amstutz@curii.com>
Thu, 26 Oct 2023 20:56:28 +0000 (16:56 -0400)
A few other important changes:

* Similar to the change in #21077, this removes the default error
snackbar popups any time a 400 error happens.  This reduces user
confusion, particularly when "harmless" errors would occur.

* The collection files component will no longer give an "unhandled
rejection" React failure when it can't load the file list (this took
forever to track down.)

* Collections, projects and workflows will now default to a "not
found" panel if they fail to load.  Previously, collections and
workflows would show nothing at all, and projects would show an empty
list.

Arvados-DCO-1.1-Signed-off-by: Peter Amstutz <peter.amstutz@curii.com>

src/components/collection-panel-files/collection-panel-files.tsx
src/index.tsx
src/services/collection-service/collection-service.ts
src/store/process-logs-panel/process-logs-panel-actions.ts
src/store/process-panel/process-panel-actions.ts
src/store/project-panel/project-panel-middleware-service.ts
src/store/workbench/workbench-actions.ts
src/views/collection-panel/collection-panel.tsx
src/views/project-panel/project-panel.tsx
src/views/workflow-panel/registered-workflow-panel.tsx

index 83de48dec8fb40552e7d5e3a970b27d087ef6983..f1e50e0f0bf7d2f9c4699265e19d515954a9d7cc 100644 (file)
@@ -311,6 +311,8 @@ export const CollectionPanelFiles = withStyles(styles)(
                             return { ...next, ...prev };
                         }, {});
                     setPathData(state => ({ ...state, ...newState }));
+                }, () => {
+                    // Nothing to do
                 })
                 .finally(() => {
                     setIsLoading(false);
index a3f6c1ee79f33ede1c7a543bc5a599d5292d24f8..b0bda23ba86711968a887734c2e23c267d26ca35 100644 (file)
@@ -153,25 +153,13 @@ fetchConfig().then(({ config, apiHost }) => {
 
     const services = createServices(config, {
         progressFn: (id, working) => {
-            //store.dispatch(progressIndicatorActions.TOGGLE_WORKING({ id, working }));
         },
         errorFn: (id, error, showSnackBar: boolean) => {
             if (showSnackBar) {
                 console.error("Backend error:", error);
-
-                if (error.status === 404) {
-                    store.dispatch(openNotFoundDialog());
-                } else if (error.status === 401 && error.errors[0].indexOf("Not logged in") > -1) {
+                if (error.status === 401 && error.errors[0].indexOf("Not logged in") > -1) {
                     // Catch auth errors when navigating and redirect to login preserving url location
                     store.dispatch(logout(false, true));
-                } else {
-                    store.dispatch(
-                        snackbarActions.OPEN_SNACKBAR({
-                            message: `${error.errors ? error.errors[0] : error.message}`,
-                            kind: SnackbarKind.ERROR,
-                            hideDuration: 8000,
-                        })
-                    );
                 }
             }
         },
index de8f258708dc1e94d278a9324a8c68e3f2a9aa6a..e50e5ed35026403c6332865d6b897c32a01f5605 100644 (file)
@@ -53,11 +53,14 @@ export class CollectionService extends TrashableResourceService<CollectionResour
     }
 
     async files(uuid: string) {
-        const request = await this.keepWebdavClient.propfind(`c=${uuid}`);
-        if (request.responseXML != null) {
-            return extractFilesData(request.responseXML);
+        try {
+            const request = await this.keepWebdavClient.propfind(`c=${uuid}`);
+            if (request.responseXML != null) {
+                return extractFilesData(request.responseXML);
+            }
+        } catch (e) {
+            return Promise.reject(e);
         }
-
         return Promise.reject();
     }
 
index 87a2fa12aaddc1d6d980908583dfda366c0f1ab4..4e52431eebadc9a05b240ee530cd29e3390569e4 100644 (file)
@@ -13,7 +13,7 @@ import { Process, getProcess } from 'store/processes/process';
 import { navigateTo } from 'store/navigation/navigation-action';
 import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions';
 import { CollectionFile, CollectionFileType } from "models/collection-file";
-import { ContainerRequestResource } from "models/container-request";
+import { ContainerRequestResource, ContainerRequestState } from "models/container-request";
 
 const SNIPLINE = `================ ✀ ================ ✀ ========= Some log(s) were skipped ========= ✀ ================ ✀ ================`;
 const LOG_TIMESTAMP_PATTERN = /^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{9}Z/;
@@ -40,15 +40,16 @@ export const setProcessLogsPanelFilter = (filter: string) =>
 
 export const initProcessLogsPanel = (processUuid: string) =>
     async (dispatch: Dispatch, getState: () => RootState, { logService }: ServiceRepository) => {
+        let process: Process | undefined;
         try {
             dispatch(processLogsPanelActions.RESET_PROCESS_LOGS_PANEL());
-            const process = getProcess(processUuid)(getState().resources);
+            process = getProcess(processUuid)(getState().resources);
             if (process?.containerRequest?.uuid) {
                 // Get log file size info
                 const logFiles = await loadContainerLogFileList(process.containerRequest, logService);
 
                 // Populate lastbyte 0 for each file
-                const filesWithProgress = logFiles.map((file) => ({file, lastByte: 0}));
+                const filesWithProgress = logFiles.map((file) => ({ file, lastByte: 0 }));
 
                 // Fetch array of LogFragments
                 const logLines = await loadContainerLogFileContents(filesWithProgress, logService, process);
@@ -57,13 +58,16 @@ export const initProcessLogsPanel = (processUuid: string) =>
                 const initialState = createInitialLogPanelState(logFiles, logLines);
                 dispatch(processLogsPanelActions.INIT_PROCESS_LOGS_PANEL(initialState));
             }
-        } catch(e) {
+        } catch (e) {
             // On error, populate empty state to allow polling to start
             const initialState = createInitialLogPanelState([], []);
             dispatch(processLogsPanelActions.INIT_PROCESS_LOGS_PANEL(initialState));
             // Only show toast on errors other than 404 since 404 is expected when logs do not exist yet
             if (e.status !== 404) {
-                dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Could not load process logs', hideDuration: 2000, kind: SnackbarKind.ERROR }));
+                dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Error loading process logs', hideDuration: 4000, kind: SnackbarKind.ERROR }));
+            }
+            if (e.status === 404 && process?.containerRequest.state === ContainerRequestState.FINAL) {
+                dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Log collection was trashed or deleted.', hideDuration: 4000, kind: SnackbarKind.WARNING }));
             }
         }
     };
@@ -88,7 +92,7 @@ export const pollProcessLogs = (processUuid: string) =>
                     const isChanged = !isNew && currentStateLogLastByte < updatedFile.size;
 
                     if (isNew || isChanged) {
-                        return acc.concat({file: updatedFile, lastByte: currentStateLogLastByte});
+                        return acc.concat({ file: updatedFile, lastByte: currentStateLogLastByte });
                     } else {
                         return acc;
                     }
@@ -132,17 +136,17 @@ const loadContainerLogFileList = async (containerRequest: ContainerRequestResour
  * @returns LogFragment[] containing a single LogFragment corresponding to each input file
  */
 const loadContainerLogFileContents = async (logFilesWithProgress: FileWithProgress[], logService: LogService, process: Process) => (
-    (await Promise.allSettled(logFilesWithProgress.filter(({file}) => file.size > 0).map(({file, lastByte}) => {
+    (await Promise.allSettled(logFilesWithProgress.filter(({ file }) => file.size > 0).map(({ file, lastByte }) => {
         const requestSize = file.size - lastByte;
         if (requestSize > maxLogFetchSize) {
             const chunkSize = Math.floor(maxLogFetchSize / 2);
-            const firstChunkEnd = lastByte+chunkSize-1;
+            const firstChunkEnd = lastByte + chunkSize - 1;
             return Promise.all([
                 logService.getLogFileContents(process.containerRequest, file, lastByte, firstChunkEnd),
-                logService.getLogFileContents(process.containerRequest, file, file.size-chunkSize, file.size-1)
+                logService.getLogFileContents(process.containerRequest, file, file.size - chunkSize, file.size - 1)
             ] as Promise<(LogFragment)>[]);
         } else {
-            return Promise.all([logService.getLogFileContents(process.containerRequest, file, lastByte, file.size-1)]);
+            return Promise.all([logService.getLogFileContents(process.containerRequest, file, lastByte, file.size - 1)]);
         }
     })).then((res) => {
         if (res.length && res.every(promiseResult => (promiseResult.status === 'rejected'))) {
@@ -150,7 +154,7 @@ const loadContainerLogFileContents = async (logFilesWithProgress: FileWithProgre
             //   error if every request failed
             const error = res.find(
                 (promiseResult): promiseResult is PromiseRejectedResult => promiseResult.status === 'rejected'
-              )?.reason;
+            )?.reason;
             return Promise.reject(error);
         }
         return res.filter((promiseResult): promiseResult is PromiseFulfilledResult<LogFragment[]> => (
@@ -161,16 +165,16 @@ const loadContainerLogFileContents = async (logFilesWithProgress: FileWithProgre
             //   (prevent incorrect snipline generation or an un-resumable situation)
             !!promiseResult.value.every(logFragment => logFragment.contents.length)
         )).map(one => one.value)
-    })).map((logResponseSet)=> {
+    })).map((logResponseSet) => {
         // For any multi fragment response set, modify the last line of non-final chunks to include a line break and snip line
         //   Don't add snip line as a separate line so that sorting won't reorder it
         for (let i = 1; i < logResponseSet.length; i++) {
-            const fragment = logResponseSet[i-1];
-            const lastLineIndex = fragment.contents.length-1;
+            const fragment = logResponseSet[i - 1];
+            const lastLineIndex = fragment.contents.length - 1;
             const lastLineContents = fragment.contents[lastLineIndex];
             const newLastLine = `${lastLineContents}\n${SNIPLINE}`;
 
-            logResponseSet[i-1].contents[lastLineIndex] = newLastLine;
+            logResponseSet[i - 1].contents[lastLineIndex] = newLastLine;
         }
 
         // Merge LogFragment Array (representing multiple log line arrays) into single LogLine[] / LogFragment
@@ -181,7 +185,7 @@ const loadContainerLogFileContents = async (logFilesWithProgress: FileWithProgre
     })
 );
 
-const createInitialLogPanelState = (logFiles: CollectionFile[], logFragments: LogFragment[]): {filters: string[], logs: ProcessLogs} => {
+const createInitialLogPanelState = (logFiles: CollectionFile[], logFragments: LogFragment[]): { filters: string[], logs: ProcessLogs } => {
     const logs = groupLogs(logFiles, logFragments);
     const filters = Object.keys(logs);
     return { filters, logs };
@@ -201,12 +205,12 @@ const groupLogs = (logFiles: CollectionFile[], logFragments: LogFragment[]): Pro
 
     const groupedLogs = logFragments.reduce((grouped, fragment) => ({
         ...grouped,
-        [fragment.logType as string]: {lastByte: fetchLastByteNumber(logFiles, fragment.logType), contents: fragment.contents}
+        [fragment.logType as string]: { lastByte: fetchLastByteNumber(logFiles, fragment.logType), contents: fragment.contents }
     }), {});
 
     return {
-        [MAIN_FILTER_TYPE]: {lastByte: undefined, contents: mainLogs},
-        [ALL_FILTER_TYPE]: {lastByte: undefined, contents: allLogs},
+        [MAIN_FILTER_TYPE]: { lastByte: undefined, contents: mainLogs },
+        [ALL_FILTER_TYPE]: { lastByte: undefined, contents: allLogs },
         ...groupedLogs,
     }
 };
@@ -233,9 +237,9 @@ const mergeMultilineLoglines = (logFragments: LogFragment[]) => (
                     // Partial line without timestamp detected
                     if (i > 0) {
                         // If not first line, copy line to previous line
-                        const previousLineContents = fragmentCopy.contents[i-1];
+                        const previousLineContents = fragmentCopy.contents[i - 1];
                         const newPreviousLineContents = `${previousLineContents}\n${lineContents}`;
-                        fragmentCopy.contents[i-1] = newPreviousLineContents;
+                        fragmentCopy.contents[i - 1] = newPreviousLineContents;
                     }
                     // Delete the current line and prevent iterating
                     fragmentCopy.contents.splice(i, 1);
@@ -283,7 +287,7 @@ export const navigateToLogCollection = (uuid: string) =>
             await services.collectionService.get(uuid);
             dispatch<any>(navigateTo(uuid));
         } catch {
-            dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Could not request collection', hideDuration: 2000, kind: SnackbarKind.ERROR }));
+            dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Log collection was trashed or deleted.', hideDuration: 4000, kind: SnackbarKind.WARNING }));
         }
     };
 
index 03e36aac98fb837752c932df4a72c913cea5cc6c..66de2f69f37461861eeeb73b1d1d16861d97f3c0 100644 (file)
@@ -59,7 +59,7 @@ export const navigateToOutput = (uuid: string) => async (dispatch: Dispatch<any>
         await services.collectionService.get(uuid);
         dispatch<any>(navigateTo(uuid));
     } catch {
-        dispatch(snackbarActions.OPEN_SNACKBAR({ message: "This collection does not exists!", hideDuration: 2000, kind: SnackbarKind.ERROR }));
+        dispatch(snackbarActions.OPEN_SNACKBAR({ message: "Output collection was trashed or deleted.", hideDuration: 4000, kind: SnackbarKind.WARNING }));
     }
 };
 
@@ -159,8 +159,7 @@ export const updateOutputParams = () => async (dispatch: Dispatch<any>, getState
 };
 
 export const openWorkflow = (uuid: string) => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
-    dispatch<any>(navigateToWorkflows);
-    dispatch<any>(showWorkflowDetails(uuid));
+    dispatch<any>(navigateTo(uuid));
 };
 
 export const initProcessPanelFilters = processPanelActions.SET_PROCESS_PANEL_FILTERS([
index c0c0cd1873f262546b694bf2132a5e353f12cb14..b2ddb46c66e6fa0c53f9969f313a88a91bef54bc 100644 (file)
@@ -35,6 +35,7 @@ import { updatePublicFavorites } from "store/public-favorites/public-favorites-a
 import { selectedFieldsOfGroup } from "models/group";
 import { defaultCollectionSelectedFields } from "models/collection";
 import { containerRequestFieldsNoMounts } from "models/container-request";
+import { openNotFoundDialog } from "store/not-found-panel/not-found-panel-action";
 
 export class ProjectPanelMiddlewareService extends DataExplorerMiddlewareService {
     constructor(private services: ServiceRepository, id: string) {
@@ -69,7 +70,12 @@ export class ProjectPanelMiddlewareService extends DataExplorerMiddlewareService
                         rowsPerPage: dataExplorer.rowsPerPage,
                     })
                 );
-                api.dispatch(couldNotFetchProjectContents());
+                if (e.status === 404) {
+                    // It'll just show up as not found
+                }
+                else {
+                    api.dispatch(couldNotFetchProjectContents());
+                }
             } finally {
                 if (!background) { api.dispatch(progressIndicatorActions.PERSIST_STOP_WORKING(this.getId())); }
             }
index b03400d5ae28c7abc55b86ac5e0f01e99668e95a..f2dae2c524c3221daac423cfdca622feb0d8efb2 100644 (file)
@@ -109,6 +109,12 @@ export const isWorkbenchLoading = (state: RootState) => {
 export const handleFirstTimeLoad = (action: any) => async (dispatch: Dispatch<any>, getState: () => RootState) => {
     try {
         await dispatch(action);
+    } catch (e) {
+        snackbarActions.OPEN_SNACKBAR({
+            message: "Error " + e,
+            hideDuration: 8000,
+            kind: SnackbarKind.WARNING,
+        })
     } finally {
         if (isWorkbenchLoading(getState())) {
             dispatch(progressIndicatorActions.STOP_WORKING(WORKBENCH_LOADING_SCREEN));
index 8cf19c03fef183a24d93f52f31ea1a26af822b14..eed9c7de71dd904e8d4419cc41eced617425f783 100644 (file)
@@ -37,6 +37,7 @@ import { Link as ButtonLink } from '@material-ui/core';
 import { ResourceWithName, ResponsiblePerson } from 'views-components/data-explorer/renderers';
 import { MPVContainer, MPVPanelContent, MPVPanelState } from 'components/multi-panel-view/multi-panel-view';
 import { resourceIsFrozen } from 'common/frozen-resources';
+import { DefaultView } from "components/default-view/default-view";
 
 type CssRules = 'root'
     | 'button'
@@ -229,7 +230,17 @@ export const CollectionPanel = withStyles(styles)(connect(
                             </Card>
                         </MPVPanelContent>
                     </MPVContainer >
-                    : null;
+                    : <Grid
+                        container
+                        alignItems="center"
+                        justify="center"
+                        style={{ minHeight: "100%" }}>
+                        <DefaultView
+                            icon={CollectionIcon}
+                            messages={["Collection not found"]}
+                        />
+                    </Grid>
+                    ;
             }
 
             handleContextMenu = (event: React.MouseEvent<any>) => {
index 4c94ab8d2dd05f87fd970f4c9b5a8369f4729cef..2f274c97d50c54df981966c5b61bf3543dadddb3 100644 (file)
@@ -6,7 +6,7 @@ import React from 'react';
 import withStyles from '@material-ui/core/styles/withStyles';
 import { DispatchProp, connect } from 'react-redux';
 import { RouteComponentProps } from 'react-router';
-import { StyleRulesCallback, WithStyles } from '@material-ui/core';
+import { StyleRulesCallback, WithStyles, Grid } from '@material-ui/core';
 
 import { DataExplorer } from 'views-components/data-explorer/data-explorer';
 import { DataColumns } from 'components/data-table/data-table';
@@ -51,6 +51,7 @@ import { GroupClass, GroupResource } from 'models/group';
 import { CollectionResource } from 'models/collection';
 import { resourceIsFrozen } from 'common/frozen-resources';
 import { ProjectResource } from 'models/project';
+import { DefaultView } from "components/default-view/default-view";
 
 type CssRules = 'root' | 'button';
 
@@ -238,6 +239,7 @@ const DEFAULT_VIEW_MESSAGES = ['Your project is empty.', 'Please create a projec
 interface ProjectPanelDataProps {
     currentItemId: string;
     resources: ResourcesState;
+    project: GroupResource;
     isAdmin: boolean;
     userUuid: string;
     dataExplorerItems: any;
@@ -245,17 +247,24 @@ interface ProjectPanelDataProps {
 
 type ProjectPanelProps = ProjectPanelDataProps & DispatchProp & WithStyles<CssRules> & RouteComponentProps<{ id: string }>;
 
-export const ProjectPanel = withStyles(styles)(
-    connect((state: RootState) => ({
-        currentItemId: getProperty(PROJECT_PANEL_CURRENT_UUID)(state.properties),
+const mapStateToProps = (state: RootState) => {
+    const currentItemId = getProperty<string>(PROJECT_PANEL_CURRENT_UUID)(state.properties);
+    const project = getResource<GroupResource>(currentItemId || "")(state.resources);
+    return {
+        currentItemId,
+        project,
         resources: state.resources,
         userUuid: state.auth.user!.uuid,
-    }))(
+    };
+}
+
+export const ProjectPanel = withStyles(styles)(
+    connect(mapStateToProps)(
         class extends React.Component<ProjectPanelProps> {
             render() {
                 const { classes } = this.props;
 
-                return (
+                return this.props.project ?
                     <div data-cy='project-panel' className={classes.root}>
                         <DataExplorer
                             id={PROJECT_PANEL_ID}
@@ -267,7 +276,16 @@ export const ProjectPanel = withStyles(styles)(
                             defaultViewMessages={DEFAULT_VIEW_MESSAGES}
                         />
                     </div>
-                );
+                    : <Grid
+                        container
+                        alignItems="center"
+                        justify="center"
+                        style={{ minHeight: "100%" }}>
+                        <DefaultView
+                            icon={ProjectIcon}
+                            messages={["Project not found"]}
+                        />
+                    </Grid>;
             }
 
             isCurrentItemChild = (resource: Resource) => {
index 5973efedc8fe0626e10cd13ad5465e36baab79f4..da273719bf1b8baa544c47d58c653db32af0ff67 100644 (file)
@@ -13,6 +13,7 @@ import {
     CardHeader,
     CardContent,
     IconButton,
+    Grid
 } from '@material-ui/core';
 import { connect, DispatchProp } from "react-redux";
 import { RouteComponentProps } from 'react-router';
@@ -26,6 +27,7 @@ import { getResource } from 'store/resources/resources';
 import { openContextMenu, resourceUuidToContextMenuKind } from 'store/context-menu/context-menu-actions';
 import { MPVContainer, MPVPanelContent, MPVPanelState } from 'components/multi-panel-view/multi-panel-view';
 import { ProcessIOCard, ProcessIOCardType } from 'views/process-panel/process-io-card';
+import { DefaultView } from "components/default-view/default-view";
 
 type CssRules = 'root'
     | 'button'
@@ -200,7 +202,16 @@ export const RegisteredWorkflowPanel = withStyles(styles)(connect(
                             </Card>
                         </MPVPanelContent>
                     </MPVContainer>
-                    : null;
+                    : <Grid
+                        container
+                        alignItems="center"
+                        justify="center"
+                        style={{ minHeight: "100%" }}>
+                        <DefaultView
+                            icon={WorkflowIcon}
+                            messages={["Workflow not found"]}
+                        />
+                    </Grid>;
             }
 
             handleContextMenu = (event: React.MouseEvent<any>) => {