Skip to content

Commit c8c96b3

Browse files
VladimirFilonovNicholasPeretti
authored andcommitted
Fix workflow execution history is limited to 10 previous executions (elastic#238623)
## Summary closes elastic/security-team#14160 Problem: we showed only last 10 executions in Execution Tab. https://github.com/user-attachments/assets/cbf5ca2b-076c-45cd-9ccc-af7b3dd0b0f0 Solution: * Increase default limit from 10 to 20 * Added endless scrolling pagination to load more executions if required https://github.com/user-attachments/assets/1806d385-4d9c-4eab-bbb3-2f58d0ca560a ### Checklist Check the PR satisfies following conditions. Reviewers should verify this PR satisfies this list as well. - [ ] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/src/platform/packages/shared/kbn-i18n/README.md) - [ ] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials - [ ] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [ ] If a plugin configuration key changed, check if it needs to be allowlisted in the cloud and added to the [docker list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker) - [ ] This was checked for breaking HTTP API changes, and any breaking changes have been approved by the breaking-change committee. The `release_note:breaking` label should be applied in these situations. - [ ] [Flaky Test Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was used on any tests changed - [ ] The PR description includes the appropriate Release Notes section, and the correct `release_note:*` label is applied per the [guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) - [ ] Review the [backport guidelines](https://docs.google.com/document/d/1VyN5k91e5OVumlc0Gb9RPa3h1ewuPE705nRtioPiTvY/edit?usp=sharing) and apply applicable `backport:*` labels. ### Identify risks Does this PR introduce any risks? For example, consider risks like hard to test bugs, performance regression, potential of data loss. Describe the risk, its severity, and mitigation for each identified risk. Invite stakeholders and evaluate how to proceed before merging. - [ ] [See some risk examples](https://github.com/elastic/kibana/blob/main/RISK_MATRIX.mdx) - [ ] ...
1 parent 92e2383 commit c8c96b3

File tree

8 files changed

+307
-50
lines changed

8 files changed

+307
-50
lines changed

src/platform/plugins/shared/workflows_management/public/entities/workflows/model/use_workflow_executions.ts

Lines changed: 127 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,40 +7,156 @@
77
* License v3.0 only", or the "Server Side Public License, v 1".
88
*/
99

10+
import { useCallback, useEffect, useMemo, useRef } from 'react';
1011
import { useKibana } from '@kbn/kibana-react-plugin/public';
1112
import type { ExecutionStatus, ExecutionType, WorkflowExecutionListDto } from '@kbn/workflows';
12-
import { useQuery, type UseQueryOptions } from '@tanstack/react-query';
13+
import { useInfiniteQuery, type UseInfiniteQueryOptions } from '@tanstack/react-query';
14+
15+
const DEFAULT_PAGE_SIZE = 20;
16+
const MAX_RETRIES = 3;
1317

1418
interface UseWorkflowExecutionsParams {
1519
workflowId: string | null;
1620
statuses?: ExecutionStatus[];
1721
executionTypes?: ExecutionType[];
22+
perPage?: number;
1823
}
1924

2025
export function useWorkflowExecutions(
2126
params: UseWorkflowExecutionsParams,
22-
options: Omit<UseQueryOptions<WorkflowExecutionListDto>, 'queryKey' | 'queryFn'> = {}
27+
options: Omit<
28+
UseInfiniteQueryOptions<
29+
WorkflowExecutionListDto,
30+
unknown,
31+
WorkflowExecutionListDto,
32+
WorkflowExecutionListDto,
33+
(string | number | ExecutionStatus[] | ExecutionType[] | null | undefined)[]
34+
>,
35+
'queryKey' | 'queryFn' | 'getNextPageParam'
36+
> = {}
2337
) {
2438
const { http } = useKibana().services;
39+
const perPage = params.perPage ?? DEFAULT_PAGE_SIZE;
40+
41+
const queryFn = useCallback(
42+
async ({ pageParam = 1 }: { pageParam?: number }) => {
43+
return http!.get<WorkflowExecutionListDto>(`/api/workflowExecutions`, {
44+
query: {
45+
workflowId: params.workflowId,
46+
statuses: params.statuses,
47+
executionTypes: params.executionTypes,
48+
page: pageParam,
49+
perPage,
50+
},
51+
});
52+
},
53+
[http, params.workflowId, params.statuses, params.executionTypes, perPage]
54+
);
2555

26-
return useQuery<WorkflowExecutionListDto>({
56+
const getNextPageParam = useCallback((lastPage: WorkflowExecutionListDto) => {
57+
const { page, limit, total } = lastPage._pagination;
58+
const totalPages = Math.ceil(total / limit);
59+
60+
if (page >= totalPages) {
61+
return undefined;
62+
}
63+
64+
return page + 1;
65+
}, []);
66+
67+
const {
68+
data,
69+
fetchNextPage,
70+
hasNextPage,
71+
isFetched,
72+
isFetching,
73+
isLoading: isInitialLoading,
74+
refetch,
75+
error,
76+
} = useInfiniteQuery({
2777
networkMode: 'always',
2878
queryKey: [
2979
'workflows',
3080
params.workflowId,
3181
'executions',
3282
params.statuses,
3383
params.executionTypes,
84+
perPage,
3485
],
35-
queryFn: () =>
36-
http!.get(`/api/workflowExecutions`, {
37-
query: {
38-
workflowId: params.workflowId,
39-
statuses: params.statuses,
40-
executionTypes: params.executionTypes,
41-
},
42-
}),
86+
queryFn,
87+
getNextPageParam,
4388
enabled: params.workflowId !== null,
89+
retry: MAX_RETRIES,
90+
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
4491
...options,
4592
});
93+
94+
// Computed loading states for better semantics
95+
const isLoadingMore = isFetching && !isInitialLoading;
96+
97+
// Flatten all pages into a single list
98+
const allExecutions = useMemo<WorkflowExecutionListDto | null>(() => {
99+
if (!data?.pages?.length) {
100+
return null;
101+
}
102+
103+
const firstPage = data.pages[0];
104+
const allResults = data.pages.flatMap((page) => page.results);
105+
106+
return {
107+
results: allResults,
108+
_pagination: {
109+
page: data.pages.length, // Number of pages loaded
110+
limit: firstPage._pagination.limit, // Keep original page size
111+
total: firstPage._pagination.total, // Total available
112+
},
113+
};
114+
}, [data]);
115+
116+
// IntersectionObserver setup for infinite scroll
117+
const observerRef = useRef<IntersectionObserver>();
118+
const fetchNext = useCallback(
119+
async ([{ isIntersecting }]: IntersectionObserverEntry[]) => {
120+
if (isIntersecting && hasNextPage && !isInitialLoading && !isFetching) {
121+
await fetchNextPage();
122+
// Don't disconnect - the observer will be reattached to the new last element
123+
}
124+
},
125+
[fetchNextPage, hasNextPage, isFetching, isInitialLoading]
126+
);
127+
128+
useEffect(() => {
129+
return () => observerRef.current?.disconnect();
130+
}, []);
131+
132+
// Attaches an intersection observer to the last element
133+
// to trigger a callback to paginate when the user scrolls to it
134+
const setPaginationObserver = useCallback(
135+
(ref: HTMLDivElement | null) => {
136+
observerRef.current?.disconnect();
137+
138+
if (!ref) {
139+
return;
140+
}
141+
142+
observerRef.current = new IntersectionObserver(fetchNext, {
143+
root: null,
144+
rootMargin: '0px',
145+
threshold: 0.1,
146+
});
147+
observerRef.current.observe(ref);
148+
},
149+
[fetchNext]
150+
);
151+
152+
return {
153+
data: allExecutions,
154+
isInitialLoading,
155+
isLoadingMore,
156+
isFetched,
157+
hasNextPage,
158+
error,
159+
refetch,
160+
setPaginationObserver,
161+
};
46162
}

src/platform/plugins/shared/workflows_management/public/features/workflow_execution_list/ui/workflow_execution_list.stories.tsx

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,8 +113,11 @@ export const Default: Story = {
113113
total: 8,
114114
},
115115
},
116+
isInitialLoading: false,
117+
isLoadingMore: false,
116118
onExecutionClick: () => {},
117119
selectedId: '2',
120+
setPaginationObserver: () => {},
118121
},
119122
};
120123

