From 0b75cd9fdaa69a8f73375bb6f6df50bc01d19b73 Mon Sep 17 00:00:00 2001 From: Eric Harmeling Date: Tue, 15 Nov 2022 13:09:15 -0500 Subject: [PATCH] ui: add execution insights to statement and transaction fingerprint details This commit adds execution insights to the Statement Fingerprint and Transaction Fingerprint Details pages. Part of #83780. Release note (ui change): Added execution insights to the Statement Fingerprint Details and Transaction Fingerprint Details Pages. --- .../cluster-ui/src/api/stmtInsightsApi.ts | 24 +++-- .../cluster-ui/src/api/txnInsightsApi.ts | 61 +++++------- .../schemaInsights/schemaInsightsView.tsx | 3 + .../cluster-ui/src/insights/types.ts | 5 + .../cluster-ui/src/insights/utils.ts | 8 +- .../insightDetailsTables.tsx | 4 +- .../statementInsightDetails.tsx | 2 +- .../statementInsightDetailsOverviewTab.tsx | 7 +- .../transactionInsightDetails.tsx | 4 +- .../transactionInsightDetailsOverviewTab.tsx | 3 +- .../statementInsightsTable.tsx | 2 +- .../workloadInsights/util/queriesCell.tsx | 8 +- .../insightsTable/insightsTable.module.scss | 6 ++ .../src/insightsTable/insightsTable.tsx | 97 +++++++++++++++---- .../cluster-ui/src/selectors/common.ts | 18 +++- .../src/selectors/insightsCommon.selectors.ts | 17 +++- .../planDetails/planDetails.tsx | 2 +- .../statementDetails.fixture.ts | 1 + .../statementDetails.module.scss | 3 + .../src/statementDetails/statementDetails.tsx | 71 +++++++++++++- .../statementDetailsConnected.ts | 15 ++- .../statementFingerprintInsights/index.ts | 13 +++ .../statementFingerprintInsights.reducer.ts | 68 +++++++++++++ .../statementFingerprintInsights.sagas.ts | 73 ++++++++++++++ .../statementFingerprintInsights.selectors.ts | 25 +++++ .../statementInsights.selectors.ts | 13 ++- .../transactionInsights.selectors.ts | 14 +++ .../cluster-ui/src/store/reducers.ts | 6 ++ .../workspaces/cluster-ui/src/store/sagas.ts | 2 + .../transactionDetails/transactionDetails.tsx | 81 +++++++++++++++- .../transactionDetailsConnected.tsx | 11 +++ pkg/ui/workspaces/db-console/src/app.spec.tsx | 4 +- .../db-console/src/redux/apiReducers.ts | 20 +++- .../src/views/insights/insightsSelectors.ts | 14 +++ .../src/views/statements/statementDetails.tsx | 16 +++ .../views/transactions/transactionDetails.tsx | 6 ++ 36 files changed, 634 insertions(+), 93 deletions(-) create mode 100644 pkg/ui/workspaces/cluster-ui/src/store/insights/statementFingerprintInsights/index.ts create mode 100644 pkg/ui/workspaces/cluster-ui/src/store/insights/statementFingerprintInsights/statementFingerprintInsights.reducer.ts create mode 100644 pkg/ui/workspaces/cluster-ui/src/store/insights/statementFingerprintInsights/statementFingerprintInsights.sagas.ts create mode 100644 pkg/ui/workspaces/cluster-ui/src/store/insights/statementFingerprintInsights/statementFingerprintInsights.selectors.ts diff --git a/pkg/ui/workspaces/cluster-ui/src/api/stmtInsightsApi.ts b/pkg/ui/workspaces/cluster-ui/src/api/stmtInsightsApi.ts index 41b174df8305..93c8f7479d73 100644 --- a/pkg/ui/workspaces/cluster-ui/src/api/stmtInsightsApi.ts +++ b/pkg/ui/workspaces/cluster-ui/src/api/stmtInsightsApi.ts @@ -30,6 +30,7 @@ export type StmtInsightsReq = { start?: moment.Moment; end?: moment.Moment; stmtExecutionID?: string; + stmtFingerprintId?: string; }; type InsightsContentionResponseEvent = { @@ -118,17 +119,22 @@ WHERE stmt_id = '${filters.stmtExecutionID}'`; whereClause = whereClause + ` AND end_time <= '${filters.end.toISOString()}'`; } + if (filters?.stmtFingerprintId) { + whereClause = + whereClause + + ` AND encode(stmt_fingerprint_id, 'hex') = '${filters.stmtFingerprintId}'`; + } return ` -SELECT ${stmtColumns} FROM ( -SELECT - *, - row_number() OVER ( PARTITION BY stmt_fingerprint_id ORDER BY end_time DESC ) as rank -FROM - crdb_internal.cluster_execution_insights - ${whereClause} -) WHERE rank = 1 - `; +SELECT ${stmtColumns} FROM + ( + SELECT DISTINCT ON (stmt_fingerprint_id, problem, causes) + * + FROM + crdb_internal.cluster_execution_insights + ${whereClause} + ORDER BY stmt_fingerprint_id, problem, causes, end_time DESC + )`; }; export const stmtInsightsByTxnExecutionQuery = (id: string): string => ` diff --git a/pkg/ui/workspaces/cluster-ui/src/api/txnInsightsApi.ts b/pkg/ui/workspaces/cluster-ui/src/api/txnInsightsApi.ts index 060977f80477..7f49b794da46 100644 --- a/pkg/ui/workspaces/cluster-ui/src/api/txnInsightsApi.ts +++ b/pkg/ui/workspaces/cluster-ui/src/api/txnInsightsApi.ts @@ -128,11 +128,9 @@ function createStmtFingerprintToQueryMap( return idToQuery; } -function getTxnContentionWhereClause( - clause: string, - filters?: TxnInsightDetailsRequest, -): string { - let whereClause = clause; +// txnContentionDetailsQuery selects information about a specific transaction contention event. +function txnContentionDetailsQuery(filters: TxnContentionDetailsRequest) { + let whereClause = ` WHERE waiting_txn_id = '${filters.txnExecutionID}'`; if (filters?.start) { whereClause = whereClause + ` AND collection_ts >= '${filters.start.toISOString()}'`; @@ -142,20 +140,6 @@ function getTxnContentionWhereClause( whereClause + ` AND (collection_ts + contention_duration) <= '${filters.end.toISOString()}'`; } - return whereClause; -} - -export type TransactionContentionEventDetails = Omit< - TxnContentionInsightDetails, - "application" | "queries" | "blockingQueries" ->; - -// txnContentionDetailsQuery selects information about a specific transaction contention event. -function txnContentionDetailsQuery(filters: TxnContentionDetailsRequest) { - const whereClause = getTxnContentionWhereClause( - ` WHERE waiting_txn_id = '${filters.txnExecutionID}'`, - filters, - ); return ` SELECT DISTINCT collection_ts, @@ -262,7 +246,7 @@ function formatTxnContentionDetailsResponse( } export type TxnContentionDetailsRequest = { - txnExecutionID: string; + txnExecutionID?: string; start?: moment.Moment; end?: moment.Moment; }; @@ -415,6 +399,7 @@ type TxnInsightsResponseRow = { type TxnQueryFilters = { execID?: string; + fingerprintID?: string; start?: moment.Moment; end?: moment.Moment; }; @@ -466,15 +451,20 @@ AND txn_id != '00000000-0000-0000-0000-000000000000'`; whereClause += ` AND end_time <= '${filters.end.toISOString()}'`; } + if (filters?.fingerprintID) { + whereClause += ` AND encode(txn_fingerprint_id, 'hex') = '${filters.fingerprintID}'`; + } + return ` -SELECT ${txnColumns} FROM ( - SELECT - *, - row_number() OVER ( PARTITION BY txn_fingerprint_id ORDER BY end_time DESC ) as rank - FROM ${TXN_INSIGHTS_TABLE_NAME} - ${whereClause} - -) WHERE rank = 1; +SELECT ${txnColumns} FROM + ( + SELECT DISTINCT ON (txn_fingerprint_id, problems, causes) + * + FROM + ${TXN_INSIGHTS_TABLE_NAME} + ${whereClause} + ORDER BY txn_fingerprint_id, problems, causes, end_time DESC + ) `; }; @@ -511,6 +501,7 @@ function formatTxnInsightsRow(row: TxnInsightsResponseRow): TxnInsightEvent { export type TxnInsightsRequest = { txnExecutionID?: string; + txnFingerprintID?: string; start?: moment.Moment; end?: moment.Moment; }; @@ -518,13 +509,13 @@ export type TxnInsightsRequest = { export function getTxnInsightsApi( req?: TxnInsightsRequest, ): Promise { - const request = makeInsightsSqlRequest([ - createTxnInsightsQuery({ - execID: req?.txnExecutionID, - start: req?.start, - end: req?.end, - }), - ]); + const filters: TxnQueryFilters = { + start: req?.start, + end: req?.end, + execID: req?.txnExecutionID ? req.txnExecutionID : null, + fingerprintID: req?.txnFingerprintID ? req.txnFingerprintID : null, + }; + const request = makeInsightsSqlRequest([createTxnInsightsQuery(filters)]); return executeInternalSql(request).then(result => { if (result.error) { throw new Error( diff --git a/pkg/ui/workspaces/cluster-ui/src/insights/schemaInsights/schemaInsightsView.tsx b/pkg/ui/workspaces/cluster-ui/src/insights/schemaInsights/schemaInsightsView.tsx index 7412d052b91e..6f04662dcaf2 100644 --- a/pkg/ui/workspaces/cluster-ui/src/insights/schemaInsights/schemaInsightsView.tsx +++ b/pkg/ui/workspaces/cluster-ui/src/insights/schemaInsights/schemaInsightsView.tsx @@ -38,8 +38,10 @@ import { InsightsError } from "../insightsErrorComponent"; import { Pagination } from "../../pagination"; import { EmptySchemaInsightsTablePlaceholder } from "./emptySchemaInsightsTablePlaceholder"; import { CockroachCloudContext } from "../../contexts"; +import insightTableStyles from "../../insightsTable/insightsTable.module.scss"; const cx = classNames.bind(styles); const sortableTableCx = classNames.bind(sortableTableStyles); +const insightTableCx = classNames.bind(insightTableStyles); export type SchemaInsightsViewStateProps = { schemaInsights: InsightRecommendation[]; @@ -248,6 +250,7 @@ export const SchemaInsightsView: React.FC = ({ } /> } + tableWrapperClassName={insightTableCx("sorted-table")} /> @@ -404,15 +407,16 @@ export function getTxnInsightRecommendations( if (!insightDetails) return []; const execDetails: ExecutionDetails = { + transactionExecutionID: insightDetails.transactionExecutionID, retries: insightDetails.retries, contentionTimeMs: insightDetails.contentionTime.asMilliseconds(), elapsedTimeMillis: insightDetails.elapsedTimeMillis, + execType: InsightExecEnum.TRANSACTION, }; const recs: InsightRecommendation[] = []; insightDetails?.insights?.forEach(insight => recs.push(getRecommendationForExecInsight(insight, execDetails)), ); - return recs; } diff --git a/pkg/ui/workspaces/cluster-ui/src/insights/workloadInsightDetails/insightDetailsTables.tsx b/pkg/ui/workspaces/cluster-ui/src/insights/workloadInsightDetails/insightDetailsTables.tsx index d1ae1ba3f767..f00c59c18623 100644 --- a/pkg/ui/workspaces/cluster-ui/src/insights/workloadInsightDetails/insightDetailsTables.tsx +++ b/pkg/ui/workspaces/cluster-ui/src/insights/workloadInsightDetails/insightDetailsTables.tsx @@ -54,8 +54,8 @@ export function makeInsightDetailsColumns( { name: "query", title: insightsTableTitles.query(execType), - cell: (item: ContentionEvent) => QueriesCell(item.queries, 50), - sort: (item: ContentionEvent) => item.queries.length, + cell: (item: ContentionEvent) => QueriesCell(item?.queries, 50), + sort: (item: ContentionEvent) => item.queries?.length, }, { name: "contentionStartTime", diff --git a/pkg/ui/workspaces/cluster-ui/src/insights/workloadInsightDetails/statementInsightDetails.tsx b/pkg/ui/workspaces/cluster-ui/src/insights/workloadInsightDetails/statementInsightDetails.tsx index ae4f7356e773..50b9c7fbbc8d 100644 --- a/pkg/ui/workspaces/cluster-ui/src/insights/workloadInsightDetails/statementInsightDetails.tsx +++ b/pkg/ui/workspaces/cluster-ui/src/insights/workloadInsightDetails/statementInsightDetails.tsx @@ -144,7 +144,7 @@ export const StatementInsightDetails: React.FC< iconPosition="left" className={commonStyles("small-margin")} > - Insights + Previous page

