diff --git a/ui/webui/src/components/AnacondaHeader.jsx b/ui/webui/src/components/AnacondaHeader.jsx index e4c22f1ae25..efb7443f7ea 100644 --- a/ui/webui/src/components/AnacondaHeader.jsx +++ b/ui/webui/src/components/AnacondaHeader.jsx @@ -16,7 +16,7 @@ */ import cockpit from "cockpit"; -import React from "react"; +import React, { useState, useEffect } from "react"; import { Flex, @@ -29,39 +29,22 @@ import { InfoCircleIcon } from "@patternfly/react-icons"; import { HeaderKebab } from "./HeaderKebab.jsx"; +import { getIsFinal } from "../apis/runtime"; + import "./AnacondaHeader.scss"; const _ = cockpit.gettext; +const N_ = cockpit.noop; -export const AnacondaHeader = ({ beta, title, reportLinkURL, isConnected }) => { - const prerelease = _("Pre-release"); - const betanag = beta - ? ( - - - {_("Notice: This is pre-released software that is intended for development and testing purposes only. Do NOT use this software for any critical work or for production environments.")} - - - {_("By continuing to use this software, you understand and accept the risks associated with pre-released software, that you intend to use this for testing and development purposes only and are willing to report any bugs or issues in order to enhance this work.")} - - - {_("If you do not understand or accept the risks, then please exit this program.")} - - - } - > - {/* HACK Patternfly currently doesn't implement clickable labels so the styling had to be done manually. */} -
- -
-
- ) - : null; +export const AnacondaHeader = ({ title, reportLinkURL, isConnected, onCritFail }) => { + const [beta, setBeta] = useState(); + + useEffect(() => { + getIsFinal().then( + isFinal => setBeta(!isFinal), + onCritFail({ context: N_("Reading installer version information failed.") }) + ); + }, [onCritFail]); return ( @@ -70,9 +53,39 @@ export const AnacondaHeader = ({ beta, title, reportLinkURL, isConnected }) => { {title} - {betanag} + {beta && } ); }; + +const Beta = () => { + const prerelease = _("Pre-release"); + + return ( + + + {_("Notice: This is pre-released software that is intended for development and testing purposes only. Do NOT use this software for any critical work or for production environments.")} + + + {_("By continuing to use this software, you understand and accept the risks associated with pre-released software, that you intend to use this for testing and development purposes only and are willing to report any bugs or issues in order to enhance this work.")} + + + {_("If you do not understand or accept the risks, then please exit this program.")} + + + } + > + {/* HACK Patternfly currently doesn't implement clickable labels so the styling had to be done manually. */} +
+ +
+
+ ); +}; diff --git a/ui/webui/src/components/AnacondaPage.jsx b/ui/webui/src/components/AnacondaPage.jsx index 0937e486c57..e08c61668d0 100644 --- a/ui/webui/src/components/AnacondaPage.jsx +++ b/ui/webui/src/components/AnacondaPage.jsx @@ -21,7 +21,7 @@ export const AnacondaPage = ({ title, children, step, stepNotification }) => { return ( {title && {title}} - {stepNotification && stepNotification.step === step && + {stepNotification?.step === step && . */ import cockpit from "cockpit"; -import React, { useEffect, useState, useMemo } from "react"; +import React, { useContext, useEffect, useState, useMemo } from "react"; import { ActionList, @@ -50,11 +50,12 @@ import { resetPartitioning, getRequiredMountPoints, } from "../apis/storage.js"; +import { SystemTypeContext, OsReleaseContext } from "./Common.jsx"; const _ = cockpit.gettext; const N_ = cockpit.noop; -export const AnacondaWizard = ({ dispatch, isBootIso, osRelease, storageData, localizationData, onCritFail, title, conf }) => { +export const AnacondaWizard = ({ dispatch, storageData, localizationData, onCritFail, title, conf }) => { const [isFormDisabled, setIsFormDisabled] = useState(false); const [isFormValid, setIsFormValid] = useState(false); const [requiredMountPoints, setRequiredMountPoints] = useState(); @@ -62,6 +63,8 @@ export const AnacondaWizard = ({ dispatch, isBootIso, osRelease, storageData, lo const [stepNotification, setStepNotification] = useState(); const [storageEncryption, setStorageEncryption] = useState(getStorageEncryptionState()); const [storageScenarioId, setStorageScenarioId] = useState(window.sessionStorage.getItem("storage-scenario-id") || getDefaultScenario().id); + const osRelease = useContext(OsReleaseContext); + const isBootIso = useContext(SystemTypeContext) === "BOOT_ISO"; const availableDevices = useMemo(() => { return Object.keys(storageData.devices); @@ -143,7 +146,6 @@ export const AnacondaWizard = ({ dispatch, isBootIso, osRelease, storageData, lo requests: storageData.partitioning ? storageData.partitioning.requests : null, language, localizationData, - osRelease, storageScenarioId, }, ...getReviewConfigurationProps() @@ -151,9 +153,6 @@ export const AnacondaWizard = ({ dispatch, isBootIso, osRelease, storageData, lo { component: InstallationProgress, id: "installation-progress", - data: { - osRelease - } } ]; @@ -210,8 +209,6 @@ export const AnacondaWizard = ({ dispatch, isBootIso, osRelease, storageData, lo stepNotification={stepNotification} isFormDisabled={isFormDisabled} setIsFormDisabled={setIsFormDisabled} - isBootIso={isBootIso} - osRelease={osRelease} {...s.data} /> @@ -260,7 +257,6 @@ export const AnacondaWizard = ({ dispatch, isBootIso, osRelease, storageData, lo setIsFormDisabled={setIsFormDisabled} storageEncryption={storageEncryption} storageScenarioId={storageScenarioId} - isBootIso={isBootIso} />} hideClose mainAriaLabel={`${title} content`} @@ -285,10 +281,10 @@ const Footer = ({ setIsFormDisabled, storageEncryption, storageScenarioId, - isBootIso }) => { const [nextWaitsConfirmation, setNextWaitsConfirmation] = useState(false); const [quitWaitsConfirmation, setQuitWaitsConfirmation] = useState(false); + const isBootIso = useContext(SystemTypeContext) === "BOOT_ISO"; const goToNextStep = (activeStep, onNext) => { // first reset validation state to default @@ -380,7 +376,6 @@ const Footer = ({ } {activeStep.id === "installation-method" && !isFormValid && @@ -440,7 +435,9 @@ const Footer = ({ ); }; -export const QuitInstallationConfirmModal = ({ exitGui, setQuitWaitsConfirmation, isBootIso }) => { +export const QuitInstallationConfirmModal = ({ exitGui, setQuitWaitsConfirmation }) => { + const isBootIso = useContext(SystemTypeContext) === "BOOT_ISO"; + return ( { return ( diff --git a/ui/webui/src/components/Error.jsx b/ui/webui/src/components/Error.jsx index 07bad36a042..8c37434c797 100644 --- a/ui/webui/src/components/Error.jsx +++ b/ui/webui/src/components/Error.jsx @@ -16,7 +16,7 @@ */ import cockpit from "cockpit"; -import React, { useEffect, useState } from "react"; +import React, { useContext, useEffect, useState } from "react"; import { ActionList, @@ -38,6 +38,7 @@ import { import { ExternalLinkAltIcon, DisconnectedIcon } from "@patternfly/react-icons"; import { exitGui } from "../helpers/exit.js"; +import { SystemTypeContext } from "./Common.jsx"; import "./Error.scss"; @@ -208,7 +209,8 @@ const quitButton = (isBootIso) => { ); }; -export const CriticalError = ({ exception, isBootIso, isConnected, reportLinkURL }) => { +export const CriticalError = ({ exception, isConnected, reportLinkURL }) => { + const isBootIso = useContext(SystemTypeContext) === "BOOT_ISO"; const context = exception.contextData?.context; const description = context ? cockpit.format(_("The installer cannot continue due to a critical error: $0"), _(context)) diff --git a/ui/webui/src/components/HeaderKebab.jsx b/ui/webui/src/components/HeaderKebab.jsx index 0238ba3ce10..01bacacdaf5 100644 --- a/ui/webui/src/components/HeaderKebab.jsx +++ b/ui/webui/src/components/HeaderKebab.jsx @@ -16,7 +16,7 @@ */ import cockpit from "cockpit"; -import React, { useState, useEffect } from "react"; +import React, { useContext, useState, useEffect } from "react"; import { AboutModal, Button, @@ -37,9 +37,9 @@ import { EllipsisVIcon } from "@patternfly/react-icons"; -import { read_os_release as readOsRelease } from "os-release.js"; import { getAnacondaVersion } from "../helpers/product.js"; import { UserIssue } from "./Error.jsx"; +import { OsReleaseContext } from "./Common.jsx"; import "./HeaderKebab.scss"; @@ -63,15 +63,7 @@ const AboutModalVersions = () => { }; const ProductName = () => { - const [osRelease, setOsRelease] = useState(); - - useEffect(() => { - readOsRelease().then(setOsRelease); - }, []); - - if (!osRelease) { - return null; - } + const osRelease = useContext(OsReleaseContext); return ( diff --git a/ui/webui/src/components/app.jsx b/ui/webui/src/components/app.jsx index d966f88e9ac..e952d4e85c0 100644 --- a/ui/webui/src/components/app.jsx +++ b/ui/webui/src/components/app.jsx @@ -25,7 +25,7 @@ import { import { read_os_release as readOsRelease } from "os-release.js"; import { WithDialogs } from "dialogs.jsx"; -import { AddressContext, LanguageContext } from "./Common.jsx"; +import { AddressContext, LanguageContext, SystemTypeContext, OsReleaseContext } from "./Common.jsx"; import { AnacondaHeader } from "./AnacondaHeader.jsx"; import { AnacondaWizard } from "./AnacondaWizard.jsx"; import { CriticalError, errorHandlerWithContext, bugzillaPrefiledReportURL } from "./Error.jsx"; @@ -34,7 +34,7 @@ import { BossClient } from "../apis/boss.js"; import { LocalizationClient, initDataLocalization, startEventMonitorLocalization } from "../apis/localization.js"; import { StorageClient, initDataStorage, startEventMonitorStorage } from "../apis/storage.js"; import { PayloadsClient } from "../apis/payloads"; -import { RuntimeClient, getIsFinal } from "../apis/runtime"; +import { RuntimeClient } from "../apis/runtime"; import { NetworkClient, initDataNetwork, startEventMonitorNetwork } from "../apis/network.js"; import { setCriticalErrorAction } from "../actions/miscellaneous-actions.js"; @@ -48,7 +48,6 @@ const N_ = cockpit.noop; export const Application = () => { const [address, setAddress] = useState(); - const [beta, setBeta] = useState(); const [conf, setConf] = useState(); const [language, setLanguage] = useState(); const [osRelease, setOsRelease] = useState(""); @@ -88,11 +87,6 @@ export const Application = () => { startEventMonitorLocalization({ dispatch }); startEventMonitorNetwork({ dispatch }); }, onCritFail({ context: N_("Reading information about the computer failed.") })); - - getIsFinal().then( - isFinal => setBeta(!isFinal), - onCritFail({ context: N_("Reading installer version information failed.") }) - ); }); readConf().then( @@ -104,13 +98,13 @@ export const Application = () => { }, [dispatch, onCritFail]); // Postpone rendering anything until we read the dbus address and the default configuration - if (!criticalError && (!address || !conf || beta === undefined || !osRelease || !storeInitilized)) { + if (!criticalError && (!address || !conf || !osRelease || !storeInitilized)) { debug("Loading initial data..."); return null; } // On live media rebooting the system will actually shut it off - const isBootIso = conf?.["Installation System"].type === "BOOT_ISO"; + const systemType = conf?.["Installation System"].type; const title = cockpit.format(_("$0 installation"), osRelease.PRETTY_NAME); const bzReportURL = bugzillaPrefiledReportURL({ @@ -119,36 +113,36 @@ export const Application = () => { }); const page = ( - <> - {criticalError && - } - - - - - - - + + {criticalError && + } + + + - - - - + + + + + + + + + ); return ( diff --git a/ui/webui/src/components/installation/InstallationProgress.jsx b/ui/webui/src/components/installation/InstallationProgress.jsx index a7bb2f164fb..525a1a51243 100644 --- a/ui/webui/src/components/installation/InstallationProgress.jsx +++ b/ui/webui/src/components/installation/InstallationProgress.jsx @@ -15,7 +15,7 @@ * along with This program; If not, see . */ import cockpit from "cockpit"; -import React, { useState, useEffect, useRef } from "react"; +import React, { useContext, useState, useEffect, useRef } from "react"; import { Button, Flex, @@ -31,20 +31,26 @@ import { CheckCircleIcon, ExclamationCircleIcon } from "@patternfly/react-icons"; + import { EmptyStatePanel } from "cockpit-components-empty-state.jsx"; +import { SystemTypeContext, OsReleaseContext } from "../Common.jsx"; + import { BossClient, getSteps, installWithTasks } from "../../apis/boss.js"; import { exitGui } from "../../helpers/exit.js"; + import "./InstallationProgress.scss"; const _ = cockpit.gettext; const N_ = cockpit.noop; -export const InstallationProgress = ({ onCritFail, idPrefix, isBootIso, osRelease }) => { +export const InstallationProgress = ({ onCritFail, idPrefix }) => { const [status, setStatus] = useState(); const [statusMessage, setStatusMessage] = useState(""); const [steps, setSteps] = useState(); const [currentProgressStep, setCurrentProgressStep] = useState(0); const refStatusMessage = useRef(""); + const isBootIso = useContext(SystemTypeContext) === "BOOT_ISO"; + const osRelease = useContext(OsReleaseContext); useEffect(() => { installWithTasks() diff --git a/ui/webui/src/components/review/ReviewConfiguration.jsx b/ui/webui/src/components/review/ReviewConfiguration.jsx index dc5bd26e8be..2200c4a2668 100644 --- a/ui/webui/src/components/review/ReviewConfiguration.jsx +++ b/ui/webui/src/components/review/ReviewConfiguration.jsx @@ -15,7 +15,7 @@ * along with This program; If not, see . */ import cockpit from "cockpit"; -import React, { useEffect, useState } from "react"; +import React, { useContext, useEffect, useState } from "react"; import { Button, @@ -34,6 +34,7 @@ import { import { checkDeviceInSubTree } from "../../helpers/storage.js"; import { getScenario } from "../storage/InstallationScenario.jsx"; +import { OsReleaseContext } from "../Common.jsx"; import "./ReviewConfiguration.scss"; @@ -97,8 +98,9 @@ const DeviceRow = ({ deviceData, disk, requests }) => { ); }; -export const ReviewConfiguration = ({ deviceData, diskSelection, language, localizationData, osRelease, requests, idPrefix, setIsFormValid, storageScenarioId }) => { +export const ReviewConfiguration = ({ deviceData, diskSelection, language, localizationData, requests, idPrefix, setIsFormValid, storageScenarioId }) => { const [encrypt, setEncrypt] = useState(); + const osRelease = useContext(OsReleaseContext); useEffect(() => { const initializeEncrypt = async () => { diff --git a/ui/webui/src/components/storage/InstallationDestination.jsx b/ui/webui/src/components/storage/InstallationDestination.jsx new file mode 100644 index 00000000000..c3b32aeb2c2 --- /dev/null +++ b/ui/webui/src/components/storage/InstallationDestination.jsx @@ -0,0 +1,438 @@ +/* + * Copyright (C) 2022 Red Hat, Inc. + * + * This program 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. + * + * This program 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 This program; If not, see . + */ +import cockpit from "cockpit"; +import React, { useContext, useEffect, useRef, useState } from "react"; + +import { + Alert, + AlertActionCloseButton, + Button, + Chip, + ChipGroup, + Flex, + FlexItem, + FormGroup, + MenuToggle, + Select, + SelectList, + SelectOption, + Spinner, + TextInputGroup, + TextInputGroupMain, + TextInputGroupUtilities, + Title, +} from "@patternfly/react-core"; +import { SyncAltIcon, TimesIcon } from "@patternfly/react-icons"; + +import { SystemTypeContext } from "../Common.jsx"; +import { ModifyStorage } from "./ModifyStorage.jsx"; + +import { + resetPartitioning, + runStorageTask, + scanDevicesWithTask, + setSelectedDisks, +} from "../../apis/storage.js"; + +import { getDevicesAction, getDiskSelectionAction } from "../../actions/storage-actions.js"; +import { debug } from "../../helpers/log.js"; +import { checkIfArraysAreEqual } from "../../helpers/utils.js"; + +import "./InstallationDestination.scss"; + +const _ = cockpit.gettext; +const N_ = cockpit.noop; + +/** + * Select default disks for the partitioning. + * + * If there are some usable disks already selected, show these. + * In the automatic installation, select all disks. In + * the interactive installation, select a disk if there + * is only one available. + * @return: the list of selected disks + */ +const selectDefaultDisks = ({ ignoredDisks, selectedDisks, usableDisks }) => { + if (selectedDisks.length && selectedDisks.some(disk => usableDisks.includes(disk))) { + // Filter the selection by checking the usable disks if there are some disks selected + console.log("Selecting disks selected in backend:", selectedDisks.join(",")); + return selectedDisks.filter(disk => usableDisks.includes(disk)); + } else { + const availableDisks = usableDisks.filter(disk => !ignoredDisks.includes(disk)); + console.log("Selecting one or less disks by default:", availableDisks.join(",")); + + // Select a usable disk if there is only one available + if (availableDisks.length === 1) { + return availableDisks; + } + return []; + } +}; + +const LocalDisksSelect = ({ deviceData, diskSelection, idPrefix, isDisabled, setSelectedDisks }) => { + const [isOpen, setIsOpen] = useState(false); + const [inputValue, setInputValue] = useState(""); + const [focusedItemIndex, setFocusedItemIndex] = useState(null); + const [diskSelectionInProgress, setDiskSelectionInProgress] = useState(false); + const textInputRef = useRef(); + + useEffect(() => { + setDiskSelectionInProgress(false); + }, [diskSelection.selectedDisks]); + + let selectOptions = diskSelection.usableDisks + .map(disk => ({ + description: deviceData[disk]?.description.v, + name: disk, + size: cockpit.format_bytes(deviceData[disk]?.total.v), + value: disk, + })) + .filter(option => + String(option.name) + .toLowerCase() + .includes(inputValue.toLowerCase()) || + String(option.description) + .toLowerCase() + .includes(inputValue.toLowerCase()) + ); + + if (selectOptions.length === 0) { + selectOptions = [ + { children: _("No results found"), value: "no results" } + ]; + } + + const onSelect = (selectedDisk) => { + setDiskSelectionInProgress(true); + + if (diskSelection.selectedDisks.includes(selectedDisk)) { + setSelectedDisks({ drives: diskSelection.selectedDisks.filter(disk => disk !== selectedDisk) }); + } else { + setSelectedDisks({ drives: [...diskSelection.selectedDisks, selectedDisk] }); + } + textInputRef.current?.focus(); + setIsOpen(false); + }; + + const clearSelection = () => { + setSelectedDisks({ drives: [] }); + }; + + const handleMenuArrowKeys = (key) => { + let indexToFocus; + + if (isOpen) { + if (key === "ArrowUp") { + // When no index is set or at the first index, focus to the last, otherwise decrement focus index + if (focusedItemIndex === null || focusedItemIndex === 0) { + indexToFocus = selectOptions.length - 1; + } else { + indexToFocus = focusedItemIndex - 1; + } + } + + if (key === "ArrowDown") { + // When no index is set or at the last index, focus to the first, otherwise increment focus index + if (focusedItemIndex === null || focusedItemIndex === selectOptions.length - 1) { + indexToFocus = 0; + } else { + indexToFocus = focusedItemIndex + 1; + } + } + + setFocusedItemIndex(indexToFocus); + } + }; + + const onInputKeyDown = (event) => { + const enabledMenuItems = selectOptions.filter((menuItem) => !menuItem.isDisabled); + const [firstMenuItem] = enabledMenuItems; + const focusedItem = focusedItemIndex ? enabledMenuItems[focusedItemIndex] : firstMenuItem; + + switch (event.key) { + // Select the first available option + case "Enter": + if (!isOpen) { + setIsOpen((prevIsOpen) => !prevIsOpen); + } else if (focusedItem.name !== "no results") { + onSelect(focusedItem.name); + } + break; + case "Tab": + case "Escape": + setIsOpen(false); + break; + case "ArrowUp": + case "ArrowDown": + event.preventDefault(); + handleMenuArrowKeys(event.key); + break; + } + }; + + const onToggleClick = () => { + setIsOpen(!isOpen); + }; + + const onTextInputChange = (_event, value) => { + setInputValue(value); + }; + + const toggle = (toggleRef) => ( + + + + + {diskSelection.selectedDisks.map((selection, index) => ( + { + ev.stopPropagation(); + onSelect(selection); + }} + > + {selection} + + ))} + + + + {diskSelection.selectedDisks.length > 0 && ( + + )} + {diskSelectionInProgress && } + + + + ); + + return ( + + ); +}; + +const rescanDisks = (setIsRescanningDisks, refUsableDisks, dispatch, errorHandler, setIsFormDisabled) => { + setIsRescanningDisks(true); + setIsFormDisabled(true); + refUsableDisks.current = undefined; + scanDevicesWithTask() + .then(res => { + return runStorageTask({ + task: res[0], + onSuccess: () => resetPartitioning() + .then(() => Promise.all([ + dispatch(getDevicesAction()), + dispatch(getDiskSelectionAction()) + ])) + .finally(() => { + setIsFormDisabled(false); + setIsRescanningDisks(false); + }) + .catch(errorHandler), + onFail: exc => { + setIsFormDisabled(false); + setIsRescanningDisks(false); + errorHandler(exc); + } + }); + }); +}; + +export const InstallationDestination = ({ + deviceData, + diskSelection, + dispatch, + idPrefix, + isFormDisabled, + setIsFormValid, + setIsFormDisabled, + onRescanDisks, + onCritFail +}) => { + const [isRescanningDisks, setIsRescanningDisks] = useState(false); + const [equalDisksNotify, setEqualDisksNotify] = useState(false); + const refUsableDisks = useRef(); + const isBootIso = useContext(SystemTypeContext) === "BOOT_ISO"; + + debug("DiskSelector: deviceData: ", JSON.stringify(Object.keys(deviceData)), ", diskSelection: ", JSON.stringify(diskSelection)); + + useEffect(() => { + if (isRescanningDisks && refUsableDisks.current === undefined) { + refUsableDisks.current = diskSelection.usableDisks; + setEqualDisksNotify(true); + } + }, [isRescanningDisks, diskSelection.usableDisks]); + + useEffect(() => { + // Select default disks for the partitioning on component mount + if (refUsableDisks.current !== undefined) { + return; + } + + const defaultDisks = selectDefaultDisks({ + ignoredDisks: diskSelection.ignoredDisks, + selectedDisks: diskSelection.selectedDisks, + usableDisks: diskSelection.usableDisks, + }); + + if (!checkIfArraysAreEqual(diskSelection.selectedDisks, defaultDisks)) { + setSelectedDisks({ drives: defaultDisks }); + } + }, [diskSelection]); + + const selectedDisksCnt = diskSelection.selectedDisks.length; + + useEffect(() => { + setIsFormValid(selectedDisksCnt > 0); + }, [selectedDisksCnt, setIsFormValid]); + + const loading = !deviceData || diskSelection.usableDisks.some(disk => !deviceData[disk]); + + const rescanErrorHandler = onCritFail({ + context: N_("Rescanning of the disks failed.") + }); + const onClickRescan = () => rescanDisks( + setIsRescanningDisks, + refUsableDisks, + dispatch, + rescanErrorHandler, + setIsFormDisabled, + ); + + const rescanDisksButton = ( + + ); + + const localDisksSelect = ( + + ); + + const equalDisks = refUsableDisks.current && checkIfArraysAreEqual(refUsableDisks.current, diskSelection.usableDisks); + const headingLevel = isBootIso ? "h2" : "h3"; + + return ( + <> + {_("Destination")} + {equalDisksNotify && equalDisks && + { setEqualDisksNotify(false) }} />} + />} + + + {(diskSelection.usableDisks.length > 1 || (diskSelection.usableDisks.length === 1 && diskSelection.selectedDisks.length === 0)) + ? localDisksSelect + : ( + diskSelection.usableDisks.length === 1 && diskSelection.selectedDisks.length === 1 + ? ( + + + {cockpit.format( + _("Installing to $0 ($1)"), + deviceData[diskSelection.selectedDisks[0]]?.description.v, + diskSelection.selectedDisks[0] + )} + + + {cockpit.format_bytes(deviceData[diskSelection.selectedDisks[0]]?.total.v)} + + + ) + : _("No usable disks detected") + )} + {rescanDisksButton} + {!isBootIso && } + + + + ); +}; diff --git a/ui/webui/src/components/storage/InstallationMethod.scss b/ui/webui/src/components/storage/InstallationDestination.scss similarity index 50% rename from ui/webui/src/components/storage/InstallationMethod.scss rename to ui/webui/src/components/storage/InstallationDestination.scss index 056a3a97f04..b8dd5dde587 100644 --- a/ui/webui/src/components/storage/InstallationMethod.scss +++ b/ui/webui/src/components/storage/InstallationDestination.scss @@ -12,15 +12,3 @@ color: var(--pf-v5-global--Color--200); font-size: var(--pf-v5-global--FontSize--sm); } - -.installation-method-scenario { - .pf-v5-c-radio__body { - margin-top: 0; - font-size: var(--pf-v5-global--FontSize--sm); - - .installation-method-scenario-disabled-reason { - font-weight: var(--pf-v5-global--FontWeight--bold); - margin-inline-end: var(--pf-v5-global--spacer--xs); - } - } -} diff --git a/ui/webui/src/components/storage/InstallationMethod.jsx b/ui/webui/src/components/storage/InstallationMethod.jsx index 757c5f94543..1c9a845db84 100644 --- a/ui/webui/src/components/storage/InstallationMethod.jsx +++ b/ui/webui/src/components/storage/InstallationMethod.jsx @@ -15,438 +15,25 @@ * along with This program; If not, see . */ import cockpit from "cockpit"; -import React, { useEffect, useRef, useState } from "react"; +import React from "react"; import { Alert, - AlertActionCloseButton, - Button, - Chip, - ChipGroup, - Flex, - FlexItem, Form, - FormGroup, - MenuToggle, - Select, - SelectList, - SelectOption, - Spinner, - TextInputGroup, - TextInputGroupMain, - TextInputGroupUtilities, - Title, } from "@patternfly/react-core"; -import { SyncAltIcon, TimesIcon } from "@patternfly/react-icons"; import { InstallationScenario } from "./InstallationScenario.jsx"; -import { ModifyStorage } from "./ModifyStorage.jsx"; - -import { - resetPartitioning, - runStorageTask, - scanDevicesWithTask, - setSelectedDisks, -} from "../../apis/storage.js"; - -import { getDevicesAction, getDiskSelectionAction } from "../../actions/storage-actions.js"; -import { debug } from "../../helpers/log.js"; -import { checkIfArraysAreEqual } from "../../helpers/utils.js"; - -import "./InstallationMethod.scss"; +import { InstallationDestination } from "./InstallationDestination.jsx"; const _ = cockpit.gettext; -const N_ = cockpit.noop; - -/** - * Select default disks for the partitioning. - * - * If there are some usable disks already selected, show these. - * In the automatic installation, select all disks. In - * the interactive installation, select a disk if there - * is only one available. - * @return: the list of selected disks - */ -const selectDefaultDisks = ({ ignoredDisks, selectedDisks, usableDisks }) => { - if (selectedDisks.length && selectedDisks.some(disk => usableDisks.includes(disk))) { - // Filter the selection by checking the usable disks if there are some disks selected - console.log("Selecting disks selected in backend:", selectedDisks.join(",")); - return selectedDisks.filter(disk => usableDisks.includes(disk)); - } else { - const availableDisks = usableDisks.filter(disk => !ignoredDisks.includes(disk)); - console.log("Selecting one or less disks by default:", availableDisks.join(",")); - - // Select a usable disk if there is only one available - if (availableDisks.length === 1) { - return availableDisks; - } - return []; - } -}; - -const LocalDisksSelect = ({ deviceData, diskSelection, idPrefix, isDisabled, setSelectedDisks }) => { - const [isOpen, setIsOpen] = useState(false); - const [inputValue, setInputValue] = useState(""); - const [focusedItemIndex, setFocusedItemIndex] = useState(null); - const [diskSelectionInProgress, setDiskSelectionInProgress] = useState(false); - const textInputRef = useRef(); - - useEffect(() => { - setDiskSelectionInProgress(false); - }, [diskSelection.selectedDisks]); - - let selectOptions = diskSelection.usableDisks - .map(disk => ({ - description: deviceData[disk]?.description.v, - name: disk, - size: cockpit.format_bytes(deviceData[disk]?.total.v), - value: disk, - })) - .filter(option => - String(option.name) - .toLowerCase() - .includes(inputValue.toLowerCase()) || - String(option.description) - .toLowerCase() - .includes(inputValue.toLowerCase()) - ); - - if (selectOptions.length === 0) { - selectOptions = [ - { children: _("No results found"), value: "no results" } - ]; - } - - const onSelect = (selectedDisk) => { - setDiskSelectionInProgress(true); - - if (diskSelection.selectedDisks.includes(selectedDisk)) { - setSelectedDisks({ drives: diskSelection.selectedDisks.filter(disk => disk !== selectedDisk) }); - } else { - setSelectedDisks({ drives: [...diskSelection.selectedDisks, selectedDisk] }); - } - textInputRef.current?.focus(); - setIsOpen(false); - }; - - const clearSelection = () => { - setSelectedDisks({ drives: [] }); - }; - - const handleMenuArrowKeys = (key) => { - let indexToFocus; - - if (isOpen) { - if (key === "ArrowUp") { - // When no index is set or at the first index, focus to the last, otherwise decrement focus index - if (focusedItemIndex === null || focusedItemIndex === 0) { - indexToFocus = selectOptions.length - 1; - } else { - indexToFocus = focusedItemIndex - 1; - } - } - - if (key === "ArrowDown") { - // When no index is set or at the last index, focus to the first, otherwise increment focus index - if (focusedItemIndex === null || focusedItemIndex === selectOptions.length - 1) { - indexToFocus = 0; - } else { - indexToFocus = focusedItemIndex + 1; - } - } - - setFocusedItemIndex(indexToFocus); - } - }; - - const onInputKeyDown = (event) => { - const enabledMenuItems = selectOptions.filter((menuItem) => !menuItem.isDisabled); - const [firstMenuItem] = enabledMenuItems; - const focusedItem = focusedItemIndex ? enabledMenuItems[focusedItemIndex] : firstMenuItem; - - switch (event.key) { - // Select the first available option - case "Enter": - if (!isOpen) { - setIsOpen((prevIsOpen) => !prevIsOpen); - } else if (focusedItem.name !== "no results") { - onSelect(focusedItem.name); - } - break; - case "Tab": - case "Escape": - setIsOpen(false); - break; - case "ArrowUp": - case "ArrowDown": - event.preventDefault(); - handleMenuArrowKeys(event.key); - break; - } - }; - - const onToggleClick = () => { - setIsOpen(!isOpen); - }; - - const onTextInputChange = (_event, value) => { - setInputValue(value); - }; - - const toggle = (toggleRef) => ( - - - - - {diskSelection.selectedDisks.map((selection, index) => ( - { - ev.stopPropagation(); - onSelect(selection); - }} - > - {selection} - - ))} - - - - {diskSelection.selectedDisks.length > 0 && ( - - )} - {diskSelectionInProgress && } - - - - ); - - return ( - - ); -}; - -const rescanDisks = (setIsRescanningDisks, refUsableDisks, dispatch, errorHandler, setIsFormDisabled) => { - setIsRescanningDisks(true); - setIsFormDisabled(true); - refUsableDisks.current = undefined; - scanDevicesWithTask() - .then(res => { - return runStorageTask({ - task: res[0], - onSuccess: () => resetPartitioning() - .then(() => Promise.all([ - dispatch(getDevicesAction()), - dispatch(getDiskSelectionAction()) - ])) - .finally(() => { - setIsFormDisabled(false); - setIsRescanningDisks(false); - }) - .catch(errorHandler), - onFail: exc => { - setIsFormDisabled(false); - setIsRescanningDisks(false); - errorHandler(exc); - } - }); - }); -}; - -const InstallationDestination = ({ - deviceData, - diskSelection, - dispatch, - idPrefix, - isBootIso, - isFormDisabled, - setIsFormValid, - setIsFormDisabled, - onRescanDisks, - onCritFail -}) => { - const [isRescanningDisks, setIsRescanningDisks] = useState(false); - const [equalDisksNotify, setEqualDisksNotify] = useState(false); - const refUsableDisks = useRef(); - - debug("DiskSelector: deviceData: ", JSON.stringify(Object.keys(deviceData)), ", diskSelection: ", JSON.stringify(diskSelection)); - - useEffect(() => { - if (isRescanningDisks && refUsableDisks.current === undefined) { - refUsableDisks.current = diskSelection.usableDisks; - setEqualDisksNotify(true); - } - }, [isRescanningDisks, diskSelection.usableDisks]); - - useEffect(() => { - // Select default disks for the partitioning on component mount - if (refUsableDisks.current !== undefined) { - return; - } - - const defaultDisks = selectDefaultDisks({ - ignoredDisks: diskSelection.ignoredDisks, - selectedDisks: diskSelection.selectedDisks, - usableDisks: diskSelection.usableDisks, - }); - - if (!checkIfArraysAreEqual(diskSelection.selectedDisks, defaultDisks)) { - setSelectedDisks({ drives: defaultDisks }); - } - }, [diskSelection]); - - const selectedDisksCnt = diskSelection.selectedDisks.length; - - useEffect(() => { - setIsFormValid(selectedDisksCnt > 0); - }, [selectedDisksCnt, setIsFormValid]); - - const loading = !deviceData || diskSelection.usableDisks.some(disk => !deviceData[disk]); - - const rescanErrorHandler = onCritFail({ - context: N_("Rescanning of the disks failed.") - }); - const onClickRescan = () => rescanDisks( - setIsRescanningDisks, - refUsableDisks, - dispatch, - rescanErrorHandler, - setIsFormDisabled, - ); - - const rescanDisksButton = ( - - ); - - const localDisksSelect = ( - - ); - - const equalDisks = refUsableDisks.current && checkIfArraysAreEqual(refUsableDisks.current, diskSelection.usableDisks); - const headingLevel = isBootIso ? "h2" : "h3"; - - return ( - <> - {_("Destination")} - {equalDisksNotify && equalDisks && - { setEqualDisksNotify(false) }} />} - />} - - - {(diskSelection.usableDisks.length > 1 || (diskSelection.usableDisks.length === 1 && diskSelection.selectedDisks.length === 0)) - ? localDisksSelect - : ( - diskSelection.usableDisks.length === 1 && diskSelection.selectedDisks.length === 1 - ? ( - - - {cockpit.format( - _("Installing to $0 ($1)"), - deviceData[diskSelection.selectedDisks[0]]?.description.v, - diskSelection.selectedDisks[0] - )} - - - {cockpit.format_bytes(deviceData[diskSelection.selectedDisks[0]]?.total.v)} - - - ) - : _("No usable disks detected") - )} - {rescanDisksButton} - - - - - ); -}; export const InstallationMethod = ({ deviceData, diskSelection, dispatch, idPrefix, - isBootIso, isFormDisabled, onCritFail, - osRelease, setIsFormValid, setIsFormDisabled, setStorageScenarioId, @@ -454,43 +41,39 @@ export const InstallationMethod = ({ storageScenarioId, }) => { return ( - <> -
{ e.preventDefault(); return false }} - > - {stepNotification && (stepNotification.step === "installation-method") && - } - - - - +
{ e.preventDefault(); return false }} + > + {stepNotification?.step === "installation-method" && + } + + + ); }; diff --git a/ui/webui/src/components/storage/InstallationScenario.jsx b/ui/webui/src/components/storage/InstallationScenario.jsx index 818f4b4740c..f8b2b13bc50 100644 --- a/ui/webui/src/components/storage/InstallationScenario.jsx +++ b/ui/webui/src/components/storage/InstallationScenario.jsx @@ -16,7 +16,7 @@ */ import cockpit from "cockpit"; -import React, { useState, useEffect } from "react"; +import React, { useContext, useState, useEffect } from "react"; import { FormGroup, @@ -24,6 +24,7 @@ import { Title, } from "@patternfly/react-core"; +import { SystemTypeContext } from "../Common.jsx"; import { helpEraseAll, helpUseFreeSpace, helpMountPointMapping } from "./HelpAutopartOptions.jsx"; import { findDuplicatesInArray } from "../../helpers/utils.js"; @@ -39,6 +40,8 @@ import { getRequiredSpace, } from "../../apis/payloads"; +import "./InstallationScenario.scss"; + const _ = cockpit.gettext; const N_ = cockpit.noop; @@ -278,7 +281,8 @@ const InstallationScenarioSelector = ({ deviceData, selectedDisks, idPrefix, isF return scenarioItems; }; -export const InstallationScenario = ({ deviceData, diskSelection, idPrefix, isFormDisabled, onCritFail, setIsFormValid, storageScenarioId, setStorageScenarioId, isBootIso }) => { +export const InstallationScenario = ({ deviceData, diskSelection, idPrefix, isFormDisabled, onCritFail, setIsFormValid, storageScenarioId, setStorageScenarioId }) => { + const isBootIso = useContext(SystemTypeContext) === "BOOT_ISO"; const headingLevel = isBootIso ? "h2" : "h3"; return ( diff --git a/ui/webui/src/components/storage/InstallationScenario.scss b/ui/webui/src/components/storage/InstallationScenario.scss new file mode 100644 index 00000000000..c8c44b3942b --- /dev/null +++ b/ui/webui/src/components/storage/InstallationScenario.scss @@ -0,0 +1,11 @@ +.installation-method-scenario { + .pf-v5-c-radio__body { + margin-top: 0; + font-size: var(--pf-v5-global--FontSize--sm); + + .installation-method-scenario-disabled-reason { + font-weight: var(--pf-v5-global--FontWeight--bold); + margin-inline-end: var(--pf-v5-global--spacer--xs); + } + } +} diff --git a/ui/webui/src/components/storage/ModifyStorage.jsx b/ui/webui/src/components/storage/ModifyStorage.jsx index ec73071a69e..a731685f9fe 100644 --- a/ui/webui/src/components/storage/ModifyStorage.jsx +++ b/ui/webui/src/components/storage/ModifyStorage.jsx @@ -128,13 +128,9 @@ const ModifyStorageModal = ({ onClose, onToolStarted, errorHandler }) => { ); }; -export const ModifyStorage = ({ idPrefix, isBootIso, onCritFail, onRescan }) => { +export const ModifyStorage = ({ idPrefix, onCritFail, onRescan }) => { const [openedDialog, setOpenedDialog] = useState(""); - if (isBootIso) { - return null; - } - return ( <>