@@ -128,28 +131,78 @@ export const Empty: Story = {
128131
total: 0,
129132
},
130133
},
134+
isInitialLoading: false,
135+
isLoadingMore: false,
131136
selectedId: null,
132137
filters: mockFilters,
133138
onFiltersChange: () => {},
139+
onExecutionClick: () => {},
140+
setPaginationObserver: () => {},
134141
},
135142
};
136143

137144
export const Loading: Story = {
138145
args: {
139-
isLoading: true,
146+
isInitialLoading: true,
147+
isLoadingMore: false,
140148
error: null,
141149
selectedId: null,
142150
filters: mockFilters,
143151
onFiltersChange: () => {},
152+
onExecutionClick: () => {},
153+
setPaginationObserver: () => {},
144154
},
145155
};
146156

147157
export const ErrorStory: Story = {
148158
args: {
149-
isLoading: false,
159+
isInitialLoading: false,
160+
isLoadingMore: false,
150161
error: new Error('Internal server error'),
151162
selectedId: null,
152163
filters: mockFilters,
153164
onFiltersChange: () => {},
165+
onExecutionClick: () => {},
166+
setPaginationObserver: () => {},
167+
},
168+
};
169+
170+
export const LoadingMore: Story = {
171+
args: {
172+
executions: {
173+
results: [
174+
{
175+
id: '1',
176+
status: ExecutionStatus.COMPLETED,
177+
startedAt: new Date().toISOString(),
178+
finishedAt: new Date().toISOString(),
179+
spaceId: 'default',
180+
duration: parseDuration('1h2m'),
181+
stepId: 'my_first_step',
182+
},
183+
{
184+
id: '2',
185+
status: ExecutionStatus.FAILED,
186+
startedAt: new Date().toISOString(),
187+
finishedAt: new Date().toISOString(),
188+
spaceId: 'default',
189+
duration: parseDuration('1d2h'),
190+
stepId: 'my_first_step',
191+
},
192+
],
193+
_pagination: {
194+
page: 1,
195+
limit: 10,
196+
total: 20,
197+
},
198+
},
199+
isInitialLoading: false,
200+
isLoadingMore: true,
201+
error: null,
202+
selectedId: null,
203+
filters: mockFilters,
204+
onFiltersChange: () => {},
205+
onExecutionClick: () => {},
206+
setPaginationObserver: () => {},
154207
},
155208
};

