From 566f3cba3783e863c32ad80e2719b576e1ff36b9 Mon Sep 17 00:00:00 2001 From: adityamaru Date: Mon, 24 Jul 2023 16:02:25 -0400 Subject: [PATCH] jobs: enable downloading execution detail files In #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: #105076 Release note: None --- .../cluster-ui/src/api/jobProfilerApi.ts | 19 ++- .../src/downloadFile/downloadFile.tsx | 22 ++-- .../src/jobs/jobDetailsPage/jobDetails.tsx | 18 ++- .../jobDetailsPage/jobDetailsConnected.tsx | 11 +- .../jobProfilerView.module.scss | 21 ++++ .../jobs/jobDetailsPage/jobProfilerView.tsx | 110 +++++++++++++++--- .../src/store/jobs/jobProfiler.reducer.ts | 2 +- .../src/store/jobs/jobProfiler.sagas.ts | 4 +- .../cluster-ui/src/store/reducers.ts | 8 +- .../db-console/src/redux/apiReducers.ts | 4 +- pkg/ui/workspaces/db-console/src/util/api.ts | 2 +- .../db-console/src/views/jobs/jobDetails.tsx | 14 ++- 12 files changed, 183 insertions(+), 52 deletions(-) diff --git a/pkg/ui/workspaces/cluster-ui/src/api/jobProfilerApi.ts b/pkg/ui/workspaces/cluster-ui/src/api/jobProfilerApi.ts index f13b858ceb7f..1dcb35c62574 100644 --- a/pkg/ui/workspaces/cluster-ui/src/api/jobProfilerApi.ts +++ b/pkg/ui/workspaces/cluster-ui/src/api/jobProfilerApi.ts @@ -16,7 +16,12 @@ export type ListJobProfilerExecutionDetailsRequest = export type ListJobProfilerExecutionDetailsResponse = cockroach.server.serverpb.ListJobProfilerExecutionDetailsResponse; -export const getExecutionDetails = ( +export type GetJobProfilerExecutionDetailRequest = + cockroach.server.serverpb.GetJobProfilerExecutionDetailRequest; +export type GetJobProfilerExecutionDetailResponse = + cockroach.server.serverpb.GetJobProfilerExecutionDetailResponse; + +export const listExecutionDetailFiles = ( req: ListJobProfilerExecutionDetailsRequest, ): Promise => { return fetchData( @@ -27,3 +32,15 @@ export const getExecutionDetails = ( "30M", ); }; + +export const getExecutionDetailFile = ( + req: GetJobProfilerExecutionDetailRequest, +): Promise => { + return fetchData( + cockroach.server.serverpb.GetJobProfilerExecutionDetailResponse, + `/_status/job_profiler_execution_details/${req.job_id}/${req.filename}`, + null, + null, + "30M", + ); +}; diff --git a/pkg/ui/workspaces/cluster-ui/src/downloadFile/downloadFile.tsx b/pkg/ui/workspaces/cluster-ui/src/downloadFile/downloadFile.tsx index 05e8cae23f04..30bf1719f3e4 100644 --- a/pkg/ui/workspaces/cluster-ui/src/downloadFile/downloadFile.tsx +++ b/pkg/ui/workspaces/cluster-ui/src/downloadFile/downloadFile.tsx @@ -15,16 +15,13 @@ import React, { useImperativeHandle, } from "react"; -type FileTypes = "text/plain" | "application/json"; - export interface DownloadAsFileProps { fileName?: string; - fileType?: FileTypes; - content?: string; + content?: Blob; } export interface DownloadFileRef { - download: (name: string, type: FileTypes, body: string) => void; + download: (name: string, body: Blob) => void; } /* @@ -58,13 +55,12 @@ export interface DownloadFileRef { // tslint:disable-next-line:variable-name export const DownloadFile = forwardRef( (props, ref) => { - const { children, fileName, fileType, content } = props; + const { children, fileName, content } = props; const anchorRef = useRef(); - const bootstrapFile = (name: string, type: FileTypes, body: string) => { + const bootstrapFile = (name: string, body: Blob) => { const anchorElement = anchorRef.current; - const file = new Blob([body], { type }); - anchorElement.href = URL.createObjectURL(file); + anchorElement.href = URL.createObjectURL(body); anchorElement.download = name; }; @@ -72,12 +68,12 @@ export const DownloadFile = forwardRef( if (content === undefined) { return; } - bootstrapFile(fileName, fileType, content); - }, [fileName, fileType, content]); + bootstrapFile(fileName, content); + }, [fileName, content]); useImperativeHandle(ref, () => ({ - download: (name: string, type: FileTypes, body: string) => { - bootstrapFile(name, type, body); + download: (name: string, body: Blob) => { + bootstrapFile(name, body); anchorRef.current.click(); }, })); diff --git a/pkg/ui/workspaces/cluster-ui/src/jobs/jobDetailsPage/jobDetails.tsx b/pkg/ui/workspaces/cluster-ui/src/jobs/jobDetailsPage/jobDetails.tsx index 93135053a98e..704e2597a8a3 100644 --- a/pkg/ui/workspaces/cluster-ui/src/jobs/jobDetailsPage/jobDetails.tsx +++ b/pkg/ui/workspaces/cluster-ui/src/jobs/jobDetailsPage/jobDetails.tsx @@ -40,6 +40,8 @@ import jobStyles from "src/jobs/jobs.module.scss"; import classNames from "classnames/bind"; import { Timestamp } from "../../timestamp"; import { + GetJobProfilerExecutionDetailRequest, + GetJobProfilerExecutionDetailResponse, ListJobProfilerExecutionDetailsRequest, ListJobProfilerExecutionDetailsResponse, RequestState, @@ -60,14 +62,17 @@ enum TabKeysEnum { export interface JobDetailsStateProps { jobRequest: RequestState; - jobProfilerResponse: RequestState; + jobProfilerExecutionDetailFilesResponse: RequestState; jobProfilerLastUpdated: moment.Moment; jobProfilerDataIsValid: boolean; + onDownloadExecutionFileClicked: ( + req: GetJobProfilerExecutionDetailRequest, + ) => Promise; } export interface JobDetailsDispatchProps { refreshJob: (req: JobRequest) => void; - refreshExecutionDetails: ( + refreshExecutionDetailFiles: ( req: ListJobProfilerExecutionDetailsRequest, ) => void; } @@ -130,10 +135,15 @@ export class JobDetails extends React.Component< return ( ); }; diff --git a/pkg/ui/workspaces/cluster-ui/src/jobs/jobDetailsPage/jobDetailsConnected.tsx b/pkg/ui/workspaces/cluster-ui/src/jobs/jobDetailsPage/jobDetailsConnected.tsx index c6b10ba48f47..5e88adfd780e 100644 --- a/pkg/ui/workspaces/cluster-ui/src/jobs/jobDetailsPage/jobDetailsConnected.tsx +++ b/pkg/ui/workspaces/cluster-ui/src/jobs/jobDetailsPage/jobDetailsConnected.tsx @@ -23,6 +23,7 @@ import { selectID } from "../../selectors"; import { ListJobProfilerExecutionDetailsRequest, createInitialState, + getExecutionDetailFile, } from "src/api"; import { initialState, @@ -39,15 +40,17 @@ const mapStateToProps = ( const jobID = selectID(state, props); return { jobRequest: state.adminUI?.job?.cachedData[jobID] ?? emptyState, - jobProfilerResponse: state.adminUI?.executionDetails ?? initialState, - jobProfilerLastUpdated: state.adminUI?.executionDetails?.lastUpdated, - jobProfilerDataIsValid: state.adminUI?.executionDetails?.valid, + jobProfilerExecutionDetailFilesResponse: + state.adminUI?.executionDetailFiles ?? initialState, + jobProfilerLastUpdated: state.adminUI?.executionDetailFiles?.lastUpdated, + jobProfilerDataIsValid: state.adminUI?.executionDetailFiles?.valid, + onDownloadExecutionFileClicked: getExecutionDetailFile, }; }; const mapDispatchToProps = (dispatch: Dispatch): JobDetailsDispatchProps => ({ refreshJob: (req: JobRequest) => jobActions.refresh(req), - refreshExecutionDetails: (req: ListJobProfilerExecutionDetailsRequest) => + refreshExecutionDetailFiles: (req: ListJobProfilerExecutionDetailsRequest) => dispatch(jobProfilerActions.refresh(req)), }); diff --git a/pkg/ui/workspaces/cluster-ui/src/jobs/jobDetailsPage/jobProfilerView.module.scss b/pkg/ui/workspaces/cluster-ui/src/jobs/jobDetailsPage/jobProfilerView.module.scss index d3e955fa1e52..8f96e3e09859 100644 --- a/pkg/ui/workspaces/cluster-ui/src/jobs/jobDetailsPage/jobProfilerView.module.scss +++ b/pkg/ui/workspaces/cluster-ui/src/jobs/jobDetailsPage/jobProfilerView.module.scss @@ -1,5 +1,26 @@ @import "src/core/index.module"; +.crl-job-profiler-view { + &__actions-column { + display: flex; + flex-direction: row; + flex-wrap: nowrap; + justify-content: flex-end; + } +} + +.column-size-medium { + width: 230px; +} + +.download-execution-detail-button { + white-space: nowrap; + + >svg { + margin-right: $spacing-x-small; + } +} + .sorted-table { width: 100%; } \ No newline at end of file diff --git a/pkg/ui/workspaces/cluster-ui/src/jobs/jobDetailsPage/jobProfilerView.tsx b/pkg/ui/workspaces/cluster-ui/src/jobs/jobDetailsPage/jobProfilerView.tsx index e92918c15963..4948cdba01ea 100644 --- a/pkg/ui/workspaces/cluster-ui/src/jobs/jobDetailsPage/jobProfilerView.tsx +++ b/pkg/ui/workspaces/cluster-ui/src/jobs/jobDetailsPage/jobProfilerView.tsx @@ -11,12 +11,8 @@ import { cockroach } from "@cockroachlabs/crdb-protobuf-client"; import moment from "moment-timezone"; import React, { useCallback, useEffect, useState } from "react"; -import { - RequestState, - ListJobProfilerExecutionDetailsResponse, - ListJobProfilerExecutionDetailsRequest, -} from "src/api"; -import { InlineAlert } from "@cockroachlabs/ui-components"; +import { RequestState } from "src/api"; +import { Button, InlineAlert, Icon } from "@cockroachlabs/ui-components"; import { Row, Col } from "antd"; import "antd/lib/col/style"; import "antd/lib/row/style"; @@ -29,19 +25,29 @@ import classnames from "classnames/bind"; import styles from "./jobProfilerView.module.scss"; import { EmptyTable } from "src/empty"; import { useScheduleFunction } from "src/util/hooks"; +import { DownloadFile, DownloadFileRef } from "src/downloadFile"; +import { + GetJobProfilerExecutionDetailRequest, + GetJobProfilerExecutionDetailResponse, + ListJobProfilerExecutionDetailsRequest, + ListJobProfilerExecutionDetailsResponse, +} from "src/api/jobProfilerApi"; const cardCx = classNames.bind(summaryCardStyles); const cx = classnames.bind(styles); export type JobProfilerStateProps = { jobID: long; - executionDetailsResponse: RequestState; + executionDetailFilesResponse: RequestState; lastUpdated: moment.Moment; isDataValid: boolean; + onDownloadExecutionFileClicked: ( + req: GetJobProfilerExecutionDetailRequest, + ) => Promise; }; export type JobProfilerDispatchProps = { - refreshExecutionDetails: ( + refreshExecutionDetailFiles: ( req: ListJobProfilerExecutionDetailsRequest, ) => void; }; @@ -49,7 +55,34 @@ export type JobProfilerDispatchProps = { export type JobProfilerViewProps = JobProfilerStateProps & JobProfilerDispatchProps; -export function makeJobProfilerViewColumns(): ColumnDescriptor[] { +export function extractFileExtension(filename: string): string { + const parts = filename.split("."); + // The extension is the last part after the last dot (if it exists). + return parts.length > 1 ? parts[parts.length - 1] : ""; +} + +export function getContentTypeForFile(filename: string): string { + const extension = extractFileExtension(filename); + switch (extension) { + case "txt": + return "text/plain"; + case "zip": + return "application/zip"; + case "html": + return "text/html"; + default: + return ""; + } +} + +export function makeJobProfilerViewColumns( + jobID: Long, + onDownloadExecutionFileClicked: ( + req: GetJobProfilerExecutionDetailRequest, + ) => Promise, +): ColumnDescriptor[] { + const downloadRef: React.RefObject = + React.createRef(); return [ { name: "executionDetailFiles", @@ -57,17 +90,64 @@ export function makeJobProfilerViewColumns(): ColumnDescriptor[] { hideTitleUnderline: true, cell: (executionDetails: string) => executionDetails, }, + { + name: "actions", + title: "", + hideTitleUnderline: true, + className: cx("column-size-medium"), + cell: (executionDetailFile: string) => { + return ( +
+ + +
+ ); + }, + }, ]; } export const JobProfilerView: React.FC = ({ jobID, - executionDetailsResponse, + executionDetailFilesResponse, lastUpdated, isDataValid, - refreshExecutionDetails, + onDownloadExecutionFileClicked, + refreshExecutionDetailFiles, }: JobProfilerViewProps) => { - const columns = makeJobProfilerViewColumns(); + const columns = makeJobProfilerViewColumns( + jobID, + onDownloadExecutionFileClicked, + ); const [sortSetting, setSortSetting] = useState({ ascending: true, columnTitle: "executionDetailFiles", @@ -77,8 +157,8 @@ export const JobProfilerView: React.FC = ({ job_id: jobID, }); const refresh = useCallback(() => { - refreshExecutionDetails(req); - }, [refreshExecutionDetails, req]); + refreshExecutionDetailFiles(req); + }, [refreshExecutionDetailFiles, req]); const [refetch] = useScheduleFunction( refresh, true, @@ -119,7 +199,7 @@ export const JobProfilerView: React.FC = ({ ; export const initialState = diff --git a/pkg/ui/workspaces/cluster-ui/src/store/jobs/jobProfiler.sagas.ts b/pkg/ui/workspaces/cluster-ui/src/store/jobs/jobProfiler.sagas.ts index d0c19679deda..6a904af42d52 100644 --- a/pkg/ui/workspaces/cluster-ui/src/store/jobs/jobProfiler.sagas.ts +++ b/pkg/ui/workspaces/cluster-ui/src/store/jobs/jobProfiler.sagas.ts @@ -13,7 +13,7 @@ import { actions } from "./jobProfiler.reducer"; import { call, put, all, takeEvery } from "redux-saga/effects"; import { ListJobProfilerExecutionDetailsRequest, - getExecutionDetails, + listExecutionDetailFiles, } from "src/api"; export function* refreshJobProfilerSaga( @@ -26,7 +26,7 @@ export function* requestJobProfilerSaga( action: PayloadAction, ): any { try { - const result = yield call(getExecutionDetails, action.payload); + const result = yield call(listExecutionDetailFiles, action.payload); yield put(actions.received(result)); } catch (e) { yield put(actions.failed(e)); diff --git a/pkg/ui/workspaces/cluster-ui/src/store/reducers.ts b/pkg/ui/workspaces/cluster-ui/src/store/reducers.ts index c8c2ff2ed0be..1c6e61b7ef70 100644 --- a/pkg/ui/workspaces/cluster-ui/src/store/reducers.ts +++ b/pkg/ui/workspaces/cluster-ui/src/store/reducers.ts @@ -77,8 +77,8 @@ import { reducer as tableDetails, } from "./databaseTableDetails/tableDetails.reducer"; import { - JobProfilerState, - reducer as executionDetails, + JobProfilerExecutionDetailFilesState, + reducer as executionDetailFiles, } from "./jobs/jobProfiler.reducer"; export type AdminUiState = { @@ -95,7 +95,7 @@ export type AdminUiState = { indexStats: IndexStatsReducerState; jobs: JobsState; job: JobDetailsReducerState; - executionDetails: JobProfilerState; + executionDetailFiles: JobProfilerExecutionDetailFilesState; clusterLocks: ClusterLocksReqState; databasesList: DatabasesListState; databaseDetails: KeyedDatabaseDetailsState; @@ -129,7 +129,7 @@ export const reducers = combineReducers({ indexStats, jobs, job, - executionDetails, + executionDetailFiles, clusterLocks, databasesList, databaseDetails, diff --git a/pkg/ui/workspaces/db-console/src/redux/apiReducers.ts b/pkg/ui/workspaces/db-console/src/redux/apiReducers.ts index be71baa649a0..b89fc635f361 100644 --- a/pkg/ui/workspaces/db-console/src/redux/apiReducers.ts +++ b/pkg/ui/workspaces/db-console/src/redux/apiReducers.ts @@ -220,13 +220,13 @@ export const jobProfilerRequestKey = ( ): string => `${req.job_id}`; const jobProfilerReducerObj = new KeyedCachedDataReducer( - api.getExecutionDetails, + api.listExecutionDetailFiles, "jobProfiler", jobProfilerRequestKey, null, moment.duration(10, "m"), ); -export const refreshExecutionDetails = jobProfilerReducerObj.refresh; +export const refreshListExecutionDetailFiles = jobProfilerReducerObj.refresh; export const queryToID = (req: api.QueryPlanRequestMessage): string => req.query; diff --git a/pkg/ui/workspaces/db-console/src/util/api.ts b/pkg/ui/workspaces/db-console/src/util/api.ts index b1f3acfc90fe..86ca3edc02ab 100644 --- a/pkg/ui/workspaces/db-console/src/util/api.ts +++ b/pkg/ui/workspaces/db-console/src/util/api.ts @@ -476,7 +476,7 @@ export function getJob( ); } -export function getExecutionDetails( +export function listExecutionDetailFiles( req: ListJobProfilerExecutionDetailsRequestMessage, timeout?: moment.Duration, ): Promise { diff --git a/pkg/ui/workspaces/db-console/src/views/jobs/jobDetails.tsx b/pkg/ui/workspaces/db-console/src/views/jobs/jobDetails.tsx index 2c7f2c03971c..c445090b7fdd 100644 --- a/pkg/ui/workspaces/db-console/src/views/jobs/jobDetails.tsx +++ b/pkg/ui/workspaces/db-console/src/views/jobs/jobDetails.tsx @@ -16,19 +16,19 @@ import { connect } from "react-redux"; import { RouteComponentProps, withRouter } from "react-router-dom"; import { createSelectorForKeyedCachedDataField, - refreshExecutionDetails, + refreshListExecutionDetailFiles, refreshJob, } from "src/redux/apiReducers"; import { AdminUIState } from "src/redux/state"; import { ListJobProfilerExecutionDetailsResponseMessage } from "src/util/api"; +import { api as clusterUiApi } from "@cockroachlabs/cluster-ui"; const selectJob = createSelectorForKeyedCachedDataField("job", selectID); -const selectExecutionDetails = +const selectExecutionDetailFiles = createSelectorForKeyedCachedDataField( "jobProfiler", selectID, ); - const mapStateToProps = ( state: AdminUIState, props: RouteComponentProps, @@ -36,15 +36,19 @@ const mapStateToProps = ( const jobID = selectID(state, props); return { jobRequest: selectJob(state, props), - jobProfilerResponse: selectExecutionDetails(state, props), + jobProfilerExecutionDetailFilesResponse: selectExecutionDetailFiles( + state, + props, + ), jobProfilerLastUpdated: state.cachedData.jobProfiler[jobID]?.setAt, jobProfilerDataIsValid: state.cachedData.jobProfiler[jobID]?.valid, + onDownloadExecutionFileClicked: clusterUiApi.getExecutionDetailFile, }; }; const mapDispatchToProps = { refreshJob, - refreshExecutionDetails, + refreshExecutionDetailFiles: refreshListExecutionDetailFiles, }; export default withRouter(