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..1873e6eddd6e --- /dev/null +++ b/pkg/ui/workspaces/cluster-ui/src/api/insightsApi.ts @@ -0,0 +1,124 @@ +// 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, + SqlExecutionResponse, +} from "./sqlApi"; +import { InsightEvent, InsightExecEnum, InsightNameEnum } from "src/insights"; +import moment from "moment"; + +export type InsightEventState = Omit & { + insightName: string; +}; + +export type InsightEventsResponse = InsightEventState[]; + +type InsightQuery = { + name: InsightNameEnum; + query: string; + toState: ( + response: SqlExecutionResponse, + results: Record, + ) => InsightEventState[]; +}; + +export const HIGH_WAIT_CONTENTION_THRESHOLD = moment.duration( + 2, + "milliseconds", +); + +type TransactionContentionResponseColumns = { + blocking_txn_id: string; + blocking_queries: string[]; + collection_ts: string; + contention_duration: string; + app_name: string; +}; + +function transactionContentionResultsToEventState( + response: SqlExecutionResponse, + results: Record, +): InsightEventState[] { + response.execution.txn_results[0].rows.forEach(row => { + const key = row.blocking_txn_id; + if (!results[key]) { + results[key] = { + executionID: row.blocking_txn_id, + queries: row.blocking_queries, + startTime: moment(row.collection_ts), + elapsedTime: moment.duration(row.contention_duration).asMilliseconds(), + application: row.app_name, + insightName: highWaitTimeQuery.name, + execType: InsightExecEnum.TRANSACTION, + }; + } + }); + + return Object.values(results); +} + +const highWaitTimeQuery: InsightQuery = { + name: InsightNameEnum.highWaitTime, + query: `SELECT + blocking_txn_id, + blocking_queries, + collection_ts, + contention_duration, + app_name + FROM + ( + SELECT + blocking_txn_id, + blocking_txn_fingerprint_id, + collection_ts, + contention_duration + FROM + crdb_internal.transaction_contention_events + WHERE + contention_duration > INTERVAL '${HIGH_WAIT_CONTENTION_THRESHOLD.toISOString()}' + ) 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`, + toState: transactionContentionResultsToEventState, +}; + +// getInsightEventState is currently hardcoded to use a single insight type and execution event type +export function getInsightEventState(): Promise { + const request: SqlExecutionRequest = { + statements: [ + { + sql: `${highWaitTimeQuery.query}`, + }, + ], + execute: true, + }; + return executeSql(request).then( + result => { + if (!result.execution.txn_results[0].rows) { + // No data. + return []; + } + + const results: Record = {}; + return highWaitTimeQuery.toState(result, results); + }, + ); +} 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..b87822f1b3f6 --- /dev/null +++ b/pkg/ui/workspaces/cluster-ui/src/insights/sqlInsights/index.ts @@ -0,0 +1,11 @@ +// 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"; diff --git a/pkg/ui/workspaces/cluster-ui/src/insights/sqlInsights/sqlInsightsTable/dropDownSelect.tsx b/pkg/ui/workspaces/cluster-ui/src/insights/sqlInsights/sqlInsightsTable/dropDownSelect.tsx new file mode 100644 index 000000000000..3f5a9e6db0fd --- /dev/null +++ b/pkg/ui/workspaces/cluster-ui/src/insights/sqlInsights/sqlInsightsTable/dropDownSelect.tsx @@ -0,0 +1,54 @@ +// 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 { useHistory, useLocation } from "react-router-dom"; +import { viewAttr, tabAttr } from "src/util"; +import { queryByName } from "src/util/query"; +import { Dropdown, DropdownOption } from "src/dropdown"; + +type SelectProps = { + label: string; + options: { value: string; label: string }[]; +}; + +export const DropDownSelect = ({ + label, + options, +}: SelectProps): React.ReactElement => { + const history = useHistory(); + const location = useLocation(); + const tab = queryByName(location, tabAttr); + + const onViewChange = (view: string): void => { + const searchParams = new URLSearchParams({ + [viewAttr]: view, + }); + if (tab) { + searchParams.set(tabAttr, tab); + } + history.push({ + search: searchParams.toString(), + }); + }; + + const dropDownOptions = (): DropdownOption[] => { + return options.map(option => ({ + name: option.label, + value: option.value, + })); + }; + + return ( + + {label} + + ); +}; 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..4447d90191fc --- /dev/null +++ b/pkg/ui/workspaces/cluster-ui/src/insights/sqlInsights/sqlInsightsTable/emptyInsightsTablePlaceholder.tsx @@ -0,0 +1,42 @@ +// 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, + 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..97b80376f8be --- /dev/null +++ b/pkg/ui/workspaces/cluster-ui/src/insights/sqlInsights/sqlInsightsTable/index.ts @@ -0,0 +1,15 @@ +// 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"; +export * from "./dropDownSelect"; +export * from "./sqlInsightsError"; 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..f4591740b90c --- /dev/null +++ b/pkg/ui/workspaces/cluster-ui/src/insights/sqlInsights/sqlInsightsTable/insightCell.tsx @@ -0,0 +1,37 @@ +// 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 "src/insights"; +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..49b46e9d5af5 --- /dev/null +++ b/pkg/ui/workspaces/cluster-ui/src/insights/sqlInsights/sqlInsightsTable/insightTable.module.scss @@ -0,0 +1,42 @@ +// 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; +} + +.action { + color: $colors--link; + display: flex; + line-height: 24px; + &:hover { + color: $colors--link; + text-decoration: underline; + } +} + +.row { + display: flex; + flex-direction: row; +} 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..119929637b07 --- /dev/null +++ b/pkg/ui/workspaces/cluster-ui/src/insights/sqlInsights/sqlInsightsTable/insightsColumns.tsx @@ -0,0 +1,122 @@ +// 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 "src/insights"; + +export const insightsColumnLabels = { + executionID: "Execution ID", + query: "Execution", + insights: "Insights", + startTime: "Start Time (UTC)", + elapsedTime: "Elapsed Time", + 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 queries attempted in the transaction."; + } + return ( + + {getLabel("query", execType)} + + ); + }, + insights: (execType: InsightExecEnum) => { + return ( + + The category of insight identified for the {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/sqlInsightsTable/sqlInsightsError.tsx b/pkg/ui/workspaces/cluster-ui/src/insights/sqlInsights/sqlInsightsTable/sqlInsightsError.tsx new file mode 100644 index 000000000000..c1b053b38a7a --- /dev/null +++ b/pkg/ui/workspaces/cluster-ui/src/insights/sqlInsights/sqlInsightsTable/sqlInsightsError.tsx @@ -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 React from "react"; +import classNames from "classnames/bind"; +import styles from "./insightTable.module.scss"; + +const cx = classNames.bind(styles); + +type SQLInsightsErrorProps = { + execType: string; +}; + +export const SQLInsightsError = ( + execType: SQLInsightsErrorProps, +): React.ReactElement => { + return ( +
+ + This page had an unexpected error while loading + {" " + execType}. + +   + { + window.location.reload(); + }} + > + Reload this page + +
+ ); +}; 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..6a98adbcf1a6 --- /dev/null +++ b/pkg/ui/workspaces/cluster-ui/src/insights/sqlInsights/transactionInsights/transactionInsightsTable.tsx @@ -0,0 +1,86 @@ +// 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, Duration } from "src/util"; +import { InsightExecEnum, InsightEvent } from "src/insights"; +import { InsightCell, insightsTableTitles } from "../sqlInsightsTable"; + +interface TransactionInsightsTable { + data: InsightEvent[]; + 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: InsightEvent) => String(item.executionID), + sort: (item: InsightEvent) => String(item.executionID), + }, + { + name: "query", + title: insightsTableTitles.query(execType), + cell: (item: InsightEvent) => + item.queries.map(query =>
{query}
), + sort: (item: InsightEvent) => item.queries.length, + }, + { + name: "insights", + title: insightsTableTitles.insights(execType), + cell: (item: InsightEvent) => + item.insights ? item.insights.map(insight => InsightCell(insight)) : "", + sort: (item: InsightEvent) => + item.insights + ? item.insights.map(insight => insight.label).toString() + : "", + }, + { + name: "startTime", + title: insightsTableTitles.startTime(execType), + cell: (item: InsightEvent) => item.startTime.format(DATE_FORMAT), + sort: (item: InsightEvent) => item.startTime.unix(), + }, + { + name: "elapsedTime", + title: insightsTableTitles.elapsedTime(execType), + cell: (item: InsightEvent) => Duration(item.elapsedTime * 1e6), + sort: (item: InsightEvent) => item.elapsedTime, + }, + { + name: "applicationName", + title: insightsTableTitles.applicationName(execType), + cell: (item: InsightEvent) => item.application, + sort: (item: InsightEvent) => 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..ddc681616c3b --- /dev/null +++ b/pkg/ui/workspaces/cluster-ui/src/insights/sqlInsights/transactionInsights/transactionInsightsView.tsx @@ -0,0 +1,261 @@ +// 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 { + calculateActiveFilters, + Filter, + getFullFiltersAsStringRecord, +} from "src/queryFilter/filter"; +import { getInsightEventFiltersFromURL } from "src/queryFilter/utils"; +import { Pagination } from "src/pagination"; +import { queryByName, syncHistory } from "src/util/query"; +import { getTableSortFromURL } from "src/sortedtable/getTableSortFromURL"; +import { TableStatistics } from "src/tableStatistics"; + +import { InsightEventsResponse } from "src/api/insightsApi"; +import { + filterTransactionInsights, + getAppsFromTransactionInsights, + InsightEventFilters, + getInsightsFromState, + InsightExecOptions, + defaultInsightFilters, +} from "src/insights"; +import { + EmptyInsightsTablePlaceholder, + DropDownSelect, + SQLInsightsError, +} from "../sqlInsightsTable"; +import { TransactionInsightsTable } from "./transactionInsightsTable"; + +import styles from "src/statementsPage/statementsPage.module.scss"; +import sortableTableStyles from "src/sortedtable/sortedtable.module.scss"; +const cx = classNames.bind(styles); +const sortableTableCx = classNames.bind(sortableTableStyles); + +export type TransactionInsightsViewStateProps = { + transactions: InsightEventsResponse; + 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: 10, + }); + 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: 10, + }); + }; + + const resetPagination = () => { + setPagination({ + current: 1, + pageSize: 10, + }); + }; + + const onChangeSortSetting = (ss: SortSetting): void => { + onSortChange(ss); + resetPagination(); + }; + + const onSubmitSearch = (newSearch: string) => { + if (newSearch === search) return; + setSearch(newSearch); + resetPagination(); + }; + + const clearSearch = () => onSubmitSearch(""); + + const onSubmitFilters = (selectedFilters: InsightEventFilters) => { + onFiltersChange(selectedFilters); + resetPagination(); + }; + + const clearFilters = () => onSubmitFilters(defaultInsightFilters); + + const transactionInsights = getInsightsFromState(transactions); + + const apps = getAppsFromTransactionInsights( + transactionInsights, + internalAppNamePrefix, + ); + const countActiveFilters = calculateActiveFilters(filters); + const filteredTransactions = filterTransactionInsights( + transactionInsights, + filters, + internalAppNamePrefix, + search, + ); + + return ( +
+ + + + + + + + + + + +
+ + SQLInsightsError({ + execType: "transaction insights", + }) + } + > +
+
+
+ +
+ 0 && filteredTransactions?.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..086d33dbf00d --- /dev/null +++ b/pkg/ui/workspaces/cluster-ui/src/insights/types.ts @@ -0,0 +1,63 @@ +// 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 { HIGH_WAIT_CONTENTION_THRESHOLD } from "../api"; + +export enum InsightNameEnum { + highWaitTime = "highWaitTime", +} + +export enum InsightExecEnum { + TRANSACTION = "transaction", + STATEMENT = "statement", +} + +export type InsightEvent = { + executionID: string; + queries: string[]; + insights: Insight[]; + startTime: Moment; + elapsedTime: number; + application: string; + execType: InsightExecEnum; +}; + +export type Insight = { + name: InsightNameEnum; + label: string; + description: string; +}; + +const highWaitTimeInsight = ( + execType: InsightExecEnum = InsightExecEnum.TRANSACTION, +): Insight => { + const threshold = HIGH_WAIT_CONTENTION_THRESHOLD.asMilliseconds(); + return { + name: InsightNameEnum.highWaitTime, + label: "High Wait Time", + description: + `This ${execType} has been waiting for more than ${threshold}ms on other ${execType}s to execute. ` + + `Click the ${execType} execution ID to see more details.`, + }; +}; + +export const InsightTypes = [highWaitTimeInsight]; + +export const InsightExecOptions = [ + { + value: InsightExecEnum.TRANSACTION.toString(), + label: "Transaction Executions", + }, + { + value: InsightExecEnum.STATEMENT.toString(), + label: "Statement Executions", + }, +]; 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..bb8642acaed4 --- /dev/null +++ b/pkg/ui/workspaces/cluster-ui/src/insights/utils.ts @@ -0,0 +1,127 @@ +// 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 { Filters, defaultFilters } from "src/queryFilter"; +import { InsightEventsResponse, InsightEventState } from "src/api/insightsApi"; +import { Insight, InsightExecEnum, InsightTypes, InsightEvent } from "./types"; + +export type InsightEventFilters = Omit< + Filters, + | "database" + | "sqlType" + | "fullScan" + | "distributed" + | "regions" + | "nodes" + | "username" + | "sessionStatus" +>; + +export const defaultInsightFilters: InsightEventFilters = { + app: defaultFilters.app, +}; + +export const getInsights = (eventState: InsightEventState): Insight[] => { + const insights: Insight[] = []; + InsightTypes.forEach(insight => { + if (insight(eventState.execType).name == eventState.insightName) { + insights.push(insight(eventState.execType)); + } + }); + return insights; +}; + +export function getInsightsFromState( + insightEventsResponse: InsightEventsResponse, +): InsightEvent[] { + const insightEvents: InsightEvent[] = []; + if (!insightEventsResponse || insightEventsResponse?.length < 0) { + return insightEvents; + } + + insightEventsResponse.forEach(e => { + const insightsForEvent = getInsights(e); + if (insightsForEvent.length < 1) { + return; + } else { + insightEvents.push({ + executionID: e.executionID, + queries: e.queries, + insights: insightsForEvent, + startTime: e.startTime, + elapsedTime: e.elapsedTime, + application: e.application, + execType: InsightExecEnum.TRANSACTION, + }); + } + }); + + return insightEvents; +} + +export const filterTransactionInsights = ( + transactions: InsightEvent[] | null, + filters: InsightEventFilters, + internalAppNamePrefix: string, + search?: string, +): InsightEvent[] => { + if (transactions == null) return []; + + let filteredTransactions = transactions; + + const isInternal = (txn: InsightEvent) => + txn.application.startsWith(internalAppNamePrefix); + if (filters.app) { + filteredTransactions = filteredTransactions.filter((txn: InsightEvent) => { + 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.queries?.find(query => query.includes(search)), + ); + } + return filteredTransactions; +}; + +export function getAppsFromTransactionInsights( + transactions: InsightEvent[] | 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/selectWithDescription/selectWithDescription.tsx b/pkg/ui/workspaces/cluster-ui/src/selectWithDescription/selectWithDescription.tsx index 6adf6dabb032..d37eeb92266a 100644 --- a/pkg/ui/workspaces/cluster-ui/src/selectWithDescription/selectWithDescription.tsx +++ b/pkg/ui/workspaces/cluster-ui/src/selectWithDescription/selectWithDescription.tsx @@ -17,6 +17,7 @@ import type { RadioChangeEvent } from "antd/lib/radio"; import { Button } from "../button"; import styles from "../statementsPage/statementTypeSelect.module.scss"; +import { Dropdown, DropdownOption } from "../dropdown"; const cx = classNames.bind(styles); @@ -24,24 +25,30 @@ export type Option = { value: string; label: string; description: React.ReactChild; - component: React.ReactElement; + component?: React.ReactElement; }; +export const enum SelectMode { + RADIO = "radio", + DROPDOWN = "dropdown", +} + type SelectProps = { options: Option[]; value: string; onChange: (value: string) => void; + selectMode?: SelectMode; + label?: string; }; export const SelectWithDescription = ({ options, value, onChange, + selectMode = SelectMode.RADIO, + label, }: SelectProps): React.ReactElement => { const [showDescription, setShowDescription] = useState(false); - const onSelectChange = (e: RadioChangeEvent) => { - onChange(e.target.value); - }; const toggleDescription = (): void => { setShowDescription(!showDescription); @@ -53,20 +60,46 @@ export const SelectWithDescription = ({ const description = getDescription(); + const renderOptions = () => { + switch (selectMode) { + case SelectMode.RADIO: { + const onSelectChange = (e: RadioChangeEvent) => { + onChange(e.target.value); + }; + return ( + + {options.map(option => ( + + {option.label} + + ))} + + ); + } + case SelectMode.DROPDOWN: { + const dropDownOptions = (): DropdownOption[] => { + return options.map(option => ({ + name: option.label, + value: option.value, + })); + }; + return ( + + {label} + + ); + } + } + }; + return (
- - {options.map(option => ( - - {option.label} - - ))} - + {renderOptions()}