/* * Copyright (C) 2023 Red Hat, Inc. * SPDX-License-Identifier: LGPL-2.1-or-later */ import cockpit from "cockpit"; import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; import { Button } from "@patternfly/react-core/dist/esm/components/Button/index.js"; import { HelperText, HelperTextItem } from "@patternfly/react-core/dist/esm/components/HelperText/index.js"; import { Label } from "@patternfly/react-core/dist/esm/components/Label/index.js"; import { MenuToggle } from "@patternfly/react-core/dist/esm/components/MenuToggle/index.js"; import { Select, SelectGroup, SelectOption } from "@patternfly/react-core/dist/esm/components/Select/index.js"; import { Switch } from "@patternfly/react-core/dist/esm/components/Switch/index.js"; import { TextInput } from "@patternfly/react-core/dist/esm/components/TextInput/index.js"; import { useWizardFooter } from "@patternfly/react-core/dist/esm/components/Wizard/index.js"; import { Flex, FlexItem } from "@patternfly/react-core/dist/esm/layouts/Flex/index.js"; import { TrashIcon } from "@patternfly/react-icons/dist/esm/icons/trash-icon"; import { applyStorage, gatherRequests, setManualPartitioningRequests } from "../../../apis/storage_partitioning.js"; import { filterPartitioningRequests, getDeviceAncestors, getDeviceChildren, getLockedEncryptedDevices, getMountPointFormatConstraintError, hasDuplicateFields, isDeviceEncrypted, isDuplicateRequestField, isOnEncryptedDevice, requestsFromDbus, requestsToDbus, } from "../../../helpers/storage.js"; import { PageContext, StorageContext } from "../../../contexts/Common.jsx"; import { getNewPartitioning, useMountPointConstraints, useOriginalDevices } from "../../../hooks/Storage.jsx"; import { EmptyStatePanel } from "cockpit-components-empty-state.jsx"; import { ListingTable } from "cockpit-components-table.jsx"; import { AnacondaWizardFooter } from "../../AnacondaWizardFooter.jsx"; import { createStorageValidationNotification } from "../Common.jsx"; import "./MountPointMapping.scss"; const _ = cockpit.gettext; const SCREEN_ID = "anaconda-screen-mount-point-mapping"; /* Filter out the partitioning requests array to contain only: * - rows with required mount points * - rows with mount points already selected by the user * @param {Array} requests - partitioning requests * @param {Array} mountPointConstraints - constraints on mount points * @returns {Array} filtered requests */ const getInitialRequests = (requests, mountPointConstraints) => { const constrainedRequests = mountPointConstraints.filter(constraint => !!constraint["mount-point"].v).map(constraint => { const originalRequest = requests.find(request => request["mount-point"] === constraint["mount-point"].v); const mountPoint = constraint["mount-point"].v; // If user already set device for this mount point the Request is consider complete, respect their choices if (originalRequest?.["device-spec"]) { return originalRequest; } const request = ({ "mount-point": mountPoint, reformat: mountPoint === "/" }); if (originalRequest) { return { ...originalRequest, ...request }; } return request; }); const extraRequests = requests.filter(r => ( r["mount-point"] && !mountPointConstraints.find(m => m["mount-point"].v === r["mount-point"]) )) || []; return [...constrainedRequests, ...extraRequests]; }; /* Check validity of the requests array * @param {Array} requests - partitioning requests * @deviceData {Object} deviceData - device data * @returns {boolean} */ const getRequestsValid = (requests, deviceData) => { const checkValidRequest = r => { return ( r["mount-point"] && r["device-spec"] && !isReformatInvalid(deviceData, r, requests)[0] ); }; /* When requests change check for duplicate mount point or device assignments and update form validity */ const isFormValid = ( !hasDuplicateFields(requests, "mount-point") && !hasDuplicateFields(requests, "device-spec") && requests.every(checkValidRequest) ); return isFormValid; }; const isReformatInvalid = (deviceData, request, requests) => { const device = request["device-spec"]; if (!device || !request.reformat) { return [false, ""]; } if (!deviceData[device].formatData.formattable.v) { return [true, cockpit.format(_("Selected device's format '$0' cannot be reformatted."), deviceData[device].formatData.type.v)]; } const children = getDeviceChildren({ device, deviceData }); /* When parent device is re-formatted all children must: * - either exist in the mount points mapper table and be re-formatted * - or not exist in the mountpoints mapper table */ const isChildReformatValid = children.every(child => { const childRequest = requests.find(r => r["device-spec"] === child); return !childRequest || childRequest.reformat === true; }); if (!isChildReformatValid) { return [true, _("Mismatch between parent device and child device reformat selection.")]; } else { return [false, ""]; } }; /* Build the backend-requests object from the unapplied requests. * @param {Array.} requests An array of request objects from back-end * @param {Array.} newRequests An array of request objects from front-end * @param string partitioning DBus path to a partitioning * @returns {Promise} */ const updatePartitioningRequests = ({ newRequests, partitioning, requests }) => { const backendRequests = [...requests]; backendRequests.forEach((backendRequest, backendRequestIndex) => { const newRequestIndex = newRequests.findIndex(r => r["device-spec"] === backendRequest["device-spec"]); if (newRequestIndex === -1) { // When a 'device' is not selected in the front-end set the mount-point to empty string backendRequests[backendRequestIndex]["mount-point"] = ""; } else if (newRequests[newRequestIndex]?.["device-spec"]) { // Otherwise sync the object from the front-end to back-end backendRequests[backendRequestIndex] = newRequests[newRequestIndex]; } }); return setManualPartitioningRequests({ partitioning, requests: requestsToDbus(backendRequests), }); }; const isDeviceMountPointInvalid = (deviceData, mountPointConstraints, request) => { const device = request["device-spec"]; const constrainedMountPointData = mountPointConstraints.find(val => val["mount-point"].v === request["mount-point"]); if (!device || !request["mount-point"] || !constrainedMountPointData) { return [false, ""]; } const [formatInvalid, formatError] = getMountPointFormatConstraintError({ constraint: constrainedMountPointData, device, devices: deviceData, mountPoint: request["mount-point"], }); if (formatInvalid) { return [true, formatError]; } if (constrainedMountPointData && !constrainedMountPointData["encryption-allowed"].v && isOnEncryptedDevice(deviceData, device)) { return [true, cockpit.format(_("'$0' filesystem cannot be on an encrypted block device"), request["mount-point"])]; } return [false, ""]; }; const MountPointColumn = ({ handleRequestChange, idPrefix, isRecommendedMountPoint, isRequiredMountPoint, request, requestIndex, requests }) => { const mountpoint = request["mount-point"] || ""; const [mountPointText, setMountPointText] = useState(mountpoint); const duplicatedMountPoint = isDuplicateRequestField(requests, "mount-point", mountpoint); const swapMountpoint = mountpoint === "swap"; useEffect(() => { setMountPointText(request["mount-point"] || ""); }, [request]); return ( {((isRequiredMountPoint || isRecommendedMountPoint) && !duplicatedMountPoint) || swapMountpoint ? ( {mountpoint || request["format-type"]} ) : handleRequestChange({ deviceSpec: request["device-spec"], mountPoint: mountPointText, requestIndex })} onChange={(_event, val) => setMountPointText(val)} value={mountPointText} />} {isRequiredMountPoint && } {!isRequiredMountPoint && isRecommendedMountPoint && } {!isRequiredMountPoint && !isRecommendedMountPoint && } {mountpoint && duplicatedMountPoint && {_("Duplicate mount point.")} } ); }; export const DeviceColumnSelect = ({ deviceData, devices, handleRequestChange, idPrefix, isRequiredMountPoint, lockedEncryptedDevices, request, requestIndex }) => { const [isOpen, setIsOpen] = useState(false); const device = request["device-spec"]; const optionGroups = {}; for (const device of devices) { const deviceName = deviceData[device].name.v; const ancestors = getDeviceAncestors(deviceData, device); const parentDisk = [device, ...ancestors].find(ancestor => deviceData[ancestor]["is-disk"].v); const parentPartition = [device, ...ancestors].find(ancestor => deviceData[ancestor].type.v === "partition"); const typeLabel = device[parentPartition]?.attrs?.v?.["partition-type-name"] || ""; const formatType = deviceData[device]?.formatData.type.v; const format = deviceData[device]?.formatData.description.v; const size = cockpit.format_bytes(deviceData[device]?.total.v); const description = ( typeLabel && typeLabel !== deviceName ? cockpit.format("$0 $1, used by $2", size, format, typeLabel) : cockpit.format("$0 $1", size, format) ); const isLockedEncryptedDevice = lockedEncryptedDevices.some(p => device.includes(p)); /* Disable the following devices: * - Locked LUKS devices * - Swap devices when the mount point is preset (required) as these reset it */ const isAriaDisabled = isLockedEncryptedDevice || (formatType === "swap" && isRequiredMountPoint); const node = ( {deviceName} ); if (optionGroups[parentDisk]) { optionGroups[parentDisk].push(node); } else { optionGroups[parentDisk] = [node]; } } const getToggleText = () => { if (!device) return cockpit.gettext("Select a device"); const name = deviceData[device]?.name.v; const size = cockpit.format_bytes(deviceData[device]?.total.v); const isAmbiguous = Object.values(deviceData).filter(d => d.name.v === name).length > 1; return isAmbiguous ? cockpit.format("$0 ($1)", name, size) : name; }; return ( ); }; const DeviceColumn = ({ deviceData, devices, handleRequestChange, idPrefix, isRequiredMountPoint, lockedEncryptedDevices, mountPointConstraints, request, requestIndex, requests }) => { const device = request["device-spec"]; const duplicatedDevice = isDuplicateRequestField(requests, "device-spec", device); const [deviceInvalid, errorMessage] = isDeviceMountPointInvalid(deviceData, mountPointConstraints, request); return ( {device && duplicatedDevice && {_("Duplicate device.")} } {deviceInvalid && {errorMessage} } ); }; const FormatColumn = ({ deviceData, handleRequestChange, idPrefix, request, requestIndex, requests }) => { const [reformatInvalid, reformatErrorMsg] = isReformatInvalid(deviceData, request, requests); const FormatSwitch = () => { return ( handleRequestChange({ deviceSpec: request["device-spec"], mountPoint: request["mount-point"], reformat: checked, requestIndex })} /> ); }; return ( {reformatInvalid && {reformatErrorMsg} } ); }; const MountPointRowRemove = ({ handleRequestChange, requestIndex }) => { const handleRemove = () => { // remove row from requests and update requests with higher ID handleRequestChange({ remove: true, requestIndex }); }; return ( ); }; const isUsableDevice = (devSpec, deviceData) => { const device = deviceData[devSpec]; if (device === undefined || device.formatData === undefined) { return false; } // encrypted devices are allowed -- we need to be able to unlock it if (isDeviceEncrypted(device)) { return true; } // only swap and mountable filesystems should be shown in the mount point assignment if (device.formatData.type.v === "swap" || device.formatData.mountable.v === true) { return true; } return false; }; export const usePartitioningReuse = () => { const { setIsFormDisabled } = useContext(PageContext) ?? {}; const { partitioning } = useContext(StorageContext); const previousRequestsRef = useRef(); const mergedRequestsRef = useRef(); useEffect(() => { const _updateInitialRequests = async () => { if (previousRequestsRef.current !== undefined) { return; } // Store previous requests before creating new partitioning previousRequestsRef.current = partitioning?.requests || []; const part = await getNewPartitioning({ method: "MANUAL", storageScenarioId: "mount-point-mapping" }); const newRequestsDbus = await gatherRequests({ partitioning: part }); const newRequests = requestsFromDbus(newRequestsDbus); // Merge old requests into new requests by device-spec const _mergedRequests = newRequests.map(newReq => { if (!newReq["device-spec"]) { return newReq; } const oldReq = previousRequestsRef.current.find(old => old["device-spec"] === newReq["device-spec"]); return oldReq || newReq; }); mergedRequestsRef.current = _mergedRequests; // Set merged requests on the new partitioning (context will auto-update) await setManualPartitioningRequests({ partitioning: part, requests: requestsToDbus(_mergedRequests), }); }; _updateInitialRequests(); }, [partitioning?.requests]); // Wait for partitioning.requests to be updated with merged requests useEffect(() => { if (!partitioning?.requests || !mergedRequestsRef.current) { return; } setIsFormDisabled(false); }, [partitioning?.requests, setIsFormDisabled]); }; export const MountPointMapping = () => { // Display custom footer const getFooter = useMemo(() => , []); useWizardFooter(getFooter); return ( ); }; const CustomFooter = () => { const { setIsFormDisabled, setStepNotification } = useContext(PageContext) ?? {}; const { partitioning } = useContext(StorageContext); const devices = useOriginalDevices(); const step = SCREEN_ID; const [partitioningApplied, setPartitioningApplied] = useState(false); const onNext = async ({ goToNextStep }) => { if (partitioningApplied) { setPartitioningApplied(false); setStepNotification(); goToNextStep(); setIsFormDisabled(false); return; } const partitioningPath = partitioning.path; setIsFormDisabled(true); try { // Before applying storage, filter requests from partitioning.requests // partitioning.requests are in plain format, but filterPartitioningRequests handles both const filteredRequests = filterPartitioningRequests(partitioning.requests || []); // Convert to DBus format before sending to backend await setManualPartitioningRequests({ partitioning: partitioningPath, requests: requestsToDbus(filteredRequests), }); const { validationReport } = await applyStorage({ devices, partitioning: partitioningPath }); const notification = createStorageValidationNotification(validationReport, step); setStepNotification(notification); setPartitioningApplied(notification?.variant === "warning"); if (!notification) { goToNextStep(); } } catch (ex) { setPartitioningApplied(false); setStepNotification({ message: ex.message || String(ex), step }); } finally { setIsFormDisabled(false); } }; return ; };