add DHCP form

This commit is contained in:
Ildar Kamalov
2025-01-20 10:54:13 +03:00
parent 93890c2c6f
commit efd907216f
9 changed files with 179 additions and 261 deletions

View File

@@ -19,7 +19,6 @@ import {
CHECK_TIMEOUT,
STATUS_RESPONSE,
SETTINGS_NAMES,
FORM_NAME,
MANUAL_UPDATE_LINK,
DISABLE_PROTECTION_TIMINGS,
} from '../helpers/constants';
@@ -511,16 +510,15 @@ export const findActiveDhcpRequest = createAction('FIND_ACTIVE_DHCP_REQUEST');
export const findActiveDhcpSuccess = createAction('FIND_ACTIVE_DHCP_SUCCESS');
export const findActiveDhcpFailure = createAction('FIND_ACTIVE_DHCP_FAILURE');
export const findActiveDhcp = (name: any) => async (dispatch: any, getState: any) => {
export const findActiveDhcp = (selectedInterface: any) => async (dispatch: any, getState: any) => {
dispatch(findActiveDhcpRequest());
try {
const req = {
interface: name,
interface: selectedInterface,
};
const activeDhcp = await apiClient.findActiveDhcp(req);
dispatch(findActiveDhcpSuccess(activeDhcp));
const { check, interface_name, interfaces } = getState().dhcp;
const selectedInterface = getState().form[FORM_NAME.DHCP_INTERFACES].values.interface_name;
const v4 = check?.v4 ?? { static_ip: {}, other_server: {} };
const v6 = check?.v6 ?? { other_server: {} };

View File

@@ -1,7 +1,6 @@
import React from 'react';
import { useForm } from 'react-hook-form';
import { useFormContext } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import { UINT32_RANGE } from '../../../helpers/constants';
import {
@@ -12,21 +11,10 @@ import {
validateNotInRange,
validateRequiredValue,
} from '../../../helpers/validators';
import { RootState } from '../../../initialState';
type FormValues = {
v4?: {
gateway_ip?: string;
subnet_mask?: string;
range_start?: string;
range_end?: string;
lease_duration?: number;
};
}
import { DhcpFormValues } from '.';
type FormDHCPv4Props = {
processingConfig?: boolean;
initialValues?: FormValues;
ipv4placeholders?: {
gateway_ip: string;
subnet_mask: string;
@@ -34,46 +22,28 @@ type FormDHCPv4Props = {
range_end: string;
lease_duration: string;
};
onSubmit?: (data: FormValues) => Promise<void> | void;
}
interfaces: any;
onSubmit?: (data: DhcpFormValues) => Promise<void> | void;
};
const FormDHCPv4 = ({
processingConfig,
initialValues,
ipv4placeholders,
onSubmit
}: FormDHCPv4Props) => {
const FormDHCPv4 = ({ processingConfig, ipv4placeholders, interfaces, onSubmit }: FormDHCPv4Props) => {
const { t } = useTranslation();
const interfaces = useSelector((state: RootState) => state.form.DHCP_INTERFACES);
const interface_name = interfaces?.values?.interface_name;
const isInterfaceIncludesIpv4 = useSelector(
(state: RootState) => !!state.dhcp?.interfaces?.[interface_name]?.ipv4_addresses,
);
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
watch,
} = useForm<FormValues>({
mode: 'onChange',
defaultValues: {
v4: initialValues?.v4 || {
gateway_ip: '',
subnet_mask: '',
range_start: '',
range_end: '',
lease_duration: 0,
},
},
});
} = useFormContext<DhcpFormValues>();
const interfaceName = watch('interface_name');
const isInterfaceIncludesIpv4 = interfaces?.[interfaceName]?.ipv4_addresses;
const formValues = watch('v4');
const isEmptyConfig = !Object.values(formValues || {}).some(Boolean);
const handleFormSubmit = async (data: FormValues) => {
const handleFormSubmit = async (data: DhcpFormValues) => {
// TODO handle submit
if (onSubmit) {
await onSubmit(data);
}
@@ -93,15 +63,13 @@ const FormDHCPv4 = ({
{...register('v4.gateway_ip', {
validate: {
ipv4: validateIpv4,
required: (value) => isEmptyConfig ? undefined : validateRequiredValue(value),
required: (value) => (isEmptyConfig ? undefined : validateRequiredValue(value)),
notInRange: validateNotInRange,
}
},
})}
/>
{errors.v4?.gateway_ip && (
<div className="form__message form__message--error">
{t(errors.v4.gateway_ip.message)}
</div>
<div className="form__message form__message--error">{t(errors.v4.gateway_ip.message)}</div>
)}
</div>
@@ -114,15 +82,13 @@ const FormDHCPv4 = ({
disabled={!isInterfaceIncludesIpv4}
{...register('v4.subnet_mask', {
validate: {
required: (value) => isEmptyConfig ? undefined : validateRequiredValue(value),
required: (value) => (isEmptyConfig ? undefined : validateRequiredValue(value)),
subnet: validateGatewaySubnetMask,
}
},
})}
/>
{errors.v4?.subnet_mask && (
<div className="form__message form__message--error">
{t(errors.v4.subnet_mask.message)}
</div>
<div className="form__message form__message--error">{t(errors.v4.subnet_mask.message)}</div>
)}
</div>
</div>
@@ -144,7 +110,7 @@ const FormDHCPv4 = ({
validate: {
ipv4: validateIpv4,
gateway: validateIpForGatewaySubnetMask,
}
},
})}
/>
{errors.v4?.range_start && (
@@ -165,7 +131,7 @@ const FormDHCPv4 = ({
ipv4: validateIpv4,
rangeEnd: validateIpv4RangeEnd,
gateway: validateIpForGatewaySubnetMask,
}
},
})}
/>
{errors.v4?.range_end && (
@@ -189,8 +155,8 @@ const FormDHCPv4 = ({
{...register('v4.lease_duration', {
valueAsNumber: true,
validate: {
required: (value) => isEmptyConfig ? undefined : validateRequiredValue(value),
}
required: (value) => (isEmptyConfig ? undefined : validateRequiredValue(value)),
},
})}
/>
{errors.v4?.lease_duration && (
@@ -206,8 +172,13 @@ const FormDHCPv4 = ({
<button
type="submit"
className="btn btn-success btn-standard"
disabled={isSubmitting || processingConfig || !isInterfaceIncludesIpv4 || isEmptyConfig || Object.keys(errors).length > 0}
>
disabled={
isSubmitting ||
processingConfig ||
!isInterfaceIncludesIpv4 ||
isEmptyConfig ||
Object.keys(errors).length > 0
}>
{t('save_config')}
</button>
</div>

View File

@@ -1,11 +1,10 @@
import React from 'react';
import { useForm } from 'react-hook-form';
import { useFormContext } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import { UINT32_RANGE } from '../../../helpers/constants';
import { validateIpv6, validateRequiredValue } from '../../../helpers/validators';
import { RootState } from '../../../initialState';
import { DhcpFormValues } from '.';
type FormValues = {
v6?: {
@@ -13,49 +12,30 @@ type FormValues = {
range_end?: string;
lease_duration?: number;
};
}
};
type FormDHCPv6Props = {
processingConfig?: boolean;
initialValues?: FormValues;
ipv6placeholders?: {
range_start: string;
range_end: string;
lease_duration: string;
};
onSubmit?: (data: FormValues) => Promise<void> | void;
}
interfaces: any;
onSubmit?: (data: DhcpFormValues) => Promise<void> | void;
};
const FormDHCPv6 = ({
processingConfig,
initialValues,
ipv6placeholders,
onSubmit,
}: FormDHCPv6Props) => {
const FormDHCPv6 = ({ processingConfig, ipv6placeholders, interfaces, onSubmit }: FormDHCPv6Props) => {
const { t } = useTranslation();
const interfaces = useSelector((state: RootState) => state.form.DHCP_INTERFACES);
const interface_name = interfaces?.values?.interface_name;
const isInterfaceIncludesIpv6 = useSelector(
(state: RootState) => !!state.dhcp?.interfaces?.[interface_name]?.ipv6_addresses,
);
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
watch,
} = useForm<FormValues>({
mode: 'onChange',
defaultValues: {
v6: initialValues?.v6 || {
range_start: '',
range_end: '',
lease_duration: 0,
},
},
});
} = useFormContext<DhcpFormValues>();
const interfaceName = watch('interface_name');
const isInterfaceIncludesIpv6 = interfaces?.[interfaceName]?.ipv6_addresses;
const formValues = watch('v6');
const isEmptyConfig = !Object.values(formValues || {}).some(Boolean);
@@ -85,8 +65,9 @@ const FormDHCPv6 = ({
{...register('v6.range_start', {
validate: {
ipv6: validateIpv6,
required: (value) => isEmptyConfig ? undefined : validateRequiredValue(value),
}
required: (value) =>
isEmptyConfig ? undefined : validateRequiredValue(value),
},
})}
/>
{errors.v6?.range_start && (
@@ -105,8 +86,9 @@ const FormDHCPv6 = ({
{...register('v6.range_end', {
validate: {
ipv6: validateIpv6,
required: (value) => isEmptyConfig ? undefined : validateRequiredValue(value),
}
required: (value) =>
isEmptyConfig ? undefined : validateRequiredValue(value),
},
})}
/>
{errors.v6?.range_end && (
@@ -133,14 +115,12 @@ const FormDHCPv6 = ({
{...register('v6.lease_duration', {
valueAsNumber: true,
validate: {
required: (value) => isEmptyConfig ? undefined : validateRequiredValue(value),
}
required: (value) => (isEmptyConfig ? undefined : validateRequiredValue(value)),
},
})}
/>
{errors.v6?.lease_duration && (
<div className="form__message form__message--error">
{t(errors.v6.lease_duration.message)}
</div>
<div className="form__message form__message--error">{t(errors.v6.lease_duration.message)}</div>
)}
</div>
</div>
@@ -149,8 +129,13 @@ const FormDHCPv6 = ({
<button
type="submit"
className="btn btn-success btn-standard"
disabled={isSubmitting || processingConfig || !isInterfaceIncludesIpv6 || isEmptyConfig || Object.keys(errors).length > 0}
>
disabled={
isSubmitting ||
processingConfig ||
!isInterfaceIncludesIpv6 ||
isEmptyConfig ||
Object.keys(errors).length > 0
}>
{t('save_config')}
</button>
</div>

View File

@@ -1,20 +1,11 @@
import React from 'react';
import { useSelector } from 'react-redux';
import { useForm } from 'react-hook-form';
import { useFormContext } from 'react-hook-form';
import { Trans, useTranslation } from 'react-i18next';
import { validateRequiredValue } from '../../../helpers/validators';
import { RootState } from '../../../initialState';
type FormValues = {
interface_name: string;
};
type InterfacesProps = {
initialValues?: {
interface_name: string;
};
};
import { DhcpFormValues } from '.';
const renderInterfaces = (interfaces: any) =>
Object.keys(interfaces).map((item) => {
@@ -82,19 +73,15 @@ const renderInterfaceValues = ({ gateway_ip, hardware_address, ip_addresses }: R
</div>
);
const Interfaces = ({ initialValues }: InterfacesProps) => {
const Interfaces = () => {
const { t } = useTranslation();
const { processingInterfaces, interfaces, enabled } = useSelector((store: RootState) => store.dhcp);
const {
register,
watch,
formState: { errors },
} = useForm<FormValues>({
mode: 'onChange',
defaultValues: initialValues,
});
} = useFormContext<DhcpFormValues>();
const { processingInterfaces, interfaces, enabled } = useSelector((store: RootState) => store.dhcp);
const interface_name = watch('interface_name');
@@ -116,24 +103,22 @@ const Interfaces = ({ initialValues }: InterfacesProps) => {
disabled={enabled}
{...register('interface_name', {
validate: validateRequiredValue,
})}
>
})}>
<option value="" disabled={enabled}>
{t('dhcp_interface_select')}
</option>
{renderInterfaces(interfaces)}
</select>
{errors.interface_name && (
<div className="form__message form__message--error">
{t(errors.interface_name.message)}
</div>
<div className="form__message form__message--error">{t(errors.interface_name.message)}</div>
)}
</div>
{interfaceValue && renderInterfaceValues({
gateway_ip: interfaceValue.gateway_ip,
hardware_address: interfaceValue.hardware_address,
ip_addresses: interfaceValue.ip_addresses
})}
{interfaceValue &&
renderInterfaceValues({
gateway_ip: interfaceValue.gateway_ip,
hardware_address: interfaceValue.hardware_address,
ip_addresses: interfaceValue.ip_addresses,
})}
</div>
);
};

View File

@@ -4,8 +4,8 @@ import { Trans, useTranslation } from 'react-i18next';
import { shallowEqual, useDispatch, useSelector } from 'react-redux';
import classNames from 'classnames';
import { destroy } from 'redux-form';
import { DHCP_DESCRIPTION_PLACEHOLDERS, DHCP_FORM_NAMES, STATUS_RESPONSE, FORM_NAME } from '../../../helpers/constants';
import { FormProvider, useForm } from 'react-hook-form';
import { DHCP_DESCRIPTION_PLACEHOLDERS, STATUS_RESPONSE } from '../../../helpers/constants';
import Leases from './Leases';
@@ -40,6 +40,36 @@ import {
import './index.css';
import { RootState } from '../../../initialState';
export type DhcpFormValues = {
v4?: {
gateway_ip?: string;
subnet_mask?: string;
range_start?: string;
range_end?: string;
lease_duration?: number;
};
v6?: {
range_start?: string;
range_end?: string;
lease_duration?: number;
};
interface_name?: string;
};
const DEFAULT_V4_VALUES = {
gateway_ip: '',
subnet_mask: '',
range_start: '',
range_end: '',
lease_duration: 0,
};
const DEFAULT_V6_VALUES = {
range_start: '',
range_end: '',
lease_duration: 0,
};
const Dhcp = () => {
const { t } = useTranslation();
const dispatch = useDispatch();
@@ -65,14 +95,21 @@ const Dhcp = () => {
modalType,
} = useSelector((state: RootState) => state.dhcp, shallowEqual);
const interface_name = useSelector(
(state: RootState) => state.form[FORM_NAME.DHCP_INTERFACES]?.values?.interface_name,
);
const methods = useForm<DhcpFormValues>({
mode: 'onChange',
defaultValues: {
v4: v4 || DEFAULT_V4_VALUES,
v6: v6 || DEFAULT_V6_VALUES,
interface_name: interfaceName || '',
},
});
const { watch, reset } = methods;
const interface_name = watch('interface_name');
const isInterfaceIncludesIpv4 = useSelector(
(state: RootState) => !!state.dhcp?.interfaces?.[interface_name]?.ipv4_addresses,
);
const dhcp = useSelector((state: RootState) => state.form[FORM_NAME.DHCPv4], shallowEqual);
const ipv4Config = watch('v4');
const [ipv4placeholders, setIpv4Placeholders] = useState(DHCP_DESCRIPTION_PLACEHOLDERS.ipv4);
const [ipv6placeholders, setIpv6Placeholders] = useState(DHCP_DESCRIPTION_PLACEHOLDERS.ipv6);
@@ -87,6 +124,16 @@ const Dhcp = () => {
}
}, [dhcp_available]);
useEffect(() => {
if (v4 || v6 || interfaceName) {
reset({
v4: v4 || DEFAULT_V4_VALUES,
v6: v6 || DEFAULT_V6_VALUES,
interface_name: interfaceName || '',
});
}
}, [v4, v6, interfaceName, reset]);
useEffect(() => {
const [ipv4] = interfaces?.[interface_name]?.ipv4_addresses ?? [];
const [ipv6] = interfaces?.[interface_name]?.ipv6_addresses ?? [];
@@ -105,7 +152,11 @@ const Dhcp = () => {
const clear = () => {
// eslint-disable-next-line no-alert
if (window.confirm(t('dhcp_reset'))) {
Object.values(DHCP_FORM_NAMES).forEach((formName: any) => dispatch(destroy(formName)));
reset({
v4: DEFAULT_V4_VALUES,
v6: DEFAULT_V6_VALUES,
interface_name: '',
});
dispatch(resetDhcp());
dispatch(getDhcpStatus());
}
@@ -170,9 +221,6 @@ const Dhcp = () => {
const toggleModal = () => dispatch(toggleLeaseModal());
const initialV4 = enteredSomeV4Value ? v4 : {};
const initialV6 = enteredSomeV6Value ? v6 : {};
if (processing || processingInterfaces) {
return <Loading />;
}
@@ -193,15 +241,13 @@ const Dhcp = () => {
const toggleDhcpButton = getToggleDhcpButton();
const inputtedIPv4values = dhcp?.values?.v4?.gateway_ip && dhcp?.values?.v4?.subnet_mask;
const inputtedIPv4values = ipv4Config.gateway_ip && ipv4Config.subnet_mask;
const isEmptyConfig = !Object.values(dhcp?.values?.v4 ?? {}).some(Boolean);
const isEmptyConfig = !Object.values(ipv4Config).some(Boolean);
const disabledLeasesButton = Boolean(
dhcp?.syncErrors || !isInterfaceIncludesIpv4 || isEmptyConfig || processingConfig || !inputtedIPv4values,
!isInterfaceIncludesIpv4 || isEmptyConfig || processingConfig || !inputtedIPv4values,
);
const cidr = inputtedIPv4values
? `${dhcp?.values?.v4?.gateway_ip}/${subnetMaskToBitMask(dhcp?.values?.v4?.subnet_mask)}`
: '';
const cidr = inputtedIPv4values ? `${ipv4Config.gateway_ip}/${subnetMaskToBitMask(ipv4Config.subnet_mask)}` : '';
return (
<>
@@ -239,29 +285,32 @@ const Dhcp = () => {
</div>
)}
<Interfaces initialValues={{ interface_name: interfaceName }} />
<FormProvider {...methods}>
<Interfaces />
<Card title={t('dhcp_ipv4_settings')} bodyType="card-body box-body--settings">
<div>
<FormDHCPv4
onSubmit={handleSubmit}
initialValues={{ v4: initialV4 }}
processingConfig={processingConfig}
ipv4placeholders={ipv4placeholders}
/>
</div>
</Card>
<Card title={t('dhcp_ipv4_settings')} bodyType="card-body box-body--settings">
<div>
<FormDHCPv4
onSubmit={handleSubmit}
processingConfig={processingConfig}
ipv4placeholders={ipv4placeholders}
interfaces={interfaces}
/>
</div>
</Card>
<Card title={t('dhcp_ipv6_settings')} bodyType="card-body box-body--settings">
<div>
<FormDHCPv6
onSubmit={handleSubmit}
processingConfig={processingConfig}
ipv6placeholders={ipv6placeholders}
interfaces={interfaces}
/>
</div>
</Card>
</FormProvider>
<Card title={t('dhcp_ipv6_settings')} bodyType="card-body box-body--settings">
<div>
<FormDHCPv6
onSubmit={handleSubmit}
initialValues={{ v6: initialV6 }}
processingConfig={processingConfig}
ipv6placeholders={ipv6placeholders}
/>
</div>
</Card>
{enabled && (
<Card title={t('dhcp_leases')} bodyType="card-body box-body--settings">
<div className="row">
@@ -283,7 +332,7 @@ const Dhcp = () => {
processingDeleting={processingDeleting}
processingUpdating={processingUpdating}
cidr={cidr}
gatewayIp={dhcp?.values?.v4?.gateway_ip}
gatewayIp={ipv4Config.gateway_ip}
/>
<div className="btn-list mt-2">

View File

@@ -181,7 +181,6 @@ export const Form = ({
if (JSON.stringify(previousValues) !== JSON.stringify(watchedValues)) {
// TODO onChange TLS config validation
console.log('TLS config validation');
previousValuesRef.current = watchedValues;
}
}, [watchedValues]);

View File

@@ -1,63 +0,0 @@
import React from 'react';
type Props = {
id?: string;
className?: string;
placeholder?: string;
type?: string;
disabled?: boolean;
autoComplete?: string;
isActionAvailable?: boolean;
removeField?: () => void;
normalizeOnBlur?: (value: string) => string;
value: string;
onChange: (value: string) => void;
onBlur: () => void;
};
export const InputGroup = ({
id,
className,
placeholder,
type,
disabled,
autoComplete,
isActionAvailable,
removeField,
normalizeOnBlur,
value,
onChange,
onBlur,
}: Props) => {
const handleBlur = (event: React.FocusEvent<HTMLInputElement>) => {
if (normalizeOnBlur) {
onChange(normalizeOnBlur(event.target.value));
}
onBlur();
};
return (
<div className="input-group">
<input
id={id}
placeholder={placeholder}
type={type}
className={className}
disabled={disabled}
autoComplete={autoComplete}
value={value}
onChange={(e) => onChange(e.target.value)}
onBlur={handleBlur}
/>
{isActionAvailable && (
<span className="input-group-append">
<button type="button" className="btn btn-secondary btn-icon btn-icon--green" onClick={removeField}>
<svg className="icon icon--24">
<use xlinkHref="#cross" />
</svg>
</button>
</span>
)}
</div>
);
};

View File

@@ -7,26 +7,23 @@ interface Props extends ComponentProps<'textarea'> {
error?: string;
}
export const Textarea = forwardRef<HTMLTextAreaElement, Props>(
({ name, label, className, error, onClick, ...rest }, ref) => (
<div className={clsx('form-group', { 'has-error': !!error })}>
{label && (
<label className="form__label" htmlFor={name}>
{label}
</label>
export const Textarea = forwardRef<HTMLTextAreaElement, Props>(({ name, label, className, error, ...rest }, ref) => (
<div className={clsx('form-group', { 'has-error': !!error })}>
{label && (
<label className="form__label" htmlFor={name}>
{label}
</label>
)}
<textarea
className={clsx(
'form-control form-control--textarea form-control--textarea-small font-monospace',
className,
)}
<textarea
onClick={onClick}
className={clsx(
'form-control form-control--textarea form-control--textarea-small font-monospace',
className,
)}
ref={ref}
{...rest}
/>
{error && <div className="form__message form__message--error">{error}</div>}
</div>
),
);
ref={ref}
{...rest}
/>
{error && <div className="form__message form__message--error">{error}</div>}
</div>
));
Textarea.displayName = 'Textarea';

View File

@@ -757,12 +757,9 @@ type NestedObject = {
order: number;
};
export const getObjectKeysSorted = <
T extends Record<string, NestedObject>,
K extends keyof NestedObject
>(
export const getObjectKeysSorted = <T extends Record<string, NestedObject>, K extends keyof NestedObject>(
object: T,
sortKey: K
sortKey: K,
): string[] => {
return Object.entries(object)
.sort(([, a], [, b]) => (a[sortKey] as number) - (b[sortKey] as number))