diff --git a/pkg/ui/workspaces/cluster-ui/src/statementsPage/statementsPage.fixture.ts b/pkg/ui/workspaces/cluster-ui/src/statementsPage/statementsPage.fixture.ts index 0904ccf442dd..1b5c425f667c 100644 --- a/pkg/ui/workspaces/cluster-ui/src/statementsPage/statementsPage.fixture.ts +++ b/pkg/ui/workspaces/cluster-ui/src/statementsPage/statementsPage.fixture.ts @@ -246,6 +246,7 @@ const diagnosticsReportsInProgress: IStatementDiagnosticsReport[] = [ const aggregatedTs = Date.parse("Sep 15 2021 01:00:00 GMT") * 1e-3; const aggregationInterval = 3600; // 1 hour +const lastUpdated = moment("Sep 15 2021 01:30:00 GMT"); const statementsPagePropsFixture: StatementsPageProps = { history, @@ -283,6 +284,9 @@ const statementsPagePropsFixture: StatementsPageProps = { regions: "", nodes: "", }, + lastUpdated, + isDataValid: true, + isReqInFlight: false, // Aggregate key values in these statements will need to change if implementation // of 'statementKey' in appStats.ts changes. statements: [ diff --git a/pkg/ui/workspaces/cluster-ui/src/statementsPage/statementsPage.selectors.ts b/pkg/ui/workspaces/cluster-ui/src/statementsPage/statementsPage.selectors.ts index e02cc3b27920..a7dec2ae1e3d 100644 --- a/pkg/ui/workspaces/cluster-ui/src/statementsPage/statementsPage.selectors.ts +++ b/pkg/ui/workspaces/cluster-ui/src/statementsPage/statementsPage.selectors.ts @@ -25,6 +25,7 @@ import { } from "src/util"; import { cockroach } from "@cockroachlabs/crdb-protobuf-client"; import { RouteComponentProps } from "react-router-dom"; +import { Moment } from "moment"; import { AppState } from "src/store"; import { selectDiagnosticsReportsPerStatement } from "../store/statementDiagnostics"; @@ -93,13 +94,13 @@ export const selectApps = createSelector(sqlStatsSelector, sqlStatsState => { // in the data. export const selectDatabases = createSelector( sqlStatsSelector, - sqlStatsState => { + (sqlStatsState): string[] => { if (!sqlStatsState.data) { return []; } return Array.from( - new Set( + new Set( sqlStatsState.data.statements.map(s => s.key.key_data.database ? s.key.key_data.database : unset, ), @@ -133,6 +134,27 @@ export const selectLastReset = createSelector(sqlStatsSelector, state => { return formatDate(TimestampToMoment(state.data.last_reset)); }); +export const selectStatementsDataValid = createSelector( + sqlStatsSelector, + (state: SQLStatsState): boolean => { + return state.valid; + }, +); + +export const selectStatementsDataInFlight = createSelector( + sqlStatsSelector, + (state: SQLStatsState): boolean => { + return state.inFlight; + }, +); + +export const selectStatementsLastUpdated = createSelector( + sqlStatsSelector, + (state: SQLStatsState): Moment => { + return state.lastUpdated; + }, +); + export const selectStatements = createSelector( sqlStatsSelector, (_: AppState, props: RouteComponentProps) => props, diff --git a/pkg/ui/workspaces/cluster-ui/src/statementsPage/statementsPage.tsx b/pkg/ui/workspaces/cluster-ui/src/statementsPage/statementsPage.tsx index bb52c9008126..b3815cf5aa9e 100644 --- a/pkg/ui/workspaces/cluster-ui/src/statementsPage/statementsPage.tsx +++ b/pkg/ui/workspaces/cluster-ui/src/statementsPage/statementsPage.tsx @@ -10,7 +10,7 @@ import React from "react"; import { RouteComponentProps } from "react-router-dom"; -import { isNil, merge } from "lodash"; +import { merge } from "lodash"; import classNames from "classnames/bind"; import { getValidErrorsList, Loading } from "src/loading"; import { Delayed } from "src/delayed"; @@ -96,7 +96,7 @@ export interface StatementsPageDispatchProps { refreshStatementDiagnosticsRequests: () => void; refreshNodes: () => void; refreshUserSQLRoles: () => void; - resetSQLStats: (req: StatementsRequest) => void; + resetSQLStats: () => void; dismissAlertMessage: () => void; onActivateStatementDiagnostics: ( statement: string, @@ -122,6 +122,9 @@ export interface StatementsPageDispatchProps { export interface StatementsPageStateProps { statements: AggregateStatistics[]; + isDataValid: boolean; + isReqInFlight: boolean; + lastUpdated: moment.Moment | null; timeScale: TimeScale; statementsError: Error | null; apps: string[]; @@ -142,7 +145,6 @@ export interface StatementsPageState { pagination: ISortedTablePagination; filters?: Filters; activeFilters?: number; - startRequest?: Date; } export type StatementsPageProps = StatementsPageDispatchProps & @@ -188,6 +190,7 @@ export class StatementsPage extends React.Component< StatementsPageState > { activateDiagnosticsRef: React.RefObject; + constructor(props: StatementsPageProps) { super(props); const defaultState = { @@ -195,19 +198,10 @@ export class StatementsPage extends React.Component< pageSize: 20, current: 1, }, - startRequest: new Date(), }; const stateFromHistory = this.getStateFromHistory(); this.state = merge(defaultState, stateFromHistory); this.activateDiagnosticsRef = React.createRef(); - - // In case the user selected a option not available on this page, - // force a selection of a valid option. This is necessary for the case - // where the value 10/30 min is selected on the Metrics page. - const ts = getValidOption(this.props.timeScale, timeScale1hMinOptions); - if (ts !== this.props.timeScale) { - this.changeTimeScale(ts); - } } getStateFromHistory = (): Partial => { @@ -266,9 +260,6 @@ export class StatementsPage extends React.Component< if (this.props.onTimeScaleChange) { this.props.onTimeScaleChange(ts); } - this.setState({ - startRequest: new Date(), - }); }; resetPagination = (): void => { @@ -287,18 +278,24 @@ export class StatementsPage extends React.Component< this.props.refreshStatements(req); }; resetSQLStats = (): void => { - const req = statementsRequestFromProps(this.props); - this.props.resetSQLStats(req); - this.setState({ - startRequest: new Date(), - }); + this.props.resetSQLStats(); }; componentDidMount(): void { - this.setState({ - startRequest: new Date(), - }); - this.refreshStatements(); + // In case the user selected a option not available on this page, + // force a selection of a valid option. This is necessary for the case + // where the value 10/30 min is selected on the Metrics page. + const ts = getValidOption(this.props.timeScale, timeScale1hMinOptions); + if (ts !== this.props.timeScale) { + this.changeTimeScale(ts); + } else if ( + !this.props.isDataValid || + !this.props.lastUpdated || + !this.props.statements + ) { + this.refreshStatements(); + } + this.props.refreshUserSQLRoles(); if (!this.props.isTenant) { this.props.refreshNodes(); @@ -340,7 +337,7 @@ export class StatementsPage extends React.Component< ); } - componentDidUpdate = (): void => { + componentDidUpdate = (prevProps: StatementsPageProps): void => { this.updateQueryParams(); if (!this.props.isTenant) { this.props.refreshNodes(); @@ -348,6 +345,13 @@ export class StatementsPage extends React.Component< this.props.refreshStatementDiagnosticsRequests(); } } + + if ( + prevProps.timeScale !== this.props.timeScale || + (prevProps.isDataValid && !this.props.isDataValid) + ) { + this.refreshStatements(); + } }; componentWillUnmount(): void { @@ -438,8 +442,12 @@ export class StatementsPage extends React.Component< }; filteredStatementsData = (): AggregateStatistics[] => { - const { filters } = this.state; - const { search, statements, nodeRegions, isTenant } = this.props; + const { search, statements, nodeRegions, isTenant, filters } = this.props; + + if (!statements) { + return []; + } + const timeValue = getTimeValueInSeconds(filters); const sqlTypes = filters.sqlType.length > 0 @@ -517,7 +525,6 @@ export class StatementsPage extends React.Component< renderStatements = (regions: string[]): React.ReactElement => { const { pagination, filters, activeFilters } = this.state; const { - statements, onSelectDiagnosticsReportDropdownOption, onStatementClick, columns: userSelectedColumnsToShow, @@ -530,6 +537,8 @@ export class StatementsPage extends React.Component< } = this.props; const data = this.filteredStatementsData(); const totalWorkload = calculateTotalWorkload(data); + const statements = this.props.statements ?? []; + const totalCount = data.length; const isEmptySearchResults = statements?.length > 0 && search?.length > 0; // If the cluster is a tenant cluster we don't show info @@ -647,15 +656,14 @@ export class StatementsPage extends React.Component< : unique(nodes.map(node => nodeRegions[node.toString()])).sort(); const { filters, activeFilters } = this.state; - const longLoadingMessage = isNil(this.props.statements) && - isNil(getValidErrorsList(this.props.statementsError)) && ( - - - - ); + const longLoadingMessage = ( + + + + ); return (
@@ -704,7 +712,7 @@ export class StatementsPage extends React.Component< )} this.renderStatements(regions)} @@ -717,7 +725,9 @@ export class StatementsPage extends React.Component< }) } /> - {longLoadingMessage} + {this.props.isReqInFlight && + getValidErrorsList(this.props.statementsError) == null && + longLoadingMessage} ( - (state: AppState, props: StatementsPageProps) => ({ + (state: AppState, props): StatementsPageStateProps => ({ apps: selectApps(state), columns: selectColumns(state), databases: selectDatabases(state), @@ -72,10 +74,13 @@ export const ConnectedStatementsPage = withRouter( hasViewActivityRedactedRole: selectHasViewActivityRedactedRole(state), hasAdminRole: selectHasAdminRole(state), lastReset: selectLastReset(state), - nodeRegions: selectIsTenant(state) ? {} : nodeRegionsByIDSelector(state), + nodeRegions: nodeRegionsByIDSelector(state), search: selectSearch(state), sortSetting: selectSortSetting(state), statements: selectStatements(state, props), + isDataValid: selectStatementsDataValid(state), + isReqInFlight: selectStatementsDataInFlight(state), + lastUpdated: selectStatementsLastUpdated(state), statementsError: selectStatementsLastError(state), totalFingerprints: selectTotalFingerprints(state), }), @@ -94,8 +99,7 @@ export const ConnectedStatementsPage = withRouter( refreshNodes: () => dispatch(nodesActions.refresh()), refreshUserSQLRoles: () => dispatch(uiConfigActions.refreshUserSQLRoles()), - resetSQLStats: (req: StatementsRequest) => - dispatch(sqlStatsActions.reset(req)), + resetSQLStats: () => dispatch(sqlStatsActions.reset()), dismissAlertMessage: () => dispatch( localStorageActions.update({ diff --git a/pkg/ui/workspaces/cluster-ui/src/store/localStorage/localStorage.reducer.ts b/pkg/ui/workspaces/cluster-ui/src/store/localStorage/localStorage.reducer.ts index 28e0ed906c29..044ea5581bce 100644 --- a/pkg/ui/workspaces/cluster-ui/src/store/localStorage/localStorage.reducer.ts +++ b/pkg/ui/workspaces/cluster-ui/src/store/localStorage/localStorage.reducer.ts @@ -8,7 +8,6 @@ // by the Apache License, Version 2.0, included in the file // licenses/APL.txt. -import moment from "moment"; import { createSlice, PayloadAction } from "@reduxjs/toolkit"; import { DOMAIN_NAME } from "../utils"; import { defaultFilters, Filters } from "../../queryFilter"; @@ -19,12 +18,16 @@ type SortSetting = { columnTitle: string; }; +export enum LocalStorageKeys { + GLOBAL_TIME_SCALE = "timeScale/SQLActivity", +} + export type LocalStorageState = { "adminUi/showDiagnosticsModal": boolean; "showColumns/StatementsPage": string; "showColumns/TransactionPage": string; "showColumns/SessionsPage": string; - "timeScale/SQLActivity": TimeScale; + [LocalStorageKeys.GLOBAL_TIME_SCALE]: TimeScale; "sortSetting/StatementsPage": SortSetting; "sortSetting/TransactionsPage": SortSetting; "sortSetting/SessionsPage": SortSetting; @@ -37,7 +40,11 @@ export type LocalStorageState = { type Payload = { key: keyof LocalStorageState; - value: any; + value: unknown; +}; + +export type TypedPayload = { + value: T; }; const defaultSortSetting: SortSetting = { @@ -61,8 +68,8 @@ const initialState: LocalStorageState = { JSON.parse(localStorage.getItem("showColumns/TransactionPage")) || null, "showColumns/SessionsPage": JSON.parse(localStorage.getItem("showColumns/SessionsPage")) || null, - "timeScale/SQLActivity": - JSON.parse(localStorage.getItem("timeScale/SQLActivity")) || + [LocalStorageKeys.GLOBAL_TIME_SCALE]: + JSON.parse(localStorage.getItem(LocalStorageKeys.GLOBAL_TIME_SCALE)) || defaultTimeScaleSelected, "sortSetting/StatementsPage": JSON.parse(localStorage.getItem("sortSetting/StatementsPage")) || @@ -94,6 +101,12 @@ const localStorageSlice = createSlice({ update: (state: any, action: PayloadAction) => { state[action.payload.key] = action.payload.value; }, + updateTimeScale: ( + state, + action: PayloadAction>, + ) => { + state[LocalStorageKeys.GLOBAL_TIME_SCALE] = action.payload.value; + }, }, }); diff --git a/pkg/ui/workspaces/cluster-ui/src/store/localStorage/localStorage.saga.ts b/pkg/ui/workspaces/cluster-ui/src/store/localStorage/localStorage.saga.ts index c2d0ca08df60..0604c38e4f7e 100644 --- a/pkg/ui/workspaces/cluster-ui/src/store/localStorage/localStorage.saga.ts +++ b/pkg/ui/workspaces/cluster-ui/src/store/localStorage/localStorage.saga.ts @@ -9,8 +9,12 @@ // licenses/APL.txt. import { AnyAction } from "redux"; -import { all, call, takeEvery } from "redux-saga/effects"; -import { actions } from "./localStorage.reducer"; +import { all, call, takeEvery, takeLatest, put } from "redux-saga/effects"; +import { actions, LocalStorageKeys } from "./localStorage.reducer"; +import { actions as sqlStatsActions } from "src/store/sqlStats"; +import { PayloadAction } from "@reduxjs/toolkit"; +import { TypedPayload } from "./localStorage.reducer"; +import { TimeScale } from "../../timeScaleDropdown"; export function* updateLocalStorageItemSaga(action: AnyAction) { const { key, value } = action.payload; @@ -21,6 +25,21 @@ export function* updateLocalStorageItemSaga(action: AnyAction) { ); } +export function* updateTimeScale( + action: PayloadAction>, +) { + yield all([put(sqlStatsActions.invalidated())]); + const { value } = action.payload; + yield call( + { context: localStorage, fn: localStorage.setItem }, + LocalStorageKeys.GLOBAL_TIME_SCALE, + JSON.stringify(value), + ); +} + export function* localStorageSaga() { - yield all([takeEvery(actions.update, updateLocalStorageItemSaga)]); + yield all([ + takeEvery(actions.update, updateLocalStorageItemSaga), + takeLatest(actions.updateTimeScale, updateTimeScale), + ]); } diff --git a/pkg/ui/workspaces/cluster-ui/src/store/sqlStats/sqlStats.reducer.ts b/pkg/ui/workspaces/cluster-ui/src/store/sqlStats/sqlStats.reducer.ts index 1a00165fdeb2..970e7aad0079 100644 --- a/pkg/ui/workspaces/cluster-ui/src/store/sqlStats/sqlStats.reducer.ts +++ b/pkg/ui/workspaces/cluster-ui/src/store/sqlStats/sqlStats.reducer.ts @@ -13,6 +13,7 @@ import { cockroach } from "@cockroachlabs/crdb-protobuf-client"; import { DOMAIN_NAME } from "../utils"; import { StatementsRequest } from "src/api/statementsApi"; import { TimeScale } from "../../timeScaleDropdown"; +import moment from "moment"; export type StatementsResponse = cockroach.server.serverpb.StatementsResponse; @@ -20,12 +21,16 @@ export type SQLStatsState = { data: StatementsResponse; lastError: Error; valid: boolean; + lastUpdated: moment.Moment | null; + inFlight: boolean; }; const initialState: SQLStatsState = { data: null, lastError: null, valid: true, + lastUpdated: null, + inFlight: false, }; export type UpdateTimeScalePayload = { @@ -40,18 +45,26 @@ const sqlStatsSlice = createSlice({ state.data = action.payload; state.valid = true; state.lastError = null; + state.lastUpdated = moment.utc(); + state.inFlight = false; }, failed: (state, action: PayloadAction) => { state.valid = false; state.lastError = action.payload; + state.lastUpdated = moment.utc(); + state.inFlight = false; }, invalidated: state => { state.valid = false; }, - refresh: (_, action: PayloadAction) => {}, - request: (_, action: PayloadAction) => {}, - updateTimeScale: (_, action: PayloadAction) => {}, - reset: (_, action: PayloadAction) => {}, + refresh: (state, _action: PayloadAction) => { + state.inFlight = true; + }, + request: (state, _action: PayloadAction) => { + state.inFlight = true; + }, + updateTimeScale: (_, _action: PayloadAction) => {}, + reset: () => {}, }, }); diff --git a/pkg/ui/workspaces/cluster-ui/src/store/sqlStats/sqlStats.sagas.spec.ts b/pkg/ui/workspaces/cluster-ui/src/store/sqlStats/sqlStats.sagas.spec.ts index fe2bcf6bcc61..bbb56879d220 100644 --- a/pkg/ui/workspaces/cluster-ui/src/store/sqlStats/sqlStats.sagas.spec.ts +++ b/pkg/ui/workspaces/cluster-ui/src/store/sqlStats/sqlStats.sagas.spec.ts @@ -20,7 +20,6 @@ import { cockroach } from "@cockroachlabs/crdb-protobuf-client"; import { getCombinedStatements } from "src/api/statementsApi"; import { resetSQLStats } from "src/api/sqlStatsApi"; import { - receivedSQLStatsSaga, refreshSQLStatsSaga, requestSQLStatsSaga, resetSQLStatsSaga, @@ -28,8 +27,20 @@ import { import { actions, reducer, SQLStatsState } from "./sqlStats.reducer"; import { actions as sqlDetailsStatsActions } from "../statementDetails/statementDetails.reducer"; import Long from "long"; +import moment from "moment"; + +const lastUpdated = moment(); describe("SQLStats sagas", () => { + let spy: jest.SpyInstance; + beforeAll(() => { + spy = jest.spyOn(moment, "utc").mockImplementation(() => lastUpdated); + }); + + afterAll(() => { + spy.mockRestore(); + }); + const payload = new cockroach.server.serverpb.StatementsRequest({ start: Long.fromNumber(1596816675), end: Long.fromNumber(1596820675), @@ -70,6 +81,8 @@ describe("SQLStats sagas", () => { data: sqlStatsResponse, lastError: null, valid: true, + lastUpdated, + inFlight: false, }) .run(); }); @@ -84,52 +97,35 @@ describe("SQLStats sagas", () => { data: null, lastError: error, valid: false, + lastUpdated, + inFlight: false, }) .run(); }); }); - describe("receivedSQLStatsSaga", () => { - it("sets valid status to false after specified period of time", () => { - const timeout = 500; - return expectSaga(receivedSQLStatsSaga, timeout) - .delay(timeout) - .put(actions.invalidated()) - .withReducer(reducer, { - data: sqlStatsResponse, - lastError: null, - valid: true, - }) - .hasFinalState({ - data: sqlStatsResponse, - lastError: null, - valid: false, - }) - .run(1000); - }); - }); - describe("resetSQLStatsSaga", () => { const resetSQLStatsResponse = new cockroach.server.serverpb.ResetSQLStatsResponse(); it("successfully resets SQL stats", () => { - return expectSaga(resetSQLStatsSaga, payload) + return expectSaga(resetSQLStatsSaga) .provide([[matchers.call.fn(resetSQLStats), resetSQLStatsResponse]]) .put(actions.invalidated()) .put(sqlDetailsStatsActions.invalidateAll()) - .put(actions.refresh()) .withReducer(reducer) .hasFinalState({ data: null, lastError: null, valid: false, + lastUpdated: null, + inFlight: false, }) .run(); }); it("returns error on failed reset", () => { const err = new Error("failed to reset"); - return expectSaga(resetSQLStatsSaga, payload) + return expectSaga(resetSQLStatsSaga) .provide([[matchers.call.fn(resetSQLStats), throwError(err)]]) .put(actions.failed(err)) .withReducer(reducer) @@ -137,6 +133,8 @@ describe("SQLStats sagas", () => { data: null, lastError: err, valid: false, + lastUpdated, + inFlight: false, }) .run(); }); diff --git a/pkg/ui/workspaces/cluster-ui/src/store/sqlStats/sqlStats.sagas.ts b/pkg/ui/workspaces/cluster-ui/src/store/sqlStats/sqlStats.sagas.ts index baa0be4f7a30..d6d6ae21911c 100644 --- a/pkg/ui/workspaces/cluster-ui/src/store/sqlStats/sqlStats.sagas.ts +++ b/pkg/ui/workspaces/cluster-ui/src/store/sqlStats/sqlStats.sagas.ts @@ -9,16 +9,7 @@ // licenses/APL.txt. import { PayloadAction } from "@reduxjs/toolkit"; -import { - all, - call, - put, - delay, - takeLatest, - takeEvery, -} from "redux-saga/effects"; -import Long from "long"; -import { cockroach } from "@cockroachlabs/crdb-protobuf-client"; +import { all, call, put, takeLatest, takeEvery } from "redux-saga/effects"; import { getCombinedStatements, StatementsRequest, @@ -30,9 +21,6 @@ import { UpdateTimeScalePayload, } from "./sqlStats.reducer"; import { actions as sqlDetailsStatsActions } from "../statementDetails/statementDetails.reducer"; -import { rootActions } from "../reducers"; -import { CACHE_INVALIDATION_PERIOD, throttleWithReset } from "src/store/utils"; -import { toDateRange } from "../../timeScaleDropdown"; export function* refreshSQLStatsSaga(action: PayloadAction) { yield put(sqlStatsActions.request(action.payload)); @@ -49,58 +37,33 @@ export function* requestSQLStatsSaga( } } -export function* receivedSQLStatsSaga(delayMs: number) { - yield delay(delayMs); - yield put(sqlStatsActions.invalidated()); -} - export function* updateSQLStatsTimeScaleSaga( action: PayloadAction, ) { const { ts } = action.payload; yield put( - localStorageActions.update({ - key: "timeScale/SQLActivity", + localStorageActions.updateTimeScale({ value: ts, }), ); - yield put(sqlStatsActions.invalidated()); - const [start, end] = toDateRange(ts); - const req = new cockroach.server.serverpb.StatementsRequest({ - combined: true, - start: Long.fromNumber(start.unix()), - end: Long.fromNumber(end.unix()), - }); - yield put(sqlStatsActions.refresh(req)); } -export function* resetSQLStatsSaga(action: PayloadAction) { +export function* resetSQLStatsSaga() { try { yield call(resetSQLStats); - yield put(sqlStatsActions.invalidated()); - yield put(sqlDetailsStatsActions.invalidateAll()); - yield put(sqlStatsActions.refresh(action.payload)); + yield all([ + put(sqlDetailsStatsActions.invalidateAll()), + put(sqlStatsActions.invalidated()), + ]); } catch (e) { yield put(sqlStatsActions.failed(e)); } } -export function* sqlStatsSaga( - cacheInvalidationPeriod: number = CACHE_INVALIDATION_PERIOD, -) { +export function* sqlStatsSaga() { yield all([ - throttleWithReset( - cacheInvalidationPeriod, - sqlStatsActions.refresh, - [sqlStatsActions.invalidated, rootActions.resetState], - refreshSQLStatsSaga, - ), + takeLatest(sqlStatsActions.refresh, refreshSQLStatsSaga), takeLatest(sqlStatsActions.request, requestSQLStatsSaga), - takeLatest( - sqlStatsActions.received, - receivedSQLStatsSaga, - cacheInvalidationPeriod, - ), takeLatest(sqlStatsActions.updateTimeScale, updateSQLStatsTimeScaleSaga), takeEvery(sqlStatsActions.reset, resetSQLStatsSaga), ]); diff --git a/pkg/ui/workspaces/cluster-ui/src/store/utils/selectors.ts b/pkg/ui/workspaces/cluster-ui/src/store/utils/selectors.ts new file mode 100644 index 000000000000..9b986ae61c7d --- /dev/null +++ b/pkg/ui/workspaces/cluster-ui/src/store/utils/selectors.ts @@ -0,0 +1,28 @@ +// Copyright 2022 The Cockroach Authors. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0, included in the file +// licenses/APL.txt. + +import { createSelector } from "reselect"; +import { LocalStorageKeys } from "../localStorage"; +import { AppState } from "../reducers"; + +export const adminUISelector = createSelector( + (state: AppState) => state.adminUI, + adminUiState => adminUiState, +); + +export const localStorageSelector = createSelector( + adminUISelector, + adminUiState => adminUiState?.localStorage, +); + +export const selectTimeScale = createSelector( + localStorageSelector, + localStorage => localStorage[LocalStorageKeys.GLOBAL_TIME_SCALE], +); diff --git a/pkg/ui/workspaces/cluster-ui/src/transactionsPage/transactionsPage.tsx b/pkg/ui/workspaces/cluster-ui/src/transactionsPage/transactionsPage.tsx index d7b95979e889..898436ea2e61 100644 --- a/pkg/ui/workspaces/cluster-ui/src/transactionsPage/transactionsPage.tsx +++ b/pkg/ui/workspaces/cluster-ui/src/transactionsPage/transactionsPage.tsx @@ -84,6 +84,9 @@ interface TState { export interface TransactionsPageStateProps { columns: string[]; data: IStatementsResponse; + isDataValid: boolean; + isReqInFlight: boolean; + lastUpdated: moment.Moment | null; timeScale: TimeScale; error?: Error | null; filters: Filters; @@ -99,7 +102,7 @@ export interface TransactionsPageDispatchProps { refreshData: (req: StatementsRequest) => void; refreshNodes: () => void; refreshUserSQLRoles: () => void; - resetSQLStats: (req: StatementsRequest) => void; + resetSQLStats: () => void; onTimeScaleChange?: (ts: TimeScale) => void; onColumnsChange?: (selectedColumns: string[]) => void; onFilterChange?: (value: Filters) => void; @@ -139,14 +142,6 @@ export class TransactionsPage extends React.Component< }; const stateFromHistory = this.getStateFromHistory(); this.state = merge(this.state, stateFromHistory); - - // In case the user selected a option not available on this page, - // force a selection of a valid option. This is necessary for the case - // where the value 10/30 min is selected on the Metrics page. - const ts = getValidOption(this.props.timeScale, timeScale1hMinOptions); - if (ts !== this.props.timeScale) { - this.changeTimeScale(ts); - } } getStateFromHistory = (): Partial => { @@ -191,17 +186,31 @@ export class TransactionsPage extends React.Component< const req = statementsRequestFromProps(this.props); this.props.refreshData(req); }; + resetSQLStats = (): void => { - const req = statementsRequestFromProps(this.props); - this.props.resetSQLStats(req); + this.props.resetSQLStats(); }; componentDidMount(): void { - this.refreshData(); - this.props.refreshUserSQLRoles(); + // In case the user selected a option not available on this page, + // force a selection of a valid option. This is necessary for the case + // where the value 10/30 min is selected on the Metrics page. + const ts = getValidOption(this.props.timeScale, timeScale1hMinOptions); + if (ts !== this.props.timeScale) { + this.changeTimeScale(ts); + } else if ( + !this.props.isDataValid || + !this.props.data || + !this.props.lastUpdated + ) { + this.refreshData(); + } + if (!this.props.isTenant) { this.props.refreshNodes(); } + + this.props.refreshUserSQLRoles(); } updateQueryParams(): void { @@ -236,11 +245,18 @@ export class TransactionsPage extends React.Component< ); } - componentDidUpdate(): void { + componentDidUpdate(prevProps: TransactionsPageProps): void { this.updateQueryParams(); if (!this.props.isTenant) { this.props.refreshNodes(); } + + if ( + prevProps.timeScale !== this.props.timeScale || + (prevProps.isDataValid && !this.props.isDataValid) + ) { + this.refreshData(); + } } onChangeSortSetting = (ss: SortSetting): void => { @@ -397,7 +413,8 @@ export class TransactionsPage extends React.Component< data?.transactions || [], internal_app_name_prefix, ); - const longLoadingMessage = !this.props?.data && ( + + const longLoadingMessage = (
{ @@ -464,7 +481,7 @@ export class TransactionsPage extends React.Component< : generateRegionNode(t, statements, nodeRegions), })); const { current, pageSize } = pagination; - const hasData = data.transactions?.length > 0; + const hasData = data?.transactions?.length > 0; const isUsedFilter = search?.length > 0; // Creates a list of all possible columns, @@ -560,7 +577,7 @@ export class TransactionsPage extends React.Component< }) } /> - {longLoadingMessage} + {this.props.isReqInFlight && longLoadingMessage}
); diff --git a/pkg/ui/workspaces/cluster-ui/src/transactionsPage/transactionsPageConnected.tsx b/pkg/ui/workspaces/cluster-ui/src/transactionsPage/transactionsPageConnected.tsx index 80253d1ea809..6b763671a9b5 100644 --- a/pkg/ui/workspaces/cluster-ui/src/transactionsPage/transactionsPageConnected.tsx +++ b/pkg/ui/workspaces/cluster-ui/src/transactionsPage/transactionsPageConnected.tsx @@ -30,7 +30,12 @@ import { } from "./transactionsPage.selectors"; import { selectHasAdminRole, selectIsTenant } from "../store/uiConfig"; import { nodeRegionsByIDSelector } from "../store/nodes"; -import { selectTimeScale } from "src/statementsPage/statementsPage.selectors"; +import { + selectStatementsLastUpdated, + selectStatementsDataValid, + selectStatementsDataInFlight, +} from "src/statementsPage/statementsPage.selectors"; +import { selectTimeScale } from "../store/utils/selectors"; import { StatementsRequest } from "src/api/statementsApi"; import { actions as localStorageActions } from "../store/localStorage"; import { Filters } from "../queryFilter"; @@ -46,6 +51,9 @@ export const TransactionsPageConnected = withRouter( (state: AppState) => ({ columns: selectTxnColumns(state), data: selectTransactionsData(state), + isDataValid: selectStatementsDataValid(state), + isReqInFlight: selectStatementsDataInFlight(state), + lastUpdated: selectStatementsLastUpdated(state), timeScale: selectTimeScale(state), error: selectTransactionsLastError(state), filters: selectFilters(state), @@ -61,8 +69,7 @@ export const TransactionsPageConnected = withRouter( refreshNodes: () => dispatch(nodesActions.refresh()), refreshUserSQLRoles: () => dispatch(uiConfigActions.refreshUserSQLRoles()), - resetSQLStats: (req: StatementsRequest) => - dispatch(sqlStatsActions.reset(req)), + resetSQLStats: () => dispatch(sqlStatsActions.reset()), onTimeScaleChange: (ts: TimeScale) => { dispatch( sqlStatsActions.updateTimeScale({ diff --git a/pkg/ui/workspaces/db-console/src/redux/sqlStats/sqlStatsActions.ts b/pkg/ui/workspaces/db-console/src/redux/sqlStats/sqlStatsActions.ts index 4a257e7c2e75..453d0117fec4 100644 --- a/pkg/ui/workspaces/db-console/src/redux/sqlStats/sqlStatsActions.ts +++ b/pkg/ui/workspaces/db-console/src/redux/sqlStats/sqlStatsActions.ts @@ -9,29 +9,14 @@ // licenses/APL.txt. import { Action } from "redux"; -import { PayloadAction } from "@reduxjs/toolkit"; -import { cockroach } from "src/js/protos"; export const RESET_SQL_STATS = "cockroachui/sqlStats/RESET_SQL_STATS"; -export const RESET_SQL_STATS_COMPLETE = - "cockroachui/sqlStats/RESET_SQL_STATS_COMPLETE"; export const RESET_SQL_STATS_FAILED = "cockroachui/sqlStats/RESET_SQL_STATS_FAILED"; -import StatementsRequest = cockroach.server.serverpb.StatementsRequest; - -export function resetSQLStatsAction( - req: StatementsRequest, -): PayloadAction { +export function resetSQLStatsAction(): Action { return { type: RESET_SQL_STATS, - payload: req, - }; -} - -export function resetSQLStatsCompleteAction(): Action { - return { - type: RESET_SQL_STATS_COMPLETE, }; } diff --git a/pkg/ui/workspaces/db-console/src/redux/sqlStats/sqlStatsSagas.spec.ts b/pkg/ui/workspaces/db-console/src/redux/sqlStats/sqlStatsSagas.spec.ts index 19c2c6c9c46c..73dd0c8837c5 100644 --- a/pkg/ui/workspaces/db-console/src/redux/sqlStats/sqlStatsSagas.spec.ts +++ b/pkg/ui/workspaces/db-console/src/redux/sqlStats/sqlStatsSagas.spec.ts @@ -11,11 +11,7 @@ import { expectSaga } from "redux-saga-test-plan"; import { call } from "redux-saga-test-plan/matchers"; -import { - resetSQLStatsFailedAction, - resetSQLStatsCompleteAction, - resetSQLStatsAction, -} from "./sqlStatsActions"; +import { resetSQLStatsFailedAction } from "./sqlStatsActions"; import { resetSQLStatsSaga } from "./sqlStatsSagas"; import { resetSQLStats } from "src/util/api"; import { @@ -26,24 +22,17 @@ import { import { throwError } from "redux-saga-test-plan/providers"; import { cockroach } from "src/js/protos"; -import Long from "long"; describe("SQL Stats sagas", () => { describe("resetSQLStatsSaga", () => { - const payload = new cockroach.server.serverpb.StatementsRequest({ - start: Long.fromNumber(1596816675), - end: Long.fromNumber(1596820675), - combined: false, - }); const resetSQLStatsResponse = new cockroach.server.serverpb.ResetSQLStatsResponse(); it("successfully resets SQL stats", () => { // TODO(azhng): validate refreshStatement() actions once we can figure out // how to get ThunkAction to work with sagas. - return expectSaga(resetSQLStatsSaga, resetSQLStatsAction(payload)) + return expectSaga(resetSQLStatsSaga) .withReducer(apiReducersReducer) .provide([[call.fn(resetSQLStats), resetSQLStatsResponse]]) - .put(resetSQLStatsCompleteAction()) .put(invalidateStatements()) .put(invalidateAllStatementDetails()) .run(); @@ -51,7 +40,7 @@ describe("SQL Stats sagas", () => { it("returns error on failed reset", () => { const err = new Error("failed to reset"); - return expectSaga(resetSQLStatsSaga, resetSQLStatsAction(payload)) + return expectSaga(resetSQLStatsSaga) .provide([[call.fn(resetSQLStats), throwError(err)]]) .put(resetSQLStatsFailedAction()) .run(); diff --git a/pkg/ui/workspaces/db-console/src/redux/sqlStats/sqlStatsSagas.ts b/pkg/ui/workspaces/db-console/src/redux/sqlStats/sqlStatsSagas.ts index 0b44677d2223..d313b4f217ed 100644 --- a/pkg/ui/workspaces/db-console/src/redux/sqlStats/sqlStatsSagas.ts +++ b/pkg/ui/workspaces/db-console/src/redux/sqlStats/sqlStatsSagas.ts @@ -11,22 +11,15 @@ import { cockroach } from "src/js/protos"; import { resetSQLStats } from "src/util/api"; import { all, call, put, takeEvery } from "redux-saga/effects"; -import { - RESET_SQL_STATS, - resetSQLStatsCompleteAction, - resetSQLStatsFailedAction, -} from "./sqlStatsActions"; +import { RESET_SQL_STATS, resetSQLStatsFailedAction } from "./sqlStatsActions"; import { invalidateAllStatementDetails, invalidateStatements, - refreshStatements, } from "src/redux/apiReducers"; import ResetSQLStatsRequest = cockroach.server.serverpb.ResetSQLStatsRequest; -import StatementsRequest = cockroach.server.serverpb.StatementsRequest; -import { PayloadAction } from "@reduxjs/toolkit"; -export function* resetSQLStatsSaga(action: PayloadAction) { +export function* resetSQLStatsSaga() { const resetSQLStatsRequest = new ResetSQLStatsRequest({ // reset_persisted_stats is set to true in order to clear both // in-memory stats as well as persisted stats. @@ -34,10 +27,10 @@ export function* resetSQLStatsSaga(action: PayloadAction) { }); try { yield call(resetSQLStats, resetSQLStatsRequest); - yield put(resetSQLStatsCompleteAction()); - yield put(invalidateStatements()); - yield put(invalidateAllStatementDetails()); - yield put(refreshStatements(action.payload) as any); + yield all([ + put(invalidateStatements()), + put(invalidateAllStatementDetails()), + ]); } catch (e) { yield put(resetSQLStatsFailedAction()); } diff --git a/pkg/ui/workspaces/db-console/src/redux/statements/statementsSagas.ts b/pkg/ui/workspaces/db-console/src/redux/statements/statementsSagas.ts index 446a00122c5c..6e1fd84b10a6 100644 --- a/pkg/ui/workspaces/db-console/src/redux/statements/statementsSagas.ts +++ b/pkg/ui/workspaces/db-console/src/redux/statements/statementsSagas.ts @@ -30,19 +30,15 @@ import { import { cockroach } from "src/js/protos"; import CreateStatementDiagnosticsReportRequest = cockroach.server.serverpb.CreateStatementDiagnosticsReportRequest; import CancelStatementDiagnosticsReportRequest = cockroach.server.serverpb.CancelStatementDiagnosticsReportRequest; -import CombinedStatementsRequest = cockroach.server.serverpb.StatementsRequest; import { invalidateStatementDiagnosticsRequests, refreshStatementDiagnosticsRequests, - invalidateStatements, - refreshStatements, } from "src/redux/apiReducers"; import { createStatementDiagnosticsAlertLocalSetting, cancelStatementDiagnosticsAlertLocalSetting, } from "src/redux/alerts"; -import { TimeScale, toDateRange } from "@cockroachlabs/cluster-ui"; -import Long from "long"; +import { TimeScale } from "@cockroachlabs/cluster-ui"; import { setTimeScale } from "src/redux/timeScale"; export function* createDiagnosticsReportSaga( @@ -158,14 +154,6 @@ export function* setCombinedStatementsTimeScaleSaga( const ts = action.payload; yield put(setTimeScale(ts)); - const [start, end] = toDateRange(ts); - const req = new CombinedStatementsRequest({ - combined: true, - start: Long.fromNumber(start.unix()), - end: Long.fromNumber(end.unix()), - }); - yield put(invalidateStatements()); - yield put(refreshStatements(req) as any); } export function* statementsSaga() { diff --git a/pkg/ui/workspaces/db-console/src/redux/timeScale.ts b/pkg/ui/workspaces/db-console/src/redux/timeScale.ts index 2663ec34732a..69db1a39c2d0 100644 --- a/pkg/ui/workspaces/db-console/src/redux/timeScale.ts +++ b/pkg/ui/workspaces/db-console/src/redux/timeScale.ts @@ -25,6 +25,7 @@ import { getValueFromSessionStorage, setLocalSetting, } from "src/redux/localsettings"; +import { invalidateStatements } from "./apiReducers"; export const SET_SCALE = "cockroachui/timewindow/SET_SCALE"; export const SET_METRICS_MOVING_WINDOW = @@ -138,14 +139,17 @@ export function timeScaleReducer( } case SET_METRICS_MOVING_WINDOW: { const { payload: tw } = action as PayloadAction; - state = _.cloneDeep(state); + // We don't want to deep clone the state here, because we're + // not changing the scale object here. For components observing + // timescale changes, we don't want to update them unnecessarily. + state = { ...state, metricsTime: _.cloneDeep(state.metricsTime) }; state.metricsTime.currentWindow = tw; state.metricsTime.shouldUpdateMetricsWindowFromScale = false; return state; } case SET_METRICS_FIXED_WINDOW: { const { payload: data } = action as PayloadAction; - state = _.cloneDeep(state); + state = { ...state, metricsTime: _.cloneDeep(state.metricsTime) }; state.metricsTime.currentWindow = data; state.metricsTime.isFixedWindow = true; state.metricsTime.shouldUpdateMetricsWindowFromScale = false; @@ -251,5 +255,6 @@ export const adjustTimeScale = ( export function* timeScaleSaga() { yield takeEvery(SET_SCALE, function*({ payload }: PayloadAction) { yield put(setLocalSetting(TIME_SCALE_SESSION_STORAGE_KEY, payload)); + yield put(invalidateStatements()); }); } diff --git a/pkg/ui/workspaces/db-console/src/util/constants.ts b/pkg/ui/workspaces/db-console/src/util/constants.ts index dab15b32618b..5485bf8bea08 100644 --- a/pkg/ui/workspaces/db-console/src/util/constants.ts +++ b/pkg/ui/workspaces/db-console/src/util/constants.ts @@ -13,18 +13,14 @@ import { util } from "@cockroachlabs/cluster-ui"; export const indexNameAttr = "index_name"; export const { - aggregationIntervalAttr, aggregatedTsAttr, appAttr, appNamesAttr, - dashQueryString, dashboardNameAttr, databaseAttr, databaseNameAttr, - fingerprintIDAttr, implicitTxnAttr, nodeIDAttr, - nodeQueryString, rangeIDAttr, statementAttr, sessionAttr, @@ -32,6 +28,5 @@ export const { tableNameAttr, txnFingerprintIdAttr, unset, - viewAttr, REMOTE_DEBUGGING_ERROR_TEXT, } = util; diff --git a/pkg/ui/workspaces/db-console/src/views/statements/statements.spec.tsx b/pkg/ui/workspaces/db-console/src/views/statements/statements.spec.tsx index 7ec8b63d33d8..0ab70a719640 100644 --- a/pkg/ui/workspaces/db-console/src/views/statements/statements.spec.tsx +++ b/pkg/ui/workspaces/db-console/src/views/statements/statements.spec.tsx @@ -601,7 +601,7 @@ function makeStateWithStatementsAndLastReset( }, }, localSettings: { - "timeScale/SQLActivity": timeScale, + [localStorage.GlOBAL_TIME_SCALE]: timeScale, }, timeScale: { scale: timeScale, diff --git a/pkg/ui/workspaces/db-console/src/views/statements/statementsPage.tsx b/pkg/ui/workspaces/db-console/src/views/statements/statementsPage.tsx index fbed4a5b27d5..d42278fc87ca 100644 --- a/pkg/ui/workspaces/db-console/src/views/statements/statementsPage.tsx +++ b/pkg/ui/workspaces/db-console/src/views/statements/statementsPage.tsx @@ -290,6 +290,9 @@ export default withRouter( search: searchLocalSetting.selector(state), sortSetting: sortSettingLocalSetting.selector(state), statements: selectStatements(state, props), + isDataValid: state?.cachedData?.statements?.valid ?? false, + isReqInFlight: state?.cachedData?.statements?.inFlight ?? false, + lastUpdated: state?.cachedData?.statements?.setAt, statementsError: state.cachedData.statements.lastError, totalFingerprints: selectTotalFingerprints(state), hasViewActivityRedactedRole: selectHasViewActivityRedactedRole(state), diff --git a/pkg/ui/workspaces/db-console/src/views/transactions/transactionsPage.tsx b/pkg/ui/workspaces/db-console/src/views/transactions/transactionsPage.tsx index 91b2eb9a733a..f97693b8bcb5 100644 --- a/pkg/ui/workspaces/db-console/src/views/transactions/transactionsPage.tsx +++ b/pkg/ui/workspaces/db-console/src/views/transactions/transactionsPage.tsx @@ -92,6 +92,9 @@ const TransactionsPageConnected = withRouter( (state: AdminUIState) => ({ columns: transactionColumnsLocalSetting.selectorToArray(state), data: selectData(state), + isDataValid: state?.cachedData?.statements?.valid ?? false, + isReqInFlight: state?.cachedData?.statements?.inFlight ?? false, + lastUpdated: state?.cachedData?.statements?.setAt, timeScale: selectTimeScale(state), error: selectLastError(state), filters: filtersLocalSetting.selector(state),