From da3cc3593730ddb5c852108a515c43cd18227d1e Mon Sep 17 00:00:00 2001 From: Pete Vilter Date: Mon, 4 Dec 2017 18:45:43 -0500 Subject: [PATCH] ui: add data distribution screen (aka replica matrix) with collapsible tree matrix widget --- pkg/ui/src/index.tsx | 2 + pkg/ui/src/redux/apiReducers.ts | 5 + pkg/ui/src/util/api.ts | 9 + .../containers/dataDistribution/index.styl | 19 + .../containers/dataDistribution/index.tsx | 180 ++++++++ .../containers/dataDistribution/matrix.styl | 14 + .../containers/dataDistribution/matrix.tsx | 167 +++++++ .../containers/dataDistribution/tree.spec.ts | 275 +++++++++++ .../containers/dataDistribution/tree.ts | 428 ++++++++++++++++++ .../dataDistribution/zoneConfigList.tsx | 65 +++ .../views/reports/containers/debug/index.tsx | 6 +- 11 files changed, 1169 insertions(+), 1 deletion(-) create mode 100644 pkg/ui/src/views/cluster/containers/dataDistribution/index.styl create mode 100644 pkg/ui/src/views/cluster/containers/dataDistribution/index.tsx create mode 100644 pkg/ui/src/views/cluster/containers/dataDistribution/matrix.styl create mode 100644 pkg/ui/src/views/cluster/containers/dataDistribution/matrix.tsx create mode 100644 pkg/ui/src/views/cluster/containers/dataDistribution/tree.spec.ts create mode 100644 pkg/ui/src/views/cluster/containers/dataDistribution/tree.ts create mode 100644 pkg/ui/src/views/cluster/containers/dataDistribution/zoneConfigList.tsx diff --git a/pkg/ui/src/index.tsx b/pkg/ui/src/index.tsx index 99382bd71297..5148fe5b950d 100644 --- a/pkg/ui/src/index.tsx +++ b/pkg/ui/src/index.tsx @@ -32,6 +32,7 @@ import Layout from "src/views/app/containers/layout"; import { DatabaseTablesList, DatabaseGrantsList } from "src/views/databases/containers/databases"; import TableDetails from "src/views/databases/containers/tableDetails"; import { EventPage } from "src/views/cluster/containers/events"; +import DataDistributionPage from "src/views/cluster/containers/dataDistribution"; import Raft from "src/views/devtools/containers/raft"; import RaftRanges from "src/views/devtools/containers/raftRanges"; import RaftMessages from "src/views/devtools/containers/raftMessages"; @@ -136,6 +137,7 @@ ReactDOM.render( + diff --git a/pkg/ui/src/redux/apiReducers.ts b/pkg/ui/src/redux/apiReducers.ts index c4bb08031776..73f3e6d2019e 100644 --- a/pkg/ui/src/redux/apiReducers.ts +++ b/pkg/ui/src/redux/apiReducers.ts @@ -228,6 +228,9 @@ export const settingsReducerObj = new CachedDataReducer( ); export const refreshSettings = settingsReducerObj.refresh; +const replicaMatrixReducerObj = new CachedDataReducer(api.getReplicaMatrix, "replicaMatrix", moment.duration(1, "m")); +export const refreshReplicaMatrix = replicaMatrixReducerObj.refresh; + export interface APIReducersState { cluster: CachedDataReducerState; events: CachedDataReducerState; @@ -252,6 +255,7 @@ export interface APIReducersState { rangeLog: KeyedCachedDataReducerState; commandQueue: KeyedCachedDataReducerState; settings: CachedDataReducerState; + replicaMatrix: CachedDataReducerState; } export const apiReducersReducer = combineReducers({ @@ -278,6 +282,7 @@ export const apiReducersReducer = combineReducers({ [rangeLogReducerObj.actionNamespace]: rangeLogReducerObj.reducer, [commandQueueReducerObj.actionNamespace]: commandQueueReducerObj.reducer, [settingsReducerObj.actionNamespace]: settingsReducerObj.reducer, + [replicaMatrixReducerObj.actionNamespace]: replicaMatrixReducerObj.reducer, }); export { CachedDataReducerState, KeyedCachedDataReducerState }; diff --git a/pkg/ui/src/util/api.ts b/pkg/ui/src/util/api.ts index c52d17968395..1a9abf49bb1a 100644 --- a/pkg/ui/src/util/api.ts +++ b/pkg/ui/src/util/api.ts @@ -86,6 +86,9 @@ export type CommandQueueResponseMessage = protos.cockroach.server.serverpb.Comma export type SettingsRequestMessage = protos.cockroach.server.serverpb.SettingsRequest; export type SettingsResponseMessage = protos.cockroach.server.serverpb.SettingsResponse; +export type ReplicaMatrixRequestMessage = protos.cockroach.server.serverpb.ReplicaMatrixRequest; +export type ReplicaMatrixResponseMessage = protos.cockroach.server.serverpb.ReplicaMatrixResponse; + // API constants export const API_PREFIX = "_admin/v1"; @@ -309,3 +312,9 @@ export function getCommandQueue(req: CommandQueueRequestMessage, timeout?: momen export function getSettings(_req: SettingsRequestMessage, timeout?: moment.Duration): Promise { return timeoutFetch(serverpb.SettingsResponse, `${API_PREFIX}/settings`, null, timeout); } + +// getReplicaMatrix returns data distribution information +// TODO(vilterp): rename to DataDistribution? +export function getReplicaMatrix(timeout?: moment.Duration): Promise { + return timeoutFetch(serverpb.ReplicaMatrixResponse, `${API_PREFIX}/replica_matrix`, null, timeout); +} diff --git a/pkg/ui/src/views/cluster/containers/dataDistribution/index.styl b/pkg/ui/src/views/cluster/containers/dataDistribution/index.styl new file mode 100644 index 000000000000..276dc8acb674 --- /dev/null +++ b/pkg/ui/src/views/cluster/containers/dataDistribution/index.styl @@ -0,0 +1,19 @@ +.replica-matrix + background-color white + border-collapse collapse + border-bottom 1px solid lightgrey + border-top 1px solid lightgrey + + thead + border-bottom 1px solid black + + th, td + padding 5px + border-left 1px solid lightgrey + border-right 1px solid lightgrey + + td.table-name + text-align left + + td.value + text-align center diff --git a/pkg/ui/src/views/cluster/containers/dataDistribution/index.tsx b/pkg/ui/src/views/cluster/containers/dataDistribution/index.tsx new file mode 100644 index 000000000000..ebf4c201466c --- /dev/null +++ b/pkg/ui/src/views/cluster/containers/dataDistribution/index.tsx @@ -0,0 +1,180 @@ +import _ from "lodash"; +import React from "react"; +import { Link } from "react-router"; +import { connect } from "react-redux"; + +import Loading from "src/views/shared/components/loading"; +import spinner from "assets/spinner.gif"; +import { cockroach } from "src/js/protos"; +import { NodeStatus$Properties } from "src/util/proto"; +import { AdminUIState } from "src/redux/state"; +import docsURL from "src/util/docs"; +import { refreshReplicaMatrix, refreshNodes } from "src/redux/apiReducers"; +import Matrix from "./matrix"; +import ZoneConfigList from "./zoneConfigList"; +import { TreeNode, setAtPath, TreePath } from "./tree"; +import "./index.styl"; + +type ReplicaMatrixResponse = cockroach.server.serverpb.ReplicaMatrixResponse; +type NodeDescriptor = cockroach.roachpb.NodeDescriptor$Properties; + +interface TableDesc { + dbName: string; + tableName?: string; +} + +function makeNodeTree(nodes: NodeDescriptor[]): TreeNode { + const root: TreeNode = { + name: "Cluster", + data: {}, + children: [], + }; + + nodes.forEach((node) => { + const path = node.locality.tiers.map((tier) => `${tier.key}=${tier.value}`); + setAtPath(root, path, { + name: `n${node.node_id.toString()}`, + data: node, + }); + }); + return root; +} + +const ZONE_CONFIGS_DOCS_URL = docsURL("configure-replication-zones.html"); + +class ReplicaMatrix extends Matrix {} + +interface DataDistributionProps { + replicaMatrix: ReplicaMatrixResponse; + nodes: NodeStatus$Properties[]; + refreshReplicaMatrix: typeof refreshReplicaMatrix; + refreshNodes: typeof refreshNodes; +} + +class DataDistribution extends React.Component { + + render() { + // TODO(vilterp): use locality tree selector + const nodeTree = makeNodeTree(this.props.nodes.map((n) => n.desc)); + const databaseInfo = this.props.replicaMatrix.database_info; + + const dbTree: TreeNode = { + name: "Cluster", + data: null, + children: _.map(databaseInfo, (dbInfo, dbName) => ({ + name: dbName, + data: { dbName }, + children: _.map(dbInfo.table_info, (_, tableName) => ({ + name: tableName, + data: { dbName, tableName }, + })), + })), + }; + + function getValue(dbPath: TreePath, nodePath: TreePath): number { + const [dbName, tableName] = dbPath; + // TODO(vilterp): substring is to get rid of the "n" prefix; find a different way + const nodeID = nodePath[nodePath.length - 1].substr(1); + + const res = databaseInfo[dbName].table_info[tableName].replica_count_by_node_id[nodeID]; + if (!res) { + return 0; + } + return res.toInt(); + } + + return ( + + + + + + + +
+

+ Zone Configs{" "} + + (?) + +

+ +
+ # Replicas} + cols={nodeTree} + rows={dbTree} + colNodeLabel={(_, path, isPlaceholder) => ( + isPlaceholder ? "" : path[path.length - 1] + )} + colLeafLabel={(node, path, isPlaceholder) => ( + isPlaceholder + ? "" + : node === null + ? path[path.length - 1] + : `n${node.node_id.toString()}` + )} + rowNodeLabel={(row: TableDesc) => (`DB: ${row.dbName}`)} + rowLeafLabel={(row: TableDesc) => (row.tableName)} + getValue={getValue} + /> +
+ ); + } +} + +class DataDistributionPage extends React.Component { + + componentDidMount() { + this.props.refreshReplicaMatrix(); + this.props.refreshNodes(); + } + + componentDidUpdate() { + this.props.refreshReplicaMatrix(); + this.props.refreshNodes(); + } + + render() { + return ( +
+
+ < Back to Debug +
+
+

Data Distribution

+
+
+ + + +
+
+ ); + } +} + +// tslint:disable-next-line:variable-name +const DataDistributionPageConnected = connect( + (state: AdminUIState) => { + return { + replicaMatrix: state.cachedData.replicaMatrix.data, + nodes: state.cachedData.nodes.data, + }; + }, + { + refreshReplicaMatrix, + refreshNodes, + }, +)(DataDistributionPage); + +export default DataDistributionPageConnected; diff --git a/pkg/ui/src/views/cluster/containers/dataDistribution/matrix.styl b/pkg/ui/src/views/cluster/containers/dataDistribution/matrix.styl new file mode 100644 index 000000000000..276ca4162d24 --- /dev/null +++ b/pkg/ui/src/views/cluster/containers/dataDistribution/matrix.styl @@ -0,0 +1,14 @@ +.matrix-row.node + cursor pointer + +.matrix-row.node:hover + background-color rgb(240, 240, 240) + +.matrix-column-header + font-weight bold + +.matrix-column-header.toggleable + cursor pointer + +.matrix-column-header.toggleable:hover + background-color rgb(240, 240, 240) diff --git a/pkg/ui/src/views/cluster/containers/dataDistribution/matrix.tsx b/pkg/ui/src/views/cluster/containers/dataDistribution/matrix.tsx new file mode 100644 index 000000000000..ce38964e9c68 --- /dev/null +++ b/pkg/ui/src/views/cluster/containers/dataDistribution/matrix.tsx @@ -0,0 +1,167 @@ +import _ from "lodash"; +import React, { Component } from "react"; +import classNames from "classnames"; + +import { + TreeNode, TreePath, layoutTree, flatten, sumValuesUnderPaths, + deepIncludes, +} from "./tree"; +import "./matrix.styl"; + +const DOWN_ARROW = "▼"; +const SIDE_ARROW = "▶"; + +interface MatrixState { + collapsedRows: TreePath[]; + collapsedCols: TreePath[]; +} + +interface MatrixProps { + label?: JSX.Element; + cols: TreeNode; + rows: TreeNode; + getValue: (rowPath: TreePath, colPath: TreePath) => number; + rowLeafLabel?: (row: R, path?: TreePath) => string; + rowNodeLabel?: (row: R, path?: TreePath) => string; + colLeafLabel?: (col: C, path: TreePath, isPlaceholder: boolean) => string; + colNodeLabel?: (col: C, path: TreePath, isPlaceholder: boolean) => string; +} + +class Matrix extends Component, MatrixState> { + + constructor(props: MatrixProps) { + super(props); + this.state = { + collapsedRows: [], + collapsedCols: [], + }; + } + + expandRow = (path: TreePath) => { + this.setState({ + collapsedRows: this.state.collapsedRows.filter((tp) => !_.isEqual(tp, path)), + }); + } + + collapseRow = (path: TreePath) => { + this.setState({ + collapsedRows: [...this.state.collapsedRows, path], + }); + } + + expandCol = (path: TreePath) => { + this.setState({ + collapsedCols: this.state.collapsedCols.filter((tp) => !_.isEqual(tp, path)), + }); + } + + collapseCol = (path: TreePath) => { + this.setState({ + collapsedCols: [...this.state.collapsedCols, path], + }); + } + + render() { + const { + label, + cols, + rows, + getValue, + colLeafLabel, + colNodeLabel, + rowLeafLabel, + rowNodeLabel, + } = this.props; + const { + collapsedRows, + collapsedCols, + } = this.state; + + const flattenedRows = flatten(rows, collapsedRows, true /* includeNodes */); + const headerRows = layoutTree(cols, collapsedCols); + const flattenedCols = flatten(cols, collapsedCols, false /* includeNodes */); + + return ( + + + {headerRows.slice(1).map((row, idx) => ( + + {idx === 0 + ? + : + ); + })} + + ))} + + + {flattenedRows.filter((n) => n.depth > 0).map((row) => { + const arrow = row.isCollapsed ? SIDE_ARROW : DOWN_ARROW; + return ( + ( + row.isCollapsed + ? this.expandRow(row.path) + : this.collapseRow(row.path) + )} + > + + {flattenedCols.map((col) => { + return ( + + ); + })} + + ); + })} + +
{label}} + {row.map((col) => { + const colIsCollapsed = deepIncludes(collapsedCols, col.path); + const arrow = colIsCollapsed ? SIDE_ARROW : DOWN_ARROW; + return ( + 1 })} + onClick={() => ( + colIsCollapsed + ? this.expandCol(col.path) + : this.collapseCol(col.path) + )} + > + {col.isPlaceholder + ? null + : col.depth === 1 + ? colLeafLabel(col.data, col.path, col.isPlaceholder) + : `${arrow} ${colNodeLabel(col.data, col.path, col.isPlaceholder)}`} +
+ {row.isLeaf + ? rowLeafLabel(row.data, row.path) + : `${arrow} ${rowNodeLabel(row.data, row.path)}`} + + {row.isLeaf || row.isCollapsed + ? emptyIfZero(sumValuesUnderPaths(rows, cols, row.path, col.path, getValue)) + : null} +
+ ); + } + +} + +function emptyIfZero(n: number): string { + if (n === 0) { + return ""; + } + return `${n}`; +} + +export default Matrix; diff --git a/pkg/ui/src/views/cluster/containers/dataDistribution/tree.spec.ts b/pkg/ui/src/views/cluster/containers/dataDistribution/tree.spec.ts new file mode 100644 index 000000000000..186d48f2d7d7 --- /dev/null +++ b/pkg/ui/src/views/cluster/containers/dataDistribution/tree.spec.ts @@ -0,0 +1,275 @@ +import { assert } from "chai"; + +import { + flatten, layoutTree, sumValuesUnderPaths, TreePath, LayoutNode, +} from "./tree"; + +describe("tree", () => { + + describe("layoutTree", () => { + + it("lays out a simple tree", () => { + // (a [b c]) + const tree = { + name: "a", data: "a", + children: [ + { name: "b", data: "b" }, + { name: "c", data: "c" }, + ], + }; + + // | a | + // | b | c | + const expectedLayout: LayoutNode[][] = [ + [ { width: 2, depth: 2, data: "a", path: [], isCollapsed: false, isPlaceholder: false } ], + [ + { width: 1, depth: 1, path: ["b"], data: "b", isCollapsed: false, isPlaceholder: false }, + { width: 1, depth: 1, path: ["c"], data: "c", isCollapsed: false, isPlaceholder: false }, + ], + ]; + assert.deepEqual(layoutTree(tree, []), expectedLayout); + }); + + it("lays out a tree of inconsistent depth, inserting a placeholder", () => { + // (a [b (c [d e])]) + const tree = { + name: "a", data: "a", + children: [ + { name: "b", data: "b" }, + { + name: "c", data: "c", + children: [ + { name: "d", data: "d" }, + { name: "e", data: "e" }, + ], + }, + ], + }; + + // | a | + // |

| c | + // | b | d | e | + const expectedLayout = [ + [ { width: 3, depth: 3, data: "a", path: [], isCollapsed: false, isPlaceholder: false } ], + [ { width: 1, depth: 1, path: ["b"], data: "b", isCollapsed: false, isPlaceholder: true }, + { width: 2, depth: 2, data: "c", path: ["c" ], isCollapsed: false, isPlaceholder: false }, + ], + [ { width: 1, depth: 1, path: ["b"], data: "b", isCollapsed: false, isPlaceholder: false }, + { width: 1, depth: 1, path: ["c", "d"], data: "d", isCollapsed: false, isPlaceholder: false }, + { width: 1, depth: 1, path: ["c", "e"], data: "e", isCollapsed: false, isPlaceholder: false }, + ], + ]; + const actualLayout = layoutTree(tree, []); + assert.deepEqual(actualLayout, expectedLayout); + }); + + it("inserts placeholders under a collapsed node, if other subtrees are deeper", () => { + // (a [(b [c d]) (e [f g])]) + const tree = { + name: "a", data: "a", + children: [ + { name: "b", data: "b", + children: [ + { name: "c", data: "c" }, + { name: "d", data: "d" }, + ], + }, + { + name: "e", data: "e", + children: [ + { name: "f", data: "f" }, + { name: "g", data: "g" }, + ], + }, + ], + }; + + // Without anything collapsed: + // | a | + // | b | e | + // | c | d | f | g | + const expectedLayout = [ + [ { width: 4, depth: 3, data: "a", path: [], isCollapsed: false, isPlaceholder: false } ], + [ { width: 2, depth: 2, path: ["b"], data: "b", isCollapsed: false, isPlaceholder: false }, + { width: 2, depth: 2, path: ["e"], data: "e", isCollapsed: false, isPlaceholder: false }, + ], + [ { width: 1, depth: 1, path: ["b", "c"], data: "c", isCollapsed: false, isPlaceholder: false }, + { width: 1, depth: 1, path: ["b", "d"], data: "d", isCollapsed: false, isPlaceholder: false }, + { width: 1, depth: 1, path: ["e", "f"], data: "f", isCollapsed: false, isPlaceholder: false }, + { width: 1, depth: 1, path: ["e", "g"], data: "g", isCollapsed: false, isPlaceholder: false }, + ], + ]; + const actualLayout = layoutTree(tree, []); + assert.deepEqual(actualLayout, expectedLayout); + + // Collapse e: + // | a | + // | b | e | + // | c | d |

| + const expectedLayoutCollapseE = [ + [ { width: 3, depth: 3, data: "a", path: [], isCollapsed: false, isPlaceholder: false } ], + [ { width: 2, depth: 2, path: ["b"], data: "b", isCollapsed: false, isPlaceholder: false }, + { width: 1, depth: 2, path: ["e"], data: "e", isCollapsed: true, isPlaceholder: false }, + ], + [ { width: 1, depth: 1, path: ["b", "c"], data: "c", isCollapsed: false, isPlaceholder: false }, + { width: 1, depth: 1, path: ["b", "d"], data: "d", isCollapsed: false, isPlaceholder: false }, + { width: 1, depth: 1, path: ["e"], data: "e", isCollapsed: false, isPlaceholder: true }, + ], + ]; + const actualLayoutCollapseE = layoutTree(tree, [["e"]]); + assert.deepEqual(actualLayoutCollapseE, expectedLayoutCollapseE); + + // Collapse e and b: + // | a | + // | b | e | + // TODO(vilterp): these depth numbers don't reflect that anything was collapsed. + // maybe that's ok; doesn't mess up the rendering. + const expectedLayoutCollapseBE: LayoutNode[][] = [ + [ { width: 2, depth: 3, data: "a", path: [], isCollapsed: false, isPlaceholder: false } ], + [ { width: 1, depth: 2, path: ["b"], data: "b", isCollapsed: true, isPlaceholder: false }, + { width: 1, depth: 2, path: ["e"], data: "e", isCollapsed: true, isPlaceholder: false }, + ], + ]; + const actualLayoutCollapseBE = layoutTree(tree, [["b"], ["e"]]); + assert.deepEqual(actualLayoutCollapseBE, expectedLayoutCollapseBE); + + }); + + }); + + describe("flatten", () => { + + // (a [(b [c d]) (e [f g])]) + const tree = { + name: "a", data: "a", + children: [ + { name: "b", data: "b", + children: [ + { name: "c", data: "c" }, + { name: "d", data: "d" }, + ], + }, + { + name: "e", data: "e", + children: [ + { name: "f", data: "f" }, + { name: "g", data: "g" }, + ], + }, + ], + }; + + describe("with includeNodes = true", () => { + + it("lays out a tree with nothing collapsed", () => { + const actualFlattened = flatten(tree, [], true); + const expectedFlattened = [ + { depth: 0, isLeaf: false, isCollapsed: false, data: "a", path: [] }, + { depth: 1, isLeaf: false, isCollapsed: false, data: "b", path: ["b"] }, + { depth: 2, isLeaf: true, isCollapsed: false, data: "c", path: ["b", "c"] }, + { depth: 2, isLeaf: true, isCollapsed: false, data: "d", path: ["b", "d"] }, + { depth: 1, isLeaf: false, isCollapsed: false, data: "e", path: ["e"] }, + { depth: 2, isLeaf: true, isCollapsed: false, data: "f", path: ["e", "f"] }, + { depth: 2, isLeaf: true, isCollapsed: false, data: "g", path: ["e", "g"] }, + ]; + + assert.deepEqual(actualFlattened, expectedFlattened); + }); + + it("lays out a tree with a node collapsed", () => { + const actualFlattened = flatten(tree, [["b"]], true); + const expectedFlattened = [ + { depth: 0, isLeaf: false, isCollapsed: false, data: "a", path: [] }, + { depth: 1, isLeaf: false, isCollapsed: true, data: "b", path: ["b"] }, + { depth: 1, isLeaf: false, isCollapsed: false, data: "e", path: ["e"] }, + { depth: 2, isLeaf: true, isCollapsed: false, data: "f", path: ["e", "f"] }, + { depth: 2, isLeaf: true, isCollapsed: false, data: "g", path: ["e", "g"] }, + ]; + + assert.deepEqual(actualFlattened, expectedFlattened); + }); + + }); + + describe("with inclueNodes = false", () => { + + it("lays out a tree with nothing collapsed", () => { + const actualFlattened = flatten(tree, [], false); + const expectedFlattened = [ + { depth: 2, isLeaf: true, isCollapsed: false, data: "c", path: ["b", "c"] }, + { depth: 2, isLeaf: true, isCollapsed: false, data: "d", path: ["b", "d"] }, + { depth: 2, isLeaf: true, isCollapsed: false, data: "f", path: ["e", "f"] }, + { depth: 2, isLeaf: true, isCollapsed: false, data: "g", path: ["e", "g"] }, + ]; + + assert.deepEqual(actualFlattened, expectedFlattened); + }); + + it("lays out a tree with a node collapsed", () => { + const actualFlattened = flatten(tree, [["b"]], false); + const expectedFlattened = [ + { depth: 1, isLeaf: false, isCollapsed: true, data: "b", path: ["b"] }, + { depth: 2, isLeaf: true, isCollapsed: false, data: "f", path: ["e", "f"] }, + { depth: 2, isLeaf: true, isCollapsed: false, data: "g", path: ["e", "g"] }, + ]; + + assert.deepEqual(actualFlattened, expectedFlattened); + }); + + }); + + }); + + describe("sumValuesUnderPaths", () => { + + // | | C_1 | + // | | C_2 | C_3 | + // |-------|-----|-----| + // | R_a | | | + // | R_b | 1 | 2 | + // | R_c | 3 | 4 | + + const rowTree = { + name: "a", + children: [ + { name: "b" }, + { name: "c" }, + ], + }; + const colTree = { + name: "1", + children: [ + { name: "2" }, + { name: "3" }, + ], + }; + // by row, then col. + const values: {[name: string]: {[name: string]: number}} = { + "b": {"2": 1, "3": 2}, + "c": {"2": 3, "3": 4}, + }; + function getValue(rowPath: TreePath, colPath: TreePath): number { + return values[rowPath[0]][colPath[0]]; + } + + it("computes a sum for the roots of both trees", () => { + const actualSum = sumValuesUnderPaths(rowTree, colTree, [], [], getValue); + const expectedSum = 1 + 2 + 3 + 4; + assert.equal(actualSum, expectedSum); + }); + + it("computes a sum for the root of one tree and the leaf of another", () => { + const actualSum = sumValuesUnderPaths(rowTree, colTree, ["b"], [], getValue); + const expectedSum = 1 + 2; + assert.equal(actualSum, expectedSum); + }); + + it("computes a sum for a single cell (two leaves)", () => { + const actualSum = sumValuesUnderPaths(rowTree, colTree, ["b"], ["3"], getValue); + const expectedSum = 2; + assert.equal(actualSum, expectedSum); + }); + + }); + +}); diff --git a/pkg/ui/src/views/cluster/containers/dataDistribution/tree.ts b/pkg/ui/src/views/cluster/containers/dataDistribution/tree.ts new file mode 100644 index 000000000000..fe6d21a1caf3 --- /dev/null +++ b/pkg/ui/src/views/cluster/containers/dataDistribution/tree.ts @@ -0,0 +1,428 @@ +import _ from "lodash"; + +export interface TreeNode { + name: string; + children?: TreeNode[]; + data?: T; +} + +export type TreePath = string[]; + +export function isLeaf(t: TreeNode): boolean { + return !_.has(t, "children"); +} + +export interface LayoutNode { + width: number; + depth: number; // Depth of this subtree. Leaves have a depth of 1. + path: TreePath; + isCollapsed: boolean; + isPlaceholder: boolean; + data: T; +} + +/** + * layoutTree turns a tree into a tabular, horizontal layout. + * For instance, the tree + * + * (a [b c]) + * + * becomes: + * + * | a | + * | b | c | + * + * The layout is returned as a 2d array of LayoutNodes: + * + * [ [ ], + * [ , ] ] + * + * If the tree is of uneven depth, placeholder elements + * are returned to maintain the rectangularity of the table. + * + * For instance, the tree + * + * (a [(b [c d]) e]) + * + * becomes: + * + * | a | + * | b |

| + * | c | d | e | + * + * Where

is a LayoutNode with `isPlaceholder: true`. + * + * Further, if part of the tree is collapsed (specified by + * the `collapsedPaths` argument), its LayoutNodes are + * returned with `isCollapsed: true`, and placeholders are + * returned to maintain rectangularity. + * + * The tree + * + * (a [(b [c d]) (e [f g])]) + * + * without anything collapsed becomes: + * + * | a | + * | b | e | + * | c | d | f | g | + * + * Collapsing `e` yields: + * + * | a | + * | b | e | + * | c | d |

| + * + * Where

is a LayoutNode with `isPlaceholder: true` + * and e is a LayoutNode with `isCollapsed: true`. + * + */ +export function layoutTree(root: TreeNode, collapsedPaths: TreePath[]): LayoutNode[][] { + const depth = getDepth(root); + function recur(node: TreeNode, pathToThis: TreePath): LayoutNode[][] { + const depthUnderThis = depth - pathToThis.length; + const placeholderRows = _.range(depthUnderThis).reverse().map((thisDepth) => ( + [ + { + width: 1, + depth: thisDepth + 1, + isPlaceholder: true, + isCollapsed: false, + path: pathToThis, + data: node.data, + }, + ] + )); + + if (isLeaf(node)) { + return [ + ...placeholderRows, + [ + { + width: 1, + depth: 1, + path: pathToThis, + data: node.data, + isCollapsed: false, + isPlaceholder: false, + }, + ], + ]; + } + + const isCollapsed = deepIncludes(collapsedPaths, pathToThis); + if (isCollapsed) { + return [ + [ + { + width: 1, + depth: depthUnderThis + 1, + path: pathToThis, + data: node.data, + isCollapsed: true, + isPlaceholder: false, + }, + ], + ...placeholderRows, + ]; + } + + const childLayouts = node.children.map((childNode) => ( + recur(childNode, [...pathToThis, childNode.name]) + )); + const maxDepth = _.maxBy(childLayouts, (cl) => cl[0][0].depth)[0][0].depth; + const transposedChildLayouts = _.range(depth).map(() => ([])); + + _.forEach(childLayouts, (childLayout) => { + _.forEach(childLayout, (row, rowIdx) => { + _.forEach(row, (col) => { + transposedChildLayouts[rowIdx].push(col); + }); + }); + }); + + return [ + [ + { + width: _.sumBy(childLayouts, (cl) => cl[0][0].width), + depth: maxDepth + 1, + data: node.data, + path: pathToThis, + isCollapsed, + isPlaceholder: false, + }, + ], + ...transposedChildLayouts, + ]; + } + + const recurRes = recur(root, []); + return removePlaceholdersFromEnd(recurRes); +} + +function removePlaceholdersFromEnd(arr: LayoutNode[][]): LayoutNode[][] { + const output: LayoutNode[][] = []; + for (let i = 0; i < arr.length; i++) { + const row = arr[i]; + if (row.every((cell) => cell.isPlaceholder)) { + return output; + } + output.push(row); + } + return output; +} + +export interface FlattenedNode { + depth: number; + isLeaf: boolean; + isCollapsed: boolean; + data: T; + path: TreePath; +} + +/** + * flatten takes a tree and returns it as an array with depth information. + * + * E.g. the tree + * + * (a [b c]) + * + * Becomes (with includeNodes = true): + * + * a (depth: 0) + * b (depth: 1) + * c (depth: 1) + * + * Or (with includeNodes = false): + * + * b (depth: 1) + * c (depth: 1) + * + * Collapsed nodes (specified with the `collapsedPaths` argument) + * are returned with `isCollapsed: true`; their children are not + * returned. + * + * E.g. the tree + * + * (a [(b [c d]) (e [f g])]) + * + * without anything collapsed becomes: + * + * a + * b + * c + * d + * e + * f + * g + * + * With b collapsed, it becomes: + * + * a + * b (isCollapsed: true) + * e + * f + * g + * + */ +export function flatten( + tree: TreeNode, + collapsedPaths: TreePath[], + includeNodes: boolean, +): FlattenedNode[] { + const output: FlattenedNode[] = []; + function recur(node: TreeNode, depth: number, pathSoFar: TreePath) { + if (isLeaf(node)) { + output.push({ + depth, + isLeaf: true, + isCollapsed: false, + data: node.data, + path: pathSoFar, + }); + } else { + const isExpanded = _.filter(collapsedPaths, (p) => _.isEqual(p, pathSoFar)).length === 0; + if (includeNodes || !isExpanded) { + output.push({ + depth, + isLeaf: false, + isCollapsed: !isExpanded, + data: node.data, + path: pathSoFar, + }); + } + if (isExpanded) { + node.children.forEach((child) => { + recur(child, depth + 1, [...pathSoFar, child.name]); + }); + } + } + } + recur(tree, 0, []); + return output; +} + +/** + * setAtPath mutates `tree`, inserting `node` at `path`. If nodes along the path + * don't exist, they're created, like `mkdir -p`. + */ +export function setAtPath(tree: TreeNode, path: TreePath, node: TreeNode) { + if (path.length === 0) { + tree.children.push(node); + return; + } + const nextPathSegment = path[0]; + let nextChild = _.find(tree.children, (child) => _.isEqual(child.name, nextPathSegment)); + if (!nextChild) { + nextChild = { + name: nextPathSegment, + children: [], + data: null, + }; + tree.children.push(nextChild); + } + setAtPath(nextChild, path.slice(1), node); +} + +/** + * nodeAtPath returns the node found under `root` at `path`, throwing + * an error if nothing is found. + */ +function nodeAtPath(root: TreeNode, path: TreePath): TreeNode { + if (path.length === 0) { + return root; + } + const pathSegment = path[0]; + const child = root.children.find((c) => (c.name === pathSegment)); + if (child === undefined) { + throw new Error(`not found: ${path}`); + } + return nodeAtPath(child, path.slice(1)); +} + +/** + * visitNodes invokes `f` on each node in the tree in pre-order + * (`f` is invoked on a node before being invoked on its children). + */ +function visitNodes(root: TreeNode, f: (node: TreeNode, path: TreePath) => void) { + function recur(node: TreeNode, path: TreePath) { + f(node, path); + if (node.children) { + node.children.forEach((child) => { + recur(child, [...path, child.name]); + }); + } + } + recur(root, []); +} + +function getDepth(root: TreeNode): number { + return _.max(getLeafPaths(root).map((p) => p.length)); +} + +/** + * getLeafPathsUnderPath returns paths to all leaf nodes under the given + * `path` in `root`. + * + * E.g. for the tree + * + * T = (a [(b [c d]) (e [f g])]) + * + * getLeafPaths(T, ['a', 'b']) yields: + * + * [ ['a', 'b', 'c'], + * ['a', 'b', 'd'] ] + * + */ +function getLeafPathsUnderPath(root: TreeNode, path: TreePath): TreePath[] { + const atPath = nodeAtPath(root, path); + const output: TreePath[] = []; + visitNodes(atPath, (node, subPath) => { + if (isLeaf(node)) { + output.push([...path, ...subPath]); + } + }); + return output; +} + +/** + * getLeafPaths returns paths to all leaves under `root`. + */ +function getLeafPaths(root: TreeNode): TreePath[] { + return getLeafPathsUnderPath(root, []); +} + +/** + * cartProd returns all combinations of elements in `as` and `bs`. + * + * e.g. cartProd([1, 2], ['a', 'b']) + * yields: + * [ + * {a: 1, b: 'a'}, + * {a: 1, b: 'b'}, + * {a: 2, b: 'a'}, + * {a: 2, b: 'b'}, + * ] + */ +function cartProd(as: A[], bs: B[]): {a: A, b: B}[] { + const output: {a: A, b: B}[] = []; + as.forEach((a) => { + bs.forEach((b) => { + output.push({ a, b }); + }); + }); + return output; +} + +/** + * sumValuesUnderPaths returns the sum of `getValue(R, C)` + * for all leaf paths R under `rowPath` in `rowTree`, + * and all leaf paths C under `colPath` in `rowTree`. + * + * E.g. in the matrix + * + * | | C_1 | + * | | C_2 | C_3 | + * |-------|-----|-----| + * | R_a | | | + * | R_b | 1 | 2 | + * | R_c | 3 | 4 | + * + * represented by + * + * rowTree = (R_a [R_b R_c]) + * colTree = (C_1 [C_2 C_3]) + * + * calling sumValuesUnderPath(rowTree, colTree, ['R_a'], ['C_b'], getValue) + * sums up all the cells in the matrix, yielding + * yielding 1 + 2 + 3 + 4 = 10. + * + * Calling sumValuesUnderPath(rowTree, colTree, ['R_a', 'R_b'], ['C_b'], getValue) + * sums up only the cells under R_b, + * yielding 1 + 2 = 3. + * + */ +export function sumValuesUnderPaths( + rowTree: TreeNode, + colTree: TreeNode, + rowPath: TreePath, + colPath: TreePath, + getValue: (row: TreePath, col: TreePath) => number, +): number { + const rowPaths = getLeafPathsUnderPath(rowTree, rowPath); + const colPaths = getLeafPathsUnderPath(colTree, colPath); + const prod = cartProd(rowPaths, colPaths); + let sum = 0; + prod.forEach((coords) => { + sum += getValue(coords.a, coords.b); + }); + return sum; +} + +/** + * deepIncludes returns true if `array` contains `val`, doing + * a deep equality comparison. + */ +export function deepIncludes(array: T[], val: T): boolean { + return _.some(array, (v) => _.isEqual(val, v)); +} diff --git a/pkg/ui/src/views/cluster/containers/dataDistribution/zoneConfigList.tsx b/pkg/ui/src/views/cluster/containers/dataDistribution/zoneConfigList.tsx new file mode 100644 index 000000000000..d25fb99e8d92 --- /dev/null +++ b/pkg/ui/src/views/cluster/containers/dataDistribution/zoneConfigList.tsx @@ -0,0 +1,65 @@ +import React from "react"; +import { connect } from "react-redux"; +import _ from "lodash"; + +import { AdminUIState } from "src/redux/state"; +import { refreshReplicaMatrix } from "src/redux/apiReducers"; +import { cockroach } from "src/js/protos"; +import spinner from "assets/spinner.gif"; +import Loading from "src/views/shared/components/loading"; + +interface ZoneConfigListProps { + refreshReplicaMatrix: typeof refreshReplicaMatrix; + zoneConfigs: { [id: string]: cockroach.server.serverpb.ReplicaMatrixResponse.ZoneConfig }; +} + +class ZoneConfigList extends React.Component { + componentDidMount() { + this.props.refreshReplicaMatrix(); + } + + componentDidUpdate() { + this.props.refreshReplicaMatrix(); + } + + render() { + const sortedIDs = _.keys(this.props.zoneConfigs); + sortedIDs.sort(); + + return ( +

+ +
    + {sortedIDs.map((zcId) => { + const zoneConfig = this.props.zoneConfigs[zcId]; + return ( +
  • +

    {zoneConfig.cli_specifier}

    +
    +                    {zoneConfig.config_yaml}
    +                  
    +
  • + ); + })} +
+
+
+ ); + } +} + +// tslint:disable-next-line:variable-name +const ZoneConfigListConnected = connect( + (state: AdminUIState) => { + return { + zoneConfigs: state.cachedData.replicaMatrix.data + ? state.cachedData.replicaMatrix.data.zone_configs + : null, + }; + }, + { + refreshReplicaMatrix, + }, +)(ZoneConfigList); + +export default ZoneConfigListConnected; diff --git a/pkg/ui/src/views/reports/containers/debug/index.tsx b/pkg/ui/src/views/reports/containers/debug/index.tsx index 77dc393f25bf..a25b7cc6f681 100644 --- a/pkg/ui/src/views/reports/containers/debug/index.tsx +++ b/pkg/ui/src/views/reports/containers/debug/index.tsx @@ -66,8 +66,12 @@ export default function Debug() { note="#/reports/nodes?locality=[regex]" /> - + +