diff options
Diffstat (limited to 'src/ImageRunModal.jsx')
-rw-r--r-- | src/ImageRunModal.jsx | 1183 |
1 files changed, 1183 insertions, 0 deletions
diff --git a/src/ImageRunModal.jsx b/src/ImageRunModal.jsx new file mode 100644 index 0000000..477be02 --- /dev/null +++ b/src/ImageRunModal.jsx @@ -0,0 +1,1183 @@ +import React from 'react'; +import { Button } from "@patternfly/react-core/dist/esm/components/Button"; +import { Checkbox } from "@patternfly/react-core/dist/esm/components/Checkbox"; +import { Form, FormGroup } from "@patternfly/react-core/dist/esm/components/Form"; +import { FormHelper } from "cockpit-components-form-helper.jsx"; +import { FormSelect, FormSelectOption } from "@patternfly/react-core/dist/esm/components/FormSelect"; +import { Grid, GridItem } from "@patternfly/react-core/dist/esm/layouts/Grid"; +import { Modal } from "@patternfly/react-core/dist/esm/components/Modal"; +import { Radio } from "@patternfly/react-core/dist/esm/components/Radio"; +import { Select, SelectGroup, SelectOption, SelectVariant } from "@patternfly/react-core/dist/esm/deprecated/components/Select"; +import { NumberInput } from "@patternfly/react-core/dist/esm/components/NumberInput"; +import { InputGroup, InputGroupText } from "@patternfly/react-core/dist/esm/components/InputGroup"; +import { TextInput } from "@patternfly/react-core/dist/esm/components/TextInput"; +import { Tab, TabTitleText, Tabs } from "@patternfly/react-core/dist/esm/components/Tabs"; +import { Text, TextContent, TextList, TextListItem, TextVariants } from "@patternfly/react-core/dist/esm/components/Text"; +import { ToggleGroup, ToggleGroupItem } from "@patternfly/react-core/dist/esm/components/ToggleGroup"; +import { Flex, FlexItem } from "@patternfly/react-core/dist/esm/layouts/Flex"; +import { Popover } from "@patternfly/react-core/dist/esm/components/Popover"; +import { OutlinedQuestionCircleIcon } from '@patternfly/react-icons'; +import * as dockerNames from 'docker-names'; + +import { ErrorNotification } from './Notification.jsx'; +import * as utils from './util.js'; +import * as client from './client.js'; +import rest from './rest.js'; +import cockpit from 'cockpit'; +import { onDownloadContainer, onDownloadContainerFinished } from './Containers.jsx'; +import { PublishPort, validatePublishPort } from './PublishPort.jsx'; +import { DynamicListForm } from 'cockpit-components-dynamic-list.jsx'; +import { validateVolume, Volume } from './Volume.jsx'; +import { EnvVar, validateEnvVar } from './Env.jsx'; + +import { debounce } from 'throttle-debounce'; + +import "./ImageRunModal.scss"; + +const _ = cockpit.gettext; + +const systemOwner = "system"; + +const units = { + KB: { + name: "KB", + baseExponent: 1, + }, + MB: { + name: "MB", + baseExponent: 2, + }, + GB: { + name: "GB", + baseExponent: 3, + }, +}; + +// healthchecks.go HealthCheckOnFailureAction +const HealthCheckOnFailureActionOrder = [ + { value: 0, label: _("No action") }, + { value: 3, label: _("Restart") }, + { value: 4, label: _("Stop") }, + { value: 2, label: _("Force stop") }, +]; + +export class ImageRunModal extends React.Component { + constructor(props) { + super(props); + + let command = ""; + if (this.props.image && this.props.image.Command) { + command = utils.quote_cmdline(this.props.image.Command); + } + + const entrypoint = utils.quote_cmdline(this.props.image?.Entrypoint); + + let selectedImage = ""; + if (this.props.image) { + selectedImage = utils.image_name(this.props.image); + } + + let default_owner = this.props.systemServiceAvailable ? systemOwner : this.props.user; + if (this.props.pod) + default_owner = this.props.pod.isSystem ? systemOwner : this.props.user; + + this.state = { + command, + containerName: dockerNames.getRandomName(), + entrypoint, + env: [], + hasTTY: true, + publish: [], + image: props.image, + memory: 512, + cpuShares: 1024, + memoryConfigure: false, + cpuSharesConfigure: false, + memoryUnit: 'MB', + validationFailed: {}, + volumes: [], + restartPolicy: "no", + restartTries: 5, + pullLatestImage: false, + activeTabKey: 0, + owner: default_owner, + /* image select */ + selectedImage, + searchFinished: false, + searchInProgress: false, + searchText: "", + imageResults: {}, + isImageSelectOpen: false, + searchByRegistry: 'all', + /* health check */ + healthcheck_command: "", + healthcheck_interval: 30, + healthcheck_timeout: 30, + healthcheck_start_period: 0, + healthcheck_retries: 3, + healthcheck_action: 0, + }; + this.getCreateConfig = this.getCreateConfig.bind(this); + this.onValueChanged = this.onValueChanged.bind(this); + } + + componentDidMount() { + this._isMounted = true; + this.onSearchTriggered(this.state.searchText); + } + + componentWillUnmount() { + this._isMounted = false; + + if (this.activeConnection) + this.activeConnection.close(); + } + + getCreateConfig() { + const createConfig = {}; + + if (this.props.pod) { + createConfig.pod = this.props.pod.Id; + } + + if (this.state.image) { + createConfig.image = this.state.image.RepoTags ? this.state.image.RepoTags[0] : ""; + } else { + let img = this.state.selectedImage.Name; + // Make implicit :latest + if (!img.includes(":")) { + img += ":latest"; + } + createConfig.image = img; + } + if (this.state.containerName) + createConfig.name = this.state.containerName; + if (this.state.command) { + createConfig.command = utils.unquote_cmdline(this.state.command); + } + const resourceLimit = {}; + if (this.state.memoryConfigure && this.state.memory) { + const memorySize = this.state.memory * (1000 ** units[this.state.memoryUnit].baseExponent); + resourceLimit.memory = { limit: memorySize }; + createConfig.resource_limits = resourceLimit; + } + if (this.state.cpuSharesConfigure && parseInt(this.state.cpuShares) !== 0) { + resourceLimit.cpu = { shares: parseInt(this.state.cpuShares) }; + createConfig.resource_limits = resourceLimit; + } + createConfig.terminal = this.state.hasTTY; + if (this.state.publish.some(port => port !== undefined)) + createConfig.portmappings = this.state.publish + .filter(port => port?.containerPort) + .map(port => { + const pm = { container_port: parseInt(port.containerPort), protocol: port.protocol }; + if (port.hostPort !== null) + pm.host_port = parseInt(port.hostPort); + if (port.IP !== null) + pm.host_ip = port.IP; + return pm; + }); + if (this.state.env.some(item => item !== undefined)) { + const envs = {}; + this.state.env.forEach(item => { + if (item !== undefined) + envs[item.envKey] = item.envValue; + }); + createConfig.env = envs; + } + if (this.state.volumes.some(volume => volume !== undefined)) { + createConfig.mounts = this.state.volumes + .filter(volume => volume?.hostPath && volume?.containerPath) + .map(volume => { + const record = { source: volume.hostPath, destination: volume.containerPath, type: "bind" }; + record.options = []; + if (volume.mode) + record.options.push(volume.mode); + if (volume.selinux) + record.options.push(volume.selinux); + return record; + }); + } + + if (this.state.restartPolicy !== "no") { + createConfig.restart_policy = this.state.restartPolicy; + if (this.state.restartPolicy === "on-failure" && this.state.restartTries !== null) { + createConfig.restart_tries = parseInt(this.state.restartTries); + } + // Enable podman-restart.service for system containers, for user + // sessions enable-linger needs to be enabled for containers to start on boot. + if (this.state.restartPolicy === "always" && (this.props.podmanInfo.userLingeringEnabled || this.props.systemServiceAvailable)) { + this.enablePodmanRestartService(); + } + } + + if (this.state.healthcheck_command !== "") { + createConfig.healthconfig = { + Interval: parseInt(this.state.healthcheck_interval) * 1000000000, + Retries: this.state.healthcheck_retries, + StartPeriod: parseInt(this.state.healthcheck_start_period) * 1000000000, + Test: utils.unquote_cmdline(this.state.healthcheck_command), + Timeout: parseInt(this.state.healthcheck_timeout) * 1000000000, + }; + createConfig.health_check_on_failure_action = parseInt(this.state.healthcheck_action); + } + + return createConfig; + } + + createContainer = (isSystem, createConfig, runImage) => { + const Dialogs = this.props.dialogs; + client.createContainer(isSystem, createConfig) + .then(reply => { + if (runImage) { + client.postContainer(isSystem, "start", reply.Id, {}) + .then(() => Dialogs.close()) + .catch(ex => { + // If container failed to start remove it, so a user can fix the settings and retry and + // won't get another error that the container name is already taken. + client.delContainer(isSystem, reply.Id, true) + .then(() => { + this.setState({ + dialogError: _("Container failed to be started"), + dialogErrorDetail: cockpit.format("$0: $1", ex.reason, ex.message) + }); + }) + .catch(ex => { + this.setState({ + dialogError: _("Failed to clean up container"), + dialogErrorDetail: cockpit.format("$0: $1", ex.reason, ex.message) + }); + }); + }); + } else { + Dialogs.close(); + } + }) + .catch(ex => { + this.setState({ + dialogError: _("Container failed to be created"), + dialogErrorDetail: cockpit.format("$0: $1", ex.reason, ex.message) + }); + }); + }; + + async onCreateClicked(runImage = false) { + if (!await this.validateForm()) + return; + + const Dialogs = this.props.dialogs; + const createConfig = this.getCreateConfig(); + const { pullLatestImage } = this.state; + const isSystem = this.isSystem(); + let imageExists = true; + + try { + await client.imageExists(isSystem, createConfig.image); + } catch (error) { + imageExists = false; + } + + if (imageExists && !pullLatestImage) { + this.createContainer(isSystem, createConfig, runImage); + } else { + Dialogs.close(); + const tempImage = { ...createConfig }; + + // Assign temporary properties to allow rendering + tempImage.Id = tempImage.name; + tempImage.isSystem = isSystem; + tempImage.State = { Status: _("downloading") }; + tempImage.Created = new Date(); + tempImage.Name = tempImage.name; + tempImage.Image = createConfig.image; + tempImage.isDownloading = true; + + onDownloadContainer(tempImage); + + client.pullImage(isSystem, createConfig.image).then(reply => { + client.createContainer(isSystem, createConfig) + .then(reply => { + if (runImage) { + client.postContainer(isSystem, "start", reply.Id, {}) + .then(() => onDownloadContainerFinished(createConfig)) + .catch(ex => { + onDownloadContainerFinished(createConfig); + const error = cockpit.format(_("Failed to run container $0"), tempImage.name); + this.props.onAddNotification({ type: 'danger', error, errorDetail: ex.message }); + }); + } + }) + .catch(ex => { + onDownloadContainerFinished(createConfig); + const error = cockpit.format(_("Failed to create container $0"), tempImage.name); + this.props.onAddNotification({ type: 'danger', error, errorDetail: ex.reason }); + }); + }) + .catch(ex => { + onDownloadContainerFinished(createConfig); + const error = cockpit.format(_("Failed to pull image $0"), tempImage.image); + this.props.onAddNotification({ type: 'danger', error, errorDetail: ex.message }); + }); + } + } + + onValueChanged(key, value) { + this.setState({ [key]: value }); + } + + onPlusOne(key) { + this.setState(state => ({ [key]: parseInt(state[key]) + 1 })); + } + + onMinusOne(key) { + this.setState(state => ({ [key]: parseInt(state[key]) - 1 })); + } + + handleTabClick = (event, tabIndex) => { + // Prevent the form from being submitted. + event.preventDefault(); + this.setState({ + activeTabKey: tabIndex, + }); + }; + + onSearchTriggered = value => { + // Do not call the SearchImage API if the input string is not at least 2 chars, + // The comparison was done considering the fact that we miss always one letter due to delayed setState + if (value.length < 2) + return; + + // Don't search for a value with a tag specified + const patt = /:[\w|\d]+$/; + if (patt.test(value)) { + return; + } + + if (this.activeConnection) + this.activeConnection.close(); + + this.setState({ searchFinished: false, searchInProgress: true }); + this.activeConnection = rest.connect(client.getAddress(this.isSystem()), this.isSystem()); + let searches = []; + + // If there are registries configured search in them, or if a user searches for `docker.io/cockpit` let + // podman search in the user specified registry. + if (Object.keys(this.props.podmanInfo.registries).length !== 0 || value.includes('/')) { + searches.push(this.activeConnection.call({ + method: "GET", + path: client.VERSION + "libpod/images/search", + body: "", + params: { + term: value, + } + })); + } else { + searches = searches.concat(utils.fallbackRegistries.map(registry => + this.activeConnection.call({ + method: "GET", + path: client.VERSION + "libpod/images/search", + body: "", + params: { + term: registry + "/" + value + } + }))); + } + + Promise.allSettled(searches) + .then(reply => { + if (reply && this._isMounted) { + let imageResults = []; + let dialogError = ""; + let dialogErrorDetail = ""; + + for (const result of reply) { + if (result.status === "fulfilled") { + imageResults = imageResults.concat(JSON.parse(result.value)); + } else { + dialogError = _("Failed to search for new images"); + // TODO: add registry context, podman does not include it in the reply. + dialogErrorDetail = result.reason ? cockpit.format(_("Failed to search for images: $0"), result.reason.message) : _("Failed to search for images."); + } + } + // Group images on registry + const images = {}; + imageResults.forEach(image => { + // Add Tag is it's there + image.toString = function imageToString() { + if (this.Tag) { + return this.Name + ':' + this.Tag; + } + return this.Name; + }; + + let index = image.Index; + + // listTags results do not return the registry Index. + // https://github.com/containers/common/pull/803 + if (!index) { + index = image.Name.split('/')[0]; + } + + if (index in images) { + images[index].push(image); + } else { + images[index] = [image]; + } + }); + this.setState({ + imageResults: images || {}, + searchFinished: true, + searchInProgress: false, + dialogError, + dialogErrorDetail, + }); + } + }); + }; + + clearImageSelection = () => { + // Reset command if it was prefilled + let command = this.state.command; + if (this.state.command === utils.quote_cmdline(this.state.selectedImage?.Command)) + command = ""; + + this.setState({ + selectedImage: "", + image: "", + isImageSelectOpen: false, + imageResults: {}, + searchText: "", + searchFinished: false, + command, + entrypoint: "", + }); + }; + + onImageSelectToggle = (_, isOpen) => { + this.setState({ + isImageSelectOpen: isOpen, + }); + }; + + onImageSelect = (event, value, placeholder) => { + if (event === undefined) + return; + + let command = this.state.command; + if (value.Command && !command) + command = utils.quote_cmdline(value.Command); + + const entrypoint = utils.quote_cmdline(value?.Entrypoint); + + this.setState({ + selectedImage: value, + isImageSelectOpen: false, + command, + entrypoint, + }); + }; + + handleImageSelectInput = value => { + this.setState({ + searchText: value, + // Reset searchFinished status when text input changes + searchFinished: false, + selectedImage: "", + }); + this.onSearchTriggered(value); + }; + + debouncedInputChanged = debounce(300, this.handleImageSelectInput); + + handleOwnerSelect = (event) => { + const value = event.currentTarget.value; + this.setState({ + owner: value + }); + }; + + filterImages = () => { + const { localImages } = this.props; + const { imageResults, searchText } = this.state; + const local = _("Local images"); + const images = { ...imageResults }; + const isSystem = this.isSystem(); + + let imageRegistries = []; + if (this.state.searchByRegistry == 'local' || this.state.searchByRegistry == 'all') { + imageRegistries.push(local); + images[local] = localImages; + + if (this.state.searchByRegistry == 'all') + imageRegistries = imageRegistries.concat(Object.keys(imageResults)); + } else { + imageRegistries.push(this.state.searchByRegistry); + } + + // Strip out all non-allowed container image characters when filtering. + let regexString = searchText.replace(/[^\w_.:-]/g, ""); + // Strip image registry option if set for comparing results for docker.io searching for docker.io/fedora + // returns docker.io/$username/fedora for example. + if (regexString.includes('/')) { + regexString = searchText.replace(searchText.split('/')[0], ''); + } + const input = new RegExp(regexString, 'i'); + + const results = imageRegistries + .map((reg, index) => { + const filtered = (reg in images ? images[reg] : []) + .filter(image => { + if (image.isSystem && !isSystem) { + return false; + } + if ('isSystem' in image && !image.isSystem && isSystem) { + return false; + } + return image.Name.search(input) !== -1; + }) + .map((image, index) => { + return ( + <SelectOption + key={index} + value={image} + {...(image.Description && { description: image.Description })} + /> + ); + }); + + if (filtered.length === 0) { + return []; + } else { + return ( + <SelectGroup label={reg} key={index} value={reg}> + {filtered} + </SelectGroup> + ); + } + }) + .filter(group => group.length !== 0); // filter out empty groups + + // Remove <SelectGroup> when there is a filter selected. + if (this.state.searchByRegistry !== 'all' && imageRegistries.length === 1 && results.length === 1) { + return results[0].props.children; + } + + return results; + }; + + // Similar to the output of podman search and podman's /libpod/images/search endpoint only show the root domain. + truncateRegistryDomain = (domain) => { + const parts = domain.split('.'); + if (parts.length > 2) { + return parts[parts.length - 2] + "." + parts[parts.length - 1]; + } + return domain; + }; + + enablePodmanRestartService = () => { + const argv = ["systemctl", "enable", "podman-restart.service"]; + if (!this.isSystem()) { + argv.splice(1, 0, "--user"); + } + + cockpit.spawn(argv, { superuser: this.isSystem() ? "require" : "", err: "message" }) + .catch(err => { + console.warn("Failed to start podman-restart.service:", JSON.stringify(err)); + }); + }; + + isSystem = () => { + const { owner } = this.state; + return owner === systemOwner; + }; + + isFormInvalid = validationFailed => { + const groupHasError = row => row && Object.values(row) + .filter(val => val) // Filter out empty/undefined properties + .length > 0; // If one field has error, the whole group (dynamicList) is invalid + + // If at least one group is invalid, then the whole form is invalid + return validationFailed.publish?.some(groupHasError) || + validationFailed.volumes?.some(groupHasError) || + validationFailed.env?.some(groupHasError) || + !!validationFailed.containerName; + }; + + async validateContainerName(containerName) { + try { + await client.containerExists(this.isSystem(), containerName); + } catch (error) { + return; + } + return _("Name already in use"); + } + + async validateForm() { + const { publish, volumes, env, containerName } = this.state; + const validationFailed = { }; + + const publishValidation = publish.map(a => { + if (a === undefined) + return undefined; + + return { + IP: validatePublishPort(a.IP, "IP"), + hostPort: validatePublishPort(a.hostPort, "hostPort"), + containerPort: validatePublishPort(a.containerPort, "containerPort"), + }; + }); + if (publishValidation.some(entry => Object.keys(entry).length > 0)) + validationFailed.publish = publishValidation; + + const volumesValidation = volumes.map(a => { + if (a === undefined) + return undefined; + + return { + hostPath: validateVolume(a.hostPath, "hostPath"), + containerPath: validateVolume(a.containerPath, "containerPath"), + }; + }); + if (volumesValidation.some(entry => Object.keys(entry).length > 0)) + validationFailed.volumes = volumesValidation; + + const envValidation = env.map(a => { + if (a === undefined) + return undefined; + + return { + envKey: validateEnvVar(a.envKey, "envKey"), + envValue: validateEnvVar(a.envValue, "envValue"), + }; + }); + if (envValidation.some(entry => Object.keys(entry).length > 0)) + validationFailed.env = envValidation; + + const containerNameValidation = await this.validateContainerName(containerName); + + if (containerNameValidation) + validationFailed.containerName = containerNameValidation; + + this.setState({ validationFailed }); + + return !this.isFormInvalid(validationFailed); + } + + /* Updates a validation object of the whole dynamic list's form (e.g. the whole port-mapping form) + * + * Arguments + * - key: [publish/volumes/env] - Specifies the validation of which dynamic form of the Image run dialog is being updated + * - value: An array of validation errors of the form. Each item of the array represents a row of the dynamic list. + * Index needs to corellate with a row number + */ + dynamicListOnValidationChange = (key, value) => { + const validationFailedDelta = { ...this.state.validationFailed }; + + validationFailedDelta[key] = value; + + if (validationFailedDelta[key].every(a => a === undefined)) + delete validationFailedDelta[key]; + + this.onValueChanged('validationFailed', validationFailedDelta); + }; + + render() { + const Dialogs = this.props.dialogs; + const { registries, podmanRestartAvailable, userLingeringEnabled, userPodmanRestartAvailable, selinuxAvailable, version } = this.props.podmanInfo; + const { image } = this.props; + const dialogValues = this.state; + const { activeTabKey, owner, selectedImage } = this.state; + + let imageListOptions = []; + if (!image) { + imageListOptions = this.filterImages(); + } + + const localImage = this.state.image || (selectedImage && this.props.localImages.some(img => img.Id === selectedImage.Id)); + const podmanRegistries = registries && registries.search ? registries.search : utils.fallbackRegistries; + + // Add the search component + const footer = ( + <ToggleGroup className='image-search-footer' aria-label={_("Search by registry")}> + <ToggleGroupItem text={_("All")} key='all' isSelected={this.state.searchByRegistry == 'all'} onChange={(ev, _) => { + ev.stopPropagation(); + this.setState({ searchByRegistry: 'all' }); + }} + // Ignore SelectToggle's touchstart's default behaviour + onTouchStart={ev => { + ev.stopPropagation(); + }} + /> + <ToggleGroupItem text={_("Local")} key='local' isSelected={this.state.searchByRegistry == 'local'} onChange={(ev, _) => { + ev.stopPropagation(); + this.setState({ searchByRegistry: 'local' }); + }} + onTouchStart={ev => { + ev.stopPropagation(); + }} + /> + {podmanRegistries.map(registry => { + const index = this.truncateRegistryDomain(registry); + return ( + <ToggleGroupItem + text={index} key={index} + isSelected={ this.state.searchByRegistry == index } + onChange={ (ev, _) => { + ev.stopPropagation(); + this.setState({ searchByRegistry: index }); + } } + onTouchStart={ ev => ev.stopPropagation() } + /> + ); + })} + </ToggleGroup> + ); + + const defaultBody = ( + <Form> + {this.state.dialogError && <ErrorNotification errorMessage={this.state.dialogError} errorDetail={this.state.dialogErrorDetail} />} + <FormGroup id="image-name-group" fieldId='run-image-dialog-name' label={_("Name")} className="ct-m-horizontal"> + <TextInput id='run-image-dialog-name' + className="image-name" + placeholder={_("Container name")} + validated={dialogValues.validationFailed.containerName ? "error" : "default"} + value={dialogValues.containerName} + onChange={(_, value) => { + utils.validationClear(dialogValues.validationFailed, "containerName", (value) => this.onValueChanged("validationFailed", value)); + utils.validationDebounce(async () => { + const delta = await this.validateContainerName(value); + if (delta) + this.onValueChanged("validationFailed", { ...dialogValues.validationFailed, containerName: delta }); + }); + this.onValueChanged('containerName', value); + }} /> + <FormHelper helperTextInvalid={dialogValues.validationFailed.containerName} /> + </FormGroup> + <Tabs activeKey={activeTabKey} onSelect={this.handleTabClick}> + <Tab eventKey={0} title={<TabTitleText>{_("Details")}</TabTitleText>} className="pf-v5-c-form pf-m-horizontal"> + { this.props.userServiceAvailable && this.props.systemServiceAvailable && + <FormGroup isInline hasNoPaddingTop fieldId='run-image-dialog-owner' label={_("Owner")} + labelIcon={ + <Popover aria-label={_("Owner help")} + enableFlip + bodyContent={ + <> + <TextContent> + <Text component={TextVariants.h4}>{_("System")}</Text> + <TextList> + <TextListItem> + {_("Ideal for running services")} + </TextListItem> + <TextListItem> + {_("Resource limits can be set")} + </TextListItem> + <TextListItem> + {_("Checkpoint and restore support")} + </TextListItem> + <TextListItem> + {_("Ports under 1024 can be mapped")} + </TextListItem> + </TextList> + </TextContent> + <TextContent> + <Text component={TextVariants.h4}>{cockpit.format("$0 $1", _("User:"), this.props.user)}</Text> + <TextList> + <TextListItem> + {_("Ideal for development")} + </TextListItem> + <TextListItem> + {_("Restricted by user account permissions")} + </TextListItem> + </TextList> + </TextContent> + </> + }> + <button onClick={e => e.preventDefault()} className="pf-v5-c-form__group-label-help"> + <OutlinedQuestionCircleIcon /> + </button> + </Popover> + }> + <Radio value="system" + label={_("System")} + id="run-image-dialog-owner-system" + isChecked={owner === "system"} + isDisabled={this.props.pod} + onChange={this.handleOwnerSelect} /> + <Radio value={this.props.user} + label={cockpit.format("$0 $1", _("User:"), this.props.user)} + id="run-image-dialog-owner-user" + isDisabled={this.props.pod} + isChecked={owner === this.props.user} + onChange={this.handleOwnerSelect} /> + </FormGroup> + } + <FormGroup fieldId="create-image-image-select-typeahead" label={_("Image")} + labelIcon={!this.props.image && + <Popover aria-label={_("Image selection help")} + enableFlip + bodyContent={ + <Flex direction={{ default: 'column' }}> + <FlexItem>{_("host[:port]/[user]/container[:tag]")}</FlexItem> + <FlexItem>{cockpit.format(_("Example: $0"), "quay.io/libpod/busybox")}</FlexItem> + <FlexItem>{cockpit.format(_("Searching: $0"), "quay.io/busybox")}</FlexItem> + </Flex> + }> + <button onClick={e => e.preventDefault()} className="pf-v5-c-form__group-label-help"> + <OutlinedQuestionCircleIcon /> + </button> + </Popover> + } + > + <Select + // We are unable to set id of the input directly, the select component appends + // '-select-typeahead' to toggleId. + toggleId='create-image-image' + isGrouped + {...(this.state.searchInProgress && { loadingVariant: 'spinner' })} + menuAppendTo={() => document.body} + variant={SelectVariant.typeahead} + noResultsFoundText={_("No images found")} + onToggle={this.onImageSelectToggle} + isOpen={this.state.isImageSelectOpen} + selections={selectedImage} + isInputValuePersisted + placeholderText={_("Search string or container location")} + onSelect={this.onImageSelect} + onClear={this.clearImageSelection} + // onFilter must be set or the spinner crashes https://github.com/patternfly/patternfly-react/issues/6384 + onFilter={() => {}} + onTypeaheadInputChanged={this.debouncedInputChanged} + footer={footer} + isDisabled={!!this.props.image} + > + {imageListOptions} + </Select> + </FormGroup> + + {(image || localImage) && + <FormGroup fieldId="run-image-dialog-pull-latest-image"> + <Checkbox isChecked={this.state.pullLatestImage} id="run-image-dialog-pull-latest-image" + onChange={(_event, value) => this.onValueChanged('pullLatestImage', value)} label={_("Pull latest image")} + /> + </FormGroup> + } + + {dialogValues.entrypoint && + <FormGroup fieldId='run-image-dialog-entrypoint' hasNoPaddingTop label={_("Entrypoint")}> + <Text id="run-image-dialog-entrypoint">{dialogValues.entrypoint}</Text> + </FormGroup> + } + + <FormGroup fieldId='run-image-dialog-command' label={_("Command")}> + <TextInput id='run-image-dialog-command' + value={dialogValues.command || ''} + onChange={(_, value) => this.onValueChanged('command', value)} /> + </FormGroup> + + <FormGroup fieldId="run=image-dialog-tty"> + <Checkbox id="run-image-dialog-tty" + isChecked={this.state.hasTTY} + label={_("With terminal")} + onChange={(_event, checked) => this.onValueChanged('hasTTY', checked)} /> + </FormGroup> + + <FormGroup fieldId='run-image-dialog-memory' label={_("Memory limit")}> + <Flex alignItems={{ default: 'alignItemsCenter' }} className="ct-input-group-spacer-sm modal-run-limiter" id="run-image-dialog-memory-limit"> + <Checkbox id="run-image-dialog-memory-limit-checkbox" + isChecked={this.state.memoryConfigure} + onChange={(_event, checked) => this.onValueChanged('memoryConfigure', checked)} /> + <NumberInput + value={dialogValues.memory} + id="run-image-dialog-memory" + min={0} + isDisabled={!this.state.memoryConfigure} + onClick={() => !this.state.memoryConfigure && this.onValueChanged('memoryConfigure', true)} + onPlus={() => this.onPlusOne('memory')} + onMinus={() => this.onMinusOne('memory')} + minusBtnAriaLabel={_("Decrease memory")} + plusBtnAriaLabel={_("Increase memory")} + onChange={ev => this.onValueChanged('memory', parseInt(ev.target.value) < 0 ? 0 : ev.target.value)} /> + <FormSelect id='memory-unit-select' + aria-label={_("Memory unit")} + value={this.state.memoryUnit} + isDisabled={!this.state.memoryConfigure} + className="dialog-run-form-select" + onChange={(_event, value) => this.onValueChanged('memoryUnit', value)}> + <FormSelectOption value={units.KB.name} key={units.KB.name} label={_("KB")} /> + <FormSelectOption value={units.MB.name} key={units.MB.name} label={_("MB")} /> + <FormSelectOption value={units.GB.name} key={units.GB.name} label={_("GB")} /> + </FormSelect> + </Flex> + </FormGroup> + + {this.isSystem() && + <FormGroup + fieldId='run-image-cpu-priority' + label={_("CPU shares")} + labelIcon={ + <Popover aria-label={_("CPU Shares help")} + enableFlip + bodyContent={_("CPU shares determine the priority of running containers. Default priority is 1024. A higher number prioritizes this container. A lower number decreases priority.")}> + <button onClick={e => e.preventDefault()} className="pf-v5-c-form__group-label-help"> + <OutlinedQuestionCircleIcon /> + </button> + </Popover> + }> + <Flex alignItems={{ default: 'alignItemsCenter' }} className="ct-input-group-spacer-sm modal-run-limiter" id="run-image-dialog-cpu-priority"> + <Checkbox id="run-image-dialog-cpu-priority-checkbox" + isChecked={this.state.cpuSharesConfigure} + onChange={(_event, checked) => this.onValueChanged('cpuSharesConfigure', checked)} /> + <NumberInput + id="run-image-cpu-priority" + value={dialogValues.cpuShares} + onClick={() => !this.state.cpuSharesConfigure && this.onValueChanged('cpuSharesConfigure', true)} + min={2} + max={262144} + isDisabled={!this.state.cpuSharesConfigure} + onPlus={() => this.onPlusOne('cpuShares')} + onMinus={() => this.onMinusOne('cpuShares')} + minusBtnAriaLabel={_("Decrease CPU shares")} + plusBtnAriaLabel={_("Increase CPU shares")} + onChange={ev => this.onValueChanged('cpuShares', parseInt(ev.target.value) < 2 ? 2 : ev.target.value)} /> + </Flex> + </FormGroup> + } + {((userLingeringEnabled && userPodmanRestartAvailable) || (this.isSystem() && podmanRestartAvailable)) && + <Grid hasGutter md={6} sm={3}> + <GridItem> + <FormGroup fieldId='run-image-dialog-restart-policy' label={_("Restart policy")} + labelIcon={ + <Popover aria-label={_("Restart policy help")} + enableFlip + 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.")}> + <button onClick={e => e.preventDefault()} className="pf-v5-c-form__group-label-help"> + <OutlinedQuestionCircleIcon /> + </button> + </Popover> + } + > + <FormSelect id="run-image-dialog-restart-policy" + aria-label={_("Restart policy help")} + value={dialogValues.restartPolicy} + onChange={(_event, value) => this.onValueChanged('restartPolicy', value)}> + <FormSelectOption value='no' key='no' label={_("No")} /> + <FormSelectOption value='on-failure' key='on-failure' label={_("On failure")} /> + <FormSelectOption value='always' key='always' label={_("Always")} /> + </FormSelect> + </FormGroup> + </GridItem> + {dialogValues.restartPolicy === "on-failure" && + <FormGroup fieldId='run-image-dialog-restart-retries' + label={_("Maximum retries")}> + <NumberInput + id="run-image-dialog-restart-retries" + value={dialogValues.restartTries} + min={1} + max={65535} + widthChars={5} + minusBtnAriaLabel={_("Decrease maximum retries")} + plusBtnAriaLabel={_("Increase maximum retries")} + onMinus={() => this.onMinusOne('restartTries')} + onPlus={() => this.onPlusOne('restartTries')} + onChange={ev => this.onValueChanged('restartTries', parseInt(ev.target.value) < 1 ? 1 : ev.target.value)} + /> + </FormGroup> + } + </Grid> + } + </Tab> + <Tab eventKey={1} title={<TabTitleText>{_("Integration")}</TabTitleText>} id="create-image-dialog-tab-integration" className="pf-v5-c-form"> + + <DynamicListForm id='run-image-dialog-publish' + emptyStateString={_("No ports exposed")} + formclass='publish-port-form' + label={_("Port mapping")} + actionLabel={_("Add port mapping")} + validationFailed={dialogValues.validationFailed.publish} + onValidationChange={value => this.dynamicListOnValidationChange('publish', value)} + onChange={value => this.onValueChanged('publish', value)} + default={{ IP: null, containerPort: null, hostPort: null, protocol: 'tcp' }} + itemcomponent={ <PublishPort />} /> + <DynamicListForm id='run-image-dialog-volume' + emptyStateString={_("No volumes specified")} + formclass='volume-form' + label={_("Volumes")} + actionLabel={_("Add volume")} + validationFailed={dialogValues.validationFailed.volumes} + onValidationChange={value => this.dynamicListOnValidationChange('volumes', value)} + onChange={value => this.onValueChanged('volumes', value)} + default={{ containerPath: null, hostPath: null, mode: 'rw' }} + options={{ selinuxAvailable }} + itemcomponent={ <Volume />} /> + + <DynamicListForm id='run-image-dialog-env' + emptyStateString={_("No environment variables specified")} + formclass='env-form' + label={_("Environment variables")} + actionLabel={_("Add variable")} + validationFailed={dialogValues.validationFailed.env} + onValidationChange={value => this.dynamicListOnValidationChange('env', value)} + onChange={value => this.onValueChanged('env', value)} + default={{ envKey: null, envValue: null }} + helperText={_("Paste one or more lines of key=value pairs into any field for bulk import")} + itemcomponent={ <EnvVar />} /> + </Tab> + <Tab eventKey={2} title={<TabTitleText>{_("Health check")}</TabTitleText>} id="create-image-dialog-tab-healthcheck" className="pf-v5-c-form pf-m-horizontal"> + <FormGroup fieldId='run-image-dialog-healthcheck-command' label={_("Command")}> + <TextInput id='run-image-dialog-healthcheck-command' + value={dialogValues.healthcheck_command || ''} + onChange={(_, value) => this.onValueChanged('healthcheck_command', value)} /> + </FormGroup> + + <FormGroup fieldId='run-image-healthcheck-interval' label={_("Interval")} + labelIcon={ + <Popover aria-label={_("Health check interval help")} + enableFlip + bodyContent={_("Interval how often health check is run.")}> + <button onClick={e => e.preventDefault()} className="pf-v5-c-form__group-label-help"> + <OutlinedQuestionCircleIcon /> + </button> + </Popover> + }> + <InputGroup> + <NumberInput + id="run-image-healthcheck-interval" + value={dialogValues.healthcheck_interval} + min={0} + max={262144} + widthChars={6} + minusBtnAriaLabel={_("Decrease interval")} + plusBtnAriaLabel={_("Increase interval")} + onMinus={() => this.onMinusOne('healthcheck_interval')} + onPlus={() => this.onPlusOne('healthcheck_interval')} + onChange={ev => this.onValueChanged('healthcheck_interval', parseInt(ev.target.value) < 0 ? 0 : ev.target.value)} /> + <InputGroupText isPlain>{_("seconds")}</InputGroupText> + </InputGroup> + </FormGroup> + <FormGroup fieldId='run-image-healthcheck-timeout' label={_("Timeout")} + labelIcon={ + <Popover aria-label={_("Health check timeout help")} + enableFlip + bodyContent={_("The maximum time allowed to complete the health check before an interval is considered failed.")}> + <button onClick={e => e.preventDefault()} className="pf-v5-c-form__group-label-help"> + <OutlinedQuestionCircleIcon /> + </button> + </Popover> + }> + <InputGroup> + <NumberInput + id="run-image-healthcheck-timeout" + value={dialogValues.healthcheck_timeout} + min={0} + max={262144} + widthChars={6} + minusBtnAriaLabel={_("Decrease timeout")} + plusBtnAriaLabel={_("Increase timeout")} + onMinus={() => this.onMinusOne('healthcheck_timeout')} + onPlus={() => this.onPlusOne('healthcheck_timeout')} + onChange={ev => this.onValueChanged('healthcheck_timeout', parseInt(ev.target.value) < 0 ? 0 : ev.target.value)} /> + <InputGroupText isPlain>{_("seconds")}</InputGroupText> + </InputGroup> + </FormGroup> + <FormGroup fieldId='run-image-healthcheck-start-period' label={_("Start period")} + labelIcon={ + <Popover aria-label={_("Health check start period help")} + enableFlip + bodyContent={_("The initialization time needed for a container to bootstrap.")}> + <button onClick={e => e.preventDefault()} className="pf-v5-c-form__group-label-help"> + <OutlinedQuestionCircleIcon /> + </button> + </Popover> + }> + <InputGroup> + <NumberInput + id="run-image-healthcheck-start-period" + value={dialogValues.healthcheck_start_period} + min={0} + max={262144} + widthChars={6} + minusBtnAriaLabel={_("Decrease start period")} + plusBtnAriaLabel={_("Increase start period")} + onMinus={() => this.onMinusOne('healthcheck_start_period')} + onPlus={() => this.onPlusOne('healthcheck_start_period')} + onChange={ev => this.onValueChanged('healthcheck_start_period', parseInt(ev.target.value) < 0 ? 0 : ev.target.value)} /> + <InputGroupText isPlain>{_("seconds")}</InputGroupText> + </InputGroup> + </FormGroup> + <FormGroup fieldId='run-image-healthcheck-retries' label={_("Retries")} + labelIcon={ + <Popover aria-label={_("Health check retries help")} + enableFlip + bodyContent={_("The number of retries allowed before a healthcheck is considered to be unhealthy.")}> + <button onClick={e => e.preventDefault()} className="pf-v5-c-form__group-label-help"> + <OutlinedQuestionCircleIcon /> + </button> + </Popover> + }> + <NumberInput + id="run-image-healthcheck-retries" + value={dialogValues.healthcheck_retries} + min={0} + max={999} + widthChars={3} + minusBtnAriaLabel={_("Decrease retries")} + plusBtnAriaLabel={_("Increase retries")} + onMinus={() => this.onMinusOne('healthcheck_retries')} + onPlus={() => this.onPlusOne('healthcheck_retries')} + onChange={ev => this.onValueChanged('healthcheck_retries', parseInt(ev.target.value) < 0 ? 0 : ev.target.value)} /> + </FormGroup> + {version.localeCompare("4.3", undefined, { numeric: true, sensitivity: 'base' }) >= 0 && + <FormGroup isInline hasNoPaddingTop fieldId='run-image-healthcheck-action' label={_("When unhealthy") } + labelIcon={ + <Popover aria-label={_("Health failure check action help")} + enableFlip + bodyContent={_("Action to take once the container transitions to an unhealthy state.")}> + <button onClick={e => e.preventDefault()} className="pf-v5-c-form__group-label-help"> + <OutlinedQuestionCircleIcon /> + </button> + </Popover> + }> + {HealthCheckOnFailureActionOrder.map(item => + <Radio value={item.value} + key={item.value} + label={item.label} + id={`run-image-healthcheck-action-${item.value}`} + isChecked={dialogValues.healthcheck_action === item.value} + onChange={() => this.onValueChanged('healthcheck_action', item.value)} /> + )} + </FormGroup> + } + </Tab> + </Tabs> + </Form> + ); + return ( + <Modal isOpen + position="top" variant="medium" + onClose={Dialogs.close} + // TODO: still not ideal on chromium https://github.com/patternfly/patternfly-react/issues/6471 + onEscapePress={() => { + if (this.state.isImageSelectOpen) { + this.onImageSelectToggle(!this.state.isImageSelectOpen); + } else { + Dialogs.close(); + } + }} + title={this.props.pod ? cockpit.format(_("Create container in $0"), this.props.pod.Name) : _("Create container")} + footer={<> + <Button variant='primary' id="create-image-create-run-btn" onClick={() => this.onCreateClicked(true)} isDisabled={(!image && selectedImage === "") || this.isFormInvalid(dialogValues.validationFailed)}> + {_("Create and run")} + </Button> + <Button variant='secondary' id="create-image-create-btn" onClick={() => this.onCreateClicked(false)} isDisabled={(!image && selectedImage === "") || this.isFormInvalid(dialogValues.validationFailed)}> + {_("Create")} + </Button> + <Button variant='link' className='btn-cancel' onClick={Dialogs.close}> + {_("Cancel")} + </Button> + </>} + > + {defaultBody} + </Modal> + ); + } +} |