Topic settings editing (#55)

* Topic editing

* Remove old code

* Implement unique field name select

* Final changes to topic editing

* Cleanup eslint.json
This commit is contained in:
Sergey Zakirov 2020-06-16 12:24:24 +03:00 committed by GitHub
parent 5a0b23ed59
commit 128c0d2e92
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
39 changed files with 5883 additions and 3552 deletions

2
.gitignore vendored
View file

@ -30,3 +30,5 @@ build/
### VS Code ###
.vscode/
/kafka-ui-api/app/node
.DS_Store

File diff suppressed because it is too large Load diff

View file

@ -3,37 +3,21 @@
"version": "0.1.0",
"private": true,
"dependencies": {
"@testing-library/jest-dom": "^4.2.4",
"@testing-library/react": "^9.3.2",
"@testing-library/user-event": "^7.1.2",
"@types/classnames": "^2.2.9",
"@types/jest": "^24.0.25",
"@types/lodash": "^4.14.149",
"@types/node": "^12.12.24",
"@types/react": "^16.9.17",
"@types/react-dom": "^16.9.0",
"@types/react-redux": "^7.1.5",
"@types/react-router-dom": "^5.1.3",
"@types/redux": "^3.6.0",
"@types/redux-thunk": "^2.1.0",
"bulma": "^0.8.0",
"bulma-switch": "^2.0.0",
"classnames": "^2.2.6",
"json-server": "^0.15.1",
"immer": "^6.0.5",
"lodash": "^4.17.15",
"node-sass": "^4.13.1",
"pretty-ms": "^6.0.1",
"react": "^16.12.0",
"react-dom": "^16.12.0",
"react-hook-form": "^4.5.5",
"react-redux": "^7.1.3",
"react-router-dom": "^5.1.2",
"react-scripts": "3.3.0",
"redux": "^4.0.5",
"redux-thunk": "^2.3.0",
"reselect": "^4.0.0",
"typesafe-actions": "^5.1.0",
"typescript": "~3.7.4"
"typesafe-actions": "^5.1.0"
},
"lint-staged": {
"*.{js,ts,jsx,tsx}": [
@ -70,6 +54,19 @@
]
},
"devDependencies": {
"@testing-library/jest-dom": "^4.2.4",
"@testing-library/react": "^9.3.2",
"@testing-library/user-event": "^7.1.2",
"@types/classnames": "^2.2.9",
"@types/jest": "^24.0.25",
"@types/lodash": "^4.14.149",
"@types/node": "^12.12.24",
"@types/react": "^16.9.17",
"@types/react-dom": "^16.9.0",
"@types/react-redux": "^7.1.5",
"@types/react-router-dom": "^5.1.3",
"@types/redux": "^3.6.0",
"@types/redux-thunk": "^2.1.0",
"@typescript-eslint/eslint-plugin": "^2.27.0",
"@typescript-eslint/parser": "^2.27.0",
"eslint": "^6.8.0",
@ -82,7 +79,12 @@
"eslint-plugin-react-hooks": "^2.5.1",
"esprint": "^0.6.0",
"husky": "^4.2.5",
"json-server": "^0.15.1",
"lint-staged": ">=10",
"prettier": "^2.0.4"
}
"node-sass": "^4.13.1",
"prettier": "^2.0.4",
"react-scripts": "3.4.0",
"typescript": "~3.7.4"
},
"proxy": "http://localhost:8080"
}

View file