Statement Execution ID: {executionID} diff --git a/pkg/ui/workspaces/cluster-ui/src/insights/workloadInsightDetails/statementInsightDetailsOverviewTab.tsx b/pkg/ui/workspaces/cluster-ui/src/insights/workloadInsightDetails/statementInsightDetailsOverviewTab.tsx index 406ff6e4cb94..3568d402ce32 100644 --- a/pkg/ui/workspaces/cluster-ui/src/insights/workloadInsightDetails/statementInsightDetailsOverviewTab.tsx +++ b/pkg/ui/workspaces/cluster-ui/src/insights/workloadInsightDetails/statementInsightDetailsOverviewTab.tsx @@ -56,7 +56,7 @@ export const StatementInsightDetailsOverviewTab: React.FC< const isCockroachCloud = useContext(CockroachCloudContext); const insightsColumns = useMemo( - () => makeInsightsColumns(isCockroachCloud, hasAdminRole), + () => makeInsightsColumns(isCockroachCloud, hasAdminRole, true), [isCockroachCloud, hasAdminRole], ); @@ -175,13 +175,14 @@ export const StatementInsightDetailsOverviewTab: React.FC< - - + + 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 5f803c880e8a..f53d287bdfe8 100644 --- a/pkg/ui/workspaces/cluster-ui/src/insights/workloadInsightDetails/transactionInsightDetails.tsx +++ b/pkg/ui/workspaces/cluster-ui/src/insights/workloadInsightDetails/transactionInsightDetails.tsx @@ -75,7 +75,7 @@ export const TransactionInsightDetails: React.FC< useEffect(() => { const stmtsComplete = - stmts != null && stmts.length === txnDetails?.stmtExecutionIDs?.length; + stmts != null && stmts?.length === txnDetails?.stmtExecutionIDs?.length; const contentionComplete = contentionInfo != null || @@ -125,7 +125,7 @@ export const TransactionInsightDetails: React.FC< iconPosition="left" className={commonStyles("small-margin")} > - Insights + Previous page

= ({ - + diff --git a/pkg/ui/workspaces/cluster-ui/src/insights/workloadInsights/statementInsights/statementInsightsTable.tsx b/pkg/ui/workspaces/cluster-ui/src/insights/workloadInsights/statementInsights/statementInsightsTable.tsx index e31fdc1b353f..ab185e77c190 100644 --- a/pkg/ui/workspaces/cluster-ui/src/insights/workloadInsights/statementInsights/statementInsightsTable.tsx +++ b/pkg/ui/workspaces/cluster-ui/src/insights/workloadInsights/statementInsights/statementInsightsTable.tsx @@ -169,7 +169,7 @@ export function makeStatementInsightsColumns( showByDefault: false, }, { - name: "transactionID", + name: "transactionExecutionID", title: insightsTableTitles.latestExecutionID(InsightExecEnum.TRANSACTION), cell: (item: StmtInsightEvent) => item.transactionExecutionID, sort: (item: StmtInsightEvent) => item.transactionExecutionID, diff --git a/pkg/ui/workspaces/cluster-ui/src/insights/workloadInsights/util/queriesCell.tsx b/pkg/ui/workspaces/cluster-ui/src/insights/workloadInsights/util/queriesCell.tsx index ff15a7c8134e..be3ab3059864 100644 --- a/pkg/ui/workspaces/cluster-ui/src/insights/workloadInsights/util/queriesCell.tsx +++ b/pkg/ui/workspaces/cluster-ui/src/insights/workloadInsights/util/queriesCell.tsx @@ -21,15 +21,15 @@ export function QueriesCell( textLimit: number, ): React.ReactElement { if ( - !transactionQueries.length || - (transactionQueries.length === 1 && - transactionQueries[0].length < textLimit) + !transactionQueries?.length || + (transactionQueries?.length === 1 && + transactionQueries[0]?.length < textLimit) ) { const query = transactionQueries?.length ? transactionQueries[0] : ""; return
{query}
; } - const combinedQuery = transactionQueries.map((query, idx, arr) => ( + const combinedQuery = transactionQueries?.map((query, idx, arr) => (
{idx != 0 &&
} {query} 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 fde0f8b318be..2e8970417605 100644 --- a/pkg/ui/workspaces/cluster-ui/src/insightsTable/insightsTable.module.scss +++ b/pkg/ui/workspaces/cluster-ui/src/insightsTable/insightsTable.module.scss @@ -39,3 +39,9 @@ .inline { display: inline-flex; } + +.sorted-table { + border-radius: 3px; + box-shadow: 0 0 1px 0 rgba(67, 90, 111, 0.41); + width: 100%; +} diff --git a/pkg/ui/workspaces/cluster-ui/src/insightsTable/insightsTable.tsx b/pkg/ui/workspaces/cluster-ui/src/insightsTable/insightsTable.tsx index 0db38f53fe78..5851785b8cb2 100644 --- a/pkg/ui/workspaces/cluster-ui/src/insightsTable/insightsTable.tsx +++ b/pkg/ui/workspaces/cluster-ui/src/insightsTable/insightsTable.tsx @@ -27,7 +27,11 @@ import { } from "../util"; import { Anchor } from "../anchor"; import { Link } from "react-router-dom"; -import { InsightRecommendation, insightType } from "../insights"; +import { + InsightExecEnum, + InsightRecommendation, + insightType, +} from "../insights"; const cx = classNames.bind(styles); @@ -37,6 +41,7 @@ const insightColumnLabels = { insights: "Insights", details: "Details", query: "Statement", + latestExecution: "Latest Execution ID", actions: "", }; export type InsightsTableColumnKeys = keyof typeof insightColumnLabels; @@ -69,6 +74,19 @@ export const insightsTableTitles: InsightsTableTitleType = { ); }, + latestExecution: () => { + return ( + + {insightColumnLabels.latestExecution} + + ); + }, actions: () => { return <>; }, @@ -113,11 +131,11 @@ const StatementExecution = ({ function descriptionCell( insightRec: InsightRecommendation, - disableStmtLink: boolean, + isExecution: boolean, isCockroachCloud: boolean, ): React.ReactElement { const stmtLink = isIndexRec(insightRec) ? ( - + ) : null; const clusterSettingsLink = ( @@ -183,12 +201,17 @@ function descriptionCell( case "HighContention": return ( <> + {isExecution && ( +
+ Time Spent Waiting: {" "} + {Duration(insightRec.details.duration * 1e6)} +
+ )} + {stmtLink}
- Time Spent Waiting: {" "} - {Duration(insightRec.details.duration * 1e6)} -
-
- Description: {" "} + {isExecution && ( + Description: + )} {insightRec.details.description} {clusterSettingsLink}
@@ -249,12 +272,17 @@ function descriptionCell( case "Unknown": return ( <> + {isExecution && ( +
+ Elapsed Time: + {Duration(insightRec.details.duration * 1e6)} +
+ )} + {stmtLink}
- Elapsed Time: - {Duration(insightRec.details.duration * 1e6)} -
-
- Description: {" "} + {isExecution && ( + Description: + )} {insightRec.details.description} {clusterSettingsLink}
@@ -271,6 +299,32 @@ function descriptionCell( } } +function linkCell(insightRec: InsightRecommendation): React.ReactElement { + if (insightRec.execution.execType === InsightExecEnum.STATEMENT) { + return ( + <> + + {String(insightRec.execution.statementExecutionID)} + + + ); + } + if (insightRec.execution.execType === InsightExecEnum.TRANSACTION) { + return ( + <> + + {String(insightRec.execution.transactionExecutionID)} + + + ); + } + return <>No execution ID found; +} + function actionCell( insightRec: InsightRecommendation, hideAction: boolean, @@ -335,9 +389,9 @@ const isIndexRec = (rec: InsightRecommendation) => { export function makeInsightsColumns( isCockroachCloud: boolean, hasAdminRole: boolean, - disableStmtLink?: boolean, + isExecution?: boolean, ): ColumnDescriptor[] { - return [ + const columns: ColumnDescriptor[] = [ { name: "insights", title: insightsTableTitles.insights(), @@ -348,14 +402,23 @@ export function makeInsightsColumns( name: "details", title: insightsTableTitles.details(), cell: (item: InsightRecommendation) => - descriptionCell(item, disableStmtLink, isCockroachCloud), + descriptionCell(item, isExecution, isCockroachCloud), sort: (item: InsightRecommendation) => item.type, }, { name: "action", title: insightsTableTitles.actions(), cell: (item: InsightRecommendation) => - actionCell(item, isCockroachCloud || !hasAdminRole), + actionCell(item, isCockroachCloud || !hasAdminRole || !isExecution), }, ]; + if (!isExecution) { + columns.push({ + name: "latestExecution", + title: insightsTableTitles.latestExecution(), + cell: (item: InsightRecommendation) => linkCell(item), + sort: (item: InsightRecommendation) => item.type, + }); + } + return columns; } diff --git a/pkg/ui/workspaces/cluster-ui/src/selectors/common.ts b/pkg/ui/workspaces/cluster-ui/src/selectors/common.ts index c3516317a481..c3406e6af58c 100644 --- a/pkg/ui/workspaces/cluster-ui/src/selectors/common.ts +++ b/pkg/ui/workspaces/cluster-ui/src/selectors/common.ts @@ -9,7 +9,13 @@ // licenses/APL.txt. import { RouteComponentProps } from "react-router"; -import { getMatchParamByName, executionIdAttr, idAttr } from "src/util"; +import { + getMatchParamByName, + executionIdAttr, + idAttr, + statementAttr, + txnFingerprintIdAttr, +} from "src/util"; // The functions in this file are agnostic to the different shape of each // state in db-console and cluster-ui. This file contains selector functions @@ -30,3 +36,13 @@ export const selectID = ( ): string | null => { return getMatchParamByName(props.match, idAttr); }; + +export const selectStatementFingerprintID = ( + _state: unknown, + props: RouteComponentProps, +): string | null => getMatchParamByName(props.match, statementAttr); + +export const selectTransactionFingerprintID = ( + _state: unknown, + props: RouteComponentProps, +): string | null => getMatchParamByName(props.match, txnFingerprintIdAttr); diff --git a/pkg/ui/workspaces/cluster-ui/src/selectors/insightsCommon.selectors.ts b/pkg/ui/workspaces/cluster-ui/src/selectors/insightsCommon.selectors.ts index 94dbc447271a..2d0775ca9153 100644 --- a/pkg/ui/workspaces/cluster-ui/src/selectors/insightsCommon.selectors.ts +++ b/pkg/ui/workspaces/cluster-ui/src/selectors/insightsCommon.selectors.ts @@ -26,12 +26,27 @@ export const selectStatementInsightDetailsCombiner = ( if (!statementInsights || !executionID) { return null; } - return statementInsights.find( insight => insight.statementExecutionID === executionID, ); }; +export const selectStatementInsightDetailsCombinerByFingerprint = ( + statementInsights: StmtInsightEvent[], + fingerprintID: string, +): StmtInsightEvent[] | null => { + if (!statementInsights || statementInsights?.length < 1 || !fingerprintID) { + return null; + } + const insightEvents: StmtInsightEvent[] = []; + statementInsights.forEach(insight => { + if (insight.statementFingerprintID === fingerprintID) { + insightEvents.push(insight); + } + }); + return insightEvents; +}; + export const selectTxnInsightDetailsCombiner = ( txnInsights: TxnInsightEvent, txnInsightsDetails: TxnInsightDetails, 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 699eafd1c117..3d56b12424a2 100644 --- a/pkg/ui/workspaces/cluster-ui/src/statementDetails/planDetails/planDetails.tsx +++ b/pkg/ui/workspaces/cluster-ui/src/statementDetails/planDetails/planDetails.tsx @@ -295,7 +295,7 @@ export function Insights({ hasAdminRole, }: InsightsProps): React.ReactElement { const hideAction = useContext(CockroachCloudContext) || database?.length == 0; - const insightsColumns = makeInsightsColumns(hideAction, hasAdminRole, true); + const insightsColumns = makeInsightsColumns(hideAction, hasAdminRole, false); const data = formatIdxRecommendations( idxRecommendations, database, diff --git a/pkg/ui/workspaces/cluster-ui/src/statementDetails/statementDetails.fixture.ts b/pkg/ui/workspaces/cluster-ui/src/statementDetails/statementDetails.fixture.ts index 40aa892136cc..418a31684542 100644 --- a/pkg/ui/workspaces/cluster-ui/src/statementDetails/statementDetails.fixture.ts +++ b/pkg/ui/workspaces/cluster-ui/src/statementDetails/statementDetails.fixture.ts @@ -858,6 +858,7 @@ export const getStatementDetailsPropsFixture = ( refreshNodes: noop, refreshNodesLiveness: noop, refreshUserSQLRoles: noop, + refreshStatementFingerprintInsights: noop, diagnosticsReports: [], dismissStatementDiagnosticsAlertMessage: noop, onTimeScaleChange: noop, diff --git a/pkg/ui/workspaces/cluster-ui/src/statementDetails/statementDetails.module.scss b/pkg/ui/workspaces/cluster-ui/src/statementDetails/statementDetails.module.scss index ad66bd9eb2c2..08350a8217e6 100644 --- a/pkg/ui/workspaces/cluster-ui/src/statementDetails/statementDetails.module.scss +++ b/pkg/ui/workspaces/cluster-ui/src/statementDetails/statementDetails.module.scss @@ -22,6 +22,9 @@ .section { flex: 0 0 auto; padding: 12px 24px 12px 0px; + row-gap: 24px; + display: flex; + flex-direction: column; &--heading { padding-top: 0; diff --git a/pkg/ui/workspaces/cluster-ui/src/statementDetails/statementDetails.tsx b/pkg/ui/workspaces/cluster-ui/src/statementDetails/statementDetails.tsx index 128831fef0fd..e74d4248cbcc 100644 --- a/pkg/ui/workspaces/cluster-ui/src/statementDetails/statementDetails.tsx +++ b/pkg/ui/workspaces/cluster-ui/src/statementDetails/statementDetails.tsx @@ -8,7 +8,7 @@ // by the Apache License, Version 2.0, included in the file // licenses/APL.txt. -import React, { ReactNode } from "react"; +import React, { ReactNode, useContext, useMemo } from "react"; import { Col, Row, Tabs } from "antd"; import "antd/lib/col/style"; import "antd/lib/row/style"; @@ -43,6 +43,7 @@ import { SqlBox, SqlBoxSize } from "src/sql"; import { PlanDetails } from "./planDetails"; import { SummaryCard, SummaryCardItem } from "src/summaryCard"; import { DiagnosticsView } from "./diagnostics/diagnosticsView"; +import insightTableStyles from "../insightsTable/insightsTable.module.scss"; import summaryCardStyles from "src/summaryCard/summaryCard.module.scss"; import timeScaleStyles from "src/timeScaleDropdown/timeScale.module.scss"; import styles from "./statementDetails.module.scss"; @@ -74,8 +75,21 @@ import { Delayed } from "../delayed"; import moment from "moment"; import { InsertStmtDiagnosticRequest, + InsightRecommendation, StatementDiagnosticsReport, + StmtInsightsReq, } from "../api"; +import { + getStmtInsightRecommendations, + InsightNameEnum, + InsightType, + StmtInsightEvent, +} from "../insights"; +import { + InsightsSortedTable, + makeInsightsColumns, +} from "../insightsTable/insightsTable"; +import { CockroachCloudContext } from "../contexts"; type StatementDetailsResponse = cockroach.server.serverpb.StatementDetailsResponse; @@ -110,6 +124,7 @@ export interface StatementDetailsDispatchProps { refreshUserSQLRoles: () => void; refreshNodes: () => void; refreshNodesLiveness: () => void; + refreshStatementFingerprintInsights: (req: StmtInsightsReq) => void; createStatementDiagnosticsReport: ( insertStmtDiagnosticsRequest: InsertStmtDiagnosticRequest, ) => void; @@ -140,6 +155,7 @@ export interface StatementDetailsStateProps { isTenant?: UIConfigState["isTenant"]; hasViewActivityRedactedRole?: UIConfigState["hasViewActivityRedactedRole"]; hasAdminRole?: UIConfigState["hasAdminRole"]; + statementFingerprintInsights?: StmtInsightEvent[]; } export type StatementDetailsOwnProps = StatementDetailsDispatchProps & @@ -148,6 +164,7 @@ export type StatementDetailsOwnProps = StatementDetailsDispatchProps & const cx = classNames.bind(styles); const summaryCardStylesCx = classNames.bind(summaryCardStyles); const timeScaleStylesCx = classNames.bind(timeScaleStyles); +const insightsTableCx = classNames.bind(insightTableStyles); function getStatementDetailsRequestFromProps( props: StatementDetailsProps, @@ -249,12 +266,24 @@ export class StatementDetails extends React.Component< } } - refreshStatementDetails = (): void => { + refreshStatementDetails = () => { const req = getStatementDetailsRequestFromProps(this.props); this.props.refreshStatementDetails(req); + this.refreshStatementInsights(this.props.statementFingerprintID); this.resetPolling(this.props.timeScale.key); }; + refreshStatementInsights = (fingerprintID: string) => { + const [startTime, endTime] = toRoundedDateRange(this.props.timeScale); + const id = BigInt(fingerprintID).toString(16); + const req: StmtInsightsReq = { + start: startTime, + end: endTime, + stmtFingerprintId: id, + }; + this.props.refreshStatementFingerprintInsights(req); + }; + handleResize = (): void => { // Use the same size as the summary card and remove a space for margin (22). const cardWidth = document.getElementById("first-card") @@ -523,7 +552,7 @@ export class StatementDetails extends React.Component< return this.renderNoDataWithTimeScaleAndSqlBoxTabContent(hasTimeout); } const { cardWidth } = this.state; - const { nodeRegions, isTenant } = this.props; + const { nodeRegions, isTenant, statementFingerprintInsights } = this.props; const { stats } = this.props.statementDetails.statement; const { app_names, @@ -632,6 +661,26 @@ export class StatementDetails extends React.Component< width: cardWidth, }; + const isCockroachCloud = useContext(CockroachCloudContext); + const insightsColumns = useMemo( + () => + makeInsightsColumns(isCockroachCloud, this.props.hasAdminRole, false), + [isCockroachCloud], + ); + const tableData: InsightRecommendation[] = []; + if (statementFingerprintInsights) { + const tableDataTypes: InsightType[] = []; + statementFingerprintInsights.forEach(insight => { + const rec = getStmtInsightRecommendations(insight); + rec.forEach(entry => { + if (!tableDataTypes.find(seen => seen === entry.type)) { + tableData.push(entry); + tableDataTypes.push(entry.type); + } + }); + }); + } + return ( <> @@ -708,6 +757,22 @@ export class StatementDetails extends React.Component< + {tableData != null && tableData?.length > 0 && ( + <> +

+ + + + + + + )}

diff --git a/pkg/ui/workspaces/cluster-ui/src/statementDetails/statementDetailsConnected.ts b/pkg/ui/workspaces/cluster-ui/src/statementDetails/statementDetailsConnected.ts index d54fec059ce9..0a165b506897 100644 --- a/pkg/ui/workspaces/cluster-ui/src/statementDetails/statementDetailsConnected.ts +++ b/pkg/ui/workspaces/cluster-ui/src/statementDetails/statementDetailsConnected.ts @@ -39,12 +39,17 @@ import { actions as nodesActions } from "../store/nodes"; import { actions as nodeLivenessActions } from "../store/liveness"; import { selectTimeScale } from "../store/utils/selectors"; import { + actions as statementFingerprintInsightActions, + selectStatementFingerprintInsights, +} from "src/store/insights/statementFingerprintInsights"; +import { + StmtInsightsReq, InsertStmtDiagnosticRequest, StatementDetailsRequest, StatementDiagnosticsReport, -} from "../api"; +} from "src/api"; import { TimeScale } from "../timeScaleDropdown"; -import { getMatchParamByName, statementAttr } from "../util"; +import { getMatchParamByName, statementAttr } from "src/util"; // For tenant cases, we don't show information about node, regions and // diagnostics. @@ -71,6 +76,10 @@ const mapStateToProps = (state: AppState, props: RouteComponentProps) => { isTenant: selectIsTenant(state), hasViewActivityRedactedRole: selectHasViewActivityRedactedRole(state), hasAdminRole: selectHasAdminRole(state), + statementFingerprintInsights: selectStatementFingerprintInsights( + state, + props, + ), }; }; @@ -84,6 +93,8 @@ const mapDispatchToProps = ( refreshNodes: () => dispatch(nodesActions.refresh()), refreshNodesLiveness: () => dispatch(nodeLivenessActions.refresh()), refreshUserSQLRoles: () => dispatch(uiConfigActions.refreshUserSQLRoles()), + refreshStatementFingerprintInsights: (req: StmtInsightsReq) => + dispatch(statementFingerprintInsightActions.refresh(req)), onTimeScaleChange: (ts: TimeScale) => { dispatch( sqlStatsActions.updateTimeScale({ diff --git a/pkg/ui/workspaces/cluster-ui/src/store/insights/statementFingerprintInsights/index.ts b/pkg/ui/workspaces/cluster-ui/src/store/insights/statementFingerprintInsights/index.ts new file mode 100644 index 000000000000..b9fad869bd33 --- /dev/null +++ b/pkg/ui/workspaces/cluster-ui/src/store/insights/statementFingerprintInsights/index.ts @@ -0,0 +1,13 @@ +// Copyright 2023 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 "./statementFingerprintInsights.reducer"; +export * from "./statementFingerprintInsights.sagas"; +export * from "./statementFingerprintInsights.selectors"; diff --git a/pkg/ui/workspaces/cluster-ui/src/store/insights/statementFingerprintInsights/statementFingerprintInsights.reducer.ts b/pkg/ui/workspaces/cluster-ui/src/store/insights/statementFingerprintInsights/statementFingerprintInsights.reducer.ts new file mode 100644 index 000000000000..e5fa1313283c --- /dev/null +++ b/pkg/ui/workspaces/cluster-ui/src/store/insights/statementFingerprintInsights/statementFingerprintInsights.reducer.ts @@ -0,0 +1,68 @@ +// Copyright 2023 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 } from "../../utils"; +import moment, { Moment } from "moment"; +import { ErrorWithKey, StmtInsightsReq } from "src/api"; +import { StmtInsightEvent } from "../../../insights"; + +export type StatementFingerprintInsightsState = { + data: StmtInsightEvent[] | null; + lastUpdated: Moment | null; + lastError: Error; + valid: boolean; +}; + +export type StatementFingerprintInsightsCachedState = { + cachedData: { [id: string]: StatementFingerprintInsightsState }; +}; + +export type FingerprintInsightResponseWithKey = { + response: StmtInsightEvent[]; + key: string; +}; + +const initialState: StatementFingerprintInsightsCachedState = { + cachedData: {}, +}; + +const statementFingerprintInsightsSlice = createSlice({ + name: `${DOMAIN_NAME}/statementFingerprintInsightsSlice`, + initialState, + reducers: { + received: ( + state, + action: PayloadAction, + ) => { + state.cachedData[action.payload.key] = { + data: action.payload.response, + valid: true, + lastError: null, + lastUpdated: moment.utc(), + }; + }, + failed: (state, action: PayloadAction) => { + state.cachedData[action.payload.key] = { + data: null, + valid: false, + lastError: action.payload.err, + lastUpdated: null, + }; + }, + invalidated: (state, action: PayloadAction<{ key: string }>) => { + delete state.cachedData[action.payload.key]; + }, + refresh: (_, _action: PayloadAction) => {}, + request: (_, _action: PayloadAction) => {}, + }, +}); + +export const { reducer, actions } = statementFingerprintInsightsSlice; diff --git a/pkg/ui/workspaces/cluster-ui/src/store/insights/statementFingerprintInsights/statementFingerprintInsights.sagas.ts b/pkg/ui/workspaces/cluster-ui/src/store/insights/statementFingerprintInsights/statementFingerprintInsights.sagas.ts new file mode 100644 index 000000000000..33db1aec9ab6 --- /dev/null +++ b/pkg/ui/workspaces/cluster-ui/src/store/insights/statementFingerprintInsights/statementFingerprintInsights.sagas.ts @@ -0,0 +1,73 @@ +// Copyright 2023 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, + FingerprintInsightResponseWithKey, +} from "./statementFingerprintInsights.reducer"; +import { PayloadAction } from "@reduxjs/toolkit"; +import { + ErrorWithKey, + StmtInsightsReq, + getStmtInsightsApi, +} from "../../../api"; +import { HexStringToInt64String } from "../../../util"; + +export function* refreshStatementFingerprintInsightsSaga( + action: PayloadAction, +): any { + yield put(actions.request(action.payload)); +} + +export function* requestStatementFingerprintInsightsSaga( + action: PayloadAction, +): any { + const key = HexStringToInt64String(action.payload.stmtFingerprintId); + try { + const result = yield call(getStmtInsightsApi, action.payload); + const resultWithKey: FingerprintInsightResponseWithKey = { + response: result, + key, + }; + yield put(actions.received(resultWithKey)); + } catch (e) { + const err: ErrorWithKey = { + err: e, + key: action.payload.stmtFingerprintId, + }; + yield put(actions.failed(err)); + } +} + +const CACHE_INVALIDATION_PERIOD = 5 * 60 * 1000; // 5 minutes in ms + +const timeoutsByFingerprintID = new Map(); + +export function receivedStatementFingerprintInsightsSaga( + action: PayloadAction, +) { + const fingerprintID = action.payload.key; + clearTimeout(timeoutsByFingerprintID.get(fingerprintID)); + const id = setTimeout(() => { + actions.invalidated({ key: fingerprintID }); + timeoutsByFingerprintID.delete(fingerprintID); + }, CACHE_INVALIDATION_PERIOD); + timeoutsByFingerprintID.set(fingerprintID, id); +} + +export function* statementFingerprintInsightsSaga() { + yield all([ + takeLatest(actions.refresh, refreshStatementFingerprintInsightsSaga), + takeLatest(actions.request, requestStatementFingerprintInsightsSaga), + takeLatest(actions.received, receivedStatementFingerprintInsightsSaga), + ]); +} diff --git a/pkg/ui/workspaces/cluster-ui/src/store/insights/statementFingerprintInsights/statementFingerprintInsights.selectors.ts b/pkg/ui/workspaces/cluster-ui/src/store/insights/statementFingerprintInsights/statementFingerprintInsights.selectors.ts new file mode 100644 index 000000000000..fece33225470 --- /dev/null +++ b/pkg/ui/workspaces/cluster-ui/src/store/insights/statementFingerprintInsights/statementFingerprintInsights.selectors.ts @@ -0,0 +1,25 @@ +// Copyright 2023 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 { AppState } from "src/store/reducers"; + +import { selectStatementFingerprintID } from "src/selectors/common"; + +export const selectStatementFingerprintInsights = createSelector( + (state: AppState) => state.adminUI?.statementFingerprintInsights?.cachedData, + selectStatementFingerprintID, + (cachedFingerprintInsights, fingerprintID) => { + if (!cachedFingerprintInsights) { + return null; + } + return cachedFingerprintInsights[fingerprintID]?.data; + }, +); diff --git a/pkg/ui/workspaces/cluster-ui/src/store/insights/statementInsights/statementInsights.selectors.ts b/pkg/ui/workspaces/cluster-ui/src/store/insights/statementInsights/statementInsights.selectors.ts index 5ad3c88f8e08..4b46d13f1fee 100644 --- a/pkg/ui/workspaces/cluster-ui/src/store/insights/statementInsights/statementInsights.selectors.ts +++ b/pkg/ui/workspaces/cluster-ui/src/store/insights/statementInsights/statementInsights.selectors.ts @@ -12,8 +12,11 @@ import { createSelector } from "reselect"; import { localStorageSelector } from "src/store/utils/selectors"; import { AppState } from "src/store/reducers"; -import { selectStatementInsightDetailsCombiner } from "src/selectors/insightsCommon.selectors"; -import { selectID } from "src/selectors/common"; +import { + selectStatementInsightDetailsCombiner, + selectStatementInsightDetailsCombinerByFingerprint, +} from "src/selectors/insightsCommon.selectors"; +import { selectStatementFingerprintID, selectID } from "src/selectors/common"; import { InsightEnumToLabel, StmtInsightEvent } from "src/insights"; export const selectStmtInsights = (state: AppState): StmtInsightEvent[] => @@ -49,3 +52,9 @@ export const selectColumns = createSelector( export const selectStmtInsightsLoading = (state: AppState): boolean => state.adminUI.stmtInsights?.inFlight && (!state.adminUI.stmtInsights?.valid || !state.adminUI.stmtInsights?.data); + +export const selectInsightsByFingerprint = createSelector( + selectStmtInsights, + selectStatementFingerprintID, + selectStatementInsightDetailsCombinerByFingerprint, +); diff --git a/pkg/ui/workspaces/cluster-ui/src/store/insights/transactionInsights/transactionInsights.selectors.ts b/pkg/ui/workspaces/cluster-ui/src/store/insights/transactionInsights/transactionInsights.selectors.ts index b595d2be4235..e5a4cd2ece0b 100644 --- a/pkg/ui/workspaces/cluster-ui/src/store/insights/transactionInsights/transactionInsights.selectors.ts +++ b/pkg/ui/workspaces/cluster-ui/src/store/insights/transactionInsights/transactionInsights.selectors.ts @@ -12,6 +12,8 @@ import { createSelector } from "reselect"; import { AppState } from "src/store/reducers"; import { localStorageSelector } from "src/store/utils/selectors"; import { TxnInsightEvent } from "src/insights"; +import { selectTransactionFingerprintID } from "src/selectors/common"; +import { FixFingerprintHexValue } from "../../../util"; export const selectTransactionInsights = (state: AppState): TxnInsightEvent[] => state.adminUI.txnInsights?.data; @@ -19,6 +21,18 @@ export const selectTransactionInsights = (state: AppState): TxnInsightEvent[] => export const selectTransactionInsightsError = (state: AppState): Error | null => state.adminUI.txnInsights?.lastError; +export const selectTxnInsightsByFingerprint = createSelector( + selectTransactionInsights, + selectTransactionFingerprintID, + (execInsights, fingerprintID) => { + if (fingerprintID == null) { + return null; + } + const id = FixFingerprintHexValue(BigInt(fingerprintID).toString(16)); + return execInsights?.filter(txn => txn.transactionFingerprintID === id); + }, +); + export const selectSortSetting = createSelector( localStorageSelector, localStorage => localStorage["sortSetting/InsightsPage"], diff --git a/pkg/ui/workspaces/cluster-ui/src/store/reducers.ts b/pkg/ui/workspaces/cluster-ui/src/store/reducers.ts index 72cee45364c3..8e7437455bff 100644 --- a/pkg/ui/workspaces/cluster-ui/src/store/reducers.ts +++ b/pkg/ui/workspaces/cluster-ui/src/store/reducers.ts @@ -59,6 +59,10 @@ import { } from "./terminateQuery"; import { reducer as uiConfig, UIConfigState } from "./uiConfig"; import { DOMAIN_NAME } from "./utils"; +import { + reducer as statementFingerprintInsights, + StatementFingerprintInsightsCachedState, +} from "./insights/statementFingerprintInsights"; export type AdminUiState = { statementDiagnostics: StatementDiagnosticsState; @@ -79,6 +83,7 @@ export type AdminUiState = { txnInsightDetails: TxnInsightDetailsCachedState; txnInsights: TxnInsightsState; schemaInsights: SchemaInsightsState; + statementFingerprintInsights: StatementFingerprintInsightsCachedState; }; export type AppState = { @@ -104,6 +109,7 @@ export const reducers = combineReducers({ clusterLocks, databasesList, schemaInsights, + statementFingerprintInsights, }); 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 6af2519812fc..4f8b97c127b4 100644 --- a/pkg/ui/workspaces/cluster-ui/src/store/sagas.ts +++ b/pkg/ui/workspaces/cluster-ui/src/store/sagas.ts @@ -30,6 +30,7 @@ import { transactionInsightDetailsSaga } from "./insightDetails/transactionInsig import { statementInsightsSaga } from "./insights/statementInsights"; import { schemaInsightsSaga } from "./schemaInsights"; import { uiConfigSaga } from "./uiConfig"; +import { statementFingerprintInsightsSaga } from "./insights/statementFingerprintInsights"; export function* sagas(cacheInvalidationPeriod?: number): SagaIterator { yield all([ @@ -52,5 +53,6 @@ export function* sagas(cacheInvalidationPeriod?: number): SagaIterator { fork(clusterLocksSaga), fork(schemaInsightsSaga), fork(uiConfigSaga, cacheInvalidationPeriod), + fork(statementFingerprintInsightsSaga), ]); } diff --git a/pkg/ui/workspaces/cluster-ui/src/transactionDetails/transactionDetails.tsx b/pkg/ui/workspaces/cluster-ui/src/transactionDetails/transactionDetails.tsx index 4f5960344c53..df7aee3fb444 100644 --- a/pkg/ui/workspaces/cluster-ui/src/transactionDetails/transactionDetails.tsx +++ b/pkg/ui/workspaces/cluster-ui/src/transactionDetails/transactionDetails.tsx @@ -8,7 +8,7 @@ // by the Apache License, Version 2.0, included in the file // licenses/APL.txt. -import React from "react"; +import React, { useContext, useMemo } from "react"; import * as protos from "@cockroachlabs/crdb-protobuf-client"; import classNames from "classnames/bind"; import _ from "lodash"; @@ -61,21 +61,38 @@ import { } from "src/statementsTable/statementsTable"; import { Transaction } from "src/transactionsTable"; import Long from "long"; -import { StatementsRequest } from "../api"; +import { + InsightRecommendation, + StatementsRequest, + TxnInsightsRequest, +} from "../api"; +import { + getTxnInsightRecommendations, + InsightType, + TxnInsightEvent, +} from "../insights"; import { getValidOption, TimeScale, timeScale1hMinOptions, TimeScaleDropdown, + timeScaleRangeToObj, timeScaleToString, toRoundedDateRange, } from "../timeScaleDropdown"; import moment from "moment"; import timeScaleStyles from "../timeScaleDropdown/timeScale.module.scss"; +import insightTableStyles from "../insightsTable/insightsTable.module.scss"; +import { + InsightsSortedTable, + makeInsightsColumns, +} from "../insightsTable/insightsTable"; +import { CockroachCloudContext } from "../contexts"; const { containerClass } = tableClasses; const cx = classNames.bind(statementsStyles); const timeScaleStylesCx = classNames.bind(timeScaleStyles); +const insightsTableCx = classNames.bind(insightTableStyles); type Statement = protos.cockroach.server.serverpb.StatementsResponse.ICollectedStatementStatistics; @@ -94,12 +111,15 @@ export interface TransactionDetailsStateProps { transactionFingerprintId: string; isLoading: boolean; lastUpdated: moment.Moment | null; + transactionInsights: TxnInsightEvent[]; + hasAdminRole?: UIConfigState["hasAdminRole"]; } export interface TransactionDetailsDispatchProps { refreshData: (req?: StatementsRequest) => void; refreshNodes: () => void; refreshUserSQLRoles: () => void; + refreshTransactionInsights: (req: TxnInsightsRequest) => void; onTimeScaleChange: (ts: TimeScale) => void; } @@ -213,6 +233,8 @@ export class TransactionDetails extends React.Component< } refreshData = (prevTransactionFingerprintId: string): void => { + const insightsReq = timeScaleRangeToObj(this.props.timeScale); + this.props.refreshTransactionInsights(insightsReq); const req = statementsRequestFromProps(this.props); this.props.refreshData(req); this.getTransactionStateInfo(prevTransactionFingerprintId); @@ -354,7 +376,11 @@ export class TransactionDetails extends React.Component< ); } - const { isTenant, hasViewActivityRedactedRole } = this.props; + const { + isTenant, + hasViewActivityRedactedRole, + transactionInsights, + } = this.props; const { sortSetting, pagination } = this.state; const aggregatedStatements = aggregateStatements( @@ -426,6 +452,30 @@ export class TransactionDetails extends React.Component< unavailableTooltip ); + const isCockroachCloud = useContext(CockroachCloudContext); + const insightsColumns = useMemo( + () => + makeInsightsColumns( + isCockroachCloud, + this.props.hasAdminRole, + false, + ), + [isCockroachCloud], + ); + const tableData: InsightRecommendation[] = []; + if (transactionInsights) { + const tableDataTypes: InsightType[] = []; + transactionInsights.forEach(transaction => { + const rec = getTxnInsightRecommendations(transaction); + rec.forEach(entry => { + if (!tableDataTypes.find(seen => seen === entry.type)) { + tableData.push(entry); + tableDataTypes.push(entry.type); + } + }); + }); + } + return (

@@ -508,6 +558,31 @@ export class TransactionDetails extends React.Component< + {tableData != null && tableData?.length > 0 && ( + <> +

+ + + + + + + )} +

{ + dispatch(transactionInsights.refresh(req)); + }, }); export const TransactionDetailsPageConnected = withRouter( diff --git a/pkg/ui/workspaces/db-console/src/app.spec.tsx b/pkg/ui/workspaces/db-console/src/app.spec.tsx index 5b8641ddfce3..d283fec22c8e 100644 --- a/pkg/ui/workspaces/db-console/src/app.spec.tsx +++ b/pkg/ui/workspaces/db-console/src/app.spec.tsx @@ -27,6 +27,7 @@ stubComponentInModule( stubComponentInModule("src/views/statements/statementsPage", "default"); stubComponentInModule("src/views/statements/statementDetails", "default"); stubComponentInModule("src/views/transactions/transactionsPage", "default"); +stubComponentInModule("src/views/transactions/transactionDetails", "default"); stubComponentInModule( "src/views/statements/recentStatementDetailsConnected", "default", @@ -64,7 +65,6 @@ const LOADING_CLUSTER_STATUS = /Loading cluster status.*/; const NODE_LOG_HEADER = /Logs Node.*/; const JOBS_HEADER = "Jobs"; const SQL_ACTIVITY_HEADER = "SQL Activity"; -const TRANSACTION_DETAILS_HEADER = "Transaction Details"; const ADVANCED_DEBUG_HEADER = "Advanced Debugging"; const REDUX_DEBUG_HEADER = "Redux State"; const CUSTOM_METRICS_CHART_HEADER = "Custom Chart"; @@ -423,7 +423,7 @@ describe("Routing to", () => { describe("'/transaction/:aggregated_ts/:txn_fingerprint_id' path", () => { test("routes to component", () => { navigateToPath("/transaction/1637877600/4948941983164833719"); - screen.getByText(TRANSACTION_DETAILS_HEADER, { selector: "h3" }); + screen.getByTestId("transactionDetails"); }); }); diff --git a/pkg/ui/workspaces/db-console/src/redux/apiReducers.ts b/pkg/ui/workspaces/db-console/src/redux/apiReducers.ts index 11eecb52c28a..091e63cde6d6 100644 --- a/pkg/ui/workspaces/db-console/src/redux/apiReducers.ts +++ b/pkg/ui/workspaces/db-console/src/redux/apiReducers.ts @@ -33,7 +33,7 @@ import { INodeStatus, RollupStoreMetrics } from "src/util/proto"; import * as protos from "src/js/protos"; import Long from "long"; -const { generateStmtDetailsToID } = util; +const { generateStmtDetailsToID, HexStringToInt64String } = util; const SessionsRequest = protos.cockroach.server.serverpb.ListSessionsRequest; @@ -443,6 +443,21 @@ const txnInsightDetailsReducerObj = new KeyedCachedDataReducer( export const refreshTxnInsightDetails = txnInsightDetailsReducerObj.refresh; +export const statementFingerprintInsightRequestKey = ( + req: clusterUiApi.StmtInsightsReq, +): string => `${HexStringToInt64String(req.stmtFingerprintId)}`; + +const statementFingerprintInsightsReducerObj = new KeyedCachedDataReducer( + clusterUiApi.getStmtInsightsApi, + "statementFingerprintInsights", + statementFingerprintInsightRequestKey, + null, + moment.duration(5, "m"), +); + +export const refreshStatementFingerprintInsights = + statementFingerprintInsightsReducerObj.refresh; + const schemaInsightsReducerObj = new CachedDataReducer( clusterUiApi.getSchemaInsights, "schemaInsights", @@ -546,6 +561,7 @@ export interface APIReducersState { txnInsightDetails: KeyedCachedDataReducerState; txnInsights: CachedDataReducerState; schemaInsights: CachedDataReducerState; + statementFingerprintInsights: KeyedCachedDataReducerState; schedules: KeyedCachedDataReducerState; schedule: KeyedCachedDataReducerState; snapshots: KeyedCachedDataReducerState; @@ -602,6 +618,8 @@ export const apiReducersReducer = combineReducers({ [snapshotsReducerObj.actionNamespace]: snapshotsReducerObj.reducer, [snapshotReducerObj.actionNamespace]: snapshotReducerObj.reducer, [rawTraceReducerObj.actionNamespace]: rawTraceReducerObj.reducer, + [statementFingerprintInsightsReducerObj.actionNamespace]: + statementFingerprintInsightsReducerObj.reducer, }); export { CachedDataReducerState, KeyedCachedDataReducerState }; 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 546f7c4434b7..fa07aa1cccb2 100644 --- a/pkg/ui/workspaces/db-console/src/views/insights/insightsSelectors.ts +++ b/pkg/ui/workspaces/db-console/src/views/insights/insightsSelectors.ts @@ -18,11 +18,13 @@ import { SchemaInsightEventFilters, SortSetting, selectID, + selectTransactionFingerprintID, selectStatementInsightDetailsCombiner, selectTxnInsightDetailsCombiner, InsightEnumToLabel, TxnInsightDetails, api, + util, } from "@cockroachlabs/cluster-ui"; export const filtersLocalSetting = new LocalSetting< @@ -106,6 +108,18 @@ export const selectStmtInsightsLoading = (state: AdminUIState) => (!state.cachedData.stmtInsights?.data || !state.cachedData.stmtInsights.valid); +export const selectTxnInsightsByFingerprint = createSelector( + selectTransactionInsights, + selectTransactionFingerprintID, + (execInsights, fingerprintID) => { + if (fingerprintID == null) { + return null; + } + const id = util.FixFingerprintHexValue(BigInt(fingerprintID).toString(16)); + return execInsights?.filter(txn => txn.transactionFingerprintID === id); + }, +); + export const selectInsightTypes = () => { const insights: string[] = []; InsightEnumToLabel.forEach(insight => { diff --git a/pkg/ui/workspaces/db-console/src/views/statements/statementDetails.tsx b/pkg/ui/workspaces/db-console/src/views/statements/statementDetails.tsx index e10b9c7e142b..e5fcbcefac14 100644 --- a/pkg/ui/workspaces/db-console/src/views/statements/statementDetails.tsx +++ b/pkg/ui/workspaces/db-console/src/views/statements/statementDetails.tsx @@ -18,6 +18,7 @@ import { refreshStatementDiagnosticsRequests, refreshStatementDetails, refreshUserSQLRoles, + refreshStatementFingerprintInsights, } from "src/redux/apiReducers"; import { RouteComponentProps } from "react-router"; import { nodeRegionsByIDSelector } from "src/redux/nodes"; @@ -102,6 +103,15 @@ export const selectStatementDetails = createSelector( }, ); +const selectStatementFingerprintInsights = createSelector( + (state: AdminUIState) => state.cachedData.statementFingerprintInsights, + (_state: AdminUIState, props: RouteComponentProps): string => + getMatchParamByName(props.match, statementAttr), + (cachedFingerprintInsights, fingerprintID) => { + return cachedFingerprintInsights[fingerprintID]?.data; + }, +); + const mapStateToProps = ( state: AdminUIState, props: RouteComponentProps, @@ -123,6 +133,10 @@ const mapStateToProps = ( ), hasViewActivityRedactedRole: selectHasViewActivityRedactedRole(state), hasAdminRole: selectHasAdminRole(state), + statementFingerprintInsights: selectStatementFingerprintInsights( + state, + props, + ), }; }; @@ -160,6 +174,8 @@ const mapDispatchToProps: StatementDetailsDispatchProps = { refreshNodes: refreshNodes, refreshNodesLiveness: refreshLiveness, refreshUserSQLRoles: refreshUserSQLRoles, + refreshStatementFingerprintInsights: (req: clusterUiApi.StmtInsightsReq) => + refreshStatementFingerprintInsights(req), }; export default withRouter( diff --git a/pkg/ui/workspaces/db-console/src/views/transactions/transactionDetails.tsx b/pkg/ui/workspaces/db-console/src/views/transactions/transactionDetails.tsx index 3b26c5c59951..929b17cd5641 100644 --- a/pkg/ui/workspaces/db-console/src/views/transactions/transactionDetails.tsx +++ b/pkg/ui/workspaces/db-console/src/views/transactions/transactionDetails.tsx @@ -14,6 +14,7 @@ import { RouteComponentProps, withRouter } from "react-router-dom"; import { refreshNodes, refreshStatements, + refreshTxnInsights, refreshUserSQLRoles, } from "src/redux/apiReducers"; import { AdminUIState } from "src/redux/state"; @@ -32,6 +33,8 @@ import { } from "@cockroachlabs/cluster-ui"; import { setGlobalTimeScaleAction } from "src/redux/statements"; import { selectTimeScale } from "src/redux/timeScale"; +import { selectTxnInsightsByFingerprint } from "src/views/insights/insightsSelectors"; +import { selectHasAdminRole } from "src/redux/user"; export const selectTransaction = createSelector( (state: AdminUIState) => state.cachedData.statements, @@ -86,6 +89,8 @@ export default withRouter( ), isLoading: isLoading, lastUpdated: lastUpdated, + transactionInsights: selectTxnInsightsByFingerprint(state, props), + hasAdminRole: selectHasAdminRole(state), }; }, { @@ -93,6 +98,7 @@ export default withRouter( refreshNodes, refreshUserSQLRoles, onTimeScaleChange: setGlobalTimeScaleAction, + refreshTransactionInsights: refreshTxnInsights, }, )(TransactionDetails), );