From e36a054a86821b4dbc761b5ec2a34073513b894a Mon Sep 17 00:00:00 2001 From: Eric Harmeling Date: Mon, 28 Nov 2022 13:15:59 -0500 Subject: [PATCH] ui: fix polling for statement and transaction details pages This commit moves polling to the statement and transaction details page components from the cached data reducer and sagas, using the same approach as #85772. Fixes #91297. Release note (ui change): The statement fingerprint details page in the Console no longer infinitely loads after 5 minutes. --- .../statementDetails.fixture.ts | 3 + .../statementDetails.selectors.ts | 11 ++- .../src/statementDetails/statementDetails.tsx | 81 ++++++++++++------- .../statementDetailsConnected.ts | 7 +- .../statementDetails.reducer.ts | 6 ++ .../statementDetails.sagas.spec.ts | 16 ++++ .../statementDetails.sagas.ts | 24 +----- .../transactionDetails/transactionDetails.tsx | 56 ++++++++++++- .../transactionDetailsConnected.tsx | 7 +- .../src/transactionsPage/transactionsPage.tsx | 2 +- .../db-console/src/redux/apiReducers.ts | 2 +- .../src/views/statements/statementDetails.tsx | 17 ++-- .../views/transactions/transactionDetails.tsx | 8 +- 13 files changed, 172 insertions(+), 68 deletions(-) diff --git a/pkg/ui/workspaces/cluster-ui/src/statementDetails/statementDetails.fixture.ts b/pkg/ui/workspaces/cluster-ui/src/statementDetails/statementDetails.fixture.ts index f7a81172c5ab..e5061d4e1a6a 100644 --- a/pkg/ui/workspaces/cluster-ui/src/statementDetails/statementDetails.fixture.ts +++ b/pkg/ui/workspaces/cluster-ui/src/statementDetails/statementDetails.fixture.ts @@ -17,6 +17,8 @@ import { StatementDetailsResponse } from "../api"; const history = createMemoryHistory({ initialEntries: ["/statements"] }); +const lastUpdated = moment("Nov 28 2022 01:30:00 GMT"); + const statementDetailsNoData: StatementDetailsResponse = { statement: { metadata: { @@ -806,6 +808,7 @@ export const getStatementDetailsPropsFixture = ( }, }, isLoading: false, + lastUpdated: lastUpdated, timeScale: { windowSize: moment.duration(5, "day"), sampleSize: moment.duration(5, "minutes"), diff --git a/pkg/ui/workspaces/cluster-ui/src/statementDetails/statementDetails.selectors.ts b/pkg/ui/workspaces/cluster-ui/src/statementDetails/statementDetails.selectors.ts index c1345c64fbb8..7fbfb2fd46a0 100644 --- a/pkg/ui/workspaces/cluster-ui/src/statementDetails/statementDetails.selectors.ts +++ b/pkg/ui/workspaces/cluster-ui/src/statementDetails/statementDetails.selectors.ts @@ -22,6 +22,8 @@ import { import { cockroach } from "@cockroachlabs/crdb-protobuf-client"; import { TimeScale, toRoundedDateRange } from "../timeScaleDropdown"; import { selectTimeScale } from "../statementsPage/statementsPage.selectors"; +import moment from "moment"; + type StatementDetailsResponseMessage = cockroach.server.serverpb.StatementDetailsResponse; @@ -41,6 +43,7 @@ export const selectStatementDetails = createSelector( statementDetails: StatementDetailsResponseMessage; isLoading: boolean; lastError: Error; + lastUpdated: moment.Moment | null; } => { // Since the aggregation interval is 1h, we want to round the selected timeScale to include // the full hour. If a timeScale is between 14:32 - 15:17 we want to search for values @@ -59,9 +62,15 @@ export const selectStatementDetails = createSelector( statementDetails: statementDetailsStatsData[key].data, isLoading: statementDetailsStatsData[key].inFlight, lastError: statementDetailsStatsData[key].lastError, + lastUpdated: statementDetailsStatsData[key].lastUpdated, }; } - return { statementDetails: null, isLoading: true, lastError: null }; + return { + statementDetails: null, + isLoading: true, + lastError: null, + lastUpdated: null, + }; }, ); diff --git a/pkg/ui/workspaces/cluster-ui/src/statementDetails/statementDetails.tsx b/pkg/ui/workspaces/cluster-ui/src/statementDetails/statementDetails.tsx index f2d1396ef79e..bb70a493407f 100644 --- a/pkg/ui/workspaces/cluster-ui/src/statementDetails/statementDetails.tsx +++ b/pkg/ui/workspaces/cluster-ui/src/statementDetails/statementDetails.tsx @@ -132,6 +132,7 @@ export interface StatementDetailsStateProps { statementDetails: StatementDetailsResponse; isLoading: boolean; statementsError: Error | null; + lastUpdated: moment.Moment | null; timeScale: TimeScale; nodeRegions: { [nodeId: string]: string }; diagnosticsReports: StatementDiagnosticsReport[]; @@ -147,15 +148,13 @@ const cx = classNames.bind(styles); const summaryCardStylesCx = classNames.bind(summaryCardStyles); const timeScaleStylesCx = classNames.bind(timeScaleStyles); -function getStatementDetailsRequest( - timeScale: TimeScale, - statementFingerprintID: string, - location: Location, +function getStatementDetailsRequestFromProps( + props: StatementDetailsProps, ): cockroach.server.serverpb.StatementDetailsRequest { - const [start, end] = toRoundedDateRange(timeScale); + const [start, end] = toRoundedDateRange(props.timeScale); return new cockroach.server.serverpb.StatementDetailsRequest({ - fingerprint_id: statementFingerprintID, - app_names: queryByName(location, appNamesAttr)?.split(","), + fingerprint_id: props.statementFingerprintID, + app_names: queryByName(props.location, appNamesAttr)?.split(","), start: Long.fromNumber(start.unix()), end: Long.fromNumber(end.unix()), }); @@ -200,6 +199,7 @@ export class StatementDetails extends React.Component< StatementDetailsState > { activateDiagnosticsRef: React.RefObject; + refreshDataTimeout: NodeJS.Timeout; constructor(props: StatementDetailsProps) { super(props); @@ -218,7 +218,7 @@ export class StatementDetails extends React.Component< // 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.props.onTimeScaleChange(ts); + this.changeTimeScale(ts); } } @@ -234,17 +234,33 @@ export class StatementDetails extends React.Component< hasDiagnosticReports = (): boolean => this.props.diagnosticsReports.length > 0; - refreshStatementDetails = ( - timeScale: TimeScale, - statementFingerprintID: string, - location: Location, - ): void => { - const req = getStatementDetailsRequest( - timeScale, - statementFingerprintID, - location, - ); + changeTimeScale = (ts: TimeScale): void => { + if (this.props.onTimeScaleChange) { + this.props.onTimeScaleChange(ts); + } + this.resetPolling(ts.key); + }; + + clearRefreshDataTimeout() { + if (this.refreshDataTimeout !== null) { + clearTimeout(this.refreshDataTimeout); + } + } + + resetPolling(key: string) { + this.clearRefreshDataTimeout(); + if (key !== "Custom") { + this.refreshDataTimeout = setTimeout( + this.refreshStatementDetails, + 300000, // 5 minutes + ); + } + } + + refreshStatementDetails = (): void => { + const req = getStatementDetailsRequestFromProps(this.props); this.props.refreshStatementDetails(req); + this.resetPolling(this.props.timeScale.key); }; handleResize = (): void => { @@ -260,13 +276,24 @@ export class StatementDetails extends React.Component< }; componentDidMount(): void { + this.refreshStatementDetails(); window.addEventListener("resize", this.handleResize); this.handleResize(); - this.refreshStatementDetails( - this.props.timeScale, - this.props.statementFingerprintID, - this.props.location, - ); + // For the first data fetch for this page, we refresh if there are: + // - Last updated is null (no statement details fetched previously) + // - The time interval is not custom, i.e. we have a moving window + // in which case we poll every 5 minutes. For the first fetch we will + // calculate the next time to refresh based on when the data was last + // updated. + if (this.props.timeScale.key !== "Custom" || !this.props.lastUpdated) { + const now = moment(); + const nextRefresh = + this.props.lastUpdated?.clone().add(5, "minutes") || now; + setTimeout( + this.refreshStatementDetails, + Math.max(0, nextRefresh.diff(now, "milliseconds")), + ); + } this.props.refreshUserSQLRoles(); this.props.refreshNodes(); if (!this.props.isTenant) { @@ -284,11 +311,7 @@ export class StatementDetails extends React.Component< prevProps.statementFingerprintID != this.props.statementFingerprintID || prevProps.location != this.props.location ) { - this.refreshStatementDetails( - this.props.timeScale, - this.props.statementFingerprintID, - this.props.location, - ); + this.refreshStatementDetails(); } this.props.refreshNodes(); @@ -754,7 +777,7 @@ export class StatementDetails extends React.Component< diff --git a/pkg/ui/workspaces/cluster-ui/src/statementDetails/statementDetailsConnected.ts b/pkg/ui/workspaces/cluster-ui/src/statementDetails/statementDetailsConnected.ts index 6b2c364edd46..2766986062c2 100644 --- a/pkg/ui/workspaces/cluster-ui/src/statementDetails/statementDetailsConnected.ts +++ b/pkg/ui/workspaces/cluster-ui/src/statementDetails/statementDetailsConnected.ts @@ -48,16 +48,15 @@ import { getMatchParamByName, statementAttr } from "../util"; // For tenant cases, we don't show information about node, regions and // diagnostics. const mapStateToProps = (state: AppState, props: RouteComponentProps) => { - const { statementDetails, isLoading, lastError } = selectStatementDetails( - state, - props, - ); + const { statementDetails, isLoading, lastError, lastUpdated } = + selectStatementDetails(state, props); const statementFingerprint = statementDetails?.statement.metadata.query; return { statementFingerprintID: getMatchParamByName(props.match, statementAttr), statementDetails, isLoading: isLoading, statementsError: lastError, + lastUpdated: lastUpdated, timeScale: selectTimeScale(state), nodeRegions: nodeRegionsByIDSelector(state), diagnosticsReports: diff --git a/pkg/ui/workspaces/cluster-ui/src/store/statementDetails/statementDetails.reducer.ts b/pkg/ui/workspaces/cluster-ui/src/store/statementDetails/statementDetails.reducer.ts index 400b79576d35..77aec1076ef6 100644 --- a/pkg/ui/workspaces/cluster-ui/src/store/statementDetails/statementDetails.reducer.ts +++ b/pkg/ui/workspaces/cluster-ui/src/store/statementDetails/statementDetails.reducer.ts @@ -17,12 +17,14 @@ import { StatementDetailsResponseWithKey, } from "src/api/statementsApi"; import { generateStmtDetailsToID } from "../../util"; +import moment from "moment"; export type SQLDetailsStatsState = { data: StatementDetailsResponse; lastError: Error; valid: boolean; inFlight: boolean; + lastUpdated: moment.Moment | null; }; export type SQLDetailsStatsReducerState = { @@ -48,6 +50,7 @@ const sqlDetailsStatsSlice = createSlice({ valid: true, lastError: null, inFlight: false, + lastUpdated: moment.utc(), }; }, failed: (state, action: PayloadAction) => { @@ -56,6 +59,7 @@ const sqlDetailsStatsSlice = createSlice({ valid: false, lastError: action.payload.err, inFlight: false, + lastUpdated: moment.utc(), }; }, invalidated: (state, action: PayloadAction<{ key: string }>) => { @@ -81,6 +85,7 @@ const sqlDetailsStatsSlice = createSlice({ valid: false, lastError: null, inFlight: true, + lastUpdated: null, }; }, request: (state, action: PayloadAction) => { @@ -97,6 +102,7 @@ const sqlDetailsStatsSlice = createSlice({ valid: false, lastError: null, inFlight: true, + lastUpdated: null, }; }, }, diff --git a/pkg/ui/workspaces/cluster-ui/src/store/statementDetails/statementDetails.sagas.spec.ts b/pkg/ui/workspaces/cluster-ui/src/store/statementDetails/statementDetails.sagas.spec.ts index 0f12e1fb7eba..4a17d3d85fd3 100644 --- a/pkg/ui/workspaces/cluster-ui/src/store/statementDetails/statementDetails.sagas.spec.ts +++ b/pkg/ui/workspaces/cluster-ui/src/store/statementDetails/statementDetails.sagas.spec.ts @@ -28,10 +28,24 @@ import { reducer, SQLDetailsStatsReducerState, } from "./statementDetails.reducer"; + +import moment from "moment"; + +const lastUpdated = moment(); + export type StatementDetailsRequest = cockroach.server.serverpb.StatementDetailsRequest; describe("SQLDetailsStats sagas", () => { + let spy: jest.SpyInstance; + beforeAll(() => { + spy = jest.spyOn(moment, "utc").mockImplementation(() => lastUpdated); + }); + + afterAll(() => { + spy.mockRestore(); + }); + const action: PayloadAction = { payload: cockroach.server.serverpb.StatementDetailsRequest.create({ fingerprint_id: "SELECT * FROM crdb_internal.node_build_info", @@ -665,6 +679,7 @@ describe("SQLDetailsStats sagas", () => { lastError: null, valid: true, inFlight: false, + lastUpdated: lastUpdated, }, }, }) @@ -690,6 +705,7 @@ describe("SQLDetailsStats sagas", () => { lastError: error, valid: false, inFlight: false, + lastUpdated: lastUpdated, }, }, }) diff --git a/pkg/ui/workspaces/cluster-ui/src/store/statementDetails/statementDetails.sagas.ts b/pkg/ui/workspaces/cluster-ui/src/store/statementDetails/statementDetails.sagas.ts index e5d8bb6612f6..81298eb6df5a 100644 --- a/pkg/ui/workspaces/cluster-ui/src/store/statementDetails/statementDetails.sagas.ts +++ b/pkg/ui/workspaces/cluster-ui/src/store/statementDetails/statementDetails.sagas.ts @@ -9,7 +9,7 @@ // licenses/APL.txt. import { PayloadAction } from "@reduxjs/toolkit"; -import { all, call, put, delay, takeLatest } from "redux-saga/effects"; +import { all, call, put, takeLatest } from "redux-saga/effects"; import { ErrorWithKey, getStatementDetails, @@ -17,7 +17,6 @@ import { StatementDetailsResponseWithKey, } from "src/api/statementsApi"; import { actions as sqlDetailsStatsActions } from "./statementDetails.reducer"; -import { CACHE_INVALIDATION_PERIOD } from "src/store/utils"; import { generateStmtDetailsToID } from "../../util"; export function* refreshSQLDetailsStatsSaga( @@ -53,28 +52,9 @@ export function* requestSQLDetailsStatsSaga( } } -export function receivedSQLDetailsStatsSagaFactory(delayMs: number) { - return function* receivedSQLDetailsStatsSaga( - action: PayloadAction, - ) { - yield delay(delayMs); - yield put( - sqlDetailsStatsActions.invalidated({ - key: action?.payload.key, - }), - ); - }; -} - -export function* sqlDetailsStatsSaga( - cacheInvalidationPeriod: number = CACHE_INVALIDATION_PERIOD, -) { +export function* sqlDetailsStatsSaga() { yield all([ takeLatest(sqlDetailsStatsActions.refresh, refreshSQLDetailsStatsSaga), takeLatest(sqlDetailsStatsActions.request, requestSQLDetailsStatsSaga), - takeLatest( - sqlDetailsStatsActions.received, - receivedSQLDetailsStatsSagaFactory(cacheInvalidationPeriod), - ), ]); } diff --git a/pkg/ui/workspaces/cluster-ui/src/transactionDetails/transactionDetails.tsx b/pkg/ui/workspaces/cluster-ui/src/transactionDetails/transactionDetails.tsx index 964793161c08..f1b48f7723c7 100644 --- a/pkg/ui/workspaces/cluster-ui/src/transactionDetails/transactionDetails.tsx +++ b/pkg/ui/workspaces/cluster-ui/src/transactionDetails/transactionDetails.tsx @@ -70,8 +70,9 @@ import { timeScaleToString, toRoundedDateRange, } from "../timeScaleDropdown"; -import timeScaleStyles from "../timeScaleDropdown/timeScale.module.scss"; +import moment from "moment"; +import timeScaleStyles from "../timeScaleDropdown/timeScale.module.scss"; const { containerClass } = tableClasses; const cx = classNames.bind(statementsStyles); const timeScaleStylesCx = classNames.bind(timeScaleStyles); @@ -92,6 +93,7 @@ export interface TransactionDetailsStateProps { transaction: Transaction; transactionFingerprintId: string; isLoading: boolean; + lastUpdated: moment.Moment | null; } export interface TransactionDetailsDispatchProps { @@ -126,6 +128,8 @@ export class TransactionDetails extends React.Component< TransactionDetailsProps, TState > { + refreshDataTimeout: NodeJS.Timeout; + constructor(props: TransactionDetailsProps) { super(props); this.state = { @@ -146,7 +150,7 @@ export class TransactionDetails extends React.Component< // 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.props.onTimeScaleChange(ts); + this.changeTimeScale(ts); } } @@ -188,18 +192,64 @@ export class TransactionDetails extends React.Component< } }; + changeTimeScale = (ts: TimeScale): void => { + if (this.props.onTimeScaleChange) { + this.props.onTimeScaleChange(ts); + } + this.resetPolling(ts.key); + }; + + clearRefreshDataTimeout() { + if (this.refreshDataTimeout != null) { + clearTimeout(this.refreshDataTimeout); + } + } + + // Schedule the next data request depending on the time + // range key. + resetPolling(key: string) { + this.clearRefreshDataTimeout(); + if (key !== "Custom") { + this.refreshDataTimeout = setTimeout( + this.refreshData, + 300000, // 5 minutes + ); + } + } + refreshData = (prevTransactionFingerprintId: string): void => { const req = statementsRequestFromProps(this.props); this.props.refreshData(req); this.getTransactionStateInfo(prevTransactionFingerprintId); + this.resetPolling(this.props.timeScale.key); }; componentDidMount(): void { this.refreshData(""); + // For the first data fetch for this page, we refresh if there are: + // - Last updated is null (no statements fetched previously) + // - The time interval is not custom, i.e. we have a moving window + // in which case we poll every 5 minutes. For the first fetch we will + // calculate the next time to refresh based on when the data was last + // updated. + if (this.props.timeScale.key !== "Custom" || !this.props.lastUpdated) { + const now = moment(); + const nextRefresh = + this.props.lastUpdated?.clone().add(5, "minutes") || now; + setTimeout( + this.refreshData, + Math.max(0, nextRefresh.diff(now, "milliseconds")), + this.props.transactionFingerprintId, + ); + } this.props.refreshUserSQLRoles(); this.props.refreshNodes(); } + componentWillUnmount(): void { + this.clearRefreshDataTimeout(); + } + componentDidUpdate(prevProps: TransactionDetailsProps): void { this.getTransactionStateInfo(prevProps.transactionFingerprintId); this.props.refreshNodes(); @@ -270,7 +320,7 @@ export class TransactionDetails extends React.Component< diff --git a/pkg/ui/workspaces/cluster-ui/src/transactionDetails/transactionDetailsConnected.tsx b/pkg/ui/workspaces/cluster-ui/src/transactionDetails/transactionDetailsConnected.tsx index 5d7bf675d2b0..b7971a50f8e4 100644 --- a/pkg/ui/workspaces/cluster-ui/src/transactionDetails/transactionDetailsConnected.tsx +++ b/pkg/ui/workspaces/cluster-ui/src/transactionDetails/transactionDetailsConnected.tsx @@ -60,6 +60,7 @@ export const selectTransaction = createSelector( return { isLoading: false, transaction: transaction, + lastUpdated: transactionState.lastUpdated, }; }, ); @@ -68,7 +69,10 @@ const mapStateToProps = ( state: AppState, props: TransactionDetailsProps, ): TransactionDetailsStateProps => { - const { isLoading, transaction } = selectTransaction(state, props); + const { isLoading, transaction, lastUpdated } = selectTransaction( + state, + props, + ); return { timeScale: selectTimeScale(state), error: selectTransactionsLastError(state), @@ -81,6 +85,7 @@ const mapStateToProps = ( txnFingerprintIdAttr, ), isLoading: isLoading, + lastUpdated: lastUpdated, hasViewActivityRedactedRole: selectHasViewActivityRedactedRole(state), }; }; diff --git a/pkg/ui/workspaces/cluster-ui/src/transactionsPage/transactionsPage.tsx b/pkg/ui/workspaces/cluster-ui/src/transactionsPage/transactionsPage.tsx index c59b79dbed4c..ff9643ecf07f 100644 --- a/pkg/ui/workspaces/cluster-ui/src/transactionsPage/transactionsPage.tsx +++ b/pkg/ui/workspaces/cluster-ui/src/transactionsPage/transactionsPage.tsx @@ -196,7 +196,7 @@ export class TransactionsPage extends React.Component< } } - // Scheudle the next data request depending on the time + // Schedule the next data request depending on the time // range key. resetPolling(key: string): void { this.clearRefreshDataTimeout(); diff --git a/pkg/ui/workspaces/db-console/src/redux/apiReducers.ts b/pkg/ui/workspaces/db-console/src/redux/apiReducers.ts index c6c58c311bb0..31a8ae0f2421 100644 --- a/pkg/ui/workspaces/db-console/src/redux/apiReducers.ts +++ b/pkg/ui/workspaces/db-console/src/redux/apiReducers.ts @@ -351,7 +351,7 @@ export const statementDetailsReducerObj = new KeyedCachedDataReducer( api.getStatementDetails, statementDetailsActionNamespace, statementDetailsRequestToID, - moment.duration(5, "m"), + null, moment.duration(30, "m"), ); diff --git a/pkg/ui/workspaces/db-console/src/views/statements/statementDetails.tsx b/pkg/ui/workspaces/db-console/src/views/statements/statementDetails.tsx index 854505841b0b..133e8b0e9dcb 100644 --- a/pkg/ui/workspaces/db-console/src/views/statements/statementDetails.tsx +++ b/pkg/ui/workspaces/db-console/src/views/statements/statementDetails.tsx @@ -48,6 +48,7 @@ import { getMatchParamByName, queryByName } from "src/util/query"; import { appNamesAttr, statementAttr } from "src/util/constants"; import { selectTimeScale } from "src/redux/timeScale"; import { api as clusterUiApi } from "@cockroachlabs/cluster-ui"; +import moment from "moment"; const { generateStmtDetailsToID } = util; @@ -67,6 +68,7 @@ export const selectStatementDetails = createSelector( statementDetails: StatementDetailsResponseMessage; isLoading: boolean; lastError: Error; + lastUpdated: moment.Moment | null; } => { // Since the aggregation interval is 1h, we want to round the selected timeScale to include // the full hour. If a timeScale is between 14:32 - 15:17 we want to search for values @@ -85,9 +87,15 @@ export const selectStatementDetails = createSelector( statementDetails: statementDetailsStats[key].data, isLoading: statementDetailsStats[key].inFlight, lastError: statementDetailsStats[key].lastError, + lastUpdated: statementDetailsStats[key]?.setAt?.utc(), }; } - return { statementDetails: null, isLoading: true, lastError: null }; + return { + statementDetails: null, + isLoading: true, + lastError: null, + lastUpdated: null, + }; }, ); @@ -95,16 +103,15 @@ const mapStateToProps = ( state: AdminUIState, props: RouteComponentProps, ): StatementDetailsStateProps => { - const { statementDetails, isLoading, lastError } = selectStatementDetails( - state, - props, - ); + const { statementDetails, isLoading, lastError, lastUpdated } = + selectStatementDetails(state, props); const statementFingerprint = statementDetails?.statement.metadata.query; return { statementFingerprintID: getMatchParamByName(props.match, statementAttr), statementDetails, isLoading: isLoading, statementsError: lastError, + lastUpdated: lastUpdated, timeScale: selectTimeScale(state), nodeRegions: nodeRegionsByIDSelector(state), diagnosticsReports: selectDiagnosticsReportsByStatementFingerprint( diff --git a/pkg/ui/workspaces/db-console/src/views/transactions/transactionDetails.tsx b/pkg/ui/workspaces/db-console/src/views/transactions/transactionDetails.tsx index b009b6b864a8..3b26c5c59951 100644 --- a/pkg/ui/workspaces/db-console/src/views/transactions/transactionDetails.tsx +++ b/pkg/ui/workspaces/db-console/src/views/transactions/transactionDetails.tsx @@ -42,6 +42,7 @@ export const selectTransaction = createSelector( return { isLoading: true, transaction: null, + lastUpdated: null, }; } const txnFingerprintId = getMatchParamByName( @@ -57,6 +58,7 @@ export const selectTransaction = createSelector( return { isLoading: false, transaction: transaction, + lastUpdated: transactionState?.setAt?.utc(), }; }, ); @@ -67,7 +69,10 @@ export default withRouter( state: AdminUIState, props: TransactionDetailsProps, ): TransactionDetailsStateProps => { - const { isLoading, transaction } = selectTransaction(state, props); + const { isLoading, transaction, lastUpdated } = selectTransaction( + state, + props, + ); return { timeScale: selectTimeScale(state), error: selectLastError(state), @@ -80,6 +85,7 @@ export default withRouter( txnFingerprintIdAttr, ), isLoading: isLoading, + lastUpdated: lastUpdated, }; }, {