From d3cd1e71d1aadc3d4c523580ba1ce597bc0cc999 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Mon, 26 Aug 2024 13:11:50 +0100 Subject: [PATCH 01/53] refactor(web): convert ProposalPage and Drawer to TypeScript --- web/src/components/core/{Drawer.jsx => Drawer.tsx} | 12 ++++++++---- web/src/components/storage/ProposalActionsDialog.jsx | 2 +- .../components/storage/ProposalActionsSummary.jsx | 2 +- .../storage/{ProposalPage.jsx => ProposalPage.tsx} | 2 ++ web/src/utils.js | 2 +- 5 files changed, 13 insertions(+), 7 deletions(-) rename web/src/components/core/{Drawer.jsx => Drawer.tsx} (90%) rename web/src/components/storage/{ProposalPage.jsx => ProposalPage.tsx} (98%) diff --git a/web/src/components/core/Drawer.jsx b/web/src/components/core/Drawer.tsx similarity index 90% rename from web/src/components/core/Drawer.jsx rename to web/src/components/core/Drawer.tsx index 97b23dfee0..c52bb34d65 100644 --- a/web/src/components/core/Drawer.jsx +++ b/web/src/components/core/Drawer.tsx @@ -19,9 +19,7 @@ * find current contact information at www.suse.com. */ -// FIXME: rewrite to .tsx - -import React, { forwardRef, useImperativeHandle, useState } from "react"; +import React, { ReactNode, forwardRef, useImperativeHandle, useState } from "react"; import { Drawer as PFDrawer, DrawerPanelBody, @@ -31,8 +29,14 @@ import { DrawerHead, DrawerActions, DrawerCloseButton, + DrawerProps as PFDrawerProps } from "@patternfly/react-core"; +type DrawerProps = { + panelHeader: ReactNode, + panelContent: ReactNode, +} & PFDrawerProps; + /** * PF/Drawer wrapper * @@ -43,7 +47,7 @@ import { * * @todo write documentation */ -const Drawer = forwardRef(({ panelHeader, panelContent, isExpanded = false, children }, ref) => { +const Drawer = forwardRef(({ panelHeader, panelContent, isExpanded = false, children }: DrawerProps, ref) => { const [isOpen, setIsOpen] = useState(isExpanded); const open = () => setIsOpen(true); const close = () => setIsOpen(false); diff --git a/web/src/components/storage/ProposalActionsDialog.jsx b/web/src/components/storage/ProposalActionsDialog.jsx index e3874dc578..79c3a2562d 100644 --- a/web/src/components/storage/ProposalActionsDialog.jsx +++ b/web/src/components/storage/ProposalActionsDialog.jsx @@ -51,7 +51,7 @@ const ActionsList = ({ actions }) => { * @param {object} props * @param {object[]} [props.actions=[]] - The actions to perform in the system. * @param {boolean} [props.isOpen=false] - Whether the dialog is visible or not. - * @param {() => void} props.onClose - Whether the dialog is visible or not. + * @param {() => void} [props.onClose] - Whether the dialog is visible or not. */ export default function ProposalActionsDialog({ actions = [] }) { const [isExpanded, setIsExpanded] = useState(false); diff --git a/web/src/components/storage/ProposalActionsSummary.jsx b/web/src/components/storage/ProposalActionsSummary.jsx index e5ede0261c..47b465b61a 100644 --- a/web/src/components/storage/ProposalActionsSummary.jsx +++ b/web/src/components/storage/ProposalActionsSummary.jsx @@ -200,7 +200,7 @@ const ActionsSkeleton = () => ( * @param {Action[]} [props.actions=[]] * @param {SpaceAction[]} [props.spaceActions=[]] * @param {StorageDevice[]} props.devices - * @param {() => void} props.onActionsClick + * @param {() => void|undefined} props.onActionsClick */ export default function ProposalActionsSummary({ isLoading, diff --git a/web/src/components/storage/ProposalPage.jsx b/web/src/components/storage/ProposalPage.tsx similarity index 98% rename from web/src/components/storage/ProposalPage.jsx rename to web/src/components/storage/ProposalPage.tsx index 16fd0efbea..241046f3d5 100644 --- a/web/src/components/storage/ProposalPage.jsx +++ b/web/src/components/storage/ProposalPage.tsx @@ -326,6 +326,8 @@ export default function ProposalPage() { actions={state.actions} spaceActions={state.settings.spaceActions} devices={state.settings.installationDevices} + // @ts-expect-error: we do not know how to specify the type of + // drawerRef properly and TS does not find the "open" property onActionsClick={drawerRef.current?.open} isLoading={showSkeleton(state.loading, "ProposalActionsSummary", state.changing)} /> diff --git a/web/src/utils.js b/web/src/utils.js index 019ddd69b8..eeb05da1c0 100644 --- a/web/src/utils.js +++ b/web/src/utils.js @@ -298,7 +298,7 @@ const hex = (value) => { * * @todo This conversion will not be needed after adapting Section to directly work with issues. * - * @param {import("~/client/mixins").Issue} issue + * @param {import("~/types/issues").Issue} issue * @returns {import("~/client/mixins").ValidationError} */ const toValidationError = (issue) => ({ message: issue.description }); From cc0d7b0ee5403295844f5bf5ee38d9d65be55f8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Mon, 2 Sep 2024 12:08:21 +0100 Subject: [PATCH 02/53] refactor(web): extract storage types to TypeScript --- web/src/types/storage.ts | 185 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 185 insertions(+) create mode 100644 web/src/types/storage.ts diff --git a/web/src/types/storage.ts b/web/src/types/storage.ts new file mode 100644 index 0000000000..6cbb3b2692 --- /dev/null +++ b/web/src/types/storage.ts @@ -0,0 +1,185 @@ +/* + * 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. + */ + +type StorageDevice = { + sid: number; + name: string; + description: string; + isDrive: boolean; + type: string; + vendor?: string; + model?: string; + driver?: string[]; + bus?: string; + busId?: string; + transport?: string; + sdCard?: boolean; + dellBOSS?: boolean; + devices?: StorageDevice[]; + wires?: StorageDevice[]; + level?: string; + uuid?: string; + start?: number; + active?: boolean; + encrypted?: boolean; + isEFI?: boolean; + size?: number; + shrinking?: ShrinkingInfo; + systems?: string[]; + udevIds?: string[]; + udevPaths?: string[]; + partitionTable?: PartitionTable; + filesystem?: Filesystem; + component?: Component; + physicalVolumes?: StorageDevice[]; + logicalVolumes?: StorageDevice[]; +} + +type PartitionTable = { + type: string, + partitions: StorageDevice[], + unusedSlots: PartitionSlot[], + unpartitionedSize: number +} + +type PartitionSlot = { + start: number, + size: number +} + +type Component = { + // FIXME: should it be DeviceType? + type: string, + deviceNames: string[], +} + +type Filesystem = { + sid: number, + type: string, + mountPath?: string, + label?: string +} + +type ShrinkingInfo = { + supported?: number; + unsupported?: string[] +} + +type ProposalResult = { + settings: ProposalSettings, + actions: Action[] +} + +type Action = { + device: number; + text: string; + subvol: boolean; + delete: boolean; + resize: boolean; +} + +type ProposalSettings = { + target: ProposalTarget; + targetDevice?: string; + targetPVDevices: string[]; + configureBoot: boolean; + bootDevice: string; + defaultBootDevice: string; + encryptionPassword: string; + encryptionMethod: string; + spacePolicy: string; + spaceActions: SpaceAction[]; + volumes: Volume[]; + installationDevices: StorageDevice[]; +}; + +type SpaceAction = { + device: string; + action: string; +}; + +type Volume = { + mountPath: string; + target: VolumeTarget; + targetDevice?: StorageDevice; + fsType: string; + minSize: number; + maxSize?: number; + autoSize: boolean; + snapshots: boolean; + transactional: boolean; + outline: VolumeOutline; +}; + +type VolumeOutline = { + required: boolean; + productDefined: boolean; + fsTypes: string[]; + adjustByRam: boolean; + supportAutoSize: boolean; + snapshotsConfigurable: boolean; + snapshotsAffectSizes: boolean; + sizeRelevantVolumes: string[]; +} + +/** + * Enum for the possible proposal targets. + * + * @readonly + */ +enum ProposalTarget { + DISK = "disk", + NEW_LVM_VG = "newLvmVg", + REUSED_LVM_VG = "reusedLvmVg", +}; + +/** + * Enum for the possible volume targets. + * + * @readonly + */ +enum VolumeTarget { + DEFAULT = "default", + NEW_PARTITION = "new_partition", + NEW_VG = "new_vg", + DEVICE = "device", + FILESYSTEM = "filesystem", +}; + +export type { + Action, + Component, + Filesystem, + PartitionSlot, + PartitionTable, + ProposalResult, + ProposalSettings, + ShrinkingInfo, + SpaceAction, + StorageDevice, + Volume, + VolumeOutline, +}; + +export { + VolumeTarget, + ProposalTarget +}; From 427ad3187f14bab2216945a07abd4996bacab2e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Mon, 2 Sep 2024 12:12:34 +0100 Subject: [PATCH 03/53] feat(web): add API storage types --- web/src/api/storage/types.ts | 414 +++++++++++++++++++++++++++++++++++ 1 file changed, 414 insertions(+) create mode 100644 web/src/api/storage/types.ts diff --git a/web/src/api/storage/types.ts b/web/src/api/storage/types.ts new file mode 100644 index 0000000000..dde3b7576e --- /dev/null +++ b/web/src/api/storage/types.ts @@ -0,0 +1,414 @@ +// This file is auto-generated by @hey-api/openapi-ts + +/** + * Represents a single change action done to storage + */ +export type Action = { + delete: boolean; + device: DeviceSid; + resize: boolean; + subvol: boolean; + text: string; +}; + +export type BlockDevice = { + active: boolean; + encrypted: boolean; + shrinking: ShrinkingInfo; + size: DeviceSize; + start: number; + systems: Array<(string)>; + udevIds: Array<(string)>; + udevPaths: Array<(string)>; +}; + +export type Component = { + deviceNames: Array<(string)>; + devices: Array; + type: string; +}; + +/** + * Information about system device created by composition to reflect different devices on system + */ +export type Device = { + blockDevice?: ((BlockDevice) | null); + component?: ((Component) | null); + deviceInfo: DeviceInfo; + drive?: ((Drive) | null); + filesystem?: ((Filesystem) | null); + lvmLv?: ((LvmLv) | null); + lvmVg?: ((LvmVg) | null); + md?: ((Md) | null); + multipath?: ((Multipath) | null); + partition?: ((Partition) | null); + partitionTable?: ((PartitionTable) | null); + raid?: ((Raid) | null); +}; + +export type DeviceInfo = { + description: string; + name: string; + sid: DeviceSid; +}; + +export type DeviceSid = number; + +export type DeviceSize = number; + +export type DiscoverParams = { + /** + * iSCSI server address. + */ + address: string; + options?: ISCSIAuth; + /** + * iSCSI service port. + */ + port: number; +}; + +export type Drive = { + bus: string; + busId: string; + driver: Array<(string)>; + info: DriveInfo; + model: string; + transport: string; + type: string; + vendor: string; +}; + +export type DriveInfo = { + dellBOSS: boolean; + sdCard: boolean; +}; + +export type Filesystem = { + label: string; + mountPath: string; + sid: DeviceSid; + type: string; +}; + +export type ISCSIAuth = { + /** + * Password for authentication by target. + */ + password?: (string) | null; + /** + * Password for authentication by initiator. + */ + reverse_password?: (string) | null; + /** + * Username for authentication by initiator. + */ + reverse_username?: (string) | null; + /** + * Username for authentication by target. + */ + username?: (string) | null; +}; + +export type ISCSIInitiator = { + ibft: boolean; + name: string; +}; + +/** + * ISCSI node + */ +export type ISCSINode = { + /** + * Target IP address (in string-like form). + */ + address: string; + /** + * Whether the node is connected (there is a session). + */ + connected: boolean; + /** + * Whether the node was initiated by iBFT + */ + ibft: boolean; + /** + * Artificial ID to match it against the D-Bus backend. + */ + id: number; + /** + * Interface name. + */ + interface: string; + /** + * Target port. + */ + port: number; + /** + * Startup status (TODO: document better) + */ + startup: string; + /** + * Target name. + */ + target: string; +}; + +export type InitiatorParams = { + /** + * iSCSI initiator name. + */ + name: string; +}; + +export type LoginParams = ISCSIAuth & { + /** + * Startup value. + */ + startup: string; +}; + +export type LoginResult = 'Success' | 'InvalidStartup' | 'Failed'; + +export type LvmLv = { + volumeGroup: DeviceSid; +}; + +export type LvmVg = { + logicalVolumes: Array; + physicalVolumes: Array; + size: DeviceSize; +}; + +export type Md = { + devices: Array; + level: string; + uuid: string; +}; + +export type Multipath = { + wires: Array<(string)>; +}; + +export type NodeParams = { + /** + * Startup value. + */ + startup: string; +}; + +export type Partition = { + device: DeviceSid; + efi: boolean; +}; + +export type PartitionTable = { + partitions: Array; + type: string; + unusedSlots: Array; +}; + +export type PingResponse = { + /** + * API status + */ + status: string; +}; + +export type ProductParams = { + /** + * Encryption methods allowed by the product. + */ + encryptionMethods: Array<(string)>; + /** + * Mount points defined by the product. + */ + mountPoints: Array<(string)>; +}; + +/** + * Represents a proposal configuration + */ +export type ProposalSettings = { + bootDevice: string; + configureBoot: boolean; + defaultBootDevice: string; + encryptionMethod: string; + encryptionPBKDFunction: string; + encryptionPassword: string; + spaceActions: Array; + spacePolicy: string; + target: ProposalTarget; + targetDevice?: (string) | null; + targetPVDevices?: Array<(string)> | null; + volumes: Array; +}; + +/** + * Represents a proposal patch -> change of proposal configuration that can be partial + */ +export type ProposalSettingsPatch = { + bootDevice?: (string) | null; + configureBoot?: (boolean) | null; + encryptionMethod?: (string) | null; + encryptionPBKDFunction?: (string) | null; + encryptionPassword?: (string) | null; + spaceActions?: Array | null; + spacePolicy?: (string) | null; + target?: ((ProposalTarget) | null); + targetDevice?: (string) | null; + targetPVDevices?: Array<(string)> | null; + volumes?: Array | null; +}; + +export type ProposalTarget = 'disk' | 'newLvmVg' | 'reusedLvmVg'; + +export type Raid = { + devices: Array<(string)>; +}; + +export type ShrinkingInfo = { + supported: DeviceSize; +} | { + unsupported: Array<(string)>; +}; + +export type SpaceAction = 'force_delete' | 'resize'; + +export type SpaceActionSettings = { + action: SpaceAction; + device: string; +}; + +export type UnusedSlot = { + size: DeviceSize; + start: number; +}; + +/** + * Represents a single volume + */ +export type Volume = { + autoSize: boolean; + fsType: string; + maxSize?: ((DeviceSize) | null); + minSize?: ((DeviceSize) | null); + mountOptions: Array<(string)>; + mountPath: string; + outline?: ((VolumeOutline) | null); + snapshots: boolean; + target: VolumeTarget; + targetDevice?: (string) | null; + transactional?: (boolean) | null; +}; + +/** + * Represents volume outline aka requirements for volume + */ +export type VolumeOutline = { + adjustByRam: boolean; + fsTypes: Array<(string)>; + /** + * whether it is required + */ + required: boolean; + sizeRelevantVolumes: Array<(string)>; + snapshotsAffectSizes: boolean; + snapshotsConfigurable: boolean; + supportAutoSize: boolean; +}; + +/** + * Represents value for target key of Volume + * It is snake cased when serializing to be compatible with yast2-storage-ng. + */ +export type VolumeTarget = 'default' | 'new_partition' | 'new_vg' | 'device' | 'filesystem'; + +export type DevicesDirtyResponse = (boolean); + +export type StagingDevicesResponse = (Array); + +export type SystemDevicesResponse = (Array); + +export type DiscoverData = { + requestBody: DiscoverParams; +}; + +export type DiscoverResponse = (void); + +export type InitiatorResponse = (ISCSIInitiator); + +export type UpdateInitiatorData = { + requestBody: InitiatorParams; +}; + +export type UpdateInitiatorResponse = (void); + +export type NodesResponse = (Array); + +export type UpdateNodeData = { + /** + * iSCSI artificial ID. + */ + id: number; + requestBody: NodeParams; +}; + +export type UpdateNodeResponse = (NodeParams); + +export type DeleteNodeData = { + /** + * iSCSI artificial ID. + */ + id: number; +}; + +export type DeleteNodeResponse = (void); + +export type LoginNodeData = { + /** + * iSCSI artificial ID. + */ + id: number; + requestBody: LoginParams; +}; + +export type LoginNodeResponse = (void); + +export type LogoutNodeData = { + /** + * iSCSI artificial ID. + */ + id: number; +}; + +export type LogoutNodeResponse = (void); + +export type StorageProbeResponse = (unknown); + +export type ProductParamsResponse = (ProductParams); + +export type VolumeForData = { + /** + * Mount path of the volume (empty for an arbitrary volume). + */ + mountPath: string; +}; + +export type VolumeForResponse = (Volume); + +export type ActionsResponse = (Array); + +export type GetProposalSettingsResponse = (ProposalSettings); + +export type SetProposalSettingsData = { + /** + * Proposal settings + */ + requestBody: ProposalSettingsPatch; +}; + +export type SetProposalSettingsResponse = (boolean); + +export type UsableDevicesResponse = (Array); + +export type PingResponse2 = (PingResponse); \ No newline at end of file From fbe481875a9759ac9a7671e0a70fd7f574563ea3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Mon, 2 Sep 2024 12:13:33 +0100 Subject: [PATCH 04/53] refactor(web): replace DevicesManager with api/storage/devices --- web/src/api/storage/devices.ts | 160 +++++++++++++++++ web/src/client/storage.js | 183 +------------------- web/src/components/storage/ProposalPage.tsx | 5 +- 3 files changed, 170 insertions(+), 178 deletions(-) create mode 100644 web/src/api/storage/devices.ts diff --git a/web/src/api/storage/devices.ts b/web/src/api/storage/devices.ts new file mode 100644 index 0000000000..b3147f7a44 --- /dev/null +++ b/web/src/api/storage/devices.ts @@ -0,0 +1,160 @@ +/* + * 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. + */ + +import { get } from "~/api/http"; +import { Component, Device, Drive, Filesystem, LvmLv, LvmVg, Md, Multipath, Partition, PartitionTable, Raid } from "./types"; +import { StorageDevice } from "~/types/storage"; + +/** + * Returns the list of devices in the given scope + * + * @param scope - "system": devices in the current state of the system; "result": + * devices in the proposal ("stage") + */ +const fetchDevices = async (scope: "result" | "system") => { + const buildDevice = (jsonDevice: Device, jsonDevices: Device[]) => { + const buildDefaultDevice = (): StorageDevice => { + return { + sid: 0, + name: "", + description: "", + isDrive: false, + type: "drive", + }; + }; + + const buildCollectionFromNames = (names: string[]): StorageDevice[] => { + return names.map((name) => ({ ...buildDefaultDevice(), name })); + }; + + const buildCollection = (sids: number[], jsonDevices: Device[]): StorageDevice[] => { + if (sids === null || sids === undefined) return []; + + return sids.map((sid) => + buildDevice( + jsonDevices.find((dev) => dev.deviceInfo?.sid === sid), + jsonDevices, + ), + ); + }; + + const addDriveInfo = (device: StorageDevice, info: Drive) => { + device.isDrive = true; + device.type = info.type; + device.vendor = info.vendor; + device.model = info.model; + device.driver = info.driver; + device.bus = info.bus; + device.busId = info.busId; + device.transport = info.transport; + device.sdCard = info.info.sdCard; + device.dellBOSS = info.info.dellBOSS; + }; + + const addRaidInfo = (device: StorageDevice, info: Raid) => { + device.devices = buildCollectionFromNames(info.devices); + }; + + const addMultipathInfo = (device: StorageDevice, info: Multipath) => { + device.wires = buildCollectionFromNames(info.wires); + }; + + const addMDInfo = (device: StorageDevice, info: Md) => { + device.type = "md"; + device.level = info.level; + device.uuid = info.uuid; + device.devices = buildCollection(info.devices, jsonDevices); + }; + + const addPartitionInfo = (device: StorageDevice, info: Partition) => { + device.type = "partition"; + device.isEFI = info.efi; + }; + + const addVgInfo = (device: StorageDevice, info: LvmVg) => { + device.type = "lvmVg"; + device.size = info.size; + device.physicalVolumes = buildCollection(info.physicalVolumes, jsonDevices); + device.logicalVolumes = buildCollection(info.logicalVolumes, jsonDevices); + }; + + const addLvInfo = (device: StorageDevice, _info: LvmLv) => { + device.type = "lvmLv"; + }; + + const addPTableInfo = (device: StorageDevice, tableInfo: PartitionTable) => { + const partitions = buildCollection(tableInfo.partitions, jsonDevices); + device.partitionTable = { + type: tableInfo.type, + partitions, + unpartitionedSize: device.size - partitions.reduce((s, p) => s + p.size, 0), + unusedSlots: tableInfo.unusedSlots.map((s) => Object.assign({}, s)), + }; + }; + + const addFilesystemInfo = (device: StorageDevice, filesystemInfo: Filesystem) => { + const buildMountPath = (path: string) => (path.length > 0 ? path : undefined); + const buildLabel = (label: string) => (label.length > 0 ? label : undefined); + device.filesystem = { + sid: filesystemInfo.sid, + type: filesystemInfo.type, + mountPath: buildMountPath(filesystemInfo.mountPath), + label: buildLabel(filesystemInfo.label), + }; + }; + + const addComponentInfo = (device: StorageDevice, info: Component) => { + device.component = { + type: info.type, + deviceNames: info.deviceNames, + }; + }; + + const device = buildDefaultDevice(); + + const process = (jsonProperty: string, method: Function) => { + const info = jsonDevice[jsonProperty]; + if (info === undefined || info === null) return; + + method(device, info); + }; + + process("deviceInfo", Object.assign); + process("drive", addDriveInfo); + process("raid", addRaidInfo); + process("multipath", addMultipathInfo); + process("md", addMDInfo); + process("blockDevice", Object.assign); + process("partition", addPartitionInfo); + process("lvmVg", addVgInfo); + process("lvmLv", addLvInfo); + process("partitionTable", addPTableInfo); + process("filesystem", addFilesystemInfo); + process("component", addComponentInfo); + + return device; + }; + + const jsonDevices: Device[] = await get(`/api/storage/devices/${scope}`); + return jsonDevices.map((d) => buildDevice(d, jsonDevices)); +} + +export { fetchDevices }; diff --git a/web/src/client/storage.js b/web/src/client/storage.js index 3a14b4e318..c299bad2fa 100644 --- a/web/src/client/storage.js +++ b/web/src/client/storage.js @@ -25,6 +25,7 @@ import { compact, hex, uniq } from "~/utils"; import { WithStatus } from "./mixins"; import { HTTPClient } from "./http"; +import { fetchDevices } from "~/api/storage/devices"; const SERVICE_NAME = "org.opensuse.Agama.Storage1"; const STORAGE_OBJECT = "/org/opensuse/Agama/Storage1"; @@ -238,183 +239,15 @@ const EncryptionMethods = Object.freeze({ */ const dbusBasename = (path) => path.split("/").slice(-1)[0]; -/** - * Class providing an API for managing a devices tree through D-Bus - */ -class DevicesManager { - /** - * @param {HTTPClient} client - * @param {string} rootPath - path of the devices tree, either system or staging - */ - constructor(client, rootPath) { - this.client = client; - this.rootPath = rootPath; - } - - /** - * Gets all the exported devices - * - * @returns {Promise} - */ - async getDevices() { - const buildDevice = (jsonDevice, jsonDevices) => { - /** @type {() => StorageDevice} */ - const buildDefaultDevice = () => { - return { - sid: 0, - name: "", - description: "", - isDrive: false, - type: "", - }; - }; - - /** @type {(names: string[]) => StorageDevice[]} */ - const buildCollectionFromNames = (names) => { - return names.map((name) => ({ ...buildDefaultDevice(), name })); - }; - - /** @type {(sids: String[], jsonDevices: object[]) => StorageDevice[]} */ - const buildCollection = (sids, jsonDevices) => { - if (sids === null || sids === undefined) return []; - - return sids.map((sid) => - buildDevice( - jsonDevices.find((dev) => dev.deviceInfo?.sid === sid), - jsonDevices, - ), - ); - }; - - /** @type {(device: StorageDevice, info: object) => void} */ - const addDriveInfo = (device, info) => { - device.isDrive = true; - device.type = info.type; - device.vendor = info.vendor; - device.model = info.model; - device.driver = info.driver; - device.bus = info.bus; - device.busId = info.busId; - device.transport = info.transport; - device.sdCard = info.info.sdCard; - device.dellBOSS = info.info.dellBOSS; - }; - - /** @type {(device: StorageDevice, info: object) => void} */ - const addRaidInfo = (device, info) => { - device.devices = buildCollectionFromNames(info.devices); - }; - - /** @type {(device: StorageDevice, info: object) => void} */ - const addMultipathInfo = (device, info) => { - device.wires = buildCollectionFromNames(info.wires); - }; - - /** @type {(device: StorageDevice, info: object) => void} */ - const addMDInfo = (device, info) => { - device.type = "md"; - device.level = info.level; - device.uuid = info.uuid; - device.devices = buildCollection(info.devices, jsonDevices); - }; - - /** @type {(device: StorageDevice, info: object) => void} */ - const addPartitionInfo = (device, info) => { - device.type = "partition"; - device.isEFI = info.efi; - }; - - /** @type {(device: StorageDevice, info: object) => void} */ - const addVgInfo = (device, info) => { - device.type = "lvmVg"; - device.size = info.size; - device.physicalVolumes = buildCollection(info.physicalVolumes, jsonDevices); - device.logicalVolumes = buildCollection(info.logicalVolumes, jsonDevices); - }; - - /** @type {(device: StorageDevice, info: object) => void} */ - const addLvInfo = (device, _info) => { - device.type = "lvmLv"; - }; - - /** @type {(device: StorageDevice, tableInfo: object) => void} */ - const addPTableInfo = (device, tableInfo) => { - const partitions = buildCollection(tableInfo.partitions, jsonDevices); - device.partitionTable = { - type: tableInfo.type, - partitions, - unpartitionedSize: device.size - partitions.reduce((s, p) => s + p.size, 0), - unusedSlots: tableInfo.unusedSlots.map((s) => Object.assign({}, s)), - }; - }; - - /** @type {(device: StorageDevice, filesystemInfo: object) => void} */ - const addFilesystemInfo = (device, filesystemInfo) => { - const buildMountPath = (path) => (path.length > 0 ? path : undefined); - const buildLabel = (label) => (label.length > 0 ? label : undefined); - device.filesystem = { - sid: filesystemInfo.sid, - type: filesystemInfo.type, - mountPath: buildMountPath(filesystemInfo.mountPath), - label: buildLabel(filesystemInfo.label), - }; - }; - - /** @type {(device: StorageDevice, info: object) => void} */ - const addComponentInfo = (device, info) => { - device.component = { - type: info.type, - deviceNames: info.deviceNames, - }; - }; - - const device = buildDefaultDevice(); - - /** @type {(jsonProperty: String, info: function) => void} */ - const process = (jsonProperty, method) => { - const info = jsonDevice[jsonProperty]; - if (info === undefined || info === null) return; - - method(device, info); - }; - - process("deviceInfo", Object.assign); - process("drive", addDriveInfo); - process("raid", addRaidInfo); - process("multipath", addMultipathInfo); - process("md", addMDInfo); - process("blockDevice", Object.assign); - process("partition", addPartitionInfo); - process("lvmVg", addVgInfo); - process("lvmLv", addLvInfo); - process("partitionTable", addPTableInfo); - process("filesystem", addFilesystemInfo); - process("component", addComponentInfo); - - return device; - }; - - const response = await this.client.get(`/storage/devices/${this.rootPath}`); - if (!response.ok) { - console.warn("Failed to get storage devices: ", response); - return []; - } - const jsonDevices = await response.json(); - return jsonDevices.map((d) => buildDevice(d, jsonDevices)); - } -} - /** * Class providing an API for managing the storage proposal through D-Bus */ class ProposalManager { /** * @param {HTTPClient} client - * @param {DevicesManager} system */ - constructor(client, system) { + constructor(client) { this.client = client; - this.system = system; } /** @@ -431,7 +264,7 @@ class ProposalManager { return device; }; - const systemDevices = await this.system.getDevices(); + const systemDevices = await fetchDevices("system"); const response = await this.client.get("/storage/proposal/usable_devices"); if (!response.ok) { @@ -469,7 +302,7 @@ class ProposalManager { /** @type {(device: StorageDevice[]) => boolean} */ const allAvailable = (devices) => devices.every(isAvailable); - const system = await this.system.getDevices(); + const system = await fetchDevices("system"); const mds = system.filter((d) => d.type === "md" && allAvailable(d.devices)); const vgs = system.filter((d) => d.type === "lvmVg" && allAvailable(d.physicalVolumes)); @@ -520,7 +353,7 @@ class ProposalManager { return undefined; } - const systemDevices = await this.system.getDevices(); + const systemDevices = await fetchDevices("system"); const productMountPoints = await this.getProductMountPoints(); return response.json().then((volume) => { @@ -600,7 +433,7 @@ class ProposalManager { const settings = await settingsResponse.json(); const actions = await actionsResponse.json(); - const systemDevices = await this.system.getDevices(); + const systemDevices = await fetchDevices("system"); const productMountPoints = await this.getProductMountPoints(); return { @@ -1597,9 +1430,7 @@ class StorageBaseClient { */ constructor(client = undefined) { this.client = client; - this.system = new DevicesManager(this.client, "system"); - this.staging = new DevicesManager(this.client, "result"); - this.proposal = new ProposalManager(this.client, this.system); + this.proposal = new ProposalManager(this.client); this.iscsi = new ISCSIManager(this.client); // @ts-ignore this.dasd = new DASDManager(StorageBaseClient.SERVICE, client); diff --git a/web/src/components/storage/ProposalPage.tsx b/web/src/components/storage/ProposalPage.tsx index 241046f3d5..4e9e6f2c7f 100644 --- a/web/src/components/storage/ProposalPage.tsx +++ b/web/src/components/storage/ProposalPage.tsx @@ -34,6 +34,7 @@ import { useInstallerClient } from "~/context/installer"; import { toValidationError, useCancellablePromise } from "~/utils"; import { useIssues } from "~/queries/issues"; import { IssueSeverity } from "~/types/issues"; +import { fetchDevices } from "~/api/storage/devices"; /** * @typedef {import ("~/components/storage/utils").SpacePolicy} SpacePolicy @@ -183,8 +184,8 @@ export default function ProposalPage() { }, [client, cancellablePromise]); const loadDevices = useCallback(async () => { - const system = (await cancellablePromise(client.system.getDevices())) || []; - const staging = (await cancellablePromise(client.staging.getDevices())) || []; + const system = (await cancellablePromise(fetchDevices("system"))) || []; + const staging = (await cancellablePromise(fetchDevices("result"))) || []; return { system, staging }; }, [client, cancellablePromise]); From 387f128ba705fd94527e0caf6a2041b9558bc886 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Tue, 3 Sep 2024 15:21:32 +0100 Subject: [PATCH 05/53] feat(web): add an api/storage/proposal module --- web/src/api/storage/proposal.ts | 44 +++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 web/src/api/storage/proposal.ts diff --git a/web/src/api/storage/proposal.ts b/web/src/api/storage/proposal.ts new file mode 100644 index 0000000000..6b1b12a4b9 --- /dev/null +++ b/web/src/api/storage/proposal.ts @@ -0,0 +1,44 @@ +/* + * 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. + */ + +import { get } from "../http"; +import { Action, ProductParams, ProposalSettings, Volume } from "./types"; + +const fetchUsableDevices = (): Promise => get(`/api/storage/proposal/usable_devices`); + +const fetchProductParams = (): Promise => get("/api/storage/product/params"); + +const fetchDefaultVolume = (mountPath: string): Promise => { + const path = encodeURIComponent(mountPath); + return get(`/api/storage/product/volume_for?mount_path=${path}`); +}; + +const fetchSettings = (): Promise => get("/api/storage/proposal/settings"); + +const fetchActions = (): Promise => get("/api/storage/proposal/actions"); + +export { + fetchUsableDevices, + fetchProductParams, + fetchDefaultVolume, + fetchSettings, + fetchActions, +} From 2eedc918eecb1a1b724d018e4b3ae0cbca57676f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Tue, 3 Sep 2024 15:22:46 +0100 Subject: [PATCH 06/53] feat(web): add more storage queries --- web/src/queries/storage.ts | 241 +++++++++++++++++++++++++++++++++++++ 1 file changed, 241 insertions(+) create mode 100644 web/src/queries/storage.ts diff --git a/web/src/queries/storage.ts b/web/src/queries/storage.ts new file mode 100644 index 0000000000..ed19df3a15 --- /dev/null +++ b/web/src/queries/storage.ts @@ -0,0 +1,241 @@ +/* + * 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. + */ + +import { useQuery, useSuspenseQueries, useSuspenseQuery } from "@tanstack/react-query"; +import { fetchDevices } from "~/api/storage/devices"; +import { fetchActions, fetchDefaultVolume, fetchProductParams, fetchSettings, fetchUsableDevices } from "~/api/storage/proposal"; +import { ProductParams, Volume as APIVolume, ProposalSettings, ProposalTarget as APIProposalTarget } from "~/api/storage/types"; +import { ProposalResult, ProposalTarget, StorageDevice, Volume, VolumeTarget } from "~/types/storage"; +import { compact, uniq } from "~/utils"; + +const devicesQuery = (scope: "result" | "system") => ({ + queryKey: ["storage", "devices", scope], + queryFn: () => fetchDevices(scope), + staleTime: Infinity +}); + +const usableDevicesQuery = { + queryKey: ["storage", "usableDevices"], + queryFn: fetchUsableDevices, + staleTime: Infinity +}; + +const productParamsQuery = { + queryKey: ["storage", "encryptionMethods"], + queryFn: fetchProductParams, + staleTime: Infinity +} + +const defaultVolumeQuery = (mountPath: string) => ({ + queryKey: ["storage", "volumeFor", mountPath], + queryFn: () => fetchDefaultVolume(mountPath), + staleTime: Infinity +}); + +/** + * Hook that returns the list of storage devices for the given scope + * + * @param scope - "system": devices in the current state of the system; "result": + * devices in the proposal ("stage") + */ +const useDevices = (scope: "result" | "system", options?: QueryHookOptions): StorageDevice[] | undefined => { + const query = devicesQuery(scope); + const func = options?.suspense ? useSuspenseQuery : useQuery; + const { data } = func(query); + return data; +} + +/** + * Hook that returns the list of available devices for installation. + */ +const useAvailableDevices = () => { + const findDevice = (devices: StorageDevice[], sid: number) => { + const device = devices.find((d) => d.sid === sid); + + if (device === undefined) console.warn("Device not found:", sid); + + return device; + }; + + const devices = useDevices("system", { suspense: true }); + const { data } = useSuspenseQuery(usableDevicesQuery); + + return data.map((sid) => findDevice(devices, sid)).filter((d) => d); +} + +/** + * Hook that returns the product parameters (e.g., mount points). + */ +const useProductParams = (options?: QueryHookOptions): ProductParams => { + const func = options?.suspense ? useSuspenseQuery : useQuery; + const { data } = func(productParamsQuery); + return data; +} + +/** + * Hook that returns the volume templates for the current product. + */ +const useVolumeTemplates = (options?: QueryHookOptions): Volume[] => { + const systemDevices = useDevices("system", { suspense: true }); + const product = useProductParams(options || {}); + if (!product) return []; + + const queries = product.mountPoints.map((p) => defaultVolumeQuery(p)); + queries.push(defaultVolumeQuery("")); + const results = useSuspenseQueries({ queries }) as Array<{ data: APIVolume }>; + return results.map(({ data }) => buildVolume(data, systemDevices, product.mountPoints)); +} + +/** + * Hook that returns the devices that can be selected as target for volume. + * + * A device can be selected as target for a volume if either it is an available device for + * installation or it is a device built over the available devices for installation. For example, + * a MD RAID is a possible target only if all its members are available devices or children of the + * available devices. + */ +const useVolumeDevices = (): StorageDevice[] => { + const isAvailable = (device: StorageDevice) => { + const isChildren = (device: StorageDevice, parentDevice: StorageDevice) => { + const partitions = parentDevice.partitionTable?.partitions || []; + return !!partitions.find((d) => d.name === device.name); + }; + + return !!availableDevices.find((d) => d.name === device.name || isChildren(device, d)); + }; + + const allAvailable = (devices: StorageDevice[]) => devices.every(isAvailable); + + const availableDevices = useAvailableDevices(); + const system = useDevices("system", { suspense: true }); + 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]; +} + +const proposalSettingsQuery = { + queryKey: ["storage", "proposal", "settings"], + queryFn: fetchSettings +}; + +const proposalActionsQuery = { + queryKey: ["storage", "proposal", "actions"], + queryFn: fetchActions +}; + +/** + * Gets the values of the current proposal + */ +const useProposalResult = (): ProposalResult | undefined => { + const buildTarget = (value: APIProposalTarget): ProposalTarget => { + // FIXME: handle the case where they do not match + const target = value as ProposalTarget; + return target; + } + + /** @todo Read installation devices from D-Bus. */ + const buildInstallationDevices = (settings: ProposalSettings, devices: StorageDevice[]) => { + const findDevice = (name: string) => { + const device = devices.find((d) => d.name === name); + + if (device === undefined) console.error("Device object not found: ", name); + + return device; + }; + + // Only consider the device assigned to a volume as installation device if it is needed + // to find space in that device. For example, devices directly formatted or mounted are not + // considered as installation devices. + const volumes = settings.volumes.filter((vol) => { + return true; + // const target = vol.target as VolumeTarget; + // return [VolumeTarget.NEW_PARTITION, VolumeTarget.NEW_VG].includes(target); + }); + + const values = [ + settings.targetDevice, + settings.targetPVDevices, + volumes.map((v) => v.targetDevice), + ].flat(); + + if (settings.configureBoot) values.push(settings.bootDevice); + + const names = uniq(compact(values)).filter((d) => d.length > 0); + + // #findDevice returns undefined if no device is found with the given name. + return compact(names.sort().map(findDevice)); + }; + + const [ + { data: settings }, { data: actions } + ] = useSuspenseQueries({ queries: [proposalSettingsQuery, proposalActionsQuery] }); + const systemDevices = useDevices("system", { suspense: true }); + const { mountPoints: productMountPoints } = useProductParams({ suspense: true }); + + return { + settings: { + ...settings, + targetPVDevices: settings.targetPVDevices || [], + target: buildTarget(settings.target), + volumes: settings.volumes.map((v) => + buildVolume(v, systemDevices, productMountPoints), + ), + // NOTE: strictly speaking, installation devices does not belong to the settings. It + // should be a separate method instead of an attribute in the settings object. + // Nevertheless, it was added here for simplicity and to avoid passing more props in some + // react components. Please, do not use settings as a jumble. + installationDevices: buildInstallationDevices(settings, systemDevices), + }, + actions, + }; +} + +/** + * @private + * Builds a volume from the D-Bus data + */ +const buildVolume = (rawVolume: APIVolume, devices: StorageDevice[], productMountPoints: string[]): Volume => { + const outline = { + ...rawVolume.outline, + // Indicate whether a volume is defined by the product. + productDefined: productMountPoints.includes(rawVolume.mountPath) + }; + const volume: Volume = { + ...rawVolume, + outline, + minSize: rawVolume.minSize || 0, + transactional: rawVolume.transactional || false, + target: rawVolume.target as VolumeTarget, + targetDevice: devices.find((d) => d.name === rawVolume.targetDevice), + }; + + return volume; +} + +export { + useDevices, + useAvailableDevices, + useProductParams, + useVolumeTemplates, + useVolumeDevices, + useProposalResult +} From 532b399ff4908130a980c37632c376dea24d9c28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Tue, 3 Sep 2024 15:23:24 +0100 Subject: [PATCH 07/53] feat(web): use more queries in ProposalPage --- web/src/components/storage/ProposalPage.tsx | 143 +++++--------------- web/src/queries/storage.ts | 5 +- 2 files changed, 33 insertions(+), 115 deletions(-) diff --git a/web/src/components/storage/ProposalPage.tsx b/web/src/components/storage/ProposalPage.tsx index 4e9e6f2c7f..6623455d03 100644 --- a/web/src/components/storage/ProposalPage.tsx +++ b/web/src/components/storage/ProposalPage.tsx @@ -34,7 +34,7 @@ import { useInstallerClient } from "~/context/installer"; import { toValidationError, useCancellablePromise } from "~/utils"; import { useIssues } from "~/queries/issues"; import { IssueSeverity } from "~/types/issues"; -import { fetchDevices } from "~/api/storage/devices"; +import { useAvailableDevices, useDevices, useProductParams, useProposalResult, useVolumeDevices, useVolumeTemplates } from "~/queries/storage"; /** * @typedef {import ("~/components/storage/utils").SpacePolicy} SpacePolicy @@ -44,13 +44,7 @@ const initialState = { loading: true, // which UI item is being changed by user changing: undefined, - availableDevices: [], - volumeDevices: [], - volumeTemplates: [], - encryptionMethods: [], settings: {}, - system: [], - staging: [], actions: [], }; @@ -65,26 +59,6 @@ const reducer = (state, action) => { return { ...state, loading: false, changing: undefined }; } - case "UPDATE_AVAILABLE_DEVICES": { - const { availableDevices } = action.payload; - 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 }; - } - - case "UPDATE_VOLUME_TEMPLATES": { - const { volumeTemplates } = action.payload; - return { ...state, volumeTemplates }; - } - case "UPDATE_RESULT": { const { settings, actions } = action.payload.result; return { ...state, settings, actions }; @@ -95,11 +69,6 @@ const reducer = (state, action) => { return { ...state, settings, changing }; } - case "UPDATE_DEVICES": { - const { system, staging } = action.payload; - return { ...state, system, staging }; - } - default: { return state; } @@ -150,45 +119,18 @@ export default function ProposalPage() { const { cancellablePromise } = useCancellablePromise(); const [state, dispatch] = useReducer(reducer, initialState); const drawerRef = useRef(); + const systemDevices = useDevices("system"); + const stagingDevices = useDevices("result"); + const availableDevices = useAvailableDevices(); + const { encryptionMethods } = useProductParams({ suspense: true }); + const volumeTemplates = useVolumeTemplates({ suspense: true }); + const volumeDevices = useVolumeDevices(); + const { actions, settings } = useProposalResult(); const errors = useIssues("storage") .filter((s) => s.severity === IssueSeverity.Error) .map(toValidationError); - const loadAvailableDevices = useCallback(async () => { - 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]); - - const loadVolumeTemplates = useCallback(async () => { - const mountPoints = await cancellablePromise(client.proposal.getProductMountPoints()); - const volumeTemplates = []; - - for (const mountPoint of mountPoints) { - volumeTemplates.push(await cancellablePromise(client.proposal.defaultVolume(mountPoint))); - } - - volumeTemplates.push(await cancellablePromise(client.proposal.defaultVolume(""))); - return volumeTemplates; - }, [client, cancellablePromise]); - - const loadProposalResult = useCallback(async () => { - return await cancellablePromise(client.proposal.getResult()); - }, [client, cancellablePromise]); - - const loadDevices = useCallback(async () => { - const system = (await cancellablePromise(fetchDevices("system"))) || []; - const staging = (await cancellablePromise(fetchDevices("result"))) || []; - return { system, staging }; - }, [client, cancellablePromise]); - const calculateProposal = useCallback( async (settings) => { return await cancellablePromise(client.proposal.calculate(settings)); @@ -201,40 +143,20 @@ export default function ProposalPage() { const isDeprecated = await cancellablePromise(client.isDeprecated()); if (isDeprecated) { - const result = await loadProposalResult(); + //const result = await loadProposalResult(); await cancellablePromise(client.probe()); - if (result?.settings) await calculateProposal(result.settings); + // if (result?.settings) await calculateProposal(result.settings); + await calculateProposal(settings); } - const availableDevices = await loadAvailableDevices(); - dispatch({ type: "UPDATE_AVAILABLE_DEVICES", payload: { availableDevices } }); + // const result = await loadProposalResult(); + // if (result !== undefined) dispatch({ type: "UPDATE_RESULT", payload: { result } }); - const volumeDevices = await loadVolumeDevices(); - dispatch({ type: "UPDATE_VOLUME_DEVICES", payload: { volumeDevices } }); - - const encryptionMethods = await loadEncryptionMethods(); - dispatch({ type: "UPDATE_ENCRYPTION_METHODS", payload: { encryptionMethods } }); - - const volumeTemplates = await loadVolumeTemplates(); - dispatch({ type: "UPDATE_VOLUME_TEMPLATES", payload: { volumeTemplates } }); - - const result = await loadProposalResult(); - if (result !== undefined) dispatch({ type: "UPDATE_RESULT", payload: { result } }); - - const devices = await loadDevices(); - dispatch({ type: "UPDATE_DEVICES", payload: devices }); - - if (result !== undefined) dispatch({ type: "STOP_LOADING" }); + dispatch({ type: "STOP_LOADING" }); }, [ calculateProposal, cancellablePromise, client, - loadAvailableDevices, - loadVolumeDevices, - loadDevices, - loadEncryptionMethods, - loadProposalResult, - loadVolumeTemplates, ]); const calculate = useCallback( @@ -243,15 +165,12 @@ export default function ProposalPage() { await calculateProposal(settings); - const result = await loadProposalResult(); + // const result = await loadProposalResult(); dispatch({ type: "UPDATE_RESULT", payload: { result } }); - const devices = await loadDevices(); - dispatch({ type: "UPDATE_DEVICES", payload: devices }); - dispatch({ type: "STOP_LOADING" }); }, - [calculateProposal, loadDevices, loadProposalResult], + [calculateProposal], ); useEffect(() => { @@ -298,15 +217,15 @@ export default function ProposalPage() { - + {_("Planned Actions")}} - panelContent={} + panelContent={} > diff --git a/web/src/queries/storage.ts b/web/src/queries/storage.ts index ed19df3a15..34e1513f96 100644 --- a/web/src/queries/storage.ts +++ b/web/src/queries/storage.ts @@ -166,9 +166,8 @@ const useProposalResult = (): ProposalResult | undefined => { // to find space in that device. For example, devices directly formatted or mounted are not // considered as installation devices. const volumes = settings.volumes.filter((vol) => { - return true; - // const target = vol.target as VolumeTarget; - // return [VolumeTarget.NEW_PARTITION, VolumeTarget.NEW_VG].includes(target); + const target = vol.target as VolumeTarget; + return [VolumeTarget.NEW_PARTITION, VolumeTarget.NEW_VG].includes(target); }); const values = [ From e2caa2229ab9ae823fff3f02b5719f867bce39a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Tue, 3 Sep 2024 15:48:55 +0100 Subject: [PATCH 08/53] refactor(web): convert InstallationDeviceField to TypeScript --- ...t.jsx => InstallationDeviceField.test.tsx} | 29 ++++------ ...eField.jsx => InstallationDeviceField.tsx} | 55 +++++++++---------- 2 files changed, 36 insertions(+), 48 deletions(-) rename web/src/components/storage/{InstallationDeviceField.test.jsx => InstallationDeviceField.test.tsx} (91%) rename web/src/components/storage/{InstallationDeviceField.jsx => InstallationDeviceField.tsx} (68%) diff --git a/web/src/components/storage/InstallationDeviceField.test.jsx b/web/src/components/storage/InstallationDeviceField.test.tsx similarity index 91% rename from web/src/components/storage/InstallationDeviceField.test.jsx rename to web/src/components/storage/InstallationDeviceField.test.tsx index b2ab5cd248..68e5fb585b 100644 --- a/web/src/components/storage/InstallationDeviceField.test.jsx +++ b/web/src/components/storage/InstallationDeviceField.test.tsx @@ -24,7 +24,8 @@ import React from "react"; import { screen, within } from "@testing-library/react"; import { installerRender } from "~/test-utils"; -import InstallationDeviceField from "~/components/storage/InstallationDeviceField"; +import InstallationDeviceField, { InstallationDeviceFieldProps } from "~/components/storage/InstallationDeviceField"; +import { ProposalTarget, StorageDevice } from "~/types/storage"; jest.mock("@patternfly/react-core", () => { const original = jest.requireActual("@patternfly/react-core"); @@ -35,16 +36,10 @@ jest.mock("@patternfly/react-core", () => { }; }); -/** - * @typedef {import ("~/components/storage/InstallationDeviceField").InstallationDeviceFieldProps} InstallationDeviceFieldProps - * @typedef {import ("~/client/storage").StorageDevice} StorageDevice - */ - -/** @type {StorageDevice} */ -const sda = { +const sda: StorageDevice = { sid: 59, isDrive: true, - type: "disk", + type: ProposalTarget.DISK, description: "", vendor: "Micron", model: "Micron 1100 SATA", @@ -63,11 +58,10 @@ const sda = { udevPaths: ["pci-0000:00-12", "pci-0000:00-12-ata"], }; -/** @type {StorageDevice} */ -const sdb = { +const sdb: StorageDevice = { sid: 62, isDrive: true, - type: "disk", + type: ProposalTarget.DISK, description: "", vendor: "Samsung", model: "Samsung Evo 8 Pro", @@ -86,12 +80,11 @@ const sdb = { udevPaths: ["pci-0000:00-19"], }; -/** @type {InstallationDeviceFieldProps} */ -let props; +let props: InstallationDeviceFieldProps; beforeEach(() => { props = { - target: "DISK", + target: ProposalTarget.DISK, targetDevice: sda, targetPVDevices: [], devices: [sda, sdb], @@ -115,7 +108,7 @@ describe.skip("when set as loading", () => { describe.skip("when the target is a disk", () => { beforeEach(() => { - props.target = "DISK"; + props.target = ProposalTarget.DISK; }); describe("and installation device is not selected yet", () => { @@ -143,7 +136,7 @@ describe.skip("when the target is a disk", () => { describe.skip("when the target is a new LVM volume group", () => { beforeEach(() => { - props.target = "NEW_LVM_VG"; + props.target = ProposalTarget.NEW_LVM_VG; }); describe("and the target devices are not selected yet", () => { @@ -197,7 +190,7 @@ it.skip("allows changing the selected device", async () => { expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); expect(props.onChange).toHaveBeenCalledWith({ - target: "DISK", + target: ProposalTarget.DISK, targetDevice: sdb, targetPVDevices: [], }); diff --git a/web/src/components/storage/InstallationDeviceField.jsx b/web/src/components/storage/InstallationDeviceField.tsx similarity index 68% rename from web/src/components/storage/InstallationDeviceField.jsx rename to web/src/components/storage/InstallationDeviceField.tsx index 7afba631c8..9d9b3e42b6 100644 --- a/web/src/components/storage/InstallationDeviceField.jsx +++ b/web/src/components/storage/InstallationDeviceField.tsx @@ -28,11 +28,7 @@ import { deviceLabel } from "~/components/storage/utils"; import { PATHS } from "~/routes/storage"; import { _ } from "~/i18n"; import { sprintf } from "sprintf-js"; - -/** - * @typedef {import ("~/client/storage").ProposalTarget} ProposalTarget - * @typedef {import ("~/client/storage").StorageDevice} StorageDevice - */ +import { ProposalTarget, StorageDevice } from "~/types/storage"; const LABEL = _("Installation device"); // TRANSLATORS: The storage "Installation device" field's description. @@ -41,18 +37,13 @@ const DESCRIPTION = _("Main disk or LVM Volume Group for installation."); /** * Generates the target value. * @function - * - * @param {ProposalTarget} target - * @param {StorageDevice} targetDevice - * @param {StorageDevice[]} targetPVDevices - * @returns {string} */ -const targetValue = (target, targetDevice, targetPVDevices) => { - if (target === "DISK" && targetDevice) { +const targetValue = (target: ProposalTarget, targetDevice: StorageDevice, targetPVDevices: StorageDevice[]): string => { + if (target === ProposalTarget.DISK && targetDevice) { // TRANSLATORS: %s is the installation disk (eg. "/dev/sda, 80 GiB) return sprintf(_("File systems created as new partitions at %s"), deviceLabel(targetDevice)); } - if (target === "NEW_LVM_VG" && targetPVDevices.length > 0) { + if (ProposalTarget.NEW_LVM_VG && targetPVDevices.length > 0) { if (targetPVDevices.length > 1) return _("File systems created at a new LVM volume group"); if (targetPVDevices.length === 1) { @@ -70,30 +61,34 @@ const targetValue = (target, targetDevice, targetPVDevices) => { /** * Allows to select the installation device. * @component - * - * @typedef {object} InstallationDeviceFieldProps - * @property {ProposalTarget|undefined} target - Installation target - * @property {StorageDevice|undefined} targetDevice - Target device (for target "DISK"). - * @property {StorageDevice[]} targetPVDevices - Target devices for the LVM volume group (target "NEW_LVM_VG"). - * @property {StorageDevice[]} devices - Available devices for installation. - * @property {boolean} isLoading - * @property {(target: TargetConfig) => void} onChange - * - * @typedef {object} TargetConfig - * @property {ProposalTarget} target - * @property {StorageDevice|undefined} targetDevice - * @property {StorageDevice[]} targetPVDevices - * - * @param {InstallationDeviceFieldProps} props */ +type TargetConfig = { + target: ProposalTarget; + targetDevice: StorageDevice | undefined; + targetPVDevices: StorageDevice[]; +} + +export type InstallationDeviceFieldProps = { + // Installation target + target: ProposalTarget | undefined; + // Target device (for target "disk") + targetDevice: StorageDevice | undefined; + // Target devices for the LVM volume group (target "newLvmVg") + targetPVDevices: StorageDevice[]; + // Available devices for installation. + devices: StorageDevice[]; + isLoading: boolean; + onChange: (target: TargetConfig) => void +} + export default function InstallationDeviceField({ target, targetDevice, targetPVDevices, isLoading, -}) { - let value; +}: InstallationDeviceFieldProps) { + let value: React.ReactNode; if (isLoading || !target) value = ; else value = targetValue(target, targetDevice, targetPVDevices); From 1b32c70fd43f71c03f82275ea8cd06f7b4ee777c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Wed, 4 Sep 2024 09:46:20 +0100 Subject: [PATCH 09/53] refactor(web): use queries to update the proposal --- web/src/api/storage/devices.ts | 6 +- web/src/api/storage/proposal.ts | 7 +- web/src/components/storage/ProposalPage.tsx | 94 +++------------------ web/src/queries/storage.ts | 92 ++++++++++++++++++-- web/src/types/storage.ts | 3 +- 5 files changed, 108 insertions(+), 94 deletions(-) diff --git a/web/src/api/storage/devices.ts b/web/src/api/storage/devices.ts index b3147f7a44..fe379663e7 100644 --- a/web/src/api/storage/devices.ts +++ b/web/src/api/storage/devices.ts @@ -20,7 +20,7 @@ */ import { get } from "~/api/http"; -import { Component, Device, Drive, Filesystem, LvmLv, LvmVg, Md, Multipath, Partition, PartitionTable, Raid } from "./types"; +import { Component, Device, DevicesDirtyResponse, Drive, Filesystem, LvmLv, LvmVg, Md, Multipath, Partition, PartitionTable, Raid } from "./types"; import { StorageDevice } from "~/types/storage"; /** @@ -157,4 +157,6 @@ const fetchDevices = async (scope: "result" | "system") => { return jsonDevices.map((d) => buildDevice(d, jsonDevices)); } -export { fetchDevices }; +const fetchDevicesDirty = (): Promise => get("/api/storage/devices/dirty"); + +export { fetchDevices, fetchDevicesDirty }; diff --git a/web/src/api/storage/proposal.ts b/web/src/api/storage/proposal.ts index 6b1b12a4b9..3309fa54d7 100644 --- a/web/src/api/storage/proposal.ts +++ b/web/src/api/storage/proposal.ts @@ -19,8 +19,8 @@ * find current contact information at www.suse.com. */ -import { get } from "../http"; -import { Action, ProductParams, ProposalSettings, Volume } from "./types"; +import { get, put } from "../http"; +import { Action, ProductParams, ProposalSettings, ProposalSettingsPatch, Volume } from "./types"; const fetchUsableDevices = (): Promise => get(`/api/storage/proposal/usable_devices`); @@ -35,10 +35,13 @@ const fetchSettings = (): Promise => get("/api/storage/proposa const fetchActions = (): Promise => get("/api/storage/proposal/actions"); +const calculate = (settings: ProposalSettingsPatch) => put("/api/storage/proposal/settings", settings); + export { fetchUsableDevices, fetchProductParams, fetchDefaultVolume, fetchSettings, fetchActions, + calculate, } diff --git a/web/src/components/storage/ProposalPage.tsx b/web/src/components/storage/ProposalPage.tsx index 6623455d03..cfe6454216 100644 --- a/web/src/components/storage/ProposalPage.tsx +++ b/web/src/components/storage/ProposalPage.tsx @@ -28,47 +28,25 @@ import ProposalResultSection from "./ProposalResultSection"; import ProposalActionsSummary from "~/components/storage/ProposalActionsSummary"; import { ProposalActionsDialog } from "~/components/storage"; import { _ } from "~/i18n"; -import { IDLE } from "~/client/status"; import { SPACE_POLICIES } from "~/components/storage/utils"; import { useInstallerClient } from "~/context/installer"; import { toValidationError, useCancellablePromise } from "~/utils"; import { useIssues } from "~/queries/issues"; import { IssueSeverity } from "~/types/issues"; -import { useAvailableDevices, useDevices, useProductParams, useProposalResult, useVolumeDevices, useVolumeTemplates } from "~/queries/storage"; +import { useAvailableDevices, useDeprecated, useDeprecatedChanges, useDevices, useProductParams, useProposalMutation, useProposalResult, useVolumeDevices, useVolumeTemplates } from "~/queries/storage"; /** * @typedef {import ("~/components/storage/utils").SpacePolicy} SpacePolicy */ const initialState = { - loading: true, - // which UI item is being changed by user - changing: undefined, + loading: false, settings: {}, actions: [], }; const reducer = (state, action) => { switch (action.type) { - case "START_LOADING": { - return { ...state, loading: true }; - } - - case "STOP_LOADING": { - // reset the changing value after the refresh is finished - return { ...state, loading: false, changing: undefined }; - } - - case "UPDATE_RESULT": { - const { settings, actions } = action.payload.result; - return { ...state, settings, actions }; - } - - case "UPDATE_SETTINGS": { - const { settings, changing } = action.payload; - return { ...state, settings, changing }; - } - default: { return state; } @@ -126,6 +104,9 @@ export default function ProposalPage() { const volumeTemplates = useVolumeTemplates({ suspense: true }); const volumeDevices = useVolumeDevices(); const { actions, settings } = useProposalResult(); + const updateProposal = useProposalMutation(); + const deprecated = useDeprecated(); + useDeprecatedChanges(); const errors = useIssues("storage") .filter((s) => s.severity === IssueSeverity.Error) @@ -138,69 +119,18 @@ export default function ProposalPage() { [client, cancellablePromise], ); - const load = useCallback(async () => { - dispatch({ type: "START_LOADING" }); - - const isDeprecated = await cancellablePromise(client.isDeprecated()); - if (isDeprecated) { - //const result = await loadProposalResult(); - await cancellablePromise(client.probe()); - // if (result?.settings) await calculateProposal(result.settings); - await calculateProposal(settings); - } - - // const result = await loadProposalResult(); - // if (result !== undefined) dispatch({ type: "UPDATE_RESULT", payload: { result } }); - - dispatch({ type: "STOP_LOADING" }); - }, [ - calculateProposal, - cancellablePromise, - client, - ]); - - const calculate = useCallback( - async (settings) => { - dispatch({ type: "START_LOADING" }); - - await calculateProposal(settings); - - // const result = await loadProposalResult(); - dispatch({ type: "UPDATE_RESULT", payload: { result } }); - - dispatch({ type: "STOP_LOADING" }); - }, - [calculateProposal], - ); - useEffect(() => { - load().catch(console.error); - - return client.onDeprecate(() => load()); - }, [client, load]); - - useEffect(() => { - const proposalLoaded = () => state.settings.targetDevice !== undefined; - - const statusHandler = (serviceStatus) => { - // Load the proposal if no proposal has been loaded yet. This can happen if the proposal - // page is visited before probing has finished. - if (serviceStatus === IDLE && !proposalLoaded()) load(); - }; - - if (!proposalLoaded()) { - return client.onStatusChange(statusHandler); + if (deprecated) { + cancellablePromise(client.probe()); } - }, [client, load, state.settings]); - - const changeSettings = async (changing, settings) => { - const newSettings = { ...state.settings, ...settings }; + }, [deprecated]); - dispatch({ type: "UPDATE_SETTINGS", payload: { settings: newSettings, changing } }); - calculate(newSettings).catch(console.error); + const changeSettings = async (changing, updated: object) => { + const newSettings = { ...settings, ...updated }; + updateProposal.mutateAsync(newSettings).catch(console.error); }; - const spacePolicy = SPACE_POLICIES.find((p) => p.id === state.settings.spacePolicy); + const spacePolicy = SPACE_POLICIES.find((p) => p.id === settings.spacePolicy); /** * @todo Enable type checking and ensure the components are called with the correct props. diff --git a/web/src/queries/storage.ts b/web/src/queries/storage.ts index 34e1513f96..d4ca816c25 100644 --- a/web/src/queries/storage.ts +++ b/web/src/queries/storage.ts @@ -19,11 +19,13 @@ * find current contact information at www.suse.com. */ -import { useQuery, useSuspenseQueries, useSuspenseQuery } from "@tanstack/react-query"; -import { fetchDevices } from "~/api/storage/devices"; -import { fetchActions, fetchDefaultVolume, fetchProductParams, fetchSettings, fetchUsableDevices } from "~/api/storage/proposal"; -import { ProductParams, Volume as APIVolume, ProposalSettings, ProposalTarget as APIProposalTarget } from "~/api/storage/types"; -import { ProposalResult, ProposalTarget, StorageDevice, Volume, VolumeTarget } from "~/types/storage"; +import { useMutation, useQuery, useQueryClient, useSuspenseQueries, useSuspenseQuery } from "@tanstack/react-query"; +import React from "react"; +import { fetchDevices, fetchDevicesDirty } from "~/api/storage/devices"; +import { calculate, fetchActions, fetchDefaultVolume, fetchProductParams, fetchSettings, fetchUsableDevices } from "~/api/storage/proposal"; +import { ProductParams, Volume as APIVolume, ProposalSettings as APIProposalSettings, ProposalTarget as APIProposalTarget, ProposalSettingsPatch } from "~/api/storage/types"; +import { useInstallerClient } from "~/context/installer"; +import { ProposalSettings, ProposalResult, ProposalTarget, StorageDevice, Volume, VolumeTarget } from "~/types/storage"; import { compact, uniq } from "~/utils"; const devicesQuery = (scope: "result" | "system") => ({ @@ -153,7 +155,7 @@ const useProposalResult = (): ProposalResult | undefined => { } /** @todo Read installation devices from D-Bus. */ - const buildInstallationDevices = (settings: ProposalSettings, devices: StorageDevice[]) => { + const buildInstallationDevices = (settings: APIProposalSettings, devices: StorageDevice[]) => { const findDevice = (name: string) => { const device = devices.find((d) => d.name === name); @@ -230,11 +232,87 @@ const buildVolume = (rawVolume: APIVolume, devices: StorageDevice[], productMoun return volume; } +const useProposalMutation = () => { + const queryClient = useQueryClient(); + const query = { + mutationFn: (settings: ProposalSettings) => { + const buildHttpVolume = (volume: Volume): APIVolume => { + return { + autoSize: volume.autoSize, + fsType: volume.fsType, + maxSize: volume.maxSize, + minSize: volume.minSize, + mountOptions: [], + mountPath: volume.mountPath, + snapshots: volume.snapshots, + target: volume.target, + targetDevice: volume.targetDevice?.name, + }; + }; + + const buildHttpSettings = (settings: ProposalSettings): ProposalSettingsPatch => { + return { + bootDevice: settings.bootDevice, + configureBoot: settings.configureBoot, + encryptionMethod: settings.encryptionMethod, + encryptionPBKDFunction: settings.encryptionPBKDFunction, + encryptionPassword: settings.encryptionPassword, + spaceActions: settings.spacePolicy === "custom" ? settings.spaceActions : undefined, + spacePolicy: settings.spacePolicy, + target: settings.target, + targetDevice: settings.targetDevice, + targetPVDevices: settings.targetPVDevices, + volumes: settings.volumes?.map(buildHttpVolume), + }; + }; + + const httpSettings = buildHttpSettings(settings); + return calculate(httpSettings); + }, + onSuccess: () => queryClient.invalidateQueries({ queryKey: ["storage"] }) + }; + + return useMutation(query); +} + +const deprecatedQuery = { + queryKey: ["storage", "dirty"], + queryFn: fetchDevicesDirty +} + +/** + * Hook that returns whether the storage devices are "dirty". + */ +const useDeprecated = () => { + const { isPending, data } = useQuery(deprecatedQuery); + return (isPending) ? false : data; +} + +/** + * Hook that listens for changes to the devices dirty property. + */ +const useDeprecatedChanges = () => { + const client = useInstallerClient(); + const queryClient = useQueryClient(); + React.useEffect(() => { + if (!client) return; + + return client.ws().onEvent(({ type, value }) => { + if (type === "DevicesDirty") { + queryClient.setQueryData(deprecatedQuery.queryKey, value); + } + }); + }); +} + export { useDevices, useAvailableDevices, useProductParams, useVolumeTemplates, useVolumeDevices, - useProposalResult + useProposalResult, + useProposalMutation, + useDeprecated, + useDeprecatedChanges } diff --git a/web/src/types/storage.ts b/web/src/types/storage.ts index 6cbb3b2692..ec1d7c6d41 100644 --- a/web/src/types/storage.ts +++ b/web/src/types/storage.ts @@ -105,6 +105,7 @@ type ProposalSettings = { defaultBootDevice: string; encryptionPassword: string; encryptionMethod: string; + encryptionPBKDFunction?: string, spacePolicy: string; spaceActions: SpaceAction[]; volumes: Volume[]; @@ -113,7 +114,7 @@ type ProposalSettings = { type SpaceAction = { device: string; - action: string; + action: 'force_delete' | 'resize' }; type Volume = { From c0dcbf7984af83a303da5f8565357cc5f49beebb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Wed, 4 Sep 2024 16:42:46 +0100 Subject: [PATCH 10/53] refactor(web): use queries and TypeScript in DeviceSelection --- ...eviceSelection.jsx => DeviceSelection.tsx} | 84 +++++++------------ 1 file changed, 30 insertions(+), 54 deletions(-) rename web/src/components/storage/{DeviceSelection.jsx => DeviceSelection.tsx} (75%) diff --git a/web/src/components/storage/DeviceSelection.jsx b/web/src/components/storage/DeviceSelection.tsx similarity index 75% rename from web/src/components/storage/DeviceSelection.jsx rename to web/src/components/storage/DeviceSelection.tsx index 92b7fa7688..fb815dd822 100644 --- a/web/src/components/storage/DeviceSelection.jsx +++ b/web/src/components/storage/DeviceSelection.tsx @@ -45,86 +45,62 @@ import { DeviceSelectorTable } from "~/components/storage"; import DevicesTechMenu from "./DevicesTechMenu"; import { compact, useCancellablePromise } from "~/utils"; import { useInstallerClient } from "~/context/installer"; - -/** - * @typedef {import ("~/client/storage").StorageDevice} StorageDevice - */ +import { useAvailableDevices, useProposalMutation, useProposalResult } from "~/queries/storage"; +import { ProposalTarget, StorageDevice } from "~/types/storage"; const SELECT_DISK_ID = "select-disk"; const CREATE_LVM_ID = "create-lvm"; const SELECT_DISK_PANEL_ID = "panel-for-disk-selection"; const CREATE_LVM_PANEL_ID = "panel-for-lvm-creation"; +type DeviceSelectionState = { + target?: ProposalTarget; + targetDevice?: StorageDevice; + targetPVDevices?: StorageDevice[]; +} + /** * Allows the user to select a target device for installation. * @component */ export default function DeviceSelection() { - /** - * @typedef {object} DeviceSelectionState - * @property {boolean} load - * @property {string} [target] - * @property {StorageDevice} [targetDevice] - * @property {StorageDevice[]} [targetPVDevices] - * @property {StorageDevice[]} [availableDevices] - */ + const { settings } = useProposalResult(); + const availableDevices = useAvailableDevices(); + const updateProposal = useProposalMutation(); const navigate = useNavigate(); - const { cancellablePromise } = useCancellablePromise(); - /** @type ReturnType> */ - const [state, setState] = useState({ load: false }); - - const isTargetDisk = state.target === "DISK"; - const isTargetNewLvmVg = state.target === "NEW_LVM_VG"; - const { storage: client } = useInstallerClient(); + const [state, setState] = useState({}); - const loadProposalResult = useCallback(async () => { - return await cancellablePromise(client.proposal.getResult()); - }, [client, cancellablePromise]); - - const loadAvailableDevices = useCallback(async () => { - return await cancellablePromise(client.proposal.getAvailableDevices()); - }, [client, cancellablePromise]); + const isTargetDisk = state.target === ProposalTarget.DISK; + const isTargetNewLvmVg = state.target === ProposalTarget.NEW_LVM_VG; useEffect(() => { - const load = async () => { - const { settings } = await loadProposalResult(); - const availableDevices = await loadAvailableDevices(); - - // FIXME: move to a state/reducer - setState({ - load: true, - availableDevices, - target: settings.target, - targetDevice: availableDevices.find((d) => d.name === settings.targetDevice), - targetPVDevices: availableDevices.filter((d) => settings.targetPVDevices?.includes(d.name)), - }); - }; - - if (state.load) return; - - load().catch(console.error); - }, [state, loadAvailableDevices, loadProposalResult]); + if (state.target !== undefined) return; - if (!state.load) return ; + // FIXME: move to a state/reducer + setState({ + target: settings.target, + targetDevice: availableDevices.find((d) => d.name === settings.targetDevice), + targetPVDevices: availableDevices.filter((d) => settings.targetPVDevices?.includes(d.name)), + }); + }, [settings, availableDevices]); - const selectTargetDisk = () => setState({ ...state, target: "DISK" }); - const selectTargetNewLvmVG = () => setState({ ...state, target: "NEW_LVM_VG" }); + const selectTargetDisk = () => setState({ ...state, target: ProposalTarget.DISK }); + const selectTargetNewLvmVG = () => setState({ ...state, target: ProposalTarget.NEW_LVM_VG }); - const selectTargetDevice = (devices) => setState({ ...state, targetDevice: devices[0] }); - const selectTargetPVDevices = (devices) => { + const selectTargetDevice = (devices: StorageDevice[]) => setState({ ...state, targetDevice: devices[0] }); + const selectTargetPVDevices = (devices: StorageDevice[]) => { setState({ ...state, targetPVDevices: devices }); }; const onSubmit = async (e) => { e.preventDefault(); - const { settings } = await loadProposalResult(); const newSettings = { target: state.target, targetDevice: isTargetDisk ? state.targetDevice?.name : "", targetPVDevices: isTargetNewLvmVg ? state.targetPVDevices.map((d) => d.name) : [], }; - await client.proposal.calculate({ ...settings, ...newSettings }); + updateProposal.mutateAsync({ ...settings, ...newSettings }); navigate(".."); }; @@ -135,7 +111,7 @@ export default function DeviceSelection() { return true; }; - const isDeviceSelectable = (device) => device.isDrive || device.type === "md"; + const isDeviceSelectable = (device: StorageDevice) => device.isDrive || device.type === "md"; // TRANSLATORS: description for using plain partitions for installing the // system, the text in the square brackets [] is displayed in bold, use only @@ -201,7 +177,7 @@ devices.", Date: Wed, 4 Sep 2024 23:53:01 +0100 Subject: [PATCH 11/53] refactor(web): clean-up of the ProposalPage component --- web/src/api/storage.ts | 31 ++ .../components/storage/ProposalPage.test.jsx | 276 ------------------ .../components/storage/ProposalPage.test.tsx | 139 +++++++++ web/src/components/storage/ProposalPage.tsx | 68 +---- web/src/queries/storage.ts | 12 +- 5 files changed, 185 insertions(+), 341 deletions(-) create mode 100644 web/src/api/storage.ts delete mode 100644 web/src/components/storage/ProposalPage.test.jsx create mode 100644 web/src/components/storage/ProposalPage.test.tsx diff --git a/web/src/api/storage.ts b/web/src/api/storage.ts new file mode 100644 index 0000000000..036e9c43a0 --- /dev/null +++ b/web/src/api/storage.ts @@ -0,0 +1,31 @@ +/* + * Copyright (c) [2022-2023] 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. + */ + +import { post } from "~/api/http"; + +/** + * Starts the storage probing process. + */ +const probe = (): Promise => post("/api/storage/probe"); + +export { + probe +} diff --git a/web/src/components/storage/ProposalPage.test.jsx b/web/src/components/storage/ProposalPage.test.jsx deleted file mode 100644 index d3f48030c1..0000000000 --- a/web/src/components/storage/ProposalPage.test.jsx +++ /dev/null @@ -1,276 +0,0 @@ -/* - * Copyright (c) [2022-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 { act, screen, waitFor } from "@testing-library/react"; -import { createCallbackMock, installerRender } from "~/test-utils"; -import { createClient } from "~/client"; -// eslint-disable-next-line @typescript-eslint/no-unused-vars -import { StorageClient } from "~/client/storage"; -import { IDLE } from "~/client/status"; -import { ProposalPage } from "~/components/storage"; - -/** - * @typedef {import ("~/client/storage").ProposalResult} ProposalResult - * @typedef {import ("~/client/storage").StorageDevice} StorageDevice - * @typedef {import ("~/client/storage").Volume} Volume - */ - -jest.mock("~/client"); -jest.mock("@patternfly/react-core", () => { - const original = jest.requireActual("@patternfly/react-core"); - - return { - ...original, - Skeleton: () =>
PFSkeleton
, - }; -}); -jest.mock("./DevicesTechMenu", () => () =>
Devices Tech Menu
); - -jest.mock("~/queries/software", () => ({ - ...jest.requireActual("~/queries/software"), - useProduct: () => ({ - selectedProduct: { name: "Test" }, - }), - useProductChanges: () => jest.fn(), -})); - -const createClientMock = /** @type {jest.Mock} */ (createClient); - -/** @type {StorageDevice} */ -const vda = { - sid: 59, - type: "disk", - isDrive: true, - description: "", - vendor: "Micron", - model: "Micron 1100 SATA", - driver: ["ahci", "mmcblk"], - bus: "IDE", - transport: "usb", - dellBOSS: false, - sdCard: true, - active: true, - name: "/dev/vda", - size: 1e12, - systems: ["Windows 11", "openSUSE Leap 15.2"], - udevIds: ["ata-Micron_1100_SATA_512GB_12563", "scsi-0ATA_Micron_1100_SATA_512GB"], - udevPaths: ["pci-0000:00-12", "pci-0000:00-12-ata"], -}; - -/** @type {StorageDevice} */ -const vdb = { - sid: 60, - type: "disk", - isDrive: true, - description: "", - vendor: "Seagate", - model: "Unknown", - driver: ["ahci", "mmcblk"], - bus: "IDE", - name: "/dev/vdb", - size: 1e6, -}; - -/** - * @param {string} mountPath - * @returns {Volume} - */ -const volume = (mountPath) => { - return { - mountPath, - target: "DEFAULT", - fsType: "Btrfs", - minSize: 1024, - maxSize: 1024, - autoSize: false, - snapshots: false, - transactional: false, - outline: { - required: false, - fsTypes: ["Btrfs"], - supportAutoSize: false, - snapshotsConfigurable: false, - snapshotsAffectSizes: false, - sizeRelevantVolumes: [], - adjustByRam: false, - productDefined: false, - }, - }; -}; - -/** @type {StorageClient} */ -let storage; - -/** @type {ProposalResult} */ -let proposalResult; - -beforeEach(() => { - proposalResult = { - settings: { - target: "DISK", - targetPVDevices: [], - configureBoot: false, - bootDevice: "", - defaultBootDevice: "", - encryptionPassword: "", - encryptionMethod: "", - spacePolicy: "", - spaceActions: [], - volumes: [], - installationDevices: [], - }, - actions: [], - }; - - storage = { - probe: jest.fn().mockResolvedValue(0), - // @ts-expect-error Some methods have to be private to avoid type complaint. - proposal: { - getAvailableDevices: jest.fn().mockResolvedValue([vda, vdb]), - getVolumeDevices: jest.fn().mockResolvedValue([vda, vdb]), - getEncryptionMethods: jest.fn().mockResolvedValue([]), - getProductMountPoints: jest.fn().mockResolvedValue([]), - getResult: jest.fn().mockResolvedValue(proposalResult), - defaultVolume: jest.fn((mountPath) => Promise.resolve(volume(mountPath))), - calculate: jest.fn().mockResolvedValue(0), - }, - // @ts-expect-error Some methods have to be private to avoid type complaint. - system: { - getDevices: jest.fn().mockResolvedValue([vda, vdb]), - }, - // @ts-expect-error Some methods have to be private to avoid type complaint. - staging: { - getDevices: jest.fn().mockResolvedValue([vda]), - }, - getErrors: jest.fn().mockResolvedValue([]), - isDeprecated: jest.fn().mockResolvedValue(false), - onDeprecate: jest.fn(), - onStatusChange: jest.fn(), - }; - - createClientMock.mockImplementation(() => ({ storage })); -}); - -it.skip("probes storage if the storage devices are deprecated", async () => { - storage.isDeprecated = jest.fn().mockResolvedValue(true); - installerRender(); - await waitFor(() => expect(storage.probe).toHaveBeenCalled()); -}); - -it.skip("does not probe storage if the storage devices are not deprecated", async () => { - installerRender(); - await waitFor(() => expect(storage.probe).not.toHaveBeenCalled()); -}); - -it.skip("loads the proposal data", async () => { - proposalResult.settings.target = "DISK"; - proposalResult.settings.targetDevice = vda.name; - - installerRender(); - - await screen.findByText(/\/dev\/vda/); -}); - -it.skip("renders the device, settings and result sections", async () => { - installerRender(); - - await screen.findByText(/Device/); - await screen.findByText(/Settings/); - await screen.findByText(/Result/); -}); - -describe.skip("when the storage devices become deprecated", () => { - it("probes storage", async () => { - const [mockFunction, callbacks] = createCallbackMock(); - storage.onDeprecate = mockFunction; - - installerRender(); - - storage.isDeprecated = jest.fn().mockResolvedValue(true); - const [onDeprecateCb] = callbacks; - await act(() => onDeprecateCb()); - - await waitFor(() => expect(storage.probe).toHaveBeenCalled()); - }); - - it("loads the proposal data", async () => { - proposalResult.settings.target = "DISK"; - proposalResult.settings.targetDevice = vda.name; - - const [mockFunction, callbacks] = createCallbackMock(); - storage.onDeprecate = mockFunction; - - installerRender(); - - await screen.findByText(/\/dev\/vda/); - - proposalResult.settings.targetDevice = vdb.name; - - const [onDeprecateCb] = callbacks; - await act(() => onDeprecateCb()); - - await screen.findByText(/\/dev\/vdb/); - }); -}); - -describe.skip("when there is no proposal yet", () => { - it("loads the proposal when the service finishes to calculate", async () => { - const defaultResult = proposalResult; - proposalResult = undefined; - - const [mockFunction, callbacks] = createCallbackMock(); - storage.onStatusChange = mockFunction; - - installerRender(); - - screen.getAllByText(/PFSkeleton/); - - proposalResult = defaultResult; - proposalResult.settings.target = "DISK"; - proposalResult.settings.targetDevice = vda.name; - - const [onStatusChangeCb] = callbacks; - await act(() => onStatusChangeCb(IDLE)); - await screen.findByText(/\/dev\/vda/); - }); -}); - -describe.skip("when there is a proposal", () => { - beforeEach(() => { - proposalResult.settings.target = "DISK"; - proposalResult.settings.targetDevice = vda.name; - }); - - it("does not load the proposal when the service finishes to calculate", async () => { - const [mockFunction, callbacks] = createCallbackMock(); - storage.onStatusChange = mockFunction; - - installerRender(); - - await screen.findByText(/\/dev\/vda/); - - const [onStatusChangeCb] = callbacks; - expect(onStatusChangeCb).toBeUndefined(); - }); -}); diff --git a/web/src/components/storage/ProposalPage.test.tsx b/web/src/components/storage/ProposalPage.test.tsx new file mode 100644 index 0000000000..4a373e5b50 --- /dev/null +++ b/web/src/components/storage/ProposalPage.test.tsx @@ -0,0 +1,139 @@ +/* + * Copyright (c) [2022-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. + */ + +/* + * NOTE: this test is not useful. The ProposalPage loads several queries but, + * perhaps, each nested component should be responsible for loading the + * information they need. + */ +import React from "react"; +import { screen } from "@testing-library/react"; +import { plainRender } from "~/test-utils"; +import { ProposalPage } from "~/components/storage"; +import { ProposalResult, ProposalTarget, StorageDevice, Volume, VolumeTarget } from "~/types/storage"; + +jest.mock("~/queries/issues", () => ({ + ...jest.requireActual("~/queries/issues"), + useIssuesChanges: jest.fn(), + useIssues: () => [], +})); + +jest.mock("./ProposalSettingsSection", () => () =>
proposal settings
); +jest.mock("./ProposalActionsSummary", () => () =>
actions section
); +jest.mock("./ProposalResultSection", () => () =>
result section
); +jest.mock("./ProposalTransactionalInfo", () => () =>
trasactional info
); + +const vda: StorageDevice = { + sid: 59, + type: "disk", + isDrive: true, + description: "", + vendor: "Micron", + model: "Micron 1100 SATA", + driver: ["ahci", "mmcblk"], + bus: "IDE", + transport: "usb", + dellBOSS: false, + sdCard: true, + active: true, + name: "/dev/vda", + size: 1e12, + systems: ["Windows 11", "openSUSE Leap 15.2"], + udevIds: ["ata-Micron_1100_SATA_512GB_12563", "scsi-0ATA_Micron_1100_SATA_512GB"], + udevPaths: ["pci-0000:00-12", "pci-0000:00-12-ata"], +}; + +const vdb: StorageDevice = { + sid: 60, + type: "disk", + isDrive: true, + description: "", + vendor: "Seagate", + model: "Unknown", + driver: ["ahci", "mmcblk"], + bus: "IDE", + name: "/dev/vdb", + size: 1e6, +}; + +/** + * Returns a volume specification with the given path. + */ +const volume = (mountPath: string): Volume => { + return { + mountPath, + target: VolumeTarget.DEFAULT, + fsType: "Btrfs", + minSize: 1024, + maxSize: 1024, + autoSize: false, + snapshots: false, + transactional: false, + outline: { + required: false, + fsTypes: ["Btrfs"], + supportAutoSize: false, + snapshotsConfigurable: false, + snapshotsAffectSizes: false, + sizeRelevantVolumes: [], + adjustByRam: false, + productDefined: false, + }, + }; +}; + +const mockProposalResult: ProposalResult = { + settings: { + target: ProposalTarget.DISK, + targetPVDevices: [], + configureBoot: false, + bootDevice: "", + defaultBootDevice: "", + encryptionPassword: "", + encryptionMethod: "", + spacePolicy: "", + spaceActions: [], + volumes: [], + installationDevices: [], + }, + actions: [], +}; + +jest.mock("~/queries/storage", () => ({ + ...jest.requireActual("~/queries/storage"), + useDevices: () => ([vda, vdb]), + useAvailableDevices: () => ([vda, vdb]), + useVolumeDevices: () => ([vda, vdb]), + useVolumeTemplates: () => [volume("/")], + useProductParams: () => ({ + encryptionMethods: [], + mountPoints: ["/", "swap"] + }), + useProposalResult: () => mockProposalResult, + useDeprecated: () => false, + useDeprecatedChanges: jest.fn(), + useProposalMutation: jest.fn() +})); + +it("renders the device, settings and result sections", () => { + plainRender(); + screen.findByText("Device"); +}); diff --git a/web/src/components/storage/ProposalPage.tsx b/web/src/components/storage/ProposalPage.tsx index cfe6454216..712724a774 100644 --- a/web/src/components/storage/ProposalPage.tsx +++ b/web/src/components/storage/ProposalPage.tsx @@ -19,7 +19,7 @@ * find current contact information at www.suse.com. */ -import React, { useCallback, useReducer, useEffect, useRef } from "react"; +import React, { useReducer, useEffect, useRef } from "react"; import { Grid, GridItem, Stack } from "@patternfly/react-core"; import { Page, Drawer } from "~/components/core/"; import ProposalTransactionalInfo from "./ProposalTransactionalInfo"; @@ -29,29 +29,11 @@ import ProposalActionsSummary from "~/components/storage/ProposalActionsSummary" import { ProposalActionsDialog } from "~/components/storage"; import { _ } from "~/i18n"; import { SPACE_POLICIES } from "~/components/storage/utils"; -import { useInstallerClient } from "~/context/installer"; import { toValidationError, useCancellablePromise } from "~/utils"; import { useIssues } from "~/queries/issues"; import { IssueSeverity } from "~/types/issues"; import { useAvailableDevices, useDeprecated, useDeprecatedChanges, useDevices, useProductParams, useProposalMutation, useProposalResult, useVolumeDevices, useVolumeTemplates } from "~/queries/storage"; - -/** - * @typedef {import ("~/components/storage/utils").SpacePolicy} SpacePolicy - */ - -const initialState = { - loading: false, - settings: {}, - actions: [], -}; - -const reducer = (state, action) => { - switch (action.type) { - default: { - return state; - } - } -}; +import { probe } from "~/api/storage"; /** * Which UI item is being changed by user @@ -77,32 +59,15 @@ export const NOT_AFFECTED = { ProposalActionsSummary: [CHANGING.ENCRYPTION, CHANGING.TARGET], }; -/** - * A helper function to decide whether to show the progress skeletons or not - * for the specified component - * - * FIXME: remove duplication - * - * @param {boolean} loading loading status - * @param {string} component name of the component - * @param {symbol} changing the item which is being changed - * @returns {boolean} true if the skeleton should be displayed, false otherwise - */ -const showSkeleton = (loading, component, changing) => { - return loading && !NOT_AFFECTED[component].includes(changing); -}; - export default function ProposalPage() { - const { storage: client } = useInstallerClient(); const { cancellablePromise } = useCancellablePromise(); - const [state, dispatch] = useReducer(reducer, initialState); const drawerRef = useRef(); const systemDevices = useDevices("system"); const stagingDevices = useDevices("result"); const availableDevices = useAvailableDevices(); - const { encryptionMethods } = useProductParams({ suspense: true }); - const volumeTemplates = useVolumeTemplates({ suspense: true }); const volumeDevices = useVolumeDevices(); + const volumeTemplates = useVolumeTemplates({ suspense: true }); + const { encryptionMethods } = useProductParams({ suspense: true }); const { actions, settings } = useProposalResult(); const updateProposal = useProposalMutation(); const deprecated = useDeprecated(); @@ -112,16 +77,9 @@ export default function ProposalPage() { .filter((s) => s.severity === IssueSeverity.Error) .map(toValidationError); - const calculateProposal = useCallback( - async (settings) => { - return await cancellablePromise(client.proposal.calculate(settings)); - }, - [client, cancellablePromise], - ); - useEffect(() => { if (deprecated) { - cancellablePromise(client.probe()); + cancellablePromise(probe()); } }, [deprecated]); @@ -132,13 +90,6 @@ export default function ProposalPage() { const spacePolicy = SPACE_POLICIES.find((p) => p.id === settings.spacePolicy); - /** - * @todo Enable type checking and ensure the components are called with the correct props. - * - * @note The default value for `settings` should be `undefined` instead of an empty object, and - * the settings prop of the components should accept both a ProposalSettings object or undefined. - */ - return ( @@ -157,8 +108,7 @@ export default function ProposalPage() { volumeTemplates={volumeTemplates} settings={settings} onChange={changeSettings} - isLoading={state.loading} - changing={state.changing} + isLoading={false} />
@@ -179,14 +129,14 @@ export default function ProposalPage() { // @ts-expect-error: we do not know how to specify the type of // drawerRef properly and TS does not find the "open" property onActionsClick={drawerRef.current?.open} - isLoading={showSkeleton(state.loading, "ProposalActionsSummary", state.changing)} + isLoading={false} /> diff --git a/web/src/queries/storage.ts b/web/src/queries/storage.ts index d4ca816c25..9f5e08d3b6 100644 --- a/web/src/queries/storage.ts +++ b/web/src/queries/storage.ts @@ -23,10 +23,10 @@ import { useMutation, useQuery, useQueryClient, useSuspenseQueries, useSuspenseQ import React from "react"; import { fetchDevices, fetchDevicesDirty } from "~/api/storage/devices"; import { calculate, fetchActions, fetchDefaultVolume, fetchProductParams, fetchSettings, fetchUsableDevices } from "~/api/storage/proposal"; -import { ProductParams, Volume as APIVolume, ProposalSettings as APIProposalSettings, ProposalTarget as APIProposalTarget, ProposalSettingsPatch } from "~/api/storage/types"; import { useInstallerClient } from "~/context/installer"; -import { ProposalSettings, ProposalResult, ProposalTarget, StorageDevice, Volume, VolumeTarget } from "~/types/storage"; import { compact, uniq } from "~/utils"; +import { ProductParams, Volume as APIVolume, ProposalSettings as APIProposalSettings, ProposalTarget as APIProposalTarget, ProposalSettingsPatch } from "~/api/storage/types"; +import { ProposalSettings, ProposalResult, ProposalTarget, StorageDevice, Volume, VolumeTarget } from "~/types/storage"; const devicesQuery = (scope: "result" | "system") => ({ queryKey: ["storage", "devices", scope], @@ -53,7 +53,7 @@ const defaultVolumeQuery = (mountPath: string) => ({ }); /** - * Hook that returns the list of storage devices for the given scope + * Hook that returns the list of storage devices for the given scope. * * @param scope - "system": devices in the current state of the system; "result": * devices in the proposal ("stage") @@ -95,9 +95,9 @@ const useProductParams = (options?: QueryHookOptions): ProductParams => { /** * Hook that returns the volume templates for the current product. */ -const useVolumeTemplates = (options?: QueryHookOptions): Volume[] => { +const useVolumeTemplates = (): Volume[] => { const systemDevices = useDevices("system", { suspense: true }); - const product = useProductParams(options || {}); + const product = useProductParams(); if (!product) return []; const queries = product.mountPoints.map((p) => defaultVolumeQuery(p)); @@ -145,7 +145,7 @@ const proposalActionsQuery = { }; /** - * Gets the values of the current proposal + * Hook that returns the current proposal (settings and actions). */ const useProposalResult = (): ProposalResult | undefined => { const buildTarget = (value: APIProposalTarget): ProposalTarget => { From 1e51ad0f506684d1a4073d21490a91a8dd9cb333 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Thu, 5 Sep 2024 08:56:15 +0100 Subject: [PATCH 12/53] chore(web): fix file header --- web/src/api/storage.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/api/storage.ts b/web/src/api/storage.ts index 036e9c43a0..bd0b7fac52 100644 --- a/web/src/api/storage.ts +++ b/web/src/api/storage.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) [2022-2023] SUSE LLC + * Copyright (c) [2024] SUSE LLC * * All Rights Reserved. * From 5d93b336ffeab98665d627c5faebf862df4208e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Thu, 5 Sep 2024 08:31:46 +0100 Subject: [PATCH 13/53] refactor(web): convert storage utils module to TypeScript --- .../storage/{utils.test.js => utils.test.ts} | 35 ++---- .../components/storage/{utils.js => utils.ts} | 102 ++++++------------ 2 files changed, 40 insertions(+), 97 deletions(-) rename web/src/components/storage/{utils.test.js => utils.test.ts} (94%) rename web/src/components/storage/{utils.js => utils.ts} (78%) diff --git a/web/src/components/storage/utils.test.js b/web/src/components/storage/utils.test.ts similarity index 94% rename from web/src/components/storage/utils.test.js rename to web/src/components/storage/utils.test.ts index 0f07410a00..24545087e0 100644 --- a/web/src/components/storage/utils.test.js +++ b/web/src/components/storage/utils.test.ts @@ -19,8 +19,7 @@ * find current contact information at www.suse.com. */ -// @ts-check - +import { StorageDevice, Volume, VolumeTarget } from "~/types/storage"; import { deviceSize, deviceBaseName, @@ -34,22 +33,13 @@ import { isTransactionalSystem, } from "./utils"; -/** - * @typedef {import("~/client/storage").StorageDevice} StorageDevice - * @typedef {import("~/client/storage").Volume} Volume - */ - /** Volume factory. * @function - * - * @param {object} [properties={}] - * @returns {Volume} */ -const volume = (properties = {}) => { - /** @type {Volume} */ - const testVolume = { +const volume = (properties: object = {}): Volume => { + const testVolume: Volume = { mountPath: "/test", - target: "DEFAULT", + target: VolumeTarget.DEFAULT, fsType: "Btrfs", minSize: 1024, maxSize: 2048, @@ -71,8 +61,7 @@ const volume = (properties = {}) => { return { ...testVolume, ...properties }; }; -/** @type {StorageDevice} */ -const sda = { +const sda: StorageDevice = { sid: 59, isDrive: true, type: "disk", @@ -92,8 +81,7 @@ const sda = { udevPaths: [], }; -/** @type {StorageDevice} */ -const sda1 = { +const sda1: StorageDevice = { sid: 60, isDrive: false, type: "partition", @@ -110,8 +98,7 @@ const sda1 = { isEFI: false, }; -/** @type {StorageDevice} */ -const sda2 = { +const sda2: StorageDevice = { sid: 61, isDrive: false, type: "partition", @@ -138,8 +125,7 @@ sda.partitionTable = { ], }; -/** @type {StorageDevice} */ -const lvmVg = { +const lvmVg: StorageDevice = { sid: 72, isDrive: false, type: "lvmVg", @@ -148,8 +134,7 @@ const lvmVg = { size: 512, }; -/** @type {StorageDevice} */ -const lvmLv1 = { +const lvmLv1: StorageDevice = { sid: 73, isDrive: false, type: "lvmLv", @@ -199,7 +184,7 @@ describe("deviceLabel", () => { describe("deviceChildren", () => { /** @type {StorageDevice} */ - let device; + let device: StorageDevice; describe("if the device has partition table", () => { beforeEach(() => { diff --git a/web/src/components/storage/utils.js b/web/src/components/storage/utils.ts similarity index 78% rename from web/src/components/storage/utils.js rename to web/src/components/storage/utils.ts index 47d9367ec7..21f6665cda 100644 --- a/web/src/components/storage/utils.js +++ b/web/src/components/storage/utils.ts @@ -30,29 +30,25 @@ */ import xbytes from "xbytes"; - import { N_ } from "~/i18n"; - -/** - * @typedef {import ("~/client/storage").Volume} Volume - * @typedef {import ("~/client/storage").StorageDevice} StorageDevice - * @typedef {import ("~/client/storage").PartitionSlot} PartitionSlot - */ +import { PartitionSlot, StorageDevice, Volume } from "~/types/storage"; /** * @note undefined for either property means unknown - * @typedef {object} SizeObject - * @property {number|undefined} size - The "amount" of size (10, 128, ...) - * @property {string|undefined} unit - The size unit (MiB, GiB, ...) - * - * @typedef {object} SpacePolicy - * @property {string} id - * @property {string} label - * @property {string} description - * @property {string[]} summaryLabels - * - * @typedef {"auto"|"fixed"|"range"} SizeMethod */ +export type SizeObject = { + size: number | undefined; + unit: string | undefined; +} + +export type SpacePolicy = { + id: string; + label: string; + description: string; + summaryLabels: string[]; +} + +export type SizeMethod = "auto" | "fixed" | "range"; const SIZE_METHODS = Object.freeze({ AUTO: "auto", @@ -70,8 +66,7 @@ const SIZE_UNITS = Object.freeze({ const DEFAULT_SIZE_UNIT = "GiB"; -/** @type {SpacePolicy[]} */ -const SPACE_POLICIES = [ +const SPACE_POLICIES: SpacePolicy[] = [ { id: "delete", label: N_("Delete current content"), @@ -122,11 +117,8 @@ const SPACE_POLICIES = [ * input otherwise. Note, however, that -1 number will treated as empty string * since it means nothing for Agama UI although it represents the "unlimited" * size in the backend. - * - * @param {number|string|undefined} size - * @returns {SizeObject} */ -const splitSize = (size) => { +const splitSize = (size: number | string | undefined): SizeObject => { // From D-Bus, maxSize comes as undefined when set as "unlimited", but for Agama UI // it means "leave it empty" const sanitizedSize = size === undefined ? "" : size; @@ -150,11 +142,8 @@ const splitSize = (size) => { * @example * deviceSize(1024) * // returns "1 KiB" - * - * @param {number} size - Number of bytes - * @returns {string} */ -const deviceSize = (size) => { +const deviceSize = (size: number): string => { // Sadly, we cannot returns directly the xbytes(size, { iec: true }) because // it does not have an option for dropping/ignoring trailing zeroes and we do // not want to render them. @@ -175,11 +164,8 @@ const deviceSize = (size) => { * * parseToBytes("") * // returns 0 - * - * @param {string|number} size - * @returns {number} */ -const parseToBytes = (size) => { +const parseToBytes = (size: string | number): number => { if (!size || size === undefined || size === "") return 0; const value = xbytes.parseSize(size.toString(), { iec: true }) || parseInt(size.toString()); @@ -191,22 +177,16 @@ const parseToBytes = (size) => { /** * Base name of a device. * @function - * - * @param {StorageDevice} device - * @returns {string} */ -const deviceBaseName = (device) => { +const deviceBaseName = (device: StorageDevice): string => { return device.name.split("/").pop(); }; /** * Generates the label for the given device * @function - * - * @param {StorageDevice} device - * @returns {string} */ -const deviceLabel = (device) => { +const deviceLabel = (device: StorageDevice): string => { const name = device.name; const size = device.size; @@ -220,11 +200,8 @@ const deviceLabel = (device) => { * @note This method could be directly provided by the device object. For now, the method is kept * here because the elements considered as children (e.g., partitions + unused slots) is not a * semantic storage concept but a helper for UI components. - * - * @param {StorageDevice} device - * @returns {(StorageDevice|PartitionSlot)[]} */ -const deviceChildren = (device) => { +const deviceChildren = (device: StorageDevice): (StorageDevice | PartitionSlot)[] => { const partitionTableChildren = (partitionTable) => { const { partitions, unusedSlots } = partitionTable; const children = partitions.concat(unusedSlots).filter((i) => !!i); @@ -248,7 +225,7 @@ const deviceChildren = (device) => { * @param {string} fs - Filesystem name to check. * @returns {boolean} true when volume uses given fs */ -const hasFS = (volume, fs) => { +const hasFS = (volume: Volume, fs: string): boolean => { const volFS = volume.fsType; return volFS.toLowerCase() === fs.toLocaleLowerCase(); @@ -257,68 +234,49 @@ const hasFS = (volume, fs) => { /** * Checks whether the given volume has snapshots. * @function - * - * @param {Volume} volume - * @returns {boolean} */ -const hasSnapshots = (volume) => { +const hasSnapshots = (volume: Volume): boolean => { return hasFS(volume, "btrfs") && volume.snapshots; }; /** * Checks whether the given volume defines a transactional root. * @function - * - * @param {Volume} volume - * @returns {boolean} */ -const isTransactionalRoot = (volume) => { +const isTransactionalRoot = (volume: Volume): boolean => { return volume.mountPath === "/" && volume.transactional; }; /** * Checks whether the given volumes defines a transactional system. * @function - * - * @param {Volume[]} volumes - * @returns {boolean} */ -const isTransactionalSystem = (volumes = []) => { +const isTransactionalSystem = (volumes: Volume[] = []): boolean => { return volumes.find((v) => isTransactionalRoot(v)) !== undefined; }; /** * Checks whether the given volume is configured to mount an existing file system. * @function - * - * @param {Volume} volume - * @returns {boolean} */ -const mountFilesystem = (volume) => volume.target === "FILESYSTEM"; +const mountFilesystem = (volume: Volume): boolean => volume.target === "filesystem"; /** * Checks whether the given volume is configured to reuse a device (format or mount a file system). * @function - * - * @param {Volume} volume - * @returns {boolean} */ -const reuseDevice = (volume) => volume.target === "FILESYSTEM" || volume.target === "DEVICE"; +const reuseDevice = (volume: Volume): boolean => volume.target === "filesystem" || volume.target === "device"; /** * Generates a label for the given volume. * @function - * - * @param {Volume} volume - * @returns {string} */ -const volumeLabel = (volume) => (volume.mountPath === "/" ? "root" : volume.mountPath); +const volumeLabel = (volume: Volume): string => (volume.mountPath === "/" ? "root" : volume.mountPath); /** * GiB to Bytes. - * - * @type {(value: number) => number } */ -const gib = (value) => value * 1024 ** 3; + */ +const gib: (value: number) => number = (value): number => value * 1024 ** 3; export { DEFAULT_SIZE_UNIT, From 421aadbef499871a65ebc73512085409ab2c02e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Thu, 5 Sep 2024 08:53:11 +0100 Subject: [PATCH 14/53] feat(web): convert EncryptionField to TypeScript --- ...ield.test.jsx => EncryptionField.test.tsx} | 4 +- ...ncryptionField.jsx => EncryptionField.tsx} | 43 ++++++++----------- web/src/types/storage.ts | 17 +++++++- 3 files changed, 33 insertions(+), 31 deletions(-) rename web/src/components/storage/{EncryptionField.test.jsx => EncryptionField.test.tsx} (96%) rename web/src/components/storage/{EncryptionField.jsx => EncryptionField.tsx} (77%) diff --git a/web/src/components/storage/EncryptionField.test.jsx b/web/src/components/storage/EncryptionField.test.tsx similarity index 96% rename from web/src/components/storage/EncryptionField.test.jsx rename to web/src/components/storage/EncryptionField.test.tsx index 0df4f5d8d0..12034dfc44 100644 --- a/web/src/components/storage/EncryptionField.test.jsx +++ b/web/src/components/storage/EncryptionField.test.tsx @@ -19,12 +19,10 @@ * find current contact information at www.suse.com. */ -// @ts-check - import React from "react"; import { screen, within } from "@testing-library/react"; import { plainRender } from "~/test-utils"; -import { EncryptionMethods } from "~/client/storage"; +import { EncryptionMethods } from "~/types/storage"; import EncryptionField from "~/components/storage/EncryptionField"; describe("EncryptionField", () => { diff --git a/web/src/components/storage/EncryptionField.jsx b/web/src/components/storage/EncryptionField.tsx similarity index 77% rename from web/src/components/storage/EncryptionField.jsx rename to web/src/components/storage/EncryptionField.tsx index 017ea2dc95..4695594725 100644 --- a/web/src/components/storage/EncryptionField.jsx +++ b/web/src/components/storage/EncryptionField.tsx @@ -19,20 +19,14 @@ * find current contact information at www.suse.com. */ -// @ts-check - import React, { useCallback, useEffect, useState } from "react"; import { Button, Skeleton } from "@patternfly/react-core"; import { CardField } from "~/components/core"; -import { EncryptionMethods } from "~/client/storage"; -import EncryptionSettingsDialog from "~/components/storage/EncryptionSettingsDialog"; +import EncryptionSettingsDialog, { EncryptionSetting } from "~/components/storage/EncryptionSettingsDialog"; +import { EncryptionMethods } from "~/types/storage"; import { _ } from "~/i18n"; import { noop } from "~/utils"; -/** - * @typedef {import ("~/client/storage").StorageDevice} StorageDevice - */ - // Field texts at root level to avoid redefinitions every time the component // is rendered. const LABEL = _("Encryption"); @@ -66,22 +60,22 @@ const Action = ({ isEnabled, isLoading, onClick }) => { ); }; +export type EncryptionConfig = { + password: string; + method?: string; +} + +export type EncryptionFieldProps = { + password?: string; + method?: string; + methods?: string[]; + isLoading?: boolean; + onChange?: (config: EncryptionConfig) => void; +} + /** * Allows to define encryption * @component - * - * @typedef {object} EncryptionConfig - * @property {string} password - * @property {string} [method] - * - * @typedef {object} EncryptionFieldProps - * @property {string} [password=""] - Password for encryption - * @property {string} [method=""] - Encryption method - * @property {string[]} [methods=[]] - Possible encryption methods - * @property {boolean} [isLoading=false] - Whether to show the selector as loading - * @property {(config: EncryptionConfig) => void} [onChange=noop] - On change callback - * - * @param {EncryptionFieldProps} props */ export default function EncryptionField({ password = "", @@ -90,7 +84,7 @@ export default function EncryptionField({ methods = [], isLoading = false, onChange = noop, -}) { +}: EncryptionFieldProps) { const validPassword = useCallback(() => password?.length > 0, [password]); const [isEnabled, setIsEnabled] = useState(validPassword()); const [isDialogOpen, setIsDialogOpen] = useState(false); @@ -103,10 +97,7 @@ export default function EncryptionField({ const closeDialog = () => setIsDialogOpen(false); - /** - * @param {import("~/components/storage/EncryptionSettingsDialog").EncryptionSetting} encryptionSetting - */ - const onAccept = (encryptionSetting) => { + const onAccept = (encryptionSetting: EncryptionSetting) => { closeDialog(); onChange(encryptionSetting); }; diff --git a/web/src/types/storage.ts b/web/src/types/storage.ts index ec1d7c6d41..3af5eff5c8 100644 --- a/web/src/types/storage.ts +++ b/web/src/types/storage.ts @@ -165,6 +165,18 @@ enum VolumeTarget { FILESYSTEM = "filesystem", }; +/** + * Enum for the encryption method values + * + * @readonly + * @enum { string } + */ +const EncryptionMethods = Object.freeze({ + LUKS2: "luks2", + TPM: "tpm_fde", +}); + + export type { Action, Component, @@ -181,6 +193,7 @@ export type { }; export { - VolumeTarget, - ProposalTarget + EncryptionMethods, + ProposalTarget, + VolumeTarget }; From 12bacd61d4aeef5463b51d628142d08be5515ca2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Thu, 5 Sep 2024 09:18:06 +0100 Subject: [PATCH 15/53] refactor(web): convert PartitionsField to TypeScript --- ...ield.test.jsx => PartitionsField.test.tsx} | 55 ++- ...artitionsField.jsx => PartitionsField.tsx} | 352 ++++++++---------- 2 files changed, 171 insertions(+), 236 deletions(-) rename web/src/components/storage/{PartitionsField.test.jsx => PartitionsField.test.tsx} (92%) rename web/src/components/storage/{PartitionsField.jsx => PartitionsField.tsx} (69%) diff --git a/web/src/components/storage/PartitionsField.test.jsx b/web/src/components/storage/PartitionsField.test.tsx similarity index 92% rename from web/src/components/storage/PartitionsField.test.jsx rename to web/src/components/storage/PartitionsField.test.tsx index 4cae323a25..2d34c13865 100644 --- a/web/src/components/storage/PartitionsField.test.jsx +++ b/web/src/components/storage/PartitionsField.test.tsx @@ -24,13 +24,8 @@ import React from "react"; import { screen, within } from "@testing-library/react"; import { plainRender } from "~/test-utils"; -import PartitionsField from "~/components/storage/PartitionsField"; - -/** - * @typedef {import("~/components/storage/PartitionsField").PartitionsFieldProps} PartitionsFieldProps - * @typedef {import ("~/client/storage").StorageDevice} StorageDevice - * @typedef {import ("~/client/storage").Volume} Volume - */ +import PartitionsField, { PartitionsFieldProps } from "~/components/storage/PartitionsField"; +import { ProposalTarget, StorageDevice, Volume, VolumeTarget } from "~/types/storage"; jest.mock("@patternfly/react-core", () => { const original = jest.requireActual("@patternfly/react-core"); @@ -41,10 +36,9 @@ jest.mock("@patternfly/react-core", () => { }; }); -/** @type {Volume} */ -const rootVolume = { +const rootVolume: Volume = { mountPath: "/", - target: "DEFAULT", + target: VolumeTarget.DEFAULT, fsType: "Btrfs", minSize: 1024, maxSize: 2048, @@ -63,10 +57,9 @@ const rootVolume = { }, }; -/** @type {Volume} */ -const swapVolume = { +const swapVolume: Volume = { mountPath: "swap", - target: "DEFAULT", + target: VolumeTarget.DEFAULT, fsType: "Swap", minSize: 1024, maxSize: 1024, @@ -85,10 +78,9 @@ const swapVolume = { }, }; -/** @type {Volume} */ -const homeVolume = { +const homeVolume: Volume = { mountPath: "/home", - target: "DEFAULT", + target: VolumeTarget.DEFAULT, fsType: "XFS", minSize: 1024, autoSize: false, @@ -106,10 +98,9 @@ const homeVolume = { }, }; -/** @type {Volume} */ -const arbitraryVolume = { +const arbitraryVolume: Volume = { mountPath: "", - target: "DEFAULT", + target: VolumeTarget.DEFAULT, fsType: "XFS", minSize: 1024, maxSize: 4096, @@ -128,8 +119,7 @@ const arbitraryVolume = { }, }; -/** @type {StorageDevice} */ -const sda = { +const sda: StorageDevice = { sid: 59, name: "/dev/sda", description: "", @@ -141,8 +131,7 @@ const sda = { size: 1024, }; -/** @type {StorageDevice} */ -const sda1 = { +const sda1: StorageDevice = { sid: 69, name: "/dev/sda1", description: "", @@ -155,8 +144,7 @@ const sda1 = { }, }; -/** @type {StorageDevice} */ -const sda2 = { +const sda2: StorageDevice = { sid: 79, name: "/dev/sda2", description: "", @@ -169,8 +157,7 @@ const sda2 = { }, }; -/** @type {PartitionsFieldProps} */ -let props; +let props: PartitionsFieldProps; const expandField = async () => { const render = plainRender(); @@ -185,7 +172,7 @@ beforeEach(() => { templates: [], availableDevices: [], volumeDevices: [sda], - target: "DISK", + target: ProposalTarget.DISK, targetDevices: [], configureBoot: false, bootDevice: undefined, @@ -195,7 +182,7 @@ beforeEach(() => { }; }); -it.skip("allows to reset the file systems", async () => { +it("allows to reset the file systems", async () => { const { user } = await expandField(); const button = screen.getByRole("button", { name: "Reset to defaults" }); await user.click(button); @@ -372,7 +359,7 @@ describe.skip("if there are volumes", () => { describe("and a volume has a non default location", () => { beforeEach(() => { - props.volumes = [{ ...homeVolume, target: "NEW_PARTITION", targetDevice: sda }]; + props.volumes = [{ ...homeVolume, target: VolumeTarget.NEW_PARTITION, targetDevice: sda }]; }); it("allows resetting the volume location", async () => { @@ -439,8 +426,8 @@ describe.skip("if there are volumes", () => { beforeEach(() => { props.volumes = [ rootVolume, - { ...swapVolume, target: "NEW_PARTITION", targetDevice: sda }, - { ...homeVolume, target: "NEW_VG", targetDevice: sda }, + { ...swapVolume, target: VolumeTarget.NEW_PARTITION, targetDevice: sda }, + { ...homeVolume, target: VolumeTarget.NEW_VG, targetDevice: sda }, ]; }); @@ -460,8 +447,8 @@ describe.skip("if there are volumes", () => { beforeEach(() => { props.volumes = [ rootVolume, - { ...swapVolume, target: "FILESYSTEM", targetDevice: sda1 }, - { ...homeVolume, target: "DEVICE", targetDevice: sda2 }, + { ...swapVolume, target: VolumeTarget.FILESYSTEM, targetDevice: sda1 }, + { ...homeVolume, target: VolumeTarget.DEVICE, targetDevice: sda2 }, ]; }); diff --git a/web/src/components/storage/PartitionsField.jsx b/web/src/components/storage/PartitionsField.tsx similarity index 69% rename from web/src/components/storage/PartitionsField.jsx rename to web/src/components/storage/PartitionsField.tsx index 88faf9d78c..72dab5e909 100644 --- a/web/src/components/storage/PartitionsField.jsx +++ b/web/src/components/storage/PartitionsField.tsx @@ -19,8 +19,6 @@ * find current contact information at www.suse.com. */ -// @ts-check - import React, { useState } from "react"; import { Button, @@ -51,25 +49,16 @@ import { reuseDevice, } from "~/components/storage/utils"; import BootConfigField from "~/components/storage/BootConfigField"; -import SnapshotsField from "~/components/storage/SnapshotsField"; -import VolumeDialog from "~/components/storage/VolumeDialog"; +import SnapshotsField, { SnapshotsConfig } from "~/components/storage/SnapshotsField"; +import VolumeDialog from "./VolumeDialog"; import VolumeLocationDialog from "~/components/storage/VolumeLocationDialog"; - -/** - * @typedef {import ("~/client/storage").ProposalTarget} ProposalTarget - * @typedef {import ("~/client/storage").StorageDevice} StorageDevice - * @typedef {import("~/components/storage/SnapshotsField").SnapshotsConfig} SnapshotsConfig - * @typedef {import ("~/client/storage").Volume} Volume - */ +import { ProposalTarget, StorageDevice, Volume, VolumeTarget } from "~/types/storage"; /** * @component - * - * @param {object} props - * @param {Volume} props.volume */ -const SizeText = ({ volume }) => { - let targetSize; +const SizeText = ({ volume }: { volume: Volume; }) => { + let targetSize: number; if (reuseDevice(volume)) targetSize = volume.targetDevice.size; const minSize = deviceSize(targetSize || volume.minSize); @@ -86,99 +75,89 @@ const SizeText = ({ volume }) => { return `${minSize}`; }; -/** - * @component - * - * @param {object} props - * @param {Volume} props.volume - * @param {ProposalTarget} props.target - */ -const BasicVolumeText = ({ volume, target }) => { +const BasicVolumeText = ({ volume, target }: { volume: Volume, target: ProposalTarget }) => { const snapshots = hasSnapshots(volume); const transactional = isTransactionalRoot(volume); const size = SizeText({ volume }); - const lvm = target === "NEW_LVM_VG"; + const lvm = target === ProposalTarget.NEW_LVM_VG; // When target is "filesystem" or "device" this is irrelevant since the type of device // is not mentioned - const lv = volume.target === "NEW_VG" || (volume.target === "DEFAULT" && lvm); + const lv = volume.target === VolumeTarget.NEW_VG || (volume.target === VolumeTarget.DEFAULT && lvm); if (transactional) return lv ? // TRANSLATORS: "/" is in an LVM logical volume. %s replaced by size string, e.g. "17.5 GiB" - sprintf(_("Transactional Btrfs root volume (%s)"), size) + sprintf(_("Transactional Btrfs root volume (%s)"), size) : // TRANSLATORS: %s replaced by size string, e.g. "17.5 GiB" - sprintf(_("Transactional Btrfs root partition (%s)"), size); + sprintf(_("Transactional Btrfs root partition (%s)"), size); if (snapshots) return lv ? // TRANSLATORS: "/" is in an LVM logical volume. %s replaced by size string, e.g. "17.5 GiB" - sprintf(_("Btrfs root volume with snapshots (%s)"), size) + sprintf(_("Btrfs root volume with snapshots (%s)"), size) : // TRANSLATORS: %s replaced by size string, e.g. "17.5 GiB" - sprintf(_("Btrfs root partition with snapshots (%s)"), size); + sprintf(_("Btrfs root partition with snapshots (%s)"), size); const volTarget = volume.target; const mount = volume.mountPath; const device = volume.targetDevice?.name; - if (volTarget === "FILESYSTEM") + if (volTarget === VolumeTarget.FILESYSTEM) // TRANSLATORS: This results in something like "Mount /dev/sda3 at /home (25 GiB)" since // %1$s is replaced by the device name, %2$s by the mount point and %3$s by the size return sprintf(_("Mount %1$s at %2$s (%3$s)"), device, mount, size); if (mount === "swap") { - if (volTarget === "DEVICE") + if (volTarget === VolumeTarget.DEVICE) // TRANSLATORS: This results in something like "Swap at /dev/sda3 (2 GiB)" since // %1$s is replaced by the device name, and %2$s by the size return sprintf(_("Swap at %1$s (%2$s)"), device, size); return lv ? // TRANSLATORS: Swap is in an LVM logical volume. %s replaced by size string, e.g. "8 GiB" - sprintf(_("Swap volume (%s)"), size) + sprintf(_("Swap volume (%s)"), size) : // TRANSLATORS: %s replaced by size string, e.g. "8 GiB" - sprintf(_("Swap partition (%s)"), size); + sprintf(_("Swap partition (%s)"), size); } const type = volume.fsType; if (mount === "/") { - if (volTarget === "DEVICE") + if (volTarget === VolumeTarget.DEVICE) // TRANSLATORS: This results in something like "Btrfs root at /dev/sda3 (20 GiB)" since // %1$s is replaced by the filesystem type, %2$s by the device name, and %3$s by the size return sprintf(_("%1$s root at %2$s (%3$s)"), type, device, size); return lv ? // TRANSLATORS: "/" is in an LVM logical volume. - // Results in something like "Btrfs root volume (at least 20 GiB)" since - // $1$s is replaced by filesystem type and %2$s by size description - sprintf(_("%1$s root volume (%2$s)"), type, size) + // Results in something like "Btrfs root volume (at least 20 GiB)" since + // $1$s is replaced by filesystem type and %2$s by size description + sprintf(_("%1$s root volume (%2$s)"), type, size) : // TRANSLATORS: Results in something like "Btrfs root partition (at least 20 GiB)" since - // $1$s is replaced by filesystem type and %2$s by size description - sprintf(_("%1$s root partition (%2$s)"), type, size); + // $1$s is replaced by filesystem type and %2$s by size description + sprintf(_("%1$s root partition (%2$s)"), type, size); } - if (volTarget === "DEVICE") + if (volTarget === VolumeTarget.DEVICE) // TRANSLATORS: This results in something like "Ext4 /home at /dev/sda3 (20 GiB)" since // %1$s is replaced by filesystem type, %2$s by mount point, %3$s by device name and %4$s by size return sprintf(_("%1$s %2$s at %3$s (%4$s)"), type, mount, device, size); return lv ? // TRANSLATORS: The filesystem is in an LVM logical volume. - // Results in something like "Ext4 /home volume (at least 10 GiB)" since - // %1$s is replaced by the filesystem type, %2$s by the mount point and %3$s by the size description - sprintf(_("%1$s %2$s volume (%3$s)"), type, mount, size) + // Results in something like "Ext4 /home volume (at least 10 GiB)" since + // %1$s is replaced by the filesystem type, %2$s by the mount point and %3$s by the size description + sprintf(_("%1$s %2$s volume (%3$s)"), type, mount, size) : // TRANSLATORS: This results in something like "Ext4 /home partition (at least 10 GiB)" since - // %1$s is replaced by the filesystem type, %2$s by the mount point and %3$s by the size description - sprintf(_("%1$s %2$s partition (%3$s)"), type, mount, size); + // %1$s is replaced by the filesystem type, %2$s by the mount point and %3$s by the size description + sprintf(_("%1$s %2$s partition (%3$s)"), type, mount, size); }; /** + * Generates a text explaining the system boot configuration. * @component - * - * @param {object} props - * @param {boolean} props.configure - * @param {StorageDevice} props.device */ -const BootLabelText = ({ configure, device }) => { +const BootLabelText = ({ configure, device }: { configure: boolean, device: StorageDevice }) => { if (!configure) return _("Do not configure partitions for booting"); if (!device) return _("Boot partitions at installation disk"); @@ -191,11 +170,8 @@ const BootLabelText = ({ configure, device }) => { * Generates an hint describing which attributes affect the auto-calculated limits. * If the limits are not affected then it returns `null`. * @component - * - * @param {object} props - * @param {Volume} props.volume */ -const AutoCalculatedHint = ({ volume }) => { +const AutoCalculatedHint = ({ volume }: { volume: Volume }) => { const { snapshotsAffectSizes = false, sizeRelevantVolumes = [], adjustByRam } = volume.outline; // no hint, the size is not affected by known criteria @@ -231,12 +207,8 @@ const AutoCalculatedHint = ({ volume }) => { /** * @component - * - * @param {object} props - * @param {Volume} props.volume - * @param {ProposalTarget} props.target */ -const VolumeLabel = ({ volume, target }) => { +const VolumeLabel = ({ volume, target }: { volume: Volume, target: ProposalTarget }) => { return ( { ); }; -/** - * @component - * - * @param {object} props - * @param {StorageDevice|undefined} props.bootDevice - * @param {boolean} props.configureBoot - */ -const BootLabel = ({ bootDevice, configureBoot }) => { +const BootLabel = ({ + bootDevice, + configureBoot +}: { + bootDevice: StorageDevice | undefined, + configureBoot: boolean +}) => { return ( { // TODO: Extract VolumesTable or at least VolumeRow and all related internal // components to a new file. -/** - * @component - * @param {object} props - * @param {Volume} props.volume - */ -const VolumeSizeLimits = ({ volume }) => { +const VolumeSizeLimits = ({ volume }: { volume: Volume }) => { const isAuto = volume.autoSize; return ( @@ -295,16 +261,11 @@ const VolumeSizeLimits = ({ volume }) => { ); }; -/** - * @component - * @param {object} props - * @param {Volume} props.volume - */ -const VolumeDetails = ({ volume }) => { +const VolumeDetails = ({ volume }: { volume: Volume }) => { const snapshots = hasSnapshots(volume); const transactional = isTransactionalRoot(volume); - if (volume.target === "FILESYSTEM") + if (volume.target === VolumeTarget.FILESYSTEM) // TRANSLATORS: %s will be replaced by a file-system type like "Btrfs" or "Ext4" return sprintf(_("Reused %s"), volume.targetDevice?.filesystem?.type || ""); if (transactional) return _("Transactional Btrfs"); @@ -313,39 +274,37 @@ const VolumeDetails = ({ volume }) => { return volume.fsType; }; -/** - * @component - * @param {object} props - * @param {Volume} props.volume - * @param {ProposalTarget} props.target - */ -const VolumeLocation = ({ volume, target }) => { - if (volume.target === "NEW_PARTITION") +type VolumeLocationProps = { + volume: Volume; + target: ProposalTarget; +} + +const VolumeLocation = ({ volume, target }: VolumeLocationProps) => { + if (volume.target === VolumeTarget.NEW_PARTITION) // TRANSLATORS: %s will be replaced by a disk name (eg. "/dev/sda") return sprintf(_("Partition at %s"), volume.targetDevice?.name || ""); - if (volume.target === "NEW_VG") + if (volume.target === VolumeTarget.NEW_VG) // TRANSLATORS: %s will be replaced by a disk name (eg. "/dev/sda") return sprintf(_("Separate LVM at %s"), volume.targetDevice?.name || ""); - if (volume.target === "DEVICE" || volume.target === "FILESYSTEM") + if (volume.target === VolumeTarget.DEVICE || volume.target === VolumeTarget.FILESYSTEM) return volume.targetDevice?.name || ""; - if (target === "NEW_LVM_VG") return _("Logical volume at system LVM"); + if (target === ProposalTarget.NEW_LVM_VG) return _("Logical volume at system LVM"); return _("Partition at installation disk"); }; -/** - * @component - * @param {object} props - * @param {Volume} props.volume - * @param {() => void} props.onEdit - * @param {() => void} props.onResetLocation - * @param {() => void} props.onLocation - * @param {() => void} props.onDelete - */ -const VolumeActions = ({ volume, onEdit, onResetLocation, onLocation, onDelete }) => { +type VolumeActionsProps = { + volume: Volume; + onEdit: () => void; + onResetLocation: () => void; + onLocation: () => void; + onDelete: () => void; +} + +const VolumeActions = ({ volume, onEdit, onResetLocation, onLocation, onDelete }: VolumeActionsProps) => { const actions = [ - { title: _("Edit"), onClick: onEdit }, - volume.target !== "DEFAULT" && { title: _("Reset location"), onClick: onResetLocation }, + { title: _("Edit"), onClick: onEdit, }, + volume.target !== "default" && { title: _("Reset location"), onClick: onResetLocation }, { title: _("Change location"), onClick: onLocation }, !volume.outline.required && { title: _("Delete"), onClick: onDelete, isDanger: true }, ]; @@ -353,21 +312,22 @@ const VolumeActions = ({ volume, onEdit, onResetLocation, onLocation, onDelete } return ; }; +type VolumeRowProps = { + columns?: any; + volume?: Volume; + volumes?: Volume[]; + templates?: Volume[]; + volumeDevices?: StorageDevice[]; + target?: ProposalTarget; + targetDevices?: StorageDevice[]; + isLoading: boolean; + onEdit?: (volume: Volume) => void; + onDelete?: () => void; +} + /** * Renders a table row with the information and actions for a volume * @component - * - * @param {object} props - * @param {object} [props.columns] - Column specs - * @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.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 {() => void} [props.onDelete=noop] - Function to use for deleting the volume */ const VolumeRow = ({ columns, @@ -380,9 +340,8 @@ const VolumeRow = ({ isLoading, onEdit = noop, onDelete = noop, -}) => { - /** @type {[string, (dialog: string) => void]} */ - const [dialog, setDialog] = useState(); +}: VolumeRowProps) => { + const [dialog, setDialog] = useState(); const openEditDialog = () => setDialog("edit"); @@ -391,10 +350,10 @@ const VolumeRow = ({ const closeDialog = () => setDialog(undefined); const onResetLocationClick = () => { - onEdit({ ...volume, target: "DEFAULT", targetDevice: undefined }); + onEdit({ ...volume, target: VolumeTarget.DEFAULT, targetDevice: undefined }); }; - const acceptForm = (volume) => { + const acceptForm = (volume: Volume) => { closeDialog(); onEdit(volume); }; @@ -460,18 +419,19 @@ const VolumeRow = ({ ); }; +type VolumesTableProps = { + volumes: Volume[]; + templates: Volume[]; + volumeDevices: StorageDevice[]; + target: ProposalTarget; + targetDevices: StorageDevice[]; + isLoading: boolean; + onVolumesChange: (volumes: Volume[]) => void; +} + /** * Renders a table with the information and actions of the volumes * @component - * - * @param {object} props - * @param {Volume[]} props.volumes - Volumes to show - * @param {Volume[]} props.templates - List of available templates - * @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, @@ -481,7 +441,7 @@ const VolumesTable = ({ targetDevices, isLoading, onVolumesChange, -}) => { +}: VolumesTableProps) => { const columns = { mountPath: _("Mount point"), details: _("Details"), @@ -491,22 +451,19 @@ const VolumesTable = ({ actions: _("Actions"), }; - /** @type {(volume: Volume) => void} */ - const editVolume = (volume) => { + const editVolume = (volume: Volume) => { const index = volumes.findIndex((v) => v.mountPath === volume.mountPath); const newVolumes = [...volumes]; newVolumes[index] = volume; onVolumesChange(newVolumes); }; - /** @type {(volume: Volume) => void} */ - const deleteVolume = (volume) => { + const deleteVolume = (volume: Volume) => { const newVolumes = volumes.filter((v) => v.mountPath !== volume.mountPath); onVolumesChange(newVolumes); }; - /** @type {() => React.ReactElement[]|React.ReactElement} */ - const renderVolumes = () => { + const renderVolumes: () => React.ReactElement[] | React.ReactElement = () => { if (volumes.length === 0 && isLoading) return ; return volumes.map((volume, index) => { @@ -547,15 +504,13 @@ const VolumesTable = ({ /** * Content to show when the field is collapsed. * @component - * - * @param {object} props - * @param {Volume[]} props.volumes - * @param {boolean} props.configureBoot - * @param {StorageDevice|undefined} props.bootDevice - * @param {ProposalTarget} props.target - * @param {boolean} props.isLoading */ -const Basic = ({ volumes, configureBoot, bootDevice, target, isLoading }) => { +const Basic = ( + { + volumes, configureBoot, bootDevice, target, isLoading + }: { + volumes: Volume[]; configureBoot: boolean; bootDevice: StorageDevice | undefined; target: ProposalTarget; isLoading: boolean; + }) => { if (isLoading) return ( @@ -580,19 +535,17 @@ const Basic = ({ volumes, configureBoot, bootDevice, target, isLoading }) => { * of options. * @component * - * @param {object} props - * @param {string[]} props.options - Possible mount points to add. An empty string represent an + * @param props + * @param props.options - Possible mount points to add. An empty string represent an * arbitrary mount point. - * @param {(option: string) => void} props.onClick + * @param props.onClick */ -const AddVolumeButton = ({ options, onClick }) => { +const AddVolumeButton = ({ options, onClick }: { options: string[]; onClick: (option: string) => void; }) => { const [isOpen, setIsOpen] = React.useState(false); - /** @type {() => void} */ - const onToggleClick = () => setIsOpen(!isOpen); + const onToggleClick: () => void = () => setIsOpen(!isOpen); - /** @type {(_: any, value: string) => void} */ - const onSelect = (_, value) => { + const onSelect: (_: any, value: string) => void = (_, value): void => { setIsOpen(false); onClick(value); }; @@ -650,23 +603,25 @@ const AddVolumeButton = ({ options, onClick }) => { ); }; +type AdvancedProps = { + volumes: Volume[]; + templates: Volume[]; + availableDevices: StorageDevice[]; + volumeDevices: StorageDevice[]; + target: ProposalTarget; + targetDevices: StorageDevice[]; + configureBoot: boolean; + bootDevice: StorageDevice | undefined; + defaultBootDevice: StorageDevice | undefined; + onVolumesChange: (volumes: Volume[]) => void; + onBootChange: (boot: BootConfig) => void; + isLoading: boolean; +}; + /** * Content to show when the field is expanded. * @component * - * @param {object} props - * @param {Volume[]} props.volumes - * @param {Volume[]} props.templates - * @param {StorageDevice[]} props.availableDevices - * @param {StorageDevice[]} props.volumeDevices - * @param {ProposalTarget} props.target - * @param {StorageDevice[]} props.targetDevices - * @param {boolean} props.configureBoot - * @param {StorageDevice|undefined} props.bootDevice - * @param {StorageDevice|undefined} props.defaultBootDevice - * @param {(volumes: Volume[]) => void} props.onVolumesChange - * @param {(boot: BootConfig) => void} props.onBootChange - * @param {boolean} props.isLoading */ const Advanced = ({ volumes, @@ -681,17 +636,15 @@ const Advanced = ({ onVolumesChange, onBootChange, isLoading, -}) => { +}: AdvancedProps) => { const [isVolumeDialogOpen, setIsVolumeDialogOpen] = useState(false); - /** @type {[Volume|undefined, (volume: Volume) => void]} */ - const [template, setTemplate] = useState(); + const [template, setTemplate] = useState(); const openVolumeDialog = () => setIsVolumeDialogOpen(true); const closeVolumeDialog = () => setIsVolumeDialogOpen(false); - /** @type {(volume: Volume) => void} */ - const onAcceptVolumeDialog = (volume) => { + const onAcceptVolumeDialog: (volume: Volume) => void = (volume) => { closeVolumeDialog(); const index = volumes.findIndex((v) => v.mountPath === volume.mountPath); @@ -707,8 +660,7 @@ const Advanced = ({ const resetVolumes = () => onVolumesChange([]); - /** @type {(mountPath: string) => void} */ - const addVolume = (mountPath) => { + const addVolume: (mountPath: string) => void = (mountPath) => { const template = templates.find((t) => t.mountPath === mountPath); setTemplate(template); openVolumeDialog(); @@ -716,9 +668,8 @@ const Advanced = ({ /** * Possible mount paths to add. - * @type {() => string[]} */ - const mountPathOptions = () => { + const mountPathOptions: () => string[] = () => { const mountPaths = volumes.map((v) => v.mountPath); const isTransactional = isTransactionalSystem(templates); @@ -730,9 +681,8 @@ const Advanced = ({ /** * Whether to show the button for adding a volume. - * @type {() => boolean} */ - const showAddVolume = () => { + const showAddVolume: () => boolean = () => { const hasOptionalVolumes = () => { return templates.find((t) => t.mountPath.length && !t.outline.required) !== undefined; }; @@ -740,11 +690,9 @@ const Advanced = ({ return !isTransactionalSystem(templates) || hasOptionalVolumes(); }; - /** @type {Volume} */ - const rootVolume = volumes.find((v) => v.mountPath === "/"); + const rootVolume = volumes.find((v: Volume) => v.mountPath === "/"); - /** @type {(config: SnapshotsConfig) => void} */ - const changeBtrfsSnapshots = ({ active }) => { + const changeBtrfsSnapshots: (config: SnapshotsConfig) => void = ({ active }) => { if (active) { rootVolume.fsType = "Btrfs"; rootVolume.snapshots = true; @@ -800,32 +748,32 @@ const Advanced = ({ ); }; +export type PartitionsFieldProps = { + volumes: Volume[]; + templates: Volume[]; + availableDevices: StorageDevice[]; + volumeDevices: StorageDevice[]; + target: ProposalTarget; + targetDevices: StorageDevice[]; + configureBoot: boolean; + bootDevice: StorageDevice | undefined; + defaultBootDevice: StorageDevice | undefined; + isLoading?: boolean; + onVolumesChange: (volumes: Volume[]) => void; + onBootChange: (boot: BootConfig) => void; +} + +type BootConfig = { + configureBoot: boolean; + bootDevice: StorageDevice | undefined; +} + /** * @todo This component should be restructured to use the same approach as other newer components: * * Use a TreeTable, specially if we need to represent subvolumes. * * Renders information of the volumes and boot-related partitions and actions to modify them. * @component - * - * @typedef {object} PartitionsFieldProps - * @property {Volume[]} volumes - Volumes to show - * @property {Volume[]} templates - Templates to use for new volumes - * @property {StorageDevice[]} availableDevices - Devices available for installation - * @property {StorageDevice[]} volumeDevices - Devices that can be selected as target for a volume - * @property {ProposalTarget} target - Installation target - * @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. - * @property {boolean} [isLoading=false] - Whether to show the content as loading - * @property {(volumes: Volume[]) => void} onVolumesChange - Function to use for changing the volumes - * @property {(boot: BootConfig) => void} onBootChange - Function for changing the boot settings - * - * @typedef {object} BootConfig - * @property {boolean} configureBoot - * @property {StorageDevice|undefined} bootDevice - * - * @param {PartitionsFieldProps} props */ export default function PartitionsField({ volumes, @@ -840,7 +788,7 @@ export default function PartitionsField({ isLoading = false, onVolumesChange, onBootChange, -}) { +}: PartitionsFieldProps) { const [isExpanded, setIsExpanded] = useState(false); const onExpand = () => setIsExpanded(!isExpanded); From a9603cd3b6f16f38d0ac36568578f5762214e73a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Thu, 5 Sep 2024 09:19:15 +0100 Subject: [PATCH 16/53] fix(web): adapt call to useVolumeTemplates --- web/src/components/storage/ProposalPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/components/storage/ProposalPage.tsx b/web/src/components/storage/ProposalPage.tsx index 712724a774..1d9e6e4aee 100644 --- a/web/src/components/storage/ProposalPage.tsx +++ b/web/src/components/storage/ProposalPage.tsx @@ -66,7 +66,7 @@ export default function ProposalPage() { const stagingDevices = useDevices("result"); const availableDevices = useAvailableDevices(); const volumeDevices = useVolumeDevices(); - const volumeTemplates = useVolumeTemplates({ suspense: true }); + const volumeTemplates = useVolumeTemplates(); const { encryptionMethods } = useProductParams({ suspense: true }); const { actions, settings } = useProposalResult(); const updateProposal = useProposalMutation(); From 8358a9423c728392f61b1dde22751153fccee189 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Thu, 5 Sep 2024 09:28:57 +0100 Subject: [PATCH 17/53] refactor(web): convert ProposalSettingsSection to TypeScript --- ...t.jsx => ProposalSettingsSection.test.tsx} | 20 ++---- ...ection.jsx => ProposalSettingsSection.tsx} | 72 ++++++++----------- 2 files changed, 35 insertions(+), 57 deletions(-) rename web/src/components/storage/{ProposalSettingsSection.test.jsx => ProposalSettingsSection.test.tsx} (90%) rename web/src/components/storage/{ProposalSettingsSection.jsx => ProposalSettingsSection.tsx} (65%) diff --git a/web/src/components/storage/ProposalSettingsSection.test.jsx b/web/src/components/storage/ProposalSettingsSection.test.tsx similarity index 90% rename from web/src/components/storage/ProposalSettingsSection.test.jsx rename to web/src/components/storage/ProposalSettingsSection.test.tsx index 44d19ffc13..c262326797 100644 --- a/web/src/components/storage/ProposalSettingsSection.test.jsx +++ b/web/src/components/storage/ProposalSettingsSection.test.tsx @@ -19,17 +19,12 @@ * find current contact information at www.suse.com. */ -// @ts-check - import React from "react"; import { screen } from "@testing-library/react"; import { installerRender } from "~/test-utils"; import { ProposalSettingsSection } from "~/components/storage"; - -/** - * @typedef {import ("~/components/storage/ProposalSettingsSection").ProposalSettingsSectionProps} ProposalSettingsSectionProps - * @typedef {import ("~/client/storage").StorageDevice} StorageDevice - */ +import { ProposalTarget, StorageDevice } from "~/types/storage"; +import { ProposalSettingsSectionProps } from "./ProposalSettingsSection"; jest.mock("@patternfly/react-core", () => { const original = jest.requireActual("@patternfly/react-core"); @@ -40,8 +35,7 @@ jest.mock("@patternfly/react-core", () => { }; }); -/** @type {StorageDevice} */ -const sda = { +const sda: StorageDevice = { sid: 59, isDrive: true, type: "disk", @@ -63,8 +57,7 @@ const sda = { udevPaths: ["pci-0000:00-12", "pci-0000:00-12-ata"], }; -/** @type {StorageDevice} */ -const sdb = { +const sdb: StorageDevice = { sid: 62, isDrive: true, type: "disk", @@ -86,13 +79,12 @@ const sdb = { udevPaths: ["pci-0000:00-19"], }; -/** @type {ProposalSettingsSectionProps} */ -let props; +let props: ProposalSettingsSectionProps; beforeEach(() => { props = { settings: { - target: "DISK", + target: ProposalTarget.DISK, targetDevice: "/dev/sda", targetPVDevices: [], configureBoot: false, diff --git a/web/src/components/storage/ProposalSettingsSection.jsx b/web/src/components/storage/ProposalSettingsSection.tsx similarity index 65% rename from web/src/components/storage/ProposalSettingsSection.jsx rename to web/src/components/storage/ProposalSettingsSection.tsx index d35f6a5f16..a6d2525b8a 100644 --- a/web/src/components/storage/ProposalSettingsSection.jsx +++ b/web/src/components/storage/ProposalSettingsSection.tsx @@ -19,52 +19,44 @@ * find current contact information at www.suse.com. */ -// @ts-check - import React from "react"; import { Grid, GridItem } from "@patternfly/react-core"; -import EncryptionField from "~/components/storage/EncryptionField"; +import EncryptionField, { EncryptionConfig } from "~/components/storage/EncryptionField"; import InstallationDeviceField from "~/components/storage/InstallationDeviceField"; import PartitionsField from "~/components/storage/PartitionsField"; +import { TargetConfig } from "~/components/storage/InstallationDeviceField"; +import { BootConfig } from "~/components/storage/BootConfigField"; +import { CHANGING, NOT_AFFECTED } from "~/components/storage/ProposalPage"; +import { ProposalSettings, StorageDevice, Volume } from "~/types/storage"; import { _ } from "~/i18n"; import { compact } from "~/utils"; -import { CHANGING, NOT_AFFECTED } from "~/components/storage/ProposalPage"; - -/** - * @typedef {import ("~/client/storage").ProposalSettings} ProposalSettings - * @typedef {import ("~/client/storage").ProposalTarget} ProposalTarget - * @typedef {import ("~/client/storage").SpaceAction} SpaceAction - * @typedef {import ("~/client/storage").StorageDevice} StorageDevice - * @typedef {import ("~/client/storage").Volume} Volume - */ /** * A helper function to decide whether to show the progress skeletons or not * for the specified component - * @param {boolean} loading loading status - * @param {string} component name of the component - * @param {symbol} changing the item which is being changed + * @param loading - loading status + * @param component - name of the component + * @param changing - the item which is being changed * @returns {boolean} true if the skeleton should be displayed, false otherwise */ -const showSkeleton = (loading, component, changing) => { +const showSkeleton = (loading: boolean, component: string, changing: symbol): boolean => { return loading && !NOT_AFFECTED[component].includes(changing); }; +export type ProposalSettingsSectionProps = { + settings: ProposalSettings; + availableDevices: StorageDevice[]; + volumeDevices: StorageDevice[]; + encryptionMethods: string[]; + volumeTemplates: Volume[]; + isLoading?: boolean; + changing?: symbol; + onChange: (changing: symbol, settings: object) => void; +} + /** * Section for editing the proposal settings * @component - * - * @typedef {object} ProposalSettingsSectionProps - * @property {ProposalSettings} settings - * @property {StorageDevice[]} availableDevices - * @property {StorageDevice[]} volumeDevices - * @property {String[]} encryptionMethods - * @property {Volume[]} volumeTemplates - * @property {boolean} [isLoading=false] - * @property {symbol} [changing=undefined] which part of the configuration is being changed by user - * @property {(changing: symbol, settings: object) => void} onChange - * - * @param {ProposalSettingsSectionProps} props */ export default function ProposalSettingsSection({ settings, @@ -75,9 +67,8 @@ export default function ProposalSettingsSection({ isLoading = false, changing = undefined, onChange, -}) { - /** @param {import("~/components/storage/InstallationDeviceField").TargetConfig} targetConfig */ - const changeTarget = ({ target, targetDevice, targetPVDevices }) => { +}: ProposalSettingsSectionProps) { + const changeTarget = ({ target, targetDevice, targetPVDevices }: TargetConfig) => { onChange(CHANGING.TARGET, { target, targetDevice: targetDevice?.name, @@ -85,18 +76,15 @@ export default function ProposalSettingsSection({ }); }; - /** @param {import("~/components/storage/EncryptionField").EncryptionConfig} encryptionConfig */ - const changeEncryption = ({ password, method }) => { + const changeEncryption = ({ password, method }: EncryptionConfig) => { onChange(CHANGING.ENCRYPTION, { encryptionPassword: password, encryptionMethod: method }); }; - /** @param {Volume[]} volumes */ - const changeVolumes = (volumes) => { + const changeVolumes = (volumes: Volume[]) => { onChange(CHANGING.VOLUMES, { volumes }); }; - /** @param {import("~/components/storage/PartitionsField").BootConfig} bootConfig */ - const changeBoot = ({ configureBoot, bootDevice }) => { + const changeBoot = ({ configureBoot, bootDevice }: BootConfig) => { onChange(CHANGING.BOOT, { configureBoot, bootDevice: bootDevice?.name, @@ -107,13 +95,11 @@ export default function ProposalSettingsSection({ * @param {string} name * @returns {StorageDevice|undefined} */ - const findDevice = (name) => availableDevices.find((a) => a.name === name); + const findDevice = (name: string): StorageDevice | undefined => availableDevices.find((a) => a.name === name); - /** @type {StorageDevice|undefined} */ - const targetDevice = findDevice(settings.targetDevice); - /** @type {StorageDevice[]} */ - const targetPVDevices = compact(settings.targetPVDevices?.map(findDevice) || []); - const { volumes = [], installationDevices = [], spaceActions = [] } = settings; + const targetDevice: StorageDevice | undefined = findDevice(settings.targetDevice); + const targetPVDevices: StorageDevice[] = compact(settings.targetPVDevices?.map(findDevice) || []); + const { volumes = [] } = settings; const bootDevice = findDevice(settings.bootDevice); const defaultBootDevice = findDevice(settings.defaultBootDevice); const targetDevices = compact([targetDevice, ...targetPVDevices]); From 0d9626a99fc313bba0a4725b605a18918f3e672d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Thu, 5 Sep 2024 09:52:43 +0100 Subject: [PATCH 18/53] refactor(web): adapt ProposalSettingsSection tests --- .../storage/ProposalSettingsSection.test.tsx | 97 ++++--------------- 1 file changed, 18 insertions(+), 79 deletions(-) diff --git a/web/src/components/storage/ProposalSettingsSection.test.tsx b/web/src/components/storage/ProposalSettingsSection.test.tsx index c262326797..29fe286afc 100644 --- a/web/src/components/storage/ProposalSettingsSection.test.tsx +++ b/web/src/components/storage/ProposalSettingsSection.test.tsx @@ -20,65 +20,12 @@ */ import React from "react"; -import { screen } from "@testing-library/react"; -import { installerRender } from "~/test-utils"; +import { screen, within } from "@testing-library/react"; +import { plainRender } from "~/test-utils"; import { ProposalSettingsSection } from "~/components/storage"; -import { ProposalTarget, StorageDevice } from "~/types/storage"; +import { ProposalTarget } from "~/types/storage"; import { ProposalSettingsSectionProps } from "./ProposalSettingsSection"; -jest.mock("@patternfly/react-core", () => { - const original = jest.requireActual("@patternfly/react-core"); - - return { - ...original, - Skeleton: () =>
PFSkeleton
, - }; -}); - -const sda: StorageDevice = { - sid: 59, - isDrive: true, - type: "disk", - description: "", - vendor: "Micron", - model: "Micron 1100 SATA", - driver: ["ahci", "mmcblk"], - bus: "IDE", - busId: "", - transport: "usb", - dellBOSS: false, - sdCard: true, - active: true, - name: "/dev/sda", - size: 1024, - shrinking: { unsupported: ["Resizing is not supported"] }, - systems: [], - udevIds: ["ata-Micron_1100_SATA_512GB_12563", "scsi-0ATA_Micron_1100_SATA_512GB"], - udevPaths: ["pci-0000:00-12", "pci-0000:00-12-ata"], -}; - -const sdb: StorageDevice = { - sid: 62, - isDrive: true, - type: "disk", - description: "", - vendor: "Samsung", - model: "Samsung Evo 8 Pro", - driver: ["ahci"], - bus: "IDE", - busId: "", - transport: "", - dellBOSS: false, - sdCard: false, - active: true, - name: "/dev/sdb", - size: 2048, - shrinking: { unsupported: ["Resizing is not supported"] }, - systems: [], - udevIds: [], - udevPaths: ["pci-0000:00-19"], -}; - let props: ProposalSettingsSectionProps; beforeEach(() => { @@ -95,7 +42,7 @@ beforeEach(() => { spacePolicy: "delete", spaceActions: [], volumes: [], - installationDevices: [sda, sdb], + installationDevices: [], }, availableDevices: [], volumeDevices: [], @@ -105,31 +52,23 @@ beforeEach(() => { }; }); -it.skip("allows changing the selected device", async () => { - const { user } = installerRender(); - const button = screen.getByRole("button", { name: /installation device/i }); - - await user.click(button); - await screen.findByRole("dialog", { name: /Device for installing/ }); +it("allows changing the selected device", () => { + plainRender(); + const region = screen.getByRole("region", { name: "Installation device" }); + const link: HTMLAnchorElement = within(region).getByRole("link", { name: "Change" }); + expect(link.href).toMatch(/storage\/target-device/); }); -it.skip("allows changing the encryption settings", async () => { - const { user } = installerRender(); - const button = screen.getByRole("button", { name: /Encryption/ }); - +it("allows changing the encryption settings", async () => { + const { user } = plainRender(); + const region = screen.getByRole("region", { name: "Encryption" }); + const button = within(region).getByRole("button", { name: "Enable" }); await user.click(button); - await screen.findByRole("dialog", { name: /Encryption/ }); -}); - -it.skip("renders a section holding file systems related stuff", () => { - installerRender(); - screen.getByRole("button", { name: /Partitions and file systems/ }); + await screen.findByRole("dialog", { name: "Encryption" }); }); -it.skip("allows changing the space policy settings", async () => { - const { user } = installerRender(); - const button = screen.getByRole("button", { name: /Find space/ }); - - await user.click(button); - await screen.findByRole("dialog", { name: /Find space/ }); +it("renders a section holding file systems related stuff", () => { + plainRender(); + const region = screen.getByRole("region", { name: "Partitions and file systems" }); + expect(region).toBeInTheDocument(); }); From bc7c91e815d4e38014aa35d4c2cd7bdd2a72c2c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Thu, 5 Sep 2024 10:18:24 +0100 Subject: [PATCH 19/53] refactor(web): adapt SnapshotsField tests --- ...Field.test.jsx => SnapshotsField.test.tsx} | 47 +++++-------------- ...{SnapshotsField.jsx => SnapshotsField.tsx} | 31 +++++------- 2 files changed, 25 insertions(+), 53 deletions(-) rename web/src/components/storage/{SnapshotsField.test.jsx => SnapshotsField.test.tsx} (55%) rename web/src/components/storage/{SnapshotsField.jsx => SnapshotsField.tsx} (71%) diff --git a/web/src/components/storage/SnapshotsField.test.jsx b/web/src/components/storage/SnapshotsField.test.tsx similarity index 55% rename from web/src/components/storage/SnapshotsField.test.jsx rename to web/src/components/storage/SnapshotsField.test.tsx index 2f29b470c4..377a7bdd85 100644 --- a/web/src/components/storage/SnapshotsField.test.jsx +++ b/web/src/components/storage/SnapshotsField.test.tsx @@ -24,17 +24,12 @@ import React from "react"; import { screen } from "@testing-library/react"; import { plainRender } from "~/test-utils"; -import SnapshotsField from "~/components/storage/SnapshotsField"; +import SnapshotsField, { SnapshotsFieldProps } from "~/components/storage/SnapshotsField"; +import { Volume, VolumeTarget } from "~/types/storage"; -/** - * @typedef {import ("~/client/storage").Volume} Volume - * @typedef {import ("~/components/storage/SnapshotsField").SnapshotsFieldProps} SnapshotsFieldProps - */ - -/** @type {Volume} */ -const rootVolume = { +const rootVolume: Volume = { mountPath: "/", - target: "DEFAULT", + target: VolumeTarget.DEFAULT, fsType: "Btrfs", minSize: 1024, autoSize: true, @@ -54,39 +49,21 @@ const rootVolume = { const onChangeFn = jest.fn(); -/** @type {SnapshotsFieldProps} */ -let props; +let props: SnapshotsFieldProps; -describe.skip("SnapshotsField", () => { +describe("SnapshotsField", () => { it("reflects snapshots status", () => { - let button; - props = { rootVolume: { ...rootVolume, snapshots: true }, onChange: onChangeFn }; - const { rerender } = plainRender(); - button = screen.getByRole("switch"); - expect(button).toHaveAttribute("aria-checked", "true"); - - props = { rootVolume: { ...rootVolume, snapshots: false }, onChange: onChangeFn }; - rerender(); - button = screen.getByRole("switch"); - expect(button).toHaveAttribute("aria-checked", "false"); + plainRender(); + const checkbox: HTMLInputElement = screen.getByRole("checkbox"); + expect(checkbox.value).toEqual("on"); }); it("allows toggling snapshots status", async () => { - let button; - props = { rootVolume: { ...rootVolume, snapshots: true }, onChange: onChangeFn }; - const { user, rerender } = plainRender(); - button = screen.getByRole("switch"); - expect(button).toHaveAttribute("aria-checked", "true"); - await user.click(button); + const { user } = plainRender(); + const checkbox: HTMLInputElement = screen.getByRole("checkbox"); + await user.click(checkbox); expect(onChangeFn).toHaveBeenCalledWith({ active: false }); - - props = { rootVolume: { ...rootVolume, snapshots: false }, onChange: onChangeFn }; - rerender(); - button = screen.getByRole("switch"); - expect(button).toHaveAttribute("aria-checked", "false"); - await user.click(button); - expect(onChangeFn).toHaveBeenCalledWith({ active: true }); }); }); diff --git a/web/src/components/storage/SnapshotsField.jsx b/web/src/components/storage/SnapshotsField.tsx similarity index 71% rename from web/src/components/storage/SnapshotsField.jsx rename to web/src/components/storage/SnapshotsField.tsx index 5aecc518e3..8e6883f7ed 100644 --- a/web/src/components/storage/SnapshotsField.jsx +++ b/web/src/components/storage/SnapshotsField.tsx @@ -24,14 +24,9 @@ import React from "react"; import { Split, Switch } from "@patternfly/react-core"; import { _ } from "~/i18n"; -import { noop } from "~/utils"; import { hasFS } from "~/components/storage/utils"; import textStyles from "@patternfly/react-styles/css/utilities/Text/text"; - -/** - * @typedef {import ("~/client/storage").ProposalSettings} ProposalSettings - * @typedef {import ("~/client/storage").Volume} Volume - */ +import { Volume } from "~/types/storage"; const LABEL = _("Use Btrfs snapshots for the root file system"); const DESCRIPTION = _( @@ -39,29 +34,29 @@ const DESCRIPTION = _( system after configuration changes or software upgrades.", ); +export type SnapshotsFieldProps = { + rootVolume: Volume; + onChange?: (config: SnapshotsConfig) => void; +} + +export type SnapshotsConfig = { + active: boolean; +} + /** * Allows to define snapshots enablement * @component - * - * @typedef {object} SnapshotsFieldProps - * @property {Volume} rootVolume - * @property {(config: SnapshotsConfig) => void} onChange - * - * @typedef {object} SnapshotsConfig - * @property {boolean} active - * - * @param {SnapshotsFieldProps} props */ -export default function SnapshotsField({ rootVolume, onChange = noop }) { +export default function SnapshotsField({ rootVolume, onChange }: SnapshotsFieldProps) { const isChecked = hasFS(rootVolume, "Btrfs") && rootVolume.snapshots; const switchState = () => { - onChange({ active: !isChecked }); + if (onChange) onChange({ active: !isChecked }); }; return ( - +
{LABEL}
{DESCRIPTION}
From 0dd5e03549c5dc826cde50b65d62ec1df51c7f7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Thu, 5 Sep 2024 10:26:36 +0100 Subject: [PATCH 20/53] refactor(web): convert VolumeDialog to TypeScript --- ...eDialog.test.jsx => VolumeDialog.test.tsx} | 31 +- .../{VolumeDialog.jsx => VolumeDialog.tsx} | 378 +++++------------- 2 files changed, 122 insertions(+), 287 deletions(-) rename web/src/components/storage/{VolumeDialog.test.jsx => VolumeDialog.test.tsx} (96%) rename web/src/components/storage/{VolumeDialog.jsx => VolumeDialog.tsx} (67%) diff --git a/web/src/components/storage/VolumeDialog.test.jsx b/web/src/components/storage/VolumeDialog.test.tsx similarity index 96% rename from web/src/components/storage/VolumeDialog.test.jsx rename to web/src/components/storage/VolumeDialog.test.tsx index 972f6a1462..d10bc3a58e 100644 --- a/web/src/components/storage/VolumeDialog.test.jsx +++ b/web/src/components/storage/VolumeDialog.test.tsx @@ -25,17 +25,12 @@ import React from "react"; import { screen, waitFor, within } from "@testing-library/react"; import { plainRender } from "~/test-utils"; import { parseToBytes } from "~/components/storage/utils"; -import VolumeDialog from "~/components/storage/VolumeDialog"; +import VolumeDialog, { VolumeDialogProps } from "./VolumeDialog"; +import { Volume, VolumeTarget } from "~/types/storage"; -/** - * @typedef {import ("~/client/storage").Volume} Volume - * @typedef {import ("~/components/storage/VolumeDialog").VolumeDialogProps} VolumeDialogProps - */ - -/** @type {Volume} */ -const rootVolume = { +const rootVolume: Volume = { mountPath: "/", - target: "DEFAULT", + target: VolumeTarget.DEFAULT, fsType: "Btrfs", minSize: 1024, maxSize: 2048, @@ -54,10 +49,9 @@ const rootVolume = { }, }; -/** @type {Volume} */ -const swapVolume = { +const swapVolume: Volume = { mountPath: "swap", - target: "DEFAULT", + target: VolumeTarget.DEFAULT, fsType: "Swap", minSize: 1024, maxSize: 1024, @@ -76,10 +70,9 @@ const swapVolume = { }, }; -/** @type {Volume} */ -const homeVolume = { +const homeVolume: Volume = { mountPath: "/home", - target: "DEFAULT", + target: VolumeTarget.DEFAULT, fsType: "XFS", minSize: 1024, maxSize: 4096, @@ -98,10 +91,9 @@ const homeVolume = { }, }; -/** @type {Volume} */ -const arbitraryVolume = { +const arbitraryVolume: Volume = { mountPath: "", - target: "DEFAULT", + target: VolumeTarget.DEFAULT, fsType: "XFS", minSize: 1024, maxSize: 4096, @@ -120,8 +112,7 @@ const arbitraryVolume = { }, }; -/** @type {VolumeDialogProps} */ -let props; +let props: VolumeDialogProps; describe("VolumeDialog", () => { beforeEach(() => { diff --git a/web/src/components/storage/VolumeDialog.jsx b/web/src/components/storage/VolumeDialog.tsx similarity index 67% rename from web/src/components/storage/VolumeDialog.jsx rename to web/src/components/storage/VolumeDialog.tsx index a2a4c39a9c..659d4a2a14 100644 --- a/web/src/components/storage/VolumeDialog.jsx +++ b/web/src/components/storage/VolumeDialog.tsx @@ -19,9 +19,7 @@ * find current contact information at www.suse.com. */ -// @ts-check - -import React, { useReducer } from "react"; +import React, { FormEvent, useReducer } from "react"; import { Alert, Button, Form, Split } from "@patternfly/react-core"; import { Popup } from "~/components/core"; import { FsField, MountPathField, SizeOptionsField } from "~/components/storage/VolumeFields"; @@ -37,45 +35,39 @@ import { splitSize, volumeLabel, } from "~/components/storage/utils"; +import { Volume } from "~/types/storage"; +import { SizeMethod } from "~/components/storage/utils"; -/** - * @typedef {import ("~/client/storage").Volume} Volume - * @typedef {import("~/components/storage/utils").SizeMethod} SizeMethod - * - * @typedef {object} VolumeFormState - * @property {Volume} volume - * @property {VolumeFormData} formData - * @property {VolumeFormErrors} errors - * - * @typedef {object} VolumeFormData - * @property {number|string} [minSize] - * @property {string} [minSizeUnit] - * @property {number|string} [maxSize] - * @property {string} [maxSizeUnit] - * @property {SizeMethod} sizeMethod - * @property {string} mountPath - * @property {string} fsType - * @property {boolean} snapshots - * - * @typedef {object} VolumeFormErrors - * @property {string|null} missingMountPath - * @property {string|null} invalidMountPath - * @property {React.ReactElement|null} existingVolume - * @property {React.ReactElement|null} existingTemplate - * @property {string|null} missingSize - * @property {string|null} missingMinSize - * @property {string|null} invalidMaxSize - */ +type VolumeFormState = { + volume: Volume; + formData: VolumeFormData; + errors: VolumeFormErrors; +} +type VolumeFormData = { + minSize?: number | string; + minSizeUnit?: string; + maxSize?: number | string; + maxSizeUnit?: string; + sizeMethod: SizeMethod; + mountPath: string; + fsType: string; + snapshots: boolean; +} +type VolumeFormErrors = { + missingMountPath: string | null; + invalidMountPath: string | null; + existingVolume: React.ReactElement | null; + existingTemplate: React.ReactElement | null; + missingSize: string | null; + missingMinSize: string | null; + invalidMaxSize: string | null; +} /** * Renders the title for the dialog. * @function - * - * @param {Volume} volume - * @param {Volume[]} volumes - * @returns {string} */ -const renderTitle = (volume, volumes) => { +const renderTitle = (volume: Volume, volumes: Volume[]): string => { const isNewVolume = !volumes.includes(volume); const isProductDefined = volume.outline.productDefined; const label = volumeLabel(volume); @@ -88,12 +80,9 @@ const renderTitle = (volume, volumes) => { /** * @component - * - * @param {object} props - * @param {Volume} props.volume */ -const VolumeAlert = ({ volume }) => { - let alert; +const VolumeAlert = ({ volume }: { volume: Volume; }) => { + let alert: { title: string, text: string }; if (mountFilesystem(volume)) { alert = { @@ -137,181 +126,115 @@ const VolumeAlert = ({ volume }) => { * const error = checker.existingMountPathError(); * const message = error?.render(onClick); */ - class MissingMountPathError { - /** - * @constructor - * @param {string} mountPath - */ - constructor(mountPath) { + mountPath: string; + + constructor(mountPath: string) { this.mountPath = mountPath; } - /** - * @method - * @returns {boolean} - */ - check() { + check(): boolean { return this.mountPath.length === 0; } - /** - * @method - * @returns {String} - */ - render() { + render(): string { return _("A mount point is required"); } } class InvalidMountPathError { - /** - * @constructor - * @param {string} mountPath - */ - constructor(mountPath) { + mountPath: string; + + constructor(mountPath: string) { this.mountPath = mountPath; } - /** - * @method - * @returns {boolean} - */ - check() { + check(): boolean { const regex = /^swap$|^\/$|^(\/[^/\s]+([^/]*[^/\s])*)+$/; return !regex.test(this.mountPath); } - /** - * @method - * @returns {string} - */ - render() { + render(): string { return _("The mount point is invalid"); } } class MissingSizeError { - /** - * @constructor - * @param {SizeMethod} sizeMethod - * @param {string|number} size - */ - constructor(sizeMethod, size) { + sizeMethod: SizeMethod; + size: string | number; + + constructor(sizeMethod: SizeMethod, size: string | number) { this.sizeMethod = sizeMethod; this.size = size; } - /** - * @method - * @returns {boolean} - */ - check() { + check(): boolean { return this.sizeMethod === SIZE_METHODS.MANUAL && !this.size; } - /** - * @method - * @returns {string} - */ - render() { + render(): string { return _("A size value is required"); } } class MissingMinSizeError { - /** - * @constructor - * @param {SizeMethod} sizeMethod - * @param {string|number} minSize - */ - constructor(sizeMethod, minSize) { + sizeMethod: SizeMethod; + minSize: string | number; + + constructor(sizeMethod: SizeMethod, minSize: string | number) { this.sizeMethod = sizeMethod; this.minSize = minSize; } - /** - * @method - * @returns {boolean} - */ - check() { + check(): boolean { return this.sizeMethod === SIZE_METHODS.RANGE && !this.minSize; } - /** - * @method - * @returns {string} - */ - render() { + render(): string { return _("Minimum size is required"); } } class InvalidMaxSizeError { - /** - * @constructor - * @param {SizeMethod} sizeMethod - * @param {string|number} minSize - * @param {string|number} maxSize - */ - constructor(sizeMethod, minSize, maxSize) { + sizeMethod: SizeMethod; + minSize: string | number; + maxSize: string | number; + + constructor(sizeMethod: SizeMethod, minSize: string | number, maxSize: string | number) { this.sizeMethod = sizeMethod; this.minSize = minSize; this.maxSize = maxSize; } - /** - * @method - * @returns {boolean} - */ - check() { + check(): boolean { return ( this.sizeMethod === SIZE_METHODS.RANGE && this.maxSize !== -1 && this.maxSize <= this.minSize ); } - /** - * @method - * @returns {string} - */ - render() { + render(): string { return _("Maximum must be greater than minimum"); } } class ExistingVolumeError { - /** - * @constructor - * @param {string} mountPath - * @param {Volume[]} volumes - */ - constructor(mountPath, volumes) { + mountPath: string; + volumes: Volume[]; + + constructor(mountPath: string, volumes: Volume[]) { this.mountPath = mountPath; this.volumes = volumes; } - /** - * @method - * @returns {Volume|undefined} - */ - findVolume() { + findVolume(): Volume | undefined { return this.volumes.find((t) => t.mountPath === this.mountPath); } - /** - * @method - * @returns {boolean} - */ - check() { + check(): boolean { return this.mountPath.length && this.findVolume() !== undefined; } - /** - * @method - * @param {(volume: Volume) => void} onClick - * @returns {React.ReactElement} - */ - render(onClick) { + render(onClick: (volume: Volume) => void): React.ReactElement { const volume = this.findVolume(); const path = this.mountPath === "/" ? "root" : this.mountPath; @@ -327,38 +250,23 @@ class ExistingVolumeError { } class ExistingTemplateError { - /** - * @constructor - * @param {string} mountPath - * @param {Volume[]} templates - */ - constructor(mountPath, templates) { + mountPath: string; + templates: Volume[]; + + constructor(mountPath: string, templates: Volume[]) { this.mountPath = mountPath; this.templates = templates; } - /** - * @method - * @returns {Volume|undefined} - */ - findTemplate() { + findTemplate(): Volume | undefined { return this.templates.find((t) => t.mountPath === this.mountPath); } - /** - * @method - * @returns {boolean} - */ - check() { + check(): boolean { return this.mountPath.length && this.findTemplate() !== undefined; } - /** - * @method - * @param {(template: Volume) => void} onClick - * @returns {React.ReactElement} - */ - render(onClick) { + render(onClick: (template: Volume) => void): React.ReactElement { const template = this.findTemplate(); const path = this.mountPath === "/" ? "root" : this.mountPath; @@ -376,11 +284,8 @@ class ExistingTemplateError { /** * Error if the mount path is missing. * @function - * - * @param {string} mountPath - * @returns {string|null} */ -const missingMountPathError = (mountPath) => { +const missingMountPathError = (mountPath: string): string | null => { const error = new MissingMountPathError(mountPath); return error.check() ? error.render() : null; }; @@ -388,11 +293,8 @@ const missingMountPathError = (mountPath) => { /** * Error if the mount path is not valid. * @function - * - * @param {string} mountPath - * @returns {string|null} */ -const invalidMountPathError = (mountPath) => { +const invalidMountPathError = (mountPath: string): string | null => { const error = new InvalidMountPathError(mountPath); return error.check() ? error.render() : null; }; @@ -400,12 +302,8 @@ const invalidMountPathError = (mountPath) => { /** * Error if the size is missing. * @function - * - * @param {SizeMethod} sizeMethod - * @param {string|number} size - * @returns {string|null} */ -const missingSizeError = (sizeMethod, size) => { +const missingSizeError = (sizeMethod: SizeMethod, size: string | number): string | null => { const error = new MissingSizeError(sizeMethod, size); return error.check() ? error.render() : null; }; @@ -413,12 +311,8 @@ const missingSizeError = (sizeMethod, size) => { /** * Error if the min size is missing. * @function - * - * @param {SizeMethod} sizeMethod - * @param {string|number} minSize - * @returns {string|null} */ -const missingMinSizeError = (sizeMethod, minSize) => { +const missingMinSizeError = (sizeMethod: SizeMethod, minSize: string | number): string | null => { const error = new MissingMinSizeError(sizeMethod, minSize); return error.check() ? error.render() : null; }; @@ -426,13 +320,8 @@ const missingMinSizeError = (sizeMethod, minSize) => { /** * Error if the max size is not valid. * @function - * - * @param {SizeMethod} sizeMethod - * @param {string|number} minSize - * @param {string|number} maxSize - * @returns {string|null} */ -const invalidMaxSizeError = (sizeMethod, minSize, maxSize) => { +const invalidMaxSizeError = (sizeMethod: SizeMethod, minSize: string | number, maxSize: string | number): string | null => { const error = new InvalidMaxSizeError(sizeMethod, minSize, maxSize); return error.check() ? error.render() : null; }; @@ -440,13 +329,8 @@ const invalidMaxSizeError = (sizeMethod, minSize, maxSize) => { /** * Error if the given mount path exists in the list of volumes. * @function - * - * @param {string} mountPath - * @param {Volume[]} volumes - * @param {(volume: Volume) => void} onClick - * @returns {React.ReactElement|null} */ -const existingVolumeError = (mountPath, volumes, onClick) => { +const existingVolumeError = (mountPath: string, volumes: Volume[], onClick: (volume: Volume) => void): React.ReactElement | null => { const error = new ExistingVolumeError(mountPath, volumes); return error.check() ? error.render(onClick) : null; }; @@ -454,13 +338,8 @@ const existingVolumeError = (mountPath, volumes, onClick) => { /** * Error if the given mount path exists in the list of templates. * @function - * - * @param {string} mountPath - * @param {Volume[]} templates - * @param {(template: Volume) => void} onClick - * @returns {React.ReactElement|null} */ -const existingTemplateError = (mountPath, templates, onClick) => { +const existingTemplateError = (mountPath: string, templates: Volume[], onClick: (template: Volume) => void): React.ReactElement | null => { const error = new ExistingTemplateError(mountPath, templates); return error.check() ? error.render(onClick) : null; }; @@ -468,22 +347,16 @@ const existingTemplateError = (mountPath, templates, onClick) => { /** * Checks whether there is any error. * @function - * - * @param {VolumeFormErrors} errors - * @returns {boolean} */ -const anyError = (errors) => { +const anyError = (errors: VolumeFormErrors): boolean => { return compact(Object.values(errors)).length > 0; }; /** * Remove leftover trailing slash. * @function - * - * @param {string} mountPath - * @returns {string} */ -const sanitizeMountPath = (mountPath) => { +const sanitizeMountPath = (mountPath: string): string => { if (mountPath === "/") return mountPath; return mountPath.replace(/\/$/, ""); @@ -492,12 +365,8 @@ const sanitizeMountPath = (mountPath) => { /** * Creates a new storage volume object based on given params. * @function - * - * @param {Volume} volume - * @param {VolumeFormData} formData - * @returns {Volume} */ -const createUpdatedVolume = (volume, formData) => { +const createUpdatedVolume = (volume: Volume, formData: VolumeFormData): Volume => { let sizeAttrs = {}; const minSize = parseToBytes(`${formData.minSize} ${formData.minSizeUnit}`); const maxSize = parseToBytes(`${formData.maxSize} ${formData.maxSizeUnit}`); @@ -523,11 +392,8 @@ const createUpdatedVolume = (volume, formData) => { /** * Form-related helper for guessing the size method for given volume * @function - * - * @param {Volume} volume - a storage volume - * @return {SizeMethod} corresponding size method */ -const sizeMethodFor = (volume) => { +const sizeMethodFor = (volume: Volume): SizeMethod => { const { autoSize, minSize, maxSize } = volume; if (autoSize) { @@ -542,11 +408,8 @@ const sizeMethodFor = (volume) => { /** * Form-related helper for preparing data based on given volume * @function - * - * @param {Volume} volume - a storage volume object - * @return {VolumeFormData} an object ready to be used as a "form state" */ -const prepareFormData = (volume) => { +const prepareFormData = (volume: Volume): VolumeFormData => { const { size: minSize = "", unit: minSizeUnit = DEFAULT_SIZE_UNIT } = splitSize(volume.minSize); const { size: maxSize = "", unit: maxSizeUnit = minSizeUnit || DEFAULT_SIZE_UNIT } = splitSize( volume.maxSize, @@ -567,10 +430,8 @@ const prepareFormData = (volume) => { /** * Possible errors from the form data. * @function - * - * @returns {VolumeFormErrors} */ -const prepareErrors = () => { +const prepareErrors = (): VolumeFormErrors => { return { missingMountPath: null, invalidMountPath: null, @@ -586,10 +447,9 @@ const prepareErrors = () => { * Initializer function for the React#useReducer used in the {@link VolumesForm} * @function * - * @param {Volume} volume - a storage volume object - * @returns {VolumeFormState} + * @param volume - a storage volume object */ -const createInitialState = (volume) => { +const createInitialState = (volume: Volume): VolumeFormState => { const formData = prepareFormData(volume); const errors = prepareErrors(); @@ -599,11 +459,8 @@ const createInitialState = (volume) => { /** * The VolumeForm reducer. * @function - * - * @param {VolumeFormState} state - * @param {object} action */ -const reducer = (state, action) => { +const reducer = (state: VolumeFormState, action: { type: string, payload: any }) => { const { type, payload } = action; switch (type) { @@ -632,19 +489,18 @@ const reducer = (state, action) => { } }; +export type VolumeDialogProps = { + volume: Volume; + volumes: Volume[]; + templates: Volume[]; + isOpen?: boolean; + onCancel: () => void; + onAccept: (volume: Volume) => void; +} + /** * Renders a dialog that allows the user to add or edit a file system. * @component - * - * @typedef {object} VolumeDialogProps - * @property {Volume} volume - * @property {Volume[]} volumes - * @property {Volume[]} templates - * @property {boolean} [isOpen=false] - * @property {() => void} onCancel - * @property {(volume: Volume) => void} onAccept - * - * @param {VolumeDialogProps} props */ export default function VolumeDialog({ volume: currentVolume, @@ -653,32 +509,25 @@ export default function VolumeDialog({ isOpen, onCancel, onAccept, -}) { - /** @type {[VolumeFormState, (action: object) => void]} */ - const [state, dispatch] = useReducer(reducer, currentVolume, createInitialState); +}: VolumeDialogProps) { + const [state, dispatch]: [VolumeFormState, (action: any) => void] = useReducer(reducer, currentVolume, createInitialState); - /** @type {Function} */ - const delayed = useDebounce((f) => f(), 1000); + const delayed: Function = useDebounce((f) => f(), 1000); - /** @type {(volume: Volume) => void} */ - const changeVolume = (volume) => { + const changeVolume: (volume: Volume) => void = (volume) => { dispatch({ type: "CHANGE_VOLUME", payload: { volume } }); }; - /** @type {(data: object) => void} */ - const updateData = (data) => dispatch({ type: "UPDATE_DATA", payload: data }); + const updateData: (data: object) => void = (data): void => dispatch({ type: "UPDATE_DATA", payload: data }); - /** @type {(errors: object) => void} */ - const updateErrors = (errors) => dispatch({ type: "SET_ERRORS", payload: errors }); + const updateErrors: (errors: object) => void = (errors): void => dispatch({ type: "SET_ERRORS", payload: errors }); - /** @type {() => string|React.ReactElement} */ - const mountPathError = () => { + const mountPathError: () => string | React.ReactElement = () => { const { missingMountPath, invalidMountPath, existingVolume, existingTemplate } = state.errors; return missingMountPath || invalidMountPath || existingVolume || existingTemplate; }; - /** @type {() => object} */ - const sizeErrors = () => { + const sizeErrors: () => object = () => { return { size: state.errors.missingSize, minSize: state.errors.missingMinSize, @@ -686,21 +535,18 @@ export default function VolumeDialog({ }; }; - /** @type {() => boolean} */ - const disableWidgets = () => { + const disableWidgets: () => boolean = () => { const { existingVolume, existingTemplate } = state.errors; return existingVolume !== null || existingTemplate !== null; }; - /** @type {() => boolean} */ - const isMountPathEditable = () => { + const isMountPathEditable: () => boolean = () => { const isNewVolume = !volumes.includes(state.volume); const isPredefined = state.volume.outline.productDefined; return isNewVolume && !isPredefined; }; - /** @type {(mountPath: string) => void} */ - const changeMountPath = (mountPath) => { + const changeMountPath: (mountPath: string) => void = (mountPath) => { // Reset current errors. const errors = { missingMountPath: null, @@ -722,8 +568,7 @@ export default function VolumeDialog({ updateData({ mountPath }); }; - /** @type {(data: object) => void} */ - const changeSizeOptions = (data) => { + const changeSizeOptions: (data: object) => void = (data) => { // Reset errors. const errors = { missingSize: null, @@ -734,8 +579,7 @@ export default function VolumeDialog({ updateData(data); }; - /** @type {(e: import("react").FormEvent) => void} */ - const submitForm = (e) => { + const submitForm: (e: FormEvent) => void = (e) => { e.preventDefault(); const { volume: originalVolume, formData } = state; const volume = createUpdatedVolume(originalVolume, formData); From bdf5ea0ca6ef124f32c0ee254eaa8e25b6034b15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Thu, 5 Sep 2024 10:34:58 +0100 Subject: [PATCH 21/53] refactor(web): convert InvalidMaxSizeError to TypeScript --- .../storage/InvalidMaxSizeError.tsx | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 web/src/components/storage/InvalidMaxSizeError.tsx diff --git a/web/src/components/storage/InvalidMaxSizeError.tsx b/web/src/components/storage/InvalidMaxSizeError.tsx new file mode 100644 index 0000000000..ce9469a69e --- /dev/null +++ b/web/src/components/storage/InvalidMaxSizeError.tsx @@ -0,0 +1,46 @@ +/* + * 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. + */ + +import { _ } from "~/i18n"; +import { SIZE_METHODS, SizeMethod } from "~/components/storage/utils"; + +export class InvalidMaxSizeError { + sizeMethod: SizeMethod; + minSize: string | number; + maxSize: string | number; + + constructor(sizeMethod: SizeMethod, minSize: string | number, maxSize: string | number) { + this.sizeMethod = sizeMethod; + this.minSize = minSize; + this.maxSize = maxSize; + } + + check(): boolean { + return ( + this.sizeMethod === SIZE_METHODS.RANGE && this.maxSize !== -1 && this.maxSize <= this.minSize + ); + } + + render(): string { + return _("Maximum must be greater than minimum"); + } +} + From 72bece44e2ad7e32ff3b4d32573f8b1badeffd44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Thu, 5 Sep 2024 10:43:07 +0100 Subject: [PATCH 22/53] refactor(web): export TargetConfig from InstallationDeviceField --- web/src/components/storage/InstallationDeviceField.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/components/storage/InstallationDeviceField.tsx b/web/src/components/storage/InstallationDeviceField.tsx index 9d9b3e42b6..73a576a6d3 100644 --- a/web/src/components/storage/InstallationDeviceField.tsx +++ b/web/src/components/storage/InstallationDeviceField.tsx @@ -63,7 +63,7 @@ const targetValue = (target: ProposalTarget, targetDevice: StorageDevice, target * @component */ -type TargetConfig = { +export type TargetConfig = { target: ProposalTarget; targetDevice: StorageDevice | undefined; targetPVDevices: StorageDevice[]; From 29511fe432659aae5d1fbc4848b25c73386ae47a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Thu, 5 Sep 2024 10:48:19 +0100 Subject: [PATCH 23/53] refactor(web): convert VolumeFields to TypeScript --- ...eFields.test.jsx => VolumeFields.test.tsx} | 12 +- .../{VolumeFields.jsx => VolumeFields.tsx} | 135 +++++++++--------- 2 files changed, 70 insertions(+), 77 deletions(-) rename web/src/components/storage/{VolumeFields.test.jsx => VolumeFields.test.tsx} (98%) rename web/src/components/storage/{VolumeFields.jsx => VolumeFields.tsx} (82%) diff --git a/web/src/components/storage/VolumeFields.test.jsx b/web/src/components/storage/VolumeFields.test.tsx similarity index 98% rename from web/src/components/storage/VolumeFields.test.jsx rename to web/src/components/storage/VolumeFields.test.tsx index 94def2e678..878fd93554 100644 --- a/web/src/components/storage/VolumeFields.test.jsx +++ b/web/src/components/storage/VolumeFields.test.tsx @@ -19,22 +19,16 @@ * find current contact information at www.suse.com. */ -// @ts-check - import React from "react"; import { screen, within } from "@testing-library/react"; import { plainRender } from "~/test-utils"; import { SIZE_METHODS } from "~/components/storage/utils"; import { FsField, MountPathField, SizeOptionsField } from "~/components/storage/VolumeFields"; +import { Volume, VolumeTarget } from "~/types/storage"; -/** - * @typedef {import ("~/client/storage").Volume} Volume - */ - -/** @type {Volume} */ -const volume = { +const volume: Volume = { mountPath: "/home", - target: "DEFAULT", + target: VolumeTarget.DEFAULT, fsType: "XFS", minSize: 1024, maxSize: 4096, diff --git a/web/src/components/storage/VolumeFields.jsx b/web/src/components/storage/VolumeFields.tsx similarity index 82% rename from web/src/components/storage/VolumeFields.jsx rename to web/src/components/storage/VolumeFields.tsx index f8047f93e5..f8ff608f3c 100644 --- a/web/src/components/storage/VolumeFields.jsx +++ b/web/src/components/storage/VolumeFields.tsx @@ -19,8 +19,6 @@ * find current contact information at www.suse.com. */ -// @ts-check - import React, { useState } from "react"; import { FormGroup, @@ -37,35 +35,32 @@ import { Split, Stack, TextInput, + FormSelectProps, } from "@patternfly/react-core"; import { FormValidationError, FormReadOnlyField, NumericTextInput } from "~/components/core"; import { Icon } from "~/components/layout"; import { _, N_ } from "~/i18n"; import { sprintf } from "sprintf-js"; import { SIZE_METHODS, SIZE_UNITS } from "~/components/storage/utils"; +import { Volume } from "~/types/storage"; const { K, ...MAX_SIZE_UNITS } = SIZE_UNITS; -/** - * @typedef {import ("~/client/storage").Volume} Volume - */ +export type MountPathFieldProps = { + value?: string; + isReadOnly?: boolean; + onChange: (mountPath: string) => void; + error?: React.ReactNode; +} /** * Field for the mount path of a volume. * @component - * - * @typedef {object} MountPathFieldProps - * @property {string} [value=""] - * @property {boolean} [isReadOnly=false] - * @property {(mountPath: string) => void} onChange - * @property {React.ReactNode} [error] - * - * @param {MountPathFieldProps} props */ -const MountPathField = ({ value = "", onChange, isReadOnly = false, error }) => { +const MountPathField = ({ value = "", onChange, isReadOnly = false, error }: MountPathFieldProps) => { const label = _("Mount point"); - /** @type {(_: any, mountPath: string) => void} */ - const changeMountPath = (_, mountPath) => onChange(mountPath); + + const changeMountPath: (_: any, mountPath: string) => void = (_, mountPath) => onChange(mountPath); if (isReadOnly) { return {value}; @@ -93,10 +88,10 @@ const MountPathField = ({ value = "", onChange, isReadOnly = false, error }) => * Based on {@link PF/FormSelect https://www.patternfly.org/components/forms/form-select} * * @param {object} props - * @param {Array} props.units - a collection of size units - * @param {import("@patternfly/react-core").FormSelectProps} props.formSelectProps + * @param props.units - a collection of size units + * @param props.formSelectProps */ -const SizeUnitFormSelect = ({ units, ...formSelectProps }) => { +const SizeUnitFormSelect = ({ units, ...formSelectProps }: { units: Array; formSelectProps: FormSelectProps }) => { return ( {units.map((unit) => { @@ -111,22 +106,16 @@ const SizeUnitFormSelect = ({ units, ...formSelectProps }) => { /** * Possible file system type options for a volume. * @function - * - * @param {Volume} volume - * @returns {string[]} */ -const fsOptions = (volume) => { +const fsOptions = (volume: Volume): string[] => { return volume.outline.fsTypes; }; /** * Option for selecting a file system type. * @component - * - * @param {object} props - * @param {string} props.fsOption - File system type option. */ -const FsSelectOption = ({ fsOption }) => { +const FsSelectOption = ({ fsOption }: { fsOption: string; }) => { return ( {fsOption} @@ -138,14 +127,18 @@ const FsSelectOption = ({ fsOption }) => { * Widget for selecting a file system type. * @component * - * @param {object} props - * @param {string} props.id - Widget id. - * @param {string} props.value - Currently selected file system. - * @param {Volume} props.volume - The selected storage volume. - * @param {boolean} props.isDisabled - * @param {(data: object) => void} props.onChange - Callback for notifying input changes. + * @param props + * @param props.id - Widget id. + * @param props.value - Currently selected file system. + * @param props.volume - The selected storage volume. + * @param props.isDisabled + * @param props.onChange - Callback for notifying input changes. */ -const FsSelect = ({ id, value, volume, isDisabled, onChange }) => { +const FsSelect = ({ + id, value, volume, isDisabled, onChange +}: { + id: string; value: string; volume: Volume; isDisabled: boolean; onChange: (data: object) => void; +}) => { const [isOpen, setIsOpen] = useState(false); const options = fsOptions(volume); @@ -193,22 +186,21 @@ const FsSelect = ({ id, value, volume, isDisabled, onChange }) => { ); }; +type FsFieldProps = { + value: string; + volume: Volume; + isDisabled?: boolean; + onChange: (data: object) => void; +} + /** * Widget for rendering the file system configuration. * * Allows selecting a file system type. If there is only one possible option, then it renders plain * text with the unique option. * @component - * - * @typedef {object} FsFieldProps - * @property {string} value - Currently selected file system. - * @property {Volume} volume - The selected storage volume. - * @property {boolean} [isDisabled=false] - Whether the field is disabled or not. - * @property {(data: object) => void} onChange - Callback for notifying input changes. - * - * @param {FsFieldProps} props */ -const FsField = ({ value, volume, isDisabled = false, onChange }) => { +const FsField = ({ value, volume, isDisabled = false, onChange }: FsFieldProps) => { const isSingleFs = () => { // check for btrfs with snapshots if (volume.fsType === "Btrfs" && volume.snapshots) { @@ -266,7 +258,7 @@ const FsField = ({ value, volume, isDisabled = false, onChange }) => { * @param {object} props * @param {Volume} props.volume - a storage volume object */ -const SizeAuto = ({ volume }) => { +const SizeAuto = ({ volume }: { volume: Volume; }) => { const conditions = []; if (volume.outline.snapshotsAffectSizes) @@ -309,13 +301,17 @@ const SizeAuto = ({ volume }) => { * Widget for rendering the size option content when SIZE_UNITS.MANUAL is selected * @component * - * @param {object} props - * @param {object} props.errors - the form errors - * @param {object} props.formData - the form data - * @param {boolean} props.isDisabled - * @param {(v: object) => void} props.onChange - callback for notifying input changes + * @param props + * @param props.errors - the form errors + * @param props.formData - the form data + * @param props.isDisabled + * @param props.onChange - callback for notifying input changes */ -const SizeManual = ({ errors, formData, isDisabled, onChange }) => { +const SizeManual = ({ + errors, formData, isDisabled, onChange +}: { + errors: any; formData: any; isDisabled: boolean; onChange: (v: object) => void; +}) => { return (

{_("Exact size for the file system.")}

@@ -362,13 +358,17 @@ const SizeManual = ({ errors, formData, isDisabled, onChange }) => { * Widget for rendering the size option content when SIZE_UNITS.RANGE is selected * @component * - * @param {object} props - * @param {object} props.errors - the form errors - * @param {object} props.formData - the form data - * @param {boolean} props.isDisabled - * @param {(v: object) => void} props.onChange - callback for notifying input changes + * @param props + * @param props.errors - the form errors + * @param props.formData - the form data + * @param props.isDisabled + * @param props.onChange - callback for notifying input changes */ -const SizeRange = ({ errors, formData, isDisabled, onChange }) => { +const SizeRange = ( + { errors, formData, isDisabled, onChange + }: { + errors: any; formData: any; isDisabled: boolean; onChange: (v: object) => void; + }) => { return (

@@ -465,22 +465,21 @@ const SIZE_OPTION_LABELS = Object.freeze({ /** * Widget for rendering the volume size options * @component - * - * @typedef {object} SizeOptionsFieldProps - * @property {Volume} volume - the selected storage volume - * @property {object} formData - the form data - * @property {object} [errors={}] - the form errors - * @property {boolean} [isDisabled=false] - Whether the field options are disabled or not. - * @property {(v: object) => void} onChange - callback for notifying input changes - * - * @param {SizeOptionsFieldProps} props */ -const SizeOptionsField = ({ volume, formData, isDisabled = false, errors = {}, onChange }) => { + +type SizeOptionsFieldProps = { + volume: Volume; + formData: any; + errors?: object; + isDisabled?: boolean; + onChange: (v: object) => void; +} + +const SizeOptionsField = ({ volume, formData, isDisabled = false, errors = {}, onChange }: SizeOptionsFieldProps) => { const { sizeMethod } = formData; const sizeWidgetProps = { errors, formData, volume, isDisabled, onChange }; - /** @type {string[]} */ - const sizeOptions = [SIZE_METHODS.MANUAL, SIZE_METHODS.RANGE]; + const sizeOptions: string[] = [SIZE_METHODS.MANUAL, SIZE_METHODS.RANGE]; if (volume.outline.supportAutoSize) sizeOptions.push(SIZE_METHODS.AUTO); From 2a9ec0b3e5bfbcab22e8385c101b53badb53a335 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Thu, 5 Sep 2024 11:09:43 +0100 Subject: [PATCH 24/53] refactor(web): convert VolumeLocationDialog to TypeScript --- ...test.jsx => VolumeLocationDialog.test.tsx} | 33 +++----- ...ionDialog.jsx => VolumeLocationDialog.tsx} | 84 +++++++++---------- 2 files changed, 49 insertions(+), 68 deletions(-) rename web/src/components/storage/{VolumeLocationDialog.test.jsx => VolumeLocationDialog.test.tsx} (89%) rename web/src/components/storage/{VolumeLocationDialog.jsx => VolumeLocationDialog.tsx} (72%) diff --git a/web/src/components/storage/VolumeLocationDialog.test.jsx b/web/src/components/storage/VolumeLocationDialog.test.tsx similarity index 89% rename from web/src/components/storage/VolumeLocationDialog.test.jsx rename to web/src/components/storage/VolumeLocationDialog.test.tsx index e5699ec628..d4fa88a00c 100644 --- a/web/src/components/storage/VolumeLocationDialog.test.jsx +++ b/web/src/components/storage/VolumeLocationDialog.test.tsx @@ -19,21 +19,13 @@ * find current contact information at www.suse.com. */ -// @ts-check - import React from "react"; import { screen, within } from "@testing-library/react"; import { plainRender } from "~/test-utils"; -import VolumeLocationDialog from "~/components/storage/VolumeLocationDialog"; - -/** - * @typedef {import ("~/client/storage").StorageDevice} StorageDevice - * @typedef {import ("~/client/storage").Volume} Volume - * @typedef {import ("~/components/storage/VolumeLocationDialog").VolumeLocationDialogProps} VolumeLocationDialogProps - */ +import VolumeLocationDialog, { VolumeLocationDialogProps } from "~/components/storage/VolumeLocationDialog"; +import { StorageDevice, Volume, VolumeTarget } from "~/types/storage"; -/** @type {StorageDevice} */ -const sda = { +const sda: StorageDevice = { sid: 59, isDrive: true, type: "disk", @@ -55,8 +47,7 @@ const sda = { udevPaths: ["pci-0000:00-12", "pci-0000:00-12-ata"], }; -/** @type {StorageDevice} */ -const sda1 = { +const sda1: StorageDevice = { sid: 69, name: "/dev/sda1", description: "", @@ -69,8 +60,7 @@ const sda1 = { }, }; -/** @type {StorageDevice} */ -const sda2 = { +const sda2: StorageDevice = { sid: 79, name: "/dev/sda2", description: "", @@ -90,8 +80,7 @@ sda.partitionTable = { unusedSlots: [], }; -/** @type {StorageDevice} */ -const sdb = { +const sdb: StorageDevice = { sid: 62, isDrive: true, type: "disk", @@ -113,10 +102,9 @@ const sdb = { udevPaths: ["pci-0000:00-19"], }; -/** @type {Volume} */ -const volume = { +const volume: Volume = { mountPath: "/", - target: "DEFAULT", + target: VolumeTarget.DEFAULT, fsType: "Btrfs", minSize: 1024, maxSize: 2048, @@ -135,8 +123,7 @@ const volume = { }, }; -/** @type {VolumeLocationDialogProps} */ -let props; +let props: VolumeLocationDialogProps; describe("VolumeLocationDialog", () => { beforeEach(() => { @@ -232,7 +219,7 @@ describe("VolumeLocationDialog", () => { await user.click(accept); expect(props.onAccept).toHaveBeenCalledWith( - expect.objectContaining({ target: "DEVICE", targetDevice: sdb }), + expect.objectContaining({ target: VolumeTarget.DEVICE, targetDevice: sdb }), ); }); diff --git a/web/src/components/storage/VolumeLocationDialog.jsx b/web/src/components/storage/VolumeLocationDialog.tsx similarity index 72% rename from web/src/components/storage/VolumeLocationDialog.jsx rename to web/src/components/storage/VolumeLocationDialog.tsx index d730b0189a..d342299d87 100644 --- a/web/src/components/storage/VolumeLocationDialog.jsx +++ b/web/src/components/storage/VolumeLocationDialog.tsx @@ -28,13 +28,9 @@ import VolumeLocationSelectorTable from "~/components/storage/VolumeLocationSele import { _ } from "~/i18n"; import { sprintf } from "sprintf-js"; import { deviceChildren, volumeLabel } from "~/components/storage/utils"; +import { StorageDevice, Volume, VolumeTarget } from "~/types/storage"; -/** - * @typedef {"auto"|"device"|"reuse"} LocationOption - * @typedef {import ("~/client/storage").StorageDevice} StorageDevice - * @typedef {import ("~/client/storage").Volume} Volume - * @typedef {import ("~/client/storage").VolumeTarget} VolumeTarget - */ +type LocationOption = "auto" | "device" | "reuse"; // TRANSLATORS: Description of the dialog for changing the location of a file system. const DIALOG_DESCRIPTION = _( @@ -42,49 +38,47 @@ const DIALOG_DESCRIPTION = _( default. Indicate a custom location to create the file system at a specific device.", ); -/** @type {(device: StorageDevice|undefined) => VolumeTarget} */ -const defaultTarget = (device) => { - if (["partition", "lvmLv", "md"].includes(device?.type)) return "DEVICE"; +const defaultTarget: (device: StorageDevice | undefined) => VolumeTarget = (device): VolumeTarget => { + if (["partition", "lvmLv", "md"].includes(device?.type)) return VolumeTarget.DEVICE; - return "NEW_PARTITION"; + return VolumeTarget.NEW_PARTITION; }; /** @type {(volume: Volume, device: StorageDevice|undefined) => VolumeTarget[]} */ -const availableTargets = (volume, device) => { +const availableTargets: (volume: Volume, device: StorageDevice | undefined) => VolumeTarget[] = (volume, device): VolumeTarget[] => { /** @type {VolumeTarget[]} */ - const targets = ["DEVICE"]; + const targets: VolumeTarget[] = [VolumeTarget.DEVICE]; if (device?.isDrive) { - targets.push("NEW_PARTITION"); - targets.push("NEW_VG"); + targets.push(VolumeTarget.NEW_PARTITION); + targets.push(VolumeTarget.NEW_VG); } if (device?.filesystem && volume.outline.fsTypes.includes(device.filesystem.type)) - targets.push("FILESYSTEM"); + targets.push(VolumeTarget.FILESYSTEM); return targets; }; /** @type {(volume: Volume, device: StorageDevice|undefined) => VolumeTarget} */ -const sanitizeTarget = (volume, device) => { +const sanitizeTarget: (volume: Volume, device: StorageDevice | undefined) => VolumeTarget = (volume, device): VolumeTarget => { const targets = availableTargets(volume, device); return targets.includes(volume.target) ? volume.target : defaultTarget(device); }; +export type VolumeLocationDialogProps = { + volume: Volume; + volumes: Volume[]; + volumeDevices: StorageDevice[]; + targetDevices: StorageDevice[]; + isOpen?: boolean; + onCancel: () => void; + onAccept: (volume: Volume) => void; +} + /** * Renders a dialog that allows the user to change the location of a volume. * @component - * - * @typedef {object} VolumeLocationDialogProps - * @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 - * - * @param {VolumeLocationDialogProps} props */ export default function VolumeLocationDialog({ volume, @@ -95,17 +89,17 @@ export default function VolumeLocationDialog({ onCancel, onAccept, ...props -}) { +}: VolumeLocationDialogProps) { /** @type {StorageDevice|undefined} */ - const initialDevice = volume.targetDevice || targetDevices[0] || volumeDevices[0]; + const initialDevice: StorageDevice | undefined = volume.targetDevice || targetDevices[0] || volumeDevices[0]; /** @type {VolumeTarget} */ - const initialTarget = sanitizeTarget(volume, initialDevice); + const initialTarget: VolumeTarget = sanitizeTarget(volume, initialDevice); const [target, setTarget] = useState(initialTarget); const [targetDevice, setTargetDevice] = useState(initialDevice); /** @type {(devices: StorageDevice[]) => void} */ - const changeTargetDevice = (devices) => { + const changeTargetDevice: (devices: StorageDevice[]) => void = (devices): void => { const newTargetDevice = devices[0]; if (newTargetDevice.name !== targetDevice.name) { @@ -115,14 +109,14 @@ export default function VolumeLocationDialog({ }; /** @type {(e: import("react").FormEvent) => void} */ - const onSubmit = (e) => { + const onSubmit: (e: import("react").FormEvent) => void = (e): void => { e.preventDefault(); const newVolume = { ...volume, target, targetDevice }; onAccept(newVolume); }; /** @type {(device: StorageDevice) => boolean} */ - const isDeviceSelectable = (device) => { + const isDeviceSelectable: (device: StorageDevice) => boolean = (device): boolean => { return device.isDrive || ["md", "partition", "lvmLv"].includes(device.type); }; @@ -169,9 +163,9 @@ export default function VolumeLocationDialog({ "The file system will be allocated as a new partition at the selected \ disk.", )} - isChecked={target === "NEW_PARTITION"} - isDisabled={!targets.includes("NEW_PARTITION")} - onChange={() => setTarget("NEW_PARTITION")} + isChecked={target === VolumeTarget.NEW_PARTITION} + isDisabled={!targets.includes(VolumeTarget.NEW_PARTITION)} + onChange={() => setTarget(VolumeTarget.NEW_PARTITION)} /> setTarget("NEW_VG")} + isChecked={target === VolumeTarget.NEW_VG} + isDisabled={!targets.includes(VolumeTarget.NEW_VG)} + onChange={() => setTarget(VolumeTarget.NEW_VG)} /> setTarget("DEVICE")} + isChecked={target === VolumeTarget.DEVICE} + isDisabled={!targets.includes(VolumeTarget.DEVICE)} + onChange={() => setTarget(VolumeTarget.DEVICE)} /> setTarget("FILESYSTEM")} + isChecked={target === VolumeTarget.FILESYSTEM} + isDisabled={!targets.includes(VolumeTarget.FILESYSTEM)} + onChange={() => setTarget(VolumeTarget.FILESYSTEM)} /> From 01bb419b465b2031fd381481a5564623e66443bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Thu, 5 Sep 2024 11:12:19 +0100 Subject: [PATCH 25/53] refactor(web): convert VolumeLocationSelectorTable to TypeScript --- ...le.jsx => VolumeLocationSelectorTable.tsx} | 49 ++++++------------- 1 file changed, 15 insertions(+), 34 deletions(-) rename web/src/components/storage/{VolumeLocationSelectorTable.jsx => VolumeLocationSelectorTable.tsx} (68%) diff --git a/web/src/components/storage/VolumeLocationSelectorTable.jsx b/web/src/components/storage/VolumeLocationSelectorTable.tsx similarity index 68% rename from web/src/components/storage/VolumeLocationSelectorTable.jsx rename to web/src/components/storage/VolumeLocationSelectorTable.tsx index 8859d7be23..8bcc318f14 100644 --- a/web/src/components/storage/VolumeLocationSelectorTable.jsx +++ b/web/src/components/storage/VolumeLocationSelectorTable.tsx @@ -19,11 +19,8 @@ * find current contact information at www.suse.com. */ -// @ts-check - import React from "react"; import { Chip, Split } from "@patternfly/react-core"; - import { _ } from "~/i18n"; import { DeviceName, @@ -32,25 +29,14 @@ import { toStorageDevice, } from "~/components/storage/device-utils"; import { ExpandableSelector } from "~/components/core"; - -/** - * @typedef {import ("~/components/core/ExpandableSelector").ExpandableSelectorColumn} ExpandableSelectorColumn - * @typedef {import ("~/components/core/ExpandableSelector").ExpandableSelectorProps} ExpandableSelectorProps - * @typedef {import ("~/client/storage").PartitionSlot} PartitionSlot - * @typedef {import ("~/client/storage").StorageDevice} StorageDevice - * @typedef {import ("~/client/storage").Volume} Volume - */ +import { ExpandableSelectorColumn, ExpandableSelectorProps } from "~/components/core/ExpandableSelector"; +import { PartitionSlot, StorageDevice, Volume } from "~/types/storage"; /** * Returns what (volumes, installation device) is using a device. * @function - * - * @param {PartitionSlot|StorageDevice} item - * @param {StorageDevice[]} targetDevices - * @param {Volume[]} volumes - * @returns {string[]} */ -const deviceUsers = (item, targetDevices, volumes) => { +const deviceUsers = (item: PartitionSlot | StorageDevice, targetDevices: StorageDevice[], volumes: Volume[]): string[] => { const device = toStorageDevice(item); if (!device) return []; @@ -65,11 +51,8 @@ const deviceUsers = (item, targetDevices, volumes) => { /** * @component - * - * @param {object} props - * @param {string[]} props.users */ -const DeviceUsage = ({ users }) => { +const DeviceUsage = ({ users }: { users: string[]; }) => { return ( {users.map((user, index) => ( @@ -81,19 +64,18 @@ const DeviceUsage = ({ users }) => { ); }; +type VolumeLocationSelectorTableBaseProps = { + devices: StorageDevice[]; + selectedDevices: StorageDevice[]; + targetDevices: StorageDevice[]; + volumes: Volume[]; +} + +export type VolumeLocationSelectorTableProps = VolumeLocationSelectorTableBaseProps & ExpandableSelectorProps; + /** * 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} VolumeLocationSelectorTableProps - * - * @param {VolumeLocationSelectorTableProps} props */ export default function VolumeLocationSelectorTable({ devices, @@ -101,9 +83,8 @@ export default function VolumeLocationSelectorTable({ targetDevices, volumes, ...props -}) { - /** @type {ExpandableSelectorColumn[]} */ - const columns = [ +}: VolumeLocationSelectorTableProps) { + const columns: ExpandableSelectorColumn[] = [ { name: _("Device"), value: (item) => }, { name: _("Details"), value: (item) => }, { From f1328ef390d8e2dea7ef3d3a37ec8af045851b16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Thu, 5 Sep 2024 11:14:47 +0100 Subject: [PATCH 26/53] chore(web): remove the @function tags * It is not needed as the parser correctly identifies those functions. --- .../storage/InstallationDeviceField.tsx | 1 - web/src/components/storage/VolumeDialog.tsx | 16 ---------------- web/src/components/storage/VolumeFields.tsx | 1 - .../storage/VolumeLocationSelectorTable.tsx | 1 - web/src/components/storage/device-utils.jsx | 1 - web/src/components/storage/utils.test.ts | 4 ++-- web/src/components/storage/utils.ts | 13 ------------- web/src/utils.js | 6 ------ 8 files changed, 2 insertions(+), 41 deletions(-) diff --git a/web/src/components/storage/InstallationDeviceField.tsx b/web/src/components/storage/InstallationDeviceField.tsx index 73a576a6d3..1437503c3e 100644 --- a/web/src/components/storage/InstallationDeviceField.tsx +++ b/web/src/components/storage/InstallationDeviceField.tsx @@ -36,7 +36,6 @@ const DESCRIPTION = _("Main disk or LVM Volume Group for installation."); /** * Generates the target value. - * @function */ const targetValue = (target: ProposalTarget, targetDevice: StorageDevice, targetPVDevices: StorageDevice[]): string => { if (target === ProposalTarget.DISK && targetDevice) { diff --git a/web/src/components/storage/VolumeDialog.tsx b/web/src/components/storage/VolumeDialog.tsx index 659d4a2a14..9405a621b1 100644 --- a/web/src/components/storage/VolumeDialog.tsx +++ b/web/src/components/storage/VolumeDialog.tsx @@ -65,7 +65,6 @@ type VolumeFormErrors = { /** * Renders the title for the dialog. - * @function */ const renderTitle = (volume: Volume, volumes: Volume[]): string => { const isNewVolume = !volumes.includes(volume); @@ -283,7 +282,6 @@ class ExistingTemplateError { /** * Error if the mount path is missing. - * @function */ const missingMountPathError = (mountPath: string): string | null => { const error = new MissingMountPathError(mountPath); @@ -292,7 +290,6 @@ const missingMountPathError = (mountPath: string): string | null => { /** * Error if the mount path is not valid. - * @function */ const invalidMountPathError = (mountPath: string): string | null => { const error = new InvalidMountPathError(mountPath); @@ -301,7 +298,6 @@ const invalidMountPathError = (mountPath: string): string | null => { /** * Error if the size is missing. - * @function */ const missingSizeError = (sizeMethod: SizeMethod, size: string | number): string | null => { const error = new MissingSizeError(sizeMethod, size); @@ -310,7 +306,6 @@ const missingSizeError = (sizeMethod: SizeMethod, size: string | number): string /** * Error if the min size is missing. - * @function */ const missingMinSizeError = (sizeMethod: SizeMethod, minSize: string | number): string | null => { const error = new MissingMinSizeError(sizeMethod, minSize); @@ -319,7 +314,6 @@ const missingMinSizeError = (sizeMethod: SizeMethod, minSize: string | number): /** * Error if the max size is not valid. - * @function */ const invalidMaxSizeError = (sizeMethod: SizeMethod, minSize: string | number, maxSize: string | number): string | null => { const error = new InvalidMaxSizeError(sizeMethod, minSize, maxSize); @@ -328,7 +322,6 @@ const invalidMaxSizeError = (sizeMethod: SizeMethod, minSize: string | number, m /** * Error if the given mount path exists in the list of volumes. - * @function */ const existingVolumeError = (mountPath: string, volumes: Volume[], onClick: (volume: Volume) => void): React.ReactElement | null => { const error = new ExistingVolumeError(mountPath, volumes); @@ -337,7 +330,6 @@ const existingVolumeError = (mountPath: string, volumes: Volume[], onClick: (vol /** * Error if the given mount path exists in the list of templates. - * @function */ const existingTemplateError = (mountPath: string, templates: Volume[], onClick: (template: Volume) => void): React.ReactElement | null => { const error = new ExistingTemplateError(mountPath, templates); @@ -346,7 +338,6 @@ const existingTemplateError = (mountPath: string, templates: Volume[], onClick: /** * Checks whether there is any error. - * @function */ const anyError = (errors: VolumeFormErrors): boolean => { return compact(Object.values(errors)).length > 0; @@ -354,7 +345,6 @@ const anyError = (errors: VolumeFormErrors): boolean => { /** * Remove leftover trailing slash. - * @function */ const sanitizeMountPath = (mountPath: string): string => { if (mountPath === "/") return mountPath; @@ -364,7 +354,6 @@ const sanitizeMountPath = (mountPath: string): string => { /** * Creates a new storage volume object based on given params. - * @function */ const createUpdatedVolume = (volume: Volume, formData: VolumeFormData): Volume => { let sizeAttrs = {}; @@ -391,7 +380,6 @@ const createUpdatedVolume = (volume: Volume, formData: VolumeFormData): Volume = /** * Form-related helper for guessing the size method for given volume - * @function */ const sizeMethodFor = (volume: Volume): SizeMethod => { const { autoSize, minSize, maxSize } = volume; @@ -407,7 +395,6 @@ const sizeMethodFor = (volume: Volume): SizeMethod => { /** * Form-related helper for preparing data based on given volume - * @function */ const prepareFormData = (volume: Volume): VolumeFormData => { const { size: minSize = "", unit: minSizeUnit = DEFAULT_SIZE_UNIT } = splitSize(volume.minSize); @@ -429,7 +416,6 @@ const prepareFormData = (volume: Volume): VolumeFormData => { /** * Possible errors from the form data. - * @function */ const prepareErrors = (): VolumeFormErrors => { return { @@ -445,7 +431,6 @@ const prepareErrors = (): VolumeFormErrors => { /** * Initializer function for the React#useReducer used in the {@link VolumesForm} - * @function * * @param volume - a storage volume object */ @@ -458,7 +443,6 @@ const createInitialState = (volume: Volume): VolumeFormState => { /** * The VolumeForm reducer. - * @function */ const reducer = (state: VolumeFormState, action: { type: string, payload: any }) => { const { type, payload } = action; diff --git a/web/src/components/storage/VolumeFields.tsx b/web/src/components/storage/VolumeFields.tsx index f8ff608f3c..48a8c65842 100644 --- a/web/src/components/storage/VolumeFields.tsx +++ b/web/src/components/storage/VolumeFields.tsx @@ -105,7 +105,6 @@ const SizeUnitFormSelect = ({ units, ...formSelectProps }: { units: Array { return volume.outline.fsTypes; diff --git a/web/src/components/storage/VolumeLocationSelectorTable.tsx b/web/src/components/storage/VolumeLocationSelectorTable.tsx index 8bcc318f14..1ad5c0f23d 100644 --- a/web/src/components/storage/VolumeLocationSelectorTable.tsx +++ b/web/src/components/storage/VolumeLocationSelectorTable.tsx @@ -34,7 +34,6 @@ import { PartitionSlot, StorageDevice, Volume } from "~/types/storage"; /** * Returns what (volumes, installation device) is using a device. - * @function */ const deviceUsers = (item: PartitionSlot | StorageDevice, targetDevices: StorageDevice[], volumes: Volume[]): string[] => { const device = toStorageDevice(item); diff --git a/web/src/components/storage/device-utils.jsx b/web/src/components/storage/device-utils.jsx index 5f98ba43c5..9c923c378e 100644 --- a/web/src/components/storage/device-utils.jsx +++ b/web/src/components/storage/device-utils.jsx @@ -34,7 +34,6 @@ import { deviceBaseName, deviceSize } from "~/components/storage/utils"; /** * Ensures the given item is a StorageDevice. - * @function * * @param {PartitionSlot|StorageDevice} item * @returns {StorageDevice|undefined} diff --git a/web/src/components/storage/utils.test.ts b/web/src/components/storage/utils.test.ts index 24545087e0..6380bcccf1 100644 --- a/web/src/components/storage/utils.test.ts +++ b/web/src/components/storage/utils.test.ts @@ -33,8 +33,8 @@ import { isTransactionalSystem, } from "./utils"; -/** Volume factory. - * @function +/** + * Volume factory. */ const volume = (properties: object = {}): Volume => { const testVolume: Volume = { diff --git a/web/src/components/storage/utils.ts b/web/src/components/storage/utils.ts index 21f6665cda..2fb2049e3a 100644 --- a/web/src/components/storage/utils.ts +++ b/web/src/components/storage/utils.ts @@ -111,7 +111,6 @@ const SPACE_POLICIES: SpacePolicy[] = [ /** * Convenience method for generating a size object based on given input - * @function * * It split given input when a string is given or the result of converting the * input otherwise. Note, however, that -1 number will treated as empty string @@ -137,7 +136,6 @@ const splitSize = (size: number | string | undefined): SizeObject => { /** * Generates a disk size representation - * @function * * @example * deviceSize(1024) @@ -153,7 +151,6 @@ const deviceSize = (size: number): string => { /** * Returns the equivalent in bytes resulting from parsing given input - * @function * * @example * parseToBytes(1024) @@ -176,7 +173,6 @@ const parseToBytes = (size: string | number): number => { /** * Base name of a device. - * @function */ const deviceBaseName = (device: StorageDevice): string => { return device.name.split("/").pop(); @@ -184,7 +180,6 @@ const deviceBaseName = (device: StorageDevice): string => { /** * Generates the label for the given device - * @function */ const deviceLabel = (device: StorageDevice): string => { const name = device.name; @@ -195,7 +190,6 @@ const deviceLabel = (device: StorageDevice): string => { /** * Sorted list of children devices (i.e., partitions and unused slots or logical volumes). - * @function * * @note This method could be directly provided by the device object. For now, the method is kept * here because the elements considered as children (e.g., partitions + unused slots) is not a @@ -219,7 +213,6 @@ const deviceChildren = (device: StorageDevice): (StorageDevice | PartitionSlot)[ /** * Checks if volume uses given fs. This method works same as in backend case insensitive. - * @function * * @param {Volume} volume * @param {string} fs - Filesystem name to check. @@ -233,7 +226,6 @@ const hasFS = (volume: Volume, fs: string): boolean => { /** * Checks whether the given volume has snapshots. - * @function */ const hasSnapshots = (volume: Volume): boolean => { return hasFS(volume, "btrfs") && volume.snapshots; @@ -241,7 +233,6 @@ const hasSnapshots = (volume: Volume): boolean => { /** * Checks whether the given volume defines a transactional root. - * @function */ const isTransactionalRoot = (volume: Volume): boolean => { return volume.mountPath === "/" && volume.transactional; @@ -249,7 +240,6 @@ const isTransactionalRoot = (volume: Volume): boolean => { /** * Checks whether the given volumes defines a transactional system. - * @function */ const isTransactionalSystem = (volumes: Volume[] = []): boolean => { return volumes.find((v) => isTransactionalRoot(v)) !== undefined; @@ -257,19 +247,16 @@ const isTransactionalSystem = (volumes: Volume[] = []): boolean => { /** * Checks whether the given volume is configured to mount an existing file system. - * @function */ const mountFilesystem = (volume: Volume): boolean => volume.target === "filesystem"; /** * Checks whether the given volume is configured to reuse a device (format or mount a file system). - * @function */ const reuseDevice = (volume: Volume): boolean => volume.target === "filesystem" || volume.target === "device"; /** * Generates a label for the given volume. - * @function */ const volumeLabel = (volume: Volume): string => (volume.mountPath === "/" ? "root" : volume.mountPath); diff --git a/web/src/utils.js b/web/src/utils.js index eeb05da1c0..fdb3526c9d 100644 --- a/web/src/utils.js +++ b/web/src/utils.js @@ -82,7 +82,6 @@ const partition = (collection, filter) => { /** * Generates a new array without null and undefined values. - * @function * * @param {Array} collection * @returns {Array} @@ -93,7 +92,6 @@ function compact(collection) { /** * Generates a new array without duplicates. - * @function * * @param {Array} collection * @returns {Array} @@ -248,7 +246,6 @@ const useLocalStorage = (storageKey, fallbackState) => { /** * Debounce hook. - * @function * * Source {@link https://designtechworld.medium.com/create-a-custom-debounce-hook-in-react-114f3f245260} * @@ -294,7 +291,6 @@ const hex = (value) => { /** * Converts an issue to a validation error - * @function * * @todo This conversion will not be needed after adapting Section to directly work with issues. * @@ -305,7 +301,6 @@ const toValidationError = (issue) => ({ message: issue.description }); /** * Wrapper around window.location.reload - * @function * * It's needed mainly to ease testing because we can't override window in jest with jsdom anymore * @@ -319,7 +314,6 @@ const locationReload = () => { /** * Wrapper around window.location.search setter - * @function * * It's needed mainly to ease testing as we can't override window in jest with jsdom anymore * From 0413927c1042f20f7a940014bb875e74d240d644 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Thu, 5 Sep 2024 11:18:00 +0100 Subject: [PATCH 27/53] chore(web): clean-up DeviceSelection component --- web/src/components/storage/DeviceSelection.tsx | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/web/src/components/storage/DeviceSelection.tsx b/web/src/components/storage/DeviceSelection.tsx index fb815dd822..8a709b8f2d 100644 --- a/web/src/components/storage/DeviceSelection.tsx +++ b/web/src/components/storage/DeviceSelection.tsx @@ -19,11 +19,7 @@ * find current contact information at www.suse.com. */ -// @ts-check - -// TODO: Improve it. - -import React, { useEffect, useState, useCallback } from "react"; +import React, { useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; import { Card, @@ -39,12 +35,10 @@ import a11y from "@patternfly/react-styles/css/utilities/Accessibility/accessibi import { _ } from "~/i18n"; import { deviceChildren } from "~/components/storage/utils"; -import { Loading } from "~/components/layout"; import { Page } from "~/components/core"; import { DeviceSelectorTable } from "~/components/storage"; import DevicesTechMenu from "./DevicesTechMenu"; -import { compact, useCancellablePromise } from "~/utils"; -import { useInstallerClient } from "~/context/installer"; +import { compact } from "~/utils"; import { useAvailableDevices, useProposalMutation, useProposalResult } from "~/queries/storage"; import { ProposalTarget, StorageDevice } from "~/types/storage"; From 881d506c9b575f10dd574abb6a8b118b6ed13155 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Thu, 5 Sep 2024 11:41:54 +0100 Subject: [PATCH 28/53] chore(web): convert SpacePolicySelection to TypeScript --- ...Selection.jsx => SpacePolicySelection.tsx} | 24 +++++++------------ 1 file changed, 8 insertions(+), 16 deletions(-) rename web/src/components/storage/{SpacePolicySelection.jsx => SpacePolicySelection.tsx} (90%) diff --git a/web/src/components/storage/SpacePolicySelection.jsx b/web/src/components/storage/SpacePolicySelection.tsx similarity index 90% rename from web/src/components/storage/SpacePolicySelection.jsx rename to web/src/components/storage/SpacePolicySelection.tsx index 9cf0e64e65..38e203a5f4 100644 --- a/web/src/components/storage/SpacePolicySelection.jsx +++ b/web/src/components/storage/SpacePolicySelection.tsx @@ -28,28 +28,21 @@ import { Loading } from "~/components/layout"; import { Page } from "~/components/core"; import { SpaceActionsTable } from "~/components/storage"; import { _ } from "~/i18n"; -import { SPACE_POLICIES } from "~/components/storage/utils"; +import { SPACE_POLICIES, SpacePolicy } from "~/components/storage/utils"; import { noop, useCancellablePromise } from "~/utils"; import { useInstallerClient } from "~/context/installer"; import textStyles from "@patternfly/react-styles/css/utilities/Text/text"; - -// FIXME: Improve and refactor - -/** - * @typedef {import ("~/client/storage").SpaceAction} SpaceAction - * @typedef {import ("~/components/storage/utils").SpacePolicy} SpacePolicy - * @typedef {import ("~/client/storage").StorageDevice} StorageDevice - */ +import { SpaceAction } from "~/types/storage"; /** * Widget to allow user picking desired policy to make space. * @component * - * @param {object} props - * @param {SpacePolicy} props.currentPolicy - * @param {(policy: SpacePolicy) => void} [props.onChange] + * @param props + * @param props.currentPolicy + * @param [props.onChange] */ -const SpacePolicyPicker = ({ currentPolicy, onChange = noop }) => { +const SpacePolicyPicker = ({ currentPolicy, onChange = noop }: { currentPolicy: SpacePolicy; onChange?: (policy: SpacePolicy) => void; }) => { return ( @@ -85,8 +78,7 @@ const SpacePolicyPicker = ({ currentPolicy, onChange = noop }) => { */ export default function SpacePolicySelection() { const [state, setState] = useState({ load: false, settings: {} }); - /** @type ReturnType> */ - const [policy, setPolicy] = useState(); + const [policy, setPolicy] = useState(); const [actions, setActions] = useState([]); const [expandedDevices, setExpandedDevices] = useState([]); const [customUsed, setCustomUsed] = useState(false); @@ -146,7 +138,7 @@ export default function SpacePolicySelection() { return policyAction[policy?.id]; }; - const changeActions = (spaceAction) => { + const changeActions = (spaceAction: SpaceAction) => { const spaceActions = actions.filter((a) => a.device !== spaceAction.device); if (spaceAction.action !== "keep") spaceActions.push(spaceAction); From f8cef05aaa2c8eaab8f2779d50529cec6ba69019 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Thu, 5 Sep 2024 11:42:30 +0100 Subject: [PATCH 29/53] fix(web): fix SpaceAction definition --- web/src/types/storage.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/types/storage.ts b/web/src/types/storage.ts index 3af5eff5c8..ca5f790dd9 100644 --- a/web/src/types/storage.ts +++ b/web/src/types/storage.ts @@ -114,7 +114,7 @@ type ProposalSettings = { type SpaceAction = { device: string; - action: 'force_delete' | 'resize' + action: 'force_delete' | 'resize' | 'keep' }; type Volume = { From a89816e2b5372e354c3b35fe76e0b300371ab23a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Thu, 5 Sep 2024 12:26:15 +0100 Subject: [PATCH 30/53] chore(web): disable accidentally enabled test --- web/src/components/storage/PartitionsField.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/components/storage/PartitionsField.test.tsx b/web/src/components/storage/PartitionsField.test.tsx index 2d34c13865..04225997b6 100644 --- a/web/src/components/storage/PartitionsField.test.tsx +++ b/web/src/components/storage/PartitionsField.test.tsx @@ -182,7 +182,7 @@ beforeEach(() => { }; }); -it("allows to reset the file systems", async () => { +it.skip("allows to reset the file systems", async () => { const { user } = await expandField(); const button = screen.getByRole("button", { name: "Reset to defaults" }); await user.click(button); From 512811cb64dd8664c1f4a26a45a4adb89359a8fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Thu, 5 Sep 2024 12:28:08 +0100 Subject: [PATCH 31/53] fix(web): update storage API types --- web/src/api/storage/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/api/storage/types.ts b/web/src/api/storage/types.ts index dde3b7576e..f697aaa461 100644 --- a/web/src/api/storage/types.ts +++ b/web/src/api/storage/types.ts @@ -272,7 +272,7 @@ export type ShrinkingInfo = { unsupported: Array<(string)>; }; -export type SpaceAction = 'force_delete' | 'resize'; +export type SpaceAction = 'force_delete' | 'resize' | 'keep'; export type SpaceActionSettings = { action: SpaceAction; From ab1f373b566d9c913c12928e4808f03170a8efc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Thu, 5 Sep 2024 12:29:11 +0100 Subject: [PATCH 32/53] refactor(web): drop storage client tests * The client is deprecated. Let's move to queries. --- web/src/client/storage.test.js | 2591 -------------------------------- 1 file changed, 2591 deletions(-) delete mode 100644 web/src/client/storage.test.js diff --git a/web/src/client/storage.test.js b/web/src/client/storage.test.js deleted file mode 100644 index 6b9d214d0a..0000000000 --- a/web/src/client/storage.test.js +++ /dev/null @@ -1,2591 +0,0 @@ -/* - * Copyright (c) [2022-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 -// cspell:ignore ECKD dasda ddgdcbibhd wwpns - -import { HTTPClient } from "./http"; -import DBusClient from "./dbus"; -import { StorageClient } from "./storage"; - -/** - * @typedef {import("~/client/storage").StorageDevice} StorageDevice - */ - -jest.mock("./dbus"); - -const cockpitProxies = {}; - -const cockpitCallbacks = {}; - -let managedObjects = {}; - -// System devices - -/** @type {StorageDevice} */ -const sda = { - sid: 59, - isDrive: true, - type: "disk", - vendor: "Micron", - model: "Micron 1100 SATA", - driver: ["ahci", "mmcblk"], - bus: "IDE", - busId: "", - transport: "usb", - dellBOSS: false, - sdCard: true, - active: true, - name: "/dev/sda", - description: "", - size: 1024, - start: 0, - encrypted: false, - shrinking: { unsupported: ["Resizing is not supported"] }, - systems: [], - udevIds: ["ata-Micron_1100_SATA_512GB_12563", "scsi-0ATA_Micron_1100_SATA_512GB"], - udevPaths: ["pci-0000:00-12", "pci-0000:00-12-ata"], -}; - -/** @type {StorageDevice} */ -const sda1 = { - sid: 60, - isDrive: false, - type: "partition", - active: true, - name: "/dev/sda1", - description: "", - size: 512, - start: 123, - encrypted: false, - shrinking: { unsupported: ["Resizing is not supported"] }, - systems: [], - udevIds: [], - udevPaths: [], - isEFI: true, -}; - -/** @type {StorageDevice} */ -const sda2 = { - sid: 61, - isDrive: false, - type: "partition", - active: true, - name: "/dev/sda2", - description: "", - size: 256, - start: 1789, - encrypted: false, - shrinking: { unsupported: ["Resizing is not supported"] }, - systems: [], - udevIds: [], - udevPaths: [], - isEFI: false, -}; - -/** @type {StorageDevice} */ -const sdb = { - sid: 62, - isDrive: true, - type: "disk", - vendor: "Samsung", - model: "Samsung Evo 8 Pro", - driver: ["ahci"], - bus: "IDE", - busId: "", - transport: "", - dellBOSS: false, - sdCard: false, - active: true, - name: "/dev/sdb", - description: "", - size: 2048, - start: 0, - encrypted: false, - shrinking: { unsupported: ["Resizing is not supported"] }, - systems: [], - udevIds: [], - udevPaths: ["pci-0000:00-19"], -}; - -/** @type {StorageDevice} */ -const sdc = { - sid: 63, - isDrive: true, - type: "disk", - vendor: "Disk", - model: "", - driver: [], - bus: "IDE", - busId: "", - transport: "", - dellBOSS: false, - sdCard: false, - active: true, - name: "/dev/sdc", - description: "", - size: 2048, - start: 0, - encrypted: false, - shrinking: { unsupported: ["Resizing is not supported"] }, - systems: [], - udevIds: [], - udevPaths: [], -}; - -/** @type {StorageDevice} */ -const sdd = { - sid: 64, - isDrive: true, - type: "disk", - vendor: "Disk", - model: "", - driver: [], - bus: "IDE", - busId: "", - transport: "", - dellBOSS: false, - sdCard: false, - active: true, - name: "/dev/sdd", - description: "", - size: 2048, - start: 0, - encrypted: false, - shrinking: { unsupported: ["Resizing is not supported"] }, - systems: [], - udevIds: [], - udevPaths: [], -}; - -/** @type {StorageDevice} */ -const sde = { - sid: 65, - isDrive: true, - type: "disk", - vendor: "Disk", - model: "", - driver: [], - bus: "IDE", - busId: "", - transport: "", - dellBOSS: false, - sdCard: false, - active: true, - name: "/dev/sde", - description: "", - size: 2048, - start: 0, - encrypted: false, - shrinking: { unsupported: ["Resizing is not supported"] }, - systems: [], - udevIds: [], - udevPaths: [], -}; - -/** @type {StorageDevice} */ -const md0 = { - sid: 66, - isDrive: false, - type: "md", - level: "raid0", - uuid: "12345:abcde", - active: true, - name: "/dev/md0", - description: "EXT4 RAID", - size: 2048, - start: 0, - encrypted: false, - shrinking: { unsupported: ["Resizing is not supported"] }, - devices: [], - systems: ["openSUSE Leap 15.2"], - udevIds: [], - udevPaths: [], - filesystem: { sid: 100, type: "ext4", mountPath: "/test", label: "system" }, -}; - -/** @type {StorageDevice} */ -const raid = { - sid: 67, - isDrive: true, - type: "raid", - vendor: "Dell", - model: "Dell BOSS-N1 Modular", - driver: [], - bus: "", - busId: "", - transport: "", - dellBOSS: true, - sdCard: false, - active: true, - name: "/dev/mapper/isw_ddgdcbibhd_244", - description: "", - size: 2048, - start: 0, - encrypted: false, - shrinking: { unsupported: ["Resizing is not supported"] }, - devices: [], - systems: [], - udevIds: [], - udevPaths: [], -}; - -/** @type {StorageDevice} */ -const multipath = { - sid: 68, - isDrive: true, - type: "multipath", - vendor: "", - model: "", - driver: [], - bus: "", - busId: "", - transport: "", - dellBOSS: false, - sdCard: false, - active: true, - name: "/dev/mapper/36005076305ffc73a00000000000013b4", - description: "", - size: 2048, - start: 0, - encrypted: false, - shrinking: { unsupported: ["Resizing is not supported"] }, - systems: [], - udevIds: [], - udevPaths: [], -}; - -/** @type {StorageDevice} */ -const dasd = { - sid: 69, - isDrive: true, - type: "dasd", - vendor: "IBM", - model: "IBM", - driver: [], - bus: "", - busId: "0.0.0150", - transport: "", - dellBOSS: false, - sdCard: false, - active: true, - name: "/dev/dasda", - description: "", - size: 2048, - start: 0, - encrypted: false, - shrinking: { unsupported: ["Resizing is not supported"] }, - systems: [], - udevIds: [], - udevPaths: [], -}; - -/** @type {StorageDevice} */ -const sdf = { - sid: 70, - isDrive: true, - type: "disk", - vendor: "Disk", - model: "", - driver: [], - bus: "IDE", - busId: "", - transport: "", - dellBOSS: false, - sdCard: false, - active: true, - name: "/dev/sdf", - description: "", - size: 2048, - start: 0, - encrypted: false, - shrinking: { unsupported: ["Resizing is not supported"] }, - systems: [], - udevIds: [], - udevPaths: [], -}; - -/** @type {StorageDevice} */ -const sdf1 = { - sid: 71, - isDrive: false, - type: "partition", - active: true, - name: "/dev/sdf1", - description: "PV of vg0", - size: 512, - start: 1024, - encrypted: true, - shrinking: { unsupported: ["Resizing is not supported"] }, - systems: [], - udevIds: [], - udevPaths: [], - isEFI: false, -}; - -/** @type {StorageDevice} */ -const lvmVg = { - sid: 72, - isDrive: false, - type: "lvmVg", - name: "/dev/vg0", - description: "LVM", - size: 512, -}; - -/** @type {StorageDevice} */ -const lvmLv1 = { - sid: 73, - isDrive: false, - type: "lvmLv", - active: true, - name: "/dev/vg0/lv1", - description: "", - size: 512, - start: 0, - encrypted: false, - shrinking: { supported: 128 }, - systems: [], - udevIds: [], - udevPaths: [], -}; - -// Define relationship between devices - -sda.partitionTable = { - type: "gpt", - partitions: [sda1, sda2], - unpartitionedSize: 256, - unusedSlots: [{ start: 1234, size: 256 }], -}; - -sda1.component = { - type: "md_device", - deviceNames: ["/dev/md0"], -}; - -sda2.component = { - type: "md_device", - deviceNames: ["/dev/md0"], -}; - -sdb.component = { - type: "raid_device", - deviceNames: ["/dev/mapper/isw_ddgdcbibhd_244"], -}; - -sdc.component = { - type: "raid_device", - deviceNames: ["/dev/mapper/isw_ddgdcbibhd_244"], -}; - -sdd.component = { - type: "multipath_wire", - deviceNames: ["/dev/mapper/36005076305ffc73a00000000000013b4"], -}; - -sde.component = { - type: "multipath_wire", - deviceNames: ["/dev/mapper/36005076305ffc73a00000000000013b4"], -}; - -sdf.partitionTable = { - type: "gpt", - partitions: [sdf1], - unpartitionedSize: 1536, - unusedSlots: [], -}; - -sdf1.component = { - type: "physical_volume", - deviceNames: ["/dev/vg0"], -}; - -md0.devices = [sda1, sda2]; - -raid.devices = [ - { - sid: 0, - name: "/dev/sdb", - description: "", - isDrive: false, - type: "", - }, - { - sid: 0, - name: "/dev/sdc", - description: "", - isDrive: false, - type: "", - }, -]; - -multipath.wires = [ - { - sid: 0, - name: "/dev/sdd", - description: "", - isDrive: false, - type: "", - }, - { - sid: 0, - name: "/dev/sde", - description: "", - isDrive: false, - type: "", - }, -]; - -lvmVg.logicalVolumes = [lvmLv1]; -lvmVg.physicalVolumes = [sdf1]; - -const systemDevices = { - sda, - sda1, - sda2, - sdb, - sdc, - sdd, - sde, - md0, - raid, - multipath, - dasd, - sdf, - sdf1, - lvmVg, - lvmLv1, -}; - -// Staging devices -// -// Using a single device because most of the checks are already done with system devices. - -/** @type {StorageDevice} */ -const sdbStaging = { - sid: 62, - isDrive: true, - type: "disk", - vendor: "Samsung", - model: "Samsung Evo 8 Pro", - driver: ["ahci"], - bus: "IDE", - busId: "", - transport: "", - dellBOSS: false, - sdCard: false, - active: true, - name: "/dev/sdb", - description: "", - size: 2048, - start: 0, - encrypted: false, - shrinking: { unsupported: ["Resizing is not supported"] }, - systems: [], - udevIds: [], - udevPaths: ["pci-0000:00-19"], -}; - -const stagingDevices = { sdb: sdbStaging }; - -const contexts = { - withProposal: () => { - return { - settings: { - target: "newLvmVg", - targetPVDevices: ["/dev/sda", "/dev/sdb"], - configureBoot: true, - bootDevice: "/dev/sda", - defaultBootDevice: "/dev/sdb", - encryptionPassword: "00000", - encryptionMethod: "luks1", - spacePolicy: "custom", - spaceActions: [ - { device: "/dev/sda", action: "force_delete" }, - { device: "/dev/sdb", action: "resize" }, - ], - volumes: [ - { - mountPath: "/", - target: "default", - targetDevice: "", - fsType: "Btrfs", - minSize: 1024, - maxSize: 2048, - autoSize: true, - snapshots: true, - transactional: true, - outline: { - required: true, - fsTypes: ["Btrfs", "Ext3"], - supportAutoSize: true, - snapshotsConfigurable: true, - snapshotsAffectSizes: true, - adjustByRam: false, - sizeRelevantVolumes: ["/home"], - }, - }, - { - mountPath: "/home", - target: "default", - targetDevice: "", - fsType: "XFS", - minSize: 2048, - maxSize: 4096, - autoSize: false, - snapshots: false, - transactional: false, - outline: { - required: false, - fsTypes: ["Ext4", "XFS"], - supportAutoSize: false, - snapshotsConfigurable: false, - snapshotsAffectSizes: false, - adjustByRam: false, - sizeRelevantVolumes: [], - }, - }, - ], - }, - actions: [{ device: 2, text: "Mount /dev/sdb1 as root", subvol: false, delete: false }], - }; - }, - withAvailableDevices: () => [59, 62], - withIssues: () => [ - { description: "Issue 1", details: "", source: 1, severity: 1 }, - { description: "Issue 2", details: "", source: 1, severity: 0 }, - { description: "Issue 3", details: "", source: 2, severity: 1 }, - ], - withoutISCSINodes: () => { - cockpitProxies.iscsiNodes = {}; - }, - withISCSINodes: () => [ - { - id: 1, - target: "iqn.2023-01.com.example:37dac", - address: "192.168.100.101", - port: 3260, - interface: "default", - ibft: false, - connected: false, - startup: "", - }, - { - id: 2, - target: "iqn.2023-01.com.example:74afb", - address: "192.168.100.102", - port: 3260, - interface: "default", - ibft: true, - connected: true, - startup: "onboot", - }, - ], - withoutDASDDevices: () => { - cockpitProxies.dasdDevices = {}; - }, - withDASDDevices: () => { - cockpitProxies.dasdDevices = { - "/org/opensuse/Agama/Storage1/dasds/8": { - path: "/org/opensuse/Agama/Storage1/dasds/8", - AccessType: "", - DeviceName: "dasd_sample_8", - Diag: false, - Enabled: true, - Formatted: false, - Id: "0.0.019e", - PartitionInfo: "", - Type: "ECKD", - }, - "/org/opensuse/Agama/Storage1/dasds/9": { - path: "/org/opensuse/Agama/Storage1/dasds/9", - AccessType: "rw", - DeviceName: "dasd_sample_9", - Diag: false, - Enabled: true, - Formatted: false, - Id: "0.0.ffff", - PartitionInfo: "/dev/dasd_sample_9", - Type: "FBA", - }, - }; - }, - withoutZFCPControllers: () => { - cockpitProxies.zfcpControllers = {}; - }, - withZFCPControllers: () => { - cockpitProxies.zfcpControllers = { - "/org/opensuse/Agama/Storage1/zfcp_controllers/1": { - path: "/org/opensuse/Agama/Storage1/zfcp_controllers/1", - Active: false, - LUNScan: false, - Channel: "0.0.fa00", - }, - "/org/opensuse/Agama/Storage1/zfcp_controllers/2": { - path: "/org/opensuse/Agama/Storage1/zfcp_controllers/2", - Active: false, - LUNScan: false, - Channel: "0.0.fc00", - }, - }; - }, - withoutZFCPDisks: () => { - cockpitProxies.zfcpDisks = {}; - }, - withZFCPDisks: () => { - cockpitProxies.zfcpDisks = { - "/org/opensuse/Agama/Storage1/zfcp_disks/1": { - path: "/org/opensuse/Agama/Storage1/zfcp_disks/1", - Name: "/dev/sda", - Channel: "0.0.fa00", - WWPN: "0x500507630703d3b3", - LUN: "0x0000000000000000", - }, - "/org/opensuse/Agama/Storage1/zfcp_disks/2": { - path: "/org/opensuse/Agama/Storage1/zfcp_disks/2", - Name: "/dev/sdb", - Channel: "0.0.fa00", - WWPN: "0x500507630703d3b3", - LUN: "0x0000000000000001", - }, - }; - }, - withSystemDevices: () => [ - { - deviceInfo: { - sid: 59, - name: "/dev/sda", - description: "", - }, - blockDevice: { - active: true, - encrypted: false, - size: 1024, - start: 0, - shrinking: { unsupported: ["Resizing is not supported"] }, - systems: [], - udevIds: ["ata-Micron_1100_SATA_512GB_12563", "scsi-0ATA_Micron_1100_SATA_512GB"], - udevPaths: ["pci-0000:00-12", "pci-0000:00-12-ata"], - }, - drive: { - type: "disk", - vendor: "Micron", - model: "Micron 1100 SATA", - driver: ["ahci", "mmcblk"], - bus: "IDE", - busId: "", - transport: "usb", - info: { - dellBOSS: false, - sdCard: true, - }, - }, - partitionTable: { - type: "gpt", - partitions: [60, 61], - unusedSlots: [{ start: 1234, size: 256 }], - }, - }, - { - deviceInfo: { - sid: 60, - name: "/dev/sda1", - description: "", - }, - partition: { efi: true }, - blockDevice: { - active: true, - encrypted: false, - size: 512, - start: 123, - shrinking: { unsupported: ["Resizing is not supported"] }, - systems: [], - udevIds: [], - udevPaths: [], - }, - component: { - type: "md_device", - deviceNames: ["/dev/md0"], - devices: [66], - }, - }, - { - deviceInfo: { - sid: 61, - name: "/dev/sda2", - description: "", - }, - partition: { efi: false }, - blockDevice: { - active: true, - encrypted: false, - size: 256, - start: 1789, - shrinking: { unsupported: ["Resizing is not supported"] }, - systems: [], - udevIds: [], - udevPaths: [], - }, - component: { - type: "md_device", - deviceNames: ["/dev/md0"], - devices: [66], - }, - }, - { - deviceInfo: { - sid: 62, - name: "/dev/sdb", - description: "", - }, - blockDevice: { - active: true, - encrypted: false, - size: 2048, - start: 0, - shrinking: { unsupported: ["Resizing is not supported"] }, - systems: [], - udevIds: [], - udevPaths: ["pci-0000:00-19"], - }, - drive: { - type: "disk", - vendor: "Samsung", - model: "Samsung Evo 8 Pro", - driver: ["ahci"], - bus: "IDE", - busId: "", - transport: "", - info: { - dellBOSS: false, - sdCard: false, - }, - }, - component: { - type: "raid_device", - deviceNames: ["/dev/mapper/isw_ddgdcbibhd_244"], - devices: [67], - }, - }, - { - deviceInfo: { - sid: 63, - name: "/dev/sdc", - description: "", - }, - blockDevice: { - active: true, - encrypted: false, - size: 2048, - start: 0, - shrinking: { unsupported: ["Resizing is not supported"] }, - systems: [], - udevIds: [], - udevPaths: [], - }, - drive: { - type: "disk", - vendor: "Disk", - model: "", - driver: [], - bus: "IDE", - busId: "", - transport: "", - info: { - dellBOSS: false, - sdCard: false, - }, - }, - component: { - type: "raid_device", - deviceNames: ["/dev/mapper/isw_ddgdcbibhd_244"], - devices: [67], - }, - }, - { - deviceInfo: { - sid: 64, - name: "/dev/sdd", - description: "", - }, - blockDevice: { - active: true, - encrypted: false, - size: 2048, - start: 0, - shrinking: { unsupported: ["Resizing is not supported"] }, - systems: [], - udevIds: [], - udevPaths: [], - }, - drive: { - type: "disk", - vendor: "Disk", - model: "", - driver: [], - bus: "IDE", - busId: "", - transport: "", - info: { - dellBOSS: false, - sdCard: false, - }, - }, - component: { - type: "multipath_wire", - deviceNames: ["/dev/mapper/36005076305ffc73a00000000000013b4"], - devices: [68], - }, - }, - { - deviceInfo: { - sid: 65, - name: "/dev/sde", - description: "", - }, - blockDevice: { - active: true, - encrypted: false, - size: 2048, - start: 0, - shrinking: { unsupported: ["Resizing is not supported"] }, - systems: [], - udevIds: [], - udevPaths: [], - }, - drive: { - type: "disk", - vendor: "Disk", - model: "", - driver: [], - bus: "IDE", - busId: "", - transport: "", - info: { - dellBOSS: false, - sdCard: false, - }, - }, - component: { - type: "multipath_wire", - deviceNames: ["/dev/mapper/36005076305ffc73a00000000000013b4"], - devices: [68], - }, - }, - { - deviceInfo: { - sid: 66, - name: "/dev/md0", - description: "EXT4 RAID", - }, - blockDevice: { - active: true, - encrypted: false, - size: 2048, - start: 0, - shrinking: { unsupported: ["Resizing is not supported"] }, - systems: ["openSUSE Leap 15.2"], - udevIds: [], - udevPaths: [], - }, - md: { - level: "raid0", - uuid: "12345:abcde", - devices: [60, 61], - }, - filesystem: { - sid: 100, - type: "ext4", - mountPath: "/test", - label: "system", - }, - }, - { - deviceInfo: { - sid: 67, - name: "/dev/mapper/isw_ddgdcbibhd_244", - description: "", - }, - blockDevice: { - active: true, - encrypted: false, - size: 2048, - start: 0, - shrinking: { unsupported: ["Resizing is not supported"] }, - systems: [], - udevIds: [], - udevPaths: [], - }, - drive: { - type: "raid", - vendor: "Dell", - model: "Dell BOSS-N1 Modular", - driver: [], - bus: "", - busId: "", - transport: "", - info: { - dellBOSS: true, - sdCard: false, - }, - }, - raid: { - devices: ["/dev/sdb", "/dev/sdc"], - }, - }, - { - deviceInfo: { - sid: 68, - name: "/dev/mapper/36005076305ffc73a00000000000013b4", - description: "", - }, - blockDevice: { - active: true, - encrypted: false, - size: 2048, - start: 0, - shrinking: { unsupported: ["Resizing is not supported"] }, - systems: [], - udevIds: [], - udevPaths: [], - }, - drive: { - type: "multipath", - vendor: "", - model: "", - driver: [], - bus: "", - busId: "", - transport: "", - info: { - dellBOSS: false, - sdCard: false, - }, - }, - multipath: { - wires: ["/dev/sdd", "/dev/sde"], - }, - }, - { - deviceInfo: { - sid: 69, - name: "/dev/dasda", - description: "", - }, - blockDevice: { - active: true, - encrypted: false, - size: 2048, - start: 0, - shrinking: { unsupported: ["Resizing is not supported"] }, - systems: [], - udevIds: [], - udevPaths: [], - }, - drive: { - type: "dasd", - vendor: "IBM", - model: "IBM", - driver: [], - bus: "", - busId: "0.0.0150", - transport: "", - info: { - dellBOSS: false, - sdCard: false, - }, - }, - }, - { - deviceInfo: { - sid: 70, - name: "/dev/sdf", - description: "", - }, - blockDevice: { - active: true, - encrypted: false, - size: 2048, - start: 0, - shrinking: { unsupported: ["Resizing is not supported"] }, - systems: [], - udevIds: [], - udevPaths: [], - }, - drive: { - type: "disk", - vendor: "Disk", - model: "", - driver: [], - bus: "IDE", - busId: "", - transport: "", - info: { - dellBOSS: false, - sdCard: false, - }, - }, - partitionTable: { - type: "gpt", - partitions: [71], - unusedSlots: [], - }, - }, - { - deviceInfo: { - sid: 71, - name: "/dev/sdf1", - description: "PV of vg0", - }, - partition: { efi: false }, - blockDevice: { - active: true, - encrypted: true, - size: 512, - start: 1024, - shrinking: { unsupported: ["Resizing is not supported"] }, - systems: [], - udevIds: [], - udevPaths: [], - }, - component: { - type: "physical_volume", - deviceNames: ["/dev/vg0"], - devices: [72], - }, - }, - { - deviceInfo: { - sid: 72, - name: "/dev/vg0", - description: "LVM", - }, - lvmVg: { - type: "physical_volume", - size: 512, - physicalVolumes: [71], - logicalVolumes: [73], - }, - }, - { - deviceInfo: { - sid: 73, - name: "/dev/vg0/lv1", - description: "", - }, - blockDevice: { - active: true, - encrypted: false, - size: 512, - start: 0, - shrinking: { supported: 128 }, - systems: [], - udevIds: [], - udevPaths: [], - }, - lvmLv: { - volumeGroup: [72], - }, - }, - ], - withStagingDevices: () => [ - { - deviceInfo: { - sid: 62, - name: "/dev/sdb", - description: "", - }, - drive: { - type: "disk", - vendor: "Samsung", - model: "Samsung Evo 8 Pro", - driver: ["ahci"], - bus: "IDE", - busId: "", - transport: "", - info: { - dellBOSS: false, - sdCard: false, - }, - }, - blockDevice: { - active: true, - encrypted: false, - size: 2048, - start: 0, - shrinking: { unsupported: ["Resizing is not supported"] }, - systems: [], - udevIds: [], - udevPaths: ["pci-0000:00-19"], - }, - }, - ], -}; - -const mockProxy = (iface, path) => { - switch (iface) { - case "org.opensuse.Agama.Storage1.ISCSI.Initiator": - return cockpitProxies.iscsiInitiator; - case "org.opensuse.Agama.Storage1.ISCSI.Node": - return cockpitProxies.iscsiNode[path]; - case "org.opensuse.Agama.Storage1.DASD.Manager": - return cockpitProxies.dasdManager; - case "org.opensuse.Agama.Storage1.ZFCP.Manager": - return cockpitProxies.zfcpManager; - case "org.opensuse.Agama.Storage1.ZFCP.Controller": - return cockpitProxies.zfcpController[path]; - } -}; - -const mockProxies = (iface) => { - switch (iface) { - case "org.opensuse.Agama.Storage1.ISCSI.Node": - return cockpitProxies.iscsiNodes; - case "org.opensuse.Agama.Storage1.DASD.Device": - return cockpitProxies.dasdDevices; - case "org.opensuse.Agama.Storage1.ZFCP.Controller": - return cockpitProxies.zfcpControllers; - case "org.opensuse.Agama.Storage1.ZFCP.Disk": - return cockpitProxies.zfcpDisks; - } -}; - -const mockOnObjectChanged = (path, iface, handler) => { - if (!cockpitCallbacks[path]) cockpitCallbacks[path] = {}; - cockpitCallbacks[path][iface] = handler; -}; - -const emitSignal = (path, iface, data) => { - if (!cockpitCallbacks[path]) return; - - const handler = cockpitCallbacks[path][iface]; - if (!handler) return; - - return handler(data); -}; - -const mockCall = (_path, iface, method) => { - if (iface === "org.freedesktop.DBus.ObjectManager" && method === "GetManagedObjects") - return [managedObjects]; -}; - -const reset = () => { - cockpitProxies.iscsiInitiator = {}; - cockpitProxies.iscsiNodes = {}; - cockpitProxies.iscsiNode = {}; - cockpitProxies.dasdManager = {}; - cockpitProxies.dasdDevices = {}; - cockpitProxies.zfcpManager = {}; - cockpitProxies.zfcpControllers = {}; - cockpitProxies.zfcpDisks = {}; - cockpitProxies.zfcpController = {}; - managedObjects = {}; -}; - -let mockJsonFn; -let mockGetFn; -let mockPostFn; -let mockPutFn; -let mockDeleteFn; -let mockPatchFn; -let mockHTTPClient; -let http; - -jest.mock("./http", () => { - return { - HTTPClient: jest.fn().mockImplementation(() => mockHTTPClient), - }; -}); - -beforeEach(() => { - reset(); - - // @ts-ignore - DBusClient.mockImplementation(() => { - return { - proxy: mockProxy, - proxies: mockProxies, - onObjectChanged: mockOnObjectChanged, - call: mockCall, - }; - }); - - mockJsonFn = jest.fn(); - mockGetFn = jest.fn().mockImplementation(() => { - return { ok: true, json: mockJsonFn }; - }); - mockPostFn = jest.fn().mockImplementation(() => { - return { ok: true }; - }); - mockPutFn = jest.fn().mockImplementation(() => { - return { ok: true }; - }); - mockDeleteFn = jest.fn().mockImplementation(() => { - return { - ok: true, - }; - }); - mockPatchFn = jest.fn().mockImplementation(() => { - return { ok: true }; - }); - - mockHTTPClient = { - get: mockGetFn, - patch: mockPatchFn, - post: mockPostFn, - put: mockPutFn, - delete: mockDeleteFn, - }; - - http = new HTTPClient(new URL("http://localhost")); -}); - -let client; - -describe("#probe", () => { - beforeEach(() => { - client = new StorageClient(http); - }); - - it("probes the system", async () => { - await client.probe(); - expect(mockPostFn).toHaveBeenCalledWith("/storage/probe"); - }); -}); - -describe("#isDeprecated", () => { - describe("if the system is deprecated", () => { - beforeEach(() => { - mockJsonFn.mockResolvedValue(true); - client = new StorageClient(http); - }); - - it("returns true", async () => { - const result = await client.isDeprecated(); - expect(result).toEqual(true); - }); - }); - - describe("if the system is not deprecated", () => { - beforeEach(() => { - mockGetFn.mockResolvedValue(false); - client = new StorageClient(http); - }); - - it("returns false", async () => { - const result = await client.isDeprecated(); - expect(result).toEqual(false); - }); - }); - - describe("when the HTTP call fails", () => { - beforeEach(() => { - mockGetFn.mockImplementation((path) => { - if (path === "/storage/devices/dirty") return { ok: false, json: undefined }; - else return { ok: true, json: mockJsonFn }; - }); - - client = new StorageClient(http); - }); - - it("returns false", async () => { - const result = await client.isDeprecated(); - expect(result).toEqual(false); - }); - }); -}); - -// @fixme We need to rethink signals mocking, now that we switched from DBus to HTTP -describe.skip("#onDeprecate", () => { - const handler = jest.fn(); - - beforeEach(() => { - client = new StorageClient(); - client.onDeprecate(handler); - }); - - describe("if the system was not deprecated", () => { - beforeEach(() => { - emitSignal("/org/opensuse/Agama/Storage1", "org.opensuse.Agama.Storage1", {}); - }); - - it("does not run the handler", async () => { - expect(handler).not.toHaveBeenCalled(); - }); - }); - - describe("if the system was deprecated", () => { - beforeEach(() => { - emitSignal("/org/opensuse/Agama/Storage1", "org.opensuse.Agama.Storage1", { - DeprecatedSystem: true, - }); - }); - - it("runs the handler", async () => { - expect(handler).not.toHaveBeenCalled(); - }); - }); -}); - -describe("#system", () => { - describe("#getDevices", () => { - beforeEach(() => { - client = new StorageClient(http); - }); - - describe("when there are devices", () => { - beforeEach(() => { - mockJsonFn.mockResolvedValue(contexts.withSystemDevices()); - }); - - it("returns the system devices", async () => { - const devices = await client.system.getDevices(); - expect(devices).toEqual(Object.values(systemDevices)); - }); - }); - - describe("when there are not devices", () => { - beforeEach(() => { - mockJsonFn.mockResolvedValue([]); - }); - - it("returns an empty list", async () => { - const devices = await client.system.getDevices(); - expect(devices).toEqual([]); - }); - }); - - describe("when the HTTP call fails", () => { - beforeEach(() => { - mockGetFn.mockImplementation((path) => { - if (path === "/storage/devices/system") return { ok: false, json: undefined }; - else return { ok: true, json: mockJsonFn }; - }); - - client = new StorageClient(http); - }); - - it("returns an empty list", async () => { - const devices = await client.system.getDevices(); - expect(devices).toEqual([]); - }); - }); - }); -}); - -describe("#staging", () => { - describe("#getDevices", () => { - beforeEach(() => { - client = new StorageClient(http); - }); - - describe("when there are devices", () => { - beforeEach(() => { - mockJsonFn.mockResolvedValue(contexts.withStagingDevices()); - }); - - it("returns the staging devices", async () => { - const devices = await client.staging.getDevices(); - expect(devices).toEqual(Object.values(stagingDevices)); - }); - }); - - describe("when there are not devices", () => { - beforeEach(() => { - mockJsonFn.mockResolvedValue([]); - }); - - it("returns an empty list", async () => { - const devices = await client.staging.getDevices(); - expect(devices).toEqual([]); - }); - }); - - describe("when the HTTP call fails", () => { - beforeEach(() => { - mockGetFn.mockImplementation((path) => { - if (path === "/storage/devices/result") return { ok: false, json: undefined }; - else return { ok: true, json: mockJsonFn }; - }); - - client = new StorageClient(http); - }); - - it("returns an empty list", async () => { - const devices = await client.staging.getDevices(); - expect(devices).toEqual([]); - }); - }); - }); -}); - -describe("#proposal", () => { - describe("#getAvailableDevices", () => { - let response; - - beforeEach(() => { - response = { ok: true, json: jest.fn().mockResolvedValue(contexts.withAvailableDevices()) }; - - mockGetFn.mockImplementation((path) => { - switch (path) { - case "/storage/devices/system": - return { ok: true, json: jest.fn().mockResolvedValue(contexts.withSystemDevices()) }; - case "/storage/proposal/usable_devices": - return response; - default: - return { ok: true, json: mockJsonFn }; - } - }); - - client = new StorageClient(http); - }); - - it("returns the list of available devices", async () => { - const availableDevices = await client.proposal.getAvailableDevices(); - expect(availableDevices).toEqual([systemDevices.sda, systemDevices.sdb]); - }); - - describe("when the HTTP call fails", () => { - beforeEach(() => { - response = { ok: false, json: undefined }; - }); - - it("returns an empty list", async () => { - const availableDevices = await client.proposal.getAvailableDevices(); - expect(availableDevices).toEqual([]); - }); - }); - }); - - describe("#getProductMountPoints", () => { - beforeEach(() => { - mockJsonFn.mockResolvedValue({ mountPoints: ["/", "swap", "/home"] }); - client = new StorageClient(http); - }); - - it("returns the list of product mount points", async () => { - const mount_points = await client.proposal.getProductMountPoints(); - expect(mount_points).toEqual(["/", "swap", "/home"]); - }); - - describe("when the HTTP call fails", () => { - beforeEach(() => { - mockGetFn.mockImplementation((path) => { - if (path === "/storage/product/params") return { ok: false, json: undefined }; - else return { ok: true, json: mockJsonFn }; - }); - - client = new StorageClient(http); - }); - - it("returns an empty list", async () => { - const mount_points = await client.proposal.getProductMountPoints(); - expect(mount_points).toEqual([]); - }); - }); - }); - - describe("#getEncryptionMethods", () => { - beforeEach(() => { - mockJsonFn.mockResolvedValue({ encryptionMethods: ["luks1", "luks2"] }); - client = new StorageClient(http); - }); - - it("returns the list of encryption methods", async () => { - const encryptionMethods = await client.proposal.getEncryptionMethods(); - expect(encryptionMethods).toEqual(["luks1", "luks2"]); - }); - - describe("when the HTTP call fails", () => { - beforeEach(() => { - mockGetFn.mockImplementation((path) => { - if (path === "/storage/product/params") return { ok: false, json: undefined }; - else return { ok: true, json: mockJsonFn }; - }); - - client = new StorageClient(http); - }); - - it("returns an empty list", async () => { - const encryptionMethods = await client.proposal.getEncryptionMethods(); - expect(encryptionMethods).toEqual([]); - }); - }); - }); - - describe("#defaultVolume", () => { - let response; - - beforeEach(() => { - response = (path) => { - const param = path.split("=")[1]; - switch (param) { - case "%2Fhome": - return { - ok: true, - json: jest.fn().mockResolvedValue({ - mountPath: "/home", - target: "default", - targetDevice: "", - fsType: "XFS", - minSize: 2048, - maxSize: 4096, - autoSize: false, - snapshots: false, - transactional: false, - outline: { - required: false, - fsTypes: ["Ext4", "XFS"], - supportAutoSize: false, - snapshotsConfigurable: false, - snapshotsAffectSizes: false, - adjustByRam: false, - sizeRelevantVolumes: [], - }, - }), - }; - default: - return { - ok: true, - json: jest.fn().mockResolvedValue({ - mountPath: "", - target: "default", - targetDevice: "", - fsType: "Ext4", - minSize: 1024, - maxSize: 2048, - autoSize: false, - snapshots: false, - transactional: false, - outline: { - required: false, - fsTypes: ["Ext4", "XFS"], - supportAutoSize: false, - snapshotsConfigurable: false, - snapshotsAffectSizes: false, - adjustByRam: false, - sizeRelevantVolumes: [], - }, - }), - }; - } - }; - - mockGetFn.mockImplementation((path) => { - switch (path) { - case "/storage/devices/system": - return { ok: true, json: jest.fn().mockResolvedValue(contexts.withSystemDevices()) }; - case "/storage/product/params": - return { - ok: true, - json: jest.fn().mockResolvedValue({ mountPoints: ["/", "swap", "/home"] }), - }; - // GET for /storage/product/volume_for?path=XX - default: - return response(path); - } - }); - - client = new StorageClient(http); - }); - - it("returns the default volume for the given path", async () => { - const home = await client.proposal.defaultVolume("/home"); - - expect(home).toStrictEqual({ - mountPath: "/home", - target: "DEFAULT", - targetDevice: undefined, - fsType: "XFS", - minSize: 2048, - maxSize: 4096, - autoSize: false, - snapshots: false, - transactional: false, - outline: { - required: false, - fsTypes: ["Ext4", "XFS"], - supportAutoSize: false, - snapshotsConfigurable: false, - snapshotsAffectSizes: false, - adjustByRam: false, - sizeRelevantVolumes: [], - productDefined: true, - }, - }); - - const generic = await client.proposal.defaultVolume(""); - - expect(generic).toStrictEqual({ - mountPath: "", - target: "DEFAULT", - targetDevice: undefined, - fsType: "Ext4", - minSize: 1024, - maxSize: 2048, - autoSize: false, - snapshots: false, - transactional: false, - outline: { - required: false, - fsTypes: ["Ext4", "XFS"], - supportAutoSize: false, - snapshotsConfigurable: false, - snapshotsAffectSizes: false, - adjustByRam: false, - sizeRelevantVolumes: [], - productDefined: false, - }, - }); - }); - - describe("when then HTTP call fails", () => { - beforeEach(() => { - response = () => ({ ok: false, json: undefined }); - }); - - it("returns undefined", async () => { - const volume = await client.proposal.defaultVolume("/home"); - expect(volume).toBeUndefined(); - }); - }); - }); - - describe("#getResult", () => { - beforeEach(() => { - client = new StorageClient(http); - }); - - describe("if there is no proposal yet", () => { - beforeEach(() => { - mockGetFn.mockImplementation(() => { - return { ok: false }; - }); - }); - - it("returns undefined", async () => { - const result = await client.proposal.getResult(); - expect(result).toBe(undefined); - }); - }); - - describe("if there is a proposal", () => { - beforeEach(() => { - const proposal = contexts.withProposal(); - mockJsonFn.mockResolvedValue(proposal.settings); - - mockGetFn.mockImplementation((path) => { - switch (path) { - case "/storage/devices/system": - return { ok: true, json: jest.fn().mockResolvedValue(contexts.withSystemDevices()) }; - case "/storage/proposal/settings": - return { ok: true, json: mockJsonFn }; - case "/storage/proposal/actions": - return { ok: true, json: jest.fn().mockResolvedValue(proposal.actions) }; - case "/storage/product/params": - return { - ok: true, - json: jest.fn().mockResolvedValue({ mountPoints: ["/", "swap"] }), - }; - } - }); - }); - - it("returns the proposal settings and actions", async () => { - const { settings, actions } = await client.proposal.getResult(); - - expect(settings).toMatchObject({ - target: "NEW_LVM_VG", - targetPVDevices: ["/dev/sda", "/dev/sdb"], - configureBoot: true, - bootDevice: "/dev/sda", - defaultBootDevice: "/dev/sdb", - encryptionPassword: "00000", - spacePolicy: "custom", - spaceActions: [ - { device: "/dev/sda", action: "force_delete" }, - { device: "/dev/sdb", action: "resize" }, - ], - volumes: [ - { - mountPath: "/", - target: "DEFAULT", - targetDevice: undefined, - fsType: "Btrfs", - minSize: 1024, - maxSize: 2048, - autoSize: true, - snapshots: true, - transactional: true, - outline: { - required: true, - fsTypes: ["Btrfs", "Ext3"], - supportAutoSize: true, - snapshotsConfigurable: true, - snapshotsAffectSizes: true, - sizeRelevantVolumes: ["/home"], - productDefined: true, - }, - }, - { - mountPath: "/home", - target: "DEFAULT", - targetDevice: undefined, - fsType: "XFS", - minSize: 2048, - maxSize: 4096, - autoSize: false, - snapshots: false, - transactional: false, - outline: { - required: false, - fsTypes: ["Ext4", "XFS"], - supportAutoSize: false, - snapshotsConfigurable: false, - snapshotsAffectSizes: false, - sizeRelevantVolumes: [], - productDefined: false, - }, - }, - ], - }); - - expect(settings.installationDevices.map((d) => d.name).sort()).toStrictEqual( - ["/dev/sda", "/dev/sdb"].sort(), - ); - - expect(actions).toStrictEqual([ - { device: 2, text: "Mount /dev/sdb1 as root", subvol: false, delete: false }, - ]); - }); - - describe("if boot is not configured", () => { - beforeEach(() => { - mockJsonFn.mockResolvedValue({ - ...contexts.withProposal().settings, - configureBoot: false, - bootDevice: "/dev/sdc", - }); - }); - - it("does not include the boot device as installation device", async () => { - const { settings } = await client.proposal.getResult(); - expect(settings.installationDevices).toEqual([sda, sdb]); - }); - }); - }); - }); - - describe("#calculate", () => { - let response = { ok: true, json: jest.fn().mockResolvedValue(true) }; - - beforeEach(() => { - mockPutFn.mockImplementation((path) => { - if (path === "/storage/proposal/settings") return response; - - return { ok: true }; - }); - - client = new StorageClient(http); - }); - - it("calculates a default proposal when no settings are given", async () => { - await client.proposal.calculate({}); - expect(mockPutFn).toHaveBeenCalledWith("/storage/proposal/settings", {}); - }); - - it("calculates a proposal with the given settings", async () => { - await client.proposal.calculate({ - target: "DISK", - targetDevice: "/dev/vdc", - configureBoot: true, - bootDevice: "/dev/vdb", - encryptionPassword: "12345", - spacePolicy: "custom", - spaceActions: [{ device: "/dev/sda", action: "resize" }], - volumes: [ - { - mountPath: "/test1", - fsType: "Btrfs", - minSize: 1024, - maxSize: 2048, - autoSize: false, - snapshots: true, - }, - { - mountPath: "/test2", - minSize: 1024, - }, - ], - }); - - expect(mockPutFn).toHaveBeenCalledWith("/storage/proposal/settings", { - target: "disk", - targetDevice: "/dev/vdc", - configureBoot: true, - bootDevice: "/dev/vdb", - encryptionPassword: "12345", - spacePolicy: "custom", - spaceActions: [{ device: "/dev/sda", action: "resize" }], - volumes: [ - { - mountPath: "/test1", - fsType: "Btrfs", - minSize: 1024, - maxSize: 2048, - autoSize: false, - snapshots: true, - }, - { - mountPath: "/test2", - minSize: 1024, - }, - ], - }); - }); - - it("calculates a proposal without space actions if the policy is not custom", async () => { - await client.proposal.calculate({ - spacePolicy: "delete", - spaceActions: [{ device: "/dev/sda", action: "resize" }], - }); - - expect(mockPutFn).toHaveBeenCalledWith("/storage/proposal/settings", { - spacePolicy: "delete", - }); - }); - - it("returns false if the call fails", async () => { - response = { ok: false, json: undefined }; - - const result = await client.proposal.calculate({}); - expect(result).toEqual(false); - }); - - it("returns false if a proposal was not calculated", async () => { - response = { ok: true, json: jest.fn().mockResolvedValue(false) }; - - const result = await client.proposal.calculate({}); - expect(result).toEqual(false); - }); - - it("returns true if a proposal was calculated", async () => { - response = { ok: true, json: jest.fn().mockResolvedValue(true) }; - - const result = await client.proposal.calculate({}); - expect(result).toEqual(true); - }); - }); -}); - -describe.skip("#dasd", () => { - const sampleDasdDevice = { - id: "8", - accessType: "", - channelId: "0.0.019e", - diag: false, - enabled: true, - formatted: false, - hexId: 414, - name: "sample_dasd_device", - partitionInfo: "", - type: "ECKD", - }; - - const probeFn = jest.fn(); - const setDiagFn = jest.fn(); - const enableFn = jest.fn(); - const disableFn = jest.fn(); - - beforeEach(() => { - client = new StorageClient(); - cockpitProxies.dasdManager = { - Probe: probeFn, - SetDiag: setDiagFn, - Enable: enableFn, - Disable: disableFn, - }; - contexts.withDASDDevices(); - }); - - describe("#getDevices", () => { - it("triggers probing", async () => { - await client.dasd.getDevices(); - expect(probeFn).toHaveBeenCalled(); - }); - - describe("if there is no exported DASD devices yet", () => { - beforeEach(() => { - contexts.withoutDASDDevices(); - }); - - it("returns an empty list", async () => { - const result = await client.dasd.getDevices(); - expect(result).toStrictEqual([]); - }); - }); - - describe("if there are exported DASD devices", () => { - it("returns a list with the exported DASD devices", async () => { - const result = await client.dasd.getDevices(); - expect(result.length).toEqual(2); - expect(result).toContainEqual({ - id: "8", - accessType: "", - channelId: "0.0.019e", - diag: false, - enabled: true, - formatted: false, - hexId: 414, - name: "dasd_sample_8", - partitionInfo: "", - type: "ECKD", - }); - expect(result).toContainEqual({ - id: "9", - accessType: "rw", - channelId: "0.0.ffff", - diag: false, - enabled: true, - formatted: false, - hexId: 65535, - name: "dasd_sample_9", - partitionInfo: "/dev/dasd_sample_9", - type: "FBA", - }); - }); - }); - }); - - describe("#setDIAG", () => { - it("requests for setting DIAG for given devices", async () => { - await client.dasd.setDIAG([sampleDasdDevice], true); - expect(setDiagFn).toHaveBeenCalledWith(["/org/opensuse/Agama/Storage1/dasds/8"], true); - - await client.dasd.setDIAG([sampleDasdDevice], false); - expect(setDiagFn).toHaveBeenCalledWith(["/org/opensuse/Agama/Storage1/dasds/8"], false); - }); - }); - - describe("#enableDevices", () => { - it("requests for enabling given devices", async () => { - await client.dasd.enableDevices([sampleDasdDevice]); - expect(enableFn).toHaveBeenCalledWith(["/org/opensuse/Agama/Storage1/dasds/8"]); - }); - }); - - describe("#disableDevices", () => { - it("requests for disabling given devices", async () => { - await client.dasd.disableDevices([sampleDasdDevice]); - expect(disableFn).toHaveBeenCalledWith(["/org/opensuse/Agama/Storage1/dasds/8"]); - }); - }); -}); - -describe.skip("#zfcp", () => { - const probeFn = jest.fn(); - let controllersCallbacks; - let disksCallbacks; - - const mockEventListener = (proxy, callbacks) => { - proxy.addEventListener = jest.fn().mockImplementation((signal, handler) => { - if (!callbacks[signal]) callbacks[signal] = []; - callbacks[signal].push(handler); - }); - - proxy.removeEventListener = jest.fn(); - }; - - const emitSignals = (callbacks, signal, proxy) => { - callbacks[signal].forEach((handler) => handler(null, proxy)); - }; - - beforeEach(() => { - client = new StorageClient(); - cockpitProxies.zfcpManager = { - Probe: probeFn, - AllowLUNScan: true, - }; - - controllersCallbacks = {}; - mockEventListener(cockpitProxies.zfcpControllers, controllersCallbacks); - - disksCallbacks = {}; - mockEventListener(cockpitProxies.zfcpDisks, disksCallbacks); - }); - - describe("#isSupported", () => { - describe("if zFCP manager is available", () => { - it("returns true", async () => { - const result = await client.zfcp.isSupported(); - expect(result).toEqual(true); - }); - }); - - describe("if zFCP manager is not available", () => { - beforeEach(() => { - cockpitProxies.zfcpManager = undefined; - }); - - it("returns false", async () => { - const result = await client.zfcp.isSupported(); - expect(result).toEqual(false); - }); - }); - }); - - describe("#getAllowLUNScan", () => { - it("returns whether allow_lun_scan is active", async () => { - const result = await client.zfcp.getAllowLUNScan(); - expect(result).toEqual(true); - }); - - describe("if zFCP manager is not available", () => { - beforeEach(() => { - cockpitProxies.zfcpManager = undefined; - }); - - it("returns undefined", async () => { - const result = await client.zfcp.getAllowLUNScan(); - expect(result).toBeUndefined(); - }); - }); - }); - - describe("#probe", () => { - it("triggers probing", async () => { - await client.zfcp.probe(); - expect(probeFn).toHaveBeenCalled(); - }); - - describe("if zFCP manager is not available", () => { - beforeEach(() => { - cockpitProxies.zfcpManager = undefined; - }); - - it("returns undefined", async () => { - const result = await client.zfcp.probe(); - expect(result).toBeUndefined(); - }); - }); - }); - - describe("#getControllers", () => { - describe("if there is no exported zFCP controllers yet", () => { - beforeEach(() => { - contexts.withoutZFCPControllers(); - }); - - it("returns an empty list", async () => { - const result = await client.zfcp.getControllers(); - expect(result).toStrictEqual([]); - }); - }); - - describe("if there are exported ZFCP controllers", () => { - beforeEach(() => { - contexts.withZFCPControllers(); - }); - - it("returns a list with the exported ZFCP controllers", async () => { - const result = await client.zfcp.getControllers(); - expect(result.length).toEqual(2); - expect(result).toContainEqual({ - id: "1", - active: false, - lunScan: false, - channel: "0.0.fa00", - }); - expect(result).toContainEqual({ - id: "2", - active: false, - lunScan: false, - channel: "0.0.fc00", - }); - }); - }); - }); - - describe("#getDisks", () => { - describe("if there is no exported zFCP disks yet", () => { - beforeEach(() => { - contexts.withoutZFCPDisks(); - }); - - it("returns an empty list", async () => { - const result = await client.zfcp.getDisks(); - expect(result).toStrictEqual([]); - }); - }); - - describe("if there are exported ZFCP disks", () => { - beforeEach(() => { - contexts.withZFCPDisks(); - }); - - it("returns a list with the exported ZFCP disks", async () => { - const result = await client.zfcp.getDisks(); - expect(result.length).toEqual(2); - expect(result).toContainEqual({ - id: "1", - name: "/dev/sda", - channel: "0.0.fa00", - wwpn: "0x500507630703d3b3", - lun: "0x0000000000000000", - }); - expect(result).toContainEqual({ - id: "2", - name: "/dev/sdb", - channel: "0.0.fa00", - wwpn: "0x500507630703d3b3", - lun: "0x0000000000000001", - }); - }); - }); - }); - - describe("#getWWPNs", () => { - const wwpns = ["0x500507630703d3b3", "0x500507630708d3b3"]; - - const controllerProxy = { - GetWWPNs: jest.fn().mockReturnValue(wwpns), - }; - - beforeEach(() => { - cockpitProxies.zfcpController = { - "/org/opensuse/Agama/Storage1/zfcp_controllers/1": controllerProxy, - }; - }); - - it("returns a list with the WWPNs of the zFCP controller", async () => { - const result = await client.zfcp.getWWPNs({ id: "1" }); - expect(result).toStrictEqual(wwpns); - }); - - describe("if there is no proxy", () => { - beforeEach(() => { - cockpitProxies.zfcpController = {}; - }); - - it("returns undefined", async () => { - const result = await client.zfcp.getWWPNs({ id: "1" }); - expect(result).toBeUndefined(); - }); - }); - }); - - describe("#getLUNs", () => { - const luns = { - "0x500507630703d3b3": ["0x0000000000000000", "0x0000000000000001", "0x0000000000000002"], - }; - - const controllerProxy = { - GetLUNs: jest.fn().mockImplementation((wwpn) => luns[wwpn]), - }; - - beforeEach(() => { - cockpitProxies.zfcpController = { - "/org/opensuse/Agama/Storage1/zfcp_controllers/1": controllerProxy, - }; - }); - - it("returns a list with the LUNs for a WWPN of the zFCP controller", async () => { - const result = await client.zfcp.getLUNs({ id: "1" }, "0x500507630703d3b3"); - expect(result).toStrictEqual(luns["0x500507630703d3b3"]); - }); - - describe("if there is no proxy", () => { - beforeEach(() => { - cockpitProxies.zfcpController = {}; - }); - - it("returns undefined", async () => { - const result = await client.zfcp.getLUNs({ id: "1" }, "0x500507630703d3b3"); - expect(result).toBeUndefined(); - }); - }); - }); - - describe("#activateController", () => { - const activateFn = jest.fn().mockReturnValue(0); - - const controllerProxy = { - Activate: activateFn, - }; - - beforeEach(() => { - cockpitProxies.zfcpController = { - "/org/opensuse/Agama/Storage1/zfcp_controllers/1": controllerProxy, - }; - }); - - it("tries to activate the given zFCP controller", async () => { - const result = await client.zfcp.activateController({ id: "1" }); - expect(activateFn).toHaveBeenCalled(); - expect(result).toEqual(0); - }); - - describe("if there is no proxy", () => { - beforeEach(() => { - cockpitProxies.zfcpController = {}; - }); - - it("returns undefined", async () => { - const result = await client.zfcp.activateController({ id: "1" }); - expect(result).toBeUndefined(); - }); - }); - }); - - describe("#activateDisk", () => { - const activateDiskFn = jest.fn().mockReturnValue(0); - - const controllerProxy = { - ActivateDisk: activateDiskFn, - }; - - beforeEach(() => { - cockpitProxies.zfcpController = { - "/org/opensuse/Agama/Storage1/zfcp_controllers/1": controllerProxy, - }; - }); - - it("tries to activate the given zFCP disk", async () => { - const result = await client.zfcp.activateDisk( - { id: "1" }, - "0x500507630703d3b3", - "0x0000000000000000", - ); - expect(activateDiskFn).toHaveBeenCalledWith("0x500507630703d3b3", "0x0000000000000000"); - expect(result).toEqual(0); - }); - - describe("if there is no proxy", () => { - beforeEach(() => { - cockpitProxies.zfcpController = {}; - }); - - it("returns undefined", async () => { - const result = await client.zfcp.activateDisk( - { id: "1" }, - "0x500507630703d3b3", - "0x0000000000000000", - ); - expect(result).toBeUndefined(); - }); - }); - }); - - describe("#deactivateDisk", () => { - const deactivateDiskFn = jest.fn().mockReturnValue(0); - - const controllerProxy = { - ActivateDisk: deactivateDiskFn, - }; - - beforeEach(() => { - cockpitProxies.zfcpController = { - "/org/opensuse/Agama/Storage1/zfcp_controllers/1": controllerProxy, - }; - }); - - it("tries to deactivate the given zFCP disk", async () => { - const result = await client.zfcp.activateDisk( - { id: "1" }, - "0x500507630703d3b3", - "0x0000000000000000", - ); - expect(deactivateDiskFn).toHaveBeenCalledWith("0x500507630703d3b3", "0x0000000000000000"); - expect(result).toEqual(0); - }); - - describe("if there is no proxy", () => { - beforeEach(() => { - cockpitProxies.zfcpController = {}; - }); - - it("returns undefined", async () => { - const result = await client.zfcp.deactivateDisk( - { id: "1" }, - "0x500507630703d3b3", - "0x0000000000000000", - ); - expect(result).toBeUndefined(); - }); - }); - }); - - describe("#onControllerChanged", () => { - it("runs the handler when a zFCP controller changes", async () => { - const handler = jest.fn(); - await client.zfcp.onControllerChanged(handler); - - emitSignals(controllersCallbacks, "changed", { - path: "/org/opensuse/Agama/Storage1/zfcp_controllers/1", - Active: true, - LUNScan: true, - Channel: "0.0.fa00", - }); - - expect(handler).toHaveBeenCalledWith({ - id: "1", - active: true, - lunScan: true, - channel: "0.0.fa00", - }); - }); - }); - - describe("#onDiskAdded", () => { - it("runs the handler when a zFCP disk is added", async () => { - const handler = jest.fn(); - await client.zfcp.onDiskAdded(handler); - - emitSignals(disksCallbacks, "added", { - path: "/org/opensuse/Agama/Storage1/zfcp_disks/1", - Name: "/dev/sda", - Channel: "0.0.fa00", - WWPN: "0x500507630703d3b3", - LUN: "0x0000000000000000", - }); - - expect(handler).toHaveBeenCalledWith({ - id: "1", - name: "/dev/sda", - channel: "0.0.fa00", - wwpn: "0x500507630703d3b3", - lun: "0x0000000000000000", - }); - }); - }); - - describe("#onDiskChanged", () => { - it("runs the handler when a zFCP disk changes", async () => { - const handler = jest.fn(); - await client.zfcp.onDiskChanged(handler); - - emitSignals(disksCallbacks, "changed", { - path: "/org/opensuse/Agama/Storage1/zfcp_disks/1", - Name: "/dev/sda", - Channel: "0.0.fa00", - WWPN: "0x500507630703d3b3", - LUN: "0x0000000000000000", - }); - - expect(handler).toHaveBeenCalledWith({ - id: "1", - name: "/dev/sda", - channel: "0.0.fa00", - wwpn: "0x500507630703d3b3", - lun: "0x0000000000000000", - }); - }); - }); - - describe("#onDiskRemoved", () => { - it("runs the handler when a zFCP disk is removed", async () => { - const handler = jest.fn(); - await client.zfcp.onDiskRemoved(handler); - - emitSignals(disksCallbacks, "removed", { - path: "/org/opensuse/Agama/Storage1/zfcp_disks/1", - Name: "/dev/sda", - Channel: "0.0.fa00", - WWPN: "0x500507630703d3b3", - LUN: "0x0000000000000000", - }); - - expect(handler).toHaveBeenCalledWith({ - id: "1", - name: "/dev/sda", - channel: "0.0.fa00", - wwpn: "0x500507630703d3b3", - lun: "0x0000000000000000", - }); - }); - }); -}); - -describe("#iscsi", () => { - beforeEach(() => { - client = new StorageClient(new HTTPClient(new URL("http://localhost"))); - }); - - describe("#getInitiator", () => { - beforeEach(() => { - mockGetFn.mockResolvedValue({ ok: true, json: mockJsonFn }); - mockJsonFn.mockResolvedValue({ - name: "iqn.1996-04.com.suse:01:351e6d6249", - ibft: false, - }); - }); - - it("returns the current initiator", async () => { - const { name, ibft } = await client.iscsi.getInitiator(); - expect(name).toEqual("iqn.1996-04.com.suse:01:351e6d6249"); - expect(ibft).toEqual(false); - }); - - describe("when the HTTP call fails", () => { - beforeEach(() => { - mockGetFn.mockResolvedValue({ ok: false, json: undefined }); - }); - - it("returns undefined", async () => { - const initiator = await client.iscsi.getInitiator(); - expect(initiator).toBeUndefined(); - }); - }); - }); - - describe("#setInitiatorName", () => { - beforeEach(() => { - cockpitProxies.iscsiInitiator = { - InitiatorName: "iqn.1996-04.com.suse:01:351e6d6249", - }; - }); - - it("sets the given initiator name", async () => { - await client.iscsi.setInitiatorName("test"); - expect(mockPatchFn).toHaveBeenCalledWith("/storage/iscsi/initiator", { name: "test" }); - }); - }); - - describe("#getNodes", () => { - describe("if there is no exported iSCSI nodes yet", () => { - beforeEach(() => { - mockJsonFn.mockResolvedValue([]); - }); - - it("returns an empty list", async () => { - const result = await client.iscsi.getNodes(); - expect(result).toStrictEqual([]); - }); - }); - - describe("if there are exported iSCSI nodes", () => { - beforeEach(() => { - mockJsonFn.mockResolvedValue(contexts.withISCSINodes()); - }); - - it("returns a list with the exported iSCSI nodes", async () => { - const result = await client.iscsi.getNodes(); - expect(result.length).toEqual(2); - expect(result).toContainEqual({ - id: 1, - target: "iqn.2023-01.com.example:37dac", - address: "192.168.100.101", - port: 3260, - interface: "default", - ibft: false, - connected: false, - startup: "", - }); - expect(result).toContainEqual({ - id: 2, - target: "iqn.2023-01.com.example:74afb", - address: "192.168.100.102", - port: 3260, - interface: "default", - ibft: true, - connected: true, - startup: "onboot", - }); - }); - - describe("when the HTTP call fails", () => { - beforeEach(() => { - mockGetFn.mockResolvedValue({ ok: false, json: undefined }); - }); - - it("returns an empty list", async () => { - const result = await client.iscsi.getNodes(); - expect(result).toStrictEqual([]); - }); - }); - }); - }); - - describe("#discover", () => { - it("performs an iSCSI discovery with the given options", async () => { - const options = { - username: "test", - password: "12345", - reverseUsername: "target", - reversePassword: "nonsecret", - }; - await client.iscsi.discover("192.168.100.101", 3260, options); - expect(mockPostFn).toHaveBeenCalledWith("/storage/iscsi/discover", { - address: "192.168.100.101", - port: 3260, - options, - }); - }); - }); - - describe("#delete", () => { - it("deletes the given iSCSI node", async () => { - await client.iscsi.delete({ id: "1" }); - expect(mockDeleteFn).toHaveBeenCalledWith("/storage/iscsi/nodes/1"); - }); - }); - - describe("#login", () => { - const auth = { - username: "test", - password: "12345", - reverseUsername: "target", - reversePassword: "nonsecret", - startup: "automatic", - }; - - it("performs an iSCSI login with the given options", async () => { - const result = await client.iscsi.login({ id: "1" }, auth); - - expect(result).toEqual(0); - expect(mockPostFn).toHaveBeenCalledWith("/storage/iscsi/nodes/1/login", auth); - }); - - it("returns 1 when the startup is invalid", async () => { - mockPostFn.mockImplementation(() => ({ ok: false, json: mockJsonFn })); - mockJsonFn.mockResolvedValue("InvalidStartup"); - - const result = await client.iscsi.login({ id: "1" }, { ...auth, startup: "invalid" }); - expect(result).toEqual(1); - }); - - it("returns 2 in case of an error different from an invalid startup value", async () => { - mockPostFn.mockImplementation(() => ({ ok: false, json: mockJsonFn })); - mockJsonFn.mockResolvedValue("Failed"); - - const result = await client.iscsi.login({ id: "1" }, { ...auth, startup: "invalid" }); - expect(result).toEqual(2); - }); - }); - - describe("#logout", () => { - it("performs an iSCSI logout of the given node", async () => { - await client.iscsi.logout({ id: "1" }); - expect(mockPostFn).toHaveBeenCalledWith("/storage/iscsi/nodes/1/logout"); - }); - }); -}); From 6893e350e6d2697f51fe340f9c18b40aecedf9eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Thu, 5 Sep 2024 12:38:49 +0100 Subject: [PATCH 33/53] refactor(web): convert BootSelection to TypeScript --- ...ection.test.jsx => BootSelection.test.tsx} | 14 +++----- .../{BootSelection.jsx => BootSelection.tsx} | 32 ++++++++----------- 2 files changed, 18 insertions(+), 28 deletions(-) rename web/src/components/storage/{BootSelection.test.jsx => BootSelection.test.tsx} (97%) rename web/src/components/storage/{BootSelection.jsx => BootSelection.tsx} (92%) diff --git a/web/src/components/storage/BootSelection.test.jsx b/web/src/components/storage/BootSelection.test.tsx similarity index 97% rename from web/src/components/storage/BootSelection.test.jsx rename to web/src/components/storage/BootSelection.test.tsx index 9b65e98c75..e34db368ec 100644 --- a/web/src/components/storage/BootSelection.test.jsx +++ b/web/src/components/storage/BootSelection.test.tsx @@ -25,13 +25,9 @@ import React from "react"; import { screen, within } from "@testing-library/react"; import { plainRender } from "~/test-utils"; import BootSelection from "./BootSelection"; +import { StorageDevice } from "~/types/storage"; -/** - * @typedef {import ("~/client/storage").StorageDevice} StorageDevice - */ - -/** @type {StorageDevice} */ -const sda = { +const sda: StorageDevice = { sid: 59, isDrive: true, type: "disk", @@ -53,8 +49,7 @@ const sda = { udevPaths: ["pci-0000:00-12", "pci-0000:00-12-ata"], }; -/** @type {StorageDevice} */ -const sdb = { +const sdb: StorageDevice = { sid: 62, isDrive: true, type: "disk", @@ -76,8 +71,7 @@ const sdb = { udevPaths: ["pci-0000:00-19"], }; -/** @type {StorageDevice} */ -const sdc = { +const sdc: StorageDevice = { sid: 63, isDrive: true, type: "disk", diff --git a/web/src/components/storage/BootSelection.jsx b/web/src/components/storage/BootSelection.tsx similarity index 92% rename from web/src/components/storage/BootSelection.jsx rename to web/src/components/storage/BootSelection.tsx index 17bdeac125..e70788af68 100644 --- a/web/src/components/storage/BootSelection.jsx +++ b/web/src/components/storage/BootSelection.tsx @@ -33,14 +33,11 @@ import { sprintf } from "sprintf-js"; import { useCancellablePromise } from "~/utils"; import { useInstallerClient } from "~/context/installer"; import textStyles from "@patternfly/react-styles/css/utilities/Text/text"; +import { StorageDevice } from "~/types/storage"; // FIXME: improve classNames // FIXME: improve and rename to BootSelectionDialog -/** - * @typedef {import ("~/client/storage").StorageDevice} StorageDevice - */ - const BOOT_AUTO_ID = "boot-auto"; const BOOT_MANUAL_ID = "boot-manual"; const BOOT_DISABLED_ID = "boot-disabled"; @@ -49,19 +46,18 @@ const BOOT_DISABLED_ID = "boot-disabled"; * Allows the user to select the boot configuration. */ export default function BootSelectionDialog() { - /** - * @typedef {object} BootSelectionState - * @property {boolean} load - * @property {string} [selectedOption] - * @property {boolean} [configureBoot] - * @property {StorageDevice} [bootDevice] - * @property {StorageDevice} [defaultBootDevice] - * @property {StorageDevice[]} [availableDevices] - */ + type BootSelectionState = { + load: boolean; + selectedOption?: string; + configureBoot?: boolean; + bootDevice?: StorageDevice; + defaultBootDevice?: StorageDevice; + availableDevices?: StorageDevice[]; + } + const { cancellablePromise } = useCancellablePromise(); const { storage: client } = useInstallerClient(); - /** @type ReturnType> */ - const [state, setState] = useState({ load: false }); + const [state, setState] = useState({ load: false }); const navigate = useNavigate(); // FIXME: Repeated code, see DeviceSelection. Use a context/hook or whatever @@ -78,9 +74,9 @@ export default function BootSelectionDialog() { if (state.load) return; const load = async () => { - let selectedOption; + let selectedOption: string; const { settings } = await loadProposalResult(); - const availableDevices = await loadAvailableDevices(); + const availableDevices: StorageDevice[] = await loadAvailableDevices(); const { bootDevice, configureBoot, defaultBootDevice } = settings; if (!configureBoot) { @@ -91,7 +87,7 @@ export default function BootSelectionDialog() { selectedOption = BOOT_MANUAL_ID; } - const findDevice = (name) => availableDevices.find((d) => d.name === name); + const findDevice = (name: string) => availableDevices.find((d) => d.name === name); setState({ load: true, From 8552ddfc2473a145cb4b548dac9e644d9864f192 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Thu, 5 Sep 2024 13:35:52 +0100 Subject: [PATCH 34/53] chore(web): remove storage client tests --- web/src/client/storage.test.js | 2396 -------------------------------- 1 file changed, 2396 deletions(-) delete mode 100644 web/src/client/storage.test.js diff --git a/web/src/client/storage.test.js b/web/src/client/storage.test.js deleted file mode 100644 index 813c3a54ba..0000000000 --- a/web/src/client/storage.test.js +++ /dev/null @@ -1,2396 +0,0 @@ -/* - * Copyright (c) [2022-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 -// cspell:ignore ddgdcbibhd wwpns - -import { HTTPClient } from "./http"; -import DBusClient from "./dbus"; -import { StorageClient } from "./storage"; - -/** - * @typedef {import("~/client/storage").StorageDevice} StorageDevice - */ - -jest.mock("./dbus"); - -const cockpitProxies = {}; - -const cockpitCallbacks = {}; - -let managedObjects = {}; - -// System devices - -/** @type {StorageDevice} */ -const sda = { - sid: 59, - isDrive: true, - type: "disk", - vendor: "Micron", - model: "Micron 1100 SATA", - driver: ["ahci", "mmcblk"], - bus: "IDE", - busId: "", - transport: "usb", - dellBOSS: false, - sdCard: true, - active: true, - name: "/dev/sda", - description: "", - size: 1024, - start: 0, - encrypted: false, - shrinking: { unsupported: ["Resizing is not supported"] }, - systems: [], - udevIds: ["ata-Micron_1100_SATA_512GB_12563", "scsi-0ATA_Micron_1100_SATA_512GB"], - udevPaths: ["pci-0000:00-12", "pci-0000:00-12-ata"], -}; - -/** @type {StorageDevice} */ -const sda1 = { - sid: 60, - isDrive: false, - type: "partition", - active: true, - name: "/dev/sda1", - description: "", - size: 512, - start: 123, - encrypted: false, - shrinking: { unsupported: ["Resizing is not supported"] }, - systems: [], - udevIds: [], - udevPaths: [], - isEFI: true, -}; - -/** @type {StorageDevice} */ -const sda2 = { - sid: 61, - isDrive: false, - type: "partition", - active: true, - name: "/dev/sda2", - description: "", - size: 256, - start: 1789, - encrypted: false, - shrinking: { unsupported: ["Resizing is not supported"] }, - systems: [], - udevIds: [], - udevPaths: [], - isEFI: false, -}; - -/** @type {StorageDevice} */ -const sdb = { - sid: 62, - isDrive: true, - type: "disk", - vendor: "Samsung", - model: "Samsung Evo 8 Pro", - driver: ["ahci"], - bus: "IDE", - busId: "", - transport: "", - dellBOSS: false, - sdCard: false, - active: true, - name: "/dev/sdb", - description: "", - size: 2048, - start: 0, - encrypted: false, - shrinking: { unsupported: ["Resizing is not supported"] }, - systems: [], - udevIds: [], - udevPaths: ["pci-0000:00-19"], -}; - -/** @type {StorageDevice} */ -const sdc = { - sid: 63, - isDrive: true, - type: "disk", - vendor: "Disk", - model: "", - driver: [], - bus: "IDE", - busId: "", - transport: "", - dellBOSS: false, - sdCard: false, - active: true, - name: "/dev/sdc", - description: "", - size: 2048, - start: 0, - encrypted: false, - shrinking: { unsupported: ["Resizing is not supported"] }, - systems: [], - udevIds: [], - udevPaths: [], -}; - -/** @type {StorageDevice} */ -const sdd = { - sid: 64, - isDrive: true, - type: "disk", - vendor: "Disk", - model: "", - driver: [], - bus: "IDE", - busId: "", - transport: "", - dellBOSS: false, - sdCard: false, - active: true, - name: "/dev/sdd", - description: "", - size: 2048, - start: 0, - encrypted: false, - shrinking: { unsupported: ["Resizing is not supported"] }, - systems: [], - udevIds: [], - udevPaths: [], -}; - -/** @type {StorageDevice} */ -const sde = { - sid: 65, - isDrive: true, - type: "disk", - vendor: "Disk", - model: "", - driver: [], - bus: "IDE", - busId: "", - transport: "", - dellBOSS: false, - sdCard: false, - active: true, - name: "/dev/sde", - description: "", - size: 2048, - start: 0, - encrypted: false, - shrinking: { unsupported: ["Resizing is not supported"] }, - systems: [], - udevIds: [], - udevPaths: [], -}; - -/** @type {StorageDevice} */ -const md0 = { - sid: 66, - isDrive: false, - type: "md", - level: "raid0", - uuid: "12345:abcde", - active: true, - name: "/dev/md0", - description: "EXT4 RAID", - size: 2048, - start: 0, - encrypted: false, - shrinking: { unsupported: ["Resizing is not supported"] }, - devices: [], - systems: ["openSUSE Leap 15.2"], - udevIds: [], - udevPaths: [], - filesystem: { sid: 100, type: "ext4", mountPath: "/test", label: "system" }, -}; - -/** @type {StorageDevice} */ -const raid = { - sid: 67, - isDrive: true, - type: "raid", - vendor: "Dell", - model: "Dell BOSS-N1 Modular", - driver: [], - bus: "", - busId: "", - transport: "", - dellBOSS: true, - sdCard: false, - active: true, - name: "/dev/mapper/isw_ddgdcbibhd_244", - description: "", - size: 2048, - start: 0, - encrypted: false, - shrinking: { unsupported: ["Resizing is not supported"] }, - devices: [], - systems: [], - udevIds: [], - udevPaths: [], -}; - -/** @type {StorageDevice} */ -const multipath = { - sid: 68, - isDrive: true, - type: "multipath", - vendor: "", - model: "", - driver: [], - bus: "", - busId: "", - transport: "", - dellBOSS: false, - sdCard: false, - active: true, - name: "/dev/mapper/36005076305ffc73a00000000000013b4", - description: "", - size: 2048, - start: 0, - encrypted: false, - shrinking: { unsupported: ["Resizing is not supported"] }, - systems: [], - udevIds: [], - udevPaths: [], -}; - -/** @type {StorageDevice} */ -const sdf = { - sid: 70, - isDrive: true, - type: "disk", - vendor: "Disk", - model: "", - driver: [], - bus: "IDE", - busId: "", - transport: "", - dellBOSS: false, - sdCard: false, - active: true, - name: "/dev/sdf", - description: "", - size: 2048, - start: 0, - encrypted: false, - shrinking: { unsupported: ["Resizing is not supported"] }, - systems: [], - udevIds: [], - udevPaths: [], -}; - -/** @type {StorageDevice} */ -const sdf1 = { - sid: 71, - isDrive: false, - type: "partition", - active: true, - name: "/dev/sdf1", - description: "PV of vg0", - size: 512, - start: 1024, - encrypted: true, - shrinking: { unsupported: ["Resizing is not supported"] }, - systems: [], - udevIds: [], - udevPaths: [], - isEFI: false, -}; - -/** @type {StorageDevice} */ -const lvmVg = { - sid: 72, - isDrive: false, - type: "lvmVg", - name: "/dev/vg0", - description: "LVM", - size: 512, -}; - -/** @type {StorageDevice} */ -const lvmLv1 = { - sid: 73, - isDrive: false, - type: "lvmLv", - active: true, - name: "/dev/vg0/lv1", - description: "", - size: 512, - start: 0, - encrypted: false, - shrinking: { supported: 128 }, - systems: [], - udevIds: [], - udevPaths: [], -}; - -// Define relationship between devices - -sda.partitionTable = { - type: "gpt", - partitions: [sda1, sda2], - unpartitionedSize: 256, - unusedSlots: [{ start: 1234, size: 256 }], -}; - -sda1.component = { - type: "md_device", - deviceNames: ["/dev/md0"], -}; - -sda2.component = { - type: "md_device", - deviceNames: ["/dev/md0"], -}; - -sdb.component = { - type: "raid_device", - deviceNames: ["/dev/mapper/isw_ddgdcbibhd_244"], -}; - -sdc.component = { - type: "raid_device", - deviceNames: ["/dev/mapper/isw_ddgdcbibhd_244"], -}; - -sdd.component = { - type: "multipath_wire", - deviceNames: ["/dev/mapper/36005076305ffc73a00000000000013b4"], -}; - -sde.component = { - type: "multipath_wire", - deviceNames: ["/dev/mapper/36005076305ffc73a00000000000013b4"], -}; - -sdf.partitionTable = { - type: "gpt", - partitions: [sdf1], - unpartitionedSize: 1536, - unusedSlots: [], -}; - -sdf1.component = { - type: "physical_volume", - deviceNames: ["/dev/vg0"], -}; - -md0.devices = [sda1, sda2]; - -raid.devices = [ - { - sid: 0, - name: "/dev/sdb", - description: "", - isDrive: false, - type: "", - }, - { - sid: 0, - name: "/dev/sdc", - description: "", - isDrive: false, - type: "", - }, -]; - -multipath.wires = [ - { - sid: 0, - name: "/dev/sdd", - description: "", - isDrive: false, - type: "", - }, - { - sid: 0, - name: "/dev/sde", - description: "", - isDrive: false, - type: "", - }, -]; - -lvmVg.logicalVolumes = [lvmLv1]; -lvmVg.physicalVolumes = [sdf1]; - -const systemDevices = { - sda, - sda1, - sda2, - sdb, - sdc, - sdd, - sde, - md0, - raid, - multipath, - sdf, - sdf1, - lvmVg, - lvmLv1, -}; - -// Staging devices -// -// Using a single device because most of the checks are already done with system devices. - -/** @type {StorageDevice} */ -const sdbStaging = { - sid: 62, - isDrive: true, - type: "disk", - vendor: "Samsung", - model: "Samsung Evo 8 Pro", - driver: ["ahci"], - bus: "IDE", - busId: "", - transport: "", - dellBOSS: false, - sdCard: false, - active: true, - name: "/dev/sdb", - description: "", - size: 2048, - start: 0, - encrypted: false, - shrinking: { unsupported: ["Resizing is not supported"] }, - systems: [], - udevIds: [], - udevPaths: ["pci-0000:00-19"], -}; - -const stagingDevices = { sdb: sdbStaging }; - -const contexts = { - withProposal: () => { - return { - settings: { - target: "newLvmVg", - targetPVDevices: ["/dev/sda", "/dev/sdb"], - configureBoot: true, - bootDevice: "/dev/sda", - defaultBootDevice: "/dev/sdb", - encryptionPassword: "00000", - encryptionMethod: "luks1", - spacePolicy: "custom", - spaceActions: [ - { device: "/dev/sda", action: "force_delete" }, - { device: "/dev/sdb", action: "resize" }, - ], - volumes: [ - { - mountPath: "/", - target: "default", - targetDevice: "", - fsType: "Btrfs", - minSize: 1024, - maxSize: 2048, - autoSize: true, - snapshots: true, - transactional: true, - outline: { - required: true, - fsTypes: ["Btrfs", "Ext3"], - supportAutoSize: true, - snapshotsConfigurable: true, - snapshotsAffectSizes: true, - adjustByRam: false, - sizeRelevantVolumes: ["/home"], - }, - }, - { - mountPath: "/home", - target: "default", - targetDevice: "", - fsType: "XFS", - minSize: 2048, - maxSize: 4096, - autoSize: false, - snapshots: false, - transactional: false, - outline: { - required: false, - fsTypes: ["Ext4", "XFS"], - supportAutoSize: false, - snapshotsConfigurable: false, - snapshotsAffectSizes: false, - adjustByRam: false, - sizeRelevantVolumes: [], - }, - }, - ], - }, - actions: [{ device: 2, text: "Mount /dev/sdb1 as root", subvol: false, delete: false }], - }; - }, - withAvailableDevices: () => [59, 62], - withIssues: () => [ - { description: "Issue 1", details: "", source: 1, severity: 1 }, - { description: "Issue 2", details: "", source: 1, severity: 0 }, - { description: "Issue 3", details: "", source: 2, severity: 1 }, - ], - withoutISCSINodes: () => { - cockpitProxies.iscsiNodes = {}; - }, - withISCSINodes: () => [ - { - id: 1, - target: "iqn.2023-01.com.example:37dac", - address: "192.168.100.101", - port: 3260, - interface: "default", - ibft: false, - connected: false, - startup: "", - }, - { - id: 2, - target: "iqn.2023-01.com.example:74afb", - address: "192.168.100.102", - port: 3260, - interface: "default", - ibft: true, - connected: true, - startup: "onboot", - }, - ], - withoutZFCPControllers: () => { - cockpitProxies.zfcpControllers = {}; - }, - withZFCPControllers: () => { - cockpitProxies.zfcpControllers = { - "/org/opensuse/Agama/Storage1/zfcp_controllers/1": { - path: "/org/opensuse/Agama/Storage1/zfcp_controllers/1", - Active: false, - LUNScan: false, - Channel: "0.0.fa00", - }, - "/org/opensuse/Agama/Storage1/zfcp_controllers/2": { - path: "/org/opensuse/Agama/Storage1/zfcp_controllers/2", - Active: false, - LUNScan: false, - Channel: "0.0.fc00", - }, - }; - }, - withoutZFCPDisks: () => { - cockpitProxies.zfcpDisks = {}; - }, - withZFCPDisks: () => { - cockpitProxies.zfcpDisks = { - "/org/opensuse/Agama/Storage1/zfcp_disks/1": { - path: "/org/opensuse/Agama/Storage1/zfcp_disks/1", - Name: "/dev/sda", - Channel: "0.0.fa00", - WWPN: "0x500507630703d3b3", - LUN: "0x0000000000000000", - }, - "/org/opensuse/Agama/Storage1/zfcp_disks/2": { - path: "/org/opensuse/Agama/Storage1/zfcp_disks/2", - Name: "/dev/sdb", - Channel: "0.0.fa00", - WWPN: "0x500507630703d3b3", - LUN: "0x0000000000000001", - }, - }; - }, - withSystemDevices: () => [ - { - deviceInfo: { - sid: 59, - name: "/dev/sda", - description: "", - }, - blockDevice: { - active: true, - encrypted: false, - size: 1024, - start: 0, - shrinking: { unsupported: ["Resizing is not supported"] }, - systems: [], - udevIds: ["ata-Micron_1100_SATA_512GB_12563", "scsi-0ATA_Micron_1100_SATA_512GB"], - udevPaths: ["pci-0000:00-12", "pci-0000:00-12-ata"], - }, - drive: { - type: "disk", - vendor: "Micron", - model: "Micron 1100 SATA", - driver: ["ahci", "mmcblk"], - bus: "IDE", - busId: "", - transport: "usb", - info: { - dellBOSS: false, - sdCard: true, - }, - }, - partitionTable: { - type: "gpt", - partitions: [60, 61], - unusedSlots: [{ start: 1234, size: 256 }], - }, - }, - { - deviceInfo: { - sid: 60, - name: "/dev/sda1", - description: "", - }, - partition: { efi: true }, - blockDevice: { - active: true, - encrypted: false, - size: 512, - start: 123, - shrinking: { unsupported: ["Resizing is not supported"] }, - systems: [], - udevIds: [], - udevPaths: [], - }, - component: { - type: "md_device", - deviceNames: ["/dev/md0"], - devices: [66], - }, - }, - { - deviceInfo: { - sid: 61, - name: "/dev/sda2", - description: "", - }, - partition: { efi: false }, - blockDevice: { - active: true, - encrypted: false, - size: 256, - start: 1789, - shrinking: { unsupported: ["Resizing is not supported"] }, - systems: [], - udevIds: [], - udevPaths: [], - }, - component: { - type: "md_device", - deviceNames: ["/dev/md0"], - devices: [66], - }, - }, - { - deviceInfo: { - sid: 62, - name: "/dev/sdb", - description: "", - }, - blockDevice: { - active: true, - encrypted: false, - size: 2048, - start: 0, - shrinking: { unsupported: ["Resizing is not supported"] }, - systems: [], - udevIds: [], - udevPaths: ["pci-0000:00-19"], - }, - drive: { - type: "disk", - vendor: "Samsung", - model: "Samsung Evo 8 Pro", - driver: ["ahci"], - bus: "IDE", - busId: "", - transport: "", - info: { - dellBOSS: false, - sdCard: false, - }, - }, - component: { - type: "raid_device", - deviceNames: ["/dev/mapper/isw_ddgdcbibhd_244"], - devices: [67], - }, - }, - { - deviceInfo: { - sid: 63, - name: "/dev/sdc", - description: "", - }, - blockDevice: { - active: true, - encrypted: false, - size: 2048, - start: 0, - shrinking: { unsupported: ["Resizing is not supported"] }, - systems: [], - udevIds: [], - udevPaths: [], - }, - drive: { - type: "disk", - vendor: "Disk", - model: "", - driver: [], - bus: "IDE", - busId: "", - transport: "", - info: { - dellBOSS: false, - sdCard: false, - }, - }, - component: { - type: "raid_device", - deviceNames: ["/dev/mapper/isw_ddgdcbibhd_244"], - devices: [67], - }, - }, - { - deviceInfo: { - sid: 64, - name: "/dev/sdd", - description: "", - }, - blockDevice: { - active: true, - encrypted: false, - size: 2048, - start: 0, - shrinking: { unsupported: ["Resizing is not supported"] }, - systems: [], - udevIds: [], - udevPaths: [], - }, - drive: { - type: "disk", - vendor: "Disk", - model: "", - driver: [], - bus: "IDE", - busId: "", - transport: "", - info: { - dellBOSS: false, - sdCard: false, - }, - }, - component: { - type: "multipath_wire", - deviceNames: ["/dev/mapper/36005076305ffc73a00000000000013b4"], - devices: [68], - }, - }, - { - deviceInfo: { - sid: 65, - name: "/dev/sde", - description: "", - }, - blockDevice: { - active: true, - encrypted: false, - size: 2048, - start: 0, - shrinking: { unsupported: ["Resizing is not supported"] }, - systems: [], - udevIds: [], - udevPaths: [], - }, - drive: { - type: "disk", - vendor: "Disk", - model: "", - driver: [], - bus: "IDE", - busId: "", - transport: "", - info: { - dellBOSS: false, - sdCard: false, - }, - }, - component: { - type: "multipath_wire", - deviceNames: ["/dev/mapper/36005076305ffc73a00000000000013b4"], - devices: [68], - }, - }, - { - deviceInfo: { - sid: 66, - name: "/dev/md0", - description: "EXT4 RAID", - }, - blockDevice: { - active: true, - encrypted: false, - size: 2048, - start: 0, - shrinking: { unsupported: ["Resizing is not supported"] }, - systems: ["openSUSE Leap 15.2"], - udevIds: [], - udevPaths: [], - }, - md: { - level: "raid0", - uuid: "12345:abcde", - devices: [60, 61], - }, - filesystem: { - sid: 100, - type: "ext4", - mountPath: "/test", - label: "system", - }, - }, - { - deviceInfo: { - sid: 67, - name: "/dev/mapper/isw_ddgdcbibhd_244", - description: "", - }, - blockDevice: { - active: true, - encrypted: false, - size: 2048, - start: 0, - shrinking: { unsupported: ["Resizing is not supported"] }, - systems: [], - udevIds: [], - udevPaths: [], - }, - drive: { - type: "raid", - vendor: "Dell", - model: "Dell BOSS-N1 Modular", - driver: [], - bus: "", - busId: "", - transport: "", - info: { - dellBOSS: true, - sdCard: false, - }, - }, - raid: { - devices: ["/dev/sdb", "/dev/sdc"], - }, - }, - { - deviceInfo: { - sid: 68, - name: "/dev/mapper/36005076305ffc73a00000000000013b4", - description: "", - }, - blockDevice: { - active: true, - encrypted: false, - size: 2048, - start: 0, - shrinking: { unsupported: ["Resizing is not supported"] }, - systems: [], - udevIds: [], - udevPaths: [], - }, - drive: { - type: "multipath", - vendor: "", - model: "", - driver: [], - bus: "", - busId: "", - transport: "", - info: { - dellBOSS: false, - sdCard: false, - }, - }, - multipath: { - wires: ["/dev/sdd", "/dev/sde"], - }, - }, - { - deviceInfo: { - sid: 70, - name: "/dev/sdf", - description: "", - }, - blockDevice: { - active: true, - encrypted: false, - size: 2048, - start: 0, - shrinking: { unsupported: ["Resizing is not supported"] }, - systems: [], - udevIds: [], - udevPaths: [], - }, - drive: { - type: "disk", - vendor: "Disk", - model: "", - driver: [], - bus: "IDE", - busId: "", - transport: "", - info: { - dellBOSS: false, - sdCard: false, - }, - }, - partitionTable: { - type: "gpt", - partitions: [71], - unusedSlots: [], - }, - }, - { - deviceInfo: { - sid: 71, - name: "/dev/sdf1", - description: "PV of vg0", - }, - partition: { efi: false }, - blockDevice: { - active: true, - encrypted: true, - size: 512, - start: 1024, - shrinking: { unsupported: ["Resizing is not supported"] }, - systems: [], - udevIds: [], - udevPaths: [], - }, - component: { - type: "physical_volume", - deviceNames: ["/dev/vg0"], - devices: [72], - }, - }, - { - deviceInfo: { - sid: 72, - name: "/dev/vg0", - description: "LVM", - }, - lvmVg: { - type: "physical_volume", - size: 512, - physicalVolumes: [71], - logicalVolumes: [73], - }, - }, - { - deviceInfo: { - sid: 73, - name: "/dev/vg0/lv1", - description: "", - }, - blockDevice: { - active: true, - encrypted: false, - size: 512, - start: 0, - shrinking: { supported: 128 }, - systems: [], - udevIds: [], - udevPaths: [], - }, - lvmLv: { - volumeGroup: [72], - }, - }, - ], - withStagingDevices: () => [ - { - deviceInfo: { - sid: 62, - name: "/dev/sdb", - description: "", - }, - drive: { - type: "disk", - vendor: "Samsung", - model: "Samsung Evo 8 Pro", - driver: ["ahci"], - bus: "IDE", - busId: "", - transport: "", - info: { - dellBOSS: false, - sdCard: false, - }, - }, - blockDevice: { - active: true, - encrypted: false, - size: 2048, - start: 0, - shrinking: { unsupported: ["Resizing is not supported"] }, - systems: [], - udevIds: [], - udevPaths: ["pci-0000:00-19"], - }, - }, - ], -}; - -const mockProxy = (iface, path) => { - switch (iface) { - case "org.opensuse.Agama.Storage1.ISCSI.Initiator": - return cockpitProxies.iscsiInitiator; - case "org.opensuse.Agama.Storage1.ISCSI.Node": - return cockpitProxies.iscsiNode[path]; - case "org.opensuse.Agama.Storage1.ZFCP.Manager": - return cockpitProxies.zfcpManager; - case "org.opensuse.Agama.Storage1.ZFCP.Controller": - return cockpitProxies.zfcpController[path]; - } -}; - -const mockProxies = (iface) => { - switch (iface) { - case "org.opensuse.Agama.Storage1.ISCSI.Node": - return cockpitProxies.iscsiNodes; - case "org.opensuse.Agama.Storage1.ZFCP.Controller": - return cockpitProxies.zfcpControllers; - case "org.opensuse.Agama.Storage1.ZFCP.Disk": - return cockpitProxies.zfcpDisks; - } -}; - -const mockOnObjectChanged = (path, iface, handler) => { - if (!cockpitCallbacks[path]) cockpitCallbacks[path] = {}; - cockpitCallbacks[path][iface] = handler; -}; - -const emitSignal = (path, iface, data) => { - if (!cockpitCallbacks[path]) return; - - const handler = cockpitCallbacks[path][iface]; - if (!handler) return; - - return handler(data); -}; - -const mockCall = (_path, iface, method) => { - if (iface === "org.freedesktop.DBus.ObjectManager" && method === "GetManagedObjects") - return [managedObjects]; -}; - -const reset = () => { - cockpitProxies.iscsiInitiator = {}; - cockpitProxies.iscsiNodes = {}; - cockpitProxies.iscsiNode = {}; - cockpitProxies.zfcpManager = {}; - cockpitProxies.zfcpControllers = {}; - cockpitProxies.zfcpDisks = {}; - cockpitProxies.zfcpController = {}; - managedObjects = {}; -}; - -let mockJsonFn; -let mockGetFn; -let mockPostFn; -let mockPutFn; -let mockDeleteFn; -let mockPatchFn; -let mockHTTPClient; -let http; - -jest.mock("./http", () => { - return { - HTTPClient: jest.fn().mockImplementation(() => mockHTTPClient), - }; -}); - -beforeEach(() => { - reset(); - - // @ts-ignore - DBusClient.mockImplementation(() => { - return { - proxy: mockProxy, - proxies: mockProxies, - onObjectChanged: mockOnObjectChanged, - call: mockCall, - }; - }); - - mockJsonFn = jest.fn(); - mockGetFn = jest.fn().mockImplementation(() => { - return { ok: true, json: mockJsonFn }; - }); - mockPostFn = jest.fn().mockImplementation(() => { - return { ok: true }; - }); - mockPutFn = jest.fn().mockImplementation(() => { - return { ok: true }; - }); - mockDeleteFn = jest.fn().mockImplementation(() => { - return { - ok: true, - }; - }); - mockPatchFn = jest.fn().mockImplementation(() => { - return { ok: true }; - }); - - mockHTTPClient = { - get: mockGetFn, - patch: mockPatchFn, - post: mockPostFn, - put: mockPutFn, - delete: mockDeleteFn, - }; - - http = new HTTPClient(new URL("http://localhost")); -}); - -let client; - -describe("#probe", () => { - beforeEach(() => { - client = new StorageClient(http); - }); - - it("probes the system", async () => { - await client.probe(); - expect(mockPostFn).toHaveBeenCalledWith("/storage/probe"); - }); -}); - -describe("#isDeprecated", () => { - describe("if the system is deprecated", () => { - beforeEach(() => { - mockJsonFn.mockResolvedValue(true); - client = new StorageClient(http); - }); - - it("returns true", async () => { - const result = await client.isDeprecated(); - expect(result).toEqual(true); - }); - }); - - describe("if the system is not deprecated", () => { - beforeEach(() => { - mockGetFn.mockResolvedValue(false); - client = new StorageClient(http); - }); - - it("returns false", async () => { - const result = await client.isDeprecated(); - expect(result).toEqual(false); - }); - }); - - describe("when the HTTP call fails", () => { - beforeEach(() => { - mockGetFn.mockImplementation((path) => { - if (path === "/storage/devices/dirty") return { ok: false, json: undefined }; - else return { ok: true, json: mockJsonFn }; - }); - - client = new StorageClient(http); - }); - - it("returns false", async () => { - const result = await client.isDeprecated(); - expect(result).toEqual(false); - }); - }); -}); - -// @fixme We need to rethink signals mocking, now that we switched from DBus to HTTP -describe.skip("#onDeprecate", () => { - const handler = jest.fn(); - - beforeEach(() => { - client = new StorageClient(); - client.onDeprecate(handler); - }); - - describe("if the system was not deprecated", () => { - beforeEach(() => { - emitSignal("/org/opensuse/Agama/Storage1", "org.opensuse.Agama.Storage1", {}); - }); - - it("does not run the handler", async () => { - expect(handler).not.toHaveBeenCalled(); - }); - }); - - describe("if the system was deprecated", () => { - beforeEach(() => { - emitSignal("/org/opensuse/Agama/Storage1", "org.opensuse.Agama.Storage1", { - DeprecatedSystem: true, - }); - }); - - it("runs the handler", async () => { - expect(handler).not.toHaveBeenCalled(); - }); - }); -}); - -describe("#system", () => { - describe("#getDevices", () => { - beforeEach(() => { - client = new StorageClient(http); - }); - - describe("when there are devices", () => { - beforeEach(() => { - mockJsonFn.mockResolvedValue(contexts.withSystemDevices()); - }); - - it("returns the system devices", async () => { - const devices = await client.system.getDevices(); - expect(devices).toEqual(Object.values(systemDevices)); - }); - }); - - describe("when there are not devices", () => { - beforeEach(() => { - mockJsonFn.mockResolvedValue([]); - }); - - it("returns an empty list", async () => { - const devices = await client.system.getDevices(); - expect(devices).toEqual([]); - }); - }); - - describe("when the HTTP call fails", () => { - beforeEach(() => { - mockGetFn.mockImplementation((path) => { - if (path === "/storage/devices/system") return { ok: false, json: undefined }; - else return { ok: true, json: mockJsonFn }; - }); - - client = new StorageClient(http); - }); - - it("returns an empty list", async () => { - const devices = await client.system.getDevices(); - expect(devices).toEqual([]); - }); - }); - }); -}); - -describe("#staging", () => { - describe("#getDevices", () => { - beforeEach(() => { - client = new StorageClient(http); - }); - - describe("when there are devices", () => { - beforeEach(() => { - mockJsonFn.mockResolvedValue(contexts.withStagingDevices()); - }); - - it("returns the staging devices", async () => { - const devices = await client.staging.getDevices(); - expect(devices).toEqual(Object.values(stagingDevices)); - }); - }); - - describe("when there are not devices", () => { - beforeEach(() => { - mockJsonFn.mockResolvedValue([]); - }); - - it("returns an empty list", async () => { - const devices = await client.staging.getDevices(); - expect(devices).toEqual([]); - }); - }); - - describe("when the HTTP call fails", () => { - beforeEach(() => { - mockGetFn.mockImplementation((path) => { - if (path === "/storage/devices/result") return { ok: false, json: undefined }; - else return { ok: true, json: mockJsonFn }; - }); - - client = new StorageClient(http); - }); - - it("returns an empty list", async () => { - const devices = await client.staging.getDevices(); - expect(devices).toEqual([]); - }); - }); - }); -}); - -describe("#proposal", () => { - describe("#getAvailableDevices", () => { - let response; - - beforeEach(() => { - response = { ok: true, json: jest.fn().mockResolvedValue(contexts.withAvailableDevices()) }; - - mockGetFn.mockImplementation((path) => { - switch (path) { - case "/storage/devices/system": - return { ok: true, json: jest.fn().mockResolvedValue(contexts.withSystemDevices()) }; - case "/storage/proposal/usable_devices": - return response; - default: - return { ok: true, json: mockJsonFn }; - } - }); - - client = new StorageClient(http); - }); - - it("returns the list of available devices", async () => { - const availableDevices = await client.proposal.getAvailableDevices(); - expect(availableDevices).toEqual([systemDevices.sda, systemDevices.sdb]); - }); - - describe("when the HTTP call fails", () => { - beforeEach(() => { - response = { ok: false, json: undefined }; - }); - - it("returns an empty list", async () => { - const availableDevices = await client.proposal.getAvailableDevices(); - expect(availableDevices).toEqual([]); - }); - }); - }); - - describe("#getProductMountPoints", () => { - beforeEach(() => { - mockJsonFn.mockResolvedValue({ mountPoints: ["/", "swap", "/home"] }); - client = new StorageClient(http); - }); - - it("returns the list of product mount points", async () => { - const mount_points = await client.proposal.getProductMountPoints(); - expect(mount_points).toEqual(["/", "swap", "/home"]); - }); - - describe("when the HTTP call fails", () => { - beforeEach(() => { - mockGetFn.mockImplementation((path) => { - if (path === "/storage/product/params") return { ok: false, json: undefined }; - else return { ok: true, json: mockJsonFn }; - }); - - client = new StorageClient(http); - }); - - it("returns an empty list", async () => { - const mount_points = await client.proposal.getProductMountPoints(); - expect(mount_points).toEqual([]); - }); - }); - }); - - describe("#getEncryptionMethods", () => { - beforeEach(() => { - mockJsonFn.mockResolvedValue({ encryptionMethods: ["luks1", "luks2"] }); - client = new StorageClient(http); - }); - - it("returns the list of encryption methods", async () => { - const encryptionMethods = await client.proposal.getEncryptionMethods(); - expect(encryptionMethods).toEqual(["luks1", "luks2"]); - }); - - describe("when the HTTP call fails", () => { - beforeEach(() => { - mockGetFn.mockImplementation((path) => { - if (path === "/storage/product/params") return { ok: false, json: undefined }; - else return { ok: true, json: mockJsonFn }; - }); - - client = new StorageClient(http); - }); - - it("returns an empty list", async () => { - const encryptionMethods = await client.proposal.getEncryptionMethods(); - expect(encryptionMethods).toEqual([]); - }); - }); - }); - - describe("#defaultVolume", () => { - let response; - - beforeEach(() => { - response = (path) => { - const param = path.split("=")[1]; - switch (param) { - case "%2Fhome": - return { - ok: true, - json: jest.fn().mockResolvedValue({ - mountPath: "/home", - target: "default", - targetDevice: "", - fsType: "XFS", - minSize: 2048, - maxSize: 4096, - autoSize: false, - snapshots: false, - transactional: false, - outline: { - required: false, - fsTypes: ["Ext4", "XFS"], - supportAutoSize: false, - snapshotsConfigurable: false, - snapshotsAffectSizes: false, - adjustByRam: false, - sizeRelevantVolumes: [], - }, - }), - }; - default: - return { - ok: true, - json: jest.fn().mockResolvedValue({ - mountPath: "", - target: "default", - targetDevice: "", - fsType: "Ext4", - minSize: 1024, - maxSize: 2048, - autoSize: false, - snapshots: false, - transactional: false, - outline: { - required: false, - fsTypes: ["Ext4", "XFS"], - supportAutoSize: false, - snapshotsConfigurable: false, - snapshotsAffectSizes: false, - adjustByRam: false, - sizeRelevantVolumes: [], - }, - }), - }; - } - }; - - mockGetFn.mockImplementation((path) => { - switch (path) { - case "/storage/devices/system": - return { ok: true, json: jest.fn().mockResolvedValue(contexts.withSystemDevices()) }; - case "/storage/product/params": - return { - ok: true, - json: jest.fn().mockResolvedValue({ mountPoints: ["/", "swap", "/home"] }), - }; - // GET for /storage/product/volume_for?path=XX - default: - return response(path); - } - }); - - client = new StorageClient(http); - }); - - it("returns the default volume for the given path", async () => { - const home = await client.proposal.defaultVolume("/home"); - - expect(home).toStrictEqual({ - mountPath: "/home", - target: "DEFAULT", - targetDevice: undefined, - fsType: "XFS", - minSize: 2048, - maxSize: 4096, - autoSize: false, - snapshots: false, - transactional: false, - outline: { - required: false, - fsTypes: ["Ext4", "XFS"], - supportAutoSize: false, - snapshotsConfigurable: false, - snapshotsAffectSizes: false, - adjustByRam: false, - sizeRelevantVolumes: [], - productDefined: true, - }, - }); - - const generic = await client.proposal.defaultVolume(""); - - expect(generic).toStrictEqual({ - mountPath: "", - target: "DEFAULT", - targetDevice: undefined, - fsType: "Ext4", - minSize: 1024, - maxSize: 2048, - autoSize: false, - snapshots: false, - transactional: false, - outline: { - required: false, - fsTypes: ["Ext4", "XFS"], - supportAutoSize: false, - snapshotsConfigurable: false, - snapshotsAffectSizes: false, - adjustByRam: false, - sizeRelevantVolumes: [], - productDefined: false, - }, - }); - }); - - describe("when then HTTP call fails", () => { - beforeEach(() => { - response = () => ({ ok: false, json: undefined }); - }); - - it("returns undefined", async () => { - const volume = await client.proposal.defaultVolume("/home"); - expect(volume).toBeUndefined(); - }); - }); - }); - - describe("#getResult", () => { - beforeEach(() => { - client = new StorageClient(http); - }); - - describe("if there is no proposal yet", () => { - beforeEach(() => { - mockGetFn.mockImplementation(() => { - return { ok: false }; - }); - }); - - it("returns undefined", async () => { - const result = await client.proposal.getResult(); - expect(result).toBe(undefined); - }); - }); - - describe("if there is a proposal", () => { - beforeEach(() => { - const proposal = contexts.withProposal(); - mockJsonFn.mockResolvedValue(proposal.settings); - - mockGetFn.mockImplementation((path) => { - switch (path) { - case "/storage/devices/system": - return { ok: true, json: jest.fn().mockResolvedValue(contexts.withSystemDevices()) }; - case "/storage/proposal/settings": - return { ok: true, json: mockJsonFn }; - case "/storage/proposal/actions": - return { ok: true, json: jest.fn().mockResolvedValue(proposal.actions) }; - case "/storage/product/params": - return { - ok: true, - json: jest.fn().mockResolvedValue({ mountPoints: ["/", "swap"] }), - }; - } - }); - }); - - it("returns the proposal settings and actions", async () => { - const { settings, actions } = await client.proposal.getResult(); - - expect(settings).toMatchObject({ - target: "NEW_LVM_VG", - targetPVDevices: ["/dev/sda", "/dev/sdb"], - configureBoot: true, - bootDevice: "/dev/sda", - defaultBootDevice: "/dev/sdb", - encryptionPassword: "00000", - spacePolicy: "custom", - spaceActions: [ - { device: "/dev/sda", action: "force_delete" }, - { device: "/dev/sdb", action: "resize" }, - ], - volumes: [ - { - mountPath: "/", - target: "DEFAULT", - targetDevice: undefined, - fsType: "Btrfs", - minSize: 1024, - maxSize: 2048, - autoSize: true, - snapshots: true, - transactional: true, - outline: { - required: true, - fsTypes: ["Btrfs", "Ext3"], - supportAutoSize: true, - snapshotsConfigurable: true, - snapshotsAffectSizes: true, - sizeRelevantVolumes: ["/home"], - productDefined: true, - }, - }, - { - mountPath: "/home", - target: "DEFAULT", - targetDevice: undefined, - fsType: "XFS", - minSize: 2048, - maxSize: 4096, - autoSize: false, - snapshots: false, - transactional: false, - outline: { - required: false, - fsTypes: ["Ext4", "XFS"], - supportAutoSize: false, - snapshotsConfigurable: false, - snapshotsAffectSizes: false, - sizeRelevantVolumes: [], - productDefined: false, - }, - }, - ], - }); - - expect(settings.installationDevices.map((d) => d.name).sort()).toStrictEqual( - ["/dev/sda", "/dev/sdb"].sort(), - ); - - expect(actions).toStrictEqual([ - { device: 2, text: "Mount /dev/sdb1 as root", subvol: false, delete: false }, - ]); - }); - - describe("if boot is not configured", () => { - beforeEach(() => { - mockJsonFn.mockResolvedValue({ - ...contexts.withProposal().settings, - configureBoot: false, - bootDevice: "/dev/sdc", - }); - }); - - it("does not include the boot device as installation device", async () => { - const { settings } = await client.proposal.getResult(); - expect(settings.installationDevices).toEqual([sda, sdb]); - }); - }); - }); - }); - - describe("#calculate", () => { - let response = { ok: true, json: jest.fn().mockResolvedValue(true) }; - - beforeEach(() => { - mockPutFn.mockImplementation((path) => { - if (path === "/storage/proposal/settings") return response; - - return { ok: true }; - }); - - client = new StorageClient(http); - }); - - it("calculates a default proposal when no settings are given", async () => { - await client.proposal.calculate({}); - expect(mockPutFn).toHaveBeenCalledWith("/storage/proposal/settings", {}); - }); - - it("calculates a proposal with the given settings", async () => { - await client.proposal.calculate({ - target: "DISK", - targetDevice: "/dev/vdc", - configureBoot: true, - bootDevice: "/dev/vdb", - encryptionPassword: "12345", - spacePolicy: "custom", - spaceActions: [{ device: "/dev/sda", action: "resize" }], - volumes: [ - { - mountPath: "/test1", - fsType: "Btrfs", - minSize: 1024, - maxSize: 2048, - autoSize: false, - snapshots: true, - }, - { - mountPath: "/test2", - minSize: 1024, - }, - ], - }); - - expect(mockPutFn).toHaveBeenCalledWith("/storage/proposal/settings", { - target: "disk", - targetDevice: "/dev/vdc", - configureBoot: true, - bootDevice: "/dev/vdb", - encryptionPassword: "12345", - spacePolicy: "custom", - spaceActions: [{ device: "/dev/sda", action: "resize" }], - volumes: [ - { - mountPath: "/test1", - fsType: "Btrfs", - minSize: 1024, - maxSize: 2048, - autoSize: false, - snapshots: true, - }, - { - mountPath: "/test2", - minSize: 1024, - }, - ], - }); - }); - - it("calculates a proposal without space actions if the policy is not custom", async () => { - await client.proposal.calculate({ - spacePolicy: "delete", - spaceActions: [{ device: "/dev/sda", action: "resize" }], - }); - - expect(mockPutFn).toHaveBeenCalledWith("/storage/proposal/settings", { - spacePolicy: "delete", - }); - }); - - it("returns false if the call fails", async () => { - response = { ok: false, json: undefined }; - - const result = await client.proposal.calculate({}); - expect(result).toEqual(false); - }); - - it("returns false if a proposal was not calculated", async () => { - response = { ok: true, json: jest.fn().mockResolvedValue(false) }; - - const result = await client.proposal.calculate({}); - expect(result).toEqual(false); - }); - - it("returns true if a proposal was calculated", async () => { - response = { ok: true, json: jest.fn().mockResolvedValue(true) }; - - const result = await client.proposal.calculate({}); - expect(result).toEqual(true); - }); - }); -}); - -describe.skip("#zfcp", () => { - const probeFn = jest.fn(); - let controllersCallbacks; - let disksCallbacks; - - const mockEventListener = (proxy, callbacks) => { - proxy.addEventListener = jest.fn().mockImplementation((signal, handler) => { - if (!callbacks[signal]) callbacks[signal] = []; - callbacks[signal].push(handler); - }); - - proxy.removeEventListener = jest.fn(); - }; - - const emitSignals = (callbacks, signal, proxy) => { - callbacks[signal].forEach((handler) => handler(null, proxy)); - }; - - beforeEach(() => { - client = new StorageClient(); - cockpitProxies.zfcpManager = { - Probe: probeFn, - AllowLUNScan: true, - }; - - controllersCallbacks = {}; - mockEventListener(cockpitProxies.zfcpControllers, controllersCallbacks); - - disksCallbacks = {}; - mockEventListener(cockpitProxies.zfcpDisks, disksCallbacks); - }); - - describe("#isSupported", () => { - describe("if zFCP manager is available", () => { - it("returns true", async () => { - const result = await client.zfcp.isSupported(); - expect(result).toEqual(true); - }); - }); - - describe("if zFCP manager is not available", () => { - beforeEach(() => { - cockpitProxies.zfcpManager = undefined; - }); - - it("returns false", async () => { - const result = await client.zfcp.isSupported(); - expect(result).toEqual(false); - }); - }); - }); - - describe("#getAllowLUNScan", () => { - it("returns whether allow_lun_scan is active", async () => { - const result = await client.zfcp.getAllowLUNScan(); - expect(result).toEqual(true); - }); - - describe("if zFCP manager is not available", () => { - beforeEach(() => { - cockpitProxies.zfcpManager = undefined; - }); - - it("returns undefined", async () => { - const result = await client.zfcp.getAllowLUNScan(); - expect(result).toBeUndefined(); - }); - }); - }); - - describe("#probe", () => { - it("triggers probing", async () => { - await client.zfcp.probe(); - expect(probeFn).toHaveBeenCalled(); - }); - - describe("if zFCP manager is not available", () => { - beforeEach(() => { - cockpitProxies.zfcpManager = undefined; - }); - - it("returns undefined", async () => { - const result = await client.zfcp.probe(); - expect(result).toBeUndefined(); - }); - }); - }); - - describe("#getControllers", () => { - describe("if there is no exported zFCP controllers yet", () => { - beforeEach(() => { - contexts.withoutZFCPControllers(); - }); - - it("returns an empty list", async () => { - const result = await client.zfcp.getControllers(); - expect(result).toStrictEqual([]); - }); - }); - - describe("if there are exported ZFCP controllers", () => { - beforeEach(() => { - contexts.withZFCPControllers(); - }); - - it("returns a list with the exported ZFCP controllers", async () => { - const result = await client.zfcp.getControllers(); - expect(result.length).toEqual(2); - expect(result).toContainEqual({ - id: "1", - active: false, - lunScan: false, - channel: "0.0.fa00", - }); - expect(result).toContainEqual({ - id: "2", - active: false, - lunScan: false, - channel: "0.0.fc00", - }); - }); - }); - }); - - describe("#getDisks", () => { - describe("if there is no exported zFCP disks yet", () => { - beforeEach(() => { - contexts.withoutZFCPDisks(); - }); - - it("returns an empty list", async () => { - const result = await client.zfcp.getDisks(); - expect(result).toStrictEqual([]); - }); - }); - - describe("if there are exported ZFCP disks", () => { - beforeEach(() => { - contexts.withZFCPDisks(); - }); - - it("returns a list with the exported ZFCP disks", async () => { - const result = await client.zfcp.getDisks(); - expect(result.length).toEqual(2); - expect(result).toContainEqual({ - id: "1", - name: "/dev/sda", - channel: "0.0.fa00", - wwpn: "0x500507630703d3b3", - lun: "0x0000000000000000", - }); - expect(result).toContainEqual({ - id: "2", - name: "/dev/sdb", - channel: "0.0.fa00", - wwpn: "0x500507630703d3b3", - lun: "0x0000000000000001", - }); - }); - }); - }); - - describe("#getWWPNs", () => { - const wwpns = ["0x500507630703d3b3", "0x500507630708d3b3"]; - - const controllerProxy = { - GetWWPNs: jest.fn().mockReturnValue(wwpns), - }; - - beforeEach(() => { - cockpitProxies.zfcpController = { - "/org/opensuse/Agama/Storage1/zfcp_controllers/1": controllerProxy, - }; - }); - - it("returns a list with the WWPNs of the zFCP controller", async () => { - const result = await client.zfcp.getWWPNs({ id: "1" }); - expect(result).toStrictEqual(wwpns); - }); - - describe("if there is no proxy", () => { - beforeEach(() => { - cockpitProxies.zfcpController = {}; - }); - - it("returns undefined", async () => { - const result = await client.zfcp.getWWPNs({ id: "1" }); - expect(result).toBeUndefined(); - }); - }); - }); - - describe("#getLUNs", () => { - const luns = { - "0x500507630703d3b3": ["0x0000000000000000", "0x0000000000000001", "0x0000000000000002"], - }; - - const controllerProxy = { - GetLUNs: jest.fn().mockImplementation((wwpn) => luns[wwpn]), - }; - - beforeEach(() => { - cockpitProxies.zfcpController = { - "/org/opensuse/Agama/Storage1/zfcp_controllers/1": controllerProxy, - }; - }); - - it("returns a list with the LUNs for a WWPN of the zFCP controller", async () => { - const result = await client.zfcp.getLUNs({ id: "1" }, "0x500507630703d3b3"); - expect(result).toStrictEqual(luns["0x500507630703d3b3"]); - }); - - describe("if there is no proxy", () => { - beforeEach(() => { - cockpitProxies.zfcpController = {}; - }); - - it("returns undefined", async () => { - const result = await client.zfcp.getLUNs({ id: "1" }, "0x500507630703d3b3"); - expect(result).toBeUndefined(); - }); - }); - }); - - describe("#activateController", () => { - const activateFn = jest.fn().mockReturnValue(0); - - const controllerProxy = { - Activate: activateFn, - }; - - beforeEach(() => { - cockpitProxies.zfcpController = { - "/org/opensuse/Agama/Storage1/zfcp_controllers/1": controllerProxy, - }; - }); - - it("tries to activate the given zFCP controller", async () => { - const result = await client.zfcp.activateController({ id: "1" }); - expect(activateFn).toHaveBeenCalled(); - expect(result).toEqual(0); - }); - - describe("if there is no proxy", () => { - beforeEach(() => { - cockpitProxies.zfcpController = {}; - }); - - it("returns undefined", async () => { - const result = await client.zfcp.activateController({ id: "1" }); - expect(result).toBeUndefined(); - }); - }); - }); - - describe("#activateDisk", () => { - const activateDiskFn = jest.fn().mockReturnValue(0); - - const controllerProxy = { - ActivateDisk: activateDiskFn, - }; - - beforeEach(() => { - cockpitProxies.zfcpController = { - "/org/opensuse/Agama/Storage1/zfcp_controllers/1": controllerProxy, - }; - }); - - it("tries to activate the given zFCP disk", async () => { - const result = await client.zfcp.activateDisk( - { id: "1" }, - "0x500507630703d3b3", - "0x0000000000000000", - ); - expect(activateDiskFn).toHaveBeenCalledWith("0x500507630703d3b3", "0x0000000000000000"); - expect(result).toEqual(0); - }); - - describe("if there is no proxy", () => { - beforeEach(() => { - cockpitProxies.zfcpController = {}; - }); - - it("returns undefined", async () => { - const result = await client.zfcp.activateDisk( - { id: "1" }, - "0x500507630703d3b3", - "0x0000000000000000", - ); - expect(result).toBeUndefined(); - }); - }); - }); - - describe("#deactivateDisk", () => { - const deactivateDiskFn = jest.fn().mockReturnValue(0); - - const controllerProxy = { - ActivateDisk: deactivateDiskFn, - }; - - beforeEach(() => { - cockpitProxies.zfcpController = { - "/org/opensuse/Agama/Storage1/zfcp_controllers/1": controllerProxy, - }; - }); - - it("tries to deactivate the given zFCP disk", async () => { - const result = await client.zfcp.activateDisk( - { id: "1" }, - "0x500507630703d3b3", - "0x0000000000000000", - ); - expect(deactivateDiskFn).toHaveBeenCalledWith("0x500507630703d3b3", "0x0000000000000000"); - expect(result).toEqual(0); - }); - - describe("if there is no proxy", () => { - beforeEach(() => { - cockpitProxies.zfcpController = {}; - }); - - it("returns undefined", async () => { - const result = await client.zfcp.deactivateDisk( - { id: "1" }, - "0x500507630703d3b3", - "0x0000000000000000", - ); - expect(result).toBeUndefined(); - }); - }); - }); - - describe("#onControllerChanged", () => { - it("runs the handler when a zFCP controller changes", async () => { - const handler = jest.fn(); - await client.zfcp.onControllerChanged(handler); - - emitSignals(controllersCallbacks, "changed", { - path: "/org/opensuse/Agama/Storage1/zfcp_controllers/1", - Active: true, - LUNScan: true, - Channel: "0.0.fa00", - }); - - expect(handler).toHaveBeenCalledWith({ - id: "1", - active: true, - lunScan: true, - channel: "0.0.fa00", - }); - }); - }); - - describe("#onDiskAdded", () => { - it("runs the handler when a zFCP disk is added", async () => { - const handler = jest.fn(); - await client.zfcp.onDiskAdded(handler); - - emitSignals(disksCallbacks, "added", { - path: "/org/opensuse/Agama/Storage1/zfcp_disks/1", - Name: "/dev/sda", - Channel: "0.0.fa00", - WWPN: "0x500507630703d3b3", - LUN: "0x0000000000000000", - }); - - expect(handler).toHaveBeenCalledWith({ - id: "1", - name: "/dev/sda", - channel: "0.0.fa00", - wwpn: "0x500507630703d3b3", - lun: "0x0000000000000000", - }); - }); - }); - - describe("#onDiskChanged", () => { - it("runs the handler when a zFCP disk changes", async () => { - const handler = jest.fn(); - await client.zfcp.onDiskChanged(handler); - - emitSignals(disksCallbacks, "changed", { - path: "/org/opensuse/Agama/Storage1/zfcp_disks/1", - Name: "/dev/sda", - Channel: "0.0.fa00", - WWPN: "0x500507630703d3b3", - LUN: "0x0000000000000000", - }); - - expect(handler).toHaveBeenCalledWith({ - id: "1", - name: "/dev/sda", - channel: "0.0.fa00", - wwpn: "0x500507630703d3b3", - lun: "0x0000000000000000", - }); - }); - }); - - describe("#onDiskRemoved", () => { - it("runs the handler when a zFCP disk is removed", async () => { - const handler = jest.fn(); - await client.zfcp.onDiskRemoved(handler); - - emitSignals(disksCallbacks, "removed", { - path: "/org/opensuse/Agama/Storage1/zfcp_disks/1", - Name: "/dev/sda", - Channel: "0.0.fa00", - WWPN: "0x500507630703d3b3", - LUN: "0x0000000000000000", - }); - - expect(handler).toHaveBeenCalledWith({ - id: "1", - name: "/dev/sda", - channel: "0.0.fa00", - wwpn: "0x500507630703d3b3", - lun: "0x0000000000000000", - }); - }); - }); -}); - -describe("#iscsi", () => { - beforeEach(() => { - client = new StorageClient(new HTTPClient(new URL("http://localhost"))); - }); - - describe("#getInitiator", () => { - beforeEach(() => { - mockGetFn.mockResolvedValue({ ok: true, json: mockJsonFn }); - mockJsonFn.mockResolvedValue({ - name: "iqn.1996-04.com.suse:01:351e6d6249", - ibft: false, - }); - }); - - it("returns the current initiator", async () => { - const { name, ibft } = await client.iscsi.getInitiator(); - expect(name).toEqual("iqn.1996-04.com.suse:01:351e6d6249"); - expect(ibft).toEqual(false); - }); - - describe("when the HTTP call fails", () => { - beforeEach(() => { - mockGetFn.mockResolvedValue({ ok: false, json: undefined }); - }); - - it("returns undefined", async () => { - const initiator = await client.iscsi.getInitiator(); - expect(initiator).toBeUndefined(); - }); - }); - }); - - describe("#setInitiatorName", () => { - beforeEach(() => { - cockpitProxies.iscsiInitiator = { - InitiatorName: "iqn.1996-04.com.suse:01:351e6d6249", - }; - }); - - it("sets the given initiator name", async () => { - await client.iscsi.setInitiatorName("test"); - expect(mockPatchFn).toHaveBeenCalledWith("/storage/iscsi/initiator", { name: "test" }); - }); - }); - - describe("#getNodes", () => { - describe("if there is no exported iSCSI nodes yet", () => { - beforeEach(() => { - mockJsonFn.mockResolvedValue([]); - }); - - it("returns an empty list", async () => { - const result = await client.iscsi.getNodes(); - expect(result).toStrictEqual([]); - }); - }); - - describe("if there are exported iSCSI nodes", () => { - beforeEach(() => { - mockJsonFn.mockResolvedValue(contexts.withISCSINodes()); - }); - - it("returns a list with the exported iSCSI nodes", async () => { - const result = await client.iscsi.getNodes(); - expect(result.length).toEqual(2); - expect(result).toContainEqual({ - id: 1, - target: "iqn.2023-01.com.example:37dac", - address: "192.168.100.101", - port: 3260, - interface: "default", - ibft: false, - connected: false, - startup: "", - }); - expect(result).toContainEqual({ - id: 2, - target: "iqn.2023-01.com.example:74afb", - address: "192.168.100.102", - port: 3260, - interface: "default", - ibft: true, - connected: true, - startup: "onboot", - }); - }); - - describe("when the HTTP call fails", () => { - beforeEach(() => { - mockGetFn.mockResolvedValue({ ok: false, json: undefined }); - }); - - it("returns an empty list", async () => { - const result = await client.iscsi.getNodes(); - expect(result).toStrictEqual([]); - }); - }); - }); - }); - - describe("#discover", () => { - it("performs an iSCSI discovery with the given options", async () => { - const options = { - username: "test", - password: "12345", - reverseUsername: "target", - reversePassword: "nonsecret", - }; - await client.iscsi.discover("192.168.100.101", 3260, options); - expect(mockPostFn).toHaveBeenCalledWith("/storage/iscsi/discover", { - address: "192.168.100.101", - port: 3260, - options, - }); - }); - }); - - describe("#delete", () => { - it("deletes the given iSCSI node", async () => { - await client.iscsi.delete({ id: "1" }); - expect(mockDeleteFn).toHaveBeenCalledWith("/storage/iscsi/nodes/1"); - }); - }); - - describe("#login", () => { - const auth = { - username: "test", - password: "12345", - reverseUsername: "target", - reversePassword: "nonsecret", - startup: "automatic", - }; - - it("performs an iSCSI login with the given options", async () => { - const result = await client.iscsi.login({ id: "1" }, auth); - - expect(result).toEqual(0); - expect(mockPostFn).toHaveBeenCalledWith("/storage/iscsi/nodes/1/login", auth); - }); - - it("returns 1 when the startup is invalid", async () => { - mockPostFn.mockImplementation(() => ({ ok: false, json: mockJsonFn })); - mockJsonFn.mockResolvedValue("InvalidStartup"); - - const result = await client.iscsi.login({ id: "1" }, { ...auth, startup: "invalid" }); - expect(result).toEqual(1); - }); - - it("returns 2 in case of an error different from an invalid startup value", async () => { - mockPostFn.mockImplementation(() => ({ ok: false, json: mockJsonFn })); - mockJsonFn.mockResolvedValue("Failed"); - - const result = await client.iscsi.login({ id: "1" }, { ...auth, startup: "invalid" }); - expect(result).toEqual(2); - }); - }); - - describe("#logout", () => { - it("performs an iSCSI logout of the given node", async () => { - await client.iscsi.logout({ id: "1" }); - expect(mockPostFn).toHaveBeenCalledWith("/storage/iscsi/nodes/1/logout"); - }); - }); -}); From ca9cff4318703c33315d34b29a1c68ae0f76a195 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Thu, 5 Sep 2024 12:55:22 +0100 Subject: [PATCH 35/53] refactor(web): use queries in BootSelection --- web/src/components/storage/BootSelection.tsx | 73 ++++++++------------ 1 file changed, 28 insertions(+), 45 deletions(-) diff --git a/web/src/components/storage/BootSelection.tsx b/web/src/components/storage/BootSelection.tsx index e70788af68..c8d9356d5d 100644 --- a/web/src/components/storage/BootSelection.tsx +++ b/web/src/components/storage/BootSelection.tsx @@ -27,13 +27,11 @@ import { Card, CardBody, Form, FormGroup, Radio, Stack } from "@patternfly/react import { _ } from "~/i18n"; import { DevicesFormSelect } from "~/components/storage"; import { Page } from "~/components/core"; -import { Loading } from "~/components/layout"; import { deviceLabel } from "~/components/storage/utils"; import { sprintf } from "sprintf-js"; -import { useCancellablePromise } from "~/utils"; -import { useInstallerClient } from "~/context/installer"; import textStyles from "@patternfly/react-styles/css/utilities/Text/text"; import { StorageDevice } from "~/types/storage"; +import { useAvailableDevices, useProposalMutation, useProposalResult } from "~/queries/storage"; // FIXME: improve classNames // FIXME: improve and rename to BootSelectionDialog @@ -55,54 +53,40 @@ export default function BootSelectionDialog() { availableDevices?: StorageDevice[]; } - const { cancellablePromise } = useCancellablePromise(); - const { storage: client } = useInstallerClient(); const [state, setState] = useState({ load: false }); + const { settings } = useProposalResult(); + const availableDevices = useAvailableDevices(); + const updateProposal = useProposalMutation(); const navigate = useNavigate(); - // FIXME: Repeated code, see DeviceSelection. Use a context/hook or whatever - // approach to avoid duplication - const loadProposalResult = useCallback(async () => { - return await cancellablePromise(client.proposal.getResult()); - }, [client, cancellablePromise]); - - const loadAvailableDevices = useCallback(async () => { - return await cancellablePromise(client.proposal.getAvailableDevices()); - }, [client, cancellablePromise]); - useEffect(() => { if (state.load) return; - const load = async () => { - let selectedOption: string; - const { settings } = await loadProposalResult(); - const availableDevices: StorageDevice[] = await loadAvailableDevices(); - const { bootDevice, configureBoot, defaultBootDevice } = settings; - - if (!configureBoot) { - selectedOption = BOOT_DISABLED_ID; - } else if (configureBoot && bootDevice === "") { - selectedOption = BOOT_AUTO_ID; - } else { - selectedOption = BOOT_MANUAL_ID; - } - - const findDevice = (name: string) => availableDevices.find((d) => d.name === name); - - setState({ - load: true, - bootDevice: findDevice(bootDevice) || findDevice(defaultBootDevice) || availableDevices[0], - configureBoot, - defaultBootDevice: findDevice(defaultBootDevice), - availableDevices, - selectedOption, - }); - }; + let selectedOption: string; + const { bootDevice, configureBoot, defaultBootDevice } = settings; + + if (!configureBoot) { + selectedOption = BOOT_DISABLED_ID; + } else if (configureBoot && bootDevice === "") { + selectedOption = BOOT_AUTO_ID; + } else { + selectedOption = BOOT_MANUAL_ID; + } + + const findDevice = (name: string) => availableDevices.find((d) => d.name === name); + + setState({ + load: true, + bootDevice: findDevice(bootDevice) || findDevice(defaultBootDevice) || availableDevices[0], + configureBoot, + defaultBootDevice: findDevice(defaultBootDevice), + availableDevices, + selectedOption, + }); - load().catch(console.error); - }, [state, loadAvailableDevices, loadProposalResult]); + }, [availableDevices, settings]); - if (!state.load) return ; + if (!state.load) return; const onSubmit = async (e) => { e.preventDefault(); @@ -110,13 +94,12 @@ export default function BootSelectionDialog() { // const formData = new FormData(e.target); // const mode = formData.get("bootMode"); // const device = formData.get("bootDevice"); - const { settings } = await loadProposalResult(); const newSettings = { configureBoot: state.selectedOption !== BOOT_DISABLED_ID, bootDevice: state.selectedOption === BOOT_MANUAL_ID ? state.bootDevice.name : undefined, }; - await client.proposal.calculate({ ...settings, ...newSettings }); + await updateProposal.mutateAsync({ ...settings, ...newSettings }); navigate(".."); }; From 78b50f2b913463f87863132c084af5b4812a12c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Thu, 5 Sep 2024 14:13:31 +0100 Subject: [PATCH 36/53] refactor(web): use queries in SpacePolicySelection --- .../storage/SpacePolicySelection.tsx | 48 ++++++++----------- 1 file changed, 19 insertions(+), 29 deletions(-) diff --git a/web/src/components/storage/SpacePolicySelection.tsx b/web/src/components/storage/SpacePolicySelection.tsx index 38e203a5f4..3d5a726155 100644 --- a/web/src/components/storage/SpacePolicySelection.tsx +++ b/web/src/components/storage/SpacePolicySelection.tsx @@ -21,18 +21,17 @@ // @ts-check -import React, { useCallback, useEffect, useState } from "react"; +import React, { useEffect, useState } from "react"; import { Card, CardBody, Form, Grid, GridItem, Radio, Stack } from "@patternfly/react-core"; import { useNavigate } from "react-router-dom"; -import { Loading } from "~/components/layout"; import { Page } from "~/components/core"; import { SpaceActionsTable } from "~/components/storage"; -import { _ } from "~/i18n"; import { SPACE_POLICIES, SpacePolicy } from "~/components/storage/utils"; -import { noop, useCancellablePromise } from "~/utils"; -import { useInstallerClient } from "~/context/installer"; +import { noop } from "~/utils"; +import { _ } from "~/i18n"; import textStyles from "@patternfly/react-styles/css/utilities/Text/text"; import { SpaceAction } from "~/types/storage"; +import { useProposalMutation, useProposalResult } from "~/queries/storage"; /** * Widget to allow user picking desired policy to make space. @@ -77,36 +76,27 @@ const SpacePolicyPicker = ({ currentPolicy, onChange = noop }: { currentPolicy: * Renders a page that allows the user to select the space policy and actions. */ export default function SpacePolicySelection() { - const [state, setState] = useState({ load: false, settings: {} }); + const { settings } = useProposalResult(); + const updateProposal = useProposalMutation(); + const [state, setState] = useState({ load: false }); const [policy, setPolicy] = useState(); const [actions, setActions] = useState([]); const [expandedDevices, setExpandedDevices] = useState([]); const [customUsed, setCustomUsed] = useState(false); const [devices, setDevices] = useState([]); - const { cancellablePromise } = useCancellablePromise(); - const { storage: client } = useInstallerClient(); const navigate = useNavigate(); - const loadProposalResult = useCallback(async () => { - return await cancellablePromise(client.proposal.getResult()); - }, [client, cancellablePromise]); - useEffect(() => { if (state.load) return; // FIXME: move to a state/reducer - const load = async () => { - const { settings } = await loadProposalResult(); - const policy = SPACE_POLICIES.find((p) => p.id === settings.spacePolicy); - setPolicy(policy); - setActions(settings.spaceActions); - setCustomUsed(policy.id === "custom"); - setDevices(settings.installationDevices); - setState({ load: true, settings }); - }; - - load().catch(console.error); - }, [state, loadProposalResult]); + const policy = SPACE_POLICIES.find((p) => p.id === settings.spacePolicy); + setPolicy(policy); + setActions(settings.spaceActions); + setCustomUsed(policy.id === "custom"); + setDevices(settings.installationDevices); + setState({ load: true }); + }, [state]); useEffect(() => { if (policy?.id === "custom") setExpandedDevices(devices); @@ -123,10 +113,10 @@ export default function SpacePolicySelection() { // Resets actions (i.e., sets everything to "keep") if the custom policy has not been used yet. useEffect(() => { - if (policy?.id !== "custom" && !customUsed) setActions([]); + if (policy && policy?.id !== "custom" && !customUsed) setActions([]); }, [policy, customUsed, setActions]); - if (!state.load) return ; + if (!state.load) return; // Generates the action value according to the policy. const deviceAction = (device) => { @@ -147,9 +137,9 @@ export default function SpacePolicySelection() { const onSubmit = (e) => { e.preventDefault(); - // @ts-ignore - client.proposal.calculate({ - ...state.settings, + + updateProposal.mutateAsync({ + ...settings, spacePolicy: policy.id, spaceActions: actions, }); From 3f68682030f449fd84c1ee37e6c4f734294f3e46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Thu, 5 Sep 2024 15:04:35 +0100 Subject: [PATCH 37/53] refactor(web): convert BootConfigField to TypeScript --- ...ield.test.jsx => BootConfigField.test.tsx} | 46 ++--------------- ...ootConfigField.jsx => BootConfigField.tsx} | 50 ++++++++----------- 2 files changed, 26 insertions(+), 70 deletions(-) rename web/src/components/storage/{BootConfigField.test.jsx => BootConfigField.test.tsx} (62%) rename web/src/components/storage/{BootConfigField.jsx => BootConfigField.tsx} (70%) diff --git a/web/src/components/storage/BootConfigField.test.jsx b/web/src/components/storage/BootConfigField.test.tsx similarity index 62% rename from web/src/components/storage/BootConfigField.test.jsx rename to web/src/components/storage/BootConfigField.test.tsx index f1e66f0a5f..32763b157b 100644 --- a/web/src/components/storage/BootConfigField.test.jsx +++ b/web/src/components/storage/BootConfigField.test.tsx @@ -19,20 +19,13 @@ * find current contact information at www.suse.com. */ -// @ts-check - import React from "react"; -import { screen, within } from "@testing-library/react"; +import { screen } from "@testing-library/react"; import { plainRender } from "~/test-utils"; -import BootConfigField from "~/components/storage/BootConfigField"; - -/** - * @typedef {import("~/components/storage/BootConfigField").BootConfigFieldProps} BootConfigFieldProps - * @typedef {import ("~/client/storage").StorageDevice} StorageDevice - */ +import BootConfigField, { BootConfigFieldProps } from "~/components/storage/BootConfigField"; +import { StorageDevice } from "~/types/storage"; -/** @type {StorageDevice} */ -const sda = { +const sda: StorageDevice = { sid: 59, description: "A fake disk for testing", isDrive: true, @@ -54,8 +47,7 @@ const sda = { udevPaths: ["pci-0000:00-12", "pci-0000:00-12-ata"], }; -/** @type {BootConfigFieldProps} */ -let props; +let props: BootConfigFieldProps; beforeEach(() => { props = { @@ -64,38 +56,10 @@ beforeEach(() => { defaultBootDevice: undefined, availableDevices: [sda], isLoading: false, - onChange: jest.fn(), }; }); -/** - * Helper function that implicitly test that field provides a button for - * opening the dialog - */ -const openBootConfigDialog = async () => { - const { user } = plainRender(); - const button = screen.getByRole("button"); - await user.click(button); - const dialog = screen.getByRole("dialog", { name: "Partitions for booting" }); - - return { user, dialog }; -}; - describe.skip("BootConfigField", () => { - it("triggers onChange callback when user confirms the dialog", async () => { - const { user, dialog } = await openBootConfigDialog(); - const button = within(dialog).getByRole("button", { name: "Confirm" }); - await user.click(button); - expect(props.onChange).toHaveBeenCalled(); - }); - - it("does not trigger onChange callback when user cancels the dialog", async () => { - const { user, dialog } = await openBootConfigDialog(); - const button = within(dialog).getByRole("button", { name: "Cancel" }); - await user.click(button); - expect(props.onChange).not.toHaveBeenCalled(); - }); - describe("when installation is set for not configuring boot", () => { it("renders a text warning about it", () => { plainRender(); diff --git a/web/src/components/storage/BootConfigField.jsx b/web/src/components/storage/BootConfigField.tsx similarity index 70% rename from web/src/components/storage/BootConfigField.jsx rename to web/src/components/storage/BootConfigField.tsx index 3ea4558dec..c63ce21b12 100644 --- a/web/src/components/storage/BootConfigField.jsx +++ b/web/src/components/storage/BootConfigField.tsx @@ -21,7 +21,7 @@ // @ts-check -import React from "react"; +import React, { ReactNode } from "react"; import { Link as RouterLink } from "react-router-dom"; import { Skeleton } from "@patternfly/react-core"; import { _ } from "~/i18n"; @@ -29,51 +29,43 @@ import { sprintf } from "sprintf-js"; import { deviceLabel } from "~/components/storage/utils"; import { Icon } from "~/components/layout"; import { PATHS } from "~/routes/storage"; - -/** - * @typedef {import ("~/client/storage").StorageDevice} StorageDevice - */ +import { StorageDevice } from "~/types/storage"; /** * Internal component for building the link that navigates to selector * - * @param {object} props - * @param {boolean} [props.isBold=false] - Whether text should be wrapped by . + * @param props + * @param [props.isBold=false] - Whether text should be wrapped by . */ -const Link = ({ isBold = false }) => { +const Link = ({ isBold = false }: { isBold?: boolean; }) => { const text = _("Change boot options"); return {isBold ? {text} : text}; }; +export type BootConfig = { + configureBoot: boolean; + bootDevice: StorageDevice; +} + +export type BootConfigFieldProps = { + configureBoot: boolean; + bootDevice?: StorageDevice; + defaultBootDevice?: StorageDevice; + availableDevices: StorageDevice[]; + isLoading: boolean; +} + /** - * Allows to select the boot config. + * Summarizes how the system will boot. * @component - * - * @typedef {object} BootConfigFieldProps - * @property {boolean} configureBoot - * @property {StorageDevice|undefined} bootDevice - * @property {StorageDevice|undefined} defaultBootDevice - * @property {StorageDevice[]} availableDevices - * @property {boolean} isLoading - * @property {(boot: BootConfig) => void} onChange - * - * @typedef {object} BootConfig - * @property {boolean} configureBoot - * @property {StorageDevice} bootDevice - * - * @param {BootConfigFieldProps} props */ -export default function BootConfigField({ configureBoot, bootDevice, isLoading, onChange }) { - const onAccept = ({ configureBoot, bootDevice }) => { - onChange({ configureBoot, bootDevice }); - }; - +export default function BootConfigField({ configureBoot, bootDevice, isLoading }: BootConfigFieldProps) { if (isLoading && configureBoot === undefined) { return ; } - let value; + let value: ReactNode; if (!configureBoot) { value = ( From 766fcff9f141a9222adcc7ccb5466a929be2ec57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Thu, 5 Sep 2024 15:21:16 +0100 Subject: [PATCH 38/53] fix(web): do not pass onChange to BootConfigField --- .../components/storage/BootConfigField.test.tsx | 15 +++++++-------- .../components/storage/PartitionsField.test.tsx | 1 - web/src/components/storage/PartitionsField.tsx | 6 ------ .../storage/ProposalSettingsSection.tsx | 8 -------- 4 files changed, 7 insertions(+), 23 deletions(-) diff --git a/web/src/components/storage/BootConfigField.test.tsx b/web/src/components/storage/BootConfigField.test.tsx index 32763b157b..c7e11e0a3a 100644 --- a/web/src/components/storage/BootConfigField.test.tsx +++ b/web/src/components/storage/BootConfigField.test.tsx @@ -47,16 +47,15 @@ const sda: StorageDevice = { udevPaths: ["pci-0000:00-12", "pci-0000:00-12-ata"], }; -let props: BootConfigFieldProps; +const props: BootConfigFieldProps = { + configureBoot: false, + bootDevice: undefined, + defaultBootDevice: undefined, + availableDevices: [sda], + isLoading: false, +}; beforeEach(() => { - props = { - configureBoot: false, - bootDevice: undefined, - defaultBootDevice: undefined, - availableDevices: [sda], - isLoading: false, - }; }); describe.skip("BootConfigField", () => { diff --git a/web/src/components/storage/PartitionsField.test.tsx b/web/src/components/storage/PartitionsField.test.tsx index 04225997b6..afe5a1ded6 100644 --- a/web/src/components/storage/PartitionsField.test.tsx +++ b/web/src/components/storage/PartitionsField.test.tsx @@ -178,7 +178,6 @@ beforeEach(() => { bootDevice: undefined, defaultBootDevice: undefined, onVolumesChange: jest.fn(), - onBootChange: jest.fn(), }; }); diff --git a/web/src/components/storage/PartitionsField.tsx b/web/src/components/storage/PartitionsField.tsx index 72dab5e909..534d301008 100644 --- a/web/src/components/storage/PartitionsField.tsx +++ b/web/src/components/storage/PartitionsField.tsx @@ -614,7 +614,6 @@ type AdvancedProps = { bootDevice: StorageDevice | undefined; defaultBootDevice: StorageDevice | undefined; onVolumesChange: (volumes: Volume[]) => void; - onBootChange: (boot: BootConfig) => void; isLoading: boolean; }; @@ -634,7 +633,6 @@ const Advanced = ({ bootDevice, defaultBootDevice, onVolumesChange, - onBootChange, isLoading, }: AdvancedProps) => { const [isVolumeDialogOpen, setIsVolumeDialogOpen] = useState(false); @@ -742,7 +740,6 @@ const Advanced = ({ defaultBootDevice={defaultBootDevice} availableDevices={availableDevices} isLoading={isLoading} - onChange={onBootChange} /> ); @@ -760,7 +757,6 @@ export type PartitionsFieldProps = { defaultBootDevice: StorageDevice | undefined; isLoading?: boolean; onVolumesChange: (volumes: Volume[]) => void; - onBootChange: (boot: BootConfig) => void; } type BootConfig = { @@ -787,7 +783,6 @@ export default function PartitionsField({ defaultBootDevice, isLoading = false, onVolumesChange, - onBootChange, }: PartitionsFieldProps) { const [isExpanded, setIsExpanded] = useState(false); const onExpand = () => setIsExpanded(!isExpanded); @@ -833,7 +828,6 @@ export default function PartitionsField({ bootDevice={bootDevice} defaultBootDevice={defaultBootDevice} onVolumesChange={onVolumesChange} - onBootChange={onBootChange} isLoading={isLoading} /> diff --git a/web/src/components/storage/ProposalSettingsSection.tsx b/web/src/components/storage/ProposalSettingsSection.tsx index a6d2525b8a..8353cd87ca 100644 --- a/web/src/components/storage/ProposalSettingsSection.tsx +++ b/web/src/components/storage/ProposalSettingsSection.tsx @@ -84,13 +84,6 @@ export default function ProposalSettingsSection({ onChange(CHANGING.VOLUMES, { volumes }); }; - const changeBoot = ({ configureBoot, bootDevice }: BootConfig) => { - onChange(CHANGING.BOOT, { - configureBoot, - bootDevice: bootDevice?.name, - }); - }; - /** * @param {string} name * @returns {StorageDevice|undefined} @@ -140,7 +133,6 @@ export default function ProposalSettingsSection({ showSkeleton(isLoading, "PartitionsField", changing) || settings.volumes === undefined } onVolumesChange={changeVolumes} - onBootChange={changeBoot} /> From 9f4c5c3e587dd71baa9b05685ed2be700c3b688d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Thu, 5 Sep 2024 15:36:35 +0100 Subject: [PATCH 39/53] refactor(web): convert device-utils to TypeScript --- ...e-utils.test.jsx => device-utils.test.tsx} | 23 +++------- .../{device-utils.jsx => device-utils.tsx} | 42 +++++-------------- 2 files changed, 17 insertions(+), 48 deletions(-) rename web/src/components/storage/{device-utils.test.jsx => device-utils.test.tsx} (92%) rename web/src/components/storage/{device-utils.jsx => device-utils.tsx} (69%) diff --git a/web/src/components/storage/device-utils.test.jsx b/web/src/components/storage/device-utils.test.tsx similarity index 92% rename from web/src/components/storage/device-utils.test.jsx rename to web/src/components/storage/device-utils.test.tsx index 6f4b380660..ff87b74743 100644 --- a/web/src/components/storage/device-utils.test.jsx +++ b/web/src/components/storage/device-utils.test.tsx @@ -19,8 +19,6 @@ * find current contact information at www.suse.com. */ -// @ts-check - import React from "react"; import { screen } from "@testing-library/react"; import { plainRender } from "~/test-utils"; @@ -31,17 +29,11 @@ import { FilesystemLabel, toStorageDevice, } from "~/components/storage/device-utils"; +import { PartitionSlot, StorageDevice } from "~/types/storage"; -/** - * @typedef {import("~/client/storage").PartitionSlot} PartitionSlot - * @typedef {import("~/client/storage").StorageDevice} StorageDevice - */ - -/** @type {PartitionSlot} */ -const slot = { start: 1234, size: 256 }; +const slot: PartitionSlot = { start: 1234, size: 256 }; -/** @type {StorageDevice} */ -const vda = { +const vda: StorageDevice = { sid: 59, isDrive: true, type: "disk", @@ -61,8 +53,7 @@ const vda = { udevPaths: ["pci-0000:00-12", "pci-0000:00-12-ata"], }; -/** @type {StorageDevice} */ -const vda1 = { +const vda1: StorageDevice = { sid: 60, isDrive: false, type: "partition", @@ -80,8 +71,7 @@ const vda1 = { filesystem: { sid: 100, type: "ext4", mountPath: "/test", label: "system" }, }; -/** @type {StorageDevice} */ -const lvmLv1 = { +const lvmLv1: StorageDevice = { sid: 73, isDrive: false, type: "lvmLv", @@ -122,8 +112,7 @@ describe("DeviceName", () => { }); describe("DeviceDetails", () => { - /** @type {PartitionSlot|StorageDevice} */ - let item; + let item: PartitionSlot | StorageDevice; describe("if the item is a partition slot", () => { beforeEach(() => { diff --git a/web/src/components/storage/device-utils.jsx b/web/src/components/storage/device-utils.tsx similarity index 69% rename from web/src/components/storage/device-utils.jsx rename to web/src/components/storage/device-utils.tsx index 9c923c378e..4eb326b6ef 100644 --- a/web/src/components/storage/device-utils.jsx +++ b/web/src/components/storage/device-utils.tsx @@ -26,32 +26,21 @@ import { Label } from "@patternfly/react-core"; import { _ } from "~/i18n"; import { deviceBaseName, deviceSize } from "~/components/storage/utils"; - -/** - * @typedef {import ("~/client/storage").PartitionSlot} PartitionSlot - * @typedef {import ("~/client/storage").StorageDevice} StorageDevice - */ +import { PartitionSlot, StorageDevice } from "~/types/storage"; /** * Ensures the given item is a StorageDevice. - * - * @param {PartitionSlot|StorageDevice} item - * @returns {StorageDevice|undefined} */ -const toStorageDevice = (item) => { - const { sid } = /** @type {object} */ (item); - if (!sid) return undefined; - - return /** @type {StorageDevice} */ (item); +const toStorageDevice = (item: PartitionSlot | StorageDevice): StorageDevice | undefined => { + if ("sid" in item) { + return item; + } }; /** * @component - * - * @param {object} props - * @param {PartitionSlot|StorageDevice} props.item */ -const FilesystemLabel = ({ item }) => { +const FilesystemLabel = ({ item }: { item: PartitionSlot | StorageDevice; }) => { const device = toStorageDevice(item); if (!device) return null; @@ -67,11 +56,8 @@ const FilesystemLabel = ({ item }) => { /** * @component - * - * @param {object} props - * @param {PartitionSlot|StorageDevice} props.item */ -const DeviceName = ({ item }) => { +const DeviceName = ({ item }: { item: PartitionSlot | StorageDevice; }) => { const device = toStorageDevice(item); if (!device) return null; @@ -82,21 +68,18 @@ const DeviceName = ({ item }) => { /** * @component - * - * @param {object} props - * @param {PartitionSlot|StorageDevice} props.item */ -const DeviceDetails = ({ item }) => { +const DeviceDetails = ({ item }: { item: PartitionSlot | StorageDevice; }) => { const device = toStorageDevice(item); if (!device) return _("Unused space"); - const renderContent = (device) => { + const renderContent = (device: StorageDevice) => { if (!device.partitionTable && device.systems?.length > 0) return device.systems.join(", "); return device.description; }; - const renderPTableType = (device) => { + const renderPTableType = (device: StorageDevice) => { const type = device.partitionTable?.type; if (type) return ; }; @@ -110,11 +93,8 @@ const DeviceDetails = ({ item }) => { /** * @component - * - * @param {object} props - * @param {PartitionSlot|StorageDevice} props.item */ -const DeviceSize = ({ item }) => { +const DeviceSize = ({ item }: { item: PartitionSlot | StorageDevice; }) => { return deviceSize(item.size); }; From 253b3e1aec63b6adb91180812cc7e1730fb3fdfc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Thu, 5 Sep 2024 15:59:05 +0100 Subject: [PATCH 40/53] refactor(web): convert SpaceActionsTable to TypeScript --- ...le.test.jsx => SpaceActionsTable.test.tsx} | 25 +++------ ...ActionsTable.jsx => SpaceActionsTable.tsx} | 55 ++++++++----------- 2 files changed, 31 insertions(+), 49 deletions(-) rename web/src/components/storage/{SpaceActionsTable.test.jsx => SpaceActionsTable.test.tsx} (91%) rename web/src/components/storage/{SpaceActionsTable.jsx => SpaceActionsTable.tsx} (77%) diff --git a/web/src/components/storage/SpaceActionsTable.test.jsx b/web/src/components/storage/SpaceActionsTable.test.tsx similarity index 91% rename from web/src/components/storage/SpaceActionsTable.test.jsx rename to web/src/components/storage/SpaceActionsTable.test.tsx index 4467621abc..25771eb430 100644 --- a/web/src/components/storage/SpaceActionsTable.test.jsx +++ b/web/src/components/storage/SpaceActionsTable.test.tsx @@ -25,16 +25,10 @@ import React from "react"; import { screen, within } from "@testing-library/react"; import { gib } from "~/components/storage/utils"; import { plainRender } from "~/test-utils"; -import SpaceActionsTable from "~/components/storage/SpaceActionsTable"; +import SpaceActionsTable, { SpaceActionsTableProps } from "~/components/storage/SpaceActionsTable"; +import { StorageDevice } from "~/types/storage"; -/** - * @typedef {import ("~/client/storage").StorageDevice} StorageDevice - * @typedef {import ("~/client/storage").Volume} Volume - * @typedef {import ("~/components/storage/SpaceActionsTable").SpaceActionsTableProps} SpaceActionsTableProps - */ - -/** @type {StorageDevice} */ -const sda = { +const sda: StorageDevice = { sid: 59, isDrive: true, type: "disk", @@ -56,8 +50,7 @@ const sda = { udevPaths: ["pci-0000:00-12", "pci-0000:00-12-ata"], }; -/** @type {StorageDevice} */ -const sda1 = { +const sda1: StorageDevice = { sid: 69, name: "/dev/sda1", description: "Swap partition", @@ -68,8 +61,7 @@ const sda1 = { start: 1, }; -/** @type {StorageDevice} */ -const sda2 = { +const sda2: StorageDevice = { sid: 79, name: "/dev/sda2", description: "EXT4 partition", @@ -88,7 +80,7 @@ sda.partitionTable = { }; /** @type {StorageDevice} */ -const sdb = { +const sdb: StorageDevice = { sid: 62, name: "/dev/sdb", isDrive: true, @@ -99,7 +91,7 @@ const sdb = { }; /** @type {StorageDevice} */ -const sdc = { +const sdc: StorageDevice = { sid: 63, name: "/dev/sdc", isDrive: true, @@ -120,8 +112,7 @@ const deviceAction = (device) => { return "force_delete"; }; -/** @type {SpaceActionsTableProps} */ -let props; +let props: SpaceActionsTableProps; describe("SpaceActionsTable", () => { beforeEach(() => { diff --git a/web/src/components/storage/SpaceActionsTable.jsx b/web/src/components/storage/SpaceActionsTable.tsx similarity index 77% rename from web/src/components/storage/SpaceActionsTable.jsx rename to web/src/components/storage/SpaceActionsTable.tsx index 7007cc8090..c2490d5e62 100644 --- a/web/src/components/storage/SpaceActionsTable.jsx +++ b/web/src/components/storage/SpaceActionsTable.tsx @@ -44,22 +44,14 @@ import { } from "~/components/storage/device-utils"; import { TreeTable } from "~/components/core"; import { Icon } from "~/components/layout"; - -/** - * @typedef {import("~/client/storage").PartitionSlot} PartitionSlot - * @typedef {import ("~/client/storage").SpaceAction} SpaceAction - * @typedef {import ("~/client/storage").StorageDevice} StorageDevice - * @typedef {import("../core/TreeTable").TreeTableColumn} TreeTableColumn - */ +import { PartitionSlot, SpaceAction, StorageDevice } from "~/types/storage"; +import { TreeTableColumn } from "./ProposalResultTable"; /** * Info about the device. * @component - * - * @param {object} props - * @param {StorageDevice} props.device */ -const DeviceInfoContent = ({ device }) => { +const DeviceInfoContent = ({ device }: { device: StorageDevice; }) => { const minSize = device.shrinking?.supported; if (minSize) { @@ -91,7 +83,7 @@ const DeviceInfoContent = ({ device }) => { * @param {object} props * @param {StorageDevice} props.device */ -const DeviceInfo = ({ device }) => { +const DeviceInfo = ({ device }: { device: StorageDevice; }) => { return ( }>