Line data Source code
1 43 : import React from 'react';
2 :
3 : import { Button } from "@patternfly/react-core/dist/esm/components/Button";
4 : import { Checkbox } from "@patternfly/react-core/dist/esm/components/Checkbox";
5 : import { Content, ContentVariants } from "@patternfly/react-core/dist/esm/components/Content";
6 : import { Form, FormGroup } from "@patternfly/react-core/dist/esm/components/Form";
7 : import { FormSelect, FormSelectOption } from "@patternfly/react-core/dist/esm/components/FormSelect";
8 : import { InputGroup, InputGroupText } from "@patternfly/react-core/dist/esm/components/InputGroup";
9 : import { NumberInput } from "@patternfly/react-core/dist/esm/components/NumberInput";
10 : import { Popover } from "@patternfly/react-core/dist/esm/components/Popover";
11 : import { Radio } from "@patternfly/react-core/dist/esm/components/Radio";
12 : import { Spinner } from "@patternfly/react-core/dist/esm/components/Spinner/index.js";
13 : import { Tab, TabTitleText, Tabs } from "@patternfly/react-core/dist/esm/components/Tabs";
14 : import { TextInput } from "@patternfly/react-core/dist/esm/components/TextInput";
15 : import { ToggleGroup, ToggleGroupItem } from "@patternfly/react-core/dist/esm/components/ToggleGroup";
16 : import {
17 : Modal
18 : } from '@patternfly/react-core/dist/esm/deprecated/components/Modal';
19 : import { Bullseye } from "@patternfly/react-core/dist/esm/layouts/Bullseye/index.js";
20 : import { Flex, FlexItem } from "@patternfly/react-core/dist/esm/layouts/Flex";
21 : import { Grid, GridItem } from "@patternfly/react-core/dist/esm/layouts/Grid";
22 : import { OutlinedQuestionCircleIcon } from '@patternfly/react-icons';
23 : import { FormHelper } from "cockpit-components-form-helper.jsx";
24 43 : import * as dockerNames from 'docker-names';
25 : import { debounce } from 'throttle-debounce';
26 :
27 : import cockpit from 'cockpit';
28 : import { DynamicListForm } from 'cockpit-components-dynamic-list.jsx';
29 : import { TypeaheadSelect } from 'cockpit-components-typeahead-select';
30 :
31 : import { onDownloadContainer, onDownloadContainerFinished } from './Containers.jsx';
32 : import { EnvVar, validateEnvVar } from './Env.jsx';
33 : import { ErrorNotification } from './Notification.jsx';
34 : import { PublishPort, validatePublishPort } from './PublishPort.jsx';
35 : import { validateVolume, Volume } from './Volume.jsx';
36 : import * as client from './client.js';
37 : import rest from './rest.js';
38 : import * as utils from './util.js';
39 :
40 : import "./ImageRunModal.scss";
41 :
42 43 : const _ = cockpit.gettext;
43 :
44 43 : const units = {
45 43 : KB: {
46 43 : name: "KB",
47 43 : baseExponent: 1,
48 43 : },
49 43 : MB: {
50 43 : name: "MB",
51 43 : baseExponent: 2,
52 43 : },
53 43 : GB: {
54 43 : name: "GB",
55 43 : baseExponent: 3,
56 43 : },
57 43 : };
58 :
59 : // healthchecks.go HealthCheckOnFailureAction
60 43 : const HealthCheckOnFailureActionOrder = [
61 43 : { value: 0, label: _("No action") },
62 43 : { value: 3, label: _("Restart") },
63 43 : { value: 4, label: _("Stop") },
64 43 : { value: 2, label: _("Force stop") },
65 43 : ];
66 :
67 43 : const RE_CONTAINER_TAG = /:[\w\-\d]+$/;
68 :
69 43 : export class ImageRunModal extends React.Component {
70 15 : constructor(props) {
71 15 : super(props);
72 :
73 15 : let command = "";
74 9 : if (this.props.image && this.props.image.Command) {
75 9 : command = utils.quote_cmdline(this.props.image.Command);
76 9 : }
77 :
78 9 : const entrypoint = utils.quote_cmdline(this.props.image?.Entrypoint);
79 :
80 15 : let selectedImage = "";
81 9 : if (this.props.image) {
82 9 : selectedImage = utils.image_name(this.props.image);
83 9 : }
84 :
85 15 : const default_owner = this.props.pod
86 2 : ? this.props.users.find(u => u.uid === this.props.pod.uid)
87 13 : : (this.props.image
88 9 : ? this.props.users.find(u => u.uid === this.props.image.uid)
89 5 : : this.props.users[0]);
90 :
91 15 : this.state = {
92 15 : command,
93 15 : containerName: dockerNames.getRandomName(),
94 15 : entrypoint,
95 15 : env: [],
96 15 : hasTTY: true,
97 15 : publish: [],
98 15 : image: props.image,
99 15 : memory: 512,
100 15 : cpuShares: 1024,
101 15 : memoryConfigure: false,
102 15 : cpuSharesConfigure: false,
103 15 : memoryUnit: 'MB',
104 15 : inProgress: false,
105 15 : validationFailed: {},
106 15 : volumes: [],
107 15 : restartPolicy: "no",
108 15 : restartTries: 5,
109 15 : pullLatestImage: false,
110 15 : activeTabKey: 0,
111 15 : owner: default_owner,
112 : /* image select */
113 15 : selectedImage,
114 15 : searchFinished: false,
115 15 : searchInProgress: false,
116 15 : searchText: "",
117 15 : imageResults: {},
118 15 : isImageSelectOpen: false,
119 15 : searchByRegistry: 'all',
120 : /* health check */
121 15 : healthcheck_command: "",
122 15 : healthcheck_interval: 30,
123 15 : healthcheck_timeout: 30,
124 15 : healthcheck_start_period: 0,
125 15 : healthcheck_retries: 3,
126 15 : healthcheck_action: 0,
127 15 : };
128 15 : this.getCreateConfig = this.getCreateConfig.bind(this);
129 15 : this.onValueChanged = this.onValueChanged.bind(this);
130 15 : }
131 :
132 15 : componentDidMount() {
133 15 : this._isMounted = true;
134 15 : this.onSearchTriggered(this.state.searchText);
135 15 : }
136 :
137 15 : componentWillUnmount() {
138 15 : this._isMounted = false;
139 :
140 15 : if (this.activeConnection)
141 5 : this.activeConnection.close();
142 15 : }
143 :
144 14 : getCreateConfig() {
145 14 : const createConfig = {};
146 :
147 2 : if (this.props.pod) {
148 2 : createConfig.pod = this.props.pod.Id;
149 2 : }
150 :
151 9 : if (this.state.image) {
152 0 : createConfig.image = this.state.image.RepoTags ? this.state.image.RepoTags[0] : "";
153 0 : } else {
154 5 : let img = this.state.selectedImage.Name;
155 : // Make implicit :latest
156 0 : if (!img.includes(":")) {
157 0 : img += ":latest";
158 0 : }
159 5 : createConfig.image = img;
160 5 : }
161 14 : if (this.state.containerName)
162 14 : createConfig.name = this.state.containerName;
163 13 : if (this.state.command) {
164 13 : createConfig.command = utils.unquote_cmdline(this.state.command);
165 13 : }
166 14 : const resourceLimit = {};
167 2 : if (this.state.memoryConfigure && this.state.memory) {
168 2 : const memorySize = Number.parseInt(this.state.memory * (1000 ** units[this.state.memoryUnit].baseExponent));
169 2 : resourceLimit.memory = { limit: memorySize };
170 2 : createConfig.resource_limits = resourceLimit;
171 2 : }
172 1 : if (this.state.cpuSharesConfigure && this.state.cpuShares !== 0) {
173 1 : resourceLimit.cpu = { shares: this.state.cpuShares };
174 1 : createConfig.resource_limits = resourceLimit;
175 1 : }
176 14 : createConfig.terminal = this.state.hasTTY;
177 3 : if (this.state.publish.some(port => port !== undefined))
178 3 : createConfig.portmappings = this.state.publish
179 3 : .filter(port => port?.containerPort)
180 3 : .map(port => {
181 3 : const pm = { container_port: parseInt(port.containerPort), protocol: port.protocol };
182 3 : if (port.hostPort !== null)
183 3 : pm.host_port = parseInt(port.hostPort);
184 3 : if (port.IP !== null)
185 2 : pm.host_ip = port.IP;
186 3 : return pm;
187 3 : });
188 3 : if (this.state.env.some(item => item !== undefined)) {
189 3 : const envs = {};
190 3 : this.state.env.forEach(item => {
191 3 : if (item !== undefined)
192 3 : envs[item.envKey] = item.envValue;
193 3 : });
194 3 : createConfig.env = envs;
195 3 : }
196 3 : if (this.state.volumes.some(volume => volume !== undefined)) {
197 3 : createConfig.mounts = this.state.volumes
198 2 : .filter(volume => volume?.hostPath && volume?.containerPath)
199 2 : .map(volume => {
200 2 : const record = { source: volume.hostPath, destination: volume.containerPath, type: "bind" };
201 2 : record.options = [];
202 2 : if (volume.mode)
203 2 : record.options.push(volume.mode);
204 2 : if (volume.selinux)
205 2 : record.options.push(volume.selinux);
206 2 : return record;
207 2 : });
208 3 : }
209 :
210 3 : if (this.state.restartPolicy !== "no") {
211 3 : createConfig.restart_policy = this.state.restartPolicy;
212 1 : if (this.state.restartPolicy === "on-failure" && this.state.restartTries !== null) {
213 1 : createConfig.restart_tries = this.state.restartTries;
214 1 : }
215 3 : const hasSystem = this.props.users.find(u => u.uid === 0);
216 : // Enable podman-restart.service for system containers, for user
217 : // sessions enable-linger needs to be enabled for containers to start on boot.
218 1 : if (this.state.restartPolicy === "always" && (this.props.podmanInfo.userLingeringEnabled || hasSystem)) {
219 2 : this.enablePodmanRestartService();
220 2 : }
221 3 : }
222 :
223 2 : if (this.state.healthcheck_command !== "") {
224 2 : createConfig.healthconfig = {
225 2 : Interval: this.state.healthcheck_interval * 1000000000,
226 2 : Retries: this.state.healthcheck_retries,
227 2 : StartPeriod: this.state.healthcheck_start_period * 1000000000,
228 2 : Test: utils.unquote_cmdline(this.state.healthcheck_command),
229 2 : Timeout: this.state.healthcheck_timeout * 1000000000,
230 2 : };
231 2 : createConfig.health_check_on_failure_action = this.state.healthcheck_action;
232 2 : }
233 :
234 14 : return createConfig;
235 14 : }
236 :
237 12 : createContainer = (con, createConfig, runImage) => {
238 12 : const Dialogs = this.props.dialogs;
239 12 : client.createContainer(con, createConfig)
240 12 : .then(reply => {
241 12 : if (runImage) {
242 12 : client.postContainer(con, "start", reply.Id, {})
243 12 : .then(() => Dialogs.close())
244 1 : .catch(ex => {
245 : // If container failed to start remove it, so a user can fix the settings and retry and
246 : // won't get another error that the container name is already taken.
247 1 : client.delContainer(con, reply.Id, true)
248 1 : .then(() => {
249 1 : this.setState({
250 1 : inProgress: false,
251 1 : dialogError: _("Container failed to be started"),
252 1 : dialogErrorDetail: cockpit.format("$0: $1", ex.reason, ex.message)
253 1 : });
254 1 : })
255 0 : .catch(ex => {
256 0 : this.setState({
257 0 : inProgress: false,
258 0 : dialogError: _("Failed to clean up container"),
259 0 : dialogErrorDetail: cockpit.format("$0: $1", ex.reason, ex.message)
260 0 : });
261 0 : });
262 1 : });
263 3 : } else {
264 3 : Dialogs.close();
265 3 : }
266 12 : })
267 0 : .catch(ex => {
268 0 : this.setState({
269 0 : inProgress: false,
270 0 : dialogError: _("Container failed to be created"),
271 0 : dialogErrorDetail: cockpit.format("$0: $1", ex.reason, ex.message)
272 0 : });
273 0 : });
274 12 : };
275 :
276 14 : async onCreateClicked(runImage = false) {
277 14 : if (!await this.validateForm())
278 14 : return;
279 :
280 14 : this.setState({ inProgress: true });
281 :
282 14 : const Dialogs = this.props.dialogs;
283 14 : const createConfig = this.getCreateConfig();
284 14 : const { pullLatestImage } = this.state;
285 14 : const con = this.state.owner.con;
286 14 : let imageExists = true;
287 :
288 14 : try {
289 13 : await client.imageExists(con, createConfig.image);
290 2 : } catch {
291 3 : imageExists = false;
292 3 : }
293 :
294 12 : if (imageExists && !pullLatestImage) {
295 12 : this.createContainer(con, createConfig, runImage);
296 1 : } else {
297 3 : Dialogs.close();
298 3 : const tempImage = { ...createConfig };
299 :
300 : // Assign temporary properties to allow rendering
301 3 : tempImage.Id = tempImage.name;
302 3 : tempImage.uid = con.uid;
303 3 : tempImage.key = utils.makeKey(tempImage.uid, tempImage.Id);
304 3 : tempImage.State = { Status: _("downloading") };
305 3 : tempImage.Created = new Date();
306 3 : tempImage.Name = tempImage.name;
307 3 : tempImage.Image = createConfig.image;
308 3 : tempImage.isDownloading = true;
309 :
310 3 : onDownloadContainer(tempImage);
311 :
312 3 : client.pullImage(con, createConfig.image).then(_reply => {
313 3 : client.createContainer(con, createConfig)
314 3 : .then(reply => {
315 2 : if (runImage) {
316 2 : client.postContainer(con, "start", reply.Id, {})
317 2 : .then(() => onDownloadContainerFinished(createConfig))
318 0 : .catch(ex => {
319 0 : onDownloadContainerFinished(createConfig);
320 0 : const error = cockpit.format(_("Failed to run container $0"), tempImage.name);
321 0 : this.props.onAddNotification({ type: 'danger', error, errorDetail: ex.message });
322 0 : });
323 2 : }
324 3 : })
325 0 : .catch(ex => {
326 0 : onDownloadContainerFinished(createConfig);
327 0 : const error = cockpit.format(_("Failed to create container $0"), tempImage.name);
328 0 : this.props.onAddNotification({ type: 'danger', error, errorDetail: ex.reason });
329 0 : });
330 3 : })
331 0 : .catch(ex => {
332 0 : onDownloadContainerFinished(createConfig);
333 0 : const error = cockpit.format(_("Failed to pull image $0"), tempImage.image);
334 0 : this.props.onAddNotification({ type: 'danger', error, errorDetail: ex.message });
335 0 : });
336 3 : }
337 14 : }
338 :
339 14 : onValueChanged(key, value) {
340 14 : this.setState({ [key]: value });
341 14 : }
342 :
343 0 : onPlusOne(key) {
344 0 : this.setState(state => ({ [key]: parseFloat(state[key]) + 1 }));
345 0 : }
346 :
347 2 : onMinusOne(key) {
348 2 : this.setState(state => ({ [key]: parseFloat(state[key]) - 1 }));
349 2 : }
350 :
351 4 : onNumberValue(key, value, minimum = 0, is_float = false) {
352 1 : const parseFunc = is_float ? Number.parseFloat : Number.parseInt;
353 4 : value = parseFunc(value);
354 0 : if (isNaN(value) || value < minimum) {
355 0 : value = minimum;
356 0 : }
357 4 : this.onValueChanged(key, value);
358 4 : }
359 :
360 5 : handleTabClick = (event, tabIndex) => {
361 : // Prevent the form from being submitted.
362 5 : event.preventDefault();
363 5 : this.setState({
364 5 : activeTabKey: tabIndex,
365 5 : });
366 5 : };
367 :
368 7 : buildFilterRegex(searchText, includeRegistry) {
369 : // Strip out all non-allowed container image characters when filtering.
370 7 : let regexString = searchText.replace(/[^/\w_.:-]/g, "");
371 : // drop registry from regex to allow filtering only by container names
372 5 : if (!includeRegistry && regexString.includes('/')) {
373 5 : regexString = '/' + searchText.split('/')
374 5 : .slice(1)
375 5 : .join('/');
376 5 : }
377 :
378 7 : return new RegExp(regexString, 'i');
379 7 : }
380 :
381 15 : onSearchTriggered = value => {
382 15 : let dialogError = "";
383 15 : let dialogErrorDetail = "";
384 :
385 3 : const changeDialogError = (reason) => {
386 : // Only report first encountered error
387 3 : if (dialogError === "" && dialogErrorDetail === "") {
388 3 : dialogError = _("Failed to search for new images");
389 : // TODO: add registry context, podman does not include it in the reply.
390 0 : dialogErrorDetail = reason ? cockpit.format(_("Failed to search for images: $0"), reason.message) : _("Failed to search for images.");
391 3 : }
392 3 : };
393 :
394 1 : const imageExistsLocally = (searchText, localImages) => {
395 1 : const regex = this.buildFilterRegex(searchText, true);
396 1 : return localImages.some(localImage => localImage.Name.search(regex) !== -1);
397 1 : };
398 :
399 5 : const handleManifestsQuery = (result) => {
400 3 : if (result.status === "fulfilled") {
401 3 : return JSON.parse(result.value);
402 2 : } else if (result.reason.status !== 404) {
403 2 : changeDialogError(result.reason);
404 2 : }
405 :
406 5 : return null;
407 5 : };
408 :
409 : // Do not call the SearchImage API if the input string is not at least 2 chars,
410 : // The comparison was done considering the fact that we miss always one letter due to delayed setState
411 15 : if (value.length < 2)
412 15 : return;
413 :
414 5 : if (this.activeConnection)
415 3 : this.activeConnection.close();
416 :
417 5 : this.setState({ searchFinished: false, searchInProgress: true });
418 1 : this.activeConnection = rest.connect(this.isSystem() ? 0 : null);
419 15 : const searches = [];
420 :
421 : // Try to get specified image manifest
422 15 : searches.push(this.activeConnection.call({
423 15 : method: "GET",
424 15 : path: client.VERSION + "libpod/manifests/" + value + "/json",
425 15 : body: "",
426 15 : }));
427 :
428 : // Don't start search queries when tag is specified as search API doesn't support
429 : // searching for specific image tags
430 : // instead only rely on manifests query (requires image:tag name)
431 3 : if (!RE_CONTAINER_TAG.test(value)) {
432 : // If there are registries configured search in them, or if a user searches for `docker.io/cockpit` let
433 : // podman search in the user specified registry.
434 0 : if (Object.keys(this.props.podmanInfo.registries).length !== 0 || value.includes('/')) {
435 3 : searches.push(this.activeConnection.call({
436 3 : method: "GET",
437 3 : path: client.VERSION + "libpod/images/search",
438 3 : body: "",
439 3 : params: {
440 3 : term: value,
441 3 : }
442 3 : }));
443 0 : } else {
444 0 : searches.push(...utils.fallbackRegistries.map(registry =>
445 0 : this.activeConnection.call({
446 0 : method: "GET",
447 0 : path: client.VERSION + "libpod/images/search",
448 0 : body: "",
449 0 : params: {
450 0 : term: registry + "/" + value
451 0 : }
452 0 : })));
453 0 : }
454 3 : }
455 :
456 5 : Promise.allSettled(searches)
457 5 : .then(reply => {
458 5 : if (reply && this._isMounted) {
459 5 : let imageResults = [];
460 5 : const manifestResult = handleManifestsQuery(reply[0]);
461 :
462 3 : reply.slice(1).forEach(result => {
463 2 : if (result.status === "fulfilled") {
464 2 : imageResults = imageResults.concat(JSON.parse(result.value));
465 0 : } else if (!manifestResult && !imageExistsLocally(value, this.props.localImages)) {
466 1 : changeDialogError(result.reason);
467 1 : }
468 3 : });
469 :
470 : // Add manifest query result if search query did not find the same image
471 2 : if (manifestResult && !imageResults.find(image => image.Name === value)) {
472 3 : manifestResult.Name = value;
473 3 : imageResults.push(manifestResult);
474 3 : }
475 :
476 : // Group images on registry
477 5 : const images = {};
478 3 : imageResults.forEach(image => {
479 : // Add Tag if it's there
480 3 : image.toString = function imageToString() {
481 0 : if (this.Tag) {
482 0 : return this.Name + ':' + this.Tag;
483 0 : }
484 3 : return this.Name;
485 3 : };
486 :
487 3 : let index = image.Index;
488 :
489 : // listTags results do not return the registry Index.
490 : // https://github.com/containers/common/pull/803
491 3 : if (!index) {
492 3 : index = image.Name.split('/')[0];
493 3 : }
494 :
495 0 : if (index in images) {
496 0 : images[index].push(image);
497 0 : } else {
498 3 : images[index] = [image];
499 3 : }
500 3 : });
501 :
502 5 : this.setState({
503 0 : imageResults: images || {},
504 5 : searchFinished: true,
505 5 : searchInProgress: false,
506 5 : dialogError,
507 5 : dialogErrorDetail,
508 5 : });
509 5 : }
510 5 : });
511 15 : };
512 :
513 0 : clearImageSelection = () => {
514 : // Reset command if it was prefilled
515 0 : let command = this.state.command;
516 0 : if (this.state.command === utils.quote_cmdline(this.state.selectedImage?.Command))
517 0 : command = "";
518 :
519 0 : this.setState({
520 0 : selectedImage: "",
521 0 : image: "",
522 0 : isImageSelectOpen: false,
523 0 : imageResults: {},
524 0 : searchText: "",
525 0 : searchFinished: false,
526 0 : command,
527 0 : entrypoint: "",
528 0 : });
529 0 : };
530 :
531 0 : onImageSelectToggle = (_, isOpen) => {
532 0 : this.setState({
533 0 : isImageSelectOpen: isOpen,
534 0 : });
535 0 : };
536 :
537 5 : onImageSelect = (event, value) => {
538 5 : if (event === undefined)
539 5 : return;
540 :
541 5 : let command = this.state.command;
542 4 : if (value.Command && !command)
543 4 : command = utils.quote_cmdline(value.Command);
544 :
545 5 : const entrypoint = utils.quote_cmdline(value?.Entrypoint);
546 :
547 5 : this.setState({
548 5 : selectedImage: value,
549 5 : isImageSelectOpen: false,
550 5 : command,
551 5 : entrypoint,
552 5 : dialogError: "",
553 5 : dialogErrorDetail: "",
554 5 : });
555 5 : };
556 :
557 5 : handleImageSelectInput = value => {
558 5 : const trimmedValue = value.trim();
559 5 : this.setState({
560 5 : searchText: trimmedValue,
561 : // Reset searchFinished status when text input changes
562 5 : searchFinished: false,
563 5 : selectedImage: "",
564 5 : });
565 5 : this.onSearchTriggered(trimmedValue);
566 5 : };
567 :
568 15 : debouncedInputChanged = debounce(300, this.handleImageSelectInput);
569 :
570 1 : handleOwnerSelect = event => this.setState({
571 1 : owner: this.props.users.find(u => u.name == event.currentTarget.value)
572 1 : });
573 :
574 7 : filterImages = () => {
575 7 : const { localImages } = this.props;
576 7 : const { imageResults, searchText } = this.state;
577 7 : const local = _("Local images");
578 7 : const images = { ...imageResults };
579 7 : const isSystem = this.isSystem();
580 :
581 7 : let imageRegistries = [];
582 7 : if (this.state.searchByRegistry == 'local' || this.state.searchByRegistry == 'all') {
583 7 : imageRegistries.push(local);
584 7 : images[local] = localImages;
585 :
586 7 : if (this.state.searchByRegistry == 'all')
587 7 : imageRegistries = imageRegistries.concat(Object.keys(imageResults));
588 4 : } else {
589 4 : imageRegistries.push(this.state.searchByRegistry);
590 4 : }
591 :
592 7 : const input = this.buildFilterRegex(searchText, false);
593 :
594 7 : const results = [];
595 7 : imageRegistries.forEach(reg => {
596 7 : let need_header = this.state.searchByRegistry == 'all';
597 4 : (reg in images ? images[reg] : [])
598 7 : .filter(image => {
599 2 : if (image.uid !== undefined && image.uid === 0 && !isSystem) {
600 2 : return false;
601 2 : }
602 3 : if (image.uid !== undefined && image.uid !== 0 && isSystem) {
603 3 : return false;
604 3 : }
605 7 : return image.Name.search(input) !== -1;
606 7 : })
607 7 : .forEach(image => {
608 7 : if (need_header) {
609 7 : results.push({ key: results.length, decorator: "header", content: reg });
610 7 : need_header = false;
611 7 : }
612 :
613 7 : results.push({
614 7 : key: results.length,
615 7 : value: image,
616 7 : content: image.toString(),
617 7 : description: image.Description
618 7 : });
619 7 : });
620 7 : });
621 :
622 7 : return results;
623 7 : };
624 :
625 : // Similar to the output of podman search and podman's /libpod/images/search endpoint only show the root domain.
626 15 : truncateRegistryDomain = (domain) => {
627 15 : const parts = domain.split('.');
628 15 : if (parts.length > 2) {
629 15 : return parts[parts.length - 2] + "." + parts[parts.length - 1];
630 15 : }
631 15 : return domain;
632 15 : };
633 :
634 2 : enablePodmanRestartService = () => {
635 2 : const argv = ["systemctl", "enable", "podman-restart.service"];
636 1 : if (!this.isSystem()) {
637 1 : argv.splice(1, 0, "--user");
638 1 : }
639 :
640 0 : cockpit.spawn(argv, { superuser: this.isSystem() ? "require" : "", err: "message" })
641 0 : .catch(err => {
642 0 : console.warn("Failed to start podman-restart.service:", JSON.stringify(err));
643 0 : });
644 2 : };
645 :
646 15 : isSystem = () => this.state.owner.uid === 0;
647 :
648 14 : isFormInvalid = validationFailed => {
649 14 : function checkGroup(validation, values) {
650 3 : function rowHasError(row, idx) {
651 : // We always ignore errors for empty slots in
652 : // "values". Errors for these slots might show up when
653 : // the debounced validation runs after a row has been
654 : // removed.
655 3 : if (!row || !values[idx])
656 3 : return false;
657 :
658 3 : return Object.values(row)
659 3 : .filter(val => val) // Filter out empty/undefined properties
660 3 : .length > 0; // If one field has error, the whole group (dynamicList) is invalid
661 3 : }
662 3 : return validation && validation.some(rowHasError);
663 14 : }
664 : // If at least one group is invalid, then the whole form is invalid
665 14 : return checkGroup(validationFailed.publish, this.state.publish) ||
666 14 : checkGroup(validationFailed.volumes, this.state.volumes) ||
667 14 : checkGroup(validationFailed.env, this.state.env) ||
668 14 : !!validationFailed.containerName;
669 14 : };
670 :
671 14 : async validateContainerName(containerName) {
672 14 : try {
673 5 : await client.containerExists(this.state.owner.con, containerName);
674 5 : } catch {
675 14 : return;
676 14 : }
677 5 : return _("Name already in use");
678 14 : }
679 :
680 14 : async validateForm() {
681 14 : const { publish, volumes, env, containerName } = this.state;
682 14 : const validationFailed = { };
683 :
684 3 : const publishValidation = publish.map(a => {
685 3 : if (a === undefined)
686 2 : return undefined;
687 :
688 3 : return {
689 3 : IP: validatePublishPort(a.IP, "IP"),
690 3 : hostPort: validatePublishPort(a.hostPort, "hostPort"),
691 3 : containerPort: validatePublishPort(a.containerPort, "containerPort"),
692 3 : };
693 3 : });
694 3 : if (publishValidation.some(entry => entry && Object.keys(entry).length > 0))
695 3 : validationFailed.publish = publishValidation;
696 :
697 3 : const volumesValidation = volumes.map(a => {
698 3 : if (a === undefined)
699 2 : return undefined;
700 :
701 3 : return {
702 3 : hostPath: validateVolume(a.hostPath, "hostPath"),
703 3 : containerPath: validateVolume(a.containerPath, "containerPath"),
704 3 : };
705 3 : });
706 3 : if (volumesValidation.some(entry => entry && Object.keys(entry).length > 0))
707 3 : validationFailed.volumes = volumesValidation;
708 :
709 3 : const envValidation = env.map(a => {
710 3 : if (a === undefined)
711 3 : return undefined;
712 :
713 3 : return {
714 3 : envKey: validateEnvVar(a.envKey, "envKey"),
715 3 : envValue: validateEnvVar(a.envValue, "envValue"),
716 3 : };
717 3 : });
718 3 : if (envValidation.some(entry => entry && Object.keys(entry).length > 0))
719 3 : validationFailed.env = envValidation;
720 :
721 14 : const containerNameValidation = await this.validateContainerName(containerName);
722 :
723 14 : if (containerNameValidation)
724 0 : validationFailed.containerName = containerNameValidation;
725 :
726 14 : this.setState({ validationFailed });
727 :
728 14 : return !this.isFormInvalid(validationFailed);
729 14 : }
730 :
731 : /* Updates a validation object of the whole dynamic list's form (e.g. the whole port-mapping form)
732 : *
733 : * Arguments
734 : * - key: [publish/volumes/env] - Specifies the validation of which dynamic form of the Image run dialog is being updated
735 : * - value: An array of validation errors of the form. Each item of the array represents a row of the dynamic list.
736 : * Index needs to correlate with a row number
737 : */
738 3 : dynamicListOnValidationChange = (key, value) => {
739 3 : const validationFailedDelta = { ...this.state.validationFailed };
740 :
741 3 : validationFailedDelta[key] = value;
742 :
743 3 : if (validationFailedDelta[key].every(a => a === undefined))
744 3 : delete validationFailedDelta[key];
745 :
746 3 : this.onValueChanged('validationFailed', validationFailedDelta);
747 3 : };
748 :
749 15 : render() {
750 15 : const Dialogs = this.props.dialogs;
751 15 : const { registries, podmanRestartAvailable, userLingeringEnabled, userPodmanRestartAvailable, selinuxAvailable, version } = this.props.podmanInfo;
752 15 : const { image } = this.props;
753 15 : const dialogValues = this.state;
754 15 : const { activeTabKey, owner, selectedImage } = this.state;
755 :
756 15 : let imageListOptions = [];
757 7 : if (!image) {
758 7 : imageListOptions = this.filterImages();
759 7 : }
760 :
761 5 : const localImage = this.state.image || (selectedImage && this.props.localImages.some(img => img.Id === selectedImage.Id));
762 0 : const podmanRegistries = registries && registries.search ? registries.search : utils.fallbackRegistries;
763 :
764 : // Add the search component
765 15 : const footer = (
766 15 : <ToggleGroup className='image-search-footer' aria-label={_("Search by registry")}>
767 1 : <ToggleGroupItem text={_("All")} key='all' isSelected={this.state.searchByRegistry == 'all'} onChange={(ev, _) => {
768 1 : ev.stopPropagation();
769 1 : this.setState({ searchByRegistry: 'all' });
770 1 : }}
771 : // Ignore SelectToggle's touchstart's default behaviour
772 0 : onTouchStart={ev => ev.stopPropagation()}
773 15 : />
774 5 : <ToggleGroupItem text={_("Local")} key='local' isSelected={this.state.searchByRegistry == 'local'} onChange={(ev, _) => {
775 5 : ev.stopPropagation();
776 5 : this.setState({ searchByRegistry: 'local' });
777 5 : }}
778 0 : onTouchStart={ev => ev.stopPropagation()}
779 15 : />
780 15 : {podmanRegistries.map(registry => {
781 15 : const index = this.truncateRegistryDomain(registry);
782 15 : return (
783 15 : <ToggleGroupItem
784 15 : text={index} key={index}
785 15 : isSelected={ this.state.searchByRegistry == registry }
786 4 : onChange={ (ev, _) => {
787 4 : ev.stopPropagation();
788 4 : this.setState({ searchByRegistry: registry });
789 4 : } }
790 0 : onTouchStart={ ev => ev.stopPropagation() }
791 15 : />
792 : );
793 15 : })}
794 15 : </ToggleGroup>
795 : );
796 :
797 15 : const spinnerOptions = (
798 15 : this.state.searchInProgress
799 5 : ? [{ value: "_searching", content: <Bullseye><Spinner size="lg" /></Bullseye>, isDisabled: true }]
800 15 : : []
801 : );
802 :
803 : /* ignore Enter key, it otherwise opens the first popover help; this clears
804 : * the search input and is still irritating from other elements like check boxes */
805 15 : const defaultBody = (
806 2 : <Form onKeyDown={e => e.key === 'Enter' && e.preventDefault()}>
807 4 : {this.state.dialogError && <ErrorNotification errorMessage={this.state.dialogError} errorDetail={this.state.dialogErrorDetail} />}
808 15 : <FormGroup id="image-name-group" fieldId='run-image-dialog-name' label={_("Name")} className="ct-m-horizontal">
809 15 : <TextInput id='run-image-dialog-name'
810 15 : className="image-name"
811 15 : placeholder={_("Container name")}
812 4 : validated={dialogValues.validationFailed.containerName ? "error" : "default"}
813 15 : value={dialogValues.containerName}
814 14 : onChange={(_, value) => {
815 14 : utils.validationClear(dialogValues.validationFailed, "containerName", (value) => this.onValueChanged("validationFailed", value));
816 14 : utils.validationDebounce(async () => {
817 14 : const delta = await this.validateContainerName(value);
818 14 : if (delta)
819 5 : this.onValueChanged("validationFailed", { ...dialogValues.validationFailed, containerName: delta });
820 14 : });
821 14 : this.onValueChanged('containerName', value);
822 14 : }} />
823 15 : <FormHelper helperTextInvalid={dialogValues.validationFailed.containerName} />
824 15 : </FormGroup>
825 15 : <Tabs activeKey={activeTabKey} onSelect={this.handleTabClick}>
826 15 : <Tab eventKey={0} title={<TabTitleText>{_("Details")}</TabTitleText>} className="pf-v6-c-form pf-m-horizontal">
827 15 : { this.props.users.length > 1 &&
828 7 : <FormGroup isInline hasNoPaddingTop fieldId='run-image-dialog-owner' label={_("Owner")}
829 7 : labelHelp={
830 7 : <Popover aria-label={_("Owner help")}
831 7 : enableFlip
832 7 : bodyContent={
833 7 : <>
834 7 : <Content>
835 7 : <Content component={ContentVariants.h4}>{_("System")}</Content>
836 7 : <Content component="ul">
837 7 : <Content component="li">
838 7 : {_("Ideal for running services")}
839 7 : </Content>
840 7 : <Content component="li">
841 7 : {_("Resource limits can be set")}
842 7 : </Content>
843 7 : <Content component="li">
844 7 : {_("Checkpoint and restore support")}
845 7 : </Content>
846 7 : <Content component="li">
847 7 : {_("Ports under 1024 can be mapped")}
848 7 : </Content>
849 7 : </Content>
850 7 : </Content>
851 7 : <Content>
852 7 : <Content component={ContentVariants.h4}>{_("User")}</Content>
853 7 : <Content component="ul">
854 7 : <Content component="li">
855 7 : {_("Ideal for development")}
856 7 : </Content>
857 7 : <Content component="li">
858 7 : {_("Restricted by user account permissions")}
859 7 : </Content>
860 7 : </Content>
861 7 : </Content>
862 7 : </>
863 : }>
864 7 : <Button variant="plain" hasNoPadding aria-label="More info" icon={<OutlinedQuestionCircleIcon />} />
865 7 : </Popover>
866 : }>
867 7 : { this.props.users.map(user => (
868 7 : <Radio key={user.name}
869 7 : value={user.name}
870 7 : label={user.uid === 0 ? _("System") : cockpit.format("$0 $1", _("User:"), user.name)}
871 7 : id={"run-image-dialog-owner-" + user.name}
872 7 : isChecked={owner === user}
873 7 : isDisabled={this.props.pod}
874 7 : onChange={this.handleOwnerSelect} />))
875 : }
876 7 : </FormGroup>
877 : }
878 15 : <FormGroup fieldId="create-image-image-select-typeahead" label={_("Image")}
879 15 : labelHelp={!this.props.image &&
880 7 : <Popover aria-label={_("Image selection help")}
881 7 : enableFlip
882 7 : bodyContent={
883 7 : <Flex direction={{ default: 'column' }}>
884 7 : <FlexItem>{_("host[:port]/[user]/container[:tag]")}</FlexItem>
885 7 : <FlexItem>{cockpit.format(_("Example: $0"), "quay.io/libpod/busybox")}</FlexItem>
886 7 : <FlexItem>{cockpit.format(_("Searching: $0"), "quay.io/busybox")}</FlexItem>
887 7 : </Flex>
888 : }>
889 7 : <Button variant="plain" hasNoPadding aria-label="More info" icon={<OutlinedQuestionCircleIcon />} />
890 7 : </Popover>
891 : }
892 : >
893 15 : <TypeaheadSelect
894 15 : toggleProps={{ id: 'create-image-image' }}
895 15 : isScrollable
896 15 : noOptionsFoundMessage={_("No images found")}
897 15 : noOptionsAvailableMessage={_("No images found")}
898 15 : selected={selectedImage}
899 15 : selectedIsTrusted
900 15 : placeholder={_("Search string or container location")}
901 15 : onSelect={this.onImageSelect}
902 15 : onClearSelection={this.clearImageSelection}
903 15 : onInputChange={this.debouncedInputChanged}
904 15 : isDisabled={!!this.props.image}
905 : // We do our own filtering when producing imageListOptions
906 6 : filterFunction={(_filterValue, options) => options}
907 15 : selectOptions={imageListOptions.concat(spinnerOptions)}
908 15 : footer={footer} />
909 15 : </FormGroup>
910 :
911 7 : {(image || localImage) &&
912 13 : <FormGroup fieldId="run-image-dialog-pull-latest-image">
913 13 : <Checkbox isChecked={this.state.pullLatestImage} id="run-image-dialog-pull-latest-image"
914 2 : onChange={(_event, value) => this.onValueChanged('pullLatestImage', value)} label={_("Pull latest image")}
915 13 : />
916 13 : </FormGroup>
917 : }
918 :
919 15 : {dialogValues.entrypoint &&
920 1 : <FormGroup fieldId='run-image-dialog-entrypoint' hasNoPaddingTop label={_("Entrypoint")}>
921 1 : <Content component="p" id="run-image-dialog-entrypoint">{dialogValues.entrypoint}</Content>
922 1 : </FormGroup>
923 : }
924 :
925 15 : <FormGroup fieldId='run-image-dialog-command' label={_("Command")}>
926 15 : <TextInput id='run-image-dialog-command'
927 7 : value={dialogValues.command || ''}
928 4 : onChange={(_, value) => this.onValueChanged('command', value)} />
929 15 : </FormGroup>
930 :
931 15 : <FormGroup fieldId="run=image-dialog-tty">
932 15 : <Checkbox id="run-image-dialog-tty"
933 15 : isChecked={this.state.hasTTY}
934 15 : label={_("With terminal")}
935 2 : onChange={(_event, checked) => this.onValueChanged('hasTTY', checked)} />
936 15 : </FormGroup>
937 :
938 15 : <FormGroup fieldId='run-image-dialog-memory' label={_("Memory limit")}>
939 15 : <Flex alignItems={{ default: 'alignItemsCenter' }} className="ct-input-group-spacer-sm modal-run-limiter" id="run-image-dialog-memory-limit">
940 15 : <Checkbox id="run-image-dialog-memory-limit-checkbox"
941 15 : isChecked={this.state.memoryConfigure}
942 2 : onChange={(_event, checked) => this.onValueChanged('memoryConfigure', checked)} />
943 15 : <NumberInput
944 15 : value={dialogValues.memory}
945 15 : id="run-image-dialog-memory"
946 15 : min={0}
947 15 : isDisabled={!this.state.memoryConfigure}
948 0 : onClick={() => !this.state.memoryConfigure && this.onValueChanged('memoryConfigure', true)}
949 0 : onPlus={() => this.onPlusOne('memory')}
950 0 : onMinus={() => this.onMinusOne('memory')}
951 15 : minusBtnAriaLabel={_("Decrease memory")}
952 15 : plusBtnAriaLabel={_("Increase memory")}
953 2 : onChange={ev => this.onNumberValue('memory', ev.target.value, 0, true)} />
954 15 : <FormSelect id='memory-unit-select'
955 15 : aria-label={_("Memory unit")}
956 15 : value={this.state.memoryUnit}
957 15 : isDisabled={!this.state.memoryConfigure}
958 15 : className="dialog-run-form-select"
959 2 : onChange={(_event, value) => this.onValueChanged('memoryUnit', value)}>
960 15 : <FormSelectOption value={units.KB.name} key={units.KB.name} label={_("KB")} />
961 15 : <FormSelectOption value={units.MB.name} key={units.MB.name} label={_("MB")} />
962 15 : <FormSelectOption value={units.GB.name} key={units.GB.name} label={_("GB")} />
963 15 : </FormSelect>
964 15 : </Flex>
965 15 : </FormGroup>
966 :
967 15 : {this.isSystem() &&
968 8 : <FormGroup
969 8 : fieldId='run-image-cpu-priority'
970 8 : label={_("CPU shares")}
971 8 : labelHelp={
972 8 : <Popover aria-label={_("CPU Shares help")}
973 8 : enableFlip
974 8 : bodyContent={_("CPU shares determine the priority of running containers. Default priority is 1024. A higher number prioritizes this container. A lower number decreases priority.")}>
975 8 : <Button variant="plain" hasNoPadding aria-label="More info" icon={<OutlinedQuestionCircleIcon />} />
976 8 : </Popover>
977 : }>
978 8 : <Flex alignItems={{ default: 'alignItemsCenter' }} className="ct-input-group-spacer-sm modal-run-limiter" id="run-image-dialog-cpu-priority">
979 8 : <Checkbox id="run-image-dialog-cpu-priority-checkbox"
980 8 : isChecked={this.state.cpuSharesConfigure}
981 1 : onChange={(_event, checked) => this.onValueChanged('cpuSharesConfigure', checked)} />
982 8 : <NumberInput
983 8 : id="run-image-cpu-priority"
984 8 : value={dialogValues.cpuShares}
985 1 : onClick={() => !this.state.cpuSharesConfigure && this.onValueChanged('cpuSharesConfigure', true)}
986 8 : min={2}
987 8 : max={262144}
988 8 : isDisabled={!this.state.cpuSharesConfigure}
989 0 : onPlus={() => this.onPlusOne('cpuShares')}
990 0 : onMinus={() => this.onMinusOne('cpuShares')}
991 8 : minusBtnAriaLabel={_("Decrease CPU shares")}
992 8 : plusBtnAriaLabel={_("Increase CPU shares")}
993 1 : onChange={ev => this.onNumberValue('cpuShares', ev.target.value, 2)} />
994 8 : </Flex>
995 8 : </FormGroup>
996 : }
997 0 : {((userLingeringEnabled && userPodmanRestartAvailable) || (this.isSystem() && podmanRestartAvailable)) &&
998 9 : <Grid hasGutter md={6} sm={3}>
999 9 : <GridItem>
1000 9 : <FormGroup fieldId='run-image-dialog-restart-policy' label={_("Restart policy")}
1001 9 : labelHelp={
1002 9 : <Popover aria-label={_("Restart policy help")}
1003 9 : enableFlip
1004 0 : bodyContent={userLingeringEnabled ? _("Restart policy to follow when containers exit. Using linger for auto-starting containers may not work in some circumstances, such as when ecryptfs, systemd-homed, NFS, or 2FA are used on a user account.") : _("Restart policy to follow when containers exit.")}>
1005 9 : <Button variant="plain" hasNoPadding aria-label="More info" icon={<OutlinedQuestionCircleIcon />} />
1006 9 : </Popover>
1007 : }
1008 : >
1009 9 : <FormSelect id="run-image-dialog-restart-policy"
1010 9 : aria-label={_("Restart policy help")}
1011 9 : value={dialogValues.restartPolicy}
1012 3 : onChange={(_event, value) => this.onValueChanged('restartPolicy', value)}>
1013 9 : <FormSelectOption value='no' key='no' label={_("No")} />
1014 9 : <FormSelectOption value='on-failure' key='on-failure' label={_("On failure")} />
1015 9 : <FormSelectOption value='always' key='always' label={_("Always")} />
1016 9 : </FormSelect>
1017 9 : </FormGroup>
1018 9 : </GridItem>
1019 9 : {dialogValues.restartPolicy === "on-failure" &&
1020 1 : <FormGroup fieldId='run-image-dialog-restart-retries'
1021 1 : label={_("Maximum retries")}>
1022 1 : <NumberInput
1023 1 : id="run-image-dialog-restart-retries"
1024 1 : value={dialogValues.restartTries}
1025 1 : min={1}
1026 1 : max={65535}
1027 1 : widthChars={5}
1028 1 : minusBtnAriaLabel={_("Decrease maximum retries")}
1029 1 : plusBtnAriaLabel={_("Increase maximum retries")}
1030 0 : onMinus={() => this.onMinusOne('restartTries')}
1031 0 : onPlus={() => this.onPlusOne('restartTries')}
1032 1 : onChange={ev => this.onNumberValue('restartTries', ev.target.value, 1)}
1033 1 : />
1034 1 : </FormGroup>
1035 : }
1036 9 : </Grid>
1037 : }
1038 15 : </Tab>
1039 15 : <Tab eventKey={1} title={<TabTitleText>{_("Integration")}</TabTitleText>} id="create-image-dialog-tab-integration" className="pf-v6-c-form">
1040 :
1041 15 : <DynamicListForm id='run-image-dialog-publish'
1042 15 : emptyStateString={_("No ports exposed")}
1043 15 : formclass='publish-port-form'
1044 15 : label={_("Port mapping")}
1045 15 : actionLabel={_("Add port mapping")}
1046 15 : validationFailed={dialogValues.validationFailed.publish}
1047 3 : onValidationChange={value => this.dynamicListOnValidationChange('publish', value)}
1048 3 : onChange={value => this.onValueChanged('publish', value)}
1049 15 : default={{ IP: null, containerPort: null, hostPort: null, protocol: 'tcp' }}
1050 15 : itemcomponent={PublishPort} />
1051 15 : <DynamicListForm id='run-image-dialog-volume'
1052 15 : emptyStateString={_("No volumes specified")}
1053 15 : formclass='volume-form'
1054 15 : label={_("Volumes")}
1055 15 : actionLabel={_("Add volume")}
1056 15 : validationFailed={dialogValues.validationFailed.volumes}
1057 3 : onValidationChange={value => this.dynamicListOnValidationChange('volumes', value)}
1058 3 : onChange={value => this.onValueChanged('volumes', value)}
1059 15 : default={{ containerPath: null, hostPath: null, mode: 'rw' }}
1060 15 : options={{ selinuxAvailable }}
1061 15 : itemcomponent={Volume} />
1062 :
1063 15 : <DynamicListForm id='run-image-dialog-env'
1064 15 : emptyStateString={_("No environment variables specified")}
1065 15 : formclass='env-form'
1066 15 : label={_("Environment variables")}
1067 15 : actionLabel={_("Add variable")}
1068 15 : validationFailed={dialogValues.validationFailed.env}
1069 3 : onValidationChange={value => this.dynamicListOnValidationChange('env', value)}
1070 3 : onChange={value => this.onValueChanged('env', value)}
1071 15 : default={{ envKey: null, envValue: null }}
1072 15 : helperText={_("Paste one or more lines of key=value pairs into any field for bulk import")}
1073 15 : itemcomponent={EnvVar} />
1074 15 : </Tab>
1075 15 : <Tab eventKey={2} title={<TabTitleText>{_("Health check")}</TabTitleText>} id="create-image-dialog-tab-healthcheck" className="pf-v6-c-form pf-m-horizontal">
1076 15 : <FormGroup fieldId='run-image-dialog-healthcheck-command' label={_("Command")}>
1077 15 : <TextInput id='run-image-dialog-healthcheck-command'
1078 15 : value={dialogValues.healthcheck_command || ''}
1079 2 : onChange={(_, value) => this.onValueChanged('healthcheck_command', value)} />
1080 15 : </FormGroup>
1081 :
1082 15 : <FormGroup fieldId='run-image-healthcheck-interval' label={_("Interval")}
1083 15 : labelHelp={
1084 15 : <Popover aria-label={_("Health check interval help")}
1085 15 : enableFlip
1086 15 : bodyContent={_("Interval how often health check is run.")}>
1087 15 : <Button variant="plain" hasNoPadding aria-label="More info" icon={<OutlinedQuestionCircleIcon />} />
1088 15 : </Popover>
1089 : }>
1090 15 : <InputGroup>
1091 15 : <NumberInput
1092 15 : id="run-image-healthcheck-interval"
1093 15 : value={dialogValues.healthcheck_interval}
1094 15 : min={0}
1095 15 : max={262144}
1096 15 : widthChars={6}
1097 15 : minusBtnAriaLabel={_("Decrease interval")}
1098 15 : plusBtnAriaLabel={_("Increase interval")}
1099 0 : onMinus={() => this.onMinusOne('healthcheck_interval')}
1100 0 : onPlus={() => this.onPlusOne('healthcheck_interval')}
1101 2 : onChange={ev => this.onNumberValue('healthcheck_interval', ev.target.value)} />
1102 15 : <InputGroupText isPlain>{_("seconds")}</InputGroupText>
1103 15 : </InputGroup>
1104 15 : </FormGroup>
1105 15 : <FormGroup fieldId='run-image-healthcheck-timeout' label={_("Timeout")}
1106 15 : labelHelp={
1107 15 : <Popover aria-label={_("Health check timeout help")}
1108 15 : enableFlip
1109 15 : bodyContent={_("The maximum time allowed to complete the health check before an interval is considered failed.")}>
1110 15 : <Button variant="plain" hasNoPadding aria-label="More info" icon={<OutlinedQuestionCircleIcon />} />
1111 15 : </Popover>
1112 : }>
1113 15 : <InputGroup>
1114 15 : <NumberInput
1115 15 : id="run-image-healthcheck-timeout"
1116 15 : value={dialogValues.healthcheck_timeout}
1117 15 : min={0}
1118 15 : max={262144}
1119 15 : widthChars={6}
1120 15 : minusBtnAriaLabel={_("Decrease timeout")}
1121 15 : plusBtnAriaLabel={_("Increase timeout")}
1122 0 : onMinus={() => this.onMinusOne('healthcheck_timeout')}
1123 0 : onPlus={() => this.onPlusOne('healthcheck_timeout')}
1124 2 : onChange={ev => this.onNumberValue('healthcheck_timeout', ev.target.value)} />
1125 15 : <InputGroupText isPlain>{_("seconds")}</InputGroupText>
1126 15 : </InputGroup>
1127 15 : </FormGroup>
1128 15 : <FormGroup fieldId='run-image-healthcheck-start-period' label={_("Start period")}
1129 15 : labelHelp={
1130 15 : <Popover aria-label={_("Health check start period help")}
1131 15 : enableFlip
1132 15 : bodyContent={_("The initialization time needed for a container to bootstrap.")}>
1133 15 : <Button variant="plain" hasNoPadding aria-label="More info" icon={<OutlinedQuestionCircleIcon />} />
1134 15 : </Popover>
1135 : }>
1136 15 : <InputGroup>
1137 15 : <NumberInput
1138 15 : id="run-image-healthcheck-start-period"
1139 15 : value={dialogValues.healthcheck_start_period}
1140 15 : min={0}
1141 15 : max={262144}
1142 15 : widthChars={6}
1143 15 : minusBtnAriaLabel={_("Decrease start period")}
1144 15 : plusBtnAriaLabel={_("Increase start period")}
1145 0 : onMinus={() => this.onMinusOne('healthcheck_start_period')}
1146 0 : onPlus={() => this.onPlusOne('healthcheck_start_period')}
1147 2 : onChange={ev => this.onNumberValue('healthcheck_start_period', ev.target.value)} />
1148 15 : <InputGroupText isPlain>{_("seconds")}</InputGroupText>
1149 15 : </InputGroup>
1150 15 : </FormGroup>
1151 15 : <FormGroup fieldId='run-image-healthcheck-retries' label={_("Retries")}
1152 15 : labelHelp={
1153 15 : <Popover aria-label={_("Health check retries help")}
1154 15 : enableFlip
1155 15 : bodyContent={_("The number of retries allowed before a healthcheck is considered to be unhealthy.")}>
1156 15 : <Button variant="plain" hasNoPadding aria-label="More info" icon={<OutlinedQuestionCircleIcon />} />
1157 15 : </Popover>
1158 : }>
1159 15 : <NumberInput
1160 15 : id="run-image-healthcheck-retries"
1161 15 : value={dialogValues.healthcheck_retries}
1162 15 : min={0}
1163 15 : max={999}
1164 15 : widthChars={3}
1165 15 : minusBtnAriaLabel={_("Decrease retries")}
1166 15 : plusBtnAriaLabel={_("Increase retries")}
1167 2 : onMinus={() => this.onMinusOne('healthcheck_retries')}
1168 0 : onPlus={() => this.onPlusOne('healthcheck_retries')}
1169 0 : onChange={ev => this.onNumberValue('healthcheck_retries', ev.target.value)} />
1170 15 : </FormGroup>
1171 15 : {version.localeCompare("4.3", undefined, { numeric: true, sensitivity: 'base' }) >= 0 &&
1172 15 : <FormGroup isInline hasNoPaddingTop fieldId='run-image-healthcheck-action' label={_("When unhealthy") }
1173 15 : labelHelp={
1174 15 : <Popover aria-label={_("Health failure check action help")}
1175 15 : enableFlip
1176 15 : bodyContent={_("Action to take once the container transitions to an unhealthy state.")}>
1177 15 : <Button variant="plain" hasNoPadding aria-label="More info" icon={<OutlinedQuestionCircleIcon />} />
1178 15 : </Popover>
1179 : }>
1180 15 : {HealthCheckOnFailureActionOrder.map(item =>
1181 15 : <Radio value={item.value}
1182 15 : key={item.value}
1183 15 : label={item.label}
1184 15 : id={`run-image-healthcheck-action-${item.value}`}
1185 15 : isChecked={dialogValues.healthcheck_action === item.value}
1186 2 : onChange={() => this.onValueChanged('healthcheck_action', item.value)} />
1187 15 : )}
1188 15 : </FormGroup>
1189 : }
1190 15 : </Tab>
1191 15 : </Tabs>
1192 15 : </Form>
1193 : );
1194 :
1195 6 : const isDisabled = (!image && selectedImage === "") || this.isFormInvalid(dialogValues.validationFailed) || this.state.inProgress;
1196 :
1197 15 : return (
1198 15 : <Modal isOpen
1199 15 : position="top" variant="medium"
1200 15 : onClose={Dialogs.close}
1201 : // TODO: still not ideal on chromium https://github.com/patternfly/patternfly-react/issues/6471
1202 0 : onEscapePress={() => {
1203 0 : if (this.state.isImageSelectOpen) {
1204 0 : this.onImageSelectToggle(!this.state.isImageSelectOpen);
1205 0 : } else {
1206 0 : Dialogs.close();
1207 0 : }
1208 0 : }}
1209 0 : title={this.props.pod ? cockpit.format(_("Create container in $0"), this.props.pod.Name) : _("Create container")}
1210 15 : footer={<>
1211 13 : <Button variant='primary' id="create-image-create-run-btn" onClick={() => this.onCreateClicked(true)}
1212 15 : isDisabled={isDisabled} isLoading={this.state.inProgress}>
1213 15 : {_("Create and run")}
1214 15 : </Button>
1215 4 : <Button variant='secondary' id="create-image-create-btn" onClick={() => this.onCreateClicked(false)}
1216 15 : isDisabled={isDisabled} isLoading={this.state.inProgress}>
1217 15 : {_("Create")}
1218 15 : </Button>
1219 15 : <Button variant='link' className='btn-cancel' onClick={Dialogs.close} isDisabled={this.state.inProgress}>
1220 15 : {_("Cancel")}
1221 15 : </Button>
1222 15 : </>}
1223 : >
1224 15 : {defaultBody}
1225 15 : </Modal>
1226 : );
1227 15 : }
1228 43 : }
|