Skip to content

Commit 566f3cb

Browse files
committed
jobs: enable downloading execution detail files
In cockroachdb#106879 we added a table to the `Advanced Debugging` tab of the job details page. This table lists out all the execution detail files that are available for the given job. This change is a follow up to add download functionality to each row in the table. The format of the downloaded file is determined by the prefix of the filename. A final change to allow users to generate execution details will be added in the next follow up. Informs: cockroachdb#105076 Release note: None
1 parent f2e63a4 commit 566f3cb

File tree

12 files changed

+183
-52
lines changed

12 files changed

+183
-52
lines changed

pkg/ui/workspaces/cluster-ui/src/api/jobProfilerApi.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,12 @@ export type ListJobProfilerExecutionDetailsRequest =
1616
export type ListJobProfilerExecutionDetailsResponse =
1717
cockroach.server.serverpb.ListJobProfilerExecutionDetailsResponse;
1818

19-
export const getExecutionDetails = (
19+
export type GetJobProfilerExecutionDetailRequest =
20+
cockroach.server.serverpb.GetJobProfilerExecutionDetailRequest;
21+
export type GetJobProfilerExecutionDetailResponse =
22+
cockroach.server.serverpb.GetJobProfilerExecutionDetailResponse;
23+
24+
export const listExecutionDetailFiles = (
2025
req: ListJobProfilerExecutionDetailsRequest,
2126
): Promise<cockroach.server.serverpb.ListJobProfilerExecutionDetailsResponse> => {
2227
return fetchData(
@@ -27,3 +32,15 @@ export const getExecutionDetails = (
2732
"30M",
2833
);
2934
};
35+
36+
export const getExecutionDetailFile = (
37+
req: GetJobProfilerExecutionDetailRequest,
38+
): Promise<cockroach.server.serverpb.GetJobProfilerExecutionDetailResponse> => {
39+
return fetchData(
40+
cockroach.server.serverpb.GetJobProfilerExecutionDetailResponse,
41+
`/_status/job_profiler_execution_details/${req.job_id}/${req.filename}`,
42+
null,
43+
null,
44+
"30M",
45+
);
46+
};

pkg/ui/workspaces/cluster-ui/src/downloadFile/downloadFile.tsx

Lines changed: 9 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -15,16 +15,13 @@ import React, {
1515
useImperativeHandle,
1616
} from "react";
1717

18-
type FileTypes = "text/plain" | "application/json";
19-
2018
export interface DownloadAsFileProps {
2119
fileName?: string;
22-
fileType?: FileTypes;
23-
content?: string;
20+
content?: Blob;
2421
}
2522

2623
export interface DownloadFileRef {
27-
download: (name: string, type: FileTypes, body: string) => void;
24+
download: (name: string, body: Blob) => void;
2825
}
2926

3027
/*
@@ -58,26 +55,25 @@ export interface DownloadFileRef {
5855
// tslint:disable-next-line:variable-name
5956
export const DownloadFile = forwardRef<DownloadFileRef, DownloadAsFileProps>(
6057
(props, ref) => {
61-
const { children, fileName, fileType, content } = props;
58+
const { children, fileName, content } = props;
6259
const anchorRef = useRef<HTMLAnchorElement>();
6360

64-
const bootstrapFile = (name: string, type: FileTypes, body: string) => {
61+
const bootstrapFile = (name: string, body: Blob) => {
6562
const anchorElement = anchorRef.current;
66-
const file = new Blob([body], { type });
67-
anchorElement.href = URL.createObjectURL(file);
63+
anchorElement.href = URL.createObjectURL(body);
6864
anchorElement.download = name;
6965
};
7066

7167
useEffect(() => {
7268
if (content === undefined) {
7369
return;
7470
}
75-
bootstrapFile(fileName, fileType, content);
76-
}, [fileName, fileType, content]);
71+
bootstrapFile(fileName, content);
72+
}, [fileName, content]);
7773

7874
useImperativeHandle(ref, () => ({
79-
download: (name: string, type: FileTypes, body: string) => {
80-
bootstrapFile(name, type, body);
75+
download: (name: string, body: Blob) => {
76+
bootstrapFile(name, body);
8177
anchorRef.current.click();
8278
},
8379
}));

pkg/ui/workspaces/cluster-ui/src/jobs/jobDetailsPage/jobDetails.tsx

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ import jobStyles from "src/jobs/jobs.module.scss";
4040
import classNames from "classnames/bind";
4141
import { Timestamp } from "../../timestamp";
4242
import {
43+
GetJobProfilerExecutionDetailRequest,
44+
GetJobProfilerExecutionDetailResponse,
4345
ListJobProfilerExecutionDetailsRequest,
4446
ListJobProfilerExecutionDetailsResponse,
4547
RequestState,
@@ -60,14 +62,17 @@ enum TabKeysEnum {
6062

6163
export interface JobDetailsStateProps {
6264
jobRequest: RequestState<JobResponse>;
63-
jobProfilerResponse: RequestState<ListJobProfilerExecutionDetailsResponse>;
65+
jobProfilerExecutionDetailFilesResponse: RequestState<ListJobProfilerExecutionDetailsResponse>;
6466
jobProfilerLastUpdated: moment.Moment;
6567
jobProfilerDataIsValid: boolean;
68+
onDownloadExecutionFileClicked: (
69+
req: GetJobProfilerExecutionDetailRequest,
70+
) => Promise<GetJobProfilerExecutionDetailResponse>;
6671
}
6772

6873
export interface JobDetailsDispatchProps {
6974
refreshJob: (req: JobRequest) => void;
70-
refreshExecutionDetails: (
75+
refreshExecutionDetailFiles: (
7176
req: ListJobProfilerExecutionDetailsRequest,
7277
) => void;
7378
}
@@ -130,10 +135,15 @@ export class JobDetails extends React.Component<
130135
return (
131136
<JobProfilerView
132137
jobID={id}
133-
executionDetailsResponse={this.props.jobProfilerResponse}
134-
refreshExecutionDetails={this.props.refreshExecutionDetails}
138+
executionDetailFilesResponse={
139+
this.props.jobProfilerExecutionDetailFilesResponse
140+
}
141+
refreshExecutionDetailFiles={this.props.refreshExecutionDetailFiles}
135142
lastUpdated={this.props.jobProfilerLastUpdated}
136143
isDataValid={this.props.jobProfilerDataIsValid}
144+
onDownloadExecutionFileClicked={
145+
this.props.onDownloadExecutionFileClicked
146+
}
137147
/>
138148
);
139149
};

pkg/ui/workspaces/cluster-ui/src/jobs/jobDetailsPage/jobDetailsConnected.tsx

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { selectID } from "../../selectors";
2323
import {
2424
ListJobProfilerExecutionDetailsRequest,
2525
createInitialState,
26+
getExecutionDetailFile,
2627
} from "src/api";
2728
import {
2829
initialState,
@@ -39,15 +40,17 @@ const mapStateToProps = (
3940
const jobID = selectID(state, props);
4041
return {
4142
jobRequest: state.adminUI?.job?.cachedData[jobID] ?? emptyState,
42-
jobProfilerResponse: state.adminUI?.executionDetails ?? initialState,
43-
jobProfilerLastUpdated: state.adminUI?.executionDetails?.lastUpdated,
44-
jobProfilerDataIsValid: state.adminUI?.executionDetails?.valid,
43+
jobProfilerExecutionDetailFilesResponse:
44+
state.adminUI?.executionDetailFiles ?? initialState,
45+
jobProfilerLastUpdated: state.adminUI?.executionDetailFiles?.lastUpdated,
46+
jobProfilerDataIsValid: state.adminUI?.executionDetailFiles?.valid,
47+
onDownloadExecutionFileClicked: getExecutionDetailFile,
4548
};
4649
};
4750

4851
const mapDispatchToProps = (dispatch: Dispatch): JobDetailsDispatchProps => ({
4952
refreshJob: (req: JobRequest) => jobActions.refresh(req),
50-
refreshExecutionDetails: (req: ListJobProfilerExecutionDetailsRequest) =>
53+
refreshExecutionDetailFiles: (req: ListJobProfilerExecutionDetailsRequest) =>
5154
dispatch(jobProfilerActions.refresh(req)),
5255
});
5356

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,26 @@
11
@import "src/core/index.module";
22

3+
.crl-job-profiler-view {
4+
&__actions-column {
5+
display: flex;
6+
flex-direction: row;
7+
flex-wrap: nowrap;
8+
justify-content: flex-end;
9+
}
10+
}
11+
12+
.column-size-medium {
13+
width: 230px;
14+
}
15+
16+
.download-execution-detail-button {
17+
white-space: nowrap;
18+
19+
>svg {
20+
margin-right: $spacing-x-small;
21+
}
22+
}
23+
324
.sorted-table {
425
width: 100%;
526
}

pkg/ui/workspaces/cluster-ui/src/jobs/jobDetailsPage/jobProfilerView.tsx

Lines changed: 95 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,8 @@
1111
import { cockroach } from "@cockroachlabs/crdb-protobuf-client";
1212
import moment from "moment-timezone";
1313
import React, { useCallback, useEffect, useState } from "react";
14-
import {
15-
RequestState,
16-
ListJobProfilerExecutionDetailsResponse,
17-
ListJobProfilerExecutionDetailsRequest,
18-
} from "src/api";
19-
import { InlineAlert } from "@cockroachlabs/ui-components";
14+
import { RequestState } from "src/api";
15+
import { Button, InlineAlert, Icon } from "@cockroachlabs/ui-components";
2016
import { Row, Col } from "antd";
2117
import "antd/lib/col/style";
2218
import "antd/lib/row/style";
@@ -29,45 +25,129 @@ import classnames from "classnames/bind";
2925
import styles from "./jobProfilerView.module.scss";
3026
import { EmptyTable } from "src/empty";
3127
import { useScheduleFunction } from "src/util/hooks";
28+
import { DownloadFile, DownloadFileRef } from "src/downloadFile";
29+
import {
30+
GetJobProfilerExecutionDetailRequest,
31+
GetJobProfilerExecutionDetailResponse,
32+
ListJobProfilerExecutionDetailsRequest,
33+
ListJobProfilerExecutionDetailsResponse,
34+
} from "src/api/jobProfilerApi";
3235

3336
const cardCx = classNames.bind(summaryCardStyles);
3437
const cx = classnames.bind(styles);
3538

3639
export type JobProfilerStateProps = {
3740
jobID: long;
38-
executionDetailsResponse: RequestState<ListJobProfilerExecutionDetailsResponse>;
41+
executionDetailFilesResponse: RequestState<ListJobProfilerExecutionDetailsResponse>;
3942
lastUpdated: moment.Moment;
4043
isDataValid: boolean;
44+
onDownloadExecutionFileClicked: (
45+
req: GetJobProfilerExecutionDetailRequest,
46+
) => Promise<GetJobProfilerExecutionDetailResponse>;
4147
};
4248

4349
export type JobProfilerDispatchProps = {
44-
refreshExecutionDetails: (
50+
refreshExecutionDetailFiles: (
4551
req: ListJobProfilerExecutionDetailsRequest,
4652
) => void;
4753
};
4854

4955
export type JobProfilerViewProps = JobProfilerStateProps &
5056
JobProfilerDispatchProps;
5157

52-
export function makeJobProfilerViewColumns(): ColumnDescriptor<string>[] {
58+
export function extractFileExtension(filename: string): string {
59+
const parts = filename.split(".");
60+
// The extension is the last part after the last dot (if it exists).
61+
return parts.length > 1 ? parts[parts.length - 1] : "";
62+
}
63+
64+
export function getContentTypeForFile(filename: string): string {
65+
const extension = extractFileExtension(filename);
66+
switch (extension) {
67+
case "txt":
68+
return "text/plain";
69+
case "zip":
70+
return "application/zip";
71+
case "html":
72+
return "text/html";
73+
default:
74+
return "";
75+
}
76+
}
77+
78+
export function makeJobProfilerViewColumns(
79+
jobID: Long,
80+
onDownloadExecutionFileClicked: (
81+
req: GetJobProfilerExecutionDetailRequest,
82+
) => Promise<GetJobProfilerExecutionDetailResponse>,
83+
): ColumnDescriptor<string>[] {
84+
const downloadRef: React.RefObject<DownloadFileRef> =
85+
React.createRef<DownloadFileRef>();
5386
return [
5487
{
5588
name: "executionDetailFiles",
5689
title: "Execution Detail Files",
5790
hideTitleUnderline: true,
5891
cell: (executionDetails: string) => executionDetails,
5992
},
93+
{
94+
name: "actions",
95+
title: "",
96+
hideTitleUnderline: true,
97+
className: cx("column-size-medium"),
98+
cell: (executionDetailFile: string) => {
99+
return (
100+
<div className={cx("crl-job-profiler-view__actions-column")}>
101+
<DownloadFile ref={downloadRef} />
102+
<Button
103+
as="a"
104+
size="small"
105+
intent="tertiary"
106+
className={cx("download-execution-detail-button")}
107+
onClick={() => {
108+
const req =
109+
new cockroach.server.serverpb.GetJobProfilerExecutionDetailRequest(
110+
{
111+
job_id: jobID,
112+
filename: executionDetailFile,
113+
},
114+
);
115+
onDownloadExecutionFileClicked(req).then(resp => {
116+
const type = getContentTypeForFile(executionDetailFile);
117+
const executionFileBytes = new Blob([resp.data], {
118+
type: type,
119+
});
120+
Promise.resolve().then(() => {
121+
downloadRef.current.download(
122+
executionDetailFile,
123+
executionFileBytes,
124+
);
125+
});
126+
});
127+
}}
128+
>
129+
<Icon iconName="Download" />
130+
Download
131+
</Button>
132+
</div>
133+
);
134+
},
135+
},
60136
];
61137
}
62138

63139
export const JobProfilerView: React.FC<JobProfilerViewProps> = ({
64140
jobID,
65-
executionDetailsResponse,
141+
executionDetailFilesResponse,
66142
lastUpdated,
67143
isDataValid,
68-
refreshExecutionDetails,
144+
onDownloadExecutionFileClicked,
145+
refreshExecutionDetailFiles,
69146
}: JobProfilerViewProps) => {
70-
const columns = makeJobProfilerViewColumns();
147+
const columns = makeJobProfilerViewColumns(
148+
jobID,
149+
onDownloadExecutionFileClicked,
150+
);
71151
const [sortSetting, setSortSetting] = useState<SortSetting>({
72152
ascending: true,
73153
columnTitle: "executionDetailFiles",
@@ -77,8 +157,8 @@ export const JobProfilerView: React.FC<JobProfilerViewProps> = ({
77157
job_id: jobID,
78158
});
79159
const refresh = useCallback(() => {
80-
refreshExecutionDetails(req);
81-
}, [refreshExecutionDetails, req]);
160+
refreshExecutionDetailFiles(req);
161+
}, [refreshExecutionDetailFiles, req]);
82162
const [refetch] = useScheduleFunction(
83163
refresh,
84164
true,
@@ -119,7 +199,7 @@ export const JobProfilerView: React.FC<JobProfilerViewProps> = ({
119199
<Row gutter={24}>
120200
<Col className="gutter-row" span={24}>
121201
<SortedTable
122-
data={executionDetailsResponse.data?.files}
202+
data={executionDetailFilesResponse.data?.files}
123203
columns={columns}
124204
tableWrapperClassName={cx("sorted-table")}
125205
sortSetting={sortSetting}

pkg/ui/workspaces/cluster-ui/src/store/jobs/jobProfiler.reducer.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import {
1717
ListJobProfilerExecutionDetailsResponse,
1818
} from "src/api";
1919

20-
export type JobProfilerState =
20+
export type JobProfilerExecutionDetailFilesState =
2121
RequestState<ListJobProfilerExecutionDetailsResponse>;
2222

2323
export const initialState =

pkg/ui/workspaces/cluster-ui/src/store/jobs/jobProfiler.sagas.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { actions } from "./jobProfiler.reducer";
1313
import { call, put, all, takeEvery } from "redux-saga/effects";
1414
import {
1515
ListJobProfilerExecutionDetailsRequest,
16-
getExecutionDetails,
16+
listExecutionDetailFiles,
1717
} from "src/api";
1818

1919
export function* refreshJobProfilerSaga(
@@ -26,7 +26,7 @@ export function* requestJobProfilerSaga(
2626
action: PayloadAction<ListJobProfilerExecutionDetailsRequest>,
2727
): any {
2828
try {
29-
const result = yield call(getExecutionDetails, action.payload);
29+
const result = yield call(listExecutionDetailFiles, action.payload);
3030
yield put(actions.received(result));
3131
} catch (e) {
3232
yield put(actions.failed(e));

0 commit comments

Comments
 (0)