Skip to content

Commit 1d8c619

Browse files
NeiruBugziliaxiliaxHaarolean
authored
FE: Expose cluster ACL list (#3662)
Co-authored-by: iliax <ikuramshin@provectus.com> Co-authored-by: Ilya Kuramshin <iliax@proton.me> Co-authored-by: Roman Zabaluev <rzabaluev@provectus.com>
1 parent 52a42e6 commit 1d8c619

File tree

18 files changed

+550
-14
lines changed

18 files changed

+550
-14
lines changed
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import React from 'react';
2+
import { Routes, Route } from 'react-router-dom';
3+
import ACList from 'components/ACLPage/List/List';
4+
5+
const ACLPage = () => {
6+
return (
7+
<Routes>
8+
<Route index element={<ACList />} />
9+
</Routes>
10+
);
11+
};
12+
13+
export default ACLPage;
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import styled from 'styled-components';
2+
3+
export const EnumCell = styled.div`
4+
text-transform: capitalize;
5+
`;
6+
7+
export const DeleteCell = styled.div`
8+
svg {
9+
cursor: pointer;
10+
}
11+
`;
12+
13+
export const Chip = styled.div<{
14+
chipType?: 'default' | 'success' | 'danger' | 'secondary' | string;
15+
}>`
16+
width: fit-content;
17+
text-transform: capitalize;
18+
padding: 2px 8px;
19+
font-size: 12px;
20+
line-height: 16px;
21+
border-radius: 16px;
22+
color: ${({ theme }) => theme.tag.color};
23+
background-color: ${({ theme, chipType }) => {
24+
switch (chipType) {
25+
case 'success':
26+
return theme.tag.backgroundColor.green;
27+
case 'danger':
28+
return theme.tag.backgroundColor.red;
29+
case 'secondary':
30+
return theme.tag.backgroundColor.secondary;
31+
default:
32+
return theme.tag.backgroundColor.gray;
33+
}
34+
}};
35+
`;
36+
37+
export const PatternCell = styled.div`
38+
display: flex;
39+
align-items: center;
40+
41+
${Chip} {
42+
margin-left: 4px;
43+
}
44+
`;
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import React from 'react';
2+
import { ColumnDef } from '@tanstack/react-table';
3+
import { useTheme } from 'styled-components';
4+
import PageHeading from 'components/common/PageHeading/PageHeading';
5+
import Table from 'components/common/NewTable';
6+
import DeleteIcon from 'components/common/Icons/DeleteIcon';
7+
import { useConfirm } from 'lib/hooks/useConfirm';
8+
import useAppParams from 'lib/hooks/useAppParams';
9+
import { useAcls, useDeleteAcl } from 'lib/hooks/api/acl';
10+
import { ClusterName } from 'redux/interfaces';
11+
import {
12+
KafkaAcl,
13+
KafkaAclNamePatternType,
14+
KafkaAclPermissionEnum,
15+
} from 'generated-sources';
16+
17+
import * as S from './List.styled';
18+
19+
const ACList: React.FC = () => {
20+
const { clusterName } = useAppParams<{ clusterName: ClusterName }>();
21+
const theme = useTheme();
22+
const { data: aclList } = useAcls(clusterName);
23+
const { deleteResource } = useDeleteAcl(clusterName);
24+
const modal = useConfirm(true);
25+
26+
const [rowId, setRowId] = React.useState('');
27+
28+
const onDeleteClick = (acl: KafkaAcl | null) => {
29+
if (acl) {
30+
modal('Are you sure want to delete this ACL record?', () =>
31+
deleteResource(acl)
32+
);
33+
}
34+
};
35+
36+
const columns = React.useMemo<ColumnDef<KafkaAcl>[]>(
37+
() => [
38+
{
39+
header: 'Principal',
40+
accessorKey: 'principal',
41+
size: 257,
42+
},
43+
{
44+
header: 'Resource',
45+
accessorKey: 'resourceType',
46+
// eslint-disable-next-line react/no-unstable-nested-components
47+
cell: ({ getValue }) => (
48+
<S.EnumCell>{getValue<string>().toLowerCase()}</S.EnumCell>
49+
),
50+
size: 145,
51+
},
52+
{
53+
header: 'Pattern',
54+
accessorKey: 'resourceName',
55+
// eslint-disable-next-line react/no-unstable-nested-components
56+
cell: ({ getValue, row }) => {
57+
let chipType;
58+
if (
59+
row.original.namePatternType === KafkaAclNamePatternType.PREFIXED
60+
) {
61+
chipType = 'default';
62+
}
63+
64+
if (
65+
row.original.namePatternType === KafkaAclNamePatternType.LITERAL
66+
) {
67+
chipType = 'secondary';
68+
}
69+
return (
70+
<S.PatternCell>
71+
{getValue<string>()}
72+
{chipType ? (
73+
<S.Chip chipType={chipType}>
74+
{row.original.namePatternType.toLowerCase()}
75+
</S.Chip>
76+
) : null}
77+
</S.PatternCell>
78+
);
79+
},
80+
size: 257,
81+
},
82+
{
83+
header: 'Host',
84+
accessorKey: 'host',
85+
size: 257,
86+
},
87+
{
88+
header: 'Operation',
89+
accessorKey: 'operation',
90+
// eslint-disable-next-line react/no-unstable-nested-components
91+
cell: ({ getValue }) => (
92+
<S.EnumCell>{getValue<string>().toLowerCase()}</S.EnumCell>
93+
),
94+
size: 121,
95+
},
96+
{
97+
header: 'Permission',
98+
accessorKey: 'permission',
99+
// eslint-disable-next-line react/no-unstable-nested-components
100+
cell: ({ getValue }) => (
101+
<S.Chip
102+
chipType={
103+
getValue<string>() === KafkaAclPermissionEnum.ALLOW
104+
? 'success'
105+
: 'danger'
106+
}
107+
>
108+
{getValue<string>().toLowerCase()}
109+
</S.Chip>
110+
),
111+
size: 111,
112+
},
113+
{
114+
id: 'delete',
115+
// eslint-disable-next-line react/no-unstable-nested-components
116+
cell: ({ row }) => {
117+
return (
118+
<S.DeleteCell onClick={() => onDeleteClick(row.original)}>
119+
<DeleteIcon
120+
fill={
121+
rowId === row.id ? theme.acl.table.deleteIcon : 'transparent'
122+
}
123+
/>
124+
</S.DeleteCell>
125+
);
126+
},
127+
size: 76,
128+
},
129+
],
130+
[rowId]
131+
);
132+
133+
const onRowHover = (value: unknown) => {
134+
if (value && typeof value === 'object' && 'id' in value) {
135+
setRowId(value.id as string);
136+
}
137+
};
138+
139+
return (
140+
<>
141+
<PageHeading text="Access Control List" />
142+
<Table
143+
columns={columns}
144+
data={aclList ?? []}
145+
emptyMessage="No ACL items found"
146+
onRowHover={onRowHover}
147+
onMouseLeave={() => setRowId('')}
148+
/>
149+
</>
150+
);
151+
};
152+
153+
export default ACList;
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import React from 'react';
2+
import { render, WithRoute } from 'lib/testHelpers';
3+
import { screen } from '@testing-library/dom';
4+
import userEvent from '@testing-library/user-event';
5+
import { clusterACLPath } from 'lib/paths';
6+
import ACList from 'components/ACLPage/List/List';
7+
import { useAcls, useDeleteAcl } from 'lib/hooks/api/acl';
8+
import { aclPayload } from 'lib/fixtures/acls';
9+
10+
jest.mock('lib/hooks/api/acl', () => ({
11+
useAcls: jest.fn(),
12+
useDeleteAcl: jest.fn(),
13+
}));
14+
15+
describe('ACLList Component', () => {
16+
const clusterName = 'local';
17+
const renderComponent = () =>
18+
render(
19+
<WithRoute path={clusterACLPath()}>
20+
<ACList />
21+
</WithRoute>,
22+
{
23+
initialEntries: [clusterACLPath(clusterName)],
24+
}
25+
);
26+
27+
describe('ACLList', () => {
28+
describe('when the acls are loaded', () => {
29+
beforeEach(() => {
30+
(useAcls as jest.Mock).mockImplementation(() => ({
31+
data: aclPayload,
32+
}));
33+
(useDeleteAcl as jest.Mock).mockImplementation(() => ({
34+
deleteResource: jest.fn(),
35+
}));
36+
});
37+
38+
it('renders ACLList with records', async () => {
39+
renderComponent();
40+
expect(screen.getByRole('table')).toBeInTheDocument();
41+
expect(screen.getAllByRole('row').length).toEqual(4);
42+
});
43+
44+
it('shows delete icon on hover', async () => {
45+
const { container } = renderComponent();
46+
const [trElement] = screen.getAllByRole('row');
47+
await userEvent.hover(trElement);
48+
const deleteElement = container.querySelector('svg');
49+
expect(deleteElement).not.toHaveStyle({
50+
fill: 'transparent',
51+
});
52+
});
53+
});
54+
55+
describe('when it has no acls', () => {
56+
beforeEach(() => {
57+
(useAcls as jest.Mock).mockImplementation(() => ({
58+
data: [],
59+
}));
60+
(useDeleteAcl as jest.Mock).mockImplementation(() => ({
61+
deleteResource: jest.fn(),
62+
}));
63+
});
64+
65+
it('renders empty ACLList with message', async () => {
66+
renderComponent();
67+
expect(screen.getByRole('table')).toBeInTheDocument();
68+
expect(
69+
screen.getByRole('row', { name: 'No ACL items found' })
70+
).toBeInTheDocument();
71+
});
72+
});
73+
});
74+
});

kafka-ui-react-app/src/components/ClusterPage/ClusterPage.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
clusterTopicsRelativePath,
1414
clusterConfigRelativePath,
1515
getNonExactPath,
16+
clusterAclRelativePath,
1617
} from 'lib/paths';
1718
import ClusterContext from 'components/contexts/ClusterContext';
1819
import PageLoader from 'components/common/PageLoader/PageLoader';
@@ -30,6 +31,7 @@ const ClusterConfigPage = React.lazy(
3031
const ConsumerGroups = React.lazy(
3132
() => import('components/ConsumerGroups/ConsumerGroups')
3233
);
34+
const AclPage = React.lazy(() => import('components/ACLPage/ACLPage'));
3335

3436
const ClusterPage: React.FC = () => {
3537
const { clusterName } = useAppParams<ClusterNameRoute>();
@@ -51,6 +53,9 @@ const ClusterPage: React.FC = () => {
5153
ClusterFeaturesEnum.TOPIC_DELETION
5254
),
5355
hasKsqlDbConfigured: features.includes(ClusterFeaturesEnum.KSQL_DB),
56+
hasAclViewConfigured:
57+
features.includes(ClusterFeaturesEnum.KAFKA_ACL_VIEW) ||
58+
features.includes(ClusterFeaturesEnum.KAFKA_ACL_EDIT),
5459
};
5560
}, [clusterName, data]);
5661

