From 1d4670e612e7030587d1b51ed5aff141a90f9e9c Mon Sep 17 00:00:00 2001 From: Randy Schott <1815175+schottra@users.noreply.github.com> Date: Mon, 22 Jun 2020 14:54:01 -0700 Subject: [PATCH] feat: Remove intermediate NodeExecutionsTable row content (#75) * refactor: removing specialized rows and rendering only nodes * refactor: moving contexts up to common folder * refactor: use a data cache for nested node mapping * refactor: update loading of workflow data * fix: update usage of NodeExecutions in graph tab * fix: update TaskExecutionDetails to use data cache * fix: getting tests and stories working again * chore: docs and cleanup * test: use a more robust element query * refactor: use filter instead of reduce * docs: adding some missing function docs --- src/components/Cache/createCache.ts | 8 +- .../ExecutionDetails/ExecutionDetails.tsx | 26 +- .../ExecutionNodeDetails/InputNodeDetails.tsx | 2 +- .../OutputNodeDetails.tsx | 4 +- .../TaskExecutionNodeDetails.tsx | 2 +- .../ExecutionDetails/ExecutionNodeViews.tsx | 2 +- .../ExecutionWorkflowGraph.tsx | 13 +- .../TaskExecutionNode.tsx | 2 +- .../ExecutionInputsOutputsModal.tsx | 2 +- .../Tables/NodeExecutionChildren.tsx | 162 +++--------- .../Executions/Tables/NodeExecutionRow.tsx | 39 +-- .../Executions/Tables/NodeExecutionsTable.tsx | 9 +- .../Tables/TaskExecutionChildren.tsx | 61 ----- .../Executions/Tables/TaskExecutionRow.tsx | 87 ------- .../Tables/WorkflowExecutionChildren.tsx | 48 ---- .../Tables/WorkflowExecutionRow.tsx | 79 ------ .../NodeExecutionsTable.stories.tsx | 39 ++- src/components/Executions/Tables/contexts.ts | 14 +- .../Tables/taskExecutionColumns.tsx | 180 ------------- .../test/NodeExecutionChildren.test.tsx | 65 ----- .../Tables/test/NodeExecutionsTable.test.tsx | 120 ++++++--- .../Tables/useNodeExecutionsTableState.ts | 16 +- .../Tables/workflowExecutionColumns.tsx | 101 -------- .../TaskExecutionDetails.tsx | 7 +- .../TaskExecutionNodes.tsx | 40 ++- .../useTerminateExecutionState.ts | 2 +- .../__mocks__/createMockExecutionEntities.ts | 45 ++++ .../{ExecutionDetails => }/contexts.ts | 6 + src/components/Executions/types.ts | 65 ++++- .../Executions/useChildNodeExecutions.ts | 101 ++++---- .../Executions/useDetailedNodeExecutions.ts | 59 +++-- .../Executions/useExecutionDataCache.ts | 242 ++++++++++++++++++ .../useWorkflowExecution.ts | 10 +- .../Executions/useWorkflowExecutionState.ts | 40 ++- src/components/Executions/utils.ts | 142 +++++----- .../useExecutionLaunchConfiguration.ts | 3 +- src/components/common/scopedContext.ts | 0 src/components/data/__mocks__/apiContext.ts | 4 +- src/components/hooks/index.ts | 1 - src/components/hooks/useTask.ts | 19 +- src/components/hooks/useWorkflow.ts | 11 +- src/components/hooks/useWorkflows.ts | 4 +- src/components/hooks/utils.ts | 19 +- src/models/Execution/__mocks__/constants.ts | 7 + .../__mocks__/mockNodeExecutionsData.ts | 7 +- .../__mocks__/mockWorkflowExecutionsData.ts | 7 +- src/models/Node/types.ts | 10 + 47 files changed, 859 insertions(+), 1073 deletions(-) delete mode 100644 src/components/Executions/Tables/TaskExecutionChildren.tsx delete mode 100644 src/components/Executions/Tables/TaskExecutionRow.tsx delete mode 100644 src/components/Executions/Tables/WorkflowExecutionChildren.tsx delete mode 100644 src/components/Executions/Tables/WorkflowExecutionRow.tsx delete mode 100644 src/components/Executions/Tables/taskExecutionColumns.tsx delete mode 100644 src/components/Executions/Tables/test/NodeExecutionChildren.test.tsx delete mode 100644 src/components/Executions/Tables/workflowExecutionColumns.tsx create mode 100644 src/components/Executions/__mocks__/createMockExecutionEntities.ts rename src/components/Executions/{ExecutionDetails => }/contexts.ts (76%) create mode 100644 src/components/Executions/useExecutionDataCache.ts rename src/components/{hooks => Executions}/useWorkflowExecution.ts (88%) create mode 100644 src/components/common/scopedContext.ts create mode 100644 src/models/Execution/__mocks__/constants.ts diff --git a/src/components/Cache/createCache.ts b/src/components/Cache/createCache.ts index 9886d30ef..dfd12d9ec 100644 --- a/src/components/Cache/createCache.ts +++ b/src/components/Cache/createCache.ts @@ -14,9 +14,9 @@ function hasId(value: Object): value is HasIdObject { * strictly equal (===). The cache provides methods for getting/setting values * by key and for merging values or arrays of values with any existing items. */ -export interface ValueCache { +export interface ValueCache { /** Retrieve an item by key */ - get(key: EntityKey): object | undefined; + get(key: EntityKey): ValueType | undefined; /** Check existence of an item by key */ has(key: EntityKey): boolean; /** Merge an array of values. If the items have an `id` property, its value @@ -30,9 +30,9 @@ export interface ValueCache { * performed. For arrays, the value is _replaced_. * @returns The merged value */ - mergeValue(key: EntityKey, value: object): object; + mergeValue(key: EntityKey, value: ValueType): ValueType; /** Set an item value by key. Replaces any existing value. */ - set(key: EntityKey, value: object): object; + set(key: EntityKey, value: ValueType): ValueType; } type Cacheable = object | any[]; diff --git a/src/components/Executions/ExecutionDetails/ExecutionDetails.tsx b/src/components/Executions/ExecutionDetails/ExecutionDetails.tsx index 170afaf7d..ecf511921 100644 --- a/src/components/Executions/ExecutionDetails/ExecutionDetails.tsx +++ b/src/components/Executions/ExecutionDetails/ExecutionDetails.tsx @@ -1,14 +1,12 @@ import { WaitForData, withRouteParams } from 'components/common'; -import { - RefreshConfig, - useDataRefresher, - useWorkflowExecution -} from 'components/hooks'; +import { RefreshConfig, useDataRefresher } from 'components/hooks'; import { Execution } from 'models'; import * as React from 'react'; import { executionRefreshIntervalMs } from '../constants'; +import { ExecutionContext, ExecutionDataCacheContext } from '../contexts'; +import { useExecutionDataCache } from '../useExecutionDataCache'; +import { useWorkflowExecution } from '../useWorkflowExecution'; import { executionIsTerminal } from '../utils'; -import { ExecutionContext } from './contexts'; import { ExecutionDetailsAppBarContent } from './ExecutionDetailsAppBarContent'; import { ExecutionNodeViews } from './ExecutionNodeViews'; @@ -35,15 +33,23 @@ export const ExecutionDetailsContainer: React.FC = domain: domainId, name: executionId }; - - const { fetchable, terminateExecution } = useWorkflowExecution(id); + const dataCache = useExecutionDataCache(); + const { fetchable, terminateExecution } = useWorkflowExecution( + id, + dataCache + ); useDataRefresher(id, fetchable, executionRefreshConfig); - const contextValue = { terminateExecution, execution: fetchable.value }; + const contextValue = { + terminateExecution, + execution: fetchable.value + }; return ( - + + + ); diff --git a/src/components/Executions/ExecutionDetails/ExecutionNodeDetails/InputNodeDetails.tsx b/src/components/Executions/ExecutionDetails/ExecutionNodeDetails/InputNodeDetails.tsx index 2ec0aa417..56c19d460 100644 --- a/src/components/Executions/ExecutionDetails/ExecutionNodeDetails/InputNodeDetails.tsx +++ b/src/components/Executions/ExecutionDetails/ExecutionNodeDetails/InputNodeDetails.tsx @@ -6,7 +6,7 @@ import { NodeDetailsProps } from 'components/WorkflowGraph'; import { useStyles as useBaseStyles } from 'components/WorkflowGraph/NodeDetails/styles'; import { LiteralMapViewer } from 'components/Literals'; -import { ExecutionContext } from '../contexts'; +import { ExecutionContext } from '../../contexts'; /** Details panel renderer for the start/input node in a graph. Displays the * top level `WorkflowExecution` inputs. diff --git a/src/components/Executions/ExecutionDetails/ExecutionNodeDetails/OutputNodeDetails.tsx b/src/components/Executions/ExecutionDetails/ExecutionNodeDetails/OutputNodeDetails.tsx index 5afc25cb0..9d2b5530f 100644 --- a/src/components/Executions/ExecutionDetails/ExecutionNodeDetails/OutputNodeDetails.tsx +++ b/src/components/Executions/ExecutionDetails/ExecutionNodeDetails/OutputNodeDetails.tsx @@ -1,12 +1,12 @@ import { SectionHeader, WaitForData } from 'components/common'; import { useCommonStyles } from 'components/common/styles'; -import { useWorkflowExecutionData } from 'components/hooks'; import { LiteralMapViewer, RemoteLiteralMapViewer } from 'components/Literals'; import { NodeDetailsProps } from 'components/WorkflowGraph'; import { useStyles as useBaseStyles } from 'components/WorkflowGraph/NodeDetails/styles'; import { emptyLiteralMapBlob, Execution } from 'models'; import * as React from 'react'; -import { ExecutionContext } from '../contexts'; +import { ExecutionContext } from '../../contexts'; +import { useWorkflowExecutionData } from '../../useWorkflowExecution'; const RemoteExecutionOutputs: React.FC<{ execution: Execution }> = ({ execution diff --git a/src/components/Executions/ExecutionDetails/ExecutionNodeDetails/TaskExecutionNodeDetails.tsx b/src/components/Executions/ExecutionDetails/ExecutionNodeDetails/TaskExecutionNodeDetails.tsx index 0bc4e8d20..c0dc40132 100644 --- a/src/components/Executions/ExecutionDetails/ExecutionNodeDetails/TaskExecutionNodeDetails.tsx +++ b/src/components/Executions/ExecutionDetails/ExecutionNodeDetails/TaskExecutionNodeDetails.tsx @@ -14,7 +14,7 @@ import { TaskNodeDetails } from 'components/WorkflowGraph'; import { useStyles as useBaseStyles } from 'components/WorkflowGraph/NodeDetails/styles'; -import { NodeExecutionsContext } from '../contexts'; +import { NodeExecutionsContext } from '../../contexts'; import { NodeExecutionData } from '../NodeExecutionData'; import { NodeExecutionInputs } from '../NodeExecutionInputs'; import { NodeExecutionOutputs } from '../NodeExecutionOutputs'; diff --git a/src/components/Executions/ExecutionDetails/ExecutionNodeViews.tsx b/src/components/Executions/ExecutionDetails/ExecutionNodeViews.tsx index 9367b8b1b..d5d39d4e4 100644 --- a/src/components/Executions/ExecutionDetails/ExecutionNodeViews.tsx +++ b/src/components/Executions/ExecutionDetails/ExecutionNodeViews.tsx @@ -4,11 +4,11 @@ import { WaitForData } from 'components/common'; import { useTabState } from 'components/hooks/useTabState'; import { Execution } from 'models'; import * as React from 'react'; +import { NodeExecutionsRequestConfigContext } from '../contexts'; import { ExecutionFilters } from '../ExecutionFilters'; import { useNodeExecutionFiltersState } from '../filters/useExecutionFiltersState'; import { NodeExecutionsTable } from '../Tables/NodeExecutionsTable'; import { useWorkflowExecutionState } from '../useWorkflowExecutionState'; -import { NodeExecutionsRequestConfigContext } from './contexts'; import { ExecutionWorkflowGraph } from './ExecutionWorkflowGraph'; const useStyles = makeStyles((theme: Theme) => ({ diff --git a/src/components/Executions/ExecutionDetails/ExecutionWorkflowGraph.tsx b/src/components/Executions/ExecutionDetails/ExecutionWorkflowGraph.tsx index e61046165..498c56b2b 100644 --- a/src/components/Executions/ExecutionDetails/ExecutionWorkflowGraph.tsx +++ b/src/components/Executions/ExecutionDetails/ExecutionWorkflowGraph.tsx @@ -1,16 +1,16 @@ import { DetailsPanel } from 'components/common'; import { WorkflowGraph } from 'components/WorkflowGraph'; import { keyBy } from 'lodash'; -import { endNodeId, startNodeId } from 'models'; +import { endNodeId, NodeExecution, startNodeId } from 'models'; import { Workflow } from 'models/Workflow'; import * as React from 'react'; -import { DetailedNodeExecution } from '../types'; -import { NodeExecutionsContext } from './contexts'; +import { NodeExecutionsContext } from '../contexts'; +import { useDetailedNodeExecutions } from '../useDetailedNodeExecutions'; import { NodeExecutionDetails } from './NodeExecutionDetails'; import { TaskExecutionNodeRenderer } from './TaskExecutionNodeRenderer/TaskExecutionNodeRenderer'; export interface ExecutionWorkflowGraphProps { - nodeExecutions: DetailedNodeExecution[]; + nodeExecutions: NodeExecution[]; workflow: Workflow; } @@ -19,9 +19,10 @@ export const ExecutionWorkflowGraph: React.FC = ({ nodeExecutions, workflow }) => { + const detailedNodeExecutions = useDetailedNodeExecutions(nodeExecutions); const nodeExecutionsById = React.useMemo( - () => keyBy(nodeExecutions, 'id.nodeId'), - [nodeExecutions] + () => keyBy(detailedNodeExecutions, 'id.nodeId'), + [detailedNodeExecutions] ); const [selectedNodes, setSelectedNodes] = React.useState([]); const onNodeSelectionChanged = (newSelection: string[]) => { diff --git a/src/components/Executions/ExecutionDetails/TaskExecutionNodeRenderer/TaskExecutionNode.tsx b/src/components/Executions/ExecutionDetails/TaskExecutionNodeRenderer/TaskExecutionNode.tsx index e7299f9a5..3f9e23b28 100644 --- a/src/components/Executions/ExecutionDetails/TaskExecutionNodeRenderer/TaskExecutionNode.tsx +++ b/src/components/Executions/ExecutionDetails/TaskExecutionNodeRenderer/TaskExecutionNode.tsx @@ -6,7 +6,7 @@ import { TaskNodeRenderer } from 'components/WorkflowGraph/TaskNodeRenderer'; import { NodeExecutionPhase } from 'models/Execution/enums'; import { DAGNode } from 'models/Graph'; -import { NodeExecutionsContext } from '../contexts'; +import { NodeExecutionsContext } from '../../contexts'; import { StatusIndicator } from './StatusIndicator'; /** Renders DAGNodes with colors based on their node type, as well as dots to diff --git a/src/components/Executions/ExecutionInputsOutputsModal.tsx b/src/components/Executions/ExecutionInputsOutputsModal.tsx index 6b6f4ac03..d33980efe 100644 --- a/src/components/Executions/ExecutionInputsOutputsModal.tsx +++ b/src/components/Executions/ExecutionInputsOutputsModal.tsx @@ -2,10 +2,10 @@ import { Dialog, DialogContent, Tab, Tabs } from '@material-ui/core'; import { makeStyles, Theme } from '@material-ui/core/styles'; import { WaitForData } from 'components/common'; import { ClosableDialogTitle } from 'components/common/ClosableDialogTitle'; -import { useWorkflowExecutionData } from 'components/hooks'; import { LiteralMapViewer, RemoteLiteralMapViewer } from 'components/Literals'; import { emptyLiteralMapBlob, Execution } from 'models'; import * as React from 'react'; +import { useWorkflowExecutionData } from './useWorkflowExecution'; const useStyles = makeStyles((theme: Theme) => ({ content: { diff --git a/src/components/Executions/Tables/NodeExecutionChildren.tsx b/src/components/Executions/Tables/NodeExecutionChildren.tsx index 8b9eda93c..80dfe64df 100644 --- a/src/components/Executions/Tables/NodeExecutionChildren.tsx +++ b/src/components/Executions/Tables/NodeExecutionChildren.tsx @@ -1,132 +1,50 @@ -import { WaitForData } from 'components/common'; -import { - RefreshConfig, - useDataRefresher, - useWorkflowExecution -} from 'components/hooks'; -import { isEqual } from 'lodash'; -import { Execution, NodeExecutionClosure, WorkflowNodeMetadata } from 'models'; +import { Typography } from '@material-ui/core'; +import * as classnames from 'classnames'; +import { useExpandableMonospaceTextStyles } from 'components/common/ExpandableMonospaceText'; import * as React from 'react'; -import { executionIsTerminal, executionRefreshIntervalMs } from '..'; -import { ExecutionContext } from '../ExecutionDetails/contexts'; -import { DetailedNodeExecution } from '../types'; -import { useDetailedTaskExecutions } from '../useDetailedTaskExecutions'; -import { - useTaskExecutions, - useTaskExecutionsRefresher -} from '../useTaskExecutions'; -import { generateRowSkeleton } from './generateRowSkeleton'; -import { NoExecutionsContent } from './NoExecutionsContent'; -import { useColumnStyles } from './styles'; -import { generateColumns as generateTaskExecutionColumns } from './taskExecutionColumns'; -import { TaskExecutionRow } from './TaskExecutionRow'; -import { generateColumns as generateWorkflowExecutionColumns } from './workflowExecutionColumns'; -import { WorkflowExecutionRow } from './WorkflowExecutionRow'; +import { DetailedNodeExecutionGroup } from '../types'; +import { NodeExecutionRow } from './NodeExecutionRow'; +import { useExecutionTableStyles } from './styles'; export interface NodeExecutionChildrenProps { - execution: DetailedNodeExecution; + childGroups: DetailedNodeExecutionGroup[]; } -const TaskNodeExecutionChildren: React.FC = ({ - execution: nodeExecution +/** Renders a nested list of row items for children of a NodeExecution */ +export const NodeExecutionChildren: React.FC = ({ + childGroups }) => { - const taskExecutions = useDetailedTaskExecutions( - useTaskExecutions(nodeExecution.id) - ); - useTaskExecutionsRefresher(nodeExecution, taskExecutions); - - const columnStyles = useColumnStyles(); - // Memoizing columns so they won't be re-generated unless the styles change - const { columns, Skeleton } = React.useMemo(() => { - const columns = generateTaskExecutionColumns(columnStyles); - return { columns, Skeleton: generateRowSkeleton(columns) }; - }, [columnStyles]); + const showNames = childGroups.length > 1; + const tableStyles = useExecutionTableStyles(); + const monospaceTextStyles = useExpandableMonospaceTextStyles(); return ( - - {taskExecutions.value.length ? ( - taskExecutions.value.map(taskExecution => ( - + {childGroups.map(({ name, nodeExecutions }) => { + const rows = nodeExecutions.map(nodeExecution => ( + - )) - ) : ( - - )} - - ); -}; - -interface WorkflowNodeExecution extends DetailedNodeExecution { - closure: NodeExecutionClosure & { - workflowNodeMetadata: WorkflowNodeMetadata; - }; -} -interface WorkflowNodeExecutionChildrenProps - extends NodeExecutionChildrenProps { - execution: WorkflowNodeExecution; -} - -const executionRefreshConfig: RefreshConfig = { - interval: executionRefreshIntervalMs, - valueIsFinal: executionIsTerminal -}; - -const WorkflowNodeExecutionChildren: React.FC = ({ - execution -}) => { - const { executionId } = execution.closure.workflowNodeMetadata; - const workflowExecution = useWorkflowExecution(executionId).fetchable; - useDataRefresher(executionId, workflowExecution, executionRefreshConfig); - - const columnStyles = useColumnStyles(); - // Memoizing columns so they won't be re-generated unless the styles change - const { columns, Skeleton } = React.useMemo(() => { - const columns = generateWorkflowExecutionColumns(columnStyles); - return { columns, Skeleton: generateRowSkeleton(columns) }; - }, [columnStyles]); - return ( - - {workflowExecution.value ? ( - - ) : ( - - )} - + )); + const key = `group-${name}`; + return showNames ? ( +
+
+ {name} +
+
+ {rows} +
+
+ ) : ( +
{rows}
+ ); + })} + ); }; - -/** Renders a nested list of row items for children of a NodeExecution */ -export const NodeExecutionChildren: React.FC = props => { - const { workflowNodeMetadata } = props.execution.closure; - const { execution: topExecution } = React.useContext(ExecutionContext); - - // Nested NodeExecutions will sometimes have `workflowNodeMetadata` that - // points to the parent WorkflowExecution. We only want to expand workflow - // nodes that point to a different workflow execution - if ( - workflowNodeMetadata && - !isEqual(workflowNodeMetadata.executionId, topExecution.id) - ) { - return ( - - ); - } - return ; -}; diff --git a/src/components/Executions/Tables/NodeExecutionRow.tsx b/src/components/Executions/Tables/NodeExecutionRow.tsx index 89232b182..41205b3bb 100644 --- a/src/components/Executions/Tables/NodeExecutionRow.tsx +++ b/src/components/Executions/Tables/NodeExecutionRow.tsx @@ -1,49 +1,58 @@ import * as classnames from 'classnames'; import { useExpandableMonospaceTextStyles } from 'components/common/ExpandableMonospaceText'; import * as React from 'react'; -import { NodeExecutionsRequestConfigContext } from '../ExecutionDetails/contexts'; +import { + ExecutionContext, + NodeExecutionsRequestConfigContext +} from '../contexts'; import { DetailedNodeExecution } from '../types'; import { useChildNodeExecutions } from '../useChildNodeExecutions'; +import { useDetailedChildNodeExecutions } from '../useDetailedNodeExecutions'; import { NodeExecutionsTableContext } from './contexts'; import { ExpandableExecutionError } from './ExpandableExecutionError'; import { NodeExecutionChildren } from './NodeExecutionChildren'; import { RowExpander } from './RowExpander'; import { selectedClassName, useExecutionTableStyles } from './styles'; -import { NodeExecutionColumnDefinition } from './types'; interface NodeExecutionRowProps { - columns: NodeExecutionColumnDefinition[]; execution: DetailedNodeExecution; style?: React.CSSProperties; } /** Renders a NodeExecution as a row inside a `NodeExecutionsTable` */ export const NodeExecutionRow: React.FC = ({ - columns, - execution, + execution: nodeExecution, style }) => { - const state = React.useContext(NodeExecutionsTableContext); + const { columns, state } = React.useContext(NodeExecutionsTableContext); const requestConfig = React.useContext(NodeExecutionsRequestConfigContext); + const { execution: workflowExecution } = React.useContext(ExecutionContext); const [expanded, setExpanded] = React.useState(false); const toggleExpanded = () => { setExpanded(!expanded); }; - const childNodeExecutions = useChildNodeExecutions( - execution, - requestConfig + // TODO: Handle error case for loading children. + // Maybe show an expander in that case and make the content the error? + const childNodeExecutions = useChildNodeExecutions({ + nodeExecution, + requestConfig, + workflowExecution + }); + + const detailedChildNodeExecutions = useDetailedChildNodeExecutions( + childNodeExecutions.value ); - const isExpandable = childNodeExecutions.value.length > 0; + const isExpandable = detailedChildNodeExecutions.length > 0; const tableStyles = useExecutionTableStyles(); const monospaceTextStyles = useExpandableMonospaceTextStyles(); const selected = state.selectedExecution - ? state.selectedExecution === execution + ? state.selectedExecution === nodeExecution : false; - const { error } = execution.closure; + const { error } = nodeExecution.closure; const expanderContent = isExpandable ? ( @@ -61,7 +70,7 @@ export const NodeExecutionRow: React.FC = ({ )} > {errorContent} - + ) : ( errorContent @@ -82,8 +91,8 @@ export const NodeExecutionRow: React.FC = ({ className={classnames(tableStyles.rowColumn, className)} > {cellRenderer({ - execution, - state + state, + execution: nodeExecution })} ))} diff --git a/src/components/Executions/Tables/NodeExecutionsTable.tsx b/src/components/Executions/Tables/NodeExecutionsTable.tsx index 0bc651106..907b523ae 100644 --- a/src/components/Executions/Tables/NodeExecutionsTable.tsx +++ b/src/components/Executions/Tables/NodeExecutionsTable.tsx @@ -2,9 +2,9 @@ import * as classnames from 'classnames'; import { DetailsPanel, ListProps } from 'components/common'; import { useCommonStyles } from 'components/common/styles'; import * as scrollbarSize from 'dom-helpers/util/scrollbarSize'; +import { NodeExecution } from 'models/Execution/types'; import * as React from 'react'; import { NodeExecutionDetails } from '../ExecutionDetails/NodeExecutionDetails'; -import { DetailedNodeExecution } from '../types'; import { NodeExecutionsTableContext } from './contexts'; import { ExecutionsTableHeader } from './ExecutionsTableHeader'; import { generateColumns } from './nodeExecutionColumns'; @@ -13,8 +13,7 @@ import { NoExecutionsContent } from './NoExecutionsContent'; import { useColumnStyles, useExecutionTableStyles } from './styles'; import { useNodeExecutionsTableState } from './useNodeExecutionsTableState'; -export interface NodeExecutionsTableProps - extends ListProps {} +export interface NodeExecutionsTableProps extends ListProps {} const scrollbarPadding = scrollbarSize(); @@ -36,7 +35,7 @@ export const NodeExecutionsTable: React.FC = props => const onCloseDetailsPanel = () => state.setSelectedExecution(null); - const rowProps = { columns, state, onHeightChange: () => {} }; + const rowProps = { state, onHeightChange: () => {} }; const content = state.executions.length > 0 ? ( state.executions.map(execution => { @@ -63,7 +62,7 @@ export const NodeExecutionsTable: React.FC = props => columns={columns} scrollbarPadding={scrollbarPadding} /> - +
{content}
= ({ - taskExecution -}) => { - const sort = { - key: executionSortFields.createdAt, - direction: SortDirection.ASCENDING - }; - const nodeExecutions = useDetailedNodeExecutions( - useTaskExecutionChildren(taskExecution.id, { - sort, - limit: limits.NONE - }) - ); - - const columnStyles = useColumnStyles(); - // Memoizing columns so they won't be re-generated unless the styles change - const { columns, Skeleton } = React.useMemo(() => { - const columns = generateColumns(columnStyles); - return { columns, Skeleton: generateRowSkeleton(columns) }; - }, [columnStyles]); - return ( - - {nodeExecutions.value.length ? ( - nodeExecutions.value.map(execution => ( - - )) - ) : ( - - )} - - ); -}; diff --git a/src/components/Executions/Tables/TaskExecutionRow.tsx b/src/components/Executions/Tables/TaskExecutionRow.tsx deleted file mode 100644 index 4cd519759..000000000 --- a/src/components/Executions/Tables/TaskExecutionRow.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import * as classnames from 'classnames'; -import { useExpandableMonospaceTextStyles } from 'components/common/ExpandableMonospaceText'; -import { TaskExecution } from 'models'; -import * as React from 'react'; -import { DetailedNodeExecution, DetailedTaskExecution } from '../types'; -import { NodeExecutionsTableContext } from './contexts'; -import { ExpandableExecutionError } from './ExpandableExecutionError'; -import { RowExpander } from './RowExpander'; -import { useExecutionTableStyles } from './styles'; -import { TaskExecutionChildren } from './TaskExecutionChildren'; -import { TaskExecutionColumnDefinition } from './types'; - -interface TaskExecutionRowProps { - columns: TaskExecutionColumnDefinition[]; - execution: DetailedTaskExecution; - nodeExecution: DetailedNodeExecution; - style?: React.CSSProperties; -} - -function isExpandableExecution(execution: TaskExecution) { - return execution.isParent; -} - -/** Renders a TaskExecution as a row inside a `NodeExecutionsTable` */ -export const TaskExecutionRow: React.FC = ({ - columns, - execution, - nodeExecution, - style -}) => { - const state = React.useContext(NodeExecutionsTableContext); - const [expanded, setExpanded] = React.useState(false); - const toggleExpanded = () => { - setExpanded(!expanded); - }; - const tableStyles = useExecutionTableStyles(); - const monospaceTextStyles = useExpandableMonospaceTextStyles(); - const { error } = execution.closure; - - const expanderContent = isExpandableExecution(execution) ? ( - - ) : null; - - const errorContent = null; - // TODO: Show errors or remove this - // const errorContent = error ? ( - // - // ) : null; - - const extraContent = expanded ? ( -
- {errorContent} - -
- ) : ( - errorContent - ); - - return ( -
-
-
{expanderContent}
- {columns.map(({ className, key: columnKey, cellRenderer }) => ( -
- {cellRenderer({ - execution, - nodeExecution, - state - })} -
- ))} -
- {extraContent} -
- ); -}; diff --git a/src/components/Executions/Tables/WorkflowExecutionChildren.tsx b/src/components/Executions/Tables/WorkflowExecutionChildren.tsx deleted file mode 100644 index 753242188..000000000 --- a/src/components/Executions/Tables/WorkflowExecutionChildren.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { WaitForData } from 'components/common'; -import { waitForAllFetchables } from 'components/hooks'; -import { Execution } from 'models'; -import * as React from 'react'; -import { useWorkflowExecutionState } from '../useWorkflowExecutionState'; -import { generateRowSkeleton } from './generateRowSkeleton'; -import { generateColumns } from './nodeExecutionColumns'; -import { NodeExecutionRow } from './NodeExecutionRow'; -import { NoExecutionsContent } from './NoExecutionsContent'; -import { useColumnStyles } from './styles'; - -export interface WorkflowExecutionChildrenProps { - execution: Execution; -} - -/** Renders a nested list of row items for children of a WorkflowExecution */ -export const WorkflowExecutionChildren: React.FC = ({ - execution -}) => { - // Note: Not applying a filter to nested node executions - const { workflow, nodeExecutions } = useWorkflowExecutionState(execution); - - const columnStyles = useColumnStyles(); - // Memoizing columns so they won't be re-generated unless the styles change - const { columns, Skeleton } = React.useMemo(() => { - const columns = generateColumns(columnStyles); - return { columns, Skeleton: generateRowSkeleton(columns) }; - }, [columnStyles]); - return ( - - {nodeExecutions.value.length ? ( - nodeExecutions.value.map(execution => ( - - )) - ) : ( - - )} - - ); -}; diff --git a/src/components/Executions/Tables/WorkflowExecutionRow.tsx b/src/components/Executions/Tables/WorkflowExecutionRow.tsx deleted file mode 100644 index 6b0388fbb..000000000 --- a/src/components/Executions/Tables/WorkflowExecutionRow.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import * as classnames from 'classnames'; -import { useExpandableMonospaceTextStyles } from 'components/common/ExpandableMonospaceText'; -import { Execution } from 'models/Execution'; -import * as React from 'react'; -import { NodeExecutionsTableContext } from './contexts'; -import { ExpandableExecutionError } from './ExpandableExecutionError'; -import { RowExpander } from './RowExpander'; -import { useExecutionTableStyles } from './styles'; -import { WorkflowExecutionColumnDefinition } from './types'; -import { WorkflowExecutionChildren } from './WorkflowExecutionChildren'; -// import { WorkflowExecutionChildren } from './WorkflowExecutionChildren'; - -interface WorkflowExecutionRowProps { - columns: WorkflowExecutionColumnDefinition[]; - execution: Execution; - style?: React.CSSProperties; -} - -function isExpandableExecution(execution: Execution) { - return true; -} - -/** Renders a WorkflowExecution as a row inside a `NodeExecutionsTable` */ -export const WorkflowExecutionRow: React.FC = ({ - columns, - execution, - style -}) => { - const state = React.useContext(NodeExecutionsTableContext); - const [expanded, setExpanded] = React.useState(false); - const toggleExpanded = () => { - setExpanded(!expanded); - }; - const tableStyles = useExecutionTableStyles(); - const monospaceTextStyles = useExpandableMonospaceTextStyles(); - const { error } = execution.closure; - - const expanderContent = isExpandableExecution(execution) ? ( - - ) : null; - - const errorContent = error ? ( - - ) : null; - - const extraContent = expanded ? ( -
- {errorContent} - -
- ) : ( - errorContent - ); - - return ( -
-
-
{expanderContent}
- {columns.map(({ className, key: columnKey, cellRenderer }) => ( -
- {cellRenderer({ - execution, - state - })} -
- ))} -
- {extraContent} -
- ); -}; diff --git a/src/components/Executions/Tables/__stories__/NodeExecutionsTable.stories.tsx b/src/components/Executions/Tables/__stories__/NodeExecutionsTable.stories.tsx index 40d6b2f27..0a5dd5d92 100644 --- a/src/components/Executions/Tables/__stories__/NodeExecutionsTable.stories.tsx +++ b/src/components/Executions/Tables/__stories__/NodeExecutionsTable.stories.tsx @@ -1,19 +1,15 @@ import { makeStyles, Theme } from '@material-ui/core/styles'; import { action } from '@storybook/addon-actions'; import { storiesOf } from '@storybook/react'; -import axios from 'axios'; -// tslint:disable-next-line:import-name -import AxiosMockAdapter from 'axios-mock-adapter'; -import { mapNodeExecutionDetails } from 'components/Executions/utils'; -import { NavBar } from 'components/Navigation'; -import { Admin } from 'flyteidl'; -import { encodeProtoPayload } from 'models'; +import { mockAPIContextValue } from 'components/data/__mocks__/apiContext'; +import { ExecutionDataCacheContext } from 'components/Executions/contexts'; +import { createExecutionDataCache } from 'components/Executions/useExecutionDataCache'; import { createMockWorkflow, createMockWorkflowClosure } from 'models/__mocks__/workflowData'; import { createMockNodeExecutions } from 'models/Execution/__mocks__/mockNodeExecutionsData'; -import { createMockTaskExecutionsListResponse } from 'models/Execution/__mocks__/mockTaskExecutionsData'; +import { Execution } from 'models/Execution/types'; import { mockTasks } from 'models/Task/__mocks__/mockTaskData'; import * as React from 'react'; import { @@ -47,10 +43,18 @@ template.nodes = template.nodes.concat(mockNodes); compiledWorkflow.tasks = tasks.concat(mockTasks); mockWorkflow.closure = mockWorkflowClosure; +const apiContext = mockAPIContextValue({}); +const dataCache = createExecutionDataCache(apiContext); +dataCache.insertWorkflow(mockWorkflow); +dataCache.insertWorkflowExecutionReference( + mockExecutions[0].id.executionId, + mockWorkflow.id +); + const fetchAction = action('fetch'); const props: NodeExecutionsTableProps = { - value: mapNodeExecutionDetails(mockExecutions, mockWorkflow), + value: mockExecutions, lastError: null, loading: false, moreItemsAvailable: false, @@ -59,26 +63,13 @@ const props: NodeExecutionsTableProps = { const stories = storiesOf('Tables/NodeExecutionsTable', module); stories.addDecorator(story => { - React.useEffect(() => { - const executionsResponse = createMockTaskExecutionsListResponse(3); - const mock = new AxiosMockAdapter(axios); - mock.onGet(/.*\/task_executions\/.*/).reply(() => [ - 200, - encodeProtoPayload(executionsResponse, Admin.TaskExecutionList), - { 'Content-Type': 'application/octet-stream' } - ]); - return () => mock.restore(); - }); return ( - <> +
{story()}
- +
); }); stories.add('Basic', () => ); -stories.add('With more items available', () => ( - -)); stories.add('With no items', () => ( )); diff --git a/src/components/Executions/Tables/contexts.ts b/src/components/Executions/Tables/contexts.ts index ad2b55e43..b2be1892c 100644 --- a/src/components/Executions/Tables/contexts.ts +++ b/src/components/Executions/Tables/contexts.ts @@ -1,6 +1,14 @@ import * as React from 'react'; -import { NodeExecutionsTableState } from './types'; +import { + NodeExecutionColumnDefinition, + NodeExecutionsTableState +} from './types'; + +export interface NodeExecutionsTableContextData { + columns: NodeExecutionColumnDefinition[]; + state: NodeExecutionsTableState; +} export const NodeExecutionsTableContext = React.createContext< - NodeExecutionsTableState ->({} as NodeExecutionsTableState); + NodeExecutionsTableContextData +>({} as NodeExecutionsTableContextData); diff --git a/src/components/Executions/Tables/taskExecutionColumns.tsx b/src/components/Executions/Tables/taskExecutionColumns.tsx deleted file mode 100644 index 4847c0293..000000000 --- a/src/components/Executions/Tables/taskExecutionColumns.tsx +++ /dev/null @@ -1,180 +0,0 @@ -import { Typography } from '@material-ui/core'; -import { - formatDateLocalTimezone, - formatDateUTC, - millisecondsToHMS -} from 'common/formatters'; -import { timestampToDate } from 'common/utils'; -import { NewTargetLink } from 'components/common'; -import { useCommonStyles } from 'components/common/styles'; -import { useTheme } from 'components/Theme/useTheme'; -import { TaskExecutionPhase } from 'models/Execution/enums'; -import * as React from 'react'; -import { ExecutionStatusBadge, getTaskExecutionTimingMS } from '..'; -import { noLogsFoundString } from '../constants'; -import { getUniqueTaskExecutionName } from '../TaskExecutionsList/utils'; -import { nodeExecutionsTableColumnWidths } from './constants'; -import { SelectNodeExecutionLink } from './SelectNodeExecutionLink'; -import { useColumnStyles, useExecutionTableStyles } from './styles'; -import { - TaskExecutionCellRendererData, - TaskExecutionColumnDefinition -} from './types'; -import { splitLogLinksAtWidth } from './utils'; - -const TaskExecutionLogLinks: React.FC = ({ - execution, - nodeExecution, - state -}) => { - const tableStyles = useExecutionTableStyles(); - const commonStyles = useCommonStyles(); - const { logs = [] } = execution.closure; - const { measureTextWidth } = useTheme(); - const measuredLogs = React.useMemo( - () => - logs.map(log => ({ - ...log, - width: measureTextWidth('body1', log.name) - })), - [logs, measureTextWidth] - ); - - if (measuredLogs.length === 0) { - return ( - {noLogsFoundString} - ); - } - - // Leaving room at the end to render the "xxx More" string - const [taken, left] = splitLogLinksAtWidth( - measuredLogs, - nodeExecutionsTableColumnWidths.logs - 56 - ); - - // If we don't have enough room to render any individual links, just - // show the selection link to open the details panel - if (!taken.length) { - return ( - - ); - } - - return ( -
- {taken.map(({ name, uri }, index) => ( - - {name} - - ))} - {left.length === 0 ? null : ( - - )} -
- ); -}; - -export function generateColumns( - styles: ReturnType -): TaskExecutionColumnDefinition[] { - return [ - { - cellRenderer: ({ execution }) => - getUniqueTaskExecutionName(execution), - className: styles.columnName, - key: 'name', - label: 'node' - }, - { - cellRenderer: ({ - execution: { - closure: { phase = TaskExecutionPhase.UNDEFINED } - } - }) => , - className: styles.columnStatus, - key: 'phase', - label: 'status' - }, - { - cellRenderer: () => 'Task Execution', - className: styles.columnType, - key: 'type', - label: 'type' - }, - { - cellRenderer: ({ execution: { closure } }) => { - const { startedAt } = closure; - if (!startedAt) { - return ''; - } - const startedAtDate = timestampToDate(startedAt); - return ( - <> - - {formatDateUTC(startedAtDate)} - - - {formatDateLocalTimezone(startedAtDate)} - - - ); - }, - className: styles.columnStartedAt, - key: 'startedAt', - label: 'start time' - }, - { - cellRenderer: ({ execution }) => { - const timing = getTaskExecutionTimingMS(execution); - if (timing === null) { - return ''; - } - return ( - <> - - {millisecondsToHMS(timing.duration)} - - - {millisecondsToHMS(timing.queued)} - - - ); - }, - className: styles.columnDuration, - key: 'duration', - label: () => ( - <> - - duration - - - Queued Time - - - ) - }, - { - cellRenderer: props => , - className: styles.columnLogs, - key: 'logs', - label: 'logs' - } - ]; -} diff --git a/src/components/Executions/Tables/test/NodeExecutionChildren.test.tsx b/src/components/Executions/Tables/test/NodeExecutionChildren.test.tsx deleted file mode 100644 index b68650762..000000000 --- a/src/components/Executions/Tables/test/NodeExecutionChildren.test.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { render, waitFor } from '@testing-library/react'; -import { noExecutionsFoundString } from 'common/constants'; -import { mockAPIContextValue } from 'components/data/__mocks__/apiContext'; -import { APIContext } from 'components/data/apiContext'; -import { - DetailedNodeExecution, - NodeExecutionDisplayType -} from 'components/Executions/types'; -import { - listTaskExecutions, - NodeExecution, - SortDirection, - taskSortFields -} from 'models'; -import { mockNodeExecutionResponse } from 'models/Execution/__mocks__/mockNodeExecutionsData'; -import * as React from 'react'; -import { NodeExecutionChildren } from '../NodeExecutionChildren'; - -describe('NodeExecutionChildren', () => { - let nodeExecution: DetailedNodeExecution; - let mockListTaskExecutions: jest.Mock>; - - const renderChildren = () => - render( - - - - ); - beforeEach(() => { - nodeExecution = { - ...(mockNodeExecutionResponse as NodeExecution), - displayId: 'testNode', - displayType: NodeExecutionDisplayType.PythonTask, - cacheKey: 'abcdefg' - }; - mockListTaskExecutions = jest.fn().mockResolvedValue({ entities: [] }); - }); - - it('Renders message when no task executions exist', async () => { - const { queryByText } = renderChildren(); - await waitFor(() => {}); - expect(mockListTaskExecutions).toHaveBeenCalled(); - expect(queryByText(noExecutionsFoundString)).toBeInTheDocument(); - }); - - it('Requests items in correct order', async () => { - renderChildren(); - await waitFor(() => {}); - expect(mockListTaskExecutions).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - sort: { - key: taskSortFields.createdAt, - direction: SortDirection.ASCENDING - } - }) - ); - }); -}); diff --git a/src/components/Executions/Tables/test/NodeExecutionsTable.test.tsx b/src/components/Executions/Tables/test/NodeExecutionsTable.test.tsx index fd6dd0f02..b8e3d072f 100644 --- a/src/components/Executions/Tables/test/NodeExecutionsTable.test.tsx +++ b/src/components/Executions/Tables/test/NodeExecutionsTable.test.tsx @@ -1,22 +1,32 @@ import { render, waitFor } from '@testing-library/react'; -import { mapNodeExecutionDetails } from 'components'; import { mockAPIContextValue } from 'components/data/__mocks__/apiContext'; -import { APIContext } from 'components/data/apiContext'; -import { NodeExecutionsRequestConfigContext } from 'components/Executions/ExecutionDetails/contexts'; -import { DetailedNodeExecution } from 'components/Executions/types'; -import { FilterOperationName, RequestConfig } from 'models'; +import { APIContext, APIContextValue } from 'components/data/apiContext'; +import { createMockExecutionEntities } from 'components/Executions/__mocks__/createMockExecutionEntities'; import { - createMockWorkflow, - createMockWorkflowClosure -} from 'models/__mocks__/workflowData'; -import { createMockNodeExecutions } from 'models/Execution/__mocks__/mockNodeExecutionsData'; + ExecutionContext, + ExecutionContextData, + ExecutionDataCacheContext, + NodeExecutionsRequestConfigContext +} from 'components/Executions/contexts'; +import { ExecutionDataCache } from 'components/Executions/types'; +import { createExecutionDataCache } from 'components/Executions/useExecutionDataCache'; +import { + FilterOperationName, + getTask, + NodeExecution, + RequestConfig, + WorkflowExecutionIdentifier +} from 'models'; +import { createMockExecution } from 'models/__mocks__/executionsData'; import { createMockTaskExecutionsListResponse } from 'models/Execution/__mocks__/mockTaskExecutionsData'; import { + getExecution, listTaskExecutionChildren, listTaskExecutions } from 'models/Execution/api'; import { mockTasks } from 'models/Task/__mocks__/mockTaskData'; import * as React from 'react'; +import { Identifier } from 'typescript'; import { NodeExecutionsTable, NodeExecutionsTableProps @@ -24,8 +34,13 @@ import { describe('NodeExecutionsTable', () => { let props: NodeExecutionsTableProps; + let apiContext: APIContextValue; + let executionContext: ExecutionContextData; + let dataCache: ExecutionDataCache; let requestConfig: RequestConfig; - let mockNodeExecutions: DetailedNodeExecution[]; + let mockNodeExecutions: NodeExecution[]; + let mockGetExecution: jest.Mock>; + let mockGetTask: jest.Mock>; let mockListTaskExecutions: jest.Mock>; @@ -34,35 +49,52 @@ describe('NodeExecutionsTable', () => { >>; beforeEach(() => { + const { + nodeExecutions, + workflow, + workflowExecution + } = createMockExecutionEntities({ + workflowName: 'SampleWorkflow', + nodeExecutionCount: 2 + }); + + mockNodeExecutions = nodeExecutions; + mockListTaskExecutions = jest.fn().mockResolvedValue({ entities: [] }); mockListTaskExecutionChildren = jest .fn() .mockResolvedValue({ entities: [] }); - const { - executions: mockExecutions, - nodes: mockNodes - } = createMockNodeExecutions(1); + mockGetExecution = jest + .fn() + .mockImplementation(async (id: WorkflowExecutionIdentifier) => { + return { ...createMockExecution(id.name), id }; + }); + mockGetTask = jest.fn().mockImplementation(async (id: Identifier) => { + return { template: { ...mockTasks[0].template, id } }; + }); - const mockWorkflow = createMockWorkflow('SampleWorkflow'); - const mockWorkflowClosure = createMockWorkflowClosure(); - const compiledWorkflow = mockWorkflowClosure.compiledWorkflow!; - const { - primary: { template }, - tasks - } = compiledWorkflow; - template.nodes = template.nodes.concat(mockNodes); - compiledWorkflow.tasks = tasks.concat(mockTasks); - mockWorkflow.closure = mockWorkflowClosure; + apiContext = mockAPIContextValue({ + getExecution: mockGetExecution, + getTask: mockGetTask, + listTaskExecutions: mockListTaskExecutions, + listTaskExecutionChildren: mockListTaskExecutionChildren + }); - mockNodeExecutions = mapNodeExecutionDetails( - mockExecutions, - mockWorkflow + dataCache = createExecutionDataCache(apiContext); + dataCache.insertWorkflow(workflow); + dataCache.insertWorkflowExecutionReference( + workflowExecution.id, + workflow.id ); requestConfig = {}; + executionContext = { + execution: workflowExecution, + terminateExecution: jest.fn().mockRejectedValue('Not Implemented') + }; props = { - value: mockNodeExecutions, + value: nodeExecutions, lastError: null, loading: false, moreItemsAvailable: false, @@ -72,26 +104,31 @@ describe('NodeExecutionsTable', () => { const renderTable = () => render( - + - + + + + + ); - it('renders displayId for nodes', async () => { - const { queryByText } = renderTable(); + it('renders task name for task nodes', async () => { + const { queryAllByText } = renderTable(); await waitFor(() => {}); - const node = mockNodeExecutions[0]; - expect(queryByText(node.displayId)).toBeInTheDocument(); + const node = dataCache.getNodeForNodeExecution( + mockNodeExecutions[0].id + ); + const taskId = node?.node.taskNode?.referenceId; + expect(taskId).toBeDefined(); + const task = dataCache.getTaskTemplate(taskId!); + expect(task).toBeDefined(); + expect(queryAllByText(task!.id.name)[0]).toBeInTheDocument(); }); it('requests child node executions using configuration from context', async () => { @@ -101,8 +138,11 @@ describe('NodeExecutionsTable', () => { requestConfig.filter = [ { key: 'test', operation: FilterOperationName.EQ, value: 'test' } ]; + renderTable(); - await waitFor(() => {}); + await waitFor(() => + expect(mockListTaskExecutionChildren).toHaveBeenCalled() + ); expect(mockListTaskExecutionChildren).toHaveBeenCalledWith( taskExecutions[0].id, diff --git a/src/components/Executions/Tables/useNodeExecutionsTableState.ts b/src/components/Executions/Tables/useNodeExecutionsTableState.ts index 15c278e21..579d44abb 100644 --- a/src/components/Executions/Tables/useNodeExecutionsTableState.ts +++ b/src/components/Executions/Tables/useNodeExecutionsTableState.ts @@ -1,12 +1,18 @@ -import { useState } from 'react'; +import { useContext, useMemo, useState } from 'react'; +import { ExecutionDataCacheContext } from '../contexts'; import { DetailedNodeExecution } from '../types'; +import { mapNodeExecutionDetails } from '../utils'; import { NodeExecutionsTableProps } from './NodeExecutionsTable'; import { NodeExecutionsTableState } from './types'; -export function useNodeExecutionsTableState( - props: NodeExecutionsTableProps -): NodeExecutionsTableState { - const { value: executions } = props; +export function useNodeExecutionsTableState({ + value +}: NodeExecutionsTableProps): NodeExecutionsTableState { + const dataCache = useContext(ExecutionDataCacheContext); + const executions = useMemo( + () => mapNodeExecutionDetails(value, dataCache), + [value] + ); const [ selectedExecution, diff --git a/src/components/Executions/Tables/workflowExecutionColumns.tsx b/src/components/Executions/Tables/workflowExecutionColumns.tsx deleted file mode 100644 index d8e793d5c..000000000 --- a/src/components/Executions/Tables/workflowExecutionColumns.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import { Typography } from '@material-ui/core'; -import { - formatDateLocalTimezone, - formatDateUTC, - millisecondsToHMS -} from 'common/formatters'; -import { timestampToDate } from 'common/utils'; -import { WorkflowExecutionPhase } from 'models/Execution/enums'; -import * as React from 'react'; -import { ExecutionStatusBadge, getWorkflowExecutionTimingMS } from '..'; -import { useColumnStyles } from './styles'; -import { WorkflowExecutionColumnDefinition } from './types'; - -export function generateColumns( - styles: ReturnType -): WorkflowExecutionColumnDefinition[] { - return [ - { - cellRenderer: ({ execution }) => execution.id.name, - className: styles.columnName, - key: 'name', - label: 'node' - }, - { - cellRenderer: ({ - execution: { - closure: { phase = WorkflowExecutionPhase.UNDEFINED } - } - }) => , - className: styles.columnStatus, - key: 'phase', - label: 'status' - }, - { - cellRenderer: () => 'Workflow Execution', - className: styles.columnType, - key: 'type', - label: 'type' - }, - { - cellRenderer: ({ execution: { closure } }) => { - const { startedAt } = closure; - if (!startedAt) { - return ''; - } - const startedAtDate = timestampToDate(startedAt); - return ( - <> - - {formatDateUTC(startedAtDate)} - - - {formatDateLocalTimezone(startedAtDate)} - - - ); - }, - className: styles.columnStartedAt, - key: 'startedAt', - label: 'start time' - }, - { - cellRenderer: ({ execution }) => { - const timing = getWorkflowExecutionTimingMS(execution); - if (timing === null) { - return ''; - } - return ( - <> - - {millisecondsToHMS(timing.duration)} - - - ); - }, - className: styles.columnDuration, - key: 'duration', - label: () => ( - <> - - duration - - - Queued Time - - - ) - }, - { - // TODO: Implement this content - cellRenderer: () => null, - className: styles.columnLogs, - key: 'logs', - label: 'logs' - } - ]; -} diff --git a/src/components/Executions/TaskExecutionDetails/TaskExecutionDetails.tsx b/src/components/Executions/TaskExecutionDetails/TaskExecutionDetails.tsx index a67e24249..ba6da5250 100644 --- a/src/components/Executions/TaskExecutionDetails/TaskExecutionDetails.tsx +++ b/src/components/Executions/TaskExecutionDetails/TaskExecutionDetails.tsx @@ -7,6 +7,8 @@ import { import { TaskExecution, TaskExecutionIdentifier } from 'models'; import * as React from 'react'; import { executionRefreshIntervalMs } from '../constants'; +import { ExecutionDataCacheContext } from '../contexts'; +import { useExecutionDataCache } from '../useExecutionDataCache'; import { taskExecutionIsTerminal } from '../utils'; import { TaskExecutionDetailsAppBarContent } from './TaskExecutionDetailsAppBarContent'; import { TaskExecutionNodes } from './TaskExecutionNodes'; @@ -58,6 +60,7 @@ function routeParamsToTaskExecutionId( export const TaskExecutionDetailsContainer: React.FC = props => { const taskExecutionId = routeParamsToTaskExecutionId(props); + const dataCache = useExecutionDataCache(); const taskExecution = useTaskExecution(taskExecutionId); useDataRefresher(taskExecutionId, taskExecution, refreshConfig); @@ -67,7 +70,9 @@ export const TaskExecutionDetailsContainer: React.FC - + + + ); }; diff --git a/src/components/Executions/TaskExecutionDetails/TaskExecutionNodes.tsx b/src/components/Executions/TaskExecutionDetails/TaskExecutionNodes.tsx index e48d2380b..df7529e5f 100644 --- a/src/components/Executions/TaskExecutionDetails/TaskExecutionNodes.tsx +++ b/src/components/Executions/TaskExecutionDetails/TaskExecutionNodes.tsx @@ -1,21 +1,24 @@ import { Tab, Tabs } from '@material-ui/core'; import { makeStyles, Theme } from '@material-ui/core/styles'; import { WaitForData } from 'components/common'; -import { useDataRefresher, useTaskExecutionChildren } from 'components/hooks'; +import { useDataRefresher, useFetchableData } from 'components/hooks'; import { useTabState } from 'components/hooks/useTabState'; import { every } from 'lodash'; import { executionSortFields, limits, + NodeExecution, + RequestConfig, SortDirection, - TaskExecution + TaskExecution, + TaskExecutionIdentifier } from 'models'; import * as React from 'react'; import { executionRefreshIntervalMs, nodeExecutionIsTerminal } from '..'; +import { ExecutionDataCacheContext } from '../contexts'; import { ExecutionFilters } from '../ExecutionFilters'; import { useNodeExecutionFiltersState } from '../filters/useExecutionFiltersState'; import { NodeExecutionsTable } from '../Tables/NodeExecutionsTable'; -import { useDetailedNodeExecutions } from '../useDetailedNodeExecutions'; import { taskExecutionIsTerminal } from '../utils'; const useStyles = makeStyles((theme: Theme) => ({ @@ -41,6 +44,28 @@ const tabIds = { nodes: 'nodes' }; +interface UseCachedTaskExecutionChildrenArgs { + config: RequestConfig; + id: TaskExecutionIdentifier; +} +function useCachedTaskExecutionChildren( + args: UseCachedTaskExecutionChildrenArgs +) { + const dataCache = React.useContext(ExecutionDataCacheContext); + return useFetchableData< + NodeExecution[], + UseCachedTaskExecutionChildrenArgs + >( + { + debugName: 'CachedTaskExecutionChildren', + defaultValue: [], + doFetch: ({ id, config }) => + dataCache.getTaskExecutionChildren(id, config) + }, + args + ); +} + /** Contains the content for viewing child NodeExecutions for a TaskExecution */ export const TaskExecutionNodes: React.FC = ({ taskExecution @@ -52,13 +77,14 @@ export const TaskExecutionNodes: React.FC = ({ key: executionSortFields.createdAt, direction: SortDirection.ASCENDING }; - const nodeExecutions = useDetailedNodeExecutions( - useTaskExecutionChildren(taskExecution.id, { + const nodeExecutions = useCachedTaskExecutionChildren({ + id: taskExecution.id, + config: { sort, limit: limits.NONE, filter: filterState.appliedFilters - }) - ); + } + }); // We will continue to refresh the node executions list as long // as either the parent execution or any child is non-terminal diff --git a/src/components/Executions/TerminateExecution/useTerminateExecutionState.ts b/src/components/Executions/TerminateExecution/useTerminateExecutionState.ts index b428434d0..9129147af 100644 --- a/src/components/Executions/TerminateExecution/useTerminateExecutionState.ts +++ b/src/components/Executions/TerminateExecution/useTerminateExecutionState.ts @@ -1,5 +1,5 @@ import { useContext, useState } from 'react'; -import { ExecutionContext } from '../ExecutionDetails/contexts'; +import { ExecutionContext } from '../contexts'; /** Holds state for `TerminateExecutionForm` */ export function useTerminateExecutionState(onClose: () => void) { diff --git a/src/components/Executions/__mocks__/createMockExecutionEntities.ts b/src/components/Executions/__mocks__/createMockExecutionEntities.ts new file mode 100644 index 000000000..fd9e7021b --- /dev/null +++ b/src/components/Executions/__mocks__/createMockExecutionEntities.ts @@ -0,0 +1,45 @@ +import { cloneDeep } from 'lodash'; +import { + createMockWorkflow, + createMockWorkflowClosure +} from 'models/__mocks__/workflowData'; +import { createMockNodeExecutions } from 'models/Execution/__mocks__/mockNodeExecutionsData'; +import { mockExecution as mockWorkflowExecution } from 'models/Execution/__mocks__/mockWorkflowExecutionsData'; +import { mockTasks } from 'models/Task/__mocks__/mockTaskData'; + +interface CreateExecutionEntitiesArgs { + workflowName: string; + nodeExecutionCount: number; +} + +/** Creates the basic entities necessary to render the majority of the + * ExecutionDetails page. These can be inserted into an ExecutionDataCache + * for mocking. + */ +export function createMockExecutionEntities({ + workflowName, + nodeExecutionCount +}: CreateExecutionEntitiesArgs) { + const { executions: nodeExecutions, nodes } = createMockNodeExecutions( + nodeExecutionCount + ); + + const workflow = createMockWorkflow(workflowName); + const workflowClosure = createMockWorkflowClosure(); + const compiledWorkflow = workflowClosure.compiledWorkflow!; + const { + primary: { template }, + tasks + } = compiledWorkflow; + template.nodes = template.nodes.concat(nodes); + compiledWorkflow.tasks = tasks.concat(cloneDeep(mockTasks)); + workflow.closure = workflowClosure; + + return { + nodes, + nodeExecutions, + tasks, + workflow, + workflowExecution: mockWorkflowExecution + }; +} diff --git a/src/components/Executions/ExecutionDetails/contexts.ts b/src/components/Executions/contexts.ts similarity index 76% rename from src/components/Executions/ExecutionDetails/contexts.ts rename to src/components/Executions/contexts.ts index 8460f68d3..3a3afdebc 100644 --- a/src/components/Executions/ExecutionDetails/contexts.ts +++ b/src/components/Executions/contexts.ts @@ -1,11 +1,13 @@ import * as React from 'react'; import { Execution, NodeExecution, RequestConfig } from 'models'; +import { ExecutionDataCache } from './types'; export interface ExecutionContextData { execution: Execution; terminateExecution(cause: string): Promise; } + export const ExecutionContext = React.createContext( {} as ExecutionContextData ); @@ -16,3 +18,7 @@ export const NodeExecutionsContext = React.createContext< export const NodeExecutionsRequestConfigContext = React.createContext< RequestConfig >({}); + +export const ExecutionDataCacheContext = React.createContext< + ExecutionDataCache +>({} as ExecutionDataCache); diff --git a/src/components/Executions/types.ts b/src/components/Executions/types.ts index 4bfc3fbab..a6e13f7d6 100644 --- a/src/components/Executions/types.ts +++ b/src/components/Executions/types.ts @@ -1,4 +1,20 @@ -import { NodeExecution, TaskExecution } from 'models/Execution/types'; +import { + CompiledNode, + GloballyUniqueNode, + Identifier, + NodeId, + RequestConfig, + Workflow, + WorkflowId +} from 'models'; +import { + Execution, + NodeExecution, + NodeExecutionIdentifier, + TaskExecution, + TaskExecutionIdentifier, + WorkflowExecutionIdentifier +} from 'models/Execution/types'; import { TaskTemplate } from 'models/Task/types'; export interface ExecutionPhaseConstants { @@ -22,6 +38,15 @@ export enum NodeExecutionDisplayType { WaitableTask = 'Waitable Task' } +export interface UniqueNodeId { + workflowId: WorkflowId; + nodeId: string; +} +export interface NodeInformation { + id: UniqueNodeId; + node: CompiledNode; +} + /** An interface combining a NodeExecution with data pulled from the * corresponding Workflow Node structure. */ @@ -40,3 +65,41 @@ export interface NodeExecutionGroup { name: string; nodeExecutions: NodeExecution[]; } + +export interface DetailedNodeExecutionGroup extends NodeExecutionGroup { + nodeExecutions: DetailedNodeExecution[]; +} + +export interface ExecutionDataCache { + getNode(id: NodeId): GloballyUniqueNode | undefined; + getNodeForNodeExecution( + nodeExecutionId: NodeExecutionIdentifier + ): GloballyUniqueNode | null | undefined; + getNodeExecutions( + workflowExecutionId: WorkflowExecutionIdentifier, + config: RequestConfig + ): Promise; + getTaskExecutions( + nodeExecutionId: NodeExecutionIdentifier + ): Promise; + getTaskExecutionChildren: ( + taskExecutionId: TaskExecutionIdentifier, + config: RequestConfig + ) => Promise; + getTaskTemplate: (taskId: Identifier) => TaskTemplate | undefined; + getWorkflow: (workflowId: Identifier) => Promise; + getWorkflowExecution: ( + executionId: WorkflowExecutionIdentifier + ) => Promise; + getWorkflowIdForWorkflowExecution: ( + executionId: WorkflowExecutionIdentifier + ) => Promise; + insertExecution(execution: Execution): void; + insertNodes(nodes: GloballyUniqueNode[]): void; + insertTaskTemplates(templates: TaskTemplate[]): void; + insertWorkflow(workflow: Workflow): void; + insertWorkflowExecutionReference( + executionId: WorkflowExecutionIdentifier, + workflowId: WorkflowId + ): void; +} diff --git a/src/components/Executions/useChildNodeExecutions.ts b/src/components/Executions/useChildNodeExecutions.ts index 245e7bd5d..70cd4ad95 100644 --- a/src/components/Executions/useChildNodeExecutions.ts +++ b/src/components/Executions/useChildNodeExecutions.ts @@ -1,30 +1,25 @@ -import { APIContextValue, useAPIContext } from 'components/data/apiContext'; -import { - FetchableData, - fetchNodeExecutions, - fetchTaskExecutionChildren -} from 'components/hooks'; +import { FetchableData } from 'components/hooks'; import { useFetchableData } from 'components/hooks/useFetchableData'; import { isEqual } from 'lodash'; import { + Execution, NodeExecution, RequestConfig, TaskExecutionIdentifier, WorkflowExecutionIdentifier } from 'models'; -import * as React from 'react'; -import { ExecutionContext } from './ExecutionDetails/contexts'; +import { useContext } from 'react'; +import { ExecutionContext, ExecutionDataCacheContext } from './contexts'; import { formatRetryAttempt } from './TaskExecutionsList/utils'; -import { NodeExecutionGroup } from './types'; -import { fetchTaskExecutions } from './useTaskExecutions'; +import { ExecutionDataCache, NodeExecutionGroup } from './types'; interface FetchGroupForTaskExecutionArgs { - apiContext: APIContextValue; config: RequestConfig; + dataCache: ExecutionDataCache; taskExecutionId: TaskExecutionIdentifier; } async function fetchGroupForTaskExecution({ - apiContext, + dataCache, config, taskExecutionId }: FetchGroupForTaskExecutionArgs): Promise { @@ -32,60 +27,70 @@ async function fetchGroupForTaskExecution({ // NodeExecutions created by a TaskExecution are grouped // by the retry attempt of the task. name: formatRetryAttempt(taskExecutionId.retryAttempt), - nodeExecutions: await fetchTaskExecutionChildren( - { config, taskExecutionId }, - apiContext + nodeExecutions: await dataCache.getTaskExecutionChildren( + taskExecutionId, + config ) }; } interface FetchGroupForWorkflowExecutionArgs { - apiContext: APIContextValue; config: RequestConfig; + dataCache: ExecutionDataCache; workflowExecutionId: WorkflowExecutionIdentifier; } async function fetchGroupForWorkflowExecution({ - apiContext, config, + dataCache, workflowExecutionId }: FetchGroupForWorkflowExecutionArgs): Promise { return { // NodeExecutions created by a workflow execution are grouped // by the execution id, since workflow executions are not retryable. name: workflowExecutionId.name, - nodeExecutions: await fetchNodeExecutions( - { config, id: workflowExecutionId }, - apiContext + nodeExecutions: await dataCache.getNodeExecutions( + workflowExecutionId, + config ) }; } interface FetchNodeExecutionGroupArgs { - apiContext: APIContextValue; config: RequestConfig; + dataCache: ExecutionDataCache; nodeExecution: NodeExecution; } async function fetchGroupsForTaskExecutionNode({ - apiContext, config, + dataCache, nodeExecution: { id: nodeExecutionId } }: FetchNodeExecutionGroupArgs): Promise { - const taskExecutions = await fetchTaskExecutions( - nodeExecutionId, - apiContext - ); + const taskExecutions = await dataCache.getTaskExecutions(nodeExecutionId); - return await Promise.all( - taskExecutions.map(({ id: taskExecutionId }) => - fetchGroupForTaskExecution({ apiContext, config, taskExecutionId }) + // For TaskExecutions marked as parents, fetch its children and create a group. + // Otherwise, return null and we will filter it out later. + const groups = await Promise.all( + taskExecutions.map(execution => + execution.isParent + ? fetchGroupForTaskExecution({ + dataCache, + config, + taskExecutionId: execution.id + }) + : Promise.resolve(null) ) ); + + // Remove any empty groups + return groups.filter( + group => group !== null && group.nodeExecutions.length > 0 + ) as NodeExecutionGroup[]; } async function fetchGroupsForWorkflowExecutionNode({ - apiContext, config, + dataCache, nodeExecution }: FetchNodeExecutionGroupArgs): Promise { if (!nodeExecution.closure.workflowNodeMetadata) { @@ -96,35 +101,41 @@ async function fetchGroupsForWorkflowExecutionNode({ } = nodeExecution.closure.workflowNodeMetadata; // We can only have one WorkflowExecution (no retries), so there is only // one group to return. But calling code expects it as an array. - return [ - await fetchGroupForWorkflowExecution({ - apiContext, - config, - workflowExecutionId - }) - ]; + const group = await fetchGroupForWorkflowExecution({ + dataCache, + config, + workflowExecutionId + }); + return group.nodeExecutions.length > 0 ? [group] : []; +} + +export interface UseChildNodeExecutionsArgs { + requestConfig: RequestConfig; + nodeExecution: NodeExecution; + workflowExecution: Execution; } /** Fetches and groups `NodeExecution`s which are direct children of the given * `NodeExecution`. */ -export function useChildNodeExecutions( - nodeExecution: NodeExecution, - config: RequestConfig -): FetchableData { - const apiContext = useAPIContext(); +export function useChildNodeExecutions({ + nodeExecution, + requestConfig +}: UseChildNodeExecutionsArgs): FetchableData { + const { execution: topExecution } = useContext(ExecutionContext); + const dataCache = useContext(ExecutionDataCacheContext); const { workflowNodeMetadata } = nodeExecution.closure; - const { execution: topExecution } = React.useContext(ExecutionContext); return useFetchableData( { debugName: 'ChildNodeExecutions', defaultValue: [], doFetch: async data => { const fetchArgs = { - apiContext, - config, + dataCache, + config: requestConfig, nodeExecution: data }; + // Nested NodeExecutions will sometimes have `workflowNodeMetadata` that // points to the parent WorkflowExecution. We're only interested in // showing children if this node is a sub-workflow. diff --git a/src/components/Executions/useDetailedNodeExecutions.ts b/src/components/Executions/useDetailedNodeExecutions.ts index c7125ceb2..d1bdb7f7b 100644 --- a/src/components/Executions/useDetailedNodeExecutions.ts +++ b/src/components/Executions/useDetailedNodeExecutions.ts @@ -1,31 +1,38 @@ -import { useNodeExecutions, useWorkflow } from 'components/hooks'; -import { Workflow } from 'models'; -import { useMemo } from 'react'; +import { NodeExecution } from 'models'; +import { useContext, useMemo } from 'react'; +import { ExecutionDataCacheContext } from './contexts'; +import { NodeExecutionGroup } from './types'; import { mapNodeExecutionDetails } from './utils'; -/** Decorates a fetchable list of NodeExecutions generated by `useNodeExecutions`, - * mapping the list items to `DetailedNodeExecution`s. The node details are - * pulled from the closure of a fetchable `Workflow`. - * Note: This hook can generate output without a valid workflow value (i.e. before - * the workflow has finished fetching). It is the responsibility of calling code - * to use `waitForData` or `waitForAllFetchables` to prevent display of the - * output if this would be undesirable. +/** Decorates a list of NodeExecutions, mapping the list items to + * `DetailedNodeExecution`s. The node details are pulled from the the nearest + * `ExecutionContext.dataCache`. */ -export function useDetailedNodeExecutions( - nodeExecutionsFetchable: ReturnType, - workflowFetchable?: ReturnType -) { - const { value: nodeExecutions } = nodeExecutionsFetchable; - let workflow: Workflow | undefined = undefined; - if (workflowFetchable && workflowFetchable.hasLoaded) { - workflow = workflowFetchable.value; - } +export function useDetailedNodeExecutions(nodeExecutions: NodeExecution[]) { + const dataCache = useContext(ExecutionDataCacheContext); + + return useMemo(() => mapNodeExecutionDetails(nodeExecutions, dataCache), [ + nodeExecutions, + dataCache + ]); +} - return { - ...nodeExecutionsFetchable, - value: useMemo( - () => mapNodeExecutionDetails(nodeExecutions, workflow), - [nodeExecutions, workflow] - ) - }; +/** Decorates a list of `NodeExecutionGroup`s, transforming their lists of + * `NodeExecution`s into `DetailedNodeExecution`s. + */ +export function useDetailedChildNodeExecutions( + nodeExecutionGroups: NodeExecutionGroup[] +) { + const dataCache = useContext(ExecutionDataCacheContext); + return useMemo( + () => + nodeExecutionGroups.map(group => ({ + ...group, + nodeExecutions: mapNodeExecutionDetails( + group.nodeExecutions, + dataCache + ) + })), + [nodeExecutionGroups, dataCache] + ); } diff --git a/src/components/Executions/useExecutionDataCache.ts b/src/components/Executions/useExecutionDataCache.ts new file mode 100644 index 000000000..325cf4af4 --- /dev/null +++ b/src/components/Executions/useExecutionDataCache.ts @@ -0,0 +1,242 @@ +import { log } from 'common/log'; +import { getCacheKey } from 'components/Cache'; +import { APIContextValue, useAPIContext } from 'components/data/apiContext'; +import { + fetchNodeExecutions, + fetchTaskExecutionChildren +} from 'components/hooks'; +import { + extractAndIdentifyNodes, + extractTaskTemplates +} from 'components/hooks/utils'; +import { NotFoundError } from 'errors'; +import { + Execution, + GloballyUniqueNode, + Identifier, + NodeExecutionIdentifier, + NodeId, + RequestConfig, + TaskExecutionIdentifier, + TaskTemplate, + Workflow, + WorkflowExecutionIdentifier, + WorkflowId +} from 'models'; +import { useState } from 'react'; +import { ExecutionDataCache } from './types'; +import { fetchTaskExecutions } from './useTaskExecutions'; + +function cacheItems( + map: Map, + values: T[] +) { + values.forEach(v => map.set(getCacheKey(v.id), v)); +} + +/** Creates a new ExecutionDataCache which will use the provided API context + * to fetch entities. + */ +export function createExecutionDataCache( + apiContext: APIContextValue +): ExecutionDataCache { + const workflowsById: Map = new Map(); + const nodesById: Map = new Map(); + const taskTemplatesById: Map = new Map(); + const workflowExecutionIdToWorkflowId: Map = new Map(); + + const insertNodes = (nodes: GloballyUniqueNode[]) => { + cacheItems(nodesById, nodes); + }; + + const insertTaskTemplates = (templates: TaskTemplate[]) => { + cacheItems(taskTemplatesById, templates); + }; + + const insertWorkflow = (workflow: Workflow) => { + workflowsById.set(getCacheKey(workflow.id), workflow); + insertNodes(extractAndIdentifyNodes(workflow)); + insertTaskTemplates(extractTaskTemplates(workflow)); + }; + + const insertExecution = (execution: Execution) => { + workflowExecutionIdToWorkflowId.set( + getCacheKey(execution.id), + execution.closure.workflowId + ); + }; + + const insertWorkflowExecutionReference = ( + executionId: WorkflowExecutionIdentifier, + workflowId: WorkflowId + ) => { + workflowExecutionIdToWorkflowId.set( + getCacheKey(executionId), + workflowId + ); + }; + + const getWorkflow = async (id: WorkflowId) => { + const key = getCacheKey(id); + if (workflowsById.has(key)) { + return workflowsById.get(key)!; + } + const workflow = await apiContext.getWorkflow(id); + insertWorkflow(workflow); + return workflow; + }; + + const getNode = (id: NodeId) => { + const node = nodesById.get(getCacheKey(id)); + if (node === undefined) { + log.error('Unexpected Node missing from cache:', id); + } + return node; + }; + + const getNodeForNodeExecution = ({ + executionId, + nodeId + }: NodeExecutionIdentifier) => { + const workflowExecutionKey = getCacheKey(executionId); + if (!workflowExecutionIdToWorkflowId.has(workflowExecutionKey)) { + log.error( + 'Unexpected missing parent workflow execution: ', + executionId + ); + return null; + } + const workflowId = workflowExecutionIdToWorkflowId.get( + workflowExecutionKey + )!; + return getNode({ nodeId, workflowId }); + }; + + const getNodeExecutions = async ( + id: WorkflowExecutionIdentifier, + config: RequestConfig + ) => { + const execution = await getWorkflowExecution(id); + // Fetching workflow to ensure node definitions exist + const [_, nodeExecutions] = await Promise.all([ + getWorkflow(execution.closure.workflowId), + fetchNodeExecutions({ config, id }, apiContext) + ]); + return nodeExecutions; + }; + + const getTaskTemplate = (id: Identifier) => { + const template = taskTemplatesById.get(getCacheKey(id)); + if (template === undefined) { + log.error('Unexpected TaskTemplate missing from cache:', id); + } + return template; + }; + + const getOrFetchTaskTemplate = async (id: Identifier) => { + const key = getCacheKey(id); + if (taskTemplatesById.has(key)) { + return taskTemplatesById.get(key); + } + try { + const { template } = ( + await apiContext.getTask(id) + ).closure.compiledTask; + taskTemplatesById.set(key, template); + return template; + } catch (e) { + if (e instanceof NotFoundError) { + log.warn('No task template found for task: ', id); + return; + } + throw e; + } + }; + + const getWorkflowExecution = async (id: WorkflowExecutionIdentifier) => { + const execution = await apiContext.getExecution(id); + insertWorkflowExecutionReference( + execution.id, + execution.closure.workflowId + ); + return execution; + }; + + const getWorkflowIdForWorkflowExecution = async ( + id: WorkflowExecutionIdentifier + ) => { + const key = getCacheKey(id); + if (workflowExecutionIdToWorkflowId.has(key)) { + return workflowExecutionIdToWorkflowId.get(key)!; + } + return (await getWorkflowExecution(id)).closure.workflowId; + }; + + const getTaskExecutions = async (id: NodeExecutionIdentifier) => + fetchTaskExecutions(id, apiContext); + + const getTaskExecutionChildren = async ( + id: TaskExecutionIdentifier, + config: RequestConfig + ) => { + const childrenPromise = fetchTaskExecutionChildren( + { config, taskExecutionId: id }, + apiContext + ); + const workflowIdPromise = getWorkflowIdForWorkflowExecution( + id.nodeExecutionId.executionId + ); + + const cacheTaskTemplatePromise = await getOrFetchTaskTemplate( + id.taskId + ); + + const [children, workflowId, _] = await Promise.all([ + childrenPromise, + workflowIdPromise, + cacheTaskTemplatePromise + ]); + + // We need to synthesize a record for each child node, + // as they won't exist in any Workflow closure. + const nodes = children.map(node => ({ + id: { + workflowId, + nodeId: node.id.nodeId + }, + node: { + id: node.id.nodeId, + taskNode: { + referenceId: id.taskId + } + } + })); + insertNodes(nodes); + + return children; + }; + + return { + getNode, + getNodeForNodeExecution, + getNodeExecutions, + getTaskExecutions, + getTaskExecutionChildren, + getTaskTemplate, + getWorkflow, + getWorkflowExecution, + getWorkflowIdForWorkflowExecution, + insertExecution, + insertNodes, + insertTaskTemplates, + insertWorkflow, + insertWorkflowExecutionReference + }; +} + +/** A hook for creating a new ExecutionDataCache wired to the nearest `APIContext` */ +export function useExecutionDataCache() { + const apiContext = useAPIContext(); + const [dataCache] = useState(() => createExecutionDataCache(apiContext)); + return dataCache; +} diff --git a/src/components/hooks/useWorkflowExecution.ts b/src/components/Executions/useWorkflowExecution.ts similarity index 88% rename from src/components/hooks/useWorkflowExecution.ts rename to src/components/Executions/useWorkflowExecution.ts index 47fdc5020..b5f7e4d42 100644 --- a/src/components/hooks/useWorkflowExecution.ts +++ b/src/components/Executions/useWorkflowExecution.ts @@ -9,18 +9,20 @@ import { import { useAPIContext } from 'components/data/apiContext'; import { maxBlobDownloadSizeBytes } from 'components/Literals/constants'; -import { FetchableData, FetchableExecution } from './types'; -import { useFetchableData } from './useFetchableData'; +import { FetchableData, FetchableExecution } from '../hooks/types'; +import { useFetchableData } from '../hooks/useFetchableData'; +import { ExecutionDataCache } from './types'; /** A hook for fetching a WorkflowExecution */ export function useWorkflowExecution( - id: WorkflowExecutionIdentifier + id: WorkflowExecutionIdentifier, + dataCache: ExecutionDataCache ): FetchableExecution { const fetchable = useFetchableData( { debugName: 'Execution', defaultValue: {} as Execution, - doFetch: id => getExecution(id) + doFetch: id => dataCache.getWorkflowExecution(id) }, id ); diff --git a/src/components/Executions/useWorkflowExecutionState.ts b/src/components/Executions/useWorkflowExecutionState.ts index 76edd1a4c..d42b5c38b 100644 --- a/src/components/Executions/useWorkflowExecutionState.ts +++ b/src/components/Executions/useWorkflowExecutionState.ts @@ -1,7 +1,7 @@ import { useDataRefresher, - useNodeExecutions, - useWorkflow + useFetchableData, + useNodeExecutions } from 'components/hooks'; import { every } from 'lodash'; import { @@ -9,14 +9,33 @@ import { executionSortFields, FilterOperation, limits, - SortDirection + SortDirection, + Workflow, + WorkflowId } from 'models'; +import { useContext } from 'react'; import { executionIsTerminal, executionRefreshIntervalMs, nodeExecutionIsTerminal } from '.'; -import { useDetailedNodeExecutions } from './useDetailedNodeExecutions'; +import { ExecutionDataCacheContext } from './contexts'; + +/** Using a custom fetchable to make sure the related workflow is fetched + * using an ExecutionDataCache, ensuring that the extended details for NodeExecutions + * can be found. + */ +function useCachedWorkflow(id: WorkflowId) { + const dataCache = useContext(ExecutionDataCacheContext); + return useFetchableData( + { + debugName: 'Workflow', + defaultValue: {} as Workflow, + doFetch: id => dataCache.getWorkflow(id) + }, + id + ); +} /** Fetches both the workflow and nodeExecutions for a given WorkflowExecution. * Will also map node details to the node executions. @@ -34,22 +53,19 @@ export function useWorkflowExecutionState( sort, limit: limits.NONE }; - const rawNodeExecutions = useNodeExecutions( + const nodeExecutions = useNodeExecutions( execution.id, nodeExecutionsRequestConfig ); - const workflow = useWorkflow(execution.closure.workflowId); - const nodeExecutions = useDetailedNodeExecutions( - rawNodeExecutions, - workflow - ); + + const workflow = useCachedWorkflow(execution.closure.workflowId); // We will continue to refresh the node executions list as long // as either the parent execution or any child is non-terminal useDataRefresher(execution.id, nodeExecutions, { interval: executionRefreshIntervalMs, - valueIsFinal: nodeExecutions => - every(nodeExecutions, nodeExecutionIsTerminal) && + valueIsFinal: executions => + every(executions, nodeExecutionIsTerminal) && executionIsTerminal(execution) }); diff --git a/src/components/Executions/utils.ts b/src/components/Executions/utils.ts index 2f0346448..679af0505 100644 --- a/src/components/Executions/utils.ts +++ b/src/components/Executions/utils.ts @@ -36,6 +36,7 @@ import { } from './constants'; import { DetailedNodeExecution, + ExecutionDataCache, ExecutionPhaseConstants, NodeExecutionDisplayType } from './types'; @@ -114,96 +115,81 @@ export const taskExecutionIsTerminal = (taskExecution: TaskExecution) => taskExecution.closure && terminalTaskExecutionStates.includes(taskExecution.closure.phase); -/** Assigns display information to NodeExecutions. Each NodeExecution has an - * associated `nodeId`. If a `Workflow` is provided, node/task information will - * be pulled from the workflow closure to determine the node type. - */ -export function mapNodeExecutionDetails( - executions: NodeExecution[], - workflow?: Workflow +/** Populates a NodeExecution with extended information read from an `ExecutionDataCache` */ +export function populateNodeExecutionDetails( + nodeExecution: NodeExecution, + dataCache: ExecutionDataCache ) { - let nodesById: Dictionary = {}; - let taskTemplates: Dictionary = {}; + const { nodeId } = nodeExecution.id; + const cacheKey = getCacheKey(nodeExecution.id); + const nodeInfo = dataCache.getNodeForNodeExecution(nodeExecution.id); - if (workflow) { - if (!workflow.closure) { - throw new Error('Workflow has no closure'); + let displayId = nodeId; + let displayType = NodeExecutionDisplayType.Unknown; + let taskTemplate: TaskTemplate | undefined = undefined; + + if (nodeInfo == null) { + return { ...nodeExecution, cacheKey, displayId, displayType }; + } + const { node } = nodeInfo; + + if (node.branchNode) { + displayId = nodeId; + displayType = NodeExecutionDisplayType.BranchNode; + } else if (node.taskNode) { + displayType = NodeExecutionDisplayType.UnknownTask; + taskTemplate = dataCache.getTaskTemplate(node.taskNode.referenceId); + + if (!taskTemplate) { + displayType = NodeExecutionDisplayType.UnknownTask; + } else { + displayId = taskTemplate.id.name; + displayType = + taskTypeToNodeExecutionDisplayType[ + taskTemplate.type as TaskType + ]; + if (!displayType) { + displayType = NodeExecutionDisplayType.UnknownTask; + } } - if (!workflow.closure.compiledWorkflow) { - throw new Error('Workflow closure missing a compiled workflow'); + } else if (node.workflowNode) { + displayType = NodeExecutionDisplayType.Workflow; + const { launchplanRef, subWorkflowRef } = node.workflowNode; + const identifier = (launchplanRef + ? launchplanRef + : subWorkflowRef) as Identifier; + if (!identifier) { + log.warn(`Unexpected workflow node with no ref: ${nodeId}`); + } else { + displayId = identifier.name; } - - taskTemplates = keyBy(extractTaskTemplates(workflow), t => - getCacheKey(t.id) - ); - nodesById = keyBy( - workflow.closure.compiledWorkflow.primary.template.nodes, - 'id' - ); } + return { + ...nodeExecution, + cacheKey, + displayId, + displayType, + taskTemplate + }; +} + +/** Assigns display information to NodeExecutions. Each NodeExecution has an + * associated `nodeId`. Extended details are populated using the provided `ExecutionDataCache` + */ +export function mapNodeExecutionDetails( + executions: NodeExecution[], + dataCache: ExecutionDataCache +) { return executions .filter(execution => { // Exclude the start/end nodes from the renderered list const { nodeId } = execution.id; return !(nodeId === startNodeId || nodeId === endNodeId); }) - .map(execution => { - const { nodeId } = execution.id; - const node = nodesById[nodeId]; - const cacheKey = getCacheKey(execution.id); - let displayId = nodeId; - let displayType = NodeExecutionDisplayType.Unknown; - let taskTemplate: TaskTemplate | undefined = undefined; - - if (!node) { - return { ...execution, cacheKey, displayId, displayType }; - } - - if (node.branchNode) { - displayId = nodeId; - displayType = NodeExecutionDisplayType.BranchNode; - } else if (node.taskNode) { - displayType = NodeExecutionDisplayType.UnknownTask; - taskTemplate = - taskTemplates[getCacheKey(node.taskNode.referenceId)]; - - if (!taskTemplate) { - log.warn( - `Unexpected missing workflow task for node ${nodeId}` - ); - displayType = NodeExecutionDisplayType.UnknownTask; - } else { - displayId = taskTemplate.id.name; - displayType = - taskTypeToNodeExecutionDisplayType[ - taskTemplate.type as TaskType - ]; - if (!displayType) { - displayType = NodeExecutionDisplayType.UnknownTask; - } - } - } else if (node.workflowNode) { - displayType = NodeExecutionDisplayType.Workflow; - const { launchplanRef, subWorkflowRef } = node.workflowNode; - const identifier = (launchplanRef - ? launchplanRef - : subWorkflowRef) as Identifier; - if (!identifier) { - log.warn(`Unexpected workflow node with no ref: ${nodeId}`); - } else { - displayId = identifier.name; - } - } - - return { - ...execution, - cacheKey, - displayId, - displayType, - taskTemplate - }; - }); + .map(execution => + populateNodeExecutionDetails(execution, dataCache) + ); } interface GetExecutionDurationMSArgs { diff --git a/src/components/Launch/LaunchWorkflowForm/useExecutionLaunchConfiguration.ts b/src/components/Launch/LaunchWorkflowForm/useExecutionLaunchConfiguration.ts index fe0f1d54a..ba5b40fa9 100644 --- a/src/components/Launch/LaunchWorkflowForm/useExecutionLaunchConfiguration.ts +++ b/src/components/Launch/LaunchWorkflowForm/useExecutionLaunchConfiguration.ts @@ -1,5 +1,6 @@ import { log } from 'common/log'; -import { FetchableData, useWorkflowExecutionInputs } from 'components/hooks'; +import { useWorkflowExecutionInputs } from 'components/Executions/useWorkflowExecution'; +import { FetchableData } from 'components/hooks'; import { Execution, Variable } from 'models'; import { useEffect, useState } from 'react'; import { InitialLaunchParameters, LiteralValueMap } from './types'; diff --git a/src/components/common/scopedContext.ts b/src/components/common/scopedContext.ts new file mode 100644 index 000000000..e69de29bb diff --git a/src/components/data/__mocks__/apiContext.ts b/src/components/data/__mocks__/apiContext.ts index 39bb0aa11..995dea144 100644 --- a/src/components/data/__mocks__/apiContext.ts +++ b/src/components/data/__mocks__/apiContext.ts @@ -12,7 +12,9 @@ export function mockAPIContextValue( >( (out, key) => Object.assign(out, { - [key]: () => Promise.reject(` ${key} not implemented`) + [key]: () => { + throw new Error(` ${key} not implemented`); + } }), {} as APIContextValue ); diff --git a/src/components/hooks/index.ts b/src/components/hooks/index.ts index 7cacf6045..2b9cbb891 100644 --- a/src/components/hooks/index.ts +++ b/src/components/hooks/index.ts @@ -10,7 +10,6 @@ export * from './useRemoteLiteralMap'; export * from './useTask'; export * from './useTaskExecution'; export * from './useWorkflow'; -export * from './useWorkflowExecution'; export * from './useWorkflowExecutions'; export * from './useWorkflows'; export * from './useWorkflowSchedules'; diff --git a/src/components/hooks/useTask.ts b/src/components/hooks/useTask.ts index c23e4f948..368501bb2 100644 --- a/src/components/hooks/useTask.ts +++ b/src/components/hooks/useTask.ts @@ -1,5 +1,4 @@ import { useAPIContext } from 'components/data/apiContext'; -import { NotFoundError } from 'errors'; import { Identifier, IdentifierScope, @@ -11,22 +10,20 @@ import { FetchableData } from './types'; import { useFetchableData } from './useFetchableData'; import { usePagination } from './usePagination'; -/** A hook for fetching a Task template */ +/** A hook for fetching a Task template. TaskTemplates may have already been + * fetched as part of retrieving a Workflow. If not, we can retrieve the Task + * directly and read the template from there. + */ export function useTaskTemplate(id: Identifier): FetchableData { + const { getTask } = useAPIContext(); return useFetchableData( { + // Tasks are immutable useCache: true, debugName: 'TaskTemplate', defaultValue: {} as TaskTemplate, - // Fetching the parent workflow should insert these into the cache - // for us. If we get to this point, something went wrong. - doFetch: () => - Promise.reject( - new NotFoundError( - 'Task template', - 'No template has been loaded for this task' - ) - ) + doFetch: async taskId => + (await getTask(taskId)).closure.compiledTask.template }, id ); diff --git a/src/components/hooks/useWorkflow.ts b/src/components/hooks/useWorkflow.ts index 169abb1b9..5a9d67898 100644 --- a/src/components/hooks/useWorkflow.ts +++ b/src/components/hooks/useWorkflow.ts @@ -1,34 +1,25 @@ -import { useContext } from 'react'; - -import { CacheContext } from 'components/Cache'; import { useAPIContext } from 'components/data/apiContext'; import { Workflow, WorkflowId } from 'models'; import { FetchableData } from './types'; import { useFetchableData } from './useFetchableData'; -import { extractTaskTemplates } from './utils'; /** A hook for fetching a Workflow */ export function useWorkflow( id: WorkflowId | null = null ): FetchableData { - const cache = useContext(CacheContext); const { getWorkflow } = useAPIContext(); const doFetch = async (id: WorkflowId | null) => { if (id === null) { throw new Error('workflow id missing'); } - const workflow = await getWorkflow(id); - const templates = extractTaskTemplates(workflow); - cache.mergeArray(templates); - return workflow; + return getWorkflow(id); }; return useFetchableData( { doFetch, autoFetch: id !== null, - useCache: false, debugName: 'Workflow', defaultValue: {} as Workflow }, diff --git a/src/components/hooks/useWorkflows.ts b/src/components/hooks/useWorkflows.ts index 7bd400190..59c239082 100644 --- a/src/components/hooks/useWorkflows.ts +++ b/src/components/hooks/useWorkflows.ts @@ -12,7 +12,9 @@ import { usePagination } from './usePagination'; export function useWorkflows(scope: IdentifierScope, config: RequestConfig) { const { listWorkflows } = useAPIContext(); return usePagination( - { ...config, cacheItems: true, fetchArg: scope }, + // Workflows are not full records when listed, so don't + // cache them + { ...config, cacheItems: false, fetchArg: scope }, listWorkflows ); } diff --git a/src/components/hooks/utils.ts b/src/components/hooks/utils.ts index 7776e8d13..813a34ca9 100644 --- a/src/components/hooks/utils.ts +++ b/src/components/hooks/utils.ts @@ -1,4 +1,4 @@ -import { TaskTemplate, Workflow } from 'models'; +import { GloballyUniqueNode, TaskTemplate, Workflow } from 'models'; export function extractTaskTemplates(workflow: Workflow): TaskTemplate[] { if (!workflow.closure || !workflow.closure.compiledWorkflow) { @@ -6,3 +6,20 @@ export function extractTaskTemplates(workflow: Workflow): TaskTemplate[] { } return workflow.closure.compiledWorkflow.tasks.map(t => t.template); } + +export function extractAndIdentifyNodes( + workflow: Workflow +): GloballyUniqueNode[] { + if (!workflow.closure || !workflow.closure.compiledWorkflow) { + return []; + } + return workflow.closure.compiledWorkflow.primary.template.nodes.map( + node => ({ + node, + id: { + nodeId: node.id, + workflowId: workflow.id + } + }) + ); +} diff --git a/src/models/Execution/__mocks__/constants.ts b/src/models/Execution/__mocks__/constants.ts new file mode 100644 index 000000000..a9a29b756 --- /dev/null +++ b/src/models/Execution/__mocks__/constants.ts @@ -0,0 +1,7 @@ +import { WorkflowExecutionIdentifier } from '../types'; + +export const mockWorkflowExecutionId: WorkflowExecutionIdentifier = { + project: 'flytekit', + domain: 'development', + name: 'ABC456' +}; diff --git a/src/models/Execution/__mocks__/mockNodeExecutionsData.ts b/src/models/Execution/__mocks__/mockNodeExecutionsData.ts index 981dc443b..ed62d8858 100644 --- a/src/models/Execution/__mocks__/mockNodeExecutionsData.ts +++ b/src/models/Execution/__mocks__/mockNodeExecutionsData.ts @@ -5,15 +5,12 @@ import { CompiledNode } from 'models/Node'; import { mockNodes } from 'models/Node/__mocks__/mockNodeData'; import { NodeExecutionPhase } from '../enums'; import { NodeExecution } from '../types'; +import { mockWorkflowExecutionId } from './constants'; import { sampleError } from './sampleExecutionError'; export const mockNodeExecutionResponse: Admin.INodeExecution = { id: { - executionId: { - project: 'flytekit', - domain: 'development', - name: '4a580545ce6344fc9950' - }, + executionId: mockWorkflowExecutionId, nodeId: 'DefaultNodeId' }, inputUri: 's3://path/to/my/inputs.pb', diff --git a/src/models/Execution/__mocks__/mockWorkflowExecutionsData.ts b/src/models/Execution/__mocks__/mockWorkflowExecutionsData.ts index 105b80097..6960d4e9e 100644 --- a/src/models/Execution/__mocks__/mockWorkflowExecutionsData.ts +++ b/src/models/Execution/__mocks__/mockWorkflowExecutionsData.ts @@ -4,14 +4,11 @@ import { cloneDeep, random } from 'lodash'; import * as Long from 'long'; import { WorkflowExecutionPhase } from '../enums'; import { Execution } from '../types'; +import { mockWorkflowExecutionId } from './constants'; import { sampleError } from './sampleExecutionError'; export const mockWorkflowExecutionResponse: Admin.IExecution = { - id: { - project: 'flytekit', - domain: 'development', - name: 'ABC456' - }, + id: mockWorkflowExecutionId, spec: { launchPlan: { resourceType: Core.ResourceType.LAUNCH_PLAN, diff --git a/src/models/Node/types.ts b/src/models/Node/types.ts index b08201819..c54145227 100644 --- a/src/models/Node/types.ts +++ b/src/models/Node/types.ts @@ -45,3 +45,13 @@ export interface ConnectionSet extends Core.IConnectionSet { downstream: Record; upstream: Record; } + +export interface NodeId { + workflowId: Identifier; + nodeId: string; +} + +export interface GloballyUniqueNode { + id: NodeId; + node: CompiledNode; +}