From 1a2e363a4e1f6789e88df9bf31fb9e3769fbb995 Mon Sep 17 00:00:00 2001 From: Lindsey Jin Date: Thu, 18 Nov 2021 16:20:41 -0500 Subject: [PATCH] ui: add index stats to table details page Resolves #67647, #72842 Previously, there was no way to view and clear index usage stats from the frontend db console. This commit adds Index Stats tables for each table on the Table Detail pages, allowing users to view index names, total reads, and last used statistics. This commit also adds the functionality of clearing all index stats as a button on the Index Stats tables. Release note (ui change): Add index stats table and button to clear index usage stats on the Table Details page for each table. --- .../databaseTablePage.module.scss | 59 ++++++ .../databaseTablePage.stories.tsx | 36 ++++ .../databaseTablePage/databaseTablePage.tsx | 177 +++++++++++++++++- .../db-console/src/redux/apiReducers.ts | 17 +- .../src/redux/indexUsageStats/index.ts | 12 ++ .../indexUsageStats/indexUsageStatsActions.ts | 50 +++++ .../indexUsageStatsSagas.spec.ts | 55 ++++++ .../indexUsageStats/indexUsageStatsSagas.ts | 70 +++++++ .../workspaces/db-console/src/redux/sagas.ts | 2 + pkg/ui/workspaces/db-console/src/util/api.ts | 32 ++++ .../workspaces/db-console/src/util/fakeApi.ts | 24 ++- .../databases/databaseTablePage/redux.spec.ts | 88 ++++++++- .../databases/databaseTablePage/redux.ts | 49 ++++- 13 files changed, 655 insertions(+), 16 deletions(-) create mode 100644 pkg/ui/workspaces/db-console/src/redux/indexUsageStats/index.ts create mode 100644 pkg/ui/workspaces/db-console/src/redux/indexUsageStats/indexUsageStatsActions.ts create mode 100644 pkg/ui/workspaces/db-console/src/redux/indexUsageStats/indexUsageStatsSagas.spec.ts create mode 100644 pkg/ui/workspaces/db-console/src/redux/indexUsageStats/indexUsageStatsSagas.ts diff --git a/pkg/ui/workspaces/cluster-ui/src/databaseTablePage/databaseTablePage.module.scss b/pkg/ui/workspaces/cluster-ui/src/databaseTablePage/databaseTablePage.module.scss index e63861b7bfde..664770d539cb 100644 --- a/pkg/ui/workspaces/cluster-ui/src/databaseTablePage/databaseTablePage.module.scss +++ b/pkg/ui/workspaces/cluster-ui/src/databaseTablePage/databaseTablePage.module.scss @@ -10,6 +10,10 @@ @import "src/core/index.module"; +.tab-area { + margin-bottom: $spacing-large; +} + .database-table-page { &__indexes { &--value { @@ -54,3 +58,58 @@ fill: $colors--primary-text; } } + +.index-stats { + &__summary-card { + width: fit-content; + padding: 0; + } + + &__header { + align-items: baseline; + display: flex; + flex-direction: row; + justify-content: space-between; + padding: $spacing-medium $spacing-medium 0; + } + + &__clear-info { + display: flex; + flex-direction: row; + } + + &__last-cleared { + color: $colors--neutral-6; + margin-right: $spacing-base; + } + + &__clear-btn { + border-bottom: none; + text-decoration: none; + } + + &-table { + &__col { + &-indexes { + width: 30em; + } + &-last-used { + width: 30em; + } + } + } +} + + +.icon { + &--s { + height: $line-height--x-small; + width: $line-height--x-small; + margin-right: 10px; + } + + &--primary { + fill: $colors--primary-text; + } +} + diff --git a/pkg/ui/workspaces/cluster-ui/src/databaseTablePage/databaseTablePage.stories.tsx b/pkg/ui/workspaces/cluster-ui/src/databaseTablePage/databaseTablePage.stories.tsx index a57643382927..47a7038028f4 100644 --- a/pkg/ui/workspaces/cluster-ui/src/databaseTablePage/databaseTablePage.stories.tsx +++ b/pkg/ui/workspaces/cluster-ui/src/databaseTablePage/databaseTablePage.stories.tsx @@ -19,6 +19,7 @@ import { randomTablePrivilege, } from "src/storybook/fixtures"; import { DatabaseTablePage, DatabaseTablePageProps } from "./databaseTablePage"; +import moment from "moment"; const withLoadingIndicator: DatabaseTablePageProps = { databaseName: randomName(), @@ -37,8 +38,16 @@ const withLoadingIndicator: DatabaseTablePageProps = { sizeInBytes: 0, rangeCount: 0, }, + indexStats: { + loading: true, + loaded: false, + stats: [], + lastReset: moment("2021-09-04T13:55:00Z"), + }, refreshTableDetails: () => {}, refreshTableStats: () => {}, + refreshIndexStats: () => {}, + resetIndexUsageStats: () => {}, }; const name = randomName(); @@ -80,8 +89,35 @@ const withData: DatabaseTablePageProps = { nodesByRegionString: "gcp-europe-west1(n8), gcp-us-east1(n1), gcp-us-west1(n6)", }, + indexStats: { + loading: false, + loaded: true, + stats: [ + { + totalReads: 0, + lastUsed: moment("2021-10-11T11:29:00Z"), + lastUsedType: "read", + indexName: "primary", + }, + { + totalReads: 3, + lastUsed: moment("2021-11-10T16:29:00Z"), + lastUsedType: "read", + indexName: "primary", + }, + { + totalReads: 2, + lastUsed: moment("2021-09-04T13:55:00Z"), + lastUsedType: "reset", + indexName: "secondary", + }, + ], + lastReset: moment("2021-09-04T13:55:00Z"), + }, refreshTableDetails: () => {}, refreshTableStats: () => {}, + refreshIndexStats: () => {}, + resetIndexUsageStats: () => {}, }; storiesOf("Database Table Page", module) diff --git a/pkg/ui/workspaces/cluster-ui/src/databaseTablePage/databaseTablePage.tsx b/pkg/ui/workspaces/cluster-ui/src/databaseTablePage/databaseTablePage.tsx index db0d80988f9f..a6c0e4948675 100644 --- a/pkg/ui/workspaces/cluster-ui/src/databaseTablePage/databaseTablePage.tsx +++ b/pkg/ui/workspaces/cluster-ui/src/databaseTablePage/databaseTablePage.tsx @@ -13,6 +13,7 @@ import { Col, Row, Tabs } from "antd"; import classNames from "classnames/bind"; import _ from "lodash"; import { Tooltip } from "antd"; +import { Heading } from "@cockroachlabs/ui-components"; import { Breadcrumbs } from "src/breadcrumbs"; import { CaretRight } from "src/icon/caretRight"; @@ -25,6 +26,9 @@ import * as format from "src/util/format"; import styles from "./databaseTablePage.module.scss"; import { commonStyles } from "src/common"; import { baseHeadingClasses } from "src/transactionsPage/transactionsPageClasses"; +import moment, { Moment } from "moment"; +import { Search as IndexIcon } from "@cockroachlabs/icons"; +import { formatDate } from "antd/es/date-picker/utils"; const cx = classNames.bind(styles); const { TabPane } = Tabs; @@ -58,12 +62,24 @@ const { TabPane } = Tabs; // rangeCount: number; // nodesByRegionString: string; // }; +// indexStats: { // DatabaseTablePageIndexStats +// loading: boolean; +// loaded: boolean; +// stats: { +// indexName: string; +// totalReads: number; +// lastUsed: Moment; +// lastUsedType: string; +// }[]; +// lastReset: Moment; +// }; // } export interface DatabaseTablePageData { databaseName: string; name: string; details: DatabaseTablePageDataDetails; stats: DatabaseTablePageDataStats; + indexStats: DatabaseTablePageIndexStats; showNodeRegionsSection?: boolean; } @@ -76,6 +92,20 @@ export interface DatabaseTablePageDataDetails { grants: Grant[]; } +export interface DatabaseTablePageIndexStats { + loading: boolean; + loaded: boolean; + stats: IndexStat[]; + lastReset: Moment; +} + +interface IndexStat { + indexName: string; + totalReads: number; + lastUsed: Moment; + lastUsedType: string; +} + interface Grant { user: string; privilege: string; @@ -91,7 +121,9 @@ export interface DatabaseTablePageDataStats { export interface DatabaseTablePageActions { refreshTableDetails: (database: string, table: string) => void; - refreshTableStats: (databse: string, table: string) => void; + refreshTableStats: (database: string, table: string) => void; + refreshIndexStats?: (database: string, table: string) => void; + resetIndexUsageStats?: (database: string, table: string) => void; refreshNodes?: () => void; } @@ -103,6 +135,7 @@ interface DatabaseTablePageState { } class DatabaseTableGrantsTable extends SortedTable {} +class IndexUsageStatsTable extends SortedTable {} export class DatabaseTablePage extends React.Component< DatabaseTablePageProps, @@ -143,13 +176,103 @@ export class DatabaseTablePage extends React.Component< this.props.name, ); } + + if (!this.props.indexStats.loaded && !this.props.indexStats.loading) { + return this.props.refreshIndexStats( + this.props.databaseName, + this.props.name, + ); + } } + minDate = moment.utc("0001-01-01"); // minimum value as per UTC + private changeSortSetting(sortSetting: SortSetting) { this.setState({ sortSetting }); } - private columns: ColumnDescriptor[] = [ + private getLastResetString() { + const lastReset = this.props.indexStats.lastReset; + if (lastReset.isSame(this.minDate)) { + return "Last cleared: Never"; + } else { + return ( + "Last cleared: " + + formatDate(lastReset, "MMM DD, YYYY [at] h:mm A [(UTC)]") + ); + } + } + + private getLastUsedString(indexStat: IndexStat) { + const lastReset = this.props.indexStats.lastReset; + switch (indexStat.lastUsedType) { + case "read": + return formatDate( + indexStat.lastUsed, + "[Last read:] MMM DD, YYYY [at] h:mm A", + ); + case "reset": + default: + // TODO(lindseyjin): replace default case with create time after it's added to table_indexes + if (lastReset.isSame(this.minDate)) { + return "Last reset: Never"; + } else { + return formatDate( + lastReset, + "[Last reset:] MMM DD, YYYY [at] h:mm A", + ); + } + } + } + + private indexStatsColumns: ColumnDescriptor[] = [ + { + name: "indexes", + title: ( + + Indexes + + ), + className: cx("index-stats-table__col-indexes"), + cell: indexStat => ( + <> + + {indexStat.indexName} + + ), + sort: indexStat => indexStat.indexName, + }, + { + name: "total reads", + title: ( + + Total Reads + + ), + cell: indexStat => indexStat.totalReads, + sort: indexStat => indexStat.totalReads, + }, + { + name: "last used", + title: ( + + Last Used (UTC) + + ), + className: cx("index-stats-table__col-last-used"), + cell: indexStat => this.getLastUsedString(indexStat), + sort: indexStat => indexStat.lastUsed, + }, + // TODO(lindseyjin): add index recommendations column + ]; + + private grantsColumns: ColumnDescriptor[] = [ { name: "user", title: ( @@ -200,7 +323,7 @@ export class DatabaseTablePage extends React.Component< -
+
@@ -247,12 +370,56 @@ export class DatabaseTablePage extends React.Component< + + + + + + - generateTableID(req.database, req.table); const tableDetailsReducerObj = new KeyedCachedDataReducer( @@ -125,6 +128,16 @@ const tableStatsReducerObj = new KeyedCachedDataReducer( ); export const refreshTableStats = tableStatsReducerObj.refresh; +const indexStatsReducerObj = new KeyedCachedDataReducer( + api.getIndexStats, + "indexStats", + tableRequestToID, +); + +export const invalidateIndexStats = + indexStatsReducerObj.cachedDataReducer.invalidateData; +export const refreshIndexStats = indexStatsReducerObj.refresh; + const nonTableStatsReducerObj = new CachedDataReducer( api.getNonTableStats, "nonTableStats", @@ -325,6 +338,7 @@ export interface APIReducersState { >; tableDetails: KeyedCachedDataReducerState; tableStats: KeyedCachedDataReducerState; + indexStats: KeyedCachedDataReducerState; nonTableStats: CachedDataReducerState; logs: CachedDataReducerState; liveness: CachedDataReducerState; @@ -362,6 +376,7 @@ export const apiReducersReducer = combineReducers({ databaseDetailsReducerObj.reducer, [tableDetailsReducerObj.actionNamespace]: tableDetailsReducerObj.reducer, [tableStatsReducerObj.actionNamespace]: tableStatsReducerObj.reducer, + [indexStatsReducerObj.actionNamespace]: indexStatsReducerObj.reducer, [nonTableStatsReducerObj.actionNamespace]: nonTableStatsReducerObj.reducer, [logsReducerObj.actionNamespace]: logsReducerObj.reducer, [livenessReducerObj.actionNamespace]: livenessReducerObj.reducer, diff --git a/pkg/ui/workspaces/db-console/src/redux/indexUsageStats/index.ts b/pkg/ui/workspaces/db-console/src/redux/indexUsageStats/index.ts new file mode 100644 index 000000000000..17406d764d45 --- /dev/null +++ b/pkg/ui/workspaces/db-console/src/redux/indexUsageStats/index.ts @@ -0,0 +1,12 @@ +// Copyright 2021 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 "./indexUsageStatsActions"; +export * from "./indexUsageStatsSagas"; diff --git a/pkg/ui/workspaces/db-console/src/redux/indexUsageStats/indexUsageStatsActions.ts b/pkg/ui/workspaces/db-console/src/redux/indexUsageStats/indexUsageStatsActions.ts new file mode 100644 index 000000000000..a2ca5cdcf8ac --- /dev/null +++ b/pkg/ui/workspaces/db-console/src/redux/indexUsageStats/indexUsageStatsActions.ts @@ -0,0 +1,50 @@ +// Copyright 2021 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 { Action } from "redux"; +import { PayloadAction } from "oss/src/interfaces/action"; + +export const RESET_INDEX_USAGE_STATS = + "cockroachui/indexUsageStats/RESET_INDEX_USAGE_STATS"; +export const RESET_INDEX_USAGE_STATS_COMPLETE = + "cockroachui/indexUsageStats/RESET_INDEX_USAGE_STATS_COMPLETE"; +"cockroachui/indexUsageStats/RESET_INDEX_USAGE_STATS_COMPLETE"; +export const RESET_INDEX_USAGE_STATS_FAILED = + "cockroachui/indexUsageStats/RESET_INDEX_USAGE_STATS_FAILED"; + +export type resetIndexUsageStatsPayload = { + database: string; + table: string; +}; + +export function resetIndexUsageStatsAction( + database: string, + table: string, +): PayloadAction { + return { + type: RESET_INDEX_USAGE_STATS, + payload: { + database, + table, + }, + }; +} + +export function resetIndexUsageStatsCompleteAction(): Action { + return { + type: RESET_INDEX_USAGE_STATS_COMPLETE, + }; +} + +export function resetIndexUsageStatsFailedAction(): Action { + return { + type: RESET_INDEX_USAGE_STATS_FAILED, + }; +} diff --git a/pkg/ui/workspaces/db-console/src/redux/indexUsageStats/indexUsageStatsSagas.spec.ts b/pkg/ui/workspaces/db-console/src/redux/indexUsageStats/indexUsageStatsSagas.spec.ts new file mode 100644 index 000000000000..9ad28a87be1b --- /dev/null +++ b/pkg/ui/workspaces/db-console/src/redux/indexUsageStats/indexUsageStatsSagas.spec.ts @@ -0,0 +1,55 @@ +// Copyright 2021 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 { expectSaga } from "redux-saga-test-plan"; +import { call, select } from "redux-saga-test-plan/matchers"; + +import { + resetIndexUsageStatsFailedAction, + resetIndexUsageStatsCompleteAction, + resetIndexUsageStatsAction, +} from "./indexUsageStatsActions"; +import { + resetIndexUsageStatsSaga, + selectIndexStatsKeys, +} from "./indexUsageStatsSagas"; +import { resetIndexUsageStats } from "src/util/api"; +import { throwError } from "redux-saga-test-plan/providers"; + +import { cockroach } from "src/js/protos"; + +describe("Index Usage Stats sagas", () => { + describe("resetIndexUsageStatsSaga", () => { + const resetIndexUsageStatsResponse = new cockroach.server.serverpb.ResetIndexUsageStatsResponse(); + const action = resetIndexUsageStatsAction("database", "table"); + + it("successfully resets index usage stats", () => { + // TODO(lindseyjin): figure out how to test invalidate and refresh actions + // once we can figure out how to get ThunkAction to work with sagas. + return expectSaga(resetIndexUsageStatsSaga, action) + .provide([ + [call.fn(resetIndexUsageStats), resetIndexUsageStatsResponse], + [select(selectIndexStatsKeys), ["database/table"]], + ]) + .put(resetIndexUsageStatsCompleteAction()) + .dispatch(action) + .run(); + }); + + it("returns error on failed reset", () => { + const err = new Error("failed to reset"); + return expectSaga(resetIndexUsageStatsSaga, action) + .provide([[call.fn(resetIndexUsageStats), throwError(err)]]) + .put(resetIndexUsageStatsFailedAction()) + .dispatch(action) + .run(); + }); + }); +}); diff --git a/pkg/ui/workspaces/db-console/src/redux/indexUsageStats/indexUsageStatsSagas.ts b/pkg/ui/workspaces/db-console/src/redux/indexUsageStats/indexUsageStatsSagas.ts new file mode 100644 index 000000000000..c030a03a8141 --- /dev/null +++ b/pkg/ui/workspaces/db-console/src/redux/indexUsageStats/indexUsageStatsSagas.ts @@ -0,0 +1,70 @@ +// Copyright 2021 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 { cockroach } from "src/js/protos"; +import { all, call, put, takeEvery, select } from "redux-saga/effects"; +import { + RESET_INDEX_USAGE_STATS, + resetIndexUsageStatsCompleteAction, + resetIndexUsageStatsFailedAction, + resetIndexUsageStatsPayload, +} from "./indexUsageStatsActions"; + +import ResetIndexUsageStatsRequest = cockroach.server.serverpb.ResetIndexUsageStatsRequest; +import { + invalidateIndexStats, + KeyedCachedDataReducerState, + refreshIndexStats, +} from "src/redux/apiReducers"; +import { IndexStatsResponseMessage, resetIndexUsageStats } from "src/util/api"; +import { createSelector } from "reselect"; +import { AdminUIState } from "src/redux/state"; +import TableIndexStatsRequest = cockroach.server.serverpb.TableIndexStatsRequest; +import { PayloadAction } from "src/interfaces/action"; + +export const selectIndexStatsKeys = createSelector( + (state: AdminUIState) => state.cachedData.indexStats, + (indexUsageStats: KeyedCachedDataReducerState) => + Object.keys(indexUsageStats), +); + +export const KeyToTableRequest = (key: string): TableIndexStatsRequest => { + const s = key.split("/"); + const database = s[0]; + const table = s[1]; + return new TableIndexStatsRequest({ database, table }); +}; +export function* resetIndexUsageStatsSaga( + action: PayloadAction, +) { + const resetIndexUsageStatsRequest = new ResetIndexUsageStatsRequest(); + const { database, table } = action.payload; + try { + yield call(resetIndexUsageStats, resetIndexUsageStatsRequest); + yield put(resetIndexUsageStatsCompleteAction()); + + // invalidate all index stats in cache. + const keys: string[] = yield select(selectIndexStatsKeys); + yield keys.forEach(key => + put(invalidateIndexStats(KeyToTableRequest(key))), + ); + + // refresh index stats for table page that user is on. + yield put( + refreshIndexStats(new TableIndexStatsRequest({ database, table })) as any, + ); + } catch (e) { + yield put(resetIndexUsageStatsFailedAction()); + } +} + +export function* indexUsageStatsSaga() { + yield all([takeEvery(RESET_INDEX_USAGE_STATS, resetIndexUsageStatsSaga)]); +} diff --git a/pkg/ui/workspaces/db-console/src/redux/sagas.ts b/pkg/ui/workspaces/db-console/src/redux/sagas.ts index 92d61cccbcd0..69c43fe9bf0f 100644 --- a/pkg/ui/workspaces/db-console/src/redux/sagas.ts +++ b/pkg/ui/workspaces/db-console/src/redux/sagas.ts @@ -17,6 +17,7 @@ import { statementsSaga } from "./statements"; import { analyticsSaga } from "./analyticsSagas"; import { sessionsSaga } from "./sessions"; import { sqlStatsSaga } from "./sqlStats"; +import { indexUsageStatsSaga } from "./indexUsageStats"; export default function* rootSaga() { yield all([ @@ -27,5 +28,6 @@ export default function* rootSaga() { fork(analyticsSaga), fork(sessionsSaga), fork(sqlStatsSaga), + fork(indexUsageStatsSaga), ]); } diff --git a/pkg/ui/workspaces/db-console/src/util/api.ts b/pkg/ui/workspaces/db-console/src/util/api.ts index 837f20aadd3e..75bc477d9c9d 100644 --- a/pkg/ui/workspaces/db-console/src/util/api.ts +++ b/pkg/ui/workspaces/db-console/src/util/api.ts @@ -59,6 +59,9 @@ export type ClusterResponseMessage = protos.cockroach.server.serverpb.ClusterRes export type TableStatsRequestMessage = protos.cockroach.server.serverpb.TableStatsRequest; export type TableStatsResponseMessage = protos.cockroach.server.serverpb.TableStatsResponse; +export type IndexStatsRequestMessage = protos.cockroach.server.serverpb.TableIndexStatsRequest; +export type IndexStatsResponseMessage = protos.cockroach.server.serverpb.TableIndexStatsResponse; + export type NonTableStatsRequestMessage = protos.cockroach.server.serverpb.NonTableStatsRequest; export type NonTableStatsResponseMessage = protos.cockroach.server.serverpb.NonTableStatsResponse; @@ -136,6 +139,9 @@ export type StatementsRequestMessage = protos.cockroach.server.serverpb.Statemen export type ResetSQLStatsRequestMessage = protos.cockroach.server.serverpb.ResetSQLStatsRequest; export type ResetSQLStatsResponseMessage = protos.cockroach.server.serverpb.ResetSQLStatsResponse; +export type ResetIndexUsageStatsRequestMessage = protos.cockroach.server.serverpb.ResetIndexUsageStatsRequest; +export type ResetIndexUsageStatsResponseMessage = protos.cockroach.server.serverpb.ResetIndexUsageStatsResponse; + // API constants export const API_PREFIX = "_admin/v1"; @@ -456,6 +462,19 @@ export function getTableStats( ); } +// getIndexStats gets detailed stats about the current table's index usage statistics. +export function getIndexStats( + req: IndexStatsRequestMessage, + timeout?: moment.Duration, +): Promise { + return timeoutFetch( + serverpb.TableIndexStatsResponse, + `${STATUS_PREFIX}/databases/${req.database}/tables/${req.table}/indexstats`, + null, + timeout, + ); +} + // getNonTableStats gets detailed stats about non-table data ranges on the // cluster. export function getNonTableStats( @@ -759,3 +778,16 @@ export function resetSQLStats( timeout, ); } + +// resetIndexUsageStats refreshes all index usage stats for all tables. +export function resetIndexUsageStats( + req: ResetIndexUsageStatsRequestMessage, + timeout?: moment.Duration, +): Promise { + return timeoutFetch( + serverpb.ResetIndexUsageStatsResponse, + `${STATUS_PREFIX}/resetindexusagestats`, + req as any, + timeout, + ); +} diff --git a/pkg/ui/workspaces/db-console/src/util/fakeApi.ts b/pkg/ui/workspaces/db-console/src/util/fakeApi.ts index e188b2f7b89b..b6d29d961000 100644 --- a/pkg/ui/workspaces/db-console/src/util/fakeApi.ts +++ b/pkg/ui/workspaces/db-console/src/util/fakeApi.ts @@ -11,7 +11,7 @@ import * as $protobuf from "protobufjs"; import { cockroach } from "src/js/protos"; -import { API_PREFIX, toArrayBuffer } from "src/util/api"; +import { API_PREFIX, STATUS_PREFIX, toArrayBuffer } from "src/util/api"; import fetchMock from "src/util/fetch-mock"; const { @@ -19,6 +19,7 @@ const { DatabaseDetailsResponse, TableDetailsResponse, TableStatsResponse, + TableIndexStatsResponse, } = cockroach.server.serverpb; // These test-time functions provide typesafe wrappers around fetchMock, @@ -56,7 +57,7 @@ export function restore() { export function stubDatabases( response: cockroach.server.serverpb.IDatabasesResponse, ) { - stubGet("/databases", DatabasesResponse.encode(response)); + stubGet("/databases", DatabasesResponse.encode(response), API_PREFIX); } export function stubDatabaseDetails( @@ -66,6 +67,7 @@ export function stubDatabaseDetails( stubGet( `/databases/${database}?include_stats=true`, DatabaseDetailsResponse.encode(response), + API_PREFIX, ); } @@ -77,6 +79,7 @@ export function stubTableDetails( stubGet( `/databases/${database}/tables/${table}`, TableDetailsResponse.encode(response), + API_PREFIX, ); } @@ -88,9 +91,22 @@ export function stubTableStats( stubGet( `/databases/${database}/tables/${table}/stats`, TableStatsResponse.encode(response), + API_PREFIX, ); } -function stubGet(path: string, writer: $protobuf.Writer) { - fetchMock.get(`${API_PREFIX}${path}`, toArrayBuffer(writer.finish())); +export function stubIndexStats( + database: string, + table: string, + response: cockroach.server.serverpb.ITableIndexStatsResponse, +) { + stubGet( + `/databases/${database}/tables/${table}/indexstats`, + TableIndexStatsResponse.encode(response), + STATUS_PREFIX, + ); +} + +function stubGet(path: string, writer: $protobuf.Writer, prefix: string) { + fetchMock.get(`${prefix}${path}`, toArrayBuffer(writer.finish())); } diff --git a/pkg/ui/workspaces/db-console/src/views/databases/databaseTablePage/redux.spec.ts b/pkg/ui/workspaces/db-console/src/views/databases/databaseTablePage/redux.spec.ts index 1eb923c05675..913325e4c3b1 100644 --- a/pkg/ui/workspaces/db-console/src/views/databases/databaseTablePage/redux.spec.ts +++ b/pkg/ui/workspaces/db-console/src/views/databases/databaseTablePage/redux.spec.ts @@ -10,7 +10,6 @@ import assert from "assert"; import { createMemoryHistory } from "history"; -import _ from "lodash"; import Long from "long"; import { RouteComponentProps } from "react-router-dom"; import { bindActionCreators, Store } from "redux"; @@ -19,12 +18,23 @@ import { DatabaseTablePageData, DatabaseTablePageDataDetails, DatabaseTablePageDataStats, + DatabaseTablePageIndexStats, } from "@cockroachlabs/cluster-ui"; import { AdminUIState, createAdminUIStore } from "src/redux/state"; import { databaseNameAttr, tableNameAttr } from "src/util/constants"; import * as fakeApi from "src/util/fakeApi"; import { mapStateToProps, mapDispatchToProps } from "./redux"; +import * as protos from "oss/src/js"; +import moment from "moment"; + +type Timestamp = protos.google.protobuf.ITimestamp; + +function makeTimestamp(date: string): Timestamp { + return new protos.google.protobuf.Timestamp({ + seconds: new Long(Date.parse(date) * 1e-3), + }); +} function fakeRouteComponentProps( k1: string, @@ -78,15 +88,33 @@ class TestDriver { } assertProperties(expected: DatabaseTablePageData) { - assert.deepEqual(this.properties(), expected); + this.properties().indexStats.lastReset.isSame( + expected.indexStats.lastReset, + ); + delete this.properties().indexStats.lastReset; + delete expected.indexStats.lastReset; + assert.deepStrictEqual(this.properties(), expected); } assertTableDetails(expected: DatabaseTablePageDataDetails) { - assert.deepEqual(this.properties().details, expected); + assert.deepStrictEqual(this.properties().details, expected); } assertTableStats(expected: DatabaseTablePageDataStats) { - assert.deepEqual(this.properties().stats, expected); + assert.deepStrictEqual(this.properties().stats, expected); + } + + assertIndexStats(expected: DatabaseTablePageIndexStats) { + // Convert moments to long + this.properties().indexStats.stats[0].lastUsed.isSame( + expected.stats[0].lastUsed, + ); + delete this.properties().indexStats.stats[0].lastUsed; + delete expected.stats[0].lastUsed; + this.properties().indexStats.lastReset.isSame(expected.lastReset); + delete this.properties().indexStats.lastReset; + delete expected.lastReset; + assert.deepStrictEqual(this.properties().indexStats, expected); } async refreshTableDetails() { @@ -96,6 +124,10 @@ class TestDriver { async refreshTableStats() { return this.actions.refreshTableStats(this.database, this.table); } + + async refreshIndexStats() { + return this.actions.refreshIndexStats(this.database, this.table); + } } describe("Database Table Page", function() { @@ -133,6 +165,12 @@ describe("Database Table Page", function() { rangeCount: 0, nodesByRegionString: "", }, + indexStats: { + loading: false, + loaded: false, + stats: [], + lastReset: moment(), + }, }); }); @@ -185,4 +223,46 @@ describe("Database Table Page", function() { nodesByRegionString: "", }); }); + + it("loads index stats", async function() { + fakeApi.stubIndexStats("DATABASE", "TABLE", { + statistics: [ + { + statistics: { + key: { + table_id: 15, + index_id: 2, + }, + stats: { + total_read_count: new Long(2), + last_read: makeTimestamp("2021-11-19T23:01:05.167627Z"), + total_rows_read: new Long(0), + total_write_count: new Long(0), + last_write: makeTimestamp("0001-01-01T00:00:00Z"), + total_rows_written: new Long(0), + }, + }, + index_name: "jobs_status_created_idx", + index_type: "secondary", + }, + ], + last_reset: makeTimestamp("2021-11-12T20:18:22.167627Z"), + }); + + await driver.refreshIndexStats(); + + driver.assertIndexStats({ + loading: false, + loaded: true, + stats: [ + { + indexName: "jobs_status_created_idx", + totalReads: 2, + lastUsed: moment("2021-11-19T23:01:05.167627Z"), + lastUsedType: "read", + }, + ], + lastReset: moment("2021-11-12T20:18:22.167627Z"), + }); + }); }); diff --git a/pkg/ui/workspaces/db-console/src/views/databases/databaseTablePage/redux.ts b/pkg/ui/workspaces/db-console/src/views/databases/databaseTablePage/redux.ts index cc0ed1cbc913..190772dc8917 100644 --- a/pkg/ui/workspaces/db-console/src/views/databases/databaseTablePage/redux.ts +++ b/pkg/ui/workspaces/db-console/src/views/databases/databaseTablePage/redux.ts @@ -19,18 +19,25 @@ import { refreshTableDetails, refreshTableStats, refreshNodes, + refreshIndexStats, } from "src/redux/apiReducers"; import { AdminUIState } from "src/redux/state"; import { databaseNameAttr, tableNameAttr } from "src/util/constants"; -import { FixLong } from "src/util/fixLong"; +import { FixLong, longToInt } from "src/util/fixLong"; import { getMatchParamByName } from "src/util/query"; import { nodeRegionsByIDSelector, selectIsMoreThanOneNode, } from "src/redux/nodes"; import { getNodesByRegionString } from "../utils"; +import { TimestampToMoment } from "src/util/convert"; +import { resetIndexUsageStatsAction } from "oss/src/redux/indexUsageStats"; -const { TableDetailsRequest, TableStatsRequest } = cockroach.server.serverpb; +const { + TableDetailsRequest, + TableStatsRequest, + TableIndexStatsRequest, +} = cockroach.server.serverpb; export const mapStateToProps = createSelector( (_state: AdminUIState, props: RouteComponentProps): string => @@ -40,6 +47,7 @@ export const mapStateToProps = createSelector( state => state.cachedData.tableDetails, state => state.cachedData.tableStats, + state => state.cachedData.indexStats, state => nodeRegionsByIDSelector(state), state => selectIsMoreThanOneNode(state), @@ -48,11 +56,36 @@ export const mapStateToProps = createSelector( table, tableDetails, tableStats, + indexUsageStats, nodeRegions, showNodeRegionsSection, ): DatabaseTablePageData => { const details = tableDetails[generateTableID(database, table)]; const stats = tableStats[generateTableID(database, table)]; + const indexStats = indexUsageStats[generateTableID(database, table)]; + const lastReset = TimestampToMoment(indexStats?.data?.last_reset); + const indexStatsData = _.flatMap( + indexStats?.data?.statistics, + indexStat => { + const lastRead = TimestampToMoment( + indexStat.statistics?.stats?.last_read, + ); + let lastUsed, lastUsedType; + if (lastRead.isAfter(lastReset)) { + lastUsed = lastRead; + lastUsedType = "read"; + } else { + lastUsed = lastReset; + lastUsedType = "reset"; + } + return { + indexName: indexStat.index_name, + totalReads: longToInt(indexStat.statistics?.stats?.total_read_count), + lastUsed: lastUsed, + lastUsedType: lastUsedType, + }; + }, + ); const grants = _.flatMap(details?.data?.grants, grant => _.map(grant.privileges, privilege => { return { user: grant.user, privilege }; @@ -81,6 +114,12 @@ export const mapStateToProps = createSelector( rangeCount: FixLong(stats?.data?.range_count || 0).toNumber(), nodesByRegionString: getNodesByRegionString(nodes, nodeRegions), }, + indexStats: { + loading: !!indexStats?.inFlight, + loaded: !!indexStats?.valid, + stats: indexStatsData, + lastReset: lastReset, + }, }; }, ); @@ -94,5 +133,11 @@ export const mapDispatchToProps = { return refreshTableStats(new TableStatsRequest({ database, table })); }, + refreshIndexStats: (database: string, table: string) => { + return refreshIndexStats(new TableIndexStatsRequest({ database, table })); + }, + + resetIndexUsageStats: resetIndexUsageStatsAction, + refreshNodes, };