From fd20fdab16fad10639cdaa42a8f0988ff4d6615d Mon Sep 17 00:00:00 2001 From: Katerina Koukiou Date: Tue, 23 Jan 2024 12:21:06 +0100 Subject: [PATCH 1/9] Enhance integration between Anaconda Web UI and Cockpit storage This commit introduces a higher level of integration between Anaconda Web UI and Cockpit storage. When users perform advanced storage configuration, their storage settings will now seamlessly sync with Anaconda web UI. Specifically, selected mount points and configured LUKS devices will be automatically detected and utilized during installation, streamlining the process for users. This enhancement aims to improve the user experience and reduce manual configuration efforts during installation. Resolves: rhbz#2263971 --- packaging/anaconda-webui.spec.in | 2 +- src/components/AnacondaWizard.jsx | 10 + src/components/review/ReviewConfiguration.jsx | 9 +- .../storage/CockpitStorageIntegration.jsx | 349 ++++++++++++++++-- .../storage/CockpitStorageIntegration.scss | 14 + src/components/storage/InstallationMethod.jsx | 8 +- .../storage/InstallationScenario.jsx | 173 +++++++-- test/check-storage | 214 ++++++++++- test/helpers/installer.py | 9 +- test/helpers/storage.py | 4 + 10 files changed, 717 insertions(+), 75 deletions(-) diff --git a/packaging/anaconda-webui.spec.in b/packaging/anaconda-webui.spec.in index 1c80df3cbc..0eb43295cf 100644 --- a/packaging/anaconda-webui.spec.in +++ b/packaging/anaconda-webui.spec.in @@ -13,7 +13,7 @@ BuildRequires: gettext %global anacondacorever 40.20 %global cockpitver 275 -%global cockpitstorver 310 +%global cockpitstorver 311 Requires: cockpit-storaged >= %{cockpitstorver} Requires: cockpit-bridge >= %{cockpitver} diff --git a/src/components/AnacondaWizard.jsx b/src/components/AnacondaWizard.jsx index 136e409915..bd2891a873 100644 --- a/src/components/AnacondaWizard.jsx +++ b/src/components/AnacondaWizard.jsx @@ -65,6 +65,13 @@ export const AnacondaWizard = ({ dispatch, storageData, localizationData, runtim const osRelease = useContext(OsReleaseContext); const isBootIso = useContext(SystemTypeContext) === "BOOT_ISO"; const selectedDisks = storageData.diskSelection.selectedDisks; + const [scenarioPartitioningMapping, setScenarioPartitioningMapping] = useState({}); + + useEffect(() => { + if (storageScenarioId && storageData.partitioning.path) { + setScenarioPartitioningMapping({ [storageScenarioId]: storageData.partitioning.path }); + } + }, [storageData.partitioning.path, storageScenarioId]); const availableDevices = useMemo(() => { return Object.keys(storageData.devices); @@ -108,6 +115,8 @@ export const AnacondaWizard = ({ dispatch, storageData, localizationData, runtim deviceNames: storageData.deviceNames, diskSelection: storageData.diskSelection, dispatch, + partitioning: storageData.partitioning.path, + scenarioPartitioningMapping, storageScenarioId, setStorageScenarioId: (scenarioId) => { window.sessionStorage.setItem("storage-scenario-id", scenarioId); @@ -275,6 +284,7 @@ export const AnacondaWizard = ({ dispatch, storageData, localizationData, runtim deviceData={storageData.devices} dispatch={dispatch} onCritFail={onCritFail} + scenarioPartitioningMapping={scenarioPartitioningMapping} selectedDisks={selectedDisks} setShowStorage={setShowStorage} setStorageScenarioId={setStorageScenarioId} diff --git a/src/components/review/ReviewConfiguration.jsx b/src/components/review/ReviewConfiguration.jsx index a7f51b37c0..4f58a65cd1 100644 --- a/src/components/review/ReviewConfiguration.jsx +++ b/src/components/review/ReviewConfiguration.jsx @@ -177,7 +177,14 @@ export const ReviewConfiguration = ({ deviceData, diskSelection, language, local {diskSelection.selectedDisks.map(disk => { - return ; + return ( + + ); })} diff --git a/src/components/storage/CockpitStorageIntegration.jsx b/src/components/storage/CockpitStorageIntegration.jsx index 4175afed42..22f83d39f2 100644 --- a/src/components/storage/CockpitStorageIntegration.jsx +++ b/src/components/storage/CockpitStorageIntegration.jsx @@ -16,17 +16,21 @@ * along with This program; If not, see . */ import cockpit from "cockpit"; -import React, { useState } from "react"; +import React, { useEffect, useMemo, useState } from "react"; import { + ActionList, Alert, Button, Card, CardBody, Flex, FlexItem, + HelperText, + HelperTextItem, List, ListItem, + Modal, PageSection, PageSectionVariants, Text, @@ -35,61 +39,65 @@ import { } from "@patternfly/react-core"; import { ArrowLeftIcon } from "@patternfly/react-icons"; -import { useRequiredSize, useMountPointConstraints } from "./Common.jsx"; +import { EmptyStatePanel } from "cockpit-components-empty-state"; +import { checkConfiguredStorage, checkUseFreeSpace } from "./InstallationScenario.jsx"; +import { useDiskTotalSpace, useDiskFreeSpace, useRequiredSize, useMountPointConstraints } from "./Common.jsx"; import { runStorageTask, scanDevicesWithTask, } from "../../apis/storage.js"; +import { + setBootloaderDrive, +} from "../../apis/storage_bootloader.js"; +import { + setInitializationMode, +} from "../../apis/storage_disk_initialization.js"; +import { + applyStorage, + createPartitioning, + gatherRequests, + resetPartitioning, + setManualPartitioningRequests +} from "../../apis/storage_partitioning.js"; + import { getDevicesAction } from "../../actions/storage-actions.js"; +import { getDeviceNameByPath } from "../../helpers/storage.js"; import "./CockpitStorageIntegration.scss"; const _ = cockpit.gettext; const idPrefix = "cockpit-storage-integration"; -const ReturnToInstallationButton = ({ dispatch, setShowStorage, onCritFail }) => { - const [isInProgress, setIsInProgress] = useState(false); - - const rescanStorage = () => { - setIsInProgress(true); - - return scanDevicesWithTask() - .then(task => { - return runStorageTask({ - task, - onSuccess: () => dispatch(getDevicesAction()) - .then(() => { - setIsInProgress(false); - setShowStorage(false); - }), - onFail: exc => { - setIsInProgress(false); - onCritFail()(exc); - } - }); - }); - }; - - return ( - - ); -}; +const ReturnToInstallationButton = ({ isDisabled, onAction }) => ( + +); export const CockpitStorageIntegration = ({ + scenarioAvailability, + scenarioPartitioningMapping, + selectedDisks, + setStorageScenarioId, + deviceData, dispatch, onCritFail, setShowStorage, }) => { + const [showDialog, setShowDialog] = useState(false); + const [needsResetPartitioning, setNeedsResetPartitioning] = useState(true); + + useEffect(() => { + resetPartitioning().then(() => setNeedsResetPartitioning(false), onCritFail); + }, [onCritFail]); + return ( <> + isDisabled={needsResetPartitioning} + onAction={() => setShowDialog(true)} /> + {showDialog && + } ); }; +export const preparePartitioning = async ({ deviceData, newMountPoints }) => { + try { + await setBootloaderDrive({ drive: "" }); + + const partitioning = await createPartitioning({ method: "MANUAL" }); + const requests = await gatherRequests({ partitioning }); + + const addRequest = (devicePath, object, isSubVolume = false) => { + const { dir, type, subvolumes, content } = object; + let deviceSpec; + if (!isSubVolume) { + deviceSpec = getDeviceNameByPath(deviceData, devicePath); + } else if (deviceData[devicePath]) { + deviceSpec = devicePath; + } else { + return; + } + + if (deviceSpec && (dir || type === "swap")) { + const existingRequestIndex = ( + requests.findIndex(request => request["device-spec"].v === deviceSpec) + ); + + if (existingRequestIndex !== -1) { + requests[existingRequestIndex] = { + "mount-point": cockpit.variant("s", dir || type), + "device-spec": cockpit.variant("s", deviceSpec), + }; + } else { + requests.push({ + "mount-point": cockpit.variant("s", dir || type), + "device-spec": cockpit.variant("s", deviceSpec), + }); + } + } else if (subvolumes) { + Object.keys(subvolumes).forEach(subvolume => addRequest(subvolume, subvolumes[subvolume], true)); + } else if (type === "crypto") { + const clearTextDevice = deviceData[deviceSpec].children.v[0]; + const clearTextDevicePath = deviceData[clearTextDevice].path.v; + + addRequest(clearTextDevicePath, content); + } + }; + + Object.keys(newMountPoints).forEach(usedDevice => { + addRequest(usedDevice, newMountPoints[usedDevice]); + }); + + await setManualPartitioningRequests({ partitioning, requests }); + return partitioning; + } catch (error) { + console.error("Failed to prepare partitioning", error); + } +}; + +const CheckStorageDialog = ({ + deviceData, + dispatch, + onCritFail, + scenarioPartitioningMapping, + selectedDisks, + setShowDialog, + setShowStorage, + setStorageScenarioId, +}) => { + const [error, setError] = useState(); + const [checkStep, setCheckStep] = useState("rescan"); + const diskTotalSpace = useDiskTotalSpace({ selectedDisks, devices: deviceData }); + const diskFreeSpace = useDiskFreeSpace({ selectedDisks, devices: deviceData }); + const mountPointConstraints = useMountPointConstraints(); + const requiredSize = useRequiredSize(); + + const newMountPoints = useMemo(() => JSON.parse(window.localStorage.getItem("cockpit_mount_points") || "{}"), []); + + const useConfiguredStorage = useMemo(() => { + const availability = checkConfiguredStorage({ + mountPointConstraints, + scenarioPartitioningMapping, + newMountPoints, + }); + + return availability.available; + }, [mountPointConstraints, newMountPoints, scenarioPartitioningMapping]); + + const useFreeSpace = useMemo(() => { + const availability = checkUseFreeSpace({ diskFreeSpace, diskTotalSpace, requiredSize }); + + return availability.available && !availability.hidden; + }, [diskFreeSpace, diskTotalSpace, requiredSize]); + + const loading = !error && checkStep !== undefined; + const storageRequirementsNotMet = !loading && (error || (!useConfiguredStorage && !useFreeSpace)); + + useEffect(() => { + if (!useConfiguredStorage && checkStep === "prepare-partitioning") { + setCheckStep(); + } + }, [useConfiguredStorage, checkStep]); + + useEffect(() => { + // If the required devices needed for manual partitioning are set up, + // and prepare the partitioning + if (checkStep !== "prepare-partitioning") { + return; + } + + const applyNewPartitioning = async () => { + // CLEAR_PARTITIONS_NONE = 0 + try { + await setInitializationMode({ mode: 0 }); + const partitioning = await preparePartitioning({ deviceData, newMountPoints }); + + applyStorage({ + partitioning, + onFail: exc => { + setCheckStep(); + setError(exc); + }, + onSuccess: () => setCheckStep(), + }); + } catch (exc) { + setCheckStep(); + setError(exc); + } + }; + + applyNewPartitioning(); + }, [deviceData, checkStep, newMountPoints, useConfiguredStorage]); + + useEffect(() => { + if (checkStep !== "rescan" || useConfiguredStorage === undefined) { + return; + } + + // When the dialog is shown rescan to get latest configured storage + // and check if we need to prepare manual partitioning + scanDevicesWithTask() + .then(task => { + return runStorageTask({ + task, + onSuccess: () => dispatch(getDevicesAction()) + .then(() => { + if (useConfiguredStorage) { + setCheckStep("prepare-partitioning"); + } else { + setCheckStep(); + } + }) + .catch(exc => { + setCheckStep(); + setError(exc); + }), + onFail: exc => { + setCheckStep(); + setError(exc); + } + }); + }); + }, [useConfiguredStorage, checkStep, dispatch, setError]); + + const goBackToInstallation = () => { + const mode = useConfiguredStorage ? "use-configured-storage" : "use-free-space"; + + setStorageScenarioId(mode); + setShowStorage(false); + }; + + const loadingDescription = ( + + ); + + const modalProps = {}; + if (!loading) { + if (storageRequirementsNotMet) { + modalProps.title = _("Storage requirements not met"); + } else { + modalProps.title = _("Continue with installation"); + } + } else { + modalProps["aria-label"] = _("Checking storage configuration"); + } + + return ( + setShowDialog(false)} + titleIconVariant={!loading && storageRequirementsNotMet && "warning"} + position="top" variant="small" isOpen + {...modalProps} + footer={ + !loading && + <> + + {!storageRequirementsNotMet && + <> + + + } + {storageRequirementsNotMet && + <> + + + } + + + } + > + <> + {loading && loadingDescription} + {!loading && + <> + {storageRequirementsNotMet ? error?.message : null} + + {!storageRequirementsNotMet && + + {_("Current configuration can be used for installation.")} + } + + } + + + + ); +}; + const ModifyStorageSideBar = () => { const mountPointConstraints = useMountPointConstraints(); const requiredSize = useRequiredSize(); diff --git a/src/components/storage/CockpitStorageIntegration.scss b/src/components/storage/CockpitStorageIntegration.scss index e2952616e8..11e27b9d63 100644 --- a/src/components/storage/CockpitStorageIntegration.scss +++ b/src/components/storage/CockpitStorageIntegration.scss @@ -3,6 +3,10 @@ width: 100%; } +.cockpit-storage-integration-page-section-storage-alert { + padding-bottom: 0; +} + .cockpit-storage-integration-iframe-cockpit-storage { height: 100%; } @@ -32,3 +36,13 @@ ul.cockpit-storage-integration-requirements-hint-list { .cockpit-storage-integration-requirements-hint-detail { font-size: small; } + +// Hide the [x] button in the loading mode +.cockpit-storage-integration-check-storage-dialog--loading .pf-v5-c-modal-box__close { + display: none; +} + +// Make Spinner smaller - default EmptyStatePanel svg size is too big +.cockpit-storage-integration-check-storage-dialog--loading svg.pf-v5-c-spinner { + --pf-v5-c-spinner--diameter: var(--pf-v5-c-spinner--m-lg--diameter); +} diff --git a/src/components/storage/InstallationMethod.jsx b/src/components/storage/InstallationMethod.jsx index e9f017e575..aa1e6172ec 100644 --- a/src/components/storage/InstallationMethod.jsx +++ b/src/components/storage/InstallationMethod.jsx @@ -37,6 +37,8 @@ export const InstallationMethod = ({ isEfi, isFormDisabled, onCritFail, + partitioning, + scenarioPartitioningMapping, setIsFormDisabled, setIsFormValid, setShowStorage, @@ -64,11 +66,13 @@ export const InstallationMethod = ({