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 };