diff --git a/web/src/api/storage.ts b/web/src/api/storage.ts new file mode 100644 index 0000000000..bd0b7fac52 --- /dev/null +++ b/web/src/api/storage.ts @@ -0,0 +1,31 @@ +/* + * 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 { post } from "~/api/http"; + +/** + * Starts the storage probing process. + */ +const probe = (): Promise => post("/api/storage/probe"); + +export { + probe +} diff --git a/web/src/api/storage/devices.ts b/web/src/api/storage/devices.ts new file mode 100644 index 0000000000..fe379663e7 --- /dev/null +++ b/web/src/api/storage/devices.ts @@ -0,0 +1,162 @@ +/* + * 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, DevicesDirtyResponse, 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)); +} + +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 new file mode 100644 index 0000000000..3309fa54d7 --- /dev/null +++ b/web/src/api/storage/proposal.ts @@ -0,0 +1,47 @@ +/* + * 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, put } from "../http"; +import { Action, ProductParams, ProposalSettings, ProposalSettingsPatch, 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"); + +const calculate = (settings: ProposalSettingsPatch) => put("/api/storage/proposal/settings", settings); + +export { + fetchUsableDevices, + fetchProductParams, + fetchDefaultVolume, + fetchSettings, + fetchActions, + calculate, +} 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 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/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/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.", { 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); 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.jsx deleted file mode 100644 index 16fd0efbea..0000000000 --- a/web/src/components/storage/ProposalPage.jsx +++ /dev/null @@ -1,346 +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. - */ - -import React, { useCallback, useReducer, useEffect, useRef } from "react"; -import { Grid, GridItem, Stack } from "@patternfly/react-core"; -import { Page, Drawer } from "~/components/core/"; -import ProposalTransactionalInfo from "./ProposalTransactionalInfo"; -import ProposalSettingsSection from "./ProposalSettingsSection"; -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"; - -/** - * @typedef {import ("~/components/storage/utils").SpacePolicy} SpacePolicy - */ - -const initialState = { - loading: true, - // which UI item is being changed by user - changing: undefined, - availableDevices: [], - volumeDevices: [], - volumeTemplates: [], - encryptionMethods: [], - settings: {}, - system: [], - staging: [], - 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_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 }; - } - - case "UPDATE_SETTINGS": { - const { settings, changing } = action.payload; - return { ...state, settings, changing }; - } - - case "UPDATE_DEVICES": { - const { system, staging } = action.payload; - return { ...state, system, staging }; - } - - default: { - return state; - } - } -}; - -/** - * Which UI item is being changed by user - */ -export const CHANGING = Object.freeze({ - ENCRYPTION: Symbol("encryption"), - TARGET: Symbol("target"), - VOLUMES: Symbol("volumes"), - POLICY: Symbol("policy"), - BOOT: Symbol("boot"), -}); - -// mapping of not affected values for settings components -// key: component name -// value: list of items which can be changed without affecting -// the state of the component -export const NOT_AFFECTED = { - // the EncryptionField shows the skeleton only during initial load, - // it does not depend on any changed item and does not show skeleton later. - // the ProposalResultSection is refreshed always - InstallationDeviceField: [CHANGING.ENCRYPTION, CHANGING.BOOT, CHANGING.POLICY, CHANGING.VOLUMES], - PartitionsField: [CHANGING.ENCRYPTION, CHANGING.POLICY], - 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 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(client.system.getDevices())) || []; - const staging = (await cancellablePromise(client.staging.getDevices())) || []; - return { system, staging }; - }, [client, cancellablePromise]); - - const calculateProposal = useCallback( - async (settings) => { - return await cancellablePromise(client.proposal.calculate(settings)); - }, - [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); - } - - const availableDevices = await loadAvailableDevices(); - dispatch({ type: "UPDATE_AVAILABLE_DEVICES", payload: { availableDevices } }); - - const volumeDevices = await loadVolumeDevices(); - dispatch({ type: "UPDATE_VOLUME_DEVICES", payload: { volumeDevices } }); - - const encryptionMethods = await loadEncryptionMethods(); - dispatch({ type: "UPDATE_ENCRYPTION_METHODS", payload: { encryptionMethods } }); - - 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" }); - }, [ - calculateProposal, - cancellablePromise, - client, - loadAvailableDevices, - loadVolumeDevices, - loadDevices, - loadEncryptionMethods, - loadProposalResult, - loadVolumeTemplates, - ]); - - const calculate = useCallback( - async (settings) => { - dispatch({ type: "START_LOADING" }); - - await calculateProposal(settings); - - 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], - ); - - 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); - } - }, [client, load, state.settings]); - - const changeSettings = async (changing, settings) => { - const newSettings = { ...state.settings, ...settings }; - - dispatch({ type: "UPDATE_SETTINGS", payload: { settings: newSettings, changing } }); - calculate(newSettings).catch(console.error); - }; - - const spacePolicy = SPACE_POLICIES.find((p) => p.id === state.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 ( - - -

{_("Storage")}

-
- - - - - - - - - - {_("Planned Actions")}} - panelContent={} - > - - - - - - - - -
- ); -} 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 new file mode 100644 index 0000000000..712724a774 --- /dev/null +++ b/web/src/components/storage/ProposalPage.tsx @@ -0,0 +1,148 @@ +/* + * 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. + */ + +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"; +import ProposalSettingsSection from "./ProposalSettingsSection"; +import ProposalResultSection from "./ProposalResultSection"; +import ProposalActionsSummary from "~/components/storage/ProposalActionsSummary"; +import { ProposalActionsDialog } from "~/components/storage"; +import { _ } from "~/i18n"; +import { SPACE_POLICIES } from "~/components/storage/utils"; +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"; +import { probe } from "~/api/storage"; + +/** + * Which UI item is being changed by user + */ +export const CHANGING = Object.freeze({ + ENCRYPTION: Symbol("encryption"), + TARGET: Symbol("target"), + VOLUMES: Symbol("volumes"), + POLICY: Symbol("policy"), + BOOT: Symbol("boot"), +}); + +// mapping of not affected values for settings components +// key: component name +// value: list of items which can be changed without affecting +// the state of the component +export const NOT_AFFECTED = { + // the EncryptionField shows the skeleton only during initial load, + // it does not depend on any changed item and does not show skeleton later. + // the ProposalResultSection is refreshed always + InstallationDeviceField: [CHANGING.ENCRYPTION, CHANGING.BOOT, CHANGING.POLICY, CHANGING.VOLUMES], + PartitionsField: [CHANGING.ENCRYPTION, CHANGING.POLICY], + ProposalActionsSummary: [CHANGING.ENCRYPTION, CHANGING.TARGET], +}; + +export default function ProposalPage() { + const { cancellablePromise } = useCancellablePromise(); + const drawerRef = useRef(); + const systemDevices = useDevices("system"); + const stagingDevices = useDevices("result"); + const availableDevices = useAvailableDevices(); + const volumeDevices = useVolumeDevices(); + const volumeTemplates = useVolumeTemplates({ suspense: true }); + const { encryptionMethods } = useProductParams({ suspense: true }); + const { actions, settings } = useProposalResult(); + const updateProposal = useProposalMutation(); + const deprecated = useDeprecated(); + useDeprecatedChanges(); + + const errors = useIssues("storage") + .filter((s) => s.severity === IssueSeverity.Error) + .map(toValidationError); + + useEffect(() => { + if (deprecated) { + cancellablePromise(probe()); + } + }, [deprecated]); + + const changeSettings = async (changing, updated: object) => { + const newSettings = { ...settings, ...updated }; + updateProposal.mutateAsync(newSettings).catch(console.error); + }; + + const spacePolicy = SPACE_POLICIES.find((p) => p.id === settings.spacePolicy); + + return ( + + +

