Skip to content

Commit

Permalink
feat: add Metadata section to TrialDetailsOverview (ET-224) (#9639)
Browse files Browse the repository at this point in the history
  • Loading branch information
johnkim-det authored Jul 30, 2024
1 parent 287faf7 commit 0806597
Show file tree
Hide file tree
Showing 8 changed files with 234 additions and 5 deletions.
8 changes: 4 additions & 4 deletions webui/react/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion webui/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@
"fp-ts": "^2.16.5",
"fuse.js": "^7.0.0",
"hermes-parallel-coordinates": "^0.6.17",
"hew": "npm:@hpe.com/hew@^0.6.39",
"hew": "npm:@hpe.com/hew@^0.6.40",
"humanize-duration": "^3.28.0",
"immutable": "^4.3.0",
"io-ts": "^2.2.21",
Expand Down
5 changes: 5 additions & 0 deletions webui/react/src/components/Metadata.module.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
.base {
:global(.ant-tree-node-content-wrapper-close) {
font-weight: bold;
}
}
140 changes: 140 additions & 0 deletions webui/react/src/components/Metadata.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import UIProvider, { DefaultTheme } from 'hew/Theme';
import { isArray } from 'lodash';

import { JsonObject, TrialDetails } from 'types';
import { downloadText } from 'utils/browser';
import { isJsonObject } from 'utils/data';

import Metadata, { EMPTY_MESSAGE } from './Metadata';
import { ThemeProvider } from './ThemeProvider';

const mockMetadata = {
other: {
test: 105,
testing: 'asdf',
},
steps_completed: 101,
};

const mockTrial: TrialDetails = {
autoRestarts: 0,
bestAvailableCheckpoint: {
endTime: '2024-07-09T18:11:37.179665Z',
resources: {
'metadata.json': 28,
'state': 13,
},
state: 'COMPLETED',
totalBatches: 100,
uuid: 'd1f2ea2f-4872-4b3d-a6ba-171647d87d49',
},
bestValidationMetric: {
endTime: '2024-07-09T18:11:37.428948Z',
metrics: {
x: 100,
},
totalBatches: 100,
},
checkpointCount: 2,
endTime: '2024-07-09T18:11:51.633537Z',
experimentId: 7525,
hyperparameters: {},
id: 51646,
latestValidationMetric: {
endTime: '2024-07-09T18:11:37.428948Z',
metrics: {
x: 100,
},
totalBatches: 100,
},
metadata: mockMetadata,
searcherMetricsVal: 0,
startTime: '2024-07-09T18:05:42.629537Z',
state: 'COMPLETED',
summaryMetrics: {
avgMetrics: {
x: {
count: 10,
last: 100,
max: 100,
min: 10,
sum: 550,
type: 'number',
},
},
validationMetrics: {
x: {
count: 1,
last: 100,
max: 100,
min: 100,
sum: 100,
type: 'number',
},
},
},
taskId: '7525.048f395e-b68a-43f1-9a9f-e63720eb98be',
totalBatchesProcessed: 100,
totalCheckpointSize: 79,
};

vi.mock('utils/browser', () => ({
downloadText: vi.fn(),
}));

const user = userEvent.setup();

const setup = (empty?: boolean) => {
render(
<UIProvider theme={DefaultTheme.Light}>
<ThemeProvider>
<Metadata trial={empty ? undefined : mockTrial} />,
</ThemeProvider>
</UIProvider>,
);
};

describe('Metadata', () => {
it('should display empty state', () => {
setup(true);
expect(screen.getByText(EMPTY_MESSAGE)).toBeInTheDocument();
expect(screen.getByRole('button')).toBeDisabled();
});

it('should allow metadata download', async () => {
setup();
await user.click(screen.getByRole('button'));
expect(vi.mocked(downloadText)).toBeCalledWith(`${mockTrial?.id}_metadata.json`, [
JSON.stringify(mockTrial.metadata),
]);
});

it('should display Tree with metadata', () => {
setup();
const treeValues: string[] = [];
const extractTreeValuesFromObject = (object: JsonObject) => {
for (const [key, value] of Object.entries(object)) {
if (value === null) continue;
if (isJsonObject(value)) {
extractTreeValuesFromObject(value);
treeValues.push(key);
} else {
let stringValue = '';
if (isArray(value)) {
stringValue = `[${value.join(', ')}]`;
} else {
stringValue = value.toString();
}
treeValues.push(`${key}:`);
treeValues.push(stringValue);
}
}
};
extractTreeValuesFromObject(mockMetadata);
treeValues.forEach((treeValue) => {
expect(screen.getByText(treeValue.toString())).toBeInTheDocument();
});
});
});
80 changes: 80 additions & 0 deletions webui/react/src/components/Metadata.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import Button from 'hew/Button';
import Icon from 'hew/Icon';
import Message from 'hew/Message';
import Surface from 'hew/Surface';
import { useTheme } from 'hew/Theme';
import Tooltip from 'hew/Tooltip';
import Tree, { TreeDataNode } from 'hew/Tree';
import { isArray } from 'lodash';

import { RawJson, TrialDetails } from 'types';
import { downloadText } from 'utils/browser';
import { isJsonObject } from 'utils/data';

import css from './Metadata.module.scss';
import Section from './Section';

interface Props {
trial?: TrialDetails;
}

export const EMPTY_MESSAGE = 'No metadata found';

const Metadata: React.FC<Props> = ({ trial }: Props) => {
const { tokens } = useTheme();

const getNodes = (data: RawJson): TreeDataNode[] => {
return Object.entries(data).map(([key, value]) => {
if (isJsonObject(value) || isArray(value)) {
return {
children: getNodes(value),
key,
selectable: false,
title: <span style={{ color: tokens.colorTextDescription }}>{key}</span>,
};
}
return {
key,
selectable: false,
title: (
<>
<span style={{ color: tokens.colorTextDescription }}>{key}:</span> <span>{value}</span>
</>
),
};
});
};

const downloadMetadata = () => {
downloadText(`${trial?.id}_metadata.json`, [JSON.stringify(trial?.metadata)]);
};

const treeData = (trial?.metadata && getNodes(trial?.metadata)) ?? [];

return (
<Section
options={[
<Tooltip content="Download metadata" key="download" placement="left">
<Button
disabled={!treeData.length}
icon={<Icon decorative name="download" />}
type="text"
onClick={downloadMetadata}
/>
</Tooltip>,
]}
title="Metadata">
<Surface>
{treeData.length ? (
<div className={css.base}>
<Tree defaultExpandAll treeData={treeData} />
</div>
) : (
<Message title={EMPTY_MESSAGE} />
)}
</Surface>
</Section>
);
};

export default Metadata;
2 changes: 2 additions & 0 deletions webui/react/src/pages/TrialDetails/TrialDetailsOverview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import Spinner from 'hew/Spinner';
import { Loadable } from 'hew/utils/loadable';
import React, { useCallback, useMemo } from 'react';

import Metadata from 'components/Metadata';
import { terminalRunStates } from 'constants/states';
import useFeature from 'hooks/useFeature';
import useMetricNames from 'hooks/useMetricNames';
Expand Down Expand Up @@ -110,6 +111,7 @@ const TrialDetailsOverview: React.FC<Props> = ({ experiment, trial }: Props) =>
) : (
<Spinner spinning />
)}
<Metadata trial={trial} />
</>
) : null}
</>
Expand Down
1 change: 1 addition & 0 deletions webui/react/src/services/decoder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -679,6 +679,7 @@ export const decodeV1TrialToTrialItem = (data: Sdk.Trialv1Trial): types.TrialIte
id: data.id,
latestValidationMetric: data.latestValidation && decodeMetricsWorkload(data.latestValidation),
logRetentionDays: data.logRetentionDays,
metadata: data.metadata,
searcherMetricsVal: data.searcherMetricValue,
startTime: data.startTime as unknown as string,
state: decodeExperimentState(data.state),
Expand Down
1 change: 1 addition & 0 deletions webui/react/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -709,6 +709,7 @@ export interface TrialItem extends StartEndTimes {
searcherMetricsVal?: number;
logRetentionDays?: number;
taskId?: string;
metadata?: JsonObject;
}

export interface TrialDetails extends TrialItem {
Expand Down

0 comments on commit 0806597

Please sign in to comment.