diff --git a/packages/admin-ui-components/.eslintrc.json b/packages/admin-ui-components/.eslintrc.json index 0fae2fb31..a48701539 100644 --- a/packages/admin-ui-components/.eslintrc.json +++ b/packages/admin-ui-components/.eslintrc.json @@ -7,6 +7,7 @@ "rules": { "@typescript-eslint/interface-name-prefix": "off", "@typescript-eslint/camelcase": "warn", - "@typescript-eslint/no-explicit-any": "warn" + "@typescript-eslint/no-explicit-any": "warn", + "@typescript-eslint/no-namespace": "off" } } diff --git a/packages/admin-ui-components/jest.testing.config.js b/packages/admin-ui-components/jest.testing.config.js index 3f96e4665..b6e49c802 100644 --- a/packages/admin-ui-components/jest.testing.config.js +++ b/packages/admin-ui-components/jest.testing.config.js @@ -2,9 +2,9 @@ module.exports = { displayName: "test", moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json", "node"], moduleNameMapper: { - "\\.(jpg|ico|jpeg|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "identity-obj-proxy", + "\\.(jpg|ico|jpeg|eot|otf|webp|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "identity-obj-proxy", "\\.(css|scss|less)$": "identity-obj-proxy", - "\\.(gif|png)$": "/.jest/fileMock.js", + "\\.(gif|png|svg)$": "/.jest/fileMock.js", }, "moduleDirectories": [ ".", diff --git a/packages/admin-ui-components/package.json b/packages/admin-ui-components/package.json index 27ec5ffb5..03c7a837d 100644 --- a/packages/admin-ui-components/package.json +++ b/packages/admin-ui-components/package.json @@ -26,7 +26,7 @@ "keywords": [], "license": "MIT", "dependencies": { - "@cockroachlabs/crdb-protobuf-client": "^0.0.3", + "@cockroachlabs/crdb-protobuf-client": "^0.0.4", "@cockroachlabs/icons": "0.3.0", "@cockroachlabs/ui-components": "0.2.14-alpha.0", "@popperjs/core": "^2.4.0", diff --git a/packages/admin-ui-components/src/barCharts/genericBarChart.tsx b/packages/admin-ui-components/src/barCharts/genericBarChart.tsx new file mode 100644 index 000000000..442ae974c --- /dev/null +++ b/packages/admin-ui-components/src/barCharts/genericBarChart.tsx @@ -0,0 +1,101 @@ +import React from "react"; +import classNames from "classnames/bind"; +import { scaleLinear } from "d3-scale"; +import { format as d3Format } from "d3-format"; + +import { stdDevLong } from "src/util/appStats"; +import { Tooltip } from "src/tooltip"; +import { NumericStat } from "../util"; +import { clamp, longToInt, normalizeClosedDomain } from "./utils"; +import styles from "./barCharts.module.scss"; + +const cx = classNames.bind(styles); + +function renderNumericStatLegend( + count: number | Long, + stat: number, + sd: number, + formatter: (d: number) => string, +) { + return ( + + + + + + + + + + + +
+
+ Mean +
{formatter(stat)}
+
+ Standard Deviation +
{longToInt(count) < 2 ? "-" : sd ? formatter(sd) : "0"}
+ ); +} + +export function genericBarChart( + s: NumericStat, + count: number | Long, + format?: (v: number) => string, +) { + if (!s) { + return () =>
; + } + const mean = s.mean; + const sd = stdDevLong(s, count); + + const max = mean + sd; + const scale = scaleLinear() + .domain(normalizeClosedDomain([0, max])) + .range([0, 100]); + if (!format) { + format = d3Format(".2f"); + } + return function MakeGenericBarChart() { + const width = scale(clamp(mean - sd)); + const right = scale(mean); + const spread = scale(sd + (sd > mean ? mean : sd)); + const title = renderNumericStatLegend(count, mean, sd, format); + return ( + +
+
{format(mean)}
+
+
+
+
+
+ + ); + }; +} diff --git a/packages/admin-ui-components/src/barCharts/index.ts b/packages/admin-ui-components/src/barCharts/index.ts index 5a5610968..ad1deee12 100644 --- a/packages/admin-ui-components/src/barCharts/index.ts +++ b/packages/admin-ui-components/src/barCharts/index.ts @@ -1 +1,5 @@ export * from "./barCharts"; +export * from "./rowsBreakdown"; +export * from "./utils"; +export * from "./latencyBreakdown"; +export * from "./genericBarChart"; diff --git a/packages/admin-ui-components/src/barCharts/rowsBrealdown.ts b/packages/admin-ui-components/src/barCharts/rowsBreakdown.ts similarity index 100% rename from packages/admin-ui-components/src/barCharts/rowsBrealdown.ts rename to packages/admin-ui-components/src/barCharts/rowsBreakdown.ts diff --git a/packages/admin-ui-components/src/declaration.d.ts b/packages/admin-ui-components/src/declaration.d.ts index 93810f526..ab0d55fad 100644 --- a/packages/admin-ui-components/src/declaration.d.ts +++ b/packages/admin-ui-components/src/declaration.d.ts @@ -14,3 +14,7 @@ type FirstConstructorParameter< > = ConstructorParameters

[0]; type Tuple = [T, T]; + +type Dictionary = { + [key: string]: V; +}; diff --git a/packages/admin-ui-components/src/downloadFile/downloadFile.tsx b/packages/admin-ui-components/src/downloadFile/downloadFile.tsx new file mode 100644 index 000000000..05e8cae23 --- /dev/null +++ b/packages/admin-ui-components/src/downloadFile/downloadFile.tsx @@ -0,0 +1,87 @@ +// Copyright 2020 The Cockroach Authors. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0, included in the file +// licenses/APL.txt. + +import React, { + useRef, + useEffect, + forwardRef, + useImperativeHandle, +} from "react"; + +type FileTypes = "text/plain" | "application/json"; + +export interface DownloadAsFileProps { + fileName?: string; + fileType?: FileTypes; + content?: string; +} + +export interface DownloadFileRef { + download: (name: string, type: FileTypes, body: string) => void; +} + +/* + * DownloadFile can download file in two modes `default` and `imperative`. + * `Default` mode - when DownloadFile wraps component which should trigger + * downloading and can work only if content of file is already available. + * + * For example: + * ``` + * + * + * ``` + * */ +// tslint:disable-next-line:variable-name +export const DownloadFile = forwardRef( + (props, ref) => { + const { children, fileName, fileType, content } = props; + const anchorRef = useRef(); + + const bootstrapFile = (name: string, type: FileTypes, body: string) => { + const anchorElement = anchorRef.current; + const file = new Blob([body], { type }); + anchorElement.href = URL.createObjectURL(file); + anchorElement.download = name; + }; + + useEffect(() => { + if (content === undefined) { + return; + } + bootstrapFile(fileName, fileType, content); + }, [fileName, fileType, content]); + + useImperativeHandle(ref, () => ({ + download: (name: string, type: FileTypes, body: string) => { + bootstrapFile(name, type, body); + anchorRef.current.click(); + }, + })); + + return {children}; + }, +); diff --git a/packages/admin-ui-components/src/downloadFile/index.ts b/packages/admin-ui-components/src/downloadFile/index.ts new file mode 100644 index 000000000..8b04ed62d --- /dev/null +++ b/packages/admin-ui-components/src/downloadFile/index.ts @@ -0,0 +1,11 @@ +// Copyright 2020 The Cockroach Authors. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0, included in the file +// licenses/APL.txt. + +export * from "./downloadFile"; diff --git a/packages/admin-ui-components/src/index.ts b/packages/admin-ui-components/src/index.ts index ce215475c..362b62f69 100644 --- a/packages/admin-ui-components/src/index.ts +++ b/packages/admin-ui-components/src/index.ts @@ -6,6 +6,7 @@ export * from "./anchor"; export * from "./badge"; export * from "./barCharts"; export * from "./button"; +export * from "./downloadFile"; export * from "./dropdown"; export * from "./empty"; export * from "./highlightedText"; @@ -17,7 +18,11 @@ export * from "./search"; export * from "./sortedtable"; export * from "./statementsDiagnostics"; export * from "./statementsPage"; +export * from "./statementDetails"; export * from "./statementsTable"; +export * from "./statementDetails/statementDetails"; +export * from "./sql"; +export * from "./table"; export * from "./store"; export * from "./transactionsPage"; export * from "./text"; diff --git a/packages/admin-ui-components/src/loading/loading.tsx b/packages/admin-ui-components/src/loading/loading.tsx index 56e043222..e53345fbb 100644 --- a/packages/admin-ui-components/src/loading/loading.tsx +++ b/packages/admin-ui-components/src/loading/loading.tsx @@ -99,7 +99,7 @@ export const Loading: React.FC = props => { return (