{_("Storage")}

+
+ + + + + + + + + + {_("Planned Actions")}} + panelContent={} + > + + + + + + + + +
+ ); +} diff --git a/web/src/queries/storage.ts b/web/src/queries/storage.ts new file mode 100644 index 0000000000..9f5e08d3b6 --- /dev/null +++ b/web/src/queries/storage.ts @@ -0,0 +1,318 @@ +/* + * 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 { 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 { useInstallerClient } from "~/context/installer"; +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], + 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 = (): Volume[] => { + const systemDevices = useDevices("system", { suspense: true }); + const product = useProductParams(); + 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 +}; + +/** + * Hook that returns the current proposal (settings and actions). + */ +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: APIProposalSettings, 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) => { + 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; +} + +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, + useProposalMutation, + useDeprecated, + useDeprecatedChanges +} diff --git a/web/src/types/storage.ts b/web/src/types/storage.ts new file mode 100644 index 0000000000..ec1d7c6d41 --- /dev/null +++ b/web/src/types/storage.ts @@ -0,0 +1,186 @@ +/* + * 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; + encryptionPBKDFunction?: string, + spacePolicy: string; + spaceActions: SpaceAction[]; + volumes: Volume[]; + installationDevices: StorageDevice[]; +}; + +type SpaceAction = { + device: string; + action: 'force_delete' | 'resize' +}; + +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 +}; 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 });