Merge pull request #554 from ylei-tsubame/issues/413-manifest-suggest-ifn

Web UI: patch 485, 425, 413
main
Digimer 1 year ago committed by GitHub
commit 3438186b02
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 2
      striker-ui-api/out/index.js
  2. 16
      striker-ui-api/src/lib/request_handlers/manifest/buildManifest.ts
  3. 13
      striker-ui/components/BriefNetworkInterface.tsx
  4. 49
      striker-ui/components/InputWithRef.tsx
  5. 17
      striker-ui/components/ManageManifest/AddManifestInputGroup.tsx
  6. 69
      striker-ui/components/ManageManifest/AnHostConfigInputGroup.tsx
  7. 1
      striker-ui/components/ManageManifest/AnHostInputGroup.tsx
  8. 203
      striker-ui/components/ManageManifest/AnIdInputGroup.tsx
  9. 148
      striker-ui/components/ManageManifest/AnNetworkConfigInputGroup.tsx
  10. 49
      striker-ui/components/ManageManifest/AnNetworkInputGroup.tsx
  11. 7
      striker-ui/components/ManageManifest/ManageManifestPanel.tsx
  12. 2
      striker-ui/components/NetworkInitForm.tsx
  13. 8
      striker-ui/components/UncontrolledInput.tsx
  14. 1
      striker-ui/out/_next/static/Kz-iFpfWQR4uU-BQo8Tpi/_buildManifest.js
  15. 0
      striker-ui/out/_next/static/Kz-iFpfWQR4uU-BQo8Tpi/_middlewareManifest.js
  16. 0
      striker-ui/out/_next/static/Kz-iFpfWQR4uU-BQo8Tpi/_ssgManifest.js
  17. 1
      striker-ui/out/_next/static/L_YVfOsZ3q029Wna3HXeD/_buildManifest.js
  18. 1
      striker-ui/out/_next/static/chunks/336-33ece0c8120f3bd4.js
  19. 1
      striker-ui/out/_next/static/chunks/336-fc22c38ce3bd59c5.js
  20. 1
      striker-ui/out/_next/static/chunks/86-9d0634bddd7b8dc2.js
  21. 1
      striker-ui/out/_next/static/chunks/86-af7e2d6c5444a983.js
  22. 2
      striker-ui/out/_next/static/chunks/pages/config-0c3fc9e77c3ed0ed.js
  23. 1
      striker-ui/out/_next/static/chunks/pages/init-a4caa81141ec112f.js
  24. 1
      striker-ui/out/_next/static/chunks/pages/init-b774a276c8a4ad79.js
  25. 2
      striker-ui/out/_next/static/chunks/pages/login-452bcef79590e137.js
  26. 1
      striker-ui/out/_next/static/chunks/pages/manage-element-0c2dc758c633b42d.js
  27. 1
      striker-ui/out/_next/static/chunks/pages/manage-element-e577aadd99900dcb.js
  28. 2
      striker-ui/out/anvil.html
  29. 2
      striker-ui/out/config.html
  30. 2
      striker-ui/out/file-manager.html
  31. 2
      striker-ui/out/index.html
  32. 2
      striker-ui/out/init.html
  33. 2
      striker-ui/out/login.html
  34. 2
      striker-ui/out/manage-element.html
  35. 2
      striker-ui/out/server.html
  36. 30
      striker-ui/types/ManageManifest.d.ts
  37. 5
      striker-ui/types/UncontrolledInput.d.ts

File diff suppressed because one or more lines are too long

