* 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";
* - 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 (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];
};
* @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]
);
};
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 });
* - 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, ""];
}
};
* @param {Array.<Object>} requests An array of request objects from back-end
* @param {Array.<Object>} 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) {
backendRequests[backendRequestIndex]["mount-point"] = "";
} else if (newRequests[newRequestIndex]?.["device-spec"]) {
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 (
<Flex direction={{ default: "column" }} spaceItems={{ default: "spaceItemsNone" }}>
<Flex spaceItems={{ default: "spaceItemsMd" }} flexWrap={{ default: "nowrap" }}>
{((isRequiredMountPoint || isRecommendedMountPoint) && !duplicatedMountPoint) || swapMountpoint
? (
<FlexItem
className={idPrefix + "__mountpoint-text"}
id={idPrefix}
>
{mountpoint || request["format-type"]}
</FlexItem>
)
: <TextInput
className={idPrefix + "__mountpoint-text"}
id={idPrefix}
onBlur={() => handleRequestChange({ deviceSpec: request["device-spec"], mountPoint: mountPointText, requestIndex })}
onChange={(_event, val) => setMountPointText(val)}
value={mountPointText}
/>}
{isRequiredMountPoint && <Label color="yellow">{_("Required")}</Label>}
{!isRequiredMountPoint && isRecommendedMountPoint && <Label color="yellow">{_("Recommended")}</Label>}
{!isRequiredMountPoint && !isRecommendedMountPoint && <Label color="purple">{_("Custom")}</Label>}
</Flex>
{mountpoint && duplicatedMountPoint &&
<HelperText>
<HelperTextItem variant="error">
{_("Duplicate mount point.")}
</HelperTextItem>
</HelperText>}
</Flex>
);
};
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));
* - Locked LUKS devices
* - Swap devices when the mount point is preset (required) as these reset it
*/
const isAriaDisabled = isLockedEncryptedDevice || (formatType === "swap" && isRequiredMountPoint);
const node = (
<SelectOption
data-device-name={deviceName}
data-device-id={device}
description={description}
isAriaDisabled={isAriaDisabled}
itemId={device}
key={device}
>
{deviceName}
</SelectOption>
);
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 (
<Select
isGrouped={Object.keys(optionGroups).length > 1}
isOpen={isOpen}
selected={device}
variant="single"
onOpenChange={setIsOpen}
onSelect={(_, selection) => {
handleRequestChange({ deviceSpec: selection, mountPoint: request["mount-point"], requestIndex });
setIsOpen(false);
}}
onClear={() => {
handleRequestChange({ deviceSpec: "", mountPoint: request["mount-point"], requestIndex });
setIsOpen(false);
}}
popperProps={{ appendTo: "inline" }}
shouldFocusToggleOnSelect
toggle={toggleRef => (
<MenuToggle
ref={toggleRef}
isExpanded={isOpen}
onClick={() => setIsOpen(prev => !prev)}
id={idPrefix + "-select-toggle"}
>
<span className="pf-v6-c-select__toggle-text">
{device ? getToggleText() : _("Select a device")}
</span>
</MenuToggle>
)}
>
{Object.keys(optionGroups).map(disk => {
if (Object.keys(optionGroups).length === 1) {
return optionGroups[disk];
}
const groupLabel = (
cockpit.format(
_("$0 ($1)"),
deviceData[disk]?.description.v,
deviceData[disk]?.name.v
)
);
return (
<SelectGroup label={groupLabel} key={disk}>
{optionGroups[disk]}
</SelectGroup>
);
})}
</Select>
);
};
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 (
<Flex
direction={{ default: "column" }}
id={idPrefix}
spaceItems={{ default: "spaceItemsNone" }}
>
<DeviceColumnSelect
deviceData={deviceData}
devices={devices}
idPrefix={idPrefix}
isRequiredMountPoint={isRequiredMountPoint}
handleRequestChange={handleRequestChange}
lockedEncryptedDevices={lockedEncryptedDevices}
request={request}
requestIndex={requestIndex}
/>
{device && duplicatedDevice &&
<HelperText>
<HelperTextItem variant="error">
{_("Duplicate device.")}
</HelperTextItem>
</HelperText>}
{deviceInvalid &&
<HelperText>
<HelperTextItem variant="error">
{errorMessage}
</HelperTextItem>
</HelperText>}
</Flex>
);
};
const FormatColumn = ({ deviceData, handleRequestChange, idPrefix, request, requestIndex, requests }) => {
const [reformatInvalid, reformatErrorMsg] = isReformatInvalid(deviceData, request, requests);
const FormatSwitch = () => {
return (
<Switch
id={idPrefix + "-switch"}
isChecked={!!request.reformat}
aria-label={_("Reformat")}
onChange={(_event, checked) => handleRequestChange({ deviceSpec: request["device-spec"], mountPoint: request["mount-point"], reformat: checked, requestIndex })}
/>
);
};
return (
<Flex id={idPrefix}>
<FormatSwitch />
{reformatInvalid &&
<HelperText>
<HelperTextItem variant="error">
{reformatErrorMsg}
</HelperTextItem>
</HelperText>}
</Flex>
);
};
const MountPointRowRemove = ({ handleRequestChange, requestIndex }) => {
const handleRemove = () => {
handleRequestChange({ remove: true, requestIndex });
};
return (
<Button
aria-label={_("Remove")}
icon={<TrashIcon />}
onClick={handleRemove}
variant="plain"
/>
);
};
const getRequestRow = ({
allDevices,
deviceData,
handleRequestChange,
idPrefix,
lockedEncryptedDevices,
mountPointConstraints,
request,
requestIndex,
requests,
}) => {
const columnClassName = idPrefix + "__column";
const isRequiredMountPoint = (
request["mount-point"] !== "" &&
mountPointConstraints.filter(val => val.required.v && val["mount-point"].v === request["mount-point"]).length > 0
);
const isRecommendedMountPoint = mountPointConstraints.filter(val => val.recommended.v && val["mount-point"].v === request["mount-point"]).length > 0;
const duplicatedMountPoint = isDuplicateRequestField(requests, "mount-point", request["mount-point"]);
const rowId = idPrefix + "-row-" + (requestIndex + 1);
return ({
columns: [
{
props: { className: columnClassName },
title: (
<MountPointColumn
handleRequestChange={handleRequestChange}
idPrefix={rowId + "-mountpoint"}
isRequiredMountPoint={isRequiredMountPoint}
isRecommendedMountPoint={isRecommendedMountPoint}
request={request}
requestIndex={requestIndex}
requests={requests}
/>
)
},
{
props: { className: columnClassName },
title: (
<DeviceColumn
deviceData={deviceData}
devices={allDevices}
handleRequestChange={handleRequestChange}
idPrefix={rowId + "-device"}
isRequiredMountPoint={isRequiredMountPoint}
lockedEncryptedDevices={lockedEncryptedDevices}
request={request}
requestIndex={requestIndex}
requests={requests}
mountPointConstraints={mountPointConstraints}
/>
)
},
{
props: { className: columnClassName },
title: (
<FormatColumn
deviceData={deviceData}
handleRequestChange={handleRequestChange}
idPrefix={rowId + "-format"}
request={request}
requestIndex={requestIndex}
requests={requests}
/>
)
},
{
props: { className: columnClassName },
title: (
(isRequiredMountPoint && !duplicatedMountPoint) ? null : <MountPointRowRemove requestIndex={requestIndex} handleRequestChange={handleRequestChange} />
)
}
],
props: { id: rowId, key: requestIndex },
});
};
const getNewRequestProps = ({ deviceSpec, mountPoint, reformat, requests }) => {
const existingRequestForDev = requests.find(device => device["device-spec"] === deviceSpec);
const newProps = { ...existingRequestForDev };
if (mountPoint !== undefined) {
newProps["mount-point"] = mountPoint;
}
if (deviceSpec !== undefined) {
newProps["device-spec"] = deviceSpec;
if (newProps["format-type"] === "swap") {
newProps["mount-point"] = "swap";
}
}
if (reformat !== undefined) {
newProps.reformat = !!reformat;
}
return newProps;
};
const RequestsTable = ({
idPrefix,
}) => {
const { setIsFormValid, setStepNotification } = useContext(PageContext) ?? {};
const { diskSelection, partitioning } = useContext(StorageContext);
const selectedDisks = diskSelection.selectedDisks;
const deviceData = useOriginalDevices();
const mountPointConstraints = useMountPointConstraints({ devices: deviceData, selectedDisks });
const requests = partitioning?.requests;
const [unappliedRequests, setUnappliedRequests] = useState();
const allDevices = useMemo(() => {
return requests?.filter(r => isUsableDevice(r["device-spec"], deviceData)).map(r => r["device-spec"]) || [];
}, [requests, deviceData]);
const isLoadingPartitioning = mountPointConstraints === undefined || !requests;
const lockedEncryptedDevices = useMemo(
() => getLockedEncryptedDevices(selectedDisks, deviceData),
[deviceData, selectedDisks]
);
useEffect(() => {
if (isLoadingPartitioning || unappliedRequests !== undefined) {
return;
}
const initialRequests = getInitialRequests(requests, mountPointConstraints);
setUnappliedRequests(initialRequests);
setIsFormValid(getRequestsValid(initialRequests, deviceData));
}, [deviceData, setIsFormValid, partitioning.path, requests, isLoadingPartitioning, unappliedRequests, mountPointConstraints]);
const handleRequestChange = useCallback(({ deviceSpec, mountPoint, reformat, remove, requestIndex }) => {
const newRequests = [...unappliedRequests];
if (remove) {
newRequests.splice(requestIndex, 1);
} else {
const newRequestProps = newRequests[requestIndex] || {};
const newRequest = (
getNewRequestProps({
deviceSpec,
mountPoint: mountPoint || newRequestProps.mountPoint,
reformat: reformat !== undefined ? reformat : newRequestProps.reformat,
requests
})
);
if (requestIndex === unappliedRequests.length) {
newRequests.push(newRequest);
} else {
newRequests[requestIndex] = newRequest;
}
}
setIsFormValid(getRequestsValid(newRequests, deviceData));
updatePartitioningRequests({
newRequests,
partitioning: partitioning.path,
requests
}).catch(ex => {
setStepNotification(ex);
setIsFormValid(false);
});
setUnappliedRequests(newRequests);
}, [setIsFormValid, deviceData, unappliedRequests, requests, partitioning.path, setStepNotification]);
if (isLoadingPartitioning || unappliedRequests === undefined) {
return <EmptyStatePanel loading />;
}
return (
<>
<ListingTable
aria-label={_("Mount point assignment")}
columns={[
{ props: { width: 30 }, title: _("Mount point") },
{ props: { width: 40 }, title: _("Device") },
{ props: { width: 20 }, title: _("Reformat") },
{ props: { "aria-label": _("Remove"), width: 10 }, title: "" },
]}
emptyCaption={_("No devices")}
id={idPrefix}
rows={unappliedRequests
.map((request, idx) => (
getRequestRow({
allDevices,
deviceData,
handleRequestChange,
idPrefix,
lockedEncryptedDevices,
mountPointConstraints,
request,
requestIndex: idx,
requests: unappliedRequests,
})
))} />
<div>
<Button
variant="secondary"
onClick={() => handleRequestChange({ requestIndex: unappliedRequests.length })}>
{_("Add mount")}
</Button>
</div>
</>
);
};
const isUsableDevice = (devSpec, deviceData) => {
const device = deviceData[devSpec];
if (device === undefined || device.formatData === undefined) {
return false;
}
if (isDeviceEncrypted(device)) {
return true;
}
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;
}
previousRequestsRef.current = partitioning?.requests || [];
const part = await getNewPartitioning({ method: "MANUAL", storageScenarioId: "mount-point-mapping" });
const newRequestsDbus = await gatherRequests({ partitioning: part });
const newRequests = requestsFromDbus(newRequestsDbus);
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;
await setManualPartitioningRequests({
partitioning: part,
requests: requestsToDbus(_mergedRequests),
});
};
_updateInitialRequests();
}, [partitioning?.requests]);
useEffect(() => {
if (!partitioning?.requests || !mergedRequestsRef.current) {
return;
}
setIsFormDisabled(false);
}, [partitioning?.requests, setIsFormDisabled]);
};
export const MountPointMapping = () => {
const getFooter = useMemo(() => <CustomFooter />, []);
useWizardFooter(getFooter);
return (
<RequestsTable idPrefix={SCREEN_ID + "-table"} />
);
};
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 {
const filteredRequests = filterPartitioningRequests(partitioning.requests || []);
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 <AnacondaWizardFooter onNext={onNext} />;
};