Skip to content

Commit dd89f1e

Browse files
committed
Add option to clone containers
Adds 'Clone' button to container actions which opens prefilled create container modal.
1 parent 36e55a6 commit dd89f1e

File tree

5 files changed

+373
-20
lines changed

5 files changed

+373
-20
lines changed

src/Containers.jsx

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ import PruneUnusedContainersModal from './PruneUnusedContainersModal.jsx';
4141

4242
const _ = cockpit.gettext;
4343

44-
const ContainerActions = ({ container, healthcheck, onAddNotification, version, localImages, updateContainerAfterEvent }) => {
44+
const ContainerActions = ({ container, containerDetail, healthcheck, onAddNotification, version, localImages, updateContainerAfterEvent, copyContainer }) => {
4545
const Dialogs = useDialogs();
4646
const [isActionsKebabOpen, setActionsKebabOpen] = useState(false);
4747
const isRunning = container.State == "running";
@@ -256,6 +256,15 @@ const ContainerActions = ({ container, healthcheck, onAddNotification, version,
256256
}
257257
}
258258

259+
if (container && containerDetail && localImages) {
260+
actions.push(
261+
<DropdownItem key="clone-container"
262+
onClick={() => copyContainer(container, containerDetail, localImages)}>
263+
{_("Clone")}
264+
</DropdownItem>
265+
);
266+
}
267+
259268
actions.push(<DropdownSeparator key="separator-1" />);
260269
actions.push(
261270
<DropdownItem key="commit"
@@ -361,6 +370,8 @@ class Containers extends React.Component {
361370
onDownloadContainer = onDownloadContainer.bind(this);
362371
onDownloadContainerFinished = onDownloadContainerFinished.bind(this);
363372

373+
this.copyContainer = this.copyContainer.bind(this);
374+
364375
window.addEventListener('resize', this.onWindowResize);
365376
}
366377

@@ -429,7 +440,11 @@ class Containers extends React.Component {
429440
];
430441

431442
if (!container.isDownloading) {
432-
columns.push({ title: <ContainerActions version={this.props.version} container={container} healthcheck={healthcheck} onAddNotification={this.props.onAddNotification} localImages={localImages} updateContainerAfterEvent={this.props.updateContainerAfterEvent} />, props: { className: "pf-c-table__action" } });
443+
columns.push({
444+
title: <ContainerActions version={this.props.version} container={container} containerDetail={containerDetail} healthcheck={healthcheck} onAddNotification={this.props.onAddNotification}
445+
localImages={localImages} updateContainerAfterEvent={this.props.updateContainerAfterEvent} copyContainer={this.copyContainer} />,
446+
props: { className: "pf-c-table__action" }
447+
});
433448
}
434449

435450
const tty = containerDetail ? !!containerDetail.Config.Tty : undefined;
@@ -575,6 +590,23 @@ class Containers extends React.Component {
575590
this.setState({ showPruneUnusedContainersModal: true });
576591
};
577592

593+
copyContainer(container, containerDetail, localImages) {
594+
this.context.show(<ImageRunModal user={this.props.user}
595+
localImages={localImages}
596+
pod={this.props.pods[container.Pod + container.isSystem]}
597+
registries={this.props.registries}
598+
selinuxAvailable={this.props.selinuxAvailable}
599+
podmanRestartAvailable={this.props.podmanRestartAvailable}
600+
userServiceAvailable={this.props.userServiceAvailable}
601+
systemServiceAvailable={this.props.systemServiceAvailable}
602+
onAddNotification={this.props.onAddNotification}
603+
version={this.props.version}
604+
container={container}
605+
containerDetail={containerDetail}
606+
prefill
607+
/>);
608+
}
609+
578610
render() {
579611
const Dialogs = this.context;
580612
const columnTitles = [

src/DynamicListForm.jsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ export class DynamicListForm extends React.Component {
1111
constructor(props) {
1212
super(props);
1313
this.state = {
14-
list: [],
14+
list: this.props.prefill ?? [],
1515
};
1616
this.keyCounter = 0;
1717
this.removeItem = this.removeItem.bind(this);

src/ImageRunModal.jsx

Lines changed: 121 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import { Popover } from "@patternfly/react-core/dist/esm/components/Popover";
1818
import { MinusIcon, OutlinedQuestionCircleIcon } from '@patternfly/react-icons';
1919
import * as dockerNames from 'docker-names';
2020

21-
import { ErrorNotification } from './Notification.jsx';
21+
import { ErrorNotification, WarningNotification } from './Notification.jsx';
2222
import * as utils from './util.js';
2323
import * as client from './client.js';
2424
import rest from './rest.js';
@@ -54,10 +54,10 @@ const units = {
5454

5555
// healthchecks.go HealthCheckOnFailureAction
5656
const HealthCheckOnFailureActionOrder = [
57-
{ value: 0, label: _("No action") },
58-
{ value: 3, label: _("Restart") },
59-
{ value: 4, label: _("Stop") },
60-
{ value: 2, label: _("Force stop") },
57+
{ value: 0, label: _("No action"), apiName: "none" },
58+
{ value: 3, label: _("Restart"), apiName: "restart" },
59+
{ value: 4, label: _("Stop"), apiName: "stop" },
60+
{ value: 2, label: _("Force stop"), apiName: "kill" },
6161
];
6262

6363
const handleEnvValue = (key, value, idx, onChange, additem, itemCount, companionField) => {
@@ -175,6 +175,9 @@ export class ImageRunModal extends React.Component {
175175
componentDidMount() {
176176
this._isMounted = true;
177177
this.onSearchTriggered(this.state.searchText);
178+
179+
if (this.props.prefill)
180+
this.prefillModal();
178181
}
179182

180183
componentWillUnmount() {
@@ -635,6 +638,75 @@ export class ImageRunModal extends React.Component {
635638
return owner === systemOwner;
636639
};
637640

641+
prefillModal() {
642+
const container = this.props.container;
643+
const containerDetail = this.props.containerDetail;
644+
const image = this.props.localImages.find(img => img.Id === container.ImageID);
645+
const owner = container.isSystem ? 'system' : this.props.user;
646+
647+
if (containerDetail.Config.CreateCommand) {
648+
this.setState({
649+
dialogWarning: _("This container was not created by cockpit"),
650+
dialogWarningDetail: _("Some options may not be copied to the new container."),
651+
});
652+
}
653+
654+
const env = containerDetail.Config.Env.filter(variable => {
655+
if (image.Env.includes(variable)) {
656+
return false;
657+
}
658+
659+
return !variable.match(/((HOME|TERM|HOSTNAME)=.*)|container=podman/);
660+
}).map((variable, index) => {
661+
const split = variable.split('=');
662+
return { key: index, envKey: split[0], envValue: split[1] };
663+
});
664+
665+
const publish = container.Ports
666+
? container.Ports.map((port, index) => {
667+
return { key: index, IP: port.hostIP || port.host_ip, containerPort: port.containerPort || port.container_port, hostPort: port.hostPort || port.host_port, protocol: port.protocol };
668+
})
669+
: [];
670+
671+
const volumes = containerDetail.Mounts.map((mount, index) => {
672+
// podman does not expose SELinux labels
673+
return { key: index, containerPath: mount.Destination, hostPath: mount.Source, mode: (mount.RW ? 'rw' : 'ro'), selinux: '' };
674+
});
675+
676+
// check if memory and cpu limitations or healthcheck are used
677+
const memoryConfigure = containerDetail.HostConfig.Memory > 0;
678+
const cpuSharesConfigure = containerDetail.HostConfig.CpuShares > 0;
679+
const healthcheck = !!containerDetail.Config.Healthcheck;
680+
const healthCheckOnFailureAction = (this.props.version.split(".")) >= [4, 3, 0]
681+
? HealthCheckOnFailureActionOrder.find(item => item.apiName === containerDetail.Config.HealthcheckOnFailureAction).value
682+
: null;
683+
684+
this.setState({
685+
command: container.Command ? container.Command.join(' ') : "",
686+
containerName: container.Names[0] + "_copy",
687+
env,
688+
hasTTY: containerDetail.Config.Tty,
689+
publish,
690+
// memory in MB
691+
memory: memoryConfigure ? (containerDetail.HostConfig.Memory / 1000000) : 512,
692+
cpuShares: cpuSharesConfigure ? containerDetail.HostConfig.CpuShares : 1024,
693+
memoryConfigure,
694+
cpuSharesConfigure,
695+
volumes,
696+
owner,
697+
// unless-stopped: Identical to always
698+
restartPolicy: containerDetail.HostConfig.RestartPolicy.Name === 'unless-stopped' ? 'always' : containerDetail.HostConfig.RestartPolicy.Name,
699+
selectedImage: image,
700+
healthcheck_command: healthcheck ? containerDetail.Config.Healthcheck.Test.join(' ') : "",
701+
// convert to seconds
702+
healthcheck_interval: healthcheck ? (containerDetail.Config.Healthcheck.Interval / 1000000000) : 30,
703+
healthcheck_timeout: healthcheck ? (containerDetail.Config.Healthcheck.Timeout / 1000000000) : 30,
704+
healthcheck_start_period: healthcheck ? (containerDetail.Config.Healthcheck.StartPeriod / 1000000000) : 0,
705+
healthcheck_retries: healthcheck ? containerDetail.Config.Healthcheck.Retries : 3,
706+
healthcheck_action: healthcheck ? healthCheckOnFailureAction : 0,
707+
});
708+
}
709+
638710
render() {
639711
const Dialogs = this.context;
640712
const { image } = this.props;
@@ -688,6 +760,7 @@ export class ImageRunModal extends React.Component {
688760

689761
const defaultBody = (
690762
<Form>
763+
{this.state.dialogWarning && <WarningNotification warningMessage={this.state.dialogWarning} warningDetail={this.state.dialogWarningDetail} />}
691764
{this.state.dialogError && <ErrorNotification errorMessage={this.state.dialogError} errorDetail={this.state.dialogErrorDetail} />}
692765
<FormGroup fieldId='run-image-dialog-name' label={_("Name")} className="ct-m-horizontal">
693766
<TextInput id='run-image-dialog-name'
@@ -938,6 +1011,7 @@ export class ImageRunModal extends React.Component {
9381011
actionLabel={_("Add port mapping")}
9391012
onChange={value => this.onValueChanged('publish', value)}
9401013
default={{ IP: null, containerPort: null, hostPort: null, protocol: 'tcp' }}
1014+
prefill={this.state.publish}
9411015
itemcomponent={ <PublishPort />} />
9421016

9431017
<DynamicListForm id='run-image-dialog-volume'
@@ -948,6 +1022,7 @@ export class ImageRunModal extends React.Component {
9481022
onChange={value => this.onValueChanged('volumes', value)}
9491023
default={{ containerPath: null, hostPath: null, mode: 'rw' }}
9501024
options={{ selinuxAvailable: this.props.selinuxAvailable }}
1025+
prefill={this.state.volumes}
9511026
itemcomponent={ <Volume />} />
9521027

9531028
<DynamicListForm id='run-image-dialog-env'
@@ -958,6 +1033,7 @@ export class ImageRunModal extends React.Component {
9581033
onChange={value => this.onValueChanged('env', value)}
9591034
default={{ envKey: null, envValue: null }}
9601035
helperText={_("Paste one or more lines of key=value pairs into any field for bulk import")}
1036+
prefill={this.state.env}
9611037
itemcomponent={ <EnvVar />} />
9621038
</Tab>
9631039
<Tab eventKey={2} title={<TabTitleText>{_("Health check")}</TabTitleText>} id="create-image-dialog-tab-healthcheck" className="pf-c-form pf-m-horizontal">
@@ -1089,6 +1165,44 @@ export class ImageRunModal extends React.Component {
10891165
</Tabs>
10901166
</Form>
10911167
);
1168+
1169+
const cardFooter = () => {
1170+
let createRunText = _("Create and run");
1171+
let createText = _("Create");
1172+
1173+
if (this.props.prefill) {
1174+
createRunText = _("Clone and run");
1175+
createText = _("Clone");
1176+
}
1177+
1178+
return (
1179+
<>
1180+
<Button variant='primary' id="create-image-create-run-btn" onClick={() => this.onCreateClicked(true)} isDisabled={(!image && selectedImage === "")}>
1181+
{createRunText}
1182+
</Button>
1183+
<Button variant='secondary' id="create-image-create-btn" onClick={() => this.onCreateClicked(false)} isDisabled={(!image && selectedImage === "")}>
1184+
{createText}
1185+
</Button>
1186+
<Button variant='link' className='btn-cancel' onClick={Dialogs.close}>
1187+
{_("Cancel")}
1188+
</Button>
1189+
</>
1190+
);
1191+
};
1192+
1193+
const modalTitle = () => {
1194+
let titleText = _("Create container");
1195+
1196+
if (this.props.prefill && this.props.pod)
1197+
titleText = _("Clone container in $0");
1198+
else if (this.props.prefill)
1199+
titleText = _("Clone container");
1200+
else if (this.props.pod)
1201+
titleText = _("Create container in $0");
1202+
1203+
return this.props.pod ? cockpit.format(titleText, this.props.pod.Name) : titleText;
1204+
};
1205+
10921206
return (
10931207
<Modal isOpen
10941208
position="top" variant="medium"
@@ -1101,18 +1215,8 @@ export class ImageRunModal extends React.Component {
11011215
Dialogs.close();
11021216
}
11031217
}}
1104-
title={this.props.pod ? cockpit.format(_("Create container in $0"), this.props.pod.Name) : _("Create container")}
1105-
footer={<>
1106-
<Button variant='primary' id="create-image-create-run-btn" onClick={() => this.onCreateClicked(true)} isDisabled={!image && selectedImage === ""}>
1107-
{_("Create and run")}
1108-
</Button>
1109-
<Button variant='secondary' id="create-image-create-btn" onClick={() => this.onCreateClicked(false)} isDisabled={!image && selectedImage === ""}>
1110-
{_("Create")}
1111-
</Button>
1112-
<Button variant='link' className='btn-cancel' onClick={Dialogs.close}>
1113-
{_("Cancel")}
1114-
</Button>
1115-
</>}
1218+
title={modalTitle()}
1219+
footer={cardFooter()}
11161220
>
11171221
{defaultBody}
11181222
</Modal>

src/Notification.jsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,11 @@ export const ErrorNotification = ({ errorMessage, errorDetail, onDismiss }) => {
4343
</Alert>
4444
);
4545
};
46+
47+
export const WarningNotification = ({ warningMessage, warningDetail }) => {
48+
return (
49+
<Alert isInline variant='warning' title={warningMessage}>
50+
{ warningDetail && <p> {_("Warning message")}: <samp>{warningDetail}</samp> </p> }
51+
</Alert>
52+
);
53+
};

0 commit comments

Comments
 (0)