Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<TaskDTO> tasks = new ArrayList<>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,25 +14,22 @@ class KafkaConnectNgramFilterTest extends AbstractNgramFilterTest<FullConnectorI

@Override
protected NgramFilter<FullConnectorInfoDTO> buildFilter(List<FullConnectorInfoDTO> items,
boolean enabled,
ClustersProperties.NgramProperties ngramProperties) {
boolean enabled,
ClustersProperties.NgramProperties ngramProperties) {
return new KafkaConnectNgramFilter(items, enabled, ngramProperties);
}

@Override
protected List<FullConnectorInfoDTO> 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
Expand Down
1 change: 1 addition & 0 deletions contract-typespec/api/kafka-connect.tsp
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,7 @@ enum ConnectorState {
model ConnectorStatus {
state: ConnectorState;
workerId?: string;
trace?: string;
}

model Connector {
Expand Down
2 changes: 2 additions & 0 deletions contract/src/main/resources/swagger/kafbat-ui-api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3725,6 +3725,8 @@ components:
$ref: '#/components/schemas/ConnectorState'
workerId:
type: string
trace:
type: string
required:
- state

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import styled, { css } from 'styled-components';

export const ModalOverlay = styled.div`
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe you could create reusable component Modal in components/common ?
You are already have everything to do it and I think that there will be a lot of another cases where kafbat/ui will need Modal component.

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;
`
);
113 changes: 86 additions & 27 deletions frontend/src/components/Connect/Details/Overview/Overview.tsx
Original file line number Diff line number Diff line change
@@ -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<RouterParamsClusterConnectConnector>();
const [showTraceModal, setShowTraceModal] = useState(false);

const { data: connector } = useConnector(routerProps);
const { data: tasks } = useConnectorTasks(routerProps);
Expand All @@ -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) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like will be better to move this condition into separate helper. Something like

const canShowTrace = (connector: Connector) => connector.status.state === ConnectorState.FAILED && !!connector.status.trace;

Copy link
Contributor

@Leshe4ka Leshe4ka Oct 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like will be better to move this condition into separate helper. Something like

const canShowTrace = (connector: Connector) =>
  connector.status.state === ConnectorState.FAILED && !!connector.status.trace;

And use it here firstly

const handleStateClick = () => {
    if (canShowTrace(connector)) {
      setShowTraceModal(true);
    }
  };

setShowTraceModal(true);
}
};

return (
<Metrics.Wrapper>
<Metrics.Section>
{connector.status?.workerId && (
<Metrics.Indicator label="Worker">
{connector.status.workerId}
<>
<Metrics.Wrapper>
<Metrics.Section>
{connector.status?.workerId && (
<Metrics.Indicator label="Worker">
{connector.status.workerId}
</Metrics.Indicator>
)}
<Metrics.Indicator label="Type">{connector.type}</Metrics.Indicator>
{connector.config['connector.class'] && (
<Metrics.Indicator label="Class">
{connector.config['connector.class']}
</Metrics.Indicator>
)}
<Metrics.Indicator label="State">
<C.Tag
color={getTagColor(connector.status.state)}
style={{
cursor:
connector.status.state === ConnectorState.FAILED &&
hasTraceInfo
? 'pointer'
: 'default',
}}
Comment on lines +53 to +59
Copy link
Contributor

@Leshe4ka Leshe4ka Oct 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And here instead of inline style - create an optional prop clickable for C.Tag where you will manage cursor styles.

<C.Tag
    color={getTagColor(connector.status.state)}
    clickable={canShowTrace(connector)}
    onClick={handleStateClick}
  >
   {connector.status.state}
 </C.Tag>

onClick={handleStateClick}
>
{connector.status.state}
</C.Tag>
</Metrics.Indicator>
)}
<Metrics.Indicator label="Type">{connector.type}</Metrics.Indicator>
{connector.config['connector.class'] && (
<Metrics.Indicator label="Class">
{connector.config['connector.class']}
<Metrics.Indicator label="Tasks Running">{running}</Metrics.Indicator>
<Metrics.Indicator
label="Tasks Failed"
isAlert
alertType={failed > 0 ? 'error' : 'success'}
>
{failed}
</Metrics.Indicator>
)}
<Metrics.Indicator label="State">
<C.Tag color={getTagColor(connector.status.state)}>
{connector.status.state}
</C.Tag>
</Metrics.Indicator>
<Metrics.Indicator label="Tasks Running">{running}</Metrics.Indicator>
<Metrics.Indicator
label="Tasks Failed"
isAlert
alertType={failed > 0 ? 'error' : 'success'}
>
{failed}
</Metrics.Indicator>
</Metrics.Section>
</Metrics.Wrapper>
</Metrics.Section>
</Metrics.Wrapper>

{showTraceModal && (
<S.ModalOverlay onClick={() => setShowTraceModal(false)}>
<S.ModalContent
onClick={(e: React.MouseEvent) => e.stopPropagation()}
>
<S.ModalHeader>
<div>
<S.ModalTitle>Connector Error Details</S.ModalTitle>
{connector.status.workerId && (
<S.WorkerInfo>
Worker: {connector.status.workerId}
</S.WorkerInfo>
)}
</div>
</S.ModalHeader>

<S.TraceContent>
{connector.status.trace ? (
<div>{connector.status.trace}</div>
) : null}
</S.TraceContent>
Comment on lines +92 to +96
Copy link
Contributor

@Leshe4ka Leshe4ka Oct 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
<S.TraceContent>
{connector.status.trace ? (
<div>{connector.status.trace}</div>
) : null}
</S.TraceContent>
{connector.status.trace ? (
<S.TraceContent>
<div>{connector.status.trace}</div>
</S.TraceContent>
) : null}


<S.ModalFooter>
<Button
buttonType="primary"
buttonSize="M"
onClick={() => setShowTraceModal(false)}
>
Close
</Button>
</S.ModalFooter>
</S.ModalContent>
</S.ModalOverlay>
)}
</>
);
};

Expand Down
Original file line number Diff line number Diff line change
@@ -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(),
Expand Down Expand Up @@ -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(<Overview />);

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(<Overview />);

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(<Overview />);

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();
});
});
});
Loading