diff --git a/frontend/package.json b/frontend/package.json index 0d3881493..4f98cad2b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -27,6 +27,7 @@ "react-error-boundary": "4.0.13", "react-hook-form": "7.54.2", "react-hot-toast": "2.4.1", + "react-innertext": "1.1.5", "react-is": "18.2.0", "react-multi-select-component": "4.3.4", "react-router-dom": "6.23.0", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index be267d752..6c245f201 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -90,6 +90,9 @@ importers: react-hot-toast: specifier: 2.4.1 version: 2.4.1(csstype@3.1.2)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + react-innertext: + specifier: 1.1.5 + version: 1.1.5(@types/react@18.2.79)(react@18.2.0) react-is: specifier: 18.2.0 version: 18.2.0 @@ -3739,6 +3742,12 @@ packages: react: '>=16' react-dom: '>=16' + react-innertext@1.1.5: + resolution: {integrity: sha512-PWAqdqhxhHIv80dT9znP2KvS+hfkbRovFp4zFYHFFlOoQLRiawIic81gKb3U1wEyJZgMwgs3JoLtwryASRWP3Q==} + peerDependencies: + '@types/react': '>=0.0.0 <=99' + react: '>=0.0.0 <=99' + react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} @@ -8703,6 +8712,11 @@ snapshots: transitivePeerDependencies: - csstype + react-innertext@1.1.5(@types/react@18.2.79)(react@18.2.0): + dependencies: + '@types/react': 18.2.79 + react: 18.2.0 + react-is@16.13.1: {} react-is@17.0.2: {} diff --git a/frontend/src/components/Brokers/BrokersList/BrokersList.tsx b/frontend/src/components/Brokers/BrokersList/BrokersList.tsx index effba3b83..10263436f 100644 --- a/frontend/src/components/Brokers/BrokersList/BrokersList.tsx +++ b/frontend/src/components/Brokers/BrokersList/BrokersList.tsx @@ -2,14 +2,18 @@ import React, { useMemo } from 'react'; import { ClusterName } from 'lib/interfaces/cluster'; import { useNavigate } from 'react-router-dom'; import useAppParams from 'lib/hooks/useAppParams'; -import Table from 'components/common/NewTable'; +import Table, { + exportTableCSV, + TableProvider, +} from 'components/common/NewTable'; import { clusterBrokerPath } from 'lib/paths'; import { useBrokers } from 'lib/hooks/api/brokers'; import { useClusterStats } from 'lib/hooks/api/clusters'; import ResourcePageHeading from 'components/common/ResourcePageHeading/ResourcePageHeading'; +import { Button } from 'components/common/Button/Button'; -import { BrokersMetrics } from './BrokersMetrics/BrokersMetrics'; import { getBrokersTableColumns, getBrokersTableRows } from './lib'; +import { BrokersMetrics } from './BrokersMetrics/BrokersMetrics'; const BrokersList: React.FC = () => { const navigate = useNavigate(); @@ -44,30 +48,48 @@ const BrokersList: React.FC = () => { const columns = useMemo(() => getBrokersTableColumns(), []); return ( - <> - + + {({ table }) => { + const handleExportClick = () => { + exportTableCSV(table, { prefix: 'brokers' }); + }; + + return ( + <> + + + - + - - navigate(clusterBrokerPath(clusterName, brokerId)) - } - emptyMessage="No clusters are online" - /> - +
+ navigate(clusterBrokerPath(clusterName, brokerId)) + } + emptyMessage="No clusters are online" + /> + + ); + }} + ); }; diff --git a/frontend/src/components/Brokers/BrokersList/lib/utils.ts b/frontend/src/components/Brokers/BrokersList/lib/utils.ts index 82ba68518..be40ec9ac 100644 --- a/frontend/src/components/Brokers/BrokersList/lib/utils.ts +++ b/frontend/src/components/Brokers/BrokersList/lib/utils.ts @@ -68,6 +68,7 @@ export const getBrokersTableColumns = () => { }), columnHelper.accessor('replicasSkew', { header: SkewHeader, + meta: { csv: 'Replicas skew' }, cell: Cell.Skew, }), columnHelper.accessor('partitionsLeader', { header: 'Leaders' }), diff --git a/frontend/src/components/Connect/Clusters/ui/List/Cells/ConnectorsCell.tsx b/frontend/src/components/Connect/Clusters/ui/List/Cells/ConnectorsCell.tsx index 2319cc07d..8a2475a94 100644 --- a/frontend/src/components/Connect/Clusters/ui/List/Cells/ConnectorsCell.tsx +++ b/frontend/src/components/Connect/Clusters/ui/List/Cells/ConnectorsCell.tsx @@ -2,15 +2,23 @@ import AlertBadge from 'components/common/AlertBadge/AlertBadge'; import { Connect } from 'generated-sources'; import React from 'react'; -type Props = { connect: Connect }; -const ConnectorsCell = ({ connect }: Props) => { +export const getConnectorsCountText = (connect: Connect) => { const count = connect.connectorsCount ?? 0; const failedCount = connect.failedConnectorsCount ?? 0; - const text = `${count - failedCount}/${count}`; - if (count === 0) { - return null; - } + return { + count, + failedCount, + text: `${count - failedCount}/${count}`, + }; +}; + +type Props = { connect: Connect }; + +const ConnectorsCell = ({ connect }: Props) => { + const { count, failedCount, text } = getConnectorsCountText(connect); + + if (count === 0) return null; if (failedCount > 0) { return ( diff --git a/frontend/src/components/Connect/Clusters/ui/List/Cells/TasksCell.tsx b/frontend/src/components/Connect/Clusters/ui/List/Cells/TasksCell.tsx index 041237e0a..855a80408 100644 --- a/frontend/src/components/Connect/Clusters/ui/List/Cells/TasksCell.tsx +++ b/frontend/src/components/Connect/Clusters/ui/List/Cells/TasksCell.tsx @@ -2,15 +2,20 @@ import AlertBadge from 'components/common/AlertBadge/AlertBadge'; import { Connect } from 'generated-sources'; import React from 'react'; -type Props = { connect: Connect }; -const TasksCell = ({ connect }: Props) => { +export const getTasksCountText = (connect: Connect) => { const count = connect.tasksCount ?? 0; const failedCount = connect.failedTasksCount ?? 0; const text = `${count - failedCount}/${count}`; - if (!count) { - return null; - } + return { count, failedCount, text }; +}; + +type Props = { connect: Connect }; + +const TasksCell = ({ connect }: Props) => { + const { count, failedCount, text } = getTasksCountText(connect); + + if (!count) return null; if (failedCount > 0) { return ( diff --git a/frontend/src/components/Connect/Clusters/ui/List/List.tsx b/frontend/src/components/Connect/Clusters/ui/List/List.tsx index f0cd4e693..e8ad8813a 100644 --- a/frontend/src/components/Connect/Clusters/ui/List/List.tsx +++ b/frontend/src/components/Connect/Clusters/ui/List/List.tsx @@ -7,29 +7,31 @@ import { useNavigate } from 'react-router-dom'; import { clusterConnectorsPath } from 'lib/paths'; import { createColumnHelper } from '@tanstack/react-table'; -import ConnectorsCell from './Cells/ConnectorsCell'; +import ConnectorsCell, { getConnectorsCountText } from './Cells/ConnectorsCell'; import NameCell from './Cells/NameCell'; -import TasksCell from './Cells/TasksCell'; +import TasksCell, { getTasksCountText } from './Cells/TasksCell'; const helper = createColumnHelper(); export const columns = [ - helper.accessor('name', { cell: NameCell, size: 600 }), + helper.accessor('name', { header: 'Name', cell: NameCell, size: 600 }), helper.accessor('version', { header: 'Version', cell: ({ getValue }) => getValue(), enableSorting: true, }), - helper.display({ + helper.accessor('connectorsCount', { header: 'Connectors', id: 'connectors', cell: (props) => , size: 100, + meta: { csvFn: (row) => getConnectorsCountText(row).text }, }), - helper.display({ + helper.accessor('tasksCount', { header: 'Running tasks', id: 'tasks', cell: (props) => , size: 100, + meta: { csvFn: (row) => getTasksCountText(row).text }, }), ]; diff --git a/frontend/src/components/Connect/Connect.tsx b/frontend/src/components/Connect/Connect.tsx index 48787f5ae..54aa1aac5 100644 --- a/frontend/src/components/Connect/Connect.tsx +++ b/frontend/src/components/Connect/Connect.tsx @@ -10,6 +10,7 @@ import { import useAppParams from 'lib/hooks/useAppParams'; import Navbar from 'components/common/Navigation/Navbar.styled'; import Clusters from 'components/Connect/Clusters/Clusters'; +import { TableProvider } from 'components/common/NewTable'; import Connectors from './List/ListPage'; import Header from './Header/Header'; @@ -18,7 +19,7 @@ const Connect: React.FC = () => { const { clusterName } = useAppParams(); return ( - <> +
{ } /> } /> - + ); }; diff --git a/frontend/src/components/Connect/Header/Header.tsx b/frontend/src/components/Connect/Header/Header.tsx index 75b19a6a8..3985559e8 100644 --- a/frontend/src/components/Connect/Header/Header.tsx +++ b/frontend/src/components/Connect/Header/Header.tsx @@ -5,13 +5,47 @@ 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 { + clusterConnectorNewPath, + clusterConnectorsRelativePath, + ClusterNameRoute, + kafkaConnectClustersRelativePath, +} from 'lib/paths'; import React from 'react'; +import { exportTableCSV, useTableInstance } from 'components/common/NewTable'; +import { Button } from 'components/common/Button/Button'; + +type ConnectPage = + | typeof kafkaConnectClustersRelativePath + | typeof clusterConnectorsRelativePath; + +const getCsvPrefix = (page: ConnectPage) => { + let prefix = 'kafka-connect'; + + if (page === clusterConnectorsRelativePath) { + prefix += '-connectors'; + } + + if (page === kafkaConnectClustersRelativePath) { + prefix += '-clusters'; + } + + return prefix; +}; const Header = () => { const { isReadOnly } = React.useContext(ClusterContext); - const { clusterName } = useAppParams(); + const { '*': currentPath, clusterName } = useAppParams< + ClusterNameRoute & { ['*']: ConnectPage } + >(); const { data: connects = [] } = useConnects(clusterName, true); + + const instance = useTableInstance(); + + const handleExportClick = () => { + exportTableCSV(instance?.table, { prefix: getCsvPrefix(currentPath) }); + }; + return ( {!isReadOnly && ( @@ -35,6 +69,10 @@ const Header = () => { placement="left" /> )} + + ); }; diff --git a/frontend/src/components/Connect/Header/__tests__/Header.spec.tsx b/frontend/src/components/Connect/Header/__tests__/Header.spec.tsx index 8f1bd93d1..3e1ccfc00 100644 --- a/frontend/src/components/Connect/Header/__tests__/Header.spec.tsx +++ b/frontend/src/components/Connect/Header/__tests__/Header.spec.tsx @@ -12,6 +12,13 @@ jest.mock('lib/hooks/api/kafkaConnect', () => ({ useConnects: jest.fn(), })); +jest.mock('components/common/NewTable', () => ({ + useTableInstance: () => ({ + table: null, + }), + exportTableCSV: jest.fn(), +})); + describe('Kafka Connect header', () => { beforeEach(() => { (useConnects as jest.Mock).mockImplementation(() => ({ diff --git a/frontend/src/components/Connect/List/ConnectorsTable/connectorsColumns/cells/RunningTasksCell.tsx b/frontend/src/components/Connect/List/ConnectorsTable/connectorsColumns/cells/RunningTasksCell.tsx index fc829df49..9f7ba53bf 100644 --- a/frontend/src/components/Connect/List/ConnectorsTable/connectorsColumns/cells/RunningTasksCell.tsx +++ b/frontend/src/components/Connect/List/ConnectorsTable/connectorsColumns/cells/RunningTasksCell.tsx @@ -3,19 +3,23 @@ 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; +export const getRunningTasksCountText = (connector: FullConnectorInfo) => { + const { tasksCount, failedTasksCount } = connector; const failedCount = failedTasksCount ?? 0; const count = tasksCount ?? 0; const text = `${count - failedCount}/${count}`; - if (!tasksCount) { - return null; - } + return { count, failedCount, text }; +}; + +const RunningTasksCell: React.FC> = ({ + row, +}) => { + const { count, failedCount, text } = getRunningTasksCountText(row.original); + + if (!count) return null; if (failedCount > 0) { return ( diff --git a/frontend/src/components/Connect/List/ConnectorsTable/connectorsColumns/columns.tsx b/frontend/src/components/Connect/List/ConnectorsTable/connectorsColumns/columns.tsx index 1fc65eff6..7cc92b3d3 100644 --- a/frontend/src/components/Connect/List/ConnectorsTable/connectorsColumns/columns.tsx +++ b/frontend/src/components/Connect/List/ConnectorsTable/connectorsColumns/columns.tsx @@ -5,7 +5,9 @@ import { TagCell } from 'components/common/NewTable'; import { KafkaConnectLinkCell } from './cells/KafkaConnectLinkCell'; import TopicsCell from './cells/TopicsCell'; -import RunningTasksCell from './cells/RunningTasksCell'; +import RunningTasksCell, { + getRunningTasksCountText, +} from './cells/RunningTasksCell'; import ActionsCell from './cells/ActionsCell'; export const connectorsColumns: ColumnDef[] = [ @@ -43,7 +45,10 @@ export const connectorsColumns: ColumnDef[] = [ accessorKey: 'topics', cell: TopicsCell, enableColumnFilter: true, - meta: { filterVariant: 'multi-select' }, + meta: { + filterVariant: 'multi-select', + csvFn: (row) => (row.topics ? row.topics.join(', ') : '-'), + }, filterFn: 'arrIncludesSome', enableResizing: true, }, @@ -51,14 +56,16 @@ export const connectorsColumns: ColumnDef[] = [ header: 'Status', accessorKey: 'status.state', cell: TagCell, - meta: { filterVariant: 'multi-select' }, + meta: { filterVariant: 'multi-select', csvFn: (row) => row.status.state }, filterFn: 'arrIncludesSome', }, { id: 'running_task', + accessorKey: 'tasksCount', header: 'Running Tasks', cell: RunningTasksCell, size: 120, + meta: { csvFn: (row) => getRunningTasksCountText(row).text }, }, { header: '', diff --git a/frontend/src/components/Connect/List/List.tsx b/frontend/src/components/Connect/List/List.tsx deleted file mode 100644 index b8abbbcf7..000000000 --- a/frontend/src/components/Connect/List/List.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import React from 'react'; -import { ConnectorsTable } from 'components/Connect/List/ConnectorsTable/ConnectorsTable'; -import { FullConnectorInfo } from 'generated-sources'; - -interface ConnectorsListProps { - connectors: FullConnectorInfo[]; -} - -const List: React.FC = ({ connectors }) => { - return ; -}; - -export default List; diff --git a/frontend/src/components/Connect/List/ListPage.tsx b/frontend/src/components/Connect/List/ListPage.tsx index dab3fe3f1..040e3663d 100644 --- a/frontend/src/components/Connect/List/ListPage.tsx +++ b/frontend/src/components/Connect/List/ListPage.tsx @@ -11,7 +11,7 @@ import { FullConnectorInfo } from 'generated-sources'; import { FilteredConnectorsProvider } from 'components/Connect/model/FilteredConnectorsProvider'; import * as S from './ListPage.styled'; -import List from './List'; +import { ConnectorsTable } from './ConnectorsTable/ConnectorsTable'; import ConnectorsStatistics from './Statistics/Statistics'; const emptyConnectors: FullConnectorInfo[] = []; @@ -36,7 +36,7 @@ const ListPage: React.FC = () => { /> }> - + ); diff --git a/frontend/src/components/Connect/List/__tests__/List.spec.tsx b/frontend/src/components/Connect/List/__tests__/List.spec.tsx deleted file mode 100644 index a489f2fb7..000000000 --- a/frontend/src/components/Connect/List/__tests__/List.spec.tsx +++ /dev/null @@ -1,168 +0,0 @@ -import React from 'react'; -import { connectors } from 'lib/fixtures/kafkaConnect'; -import ClusterContext, { - ContextProps, - initialValue, -} from 'components/contexts/ClusterContext'; -import List from 'components/Connect/List/List'; -import { screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { render, WithRoute } from 'lib/testHelpers'; -import { clusterConnectConnectorPath, clusterConnectorsPath } from 'lib/paths'; -import { - useConnectors, - useDeleteConnector, - useResetConnectorOffsets, - useUpdateConnectorState, -} from 'lib/hooks/api/kafkaConnect'; -import { FullConnectorInfo } from 'generated-sources'; -import { FilteredConnectorsProvider } from 'components/Connect/model/FilteredConnectorsProvider'; - -const mockedUsedNavigate = jest.fn(); -const mockDelete = jest.fn(); -const mockResetOffsets = jest.fn(); - -jest.mock('react-router-dom', () => ({ - ...jest.requireActual('react-router-dom'), - useNavigate: () => mockedUsedNavigate, -})); - -jest.mock('lib/hooks/api/kafkaConnect', () => ({ - useConnectors: jest.fn(), - useDeleteConnector: jest.fn(), - useUpdateConnectorState: jest.fn(), - useResetConnectorOffsets: jest.fn(), -})); - -const clusterName = 'local'; - -const renderComponent = ( - contextValue: ContextProps = initialValue, - data: FullConnectorInfo[] = connectors -) => - render( - - - - - - - , - { initialEntries: [clusterConnectorsPath(clusterName)] } - ); - -describe('Connectors List', () => { - describe('when the connectors are loaded', () => { - beforeEach(() => { - const restartConnector = jest.fn(); - (useUpdateConnectorState as jest.Mock).mockImplementation(() => ({ - mutateAsync: restartConnector, - })); - }); - - it('renders', async () => { - renderComponent(); - expect(screen.getByRole('table')).toBeInTheDocument(); - expect(screen.getAllByRole('row').length).toEqual(4); - }); - - it('opens broker when row clicked', async () => { - renderComponent(); - screen.debug(); - expect(screen.getByText('hdfs-source-connector')).toHaveAttribute( - 'href', - clusterConnectConnectorPath( - clusterName, - 'first', - 'hdfs-source-connector' - ) - ); - }); - }); - - describe('when table is empty', () => { - beforeEach(() => { - (useConnectors as jest.Mock).mockImplementation(() => ({ - data: [], - })); - }); - - it('renders empty table', async () => { - renderComponent(undefined, []); - expect(screen.getByRole('table')).toBeInTheDocument(); - expect( - screen.getByRole('row', { name: 'No connectors found' }) - ).toBeInTheDocument(); - }); - }); - - describe('when delete modal is open', () => { - beforeEach(() => { - (useConnectors as jest.Mock).mockImplementation(() => ({ - data: connectors, - })); - (useDeleteConnector as jest.Mock).mockImplementation(() => ({ - mutateAsync: mockDelete, - })); - }); - - it('calls deleteConnector on confirm', async () => { - renderComponent(); - const deleteButton = screen.getAllByText('Delete')[0]; - await waitFor(() => userEvent.click(deleteButton)); - - const submitButton = screen.getAllByRole('button', { - name: 'Confirm', - })[0]; - await userEvent.click(submitButton); - expect(mockDelete).toHaveBeenCalledWith(); - }); - - it('closes the modal when cancel button is clicked', async () => { - renderComponent(); - const deleteButton = screen.getAllByText('Delete')[0]; - await waitFor(() => userEvent.click(deleteButton)); - - const cancelButton = screen.getAllByRole('button', { - name: 'Cancel', - })[0]; - await waitFor(() => userEvent.click(cancelButton)); - expect(cancelButton).not.toBeInTheDocument(); - }); - }); - - describe('when reset connector offsets modal is open', () => { - beforeEach(() => { - (useConnectors as jest.Mock).mockImplementation(() => ({ - data: connectors, - })); - (useResetConnectorOffsets as jest.Mock).mockImplementation(() => ({ - mutateAsync: mockResetOffsets, - })); - }); - - it('calls resetConnectorOffsets on confirm', async () => { - renderComponent(); - const resetButton = screen.getAllByText('Reset Offsets')[2]; - await waitFor(() => userEvent.click(resetButton)); - - const submitButton = screen.getAllByRole('button', { - name: 'Confirm', - })[0]; - await userEvent.click(submitButton); - expect(mockResetOffsets).toHaveBeenCalledWith(); - }); - - it('closes the modal when cancel button is clicked', async () => { - renderComponent(); - const resetButton = screen.getAllByText('Reset Offsets')[2]; - await waitFor(() => userEvent.click(resetButton)); - - const cancelButton = screen.getAllByRole('button', { - name: 'Cancel', - })[0]; - await waitFor(() => userEvent.click(cancelButton)); - expect(cancelButton).not.toBeInTheDocument(); - }); - }); -}); diff --git a/frontend/src/components/Connect/List/__tests__/ListPage.spec.tsx b/frontend/src/components/Connect/List/__tests__/ListPage.spec.tsx index 77196a1be..18c4a8ab3 100644 --- a/frontend/src/components/Connect/List/__tests__/ListPage.spec.tsx +++ b/frontend/src/components/Connect/List/__tests__/ListPage.spec.tsx @@ -10,10 +10,20 @@ import { render, WithRoute } from 'lib/testHelpers'; import { clusterConnectorsPath } from 'lib/paths'; import { useConnectors, useConnects } from 'lib/hooks/api/kafkaConnect'; -jest.mock('components/Connect/List/List', () => () => ( -
Connectors List
+jest.mock('components/Connect/List/ConnectorsTable/ConnectorsTable', () => ({ + ConnectorsTable: () =>
Connectors List
, +})); + +jest.mock('components/Connect/List/Statistics/Statistics', () => () => ( +
Statistics
)); +jest.mock('components/common/Fts/Fts', () => () =>
Fts
); + +jest.mock('components/common/Fts/useFts', () => () => ({ + isFtsEnabled: false, +})); + jest.mock('lib/hooks/api/kafkaConnect', () => ({ useConnectors: jest.fn(), useConnects: jest.fn(), diff --git a/frontend/src/components/ConsumerGroups/Details/Details.tsx b/frontend/src/components/ConsumerGroups/Details/Details.tsx index 006882e18..f024df640 100644 --- a/frontend/src/components/ConsumerGroups/Details/Details.tsx +++ b/frontend/src/components/ConsumerGroups/Details/Details.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { useNavigate, useSearchParams } from 'react-router-dom'; +import { useNavigate } from 'react-router-dom'; import useAppParams from 'lib/hooks/useAppParams'; import { clusterConsumerGroupResetRelativePath, @@ -10,14 +10,11 @@ import Search from 'components/common/Search/Search'; import ClusterContext from 'components/contexts/ClusterContext'; import * as Metrics from 'components/common/Metrics'; import { Tag } from 'components/common/Tag/Tag.styled'; -import groupBy from 'lib/functions/groupBy'; -import { Table } from 'components/common/table/Table/Table.styled'; import getTagColor from 'components/common/Tag/getTagColor'; import { Dropdown } from 'components/common/Dropdown'; import { ControlPanelWrapper } from 'components/common/ControlPanel/ControlPanel.styled'; import { Action, ConsumerGroupState, ResourceType } from 'generated-sources'; import { ActionDropdownItem } from 'components/common/ActionComponent'; -import TableHeaderCell from 'components/common/table/TableHeaderCell/TableHeaderCell'; import { useConsumerGroupDetails, useDeleteConsumerGroupMutation, @@ -25,18 +22,18 @@ import { import Tooltip from 'components/common/Tooltip/Tooltip'; import { CONSUMER_GROUP_STATE_TOOLTIPS } from 'lib/constants'; import ResourcePageHeading from 'components/common/ResourcePageHeading/ResourcePageHeading'; +import { exportTableCSV, TableProvider } from 'components/common/NewTable'; +import { Button } from 'components/common/Button/Button'; -import ListItem from './ListItem'; +import { TopicsTable } from './TopicsTable/TopicsTable'; const Details: React.FC = () => { const navigate = useNavigate(); - const [searchParams] = useSearchParams(); - const searchValue = searchParams.get('q') || ''; const { isReadOnly } = React.useContext(ClusterContext); const routeParams = useAppParams(); const { clusterName, consumerGroupID } = routeParams; - const consumerGroup = useConsumerGroupDetails(routeParams); + const { data: consumerGroup } = useConsumerGroupDetails(routeParams); const deleteConsumerGroup = useDeleteConsumerGroupMutation(routeParams); const onDelete = async () => { @@ -48,112 +45,103 @@ const Details: React.FC = () => { navigate(clusterConsumerGroupResetRelativePath); }; - const partitionsByTopic = groupBy( - consumerGroup.data?.partitions || [], - 'topic' - ); - const filteredPartitionsByTopic = Object.keys(partitionsByTopic).filter( - (el) => el.includes(searchValue) - ); - const currentPartitionsByTopic = searchValue.length - ? filteredPartitionsByTopic - : Object.keys(partitionsByTopic); - - const hasAssignedTopics = consumerGroup?.data?.topics !== 0; + const hasAssignedTopics = consumerGroup?.topics !== 0; return ( -
-
- - {!isReadOnly && ( - - - Reset offset - - + {({ table }) => { + const handleExportClick = () => { + exportTableCSV(table, { prefix: 'connector-topics' }); + }; + + return ( + <> +
+ - Delete consumer group - - - )} - -
- - - - - {consumerGroup.data?.state} - - } - content={ - CONSUMER_GROUP_STATE_TOOLTIPS[ - consumerGroup.data?.state || ConsumerGroupState.UNKNOWN - ] - } - placement="bottom-start" - /> - - - {consumerGroup.data?.members} - - - {consumerGroup.data?.topics} - - - {consumerGroup.data?.partitions?.length} - - - {consumerGroup.data?.coordinator?.id} - - - {consumerGroup.data?.consumerLag} - - - - - - -
- - - - - - - - {currentPartitionsByTopic.map((key) => ( - - ))} - -
- + + + {!isReadOnly && ( + + + Reset offset + + + Delete consumer group + + + )} +
+ + + + + + {consumerGroup?.state} + + } + content={ + CONSUMER_GROUP_STATE_TOOLTIPS[ + consumerGroup?.state || ConsumerGroupState.UNKNOWN + ] + } + placement="bottom-start" + /> + + + {consumerGroup?.members} + + + {consumerGroup?.topics} + + + {consumerGroup?.partitions?.length} + + + {consumerGroup?.coordinator?.id} + + + {consumerGroup?.consumerLag} + + + + + + + + + + ); + }} + ); }; diff --git a/frontend/src/components/ConsumerGroups/Details/ListItem.styled.ts b/frontend/src/components/ConsumerGroups/Details/ListItem.styled.ts deleted file mode 100644 index 358a45e0a..000000000 --- a/frontend/src/components/ConsumerGroups/Details/ListItem.styled.ts +++ /dev/null @@ -1,7 +0,0 @@ -import styled from 'styled-components'; - -export const FlexWrapper = styled.div` - display: flex; - align-items: center; - gap: 8px; -`; diff --git a/frontend/src/components/ConsumerGroups/Details/ListItem.tsx b/frontend/src/components/ConsumerGroups/Details/ListItem.tsx deleted file mode 100644 index 19701b91d..000000000 --- a/frontend/src/components/ConsumerGroups/Details/ListItem.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import React from 'react'; -import { - Action, - ConsumerGroupTopicPartition, - ResourceType, -} from 'generated-sources'; -import { Link } from 'react-router-dom'; -import { ClusterName } from 'lib/interfaces/cluster'; -import { ClusterGroupParam, clusterTopicPath } from 'lib/paths'; -import { useDeleteConsumerGroupOffsetsMutation } from 'lib/hooks/api/consumers'; -import useAppParams from 'lib/hooks/useAppParams'; -import { Dropdown } from 'components/common/Dropdown'; -import { ActionDropdownItem } from 'components/common/ActionComponent'; -import MessageToggleIcon from 'components/common/Icons/MessageToggleIcon'; -import IconButtonWrapper from 'components/common/Icons/IconButtonWrapper'; -import { TableKeyLink } from 'components/common/table/Table/TableKeyLink.styled'; - -import TopicContents from './TopicContents/TopicContents'; -import { FlexWrapper } from './ListItem.styled'; - -interface Props { - clusterName: ClusterName; - name: string; - consumers: ConsumerGroupTopicPartition[]; -} - -const ListItem: React.FC = ({ clusterName, name, consumers }) => { - const [isOpen, setIsOpen] = React.useState(false); - const consumerProps = useAppParams(); - const deleteOffsetMutation = - useDeleteConsumerGroupOffsetsMutation(consumerProps); - - const getTotalconsumerLag = () => { - if (consumers.every((consumer) => consumer?.consumerLag === null)) { - return 'N/A'; - } - let count = 0; - consumers.forEach((consumer) => { - count += consumer?.consumerLag || 0; - }); - return count; - }; - - const deleteOffsetHandler = (topicName?: string) => { - if (topicName === undefined) return; - deleteOffsetMutation.mutateAsync(topicName); - }; - - return ( - <> - - - - setIsOpen(!isOpen)} aria-hidden> - - - - {name} - - - - {getTotalconsumerLag()} - - - deleteOffsetHandler(name)} - danger - confirm="Are you sure you want to delete offsets from the topic?" - permission={{ - resource: ResourceType.CONSUMER, - action: Action.RESET_OFFSETS, - value: consumerProps.consumerGroupID, - }} - > - Delete offsets - - - - - {isOpen && } - - ); -}; - -export default ListItem; diff --git a/frontend/src/components/ConsumerGroups/Details/TopicContents/TopicContent.styled.ts b/frontend/src/components/ConsumerGroups/Details/TopicContents/TopicContent.styled.ts deleted file mode 100644 index af36dfbee..000000000 --- a/frontend/src/components/ConsumerGroups/Details/TopicContents/TopicContent.styled.ts +++ /dev/null @@ -1,18 +0,0 @@ -import styled, { css } from 'styled-components'; - -export const TopicContentWrapper = styled.tr` - background-color: ${({ theme }) => theme.default.backgroundColor}; - & > td { - padding: 16px !important; - background-color: ${({ theme }) => - theme.consumerTopicContent.td.backgroundColor}; - } -`; - -export const ContentBox = styled.div( - ({ theme }) => css` - background-color: ${theme.default.backgroundColor}; - padding: 20px; - border-radius: 8px; - ` -); diff --git a/frontend/src/components/ConsumerGroups/Details/TopicContents/TopicContents.tsx b/frontend/src/components/ConsumerGroups/Details/TopicContents/TopicContents.tsx index 8686c26ff..a6f3ebc47 100644 --- a/frontend/src/components/ConsumerGroups/Details/TopicContents/TopicContents.tsx +++ b/frontend/src/components/ConsumerGroups/Details/TopicContents/TopicContents.tsx @@ -3,8 +3,6 @@ import TableHeaderCell from 'components/common/table/TableHeaderCell/TableHeader import { ConsumerGroupTopicPartition, SortOrder } from 'generated-sources'; import React from 'react'; -import { ContentBox, TopicContentWrapper } from './TopicContent.styled'; - interface Props { consumers: ConsumerGroupTopicPartition[]; } @@ -129,40 +127,34 @@ const TopicContents: React.FC = ({ consumers }) => { }, [orderBy, sortOrder, consumers]); return ( - - - - - - - {TABLE_HEADERS_MAP.map((header) => ( - - ))} - - - - {sortedConsumers.map((consumer) => ( - - - - - - - - - ))} - -
{consumer.partition}{consumer.consumerId}{consumer.host}{consumer.consumerLag}{consumer.currentOffset}{consumer.endOffset}
-
- -
+ + + + {TABLE_HEADERS_MAP.map((header) => ( + + ))} + + + + {sortedConsumers.map((consumer) => ( + + + + + + + + + ))} + +
{consumer.partition}{consumer.consumerId}{consumer.host}{consumer.consumerLag}{consumer.currentOffset}{consumer.endOffset}
); }; diff --git a/frontend/src/components/ConsumerGroups/Details/TopicsTable/TopicsTable.tsx b/frontend/src/components/ConsumerGroups/Details/TopicsTable/TopicsTable.tsx new file mode 100644 index 000000000..64f768e5c --- /dev/null +++ b/frontend/src/components/ConsumerGroups/Details/TopicsTable/TopicsTable.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import Table from 'components/common/NewTable'; +import { ConsumerGroupTopicPartition } from 'generated-sources'; +import { useSearchParams } from 'react-router-dom'; +import TopicContents from 'components/ConsumerGroups/Details/TopicContents/TopicContents'; + +import { + getConsumerGroupTopicsTableColumns, + getConsumerGroupTopicsTableData, +} from './lib/utils'; + +type TopicsTableProps = { + partitions: ConsumerGroupTopicPartition[]; +}; + +export const TopicsTable = ({ partitions }: TopicsTableProps) => { + const [searchParams] = useSearchParams(); + const searchQuery = searchParams.get('q') || ''; + + const columns = getConsumerGroupTopicsTableColumns(); + const tableData = getConsumerGroupTopicsTableData({ + partitions, + searchQuery, + }); + + return ( + true} + columns={columns} + data={tableData} + emptyMessage="No topics" + renderSubComponent={() => } + /> + ); +}; diff --git a/frontend/src/components/ConsumerGroups/Details/TopicsTable/cells/cells.tsx b/frontend/src/components/ConsumerGroups/Details/TopicsTable/cells/cells.tsx new file mode 100644 index 000000000..cd46f2f95 --- /dev/null +++ b/frontend/src/components/ConsumerGroups/Details/TopicsTable/cells/cells.tsx @@ -0,0 +1,70 @@ +import React from 'react'; +import { CellContext } from '@tanstack/react-table'; +import { Link } from 'react-router-dom'; +import { ClusterGroupParam, clusterTopicPath } from 'lib/paths'; +import { TableKeyLink } from 'components/common/table/Table/TableKeyLink.styled'; +import useAppParams from 'lib/hooks/useAppParams'; +import { ConsumerGroupTopicsTableRow } from 'components/ConsumerGroups/Details/TopicsTable/lib/types'; +import { ActionDropdownItem } from 'components/common/ActionComponent'; +import { Action, ResourceType } from 'generated-sources'; +import { Dropdown } from 'components/common/Dropdown'; +import { useDeleteConsumerGroupOffsetsMutation } from 'lib/hooks/api/consumers'; + +type TopicNameProps = CellContext< + ConsumerGroupTopicsTableRow, + ConsumerGroupTopicsTableRow['topicName'] +>; + +export const TopicName = ({ getValue }: TopicNameProps) => { + const routeParams = useAppParams(); + const { clusterName } = routeParams; + const topicName = getValue(); + + return ( + + {topicName} + + ); +}; + +type ConsumerLagProps = CellContext< + ConsumerGroupTopicsTableRow, + ConsumerGroupTopicsTableRow['consumerLag'] +>; + +export const ConsumerLag = ({ getValue }: ConsumerLagProps) => getValue(); + +type ActionsProps = CellContext< + ConsumerGroupTopicsTableRow, + ConsumerGroupTopicsTableRow['topicName'] +>; + +export const Actions = ({ getValue }: ActionsProps) => { + const routeParams = useAppParams(); + const topicName = getValue(); + + const deleteOffsetMutation = + useDeleteConsumerGroupOffsetsMutation(routeParams); + + const deleteOffsetHandler = () => { + if (topicName === undefined) return; + deleteOffsetMutation.mutateAsync(topicName); + }; + + return ( + + + Delete offsets + + + ); +}; diff --git a/frontend/src/components/ConsumerGroups/Details/TopicsTable/lib/types.ts b/frontend/src/components/ConsumerGroups/Details/TopicsTable/lib/types.ts new file mode 100644 index 000000000..a6228a750 --- /dev/null +++ b/frontend/src/components/ConsumerGroups/Details/TopicsTable/lib/types.ts @@ -0,0 +1,6 @@ +import { Topic } from 'generated-sources'; + +export type ConsumerGroupTopicsTableRow = { + topicName: Topic['name']; + consumerLag: string | number; +}; diff --git a/frontend/src/components/ConsumerGroups/Details/TopicsTable/lib/utils.ts b/frontend/src/components/ConsumerGroups/Details/TopicsTable/lib/utils.ts new file mode 100644 index 000000000..6e5df177c --- /dev/null +++ b/frontend/src/components/ConsumerGroups/Details/TopicsTable/lib/utils.ts @@ -0,0 +1,61 @@ +import { ConsumerGroupTopicPartition } from 'generated-sources'; +import { createColumnHelper } from '@tanstack/react-table'; +import { NA } from 'components/Brokers/BrokersList/lib'; +import * as Cell from 'components/ConsumerGroups/Details/TopicsTable/cells/cells'; + +import { ConsumerGroupTopicsTableRow } from './types'; + +const getConsumerLagByTopic = (partitions: ConsumerGroupTopicPartition[]) => + partitions.reduce>( + (acc, p) => ({ + ...acc, + [p.topic]: [...(acc[p.topic] ?? []), p.consumerLag ?? 0], + }), + {} + ); + +const calculateConsumerLag = (lags: number[]) => { + const nonNullLags = lags.filter((x) => x != null); + return nonNullLags.length === 0 ? NA : nonNullLags.reduce((a, v) => a + v, 0); +}; + +export const getConsumerGroupTopicsTableData = ({ + partitions = [], + searchQuery, +}: { + partitions: ConsumerGroupTopicPartition[]; + searchQuery: string; +}): ConsumerGroupTopicsTableRow[] => { + if (partitions.length === 0) return []; + + const grouped = getConsumerLagByTopic(partitions); + return Object.entries(grouped) + .filter(([topic]) => topic.includes(searchQuery)) + .map(([topic, lags]) => ({ + topicName: topic, + consumerLag: calculateConsumerLag(lags), + })); +}; + +export const getConsumerGroupTopicsTableColumns = () => { + const columnHelper = createColumnHelper(); + + return [ + columnHelper.accessor('topicName', { + header: 'Topic', + cell: Cell.TopicName, + size: 800, + }), + columnHelper.accessor('consumerLag', { + header: 'Consumer lag', + cell: Cell.ConsumerLag, + size: 350, + }), + columnHelper.accessor('topicName', { + id: 'actions', + header: undefined, + cell: Cell.Actions, + size: 10, + }), + ]; +}; diff --git a/frontend/src/components/ConsumerGroups/List.tsx b/frontend/src/components/ConsumerGroups/List.tsx index ad66e718c..c9dae3621 100644 --- a/frontend/src/components/ConsumerGroups/List.tsx +++ b/frontend/src/components/ConsumerGroups/List.tsx @@ -10,7 +10,12 @@ import { import useAppParams from 'lib/hooks/useAppParams'; import { clusterConsumerGroupDetailsPath, ClusterNameRoute } from 'lib/paths'; import { ColumnDef } from '@tanstack/react-table'; -import Table, { LinkCell, TagCell } from 'components/common/NewTable'; +import Table, { + exportTableCSV, + LinkCell, + TableProvider, + TagCell, +} from 'components/common/NewTable'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { CONSUMER_GROUP_STATE_TOOLTIPS, PER_PAGE } from 'lib/constants'; import { useConsumerGroups } from 'lib/hooks/api/consumers'; @@ -19,6 +24,7 @@ import ResourcePageHeading from 'components/common/ResourcePageHeading/ResourceP import { useLocalStoragePersister } from 'components/common/NewTable/ColumnResizer/lib'; import useFts from 'components/common/Fts/useFts'; import Fts from 'components/common/Fts/Fts'; +import { Button } from 'components/common/Button/Button'; const List = () => { const { clusterName } = useAppParams(); @@ -53,6 +59,9 @@ const List = () => { /> ), size: 600, + meta: { + csvFn: (row) => row.groupId, + }, }, { id: ConsumerGroupOrdering.MEMBERS, @@ -80,6 +89,9 @@ const List = () => { accessorKey: 'coordinator.id', enableSorting: false, size: 104, + meta: { + csvFn: (row) => String(row.coordinator?.id) || '-', + }, }, { id: ConsumerGroupOrdering.STATE, @@ -97,6 +109,9 @@ const List = () => { ); }, size: 124, + meta: { + csvFn: (row) => String(row.state), + }, }, ], [] @@ -105,35 +120,53 @@ const List = () => { const columnSizingPersister = useLocalStoragePersister('Consumers'); return ( - <> - - - } - /> - -
- navigate( - clusterConsumerGroupDetailsPath(clusterName, original.groupId) - ) - } - enableColumnResizing - columnSizingPersister={columnSizingPersister} - disabled={consumerGroups.isFetching} - /> - + + {({ table }) => { + const handleExportClick = () => { + exportTableCSV(table, { prefix: 'consumers' }); + }; + + return ( + <> + + + + + } + /> + +
+ navigate( + clusterConsumerGroupDetailsPath(clusterName, original.groupId) + ) + } + enableColumnResizing + columnSizingPersister={columnSizingPersister} + disabled={consumerGroups.isFetching} + /> + + ); + }} + ); }; diff --git a/frontend/src/components/Topics/List/ListPage.tsx b/frontend/src/components/Topics/List/ListPage.tsx index 811bde1a7..d44c6119f 100644 --- a/frontend/src/components/Topics/List/ListPage.tsx +++ b/frontend/src/components/Topics/List/ListPage.tsx @@ -14,6 +14,8 @@ import { Action, ResourceType } from 'generated-sources'; import ResourcePageHeading from 'components/common/ResourcePageHeading/ResourcePageHeading'; import Fts from 'components/common/Fts/Fts'; import useFts from 'components/common/Fts/useFts'; +import { exportTableCSV, TableProvider } from 'components/common/NewTable'; +import { Button } from 'components/common/Button/Button'; const ListPage: React.FC = () => { const { isReadOnly } = React.useContext(ClusterContext); @@ -49,40 +51,60 @@ const ListPage: React.FC = () => { }; return ( - <> - - {!isReadOnly && ( - - Add a Topic - - )} - - - } - /> - - - }> - - - + + {({ table }) => { + const handleExportClick = () => { + exportTableCSV(table, { prefix: 'topics' }); + }; + + return ( + <> + + <> + {!isReadOnly && ( + + Add a Topic + + )} + + + + + + } + /> + + + }> + + + + ); + }} + ); }; diff --git a/frontend/src/components/Topics/List/TopicTable.tsx b/frontend/src/components/Topics/List/TopicTable.tsx index 1ce85b5e6..214bf6789 100644 --- a/frontend/src/components/Topics/List/TopicTable.tsx +++ b/frontend/src/components/Topics/List/TopicTable.tsx @@ -10,6 +10,7 @@ import { useTopics } from 'lib/hooks/api/topics'; import { PER_PAGE } from 'lib/constants'; import { useLocalStoragePersister } from 'components/common/NewTable/ColumnResizer/lib'; import useFts from 'components/common/Fts/useFts'; +import { formatBytes } from 'components/common/BytesFormatted/utils'; import { TopicTitleCell } from './TopicTitleCell'; import ActionsCell from './ActionsCell'; @@ -93,6 +94,9 @@ const TopicTable: React.FC = () => { accessorKey: 'segmentSize', size: 100, cell: SizeCell, + meta: { + csvFn: (row: Topic) => formatBytes(row.segmentSize, 0), + }, }, { id: 'actions', diff --git a/frontend/src/components/common/BytesFormatted/BytesFormatted.tsx b/frontend/src/components/common/BytesFormatted/BytesFormatted.tsx index 5bfc5a167..b2f3cf6cf 100644 --- a/frontend/src/components/common/BytesFormatted/BytesFormatted.tsx +++ b/frontend/src/components/common/BytesFormatted/BytesFormatted.tsx @@ -1,5 +1,6 @@ import React from 'react'; +import { formatBytes } from './utils'; import { NoWrap } from './BytesFormatted.styled'; interface Props { @@ -7,23 +8,11 @@ interface Props { precision?: number; } -export const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; - const BytesFormatted: React.FC = ({ value, precision = 0 }) => { - const formattedValue = React.useMemo((): string => { - try { - const bytes = typeof value === 'string' ? parseInt(value, 10) : value; - if (Number.isNaN(bytes) || (bytes && bytes < 0)) return `-Bytes`; - if (!bytes || bytes < 1024) return `${Math.ceil(bytes || 0)} ${sizes[0]}`; - const pow = Math.floor(Math.log2(bytes) / 10); - const multiplier = 10 ** (precision < 0 ? 0 : precision); - return `${Math.round((bytes * multiplier) / 1024 ** pow) / multiplier} ${ - sizes[pow] - }`; - } catch (e) { - return `-Bytes`; - } - }, [precision, value]); + const formattedValue = React.useMemo( + () => formatBytes(value, precision), + [precision, value] + ); return {formattedValue}; }; diff --git a/frontend/src/components/common/BytesFormatted/__tests__/BytesFormatted.spec.tsx b/frontend/src/components/common/BytesFormatted/__tests__/BytesFormatted.spec.tsx index e97767fa2..6cd94dd99 100644 --- a/frontend/src/components/common/BytesFormatted/__tests__/BytesFormatted.spec.tsx +++ b/frontend/src/components/common/BytesFormatted/__tests__/BytesFormatted.spec.tsx @@ -1,8 +1,7 @@ import React from 'react'; -import BytesFormatted, { - sizes, -} from 'components/common/BytesFormatted/BytesFormatted'; +import BytesFormatted from 'components/common/BytesFormatted/BytesFormatted'; import { render, screen } from '@testing-library/react'; +import { sizes } from 'components/common/BytesFormatted/utils'; describe('BytesFormatted', () => { it('renders Bytes correctly', () => { diff --git a/frontend/src/components/common/BytesFormatted/utils.ts b/frontend/src/components/common/BytesFormatted/utils.ts new file mode 100644 index 000000000..04c1c24b9 --- /dev/null +++ b/frontend/src/components/common/BytesFormatted/utils.ts @@ -0,0 +1,19 @@ +export const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; + +export const formatBytes = ( + value: string | number | undefined, + precision: number = 0 +): string => { + try { + const bytes = typeof value === 'string' ? parseInt(value, 10) : value; + if (Number.isNaN(bytes) || (bytes && bytes < 0)) return '-Bytes'; + if (!bytes || bytes < 1024) return `${Math.ceil(bytes || 0)} Bytes`; + + const pow = Math.floor(Math.log2(bytes) / 10); + const multiplier = 10 ** (precision < 0 ? 0 : precision); + + return `${Math.round((bytes * multiplier) / 1024 ** pow) / multiplier} ${sizes[pow]}`; + } catch (e) { + return '-Bytes'; + } +}; diff --git a/frontend/src/components/common/NewTable/Provider/context.ts b/frontend/src/components/common/NewTable/Provider/context.ts new file mode 100644 index 000000000..41870eb00 --- /dev/null +++ b/frontend/src/components/common/NewTable/Provider/context.ts @@ -0,0 +1,16 @@ +import { createContext, useContext, useMemo } from 'react'; +import type { Table } from '@tanstack/react-table'; + +export type TableContextValue = { + table: Table | null; + setTable: (t: Table) => void; +}; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const TableContext = createContext | null>(null); + +export const useTableInstance = () => { + const ctx = useContext | null>(TableContext); + + return useMemo(() => ctx, [ctx]); +}; diff --git a/frontend/src/components/common/NewTable/Provider/index.ts b/frontend/src/components/common/NewTable/Provider/index.ts new file mode 100644 index 000000000..5d6c54b24 --- /dev/null +++ b/frontend/src/components/common/NewTable/Provider/index.ts @@ -0,0 +1,2 @@ +export { TableProvider } from './provider'; +export { useTableInstance } from './context'; diff --git a/frontend/src/components/common/NewTable/Provider/provider.tsx b/frontend/src/components/common/NewTable/Provider/provider.tsx new file mode 100644 index 000000000..1c284b189 --- /dev/null +++ b/frontend/src/components/common/NewTable/Provider/provider.tsx @@ -0,0 +1,20 @@ +import React, { useMemo, useState } from 'react'; +import type { Table } from '@tanstack/react-table'; + +import { TableContext, TableContextValue } from './context'; + +type TableProviderProps = { + children: React.ReactNode | ((ctx: TableContextValue) => React.ReactNode); +}; + +export function TableProvider({ children }: TableProviderProps) { + const [table, setTable] = useState | null>(null); + + const value = useMemo(() => ({ table, setTable }), [table, setTable]); + + return ( + + {typeof children === 'function' ? children(value) : children} + + ); +} diff --git a/frontend/src/components/common/NewTable/Table.tsx b/frontend/src/components/common/NewTable/Table.tsx index 665f141c6..f859c0f88 100644 --- a/frontend/src/components/common/NewTable/Table.tsx +++ b/frontend/src/components/common/NewTable/Table.tsx @@ -32,6 +32,7 @@ import SelectRowCell from './SelectRowCell'; import SelectRowHeader from './SelectRowHeader'; import ColumnFilter, { type Persister } from './ColumnFilter'; import { ColumnSizingPersister } from './ColumnResizer/lib/persister/types'; +import { useTableInstance } from './Provider'; export interface TableProps { data: TData[]; @@ -170,6 +171,8 @@ function Table({ const [searchParams, setSearchParams] = useSearchParams(); const location = useLocation(); + const ctx = useTableInstance(); + const [rowSelection, setRowSelection] = React.useState({}); const onSortingChange = React.useCallback( @@ -265,14 +268,20 @@ function Table({ }, }); + useEffect(() => { + ctx?.setTable(table); + }, [table]); + const columnSizeVars = React.useMemo(() => { const headers = table.getFlatHeaders(); const colSizes: { [key: string]: number } = {}; for (let i = 0; i < headers.length; i += 1) { const header = headers[i]!; + colSizes[`--header-${header.id}-size`] = header.getSize(); colSizes[`--col-${header.column.id}-size`] = header.column.getSize(); } + return colSizes; }, [table.getState().columnSizingInfo, table.getState().columnSizing]); diff --git a/frontend/src/components/common/NewTable/__test__/Table.spec.tsx b/frontend/src/components/common/NewTable/__test__/Table.spec.tsx index 73d044c00..8f9bed485 100644 --- a/frontend/src/components/common/NewTable/__test__/Table.spec.tsx +++ b/frontend/src/components/common/NewTable/__test__/Table.spec.tsx @@ -7,6 +7,7 @@ import Table, { LinkCell, TagCell, } from 'components/common/NewTable'; +import { TableProvider } from 'components/common/NewTable/Provider'; import { screen } from '@testing-library/dom'; import { ColumnDef, Row } from '@tanstack/react-table'; import userEvent from '@testing-library/user-event'; @@ -101,12 +102,14 @@ interface Props extends TableProps { const renderComponent = (props: Partial = {}) => { render( -
+ +
+ , { initialEntries: [props.path || ''] } ); diff --git a/frontend/src/components/common/NewTable/index.ts b/frontend/src/components/common/NewTable/index.ts index 4584db2a5..c8ce6a5c0 100644 --- a/frontend/src/components/common/NewTable/index.ts +++ b/frontend/src/components/common/NewTable/index.ts @@ -4,6 +4,9 @@ import SizeCell from './SizeCell'; import LinkCell from './LinkCell'; import TagCell from './TagCell'; +export { TableProvider, useTableInstance } from './Provider'; +export { exportTableCSV } from './utils/exportTableCSV'; + export type { TableProps }; export { TimestampCell, SizeCell, LinkCell, TagCell }; diff --git a/frontend/src/components/common/NewTable/utils/exportTableCSV.ts b/frontend/src/components/common/NewTable/utils/exportTableCSV.ts new file mode 100644 index 000000000..4786a3bba --- /dev/null +++ b/frontend/src/components/common/NewTable/utils/exportTableCSV.ts @@ -0,0 +1,118 @@ +import type { RowData, Table } from '@tanstack/react-table'; +import innerText from 'react-innertext'; + +const escapeCsv = (value: string) => { + if (value.includes(',') || value.includes('"') || value.includes('\n')) { + return `"${value.replace(/"/g, '""')}"`; + } + return value; +}; + +export type ExportCsvOptions = { + filename?: string; + prefix?: string; + includeDate?: boolean; + dateFormat?: (d: Date) => string; +}; + +export const exportTableCSV = ( + table: Table | null | undefined, + options: ExportCsvOptions = {} +) => { + if (!table) return; + + const { + filename, + prefix = 'table_data', + includeDate = true, + dateFormat = (d: Date) => d.toISOString().slice(0, 10), + } = options; + + const rowsToExport = table.getSelectedRowModel().rows.length + ? table.getSelectedRowModel().rows + : table.getRowModel().rows; + + if (!rowsToExport.length) return; + + const headersColumns = table.getAllColumns().filter((col) => { + const header = col.columnDef.meta?.csv ?? col.columnDef.header; + const hasAccessorKey = + 'accessorKey' in col.columnDef && col.columnDef.accessorKey; + return header && hasAccessorKey; + }); + + const headers = headersColumns.map( + (col) => col.columnDef.meta?.csv ?? col.columnDef.header ?? col.id + ); + + const body = rowsToExport.map((row) => + headersColumns.map((col) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const original = row.original as Record; + + if (col.columnDef.meta?.csvFn) { + return escapeCsv(String(col.columnDef.meta.csvFn(row.original))); + } + + if (col.columnDef.cell && typeof col.columnDef.cell === 'function') { + try { + const accessorKey = + 'accessorKey' in col.columnDef + ? (col.columnDef.accessorKey as string) + : undefined; + + const cellValue = col.columnDef.cell({ + getValue: () => (accessorKey ? original[accessorKey] : undefined), + row, + column: col, + table, + cell: row.getAllCells().find((c) => c.column.id === col.id)!, + renderValue: () => + accessorKey ? original[accessorKey] : undefined, + }); + + if ( + cellValue && + typeof cellValue === 'object' && + 'props' in cellValue + ) { + return escapeCsv(innerText(cellValue) || ''); + } + + return escapeCsv(String(cellValue ?? '')); + } catch (error) { + // eslint-disable-next-line no-console + console.warn('CSV export: cell renderer failed', error); + } + } + + if ('accessorKey' in col.columnDef && col.columnDef.accessorKey) { + const accessorKey = col.columnDef.accessorKey as string; + const value = original[accessorKey]; + if (value === null || value === undefined) { + return ''; + } + + if (typeof value === 'object') { + return escapeCsv(JSON.stringify(value)); + } + return escapeCsv(String(value)); + } + + return ''; + }) + ); + + const csv = [headers.join(','), ...body.map((r) => r.join(','))].join('\n'); + + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); + const datePart = includeDate ? `_${dateFormat(new Date())}` : ''; + const file = filename ?? `${prefix}${datePart}.csv`; + + const link = document.createElement('a'); + link.href = window.URL.createObjectURL(blob); + link.setAttribute('download', file); + document.body.appendChild(link); + link.click(); + link.remove(); +}; diff --git a/frontend/src/components/common/ResourcePageHeading/ResourcePageHeading.tsx b/frontend/src/components/common/ResourcePageHeading/ResourcePageHeading.tsx index b3cf9f163..9b5d2619e 100644 --- a/frontend/src/components/common/ResourcePageHeading/ResourcePageHeading.tsx +++ b/frontend/src/components/common/ResourcePageHeading/ResourcePageHeading.tsx @@ -7,6 +7,7 @@ type ResourcePageHeadingProps = ComponentProps; const ResourcePageHeading: FC = (props) => { const { clusterName } = useAppParams<{ clusterName: ClusterName }>(); + return ; }; diff --git a/frontend/src/tanstack.d.ts b/frontend/src/tanstack.d.ts index b50d25efb..df607d41e 100644 --- a/frontend/src/tanstack.d.ts +++ b/frontend/src/tanstack.d.ts @@ -6,6 +6,8 @@ declare module '@tanstack/react-table' { interface ColumnMeta { filterVariant?: 'multi-select' | 'text'; width?: string; + csv?: string; + csvFn?: (row: TData) => string; } interface FilterFns {