/* * Copyright (C) 2024 Red Hat, Inc. * SPDX-License-Identifier: LGPL-2.1-or-later */ import cockpit from "cockpit"; import { fmt_to_fragments as fmtToFragments } from "utils"; import React, { useContext, useEffect, useRef, useState } from "react"; import { ActionList } from "@patternfly/react-core/dist/esm/components/ActionList/index.js"; import { Alert } from "@patternfly/react-core/dist/esm/components/Alert/index.js"; import { Button } from "@patternfly/react-core/dist/esm/components/Button/index.js"; import { Content } from "@patternfly/react-core/dist/esm/components/Content/index.js"; import { HelperText, HelperTextItem } from "@patternfly/react-core/dist/esm/components/HelperText/index.js"; import { InputGroup, InputGroupItem, InputGroupText } from "@patternfly/react-core/dist/esm/components/InputGroup/index.js"; import { Modal, ModalBody, ModalFooter, ModalHeader, ModalVariant } from "@patternfly/react-core/dist/esm/components/Modal/index.js"; import { Panel } from "@patternfly/react-core/dist/esm/components/Panel/index.js"; import { Popover } from "@patternfly/react-core/dist/esm/components/Popover/index.js"; import { Slider } from "@patternfly/react-core/dist/esm/components/Slider/index.js"; import { TextInput } from "@patternfly/react-core/dist/esm/components/TextInput/index.js"; import { Tooltip } from "@patternfly/react-core/dist/esm/components/Tooltip/index.js"; import { Flex, FlexItem } from "@patternfly/react-core/dist/esm/layouts/Flex/index.js"; import { Stack } from "@patternfly/react-core/dist/esm/layouts/Stack/index.js"; import { CompressArrowsAltIcon } from "@patternfly/react-icons/dist/esm/icons/compress-arrows-alt-icon"; import { HddIcon } from "@patternfly/react-icons/dist/esm/icons/hdd-icon"; import { LockIcon } from "@patternfly/react-icons/dist/esm/icons/lock-icon"; import { OutlinedQuestionCircleIcon } from "@patternfly/react-icons/dist/esm/icons/outlined-question-circle-icon"; import { TrashIcon } from "@patternfly/react-icons/dist/esm/icons/trash-icon"; import { UndoIcon } from "@patternfly/react-icons/dist/esm/icons/undo-icon"; import { isDeviceShrinkable, removeDevice, shrinkDevice } from "../../../apis/storage_partitioning_automatic_resizable.js"; import { deviceHasEncryptionKey, getDeviceAncestors, getDeviceTypeInfo, isDeviceEncrypted, unitMultiplier } from "../../../helpers/storage.js"; import { PageContext, StorageContext } from "../../../contexts/Common.jsx"; import { useDiskFreeSpace, useOriginalDevices, useOriginalExistingSystems, useRequiredSize } from "../../../hooks/Storage.jsx"; import { ModalError } from "cockpit-components-inline-notification.jsx"; import { ListingTable } from "cockpit-components-table.jsx"; import "./ReclaimSpaceModal.scss"; const _ = cockpit.gettext; const idPrefix = "reclaim-space-modal"; export const ReclaimSpaceModal = ({ onClose, onNext }) => { const { isFormDisabled } = useContext(PageContext) ?? {}; const { diskSelection, partitioning } = useContext(StorageContext); const devices = useOriginalDevices(); const [dialogError, setDialogError] = useState(); const [onNextClicked, setOnNextClicked] = useState(false); const [unappliedActions, setUnappliedActions] = useState( Object.keys(devices).reduce((acc, device) => { acc[device] = []; return acc }, {}) ); const rows = ( diskSelection.selectedDisks .map(disk => getDeviceRow(disk, devices, 0, unappliedActions, setUnappliedActions)) .flat(Infinity) ); const onReclaim = async () => { for (const item of Object.entries(unappliedActions)) { const [device, actions] = item; // If device parent is removed we should not schedule any actions for this device if (isDeviceParentRemoved(device, devices, unappliedActions)) { continue; } for (const action of actions) { try { if (action.type === "remove") { await removeDevice({ device, deviceTree: partitioning.deviceTree.path, }); } else if (action.type === "shrink") { await shrinkDevice({ device, deviceTree: partitioning.deviceTree.path, newSize: action.value, }); } } catch (error) { if (action.type === "remove") { setDialogError({ ...error, text: cockpit.format(_("Unable to schedule deletion of $0"), device) }); } else if (action.type === "shrink") { setDialogError({ ...error, text: cockpit.format(_("Unable to schedule resizing of $0"), device) }); } return; } } } setOnNextClicked(true); }; useEffect(() => { // Call the onNextClicked only once the form is not disabled // otherwise it silently fails if (onNextClicked && !isFormDisabled) { onNext(); setOnNextClicked(false); } }, [onNextClicked, isFormDisabled, onNext]); return ( {_("Remove or resize existing filesystems to free up space for the installation.")} { _("Removing a filesystem will permanently delete all of the data it contains. Resizing a partition can free up unused space, but is not risk-free. Be sure to have backups of anything important before reclaiming space.") } } /> {dialogError && } ); }; const isDeviceRemoved = (device, unappliedActions) => ( unappliedActions[device].map(_action => _action.type).includes("remove") ); const isDeviceResized = (device, unappliedActions) => ( unappliedActions[device].map(_action => _action.type).includes("shrink") ); const isDeviceParentRemoved = (device, devices, unappliedActions) => ( getDeviceAncestors(devices, device).some(dev => isDeviceRemoved(dev, unappliedActions)) ); const getReclaimableSpaceFromAction = ({ action, devices, unappliedActions }) => { if (action === "remove") { return Object.keys(unappliedActions) .filter(device => isDeviceRemoved(device, unappliedActions) && !isDeviceParentRemoved(device, devices, unappliedActions)) .reduce((acc, device) => acc + devices[device].total.v - devices[device].free.v, 0); } if (action === "shrink") { return Object.keys(unappliedActions) .filter(device => isDeviceResized(device, unappliedActions) && !isDeviceParentRemoved(device, devices, unappliedActions)) .reduce((acc, device) => acc + unappliedActions[device].reduce((acc, action) => acc + devices[device].total.v - action.value, 0), 0); } }; const ReclaimFooter = ({ onClose, onReclaim, unappliedActions }) => { const { isFormDisabled } = useContext(PageContext) ?? {}; const devices = useOriginalDevices(); const diskFreeSpace = useDiskFreeSpace(); const requiredSize = useRequiredSize(); const selectedSpaceToDelete = getReclaimableSpaceFromAction({ action: "remove", devices, unappliedActions }); const selectedSpaceToShrink = getReclaimableSpaceFromAction({ action: "shrink", devices, unappliedActions }); const selectedSpaceToReclaim = selectedSpaceToDelete + selectedSpaceToShrink; const status = (diskFreeSpace + selectedSpaceToReclaim) < requiredSize ? "warning" : "success"; return ( {fmtToFragments( _("Available free space: $0. Installation requires: $1."), {cockpit.format_bytes(diskFreeSpace + selectedSpaceToReclaim)}, {cockpit.format_bytes(requiredSize)} )} ); }; /** * @param {object} device * @returns {Boolean} true if the device is locked (LUKS or bitlocker) **/ const isDeviceLocked = ({ device }) => { return ( device.formatData.type.v === "bitlocker" || (isDeviceEncrypted(device) && !deviceHasEncryptionKey(device)) ); }; const ExtendedPartitionType = ({ deviceName }) => ( {_("extended partition")} {deviceName} ) } headerContent={_("Extended partition")} position="top" > ); const getDeviceRow = (disk, devices, level = 0, unappliedActions, setUnappliedActions) => { const device = devices[disk]; const isDisk = device["is-disk"].v; const isPartition = device.type.v === "partition"; const typeLabel = device.attrs?.v["partition-type-name"] || ""; // Disable the remove action for partitions without children witch are not lead nodes const isExtendedPartition = device.attrs?.v.isleaf === "False" && device.children.v.length === 0; const diskDescription = ( <> {cockpit.format("$0 ($1)", device.name.v, device.description.v)} ); const classNames = [ idPrefix + "-table-row", idPrefix + "-device-level-" + level, ]; const isLastChild = device.attrs?.v.isleaf === "True"; if (isLastChild) { classNames.push(idPrefix + "-device-leaf"); } let deviceType = getDeviceTypeInfo(device); if (isDeviceLocked({ device })) { deviceType = ( {deviceType} ); } if (isExtendedPartition) { deviceType = ( ); } const size = level < 2 && !isExtendedPartition ? cockpit.format_bytes(device.total.v) : ""; const deviceActions = ( ); return [ { columns: [ { props: { colSpan: isDisk ? 2 : 1 }, title: isDisk ? diskDescription : (isPartition ? device.name.v : "") }, ...!isDisk ? [{ title: isPartition ? typeLabel : device.name.v }] : [], { title: deviceType }, { title: size }, { title: deviceActions } ], props: { className: classNames.join(" "), "data-device": device.name.v, key: disk }, }, ...device.children.v.map((child) => getDeviceRow(child, devices, level + 1, unappliedActions, setUnappliedActions)) ]; }; const getDeviceActionOfType = ({ device, type, unappliedActions }) => { return unappliedActions[device].find(action => action.type === type); }; const DeviceActions = ({ device, isExtendedPartition, level, setUnappliedActions, unappliedActions }) => { // Only show actions for disks and the first level of partitions, or not extended // This is to simplify the feature for the first iteration if (level > 1 || isExtendedPartition) { return null; } const deviceId = device["device-id"].v; const parents = device.parents.v; const parentHasRemove = parents?.some((parent) => getDeviceActionOfType({ device: parent, type: "remove", unappliedActions })); const hasBeenRemoved = parentHasRemove || getDeviceActionOfType({ device: deviceId, type: "remove", unappliedActions }); const newDeviceSize = getDeviceActionOfType({ device: deviceId, type: "shrink", unappliedActions })?.value; const hasUnappliedActions = !parentHasRemove && unappliedActions[deviceId].length > 0; const onAction = (action, value = "") => { setUnappliedActions((prevUnappliedActions) => { const _unappliedActions = { ...prevUnappliedActions }; _unappliedActions[deviceId].push({ type: action, value }); return _unappliedActions; }); }; const onUndo = () => { setUnappliedActions((prevUnappliedActions) => { const _unappliedActions = { ...prevUnappliedActions }; _unappliedActions[deviceId].pop(); return _unappliedActions; }); }; const deviceActionProps = { device, hasBeenRemoved, newDeviceSize, onAction, }; return ( {hasUnappliedActions && ( )} > {shrinkButton} ); }; const WindowsHint = () => { const devices = useOriginalDevices(); const originalExistingSystems = useOriginalExistingSystems(); const requiredSize = useRequiredSize(); const windows = originalExistingSystems.find(itm => itm["os-name"].v === "Windows"); if (windows?.devices.v.find(itm => devices[itm].formatData.type.v === "bitlocker")) { return ( {cockpit.format( _("Reboot into Windows to resize BitLocker-encrypted partitions. Make at least $0 of free space available for installation."), cockpit.format_bytes(requiredSize) )} ); } };