diff --git a/pkg/ui/workspaces/cluster-ui/src/api/index.ts b/pkg/ui/workspaces/cluster-ui/src/api/index.ts index 8e97efd8eb46..7f7bf1678b40 100644 --- a/pkg/ui/workspaces/cluster-ui/src/api/index.ts +++ b/pkg/ui/workspaces/cluster-ui/src/api/index.ts @@ -16,3 +16,4 @@ export * from "./nodesApi"; export * from "./clusterLocksApi"; export * from "./insightsApi"; export * from "./indexActionsApi"; +export * from "./schemaInsightsApi"; diff --git a/pkg/ui/workspaces/cluster-ui/src/api/schemaInsightsApi.ts b/pkg/ui/workspaces/cluster-ui/src/api/schemaInsightsApi.ts new file mode 100644 index 000000000000..e53359869262 --- /dev/null +++ b/pkg/ui/workspaces/cluster-ui/src/api/schemaInsightsApi.ts @@ -0,0 +1,195 @@ +// Copyright 2022 The Cockroach Authors. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0, included in the file +// licenses/APL.txt. + +import { executeSql, SqlExecutionRequest, SqlTxnResult } from "./sqlApi"; +import { + InsightRecommendation, + InsightType, + recommendDropUnusedIndex, +} from "../insights"; +import { HexStringToInt64String } from "../util"; + +// Export for db-console import from clusterUiApi. +export type { InsightRecommendation } from "../insights"; + +export type ClusterIndexUsageStatistic = { + table_id: number; + index_id: number; + last_read?: string; + created_at?: string; + index_name: string; + table_name: string; + database_id: number; + database_name: string; + unused_threshold: string; +}; + +type CreateIndexRecommendationsResponse = { + fingerprint_id: string; + db: string; + query: string; + querysummary: string; + implicittxn: boolean; + index_recommendations: string[]; +}; + +type SchemaInsightResponse = + | ClusterIndexUsageStatistic + | CreateIndexRecommendationsResponse; +type SchemaInsightQuery = { + name: InsightType; + query: string; + toSchemaInsight: (response: SqlTxnResult) => InsightRecommendation[]; +}; + +function clusterIndexUsageStatsToSchemaInsight( + txn_result: SqlTxnResult, +): InsightRecommendation[] { + const results: Record = {}; + + txn_result.rows.forEach(row => { + const result = recommendDropUnusedIndex(row); + if (result.recommend) { + const key = row.table_id.toString() + row.index_id.toString(); + if (!results[key]) { + results[key] = { + type: "DROP_INDEX", + database: row.database_name, + query: `DROP INDEX ${row.table_name}@${row.index_name};`, + indexDetails: { + table: row.table_name, + indexID: row.index_id, + indexName: row.index_name, + lastUsed: result.reason, + }, + }; + } + } + }); + + return Object.values(results); +} + +function createIndexRecommendationsToSchemaInsight( + txn_result: SqlTxnResult, +): InsightRecommendation[] { + const results: InsightRecommendation[] = []; + + txn_result.rows.forEach(row => { + row.index_recommendations.forEach(rec => { + const recSplit = rec.split(" : "); + const recType = recSplit[0]; + const recQuery = recSplit[1]; + let idxType: InsightType; + switch (recType) { + case "creation": + idxType = "CREATE_INDEX"; + break; + case "replacement": + idxType = "REPLACE_INDEX"; + break; + case "drop": + idxType = "DROP_INDEX"; + break; + } + + results.push({ + type: idxType, + database: row.db, + execution: { + statement: row.query, + summary: row.querysummary, + fingerprintID: HexStringToInt64String(row.fingerprint_id), + implicit: row.implicittxn, + }, + query: recQuery, + }); + }); + }); + return results; +} + +const dropUnusedIndexQuery: SchemaInsightQuery = { + name: "DROP_INDEX", + query: `SELECT + us.table_id, + us.index_id, + us.last_read, + ti.created_at, + ti.index_name, + t.name as table_name, + t.parent_id as database_id, + t.database_name, + (SELECT value FROM crdb_internal.cluster_settings WHERE variable = 'sql.index_recommendation.drop_unused_duration') AS unused_threshold + FROM "".crdb_internal.index_usage_statistics AS us + JOIN "".crdb_internal.table_indexes as ti ON us.index_id = ti.index_id AND us.table_id = ti.descriptor_id + JOIN "".crdb_internal.tables as t ON t.table_id = ti.descriptor_id and t.name = ti.descriptor_name + WHERE t.database_name != 'system' AND ti.index_type != 'primary';`, + toSchemaInsight: clusterIndexUsageStatsToSchemaInsight, +}; + +const createIndexRecommendationsQuery: SchemaInsightQuery = + { + name: "CREATE_INDEX", + query: `SELECT + encode(fingerprint_id, 'hex') AS fingerprint_id, + metadata ->> 'db' AS db, + metadata ->> 'query' AS query, + metadata ->> 'querySummary' as querySummary, + metadata ->> 'implicitTxn' AS implicitTxn, + index_recommendations + FROM ( + SELECT + fingerprint_id, + statistics -> 'statistics' ->> 'lastExecAt' as lastExecAt, + metadata, + index_recommendations, + row_number() over( + PARTITION BY + fingerprint_id + ORDER BY statistics -> 'statistics' ->> 'lastExecAt' DESC + ) AS rank + FROM crdb_internal.statement_statistics WHERE aggregated_ts >= now() - INTERVAL '1 week') + WHERE rank=1 AND array_length(index_recommendations,1) > 0;`, + toSchemaInsight: createIndexRecommendationsToSchemaInsight, + }; + +const schemaInsightQueries: SchemaInsightQuery[] = [ + dropUnusedIndexQuery, + createIndexRecommendationsQuery, +]; + +// getSchemaInsights makes requests over the SQL API and transforms the corresponding +// SQL responses into schema insights. +export function getSchemaInsights(): Promise { + const request: SqlExecutionRequest = { + statements: schemaInsightQueries.map(insightQuery => ({ + sql: insightQuery.query, + })), + execute: true, + }; + return executeSql(request).then(result => { + const results: InsightRecommendation[] = []; + if (result.execution.txn_results.length === 0) { + // No data. + return results; + } + + result.execution.txn_results.map(txn_result => { + // Note: txn_result.statement values begin at 1, not 0. + const insightQuery: SchemaInsightQuery = + schemaInsightQueries[txn_result.statement - 1]; + if (txn_result.rows) { + results.push(...insightQuery.toSchemaInsight(txn_result)); + } + }); + return results; + }); +} diff --git a/pkg/ui/workspaces/cluster-ui/src/insights/index.ts b/pkg/ui/workspaces/cluster-ui/src/insights/index.ts index 6bccb1327b89..c16a8431c2cf 100644 --- a/pkg/ui/workspaces/cluster-ui/src/insights/index.ts +++ b/pkg/ui/workspaces/cluster-ui/src/insights/index.ts @@ -10,5 +10,7 @@ export * from "./workloadInsights"; export * from "./workloadInsightDetails"; +export * from "./schemaInsights"; export * from "./utils"; export * from "./types"; +export * from "./insightsErrorComponent"; diff --git a/pkg/ui/workspaces/cluster-ui/src/insights/indexActionBtn.tsx b/pkg/ui/workspaces/cluster-ui/src/insights/indexActionBtn.tsx index 8e8b6cb64a13..5045c9074ddd 100644 --- a/pkg/ui/workspaces/cluster-ui/src/insights/indexActionBtn.tsx +++ b/pkg/ui/workspaces/cluster-ui/src/insights/indexActionBtn.tsx @@ -11,7 +11,6 @@ import React, { useCallback, useState } from "react"; import { Modal } from "../modal"; import { Text, TextTypes } from "../text"; -import { InsightType } from "../insightsTable/insightsTable"; import { Button } from "../button"; import { executeIndexRecAction, IndexActionResponse } from "../api"; import { createIndex, dropIndex, onlineSchemaChanges } from "../util"; @@ -19,6 +18,7 @@ import { Anchor } from "../anchor"; import { InlineAlert } from "@cockroachlabs/ui-components"; import classNames from "classnames/bind"; import styles from "./indexActionBtn.module.scss"; +import { InsightType } from "./types"; const cx = classNames.bind(styles); diff --git a/pkg/ui/workspaces/cluster-ui/src/insights/workloadInsights/util/workloadInsightsError.tsx b/pkg/ui/workspaces/cluster-ui/src/insights/insightsErrorComponent.tsx similarity index 89% rename from pkg/ui/workspaces/cluster-ui/src/insights/workloadInsights/util/workloadInsightsError.tsx rename to pkg/ui/workspaces/cluster-ui/src/insights/insightsErrorComponent.tsx index ec74ae802229..2ff7abbbe9b6 100644 --- a/pkg/ui/workspaces/cluster-ui/src/insights/workloadInsights/util/workloadInsightsError.tsx +++ b/pkg/ui/workspaces/cluster-ui/src/insights/insightsErrorComponent.tsx @@ -10,7 +10,7 @@ import React from "react"; import classNames from "classnames/bind"; -import styles from "./workloadInsights.module.scss"; +import styles from "./workloadInsights/util/workloadInsights.module.scss"; const cx = classNames.bind(styles); @@ -18,7 +18,7 @@ type SQLInsightsErrorProps = { execType: string; }; -export const WorkloadInsightsError = ( +export const InsightsError = ( props: SQLInsightsErrorProps, ): React.ReactElement => { return ( diff --git a/pkg/ui/workspaces/cluster-ui/src/insights/schemaInsights/emptySchemaInsightsTablePlaceholder.tsx b/pkg/ui/workspaces/cluster-ui/src/insights/schemaInsights/emptySchemaInsightsTablePlaceholder.tsx new file mode 100644 index 000000000000..9c6ac0bbf42d --- /dev/null +++ b/pkg/ui/workspaces/cluster-ui/src/insights/schemaInsights/emptySchemaInsightsTablePlaceholder.tsx @@ -0,0 +1,32 @@ +// Copyright 2022 The Cockroach Authors. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0, included in the file +// licenses/APL.txt. + +import React from "react"; +import { EmptyTable, EmptyTableProps } from "src/empty"; +import magnifyingGlassImg from "src/assets/emptyState/magnifying-glass.svg"; +import emptyTableResultsImg from "src/assets/emptyState/empty-table-results.svg"; + +const emptySearchResults = { + title: "No schema insight match your search.", + icon: magnifyingGlassImg, +}; + +export const EmptySchemaInsightsTablePlaceholder: React.FC<{ + isEmptySearchResults: boolean; +}> = props => { + const emptyPlaceholderProps: EmptyTableProps = props.isEmptySearchResults + ? emptySearchResults + : { + title: "No schema insight since this page was last refreshed.", + icon: emptyTableResultsImg, + }; + + return ; +}; diff --git a/pkg/ui/workspaces/cluster-ui/src/insights/schemaInsights/index.ts b/pkg/ui/workspaces/cluster-ui/src/insights/schemaInsights/index.ts new file mode 100644 index 000000000000..45c5dbc2f62a --- /dev/null +++ b/pkg/ui/workspaces/cluster-ui/src/insights/schemaInsights/index.ts @@ -0,0 +1,13 @@ +// Copyright 2022 The Cockroach Authors. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0, included in the file +// licenses/APL.txt. + +export * from "./indexUsageStatsRec"; +export * from "./schemaInsightsView"; +export * from "./emptySchemaInsightsTablePlaceholder"; diff --git a/pkg/ui/workspaces/cluster-ui/src/insights/schemaInsights/indexUsageStatsRec.spec.ts b/pkg/ui/workspaces/cluster-ui/src/insights/schemaInsights/indexUsageStatsRec.spec.ts new file mode 100644 index 000000000000..14e176f4b2e6 --- /dev/null +++ b/pkg/ui/workspaces/cluster-ui/src/insights/schemaInsights/indexUsageStatsRec.spec.ts @@ -0,0 +1,130 @@ +// Copyright 2022 The Cockroach Authors. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0, included in the file +// licenses/APL.txt. + +import { + formatMomentDuration, + indexNeverUsedReason, + recommendDropUnusedIndex, +} from "./indexUsageStatsRec"; +import { ClusterIndexUsageStatistic } from "../../api"; +import moment from "moment"; + +describe("recommendDropUnusedIndex", () => { + const mockCurrentTime = moment(); + const oneHourAgo: moment.Moment = moment(mockCurrentTime).subtract(1, "hour"); + + describe("Recently Used Index", () => { + const recentlyUsedIndex: ClusterIndexUsageStatistic = { + table_id: 1, + index_id: 1, + last_read: moment.utc(oneHourAgo, "X").format(), + created_at: null, + index_name: "recent_index", + table_name: "test_table", + database_id: 1, + database_name: "test_db", + unused_threshold: "10h0m0s", + }; + it("should not recommend index to be dropped", () => { + expect(recommendDropUnusedIndex(recentlyUsedIndex)).toEqual({ + recommend: false, + reason: "", + }); + }); + }); + describe("Never Used Index", () => { + const neverUsedIndex: ClusterIndexUsageStatistic = { + table_id: 1, + index_id: 1, + last_read: null, + created_at: null, + index_name: "recent_index", + table_name: "test_table", + database_id: 1, + database_name: "test_db", + unused_threshold: "10h0m0s", + }; + it("should recommend index to be dropped with the reason that the index is never used", () => { + expect(recommendDropUnusedIndex(neverUsedIndex)).toEqual({ + recommend: true, + reason: indexNeverUsedReason, + }); + }); + }); + describe("Index Last Use Exceeds Duration Threshold", () => { + const exceedsDurationIndex: ClusterIndexUsageStatistic = { + table_id: 1, + index_id: 1, + last_read: moment.utc(oneHourAgo, "X").format(), + created_at: null, + index_name: "recent_index", + table_name: "test_table", + database_id: 1, + database_name: "test_db", + unused_threshold: "0h30m0s", + }; + it("should recommend index to be dropped with the reason that it has exceeded the configured index unuse duration", () => { + expect(recommendDropUnusedIndex(exceedsDurationIndex)).toEqual({ + recommend: true, + reason: `This index has not been used in over ${formatMomentDuration( + moment.duration( + "PT" + exceedsDurationIndex.unused_threshold.toUpperCase(), + ), + )} and can be removed for better write performance.`, + }); + }); + }); + describe("Index Created But Never Read", () => { + describe("creation date does not exceed unuse duration", () => { + const createdNeverReadIndexNoExceed: ClusterIndexUsageStatistic = { + table_id: 1, + index_id: 1, + last_read: null, + created_at: moment.utc(oneHourAgo, "X").format(), + index_name: "recent_index", + table_name: "test_table", + database_id: 1, + database_name: "test_db", + unused_threshold: "10h0m0s", + }; + it("should not recommend index to be dropped", () => { + expect(recommendDropUnusedIndex(createdNeverReadIndexNoExceed)).toEqual( + { + recommend: false, + reason: "", + }, + ); + }); + }); + describe("creation date exceeds unuse duration", () => { + const createdNeverReadIndexExceed: ClusterIndexUsageStatistic = { + table_id: 1, + index_id: 1, + last_read: null, + created_at: moment.utc(oneHourAgo, "X").format(), + index_name: "recent_index", + table_name: "test_table", + database_id: 1, + database_name: "test_db", + unused_threshold: "0h30m0s", + }; + it("should recommend index to be dropped with the reason that it has exceeded the configured index unuse duration", () => { + expect(recommendDropUnusedIndex(createdNeverReadIndexExceed)).toEqual({ + recommend: true, + reason: `This index has not been used in over ${formatMomentDuration( + moment.duration( + "PT" + createdNeverReadIndexExceed.unused_threshold.toUpperCase(), + ), + )} and can be removed for better write performance.`, + }); + }); + }); + }); +}); diff --git a/pkg/ui/workspaces/cluster-ui/src/insights/schemaInsights/indexUsageStatsRec.ts b/pkg/ui/workspaces/cluster-ui/src/insights/schemaInsights/indexUsageStatsRec.ts new file mode 100644 index 000000000000..516dc6e6be7c --- /dev/null +++ b/pkg/ui/workspaces/cluster-ui/src/insights/schemaInsights/indexUsageStatsRec.ts @@ -0,0 +1,73 @@ +// Copyright 2022 The Cockroach Authors. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0, included in the file +// licenses/APL.txt. + +import { ClusterIndexUsageStatistic } from "../../api/schemaInsightsApi"; +import moment from "moment"; + +export const indexNeverUsedReason = + "This index has not been used and can be removed for better write performance."; + +const minDate = moment.utc("0001-01-01"); // minimum value as per UTC. + +type dropIndexRecommendation = { + recommend: boolean; + reason: string; +}; + +export function recommendDropUnusedIndex( + clusterIndexUsageStat: ClusterIndexUsageStatistic, +): dropIndexRecommendation { + const createdAt = clusterIndexUsageStat.created_at + ? moment.utc(clusterIndexUsageStat.created_at) + : minDate; + const lastRead = clusterIndexUsageStat.last_read + ? moment.utc(clusterIndexUsageStat.last_read) + : minDate; + let lastActive = createdAt; + if (lastActive.isSame(minDate) && !lastRead.isSame(minDate)) { + lastActive = lastRead; + } + + if (lastActive.isSame(minDate)) { + return { recommend: true, reason: indexNeverUsedReason }; + } + + const duration = moment.duration(moment().diff(lastActive)); + const unusedThreshold = moment.duration( + "PT" + clusterIndexUsageStat.unused_threshold.toUpperCase(), + ); + if (duration >= unusedThreshold) { + return { + recommend: true, + reason: `This index has not been used in over ${formatMomentDuration( + unusedThreshold, + )} and can be removed for better write performance.`, + }; + } + return { recommend: false, reason: "" }; +} + +export function formatMomentDuration(duration: moment.Duration): string { + const numSecondsInMinute = 60; + const numMinutesInHour = 60; + const numHoursInDay = 24; + + const seconds = Math.floor(duration.as("s")) % numSecondsInMinute; + const minutes = Math.floor(duration.as("m")) % numMinutesInHour; + const hours = Math.floor(duration.as("h")) % numHoursInDay; + const days = Math.floor(duration.as("d")); + + const daysSubstring = days > 0 ? `${days} days, ` : ""; + const hoursSubstring = hours > 0 ? `${hours} hours, ` : ""; + const minutesSubstring = minutes > 0 ? `${minutes} minutes, ` : ""; + const secondsSubstring = seconds > 0 ? `${seconds} seconds, ` : ""; + + return `${daysSubstring}${hoursSubstring}${minutesSubstring}${secondsSubstring}`; +} diff --git a/pkg/ui/workspaces/cluster-ui/src/insights/schemaInsights/schemaInsights.fixture.ts b/pkg/ui/workspaces/cluster-ui/src/insights/schemaInsights/schemaInsights.fixture.ts new file mode 100644 index 000000000000..ff49590043cc --- /dev/null +++ b/pkg/ui/workspaces/cluster-ui/src/insights/schemaInsights/schemaInsights.fixture.ts @@ -0,0 +1,74 @@ +// Copyright 2022 The Cockroach Authors. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0, included in the file +// licenses/APL.txt. + +import { SchemaInsightsViewProps } from "./schemaInsightsView"; + +export const SchemaInsightsPropsFixture: SchemaInsightsViewProps = { + schemaInsights: [ + { + type: "DROP_INDEX", + database: "db_name", + indexDetails: { + table: "table_name", + indexID: 1, + indexName: "index_name", + lastUsed: + "This index has not been used and can be removed for better write performance.", + }, + }, + { + type: "DROP_INDEX", + database: "db_name2", + indexDetails: { + table: "table_name2", + indexID: 2, + indexName: "index_name2", + lastUsed: + "This index has not been used in over 9 days, 5 hours, and 3 minutes and can be removed for better write performance.", + }, + }, + { + type: "CREATE_INDEX", + database: "db_name", + query: "CREATE INDEX ON test_table (another_num) STORING (num);", + execution: { + statement: "SELECT * FROM test_table WHERE another_num > _", + summary: "SELECT * FROM test_table", + fingerprintID: "\\xc093e4523ab0bd3e", + implicit: true, + }, + }, + { + type: "CREATE_INDEX", + database: "db_name", + query: "CREATE INDEX ON test_table (yet_another_num) STORING (num);", + execution: { + statement: "SELECT * FROM test_table WHERE yet_another_num > _", + summary: "SELECT * FROM test_table", + fingerprintID: "\\xc093e4523ab0db9o", + implicit: false, + }, + }, + ], + schemaInsightsDatabases: ["db_name", "db_name2"], + schemaInsightsTypes: ["DROP_INDEX", "CREATE_INDEX"], + schemaInsightsError: null, + sortSetting: { + ascending: false, + columnTitle: "insights", + }, + filters: { + database: "", + schemaInsightType: "", + }, + refreshSchemaInsights: () => {}, + onSortChange: () => {}, + onFiltersChange: () => {}, +}; diff --git a/pkg/ui/workspaces/cluster-ui/src/insights/schemaInsights/schemaInsightsView.tsx b/pkg/ui/workspaces/cluster-ui/src/insights/schemaInsights/schemaInsightsView.tsx new file mode 100644 index 000000000000..0eced088f120 --- /dev/null +++ b/pkg/ui/workspaces/cluster-ui/src/insights/schemaInsights/schemaInsightsView.tsx @@ -0,0 +1,251 @@ +// Copyright 2022 The Cockroach Authors. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0, included in the file +// licenses/APL.txt. + +import React, { useContext } from "react"; +import styles from "src/statementsPage/statementsPage.module.scss"; +import sortableTableStyles from "src/sortedtable/sortedtable.module.scss"; +import { ISortedTablePagination, SortSetting } from "../../sortedtable"; +import classNames from "classnames/bind"; +import { PageConfig, PageConfigItem } from "../../pageConfig"; +import { Loading } from "../../loading"; +import { useEffect, useState } from "react"; +import { useHistory } from "react-router-dom"; +import { + InsightsSortedTable, + makeInsightsColumns, +} from "../../insightsTable/insightsTable"; +import { + calculateActiveFilters, + defaultFilters, + Filter, + getFullFiltersAsStringRecord, +} from "../../queryFilter"; +import { queryByName, syncHistory } from "../../util"; +import { getTableSortFromURL } from "../../sortedtable/getTableSortFromURL"; +import { TableStatistics } from "../../tableStatistics"; +import { InsightRecommendation, SchemaInsightEventFilters } from "../types"; +import { getSchemaInsightEventFiltersFromURL } from "../../queryFilter/utils"; +import { filterSchemaInsights } from "../utils"; +import { Search } from "../../search"; +import { InsightsError } from "../insightsErrorComponent"; +import { Pagination } from "../../pagination"; +import { EmptySchemaInsightsTablePlaceholder } from "./emptySchemaInsightsTablePlaceholder"; +import { CockroachCloudContext } from "../../contexts"; +const cx = classNames.bind(styles); +const sortableTableCx = classNames.bind(sortableTableStyles); + +export type SchemaInsightsViewStateProps = { + schemaInsights: InsightRecommendation[]; + schemaInsightsDatabases: string[]; + schemaInsightsTypes: string[]; + schemaInsightsError: Error | null; + filters: SchemaInsightEventFilters; + sortSetting: SortSetting; +}; + +export type SchemaInsightsViewDispatchProps = { + onFiltersChange: (filters: SchemaInsightEventFilters) => void; + onSortChange: (ss: SortSetting) => void; + refreshSchemaInsights: () => void; +}; + +export type SchemaInsightsViewProps = SchemaInsightsViewStateProps & + SchemaInsightsViewDispatchProps; + +const SCHEMA_INSIGHT_SEARCH_PARAM = "q"; + +export const SchemaInsightsView: React.FC = ({ + sortSetting, + schemaInsights, + schemaInsightsDatabases, + schemaInsightsTypes, + schemaInsightsError, + filters, + refreshSchemaInsights, + onFiltersChange, + onSortChange, +}: SchemaInsightsViewProps) => { + const isCockroachCloud = useContext(CockroachCloudContext); + const [pagination, setPagination] = useState({ + current: 1, + pageSize: 10, + }); + const history = useHistory(); + const [search, setSearch] = useState( + queryByName(history.location, SCHEMA_INSIGHT_SEARCH_PARAM), + ); + + useEffect(() => { + // Refresh every 5mins. + refreshSchemaInsights(); + const interval = setInterval(refreshSchemaInsights, 60 * 1000 * 5); + return () => { + clearInterval(interval); + }; + }, [refreshSchemaInsights]); + + useEffect(() => { + // We use this effect to sync settings defined on the URL (sort, filters), + // with the redux store. The only time we do this is when the user navigates + // to the page directly via the URL and specifies settings in the query string. + // Note that the desired behaviour is currently that the user is unable to + // clear filters via the URL, and must do so with page controls. + const sortSettingURL = getTableSortFromURL(history.location); + const filtersFromURL = getSchemaInsightEventFiltersFromURL( + history.location, + ); + + if (sortSettingURL) { + onSortChange(sortSettingURL); + } + if (filtersFromURL) { + onFiltersChange(filtersFromURL); + } + }, [history, onFiltersChange, onSortChange]); + + useEffect(() => { + // This effect runs when the filters or sort settings received from + // redux changes and syncs the URL params with redux. + syncHistory( + { + ascending: sortSetting.ascending.toString(), + columnTitle: sortSetting.columnTitle, + ...getFullFiltersAsStringRecord(filters), + [SCHEMA_INSIGHT_SEARCH_PARAM]: search, + }, + history, + ); + }, [ + history, + filters, + sortSetting.ascending, + sortSetting.columnTitle, + search, + ]); + + const onChangePage = (current: number): void => { + setPagination({ + current: current, + pageSize: 10, + }); + }; + + const resetPagination = () => { + setPagination({ + current: 1, + pageSize: 10, + }); + }; + + const onChangeSortSetting = (ss: SortSetting): void => { + onSortChange(ss); + resetPagination(); + }; + + const onSubmitSearch = (newSearch: string) => { + if (newSearch === search) return; + setSearch(newSearch); + resetPagination(); + }; + + const clearSearch = () => onSubmitSearch(""); + + const onSubmitFilters = (selectedFilters: SchemaInsightEventFilters) => { + onFiltersChange(selectedFilters); + resetPagination(); + }; + + const clearFilters = () => + onSubmitFilters({ + database: defaultFilters.database, + schemaInsightType: defaultFilters.schemaInsightType, + }); + + const countActiveFilters = calculateActiveFilters(filters); + + const filteredSchemaInsights = filterSchemaInsights( + schemaInsights, + filters, + search, + ); + + return ( +
+ + + + + + + + +
+ + InsightsError({ + execType: "schema insights", + }) + } + > +
+
+
+ +
+ 0 && filteredSchemaInsights?.length == 0 + } + /> + } + /> +
+ +
+
+
+
+ ); +}; diff --git a/pkg/ui/workspaces/cluster-ui/src/insights/types.ts b/pkg/ui/workspaces/cluster-ui/src/insights/types.ts index 1f0cbd258784..2dc7c7d581e0 100644 --- a/pkg/ui/workspaces/cluster-ui/src/insights/types.ts +++ b/pkg/ui/workspaces/cluster-ui/src/insights/types.ts @@ -108,3 +108,47 @@ export type InsightEventFilters = Omit< | "timeNumber" | "timeUnit" >; + +export type SchemaInsightEventFilters = Pick< + Filters, + "database" | "schemaInsightType" +>; + +export type InsightType = + | "DROP_INDEX" + | "CREATE_INDEX" + | "REPLACE_INDEX" + | "HIGH_WAIT_TIME" + | "HIGH_RETRIES" + | "SUBOPTIMAL_PLAN" + | "FAILED"; + +export interface InsightRecommendation { + type: InsightType; + database?: string; + query?: string; + indexDetails?: indexDetails; + execution?: executionDetails; + details?: insightDetails; +} + +export interface indexDetails { + table: string; + indexID: number; + indexName: string; + lastUsed?: string; +} + +export interface executionDetails { + statement?: string; + summary?: string; + fingerprintID?: string; + implicit?: boolean; + retries?: number; + indexRecommendations?: string[]; +} + +export interface insightDetails { + duration: number; + description: string; +} diff --git a/pkg/ui/workspaces/cluster-ui/src/insights/utils.ts b/pkg/ui/workspaces/cluster-ui/src/insights/utils.ts index 7dc57919f93a..94ae32a15a09 100644 --- a/pkg/ui/workspaces/cluster-ui/src/insights/utils.ts +++ b/pkg/ui/workspaces/cluster-ui/src/insights/utils.ts @@ -22,6 +22,9 @@ import { InsightEvent, InsightEventFilters, InsightEventDetails, + SchemaInsightEventFilters, + InsightType, + InsightRecommendation, } from "./types"; export const getInsights = ( @@ -145,3 +148,76 @@ export function getAppsFromTransactionInsights( return Array.from(uniqueAppNames).sort(); } + +export const filterSchemaInsights = ( + schemaInsights: InsightRecommendation[], + filters: SchemaInsightEventFilters, + search?: string, +): InsightRecommendation[] => { + if (schemaInsights == null) return []; + + let filteredSchemaInsights = schemaInsights; + + if (filters.database) { + const databases = + filters.database.toString().length > 0 + ? filters.database.toString().split(",") + : []; + if (databases.includes(unset)) { + databases.push(""); + } + filteredSchemaInsights = filteredSchemaInsights.filter( + schemaInsight => + databases.length === 0 || databases.includes(schemaInsight.database), + ); + } + + if (filters.schemaInsightType) { + const schemaInsightTypes = + filters.schemaInsightType.toString().length > 0 + ? filters.schemaInsightType.toString().split(",") + : []; + if (schemaInsightTypes.includes(unset)) { + schemaInsightTypes.push(""); + } + filteredSchemaInsights = filteredSchemaInsights.filter( + schemaInsight => + schemaInsightTypes.length === 0 || + schemaInsightTypes.includes(insightType(schemaInsight.type)), + ); + } + + if (search) { + search = search.toLowerCase(); + filteredSchemaInsights = filteredSchemaInsights.filter( + schemaInsight => + schemaInsight.query?.toLowerCase().includes(search) || + schemaInsight.indexDetails?.indexName?.toLowerCase().includes(search) || + schemaInsight.execution?.statement.toLowerCase().includes(search) || + schemaInsight.execution?.summary.toLowerCase().includes(search) || + schemaInsight.execution?.fingerprintID.toLowerCase().includes(search), + ); + } + return filteredSchemaInsights; +}; + +export function insightType(type: InsightType): string { + switch (type) { + case "CREATE_INDEX": + return "Create New Index"; + case "DROP_INDEX": + return "Drop Unused Index"; + case "REPLACE_INDEX": + return "Replace Index"; + case "HIGH_WAIT_TIME": + return "High Wait Time"; + case "HIGH_RETRIES": + return "High Retry Counts"; + case "SUBOPTIMAL_PLAN": + return "Sub-Optimal Plan"; + case "FAILED": + return "Failed Execution"; + default: + return "Insight"; + } +} diff --git a/pkg/ui/workspaces/cluster-ui/src/insights/workloadInsightDetails/transactionInsightDetails.tsx b/pkg/ui/workspaces/cluster-ui/src/insights/workloadInsightDetails/transactionInsightDetails.tsx index e024967622f8..fdea1e3c0b6b 100644 --- a/pkg/ui/workspaces/cluster-ui/src/insights/workloadInsightDetails/transactionInsightDetails.tsx +++ b/pkg/ui/workspaces/cluster-ui/src/insights/workloadInsightDetails/transactionInsightDetails.tsx @@ -32,19 +32,18 @@ import { InsightEventDetailsResponse, } from "src/api"; import { - InsightRecommendation, InsightsSortedTable, makeInsightsColumns, } from "src/insightsTable/insightsTable"; import { WaitTimeDetailsTable } from "./insightDetailsTables"; import { getInsightEventDetailsFromState } from "../utils"; -import { EventExecution } from "../types"; -import { WorkloadInsightsError } from "../workloadInsights/util"; +import { EventExecution, InsightRecommendation } from "../types"; import classNames from "classnames/bind"; import { commonStyles } from "src/common"; import insightTableStyles from "src/insightsTable/insightsTable.module.scss"; import { CockroachCloudContext } from "../../contexts"; +import { InsightsError } from "../insightsErrorComponent"; const tableCx = classNames.bind(insightTableStyles); @@ -227,7 +226,7 @@ export class InsightDetails extends React.Component { error={this.props.insightError} render={this.renderContent} renderError={() => - WorkloadInsightsError({ + InsightsError({ execType: "transaction insights", }) } diff --git a/pkg/ui/workspaces/cluster-ui/src/insights/workloadInsights/transactionInsights/transactionInsightsView.tsx b/pkg/ui/workspaces/cluster-ui/src/insights/workloadInsights/transactionInsights/transactionInsightsView.tsx index 7ac0463e3eb6..e834243a84b3 100644 --- a/pkg/ui/workspaces/cluster-ui/src/insights/workloadInsights/transactionInsights/transactionInsightsView.tsx +++ b/pkg/ui/workspaces/cluster-ui/src/insights/workloadInsights/transactionInsights/transactionInsightsView.tsx @@ -37,8 +37,9 @@ import { getInsightsFromState, InsightEventFilters, } from "src/insights"; -import { EmptyInsightsTablePlaceholder, WorkloadInsightsError } from "../util"; +import { EmptyInsightsTablePlaceholder } from "../util"; import { TransactionInsightsTable } from "./transactionInsightsTable"; +import { InsightsError } from "../../insightsErrorComponent"; import styles from "src/statementsPage/statementsPage.module.scss"; import sortableTableStyles from "src/sortedtable/sortedtable.module.scss"; @@ -214,7 +215,7 @@ export const TransactionInsightsView: React.FC< page="transaction insights" error={transactionsError} renderError={() => - WorkloadInsightsError({ + InsightsError({ execType: "transaction insights", }) } diff --git a/pkg/ui/workspaces/cluster-ui/src/insights/workloadInsights/util/index.ts b/pkg/ui/workspaces/cluster-ui/src/insights/workloadInsights/util/index.ts index 3bbb3d532f1e..67f1409e71c9 100644 --- a/pkg/ui/workspaces/cluster-ui/src/insights/workloadInsights/util/index.ts +++ b/pkg/ui/workspaces/cluster-ui/src/insights/workloadInsights/util/index.ts @@ -13,4 +13,3 @@ export * from "./queriesCell"; export * from "./emptyInsightsTablePlaceholder"; export * from "./insightsColumns"; export * from "./dropDownSelect"; -export * from "./workloadInsightsError"; diff --git a/pkg/ui/workspaces/cluster-ui/src/insightsTable/insightsTable.module.scss b/pkg/ui/workspaces/cluster-ui/src/insightsTable/insightsTable.module.scss index 08152975dd6b..e031916ff009 100644 --- a/pkg/ui/workspaces/cluster-ui/src/insightsTable/insightsTable.module.scss +++ b/pkg/ui/workspaces/cluster-ui/src/insightsTable/insightsTable.module.scss @@ -14,6 +14,10 @@ .description-item { margin-bottom: 5px; + + .table-link { + color: $colors--link + } } .margin-bottom { diff --git a/pkg/ui/workspaces/cluster-ui/src/insightsTable/insightsTable.tsx b/pkg/ui/workspaces/cluster-ui/src/insightsTable/insightsTable.tsx index c8433f634664..78b1489d34eb 100644 --- a/pkg/ui/workspaces/cluster-ui/src/insightsTable/insightsTable.tsx +++ b/pkg/ui/workspaces/cluster-ui/src/insightsTable/insightsTable.tsx @@ -17,42 +17,12 @@ import { StatementLink } from "../statementsTable"; import IdxRecAction from "../insights/indexActionBtn"; import { Duration, statementsRetries } from "../util"; import { Anchor } from "../anchor"; +import { Link } from "react-router-dom"; +import { performanceTuningRecipes } from "../util"; +import { InsightRecommendation, insightType } from "../insights"; const cx = classNames.bind(styles); -export type InsightType = - | "DROP_INDEX" - | "CREATE_INDEX" - | "REPLACE_INDEX" - | "HIGH_WAIT_TIME" - | "HIGH_RETRIES" - | "SUBOPTIMAL_PLAN" - | "FAILED"; - -export interface InsightRecommendation { - type: InsightType; - database?: string; - table?: string; - indexID?: number; - query?: string; - execution?: executionDetails; - details?: insightDetails; -} - -export interface executionDetails { - statement?: string; - summary?: string; - fingerprintID?: string; - implicit?: boolean; - retries?: number; - indexRecommendations?: string[]; -} - -export interface insightDetails { - duration: number; - description: string; -} - export class InsightsSortedTable extends SortedTable {} const insightColumnLabels = { @@ -94,27 +64,6 @@ export const insightsTableTitles: InsightsTableTitleType = { }, }; -function insightType(type: InsightType): string { - switch (type) { - case "CREATE_INDEX": - return "Create New Index"; - case "DROP_INDEX": - return "Drop Unused Index"; - case "REPLACE_INDEX": - return "Replace Index"; - case "HIGH_WAIT_TIME": - return "High Wait Time"; - case "HIGH_RETRIES": - return "High Retry Counts"; - case "SUBOPTIMAL_PLAN": - return "Sub-Optimal Plan"; - case "FAILED": - return "Failed Execution"; - default: - return "Insight"; - } -} - function typeCell(value: string): React.ReactElement { return
{value}
; } @@ -144,7 +93,32 @@ function descriptionCell( ); case "DROP_INDEX": - return <>{`Index ${insightRec.indexID}`}; + return ( + <> +
+ Index: {" "} + + {insightRec.indexDetails.indexName} + +
+
+ Description: {" "} + {insightRec.indexDetails?.lastUsed} + {" Learn more about "} + + unused indexes + + {"."} +
+ + ); case "HIGH_WAIT_TIME": return ( <> diff --git a/pkg/ui/workspaces/cluster-ui/src/queryFilter/filter.spec.tsx b/pkg/ui/workspaces/cluster-ui/src/queryFilter/filter.spec.tsx index 72d29f9f9c10..3bab1b4b9173 100644 --- a/pkg/ui/workspaces/cluster-ui/src/queryFilter/filter.spec.tsx +++ b/pkg/ui/workspaces/cluster-ui/src/queryFilter/filter.spec.tsx @@ -21,6 +21,7 @@ describe("Test filter functions", (): void => { sqlType: "", database: "", regions: "", + schemaInsightType: "", sessionStatus: "", nodes: "", username: "", @@ -39,12 +40,13 @@ describe("Test filter functions", (): void => { sqlType: "DML", database: "movr", regions: "us-central", + schemaInsightType: "Drop Unused Index", sessionStatus: "idle", nodes: "n1,n2", username: "root", }; const resultFilters = getFiltersFromQueryString( - "app=%24+internal&timeNumber=1&timeUnit=milliseconds&fullScan=true&sqlType=DML&database=movr&sessionStatus=idle&username=root®ions=us-central&nodes=n1,n2", + "app=%24+internal&timeNumber=1&timeUnit=milliseconds&fullScan=true&sqlType=DML&database=movr&sessionStatus=idle&username=root®ions=us-central&nodes=n1,n2&schemaInsightType=Drop+Unused+Index", ); expect(resultFilters).toEqual(expectedFilters); }); @@ -58,6 +60,7 @@ describe("Test filter functions", (): void => { sqlType: "", database: "", regions: "", + schemaInsightType: "", sessionStatus: "", nodes: "", username: "", @@ -75,6 +78,7 @@ describe("Test filter functions", (): void => { sqlType: "", database: "", regions: "", + schemaInsightType: "", sessionStatus: "", nodes: "", username: "", @@ -92,6 +96,7 @@ describe("Test filter functions", (): void => { sqlType: "", database: "", regions: "", + schemaInsightType: "", sessionStatus: "open", nodes: "", username: "", @@ -109,6 +114,7 @@ describe("Test filter functions", (): void => { sqlType: "", database: "", regions: "", + schemaInsightType: "", sessionStatus: "idle", nodes: "", username: "", @@ -126,6 +132,7 @@ describe("Test filter functions", (): void => { sqlType: "", database: "", regions: "", + schemaInsightType: "", sessionStatus: "closed", nodes: "", username: "", @@ -133,4 +140,24 @@ describe("Test filter functions", (): void => { const resultFilters = getFiltersFromQueryString("sessionStatus=closed"); expect(resultFilters).toEqual(expectedFilters); }); + + it("testing schemaInsightType", (): void => { + const expectedFilters: Filters = { + app: "", + timeNumber: "0", + timeUnit: "seconds", + fullScan: false, + sqlType: "", + database: "", + regions: "", + schemaInsightType: "Drop Unused Index", + sessionStatus: "", + nodes: "", + username: "", + }; + const resultFilters = getFiltersFromQueryString( + "schemaInsightType=Drop+Unused+Index", + ); + expect(resultFilters).toEqual(expectedFilters); + }); }); diff --git a/pkg/ui/workspaces/cluster-ui/src/queryFilter/filter.tsx b/pkg/ui/workspaces/cluster-ui/src/queryFilter/filter.tsx index e7ab48bd1336..7f0930351a84 100644 --- a/pkg/ui/workspaces/cluster-ui/src/queryFilter/filter.tsx +++ b/pkg/ui/workspaces/cluster-ui/src/queryFilter/filter.tsx @@ -33,17 +33,20 @@ import { syncHistory } from "../util"; interface QueryFilter { onSubmitFilters: (filters: Filters) => void; smth?: string; - appNames: string[]; + appNames?: string[]; activeFilters: number; filters: Filters; dbNames?: string[]; usernames?: string[]; sessionStatuses?: string[]; + schemaInsightTypes?: string[]; regions?: string[]; nodes?: string[]; + hideAppNames?: boolean; showDB?: boolean; showUsername?: boolean; showSessionStatus?: boolean; + showSchemaInsightTypes?: boolean; showSqlType?: boolean; showScan?: boolean; showRegions?: boolean; @@ -71,6 +74,7 @@ export interface Filters extends Record { nodes?: string; username?: string; sessionStatus?: string; + schemaInsightType?: string; } const timeUnit = [ @@ -90,6 +94,7 @@ export const defaultFilters: Required = { nodes: "", username: "", sessionStatus: "", + schemaInsightType: "", }; // getFullFiltersObject returns Filters with every field defined as @@ -383,9 +388,11 @@ export class Filter extends React.Component { dbNames, usernames, sessionStatuses, + schemaInsightTypes, regions, nodes, activeFilters, + hideAppNames, showDB, showSqlType, showScan, @@ -394,6 +401,7 @@ export class Filter extends React.Component { timeLabel, showUsername, showSessionStatus, + showSchemaInsightTypes, } = this.props; const dropdownArea = hide ? hidden : dropdown; const customStyles = { @@ -429,11 +437,13 @@ export class Filter extends React.Component { border: "none", }); - const appsOptions = appNames.map(app => ({ - label: app, - value: app, - isSelected: this.isOptionSelected(app, filters.app), - })); + const appsOptions = !hideAppNames + ? appNames.map(app => ({ + label: app, + value: app, + isSelected: this.isOptionSelected(app, filters.app), + })) + : []; const appValue = appsOptions.filter(option => { return filters.app.split(",").includes(option.label); }); @@ -522,6 +532,32 @@ export class Filter extends React.Component { ); + const schemaInsightTypeOptions = showSchemaInsightTypes + ? schemaInsightTypes.map(schemaInsight => ({ + label: schemaInsight, + value: schemaInsight, + isSelected: this.isOptionSelected( + schemaInsight, + filters.schemaInsightType, + ), + })) + : []; + const schemaInsightTypeValue = schemaInsightTypeOptions.filter(option => { + return filters.schemaInsightType.split(",").includes(option.label); + }); + const schemaInsightTypeFilter = ( +
+
Schema Insight Type
+ +
+ ); + const regionsOptions = showRegions ? regions.map(region => ({ label: region, @@ -633,10 +669,11 @@ export class Filter extends React.Component {
- {appFilter} + {!hideAppNames ? appFilter : ""} {showDB ? dbFilter : ""} {showUsername ? usernameFilter : ""} {showSessionStatus ? sessionStatusFilter : ""} + {showSchemaInsightTypes ? schemaInsightTypeFilter : ""} {showSqlType ? sqlTypeFilter : ""} {showRegions ? regionsFilter : ""} {showNodes ? nodesFilter : ""} diff --git a/pkg/ui/workspaces/cluster-ui/src/queryFilter/utils.ts b/pkg/ui/workspaces/cluster-ui/src/queryFilter/utils.ts index ff67ca7704c0..87f84f5a7e96 100644 --- a/pkg/ui/workspaces/cluster-ui/src/queryFilter/utils.ts +++ b/pkg/ui/workspaces/cluster-ui/src/queryFilter/utils.ts @@ -14,7 +14,7 @@ import { ActiveStatementFilters, ActiveTransactionFilters, } from "src/activeExecutions/types"; -import { InsightEventFilters } from "../insights"; +import { InsightEventFilters, SchemaInsightEventFilters } from "../insights"; // This function returns a Filters object populated with values from the URL, or null // if there were no filters set. @@ -83,3 +83,20 @@ export function getInsightEventFiltersFromURL( return appFilters; } + +export function getSchemaInsightEventFiltersFromURL( + location: Location, +): Partial | null { + const filters = getFiltersFromURL(location); + if (!filters) return null; + + const schemaFilters = { + database: filters.database, + schemaInsightType: filters.schemaInsightType, + }; + + // If every entry is null, there were no active filters. Return null. + if (Object.values(schemaFilters).every(val => !val)) return null; + + return schemaFilters; +} diff --git a/pkg/ui/workspaces/cluster-ui/src/statementDetails/planDetails/planDetails.tsx b/pkg/ui/workspaces/cluster-ui/src/statementDetails/planDetails/planDetails.tsx index 29b2abc65105..ca364bd24cb4 100644 --- a/pkg/ui/workspaces/cluster-ui/src/statementDetails/planDetails/planDetails.tsx +++ b/pkg/ui/workspaces/cluster-ui/src/statementDetails/planDetails/planDetails.tsx @@ -22,14 +22,13 @@ import { SortSetting } from "../../sortedtable"; import { Row } from "antd"; import "antd/lib/row/style"; import { - InsightRecommendation, InsightsSortedTable, - InsightType, makeInsightsColumns, } from "../../insightsTable/insightsTable"; import classNames from "classnames/bind"; import styles from "../statementDetails.module.scss"; import { CockroachCloudContext } from "../../contexts"; +import { InsightRecommendation, InsightType } from "../../insights"; const cx = classNames.bind(styles); diff --git a/pkg/ui/workspaces/cluster-ui/src/statementsTable/statementsTableContent.module.scss b/pkg/ui/workspaces/cluster-ui/src/statementsTable/statementsTableContent.module.scss index a37df251caa2..ca80114c0330 100644 --- a/pkg/ui/workspaces/cluster-ui/src/statementsTable/statementsTableContent.module.scss +++ b/pkg/ui/workspaces/cluster-ui/src/statementsTable/statementsTableContent.module.scss @@ -76,4 +76,5 @@ .inline { display: inline-flex; + color: $colors--link !important; } diff --git a/pkg/ui/workspaces/cluster-ui/src/store/localStorage/localStorage.reducer.ts b/pkg/ui/workspaces/cluster-ui/src/store/localStorage/localStorage.reducer.ts index bdc3c9350adc..5e2ac26a815a 100644 --- a/pkg/ui/workspaces/cluster-ui/src/store/localStorage/localStorage.reducer.ts +++ b/pkg/ui/workspaces/cluster-ui/src/store/localStorage/localStorage.reducer.ts @@ -33,12 +33,14 @@ export type LocalStorageState = { "sortSetting/SessionsPage": SortSetting; "sortSetting/JobsPage": SortSetting; "sortSetting/InsightsPage": SortSetting; + "sortSetting/SchemaInsightsPage": SortSetting; "filters/ActiveStatementsPage": Filters; "filters/ActiveTransactionsPage": Filters; "filters/StatementsPage": Filters; "filters/TransactionsPage": Filters; "filters/SessionsPage": Filters; "filters/InsightsPage": Filters; + "filters/SchemaInsightsPage": Filters; "search/StatementsPage": string; "search/TransactionsPage": string; "typeSetting/JobsPage": number; @@ -66,6 +68,11 @@ const defaultSortSettingInsights: SortSetting = { columnTitle: "startTime", }; +const defaultSortSettingSchemaInsights: SortSetting = { + ascending: false, + columnTitle: "insights", +}; + const defaultFiltersActiveExecutions = { app: defaultFilters.app, }; @@ -74,6 +81,11 @@ const defaultFiltersInsights = { app: defaultFilters.app, }; +const defaultFiltersSchemaInsights = { + database: defaultFilters.database, + schemaInsightType: defaultFilters.schemaInsightType, +}; + const defaultSessionsSortSetting: SortSetting = { ascending: false, columnTitle: "statementAge", @@ -134,6 +146,9 @@ const initialState: LocalStorageState = { "sortSetting/InsightsPage": JSON.parse(localStorage.getItem("sortSetting/InsightsPage")) || defaultSortSettingInsights, + "sortSetting/SchemaInsightsPage": + JSON.parse(localStorage.getItem("sortSetting/SchemaInsightsPage")) || + defaultSortSettingSchemaInsights, "filters/ActiveStatementsPage": JSON.parse(localStorage.getItem("filters/ActiveStatementsPage")) || defaultFiltersActiveExecutions, @@ -151,6 +166,9 @@ const initialState: LocalStorageState = { "filters/InsightsPage": JSON.parse(localStorage.getItem("filters/InsightsPage")) || defaultFiltersInsights, + "filters/SchemaInsightsPage": + JSON.parse(localStorage.getItem("filters/SchemaInsightsPage")) || + defaultFiltersSchemaInsights, "search/StatementsPage": JSON.parse(localStorage.getItem("search/StatementsPage")) || null, "search/TransactionsPage": diff --git a/pkg/ui/workspaces/cluster-ui/src/store/reducers.ts b/pkg/ui/workspaces/cluster-ui/src/store/reducers.ts index dd16b2e56f3e..fd639758947e 100644 --- a/pkg/ui/workspaces/cluster-ui/src/store/reducers.ts +++ b/pkg/ui/workspaces/cluster-ui/src/store/reducers.ts @@ -44,6 +44,10 @@ import { InsightDetailsState, reducer as insightDetails, } from "./insightDetails"; +import { + SchemaInsightsState, + reducer as schemaInsights, +} from "./schemaInsights"; export type AdminUiState = { statementDiagnostics: StatementDiagnosticsState; @@ -61,6 +65,7 @@ export type AdminUiState = { clusterLocks: ClusterLocksReqState; insights: InsightsState; insightDetails: InsightDetailsState; + schemaInsights: SchemaInsightsState; }; export type AppState = { @@ -83,6 +88,7 @@ export const reducers = combineReducers({ jobs, job, clusterLocks, + schemaInsights, }); export const rootActions = { diff --git a/pkg/ui/workspaces/cluster-ui/src/store/sagas.ts b/pkg/ui/workspaces/cluster-ui/src/store/sagas.ts index 324a2519d3b3..bb9b199eb08d 100644 --- a/pkg/ui/workspaces/cluster-ui/src/store/sagas.ts +++ b/pkg/ui/workspaces/cluster-ui/src/store/sagas.ts @@ -26,6 +26,7 @@ import { indexStatsSaga } from "./indexStats/indexStats.sagas"; import { clusterLocksSaga } from "./clusterLocks/clusterLocks.saga"; import { insightsSaga } from "./insights/insights.sagas"; import { insightDetailsSaga } from "./insightDetails"; +import { schemaInsightsSaga } from "./schemaInsights"; export function* sagas(cacheInvalidationPeriod?: number): SagaIterator { yield all([ @@ -44,5 +45,6 @@ export function* sagas(cacheInvalidationPeriod?: number): SagaIterator { fork(sqlDetailsStatsSaga), fork(indexStatsSaga), fork(clusterLocksSaga), + fork(schemaInsightsSaga), ]); } diff --git a/pkg/ui/workspaces/cluster-ui/src/store/schemaInsights/index.ts b/pkg/ui/workspaces/cluster-ui/src/store/schemaInsights/index.ts new file mode 100644 index 000000000000..a5861f51d54c --- /dev/null +++ b/pkg/ui/workspaces/cluster-ui/src/store/schemaInsights/index.ts @@ -0,0 +1,13 @@ +// Copyright 2022 The Cockroach Authors. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0, included in the file +// licenses/APL.txt. + +export * from "./schemaInsights.reducer"; +export * from "./schemaInsights.sagas"; +export * from "./schemaInsights.selectors"; diff --git a/pkg/ui/workspaces/cluster-ui/src/store/schemaInsights/schemaInsights.reducer.ts b/pkg/ui/workspaces/cluster-ui/src/store/schemaInsights/schemaInsights.reducer.ts new file mode 100644 index 000000000000..7691ae0dde45 --- /dev/null +++ b/pkg/ui/workspaces/cluster-ui/src/store/schemaInsights/schemaInsights.reducer.ts @@ -0,0 +1,55 @@ +// Copyright 2022 The Cockroach Authors. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0, included in the file +// licenses/APL.txt. + +import { createSlice, PayloadAction } from "@reduxjs/toolkit"; +import { DOMAIN_NAME, noopReducer } from "../utils"; +import moment, { Moment } from "moment"; +import { InsightRecommendation } from "../../insights"; + +export type SchemaInsightsState = { + data: InsightRecommendation[]; + lastUpdated: Moment; + lastError: Error; + valid: boolean; +}; + +const initialState: SchemaInsightsState = { + data: null, + lastUpdated: null, + lastError: null, + valid: false, +}; + +const schemaInsightsSlice = createSlice({ + name: `${DOMAIN_NAME}/schemaInsightsSlice`, + initialState, + reducers: { + received: (state, action: PayloadAction) => { + state.data = action.payload; + state.valid = true; + state.lastError = null; + state.lastUpdated = moment.utc(); + }, + failed: (state, action: PayloadAction) => { + state.valid = false; + state.lastError = action.payload; + state.lastUpdated = moment.utc(); + }, + invalidated: state => { + state.valid = false; + state.lastUpdated = moment.utc(); + }, + // Define actions that don't change state. + refresh: noopReducer, + request: noopReducer, + }, +}); + +export const { reducer, actions } = schemaInsightsSlice; diff --git a/pkg/ui/workspaces/cluster-ui/src/store/schemaInsights/schemaInsights.sagas.spec.ts b/pkg/ui/workspaces/cluster-ui/src/store/schemaInsights/schemaInsights.sagas.spec.ts new file mode 100644 index 000000000000..35f43ff02db3 --- /dev/null +++ b/pkg/ui/workspaces/cluster-ui/src/store/schemaInsights/schemaInsights.sagas.spec.ts @@ -0,0 +1,100 @@ +// Copyright 2022 The Cockroach Authors. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0, included in the file +// licenses/APL.txt. + +import { expectSaga } from "redux-saga-test-plan"; +import { + EffectProviders, + StaticProvider, + throwError, +} from "redux-saga-test-plan/providers"; +import * as matchers from "redux-saga-test-plan/matchers"; +import moment from "moment"; +import { getSchemaInsights } from "../../api"; +import { + refreshSchemaInsightsSaga, + requestSchemaInsightsSaga, +} from "./schemaInsights.sagas"; +import { + actions, + reducer, + SchemaInsightsState, +} from "./schemaInsights.reducer"; +import { InsightRecommendation } from "../../insights"; + +const lastUpdated = moment(); + +describe("SchemaInsights sagas", () => { + let spy: jest.SpyInstance; + beforeAll(() => { + spy = jest.spyOn(moment, "utc").mockImplementation(() => lastUpdated); + }); + + afterAll(() => { + spy.mockRestore(); + }); + + const schemaInsightsResponse: InsightRecommendation[] = [ + { + type: "DROP_INDEX", + database: "test_database", + query: "DROP INDEX test_table@test_idx;", + indexDetails: { + table: "test_table", + indexName: "test_idx", + indexID: 1, + lastUsed: "2022-08-22T22:30:02Z", + }, + }, + ]; + + const schemaInsightsAPIProvider: (EffectProviders | StaticProvider)[] = [ + [matchers.call.fn(getSchemaInsights), schemaInsightsResponse], + ]; + + describe("refreshSchemaInsightsSaga", () => { + it("dispatches request Schema Insights action", () => { + return expectSaga(refreshSchemaInsightsSaga, actions.request()) + .provide(schemaInsightsAPIProvider) + .put(actions.request()) + .run(); + }); + }); + + describe("requestSchemaInsightsSaga", () => { + it("successfully requests schema insights", () => { + return expectSaga(requestSchemaInsightsSaga, actions.request()) + .provide(schemaInsightsAPIProvider) + .put(actions.received(schemaInsightsResponse)) + .withReducer(reducer) + .hasFinalState({ + data: schemaInsightsResponse, + lastError: null, + valid: true, + lastUpdated, + }) + .run(); + }); + + it("returns error on failed request", () => { + const error = new Error("Failed request"); + return expectSaga(requestSchemaInsightsSaga, actions.request()) + .provide([[matchers.call.fn(getSchemaInsights), throwError(error)]]) + .put(actions.failed(error)) + .withReducer(reducer) + .hasFinalState({ + data: null, + lastError: error, + valid: false, + lastUpdated, + }) + .run(); + }); + }); +}); diff --git a/pkg/ui/workspaces/cluster-ui/src/store/schemaInsights/schemaInsights.sagas.ts b/pkg/ui/workspaces/cluster-ui/src/store/schemaInsights/schemaInsights.sagas.ts new file mode 100644 index 000000000000..3480d9b6c457 --- /dev/null +++ b/pkg/ui/workspaces/cluster-ui/src/store/schemaInsights/schemaInsights.sagas.ts @@ -0,0 +1,43 @@ +// 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 { all, call, put, takeLatest } from "redux-saga/effects"; + +import { actions } from "./schemaInsights.reducer"; +import { CACHE_INVALIDATION_PERIOD, throttleWithReset } from "../utils"; +import { rootActions } from "../reducers"; +import { getSchemaInsights } from "../../api"; + +export function* refreshSchemaInsightsSaga() { + yield put(actions.request()); +} + +export function* requestSchemaInsightsSaga(): any { + try { + const result = yield call(getSchemaInsights); + yield put(actions.received(result)); + } catch (e) { + yield put(actions.failed(e)); + } +} + +export function* schemaInsightsSaga( + cacheInvalidationPeriod: number = CACHE_INVALIDATION_PERIOD, +) { + yield all([ + throttleWithReset( + cacheInvalidationPeriod, + actions.refresh, + [actions.invalidated, rootActions.resetState], + refreshSchemaInsightsSaga, + ), + takeLatest(actions.request, requestSchemaInsightsSaga), + ]); +} diff --git a/pkg/ui/workspaces/cluster-ui/src/store/schemaInsights/schemaInsights.selectors.ts b/pkg/ui/workspaces/cluster-ui/src/store/schemaInsights/schemaInsights.selectors.ts new file mode 100644 index 000000000000..8266fc36a761 --- /dev/null +++ b/pkg/ui/workspaces/cluster-ui/src/store/schemaInsights/schemaInsights.selectors.ts @@ -0,0 +1,43 @@ +// Copyright 2020 The Cockroach Authors. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0, included in the file +// licenses/APL.txt. + +import { createSelector } from "reselect"; +import { adminUISelector } from "../utils/selectors"; +import { insightType } from "../../insights"; + +export const selectSchemaInsights = createSelector( + adminUISelector, + adminUiState => { + if (!adminUiState.schemaInsights) return []; + return adminUiState.schemaInsights.data; + }, +); + +export const selectSchemaInsightsDatabases = createSelector( + selectSchemaInsights, + schemaInsights => { + if (!schemaInsights) return []; + return Array.from( + new Set(schemaInsights.map(schemaInsight => schemaInsight.database)), + ).sort(); + }, +); + +export const selectSchemaInsightsTypes = createSelector( + selectSchemaInsights, + schemaInsights => { + if (!schemaInsights) return []; + return Array.from( + new Set( + schemaInsights.map(schemaInsight => insightType(schemaInsight.type)), + ), + ).sort(); + }, +); diff --git a/pkg/ui/workspaces/db-console/src/app.spec.tsx b/pkg/ui/workspaces/db-console/src/app.spec.tsx index 3e30893d6e33..5829fb0f4c62 100644 --- a/pkg/ui/workspaces/db-console/src/app.spec.tsx +++ b/pkg/ui/workspaces/db-console/src/app.spec.tsx @@ -39,6 +39,10 @@ stubComponentInModule( "src/views/insights/workloadInsightDetailsPageConnected", "default", ); +stubComponentInModule( + "src/views/insights/schemaInsightsPageConnected", + "default", +); import React from "react"; import { Action, Store } from "redux"; @@ -425,10 +429,14 @@ describe("Routing to", () => { /* insights */ } describe("'/insights' path", () => { - test("routes to component", () => { + test("routes to component - workload insights page", () => { navigateToPath("/insights"); screen.getByTestId("workloadInsightsPageConnected"); }); + test("routes to component - schema insights page", () => { + navigateToPath("/insights?tab=Schema+Insights"); + screen.getByTestId("schemaInsightsPageConnected"); + }); }); describe("'/insights/insightID' path", () => { test("routes to component", () => { diff --git a/pkg/ui/workspaces/db-console/src/redux/apiReducers.ts b/pkg/ui/workspaces/db-console/src/redux/apiReducers.ts index 05df89112e1a..2d14ac7af27f 100644 --- a/pkg/ui/workspaces/db-console/src/redux/apiReducers.ts +++ b/pkg/ui/workspaces/db-console/src/redux/apiReducers.ts @@ -414,6 +414,14 @@ const insightDetailsReducerObj = new KeyedCachedDataReducer( ); export const refreshInsightDetails = insightDetailsReducerObj.refresh; +const schemaInsightsReducerObj = new CachedDataReducer( + clusterUiApi.getSchemaInsights, + "schemaInsights", + null, + moment.duration(30, "s"), +); +export const refreshSchemaInsights = schemaInsightsReducerObj.refresh; + export interface APIReducersState { cluster: CachedDataReducerState; events: CachedDataReducerState; @@ -451,6 +459,7 @@ export interface APIReducersState { clusterLocks: CachedDataReducerState; insights: CachedDataReducerState; insightDetails: KeyedCachedDataReducerState; + schemaInsights: CachedDataReducerState; } export const apiReducersReducer = combineReducers({ @@ -494,6 +503,7 @@ export const apiReducersReducer = combineReducers({ [clusterLocksReducerObj.actionNamespace]: clusterLocksReducerObj.reducer, [insightsReducerObj.actionNamespace]: insightsReducerObj.reducer, [insightDetailsReducerObj.actionNamespace]: insightDetailsReducerObj.reducer, + [schemaInsightsReducerObj.actionNamespace]: schemaInsightsReducerObj.reducer, }); export { CachedDataReducerState, KeyedCachedDataReducerState }; diff --git a/pkg/ui/workspaces/db-console/src/views/insights/insightsOverview.tsx b/pkg/ui/workspaces/db-console/src/views/insights/insightsOverview.tsx index 3815d94313c4..fc401f659a3b 100644 --- a/pkg/ui/workspaces/db-console/src/views/insights/insightsOverview.tsx +++ b/pkg/ui/workspaces/db-console/src/views/insights/insightsOverview.tsx @@ -19,6 +19,7 @@ import { commonStyles, util } from "@cockroachlabs/cluster-ui"; import { RouteComponentProps } from "react-router-dom"; import { tabAttr, viewAttr } from "src/util/constants"; import WorkloadInsightsPageConnected from "src/views/insights/workloadInsightsPageConnected"; +import SchemaInsightsPageConnected from "src/views/insights/schemaInsightsPageConnected"; const { TabPane } = Tabs; @@ -64,6 +65,9 @@ const InsightsOverviewPage = (props: RouteComponentProps) => { + + +
); diff --git a/pkg/ui/workspaces/db-console/src/views/insights/insightsSelectors.ts b/pkg/ui/workspaces/db-console/src/views/insights/insightsSelectors.ts index 8cbe77abceb2..68c10709f222 100644 --- a/pkg/ui/workspaces/db-console/src/views/insights/insightsSelectors.ts +++ b/pkg/ui/workspaces/db-console/src/views/insights/insightsSelectors.ts @@ -15,7 +15,9 @@ import { defaultFilters, SortSetting, InsightEventFilters, + SchemaInsightEventFilters, api, + insightType, } from "@cockroachlabs/cluster-ui"; import { RouteComponentProps } from "react-router-dom"; import { CachedDataReducerState } from "src/redux/cachedDataReducer"; @@ -57,3 +59,53 @@ export const selectInsightDetails = createSelector( return insight[insightId]; }, ); + +export const schemaInsightsFiltersLocalSetting = new LocalSetting< + AdminUIState, + SchemaInsightEventFilters +>("filters/SchemaInsightsPage", (state: AdminUIState) => state.localSettings, { + database: defaultFilters.database, + schemaInsightType: defaultFilters.schemaInsightType, +}); + +export const schemaInsightsSortLocalSetting = new LocalSetting< + AdminUIState, + SortSetting +>( + "sortSetting/SchemaInsightsPage", + (state: AdminUIState) => state.localSettings, + { + ascending: false, + columnTitle: "insights", + }, +); + +export const selectSchemaInsights = createSelector( + (state: AdminUIState) => state.cachedData, + adminUiState => { + if (!adminUiState.schemaInsights) return []; + return adminUiState.schemaInsights.data; + }, +); + +export const selectSchemaInsightsDatabases = createSelector( + selectSchemaInsights, + schemaInsights => { + if (!schemaInsights) return []; + return Array.from( + new Set(schemaInsights.map(schemaInsight => schemaInsight.database)), + ).sort(); + }, +); + +export const selectSchemaInsightsTypes = createSelector( + selectSchemaInsights, + schemaInsights => { + if (!schemaInsights) return []; + return Array.from( + new Set( + schemaInsights.map(schemaInsight => insightType(schemaInsight.type)), + ), + ).sort(); + }, +); diff --git a/pkg/ui/workspaces/db-console/src/views/insights/schemaInsightsPageConnected.tsx b/pkg/ui/workspaces/db-console/src/views/insights/schemaInsightsPageConnected.tsx new file mode 100644 index 000000000000..8647ceddaa4e --- /dev/null +++ b/pkg/ui/workspaces/db-console/src/views/insights/schemaInsightsPageConnected.tsx @@ -0,0 +1,60 @@ +// Copyright 2022 The Cockroach Authors. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0, included in the file +// licenses/APL.txt. + +import { connect } from "react-redux"; +import { RouteComponentProps, withRouter } from "react-router-dom"; +import { refreshSchemaInsights } from "src/redux/apiReducers"; +import { AdminUIState } from "src/redux/state"; +import { + SchemaInsightEventFilters, + SortSetting, + SchemaInsightsViewStateProps, + SchemaInsightsViewDispatchProps, + SchemaInsightsView, +} from "@cockroachlabs/cluster-ui"; +import { + schemaInsightsFiltersLocalSetting, + schemaInsightsSortLocalSetting, + selectSchemaInsights, + selectSchemaInsightsDatabases, + selectSchemaInsightsTypes, +} from "src/views/insights/insightsSelectors"; + +const mapStateToProps = ( + state: AdminUIState, + _props: RouteComponentProps, +): SchemaInsightsViewStateProps => ({ + schemaInsights: selectSchemaInsights(state), + schemaInsightsDatabases: selectSchemaInsightsDatabases(state), + schemaInsightsTypes: selectSchemaInsightsTypes(state), + schemaInsightsError: state.cachedData?.schemaInsights.lastError, + filters: schemaInsightsFiltersLocalSetting.selector(state), + sortSetting: schemaInsightsSortLocalSetting.selector(state), +}); + +const mapDispatchToProps = { + onFiltersChange: (filters: SchemaInsightEventFilters) => + schemaInsightsFiltersLocalSetting.set(filters), + onSortChange: (ss: SortSetting) => schemaInsightsSortLocalSetting.set(ss), + refreshSchemaInsights: refreshSchemaInsights, +}; + +const SchemaInsightsPageConnected = withRouter( + connect< + SchemaInsightsViewStateProps, + SchemaInsightsViewDispatchProps, + RouteComponentProps + >( + mapStateToProps, + mapDispatchToProps, + )(SchemaInsightsView), +); + +export default SchemaInsightsPageConnected;