github.com/cockroachdb/cockroach@v20.2.0-alpha.1+incompatible/pkg/ui/src/views/cluster/containers/nodesOverview/index.tsx (about) 1 // Copyright 2018 The Cockroach Authors. 2 // 3 // Use of this software is governed by the Business Source License 4 // included in the file licenses/BSL.txt. 5 // 6 // As of the Change Date specified in that file, in accordance with 7 // the Business Source License, use of this software will be governed 8 // by the Apache License, Version 2.0, included in the file 9 // licenses/APL.txt. 10 11 import React from "react"; 12 import { Link } from "react-router-dom"; 13 import { connect } from "react-redux"; 14 import moment, { Moment } from "moment"; 15 import { createSelector } from "reselect"; 16 import _ from "lodash"; 17 18 import { 19 LivenessStatus, 20 nodeCapacityStats, 21 nodesSummarySelector, 22 partitionedStatuses, 23 selectNodesSummaryValid, 24 } from "src/redux/nodes"; 25 import { AdminUIState } from "src/redux/state"; 26 import { refreshNodes, refreshLiveness } from "src/redux/apiReducers"; 27 import { LocalSetting } from "src/redux/localsettings"; 28 import { SortSetting } from "src/views/shared/components/sortabletable"; 29 import { LongToMoment } from "src/util/convert"; 30 import { INodeStatus, MetricConstants } from "src/util/proto"; 31 import { ColumnsConfig, Table, Text, TextTypes, Tooltip, Badge, BadgeProps } from "src/components"; 32 import { Percentage } from "src/util/format"; 33 import { FixLong } from "src/util/fixLong"; 34 import { getNodeLocalityTiers } from "src/util/localities"; 35 import { LocalityTier } from "src/redux/localities"; 36 import { switchExhaustiveCheck } from "src/util/switchExhaustiveCheck"; 37 38 import TableSection from "./tableSection"; 39 import "./nodes.styl"; 40 41 const liveNodesSortSetting = new LocalSetting<AdminUIState, SortSetting>( 42 "nodes/live_sort_setting", (s) => s.localSettings, 43 ); 44 45 const decommissionedNodesSortSetting = new LocalSetting<AdminUIState, SortSetting>( 46 "nodes/decommissioned_sort_setting", (s) => s.localSettings, 47 ); 48 49 // AggregatedNodeStatus indexes have to be greater than LivenessStatus indexes 50 // for correct sorting in the table. 51 enum AggregatedNodeStatus { 52 LIVE = 6, 53 WARNING = 7, 54 DEAD = 8, 55 } 56 57 // Represents the aggregated dataset with possibly nested items 58 // for table view. Note: table columns do not match exactly to fields, 59 // instead, column values are computed based on these fields. 60 // It is required to reduce computation for top level (grouped) fields, 61 // and to allow sorting functionality with specific rather then on column value. 62 export interface NodeStatusRow { 63 key: string; 64 nodeId?: number; 65 nodeName?: string; 66 region?: string; 67 tiers?: LocalityTier[]; 68 nodesCount?: number; 69 uptime?: string; 70 replicas: number; 71 usedCapacity: number; 72 availableCapacity: number; 73 usedMemory: number; 74 availableMemory: number; 75 numCpus: number; 76 version?: string; 77 /* 78 * status is a union of Node statuses and two artificial statuses 79 * used to represent the status of top-level grouped items. 80 * If all nested nodes have Live status then the current item has Ready status. 81 * Otherwise, it has Warning status. 82 * */ 83 status: LivenessStatus | AggregatedNodeStatus; 84 children?: Array<NodeStatusRow>; 85 } 86 87 interface DecommissionedNodeStatusRow { 88 key: string; 89 nodeId: number; 90 nodeName: string; 91 status: LivenessStatus; 92 decommissionedDate: Moment; 93 } 94 95 /** 96 * NodeCategoryListProps are the properties shared by both LiveNodeList and 97 * NotLiveNodeList. 98 */ 99 interface NodeCategoryListProps { 100 sortSetting: SortSetting; 101 setSort: typeof liveNodesSortSetting.set; 102 } 103 104 interface LiveNodeListProps extends NodeCategoryListProps { 105 dataSource: NodeStatusRow[]; 106 nodesCount: number; 107 regionsCount: number; 108 } 109 110 interface DecommissionedNodeListProps extends NodeCategoryListProps { 111 dataSource: DecommissionedNodeStatusRow[]; 112 isCollapsible: boolean; 113 } 114 115 const getStatusDescription = (status: LivenessStatus) => { 116 switch (status) { 117 case LivenessStatus.LIVE: 118 return "This node is currently healthy."; 119 case LivenessStatus.DECOMMISSIONING: 120 return `This node is in the process of being decommissioned. 121 It may take some time to transfer the data to other nodes. 122 When finished, it will appear below as a decommissioned node.`; 123 default: 124 return "This node has not recently reported as being live. " + 125 "It may not be functioning correctly, but no automatic action has yet been taken."; 126 } 127 }; 128 129 const getBadgeTypeByNodeStatus = (status: LivenessStatus | AggregatedNodeStatus): BadgeProps["status"] => { 130 switch (status) { 131 case LivenessStatus.UNKNOWN: 132 return "warning"; 133 case LivenessStatus.DEAD: 134 return "danger"; 135 case LivenessStatus.UNAVAILABLE: 136 return "warning"; 137 case LivenessStatus.LIVE: 138 return "default"; 139 case LivenessStatus.DECOMMISSIONING: 140 return "warning"; 141 case LivenessStatus.DECOMMISSIONED: 142 return "default"; 143 case AggregatedNodeStatus.LIVE: 144 return "default"; 145 case AggregatedNodeStatus.WARNING: 146 return "warning"; 147 case AggregatedNodeStatus.DEAD: 148 return "danger"; 149 default: 150 return switchExhaustiveCheck(status); 151 } 152 }; 153 154 // tslint:disable-next-line:variable-name 155 const NodeNameColumn: React.FC<{ record: NodeStatusRow | DecommissionedNodeStatusRow }> = ({ record }) => { 156 return ( 157 <Link className="nodes-table__link" to={`/node/${record.nodeId}`}> 158 <Text>{record.nodeName}</Text> 159 <Text textType={TextTypes.BodyStrong}>{` (n${record.nodeId})`}</Text> 160 </Link> 161 ); 162 }; 163 164 // tslint:disable-next-line:variable-name 165 const NodeLocalityColumn: React.FC<{ record: NodeStatusRow }> = ({ record }) => { 166 return ( 167 <Text> 168 <Tooltip 169 placement={"bottom"} 170 title={ 171 <div> 172 { 173 record.tiers.map((tier, idx) => 174 <div key={idx}>{`${tier.key} = ${tier.value}`}</div>) 175 } 176 </div> 177 } 178 > 179 {record.region} 180 </Tooltip> 181 </Text> 182 ); 183 }; 184 185 /** 186 * LiveNodeList displays a sortable table of all "live" nodes, which includes 187 * both healthy and suspect nodes. Included is a side-bar with summary 188 * statistics for these nodes. 189 */ 190 export class NodeList extends React.Component<LiveNodeListProps> { 191 192 readonly columns: ColumnsConfig<NodeStatusRow> = [ 193 { 194 key: "region", 195 title: "nodes", 196 render: (_text, record) => { 197 if (!!record.nodeId) { 198 return <NodeNameColumn record={record} />; 199 } else { 200 return <NodeLocalityColumn record={record} />; 201 } 202 }, 203 sorter: (a, b) => { 204 if (!_.isUndefined(a.nodeId) && !_.isUndefined(b.nodeId)) { return 0; } 205 if (a.region < b.region) { return -1; } 206 if (a.region > b.region) { return 1; } 207 return 0; 208 }, 209 className: "column--border-right", 210 width: "20%", 211 }, 212 { 213 key: "nodesCount", 214 title: "node count", 215 sorter: (a, b) => { 216 if (_.isUndefined(a.nodesCount) || _.isUndefined(b.nodesCount)) { return 0; } 217 if (a.nodesCount < b.nodesCount) { return -1; } 218 if (a.nodesCount > b.nodesCount) { return 1; } 219 return 0; 220 }, 221 render: (_text, record) => record.nodesCount, 222 sortDirections: ["ascend", "descend"], 223 className: "column--align-right", 224 width: "10%", 225 }, 226 { 227 key: "uptime", 228 dataIndex: "uptime", 229 title: "uptime", 230 sorter: true, 231 className: "column--align-right", 232 width: "10%", 233 ellipsis: true, 234 }, 235 { 236 key: "replicas", 237 dataIndex: "replicas", 238 title: "replicas", 239 sorter: true, 240 className: "column--align-right", 241 width: "10%", 242 }, 243 { 244 key: "capacityUse", 245 title: "capacity use", 246 render: (_text, record) => Percentage(record.usedCapacity, record.availableCapacity), 247 sorter: (a, b) => 248 a.usedCapacity / a.availableCapacity - b.usedCapacity / b.availableCapacity, 249 className: "column--align-right", 250 width: "10%", 251 }, 252 { 253 key: "memoryUse", 254 title: "memory use", 255 render: (_text, record) => Percentage(record.usedMemory, record.availableMemory), 256 sorter: (a, b) => 257 a.usedMemory / a.availableMemory - b.usedMemory / b.availableMemory, 258 className: "column--align-right", 259 width: "10%", 260 }, 261 { 262 key: "numCpus", 263 title: "cpus", 264 dataIndex: "numCpus", 265 sorter: true, 266 className: "column--align-right", 267 width: "8%", 268 }, 269 { 270 key: "version", 271 dataIndex: "version", 272 title: "version", 273 sorter: true, 274 width: "8%", 275 ellipsis: true, 276 }, 277 { 278 key: "status", 279 render: (_text, record) => { 280 let badgeText: string; 281 let tooltipText: string; 282 const badgeType = getBadgeTypeByNodeStatus(record.status); 283 284 switch (record.status) { 285 case AggregatedNodeStatus.DEAD: 286 badgeText = "warning"; 287 break; 288 case AggregatedNodeStatus.LIVE: 289 case AggregatedNodeStatus.WARNING: 290 badgeText = AggregatedNodeStatus[record.status]; 291 break; 292 case LivenessStatus.UNKNOWN: 293 case LivenessStatus.UNAVAILABLE: 294 badgeText = "suspect"; 295 tooltipText = getStatusDescription(record.status); 296 break; 297 default: 298 badgeText = LivenessStatus[record.status]; 299 tooltipText = getStatusDescription(record.status); 300 break; 301 } 302 return ( 303 <Badge 304 status={badgeType} 305 text={ 306 <Tooltip title={tooltipText}> 307 {badgeText} 308 </Tooltip> 309 } 310 /> 311 ); 312 }, 313 title: "status", 314 sorter: (a, b) => a.status - b.status, 315 width: "13%", 316 }, 317 { 318 key: "logs", 319 title: "", 320 render: (_text, record) => record.nodeId && ( 321 <div className="cell--show-on-hover nodes-table__link"> 322 <Link to={`/node/${record.nodeId}/logs`}>Logs</Link> 323 </div>), 324 width: "5%", 325 }, 326 ]; 327 328 render() { 329 const { nodesCount, regionsCount } = this.props; 330 let columns = this.columns; 331 let dataSource = this.props.dataSource; 332 333 // Remove "Nodes Count" column If nodes are not partitioned by regions, 334 if (regionsCount === 1) { 335 columns = columns.filter(column => column.key !== "nodesCount"); 336 dataSource = _.head(dataSource).children; 337 } 338 return ( 339 <div className="nodes-overview__panel"> 340 <TableSection 341 id={`nodes-overview__live-nodes`} 342 title={`Nodes (${nodesCount})`} 343 className="embedded-table"> 344 <Table 345 dataSource={dataSource} 346 columns={columns} 347 tableLayout="fixed" 348 className="nodes-overview__live-nodes-table" 349 /> 350 </TableSection> 351 </div> 352 ); 353 } 354 } 355 356 /** 357 * DecommissionedNodeList renders a view with a table for recently "decommissioned" 358 * nodes on a link on a full list of decommissioned nodes. 359 */ 360 class DecommissionedNodeList extends React.Component<DecommissionedNodeListProps> { 361 columns: ColumnsConfig<DecommissionedNodeStatusRow> = [ 362 { 363 key: "nodes", 364 title: "decommissioned nodes", 365 render: (_text, record) => 366 <NodeNameColumn record={record}/>, 367 }, 368 { 369 key: "decommissionedSince", 370 title: "decommissioned on", 371 render: (_text, record) => record.decommissionedDate.format("LL[ at ]h:mm a"), 372 }, 373 { 374 key: "status", 375 title: "status", 376 render: (_text, record) => { 377 const badgeText = _.capitalize(LivenessStatus[record.status]); 378 const tooltipText = getStatusDescription(record.status); 379 return ( 380 <Badge 381 status="default" 382 text={ 383 <Tooltip title={tooltipText}> 384 {badgeText} 385 </Tooltip> 386 } 387 /> 388 ); 389 }, 390 }, 391 ]; 392 393 render() { 394 const { dataSource, isCollapsible } = this.props; 395 if (_.isEmpty(dataSource)) { 396 return null; 397 } 398 399 return ( 400 <div className="nodes-overview__panel"> 401 <TableSection 402 id={`nodes-overview__decommissioned-nodes`} 403 title="Recently Decommissioned Nodes" 404 footer={<Link to={`/reports/nodes/history`}>View all decommissioned nodes </Link>} 405 isCollapsible={isCollapsible} 406 className="embedded-table embedded-table--dense"> 407 <Table 408 dataSource={dataSource} 409 columns={this.columns} 410 className="nodes-overview__decommissioned-nodes-table" 411 /> 412 </TableSection> 413 </div> 414 ); 415 } 416 } 417 418 export const liveNodesTableDataSelector = createSelector( 419 partitionedStatuses, 420 nodesSummarySelector, 421 (statuses, nodesSummary) => { 422 const liveStatuses = statuses.live || []; 423 424 // Do not display aggregated category and # of nodes column 425 // when `withLocalitiesSetup` is false. 426 // const withLocalitiesSetup = liveStatuses.some(getNodeRegion); 427 428 // `data` can be represented as nested or flat structure. 429 // In case cluster is geo partitioned or at least one locality is specified: 430 // - nodes are grouped by region 431 // - top level record contains aggregated information about nodes in current region 432 // In case cluster is setup without localities: 433 // - it represents a flat structure. 434 const data = _.chain(liveStatuses) 435 .groupBy((node: INodeStatus) => { 436 return node.desc.locality.tiers.map(tier => tier.value).join("."); 437 }) 438 .map((nodesPerRegion: INodeStatus[], regionKey: string): NodeStatusRow => { 439 const nestedRows = nodesPerRegion.map((ns, idx): NodeStatusRow => { 440 const { used: usedCapacity, usable: availableCapacity } = nodeCapacityStats(ns); 441 return { 442 key: `${regionKey}-${idx}`, 443 nodeId: ns.desc.node_id, 444 nodeName: ns.desc.address.address_field, 445 uptime: moment.duration(LongToMoment(ns.started_at).diff(moment())).humanize(), 446 replicas: ns.metrics[MetricConstants.replicas], 447 usedCapacity, 448 availableCapacity, 449 usedMemory: ns.metrics[MetricConstants.rss], 450 availableMemory: FixLong(ns.total_system_memory).toNumber(), 451 numCpus: ns.num_cpus, 452 version: ns.build_info.tag, 453 status: nodesSummary.livenessStatusByNodeID[ns.desc.node_id] || LivenessStatus.LIVE, 454 }; 455 }); 456 457 // Grouped buckets with node statuses contain at least one element. 458 // The list of tires and lower level location are the same for every 459 // element in the group because grouping is made by string composed 460 // from location values. 461 const firstNodeInGroup = nodesPerRegion[0]; 462 const tiers = getNodeLocalityTiers(firstNodeInGroup); 463 const lastTier = _.last(tiers); 464 465 const getLocalityStatus = () => { 466 const nodesByStatus = _.groupBy(nestedRows, (row: NodeStatusRow) => row.status); 467 468 // Return DEAD status if at least one node is dead; 469 if (!_.isEmpty(nodesByStatus[LivenessStatus.DEAD])) { 470 return AggregatedNodeStatus.DEAD; 471 } 472 473 // Return WARNING status if at least one node is decommissioning or suspected; 474 if (!_.isEmpty(nodesByStatus[LivenessStatus.DECOMMISSIONING]) 475 || !_.isEmpty(nodesByStatus[LivenessStatus.UNKNOWN]) 476 || !_.isEmpty(nodesByStatus[LivenessStatus.UNAVAILABLE])) { 477 return AggregatedNodeStatus.WARNING; 478 } 479 480 return AggregatedNodeStatus.LIVE; 481 }; 482 483 return { 484 key: `${regionKey}`, 485 region: lastTier?.value, 486 tiers, 487 nodesCount: nodesPerRegion.length, 488 replicas: _.sum(nestedRows.map(nr => nr.replicas)), 489 usedCapacity: _.sum(nestedRows.map(nr => nr.usedCapacity)), 490 availableCapacity: _.sum(nestedRows.map(nr => nr.availableCapacity)), 491 usedMemory: _.sum(nestedRows.map(nr => nr.usedMemory)), 492 availableMemory: _.sum(nestedRows.map(nr => nr.availableMemory)), 493 numCpus: _.sum(nestedRows.map(nr => nr.numCpus)), 494 status: getLocalityStatus(), 495 children: nestedRows, 496 }; 497 }) 498 .value(); 499 500 return data; 501 }); 502 503 export const decommissionedNodesTableDataSelector = createSelector( 504 partitionedStatuses, 505 nodesSummarySelector, 506 (statuses, nodesSummary): DecommissionedNodeStatusRow[] => { 507 const decommissionedStatuses = statuses.decommissioned || []; 508 509 const getDecommissionedTime = (nodeId: number) => { 510 const liveness = nodesSummary.livenessByNodeID[nodeId]; 511 if (!liveness) { 512 return undefined; 513 } 514 const deadTime = liveness.expiration.wall_time; 515 return LongToMoment(deadTime); 516 }; 517 518 // DecommissionedNodeList displays 5 most recent nodes. 519 const data = _.chain(decommissionedStatuses) 520 .orderBy([(ns: INodeStatus) => getDecommissionedTime(ns.desc.node_id)], ["desc"]) 521 .take(5) 522 .map((ns: INodeStatus, idx: number) => { 523 return { 524 key: `${idx}`, 525 nodeId: ns.desc.node_id, 526 nodeName: ns.desc.address.address_field, 527 status: nodesSummary.livenessStatusByNodeID[ns.desc.node_id], 528 decommissionedDate: getDecommissionedTime(ns.desc.node_id), 529 }; 530 }) 531 .value(); 532 return data; 533 }); 534 535 /** 536 * LiveNodesConnected is a redux-connected HOC of LiveNodeList. 537 */ 538 // tslint:disable-next-line:variable-name 539 const NodesConnected = connect( 540 (state: AdminUIState) => { 541 const liveNodes = partitionedStatuses(state).live || []; 542 const data = liveNodesTableDataSelector(state); 543 return { 544 sortSetting: liveNodesSortSetting.selector(state), 545 dataSource: data, 546 nodesCount: liveNodes.length, 547 regionsCount: data.length, 548 }; 549 }, 550 { 551 setSort: liveNodesSortSetting.set, 552 }, 553 )(NodeList); 554 555 /** 556 * DecommissionedNodesConnected is a redux-connected HOC of NotLiveNodeList. 557 */ 558 // tslint:disable-next-line:variable-name 559 const DecommissionedNodesConnected = connect( 560 (state: AdminUIState) => { 561 return { 562 sortSetting: decommissionedNodesSortSetting.selector(state), 563 dataSource: decommissionedNodesTableDataSelector(state), 564 isCollapsible: true, 565 }; 566 }, 567 { 568 setSort: decommissionedNodesSortSetting.set, 569 }, 570 )(DecommissionedNodeList); 571 572 /** 573 * NodesMainProps is the type of the props object that must be passed to 574 * NodesMain component. 575 */ 576 interface NodesMainProps { 577 // Call if the nodes statuses are stale and need to be refreshed. 578 refreshNodes: typeof refreshNodes; 579 // Call if the liveness statuses are stale and need to be refreshed. 580 refreshLiveness: typeof refreshLiveness; 581 // True if current status results are still valid. Needed so that this 582 // component refreshes status query when it becomes invalid. 583 nodesSummaryValid: boolean; 584 } 585 586 /** 587 * Renders the main content of the nodes page, which is primarily a data table 588 * of all nodes. 589 */ 590 class NodesMain extends React.Component<NodesMainProps, {}> { 591 componentDidMount() { 592 // Refresh nodes status query when mounting. 593 this.props.refreshNodes(); 594 this.props.refreshLiveness(); 595 } 596 597 componentDidUpdate() { 598 // Refresh nodes status query when props are received; this will immediately 599 // trigger a new request if previous results are invalidated. 600 this.props.refreshNodes(); 601 this.props.refreshLiveness(); 602 } 603 604 render() { 605 return ( 606 <div className="nodes-overview"> 607 <NodesConnected /> 608 <DecommissionedNodesConnected /> 609 </div> 610 ); 611 } 612 } 613 614 /** 615 * NodesMainConnected is a redux-connected HOC of NodesMain. 616 */ 617 // tslint:disable-next-line:variable-name 618 const NodesMainConnected = connect( 619 (state: AdminUIState) => { 620 return { 621 nodesSummaryValid: selectNodesSummaryValid(state), 622 }; 623 }, 624 { 625 refreshNodes, 626 refreshLiveness, 627 }, 628 )(NodesMain); 629 630 export { NodesMainConnected as NodesOverview };