diff --git a/web/src/client/storage.js b/web/src/client/storage.js index 55623d8c36..8649ba9395 100644 --- a/web/src/client/storage.js +++ b/web/src/client/storage.js @@ -451,6 +451,35 @@ class ProposalManager { return proxy.AvailableDevices.map(path => findDevice(systemDevices, path)).filter(d => d); } + /** + * Gets the devices that can be selected as target for a volume. + * + * @returns {Promise} + */ + async getVolumeDevices() { + const availableDevices = await this.getAvailableDevices(); + + const isAvailable = (device) => { + const isChildren = (device, parentDevice) => { + const partitions = parentDevice.partitionTable?.partitions || []; + return !!partitions.find(d => d.name === device.name); + }; + + return ( + !!availableDevices.find(d => d.name === device.name) || + !!availableDevices.find(d => isChildren(device, d)) + ); + }; + + const allAvailable = (devices) => devices.every(isAvailable); + + const system = await this.system.getDevices(); + const mds = system.filter(d => d.type === "md" && allAvailable(d.devices)); + const vgs = system.filter(d => d.type === "lvmVg" && allAvailable(d.physicalVolumes)); + + return [...availableDevices, ...mds, ...vgs]; + } + /** * Gets the list of meaningful mount points for the selected product * diff --git a/web/src/components/core/ExpandableSelector.jsx b/web/src/components/core/ExpandableSelector.jsx index f81df3f271..af98528c71 100644 --- a/web/src/components/core/ExpandableSelector.jsx +++ b/web/src/components/core/ExpandableSelector.jsx @@ -112,7 +112,7 @@ const sanitizeSelection = (selection, allowMultiple) => { * @property {(item: object) => boolean} [itemSelectable=() => true] - Whether an item will be selectable or not. * @property {(item: object) => (string|undefined)} [itemClassNames=() => ""] - Callback that allows adding additional CSS class names to item row. * @property {object[]} [itemsSelected=[]] - Collection of selected items. - * @property {string[]} [initialExpandedKeys=[]] - Ids of initially expanded items. + * @property {any[]} [initialExpandedKeys=[]] - Ids of initially expanded items. * @property {(selection: Array) => void} [onSelectionChange=noop] - Callback to be triggered when selection changes. * * @typedef {ExpandableSelectorBaseProps & TableProps & HTMLTableProps} ExpandableSelectorProps diff --git a/web/src/components/storage/BootConfigField.jsx b/web/src/components/storage/BootConfigField.jsx index b62ccf14fb..3742f1971d 100644 --- a/web/src/components/storage/BootConfigField.jsx +++ b/web/src/components/storage/BootConfigField.jsx @@ -60,7 +60,7 @@ const Button = ({ isBold = false, onClick }) => { * @param {boolean} props.configureBoot * @param {StorageDevice|undefined} props.bootDevice * @param {StorageDevice|undefined} props.defaultBootDevice - * @param {StorageDevice[]} props.devices + * @param {StorageDevice[]} props.availableDevices * @param {boolean} props.isLoading * @param {(boot: BootConfig) => void} props.onChange * @@ -72,7 +72,7 @@ export default function BootConfigField({ configureBoot, bootDevice, defaultBootDevice, - devices, + availableDevices, isLoading, onChange }) { @@ -113,7 +113,7 @@ export default function BootConfigField({ configureBoot={configureBoot} bootDevice={bootDevice} defaultBootDevice={defaultBootDevice} - devices={devices} + availableDevices={availableDevices} onAccept={onAccept} onCancel={closeDialog} /> diff --git a/web/src/components/storage/BootSelectionDialog.jsx b/web/src/components/storage/BootSelectionDialog.jsx index 006cb54d9e..565ddfa2f8 100644 --- a/web/src/components/storage/BootSelectionDialog.jsx +++ b/web/src/components/storage/BootSelectionDialog.jsx @@ -66,7 +66,7 @@ const RadioOption = ({ id, onChange, defaultChecked, children }) => { * @param {boolean} props.configureBoot - Whether the boot is configurable * @param {StorageDevice|undefined} props.bootDevice - Currently selected booting device. * @param {StorageDevice|undefined} props.defaultBootDevice - Default booting device. - * @param {StorageDevice[]} props.devices - Devices that user can select to boot from. + * @param {StorageDevice[]} props.availableDevices - Devices that user can select to boot from. * @param {boolean} [props.isOpen=false] - Whether the dialog is visible or not. * @param {function} [props.onCancel=noop] * @param {(boot: Boot) => void} [props.onAccept=noop] @@ -75,7 +75,7 @@ export default function BootSelectionDialog({ configureBoot: configureBootProp, bootDevice: bootDeviceProp, defaultBootDevice, - devices, + availableDevices, isOpen, onCancel = noop, onAccept = noop, @@ -161,7 +161,7 @@ partitions in the appropriate disk." device.isDrive || device.type === "lvmVg"; + const isTarget = (device) => device.isDrive || ["md", "lvmVg"].includes(device.type); // Check in system devices to detect removals. const targetSystem = this.system.filter(isTarget); diff --git a/web/src/components/storage/PartitionsField.jsx b/web/src/components/storage/PartitionsField.jsx index ced0f31d57..3967f4bb0b 100644 --- a/web/src/components/storage/PartitionsField.jsx +++ b/web/src/components/storage/PartitionsField.jsx @@ -328,21 +328,21 @@ const VolumeActions = ({ volume, onEdit, onResetLocation, onLocation, onDelete } * @param {Volume} [props.volume] - Volume to show * @param {Volume[]} [props.volumes] - List of current volumes * @param {Volume[]} [props.templates] - List of available templates - * @param {StorageDevice[]} [props.devices=[]] - Devices available for installation - * @param {ProposalTarget} [props.target] - Installation target - * @param {StorageDevice} [props.targetDevice] - Device selected for installation, if target is a disk + * @param {StorageDevice[]} [props.volumeDevices=[]] - Devices available for installation + * @param {ProposalTarget} [props.target] + * @param {StorageDevice[]} [props.targetDevices] - Device selected for installation, if target is a disk * @param {boolean} props.isLoading - Whether to show the row as loading * @param {(volume: Volume) => void} [props.onEdit=noop] - Function to use for editing the volume - * @param {(volume: Volume) => void} [props.onDelete=noop] - Function to use for deleting the volume + * @param {() => void} [props.onDelete=noop] - Function to use for deleting the volume */ const VolumeRow = ({ columns, volume, volumes, templates, - devices, + volumeDevices, target, - targetDevice, + targetDevices, isLoading, onEdit = noop, onDelete = noop @@ -412,9 +412,9 @@ const VolumeRow = ({ @@ -431,18 +431,18 @@ const VolumeRow = ({ * @param {object} props * @param {Volume[]} props.volumes - Volumes to show * @param {Volume[]} props.templates - List of available templates - * @param {StorageDevice[]} props.devices - Devices available for installation - * @param {ProposalTarget} props.target - Installation target - * @param {StorageDevice|undefined} props.targetDevice - Device selected for installation, if target is a disk + * @param {StorageDevice[]} props.volumeDevices + * @param {ProposalTarget} props.target + * @param {StorageDevice[]} props.targetDevices * @param {boolean} props.isLoading - Whether to show the table as loading * @param {(volumes: Volume[]) => void} props.onVolumesChange - Function to submit changes in volumes */ const VolumesTable = ({ volumes, templates, - devices, + volumeDevices, target, - targetDevice, + targetDevices, isLoading, onVolumesChange }) => { @@ -481,9 +481,9 @@ const VolumesTable = ({ volume={volume} volumes={volumes} templates={templates} - devices={devices} + volumeDevices={volumeDevices} target={target} - targetDevice={targetDevice} + targetDevices={targetDevices} isLoading={isLoading} onEdit={editVolume} onDelete={() => deleteVolume(volume)} @@ -608,9 +608,10 @@ const AddVolumeButton = ({ options, onClick }) => { * @param {object} props * @param {Volume[]} props.volumes * @param {Volume[]} props.templates - * @param {StorageDevice[]} props.devices + * @param {StorageDevice[]} props.availableDevices + * @param {StorageDevice[]} props.volumeDevices * @param {ProposalTarget} props.target - * @param {StorageDevice|undefined} props.targetDevice + * @param {StorageDevice[]} props.targetDevices * @param {boolean} props.configureBoot * @param {StorageDevice|undefined} props.bootDevice * @param {StorageDevice|undefined} props.defaultBootDevice @@ -621,9 +622,10 @@ const AddVolumeButton = ({ options, onClick }) => { const Advanced = ({ volumes, templates, - devices, + availableDevices, + volumeDevices, target, - targetDevice, + targetDevices, configureBoot, bootDevice, defaultBootDevice, @@ -713,9 +715,9 @@ const Advanced = ({ @@ -744,7 +746,7 @@ const Advanced = ({ configureBoot={configureBoot} bootDevice={bootDevice} defaultBootDevice={defaultBootDevice} - devices={devices} + availableDevices={availableDevices} isLoading={isLoading} onChange={onBootChange} /> @@ -762,9 +764,10 @@ const Advanced = ({ * @typedef {object} PartitionsFieldProps * @property {Volume[]} volumes - Volumes to show * @property {Volume[]} templates - Templates to use for new volumes - * @property {StorageDevice[]} devices - Devices available for installation + * @property {StorageDevice[]} availableDevices - Devices available for installation + * @property {StorageDevice[]} volumeDevices * @property {ProposalTarget} target - Installation target - * @property {StorageDevice|undefined} targetDevice - Device selected for installation, if target is a disk + * @property {StorageDevice[]} targetDevices * @property {boolean} configureBoot - Whether to configure boot partitions. * @property {StorageDevice|undefined} bootDevice - Device to use for creating boot partitions. * @property {StorageDevice|undefined} defaultBootDevice - Default device for boot partitions if no device has been indicated yet. @@ -781,9 +784,10 @@ const Advanced = ({ export default function PartitionsField({ volumes, templates, - devices, + availableDevices, + volumeDevices, target, - targetDevice, + targetDevices, configureBoot, bootDevice, defaultBootDevice, @@ -807,9 +811,10 @@ export default function PartitionsField({ { return { ...state, availableDevices }; } + case "UPDATE_VOLUME_DEVICES": { + const { volumeDevices } = action.payload; + return { ...state, volumeDevices }; + } + case "UPDATE_ENCRYPTION_METHODS": { const { encryptionMethods } = action.payload; return { ...state, encryptionMethods }; @@ -105,6 +111,10 @@ export default function ProposalPage() { return await cancellablePromise(client.proposal.getAvailableDevices()); }, [client, cancellablePromise]); + const loadVolumeDevices = useCallback(async () => { + return await cancellablePromise(client.proposal.getVolumeDevices()); + }, [client, cancellablePromise]); + const loadEncryptionMethods = useCallback(async () => { return await cancellablePromise(client.proposal.getEncryptionMethods()); }, [client, cancellablePromise]); @@ -153,6 +163,9 @@ export default function ProposalPage() { const availableDevices = await loadAvailableDevices(); dispatch({ type: "UPDATE_AVAILABLE_DEVICES", payload: { availableDevices } }); + const volumeDevices = await loadVolumeDevices(); + dispatch({ type: "UPDATE_VOLUME_DEVICES", payload: { volumeDevices } }); + const encryptionMethods = await loadEncryptionMethods(); dispatch({ type: "UPDATE_ENCRYPTION_METHODS", payload: { encryptionMethods } }); @@ -169,7 +182,7 @@ export default function ProposalPage() { dispatch({ type: "UPDATE_ERRORS", payload: { errors } }); if (result !== undefined) dispatch({ type: "STOP_LOADING" }); - }, [calculateProposal, cancellablePromise, client, loadAvailableDevices, loadDevices, loadEncryptionMethods, loadErrors, loadProposalResult, loadVolumeTemplates]); + }, [calculateProposal, cancellablePromise, client, loadAvailableDevices, loadVolumeDevices, loadDevices, loadEncryptionMethods, loadErrors, loadProposalResult, loadVolumeTemplates]); const calculate = useCallback(async (settings) => { dispatch({ type: "START_LOADING" }); @@ -231,6 +244,7 @@ export default function ProposalPage() { /> { * @param {TableItem} props.item * @param {DevicesManager} props.devicesManager */ -const ExtendedDeviceDetails = ({ item, devicesManager }) => { +const DeviceCustomDetails = ({ item, devicesManager }) => { const isNew = () => { const device = toStorageDevice(item); if (!device) return false; @@ -83,7 +83,7 @@ const ExtendedDeviceDetails = ({ item, devicesManager }) => { * @param {TableItem} props.item * @param {DevicesManager} props.devicesManager */ -const ExtendedDeviceSize = ({ item, devicesManager }) => { +const DeviceCustomSize = ({ item, devicesManager }) => { const device = toStorageDevice(item); const isResized = device && devicesManager.isShrunk(device); const sizeBefore = isResized ? devicesManager.systemDevice(device.sid).size : item.size; @@ -121,12 +121,12 @@ const columns = (devicesManager) => { /** @type {() => (item: TableItem) => React.ReactNode} */ const detailsRender = () => { - return (item) => ; + return (item) => ; }; /** @type {() => (item: TableItem) => React.ReactNode} */ const sizeRender = () => { - return (item) => ; + return (item) => ; }; return [ diff --git a/web/src/components/storage/ProposalSettingsSection.jsx b/web/src/components/storage/ProposalSettingsSection.jsx index 37bf0669ac..822e14efa2 100644 --- a/web/src/components/storage/ProposalSettingsSection.jsx +++ b/web/src/components/storage/ProposalSettingsSection.jsx @@ -47,6 +47,7 @@ import SpacePolicyField from "~/components/storage/SpacePolicyField"; * @typedef {object} ProposalSettingsSectionProps * @property {ProposalSettings} settings * @property {StorageDevice[]} availableDevices + * @property {StorageDevice[]} volumeDevices * @property {String[]} encryptionMethods * @property {Volume[]} volumeTemplates * @property {boolean} [isLoading=false] @@ -57,6 +58,7 @@ import SpacePolicyField from "~/components/storage/SpacePolicyField"; export default function ProposalSettingsSection({ settings, availableDevices, + volumeDevices, encryptionMethods, volumeTemplates, isLoading = false, @@ -112,6 +114,8 @@ export default function ProposalSettingsSection({ const defaultBootDevice = findDevice(settings.defaultBootDevice); const spacePolicy = SPACE_POLICIES.find(p => p.id === settings.spacePolicy); + const targetDevices = compact([targetDevice, ...targetPVDevices]); + return ( <>
@@ -133,9 +137,10 @@ export default function ProposalSettingsSection({ VolumeTarget} */ +const defaultTarget = (device) => { + if (["partition", "lvmLv", "md"].includes(device.type)) return "DEVICE"; -/** - * Generates a location option value from the given target. - * @function - * - * @param {VolumeTarget} target - * @returns {LocationOption} - */ -const targetToOption = (target) => { - switch (target) { - case "DEFAULT": - return "auto"; - case "NEW_PARTITION": - case "NEW_VG": - return "device"; - case "DEVICE": - case "FILESYSTEM": - return "reuse"; + return "NEW_PARTITION"; +}; + +/** @type {(volume: Volume, device: StorageDevice) => VolumeTarget[]} */ +const availableTargets = (volume, device) => { + /** @type {VolumeTarget[]} */ + const targets = ["DEVICE"]; + + if (device.isDrive) { + targets.push("NEW_PARTITION"); + targets.push("NEW_VG"); } + + /** @fixme define type for possible fstypes */ + const fsTypes = volume.outline.fsTypes.map(f => f.toLowerCase()); + if (device.filesystem && fsTypes.includes(device.filesystem.type)) + targets.push("FILESYSTEM"); + + return targets; }; -/** - * Internal component for building the options. - * @component - * - * @param {React.PropsWithChildren>} props - */ -const RadioOption = ({ id, onChange, defaultChecked, children }) => { - return ( - <> - - - - ); +/** @type {(volume: Volume, device: StorageDevice) => VolumeTarget} */ +const sanitizeTarget = (volume, device) => { + const targets = availableTargets(volume, device); + return targets.includes(volume.target) ? volume.target : defaultTarget(device); }; /** @@ -80,10 +73,10 @@ const RadioOption = ({ id, onChange, defaultChecked, children }) => { * @component * * @typedef {object} VolumeLocationDialogProps - * @property {Volume} volume - Volume to edit. - * @property {StorageDevice[]} devices - Devices available for installation. - * @property {ProposalTarget} target - Installation target. - * @property {StorageDevice|undefined} targetDevice - Device selected for installation, if target is a disk. + * @property {Volume} volume + * @property {Volume[]} volumes + * @property {StorageDevice[]} volumeDevices + * @property {StorageDevice[]} targetDevices * @property {boolean} [isOpen=false] - Whether the dialog is visible or not. * @property {() => void} onCancel * @property {(volume: Volume) => void} onAccept @@ -92,105 +85,110 @@ const RadioOption = ({ id, onChange, defaultChecked, children }) => { */ export default function VolumeLocationDialog({ volume, - devices, - target, - targetDevice: defaultTargetDevice, + volumes, + volumeDevices, + targetDevices, isOpen, onCancel, onAccept, ...props }) { - const [locationOption, setLocationOption] = useState(targetToOption(volume.target)); - const [targetDevice, setTargetDevice] = useState(volume.targetDevice || defaultTargetDevice || devices[0]); - const [isDedicatedVG, setIsDedicatedVG] = useState(volume.target === "NEW_VG"); - - const selectAutoOption = () => setLocationOption("auto"); - const selectDeviceOption = () => setLocationOption("device"); - const toggleDedicatedVG = (_, value) => setIsDedicatedVG(value); - - const isLocationAuto = locationOption === "auto"; - const isLocationDevice = locationOption === "device"; + const initialDevice = volume.targetDevice || targetDevices[0] || volumeDevices[0]; + const initialTarget = sanitizeTarget(volume, initialDevice); - const onSubmit = (e) => { - e.preventDefault(); - const newVolume = { ...volume }; + const [target, setTarget] = useState(initialTarget); + const [targetDevice, setTargetDevice] = useState(initialDevice); - if (isLocationAuto) { - newVolume.target = "DEFAULT"; - newVolume.targetDevice = undefined; - } + const changeTargetDevice = (devices) => { + const newTargetDevice = devices[0]; - if (isLocationDevice) { - newVolume.target = isDedicatedVG ? "NEW_VG" : "NEW_PARTITION"; - newVolume.targetDevice = targetDevice; + if (newTargetDevice.name !== targetDevice.name) { + setTarget(defaultTarget(newTargetDevice)); + setTargetDevice(newTargetDevice); } + }; + const onSubmit = (e) => { + e.preventDefault(); + const newVolume = { ...volume, target, targetDevice }; onAccept(newVolume); }; const isAcceptDisabled = () => { - return isLocationDevice && targetDevice === undefined; + return false; }; - const autoText = () => { - if (target === "DISK" && defaultTargetDevice) - // TRANSLATORS: %s is replaced by a device label (e.g., "/dev/vda, 50 GiB"). - return sprintf(_("The filesystem will be allocated as a new partition at the installation \ -disk (%s)."), deviceLabel(defaultTargetDevice)); - - if (target === "DISK") - return _("The filesystem will be allocated as a new partition at the installation disk."); - - return _("The file system will be allocated as a logical volume at the system LVM."); + /** @type {(device: StorageDevice) => boolean} */ + const isDeviceSelectable = (device) => { + return device.isDrive || ["md", "partition", "lvmLv"].includes(device.type); }; + const targets = availableTargets(volume, targetDevice); + return (
-
- - - {_("Automatic")} - - -
- {autoText()} -
-
- -
- - - {_("Select a disk")} - - - -
-
- {_("The file system will be allocated as a new partition at the selected disk.")} -
- + d.sid)} + variant="compact" + /> + + setTarget("NEW_PARTITION")} /> - setTarget("NEW_VG")} + /> + setTarget("DEVICE")} + /> + setTarget("FILESYSTEM")} /> -
-
+ +
diff --git a/web/src/components/storage/VolumeLocationSelectorTable.jsx b/web/src/components/storage/VolumeLocationSelectorTable.jsx new file mode 100644 index 0000000000..c9a8db2604 --- /dev/null +++ b/web/src/components/storage/VolumeLocationSelectorTable.jsx @@ -0,0 +1,101 @@ +/* + * Copyright (c) [2024] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * 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 General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +// @ts-check + +import React from "react"; +import { Chip } from '@patternfly/react-core'; + +import { _ } from "~/i18n"; +import { + DeviceName, DeviceDetails, DeviceSize, toStorageDevice +} from "~/components/storage/device-utils"; +import { ExpandableSelector } from "~/components/core"; + +/** + * @typedef {import("../core/ExpandableSelector").ExpandableSelectorColumn} ExpandableSelectorColumn + * @typedef {import("../core/ExpandableSelector").ExpandableSelectorProps} ExpandableSelectorProps + * @typedef {import ("~/client/storage").StorageDevice} StorageDevice + * @typedef {import ("~/client/storage").Volume} Volume + */ + +const deviceUsers = (item, targetDevices, volumes) => { + const device = toStorageDevice(item); + if (!device) return []; + + const isTargetDevice = !!targetDevices.find(d => d.name === device.name); + const volumeUsers = volumes.filter(v => v.targetDevice?.name === device.name); + + const users = []; + if (isTargetDevice) users.push(_("Installation device")); + + return users.concat(volumeUsers.map(v => v.mountPath)); +}; + +const DeviceUsage = ({ users }) => { + return users.map((user, index) => {user}); +}; + +/** + * Table for selecting the location for a volume. + * @component + * + * @typedef {object} VolumeLocationSelectorTableBaseProps + * @property {StorageDevice[]} devices + * @property {StorageDevice[]} selectedDevices + * @property {StorageDevice[]} targetDevices + * @property {Volume[]} volumes + * + * @typedef {VolumeLocationSelectorTableBaseProps & ExpandableSelectorProps} VolumeLocationSelectorTable + * + * @param {VolumeLocationSelectorTable} props + */ +export default function VolumeLocationSelectorTable({ + devices, + selectedDevices, + targetDevices, + volumes, + ...props +}) { + /** @type {ExpandableSelectorColumn[]} */ + const columns = [ + { name: _("Device"), value: (item) => }, + { name: _("Details"), value: (item) => }, + { name: _("Usage"), value: (item) => }, + { name: _("Size"), value: (item) => , classNames: "sizes-column" } + ]; + + return ( + { + if (!device.sid) { + return "dimmed-row"; + } + }} + itemsSelected={selectedDevices} + className="devices-table" + {...props} + /> + ); +}