diff --git a/src/components/vm/confirmDialog.jsx b/src/components/vm/confirmDialog.jsx new file mode 100644 index 000000000..f6cfad803 --- /dev/null +++ b/src/components/vm/confirmDialog.jsx @@ -0,0 +1,76 @@ +/* + * This file is part of Cockpit. + * + * Copyright (C) 2023 Red Hat, Inc. + * + * Cockpit is free software; you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation; either version 2.1 of the License, or + * (at your option) any later version. + * + * Cockpit is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Cockpit; If not, see . + */ +import cockpit from 'cockpit'; +import React, { useEffect, useState } from 'react'; +import { Button } from "@patternfly/react-core/dist/esm/components/Button"; +import { DescriptionList, DescriptionListDescription, DescriptionListGroup, DescriptionListTerm } from "@patternfly/react-core/dist/esm/components/DescriptionList"; +import { Modal } from "@patternfly/react-core/dist/esm/components/Modal"; +import { useDialogs } from 'dialogs.jsx'; +import { distanceToNow } from 'timeformat.js'; + +import { domainGetStartTime } from '../../libvirtApi/domain.js'; + +const _ = cockpit.gettext; + +export const ConfirmDialog = ({ idPrefix, actionsList, title, titleIcon, vm }) => { + const Dialogs = useDialogs(); + const [uptime, setUptime] = useState(); + + useEffect(() => { + return domainGetStartTime({ connectionName: vm.connectionName, vmName: vm.name }) + .then(res => setUptime(res)) + .catch(e => console.error(JSON.stringify(e))); + }, [vm]); + + const actions = actionsList.map(action => + + ); + actions.push( + + ); + + return ( + + {uptime && + + + {_("Uptime")} + {distanceToNow(new Date(uptime))} + + } + + ); +}; diff --git a/src/components/vm/vmActions.jsx b/src/components/vm/vmActions.jsx index f1414161a..e48970e0b 100644 --- a/src/components/vm/vmActions.jsx +++ b/src/components/vm/vmActions.jsx @@ -22,7 +22,9 @@ import PropTypes from 'prop-types'; import { Button } from "@patternfly/react-core/dist/esm/components/Button"; import { Dropdown, DropdownItem, DropdownSeparator, KebabToggle } from "@patternfly/react-core/dist/esm/deprecated/components/Dropdown"; import { Tooltip } from "@patternfly/react-core/dist/esm/components/Tooltip"; +import { PowerOffIcon, RedoIcon } from '@patternfly/react-icons'; import { useDialogs } from 'dialogs.jsx'; +import { fmt_to_fragments } from 'utils.jsx'; import { updateVm } from '../../actions/store-actions.js'; import { @@ -30,6 +32,7 @@ import { } from "../../helpers.js"; import { CloneDialog } from './vmCloneDialog.jsx'; +import { ConfirmDialog } from './confirmDialog.jsx'; import { DeleteDialog } from "./deleteDialog.jsx"; import { MigrateDialog } from './vmMigrateDialog.jsx'; import { RenameDialog } from './vmRenameDialog.jsx'; @@ -227,21 +230,72 @@ const VmActions = ({ vm, onAddErrorNotification, isDetailsPage }) => { variant={isDetailsPage ? 'primary' : 'secondary'} isLoading={operationInProgress} isDisabled={operationInProgress} - onClick={() => { setOperationInProgress(true); onShutdown(vm, setOperationInProgress) }} id={`${id}-shutdown-button`}> + id={`${id}-shutdown-button`} + onClick={() => Dialogs.show( + {vm.name})} + titleIcon={PowerOffIcon} + vm={vm} + actionsList={[ + { + variant: "primary", + handler: () => { + setOperationInProgress(true); + onShutdown(vm, setOperationInProgress); + }, + name: _("Shut down"), + id: "off", + }, + { + variant: "secondary", + handler: () => { + onReboot(vm); + }, + name: _("Reboot"), + id: "reboot", + }, + ]} /> + )}> {_("Shut down")} ); dropdownItems.push( onShutdown(vm)}> + onClick={() => Dialogs.show( + {vm.name})} + titleIcon={PowerOffIcon} + vm={vm} + actionsList={[ + { + variant: "primary", + handler: () => onShutdown(vm), + name: _("Shut down"), + id: "off", + }, + ]} /> + )}> {_("Shut down")} ); dropdownItems.push( onForceoff(vm)}> + onClick={() => Dialogs.show( + {vm.name})} + titleIcon={PowerOffIcon} + vm={vm} + actionsList={[ + { + variant: "primary", + handler: () => onForceoff(vm), + name: _("Force shut down"), + id: "forceOff", + }, + ]} /> + )}> {_("Force shut down")} ); @@ -249,7 +303,19 @@ const VmActions = ({ vm, onAddErrorNotification, isDetailsPage }) => { dropdownItems.push( onSendNMI(vm)}> + onClick={() => Dialogs.show( + {vm.name})} + vm={vm} + actionsList={[ + { + variant: "primary", + handler: () => onSendNMI(vm), + name: _("Send non-maskable interrupt"), + id: "sendNMI", + }, + ]} /> + )}> {_("Send non-maskable interrupt")} ); @@ -260,14 +326,40 @@ const VmActions = ({ vm, onAddErrorNotification, isDetailsPage }) => { dropdownItems.push( onReboot(vm)}> + onClick={() => Dialogs.show( + {vm.name})} + titleIcon={RedoIcon} + vm={vm} + actionsList={[ + { + variant: "primary", + handler: onReboot(vm), + name: _("Reboot"), + id: "reboot", + }, + ]} /> + )}> {_("Reboot")} ); dropdownItems.push( onForceReboot(vm)}> + onClick={() => Dialogs.show( + {vm.name})} + vm={vm} + titleIcon={RedoIcon} + actionsList={[ + { + variant: "primary", + handler: onForceReboot(vm), + name: _("Force reboot"), + id: "forceReboot", + }, + ]} /> + )}> {_("Force reboot")} ); diff --git a/test/check-machines-lifecycle b/test/check-machines-lifecycle index 6002e0ed1..a754dd224 100755 --- a/test/check-machines-lifecycle +++ b/test/check-machines-lifecycle @@ -183,6 +183,8 @@ class TestMachinesLifecycle(VirtualMachinesCase): # the shut off button beside the VM name self.waitCirrOSBooted(args['logfile']) b.click("#vm-subVmTest1-system-shutdown-button") + b.wait_visible("#vm-subVmTest1-system-confirm-action-modal") + b.click(".pf-c-modal-box__footer #vm-subVmTest1-system-off") b.wait_in_text("#vm-subVmTest1-system-state", "Shut off") b.wait_visible("#vm-subVmTest1-system-run") @@ -211,7 +213,7 @@ class TestMachinesLifecycle(VirtualMachinesCase): m.execute("virsh define --file /tmp/subVmTest1.xml") # start another one, should appear automatically - self.createVm("subVmTest2") + args2 = self.createVm("subVmTest2") b.wait_in_text("#vm-subVmTest2-system-state", "Running") self.goToVmPage("subVmTest2") b.wait_in_text("#vm-subVmTest2-cpu", "1 vCPU") @@ -231,6 +233,23 @@ class TestMachinesLifecycle(VirtualMachinesCase): b.click("#vm-subVmTest2-system-run") + # reboot through shutdown dialog + self.machine.execute(f"echo '' > {args2['logfile']}") + b.click("#vm-subVmTest2-system-shutdown-button") + b.wait_visible("#vm-subVmTest2-system-confirm-action-modal") + b.click(".pf-c-modal-box__footer button:contains(Reboot)") + b.wait_not_present("#vm-subVmTest2-system-confirm-action-modal") + b.wait_in_text("#vm-subVmTest2-system-state", "Running") + self.waitCirrOSBooted(args2['logfile']) + + # Check uptime + b.click("#vm-subVmTest2-system-shutdown-button") + b.wait_visible("#vm-subVmTest2-system-confirm-action-modal") + if run_pixel_tests: + b.assert_pixels("#vm-subVmTest2-system-confirm-action-modal", "shutdown-confirm-dialog") + b.click(".pf-c-modal-box__footer button:contains(Cancel)") + b.wait_not_present("#vm-subVmTest2-system-confirm-action-modal") + b.set_input_text("#text-search", "subVmTest2") self.waitVmRow("subVmTest2") self.waitVmRow("subVmTest1", "system", False) @@ -472,8 +491,10 @@ class TestMachinesLifecycle(VirtualMachinesCase): m.execute(f"virsh undefine {name}") b.wait_visible(f"tr[data-row-id=vm-{name}-system][data-vm-transient=true]") b.click(f"#vm-{name}-system-action-kebab button") - b.wait_visible(f"#vm-{name}-system-delete a.pf-m-disabled") + b.wait_visible(f"#vm-{name}-system-delete a.pf-m-disabled") # delete buton should be disabled b.click(f"#vm-{name}-system-forceOff") + b.wait_visible(f"#vm-{name}-system-confirm-action-modal") + b.click(f".pf-c-modal-box__footer #vm-{name}-system-forceOff") self.waitVmRow(name, 'system', False) b.wait_not_present(f'#vm-{name}-system-state button:contains("view more")') diff --git a/test/machineslib.py b/test/machineslib.py index 70f7452e4..2d4a1910f 100644 --- a/test/machineslib.py +++ b/test/machineslib.py @@ -39,6 +39,12 @@ def performAction(self, vmName, action, checkExpectedState=True, connectionName= b.wait_visible("#vm-{0}-{1}-action-kebab > .pf-c-dropdown__menu".format(vmName, connectionName)) b.click("#vm-{0}-{1}-{2} a".format(vmName, connectionName, action)) + # Some actions, which can cause expensive downtime when clicked accidentally, have confirmation dialog + if action in ["off", "forceOff", "reboot", "forceReboot", "sendNMI"]: + b.wait_visible("#vm-{0}-{1}-confirm-action-modal".format(vmName, connectionName)) + b.click(".pf-c-modal-box__footer #vm-{0}-{1}-{2}".format(vmName, connectionName, action)) + b.wait_not_present("#vm-{0}-{1}-confirm-action-modal".format(vmName, connectionName)) + if not checkExpectedState: return diff --git a/test/reference b/test/reference index d5f8e3e1d..1e327c7d1 160000 --- a/test/reference +++ b/test/reference @@ -1 +1 @@ -Subproject commit d5f8e3e1d5c30cfb5b09c92bf9a59e032b995053 +Subproject commit 1e327c7d1aca5ccacf77ff5d9a577a8f90ebe684