diff --git a/pkg/col/coldata/bytes.go b/pkg/col/coldata/bytes.go index e92a0704b508..8463bedfe0fa 100644 --- a/pkg/col/coldata/bytes.go +++ b/pkg/col/coldata/bytes.go @@ -556,7 +556,7 @@ func (b *Bytes) elementsAsBytes(n int) []byte { var zeroInt32Slice []int32 func init() { - zeroInt32Slice = make([]int32, BatchSize()) + zeroInt32Slice = make([]int32, MaxBatchSize) } // Serialize converts b into the "arrow-like" (which is arrow-compatible) @@ -579,12 +579,12 @@ func init() { // buffer = [), len() + len(buffer)] // -// Note: it is assumed that n is not larger than BatchSize(). +// Note: it is assumed that n is not larger than MaxBatchSize. func (b *Bytes) Serialize(n int, dataScratch []byte, offsetsScratch []int32) ([]byte, []int32) { if buildutil.CrdbTestBuild { - if n > BatchSize() { + if n > MaxBatchSize { colexecerror.InternalError(errors.AssertionFailedf( - "too many bytes elements to serialize: %d vs BatchSize() of %d", n, BatchSize(), + "too many bytes elements to serialize: %d vs MaxBatchSize of %d", n, MaxBatchSize, )) } } diff --git a/pkg/ui/workspaces/cluster-ui/src/api/tracezApi.ts b/pkg/ui/workspaces/cluster-ui/src/api/tracezApi.ts index a0c9083942ad..ba202ee61843 100644 --- a/pkg/ui/workspaces/cluster-ui/src/api/tracezApi.ts +++ b/pkg/ui/workspaces/cluster-ui/src/api/tracezApi.ts @@ -10,7 +10,7 @@ import { cockroach } from "@cockroachlabs/crdb-protobuf-client"; import { fetchData } from "src/api"; - +import Long from "long"; export type ListTracingSnapshotsRequest = cockroach.server.serverpb.ListTracingSnapshotsRequest; export type ListTracingSnapshotsResponse = @@ -29,9 +29,15 @@ export type GetTracingSnapshotResponse = export type Span = cockroach.server.serverpb.ITracingSpan; export type Snapshot = cockroach.server.serverpb.ITracingSnapshot; -export type GetTraceRequest = cockroach.server.serverpb.GetTraceRequest; +export const GetTraceRequest = cockroach.server.serverpb.GetTraceRequest; export type GetTraceResponse = cockroach.server.serverpb.GetTraceResponse; +export const SetTraceRecordingTypeRequest = + cockroach.server.serverpb.SetTraceRecordingTypeRequest; +export type SetTraceRecordingTypeResponse = + cockroach.server.serverpb.SetTraceRecordingTypeResponse; +export type RecordingMode = cockroach.util.tracing.tracingpb.RecordingMode; + const API_PREFIX = "_admin/v1"; export function listTracingSnapshots( @@ -58,6 +64,8 @@ export function takeTracingSnapshot( ); } +// This is getting plugged into our redux libraries, which want calls with a +// single argument. So wrap the two arguments in a request object. export function getTracingSnapshot(req: { nodeID: string; snapshotID: number; @@ -70,14 +78,39 @@ export function getTracingSnapshot(req: { ); } -export function getTraceForSnapshot(req: { +// This is getting plugged into our redux libraries, which want calls with a +// single argument. So wrap the two arguments in a request object. +export function getRawTrace(req: { nodeID: string; - req: GetTraceRequest; + snapshotID: number; + traceID: Long; }): Promise { + const rpcReq = new GetTraceRequest({ + snapshot_id: Long.fromNumber(req.snapshotID), + trace_id: req.traceID, + }); return fetchData( cockroach.server.serverpb.GetTraceResponse, `${API_PREFIX}/traces?remote_node_id=${req.nodeID}`, cockroach.server.serverpb.GetTraceRequest, - req.req as any, + rpcReq as any, + ); +} + +export function setTraceRecordingType( + nodeID: string, + traceID: Long, + recordingMode: RecordingMode, +): Promise { + const req = new SetTraceRecordingTypeRequest({ + trace_id: traceID, + recording_mode: recordingMode, + }); + return fetchData( + cockroach.server.serverpb.SetTraceRecordingTypeResponse, + // TODO(davidh): Consider making this endpoint just POST to `/traces/{trace_ID}` + `${API_PREFIX}/settracerecordingtype?remote_node_id=${nodeID}`, + cockroach.server.serverpb.SetTraceRecordingTypeRequest, + req as any, ); } diff --git a/pkg/ui/workspaces/cluster-ui/src/tracez/snapshot.module.scss b/pkg/ui/workspaces/cluster-ui/src/tracez/snapshot.module.scss index a5d839a19aa4..dd0c17af434d 100644 --- a/pkg/ui/workspaces/cluster-ui/src/tracez/snapshot.module.scss +++ b/pkg/ui/workspaces/cluster-ui/src/tracez/snapshot.module.scss @@ -13,6 +13,7 @@ display: flex; flex-flow: column; height: 100%; + font-family: $font-family--base; } .no-results { @@ -66,6 +67,7 @@ .table-title-time { width: 20ch; + margin-right: 4px; } .tag-group { @@ -104,7 +106,66 @@ padding-top: 1px; } +.icon-gray { + fill: lightgray; + height: 10px; + width: 10px; + padding-top: 1px; +} + .section { flex: 0 0 auto; - padding: 12px 24px 140px 0px; + padding: 12px 24px 12px 0px; +} + +.span-section { + background-color: $colors--neutral-0; + padding: 12px; + margin-right: 12px; + margin-bottom: 12px; +} + +.bottom-padding { + padding-bottom: 140px; +} + +.span-snapshot-key { + font-family: $font-family--bold; +} + +.span-snapshot-key-value { + padding-bottom: 4px; +} + +.span-snapshot-column { + min-width: fit-content; +} + +.span-snapshot-columns { + display: flex; + flex-direction: row; + gap: 24px; +} + +.span-header-columns { + display: flex; + flex-direction: row; + padding-bottom: 12px; + padding-right: 12px; + align-items: center; +} + +.span-details-title { + color: $colors--neutral-7; + font-family: $font-family--semi-bold; + font-style: normal; + font-stretch: normal; + font-size: 20px; + padding-bottom: 0px; + margin-bottom: 0px; + width: 100%; +} + +.span-details-raw-trace-button { + flex-shrink: 0; } diff --git a/pkg/ui/workspaces/cluster-ui/src/tracez/snapshot/rawTraceComponent.tsx b/pkg/ui/workspaces/cluster-ui/src/tracez/snapshot/rawTraceComponent.tsx new file mode 100644 index 000000000000..1234e44c0e4e --- /dev/null +++ b/pkg/ui/workspaces/cluster-ui/src/tracez/snapshot/rawTraceComponent.tsx @@ -0,0 +1,73 @@ +// 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 { GetTraceResponse } from "src/api"; +import Long from "long"; +import { Loading } from "src/loading"; +import React, { useEffect } from "react"; +import classNames from "classnames/bind"; +import styles from "../snapshot.module.scss"; +const cx = classNames.bind(styles); + +export const RawTraceComponent: React.FC<{ + nodeID: string; + snapshotID: number; + traceID: Long; + rawTrace: GetTraceResponse; + rawTraceLoading: boolean; + rawTraceError?: Error; + refreshRawTrace: (req: { + nodeID: string; + snapshotID: number; + traceID: Long; + }) => void; +}> = props => { + const { + nodeID, + snapshotID, + traceID, + rawTrace, + rawTraceLoading, + rawTraceError, + refreshRawTrace, + } = props; + + useEffect(() => { + if (!(nodeID && snapshotID && traceID)) { + return; + } + refreshRawTrace({ + nodeID, + snapshotID, + traceID, + }); + }, [nodeID, snapshotID, traceID, refreshRawTrace]); + + return ( + { + return ( + <> +
+
{rawTrace?.serialized_recording}
+
+
+ + ); + }} + /> + ); +}; diff --git a/pkg/ui/workspaces/cluster-ui/src/tracez/snapshot/snapshotComponent.tsx b/pkg/ui/workspaces/cluster-ui/src/tracez/snapshot/snapshotComponent.tsx new file mode 100644 index 000000000000..1202c4217c49 --- /dev/null +++ b/pkg/ui/workspaces/cluster-ui/src/tracez/snapshot/snapshotComponent.tsx @@ -0,0 +1,161 @@ +// 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 { Helmet } from "react-helmet"; +import { commonStyles } from "src/common"; +import { PageConfig, PageConfigItem } from "src/pageConfig"; +import { Button, Icon } from "@cockroachlabs/ui-components"; +import { Dropdown } from "src/dropdown"; +import { Loading } from "src/loading"; +import { SpanTable } from "./spanTable"; +import React, { useMemo } from "react"; +import classNames from "classnames/bind"; +import styles from "../snapshot.module.scss"; +import { TimestampToMoment } from "src/util"; +import { SortSetting } from "src/sortedtable"; +import { + GetTracingSnapshotResponse, + ListTracingSnapshotsResponse, +} from "src/api"; +import { cockroach } from "@cockroachlabs/crdb-protobuf-client"; +import Long from "long"; +const cx = classNames.bind(styles); + +export const SnapshotComponent: React.FC<{ + sort: SortSetting; + changeSortSetting: (value: SortSetting) => void; + nodes?: cockroach.server.status.statuspb.INodeStatus[]; + nodeID: string; + onNodeSelected: (_: string) => void; + snapshots: ListTracingSnapshotsResponse; + snapshotID: number; + snapshot: GetTracingSnapshotResponse; + onSnapshotSelected: (_: number) => void; + isLoading: boolean; + error: Error; + spanDetailsURL: (_: Long) => string; + takeAndLoadSnapshot: () => void; +}> = props => { + const { + sort, + changeSortSetting, + nodes, + nodeID, + onNodeSelected, + snapshots, + snapshotID, + snapshot, + onSnapshotSelected, + isLoading, + error, + spanDetailsURL, + takeAndLoadSnapshot, + } = props; + + const snapshotsAsJson = JSON.stringify(snapshots); + + const [snapshotItems, snapshotName] = useMemo(() => { + if (!snapshots) { + return [[], ""]; + } + let selectedName = ""; + const items = snapshots.snapshots.map(snapshotInfo => { + const id = snapshotInfo.snapshot_id.toNumber(); + const time = TimestampToMoment(snapshotInfo.captured_at).format( + "MMM D, YYYY [at] HH:mm:ss", + ); + const out = { + name: "Snapshot " + id + ": " + time, + value: id, + }; + if (id === snapshotID) { + selectedName = out.name; + } + return out; + }); + return [items, selectedName]; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [snapshotsAsJson, snapshotID]); + + const [nodeItems, nodeName] = useMemo(() => { + if (!nodes) { + return [[], ""]; + } + let selectedName = ""; + const items = nodes.map(node => { + const id = node.desc.node_id.toString(); + const out = { + name: "Node " + id, + value: id, + }; + if (id === nodeID) { + selectedName = out.name; + } + return out; + }); + return [items, selectedName]; + }, [nodes, nodeID]); + + return ( +
+ +

