diff --git a/pkg/ui/workspaces/cluster-ui/src/api/index.ts b/pkg/ui/workspaces/cluster-ui/src/api/index.ts
index 7f7bf1678b40..346d6d395a59 100644
--- a/pkg/ui/workspaces/cluster-ui/src/api/index.ts
+++ b/pkg/ui/workspaces/cluster-ui/src/api/index.ts
@@ -17,3 +17,4 @@ export * from "./clusterLocksApi";
export * from "./insightsApi";
export * from "./indexActionsApi";
export * from "./schemaInsightsApi";
+export * from "./schedulesApi";
diff --git a/pkg/ui/workspaces/cluster-ui/src/api/schedulesApi.ts b/pkg/ui/workspaces/cluster-ui/src/api/schedulesApi.ts
new file mode 100644
index 000000000000..c44afde82f45
--- /dev/null
+++ b/pkg/ui/workspaces/cluster-ui/src/api/schedulesApi.ts
@@ -0,0 +1,153 @@
+// 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 Long from "long";
+import moment from "moment";
+import { executeSql, SqlExecutionRequest } from "./sqlApi";
+import { RequestError } from "../util";
+
+type ScheduleColumns = {
+ id: string;
+ label: string;
+ schedule_status: string;
+ next_run: string;
+ state: string;
+ recurrence: string;
+ jobsrunning: number;
+ owner: string;
+ created: string;
+ command: string;
+};
+
+export type Schedule = {
+ id: Long;
+ label: string;
+ status: string;
+ nextRun?: moment.Moment;
+ state: string;
+ recurrence: string;
+ jobsRunning: number;
+ owner: string;
+ created: moment.Moment;
+ command: string;
+};
+
+export type Schedules = Schedule[];
+
+export function getSchedules(req: {
+ status: string;
+ limit: number;
+}): Promise {
+ // Cast int64 to string, since otherwise it gets truncated.
+ // Likewise, prettify `command` on the server since contained int64s
+ // may also be truncated.
+ let stmt = `
+ WITH schedules AS (SHOW SCHEDULES)
+ SELECT id::string, label, schedule_status, next_run,
+ state, recurrence, jobsrunning, owner,
+ created, jsonb_pretty(command) as command
+ FROM schedules
+ `;
+ const args = [];
+ if (req.status) {
+ stmt += " WHERE schedule_status = $" + (args.length + 1);
+ args.push(req.status);
+ }
+ stmt += " ORDER BY created DESC";
+ if (req.limit) {
+ stmt += " LIMIT $" + (args.length + 1);
+ args.push(req.limit.toString());
+ }
+ const request: SqlExecutionRequest = {
+ statements: [
+ {
+ sql: stmt,
+ arguments: args,
+ },
+ ],
+ execute: true,
+ };
+ return executeSql(request).then(result => {
+ const txn_results = result.execution.txn_results;
+ if (txn_results.length === 0 || !txn_results[0].rows) {
+ // No data.
+ return [];
+ }
+
+ return txn_results[0].rows.map(row => {
+ return {
+ id: Long.fromString(row.id),
+ label: row.label,
+ status: row.schedule_status,
+ nextRun: row.next_run ? moment.utc(row.next_run) : null,
+ state: row.state,
+ recurrence: row.recurrence,
+ jobsRunning: row.jobsrunning,
+ owner: row.owner,
+ created: moment.utc(row.created),
+ command: JSON.parse(row.command),
+ };
+ });
+ });
+}
+
+export function getSchedule(id: Long): Promise {
+ const request: SqlExecutionRequest = {
+ statements: [
+ {
+ // Cast int64 to string, since otherwise it gets truncated.
+ // Likewise, prettify `command` on the server since contained int64s
+ // may also be truncated.
+ sql: `
+ WITH schedules AS (SHOW SCHEDULES)
+ SELECT id::string, label, schedule_status, next_run,
+ state, recurrence, jobsrunning, owner,
+ created, jsonb_pretty(command) as command
+ FROM schedules
+ WHERE ID = $1::int64
+ `,
+ arguments: [id.toString()],
+ },
+ ],
+ execute: true,
+ };
+ return executeSql(request).then(result => {
+ const txn_results = result.execution.txn_results;
+ if (txn_results.length === 0 || !txn_results[0].rows) {
+ // No data.
+ throw new RequestError(
+ "Bad Request",
+ 400,
+ "No schedule found with this ID.",
+ );
+ }
+
+ if (txn_results[0].rows.length > 1) {
+ throw new RequestError(
+ "Internal Server Error",
+ 500,
+ "Multiple schedules found for ID.",
+ );
+ }
+ const row = txn_results[0].rows[0];
+ return {
+ id: Long.fromString(row.id),
+ label: row.label,
+ status: row.schedule_status,
+ nextRun: row.next_run ? moment.utc(row.next_run) : null,
+ state: row.state,
+ recurrence: row.recurrence,
+ jobsRunning: row.jobsrunning,
+ owner: row.owner,
+ created: moment.utc(row.created),
+ command: row.command,
+ };
+ });
+}
diff --git a/pkg/ui/workspaces/cluster-ui/src/index.ts b/pkg/ui/workspaces/cluster-ui/src/index.ts
index 551bc580787f..971f59f77c68 100644
--- a/pkg/ui/workspaces/cluster-ui/src/index.ts
+++ b/pkg/ui/workspaces/cluster-ui/src/index.ts
@@ -34,6 +34,7 @@ export * from "./pageConfig";
export * from "./pagination";
export * from "./queryFilter";
export * from "./search";
+export * from "./schedules";
export * from "./sortedtable";
export * from "./statementsDiagnostics";
export * from "./statementsPage";
diff --git a/pkg/ui/workspaces/cluster-ui/src/schedules/index.ts b/pkg/ui/workspaces/cluster-ui/src/schedules/index.ts
new file mode 100644
index 000000000000..126b6e052d5a
--- /dev/null
+++ b/pkg/ui/workspaces/cluster-ui/src/schedules/index.ts
@@ -0,0 +1,12 @@
+// 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 "./schedulesPage";
+export * from "./scheduleDetailsPage";
diff --git a/pkg/ui/workspaces/cluster-ui/src/schedules/scheduleDetailsPage/index.ts b/pkg/ui/workspaces/cluster-ui/src/schedules/scheduleDetailsPage/index.ts
new file mode 100644
index 000000000000..e9496b2b8384
--- /dev/null
+++ b/pkg/ui/workspaces/cluster-ui/src/schedules/scheduleDetailsPage/index.ts
@@ -0,0 +1,11 @@
+// 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 "./scheduleDetails";
diff --git a/pkg/ui/workspaces/cluster-ui/src/schedules/scheduleDetailsPage/scheduleDetails.tsx b/pkg/ui/workspaces/cluster-ui/src/schedules/scheduleDetailsPage/scheduleDetails.tsx
new file mode 100644
index 000000000000..bbf8bc621c19
--- /dev/null
+++ b/pkg/ui/workspaces/cluster-ui/src/schedules/scheduleDetailsPage/scheduleDetails.tsx
@@ -0,0 +1,126 @@
+// 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 { ArrowLeft } from "@cockroachlabs/icons";
+import { Col, Row } from "antd";
+import "antd/lib/col/style";
+import "antd/lib/row/style";
+import Long from "long";
+import React, { useEffect } from "react";
+import Helmet from "react-helmet";
+import { RouteComponentProps } from "react-router-dom";
+import { Schedule } from "src/api/schedulesApi";
+import { Button } from "src/button";
+import { Loading } from "src/loading";
+import { SqlBox, SqlBoxSize } from "src/sql";
+import { SummaryCard, SummaryCardItem } from "src/summaryCard";
+import { DATE_FORMAT_24_UTC } from "src/util/format";
+import { getMatchParamByName } from "src/util/query";
+
+import { commonStyles } from "src/common";
+import summaryCardStyles from "src/summaryCard/summaryCard.module.scss";
+import scheduleStyles from "src/schedules/schedules.module.scss";
+
+import classNames from "classnames/bind";
+
+const cardCx = classNames.bind(summaryCardStyles);
+const scheduleCx = classNames.bind(scheduleStyles);
+
+export interface ScheduleDetailsStateProps {
+ schedule: Schedule;
+ scheduleError: Error | null;
+ scheduleLoading: boolean;
+}
+
+export interface ScheduleDetailsDispatchProps {
+ refreshSchedule: (id: Long) => void;
+}
+
+export type ScheduleDetailsProps = ScheduleDetailsStateProps &
+ ScheduleDetailsDispatchProps &
+ RouteComponentProps;
+
+export const ScheduleDetails: React.FC = props => {
+ const idStr = getMatchParamByName(props.match, "id");
+ const { refreshSchedule } = props;
+ useEffect(() => {
+ refreshSchedule(Long.fromString(idStr));
+ }, [idStr, refreshSchedule]);
+
+ const prevPage = (): void => props.history.goBack();
+
+ const renderContent = (): React.ReactElement => {
+ const schedule = props.schedule;
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+ };
+
+ return (
+
+
+
+ }
+ iconPosition="left"
+ className={commonStyles("small-margin")}
+ >
+ Schedules
+
+
{`Schedule ID: ${idStr}`}
+
+
+
+ );
+};
diff --git a/pkg/ui/workspaces/cluster-ui/src/schedules/schedules.module.scss b/pkg/ui/workspaces/cluster-ui/src/schedules/schedules.module.scss
new file mode 100644
index 000000000000..ee35afc5ae5d
--- /dev/null
+++ b/pkg/ui/workspaces/cluster-ui/src/schedules/schedules.module.scss
@@ -0,0 +1,265 @@
+// 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 "src/core/index.module.scss";
+
+.schedules-page {
+ display: flex;
+ flex-flow: column;
+ height: 100%;
+}
+
+.no-results {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 100%;
+}
+
+.schedules-table-summary {
+ &__retention-divider {
+ padding-right: 7px;
+ padding-left: 7px;
+ }
+}
+
+.schedules-table {
+ h3 {
+ color: $headings-color;
+ }
+
+ &__row {
+ &--paused .rc-progress-line-path {
+ stroke: $tooltip-color;
+ }
+
+ &--failed {
+ & + & {
+ // Match two adjacent failed rows.
+ border-top: 1px solid $table-border-color;
+ }
+ }
+ }
+
+ &__progress-bar {
+ padding-right: 0.7em;
+ width: 120px;
+ }
+
+ &__running-status,
+ &__duration {
+ font-family: $font-family--base;
+ font-size: $font-size--small;
+ line-height: 1.67;
+ letter-spacing: 0.3px;
+ color: $colors--neutral-8;
+ }
+
+ &__status,
+ &__running-status {
+ text-transform: capitalize;
+ }
+ &__progress {
+ display: flex;
+ align-items: center;
+ justify-content: flex-start;
+ }
+ &__two-statuses {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 5px;
+ }
+ &__status {
+ margin-bottom: 8px;
+ font-family: $font-family--semi-bold;
+ font-size: $font-size--small;
+ font-weight: 500;
+ line-height: 1.17;
+ letter-spacing: 1.5px;
+ color: $colors--neutral-8;
+ text-transform: uppercase;
+ padding: 5px 8px;
+ border-radius: 3px;
+ text-align: center;
+ &--percentage {
+ font-family: $font-family--base;
+ font-size: $font-size--small;
+ line-height: 1.67;
+ letter-spacing: 0.3px;
+ width: 40px;
+ }
+ .schedule-detail {
+ white-space: pre-wrap;
+ padding: 5px;
+ margin-top: 5px;
+ margin-bottom: 10px;
+ border: 1px solid $button-border-color;
+ border-radius: 4px;
+ background-color: white;
+ color: $headings-color;
+ }
+ }
+
+ &__cell--description {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ max-width: 500px;
+ word-wrap: break-word;
+ }
+
+ &__status-icon {
+ display: inline-block;
+ margin-right: 7px;
+ }
+}
+
+.schedule-details {
+ .summary--card__counting {
+ margin-bottom: 15px;
+ &--value {
+ margin-bottom: 5px;
+ }
+ }
+
+ .summary--card--title {
+ font-family: $font-family--base;
+ font-size: $font-size--tall;
+ font-weight: $font-weight--bold;
+ line-height: 1.5;
+ color: $colors--neutral-8;
+ margin-bottom: 15px;
+ }
+ .secondary {
+ margin-top: 15px;
+ }
+ .page--header__title {
+ margin-bottom: 30px;
+ }
+ .schedule-status__line,
+ .schedules-table__status {
+ display: flex;
+ align-items: baseline;
+ line-height: 1.6;
+ letter-spacing: -0.2px;
+ margin-right: 8px;
+ }
+ .schedule-status__line {
+ font-size: $font-size--medium;
+ display: flex;
+ align-items: center;
+ .ant-divider {
+ height: 20px;
+ }
+ }
+ .schedules-table__status {
+ width: fit-content;
+ font-size: $font-size--small;
+ margin-bottom: 0;
+ }
+ .schedule-status__line {
+ color: $colors--neutral-7;
+ span {
+ &:last-child {
+ margin-left: 8px;
+ }
+ }
+ }
+ .schedule-status__line--percentage {
+ display: flex;
+ align-items: center;
+ .ant-divider {
+ height: 20px;
+ }
+ span {
+ font-family: $font-family--base;
+ font-size: $font-size--medium;
+ line-height: 1.57;
+ letter-spacing: 0.1px;
+ color: $colors--neutral-7;
+ margin: 0;
+ }
+ }
+ .ant-divider {
+ margin-left: 15px;
+ margin-right: 15px;
+ }
+ .schedules-table__progress-bar {
+ width: 100%;
+ }
+ .schedules-table__duration {
+ font-size: $font-size--medium;
+ line-height: 22px;
+ color: $colors--neutral-7;
+ }
+}
+
+.inline-message {
+ margin-top: $spacing-smaller;
+ width: 100%;
+}
+
+.page--header {
+ padding: 0;
+
+ &__title {
+ font-family: $font-family--base;
+ font-size: $font-size--large;
+ line-height: 1.6;
+ letter-spacing: -0.2px;
+ color: $colors--neutral-8;
+ margin-bottom: 25px;
+ }
+}
+
+.section {
+ flex: 0 0 auto;
+ padding: 12px 24px 12px 0px;
+
+ &--heading {
+ padding-top: 0;
+ padding-bottom: 0;
+ }
+
+ &--container {
+ padding: 0 24px 0 0;
+ }
+}
+
+.cl-table__col-query-text {
+ font-family: $font-family--monospace;
+ font-size: $font-size--medium;
+ div {
+ font-size: $font-size--small;
+ @include line-clamp(2);
+ }
+}
+
+.cl-table-statistic {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 7px;
+ height: 32px;
+}
+
+.cl-count-title {
+ font-family: $font-family--base;
+ font-size: $font-size--medium;
+ padding: 0px;
+ margin: 0px;
+ color: $colors--neutral-6;
+ line-height: 1.57;
+ letter-spacing: 0.1px;
+ .label {
+ font-family: $font-family--bold;
+ color: $colors--neutral-7;
+ }
+}
diff --git a/pkg/ui/workspaces/cluster-ui/src/schedules/schedulesPage/index.ts b/pkg/ui/workspaces/cluster-ui/src/schedules/schedulesPage/index.ts
new file mode 100644
index 000000000000..eef3e32b6480
--- /dev/null
+++ b/pkg/ui/workspaces/cluster-ui/src/schedules/schedulesPage/index.ts
@@ -0,0 +1,12 @@
+// 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 "./schedulesPage";
+export * from "./scheduleTable";
diff --git a/pkg/ui/workspaces/cluster-ui/src/schedules/schedulesPage/scheduleOptions.tsx b/pkg/ui/workspaces/cluster-ui/src/schedules/schedulesPage/scheduleOptions.tsx
new file mode 100644
index 000000000000..e41d5ddf6177
--- /dev/null
+++ b/pkg/ui/workspaces/cluster-ui/src/schedules/schedulesPage/scheduleOptions.tsx
@@ -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.
+export const SCHEDULE_STATUS_ACTIVE = "ACTIVE";
+export const SCHEDULE_STATUS_PAUSED = "PAUSED";
+
+export const statusOptions = [
+ { value: "", name: "All" },
+ { value: SCHEDULE_STATUS_ACTIVE, name: "Active" },
+ { value: SCHEDULE_STATUS_PAUSED, name: "Paused" },
+];
+
+export const showOptions = [
+ { value: "50", name: "Latest 50" },
+ { value: "0", name: "All" },
+];
diff --git a/pkg/ui/workspaces/cluster-ui/src/schedules/schedulesPage/scheduleTable.spec.tsx b/pkg/ui/workspaces/cluster-ui/src/schedules/schedulesPage/scheduleTable.spec.tsx
new file mode 100644
index 000000000000..65fda955a6ee
--- /dev/null
+++ b/pkg/ui/workspaces/cluster-ui/src/schedules/schedulesPage/scheduleTable.spec.tsx
@@ -0,0 +1,42 @@
+// 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 React from "react";
+import { shallow } from "enzyme";
+import { ScheduleTable, ScheduleTableProps } from "./scheduleTable";
+import { allSchedulesFixture } from "./schedulesPage.fixture";
+
+describe("", () => {
+ it("should reset page to 1 after schedule list prop changes", () => {
+ const scheduleTableProps: ScheduleTableProps = {
+ sort: { columnTitle: null, ascending: true },
+ setSort: () => {},
+ schedules: allSchedulesFixture,
+ current: 2,
+ pageSize: 2,
+ isUsedFilter: true,
+ };
+ const scheduleTable = shallow(
+ ,
+ );
+ expect(scheduleTable.state().pagination.current).toBe(2);
+ scheduleTable.setProps({
+ ...scheduleTableProps,
+ schedules: [allSchedulesFixture[0]],
+ });
+ expect(scheduleTable.state().pagination.current).toBe(1);
+ });
+});
diff --git a/pkg/ui/workspaces/cluster-ui/src/schedules/schedulesPage/scheduleTable.tsx b/pkg/ui/workspaces/cluster-ui/src/schedules/schedulesPage/scheduleTable.tsx
new file mode 100644
index 000000000000..6d6ff7df3ac6
--- /dev/null
+++ b/pkg/ui/workspaces/cluster-ui/src/schedules/schedulesPage/scheduleTable.tsx
@@ -0,0 +1,291 @@
+// 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 { Tooltip } from "@cockroachlabs/ui-components";
+import { isEqual, map } from "lodash";
+import React from "react";
+import { Link } from "react-router-dom";
+import { Nodes, MagnifyingGlass } from "@cockroachlabs/icons";
+import { Anchor } from "src/anchor";
+import { Schedule, Schedules } from "src/api/schedulesApi";
+import { EmptyTable } from "src/empty";
+import { Pagination, ResultsPerPageLabel } from "src/pagination";
+import { ColumnDescriptor, SortSetting, SortedTable } from "src/sortedtable";
+import { dropSchedules, pauseSchedules, resumeSchedules } from "src/util/docs";
+import { DATE_FORMAT_24_UTC } from "src/util/format";
+
+import styles from "../schedules.module.scss";
+import classNames from "classnames/bind";
+const cx = classNames.bind(styles);
+
+class SchedulesSortedTable extends SortedTable {}
+
+const schedulesTableColumns: ColumnDescriptor[] = [
+ {
+ name: "scheduleId",
+ title: (
+
+ {"Unique schedule ID. This value is used to "}
+
+ pause
+
+ {", "}
+
+ resume
+
+ {", or "}
+
+ cancel
+
+ {" schedules."}
+
+ }
+ >
+ {"Schedule ID"}
+
+ ),
+ titleAlign: "right",
+ cell: schedule => {
+ return (
+
+ {String(schedule.id)}
+
+ );
+ },
+ sort: schedule => schedule.id,
+ },
+ {
+ name: "label",
+ title: (
+ The schedule's label.}
+ >
+ {"Label"}
+
+ ),
+ className: cx("cl-table__col-query-text"),
+ cell: schedule => schedule.label,
+ sort: schedule => schedule.label,
+ },
+ {
+ name: "status",
+ title: (
+ Current schedule status.}
+ >
+ {"Status"}
+
+ ),
+ cell: schedule => schedule.status,
+ sort: schedule => schedule.status,
+ },
+ {
+ name: "nextRun",
+ title: (
+ Date and time the schedule will next execute.}
+ >
+ {"Next Execution Time (UTC)"}
+
+ ),
+ cell: schedule => schedule.nextRun?.format(DATE_FORMAT_24_UTC),
+ sort: schedule => schedule.nextRun?.valueOf(),
+ },
+ {
+ name: "recurrence",
+ title: (
+ How often the schedule executes.}
+ >
+ {"Recurrence"}
+
+ ),
+ cell: schedule => schedule.recurrence,
+ sort: schedule => schedule.recurrence,
+ },
+ {
+ name: "jobsRunning",
+ title: (
+ Number of jobs currently running.}
+ >
+ {"Jobs Running"}
+
+ ),
+ cell: schedule => String(schedule.jobsRunning),
+ sort: schedule => schedule.jobsRunning,
+ },
+ {
+ name: "owner",
+ title: (
+ User that created the schedule.}
+ >
+ {"Owner"}
+
+ ),
+ cell: schedule => schedule.owner,
+ sort: schedule => schedule.owner,
+ },
+ {
+ name: "creationTime",
+ title: (
+ Date and time the schedule was created.}
+ >
+ {"Creation Time (UTC)"}
+
+ ),
+ cell: schedule => schedule.created?.format(DATE_FORMAT_24_UTC),
+ sort: schedule => schedule.created?.valueOf(),
+ },
+];
+
+export interface ScheduleTableProps {
+ sort: SortSetting;
+ setSort: (value: SortSetting) => void;
+ schedules: Schedules;
+ pageSize?: number;
+ current?: number;
+ isUsedFilter: boolean;
+}
+
+export interface ScheduleTableState {
+ pagination: {
+ pageSize: number;
+ current: number;
+ };
+}
+
+export class ScheduleTable extends React.Component<
+ ScheduleTableProps,
+ ScheduleTableState
+> {
+ constructor(props: ScheduleTableProps) {
+ super(props);
+
+ this.state = {
+ pagination: {
+ pageSize: props.pageSize || 20,
+ current: props.current || 1,
+ },
+ };
+ }
+
+ componentDidUpdate(prevProps: Readonly): void {
+ this.setCurrentPageToOneIfSchedulesChanged(prevProps);
+ }
+
+ onChangePage = (current: number) => {
+ const { pagination } = this.state;
+ this.setState({ pagination: { ...pagination, current } });
+ };
+
+ renderEmptyState = () => {
+ const { isUsedFilter, schedules } = this.props;
+ const hasData = schedules?.length > 0;
+
+ if (hasData) {
+ return null;
+ }
+
+ if (isUsedFilter) {
+ return (
+ }
+ />
+ );
+ } else {
+ return (
+ }
+ message="The schedules page provides details about backup/restore schedules, sql operation schedules, and others."
+ />
+ );
+ }
+ };
+
+ render() {
+ const schedules = this.props.schedules;
+ const { pagination } = this.state;
+
+ return (
+
+
+
+
+
+
+ cx("schedules-table__row--" + schedule.status)}
+ columns={schedulesTableColumns}
+ renderNoResult={this.renderEmptyState()}
+ pagination={pagination}
+ />
+
+
+ );
+ }
+
+ private setCurrentPageToOneIfSchedulesChanged(
+ prevProps: Readonly,
+ ) {
+ if (
+ !isEqual(
+ map(prevProps.schedules, j => {
+ return j.id;
+ }),
+ map(this.props.schedules, j => {
+ return j.id;
+ }),
+ )
+ ) {
+ this.setState((prevState: Readonly) => {
+ return {
+ pagination: {
+ ...prevState.pagination,
+ current: 1,
+ },
+ };
+ });
+ }
+ }
+}
diff --git a/pkg/ui/workspaces/cluster-ui/src/schedules/schedulesPage/schedulesPage.fixture.tsx b/pkg/ui/workspaces/cluster-ui/src/schedules/schedulesPage/schedulesPage.fixture.tsx
new file mode 100644
index 000000000000..f9c2b738ee8d
--- /dev/null
+++ b/pkg/ui/workspaces/cluster-ui/src/schedules/schedulesPage/schedulesPage.fixture.tsx
@@ -0,0 +1,107 @@
+// 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 * as protos from "@cockroachlabs/crdb-protobuf-client";
+import { createMemoryHistory } from "history";
+import Long from "long";
+import moment from "moment";
+import { SchedulesPageProps } from "./schedulesPage";
+
+import { Schedule } from "src/api/schedulesApi";
+
+const schedulesTimeoutErrorMessage =
+ "Unable to retrieve the Schedules table. To reduce the amount of data, try filtering the table.";
+
+const defaultScheduleProperties = {
+ owner: "root",
+ created: moment("15 Aug 2022"),
+ nextRun: moment("15 Aug 2122"),
+ jobsRunning: 1,
+ state: "doing some stuff",
+ recurrence: "@weekly",
+ command: "{}",
+};
+
+export const activeScheduleFixture = {
+ ...defaultScheduleProperties,
+ id: new Long(8136728577, 70289336),
+ label: "automatic SQL Stats compaction",
+ owner: "node",
+ status: "ACTIVE",
+};
+
+const pausedScheduleFixture = {
+ ...defaultScheduleProperties,
+ id: new Long(7003330561, 70312826),
+ label:
+ "ALTER TABLE movr.public.user_promo_codes ADD FOREIGN KEY (city, user_id) REFERENCES movr.public.users (city, id)",
+ status: "PAUSED",
+ error: "mock failure message",
+};
+
+export const allSchedulesFixture = [
+ activeScheduleFixture,
+ pausedScheduleFixture,
+];
+
+const history = createMemoryHistory({ initialEntries: ["/statements"] });
+
+const staticScheduleProps: Omit<
+ SchedulesPageProps,
+ "schedules" | "schedulesError" | "schedulesLoading" | "onFilterChange"
+> = {
+ history,
+ location: {
+ pathname: "/schedules",
+ search: "",
+ hash: "",
+ state: null,
+ },
+ match: {
+ path: "/schedules",
+ url: "/schedules",
+ isExact: true,
+ params: "{}",
+ },
+ sort: {
+ columnTitle: "creationTime",
+ ascending: false,
+ },
+ status: "",
+ show: "50",
+ setSort: () => {},
+ setStatus: () => {},
+ setShow: () => {},
+ refreshSchedules: () => null,
+};
+
+const getSchedulesPageProps = (
+ schedules: Array,
+ error: Error | null = null,
+ loading = false,
+): SchedulesPageProps => ({
+ ...staticScheduleProps,
+ schedules,
+ schedulesError: error,
+ schedulesLoading: loading,
+});
+
+export const withData: SchedulesPageProps =
+ getSchedulesPageProps(allSchedulesFixture);
+export const empty: SchedulesPageProps = getSchedulesPageProps([]);
+export const loading: SchedulesPageProps = getSchedulesPageProps(
+ allSchedulesFixture,
+ null,
+ true,
+);
+export const error: SchedulesPageProps = getSchedulesPageProps(
+ allSchedulesFixture,
+ new Error(schedulesTimeoutErrorMessage),
+ false,
+);
diff --git a/pkg/ui/workspaces/cluster-ui/src/schedules/schedulesPage/schedulesPage.spec.tsx b/pkg/ui/workspaces/cluster-ui/src/schedules/schedulesPage/schedulesPage.spec.tsx
new file mode 100644
index 000000000000..cf6b818d5f2c
--- /dev/null
+++ b/pkg/ui/workspaces/cluster-ui/src/schedules/schedulesPage/schedulesPage.spec.tsx
@@ -0,0 +1,78 @@
+// 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 moment from "moment";
+import { cockroach } from "@cockroachlabs/crdb-protobuf-client";
+import { SchedulesPage, SchedulesPageProps } from "./schedulesPage";
+import { allSchedulesFixture } from "./schedulesPage.fixture";
+import { render } from "@testing-library/react";
+import React from "react";
+import { MemoryRouter } from "react-router-dom";
+import * as H from "history";
+import { Schedule } from "src/api/schedulesApi";
+
+const getMockSchedulesPageProps = (
+ schedules: Array,
+): SchedulesPageProps => {
+ const history = H.createHashHistory();
+ return {
+ sort: { columnTitle: null, ascending: true },
+ status: "",
+ show: "50",
+ setSort: () => {},
+ setStatus: () => {},
+ setShow: () => {},
+ schedules: schedules,
+ schedulesLoading: false,
+ schedulesError: null,
+ refreshSchedules: () => {},
+ location: history.location,
+ history,
+ match: {
+ url: "",
+ path: history.location.pathname,
+ isExact: false,
+ params: {},
+ },
+ };
+};
+
+describe("Schedules", () => {
+ it("renders expected schedules table columns", () => {
+ const { getByText } = render(
+
+
+ ,
+ );
+ const expectedColumnTitles = [
+ "Label",
+ "Status",
+ "Schedule ID",
+ "Owner",
+ "Recurrence",
+ "Creation Time (UTC)",
+ "Next Execution Time (UTC)",
+ "Jobs Running",
+ ];
+
+ for (const columnTitle of expectedColumnTitles) {
+ getByText(columnTitle);
+ }
+ });
+
+ it("renders a message when the table is empty", () => {
+ const { getByText } = render(
+
+
+ ,
+ );
+ getByText("No schedules to show");
+ });
+});
diff --git a/pkg/ui/workspaces/cluster-ui/src/schedules/schedulesPage/schedulesPage.stories.tsx b/pkg/ui/workspaces/cluster-ui/src/schedules/schedulesPage/schedulesPage.stories.tsx
new file mode 100644
index 000000000000..bc86bcee738e
--- /dev/null
+++ b/pkg/ui/workspaces/cluster-ui/src/schedules/schedulesPage/schedulesPage.stories.tsx
@@ -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 React from "react";
+import { storiesOf } from "@storybook/react";
+import { withRouterProvider } from "src/storybook/decorators";
+
+import { SchedulesPage } from "./schedulesPage";
+import { withData, empty, loading, error } from "./schedulesPage.fixture";
+
+storiesOf("SchedulesPage", module)
+ .addDecorator(withRouterProvider)
+ .add("With data", () => )
+ .add("Empty", () => )
+ .add("Loading; with delayed message", () => )
+ .add("Timeout error", () => );
diff --git a/pkg/ui/workspaces/cluster-ui/src/schedules/schedulesPage/schedulesPage.tsx b/pkg/ui/workspaces/cluster-ui/src/schedules/schedulesPage/schedulesPage.tsx
new file mode 100644
index 000000000000..b213844fd3b5
--- /dev/null
+++ b/pkg/ui/workspaces/cluster-ui/src/schedules/schedulesPage/schedulesPage.tsx
@@ -0,0 +1,184 @@
+// 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 { InlineAlert } from "@cockroachlabs/ui-components";
+import moment from "moment";
+import React, { useEffect } from "react";
+import { Helmet } from "react-helmet";
+import { RouteComponentProps } from "react-router-dom";
+import { Schedules } from "src/api/schedulesApi";
+import { Delayed } from "src/delayed";
+import { Dropdown, DropdownOption } from "src/dropdown";
+import { Loading } from "src/loading";
+import { PageConfig, PageConfigItem } from "src/pageConfig";
+import { SortSetting } from "src/sortedtable";
+import { syncHistory } from "src/util";
+
+import { ScheduleTable } from "./scheduleTable";
+
+import { commonStyles } from "src/common";
+import styles from "../schedules.module.scss";
+import classNames from "classnames/bind";
+
+import { statusOptions, showOptions } from "./scheduleOptions";
+
+const cx = classNames.bind(styles);
+
+export interface SchedulesPageStateProps {
+ sort: SortSetting;
+ status: string;
+ show: string;
+ schedules: Schedules;
+ schedulesError: Error | null;
+ schedulesLoading: boolean;
+}
+
+export interface SchedulesPageDispatchProps {
+ setSort: (value: SortSetting) => void;
+ setStatus: (value: string) => void;
+ setShow: (value: string) => void;
+ refreshSchedules: (req: { status: string; limit: number }) => void;
+ onFilterChange?: (req: { status: string; limit: number }) => void;
+}
+
+export type SchedulesPageProps = SchedulesPageStateProps &
+ SchedulesPageDispatchProps &
+ RouteComponentProps;
+
+export const SchedulesPage: React.FC = props => {
+ const {
+ history,
+ onFilterChange,
+ refreshSchedules,
+ status,
+ setStatus,
+ show,
+ setShow,
+ sort,
+ setSort,
+ } = props;
+ const searchParams = new URLSearchParams(history.location.search);
+
+ // Sort Settings.
+ const ascending = (searchParams.get("ascending") || undefined) === "true";
+ const columnTitle = searchParams.get("columnTitle") || "";
+
+ useEffect(() => {
+ if (!columnTitle) {
+ return;
+ }
+ setSort({ columnTitle, ascending });
+ }, [setSort, columnTitle, ascending]);
+
+ // Filter Status.
+ const paramStatus = searchParams.get("status") || undefined;
+ useEffect(() => {
+ if (paramStatus === undefined) {
+ return;
+ }
+ setStatus(paramStatus);
+ }, [paramStatus, setStatus]);
+
+ // Filter Show.
+ const paramShow = searchParams.get("show") || undefined;
+ useEffect(() => {
+ if (paramShow === undefined) {
+ return;
+ }
+ setShow(paramShow);
+ }, [paramShow, setShow]);
+
+ useEffect(() => {
+ const req = {
+ status: status,
+ limit: parseInt(show, 10),
+ };
+
+ onFilterChange ? onFilterChange(req) : refreshSchedules(req);
+ }, [status, show, refreshSchedules, onFilterChange]);
+
+ const onStatusSelected = (item: string) => {
+ setStatus(item);
+ syncHistory(
+ {
+ status: item,
+ },
+ history,
+ );
+ };
+
+ const onShowSelected = (item: string) => {
+ setShow(item);
+ syncHistory(
+ {
+ show: item,
+ },
+ history,
+ );
+ };
+
+ const changeSortSetting = (ss: SortSetting): void => {
+ setSort(ss);
+ syncHistory(
+ {
+ ascending: ss.ascending.toString(),
+ columnTitle: ss.columnTitle,
+ },
+ history,
+ );
+ };
+
+ const isLoading = !props.schedules || props.schedulesLoading;
+ const error = props.schedulesError;
+ return (
+
+
+
Schedules
+
+
+
+
+ Status:{" "}
+ {statusOptions.find(option => option["value"] === status)["name"]}
+
+
+
+
+ Show:{" "}
+ {showOptions.find(option => option["value"] === show)["name"]}
+
+
+
+
+
+ (
+ 0}
+ schedules={props.schedules}
+ setSort={changeSortSetting}
+ sort={sort}
+ />
+ )}
+ />
+ {isLoading && !error && (
+
+
+
+ )}
+
+
+ );
+};
diff --git a/pkg/ui/workspaces/cluster-ui/src/util/docs.ts b/pkg/ui/workspaces/cluster-ui/src/util/docs.ts
index 875a62760340..843ba17f7801 100644
--- a/pkg/ui/workspaces/cluster-ui/src/util/docs.ts
+++ b/pkg/ui/workspaces/cluster-ui/src/util/docs.ts
@@ -30,6 +30,9 @@ export const startFlags = docsURL("start-a-node.html#flags");
export const pauseJob = docsURL("pause-job.html");
export const resumeJob = docsURL("resume-job.html");
export const cancelJob = docsURL("cancel-job.html");
+export const pauseSchedules = docsURL("pause-schedules.html");
+export const resumeSchedules = docsURL("resume-schedules.html");
+export const dropSchedules = docsURL("drop-schedules.html");
export const enableNodeMap = docsURL("enable-node-map.html");
export const configureReplicationZones = docsURL(
"configure-replication-zones.html",
diff --git a/pkg/ui/workspaces/db-console/src/app.spec.tsx b/pkg/ui/workspaces/db-console/src/app.spec.tsx
index 5829fb0f4c62..a9751c715cf7 100644
--- a/pkg/ui/workspaces/db-console/src/app.spec.tsx
+++ b/pkg/ui/workspaces/db-console/src/app.spec.tsx
@@ -43,6 +43,8 @@ stubComponentInModule(
"src/views/insights/schemaInsightsPageConnected",
"default",
);
+stubComponentInModule("src/views/schedules/schedulesPage", "default");
+stubComponentInModule("src/views/schedules/scheduleDetails", "default");
import React from "react";
import { Action, Store } from "redux";
@@ -248,6 +250,20 @@ describe("Routing to", () => {
});
});
+ describe("'/schedules' path", () => {
+ test("routes to component", () => {
+ navigateToPath("/schedules");
+ screen.getByTestId("schedulesPage");
+ });
+ });
+
+ describe("'/schedules/:id' path", () => {
+ test("routes to component", () => {
+ navigateToPath("/schedules/12345");
+ screen.getByTestId("scheduleDetails");
+ });
+ });
+
{
/* databases */
}
diff --git a/pkg/ui/workspaces/db-console/src/app.tsx b/pkg/ui/workspaces/db-console/src/app.tsx
index c6e0e6032c91..24217d63c0ee 100644
--- a/pkg/ui/workspaces/db-console/src/app.tsx
+++ b/pkg/ui/workspaces/db-console/src/app.tsx
@@ -65,6 +65,8 @@ import ProblemRanges from "src/views/reports/containers/problemRanges";
import Range from "src/views/reports/containers/range";
import ReduxDebug from "src/views/reports/containers/redux";
import HotRanges from "src/views/reports/containers/hotranges";
+import SchedulesPage from "src/views/schedules/schedulesPage";
+import ScheduleDetails from "src/views/schedules/scheduleDetails";
import Settings from "src/views/reports/containers/settings";
import Stores from "src/views/reports/containers/stores";
import SQLActivityPage from "src/views/sqlActivity/sqlActivityPage";
@@ -159,6 +161,9 @@ export const App: React.FC = (props: AppProps) => {
+
+
+
{/* databases */}
diff --git a/pkg/ui/workspaces/db-console/src/redux/apiReducers.ts b/pkg/ui/workspaces/db-console/src/redux/apiReducers.ts
index 06831dfa8c4b..200c36cd64e8 100644
--- a/pkg/ui/workspaces/db-console/src/redux/apiReducers.ts
+++ b/pkg/ui/workspaces/db-console/src/redux/apiReducers.ts
@@ -430,6 +430,30 @@ const schemaInsightsReducerObj = new CachedDataReducer(
);
export const refreshSchemaInsights = schemaInsightsReducerObj.refresh;
+export const schedulesKey = (req: { status: string; limit: number }) =>
+ `${encodeURIComponent(req.status)}/${encodeURIComponent(
+ req.limit?.toString(),
+ )}`;
+
+const schedulesReducerObj = new KeyedCachedDataReducer(
+ clusterUiApi.getSchedules,
+ "schedules",
+ schedulesKey,
+ moment.duration(10, "s"),
+ moment.duration(1, "minute"),
+);
+export const refreshSchedules = schedulesReducerObj.refresh;
+
+export const scheduleKey = (scheduleID: Long): string => scheduleID.toString();
+
+const scheduleReducerObj = new KeyedCachedDataReducer(
+ clusterUiApi.getSchedule,
+ "schedule",
+ scheduleKey,
+ moment.duration(10, "s"),
+);
+export const refreshSchedule = scheduleReducerObj.refresh;
+
export interface APIReducersState {
cluster: CachedDataReducerState;
events: CachedDataReducerState;
@@ -469,6 +493,8 @@ export interface APIReducersState {
insightDetails: KeyedCachedDataReducerState;
statementInsights: CachedDataReducerState;
schemaInsights: CachedDataReducerState;
+ schedules: KeyedCachedDataReducerState;
+ schedule: KeyedCachedDataReducerState;
}
export const apiReducersReducer = combineReducers({
@@ -515,6 +541,8 @@ export const apiReducersReducer = combineReducers({
[statementInsightsReducerObj.actionNamespace]:
statementInsightsReducerObj.reducer,
[schemaInsightsReducerObj.actionNamespace]: schemaInsightsReducerObj.reducer,
+ [schedulesReducerObj.actionNamespace]: schedulesReducerObj.reducer,
+ [scheduleReducerObj.actionNamespace]: scheduleReducerObj.reducer,
});
export { CachedDataReducerState, KeyedCachedDataReducerState };
diff --git a/pkg/ui/workspaces/db-console/src/redux/cachedDataReducer.spec.ts b/pkg/ui/workspaces/db-console/src/redux/cachedDataReducer.spec.ts
index b8ef51078509..b23ddaa84361 100644
--- a/pkg/ui/workspaces/db-console/src/redux/cachedDataReducer.spec.ts
+++ b/pkg/ui/workspaces/db-console/src/redux/cachedDataReducer.spec.ts
@@ -508,8 +508,6 @@ describe("PaginatedCachedDataReducer", function () {
expected.valid = false;
expected.setAt = undefined;
expected.requestedAt = undefined;
- console.log("state", JSON.stringify(state, undefined, 2));
- console.log("expected", JSON.stringify(expected, undefined, 2));
expect(state).toEqual(expected);
});
diff --git a/pkg/ui/workspaces/db-console/src/views/app/components/layoutSidebar/index.tsx b/pkg/ui/workspaces/db-console/src/views/app/components/layoutSidebar/index.tsx
index 25a62e3703af..44e271970903 100644
--- a/pkg/ui/workspaces/db-console/src/views/app/components/layoutSidebar/index.tsx
+++ b/pkg/ui/workspaces/db-console/src/views/app/components/layoutSidebar/index.tsx
@@ -62,6 +62,7 @@ export class Sidebar extends React.Component {
activeFor: ["/hotranges"],
},
{ path: "/jobs", text: "Jobs", activeFor: [] },
+ { path: "/schedules", text: "Schedules", activeFor: [] },
{
path: "/debug",
text: "Advanced Debug",
diff --git a/pkg/ui/workspaces/db-console/src/views/schedules/scheduleDetails.tsx b/pkg/ui/workspaces/db-console/src/views/schedules/scheduleDetails.tsx
new file mode 100644
index 000000000000..6bfe1895f502
--- /dev/null
+++ b/pkg/ui/workspaces/db-console/src/views/schedules/scheduleDetails.tsx
@@ -0,0 +1,57 @@
+// 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 {
+ api,
+ ScheduleDetails,
+ ScheduleDetailsStateProps,
+} from "@cockroachlabs/cluster-ui";
+import { connect } from "react-redux";
+import { RouteComponentProps, withRouter } from "react-router-dom";
+import { createSelector } from "reselect";
+import { CachedDataReducerState, refreshSchedule } from "src/redux/apiReducers";
+import { AdminUIState } from "src/redux/state";
+import { getMatchParamByName } from "src/util/query";
+
+const selectScheduleState = createSelector(
+ [
+ (state: AdminUIState) => state.cachedData.schedule,
+ (_state: AdminUIState, props: RouteComponentProps) => props,
+ ],
+ (schedule, props): CachedDataReducerState => {
+ const scheduleId = getMatchParamByName(props.match, "id");
+ if (!schedule) {
+ return null;
+ }
+ return schedule[scheduleId];
+ },
+);
+
+const mapStateToProps = (
+ state: AdminUIState,
+ props: RouteComponentProps,
+): ScheduleDetailsStateProps => {
+ const scheduleState = selectScheduleState(state, props);
+ const schedule = scheduleState ? scheduleState.data : null;
+ const scheduleLoading = scheduleState ? scheduleState.inFlight : false;
+ const scheduleError = scheduleState ? scheduleState.lastError : null;
+ return {
+ schedule,
+ scheduleLoading,
+ scheduleError,
+ };
+};
+
+const mapDispatchToProps = {
+ refreshSchedule,
+};
+
+export default withRouter(
+ connect(mapStateToProps, mapDispatchToProps)(ScheduleDetails),
+);
diff --git a/pkg/ui/workspaces/db-console/src/views/schedules/schedulesPage.tsx b/pkg/ui/workspaces/db-console/src/views/schedules/schedulesPage.tsx
new file mode 100644
index 000000000000..f49880af1216
--- /dev/null
+++ b/pkg/ui/workspaces/db-console/src/views/schedules/schedulesPage.tsx
@@ -0,0 +1,91 @@
+// 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 {
+ api,
+ SchedulesPage,
+ SchedulesPageStateProps,
+ SortSetting,
+ showOptions,
+ statusOptions,
+} from "@cockroachlabs/cluster-ui";
+import { connect } from "react-redux";
+import { RouteComponentProps, withRouter } from "react-router-dom";
+import { createSelector } from "reselect";
+import {
+ CachedDataReducerState,
+ schedulesKey,
+ refreshSchedules,
+} from "src/redux/apiReducers";
+import { LocalSetting } from "src/redux/localsettings";
+import { AdminUIState } from "src/redux/state";
+
+export const statusSetting = new LocalSetting(
+ "schedules/status_setting",
+ s => s.localSettings,
+ statusOptions[0].value,
+);
+
+export const showSetting = new LocalSetting(
+ "schedules/show_setting",
+ s => s.localSettings,
+ showOptions[0].value,
+);
+
+export const sortSetting = new LocalSetting(
+ "sortSetting/Schedules",
+ s => s.localSettings,
+ { columnTitle: "creationTime", ascending: false },
+);
+
+const selectSchedulesState = createSelector(
+ [
+ (state: AdminUIState) => state.cachedData.schedules,
+ (_state: AdminUIState, key: string) => key,
+ ],
+ (schedules, key): CachedDataReducerState => {
+ if (!schedules) {
+ return null;
+ }
+ return schedules[key];
+ },
+);
+
+const mapStateToProps = (
+ state: AdminUIState,
+ _: RouteComponentProps,
+): SchedulesPageStateProps => {
+ const sort = sortSetting.selector(state);
+ const status = statusSetting.selector(state);
+ const show = showSetting.selector(state);
+ const key = schedulesKey({ status, limit: parseInt(show, 10) });
+ const schedulesState = selectSchedulesState(state, key);
+ const schedules = schedulesState ? schedulesState.data : null;
+ const schedulesLoading = schedulesState ? schedulesState.inFlight : false;
+ const schedulesError = schedulesState ? schedulesState.lastError : null;
+ return {
+ sort,
+ status,
+ show,
+ schedules,
+ schedulesLoading,
+ schedulesError,
+ };
+};
+
+const mapDispatchToProps = {
+ setSort: sortSetting.set,
+ setStatus: statusSetting.set,
+ setShow: showSetting.set,
+ refreshSchedules,
+};
+
+export default withRouter(
+ connect(mapStateToProps, mapDispatchToProps)(SchedulesPage),
+);