diff --git a/pkg/server/api_v2_auth.go b/pkg/server/api_v2_auth.go index e1641458fde1..02e2030f02f4 100644 --- a/pkg/server/api_v2_auth.go +++ b/pkg/server/api_v2_auth.go @@ -277,32 +277,54 @@ func newAuthenticationV2Mux(s *authenticationV2Server, inner http.Handler) *auth } } -// getSession decodes the cookie from the request, looks up the corresponding session, and -// returns the logged in user name. If there's an error, it returns an error value and +// getSession decodes the cookie from the request, looks up the +// corresponding session, and returns the logged in user name. The +// session can be looked up either from a session cookie as used in the +// non-v2 API server, or via the session header. In order for us to pick +// up a session, the header must still be non-empty in the case of +// cookie-based auth. If there's an error, it returns an error value and // also sends the error over http using w. func (a *authenticationV2Mux) getSession( w http.ResponseWriter, req *http.Request, ) (string, *serverpb.SessionCookie, error) { - // Validate the returned cookie. + // Validate the returned session header or cookie. rawSession := req.Header.Get(apiV2AuthHeader) if len(rawSession) == 0 { err := errors.New("invalid session header") http.Error(w, err.Error(), http.StatusUnauthorized) return "", nil, err } + + possibleSessions := []string{} + cookies := req.Cookies() + for _, c := range cookies { + if c.Name != SessionCookieName { + continue + } + possibleSessions = append(possibleSessions, c.Value) + } + possibleSessions = append(possibleSessions, rawSession) + sessionCookie := &serverpb.SessionCookie{} - decoded, err := base64.StdEncoding.DecodeString(rawSession) - if err != nil { - err := errors.New("invalid session header") - http.Error(w, err.Error(), http.StatusBadRequest) - return "", nil, err + var decoded []byte + var err error + for i := range possibleSessions { + decoded, err = base64.StdEncoding.DecodeString(possibleSessions[i]) + if err != nil { + continue + } + err = protoutil.Unmarshal(decoded, sessionCookie) + if err != nil { + continue + } + // We've successfully decoded a session from cookie or header + break } - if err := protoutil.Unmarshal(decoded, sessionCookie); err != nil { + if err != nil { err := errors.New("invalid session header") http.Error(w, err.Error(), http.StatusBadRequest) return "", nil, err } - valid, username, err := a.s.authServer.verifySession(req.Context(), sessionCookie) if err != nil { apiV2InternalError(req.Context(), err, w) diff --git a/pkg/server/api_v2_test.go b/pkg/server/api_v2_test.go index f6a7f444bdcd..5f529c15e81e 100644 --- a/pkg/server/api_v2_test.go +++ b/pkg/server/api_v2_test.go @@ -13,6 +13,7 @@ package server import ( "context" gosql "database/sql" + "encoding/base64" "encoding/json" "io/ioutil" "net/http" @@ -27,6 +28,7 @@ import ( "github.com/cockroachdb/cockroach/pkg/util/leaktest" "github.com/cockroachdb/cockroach/pkg/util/log" "github.com/cockroachdb/cockroach/pkg/util/metric" + "github.com/cockroachdb/cockroach/pkg/util/protoutil" "github.com/stretchr/testify/require" "gopkg.in/yaml.v2" ) @@ -184,3 +186,75 @@ func TestRulesV2(t *testing.T) { require.NoError(t, yaml.NewDecoder(resp.Body).Decode(&ruleGroups)) require.NoError(t, resp.Body.Close()) } + +func TestAuthV2(t *testing.T) { + defer leaktest.AfterTest(t)() + defer log.Scope(t).Close(t) + + testCluster := serverutils.StartNewTestCluster(t, 3, base.TestClusterArgs{}) + ctx := context.Background() + defer testCluster.Stopper().Stop(ctx) + + ts := testCluster.Server(0) + client, err := ts.GetUnauthenticatedHTTPClient() + require.NoError(t, err) + + session, err := ts.GetAuthSession(true) + require.NoError(t, err) + sessionBytes, err := protoutil.Marshal(session) + require.NoError(t, err) + sessionEncoded := base64.StdEncoding.EncodeToString(sessionBytes) + + for _, tc := range []struct { + name string + header string + cookie string + expectedStatus int + }{ + { + name: "no auth", + expectedStatus: http.StatusUnauthorized, + }, + { + name: "cookie auth but missing header", + cookie: sessionEncoded, + expectedStatus: http.StatusUnauthorized, + }, + { + name: "cookie auth", + cookie: sessionEncoded, + header: "yes", + expectedStatus: http.StatusOK, + }, + { + name: "just header", + header: sessionEncoded, + expectedStatus: http.StatusOK, + }, + } { + t.Run(tc.name, func(t *testing.T) { + req, err := http.NewRequest("GET", ts.AdminURL()+apiV2Path+"sessions/", nil) + require.NoError(t, err) + if tc.header != "" { + req.Header.Set(apiV2AuthHeader, tc.header) + } + if tc.cookie != "" { + req.AddCookie(&http.Cookie{ + Name: SessionCookieName, + Value: tc.cookie, + }) + } + resp, err := client.Do(req) + require.NoError(t, err) + require.NotNil(t, resp) + defer resp.Body.Close() + + if tc.expectedStatus != resp.StatusCode { + body, err := ioutil.ReadAll(resp.Body) + require.NoError(t, err) + t.Fatal(string(body)) + } + }) + } + +} diff --git a/pkg/server/testserver_http.go b/pkg/server/testserver_http.go index 256b7f2ffd0d..ef6f0aaf163b 100644 --- a/pkg/server/testserver_http.go +++ b/pkg/server/testserver_http.go @@ -71,6 +71,16 @@ func (ts *httpTestServer) GetAuthenticatedHTTPClient(isAdmin bool) (http.Client, return httpClient, err } +// GetAuthenticatedHTTPClient implements the TestServerInterface. +func (ts *httpTestServer) GetAuthSession(isAdmin bool) (*serverpb.SessionCookie, error) { + authUser := authenticatedUserName() + if !isAdmin { + authUser = authenticatedUserNameNoAdmin() + } + _, cookie, err := ts.getAuthenticatedHTTPClientAndCookie(authUser, isAdmin) + return cookie, err +} + func (ts *httpTestServer) getAuthenticatedHTTPClientAndCookie( authUser username.SQLUsername, isAdmin bool, ) (http.Client, *serverpb.SessionCookie, error) { diff --git a/pkg/testutils/serverutils/BUILD.bazel b/pkg/testutils/serverutils/BUILD.bazel index b4c6fab15a00..3585df3ecfe5 100644 --- a/pkg/testutils/serverutils/BUILD.bazel +++ b/pkg/testutils/serverutils/BUILD.bazel @@ -20,6 +20,7 @@ go_library( "//pkg/rpc", "//pkg/security", "//pkg/security/username", + "//pkg/server/serverpb", "//pkg/server/status", "//pkg/settings/cluster", "//pkg/storage", diff --git a/pkg/testutils/serverutils/test_tenant_shim.go b/pkg/testutils/serverutils/test_tenant_shim.go index 4c798df1b470..9691994ec067 100644 --- a/pkg/testutils/serverutils/test_tenant_shim.go +++ b/pkg/testutils/serverutils/test_tenant_shim.go @@ -20,6 +20,7 @@ import ( "github.com/cockroachdb/cockroach/pkg/base" "github.com/cockroachdb/cockroach/pkg/config" "github.com/cockroachdb/cockroach/pkg/rpc" + "github.com/cockroachdb/cockroach/pkg/server/serverpb" "github.com/cockroachdb/cockroach/pkg/settings/cluster" "github.com/cockroachdb/cockroach/pkg/util/hlc" "github.com/cockroachdb/cockroach/pkg/util/log" @@ -129,6 +130,9 @@ type TestTenantInterface interface { // GetAuthenticatedHTTPClient returns an http client which has been // authenticated to access Admin API methods (via a cookie). GetAuthenticatedHTTPClient(isAdmin bool) (http.Client, error) + // GetEncodedSession returns a byte array containing a valid auth + // session. + GetAuthSession(isAdmin bool) (*serverpb.SessionCookie, error) // DrainClients shuts down client connections. DrainClients(ctx context.Context) error diff --git a/pkg/ui/workspaces/cluster-ui/src/api/fetchData.ts b/pkg/ui/workspaces/cluster-ui/src/api/fetchData.ts index cd22082cd2bc..352d7c2e54fb 100644 --- a/pkg/ui/workspaces/cluster-ui/src/api/fetchData.ts +++ b/pkg/ui/workspaces/cluster-ui/src/api/fetchData.ts @@ -89,3 +89,47 @@ export const fetchData =

