Repository: provectus/kafka-ui
Issue: View broker skew in kafka-ui
Labels: type/enhancement
scope/backend
scope/frontend
status/accepted

Hi there,
is there a way to see if there is a broker skew in kafka ui?

screenshot from conduktor.

<img width="1465" alt="MicrosoftTeams-image" src="https://user-images.githubusercontent.com/51237742/222124221-865dcf40-3a9c-49a4-a60f-60359bde667e.png">

Cheers

Issue comments:
TODO, brokers page:
add:
- partitions count per broker (+skew %)
- leaders count per broker (+skew %)

Frontend:
Brokers table:
- merge segment size & segment count, make it a single column "Disk usage", data format: "%Size%, %x% segment(s)"
- Partitions skew (Column tooltip: "The divergence from the average brokers' value"). Display "-" if null, partitionsSkew field. 
**Percentage might be either negative or positive**
Change the font color depending on the skew:
10-20% -> yellow
20%+ -> red
- Leaders
- Leader skew
- Online partitions (inSyncPartitions of partitions). If inSyncPartitions != partitions -> font color red

Pull request review comments:
why are we added this ? 
After changes of the `SizeCell` props the props string needs to be formatted as below and the eslint-disable comment ignored, so I added `AsAny`, to ignore it before props declaration
lets not call `getValue` multiple times lets just call it once and put it in a variable
Updated

Patch that resolved the issue:
diff --git a/kafka-ui-api/src/main/java/com/provectus/kafka/ui/model/PartitionDistributionStats.java b/kafka-ui-api/src/main/java/com/provectus/kafka/ui/model/PartitionDistributionStats.java
index b625533d1dd..46efc670008 100644
--- a/kafka-ui-api/src/main/java/com/provectus/kafka/ui/model/PartitionDistributionStats.java
+++ b/kafka-ui-api/src/main/java/com/provectus/kafka/ui/model/PartitionDistributionStats.java
@@ -1,7 +1,7 @@
 package com.provectus.kafka.ui.model;
 
 import java.math.BigDecimal;
-import java.math.MathContext;
+import java.math.RoundingMode;
 import java.util.HashMap;
 import java.util.Map;
 import javax.annotation.Nullable;
@@ -21,8 +21,6 @@ public class PartitionDistributionStats {
   // avg skew will show unuseful results on low number of partitions
   private static final int MIN_PARTITIONS_FOR_SKEW_CALCULATION = 50;
 
-  private static final MathContext ROUNDING_MATH_CTX = new MathContext(3);
-
   private final Map<Node, Integer> partitionLeaders;
   private final Map<Node, Integer> partitionsCount;
   private final Map<Node, Integer> inSyncPartitions;
@@ -88,6 +86,7 @@ private BigDecimal calculateAvgSkew(@Nullable Integer value, double avgValue) {
       return null;
     }
     value = value == null ? 0 : value;
-    return new BigDecimal((value - avgValue) / avgValue * 100.0).round(ROUNDING_MATH_CTX);
+    return new BigDecimal((value - avgValue) / avgValue * 100.0)
+        .setScale(1, RoundingMode.HALF_UP);
   }
 }
diff --git a/kafka-ui-e2e-checks/src/main/java/com/provectus/kafka/ui/pages/brokers/BrokersList.java b/kafka-ui-e2e-checks/src/main/java/com/provectus/kafka/ui/pages/brokers/BrokersList.java
index 50ecdff3597..9e81a0795cc 100644
--- a/kafka-ui-e2e-checks/src/main/java/com/provectus/kafka/ui/pages/brokers/BrokersList.java
+++ b/kafka-ui-e2e-checks/src/main/java/com/provectus/kafka/ui/pages/brokers/BrokersList.java
@@ -48,7 +48,8 @@ public List<SelenideElement> getAllVisibleElements() {
   }
 
   private List<SelenideElement> getEnabledColumnHeaders() {
-    return Stream.of("Broker ID", "Segment Size", "Segment Count", "Port", "Host")
+    return Stream.of("Broker ID", "Disk usage", "Partitions skew",
+            "Leaders", "Leader skew", "Online partitions", "Port", "Host")
         .map(name -> $x(String.format(columnHeaderLocator, name)))
         .collect(Collectors.toList());
   }
