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(