@ -7,10 +7,12 @@ import {
clusterTopicSettingsPath,
clusterTopicPath,
clusterTopicMessagesPath,
clusterTopicsTopicEditPath,
} from 'lib/paths';
import OverviewContainer from './Overview/OverviewContainer';
import MessagesContainer from './Messages/MessagesContainer';
import SettingsContainer from './Settings/SettingsContainer';
import SettingsEditButton from './Settings/SettingsEditButton';
interface Props extends Topic, TopicDetails {
clusterName: ClusterName;
@ -30,6 +32,9 @@ const Details: React.FC<Props> = ({ clusterName, topicName }) => {
{topicName}
</Breadcrumb>
</div>
<SettingsEditButton
to={clusterTopicsTopicEditPath(clusterName, topicName)}
/>
</div>
<div className="box">

View file

@ -0,0 +1,16 @@
import React from 'react';
import { Link } from 'react-router-dom';
interface Props {
to: string;
}
const SettingsEditButton: React.FC<Props> = ({ to }) => (
<Link to={to}>
<button type="button" className="button is-small is-warning">
Edit settings
</button>
</Link>
);
export default SettingsEditButton;

View file

@ -0,0 +1,143 @@
import React from 'react';
import {
ClusterName,
TopicFormData,
TopicName,
TopicConfigByName,
TopicWithDetailedInfo,
CleanupPolicy,
} from 'redux/interfaces';
import { useForm, FormContext } from 'react-hook-form';
import { camelCase } from 'lodash';
import TopicForm from '../shared/Form/TopicForm';
import FormBreadcrumbs from '../shared/Form/FormBreadcrumbs';
interface Props {
clusterName: ClusterName;
topicName: TopicName;
topic?: TopicWithDetailedInfo;
isFetched: boolean;
isTopicDetailsFetched: boolean;
isTopicUpdated: boolean;
fetchTopicDetails: (clusterName: ClusterName, topicName: TopicName) => void;
fetchTopicConfig: (clusterName: ClusterName, topicName: TopicName) => void;
updateTopic: (clusterName: ClusterName, form: TopicFormData) => void;
redirectToTopicPath: (clusterName: ClusterName, topicName: TopicName) => void;
resetUploadedState: () => void;
}
const DEFAULTS = {
partitions: 1,
replicationFactor: 1,
minInSyncReplicas: 1,
cleanupPolicy: CleanupPolicy.Delete,
retentionBytes: -1,
maxMessageBytes: 1000012,
};
const topicParams = (topic: TopicWithDetailedInfo | undefined) => {
if (!topic) {
return DEFAULTS;
}
const { name, replicationFactor } = topic;
const configs = topic.config?.reduce(
(result: { [name: string]: string }, param) => {
result[camelCase(param.name)] = param.value || param.defaultValue;
return result;
},
{}
);
return {
...DEFAULTS,
name,
partitions: topic.partitionCount || DEFAULTS.partitions,
replicationFactor,
...configs,
};
};
let formInit = false;
const Edit: React.FC<Props> = ({
clusterName,
topicName,
topic,
isFetched,
isTopicDetailsFetched,
isTopicUpdated,
fetchTopicDetails,
fetchTopicConfig,
updateTopic,
redirectToTopicPath,
}) => {
const defaultValues = topicParams(topic);
const methods = useForm<TopicFormData>({ defaultValues });
const [isSubmitting, setIsSubmitting] = React.useState<boolean>(false);
React.useEffect(() => {
fetchTopicConfig(clusterName, topicName);
fetchTopicDetails(clusterName, topicName);
}, [fetchTopicConfig, fetchTopicDetails, clusterName, topicName]);
React.useEffect(() => {
if (isSubmitting && isTopicUpdated) {
const { name } = methods.getValues();
redirectToTopicPath(clusterName, name);
}
}, [isSubmitting, isTopicUpdated, redirectToTopicPath, clusterName, methods]);
if (!isFetched || !isTopicDetailsFetched || !topic || !topic.config) {
return null;
}
if (!formInit) {
methods.reset(defaultValues);
formInit = true;
}
const config: TopicConfigByName = {
byName: {},
};
topic.config.forEach((param) => {
config.byName[param.name] = param;
});
const onSubmit = async (data: TopicFormData) => {
setIsSubmitting(true);
updateTopic(clusterName, data);
};
return (
<div className="section">
<div className="level">
<FormBreadcrumbs
clusterName={clusterName}
topicName={topicName}
current="Edit Topic"
/>
</div>
<div className="box">
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
<FormContext {...methods}>
<TopicForm
topicName={topicName}
config={config}
isSubmitting={isSubmitting}
isEditing
onSubmit={methods.handleSubmit(onSubmit)}
/>
</FormContext>
</div>
</div>
);
};
export default Edit;

View file

@ -0,0 +1,63 @@
import { connect } from 'react-redux';
import {
RootState,
ClusterName,
TopicFormData,
TopicName,
Action,
} from 'redux/interfaces';
import { withRouter, RouteComponentProps } from 'react-router-dom';
import {
updateTopic,
fetchTopicConfig,
fetchTopicDetails,
} from 'redux/actions';
import {
getTopicConfigFetched,
getTopicUpdated,
getIsTopicDetailsFetched,
getFullTopic,
} from 'redux/reducers/topics/selectors';
import { clusterTopicPath } from 'lib/paths';
import { ThunkDispatch } from 'redux-thunk';
import Edit from './Edit';
interface RouteProps {
clusterName: ClusterName;
topicName: TopicName;
}
type OwnProps = RouteComponentProps<RouteProps>;
const mapStateToProps = (
state: RootState,
{
match: {
params: { topicName, clusterName },
},
}: OwnProps
) => ({
clusterName,
topicName,
topic: getFullTopic(state, topicName),
isFetched: getTopicConfigFetched(state),
isTopicDetailsFetched: getIsTopicDetailsFetched(state),
isTopicUpdated: getTopicUpdated(state),
});
const mapDispatchToProps = (
dispatch: ThunkDispatch<RootState, undefined, Action>,
{ history }: OwnProps
) => ({
fetchTopicDetails: (clusterName: ClusterName, topicName: TopicName) =>
dispatch(fetchTopicDetails(clusterName, topicName)),
fetchTopicConfig: (clusterName: ClusterName, topicName: TopicName) =>
dispatch(fetchTopicConfig(clusterName, topicName)),
updateTopic: (clusterName: ClusterName, form: TopicFormData) =>
dispatch(updateTopic(clusterName, form)),
redirectToTopicPath: (clusterName: ClusterName, topicName: TopicName) => {
history.push(clusterTopicPath(clusterName, topicName));
},
});
export default withRouter(connect(mapStateToProps, mapDispatchToProps)(Edit));

View file

@ -1,20 +0,0 @@
import React from 'react';
import { TopicCustomParamOption } from 'redux/interfaces';
import { CUSTOM_PARAMS_OPTIONS } from './customParamsOptions';
interface Props {};
const CustomParamOptions: React.FC<Props> = () => (
<>
<option value=''>Select</option>
{
Object.values(CUSTOM_PARAMS_OPTIONS).map((opt: TopicCustomParamOption) => (
<option key={opt.name} value={opt.name}>
{opt.name}
</option>
))
}
</>
);
export default React.memo(CustomParamOptions);

View file

@ -1,87 +0,0 @@
import React from 'react';
import { omit, reject } from 'lodash';
import { TopicFormCustomParams } from 'redux/interfaces';
import CustomParamSelect from './CustomParamSelect';
import CustomParamValue from './CustomParamValue';
import CustomParamAction from './CustomParamAction';
import CustomParamButton, { CustomParamButtonType } from './CustomParamButton';
export const INDEX_PREFIX = 'customParams';
interface Props {
isSubmitting: boolean;
}
const CustomParams: React.FC<Props> = ({ isSubmitting }) => {
const [formCustomParams, setFormCustomParams] = React.useState<
TopicFormCustomParams
>({
byIndex: {},
allIndexes: [],
});
const onAdd = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
const newIndex = `${INDEX_PREFIX}.${new Date().getTime()}ts`;
setFormCustomParams({
...formCustomParams,
byIndex: {
...formCustomParams.byIndex,
[newIndex]: { name: '', value: '' },
},
allIndexes: [newIndex, ...formCustomParams.allIndexes],
});
};
const onRemove = (index: string) => {
setFormCustomParams({
...formCustomParams,
byIndex: omit(formCustomParams.byIndex, index),
allIndexes: reject(formCustomParams.allIndexes, (i) => i === index),
});
};
return (
<>
<div className="columns">
<div className="column">
<CustomParamButton
className="is-success"
type={CustomParamButtonType.plus}
onClick={onAdd}
btnText="Add Custom Parameter"
/>
</div>
</div>
{formCustomParams.allIndexes.map((index) => (
<div className="columns is-centered" key={index}>
<div className="column">
<CustomParamSelect
index={index}
isDisabled={isSubmitting}
name={formCustomParams.byIndex[index].name}
/>
</div>
<div className="column">
<CustomParamValue
index={index}
isDisabled={isSubmitting}
name={formCustomParams.byIndex[index].name}
defaultValue={formCustomParams.byIndex[index].value}
/>
</div>
<div className="column is-narrow">
<CustomParamAction index={index} onRemove={onRemove} />
</div>
</div>
))}
</>
);
};
export default CustomParams;

View file

@ -1,18 +0,0 @@
import { connect } from 'react-redux';
import { RootState } from 'redux/interfaces';
import { withRouter, RouteComponentProps } from 'react-router-dom';
import CustomParams from './CustomParams';
interface RouteProps {};
interface OwnProps extends RouteComponentProps<RouteProps> {
isSubmitting: boolean;
}
const mapStateToProps = (state: RootState, { isSubmitting }: OwnProps) => ({
isSubmitting,
})
export default withRouter(
connect(mapStateToProps)(CustomParams)
);

View file

@ -1,96 +0,0 @@
import { TopicCustomParamOption } from 'redux/interfaces';
interface CustomParamOption {
[optionName: string]: TopicCustomParamOption;
}
export const CUSTOM_PARAMS_OPTIONS: CustomParamOption = {
"compression.type": {
"name": "compression.type",
"defaultValue": "producer"
},
"leader.replication.throttled.replicas": {
"name": "leader.replication.throttled.replicas",
"defaultValue": ""
},
"message.downconversion.enable": {
"name": "message.downconversion.enable",
"defaultValue": "true"
},
"segment.jitter.ms": {
"name": "segment.jitter.ms",
"defaultValue": "0"
},
"flush.ms": {
"name": "flush.ms",
"defaultValue": "9223372036854775807"
},
"follower.replication.throttled.replicas": {
"name": "follower.replication.throttled.replicas",
"defaultValue": ""
},
"segment.bytes": {
"name": "segment.bytes",
"defaultValue": "1073741824"
},
"flush.messages": {
"name": "flush.messages",
"defaultValue": "9223372036854775807"
},
"message.format.version": {
"name": "message.format.version",
"defaultValue": "2.3-IV1"
},
"file.delete.delay.ms": {
"name": "file.delete.delay.ms",
"defaultValue": "60000"
},
"max.compaction.lag.ms": {
"name": "max.compaction.lag.ms",
"defaultValue": "9223372036854775807"
},
"min.compaction.lag.ms": {
"name": "min.compaction.lag.ms",
"defaultValue": "0"
},
"message.timestamp.type": {
"name": "message.timestamp.type",
"defaultValue": "CreateTime"
},
"preallocate": {
"name": "preallocate",
"defaultValue": "false"
},
"min.cleanable.dirty.ratio": {
"name": "min.cleanable.dirty.ratio",
"defaultValue": "0.5"
},
"index.interval.bytes": {
"name": "index.interval.bytes",
"defaultValue": "4096"
},
"unclean.leader.election.enable": {
"name": "unclean.leader.election.enable",
"defaultValue": "true"
},
"retention.bytes": {
"name": "retention.bytes",
"defaultValue": "-1"
},
"delete.retention.ms": {
"name": "delete.retention.ms",
"defaultValue": "86400000"
},
"segment.ms": {
"name": "segment.ms",
"defaultValue": "604800000"
},
"message.timestamp.difference.max.ms": {
"name": "message.timestamp.difference.max.ms",
"defaultValue": "9223372036854775807"
},
"segment.index.bytes": {
"name": "segment.index.bytes",
"defaultValue": "10485760"
}
}

View file

@ -1,17 +1,10 @@
import React from 'react';
import {
ClusterName,
CleanupPolicy,
TopicFormData,
TopicName,
} from 'redux/interfaces';
import { useForm, FormContext, ErrorMessage } from 'react-hook-form';
import { ClusterName, TopicFormData, TopicName } from 'redux/interfaces';
import { useForm, FormContext } from 'react-hook-form';
import Breadcrumb from 'components/common/Breadcrumb/Breadcrumb';
import { clusterTopicsPath } from 'lib/paths';
import { TOPIC_NAME_VALIDATION_PATTERN, BYTES_IN_GB } from 'lib/constants';
import CustomParamsContainer from './CustomParams/CustomParamsContainer';
import TimeToRetain from './TimeToRetain';
import TopicForm from 'components/Topics/shared/Form/TopicForm';
interface Props {
clusterName: ClusterName;
@ -36,13 +29,7 @@ const New: React.FC<Props> = ({
const { name } = methods.getValues();
redirectToTopicPath(clusterName, name);
}
}, [
isSubmitting,
isTopicCreated,
redirectToTopicPath,
clusterName,
methods.getValues,
]);
}, [isSubmitting, isTopicCreated, redirectToTopicPath, clusterName, methods]);
const onSubmit = async (data: TopicFormData) => {
// TODO: need to fix loader. After success loading the first time, we won't wait for creation any more, because state is
@ -70,161 +57,10 @@ const New: React.FC<Props> = ({
<div className="box">
{/* eslint-disable react/jsx-props-no-spreading */}
<FormContext {...methods}>
<form onSubmit={methods.handleSubmit(onSubmit)}>
<div className="columns">
<div className="column is-three-quarters">
<label className="label">Topic Name *</label>
<input
className="input"
placeholder="Topic Name"
ref={methods.register({
required: 'Topic Name is required.',
pattern: {
value: TOPIC_NAME_VALIDATION_PATTERN,
message: 'Only alphanumeric, _, -, and . allowed',
},
})}
name="name"
autoComplete="off"
disabled={isSubmitting}
<TopicForm
isSubmitting={isSubmitting}
onSubmit={methods.handleSubmit(onSubmit)}
/>
<p className="help is-danger">
<ErrorMessage errors={methods.errors} name="name" />
</p>
</div>
<div className="column">
<label className="label">Number of partitions *</label>
<input
className="input"
type="number"
placeholder="Number of partitions"
defaultValue="1"
ref={methods.register({
required: 'Number of partitions is required.',
})}
name="partitions"
disabled={isSubmitting}
/>
<p className="help is-danger">
<ErrorMessage errors={methods.errors} name="partitions" />
</p>
</div>
</div>
<div className="columns">
<div className="column">
<label className="label">Replication Factor *</label>
<input
className="input"
type="number"
placeholder="Replication Factor"
defaultValue="1"
ref={methods.register({
required: 'Replication Factor is required.',
})}
name="replicationFactor"
disabled={isSubmitting}
/>
<p className="help is-danger">
<ErrorMessage
errors={methods.errors}
name="replicationFactor"
/>
</p>
</div>
<div className="column">
<label className="label">Min In Sync Replicas *</label>
<input
className="input"
type="number"
placeholder="Replication Factor"
defaultValue="1"
ref={methods.register({
required: 'Min In Sync Replicas is required.',
})}
name="minInSyncReplicas"
disabled={isSubmitting}
/>
<p className="help is-danger">
<ErrorMessage
errors={methods.errors}
name="minInSyncReplicas"
/>
</p>
</div>
</div>
<div className="columns">
<div className="column is-one-third">
<label className="label">Cleanup policy</label>
<div className="select is-block">
<select
defaultValue={CleanupPolicy.Delete}
name="cleanupPolicy"
ref={methods.register}
disabled={isSubmitting}
>
<option value={CleanupPolicy.Delete}>Delete</option>
<option value={CleanupPolicy.Compact}>Compact</option>
</select>
</div>
</div>
<div className="column is-one-third">
<TimeToRetain isSubmitting={isSubmitting} />
</div>
<div className="column is-one-third">
<label className="label">Max size on disk in GB</label>
<div className="select is-block">
<select
defaultValue={-1}
name="retentionBytes"
ref={methods.register}
disabled={isSubmitting}
>
<option value={-1}>Not Set</option>
<option value={BYTES_IN_GB}>1 GB</option>
<option value={BYTES_IN_GB * 10}>10 GB</option>
<option value={BYTES_IN_GB * 20}>20 GB</option>
<option value={BYTES_IN_GB * 50}>50 GB</option>
</select>
</div>
</div>
</div>
<div className="columns">
<div className="column">
<label className="label">Maximum message size in bytes *</label>
<input
className="input"
type="number"
defaultValue="1000012"
ref={methods.register({
required: 'Maximum message size in bytes is required',
})}
name="maxMessageBytes"
disabled={isSubmitting}
/>
<p className="help is-danger">
<ErrorMessage
errors={methods.errors}
name="maxMessageBytes"
/>
</p>
</div>
</div>
<CustomParamsContainer isSubmitting={isSubmitting} />
<input
type="submit"
className="button is-primary"
disabled={isSubmitting}
/>
</form>
</FormContext>
</div>
</div>

View file

@ -2,6 +2,7 @@ import React from 'react';
import { ClusterName } from 'redux/interfaces';
import { Switch, Route } from 'react-router-dom';
import PageLoader from 'components/common/PageLoader/PageLoader';
import EditContainer from 'components/Topics/Edit/EditContainer';
import ListContainer from './List/ListContainer';
import DetailsContainer from './Details/DetailsContainer';
import NewContainer from './New/NewContainer';
@ -35,6 +36,11 @@ const Topics: React.FC<Props> = ({
path="/ui/clusters/:clusterName/topics/new"
component={NewContainer}
/>
<Route
exact
path="/ui/clusters/:clusterName/topics/:topicName/edit"
component={EditContainer}
/>
<Route
path="/ui/clusters/:clusterName/topics/:topicName"
component={DetailsContainer}

View file

@ -0,0 +1,57 @@
import React from 'react';
import { useFormContext, ErrorMessage } from 'react-hook-form';
import { TopicFormCustomParam } from 'redux/interfaces';
import CustomParamSelect from 'components/Topics/shared/Form/CustomParams/CustomParamSelect';
import CustomParamValue from 'components/Topics/shared/Form/CustomParams/CustomParamValue';
import CustomParamAction from 'components/Topics/shared/Form/CustomParams/CustomParamAction';
import { INDEX_PREFIX } from './CustomParams';
import CustomParamOptions from './CustomParamOptions';
interface Props {
isDisabled: boolean;
index: string;
name: string;
existingFields: string[];
defaultValue: string;
onNameChange: (inputName: string, name: string) => void;
onRemove: (index: string) => void;
}
const CustomParamField: React.FC<Props> = ({
isDisabled,
index,
name,
existingFields,
defaultValue,
onNameChange,
onRemove,
}) => {
return (
<div className="columns is-centered">
<div className="column">
<CustomParamSelect
index={index}
isDisabled={isDisabled}
name={name}
existingFields={existingFields}
onNameChange={onNameChange}
/>
</div>
<div className="column">
<CustomParamValue
index={index}
isDisabled={isDisabled}
name={name}
defaultValue={defaultValue}
/>
</div>
<div className="column is-narrow">
<CustomParamAction index={index} onRemove={onRemove} />
</div>
</div>
);
};
export default React.memo(CustomParamField);

View file

@ -0,0 +1,27 @@
import React from 'react';
import { TopicCustomParamOption } from 'redux/interfaces';
import { omitBy } from 'lodash';
import CUSTOM_PARAMS_OPTIONS from './customParamsOptions';
interface Props {
existingFields: string[];
}
const CustomParamOptions: React.FC<Props> = ({ existingFields }) => {
const fields = omitBy(Object.values(CUSTOM_PARAMS_OPTIONS), (field) =>
existingFields.includes(field.name)
);
return (
<>
<option value="">Select</option>
{Object.values(fields).map((opt: TopicCustomParamOption) => (
<option key={opt.name} value={opt.name}>
{opt.name}
</option>
))}
</>
);
};
export default React.memo(CustomParamOptions);

View file

@ -8,9 +8,17 @@ interface Props {
isDisabled: boolean;
index: string;
name: string;
existingFields: string[];
onNameChange: (inputName: string, name: string) => void;
}
const CustomParamSelect: React.FC<Props> = ({ isDisabled, index, name }) => {
const CustomParamSelect: React.FC<Props> = ({
isDisabled,
index,
name,
existingFields,
onNameChange,
}) => {
const { register, errors, getValues, triggerValidation } = useFormContext();
const optInputName = `${index}[name]`;
@ -18,18 +26,20 @@ const CustomParamSelect: React.FC<Props> = ({ isDisabled, index, name }) => {
const values = getValues({ nest: true });
const customParamsValues: TopicFormCustomParam = values.customParams;
let valid = true;
const valid = !Object.entries(customParamsValues).some(
([key, customParam]) => {
return (
`${INDEX_PREFIX}.${key}` !== index && selected === customParam.name
);
}
);
for (const [key, customParam] of Object.entries(customParamsValues)) {
if (`${INDEX_PREFIX}.${key}` !== index) {
if (selected === customParam.name) {
valid = false;
break;
}
}
}
return valid || 'Custom Parameter must be unique';
};
return valid ? true : 'Custom Parameter must be unique';
const onChange = (inputName: string) => (event: any) => {
triggerValidation(inputName);
onNameChange(index, event.target.value);
};
return (
@ -42,11 +52,11 @@ const CustomParamSelect: React.FC<Props> = ({ isDisabled, index, name }) => {
required: 'Custom Parameter is required.',
validate: { unique: (selected) => selectedMustBeUniq(selected) },
})}
onChange={() => triggerValidation(optInputName)}
onChange={onChange(optInputName)}
disabled={isDisabled}
defaultValue={name}
>
<CustomParamOptions />
<CustomParamOptions existingFields={existingFields} />
</select>
<p className="help is-danger">
<ErrorMessage errors={errors} name={optInputName} />

View file

@ -1,6 +1,7 @@
import React from 'react';
import { useFormContext, ErrorMessage } from 'react-hook-form';
import { CUSTOM_PARAMS_OPTIONS } from './customParamsOptions';
import { camelCase } from 'lodash';
import CUSTOM_PARAMS_OPTIONS from './customParamsOptions';
interface Props {
isDisabled: boolean;
@ -21,14 +22,14 @@ const CustomParamValue: React.FC<Props> = ({
const selectedParamName = watch(selectInputName, name);
React.useEffect(() => {
if (selectedParamName) {
if (selectedParamName && !defaultValue) {
setValue(
valInputName,
CUSTOM_PARAMS_OPTIONS[selectedParamName].defaultValue,
true
);
}
}, [selectedParamName]);
}, [selectedParamName, setValue, valInputName]);
return (
<>
@ -45,7 +46,7 @@ const CustomParamValue: React.FC<Props> = ({
disabled={isDisabled}
/>
<p className="help is-danger">
<ErrorMessage errors={errors} name={valInputName} />
<ErrorMessage errors={errors} name={name} />
</p>
</>
);

View file

@ -0,0 +1,105 @@
import React from 'react';
import { omit, reject, reduce, remove } from 'lodash';
import { TopicFormCustomParams, TopicConfigByName } from 'redux/interfaces';
import CustomParamButton, { CustomParamButtonType } from './CustomParamButton';
import CustomParamField from './CustomParamField';
export const INDEX_PREFIX = 'customParams';
interface Props {
isSubmitting: boolean;
config?: TopicConfigByName;
}
interface Param {
[index: string]: {
name: string;
value: string;
};
}
const existingFields: string[] = [];
const CustomParams: React.FC<Props> = ({ isSubmitting, config }) => {
const byIndex = config
? reduce(
config.byName,
(result: Param, param, paramName) => {
result[`${INDEX_PREFIX}.${new Date().getTime()}ts`] = {
name: paramName,
value: param.value,
};
return result;
},
{}
)
: {};
const [formCustomParams, setFormCustomParams] = React.useState<
TopicFormCustomParams
>({
byIndex,
allIndexes: Object.keys(byIndex),
});
const onAdd = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
const newIndex = `${INDEX_PREFIX}.${new Date().getTime()}ts`;
setFormCustomParams({
...formCustomParams,
byIndex: {
...formCustomParams.byIndex,
[newIndex]: { name: '', value: '' },
},
allIndexes: [newIndex, ...formCustomParams.allIndexes],
});
};
const onRemove = (index: string) => {
const fieldName = formCustomParams.byIndex[index].name;
remove(existingFields, (el) => el === fieldName);
setFormCustomParams({
...formCustomParams,
byIndex: omit(formCustomParams.byIndex, index),
allIndexes: reject(formCustomParams.allIndexes, (i) => i === index),
});
};
const onFieldNameChange = (index: string, name: string) => {
formCustomParams.byIndex[index].name = name;
existingFields.push(name);
};
return (
<>
<div className="columns">
<div className="column">
<CustomParamButton
className="is-success"
type={CustomParamButtonType.plus}
onClick={onAdd}
btnText="Add Custom Parameter"
/>
</div>
</div>
{formCustomParams.allIndexes.map((index) => (
<CustomParamField
key={index}
index={index}
isDisabled={isSubmitting}
name={formCustomParams.byIndex[index].name}
defaultValue={formCustomParams.byIndex[index].value}
existingFields={existingFields}
onNameChange={onFieldNameChange}
onRemove={onRemove}
/>
))}
</>
);
};
export default CustomParams;

View file

@ -0,0 +1,19 @@
import { connect } from 'react-redux';
import { RootState, TopicConfigByName } from 'redux/interfaces';
import { withRouter, RouteComponentProps } from 'react-router-dom';
import CustomParams from './CustomParams';
interface OwnProps extends RouteComponentProps {
isSubmitting: boolean;
config?: TopicConfigByName;
}
const mapStateToProps = (
state: RootState,
{ isSubmitting, config }: OwnProps
) => ({
isSubmitting,
config,
});
export default withRouter(connect(mapStateToProps)(CustomParams));

View file

@ -0,0 +1,98 @@
import { TopicCustomParamOption } from 'redux/interfaces';
interface CustomParamOption {
[optionName: string]: TopicCustomParamOption;
}
const CUSTOM_PARAMS_OPTIONS: CustomParamOption = {
'compression.type': {
name: 'compression.type',
defaultValue: 'producer',
},
'leader.replication.throttled.replicas': {
name: 'leader.replication.throttled.replicas',
defaultValue: '',
},
'message.downconversion.enable': {
name: 'message.downconversion.enable',
defaultValue: 'true',
},
'segment.jitter.ms': {
name: 'segment.jitter.ms',
defaultValue: '0',
},
'flush.ms': {
name: 'flush.ms',
defaultValue: '9223372036854775807',
},
'follower.replication.throttled.replicas': {
name: 'follower.replication.throttled.replicas',
defaultValue: '',
},
'segment.bytes': {
name: 'segment.bytes',
defaultValue: '1073741824',
},
'flush.messages': {
name: 'flush.messages',
defaultValue: '9223372036854775807',
},
'message.format.version': {
name: 'message.format.version',
defaultValue: '2.3-IV1',
},
'file.delete.delay.ms': {
name: 'file.delete.delay.ms',
defaultValue: '60000',
},
'max.compaction.lag.ms': {
name: 'max.compaction.lag.ms',
defaultValue: '9223372036854775807',
},
'min.compaction.lag.ms': {
name: 'min.compaction.lag.ms',
defaultValue: '0',
},
'message.timestamp.type': {
name: 'message.timestamp.type',
defaultValue: 'CreateTime',
},
preallocate: {
name: 'preallocate',
defaultValue: 'false',
},
'min.cleanable.dirty.ratio': {
name: 'min.cleanable.dirty.ratio',
defaultValue: '0.5',
},
'index.interval.bytes': {
name: 'index.interval.bytes',
defaultValue: '4096',
},
'unclean.leader.election.enable': {
name: 'unclean.leader.election.enable',
defaultValue: 'true',
},
'retention.bytes': {
name: 'retention.bytes',
defaultValue: '-1',
},
'delete.retention.ms': {
name: 'delete.retention.ms',
defaultValue: '86400000',
},
'segment.ms': {
name: 'segment.ms',
defaultValue: '604800000',
},
'message.timestamp.difference.max.ms': {
name: 'message.timestamp.difference.max.ms',
defaultValue: '9223372036854775807',
},
'segment.index.bytes': {
name: 'segment.index.bytes',
defaultValue: '10485760',
},
};
export default CUSTOM_PARAMS_OPTIONS;

View file

@ -0,0 +1,35 @@
import React from 'react';
import Breadcrumb from 'components/common/Breadcrumb/Breadcrumb';
import { clusterTopicsPath, clusterTopicPath } from 'lib/paths';
import { ClusterName, TopicName } from 'redux/interfaces';
interface Props {
clusterName: ClusterName;
topicName?: TopicName;
current: string;
}
const FormBreadcrumbs: React.FC<Props> = ({
clusterName,
topicName,
current,
}) => {
const allTopicsLink = {
href: clusterTopicsPath(clusterName),
label: 'All Topics',
};
const links = topicName
? [
allTopicsLink,
{ href: clusterTopicPath(clusterName, topicName), label: topicName },
]
: [allTopicsLink];
return (
<div className="level-item level-left">
<Breadcrumb links={links}>{current}</Breadcrumb>
</div>
);
};
export default FormBreadcrumbs;

View file

@ -0,0 +1,170 @@
import React from 'react';
import { useFormContext, ErrorMessage } from 'react-hook-form';
import { TOPIC_NAME_VALIDATION_PATTERN, BYTES_IN_GB } from 'lib/constants';
import { CleanupPolicy, TopicName, TopicConfigByName } from 'redux/interfaces';
import CustomParamsContainer from './CustomParams/CustomParamsContainer';
import TimeToRetain from './TimeToRetain';
interface Props {
topicName?: TopicName;
config?: TopicConfigByName;
isEditing?: boolean;
isSubmitting: boolean;
onSubmit: (e: React.BaseSyntheticEvent) => Promise<void>;
}
const TopicForm: React.FC<Props> = ({
topicName,
config,
isEditing,
isSubmitting,
onSubmit,
}) => {
const { register, errors } = useFormContext();
return (
<form onSubmit={onSubmit}>
<div className="columns">
<div className="column is-three-quarters">
<label className="label">Topic Name *</label>
<input
className="input"
placeholder="Topic Name"
ref={register({
required: 'Topic Name is required.',
pattern: {
value: TOPIC_NAME_VALIDATION_PATTERN,
message: 'Only alphanumeric, _, -, and . allowed',
},
})}
defaultValue={topicName}
name="name"
autoComplete="off"
disabled={isEditing || isSubmitting}
/>
<p className="help is-danger">
<ErrorMessage errors={errors} name="name" />
</p>
</div>
<div className="column">
<label className="label">Number of partitions *</label>
<input
className="input"
type="number"
placeholder="Number of partitions"
defaultValue="1"
ref={register({ required: 'Number of partitions is required.' })}
name="partitions"
disabled={isEditing || isSubmitting}
/>
<p className="help is-danger">
<ErrorMessage errors={errors} name="partitions" />
</p>
</div>
</div>
<div className="columns">
<div className="column">
<label className="label">Replication Factor *</label>
<input
className="input"
type="number"
placeholder="Replication Factor"
defaultValue="1"
ref={register({ required: 'Replication Factor is required.' })}
name="replicationFactor"
disabled={isEditing || isSubmitting}
/>
<p className="help is-danger">
<ErrorMessage errors={errors} name="replicationFactor" />
</p>
</div>
<div className="column">
<label className="label">Min In Sync Replicas *</label>
<input
className="input"
type="number"
placeholder="Min In Sync Replicas"
defaultValue="1"
ref={register({ required: 'Min In Sync Replicas is required.' })}
name="minInSyncReplicas"
disabled={isSubmitting}
/>
<p className="help is-danger">
<ErrorMessage errors={errors} name="minInSyncReplicas" />
</p>
</div>
</div>
<div className="columns">
<div className="column is-one-third">
<label className="label">Cleanup policy</label>
<div className="select is-block">
<select
defaultValue={CleanupPolicy.Delete}
name="cleanupPolicy"
ref={register}
disabled={isSubmitting}
>
<option value={CleanupPolicy.Delete}>Delete</option>
<option value={CleanupPolicy.Compact}>Compact</option>
</select>
</div>
</div>
<div className="column is-one-third">
<TimeToRetain isSubmitting={isSubmitting} />
</div>
<div className="column is-one-third">
<label className="label">Max size on disk in GB</label>
<div className="select is-block">
<select
defaultValue={-1}
name="retentionBytes"
ref={register}
disabled={isSubmitting}
>
<option value={-1}>Not Set</option>
<option value={BYTES_IN_GB}>1 GB</option>
<option value={BYTES_IN_GB * 10}>10 GB</option>
<option value={BYTES_IN_GB * 20}>20 GB</option>
<option value={BYTES_IN_GB * 50}>50 GB</option>
</select>
</div>
</div>
</div>
<div className="columns">
<div className="column">
<label className="label">Maximum message size in bytes *</label>
<input
className="input"
type="number"
defaultValue="1000012"
ref={register({
required: 'Maximum message size in bytes is required',
})}
name="maxMessageBytes"
disabled={isSubmitting}
/>
<p className="help is-danger">
<ErrorMessage errors={errors} name="maxMessageBytes" />
</p>
</div>
</div>
<CustomParamsContainer isSubmitting={isSubmitting} config={config} />
<input
type="submit"
className="button is-primary"
disabled={isSubmitting}
/>
</form>
);
};
export default TopicForm;

View file

@ -23,3 +23,8 @@ export const clusterTopicMessagesPath = (
clusterName: ClusterName,
topicName: TopicName
) => `${clusterTopicsPath(clusterName)}/${topicName}/messages`;
export const clusterTopicsTopicEditPath = (
clusterName: ClusterName,
topicName: TopicName
) => `${clusterTopicsPath(clusterName)}/${topicName}/edit`;

View file

@ -1,4 +1,4 @@
export enum ActionType {
enum ActionType {
GET_CLUSTERS__REQUEST = 'GET_CLUSTERS__REQUEST',
GET_CLUSTERS__SUCCESS = 'GET_CLUSTERS__SUCCESS',
GET_CLUSTERS__FAILURE = 'GET_CLUSTERS__FAILURE',
@ -27,6 +27,10 @@ export enum ActionType {
POST_TOPIC__SUCCESS = 'POST_TOPIC__SUCCESS',
POST_TOPIC__FAILURE = 'POST_TOPIC__FAILURE',
PATCH_TOPIC__REQUEST = 'PATCH_TOPIC__REQUEST',
PATCH_TOPIC__SUCCESS = 'PATCH_TOPIC__SUCCESS',
PATCH_TOPIC__FAILURE = 'PATCH_TOPIC__FAILURE',
GET_CONSUMER_GROUPS__REQUEST = 'GET_CONSUMER_GROUPS__REQUEST',
GET_CONSUMER_GROUPS__SUCCESS = 'GET_CONSUMER_GROUPS__SUCCESS',
GET_CONSUMER_GROUPS__FAILURE = 'GET_CONSUMER_GROUPS__FAILURE',
@ -34,4 +38,6 @@ export enum ActionType {
GET_CONSUMER_GROUP_DETAILS__REQUEST = 'GET_CONSUMER_GROUP_DETAILS__REQUEST',
GET_CONSUMER_GROUP_DETAILS__SUCCESS = 'GET_CONSUMER_GROUP_DETAILS__SUCCESS',
GET_CONSUMER_GROUP_DETAILS__FAILURE = 'GET_CONSUMER_GROUP_DETAILS__FAILURE',
};
}
export default ActionType;

View file

@ -1,6 +1,5 @@
import { createAsyncAction } from 'typesafe-actions';
import { ActionType } from 'redux/actionType';
import { ConsumerGroup, ConsumerGroupID, ConsumerGroupDetails } from '../interfaces/consumerGroup';
import ActionType from 'redux/actionType';
import {
Broker,
BrokerMetrics,
@ -9,58 +8,71 @@ import {
TopicConfig,
TopicDetails,
TopicName,
ConsumerGroup,
ConsumerGroupDetails,
ConsumerGroupID,
} from 'redux/interfaces';
export const fetchBrokersAction = createAsyncAction(
ActionType.GET_BROKERS__REQUEST,
ActionType.GET_BROKERS__SUCCESS,
ActionType.GET_BROKERS__FAILURE,
ActionType.GET_BROKERS__FAILURE
)<undefined, Broker[], undefined>();
export const fetchBrokerMetricsAction = createAsyncAction(
ActionType.GET_BROKER_METRICS__REQUEST,
ActionType.GET_BROKER_METRICS__SUCCESS,
ActionType.GET_BROKER_METRICS__FAILURE,
ActionType.GET_BROKER_METRICS__FAILURE
)<undefined, BrokerMetrics, undefined>();
export const fetchClusterListAction = createAsyncAction(
ActionType.GET_CLUSTERS__REQUEST,
ActionType.GET_CLUSTERS__SUCCESS,
ActionType.GET_CLUSTERS__FAILURE,
ActionType.GET_CLUSTERS__FAILURE
)<undefined, Cluster[], undefined>();
export const fetchTopicListAction = createAsyncAction(
ActionType.GET_TOPICS__REQUEST,
ActionType.GET_TOPICS__SUCCESS,
ActionType.GET_TOPICS__FAILURE,
ActionType.GET_TOPICS__FAILURE
)<undefined, Topic[], undefined>();
export const fetchTopicDetailsAction = createAsyncAction(
ActionType.GET_TOPIC_DETAILS__REQUEST,
ActionType.GET_TOPIC_DETAILS__SUCCESS,
ActionType.GET_TOPIC_DETAILS__FAILURE,
)<undefined, { topicName: TopicName, details: TopicDetails }, undefined>();
ActionType.GET_TOPIC_DETAILS__FAILURE
)<undefined, { topicName: TopicName; details: TopicDetails }, undefined>();
export const fetchTopicConfigAction = createAsyncAction(
ActionType.GET_TOPIC_CONFIG__REQUEST,
ActionType.GET_TOPIC_CONFIG__SUCCESS,
ActionType.GET_TOPIC_CONFIG__FAILURE,
)<undefined, { topicName: TopicName, config: TopicConfig[] }, undefined>();
ActionType.GET_TOPIC_CONFIG__FAILURE
)<undefined, { topicName: TopicName; config: TopicConfig[] }, undefined>();
export const createTopicAction = createAsyncAction(
ActionType.POST_TOPIC__REQUEST,
ActionType.POST_TOPIC__SUCCESS,
ActionType.POST_TOPIC__FAILURE,
ActionType.POST_TOPIC__FAILURE
)<undefined, Topic, undefined>();
export const updateTopicAction = createAsyncAction(
ActionType.PATCH_TOPIC__REQUEST,
ActionType.PATCH_TOPIC__SUCCESS,
ActionType.PATCH_TOPIC__FAILURE
)<undefined, Topic, undefined>();
export const fetchConsumerGroupsAction = createAsyncAction(
ActionType.GET_CONSUMER_GROUPS__REQUEST,
ActionType.GET_CONSUMER_GROUPS__SUCCESS,
ActionType.GET_CONSUMER_GROUPS__FAILURE,
ActionType.GET_CONSUMER_GROUPS__FAILURE
)<undefined, ConsumerGroup[], undefined>();
export const fetchConsumerGroupDetailsAction = createAsyncAction(
ActionType.GET_CONSUMER_GROUP_DETAILS__REQUEST,
ActionType.GET_CONSUMER_GROUP_DETAILS__SUCCESS,
ActionType.GET_CONSUMER_GROUP_DETAILS__FAILURE,
)<undefined, { consumerGroupID: ConsumerGroupID, details: ConsumerGroupDetails }, undefined>();
ActionType.GET_CONSUMER_GROUP_DETAILS__FAILURE
)<
undefined,
{ consumerGroupID: ConsumerGroupID; details: ConsumerGroupDetails },
undefined
>();

View file

@ -1,15 +1,19 @@
import * as api from 'redux/api';
import * as actions from './actions';
import { ConsumerGroupID } from '../interfaces/consumerGroup';
import {
ConsumerGroupID,
PromiseThunk,
Cluster,
ClusterName,
TopicFormData,
TopicName, Topic,
TopicName,
Topic,
} from 'redux/interfaces';
export const fetchBrokers = (clusterName: ClusterName): PromiseThunk<void> => async (dispatch) => {
import * as actions from './actions';
export const fetchBrokers = (
clusterName: ClusterName
): PromiseThunk<void> => async (dispatch) => {
dispatch(actions.fetchBrokersAction.request());
try {
const payload = await api.getBrokers(clusterName);
@ -19,7 +23,9 @@ export const fetchBrokers = (clusterName: ClusterName): PromiseThunk<void> => as
}
};
export const fetchBrokerMetrics = (clusterName: ClusterName): PromiseThunk<void> => async (dispatch) => {
export const fetchBrokerMetrics = (
clusterName: ClusterName
): PromiseThunk<void> => async (dispatch) => {
dispatch(actions.fetchBrokerMetricsAction.request());
try {
const payload = await api.getBrokerMetrics(clusterName);
@ -39,7 +45,9 @@ export const fetchClustersList = (): PromiseThunk<void> => async (dispatch) => {
}
};
export const fetchTopicList = (clusterName: ClusterName): PromiseThunk<void> => async (dispatch) => {
export const fetchTopicList = (
clusterName: ClusterName
): PromiseThunk<void> => async (dispatch) => {
dispatch(actions.fetchTopicListAction.request());
try {
const topics = await api.getTopics(clusterName);
@ -49,17 +57,28 @@ export const fetchTopicList = (clusterName: ClusterName): PromiseThunk<void> =>
}
};
export const fetchTopicDetails = (clusterName: ClusterName, topicName: TopicName): PromiseThunk<void> => async (dispatch) => {
export const fetchTopicDetails = (
clusterName: ClusterName,
topicName: TopicName
): PromiseThunk<void> => async (dispatch) => {
dispatch(actions.fetchTopicDetailsAction.request());
try {
const topicDetails = await api.getTopicDetails(clusterName, topicName);
dispatch(actions.fetchTopicDetailsAction.success({ topicName, details: topicDetails }));
dispatch(
actions.fetchTopicDetailsAction.success({
topicName,
details: topicDetails,
})
);
} catch (e) {
dispatch(actions.fetchTopicDetailsAction.failure());
}
};
export const fetchTopicConfig = (clusterName: ClusterName, topicName: TopicName): PromiseThunk<void> => async (dispatch) => {
export const fetchTopicConfig = (
clusterName: ClusterName,
topicName: TopicName
): PromiseThunk<void> => async (dispatch) => {
dispatch(actions.fetchTopicConfigAction.request());
try {
const config = await api.getTopicConfig(clusterName, topicName);
@ -69,7 +88,10 @@ export const fetchTopicConfig = (clusterName: ClusterName, topicName: TopicName)
}
};
export const createTopic = (clusterName: ClusterName, form: TopicFormData): PromiseThunk<void> => async (dispatch) => {
export const createTopic = (
clusterName: ClusterName,
form: TopicFormData
): PromiseThunk<void> => async (dispatch) => {
dispatch(actions.createTopicAction.request());
try {
const topic: Topic = await api.postTopic(clusterName, form);
@ -79,7 +101,22 @@ export const createTopic = (clusterName: ClusterName, form: TopicFormData): Prom
}
};
export const fetchConsumerGroupsList = (clusterName: ClusterName): PromiseThunk<void> => async (dispatch) => {
export const updateTopic = (
clusterName: ClusterName,
form: TopicFormData
): PromiseThunk<void> => async (dispatch) => {
dispatch(actions.updateTopicAction.request());
try {
const topic: Topic = await api.patchTopic(clusterName, form);
dispatch(actions.updateTopicAction.success(topic));
} catch (e) {
dispatch(actions.updateTopicAction.failure());
}
};
export const fetchConsumerGroupsList = (
clusterName: ClusterName
): PromiseThunk<void> => async (dispatch) => {
dispatch(actions.fetchConsumerGroupsAction.request());
try {
const consumerGroups = await api.getConsumerGroups(clusterName);
@ -89,11 +126,22 @@ export const fetchConsumerGroupsList = (clusterName: ClusterName): PromiseThunk<
}
};
export const fetchConsumerGroupDetails = (clusterName: ClusterName, consumerGroupID: ConsumerGroupID): PromiseThunk<void> => async (dispatch) => {
export const fetchConsumerGroupDetails = (
clusterName: ClusterName,
consumerGroupID: ConsumerGroupID
): PromiseThunk<void> => async (dispatch) => {
dispatch(actions.fetchConsumerGroupDetailsAction.request());
try {
const consumerGroupDetails = await api.getConsumerGroupDetails(clusterName, consumerGroupID);
dispatch(actions.fetchConsumerGroupDetailsAction.success({ consumerGroupID, details: consumerGroupDetails }));
const consumerGroupDetails = await api.getConsumerGroupDetails(
clusterName,
consumerGroupID
);
dispatch(
actions.fetchConsumerGroupDetailsAction.success({
consumerGroupID,
details: consumerGroupDetails,
})
);
} catch (e) {
dispatch(actions.fetchConsumerGroupDetailsAction.failure());
}

View file

@ -1,14 +1,29 @@
import { reduce } from 'lodash';
import {
ClusterName,
Topic,
TopicConfig,
TopicDetails,
TopicFormCustomParam,
TopicFormData,
TopicName,
Topic,
ClusterName,
TopicDetails,
TopicConfig,
TopicFormData,
TopicFormCustomParam,
TopicFormFormattedParams,
TopicFormCustomParams,
} from 'redux/interfaces';
import { BASE_PARAMS, BASE_URL } from 'lib/constants';
import { BASE_URL, BASE_PARAMS } from 'lib/constants';
const formatCustomParams = (
customParams: TopicFormCustomParams
): TopicFormFormattedParams => {
return Object.values(customParams || {}).reduce(
(result: TopicFormFormattedParams, customParam: TopicFormCustomParam) => {
return {
...result,
[customParam.name]: customParam.value,
};
},
{} as TopicFormFormattedParams
);
};
export const getTopicConfig = (
clusterName: ClusterName,
@ -31,10 +46,6 @@ export const getTopics = (clusterName: ClusterName): Promise<Topic[]> =>
...BASE_PARAMS,
}).then((res) => res.json());
interface Result {
[index: string]: string;
}
export const postTopic = (
clusterName: ClusterName,
form: TopicFormData
@ -50,18 +61,6 @@ export const postTopic = (
minInSyncReplicas,
} = form;
const customParams =
(form.customParams &&
reduce(
Object.values(form.customParams),
(result: Result, customParam: TopicFormCustomParam) => {
result[customParam.name] = customParam.value;
return result;
},
{}
)) ||
{};
const body = JSON.stringify({
name,
partitions,
@ -72,7 +71,7 @@ export const postTopic = (
'retention.bytes': retentionBytes,
'max.message.bytes': maxMessageBytes,
'min.insync.replicas': minInSyncReplicas,
...customParams,
...formatCustomParams(form.customParams),
},
});
@ -82,3 +81,33 @@ export const postTopic = (
body,
}).then((res) => res.json());
};
export const patchTopic = (
clusterName: ClusterName,
form: TopicFormData
): Promise<Topic> => {
const {
cleanupPolicy,
retentionBytes,
retentionMs,
maxMessageBytes,
minInSyncReplicas,
} = form;
const body = JSON.stringify({
configs: {
'cleanup.policy': cleanupPolicy,
'retention.ms': retentionMs,
'retention.bytes': retentionBytes,
'max.message.bytes': maxMessageBytes,
'min.insync.replicas': minInSyncReplicas,
...formatCustomParams(form.customParams),
},
});
return fetch(`${BASE_URL}/clusters/${clusterName}/topics/${form.name}`, {
...BASE_PARAMS,
method: 'PATCH',
body,
}).then((res) => res.json());
};

View file

@ -11,6 +11,12 @@ export interface TopicConfig {
defaultValue: string;
}
export interface TopicConfigByName {
byName: {
[paramName: string]: TopicConfig;
};
}
export interface TopicReplica {
broker: number;
leader: boolean;
@ -63,6 +69,10 @@ export interface TopicsState {
allNames: TopicName[];
}
export interface TopicFormFormattedParams {
[name: string]: string;
}
export interface TopicFormData {
name: string;
partitions: number;
@ -72,7 +82,5 @@ export interface TopicFormData {
retentionMs: number;
retentionBytes: number;
maxMessageBytes: number;
customParams: {
[index: string]: TopicFormCustomParam;
};
customParams: TopicFormCustomParams;
}

View file

@ -4,9 +4,7 @@ import {
ZooKeeperStatus,
BrokerMetrics,
} from 'redux/interfaces';
import {
ActionType,
} from 'redux/actionType';
import ActionType from 'redux/actionType';
export const initialState: BrokersState = {
items: [],
@ -21,12 +19,17 @@ export const initialState: BrokersState = {
diskUsage: [],
};
const updateBrokerSegmentSize = (state: BrokersState, payload: BrokerMetrics) => {
const updateBrokerSegmentSize = (
state: BrokersState,
payload: BrokerMetrics
) => {
const brokers = state.items;
const { diskUsage } = payload;
const items = brokers.map((broker) => {
const brokerMetrics = diskUsage.find(({ brokerId }) => brokerId === broker.brokerId);
const brokerMetrics = diskUsage.find(
({ brokerId }) => brokerId === broker.brokerId
);
if (brokerMetrics !== undefined) {
return { ...broker, ...brokerMetrics };
}

View file

@ -1,5 +1,5 @@
import { Cluster, Action } from 'redux/interfaces';
import { ActionType } from 'redux/actionType';
import ActionType from 'redux/actionType';
export const initialState: Cluster[] = [];

View file

@ -1,21 +1,22 @@
import { Action, ConsumerGroup } from 'redux/interfaces';
import { ActionType } from 'redux/actionType';
import { ConsumerGroupsState } from '../../interfaces/consumerGroup';
import { Action, ConsumerGroup, ConsumerGroupsState } from 'redux/interfaces';
import ActionType from 'redux/actionType';
export const initialState: ConsumerGroupsState = {
byID: {},
allIDs: []
allIDs: [],
};
const updateConsumerGroupsList = (state: ConsumerGroupsState, payload: ConsumerGroup[]): ConsumerGroupsState => {
const updateConsumerGroupsList = (
state: ConsumerGroupsState,
payload: ConsumerGroup[]
): ConsumerGroupsState => {
const initialMemo: ConsumerGroupsState = {
...state,
allIDs: []
allIDs: [],
};
return payload.reduce(
(memo: ConsumerGroupsState, consumerGroup) => {
const {consumerGroupId} = consumerGroup;
return payload.reduce((memo: ConsumerGroupsState, consumerGroup) => {
const { consumerGroupId } = consumerGroup;
memo.byID[consumerGroupId] = {
...memo.byID[consumerGroupId],
...consumerGroup,
@ -23,9 +24,7 @@ const updateConsumerGroupsList = (state: ConsumerGroupsState, payload: ConsumerG
memo.allIDs.push(consumerGroupId);
return memo;
},
initialMemo,
);
}, initialMemo);
};
const reducer = (state = initialState, action: Action): ConsumerGroupsState => {
@ -40,8 +39,8 @@ const reducer = (state = initialState, action: Action): ConsumerGroupsState => {
[action.payload.consumerGroupID]: {
...state.byID[action.payload.consumerGroupID],
...action.payload.details,
}
}
},
},
};
default:
return state;

View file

@ -1,5 +1,5 @@
import { Action, TopicsState, Topic } from 'redux/interfaces';
import { ActionType } from 'redux/actionType';
import ActionType from 'redux/actionType';
export const initialState: TopicsState = {
byName: {},
@ -12,9 +12,8 @@ const updateTopicList = (state: TopicsState, payload: Topic[]): TopicsState => {
allNames: [],
};
return payload.reduce(
(memo: TopicsState, topic) => {
const {name} = topic;
return payload.reduce((memo: TopicsState, topic) => {
const { name } = topic;
memo.byName[name] = {
...memo.byName[name],
...topic,
@ -22,14 +21,12 @@ const updateTopicList = (state: TopicsState, payload: Topic[]): TopicsState => {
memo.allNames.push(name);
return memo;
},
initialMemo,
);
}, initialMemo);
};
const addToTopicList = (state: TopicsState, payload: Topic): TopicsState => {
const newState: TopicsState = {
...state
...state,
};
newState.allNames.push(payload.name);
newState.byName[payload.name] = payload;
@ -48,8 +45,8 @@ const reducer = (state = initialState, action: Action): TopicsState => {
[action.payload.topicName]: {
...state.byName[action.payload.topicName],
...action.payload.details,
}
}
},
},
};
case ActionType.GET_TOPIC_CONFIG__SUCCESS:
return {
@ -59,8 +56,8 @@ const reducer = (state = initialState, action: Action): TopicsState => {
[action.payload.topicName]: {
...state.byName[action.payload.topicName],
config: action.payload.config,
}
}
},
},
};
case ActionType.POST_TOPIC__SUCCESS:
return addToTopicList(state, action.payload);

View file

@ -1,5 +1,11 @@
import { createSelector } from 'reselect';
import { RootState, TopicName, FetchStatus, TopicsState } from 'redux/interfaces';
import {
RootState,
TopicName,
FetchStatus,
TopicsState,
TopicConfigByName,
} from 'redux/interfaces';
import { createFetchingSelector } from 'redux/reducers/loader/selectors';
const topicsState = ({ topics }: RootState): TopicsState => topics;
@ -8,28 +14,36 @@ const getAllNames = (state: RootState) => topicsState(state).allNames;
const getTopicMap = (state: RootState) => topicsState(state).byName;
const getTopicListFetchingStatus = createFetchingSelector('GET_TOPICS');
const getTopicDetailsFetchingStatus = createFetchingSelector('GET_TOPIC_DETAILS');
const getTopicDetailsFetchingStatus = createFetchingSelector(
'GET_TOPIC_DETAILS'
);
const getTopicConfigFetchingStatus = createFetchingSelector('GET_TOPIC_CONFIG');
const getTopicCreationStatus = createFetchingSelector('POST_TOPIC');
const getTopicUpdateStatus = createFetchingSelector('PATCH_TOPIC');
export const getIsTopicListFetched = createSelector(
getTopicListFetchingStatus,
(status) => status === FetchStatus.fetched,
(status) => status === FetchStatus.fetched
);
export const getIsTopicDetailsFetched = createSelector(
getTopicDetailsFetchingStatus,
(status) => status === FetchStatus.fetched,
(status) => status === FetchStatus.fetched
);
export const getTopicConfigFetched = createSelector(
getTopicConfigFetchingStatus,
(status) => status === FetchStatus.fetched,
(status) => status === FetchStatus.fetched
);
export const getTopicCreated = createSelector(
getTopicCreationStatus,
(status) => status === FetchStatus.fetched,
(status) => status === FetchStatus.fetched
);
export const getTopicUpdated = createSelector(
getTopicUpdateStatus,
(status) => status === FetchStatus.fetched
);
export const getTopicList = createSelector(
@ -40,13 +54,12 @@ export const getTopicList = createSelector(
if (!isFetched) {
return [];
}
return allNames.map((name) => byName[name])
},
return allNames.map((name) => byName[name]);
}
);
export const getExternalTopicList = createSelector(
getTopicList,
(topics) => topics.filter(({ internal }) => !internal),
export const getExternalTopicList = createSelector(getTopicList, (topics) =>
topics.filter(({ internal }) => !internal)
);
const getTopicName = (_: RootState, topicName: TopicName) => topicName;
@ -54,7 +67,31 @@ const getTopicName = (_: RootState, topicName: TopicName) => topicName;
export const getTopicByName = createSelector(
getTopicMap,
getTopicName,
(topics, topicName) => topics[topicName],
(topics, topicName) => topics[topicName]
);
export const getTopicConfig = createSelector(getTopicByName, ({ config }) => config);
export const getFullTopic = createSelector(getTopicByName, (topic) =>
topic && topic.config && !!topic.partitionCount ? topic : undefined
);
export const getTopicConfig = createSelector(
getTopicByName,
({ config }) => config
);
export const getTopicConfigByParamName = createSelector(
getTopicConfig,
(config) => {
const byParamName: TopicConfigByName = {
byName: {},
};
if (config) {
config.forEach((param) => {
byParamName.byName[param.name] = param;
});
}
return byParamName;
}
);

3
package-lock.json generated Normal file
View file

@ -0,0 +1,3 @@
{
"lockfileVersion": 1
}