Skip to content

Commit 67ab90f

Browse files
olgennun-def
andauthored
[Feature] Show Run metrics on the UI (#2446)
Co-authored-by: Dmitry Meyer <me@undef.im>
1 parent 8a241c6 commit 67ab90f

File tree

18 files changed

+617
-178
lines changed

18 files changed

+617
-178
lines changed

frontend/src/App/Login/EntraID/LoginByEntraID/index.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,10 @@ import { Button } from 'components';
77
import { goToUrl } from 'libs';
88
import { useEntraAuthorizeMutation } from 'services/auth';
99

10+
import { getBaseUrl } from 'App/helpers';
11+
1012
import { ReactComponent as EntraIdIcon } from 'assets/icons/entraID.svg';
1113
import styles from './styles.module.scss';
12-
import { getBaseUrl } from 'App/helpers';
1314

1415
export const LoginByEntraID: React.FC<{ className?: string }> = ({ className }) => {
1516
const { t } = useTranslation();

frontend/src/App/Login/EntraID/LoginByEntraIDCallback/index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,9 @@ import { ROUTES } from 'routes';
1010
import { useEntraCallbackMutation } from 'services/auth';
1111

1212
import { AuthErrorMessage } from 'App/AuthErrorMessage';
13+
import { getBaseUrl } from 'App/helpers';
1314
import { Loading } from 'App/Loading';
1415
import { setAuthData } from 'App/slice';
15-
import { getBaseUrl } from 'App/helpers';
1616

1717
export const LoginByEntraIDCallback: React.FC = () => {
1818
const { t } = useTranslation();

frontend/src/api.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,10 @@ export const API = {
8585

8686
// Fleets
8787
VOLUMES_DELETE: (projectName: IProject['project_name']) => `${API.BASE()}/project/${projectName}/volumes/delete`,
88+
89+
// METRICS
90+
JOB_METRICS: (projectName: IProject['project_name'], runName: IRun['run_spec']['run_name']) =>
91+
`${API.BASE()}/project/${projectName}/metrics/job/${runName}`,
8892
},
8993

9094
BACKENDS: {

frontend/src/components/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ export { default as ChatBubble } from '@cloudscape-design/chat-components/chat-b
5151
export type { ChatBubbleProps } from '@cloudscape-design/chat-components/chat-bubble';
5252
export { default as Avatar } from '@cloudscape-design/chat-components/avatar';
5353
export type { AvatarProps } from '@cloudscape-design/chat-components/avatar';
54+
export { default as LineChart } from '@cloudscape-design/components/line-chart';
55+
export type { LineChartProps } from '@cloudscape-design/components/line-chart/interfaces';
5456
export type { ModalProps } from '@cloudscape-design/components/modal';
5557
export type { TilesProps } from '@cloudscape-design/components/tiles';
5658

frontend/src/locale/en.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,14 @@
337337
"provider_name": "Provider",
338338
"status": "Status",
339339
"submitted_at": "Submitted",
340+
"metrics": {
341+
"title": "Metrics",
342+
"show_metrics": "Show metrics",
343+
"cpu_utilization": "CPU utilization %",
344+
"memory_used": "System memory used",
345+
"per_each_cpu_utilization": "GPU utilization %",
346+
"per_each_memory_used": "GPU memory used"
347+
},
340348
"jobs": "Jobs",
341349
"job_name": "Job Name",
342350
"cost": "Cost",

frontend/src/pages/Runs/Details/Jobs/Details/index.tsx

Lines changed: 90 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
1-
import React, { useEffect, useMemo } from 'react';
1+
import React, { useEffect, useMemo, useState } from 'react';
22
import { useTranslation } from 'react-i18next';
33
import { useParams } from 'react-router-dom';
44

5-
import { Box, ColumnLayout, Container, ContentLayout, DetailsHeader, Header, Loader, StatusIndicator } from 'components';
5+
import { Box, ColumnLayout, Container, ContentLayout, DetailsHeader, Header, Loader, StatusIndicator, Tabs } from 'components';
66

77
import { useBreadcrumbs } from 'hooks';
88
import { riseRouterException } from 'libs';
9+
import { getStatusIconType } from 'libs/run';
910
import { ROUTES } from 'routes';
1011
import { useGetRunQuery } from 'services/run';
1112

12-
import { getStatusIconType } from '../../../../../libs/run';
1313
import { Logs } from '../../Logs';
1414
import {
1515
getJobListItemBackend,
@@ -22,9 +22,15 @@ import {
2222
getJobSubmittedAt,
2323
getJobTerminationReason,
2424
} from '../List/helpers';
25+
import { RunMetrics } from '../Metrics';
2526

2627
import styles from './styles.module.scss';
2728

29+
enum CodeTab {
30+
Details = 'details',
31+
Metrics = 'metrics',
32+
}
33+
2834
const getJobSubmissionId = (job?: IJob): string | undefined => {
2935
if (!job) return;
3036

@@ -34,6 +40,7 @@ const getJobSubmissionId = (job?: IJob): string | undefined => {
3440
export const JobDetails: React.FC = () => {
3541
const { t } = useTranslation();
3642
const params = useParams();
43+
const [codeTab, setCodeTab] = useState<CodeTab>(CodeTab.Details);
3744
const paramProjectName = params.projectName ?? '';
3845
const paramRunId = params.runId ?? '';
3946
const paramJobName = params.jobName ?? '';
@@ -75,7 +82,7 @@ export const JobDetails: React.FC = () => {
7582
href: ROUTES.RUNS.LIST,
7683
},
7784
{
78-
text: paramRunId,
85+
text: runData?.run_spec.run_name ?? '',
7986
href: ROUTES.PROJECT.DETAILS.RUNS.DETAILS.FORMAT(paramProjectName, paramRunId),
8087
},
8188
{
@@ -99,64 +106,85 @@ export const JobDetails: React.FC = () => {
99106

100107
{jobData && (
101108
<>
102-
<Container header={<Header variant="h2">{t('common.general')}</Header>}>
103-
<ColumnLayout columns={4} variant="text-grid">
104-
<div>
105-
<Box variant="awsui-key-label">{t('projects.run.submitted_at')}</Box>
106-
<div>{getJobSubmittedAt(jobData)}</div>
107-
</div>
108-
109-
<div>
110-
<Box variant="awsui-key-label">{t('projects.run.status')}</Box>
111-
<div>
112-
<StatusIndicator type={getStatusIconType(getJobStatus(jobData))}>
113-
{t(`projects.run.statuses.${getJobStatus(jobData)}`)}
114-
</StatusIndicator>
115-
</div>
116-
</div>
117-
118-
<div>
119-
<Box variant="awsui-key-label">{t('projects.run.termination_reason')}</Box>
120-
<div>{getJobTerminationReason(jobData)}</div>
121-
</div>
122-
123-
<div>
124-
<Box variant="awsui-key-label">{t('projects.run.backend')}</Box>
125-
<div>{getJobListItemBackend(jobData)}</div>
126-
</div>
127-
128-
<div>
129-
<Box variant="awsui-key-label">{t('projects.run.region')}</Box>
130-
<div>{getJobListItemRegion(jobData)}</div>
131-
</div>
132-
133-
<div>
134-
<Box variant="awsui-key-label">{t('projects.run.instance')}</Box>
135-
<div>{getJobListItemInstance(jobData)}</div>
136-
</div>
137-
138-
<div>
139-
<Box variant="awsui-key-label">{t('projects.run.resources')}</Box>
140-
<div>{getJobListItemResources(jobData)}</div>
141-
</div>
142-
143-
<div>
144-
<Box variant="awsui-key-label">{t('projects.run.spot')}</Box>
145-
<div>{getJobListItemSpot(jobData)}</div>
146-
</div>
147-
148-
<div>
149-
<Box variant="awsui-key-label">{t('projects.run.price')}</Box>
150-
<div>{getJobListItemPrice(jobData)}</div>
151-
</div>
152-
</ColumnLayout>
153-
</Container>
154-
155-
<Logs
156-
projectName={paramProjectName}
157-
runName={runData?.run_spec?.run_name ?? ''}
158-
jobSubmissionId={getJobSubmissionId(jobData)}
159-
className={styles.logs}
109+
<Tabs
110+
onChange={({ detail }) => setCodeTab(detail.activeTabId as CodeTab)}
111+
activeTabId={codeTab}
112+
tabs={[
113+
{
114+
label: 'Details',
115+
id: CodeTab.Details,
116+
content: (
117+
<div className={styles.details}>
118+
<Container header={<Header variant="h2">{t('common.general')}</Header>}>
119+
<ColumnLayout columns={4} variant="text-grid">
120+
<div>
121+
<Box variant="awsui-key-label">{t('projects.run.submitted_at')}</Box>
122+
<div>{getJobSubmittedAt(jobData)}</div>
123+
</div>
124+
125+
<div>
126+
<Box variant="awsui-key-label">{t('projects.run.status')}</Box>
127+
<div>
128+
<StatusIndicator type={getStatusIconType(getJobStatus(jobData))}>
129+
{t(`projects.run.statuses.${getJobStatus(jobData)}`)}
130+
</StatusIndicator>
131+
</div>
132+
</div>
133+
134+
<div>
135+
<Box variant="awsui-key-label">
136+
{t('projects.run.termination_reason')}
137+
</Box>
138+
<div>{getJobTerminationReason(jobData)}</div>
139+
</div>
140+
141+
<div>
142+
<Box variant="awsui-key-label">{t('projects.run.backend')}</Box>
143+
<div>{getJobListItemBackend(jobData)}</div>
144+
</div>
145+
146+
<div>
147+
<Box variant="awsui-key-label">{t('projects.run.region')}</Box>
148+
<div>{getJobListItemRegion(jobData)}</div>
149+
</div>
150+
151+
<div>
152+
<Box variant="awsui-key-label">{t('projects.run.instance')}</Box>
153+
<div>{getJobListItemInstance(jobData)}</div>
154+
</div>
155+
156+
<div>
157+
<Box variant="awsui-key-label">{t('projects.run.resources')}</Box>
158+
<div>{getJobListItemResources(jobData)}</div>
159+
</div>
160+
161+
<div>
162+
<Box variant="awsui-key-label">{t('projects.run.spot')}</Box>
163+
<div>{getJobListItemSpot(jobData)}</div>
164+
</div>
165+
166+
<div>
167+
<Box variant="awsui-key-label">{t('projects.run.price')}</Box>
168+
<div>{getJobListItemPrice(jobData)}</div>
169+
</div>
170+
</ColumnLayout>
171+
</Container>
172+
173+
<Logs
174+
projectName={paramProjectName}
175+
runName={runData?.run_spec?.run_name ?? ''}
176+
jobSubmissionId={getJobSubmissionId(jobData)}
177+
className={styles.logs}
178+
/>
179+
</div>
180+
),
181+
},
182+
{
183+
label: 'Metrics',
184+
id: CodeTab.Metrics,
185+
content: <RunMetrics />,
186+
},
187+
]}
160188
/>
161189
</>
162190
)}

frontend/src/pages/Runs/Details/Jobs/Details/styles.module.scss

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,18 @@
11
.page {
2-
height: 100%;
2+
.details {
3+
height: calc(100vh - 272px);
4+
display: flex;
5+
flex-direction: column;
6+
gap: 16px;
37

4-
& > [class^="awsui_layout"] {
5-
height: 100%;
6-
& > [class^="awsui_content"] {
7-
display: flex;
8-
flex-direction: column;
9-
gap: 20px;
8+
& > [class^="awsui_layout"] {
109
height: 100%;
10+
& > [class^="awsui_content"] {
11+
display: flex;
12+
flex-direction: column;
13+
gap: 20px;
14+
height: 100%;
15+
}
1116
}
1217
}
1318

frontend/src/pages/Runs/Details/Jobs/List/helpers.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,11 @@ export const getJobListItemResources = (job: IJob) => {
77
};
88

99
export const getJobListItemSpot = (job: IJob) => {
10-
return job.job_submissions?.[job.job_submissions.length - 1]?.job_provisioning_data?.instance_type?.resources?.spot?.toString() ?? '-';
10+
return (
11+
job.job_submissions?.[
12+
job.job_submissions.length - 1
13+
]?.job_provisioning_data?.instance_type?.resources?.spot?.toString() ?? '-'
14+
);
1115
};
1216

1317
export const getJobListItemPrice = (job: IJob) => {
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
export const second = 1000;
2+
export const minute = 60 * second;
3+
export const hour = minute * 60;
4+
5+
export const kByte = 1024;
6+
export const MByte = kByte * 1024;
7+
export const GByte = MByte * 1024;
8+
9+
export const CPU_NUMS = 'cpus_detected_num';
10+
export const ALL_CPU_USAGE = 'cpu_usage_percent';
11+
export const MEMORY_WORKING_SET = 'memory_working_set_bytes';
12+
export const MEMORY_TOTAL = 'memory_total_bytes';
13+
export const EACH_GPU_USAGE_PREFIX = 'gpu_util_percent_gpu';
14+
export const EACH_GPU_MEMORY_USAGE_PREFIX = 'gpu_memory_usage_bytes_gpu';
15+
export const EACH_GPU_MEMORY_TOTAL = 'gpu_memory_total_bytes';
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { GByte, kByte, MByte } from './consts';
2+
3+
export const formatTime = (date: Date) => {
4+
return date.toLocaleTimeString('en-US', {
5+
hour: 'numeric',
6+
minute: 'numeric',
7+
hour12: !1,
8+
});
9+
};
10+
11+
export const formatPercent = (percent: number) => `${percent} %`;
12+
13+
export const bytesFormatter = (bytes: number, hasPostfix = true) => {
14+
if (bytes >= GByte) {
15+
return (bytes / GByte).toFixed(1) + (hasPostfix ? ' GB' : '');
16+
}
17+
18+
if (bytes >= MByte) {
19+
return (bytes / MByte).toFixed(1) + (hasPostfix ? ' MB' : '');
20+
}
21+
22+
if (bytes >= kByte) {
23+
return (bytes / kByte).toFixed(1) + (hasPostfix ? ' KB' : '');
24+
}
25+
26+
return bytes + (hasPostfix ? ' B' : '');
27+
};
28+
29+
type GetSeriesDataArgs = {
30+
metricItem: IMetricsItem;
31+
yValueFormater?: (value: IMetricsItem['values'][number], index: number) => IMetricsItem['values'][number];
32+
};
33+
34+
export const getSeriesData = ({ metricItem, yValueFormater = (value) => value }: GetSeriesDataArgs) => {
35+
return metricItem.timestamps.map((time, index) => ({
36+
x: new Date(time),
37+
y: yValueFormater(metricItem.values[index], index),
38+
}));
39+
};
40+
41+
type GetChartPropsArgs = Pick<GetSeriesDataArgs, 'yValueFormater'> & {
42+
renderTitle: (index: number) => string;
43+
type?: string;
44+
valueFormatter?: (value: number) => void;
45+
metricItems: IMetricsItem[];
46+
customSeries?: unknown[];
47+
yDomain?: number[];
48+
};
49+
50+
export const getChartProps = ({
51+
metricItems,
52+
renderTitle,
53+
type = 'line',
54+
valueFormatter,
55+
yValueFormater,
56+
customSeries = [],
57+
yDomain = [],
58+
}: GetChartPropsArgs) => {
59+
const series = metricItems.map((metricItem, index) => ({
60+
title: renderTitle(index),
61+
type,
62+
valueFormatter,
63+
data: getSeriesData({ metricItem, yValueFormater }),
64+
}));
65+
66+
const firstSeries = series?.[0]?.data;
67+
68+
return {
69+
series: [...series, ...customSeries],
70+
xDomain: [firstSeries?.[0]?.x, firstSeries?.[firstSeries.length - 1]?.x],
71+
yDomain,
72+
};
73+
};

0 commit comments

Comments
 (0)