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 (
+
+ );
+};
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 (
+