diff --git a/kafka-ui-react-app/src/components/Brokers/BrokersList/BrokersList.tsx b/kafka-ui-react-app/src/components/Brokers/BrokersList/BrokersList.tsx
index 966edecf1fd..d8cd0a2f763 100644
--- a/kafka-ui-react-app/src/components/Brokers/BrokersList/BrokersList.tsx
+++ b/kafka-ui-react-app/src/components/Brokers/BrokersList/BrokersList.tsx
@@ -11,7 +11,9 @@ import CheckMarkRoundIcon from 'components/common/Icons/CheckMarkRoundIcon';
 import { ColumnDef } from '@tanstack/react-table';
 import { clusterBrokerPath } from 'lib/paths';
 import Tooltip from 'components/common/Tooltip/Tooltip';
+import ColoredCell from 'components/common/NewTable/ColoredCell';
 
+import SkewHeader from './SkewHeader/SkewHeader';
 import * as S from './BrokersList.styled';
 
 const NA = 'N/A';
@@ -57,11 +59,15 @@ const BrokersList: React.FC = () => {
         count: segmentCount || NA,
         port: broker?.port,
         host: broker?.host,
+        partitionsLeader: broker?.partitionsLeader,
+        partitionsSkew: broker?.partitionsSkew,
+        leadersSkew: broker?.leadersSkew,
+        inSyncPartitions: broker?.inSyncPartitions,
       };
     });
   }, [diskUsage, brokers]);
 
