From 54bd1f9396c2d7671a816a2e256d8ff86f037186 Mon Sep 17 00:00:00 2001 From: gtr Date: Mon, 28 Nov 2022 11:58:50 -0500 Subject: [PATCH] ui: Add search and filtering to the databases pages Part of #68826, #68825. Previously, the databases and databases details pages did not contain search and filter components that the txns and stmts pages did. This change adds search and filter components to both the databases and databases details pages. The search box filters by database/table name while the filter allows filtering by node and region. Release note (ui change): the databases page and the databases details pages each now contain search and filter components, allowing the ability to search and filter through databases and their tables. --- .../databaseDetailsPage.stories.tsx | 10 + .../databaseDetailsPage.tsx | 224 +++++++++++++- .../databasesPage/databasesPage.stories.tsx | 14 + .../src/databasesPage/databasesPage.tsx | 285 ++++++++++++++++-- .../cluster-ui/src/queryFilter/filter.tsx | 56 ++-- .../localStorage/localStorage.reducer.ts | 7 + .../workspaces/db-console/src/util/fakeApi.ts | 9 +- .../databaseDetailsPage/redux.spec.ts | 13 + .../databases/databaseDetailsPage/redux.ts | 36 ++- .../databases/databaseTablePage/redux.ts | 12 +- .../databases/databasesPage/redux.spec.ts | 60 +++- .../views/databases/databasesPage/redux.ts | 32 +- .../db-console/src/views/databases/utils.ts | 16 +- 13 files changed, 707 insertions(+), 67 deletions(-) diff --git a/pkg/ui/workspaces/cluster-ui/src/databaseDetailsPage/databaseDetailsPage.stories.tsx b/pkg/ui/workspaces/cluster-ui/src/databaseDetailsPage/databaseDetailsPage.stories.tsx index 33f6452a4807..c8e5852aaf29 100644 --- a/pkg/ui/workspaces/cluster-ui/src/databaseDetailsPage/databaseDetailsPage.stories.tsx +++ b/pkg/ui/workspaces/cluster-ui/src/databaseDetailsPage/databaseDetailsPage.stories.tsx @@ -27,6 +27,7 @@ import { import * as H from "history"; import moment from "moment"; +import { defaultFilters } from "src/queryFilter"; const history = H.createHashHistory(); const withLoadingIndicator: DatabaseDetailsPageProps = { @@ -49,6 +50,9 @@ const withLoadingIndicator: DatabaseDetailsPageProps = { refreshDatabaseDetails: () => {}, refreshTableDetails: () => {}, refreshTableStats: () => {}, + search: null, + filters: defaultFilters, + nodeRegions: {}, location: history.location, history, match: { @@ -79,6 +83,9 @@ const withoutData: DatabaseDetailsPageProps = { refreshDatabaseDetails: () => {}, refreshTableDetails: () => {}, refreshTableStats: () => {}, + search: null, + filters: defaultFilters, + nodeRegions: {}, location: history.location, history, match: { @@ -144,6 +151,9 @@ const withData: DatabaseDetailsPageProps = { refreshDatabaseDetails: () => {}, refreshTableDetails: () => {}, refreshTableStats: () => {}, + search: null, + filters: defaultFilters, + nodeRegions: {}, location: history.location, history, match: { diff --git a/pkg/ui/workspaces/cluster-ui/src/databaseDetailsPage/databaseDetailsPage.tsx b/pkg/ui/workspaces/cluster-ui/src/databaseDetailsPage/databaseDetailsPage.tsx index 205a762a5936..017d2910eccb 100644 --- a/pkg/ui/workspaces/cluster-ui/src/databaseDetailsPage/databaseDetailsPage.tsx +++ b/pkg/ui/workspaces/cluster-ui/src/databaseDetailsPage/databaseDetailsPage.tsx @@ -28,7 +28,7 @@ import { } from "src/sortedtable"; import * as format from "src/util/format"; import { DATE_FORMAT } from "src/util/format"; -import { mvccGarbage, syncHistory } from "../util"; +import { mvccGarbage, syncHistory, unique } from "../util"; import styles from "./databaseDetailsPage.module.scss"; import sortableTableStyles from "src/sortedtable/sortedtable.module.scss"; @@ -41,6 +41,15 @@ import { Caution } from "@cockroachlabs/icons"; import { Anchor } from "../anchor"; import LoadingError from "../sqlActivity/errorComponent"; import { Loading } from "../loading"; +import { Search } from "../search"; +import { + Filter, + Filters, + defaultFilters, + calculateActiveFilters, +} from "src/queryFilter"; +import { UIConfigState } from "src/store"; +import { TableStatistics } from "src/tableStatistics"; const cx = classNames.bind(styles); const sortableTableCx = classNames.bind(sortableTableStyles); @@ -61,6 +70,10 @@ const sortableTableCx = classNames.bind(sortableTableStyles); // name: string; // sortSettingTables: SortSetting; // sortSettingGrants: SortSetting; +// search: string; +// filters: Filters; +// nodeRegions: { [nodeId: string]: string }; +// isTenant: boolean; // viewMode: ViewMode; // tables: { // DatabaseDetailsPageDataTable[] // name: string; @@ -80,6 +93,7 @@ const sortableTableCx = classNames.bind(sortableTableStyles); // lastError: Error; // replicationSizeInBytes: number; // rangeCount: number; +// nodes: number[]; // nodesByRegionString: string; // }; // }[]; @@ -92,6 +106,10 @@ export interface DatabaseDetailsPageData { tables: DatabaseDetailsPageDataTable[]; sortSettingTables: SortSetting; sortSettingGrants: SortSetting; + search: string; + filters: Filters; + nodeRegions: { [nodeId: string]: string }; + isTenant?: UIConfigState["isTenant"]; viewMode: ViewMode; showNodeRegionsColumn?: boolean; } @@ -124,6 +142,11 @@ export interface DatabaseDetailsPageDataTableStats { lastError: Error; replicationSizeInBytes: number; rangeCount: number; + // Array of node IDs used to unambiguously filter by node and region. + nodes?: number[]; + // String of nodes grouped by region in alphabetical order, e.g. + // regionA(n1,n2), regionB(n3). Used for display in the table's + // "Regions/Nodes" column. nodesByRegionString?: string; } @@ -131,6 +154,8 @@ export interface DatabaseDetailsPageActions { refreshDatabaseDetails: (database: string) => void; refreshTableDetails: (database: string, table: string) => void; refreshTableStats: (database: string, table: string) => void; + onFilterChange?: (value: Filters) => void; + onSearchComplete?: (query: string) => void; onSortingTablesChange?: (columnTitle: string, ascending: boolean) => void; onSortingGrantsChange?: (columnTitle: string, ascending: boolean) => void; onViewModeChange?: (viewMode: ViewMode) => void; @@ -147,12 +172,35 @@ export enum ViewMode { interface DatabaseDetailsPageState { pagination: ISortedTablePagination; + filters?: Filters; + activeFilters?: number; lastStatsError: Error; lastDetailsError: Error; } class DatabaseSortedTable extends SortedTable {} +// filterBySearchQuery returns true if the search query matches the database name. +function filterBySearchQuery( + table: DatabaseDetailsPageDataTable, + search: string, +): boolean { + const matchString = table.name.toLowerCase(); + + if (search.startsWith('"') && search.endsWith('"')) { + search = search.substring(1, search.length - 1); + + return matchString.includes(search); + } + + const res = search + .toLowerCase() + .split(" ") + .every(val => matchString.includes(val)); + + return res; +} + export class DatabaseDetailsPage extends React.Component< DatabaseDetailsPageProps, DatabaseDetailsPageState @@ -286,6 +334,120 @@ export class DatabaseDetailsPage extends React.Component< } }; + onClearSearchField = (): void => { + if (this.props.onSearchComplete) { + this.props.onSearchComplete(""); + } + + syncHistory( + { + q: undefined, + }, + this.props.history, + ); + }; + + onClearFilters = (): void => { + if (this.props.onFilterChange) { + this.props.onFilterChange(defaultFilters); + } + + this.setState({ + filters: defaultFilters, + activeFilters: 0, + }); + + this.resetPagination(); + syncHistory( + { + regions: undefined, + nodes: undefined, + }, + this.props.history, + ); + }; + + onSubmitSearchField = (search: string): void => { + if (this.props.onSearchComplete) { + this.props.onSearchComplete(search); + } + + this.resetPagination(); + syncHistory( + { + q: search, + }, + this.props.history, + ); + }; + + onSubmitFilters = (filters: Filters): void => { + if (this.props.onFilterChange) { + this.props.onFilterChange(filters); + } + + this.setState({ + filters: filters, + activeFilters: calculateActiveFilters(filters), + }); + + this.resetPagination(); + syncHistory( + { + regions: filters.regions, + nodes: filters.nodes, + }, + this.props.history, + ); + }; + + resetPagination = (): void => { + this.setState(prevState => { + return { + pagination: { + current: 1, + pageSize: prevState.pagination.pageSize, + }, + }; + }); + }; + + // Returns a list of database tables to the display based on input from the + // search box and the applied filters. + filteredDatabaseTables = (): DatabaseDetailsPageDataTable[] => { + const { search, tables, filters, nodeRegions } = this.props; + + const regionsSelected = + filters.regions.length > 0 ? filters.regions.split(",") : []; + const nodesSelected = + filters.nodes.length > 0 ? filters.nodes.split(",") : []; + + return tables + .filter(table => (search ? filterBySearchQuery(table, search) : true)) + .filter(table => { + if (regionsSelected.length == 0 && nodesSelected.length == 0) + return true; + + let foundRegion = regionsSelected.length == 0; + let foundNode = nodesSelected.length == 0; + + table.stats.nodes?.forEach(node => { + if ( + foundRegion || + regionsSelected.includes(nodeRegions[node.toString()]) + ) { + foundRegion = true; + } + if (foundNode || nodesSelected.includes("n" + node.toString())) { + foundNode = true; + } + if (foundNode && foundRegion) return true; + }); + + return foundRegion && foundNode; + }); + }; + private changeViewMode(viewMode: ViewMode) { syncHistory( { @@ -594,11 +756,42 @@ export class DatabaseDetailsPage extends React.Component< } render(): React.ReactElement { + const { search, filters, isTenant, nodeRegions } = this.props; + + const tablesToDisplay = this.filteredDatabaseTables(); + const activeFilters = calculateActiveFilters(filters); + + const nodes = Object.keys(nodeRegions) + .map(n => Number(n)) + .sort(); + + const regions = unique(Object.values(nodeRegions)); + const sortSetting = this.props.viewMode == ViewMode.Tables ? this.props.sortSettingTables : this.props.sortSettingGrants; + // Only show the filter component when the viewMode is Tables. + const filterComponent = + this.props.viewMode == ViewMode.Tables ? ( + + "n" + n.toString())} + activeFilters={activeFilters} + filters={defaultFilters} + onSubmitFilters={this.onSubmitFilters} + showNodes={!isTenant && nodes.length > 1} + showRegions={regions.length > 1} + /> + + ) : ( + <> + ); + return (
@@ -631,20 +824,25 @@ export class DatabaseDetailsPage extends React.Component< View: {this.props.viewMode} + + + + {filterComponent}
-
-

- -

-
+ ( {}, refreshDatabases: () => {}, refreshSettings: () => {}, @@ -54,6 +58,9 @@ const withoutData: DatabasesPageProps = { ascending: false, columnTitle: "name", }, + search: "", + filters: defaultFilters, + nodeRegions: {}, onSortingChange: () => {}, refreshDatabases: () => {}, refreshSettings: () => {}, @@ -79,6 +86,13 @@ const withData: DatabasesPageProps = { ascending: false, columnTitle: "name", }, + search: "", + filters: defaultFilters, + nodeRegions: { + "1": "gcp-us-east1", + "6": "gcp-us-west1", + "8": "gcp-europe-west1", + }, databases: Array(42).map(() => { return { loading: false, diff --git a/pkg/ui/workspaces/cluster-ui/src/databasesPage/databasesPage.tsx b/pkg/ui/workspaces/cluster-ui/src/databasesPage/databasesPage.tsx index 8e4f7591e14a..1a46c848ea1d 100644 --- a/pkg/ui/workspaces/cluster-ui/src/databasesPage/databasesPage.tsx +++ b/pkg/ui/workspaces/cluster-ui/src/databasesPage/databasesPage.tsx @@ -19,8 +19,10 @@ import { Anchor } from "src/anchor"; import { StackIcon } from "src/icon/stackIcon"; import { Pagination, ResultsPerPageLabel } from "src/pagination"; import { BooleanSetting } from "src/settings/booleanSetting"; +import { PageConfig, PageConfigItem } from "src/pageConfig"; import { ColumnDescriptor, + handleSortSettingFromQueryString, ISortedTablePagination, SortedTable, SortSetting, @@ -33,11 +35,22 @@ import { baseHeadingClasses, statisticsClasses, } from "src/transactionsPage/transactionsPageClasses"; -import { syncHistory, tableStatsClusterSetting } from "src/util"; +import { syncHistory, tableStatsClusterSetting, unique } from "src/util"; import booleanSettingStyles from "../settings/booleanSetting.module.scss"; import { CircleFilled } from "../icon"; import LoadingError from "../sqlActivity/errorComponent"; import { Loading } from "../loading"; +import { Search } from "../search"; +import { + calculateActiveFilters, + Filter, + Filters, + defaultFilters, + handleFiltersFromQueryString, +} from "../queryFilter"; +import { merge } from "lodash"; +import { UIConfigState } from "src/store"; +import { TableStatistics } from "../tableStatistics"; const cx = classNames.bind(styles); const sortableTableCx = classNames.bind(sortableTableStyles); @@ -57,6 +70,10 @@ const booleanSettingCx = classnames.bind(booleanSettingStyles); // loaded: boolean; // lastError: Error; // sortSetting: SortSetting; +// search: string; +// filters: Filters; +// nodeRegions: { [nodeId: string]: string }; +// isTenant: boolean; // databases: { // DatabasesPageDataDatabase[] // loading: boolean; // loaded: boolean; @@ -64,6 +81,7 @@ const booleanSettingCx = classnames.bind(booleanSettingStyles); // sizeInBytes: number; // tableCount: number; // rangeCount: number; +// nodes: number[]; // nodesByRegionString: string; // missingTables: { // DatabasesPageDataMissingTable[] // loading: boolean; @@ -77,6 +95,10 @@ export interface DatabasesPageData { lastError: Error; databases: DatabasesPageDataDatabase[]; sortSetting: SortSetting; + search: string; + filters: Filters; + nodeRegions: { [nodeId: string]: string }; + isTenant?: UIConfigState["isTenant"]; automaticStatsCollectionEnabled?: boolean; showNodeRegionsColumn?: boolean; } @@ -90,8 +112,11 @@ export interface DatabasesPageDataDatabase { tableCount: number; rangeCount: number; missingTables: DatabasesPageDataMissingTable[]; + // Array of node IDs used to unambiguously filter by node and region. + nodes?: number[]; // String of nodes grouped by region in alphabetical order, e.g. - // regionA(n1,n2), regionB(n3) + // regionA(n1,n2), regionB(n3). Used for display in the table's + // "Regions/Nodes" column. nodesByRegionString?: string; numIndexRecommendations: number; } @@ -114,6 +139,8 @@ export interface DatabasesPageActions { refreshTableStats: (database: string, table: string) => void; refreshSettings: () => void; refreshNodes?: () => void; + onFilterChange?: (value: Filters) => void; + onSearchComplete?: (query: string) => void; onSortingChange?: ( name: string, columnTitle: string, @@ -127,11 +154,32 @@ export type DatabasesPageProps = DatabasesPageData & interface DatabasesPageState { pagination: ISortedTablePagination; + filters?: Filters; + activeFilters?: number; lastDetailsError: Error; } class DatabasesSortedTable extends SortedTable {} +// filterBySearchQuery returns true if the search query matches the database name. +function filterBySearchQuery( + database: DatabasesPageDataDatabase, + search: string, +): boolean { + const matchString = database.name.toLowerCase(); + + if (search.startsWith('"') && search.endsWith('"')) { + search = search.substring(1, search.length - 1); + + return matchString.includes(search); + } + + return search + .toLowerCase() + .split(" ") + .every(val => matchString.includes(val)); +} + export class DatabasesPage extends React.Component< DatabasesPageProps, DatabasesPageState @@ -140,6 +188,7 @@ export class DatabasesPage extends React.Component< super(props); this.state = { + filters: defaultFilters, pagination: { current: 1, pageSize: 20, @@ -147,6 +196,9 @@ export class DatabasesPage extends React.Component< lastDetailsError: null, }; + const stateFromHistory = this.getStateFromHistory(); + this.state = merge(this.state, stateFromHistory); + const { history } = this.props; const searchParams = new URLSearchParams(history.location.search); const ascending = (searchParams.get("ascending") || undefined) === "true"; @@ -163,11 +215,65 @@ export class DatabasesPage extends React.Component< } } + getStateFromHistory = (): Partial => { + const { + history, + search, + sortSetting, + filters, + onFilterChange, + onSearchComplete, + onSortingChange, + } = this.props; + + const searchParams = new URLSearchParams(history.location.search); + + const searchQuery = searchParams.get("q") || undefined; + if (onSearchComplete && searchQuery && search != searchQuery) { + onSearchComplete(searchQuery); + } + + handleSortSettingFromQueryString( + "Databases", + history.location.search, + sortSetting, + onSortingChange, + ); + + const latestFilter = handleFiltersFromQueryString( + history, + filters, + onFilterChange, + ); + + return { + filters: latestFilter, + activeFilters: calculateActiveFilters(latestFilter), + }; + }; + componentDidMount(): void { this.refresh(); } + updateQueryParams(): void { + const { history, search } = this.props; + + // Search + const searchParams = new URLSearchParams(history.location.search); + const searchQueryString = searchParams.get("q") || ""; + if (search && search != searchQueryString) { + syncHistory( + { + q: search, + }, + history, + ); + } + } + componentDidUpdate(): void { + this.updateQueryParams(); this.refresh(); } @@ -232,6 +338,121 @@ export class DatabasesPage extends React.Component< } }; + resetPagination = (): void => { + this.setState(prevState => { + return { + pagination: { + current: 1, + pageSize: prevState.pagination.pageSize, + }, + }; + }); + }; + + onClearSearchField = (): void => { + if (this.props.onSearchComplete) { + this.props.onSearchComplete(""); + } + + syncHistory( + { + q: undefined, + }, + this.props.history, + ); + }; + + onClearFilters = (): void => { + if (this.props.onFilterChange) { + this.props.onFilterChange(defaultFilters); + } + + this.setState({ + filters: defaultFilters, + activeFilters: 0, + }); + + this.resetPagination(); + syncHistory( + { + regions: undefined, + nodes: undefined, + }, + this.props.history, + ); + }; + + onSubmitSearchField = (search: string): void => { + if (this.props.onSearchComplete) { + this.props.onSearchComplete(search); + } + + this.resetPagination(); + syncHistory( + { + q: search, + }, + this.props.history, + ); + }; + + onSubmitFilters = (filters: Filters): void => { + if (this.props.onFilterChange) { + this.props.onFilterChange(filters); + } + + this.setState({ + filters: filters, + activeFilters: calculateActiveFilters(filters), + }); + + this.resetPagination(); + syncHistory( + { + regions: filters.regions, + nodes: filters.nodes, + }, + this.props.history, + ); + }; + + // Returns a list of databses to the display based on input from the search + // box and the applied filters. + filteredDatabasesData = (): DatabasesPageDataDatabase[] => { + const { search, databases, filters, nodeRegions } = this.props; + + // The regions and nodes selected from the filter dropdown. + const regionsSelected = + filters.regions.length > 0 ? filters.regions.split(",") : []; + const nodesSelected = + filters.nodes.length > 0 ? filters.nodes.split(",") : []; + + return databases + .filter(db => (search ? filterBySearchQuery(db, search) : true)) + .filter(db => { + if (regionsSelected.length == 0 && nodesSelected.length == 0) + return true; + + let foundRegion = regionsSelected.length == 0; + let foundNode = nodesSelected.length == 0; + + db.nodes?.forEach(node => { + if ( + foundRegion || + regionsSelected.includes(nodeRegions[node.toString()]) + ) { + foundRegion = true; + } + if (foundNode || nodesSelected.includes("n" + node.toString())) { + foundNode = true; + } + if (foundNode && foundRegion) return true; + }); + + return foundRegion && foundNode; + }); + }; + private renderIndexRecommendations = ( database: DatabasesPageDataDatabase, ): React.ReactNode => { @@ -334,7 +555,7 @@ export class DatabasesPage extends React.Component< placement="bottom" title="Regions/Nodes on which the database tables are located." > - Regions/Nodes + {this.props.isTenant ? "Regions" : "Regions/Nodes"} ), cell: database => @@ -369,6 +590,20 @@ export class DatabasesPage extends React.Component< const displayColumns = this.columns.filter( col => col.showByDefault !== false, ); + + const { filters, search, nodeRegions, isTenant } = this.props; + const { pagination } = this.state; + + const databasesToDisplay = this.filteredDatabasesData(); + const activeFilters = calculateActiveFilters(filters); + + const nodes = Object.keys(nodeRegions) + .map(n => Number(n)) + .sort(); + + const regions = unique(Object.values(nodeRegions)); + const showNodes = !isTenant && nodes.length > 1; + return (
@@ -396,20 +631,36 @@ export class DatabasesPage extends React.Component< )}
-
-

- + + -

-
- + + + "n" + n.toString())} + activeFilters={activeFilters} + filters={defaultFilters} + onSubmitFilters={this.onSubmitFilters} + showNodes={showNodes} + showRegions={regions.length > 1} + /> + + + ( { showRegions, showNodes, timeLabel, + hideTimeLabel, showUsername, showSessionStatus, showSchemaInsightTypes, @@ -672,32 +674,34 @@ export class Filter extends React.Component { {showSqlType ? sqlTypeFilter : ""} {showRegions ? regionsFilter : ""} {showNodes ? nodesFilter : ""} - {filters.timeUnit && ( - <> -
- {timeLabel - ? `${timeLabel} runs longer than` - : "Statement fingerprint runs longer than"} -
-
- this.handleChange(e, "timeNumber")} - onFocus={this.clearInput} - className={timePair.timeNumber} - /> - this.handleChange(e, "timeNumber")} + onFocus={this.clearInput} + className={timePair.timeNumber} + /> +