Line data Source code
1 43 : import React, { useState } from 'react';
2 :
3 : import { Button } from "@patternfly/react-core/dist/esm/components/Button";
4 : import { Form, FormGroup } from "@patternfly/react-core/dist/esm/components/Form";
5 : import { Radio } from "@patternfly/react-core/dist/esm/components/Radio";
6 : import { TextInput } from "@patternfly/react-core/dist/esm/components/TextInput";
7 : import {
8 : Modal
9 : } from '@patternfly/react-core/dist/esm/deprecated/components/Modal';
10 : import { FormHelper } from 'cockpit-components-form-helper.jsx';
11 : import { useDialogs } from "dialogs.jsx";
12 43 : import * as dockerNames from 'docker-names';
13 :
14 : import cockpit from 'cockpit';
15 : import { DynamicListForm } from 'cockpit-components-dynamic-list.jsx';
16 :
17 : import { ErrorNotification } from './Notification.jsx';
18 : import { PublishPort, validatePublishPort } from './PublishPort.jsx';
19 : import { Volume } from './Volume.jsx';
20 : import * as client from './client.js';
21 : import * as utils from './util.js';
22 :
23 43 : const _ = cockpit.gettext;
24 :
25 2 : export const PodCreateModal = ({ users }) => {
26 2 : const { version, selinuxAvailable } = utils.usePodmanInfo();
27 2 : const [podName, setPodName] = useState(dockerNames.getRandomName());
28 2 : const [publish, setPublish] = useState([]);
29 2 : const [volumes, setVolumes] = useState([]);
30 2 : const [owner, setOwner] = useState(users[0]);
31 2 : const [inProgress, setInProgress] = useState(false);
32 2 : const [dialogError, setDialogError] = useState(null);
33 2 : const [dialogErrorDetail, setDialogErrorDetail] = useState(null);
34 2 : const [validationFailed, setValidationFailed] = useState({});
35 2 : const Dialogs = useDialogs();
36 :
37 2 : const getCreateConfig = () => {
38 2 : const createConfig = {};
39 :
40 2 : if (podName)
41 2 : createConfig.name = podName;
42 :
43 2 : if (publish.length > 0)
44 2 : createConfig.portmappings = publish
45 2 : .filter(port => port?.containerPort)
46 2 : .map(port => {
47 2 : const pm = { container_port: parseInt(port.containerPort), protocol: port.protocol };
48 2 : if (port.hostPort !== null)
49 2 : pm.host_port = parseInt(port.hostPort);
50 2 : if (port.IP !== null)
51 2 : pm.host_ip = port.IP;
52 2 : return pm;
53 2 : });
54 :
55 2 : if (volumes.length > 0) {
56 2 : createConfig.mounts = volumes
57 2 : .filter(volume => volume?.hostPath && volume?.containerPath)
58 2 : .map(volume => {
59 2 : const record = { source: volume.hostPath, destination: volume.containerPath, type: "bind" };
60 2 : record.options = [];
61 2 : if (volume.mode)
62 2 : record.options.push(volume.mode);
63 2 : if (volume.selinux)
64 2 : record.options.push(volume.selinux);
65 2 : return record;
66 2 : });
67 2 : }
68 :
69 2 : return createConfig;
70 2 : };
71 :
72 : /* Updates a validation object of the whole dynamic list's form (e.g. the whole port-mapping form)
73 : *
74 : * Arguments
75 : * - key: [publish/volumes/env] - Specifies the validation of which dynamic form of the Image run dialog is being updated
76 : * - value: An array of validation errors of the form. Each item of the array represents a row of the dynamic list.
77 : * Index needs to correlate with a row number
78 : */
79 2 : const dynamicListOnValidationChange = (key, value) => {
80 2 : setValidationFailed(prevState => {
81 2 : const newState = Object.assign({}, prevState, { [key]: value });
82 0 : if (newState[key].every(a => a === undefined))
83 2 : delete newState[key];
84 2 : return newState;
85 2 : });
86 2 : };
87 :
88 2 : const onCreateClicked = () => {
89 2 : if (!validateForm())
90 2 : return;
91 2 : setInProgress(true);
92 2 : client.createPod(owner.con, getCreateConfig())
93 2 : .then(Dialogs.close)
94 0 : .catch(ex => {
95 0 : setInProgress(false);
96 0 : setDialogError(_("Pod failed to be created"));
97 0 : setDialogErrorDetail(cockpit.format("$0: $1", ex.reason, ex.message));
98 0 : });
99 2 : };
100 :
101 2 : const isFormInvalid = validationFailed => {
102 2 : function publishGroupHasError(row, idx) {
103 : // We always ignore errors for empty slots in
104 : // publish. Errors for these slots might show up when the
105 : // debounced validation runs after a row has been removed.
106 2 : if (!row || !publish[idx])
107 2 : return false;
108 :
109 2 : return Object.values(row)
110 2 : .filter(val => val) // Filter out empty/undefined properties
111 2 : .length > 0; // If one field has error, the whole group (dynamicList) is invalid
112 2 : }
113 :
114 : // If at least one group is invalid, then the whole form is invalid
115 2 : return validationFailed.publish?.some(publishGroupHasError) ||
116 2 : !!validationFailed.podName;
117 2 : };
118 :
119 2 : const validatePodName = value => {
120 2 : if (!utils.is_valid_container_name(value))
121 2 : return _("Invalid characters. Name can only contain letters, numbers, and certain punctuation (_ . -).");
122 2 : };
123 :
124 2 : const validateForm = () => {
125 2 : const newValidationFailed = { };
126 :
127 2 : const publishValidation = publish.map(a => {
128 2 : if (a === undefined)
129 2 : return undefined;
130 :
131 2 : return {
132 2 : IP: validatePublishPort(a.IP, "IP"),
133 2 : hostPort: validatePublishPort(a.hostPort, "hostPort"),
134 2 : containerPort: validatePublishPort(a.containerPort, "containerPort"),
135 2 : };
136 2 : });
137 2 : if (publishValidation.some(entry => entry && Object.keys(entry).length > 0))
138 2 : newValidationFailed.publish = publishValidation;
139 :
140 2 : const podNameValidation = validatePodName(podName);
141 :
142 2 : if (podNameValidation)
143 0 : newValidationFailed.containerName = podNameValidation;
144 :
145 2 : setValidationFailed(newValidationFailed);
146 2 : return !isFormInvalid(newValidationFailed);
147 2 : };
148 :
149 2 : const defaultBody = (
150 2 : <Form>
151 0 : {dialogError && <ErrorNotification errorMessage={dialogError} errorDetail={dialogErrorDetail} />}
152 2 : <FormGroup id="pod-name-group" fieldId='create-pod-dialog-name' label={_("Name")} className="ct-m-horizontal">
153 2 : <TextInput id='create-pod-dialog-name'
154 2 : className="pod-name"
155 2 : placeholder={_("Pod name")}
156 2 : value={podName}
157 2 : validated={validationFailed.podName ? "error" : "default"}
158 2 : onChange={(_, value) => {
159 2 : utils.validationClear(validationFailed, "podName", (value) => setValidationFailed(value));
160 2 : utils.validationDebounce(() => {
161 2 : const delta = validatePodName(value);
162 2 : if (delta)
163 2 : setValidationFailed(prevState => { return { ...prevState, podName: delta } });
164 2 : });
165 2 : setPodName(value);
166 2 : }} />
167 2 : <FormHelper fieldId="create-pod-dialog-name" helperTextInvalid={validationFailed?.podName} />
168 2 : </FormGroup>
169 2 : { users.length > 1 &&
170 1 : <FormGroup isInline hasNoPaddingTop fieldId='create-pod-dialog-owner' label={_("Owner")} className="ct-m-horizontal">
171 1 : { users.map(user => (
172 1 : <Radio key={user.name}
173 1 : value={user.name}
174 1 : label={user.uid === 0 ? _("System") : cockpit.format("$0 $1", _("User:"), user.name)}
175 1 : id={"create-pod-dialog-owner-" + user.name }
176 1 : isChecked={owner === user}
177 1 : onChange={() => setOwner(user)} />))
178 : }
179 1 : </FormGroup>
180 : }
181 2 : <DynamicListForm id='create-pod-dialog-publish'
182 2 : emptyStateString={_("No ports exposed")}
183 2 : formclass='publish-port-form'
184 2 : label={_("Port mapping")}
185 2 : actionLabel={_("Add port mapping")}
186 2 : validationFailed={validationFailed.publish}
187 2 : onValidationChange={value => dynamicListOnValidationChange('publish', value)}
188 2 : onChange={value => setPublish(value)}
189 2 : default={{ IP: null, containerPort: null, hostPort: null, protocol: 'tcp' }}
190 2 : itemcomponent={PublishPort} />
191 :
192 2 : {version.localeCompare("4", undefined, { numeric: true, sensitivity: 'base' }) >= 0 &&
193 2 : <DynamicListForm id='create-pod-dialog-volume'
194 2 : emptyStateString={_("No volumes specified")}
195 2 : formclass='volume-form'
196 2 : label={_("Volumes")}
197 2 : actionLabel={_("Add volume")}
198 2 : onChange={value => setVolumes(value)}
199 2 : default={{ containerPath: null, hostPath: null, mode: 'rw' }}
200 2 : options={{ selinuxAvailable }}
201 2 : itemcomponent={Volume} />
202 : }
203 :
204 2 : </Form>
205 : );
206 :
207 2 : return (
208 2 : <Modal isOpen
209 2 : position="top" variant="medium"
210 2 : onClose={Dialogs.close}
211 2 : onEscapePress={Dialogs.close}
212 2 : title={_("Create pod")}
213 2 : footer={<>
214 2 : <Button variant='primary' id="create-pod-create-btn" onClick={onCreateClicked}
215 2 : isLoading={inProgress}
216 2 : isDisabled={isFormInvalid(validationFailed) || inProgress}>
217 2 : {_("Create")}
218 2 : </Button>
219 2 : <Button variant='link' className='btn-cancel' isDisabled={inProgress} onClick={Dialogs.close}>
220 2 : {_("Cancel")}
221 2 : </Button>
222 2 : </>}
223 : >
224 2 : {defaultBody}
225 2 : </Modal>
226 : );
227 2 : };
|