+ Snapshots +

+
+ + + + + + + {nodeName} + + + {snapshotItems.length > 0 && ( + + + items={snapshotItems} + onChange={onSnapshotSelected} + > + {snapshotName} + + + )} + +
+
+ {snapshotID ? ( + ( + + )} + /> + ) : ( + "No snapshots found on this node." + )} +
+
+
+ ); +}; diff --git a/pkg/ui/workspaces/cluster-ui/src/tracez/snapshot/snapshotPage.spec.tsx b/pkg/ui/workspaces/cluster-ui/src/tracez/snapshot/snapshotPage.spec.tsx index 6f7e40bfe2e2..65830f866b92 100644 --- a/pkg/ui/workspaces/cluster-ui/src/tracez/snapshot/snapshotPage.spec.tsx +++ b/pkg/ui/workspaces/cluster-ui/src/tracez/snapshot/snapshotPage.spec.tsx @@ -8,7 +8,7 @@ // by the Apache License, Version 2.0, included in the file // licenses/APL.txt. -import { SnapshotPage, SnapshotPageProps } from "./snapshotPage"; +import { ROUTE_PREFIX, SnapshotPage, SnapshotPageProps } from "./snapshotPage"; import { render } from "@testing-library/react"; import React from "react"; import { MemoryRouter } from "react-router-dom"; @@ -16,52 +16,74 @@ import * as H from "history"; import { SortSetting } from "../../sortedtable"; import { cockroach } from "@cockroachlabs/crdb-protobuf-client"; -import { TakeTracingSnapshotResponse } from "src/api/tracezApi"; +import { + RecordingMode, + SetTraceRecordingTypeResponse, + TakeTracingSnapshotResponse, +} from "src/api/tracezApi"; import GetTracingSnapshotResponse = cockroach.server.serverpb.GetTracingSnapshotResponse; +import Long from "long"; +import { getByTestId } from "@testing-library/dom/types/queries"; const getMockSnapshotPageProps = (): SnapshotPageProps => { const history = H.createHashHistory(); + history.location.pathname = + ROUTE_PREFIX + "node/:nodeID/snapshot/:snapshotID/"; return { - snapshotsValid: false, - location: history.location, history, + location: history.location, match: { url: "", path: history.location.pathname, isExact: false, - params: {}, + params: { + nodeID: "1", + snapshotID: "1", + }, }, - refreshSnapshot: (_req: { nodeID: string; snapshotID: number }): void => {}, - refreshSnapshots: (_nodeID: string): void => {}, - refreshNodes: (): void => {}, - takeSnapshot(_nodeID: string): Promise { + rawTrace: undefined, + rawTraceLoading: false, + refreshNodes: () => void {}, + refreshRawTrace: (req: { + nodeID: string; + snapshotID: number; + traceID: Long; + }) => void {}, + refreshSnapshot: (req: { nodeID: string; snapshotID: number }): void => {}, + refreshSnapshots: (id: string): void => {}, + setSort: (value: SortSetting): void => {}, + setTraceRecordingType: ( + nodeID: string, + traceID: Long, + recordingMode: RecordingMode, + ): Promise => { return Promise.resolve(undefined); }, - setSort: (value: SortSetting): void => {}, - snapshotError: undefined, + snapshot: GetTracingSnapshotResponse.fromObject({ + snapshot: { + spans: [{ span_id: 1, operation: "spanny" }], + }, + }), snapshotLoading: false, - snapshots: undefined, - snapshotsError: undefined, snapshotsLoading: false, sort: undefined, - snapshot: null, + takeSnapshot: (nodeID: string): Promise => { + return Promise.resolve(undefined); + }, }; }; describe("Snapshot", () => { it("renders expected snapshot table columns", () => { const props = getMockSnapshotPageProps(); - props.match.params.snapshotID = "1"; - props.snapshot = GetTracingSnapshotResponse.fromObject({ - snapshot: { - spans: [{ span_id: 1 }], - }, - }); - const { getAllByText } = render( + const { getAllByText, getByTestId } = render( , ); + + getByTestId("snapshot-component-title"); + const expectedColumnTitles = [ "Span", "Start Time (UTC)", @@ -73,4 +95,30 @@ describe("Snapshot", () => { getAllByText(columnTitle); } }); + + it("renders span view", () => { + const props = getMockSnapshotPageProps(); + props.match.params["spanID"] = "1"; + const { getByTestId } = render( + + + , + ); + + getByTestId("span-component-title"); + }); + + it("renders raw trace view", () => { + const props = getMockSnapshotPageProps(); + props.match.path = + ROUTE_PREFIX + "node/:nodeID/snapshot/:snapshotID/span/:spanID/raw"; + props.match.params["spanID"] = "1"; + + const { getByTestId } = render( + + + , + ); + getByTestId("raw-trace-component"); + }); }); diff --git a/pkg/ui/workspaces/cluster-ui/src/tracez/snapshot/snapshotPage.tsx b/pkg/ui/workspaces/cluster-ui/src/tracez/snapshot/snapshotPage.tsx index fe8628616285..008c6abc2d8f 100644 --- a/pkg/ui/workspaces/cluster-ui/src/tracez/snapshot/snapshotPage.tsx +++ b/pkg/ui/workspaces/cluster-ui/src/tracez/snapshot/snapshotPage.tsx @@ -8,47 +8,61 @@ // by the Apache License, Version 2.0, included in the file // licenses/APL.txt. -import React, { useEffect, useMemo } from "react"; -import { Helmet } from "react-helmet"; +import React, { useCallback, useEffect } from "react"; import { RouteComponentProps } from "react-router-dom"; import { ListTracingSnapshotsResponse, GetTracingSnapshotResponse, TakeTracingSnapshotResponse, + SetTraceRecordingTypeResponse, + RecordingMode, + GetTraceResponse, } from "src/api/tracezApi"; -import { Button, Icon } from "@cockroachlabs/ui-components"; import { cockroach } from "@cockroachlabs/crdb-protobuf-client"; -import { Dropdown } from "src/dropdown"; -import { Loading } from "src/loading"; -import { PageConfig, PageConfigItem } from "src/pageConfig"; import { SortSetting } from "src/sortedtable"; import { join } from "path"; -import { - getDataFromServer, - getMatchParamByName, - TimestampToMoment, -} from "src/util"; +import { getMatchParamByName } from "src/util"; import { syncHistory } from "src/util"; -import { SpanTable } from "./spanTable"; - -import { commonStyles } from "src/common"; -import styles from "../snapshot.module.scss"; -import classNames from "classnames/bind"; +import { SnapshotComponent } from "./snapshotComponent"; +import Long from "long"; +import { SpanComponent } from "./spanComponent"; +import { RawTraceComponent } from "./rawTraceComponent"; +import { Breadcrumbs } from "src/breadcrumbs"; -const cx = classNames.bind(styles); +// This component does some manual route management and navigation. +// This is because the data model doesn't match the ideal route form. +// The data model is largely monolithic - one downloads a whole snapshot at a +// time. But often, one only wants to view part of the snapshot, as in e.g. +// the span view. +// In order to provide that feature with respectable performance and an easy +// GUI, we toggle between one of several components here based on the URL +// params. To manage that navigation, we need to know the route prefix. +export const ROUTE_PREFIX = "/debug/tracez_v2/"; export interface SnapshotPageStateProps { sort: SortSetting; - snapshotsError?: Error; - snapshotsLoading: boolean; + + nodes?: cockroach.server.status.statuspb.INodeStatus[]; + snapshots?: ListTracingSnapshotsResponse; - snapshotsValid: boolean; + snapshotsLoading: boolean; + snapshotsError?: Error; + snapshot: GetTracingSnapshotResponse; - snapshotError?: Error; snapshotLoading: boolean; - nodes?: cockroach.server.status.statuspb.INodeStatus[]; + snapshotError?: Error; + + rawTrace: GetTraceResponse; + rawTraceLoading: boolean; + rawTraceError?: Error; + takeSnapshot: (nodeID: string) => Promise; + setTraceRecordingType: ( + nodeID: string, + traceID: Long, + recordingMode: RecordingMode, + ) => Promise; } export interface SnapshotPageDispatchProps { @@ -56,10 +70,18 @@ export interface SnapshotPageDispatchProps { refreshSnapshots: (id: string) => void; refreshSnapshot: (req: { nodeID: string; snapshotID: number }) => void; refreshNodes: () => void; + refreshRawTrace: (req: { + nodeID: string; + snapshotID: number; + traceID: Long; + }) => void; } type UrlParams = Partial< - Record<"nodeID" | "snapshotID" | "ascending" | "columnTitle", string> + Record< + "nodeID" | "snapshotID" | "spanID" | "ascending" | "columnTitle", + string + > >; export type SnapshotPageProps = SnapshotPageStateProps & SnapshotPageDispatchProps & @@ -69,16 +91,30 @@ export const SnapshotPage: React.FC = props => { const { history, match, - refreshNodes, - refreshSnapshots, - refreshSnapshot, + + sort, + setSort, + nodes, + refreshNodes, + snapshots, + snapshotsLoading, + snapshotsError, + refreshSnapshots, + snapshot, - snapshotsValid, - sort, - setSort, + snapshotLoading, + snapshotError, + refreshSnapshot, + + rawTrace, + rawTraceLoading, + rawTraceError, + refreshRawTrace, + takeSnapshot, + setTraceRecordingType, } = props; // Sort Settings. @@ -87,20 +123,27 @@ export const SnapshotPage: React.FC = props => { // Always an integer ID. const snapshotID = parseInt(getMatchParamByName(match, "snapshotID")); + // Always a Long, or undefined. + const spanStr = getMatchParamByName(match, "spanID"); + const spanID = spanStr ? Long.fromString(spanStr) : null; // Usually a string-wrapped integer ID, but also supports alias "local." const nodeID = getMatchParamByName(match, "nodeID"); + const isRaw = + match.path === + join(ROUTE_PREFIX, "node/:nodeID/snapshot/:snapshotID/span/:spanID/raw"); + // Load initial data. useEffect(() => { refreshNodes(); }, [refreshNodes]); useEffect(() => { - if (!nodeID) { - return; - } refreshSnapshots(nodeID); - }, [nodeID, refreshSnapshots]); + // Reload the snapshots when transitioning to a new snapshot ID. + // This isn't always necessary, but doesn't hurt (it's async) and helps + // when taking a new snapshot. + }, [nodeID, snapshotID, refreshSnapshots]); useEffect(() => { if (!snapshotID) { @@ -118,22 +161,15 @@ export const SnapshotPage: React.FC = props => { // If no node was provided, navigate explicitly to the local node. // If no snapshot was provided, navigate to the most recent. useEffect(() => { - if (nodeID && snapshotID) { + if (snapshotID) { return; } - if (!nodeID) { - let targetNodeID = getDataFromServer().NodeID; - if (!targetNodeID) { - targetNodeID = "local"; - } - history.location.pathname = join("/debug/tracez_v2/node/", targetNodeID); - } - if (!snapArray?.length || !snapshotsValid) { + if (!snapArray?.length) { // If we have no snapshots, or the record is stale, don't navigate. - history.replace(history.location); return; } + const lastSnapshotID = snapArray[snapArray.length - 1].snapshot_id; history.location.pathname = join( history.location.pathname, @@ -142,7 +178,7 @@ export const SnapshotPage: React.FC = props => { ); history.replace(history.location); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [snapArrayAsJson, snapshotID, nodeID, history, snapshotsValid]); + }, [snapArrayAsJson, snapshotID, history]); // Update sort based on URL. useEffect(() => { @@ -154,7 +190,8 @@ export const SnapshotPage: React.FC = props => { const onSnapshotSelected = (item: number) => { history.location.pathname = join( - "/debug/tracez_v2/node", + ROUTE_PREFIX, + "node", nodeID, "snapshot", item.toString(), @@ -163,7 +200,7 @@ export const SnapshotPage: React.FC = props => { }; const onNodeSelected = (item: string) => { - history.location.pathname = join("/debug/tracez_v2/node/", item); + history.location.pathname = join(ROUTE_PREFIX, "node/", item); history.push(history.location); }; @@ -180,10 +217,10 @@ export const SnapshotPage: React.FC = props => { const takeAndLoadSnapshot = () => { takeSnapshot(nodeID).then(resp => { - refreshSnapshots(nodeID); // Load the new snapshot. history.location.pathname = join( - "/debug/tracez_v2/node", + ROUTE_PREFIX, + "node", nodeID, "/snapshot/", resp.snapshot.snapshot_id.toString(), @@ -192,96 +229,112 @@ export const SnapshotPage: React.FC = props => { }); }; - const [snapshotItems, snapshotName] = useMemo(() => { - if (!snapArray) { - return [[], ""]; - } - let selectedName = ""; - const items = snapArray.map(snapshotInfo => { - const id = snapshotInfo.snapshot_id.toNumber(); - const time = TimestampToMoment(snapshotInfo.captured_at).format( - "MMM D, YYYY [at] HH:mm:ss", + const spanDetailsURL = useCallback( + (spanID: Long): string => { + return join( + ROUTE_PREFIX, + "node", + nodeID, + "/snapshot/", + snapshotID.toString(), + "span", + spanID.toString(), ); - const out = { - name: "Snapshot " + id + ": " + time, - value: id, - }; - if (id === snapshotID) { - selectedName = out.name; - } - return out; - }); - return [items, selectedName]; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [snapArrayAsJson, snapshotID]); + }, + [nodeID, snapshotID], + ); - const [nodeItems, nodeName] = useMemo(() => { - if (!nodes) { - return [[], ""]; - } - let selectedName = ""; - const items = nodes.map(node => { - const id = node.desc.node_id.toString(); - const out = { - name: "Node " + id, - value: id, - }; - if (id === nodeID) { - selectedName = out.name; - } - return out; - }); - return [items, selectedName]; - }, [nodes, nodeID]); - - const isLoading = props.snapshotsLoading || props.snapshotLoading; - const error = props.snapshotsError || props.snapshotError; - return ( -
- -

