Skip to content

Commit 27fdf36

Browse files
authored
Workflow stack trace (#689)
* workflow stack trace * Add more unit tests * remove console.log * fix typecheck
1 parent fa07507 commit 27fdf36

File tree

9 files changed

+329
-3
lines changed

9 files changed

+329
-3
lines changed

src/components/page-section/page-section.styles.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,15 @@ import { getMediaQueryMargins } from '@/utils/media-query/get-media-queries-marg
44

55
export const styled = {
66
PageSection: createStyled('section', ({ $theme }: { $theme: Theme }) => ({
7+
width: '100%',
8+
margin: '0 auto',
9+
paddingLeft: $theme.sizing.scale600,
10+
paddingRight: $theme.sizing.scale600,
11+
// override default styles by media query specific styles
712
...getMediaQueryMargins($theme, (margin) => ({
813
maxWidth: `${$theme.grid.maxWidth + 2 * margin}px`,
914
paddingRight: `${margin}px`,
1015
paddingLeft: `${margin}px`,
11-
width: '100%',
12-
margin: '0 auto',
1316
})),
1417
})),
1518
};

src/utils/grpc/grpc-error.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ export type GRPCInputError = Error & {
3737

3838
export function getHTTPStatusCode(error: unknown) {
3939
if (error instanceof GRPCError) {
40-
return error.httpStatusCode;
40+
return error.httpStatusCode || 500;
4141
}
4242
return 500;
4343
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import WorkflowHistory from '@/views/workflow-history/workflow-history';
22
import WorkflowQueries from '@/views/workflow-queries/workflow-queries';
3+
import WorkflowStackTrace from '@/views/workflow-stack-trace/workflow-stack-trace';
34
import WorkflowSummaryTab from '@/views/workflow-summary-tab/workflow-summary-tab';
45

56
import type { WorkflowPageTabsContentsMap } from '../workflow-page-tab-content/workflow-page-tab-content.types';
@@ -8,6 +9,7 @@ const workflowPageTabsContentsMapConfig = {
89
summary: WorkflowSummaryTab,
910
history: WorkflowHistory,
1011
queries: WorkflowQueries,
12+
stackTrace: WorkflowStackTrace,
1113
} as const satisfies WorkflowPageTabsContentsMap;
1214

1315
export default workflowPageTabsContentsMapConfig;

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ const workflowPageTabsErrorConfig: WorkflowPageTabsErrorConfig = {
88
getWorkflowPageErrorConfig(err, 'Failed to load workflow history'),
99
queries: (err) =>
1010
getWorkflowPageErrorConfig(err, 'Failed to load workflow queries'),
11+
stackTrace: (err) =>
12+
getWorkflowPageErrorConfig(err, 'Failed to load workflow stack trace'),
1113
} as const;
1214

1315
export default workflowPageTabsErrorConfig;

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {
22
MdListAlt,
33
MdOutlineHistory,
44
MdOutlineManageSearch,
5+
MdOutlineTerminal,
56
} from 'react-icons/md';
67

78
import type { WorkflowPageTabs } from '../workflow-page-tabs/workflow-page-tabs.types';
@@ -22,6 +23,11 @@ const workflowPageTabsConfig = [
2223
title: 'Queries',
2324
artwork: MdOutlineManageSearch,
2425
},
26+
{
27+
key: 'stackTrace',
28+
title: 'Stack Trace',
29+
artwork: MdOutlineTerminal,
30+
},
2531
] as const satisfies WorkflowPageTabs;
2632

2733
export default workflowPageTabsConfig;

src/views/workflow-page/workflow-page-tabs-error/__tests__/workflow-page-tabs-error.test.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ jest.mock(
3333
queries: () => ({
3434
message: 'queries error',
3535
}),
36+
stackTrace: () => ({
37+
message: 'stackTrace error',
38+
}),
3639
}) as const satisfies WorkflowPageTabsErrorConfig
3740
);
3841

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
import { Suspense } from 'react';
2+
3+
import { HttpResponse } from 'msw';
4+
5+
import {
6+
render,
7+
screen,
8+
userEvent,
9+
waitForElementToBeRemoved,
10+
} from '@/test-utils/rtl';
11+
12+
import { type QueryWorkflowResponse } from '@/route-handlers/query-workflow/query-workflow.types';
13+
14+
import WorkflowStackTrace from '../workflow-stack-trace';
15+
16+
jest.mock('@/components/error-panel/error-panel', () =>
17+
jest.fn(() => <div>Error Panel</div>)
18+
);
19+
20+
jest.mock('baseui/skeleton', () => ({
21+
Skeleton: jest.fn(() => <div>Loading Skeleton</div>),
22+
}));
23+
jest.mock('baseui/spinner', () => ({
24+
Spinner: jest.fn(() => <div data-testid="spinner"></div>),
25+
}));
26+
27+
describe('WorkflowStackTrace', () => {
28+
it('should render correctly with no fetching or error', async () => {
29+
await setup({ resultStackTrace: 'result stack trace' });
30+
expect(screen.getByText(/Last updated/)).toBeInTheDocument();
31+
expect(screen.getByText('result stack trace')).toBeInTheDocument();
32+
expect(screen.queryByText('Error Panel')).not.toBeInTheDocument();
33+
expect(screen.queryByText('Loading Skeleton')).not.toBeInTheDocument();
34+
});
35+
36+
it('should show loading state on refetch', async () => {
37+
const { getRequestResolver } = await setup({
38+
resolveRefreshManually: true,
39+
});
40+
41+
const button = await screen.findByRole('button', { name: /Refresh/i });
42+
await userEvent.click(button);
43+
expect(screen.getByText('Loading Skeleton')).toBeInTheDocument();
44+
expect(screen.queryByText('Error Panel')).not.toBeInTheDocument();
45+
expect(screen.queryByText('test stack trace')).not.toBeInTheDocument();
46+
47+
const refetchResolver = getRequestResolver();
48+
refetchResolver();
49+
expect(await screen.findByText('test stack trace')).toBeInTheDocument();
50+
expect(screen.queryByText('Loading Skeleton')).not.toBeInTheDocument();
51+
expect(screen.queryByText('Error Panel')).not.toBeInTheDocument();
52+
});
53+
54+
it('should disable the refresh button when refetching', async () => {
55+
await setup({
56+
resolveRefreshManually: true,
57+
});
58+
59+
const refreshButton = await screen.findByRole('button', {
60+
name: /Refresh/i,
61+
});
62+
await userEvent.click(refreshButton);
63+
64+
expect(refreshButton).toBeDisabled();
65+
});
66+
67+
it('should show error state when refetch fails', async () => {
68+
const { getRequestRejector } = await setup({
69+
resolveRefreshManually: true,
70+
});
71+
const button = await screen.findByRole('button', { name: /Refresh/i });
72+
await userEvent.click(button);
73+
const refetchRejector = getRequestRejector();
74+
refetchRejector();
75+
76+
expect(await screen.findByText('Error Panel')).toBeInTheDocument();
77+
expect(screen.queryByText('Loading Skeleton')).not.toBeInTheDocument();
78+
});
79+
it('should display no stack trace when data.result is null or empty', async () => {
80+
await setup({
81+
resultStackTrace: '',
82+
});
83+
expect(screen.getByText('No stack trace...')).toBeInTheDocument();
84+
});
85+
86+
it('should show spinner on refresh button when fetching', async () => {
87+
await setup({
88+
resolveRefreshManually: true,
89+
});
90+
91+
const refreshButton = await screen.findByRole('button', {
92+
name: /Refresh/i,
93+
});
94+
await userEvent.click(refreshButton);
95+
96+
expect(screen.getByTestId('spinner')).toBeInTheDocument();
97+
});
98+
});
99+
100+
async function setup({
101+
initReqError,
102+
resolveRefreshManually,
103+
resultStackTrace = 'test stack trace',
104+
}: {
105+
initReqError?: boolean;
106+
resolveRefreshManually?: boolean;
107+
resultStackTrace?: string;
108+
}) {
109+
let requestResolver = () => {};
110+
let requestRejector = () => {};
111+
const getRequestResolver = () => requestResolver;
112+
const getRequestRejector = () => requestRejector;
113+
let requestIndex = -1;
114+
render(
115+
<Suspense fallback="Suspense placeholder">
116+
<WorkflowStackTrace
117+
params={{
118+
domain: 'test-domain',
119+
cluster: 'test-cluster',
120+
runId: 'test-runid',
121+
workflowId: 'test-workflowId',
122+
workflowTab: 'stackTrace',
123+
}}
124+
/>
125+
</Suspense>,
126+
{
127+
endpointsMocks: [
128+
{
129+
path: '/api/domains/:domain/:cluster/workflows/:workflowId/:runId/query/__stack_trace',
130+
httpMethod: 'POST',
131+
mockOnce: false,
132+
httpResolver: async () => {
133+
requestIndex = requestIndex + 1;
134+
if (requestIndex === 0 && initReqError)
135+
return HttpResponse.json(
136+
{ message: 'Failed to fetch workflow stack trace' },
137+
{ status: 500 }
138+
);
139+
140+
if (requestIndex > 0 && resolveRefreshManually) {
141+
await new Promise((resolve, reject) => {
142+
requestResolver = () =>
143+
resolve(
144+
HttpResponse.json(
145+
{
146+
rejected: null,
147+
result: resultStackTrace,
148+
} satisfies QueryWorkflowResponse,
149+
{ status: 200 }
150+
)
151+
);
152+
requestRejector = () =>
153+
reject(
154+
HttpResponse.json(
155+
{ message: 'Failed to fetch workflow stack trace' },
156+
{ status: 500 }
157+
)
158+
);
159+
});
160+
}
161+
return HttpResponse.json(
162+
{
163+
rejected: null,
164+
result: resultStackTrace,
165+
} satisfies QueryWorkflowResponse,
166+
{ status: 200 }
167+
);
168+
},
169+
},
170+
],
171+
}
172+
);
173+
174+
await waitForElementToBeRemoved(() =>
175+
screen.queryAllByText('Suspense placeholder')
176+
);
177+
178+
return { getRequestResolver, getRequestRejector };
179+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { type Theme } from 'baseui';
2+
import { type SkeletonOverrides } from 'baseui/skeleton/types';
3+
import { type StyleObject } from 'styletron-react';
4+
5+
import type {
6+
StyletronCSSObject,
7+
StyletronCSSObjectOf,
8+
} from '@/hooks/use-styletron-classes';
9+
10+
export const overrides = {
11+
loadingSkeleton: {
12+
Root: {
13+
style: ({ $theme }: { $theme: Theme }): StyleObject => ({
14+
borderRadius: $theme.borders.radius300,
15+
marginTop: $theme.sizing.scale600,
16+
height: '100%',
17+
}),
18+
},
19+
} satisfies SkeletonOverrides,
20+
};
21+
22+
const cssStylesObj = {
23+
pageContainer: {
24+
display: 'flex',
25+
flexDirection: 'column',
26+
flex: 1,
27+
},
28+
pageHeader: (theme) => ({
29+
display: 'flex',
30+
alignItems: 'center',
31+
justifyContent: 'space-between',
32+
flexWrap: 'wrap',
33+
gap: theme.sizing.scale500,
34+
}),
35+
stackTrace: (theme) => ({
36+
backgroundColor: theme.colors.backgroundSecondary,
37+
padding: theme.sizing.scale800,
38+
borderRadius: theme.borders.radius300,
39+
marginTop: theme.sizing.scale600,
40+
color: theme.colors.contentSecondary,
41+
flex: 1,
42+
}),
43+
} satisfies StyletronCSSObject;
44+
45+
export const cssStyles: StyletronCSSObjectOf<typeof cssStylesObj> =
46+
cssStylesObj;
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
'use client';
2+
3+
import { useSuspenseQuery } from '@tanstack/react-query';
4+
import { Button } from 'baseui/button';
5+
import { Skeleton } from 'baseui/skeleton';
6+
import { Spinner } from 'baseui/spinner';
7+
import { LabelSmall } from 'baseui/typography';
8+
import { MdRefresh } from 'react-icons/md';
9+
10+
import ErrorPanel from '@/components/error-panel/error-panel';
11+
import PageSection from '@/components/page-section/page-section';
12+
import useStyletronClasses from '@/hooks/use-styletron-classes';
13+
import { type QueryWorkflowResponse } from '@/route-handlers/query-workflow/query-workflow.types';
14+
import formatDate from '@/utils/data-formatters/format-date';
15+
import request from '@/utils/request';
16+
import { type RequestError } from '@/utils/request/request-error';
17+
import { type WorkflowPageTabContentProps } from '@/views/workflow-page/workflow-page-tab-content/workflow-page-tab-content.types';
18+
19+
import { cssStyles, overrides } from './workflow-stack-trace.styles';
20+
21+
export default function WorkflowStackTrace(props: WorkflowPageTabContentProps) {
22+
const { domain, cluster, workflowId, runId } = props.params;
23+
24+
const { data, refetch, isFetching, dataUpdatedAt, isError, isSuccess } =
25+
useSuspenseQuery<
26+
QueryWorkflowResponse,
27+
RequestError,
28+
QueryWorkflowResponse,
29+
[
30+
string,
31+
Pick<
32+
WorkflowPageTabContentProps['params'],
33+
'cluster' | 'domain' | 'runId' | 'workflowId'
34+
>,
35+
]
36+
>({
37+
queryKey: [
38+
'workflow_stack_trace',
39+
{ domain, cluster, workflowId, runId },
40+
] as const,
41+
queryFn: () => {
42+
return request(
43+
`/api/domains/${domain}/${cluster}/workflows/${workflowId}/${runId}/query/__stack_trace`,
44+
{
45+
method: 'POST',
46+
}
47+
).then((res) => res.json());
48+
},
49+
});
50+
51+
const { cls } = useStyletronClasses(cssStyles);
52+
return (
53+
<PageSection className={cls.pageContainer}>
54+
<div className={cls.pageHeader}>
55+
{Boolean(dataUpdatedAt) && (
56+
<LabelSmall suppressHydrationWarning>
57+
Last updated {formatDate(dataUpdatedAt)}
58+
</LabelSmall>
59+
)}
60+
<Button
61+
shape="pill"
62+
size="mini"
63+
kind="secondary"
64+
startEnhancer={<MdRefresh size={12} />}
65+
endEnhancer={isFetching && <Spinner $size={12} />}
66+
disabled={isFetching}
67+
onClick={() => refetch()}
68+
>
69+
Refresh
70+
</Button>
71+
</div>
72+
{isFetching && (
73+
<Skeleton overrides={overrides.loadingSkeleton} animation />
74+
)}
75+
{!isFetching && isError && (
76+
<ErrorPanel message="Failed to load stack trace" />
77+
)}
78+
{!isFetching && isSuccess && (
79+
<div className={cls.stackTrace}>
80+
<pre>{String(data.result) || 'No stack trace...'}</pre>
81+
</div>
82+
)}
83+
</PageSection>
84+
);
85+
}

0 commit comments

Comments
 (0)