Skip to content

Commit

Permalink
ui: add insights page
Browse files Browse the repository at this point in the history
This commit adds the v1 Insights page to the DB Console, via the
cluster-ui package. The v1 Insights page only includes a
Transactions Insights overview page, populated with information
served a new "endpoint" built on top of the SQL-over-HTTP API.

Note this PR is dependent on the changes in cockroachdb#84617 and
xinhaoz/cockroach@c069738..

Release note (ui change): Added new Insights page to DB Console
  • Loading branch information
ericharmeling committed Jul 21, 2022
1 parent f650c14 commit cf53c67
Show file tree
Hide file tree
Showing 38 changed files with 1,542 additions and 10 deletions.
42 changes: 32 additions & 10 deletions pkg/server/api_v2_auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
74 changes: 74 additions & 0 deletions pkg/server/api_v2_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ package server
import (
"context"
gosql "database/sql"
"encoding/base64"
"encoding/json"
"io/ioutil"
"net/http"
Expand All @@ -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"
)
Expand Down Expand Up @@ -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))
}
})
}

}
10 changes: 10 additions & 0 deletions pkg/server/testserver_http.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions pkg/testutils/serverutils/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ go_library(
"//pkg/rpc",
"//pkg/security",
"//pkg/security/username",
"//pkg/server/serverpb",
"//pkg/server/status",
"//pkg/settings/cluster",
"//pkg/storage",
Expand Down
4 changes: 4 additions & 0 deletions pkg/testutils/serverutils/test_tenant_shim.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
44 changes: 44 additions & 0 deletions pkg/ui/workspaces/cluster-ui/src/api/fetchData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,3 +89,47 @@ export const fetchData = <P extends ProtoBuilder<P>, T extends ProtoBuilder<T>>(
})
.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<ResponseType, RequestType>(
path: string,
reqPayload?: RequestType,
): Promise<ResponseType> {
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;
});
}
1 change: 1 addition & 0 deletions pkg/ui/workspaces/cluster-ui/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,4 @@ export * from "./statementDiagnosticsApi";
export * from "./statementsApi";
export * from "./basePath";
export * from "./nodesApi";
export * from "./insightsApi";
82 changes: 82 additions & 0 deletions pkg/ui/workspaces/cluster-ui/src/api/insightsApi.ts
Original file line number Diff line number Diff line change
@@ -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<TransactionInsight, "insights">;

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<TransactionInsightsResponse> {
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<TransactionInsightResponseColumns>(request).then(result => {
if (!result.execution.txn_results[0].rows) {
// No data.
return [];
}

const contentionEvents: Record<string, TransactionInsightState> = {};
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);
});
}
Loading

0 comments on commit cf53c67

Please sign in to comment.