From 518ff2a0af4f9df625e01520722f84e678c8ce9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Tue, 15 Jul 2025 12:28:27 +0100 Subject: [PATCH 01/20] web: add page for formatting a device --- .../storage/FormattableDevicePage.tsx | 516 ++++++++++++++++++ web/src/helpers/storage/filesystem.ts | 60 ++ web/src/hooks/storage/filesystem.ts | 49 ++ web/src/types/storage/data.ts | 12 +- web/src/types/storage/model.ts | 4 +- 5 files changed, 637 insertions(+), 4 deletions(-) create mode 100644 web/src/components/storage/FormattableDevicePage.tsx create mode 100644 web/src/helpers/storage/filesystem.ts create mode 100644 web/src/hooks/storage/filesystem.ts diff --git a/web/src/components/storage/FormattableDevicePage.tsx b/web/src/components/storage/FormattableDevicePage.tsx new file mode 100644 index 0000000000..47bf11de9d --- /dev/null +++ b/web/src/components/storage/FormattableDevicePage.tsx @@ -0,0 +1,516 @@ +/* + * Copyright (c) [2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 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 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. + */ + +import React, { useId } from "react"; +import { useParams, useNavigate } from "react-router-dom"; +import { + ActionGroup, + Content, + Divider, + Flex, + FlexItem, + Form, + FormGroup, + FormHelperText, + HelperText, + HelperTextItem, + SelectGroup, + SelectList, + SelectOption, + SelectOptionProps, + Stack, + TextInput, +} from "@patternfly/react-core"; +import { Page, SelectWrapper as Select } from "~/components/core/"; +import { SelectWrapperProps as SelectProps } from "~/components/core/SelectWrapper"; +import SelectTypeaheadCreatable from "~/components/core/SelectTypeaheadCreatable"; +import { useMissingMountPaths, useVolume } from "~/hooks/storage/product"; +import { useAddFilesystem } from "~/hooks/storage/filesystem"; +import { useModel } from "~/hooks/storage/model"; +import { useDevices } from "~/queries/storage"; +import { data, model, StorageDevice } from "~/types/storage"; +import { filesystemLabel } from "~/components/storage/utils"; +import { _ } from "~/i18n"; +import { sprintf } from "sprintf-js"; +import { apiModel } from "~/api/storage/types"; +import { STORAGE as PATHS } from "~/routes/paths"; +import { unique } from "radashi"; +import { compact } from "~/utils"; + +const NO_VALUE = ""; +const BTRFS_SNAPSHOTS = "btrfsSnapshots"; +const REUSE_FILESYSTEM = "reuse"; + +type DeviceModel = model.Drive | model.MdRaid; +type FormValue = { + mountPoint: string; + filesystem: string; + filesystemLabel: string; +}; +type Error = { + id: string; + message?: string; + isVisible: boolean; +}; +type ErrorsHandler = { + errors: Error[]; + getError: (id: string) => Error | undefined; + getVisibleError: (id: string) => Error | undefined; +}; + +function toData(value: FormValue): data.Formattable { + const filesystemType = (): apiModel.FilesystemType | undefined => { + if (value.filesystem === NO_VALUE) return undefined; + if (value.filesystem === BTRFS_SNAPSHOTS) return "btrfs"; + + /** + * @note This type cast is needed because the list of filesystems coming from a volume is not + * enumerated (the volume simply contains a list of strings). This implies we have to rely on + * whatever value coming from such a list as a filesystem type accepted by the config model. + * This will be fixed in the future by directly exporting the volumes as a JSON, similar to the + * config model. The schema for the volumes will define the explicit list of filesystem types. + */ + return value.filesystem as apiModel.FilesystemType; + }; + + const filesystem = (): data.Filesystem | undefined => { + if (value.filesystem === REUSE_FILESYSTEM) return { reuse: true }; + + const type = filesystemType(); + if (type === undefined) return undefined; + + return { + type, + snapshots: value.filesystem === BTRFS_SNAPSHOTS, + label: value.filesystemLabel, + }; + }; + + return { + mountPath: value.mountPoint, + filesystem: filesystem(), + }; +} + +function toFormValue(deviceModel: DeviceModel): FormValue { + const mountPoint = (): string => deviceModel.mountPath || NO_VALUE; + + const filesystem = (): string => { + const fsConfig = deviceModel.filesystem; + if (!fsConfig) return NO_VALUE; + if (fsConfig.reuse) return REUSE_FILESYSTEM; + if (!fsConfig.type) return NO_VALUE; + if (fsConfig.type === "btrfs" && fsConfig.snapshots) return BTRFS_SNAPSHOTS; + + return fsConfig.type; + }; + + const filesystemLabel = (): string => deviceModel.filesystem?.label || NO_VALUE; + + return { + mountPoint: mountPoint(), + filesystem: filesystem(), + filesystemLabel: filesystemLabel(), + }; +} + +function useDeviceModel(): DeviceModel { + const { list, listIndex } = useParams(); + const model = useModel({ suspense: true }); + return model[list].at(listIndex); +} + +function useDevice(): StorageDevice { + const deviceModel = useDeviceModel(); + const devices = useDevices("system", { suspense: true }); + return devices.find((d) => d.name === deviceModel.name); +} + +function useCurrentFilesystem(): string | null { + const device = useDevice(); + return device?.filesystem?.type || null; +} + +function useDefaultFilesystem(mountPoint: string): string { + const volume = useVolume(mountPoint, { suspense: true }); + return volume.mountPath === "/" && volume.snapshots ? BTRFS_SNAPSHOTS : volume.fsType; +} + +function useInitialFormValue(): FormValue | null { + const deviceModel = useDeviceModel(); + return React.useMemo(() => (deviceModel ? toFormValue(deviceModel) : null), [deviceModel]); +} + +/** Unused predefined mount points. Includes the currently used mount point when editing. */ +function useUnusedMountPoints(): string[] { + const unusedMountPaths = useMissingMountPaths(); + const deviceModel = useDeviceModel(); + return compact([deviceModel?.mountPath, ...unusedMountPaths]); +} + +function useUsableFilesystems(mountPoint: string): string[] { + const volume = useVolume(mountPoint); + const defaultFilesystem = useDefaultFilesystem(mountPoint); + + const usableFilesystems = React.useMemo(() => { + const volumeFilesystems = (): string[] => { + const allValues = volume.outline.fsTypes; + + if (volume.mountPath !== "/") return allValues; + + // Btrfs without snapshots is not an option. + if (!volume.outline.snapshotsConfigurable && volume.snapshots) { + return [BTRFS_SNAPSHOTS, ...allValues].filter((v) => v !== "btrfs"); + } + + // Btrfs with snapshots is not an option + if (!volume.outline.snapshotsConfigurable && !volume.snapshots) { + return allValues; + } + + return [BTRFS_SNAPSHOTS, ...allValues]; + }; + + return unique([defaultFilesystem, ...volumeFilesystems()]); + }, [volume, defaultFilesystem]); + + return usableFilesystems; +} + +function useMountPointError(value: FormValue): Error | undefined { + const model = useModel({ suspense: true }); + const mountPoints = model?.getMountPaths() || []; + const deviceModel = useDeviceModel(); + const mountPoint = value.mountPoint; + + if (mountPoint === NO_VALUE) { + return { + id: "mountPoint", + isVisible: false, + }; + } + + const regex = /^swap$|^\/$|^(\/[^/\s]+)+$/; + if (!regex.test(mountPoint)) { + return { + id: "mountPoint", + message: _("Select or enter a valid mount point"), + isVisible: true, + }; + } + + // Exclude itself when editing + const initialMountPoint = deviceModel?.mountPath; + if (mountPoint !== initialMountPoint && mountPoints.includes(mountPoint)) { + return { + id: "mountPoint", + message: _("Select or enter a mount point that is not already assigned to another device"), + isVisible: true, + }; + } +} + +function useErrors(value: FormValue): ErrorsHandler { + const mountPointError = useMountPointError(value); + const errors = compact([mountPointError]); + + const getError = (id: string): Error | undefined => errors.find((e) => e.id === id); + + const getVisibleError = (id: string): Error | undefined => { + const error = getError(id); + return error?.isVisible ? error : undefined; + }; + + return { errors, getError, getVisibleError }; +} + +function useAutoRefreshFilesystem(handler, value: FormValue) { + const { mountPoint } = value; + const defaultFilesystem = useDefaultFilesystem(mountPoint); + const usableFilesystems = useUsableFilesystems(mountPoint); + const currentFilesystem = useCurrentFilesystem(); + + React.useEffect(() => { + // Reset filesystem if there is no mount point yet. + if (mountPoint === NO_VALUE) handler(NO_VALUE); + // Select default filesystem for the mount point if the device has no filesystem. + if (mountPoint !== NO_VALUE && !currentFilesystem) handler(defaultFilesystem); + // Reuse the filesystem from the device if possible. + if (mountPoint !== NO_VALUE && currentFilesystem) { + const reuse = usableFilesystems.includes(currentFilesystem); + handler(reuse ? REUSE_FILESYSTEM : defaultFilesystem); + } + }, [handler, mountPoint, defaultFilesystem, usableFilesystems, currentFilesystem]); +} + +function mountPointSelectOptions(mountPoints: string[]): SelectOptionProps[] { + return mountPoints.map((p) => ({ value: p, children: p })); +} + +type FilesystemOptionLabelProps = { + value: string; +}; + +function FilesystemOptionLabel({ value }: FilesystemOptionLabelProps): React.ReactNode { + const filesystem = useCurrentFilesystem(); + if (value === NO_VALUE) return _("Waiting for a mount point"); + // TRANSLATORS: %s is a filesystem type, like Btrfs + if (value === REUSE_FILESYSTEM && filesystem) + return sprintf(_("Current %s"), filesystemLabel(filesystem)); + if (value === BTRFS_SNAPSHOTS) return _("Btrfs with snapshots"); + + return filesystemLabel(value); +} + +type FilesystemOptionsProps = { + mountPoint: string; +}; + +function FilesystemOptions({ mountPoint }: FilesystemOptionsProps): React.ReactNode { + const device = useDevice(); + const volume = useVolume(mountPoint); + const defaultFilesystem = useDefaultFilesystem(mountPoint); + const usableFilesystems = useUsableFilesystems(mountPoint); + const currentFilesystem = useCurrentFilesystem(); + const canReuse = currentFilesystem && usableFilesystems.includes(currentFilesystem); + + const defaultOptText = volume.mountPath + ? sprintf(_("Default file system for %s"), mountPoint) + : _("Default file system"); + const formatText = currentFilesystem + ? _("Destroy current data and format device as") + : _("Format device as"); + + return ( + + {mountPoint === NO_VALUE && ( + + + + )} + {mountPoint !== NO_VALUE && canReuse && ( + + + + )} + {mountPoint !== NO_VALUE && canReuse && usableFilesystems.length && } + {mountPoint !== NO_VALUE && ( + + {usableFilesystems.map((fsType, index) => ( + + + + ))} + + )} + + ); +} + +type FilesystemSelectProps = { + id?: string; + value: string; + mountPoint: string; + onChange: SelectProps["onChange"]; +}; + +function FilesystemSelect({ + id, + value, + mountPoint, + onChange, +}: FilesystemSelectProps): React.ReactNode { + const usedValue = mountPoint === NO_VALUE ? NO_VALUE : value; + + return ( + + ); +} + +type FilesystemLabelProps = { + id?: string; + value: string; + onChange: (v: string) => void; +}; + +function FilesystemLabel({ id, value, onChange }: FilesystemLabelProps): React.ReactNode { + const isValid = (v: string) => /^[\w-_.]*$/.test(v); + + return ( + isValid(v) && onChange(v)} + /> + ); +} + +/** + * @fixme This component has to be adapted to use the new hooks from ~/hooks/storage/ instead of the + * deprecated hooks from ~/queries/storage/config-model. + */ +export default function FormattableDevicePage() { + const navigate = useNavigate(); + const headingId = useId(); + const [mountPoint, setMountPoint] = React.useState(NO_VALUE); + const [filesystem, setFilesystem] = React.useState(NO_VALUE); + const [filesystemLabel, setFilesystemLabel] = React.useState(NO_VALUE); + // Filesystem and size selectors should not be auto refreshed before the user interacts with other + // selectors like the mount point or the target selectors. + const [autoRefreshFilesystem, setAutoRefreshFilesystem] = React.useState(false); + + const initialValue = useInitialFormValue(); + const value = { mountPoint, filesystem, filesystemLabel }; + const { errors, getVisibleError } = useErrors(value); + + const device = useDeviceModel(); + const unusedMountPoints = useUnusedMountPoints(); + const addFilesystem = useAddFilesystem(); + + // Initializes the form values. + React.useEffect(() => { + if (initialValue) { + setMountPoint(initialValue.mountPoint); + setFilesystem(initialValue.filesystem); + setFilesystemLabel(initialValue.filesystemLabel); + } + }, [initialValue]); + + const refreshFilesystemHandler = React.useCallback( + (filesystem: string) => autoRefreshFilesystem && setFilesystem(filesystem), + [autoRefreshFilesystem, setFilesystem], + ); + + useAutoRefreshFilesystem(refreshFilesystemHandler, value); + + const changeMountPoint = (value: string) => { + if (value !== mountPoint) { + setAutoRefreshFilesystem(true); + setMountPoint(value); + } + }; + + const changeFilesystem = (value: string) => { + setAutoRefreshFilesystem(false); + setFilesystem(value); + }; + + const onSubmit = () => { + const data = toData(value); + const { list, listIndex } = device; + addFilesystem(list, listIndex, data); + navigate(PATHS.root); + }; + + const isFormValid = errors.length === 0; + const mountPointError = getVisibleError("mountPoint"); + const usedMountPt = mountPointError ? NO_VALUE : mountPoint; + const showLabel = filesystem !== NO_VALUE && filesystem !== REUSE_FILESYSTEM; + + return ( + + + + {sprintf(_("Configure device %s"), device.name)} + + + + +
+ + + + + + + + + + + {!mountPointError && _("Select or enter a mount point")} + {mountPointError?.message} + + + + + + + + + + + + {showLabel && ( + + + + + + )} + + + + + + + +
+
+
+ ); +} diff --git a/web/src/helpers/storage/filesystem.ts b/web/src/helpers/storage/filesystem.ts new file mode 100644 index 0000000000..993851f36b --- /dev/null +++ b/web/src/helpers/storage/filesystem.ts @@ -0,0 +1,60 @@ +/* + * Copyright (c) [2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 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 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. + */ + +import { apiModel } from "~/api/storage/types"; +import { data } from "~/types/storage"; +import { copyApiModel } from "~/helpers/storage/api-model"; + +function findDevice( + apiModel: apiModel.Config, + list: string, + index: number | string, +): apiModel.Drive | apiModel.MdRaid | null { + return (apiModel[list] || []).at(index) || null; +} + +function configureFilesystem( + apiModel: apiModel.Config, + list: string, + index: number | string, + data: data.Formattable, +): apiModel.Config { + apiModel = copyApiModel(apiModel); + + const device = findDevice(apiModel, list, index); + if (!device) return apiModel; + + device.mountPath = data.mountPath; + + if (data.filesystem) { + device.filesystem = { + default: false, + ...data.filesystem, + }; + } else { + device.filesystem = undefined; + } + + return apiModel; +} + +export { configureFilesystem }; diff --git a/web/src/hooks/storage/filesystem.ts b/web/src/hooks/storage/filesystem.ts new file mode 100644 index 0000000000..b674f2fb05 --- /dev/null +++ b/web/src/hooks/storage/filesystem.ts @@ -0,0 +1,49 @@ +/* + * Copyright (c) [2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 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 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. + */ + +import { useApiModel, useUpdateApiModel } from "~/hooks/storage/api-model"; +import { configureFilesystem } from "~/helpers/storage/filesystem"; +import { QueryHookOptions } from "~/types/queries"; +import { data } from "~/types/storage"; + +type AddFilesystemFn = (list: string, index: number, data: data.Formattable) => void; + +function useAddFilesystem(options?: QueryHookOptions): AddFilesystemFn { + const apiModel = useApiModel(options); + const updateApiModel = useUpdateApiModel(); + return (list: string, index: number, data: data.Formattable) => { + updateApiModel(configureFilesystem(apiModel, list, index, data)); + }; +} + +type DeleteFilesystemFn = (list: string, index: number) => void; + +function useDeleteFilesystem(options?: QueryHookOptions): DeleteFilesystemFn { + const apiModel = useApiModel(options); + const updateApiModel = useUpdateApiModel(); + return (list: string, index: number) => { + updateApiModel(configureFilesystem(apiModel, list, index, {})); + }; +} + +export { useAddFilesystem, useDeleteFilesystem }; +export type { AddFilesystemFn, DeleteFilesystemFn }; diff --git a/web/src/types/storage/data.ts b/web/src/types/storage/data.ts index b22d850e0b..de56b55a82 100644 --- a/web/src/types/storage/data.ts +++ b/web/src/types/storage/data.ts @@ -55,6 +55,11 @@ type SpacePolicy = { actions?: SpacePolicyAction[]; }; +type Formattable = { + mountPath?: string; + filesystem?: Filesystem; +}; + // So far this type is used only for adding a pre-existing RAID searched by name. So we are starting // with this simplistic definition. Such a definition will likely grow in the future if the same // type is used for more operations. @@ -72,12 +77,13 @@ type Drive = { export type { Drive, + Filesystem, + Formattable, + LogicalVolume, MdRaid, Partition, - VolumeGroup, - LogicalVolume, - Filesystem, Size, SpacePolicy, SpacePolicyAction, + VolumeGroup, }; diff --git a/web/src/types/storage/model.ts b/web/src/types/storage/model.ts index 8a7a2b6c3a..a44eeeed69 100644 --- a/web/src/types/storage/model.ts +++ b/web/src/types/storage/model.ts @@ -95,4 +95,6 @@ interface VolumeGroup extends Omit Date: Tue, 15 Jul 2025 12:30:00 +0100 Subject: [PATCH 02/20] web: adapt content for formatted devices --- .../storage/DeviceEditorContent.tsx | 74 ++++++++++++ web/src/components/storage/DriveEditor.tsx | 10 +- web/src/components/storage/FilesystemMenu.tsx | 109 ++++++++++++++++++ web/src/components/storage/MdRaidEditor.tsx | 10 +- web/src/components/storage/PartitionsMenu.tsx | 14 +-- .../components/storage/SpacePolicyMenu.tsx | 5 +- web/src/components/storage/utils/drive.tsx | 16 ++- web/src/routes/paths.ts | 1 + web/src/routes/storage.tsx | 5 + 9 files changed, 210 insertions(+), 34 deletions(-) create mode 100644 web/src/components/storage/DeviceEditorContent.tsx create mode 100644 web/src/components/storage/FilesystemMenu.tsx diff --git a/web/src/components/storage/DeviceEditorContent.tsx b/web/src/components/storage/DeviceEditorContent.tsx new file mode 100644 index 0000000000..f293ee00ee --- /dev/null +++ b/web/src/components/storage/DeviceEditorContent.tsx @@ -0,0 +1,74 @@ +/* + * Copyright (c) [2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 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 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. + */ + +import React from "react"; +import { Stack, Flex, StackItem } from "@patternfly/react-core"; +import { generatePath } from "react-router-dom"; +import Link from "~/components/core/Link"; +import { STORAGE as PATHS } from "~/routes/paths"; +import FilesystemMenu from "~/components/storage/FilesystemMenu"; +import PartitionsMenu from "~/components/storage/PartitionsMenu"; +import SpacePolicyMenu from "~/components/storage/SpacePolicyMenu"; +import { model, StorageDevice } from "~/types/storage"; +import { _ } from "~/i18n"; + +type DeviceEmptyStateProps = Pick; + +function DeviceEmptyState({ deviceModel }: DeviceEmptyStateProps): React.ReactNode { + const { list, listIndex } = deviceModel; + const newPartitionPath = generatePath(PATHS.addPartition, { list, listIndex }); + const formatDevicePath = generatePath(PATHS.formatDevice, { list, listIndex }); + + return ( + + + + + {_("Add a new partition or mount an existing one")} + + + + + {_("Mount the device")} + + + + + ); +} + +type DeviceEditorContentProps = { deviceModel: model.Drive | model.MdRaid; device: StorageDevice }; + +export default function DeviceEditorContent({ + deviceModel, + device, +}: DeviceEditorContentProps): React.ReactNode { + if (!deviceModel.isUsed) return ; + + return ( + <> + {deviceModel.filesystem && } + {!deviceModel.filesystem && } + {!deviceModel.filesystem && } + + ); +} diff --git a/web/src/components/storage/DriveEditor.tsx b/web/src/components/storage/DriveEditor.tsx index 2f7a63e6ae..d2147ce5c2 100644 --- a/web/src/components/storage/DriveEditor.tsx +++ b/web/src/components/storage/DriveEditor.tsx @@ -23,8 +23,7 @@ import React from "react"; import ConfigEditorItem from "~/components/storage/ConfigEditorItem"; import DriveHeader from "~/components/storage/DriveHeader"; -import PartitionsMenu from "~/components/storage/PartitionsMenu"; -import SpacePolicyMenu from "~/components/storage/SpacePolicyMenu"; +import DeviceEditorContent from "~/components/storage/DeviceEditorContent"; import SearchedDeviceMenu from "~/components/storage/SearchedDeviceMenu"; import { Drive } from "~/types/storage/model"; import { model, StorageDevice } from "~/types/storage"; @@ -55,12 +54,7 @@ export default function DriveEditor({ drive, driveDevice }: DriveEditorProps) { return ( } - content={ - <> - - - - } + content={} actions={} /> ); diff --git a/web/src/components/storage/FilesystemMenu.tsx b/web/src/components/storage/FilesystemMenu.tsx new file mode 100644 index 0000000000..94198339a1 --- /dev/null +++ b/web/src/components/storage/FilesystemMenu.tsx @@ -0,0 +1,109 @@ +/* + * Copyright (c) [2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 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 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. + */ + +import React, { useId } from "react"; +import { Divider, Flex } from "@patternfly/react-core"; +import { useNavigate, generatePath } from "react-router-dom"; +import Text from "~/components/core/Text"; +import MenuHeader from "~/components/core/MenuHeader"; +import MenuButton from "~/components/core/MenuButton"; +import { useDeleteFilesystem } from "~/hooks/storage/filesystem"; +import { STORAGE as PATHS } from "~/routes/paths"; +import { model } from "~/types/storage"; +import * as driveUtils from "~/components/storage/utils/drive"; +import { sprintf } from "sprintf-js"; +import { _ } from "~/i18n"; +import { filesystemType } from "~/components/storage/utils"; + +function deviceDescription(deviceModel: FilesystemMenuProps["deviceModel"]): string { + const fs = filesystemType(deviceModel.filesystem); + const mountPath = deviceModel.mountPath; + const reuse = deviceModel.filesystem.reuse; + // TRANSLATORS: %1$s is a filesystem type (eg. Btrfs), %2$s is a mount point (eg. /home). + if (reuse && fs && mountPath) return sprintf(_("Mount current %1$s at %2$s"), fs, mountPath); + // TRANSLATORS: %1$s is a mount point (eg. /home). + if (reuse && mountPath) return sprintf(_("Mount at %1$s"), mountPath); + // TRANSLATORS: %1$s is a filesystem type (eg. Btrfs). + if (reuse && fs) return sprintf(_("Reuse current %1$s"), fs); + if (reuse) return _("Reuse current file system"); + // TRANSLATORS: %1$s is a filesystem type (eg. Btrfs), %2$s is a mount point (eg. /home). + if (mountPath) return sprintf(_("Format as %1$s for %2$s"), fs, mountPath); + + // TRANSLATORS: %1$s is a filesystem type (eg. Btrfs). + return sprintf(_("Format as %1$s"), fs); +} + +type FilesystemMenuProps = { deviceModel: model.Drive | model.MdRaid }; + +export default function FilesystemMenu({ deviceModel }: FilesystemMenuProps): React.ReactNode { + const navigate = useNavigate(); + const ariaLabelId = useId(); + const toggleTextId = useId(); + const deleteFilesystem = useDeleteFilesystem(); + const { list, listIndex } = deviceModel; + const editFilesystemPath = generatePath(PATHS.formatDevice, { list, listIndex }); + + // TRANSLATORS: %s is the name of device, like '/dev/sda'. + const detailsAriaLabel = sprintf(_("Details for %s"), deviceModel.name); + + return ( + + + {detailsAriaLabel} + + + {_("Details")} + + , + , + navigate(editFilesystemPath)} + > + {_("Edit")} + , + deleteFilesystem(list, listIndex)} + > + {_("Do not configure")} + , + ]} + > + {driveUtils.contentDescription(deviceModel)} + + + ); +} diff --git a/web/src/components/storage/MdRaidEditor.tsx b/web/src/components/storage/MdRaidEditor.tsx index e676e5b8ff..8d1465ac3e 100644 --- a/web/src/components/storage/MdRaidEditor.tsx +++ b/web/src/components/storage/MdRaidEditor.tsx @@ -23,8 +23,7 @@ import React from "react"; import ConfigEditorItem from "~/components/storage/ConfigEditorItem"; import MdRaidHeader from "~/components/storage/MdRaidHeader"; -import PartitionsMenu from "~/components/storage/PartitionsMenu"; -import SpacePolicyMenu from "~/components/storage/SpacePolicyMenu"; +import DeviceEditorContent from "~/components/storage/DeviceEditorContent"; import SearchedDeviceMenu from "~/components/storage/SearchedDeviceMenu"; import { model, StorageDevice } from "~/types/storage"; import { MdRaid } from "~/types/storage/model"; @@ -55,12 +54,7 @@ export default function MdRaidEditor({ raid, raidDevice }: MdRaidEditorProps) { return ( } - content={ - <> - - - - } + content={} actions={} /> ); diff --git a/web/src/components/storage/PartitionsMenu.tsx b/web/src/components/storage/PartitionsMenu.tsx index f2c91ac265..57aad22388 100644 --- a/web/src/components/storage/PartitionsMenu.tsx +++ b/web/src/components/storage/PartitionsMenu.tsx @@ -24,7 +24,6 @@ import React, { useId } from "react"; import { Divider, Stack, Flex } from "@patternfly/react-core"; import { useNavigate, generatePath } from "react-router-dom"; import Text from "~/components/core/Text"; -import Link from "~/components/core/Link"; import MenuButton from "~/components/core/MenuButton"; import MenuHeader from "~/components/core/MenuHeader"; import MountPathMenuItem from "~/components/storage/MountPathMenuItem"; @@ -136,23 +135,12 @@ export default function PartitionsMenu({ device }) { const navigate = useNavigate(); const ariaLabelId = useId(); const toggleTextId = useId(); - const { isBoot, isTargetDevice, list, listIndex } = device; + const { list, listIndex } = device; const newPartitionPath = generatePath(PATHS.addPartition, { list, listIndex }); // TRANSLATORS: %s is the name of device, like '/dev/sda'. const detailsAriaLabel = sprintf(_("Details for %s"), device.name); - const hasPartitions = device.partitions.some((p: Partition) => p.mountPath); - if (!isBoot && !isTargetDevice && !hasPartitions) { - return ( - - - {_("Add a new partition or mount an existing one")} - - - ); - } - // FIXME: All strings and widgets are now calculated and assembled here. But we are actually // aiming for a different organization of the widgets (eg. using a MenuGroup with a label to // render the list of partition). At that point we will be able to better distribute the logic. diff --git a/web/src/components/storage/SpacePolicyMenu.tsx b/web/src/components/storage/SpacePolicyMenu.tsx index 31d381e27b..25d63007bc 100644 --- a/web/src/components/storage/SpacePolicyMenu.tsx +++ b/web/src/components/storage/SpacePolicyMenu.tsx @@ -27,7 +27,6 @@ import { useNavigate, generatePath } from "react-router-dom"; import { useSetSpacePolicy } from "~/hooks/storage/space-policy"; import { SPACE_POLICIES } from "~/components/storage/utils"; import { apiModel } from "~/api/storage/types"; -import { Partition } from "~/api/storage/types/model"; import { STORAGE as PATHS } from "~/routes/paths"; import * as driveUtils from "~/components/storage/utils/drive"; import { isEmpty } from "radashi"; @@ -50,12 +49,10 @@ const PolicyItem = ({ policy, modelDevice, isSelected, onClick }) => { export default function SpacePolicyMenu({ modelDevice, device }) { const navigate = useNavigate(); const setSpacePolicy = useSetSpacePolicy(); - const { isBoot, isTargetDevice, list, listIndex } = modelDevice; + const { list, listIndex } = modelDevice; const existingPartitions = device.partitionTable?.partitions.length; - const hasPartitions = modelDevice.partitions.some((p: Partition) => p.mountPath); if (isEmpty(existingPartitions)) return; - if (!isBoot && !isTargetDevice && !hasPartitions) return; const onSpacePolicyChange = (spacePolicy: apiModel.SpacePolicy) => { if (spacePolicy === "custom") { diff --git a/web/src/components/storage/utils/drive.tsx b/web/src/components/storage/utils/drive.tsx index 8840e24ee1..0b0667e456 100644 --- a/web/src/components/storage/utils/drive.tsx +++ b/web/src/components/storage/utils/drive.tsx @@ -23,7 +23,13 @@ import { _, n_, formatList } from "~/i18n"; import { apiModel } from "~/api/storage/types"; import { Drive } from "~/types/storage/model"; -import { SpacePolicy, SPACE_POLICIES, baseName, formattedPath } from "~/components/storage/utils"; +import { + SpacePolicy, + SPACE_POLICIES, + baseName, + formattedPath, + filesystemType, +} from "~/components/storage/utils"; import { sprintf } from "sprintf-js"; /** @@ -147,6 +153,14 @@ const contentDescription = (drive: apiModel.Drive): string => { const newPartitions = drive.partitions.filter((p) => !p.name); const reusedPartitions = drive.partitions.filter((p) => p.name && p.mountPath); + if (drive.filesystem) { + if (drive.mountPath) { + return sprintf(_("The device will be used for %s"), drive.mountPath); + } else { + return sprintf(_("The device will formatted as %s"), filesystemType(drive.filesystem)); + } + } + if (newPartitions.length === 0) { if (reusedPartitions.length === 0) { return _("No additional partitions will be created"); diff --git a/web/src/routes/paths.ts b/web/src/routes/paths.ts index 03e72c1712..4124f63b8d 100644 --- a/web/src/routes/paths.ts +++ b/web/src/routes/paths.ts @@ -78,6 +78,7 @@ const STORAGE = { editBootDevice: "/storage/boot-device/edit", editEncryption: "/storage/encryption/edit", editSpacePolicy: "/storage/:list/:listIndex/space-policy/edit", + formatDevice: "/storage/:list/:listIndex/format", addPartition: "/storage/:list/:listIndex/partitions/add", editPartition: "/storage/:list/:listIndex/partitions/:partitionId/edit", selectDevice: "/storage/devices/select", diff --git a/web/src/routes/storage.tsx b/web/src/routes/storage.tsx index 22398340dd..82e9b1f4ca 100644 --- a/web/src/routes/storage.tsx +++ b/web/src/routes/storage.tsx @@ -29,6 +29,7 @@ import EncryptionSettingsPage from "~/components/storage/EncryptionSettingsPage" import SpacePolicySelection from "~/components/storage/SpacePolicySelection"; import ProposalPage from "~/components/storage/ProposalPage"; import ISCSIPage from "~/components/storage/ISCSIPage"; +import FormattableDevicePage from "~/components/storage/FormattableDevicePage"; import PartitionPage from "~/components/storage/PartitionPage"; import LvmPage from "~/components/storage/LvmPage"; import LogicalVolumePage from "~/components/storage/LogicalVolumePage"; @@ -64,6 +65,10 @@ const routes = (): Route => ({ path: PATHS.editSpacePolicy, element: , }, + { + path: PATHS.formatDevice, + element: , + }, { path: PATHS.addPartition, element: , From 418383b31b239c51e4d3abf1c071bb0ad357b0ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Tue, 15 Jul 2025 12:30:38 +0100 Subject: [PATCH 03/20] web: allow selecting all available devices --- web/src/components/storage/ConfigureDeviceMenu.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/components/storage/ConfigureDeviceMenu.tsx b/web/src/components/storage/ConfigureDeviceMenu.tsx index 4098651ad7..6045872647 100644 --- a/web/src/components/storage/ConfigureDeviceMenu.tsx +++ b/web/src/components/storage/ConfigureDeviceMenu.tsx @@ -24,7 +24,7 @@ import React, { useState } from "react"; import { useNavigate } from "react-router-dom"; import MenuButton, { MenuButtonItem } from "~/components/core/MenuButton"; import { Divider, MenuItemProps } from "@patternfly/react-core"; -import { useCandidateDevices } from "~/hooks/storage/system"; +import { useAvailableDevices } from "~/hooks/storage/system"; import { useModel } from "~/hooks/storage/model"; import { useAddDrive } from "~/hooks/storage/drive"; import { useAddReusedMdRaid } from "~/hooks/storage/md-raid"; @@ -104,7 +104,7 @@ export default function ConfigureDeviceMenu(): React.ReactNode { const model = useModel({ suspense: true }); const addDrive = useAddDrive(); const addReusedMdRaid = useAddReusedMdRaid(); - const allDevices = useCandidateDevices(); + const allDevices = useAvailableDevices(); const usedDevicesNames = model.drives.concat(model.mdRaids).map((d) => d.name); const usedDevicesCount = usedDevicesNames.length; From 1cf1a7436ff65310effc519eac7fd4b2e57d2ddd Mon Sep 17 00:00:00 2001 From: Ancor Gonzalez Sosa Date: Mon, 14 Jul 2025 16:18:41 +0100 Subject: [PATCH 04/20] web: Adjustment in the logic to show the DoNotUse option --- web/src/components/storage/SearchedDeviceMenu.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/web/src/components/storage/SearchedDeviceMenu.tsx b/web/src/components/storage/SearchedDeviceMenu.tsx index dd120a048b..667de872ce 100644 --- a/web/src/components/storage/SearchedDeviceMenu.tsx +++ b/web/src/components/storage/SearchedDeviceMenu.tsx @@ -209,10 +209,6 @@ const RemoveEntryOption = ({ device, onClick }: RemoveEntryOptionProps): React.R return !onlyToBoot; }; - // If the target device cannot be changed, this button will always be disabled and would only - // provide redundant information. - if (UseOnlyOneOption(device)) return; - // When no additional drives has been added, the "Do not use" button can be confusing so it is // omitted for all drives. if (!hasAdditionalDrives(model)) return; @@ -222,6 +218,10 @@ const RemoveEntryOption = ({ device, onClick }: RemoveEntryOptionProps): React.R const hasPv = device.isTargetDevice; const isDisabled = isExplicitBoot || hasPv; + // If these cases, the target device cannot be changed and this disabled button would only provide + // information that is redundant to the one already displayed at the disabled "change device" one. + if (!device.getMountPaths().length && (hasPv || isExplicitBoot)) return; + if (isExplicitBoot) { if (hasPv) { description = _("The disk is used for LVM and boot"); From 511d2e4042081f26448efd3a7e822b7f7e71f796 Mon Sep 17 00:00:00 2001 From: Ancor Gonzalez Sosa Date: Tue, 15 Jul 2025 12:00:02 +0100 Subject: [PATCH 05/20] web: Sorter device name in a selection box --- web/src/components/storage/FormattableDevicePage.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/web/src/components/storage/FormattableDevicePage.tsx b/web/src/components/storage/FormattableDevicePage.tsx index 47bf11de9d..446b0d69a5 100644 --- a/web/src/components/storage/FormattableDevicePage.tsx +++ b/web/src/components/storage/FormattableDevicePage.tsx @@ -48,7 +48,7 @@ import { useAddFilesystem } from "~/hooks/storage/filesystem"; import { useModel } from "~/hooks/storage/model"; import { useDevices } from "~/queries/storage"; import { data, model, StorageDevice } from "~/types/storage"; -import { filesystemLabel } from "~/components/storage/utils"; +import { deviceBaseName, filesystemLabel } from "~/components/storage/utils"; import { _ } from "~/i18n"; import { sprintf } from "sprintf-js"; import { apiModel } from "~/api/storage/types"; @@ -310,8 +310,10 @@ function FilesystemOptions({ mountPoint }: FilesystemOptionsProps): React.ReactN {mountPoint !== NO_VALUE && canReuse && ( From cd1e93da1ecc13b109d5794903e521f416c968a7 Mon Sep 17 00:00:00 2001 From: Ancor Gonzalez Sosa Date: Tue, 15 Jul 2025 12:01:40 +0100 Subject: [PATCH 06/20] web: Adapt SearchedDeviceMenu to formatted partitionables --- .../components/storage/NewVgMenuOption.tsx | 3 ++ .../components/storage/SearchedDeviceMenu.tsx | 42 ++++++++++++++----- 2 files changed, 35 insertions(+), 10 deletions(-) diff --git a/web/src/components/storage/NewVgMenuOption.tsx b/web/src/components/storage/NewVgMenuOption.tsx index c67d634425..d09344a6a1 100644 --- a/web/src/components/storage/NewVgMenuOption.tsx +++ b/web/src/components/storage/NewVgMenuOption.tsx @@ -33,6 +33,9 @@ export type NewVgMenuOptionProps = { device: model.Drive | model.MdRaid }; export default function NewVgMenuOption({ device }: NewVgMenuOptionProps): React.ReactNode { const convertToVg = useConvertToVolumeGroup(); + + if (device.filesystem) return; + const vgs = device.getVolumeGroups(); const paths = device.partitions.filter((p) => !p.name).map((p) => formattedPath(p.mountPath)); const displayName = deviceBaseName(device, true); diff --git a/web/src/components/storage/SearchedDeviceMenu.tsx b/web/src/components/storage/SearchedDeviceMenu.tsx index 667de872ce..cb015a3855 100644 --- a/web/src/components/storage/SearchedDeviceMenu.tsx +++ b/web/src/components/storage/SearchedDeviceMenu.tsx @@ -38,9 +38,11 @@ import { Icon } from "../layout"; const baseName = (device: StorageDevice): string => deviceBaseName(device, true); -const UseOnlyOneOption = (device: model.Drive | model.MdRaid): boolean => { - const hasPv = device.isTargetDevice; - if (!device.getMountPaths().length && (hasPv || device.isExplicitBoot)) return true; +const useOnlyOneOption = (device: model.Drive | model.MdRaid): boolean => { + if (device.filesystem && device.filesystem.reuse) return true; + + const { isTargetDevice, isExplicitBoot } = device; + if (!device.getMountPaths().length && (isTargetDevice || isExplicitBoot)) return true; return device.isReusingPartitions; }; @@ -51,14 +53,19 @@ type ChangeDeviceMenuItemProps = { } & MenuItemProps; const ChangeDeviceTitle = ({ modelDevice }) => { - const onlyOneOption = UseOnlyOneOption(modelDevice); - const mountPaths = modelDevice.getMountPaths(); - const hasMountPaths = mountPaths.length > 0; - + const onlyOneOption = useOnlyOneOption(modelDevice); if (onlyOneOption) { return _("Selected disk cannot be changed"); } + if (modelDevice.filesystem) { + // TRANSLATORS: %s is a formatted mount point like '"/home"' + return sprintf(_("Select a disk to format as %s"), formattedPath(modelDevice.mountPath)); + } + + const mountPaths = modelDevice.getMountPaths(); + const hasMountPaths = mountPaths.length > 0; + if (!hasMountPaths) { return _("Select a disk to configure"); } @@ -89,6 +96,9 @@ const ChangeDeviceDescription = ({ modelDevice, device }) => { const hasPv = volumeGroups.length > 0; const vgName = volumeGroups[0]?.vgName; + if (modelDevice.filesystem && modelDevice.filesystem.reuse) + return _("This uses the existing file system at the disk"); + if (modelDevice.isReusingPartitions) { // The current device will be the only option to choose from return _("This uses existing partitions at the disk"); @@ -163,14 +173,14 @@ const ChangeDeviceDescription = ({ modelDevice, device }) => { }; /** - * Internal component holding the logic for rendering the disks drilldown menu + * Internal component holding the presentation of the option to change the device */ const ChangeDeviceMenuItem = ({ modelDevice, device, ...props }: ChangeDeviceMenuItemProps): React.ReactNode => { - const onlyOneOption = UseOnlyOneOption(modelDevice); + const onlyOneOption = useOnlyOneOption(modelDevice); return ( { + return availableDevices.filter((availableDev) => { + if (modelDevice.name === availableDev.name) return true; + + const collection = availableDev.isDrive ? model.drives : model.mdRaids; + const device = collection.find((d) => d.name === availableDev.name); + if (!device) return true; + + return modelDevice.filesystem ? !device.isUsed : !device.filesystem; + }); +}; + export type SearchedDeviceMenuProps = { modelDevice: model.Drive | model.MdRaid; selected: StorageDevice; @@ -272,7 +294,7 @@ export default function SearchedDeviceMenu({ const hook = device.isDrive ? switchToDrive : switchToMdRaid; hook(modelDevice.name, { name: device.name }); }; - const devices = useAvailableDevices(); + const devices = targetDevices(modelDevice, useModel(), useAvailableDevices()); const onDeviceChange = ([drive]: StorageDevice[]) => { setIsSelectorOpen(false); From 32aa8aac7799100761e4c6628ee1e032fc100ebc Mon Sep 17 00:00:00 2001 From: Ancor Gonzalez Sosa Date: Tue, 15 Jul 2025 12:11:33 +0100 Subject: [PATCH 07/20] web: Headers for formatted partitionables --- web/src/components/storage/DriveHeader.tsx | 7 +++++++ web/src/components/storage/MdRaidHeader.tsx | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/web/src/components/storage/DriveHeader.tsx b/web/src/components/storage/DriveHeader.tsx index fbe62d3762..266c642a83 100644 --- a/web/src/components/storage/DriveHeader.tsx +++ b/web/src/components/storage/DriveHeader.tsx @@ -28,6 +28,13 @@ import { _ } from "~/i18n"; export type DriveHeaderProps = { drive: model.Drive; device: StorageDevice }; const text = (drive: model.Drive): string => { + if (drive.filesystem) { + // TRANSLATORS: %s will be replaced by a disk name and its size - "sda (20 GiB)" + if (drive.filesystem.reuse) return _("Mount disk %s"); + // TRANSLATORS: %s will be replaced by a disk name and its size - "sda (20 GiB)" + return _("Format disk %s"); + } + const { isBoot, isTargetDevice: hasPv } = drive; const isRoot = !!drive.getPartition("/"); const hasFs = !!drive.getMountPaths().length; diff --git a/web/src/components/storage/MdRaidHeader.tsx b/web/src/components/storage/MdRaidHeader.tsx index 52143749d5..503c778088 100644 --- a/web/src/components/storage/MdRaidHeader.tsx +++ b/web/src/components/storage/MdRaidHeader.tsx @@ -28,6 +28,13 @@ import { _ } from "~/i18n"; export type MdRaidHeaderProps = { raid: model.MdRaid; device: StorageDevice }; const text = (raid: model.MdRaid): string => { + if (raid.filesystem) { + // TRANSLATORS: %s will be replaced by a RAID name and its size - "md0 (20 GiB)" + if (raid.filesystem.reuse) return _("Mount RAID %s"); + // TRANSLATORS: %s will be replaced by a RAID name and its size - "md0 (20 GiB)" + return _("Format RAID %s"); + } + const { isBoot, isTargetDevice: hasPv } = raid; const isRoot = !!raid.getPartition("/"); const hasFs = !!raid.getMountPaths().length; From c0d210366bc2a4e08ddd04c07b0f1c761178dab0 Mon Sep 17 00:00:00 2001 From: Ancor Gonzalez Sosa Date: Tue, 15 Jul 2025 12:37:16 +0100 Subject: [PATCH 08/20] web: Adapt switchSearched to handle formatted devices --- web/src/helpers/storage/search.ts | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/web/src/helpers/storage/search.ts b/web/src/helpers/storage/search.ts index 48b8fe5566..957dd39cc4 100644 --- a/web/src/helpers/storage/search.ts +++ b/web/src/helpers/storage/search.ts @@ -79,6 +79,26 @@ function switchSearched( const device = findDevice(apiModel, oldList, index); const deviceModel = buildModelDevice(apiModel, oldList, index); + const targetIndex = findDeviceIndex(apiModel, list, name); + const target = targetIndex === -1 ? null : findDevice(apiModel, list, targetIndex); + + if (deviceModel.filesystem) { + if (target) { + target.mountPath = device.mountPath; + target.filesystem = device.filesystem; + target.spacePolicy = "keep"; + } else { + apiModel[list].push({ + name, + mountPath: device.mountPath, + filesystem: device.filesystem, + spacePolicy: "keep", + }); + } + + apiModel[oldList].splice(index, 1); + return apiModel; + } const [newPartitions, existingPartitions] = fork(deviceModel.partitions, (p) => p.isNew); const reusedPartitions = existingPartitions.filter((p) => p.isReused); @@ -90,9 +110,7 @@ function switchSearched( apiModel[oldList].splice(index, 1); } - const targetIndex = findDeviceIndex(apiModel, list, name); - if (targetIndex !== -1) { - const target = findDevice(apiModel, list, targetIndex); + if (target) { target.partitions ||= []; target.partitions = [...target.partitions, ...newPartitions]; } else { From f92a1e5e187e3eb3dd0b8aea9b91020074b117d5 Mon Sep 17 00:00:00 2001 From: Ancor Gonzalez Sosa Date: Tue, 15 Jul 2025 13:42:21 +0100 Subject: [PATCH 09/20] web: Do not allow to select formatted disks for booting --- web/src/components/storage/BootSelection.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/web/src/components/storage/BootSelection.tsx b/web/src/components/storage/BootSelection.tsx index 770dcc8786..d167d584b4 100644 --- a/web/src/components/storage/BootSelection.tsx +++ b/web/src/components/storage/BootSelection.tsx @@ -39,6 +39,14 @@ import { useDisableBootConfig, } from "~/hooks/storage/boot"; +const filteredCandidates = (candidates, model): StorageDevice[] => { + return candidates.filter((candidate) => { + const collection = candidate.isDrive ? model.drives : model.mdRaids; + const device = collection.find((d) => d.name === candidate.name); + return !device || !device.filesystem; + }); +}; + // FIXME: improve classNames // FIXME: improve and rename to BootSelectionDialog @@ -62,8 +70,8 @@ export default function BootSelectionDialog() { const [state, setState] = useState({ load: false }); const navigate = useNavigate(); const devices = useDevices("system"); - const candidateDevices = useCandidateDevices(); const model = useModel({ suspense: true }); + const candidateDevices = filteredCandidates(useCandidateDevices(), model); const setBootDevice = useSetBootDevice(); const setDefaultBootDevice = useSetDefaultBootDevice(); const disableBootConfig = useDisableBootConfig(); From 8ab84ccbc12964de84ae69ec3ca4d010af0f8283 Mon Sep 17 00:00:00 2001 From: Ancor Gonzalez Sosa Date: Tue, 15 Jul 2025 14:24:22 +0100 Subject: [PATCH 10/20] web: Do not allow to select formatted disk for LVM --- web/src/components/storage/LvmPage.tsx | 24 +++++++++--------------- 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/web/src/components/storage/LvmPage.tsx b/web/src/components/storage/LvmPage.tsx index ac058b98eb..eaee72523b 100644 --- a/web/src/components/storage/LvmPage.tsx +++ b/web/src/components/storage/LvmPage.tsx @@ -35,8 +35,7 @@ import { TextInput, } from "@patternfly/react-core"; import { Page, SubtleContent } from "~/components/core"; -import { useCandidateDevices } from "~/hooks/storage/system"; -import { useDevices } from "~/queries/storage"; +import { useAvailableDevices } from "~/hooks/storage/system"; import { StorageDevice, model, data } from "~/types/storage"; import { useModel } from "~/hooks/storage/model"; import { @@ -53,24 +52,19 @@ import { _ } from "~/i18n"; /** * Hook that returns the devices that can be selected as target to automatically create LVM PVs. * - * FIXME: temporary and weak implementation that relies on the current model to offer only the - * candidate RAIDs and those non-candidate RAIDs that are already present at the current - * configuration. In the future we plan to add all available RAIDs to this form and then this whole - * function should disappear. + * Filters out devices that are going to be directly formatted. */ function useLvmTargetDevices(): StorageDevice[] { - const candidateDevices = useCandidateDevices(); - const systemDevices = useDevices("system", { suspense: true }); + const availableDevices = useAvailableDevices(); const model = useModel({ suspense: true }); const targetDevices = useMemo(() => { - const sids = candidateDevices.map((d) => d.sid); - const raids = model.mdRaids - .map((r) => systemDevices.find((d) => d.name === r.name)) - .filter((r) => !sids.includes(r.sid)); - - return [...candidateDevices, ...raids]; - }, [candidateDevices, systemDevices, model]); + return availableDevices.filter((candidate) => { + const collection = candidate.isDrive ? model.drives : model.mdRaids; + const device = collection.find((d) => d.name === candidate.name); + return !device || !device.filesystem; + }); + }, [availableDevices, model]); return targetDevices; } From d4d7a6feeb83f59f60027dd15a311d27a640ccee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Tue, 15 Jul 2025 15:19:10 +0100 Subject: [PATCH 11/20] web: adapt tests --- .../components/storage/BootSelection.test.tsx | 8 ++++++++ .../storage/ConfigureDeviceMenu.test.tsx | 2 +- web/src/components/storage/LvmPage.test.tsx | 2 +- .../components/storage/PartitionsMenu.test.tsx | 16 +++++++--------- 4 files changed, 17 insertions(+), 11 deletions(-) diff --git a/web/src/components/storage/BootSelection.test.tsx b/web/src/components/storage/BootSelection.test.tsx index 5e02fdf743..12f8c93279 100644 --- a/web/src/components/storage/BootSelection.test.tsx +++ b/web/src/components/storage/BootSelection.test.tsx @@ -141,6 +141,8 @@ describe("BootSelection", () => { isDefault: true, getDevice: () => null, }, + drives: [], + mdRaids: [], }); }); @@ -183,6 +185,8 @@ describe("BootSelection", () => { isDefault: false, getDevice: () => sda, }, + drives: [], + mdRaids: [], }); }); @@ -203,6 +207,8 @@ describe("BootSelection", () => { isDefault: false, getDevice: () => null, }, + drives: [], + mdRaids: [], }); }); @@ -224,6 +230,8 @@ describe("BootSelection", () => { isDefault: false, getDevice: () => sda, }, + drives: [], + mdRaids: [], }); }); diff --git a/web/src/components/storage/ConfigureDeviceMenu.test.tsx b/web/src/components/storage/ConfigureDeviceMenu.test.tsx index b403fdd1fd..4af15829ec 100644 --- a/web/src/components/storage/ConfigureDeviceMenu.test.tsx +++ b/web/src/components/storage/ConfigureDeviceMenu.test.tsx @@ -72,7 +72,7 @@ const mockUseModel = jest.fn(); jest.mock("~/hooks/storage/system", () => ({ ...jest.requireActual("~/hooks/storage/system"), - useCandidateDevices: () => [vda, vdb], + useAvailableDevices: () => [vda, vdb], })); jest.mock("~/hooks/storage/model", () => ({ diff --git a/web/src/components/storage/LvmPage.test.tsx b/web/src/components/storage/LvmPage.test.tsx index 2f90ec6d6a..890e81d415 100644 --- a/web/src/components/storage/LvmPage.test.tsx +++ b/web/src/components/storage/LvmPage.test.tsx @@ -161,7 +161,7 @@ jest.mock("~/queries/storage", () => ({ jest.mock("~/hooks/storage/system", () => ({ ...jest.requireActual("~/hooks/storage/system"), - useCandidateDevices: () => mockUseAllDevices, + useAvailableDevices: () => mockUseAllDevices, })); jest.mock("~/hooks/storage/model", () => ({ diff --git a/web/src/components/storage/PartitionsMenu.test.tsx b/web/src/components/storage/PartitionsMenu.test.tsx index 61ce0c8ff7..0f7f39695d 100644 --- a/web/src/components/storage/PartitionsMenu.test.tsx +++ b/web/src/components/storage/PartitionsMenu.test.tsx @@ -89,14 +89,12 @@ jest.mock("~/hooks/storage/partition", () => ({ useDeletePartition: () => mockDeletePartition, })); -// FIXME: enable it back once wording is adapted in both, the component and the -// test. -xdescribe("PartitionMenuItem", () => { +describe("PartitionMenuItem", () => { it("allows users to delete a not required partition", async () => { const { user } = plainRender(); - const partitionsButton = screen.getByRole("button", { name: "Partitions" }); - await user.click(partitionsButton); + const detailsButton = screen.getByRole("button", { name: /Details for .*sda/ }); + await user.click(detailsButton); const partitionsMenu = screen.getByRole("menu"); const deleteSwapButton = within(partitionsMenu).getByRole("menuitem", { name: "Delete swap", @@ -108,8 +106,8 @@ xdescribe("PartitionMenuItem", () => { it("allows users to delete a required partition", async () => { const { user } = plainRender(); - const partitionsButton = screen.getByRole("button", { name: "Partitions" }); - await user.click(partitionsButton); + const detailsButton = screen.getByRole("button", { name: /Details for .*sda/ }); + await user.click(detailsButton); const partitionsMenu = screen.getByRole("menu"); const deleteRootButton = within(partitionsMenu).getByRole("menuitem", { name: "Delete /", @@ -121,8 +119,8 @@ xdescribe("PartitionMenuItem", () => { it("allows users to edit a partition", async () => { const { user } = plainRender(); - const partitionsButton = screen.getByRole("button", { name: "Partitions" }); - await user.click(partitionsButton); + const detailsButton = screen.getByRole("button", { name: /Details for .*sda/ }); + await user.click(detailsButton); const partitionsMenu = screen.getByRole("menu"); const editSwapButton = within(partitionsMenu).getByRole("menuitem", { name: "Edit swap", From f98790cc65ebd0b71ac1b25aafbf59c337e1c4b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Wed, 16 Jul 2025 08:00:59 +0100 Subject: [PATCH 12/20] web: add tests --- .../storage/FormattableDevicePage.test.tsx | 219 ++++++++++++++++++ .../storage/FormattableDevicePage.tsx | 13 +- 2 files changed, 226 insertions(+), 6 deletions(-) create mode 100644 web/src/components/storage/FormattableDevicePage.test.tsx diff --git a/web/src/components/storage/FormattableDevicePage.test.tsx b/web/src/components/storage/FormattableDevicePage.test.tsx new file mode 100644 index 0000000000..867de7908f --- /dev/null +++ b/web/src/components/storage/FormattableDevicePage.test.tsx @@ -0,0 +1,219 @@ +/* + * Copyright (c) [2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 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 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. + */ + +import React from "react"; +import { screen, within } from "@testing-library/react"; +import { installerRender, mockParams } from "~/test-utils"; +import FormattableDevicePage from "~/components/storage/FormattableDevicePage"; +import { StorageDevice, model } from "~/types/storage"; +import { Volume } from "~/api/storage/types"; +import { gib } from "./utils"; + +const sda: StorageDevice = { + sid: 59, + isDrive: true, + type: "disk", + name: "/dev/sda", + size: gib(10), + description: "", +}; + +const sdaModel: model.Drive = { + name: "/dev/sda", + spacePolicy: "keep", + partitions: [], + list: "drives", + listIndex: 0, + isExplicitBoot: false, + isUsed: true, + isAddingPartitions: true, + isReusingPartitions: true, + isTargetDevice: false, + isBoot: true, + getMountPaths: jest.fn(), + getVolumeGroups: jest.fn(), + getPartition: jest.fn(), + getConfiguredExistingPartitions: jest.fn(), +}; + +const homeVolume: Volume = { + mountPath: "/home", + mountOptions: [], + target: "default", + fsType: "btrfs", + minSize: gib(1), + maxSize: gib(5), + autoSize: false, + snapshots: false, + transactional: false, + outline: { + required: false, + fsTypes: ["btrfs", "xfs"], + supportAutoSize: false, + snapshotsConfigurable: false, + snapshotsAffectSizes: false, + sizeRelevantVolumes: [], + adjustByRam: false, + }, +}; + +jest.mock("~/queries/issues", () => ({ + ...jest.requireActual("~/queries/issues"), + useIssues: () => [], +})); + +jest.mock("~/queries/storage", () => ({ + ...jest.requireActual("~/queries/storage"), + useDevices: () => [sda], +})); + +const mockModel = jest.fn(); +jest.mock("~/hooks/storage/model", () => ({ + ...jest.requireActual("~/hooks/storage/model"), + useModel: () => mockModel(), +})); + +jest.mock("~/hooks/storage/product", () => ({ + ...jest.requireActual("~/hooks/storage/product"), + useMissingMountPaths: () => ["/home", "swap"], + useVolume: () => homeVolume, +})); + +const mockAddFilesystem = jest.fn(); +jest.mock("~/hooks/storage/filesystem", () => ({ + ...jest.requireActual("~/hooks/storage/filesystem"), + useAddFilesystem: () => mockAddFilesystem, +})); + +beforeEach(() => { + mockParams({ list: "drives", listIndex: "0" }); + mockModel.mockReturnValue({ + drives: [sdaModel], + getMountPaths: () => [], + }); +}); + +describe("FormattableDevicePage", () => { + it("renders a form for formatting the device", async () => { + const { user } = installerRender(); + screen.getByRole("form", { name: "Configure device /dev/sda" }); + const mountPoint = screen.getByRole("button", { name: "Mount point toggle" }); + const filesystem = screen.getByRole("button", { name: "File system" }); + // File system and size fields disabled until valid mount point selected + expect(filesystem).toBeDisabled(); + expect(screen.queryByRole("textbox", { name: "File system label" })).not.toBeInTheDocument(); + + await user.click(mountPoint); + const mountPointOptions = screen.getByRole("listbox", { name: "Suggested mount points" }); + const homeMountPoint = within(mountPointOptions).getByRole("option", { name: "/home" }); + await user.click(homeMountPoint); + // Valid mount point selected, enable file system field + expect(filesystem).toBeEnabled(); + expect(screen.queryByRole("textbox", { name: "File system label" })).toBeInTheDocument(); + // Display available file systems + await user.click(filesystem); + screen.getByRole("listbox", { name: "Available file systems" }); + }); + + it("allows reseting the chosen mount point", async () => { + const { user } = installerRender(); + // Note that the underline PF component gives the role combobox to the input + const mountPoint = screen.getByRole("combobox", { name: "Mount point" }); + const filesystem = screen.getByRole("button", { name: "File system" }); + expect(mountPoint).toHaveValue(""); + // File system field is disabled until a valid mount point selected + expect(filesystem).toBeDisabled(); + const mountPointToggle = screen.getByRole("button", { name: "Mount point toggle" }); + await user.click(mountPointToggle); + const mountPointOptions = screen.getByRole("listbox", { name: "Suggested mount points" }); + const homeMountPoint = within(mountPointOptions).getByRole("option", { name: "/home" }); + await user.click(homeMountPoint); + expect(mountPoint).toHaveValue("/home"); + expect(filesystem).toBeEnabled(); + expect(screen.queryByRole("textbox", { name: "File system label" })).toBeInTheDocument(); + const clearMountPointButton = screen.getByRole("button", { + name: "Clear selected mount point", + }); + await user.click(clearMountPointButton); + expect(mountPoint).toHaveValue(""); + // File system field is disabled until a valid mount point selected + expect(filesystem).toBeDisabled(); + expect(screen.queryByRole("textbox", { name: "File system label" })).not.toBeInTheDocument(); + }); + + describe("if the device has already a filesystem config", () => { + const formattedSdaModel: model.Drive = { + ...sdaModel, + mountPath: "/home", + filesystem: { + default: false, + type: "xfs", + label: "HOME", + }, + }; + + beforeEach(() => { + mockModel.mockReturnValue({ + drives: [formattedSdaModel], + getMountPaths: () => [], + }); + }); + + it("initializes the form with the current values", async () => { + installerRender(); + const mountPointSelector = screen.getByRole("combobox", { name: "Mount point" }); + expect(mountPointSelector).toHaveValue("/home"); + const filesystemButton = screen.getByRole("button", { name: "File system" }); + within(filesystemButton).getByText("XFS"); + const label = screen.getByRole("textbox", { name: "File system label" }); + expect(label).toHaveValue("HOME"); + }); + }); + + describe("if the form is accepted", () => { + it("changes the device config", async () => { + const { user } = installerRender(); + const mountPointToggle = screen.getByRole("button", { name: "Mount point toggle" }); + await user.click(mountPointToggle); + const mountPointOptions = screen.getByRole("listbox", { name: "Suggested mount points" }); + const homeMountPoint = within(mountPointOptions).getByRole("option", { name: "/home" }); + await user.click(homeMountPoint); + const filesystemButton = screen.getByRole("button", { name: "File system" }); + await user.click(filesystemButton); + const filesystemOptions = screen.getByRole("listbox", { name: "Available file systems" }); + const xfs = within(filesystemOptions).getByRole("option", { name: "XFS" }); + await user.click(xfs); + const labelInput = screen.getByRole("textbox", { name: "File system label" }); + await user.type(labelInput, "TEST"); + const acceptButton = screen.getByRole("button", { name: "Accept" }); + await user.click(acceptButton); + expect(mockAddFilesystem).toHaveBeenCalledWith("drives", 0, { + mountPath: "/home", + filesystem: { + type: "xfs", + snapshots: false, + label: "TEST", + }, + }); + }); + }); +}); diff --git a/web/src/components/storage/FormattableDevicePage.tsx b/web/src/components/storage/FormattableDevicePage.tsx index 446b0d69a5..d1e68f6b31 100644 --- a/web/src/components/storage/FormattableDevicePage.tsx +++ b/web/src/components/storage/FormattableDevicePage.tsx @@ -20,6 +20,11 @@ * find current contact information at www.suse.com. */ +/** + * @fixme This file, PartitionPage and LogicalVolumePage need to be refactored in order to avoid + * code duplication. + */ + import React, { useId } from "react"; import { useParams, useNavigate } from "react-router-dom"; import { @@ -383,18 +388,14 @@ function FilesystemLabel({ id, value, onChange }: FilesystemLabelProps): React.R ); } -/** - * @fixme This component has to be adapted to use the new hooks from ~/hooks/storage/ instead of the - * deprecated hooks from ~/queries/storage/config-model. - */ export default function FormattableDevicePage() { const navigate = useNavigate(); const headingId = useId(); const [mountPoint, setMountPoint] = React.useState(NO_VALUE); const [filesystem, setFilesystem] = React.useState(NO_VALUE); const [filesystemLabel, setFilesystemLabel] = React.useState(NO_VALUE); - // Filesystem and size selectors should not be auto refreshed before the user interacts with other - // selectors like the mount point or the target selectors. + // Filesystem selectors should not be auto refreshed before the user interacts with the mount + // point selector. const [autoRefreshFilesystem, setAutoRefreshFilesystem] = React.useState(false); const initialValue = useInitialFormValue(); From 5bb9cca1302b27c92b0df31b0392143a29a54f40 Mon Sep 17 00:00:00 2001 From: Ancor Gonzalez Sosa Date: Wed, 16 Jul 2025 10:56:44 +0100 Subject: [PATCH 13/20] web: Use a MenuButton also for unused drives/mdRaids --- .../storage/DeviceEditorContent.tsx | 33 +------ web/src/components/storage/UnusedMenu.tsx | 88 +++++++++++++++++++ 2 files changed, 90 insertions(+), 31 deletions(-) create mode 100644 web/src/components/storage/UnusedMenu.tsx diff --git a/web/src/components/storage/DeviceEditorContent.tsx b/web/src/components/storage/DeviceEditorContent.tsx index f293ee00ee..65f5143f83 100644 --- a/web/src/components/storage/DeviceEditorContent.tsx +++ b/web/src/components/storage/DeviceEditorContent.tsx @@ -21,40 +21,11 @@ */ import React from "react"; -import { Stack, Flex, StackItem } from "@patternfly/react-core"; -import { generatePath } from "react-router-dom"; -import Link from "~/components/core/Link"; -import { STORAGE as PATHS } from "~/routes/paths"; +import UnusedMenu from "~/components/storage/UnusedMenu"; import FilesystemMenu from "~/components/storage/FilesystemMenu"; import PartitionsMenu from "~/components/storage/PartitionsMenu"; import SpacePolicyMenu from "~/components/storage/SpacePolicyMenu"; import { model, StorageDevice } from "~/types/storage"; -import { _ } from "~/i18n"; - -type DeviceEmptyStateProps = Pick; - -function DeviceEmptyState({ deviceModel }: DeviceEmptyStateProps): React.ReactNode { - const { list, listIndex } = deviceModel; - const newPartitionPath = generatePath(PATHS.addPartition, { list, listIndex }); - const formatDevicePath = generatePath(PATHS.formatDevice, { list, listIndex }); - - return ( - - - - - {_("Add a new partition or mount an existing one")} - - - - - {_("Mount the device")} - - - - - ); -} type DeviceEditorContentProps = { deviceModel: model.Drive | model.MdRaid; device: StorageDevice }; @@ -62,7 +33,7 @@ export default function DeviceEditorContent({ deviceModel, device, }: DeviceEditorContentProps): React.ReactNode { - if (!deviceModel.isUsed) return ; + if (!deviceModel.isUsed) return ; return ( <> diff --git a/web/src/components/storage/UnusedMenu.tsx b/web/src/components/storage/UnusedMenu.tsx new file mode 100644 index 0000000000..10f8024d4f --- /dev/null +++ b/web/src/components/storage/UnusedMenu.tsx @@ -0,0 +1,88 @@ +/* + * Copyright (c) [2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 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 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. + */ + +import React, { useId } from "react"; +import { Flex } from "@patternfly/react-core"; +import { useNavigate, generatePath } from "react-router-dom"; +import Text from "~/components/core/Text"; +import MenuButton from "~/components/core/MenuButton"; +import { STORAGE as PATHS } from "~/routes/paths"; +import { model } from "~/types/storage"; +import { sprintf } from "sprintf-js"; +import { _ } from "~/i18n"; + +type UnusedMenuProps = { deviceModel: model.Drive | model.MdRaid }; + +export default function UnusedMenu({ deviceModel }: UnusedMenuProps): React.ReactNode { + const navigate = useNavigate(); + const ariaLabelId = useId(); + const toggleTextId = useId(); + const { list, listIndex } = deviceModel; + const newPartitionPath = generatePath(PATHS.addPartition, { list, listIndex }); + const formatDevicePath = generatePath(PATHS.formatDevice, { list, listIndex }); + + // TRANSLATORS: %s is the name of device, like '/dev/sda'. + const detailsAriaLabel = sprintf(_("Details for %s"), deviceModel.name); + const description = _("Not configured yet"); + const filesystemLabel = + list === "drives" ? _("Use the disk without partitions") : _("Use the RAID without partitions"); + + return ( + + + {detailsAriaLabel} + + + {_("Details")} + + navigate(newPartitionPath)} + > + {_("Add or use partition")} + , + navigate(formatDevicePath)} + > + {filesystemLabel} + , + ]} + > + {description} + + + ); +} From 6e7ad57656b6cada20124a71ee5529efbaa61e9c Mon Sep 17 00:00:00 2001 From: Ancor Gonzalez Sosa Date: Wed, 16 Jul 2025 11:02:20 +0100 Subject: [PATCH 14/20] web: (Temporarily) remove button to delete a filesystem definition --- web/src/components/storage/FilesystemMenu.tsx | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/web/src/components/storage/FilesystemMenu.tsx b/web/src/components/storage/FilesystemMenu.tsx index 94198339a1..4785f7bf20 100644 --- a/web/src/components/storage/FilesystemMenu.tsx +++ b/web/src/components/storage/FilesystemMenu.tsx @@ -26,7 +26,6 @@ import { useNavigate, generatePath } from "react-router-dom"; import Text from "~/components/core/Text"; import MenuHeader from "~/components/core/MenuHeader"; import MenuButton from "~/components/core/MenuButton"; -import { useDeleteFilesystem } from "~/hooks/storage/filesystem"; import { STORAGE as PATHS } from "~/routes/paths"; import { model } from "~/types/storage"; import * as driveUtils from "~/components/storage/utils/drive"; @@ -58,7 +57,6 @@ export default function FilesystemMenu({ deviceModel }: FilesystemMenuProps): Re const navigate = useNavigate(); const ariaLabelId = useId(); const toggleTextId = useId(); - const deleteFilesystem = useDeleteFilesystem(); const { list, listIndex } = deviceModel; const editFilesystemPath = generatePath(PATHS.formatDevice, { list, listIndex }); @@ -92,14 +90,6 @@ export default function FilesystemMenu({ deviceModel }: FilesystemMenuProps): Re > {_("Edit")} , - deleteFilesystem(list, listIndex)} - > - {_("Do not configure")} - , ]} > {driveUtils.contentDescription(deviceModel)} From a70a488f65507d1b37bd70669105582413073c2a Mon Sep 17 00:00:00 2001 From: Ancor Gonzalez Sosa Date: Wed, 16 Jul 2025 11:31:14 +0100 Subject: [PATCH 15/20] web: More consistent texts accross similar forms --- web/src/components/storage/FormattableDevicePage.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/components/storage/FormattableDevicePage.tsx b/web/src/components/storage/FormattableDevicePage.tsx index d1e68f6b31..32901e57c3 100644 --- a/web/src/components/storage/FormattableDevicePage.tsx +++ b/web/src/components/storage/FormattableDevicePage.tsx @@ -300,7 +300,7 @@ function FilesystemOptions({ mountPoint }: FilesystemOptionsProps): React.ReactN const defaultOptText = volume.mountPath ? sprintf(_("Default file system for %s"), mountPoint) - : _("Default file system"); + : _("Default file system for generic mount paths"); const formatText = currentFilesystem ? _("Destroy current data and format device as") : _("Format device as"); @@ -317,7 +317,7 @@ function FilesystemOptions({ mountPoint }: FilesystemOptionsProps): React.ReactN value={REUSE_FILESYSTEM} description={ // TRANSLATORS: %s is the name of a device, like vda - sprintf(_("Do not format %s and keep the current data"), deviceBaseName(device, true)) + sprintf(_("Do not format %s and keep the data"), deviceBaseName(device, true)) } > From 222523364a62d1084eeb618b5ae0f7aef53ba3de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Wed, 16 Jul 2025 11:40:16 +0100 Subject: [PATCH 16/20] web: fix useVolume hook --- web/src/hooks/storage/product.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/web/src/hooks/storage/product.ts b/web/src/hooks/storage/product.ts index e7261848e3..54f7ce425c 100644 --- a/web/src/hooks/storage/product.ts +++ b/web/src/hooks/storage/product.ts @@ -47,6 +47,12 @@ function useMissingMountPaths(options?: QueryHookOptions): string[] { function useVolume(mountPath: string, options?: QueryHookOptions): Volume { const func = options?.suspense ? useSuspenseQuery : useQuery; + const { mountPoints } = useProductParams(options); + + // The query returns a volume with the given mount path, but we need the "generic" volume without + // mount path for an arbitrary mount path. Take it into account while refactoring the backend side + // in order to report all the volumes in a single call (e.g., as part of the product params). + if (!mountPoints.includes(mountPath)) mountPath = ""; const { data } = func(volumeQuery(mountPath)); return data; } From d1b6f98b1107f6c04cf2cf19ef8250cce384a2de Mon Sep 17 00:00:00 2001 From: Ancor Gonzalez Sosa Date: Wed, 16 Jul 2025 13:30:44 +0100 Subject: [PATCH 17/20] web: More precise texts at ConfigureDeviceMenu --- .../storage/ConfigureDeviceMenu.tsx | 69 +++++++++++++------ 1 file changed, 47 insertions(+), 22 deletions(-) diff --git a/web/src/components/storage/ConfigureDeviceMenu.tsx b/web/src/components/storage/ConfigureDeviceMenu.tsx index 6045872647..fe01608c0e 100644 --- a/web/src/components/storage/ConfigureDeviceMenu.tsx +++ b/web/src/components/storage/ConfigureDeviceMenu.tsx @@ -35,36 +35,59 @@ import { StorageDevice } from "~/types/storage"; import DeviceSelectorModal from "./DeviceSelectorModal"; type AddDeviceMenuItemProps = { + /** Whether some of the available devices is an MD RAID */ + withRaids: boolean; /** Available devices to be chosen */ devices: StorageDevice[]; /** The total amount of drives and RAIDs already configured */ usedCount: number; } & MenuItemProps; -const AddDeviceTitle = ({ usedCount }) => - usedCount - ? _("Select another disk to define partitions") - : _("Select a disk to define partitions"); +const AddDeviceTitle = ({ withRaids, usedCount }) => { + if (withRaids) { + if (usedCount === 0) return _("Select a device to define partitions or to mount"); + return _("Select another device to define partitions or to mount"); + } -const AddDeviceDescription = ({ usedCount, isDisabled = false }) => { - if (isDisabled) return _("Already using all available disks"); + if (usedCount === 0) return _("Select a disk to define partitions or to mount"); + return _("Select another disk to define partitions or to mount"); +}; + +const AddDeviceDescription = ({ withRaids, usedCount, isDisabled = false }) => { + if (isDisabled) { + if (withRaids) return _("Already using all available devices"); + return _("Already using all available disks"); + } - return usedCount - ? sprintf( + if (usedCount) { + if (withRaids) + return sprintf( n_( - "Extend the installation beyond the currently selected disk", - "Extend the installation beyond the current %d disks", + "Extend the installation beyond the currently selected device", + "Extend the installation beyond the current %d devices", usedCount, ), usedCount, - ) - : _("Start configuring a basic installation"); + ); + + return sprintf( + n_( + "Extend the installation beyond the currently selected disk", + "Extend the installation beyond the current %d disks", + usedCount, + ), + usedCount, + ); + } + + return _("Start configuring a basic installation"); }; /** * Internal component holding the logic for rendering the disks drilldown menu */ const AddDeviceMenuItem = ({ + withRaids, usedCount, devices, onClick, @@ -75,10 +98,16 @@ const AddDeviceMenuItem = ({ } + description={ + + } onClick={onClick} > - + ); @@ -87,12 +116,6 @@ const AddDeviceMenuItem = ({ /** * Menu that provides options for users to configure storage drives * - * It uses a drilled-down menu approach for disks, making the available options less - * overwhelming by presenting them in a more organized manner. - * - * TODO: Refactor and test the component after extracting a basic DrillDown menu to - * share the internal logic with other potential menus that could benefit from a similar - * approach. */ export default function ConfigureDeviceMenu(): React.ReactNode { const [deviceSelectorOpen, setDeviceSelectorOpen] = useState(false); @@ -109,6 +132,7 @@ export default function ConfigureDeviceMenu(): React.ReactNode { const usedDevicesNames = model.drives.concat(model.mdRaids).map((d) => d.name); const usedDevicesCount = usedDevicesNames.length; const devices = allDevices.filter((d) => !usedDevicesNames.includes(d.name)); + const withRaids = !!allDevices.filter((d) => !d.isDrive).length; const addDevice = (device: StorageDevice) => { const hook = device.isDrive ? addDrive : addReusedMdRaid; @@ -130,6 +154,7 @@ export default function ConfigureDeviceMenu(): React.ReactNode { key="select-disk-option" usedCount={usedDevicesCount} devices={devices} + withRaids={withRaids} onClick={openDeviceSelector} />, , @@ -147,8 +172,8 @@ export default function ConfigureDeviceMenu(): React.ReactNode { {deviceSelectorOpen && ( } - description={} + title={} + description={} onCancel={closeDeviceSelector} onConfirm={([device]) => { addDevice(device); From 21eeb717275bd08a2bab83cbebb925bb5de7130b Mon Sep 17 00:00:00 2001 From: Ancor Gonzalez Sosa Date: Wed, 16 Jul 2025 13:41:19 +0100 Subject: [PATCH 18/20] web: Small fix at LogicalVolumePage --- web/src/components/storage/LogicalVolumePage.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/web/src/components/storage/LogicalVolumePage.tsx b/web/src/components/storage/LogicalVolumePage.tsx index 57ddefc7a8..2a8b3a2a41 100644 --- a/web/src/components/storage/LogicalVolumePage.tsx +++ b/web/src/components/storage/LogicalVolumePage.tsx @@ -472,9 +472,10 @@ type FilesystemOptionsProps = { function FilesystemOptions({ mountPoint }: FilesystemOptionsProps): React.ReactNode { const defaultFilesystem = useDefaultFilesystem(mountPoint); const usableFilesystems = useUsableFilesystems(mountPoint); + const volume = useVolume(mountPoint); const defaultOptText = - mountPoint !== NO_VALUE + mountPoint !== NO_VALUE && volume.mountPath ? sprintf(_("Default file system for %s"), mountPoint) : _("Default file system for generic logical volumes"); From fe2db586d27960b7717f49ca69339e66c623f4e8 Mon Sep 17 00:00:00 2001 From: Ancor Gonzalez Sosa Date: Wed, 16 Jul 2025 15:09:09 +0100 Subject: [PATCH 19/20] web: More consistent string when formatting a whole device --- web/src/components/storage/FilesystemMenu.tsx | 26 ++++++++++--------- web/src/components/storage/utils/drive.tsx | 15 ++++------- 2 files changed, 19 insertions(+), 22 deletions(-) diff --git a/web/src/components/storage/FilesystemMenu.tsx b/web/src/components/storage/FilesystemMenu.tsx index 4785f7bf20..383c9e4fba 100644 --- a/web/src/components/storage/FilesystemMenu.tsx +++ b/web/src/components/storage/FilesystemMenu.tsx @@ -31,24 +31,26 @@ import { model } from "~/types/storage"; import * as driveUtils from "~/components/storage/utils/drive"; import { sprintf } from "sprintf-js"; import { _ } from "~/i18n"; -import { filesystemType } from "~/components/storage/utils"; +import { filesystemType, formattedPath } from "~/components/storage/utils"; function deviceDescription(deviceModel: FilesystemMenuProps["deviceModel"]): string { const fs = filesystemType(deviceModel.filesystem); const mountPath = deviceModel.mountPath; const reuse = deviceModel.filesystem.reuse; - // TRANSLATORS: %1$s is a filesystem type (eg. Btrfs), %2$s is a mount point (eg. /home). - if (reuse && fs && mountPath) return sprintf(_("Mount current %1$s at %2$s"), fs, mountPath); - // TRANSLATORS: %1$s is a mount point (eg. /home). - if (reuse && mountPath) return sprintf(_("Mount at %1$s"), mountPath); - // TRANSLATORS: %1$s is a filesystem type (eg. Btrfs). - if (reuse && fs) return sprintf(_("Reuse current %1$s"), fs); - if (reuse) return _("Reuse current file system"); - // TRANSLATORS: %1$s is a filesystem type (eg. Btrfs), %2$s is a mount point (eg. /home). - if (mountPath) return sprintf(_("Format as %1$s for %2$s"), fs, mountPath); - // TRANSLATORS: %1$s is a filesystem type (eg. Btrfs). - return sprintf(_("Format as %1$s"), fs); + // I don't think this can happen, maybe when loading a configuration not created with the UI + if (!mountPath) { + if (reuse) return _("The device will be mounted"); + return _("The device will be formatted"); + } + + const path = formattedPath(mountPath); + + // TRANSLATORS: %s is a formatted mount point (eg. '"/home'"). + if (reuse) return sprintf(_("The current file system will be mounted at %s"), path); + + // TRANSLATORS: %1$s is a filesystem type (eg. Btrfs), %2$s is a mount point (eg. '"/home"'). + return sprintf(_("The device will be formatted as %1$s and mounted at %2$s"), fs, path); } type FilesystemMenuProps = { deviceModel: model.Drive | model.MdRaid }; diff --git a/web/src/components/storage/utils/drive.tsx b/web/src/components/storage/utils/drive.tsx index 0b0667e456..907c6a0c53 100644 --- a/web/src/components/storage/utils/drive.tsx +++ b/web/src/components/storage/utils/drive.tsx @@ -23,13 +23,7 @@ import { _, n_, formatList } from "~/i18n"; import { apiModel } from "~/api/storage/types"; import { Drive } from "~/types/storage/model"; -import { - SpacePolicy, - SPACE_POLICIES, - baseName, - formattedPath, - filesystemType, -} from "~/components/storage/utils"; +import { SpacePolicy, SPACE_POLICIES, baseName, formattedPath } from "~/components/storage/utils"; import { sprintf } from "sprintf-js"; /** @@ -155,10 +149,11 @@ const contentDescription = (drive: apiModel.Drive): string => { if (drive.filesystem) { if (drive.mountPath) { - return sprintf(_("The device will be used for %s"), drive.mountPath); - } else { - return sprintf(_("The device will formatted as %s"), filesystemType(drive.filesystem)); + return sprintf(_("The whole device will be used for %s"), formattedPath(drive.mountPath)); } + + // I don't think this can happen, maybe when loading a configuration not created with the UI + return _("A file system will be used for the whole device"); } if (newPartitions.length === 0) { From 0852f87d8905644eaa24cdfdb144f829c4fb84f8 Mon Sep 17 00:00:00 2001 From: Ancor Gonzalez Sosa Date: Wed, 16 Jul 2025 15:48:24 +0100 Subject: [PATCH 20/20] Changelog --- web/package/agama-web-ui.changes | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/web/package/agama-web-ui.changes b/web/package/agama-web-ui.changes index 23bf71e027..58bd85c551 100644 --- a/web/package/agama-web-ui.changes +++ b/web/package/agama-web-ui.changes @@ -1,3 +1,9 @@ +------------------------------------------------------------------- +Wed Jul 16 14:46:32 UTC 2025 - Ancor Gonzalez Sosa + +- Allow to use a whole disk or MD RAID without a partition table + (gh#agama-project/agama#2559). + ------------------------------------------------------------------- Mon Jul 14 16:02:55 UTC 2025 - José Iván López González