Snapshots

-
- - - - - - - {nodeName} - - - {snapshotItems.length > 0 && ( - - - items={snapshotItems} - onChange={onSnapshotSelected} - > - {snapshotName} - - - )} - -
-
- {snapshotID ? ( - ( - - )} - /> - ) : ( - "No snapshots found on this node." - )} -
-
+ const rawTraceURL = useCallback( + (spanID: Long): string => { + return join( + ROUTE_PREFIX, + "node", + nodeID, + "/snapshot/", + snapshotID.toString(), + "span", + spanID.toString(), + "raw", + ); + }, + [nodeID, snapshotID], + ); + + const isLoading = snapshotsLoading || snapshotLoading; + const error = snapshotsError || snapshotError; + const spans = snapshot?.snapshot.spans; + const span = spanID ? spans?.find(s => s.span_id.equals(spanID)) : null; + const snapProps = { + sort, + changeSortSetting, + nodes, + nodeID, + onNodeSelected, + snapshots, + snapshotID, + snapshot, + onSnapshotSelected, + isLoading, + error, + spanDetailsURL, + takeAndLoadSnapshot, + }; + const spanProps = { + sort, + changeSortSetting, + + nodeID, + + snapshot, + snapshotLoading, + snapshotError, + + span, + spanDetailsURL, + setTraceRecordingType, + + rawTraceURL, + }; + const rawTraceProps = { + nodeID, + snapshotID, + traceID: span?.trace_id, + + rawTrace, + rawTraceLoading, + rawTraceError, + refreshRawTrace, + }; + + const breadcrumbItems = [ + { + link: `/debug/tracez_v2/node/${nodeID}/snapshot/${snapshotID}`, + name: `Node ${nodeID}, Snapshot ${snapshotID}`, + }, + { + link: `/debug/tracez_v2/node/${nodeID}/snapshot/${snapshotID}/span/${spanID}`, + name: `${span?.operation}`, + }, + ]; + return !spanID ? ( + + ) : !isRaw ? ( + <> + + + + ) : ( + <> + + + ); }; diff --git a/pkg/ui/workspaces/cluster-ui/src/tracez/snapshot/spanComponent.tsx b/pkg/ui/workspaces/cluster-ui/src/tracez/snapshot/spanComponent.tsx new file mode 100644 index 000000000000..cff7946868b1 --- /dev/null +++ b/pkg/ui/workspaces/cluster-ui/src/tracez/snapshot/spanComponent.tsx @@ -0,0 +1,254 @@ +// 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, { useCallback, useMemo, useState } from "react"; +import moment from "moment"; +import { Helmet } from "react-helmet"; +import { commonStyles } from "src/common"; +import classNames from "classnames/bind"; +import styles from "../snapshot.module.scss"; +import { Loading } from "src/loading"; +import { SpanTable, formatDurationHours, TagCell } from "./spanTable"; +import { TimestampToMoment } from "src/util"; +import { SortSetting } from "src/sortedtable"; +import { + GetTracingSnapshotResponse, + SetTraceRecordingTypeResponse, + Span, +} from "src/api"; +import { CircleFilled } from "src/icon"; +import { cockroach } from "@cockroachlabs/crdb-protobuf-client"; +import RecordingMode = cockroach.util.tracing.tracingpb.RecordingMode; +import { Switch } from "antd"; +import "antd/lib/switch/style"; +import Long from "long"; +import { Button } from "src/button"; +import { useHistory } from "react-router-dom"; + +const cx = classNames.bind(styles); + +const SpanStatus: React.FC<{ + span: Span; + setTraceRecordingType: ( + nodeID: string, + traceID: Long, + mode: RecordingMode, + ) => Promise; + nodeID: string; +}> = props => { + const { span, setTraceRecordingType, nodeID } = props; + const spanID = span?.span_id; + const [recordingInFlight, setRecordingInFlight] = useState(false); + const traceID = span?.trace_id; + const recording = span?.current_recording_mode != RecordingMode.OFF; + + const toggleRecording = useCallback(() => { + setRecordingInFlight(true); + const targetState = recording ? RecordingMode.OFF : RecordingMode.VERBOSE; + const resp = setTraceRecordingType(nodeID, traceID, targetState); + resp + .then(() => { + span.current_recording_mode = targetState; + }) + .finally(() => { + setRecordingInFlight(false); + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + nodeID, + spanID, + traceID, + recording, + setTraceRecordingType, + setRecordingInFlight, + ]); + + if (!span?.current) { + return ( +
+
Status
+ {/* Use a viewbox to shrink the icon just a bit while leaving it centered.*/} + +   Inactive +
+ ); + } + + return ( + <> +
+
Status
+ {/* Use a viewbox to shrink the icon just a bit while leaving it centered.*/} + +   Active{recording && ", Recording"} +
+
+
Recording
+ +
+ + ); +}; + +export const SpanComponent: React.FC<{ + snapshot: GetTracingSnapshotResponse; + sort: SortSetting; + changeSortSetting: (_: SortSetting) => void; + spanDetailsURL: (_: Long) => string; + span: Span; + + rawTraceURL: (_: Long) => string; + + snapshotError: Error; + snapshotLoading: boolean; + setTraceRecordingType: ( + nodeID: string, + traceID: Long, + recordingMode: RecordingMode, + ) => Promise; + nodeID: string; +}> = props => { + const { + snapshot, + sort, + changeSortSetting, + span, + spanDetailsURL, + snapshotError, + snapshotLoading, + nodeID, + rawTraceURL, + setTraceRecordingType, + } = props; + const snapshotID = snapshot?.snapshot.snapshot_id; + const spans = snapshot?.snapshot.spans; + const spanID = span?.span_id; + const childFilteredSnapshot = useMemo(() => { + return { + ...snapshot?.snapshot, + spans: spans?.filter(s => s.parent_span_id.equals(spanID)), + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [nodeID, snapshotID, spanID]); + + const parentFilteredSnapshot = useMemo(() => { + return { + ...snapshot?.snapshot, + spans: spans?.filter(s => s.span_id.equals(span.parent_span_id)), + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [nodeID, snapshotID, spanID]); + + const snapshotTime = useMemo(() => { + return TimestampToMoment(snapshot?.snapshot.captured_at); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [nodeID, snapshotID]); + + const startTime = useMemo( + () => { + return TimestampToMoment(span?.start); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [nodeID, snapshotID, spanID], + ); + + const history = useHistory(); + return ( +
+ +
+

{span?.operation}

+ +
+
+
+
+
Snapshot Time (UTC)
+ {snapshotTime.format("YYYY-MM-DD HH:mm:ss.SSS")} +
+
+
Start Time (UTC)
+ {startTime.format("YYYY-MM-DD HH:mm:ss.SSS")} +
+
+
Duration
+ {formatDurationHours(moment.duration(snapshotTime.diff(startTime)))} +
+ +
+
+
+ Tags +
+ {span && } +
+
+ {parentFilteredSnapshot.spans?.length > 0 && ( +
+

Parent Span

+ ( + + )} + /> +
+ )} + {childFilteredSnapshot.spans?.length > 0 && ( +
+

Child Spans

+ ( + + )} + /> +
+ )} +
+
+ ); +}; diff --git a/pkg/ui/workspaces/cluster-ui/src/tracez/snapshot/spanTable.tsx b/pkg/ui/workspaces/cluster-ui/src/tracez/snapshot/spanTable.tsx index 3dc36dd2f16f..1b97a8b5c308 100644 --- a/pkg/ui/workspaces/cluster-ui/src/tracez/snapshot/spanTable.tsx +++ b/pkg/ui/workspaces/cluster-ui/src/tracez/snapshot/spanTable.tsx @@ -20,9 +20,11 @@ import { TimestampToMoment } from "src/util"; import { cockroach } from "@cockroachlabs/crdb-protobuf-client"; import ISpanTag = cockroach.server.serverpb.ISpanTag; import RecordingMode = cockroach.util.tracing.tracingpb.RecordingMode; -import { CircleFilled } from "../../icon"; +import { CircleFilled } from "src/icon"; import { Dropdown } from "src/dropdown"; import "antd/lib/switch/style"; +import { Link } from "react-router-dom"; +import Long from "long"; const cx = classNames.bind(styles); class SpanSortedTable extends SortedTable {} @@ -52,11 +54,10 @@ const Tag = ({ t }: TagRowProps) => { ); }; -const TagCell = (props: { span: Span }) => { - const tagGroups = props.span.processed_tags.filter( - tag => tag?.children?.length, - ); - const tags = props.span.processed_tags.filter(tag => !tag?.children?.length); +export const TagCell = (props: { span: Span; defaultExpanded?: boolean }) => { + const { span, defaultExpanded } = props; + const tagGroups = span.processed_tags.filter(tag => tag?.children?.length); + const tags = span.processed_tags.filter(tag => !tag?.children?.length); let hiddenTagGroup = null; if (tagGroups.length && tagGroups[tagGroups.length - 1].key === "...") { @@ -70,18 +71,20 @@ const TagCell = (props: { span: Span }) => { {tags.map((t, i) => ( ))} - {hiddenTagGroup && } + {hiddenTagGroup && ( + + )}
{tagGroups.map((t, i) => ( - + ))}
); }; -const TagGroup = (props: { tag: ISpanTag }) => { - const { tag } = props; - const [isExpanded, setExpanded] = useState(false); +const TagGroup = (props: { tag: ISpanTag; defaultExpanded?: boolean }) => { + const { tag, defaultExpanded } = props; + const [isExpanded, setExpanded] = useState(Boolean(defaultExpanded)); const { children } = tag; return (
@@ -129,7 +132,16 @@ const TagGroup = (props: { tag: ISpanTag }) => { ); }; -const formatDuration = (d: moment.Duration): string => { +const SpanCell: React.FC<{ + span: Span; + spanDetailsURL: (spanID: Long) => string; +}> = props => { + const { span, spanDetailsURL } = props; + const toPath = spanDetailsURL(span.span_id); + return {span.operation}; +}; + +export const formatDurationHours = (d: moment.Duration): string => { const hours = Math.floor(d.asHours()); const hourStr = hours ? hours + "h " : ""; const minutes = d.minutes(); @@ -148,6 +160,8 @@ const makeColumns = ( snapshot: Snapshot, relativeTimeMode: boolean, setRelativeTimeMode: (value: boolean) => void, + spanDetailsURL: (spanID: Long) => string, + sortable: boolean, ): ColumnDescriptor[] => { const startTime = TimestampToMoment(snapshot.captured_at); @@ -179,8 +193,8 @@ const makeColumns = ( name: "span", title: "Span", titleAlign: "left", - cell: span => span.operation, - sort: span => span.operation, + cell: span => , + sort: sortable && (span => span.operation), hideTitleUnderline: true, className: cx("operation-cell"), }, @@ -206,13 +220,13 @@ const makeColumns = ( titleAlign: "right", cell: span => { return relativeTimeMode - ? formatDuration( + ? formatDurationHours( moment.duration(startTime.diff(TimestampToMoment(span.start))), ) : TimestampToMoment(span.start).format("YYYY-MM-DD HH:mm:ss"); }, // This sort is backwards in duration mode, but toggling between sort keys within a column really trips things up. - sort: span => TimestampToMoment(span.start).unix(), + sort: sortable && (span => TimestampToMoment(span.start).unix()), hideTitleUnderline: true, className: cx("table-cell-time"), }, @@ -228,13 +242,14 @@ const makeColumns = ( }; export interface SpanTableProps { - sort: SortSetting; - setSort: (value: SortSetting) => void; + sort?: SortSetting; + setSort?: (value: SortSetting) => void; snapshot: Snapshot; + spanDetailsURL: (spanID: Long) => string; } export const SpanTable: React.FC = props => { - const { snapshot, sort, setSort } = props; + const { snapshot, sort, setSort, spanDetailsURL } = props; const spans = snapshot?.spans; const [relativeTimeMode, setRelativeTimeMode] = useState(false); @@ -247,7 +262,13 @@ export const SpanTable: React.FC = props => { data={spans} sortSetting={sort} onChangeSortSetting={setSort} - columns={makeColumns(snapshot, relativeTimeMode, setRelativeTimeMode)} + columns={makeColumns( + snapshot, + relativeTimeMode, + setRelativeTimeMode, + spanDetailsURL, + Boolean(setSort), + )} rowClass={() => cx("table-row")} /> ); diff --git a/pkg/ui/workspaces/db-console/src/app.tsx b/pkg/ui/workspaces/db-console/src/app.tsx index f4ca218a1ef1..13b48d73a96f 100644 --- a/pkg/ui/workspaces/db-console/src/app.tsx +++ b/pkg/ui/workspaces/db-console/src/app.tsx @@ -81,11 +81,11 @@ import RecentStatementDetails from "./views/statements/recentStatementDetailsCon import RecentTransactionDetails from "./views/transactions/recentTransactionDetailsConnected"; import "styl/app.styl"; import { Tracez } from "src/views/tracez/tracez"; -import SnapshotPage from "src/views/tracez_v2/snapshotPage"; import InsightsOverviewPage from "./views/insights/insightsOverview"; import TransactionInsightDetailsPage from "./views/insights/transactionInsightDetailsPage"; import StatementInsightDetailsPage from "./views/insights/statementInsightDetailsPage"; import { CockroachCloudContext } from "@cockroachlabs/cluster-ui"; +import { SnapshotRouter } from "src/views/tracez_v2/snapshotRoutes"; // NOTE: If you are adding a new path to the router, and that path contains any // components that are personally identifying information, you MUST update the @@ -327,21 +327,7 @@ export const App: React.FC = (props: AppProps) => { {/* debug pages */} - - - + nodeID, - moment.duration(1, "s"), ); export const refreshSnapshots = snapshotsReducerObj.refresh; @@ -512,10 +512,22 @@ const snapshotReducerObj = new KeyedCachedDataReducer( clusterUiApi.getTracingSnapshot, "snapshot", snapshotKey, - moment.duration(1, "s"), ); export const refreshSnapshot = snapshotReducerObj.refresh; +export const rawTraceKey = (req: { + nodeID: string; + snapshotID: number; + traceID: Long; +}): string => + req.nodeID + "/" + req.snapshotID.toString() + "/" + req.traceID?.toString(); +const rawTraceReducerObj = new KeyedCachedDataReducer( + clusterUiApi.getRawTrace, + "rawTrace", + rawTraceKey, +); +export const refreshRawTrace = rawTraceReducerObj.refresh; + export interface APIReducersState { cluster: CachedDataReducerState; events: CachedDataReducerState; @@ -559,6 +571,7 @@ export interface APIReducersState { schedule: KeyedCachedDataReducerState; snapshots: KeyedCachedDataReducerState; snapshot: KeyedCachedDataReducerState; + rawTrace: KeyedCachedDataReducerState; } export const apiReducersReducer = combineReducers({ @@ -611,6 +624,7 @@ export const apiReducersReducer = combineReducers({ [scheduleReducerObj.actionNamespace]: scheduleReducerObj.reducer, [snapshotsReducerObj.actionNamespace]: snapshotsReducerObj.reducer, [snapshotReducerObj.actionNamespace]: snapshotReducerObj.reducer, + [rawTraceReducerObj.actionNamespace]: rawTraceReducerObj.reducer, }); export { CachedDataReducerState, KeyedCachedDataReducerState }; diff --git a/pkg/ui/workspaces/db-console/src/views/tracez_v2/snapshotPage.tsx b/pkg/ui/workspaces/db-console/src/views/tracez_v2/snapshotPage.tsx index aafa1f1881f6..061fffe2ea90 100644 --- a/pkg/ui/workspaces/db-console/src/views/tracez_v2/snapshotPage.tsx +++ b/pkg/ui/workspaces/db-console/src/views/tracez_v2/snapshotPage.tsx @@ -17,7 +17,9 @@ import { api as clusterUiApi } from "@cockroachlabs/cluster-ui"; import { connect } from "react-redux"; import { RouteComponentProps, withRouter } from "react-router-dom"; import { + rawTraceKey, refreshNodes, + refreshRawTrace, refreshSnapshot, refreshSnapshots, snapshotKey, @@ -25,6 +27,7 @@ import { import { LocalSetting } from "src/redux/localsettings"; import { AdminUIState } from "src/redux/state"; import { getMatchParamByName } from "src/util/query"; +import Long from "long"; export const sortSetting = new LocalSetting( "sortSetting/spans", @@ -36,36 +39,50 @@ const mapStateToProps = ( state: AdminUIState, props: RouteComponentProps, ): SnapshotPageStateProps => { + const nodesState = state.cachedData.nodes; const nodeID = getMatchParamByName(props.match, "nodeID"); + const snapshotsState = state.cachedData.snapshots[nodeID]; - const snapshotID = getMatchParamByName(props.match, "snapshotID"); + const snapshotID = parseInt(getMatchParamByName(props.match, "snapshotID")); const snapshotState = state.cachedData.snapshot[ snapshotKey({ - nodeID: nodeID, - snapshotID: parseInt(snapshotID), + nodeID, + snapshotID, }) ]; - const nodesState = state.cachedData.nodes; + const spanID = getMatchParamByName(props.match, "spanID"); + let traceID: Long | null = null; + if (spanID) { + const span = snapshotState?.data?.snapshot.spans.find(s => + s.span_id.equals(spanID), + ); + traceID = span?.trace_id; + } + const rawTraceState = + state.cachedData.rawTrace[rawTraceKey({ nodeID, snapshotID, traceID })]; return { sort: sortSetting.selector(state), - // Pass down valid to gate redirect. + nodes: nodesState ? nodesState.data : null, + snapshots: snapshotsState ? snapshotsState.data : null, snapshotsLoading: snapshotsState ? snapshotsState.inFlight : false, snapshotsError: snapshotsState ? snapshotsState.lastError : null, - snapshotsValid: snapshotsState?.valid, snapshot: snapshotState ? snapshotState.data : null, snapshotLoading: snapshotState ? snapshotState.inFlight : false, snapshotError: snapshotState ? snapshotState.lastError : null, - nodes: nodesState ? nodesState.data : null, + rawTrace: rawTraceState ? rawTraceState.data : null, + rawTraceLoading: rawTraceState ? rawTraceState.inFlight : false, + rawTraceError: rawTraceState ? rawTraceState.lastError : null, takeSnapshot: clusterUiApi.takeTracingSnapshot, + setTraceRecordingType: clusterUiApi.setTraceRecordingType, }; }; @@ -74,6 +91,7 @@ const mapDispatchToProps = { refreshNodes, refreshSnapshots, refreshSnapshot, + refreshRawTrace, }; export default withRouter( diff --git a/pkg/ui/workspaces/db-console/src/views/tracez_v2/snapshotRoutes.tsx b/pkg/ui/workspaces/db-console/src/views/tracez_v2/snapshotRoutes.tsx new file mode 100644 index 000000000000..cbd37c0da70b --- /dev/null +++ b/pkg/ui/workspaces/db-console/src/views/tracez_v2/snapshotRoutes.tsx @@ -0,0 +1,60 @@ +// 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 { Route, Switch, useHistory, useRouteMatch } from "react-router-dom"; +import React, { useEffect } from "react"; +import SnapshotPage from "src/views/tracez_v2/snapshotPage"; +import { join } from "path"; +import { getDataFromServer } from "src/util/dataFromServer"; + +const NodePicker: React.FC = () => { + // If no node was provided, navigate explicitly to the local node. + const { url } = useRouteMatch(); + const history = useHistory(); + useEffect(() => { + let targetNodeID = getDataFromServer().NodeID; + if (!targetNodeID) { + targetNodeID = "local"; + } + history.location.pathname = join(url, "/node/", targetNodeID); + history.replace(history.location); + }, [url, history]); + + return null; +}; + +export const SnapshotRouter = () => { + const { path } = useRouteMatch(); + return ( + + + + + + + + ); +};