-  const columns = React.useMemo<ColumnDef<typeof rows>[]>(
+  const columns = React.useMemo<ColumnDef<(typeof rows)[number]>[]>(
     () => [
       {
         header: 'Broker ID',
@@ -84,7 +90,7 @@ const BrokersList: React.FC = () => {
         ),
       },
       {
-        header: 'Segment Size',
+        header: 'Disk usage',
         accessorKey: 'size',
         // eslint-disable-next-line react/no-unstable-nested-components
         cell: ({ getValue, table, cell, column, renderValue, row }) =>
@@ -98,10 +104,56 @@ const BrokersList: React.FC = () => {
               cell={cell}
               getValue={getValue}
               renderValue={renderValue}
+              renderSegments
             />
           ),
       },
-      { header: 'Segment Count', accessorKey: 'count' },
+      {
+        // eslint-disable-next-line react/no-unstable-nested-components
+        header: () => <SkewHeader />,
+        accessorKey: 'partitionsSkew',
+        // eslint-disable-next-line react/no-unstable-nested-components
+        cell: ({ getValue }) => {
+          const value = getValue<number>();
+          return (
+            <ColoredCell
+              value={value ? `${value.toFixed(2)}%` : '-'}
+              warn={value >= 10 && value < 20}
+              attention={value >= 20}
+            />
+          );
+        },
+      },
+      { header: 'Leaders', accessorKey: 'partitionsLeader' },
+      {
+        header: 'Leader skew',
+        accessorKey: 'leadersSkew',
+        // eslint-disable-next-line react/no-unstable-nested-components
+        cell: ({ getValue }) => {
+          const value = getValue<number>();
+          return (
+            <ColoredCell
+              value={value ? `${value.toFixed(2)}%` : '-'}
+              warn={value >= 10 && value < 20}
+              attention={value >= 20}
+            />
+          );
+        },
+      },
+      {
+        header: 'Online partitions',
+        accessorKey: 'inSyncPartitions',
+        // eslint-disable-next-line react/no-unstable-nested-components
+        cell: ({ getValue, row }) => {
+          const value = getValue<number>();
+          return (
+            <ColoredCell
+              value={value}
+              attention={value !== row.original.count}
+            />
+          );
+        },
+      },
       { header: 'Port', accessorKey: 'port' },
       {
         header: 'Host',
diff --git a/kafka-ui-react-app/src/components/Brokers/BrokersList/SkewHeader/SkewHeader.styled.ts b/kafka-ui-react-app/src/components/Brokers/BrokersList/SkewHeader/SkewHeader.styled.ts
new file mode 100644
index 00000000000..eea2fa3cd98
--- /dev/null
+++ b/kafka-ui-react-app/src/components/Brokers/BrokersList/SkewHeader/SkewHeader.styled.ts
@@ -0,0 +1,11 @@
+import styled from 'styled-components';
+import { MessageTooltip } from 'components/common/Tooltip/Tooltip.styled';
+
+export const CellWrapper = styled.div`
+  display: flex;
+  gap: 10px;
+
+  ${MessageTooltip} {
+    max-height: unset;
+  }
+`;
diff --git a/kafka-ui-react-app/src/components/Brokers/BrokersList/SkewHeader/SkewHeader.tsx b/kafka-ui-react-app/src/components/Brokers/BrokersList/SkewHeader/SkewHeader.tsx
new file mode 100644
index 00000000000..978d1768dd7
--- /dev/null
+++ b/kafka-ui-react-app/src/components/Brokers/BrokersList/SkewHeader/SkewHeader.tsx
@@ -0,0 +1,17 @@
+import React from 'react';
+import Tooltip from 'components/common/Tooltip/Tooltip';
+import InfoIcon from 'components/common/Icons/InfoIcon';
+
+import * as S from './SkewHeader.styled';
+
+const SkewHeader: React.FC = () => (
+  <S.CellWrapper>
+    Partitions skew
+    <Tooltip
+      value={<InfoIcon />}
+      content="The divergence from the average brokers' value"
+    />
+  </S.CellWrapper>
+);
+
+export default SkewHeader;
diff --git a/kafka-ui-react-app/src/components/common/NewTable/ColoredCell.tsx b/kafka-ui-react-app/src/components/common/NewTable/ColoredCell.tsx
new file mode 100644
index 00000000000..df8ab2d6a8d
--- /dev/null
+++ b/kafka-ui-react-app/src/components/common/NewTable/ColoredCell.tsx
@@ -0,0 +1,41 @@
+import React from 'react';
+import styled from 'styled-components';
+
+interface CellProps {
+  isWarning?: boolean;
+  isAttention?: boolean;
+}
+
+interface ColoredCellProps {
+  value: number | string;
+  warn?: boolean;
+  attention?: boolean;
+}
+
+const Cell = styled.div<CellProps>`
+  color: ${(props) => {
+    if (props.isAttention) {
+      return props.theme.table.colored.color.attention;
+    }
+
+    if (props.isWarning) {
+      return props.theme.table.colored.color.warning;
+    }
+
+    return 'inherit';
+  }};
+`;
+
+const ColoredCell: React.FC<ColoredCellProps> = ({
+  value,
+  warn,
+  attention,
+}) => {
+  return (
+    <Cell isWarning={warn} isAttention={attention}>
+      {value}
+    </Cell>
+  );
+};
+
+export default ColoredCell;
diff --git a/kafka-ui-react-app/src/components/common/NewTable/SizeCell.tsx b/kafka-ui-react-app/src/components/common/NewTable/SizeCell.tsx
index 00a60086d9d..24485342aa4 100644
--- a/kafka-ui-react-app/src/components/common/NewTable/SizeCell.tsx
+++ b/kafka-ui-react-app/src/components/common/NewTable/SizeCell.tsx
@@ -3,8 +3,15 @@ import { CellContext } from '@tanstack/react-table';
 import BytesFormatted from 'components/common/BytesFormatted/BytesFormatted';
 
 // eslint-disable-next-line @typescript-eslint/no-explicit-any
-const SizeCell: React.FC<CellContext<any, unknown>> = ({ getValue }) => (
-  <BytesFormatted value={getValue<string | number>()} />
+type AsAny = any;
+
+const SizeCell: React.FC<
+  CellContext<AsAny, unknown> & { renderSegments?: boolean }
+> = ({ getValue, row, renderSegments = false }) => (
+  <>
+    <BytesFormatted value={getValue<string | number>()} />
+    {renderSegments ? `, ${row?.original.count} segment(s)` : null}
+  </>
 );
 
 export default SizeCell;
diff --git a/kafka-ui-react-app/src/theme/theme.ts b/kafka-ui-react-app/src/theme/theme.ts
index 33dbf1c619e..80cc58991c3 100644
--- a/kafka-ui-react-app/src/theme/theme.ts
+++ b/kafka-ui-react-app/src/theme/theme.ts
@@ -533,6 +533,12 @@ export const theme = {
         active: Colors.neutral[90],
       },
     },
+    colored: {
+      color: {
+        attention: Colors.red[50],
+        warning: Colors.yellow[20],
+      },
+    },
     expander: {
       normal: Colors.brand[30],
       hover: Colors.brand[40],
@@ -928,6 +934,12 @@ export const darkTheme: ThemeType = {
         active: Colors.neutral[0],
       },
     },
+    colored: {
+      color: {
+        attention: Colors.red[50],
+        warning: Colors.yellow[20],
+      },
+    },
     expander: {
       normal: Colors.brand[30],
       hover: Colors.brand[40],


Test patch: