diff --git a/pkg/ui/workspaces/cluster-ui/src/activeExecutions/refreshControl/index.ts b/pkg/ui/workspaces/cluster-ui/src/activeExecutions/refreshControl/index.ts new file mode 100644 index 000000000000..9bfe2fc2d6a5 --- /dev/null +++ b/pkg/ui/workspaces/cluster-ui/src/activeExecutions/refreshControl/index.ts @@ -0,0 +1,11 @@ +// Copyright 2023 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 "./refreshControl"; diff --git a/pkg/ui/workspaces/cluster-ui/src/activeExecutions/refreshControl/refreshControl.module.scss b/pkg/ui/workspaces/cluster-ui/src/activeExecutions/refreshControl/refreshControl.module.scss new file mode 100644 index 000000000000..27d4e4ecd2e8 --- /dev/null +++ b/pkg/ui/workspaces/cluster-ui/src/activeExecutions/refreshControl/refreshControl.module.scss @@ -0,0 +1,50 @@ +// Copyright 2023 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"; + +.refresh-timestamp { + vertical-align: middle; +} + +.refresh-icon { + margin-left: 12px; + margin-right: 6px; + vertical-align: middle; +} + +.refresh-text { + color: $colors--primary-blue-3; + vertical-align: middle; + margin-right: 12px; +} + +.ant-switch-checked { + background-color: $colors--primary-blue-3; +} + +.refresh-button { + vertical-align: middle; + align-items: center; +} + +.refresh-divider { + border-left: 1px solid $colors--neutral-4; + padding-left: 12px; + height: $line-height--large; + display: inline-flex; + align-items: center; + vertical-align: middle; +} + +.auto-refresh-label { + padding-right: 8px; + vertical-align: middle; +} diff --git a/pkg/ui/workspaces/cluster-ui/src/activeExecutions/refreshControl/refreshControl.tsx b/pkg/ui/workspaces/cluster-ui/src/activeExecutions/refreshControl/refreshControl.tsx new file mode 100644 index 000000000000..51310d51fb93 --- /dev/null +++ b/pkg/ui/workspaces/cluster-ui/src/activeExecutions/refreshControl/refreshControl.tsx @@ -0,0 +1,81 @@ +// Copyright 2023 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 { Switch } from "antd"; +import "antd/lib/switch/style"; +import React from "react"; +import classNames from "classnames/bind"; +import styles from "./refreshControl.module.scss"; +import RefreshIcon from "src/icon/refreshIcon"; +import { Timestamp } from "src/timestamp"; +import { Moment } from "moment-timezone"; +import { DATE_WITH_SECONDS_FORMAT_24_TZ, capitalize } from "src/util"; +import { ExecutionType } from "../types"; + +const cx = classNames.bind(styles); + +interface RefreshControlProps { + isAutoRefreshEnabled: boolean; + onToggleAutoRefresh: () => void; + onManualRefresh: () => void; + lastRefreshTimestamp: Moment; + execType: ExecutionType; +} + +const REFRESH_BUTTON_COLOR = "#0055FF"; + +interface RefreshButtonProps { + onClick: () => void; +} + +// RefreshButton consists of the RefreshIcon and the text "Refresh". +const RefreshButton: React.FC = ({ onClick }) => ( + + + Refresh + +); + +export const RefreshControl: React.FC = ({ + isAutoRefreshEnabled, + onToggleAutoRefresh, + onManualRefresh, + lastRefreshTimestamp, + execType, +}) => { + return ( +
+ + Active {capitalize(execType)} Executions As Of: + {lastRefreshTimestamp && lastRefreshTimestamp.isValid() ? ( + + ) : ( + "N/A" + )} + + + + Auto Refresh: + + +
+ ); +}; + +export default RefreshControl; diff --git a/pkg/ui/workspaces/cluster-ui/src/icon/refreshIcon.tsx b/pkg/ui/workspaces/cluster-ui/src/icon/refreshIcon.tsx new file mode 100644 index 000000000000..6ad5b1bb215e --- /dev/null +++ b/pkg/ui/workspaces/cluster-ui/src/icon/refreshIcon.tsx @@ -0,0 +1,45 @@ +// Copyright 2023 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 React from "react"; + +interface IconProps { + className?: string; + color?: string; +} + +const RefreshIcon = ({ className, color }: IconProps) => ( + + + + + + + + + + + +); + +export default RefreshIcon; diff --git a/pkg/ui/workspaces/cluster-ui/src/statementsPage/activeStatementsPage.selectors.ts b/pkg/ui/workspaces/cluster-ui/src/statementsPage/activeStatementsPage.selectors.ts index ef3f388ef435..9e4d36f1c01e 100644 --- a/pkg/ui/workspaces/cluster-ui/src/statementsPage/activeStatementsPage.selectors.ts +++ b/pkg/ui/workspaces/cluster-ui/src/statementsPage/activeStatementsPage.selectors.ts @@ -16,6 +16,7 @@ import { ActiveStatementsViewStateProps, AppState, SortSetting, + analyticsActions, } from "src"; import { selectActiveStatements, @@ -23,7 +24,10 @@ import { selectExecutionStatus, selectClusterLocksMaxApiSizeReached, } from "src/selectors/activeExecutions.selectors"; -import { actions as localStorageActions } from "src/store/localStorage"; +import { + LocalStorageKeys, + actions as localStorageActions, +} from "src/store/localStorage"; import { actions as sessionsActions } from "src/store/sessions"; import { selectIsTenant } from "src/store/uiConfig"; import { localStorageSelector } from "../store/utils/selectors"; @@ -34,6 +38,12 @@ export const selectSortSetting = (state: AppState): SortSetting => export const selectFilters = (state: AppState): ActiveStatementFilters => localStorageSelector(state)["filters/ActiveStatementsPage"]; +export const selectIsAutoRefreshEnabled = (state: AppState): boolean => { + return localStorageSelector(state)[ + LocalStorageKeys.ACTIVE_EXECUTIONS_IS_AUTOREFRESH_ENABLED + ]; +}; + const selectLocalStorageColumns = (state: AppState) => { const localStorage = localStorageSelector(state); return localStorage["showColumns/ActiveStatementsPage"]; @@ -60,6 +70,8 @@ export const mapStateToActiveStatementsPageProps = ( internalAppNamePrefix: selectAppName(state), isTenant: selectIsTenant(state), maxSizeApiReached: selectClusterLocksMaxApiSizeReached(state), + isAutoRefreshEnabled: selectIsAutoRefreshEnabled(state), + lastUpdated: state.adminUI?.sessions.lastUpdated, }); export const mapDispatchToActiveStatementsPageProps = ( @@ -88,4 +100,27 @@ export const mapDispatchToActiveStatementsPageProps = ( value: ss, }), ), + onAutoRefreshToggle: (isEnabled: boolean) => { + dispatch( + localStorageActions.update({ + key: LocalStorageKeys.ACTIVE_EXECUTIONS_IS_AUTOREFRESH_ENABLED, + value: isEnabled, + }), + ); + dispatch( + analyticsActions.track({ + name: "Auto Refresh Toggle", + page: "Statements", + value: isEnabled, + }), + ); + }, + onManualRefresh: () => { + dispatch( + analyticsActions.track({ + name: "Manual Refresh", + page: "Statements", + }), + ); + }, }); diff --git a/pkg/ui/workspaces/cluster-ui/src/statementsPage/activeStatementsView.tsx b/pkg/ui/workspaces/cluster-ui/src/statementsPage/activeStatementsView.tsx index 4e421000e502..1900c14bd252 100644 --- a/pkg/ui/workspaces/cluster-ui/src/statementsPage/activeStatementsView.tsx +++ b/pkg/ui/workspaces/cluster-ui/src/statementsPage/activeStatementsView.tsx @@ -10,6 +10,7 @@ import React, { useEffect, useState } from "react"; import classNames from "classnames/bind"; +import { Moment } from "moment-timezone"; import { useHistory } from "react-router-dom"; import { ISortedTablePagination, @@ -39,6 +40,7 @@ import { Pagination } from "src/pagination"; import { InlineAlert } from "@cockroachlabs/ui-components"; import styles from "./statementsPage.module.scss"; +import RefreshControl from "src/activeExecutions/refreshControl/refreshControl"; const cx = classNames.bind(styles); const PAGE_SIZE = 20; @@ -48,6 +50,8 @@ export type ActiveStatementsViewDispatchProps = { onFiltersChange: (filters: ActiveStatementFilters) => void; onSortChange: (ss: SortSetting) => void; refreshLiveWorkload: () => void; + onAutoRefreshToggle: (isEnabled: boolean) => void; + onManualRefresh: () => void; }; export type ActiveStatementsViewStateProps = { @@ -60,6 +64,8 @@ export type ActiveStatementsViewStateProps = { internalAppNamePrefix: string; isTenant?: boolean; maxSizeApiReached?: boolean; + isAutoRefreshEnabled?: boolean; + lastUpdated: Moment | null; }; export type ActiveStatementsViewProps = ActiveStatementsViewStateProps & @@ -79,6 +85,10 @@ export const ActiveStatementsView: React.FC = ({ internalAppNamePrefix, isTenant, maxSizeApiReached, + isAutoRefreshEnabled, + onAutoRefreshToggle, + lastUpdated, + onManualRefresh, }: ActiveStatementsViewProps) => { const [pagination, setPagination] = useState({ current: 1, @@ -90,13 +100,25 @@ export const ActiveStatementsView: React.FC = ({ ); useEffect(() => { - // Refresh every 10 seconds. - refreshLiveWorkload(); - const interval = setInterval(refreshLiveWorkload, 10 * 1000); - return () => { - clearInterval(interval); - }; - }, [refreshLiveWorkload]); + // useEffect hook which triggers an immediate data refresh if auto-refresh + // is enabled. It fetches the latest workload details by dispatching a + // refresh action when the component mounts, ensuring that users see fresh + // data as soon as they land on the page if auto-refresh is on. + if (isAutoRefreshEnabled) { + refreshLiveWorkload(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + // Refresh every 10 seconds if auto refresh is on. + if (isAutoRefreshEnabled) { + const interval = setInterval(refreshLiveWorkload, 10 * 1000); + return () => { + clearInterval(interval); + }; + } + }, [isAutoRefreshEnabled, refreshLiveWorkload]); useEffect(() => { // We use this effect to sync settings defined on the URL (sort, filters), @@ -160,6 +182,18 @@ export const ActiveStatementsView: React.FC = ({ resetPagination(); }; + const onSubmitToggleAutoRefresh = () => { + // Refresh immediately when toggling auto-refresh on. + if (!isAutoRefreshEnabled) { + refreshLiveWorkload(); + } + onAutoRefreshToggle(!isAutoRefreshEnabled); + }; + + const handleRefresh = () => { + onManualRefresh(); + }; + const clearSearch = () => onSubmitSearch(""); const clearFilters = () => onSubmitFilters({ @@ -204,6 +238,15 @@ export const ActiveStatementsView: React.FC = ({ filters={filters} /> + + +
localStorageSelector(state)["sortSetting/ActiveTransactionsPage"]; @@ -60,6 +65,8 @@ export const mapStateToActiveTransactionsPageProps = ( internalAppNamePrefix: selectAppName(state), isTenant: selectIsTenant(state), maxSizeApiReached: selectClusterLocksMaxApiSizeReached(state), + isAutoRefreshEnabled: selectIsAutoRefreshEnabled(state), + lastUpdated: state.adminUI?.sessions.lastUpdated, }); export const mapDispatchToActiveTransactionsPageProps = ( @@ -87,4 +94,27 @@ export const mapDispatchToActiveTransactionsPageProps = ( value: ss, }), ), + onAutoRefreshToggle: (isEnabled: boolean) => { + dispatch( + localStorageActions.update({ + key: LocalStorageKeys.ACTIVE_EXECUTIONS_IS_AUTOREFRESH_ENABLED, + value: isEnabled, + }), + ); + dispatch( + analyticsActions.track({ + name: "Auto Refresh Toggle", + page: "Transactions", + value: isEnabled, + }), + ); + }, + onManualRefresh: () => { + dispatch( + analyticsActions.track({ + name: "Manual Refresh", + page: "Transactions", + }), + ); + }, }); diff --git a/pkg/ui/workspaces/cluster-ui/src/transactionsPage/activeTransactionsView.tsx b/pkg/ui/workspaces/cluster-ui/src/transactionsPage/activeTransactionsView.tsx index 6944c33832fb..b94fb99b3afc 100644 --- a/pkg/ui/workspaces/cluster-ui/src/transactionsPage/activeTransactionsView.tsx +++ b/pkg/ui/workspaces/cluster-ui/src/transactionsPage/activeTransactionsView.tsx @@ -40,6 +40,9 @@ import { getTableSortFromURL } from "src/sortedtable/getTableSortFromURL"; import { getActiveTransactionFiltersFromURL } from "src/queryFilter/utils"; import { filterActiveTransactions } from "../activeExecutions/activeStatementUtils"; import { InlineAlert } from "@cockroachlabs/ui-components"; +import { RefreshControl } from "src/activeExecutions/refreshControl"; +import { Moment } from "moment-timezone"; + const cx = classNames.bind(styles); export type ActiveTransactionsViewDispatchProps = { @@ -47,6 +50,8 @@ export type ActiveTransactionsViewDispatchProps = { onFiltersChange: (filters: ActiveTransactionFilters) => void; onSortChange: (ss: SortSetting) => void; refreshLiveWorkload: () => void; + onAutoRefreshToggle: (isEnabled: boolean) => void; + onManualRefresh: () => void; }; export type ActiveTransactionsViewStateProps = { @@ -59,6 +64,8 @@ export type ActiveTransactionsViewStateProps = { internalAppNamePrefix: string; isTenant?: boolean; maxSizeApiReached?: boolean; + isAutoRefreshEnabled?: boolean; + lastUpdated: Moment | null; }; export type ActiveTransactionsViewProps = ActiveTransactionsViewStateProps & @@ -81,6 +88,10 @@ export const ActiveTransactionsView: React.FC = ({ executionStatus, internalAppNamePrefix, maxSizeApiReached, + isAutoRefreshEnabled, + onAutoRefreshToggle, + lastUpdated, + onManualRefresh, }: ActiveTransactionsViewProps) => { const [pagination, setPagination] = useState({ current: 1, @@ -93,14 +104,25 @@ export const ActiveTransactionsView: React.FC = ({ ); useEffect(() => { - // Refresh every 10 seconds. - refreshLiveWorkload(); + // useEffect hook which triggers an immediate data refresh if auto-refresh + // is enabled. It fetches the latest workload details by dispatching a + // refresh action when the component mounts, ensuring that users see fresh + // data as soon as they land on the page if auto-refresh is on. + if (isAutoRefreshEnabled) { + refreshLiveWorkload(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); - const interval = setInterval(refreshLiveWorkload, 10 * 1000); - return () => { - clearInterval(interval); - }; - }, [refreshLiveWorkload]); + useEffect(() => { + // Refresh every 10 seconds if auto refresh is on. + if (isAutoRefreshEnabled) { + const interval = setInterval(refreshLiveWorkload, 10 * 1000); + return () => { + clearInterval(interval); + }; + } + }, [isAutoRefreshEnabled, refreshLiveWorkload]); useEffect(() => { // We use this effect to sync settings defined on the URL (sort, filters), @@ -162,6 +184,18 @@ export const ActiveTransactionsView: React.FC = ({ resetPagination(); }; + const onSubmitToggleAutoRefresh = () => { + // Refresh immediately when toggling auto-refresh on. + if (!isAutoRefreshEnabled) { + refreshLiveWorkload(); + } + onAutoRefreshToggle(!isAutoRefreshEnabled); + }; + + const handleRefresh = () => { + onManualRefresh(); + }; + const clearSearch = () => onSubmitSearch(""); const clearFilters = () => onSubmitFilters({ @@ -207,6 +241,15 @@ export const ActiveTransactionsView: React.FC = ({ filters={filters} /> + + +
state.cachedData.sessions?.data; @@ -33,6 +34,10 @@ export const selectClusterLocksMaxApiSizeReached = ( return !!state.cachedData.clusterLocks?.data?.maxSizeReached; }; +export const selectIsAutoRefreshEnabled = (state: AdminUIState): boolean => { + return state.localSettings[ACTIVE_EXECUTIONS_IS_AUTOREFRESH_ENABLED]; +}; + export const selectActiveExecutions = createSelector( selectSessions, selectClusterLocks, diff --git a/pkg/ui/workspaces/db-console/src/views/statements/activeStatementsSelectors.tsx b/pkg/ui/workspaces/db-console/src/views/statements/activeStatementsSelectors.tsx index 66fa9c1e5899..89f175ccb622 100644 --- a/pkg/ui/workspaces/db-console/src/views/statements/activeStatementsSelectors.tsx +++ b/pkg/ui/workspaces/db-console/src/views/statements/activeStatementsSelectors.tsx @@ -18,10 +18,12 @@ import { selectAppName, selectExecutionStatus, selectClusterLocksMaxApiSizeReached, + selectIsAutoRefreshEnabled, } from "src/selectors"; import { refreshLiveWorkload } from "src/redux/apiReducers"; import { LocalSetting } from "src/redux/localsettings"; import { AdminUIState } from "src/redux/state"; +import { ACTIVE_EXECUTIONS_IS_AUTOREFRESH_ENABLED } from "../transactions/activeTransactionsSelectors"; const selectedColumnsLocalSetting = new LocalSetting< AdminUIState, @@ -52,6 +54,14 @@ const sortSettingLocalSetting = new LocalSetting( { ascending: false, columnTitle: "startTime" }, ); +// autoRefreshLocalSetting is shared between the Active Statements and Active +// Transactions components. +const autoRefreshLocalSetting = new LocalSetting( + ACTIVE_EXECUTIONS_IS_AUTOREFRESH_ENABLED, + (state: AdminUIState) => state.localSettings, + true, +); + export const mapStateToActiveStatementViewProps = (state: AdminUIState) => ({ filters: filtersLocalSetting.selector(state), selectedColumns: selectedColumnsLocalSetting.selectorToArray(state), @@ -61,6 +71,8 @@ export const mapStateToActiveStatementViewProps = (state: AdminUIState) => ({ sessionsError: state.cachedData?.sessions.lastError, internalAppNamePrefix: selectAppName(state), maxSizeApiReached: selectClusterLocksMaxApiSizeReached(state), + isAutoRefreshEnabled: selectIsAutoRefreshEnabled(state), + lastUpdated: state.cachedData?.sessions.setAt, }); export const activeStatementsViewActions = { @@ -70,4 +82,7 @@ export const activeStatementsViewActions = { onFiltersChange: (filters: ActiveStatementFilters) => filtersLocalSetting.set(filters), onSortChange: (ss: SortSetting) => sortSettingLocalSetting.set(ss), + onAutoRefreshToggle: (isToggled: boolean) => + autoRefreshLocalSetting.set(isToggled), + onManualRefresh: () => refreshLiveWorkload(), }; diff --git a/pkg/ui/workspaces/db-console/src/views/transactions/activeTransactionsSelectors.tsx b/pkg/ui/workspaces/db-console/src/views/transactions/activeTransactionsSelectors.tsx index 5abf2967b12e..1b66f377ac6a 100644 --- a/pkg/ui/workspaces/db-console/src/views/transactions/activeTransactionsSelectors.tsx +++ b/pkg/ui/workspaces/db-console/src/views/transactions/activeTransactionsSelectors.tsx @@ -19,11 +19,15 @@ import { selectActiveTransactions, selectExecutionStatus, selectClusterLocksMaxApiSizeReached, + selectIsAutoRefreshEnabled, } from "src/selectors"; import { refreshLiveWorkload } from "src/redux/apiReducers"; import { LocalSetting } from "src/redux/localsettings"; import { AdminUIState } from "src/redux/state"; +export const ACTIVE_EXECUTIONS_IS_AUTOREFRESH_ENABLED = + "isAutoRefreshEnabled/ActiveExecutions"; + const transactionsColumnsLocalSetting = new LocalSetting< AdminUIState, string | null @@ -53,6 +57,14 @@ const sortSettingLocalSetting = new LocalSetting( { ascending: false, columnTitle: "startTime" }, ); +// autoRefreshLocalSetting is shared between the Active Statements and Active +// Transactions components. +const autoRefreshLocalSetting = new LocalSetting( + ACTIVE_EXECUTIONS_IS_AUTOREFRESH_ENABLED, + (state: AdminUIState) => state.localSettings, + true, +); + export const mapStateToActiveTransactionsPageProps = (state: AdminUIState) => ({ selectedColumns: transactionsColumnsLocalSetting.selectorToArray(state), transactions: selectActiveTransactions(state), @@ -62,6 +74,8 @@ export const mapStateToActiveTransactionsPageProps = (state: AdminUIState) => ({ sortSetting: sortSettingLocalSetting.selector(state), internalAppNamePrefix: selectAppName(state), maxSizeApiReached: selectClusterLocksMaxApiSizeReached(state), + isAutoRefreshEnabled: selectIsAutoRefreshEnabled(state), + lastUpdated: state.cachedData?.sessions.setAt, }); // This object is just for convenience so we don't need to supply dispatch to @@ -74,4 +88,7 @@ export const activeTransactionsPageActionCreators: ActiveTransactionsViewDispatc filtersLocalSetting.set(filters), onSortChange: (ss: SortSetting) => sortSettingLocalSetting.set(ss), refreshLiveWorkload, + onAutoRefreshToggle: (isToggled: boolean) => + autoRefreshLocalSetting.set(isToggled), + onManualRefresh: () => refreshLiveWorkload(), };