diff --git a/pkg/ccl/logictestccl/testdata/logic_test/regional_by_row_query_behavior b/pkg/ccl/logictestccl/testdata/logic_test/regional_by_row_query_behavior index 6f2ad029ba86..25c94c6239e4 100644 --- a/pkg/ccl/logictestccl/testdata/logic_test/regional_by_row_query_behavior +++ b/pkg/ccl/logictestccl/testdata/logic_test/regional_by_row_query_behavior @@ -2809,3 +2809,56 @@ SELECT * FROM [EXPLAIN SELECT * FROM regional_by_row_table_as1 LIMIT 3] OFFSET 2 table: regional_by_row_table_as1@regional_by_row_table_as1_pkey spans: [/'ca-central-1' - /'us-east-1'] limit: 3 + +subtest index_recommendations + +# Enable vectorize so we get consistent EXPLAIN output. We cannot use the +# OFFSET 2 strategy for these tests because that disables the index +# recommendation (index recommendations are only used when EXPLAIN is the +# root of the query tree). +statement ok +SET index_recommendations_enabled = true; +SET vectorize=on + +statement ok +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name STRING NOT NULL, + email STRING NOT NULL UNIQUE, + INDEX (name) +) LOCALITY REGIONAL BY ROW + +# Check that we don't recommend indexes that already exist. +query T +EXPLAIN INSERT INTO users (name, email) +VALUES ('Craig Roacher', 'craig@cockroachlabs.com') +---- +distribution: local +vectorized: true +· +• root +│ +├── • insert +│ │ into: users(id, name, email, crdb_region) +│ │ +│ └── • buffer +│ │ label: buffer 1 +│ │ +│ └── • values +│ size: 5 columns, 1 row +│ +└── • constraint-check + │ + └── • error if rows + │ + └── • lookup join (semi) + │ table: users@users_email_key + │ lookup condition: (column2 = email) AND (crdb_region IN ('ap-southeast-2', 'ca-central-1', 'us-east-1')) + │ pred: (id_default != id) OR (crdb_region_default != crdb_region) + │ + └── • scan buffer + label: buffer 1 + +statement ok +SET index_recommendations_enabled = false; +RESET vectorize diff --git a/pkg/cmd/roachtest/tests/sstable_corruption.go b/pkg/cmd/roachtest/tests/sstable_corruption.go index bfce99241474..e19decca3eb8 100644 --- a/pkg/cmd/roachtest/tests/sstable_corruption.go +++ b/pkg/cmd/roachtest/tests/sstable_corruption.go @@ -13,6 +13,7 @@ package tests import ( "context" "fmt" + "path/filepath" "strconv" "strings" "time" @@ -53,15 +54,19 @@ func runSSTableCorruption(ctx context.Context, t test.Test, c cluster.Cluster) { opts.RoachprodOpts.Wait = true c.Stop(ctx, t.L(), opts, crdbNodes) - const findTablesCmd = "" + + const nTables = 6 + var dumpManifestCmd = "" + // Take the latest manifest file ... "ls -tr {store-dir}/MANIFEST-* | tail -n1 | " + // ... dump its contents ... "xargs ./cockroach debug pebble manifest dump | " + - // ... shuffle the files to distribute corruption over the LSM. + // ... filter for SSTables that contain table data. + "grep -v added | grep -v deleted | grep '/Table/'" + var findTablesCmd = dumpManifestCmd + "| " + + // Shuffle the files to distribute corruption over the LSM ... "shuf | " + - // ... filter for up to six SSTables that contain table data. - "grep -v added | grep -v deleted | grep '/Table/' | tail -n6" + // ... take a fixed number of tables. + fmt.Sprintf("tail -n %d", nTables) for _, node := range corruptNodes { result, err := c.RunWithDetailsSingleNode(ctx, t.L(), c.Node(node), findTablesCmd) @@ -69,15 +74,45 @@ func runSSTableCorruption(ctx context.Context, t test.Test, c cluster.Cluster) { t.Fatalf("could not find tables to corrupt: %s\nstdout: %s\nstderr: %s", err, result.Stdout, result.Stderr) } tableSSTs := strings.Split(strings.TrimSpace(result.Stdout), "\n") - if len(tableSSTs) == 0 { - t.Fatal("expected at least one sst containing table keys only, got none") + if len(tableSSTs) != nTables { + // We couldn't find enough tables to corrupt. As there should be an + // abundance of tables, this warrants further investigation. To aid in + // such an investigation, print the contents of the data directory. + cmd := "ls -l {store-dir}" + result, err = c.RunWithDetailsSingleNode(ctx, t.L(), c.Node(node), cmd) + if err == nil { + t.Status("store dir contents:\n", result.Stdout) + } + // Fetch the MANIFEST files from this node. + result, err = c.RunWithDetailsSingleNode( + ctx, t.L(), c.Node(node), + "tar czf {store-dir}/manifests.tar.gz {store-dir}/MANIFEST-*", + ) + if err != nil { + t.Fatalf("could not create manifest file archive: %s", err) + } + result, err = c.RunWithDetailsSingleNode(ctx, t.L(), c.Node(node), "echo", "-n", "{store-dir}") + if err != nil { + t.Fatalf("could not infer store directory: %s", err) + } + storeDirectory := result.Stdout + srcPath := filepath.Join(storeDirectory, "manifests.tar.gz") + dstPath := filepath.Join(t.ArtifactsDir(), fmt.Sprintf("manifests.%d.tar.gz", node)) + err = c.Get(ctx, t.L(), srcPath, dstPath, c.Node(node)) + if err != nil { + t.Fatalf("could not fetch manifest archive: %s", err) + } + t.Fatalf( + "expected %d SSTables containing table keys, got %d: %s", + nTables, len(tableSSTs), tableSSTs, + ) } // Corrupt the SSTs. for _, sstLine := range tableSSTs { sstLine = strings.TrimSpace(sstLine) firstFileIdx := strings.Index(sstLine, ":") if firstFileIdx < 0 { - t.Fatalf("unexpected format for sst line: %s", sstLine) + t.Fatalf("unexpected format for sst line: %q", sstLine) } _, err = strconv.Atoi(sstLine[:firstFileIdx]) if err != nil { diff --git a/pkg/roachprod/cloud/gc.go b/pkg/roachprod/cloud/gc.go index 1c8a5c6a324f..a469e05deb8a 100644 --- a/pkg/roachprod/cloud/gc.go +++ b/pkg/roachprod/cloud/gc.go @@ -312,11 +312,7 @@ func GCClusters(l *logger.Logger, cloud *Cloud, dryrun bool) error { } } - // Send out notification to #roachprod-status. client := makeSlackClient() - channel, _ := findChannel(client, "roachprod-status", "") - postStatus(l, client, channel, dryrun, &s, badVMs) - // Send out user notifications if any of the user's clusters are expired or // will be destroyed. for user, status := range users { @@ -330,6 +326,7 @@ func GCClusters(l *logger.Logger, cloud *Cloud, dryrun bool) error { } } + channel, _ := findChannel(client, "roachprod-status", "") if !dryrun { if len(badVMs) > 0 { // Destroy bad VMs. diff --git a/pkg/sql/opt/cat/table.go b/pkg/sql/opt/cat/table.go index 955070ef4b7e..fd91ec2aa11d 100644 --- a/pkg/sql/opt/cat/table.go +++ b/pkg/sql/opt/cat/table.go @@ -136,6 +136,10 @@ type Table interface { // Zone returns a table's zone. Zone() Zone + + // IsPartitionAllBy returns true if this is a PARTITION ALL BY table. This + // includes REGIONAL BY ROW tables. + IsPartitionAllBy() bool } // CheckConstraint contains the SQL text and the validity status for a check diff --git a/pkg/sql/opt/indexrec/index_candidate_set.go b/pkg/sql/opt/indexrec/index_candidate_set.go index 0e0317aa5a4f..10e266e8461a 100644 --- a/pkg/sql/opt/indexrec/index_candidate_set.go +++ b/pkg/sql/opt/indexrec/index_candidate_set.go @@ -373,7 +373,14 @@ func addIndexToCandidates( return } + // Do not add indexes to PARTITION ALL BY tables. + // TODO(rytaft): Support these tables by adding implicit partitioning columns. + if currTable.IsPartitionAllBy() { + return + } + // Do not add indexes on spatial columns. + // TODO(rytaft): Support spatial predicates like st_contains() etc. for _, indexCol := range newIndex { colFamily := indexCol.Column.DatumType().Family() if colFamily == types.GeometryFamily || colFamily == types.GeographyFamily { diff --git a/pkg/sql/opt/testutils/testcat/test_catalog.go b/pkg/sql/opt/testutils/testcat/test_catalog.go index c8800b7e15ab..75fff204eab0 100644 --- a/pkg/sql/opt/testutils/testcat/test_catalog.go +++ b/pkg/sql/opt/testutils/testcat/test_catalog.go @@ -755,6 +755,11 @@ func (tt *Table) Zone() cat.Zone { return cat.AsZone(&zone) } +// IsPartitionAllBy is part of the cat.Table interface. +func (tt *Table) IsPartitionAllBy() bool { + return false +} + // FindOrdinal returns the ordinal of the column with the given name. func (tt *Table) FindOrdinal(name string) int { for i, col := range tt.Columns { diff --git a/pkg/sql/opt_catalog.go b/pkg/sql/opt_catalog.go index b508475727b5..c2a61c070898 100644 --- a/pkg/sql/opt_catalog.go +++ b/pkg/sql/opt_catalog.go @@ -1158,6 +1158,11 @@ func (ot *optTable) Zone() cat.Zone { return ot.zone } +// IsPartitionAllBy is part of the cat.Table interface. +func (ot *optTable) IsPartitionAllBy() bool { + return ot.desc.IsPartitionAllBy() +} + // lookupColumnOrdinal returns the ordinal of the column with the given ID. A // cache makes the lookup O(1). func (ot *optTable) lookupColumnOrdinal(colID descpb.ColumnID) (int, error) { @@ -2073,6 +2078,11 @@ func (ot *optVirtualTable) Zone() cat.Zone { panic(errors.AssertionFailedf("no zone")) } +// IsPartitionAllBy is part of the cat.Table interface. +func (ot *optVirtualTable) IsPartitionAllBy() bool { + return false +} + // CollectTypes is part of the cat.DataSource interface. func (ot *optVirtualTable) CollectTypes(ord int) (descpb.IDs, error) { col := ot.desc.AllColumns()[ord] diff --git a/pkg/ui/workspaces/cluster-ui/src/statementDetails/activeStatementDetails.selectors.ts b/pkg/ui/workspaces/cluster-ui/src/statementDetails/activeStatementDetails.selectors.ts new file mode 100644 index 000000000000..44e5a9eeb833 --- /dev/null +++ b/pkg/ui/workspaces/cluster-ui/src/statementDetails/activeStatementDetails.selectors.ts @@ -0,0 +1,30 @@ +// 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 { createSelector } from "reselect"; +import { AppState } from "src"; +import { match, RouteComponentProps } from "react-router-dom"; +import { getMatchParamByName } from "src/util/query"; +import { executionIdAttr } from "../util/constants"; +import { getActiveStatementsFromSessions } from "../activeExecutions/activeStatementUtils"; + +export const selectActiveStatement = createSelector( + (_: AppState, props: RouteComponentProps) => props.match, + (state: AppState) => state.adminUI.sessions, + (match: match, response) => { + if (!response.data) return null; + + const executionID = getMatchParamByName(match, executionIdAttr); + return getActiveStatementsFromSessions( + response.data, + response.lastUpdated, + ).find(stmt => stmt.executionID === executionID); + }, +); diff --git a/pkg/ui/workspaces/cluster-ui/src/statementDetails/activeStatementDetailsConnected.tsx b/pkg/ui/workspaces/cluster-ui/src/statementDetails/activeStatementDetailsConnected.tsx new file mode 100644 index 000000000000..d494c636bb36 --- /dev/null +++ b/pkg/ui/workspaces/cluster-ui/src/statementDetails/activeStatementDetailsConnected.tsx @@ -0,0 +1,43 @@ +// 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 { RouteComponentProps, withRouter } from "react-router-dom"; +import { connect } from "react-redux"; +import { Dispatch } from "redux"; +import { actions as sessionsActions } from "src/store/sessions"; +import { AppState } from "../store"; +import { + ActiveStatementDetails, + ActiveStatementDetailsDispatchProps, +} from "./activeStatementDetails"; +import { selectActiveStatement } from "./activeStatementDetails.selectors"; +import { ActiveStatementDetailsStateProps } from "."; + +// For tenant cases, we don't show information about node, regions and +// diagnostics. +const mapStateToProps = ( + state: AppState, + props: RouteComponentProps, +): ActiveStatementDetailsStateProps => { + return { + statement: selectActiveStatement(state, props), + match: props.match, + }; +}; + +const mapDispatchToProps = ( + dispatch: Dispatch, +): ActiveStatementDetailsDispatchProps => ({ + refreshSessions: () => dispatch(sessionsActions.refresh()), +}); + +export const ActiveStatementDetailsPageConnected = withRouter( + connect(mapStateToProps, mapDispatchToProps)(ActiveStatementDetails), +); diff --git a/pkg/ui/workspaces/cluster-ui/src/statementDetails/index.ts b/pkg/ui/workspaces/cluster-ui/src/statementDetails/index.ts index 38950537e677..2876da41b8ff 100644 --- a/pkg/ui/workspaces/cluster-ui/src/statementDetails/index.ts +++ b/pkg/ui/workspaces/cluster-ui/src/statementDetails/index.ts @@ -14,3 +14,4 @@ export * from "./diagnostics/diagnosticsUtils"; export * from "./planView"; export * from "./statementDetailsConnected"; export * from "./activeStatementDetails"; +export * from "./activeStatementDetailsConnected"; diff --git a/pkg/ui/workspaces/cluster-ui/src/statementsPage/activeStatementsPage.selectors.ts b/pkg/ui/workspaces/cluster-ui/src/statementsPage/activeStatementsPage.selectors.ts new file mode 100644 index 000000000000..0ad4e8c13e49 --- /dev/null +++ b/pkg/ui/workspaces/cluster-ui/src/statementsPage/activeStatementsPage.selectors.ts @@ -0,0 +1,90 @@ +// 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 { createSelector } from "reselect"; +import { getActiveStatementsFromSessions } from "../activeExecutions/activeStatementUtils"; +import { localStorageSelector } from "./statementsPage.selectors"; +import { + ActiveStatementFilters, + ActiveStatementsViewDispatchProps, + ActiveStatementsViewStateProps, + AppState, + SortSetting, +} from "src"; +import { actions as sessionsActions } from "src/store/sessions"; +import { actions as localStorageActions } from "src/store/localStorage"; +import { Dispatch } from "redux"; + +export const selectActiveStatements = createSelector( + (state: AppState) => state.adminUI.sessions, + response => { + if (!response.data) return []; + + return getActiveStatementsFromSessions(response.data, response.lastUpdated); + }, +); + +export const selectSortSetting = (state: AppState): SortSetting => + localStorageSelector(state)["sortSetting/ActiveStatementsPage"]; + +export const selectFilters = (state: AppState): ActiveStatementFilters => + localStorageSelector(state)["filters/ActiveStatementsPage"]; + +const selectLocalStorageColumns = (state: AppState) => { + const localStorage = localStorageSelector(state); + return localStorage["showColumns/ActiveStatementsPage"]; +}; + +export const selectColumns = createSelector( + selectLocalStorageColumns, + value => { + if (value == null) return null; + + return value.split(",").map(col => col.trim()); + }, +); + +export const mapStateToActiveStatementsPageProps = ( + state: AppState, +): ActiveStatementsViewStateProps => ({ + statements: selectActiveStatements(state), + sessionsError: state.adminUI.sessions.lastError, + selectedColumns: selectColumns(state), + sortSetting: selectSortSetting(state), + filters: selectFilters(state), +}); + +export const mapDispatchToActiveStatementsPageProps = ( + dispatch: Dispatch, +): ActiveStatementsViewDispatchProps => ({ + refreshSessions: () => dispatch(sessionsActions.refresh()), + onColumnsSelect: columns => { + dispatch( + localStorageActions.update({ + key: "showColumns/ActiveStatementsPage", + value: columns.join(","), + }), + ); + }, + onFiltersChange: (filters: ActiveStatementFilters) => + dispatch( + localStorageActions.update({ + key: "filters/ActiveStatementsPage", + value: filters, + }), + ), + onSortChange: (ss: SortSetting) => + dispatch( + localStorageActions.update({ + key: "sortSetting/ActiveStatementsPage", + value: ss, + }), + ), +}); diff --git a/pkg/ui/workspaces/cluster-ui/src/statementsPage/statementsPageConnected.tsx b/pkg/ui/workspaces/cluster-ui/src/statementsPage/statementsPageConnected.tsx index 516e8ee2b9a3..17ffdec62218 100644 --- a/pkg/ui/workspaces/cluster-ui/src/statementsPage/statementsPageConnected.tsx +++ b/pkg/ui/workspaces/cluster-ui/src/statementsPage/statementsPageConnected.tsx @@ -18,9 +18,7 @@ import { actions as analyticsActions } from "src/store/analytics"; import { actions as localStorageActions } from "src/store/localStorage"; import { actions as sqlStatsActions } from "src/store/sqlStats"; import { - StatementsPage, StatementsPageDispatchProps, - StatementsPageProps, StatementsPageStateProps, } from "./statementsPage"; import { @@ -44,6 +42,18 @@ import { nodeRegionsByIDSelector } from "../store/nodes"; import { StatementsRequest } from "src/api/statementsApi"; import { TimeScale } from "../timeScaleDropdown"; import { cockroach, google } from "@cockroachlabs/crdb-protobuf-client"; +import { + StatementsPageRoot, + StatementsPageRootProps, +} from "./statementsPageRoot"; +import { + ActiveStatementsViewDispatchProps, + ActiveStatementsViewStateProps, +} from "./activeStatementsView"; +import { + mapDispatchToActiveStatementsPageProps, + mapStateToActiveStatementsPageProps, +} from "./activeStatementsPage.selectors"; type IStatementDiagnosticsReport = cockroach.server.serverpb.IStatementDiagnosticsReport; type IDuration = google.protobuf.IDuration; @@ -54,170 +64,200 @@ const CreateStatementDiagnosticsReportRequest = const CancelStatementDiagnosticsReportRequest = cockroach.server.serverpb.CancelStatementDiagnosticsReportRequest; +type StateProps = { + fingerprintsPageProps: StatementsPageStateProps & RouteComponentProps; + activePageProps: ActiveStatementsViewStateProps; +}; + +type DispatchProps = { + fingerprintsPageProps: StatementsPageDispatchProps; + activePageProps: ActiveStatementsViewDispatchProps; +}; + export const ConnectedStatementsPage = withRouter( connect< - StatementsPageStateProps, - StatementsPageDispatchProps, - RouteComponentProps + StateProps, + DispatchProps, + RouteComponentProps, + StatementsPageRootProps >( - (state: AppState, props: StatementsPageProps) => ({ - apps: selectApps(state), - columns: selectColumns(state), - databases: selectDatabases(state), - timeScale: selectTimeScale(state), - filters: selectFilters(state), - isTenant: selectIsTenant(state), - hasViewActivityRedactedRole: selectHasViewActivityRedactedRole(state), - lastReset: selectLastReset(state), - nodeRegions: selectIsTenant(state) ? {} : nodeRegionsByIDSelector(state), - search: selectSearch(state), - sortSetting: selectSortSetting(state), - statements: selectStatements(state, props), - statementsError: selectStatementsLastError(state), - totalFingerprints: selectTotalFingerprints(state), + (state: AppState, props: RouteComponentProps) => ({ + fingerprintsPageProps: { + ...props, + apps: selectApps(state), + columns: selectColumns(state), + databases: selectDatabases(state), + timeScale: selectTimeScale(state), + filters: selectFilters(state), + isTenant: selectIsTenant(state), + hasViewActivityRedactedRole: selectHasViewActivityRedactedRole(state), + lastReset: selectLastReset(state), + nodeRegions: selectIsTenant(state) + ? {} + : nodeRegionsByIDSelector(state), + search: selectSearch(state), + sortSetting: selectSortSetting(state), + statements: selectStatements(state, props), + statementsError: selectStatementsLastError(state), + totalFingerprints: selectTotalFingerprints(state), + }, + activePageProps: mapStateToActiveStatementsPageProps(state), }), (dispatch: Dispatch) => ({ - refreshStatements: (req: StatementsRequest) => - dispatch(sqlStatsActions.refresh(req)), - onTimeScaleChange: (ts: TimeScale) => { - dispatch( - sqlStatsActions.updateTimeScale({ - ts: ts, - }), - ); - }, - refreshStatementDiagnosticsRequests: () => - dispatch(statementDiagnosticsActions.refresh()), - refreshUserSQLRoles: () => - dispatch(uiConfigActions.refreshUserSQLRoles()), - resetSQLStats: (req: StatementsRequest) => - dispatch(sqlStatsActions.reset(req)), - dismissAlertMessage: () => - dispatch( - localStorageActions.update({ - key: "adminUi/showDiagnosticsModal", - value: false, - }), - ), - onActivateStatementDiagnostics: ( - statementFingerprint: string, - minExecLatency: IDuration, - expiresAfter: IDuration, - ) => { - dispatch( - statementDiagnosticsActions.createReport( - new CreateStatementDiagnosticsReportRequest({ - statement_fingerprint: statementFingerprint, - min_execution_latency: minExecLatency, - expires_after: expiresAfter, + fingerprintsPageProps: { + refreshStatements: (req: StatementsRequest) => + dispatch(sqlStatsActions.refresh(req)), + onTimeScaleChange: (ts: TimeScale) => { + dispatch( + sqlStatsActions.updateTimeScale({ + ts: ts, + }), + ); + }, + refreshStatementDiagnosticsRequests: () => + dispatch(statementDiagnosticsActions.refresh()), + refreshUserSQLRoles: () => + dispatch(uiConfigActions.refreshUserSQLRoles()), + resetSQLStats: (req: StatementsRequest) => + dispatch(sqlStatsActions.reset(req)), + dismissAlertMessage: () => + dispatch( + localStorageActions.update({ + key: "adminUi/showDiagnosticsModal", + value: false, }), ), - ); - dispatch( - analyticsActions.track({ - name: "Statement Diagnostics Clicked", - page: "Statements", - action: "Activated", - }), - ); - }, - onSelectDiagnosticsReportDropdownOption: ( - report: IStatementDiagnosticsReport, - ) => { - if (report.completed) { + onActivateStatementDiagnostics: ( + statementFingerprint: string, + minExecLatency: IDuration, + expiresAfter: IDuration, + ) => { + dispatch( + statementDiagnosticsActions.createReport( + new CreateStatementDiagnosticsReportRequest({ + statement_fingerprint: statementFingerprint, + min_execution_latency: minExecLatency, + expires_after: expiresAfter, + }), + ), + ); dispatch( analyticsActions.track({ name: "Statement Diagnostics Clicked", page: "Statements", - action: "Downloaded", + action: "Activated", }), ); - } else { - dispatch( - statementDiagnosticsActions.cancelReport( - new CancelStatementDiagnosticsReportRequest({ - request_id: report.id, + }, + onSelectDiagnosticsReportDropdownOption: ( + report: IStatementDiagnosticsReport, + ) => { + if (report.completed) { + dispatch( + analyticsActions.track({ + name: "Statement Diagnostics Clicked", + page: "Statements", + action: "Downloaded", }), - ), + ); + } else { + dispatch( + statementDiagnosticsActions.cancelReport( + new CancelStatementDiagnosticsReportRequest({ + request_id: report.id, + }), + ), + ); + dispatch( + analyticsActions.track({ + name: "Statement Diagnostics Clicked", + page: "Statements", + action: "Cancelled", + }), + ); + } + }, + onSearchComplete: (query: string) => { + dispatch( + analyticsActions.track({ + name: "Keyword Searched", + page: "Statements", + }), + ); + dispatch( + localStorageActions.update({ + key: "search/StatementsPage", + value: query, + }), ); + }, + onFilterChange: value => { dispatch( analyticsActions.track({ - name: "Statement Diagnostics Clicked", + name: "Filter Clicked", page: "Statements", - action: "Cancelled", + filterName: "app", + value: value.toString(), }), ); - } - }, - onSearchComplete: (query: string) => { - dispatch( - analyticsActions.track({ - name: "Keyword Searched", - page: "Statements", - }), - ); - dispatch( - localStorageActions.update({ - key: "search/StatementsPage", - value: query, - }), - ); + dispatch( + localStorageActions.update({ + key: "filters/StatementsPage", + value: value, + }), + ); + }, + onSortingChange: ( + tableName: string, + columnName: string, + ascending: boolean, + ) => { + dispatch( + analyticsActions.track({ + name: "Column Sorted", + page: "Statements", + tableName, + columnName, + }), + ); + dispatch( + localStorageActions.update({ + key: "sortSetting/StatementsPage", + value: { columnTitle: columnName, ascending: ascending }, + }), + ); + }, + onStatementClick: () => + dispatch( + analyticsActions.track({ + name: "Statement Clicked", + page: "Statements", + }), + ), + // We use `null` when the value was never set and it will show all columns. + // If the user modifies the selection and no columns are selected, + // the function will save the value as a blank space, otherwise + // it gets saved as `null`. + onColumnsChange: (selectedColumns: string[]) => + dispatch( + localStorageActions.update({ + key: "showColumns/StatementsPage", + value: + selectedColumns.length === 0 ? " " : selectedColumns.join(","), + }), + ), }, - onFilterChange: value => { - dispatch( - analyticsActions.track({ - name: "Filter Clicked", - page: "Statements", - filterName: "app", - value: value.toString(), - }), - ); - dispatch( - localStorageActions.update({ - key: "filters/StatementsPage", - value: value, - }), - ); + activePageProps: mapDispatchToActiveStatementsPageProps(dispatch), + }), + (stateProps, dispatchProps) => ({ + fingerprintsPageProps: { + ...stateProps.fingerprintsPageProps, + ...dispatchProps.fingerprintsPageProps, }, - onSortingChange: ( - tableName: string, - columnName: string, - ascending: boolean, - ) => { - dispatch( - analyticsActions.track({ - name: "Column Sorted", - page: "Statements", - tableName, - columnName, - }), - ); - dispatch( - localStorageActions.update({ - key: "sortSetting/StatementsPage", - value: { columnTitle: columnName, ascending: ascending }, - }), - ); + activePageProps: { + ...stateProps.activePageProps, + ...dispatchProps.activePageProps, }, - onStatementClick: () => - dispatch( - analyticsActions.track({ - name: "Statement Clicked", - page: "Statements", - }), - ), - // We use `null` when the value was never set and it will show all columns. - // If the user modifies the selection and no columns are selected, - // the function will save the value as a blank space, otherwise - // it gets saved as `null`. - onColumnsChange: (selectedColumns: string[]) => - dispatch( - localStorageActions.update({ - key: "showColumns/StatementsPage", - value: - selectedColumns.length === 0 ? " " : selectedColumns.join(","), - }), - ), }), - )(StatementsPage), + )(StatementsPageRoot), ); 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 28e0ed906c29..0d7159dab5a1 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 @@ -21,13 +21,19 @@ type SortSetting = { export type LocalStorageState = { "adminUi/showDiagnosticsModal": boolean; + "showColumns/ActiveStatementsPage": string; + "showColumns/ActiveTransactionsPage": string; "showColumns/StatementsPage": string; "showColumns/TransactionPage": string; "showColumns/SessionsPage": string; "timeScale/SQLActivity": TimeScale; + "sortSetting/ActiveStatementsPage": SortSetting; + "sortSetting/ActiveTransactionsPage": SortSetting; "sortSetting/StatementsPage": SortSetting; "sortSetting/TransactionsPage": SortSetting; "sortSetting/SessionsPage": SortSetting; + "filters/ActiveStatementsPage": Filters; + "filters/ActiveTransactionsPage": Filters; "filters/StatementsPage": Filters; "filters/TransactionsPage": Filters; "filters/SessionsPage": Filters; @@ -45,6 +51,15 @@ const defaultSortSetting: SortSetting = { columnTitle: "executionCount", }; +const defaultSortSettingActiveExecutions: SortSetting = { + ascending: false, + columnTitle: "startTime", +}; + +const defaultFiltersActiveExecutions = { + app: defaultFilters.app, +}; + const defaultSessionsSortSetting: SortSetting = { ascending: false, columnTitle: "statementAge", @@ -55,6 +70,12 @@ const initialState: LocalStorageState = { "adminUi/showDiagnosticsModal": Boolean(JSON.parse(localStorage.getItem("adminUi/showDiagnosticsModal"))) || false, + "showColumns/ActiveStatementsPage": + JSON.parse(localStorage.getItem("showColumns/ActiveStatementsPage")) ?? + null, + "showColumns/ActiveTransactionsPage": + JSON.parse(localStorage.getItem("showColumns/ActiveTransactionsPage")) ?? + null, "showColumns/StatementsPage": JSON.parse(localStorage.getItem("showColumns/StatementsPage")) || null, "showColumns/TransactionPage": @@ -64,6 +85,12 @@ const initialState: LocalStorageState = { "timeScale/SQLActivity": JSON.parse(localStorage.getItem("timeScale/SQLActivity")) || defaultTimeScaleSelected, + "sortSetting/ActiveStatementsPage": + JSON.parse(localStorage.getItem("sortSetting/ActiveStatementsPage")) || + defaultSortSettingActiveExecutions, + "sortSetting/ActiveTransactionsPage": + JSON.parse(localStorage.getItem("sortSetting/ActiveTransactionsPage")) || + defaultSortSettingActiveExecutions, "sortSetting/StatementsPage": JSON.parse(localStorage.getItem("sortSetting/StatementsPage")) || defaultSortSetting, @@ -73,6 +100,12 @@ const initialState: LocalStorageState = { "sortSetting/SessionsPage": JSON.parse(localStorage.getItem("sortSetting/SessionsPage")) || defaultSessionsSortSetting, + "filters/ActiveStatementsPage": + JSON.parse(localStorage.getItem("filters/ActiveStatementsPage")) || + defaultFiltersActiveExecutions, + "filters/ActiveTransactionsPage": + JSON.parse(localStorage.getItem("filters/ActiveTransactionsPage")) || + defaultFiltersActiveExecutions, "filters/StatementsPage": JSON.parse(localStorage.getItem("filters/StatementsPage")) || defaultFilters, diff --git a/pkg/ui/workspaces/cluster-ui/src/store/sessions/sessions.reducer.ts b/pkg/ui/workspaces/cluster-ui/src/store/sessions/sessions.reducer.ts index 6e9a00e3f976..ffb84151f9c1 100644 --- a/pkg/ui/workspaces/cluster-ui/src/store/sessions/sessions.reducer.ts +++ b/pkg/ui/workspaces/cluster-ui/src/store/sessions/sessions.reducer.ts @@ -11,17 +11,20 @@ import { createSlice, PayloadAction } from "@reduxjs/toolkit"; import { cockroach } from "@cockroachlabs/crdb-protobuf-client"; import { DOMAIN_NAME, noopReducer } from "../utils"; +import moment, { Moment } from "moment"; type SessionsResponse = cockroach.server.serverpb.ListSessionsResponse; export type SessionsState = { data: SessionsResponse; + lastUpdated: Moment | null; lastError: Error; valid: boolean; }; const initialState: SessionsState = { data: null, + lastUpdated: null, lastError: null, valid: true, }; @@ -34,6 +37,7 @@ const ssessionsSlice = createSlice({ state.data = action.payload; state.valid = true; state.lastError = null; + state.lastUpdated = moment.utc(); }, failed: (state, action: PayloadAction) => { state.valid = false; diff --git a/pkg/ui/workspaces/cluster-ui/src/store/sessions/sessions.sagas.ts b/pkg/ui/workspaces/cluster-ui/src/store/sessions/sessions.sagas.ts index dc0004a9bf37..0912974529d4 100644 --- a/pkg/ui/workspaces/cluster-ui/src/store/sessions/sessions.sagas.ts +++ b/pkg/ui/workspaces/cluster-ui/src/store/sessions/sessions.sagas.ts @@ -12,7 +12,7 @@ import { all, call, delay, put, takeLatest } from "redux-saga/effects"; import { actions } from "./sessions.reducer"; import { getSessions } from "src/api/sessionsApi"; -import { CACHE_INVALIDATION_PERIOD, throttleWithReset } from "../utils"; +import { throttleWithReset } from "../utils"; import { rootActions } from "../reducers"; export function* refreshSessionsSaga() { @@ -33,9 +33,7 @@ export function* receivedStatementsSaga(delayMs: number) { yield put(actions.invalidated()); } -export function* sessionsSaga( - cacheInvalidationPeriod: number = CACHE_INVALIDATION_PERIOD, -) { +export function* sessionsSaga(cacheInvalidationPeriod: number = 10 * 1000) { yield all([ throttleWithReset( cacheInvalidationPeriod, diff --git a/pkg/ui/workspaces/cluster-ui/src/transactionDetails/activeTransactionDetails.selectors.tsx b/pkg/ui/workspaces/cluster-ui/src/transactionDetails/activeTransactionDetails.selectors.tsx new file mode 100644 index 000000000000..1a8f899fee1d --- /dev/null +++ b/pkg/ui/workspaces/cluster-ui/src/transactionDetails/activeTransactionDetails.selectors.tsx @@ -0,0 +1,30 @@ +// 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 { createSelector } from "reselect"; +import { AppState } from "src"; +import { match, RouteComponentProps } from "react-router-dom"; +import { getMatchParamByName } from "src/util/query"; +import { executionIdAttr } from "../util/constants"; +import { getActiveTransactionsFromSessions } from "../activeExecutions/activeStatementUtils"; + +export const selectActiveTransaction = createSelector( + (_: AppState, props: RouteComponentProps) => props.match, + (state: AppState) => state.adminUI.sessions, + (match: match, response) => { + if (!response.data) return null; + + const executionID = getMatchParamByName(match, executionIdAttr); + return getActiveTransactionsFromSessions( + response.data, + response.lastUpdated, + ).find(stmt => stmt.executionID === executionID); + }, +); diff --git a/pkg/ui/workspaces/cluster-ui/src/transactionDetails/activeTransactionDetailsConnected.tsx b/pkg/ui/workspaces/cluster-ui/src/transactionDetails/activeTransactionDetailsConnected.tsx new file mode 100644 index 000000000000..228206916cd1 --- /dev/null +++ b/pkg/ui/workspaces/cluster-ui/src/transactionDetails/activeTransactionDetailsConnected.tsx @@ -0,0 +1,43 @@ +// 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 { RouteComponentProps, withRouter } from "react-router-dom"; +import { connect } from "react-redux"; +import { Dispatch } from "redux"; +import { actions as sessionsActions } from "src/store/sessions"; +import { AppState } from "../store"; +import { + ActiveTransactionDetails, + ActiveTransactionDetailsDispatchProps, +} from "./activeTransactionDetails"; +import { selectActiveTransaction } from "./activeTransactionDetails.selectors"; +import { ActiveTransactionDetailsStateProps } from "."; + +// For tenant cases, we don't show information about node, regions and +// diagnostics. +const mapStateToProps = ( + state: AppState, + props: RouteComponentProps, +): ActiveTransactionDetailsStateProps => { + return { + transaction: selectActiveTransaction(state, props), + match: props.match, + }; +}; + +const mapDispatchToProps = ( + dispatch: Dispatch, +): ActiveTransactionDetailsDispatchProps => ({ + refreshSessions: () => dispatch(sessionsActions.refresh()), +}); + +export const ActiveTransactionDetailsPageConnected = withRouter( + connect(mapStateToProps, mapDispatchToProps)(ActiveTransactionDetails), +); diff --git a/pkg/ui/workspaces/cluster-ui/src/transactionDetails/index.ts b/pkg/ui/workspaces/cluster-ui/src/transactionDetails/index.ts index 38913885069b..7487c1a8de91 100644 --- a/pkg/ui/workspaces/cluster-ui/src/transactionDetails/index.ts +++ b/pkg/ui/workspaces/cluster-ui/src/transactionDetails/index.ts @@ -11,3 +11,4 @@ export * from "./transactionDetails"; export * from "./transactionDetailsConnected"; export * from "./activeTransactionDetails"; +export * from "./activeTransactionDetailsConnected"; diff --git a/pkg/ui/workspaces/cluster-ui/src/transactionsPage/activeTransactionsPage.selectors.tsx b/pkg/ui/workspaces/cluster-ui/src/transactionsPage/activeTransactionsPage.selectors.tsx new file mode 100644 index 000000000000..cf99ae86ce41 --- /dev/null +++ b/pkg/ui/workspaces/cluster-ui/src/transactionsPage/activeTransactionsPage.selectors.tsx @@ -0,0 +1,92 @@ +// 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 { createSelector } from "reselect"; +import { getActiveTransactionsFromSessions } from "../activeExecutions/activeStatementUtils"; +import { localStorageSelector } from "src/statementsPage/statementsPage.selectors"; +import { + ActiveTransactionFilters, + ActiveTransactionsViewDispatchProps, + ActiveTransactionsViewStateProps, + AppState, + SortSetting, +} from "src"; +import { actions as sessionsActions } from "src/store/sessions"; +import { actions as localStorageActions } from "src/store/localStorage"; +import { Dispatch } from "redux"; + +export const selectActiveTransactions = createSelector( + (state: AppState) => state.adminUI.sessions, + response => { + if (!response.data) return []; + + return getActiveTransactionsFromSessions( + response.data, + response.lastUpdated, + ); + }, +); + +export const selectSortSetting = (state: AppState): SortSetting => + localStorageSelector(state)["sortSetting/ActiveTransactionsPage"]; + +export const selectFilters = (state: AppState): ActiveTransactionFilters => + localStorageSelector(state)["filters/ActiveTransactionsPage"]; + +const selectLocalStorageColumns = (state: AppState) => { + const localStorage = localStorageSelector(state); + return localStorage["showColumns/ActiveTransactionsPage"]; +}; + +export const selectColumns = createSelector( + selectLocalStorageColumns, + value => { + if (value == null) return null; + + return value.split(",").map(col => col.trim()); + }, +); + +export const mapStateToActiveTransactionsPageProps = ( + state: AppState, +): ActiveTransactionsViewStateProps => ({ + transactions: selectActiveTransactions(state), + sessionsError: state.adminUI.sessions.lastError, + selectedColumns: selectColumns(state), + sortSetting: selectSortSetting(state), + filters: selectFilters(state), +}); + +export const mapDispatchToActiveTransactionsPageProps = ( + dispatch: Dispatch, +): ActiveTransactionsViewDispatchProps => ({ + refreshSessions: () => dispatch(sessionsActions.refresh()), + onColumnsSelect: columns => + dispatch( + localStorageActions.update({ + key: "showColumns/ActiveTransactionsPage", + value: columns ? columns.join(", ") : " ", + }), + ), + onFiltersChange: (filters: ActiveTransactionFilters) => + dispatch( + localStorageActions.update({ + key: "filters/ActiveTransactionsPage", + value: filters, + }), + ), + onSortChange: (ss: SortSetting) => + dispatch( + localStorageActions.update({ + key: "sortSetting/ActiveTransactionsPage", + value: ss, + }), + ), +}); diff --git a/pkg/ui/workspaces/cluster-ui/src/transactionsPage/transactionsPageConnected.tsx b/pkg/ui/workspaces/cluster-ui/src/transactionsPage/transactionsPageConnected.tsx index 2c7a7aa56c11..eba0bd180659 100644 --- a/pkg/ui/workspaces/cluster-ui/src/transactionsPage/transactionsPageConnected.tsx +++ b/pkg/ui/workspaces/cluster-ui/src/transactionsPage/transactionsPageConnected.tsx @@ -14,7 +14,6 @@ import { Dispatch } from "redux"; import { AppState } from "src/store"; import { actions as sqlStatsActions } from "src/store/sqlStats"; -import { TransactionsPage } from "./transactionsPage"; import { TransactionsPageStateProps, TransactionsPageDispatchProps, @@ -35,90 +34,130 @@ import { actions as localStorageActions } from "../store/localStorage"; import { Filters } from "../queryFilter"; import { actions as analyticsActions } from "../store/analytics"; import { TimeScale } from "../timeScaleDropdown"; +import { + TransactionsPageRoot, + TransactionsPageRootProps, +} from "./transactionsPageRoot"; +import { + mapStateToActiveTransactionsPageProps, + mapDispatchToActiveTransactionsPageProps, +} from "./activeTransactionsPage.selectors"; +import { + ActiveTransactionsViewStateProps, + ActiveTransactionsViewDispatchProps, +} from "./activeTransactionsView"; + +type StateProps = { + fingerprintsPageProps: TransactionsPageStateProps & RouteComponentProps; + activePageProps: ActiveTransactionsViewStateProps; +}; + +type DispatchProps = { + fingerprintsPageProps: TransactionsPageDispatchProps; + activePageProps: ActiveTransactionsViewDispatchProps; +}; export const TransactionsPageConnected = withRouter( connect< - TransactionsPageStateProps, - TransactionsPageDispatchProps, - RouteComponentProps + StateProps, + DispatchProps, + RouteComponentProps, + TransactionsPageRootProps >( - (state: AppState) => ({ - columns: selectTxnColumns(state), - data: selectTransactionsData(state), - timeScale: selectTimeScale(state), - error: selectTransactionsLastError(state), - filters: selectFilters(state), - isTenant: selectIsTenant(state), - nodeRegions: nodeRegionsByIDSelector(state), - search: selectSearch(state), - sortSetting: selectSortSetting(state), + (state: AppState, props) => ({ + fingerprintsPageProps: { + ...props, + columns: selectTxnColumns(state), + data: selectTransactionsData(state), + timeScale: selectTimeScale(state), + error: selectTransactionsLastError(state), + filters: selectFilters(state), + isTenant: selectIsTenant(state), + nodeRegions: nodeRegionsByIDSelector(state), + search: selectSearch(state), + sortSetting: selectSortSetting(state), + }, + activePageProps: mapStateToActiveTransactionsPageProps(state), }), (dispatch: Dispatch) => ({ - refreshData: (req: StatementsRequest) => - dispatch(sqlStatsActions.refresh(req)), - resetSQLStats: (req: StatementsRequest) => - dispatch(sqlStatsActions.reset(req)), - onTimeScaleChange: (ts: TimeScale) => { - dispatch( - sqlStatsActions.updateTimeScale({ - ts: ts, - }), - ); + fingerprintsPageProps: { + refreshData: (req: StatementsRequest) => + dispatch(sqlStatsActions.refresh(req)), + resetSQLStats: (req: StatementsRequest) => + dispatch(sqlStatsActions.reset(req)), + onTimeScaleChange: (ts: TimeScale) => { + dispatch( + sqlStatsActions.updateTimeScale({ + ts: ts, + }), + ); + }, + // We use `null` when the value was never set and it will show all columns. + // If the user modifies the selection and no columns are selected, + // the function will save the value as a blank space, otherwise + // it gets saved as `null`. + onColumnsChange: (selectedColumns: string[]) => + dispatch( + localStorageActions.update({ + key: "showColumns/TransactionPage", + value: + selectedColumns.length === 0 ? " " : selectedColumns.join(","), + }), + ), + onSortingChange: ( + tableName: string, + columnName: string, + ascending: boolean, + ) => { + dispatch( + localStorageActions.update({ + key: "sortSetting/TransactionsPage", + value: { columnTitle: columnName, ascending: ascending }, + }), + ); + }, + onFilterChange: (value: Filters) => { + dispatch( + analyticsActions.track({ + name: "Filter Clicked", + page: "Transactions", + filterName: "app", + value: value.toString(), + }), + ); + dispatch( + localStorageActions.update({ + key: "filters/TransactionsPage", + value: value, + }), + ); + }, + onSearchComplete: (query: string) => { + dispatch( + analyticsActions.track({ + name: "Keyword Searched", + page: "Transactions", + }), + ); + dispatch( + localStorageActions.update({ + key: "search/TransactionsPage", + value: query, + }), + ); + }, }, - // We use `null` when the value was never set and it will show all columns. - // If the user modifies the selection and no columns are selected, - // the function will save the value as a blank space, otherwise - // it gets saved as `null`. - onColumnsChange: (selectedColumns: string[]) => - dispatch( - localStorageActions.update({ - key: "showColumns/TransactionPage", - value: - selectedColumns.length === 0 ? " " : selectedColumns.join(","), - }), - ), - onSortingChange: ( - tableName: string, - columnName: string, - ascending: boolean, - ) => { - dispatch( - localStorageActions.update({ - key: "sortSetting/TransactionsPage", - value: { columnTitle: columnName, ascending: ascending }, - }), - ); - }, - onFilterChange: (value: Filters) => { - dispatch( - analyticsActions.track({ - name: "Filter Clicked", - page: "Transactions", - filterName: "app", - value: value.toString(), - }), - ); - dispatch( - localStorageActions.update({ - key: "filters/TransactionsPage", - value: value, - }), - ); + activePageProps: mapDispatchToActiveTransactionsPageProps(dispatch), + }), + (stateProps, dispatchProps) => ({ + fingerprintsPageProps: { + ...stateProps.fingerprintsPageProps, + ...dispatchProps.fingerprintsPageProps, }, - onSearchComplete: (query: string) => { - dispatch( - analyticsActions.track({ - name: "Keyword Searched", - page: "Transactions", - }), - ); - dispatch( - localStorageActions.update({ - key: "search/TransactionsPage", - value: query, - }), - ); + activePageProps: { + ...stateProps.activePageProps, + ...dispatchProps.activePageProps, }, }), - )(TransactionsPage), + )(TransactionsPageRoot), );