, T extends ProtoBuilder>( }) .then(buffer => RespBuilder.decode(new Uint8Array(buffer))); }; + +/** + * fetchDataJSON makes a request returning content type JSON. + * @param path relative path for requested resource. + * @param reqPayload request payload object. + */ +export function fetchDataJSON( + path: string, + reqPayload?: RequestType, +): Promise { + const params: RequestInit = { + headers: { + Accept: "application/json", + "Content-Type": "application/json", + "X-Cockroach-API-Session": "Cookie", + }, + credentials: "same-origin", + }; + + if (reqPayload) { + params.method = "POST"; + params.body = JSON.stringify(reqPayload); + } + + const basePath = getBasePath(); + + return fetch(`${basePath}${path}`, params) + .then(response => { + if (!response.ok) { + throw new RequestError( + response.statusText, + response.status, + response.statusText, + ); + } + + return response.json(); + }) + .then(res => { + // TODO xzhang delete dis + console.log(res); + return res; + }); +} diff --git a/pkg/ui/workspaces/cluster-ui/src/api/index.ts b/pkg/ui/workspaces/cluster-ui/src/api/index.ts index a15d4acc1bb1..1d078a03d3ae 100644 --- a/pkg/ui/workspaces/cluster-ui/src/api/index.ts +++ b/pkg/ui/workspaces/cluster-ui/src/api/index.ts @@ -13,3 +13,4 @@ export * from "./statementDiagnosticsApi"; export * from "./statementsApi"; export * from "./basePath"; export * from "./nodesApi"; +export * from "./insightsApi"; diff --git a/pkg/ui/workspaces/cluster-ui/src/api/insightsApi.ts b/pkg/ui/workspaces/cluster-ui/src/api/insightsApi.ts new file mode 100644 index 000000000000..7e39a4ccbe06 --- /dev/null +++ b/pkg/ui/workspaces/cluster-ui/src/api/insightsApi.ts @@ -0,0 +1,82 @@ +// Copyright 2022 The Cockroach Authors. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0, included in the file +// licenses/APL.txt. + +import { executeSql, SqlExecutionRequest } from "./sqlApi"; +import { InsightExecEnum, TransactionInsight } from "../insights"; +import moment from "moment"; + +// TO-DO: Add StatementInsight API + +export type TransactionInsightState = Omit; + +type TransactionInsightResponseColumns = { + blocking_txn_id: string; + blocking_queries: string; + collection_ts: string; + contention_duration: string; + app_name: string; +}; + +export type TransactionInsightsResponse = TransactionInsightState[]; + +export function getTransactionInsightState(): Promise { + const request: SqlExecutionRequest = { + statements: [ + { + sql: `SELECT + blocking_txn_id, + blocking_queries, + collection_ts, + contention_duration, + app_name + FROM + crdb_internal.transaction_contention_events AS tce + JOIN ( + SELECT + transaction_fingerprint_id, + app_name, + array_agg(metadata ->> 'query') AS blocking_queries + FROM + crdb_internal.statement_statistics + GROUP BY + transaction_fingerprint_id, + app_name + ) AS bqs ON bqs.transaction_fingerprint_id = tce.blocking_txn_fingerprint_id + `, + }, + ], + execute: true, + }; + return executeSql(request).then(result => { + if (!result.execution.txn_results[0].rows) { + // No data. + return []; + } + + const contentionEvents: Record = {}; + result.execution.txn_results[0].rows.forEach(row => { + const key = row.blocking_txn_id; + if (!contentionEvents[key]) { + contentionEvents[key] = { + executionID: row.blocking_txn_id, + query: row.blocking_queries, + startTime: moment(row.collection_ts), + elapsedTime: moment + .duration(row.contention_duration) + .asMilliseconds(), + application: row.app_name, + execType: InsightExecEnum.TRANSACTION, + }; + } + }); + + return Object.values(contentionEvents); + }); +} diff --git a/pkg/ui/workspaces/cluster-ui/src/api/sqlApi.ts b/pkg/ui/workspaces/cluster-ui/src/api/sqlApi.ts new file mode 100644 index 000000000000..faf1fe6df9dc --- /dev/null +++ b/pkg/ui/workspaces/cluster-ui/src/api/sqlApi.ts @@ -0,0 +1,77 @@ +// 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 { fetchDataJSON } from "./fetchData"; + +export type SqlStatement = { + sql: string; + arguments?: unknown[]; +}; + +export type SqlExecutionRequest = { + statements: SqlStatement[]; + execute?: boolean; + timeout?: string; // Default 5s + application_name?: string; // Defaults to '$ api-v2-sql' + database_name?: string; // Defaults to defaultDb +}; + +export type SqlExecutionResponse = { + num_statements?: number; + execution: SqlExecutionExecResult; + error?: SqlExecutionErrorMessage; + request?: SqlExecutionRequest; +}; + +export interface SqlExecutionExecResult { + retries: number; + txn_results: SqlTxnResult[]; +} + +export type SqlTxnResult = { + statement: number; // Statement index from input array + tag: string; // Short stmt tag + start: string; // Start timestamp, encoded as RFC3339 + end: string; // End timestamp, encoded as RFC3339 + rows_affected: number; + columns?: SqlResultColumn[]; + rows?: RowType[]; + error?: Error; +}; + +export type SqlResultColumn = { + name: string; + type: string; + oid: number; +}; + +export type SqlExecutionErrorMessage = { + message: string; + code: string; + severity: string; + source: { file: string; line: number; function: "string" }; +}; + +export const SQL_API_PATH = "api/v2/sql/"; + +/** + * executeSql executes the provided SQL statements in a single transaction + * over HTTP. + * + * @param req execution request details + */ +export function executeSql( + req: SqlExecutionRequest, +): Promise> { + return fetchDataJSON, SqlExecutionRequest>( + SQL_API_PATH, + req, + ); +} diff --git a/pkg/ui/workspaces/cluster-ui/src/index.ts b/pkg/ui/workspaces/cluster-ui/src/index.ts index a20e0fb2f0c1..5aab93002bd7 100644 --- a/pkg/ui/workspaces/cluster-ui/src/index.ts +++ b/pkg/ui/workspaces/cluster-ui/src/index.ts @@ -26,6 +26,7 @@ export * from "./empty"; export * from "./filter"; export * from "./highlightedText"; export * from "./indexDetailsPage"; +export * from "./insights"; export * from "./jobs"; export * from "./loading"; export * from "./modal"; diff --git a/pkg/ui/workspaces/cluster-ui/src/insights/index.ts b/pkg/ui/workspaces/cluster-ui/src/insights/index.ts new file mode 100644 index 000000000000..356dcc6dd9b4 --- /dev/null +++ b/pkg/ui/workspaces/cluster-ui/src/insights/index.ts @@ -0,0 +1,13 @@ +// Copyright 2022 The Cockroach Authors. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0, included in the file +// licenses/APL.txt. + +export * from "./sqlInsights"; +export * from "./utils"; +export * from "./types"; diff --git a/pkg/ui/workspaces/cluster-ui/src/insights/sqlInsights/index.ts b/pkg/ui/workspaces/cluster-ui/src/insights/sqlInsights/index.ts new file mode 100644 index 000000000000..1166548c5e25 --- /dev/null +++ b/pkg/ui/workspaces/cluster-ui/src/insights/sqlInsights/index.ts @@ -0,0 +1,12 @@ +// Copyright 2022 The Cockroach Authors. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0, included in the file +// licenses/APL.txt. + +export * from "./transactionInsights"; +export * from "./sqlInsightsOverview"; diff --git a/pkg/ui/workspaces/cluster-ui/src/insights/sqlInsights/sqlInsightsOverview.tsx b/pkg/ui/workspaces/cluster-ui/src/insights/sqlInsights/sqlInsightsOverview.tsx new file mode 100644 index 000000000000..79a206a33d5b --- /dev/null +++ b/pkg/ui/workspaces/cluster-ui/src/insights/sqlInsights/sqlInsightsOverview.tsx @@ -0,0 +1,44 @@ +// Copyright 2022 The Cockroach Authors. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0, included in the file +// licenses/APL.txt. + +import React from "react"; +import { Option } from "src/selectWithDescription/selectWithDescription"; +import { SQLActivityRootControls } from "src/sqlActivityRootControls/sqlActivityRootControls"; +import { TransactionInsightsView, TransactionInsightsViewProps } from "."; +import { transactionContention } from "src/util"; +import { Anchor } from "src/anchor"; +import { InsightExecEnum } from "../types"; + +export type SqlInsightsOverviewProps = { + transactionInsightsProps: TransactionInsightsViewProps; +}; + +export const SqlInsightsOverview = ({ + transactionInsightsProps, +}: SqlInsightsOverviewProps): React.ReactElement => { + const sqlInsightsOptions: Option[] = [ + { + value: InsightExecEnum.TRANSACTION, + label: "Transactions", + description: ( + + Transaction Insights provide a more detailed look into slow + transaction execution.{" "} + + Learn more about transaction contention + + + ), + component: , + }, + ]; + + return ; +}; diff --git a/pkg/ui/workspaces/cluster-ui/src/insights/sqlInsights/sqlInsightsTable/emptyInsightsTablePlaceholder.tsx b/pkg/ui/workspaces/cluster-ui/src/insights/sqlInsights/sqlInsightsTable/emptyInsightsTablePlaceholder.tsx new file mode 100644 index 000000000000..d546d7b95763 --- /dev/null +++ b/pkg/ui/workspaces/cluster-ui/src/insights/sqlInsights/sqlInsightsTable/emptyInsightsTablePlaceholder.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 React from "react"; +import { EmptyTable, EmptyTableProps } from "src/empty"; +import { Anchor } from "src/anchor"; +import { transactionContention } from "src/util"; +import magnifyingGlassImg from "src/assets/emptyState/magnifying-glass.svg"; +import emptyTableResultsImg from "src/assets/emptyState/empty-table-results.svg"; + +const footer = ( + + Learn more about transaction contention. + +); + +const emptySearchResults = { + title: "No insight events match your search.", + icon: magnifyingGlassImg, + footer, +}; + +export const EmptyInsightsTablePlaceholder: React.FC<{ + isEmptySearchResults: boolean; +}> = isEmptySearchResults => { + const emptyPlaceholderProps: EmptyTableProps = isEmptySearchResults + ? emptySearchResults + : { + title: "No insight events since this page was last refreshed.", + icon: emptyTableResultsImg, + message: "Insight events are cleared every hour.", + footer, + }; + + return ; +}; diff --git a/pkg/ui/workspaces/cluster-ui/src/insights/sqlInsights/sqlInsightsTable/index.ts b/pkg/ui/workspaces/cluster-ui/src/insights/sqlInsights/sqlInsightsTable/index.ts new file mode 100644 index 000000000000..04c5c23265a8 --- /dev/null +++ b/pkg/ui/workspaces/cluster-ui/src/insights/sqlInsights/sqlInsightsTable/index.ts @@ -0,0 +1,13 @@ +// Copyright 2022 The Cockroach Authors. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0, included in the file +// licenses/APL.txt. + +export * from "./insightCell"; +export * from "./emptyInsightsTablePlaceholder"; +export * from "./insightsColumns"; diff --git a/pkg/ui/workspaces/cluster-ui/src/insights/sqlInsights/sqlInsightsTable/insightCell.tsx b/pkg/ui/workspaces/cluster-ui/src/insights/sqlInsights/sqlInsightsTable/insightCell.tsx new file mode 100644 index 000000000000..c759e3b742ee --- /dev/null +++ b/pkg/ui/workspaces/cluster-ui/src/insights/sqlInsights/sqlInsightsTable/insightCell.tsx @@ -0,0 +1,38 @@ +// Copyright 2022 The Cockroach Authors. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0, included in the file +// licenses/APL.txt. + +import React from "react"; +import classNames from "classnames/bind"; + +import { Tooltip } from "@cockroachlabs/ui-components"; +import { Insight } from "../../types"; +import styles from "./insightTable.module.scss"; + +const cx = classNames.bind(styles); + +function mapInsightTypesToStatus(insight: Insight): string { + switch (insight.label) { + case "High Wait Time": + return "warning"; + default: + return "info"; + } +} + +export function InsightCell(insight: Insight) { + const status = mapInsightTypesToStatus(insight); + return ( + + + {insight.label} + + + ); +} diff --git a/pkg/ui/workspaces/cluster-ui/src/insights/sqlInsights/sqlInsightsTable/insightTable.module.scss b/pkg/ui/workspaces/cluster-ui/src/insights/sqlInsights/sqlInsightsTable/insightTable.module.scss new file mode 100644 index 000000000000..9a5bbf565e1b --- /dev/null +++ b/pkg/ui/workspaces/cluster-ui/src/insights/sqlInsights/sqlInsightsTable/insightTable.module.scss @@ -0,0 +1,27 @@ +// 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 "src/core/index.module"; + +.insight-type { + display: flex; + font-weight: $font-weight--bold; + border-bottom: 1px dashed $colors--neutral-5; + &--warning { + color: $colors--functional-orange-4; + } + &--info { + color: $colors--info-4; + } +} + +.insight-table { + display:flex; +} diff --git a/pkg/ui/workspaces/cluster-ui/src/insights/sqlInsights/sqlInsightsTable/insightsColumns.tsx b/pkg/ui/workspaces/cluster-ui/src/insights/sqlInsights/sqlInsightsTable/insightsColumns.tsx new file mode 100644 index 000000000000..2e2a358be196 --- /dev/null +++ b/pkg/ui/workspaces/cluster-ui/src/insights/sqlInsights/sqlInsightsTable/insightsColumns.tsx @@ -0,0 +1,123 @@ +// Copyright 2022 The Cockroach Authors. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0, included in the file +// licenses/APL.txt. + +import React from "react"; +import { Tooltip } from "@cockroachlabs/ui-components"; +import { InsightExecEnum } from "../../types"; + +export const insightsColumnLabels = { + executionID: "Execution ID", + query: "Execution", + insights: "Insights", + startTime: "Start Time (UTC)", + elapsedTime: "Elapsed Time (ms)", + applicationName: "Application", +}; + +export type InsightsTableColumnKeys = keyof typeof insightsColumnLabels; + +type InsightsTableTitleType = { + [key in InsightsTableColumnKeys]: (execType: InsightExecEnum) => JSX.Element; +}; + +export function getLabel( + key: InsightsTableColumnKeys, + execType?: string, +): string { + switch (execType) { + case InsightExecEnum.TRANSACTION: + return "Transaction " + insightsColumnLabels[key]; + case InsightExecEnum.STATEMENT: + return "Statement " + insightsColumnLabels[key]; + default: + return insightsColumnLabels[key]; + } +} + +export const insightsTableTitles: InsightsTableTitleType = { + executionID: (execType: InsightExecEnum) => { + return ( + + The {execType} execution ID. Click the {execType} execution ID to + see more details. +

+ } + > + {getLabel("executionID", execType)} + + ); + }, + query: (execType: InsightExecEnum) => { + let tooltipText = `The ${execType} query.`; + if (execType == InsightExecEnum.TRANSACTION) { + tooltipText = "The last query execution attempted in the transaction."; + } + return ( + + {getLabel("query", execType)} + + ); + }, + insights: (execType: InsightExecEnum) => { + return ( + + The category of insight identified as the cause of a slow {execType}{" "} + execution. +

+ } + > + {getLabel("insights")} +
+ ); + }, + startTime: (execType: InsightExecEnum) => { + return ( + The timestamp at which the {execType} started.

} + > + {getLabel("startTime")} +
+ ); + }, + elapsedTime: (execType: InsightExecEnum) => { + return ( + The time elapsed since the {execType} started execution.

+ } + > + {getLabel("elapsedTime")} +
+ ); + }, + applicationName: (execType: InsightExecEnum) => { + return ( + The name of the application that ran the {execType}.

} + > + {getLabel("applicationName")} +
+ ); + }, +}; diff --git a/pkg/ui/workspaces/cluster-ui/src/insights/sqlInsights/transactionInsights/index.ts b/pkg/ui/workspaces/cluster-ui/src/insights/sqlInsights/transactionInsights/index.ts new file mode 100644 index 000000000000..d9ce43cf7082 --- /dev/null +++ b/pkg/ui/workspaces/cluster-ui/src/insights/sqlInsights/transactionInsights/index.ts @@ -0,0 +1,12 @@ +// Copyright 2022 The Cockroach Authors. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0, included in the file +// licenses/APL.txt. + +export * from "./transactionInsightsView"; +export * from "./transactionInsightsTable"; diff --git a/pkg/ui/workspaces/cluster-ui/src/insights/sqlInsights/transactionInsights/transactionInsightsTable.tsx b/pkg/ui/workspaces/cluster-ui/src/insights/sqlInsights/transactionInsights/transactionInsightsTable.tsx new file mode 100644 index 000000000000..5a55a0b346a9 --- /dev/null +++ b/pkg/ui/workspaces/cluster-ui/src/insights/sqlInsights/transactionInsights/transactionInsightsTable.tsx @@ -0,0 +1,94 @@ +// Copyright 2022 The Cockroach Authors. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0, included in the file +// licenses/APL.txt. + +import React from "react"; +import { + SortedTable, + ISortedTablePagination, + ColumnDescriptor, + SortSetting, +} from "src/sortedtable"; +import { DATE_FORMAT } from "src/util"; +import { InsightExecEnum, TransactionInsight } from "../../types"; + +import { Link } from "react-router-dom"; +import { InsightCell, insightsTableTitles } from "../sqlInsightsTable"; + +interface TransactionInsightsTable { + data: TransactionInsight[]; + sortSetting: SortSetting; + onChangeSortSetting: (ss: SortSetting) => void; + pagination: ISortedTablePagination; + renderNoResult?: React.ReactNode; +} + +export function makeTransactionInsightsColumns(): ColumnDescriptor[] { + const execType = InsightExecEnum.TRANSACTION; + return [ + { + name: "executionID", + title: insightsTableTitles.executionID(execType), + cell: (item: TransactionInsight) => ( + + {String(item.executionID)} + + ), + sort: (item: TransactionInsight) => String(item.executionID), + alwaysShow: true, + }, + { + name: "query", + title: insightsTableTitles.query(execType), + cell: (item: TransactionInsight) => item.query, + sort: (item: TransactionInsight) => item.query, + alwaysShow: true, + }, + { + name: "insights", + title: insightsTableTitles.insights(execType), + cell: (item: TransactionInsight) => + item.insights ? item.insights.map(insight => InsightCell(insight)) : "", + sort: (item: TransactionInsight) => + item.insights + ? item.insights.map(insight => insight.label).toString() + : "", + }, + { + name: "startTime", + title: insightsTableTitles.startTime(execType), + cell: (item: TransactionInsight) => item.startTime.format(DATE_FORMAT), + sort: (item: TransactionInsight) => item.startTime.unix(), + }, + { + name: "elapsedTime", + title: insightsTableTitles.elapsedTime(execType), + cell: (item: TransactionInsight) => `${item.elapsedTime} ms`, + sort: (item: TransactionInsight) => item.elapsedTime, + }, + { + name: "applicationName", + title: insightsTableTitles.applicationName(execType), + cell: (item: TransactionInsight) => item.application, + sort: (item: TransactionInsight) => item.application, + }, + ]; +} + +export const TransactionInsightsTable: React.FC< + TransactionInsightsTable +> = props => { + const columns = makeTransactionInsightsColumns(); + + return ( + + ); +}; + +TransactionInsightsTable.defaultProps = {}; diff --git a/pkg/ui/workspaces/cluster-ui/src/insights/sqlInsights/transactionInsights/transactionInsightsView.tsx b/pkg/ui/workspaces/cluster-ui/src/insights/sqlInsights/transactionInsights/transactionInsightsView.tsx new file mode 100644 index 000000000000..84bba8bf7839 --- /dev/null +++ b/pkg/ui/workspaces/cluster-ui/src/insights/sqlInsights/transactionInsights/transactionInsightsView.tsx @@ -0,0 +1,236 @@ +// Copyright 2022 The Cockroach Authors. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0, included in the file +// licenses/APL.txt. + +import React, { useEffect, useState } from "react"; +import classNames from "classnames/bind"; +import { useHistory } from "react-router-dom"; +import { + ISortedTablePagination, + SortSetting, +} from "src/sortedtable/sortedtable"; +import { Loading } from "src/loading/loading"; +import { PageConfig, PageConfigItem } from "src/pageConfig/pageConfig"; +import { Search } from "src/search/search"; +import { + filterTransactionInsights, + getAppsFromTransactionInsights, + InsightEventFilters, + getInsightsFromState, +} from "../../utils"; +import SQLActivityError from "src/sqlActivity/errorComponent"; +import { + calculateActiveFilters, + Filter, + getFullFiltersAsStringRecord, +} from "src/queryFilter/filter"; + +import { Pagination } from "src/pagination"; +import { queryByName, syncHistory } from "src/util/query"; +import { getTableSortFromURL } from "src/sortedtable/getTableSortFromURL"; +import { getInsightEventFiltersFromURL } from "src/queryFilter/utils"; +import { TransactionInsightsResponse } from "src/api/insightsApi"; + +import styles from "src/statementsPage/statementsPage.module.scss"; +import { TransactionInsightsTable } from "./transactionInsightsTable"; +import { EmptyInsightsTablePlaceholder } from "../sqlInsightsTable"; + +import sortableTableStyles from "src/sortedtable/sortedtable.module.scss"; +const cx = classNames.bind(styles); +const sortableTableCx = classNames.bind(sortableTableStyles); + +export type TransactionInsightsViewStateProps = { + transactions: TransactionInsightsResponse; + transactionsError: Error | null; + filters: InsightEventFilters; + sortSetting: SortSetting; + internalAppNamePrefix: string; +}; + +export type TransactionInsightsViewDispatchProps = { + onFiltersChange: (filters: InsightEventFilters) => void; + onSortChange: (ss: SortSetting) => void; + refreshTransactionInsights: () => void; +}; + +export type TransactionInsightsViewProps = TransactionInsightsViewStateProps & + TransactionInsightsViewDispatchProps; + +const INSIGHT_TXN_SEARCH_PARAM = "q"; + +export const TransactionInsightsView: React.FC< + TransactionInsightsViewProps +> = ({ + sortSetting, + transactions, + transactionsError, + filters, + internalAppNamePrefix, + refreshTransactionInsights, + onFiltersChange, + onSortChange, +}: TransactionInsightsViewProps) => { + const [pagination, setPagination] = useState({ + current: 1, + pageSize: 20, + }); + const history = useHistory(); + const [search, setSearch] = useState( + queryByName(history.location, INSIGHT_TXN_SEARCH_PARAM), + ); + + useEffect(() => { + // Refresh every 10 seconds. + refreshTransactionInsights(); + const interval = setInterval(refreshTransactionInsights, 10 * 1000); + return () => { + clearInterval(interval); + }; + }, [refreshTransactionInsights]); + + useEffect(() => { + // We use this effect to sync settings defined on the URL (sort, filters), + // with the redux store. The only time we do this is when the user navigates + // to the page directly via the URL and specifies settings in the query string. + // Note that the desired behaviour is currently that the user is unable to + // clear filters via the URL, and must do so with page controls. + const sortSettingURL = getTableSortFromURL(history.location); + const filtersFromURL = getInsightEventFiltersFromURL(history.location); + + if (sortSettingURL) { + onSortChange(sortSettingURL); + } + if (filtersFromURL) { + onFiltersChange(filtersFromURL); + } + }, [history, onSortChange, onFiltersChange]); + + useEffect(() => { + // This effect runs when the filters or sort settings received from + // redux changes and syncs the URL params with redux. + syncHistory( + { + ascending: sortSetting.ascending.toString(), + columnTitle: sortSetting.columnTitle, + ...getFullFiltersAsStringRecord(filters), + [INSIGHT_TXN_SEARCH_PARAM]: search, + }, + history, + ); + }, [ + history, + filters, + sortSetting.ascending, + sortSetting.columnTitle, + search, + ]); + + const onChangePage = (current: number): void => { + setPagination({ + current: current, + pageSize: 20, + }); + }; + + const resetPagination = () => { + setPagination({ + current: 1, + pageSize: 20, + }); + }; + + const onChangeSortSetting = (ss: SortSetting): void => { + onSortChange(ss); + resetPagination(); + }; + + const onSubmitSearch = (newSearch: string) => { + if (newSearch === search) return; + setSearch(newSearch); + resetPagination(); + }; + + const onSubmitFilters = (selectedFilters: InsightEventFilters) => { + onFiltersChange(selectedFilters); + resetPagination(); + }; + + const clearSearch = () => onSubmitSearch(""); + + const transactionInsights = getInsightsFromState(transactions); + + const apps = getAppsFromTransactionInsights( + transactionInsights, + internalAppNamePrefix, + ); + const countActiveFilters = calculateActiveFilters(filters); + const filteredTransactions = filterTransactionInsights( + transactionInsights, + filters, + internalAppNamePrefix, + search, + ); + + return ( +
+ + + + + + + + +
+ + SQLActivityError({ + statsType: "transactions", + }) + } + > +
+ 0 && transactionInsights?.length > 0 + } + /> + } + pagination={pagination} + /> +
+ +
+
+
+ ); +}; diff --git a/pkg/ui/workspaces/cluster-ui/src/insights/types.ts b/pkg/ui/workspaces/cluster-ui/src/insights/types.ts new file mode 100644 index 000000000000..059d4a52b175 --- /dev/null +++ b/pkg/ui/workspaces/cluster-ui/src/insights/types.ts @@ -0,0 +1,61 @@ +// 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 { Moment } from "moment"; +import { TransactionInsightState } from "../api"; + +type InsightEvent = { + executionID: string; + query: string; + insights: Insight[]; + startTime: Moment; + elapsedTime: number; + application: string; + execType: InsightExecEnum; +}; + +export type Insight = { + label: string; + description: string; +}; + +export enum InsightExecEnum { + TRANSACTION = "transaction", + STATEMENT = "statement", +} + +enum InsightEnum { + HIGH_WAIT_TIME = "High Wait Time", +} + +export type TransactionInsight = InsightEvent; + +const HIGH_WAIT_CONTENTION_THRESHOLD = 2; + +const isHighWait = ( + insightState: TransactionInsightState, + threshold: number = HIGH_WAIT_CONTENTION_THRESHOLD, +): boolean => { + return insightState.elapsedTime > threshold; +}; + +const highWaitTimeInsight = ( + execType: InsightExecEnum = InsightExecEnum.TRANSACTION, + minWait: number = HIGH_WAIT_CONTENTION_THRESHOLD, +): Insight => ({ + label: InsightEnum.HIGH_WAIT_TIME, + description: + `This ${execType} has been waiting for more than ${minWait}ms on other ${execType}s to execute. ` + + `Click the ${execType} execution ID to see more details.`, +}); + +export const InsightTypes = [ + { name: "highWaitTime", insight: highWaitTimeInsight, check: isHighWait }, +]; diff --git a/pkg/ui/workspaces/cluster-ui/src/insights/utils.ts b/pkg/ui/workspaces/cluster-ui/src/insights/utils.ts new file mode 100644 index 000000000000..ffec0d41791b --- /dev/null +++ b/pkg/ui/workspaces/cluster-ui/src/insights/utils.ts @@ -0,0 +1,130 @@ +// Copyright 2022 The Cockroach Authors. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0, included in the file +// licenses/APL.txt. + +import { unset } from "src/util"; +import { ActiveStatementFilters } from "src/activeExecutions"; +import { + TransactionInsightsResponse, + TransactionInsightState, +} from "src/api/insightsApi"; +import { + Insight, + InsightExecEnum, + InsightTypes, + TransactionInsight, +} from "./types"; + +export type InsightEventFilters = Omit< + ActiveStatementFilters, + "username" | "sessionStatus" +>; + +export const getInsights = ( + insightState: TransactionInsightState, +): Insight[] => { + const insights: Insight[] = []; + InsightTypes.forEach(insight => { + if (insight.check(insightState)) { + insights.push(insight.insight()); + } + }); + console.log(insights); + return insights; +}; + +export function getInsightsFromState( + transactionInsightsResponse: TransactionInsightsResponse, +): TransactionInsight[] { + const transactionInsights: TransactionInsight[] = []; + if (!transactionInsightsResponse || transactionInsightsResponse?.length < 0) { + return transactionInsights; + } + + transactionInsightsResponse.forEach(txn => { + const insightsForTxn = getInsights(txn); + if (insightsForTxn.length < 1) { + console.log("no insights for txn"); + return; + } else { + transactionInsights.push({ + executionID: txn.executionID, + query: txn.query, + insights: insightsForTxn, + startTime: txn.startTime, + elapsedTime: txn.elapsedTime, + application: txn.application, + execType: InsightExecEnum.TRANSACTION, + }); + } + }); + + return transactionInsights; +} + +export const filterTransactionInsights = ( + transactions: TransactionInsight[] | null, + filters: InsightEventFilters, + internalAppNamePrefix: string, + search?: string, +): TransactionInsight[] => { + if (transactions == null) return []; + + let filteredTransactions = transactions; + + const isInternal = (txn: TransactionInsight) => + txn.application.startsWith(internalAppNamePrefix); + if (filters.app) { + filteredTransactions = filteredTransactions.filter( + (txn: TransactionInsight) => { + const apps = filters.app.toString().split(","); + let showInternal = false; + if (apps.includes(internalAppNamePrefix)) { + showInternal = true; + } + if (apps.includes(unset)) { + apps.push(""); + } + + return ( + (showInternal && isInternal(txn)) || apps.includes(txn.application) + ); + }, + ); + } else { + filteredTransactions = filteredTransactions.filter(txn => !isInternal(txn)); + } + if (search) { + filteredTransactions = filteredTransactions.filter( + txn => + !search || + txn.executionID?.includes(search) || + txn.query?.includes(search), + ); + } + return filteredTransactions; +}; + +export function getAppsFromTransactionInsights( + transactions: TransactionInsight[] | null, + internalAppNamePrefix: string, +): string[] { + if (transactions == null) return []; + + const uniqueAppNames = new Set( + transactions.map(t => { + if (t.application.startsWith(internalAppNamePrefix)) { + return internalAppNamePrefix; + } + return t.application ? t.application : unset; + }), + ); + + return Array.from(uniqueAppNames).sort(); +} diff --git a/pkg/ui/workspaces/cluster-ui/src/queryFilter/utils.ts b/pkg/ui/workspaces/cluster-ui/src/queryFilter/utils.ts index ede58be7be88..ff67ca7704c0 100644 --- a/pkg/ui/workspaces/cluster-ui/src/queryFilter/utils.ts +++ b/pkg/ui/workspaces/cluster-ui/src/queryFilter/utils.ts @@ -14,6 +14,7 @@ import { ActiveStatementFilters, ActiveTransactionFilters, } from "src/activeExecutions/types"; +import { InsightEventFilters } from "../insights"; // This function returns a Filters object populated with values from the URL, or null // if there were no filters set. @@ -66,3 +67,19 @@ export function getActiveTransactionFiltersFromURL( return appFilters; } + +export function getInsightEventFiltersFromURL( + location: Location, +): Partial | null { + const filters = getFiltersFromURL(location); + if (!filters) return null; + + const appFilters = { + app: filters.app, + }; + + // If every entry is null, there were no active filters. Return null. + if (Object.values(appFilters).every(val => !val)) return null; + + return appFilters; +} diff --git a/pkg/ui/workspaces/cluster-ui/src/store/insights/index.ts b/pkg/ui/workspaces/cluster-ui/src/store/insights/index.ts new file mode 100644 index 000000000000..1dc057ef784b --- /dev/null +++ b/pkg/ui/workspaces/cluster-ui/src/store/insights/index.ts @@ -0,0 +1,13 @@ +// Copyright 2021 The Cockroach Authors. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0, included in the file +// licenses/APL.txt. + +export * from "./insights.reducer"; +export * from "./insights.sagas"; +export * from "./insights.selectors"; diff --git a/pkg/ui/workspaces/cluster-ui/src/store/insights/insights.reducer.ts b/pkg/ui/workspaces/cluster-ui/src/store/insights/insights.reducer.ts new file mode 100644 index 000000000000..bba8b0960906 --- /dev/null +++ b/pkg/ui/workspaces/cluster-ui/src/store/insights/insights.reducer.ts @@ -0,0 +1,53 @@ +// Copyright 2022 The Cockroach Authors. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0, included in the file +// licenses/APL.txt. + +import { createSlice, PayloadAction } from "@reduxjs/toolkit"; +import { DOMAIN_NAME, noopReducer } from "../utils"; +import moment, { Moment } from "moment"; +import { TransactionInsightsResponse } from "src/api/insightsApi"; + +export type InsightsState = { + data: TransactionInsightsResponse | null; + lastUpdated: Moment | null; + lastError: Error; + valid: boolean; +}; + +const initialState: InsightsState = { + data: null, + lastUpdated: null, + lastError: null, + valid: true, +}; + +const insightsSlice = createSlice({ + name: `${DOMAIN_NAME}/insightsSlice`, + initialState, + reducers: { + received: (state, action: PayloadAction) => { + state.data = action.payload; + state.valid = true; + state.lastError = null; + state.lastUpdated = moment.utc(); + }, + failed: (state, action: PayloadAction) => { + state.valid = false; + state.lastError = action.payload; + }, + invalidated: state => { + state.valid = false; + }, + // Define actions that don't change state + refresh: noopReducer, + request: noopReducer, + }, +}); + +export const { reducer, actions } = insightsSlice; diff --git a/pkg/ui/workspaces/cluster-ui/src/store/insights/insights.sagas.ts b/pkg/ui/workspaces/cluster-ui/src/store/insights/insights.sagas.ts new file mode 100644 index 000000000000..fdff160f2f88 --- /dev/null +++ b/pkg/ui/workspaces/cluster-ui/src/store/insights/insights.sagas.ts @@ -0,0 +1,47 @@ +// Copyright 2021 The Cockroach Authors. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0, included in the file +// licenses/APL.txt. + +import { all, call, delay, put, takeLatest } from "redux-saga/effects"; + +import { actions } from "./insights.reducer"; +import { getTransactionInsightState } from "src/api/insightsApi"; +import { throttleWithReset } from "../utils"; +import { rootActions } from "../reducers"; + +export function* refreshInsightsSaga() { + yield put(actions.request()); +} + +export function* requestInsightsSaga(): any { + try { + const result = yield call(getTransactionInsightState); + yield put(actions.received(result)); + } catch (e) { + yield put(actions.failed(e)); + } +} + +export function* receivedInsightsSaga(delayMs: number) { + yield delay(delayMs); + yield put(actions.invalidated()); +} + +export function* insightsSaga(cacheInvalidationPeriod: number = 10 * 1000) { + yield all([ + throttleWithReset( + cacheInvalidationPeriod, + actions.refresh, + [actions.invalidated, rootActions.resetState], + refreshInsightsSaga, + ), + takeLatest(actions.request, requestInsightsSaga), + takeLatest(actions.received, receivedInsightsSaga, cacheInvalidationPeriod), + ]); +} diff --git a/pkg/ui/workspaces/cluster-ui/src/store/insights/insights.selectors.ts b/pkg/ui/workspaces/cluster-ui/src/store/insights/insights.selectors.ts new file mode 100644 index 000000000000..de77aad385b0 --- /dev/null +++ b/pkg/ui/workspaces/cluster-ui/src/store/insights/insights.selectors.ts @@ -0,0 +1,17 @@ +// Copyright 2020 The Cockroach Authors. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0, included in the file +// licenses/APL.txt. + +import { createSelector } from "reselect"; +import { adminUISelector } from "../utils/selectors"; + +export const selectInsights = createSelector(adminUISelector, adminUiState => { + if (!adminUiState.insights) return []; + return adminUiState.insights.data; +}); 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 f25232c9f1ae..6a772fe02592 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 @@ -32,11 +32,13 @@ export type LocalStorageState = { "sortSetting/TransactionsPage": SortSetting; "sortSetting/SessionsPage": SortSetting; "sortSetting/JobsPage": SortSetting; + "sortSetting/InsightsPage": SortSetting; "filters/ActiveStatementsPage": Filters; "filters/ActiveTransactionsPage": Filters; "filters/StatementsPage": Filters; "filters/TransactionsPage": Filters; "filters/SessionsPage": Filters; + "filters/InsightsPage": Filters; "search/StatementsPage": string; "search/TransactionsPage": string; "typeSetting/JobsPage": number; @@ -63,6 +65,10 @@ const defaultFiltersActiveExecutions = { app: defaultFilters.app, }; +const defaultFiltersInsights = { + app: defaultFilters.app, +}; + const defaultSessionsSortSetting: SortSetting = { ascending: false, columnTitle: "statementAge", @@ -120,6 +126,9 @@ const initialState: LocalStorageState = { "sortSetting/SessionsPage": JSON.parse(localStorage.getItem("sortSetting/SessionsPage")) || defaultSessionsSortSetting, + "sortSetting/InsightsPage": + JSON.parse(localStorage.getItem("sortSetting/InsightsPage")) || + defaultSortSettingActiveExecutions, "filters/ActiveStatementsPage": JSON.parse(localStorage.getItem("filters/ActiveStatementsPage")) || defaultFiltersActiveExecutions, @@ -134,6 +143,9 @@ const initialState: LocalStorageState = { defaultFilters, "filters/SessionsPage": JSON.parse(localStorage.getItem("filters/SessionsPage")) || defaultFilters, + "filters/InsightsPage": + JSON.parse(localStorage.getItem("filters/InsightsPage")) || + defaultFiltersInsights, "search/StatementsPage": JSON.parse(localStorage.getItem("search/StatementsPage")) || null, "search/TransactionsPage": diff --git a/pkg/ui/workspaces/cluster-ui/src/store/reducers.ts b/pkg/ui/workspaces/cluster-ui/src/store/reducers.ts index a6abfdd5f8f3..35a43bca6069 100644 --- a/pkg/ui/workspaces/cluster-ui/src/store/reducers.ts +++ b/pkg/ui/workspaces/cluster-ui/src/store/reducers.ts @@ -35,6 +35,7 @@ import { } from "./indexStats/indexStats.reducer"; import { JobsState, reducer as jobs } from "./jobs"; import { JobState, reducer as job } from "./jobDetails"; +import { InsightsState, reducer as insights } from "./insights"; export type AdminUiState = { statementDiagnostics: StatementDiagnosticsState; @@ -49,6 +50,7 @@ export type AdminUiState = { indexStats: IndexStatsReducerState; jobs: JobsState; job: JobState; + insights: InsightsState; }; export type AppState = { @@ -61,6 +63,7 @@ export const reducers = combineReducers({ nodes, liveness, sessions, + insights, terminateQuery, uiConfig, sqlStats, diff --git a/pkg/ui/workspaces/cluster-ui/src/store/sagas.ts b/pkg/ui/workspaces/cluster-ui/src/store/sagas.ts index d1cc40500b46..ef002259c469 100644 --- a/pkg/ui/workspaces/cluster-ui/src/store/sagas.ts +++ b/pkg/ui/workspaces/cluster-ui/src/store/sagas.ts @@ -23,6 +23,7 @@ import { notifificationsSaga } from "./notifications"; import { sqlStatsSaga } from "./sqlStats"; import { sqlDetailsStatsSaga } from "./statementDetails"; import { indexStatsSaga } from "./indexStats/indexStats.sagas"; +import { insightsSaga } from "./insights/insights.sagas"; export function* sagas(cacheInvalidationPeriod?: number): SagaIterator { yield all([ @@ -30,6 +31,7 @@ export function* sagas(cacheInvalidationPeriod?: number): SagaIterator { fork(statementsDiagnosticsSagas, cacheInvalidationPeriod), fork(nodesSaga, cacheInvalidationPeriod), fork(livenessSaga, cacheInvalidationPeriod), + fork(insightsSaga), fork(jobsSaga), fork(jobSaga), fork(sessionsSaga), diff --git a/pkg/ui/workspaces/cluster-ui/src/util/docs.ts b/pkg/ui/workspaces/cluster-ui/src/util/docs.ts index de1b035c657a..8cd8d747b989 100644 --- a/pkg/ui/workspaces/cluster-ui/src/util/docs.ts +++ b/pkg/ui/workspaces/cluster-ui/src/util/docs.ts @@ -123,3 +123,6 @@ export const transactionsTable = docsURL("ui-transactions-page.html"); export const performanceTuningRecipes = docsURLNoVersion( "performance-recipes.html#fix-slow-writes", ); +export const transactionContention = docsURL( + "transactions.html#transaction-contention", +); diff --git a/pkg/ui/workspaces/db-console/src/app.tsx b/pkg/ui/workspaces/db-console/src/app.tsx index 9d2f93800a39..004ca0dda585 100644 --- a/pkg/ui/workspaces/db-console/src/app.tsx +++ b/pkg/ui/workspaces/db-console/src/app.tsx @@ -78,6 +78,7 @@ import ActiveStatementDetails from "./views/statements/activeStatementDetailsCon import ActiveTransactionDetails from "./views/transactions/activeTransactionDetailsConnected"; import "styl/app.styl"; import { Tracez } from "src/views/tracez/tracez"; +import InsightsOverviewPage from "src/views/insights/insightsOverview"; // NOTE: If you are adding a new path to the router, and that path contains any // components that are personally identifying information, you MUST update the @@ -289,6 +290,12 @@ export const App: React.FC = (props: AppProps) => { from={`/transaction/:${aggregatedTsAttr}/:${txnFingerprintIdAttr}`} to={`/transaction/:${txnFingerprintIdAttr}`} /> + {/* Insights */} + {/* debug pages */} diff --git a/pkg/ui/workspaces/db-console/src/redux/apiReducers.ts b/pkg/ui/workspaces/db-console/src/redux/apiReducers.ts index 99e7f7b66686..3d8ed3483895 100644 --- a/pkg/ui/workspaces/db-console/src/redux/apiReducers.ts +++ b/pkg/ui/workspaces/db-console/src/redux/apiReducers.ts @@ -27,6 +27,7 @@ import { VersionList } from "src/interfaces/cockroachlabs"; import { versionCheck } from "src/util/cockroachlabsAPI"; import { INodeStatus, RollupStoreMetrics } from "src/util/proto"; import * as protos from "src/js/protos"; +import { api as clusterUiApi } from "@cockroachlabs/cluster-ui"; // The primary export of this file are the "refresh" functions of the various // reducers, which are used by many react components to request fresh data. @@ -374,6 +375,14 @@ const metricMetadataReducerObj = new CachedDataReducer( ); export const refreshMetricMetadata = metricMetadataReducerObj.refresh; +const insightsReducerObj = new CachedDataReducer( + clusterUiApi.getTransactionInsightState, + "insights", + moment.duration(10, "s"), + moment.duration(30, "s"), +); +export const refreshInsights = insightsReducerObj.refresh; + export interface APIReducersState { cluster: CachedDataReducerState; events: CachedDataReducerState; @@ -408,6 +417,7 @@ export interface APIReducersState { statementDiagnosticsReports: CachedDataReducerState; userSQLRoles: CachedDataReducerState; hotRanges: PaginatedCachedDataReducerState; + insights: CachedDataReducerState; } export const apiReducersReducer = combineReducers({ @@ -448,6 +458,7 @@ export const apiReducersReducer = combineReducers({ statementDiagnosticsReportsReducerObj.reducer, [userSQLRolesReducerObj.actionNamespace]: userSQLRolesReducerObj.reducer, [hotRangesReducerObj.actionNamespace]: hotRangesReducerObj.reducer, + [insightsReducerObj.actionNamespace]: insightsReducerObj.reducer, }); export { CachedDataReducerState, KeyedCachedDataReducerState }; diff --git a/pkg/ui/workspaces/db-console/src/views/app/components/layoutSidebar/index.tsx b/pkg/ui/workspaces/db-console/src/views/app/components/layoutSidebar/index.tsx index 644013491a37..25a62e3703af 100644 --- a/pkg/ui/workspaces/db-console/src/views/app/components/layoutSidebar/index.tsx +++ b/pkg/ui/workspaces/db-console/src/views/app/components/layoutSidebar/index.tsx @@ -44,6 +44,11 @@ export class Sidebar extends React.Component { text: "SQL Activity", activeFor: ["/sql-activity", "/session", "/transaction", "/statement"], }, + { + path: "/insights", + text: "Insights", + activeFor: ["/insights"], + }, { path: "/reports/network", text: "Network Latency", diff --git a/pkg/ui/workspaces/db-console/src/views/insights/insightsOverview.tsx b/pkg/ui/workspaces/db-console/src/views/insights/insightsOverview.tsx new file mode 100644 index 000000000000..9776f97dd207 --- /dev/null +++ b/pkg/ui/workspaces/db-console/src/views/insights/insightsOverview.tsx @@ -0,0 +1,71 @@ +// 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. + +// All changes made on this file, should also be done on the equivalent +// file on managed-service repo. + +import React, { useState } from "react"; +import Helmet from "react-helmet"; +import { Tabs } from "antd"; +import "antd/lib/tabs/style"; +import { commonStyles, util } from "@cockroachlabs/cluster-ui"; +import { RouteComponentProps } from "react-router-dom"; +import { tabAttr, viewAttr } from "src/util/constants"; +import SqlInsightsPageConnected from "src/views/insights/sqlInsightsOverview"; + +const { TabPane } = Tabs; + +export enum InsightsTabType { + SQL_INSIGHTS = "SQL Insights", +} + +export const INSIGHTS_DEFAULT_TAB: InsightsTabType = + InsightsTabType.SQL_INSIGHTS; + +const InsightsOverviewPage = (props: RouteComponentProps) => { + const currentTab = + util.queryByName(props.location, tabAttr) || InsightsTabType.SQL_INSIGHTS; + const currentView = util.queryByName(props.location, viewAttr); + const [restoreSqlViewParam, setRestoreSqlViewParam] = useState( + currentView, + ); + + const onTabChange = (tabId: string): void => { + const params = new URLSearchParams({ tab: tabId }); + if (tabId !== InsightsTabType.SQL_INSIGHTS) { + setRestoreSqlViewParam(currentView); + } else if (currentView || restoreSqlViewParam) { + params.set("view", currentView ?? restoreSqlViewParam ?? ""); + } + props.history.push({ + search: params.toString(), + }); + }; + + return ( +
+ +

