diff --git a/src/components/Cache/utils.ts b/src/components/Cache/utils.ts index 40c1f6a74..c55401218 100644 --- a/src/components/Cache/utils.ts +++ b/src/components/Cache/utils.ts @@ -6,5 +6,6 @@ import * as objectHash from 'object-hash'; export function getCacheKey(id: any[] | object | string) { return typeof id === 'string' || typeof id === 'symbol' ? id - : objectHash(id); + : // We only want to compare own properties, not .__proto__, .constructor, etc. + objectHash(id, { respectType: false }); } diff --git a/src/components/Executions/ExecutionDetails/ExecutionNodeViews.tsx b/src/components/Executions/ExecutionDetails/ExecutionNodeViews.tsx index 8e1c67ade..9367b8b1b 100644 --- a/src/components/Executions/ExecutionDetails/ExecutionNodeViews.tsx +++ b/src/components/Executions/ExecutionDetails/ExecutionNodeViews.tsx @@ -8,6 +8,7 @@ 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) => ({ @@ -43,10 +44,11 @@ export const ExecutionNodeViews: React.FC = ({ const filterState = useNodeExecutionFiltersState(); const tabState = useTabState(tabIds, tabIds.nodes); - const { workflow, nodeExecutions } = useWorkflowExecutionState( - execution, - filterState.appliedFilters - ); + const { + workflow, + nodeExecutions, + nodeExecutionsRequestConfig + } = useWorkflowExecutionState(execution, filterState.appliedFilters); return ( @@ -61,10 +63,14 @@ export const ExecutionNodeViews: React.FC = ({ - + + + )} diff --git a/src/components/Executions/ExecutionDetails/contexts.ts b/src/components/Executions/ExecutionDetails/contexts.ts index a1c69244d..8460f68d3 100644 --- a/src/components/Executions/ExecutionDetails/contexts.ts +++ b/src/components/Executions/ExecutionDetails/contexts.ts @@ -1,6 +1,6 @@ import * as React from 'react'; -import { Execution, NodeExecution } from 'models'; +import { Execution, NodeExecution, RequestConfig } from 'models'; export interface ExecutionContextData { execution: Execution; @@ -12,3 +12,7 @@ export const ExecutionContext = React.createContext( export const NodeExecutionsContext = React.createContext< Dictionary >({}); + +export const NodeExecutionsRequestConfigContext = React.createContext< + RequestConfig +>({}); diff --git a/src/components/Executions/Tables/NodeExecutionRow.tsx b/src/components/Executions/Tables/NodeExecutionRow.tsx index 5dd64b1c9..89232b182 100644 --- a/src/components/Executions/Tables/NodeExecutionRow.tsx +++ b/src/components/Executions/Tables/NodeExecutionRow.tsx @@ -1,7 +1,9 @@ import * as classnames from 'classnames'; import { useExpandableMonospaceTextStyles } from 'components/common/ExpandableMonospaceText'; import * as React from 'react'; +import { NodeExecutionsRequestConfigContext } from '../ExecutionDetails/contexts'; import { DetailedNodeExecution } from '../types'; +import { useChildNodeExecutions } from '../useChildNodeExecutions'; import { NodeExecutionsTableContext } from './contexts'; import { ExpandableExecutionError } from './ExpandableExecutionError'; import { NodeExecutionChildren } from './NodeExecutionChildren'; @@ -15,10 +17,6 @@ interface NodeExecutionRowProps { style?: React.CSSProperties; } -function isExpandableExecution(execution: DetailedNodeExecution) { - return true; -} - /** Renders a NodeExecution as a row inside a `NodeExecutionsTable` */ export const NodeExecutionRow: React.FC = ({ columns, @@ -26,10 +24,19 @@ export const NodeExecutionRow: React.FC = ({ style }) => { const state = React.useContext(NodeExecutionsTableContext); + const requestConfig = React.useContext(NodeExecutionsRequestConfigContext); + const [expanded, setExpanded] = React.useState(false); const toggleExpanded = () => { setExpanded(!expanded); }; + + const childNodeExecutions = useChildNodeExecutions( + execution, + requestConfig + ); + + const isExpandable = childNodeExecutions.value.length > 0; const tableStyles = useExecutionTableStyles(); const monospaceTextStyles = useExpandableMonospaceTextStyles(); @@ -38,7 +45,7 @@ export const NodeExecutionRow: React.FC = ({ : false; const { error } = execution.closure; - const expanderContent = isExpandableExecution(execution) ? ( + const expanderContent = isExpandable ? ( ) : null; diff --git a/src/components/Executions/Tables/test/NodeExecutionsTable.test.tsx b/src/components/Executions/Tables/test/NodeExecutionsTable.test.tsx index f7b372f04..fd6dd0f02 100644 --- a/src/components/Executions/Tables/test/NodeExecutionsTable.test.tsx +++ b/src/components/Executions/Tables/test/NodeExecutionsTable.test.tsx @@ -2,13 +2,19 @@ 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 { createMockWorkflow, createMockWorkflowClosure } from 'models/__mocks__/workflowData'; import { createMockNodeExecutions } from 'models/Execution/__mocks__/mockNodeExecutionsData'; -import { listTaskExecutions } from 'models/Execution/api'; +import { createMockTaskExecutionsListResponse } from 'models/Execution/__mocks__/mockTaskExecutionsData'; +import { + listTaskExecutionChildren, + listTaskExecutions +} from 'models/Execution/api'; import { mockTasks } from 'models/Task/__mocks__/mockTaskData'; import * as React from 'react'; import { @@ -18,13 +24,20 @@ import { describe('NodeExecutionsTable', () => { let props: NodeExecutionsTableProps; + let requestConfig: RequestConfig; let mockNodeExecutions: DetailedNodeExecution[]; let mockListTaskExecutions: jest.Mock>; + let mockListTaskExecutionChildren: jest.Mock>; beforeEach(() => { mockListTaskExecutions = jest.fn().mockResolvedValue({ entities: [] }); + mockListTaskExecutionChildren = jest + .fn() + .mockResolvedValue({ entities: [] }); const { executions: mockExecutions, nodes: mockNodes @@ -46,6 +59,8 @@ describe('NodeExecutionsTable', () => { mockWorkflow ); + requestConfig = {}; + props = { value: mockNodeExecutions, lastError: null, @@ -59,10 +74,15 @@ describe('NodeExecutionsTable', () => { render( - + + + ); @@ -73,4 +93,20 @@ describe('NodeExecutionsTable', () => { const node = mockNodeExecutions[0]; expect(queryByText(node.displayId)).toBeInTheDocument(); }); + + it('requests child node executions using configuration from context', async () => { + const { taskExecutions } = createMockTaskExecutionsListResponse(1); + taskExecutions[0].isParent = true; + mockListTaskExecutions.mockResolvedValue({ entities: taskExecutions }); + requestConfig.filter = [ + { key: 'test', operation: FilterOperationName.EQ, value: 'test' } + ]; + renderTable(); + await waitFor(() => {}); + + expect(mockListTaskExecutionChildren).toHaveBeenCalledWith( + taskExecutions[0].id, + expect.objectContaining(requestConfig) + ); + }); }); diff --git a/src/components/Executions/types.ts b/src/components/Executions/types.ts index d0af41d25..4bfc3fbab 100644 --- a/src/components/Executions/types.ts +++ b/src/components/Executions/types.ts @@ -35,3 +35,8 @@ export interface DetailedNodeExecution extends NodeExecution { export interface DetailedTaskExecution extends TaskExecution { cacheKey: string; } + +export interface NodeExecutionGroup { + name: string; + nodeExecutions: NodeExecution[]; +} diff --git a/src/components/Executions/useChildNodeExecutions.ts b/src/components/Executions/useChildNodeExecutions.ts new file mode 100644 index 000000000..245e7bd5d --- /dev/null +++ b/src/components/Executions/useChildNodeExecutions.ts @@ -0,0 +1,142 @@ +import { APIContextValue, useAPIContext } from 'components/data/apiContext'; +import { + FetchableData, + fetchNodeExecutions, + fetchTaskExecutionChildren +} from 'components/hooks'; +import { useFetchableData } from 'components/hooks/useFetchableData'; +import { isEqual } from 'lodash'; +import { + NodeExecution, + RequestConfig, + TaskExecutionIdentifier, + WorkflowExecutionIdentifier +} from 'models'; +import * as React from 'react'; +import { ExecutionContext } from './ExecutionDetails/contexts'; +import { formatRetryAttempt } from './TaskExecutionsList/utils'; +import { NodeExecutionGroup } from './types'; +import { fetchTaskExecutions } from './useTaskExecutions'; + +interface FetchGroupForTaskExecutionArgs { + apiContext: APIContextValue; + config: RequestConfig; + taskExecutionId: TaskExecutionIdentifier; +} +async function fetchGroupForTaskExecution({ + apiContext, + config, + taskExecutionId +}: FetchGroupForTaskExecutionArgs): Promise { + return { + // NodeExecutions created by a TaskExecution are grouped + // by the retry attempt of the task. + name: formatRetryAttempt(taskExecutionId.retryAttempt), + nodeExecutions: await fetchTaskExecutionChildren( + { config, taskExecutionId }, + apiContext + ) + }; +} + +interface FetchGroupForWorkflowExecutionArgs { + apiContext: APIContextValue; + config: RequestConfig; + workflowExecutionId: WorkflowExecutionIdentifier; +} +async function fetchGroupForWorkflowExecution({ + apiContext, + config, + 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 + ) + }; +} + +interface FetchNodeExecutionGroupArgs { + apiContext: APIContextValue; + config: RequestConfig; + nodeExecution: NodeExecution; +} + +async function fetchGroupsForTaskExecutionNode({ + apiContext, + config, + nodeExecution: { id: nodeExecutionId } +}: FetchNodeExecutionGroupArgs): Promise { + const taskExecutions = await fetchTaskExecutions( + nodeExecutionId, + apiContext + ); + + return await Promise.all( + taskExecutions.map(({ id: taskExecutionId }) => + fetchGroupForTaskExecution({ apiContext, config, taskExecutionId }) + ) + ); +} + +async function fetchGroupsForWorkflowExecutionNode({ + apiContext, + config, + nodeExecution +}: FetchNodeExecutionGroupArgs): Promise { + if (!nodeExecution.closure.workflowNodeMetadata) { + throw new Error('Unexpected empty `workflowNodeMetadata`'); + } + const { + executionId: workflowExecutionId + } = 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 + }) + ]; +} + +/** Fetches and groups `NodeExecution`s which are direct children of the given + * `NodeExecution`. + */ +export function useChildNodeExecutions( + nodeExecution: NodeExecution, + config: RequestConfig +): FetchableData { + const apiContext = useAPIContext(); + const { workflowNodeMetadata } = nodeExecution.closure; + const { execution: topExecution } = React.useContext(ExecutionContext); + return useFetchableData( + { + debugName: 'ChildNodeExecutions', + defaultValue: [], + doFetch: async data => { + const fetchArgs = { + apiContext, + config, + 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. + if ( + workflowNodeMetadata && + !isEqual(workflowNodeMetadata.executionId, topExecution.id) + ) { + return fetchGroupsForWorkflowExecutionNode(fetchArgs); + } + return fetchGroupsForTaskExecutionNode(fetchArgs); + } + }, + nodeExecution + ); +} diff --git a/src/components/Executions/useTaskExecutions.ts b/src/components/Executions/useTaskExecutions.ts index 4d616cb6b..32f459b24 100644 --- a/src/components/Executions/useTaskExecutions.ts +++ b/src/components/Executions/useTaskExecutions.ts @@ -1,4 +1,4 @@ -import { useAPIContext } from 'components/data/apiContext'; +import { APIContextValue, useAPIContext } from 'components/data/apiContext'; import { every } from 'lodash'; import { ExecutionData, @@ -16,27 +16,38 @@ import { useFetchableData } from '../hooks/useFetchableData'; import { executionRefreshIntervalMs } from './constants'; import { nodeExecutionIsTerminal, taskExecutionIsTerminal } from './utils'; +/** Fetches a list of `TaskExecution`s which are children of the given `NodeExecution`. + * This function is meant to be consumed by hooks which are composing data. + * If you're calling it from a component, consider using `useTaskExecutions` instead. + */ +export const fetchTaskExecutions = async ( + id: NodeExecutionIdentifier, + apiContext: APIContextValue +) => { + const { listTaskExecutions } = apiContext; + const { entities } = await listTaskExecutions(id, { + limit: limits.NONE, + sort: { + key: taskSortFields.createdAt, + direction: SortDirection.ASCENDING + } + }); + return entities; +}; + /** A hook for fetching the list of TaskExecutions associated with a * NodeExecution */ export function useTaskExecutions( id: NodeExecutionIdentifier ): FetchableData { - const { listTaskExecutions } = useAPIContext(); + const apiContext = useAPIContext(); return useFetchableData( { debugName: 'TaskExecutions', defaultValue: [], - doFetch: async (id: NodeExecutionIdentifier) => { - const { entities } = await listTaskExecutions(id, { - limit: limits.NONE, - sort: { - key: taskSortFields.createdAt, - direction: SortDirection.ASCENDING - } - }); - return entities; - } + doFetch: async (id: NodeExecutionIdentifier) => + fetchTaskExecutions(id, apiContext) }, id ); @@ -57,6 +68,9 @@ export function useTaskExecutionData( ); } +/** Wraps the result of `useTaskExecutions` and will refresh the data as long + * as the given `NodeExecution` is still in a non-final state. + */ export function useTaskExecutionsRefresher( nodeExecution: NodeExecution, taskExecutionsFetchable: ReturnType diff --git a/src/components/Executions/useWorkflowExecutionState.ts b/src/components/Executions/useWorkflowExecutionState.ts index a1331cb60..76edd1a4c 100644 --- a/src/components/Executions/useWorkflowExecutionState.ts +++ b/src/components/Executions/useWorkflowExecutionState.ts @@ -29,11 +29,15 @@ export function useWorkflowExecutionState( key: executionSortFields.createdAt, direction: SortDirection.ASCENDING }; - const rawNodeExecutions = useNodeExecutions(execution.id, { + const nodeExecutionsRequestConfig = { filter, sort, limit: limits.NONE - }); + }; + const rawNodeExecutions = useNodeExecutions( + execution.id, + nodeExecutionsRequestConfig + ); const workflow = useWorkflow(execution.closure.workflowId); const nodeExecutions = useDetailedNodeExecutions( rawNodeExecutions, @@ -49,5 +53,5 @@ export function useWorkflowExecutionState( executionIsTerminal(execution) }); - return { workflow, nodeExecutions }; + return { workflow, nodeExecutions, nodeExecutionsRequestConfig }; } diff --git a/src/components/hooks/useNodeExecutions.ts b/src/components/hooks/useNodeExecutions.ts index 06260ce5e..0a8433d02 100644 --- a/src/components/hooks/useNodeExecutions.ts +++ b/src/components/hooks/useNodeExecutions.ts @@ -1,7 +1,6 @@ +import { APIContextValue, useAPIContext } from 'components/data/apiContext'; import { limits, - listNodeExecutions, - listTaskExecutionChildren, NodeExecution, RequestConfig, TaskExecutionIdentifier, @@ -19,10 +18,15 @@ interface TaskExecutionChildrenFetchData { config: RequestConfig; } -const doFetchNodeExecutions = async ({ - id, - config -}: NodeExecutionsFetchData) => { +/** Fetches a list of `NodeExecution`s which are children of a WorkflowExecution. + * This function is meant to be consumed by hooks which are composing data. + * If you're calling it from a component, consider using `useNodeExecutions` instead. + */ +export const fetchNodeExecutions = async ( + { id, config }: NodeExecutionsFetchData, + apiContext: APIContextValue +) => { + const { listNodeExecutions } = apiContext; const { entities } = await listNodeExecutions(id, { ...config, limit: limits.NONE @@ -37,20 +41,26 @@ export function useNodeExecutions( id: WorkflowExecutionIdentifier, config: RequestConfig ) { + const apiContext = useAPIContext(); return useFetchableData( { debugName: 'NodeExecutions', defaultValue: [], - doFetch: doFetchNodeExecutions + doFetch: data => fetchNodeExecutions(data, apiContext) }, { id, config } ); } -const doFetchTaskExecutionChildren = async ({ - taskExecutionId, - config -}: TaskExecutionChildrenFetchData) => { +/** Fetches a list of `NodeExecution`s which are children of the given `TaskExecution`. + * This function is meant to be consumed by hooks which are composing data. + * If you're calling it from a component, consider using `useTaskExecutionChildren` instead. + */ +export const fetchTaskExecutionChildren = async ( + { taskExecutionId, config }: TaskExecutionChildrenFetchData, + apiContext: APIContextValue +) => { + const { listTaskExecutionChildren } = apiContext; const { entities } = await listTaskExecutionChildren(taskExecutionId, { ...config, limit: limits.NONE @@ -63,11 +73,12 @@ export function useTaskExecutionChildren( taskExecutionId: TaskExecutionIdentifier, config: RequestConfig ) { + const apiContext = useAPIContext(); return useFetchableData( { debugName: 'TaskExecutionChildren', defaultValue: [], - doFetch: doFetchTaskExecutionChildren + doFetch: data => fetchTaskExecutionChildren(data, apiContext) }, { taskExecutionId, config } );