Skip to content

Commit 8a3af8d

Browse files
Add table for Workflow Diagnostics metadata (#969)
- Create table component to render Workflow Diagnostics issue and root cause metadata - Create metadata parsers config that goes through metadata, matches fields and renders them appropriately (event IDs with a link to workflow history, objects as JSON, null/undefined values hidden, empty strings with a placeholder) - Pass root cause description as a metadata field if root cause has other metadata
1 parent 557d4ad commit 8a3af8d

10 files changed

+396
-6
lines changed
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { createElement } from 'react';
2+
3+
import queryString from 'query-string';
4+
5+
import Link from '@/components/link/link';
6+
7+
import WorkflowDiagnosticsMetadataJson from '../workflow-diagnostics-metadata-json/workflow-diagnostics-metadata-json';
8+
import WorkflowDiagnosticsMetadataPlaceholderText from '../workflow-diagnostics-metadata-placeholder-text/workflow-diagnostics-metadata-placeholder-text';
9+
import { type WorkflowDiagnosticsMetadataParser } from '../workflow-diagnostics-metadata-table/workflow-diagnostics-metadata-table.types';
10+
11+
const workflowDiagnosticsMetadataParsersConfig: Array<WorkflowDiagnosticsMetadataParser> =
12+
[
13+
{
14+
name: 'Links to workflow history for event IDs',
15+
matcher: (key, value) =>
16+
['ActivityScheduledID', 'ActivityStartedID', 'EventID'].includes(key) &&
17+
value !== 0,
18+
renderValue: ({ domain, cluster, workflowId, runId, value }) =>
19+
createElement(
20+
Link,
21+
{
22+
href: queryString.stringifyUrl({
23+
url: `/domains/${encodeURIComponent(domain)}/${encodeURIComponent(cluster)}/workflows/${encodeURIComponent(workflowId)}/${encodeURIComponent(runId)}/history`,
24+
query: {
25+
he: value,
26+
},
27+
}),
28+
},
29+
String(value)
30+
),
31+
},
32+
{
33+
name: 'Any object as JSON',
34+
matcher: (_, value) => value !== null && typeof value === 'object',
35+
renderValue: WorkflowDiagnosticsMetadataJson,
36+
forceWrap: true,
37+
},
38+
{
39+
name: 'Hidden null/undefined values',
40+
matcher: (_, value) => value === null || value === undefined,
41+
hide: true,
42+
},
43+
{
44+
name: 'Placeholder for empty string values',
45+
matcher: (_, value) => value === '',
46+
renderValue: () =>
47+
createElement(WorkflowDiagnosticsMetadataPlaceholderText, {
48+
placeholderText: 'Empty',
49+
}),
50+
},
51+
] as const;
52+
53+
export default workflowDiagnosticsMetadataParsersConfig;

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

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,12 +69,16 @@ export default function WorkflowDiagnosticsIssue({
6969
<styled.RootCausesContainer>
7070
{rootCauses.map((rootCause, index) => (
7171
<styled.RootCauseContainer key={`rootCauses-${index}`}>
72-
{rootCause.rootCauseType}
73-
{rootCause.metadata && (
72+
{rootCause.metadata ? (
7473
<WorkflowDiagnosticsMetadataTable
75-
metadata={rootCause.metadata}
74+
metadata={{
75+
Description: rootCause.rootCauseType,
76+
...rootCause.metadata,
77+
}}
7678
{...workflowPageParams}
7779
/>
80+
) : (
81+
rootCause.rootCauseType
7882
)}
7983
</styled.RootCauseContainer>
8084
))}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import React from 'react';
2+
3+
import { render, screen } from '@/test-utils/rtl';
4+
5+
import WorkflowDiagnosticsMetadataPlaceholderText from '../workflow-diagnostics-metadata-placeholder-text';
6+
7+
describe(WorkflowDiagnosticsMetadataPlaceholderText.name, () => {
8+
it('renders the placeholder text correctly', () => {
9+
const placeholderText = 'No metadata available';
10+
11+
render(
12+
<WorkflowDiagnosticsMetadataPlaceholderText
13+
placeholderText={placeholderText}
14+
/>
15+
);
16+
17+
expect(screen.getByText(placeholderText)).toBeInTheDocument();
18+
});
19+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { styled as createStyled, type Theme } from 'baseui';
2+
3+
export const styled = {
4+
PlaceholderText: createStyled('div', ({ $theme }: { $theme: Theme }) => ({
5+
color: $theme.colors.contentTertiary,
6+
fontStyle: 'italic',
7+
})),
8+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { styled } from './workflow-diagnostics-metadata-placeholder-text.styles';
2+
import { type Props } from './workflow-diagnostics-metadata-placeholder-text.types';
3+
4+
export default function WorkflowDiagnosticsMetadataPlaceholderText({
5+
placeholderText,
6+
}: Props) {
7+
return <styled.PlaceholderText>{placeholderText}</styled.PlaceholderText>;
8+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export type Props = {
2+
placeholderText: string;
3+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
import React from 'react';
2+
3+
import { render, screen } from '@/test-utils/rtl';
4+
5+
import WorkflowDiagnosticsMetadataTable from '../workflow-diagnostics-metadata-table';
6+
import {
7+
type WorkflowDiagnosticsMetadataParser,
8+
type Props,
9+
} from '../workflow-diagnostics-metadata-table.types';
10+
11+
jest.mock(
12+
'../../config/workflow-diagnostics-metadata-parsers.config',
13+
() =>
14+
[
15+
{
16+
name: 'Test Link Parser',
17+
matcher: (key, value) => key === 'ActivityScheduledID' && value !== 0,
18+
renderValue: ({ value }) => (
19+
<a href={`/test-link/${value}`}>Link: {String(value)}</a>
20+
),
21+
},
22+
{
23+
name: 'Test Object Parser',
24+
matcher: (_, value) => value !== null && typeof value === 'object',
25+
renderValue: ({ value }) => (
26+
<div data-testid="json-renderer">{JSON.stringify(value)}</div>
27+
),
28+
forceWrap: true,
29+
},
30+
{
31+
name: 'Test Empty String Parser',
32+
matcher: (_, value) => value === '',
33+
renderValue: () => <span data-testid="empty-string">&quot;&quot;</span>,
34+
},
35+
{
36+
name: 'Test Null parser',
37+
matcher: (_, value) => value === null,
38+
hide: true,
39+
},
40+
] satisfies Array<WorkflowDiagnosticsMetadataParser>
41+
);
42+
43+
describe(WorkflowDiagnosticsMetadataTable.name, () => {
44+
beforeEach(() => {
45+
jest.clearAllMocks();
46+
});
47+
48+
it('renders metadata items with string values when no parser matches', () => {
49+
const metadata = {
50+
simpleKey: 'simple value',
51+
numberKey: 123,
52+
booleanKey: true,
53+
};
54+
55+
setup({ metadata });
56+
57+
expect(screen.getByText('simpleKey')).toBeInTheDocument();
58+
expect(screen.getByText('simple value')).toBeInTheDocument();
59+
expect(screen.getByText('numberKey')).toBeInTheDocument();
60+
expect(screen.getByText('123')).toBeInTheDocument();
61+
expect(screen.getByText('booleanKey')).toBeInTheDocument();
62+
expect(screen.getByText('true')).toBeInTheDocument();
63+
});
64+
65+
it('renders metadata items with custom renderers when parsers match', () => {
66+
const metadata = {
67+
ActivityScheduledID: 456,
68+
objectKey: { nested: 'value' },
69+
emptyKey: '',
70+
};
71+
72+
setup({ metadata });
73+
74+
expect(screen.getByText('ActivityScheduledID')).toBeInTheDocument();
75+
expect(screen.getByText('Link: 456')).toBeInTheDocument();
76+
expect(screen.getByRole('link')).toHaveAttribute('href', '/test-link/456');
77+
78+
expect(screen.getByText('objectKey')).toBeInTheDocument();
79+
expect(screen.getByTestId('json-renderer')).toBeInTheDocument();
80+
expect(screen.getByText('{"nested":"value"}')).toBeInTheDocument();
81+
82+
expect(screen.getByText('emptyKey')).toBeInTheDocument();
83+
expect(screen.getByTestId('empty-string')).toBeInTheDocument();
84+
});
85+
86+
it('hides values when parser is configured with hide: true', () => {
87+
const metadata = {
88+
visibleKey: 'visible value',
89+
nullKey: null,
90+
anotherNullKey: null,
91+
regularKey: 'regular value',
92+
};
93+
94+
setup({ metadata });
95+
96+
// Null values should be hidden by the null parser
97+
expect(screen.queryByText('nullKey')).not.toBeInTheDocument();
98+
expect(screen.queryByText('anotherNullKey')).not.toBeInTheDocument();
99+
expect(screen.queryByText('null')).not.toBeInTheDocument();
100+
101+
// Other values should still be visible
102+
expect(screen.getByText('visibleKey')).toBeInTheDocument();
103+
expect(screen.getByText('visible value')).toBeInTheDocument();
104+
expect(screen.getByText('regularKey')).toBeInTheDocument();
105+
expect(screen.getByText('regular value')).toBeInTheDocument();
106+
});
107+
108+
it('handles undefined values gracefully', () => {
109+
const metadata = {
110+
nullKey: null,
111+
undefinedKey: undefined,
112+
};
113+
114+
setup({ metadata });
115+
116+
expect(screen.getByText('undefinedKey')).toBeInTheDocument();
117+
expect(screen.getByText('undefined')).toBeInTheDocument();
118+
});
119+
120+
it('handles complex object values', () => {
121+
const metadata = {
122+
complexObject: {
123+
nested: {
124+
array: [1, 2, 3],
125+
string: 'test',
126+
number: 42,
127+
},
128+
},
129+
};
130+
131+
setup({ metadata });
132+
133+
expect(screen.getByText('complexObject')).toBeInTheDocument();
134+
expect(screen.getByTestId('json-renderer')).toBeInTheDocument();
135+
expect(
136+
screen.getByText(
137+
'{"nested":{"array":[1,2,3],"string":"test","number":42}}'
138+
)
139+
).toBeInTheDocument();
140+
});
141+
142+
it('handles metadata with empty objects and arrays', () => {
143+
const metadata = {
144+
emptyObject: {},
145+
emptyArray: [],
146+
objectWithEmptyArray: { items: [] },
147+
arrayWithEmptyObject: [{}],
148+
};
149+
150+
setup({ metadata });
151+
152+
expect(screen.getByText('emptyObject')).toBeInTheDocument();
153+
expect(screen.getByText('{}')).toBeInTheDocument();
154+
expect(screen.getByText('emptyArray')).toBeInTheDocument();
155+
expect(screen.getByText('[]')).toBeInTheDocument();
156+
expect(screen.getByText('objectWithEmptyArray')).toBeInTheDocument();
157+
expect(screen.getByText('{"items":[]}')).toBeInTheDocument();
158+
expect(screen.getByText('arrayWithEmptyObject')).toBeInTheDocument();
159+
expect(screen.getByText('[{}]')).toBeInTheDocument();
160+
});
161+
});
162+
163+
function setup({
164+
metadata = {},
165+
}: {
166+
metadata?: Record<string, any>;
167+
} = {}) {
168+
const props: Props = {
169+
metadata,
170+
domain: 'test-domain',
171+
cluster: 'test-cluster',
172+
workflowId: 'test-workflow-id',
173+
runId: 'test-run-id',
174+
};
175+
176+
render(<WorkflowDiagnosticsMetadataTable {...props} />);
177+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { styled as createStyled, type Theme } from 'baseui';
2+
3+
export const styled = {
4+
MetadataTableContainer: createStyled(
5+
'div',
6+
({ $theme }: { $theme: Theme }) => ({
7+
display: 'flex',
8+
flexDirection: 'column',
9+
gap: $theme.sizing.scale500,
10+
paddingTop: $theme.sizing.scale100,
11+
paddingBottom: $theme.sizing.scale100,
12+
})
13+
),
14+
MetadataItemRow: createStyled<'div', { $forceWrap?: boolean }>(
15+
'div',
16+
({ $theme, $forceWrap }: { $theme: Theme; $forceWrap?: boolean }) => ({
17+
display: 'flex',
18+
flexDirection: $forceWrap ? 'column' : 'row',
19+
gap: $theme.sizing.scale300,
20+
wordBreak: 'break-word',
21+
...(!$forceWrap && { flexWrap: 'wrap' }),
22+
})
23+
),
24+
MetadataItemValue: createStyled('div', ({ $theme }: { $theme: Theme }) => ({
25+
color: $theme.colors.contentPrimary,
26+
...$theme.typography.LabelXSmall,
27+
display: 'flex',
28+
})),
29+
MetadataItemLabel: createStyled<'div', { $forceWrap?: boolean }>(
30+
'div',
31+
({ $theme, $forceWrap }) => ({
32+
minWidth: '140px',
33+
maxWidth: '140px',
34+
display: 'flex',
35+
color: $theme.colors.contentSecondary,
36+
...$theme.typography.LabelXSmall,
37+
...($forceWrap && { whiteSpace: 'nowrap' }),
38+
})
39+
),
40+
};
Lines changed: 59 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,61 @@
1-
import { type Props } from './workflow-diagnostics-metadata-table.types';
1+
import { useMemo } from 'react';
22

3-
export default function WorkflowDiagnosticsMetadataTable(props: Props) {
4-
return <div>{JSON.stringify(props.metadata, null, 2)}</div>;
3+
import workflowDiagnosticsMetadataParsersConfig from '../config/workflow-diagnostics-metadata-parsers.config';
4+
5+
import { styled } from './workflow-diagnostics-metadata-table.styles';
6+
import {
7+
type ParsedWorkflowDiagnosticsMetadataField,
8+
type Props,
9+
} from './workflow-diagnostics-metadata-table.types';
10+
11+
export default function WorkflowDiagnosticsMetadataTable({
12+
metadata,
13+
...workflowPageParams
14+
}: Props) {
15+
const parsedMetadataItems = useMemo(
16+
() =>
17+
Object.entries(metadata)
18+
.map(([key, value]) => {
19+
const renderConfig = workflowDiagnosticsMetadataParsersConfig.find(
20+
(c) => c.matcher(key, value)
21+
);
22+
23+
if (renderConfig?.hide) {
24+
return null;
25+
}
26+
27+
return {
28+
key,
29+
label: key,
30+
value: renderConfig ? (
31+
<renderConfig.renderValue value={value} {...workflowPageParams} />
32+
) : (
33+
String(value)
34+
),
35+
forceWrap: renderConfig?.forceWrap,
36+
};
37+
})
38+
.filter(
39+
(field) => field !== null
40+
) as Array<ParsedWorkflowDiagnosticsMetadataField>,
41+
[metadata, workflowPageParams]
42+
);
43+
44+
return (
45+
<styled.MetadataTableContainer>
46+
{parsedMetadataItems.map((metadataItem) => (
47+
<styled.MetadataItemRow
48+
$forceWrap={metadataItem.forceWrap}
49+
key={metadataItem.key}
50+
>
51+
<styled.MetadataItemLabel $forceWrap={metadataItem.forceWrap}>
52+
{metadataItem.label}
53+
</styled.MetadataItemLabel>
54+
<styled.MetadataItemValue>
55+
{metadataItem.value}
56+
</styled.MetadataItemValue>
57+
</styled.MetadataItemRow>
58+
))}
59+
</styled.MetadataTableContainer>
60+
);
561
}

0 commit comments

Comments
 (0)