[Experimental] New Messages layout (#2462)
This commit is contained in:
parent
93852b2600
commit
d9e74deb28
61 changed files with 1740 additions and 276 deletions
|
@ -9,6 +9,7 @@
|
||||||
"@babel/plugin-transform-react-jsx": "^7.18.6",
|
"@babel/plugin-transform-react-jsx": "^7.18.6",
|
||||||
"@hookform/error-message": "^2.0.0",
|
"@hookform/error-message": "^2.0.0",
|
||||||
"@hookform/resolvers": "^2.7.1",
|
"@hookform/resolvers": "^2.7.1",
|
||||||
|
"@microsoft/fetch-event-source": "^2.0.1",
|
||||||
"@reduxjs/toolkit": "^1.8.3",
|
"@reduxjs/toolkit": "^1.8.3",
|
||||||
"@szhsin/react-menu": "^3.1.1",
|
"@szhsin/react-menu": "^3.1.1",
|
||||||
"@tanstack/react-query": "^4.0.5",
|
"@tanstack/react-query": "^4.0.5",
|
||||||
|
@ -36,7 +37,7 @@
|
||||||
"react-hook-form": "7.6.9",
|
"react-hook-form": "7.6.9",
|
||||||
"react-hot-toast": "^2.3.0",
|
"react-hot-toast": "^2.3.0",
|
||||||
"react-is": "^18.2.0",
|
"react-is": "^18.2.0",
|
||||||
"react-multi-select-component": "^4.0.6",
|
"react-multi-select-component": "^4.3.3",
|
||||||
"react-redux": "^8.0.2",
|
"react-redux": "^8.0.2",
|
||||||
"react-router-dom": "^6.3.0",
|
"react-router-dom": "^6.3.0",
|
||||||
"redux": "^4.2.0",
|
"redux": "^4.2.0",
|
||||||
|
@ -46,7 +47,8 @@
|
||||||
"vite": "^3.0.2",
|
"vite": "^3.0.2",
|
||||||
"vite-tsconfig-paths": "^3.5.0",
|
"vite-tsconfig-paths": "^3.5.0",
|
||||||
"whatwg-fetch": "^3.6.2",
|
"whatwg-fetch": "^3.6.2",
|
||||||
"yup": "^0.32.9"
|
"yup": "^0.32.9",
|
||||||
|
"zustand": "^4.1.1"
|
||||||
},
|
},
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
"*.{ts,tsx}": [
|
"*.{ts,tsx}": [
|
||||||
|
|
36
kafka-ui-react-app/pnpm-lock.yaml
generated
36
kafka-ui-react-app/pnpm-lock.yaml
generated
|
@ -10,6 +10,7 @@ specifiers:
|
||||||
'@hookform/error-message': ^2.0.0
|
'@hookform/error-message': ^2.0.0
|
||||||
'@hookform/resolvers': ^2.7.1
|
'@hookform/resolvers': ^2.7.1
|
||||||
'@jest/types': ^29.0.3
|
'@jest/types': ^29.0.3
|
||||||
|
'@microsoft/fetch-event-source': ^2.0.1
|
||||||
'@openapitools/openapi-generator-cli': ^2.5.1
|
'@openapitools/openapi-generator-cli': ^2.5.1
|
||||||
'@reduxjs/toolkit': ^1.8.3
|
'@reduxjs/toolkit': ^1.8.3
|
||||||
'@szhsin/react-menu': ^3.1.1
|
'@szhsin/react-menu': ^3.1.1
|
||||||
|
@ -72,7 +73,7 @@ specifiers:
|
||||||
react-hook-form: 7.6.9
|
react-hook-form: 7.6.9
|
||||||
react-hot-toast: ^2.3.0
|
react-hot-toast: ^2.3.0
|
||||||
react-is: ^18.2.0
|
react-is: ^18.2.0
|
||||||
react-multi-select-component: ^4.0.6
|
react-multi-select-component: ^4.3.3
|
||||||
react-redux: ^8.0.2
|
react-redux: ^8.0.2
|
||||||
react-router-dom: ^6.3.0
|
react-router-dom: ^6.3.0
|
||||||
redux: ^4.2.0
|
redux: ^4.2.0
|
||||||
|
@ -88,6 +89,7 @@ specifiers:
|
||||||
vite-tsconfig-paths: ^3.5.0
|
vite-tsconfig-paths: ^3.5.0
|
||||||
whatwg-fetch: ^3.6.2
|
whatwg-fetch: ^3.6.2
|
||||||
yup: ^0.32.9
|
yup: ^0.32.9
|
||||||
|
zustand: ^4.1.1
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/core': 7.18.2
|
'@babel/core': 7.18.2
|
||||||
|
@ -95,6 +97,7 @@ dependencies:
|
||||||
'@babel/plugin-transform-react-jsx': 7.18.6_@babel+core@7.18.2
|
'@babel/plugin-transform-react-jsx': 7.18.6_@babel+core@7.18.2
|
||||||
'@hookform/error-message': 2.0.0_l2dcsysovzdujulgxvsen7vbsm
|
'@hookform/error-message': 2.0.0_l2dcsysovzdujulgxvsen7vbsm
|
||||||
'@hookform/resolvers': 2.8.9_react-hook-form@7.6.9
|
'@hookform/resolvers': 2.8.9_react-hook-form@7.6.9
|
||||||
|
'@microsoft/fetch-event-source': 2.0.1
|
||||||
'@reduxjs/toolkit': 1.8.3_ctm756ikdwcjcvyfxxwskzbr6q
|
'@reduxjs/toolkit': 1.8.3_ctm756ikdwcjcvyfxxwskzbr6q
|
||||||
'@szhsin/react-menu': 3.1.1_ef5jwxihqo6n7gxfmzogljlgcm
|
'@szhsin/react-menu': 3.1.1_ef5jwxihqo6n7gxfmzogljlgcm
|
||||||
'@tanstack/react-query': 4.0.5_ef5jwxihqo6n7gxfmzogljlgcm
|
'@tanstack/react-query': 4.0.5_ef5jwxihqo6n7gxfmzogljlgcm
|
||||||
|
@ -122,7 +125,7 @@ dependencies:
|
||||||
react-hook-form: 7.6.9_react@18.1.0
|
react-hook-form: 7.6.9_react@18.1.0
|
||||||
react-hot-toast: 2.3.0_ef5jwxihqo6n7gxfmzogljlgcm
|
react-hot-toast: 2.3.0_ef5jwxihqo6n7gxfmzogljlgcm
|
||||||
react-is: 18.2.0
|
react-is: 18.2.0
|
||||||
react-multi-select-component: 4.0.6_react@18.1.0
|
react-multi-select-component: 4.3.3_ef5jwxihqo6n7gxfmzogljlgcm
|
||||||
react-redux: 8.0.2_nfqigfgwurfoimtkde74cji6ga
|
react-redux: 8.0.2_nfqigfgwurfoimtkde74cji6ga
|
||||||
react-router-dom: 6.3.0_ef5jwxihqo6n7gxfmzogljlgcm
|
react-router-dom: 6.3.0_ef5jwxihqo6n7gxfmzogljlgcm
|
||||||
redux: 4.2.0
|
redux: 4.2.0
|
||||||
|
@ -133,6 +136,7 @@ dependencies:
|
||||||
vite-tsconfig-paths: 3.5.0_vite@3.0.2
|
vite-tsconfig-paths: 3.5.0_vite@3.0.2
|
||||||
whatwg-fetch: 3.6.2
|
whatwg-fetch: 3.6.2
|
||||||
yup: 0.32.11
|
yup: 0.32.11
|
||||||
|
zustand: 4.1.1_react@18.1.0
|
||||||
|
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@babel/preset-env': 7.18.2_@babel+core@7.18.2
|
'@babel/preset-env': 7.18.2_@babel+core@7.18.2
|
||||||
|
@ -3059,6 +3063,10 @@ packages:
|
||||||
'@jridgewell/resolve-uri': 3.0.7
|
'@jridgewell/resolve-uri': 3.0.7
|
||||||
'@jridgewell/sourcemap-codec': 1.4.13
|
'@jridgewell/sourcemap-codec': 1.4.13
|
||||||
|
|
||||||
|
/@microsoft/fetch-event-source/2.0.1:
|
||||||
|
resolution: {integrity: sha512-W6CLUJ2eBMw3Rec70qrsEW0jOm/3twwJv21mrmj2yORiaVmVYGS4sSS5yUwvQc1ZlDLYGPnClVWmUUMagKNsfA==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/@nestjs/common/8.4.4_47vcjb2de6lyibr6g4enoa5lyu:
|
/@nestjs/common/8.4.4_47vcjb2de6lyibr6g4enoa5lyu:
|
||||||
resolution: {integrity: sha512-QHi7QcgH/5Jinz+SCfIZJkFHc6Cch1YsAEGFEhi6wSp6MILb0sJMQ1CX06e9tCOAjSlBwaJj4PH0eFCVau5v9Q==}
|
resolution: {integrity: sha512-QHi7QcgH/5Jinz+SCfIZJkFHc6Cch1YsAEGFEhi6wSp6MILb0sJMQ1CX06e9tCOAjSlBwaJj4PH0eFCVau5v9Q==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
|
@ -7659,12 +7667,14 @@ packages:
|
||||||
/react-is/18.2.0:
|
/react-is/18.2.0:
|
||||||
resolution: {integrity: sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==}
|
resolution: {integrity: sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==}
|
||||||
|
|
||||||
/react-multi-select-component/4.0.6_react@18.1.0:
|
/react-multi-select-component/4.3.3_ef5jwxihqo6n7gxfmzogljlgcm:
|
||||||
resolution: {integrity: sha512-cNpDv8vh1kWkJiMsa097tTUqWLVTQn+La4aXlgoGOQVpOSH9u1fbj1+MsvnLQjTBySuDx+pzm/DpbIoma/i1Fw==}
|
resolution: {integrity: sha512-V8cDJC3M7F27PWv1baV8FpJReHa/SbpJGL80CmXwnlMkDK2KMlQSRDmDzBnmCjcbROIgoztdW+gYBpqo9BIF4g==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: '>=17'
|
react: ^16 || ^17 || ^18
|
||||||
|
react-dom: ^16 || ^17 || ^18
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 18.1.0
|
react: 18.1.0
|
||||||
|
react-dom: 18.1.0_react@18.1.0
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
/react-onclickoutside/6.12.1_ef5jwxihqo6n7gxfmzogljlgcm:
|
/react-onclickoutside/6.12.1_ef5jwxihqo6n7gxfmzogljlgcm:
|
||||||
|
@ -8934,3 +8944,19 @@ packages:
|
||||||
property-expr: 2.0.4
|
property-expr: 2.0.4
|
||||||
toposort: 2.0.2
|
toposort: 2.0.2
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/zustand/4.1.1_react@18.1.0:
|
||||||
|
resolution: {integrity: sha512-h4F3WMqsZgvvaE0n3lThx4MM81Ls9xebjvrABNzf5+jb3/03YjNTSgZXeyrvXDArMeV9untvWXRw1tY+ntPYbA==}
|
||||||
|
engines: {node: '>=12.7.0'}
|
||||||
|
peerDependencies:
|
||||||
|
immer: '>=9.0'
|
||||||
|
react: '>=16.8'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
immer:
|
||||||
|
optional: true
|
||||||
|
react:
|
||||||
|
optional: true
|
||||||
|
dependencies:
|
||||||
|
react: 18.1.0
|
||||||
|
use-sync-external-store: 1.2.0_react@18.1.0
|
||||||
|
dev: false
|
||||||
|
|
|
@ -68,7 +68,7 @@ const Configs: React.FC = () => {
|
||||||
<>
|
<>
|
||||||
<S.SearchWrapper>
|
<S.SearchWrapper>
|
||||||
<Search
|
<Search
|
||||||
handleSearch={setKeyword}
|
onChange={setKeyword}
|
||||||
placeholder="Search by Key"
|
placeholder="Search by Key"
|
||||||
value={keyword}
|
value={keyword}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -7,7 +7,6 @@ import * as Metrics from 'components/common/Metrics';
|
||||||
import PageHeading from 'components/common/PageHeading/PageHeading';
|
import PageHeading from 'components/common/PageHeading/PageHeading';
|
||||||
import { Button } from 'components/common/Button/Button';
|
import { Button } from 'components/common/Button/Button';
|
||||||
import { ControlPanelWrapper } from 'components/common/ControlPanel/ControlPanel.styled';
|
import { ControlPanelWrapper } from 'components/common/ControlPanel/ControlPanel.styled';
|
||||||
import useSearch from 'lib/hooks/useSearch';
|
|
||||||
import PageLoader from 'components/common/PageLoader/PageLoader';
|
import PageLoader from 'components/common/PageLoader/PageLoader';
|
||||||
import { ConnectorState } from 'generated-sources';
|
import { ConnectorState } from 'generated-sources';
|
||||||
import { useConnectors } from 'lib/hooks/api/kafkaConnect';
|
import { useConnectors } from 'lib/hooks/api/kafkaConnect';
|
||||||
|
@ -17,7 +16,6 @@ import List from './List';
|
||||||
const ListPage: React.FC = () => {
|
const ListPage: React.FC = () => {
|
||||||
const { isReadOnly } = React.useContext(ClusterContext);
|
const { isReadOnly } = React.useContext(ClusterContext);
|
||||||
const { clusterName } = useAppParams<ClusterNameRoute>();
|
const { clusterName } = useAppParams<ClusterNameRoute>();
|
||||||
const [search, handleSearch] = useSearch();
|
|
||||||
|
|
||||||
// Fetches all connectors from the API, without search criteria. Used to display general metrics.
|
// Fetches all connectors from the API, without search criteria. Used to display general metrics.
|
||||||
const { data: connectorsMetrics, isLoading } = useConnectors(clusterName);
|
const { data: connectorsMetrics, isLoading } = useConnectors(clusterName);
|
||||||
|
@ -70,11 +68,7 @@ const ListPage: React.FC = () => {
|
||||||
</Metrics.Section>
|
</Metrics.Section>
|
||||||
</Metrics.Wrapper>
|
</Metrics.Wrapper>
|
||||||
<ControlPanelWrapper hasInput>
|
<ControlPanelWrapper hasInput>
|
||||||
<Search
|
<Search placeholder="Search by Connect Name, Status or Type" />
|
||||||
handleSearch={handleSearch}
|
|
||||||
placeholder="Search by Connect Name, Status or Type"
|
|
||||||
value={search}
|
|
||||||
/>
|
|
||||||
</ControlPanelWrapper>
|
</ControlPanelWrapper>
|
||||||
<Suspense fallback={<PageLoader />}>
|
<Suspense fallback={<PageLoader />}>
|
||||||
<List />
|
<List />
|
||||||
|
|
|
@ -8,8 +8,7 @@ import {
|
||||||
useFieldArray,
|
useFieldArray,
|
||||||
useForm,
|
useForm,
|
||||||
} from 'react-hook-form';
|
} from 'react-hook-form';
|
||||||
import MultiSelect from 'react-multi-select-component';
|
import { MultiSelect, Option } from 'react-multi-select-component';
|
||||||
import { Option } from 'react-multi-select-component/dist/lib/interfaces';
|
|
||||||
import DatePicker from 'react-datepicker';
|
import DatePicker from 'react-datepicker';
|
||||||
import 'react-datepicker/dist/react-datepicker.css';
|
import 'react-datepicker/dist/react-datepicker.css';
|
||||||
import groupBy from 'lodash/groupBy';
|
import groupBy from 'lodash/groupBy';
|
||||||
|
|
|
@ -7,7 +7,6 @@ import {
|
||||||
ConsumerGroupOrdering,
|
ConsumerGroupOrdering,
|
||||||
SortOrder,
|
SortOrder,
|
||||||
} from 'generated-sources';
|
} from 'generated-sources';
|
||||||
import useSearch from 'lib/hooks/useSearch';
|
|
||||||
import { useAppDispatch } from 'lib/hooks/redux';
|
import { useAppDispatch } from 'lib/hooks/redux';
|
||||||
import useAppParams from 'lib/hooks/useAppParams';
|
import useAppParams from 'lib/hooks/useAppParams';
|
||||||
import { clusterConsumerGroupDetailsPath, ClusterNameRoute } from 'lib/paths';
|
import { clusterConsumerGroupDetailsPath, ClusterNameRoute } from 'lib/paths';
|
||||||
|
@ -23,7 +22,6 @@ export interface Props {
|
||||||
}
|
}
|
||||||
|
|
||||||
const List: React.FC<Props> = ({ consumerGroups, totalPages }) => {
|
const List: React.FC<Props> = ({ consumerGroups, totalPages }) => {
|
||||||
const [searchText, handleSearchText] = useSearch();
|
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const { clusterName } = useAppParams<ClusterNameRoute>();
|
const { clusterName } = useAppParams<ClusterNameRoute>();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
|
@ -40,10 +38,10 @@ const List: React.FC<Props> = ({ consumerGroups, totalPages }) => {
|
||||||
undefined,
|
undefined,
|
||||||
page: Number(searchParams.get('page') || 1),
|
page: Number(searchParams.get('page') || 1),
|
||||||
perPage: Number(searchParams.get('perPage') || PER_PAGE),
|
perPage: Number(searchParams.get('perPage') || PER_PAGE),
|
||||||
search: searchText,
|
search: searchParams.get('q') || '',
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}, [clusterName, searchText, dispatch, searchParams]);
|
}, [clusterName, dispatch, searchParams]);
|
||||||
|
|
||||||
const columns = React.useMemo<ColumnDef<ConsumerGroupDetails>[]>(
|
const columns = React.useMemo<ColumnDef<ConsumerGroupDetails>[]>(
|
||||||
() => [
|
() => [
|
||||||
|
@ -87,11 +85,7 @@ const List: React.FC<Props> = ({ consumerGroups, totalPages }) => {
|
||||||
<>
|
<>
|
||||||
<PageHeading text="Consumers" />
|
<PageHeading text="Consumers" />
|
||||||
<ControlPanelWrapper hasInput>
|
<ControlPanelWrapper hasInput>
|
||||||
<Search
|
<Search placeholder="Search by Consumer Group ID" />
|
||||||
placeholder="Search by Consumer Group ID"
|
|
||||||
value={searchText}
|
|
||||||
handleSearch={handleSearchText}
|
|
||||||
/>
|
|
||||||
</ControlPanelWrapper>
|
</ControlPanelWrapper>
|
||||||
<Table
|
<Table
|
||||||
columns={columns}
|
columns={columns}
|
||||||
|
|
|
@ -19,7 +19,6 @@ import PageLoader from 'components/common/PageLoader/PageLoader';
|
||||||
import { resetLoaderById } from 'redux/reducers/loader/loaderSlice';
|
import { resetLoaderById } from 'redux/reducers/loader/loaderSlice';
|
||||||
import { ControlPanelWrapper } from 'components/common/ControlPanel/ControlPanel.styled';
|
import { ControlPanelWrapper } from 'components/common/ControlPanel/ControlPanel.styled';
|
||||||
import Search from 'components/common/Search/Search';
|
import Search from 'components/common/Search/Search';
|
||||||
import useSearch from 'lib/hooks/useSearch';
|
|
||||||
import PlusIcon from 'components/common/Icons/PlusIcon';
|
import PlusIcon from 'components/common/Icons/PlusIcon';
|
||||||
import Table, { LinkCell } from 'components/common/NewTable';
|
import Table, { LinkCell } from 'components/common/NewTable';
|
||||||
import { ColumnDef } from '@tanstack/react-table';
|
import { ColumnDef } from '@tanstack/react-table';
|
||||||
|
@ -38,7 +37,6 @@ const List: React.FC = () => {
|
||||||
const isFetched = useAppSelector(getAreSchemasFulfilled);
|
const isFetched = useAppSelector(getAreSchemasFulfilled);
|
||||||
const totalPages = useAppSelector((state) => state.schemas.totalPages);
|
const totalPages = useAppSelector((state) => state.schemas.totalPages);
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const [searchText, handleSearchText] = useSearch();
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
dispatch(
|
dispatch(
|
||||||
|
@ -82,11 +80,7 @@ const List: React.FC = () => {
|
||||||
)}
|
)}
|
||||||
</PageHeading>
|
</PageHeading>
|
||||||
<ControlPanelWrapper hasInput>
|
<ControlPanelWrapper hasInput>
|
||||||
<Search
|
<Search placeholder="Search by Schema Name" />
|
||||||
placeholder="Search by Schema Name"
|
|
||||||
value={searchText}
|
|
||||||
handleSearch={handleSearchText}
|
|
||||||
/>
|
|
||||||
</ControlPanelWrapper>
|
</ControlPanelWrapper>
|
||||||
{isFetched ? (
|
{isFetched ? (
|
||||||
<Table
|
<Table
|
||||||
|
|
|
@ -9,13 +9,11 @@ import PageHeading from 'components/common/PageHeading/PageHeading';
|
||||||
import { ControlPanelWrapper } from 'components/common/ControlPanel/ControlPanel.styled';
|
import { ControlPanelWrapper } from 'components/common/ControlPanel/ControlPanel.styled';
|
||||||
import Switch from 'components/common/Switch/Switch';
|
import Switch from 'components/common/Switch/Switch';
|
||||||
import PlusIcon from 'components/common/Icons/PlusIcon';
|
import PlusIcon from 'components/common/Icons/PlusIcon';
|
||||||
import useSearch from 'lib/hooks/useSearch';
|
|
||||||
import PageLoader from 'components/common/PageLoader/PageLoader';
|
import PageLoader from 'components/common/PageLoader/PageLoader';
|
||||||
import TopicTable from 'components/Topics/List/TopicTable';
|
import TopicTable from 'components/Topics/List/TopicTable';
|
||||||
|
|
||||||
const ListPage: React.FC = () => {
|
const ListPage: React.FC = () => {
|
||||||
const { isReadOnly } = React.useContext(ClusterContext);
|
const { isReadOnly } = React.useContext(ClusterContext);
|
||||||
const [searchQuery, handleSearchQuery] = useSearch();
|
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
|
||||||
// Set the search params to the url based on the localStorage value
|
// Set the search params to the url based on the localStorage value
|
||||||
|
@ -59,11 +57,7 @@ const ListPage: React.FC = () => {
|
||||||
)}
|
)}
|
||||||
</PageHeading>
|
</PageHeading>
|
||||||
<ControlPanelWrapper hasInput>
|
<ControlPanelWrapper hasInput>
|
||||||
<Search
|
<Search placeholder="Search by Topic Name" />
|
||||||
handleSearch={handleSearchQuery}
|
|
||||||
placeholder="Search by Topic Name"
|
|
||||||
value={searchQuery}
|
|
||||||
/>
|
|
||||||
<label>
|
<label>
|
||||||
<Switch
|
<Switch
|
||||||
name="ShowInternalTopics"
|
name="ShowInternalTopics"
|
||||||
|
|
|
@ -5,11 +5,11 @@ import DangerZone, {
|
||||||
import { act, screen, waitFor, within } from '@testing-library/react';
|
import { act, screen, waitFor, within } from '@testing-library/react';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { render, WithRoute } from 'lib/testHelpers';
|
import { render, WithRoute } from 'lib/testHelpers';
|
||||||
import { clusterTopicSendMessagePath } from 'lib/paths';
|
|
||||||
import {
|
import {
|
||||||
useIncreaseTopicPartitionsCount,
|
useIncreaseTopicPartitionsCount,
|
||||||
useUpdateTopicReplicationFactor,
|
useUpdateTopicReplicationFactor,
|
||||||
} from 'lib/hooks/api/topics';
|
} from 'lib/hooks/api/topics';
|
||||||
|
import { clusterTopicPath } from 'lib/paths';
|
||||||
|
|
||||||
const defaultPartitions = 3;
|
const defaultPartitions = 3;
|
||||||
const defaultReplicationFactor = 3;
|
const defaultReplicationFactor = 3;
|
||||||
|
@ -24,14 +24,14 @@ jest.mock('lib/hooks/api/topics', () => ({
|
||||||
|
|
||||||
const renderComponent = (props?: Partial<DangerZoneProps>) =>
|
const renderComponent = (props?: Partial<DangerZoneProps>) =>
|
||||||
render(
|
render(
|
||||||
<WithRoute path={clusterTopicSendMessagePath()}>
|
<WithRoute path={clusterTopicPath()}>
|
||||||
<DangerZone
|
<DangerZone
|
||||||
defaultPartitions={defaultPartitions}
|
defaultPartitions={defaultPartitions}
|
||||||
defaultReplicationFactor={defaultReplicationFactor}
|
defaultReplicationFactor={defaultReplicationFactor}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
</WithRoute>,
|
</WithRoute>,
|
||||||
{ initialEntries: [clusterTopicSendMessagePath(clusterName, topicName)] }
|
{ initialEntries: [clusterTopicPath(clusterName, topicName)] }
|
||||||
);
|
);
|
||||||
|
|
||||||
const clickOnDialogSubmitButton = () => {
|
const clickOnDialogSubmitButton = () => {
|
||||||
|
|
|
@ -5,7 +5,7 @@ import { FilterEdit } from 'components/Topics/Topic/Messages/Filters/FilterModal
|
||||||
import SavedFilters from 'components/Topics/Topic/Messages/Filters/SavedFilters';
|
import SavedFilters from 'components/Topics/Topic/Messages/Filters/SavedFilters';
|
||||||
import SavedIcon from 'components/common/Icons/SavedIcon';
|
import SavedIcon from 'components/common/Icons/SavedIcon';
|
||||||
import QuestionIcon from 'components/common/Icons/QuestionIcon';
|
import QuestionIcon from 'components/common/Icons/QuestionIcon';
|
||||||
import useModal from 'lib/hooks/useModal';
|
import useBoolean from 'lib/hooks/useBoolean';
|
||||||
|
|
||||||
import AddEditFilterContainer from './AddEditFilterContainer';
|
import AddEditFilterContainer from './AddEditFilterContainer';
|
||||||
import InfoModal from './InfoModal';
|
import InfoModal from './InfoModal';
|
||||||
|
@ -39,7 +39,7 @@ const AddFilter: React.FC<FilterModalProps> = ({
|
||||||
onClickSavedFilters,
|
onClickSavedFilters,
|
||||||
activeFilter,
|
activeFilter,
|
||||||
}) => {
|
}) => {
|
||||||
const { isOpen, toggle } = useModal();
|
const { value: isOpen, toggle } = useBoolean();
|
||||||
|
|
||||||
const onSubmit = React.useCallback(
|
const onSubmit = React.useCallback(
|
||||||
async (values: AddMessageFilters) => {
|
async (values: AddMessageFilters) => {
|
||||||
|
|
|
@ -14,7 +14,7 @@ import React, { useContext } from 'react';
|
||||||
import omitBy from 'lodash/omitBy';
|
import omitBy from 'lodash/omitBy';
|
||||||
import { useNavigate, useLocation } from 'react-router-dom';
|
import { useNavigate, useLocation } from 'react-router-dom';
|
||||||
import MultiSelect from 'components/common/MultiSelect/MultiSelect.styled';
|
import MultiSelect from 'components/common/MultiSelect/MultiSelect.styled';
|
||||||
import { Option } from 'react-multi-select-component/dist/lib/interfaces';
|
import { Option } from 'react-multi-select-component';
|
||||||
import BytesFormatted from 'components/common/BytesFormatted/BytesFormatted';
|
import BytesFormatted from 'components/common/BytesFormatted/BytesFormatted';
|
||||||
import { BASE_PARAMS } from 'lib/constants';
|
import { BASE_PARAMS } from 'lib/constants';
|
||||||
import Select from 'components/common/Select/Select';
|
import Select from 'components/common/Select/Select';
|
||||||
|
@ -25,7 +25,7 @@ import FilterModal, {
|
||||||
} from 'components/Topics/Topic/Messages/Filters/FilterModal';
|
} from 'components/Topics/Topic/Messages/Filters/FilterModal';
|
||||||
import { SeekDirectionOptions } from 'components/Topics/Topic/Messages/Messages';
|
import { SeekDirectionOptions } from 'components/Topics/Topic/Messages/Messages';
|
||||||
import TopicMessagesContext from 'components/contexts/TopicMessagesContext';
|
import TopicMessagesContext from 'components/contexts/TopicMessagesContext';
|
||||||
import useModal from 'lib/hooks/useModal';
|
import useBoolean from 'lib/hooks/useBoolean';
|
||||||
import { RouteParamsClusterTopic } from 'lib/paths';
|
import { RouteParamsClusterTopic } from 'lib/paths';
|
||||||
import useAppParams from 'lib/hooks/useAppParams';
|
import useAppParams from 'lib/hooks/useAppParams';
|
||||||
import PlusIcon from 'components/common/Icons/PlusIcon';
|
import PlusIcon from 'components/common/Icons/PlusIcon';
|
||||||
|
@ -95,7 +95,7 @@ const Filters: React.FC<FiltersProps> = ({
|
||||||
const { searchParams, seekDirection, isLive, changeSeekDirection } =
|
const { searchParams, seekDirection, isLive, changeSeekDirection } =
|
||||||
useContext(TopicMessagesContext);
|
useContext(TopicMessagesContext);
|
||||||
|
|
||||||
const { isOpen, toggle } = useModal();
|
const { value: isOpen, toggle } = useBoolean();
|
||||||
|
|
||||||
const source = React.useRef<EventSource | null>(null);
|
const source = React.useRef<EventSource | null>(null);
|
||||||
|
|
||||||
|
@ -393,12 +393,7 @@ const Filters: React.FC<FiltersProps> = ({
|
||||||
<S.FiltersWrapper>
|
<S.FiltersWrapper>
|
||||||
<div>
|
<div>
|
||||||
<S.FilterInputs>
|
<S.FilterInputs>
|
||||||
<Search
|
<Search placeholder="Search" disabled={isTailing} />
|
||||||
placeholder="Search"
|
|
||||||
value={query}
|
|
||||||
disabled={isTailing}
|
|
||||||
handleSearch={(value: string) => setQuery(value)}
|
|
||||||
/>
|
|
||||||
<S.SeekTypeSelectorWrapper>
|
<S.SeekTypeSelectorWrapper>
|
||||||
<S.SeekTypeSelect
|
<S.SeekTypeSelect
|
||||||
id="selectSeekType"
|
id="selectSeekType"
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { Partition, SeekType } from 'generated-sources';
|
import { Partition, SeekType } from 'generated-sources';
|
||||||
import compact from 'lodash/compact';
|
import compact from 'lodash/compact';
|
||||||
import { Option } from 'react-multi-select-component/dist/lib/interfaces';
|
import { Option } from 'react-multi-select-component';
|
||||||
|
|
||||||
export const filterOptions = (options: Option[], filter: string) => {
|
export const filterOptions = (options: Option[], filter: string) => {
|
||||||
if (!filter) {
|
if (!filter) {
|
||||||
|
|
|
@ -31,7 +31,6 @@ export const ContentBox = styled.div`
|
||||||
& nav {
|
& nav {
|
||||||
padding-bottom: 16px;
|
padding-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
${SEditorViewer.Wrapper} {
|
${SEditorViewer.Wrapper} {
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
}
|
}
|
||||||
|
@ -94,5 +93,4 @@ export const Tab = styled.button<{ $active?: boolean }>(
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
);
|
);
|
||||||
|
|
||||||
export const Tabs = styled.nav``;
|
export const Tabs = styled.nav``;
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { Option } from 'react-multi-select-component/dist/lib/interfaces';
|
import { Option } from 'react-multi-select-component';
|
||||||
import {
|
import {
|
||||||
filterOptions,
|
filterOptions,
|
||||||
getOffsetFromSeekToParam,
|
getOffsetFromSeekToParam,
|
||||||
|
|
|
@ -0,0 +1,53 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { useMessageFiltersStore } from 'lib/hooks/useMessageFiltersStore';
|
||||||
|
import * as StyledTable from 'components/common/NewTable/Table.styled';
|
||||||
|
import Heading from 'components/common/heading/Heading.styled';
|
||||||
|
import { Dropdown, DropdownItem } from 'components/common/Dropdown';
|
||||||
|
|
||||||
|
import Form from './Form';
|
||||||
|
|
||||||
|
const AdvancedFilter = () => {
|
||||||
|
const { save, apply, filters, remove } = useMessageFiltersStore();
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Heading level={4}>Add new filter</Heading>
|
||||||
|
<Form save={save} apply={apply} />
|
||||||
|
{filters.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Heading level={4}>Saved Filters</Heading>
|
||||||
|
<StyledTable.Table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<StyledTable.Th>Name</StyledTable.Th>
|
||||||
|
<StyledTable.Th>Value</StyledTable.Th>
|
||||||
|
<StyledTable.Th> </StyledTable.Th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filters.map((filter) => (
|
||||||
|
<tr key={filter.name}>
|
||||||
|
<td>{filter.name}</td>
|
||||||
|
<td>
|
||||||
|
<pre>{filter.value}</pre>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<Dropdown>
|
||||||
|
<DropdownItem onClick={() => apply(filter)}>
|
||||||
|
Apply Filter
|
||||||
|
</DropdownItem>
|
||||||
|
<DropdownItem onClick={() => remove(filter.name)}>
|
||||||
|
Delete filter
|
||||||
|
</DropdownItem>
|
||||||
|
</Dropdown>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</StyledTable.Table>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AdvancedFilter;
|
|
@ -0,0 +1,118 @@
|
||||||
|
import React from 'react';
|
||||||
|
import * as S from 'components/Topics/Topic/Messages/Filters/Filters.styled';
|
||||||
|
import { InputLabel } from 'components/common/Input/InputLabel.styled';
|
||||||
|
import Input from 'components/common/Input/Input';
|
||||||
|
import { FormProvider, Controller, useForm } from 'react-hook-form';
|
||||||
|
import { ErrorMessage } from '@hookform/error-message';
|
||||||
|
import { Button } from 'components/common/Button/Button';
|
||||||
|
import { FormError } from 'components/common/Input/Input.styled';
|
||||||
|
import Editor from 'components/common/Editor/Editor';
|
||||||
|
import { yupResolver } from '@hookform/resolvers/yup';
|
||||||
|
import yup from 'lib/yupExtended';
|
||||||
|
import { AdvancedFilter } from 'lib/hooks/useMessageFiltersStore';
|
||||||
|
|
||||||
|
const validationSchema = yup.object().shape({
|
||||||
|
value: yup.string().required(),
|
||||||
|
name: yup.string().required(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface FormProps {
|
||||||
|
name?: string;
|
||||||
|
value?: string;
|
||||||
|
save(filter: AdvancedFilter): void;
|
||||||
|
apply(filter: AdvancedFilter): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Form: React.FC<FormProps> = ({ name, value, save, apply }) => {
|
||||||
|
const methods = useForm<AdvancedFilter>({
|
||||||
|
mode: 'onChange',
|
||||||
|
resolver: yupResolver(validationSchema),
|
||||||
|
});
|
||||||
|
const {
|
||||||
|
handleSubmit,
|
||||||
|
control,
|
||||||
|
formState: { isDirty, isSubmitting, isValid, errors },
|
||||||
|
reset,
|
||||||
|
getValues,
|
||||||
|
} = methods;
|
||||||
|
|
||||||
|
const onSubmit = React.useCallback(
|
||||||
|
(values: AdvancedFilter) => {
|
||||||
|
apply(values);
|
||||||
|
reset({ name: '', value: '' });
|
||||||
|
},
|
||||||
|
[reset, save]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onSave = React.useCallback(() => {
|
||||||
|
save(getValues());
|
||||||
|
handleSubmit(onSubmit);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormProvider {...methods}>
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} aria-label="Filters submit Form">
|
||||||
|
<div>
|
||||||
|
<InputLabel>Filter code</InputLabel>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="value"
|
||||||
|
defaultValue={value}
|
||||||
|
render={({ field }) => (
|
||||||
|
<Editor
|
||||||
|
value={field.value}
|
||||||
|
minLines={5}
|
||||||
|
maxLines={28}
|
||||||
|
onChange={field.onChange}
|
||||||
|
setOptions={{
|
||||||
|
showLineNumbers: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<FormError>
|
||||||
|
<ErrorMessage errors={errors} name="value" />
|
||||||
|
</FormError>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<InputLabel>Display name</InputLabel>
|
||||||
|
<Input
|
||||||
|
inputSize="M"
|
||||||
|
placeholder="Enter Name"
|
||||||
|
autoComplete="off"
|
||||||
|
name="name"
|
||||||
|
defaultValue={name}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<FormError>
|
||||||
|
<ErrorMessage errors={errors} name="name" />
|
||||||
|
</FormError>
|
||||||
|
</div>
|
||||||
|
<S.FilterButtonWrapper>
|
||||||
|
<Button
|
||||||
|
buttonSize="M"
|
||||||
|
buttonType="secondary"
|
||||||
|
type="submit"
|
||||||
|
disabled={!isValid || isSubmitting || !isDirty}
|
||||||
|
onClick={onSave}
|
||||||
|
>
|
||||||
|
Save & Apply
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
buttonSize="M"
|
||||||
|
buttonType="primary"
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting || !isDirty}
|
||||||
|
>
|
||||||
|
Apply Filter
|
||||||
|
</Button>
|
||||||
|
</S.FilterButtonWrapper>
|
||||||
|
</form>
|
||||||
|
</FormProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Form;
|
|
@ -0,0 +1,75 @@
|
||||||
|
import styled from 'styled-components';
|
||||||
|
import DatePicker from 'react-datepicker';
|
||||||
|
|
||||||
|
export const Meta = styled.div`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 6px 16px;
|
||||||
|
border-bottom: 1px solid ${({ theme }) => theme.layout.stuffBorderColor};
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const MetaRow = styled.div`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const Metric = styled.div`
|
||||||
|
color: ${({ theme }) => theme.metrics.filters.color.normal};
|
||||||
|
font-size: 12px;
|
||||||
|
display: flex;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const MetricIcon = styled.div`
|
||||||
|
color: ${({ theme }) => theme.metrics.filters.color.icon};
|
||||||
|
padding-right: 6px;
|
||||||
|
height: 12px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const MetaMessage = styled.div.attrs({
|
||||||
|
role: 'contentLoader',
|
||||||
|
})`
|
||||||
|
color: ${({ theme }) => theme.heading.h3.color};
|
||||||
|
font-size: 12px;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const StopLoading = styled.div`
|
||||||
|
color: ${({ theme }) => theme.pageLoader.borderColor};
|
||||||
|
cursor: pointer;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const FilterRow = styled.div`
|
||||||
|
margin: 8px 0 8px;
|
||||||
|
`;
|
||||||
|
export const FilterFooter = styled.div`
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: end;
|
||||||
|
margin: 16px 0;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const DatePickerInput = styled(DatePicker)`
|
||||||
|
height: 32px;
|
||||||
|
border: 1px ${(props) => props.theme.select.borderColor.normal} solid;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
width: 100%;
|
||||||
|
padding-left: 12px;
|
||||||
|
color: ${(props) => props.theme.select.color.normal};
|
||||||
|
|
||||||
|
background-image: url('data:image/svg+xml,%3Csvg width="10" height="6" viewBox="0 0 10 6" fill="none" xmlns="http://www.w3.org/2000/svg"%3E%3Cpath d="M1 1L5 5L9 1" stroke="%23454F54"/%3E%3C/svg%3E%0A') !important;
|
||||||
|
background-repeat: no-repeat !important;
|
||||||
|
background-position-x: 96% !important;
|
||||||
|
background-position-y: 55% !important;
|
||||||
|
appearance: none !important;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
`;
|
|
@ -0,0 +1,144 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
import Input from 'components/common/Input/Input';
|
||||||
|
import { ConsumingMode } from 'lib/hooks/api/topicMessages';
|
||||||
|
import Select from 'components/common/Select/Select';
|
||||||
|
import { InputLabel } from 'components/common/Input/InputLabel.styled';
|
||||||
|
import { Option } from 'react-multi-select-component';
|
||||||
|
import { Button } from 'components/common/Button/Button';
|
||||||
|
import { Partition } from 'generated-sources';
|
||||||
|
import { getModeOptions } from 'components/Topics/Topic/MessagesV2/utils/consumingModes';
|
||||||
|
|
||||||
|
import * as S from './FiltersBar.styled';
|
||||||
|
import { setSeekTo } from './utils';
|
||||||
|
|
||||||
|
type FormValues = {
|
||||||
|
mode: ConsumingMode;
|
||||||
|
offset: string;
|
||||||
|
time: Date;
|
||||||
|
partitions: Option[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const Form: React.FC<{ isFetching: boolean; partitions: Partition[] }> = ({
|
||||||
|
isFetching,
|
||||||
|
partitions,
|
||||||
|
}) => {
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
|
||||||
|
const {
|
||||||
|
handleSubmit,
|
||||||
|
setValue,
|
||||||
|
watch,
|
||||||
|
formState: { isDirty },
|
||||||
|
reset,
|
||||||
|
} = useForm<FormValues>({
|
||||||
|
defaultValues: {
|
||||||
|
mode: searchParams.get('m') || 'newest',
|
||||||
|
offset: searchParams.get('o') || '0',
|
||||||
|
time: searchParams.get('t')
|
||||||
|
? new Date(Number(searchParams.get('t')))
|
||||||
|
: Date.now(),
|
||||||
|
} as FormValues,
|
||||||
|
});
|
||||||
|
const mode = watch('mode');
|
||||||
|
const offset = watch('offset');
|
||||||
|
const time = watch('time');
|
||||||
|
|
||||||
|
const onSubmit = (values: FormValues) => {
|
||||||
|
searchParams.set('m', values.mode);
|
||||||
|
searchParams.delete('o');
|
||||||
|
searchParams.delete('t');
|
||||||
|
searchParams.delete('a');
|
||||||
|
searchParams.delete('page');
|
||||||
|
if (values.mode === 'fromOffset' || values.mode === 'toOffset') {
|
||||||
|
searchParams.set('o', values.offset);
|
||||||
|
} else if (values.mode === 'sinceTime' || values.mode === 'untilTime') {
|
||||||
|
searchParams.set('t', `${values.time.getTime()}`);
|
||||||
|
}
|
||||||
|
setSeekTo(searchParams, partitions);
|
||||||
|
setSearchParams(searchParams);
|
||||||
|
reset(values);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTimestampChange = (value: Date | null) => {
|
||||||
|
if (value) {
|
||||||
|
setValue('time', value, { shouldDirty: true });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handleOffsetChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setValue('offset', e.target.value, { shouldDirty: true });
|
||||||
|
};
|
||||||
|
const handleRefresh: React.MouseEventHandler<HTMLButtonElement> = (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
searchParams.set('a', `${Number(searchParams.get('a') || 0) + 1}`);
|
||||||
|
setSearchParams(searchParams);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
|
<S.FilterRow>
|
||||||
|
<InputLabel>Mode</InputLabel>
|
||||||
|
<Select
|
||||||
|
selectSize="M"
|
||||||
|
minWidth="100%"
|
||||||
|
value={mode}
|
||||||
|
options={getModeOptions()}
|
||||||
|
isLive={mode === 'live' && isFetching}
|
||||||
|
onChange={(option: string | number) =>
|
||||||
|
setValue('mode', option as ConsumingMode, { shouldDirty: true })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</S.FilterRow>
|
||||||
|
{['sinceTime', 'untilTime'].includes(mode) && (
|
||||||
|
<S.FilterRow>
|
||||||
|
<InputLabel>Time</InputLabel>
|
||||||
|
<S.DatePickerInput
|
||||||
|
selected={time}
|
||||||
|
onChange={handleTimestampChange}
|
||||||
|
showTimeInput
|
||||||
|
timeInputLabel="Time:"
|
||||||
|
dateFormat="MMMM d, yyyy HH:mm"
|
||||||
|
placeholderText="Select timestamp"
|
||||||
|
/>
|
||||||
|
</S.FilterRow>
|
||||||
|
)}
|
||||||
|
{['fromOffset', 'toOffset'].includes(mode) && (
|
||||||
|
<S.FilterRow>
|
||||||
|
<InputLabel>Offset</InputLabel>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
inputSize="M"
|
||||||
|
value={offset}
|
||||||
|
placeholder="Offset"
|
||||||
|
onChange={handleOffsetChange}
|
||||||
|
/>
|
||||||
|
</S.FilterRow>
|
||||||
|
)}
|
||||||
|
<S.FilterFooter>
|
||||||
|
<Button
|
||||||
|
buttonType="secondary"
|
||||||
|
disabled={!isDirty}
|
||||||
|
buttonSize="S"
|
||||||
|
onClick={() => reset()}
|
||||||
|
>
|
||||||
|
Clear All
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
buttonType="secondary"
|
||||||
|
buttonSize="S"
|
||||||
|
disabled={isDirty || isFetching}
|
||||||
|
onClick={handleRefresh}
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
<Button buttonType="primary" disabled={!isDirty} buttonSize="S">
|
||||||
|
Apply Mode
|
||||||
|
</Button>
|
||||||
|
</S.FilterFooter>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Form;
|
|
@ -0,0 +1,46 @@
|
||||||
|
import BytesFormatted from 'components/common/BytesFormatted/BytesFormatted';
|
||||||
|
import ArrowDownIcon from 'components/common/Icons/ArrowDownIcon';
|
||||||
|
import ClockIcon from 'components/common/Icons/ClockIcon';
|
||||||
|
import FileIcon from 'components/common/Icons/FileIcon';
|
||||||
|
import { TopicMessageConsuming } from 'generated-sources';
|
||||||
|
import { formatMilliseconds } from 'lib/dateTimeHelpers';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import * as S from './FiltersBar.styled';
|
||||||
|
|
||||||
|
interface MetaProps {
|
||||||
|
meta?: TopicMessageConsuming;
|
||||||
|
phase?: string;
|
||||||
|
isFetching: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Meta: React.FC<MetaProps> = ({ meta = {} }) => {
|
||||||
|
const { bytesConsumed, messagesConsumed, elapsedMs } = meta;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<S.Meta>
|
||||||
|
<S.MetaRow>
|
||||||
|
<S.Metric title="Messages Consumed">
|
||||||
|
<S.MetricIcon>
|
||||||
|
<FileIcon />
|
||||||
|
</S.MetricIcon>
|
||||||
|
<span>{messagesConsumed || 0} msg.</span>
|
||||||
|
</S.Metric>
|
||||||
|
<S.Metric title="Bytes Consumed">
|
||||||
|
<S.MetricIcon>
|
||||||
|
<ArrowDownIcon />
|
||||||
|
</S.MetricIcon>
|
||||||
|
<BytesFormatted value={bytesConsumed || 0} />
|
||||||
|
</S.Metric>
|
||||||
|
<S.Metric title="Elapsed Time">
|
||||||
|
<S.MetricIcon>
|
||||||
|
<ClockIcon />
|
||||||
|
</S.MetricIcon>
|
||||||
|
<span>{formatMilliseconds(elapsedMs)}</span>
|
||||||
|
</S.Metric>
|
||||||
|
</S.MetaRow>
|
||||||
|
</S.Meta>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Meta;
|
|
@ -0,0 +1,112 @@
|
||||||
|
import { Partition } from 'generated-sources';
|
||||||
|
import { ConsumingMode } from 'lib/hooks/api/topicMessages';
|
||||||
|
import { Option } from 'react-multi-select-component';
|
||||||
|
|
||||||
|
export const filterOptions = (options: Option[], filter: string) => {
|
||||||
|
if (!filter) {
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
return options.filter(({ value }) => value && value.toString() === filter);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const convertPartitionsToOptions = (ids: Array<string>): Option[] =>
|
||||||
|
ids.map((id) => ({
|
||||||
|
label: `Partition #${id}`,
|
||||||
|
value: `${id}`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const getSelectedPartitions = (
|
||||||
|
allIds: string[],
|
||||||
|
query: string | null
|
||||||
|
) => {
|
||||||
|
let selectedIds: string[] = [];
|
||||||
|
switch (query) {
|
||||||
|
case null: // Empty array of partitions in searchParams - means all
|
||||||
|
case 'all':
|
||||||
|
selectedIds = allIds;
|
||||||
|
break;
|
||||||
|
case 'none':
|
||||||
|
selectedIds = [];
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
selectedIds = query.split('.');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return convertPartitionsToOptions(selectedIds);
|
||||||
|
};
|
||||||
|
|
||||||
|
type PartionOffsetKey = 'offsetMax' | 'offsetMin';
|
||||||
|
|
||||||
|
const generateSeekTo = (
|
||||||
|
partitions: Partition[],
|
||||||
|
type: 'property' | 'value',
|
||||||
|
value: PartionOffsetKey | string
|
||||||
|
) => {
|
||||||
|
// we iterating over existing partitions to avoid sending wrong partition ids to the backend
|
||||||
|
const seekTo = partitions.map((partition) => {
|
||||||
|
const { partition: id } = partition;
|
||||||
|
switch (type) {
|
||||||
|
case 'property':
|
||||||
|
return `${id}-${partition[value as PartionOffsetKey]}`;
|
||||||
|
case 'value':
|
||||||
|
return `${id}-${value}`;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return seekTo.join('.');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const generateSeekToForSelectedPartitions = (
|
||||||
|
mode: ConsumingMode,
|
||||||
|
partitions: Partition[],
|
||||||
|
offset: string,
|
||||||
|
time: string
|
||||||
|
) => {
|
||||||
|
switch (mode) {
|
||||||
|
case 'live':
|
||||||
|
case 'newest':
|
||||||
|
return generateSeekTo(partitions, 'property', 'offsetMax');
|
||||||
|
case 'fromOffset':
|
||||||
|
case 'toOffset':
|
||||||
|
return generateSeekTo(partitions, 'value', offset);
|
||||||
|
case 'sinceTime':
|
||||||
|
case 'untilTime':
|
||||||
|
return generateSeekTo(partitions, 'value', time);
|
||||||
|
default:
|
||||||
|
// case 'oldest';
|
||||||
|
return generateSeekTo(partitions, 'value', '0');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setSeekTo = (
|
||||||
|
searchParams: URLSearchParams,
|
||||||
|
partitions: Partition[]
|
||||||
|
) => {
|
||||||
|
const currentSeekTo = searchParams.get('seekTo');
|
||||||
|
const mode = searchParams.get('m') as ConsumingMode;
|
||||||
|
const offset = (searchParams.get('o') as string) || '0';
|
||||||
|
const time =
|
||||||
|
(searchParams.get('t') as string) || new Date().getTime().toString();
|
||||||
|
|
||||||
|
let selectedPartitions: Partition[] = [];
|
||||||
|
// if not `seekTo` property in search params, we set it to all partition
|
||||||
|
if (!currentSeekTo) {
|
||||||
|
selectedPartitions = partitions;
|
||||||
|
} else {
|
||||||
|
const partitionIds = currentSeekTo
|
||||||
|
.split('.')
|
||||||
|
.map((prop) => prop.split('-')[0]);
|
||||||
|
|
||||||
|
selectedPartitions = partitions.filter(({ partition }) =>
|
||||||
|
partitionIds.includes(String(partition))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
searchParams.set(
|
||||||
|
'seekTo',
|
||||||
|
generateSeekToForSelectedPartitions(mode, selectedPartitions, offset, time)
|
||||||
|
);
|
||||||
|
|
||||||
|
return searchParams;
|
||||||
|
};
|
|
@ -0,0 +1,61 @@
|
||||||
|
import styled, { css } from 'styled-components';
|
||||||
|
|
||||||
|
export const Wrapper = styled.div(
|
||||||
|
({ theme }) => css`
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 300px 1fr;
|
||||||
|
justify-items: center;
|
||||||
|
min-height: calc(
|
||||||
|
100vh - ${theme.layout.navBarHeight} - ${theme.pageHeading.height} -
|
||||||
|
${theme.primaryTab.height}
|
||||||
|
);
|
||||||
|
`
|
||||||
|
);
|
||||||
|
|
||||||
|
export const Sidebar = styled.div(
|
||||||
|
({ theme }) => css`
|
||||||
|
width: 300px;
|
||||||
|
position: sticky;
|
||||||
|
top: ${theme.layout.navBarHeight};
|
||||||
|
align-self: start;
|
||||||
|
`
|
||||||
|
);
|
||||||
|
|
||||||
|
export const SidebarContent = styled.div`
|
||||||
|
padding: 8px 16px 16px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const TableWrapper = styled.div(
|
||||||
|
({ theme }) => css`
|
||||||
|
width: 100%;
|
||||||
|
border-left: 1px solid ${theme.layout.stuffBorderColor};
|
||||||
|
`
|
||||||
|
);
|
||||||
|
|
||||||
|
export const Pagination = styled.div`
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
padding: 16px;
|
||||||
|
width: 300px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const StatusBarWrapper = styled.div(
|
||||||
|
({ theme }) => css`
|
||||||
|
padding: 4px 8px;
|
||||||
|
position: sticky;
|
||||||
|
top: ${theme.layout.navBarHeight};
|
||||||
|
background-color: ${theme.layout.backgroundColor};
|
||||||
|
border-bottom: 1px solid ${theme.layout.stuffBorderColor};
|
||||||
|
white-space: nowrap;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
`
|
||||||
|
);
|
||||||
|
|
||||||
|
export const StatusTags = styled.div`
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
`;
|
|
@ -0,0 +1,137 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { ConsumingMode, useTopicMessages } from 'lib/hooks/api/topicMessages';
|
||||||
|
import useAppParams from 'lib/hooks/useAppParams';
|
||||||
|
import { RouteParamsClusterTopic } from 'lib/paths';
|
||||||
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
|
import { useTopicDetails } from 'lib/hooks/api/topics';
|
||||||
|
import { MESSAGES_PER_PAGE } from 'lib/constants';
|
||||||
|
import Search from 'components/common/Search/Search';
|
||||||
|
import { Button } from 'components/common/Button/Button';
|
||||||
|
import PlusIcon from 'components/common/Icons/PlusIcon';
|
||||||
|
import SlidingSidebar from 'components/common/SlidingSidebar';
|
||||||
|
import useBoolean from 'lib/hooks/useBoolean';
|
||||||
|
|
||||||
|
import MessagesTable from './MessagesTable/MessagesTable';
|
||||||
|
import * as S from './Messages.styled';
|
||||||
|
import Meta from './FiltersBar/Meta';
|
||||||
|
import Form from './FiltersBar/Form';
|
||||||
|
import { setSeekTo } from './FiltersBar/utils';
|
||||||
|
import handleNextPageClick from './utils/handleNextPageClick';
|
||||||
|
import StatusBar from './StatusBar';
|
||||||
|
import AdvancedFilter from './Advanced Filter/AdvancedFilter';
|
||||||
|
|
||||||
|
const Messages = () => {
|
||||||
|
const routerProps = useAppParams<RouteParamsClusterTopic>();
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const {
|
||||||
|
value: isAdvancedFiltersSidebarVisible,
|
||||||
|
setFalse: closeAdvancedFiltersSidebar,
|
||||||
|
setTrue: openAdvancedFiltersSidebar,
|
||||||
|
} = useBoolean();
|
||||||
|
const { messages, meta, phase, isFetching } = useTopicMessages({
|
||||||
|
...routerProps,
|
||||||
|
searchParams,
|
||||||
|
});
|
||||||
|
const mode = searchParams.get('m') as ConsumingMode;
|
||||||
|
const isTailing = mode === 'live' && isFetching;
|
||||||
|
const { data: topic = { partitions: [] } } = useTopicDetails(routerProps);
|
||||||
|
|
||||||
|
const partitions = topic.partitions || [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search params:
|
||||||
|
* - `q` - search query
|
||||||
|
* - `m` - way the consumer is going to consume the messages..
|
||||||
|
* - `o` - offset
|
||||||
|
* - `t` - timestamp
|
||||||
|
* - `perPage` - number of messages per page
|
||||||
|
* - `seekTo` - offset or timestamp to seek to.
|
||||||
|
* Format: `0-101.1-987` - [partition 0, offset 101], [partition 1, offset 987]
|
||||||
|
* - `page` - page number
|
||||||
|
*/
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!mode) {
|
||||||
|
searchParams.set('m', 'newest');
|
||||||
|
}
|
||||||
|
if (!searchParams.get('perPage')) {
|
||||||
|
searchParams.set('perPage', MESSAGES_PER_PAGE);
|
||||||
|
}
|
||||||
|
if (!searchParams.get('seekTo')) {
|
||||||
|
setSeekTo(searchParams, partitions);
|
||||||
|
}
|
||||||
|
setSearchParams(searchParams);
|
||||||
|
}, [topic]);
|
||||||
|
|
||||||
|
// Pagination is disabled in live mode, also we don't want to show the button
|
||||||
|
// if we are fetching the messages or if we are at the end of the topic
|
||||||
|
const isPaginationDisabled =
|
||||||
|
isTailing ||
|
||||||
|
['newest', 'oldest'].includes(mode) || // TODO: remove after BE is fixed
|
||||||
|
isFetching ||
|
||||||
|
!searchParams.get('seekTo');
|
||||||
|
|
||||||
|
const isNextPageButtonDisabled =
|
||||||
|
isPaginationDisabled ||
|
||||||
|
messages.length < Number(searchParams.get('perPage') || MESSAGES_PER_PAGE);
|
||||||
|
const isPrevPageButtonDisabled =
|
||||||
|
isPaginationDisabled || !searchParams.get('page');
|
||||||
|
|
||||||
|
const handleNextPage = () =>
|
||||||
|
handleNextPageClick(messages, searchParams, setSearchParams);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<S.Wrapper>
|
||||||
|
<S.Sidebar>
|
||||||
|
<Meta meta={meta} phase={phase} isFetching={isFetching} />
|
||||||
|
<S.SidebarContent>
|
||||||
|
<Search placeholder="Search" />
|
||||||
|
<Form isFetching={isFetching} partitions={partitions} />
|
||||||
|
</S.SidebarContent>
|
||||||
|
<S.Pagination>
|
||||||
|
<Button
|
||||||
|
buttonType="secondary"
|
||||||
|
buttonSize="L"
|
||||||
|
disabled={isPrevPageButtonDisabled}
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
>
|
||||||
|
← Back
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
buttonType="secondary"
|
||||||
|
buttonSize="L"
|
||||||
|
disabled={isNextPageButtonDisabled}
|
||||||
|
onClick={handleNextPage}
|
||||||
|
>
|
||||||
|
Next →
|
||||||
|
</Button>
|
||||||
|
</S.Pagination>
|
||||||
|
</S.Sidebar>
|
||||||
|
<S.TableWrapper>
|
||||||
|
<S.StatusBarWrapper>
|
||||||
|
<StatusBar />
|
||||||
|
<Button
|
||||||
|
buttonType="primary"
|
||||||
|
buttonSize="S"
|
||||||
|
onClick={openAdvancedFiltersSidebar}
|
||||||
|
>
|
||||||
|
<PlusIcon />
|
||||||
|
Advanced Filter
|
||||||
|
</Button>
|
||||||
|
</S.StatusBarWrapper>
|
||||||
|
<MessagesTable messages={messages} isLive={isTailing} />
|
||||||
|
</S.TableWrapper>
|
||||||
|
</S.Wrapper>
|
||||||
|
<SlidingSidebar
|
||||||
|
title="Advanced filtering"
|
||||||
|
open={isAdvancedFiltersSidebarVisible}
|
||||||
|
onClose={closeAdvancedFiltersSidebar}
|
||||||
|
>
|
||||||
|
<AdvancedFilter />
|
||||||
|
</SlidingSidebar>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Messages;
|
|
@ -0,0 +1,25 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { TopicMessage } from 'generated-sources';
|
||||||
|
import { CellContext } from '@tanstack/react-table';
|
||||||
|
import { Dropdown, DropdownItem } from 'components/common/Dropdown';
|
||||||
|
import useDataSaver from 'lib/hooks/useDataSaver';
|
||||||
|
|
||||||
|
const ActionsCell: React.FC<CellContext<TopicMessage, unknown>> = ({ row }) => {
|
||||||
|
const { content } = row.original;
|
||||||
|
|
||||||
|
const { copyToClipboard, saveFile } = useDataSaver(
|
||||||
|
'topic-message',
|
||||||
|
content || ''
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dropdown>
|
||||||
|
<DropdownItem onClick={copyToClipboard}>
|
||||||
|
Copy content to clipboard
|
||||||
|
</DropdownItem>
|
||||||
|
<DropdownItem onClick={saveFile}>Save content as a file</DropdownItem>
|
||||||
|
</Dropdown>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ActionsCell;
|
|
@ -0,0 +1,55 @@
|
||||||
|
import styled, { css } from 'styled-components';
|
||||||
|
import * as SEditorViewer from 'components/common/EditorViewer/EditorViewer.styled';
|
||||||
|
|
||||||
|
export const Section = styled.div`
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 400px;
|
||||||
|
align-items: stretch;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const ContentBox = styled.div`
|
||||||
|
background-color: white;
|
||||||
|
border-right: 1px solid ${({ theme }) => theme.layout.stuffBorderColor};
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding-right: 16px;
|
||||||
|
& nav {
|
||||||
|
padding-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
${SEditorViewer.Wrapper} {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const MetadataWrapper = styled.div`
|
||||||
|
padding-left: 16px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const Tab = styled.button<{ $active?: boolean }>(
|
||||||
|
({ theme, $active }) => css`
|
||||||
|
background-color: ${theme.secondaryTab.backgroundColor[
|
||||||
|
$active ? 'active' : 'normal'
|
||||||
|
]};
|
||||||
|
color: ${theme.secondaryTab.color[$active ? 'active' : 'normal']};
|
||||||
|
padding: 6px 16px;
|
||||||
|
height: 32px;
|
||||||
|
border: 1px solid ${theme.layout.stuffBorderColor};
|
||||||
|
cursor: pointer;
|
||||||
|
&:hover {
|
||||||
|
background-color: ${theme.secondaryTab.backgroundColor.hover};
|
||||||
|
color: ${theme.secondaryTab.color.hover};
|
||||||
|
}
|
||||||
|
&:first-child {
|
||||||
|
border-radius: 4px 0 0 4px;
|
||||||
|
}
|
||||||
|
&:last-child {
|
||||||
|
border-radius: 0 4px 4px 0;
|
||||||
|
}
|
||||||
|
&:not(:last-child) {
|
||||||
|
border-right: 0px;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
);
|
||||||
|
|
||||||
|
export const Tabs = styled.nav``;
|
|
@ -0,0 +1,106 @@
|
||||||
|
import { SchemaType, TopicMessage } from 'generated-sources';
|
||||||
|
import React from 'react';
|
||||||
|
import EditorViewer from 'components/common/EditorViewer/EditorViewer';
|
||||||
|
import BytesFormatted from 'components/common/BytesFormatted/BytesFormatted';
|
||||||
|
import { formatTimestamp } from 'lib/dateTimeHelpers';
|
||||||
|
import { Row } from '@tanstack/react-table';
|
||||||
|
import {
|
||||||
|
Label,
|
||||||
|
List,
|
||||||
|
SubText,
|
||||||
|
} from 'components/common/PropertiesList/PropertiesList.styled';
|
||||||
|
|
||||||
|
import * as S from './MessageContent.styled';
|
||||||
|
|
||||||
|
type Tab = 'key' | 'content' | 'headers';
|
||||||
|
|
||||||
|
const MessageContent: React.FC<{ row: Row<TopicMessage> }> = ({ row }) => {
|
||||||
|
const {
|
||||||
|
content,
|
||||||
|
valueFormat,
|
||||||
|
key,
|
||||||
|
keyFormat,
|
||||||
|
headers,
|
||||||
|
timestamp,
|
||||||
|
timestampType,
|
||||||
|
} = row.original;
|
||||||
|
|
||||||
|
const [activeTab, setActiveTab] = React.useState<Tab>('content');
|
||||||
|
const activeTabContent = () => {
|
||||||
|
switch (activeTab) {
|
||||||
|
case 'content':
|
||||||
|
return content;
|
||||||
|
case 'key':
|
||||||
|
return key;
|
||||||
|
default:
|
||||||
|
return JSON.stringify(headers || {});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const keySize = encoder.encode(key).length;
|
||||||
|
const contentSize = encoder.encode(content).length;
|
||||||
|
const contentType =
|
||||||
|
content && content.trim().startsWith('{')
|
||||||
|
? SchemaType.JSON
|
||||||
|
: SchemaType.PROTOBUF;
|
||||||
|
return (
|
||||||
|
<S.Section>
|
||||||
|
<S.ContentBox>
|
||||||
|
<S.Tabs>
|
||||||
|
<S.Tab
|
||||||
|
type="button"
|
||||||
|
$active={activeTab === 'key'}
|
||||||
|
onClick={() => setActiveTab('key')}
|
||||||
|
>
|
||||||
|
Key
|
||||||
|
</S.Tab>
|
||||||
|
<S.Tab
|
||||||
|
$active={activeTab === 'content'}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab('content')}
|
||||||
|
>
|
||||||
|
Content
|
||||||
|
</S.Tab>
|
||||||
|
<S.Tab
|
||||||
|
$active={activeTab === 'headers'}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab('headers')}
|
||||||
|
>
|
||||||
|
Headers
|
||||||
|
</S.Tab>
|
||||||
|
</S.Tabs>
|
||||||
|
<EditorViewer
|
||||||
|
data={activeTabContent() || ''}
|
||||||
|
maxLines={28}
|
||||||
|
schemaType={contentType}
|
||||||
|
/>
|
||||||
|
</S.ContentBox>
|
||||||
|
<S.MetadataWrapper>
|
||||||
|
<List>
|
||||||
|
<Label>Timestamp</Label>
|
||||||
|
<span>
|
||||||
|
{formatTimestamp(timestamp)}
|
||||||
|
<SubText>Timestamp type: {timestampType}</SubText>
|
||||||
|
</span>
|
||||||
|
<Label>Content</Label>
|
||||||
|
<span>
|
||||||
|
{valueFormat}
|
||||||
|
<SubText>
|
||||||
|
Size: <BytesFormatted value={contentSize} />
|
||||||
|
</SubText>
|
||||||
|
</span>
|
||||||
|
<Label>Key</Label>
|
||||||
|
<span>
|
||||||
|
{keyFormat}
|
||||||
|
<SubText>
|
||||||
|
Size: <BytesFormatted value={keySize} />
|
||||||
|
</SubText>
|
||||||
|
</span>
|
||||||
|
</List>
|
||||||
|
</S.MetadataWrapper>
|
||||||
|
</S.Section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MessageContent;
|
|
@ -0,0 +1,41 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { ColumnDef } from '@tanstack/react-table';
|
||||||
|
import Table, { TimestampCell } from 'components/common/NewTable';
|
||||||
|
import { TopicMessage } from 'generated-sources';
|
||||||
|
import TruncatedTextCell from 'components/common/NewTable/TimestampCell copy';
|
||||||
|
|
||||||
|
import MessageContent from './MessageContent/MessageContent';
|
||||||
|
import ActionsCell from './ActionsCell';
|
||||||
|
|
||||||
|
const MessagesTable: React.FC<{
|
||||||
|
messages: TopicMessage[];
|
||||||
|
isLive: boolean;
|
||||||
|
}> = ({ messages, isLive }) => {
|
||||||
|
const columns = React.useMemo<ColumnDef<TopicMessage>[]>(
|
||||||
|
() => [
|
||||||
|
{ header: 'Offset', accessorKey: 'offset' },
|
||||||
|
{ header: 'Partition', accessorKey: 'partition' },
|
||||||
|
{ header: 'Timestamp', accessorKey: 'timestamp', cell: TimestampCell },
|
||||||
|
{ header: 'Key', accessorKey: 'key', cell: TruncatedTextCell },
|
||||||
|
{ header: 'Content', accessorKey: 'content', cell: TruncatedTextCell },
|
||||||
|
{ header: '', id: 'action', cell: ActionsCell },
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
data={messages}
|
||||||
|
serverSideProcessing
|
||||||
|
pageCount={1}
|
||||||
|
emptyMessage={isLive ? 'Consuming messages...' : 'No messages to display'}
|
||||||
|
getRowCanExpand={() => true}
|
||||||
|
enableRowSelection={false}
|
||||||
|
enableSorting={false}
|
||||||
|
renderSubComponent={MessageContent}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MessagesTable;
|
|
@ -0,0 +1,39 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
import { Tag } from 'components/common/Tag/Tag.styled';
|
||||||
|
import { ConsumingMode } from 'lib/hooks/api/topicMessages';
|
||||||
|
|
||||||
|
import { StatusTags } from './Messages.styled';
|
||||||
|
import { getModeTitle } from './utils/consumingModes';
|
||||||
|
|
||||||
|
const StatusBar = () => {
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
|
||||||
|
const mode = getModeTitle(
|
||||||
|
(searchParams.get('m') as ConsumingMode) || undefined
|
||||||
|
);
|
||||||
|
const offset = searchParams.get('o');
|
||||||
|
const timestamp = searchParams.get('t');
|
||||||
|
const query = searchParams.get('q');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StatusTags>
|
||||||
|
<Tag color="green">
|
||||||
|
{offset || timestamp ? (
|
||||||
|
<>
|
||||||
|
{mode}: <b>{offset || timestamp}</b>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
mode
|
||||||
|
)}
|
||||||
|
</Tag>
|
||||||
|
{query && (
|
||||||
|
<Tag color="blue">
|
||||||
|
Search: <b>{query}</b>
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
</StatusTags>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StatusBar;
|
|
@ -0,0 +1,50 @@
|
||||||
|
import { ConsumingMode } from 'lib/hooks/api/topicMessages';
|
||||||
|
import { SelectOption } from 'components/common/Select/Select';
|
||||||
|
|
||||||
|
interface Mode {
|
||||||
|
key: ConsumingMode;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ModeOption extends SelectOption {
|
||||||
|
value: ConsumingMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const config: Mode[] = [
|
||||||
|
{
|
||||||
|
key: 'live',
|
||||||
|
title: 'Live mode',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'newest',
|
||||||
|
title: 'Newest first',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'oldest',
|
||||||
|
title: 'Oldest first',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'fromOffset',
|
||||||
|
title: 'From offset',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'toOffset',
|
||||||
|
title: 'To offset',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'sinceTime',
|
||||||
|
title: 'Since time',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'untilTime',
|
||||||
|
title: 'Until time',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const getModeOptions = (): ModeOption[] =>
|
||||||
|
config.map(({ key, title }) => ({ value: key, label: title }));
|
||||||
|
|
||||||
|
export const getModeTitle = (mode: ConsumingMode = 'newest') => {
|
||||||
|
const modeConfig = config.find((item) => item.key === mode) as Mode;
|
||||||
|
return modeConfig.title;
|
||||||
|
};
|
|
@ -0,0 +1,65 @@
|
||||||
|
import { TopicMessage } from 'generated-sources';
|
||||||
|
import { ConsumingMode } from 'lib/hooks/api/topicMessages';
|
||||||
|
|
||||||
|
export default (
|
||||||
|
messages: TopicMessage[],
|
||||||
|
searchParams: URLSearchParams,
|
||||||
|
setSearchParams: (params: URLSearchParams) => void
|
||||||
|
) => {
|
||||||
|
const seekTo = searchParams.get('seekTo');
|
||||||
|
const mode = searchParams.get('m') as ConsumingMode;
|
||||||
|
const page = searchParams.get('page');
|
||||||
|
if (!seekTo || !mode) return;
|
||||||
|
|
||||||
|
// parse current seekTo query param to array of [partition, offset] tuples
|
||||||
|
const configTuple = seekTo?.split('.').map((item) => {
|
||||||
|
const [partition, offset] = item.split('-');
|
||||||
|
return { partition: Number(partition), offset: Number(offset) };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reverse messages array for faster last displayed message search.
|
||||||
|
const reversedMessages = [...messages].reverse();
|
||||||
|
|
||||||
|
if (!configTuple) return;
|
||||||
|
|
||||||
|
const newConfigTuple = configTuple.map(({ partition, offset }) => {
|
||||||
|
const message = reversedMessages.find((m) => partition === m.partition);
|
||||||
|
if (!message) {
|
||||||
|
return { partition, offset };
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (mode) {
|
||||||
|
case 'fromOffset':
|
||||||
|
case 'oldest':
|
||||||
|
// First message in the reversed array is the message with max offset.
|
||||||
|
// Replace offset in seekTo query param with the max offset for
|
||||||
|
// each partition from displayed messages array.
|
||||||
|
return { partition, offset: Math.max(message.offset, offset) };
|
||||||
|
case 'toOffset':
|
||||||
|
case 'newest':
|
||||||
|
// First message in the reversed array is the message with min offset.
|
||||||
|
return { partition, offset: Math.min(message.offset, offset) };
|
||||||
|
case 'sinceTime':
|
||||||
|
// First message in the reversed array is the message with max timestamp.
|
||||||
|
return {
|
||||||
|
partition,
|
||||||
|
offset: Math.max(new Date(message.timestamp).getTime(), offset),
|
||||||
|
};
|
||||||
|
case 'untilTime':
|
||||||
|
// First message in the reversed array is the message with min timestamp.
|
||||||
|
return {
|
||||||
|
partition,
|
||||||
|
offset: Math.min(new Date(message.timestamp).getTime(), offset),
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return { partition, offset };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
searchParams.set('page', String(Number(page || 0) + 1));
|
||||||
|
searchParams.set(
|
||||||
|
'seekTo',
|
||||||
|
newConfigTuple.map((t) => `${t.partition}-${t.offset}`).join('.')
|
||||||
|
);
|
||||||
|
|
||||||
|
setSearchParams(searchParams);
|
||||||
|
};
|
|
@ -2,7 +2,6 @@ import styled from 'styled-components';
|
||||||
|
|
||||||
export const Wrapper = styled.div`
|
export const Wrapper = styled.div`
|
||||||
display: block;
|
display: block;
|
||||||
padding: 1.25rem;
|
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|
|
@ -1,22 +1,18 @@
|
||||||
import React, { useEffect } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import { useForm, Controller } from 'react-hook-form';
|
import { useForm, Controller } from 'react-hook-form';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { RouteParamsClusterTopic } from 'lib/paths';
|
||||||
import {
|
|
||||||
clusterTopicMessagesRelativePath,
|
|
||||||
RouteParamsClusterTopic,
|
|
||||||
} from 'lib/paths';
|
|
||||||
import jsf from 'json-schema-faker';
|
import jsf from 'json-schema-faker';
|
||||||
import { Button } from 'components/common/Button/Button';
|
import { Button } from 'components/common/Button/Button';
|
||||||
import Editor from 'components/common/Editor/Editor';
|
import Editor from 'components/common/Editor/Editor';
|
||||||
import Select, { SelectOption } from 'components/common/Select/Select';
|
import Select, { SelectOption } from 'components/common/Select/Select';
|
||||||
import useAppParams from 'lib/hooks/useAppParams';
|
import useAppParams from 'lib/hooks/useAppParams';
|
||||||
import Heading from 'components/common/heading/Heading.styled';
|
|
||||||
import { showAlert } from 'lib/errorHandling';
|
import { showAlert } from 'lib/errorHandling';
|
||||||
import {
|
import {
|
||||||
useSendMessage,
|
useSendMessage,
|
||||||
useTopicDetails,
|
useTopicDetails,
|
||||||
useTopicMessageSchema,
|
useTopicMessageSchema,
|
||||||
} from 'lib/hooks/api/topics';
|
} from 'lib/hooks/api/topics';
|
||||||
|
import { InputLabel } from 'components/common/Input/InputLabel.styled';
|
||||||
|
|
||||||
import validateMessage from './validateMessage';
|
import validateMessage from './validateMessage';
|
||||||
import * as S from './SendMessage.styled';
|
import * as S from './SendMessage.styled';
|
||||||
|
@ -28,9 +24,8 @@ type FieldValues = Partial<{
|
||||||
partition: number | string;
|
partition: number | string;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
const SendMessage: React.FC = () => {
|
const SendMessage: React.FC<{ onSubmit: () => void }> = ({ onSubmit }) => {
|
||||||
const { clusterName, topicName } = useAppParams<RouteParamsClusterTopic>();
|
const { clusterName, topicName } = useAppParams<RouteParamsClusterTopic>();
|
||||||
const navigate = useNavigate();
|
|
||||||
const { data: topic } = useTopicDetails({ clusterName, topicName });
|
const { data: topic } = useTopicDetails({ clusterName, topicName });
|
||||||
const { data: messageSchema } = useTopicMessageSchema({
|
const { data: messageSchema } = useTopicMessageSchema({
|
||||||
clusterName,
|
clusterName,
|
||||||
|
@ -92,7 +87,7 @@ const SendMessage: React.FC = () => {
|
||||||
});
|
});
|
||||||
}, [keyDefaultValue, contentDefaultValue, reset]);
|
}, [keyDefaultValue, contentDefaultValue, reset]);
|
||||||
|
|
||||||
const onSubmit = async (data: {
|
const submit = async (data: {
|
||||||
key: string;
|
key: string;
|
||||||
content: string;
|
content: string;
|
||||||
headers: string;
|
headers: string;
|
||||||
|
@ -129,16 +124,16 @@ const SendMessage: React.FC = () => {
|
||||||
headers,
|
headers,
|
||||||
partition: !partition ? 0 : partition,
|
partition: !partition ? 0 : partition,
|
||||||
});
|
});
|
||||||
navigate(`../${clusterTopicMessagesRelativePath}`);
|
onSubmit();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<S.Wrapper>
|
<S.Wrapper>
|
||||||
<form onSubmit={handleSubmit(onSubmit)}>
|
<form onSubmit={handleSubmit(submit)}>
|
||||||
<S.Columns>
|
<S.Columns>
|
||||||
<S.Column>
|
<S.Column>
|
||||||
<Heading level={3}>Partition</Heading>
|
<InputLabel>Partition</InputLabel>
|
||||||
<Controller
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
name="partition"
|
name="partition"
|
||||||
|
@ -160,7 +155,7 @@ const SendMessage: React.FC = () => {
|
||||||
|
|
||||||
<S.Columns>
|
<S.Columns>
|
||||||
<S.Column>
|
<S.Column>
|
||||||
<Heading level={3}>Key</Heading>
|
<InputLabel>Key</InputLabel>
|
||||||
<Controller
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
name="key"
|
name="key"
|
||||||
|
@ -175,7 +170,7 @@ const SendMessage: React.FC = () => {
|
||||||
/>
|
/>
|
||||||
</S.Column>
|
</S.Column>
|
||||||
<S.Column>
|
<S.Column>
|
||||||
<Heading level={3}>Content</Heading>
|
<InputLabel>Content</InputLabel>
|
||||||
<Controller
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
name="content"
|
name="content"
|
||||||
|
@ -192,7 +187,7 @@ const SendMessage: React.FC = () => {
|
||||||
</S.Columns>
|
</S.Columns>
|
||||||
<S.Columns>
|
<S.Columns>
|
||||||
<S.Column>
|
<S.Column>
|
||||||
<Heading level={3}>Headers</Heading>
|
<InputLabel>Headers</InputLabel>
|
||||||
<Controller
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
name="headers"
|
name="headers"
|
||||||
|
@ -214,7 +209,7 @@ const SendMessage: React.FC = () => {
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={!isDirty || isSubmitting}
|
disabled={!isDirty || isSubmitting}
|
||||||
>
|
>
|
||||||
Send
|
Produce Message
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</S.Wrapper>
|
</S.Wrapper>
|
||||||
|
|
|
@ -3,10 +3,7 @@ import SendMessage from 'components/Topics/Topic/SendMessage/SendMessage';
|
||||||
import { act, screen } from '@testing-library/react';
|
import { act, screen } from '@testing-library/react';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { render, WithRoute } from 'lib/testHelpers';
|
import { render, WithRoute } from 'lib/testHelpers';
|
||||||
import {
|
import { clusterTopicPath } from 'lib/paths';
|
||||||
clusterTopicMessagesRelativePath,
|
|
||||||
clusterTopicSendMessagePath,
|
|
||||||
} from 'lib/paths';
|
|
||||||
import validateMessage from 'components/Topics/Topic/SendMessage/validateMessage';
|
import validateMessage from 'components/Topics/Topic/SendMessage/validateMessage';
|
||||||
import { externalTopicPayload, topicMessageSchema } from 'lib/fixtures/topics';
|
import { externalTopicPayload, topicMessageSchema } from 'lib/fixtures/topics';
|
||||||
import {
|
import {
|
||||||
|
@ -35,12 +32,6 @@ jest.mock('lib/errorHandling', () => ({
|
||||||
showServerError: jest.fn(),
|
showServerError: jest.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const mockNavigate = jest.fn();
|
|
||||||
jest.mock('react-router-dom', () => ({
|
|
||||||
...jest.requireActual('react-router-dom'),
|
|
||||||
useNavigate: () => mockNavigate,
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock('lib/hooks/api/topics', () => ({
|
jest.mock('lib/hooks/api/topics', () => ({
|
||||||
useTopicDetails: jest.fn(),
|
useTopicDetails: jest.fn(),
|
||||||
useTopicMessageSchema: jest.fn(),
|
useTopicMessageSchema: jest.fn(),
|
||||||
|
@ -50,12 +41,14 @@ jest.mock('lib/hooks/api/topics', () => ({
|
||||||
const clusterName = 'testCluster';
|
const clusterName = 'testCluster';
|
||||||
const topicName = externalTopicPayload.name;
|
const topicName = externalTopicPayload.name;
|
||||||
|
|
||||||
|
const mockOnSubmit = jest.fn();
|
||||||
|
|
||||||
const renderComponent = async () => {
|
const renderComponent = async () => {
|
||||||
const path = clusterTopicSendMessagePath(clusterName, topicName);
|
const path = clusterTopicPath(clusterName, topicName);
|
||||||
await act(() => {
|
await act(() => {
|
||||||
render(
|
render(
|
||||||
<WithRoute path={clusterTopicSendMessagePath()}>
|
<WithRoute path={clusterTopicPath()}>
|
||||||
<SendMessage />
|
<SendMessage onSubmit={mockOnSubmit} />
|
||||||
</WithRoute>,
|
</WithRoute>,
|
||||||
{ initialEntries: [path] }
|
{ initialEntries: [path] }
|
||||||
);
|
);
|
||||||
|
@ -72,7 +65,7 @@ const renderAndSubmitData = async (error: string[] = []) => {
|
||||||
});
|
});
|
||||||
await act(() => {
|
await act(() => {
|
||||||
(validateMessage as Mock).mockImplementation(() => error);
|
(validateMessage as Mock).mockImplementation(() => error);
|
||||||
userEvent.click(screen.getByText('Send'));
|
userEvent.click(screen.getByText('Produce Message'));
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -83,10 +76,6 @@ describe('SendMessage', () => {
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
mockNavigate.mockClear();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('when schema is fetched', () => {
|
describe('when schema is fetched', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
(useTopicMessageSchema as jest.Mock).mockImplementation(() => ({
|
(useTopicMessageSchema as jest.Mock).mockImplementation(() => ({
|
||||||
|
@ -101,9 +90,7 @@ describe('SendMessage', () => {
|
||||||
}));
|
}));
|
||||||
await renderAndSubmitData();
|
await renderAndSubmitData();
|
||||||
expect(sendTopicMessageMock).toHaveBeenCalledTimes(1);
|
expect(sendTopicMessageMock).toHaveBeenCalledTimes(1);
|
||||||
expect(mockNavigate).toHaveBeenLastCalledWith(
|
expect(mockOnSubmit).toHaveBeenCalledTimes(1);
|
||||||
`../${clusterTopicMessagesRelativePath}`
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should check and view validation error message when is not valid', async () => {
|
it('should check and view validation error message when is not valid', async () => {
|
||||||
|
@ -113,7 +100,7 @@ describe('SendMessage', () => {
|
||||||
}));
|
}));
|
||||||
await renderAndSubmitData(['error']);
|
await renderAndSubmitData(['error']);
|
||||||
expect(sendTopicMessageMock).not.toHaveBeenCalled();
|
expect(sendTopicMessageMock).not.toHaveBeenCalled();
|
||||||
expect(mockNavigate).not.toHaveBeenCalled();
|
expect(mockOnSubmit).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -6,10 +6,8 @@ import {
|
||||||
clusterTopicSettingsRelativePath,
|
clusterTopicSettingsRelativePath,
|
||||||
clusterTopicConsumerGroupsRelativePath,
|
clusterTopicConsumerGroupsRelativePath,
|
||||||
clusterTopicEditRelativePath,
|
clusterTopicEditRelativePath,
|
||||||
clusterTopicSendMessageRelativePath,
|
|
||||||
clusterTopicStatisticsRelativePath,
|
clusterTopicStatisticsRelativePath,
|
||||||
clusterTopicsPath,
|
clusterTopicsPath,
|
||||||
clusterTopicSendMessagePath,
|
|
||||||
} from 'lib/paths';
|
} from 'lib/paths';
|
||||||
import ClusterContext from 'components/contexts/ClusterContext';
|
import ClusterContext from 'components/contexts/ClusterContext';
|
||||||
import PageHeading from 'components/common/PageHeading/PageHeading';
|
import PageHeading from 'components/common/PageHeading/PageHeading';
|
||||||
|
@ -33,8 +31,11 @@ import {
|
||||||
} from 'redux/reducers/topicMessages/topicMessagesSlice';
|
} from 'redux/reducers/topicMessages/topicMessagesSlice';
|
||||||
import { CleanUpPolicy } from 'generated-sources';
|
import { CleanUpPolicy } from 'generated-sources';
|
||||||
import PageLoader from 'components/common/PageLoader/PageLoader';
|
import PageLoader from 'components/common/PageLoader/PageLoader';
|
||||||
|
import SlidingSidebar from 'components/common/SlidingSidebar';
|
||||||
|
import useBoolean from 'lib/hooks/useBoolean';
|
||||||
|
|
||||||
import Messages from './Messages/Messages';
|
import Messages from './Messages/Messages';
|
||||||
|
import MessagesV2 from './MessagesV2/Messages';
|
||||||
import Overview from './Overview/Overview';
|
import Overview from './Overview/Overview';
|
||||||
import Settings from './Settings/Settings';
|
import Settings from './Settings/Settings';
|
||||||
import TopicConsumerGroups from './ConsumerGroups/TopicConsumerGroups';
|
import TopicConsumerGroups from './ConsumerGroups/TopicConsumerGroups';
|
||||||
|
@ -44,6 +45,11 @@ import SendMessage from './SendMessage/SendMessage';
|
||||||
|
|
||||||
const Topic: React.FC = () => {
|
const Topic: React.FC = () => {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
const {
|
||||||
|
value: isSidebarOpen,
|
||||||
|
setFalse: closeSidebar,
|
||||||
|
setTrue: openSidebar,
|
||||||
|
} = useBoolean(false);
|
||||||
const { clusterName, topicName } = useAppParams<RouteParamsClusterTopic>();
|
const { clusterName, topicName } = useAppParams<RouteParamsClusterTopic>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const deleteTopic = useDeleteTopic(clusterName);
|
const deleteTopic = useDeleteTopic(clusterName);
|
||||||
|
@ -76,7 +82,7 @@ const Topic: React.FC = () => {
|
||||||
<Button
|
<Button
|
||||||
buttonSize="M"
|
buttonSize="M"
|
||||||
buttonType="primary"
|
buttonType="primary"
|
||||||
to={clusterTopicSendMessagePath(clusterName, topicName)}
|
onClick={openSidebar}
|
||||||
disabled={isReadOnly}
|
disabled={isReadOnly}
|
||||||
>
|
>
|
||||||
Produce Message
|
Produce Message
|
||||||
|
@ -179,6 +185,7 @@ const Topic: React.FC = () => {
|
||||||
path={clusterTopicMessagesRelativePath}
|
path={clusterTopicMessagesRelativePath}
|
||||||
element={<Messages />}
|
element={<Messages />}
|
||||||
/>
|
/>
|
||||||
|
<Route path="v2" element={<MessagesV2 />} />
|
||||||
<Route
|
<Route
|
||||||
path={clusterTopicSettingsRelativePath}
|
path={clusterTopicSettingsRelativePath}
|
||||||
element={<Settings />}
|
element={<Settings />}
|
||||||
|
@ -192,12 +199,15 @@ const Topic: React.FC = () => {
|
||||||
element={<Statistics />}
|
element={<Statistics />}
|
||||||
/>
|
/>
|
||||||
<Route path={clusterTopicEditRelativePath} element={<Edit />} />
|
<Route path={clusterTopicEditRelativePath} element={<Edit />} />
|
||||||
<Route
|
|
||||||
path={clusterTopicSendMessageRelativePath}
|
|
||||||
element={<SendMessage />}
|
|
||||||
/>
|
|
||||||
</Routes>
|
</Routes>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
<SlidingSidebar
|
||||||
|
open={isSidebarOpen}
|
||||||
|
onClose={closeSidebar}
|
||||||
|
title="Produce Message"
|
||||||
|
>
|
||||||
|
<SendMessage onSubmit={closeSidebar} />
|
||||||
|
</SlidingSidebar>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -46,6 +46,9 @@ jest.mock('components/Topics/Topic/Overview/Overview', () => () => (
|
||||||
jest.mock('components/Topics/Topic/Messages/Messages', () => () => (
|
jest.mock('components/Topics/Topic/Messages/Messages', () => () => (
|
||||||
<>MessagesMock</>
|
<>MessagesMock</>
|
||||||
));
|
));
|
||||||
|
jest.mock('components/Topics/Topic/SendMessage/SendMessage', () => () => (
|
||||||
|
<>SendMessageMock</>
|
||||||
|
));
|
||||||
jest.mock('components/Topics/Topic/Settings/Settings', () => () => (
|
jest.mock('components/Topics/Topic/Settings/Settings', () => () => (
|
||||||
<>SettingsMock</>
|
<>SettingsMock</>
|
||||||
));
|
));
|
||||||
|
@ -98,9 +101,11 @@ describe('Details', () => {
|
||||||
});
|
});
|
||||||
describe('Action Bar', () => {
|
describe('Action Bar', () => {
|
||||||
describe('when it has readonly flag', () => {
|
describe('when it has readonly flag', () => {
|
||||||
it('renders disabled the Action button a Topic', () => {
|
it('renders disabled the Action button', () => {
|
||||||
renderComponent(true);
|
renderComponent(true);
|
||||||
expect(screen.getByText('Produce Message')).toBeDisabled();
|
expect(
|
||||||
|
screen.getByRole('button', { name: 'Produce Message' })
|
||||||
|
).toBeDisabled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { MenuProps } from '@szhsin/react-menu';
|
import { MenuProps } from '@szhsin/react-menu';
|
||||||
import React, { PropsWithChildren, useRef } from 'react';
|
import React, { PropsWithChildren, useRef } from 'react';
|
||||||
import VerticalElipsisIcon from 'components/common/Icons/VerticalElipsisIcon';
|
import VerticalElipsisIcon from 'components/common/Icons/VerticalElipsisIcon';
|
||||||
import useModal from 'lib/hooks/useModal';
|
import useBoolean from 'lib/hooks/useBoolean';
|
||||||
|
|
||||||
import * as S from './Dropdown.styled';
|
import * as S from './Dropdown.styled';
|
||||||
|
|
||||||
|
@ -12,12 +12,12 @@ interface DropdownProps extends PropsWithChildren<Partial<MenuProps>> {
|
||||||
|
|
||||||
const Dropdown: React.FC<DropdownProps> = ({ label, disabled, children }) => {
|
const Dropdown: React.FC<DropdownProps> = ({ label, disabled, children }) => {
|
||||||
const ref = useRef(null);
|
const ref = useRef(null);
|
||||||
const { isOpen, setClose, setOpen } = useModal(false);
|
const { value: isOpen, setFalse, setTrue } = useBoolean(false);
|
||||||
|
|
||||||
const handleClick: React.MouseEventHandler<HTMLButtonElement> = (e) => {
|
const handleClick: React.MouseEventHandler<HTMLButtonElement> = (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setOpen();
|
setTrue();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -33,8 +33,8 @@ const Dropdown: React.FC<DropdownProps> = ({ label, disabled, children }) => {
|
||||||
<S.Dropdown
|
<S.Dropdown
|
||||||
anchorRef={ref}
|
anchorRef={ref}
|
||||||
state={isOpen ? 'open' : 'closed'}
|
state={isOpen ? 'open' : 'closed'}
|
||||||
onMouseLeave={setClose}
|
onMouseLeave={setFalse}
|
||||||
onClose={setClose}
|
onClose={setFalse}
|
||||||
align="end"
|
align="end"
|
||||||
direction="bottom"
|
direction="bottom"
|
||||||
offsetY={10}
|
offsetY={10}
|
||||||
|
|
|
@ -2,7 +2,7 @@ import styled, { css } from 'styled-components';
|
||||||
|
|
||||||
export interface InputProps {
|
export interface InputProps {
|
||||||
inputSize?: 'S' | 'M' | 'L';
|
inputSize?: 'S' | 'M' | 'L';
|
||||||
hasLeftIcon: boolean;
|
search: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const INPUT_SIZES = {
|
const INPUT_SIZES = {
|
||||||
|
@ -14,7 +14,7 @@ const INPUT_SIZES = {
|
||||||
export const Wrapper = styled.div`
|
export const Wrapper = styled.div`
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
svg {
|
svg:first-child {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 8px;
|
top: 8px;
|
||||||
line-height: 0;
|
line-height: 0;
|
||||||
|
@ -28,14 +28,14 @@ export const Wrapper = styled.div`
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const Input = styled.input<InputProps>(
|
export const Input = styled.input<InputProps>(
|
||||||
({ theme: { input }, inputSize, hasLeftIcon }) => css`
|
({ theme: { input }, inputSize, search }) => css`
|
||||||
border: 1px ${input.borderColor.normal} solid;
|
border: 1px ${input.borderColor.normal} solid;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
height: ${inputSize && INPUT_SIZES[inputSize]
|
height: ${inputSize && INPUT_SIZES[inputSize]
|
||||||
? INPUT_SIZES[inputSize]
|
? INPUT_SIZES[inputSize]
|
||||||
: '40px'};
|
: '40px'};
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding-left: ${hasLeftIcon ? '36px' : '12px'};
|
padding-left: ${search ? '36px' : '12px'};
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
|
||||||
&::placeholder {
|
&::placeholder {
|
||||||
|
|
|
@ -6,7 +6,7 @@ import * as S from './Input.styled';
|
||||||
|
|
||||||
export interface InputProps
|
export interface InputProps
|
||||||
extends React.InputHTMLAttributes<HTMLInputElement>,
|
extends React.InputHTMLAttributes<HTMLInputElement>,
|
||||||
Omit<S.InputProps, 'hasLeftIcon'> {
|
Omit<S.InputProps, 'search'> {
|
||||||
name?: string;
|
name?: string;
|
||||||
hookFormOptions?: RegisterOptions;
|
hookFormOptions?: RegisterOptions;
|
||||||
search?: boolean;
|
search?: boolean;
|
||||||
|
@ -74,7 +74,7 @@ const Input: React.FC<InputProps> = ({
|
||||||
{search && <SearchIcon />}
|
{search && <SearchIcon />}
|
||||||
<S.Input
|
<S.Input
|
||||||
inputSize={inputSize}
|
inputSize={inputSize}
|
||||||
hasLeftIcon={!!search}
|
search={!!search}
|
||||||
type={type}
|
type={type}
|
||||||
onKeyPress={keyPressEventHandler}
|
onKeyPress={keyPressEventHandler}
|
||||||
onPaste={pasteEventHandler}
|
onPaste={pasteEventHandler}
|
||||||
|
|
|
@ -5,4 +5,10 @@ export const InputLabel = styled.label`
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 20px;
|
line-height: 20px;
|
||||||
color: ${({ theme }) => theme.input.label.color};
|
color: ${({ theme }) => theme.input.label.color};
|
||||||
|
|
||||||
|
input[type='checkbox'] {
|
||||||
|
display: inline-block;
|
||||||
|
margin-right: 8px;
|
||||||
|
vertical-align: text-top;
|
||||||
|
}
|
||||||
`;
|
`;
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import ReactMultiSelect from 'react-multi-select-component';
|
import { MultiSelect as ReactMultiSelect } from 'react-multi-select-component';
|
||||||
|
|
||||||
const MultiSelect = styled(ReactMultiSelect)<{ minWidth?: string }>`
|
const MultiSelect = styled(ReactMultiSelect)<{ minWidth?: string }>`
|
||||||
min-width: ${({ minWidth }) => minWidth || '200px;'};
|
min-width: ${({ minWidth }) => minWidth || '200px;'};
|
||||||
|
|
|
@ -3,6 +3,7 @@ import styled from 'styled-components';
|
||||||
const Navbar = styled.nav`
|
const Navbar = styled.nav`
|
||||||
display: flex;
|
display: flex;
|
||||||
border-bottom: 1px ${({ theme }) => theme.primaryTab.borderColor.nav} solid;
|
border-bottom: 1px ${({ theme }) => theme.primaryTab.borderColor.nav} solid;
|
||||||
|
height: ${({ theme }) => theme.primaryTab.height};
|
||||||
& a {
|
& a {
|
||||||
height: 40px;
|
height: 40px;
|
||||||
min-width: 96px;
|
min-width: 96px;
|
||||||
|
|
|
@ -203,3 +203,11 @@ export const PageInfo = styled.div`
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
margin-left: 16px;
|
margin-left: 16px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
export const Ellipsis = styled.div`
|
||||||
|
max-width: 300px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
display: block;
|
||||||
|
`;
|
||||||
|
|
|
@ -115,11 +115,11 @@ const Table: React.FC<TableProps<any>> = ({
|
||||||
pageCount,
|
pageCount,
|
||||||
columns,
|
columns,
|
||||||
getRowCanExpand,
|
getRowCanExpand,
|
||||||
renderSubComponent,
|
renderSubComponent: SubComponent,
|
||||||
serverSideProcessing = false,
|
serverSideProcessing = false,
|
||||||
enableSorting = false,
|
enableSorting = false,
|
||||||
enableRowSelection = false,
|
enableRowSelection = false,
|
||||||
batchActionsBar,
|
batchActionsBar: BatchActionsBar,
|
||||||
emptyMessage,
|
emptyMessage,
|
||||||
onRowClick,
|
onRowClick,
|
||||||
}) => {
|
}) => {
|
||||||
|
@ -170,8 +170,6 @@ const Table: React.FC<TableProps<any>> = ({
|
||||||
enableRowSelection,
|
enableRowSelection,
|
||||||
});
|
});
|
||||||
|
|
||||||
const Bar = batchActionsBar;
|
|
||||||
|
|
||||||
const handleRowClick = (row: Row<typeof data>) => (e: React.MouseEvent) => {
|
const handleRowClick = (row: Row<typeof data>) => (e: React.MouseEvent) => {
|
||||||
// If row selection is enabled do not handle row click.
|
// If row selection is enabled do not handle row click.
|
||||||
if (enableRowSelection) return undefined;
|
if (enableRowSelection) return undefined;
|
||||||
|
@ -192,9 +190,9 @@ const Table: React.FC<TableProps<any>> = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{table.getSelectedRowModel().flatRows.length > 0 && Bar && (
|
{table.getSelectedRowModel().flatRows.length > 0 && BatchActionsBar && (
|
||||||
<S.TableActionsBar>
|
<S.TableActionsBar>
|
||||||
<Bar
|
<BatchActionsBar
|
||||||
rows={table.getSelectedRowModel().flatRows}
|
rows={table.getSelectedRowModel().flatRows}
|
||||||
resetRowSelection={table.resetRowSelection}
|
resetRowSelection={table.resetRowSelection}
|
||||||
/>
|
/>
|
||||||
|
@ -269,11 +267,11 @@ const Table: React.FC<TableProps<any>> = ({
|
||||||
</td>
|
</td>
|
||||||
))}
|
))}
|
||||||
</S.Row>
|
</S.Row>
|
||||||
{row.getIsExpanded() && renderSubComponent && (
|
{row.getIsExpanded() && SubComponent && (
|
||||||
<S.Row expanded>
|
<S.Row expanded>
|
||||||
<td colSpan={row.getVisibleCells().length + 2}>
|
<td colSpan={row.getVisibleCells().length + 2}>
|
||||||
<S.ExpandedRowInfo>
|
<S.ExpandedRowInfo>
|
||||||
{renderSubComponent({ row })}
|
<SubComponent row={row} />
|
||||||
</S.ExpandedRowInfo>
|
</S.ExpandedRowInfo>
|
||||||
</td>
|
</td>
|
||||||
</S.Row>
|
</S.Row>
|
||||||
|
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { CellContext } from '@tanstack/react-table';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import * as S from './Table.styled';
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const TruncatedTextCell: React.FC<CellContext<any, unknown>> = ({
|
||||||
|
getValue,
|
||||||
|
}) => <S.Ellipsis>{getValue<string>()}</S.Ellipsis>;
|
||||||
|
|
||||||
|
export default TruncatedTextCell;
|
|
@ -5,7 +5,7 @@ export const List = styled.div`
|
||||||
grid-template-columns: repeat(2, max-content);
|
grid-template-columns: repeat(2, max-content);
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
column-gap: 24px;
|
column-gap: 24px;
|
||||||
margin-top: 16px;
|
margin: 16px 0;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
@ -15,3 +15,8 @@ export const Label = styled.div`
|
||||||
color: ${({ theme }) => theme.list.label.color};
|
color: ${({ theme }) => theme.list.label.color};
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
export const SubText = styled.div`
|
||||||
|
color: ${({ theme }) => theme.list.meta.color};
|
||||||
|
font-size: 12px;
|
||||||
|
`;
|
||||||
|
|
|
@ -1,31 +1,37 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useDebouncedCallback } from 'use-debounce';
|
import { useDebouncedCallback } from 'use-debounce';
|
||||||
import Input from 'components/common/Input/Input';
|
import Input from 'components/common/Input/Input';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
|
||||||
interface SearchProps {
|
interface SearchProps {
|
||||||
handleSearch: (value: string) => void;
|
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
value: string;
|
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
onChange?: (value: string) => void;
|
||||||
|
value?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Search: React.FC<SearchProps> = ({
|
const Search: React.FC<SearchProps> = ({
|
||||||
handleSearch,
|
|
||||||
placeholder = 'Search',
|
placeholder = 'Search',
|
||||||
value,
|
|
||||||
disabled = false,
|
disabled = false,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
}) => {
|
}) => {
|
||||||
const onChange = useDebouncedCallback(
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
(e) => handleSearch(e.target.value),
|
const handleChange = useDebouncedCallback((e) => {
|
||||||
300
|
if (onChange) {
|
||||||
);
|
onChange(e.target.value);
|
||||||
|
} else {
|
||||||
|
searchParams.set('q', e.target.value);
|
||||||
|
setSearchParams(searchParams);
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
onChange={onChange}
|
onChange={handleChange}
|
||||||
defaultValue={value}
|
defaultValue={value || searchParams.get('q') || ''}
|
||||||
inputSize="M"
|
inputSize="M"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
search
|
search
|
||||||
|
|
|
@ -3,42 +3,42 @@ import React from 'react';
|
||||||
import { render } from 'lib/testHelpers';
|
import { render } from 'lib/testHelpers';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { screen } from '@testing-library/react';
|
import { screen } from '@testing-library/react';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
|
||||||
jest.mock('use-debounce', () => ({
|
jest.mock('use-debounce', () => ({
|
||||||
useDebouncedCallback: (fn: (e: Event) => void) => fn,
|
useDebouncedCallback: (fn: (e: Event) => void) => fn,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const setSearchParamsMock = jest.fn();
|
||||||
|
jest.mock('react-router-dom', () => ({
|
||||||
|
...(jest.requireActual('react-router-dom') as object),
|
||||||
|
useSearchParams: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const placeholder = 'I am a search placeholder';
|
||||||
|
|
||||||
describe('Search', () => {
|
describe('Search', () => {
|
||||||
const handleSearch = jest.fn();
|
beforeEach(() => {
|
||||||
|
(useSearchParams as jest.Mock).mockImplementation(() => [
|
||||||
|
new URLSearchParams(),
|
||||||
|
setSearchParamsMock,
|
||||||
|
]);
|
||||||
|
});
|
||||||
it('calls handleSearch on input', () => {
|
it('calls handleSearch on input', () => {
|
||||||
render(
|
render(<Search placeholder={placeholder} />);
|
||||||
<Search
|
const input = screen.getByPlaceholderText(placeholder);
|
||||||
handleSearch={handleSearch}
|
|
||||||
value=""
|
|
||||||
placeholder="Search bt the Topic name"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
const input = screen.getByPlaceholderText('Search bt the Topic name');
|
|
||||||
userEvent.click(input);
|
userEvent.click(input);
|
||||||
userEvent.keyboard('value');
|
userEvent.keyboard('value');
|
||||||
expect(handleSearch).toHaveBeenCalledTimes(5);
|
expect(setSearchParamsMock).toHaveBeenCalledTimes(5);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('when placeholder is provided', () => {
|
it('when placeholder is provided', () => {
|
||||||
render(
|
render(<Search placeholder={placeholder} />);
|
||||||
<Search
|
expect(screen.getByPlaceholderText(placeholder)).toBeInTheDocument();
|
||||||
handleSearch={handleSearch}
|
|
||||||
value=""
|
|
||||||
placeholder="Search bt the Topic name"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
expect(
|
|
||||||
screen.getByPlaceholderText('Search bt the Topic name')
|
|
||||||
).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('when placeholder is not provided', () => {
|
it('when placeholder is not provided', () => {
|
||||||
render(<Search handleSearch={handleSearch} value="" />);
|
render(<Search />);
|
||||||
expect(screen.queryByPlaceholderText('Search')).toBeInTheDocument();
|
expect(screen.queryByPlaceholderText('Search')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -0,0 +1,36 @@
|
||||||
|
import styled from 'styled-components';
|
||||||
|
|
||||||
|
export const Wrapper = styled.div<{ $open?: boolean }>(
|
||||||
|
({ theme, $open }) => `
|
||||||
|
background-color: ${theme.layout.backgroundColor};
|
||||||
|
position: fixed;
|
||||||
|
top: ${theme.layout.navBarHeight};
|
||||||
|
bottom: 0;
|
||||||
|
width: 60vw;
|
||||||
|
right: calc(${$open ? '0px' : theme.layout.rightSidebarWidth} * -1);
|
||||||
|
box-shadow: -1px 0px 10px 0px rgba(0, 0, 0, 0.2);
|
||||||
|
transition: right 0.3s linear;
|
||||||
|
z-index: 200;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
border-bottom: 1px solid ${theme.layout.stuffBorderColor};
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
);
|
||||||
|
|
||||||
|
export const Content = styled.div<{ $open?: boolean }>(
|
||||||
|
({ theme }) => `
|
||||||
|
background-color: ${theme.layout.backgroundColor};
|
||||||
|
overflow-y: auto;
|
||||||
|
position: absolute;
|
||||||
|
top: 65px;
|
||||||
|
bottom: 16px;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
padding: 16px;
|
||||||
|
`
|
||||||
|
);
|
|
@ -0,0 +1,32 @@
|
||||||
|
import React, { PropsWithChildren } from 'react';
|
||||||
|
import Heading from 'components/common/heading/Heading.styled';
|
||||||
|
import { Button } from 'components/common/Button/Button';
|
||||||
|
|
||||||
|
import * as S from './SlidingSidebar.styled';
|
||||||
|
|
||||||
|
interface SlidingSidebarProps extends PropsWithChildren<unknown> {
|
||||||
|
open?: boolean;
|
||||||
|
title: string;
|
||||||
|
onClose?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SlidingSidebar: React.FC<SlidingSidebarProps> = ({
|
||||||
|
open,
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
onClose,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<S.Wrapper $open={open}>
|
||||||
|
<Heading level={3}>
|
||||||
|
<span>{title}</span>
|
||||||
|
<Button buttonSize="M" buttonType="primary" onClick={onClose}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</Heading>
|
||||||
|
<S.Content>{children}</S.Content>
|
||||||
|
</S.Wrapper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SlidingSidebar;
|
|
@ -0,0 +1,3 @@
|
||||||
|
import SlidingSidebar from './SlidingSidebar';
|
||||||
|
|
||||||
|
export default SlidingSidebar;
|
|
@ -204,17 +204,6 @@ describe('Paths', () => {
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
it('clusterTopicSendMessagePath', () => {
|
|
||||||
expect(paths.clusterTopicSendMessagePath(clusterName, topicId)).toEqual(
|
|
||||||
`${paths.clusterTopicPath(clusterName, topicId)}/message`
|
|
||||||
);
|
|
||||||
expect(paths.clusterTopicSendMessagePath()).toEqual(
|
|
||||||
paths.clusterTopicSendMessagePath(
|
|
||||||
RouteParams.clusterName,
|
|
||||||
RouteParams.topicName
|
|
||||||
)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
it('clusterTopicEditPath', () => {
|
it('clusterTopicEditPath', () => {
|
||||||
expect(paths.clusterTopicEditPath(clusterName, topicId)).toEqual(
|
expect(paths.clusterTopicEditPath(clusterName, topicId)).toEqual(
|
||||||
`${paths.clusterTopicPath(clusterName, topicId)}/edit`
|
`${paths.clusterTopicPath(clusterName, topicId)}/edit`
|
||||||
|
|
|
@ -51,6 +51,7 @@ export const NOT_SET = -1;
|
||||||
export const BYTES_IN_GB = 1_073_741_824;
|
export const BYTES_IN_GB = 1_073_741_824;
|
||||||
|
|
||||||
export const PER_PAGE = 25;
|
export const PER_PAGE = 25;
|
||||||
|
export const MESSAGES_PER_PAGE = '100';
|
||||||
|
|
||||||
export const GIT_REPO_LINK = 'https://github.com/provectus/kafka-ui';
|
export const GIT_REPO_LINK = 'https://github.com/provectus/kafka-ui';
|
||||||
export const GIT_REPO_LATEST_RELEASE_LINK =
|
export const GIT_REPO_LATEST_RELEASE_LINK =
|
||||||
|
@ -58,6 +59,8 @@ export const GIT_REPO_LATEST_RELEASE_LINK =
|
||||||
export const GIT_TAG = process.env.VITE_TAG;
|
export const GIT_TAG = process.env.VITE_TAG;
|
||||||
export const GIT_COMMIT = process.env.VITE_COMMIT;
|
export const GIT_COMMIT = process.env.VITE_COMMIT;
|
||||||
|
|
||||||
|
export const LOCAL_STORAGE_KEY_PREFIX = 'kafka-ui';
|
||||||
|
|
||||||
export enum AsyncRequestStatus {
|
export enum AsyncRequestStatus {
|
||||||
initial = 'initial',
|
initial = 'initial',
|
||||||
pending = 'pending',
|
pending = 'pending',
|
||||||
|
|
|
@ -10,3 +10,25 @@ export const formatTimestamp = (
|
||||||
|
|
||||||
return dayjs(timestamp).format(format);
|
return dayjs(timestamp).format(format);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const formatMilliseconds = (input = 0) => {
|
||||||
|
const milliseconds = Math.max(input || 0, 0);
|
||||||
|
|
||||||
|
const seconds = Math.floor(milliseconds / 1000);
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
|
||||||
|
if (hours > 0) {
|
||||||
|
return `${hours}h ${minutes % 60}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (minutes > 0) {
|
||||||
|
return `${minutes}m ${seconds % 60}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seconds > 0) {
|
||||||
|
return `${seconds}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${milliseconds}ms`;
|
||||||
|
};
|
||||||
|
|
|
@ -0,0 +1,66 @@
|
||||||
|
import { renderHook, act } from '@testing-library/react';
|
||||||
|
import useBoolean from 'lib/hooks/useBoolean';
|
||||||
|
|
||||||
|
describe('useBoolean CustomHook', () => {
|
||||||
|
it('should check true initial values', () => {
|
||||||
|
let initialValue = true;
|
||||||
|
const { result, rerender } = renderHook(() => useBoolean(initialValue));
|
||||||
|
expect(result.current.value).toBe(initialValue);
|
||||||
|
initialValue = false;
|
||||||
|
rerender();
|
||||||
|
// because state is in useState
|
||||||
|
expect(result.current.value).not.toBe(initialValue);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should check false initial values', () => {
|
||||||
|
let initialValue = false;
|
||||||
|
const { result, rerender } = renderHook(() => useBoolean(initialValue));
|
||||||
|
expect(result.current.value).toBe(initialValue);
|
||||||
|
|
||||||
|
initialValue = true;
|
||||||
|
rerender();
|
||||||
|
// because state is in useState
|
||||||
|
expect(result.current.value).not.toBe(initialValue);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should check setTrue function', () => {
|
||||||
|
const { result } = renderHook(() => useBoolean());
|
||||||
|
expect(result.current.value).toBeFalsy();
|
||||||
|
act(() => {
|
||||||
|
result.current.setTrue();
|
||||||
|
});
|
||||||
|
expect(result.current.value).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should check setFalse function', () => {
|
||||||
|
const { result } = renderHook(() => useBoolean());
|
||||||
|
|
||||||
|
expect(result.current.value).toBeFalsy();
|
||||||
|
act(() => {
|
||||||
|
result.current.setTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.value).toBeTruthy();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.setFalse();
|
||||||
|
});
|
||||||
|
expect(result.current.value).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should check setToggle function', () => {
|
||||||
|
const { result } = renderHook(() => useBoolean());
|
||||||
|
|
||||||
|
expect(result.current.value).toBeFalsy();
|
||||||
|
act(() => {
|
||||||
|
result.current.toggle();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.value).toBeTruthy();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.toggle();
|
||||||
|
});
|
||||||
|
expect(result.current.value).toBeFalsy();
|
||||||
|
});
|
||||||
|
});
|
|
@ -1,66 +0,0 @@
|
||||||
import { renderHook, act } from '@testing-library/react';
|
|
||||||
import useModal from 'lib/hooks/useModal';
|
|
||||||
|
|
||||||
describe('useModal CustomHook', () => {
|
|
||||||
it('should check true initial values', () => {
|
|
||||||
let initialValue = true;
|
|
||||||
const { result, rerender } = renderHook(() => useModal(initialValue));
|
|
||||||
expect(result.current.isOpen).toBe(initialValue);
|
|
||||||
initialValue = false;
|
|
||||||
rerender();
|
|
||||||
// because state is in useState
|
|
||||||
expect(result.current.isOpen).not.toBe(initialValue);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should check false initial values', () => {
|
|
||||||
let initialValue = false;
|
|
||||||
const { result, rerender } = renderHook(() => useModal(initialValue));
|
|
||||||
expect(result.current.isOpen).toBe(initialValue);
|
|
||||||
|
|
||||||
initialValue = true;
|
|
||||||
rerender();
|
|
||||||
// because state is in useState
|
|
||||||
expect(result.current.isOpen).not.toBe(initialValue);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should check setOpen function', () => {
|
|
||||||
const { result } = renderHook(() => useModal());
|
|
||||||
expect(result.current.isOpen).toBeFalsy();
|
|
||||||
act(() => {
|
|
||||||
result.current.setOpen();
|
|
||||||
});
|
|
||||||
expect(result.current.isOpen).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should check setClose function', () => {
|
|
||||||
const { result } = renderHook(() => useModal());
|
|
||||||
|
|
||||||
expect(result.current.isOpen).toBeFalsy();
|
|
||||||
act(() => {
|
|
||||||
result.current.setOpen();
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.current.isOpen).toBeTruthy();
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
result.current.setClose();
|
|
||||||
});
|
|
||||||
expect(result.current.isOpen).toBeFalsy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should check setToggle function', () => {
|
|
||||||
const { result } = renderHook(() => useModal());
|
|
||||||
|
|
||||||
expect(result.current.isOpen).toBeFalsy();
|
|
||||||
act(() => {
|
|
||||||
result.current.toggle();
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.current.isOpen).toBeTruthy();
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
result.current.toggle();
|
|
||||||
});
|
|
||||||
expect(result.current.isOpen).toBeFalsy();
|
|
||||||
});
|
|
||||||
});
|
|
177
kafka-ui-react-app/src/lib/hooks/api/topicMessages.tsx
Normal file
177
kafka-ui-react-app/src/lib/hooks/api/topicMessages.tsx
Normal file
|
@ -0,0 +1,177 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { fetchEventSource } from '@microsoft/fetch-event-source';
|
||||||
|
import { BASE_PARAMS, MESSAGES_PER_PAGE } from 'lib/constants';
|
||||||
|
import { ClusterName } from 'redux/interfaces';
|
||||||
|
import {
|
||||||
|
SeekDirection,
|
||||||
|
SeekType,
|
||||||
|
TopicMessage,
|
||||||
|
TopicMessageConsuming,
|
||||||
|
TopicMessageEvent,
|
||||||
|
TopicMessageEventTypeEnum,
|
||||||
|
} from 'generated-sources';
|
||||||
|
import { showServerError } from 'lib/errorHandling';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
import { StopLoading } from 'components/Topics/Topic/MessagesV2/FiltersBar/FiltersBar.styled';
|
||||||
|
|
||||||
|
interface UseTopicMessagesProps {
|
||||||
|
clusterName: ClusterName;
|
||||||
|
topicName: string;
|
||||||
|
searchParams: URLSearchParams;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ConsumingMode =
|
||||||
|
| 'live'
|
||||||
|
| 'oldest'
|
||||||
|
| 'newest'
|
||||||
|
| 'fromOffset' // from 900 -> 1000
|
||||||
|
| 'toOffset' // from 900 -> 800
|
||||||
|
| 'sinceTime' // from 10:15 -> 11:15
|
||||||
|
| 'untilTime'; // from 10:15 -> 9:15
|
||||||
|
|
||||||
|
export const useTopicMessages = ({
|
||||||
|
clusterName,
|
||||||
|
topicName,
|
||||||
|
searchParams,
|
||||||
|
}: UseTopicMessagesProps) => {
|
||||||
|
const [messages, setMessages] = React.useState<TopicMessage[]>([]);
|
||||||
|
const [phase, setPhase] = React.useState<string>();
|
||||||
|
const [meta, setMeta] = React.useState<TopicMessageConsuming>();
|
||||||
|
const [isFetching, setIsFetching] = React.useState<boolean>(false);
|
||||||
|
const abortController = new AbortController();
|
||||||
|
|
||||||
|
// get initial properties
|
||||||
|
const mode = searchParams.get('m') as ConsumingMode;
|
||||||
|
const limit = searchParams.get('perPage') || MESSAGES_PER_PAGE;
|
||||||
|
const seekTo = searchParams.get('seekTo') || '0-0';
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
setIsFetching(true);
|
||||||
|
const url = `${BASE_PARAMS.basePath}/api/clusters/${clusterName}/topics/${topicName}/messages`;
|
||||||
|
const requestParams = new URLSearchParams({
|
||||||
|
limit,
|
||||||
|
seekTo: seekTo.replaceAll('-', '::').replaceAll('.', ','),
|
||||||
|
q: searchParams.get('q') || '',
|
||||||
|
});
|
||||||
|
|
||||||
|
switch (mode) {
|
||||||
|
case 'live':
|
||||||
|
requestParams.set('seekDirection', SeekDirection.TAILING);
|
||||||
|
requestParams.set('seekType', SeekType.LATEST);
|
||||||
|
break;
|
||||||
|
case 'oldest':
|
||||||
|
requestParams.set('seekType', SeekType.BEGINNING);
|
||||||
|
requestParams.set('seekDirection', SeekDirection.FORWARD);
|
||||||
|
break;
|
||||||
|
case 'newest':
|
||||||
|
requestParams.set('seekType', SeekType.LATEST);
|
||||||
|
requestParams.set('seekDirection', SeekDirection.BACKWARD);
|
||||||
|
break;
|
||||||
|
case 'fromOffset':
|
||||||
|
requestParams.set('seekType', SeekType.OFFSET);
|
||||||
|
requestParams.set('seekDirection', SeekDirection.FORWARD);
|
||||||
|
break;
|
||||||
|
case 'toOffset':
|
||||||
|
requestParams.set('seekType', SeekType.OFFSET);
|
||||||
|
requestParams.set('seekDirection', SeekDirection.BACKWARD);
|
||||||
|
break;
|
||||||
|
case 'sinceTime':
|
||||||
|
requestParams.set('seekType', SeekType.TIMESTAMP);
|
||||||
|
requestParams.set('seekDirection', SeekDirection.FORWARD);
|
||||||
|
break;
|
||||||
|
case 'untilTime':
|
||||||
|
requestParams.set('seekType', SeekType.TIMESTAMP);
|
||||||
|
requestParams.set('seekDirection', SeekDirection.BACKWARD);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
await fetchEventSource(`${url}?${requestParams.toString()}`, {
|
||||||
|
method: 'GET',
|
||||||
|
signal: abortController.signal,
|
||||||
|
openWhenHidden: true,
|
||||||
|
async onopen(response) {
|
||||||
|
const { ok, status } = response;
|
||||||
|
if (ok && status === 200) {
|
||||||
|
// Reset list of messages.
|
||||||
|
setMessages([]);
|
||||||
|
} else if (status >= 400 && status < 500 && status !== 429) {
|
||||||
|
showServerError(response);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onmessage(event) {
|
||||||
|
const parsedData: TopicMessageEvent = JSON.parse(event.data);
|
||||||
|
const { message, consuming } = parsedData;
|
||||||
|
|
||||||
|
switch (parsedData.type) {
|
||||||
|
case TopicMessageEventTypeEnum.MESSAGE:
|
||||||
|
if (message) {
|
||||||
|
setMessages((prevMessages) => {
|
||||||
|
if (mode === 'live') {
|
||||||
|
return [message, ...prevMessages];
|
||||||
|
}
|
||||||
|
return [...prevMessages, message];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case TopicMessageEventTypeEnum.PHASE:
|
||||||
|
if (parsedData.phase?.name) setPhase(parsedData.phase.name);
|
||||||
|
break;
|
||||||
|
case TopicMessageEventTypeEnum.CONSUMING:
|
||||||
|
if (consuming) setMeta(consuming);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onclose() {
|
||||||
|
setIsFetching(false);
|
||||||
|
},
|
||||||
|
onerror(err) {
|
||||||
|
setIsFetching(false);
|
||||||
|
showServerError(err);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const abortFetchData = () => {
|
||||||
|
setIsFetching(false);
|
||||||
|
abortController.abort();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (mode === 'live') {
|
||||||
|
toast.promise(
|
||||||
|
fetchData(),
|
||||||
|
{
|
||||||
|
loading: (
|
||||||
|
<>
|
||||||
|
<div>Consuming messages...</div>
|
||||||
|
|
||||||
|
<StopLoading onClick={abortFetchData}>Abort</StopLoading>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
success: 'Cancelled',
|
||||||
|
error: 'Something went wrong. Please try again.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'messages',
|
||||||
|
position: 'top-center',
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore - missing type for icon
|
||||||
|
success: { duration: 10, icon: false },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
fetchData();
|
||||||
|
}
|
||||||
|
|
||||||
|
return abortFetchData;
|
||||||
|
}, [searchParams]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
phase,
|
||||||
|
messages,
|
||||||
|
meta,
|
||||||
|
isFetching,
|
||||||
|
};
|
||||||
|
};
|
21
kafka-ui-react-app/src/lib/hooks/useBoolean.ts
Normal file
21
kafka-ui-react-app/src/lib/hooks/useBoolean.ts
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
|
||||||
|
interface ReturnType {
|
||||||
|
value: boolean;
|
||||||
|
setTrue: () => void;
|
||||||
|
setFalse: () => void;
|
||||||
|
toggle: () => void;
|
||||||
|
setValue: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function useBoolean(defaultValue?: boolean): ReturnType {
|
||||||
|
const [value, setValue] = useState(!!defaultValue);
|
||||||
|
|
||||||
|
const setTrue = useCallback(() => setValue(true), []);
|
||||||
|
const setFalse = useCallback(() => setValue(false), []);
|
||||||
|
const toggle = useCallback(() => setValue((x) => !x), []);
|
||||||
|
|
||||||
|
return { value, setValue, setTrue, setFalse, toggle };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useBoolean;
|
20
kafka-ui-react-app/src/lib/hooks/useLocalStorage.ts
Normal file
20
kafka-ui-react-app/src/lib/hooks/useLocalStorage.ts
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
import { LOCAL_STORAGE_KEY_PREFIX } from 'lib/constants';
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
export const useLocalStorage = (featureKey: string, defaultValue: string) => {
|
||||||
|
const key = `${LOCAL_STORAGE_KEY_PREFIX}-${featureKey}`;
|
||||||
|
const [value, setValue] = useState(() => {
|
||||||
|
const saved = localStorage.getItem(key);
|
||||||
|
|
||||||
|
if (saved !== null) {
|
||||||
|
return JSON.parse(saved);
|
||||||
|
}
|
||||||
|
return defaultValue;
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem(key, JSON.stringify(value));
|
||||||
|
}, [key, value]);
|
||||||
|
|
||||||
|
return [value, setValue];
|
||||||
|
};
|
41
kafka-ui-react-app/src/lib/hooks/useMessageFiltersStore.ts
Normal file
41
kafka-ui-react-app/src/lib/hooks/useMessageFiltersStore.ts
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
import { LOCAL_STORAGE_KEY_PREFIX } from 'lib/constants';
|
||||||
|
import create from 'zustand';
|
||||||
|
import { persist } from 'zustand/middleware';
|
||||||
|
|
||||||
|
export interface AdvancedFilter {
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MessageFiltersState {
|
||||||
|
filters: AdvancedFilter[];
|
||||||
|
activeFilter?: AdvancedFilter;
|
||||||
|
save: (filter: AdvancedFilter) => void;
|
||||||
|
apply: (filter: AdvancedFilter) => void;
|
||||||
|
remove: (name: string) => void;
|
||||||
|
update: (name: string, filter: AdvancedFilter) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useMessageFiltersStore = create<MessageFiltersState>()(
|
||||||
|
persist(
|
||||||
|
(set) => ({
|
||||||
|
filters: [],
|
||||||
|
save: (filter) =>
|
||||||
|
set((state) => ({
|
||||||
|
filters: [...state.filters, filter],
|
||||||
|
})),
|
||||||
|
apply: (filter) => set(() => ({ activeFilter: filter })),
|
||||||
|
remove: (name) =>
|
||||||
|
set((state) => ({
|
||||||
|
filters: state.filters.filter((f) => f.name !== name),
|
||||||
|
})),
|
||||||
|
update: (name, filter) =>
|
||||||
|
set((state) => ({
|
||||||
|
filters: state.filters.map((f) => (f.name === name ? filter : f)),
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: `${LOCAL_STORAGE_KEY_PREFIX}-message-filters`,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
|
@ -1,32 +0,0 @@
|
||||||
import { useCallback, useState } from 'react';
|
|
||||||
|
|
||||||
interface UseModalReturn {
|
|
||||||
isOpen: boolean;
|
|
||||||
setOpen(): void;
|
|
||||||
setClose(): void;
|
|
||||||
toggle(): void;
|
|
||||||
}
|
|
||||||
const useModal = (initialModalState?: boolean): UseModalReturn => {
|
|
||||||
const [modalOpen, setModalOpen] = useState<boolean>(!!initialModalState);
|
|
||||||
|
|
||||||
const setOpen = useCallback(() => {
|
|
||||||
setModalOpen(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const setClose = useCallback(() => {
|
|
||||||
setModalOpen(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const toggle = useCallback(() => {
|
|
||||||
setModalOpen((prev) => !prev);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return {
|
|
||||||
isOpen: modalOpen,
|
|
||||||
setOpen,
|
|
||||||
setClose,
|
|
||||||
toggle,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export default useModal;
|
|
|
@ -148,7 +148,6 @@ export const clusterTopicMessagesRelativePath = 'messages';
|
||||||
export const clusterTopicConsumerGroupsRelativePath = 'consumer-groups';
|
export const clusterTopicConsumerGroupsRelativePath = 'consumer-groups';
|
||||||
export const clusterTopicStatisticsRelativePath = 'statistics';
|
export const clusterTopicStatisticsRelativePath = 'statistics';
|
||||||
export const clusterTopicEditRelativePath = 'edit';
|
export const clusterTopicEditRelativePath = 'edit';
|
||||||
export const clusterTopicSendMessageRelativePath = 'message';
|
|
||||||
export const clusterTopicPath = (
|
export const clusterTopicPath = (
|
||||||
clusterName: ClusterName = RouteParams.clusterName,
|
clusterName: ClusterName = RouteParams.clusterName,
|
||||||
topicName: TopicName = RouteParams.topicName
|
topicName: TopicName = RouteParams.topicName
|
||||||
|
@ -190,14 +189,6 @@ export const clusterTopicStatisticsPath = (
|
||||||
clusterName,
|
clusterName,
|
||||||
topicName
|
topicName
|
||||||
)}/${clusterTopicStatisticsRelativePath}`;
|
)}/${clusterTopicStatisticsRelativePath}`;
|
||||||
export const clusterTopicSendMessagePath = (
|
|
||||||
clusterName: ClusterName = RouteParams.clusterName,
|
|
||||||
topicName: TopicName = RouteParams.topicName
|
|
||||||
) =>
|
|
||||||
`${clusterTopicPath(
|
|
||||||
clusterName,
|
|
||||||
topicName
|
|
||||||
)}/${clusterTopicSendMessageRelativePath}`;
|
|
||||||
|
|
||||||
export type RouteParamsClusterTopic = {
|
export type RouteParamsClusterTopic = {
|
||||||
clusterName: ClusterName;
|
clusterName: ClusterName;
|
||||||
|
|
|
@ -75,6 +75,9 @@ const theme = {
|
||||||
label: {
|
label: {
|
||||||
color: Colors.neutral[50],
|
color: Colors.neutral[50],
|
||||||
},
|
},
|
||||||
|
meta: {
|
||||||
|
color: Colors.neutral[30],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
progressBar: {
|
progressBar: {
|
||||||
backgroundColor: Colors.neutral[3],
|
backgroundColor: Colors.neutral[3],
|
||||||
|
@ -86,6 +89,8 @@ const theme = {
|
||||||
minWidth: '1200px',
|
minWidth: '1200px',
|
||||||
navBarWidth: '201px',
|
navBarWidth: '201px',
|
||||||
navBarHeight: '53px',
|
navBarHeight: '53px',
|
||||||
|
rightSidebarWidth: '70vw',
|
||||||
|
|
||||||
stuffColor: Colors.neutral[5],
|
stuffColor: Colors.neutral[5],
|
||||||
stuffBorderColor: Colors.neutral[10],
|
stuffBorderColor: Colors.neutral[10],
|
||||||
overlay: {
|
overlay: {
|
||||||
|
@ -96,6 +101,7 @@ const theme = {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
pageHeading: {
|
pageHeading: {
|
||||||
|
height: '64px',
|
||||||
dividerColor: Colors.neutral[30],
|
dividerColor: Colors.neutral[30],
|
||||||
backLink: {
|
backLink: {
|
||||||
color: {
|
color: {
|
||||||
|
@ -350,6 +356,7 @@ const theme = {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
primaryTab: {
|
primaryTab: {
|
||||||
|
height: '41px',
|
||||||
color: {
|
color: {
|
||||||
normal: Colors.neutral[50],
|
normal: Colors.neutral[50],
|
||||||
hover: Colors.neutral[90],
|
hover: Colors.neutral[90],
|
||||||
|
|
Loading…
Add table
Reference in a new issue