Skip to content

Commit 01b41cf

Browse files
Add badge to show diagnostics issues count on workflow page (#975)
* Add Workflow Page Diagnostics Badge that displays diagnostics issues count on diagnostics tab * Add new hook that calls useDiagnoseWorkflow to compute count of diagnostics issues * Explicitly pass only domain, cluster, workflowId and runId to useDiagnoseWorkflow to correctly cache queries across sessions
1 parent db7c744 commit 01b41cf

File tree

7 files changed

+393
-8
lines changed

7 files changed

+393
-8
lines changed
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
import { createElement, Suspense } from 'react';
2+
3+
import { HttpResponse } from 'msw';
4+
import { ZodError } from 'zod';
5+
6+
import { renderHook, waitFor } from '@/test-utils/rtl';
7+
8+
import { type DescribeWorkflowResponse } from '@/route-handlers/describe-workflow/describe-workflow.types';
9+
import { mockWorkflowDiagnosticsResult } from '@/route-handlers/diagnose-workflow/__fixtures__/mock-workflow-diagnostics-result';
10+
import { type DiagnoseWorkflowResponse } from '@/route-handlers/diagnose-workflow/diagnose-workflow.types';
11+
import { mockDescribeWorkflowResponse } from '@/views/workflow-page/__fixtures__/describe-workflow-response';
12+
13+
import useWorkflowDiagnosticsIssuesCount from '../use-workflow-diagnostics-issues-count';
14+
15+
describe(useWorkflowDiagnosticsIssuesCount.name, () => {
16+
it('should return total issues count when diagnostics is enabled and workflow is closed', async () => {
17+
const { result } = setup();
18+
19+
await waitFor(() => {
20+
expect(result.current).toBe(5); // 5 issues from mockWorkflowDiagnosticsResult
21+
});
22+
});
23+
24+
it('should return undefined when diagnostics is disabled', async () => {
25+
const { result } = setup({ isDiagnosticsEnabled: false });
26+
27+
await waitFor(() => {
28+
expect(result.current).toBeUndefined();
29+
});
30+
});
31+
32+
it('should return undefined when workflow is not closed', async () => {
33+
const { result } = setup({ isWorkflowClosed: false });
34+
35+
await waitFor(() => {
36+
expect(result.current).toBeUndefined();
37+
});
38+
});
39+
40+
it('should return undefined when diagnostics has parsing error', async () => {
41+
const { result } = setup({
42+
diagnosticsResponse: {
43+
result: mockWorkflowDiagnosticsResult,
44+
parsingError: new ZodError([]),
45+
},
46+
});
47+
48+
await waitFor(() => {
49+
expect(result.current).toBeUndefined();
50+
});
51+
});
52+
53+
it('should return undefined when diagnostics data is undefined', async () => {
54+
const { result } = setup({ diagnosticsResponse: undefined });
55+
56+
await waitFor(() => {
57+
expect(result.current).toBeUndefined();
58+
});
59+
});
60+
61+
it('should return 0 when diagnostics result has no issues', async () => {
62+
const { result } = setup({
63+
diagnosticsResponse: {
64+
result: {
65+
result: {
66+
Timeouts: null,
67+
Failures: null,
68+
Retries: null,
69+
},
70+
completed: true,
71+
},
72+
parsingError: null,
73+
},
74+
});
75+
76+
await waitFor(() => {
77+
expect(result.current).toBe(0);
78+
});
79+
});
80+
81+
it('should handle config API errors gracefully', async () => {
82+
const { result } = setup({ configError: true });
83+
84+
await waitFor(() => {
85+
expect(result.current).toBeUndefined();
86+
});
87+
});
88+
89+
it('should handle describe workflow API errors gracefully', async () => {
90+
const { result } = setup({ describeWorkflowError: true });
91+
92+
await waitFor(() => {
93+
expect(result.current).toBeUndefined();
94+
});
95+
});
96+
97+
it('should handle diagnostics API errors gracefully', async () => {
98+
const { result } = setup({ diagnosticsError: true });
99+
100+
await waitFor(() => {
101+
expect(result.current).toBeUndefined();
102+
});
103+
});
104+
});
105+
106+
function setup({
107+
isDiagnosticsEnabled = true,
108+
isWorkflowClosed = true,
109+
diagnosticsResponse = {
110+
result: mockWorkflowDiagnosticsResult,
111+
parsingError: null,
112+
},
113+
configError = false,
114+
describeWorkflowError = false,
115+
diagnosticsError = false,
116+
}: {
117+
isDiagnosticsEnabled?: boolean;
118+
isWorkflowClosed?: boolean;
119+
diagnosticsResponse?: DiagnoseWorkflowResponse;
120+
configError?: boolean;
121+
describeWorkflowError?: boolean;
122+
diagnosticsError?: boolean;
123+
} = {}) {
124+
return renderHook(
125+
() =>
126+
useWorkflowDiagnosticsIssuesCount({
127+
domain: 'test-domain',
128+
cluster: 'test-cluster',
129+
workflowId: 'test-workflow-id',
130+
runId: 'test-run-id',
131+
}),
132+
{
133+
endpointsMocks: [
134+
{
135+
path: '/api/config',
136+
httpMethod: 'GET',
137+
mockOnce: false,
138+
httpResolver: async () => {
139+
if (configError) {
140+
return HttpResponse.json(
141+
{ message: 'Failed to fetch config' },
142+
{ status: 500 }
143+
);
144+
}
145+
return HttpResponse.json(isDiagnosticsEnabled);
146+
},
147+
},
148+
{
149+
path: '/api/domains/:domain/:cluster/workflows/:workflowId/:runId',
150+
httpMethod: 'GET',
151+
mockOnce: false,
152+
httpResolver: async () => {
153+
if (describeWorkflowError) {
154+
return HttpResponse.json(
155+
{ message: 'Failed to fetch workflow' },
156+
{ status: 500 }
157+
);
158+
}
159+
return HttpResponse.json({
160+
...mockDescribeWorkflowResponse,
161+
workflowExecutionInfo: {
162+
...mockDescribeWorkflowResponse.workflowExecutionInfo,
163+
...(isWorkflowClosed
164+
? { closeStatus: 'WORKFLOW_EXECUTION_CLOSE_STATUS_FAILED' }
165+
: {
166+
closeStatus: 'WORKFLOW_EXECUTION_CLOSE_STATUS_INVALID',
167+
closeEvent: null,
168+
}),
169+
},
170+
} satisfies DescribeWorkflowResponse);
171+
},
172+
},
173+
...(isDiagnosticsEnabled
174+
? [
175+
{
176+
path: '/api/domains/:domain/:cluster/workflows/:workflowId/:runId/diagnose',
177+
httpMethod: 'GET' as const,
178+
mockOnce: false,
179+
httpResolver: async () => {
180+
if (diagnosticsError) {
181+
return HttpResponse.json(
182+
{ message: 'Failed to fetch diagnostics' },
183+
{ status: 500 }
184+
);
185+
}
186+
return HttpResponse.json(diagnosticsResponse);
187+
},
188+
},
189+
]
190+
: []),
191+
],
192+
},
193+
{
194+
wrapper: ({ children }) =>
195+
createElement(Suspense, { fallback: 'Loading' }, children),
196+
}
197+
);
198+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { useMemo } from 'react';
2+
3+
import { useQuery } from '@tanstack/react-query';
4+
5+
import useDiagnoseWorkflow from '@/views/workflow-diagnostics/hooks/use-diagnose-workflow/use-diagnose-workflow';
6+
import { type UseDiagnoseWorkflowParams } from '@/views/workflow-diagnostics/hooks/use-diagnose-workflow/use-diagnose-workflow.types';
7+
import { useDescribeWorkflow } from '@/views/workflow-page/hooks/use-describe-workflow';
8+
import getIsWorkflowDiagnosticsEnabledQueryOptions from '@/views/workflow-page/hooks/use-is-workflow-diagnostics-enabled/get-is-workflow-diagnostics-enabled-query-options';
9+
10+
export default function useWorkflowDiagnosticsIssuesCount(
11+
params: UseDiagnoseWorkflowParams
12+
): number | undefined {
13+
const { data: isWorkflowDiagnosticsEnabled } = useQuery(
14+
getIsWorkflowDiagnosticsEnabledQueryOptions()
15+
);
16+
17+
const { data: describeWorkflowResponse } = useDescribeWorkflow(params);
18+
19+
const isWorkflowClosed = Boolean(
20+
describeWorkflowResponse?.workflowExecutionInfo &&
21+
describeWorkflowResponse.workflowExecutionInfo?.closeStatus &&
22+
describeWorkflowResponse.workflowExecutionInfo.closeStatus !==
23+
'WORKFLOW_EXECUTION_CLOSE_STATUS_INVALID'
24+
);
25+
26+
const { data: diagnoseWorkflowResponse } = useDiagnoseWorkflow(params, {
27+
enabled: isWorkflowDiagnosticsEnabled && isWorkflowClosed,
28+
});
29+
30+
const totalIssuesCount = useMemo(() => {
31+
if (
32+
!isWorkflowDiagnosticsEnabled ||
33+
!describeWorkflowResponse ||
34+
!isWorkflowClosed ||
35+
!diagnoseWorkflowResponse ||
36+
diagnoseWorkflowResponse?.parsingError
37+
)
38+
return undefined;
39+
40+
return Object.values(diagnoseWorkflowResponse.result.result).reduce(
41+
(numIssuesSoFar, issuesGroup) => {
42+
if (issuesGroup === null) return numIssuesSoFar;
43+
return numIssuesSoFar + issuesGroup.issues.length;
44+
},
45+
0
46+
);
47+
}, [
48+
describeWorkflowResponse,
49+
isWorkflowDiagnosticsEnabled,
50+
isWorkflowClosed,
51+
diagnoseWorkflowResponse,
52+
]);
53+
54+
return totalIssuesCount;
55+
}

src/views/workflow-diagnostics/workflow-diagnostics.tsx

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,25 +17,28 @@ import {
1717
} from './workflow-diagnostics.constants';
1818

1919
export default function WorkflowDiagnostics({
20-
params,
20+
params: { domain, cluster, workflowId, runId },
2121
}: WorkflowPageTabContentProps) {
2222
const { data: isWorkflowDiagnosticsEnabled } =
2323
useSuspenseIsWorkflowDiagnosticsEnabled();
2424

2525
const {
2626
data: { workflowExecutionInfo },
27-
} = useSuspenseDescribeWorkflow(params);
27+
} = useSuspenseDescribeWorkflow({ domain, cluster, workflowId, runId });
2828

2929
const isWorkflowClosed = Boolean(
3030
workflowExecutionInfo?.closeStatus &&
3131
workflowExecutionInfo.closeStatus !==
3232
'WORKFLOW_EXECUTION_CLOSE_STATUS_INVALID'
3333
);
3434

35-
const { data, status } = useDiagnoseWorkflow(params, {
36-
enabled: isWorkflowDiagnosticsEnabled && isWorkflowClosed,
37-
throwOnError: true,
38-
});
35+
const { data, status } = useDiagnoseWorkflow(
36+
{ domain, cluster, workflowId, runId },
37+
{
38+
enabled: isWorkflowDiagnosticsEnabled && isWorkflowClosed,
39+
throwOnError: true,
40+
}
41+
);
3942

4043
if (!isWorkflowDiagnosticsEnabled) {
4144
throw new Error(DIAGNOSTICS_CONFIG_DISABLED_ERROR_MSG);
@@ -56,8 +59,18 @@ export default function WorkflowDiagnostics({
5659
}
5760

5861
return data.parsingError ? (
59-
<WorkflowDiagnosticsFallback {...params} diagnosticsResult={data.result} />
62+
<WorkflowDiagnosticsFallback
63+
workflowId={workflowId}
64+
runId={runId}
65+
diagnosticsResult={data.result}
66+
/>
6067
) : (
61-
<WorkflowDiagnosticsContent {...params} diagnosticsResult={data.result} />
68+
<WorkflowDiagnosticsContent
69+
domain={domain}
70+
cluster={cluster}
71+
workflowId={workflowId}
72+
runId={runId}
73+
diagnosticsResult={data.result}
74+
/>
6275
);
6376
}

src/views/workflow-page/config/workflow-page-tabs.config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import WorkflowStackTrace from '@/views/workflow-stack-trace/workflow-stack-trac
1414
import WorkflowSummaryTab from '@/views/workflow-summary-tab/workflow-summary-tab';
1515

1616
import getWorkflowPageErrorConfig from '../helpers/get-workflow-page-error-config';
17+
import WorkflowPageDiagnosticsBadge from '../workflow-page-diagnostics-badge/workflow-page-diagnostics-badge';
1718
import WorkflowPagePendingEventsBadge from '../workflow-page-pending-events-badge/workflow-page-pending-events-badge';
1819
import type { WorkflowPageTabsConfig } from '../workflow-page-tabs/workflow-page-tabs.types';
1920

@@ -45,6 +46,7 @@ const workflowPageTabsConfig: WorkflowPageTabsConfig<
4546
},
4647
diagnostics: {
4748
title: 'Diagnostics',
49+
endEnhancer: WorkflowPageDiagnosticsBadge,
4850
artwork: RiStethoscopeLine,
4951
content: WorkflowDiagnostics,
5052
getErrorConfig: getWorkflowDiagnosticsErrorConfig,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import React from 'react';
2+
3+
import { render, screen } from '@/test-utils/rtl';
4+
5+
import * as useWorkflowDiagnosticsIssuesCountModule from '@/views/shared/hooks/use-workflow-diagnostics-issues-count';
6+
7+
import WorkflowPageDiagnosticsBadge from '../workflow-page-diagnostics-badge';
8+
9+
jest.mock('next/navigation', () => ({
10+
...jest.requireActual('next/navigation'),
11+
useParams: () => ({
12+
domain: 'mock-domain',
13+
cluster: 'cluster_1',
14+
workflowId: 'mock-workflow-id',
15+
runId: 'mock-run-id',
16+
}),
17+
}));
18+
19+
jest.mock('@/views/shared/hooks/use-workflow-diagnostics-issues-count', () =>
20+
jest.fn(() => undefined)
21+
);
22+
23+
describe(WorkflowPageDiagnosticsBadge.name, () => {
24+
afterEach(() => {
25+
jest.restoreAllMocks();
26+
});
27+
28+
it('should render badge with singular issue text when count is 1', () => {
29+
jest
30+
.spyOn(useWorkflowDiagnosticsIssuesCountModule, 'default')
31+
.mockReturnValue(1);
32+
33+
render(<WorkflowPageDiagnosticsBadge />);
34+
35+
expect(screen.getByText('1 issue')).toBeInTheDocument();
36+
});
37+
38+
it('should render badge with plural issues text when count is greater than 1', () => {
39+
jest
40+
.spyOn(useWorkflowDiagnosticsIssuesCountModule, 'default')
41+
.mockReturnValue(5);
42+
43+
render(<WorkflowPageDiagnosticsBadge />);
44+
45+
expect(screen.getByText('5 issues')).toBeInTheDocument();
46+
});
47+
48+
it('should not render anything when issues count is undefined', () => {
49+
jest
50+
.spyOn(useWorkflowDiagnosticsIssuesCountModule, 'default')
51+
.mockReturnValue(undefined);
52+
53+
const { container } = render(<WorkflowPageDiagnosticsBadge />);
54+
55+
expect(container.firstChild?.firstChild).toBeNull();
56+
});
57+
58+
it('should not render anything when there are 0 issues', () => {
59+
jest
60+
.spyOn(useWorkflowDiagnosticsIssuesCountModule, 'default')
61+
.mockReturnValue(0);
62+
63+
const { container } = render(<WorkflowPageDiagnosticsBadge />);
64+
65+
expect(container.firstChild?.firstChild).toBeNull();
66+
});
67+
});

0 commit comments

Comments
 (0)