From 03fde0eb71b92a33d0e8ccf721c6222d3c655a44 Mon Sep 17 00:00:00 2001 From: Ben Bardin Date: Tue, 16 Aug 2022 13:38:50 -0400 Subject: [PATCH] ui: add page to view schedules in db console. Adds /schedules page in DB console. Structure is very similar to /jobs pa ge. The new /schedules page provides a bit more observability into what schedules are running, and their various states. Release note (ui change): Add page to view schedules in DB console. Release justification: Low-risk - new, read-only page. --- pkg/ui/workspaces/cluster-ui/src/api/index.ts | 1 + .../cluster-ui/src/api/schedulesApi.ts | 153 +++++++++ pkg/ui/workspaces/cluster-ui/src/index.ts | 1 + .../cluster-ui/src/schedules/index.ts | 12 + .../schedules/scheduleDetailsPage/index.ts | 11 + .../scheduleDetailsPage/scheduleDetails.tsx | 126 ++++++++ .../src/schedules/schedules.module.scss | 265 ++++++++++++++++ .../src/schedules/schedulesPage/index.ts | 12 + .../schedulesPage/scheduleOptions.tsx | 22 ++ .../schedulesPage/scheduleTable.spec.tsx | 42 +++ .../schedules/schedulesPage/scheduleTable.tsx | 291 ++++++++++++++++++ .../schedulesPage/schedulesPage.fixture.tsx | 107 +++++++ .../schedulesPage/schedulesPage.spec.tsx | 78 +++++ .../schedulesPage/schedulesPage.stories.tsx | 22 ++ .../schedules/schedulesPage/schedulesPage.tsx | 184 +++++++++++ pkg/ui/workspaces/cluster-ui/src/util/docs.ts | 3 + pkg/ui/workspaces/db-console/src/app.spec.tsx | 16 + pkg/ui/workspaces/db-console/src/app.tsx | 5 + .../db-console/src/redux/apiReducers.ts | 28 ++ .../src/redux/cachedDataReducer.spec.ts | 2 - .../app/components/layoutSidebar/index.tsx | 1 + .../src/views/schedules/scheduleDetails.tsx | 57 ++++ .../src/views/schedules/schedulesPage.tsx | 91 ++++++ 23 files changed, 1528 insertions(+), 2 deletions(-) create mode 100644 pkg/ui/workspaces/cluster-ui/src/api/schedulesApi.ts create mode 100644 pkg/ui/workspaces/cluster-ui/src/schedules/index.ts create mode 100644 pkg/ui/workspaces/cluster-ui/src/schedules/scheduleDetailsPage/index.ts create mode 100644 pkg/ui/workspaces/cluster-ui/src/schedules/scheduleDetailsPage/scheduleDetails.tsx create mode 100644 pkg/ui/workspaces/cluster-ui/src/schedules/schedules.module.scss create mode 100644 pkg/ui/workspaces/cluster-ui/src/schedules/schedulesPage/index.ts create mode 100644 pkg/ui/workspaces/cluster-ui/src/schedules/schedulesPage/scheduleOptions.tsx create mode 100644 pkg/ui/workspaces/cluster-ui/src/schedules/schedulesPage/scheduleTable.spec.tsx create mode 100644 pkg/ui/workspaces/cluster-ui/src/schedules/schedulesPage/scheduleTable.tsx create mode 100644 pkg/ui/workspaces/cluster-ui/src/schedules/schedulesPage/schedulesPage.fixture.tsx create mode 100644 pkg/ui/workspaces/cluster-ui/src/schedules/schedulesPage/schedulesPage.spec.tsx create mode 100644 pkg/ui/workspaces/cluster-ui/src/schedules/schedulesPage/schedulesPage.stories.tsx create mode 100644 pkg/ui/workspaces/cluster-ui/src/schedules/schedulesPage/schedulesPage.tsx create mode 100644 pkg/ui/workspaces/db-console/src/views/schedules/scheduleDetails.tsx create mode 100644 pkg/ui/workspaces/db-console/src/views/schedules/schedulesPage.tsx 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 ( +
+ +
+ +

{`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), +);