src/platform/plugins/shared/workflows_management/public/features/workflow_execution_list/ui/workflow_execution_list.tsx

Lines changed: 62 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import {
1919
EuiFlexItem,
2020
} from '@elastic/eui';
2121
import { type WorkflowExecutionListDto } from '@kbn/workflows';
22-
import React from 'react';
22+
import React, { useEffect, useRef } from 'react';
2323
import { FormattedMessage } from '@kbn/i18n-react';
2424
import { css } from '@emotion/react';
2525
import { useMemoCss } from '@kbn/css-utils/public/use_memo_css';
@@ -31,10 +31,12 @@ export interface WorkflowExecutionListProps {
3131
executions: WorkflowExecutionListDto | null;
3232
filters: ExecutionListFiltersQueryParams;
3333
onFiltersChange: (filters: ExecutionListFiltersQueryParams) => void;
34-
isLoading: boolean;
34+
isInitialLoading: boolean;
35+
isLoadingMore: boolean;
3536
error: Error | null;
3637
onExecutionClick: (executionId: string) => void;
3738
selectedId: string | null;
39+
setPaginationObserver: (ref: HTMLDivElement | null) => void;
3840
}
3941

4042
// TODO: use custom table? add pagination and search
@@ -44,17 +46,27 @@ const emptyPromptCommonProps: EuiEmptyPromptProps = { titleSize: 'xs', paddingSi
4446
export const WorkflowExecutionList = ({
4547
filters,
4648
onFiltersChange,
47-
isLoading,
49+
isInitialLoading,
50+
isLoadingMore,
4851
error,
4952
executions,
5053
onExecutionClick,
5154
selectedId,
55+
setPaginationObserver,
5256
}: WorkflowExecutionListProps) => {
5357
const styles = useMemoCss(componentStyles);
58+
const scrollableContentRef = useRef<HTMLDivElement>(null);
59+
60+
// Reset scroll position when filters change
61+
useEffect(() => {
62+
if (scrollableContentRef.current) {
63+
scrollableContentRef.current.scrollTop = 0;
64+
}
65+
}, [filters.statuses, filters.executionTypes]);
5466

5567
let content: React.ReactNode = null;
5668

57-
if (isLoading) {
69+
if (isInitialLoading) {
5870
content = (
5971
<EuiEmptyPrompt
6072
{...emptyPromptCommonProps}
@@ -112,20 +124,42 @@ export const WorkflowExecutionList = ({
112124
/>
113125
);
114126
} else {
127+
const lastExecutionId = executions.results[executions.results.length - 1]?.id;
128+
115129
content = (
116-
<EuiFlexGroup direction="column" gutterSize="s">
117-
{executions.results.map((execution) => (
118-
<EuiFlexItem key={execution.id} grow={false}>
119-
<WorkflowExecutionListItem
120-
status={execution.status}
121-
startedAt={new Date(execution.startedAt)}
122-
duration={execution.duration}
123-
selected={execution.id === selectedId}
124-
onClick={() => onExecutionClick(execution.id)}
125-
/>
126-
</EuiFlexItem>
127-
))}
128-
</EuiFlexGroup>
130+
<>
131+
<EuiFlexGroup direction="column" gutterSize="s">
132+
{executions.results.map((execution) => (
133+
<React.Fragment key={execution.id}>
134+
<EuiFlexItem grow={false}>
135+
<WorkflowExecutionListItem
136+
status={execution.status}
137+
startedAt={new Date(execution.startedAt)}
138+
duration={execution.duration}
139+
selected={execution.id === selectedId}
140+
onClick={() => onExecutionClick(execution.id)}
141+
/>
142+
</EuiFlexItem>
143+
{/* Observer element for infinite scrolling - attached to last item */}
144+
{execution.id === lastExecutionId && (
145+
<div
146+
ref={setPaginationObserver}
147+
css={css`
148+
height: 1px;
149+
`}
150+
/>
151+
)}
152+
</React.Fragment>
153+
))}
154+
</EuiFlexGroup>
155+
{isLoadingMore && (
156+
<EuiFlexGroup justifyContent="center" css={css({ marginTop: '8px' })}>
157+
<EuiFlexItem grow={false}>
158+
<EuiLoadingSpinner size="m" />
159+
</EuiFlexItem>
160+
</EuiFlexGroup>
161+
)}
162+
</>
129163
);
130164
}
131165

@@ -153,8 +187,10 @@ export const WorkflowExecutionList = ({
153187
</EuiFlexItem>
154188
</EuiFlexGroup>
155189
</header>
156-
<EuiFlexItem grow={true} css={styles.scrollableContent}>
157-
{content}
190+
<EuiFlexItem grow={true} css={styles.scrollableWrapper}>
191+
<div ref={scrollableContentRef} css={styles.scrollableContent}>
192+
{content}
193+
</div>
158194
</EuiFlexItem>
159195
</EuiFlexGroup>
160196
);
@@ -167,9 +203,11 @@ const componentStyles = {
167203
height: '100%',
168204
overflow: 'hidden',
169205
}),
170-
scrollableContent: ({ euiTheme }: UseEuiTheme) =>
171-
css({
172-
overflowY: 'auto',
173-
minHeight: 0, // This is important for flex items to shrink properly
174-
}),
206+
scrollableWrapper: css({
207+
minHeight: 0,
208+
}),
209+
scrollableContent: css({
210+
height: '100%',
211+
overflowY: 'auto',
212+
}),
175213
};

0 commit comments

Comments
 (0)