@@ -95,6 +100,12 @@ const ClusterPage: React.FC = () => {
95100
element={<KsqlDb />}
96101
/>
97102
)}
103+
{contextValue.hasAclViewConfigured && (
104+
<Route
105+
path={getNonExactPath(clusterAclRelativePath)}
106+
element={<AclPage />}
107+
/>
108+
)}
98109
{appInfo.hasDynamicConfig && (
99110
<Route
100111
path={getNonExactPath(clusterConfigRelativePath)}

kafka-ui-react-app/src/components/Nav/ClusterMenu.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
clusterSchemasPath,
88
clusterConnectorsPath,
99
clusterKsqlDbPath,
10+
clusterACLPath,
1011
} from 'lib/paths';
1112

1213
import ClusterMenuItem from './ClusterMenuItem';
@@ -57,6 +58,10 @@ const ClusterMenu: React.FC<Props> = ({
5758
{hasFeatureConfigured(ClusterFeaturesEnum.KSQL_DB) && (
5859
<ClusterMenuItem to={clusterKsqlDbPath(name)} title="KSQL DB" />
5960
)}
61+
{(hasFeatureConfigured(ClusterFeaturesEnum.KAFKA_ACL_VIEW) ||
62+
hasFeatureConfigured(ClusterFeaturesEnum.KAFKA_ACL_EDIT)) && (
63+
<ClusterMenuItem to={clusterACLPath(name)} title="ACL" />
64+
)}
6065
</S.List>
6166
)}
6267
</S.List>

kafka-ui-react-app/src/components/common/Button/Button.styled.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import styled from 'styled-components';
22

33
export interface ButtonProps {
4-
buttonType: 'primary' | 'secondary';
4+
buttonType: 'primary' | 'secondary' | 'danger';
55
buttonSize: 'S' | 'M' | 'L';
66
isInverted?: boolean;
77
}

kafka-ui-react-app/src/components/common/ConfirmationModal/ConfirmationModal.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ const ConfirmationModal: React.FC = () => {
2626
Cancel
2727
</Button>
2828
<Button
29-
buttonType="primary"
29+
buttonType={context.dangerButton ? 'danger' : 'primary'}
3030
buttonSize="M"
3131
onClick={context.confirm}
3232
type="button"

kafka-ui-react-app/src/components/common/Icons/DeleteIcon.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
import React from 'react';
22
import { useTheme } from 'styled-components';
33

4-
const DeleteIcon: React.FC = () => {
4+
const DeleteIcon: React.FC<{ fill?: string }> = ({ fill }) => {
55
const theme = useTheme();
6+
const curentFill = fill || theme.editFilter.deleteIconColor;
67
return (
78
<svg
89
xmlns="http://www.w3.org/2000/svg"
910
viewBox="0 0 448 512"
10-
fill={theme.editFilter.deleteIconColor}
11+
fill={curentFill}
1112
width="14"
1213
height="14"
1314
>

0 commit comments

Comments
 (0)