diff --git a/e2e-tests/src/main/java/io/kafbat/ui/screens/connectors/KafkaConnectList.java b/e2e-tests/src/main/java/io/kafbat/ui/screens/connectors/KafkaConnectList.java index 5045e99d0..6dddae78b 100644 --- a/e2e-tests/src/main/java/io/kafbat/ui/screens/connectors/KafkaConnectList.java +++ b/e2e-tests/src/main/java/io/kafbat/ui/screens/connectors/KafkaConnectList.java @@ -13,13 +13,21 @@ public class KafkaConnectList extends BasePage { protected SelenideElement createConnectorBtn = $x("//button[contains(text(),'Create Connector')]"); + protected SelenideElement connectorsTab = $x("//a[contains(text(),'Connectors')]"); public KafkaConnectList() { tableElementNameLocator = "//tbody//td[contains(text(),'%s')]"; } + @Step + public KafkaConnectList clickConnectorsTab() { + WebUtil.clickByJavaScript(connectorsTab); + return this; + } + @Step public KafkaConnectList waitUntilScreenReady() { + clickConnectorsTab(); waitUntilSpinnerDisappear(); getPageTitleFromHeader(KAFKA_CONNECT).shouldBe(Condition.visible); return this; diff --git a/e2e-tests/src/main/java/io/kafbat/ui/screens/panels/enums/MenuItem.java b/e2e-tests/src/main/java/io/kafbat/ui/screens/panels/enums/MenuItem.java index 283d428a1..caa6025eb 100644 --- a/e2e-tests/src/main/java/io/kafbat/ui/screens/panels/enums/MenuItem.java +++ b/e2e-tests/src/main/java/io/kafbat/ui/screens/panels/enums/MenuItem.java @@ -10,7 +10,7 @@ public enum MenuItem { TOPICS("Topics", "Topics"), CONSUMERS("Consumers", "Consumers"), SCHEMA_REGISTRY("Schema Registry", "Schema Registry"), - KAFKA_CONNECT("Kafka Connect", "Connectors"), + KAFKA_CONNECT("Kafka Connect", "Kafka Connect"), KSQL_DB("KSQL DB", "KSQL DB"); private final String naviTitle; diff --git a/e2e-tests/src/main/java/io/kafbat/ui/variables/Url.java b/e2e-tests/src/main/java/io/kafbat/ui/variables/Url.java index 93eee2676..29591b882 100644 --- a/e2e-tests/src/main/java/io/kafbat/ui/variables/Url.java +++ b/e2e-tests/src/main/java/io/kafbat/ui/variables/Url.java @@ -6,6 +6,6 @@ public interface Url { String TOPICS_LIST_URL = "http://%s:8080/ui/clusters/local/all-topics"; String CONSUMERS_LIST_URL = "http://%s:8080/ui/clusters/local/consumer-groups"; String SCHEMA_REGISTRY_LIST_URL = "http://%s:8080/ui/clusters/local/schemas"; - String KAFKA_CONNECT_LIST_URL = "http://%s:8080/ui/clusters/local/connectors"; + String KAFKA_CONNECT_LIST_URL = "http://%s:8080/ui/clusters/local/kafka-connect/connectors"; String KSQL_DB_LIST_URL = "http://%s:8080/ui/clusters/local/ksqldb/tables"; } diff --git a/frontend/src/components/ClusterPage/ClusterPage.tsx b/frontend/src/components/ClusterPage/ClusterPage.tsx index 29d2015f6..a0cc13e13 100644 --- a/frontend/src/components/ClusterPage/ClusterPage.tsx +++ b/frontend/src/components/ClusterPage/ClusterPage.tsx @@ -4,8 +4,6 @@ import useAppParams from 'lib/hooks/useAppParams'; import { ClusterFeaturesEnum } from 'generated-sources'; import { clusterBrokerRelativePath, - clusterConnectorsRelativePath, - clusterConnectsRelativePath, clusterConsumerGroupsRelativePath, clusterKsqlDbRelativePath, ClusterNameRoute, @@ -14,16 +12,23 @@ import { clusterConfigRelativePath, getNonExactPath, clusterAclRelativePath, + kafkaConnectRelativePath, + clusterConnectorNewRelativePath, + clusterConnectConnectorRelativePath, } from 'lib/paths'; import ClusterContext from 'components/contexts/ClusterContext'; import PageLoader from 'components/common/PageLoader/PageLoader'; import { useClusters } from 'lib/hooks/api/clusters'; import { GlobalSettingsContext } from 'components/contexts/GlobalSettingsContext'; +import New from 'components/Connect/New/New'; +import SuspenseQueryComponent from 'components/common/SuspenseQueryComponent/SuspenseQueryComponent'; +import DetailsPage from 'components/Connect/Details/DetailsPage'; const Brokers = React.lazy(() => import('components/Brokers/Brokers')); const Topics = React.lazy(() => import('components/Topics/Topics')); const Schemas = React.lazy(() => import('components/Schemas/Schemas')); -const Connect = React.lazy(() => import('components/Connect/Connect')); +const KafkaConnect = React.lazy(() => import('components/Connect/Connect')); + const KsqlDb = React.lazy(() => import('components/KsqlDb/KsqlDb')); const ClusterConfigPage = React.lazy( () => import('components/ClusterPage/ClusterConfigPage') @@ -82,16 +87,23 @@ const ClusterPage: React.FC = () => { element={} /> )} + {contextValue.hasKafkaConnectConfigured && ( + } /> + )} {contextValue.hasKafkaConnectConfigured && ( } + path={getNonExactPath(clusterConnectConnectorRelativePath)} + element={ + + + + } /> )} {contextValue.hasKafkaConnectConfigured && ( } + path={getNonExactPath(kafkaConnectRelativePath)} + element={} /> )} {contextValue.hasKsqlDbConfigured && ( diff --git a/frontend/src/components/ClusterPage/__tests__/ClusterPage.spec.tsx b/frontend/src/components/ClusterPage/__tests__/ClusterPage.spec.tsx index b66fd0a0b..238c13155 100644 --- a/frontend/src/components/ClusterPage/__tests__/ClusterPage.spec.tsx +++ b/frontend/src/components/ClusterPage/__tests__/ClusterPage.spec.tsx @@ -6,12 +6,12 @@ import { render, WithRoute } from 'lib/testHelpers'; import { clusterBrokersPath, clusterConnectorsPath, - clusterConnectsPath, clusterConsumerGroupsPath, clusterKsqlDbPath, clusterPath, clusterSchemasPath, clusterTopicsPath, + kafkaConnectPath, } from 'lib/paths'; import { useClusters } from 'lib/hooks/api/clusters'; import { onlineClusterPayload } from 'lib/fixtures/clusters'; @@ -19,7 +19,7 @@ import { onlineClusterPayload } from 'lib/fixtures/clusters'; const CLusterCompText = { Topics: 'Topics', Schemas: 'Schemas', - Connect: 'Connect', + Connect: 'Kafka Connect', Brokers: 'Brokers', ConsumerGroups: 'ConsumerGroups', KsqlDb: 'KsqlDb', @@ -111,7 +111,7 @@ describe('ClusterPage', () => { itCorrectlyHandlesConfiguredSchema( ClusterFeaturesEnum.KAFKA_CONNECT, CLusterCompText.Connect, - clusterConnectsPath(onlineClusterPayload.name) + kafkaConnectPath(onlineClusterPayload.name) ); itCorrectlyHandlesConfiguredSchema( ClusterFeaturesEnum.KAFKA_CONNECT, diff --git a/frontend/src/components/Connect/Clusters/Clusters.tsx b/frontend/src/components/Connect/Clusters/Clusters.tsx new file mode 100644 index 000000000..2b98ea953 --- /dev/null +++ b/frontend/src/components/Connect/Clusters/Clusters.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import { useConnects } from 'lib/hooks/api/kafkaConnect'; +import useAppParams from 'lib/hooks/useAppParams'; +import { ClusterNameRoute } from 'lib/paths'; + +import ClustersStatistics from './ui/Statistics/Statistics'; +import List from './ui/List/List'; + +const KafkaConnectClustersPage = () => { + const { clusterName } = useAppParams(); + const { data: connects, isLoading } = useConnects(clusterName, true); + return ( + <> + + + + ); +}; + +export default KafkaConnectClustersPage; diff --git a/frontend/src/components/Connect/Clusters/ui/List/Cells/ConnectorsCell.tsx b/frontend/src/components/Connect/Clusters/ui/List/Cells/ConnectorsCell.tsx new file mode 100644 index 000000000..2319cc07d --- /dev/null +++ b/frontend/src/components/Connect/Clusters/ui/List/Cells/ConnectorsCell.tsx @@ -0,0 +1,26 @@ +import AlertBadge from 'components/common/AlertBadge/AlertBadge'; +import { Connect } from 'generated-sources'; +import React from 'react'; + +type Props = { connect: Connect }; +const ConnectorsCell = ({ connect }: Props) => { + const count = connect.connectorsCount ?? 0; + const failedCount = connect.failedConnectorsCount ?? 0; + const text = `${count - failedCount}/${count}`; + + if (count === 0) { + return null; + } + + if (failedCount > 0) { + return ( + + + + + ); + } + + return text; +}; +export default ConnectorsCell; diff --git a/frontend/src/components/Connect/Clusters/ui/List/Cells/NameCell.tsx b/frontend/src/components/Connect/Clusters/ui/List/Cells/NameCell.tsx new file mode 100644 index 000000000..dece56545 --- /dev/null +++ b/frontend/src/components/Connect/Clusters/ui/List/Cells/NameCell.tsx @@ -0,0 +1,9 @@ +import { CellContext } from '@tanstack/react-table'; +import { Connect } from 'generated-sources'; +import React from 'react'; + +type Props = CellContext; +const NameCell = ({ getValue }: Props) => { + return
{getValue()}
; +}; +export default NameCell; diff --git a/frontend/src/components/Connect/Clusters/ui/List/Cells/TasksCell.tsx b/frontend/src/components/Connect/Clusters/ui/List/Cells/TasksCell.tsx new file mode 100644 index 000000000..041237e0a --- /dev/null +++ b/frontend/src/components/Connect/Clusters/ui/List/Cells/TasksCell.tsx @@ -0,0 +1,26 @@ +import AlertBadge from 'components/common/AlertBadge/AlertBadge'; +import { Connect } from 'generated-sources'; +import React from 'react'; + +type Props = { connect: Connect }; +const TasksCell = ({ connect }: Props) => { + const count = connect.tasksCount ?? 0; + const failedCount = connect.failedTasksCount ?? 0; + const text = `${count - failedCount}/${count}`; + + if (!count) { + return null; + } + + if (failedCount > 0) { + return ( + + + + + ); + } + + return
{text}
; +}; +export default TasksCell; diff --git a/frontend/src/components/Connect/Clusters/ui/List/List.tsx b/frontend/src/components/Connect/Clusters/ui/List/List.tsx new file mode 100644 index 000000000..f0cd4e693 --- /dev/null +++ b/frontend/src/components/Connect/Clusters/ui/List/List.tsx @@ -0,0 +1,56 @@ +import React from 'react'; +import { Connect } from 'generated-sources'; +import Table from 'components/common/NewTable'; +import useAppParams from 'lib/hooks/useAppParams'; +import { ClusterName } from 'lib/interfaces/cluster'; +import { useNavigate } from 'react-router-dom'; +import { clusterConnectorsPath } from 'lib/paths'; +import { createColumnHelper } from '@tanstack/react-table'; + +import ConnectorsCell from './Cells/ConnectorsCell'; +import NameCell from './Cells/NameCell'; +import TasksCell from './Cells/TasksCell'; + +const helper = createColumnHelper(); +export const columns = [ + helper.accessor('name', { cell: NameCell, size: 600 }), + helper.accessor('version', { + header: 'Version', + cell: ({ getValue }) => getValue(), + enableSorting: true, + }), + helper.display({ + header: 'Connectors', + id: 'connectors', + cell: (props) => , + size: 100, + }), + helper.display({ + header: 'Running tasks', + id: 'tasks', + cell: (props) => , + size: 100, + }), +]; + +interface Props { + connects: Connect[]; +} +const List = ({ connects }: Props) => { + const navigate = useNavigate(); + const { clusterName } = useAppParams<{ clusterName: ClusterName }>(); + + return ( + { + navigate(`${clusterConnectorsPath(clusterName)}?connect=${name}`); + }} + emptyMessage="No kafka connect clusters" + enableSorting + /> + ); +}; + +export default List; diff --git a/frontend/src/components/Connect/Clusters/ui/Statistics/Statistics.tsx b/frontend/src/components/Connect/Clusters/ui/Statistics/Statistics.tsx new file mode 100644 index 000000000..699840f67 --- /dev/null +++ b/frontend/src/components/Connect/Clusters/ui/Statistics/Statistics.tsx @@ -0,0 +1,35 @@ +import React, { useMemo } from 'react'; +import { Connect } from 'generated-sources'; +import * as Statistics from 'components/common/Statistics'; + +import { computeStatistic } from './models/computeStatistics'; + +type Props = { connects: Connect[]; isLoading: boolean }; +const ClustersStatistics = ({ connects, isLoading }: Props) => { + const statistic = useMemo(() => { + return computeStatistic(connects); + }, [connects]); + return ( + + + + + + ); +}; + +export default ClustersStatistics; diff --git a/frontend/src/components/Connect/Clusters/ui/Statistics/models/computeStatistics.ts b/frontend/src/components/Connect/Clusters/ui/Statistics/models/computeStatistics.ts new file mode 100644 index 000000000..b1452f2fc --- /dev/null +++ b/frontend/src/components/Connect/Clusters/ui/Statistics/models/computeStatistics.ts @@ -0,0 +1,31 @@ +import { Connect } from 'generated-sources'; + +interface Statistic { + clustersCount: number; + connectorsCount: number; + failedConnectorsCount: number; + tasksCount: number; + failedTasksCount: number; +} +export const computeStatistic = (connects: Connect[]): Statistic => { + const clustersCount = connects.length; + let connectorsCount = 0; + let failedConnectorsCount = 0; + let tasksCount = 0; + let failedTasksCount = 0; + + connects.forEach((connect) => { + connectorsCount += connect.connectorsCount ?? 0; + failedConnectorsCount += connect.failedConnectorsCount ?? 0; + tasksCount += connect.tasksCount ?? 0; + failedTasksCount += connect.failedTasksCount ?? 0; + }); + + return { + clustersCount, + connectorsCount, + failedConnectorsCount, + tasksCount, + failedTasksCount, + }; +}; diff --git a/frontend/src/components/Connect/Connect.tsx b/frontend/src/components/Connect/Connect.tsx index 095055cb6..48787f5ae 100644 --- a/frontend/src/components/Connect/Connect.tsx +++ b/frontend/src/components/Connect/Connect.tsx @@ -1,44 +1,52 @@ import React from 'react'; -import { Navigate, Routes, Route } from 'react-router-dom'; +import { Navigate, Routes, Route, NavLink } from 'react-router-dom'; import { - RouteParams, - clusterConnectConnectorRelativePath, - clusterConnectConnectorsRelativePath, - clusterConnectorNewRelativePath, - getNonExactPath, clusterConnectorsPath, + ClusterNameRoute, + kafkaConnectClustersPath, + kafkaConnectClustersRelativePath, + clusterConnectorsRelativePath, } from 'lib/paths'; import useAppParams from 'lib/hooks/useAppParams'; -import SuspenseQueryComponent from 'components/common/SuspenseQueryComponent/SuspenseQueryComponent'; +import Navbar from 'components/common/Navigation/Navbar.styled'; +import Clusters from 'components/Connect/Clusters/Clusters'; -import ListPage from './List/ListPage'; -import New from './New/New'; -import DetailsPage from './Details/DetailsPage'; +import Connectors from './List/ListPage'; +import Header from './Header/Header'; const Connect: React.FC = () => { - const { clusterName } = useAppParams(); + const { clusterName } = useAppParams(); return ( - - } /> - } /> - - - - } - /> - } - /> - } - /> - + <> +
+ + (isActive ? 'is-active' : '')} + end + > + Clusters + + (isActive ? 'is-active' : '')} + end + > + Connectors + + + + + } + /> + } /> + } /> + + ); }; diff --git a/frontend/src/components/Connect/Header/Header.tsx b/frontend/src/components/Connect/Header/Header.tsx new file mode 100644 index 000000000..75b19a6a8 --- /dev/null +++ b/frontend/src/components/Connect/Header/Header.tsx @@ -0,0 +1,42 @@ +import { ActionButton } from 'components/common/ActionComponent'; +import ResourcePageHeading from 'components/common/ResourcePageHeading/ResourcePageHeading'; +import Tooltip from 'components/common/Tooltip/Tooltip'; +import ClusterContext from 'components/contexts/ClusterContext'; +import { ResourceType, Action } from 'generated-sources'; +import { useConnects } from 'lib/hooks/api/kafkaConnect'; +import useAppParams from 'lib/hooks/useAppParams'; +import { clusterConnectorNewPath, ClusterNameRoute } from 'lib/paths'; +import React from 'react'; + +const Header = () => { + const { isReadOnly } = React.useContext(ClusterContext); + const { clusterName } = useAppParams(); + const { data: connects = [] } = useConnects(clusterName, true); + return ( + + {!isReadOnly && ( + + Create Connector + + } + showTooltip={!connects.length} + content="No Connects available" + placement="left" + /> + )} + + ); +}; + +export default Header; diff --git a/frontend/src/components/Connect/Header/__tests__/Header.spec.tsx b/frontend/src/components/Connect/Header/__tests__/Header.spec.tsx new file mode 100644 index 000000000..8f1bd93d1 --- /dev/null +++ b/frontend/src/components/Connect/Header/__tests__/Header.spec.tsx @@ -0,0 +1,62 @@ +import React from 'react'; +import Header from 'components/Connect/Header/Header'; +import ClusterContext, { + initialValue, +} from 'components/contexts/ClusterContext'; +import { render } from 'lib/testHelpers'; +import { screen } from '@testing-library/react'; +import { connects } from 'lib/fixtures/kafkaConnect'; +import { useConnects } from 'lib/hooks/api/kafkaConnect'; + +jest.mock('lib/hooks/api/kafkaConnect', () => ({ + useConnects: jest.fn(), +})); + +describe('Kafka Connect header', () => { + beforeEach(() => { + (useConnects as jest.Mock).mockImplementation(() => ({ + data: connects, + })); + }); + + async function renderComponent({ isReadOnly }: { isReadOnly: boolean }) { + render( + +
+ + ); + } + + it('render create connector button', () => { + renderComponent({ isReadOnly: false }); + + const btn = screen.getByRole('button', { name: 'Create Connector' }); + + expect(btn).toBeInTheDocument(); + expect(btn).toBeEnabled(); + }); + + describe('when no connects', () => { + it('create connector button is disabled', () => { + renderComponent({ isReadOnly: false }); + + const btn = screen.getByRole('button', { name: 'Create Connector' }); + + expect(btn).toBeInTheDocument(); + expect(btn).toBeEnabled(); + }); + }); + + describe('when cluster is readonly', () => { + it('doesnt render create connector button', () => { + (useConnects as jest.Mock).mockImplementation(() => ({ + data: [], + })); + renderComponent({ isReadOnly: false }); + + const btn = screen.queryByRole('button', { name: 'Create Connector' }); + + expect(btn).toBeDisabled(); + }); + }); +}); diff --git a/frontend/src/components/Connect/List/ListPage.styled.ts b/frontend/src/components/Connect/List/ListPage.styled.ts new file mode 100644 index 000000000..636351d47 --- /dev/null +++ b/frontend/src/components/Connect/List/ListPage.styled.ts @@ -0,0 +1,6 @@ +import { ControlPanelWrapper } from 'components/common/ControlPanel/ControlPanel.styled'; +import styled from 'styled-components'; + +export const Search = styled(ControlPanelWrapper)` + padding-top: 24px; +`; diff --git a/frontend/src/components/Connect/List/ListPage.tsx b/frontend/src/components/Connect/List/ListPage.tsx index 3397d339f..3b257dd6d 100644 --- a/frontend/src/components/Connect/List/ListPage.tsx +++ b/frontend/src/components/Connect/List/ListPage.tsx @@ -1,89 +1,24 @@ import React, { Suspense } from 'react'; import useAppParams from 'lib/hooks/useAppParams'; -import { ClusterNameRoute, clusterConnectorNewRelativePath } from 'lib/paths'; -import ClusterContext from 'components/contexts/ClusterContext'; +import { ClusterNameRoute } from 'lib/paths'; import Search from 'components/common/Search/Search'; -import * as Metrics from 'components/common/Metrics'; -import Tooltip from 'components/common/Tooltip/Tooltip'; -import { ControlPanelWrapper } from 'components/common/ControlPanel/ControlPanel.styled'; import PageLoader from 'components/common/PageLoader/PageLoader'; -import { ConnectorState, Action, ResourceType } from 'generated-sources'; -import { useConnectors, useConnects } from 'lib/hooks/api/kafkaConnect'; -import { ActionButton } from 'components/common/ActionComponent'; -import ResourcePageHeading from 'components/common/ResourcePageHeading/ResourcePageHeading'; +import { useConnectors } from 'lib/hooks/api/kafkaConnect'; +import * as S from './ListPage.styled'; import List from './List'; +import ConnectorsStatistics from './Statistics/Statistics'; const ListPage: React.FC = () => { - const { isReadOnly } = React.useContext(ClusterContext); const { clusterName } = useAppParams(); - const { data: connects = [] } = useConnects(clusterName); - - // Fetches all connectors from the API, without search criteria. Used to display general metrics. - const { data: connectorsMetrics, isLoading } = useConnectors(clusterName); - - const numberOfFailedConnectors = connectorsMetrics?.filter( - ({ status: { state } }) => state === ConnectorState.FAILED - ).length; - - const numberOfFailedTasks = connectorsMetrics?.reduce( - (acc, metric) => acc + (metric.failedTasksCount ?? 0), - 0 - ); + const { data, isLoading } = useConnectors(clusterName); return ( <> - - {!isReadOnly && ( - - Create Connector - - } - showTooltip={!connects.length} - content="No Connects available" - placement="left" - /> - )} - - - - - {connectorsMetrics?.length || '-'} - - - {numberOfFailedConnectors ?? '-'} - - - {numberOfFailedTasks ?? '-'} - - - - + + - + }> diff --git a/frontend/src/components/Connect/List/RunningTasksCell.tsx b/frontend/src/components/Connect/List/RunningTasksCell.tsx index 4c3293d44..fc829df49 100644 --- a/frontend/src/components/Connect/List/RunningTasksCell.tsx +++ b/frontend/src/components/Connect/List/RunningTasksCell.tsx @@ -1,21 +1,32 @@ import React from 'react'; import { FullConnectorInfo } from 'generated-sources'; import { CellContext } from '@tanstack/react-table'; +import AlertBadge from 'components/common/AlertBadge/AlertBadge'; const RunningTasksCell: React.FC> = ({ row, }) => { const { tasksCount, failedTasksCount } = row.original; + const failedCount = failedTasksCount ?? 0; + const count = tasksCount ?? 0; + + const text = `${count - failedCount}/${count}`; + if (!tasksCount) { return null; } - return ( - <> - {tasksCount - (failedTasksCount || 0)} of {tasksCount} - - ); + if (failedCount > 0) { + return ( + + + + + ); + } + + return text; }; export default RunningTasksCell; diff --git a/frontend/src/components/Connect/List/Statistics/Statistics.tsx b/frontend/src/components/Connect/List/Statistics/Statistics.tsx new file mode 100644 index 000000000..4b9dd6da7 --- /dev/null +++ b/frontend/src/components/Connect/List/Statistics/Statistics.tsx @@ -0,0 +1,34 @@ +import React, { useMemo } from 'react'; +import * as Statistics from 'components/common/Statistics'; +import { FullConnectorInfo } from 'generated-sources'; + +import { computeStatistics } from './models/computeStatistics'; + +type Props = { + connectors: FullConnectorInfo[]; + isLoading: boolean; +}; +const ConnectorsStatistics = ({ connectors, isLoading }: Props) => { + const statistics = useMemo(() => { + return computeStatistics(connectors); + }, [connectors]); + + return ( + + + + + ); +}; + +export default ConnectorsStatistics; diff --git a/frontend/src/components/Connect/List/Statistics/__tests__/Statistics.spec.tsx b/frontend/src/components/Connect/List/Statistics/__tests__/Statistics.spec.tsx new file mode 100644 index 000000000..a1f1f382d --- /dev/null +++ b/frontend/src/components/Connect/List/Statistics/__tests__/Statistics.spec.tsx @@ -0,0 +1,97 @@ +import React from 'react'; +import { render } from 'lib/testHelpers'; +import { screen, within } from '@testing-library/react'; +import Statistics from 'components/Connect/List/Statistics/Statistics'; +import { FullConnectorInfo } from 'generated-sources'; +import { connectors } from 'lib/fixtures/kafkaConnect'; + +describe('Kafka Connect Connectors Statistics', () => { + async function renderComponent({ + data, + isLoading, + }: { + data: FullConnectorInfo[] | undefined; + isLoading: boolean; + }) { + render(); + } + + describe('when data loading', () => { + let statistics: HTMLElement; + beforeEach(() => { + renderComponent({ data: undefined, isLoading: true }); + statistics = screen.getByRole('group'); + }); + + it('renders statistic container elements', () => { + expect(statistics).toBeInTheDocument(); + }); + + describe('Connectors statistic', () => { + let connectorStatisticsCell: HTMLElement; + beforeEach(() => { + connectorStatisticsCell = within(statistics).getByRole('cell', { + name: 'Connectors', + }); + }); + it('exists', () => { + expect(connectorStatisticsCell).toBeInTheDocument(); + }); + + it('shows loader', () => { + const loader = within(connectorStatisticsCell).getByRole('status'); + expect(loader).toBeInTheDocument(); + }); + }); + describe('Tasks statistic', () => { + let tasksStatisticsCell: HTMLElement; + beforeEach(() => { + tasksStatisticsCell = within(statistics).getByRole('cell', { + name: 'Tasks', + }); + }); + it('exists', () => { + expect(tasksStatisticsCell).toBeInTheDocument(); + }); + + it('shows loader', () => { + const loader = within(tasksStatisticsCell).getByRole('status'); + expect(loader).toBeInTheDocument(); + }); + }); + }); + + describe('when data is loaded', () => { + let connectorsStatistic: HTMLElement; + let tasksStatistics: HTMLElement; + beforeEach(() => { + renderComponent({ data: connectors, isLoading: false }); + [connectorsStatistic, tasksStatistics] = screen.getAllByRole('cell'); + }); + + describe('Connectors statistics', () => { + it('renders statistic', () => { + expect(connectorsStatistic).toBeInTheDocument(); + + const count = within(connectorsStatistic).getByText(3); + expect(count).toBeInTheDocument(); + + const alert = within(connectorsStatistic).getByRole('alert'); + expect(alert).toBeInTheDocument(); + expect(alert).toHaveTextContent('1'); + }); + }); + describe('Tasks statistics', () => { + it('renders statistic', () => { + expect(tasksStatistics).toBeInTheDocument(); + + const count = within(tasksStatistics).getByText(5); + expect(count).toBeInTheDocument(); + + const alert = within(tasksStatistics).getByRole('alert'); + expect(alert).toBeInTheDocument(); + expect(alert).toHaveTextContent('1'); + }); + }); + }); +}); diff --git a/frontend/src/components/Connect/List/Statistics/models/computeStatistics.ts b/frontend/src/components/Connect/List/Statistics/models/computeStatistics.ts new file mode 100644 index 000000000..ad3b85a1d --- /dev/null +++ b/frontend/src/components/Connect/List/Statistics/models/computeStatistics.ts @@ -0,0 +1,33 @@ +import { ConnectorState, FullConnectorInfo } from 'generated-sources'; + +interface Statistic { + connectorsCount: number; + failedConnectorsCount: number; + tasksCount: number; + failedTasksCount: number; +} + +export const computeStatistics = ( + connectors: FullConnectorInfo[] +): Statistic => { + const connectorsCount = connectors.length; + let failedConnectorsCount = 0; + let tasksCount = 0; + let failedTasksCount = 0; + + connectors.forEach((connector) => { + if (connector.status.state === ConnectorState.FAILED) { + failedConnectorsCount += 1; + } + + tasksCount += connector.tasksCount ?? 0; + failedTasksCount += connector.failedTasksCount ?? 0; + }); + + return { + connectorsCount, + failedConnectorsCount, + tasksCount, + failedTasksCount, + }; +}; diff --git a/frontend/src/components/Connect/List/__tests__/ListPage.spec.tsx b/frontend/src/components/Connect/List/__tests__/ListPage.spec.tsx index 0cbe4dd78..77196a1be 100644 --- a/frontend/src/components/Connect/List/__tests__/ListPage.spec.tsx +++ b/frontend/src/components/Connect/List/__tests__/ListPage.spec.tsx @@ -1,11 +1,11 @@ import React from 'react'; -import { connectors, connects } from 'lib/fixtures/kafkaConnect'; +import { connects } from 'lib/fixtures/kafkaConnect'; import ClusterContext, { ContextProps, initialValue, } from 'components/contexts/ClusterContext'; import ListPage from 'components/Connect/List/ListPage'; -import { screen, within } from '@testing-library/react'; +import { screen } from '@testing-library/react'; import { render, WithRoute } from 'lib/testHelpers'; import { clusterConnectorsPath } from 'lib/paths'; import { useConnectors, useConnects } from 'lib/hooks/api/kafkaConnect'; @@ -45,28 +45,6 @@ describe('Connectors List Page', () => { { initialEntries: [clusterConnectorsPath(clusterName)] } ); - describe('Heading', () => { - it('renders header without create button for readonly cluster', async () => { - await renderComponent({ ...initialValue, isReadOnly: true }); - expect( - screen.getByRole('heading', { name: 'Connectors' }) - ).toBeInTheDocument(); - expect( - screen.queryByRole('link', { name: 'Create Connector' }) - ).not.toBeInTheDocument(); - }); - - it('renders header with create button for read/write cluster', async () => { - await renderComponent(); - expect( - screen.getByRole('heading', { name: 'Connectors' }) - ).toBeInTheDocument(); - expect( - screen.getByRole('link', { name: 'Create Connector' }) - ).toBeInTheDocument(); - }); - }); - it('renders search input', async () => { await renderComponent(); expect( @@ -78,127 +56,4 @@ describe('Connectors List Page', () => { await renderComponent(); expect(screen.getByText('Connectors List')).toBeInTheDocument(); }); - - describe('Metrics', () => { - it('renders indicators in loading state', async () => { - (useConnectors as jest.Mock).mockImplementation(() => ({ - isLoading: true, - data: connectors, - })); - - await renderComponent(); - const metrics = screen.getByRole('group'); - expect(metrics).toBeInTheDocument(); - expect(within(metrics).getAllByText('progressbar').length).toEqual(3); - }); - - it('renders indicators for empty list of connectors', async () => { - await renderComponent(); - const metrics = screen.getByRole('group'); - expect(metrics).toBeInTheDocument(); - - const connectorsIndicator = within(metrics).getByTitle( - 'Total number of connectors' - ); - expect(connectorsIndicator).toBeInTheDocument(); - expect(connectorsIndicator).toHaveTextContent('Connectors -'); - - const failedConnectorsIndicator = within(metrics).getByTitle( - 'Number of failed connectors' - ); - expect(failedConnectorsIndicator).toBeInTheDocument(); - expect(failedConnectorsIndicator).toHaveTextContent( - 'Failed Connectors 0' - ); - - const failedTasksIndicator = within(metrics).getByTitle( - 'Number of failed tasks' - ); - expect(failedTasksIndicator).toBeInTheDocument(); - expect(failedTasksIndicator).toHaveTextContent('Failed Tasks 0'); - }); - - it('renders indicators when connectors list is undefined', async () => { - (useConnectors as jest.Mock).mockImplementation(() => ({ - isFetching: false, - data: undefined, - })); - - await renderComponent(); - const metrics = screen.getByRole('group'); - expect(metrics).toBeInTheDocument(); - - const connectorsIndicator = within(metrics).getByTitle( - 'Total number of connectors' - ); - expect(connectorsIndicator).toBeInTheDocument(); - expect(connectorsIndicator).toHaveTextContent('Connectors -'); - - const failedConnectorsIndicator = within(metrics).getByTitle( - 'Number of failed connectors' - ); - expect(failedConnectorsIndicator).toBeInTheDocument(); - expect(failedConnectorsIndicator).toHaveTextContent( - 'Failed Connectors -' - ); - - const failedTasksIndicator = within(metrics).getByTitle( - 'Number of failed tasks' - ); - expect(failedTasksIndicator).toBeInTheDocument(); - expect(failedTasksIndicator).toHaveTextContent('Failed Tasks -'); - }); - - it('renders indicators list of connectors', async () => { - (useConnectors as jest.Mock).mockImplementation(() => ({ - isLoading: false, - data: connectors, - })); - - await renderComponent(); - - const metrics = screen.getByRole('group'); - expect(metrics).toBeInTheDocument(); - - const connectorsIndicator = within(metrics).getByTitle( - 'Total number of connectors' - ); - expect(connectorsIndicator).toBeInTheDocument(); - expect(connectorsIndicator).toHaveTextContent( - `Connectors ${connectors.length}` - ); - - const failedConnectorsIndicator = within(metrics).getByTitle( - 'Number of failed connectors' - ); - expect(failedConnectorsIndicator).toBeInTheDocument(); - expect(failedConnectorsIndicator).toHaveTextContent( - 'Failed Connectors 1' - ); - - const failedTasksIndicator = within(metrics).getByTitle( - 'Number of failed tasks' - ); - expect(failedTasksIndicator).toBeInTheDocument(); - expect(failedTasksIndicator).toHaveTextContent('Failed Tasks 1'); - }); - }); - - describe('Create new connector', () => { - it('Create new connector button is enabled when connects list is not empty', async () => { - await renderComponent(); - - expect(screen.getByText('Create Connector')).toBeEnabled(); - }); - - it('Create new connector button is disabled when connects list is empty', async () => { - (useConnects as jest.Mock).mockImplementation(() => ({ - data: [], - })); - - await renderComponent(); - - expect(screen.getByText('Create Connector')).toBeDisabled(); - }); - }); }); diff --git a/frontend/src/components/Connect/__tests__/Connect.spec.tsx b/frontend/src/components/Connect/__tests__/Connect.spec.tsx index 855e54645..4f96d7931 100644 --- a/frontend/src/components/Connect/__tests__/Connect.spec.tsx +++ b/frontend/src/components/Connect/__tests__/Connect.spec.tsx @@ -2,13 +2,7 @@ import React from 'react'; import { render, WithRoute } from 'lib/testHelpers'; import { screen } from '@testing-library/react'; import Connect from 'components/Connect/Connect'; -import { - clusterConnectorsPath, - clusterConnectorNewPath, - clusterConnectConnectorPath, - getNonExactPath, - clusterConnectsPath, -} from 'lib/paths'; +import { getNonExactPath, kafkaConnectPath } from 'lib/paths'; const ConnectCompText = { new: 'New Page', @@ -35,27 +29,22 @@ describe('Connect', () => { { initialEntries: [pathname] } ); - it('renders ListPage', () => { - renderComponent( - clusterConnectorsPath('my-cluster'), - clusterConnectorsPath() - ); - expect(screen.getByText(ConnectCompText.list)).toBeInTheDocument(); - }); + it('renders header', () => { + renderComponent(kafkaConnectPath('my-cluster'), kafkaConnectPath()); - it('renders New Page', () => { - renderComponent( - clusterConnectorNewPath('my-cluster'), - clusterConnectorsPath() - ); - expect(screen.getByText(ConnectCompText.new)).toBeInTheDocument(); + const header = screen.getByRole('heading', { name: 'Kafka Connect' }); + expect(header).toBeInTheDocument(); }); - it('renders Details Page', () => { - renderComponent( - clusterConnectConnectorPath('my-cluster', 'my-connect', 'my-connector'), - clusterConnectsPath() - ); - expect(screen.getByText(ConnectCompText.details)).toBeInTheDocument(); + it('renders navigation', () => { + renderComponent(kafkaConnectPath('my-cluster'), kafkaConnectPath()); + + const clusterNavigation = screen.getByRole('link', { name: 'Clusters' }); + expect(clusterNavigation).toBeInTheDocument(); + + const connectorsNavigation = screen.getByRole('link', { + name: 'Connectors', + }); + expect(connectorsNavigation).toBeInTheDocument(); }); }); diff --git a/frontend/src/components/Nav/ClusterMenu/ClusterMenu.tsx b/frontend/src/components/Nav/ClusterMenu/ClusterMenu.tsx index 8f73a96ec..6eeca0e48 100644 --- a/frontend/src/components/Nav/ClusterMenu/ClusterMenu.tsx +++ b/frontend/src/components/Nav/ClusterMenu/ClusterMenu.tsx @@ -6,12 +6,13 @@ import MenuItem from 'components/Nav/Menu/MenuItem'; import { clusterACLPath, clusterBrokersPath, - clusterConnectorsPath, clusterConnectsPath, + clusterConnectorsPath, clusterConsumerGroupsPath, clusterKsqlDbPath, clusterSchemasPath, clusterTopicsPath, + kafkaConnectPath, } from 'lib/paths'; import { useLocation } from 'react-router-dom'; import { useLocalStorage } from 'lib/hooks/useLocalStorage'; @@ -83,10 +84,11 @@ const ClusterMenu: FC = ({ {hasFeatureConfigured(ClusterFeaturesEnum.KAFKA_CONNECT) && ( )} diff --git a/frontend/src/components/common/AlertBadge/AlertBadge.styled.tsx b/frontend/src/components/common/AlertBadge/AlertBadge.styled.tsx new file mode 100644 index 000000000..495d3b2b2 --- /dev/null +++ b/frontend/src/components/common/AlertBadge/AlertBadge.styled.tsx @@ -0,0 +1,18 @@ +import styled from 'styled-components'; + +export const Container = styled.div` + height: 24px; + border-radius: 24px; + background-color: ${({ theme }) => theme.alertBadge.background}; + display: inline-flex; + padding: 2px 8px; + gap: 4px; + align-items: center; +`; + +export const Content = styled.div` + font-weight: 400; + size: 14px; + line-height: 20px; + color: ${({ theme }) => theme.alertBadge.content.color}; +`; diff --git a/frontend/src/components/common/AlertBadge/AlertBadge.tsx b/frontend/src/components/common/AlertBadge/AlertBadge.tsx new file mode 100644 index 000000000..4a5839b9e --- /dev/null +++ b/frontend/src/components/common/AlertBadge/AlertBadge.tsx @@ -0,0 +1,26 @@ +import React, { PropsWithChildren } from 'react'; +import { useTheme } from 'styled-components'; +import AlertIcon from 'components/common/Icons/AlertIcon'; + +import * as S from './AlertBadge.styled'; + +interface AlertBadgeProps {} +function AlertBadge({ children }: PropsWithChildren) { + return {children}; +} + +const Icon = () => { + const theme = useTheme(); + return ; +}; + +interface AlertBadgeContentProps { + content: string | number; +} +const Content = ({ content }: AlertBadgeContentProps) => { + return {content}; +}; + +AlertBadge.Icon = Icon; +AlertBadge.Content = Content; +export default AlertBadge; diff --git a/frontend/src/components/common/Icons/AlertIcon.tsx b/frontend/src/components/common/Icons/AlertIcon.tsx index 3c79f78e6..c8312947f 100644 --- a/frontend/src/components/common/Icons/AlertIcon.tsx +++ b/frontend/src/components/common/Icons/AlertIcon.tsx @@ -1,6 +1,10 @@ import React from 'react'; -const AlertIcon: React.FC = () => { +interface Props { + fill?: string; +} + +const AlertIcon: React.FC = ({ fill }) => { return ( { fillRule="evenodd" clipRule="evenodd" d="M9.09265 2.06673C8.60703 1.25046 7.39297 1.25046 6.90735 2.06673L1.17092 11.7088C0.685293 12.5251 1.29232 13.5454 2.26357 13.5454H13.7364C14.7077 13.5454 15.3147 12.5251 14.8291 11.7088L9.09265 2.06673ZM7 6C7 5.44772 7.44772 5 8 5C8.55228 5 9 5.44772 9 6V8C9 8.55228 8.55228 9 8 9C7.44772 9 7 8.55228 7 8V6ZM7 11C7 10.4477 7.44772 10 8 10C8.55228 10 9 10.4477 9 11C9 11.5523 8.55228 12 8 12C7.44772 12 7 11.5523 7 11Z" - fill="#E63B19" + fill={fill ?? '#E63B19'} /> ); diff --git a/frontend/src/components/common/Statistics/Statistic/Statistic.styled.tsx b/frontend/src/components/common/Statistics/Statistic/Statistic.styled.tsx new file mode 100644 index 000000000..6e61c39bc --- /dev/null +++ b/frontend/src/components/common/Statistics/Statistic/Statistic.styled.tsx @@ -0,0 +1,27 @@ +import styled from 'styled-components'; + +export const Container = styled.div` + width: 216px; + height: 68px; + border-radius: 12px; + padding: 12px 16px; + background: ${({ theme }) => theme.kafkaConectClusters.statistic.background}; +`; + +export const Header = styled.span` + font-weight: 500; + font-size: 12px; + line-height: 16px; + color: ${({ theme }) => theme.kafkaConectClusters.statistic.header.color}; +`; +export const Footer = styled.div` + display: flex; + justify-content: space-between; +`; + +export const Count = styled.div` + font-weight: 500; + font-size: 16px; + line-height: 24px; + color: ${({ theme }) => theme.kafkaConectClusters.statistic.count.color}; +`; diff --git a/frontend/src/components/common/Statistics/Statistic/Statistic.tsx b/frontend/src/components/common/Statistics/Statistic/Statistic.tsx new file mode 100644 index 000000000..f71c03c60 --- /dev/null +++ b/frontend/src/components/common/Statistics/Statistic/Statistic.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import AlertBadge from 'components/common/AlertBadge/AlertBadge'; +import SpinnerIcon from 'components/common/Icons/SpinnerIcon'; + +import * as S from './Statistic.styled'; + +type Props = { + title: string; + count: number; + isLoading: boolean; + warningCount?: number; +}; +const Statistic = ({ title, count, warningCount, isLoading }: Props) => { + const showWarning = warningCount !== undefined && warningCount > 0; + return ( + + {title} + + {isLoading ? ( +
+ +
+ ) : ( + <> + {count} + {showWarning && ( + + + + + )} + + )} +
+
+ ); +}; + +export default Statistic; diff --git a/frontend/src/components/common/Statistics/Statistics.styled.tsx b/frontend/src/components/common/Statistics/Statistics.styled.tsx new file mode 100644 index 000000000..06299e8cb --- /dev/null +++ b/frontend/src/components/common/Statistics/Statistics.styled.tsx @@ -0,0 +1,8 @@ +import styled from 'styled-components'; + +export const Container = styled.div` + padding: 16px; + display: flex; + gap: 4px; + background: ${({ theme }) => theme.kafkaConectClusters.statistics.background}; +`; diff --git a/frontend/src/components/common/Statistics/index.ts b/frontend/src/components/common/Statistics/index.ts new file mode 100644 index 000000000..ceb22b87e --- /dev/null +++ b/frontend/src/components/common/Statistics/index.ts @@ -0,0 +1,4 @@ +import Item from './Statistic/Statistic'; + +export { Container } from './Statistics.styled'; +export { Item }; diff --git a/frontend/src/lib/__test__/paths.spec.ts b/frontend/src/lib/__test__/paths.spec.ts index c6abb7707..4a731e393 100644 --- a/frontend/src/lib/__test__/paths.spec.ts +++ b/frontend/src/lib/__test__/paths.spec.ts @@ -240,17 +240,22 @@ describe('Paths', () => { paths.clusterConnectsPath(RouteParams.clusterName) ); }); - it('clusterConnectorsPath', () => { + it('kafkaConnectConnectorsPath', () => { expect(paths.clusterConnectorsPath(clusterName)).toEqual( - `${paths.clusterPath(clusterName)}/connectors` + `${paths.clusterPath(clusterName)}/kafka-connect/connectors` ); expect(paths.clusterConnectorsPath()).toEqual( paths.clusterConnectorsPath(RouteParams.clusterName) ); }); + it('kafkaConnectConnectorsPath', () => { + expect(paths.kafkaConnectClustersPath(clusterName)).toEqual( + `${paths.clusterPath(clusterName)}/kafka-connect/clusters` + ); + }); it('clusterConnectorNewPath', () => { expect(paths.clusterConnectorNewPath(clusterName)).toEqual( - `${paths.clusterConnectorsPath(clusterName)}/create-new` + `${paths.clusterPath(clusterName)}/create-new` ); expect(paths.clusterConnectorNewPath()).toEqual( paths.clusterConnectorNewPath(RouteParams.clusterName) diff --git a/frontend/src/lib/hooks/api/kafkaConnect.ts b/frontend/src/lib/hooks/api/kafkaConnect.ts index 743d78307..64ce2af41 100644 --- a/frontend/src/lib/hooks/api/kafkaConnect.ts +++ b/frontend/src/lib/hooks/api/kafkaConnect.ts @@ -19,10 +19,11 @@ interface CreateConnectorProps { newConnector: NewConnector; } -const connectsKey = (clusterName: ClusterName) => [ +const connectsKey = (clusterName: ClusterName, withStats?: boolean) => [ 'clusters', clusterName, 'connects', + withStats, ]; const connectorsKey = (clusterName: ClusterName, search?: string) => { const base = ['clusters', clusterName, 'connectors']; @@ -44,9 +45,9 @@ const connectorTasksKey = (props: UseConnectorProps) => [ 'tasks', ]; -export function useConnects(clusterName: ClusterName) { - return useQuery(connectsKey(clusterName), () => - api.getConnects({ clusterName }) +export function useConnects(clusterName: ClusterName, withStats?: boolean) { + return useQuery(connectsKey(clusterName, withStats), () => + api.getConnects({ clusterName, withStats }) ); } export function useConnectors(clusterName: ClusterName, search?: string) { diff --git a/frontend/src/lib/paths.ts b/frontend/src/lib/paths.ts index b39ee5e6a..dbef8246b 100644 --- a/frontend/src/lib/paths.ts +++ b/frontend/src/lib/paths.ts @@ -207,18 +207,31 @@ export type RouteParamsClusterTopic = { // Kafka Connect export const clusterConnectsRelativePath = 'connects'; export const clusterConnectorsRelativePath = 'connectors'; -export const clusterConnectorNewRelativePath = 'create-new'; +export const kafkaConnectRelativePath = 'kafka-connect'; +export const kafkaConnectClustersRelativePath = 'clusters'; + export const clusterConnectConnectorsRelativePath = `${RouteParams.connectName}/connectors`; -export const clusterConnectConnectorRelativePath = `${clusterConnectConnectorsRelativePath}/${RouteParams.connectorName}`; +export const clusterConnectConnectorRelativePath = `${clusterConnectsRelativePath}/${clusterConnectConnectorsRelativePath}/${RouteParams.connectorName}`; +export const clusterConnectorNewRelativePath = 'create-new'; const clusterConnectConnectorTasksRelativePath = 'tasks'; export const clusterConnectConnectorConfigRelativePath = 'config'; export const clusterConnectsPath = ( clusterName: ClusterName = RouteParams.clusterName ) => `${clusterPath(clusterName)}/connects`; + +export const kafkaConnectPath = ( + clusterName: ClusterName = RouteParams.clusterName +) => `${clusterPath(clusterName)}/kafka-connect`; +export const kafkaConnectClustersPath = ( + clusterName: ClusterName = RouteParams.clusterName +) => { + return `${kafkaConnectPath(clusterName)}/clusters`; +}; + export const clusterConnectorsPath = ( clusterName: ClusterName = RouteParams.clusterName -) => `${clusterPath(clusterName)}/connectors`; +) => `${kafkaConnectPath(clusterName)}/connectors`; export const clusterConnectorNameSubPath = ( clusterName: ClusterName = RouteParams.connectorName ) => { @@ -227,7 +240,7 @@ export const clusterConnectorNameSubPath = ( export const clusterConnectorNewPath = ( clusterName: ClusterName = RouteParams.clusterName -) => `${clusterConnectorsPath(clusterName)}/create-new`; +) => `${clusterPath(clusterName)}/create-new`; export const clusterConnectConnectorsPath = ( clusterName: ClusterName = RouteParams.clusterName, connectName: Connect['name'] = RouteParams.connectName diff --git a/frontend/src/theme/theme.ts b/frontend/src/theme/theme.ts index c2ccdb449..cfe47f721 100644 --- a/frontend/src/theme/theme.ts +++ b/frontend/src/theme/theme.ts @@ -61,6 +61,10 @@ const Colors = { '55': '#CF1717', '60': '#B81414', }, + orange: { + '10': '#BF83401A', + '100': '#FF9D00', + }, yellow: { '10': '#FFEECC', '20': '#FFDD57', @@ -404,6 +408,29 @@ const baseTheme = { export const theme = { ...baseTheme, + alertBadge: { + background: Colors.orange[10], + content: { + color: Colors.neutral[90], + }, + icon: { + color: Colors.orange[100], + }, + }, + kafkaConectClusters: { + statistics: { + background: Colors.neutral[5], + }, + statistic: { + background: Colors.neutral[0], + count: { + color: Colors.neutral[90], + }, + header: { + color: Colors.neutral[50], + }, + }, + }, logo: { color: Colors.brand[90], }, @@ -929,6 +956,29 @@ export type ClusterColorKey = export const darkTheme: ThemeType = { ...baseTheme, + alertBadge: { + background: Colors.orange[10], + content: { + color: Colors.neutral[0], + }, + icon: { + color: Colors.orange[100], + }, + }, + kafkaConectClusters: { + statistics: { + background: Colors.neutral[95], + }, + statistic: { + background: Colors.neutral[90], + count: { + color: Colors.neutral[0], + }, + header: { + color: Colors.neutral[50], + }, + }, + }, auth_page: { backgroundColor: Colors.neutral[90], fontFamily: baseTheme.auth_page.fontFamily,