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} + /> +