@ -173,16 +173,16 @@ export const buildManifest = async (
`Host number must be an integer; got [${hostNumber}]`, `Host number must be an integer; got [${hostNumber}]`,
); );
assert( if (ipmiIp) {
REP_IPV4.test(ipmiIp), assert(
`IPMI IP of ${hostId} must be an IPv4; got [${ipmiIp}]`, REP_IPV4.test(ipmiIp),
); `IPMI IP of ${hostId} must be an IPv4; got [${ipmiIp}]`,
);
assert.ok(networks, `Host networks is required`);
const ipmiIpKey = `${hostId}_ipmi_ip`; hosts[`${hostId}_ipmi_ip`] = ipmiIp;
}
hosts[ipmiIpKey] = ipmiIp; assert.ok(networks, `Host networks is required`);
try { try {
Object.values(networks).forEach( Object.values(networks).forEach(

@ -9,8 +9,7 @@ import { Close as MUICloseIcon } from '@mui/icons-material';
import { BLACK, BORDER_RADIUS, GREY } from '../lib/consts/DEFAULT_THEME'; import { BLACK, BORDER_RADIUS, GREY } from '../lib/consts/DEFAULT_THEME';
import Decorator from './Decorator'; import { MonoText } from './Text';
import { BodyText } from './Text';
type BriefNetworkInterfaceOptionalProps = { type BriefNetworkInterfaceOptionalProps = {
isFloating?: boolean; isFloating?: boolean;
@ -32,7 +31,7 @@ const BriefNetworkInterface: FC<
} }
> = ({ > = ({
isFloating, isFloating,
networkInterface: { networkInterfaceName, networkInterfaceState }, networkInterface: { networkInterfaceName },
onClose, onClose,
sx: rootSx, sx: rootSx,
...restRootProps ...restRootProps
@ -54,9 +53,9 @@ const BriefNetworkInterface: FC<
sx: { sx: {
display: 'flex', display: 'flex',
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center',
'& > :not(:first-child)': { '& > :not(:first-child)': {
alignSelf: 'center',
marginLeft: '.5em', marginLeft: '.5em',
}, },
@ -67,11 +66,7 @@ const BriefNetworkInterface: FC<
...restRootProps, ...restRootProps,
}} }}
> >
<Decorator <MonoText>{networkInterfaceName}</MonoText>
colour={networkInterfaceState === 'up' ? 'ok' : 'off'}
sx={{ height: 'auto' }}
/>
<BodyText text={networkInterfaceName} />
{onClose && ( {onClose && (
<MUIIconButton onClick={onClose} size="small" sx={{ color: GREY }}> <MUIIconButton onClick={onClose} size="small" sx={{ color: GREY }}>
<MUICloseIcon /> <MUICloseIcon />

@ -1,4 +1,5 @@
import { InputBaseProps } from '@mui/material'; import { InputBaseProps } from '@mui/material';
import { debounce } from 'lodash';
import { import {
cloneElement, cloneElement,
ForwardedRef, ForwardedRef,
@ -24,6 +25,7 @@ type InputWithRefOptionalPropsWithDefault<
type InputWithRefOptionalPropsWithoutDefault< type InputWithRefOptionalPropsWithoutDefault<
TypeName extends keyof MapToInputType, TypeName extends keyof MapToInputType,
> = { > = {
debounceWait?: number;
inputTestBatch?: InputTestBatch; inputTestBatch?: InputTestBatch;
onBlurAppend?: InputBaseProps['onBlur']; onBlurAppend?: InputBaseProps['onBlur'];
onFirstRender?: InputFirstRenderFunction; onFirstRender?: InputFirstRenderFunction;
@ -62,6 +64,7 @@ const INPUT_WITH_REF_DEFAULT_PROPS: Required<
> & > &
InputWithRefOptionalPropsWithoutDefault<'string'> = { InputWithRefOptionalPropsWithoutDefault<'string'> = {
createInputOnChangeHandlerOptions: {}, createInputOnChangeHandlerOptions: {},
debounceWait: 500,
required: false, required: false,
valueType: 'string', valueType: 'string',
}; };
@ -69,6 +72,7 @@ const INPUT_WITH_REF_DEFAULT_PROPS: Required<
const InputWithRef = forwardRef( const InputWithRef = forwardRef(
<TypeName extends keyof MapToInputType, InputComponent extends ReactElement>( <TypeName extends keyof MapToInputType, InputComponent extends ReactElement>(
{ {
debounceWait = INPUT_WITH_REF_DEFAULT_PROPS.debounceWait,
input, input,
inputTestBatch, inputTestBatch,
onBlurAppend, onBlurAppend,
@ -125,6 +129,25 @@ const InputWithRef = forwardRef(
return result; return result;
}, [inputTestBatch, isRequired]); }, [inputTestBatch, isRequired]);
const doTestAndSet = useCallback(
(value: MapToInputType[TypeName]) => {
const valid =
testInput?.call(null, {
inputs: { [INPUT_TEST_ID]: { value } },
isIgnoreOnCallbacks: true,
}) ?? false;
onFirstRender?.call(null, { isValid: valid });
setIsInputValid(valid);
},
[onFirstRender, testInput],
);
const debounceDoTestAndSet = useMemo(
() => debounce(doTestAndSet, debounceWait),
[debounceWait, doTestAndSet],
);
const onBlur = useMemo<InputBaseProps['onBlur']>( const onBlur = useMemo<InputBaseProps['onBlur']>(
() => () =>
initOnBlur ?? initOnBlur ??
@ -152,12 +175,16 @@ const InputWithRef = forwardRef(
initOnChange?.call(null, ...args); initOnChange?.call(null, ...args);
postSetAppend?.call(null, ...args); postSetAppend?.call(null, ...args);
}, },
set: setValue, set: (value) => {
setValue(value);
debounceDoTestAndSet(value as MapToInputType[TypeName]);
},
setType: valueType, setType: valueType,
valueKey: vKey, valueKey: vKey,
...restCreateInputOnChangeHandlerOptions, ...restCreateInputOnChangeHandlerOptions,
}), }),
[ [
debounceDoTestAndSet,
initOnChange, initOnChange,
postSetAppend, postSetAppend,
restCreateInputOnChangeHandlerOptions, restCreateInputOnChangeHandlerOptions,
@ -185,19 +212,25 @@ const InputWithRef = forwardRef(
* render function completes. * render function completes.
*/ */
useEffect(() => { useEffect(() => {
const isValid = doTestAndSet(inputValue);
testInput?.call(null, {
inputs: { [INPUT_TEST_ID]: { value: inputValue } },
isIgnoreOnCallbacks: true,
}) ?? false;
onFirstRender?.call(null, { isValid });
return onUnmount; return onUnmount;
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
/**
* Update the input value to the init value until it's changed by the user.
* This allows us to populate the input based on value from other field(s).
*/
useEffect(() => {
if (isChangedByUser || inputValue === initValue || !initValue) return;
doTestAndSet(initValue);
setInputValue(initValue);
}, [doTestAndSet, initValue, inputValue, isChangedByUser]);
useImperativeHandle( useImperativeHandle(
ref, ref,
() => ({ () => ({

@ -57,6 +57,10 @@ const AddManifestInputGroup = <
const { networks: previousNetworkList = DEFAULT_NETWORK_LIST } = const { networks: previousNetworkList = DEFAULT_NETWORK_LIST } =
previousNetworkConfig; previousNetworkConfig;
const [anSequence, setAnSequence] = useState<number>(
previousAnId?.sequence ?? 0,
);
const [networkList, setNetworkList] = const [networkList, setNetworkList] =
useState<ManifestNetworkList>(previousNetworkList); useState<ManifestNetworkList>(previousNetworkList);
@ -67,7 +71,17 @@ const AddManifestInputGroup = <
return ( return (
<FlexBox> <FlexBox>
<AnIdInputGroup formUtils={formUtils} previous={previousAnId} /> <AnIdInputGroup
formUtils={formUtils}
onSequenceChange={(event) => {
const {
target: { value },
} = event;
setAnSequence(Number(value));
}}
previous={previousAnId}
/>
<AnNetworkConfigInputGroup <AnNetworkConfigInputGroup
formUtils={formUtils} formUtils={formUtils}
networkListEntries={networkListEntries} networkListEntries={networkListEntries}
@ -75,6 +89,7 @@ const AddManifestInputGroup = <
setNetworkList={setNetworkList} setNetworkList={setNetworkList}
/> />
<AnHostConfigInputGroup <AnHostConfigInputGroup
anSequence={anSequence}
formUtils={formUtils} formUtils={formUtils}
knownFences={knownFences} knownFences={knownFences}
knownUpses={knownUpses} knownUpses={knownUpses}

@ -1,3 +1,4 @@
import { Netmask } from 'netmask';
import { ReactElement, useMemo } from 'react'; import { ReactElement, useMemo } from 'react';
import AnHostInputGroup from './AnHostInputGroup'; import AnHostInputGroup from './AnHostInputGroup';
@ -19,7 +20,39 @@ const DEFAULT_HOST_LIST: ManifestHostList = {
}, },
}; };
const guessHostIpOnNetwork = ({
anSeq,
minIp,
offset3 = 10,
step3 = 2,
subnetMask,
subSeq,
}: {
anSeq: number;
minIp: string;
offset3?: number;
step3?: number;
subnetMask: string;
subSeq: number;
}): string => {
try {
const block = new Netmask(`${minIp}/${subnetMask}`);
if (block.bitmask !== 16) {
return `${block.base.replace(/\.0/g, '')}.`;
}
const third = (anSeq - 1) * step3 + offset3;
const fourth = subSeq;
return minIp.replace(/^((\d+\.){2})\d+\.\d+$/, `$1${third}.${fourth}`);
} catch (error) {
return '';
}
};
const AnHostConfigInputGroup = <M extends MapToInputTestID>({ const AnHostConfigInputGroup = <M extends MapToInputTestID>({
anSequence,
formUtils, formUtils,
knownFences = {}, knownFences = {},
knownUpses = {}, knownUpses = {},
@ -47,11 +80,13 @@ const AnHostConfigInputGroup = <M extends MapToInputTestID>({
fences: previousFenceList = {}, fences: previousFenceList = {},
hostNumber, hostNumber,
hostType, hostType,
ipmiIp, ipmiIp: previousIpmiIp,
networks: previousNetworkList = {}, networks: previousNetworkList = {},
upses: previousUpsList = {}, upses: previousUpsList = {},
}: ManifestHost = previousHostArgs; }: ManifestHost = previousHostArgs;
let ipmiIp = previousIpmiIp;
const fences = knownFenceListValues.reduce<ManifestHostFenceList>( const fences = knownFenceListValues.reduce<ManifestHostFenceList>(
(fenceList, { fenceName }) => { (fenceList, { fenceName }) => {
const { [fenceName]: { fencePort = '' } = {} } = const { [fenceName]: { fencePort = '' } = {} } =
@ -63,11 +98,37 @@ const AnHostConfigInputGroup = <M extends MapToInputTestID>({
}, },
{}, {},
); );
const networks = networkListEntries.reduce<ManifestHostNetworkList>( const networks = networkListEntries.reduce<ManifestHostNetworkList>(
(networkList, [networkId, { networkNumber, networkType }]) => { (
const { [networkId]: { networkIp = '' } = {} } = networkList,
[
networkId,
{ networkMinIp, networkNumber, networkSubnetMask, networkType },
],
) => {
let { [networkId]: { networkIp = '' } = {} } =
previousNetworkList; previousNetworkList;
if (!networkIp) {
networkIp = guessHostIpOnNetwork({
anSeq: anSequence,
minIp: networkMinIp,
subnetMask: networkSubnetMask,
subSeq: hostNumber,
});
}
if (!ipmiIp && networkType === 'bcn' && networkNumber === 1) {
ipmiIp = guessHostIpOnNetwork({
anSeq: anSequence,
minIp: networkMinIp,
offset3: 11,
subnetMask: networkSubnetMask,
subSeq: hostNumber,
});
}
networkList[networkId] = { networkList[networkId] = {
networkIp, networkIp,
networkNumber, networkNumber,
@ -78,6 +139,7 @@ const AnHostConfigInputGroup = <M extends MapToInputTestID>({
}, },
{}, {},
); );
const upses = knownUpsListValues.reduce<ManifestHostUpsList>( const upses = knownUpsListValues.reduce<ManifestHostUpsList>(
(upsList, { upsName }) => { (upsList, { upsName }) => {
const { [upsName]: { isUsed = true } = {} } = previousUpsList; const { [upsName]: { isUsed = true } = {} } = previousUpsList;
@ -110,6 +172,7 @@ const AnHostConfigInputGroup = <M extends MapToInputTestID>({
{}, {},
), ),
[ [
anSequence,
formUtils, formUtils,
hostListEntries, hostListEntries,
knownFenceListValues, knownFenceListValues,

@ -377,7 +377,6 @@ const AnHostInputGroup = <M extends MapToInputTestID>({
inputIdAHIpmiIp, inputIdAHIpmiIp,
)} )}
onUnmount={buildInputUnmountFunction(inputIdAHIpmiIp)} onUnmount={buildInputUnmountFunction(inputIdAHIpmiIp)}
required
/> />
), ),
}, },

@ -1,4 +1,5 @@
import { ReactElement } from 'react'; import { debounce } from 'lodash';
import { ReactElement, useMemo } from 'react';
import Grid from '../Grid'; import Grid from '../Grid';
import InputWithRef from '../InputWithRef'; import InputWithRef from '../InputWithRef';
@ -26,109 +27,123 @@ const AnIdInputGroup = <
| typeof INPUT_ID_AI_SEQUENCE]: string; | typeof INPUT_ID_AI_SEQUENCE]: string;
}, },
>({ >({
debounceWait = 500,
formUtils: { formUtils: {
buildFinishInputTestBatchFunction, buildFinishInputTestBatchFunction,
buildInputFirstRenderFunction, buildInputFirstRenderFunction,
setMessage, setMessage,
}, },
onSequenceChange,
previous: { previous: {
domain: previousDomain, domain: previousDomain,
prefix: previousPrefix, prefix: previousPrefix,
sequence: previousSequence, sequence: previousSequence,
} = {}, } = {},
}: AnIdInputGroupProps<M>): ReactElement => ( }: AnIdInputGroupProps<M>): ReactElement => {
<Grid const debounceSequenceChangeHandler = useMemo(
columns={{ xs: 1, sm: 2, md: 3 }} () => onSequenceChange && debounce(onSequenceChange, debounceWait),
layout={{ [debounceWait, onSequenceChange],
'an-id-input-cell-prefix': { );
children: (
<InputWithRef return (
input={ <Grid
<OutlinedInputWithLabel columns={{ xs: 1, sm: 2, md: 3 }}
id={INPUT_ID_AI_PREFIX} layout={{
label={INPUT_LABEL_AI_PREFIX} 'an-id-input-cell-prefix': {
value={previousPrefix} children: (
/> <InputWithRef
} input={
inputTestBatch={buildPeacefulStringTestBatch( <OutlinedInputWithLabel
INPUT_LABEL_AI_PREFIX, id={INPUT_ID_AI_PREFIX}
() => { label={INPUT_LABEL_AI_PREFIX}
setMessage(INPUT_ID_AI_PREFIX); value={previousPrefix}
}, />
{ }
onFinishBatch: inputTestBatch={buildPeacefulStringTestBatch(
buildFinishInputTestBatchFunction(INPUT_ID_AI_PREFIX), INPUT_LABEL_AI_PREFIX,
}, () => {
(message) => { setMessage(INPUT_ID_AI_PREFIX);
setMessage(INPUT_ID_AI_PREFIX, { children: message }); },
}, {
)} onFinishBatch:
onFirstRender={buildInputFirstRenderFunction(INPUT_ID_AI_PREFIX)} buildFinishInputTestBatchFunction(INPUT_ID_AI_PREFIX),
required },
/> (message) => {
), setMessage(INPUT_ID_AI_PREFIX, { children: message });
}, },
'an-id-input-cell-domain': { )}
children: ( onFirstRender={buildInputFirstRenderFunction(INPUT_ID_AI_PREFIX)}
<InputWithRef required
input={ />
<OutlinedInputWithLabel ),
id={INPUT_ID_AI_DOMAIN} },
label={INPUT_LABEL_AI_DOMAIN} 'an-id-input-cell-domain': {
value={previousDomain} children: (
/> <InputWithRef
} input={
inputTestBatch={buildPeacefulStringTestBatch( <OutlinedInputWithLabel
INPUT_LABEL_AI_DOMAIN, id={INPUT_ID_AI_DOMAIN}
() => { label={INPUT_LABEL_AI_DOMAIN}
setMessage(INPUT_ID_AI_DOMAIN); value={previousDomain}
}, />
{ }
onFinishBatch: inputTestBatch={buildPeacefulStringTestBatch(
buildFinishInputTestBatchFunction(INPUT_ID_AI_DOMAIN), INPUT_LABEL_AI_DOMAIN,
}, () => {
(message) => { setMessage(INPUT_ID_AI_DOMAIN);
setMessage(INPUT_ID_AI_DOMAIN, { children: message }); },
}, {
)} onFinishBatch:
onFirstRender={buildInputFirstRenderFunction(INPUT_ID_AI_DOMAIN)} buildFinishInputTestBatchFunction(INPUT_ID_AI_DOMAIN),
required },
/> (message) => {
), setMessage(INPUT_ID_AI_DOMAIN, { children: message });
}, },
'an-id-input-cell-sequence': { )}
children: ( onFirstRender={buildInputFirstRenderFunction(INPUT_ID_AI_DOMAIN)}
<InputWithRef required
input={ />
<OutlinedInputWithLabel ),
id={INPUT_ID_AI_SEQUENCE} },
label={INPUT_LABEL_AI_SEQUENCE} 'an-id-input-cell-sequence': {
value={previousSequence} children: (
/> <InputWithRef
} createInputOnChangeHandlerOptions={{
inputTestBatch={buildNumberTestBatch( postSet: debounceSequenceChangeHandler,
INPUT_LABEL_AI_SEQUENCE, }}
() => { input={
setMessage(INPUT_ID_AI_SEQUENCE); <OutlinedInputWithLabel
}, id={INPUT_ID_AI_SEQUENCE}
{ label={INPUT_LABEL_AI_SEQUENCE}
onFinishBatch: value={previousSequence}
buildFinishInputTestBatchFunction(INPUT_ID_AI_SEQUENCE), />
}, }
(message) => { inputTestBatch={buildNumberTestBatch(
setMessage(INPUT_ID_AI_SEQUENCE, { children: message }); INPUT_LABEL_AI_SEQUENCE,
}, () => {
)} setMessage(INPUT_ID_AI_SEQUENCE);
onFirstRender={buildInputFirstRenderFunction(INPUT_ID_AI_SEQUENCE)} },
required {
valueType="number" onFinishBatch:
/> buildFinishInputTestBatchFunction(INPUT_ID_AI_SEQUENCE),
), },
}, (message) => {
}} setMessage(INPUT_ID_AI_SEQUENCE, { children: message });
spacing="1em" },
/> )}
); onFirstRender={buildInputFirstRenderFunction(
INPUT_ID_AI_SEQUENCE,
)}
required
valueType="number"
/>
),
},
}}
spacing="1em"
/>
);
};
export { INPUT_ID_AI_DOMAIN, INPUT_ID_AI_PREFIX, INPUT_ID_AI_SEQUENCE }; export { INPUT_ID_AI_DOMAIN, INPUT_ID_AI_PREFIX, INPUT_ID_AI_SEQUENCE };

@ -1,3 +1,4 @@
import { Netmask } from 'netmask';
import { ReactElement, useCallback, useMemo } from 'react'; import { ReactElement, useCallback, useMemo } from 'react';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
@ -30,9 +31,46 @@ const DEFAULT_DNS_CSV = '8.8.8.8,8.8.4.4';
const NETWORK_TYPE_ENTRIES = Object.entries(NETWORK_TYPES); const NETWORK_TYPE_ENTRIES = Object.entries(NETWORK_TYPES);
const MAP_TO_NETWORK_DEFAULTS: Record<string, { base: string; mask: string }> =
{
bcn: { base: '10.201.0.0', mask: '255.255.0.0' },
mn: { base: '10.199.0.0', mask: '255.255.0.0' },
sn: { base: '10.101.0.0', mask: '255.255.0.0' },
};
const assertIfn = (type: string) => type === 'ifn'; const assertIfn = (type: string) => type === 'ifn';
const assertMn = (type: string) => type === 'mn'; const assertMn = (type: string) => type === 'mn';
const guessNetworkMinIp = ({
entries,
type,
}: {
entries: [string, ManifestNetwork][];
type: string;
}): { base?: string; mask?: string } => {
const last = entries
.filter(([, { networkType }]) => networkType === type)
.sort(([, { networkNumber: a }], [, { networkNumber: b }]) =>
a > b ? 1 : -1,
)
.pop();
if (!last) {
return MAP_TO_NETWORK_DEFAULTS[type] ?? {};
}
const [, { networkMinIp, networkSubnetMask }] = last;
try {
const block = new Netmask(`${networkMinIp}/${networkSubnetMask}`);
const { base, mask } = block.next();
return { base, mask };
} catch (error) {
return {};
}
};
const AnNetworkConfigInputGroup = < const AnNetworkConfigInputGroup = <
M extends MapToInputTestID & { M extends MapToInputTestID & {
[K in [K in
@ -98,24 +136,36 @@ const AnNetworkConfigInputGroup = <
({ ({
networkMinIp = '', networkMinIp = '',
networkSubnetMask = '', networkSubnetMask = '',
networkType = 'ifn', networkType = networkListEntries.some(([, { networkType: nt }]) =>
assertMn(nt),
)
? 'ifn'
: 'mn',
// Params that depend on others. // Params that depend on others.
networkGateway = assertIfn(networkType) ? '' : undefined, networkGateway = assertIfn(networkType) ? '' : undefined,
networkNumber = getNetworkNumber(networkType) + 1, networkNumber = getNetworkNumber(networkType) + 1,
}: Partial<ManifestNetwork> = {}): { }: Partial<ManifestNetwork> = {}): {
network: ManifestNetwork; network: ManifestNetwork;
networkId: string; networkId: string;
} => ({ } => {
network: { const { base = networkMinIp, mask = networkSubnetMask } =
networkGateway, guessNetworkMinIp({
networkMinIp, entries: networkListEntries,
networkNumber, type: networkType,
networkSubnetMask, });
networkType,
}, return {
networkId: uuidv4(), network: {
}), networkGateway,
[getNetworkNumber], networkMinIp: base,
networkNumber,
networkSubnetMask: mask,
networkType,
},
networkId: uuidv4(),
};
},
[getNetworkNumber, networkListEntries],
); );
const setNetwork = useCallback( const setNetwork = useCallback(
@ -124,6 +174,26 @@ const AnNetworkConfigInputGroup = <
[setNetworkList], [setNetworkList],
); );
const setNetworkProp = useCallback(
<P extends keyof ManifestNetwork>(
nkey: string,
pkey: P,
value: ManifestNetwork[P],
) =>
setNetworkList((previous) => {
const nyu = { ...previous };
const { [nkey]: nw } = nyu;
if (nw) {
nw[pkey] = value;
}
return nyu;
}),
[setNetworkList],
);
const handleNetworkTypeChange = useCallback<AnNetworkTypeChangeEventHandler>( const handleNetworkTypeChange = useCallback<AnNetworkTypeChangeEventHandler>(
( (
{ networkId: targetId, networkType: previousType }, { networkId: targetId, networkType: previousType },
@ -136,7 +206,13 @@ const AnNetworkConfigInputGroup = <
const newList = networkListEntries.reduce<ManifestNetworkList>( const newList = networkListEntries.reduce<ManifestNetworkList>(
(previous, [networkId, networkValue]) => { (previous, [networkId, networkValue]) => {
const { networkNumber: initnn, networkType: initnt } = networkValue; const {
networkNumber: initnn,
networkType: initnt,
networkMinIp: initbase,
networkSubnetMask: initmask,
...restNetworkValue
} = networkValue;
let networkNumber = initnn; let networkNumber = initnn;
let networkType = initnt; let networkType = initnt;
@ -160,8 +236,18 @@ const AnNetworkConfigInputGroup = <
networkNumber -= 1; networkNumber -= 1;
} }
const {
base: networkMinIp = initbase,
mask: networkSubnetMask = initmask,
} = guessNetworkMinIp({
entries: networkListEntries,
type: networkType,
});
previous[networkId] = { previous[networkId] = {
...networkValue, ...restNetworkValue,
networkMinIp,
networkSubnetMask,
networkNumber, networkNumber,
networkType, networkType,
}; };
@ -181,24 +267,29 @@ const AnNetworkConfigInputGroup = <
const handleNetworkRemove = useCallback<AnNetworkCloseEventHandler>( const handleNetworkRemove = useCallback<AnNetworkCloseEventHandler>(
({ networkId: rmId, networkType: rmType }) => { ({ networkId: rmId, networkType: rmType }) => {
let isIdMatch = false; let postMatch = false;
let networkNumber = 0; let networkNumber = 0;
const newList = networkListEntries.reduce<ManifestNetworkList>( const newList = networkListEntries.reduce<ManifestNetworkList>(
(previous, [networkId, networkValue]) => { (previous, [networkId, networkValue]) => {
if (networkId === rmId) { if (networkId === rmId) {
isIdMatch = true; postMatch = true;
} else {
const { networkType } = networkValue;
if (networkType === rmType) { return previous;
networkNumber += 1; }
}
const { networkType } = networkValue;
previous[networkId] = isIdMatch const change = networkType === rmType;
if (change) {
networkNumber += 1;
}
previous[networkId] =
postMatch && change
? { ...networkValue, networkNumber } ? { ...networkValue, networkNumber }
: networkValue; : networkValue;
}
return previous; return previous;
}, },
@ -243,6 +334,14 @@ const AnNetworkConfigInputGroup = <
networkType={networkType} networkType={networkType}
networkTypeOptions={networkTypeOptions} networkTypeOptions={networkTypeOptions}
onClose={handleNetworkRemove} onClose={handleNetworkRemove}
onNetworkMinIpChange={(
{ networkId: nid },
{ target: { value } },
) => setNetworkProp(nid, 'networkMinIp', value)}
onNetworkSubnetMaskChange={(
{ networkId: nid },
{ target: { value } },
) => setNetworkProp(nid, 'networkSubnetMask', value)}
onNetworkTypeChange={handleNetworkTypeChange} onNetworkTypeChange={handleNetworkTypeChange}
previous={{ previous={{
gateway: networkGateway, gateway: networkGateway,
@ -265,11 +364,12 @@ const AnNetworkConfigInputGroup = <
return result; return result;
}, [ }, [
formUtils,
networkListEntries, networkListEntries,
formUtils,
networkTypeOptions, networkTypeOptions,
handleNetworkRemove, handleNetworkRemove,
handleNetworkTypeChange, handleNetworkTypeChange,
setNetworkProp,
]); ]);
return ( return (

@ -1,3 +1,4 @@
import { debounce } from 'lodash';
import { ReactElement, ReactNode, useMemo } from 'react'; import { ReactElement, ReactNode, useMemo } from 'react';
import NETWORK_TYPES from '../../lib/consts/NETWORK_TYPES'; import NETWORK_TYPES from '../../lib/consts/NETWORK_TYPES';
@ -77,6 +78,7 @@ const buildInputIdANSubnetMask = (networkId: string): string =>
`${INPUT_ID_PREFIX_AN_NETWORK}-${networkId}-subnet-mask`; `${INPUT_ID_PREFIX_AN_NETWORK}-${networkId}-subnet-mask`;
const AnNetworkInputGroup = <M extends MapToInputTestID>({ const AnNetworkInputGroup = <M extends MapToInputTestID>({
debounceWait = 500,
formUtils: { formUtils: {
buildFinishInputTestBatchFunction, buildFinishInputTestBatchFunction,
buildInputFirstRenderFunction, buildInputFirstRenderFunction,
@ -91,6 +93,9 @@ const AnNetworkInputGroup = <M extends MapToInputTestID>({
networkType, networkType,
networkTypeOptions, networkTypeOptions,
onClose, onClose,
onNetworkGatewayChange,
onNetworkMinIpChange,
onNetworkSubnetMaskChange,
onNetworkTypeChange, onNetworkTypeChange,
previous: { previous: {
gateway: previousGateway, gateway: previousGateway,
@ -146,6 +151,24 @@ const AnNetworkInputGroup = <M extends MapToInputTestID>({
[isShowGateway], [isShowGateway],
); );
const debounceNetworkGatewayChangeHandler = useMemo(
() =>
onNetworkGatewayChange && debounce(onNetworkGatewayChange, debounceWait),
[debounceWait, onNetworkGatewayChange],
);
const debounceNetworkMinIpChangeHandler = useMemo(
() => onNetworkMinIpChange && debounce(onNetworkMinIpChange, debounceWait),
[debounceWait, onNetworkMinIpChange],
);
const debounceNetworkSubnetMaskChangeHandler = useMemo(
() =>
onNetworkSubnetMaskChange &&
debounce(onNetworkSubnetMaskChange, debounceWait),
[debounceWait, onNetworkSubnetMaskChange],
);
const closeButtonElement = useMemo<ReactNode>( const closeButtonElement = useMemo<ReactNode>(
() => () =>
isShowCloseButton && ( isShowCloseButton && (
@ -172,6 +195,14 @@ const AnNetworkInputGroup = <M extends MapToInputTestID>({
if (isShowGateway && inputIdGateway) { if (isShowGateway && inputIdGateway) {
result = ( result = (
<InputWithRef <InputWithRef
createInputOnChangeHandlerOptions={{
postSet: (...args) =>
debounceNetworkGatewayChangeHandler?.call(
null,
{ networkId, networkType },
...args,
),
}}
input={ input={
<OutlinedInputWithLabel <OutlinedInputWithLabel
baseInputProps={{ baseInputProps={{
@ -213,6 +244,8 @@ const AnNetworkInputGroup = <M extends MapToInputTestID>({
buildFinishInputTestBatchFunction, buildFinishInputTestBatchFunction,
buildInputFirstRenderFunction, buildInputFirstRenderFunction,
buildInputUnmountFunction, buildInputUnmountFunction,
debounceNetworkGatewayChangeHandler,
networkType,
setMessage, setMessage,
]); ]);
@ -256,6 +289,14 @@ const AnNetworkInputGroup = <M extends MapToInputTestID>({
[inputCellIdIp]: { [inputCellIdIp]: {
children: ( children: (
<InputWithRef <InputWithRef
createInputOnChangeHandlerOptions={{
postSet: (...args) =>
debounceNetworkMinIpChangeHandler?.call(
null,
{ networkId, networkType },
...args,
),
}}
input={ input={
<OutlinedInputWithLabel <OutlinedInputWithLabel
baseInputProps={{ baseInputProps={{
@ -289,6 +330,14 @@ const AnNetworkInputGroup = <M extends MapToInputTestID>({
[inputCellIdSubnetMask]: { [inputCellIdSubnetMask]: {
children: ( children: (
<InputWithRef <InputWithRef
createInputOnChangeHandlerOptions={{
postSet: (...args) =>
debounceNetworkSubnetMaskChangeHandler?.call(
null,
{ networkId, networkType },
...args,
),
}}
input={ input={
<OutlinedInputWithLabel <OutlinedInputWithLabel
baseInputProps={{ baseInputProps={{

@ -47,6 +47,8 @@ import useFormUtils from '../../hooks/useFormUtils';
import useIsFirstRender from '../../hooks/useIsFirstRender'; import useIsFirstRender from '../../hooks/useIsFirstRender';
import useProtectedState from '../../hooks/useProtectedState'; import useProtectedState from '../../hooks/useProtectedState';
const REQ_BODY_MAX_DEPTH = 6;
const getFormData = ( const getFormData = (
...[{ target }]: DivFormEventHandlerParameters ...[{ target }]: DivFormEventHandlerParameters
): APIBuildManifestRequestBody => { ): APIBuildManifestRequestBody => {
@ -262,7 +264,7 @@ const ManageManifestPanel: FC = () => {
setConfirmDialogProps({ setConfirmDialogProps({
actionProceedText: 'Add', actionProceedText: 'Add',
content: <FormSummary entries={body} />, content: <FormSummary entries={body} maxDepth={REQ_BODY_MAX_DEPTH} />,
onProceedAppend: () => { onProceedAppend: () => {
submitForm({ submitForm({
body, body,
@ -311,7 +313,7 @@ const ManageManifestPanel: FC = () => {
setConfirmDialogProps({ setConfirmDialogProps({
actionProceedText: 'Edit', actionProceedText: 'Edit',
content: <FormSummary entries={body} />, content: <FormSummary entries={body} maxDepth={REQ_BODY_MAX_DEPTH} />,
onProceedAppend: () => { onProceedAppend: () => {
submitForm({ submitForm({
body, body,
@ -605,6 +607,7 @@ const ManageManifestPanel: FC = () => {
{...confirmDialogProps} {...confirmDialogProps}
ref={confirmDialogRef} ref={confirmDialogRef}
scrollContent scrollContent
wide
/> />
</> </>
); );

@ -233,7 +233,7 @@ const createNetworkInterfaceTableColumns = (
colour={networkInterfaceState === 'up' ? 'ok' : 'off'} colour={networkInterfaceState === 'up' ? 'ok' : 'off'}
sx={{ height: 'auto' }} sx={{ height: 'auto' }}
/> />
<SmallText text={value} /> <MonoText>{value}</MonoText>
</MUIBox> </MUIBox>
), ),
}, },

@ -54,7 +54,9 @@ const UncontrolledInput = forwardRef(
const [value, setValue] = useState<MapToInputType[ValueType]>(inputValue); const [value, setValue] = useState<MapToInputType[ValueType]>(inputValue);
const baseChangeEventHandler = useCallback<ReactChangeEventHandler>( const baseChangeEventHandler = useCallback<
React.ChangeEventHandler<HTMLInputElement>
>(
({ target: { [valueKey]: changed } }) => { ({ target: { [valueKey]: changed } }) => {
const converted = MAP_TO_VALUE_CONVERTER[valueType]( const converted = MAP_TO_VALUE_CONVERTER[valueType](
changed, changed,
@ -65,7 +67,9 @@ const UncontrolledInput = forwardRef(
[valueKey, valueType], [valueKey, valueType],
); );
const changeEventHandler = useCallback<ReactChangeEventHandler>( const changeEventHandler = useCallback<
React.ChangeEventHandler<HTMLInputElement>
>(
(...args) => (...args) =>
onChange?.call( onChange?.call(
null, null,

@ -0,0 +1 @@
self.__BUILD_MANIFEST=function(s,c,a,e,t,n,i,f,u,k,h,j,b,d,r,g,l,_,o,p){return{__rewrites:{beforeFiles:[],afterFiles:[],fallback:[]},"/":[s,a,e,i,u,h,r,"static/chunks/936-f64829e0e2013921.js",c,t,n,f,g,l,"static/chunks/pages/index-8766524a2b0384fc.js"],"/_error":["static/chunks/pages/_error-2280fa386d040b66.js"],"/anvil":[s,a,e,i,u,h,"static/chunks/638-13a283c3a7da370b.js",c,t,n,f,g,"static/chunks/pages/anvil-7fb5cba6fcb66e8c.js"],"/config":[k,s,a,e,b,"static/chunks/519-4b7761e884c88eb9.js",c,t,n,f,j,d,_,"static/chunks/pages/config-0c3fc9e77c3ed0ed.js"],"/file-manager":[k,s,a,e,i,u,"static/chunks/176-7308c25ba374961e.js",c,t,n,j,"static/chunks/pages/file-manager-ef725a93a3e227aa.js"],"/init":[k,s,a,i,u,h,b,o,c,t,n,f,p,"static/chunks/pages/init-a4caa81141ec112f.js"],"/login":[k,s,a,e,c,t,f,j,d,"static/chunks/pages/login-452bcef79590e137.js"],"/manage-element":[k,s,a,e,i,u,h,b,o,"static/chunks/195-d5fd184cc249f755.js",c,t,n,f,j,d,p,_,"static/chunks/pages/manage-element-0c2dc758c633b42d.js"],"/server":[s,e,i,r,c,n,l,"static/chunks/pages/server-97d4cafd19cb2e9d.js"],sortedPages:["/","/_app","/_error","/anvil","/config","/file-manager","/init","/login","/manage-element","/server"]}}("static/chunks/498-e1933a5461cd8607.js","static/chunks/668-b264bf73f0c1b5eb.js","static/chunks/910-2a0e86a170f6eb77.js","static/chunks/894-e57948de523bcf96.js","static/chunks/284-03dc30df5d459e72.js","static/chunks/157-0528651bf3cd10a7.js","static/chunks/839-dabd319a60c8df83.js","static/chunks/27-7790e406eb2ea28d.js","static/chunks/213-a0488f84cc98f172.js","static/chunks/29107295-fbcfe2172188e46f.js","static/chunks/209-4e2794319babfeec.js","static/chunks/48-d4400834d0a31c6e.js","static/chunks/644-4eec2b397fdacb0c.js","static/chunks/336-fc22c38ce3bd59c5.js","static/chunks/570-6bad4610969fc14b.js","static/chunks/707-ee38ab2abcd0aa3f.js","static/chunks/170-357f4683929223df.js","static/chunks/560-a9c9ecda0eca25a9.js","static/chunks/404-b8e9ff2043a0d30c.js","static/chunks/86-9d0634bddd7b8dc2.js"),self.__BUILD_MANIFEST_CB&&self.__BUILD_MANIFEST_CB();

@ -1 +0,0 @@
self.__BUILD_MANIFEST=function(s,c,e,a,t,n,i,f,u,k,h,j,b,d,r,g,l,_,o){return{__rewrites:{beforeFiles:[],afterFiles:[],fallback:[]},"/":[s,e,a,i,u,k,d,"static/chunks/936-f64829e0e2013921.js",c,t,n,f,r,g,"static/chunks/pages/index-8766524a2b0384fc.js"],"/_error":["static/chunks/pages/_error-2280fa386d040b66.js"],"/anvil":[s,e,a,i,u,k,"static/chunks/638-13a283c3a7da370b.js",c,t,n,f,r,"static/chunks/pages/anvil-7fb5cba6fcb66e8c.js"],"/config":[s,e,a,j,"static/chunks/519-4b7761e884c88eb9.js",c,t,n,f,h,b,l,"static/chunks/pages/config-e3aa9a84a8baacc1.js"],"/file-manager":["static/chunks/29107295-fbcfe2172188e46f.js",s,e,a,i,u,"static/chunks/176-7308c25ba374961e.js",c,t,n,h,"static/chunks/pages/file-manager-ef725a93a3e227aa.js"],"/init":[s,e,i,u,k,j,_,c,t,n,f,o,"static/chunks/pages/init-b774a276c8a4ad79.js"],"/login":[s,e,a,c,t,f,h,b,"static/chunks/pages/login-270fe7adf9f44c67.js"],"/manage-element":[s,e,a,i,u,k,j,_,"static/chunks/195-d5fd184cc249f755.js",c,t,n,f,h,b,o,l,"static/chunks/pages/manage-element-e577aadd99900dcb.js"],"/server":[s,a,i,d,c,n,g,"static/chunks/pages/server-97d4cafd19cb2e9d.js"],sortedPages:["/","/_app","/_error","/anvil","/config","/file-manager","/init","/login","/manage-element","/server"]}}("static/chunks/498-e1933a5461cd8607.js","static/chunks/668-b264bf73f0c1b5eb.js","static/chunks/910-2a0e86a170f6eb77.js","static/chunks/894-e57948de523bcf96.js","static/chunks/284-03dc30df5d459e72.js","static/chunks/157-0528651bf3cd10a7.js","static/chunks/839-dabd319a60c8df83.js","static/chunks/27-7790e406eb2ea28d.js","static/chunks/213-a0488f84cc98f172.js","static/chunks/209-4e2794319babfeec.js","static/chunks/48-d4400834d0a31c6e.js","static/chunks/644-4eec2b397fdacb0c.js","static/chunks/336-33ece0c8120f3bd4.js","static/chunks/570-6bad4610969fc14b.js","static/chunks/707-ee38ab2abcd0aa3f.js","static/chunks/170-357f4683929223df.js","static/chunks/560-a9c9ecda0eca25a9.js","static/chunks/404-b8e9ff2043a0d30c.js","static/chunks/86-af7e2d6c5444a983.js"),self.__BUILD_MANIFEST_CB&&self.__BUILD_MANIFEST_CB();

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

@ -74,6 +74,10 @@ type MapToManifestFormInputHandler = Record<string, ManifestFormInputHandler>;
/** ---------- Component types ---------- */ /** ---------- Component types ---------- */
type AnIdInputGroupOptionalProps = { type AnIdInputGroupOptionalProps = {
debounceWait?: number;
onSequenceChange?: import('react').ChangeEventHandler<
HTMLInputElement | HTMLTextAreaElement
>;
previous?: Partial<ManifestAnId>; previous?: Partial<ManifestAnId>;
}; };
@ -86,21 +90,32 @@ type AnNetworkEventHandlerPreviousArgs = {
networkId: string; networkId: string;
} & Pick<ManifestNetwork, 'networkType'>; } & Pick<ManifestNetwork, 'networkType'>;
type AnNetworkCloseEventHandler = ( type AnNetworkChangeEventHandler<Handler> = (
args: AnNetworkEventHandlerPreviousArgs, args: AnNetworkEventHandlerPreviousArgs,
...handlerArgs: Parameters<IconButtonMouseEventHandler> ...handlerArgs: Parameters<Handler>
) => ReturnType<IconButtonMouseEventHandler>; ) => ReturnType<Handler>;
type AnNetworkTypeChangeEventHandler = ( type AnNetworkCloseEventHandler =
args: AnNetworkEventHandlerPreviousArgs, AnNetworkChangeEventHandler<IconButtonMouseEventHandler>;
...handlerArgs: Parameters<SelectChangeEventHandler>
) => ReturnType<SelectChangeEventHandler>; type AnNetworkTypeChangeEventHandler =
AnNetworkChangeEventHandler<SelectChangeEventHandler>;
type AnNetworkInputGroupOptionalProps = { type AnNetworkInputGroupOptionalProps = {
debounceWait?: number;
inputGatewayLabel?: string; inputGatewayLabel?: string;
inputMinIpLabel?: string; inputMinIpLabel?: string;
inputSubnetMaskLabel?: string; inputSubnetMaskLabel?: string;
onClose?: AnNetworkCloseEventHandler; onClose?: AnNetworkCloseEventHandler;
onNetworkGatewayChange?: AnNetworkChangeEventHandler<
import('react').ChangeEventHandler<HTMLInputElement | HTMLTextAreaElement>
>;
onNetworkMinIpChange?: AnNetworkChangeEventHandler<
import('react').ChangeEventHandler<HTMLInputElement | HTMLTextAreaElement>
>;
onNetworkSubnetMaskChange?: AnNetworkChangeEventHandler<
import('react').ChangeEventHandler<HTMLInputElement | HTMLTextAreaElement>
>;
onNetworkTypeChange?: AnNetworkTypeChangeEventHandler; onNetworkTypeChange?: AnNetworkTypeChangeEventHandler;
previous?: { previous?: {
gateway?: string; gateway?: string;
@ -155,6 +170,7 @@ type AnHostConfigInputGroupOptionalProps = {
type AnHostConfigInputGroupProps<M extends MapToInputTestID> = type AnHostConfigInputGroupProps<M extends MapToInputTestID> =
AnHostConfigInputGroupOptionalProps & { AnHostConfigInputGroupOptionalProps & {
anSequence: number;
formUtils: FormUtils<M>; formUtils: FormUtils<M>;
networkListEntries: Array<[string, ManifestNetwork]>; networkListEntries: Array<[string, ManifestNetwork]>;
}; };

@ -1,8 +1,5 @@
type MuiInputBaseProps = import('@mui/material').InputBaseProps; type MuiInputBaseProps = import('@mui/material').InputBaseProps;
type ReactChangeEventHandler =
import('react').ChangeEventHandler<HTMLInputElement>;
type MuiInputBasePropsBlurEventHandler = Exclude< type MuiInputBasePropsBlurEventHandler = Exclude<
MuiInputBaseProps['onBlur'], MuiInputBaseProps['onBlur'],
undefined undefined
@ -19,7 +16,7 @@ type UncontrolledInputComponentUnmountEventHandler = () => void;
type UncontrolledInputOptionalProps = { type UncontrolledInputOptionalProps = {
onBlur?: ExtendableEventHandler<MuiInputBasePropsBlurEventHandler>; onBlur?: ExtendableEventHandler<MuiInputBasePropsBlurEventHandler>;
onChange?: ExtendableEventHandler<ReactChangeEventHandler>; onChange?: ExtendableEventHandler<React.ChangeEventHandler<HTMLInputElement>>;
onFocus?: ExtendableEventHandler<MuiInputBasePropsFocusEventHandler>; onFocus?: ExtendableEventHandler<MuiInputBasePropsFocusEventHandler>;
onMount?: UncontrolledInputComponentMountEventHandler; onMount?: UncontrolledInputComponentMountEventHandler;
onUnmount?: UncontrolledInputComponentUnmountEventHandler; onUnmount?: UncontrolledInputComponentUnmountEventHandler;

Loading…
Cancel
Save