Insights

+ + + + + +
+ ); +}; + +export default InsightsOverviewPage; diff --git a/pkg/ui/workspaces/db-console/src/views/insights/redux.ts b/pkg/ui/workspaces/db-console/src/views/insights/redux.ts new file mode 100644 index 000000000000..519325e46bba --- /dev/null +++ b/pkg/ui/workspaces/db-console/src/views/insights/redux.ts @@ -0,0 +1,41 @@ +// 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 { LocalSetting } from "src/redux/localsettings"; +import { AdminUIState } from "src/redux/state"; +import { createSelector } from "reselect"; +import { + InsightEventFilters, + defaultFilters, + SortSetting, +} from "@cockroachlabs/cluster-ui"; + +export const filtersLocalSetting = new LocalSetting< + AdminUIState, + InsightEventFilters +>("filters/InsightsPage", (state: AdminUIState) => state.localSettings, { + app: defaultFilters.app, +}); + +export const sortSettingLocalSetting = new LocalSetting< + AdminUIState, + SortSetting +>("sortSetting/InsightsPage", (state: AdminUIState) => state.localSettings, { + ascending: false, + columnTitle: "startTime", +}); + +export const selectInsights = createSelector( + (state: AdminUIState) => state.cachedData, + adminUiState => { + if (!adminUiState.insights) return []; + return adminUiState.insights.data; + }, +); diff --git a/pkg/ui/workspaces/db-console/src/views/insights/sqlInsightsOverview.tsx b/pkg/ui/workspaces/db-console/src/views/insights/sqlInsightsOverview.tsx new file mode 100644 index 000000000000..235242aa3783 --- /dev/null +++ b/pkg/ui/workspaces/db-console/src/views/insights/sqlInsightsOverview.tsx @@ -0,0 +1,68 @@ +// Copyright 2022 The Cockroach Authors. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0, included in the file +// licenses/APL.txt. + +import { connect } from "react-redux"; +import { RouteComponentProps, withRouter } from "react-router-dom"; +import { refreshInsights } from "src/redux/apiReducers"; +import { AdminUIState } from "src/redux/state"; +import { + InsightEventFilters, + SortSetting, + SqlInsightsOverviewProps, + TransactionInsightsViewStateProps, + TransactionInsightsViewDispatchProps, + SqlInsightsOverview, +} from "@cockroachlabs/cluster-ui"; +import { selectAppName } from "src/views/statements/activeStatementsSelectors"; +import { + filtersLocalSetting, + sortSettingLocalSetting, + selectInsights, +} from "src/views/insights/redux"; + +const mapStateToProps = ( + state: AdminUIState, +): TransactionInsightsViewStateProps => ({ + transactions: selectInsights(state), + transactionsError: state.cachedData?.insights.lastError, + filters: filtersLocalSetting.selector(state), + sortSetting: sortSettingLocalSetting.selector(state), + internalAppNamePrefix: selectAppName(state), +}); + +const mapDispatchToProps = { + onFiltersChange: (filters: InsightEventFilters) => + filtersLocalSetting.set(filters), + onSortChange: (ss: SortSetting) => sortSettingLocalSetting.set(ss), + refreshTransactionInsights: refreshInsights, +}; + +const SqlInsightsPageConnected = withRouter( + connect< + TransactionInsightsViewStateProps, + TransactionInsightsViewDispatchProps, + RouteComponentProps, + SqlInsightsOverviewProps + >( + mapStateToProps, + mapDispatchToProps, + ( + TransactionInsightsViewStateProps, + TransactionInsightsViewDispatchProps, + ) => ({ + transactionInsightsProps: { + ...TransactionInsightsViewStateProps, + ...TransactionInsightsViewDispatchProps, + }, + }), + )(SqlInsightsOverview), +); + +export default SqlInsightsPageConnected;