From 046d6e60426f41a1a194de3b75067107deaabdad Mon Sep 17 00:00:00 2001 From: Eric Harmeling Date: Mon, 27 Jun 2022 09:20:47 -0400 Subject: [PATCH] ui: add connected components for jobs pages This commit adds "connected" components for the jobs pages. These components can be imported from the cluster-ui package into the managed service repo and used on the CC console. Release note: None --- .../workspaces/cluster-ui/src/api/jobsApi.ts | 33 ++++++ .../src/jobs/jobDetailsPage/index.ts | 1 + .../jobDetailsPage/jobDetailsConnected.tsx | 45 ++++++++ .../cluster-ui/src/jobs/jobsPage/index.ts | 1 + .../cluster-ui/src/jobs/jobsPage/jobTable.tsx | 15 ++- .../src/jobs/jobsPage/jobsPage.spec.tsx | 13 +-- .../cluster-ui/src/jobs/jobsPage/jobsPage.tsx | 30 ++--- .../src/jobs/jobsPage/jobsPageConnected.tsx | 99 ++++++++++++++++ .../src/sessions/sessionsPageConnected.tsx | 11 +- .../activeStatementsPage.selectors.ts | 2 +- .../statementsPage.selectors.ts | 11 +- .../cluster-ui/src/store/jobDetails/index.ts | 13 +++ .../src/store/jobDetails/job.reducer.ts | 52 +++++++++ .../src/store/jobDetails/job.sagas.spec.ts | 99 ++++++++++++++++ .../src/store/jobDetails/job.sagas.ts | 50 ++++++++ .../src/store/jobDetails/job.selectors.ts | 17 +++ .../cluster-ui/src/store/jobs/index.ts | 13 +++ .../cluster-ui/src/store/jobs/jobs.reducer.ts | 53 +++++++++ .../src/store/jobs/jobs.sagas.spec.ts | 108 ++++++++++++++++++ .../cluster-ui/src/store/jobs/jobs.sagas.ts | 58 ++++++++++ .../src/store/jobs/jobs.selectors.ts | 38 ++++++ .../localStorage/localStorage.reducer.ts | 28 ++++- .../cluster-ui/src/store/reducers.ts | 6 + .../workspaces/cluster-ui/src/store/sagas.ts | 4 + .../src/store/sqlStats/sqlStats.selector.ts | 7 +- .../cluster-ui/src/store/utils/selectors.ts | 22 ++++ .../activeTransactionsPage.selectors.tsx | 2 +- .../transactionsPage.selectors.ts | 2 +- 28 files changed, 775 insertions(+), 58 deletions(-) create mode 100644 pkg/ui/workspaces/cluster-ui/src/jobs/jobDetailsPage/jobDetailsConnected.tsx create mode 100644 pkg/ui/workspaces/cluster-ui/src/jobs/jobsPage/jobsPageConnected.tsx create mode 100644 pkg/ui/workspaces/cluster-ui/src/store/jobDetails/index.ts create mode 100644 pkg/ui/workspaces/cluster-ui/src/store/jobDetails/job.reducer.ts create mode 100644 pkg/ui/workspaces/cluster-ui/src/store/jobDetails/job.sagas.spec.ts create mode 100644 pkg/ui/workspaces/cluster-ui/src/store/jobDetails/job.sagas.ts create mode 100644 pkg/ui/workspaces/cluster-ui/src/store/jobDetails/job.selectors.ts create mode 100644 pkg/ui/workspaces/cluster-ui/src/store/jobs/index.ts create mode 100644 pkg/ui/workspaces/cluster-ui/src/store/jobs/jobs.reducer.ts create mode 100644 pkg/ui/workspaces/cluster-ui/src/store/jobs/jobs.sagas.spec.ts create mode 100644 pkg/ui/workspaces/cluster-ui/src/store/jobs/jobs.sagas.ts create mode 100644 pkg/ui/workspaces/cluster-ui/src/store/jobs/jobs.selectors.ts create mode 100644 pkg/ui/workspaces/cluster-ui/src/store/utils/selectors.ts diff --git a/pkg/ui/workspaces/cluster-ui/src/api/jobsApi.ts b/pkg/ui/workspaces/cluster-ui/src/api/jobsApi.ts index e77aa220b0bf..f839847d6ad9 100644 --- a/pkg/ui/workspaces/cluster-ui/src/api/jobsApi.ts +++ b/pkg/ui/workspaces/cluster-ui/src/api/jobsApi.ts @@ -9,9 +9,42 @@ // licenses/APL.txt. import { cockroach } from "@cockroachlabs/crdb-protobuf-client"; +import { fetchData } from "./fetchData"; +import { propsToQueryString } from "../util"; + +const JOBS_PATH = "/_admin/v1/jobs"; export type JobsRequest = cockroach.server.serverpb.JobsRequest; export type JobsResponse = cockroach.server.serverpb.JobsResponse; export type JobRequest = cockroach.server.serverpb.JobRequest; export type JobResponse = cockroach.server.serverpb.JobResponse; + +export const getJobs = ( + req: JobsRequest, +): Promise => { + const queryStr = propsToQueryString({ + status: req.status, + type: req.type.toString(), + limit: req.limit, + }); + return fetchData( + cockroach.server.serverpb.JobsResponse, + `${JOBS_PATH}?${queryStr}`, + null, + null, + "30M", + ); +}; + +export const getJob = ( + req: JobRequest, +): Promise => { + return fetchData( + cockroach.server.serverpb.JobResponse, + `${JOBS_PATH}/${req.job_id}`, + null, + null, + "30M", + ); +}; diff --git a/pkg/ui/workspaces/cluster-ui/src/jobs/jobDetailsPage/index.ts b/pkg/ui/workspaces/cluster-ui/src/jobs/jobDetailsPage/index.ts index 8f4317ee5446..d8767bc5f444 100644 --- a/pkg/ui/workspaces/cluster-ui/src/jobs/jobDetailsPage/index.ts +++ b/pkg/ui/workspaces/cluster-ui/src/jobs/jobDetailsPage/index.ts @@ -9,3 +9,4 @@ // licenses/APL.txt. export * from "./jobDetails"; +export * from "./jobDetailsConnected"; diff --git a/pkg/ui/workspaces/cluster-ui/src/jobs/jobDetailsPage/jobDetailsConnected.tsx b/pkg/ui/workspaces/cluster-ui/src/jobs/jobDetailsPage/jobDetailsConnected.tsx new file mode 100644 index 000000000000..05d540cc0fd2 --- /dev/null +++ b/pkg/ui/workspaces/cluster-ui/src/jobs/jobDetailsPage/jobDetailsConnected.tsx @@ -0,0 +1,45 @@ +// Copyright 2022 The Cockroach Authors. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0, included in the file +// licenses/APL.txt. + +import { connect } from "react-redux"; +import { RouteComponentProps, withRouter } from "react-router-dom"; + +import { AppState } from "src/store"; +import { selectJobState } from "../../store/jobDetails/job.selectors"; +import { + JobDetailsStateProps, + JobDetailsDispatchProps, + JobDetails, +} from "./jobDetails"; +import { JobRequest } from "src/api/jobsApi"; +import { actions as jobActions } from "src/store/jobDetails"; + +const mapStateToProps = (state: AppState): JobDetailsStateProps => { + const jobState = selectJobState(state); + const job = jobState ? jobState.data : null; + const jobLoading = jobState ? jobState.inFlight : false; + const jobError = jobState ? jobState.lastError : null; + return { + job, + jobLoading, + jobError, + }; +}; + +const mapDispatchToProps = { + refreshJob: (req: JobRequest) => jobActions.refresh(req), +}; + +export const JobDetailsPageConnected = withRouter( + connect( + mapStateToProps, + mapDispatchToProps, + )(JobDetails), +); diff --git a/pkg/ui/workspaces/cluster-ui/src/jobs/jobsPage/index.ts b/pkg/ui/workspaces/cluster-ui/src/jobs/jobsPage/index.ts index 7edad687cfa6..0df13121d629 100644 --- a/pkg/ui/workspaces/cluster-ui/src/jobs/jobsPage/index.ts +++ b/pkg/ui/workspaces/cluster-ui/src/jobs/jobsPage/index.ts @@ -11,3 +11,4 @@ export * from "./jobDescriptionCell"; export * from "./jobsPage"; export * from "./jobTable"; +export * from "./jobsPageConnected"; diff --git a/pkg/ui/workspaces/cluster-ui/src/jobs/jobsPage/jobTable.tsx b/pkg/ui/workspaces/cluster-ui/src/jobs/jobsPage/jobTable.tsx index aa9fdd8be0ac..eac32a822c44 100644 --- a/pkg/ui/workspaces/cluster-ui/src/jobs/jobsPage/jobTable.tsx +++ b/pkg/ui/workspaces/cluster-ui/src/jobs/jobsPage/jobTable.tsx @@ -10,7 +10,7 @@ import { cockroach, google } from "@cockroachlabs/crdb-protobuf-client"; import { Tooltip } from "@cockroachlabs/ui-components"; import { isEqual, map } from "lodash"; -import React, { MouseEvent } from "react"; +import React from "react"; import { Anchor } from "src/anchor"; import { JobsResponse } from "src/api/jobsApi"; import emptyTableResultsIcon from "src/assets/emptyState/empty-table-results.svg"; @@ -28,9 +28,8 @@ import { } from "src/util/docs"; import { DATE_FORMAT_24_UTC } from "src/util/format"; -import { HighwaterTimestamp } from "../util/highwaterTimestamp"; +import { HighwaterTimestamp, JobStatusCell } from "../util"; import { JobDescriptionCell } from "./jobDescriptionCell"; -import { JobStatusCell } from "../util/jobStatusCell"; import styles from "../jobs.module.scss"; import classNames from "classnames/bind"; @@ -125,7 +124,7 @@ const jobsTableColumns: ColumnDescriptor[] = [ style="tableTitle" content={