- {errorAlerts} + {React.Children.toArray(errorAlerts)}
); } diff --git a/packages/admin-ui-components/src/network/identity.ts b/packages/admin-ui-components/src/network/identity.ts new file mode 100644 index 000000000..800063f56 --- /dev/null +++ b/packages/admin-ui-components/src/network/identity.ts @@ -0,0 +1,8 @@ +import moment from "moment"; + +export interface Identity { + nodeID: number; + address: string; + locality?: string; + updatedAt: moment.Moment; +} diff --git a/packages/admin-ui-components/src/network/index.ts b/packages/admin-ui-components/src/network/index.ts new file mode 100644 index 000000000..41e06636f --- /dev/null +++ b/packages/admin-ui-components/src/network/index.ts @@ -0,0 +1 @@ +export * from "./identity"; diff --git a/packages/admin-ui-components/src/nodes/getDisplayName.ts b/packages/admin-ui-components/src/nodes/getDisplayName.ts new file mode 100644 index 000000000..5f4f76be0 --- /dev/null +++ b/packages/admin-ui-components/src/nodes/getDisplayName.ts @@ -0,0 +1,32 @@ +import { cockroach } from "@cockroachlabs/crdb-protobuf-client"; +import { NoConnection } from "./noConnection"; + +type INodeStatus = cockroach.server.status.statuspb.INodeStatus; + +const LivenessStatus = + cockroach.kv.kvserver.liveness.livenesspb.NodeLivenessStatus; + +function isNoConnection( + node: INodeStatus | NoConnection, +): node is NoConnection { + return ( + (node as NoConnection).to !== undefined && + (node as NoConnection).from !== undefined + ); +} + +export function getDisplayName( + node: INodeStatus | NoConnection, + livenessStatus = LivenessStatus.NODE_STATUS_LIVE, +) { + const decommissionedString = + livenessStatus === LivenessStatus.NODE_STATUS_DECOMMISSIONED + ? "[decommissioned] " + : ""; + + if (isNoConnection(node)) { + return `${decommissionedString}(n${node.from.nodeID})`; + } + // as the only other type possible right now is INodeStatus we don't have a type guard for that + return `${decommissionedString}(n${node.desc.node_id}) ${node.desc.address.address_field}`; +} diff --git a/packages/admin-ui-components/src/nodes/index.ts b/packages/admin-ui-components/src/nodes/index.ts new file mode 100644 index 000000000..766d96bf7 --- /dev/null +++ b/packages/admin-ui-components/src/nodes/index.ts @@ -0,0 +1,4 @@ +export * from "./getDisplayName"; +export * from "./noConnection"; +export * from "./nodeCapacityStats"; +export * from "./nodeSummaryStats"; diff --git a/packages/admin-ui-components/src/nodes/noConnection.ts b/packages/admin-ui-components/src/nodes/noConnection.ts new file mode 100644 index 000000000..804ee6ee1 --- /dev/null +++ b/packages/admin-ui-components/src/nodes/noConnection.ts @@ -0,0 +1,6 @@ +import { Identity } from "../network"; + +export interface NoConnection { + from: Identity; + to: Identity; +} diff --git a/packages/admin-ui-components/src/nodes/nodeCapacityStats.ts b/packages/admin-ui-components/src/nodes/nodeCapacityStats.ts new file mode 100644 index 000000000..47c76909d --- /dev/null +++ b/packages/admin-ui-components/src/nodes/nodeCapacityStats.ts @@ -0,0 +1,20 @@ +import { cockroach } from "@cockroachlabs/crdb-protobuf-client"; +import { MetricConstants } from "../util/proto"; + +type INodeStatus = cockroach.server.status.statuspb.INodeStatus; + +export interface CapacityStats { + used: number; + usable: number; + available: number; +} + +export function nodeCapacityStats(n: INodeStatus): CapacityStats { + const used = n.metrics[MetricConstants.usedCapacity]; + const available = n.metrics[MetricConstants.availableCapacity]; + return { + used, + available, + usable: used + available, + }; +} diff --git a/packages/admin-ui-components/src/nodes/nodeSummaryStats.ts b/packages/admin-ui-components/src/nodes/nodeSummaryStats.ts new file mode 100644 index 000000000..c2efd693c --- /dev/null +++ b/packages/admin-ui-components/src/nodes/nodeSummaryStats.ts @@ -0,0 +1,19 @@ +export type NodeSummaryStats = { + nodeCounts: { + total: number; + healthy: number; + suspect: number; + dead: number; + decommissioned: number; + }; + capacityUsed: number; + capacityAvailable: number; + capacityTotal: number; + capacityUsable: number; + usedBytes: number; + usedMem: number; + totalRanges: number; + underReplicatedRanges: number; + unavailableRanges: number; + replicas: number; +}; diff --git a/packages/admin-ui-components/src/search/search.tsx b/packages/admin-ui-components/src/search/search.tsx index a1e353ec7..dcfb14845 100644 --- a/packages/admin-ui-components/src/search/search.tsx +++ b/packages/admin-ui-components/src/search/search.tsx @@ -87,6 +87,7 @@ export class Search extends React.Component { render() { const { value, submitted } = this.state; + const { onClear, ...inputProps } = this.props; const className = submitted ? cx("_submitted") : ""; return ( @@ -99,7 +100,7 @@ export class Search extends React.Component { prefix={} suffix={this.renderSuffix()} value={value} - {...this.props} + {...inputProps} /> diff --git a/packages/admin-ui-components/src/statementDetails/diagnostics/diagnosticsUtils.ts b/packages/admin-ui-components/src/statementDetails/diagnostics/diagnosticsUtils.ts new file mode 100644 index 000000000..8601f1b8c --- /dev/null +++ b/packages/admin-ui-components/src/statementDetails/diagnostics/diagnosticsUtils.ts @@ -0,0 +1,69 @@ +import { isUndefined } from "lodash"; +import { cockroach } from "@cockroachlabs/crdb-protobuf-client"; +import { DiagnosticStatuses } from "src/statementsDiagnostics"; + +type IStatementDiagnosticsReport = cockroach.server.serverpb.IStatementDiagnosticsReport; + +export function getDiagnosticsStatus( + diagnosticsRequest: IStatementDiagnosticsReport, +): DiagnosticStatuses { + if (diagnosticsRequest.completed) { + return "READY"; + } + + return "WAITING"; +} + +export function sortByRequestedAtField( + a: IStatementDiagnosticsReport, + b: IStatementDiagnosticsReport, +) { + const activatedOnA = a.requested_at?.seconds?.toNumber(); + const activatedOnB = b.requested_at?.seconds?.toNumber(); + if (isUndefined(activatedOnA) && isUndefined(activatedOnB)) { + return 0; + } + if (activatedOnA < activatedOnB) { + return -1; + } + if (activatedOnA > activatedOnB) { + return 1; + } + return 0; +} + +export function sortByCompletedField( + a: IStatementDiagnosticsReport, + b: IStatementDiagnosticsReport, +) { + const completedA = a.completed ? 1 : -1; + const completedB = b.completed ? 1 : -1; + if (completedA < completedB) { + return -1; + } + if (completedA > completedB) { + return 1; + } + return 0; +} + +export function sortByStatementFingerprintField( + a: IStatementDiagnosticsReport, + b: IStatementDiagnosticsReport, +) { + const statementFingerprintA = a.statement_fingerprint; + const statementFingerprintB = b.statement_fingerprint; + if ( + isUndefined(statementFingerprintA) && + isUndefined(statementFingerprintB) + ) { + return 0; + } + if (statementFingerprintA < statementFingerprintB) { + return -1; + } + if (statementFingerprintA > statementFingerprintB) { + return 1; + } + return 0; +} diff --git a/packages/admin-ui-components/src/statementDetails/diagnostics/diagnosticsView.module.scss b/packages/admin-ui-components/src/statementDetails/diagnostics/diagnosticsView.module.scss new file mode 100644 index 000000000..679dc0eec --- /dev/null +++ b/packages/admin-ui-components/src/statementDetails/diagnostics/diagnosticsView.module.scss @@ -0,0 +1,67 @@ +@import "src/core/index.module"; + +.crl-statements-diagnostics-view { + display: flex; + flex-direction: column; + + &__title { + display: flex; + flex-direction: row; + justify-content: space-between; + margin-bottom: $spacing-mid-large; + } + + &__footer { + margin: $spacing-medium 0 0 $spacing-smaller; + } + + &__content { + max-width: 650px; + } + + &__main { + margin-bottom: $spacing-small; + color: $colors--secondary-text; + } + + &__actions-column { + display: flex; + flex-direction: row; + flex-wrap: nowrap; + justify-content: flex-end; + } + + &__vertical-line { + width: 1px; + min-height: 100%; + border-left: 1px solid $colors--neutral-3; + margin: 0 $spacing-x-small; + } + + &__icon { + display: inline-block; + color: inherit; + font-style: normal; + line-height: 0; + text-align: center; + text-transform: none; + vertical-align: -0.125em; + margin-right: $spacing-x-small; + } + + &__statements-link { + color: $colors--primary-text; + + &:hover { + color: $colors--link; + } + } +} + +.summary--card__empty-state { + background-color: $colors--white; + background-image: url("./emptyTracingBackground.svg"); + background-repeat: no-repeat; + background-position-x: right; + padding: 0; +} diff --git a/packages/admin-ui-components/src/statementDetails/diagnostics/diagnosticsView.spec.tsx b/packages/admin-ui-components/src/statementDetails/diagnostics/diagnosticsView.spec.tsx new file mode 100644 index 000000000..c4f13e07c --- /dev/null +++ b/packages/admin-ui-components/src/statementDetails/diagnostics/diagnosticsView.spec.tsx @@ -0,0 +1,123 @@ +import React from "react"; +import { assert } from "chai"; +import { mount, ReactWrapper } from "enzyme"; +import sinon, { SinonSpy } from "sinon"; +import Long from "long"; +import classNames from "classnames/bind"; +import { MemoryRouter } from "react-router-dom"; +import { cockroach } from "@cockroachlabs/crdb-protobuf-client"; + +import { DiagnosticsView } from "./diagnosticsView"; +import { Table } from "src/table"; +import { TestStoreProvider } from "src/test-utils"; +import buttonStyles from "src/button.module.scss"; + +type IStatementDiagnosticsReport = cockroach.server.serverpb.IStatementDiagnosticsReport; + +const cx = classNames.bind(buttonStyles); +const sandbox = sinon.createSandbox(); + +function generateDiagnosticsRequest( + extendObject: Partial = {}, +): IStatementDiagnosticsReport { + const diagnosticsRequest = { + statement_fingerprint: "SELECT * FROM table", + completed: true, + requested_at: { + seconds: Long.fromNumber(Date.now()), + nanos: Math.random() * 1000000, + }, + }; + Object.assign(diagnosticsRequest, extendObject); + return diagnosticsRequest; +} + +describe("DiagnosticsView", () => { + let wrapper: ReactWrapper; + let activateFn: SinonSpy; + const statementFingerprint = "some-id"; + + beforeEach(() => { + sandbox.reset(); + activateFn = sandbox.spy(); + }); + + describe("With Empty state", () => { + beforeEach(() => { + wrapper = mount( + + {}} + /> + , + ); + }); + + it("calls activate callback with statementId when click on Activate button", () => { + const activateButtonComponent = wrapper + .find(`.${cx("crl-button")}`) + .first(); + activateButtonComponent.simulate("click"); + activateFn.calledOnceWith(statementFingerprint); + }); + }); + + describe("With tracing data", () => { + beforeEach(() => { + const diagnosticsRequests: IStatementDiagnosticsReport[] = [ + generateDiagnosticsRequest(), + generateDiagnosticsRequest(), + ]; + + wrapper = mount( + + {}} + /> + , + ); + }); + + it("renders Table component when diagnostics data is provided", () => { + assert.isTrue(wrapper.find(Table).exists()); + }); + + it("calls activate callback with statementId when click on Activate button", () => { + const activateButtonComponent = wrapper + .find(`.${cx("crl-button")}`) + .first(); + activateButtonComponent.simulate("click"); + activateFn.calledOnceWith(statementFingerprint); + }); + + it("Activate button is hidden if diagnostics is requested and waiting query", () => { + const diagnosticsRequests: IStatementDiagnosticsReport[] = [ + generateDiagnosticsRequest({ completed: false }), + generateDiagnosticsRequest(), + ]; + wrapper = mount( + + {}} + /> + , + ); + const activateButtonComponent = wrapper + .find(".crl-statements-diagnostics-view__activate-button") + .first(); + assert.isFalse(activateButtonComponent.exists()); + }); + }); +}); diff --git a/packages/admin-ui-components/src/statementDetails/diagnostics/diagnosticsView.tsx b/packages/admin-ui-components/src/statementDetails/diagnostics/diagnosticsView.tsx new file mode 100644 index 000000000..9187e124c --- /dev/null +++ b/packages/admin-ui-components/src/statementDetails/diagnostics/diagnosticsView.tsx @@ -0,0 +1,192 @@ +import React from "react"; +import { Link } from "react-router-dom"; +import moment from "moment"; +import classnames from "classnames/bind"; +import { cockroach } from "@cockroachlabs/crdb-protobuf-client"; +import { Download } from "@cockroachlabs/icons"; +import { Button, Text, TextTypes, Table, ColumnsConfig, Anchor } from "src"; +import { SummaryCard } from "src/summaryCard"; +import { DiagnosticStatusBadge } from "src/statementsDiagnostics"; +import emptyListIcon from "src/assets/emptyState/empty-list-results.svg"; +import { + getDiagnosticsStatus, + sortByCompletedField, + sortByRequestedAtField, +} from "./diagnosticsUtils"; +import { statementDiagnostics } from "src/util/docs"; +import { EmptyTable } from "src/empty"; +import styles from "./diagnosticsView.module.scss"; + +type IStatementDiagnosticsReport = cockroach.server.serverpb.IStatementDiagnosticsReport; + +export interface DiagnosticsViewStateProps { + hasData: boolean; + diagnosticsReports: cockroach.server.serverpb.IStatementDiagnosticsReport[]; +} + +export interface DiagnosticsViewDispatchProps { + activate: (statementFingerprint: string) => void; + dismissAlertMessage: () => void; + onDownloadDiagnosticBundleClick?: (statementFingerprint: string) => void; +} + +export interface DiagnosticsViewOwnProps { + statementFingerprint?: string; +} + +export type DiagnosticsViewProps = DiagnosticsViewOwnProps & + DiagnosticsViewStateProps & + DiagnosticsViewDispatchProps; + +interface DiagnosticsViewState { + traces: { + [diagnosticsId: string]: string; + }; +} + +const cx = classnames.bind(styles); + +export class DiagnosticsView extends React.Component< + DiagnosticsViewProps, + DiagnosticsViewState +> { + columns: ColumnsConfig = [ + { + key: "activatedOn", + title: "Activated on", + sorter: sortByRequestedAtField, + defaultSortOrder: "descend", + render: (_text, record) => { + const timestamp = record.requested_at.seconds.toNumber() * 1000; + return moment(timestamp).format("LL[ at ]h:mm a"); + }, + }, + { + key: "status", + title: "status", + sorter: sortByCompletedField, + width: "160px", + render: (_text, record) => { + const status = getDiagnosticsStatus(record); + return ( + + ); + }, + }, + { + key: "actions", + title: "", + sorter: false, + width: "160px", + render: ((onDownloadDiagnosticBundleClick: (s: string) => void) => { + return (_text: string, record: IStatementDiagnosticsReport) => { + if (record.completed) { + return ( + + ); + } + return null; + }; + })(this.props.onDownloadDiagnosticBundleClick), + }, + ]; + + onActivateButtonClick = () => { + const { activate, statementFingerprint } = this.props; + activate(statementFingerprint); + }; + + componentWillUnmount() { + this.props.dismissAlertMessage(); + } + + render() { + const { diagnosticsReports } = this.props; + + const canRequestDiagnostics = diagnosticsReports.every( + diagnostic => diagnostic.completed, + ); + + const dataSource = diagnosticsReports.map((diagnosticsReport, idx) => ({ + ...diagnosticsReport, + key: idx, + })); + + return ( + +
+ Statement diagnostics + {canRequestDiagnostics && ( + + )} +
+ + + {"When you activate statement diagnostics, CockroachDB will wait for the next query that" + + " matches this statement fingerprint. A download button will appear on the statement list and" + + " detail pages when the query is ready. The statement diagnostic will include EXPLAIN plans, table" + + " statistics, and traces. "} + + + Learn More + + + } + footer={ + + } + /> + } + dataSource={dataSource} + columns={this.columns} + /> +
+ + All statement diagnostics + +
+ + ); + } +} diff --git a/packages/admin-ui-components/src/statementDetails/diagnostics/emptyTracingBackground.svg b/packages/admin-ui-components/src/statementDetails/diagnostics/emptyTracingBackground.svg new file mode 100644 index 000000000..3ca678f57 --- /dev/null +++ b/packages/admin-ui-components/src/statementDetails/diagnostics/emptyTracingBackground.svg @@ -0,0 +1,113 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/admin-ui-components/src/statementDetails/index.ts b/packages/admin-ui-components/src/statementDetails/index.ts new file mode 100644 index 000000000..583cf1801 --- /dev/null +++ b/packages/admin-ui-components/src/statementDetails/index.ts @@ -0,0 +1,4 @@ +export * from "./statementDetails"; +export * from "./diagnostics/diagnosticsView"; +export * from "./diagnostics/diagnosticsUtils"; +export * from "./planView"; diff --git a/packages/admin-ui-components/src/statementDetails/planView/index.ts b/packages/admin-ui-components/src/statementDetails/planView/index.ts new file mode 100644 index 000000000..1f3be5e3f --- /dev/null +++ b/packages/admin-ui-components/src/statementDetails/planView/index.ts @@ -0,0 +1 @@ +export * from "./planView"; diff --git a/packages/admin-ui-components/src/statementDetails/planView/planView.fixtures.tsx b/packages/admin-ui-components/src/statementDetails/planView/planView.fixtures.tsx new file mode 100644 index 000000000..2d03bc369 --- /dev/null +++ b/packages/admin-ui-components/src/statementDetails/planView/planView.fixtures.tsx @@ -0,0 +1,184 @@ +import { cockroach } from "@cockroachlabs/crdb-protobuf-client"; + +type IExplainTreePlanNode = cockroach.sql.IExplainTreePlanNode; + +export const logicalPlan: IExplainTreePlanNode = { + name: "root", + attrs: [], + children: [ + { + name: "count", + attrs: [], + children: [ + { + name: "upsert", + attrs: [ + { + key: "into", + value: + "vehicle_location_histories(city, ride_id, timestamp, lat, long)", + }, + { + key: "strategy", + value: "opt upserter", + }, + ], + children: [ + { + name: "buffer node", + attrs: [ + { + key: "label", + value: "buffer 1", + }, + ], + children: [ + { + name: "row source to plan node", + attrs: [], + children: [ + { + name: "render", + attrs: [ + { + key: "render", + value: "column1", + }, + { + key: "render", + value: "column2", + }, + { + key: "render", + value: "column3", + }, + { + key: "render", + value: "column4", + }, + { + key: "render", + value: "column5", + }, + { + key: "render", + value: "column4", + }, + { + key: "render", + value: "column5", + }, + ], + children: [ + { + name: "values", + attrs: [ + { + key: "size", + value: "5 columns, 1 row", + }, + { + key: "row 0, expr", + value: "_", + }, + { + key: "row 0, expr", + value: "_", + }, + { + key: "row 0, expr", + value: "now()", + }, + { + key: "row 0, expr", + value: "_", + }, + { + key: "row 0, expr", + value: "_", + }, + ], + children: [], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + { + name: "postquery", + attrs: [], + children: [ + { + name: "error if rows", + attrs: [], + children: [ + { + name: "row source to plan node", + attrs: [], + children: [ + { + name: "lookup-join", + attrs: [ + { + key: "table", + value: "rides@primary", + }, + { + key: "type", + value: "anti", + }, + { + key: "equality", + value: "(column1, column2) = (city, id)", + }, + { + key: "equality cols are key", + value: "", + }, + { + key: "parallel", + value: "", + }, + ], + children: [ + { + name: "render", + attrs: [ + { + key: "render", + value: "column1", + }, + { + key: "render", + value: "column2", + }, + ], + children: [ + { + name: "scan buffer node", + children: [], + attrs: [ + { + key: "label", + value: "buffer 1", + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], +}; diff --git a/packages/admin-ui-components/src/statementDetails/planView/planView.module.scss b/packages/admin-ui-components/src/statementDetails/planView/planView.module.scss new file mode 100644 index 000000000..1573ee5f8 --- /dev/null +++ b/packages/admin-ui-components/src/statementDetails/planView/planView.module.scss @@ -0,0 +1,248 @@ +@import "src/core/index.module"; +@import 'src/sortedtable/table.module.scss'; + +.base-heading { + padding: 12px 0; + font-size: 24px; + font-family: $font-family--base; +} + +.plan-view-table { + @include table-base; + + .plan-view-table__cell { + padding: 0; + } + + .summary--card__title { + font-family: $font-family--base; + line-height: 1.6; + letter-spacing: -0.2px; + color: $popover-color; + font-size: 16px; + display: inline-block; + margin-bottom: 10px; + padding: 0; + text-transform: none; + } + + &__row { + &--body { + border-top: none; + + &:hover { + background-color: $adminui-white; + } + } + } + + &__tooltip { + .hover-tooltip__text { + width: 520px; + margin-left: 15px; + } + } +} + +.plan-view-table { + &__tooltip { + width: 36px; + height: 16px; + display: inline-block; + + + text-transform: none; + font-weight: normal; + white-space: normal; + letter-spacing: normal; + font-size: 14px; + } + + &__tooltip-hover-area { + width: 100%; + padding: 0px 10px; + } + + &__info-icon { + width: 16px; + height: 16px; + border-radius: 50%; + border: 1px solid $tooltip-color; + font-size: 12px; + line-height: 14px; + text-align: center; + color: $tooltip-color; + } + + .hover-tooltip--hovered &__info-icon { + border-color: $body-color; + color: $body-color; + } +} + +.plan-view { + color: $body-color; + position: relative; + + .plan-view-container { + height: 100%; + max-height: 100%; + overflow: hidden; + + .plan-view-container-scroll { + max-height: 400px; + overflow-y: scroll; + } + + .plan-view-container-directions { + text-align: center; + cursor: pointer; + text-transform: uppercase; + color: $main-blue-color; + font-size: smaller; + } + } + + .node-icon { + margin: 0 10px 0 0; + color: $grey-light; + } + + .warning-icon { + margin: 0 4px 0 4px; + position: relative; + top: 3px; + + path { + fill: $colors--functional-orange-4; + } + } + + .warn { + position: relative; + left: -5px; + color: $colors--functional-orange-4; + background-color: rgba(209, 135, 55, 0.06); + border-radius: 2px; + padding: 2px; + } + + .nodeDetails { + position: relative; + padding: 6px 0; + border: 1px solid transparent; + + b { + font-family: $font-family--semi-bold; + font-size: 12px; + font-weight: 600; + line-height: 1.67; + letter-spacing: 0.3px; + color: $text-color; + } + } + + .nodeAttributes { + color: $adminui-grey-2; + padding: 7px 16px 0px 18px; + margin-left: 3px; + border-left: 1px solid $grey-light; + font-family: $font-family--monospace; + font-size: 12px; + font-weight: 500; + line-height: 1.83; + + .nodeAttributeKey { + color: $colors--primary-green-3; + } + } + + ul { + padding: 0; + margin: 0; + + li { + padding: 0; + margin: 0; + position: relative; + list-style-type: none; + + &:not(:first-child):after { + content: ''; + width: 1px; + height: 19px; + background-color: $grey-light; + position: absolute; + top: -10px; + left: 4px; + } + + ul { + padding-left: 27px; + position: relative; + + &:last-child { + &:before { + content: ''; + width: 28px; + height: 29px; + position: absolute; + border-left: 1px solid $grey-light; + border-bottom: 1px solid $grey-light; + top: -10px; + left: 4px; + border-bottom-left-radius: 10px; + } + + li { + &:before { + content: none; + } + + &:first-child:after { + content: none; + } + } + } + + li { + .nodeDetails { + margin-left: 12px; + } + + &:not(:first-child):after { + left: 16px; + } + + &:last-child { + .nodeAttributes { + border-color: transparent; + } + } + + &:first-child { + &:after { + content: ''; + height: 1px; + width: 27px; + background-color: $grey-light; + position: absolute; + top: 18px; + left: -22px; + } + } + + &:before { + content: ''; + width: 1px; + height: 100%; + background-color: $grey-light; + position: absolute; + top: -10px; + left: -23px; + } + } + } + } + } +} diff --git a/packages/admin-ui-components/src/statementDetails/planView/planView.spec.tsx b/packages/admin-ui-components/src/statementDetails/planView/planView.spec.tsx new file mode 100644 index 000000000..fb7a8101c --- /dev/null +++ b/packages/admin-ui-components/src/statementDetails/planView/planView.spec.tsx @@ -0,0 +1,405 @@ +import { assert } from "chai"; + +import { cockroach } from "@cockroachlabs/crdb-protobuf-client"; +import { + FlatPlanNode, + FlatPlanNodeAttribute, + flattenTree, + flattenAttributes, +} from "./planView"; +import IAttr = cockroach.sql.ExplainTreePlanNode.IAttr; +import IExplainTreePlanNode = cockroach.sql.IExplainTreePlanNode; + +const testAttrs1: IAttr[] = [ + { + key: "key1", + value: "value1", + }, + { + key: "key2", + value: "value2", + }, +]; + +const testAttrs2: IAttr[] = [ + { + key: "key3", + value: "value3", + }, + { + key: "key4", + value: "value4", + }, +]; + +const testFlatAttrs1: FlatPlanNodeAttribute[] = [ + { + key: "key1", + values: ["value1"], + warn: false, + }, + { + key: "key2", + values: ["value2"], + warn: false, + }, +]; + +const testFlatAttrs2: FlatPlanNodeAttribute[] = [ + { + key: "key3", + values: ["value3"], + warn: false, + }, + { + key: "key4", + values: ["value4"], + warn: false, + }, +]; + +const treePlanWithSingleChildPaths: IExplainTreePlanNode = { + name: "root", + attrs: null, + children: [ + { + name: "single_grandparent", + attrs: testAttrs1, + children: [ + { + name: "single_parent", + attrs: null, + children: [ + { + name: "single_child", + attrs: testAttrs2, + children: [], + }, + ], + }, + ], + }, + ], +}; + +const expectedFlatPlanWithSingleChildPaths: FlatPlanNode[] = [ + { + name: "root", + attrs: [], + children: [], + }, + { + name: "single_grandparent", + attrs: testFlatAttrs1, + children: [], + }, + { + name: "single_parent", + attrs: [], + children: [], + }, + { + name: "single_child", + attrs: testFlatAttrs2, + children: [], + }, +]; + +const treePlanWithChildren1: IExplainTreePlanNode = { + name: "root", + attrs: testAttrs1, + children: [ + { + name: "single_grandparent", + attrs: testAttrs1, + children: [ + { + name: "parent_1", + attrs: null, + children: [ + { + name: "single_child", + attrs: testAttrs2, + children: [], + }, + ], + }, + { + name: "parent_2", + attrs: null, + children: [], + }, + ], + }, + ], +}; + +const expectedFlatPlanWithChildren1: FlatPlanNode[] = [ + { + name: "root", + attrs: testFlatAttrs1, + children: [], + }, + { + name: "single_grandparent", + attrs: testFlatAttrs1, + children: [ + [ + { + name: "parent_1", + attrs: [], + children: [], + }, + { + name: "single_child", + attrs: testFlatAttrs2, + children: [], + }, + ], + [ + { + name: "parent_2", + attrs: [], + children: [], + }, + ], + ], + }, +]; + +const treePlanWithChildren2: IExplainTreePlanNode = { + name: "root", + attrs: null, + children: [ + { + name: "single_grandparent", + attrs: null, + children: [ + { + name: "single_parent", + attrs: null, + children: [ + { + name: "child_1", + attrs: testAttrs1, + children: [], + }, + { + name: "child_2", + attrs: testAttrs2, + children: [], + }, + ], + }, + ], + }, + ], +}; + +const expectedFlatPlanWithChildren2: FlatPlanNode[] = [ + { + name: "root", + attrs: [], + children: [], + }, + { + name: "single_grandparent", + attrs: [], + children: [], + }, + { + name: "single_parent", + attrs: [], + children: [ + [ + { + name: "child_1", + attrs: testFlatAttrs1, + children: [], + }, + ], + [ + { + name: "child_2", + attrs: testFlatAttrs2, + children: [], + }, + ], + ], + }, +]; + +const treePlanWithNoChildren: IExplainTreePlanNode = { + name: "root", + attrs: testAttrs1, + children: [], +}; + +const expectedFlatPlanWithNoChildren: FlatPlanNode[] = [ + { + name: "root", + attrs: testFlatAttrs1, + children: [], + }, +]; + +describe("flattenTree", () => { + describe("when node has children", () => { + it("flattens single child paths.", () => { + assert.deepEqual( + flattenTree(treePlanWithSingleChildPaths), + expectedFlatPlanWithSingleChildPaths, + ); + }); + it("increases level if multiple children.", () => { + assert.deepEqual( + flattenTree(treePlanWithChildren1), + expectedFlatPlanWithChildren1, + ); + assert.deepEqual( + flattenTree(treePlanWithChildren2), + expectedFlatPlanWithChildren2, + ); + }); + }); + describe("when node has no children", () => { + it("returns valid flattened plan.", () => { + assert.deepEqual( + flattenTree(treePlanWithNoChildren), + expectedFlatPlanWithNoChildren, + ); + }); + }); +}); + +describe("flattenAttributes", () => { + describe("when all attributes have different keys", () => { + it("creates array with exactly one value for each attribute", () => { + const testAttrs: IAttr[] = [ + { + key: "key1", + value: "value1", + }, + { + key: "key2", + value: "value2", + }, + ]; + const expectedTestAttrs: FlatPlanNodeAttribute[] = [ + { + key: "key1", + values: ["value1"], + warn: false, + }, + { + key: "key2", + values: ["value2"], + warn: false, + }, + ]; + + assert.deepEqual(flattenAttributes(testAttrs), expectedTestAttrs); + }); + }); + describe("when there are multiple attributes with same key", () => { + it("collects values into one array for same key", () => { + const testAttrs: IAttr[] = [ + { + key: "key1", + value: "key1-value1", + }, + { + key: "key2", + value: "key2-value1", + }, + { + key: "key1", + value: "key1-value2", + }, + ]; + const expectedTestAttrs: FlatPlanNodeAttribute[] = [ + { + key: "key1", + values: ["key1-value1", "key1-value2"], + warn: false, + }, + { + key: "key2", + values: ["key2-value1"], + warn: false, + }, + ]; + + assert.deepEqual(flattenAttributes(testAttrs), expectedTestAttrs); + }); + }); + describe("when attribute key/value is `spans FULL SCAN`", () => { + it("sets warn to true", () => { + const testAttrs: IAttr[] = [ + { + key: "foo", + value: "bar", + }, + { + key: "spans", + value: "FULL SCAN", + }, + ]; + const expectedTestAttrs: FlatPlanNodeAttribute[] = [ + { + key: "foo", + values: ["bar"], + warn: false, + }, + { + key: "spans", + values: ["FULL SCAN"], + warn: true, + }, + ]; + + assert.deepEqual(flattenAttributes(testAttrs), expectedTestAttrs); + }); + }); + describe("when keys are unsorted", () => { + it("puts table key first, and sorts remaining keys alphabetically", () => { + const testAttrs: IAttr[] = [ + { + key: "zebra", + value: "foo", + }, + { + key: "table", + value: "foo", + }, + { + key: "cheetah", + value: "foo", + }, + { + key: "table", + value: "bar", + }, + ]; + const expectedTestAttrs: FlatPlanNodeAttribute[] = [ + { + key: "table", + values: ["foo", "bar"], + warn: false, + }, + { + key: "cheetah", + values: ["foo"], + warn: false, + }, + { + key: "zebra", + values: ["foo"], + warn: false, + }, + ]; + + assert.deepEqual(flattenAttributes(testAttrs), expectedTestAttrs); + }); + }); +}); diff --git a/packages/admin-ui-components/src/statementDetails/planView/planView.stories.tsx b/packages/admin-ui-components/src/statementDetails/planView/planView.stories.tsx new file mode 100644 index 000000000..bf7df470f --- /dev/null +++ b/packages/admin-ui-components/src/statementDetails/planView/planView.stories.tsx @@ -0,0 +1,8 @@ +import React from "react"; +import { storiesOf } from "@storybook/react"; +import { PlanView } from "./planView"; +import { logicalPlan } from "./planView.fixtures"; + +storiesOf("PlanView", module).add("default", () => ( + +)); diff --git a/packages/admin-ui-components/src/statementDetails/planView/planView.tsx b/packages/admin-ui-components/src/statementDetails/planView/planView.tsx new file mode 100644 index 000000000..eede2a80c --- /dev/null +++ b/packages/admin-ui-components/src/statementDetails/planView/planView.tsx @@ -0,0 +1,360 @@ +import _ from "lodash"; +import React, { Fragment } from "react"; +import classNames from "classnames/bind"; +import { cockroach } from "@cockroachlabs/crdb-protobuf-client"; +import { Tooltip } from "src/tooltip"; +import styles from "./planView.module.scss"; + +type IAttr = cockroach.sql.ExplainTreePlanNode.IAttr; +type IExplainTreePlanNode = cockroach.sql.IExplainTreePlanNode; + +const cx = classNames.bind(styles); + +const WARNING_ICON = ( + + + +); +const NODE_ICON = ; + +// FlatPlanNodeAttribute contains a flattened representation of IAttr[]. +export interface FlatPlanNodeAttribute { + key: string; + values: string[]; + warn: boolean; +} + +// FlatPlanNode contains details for the flattened representation of +// IExplainTreePlanNode. +// +// Note that the function that flattens IExplainTreePlanNode returns +// an array of FlatPlanNode (not a single FlatPlanNode). E.g.: +// +// flattenTree(IExplainTreePlanNode) => FlatPlanNode[] +// +export interface FlatPlanNode { + name: string; + attrs: FlatPlanNodeAttribute[]; + children: FlatPlanNode[][]; +} + +function warnForAttribute(attr: IAttr): boolean { + // TODO(yuzefovich): 'spans ALL' is pre-20.1 attribute (and it might show up + // during an upgrade), so we should remove the check for it after 20.2 + // release. + if ( + attr.key === "spans" && + (attr.value === "FULL SCAN" || attr.value === "ALL") + ) { + return true; + } + return false; +} + +// flattenAttributes takes a list of attrs (IAttr[]) and collapses +// all the values for the same key (FlatPlanNodeAttribute). For example, +// if attrs was: +// +// attrs: IAttr[] = [ +// { +// key: "render", +// value: "name", +// }, +// { +// key: "render", +// value: "title", +// }, +// ]; +// +// The returned FlatPlanNodeAttribute would be: +// +// flattenedAttr: FlatPlanNodeAttribute = { +// key: "render", +// value: ["name", "title"], +// }; +// +export function flattenAttributes( + attrs: IAttr[] | null, +): FlatPlanNodeAttribute[] { + if (attrs === null) { + return []; + } + const flattenedAttrsMap: { [key: string]: FlatPlanNodeAttribute } = {}; + attrs.forEach(attr => { + const existingAttr = flattenedAttrsMap[attr.key]; + const warn = warnForAttribute(attr); + if (!existingAttr) { + flattenedAttrsMap[attr.key] = { + key: attr.key, + values: [attr.value], + warn: warn, + }; + } else { + existingAttr.values.push(attr.value); + if (warn) { + existingAttr.warn = true; + } + } + }); + const flattenedAttrs = _.values(flattenedAttrsMap); + return _.sortBy(flattenedAttrs, attr => + attr.key === "table" ? "table" : "z" + attr.key, + ); +} + +/* ************************* HELPER FUNCTIONS ************************* */ + +// flattenTree takes a tree representation of a logical plan +// (IExplainTreePlanNode) and flattens any single child paths. +// For example, if an IExplainTreePlanNode was visually displayed +// as: +// +// root +// | +// |___ single_grandparent +// | +// |____ parent_1 +// | | +// | |______ single_child +// | +// |____ parent_2 +// +// Then its FlatPlanNode[] equivalent would be visually displayed +// as: +// +// root +// | +// single_grandparent +// | +// |____ parent_1 +// | | +// | single_child +// | +// |____ parent_2 +// +export function flattenTree(treePlan: IExplainTreePlanNode): FlatPlanNode[] { + const flattenedPlan: FlatPlanNode[] = [ + { + name: treePlan.name, + attrs: flattenAttributes(treePlan.attrs), + children: [], + }, + ]; + + if (treePlan.children.length === 0) { + return flattenedPlan; + } + const flattenedChildren = treePlan.children.map(child => flattenTree(child)); + if (treePlan.children.length === 1) { + // Append single child into same list that contains parent node. + flattenedPlan.push(...flattenedChildren[0]); + } else { + // Only add to children property if there are multiple children. + flattenedPlan[0].children = flattenedChildren; + } + return flattenedPlan; +} + +// shouldHideNode looks at node name to determine whether we should hide +// node from logical plan tree. +// +// Currently we're hiding `row source to planNode`, which is a node +// generated during execution (e.g. this is an internal implementation +// detail that will add more confusion than help to user). See #34594 +// for details. +function shouldHideNode(nodeName: string): boolean { + return nodeName === "row source to plan node"; +} + +/* ************************* PLAN NODES ************************* */ + +interface PlanNodeDetailProps { + node: FlatPlanNode; +} + +class PlanNodeDetails extends React.Component { + constructor(props: PlanNodeDetailProps) { + super(props); + } + + renderAttributeValues(values: string[]) { + if (!values.length || !values[0].length) { + return; + } + if (values.length === 1) { + return = {values[0]}; + } + return = [{values.join(", ")}]; + } + + renderAttribute(attr: FlatPlanNodeAttribute) { + let attrClassName = ""; + let keyClassName = "nodeAttributeKey"; + if (attr.warn) { + attrClassName = "warn"; + keyClassName = ""; + } + return ( +
+ {attr.warn && WARNING_ICON} + {attr.key} + {this.renderAttributeValues(attr.values)} +
+ ); + } + + renderNodeDetails() { + const node = this.props.node; + if (node.attrs && node.attrs.length > 0) { + return ( +
+ {node.attrs.map(attr => this.renderAttribute(attr))} +
+ ); + } + } + + render() { + const node = this.props.node; + return ( +
+ {NODE_ICON} {_.capitalize(node.name)} + {this.renderNodeDetails()} +
+ ); + } +} + +function PlanNodes(props: { nodes: FlatPlanNode[] }): React.ReactElement<{}> { + const nodes = props.nodes; + return ( +
    + {nodes.map((node, idx) => { + return ; + })} +
+ ); +} + +interface PlanNodeProps { + node: FlatPlanNode; +} + +class PlanNode extends React.Component { + render() { + if (shouldHideNode(this.props.node.name)) { + return null; + } + const node = this.props.node; + return ( +
  • + + {node.children && + node.children.map((child, idx) => ( + + ))} +
  • + ); + } +} + +interface PlanViewProps { + title: string; + plan: IExplainTreePlanNode; +} + +interface PlanViewState { + expanded: boolean; + showExpandDirections: boolean; +} + +export class PlanView extends React.Component { + private innerContainer: React.RefObject; + constructor(props: PlanViewProps) { + super(props); + this.state = { + expanded: false, + showExpandDirections: true, + }; + this.innerContainer = React.createRef(); + } + + toggleExpanded = () => { + this.setState(state => ({ + expanded: !state.expanded, + })); + }; + + showExpandDirections() { + // Only show directions to show/hide the full plan if content is longer than its max-height. + const containerObj = this.innerContainer.current; + return containerObj.scrollHeight > containerObj.clientHeight; + } + + componentDidMount() { + this.setState({ showExpandDirections: this.showExpandDirections() }); + } + + render() { + const flattenedPlanNodes = flattenTree(this.props.plan); + + const lastSampledHelpText = ( + + If the time from the last sample is greater than 5 minutes, a new plan + will be sampled. This frequency can be configured with the cluster + setting{" "} + +
    +            sql.metrics.statement_details.plan_collection.period
    +          
    +
    + . +
    + ); + + return ( +
    + + + + + + + + + + +
    +

    + {this.props.title} +

    +
    + +
    +
    i
    +
    +
    +
    +
    +
    +
    + +
    +
    +
    + ); + } +} diff --git a/packages/admin-ui-components/src/statementDetails/statementDetails.fixture.ts b/packages/admin-ui-components/src/statementDetails/statementDetails.fixture.ts new file mode 100644 index 000000000..244e27f2b --- /dev/null +++ b/packages/admin-ui-components/src/statementDetails/statementDetails.fixture.ts @@ -0,0 +1,156 @@ +import Long from "long"; +import { createMemoryHistory } from "history"; +import { noop } from "lodash"; +import { StatementDetailsProps } from "./statementDetails"; + +const history = createMemoryHistory({ initialEntries: ["/statements"] }); + +const statementStats: any = { + count: Long.fromNumber(36958), + first_attempt_count: Long.fromNumber(36958), + max_retries: Long.fromNumber(0), + num_rows: { + mean: 11.651577466313078, + squared_diffs: 1493154.3630337175, + }, + parse_lat: { + mean: 0, + squared_diffs: 0, + }, + plan_lat: { + mean: 0.00022804377942529385, + squared_diffs: 0.0030062544511648935, + }, + run_lat: { + mean: 0.00098355830943233, + squared_diffs: 0.04090499253784317, + }, + service_lat: { + mean: 0.0013101634016992284, + squared_diffs: 0.055668241814216965, + }, + overhead_lat: { + mean: 0.00009856131284160407, + squared_diffs: 0.0017520019405651047, + }, + bytes_read: { + mean: 4160407, + squared_diffs: 47880000000000, + }, + rows_read: { + mean: 7, + squared_diffs: 1000000, + }, + sensitive_info: { + last_err: "", + most_recent_plan_description: { + name: "render", + attrs: [ + { + key: "render", + value: "city", + }, + { + key: "render", + value: "id", + }, + ], + children: [ + { + name: "scan", + attrs: [ + { + key: "table", + value: "vehicles@vehicles_auto_index_fk_city_ref_users", + }, + { + key: "spans", + value: "1 span", + }, + ], + children: [], + }, + ], + }, + }, +}; + +export const getStatementDetailsPropsFixture = (): StatementDetailsProps => ({ + history, + location: { + pathname: + "/statement/true/SELECT city%2C id FROM vehicles WHERE city %3D %241", + search: "", + hash: "", + state: null, + }, + match: { + path: "/statement/:implicitTxn/:statement", + url: "/statement/true/SELECT city%2C id FROM vehicles WHERE city %3D %241", + isExact: true, + params: { + implicitTxn: "true", + statement: "SELECT city%2C id FROM vehicles WHERE city %3D %241", + }, + }, + statement: { + statement: "SELECT city, id FROM vehicles WHERE city = $1", + stats: statementStats, + byNode: [ + { + label: "4", + implicitTxn: true, + stats: statementStats, + }, + { + label: "3", + implicitTxn: true, + stats: statementStats, + }, + { + label: "2", + implicitTxn: true, + stats: statementStats, + }, + { + label: "1", + implicitTxn: true, + stats: statementStats, + }, + ], + app: ["movr"], + distSQL: { + numerator: 0, + denominator: 36958, + }, + vec: { + numerator: 36958, + denominator: 36958, + }, + opt: { + numerator: 36958, + denominator: 36958, + }, + implicit_txn: { + numerator: 36958, + denominator: 36958, + }, + failed: { + numerator: 0, + denominator: 36958, + }, + node_id: [4, 3, 2, 1], + }, + statementsError: null, + nodeNames: { + "1": "127.0.0.1:55529 (n1)", + "2": "127.0.0.1:55532 (n2)", + "3": "127.0.0.1:55538 (n3)", + "4": "127.0.0.1:55546 (n4)", + }, + refreshStatements: noop, + refreshStatementDiagnosticsRequests: noop, + diagnosticsReports: [], + dismissStatementDiagnosticsAlertMessage: noop, + createStatementDiagnosticsReport: noop, +}); diff --git a/packages/admin-ui-components/src/statementDetails/statementDetails.module.scss b/packages/admin-ui-components/src/statementDetails/statementDetails.module.scss new file mode 100644 index 000000000..744d95360 --- /dev/null +++ b/packages/admin-ui-components/src/statementDetails/statementDetails.module.scss @@ -0,0 +1,145 @@ +@import "src/core/index.module"; + +$max-window-width: 1350px; + +.app-name { + white-space: nowrap; + + &__unset { + color: $tooltip-color; + font-style: italic; + } +} + +.section { + flex: 0 0 auto; + padding: 12px 24px; + max-width: $max-window-width; + + &--heading { + padding-top: 0; + padding-bottom: 0; + } + + &--container { + padding: 0 24px 0 0; + } +} + +.page--header { + padding: 0; + + &__title { + font-family: $font-family--base; + font-size: 20px; + line-height: 1.6; + letter-spacing: -0.2px; + color: $colors--neutral-8; + margin-bottom: 25px; + } +} + +h1.base-heading { + font-size: 24px; + font-family: $font-family--base; +} + +h2.base-heading { + padding: 12px 0; + font-size: 24px; + font-family: $font-family--base +} + +.back-link { + text-decoration: none; + color: $link-color; +} + +.cockroach--tabs { + :global(.ant-tabs-bar) { + border-bottom: 1px solid $grey2; + } + + :global(.ant-tabs-tab) { + font-family: $font-family--base; + font-size: 16px; + line-height: 1.5; + letter-spacing: normal; + color: $placeholder; + + &:hover { + color: $adminui-grey-1; + } + } + + :global(.ant-tabs-tab-active) { + color: $adminui-grey-1; + } + + :global(.ant-tabs-ink-bar) { + height: 3px; + border-radius: 40px; + background-color: $blue; + } +} + +.table-details { + .summary--card__counting { + margin-bottom: 15px; + + &--value { + margin: 0; + color: $colors--neutral-8; + line-height: 32px; + } + + &--label { + font-size: 14px; + line-height: 22px; + letter-spacing: 0.1px; + } + } +} + +.last-cleared-tooltip, .numeric-stats-table, .plan-view-table { + &__tooltip { + width: 36px; + height: 16px; + display: inline-block; + + + text-transform: none; + font-weight: normal; + white-space: normal; + letter-spacing: normal; + font-size: 14px; + } + + &__tooltip-hover-area { + width: 100%; + padding: 0px 10px; + } + + &__info-icon { + width: 16px; + height: 16px; + border-radius: 50%; + border: 1px solid $tooltip-color; + font-size: 12px; + line-height: 14px; + text-align: center; + color: $tooltip-color; + } + + .hover-tooltip--hovered &__info-icon { + border-color: $body-color; + color: $body-color; + } +} + +.statements-table { + &__col-query-text { + font-family: $font-family--monospace; + white-space: pre-wrap; + } +} diff --git a/packages/admin-ui-components/src/statementDetails/statementDetails.spec.tsx b/packages/admin-ui-components/src/statementDetails/statementDetails.spec.tsx new file mode 100644 index 000000000..1c3d2cb0d --- /dev/null +++ b/packages/admin-ui-components/src/statementDetails/statementDetails.spec.tsx @@ -0,0 +1,109 @@ +import React from "react"; +import { mount } from "enzyme"; +import { assert } from "chai"; +import { createSandbox } from "sinon"; +import { MemoryRouter as Router } from "react-router-dom"; +import { StatementDetails, StatementDetailsProps } from "./statementDetails"; +import { DiagnosticsView } from "./diagnostics/diagnosticsView"; +import { getStatementDetailsPropsFixture } from "./statementDetails.fixture"; +import { Loading } from "../loading"; + +const sandbox = createSandbox(); + +describe("StatementDetails page", () => { + let statementDetailsProps: StatementDetailsProps; + + beforeEach(() => { + sandbox.reset(); + statementDetailsProps = getStatementDetailsPropsFixture(); + }); + + it("shows loading indicator when data is not ready yet", () => { + statementDetailsProps.statement = null; + statementDetailsProps.statementsError = null; + + const wrapper = mount( + + + , + ); + assert.isTrue(wrapper.find(Loading).prop("loading")); + assert.isFalse( + wrapper + .find(StatementDetails) + .find("div.ant-tabs-tab") + .exists(), + ); + }); + + it("shows error alert when `lastError` is not null", () => { + statementDetailsProps.statementsError = new Error("Something went wrong"); + + const wrapper = mount( + + + , + ); + assert.isNotNull(wrapper.find(Loading).prop("error")); + assert.isFalse( + wrapper + .find(StatementDetails) + .find("div.ant-tabs-tab") + .exists(), + ); + }); + + it("calls onTabChanged prop when selected tab is changed", () => { + const onTabChangeSpy = sandbox.spy(); + const wrapper = mount( + + + , + ); + + wrapper + .find(StatementDetails) + .find("div.ant-tabs-tab") + .last() + .simulate("click"); + + onTabChangeSpy.calledWith("execution-stats"); + }); + + describe("Diagnostics tab", () => { + beforeEach(() => { + statementDetailsProps.history.location.search = new URLSearchParams([ + ["tab", "diagnostics"], + ]).toString(); + }); + + it("calls createStatementDiagnosticsReport callback on Activate button click", () => { + const onDiagnosticsActivateClickSpy = sandbox.spy(); + const wrapper = mount( + + + , + ); + + wrapper + .find(DiagnosticsView) + .findWhere( + n => + n.name() === "Button" && + n.prop("children") === "Activate diagnostics", + ) + .first() + .simulate("click"); + + onDiagnosticsActivateClickSpy.calledOnceWith( + statementDetailsProps.statement.statement, + ); + }); + }); +}); diff --git a/packages/admin-ui-components/src/statementDetails/statementDetails.stories.tsx b/packages/admin-ui-components/src/statementDetails/statementDetails.stories.tsx new file mode 100644 index 000000000..e792068e0 --- /dev/null +++ b/packages/admin-ui-components/src/statementDetails/statementDetails.stories.tsx @@ -0,0 +1,66 @@ +import React from "react"; +import { storiesOf } from "@storybook/react"; +import { createMemoryHistory } from "history"; + +import { StatementDetails } from "./statementDetails"; +import { getStatementDetailsPropsFixture } from "./statementDetails.fixture"; +import { + ConnectedRouter, + connectRouter, + routerMiddleware, +} from "connected-react-router"; +import { + applyMiddleware, + combineReducers, + compose, + createStore, + Store, +} from "redux"; +import { AppState, rootReducer } from "../store"; +import { Provider } from "react-redux"; + +const history = createMemoryHistory(); +const routerReducer = connectRouter(history); + +const store: Store = createStore( + combineReducers({ + router: routerReducer, + adminUI: rootReducer, + }), + compose( + applyMiddleware(routerMiddleware(history)), + (window as any).__REDUX_DEVTOOLS_EXTENSION__ && + (window as any).__REDUX_DEVTOOLS_EXTENSION__(), + ), +); + +storiesOf("StatementDetails", module) + .addDecorator(storyFn => ( + + {storyFn()} + + )) + .add("Overview tab", () => ( + + )) + .add("Diagnostics tab", () => { + const props = getStatementDetailsPropsFixture(); + props.history.location.search = new URLSearchParams([ + ["tab", "diagnostics"], + ]).toString(); + return ; + }) + .add("Logical Plan tab", () => { + const props = getStatementDetailsPropsFixture(); + props.history.location.search = new URLSearchParams([ + ["tab", "logical-plan"], + ]).toString(); + return ; + }) + .add("Execution Stats tab", () => { + const props = getStatementDetailsPropsFixture(); + props.history.location.search = new URLSearchParams([ + ["tab", "execution-stats"], + ]).toString(); + return ; + }); diff --git a/packages/admin-ui-components/src/statementDetails/statementDetails.tsx b/packages/admin-ui-components/src/statementDetails/statementDetails.tsx new file mode 100644 index 000000000..1103664e3 --- /dev/null +++ b/packages/admin-ui-components/src/statementDetails/statementDetails.tsx @@ -0,0 +1,843 @@ +import { Col, Row, Tabs } from "antd"; +import _ from "lodash"; +import React, { ReactNode } from "react"; +import { Helmet } from "react-helmet"; +import { Link, RouteComponentProps } from "react-router-dom"; +import classNames from "classnames/bind"; +import { format as d3Format } from "d3-format"; +import { ArrowLeft } from "@cockroachlabs/icons"; +import { cockroach } from "@cockroachlabs/crdb-protobuf-client"; + +import { + intersperse, + Bytes, + Duration, + FixLong, + appAttr, + NumericStat, + StatementStatistics, + stdDev, + getMatchParamByName, + formatNumberForDisplay, +} from "src/util"; +import { Loading, Button, SqlBox, SortSetting } from "src"; +import { Tooltip } from "src/tooltip"; +import { PlanView } from "./planView"; +import { SummaryCard } from "src/summaryCard"; +import { + approximify, + latencyBreakdown, + genericBarChart, + longToInt, + rowsBreakdown, +} from "src/barCharts"; +import { + AggregateStatistics, + makeNodesColumns, + StatementsSortedTable, +} from "src/statementsTable"; +import { DiagnosticsView } from "./diagnostics/diagnosticsView"; +import sortedTableStyles from "src/sortedtable/sortedtable.module.scss"; +import summaryCardStyles from "src/summaryCard/summaryCard.module.scss"; +import styles from "./statementDetails.module.scss"; +import { NodeSummaryStats } from "../nodes"; + +const { TabPane } = Tabs; + +export interface Fraction { + numerator: number; + denominator: number; +} + +interface SingleStatementStatistics { + statement: string; + app: string[]; + distSQL: Fraction; + vec: Fraction; + opt: Fraction; + implicit_txn: Fraction; + failed: Fraction; + node_id: number[]; + stats: StatementStatistics; + byNode: AggregateStatistics[]; +} + +export type StatementDetailsProps = StatementDetailsOwnProps & + RouteComponentProps; + +export interface StatementDetailsState { + sortSetting: SortSetting; + currentTab?: string; +} + +interface NumericStatRow { + name: string; + value: NumericStat; + bar?: () => ReactNode; + summary?: boolean; + // You can override the table's formatter on a per-row basis with this format + // method. + format?: (v: number) => string; +} + +interface NumericStatTableProps { + title?: string; + description?: string; + measure: string; + rows: NumericStatRow[]; + count: number; + format?: (v: number) => string; +} + +export type NodesSummary = { + nodeStatuses: cockroach.server.status.statuspb.INodeStatus[]; + nodeIDs: string[]; + nodeStatusByID: Dictionary; + nodeSums: NodeSummaryStats; + nodeDisplayNameByID: Dictionary; + livenessStatusByNodeID: Dictionary< + cockroach.kv.kvserver.liveness.livenesspb.NodeLivenessStatus + >; + livenessByNodeID: Dictionary< + cockroach.kv.kvserver.liveness.livenesspb.ILiveness + >; + storeIDsByNodeID: Dictionary; +}; + +export interface StatementDetailsDispatchProps { + refreshStatements: () => void; + refreshStatementDiagnosticsRequests: () => void; + createStatementDiagnosticsReport: (statementFingerprint: string) => void; + dismissStatementDiagnosticsAlertMessage?: () => void; + onTabChanged?: (tabName: string) => void; + onDiagnosticBundleDownload?: (statementFingerprint: string) => void; +} + +export interface StatementDetailsStateProps { + statement: SingleStatementStatistics; + statementsError: Error | null; + nodeNames: { [nodeId: string]: string }; + diagnosticsReports: cockroach.server.serverpb.IStatementDiagnosticsReport[]; +} + +export type StatementDetailsOwnProps = StatementDetailsDispatchProps & + StatementDetailsStateProps; + +const cx = classNames.bind(styles); +const sortableTableCx = classNames.bind(sortedTableStyles); +const summaryCardStylesCx = classNames.bind(summaryCardStyles); + +function AppLink(props: { app: string }) { + if (!props.app) { + return (unset); + } + + return ( + + {props.app} + + ); +} + +function renderTransactionType(implicitTxn: Fraction) { + if (Number.isNaN(implicitTxn.numerator)) { + return "(unknown)"; + } + if (implicitTxn.numerator === 0) { + return "Explicit"; + } + if (implicitTxn.numerator === implicitTxn.denominator) { + return "Implicit"; + } + const fraction = + approximify(implicitTxn.numerator) + + " of " + + approximify(implicitTxn.denominator); + return `${fraction} were Implicit Txns`; +} + +function renderBools(fraction: Fraction) { + if (Number.isNaN(fraction.numerator)) { + return "(unknown)"; + } + if (fraction.numerator === 0) { + return "No"; + } + if (fraction.numerator === fraction.denominator) { + return "Yes"; + } + return ( + approximify(fraction.numerator) + " of " + approximify(fraction.denominator) + ); +} + +class NumericStatTable extends React.Component { + static defaultProps = { + format: (v: number) => `${v}`, + }; + + render() { + const { rows } = this.props; + return ( + + + + + + + + + + {rows.map((row: NumericStatRow, idx) => { + let { format } = this.props; + if (row.format) { + format = row.format; + } + const className = sortableTableCx( + "sort-table__row", + "sort-table__row--body", + { + "sort-table__row--summary": row.summary, + }, + ); + return ( + + + + + + ); + })} + +
    + {this.props.title} + + Mean {this.props.measure} + + Standard Deviation +
    + {row.name} + + {row.bar ? row.bar() : null} + + {format(stdDev(row.value, this.props.count))} +
    + ); + } +} + +export class StatementDetails extends React.Component< + StatementDetailsProps, + StatementDetailsState +> { + constructor(props: StatementDetailsProps) { + super(props); + const searchParams = new URLSearchParams(props.history.location.search); + this.state = { + sortSetting: { + sortKey: 5, // Latency + ascending: false, + }, + currentTab: searchParams.get("tab") || "overview", + }; + } + + static defaultProps: Partial = { + onDiagnosticBundleDownload: _.noop, + }; + + changeSortSetting = (ss: SortSetting) => { + this.setState({ + sortSetting: ss, + }); + }; + + componentDidMount() { + this.props.refreshStatements(); + this.props.refreshStatementDiagnosticsRequests(); + } + + componentDidUpdate() { + this.props.refreshStatements(); + this.props.refreshStatementDiagnosticsRequests(); + } + + onTabChange = (tabId: string) => { + const { history } = this.props; + const searchParams = new URLSearchParams(history.location.search); + searchParams.set("tab", tabId); + history.replace({ + ...history.location, + search: searchParams.toString(), + }); + this.setState({ + currentTab: tabId, + }); + this.props.onTabChanged && this.props.onTabChanged(tabId); + }; + + render() { + const app = getMatchParamByName(this.props.match, appAttr); + return ( +
    + +
    + +

    + Statement Details +

    +
    +
    + +
    +
    + ); + } + + renderContent = () => { + const { + createStatementDiagnosticsReport, + diagnosticsReports, + dismissStatementDiagnosticsAlertMessage, + onDiagnosticBundleDownload, + } = this.props; + const { currentTab } = this.state; + + if (!this.props.statement) { + return null; + } + const { + stats, + statement, + app, + distSQL, + vec, + opt, + failed, + implicit_txn, + } = this.props.statement; + + if (!stats) { + const sourceApp = getMatchParamByName(this.props.match, appAttr); + const listUrl = "/statements" + (sourceApp ? "/" + sourceApp : ""); + + return ( + +
    + +
    +
    +

    Unable to find statement

    + There are no execution statistics for this statement.{" "} + + Back to Statements + +
    +
    + ); + } + + const count = FixLong(stats.count).toInt(); + + const { rowsBarChart } = rowsBreakdown(this.props.statement); + const { + parseBarChart, + planBarChart, + runBarChart, + overheadBarChart, + overallBarChart, + } = latencyBreakdown(this.props.statement); + + const totalCountBarChart = longToInt(this.props.statement.stats.count); + const firstAttemptsBarChart = longToInt( + this.props.statement.stats.first_attempt_count, + ); + const retriesBarChart = totalCountBarChart - firstAttemptsBarChart; + const maxRetriesBarChart = longToInt( + this.props.statement.stats.max_retries, + ); + + const statsByNode = this.props.statement.byNode; + const logicalPlan = + stats.sensitive_info && stats.sensitive_info.most_recent_plan_description; + const duration = (v: number) => Duration(v * 1e9); + const hasDiagnosticReports = diagnosticsReports.length > 0; + return ( + + + + + + + + + + +
    +

    + {formatNumberForDisplay( + count * stats.service_lat.mean, + duration, + )} +

    +

    + Total Time +

    +
    + + +
    +

    + {formatNumberForDisplay( + stats.service_lat.mean, + duration, + )} +

    +

    + Mean Service Latency +

    +
    + +
    +

    +

    +

    + App: +

    +

    + {intersperse( + app.map(a => ), + ", ", + )} +

    +
    +
    +

    + Transaction Type +

    +

    + {renderTransactionType(implicit_txn)} +

    +
    +
    +

    + Distributed execution? +

    +

    + {renderBools(distSQL)} +

    +
    +
    +

    + Vectorized execution? +

    +

    + {renderBools(vec)} +

    +
    +
    +

    + Used cost-based optimizer? +

    +

    + {renderBools(opt)} +

    +
    +
    +

    + Failed? +

    +

    + {renderBools(failed)} +

    +
    +
    + +

    + Execution Count +

    +
    +

    + First Attempts +

    +

    + {firstAttemptsBarChart} +

    +
    +
    +

    + Retries +

    +

    0, + }, + )} + > + {retriesBarChart} +

    +
    +
    +

    + Max Retries +

    +

    0, + }, + )} + > + {maxRetriesBarChart} +

    +
    +
    +

    + Total +

    +

    + {totalCountBarChart} +

    +
    +

    +

    + Rows Affected +

    +
    +

    + Mean Rows +

    +

    + {rowsBarChart(true)} +

    +
    +
    +

    + Standard Deviation +

    +

    + {rowsBarChart()} +

    +
    +
    + +
    +
    + + + + + + + + + + +

    + Execution Latency By Phase +
    + +
    +
    + i +
    +
    +
    +
    +

    + Duration(v * 1e9)} + rows={[ + { name: "Parse", value: stats.parse_lat, bar: parseBarChart }, + { name: "Plan", value: stats.plan_lat, bar: planBarChart }, + { name: "Run", value: stats.run_lat, bar: runBarChart }, + { + name: "Overhead", + value: stats.overhead_lat, + bar: overheadBarChart, + }, + { + name: "Overall", + summary: true, + value: stats.service_lat, + bar: overallBarChart, + }, + ]} + /> +
    + +

    + Other Execution Statistics +

    + +
    + +

    + Stats By Node +
    + +
    +
    + i +
    +
    +
    +
    +

    + +
    +
    +
    + ); + }; +} diff --git a/packages/admin-ui-components/src/statementsPage/statementsPageConnected.stories.tsx b/packages/admin-ui-components/src/statementsPage/statementsPageConnected.stories.tsx index 74eecc8f3..c85af3fad 100644 --- a/packages/admin-ui-components/src/statementsPage/statementsPageConnected.stories.tsx +++ b/packages/admin-ui-components/src/statementsPage/statementsPageConnected.stories.tsx @@ -16,8 +16,7 @@ import { Store, } from "redux"; import { ConnectedStatementsPage } from "./statementsPageConnected"; -import { statementsSaga } from "src/store/statements"; -import { AppState, rootReducer } from "src/store"; +import { AppState, rootReducer, sagas } from "src/store"; const history = createMemoryHistory(); const routerReducer = connectRouter(history); @@ -35,7 +34,7 @@ const store: Store = createStore( ), ); -sagaMiddleware.run(statementsSaga); +sagaMiddleware.run(sagas); storiesOf("statementsPageConnected", module) .addDecorator(storyFn => ( diff --git a/packages/admin-ui-components/src/statementsTable/index.ts b/packages/admin-ui-components/src/statementsTable/index.ts index 55f4109f0..44188b8e3 100644 --- a/packages/admin-ui-components/src/statementsTable/index.ts +++ b/packages/admin-ui-components/src/statementsTable/index.ts @@ -1 +1,3 @@ export * from "./statementsTable"; +export * from "./statementsTableContent"; +export * from "./statementsTable"; diff --git a/packages/admin-ui-components/src/statementsTable/statementsTableContent.tsx b/packages/admin-ui-components/src/statementsTable/statementsTableContent.tsx index b7de9249a..8f41eda79 100644 --- a/packages/admin-ui-components/src/statementsTable/statementsTableContent.tsx +++ b/packages/admin-ui-components/src/statementsTable/statementsTableContent.tsx @@ -95,8 +95,7 @@ export const StatementTableTitle = { { " for each statement. If activated, this displays the status of diagnostics collection (" } - WAITING FOR QUERY, READY, OR{" "} - ERROR). + WAITING, READY, OR ERROR).

    } @@ -249,7 +248,7 @@ export const StatementTableCell = { name: ( {`${TimestampToMoment(dr.requested_at).format( "ll [at] LT [diagnostic]", diff --git a/packages/admin-ui-components/src/summaryCard/index.tsx b/packages/admin-ui-components/src/summaryCard/index.tsx index c3a125bca..8c617a7f2 100644 --- a/packages/admin-ui-components/src/summaryCard/index.tsx +++ b/packages/admin-ui-components/src/summaryCard/index.tsx @@ -1,13 +1,3 @@ -// Copyright 2018 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 "./summaryCard.module.scss"; diff --git a/packages/admin-ui-components/src/summaryCard/summaryCard.module.scss b/packages/admin-ui-components/src/summaryCard/summaryCard.module.scss index b9625aa24..840470189 100644 --- a/packages/admin-ui-components/src/summaryCard/summaryCard.module.scss +++ b/packages/admin-ui-components/src/summaryCard/summaryCard.module.scss @@ -1,14 +1,4 @@ -// Copyright 2018 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 "../core/index.module.scss"; +@import "../core/index.module"; .summary--card { border-radius: 3px; @@ -23,7 +13,7 @@ background: $table-border; } &__title { - font-family: SourceSansPro-Regular; + font-family: $font-family--base; font-size: 20px; line-height: 1.6; letter-spacing: -0.2px; @@ -32,7 +22,7 @@ } &__counting { &--value { - font-family: SourceSansPro-Regular; + font-family: $font-family--base; font-size: 20pt; line-height: 1.6; letter-spacing: -0.2px; @@ -40,7 +30,7 @@ margin-bottom: 15px; } &--label { - font-family: SourceSansPro-Regular; + font-family: $font-family--base; font-size: 14pt; line-height: 1.67; letter-spacing: 0.3px; @@ -53,7 +43,7 @@ justify-content: space-between; margin-bottom: 10px; &--label { - font-family: SourceSansPro-Regular; + font-family: $font-family--base; font-size: 14px; line-height: 1.57; letter-spacing: 0.1px; @@ -62,7 +52,7 @@ margin-bottom: 0; } &--value { - font-family: SourceSansPro-Regular; + font-family: $font-family--base; font-size: 14px; font-weight: 600; line-height: 1.71; diff --git a/packages/admin-ui-components/src/table/index.ts b/packages/admin-ui-components/src/table/index.ts new file mode 100644 index 000000000..0e948df9e --- /dev/null +++ b/packages/admin-ui-components/src/table/index.ts @@ -0,0 +1 @@ +export * from "./table"; diff --git a/packages/admin-ui-components/src/table/table.module.scss b/packages/admin-ui-components/src/table/table.module.scss new file mode 100644 index 000000000..99b454939 --- /dev/null +++ b/packages/admin-ui-components/src/table/table.module.scss @@ -0,0 +1,109 @@ +@import "src/core/index.module"; + +.crl-table-wrapper { + .ant-table { + color: $colors--primary-text; + } + + // Table header + .ant-table-thead { + background-color: $colors--neutral-1; + @include text--heading-6; + } + + .ant-table-thead > tr > th { + padding: $spacing-base $spacing-base; + color: $colors--neutral-6; + font-family: $font-family--semi-bold; + font-weight: $font-weight--bold; + letter-spacing: 1.5px; + + .ant-table-column-sorter { + vertical-align: baseline; + } + } + // END: Table header + + // Table Column + .column--align-right { + text-align: end; + } + // END: Table Column + + // Expand/Collapse icon + .ant-table-row-expand-icon { + border: none; + background-color: transparent; + } + + .ant-table-row-collapsed::after { + content: '▶'; + font-size: 8px; + } + + .ant-table-row-expanded::after { + content: '▼'; + font-size: 8px; + } + // END: Expand/Collapse icon + + // Table row + .ant-table-row { + @include text--body; + } + + .ant-table-row .cell--show-on-hover { + visibility: hidden; + } + + .ant-table-row:hover .cell--show-on-hover { + visibility: visible; + } + // END: Table row + + // Table cell + .ant-table-tbody > tr > td { + padding: $spacing-smaller $spacing-smaller; + border-bottom-color: $colors--neutral-3; + } + + // Increase right padding for columns aligned by right + .ant-table-tbody > tr > td.column--align-right { + padding-right: $spacing-mid-large; + } + + // show column with right border + .ant-table-tbody > tr > td.column--border-right { + border-right: $colors--neutral-3 solid 1px; + } + // END: Table cell + + // Table cell on hover + .ant-table-thead > tr.ant-table-row-hover:not(.ant-table-expanded-row):not(.ant-table-row-selected) > td, + .ant-table-tbody > tr.ant-table-row-hover:not(.ant-table-expanded-row):not(.ant-table-row-selected) > td, + .ant-table-thead > tr:hover:not(.ant-table-expanded-row):not(.ant-table-row-selected) > td, + .ant-table-tbody > tr:hover:not(.ant-table-expanded-row):not(.ant-table-row-selected) > td { + background: $colors--neutral-1; + } + // END: Table cell on hover + + .ant-table-placeholder { + border: $colors--neutral-1 solid 1px; + } + + .empty-table__message { + @include text--body; + text-align: center; + } + + .ant-pagination.ant-table-pagination { + text-align: center; + float: unset; + } + + &__empty { + .ant-table-placeholder { + border: none; + } + } +} diff --git a/packages/admin-ui-components/src/table/table.tsx b/packages/admin-ui-components/src/table/table.tsx new file mode 100644 index 000000000..6ba0f88a5 --- /dev/null +++ b/packages/admin-ui-components/src/table/table.tsx @@ -0,0 +1,49 @@ +import React from "react"; +import { default as AntTable, ColumnProps } from "antd/lib/table"; +import ConfigProvider from "antd/lib/config-provider"; +import classnames from "classnames/bind"; + +import "antd/lib/table/style/css"; +import styles from "./table.module.scss"; + +export type ColumnsConfig = Array>; + +export interface TableProps { + columns: Array>; + dataSource: Array; + noDataMessage?: React.ReactNode; + tableLayout?: "fixed" | "auto"; + pageSize?: number; + className?: string; +} + +const cx = classnames.bind(styles); + +const customizeRenderEmpty = (node: React.ReactNode) => () => ( +
    {node}
    +); + +export function Table(props: TableProps) { + const { + columns, + dataSource, + noDataMessage = "No data to display", + tableLayout = "auto", + pageSize, + className, + } = props; + return ( + + + className={cx(`crl-table-wrapper ${className}`, { + "crl-table-wrapper__empty": dataSource.length === 0, + })} + columns={columns} + dataSource={dataSource} + expandRowByClick + tableLayout={tableLayout} + pagination={{ hideOnSinglePage: true, pageSize }} + /> + + ); +} diff --git a/packages/admin-ui-components/src/test-utils/index.ts b/packages/admin-ui-components/src/test-utils/index.ts new file mode 100644 index 000000000..feae42e02 --- /dev/null +++ b/packages/admin-ui-components/src/test-utils/index.ts @@ -0,0 +1 @@ +export * from "./testStoreProvider"; diff --git a/packages/admin-ui-components/src/test-utils/testStoreProvider.tsx b/packages/admin-ui-components/src/test-utils/testStoreProvider.tsx new file mode 100644 index 000000000..2315abdee --- /dev/null +++ b/packages/admin-ui-components/src/test-utils/testStoreProvider.tsx @@ -0,0 +1,35 @@ +import React from "react"; +import { + Action, + Store, + createStore, + combineReducers, + applyMiddleware, +} from "redux"; +import { Provider } from "react-redux"; +import { + ConnectedRouter, + connectRouter, + routerMiddleware, +} from "connected-react-router"; +import { createMemoryHistory } from "history"; +import { AppState, rootReducer } from "src/store"; + +export const TestStoreProvider: React.FC = ({ children }) => { + const history = createMemoryHistory({ + initialEntries: ["/"], + }); + const routerReducer = connectRouter(history); + const store: Store = createStore( + combineReducers({ + adminUI: rootReducer, + router: routerReducer, + }), + applyMiddleware(routerMiddleware(history)), + ); + return ( + + {children} + + ); +}; diff --git a/packages/admin-ui-components/src/util/find.ts b/packages/admin-ui-components/src/util/find.ts new file mode 100644 index 000000000..729f73e93 --- /dev/null +++ b/packages/admin-ui-components/src/util/find.ts @@ -0,0 +1,39 @@ +import React from "react"; + +/** + * Predicate function to determine if a react child is a ReactElement. + */ +function isReactElement( + child: React.ReactNode, +): child is React.ReactElement { + return (child as React.ReactElement).type !== undefined; +} + +/** + * findChildrenOfType performs a DFS of the supplied React children collection, + * returning all children which are ReactElements of the supplied type. + */ +export function findChildrenOfType

    ( + children: React.ReactNode, + type: string | React.ComponentClass

    | React.FC

    , +): React.ReactElement

    [] { + const matchingChildren: React.ReactElement

    [] = []; + const childrenToSearch = React.Children.toArray(children); + while (childrenToSearch.length > 0) { + const child: React.ReactNode = childrenToSearch.shift(); + if (!isReactElement(child)) { + continue; + } else { + if (child.type === type) { + matchingChildren.push(child); + } + const { props } = child; + if (props.children) { + // Children added to front of search array for DFS. + childrenToSearch.unshift(...React.Children.toArray(props.children)); + } + } + } + + return matchingChildren; +} diff --git a/packages/admin-ui-components/src/util/formatNumber.ts b/packages/admin-ui-components/src/util/formatNumber.ts new file mode 100644 index 000000000..99312b95a --- /dev/null +++ b/packages/admin-ui-components/src/util/formatNumber.ts @@ -0,0 +1,15 @@ +import { isNumber } from "lodash"; + +function numberToString(n: number) { + return n.toString(); +} + +export function formatNumberForDisplay( + value: number, + format: (n: number) => string = numberToString, +) { + if (!isNumber(value)) { + return "-"; + } + return format(value); +} diff --git a/packages/admin-ui-components/src/util/index.ts b/packages/admin-ui-components/src/util/index.ts index 4f6b3e35a..208bcde85 100644 --- a/packages/admin-ui-components/src/util/index.ts +++ b/packages/admin-ui-components/src/util/index.ts @@ -9,3 +9,8 @@ export * from "./formatDate"; export * from "./requestError"; export * from "./sql/summarize"; export * from "./query"; +export * from "./intersperse"; +export * from "./pick"; +export * from "./find"; +export * from "./proto"; +export * from "./formatNumber"; diff --git a/packages/admin-ui-components/src/util/intersperse.spec.ts b/packages/admin-ui-components/src/util/intersperse.spec.ts new file mode 100644 index 000000000..57276cfb6 --- /dev/null +++ b/packages/admin-ui-components/src/util/intersperse.spec.ts @@ -0,0 +1,20 @@ +import { assert } from "chai"; + +import { intersperse } from "src/util/intersperse"; + +describe("intersperse", () => { + it("puts separator in between array items", () => { + const result = intersperse(["foo", "bar", "baz"], "-"); + assert.deepEqual(result, ["foo", "-", "bar", "-", "baz"]); + }); + + it("puts separator in between array items when given a one-item array", () => { + const result = intersperse(["baz"], "-"); + assert.deepEqual(result, ["baz"]); + }); + + it("puts separator in between array items when given an empty array", () => { + const result = intersperse([], "-"); + assert.deepEqual(result, []); + }); +}); diff --git a/packages/admin-ui-components/src/util/intersperse.ts b/packages/admin-ui-components/src/util/intersperse.ts new file mode 100644 index 000000000..fad81e46d --- /dev/null +++ b/packages/admin-ui-components/src/util/intersperse.ts @@ -0,0 +1,11 @@ +// e.g. intersperse(["foo", "bar", "baz"], "-") => ["foo", "-", "bar", "-", "baz"] +export function intersperse(array: T[], sep: T): T[] { + const output = []; + for (let i = 0; i < array.length; i++) { + if (i > 0) { + output.push(sep); + } + output.push(array[i]); + } + return output; +} diff --git a/packages/admin-ui-components/src/util/pick.ts b/packages/admin-ui-components/src/util/pick.ts new file mode 100644 index 000000000..caabd865f --- /dev/null +++ b/packages/admin-ui-components/src/util/pick.ts @@ -0,0 +1,9 @@ +/* + * Extend the TypeScript built-in type Pick to understand two levels of keys. + * Useful for typing selectors that grab from a CachedDataReducer. + */ +export type Pick = { + [P1 in K1]: { + [P2 in K2]: T[P1][P2]; + }; +}; diff --git a/packages/admin-ui-components/src/util/proto.ts b/packages/admin-ui-components/src/util/proto.ts new file mode 100644 index 000000000..9695f0de2 --- /dev/null +++ b/packages/admin-ui-components/src/util/proto.ts @@ -0,0 +1,104 @@ +import _ from "lodash"; + +import { cockroach } from "@cockroachlabs/crdb-protobuf-client"; + +export type INodeStatus = cockroach.server.status.statuspb.INodeStatus; +const nodeStatus: INodeStatus = null; +export type StatusMetrics = typeof nodeStatus.metrics; + +/** + * AccumulateMetrics is a convenience function which accumulates the values + * in multiple metrics collections. Values from all provided StatusMetrics + * collections are accumulated into the first StatusMetrics collection + * passed. + */ +export function AccumulateMetrics( + dest: StatusMetrics, + ...srcs: StatusMetrics[] +): void { + srcs.forEach((s: StatusMetrics) => { + _.forEach(s, (val: number, key: string) => { + if (_.has(dest, key)) { + dest[key] = dest[key] + val; + } else { + dest[key] = val; + } + }); + }); +} + +/** + * RollupStoreMetrics accumulates all store-level metrics into the top level + * metrics collection of the supplied NodeStatus object. This is convenient + * for all current usages of NodeStatus in the UI. + */ +export function RollupStoreMetrics(ns: INodeStatus): void { + AccumulateMetrics(ns.metrics, ..._.map(ns.store_statuses, ss => ss.metrics)); +} + +/** + * MetricConstants contains the name of several stats provided by + * CockroachDB. + */ +export namespace MetricConstants { + // Store level metrics. + export const replicas = "replicas"; + export const raftLeaders = "replicas.leaders"; + export const leaseHolders = "replicas.leaseholders"; + export const ranges = "ranges"; + export const unavailableRanges = "ranges.unavailable"; + export const underReplicatedRanges = "ranges.underreplicated"; + export const liveBytes = "livebytes"; + export const keyBytes = "keybytes"; + export const valBytes = "valbytes"; + export const totalBytes = "totalbytes"; + export const intentBytes = "intentbytes"; + export const liveCount = "livecount"; + export const keyCount = "keycount"; + export const valCount = "valcount"; + export const intentCount = "intentcount"; + export const intentAge = "intentage"; + export const gcBytesAge = "gcbytesage"; + export const capacity = "capacity"; + export const availableCapacity = "capacity.available"; + export const usedCapacity = "capacity.used"; + export const sysBytes = "sysbytes"; + export const sysCount = "syscount"; + + // Node level metrics. + export const userCPUPercent = "sys.cpu.user.percent"; + export const sysCPUPercent = "sys.cpu.sys.percent"; + export const allocBytes = "sys.go.allocbytes"; + export const sqlConns = "sql.conns"; + export const rss = "sys.rss"; +} + +/** + * TotalCPU computes the total CPU usage accounted for in a NodeStatus. + */ +export function TotalCpu(status: INodeStatus): number { + const metrics = status.metrics; + return ( + metrics[MetricConstants.sysCPUPercent] + + metrics[MetricConstants.userCPUPercent] + ); +} + +/** + * BytesUsed computes the total byte usage accounted for in a NodeStatus. + */ +const aggregateByteKeys = [ + MetricConstants.liveBytes, + MetricConstants.intentBytes, + MetricConstants.sysBytes, +]; + +export function BytesUsed(s: INodeStatus): number { + const usedCapacity = s.metrics[MetricConstants.usedCapacity]; + if (usedCapacity !== 0) { + return usedCapacity; + } + return _.sumBy(aggregateByteKeys, (key: string) => { + return s.metrics[key]; + }); +} diff --git a/packages/admin-ui-components/webpack.config.js b/packages/admin-ui-components/webpack.config.js index d3566fd4b..7c105852a 100644 --- a/packages/admin-ui-components/webpack.config.js +++ b/packages/admin-ui-components/webpack.config.js @@ -98,6 +98,7 @@ module.exports = { test: /\.js$/, loader: "source-map-loader", }, + { test: /\.css$/, use: [ "style-loader", "css-loader" ] }, ], }, diff --git a/yarn.lock b/yarn.lock index c6fd8d0d0..cc9e1ae8b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1283,10 +1283,24 @@ exec-sh "^0.3.2" minimist "^1.2.0" -"@cockroachlabs/crdb-protobuf-client@^0.0.3": - version "0.0.3" - resolved "https://registry.yarnpkg.com/@cockroachlabs/crdb-protobuf-client/-/crdb-protobuf-client-0.0.3.tgz#3ce8dd4953a1209f1895c713cf90595a15c54ab1" - integrity sha512-AXHWWW7hI05hj5fTdXgIIjfZrqfacQ/zsT83LoUsrnFUOeWZCa6qSF3qVonaR2h8FloRfEeFhC+27TDsi8RI0A== +"@cockroachlabs/crdb-protobuf-client@^0.0.4": + version "0.0.4" + resolved "https://registry.yarnpkg.com/@cockroachlabs/crdb-protobuf-client/-/crdb-protobuf-client-0.0.4.tgz#9b53ef7cbb187cd7d73b4269c95b0f573caafd45" + integrity sha512-n/SEmLzU7i9h5m8cAw9NPRAcQSgzHNdm5+F0dN3myfQSCuEMTgTlbWsfm6EnjTRL1FnZlieqY2gZzgyx4Ke0Ug== + +"@cockroachlabs/icons@^0.3.0": + version "0.3.0" + resolved "https://registry.yarnpkg.com/@cockroachlabs/icons/-/icons-0.3.0.tgz#160573074396f266e92fcbe5e520c5ba1d8750f9" + integrity sha512-GJxhlXy8Z3/PYFb9C3iM1dvU9wajGoaA/+VCj0an2ipfbkI2fhToq+h0b33vu7JuZ3dS4QMRjfVE4uhlyIUH2Q== + +"@cockroachlabs/ui-components@^0.2.14-alpha.0": + version "0.2.15" + resolved "https://registry.yarnpkg.com/@cockroachlabs/ui-components/-/ui-components-0.2.15.tgz#f93e61bc5af33ef0ba0155230674ba9741a270fe" + integrity sha512-5er6ZsGD8RgnBR7LrFd78u/UYpye21xbAuPR9r5M3Wt4wCkZetVaQA8x1g7BeKzKkKv0uLcA5swhCxKhoEzNxA== + dependencies: + "@cockroachlabs/icons" "^0.4.0" + "@popperjs/core" "^2.4.3" + react-popper "^2.2.3" "@cockroachlabs/icons@0.3.0", "@cockroachlabs/icons@^0.3.0": version "0.3.0"