diff --git a/api/src/test/java/io/kafbat/ui/mapper/KafkaConnectMapperTest.java b/api/src/test/java/io/kafbat/ui/mapper/KafkaConnectMapperTest.java index ab404046f..f6db92d38 100644 --- a/api/src/test/java/io/kafbat/ui/mapper/KafkaConnectMapperTest.java +++ b/api/src/test/java/io/kafbat/ui/mapper/KafkaConnectMapperTest.java @@ -42,8 +42,13 @@ void toKafkaConnect() { ConnectorDTO connectorDto = new ConnectorDTO(); connectorDto.setName(UUID.randomUUID().toString()); + + String traceMessage = connectorState == ConnectorStateDTO.FAILED + ? "Test error trace for failed connector" + : null; + connectorDto.setStatus( - new ConnectorStatusDTO(connectorState, UUID.randomUUID().toString()) + new ConnectorStatusDTO(connectorState, UUID.randomUUID().toString(), traceMessage) ); List tasks = new ArrayList<>(); diff --git a/api/src/test/java/io/kafbat/ui/service/index/KafkaConnectNgramFilterTest.java b/api/src/test/java/io/kafbat/ui/service/index/KafkaConnectNgramFilterTest.java index 66cf7db44..18d21bd5d 100644 --- a/api/src/test/java/io/kafbat/ui/service/index/KafkaConnectNgramFilterTest.java +++ b/api/src/test/java/io/kafbat/ui/service/index/KafkaConnectNgramFilterTest.java @@ -14,25 +14,22 @@ class KafkaConnectNgramFilterTest extends AbstractNgramFilterTest buildFilter(List items, - boolean enabled, - ClustersProperties.NgramProperties ngramProperties) { + boolean enabled, + ClustersProperties.NgramProperties ngramProperties) { return new KafkaConnectNgramFilter(items, enabled, ngramProperties); } @Override protected List items() { - return IntStream.range(0, 100).mapToObj(i -> - new FullConnectorInfoDTO( - "connect-" + i, - "connector-" + i, - "class", - ConnectorTypeDTO.SINK, - List.of(), - new ConnectorStatusDTO(ConnectorStateDTO.RUNNING, "reason"), - 1, - 0 - ) - ).toList(); + return IntStream.range(0, 100).mapToObj(i -> new FullConnectorInfoDTO( + "connect-" + i, + "connector-" + i, + "class", + ConnectorTypeDTO.SINK, + List.of(), + new ConnectorStatusDTO(ConnectorStateDTO.RUNNING, "worker-1", "reason"), + 1, + 0)).toList(); } @Override diff --git a/contract-typespec/api/kafka-connect.tsp b/contract-typespec/api/kafka-connect.tsp index 697e4bfd4..0c4d2fcd7 100644 --- a/contract-typespec/api/kafka-connect.tsp +++ b/contract-typespec/api/kafka-connect.tsp @@ -217,6 +217,7 @@ enum ConnectorState { model ConnectorStatus { state: ConnectorState; workerId?: string; + trace?: string; } model Connector { diff --git a/contract/src/main/resources/swagger/kafbat-ui-api.yaml b/contract/src/main/resources/swagger/kafbat-ui-api.yaml index a50a755a2..1f6310d54 100644 --- a/contract/src/main/resources/swagger/kafbat-ui-api.yaml +++ b/contract/src/main/resources/swagger/kafbat-ui-api.yaml @@ -3725,6 +3725,8 @@ components: $ref: '#/components/schemas/ConnectorState' workerId: type: string + trace: + type: string required: - state diff --git a/frontend/src/components/Connect/Details/Overview/Overview.styled.tsx b/frontend/src/components/Connect/Details/Overview/Overview.styled.tsx new file mode 100644 index 000000000..f61839e4c --- /dev/null +++ b/frontend/src/components/Connect/Details/Overview/Overview.styled.tsx @@ -0,0 +1,81 @@ +import styled, { css } from 'styled-components'; + +export const ModalOverlay = styled.div` + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: ${({ theme }) => theme.modal.overlay}; + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +`; + +export const ModalContent = styled.div( + ({ theme: { modal } }) => css` + background-color: ${modal.backgroundColor}; + color: ${modal.color}; + border-radius: 8px; + padding: 24px; + max-width: 65vw; + max-height: 80vh; + overflow: auto; + position: relative; + border: 1px solid ${modal.border.contrast}; + box-shadow: 0 4px 20px ${modal.shadow}; + ` +); + +export const ModalHeader = styled.div( + ({ theme: { modal } }) => css` + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; + border-bottom: 1px solid ${modal.border.bottom}; + padding-bottom: 12px; + ` +); + +export const ModalTitle = styled.h3` + margin: 0; + font-size: 18px; + font-weight: 600; +`; + +export const WorkerInfo = styled.p( + ({ theme: { modal } }) => css` + margin: 4px 0 0 0; + font-size: 14px; + color: ${modal.contentColor}; + ` +); + +export const TraceContent = styled.div( + ({ theme: { modal } }) => css` + background-color: ${modal.border.contrast}; + padding: 16px; + border-radius: 6px; + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; + font-size: 12px; + color: ${modal.color}; + border: 1px solid ${modal.border.contrast}; + max-height: 400px; + overflow-y: auto; + white-space: pre-wrap; + word-break: break-word; + ` +); + +export const ModalFooter = styled.div( + ({ theme: { modal } }) => css` + margin-top: 16px; + padding-top: 12px; + border-top: 1px solid ${modal.border.top}; + text-align: center; + display: flex; + justify-content: center; + ` +); diff --git a/frontend/src/components/Connect/Details/Overview/Overview.tsx b/frontend/src/components/Connect/Details/Overview/Overview.tsx index d1fdc56fb..aad545041 100644 --- a/frontend/src/components/Connect/Details/Overview/Overview.tsx +++ b/frontend/src/components/Connect/Details/Overview/Overview.tsx @@ -1,15 +1,19 @@ -import React from 'react'; +import React, { useState } from 'react'; import * as C from 'components/common/Tag/Tag.styled'; import * as Metrics from 'components/common/Metrics'; +import { Button } from 'components/common/Button/Button'; import getTagColor from 'components/common/Tag/getTagColor'; import { RouterParamsClusterConnectConnector } from 'lib/paths'; import useAppParams from 'lib/hooks/useAppParams'; import { useConnector, useConnectorTasks } from 'lib/hooks/api/kafkaConnect'; +import { ConnectorState } from 'generated-sources'; import getTaskMetrics from './getTaskMetrics'; +import * as S from './Overview.styled'; const Overview: React.FC = () => { const routerProps = useAppParams(); + const [showTraceModal, setShowTraceModal] = useState(false); const { data: connector } = useConnector(routerProps); const { data: tasks } = useConnectorTasks(routerProps); @@ -20,35 +24,90 @@ const Overview: React.FC = () => { const { running, failed } = getTaskMetrics(tasks); + const hasTraceInfo = connector.status.trace; + + const handleStateClick = () => { + if (connector.status.state === ConnectorState.FAILED && hasTraceInfo) { + setShowTraceModal(true); + } + }; + return ( - - - {connector.status?.workerId && ( - - {connector.status.workerId} + <> + + + {connector.status?.workerId && ( + + {connector.status.workerId} + + )} + {connector.type} + {connector.config['connector.class'] && ( + + {connector.config['connector.class']} + + )} + + + {connector.status.state} + - )} - {connector.type} - {connector.config['connector.class'] && ( - - {connector.config['connector.class']} + {running} + 0 ? 'error' : 'success'} + > + {failed} - )} - - - {connector.status.state} - - - {running} - 0 ? 'error' : 'success'} - > - {failed} - - - + + + + {showTraceModal && ( + setShowTraceModal(false)}> + e.stopPropagation()} + > + +
+ Connector Error Details + {connector.status.workerId && ( + + Worker: {connector.status.workerId} + + )} +
+
+ + + {connector.status.trace ? ( +
{connector.status.trace}
+ ) : null} +
+ + + + +
+
+ )} + ); }; diff --git a/frontend/src/components/Connect/Details/Overview/__tests__/Overview.spec.tsx b/frontend/src/components/Connect/Details/Overview/__tests__/Overview.spec.tsx index 2d4a01d14..2885ae38c 100644 --- a/frontend/src/components/Connect/Details/Overview/__tests__/Overview.spec.tsx +++ b/frontend/src/components/Connect/Details/Overview/__tests__/Overview.spec.tsx @@ -1,9 +1,10 @@ import React from 'react'; import Overview from 'components/Connect/Details/Overview/Overview'; import { connector, tasks } from 'lib/fixtures/kafkaConnect'; -import { screen } from '@testing-library/react'; +import { screen, fireEvent } from '@testing-library/react'; import { render } from 'lib/testHelpers'; import { useConnector, useConnectorTasks } from 'lib/hooks/api/kafkaConnect'; +import { ConnectorState } from 'generated-sources'; jest.mock('lib/hooks/api/kafkaConnect', () => ({ useConnector: jest.fn(), @@ -53,5 +54,96 @@ describe('Overview', () => { expect(screen.getByText('Tasks Failed')).toBeInTheDocument(); expect(screen.getByText(1)).toBeInTheDocument(); }); + + it('opens modal when FAILED state is clicked and has connector trace', () => { + const failedConnector = { + ...connector, + status: { + ...connector.status, + state: ConnectorState.FAILED, + trace: 'Test error trace', + }, + }; + + (useConnector as jest.Mock).mockImplementation(() => ({ + data: failedConnector, + })); + (useConnectorTasks as jest.Mock).mockImplementation(() => ({ + data: [], + })); + + render(); + + const stateTag = screen.getByText('FAILED'); + expect(stateTag).toBeInTheDocument(); + expect(stateTag).toHaveStyle('cursor: pointer'); + + fireEvent.click(stateTag); + + expect(screen.getByText('Connector Error Details')).toBeInTheDocument(); + expect(screen.getByText('Test error trace')).toBeInTheDocument(); + }); + + it('does not open modal when FAILED state is clicked but no trace info', () => { + const failedConnector = { + ...connector, + status: { + ...connector.status, + state: ConnectorState.FAILED, + // No trace info + }, + }; + + (useConnector as jest.Mock).mockImplementation(() => ({ + data: failedConnector, + })); + (useConnectorTasks as jest.Mock).mockImplementation(() => ({ + data: [], + })); + + render(); + + const stateTag = screen.getByText('FAILED'); + expect(stateTag).toBeInTheDocument(); + expect(stateTag).toHaveStyle('cursor: default'); + + fireEvent.click(stateTag); + + expect( + screen.queryByText('Connector Error Details') + ).not.toBeInTheDocument(); + }); + + it('closes modal when close button is clicked', () => { + const failedConnector = { + ...connector, + status: { + ...connector.status, + state: ConnectorState.FAILED, + trace: 'Test error trace', + }, + }; + + (useConnector as jest.Mock).mockImplementation(() => ({ + data: failedConnector, + })); + (useConnectorTasks as jest.Mock).mockImplementation(() => ({ + data: [], + })); + + render(); + + const stateTag = screen.getByText('FAILED'); + fireEvent.click(stateTag); + + expect(screen.getByText('Connector Error Details')).toBeInTheDocument(); + + const closeButton = screen.getByText('Close'); + fireEvent.click(closeButton); + + expect( + screen.queryByText('Connector Error Details') + ).not.toBeInTheDocument(); + }); }); });