User that created the job.

} > - {"User"} + {"User Name"} ), cell: job => job.username, @@ -179,7 +178,13 @@ const jobsTableColumns: ColumnDescriptor[] = [ Date and time the job was last executed.

} + content={ +

+ The high-water mark acts as a checkpoint for the changefeed’s job + progress, and guarantees that all changes before (or at) the + timestamp have been emitted. +

+ } > {"High-water Timestamp"}
diff --git a/pkg/ui/workspaces/cluster-ui/src/jobs/jobsPage/jobsPage.spec.tsx b/pkg/ui/workspaces/cluster-ui/src/jobs/jobsPage/jobsPage.spec.tsx index 590af42f6d5c..7a84d354c3f5 100644 --- a/pkg/ui/workspaces/cluster-ui/src/jobs/jobsPage/jobsPage.spec.tsx +++ b/pkg/ui/workspaces/cluster-ui/src/jobs/jobsPage/jobsPage.spec.tsx @@ -8,18 +8,13 @@ // by the Apache License, Version 2.0, included in the file // licenses/APL.txt. -import { assert, expect } from "chai"; +import { assert } from "chai"; import moment from "moment"; import { cockroach } from "@cockroachlabs/crdb-protobuf-client"; import { JobsPage, JobsPageProps } from "./jobsPage"; import { formatDuration } from "../util/duration"; -import { - allJobsFixture, - retryRunningJobFixture, - earliestRetainedTime, -} from "./jobsPage.fixture"; -import { render, screen, waitFor } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; +import { allJobsFixture, earliestRetainedTime } from "./jobsPage.fixture"; +import { render } from "@testing-library/react"; import React from "react"; import { MemoryRouter } from "react-router-dom"; import * as H from "history"; @@ -78,7 +73,7 @@ describe("Jobs", () => { "Description", "Status", "Job ID", - "User", + "User Name", "Creation Time (UTC)", "Last Execution Time (UTC)", "Execution Count", diff --git a/pkg/ui/workspaces/cluster-ui/src/jobs/jobsPage/jobsPage.tsx b/pkg/ui/workspaces/cluster-ui/src/jobs/jobsPage/jobsPage.tsx index d3b4a1faca84..efe05a11db31 100644 --- a/pkg/ui/workspaces/cluster-ui/src/jobs/jobsPage/jobsPage.tsx +++ b/pkg/ui/workspaces/cluster-ui/src/jobs/jobsPage/jobsPage.tsx @@ -22,7 +22,7 @@ import { SortSetting } from "src/sortedtable"; import { syncHistory } from "src/util"; import { JobTable } from "./jobTable"; -import { statusOptions, showOptions, typeOptions } from "../util/jobOptions"; +import { statusOptions, showOptions, typeOptions } from "../util"; import { commonStyles } from "src/common"; import styles from "../jobs.module.scss"; @@ -48,11 +48,12 @@ export interface JobsPageDispatchProps { setShow: (value: string) => void; setType: (value: JobType) => void; refreshJobs: (req: JobsRequest) => void; + onFilterChange?: (req: JobsRequest) => void; } export type JobsPageProps = JobsPageStateProps & JobsPageDispatchProps & - RouteComponentProps; + RouteComponentProps; export class JobsPage extends React.Component { constructor(props: JobsPageProps) { @@ -68,8 +69,8 @@ export class JobsPage extends React.Component { if ( this.props.setSort && columnTitle && - (sortSetting.columnTitle != columnTitle || - sortSetting.ascending != ascending) + (sortSetting.columnTitle !== columnTitle || + sortSetting.ascending !== ascending) ) { this.props.setSort({ columnTitle, ascending }); } @@ -82,25 +83,26 @@ export class JobsPage extends React.Component { // Filter Show. const show = searchParams.get("show") || undefined; - if (this.props.setShow && show && show != this.props.show) { + if (this.props.setShow && show && show !== this.props.show) { this.props.setShow(show); } // Filter Type. const type = parseInt(searchParams.get("type"), 10) || undefined; - if (this.props.setType && type && type != this.props.type) { + if (this.props.setType && type && type !== this.props.type) { this.props.setType(type); } } private refresh(props = this.props): void { - props.refreshJobs( - new cockroach.server.serverpb.JobsRequest({ - status: props.status, - type: props.type, - limit: parseInt(props.show, 10), - }), - ); + const jobsRequest = new cockroach.server.serverpb.JobsRequest({ + status: props.status, + type: props.type, + limit: parseInt(props.show, 10), + }); + props.onFilterChange + ? props.onFilterChange(jobsRequest) + : props.refreshJobs(jobsRequest); } componentDidMount(): void { @@ -113,7 +115,7 @@ export class JobsPage extends React.Component { prevProps.type !== this.props.type || prevProps.show !== this.props.show ) { - this.refresh(this.props); + this.refresh(); } } diff --git a/pkg/ui/workspaces/cluster-ui/src/jobs/jobsPage/jobsPageConnected.tsx b/pkg/ui/workspaces/cluster-ui/src/jobs/jobsPage/jobsPageConnected.tsx new file mode 100644 index 000000000000..b5c7d9014ee9 --- /dev/null +++ b/pkg/ui/workspaces/cluster-ui/src/jobs/jobsPage/jobsPageConnected.tsx @@ -0,0 +1,99 @@ +// Copyright 2022 The Cockroach Authors. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0, included in the file +// licenses/APL.txt. + +import { connect } from "react-redux"; +import { RouteComponentProps, withRouter } from "react-router-dom"; + +import { AppState } from "src/store"; +import { + selectJobsState, + selectShowSetting, + selectSortSetting, + selectTypeSetting, + selectStatusSetting, +} from "../../store/jobs/jobs.selectors"; +import { + JobsPageStateProps, + JobsPageDispatchProps, + JobsPage, +} from "./jobsPage"; +import { JobsRequest } from "src/api/jobsApi"; +import { actions as jobsActions } from "src/store/jobs"; +import { actions as localStorageActions } from "../../store/localStorage"; +import { Dispatch } from "redux"; +import { SortSetting } from "../../sortedtable"; + +const mapStateToProps = ( + state: AppState, + _: RouteComponentProps, +): JobsPageStateProps => { + const sort = selectSortSetting(state); + const status = selectStatusSetting(state); + const show = selectShowSetting(state); + const type = selectTypeSetting(state); + const jobsState = selectJobsState(state); + const jobs = jobsState ? jobsState.data : null; + const jobsLoading = jobsState ? jobsState.inFlight : false; + const jobsError = jobsState ? jobsState.lastError : null; + return { + sort, + status, + show, + type, + jobs, + jobsLoading, + jobsError, + }; +}; + +const mapDispatchToProps = (dispatch: Dispatch): JobsPageDispatchProps => ({ + setShow: (showValue: string) => { + dispatch( + localStorageActions.update({ + key: "showSetting/JobsPage", + value: showValue, + }), + ); + }, + setSort: (ss: SortSetting) => { + dispatch( + localStorageActions.update({ + key: "sortSetting/JobsPage", + value: ss, + }), + ); + }, + setStatus: (statusValue: string) => { + dispatch( + localStorageActions.update({ + key: "statusSetting/JobsPage", + value: statusValue, + }), + ); + }, + setType: (jobValue: number) => { + dispatch( + localStorageActions.update({ + key: "typeSetting/JobsPage", + value: jobValue, + }), + ); + }, + refreshJobs: (req: JobsRequest) => dispatch(jobsActions.refresh(req)), + onFilterChange: (req: JobsRequest) => + dispatch(jobsActions.updateFilteredJobs(req)), +}); + +export const JobsPageConnected = withRouter( + connect( + mapStateToProps, + mapDispatchToProps, + )(JobsPage), +); diff --git a/pkg/ui/workspaces/cluster-ui/src/sessions/sessionsPageConnected.tsx b/pkg/ui/workspaces/cluster-ui/src/sessions/sessionsPageConnected.tsx index 5cdcca2d8245..525338b7afe8 100644 --- a/pkg/ui/workspaces/cluster-ui/src/sessions/sessionsPageConnected.tsx +++ b/pkg/ui/workspaces/cluster-ui/src/sessions/sessionsPageConnected.tsx @@ -26,22 +26,13 @@ import { import { Dispatch } from "redux"; import { Filters } from "../queryFilter"; import { sqlStatsSelector } from "../store/sqlStats/sqlStats.selector"; +import { localStorageSelector } from "../store/utils/selectors"; export const selectSessionsData = createSelector( sqlStatsSelector, sessionsState => (sessionsState.valid ? sessionsState.data : null), ); -export const adminUISelector = createSelector( - (state: AppState) => state.adminUI, - adminUiState => adminUiState, -); - -export const localStorageSelector = createSelector( - adminUISelector, - adminUiState => adminUiState.localStorage, -); - export const selectSessions = createSelector( (state: AppState) => state.adminUI.sessions, (state: SessionsState) => { diff --git a/pkg/ui/workspaces/cluster-ui/src/statementsPage/activeStatementsPage.selectors.ts b/pkg/ui/workspaces/cluster-ui/src/statementsPage/activeStatementsPage.selectors.ts index e9e5664e2e83..196702a2cf7d 100644 --- a/pkg/ui/workspaces/cluster-ui/src/statementsPage/activeStatementsPage.selectors.ts +++ b/pkg/ui/workspaces/cluster-ui/src/statementsPage/activeStatementsPage.selectors.ts @@ -10,7 +10,7 @@ import { createSelector } from "reselect"; import { getActiveStatementsFromSessions } from "../activeExecutions/activeStatementUtils"; -import { localStorageSelector } from "./statementsPage.selectors"; +import { localStorageSelector } from "../store/utils/selectors"; import { ActiveStatementFilters, ActiveStatementsViewDispatchProps, diff --git a/pkg/ui/workspaces/cluster-ui/src/statementsPage/statementsPage.selectors.ts b/pkg/ui/workspaces/cluster-ui/src/statementsPage/statementsPage.selectors.ts index de51b071c4cf..5e171e7776f3 100644 --- a/pkg/ui/workspaces/cluster-ui/src/statementsPage/statementsPage.selectors.ts +++ b/pkg/ui/workspaces/cluster-ui/src/statementsPage/statementsPage.selectors.ts @@ -30,6 +30,7 @@ import { selectDiagnosticsReportsPerStatement } from "../store/statementDiagnost import { AggregateStatistics } from "../statementsTable"; import { sqlStatsSelector } from "../store/sqlStats/sqlStats.selector"; import { SQLStatsState } from "../store/sqlStats"; +import { localStorageSelector } from "../store/utils/selectors"; type ICollectedStatementStatistics = cockroach.server.serverpb.StatementsResponse.ICollectedStatementStatistics; @@ -45,16 +46,6 @@ export interface StatementsSummaryData { stats: StatementStatistics[]; } -export const adminUISelector = createSelector( - (state: AppState) => state.adminUI, - adminUiState => adminUiState, -); - -export const localStorageSelector = createSelector( - adminUISelector, - adminUiState => adminUiState.localStorage, -); - // selectApps returns the array of all apps with statement statistics present // in the data. export const selectApps = createSelector(sqlStatsSelector, sqlStatsState => { diff --git a/pkg/ui/workspaces/cluster-ui/src/store/jobDetails/index.ts b/pkg/ui/workspaces/cluster-ui/src/store/jobDetails/index.ts new file mode 100644 index 000000000000..6f7c02c849e8 --- /dev/null +++ b/pkg/ui/workspaces/cluster-ui/src/store/jobDetails/index.ts @@ -0,0 +1,13 @@ +// Copyright 2022 The Cockroach Authors. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0, included in the file +// licenses/APL.txt. + +export * from "./job.reducer"; +export * from "./job.sagas"; +export * from "./job.selectors"; diff --git a/pkg/ui/workspaces/cluster-ui/src/store/jobDetails/job.reducer.ts b/pkg/ui/workspaces/cluster-ui/src/store/jobDetails/job.reducer.ts new file mode 100644 index 000000000000..1269021c7896 --- /dev/null +++ b/pkg/ui/workspaces/cluster-ui/src/store/jobDetails/job.reducer.ts @@ -0,0 +1,52 @@ +// Copyright 2022 The Cockroach Authors. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0, included in the file +// licenses/APL.txt. + +import { createSlice, PayloadAction } from "@reduxjs/toolkit"; +import { JobRequest, JobResponse } from "src/api/jobsApi"; +import { DOMAIN_NAME } from "../utils"; + +export type JobState = { + data: JobResponse; + lastError: Error; + valid: boolean; + inFlight: boolean; +}; + +const initialState: JobState = { + data: null, + lastError: null, + valid: true, + inFlight: false, +}; + +const JobSlice = createSlice({ + name: `${DOMAIN_NAME}/job`, + initialState, + reducers: { + received: (state, action: PayloadAction) => { + state.data = action.payload; + state.valid = true; + state.lastError = null; + state.inFlight = false; + }, + failed: (state, action: PayloadAction) => { + state.valid = false; + state.lastError = action.payload; + }, + invalidated: state => { + state.valid = false; + }, + refresh: (_, action: PayloadAction) => {}, + request: (_, action: PayloadAction) => {}, + reset: (_, action: PayloadAction) => {}, + }, +}); + +export const { reducer, actions } = JobSlice; diff --git a/pkg/ui/workspaces/cluster-ui/src/store/jobDetails/job.sagas.spec.ts b/pkg/ui/workspaces/cluster-ui/src/store/jobDetails/job.sagas.spec.ts new file mode 100644 index 000000000000..2f25742347d6 --- /dev/null +++ b/pkg/ui/workspaces/cluster-ui/src/store/jobDetails/job.sagas.spec.ts @@ -0,0 +1,99 @@ +// Copyright 2022 The Cockroach Authors. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0, included in the file +// licenses/APL.txt. + +import { expectSaga } from "redux-saga-test-plan"; +import { + EffectProviders, + StaticProvider, + throwError, +} from "redux-saga-test-plan/providers"; +import * as matchers from "redux-saga-test-plan/matchers"; +import { cockroach } from "@cockroachlabs/crdb-protobuf-client"; + +import { getJob } from "src/api/jobsApi"; +import { refreshJobSaga, requestJobSaga, receivedJobSaga } from "./job.sagas"; +import { actions, reducer, JobState } from "./job.reducer"; +import { succeededJobFixture } from "../../jobs/jobsPage/jobsPage.fixture"; +import Long from "long"; + +describe("job sagas", () => { + const payload = new cockroach.server.serverpb.JobRequest({ + job_id: new Long(8136728577, 70289336), + }); + const jobResponse = new cockroach.server.serverpb.JobResponse( + succeededJobFixture, + ); + + const jobAPIProvider: (EffectProviders | StaticProvider)[] = [ + [matchers.call.fn(getJob), jobResponse], + ]; + + describe("refreshJobSaga", () => { + it("dispatches refresh job action", () => { + return expectSaga(refreshJobSaga, actions.request(payload)) + .provide(jobAPIProvider) + .put(actions.request(payload)) + .run(); + }); + }); + + describe("requestJobSaga", () => { + it("successfully requests job", () => { + return expectSaga(requestJobSaga, actions.request(payload)) + .provide(jobAPIProvider) + .put(actions.received(jobResponse)) + .withReducer(reducer) + .hasFinalState({ + data: jobResponse, + lastError: null, + valid: true, + inFlight: false, + }) + .run(); + }); + + it("returns error on failed request", () => { + const error = new Error("Failed request"); + return expectSaga(requestJobSaga, actions.request(payload)) + .provide([[matchers.call.fn(getJob), throwError(error)]]) + .put(actions.failed(error)) + .withReducer(reducer) + .hasFinalState({ + data: null, + lastError: error, + valid: false, + inFlight: false, + }) + .run(); + }); + }); + + describe("receivedJobSaga", () => { + it("sets valid status to false after specified period of time", () => { + const timeout = 500; + return expectSaga(receivedJobSaga, timeout) + .delay(timeout) + .put(actions.invalidated()) + .withReducer(reducer, { + data: jobResponse, + lastError: null, + valid: true, + inFlight: false, + }) + .hasFinalState({ + data: jobResponse, + lastError: null, + valid: false, + inFlight: false, + }) + .run(1000); + }); + }); +}); diff --git a/pkg/ui/workspaces/cluster-ui/src/store/jobDetails/job.sagas.ts b/pkg/ui/workspaces/cluster-ui/src/store/jobDetails/job.sagas.ts new file mode 100644 index 000000000000..e88890544899 --- /dev/null +++ b/pkg/ui/workspaces/cluster-ui/src/store/jobDetails/job.sagas.ts @@ -0,0 +1,50 @@ +// Copyright 2021 The Cockroach Authors. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0, included in the file +// licenses/APL.txt. + +import { all, call, delay, put, takeLatest } from "redux-saga/effects"; + +import { actions } from "./job.reducer"; +import { getJob, JobRequest } from "src/api/jobsApi"; +import { CACHE_INVALIDATION_PERIOD, throttleWithReset } from "../utils"; +import { rootActions } from "../reducers"; +import { PayloadAction } from "@reduxjs/toolkit"; + +export function* refreshJobSaga(action: PayloadAction) { + yield put(actions.request(action.payload)); +} + +export function* requestJobSaga(action: PayloadAction): any { + try { + const result = yield call(getJob, action.payload); + yield put(actions.received(result)); + } catch (e) { + yield put(actions.failed(e)); + } +} + +export function* receivedJobSaga(delayMs: number) { + yield delay(delayMs); + yield put(actions.invalidated()); +} + +export function* jobSaga( + cacheInvalidationPeriod: number = CACHE_INVALIDATION_PERIOD, +) { + yield all([ + throttleWithReset( + cacheInvalidationPeriod, + actions.refresh, + [actions.invalidated, rootActions.resetState], + refreshJobSaga, + ), + takeLatest(actions.request, requestJobSaga), + takeLatest(actions.received, receivedJobSaga, cacheInvalidationPeriod), + ]); +} diff --git a/pkg/ui/workspaces/cluster-ui/src/store/jobDetails/job.selectors.ts b/pkg/ui/workspaces/cluster-ui/src/store/jobDetails/job.selectors.ts new file mode 100644 index 000000000000..f4f92b61ae50 --- /dev/null +++ b/pkg/ui/workspaces/cluster-ui/src/store/jobDetails/job.selectors.ts @@ -0,0 +1,17 @@ +// Copyright 2022 The Cockroach Authors. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0, included in the file +// licenses/APL.txt. + +import { createSelector } from "reselect"; +import { adminUISelector } from "../utils/selectors"; + +export const selectJobState = createSelector( + adminUISelector, + adminUiState => adminUiState.job, +); diff --git a/pkg/ui/workspaces/cluster-ui/src/store/jobs/index.ts b/pkg/ui/workspaces/cluster-ui/src/store/jobs/index.ts new file mode 100644 index 000000000000..242875e9f2d4 --- /dev/null +++ b/pkg/ui/workspaces/cluster-ui/src/store/jobs/index.ts @@ -0,0 +1,13 @@ +// Copyright 2022 The Cockroach Authors. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0, included in the file +// licenses/APL.txt. + +export * from "./jobs.reducer"; +export * from "./jobs.sagas"; +export * from "./jobs.selectors"; diff --git a/pkg/ui/workspaces/cluster-ui/src/store/jobs/jobs.reducer.ts b/pkg/ui/workspaces/cluster-ui/src/store/jobs/jobs.reducer.ts new file mode 100644 index 000000000000..7cb625646229 --- /dev/null +++ b/pkg/ui/workspaces/cluster-ui/src/store/jobs/jobs.reducer.ts @@ -0,0 +1,53 @@ +// Copyright 2022 The Cockroach Authors. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0, included in the file +// licenses/APL.txt. + +import { createSlice, PayloadAction } from "@reduxjs/toolkit"; +import { JobsRequest, JobsResponse } from "src/api/jobsApi"; +import { DOMAIN_NAME } from "../utils"; + +export type JobsState = { + data: JobsResponse; + lastError: Error; + valid: boolean; + inFlight: boolean; +}; + +const initialState: JobsState = { + data: null, + lastError: null, + valid: true, + inFlight: false, +}; + +const JobsSlice = createSlice({ + name: `${DOMAIN_NAME}/jobs`, + initialState, + reducers: { + received: (state, action: PayloadAction) => { + state.data = action.payload; + state.valid = true; + state.lastError = null; + state.inFlight = false; + }, + failed: (state, action: PayloadAction) => { + state.valid = false; + state.lastError = action.payload; + }, + invalidated: state => { + state.valid = false; + }, + refresh: (_, action: PayloadAction) => {}, + request: (_, action: PayloadAction) => {}, + reset: (_, action: PayloadAction) => {}, + updateFilteredJobs: (_, action: PayloadAction) => {}, + }, +}); + +export const { reducer, actions } = JobsSlice; diff --git a/pkg/ui/workspaces/cluster-ui/src/store/jobs/jobs.sagas.spec.ts b/pkg/ui/workspaces/cluster-ui/src/store/jobs/jobs.sagas.spec.ts new file mode 100644 index 000000000000..833e9c977792 --- /dev/null +++ b/pkg/ui/workspaces/cluster-ui/src/store/jobs/jobs.sagas.spec.ts @@ -0,0 +1,108 @@ +// Copyright 2022 The Cockroach Authors. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0, included in the file +// licenses/APL.txt. + +import { expectSaga } from "redux-saga-test-plan"; +import { + EffectProviders, + StaticProvider, + throwError, +} from "redux-saga-test-plan/providers"; +import * as matchers from "redux-saga-test-plan/matchers"; +import { cockroach } from "@cockroachlabs/crdb-protobuf-client"; + +import { getJobs } from "src/api/jobsApi"; +import { + refreshJobsSaga, + requestJobsSaga, + receivedJobsSaga, +} from "./jobs.sagas"; +import { actions, reducer, JobsState } from "./jobs.reducer"; +import { + allJobsFixture, + earliestRetainedTime, +} from "../../jobs/jobsPage/jobsPage.fixture"; + +describe("jobs sagas", () => { + const payload = new cockroach.server.serverpb.JobsRequest({ + limit: 0, + type: 0, + status: "", + }); + const jobsResponse = new cockroach.server.serverpb.JobsResponse({ + jobs: allJobsFixture, + earliest_retained_time: earliestRetainedTime, + }); + + const jobsAPIProvider: (EffectProviders | StaticProvider)[] = [ + [matchers.call.fn(getJobs), jobsResponse], + ]; + + describe("refreshJobsSaga", () => { + it("dispatches refresh jobs action", () => { + return expectSaga(refreshJobsSaga, actions.request(payload)) + .provide(jobsAPIProvider) + .put(actions.request(payload)) + .run(); + }); + }); + + describe("requestJobsSaga", () => { + it("successfully requests jobs", () => { + return expectSaga(requestJobsSaga, actions.request(payload)) + .provide(jobsAPIProvider) + .put(actions.received(jobsResponse)) + .withReducer(reducer) + .hasFinalState({ + data: jobsResponse, + lastError: null, + valid: true, + inFlight: false, + }) + .run(); + }); + + it("returns error on failed request", () => { + const error = new Error("Failed request"); + return expectSaga(requestJobsSaga, actions.request(payload)) + .provide([[matchers.call.fn(getJobs), throwError(error)]]) + .put(actions.failed(error)) + .withReducer(reducer) + .hasFinalState({ + data: null, + lastError: error, + valid: false, + inFlight: false, + }) + .run(); + }); + }); + + describe("receivedJobsSaga", () => { + it("sets valid status to false after specified period of time", () => { + const timeout = 500; + return expectSaga(receivedJobsSaga, timeout) + .delay(timeout) + .put(actions.invalidated()) + .withReducer(reducer, { + data: jobsResponse, + lastError: null, + valid: true, + inFlight: false, + }) + .hasFinalState({ + data: jobsResponse, + lastError: null, + valid: false, + inFlight: false, + }) + .run(1000); + }); + }); +}); diff --git a/pkg/ui/workspaces/cluster-ui/src/store/jobs/jobs.sagas.ts b/pkg/ui/workspaces/cluster-ui/src/store/jobs/jobs.sagas.ts new file mode 100644 index 000000000000..e54d6997805a --- /dev/null +++ b/pkg/ui/workspaces/cluster-ui/src/store/jobs/jobs.sagas.ts @@ -0,0 +1,58 @@ +// Copyright 2021 The Cockroach Authors. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0, included in the file +// licenses/APL.txt. + +import { all, call, delay, put, takeLatest } from "redux-saga/effects"; + +import { cockroach } from "@cockroachlabs/crdb-protobuf-client"; +import { actions } from "./jobs.reducer"; +import { getJobs, JobsRequest } from "src/api/jobsApi"; +import { CACHE_INVALIDATION_PERIOD, throttleWithReset } from "../utils"; +import { PayloadAction } from "@reduxjs/toolkit"; +import { rootActions } from "../reducers"; + +export function* refreshJobsSaga(action: PayloadAction) { + yield put(actions.request(action.payload)); +} + +export function* requestJobsSaga(action: PayloadAction): any { + try { + const result = yield call(getJobs, action.payload); + yield put(actions.received(result)); + } catch (e) { + yield put(actions.failed(e)); + } +} + +export function* receivedJobsSaga(delayMs: number) { + yield delay(delayMs); + yield put(actions.invalidated()); +} + +export function* updateFilteredJobsSaga(action: PayloadAction) { + yield put(actions.invalidated()); + const req = new cockroach.server.serverpb.JobsRequest(action.payload); + yield put(actions.refresh(req)); +} + +export function* jobsSaga( + cacheInvalidationPeriod: number = CACHE_INVALIDATION_PERIOD, +) { + yield all([ + throttleWithReset( + cacheInvalidationPeriod, + actions.refresh, + [actions.invalidated, rootActions.resetState], + refreshJobsSaga, + ), + takeLatest(actions.request, requestJobsSaga), + takeLatest(actions.received, receivedJobsSaga, cacheInvalidationPeriod), + takeLatest(actions.updateFilteredJobs, updateFilteredJobsSaga), + ]); +} diff --git a/pkg/ui/workspaces/cluster-ui/src/store/jobs/jobs.selectors.ts b/pkg/ui/workspaces/cluster-ui/src/store/jobs/jobs.selectors.ts new file mode 100644 index 000000000000..162b80742982 --- /dev/null +++ b/pkg/ui/workspaces/cluster-ui/src/store/jobs/jobs.selectors.ts @@ -0,0 +1,38 @@ +// Copyright 2022 The Cockroach Authors. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0, included in the file +// licenses/APL.txt. + +import { createSelector } from "reselect"; +import { localStorageSelector } from "../utils/selectors"; +import { adminUISelector } from "../utils/selectors"; + +export const selectJobsState = createSelector( + adminUISelector, + adminUiState => adminUiState.jobs, +); + +export const selectSortSetting = createSelector( + localStorageSelector, + localStorage => localStorage["sortSetting/JobsPage"], +); + +export const selectShowSetting = createSelector( + localStorageSelector, + localStorage => localStorage["showSetting/JobsPage"], +); + +export const selectTypeSetting = createSelector( + localStorageSelector, + localStorage => localStorage["typeSetting/JobsPage"], +); + +export const selectStatusSetting = createSelector( + localStorageSelector, + localStorage => localStorage["statusSetting/JobsPage"], +); diff --git a/pkg/ui/workspaces/cluster-ui/src/store/localStorage/localStorage.reducer.ts b/pkg/ui/workspaces/cluster-ui/src/store/localStorage/localStorage.reducer.ts index 0d7159dab5a1..f25232c9f1ae 100644 --- a/pkg/ui/workspaces/cluster-ui/src/store/localStorage/localStorage.reducer.ts +++ b/pkg/ui/workspaces/cluster-ui/src/store/localStorage/localStorage.reducer.ts @@ -8,7 +8,6 @@ // by the Apache License, Version 2.0, included in the file // licenses/APL.txt. -import moment from "moment"; import { createSlice, PayloadAction } from "@reduxjs/toolkit"; import { DOMAIN_NAME } from "../utils"; import { defaultFilters, Filters } from "../../queryFilter"; @@ -32,6 +31,7 @@ export type LocalStorageState = { "sortSetting/StatementsPage": SortSetting; "sortSetting/TransactionsPage": SortSetting; "sortSetting/SessionsPage": SortSetting; + "sortSetting/JobsPage": SortSetting; "filters/ActiveStatementsPage": Filters; "filters/ActiveTransactionsPage": Filters; "filters/StatementsPage": Filters; @@ -39,6 +39,9 @@ export type LocalStorageState = { "filters/SessionsPage": Filters; "search/StatementsPage": string; "search/TransactionsPage": string; + "typeSetting/JobsPage": number; + "statusSetting/JobsPage": string; + "showSetting/JobsPage": string; }; type Payload = { @@ -65,6 +68,17 @@ const defaultSessionsSortSetting: SortSetting = { columnTitle: "statementAge", }; +const defaultJobsSortSetting: SortSetting = { + ascending: false, + columnTitle: "lastExecutionTime", +}; + +const defaultJobStatusSetting = ""; + +const defaultJobShowSetting = "0"; + +const defaultJobTypeSetting = 0; + // TODO (koorosh): initial state should be restored from preserved keys in LocalStorage const initialState: LocalStorageState = { "adminUi/showDiagnosticsModal": @@ -82,6 +96,9 @@ const initialState: LocalStorageState = { JSON.parse(localStorage.getItem("showColumns/TransactionPage")) || null, "showColumns/SessionsPage": JSON.parse(localStorage.getItem("showColumns/SessionsPage")) || null, + "showSetting/JobsPage": + JSON.parse(localStorage.getItem("showSetting/JobsPage")) || + defaultJobShowSetting, "timeScale/SQLActivity": JSON.parse(localStorage.getItem("timeScale/SQLActivity")) || defaultTimeScaleSelected, @@ -91,6 +108,9 @@ const initialState: LocalStorageState = { "sortSetting/ActiveTransactionsPage": JSON.parse(localStorage.getItem("sortSetting/ActiveTransactionsPage")) || defaultSortSettingActiveExecutions, + "sortSetting/JobsPage": + JSON.parse(localStorage.getItem("sortSetting/JobsPage")) || + defaultJobsSortSetting, "sortSetting/StatementsPage": JSON.parse(localStorage.getItem("sortSetting/StatementsPage")) || defaultSortSetting, @@ -118,6 +138,12 @@ const initialState: LocalStorageState = { JSON.parse(localStorage.getItem("search/StatementsPage")) || null, "search/TransactionsPage": JSON.parse(localStorage.getItem("search/TransactionsPage")) || null, + "typeSetting/JobsPage": + JSON.parse(localStorage.getItem("typeSetting/JobsPage")) || + defaultJobTypeSetting, + "statusSetting/JobsPage": + JSON.parse(localStorage.getItem("statusSetting/JobsPage")) || + defaultJobStatusSetting, }; const localStorageSlice = createSlice({ diff --git a/pkg/ui/workspaces/cluster-ui/src/store/reducers.ts b/pkg/ui/workspaces/cluster-ui/src/store/reducers.ts index 82b97e648a83..a6abfdd5f8f3 100644 --- a/pkg/ui/workspaces/cluster-ui/src/store/reducers.ts +++ b/pkg/ui/workspaces/cluster-ui/src/store/reducers.ts @@ -33,6 +33,8 @@ import { IndexStatsReducerState, reducer as indexStats, } from "./indexStats/indexStats.reducer"; +import { JobsState, reducer as jobs } from "./jobs"; +import { JobState, reducer as job } from "./jobDetails"; export type AdminUiState = { statementDiagnostics: StatementDiagnosticsState; @@ -45,6 +47,8 @@ export type AdminUiState = { sqlStats: SQLStatsState; sqlDetailsStats: SQLDetailsStatsReducerState; indexStats: IndexStatsReducerState; + jobs: JobsState; + job: JobState; }; export type AppState = { @@ -62,6 +66,8 @@ export const reducers = combineReducers({ sqlStats, sqlDetailsStats, indexStats, + jobs, + job, }); export const rootActions = { diff --git a/pkg/ui/workspaces/cluster-ui/src/store/sagas.ts b/pkg/ui/workspaces/cluster-ui/src/store/sagas.ts index 7c786cd3c52c..d1cc40500b46 100644 --- a/pkg/ui/workspaces/cluster-ui/src/store/sagas.ts +++ b/pkg/ui/workspaces/cluster-ui/src/store/sagas.ts @@ -14,6 +14,8 @@ import { all, fork } from "redux-saga/effects"; import { localStorageSaga } from "./localStorage"; import { statementsDiagnosticsSagas } from "./statementDiagnostics"; import { nodesSaga } from "./nodes"; +import { jobsSaga } from "./jobs"; +import { jobSaga } from "./jobDetails"; import { livenessSaga } from "./liveness"; import { sessionsSaga } from "./sessions"; import { terminateSaga } from "./terminateQuery"; @@ -28,6 +30,8 @@ export function* sagas(cacheInvalidationPeriod?: number): SagaIterator { fork(statementsDiagnosticsSagas, cacheInvalidationPeriod), fork(nodesSaga, cacheInvalidationPeriod), fork(livenessSaga, cacheInvalidationPeriod), + fork(jobsSaga), + fork(jobSaga), fork(sessionsSaga), fork(terminateSaga), fork(notifificationsSaga), diff --git a/pkg/ui/workspaces/cluster-ui/src/store/sqlStats/sqlStats.selector.ts b/pkg/ui/workspaces/cluster-ui/src/store/sqlStats/sqlStats.selector.ts index 2ddec2be2e54..700a95a17d4b 100644 --- a/pkg/ui/workspaces/cluster-ui/src/store/sqlStats/sqlStats.selector.ts +++ b/pkg/ui/workspaces/cluster-ui/src/store/sqlStats/sqlStats.selector.ts @@ -9,12 +9,7 @@ // licenses/APL.txt. import { createSelector } from "reselect"; -import { AppState } from "../reducers"; - -const adminUISelector = createSelector( - (state: AppState) => state.adminUI, - adminUiState => adminUiState, -); +import { adminUISelector } from "../utils/selectors"; export const sqlStatsSelector = createSelector( adminUISelector, diff --git a/pkg/ui/workspaces/cluster-ui/src/store/utils/selectors.ts b/pkg/ui/workspaces/cluster-ui/src/store/utils/selectors.ts new file mode 100644 index 000000000000..f12c84bcbf53 --- /dev/null +++ b/pkg/ui/workspaces/cluster-ui/src/store/utils/selectors.ts @@ -0,0 +1,22 @@ +// Copyright 2022 The Cockroach Authors. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0, included in the file +// licenses/APL.txt. + +import { createSelector } from "reselect"; +import { AppState } from "../reducers"; + +export const adminUISelector = createSelector( + (state: AppState) => state.adminUI, + adminUiState => adminUiState, +); + +export const localStorageSelector = createSelector( + adminUISelector, + adminUiState => adminUiState.localStorage, +); diff --git a/pkg/ui/workspaces/cluster-ui/src/transactionsPage/activeTransactionsPage.selectors.tsx b/pkg/ui/workspaces/cluster-ui/src/transactionsPage/activeTransactionsPage.selectors.tsx index 43be20014427..5ad711a7ba62 100644 --- a/pkg/ui/workspaces/cluster-ui/src/transactionsPage/activeTransactionsPage.selectors.tsx +++ b/pkg/ui/workspaces/cluster-ui/src/transactionsPage/activeTransactionsPage.selectors.tsx @@ -10,8 +10,8 @@ import { createSelector } from "reselect"; import { getActiveTransactionsFromSessions } from "../activeExecutions/activeStatementUtils"; -import { localStorageSelector } from "src/statementsPage/statementsPage.selectors"; import { selectAppName } from "src/statementsPage/activeStatementsPage.selectors"; +import { localStorageSelector } from "../store/utils/selectors"; import { ActiveTransactionFilters, ActiveTransactionsViewDispatchProps, diff --git a/pkg/ui/workspaces/cluster-ui/src/transactionsPage/transactionsPage.selectors.ts b/pkg/ui/workspaces/cluster-ui/src/transactionsPage/transactionsPage.selectors.ts index bef5b4e521c1..d95f71ecfeee 100644 --- a/pkg/ui/workspaces/cluster-ui/src/transactionsPage/transactionsPage.selectors.ts +++ b/pkg/ui/workspaces/cluster-ui/src/transactionsPage/transactionsPage.selectors.ts @@ -10,7 +10,7 @@ import { createSelector } from "reselect"; -import { localStorageSelector } from "../statementsPage/statementsPage.selectors"; +import { localStorageSelector } from "../store/utils/selectors"; import { sqlStatsSelector } from "../store/sqlStats/sqlStats.selector"; export const selectTransactionsData = createSelector(