From 19776b2b500c7174c6b9daf59a8caebca68237ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmetcan=20G=C3=BCle=C5=9F=C3=A7i?= Date: Sun, 7 Sep 2025 11:15:27 +0300 Subject: [PATCH 1/4] feat(connector): show failure modal when connector (not only tasks) is FAILED --- .../ui/mapper/KafkaConnectMapperTest.java | 7 +- contract-typespec/api/kafka-connect.tsp | 1 + .../main/resources/swagger/kafbat-ui-api.yaml | 2 + .../Details/Overview/Overview.styled.tsx | 81 +++++++++++++ .../Connect/Details/Overview/Overview.tsx | 113 +++++++++++++----- .../Overview/__tests__/Overview.spec.tsx | 94 ++++++++++++++- 6 files changed, 269 insertions(+), 29 deletions(-) create mode 100644 frontend/src/components/Connect/Details/Overview/Overview.styled.tsx 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/contract-typespec/api/kafka-connect.tsp b/contract-typespec/api/kafka-connect.tsp index dc131c384..aa45b4aef 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 c8e6f66de..dd0dce0d8 100644 --- a/contract/src/main/resources/swagger/kafbat-ui-api.yaml +++ b/contract/src/main/resources/swagger/kafbat-ui-api.yaml @@ -3756,6 +3756,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(); + }); }); }); From c4db259c54058f0174c59135685b22c657ce8c11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmetcan=20G=C3=BCle=C5=9F=C3=A7i?= Date: Wed, 24 Sep 2025 12:17:38 +0300 Subject: [PATCH 2/4] fix(tests): update expectations after main branch changes --- .../index/KafkaConnectNgramFilterTest.java | 25 ++++++++----------- 1 file changed, 11 insertions(+), 14 deletions(-) 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 From 5e18797ebf92ceb7ef8b6a9da74d2bbbbe7de379 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmetcan=20G=C3=BCle=C5=9F=C3=A7i?= Date: Tue, 28 Oct 2025 10:58:44 +0300 Subject: [PATCH 3/4] refactor(overview): replace custom modal implementation with reusable Modal component and update trace display logic --- .../Details/Overview/Overview.styled.tsx | 56 ---------------- .../Connect/Details/Overview/Overview.tsx | 67 ++++++++----------- .../components/common/Modal/Modal.styled.tsx | 64 ++++++++++++++++++ .../src/components/common/Modal/Modal.tsx | 47 +++++++++++++ frontend/src/components/common/Modal/index.ts | 1 + .../src/components/common/Tag/Tag.styled.tsx | 2 + 6 files changed, 142 insertions(+), 95 deletions(-) create mode 100644 frontend/src/components/common/Modal/Modal.styled.tsx create mode 100644 frontend/src/components/common/Modal/Modal.tsx create mode 100644 frontend/src/components/common/Modal/index.ts diff --git a/frontend/src/components/Connect/Details/Overview/Overview.styled.tsx b/frontend/src/components/Connect/Details/Overview/Overview.styled.tsx index f61839e4c..a55f2a590 100644 --- a/frontend/src/components/Connect/Details/Overview/Overview.styled.tsx +++ b/frontend/src/components/Connect/Details/Overview/Overview.styled.tsx @@ -1,50 +1,5 @@ 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; @@ -68,14 +23,3 @@ export const TraceContent = styled.div( 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 aad545041..28b3a95df 100644 --- a/frontend/src/components/Connect/Details/Overview/Overview.tsx +++ b/frontend/src/components/Connect/Details/Overview/Overview.tsx @@ -2,11 +2,12 @@ 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 Modal from 'components/common/Modal'; 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 { ConnectorState, Connector } from 'generated-sources'; import getTaskMetrics from './getTaskMetrics'; import * as S from './Overview.styled'; @@ -24,10 +25,12 @@ const Overview: React.FC = () => { const { running, failed } = getTaskMetrics(tasks); - const hasTraceInfo = connector.status.trace; + const canShowTrace = (connector: Connector) => + connector.status.state === ConnectorState.FAILED && + !!connector.status.trace; const handleStateClick = () => { - if (connector.status.state === ConnectorState.FAILED && hasTraceInfo) { + if (canShowTrace(connector)) { setShowTraceModal(true); } }; @@ -50,13 +53,7 @@ const Overview: React.FC = () => { {connector.status.state} @@ -74,38 +71,30 @@ const Overview: React.FC = () => { {showTraceModal && ( - setShowTraceModal(false)}> - e.stopPropagation()} - > - -
- Connector Error Details - {connector.status.workerId && ( - - Worker: {connector.status.workerId} - - )} -
-
+ setShowTraceModal(false)} + title="Connector Error Details" + footer={ + + } + > + {connector.status.workerId && ( + Worker: {connector.status.workerId} + )} + {connector.status.trace && ( - {connector.status.trace ? ( -
{connector.status.trace}
- ) : null} +
{connector.status.trace}
- - - - -
-
+ )} + )} ); diff --git a/frontend/src/components/common/Modal/Modal.styled.tsx b/frontend/src/components/common/Modal/Modal.styled.tsx new file mode 100644 index 000000000..bbbdc88eb --- /dev/null +++ b/frontend/src/components/common/Modal/Modal.styled.tsx @@ -0,0 +1,64 @@ +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<{ + maxWidth: string; + maxHeight: string; +}>( + ({ theme: { modal }, maxWidth, maxHeight }) => css` + background-color: ${modal.backgroundColor}; + color: ${modal.color}; + border-radius: 8px; + padding: 24px; + max-width: ${maxWidth}; + max-height: ${maxHeight}; + 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 ModalBody = styled.div` + margin-bottom: 16px; +`; + +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/common/Modal/Modal.tsx b/frontend/src/components/common/Modal/Modal.tsx new file mode 100644 index 000000000..5b96b36ea --- /dev/null +++ b/frontend/src/components/common/Modal/Modal.tsx @@ -0,0 +1,47 @@ +import React from 'react'; + +import * as S from './Modal.styled'; + +interface ModalProps { + isOpen: boolean; + onClose: () => void; + title?: string; + children: React.ReactNode; + footer?: React.ReactNode; + maxWidth?: string; + maxHeight?: string; +} + +const Modal: React.FC = ({ + isOpen, + onClose, + title, + children, + footer, + maxWidth = '65vw', + maxHeight = '80vh', +}) => { + if (!isOpen) return null; + + return ( + + e.stopPropagation()} + maxWidth={maxWidth} + maxHeight={maxHeight} + > + {title && ( + + {title} + + )} + + {children} + + {footer && {footer}} + + + ); +}; + +export default Modal; diff --git a/frontend/src/components/common/Modal/index.ts b/frontend/src/components/common/Modal/index.ts new file mode 100644 index 000000000..0690fecf6 --- /dev/null +++ b/frontend/src/components/common/Modal/index.ts @@ -0,0 +1 @@ +export { default } from './Modal'; diff --git a/frontend/src/components/common/Tag/Tag.styled.tsx b/frontend/src/components/common/Tag/Tag.styled.tsx index ef4dfb11e..ecf3f768a 100644 --- a/frontend/src/components/common/Tag/Tag.styled.tsx +++ b/frontend/src/components/common/Tag/Tag.styled.tsx @@ -2,6 +2,7 @@ import styled from 'styled-components'; interface Props { color: 'green' | 'gray' | 'yellow' | 'red' | 'white' | 'blue'; + clickable?: boolean; } export const Tag = styled.span.attrs({ role: 'widget' })` @@ -18,6 +19,7 @@ export const Tag = styled.span.attrs({ role: 'widget' })` text-align: center; width: max-content; margin: 2px 0; + cursor: ${({ clickable }) => (clickable ? 'pointer' : 'default')}; `; export const MultiLineTag = styled.div.attrs({ role: 'widget' })` From e732c849148febd47dd5940538d01b1ee98cbac5 Mon Sep 17 00:00:00 2001 From: mehmetcangulesci Date: Tue, 28 Oct 2025 11:09:17 +0300 Subject: [PATCH 4/4] refactor(overview): update Modal import and enhance trace display logic for connector status --- .../src/components/Connect/Details/Overview/Overview.tsx | 8 ++++---- frontend/src/components/common/Modal/index.ts | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/Connect/Details/Overview/Overview.tsx b/frontend/src/components/Connect/Details/Overview/Overview.tsx index 28b3a95df..b0c5b12be 100644 --- a/frontend/src/components/Connect/Details/Overview/Overview.tsx +++ b/frontend/src/components/Connect/Details/Overview/Overview.tsx @@ -2,7 +2,7 @@ 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 Modal from 'components/common/Modal'; +import { Modal } from 'components/common/Modal'; import getTagColor from 'components/common/Tag/getTagColor'; import { RouterParamsClusterConnectConnector } from 'lib/paths'; import useAppParams from 'lib/hooks/useAppParams'; @@ -25,9 +25,9 @@ const Overview: React.FC = () => { const { running, failed } = getTaskMetrics(tasks); - const canShowTrace = (connector: Connector) => - connector.status.state === ConnectorState.FAILED && - !!connector.status.trace; + const canShowTrace = (connectorData: Connector) => + connectorData.status.state === ConnectorState.FAILED && + !!connectorData.status.trace; const handleStateClick = () => { if (canShowTrace(connector)) { diff --git a/frontend/src/components/common/Modal/index.ts b/frontend/src/components/common/Modal/index.ts index 0690fecf6..c6b35681c 100644 --- a/frontend/src/components/common/Modal/index.ts +++ b/frontend/src/components/common/Modal/index.ts @@ -1 +1 @@ -export { default } from './Modal'; +export { default as Modal } from './Modal';