From 75b017ae9d566d523e448aaeb863c4d89b3548fe Mon Sep 17 00:00:00 2001 From: Peter Amstutz Date: Thu, 26 Oct 2023 16:56:28 -0400 Subject: [PATCH] 21067: Better handling of missing output/logs on process panel. 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 --- .../collection-panel-files.tsx | 2 + src/index.tsx | 14 +----- .../collection-service/collection-service.ts | 11 +++-- .../process-logs-panel-actions.ts | 48 ++++++++++--------- .../process-panel/process-panel-actions.ts | 5 +- .../project-panel-middleware-service.ts | 8 +++- src/store/workbench/workbench-actions.ts | 6 +++ .../collection-panel/collection-panel.tsx | 13 ++++- src/views/project-panel/project-panel.tsx | 32 ++++++++++--- .../registered-workflow-panel.tsx | 13 ++++- 10 files changed, 100 insertions(+), 52 deletions(-) diff --git a/src/components/collection-panel-files/collection-panel-files.tsx b/src/components/collection-panel-files/collection-panel-files.tsx index 83de48dec8..f1e50e0f0b 100644 --- a/src/components/collection-panel-files/collection-panel-files.tsx +++ b/src/components/collection-panel-files/collection-panel-files.tsx @@ -311,6 +311,8 @@ export const CollectionPanelFiles = withStyles(styles)( return { ...next, ...prev }; }, {}); setPathData(state => ({ ...state, ...newState })); + }, () => { + // Nothing to do }) .finally(() => { setIsLoading(false); diff --git a/src/index.tsx b/src/index.tsx index a3f6c1ee79..b0bda23ba8 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -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, - }) - ); } } }, diff --git a/src/services/collection-service/collection-service.ts b/src/services/collection-service/collection-service.ts index de8f258708..e50e5ed350 100644 --- a/src/services/collection-service/collection-service.ts +++ b/src/services/collection-service/collection-service.ts @@ -53,11 +53,14 @@ export class CollectionService extends TrashableResourceService 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 => ( @@ -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(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 })); } }; diff --git a/src/store/process-panel/process-panel-actions.ts b/src/store/process-panel/process-panel-actions.ts index 03e36aac98..66de2f69f3 100644 --- a/src/store/process-panel/process-panel-actions.ts +++ b/src/store/process-panel/process-panel-actions.ts @@ -59,7 +59,7 @@ export const navigateToOutput = (uuid: string) => async (dispatch: Dispatch await services.collectionService.get(uuid); dispatch(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, getState }; export const openWorkflow = (uuid: string) => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { - dispatch(navigateToWorkflows); - dispatch(showWorkflowDetails(uuid)); + dispatch(navigateTo(uuid)); }; export const initProcessPanelFilters = processPanelActions.SET_PROCESS_PANEL_FILTERS([ diff --git a/src/store/project-panel/project-panel-middleware-service.ts b/src/store/project-panel/project-panel-middleware-service.ts index c0c0cd1873..b2ddb46c66 100644 --- a/src/store/project-panel/project-panel-middleware-service.ts +++ b/src/store/project-panel/project-panel-middleware-service.ts @@ -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())); } } diff --git a/src/store/workbench/workbench-actions.ts b/src/store/workbench/workbench-actions.ts index b03400d5ae..f2dae2c524 100644 --- a/src/store/workbench/workbench-actions.ts +++ b/src/store/workbench/workbench-actions.ts @@ -109,6 +109,12 @@ export const isWorkbenchLoading = (state: RootState) => { export const handleFirstTimeLoad = (action: any) => async (dispatch: Dispatch, 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)); diff --git a/src/views/collection-panel/collection-panel.tsx b/src/views/collection-panel/collection-panel.tsx index 8cf19c03fe..eed9c7de71 100644 --- a/src/views/collection-panel/collection-panel.tsx +++ b/src/views/collection-panel/collection-panel.tsx @@ -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( - : null; + : + + + ; } handleContextMenu = (event: React.MouseEvent) => { diff --git a/src/views/project-panel/project-panel.tsx b/src/views/project-panel/project-panel.tsx index 4c94ab8d2d..2f274c97d5 100644 --- a/src/views/project-panel/project-panel.tsx +++ b/src/views/project-panel/project-panel.tsx @@ -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 & 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(PROJECT_PANEL_CURRENT_UUID)(state.properties); + const project = getResource(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 { render() { const { classes } = this.props; - return ( + return this.props.project ?
- ); + : + + ; } isCurrentItemChild = (resource: Resource) => { diff --git a/src/views/workflow-panel/registered-workflow-panel.tsx b/src/views/workflow-panel/registered-workflow-panel.tsx index 5973efedc8..da273719bf 100644 --- a/src/views/workflow-panel/registered-workflow-panel.tsx +++ b/src/views/workflow-panel/registered-workflow-panel.tsx @@ -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( - : null; + : + + ; } handleContextMenu = (event: React.MouseEvent) => { -- 2.30.2