LCOV - code coverage report
Current view: top level - src - ImageRunModal.jsx Coverage Total Hit
Test: cockpit-podman Lines: 88.6 % 1006 891
Test Date: 2025-05-21 17:35:03

            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 : }
        

Generated by: LCOV version 2.0-1