From 3f75124e43d755b9e8fd4ab4f43d82f4d1a12bfa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Thu, 5 Mar 2026 17:08:30 +0000 Subject: [PATCH 01/23] Improve zFCP config schema to generate TS types --- rust/agama-lib/share/zfcp.schema.json | 53 ++++++++++++++------------- 1 file changed, 28 insertions(+), 25 deletions(-) diff --git a/rust/agama-lib/share/zfcp.schema.json b/rust/agama-lib/share/zfcp.schema.json index 240fc2f829..0cc768a44b 100644 --- a/rust/agama-lib/share/zfcp.schema.json +++ b/rust/agama-lib/share/zfcp.schema.json @@ -17,31 +17,34 @@ "devices": { "description": "List of zFCP devices.", "type": "array", - "items": { - "type": "object", - "additionalProperties": false, - "required": ["channel", "wwpn", "lun"], - "properties": { - "channel": { - "description": "zFCP controller channel id.", - "type": "string", - "examples": ["0.0.fa00"] - }, - "wwpn": { - "description": "WWPN of the target port.", - "type": "string", - "examples": ["0x500507630300c562"] - }, - "lun": { - "description": "LUN of the SCSI device.", - "type": "string", - "examples": ["0x4010403300000000"] - }, - "active": { - "description": "Whether to activate the device.", - "type": "boolean", - "default": true - } + "items": { "$ref": "#/$defs/device" } + } + }, + "$defs": { + "device": { + "type": "object", + "additionalProperties": false, + "required": ["channel", "wwpn", "lun"], + "properties": { + "channel": { + "description": "zFCP controller channel id.", + "type": "string", + "examples": ["0.0.fa00"] + }, + "wwpn": { + "description": "WWPN of the target port.", + "type": "string", + "examples": ["0x500507630300c562"] + }, + "lun": { + "description": "LUN of the SCSI device.", + "type": "string", + "examples": ["0x4010403300000000"] + }, + "active": { + "description": "Whether to activate the device.", + "type": "boolean", + "default": true } } } From e75b253f68c5ee9e0f1694bef5710491bd9c45d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Thu, 5 Mar 2026 17:09:55 +0000 Subject: [PATCH 02/23] Small fixes in DASD hooks --- web/src/hooks/model/config/dasd.ts | 28 +++++++++++----------------- web/src/hooks/model/system/dasd.ts | 2 +- 2 files changed, 12 insertions(+), 18 deletions(-) diff --git a/web/src/hooks/model/config/dasd.ts b/web/src/hooks/model/config/dasd.ts index 6352876226..b243930f56 100644 --- a/web/src/hooks/model/config/dasd.ts +++ b/web/src/hooks/model/config/dasd.ts @@ -23,11 +23,8 @@ import { useSuspenseQuery } from "@tanstack/react-query"; import { configQuery } from "~/hooks/model/config"; import { patchConfig, Response } from "~/api"; - -import type { Config, DASD } from "~/model/config"; import { extendCollection } from "~/utils"; - -type addOrUpdateDevicesFn = (devices: DASD.Device[]) => Response; +import type { Config, DASD } from "~/model/config"; /** * Extract DASD config from a config object. @@ -39,7 +36,7 @@ type addOrUpdateDevicesFn = (devices: DASD.Device[]) => Response; * @see {@link https://tanstack.com/query/latest/docs/framework/react/guides/render-optimizations#select TanStack Query Select} * @see {@link https://tkdodo.eu/blog/react-query-selectors-supercharged#what-is-select Query Selectors Supercharged} */ -const dasdSelector = (data: Config | undefined): DASD.Config => data?.dasd; +const dasdSelector = (data: Config | null): DASD.Config | null => data?.dasd || null; /** * Hook to retrieve DASD configuration object. @@ -52,7 +49,7 @@ const dasdSelector = (data: Config | undefined): DASD.Config => data?.dasd; * } * ``` */ -function useConfig(): DASD.Config | undefined { +function useConfig(): DASD.Config | null { const { data } = useSuspenseQuery({ ...configQuery, select: dasdSelector, @@ -60,27 +57,24 @@ function useConfig(): DASD.Config | undefined { return data; } +type addOrUpdateDevicesFn = (devices: DASD.Device[]) => Response; + /** * Update or add if does not exist yet given devices to DASD configuration. - * - * @remarks - * Falls back to empty config when useConfig returns undefined. - * - * @todo Remove fallback once useConfig returns empty object by default */ function useAddOrUpdateDevices(): addOrUpdateDevicesFn { - const config = useConfig() || {}; + const config = useConfig(); - return (devices: DASD.Device[]) => { - const { all: newDevicesConfig } = extendCollection(config.devices, { + return (devices: DASD.Device[]): Response => { + const { all } = extendCollection(config?.devices, { with: devices, matching: "channel", precedence: "extensionWins", }); - return patchConfig({ - dasd: { ...config, devices: newDevicesConfig }, - }); + const newConfig = config ? { ...config, devices: all } : { devices: all }; + + return patchConfig({ dasd: newConfig }); }; } diff --git a/web/src/hooks/model/system/dasd.ts b/web/src/hooks/model/system/dasd.ts index fefe1f4763..4aeeb16b0e 100644 --- a/web/src/hooks/model/system/dasd.ts +++ b/web/src/hooks/model/system/dasd.ts @@ -34,7 +34,7 @@ import type { System, DASD } from "~/model/system"; * @see {@link https://tanstack.com/query/latest/docs/framework/react/guides/render-optimizations#select TanStack Query Select} * @see {@link https://tkdodo.eu/blog/react-query-selectors-supercharged#what-is-select Query Selectors Supercharged} */ -const dasdSelector = (data: System | null): DASD.System => data?.dasd; +const dasdSelector = (data: System | null): DASD.System | null => data?.dasd || null; /** * Retrieve DASD system information. From a50a0c93388f6e0fe56793817df4ac4b5ac378b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Thu, 5 Mar 2026 17:11:01 +0000 Subject: [PATCH 03/23] Add model and hooks for zFCP --- web/src/hooks/model/config.ts | 2 + web/src/hooks/model/config/zfcp.ts | 67 ++++++++++++++++++++++++++ web/src/hooks/model/system.ts | 5 +- web/src/hooks/model/system/zfcp.ts | 37 +++++++++++++++ web/src/model/config.ts | 8 ++-- web/src/model/config/zfcp.ts | 75 ++++++++++++++++++++++++++++++ web/src/model/system.ts | 7 ++- web/src/model/system/zfcp.ts | 23 +++++++++ web/src/openapi/config/zfcp.ts | 37 +++++++++++++++ web/src/openapi/system/zfcp.ts | 45 ++++++++++++++++++ 10 files changed, 299 insertions(+), 7 deletions(-) create mode 100644 web/src/hooks/model/config/zfcp.ts create mode 100644 web/src/hooks/model/system/zfcp.ts create mode 100644 web/src/model/config/zfcp.ts create mode 100644 web/src/model/system/zfcp.ts create mode 100644 web/src/openapi/config/zfcp.ts create mode 100644 web/src/openapi/system/zfcp.ts diff --git a/web/src/hooks/model/config.ts b/web/src/hooks/model/config.ts index 5d1fa538f9..0ff67ae134 100644 --- a/web/src/hooks/model/config.ts +++ b/web/src/hooks/model/config.ts @@ -57,3 +57,5 @@ export * as network from "~/hooks/model/config/network"; export * as product from "~/hooks/model/config/product"; export * as storage from "~/hooks/model/config/storage"; export * as iscsi from "~/hooks/model/config/iscsi"; +export * as dasd from "~/hooks/model/config/dasd"; +export * as zfcp from "~/hooks/model/config/zfcp"; diff --git a/web/src/hooks/model/config/zfcp.ts b/web/src/hooks/model/config/zfcp.ts new file mode 100644 index 0000000000..b7a32c7c23 --- /dev/null +++ b/web/src/hooks/model/config/zfcp.ts @@ -0,0 +1,67 @@ +/* + * Copyright (c) [2026] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import { useSuspenseQuery } from "@tanstack/react-query"; +import { configQuery } from "~/hooks/model/config"; +import { patchConfig, Response } from "~/api"; +import zfcp from "~/model/config/zfcp"; +import type { Config, ZFCP } from "~/model/config"; + +const selectConfig = (data: Config | null): ZFCP.Config => data?.zfcp || null; + +function useConfig(): ZFCP.Config | null { + const { data } = useSuspenseQuery({ + ...configQuery, + select: selectConfig, + }); + return data; +} + +type addControllersFn = (controllers: string[]) => Response; + +function useAddControllers(): addControllersFn { + const config = useConfig(); + + return (controllers: string[]): Response => { + const newConfig = zfcp.addControllers(config, controllers); + return patchConfig({ zfcp: newConfig }); + }; +} + +type addDevicesFn = (devices: ZFCP.Device[]) => Response; + +/** + * Provides a function for adding devices to the zFCP config. + * + * If a device already exists in the config, then it is replaced by the new device. + */ +function useAddDevices(): addDevicesFn { + const config = useConfig(); + + return (devices: ZFCP.Device[]): Response => { + const newConfig = zfcp.addDevices(config, devices); + return patchConfig({ zfcp: newConfig }); + }; +} + +export type { addControllersFn, addDevicesFn }; +export { useConfig, useAddControllers, useAddDevices }; diff --git a/web/src/hooks/model/system.ts b/web/src/hooks/model/system.ts index bd5753ee1d..2488a25a7c 100644 --- a/web/src/hooks/model/system.ts +++ b/web/src/hooks/model/system.ts @@ -55,7 +55,8 @@ function useSystemChanges() { export { systemQuery, useSystem, useSystemChanges }; export * as l10n from "~/hooks/model/system/l10n"; -export * as storage from "~/hooks/model/system/storage"; export * as software from "~/hooks/model/system/software"; -export * as dasd from "~/hooks/model/system/dasd"; +export * as storage from "~/hooks/model/system/storage"; export * as iscsi from "~/hooks/model/system/iscsi"; +export * as dasd from "~/hooks/model/system/dasd"; +export * as zfcp from "~/hooks/model/system/zfcp"; diff --git a/web/src/hooks/model/system/zfcp.ts b/web/src/hooks/model/system/zfcp.ts new file mode 100644 index 0000000000..581d6ce48b --- /dev/null +++ b/web/src/hooks/model/system/zfcp.ts @@ -0,0 +1,37 @@ +/* + * Copyright (c) [2026] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import { useSuspenseQuery } from "@tanstack/react-query"; +import { systemQuery } from "~/hooks/model/system"; +import type { System, ZFCP } from "~/model/system"; + +const selectSystem = (system: System | null): ZFCP.System | null => system?.zfcp || null; + +function useSystem(): ZFCP.System | null { + const { data } = useSuspenseQuery({ + ...systemQuery, + select: selectSystem, + }); + return data; +} + +export { useSystem }; diff --git a/web/src/model/config.ts b/web/src/model/config.ts index ad81a4157c..b9cffa10cb 100644 --- a/web/src/model/config.ts +++ b/web/src/model/config.ts @@ -28,8 +28,9 @@ import type * as Software from "~/openapi/config/software"; import type * as User from "~/model/config/user"; import type * as Root from "~/model/config/root"; import type * as Storage from "~/openapi/config/storage"; -import type * as DASD from "~/openapi/config/dasd"; import type * as ISCSI from "~/model/config/iscsi"; +import type * as DASD from "~/openapi/config/dasd"; +import type * as ZFCP from "~/openapi/config/zfcp"; type Config = { hostname?: Hostname.Config; @@ -37,11 +38,12 @@ type Config = { network?: Network.Config; product?: Product.Config; storage?: Storage.Config; - dasd?: DASD.Config; iscsi?: ISCSI.Config; + dasd?: DASD.Config; + zfcp?: ZFCP.Config; software?: Software.Config; user?: User.Config; root?: Root.Config; }; -export type { Config, Hostname, Product, L10n, Network, Storage, User, Root, ISCSI, DASD }; +export type { Config, Hostname, Product, L10n, Network, Storage, ISCSI, DASD, ZFCP, User, Root }; diff --git a/web/src/model/config/zfcp.ts b/web/src/model/config/zfcp.ts new file mode 100644 index 0000000000..172df92d8d --- /dev/null +++ b/web/src/model/config/zfcp.ts @@ -0,0 +1,75 @@ +/* + * Copyright (c) [2026] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import { Config, Device } from "~/openapi/config/zfcp"; +import { unique } from "radashi"; + +function defaultConfig(): Config { + return {}; +} + +function findDeviceIndex(devices: Device[], device: Device): number { + return devices.findIndex( + (d) => d.channel === device.channel && d.wwpn === device.wwpn && d.lun === device.lun, + ); +} + +function addDevice(config: Config, device: Device): Config { + const devices = [...(config.devices || [])]; + const index = findDeviceIndex(devices, device); + + if (index === -1) { + return { ...config, devices: [...devices, device] }; + } else { + return { ...config, devices: devices.with(index, device) }; + } +} + +/** Returns a new config adding the given controllers. */ +function addControllers(config: Config | null, controllers: string[]): Config { + const currentConfig = config || defaultConfig(); + + return { + ...currentConfig, + controllers: unique([...(currentConfig.controllers || []), ...controllers]), + }; +} + +/** + * Returns a new config adding the given devices. + * + * The returned config contains all the devices from the given config plus the given list of + * devices. If a device of the list already exists in the given config, then the device from the + * list replaces the device from the config. + */ +function addDevices(config: Config | null, devices: Device[]): Config { + const currentConfig = config || defaultConfig(); + + if (devices.length === 0) return { ...currentConfig }; + + const [device, ...rest] = devices; + + return addDevices(addDevice(currentConfig, device), rest); +} + +export type * from "~/openapi/config/zfcp"; +export default { addControllers, addDevices }; diff --git a/web/src/model/system.ts b/web/src/model/system.ts index 0b53783267..6b0d1db006 100644 --- a/web/src/model/system.ts +++ b/web/src/model/system.ts @@ -26,8 +26,9 @@ import type * as L10n from "~/model/system/l10n"; import type * as Network from "~/model/system/network"; import type * as Software from "~/model/system/software"; import type * as Storage from "~/model/system/storage"; -import type * as DASD from "~/model/system/dasd"; import type * as ISCSI from "~/model/system/iscsi"; +import type * as DASD from "~/model/system/dasd"; +import type * as ZFCP from "~/model/system/zfcp"; type System = { hardware?: Hardware.System; @@ -37,8 +38,9 @@ type System = { products?: Product[]; software?: Software.System; storage?: Storage.System; - dasd?: DASD.System; iscsi?: ISCSI.System; + dasd?: DASD.System; + zfcp?: ZFCP.System; }; type Product = { @@ -96,4 +98,5 @@ export type { Storage, ISCSI, DASD, + ZFCP, }; diff --git a/web/src/model/system/zfcp.ts b/web/src/model/system/zfcp.ts new file mode 100644 index 0000000000..f246cc713a --- /dev/null +++ b/web/src/model/system/zfcp.ts @@ -0,0 +1,23 @@ +/* + * Copyright (c) [2026] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +export type * from "~/openapi/system/zfcp"; diff --git a/web/src/openapi/config/zfcp.ts b/web/src/openapi/config/zfcp.ts new file mode 100644 index 0000000000..98d2cd8729 --- /dev/null +++ b/web/src/openapi/config/zfcp.ts @@ -0,0 +1,37 @@ +/** + * This file was automatically generated by json-schema-to-typescript. + * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, + * and run json-schema-to-typescript to regenerate this file. + */ + +/** + * zFCP config. + */ +export interface Config { + /** + * List of zFCP controllers. + */ + controllers?: string[]; + /** + * List of zFCP devices. + */ + devices?: Device[]; +} +export interface Device { + /** + * zFCP controller channel id. + */ + channel: string; + /** + * WWPN of the target port. + */ + wwpn: string; + /** + * LUN of the SCSI device. + */ + lun: string; + /** + * Whether to activate the device. + */ + active?: boolean; +} diff --git a/web/src/openapi/system/zfcp.ts b/web/src/openapi/system/zfcp.ts new file mode 100644 index 0000000000..683c029de4 --- /dev/null +++ b/web/src/openapi/system/zfcp.ts @@ -0,0 +1,45 @@ +/** + * This file was automatically generated by json-schema-to-typescript. + * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, + * and run json-schema-to-typescript to regenerate this file. + */ + +/** + * API description of the zFCP system + */ +export interface System { + /** + * Whether automatic LUN scan is active in the system. + */ + lunScan: boolean; + /** + * zFCP controllers + */ + controllers: Controller[]; + /** + * zFCP devices + */ + devices: Device[]; +} +export interface Controller { + channel: string; + /** + * All available WWPNs. + */ + wwpns: string[]; + /** + * Whether the controller automatically scans LUNs. + */ + lunScan: boolean; + active: boolean; +} +export interface Device { + channel: string; + wwpn: string; + lun: string; + active: boolean; + /** + * Device name assigned by the kernel once it is active. + */ + deviceName?: string; +} From b54cd4b196263d742a384af4099d588f8f26e8cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Fri, 6 Mar 2026 12:09:04 +0000 Subject: [PATCH 04/23] fix(web): adapt dasd test to latest changes Now null is expected instead of undefined --- web/src/hooks/model/config/dasd.test.ts | 8 ++++---- web/src/hooks/model/system/dasd.test.ts | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/web/src/hooks/model/config/dasd.test.ts b/web/src/hooks/model/config/dasd.test.ts index f1a7ed6f5e..240400f943 100644 --- a/web/src/hooks/model/config/dasd.test.ts +++ b/web/src/hooks/model/config/dasd.test.ts @@ -62,22 +62,22 @@ describe("hooks/model/storage/dasd", () => { expect(result.current).not.toHaveProperty("product"); }); - it("returns undefined when config data is undefined", () => { + it("returns null when config data is undefined", () => { mockConfigQuery(undefined); const { result } = renderHook(() => useConfig()); - expect(result.current).toBeUndefined(); + expect(result.current).toBeNull(); }); - it("returns undefined when dasd property is not present", () => { + it("returns null when dasd property is not present", () => { mockConfigQuery({ product: { id: "sle", mode: "standard", registrationCode: "" }, }); const { result } = renderHook(() => useConfig()); - expect(result.current).toBeUndefined(); + expect(result.current).toBeNull(); }); }); diff --git a/web/src/hooks/model/system/dasd.test.ts b/web/src/hooks/model/system/dasd.test.ts index 30633f2bb0..eeac28de33 100644 --- a/web/src/hooks/model/system/dasd.test.ts +++ b/web/src/hooks/model/system/dasd.test.ts @@ -49,22 +49,22 @@ describe("~/hooks/model/system/dasd", () => { expect(result.current).not.toHaveProperty("product"); }); - it("returns undefined when system data is undefined", () => { + it("returns null when system data is undefined", () => { mockSystemQuery(undefined); const { result } = renderHook(() => useSystem()); - expect(result.current).toBeUndefined(); + expect(result.current).toBeNull(); }); - it("returns undefined when dasd property is not present", () => { + it("returns null when dasd property is not present", () => { mockSystemQuery({ product: { id: "sle", mode: "standard", registrationCode: "" }, }); const { result } = renderHook(() => useSystem()); - expect(result.current).toBeUndefined(); + expect(result.current).toBeNull(); }); }); }); From 584b9e99820c9d5071466dccba1d12fc0c1931b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Fri, 6 Mar 2026 13:19:26 +0000 Subject: [PATCH 05/23] fix(web): add missing test for zFCP models and hooks For ensuring code works as expectd and allow refactor it with more confidence. --- web/src/hooks/model/config/zfcp.test.ts | 243 ++++++++++++++++++++++++ web/src/hooks/model/system/zfcp.test.ts | 79 ++++++++ web/src/model/config/zfcp.test.ts | 172 +++++++++++++++++ web/src/model/config/zfcp.ts | 2 +- 4 files changed, 495 insertions(+), 1 deletion(-) create mode 100644 web/src/hooks/model/config/zfcp.test.ts create mode 100644 web/src/hooks/model/system/zfcp.test.ts create mode 100644 web/src/model/config/zfcp.test.ts diff --git a/web/src/hooks/model/config/zfcp.test.ts b/web/src/hooks/model/config/zfcp.test.ts new file mode 100644 index 0000000000..8a522ae585 --- /dev/null +++ b/web/src/hooks/model/config/zfcp.test.ts @@ -0,0 +1,243 @@ +/* + * Copyright (c) [2026] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import { act, renderHook } from "@testing-library/react"; +import { clearMockedQueries, mockConfigQuery } from "~/test-utils/tanstack-query"; +import { patchConfig } from "~/api"; +import { useConfig, useAddControllers, useAddDevices } from "~/hooks/model/config/zfcp"; +import type { ZFCP } from "~/model/config"; + +const mockDevice1: ZFCP.Device = { + channel: "0.0.5000", + wwpn: "0x500507630510c1e3", + lun: "0x4010404900000000", +}; +const mockDevice2: ZFCP.Device = { + channel: "0.0.5000", + wwpn: "0x500507630510c1e3", + lun: "0x4010404900000001", +}; +const mockDevice3: ZFCP.Device = { + channel: "0.0.6000", + wwpn: "0x500507630510c1e4", + lun: "0x4010404900000000", +}; + +const mockPatchConfig = jest.fn(); + +jest.mock("~/api", () => ({ + ...jest.requireActual("~/api"), + patchConfig: (config: Parameters) => mockPatchConfig(config), +})); + +describe("hooks/model/storage/zfcp", () => { + beforeEach(() => { + jest.clearAllMocks(); + clearMockedQueries(); + }); + + describe("useConfig", () => { + it("returns only zfcp config data", () => { + mockConfigQuery({ + product: { id: "sle", mode: "standard", registrationCode: "" }, + zfcp: { devices: [mockDevice1] }, + }); + + const { result } = renderHook(() => useConfig()); + + expect(result.current).toEqual({ devices: [mockDevice1] }); + expect(result.current).not.toHaveProperty("product"); + }); + + it("returns null when config data is undefined", () => { + mockConfigQuery(undefined); + + const { result } = renderHook(() => useConfig()); + + expect(result.current).toBeNull(); + }); + + it("returns null when zfcp property is not present", () => { + mockConfigQuery({ + product: { id: "sle", mode: "standard", registrationCode: "" }, + }); + + const { result } = renderHook(() => useConfig()); + + expect(result.current).toBeNull(); + }); + }); + + describe("useAddControllers", () => { + describe("when there is not a zFCP config yet", () => { + it("calls API#patchConfig with a new config including the given controllers", async () => { + mockConfigQuery(null); + + const { result } = renderHook(() => useAddControllers()); + + await act(async () => { + result.current(["0.0.5000"]); + }); + + expect(mockPatchConfig).toHaveBeenCalledWith({ + zfcp: expect.objectContaining({ controllers: ["0.0.5000"] }), + }); + }); + }); + + describe("when there is an existing zFCP config without controllers", () => { + it("calls API#patchConfig with the given controllers added", async () => { + mockConfigQuery({ zfcp: {} }); + + const { result } = renderHook(() => useAddControllers()); + + await act(async () => { + result.current(["0.0.5000"]); + }); + + expect(mockPatchConfig).toHaveBeenCalledWith({ + zfcp: expect.objectContaining({ controllers: ["0.0.5000"] }), + }); + }); + }); + + describe("when there is an existing zFCP config with controllers", () => { + it("adds new controllers to existing ones", async () => { + mockConfigQuery({ zfcp: { controllers: ["0.0.5000"] } }); + + const { result } = renderHook(() => useAddControllers()); + + await act(async () => { + result.current(["0.0.6000"]); + }); + + expect(mockPatchConfig).toHaveBeenCalledWith({ + zfcp: expect.objectContaining({ + controllers: ["0.0.5000", "0.0.6000"], + }), + }); + }); + + it("does not duplicate controllers already present", async () => { + mockConfigQuery({ zfcp: { controllers: ["0.0.5000"] } }); + + const { result } = renderHook(() => useAddControllers()); + + await act(async () => { + result.current(["0.0.5000", "0.0.6000"]); + }); + + expect(mockPatchConfig).toHaveBeenCalledWith({ + zfcp: expect.objectContaining({ + controllers: ["0.0.5000", "0.0.6000"], + }), + }); + }); + }); + }); + + describe("useAddDevices", () => { + describe("when there is not a zFCP config yet", () => { + it("calls API#patchConfig with a new config including the given devices", async () => { + mockConfigQuery(null); + + const { result } = renderHook(() => useAddDevices()); + + await act(async () => { + result.current([mockDevice1]); + }); + + expect(mockPatchConfig).toHaveBeenCalledWith({ + zfcp: expect.objectContaining({ devices: [mockDevice1] }), + }); + }); + }); + + describe("when there is an existing zFCP config without devices", () => { + it("calls API#patchConfig with the given devices added", async () => { + mockConfigQuery({ zfcp: {} }); + + const { result } = renderHook(() => useAddDevices()); + + await act(async () => { + result.current([mockDevice1]); + }); + + expect(mockPatchConfig).toHaveBeenCalledWith({ + zfcp: expect.objectContaining({ devices: [mockDevice1] }), + }); + }); + }); + + describe("when there is an existing zFCP config with devices", () => { + it("adds new devices to existing ones", async () => { + mockConfigQuery({ zfcp: { devices: [mockDevice1] } }); + + const { result } = renderHook(() => useAddDevices()); + + await act(async () => { + result.current([mockDevice2]); + }); + + expect(mockPatchConfig).toHaveBeenCalledWith({ + zfcp: expect.objectContaining({ + devices: [mockDevice1, mockDevice2], + }), + }); + }); + + it("updates an existing device when channel, wwpn, and lun match", async () => { + mockConfigQuery({ zfcp: { devices: [mockDevice1, mockDevice2] } }); + + const updatedDevice: ZFCP.Device = { ...mockDevice1 }; + const { result } = renderHook(() => useAddDevices()); + + await act(async () => { + result.current([updatedDevice]); + }); + + expect(mockPatchConfig).toHaveBeenCalledWith({ + zfcp: expect.objectContaining({ + devices: [updatedDevice, mockDevice2], + }), + }); + }); + + it("handles a mix of new and updated devices", async () => { + mockConfigQuery({ zfcp: { devices: [mockDevice1, mockDevice2] } }); + + const updatedDevice: ZFCP.Device = { ...mockDevice1 }; + const { result } = renderHook(() => useAddDevices()); + + await act(async () => { + result.current([updatedDevice, mockDevice3]); + }); + + expect(mockPatchConfig).toHaveBeenCalledWith({ + zfcp: expect.objectContaining({ + devices: [updatedDevice, mockDevice2, mockDevice3], + }), + }); + }); + }); + }); +}); diff --git a/web/src/hooks/model/system/zfcp.test.ts b/web/src/hooks/model/system/zfcp.test.ts new file mode 100644 index 0000000000..7d1442cdf7 --- /dev/null +++ b/web/src/hooks/model/system/zfcp.test.ts @@ -0,0 +1,79 @@ +/* + * Copyright (c) [2026] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import { renderHook } from "@testing-library/react"; +import { clearMockedQueries, mockSystemQuery } from "~/test-utils/tanstack-query"; +import { useSystem } from "~/hooks/model/system/zfcp"; +import type { ZFCP } from "~/model/system"; + +const mockDevice1: ZFCP.Device = { + channel: "0.0.5000", + wwpn: "0x500507630510c1e3", + lun: "0x4010404900000000", + active: false, +}; +const mockDevice2: ZFCP.Device = { + channel: "0.0.6000", + wwpn: "0x500507630510c1e4", + lun: "0x4010404900000001", + active: false, +}; + +describe("~/hooks/model/system/zfcp", () => { + beforeEach(() => { + clearMockedQueries(); + }); + + describe("useSystem", () => { + it("returns only zfcp system data, not the full system object", () => { + mockSystemQuery({ + product: { id: "sle", mode: "standard", registrationCode: "" }, + zfcp: { + devices: [mockDevice1, mockDevice2], + }, + }); + + const { result } = renderHook(() => useSystem()); + + expect(result.current).toEqual({ devices: [mockDevice1, mockDevice2] }); + expect(result.current).not.toHaveProperty("product"); + }); + + it("returns null when system data is undefined", () => { + mockSystemQuery(undefined); + + const { result } = renderHook(() => useSystem()); + + expect(result.current).toBeNull(); + }); + + it("returns null when zfcp property is not present", () => { + mockSystemQuery({ + product: { id: "sle", mode: "standard", registrationCode: "" }, + }); + + const { result } = renderHook(() => useSystem()); + + expect(result.current).toBeNull(); + }); + }); +}); diff --git a/web/src/model/config/zfcp.test.ts b/web/src/model/config/zfcp.test.ts new file mode 100644 index 0000000000..7a04f66fed --- /dev/null +++ b/web/src/model/config/zfcp.test.ts @@ -0,0 +1,172 @@ +/* + * Copyright (c) [2026] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import zfcpModel from "~/model/config/zfcp"; +import type { Config, Device } from "~/model/config/zfcp"; + +type ConfigStructurePreservationTest = Config & { + futureProperty: "must_be_preserved"; +}; + +const mockDevice1: Device = { + channel: "0.0.5000", + wwpn: "0x500507630510c1e3", + lun: "0x4010404900000000", +}; +const mockDevice2: Device = { + channel: "0.0.5000", + wwpn: "0x500507630510c1e3", + lun: "0x4010404900000001", +}; +const mockDevice3: Device = { + channel: "0.0.6000", + wwpn: "0x500507630510c1e4", + lun: "0x4010404900000000", +}; +const mockInitialConfig: Config = { devices: [mockDevice1] }; + +describe("model/config/zfcp", () => { + describe("#addDevice", () => { + it("preserves existing config properties while adding the device", () => { + const initialConfig = { + ...mockInitialConfig, + futureProperty: "must_be_preserved", + } as ConfigStructurePreservationTest; + const newConfig = zfcpModel.addDevice( + initialConfig, + mockDevice2, + ) as ConfigStructurePreservationTest; + expect(newConfig).not.toBe(initialConfig); + expect(newConfig.futureProperty).toBe("must_be_preserved"); + }); + + describe("when the device does not yet exist in the config", () => { + it("appends the new device to the existing list", () => { + const newConfig = zfcpModel.addDevice(mockInitialConfig, mockDevice2); + expect(newConfig).not.toBe(mockInitialConfig); + expect(newConfig.devices).toContain(mockDevice1); + expect(newConfig.devices).toContain(mockDevice2); + }); + }); + + describe("when the device already exists in the config", () => { + it("replaces the existing device instead of appending", () => { + const updatedDevice: Device = { ...mockDevice1 }; + const newConfig = zfcpModel.addDevice(mockInitialConfig, updatedDevice); + expect(newConfig.devices).toHaveLength(1); + expect(newConfig.devices[0]).toBe(updatedDevice); + expect(newConfig.devices[0]).not.toBe(mockDevice1); + }); + }); + + describe("when config devices are empty or undefined", () => { + it("returns a config containing only the new device", () => { + expect(zfcpModel.addDevice({}, mockDevice1)).toEqual({ devices: [mockDevice1] }); + expect(zfcpModel.addDevice({ devices: [] }, mockDevice1)).toEqual({ + devices: [mockDevice1], + }); + expect(zfcpModel.addDevice({ devices: undefined }, mockDevice1)).toEqual({ + devices: [mockDevice1], + }); + }); + }); + }); + + describe("#addDevices", () => { + it("preserves existing config properties while adding devices", () => { + const initialConfig = { + ...mockInitialConfig, + futureProperty: "must_be_preserved", + } as ConfigStructurePreservationTest; + const newConfig = zfcpModel.addDevices(initialConfig, [ + mockDevice2, + ]) as ConfigStructurePreservationTest; + expect(newConfig).not.toBe(initialConfig); + expect(newConfig.futureProperty).toBe("must_be_preserved"); + }); + + it("adds multiple new devices to the config", () => { + const newConfig = zfcpModel.addDevices(mockInitialConfig, [mockDevice2, mockDevice3]); + expect(newConfig.devices).toContain(mockDevice1); + expect(newConfig.devices).toContain(mockDevice2); + expect(newConfig.devices).toContain(mockDevice3); + }); + + it("replaces existing devices that match channel, wwpn, and lun", () => { + const updatedDevice1: Device = { ...mockDevice1 }; + const newConfig = zfcpModel.addDevices(mockInitialConfig, [updatedDevice1, mockDevice2]); + expect(newConfig.devices).toHaveLength(2); + expect(newConfig.devices[0]).toBe(updatedDevice1); + expect(newConfig.devices[0]).not.toBe(mockDevice1); + }); + + it("returns a copy of the config when given an empty device list", () => { + const newConfig = zfcpModel.addDevices(mockInitialConfig, []); + expect(newConfig).not.toBe(mockInitialConfig); + expect(newConfig).toEqual(mockInitialConfig); + }); + + it("creates a default config when given null", () => { + const newConfig = zfcpModel.addDevices(null, [mockDevice1]); + expect(newConfig.devices).toContain(mockDevice1); + }); + }); + + describe("#addControllers", () => { + it("preserves existing config properties while adding controllers", () => { + const initialConfig = { + controllers: ["0.0.5000"], + futureProperty: "must_be_preserved", + } as ConfigStructurePreservationTest; + const newConfig = zfcpModel.addControllers(initialConfig, [ + "0.0.6000", + ]) as ConfigStructurePreservationTest; + expect(newConfig).not.toBe(initialConfig); + expect(newConfig.futureProperty).toBe("must_be_preserved"); + }); + + it("adds new controllers to an existing list", () => { + const config: Config = { controllers: ["0.0.5000"] }; + const newConfig = zfcpModel.addControllers(config, ["0.0.6000"]); + expect(newConfig.controllers).toContain("0.0.5000"); + expect(newConfig.controllers).toContain("0.0.6000"); + }); + + it("deduplicates controllers that already exist", () => { + const config: Config = { controllers: ["0.0.5000"] }; + const newConfig = zfcpModel.addControllers(config, ["0.0.5000", "0.0.6000"]); + expect(newConfig.controllers).toHaveLength(2); + expect(newConfig.controllers).toEqual(["0.0.5000", "0.0.6000"]); + }); + + it("creates a default config when given null", () => { + const newConfig = zfcpModel.addControllers(null, ["0.0.5000"]); + expect(newConfig.controllers).toContain("0.0.5000"); + }); + + it("handles an empty controllers list without changing existing controllers", () => { + const config: Config = { controllers: ["0.0.5000"] }; + const newConfig = zfcpModel.addControllers(config, []); + expect(newConfig.controllers).toEqual(["0.0.5000"]); + }); + }); +}); diff --git a/web/src/model/config/zfcp.ts b/web/src/model/config/zfcp.ts index 172df92d8d..de11c7085e 100644 --- a/web/src/model/config/zfcp.ts +++ b/web/src/model/config/zfcp.ts @@ -72,4 +72,4 @@ function addDevices(config: Config | null, devices: Device[]): Config { } export type * from "~/openapi/config/zfcp"; -export default { addControllers, addDevices }; +export default { addControllers, addDevice, addDevices }; From bf6f5280599b9c1dfa1b77fc9b1f3927b6a692ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Fri, 6 Mar 2026 14:10:29 +0000 Subject: [PATCH 06/23] refactor(web): use radashi replaceOrAppend Instead of the custom findDeviceIndex. The radashi method already covered the undefined/nullish corner cases well. --- web/src/model/config/zfcp.ts | 24 +++++++++--------------- 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/web/src/model/config/zfcp.ts b/web/src/model/config/zfcp.ts index de11c7085e..60e8e53c91 100644 --- a/web/src/model/config/zfcp.ts +++ b/web/src/model/config/zfcp.ts @@ -21,27 +21,21 @@ */ import { Config, Device } from "~/openapi/config/zfcp"; -import { unique } from "radashi"; +import { replaceOrAppend, unique } from "radashi"; function defaultConfig(): Config { return {}; } -function findDeviceIndex(devices: Device[], device: Device): number { - return devices.findIndex( - (d) => d.channel === device.channel && d.wwpn === device.wwpn && d.lun === device.lun, - ); -} - function addDevice(config: Config, device: Device): Config { - const devices = [...(config.devices || [])]; - const index = findDeviceIndex(devices, device); - - if (index === -1) { - return { ...config, devices: [...devices, device] }; - } else { - return { ...config, devices: devices.with(index, device) }; - } + return { + ...config, + devices: replaceOrAppend( + config.devices, + device, + (d) => d.channel === device.channel && d.wwpn === device.wwpn && d.lun === device.lun, + ), + }; } /** Returns a new config adding the given controllers. */ From 57db2cae551b9771fccf75c8bfd74695ee0a869a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Fri, 6 Mar 2026 15:22:06 +0000 Subject: [PATCH 07/23] Small fixes in tests --- web/src/hooks/model/config/dasd.test.ts | 8 ++-- web/src/hooks/model/config/zfcp.test.ts | 9 ++--- web/src/hooks/model/system/dasd.test.ts | 34 +++++++++++------ web/src/hooks/model/system/zfcp.test.ts | 51 +++++++++++++++---------- 4 files changed, 61 insertions(+), 41 deletions(-) diff --git a/web/src/hooks/model/config/dasd.test.ts b/web/src/hooks/model/config/dasd.test.ts index 240400f943..feeec167a7 100644 --- a/web/src/hooks/model/config/dasd.test.ts +++ b/web/src/hooks/model/config/dasd.test.ts @@ -50,7 +50,7 @@ describe("hooks/model/storage/dasd", () => { }); describe("useConfig", () => { - it("returns only dasd config data", () => { + it("returns the DASD config", () => { mockConfigQuery({ product: { id: "sle", mode: "standard", registrationCode: "" }, dasd: { devices: [mockDeviceActive] }, @@ -62,15 +62,15 @@ describe("hooks/model/storage/dasd", () => { expect(result.current).not.toHaveProperty("product"); }); - it("returns null when config data is undefined", () => { - mockConfigQuery(undefined); + it("returns null if there is no config", () => { + mockConfigQuery(null); const { result } = renderHook(() => useConfig()); expect(result.current).toBeNull(); }); - it("returns null when dasd property is not present", () => { + it("returns null if there is no DASD config", () => { mockConfigQuery({ product: { id: "sle", mode: "standard", registrationCode: "" }, }); diff --git a/web/src/hooks/model/config/zfcp.test.ts b/web/src/hooks/model/config/zfcp.test.ts index 8a522ae585..75a09f7c4a 100644 --- a/web/src/hooks/model/config/zfcp.test.ts +++ b/web/src/hooks/model/config/zfcp.test.ts @@ -56,7 +56,7 @@ describe("hooks/model/storage/zfcp", () => { }); describe("useConfig", () => { - it("returns only zfcp config data", () => { + it("returns the zFCP config", () => { mockConfigQuery({ product: { id: "sle", mode: "standard", registrationCode: "" }, zfcp: { devices: [mockDevice1] }, @@ -65,18 +65,17 @@ describe("hooks/model/storage/zfcp", () => { const { result } = renderHook(() => useConfig()); expect(result.current).toEqual({ devices: [mockDevice1] }); - expect(result.current).not.toHaveProperty("product"); }); - it("returns null when config data is undefined", () => { - mockConfigQuery(undefined); + it("returns null if there is no config", () => { + mockConfigQuery(null); const { result } = renderHook(() => useConfig()); expect(result.current).toBeNull(); }); - it("returns null when zfcp property is not present", () => { + it("returns null if there is no zFCP config", () => { mockConfigQuery({ product: { id: "sle", mode: "standard", registrationCode: "" }, }); diff --git a/web/src/hooks/model/system/dasd.test.ts b/web/src/hooks/model/system/dasd.test.ts index eeac28de33..f9f03976fb 100644 --- a/web/src/hooks/model/system/dasd.test.ts +++ b/web/src/hooks/model/system/dasd.test.ts @@ -24,10 +24,23 @@ import { renderHook } from "@testing-library/react"; // NOTE: check notes about mockSystemQuery in its documentation import { clearMockedQueries, mockSystemQuery } from "~/test-utils/tanstack-query"; import { useSystem } from "~/hooks/model/system/dasd"; -import type { Device } from "~/model/config/dasd"; +import type { DASD } from "~/model/system"; -const mockDeviceOffline: Device = { channel: "0.0.0150", state: "offline" as const }; -const mockDeviceActive: Device = { channel: "0.0.0160", state: "active" as const }; +const dasdSystem: DASD.System = { + devices: [ + { + channel: "0.0.0100", + deviceName: "dasda", + type: "ECKD", + diag: false, + accessType: "diag", + partitionInfo: "1", + status: "active", + active: true, + formatted: true, + }, + ], +}; describe("~/hooks/model/system/dasd", () => { beforeEach(() => { @@ -35,29 +48,26 @@ describe("~/hooks/model/system/dasd", () => { }); describe("useSystem", () => { - it("returns only dasd system data, not the full system object", () => { + it("returns the DASD system", () => { mockSystemQuery({ product: { id: "sle", mode: "standard", registrationCode: "" }, - dasd: { - devices: [mockDeviceActive, mockDeviceOffline], - }, + dasd: dasdSystem, }); const { result } = renderHook(() => useSystem()); - expect(result.current).toEqual({ devices: [mockDeviceActive, mockDeviceOffline] }); - expect(result.current).not.toHaveProperty("product"); + expect(result.current).toEqual(dasdSystem); }); - it("returns null when system data is undefined", () => { - mockSystemQuery(undefined); + it("returns null if there is no system", () => { + mockSystemQuery(null); const { result } = renderHook(() => useSystem()); expect(result.current).toBeNull(); }); - it("returns null when dasd property is not present", () => { + it("returns null if threre is no DASD system", () => { mockSystemQuery({ product: { id: "sle", mode: "standard", registrationCode: "" }, }); diff --git a/web/src/hooks/model/system/zfcp.test.ts b/web/src/hooks/model/system/zfcp.test.ts index 7d1442cdf7..cd2688261b 100644 --- a/web/src/hooks/model/system/zfcp.test.ts +++ b/web/src/hooks/model/system/zfcp.test.ts @@ -25,17 +25,31 @@ import { clearMockedQueries, mockSystemQuery } from "~/test-utils/tanstack-query import { useSystem } from "~/hooks/model/system/zfcp"; import type { ZFCP } from "~/model/system"; -const mockDevice1: ZFCP.Device = { - channel: "0.0.5000", - wwpn: "0x500507630510c1e3", - lun: "0x4010404900000000", - active: false, -}; -const mockDevice2: ZFCP.Device = { - channel: "0.0.6000", - wwpn: "0x500507630510c1e4", - lun: "0x4010404900000001", - active: false, +const zfcpSystem: ZFCP.System = { + lunScan: true, + controllers: [ + { + channel: "0.0.7000", + wwpns: ["0x500507630303c5f9"], + lunScan: true, + active: true, + }, + ], + devices: [ + { + channel: "0.0.7000", + wwpn: "0x500507630303c5f9", + lun: "0x5022000000000000", + active: true, + deviceName: "/dev/sda", + }, + { + channel: "0.0.5000", + wwpn: "0x500507630510c1e3", + lun: "0x4010404900000000", + active: false, + }, + ], }; describe("~/hooks/model/system/zfcp", () => { @@ -44,29 +58,26 @@ describe("~/hooks/model/system/zfcp", () => { }); describe("useSystem", () => { - it("returns only zfcp system data, not the full system object", () => { + it("returns the zFCP system", () => { mockSystemQuery({ product: { id: "sle", mode: "standard", registrationCode: "" }, - zfcp: { - devices: [mockDevice1, mockDevice2], - }, + zfcp: zfcpSystem, }); const { result } = renderHook(() => useSystem()); - expect(result.current).toEqual({ devices: [mockDevice1, mockDevice2] }); - expect(result.current).not.toHaveProperty("product"); + expect(result.current).toEqual(zfcpSystem); }); - it("returns null when system data is undefined", () => { - mockSystemQuery(undefined); + it("returns null if there is no system", () => { + mockSystemQuery(null); const { result } = renderHook(() => useSystem()); expect(result.current).toBeNull(); }); - it("returns null when zfcp property is not present", () => { + it("returns null if there is no zFCP system", () => { mockSystemQuery({ product: { id: "sle", mode: "standard", registrationCode: "" }, }); From 69ab007ba5f805c9c7a7846c01f13ffeb6461a5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Thu, 12 Mar 2026 09:09:57 +0000 Subject: [PATCH 08/23] Add zFCP hooks for setting controllers and removing devices --- web/src/hooks/model/config/zfcp.test.ts | 89 +++++++------- web/src/hooks/model/config/zfcp.ts | 31 +++-- web/src/hooks/model/system/zfcp.test.ts | 147 +++++++++++++++++++++++- web/src/hooks/model/system/zfcp.ts | 39 ++++++- web/src/model/config/zfcp.test.ts | 71 +++++++----- web/src/model/config/zfcp.ts | 53 ++++++--- 6 files changed, 336 insertions(+), 94 deletions(-) diff --git a/web/src/hooks/model/config/zfcp.test.ts b/web/src/hooks/model/config/zfcp.test.ts index 75a09f7c4a..3235f3b8cd 100644 --- a/web/src/hooks/model/config/zfcp.test.ts +++ b/web/src/hooks/model/config/zfcp.test.ts @@ -23,7 +23,12 @@ import { act, renderHook } from "@testing-library/react"; import { clearMockedQueries, mockConfigQuery } from "~/test-utils/tanstack-query"; import { patchConfig } from "~/api"; -import { useConfig, useAddControllers, useAddDevices } from "~/hooks/model/config/zfcp"; +import { + useConfig, + useSetControllers, + useAddDevices, + useRemoveDevices, +} from "~/hooks/model/config/zfcp"; import type { ZFCP } from "~/model/config"; const mockDevice1: ZFCP.Device = { @@ -86,12 +91,12 @@ describe("hooks/model/storage/zfcp", () => { }); }); - describe("useAddControllers", () => { + describe("useSetControllers", () => { describe("when there is not a zFCP config yet", () => { it("calls API#patchConfig with a new config including the given controllers", async () => { mockConfigQuery(null); - const { result } = renderHook(() => useAddControllers()); + const { result } = renderHook(() => useSetControllers()); await act(async () => { result.current(["0.0.5000"]); @@ -103,27 +108,11 @@ describe("hooks/model/storage/zfcp", () => { }); }); - describe("when there is an existing zFCP config without controllers", () => { - it("calls API#patchConfig with the given controllers added", async () => { - mockConfigQuery({ zfcp: {} }); - - const { result } = renderHook(() => useAddControllers()); - - await act(async () => { - result.current(["0.0.5000"]); - }); - - expect(mockPatchConfig).toHaveBeenCalledWith({ - zfcp: expect.objectContaining({ controllers: ["0.0.5000"] }), - }); - }); - }); - - describe("when there is an existing zFCP config with controllers", () => { - it("adds new controllers to existing ones", async () => { + describe("when there is an existing zFCP config", () => { + it("replaces the controllers with the given ones", async () => { mockConfigQuery({ zfcp: { controllers: ["0.0.5000"] } }); - const { result } = renderHook(() => useAddControllers()); + const { result } = renderHook(() => useSetControllers()); await act(async () => { result.current(["0.0.6000"]); @@ -131,23 +120,7 @@ describe("hooks/model/storage/zfcp", () => { expect(mockPatchConfig).toHaveBeenCalledWith({ zfcp: expect.objectContaining({ - controllers: ["0.0.5000", "0.0.6000"], - }), - }); - }); - - it("does not duplicate controllers already present", async () => { - mockConfigQuery({ zfcp: { controllers: ["0.0.5000"] } }); - - const { result } = renderHook(() => useAddControllers()); - - await act(async () => { - result.current(["0.0.5000", "0.0.6000"]); - }); - - expect(mockPatchConfig).toHaveBeenCalledWith({ - zfcp: expect.objectContaining({ - controllers: ["0.0.5000", "0.0.6000"], + controllers: ["0.0.6000"], }), }); }); @@ -239,4 +212,42 @@ describe("hooks/model/storage/zfcp", () => { }); }); }); + + describe("useRemoveDevices", () => { + describe("when there is not a zFCP config yet", () => { + it("calls API#patchConfig with a new config without devices", async () => { + mockConfigQuery(null); + + const { result } = renderHook(() => useRemoveDevices()); + + await act(async () => { + result.current([mockDevice1]); + }); + + expect(mockPatchConfig).toHaveBeenCalledWith({ + zfcp: {}, + }); + }); + }); + + describe("when there is an existing zFCP config", () => { + it("calls API#patchConfig without the given devices", async () => { + mockConfigQuery({ + zfcp: { + devices: [mockDevice1, mockDevice2], + }, + }); + + const { result } = renderHook(() => useRemoveDevices()); + + await act(async () => { + result.current([mockDevice2, mockDevice3]); + }); + + expect(mockPatchConfig).toHaveBeenCalledWith({ + zfcp: expect.objectContaining({ devices: [mockDevice1] }), + }); + }); + }); + }); }); diff --git a/web/src/hooks/model/config/zfcp.ts b/web/src/hooks/model/config/zfcp.ts index b7a32c7c23..86fe1a0c6f 100644 --- a/web/src/hooks/model/config/zfcp.ts +++ b/web/src/hooks/model/config/zfcp.ts @@ -36,25 +36,28 @@ function useConfig(): ZFCP.Config | null { return data; } -type addControllersFn = (controllers: string[]) => Response; +type SetControllersFn = (controllers: string[]) => Response; -function useAddControllers(): addControllersFn { +/** + * Provides a function for setting the list of zFCP controllers to activate. + */ +function useSetControllers(): SetControllersFn { const config = useConfig(); return (controllers: string[]): Response => { - const newConfig = zfcp.addControllers(config, controllers); + const newConfig = zfcp.setControllers(config, controllers); return patchConfig({ zfcp: newConfig }); }; } -type addDevicesFn = (devices: ZFCP.Device[]) => Response; +type AddDevicesFn = (devices: ZFCP.Device[]) => Response; /** * Provides a function for adding devices to the zFCP config. * * If a device already exists in the config, then it is replaced by the new device. */ -function useAddDevices(): addDevicesFn { +function useAddDevices(): AddDevicesFn { const config = useConfig(); return (devices: ZFCP.Device[]): Response => { @@ -63,5 +66,19 @@ function useAddDevices(): addDevicesFn { }; } -export type { addControllersFn, addDevicesFn }; -export { useConfig, useAddControllers, useAddDevices }; +type RemoveDevicesFn = (devices: ZFCP.Device[]) => Response; + +/** + * Provides a function for removing devices from the zFCP config. + */ +function useRemoveDevices(): RemoveDevicesFn { + const config = useConfig(); + + return (devices: ZFCP.Device[]): Response => { + const newConfig = zfcp.removeDevices(config, devices); + return patchConfig({ zfcp: newConfig }); + }; +} + +export type { SetControllersFn, AddDevicesFn, RemoveDevicesFn }; +export { useConfig, useSetControllers, useAddDevices, useRemoveDevices }; diff --git a/web/src/hooks/model/system/zfcp.test.ts b/web/src/hooks/model/system/zfcp.test.ts index cd2688261b..0f2f8b37fb 100644 --- a/web/src/hooks/model/system/zfcp.test.ts +++ b/web/src/hooks/model/system/zfcp.test.ts @@ -22,7 +22,7 @@ import { renderHook } from "@testing-library/react"; import { clearMockedQueries, mockSystemQuery } from "~/test-utils/tanstack-query"; -import { useSystem } from "~/hooks/model/system/zfcp"; +import { useSystem, useControllers, useDevices, useCheckLunScan } from "~/hooks/model/system/zfcp"; import type { ZFCP } from "~/model/system"; const zfcpSystem: ZFCP.System = { @@ -60,8 +60,7 @@ describe("~/hooks/model/system/zfcp", () => { describe("useSystem", () => { it("returns the zFCP system", () => { mockSystemQuery({ - product: { id: "sle", mode: "standard", registrationCode: "" }, - zfcp: zfcpSystem, + zfcp: { ...zfcpSystem }, }); const { result } = renderHook(() => useSystem()); @@ -78,13 +77,149 @@ describe("~/hooks/model/system/zfcp", () => { }); it("returns null if there is no zFCP system", () => { - mockSystemQuery({ - product: { id: "sle", mode: "standard", registrationCode: "" }, - }); + mockSystemQuery({}); const { result } = renderHook(() => useSystem()); expect(result.current).toBeNull(); }); }); + + describe("useControllers", () => { + it("returns the zFCP controllers", () => { + mockSystemQuery({ + zfcp: { ...zfcpSystem }, + }); + + const { result } = renderHook(() => useControllers()); + + expect(result.current).toEqual(zfcpSystem.controllers); + }); + + it("returns an empty list if there is no system", () => { + mockSystemQuery(null); + + const { result } = renderHook(() => useControllers()); + + expect(result.current).toEqual([]); + }); + + it("returns an empty list if there is no zFCP system", () => { + mockSystemQuery({}); + + const { result } = renderHook(() => useControllers()); + + expect(result.current).toEqual([]); + }); + + it("returns an empty list if there are no zFCP controllers", () => { + mockSystemQuery({ + zfcp: {}, + }); + + const { result } = renderHook(() => useControllers()); + + expect(result.current).toEqual([]); + }); + }); + + describe("useDevices", () => { + it("returns the zFCP devices", () => { + mockSystemQuery({ + zfcp: { ...zfcpSystem }, + }); + + const { result } = renderHook(() => useDevices()); + + expect(result.current).toEqual(zfcpSystem.devices); + }); + + it("returns an empty list if there is no system", () => { + mockSystemQuery(null); + + const { result } = renderHook(() => useDevices()); + + expect(result.current).toEqual([]); + }); + + it("returns an empty list if there is no zFCP system", () => { + mockSystemQuery({}); + + const { result } = renderHook(() => useDevices()); + + expect(result.current).toEqual([]); + }); + + it("returns an empty list if there are no zFCP devices", () => { + mockSystemQuery({ + zfcp: {}, + }); + + const { result } = renderHook(() => useDevices()); + + expect(result.current).toEqual([]); + }); + }); + + describe("useCheckLunScan", () => { + it("returns false if LUN Scan is not active in the system", () => { + mockSystemQuery({ + zfcp: { ...zfcpSystem, lunScan: false }, + }); + + const { result } = renderHook(() => useCheckLunScan()); + + expect(result.current("0.0.7000")).toEqual(false); + }); + + it("returns false if LUN Scan is not supported by the controller", () => { + mockSystemQuery({ + zfcp: { + lunScan: true, + controllers: [ + { + channel: "0.0.7000", + wwpns: ["0x500507630303c5f9"], + lunScan: false, + active: true, + }, + ], + }, + }); + + const { result } = renderHook(() => useCheckLunScan()); + + expect(result.current("0.0.7000")).toEqual(false); + }); + + it("returns false if the controller does not exist", () => { + mockSystemQuery({ + zfcp: { ...zfcpSystem }, + }); + + const { result } = renderHook(() => useCheckLunScan()); + + expect(result.current("0.0.8000")).toEqual(false); + }); + + it("returns true if LUN scan is active and supported by the controller", () => { + mockSystemQuery({ + zfcp: { + lunScan: true, + controllers: [ + { + channel: "0.0.7000", + wwpns: ["0x500507630303c5f9"], + lunScan: true, + active: true, + }, + ], + }, + }); + + const { result } = renderHook(() => useCheckLunScan()); + + expect(result.current("0.0.7000")).toEqual(true); + }); + }); }); diff --git a/web/src/hooks/model/system/zfcp.ts b/web/src/hooks/model/system/zfcp.ts index 581d6ce48b..12e02e8076 100644 --- a/web/src/hooks/model/system/zfcp.ts +++ b/web/src/hooks/model/system/zfcp.ts @@ -34,4 +34,41 @@ function useSystem(): ZFCP.System | null { return data; } -export { useSystem }; +const selectControllers = (system: System | null): ZFCP.Controller[] => + system?.zfcp?.controllers || []; + +function useControllers(): ZFCP.Controller[] { + const { data } = useSuspenseQuery({ + ...systemQuery, + select: selectControllers, + }); + return data; +} + +const selectDevices = (system: System | null): ZFCP.Device[] => system?.zfcp?.devices || []; + +function useDevices(): ZFCP.Device[] { + const { data } = useSuspenseQuery({ + ...systemQuery, + select: selectDevices, + }); + return data; +} + +type CheckLunScanFn = (channel: string) => boolean; + +/** + * Provides a function to check whether a zFCP controller is performing auto LUN scan. + * + * Auto LUN scan is available only if it is active in both the system and the controller. + */ +function useCheckLunScan(): CheckLunScanFn { + const system = useSystem(); + return (channel: string): boolean => + [system.lunScan, system?.controllers?.find((c) => c.channel === channel)?.lunScan].every( + (c) => c === true, + ); +} + +export type { CheckLunScanFn }; +export { useSystem, useControllers, useDevices, useCheckLunScan }; diff --git a/web/src/model/config/zfcp.test.ts b/web/src/model/config/zfcp.test.ts index 7a04f66fed..5c497dffdd 100644 --- a/web/src/model/config/zfcp.test.ts +++ b/web/src/model/config/zfcp.test.ts @@ -45,6 +45,31 @@ const mockDevice3: Device = { const mockInitialConfig: Config = { devices: [mockDevice1] }; describe("model/config/zfcp", () => { + describe("#setControllers", () => { + it("preserves existing config properties while setting controllers", () => { + const initialConfig = { + controllers: ["0.0.5000"], + futureProperty: "must_be_preserved", + } as ConfigStructurePreservationTest; + const newConfig = zfcpModel.setControllers(initialConfig, [ + "0.0.6000", + ]) as ConfigStructurePreservationTest; + expect(newConfig).not.toBe(initialConfig); + expect(newConfig.futureProperty).toBe("must_be_preserved"); + }); + + it("replaces the controllers with the given controllers", () => { + const config: Config = { controllers: ["0.0.5000"] }; + const newConfig = zfcpModel.setControllers(config, ["0.0.6000"]); + expect(newConfig.controllers).toEqual(["0.0.6000"]); + }); + + it("creates a default config if needed", () => { + const newConfig = zfcpModel.setControllers(null, ["0.0.5000"]); + expect(newConfig).toEqual({ controllers: ["0.0.5000"] }); + }); + }); + describe("#addDevice", () => { it("preserves existing config properties while adding the device", () => { const initialConfig = { @@ -119,54 +144,48 @@ describe("model/config/zfcp", () => { expect(newConfig.devices[0]).not.toBe(mockDevice1); }); - it("returns a copy of the config when given an empty device list", () => { + it("returns a copy of the config when given an empty devices list", () => { const newConfig = zfcpModel.addDevices(mockInitialConfig, []); expect(newConfig).not.toBe(mockInitialConfig); expect(newConfig).toEqual(mockInitialConfig); }); - it("creates a default config when given null", () => { + it("creates a default config if needed", () => { const newConfig = zfcpModel.addDevices(null, [mockDevice1]); expect(newConfig.devices).toContain(mockDevice1); }); }); - describe("#addControllers", () => { - it("preserves existing config properties while adding controllers", () => { + describe.only("#removeDevices", () => { + it("preserves existing config properties while removing devices", () => { const initialConfig = { - controllers: ["0.0.5000"], + ...mockInitialConfig, futureProperty: "must_be_preserved", } as ConfigStructurePreservationTest; - const newConfig = zfcpModel.addControllers(initialConfig, [ - "0.0.6000", + const newConfig = zfcpModel.removeDevices(initialConfig, [ + mockDevice1, ]) as ConfigStructurePreservationTest; expect(newConfig).not.toBe(initialConfig); expect(newConfig.futureProperty).toBe("must_be_preserved"); }); - it("adds new controllers to an existing list", () => { - const config: Config = { controllers: ["0.0.5000"] }; - const newConfig = zfcpModel.addControllers(config, ["0.0.6000"]); - expect(newConfig.controllers).toContain("0.0.5000"); - expect(newConfig.controllers).toContain("0.0.6000"); - }); - - it("deduplicates controllers that already exist", () => { - const config: Config = { controllers: ["0.0.5000"] }; - const newConfig = zfcpModel.addControllers(config, ["0.0.5000", "0.0.6000"]); - expect(newConfig.controllers).toHaveLength(2); - expect(newConfig.controllers).toEqual(["0.0.5000", "0.0.6000"]); + it("remove multiple devices from the config", () => { + const config: Config = { devices: [mockDevice1, mockDevice2, mockDevice3] }; + const newConfig = zfcpModel.removeDevices(config, [mockDevice1, mockDevice3]); + expect(newConfig.devices).not.toContain(mockDevice1); + expect(newConfig.devices).toContain(mockDevice2); + expect(newConfig.devices).not.toContain(mockDevice3); }); - it("creates a default config when given null", () => { - const newConfig = zfcpModel.addControllers(null, ["0.0.5000"]); - expect(newConfig.controllers).toContain("0.0.5000"); + it("returns a copy of the config when given an empty devices list", () => { + const newConfig = zfcpModel.removeDevices(mockInitialConfig, []); + expect(newConfig).not.toBe(mockInitialConfig); + expect(newConfig).toEqual(mockInitialConfig); }); - it("handles an empty controllers list without changing existing controllers", () => { - const config: Config = { controllers: ["0.0.5000"] }; - const newConfig = zfcpModel.addControllers(config, []); - expect(newConfig.controllers).toEqual(["0.0.5000"]); + it("creates a default config if needed", () => { + const newConfig = zfcpModel.removeDevices(null, [mockDevice1]); + expect(newConfig).toEqual({}); }); }); }); diff --git a/web/src/model/config/zfcp.ts b/web/src/model/config/zfcp.ts index 60e8e53c91..fe8986cf20 100644 --- a/web/src/model/config/zfcp.ts +++ b/web/src/model/config/zfcp.ts @@ -21,30 +21,28 @@ */ import { Config, Device } from "~/openapi/config/zfcp"; -import { replaceOrAppend, unique } from "radashi"; +import { replaceOrAppend, remove, isEmpty } from "radashi"; function defaultConfig(): Config { return {}; } -function addDevice(config: Config, device: Device): Config { - return { - ...config, - devices: replaceOrAppend( - config.devices, - device, - (d) => d.channel === device.channel && d.wwpn === device.wwpn && d.lun === device.lun, - ), - }; +/** Returns a new config setting the given controllers. */ +function setControllers(config: Config | null, controllers: string[]): Config { + const currentConfig = config || defaultConfig(); + return { ...currentConfig, controllers }; } -/** Returns a new config adding the given controllers. */ -function addControllers(config: Config | null, controllers: string[]): Config { +function addDevice(config: Config | null, device: Device): Config { const currentConfig = config || defaultConfig(); return { ...currentConfig, - controllers: unique([...(currentConfig.controllers || []), ...controllers]), + devices: replaceOrAppend( + currentConfig.devices, + device, + (d) => d.channel === device.channel && d.wwpn === device.wwpn && d.lun === device.lun, + ), }; } @@ -58,12 +56,37 @@ function addControllers(config: Config | null, controllers: string[]): Config { function addDevices(config: Config | null, devices: Device[]): Config { const currentConfig = config || defaultConfig(); - if (devices.length === 0) return { ...currentConfig }; + if (isEmpty(devices)) return { ...currentConfig }; const [device, ...rest] = devices; return addDevices(addDevice(currentConfig, device), rest); } +function removeDevice(config: Config | null, device: Device): Config { + const currentConfig = config || defaultConfig(); + + return { + ...currentConfig, + devices: remove( + currentConfig.devices || [], + (d) => d.channel === device.channel && d.wwpn === device.wwpn && d.lun === device.lun, + ), + }; +} + +/** + * Returns a new config removing the given devices. + */ +function removeDevices(config: Config | null, devices: Device[]): Config { + const currentConfig = config || defaultConfig(); + + if (isEmpty(devices) || isEmpty(currentConfig.devices)) return { ...currentConfig }; + + const [device, ...rest] = devices; + + return removeDevices(removeDevice(currentConfig, device), rest); +} + export type * from "~/openapi/config/zfcp"; -export default { addControllers, addDevice, addDevices }; +export default { setControllers, addDevice, addDevices, removeDevices }; From e131d85aba952187d77b3493b04dccab70657d48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Thu, 12 Mar 2026 09:13:20 +0000 Subject: [PATCH 09/23] Reimplement zFCP page - Adapt UI to HTTP API v2. - New controllers section. - New devices table. - New controllers page for activating controllers. --- .../storage/zfcp/ZFCPControllersPage.test.tsx | 167 +++++++ .../storage/zfcp/ZFCPControllersPage.tsx | 210 +++++++++ .../storage/zfcp/ZFCPDevicesTable.test.tsx | 265 +++++++++++ .../storage/zfcp/ZFCPDevicesTable.tsx | 436 ++++++++++++++++++ .../components/storage/zfcp/ZFCPPage.test.tsx | 193 +++++--- web/src/components/storage/zfcp/ZFCPPage.tsx | 241 +++++----- web/src/model/issue.ts | 2 +- web/src/model/status.ts | 1 + web/src/routes/paths.ts | 2 +- web/src/routes/storage.tsx | 27 +- 10 files changed, 1337 insertions(+), 207 deletions(-) create mode 100644 web/src/components/storage/zfcp/ZFCPControllersPage.test.tsx create mode 100644 web/src/components/storage/zfcp/ZFCPControllersPage.tsx create mode 100644 web/src/components/storage/zfcp/ZFCPDevicesTable.test.tsx create mode 100644 web/src/components/storage/zfcp/ZFCPDevicesTable.tsx diff --git a/web/src/components/storage/zfcp/ZFCPControllersPage.test.tsx b/web/src/components/storage/zfcp/ZFCPControllersPage.test.tsx new file mode 100644 index 0000000000..c333c69bc2 --- /dev/null +++ b/web/src/components/storage/zfcp/ZFCPControllersPage.test.tsx @@ -0,0 +1,167 @@ +/* + * Copyright (c) [2026] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React from "react"; +import { screen } from "@testing-library/react"; +import { installerRender, mockNavigateFn } from "~/test-utils"; +import ZFCPControllersPage from "./ZFCPControllersPage"; +import { STORAGE } from "~/routes/paths"; +import { useSystem, useControllers, useCheckLunScan } from "~/hooks/model/system/zfcp"; +import { useConfig, useSetControllers } from "~/hooks/model/config/zfcp"; +import type { ZFCP as System } from "~/model/system"; + +const controller1: System.Controller = { + channel: "0.0.1a10", + active: true, + wwpns: [], + lunScan: false, +}; + +const controller2: System.Controller = { + channel: "0.0.1a11", + active: false, + wwpns: [], + lunScan: false, +}; + +const controller3: System.Controller = { + channel: "0.0.1a12", + active: false, + wwpns: [], + lunScan: false, +}; + +const mockUseSystem: jest.Mock> = jest.fn(); +const mockUseControllers: jest.Mock> = jest.fn(); +const mockUseCheckLunScan: jest.Mock> = jest.fn(); +const mockUseConfig: jest.Mock> = jest.fn(); +const mockUseSetControllers = jest.fn() as jest.MockedFunction; + +jest.mock("~/hooks/model/system/zfcp", () => ({ + useSystem: () => mockUseSystem(), + useControllers: () => mockUseControllers(), + useCheckLunScan: () => mockUseCheckLunScan(), +})); + +jest.mock("~/hooks/model/config/zfcp", () => ({ + useConfig: () => mockUseConfig(), + useSetControllers: () => mockUseSetControllers, +})); + +describe("ZFCPControllersPage", () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseSystem.mockReturnValue({ lunScan: true, controllers: [], devices: [] }); + mockUseControllers.mockReturnValue([]); + mockUseConfig.mockReturnValue({ controllers: ["0.0.1a10"] }); + mockUseCheckLunScan.mockReturnValue(() => false); + }); + + it("shows an empty state if no controllers are available for activation", () => { + mockUseControllers.mockReturnValue([controller1]); + installerRender(); + expect(screen.getByText("No controllers available")).toBeInTheDocument(); + }); + + describe("when there are controllers to activate", () => { + beforeEach(() => { + mockUseControllers.mockReturnValue([controller1, controller2, controller3]); + }); + + it("renders the list of deactivated controllers", () => { + installerRender(); + expect(screen.getByLabelText("0.0.1a11")).toBeInTheDocument(); + expect(screen.getByLabelText("0.0.1a12")).toBeInTheDocument(); + expect(screen.queryByLabelText("0.0.1a10")).not.toBeInTheDocument(); + }); + + it("shows LUN scan enabled info", () => { + mockUseSystem.mockReturnValue({ lunScan: true, controllers: [], devices: [] }); + installerRender(); + expect(screen.getByText("Automatic LUN scan is enabled")).toBeInTheDocument(); + }); + + it("shows LUN scan disabled info", () => { + mockUseSystem.mockReturnValue({ lunScan: false, controllers: [], devices: [] }); + installerRender(); + expect(screen.getByText("Automatic LUN scan is disabled")).toBeInTheDocument(); + }); + + it("allows selecting and deselecting controllers", async () => { + const { user } = installerRender(); + const controllerCheckbox = screen.getByLabelText("0.0.1a11"); + + expect(controllerCheckbox).not.toBeChecked(); + await user.click(controllerCheckbox); + expect(controllerCheckbox).toBeChecked(); + await user.click(controllerCheckbox); + expect(controllerCheckbox).not.toBeChecked(); + }); + + it("calls setControllers and navigates on submit", async () => { + const { user } = installerRender(); + const controllerCheckbox = screen.getByLabelText("0.0.1a11"); + await user.click(controllerCheckbox); + + const acceptButton = screen.getByRole("button", { name: "Accept" }); + await user.click(acceptButton); + + expect(mockUseSetControllers).toHaveBeenCalledWith(["0.0.1a10", "0.0.1a11"]); + expect(mockNavigateFn).toHaveBeenCalledWith({ pathname: STORAGE.zfcp.root }); + }); + + it("submits if there are pre-selected controllers", async () => { + mockUseConfig.mockReturnValue({ controllers: ["0.0.1a11"] }); + + const { user } = installerRender(); + const acceptButton = screen.getByRole("button", { name: "Accept" }); + await user.click(acceptButton); + + expect(mockUseSetControllers).toHaveBeenCalledWith(["0.0.1a11"]); + expect(mockNavigateFn).toHaveBeenCalledWith({ pathname: STORAGE.zfcp.root }); + }); + + it("submits if pre-selected controllers are unselected", async () => { + mockUseConfig.mockReturnValue({ controllers: ["0.0.1a11"] }); + + const { user } = installerRender(); + const controllerCheckbox = screen.getByLabelText("0.0.1a11"); + await user.click(controllerCheckbox); + const acceptButton = screen.getByRole("button", { name: "Accept" }); + await user.click(acceptButton); + + expect(mockUseSetControllers).toHaveBeenCalledWith([]); + expect(mockNavigateFn).toHaveBeenCalledWith({ pathname: STORAGE.zfcp.root }); + }); + + it("shows an error if accepting without nothing to activate", async () => { + mockUseConfig.mockReturnValue({ controllers: ["0.0.1a10"] }); + + const { user } = installerRender(); + const acceptButton = screen.getByRole("button", { name: "Accept" }); + await user.click(acceptButton); + + expect(mockUseSetControllers).not.toHaveBeenCalled(); + expect(screen.getByText("Select the controllers to activate")).toBeInTheDocument(); + }); + }); +}); diff --git a/web/src/components/storage/zfcp/ZFCPControllersPage.tsx b/web/src/components/storage/zfcp/ZFCPControllersPage.tsx new file mode 100644 index 0000000000..814144126d --- /dev/null +++ b/web/src/components/storage/zfcp/ZFCPControllersPage.tsx @@ -0,0 +1,210 @@ +/* + * Copyright (c) [2026] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React, { useEffect, useState } from "react"; +import { useNavigate } from "react-router"; +import { + Alert, + Button, + Form, + FormGroup, + ActionGroup, + EmptyState, + EmptyStateBody, + Checkbox, + Split, + SplitItem, +} from "@patternfly/react-core"; +import { Page } from "~/components/core"; +import { STORAGE } from "~/routes/paths"; +import { _ } from "~/i18n"; +import { useCheckLunScan, useControllers, useSystem } from "~/hooks/model/system/zfcp"; +import { useConfig, useSetControllers } from "~/hooks/model/config/zfcp"; +import Text from "~/components/core/Text"; +import type { ZFCP as System } from "~/model/system"; +import { isEmpty } from "radashi"; + +/** + * Renders a PatternFly `EmptyState` block used when no zFCP controllers are detected on the host + * machine. + */ +const NoControllersAvailable = (): React.ReactNode => { + return ( + + {_("There are not zFCP controllers pending of activation.")} + + ); +}; + +/** + * Renders a PatternFly `Alert` to indicate the status of the LUN scan configuration. + */ +const LUNScanInfo = (): React.ReactNode => { + const system = useSystem(); + + const lunScanEnabled = [ + _("Automatic LUN scan is enabled"), + _( + "Activating a controller which is running in NPIV mode will automatically configures all its LUNs.", + ), + ]; + + const lunScanDisabled = [ + _("Automatic LUN scan is disabled"), + _("LUNs have to be manually configured after activating a controller."), + ]; + + const [title, message] = system?.lunScan ? lunScanEnabled : lunScanDisabled; + + return ( + + {message} + + ); +}; + +type ControllerOptionLabelProps = { + controller: System.Controller; +}; + +/** + * Label to show in the form for a controller. + */ +const ControllerOptionLabel = ({ controller }: ControllerOptionLabelProps): React.ReactNode => { + const checkLunScan = useCheckLunScan(); + + return ( + + + {controller.channel} + + {checkLunScan(controller.channel) && ( + + {_("Performs auto LUN scan")} + + )} + + ); +}; + +/** + * Form for activating zFCP controllers. + */ +const ZFCPControllersForm = (): React.ReactNode => { + const controllers = useControllers(); + const config = useConfig(); + const setControllers = useSetControllers(); + const [selectedControllers, setSelectedControllers] = useState([]); + const [error, setError] = useState(null); + const navigate = useNavigate(); + + useEffect(() => { + setSelectedControllers(config?.controllers || []); + }, [config]); + + const toggleController = (channel: string) => { + if (!selectedControllers.includes(channel)) { + setSelectedControllers([...selectedControllers, channel]); + } else { + setSelectedControllers(selectedControllers.filter((c) => c !== channel)); + } + }; + + const submit = async () => { + setError(null); + setControllers(selectedControllers); + navigate({ pathname: STORAGE.zfcp.root }); + }; + + const onSubmit = async (event) => { + event.preventDefault(); + + const toActivate = controllers.filter( + (c) => !c.active && selectedControllers.includes(c.channel), + ); + + if (!isEmpty(toActivate) || selectedControllers !== config.controllers) return submit(); + + setError(_("Select the controllers to activate")); + }; + + const deactivatedControllers = controllers.filter((c) => !c.active); + + return ( + <> + + {_("Select the zFCP controllers to activate:")} +
+ {error && } + {deactivatedControllers.map((controller, index) => { + return ( + + } + isChecked={selectedControllers.includes(controller.channel)} + onChange={() => toggleController(controller.channel)} + /> + + ); + })} + + + {_("Cancel")} + + + + ); +}; + +/** + * Content switcher for the zFCP controllers page. + */ +const ZFCPControllersContent = (): React.ReactNode => { + const controllers = useControllers(); + const deactivatedControllers = controllers.filter((c) => !c.active); + + if (isEmpty(deactivatedControllers)) { + return ; + } + + return ; +}; + +/** + * Top-level page component for configuring the activation of zFCP controllers. + */ +export default function ZFCPControllersPage(): React.ReactNode { + return ( + + + + + + ); +} diff --git a/web/src/components/storage/zfcp/ZFCPDevicesTable.test.tsx b/web/src/components/storage/zfcp/ZFCPDevicesTable.test.tsx new file mode 100644 index 0000000000..48e3675d25 --- /dev/null +++ b/web/src/components/storage/zfcp/ZFCPDevicesTable.test.tsx @@ -0,0 +1,265 @@ +/* + * Copyright (c) [2026] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React from "react"; +import { screen, within } from "@testing-library/react"; +import { installerRender } from "~/test-utils"; +import ZFCPDevicesTable from "./ZFCPDevicesTable"; +import type { ZFCP as System } from "~/model/system"; +import type { Config } from "~/model/config/zfcp"; + +const mockAddDevices = jest.fn(); +const mockRemoveDevices = jest.fn(); +const mockUseConfig = jest.fn(); +const mockCheckLunScan = jest.fn(); + +jest.mock("~/hooks/model/config/zfcp", () => ({ + useAddDevices: () => mockAddDevices, + useRemoveDevices: () => mockRemoveDevices, + useConfig: () => mockUseConfig(), +})); + +jest.mock("~/hooks/model/system/zfcp", () => ({ + useCheckLunScan: () => mockCheckLunScan, +})); + +let mockZFCPDevices: System.Device[] = []; +let mockConfig: Config = {}; + +describe("ZFCPDevicesTable", () => { + beforeEach(() => { + jest.clearAllMocks(); + + mockZFCPDevices = [ + { + channel: "0.0.1a10", + wwpn: "0x5005076305018611", + lun: "0x0001000000000000", + active: true, + deviceName: "dasda", + }, + { + channel: "0.0.1a10", + wwpn: "0x5005076305018612", + lun: "0x0002000000000000", + active: false, + deviceName: "", + }, + { + channel: "0.0.2b20", + wwpn: "0x5005076305018612", + lun: "0x0003000000000000", + active: false, + deviceName: "", + }, + ]; + + mockConfig = { devices: [] }; + mockUseConfig.mockReturnValue(mockConfig); + mockCheckLunScan.mockReturnValue(false); + }); + + describe("when there are some zFCP devices available", () => { + it("renders those devices", () => { + installerRender(); + + // All LUNs appear as rows + screen.getByText("0x0001000000000000"); + screen.getByText("0x0002000000000000"); + screen.getByText("0x0003000000000000"); + + // Status values are rendered + screen.getByText("Activated"); + expect(screen.queryAllByText("Deactivated").length).toBe(2); + + // Device name is shown for the active device + screen.getByText("dasda"); + }); + }); + + describe("filtering", () => { + describe("status filter", () => { + it("renders only devices matching the selected status", async () => { + const { user } = installerRender(); + await user.click(screen.getByLabelText("Status")); + await user.click(screen.getByRole("option", { name: "Activated" })); + + screen.getByText("0x0001000000000000"); + expect(screen.queryByText("0x0002000000000000")).toBeNull(); + expect(screen.queryByText("0x0003000000000000")).toBeNull(); + }); + }); + + describe("channel filter", () => { + it("renders only devices matching the selected channel", async () => { + const { user } = installerRender(); + await user.click(screen.getByLabelText("Channel")); + await user.click(screen.getByRole("option", { name: "0.0.1a10" })); + + screen.getByText("0x0001000000000000"); + screen.getByText("0x0002000000000000"); + expect(screen.queryByText("0x0003000000000000")).toBeNull(); + }); + }); + + describe("wwpn filter", () => { + it("renders only devices matching the selected wwpn", async () => { + const { user } = installerRender(); + await user.click(screen.getByLabelText("WWPN")); + await user.click(screen.getByRole("option", { name: "0x5005076305018611" })); + + screen.getByText("0x0001000000000000"); + expect(screen.queryByText("0x0002000000000000")).toBeNull(); + expect(screen.queryByText("0x0003000000000000")).toBeNull(); + }); + }); + + describe("device count", () => { + it("renders the total device count when no filter is active", () => { + installerRender(); + screen.getByText("3 devices available"); + }); + + it("renders matching vs total count when a filter is active", async () => { + const { user } = installerRender(); + await user.click(screen.getByLabelText("Status")); + await user.click(screen.getByRole("option", { name: "Activated" })); + screen.getByText("1 of 3 devices match filters"); + }); + + it("renders 0 of total when no devices match filters", async () => { + const { user } = installerRender(); + await user.click(screen.getByLabelText("Status")); + await user.click(screen.getByRole("option", { name: "Activated" })); + await user.click(screen.getByLabelText("Channel")); + await user.click(screen.getByRole("option", { name: "0.0.2b20" })); + screen.getByText("0 of 3 devices match filters"); + }); + }); + + describe("empty state", () => { + it("renders empty state with clear all filters option when no devices match", async () => { + const { user } = installerRender(); + await user.click(screen.getByLabelText("Status")); + await user.click(screen.getByRole("option", { name: "Activated" })); + await user.click(screen.getByLabelText("Channel")); + await user.click(screen.getByRole("option", { name: "0.0.2b20" })); + const emptyState = screen + .getByRole("heading", { name: "No devices match filters", level: 2 }) + .closest(".pf-v6-c-empty-state"); + within(emptyState as HTMLElement).getByRole("button", { name: "Clear all filters" }); + }); + }); + + describe("clearing filters", () => { + it("does not render 'Clear all filters' when no filter is active", () => { + installerRender(); + expect(screen.queryByRole("button", { name: "Clear all filters" })).toBeNull(); + }); + + it("renders 'Clear all filters' in the toolbar as soon as a filter is active", async () => { + const { user } = installerRender(); + await user.click(screen.getByLabelText("Status")); + await user.click(screen.getByRole("option", { name: "Activated" })); + screen.getByRole("button", { name: "Clear all filters" }); + }); + + it("restores all devices after clearing filters from the toolbar", async () => { + const { user } = installerRender(); + await user.click(screen.getByLabelText("Status")); + await user.click(screen.getByRole("option", { name: "Activated" })); + expect(screen.queryByText("0x0002000000000000")).toBeNull(); + + await user.click(screen.getByRole("button", { name: "Clear all filters" })); + + screen.getByText("0x0001000000000000"); + screen.getByText("0x0002000000000000"); + screen.getByText("0x0003000000000000"); + }); + }); + }); + + describe("actions", () => { + it("calls addDevices with correct config on activate", async () => { + const { user } = installerRender(); + await user.click(screen.getByRole("button", { name: "Actions for 0x0002000000000000" })); + await user.click(screen.getByRole("menuitem", { name: "Activate" })); + expect(mockAddDevices).toHaveBeenCalledWith([ + { + channel: "0.0.1a10", + wwpn: "0x5005076305018612", + lun: "0x0002000000000000", + active: true, + }, + ]); + }); + + it("calls addDevices with correct config on deactivate", async () => { + const { user } = installerRender(); + await user.click(screen.getByRole("button", { name: "Actions for 0x0001000000000000" })); + await user.click(screen.getByRole("menuitem", { name: "Deactivate" })); + expect(mockAddDevices).toHaveBeenCalledWith([ + { + channel: "0.0.1a10", + wwpn: "0x5005076305018611", + lun: "0x0001000000000000", + active: false, + }, + ]); + }); + + it("filters irrelevant actions for a single device", async () => { + const { user } = installerRender(); + // 0x0001000000000000 is already active — Activate should not appear + await user.click(screen.getByRole("button", { name: "Actions for 0x0001000000000000" })); + expect(screen.queryByRole("menuitem", { name: "Activate" })).toBeNull(); + screen.getByRole("menuitem", { name: "Deactivate" }); + }); + + it("shows remove action for failed activation", async () => { + mockConfig.devices = [ + { channel: "0.0.1a10", wwpn: "0x5005076305018612", lun: "0x0002000000000000" }, + ]; + const { user } = installerRender(); + await user.click(screen.getByRole("button", { name: "Actions for 0x0002000000000000" })); + screen.getByRole("menuitem", { name: "Do not activate" }); + }); + + it("calls removeDevices with correct config on remove", async () => { + mockConfig.devices = [ + { channel: "0.0.1a10", wwpn: "0x5005076305018612", lun: "0x0002000000000000" }, + ]; + const { user } = installerRender(); + await user.click(screen.getByRole("button", { name: "Actions for 0x0002000000000000" })); + await user.click(screen.getByRole("menuitem", { name: "Do not activate" })); + expect(mockRemoveDevices).toHaveBeenCalledWith([ + { channel: "0.0.1a10", wwpn: "0x5005076305018612", lun: "0x0002000000000000" }, + ]); + }); + + it("disables deactivate action when LUN is auto scanned", async () => { + mockCheckLunScan.mockReturnValue(true); + installerRender(); + expect(screen.queryByRole("button", { name: "Actions for 0x0001000000000000" })).toBeNull(); + }); + }); +}); diff --git a/web/src/components/storage/zfcp/ZFCPDevicesTable.tsx b/web/src/components/storage/zfcp/ZFCPDevicesTable.tsx new file mode 100644 index 0000000000..ec360bcd1e --- /dev/null +++ b/web/src/components/storage/zfcp/ZFCPDevicesTable.tsx @@ -0,0 +1,436 @@ +/* + * Copyright (c) [2026] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React, { useReducer } from "react"; +import { identity, zipToObject } from "radashi"; +import { sprintf } from "sprintf-js"; +import { + Button, + Content, + EmptyState, + EmptyStateActions, + EmptyStateBody, + EmptyStateFooter, + Toolbar, + ToolbarContent, + ToolbarGroup, + ToolbarItem, +} from "@patternfly/react-core"; +import Icon from "~/components/layout/Icon"; +import Text from "~/components/core/Text"; +import SelectableDataTable, { SortedBy } from "~/components/core/SelectableDataTable"; +import SimpleSelector from "~/components/core/SimpleSelector"; +import { sortCollection, translateEntries } from "~/utils"; +import { _, N_ } from "~/i18n"; +import { useCheckLunScan } from "~/hooks/model/system/zfcp"; +import { useAddDevices, useRemoveDevices, useConfig } from "~/hooks/model/config/zfcp"; +import type { ZFCP as System } from "~/model/system"; +import type { Config } from "~/model/config/zfcp"; +import type { CheckLunScanFn } from "~/hooks/model/system/zfcp"; +import type { AddDevicesFn, RemoveDevicesFn } from "~/hooks/model/config/zfcp"; + +/** + * Possible statuses of a zFCP device. + */ +const STATUS_OPTIONS = { + activated: N_("Activated"), + deactivated: N_("Deactivated"), +}; + +/** + * Filter options for narrowing down zFCP devices shown in the table. + * + * All filters are optional and may be combined. + */ +export type ZFCPDevicesFilters = { + /** Only show devices with this status. */ + status?: "all" | "activated" | "deactivated"; + /** Channel ID filtering. */ + channel?: "all" | string; + /** WWPN filtering. */ + wwpn?: "all" | string; +}; + +/** + * Predicate function for evaluating whether a zFCP device meets a given condition. + * + * Used internally to compose filter logic when narrowing down the list of devices shown in the + * table. + */ +type ZFCPDeviceCondition = (device: System.Device) => boolean; + +/** + * Filters an array of devices based on given filters. + * + * @param devices - The array of zFCP devices to filter. + * @param filters - The filters to apply. + * @returns The filtered array of zFCP devices matching all the conditions. + */ +const filterDevices = (devices: System.Device[], filters: ZFCPDevicesFilters): System.Device[] => { + const { status, channel, wwpn } = filters; + const conditions: ZFCPDeviceCondition[] = []; + + if (status && status !== "all") { + conditions.push((d: System.Device): boolean => (status === "activated" ? d.active : !d.active)); + } + + if (channel && channel !== "all") { + conditions.push((c: System.Device): boolean => c.channel === channel); + } + + if (wwpn && wwpn !== "all") { + conditions.push((c: System.Device): boolean => c.wwpn === wwpn); + } + + return devices.filter((device) => conditions.every((conditionFn) => conditionFn(device))); +}; + +type FiltersToolbarProps = { + /** Currently active filter values. */ + filters: ZFCPDevicesFilters; + /** Whether any filter differs from its default value. */ + hasActiveFilters: boolean; + /** Total number of devices before filtering. */ + totalDevices: number; + /** Number of devices that pass the current filters. */ + matchingDevices: number; + /** All available channels for selection. */ + channels: string[]; + /** All available WWPNs fo selection. */ + wwpns: string[]; + /** Callback invoked when a single filter value changes. */ + onFilterChange: (filter: keyof ZFCPDevicesFilters, value: string | number) => void; + /** Callback invoked when all filters should be reset to their defaults. */ + onReset: () => void; +}; + +/** + * Renders the filter controls toolbar for the zFCP table. + * + * Displays status, channel and WWPN range filters alongside a device count summary. When any filter + * is active the count switches from "N devices available" to "M of N devices match filters" and a + * "Clear all filters" link appears. + */ +const FiltersToolbar = ({ + filters, + hasActiveFilters, + totalDevices, + matchingDevices, + channels, + wwpns, + onFilterChange, + onReset, +}: FiltersToolbarProps): React.ReactNode => { + const countText = hasActiveFilters + ? sprintf( + // TRANSLATORS: shown in the filter toolbar when filters are active. + // %1$s is the number of matching devices, %2$s is the total number. + _("%1$d of %2$d devices match filters"), + matchingDevices, + totalDevices, + ) + : sprintf( + // TRANSLATORS: shown in the filter toolbar when no filters are active. + // %s is the total number of devices. + _("%d devices available"), + totalDevices, + ); + + return ( + + + + + onFilterChange("status", v)} + /> + onFilterChange("channel", v)} + /> + onFilterChange("wwpn", v)} + /> + + + + + {countText} + + {hasActiveFilters && ( + + + + )} + + + + ); +}; + +/** + * Builds the list of actions available for the given zFCP device. + */ +const buildActions = ( + device: System.Device, + config: Config, + addDevices: AddDevicesFn, + removeDevices: RemoveDevicesFn, + checkLunScan: CheckLunScanFn, +) => { + const deviceConfig = config.devices?.find( + (c) => c.channel === device.channel && c.wwpn === device.wwpn && device.lun === c.lun, + ); + + const failedActivate = + deviceConfig && (deviceConfig.active === undefined || deviceConfig.active) && !device.active; + + const failedDeactivate = deviceConfig && deviceConfig.active === false && device.active; + + const actions = [ + { + id: "activate", + title: failedActivate ? _("Try to activate again") : _("Activate"), + onClick: () => + addDevices([{ channel: device.channel, wwpn: device.wwpn, lun: device.lun, active: true }]), + }, + { + id: "deactivate", + title: failedDeactivate ? _("Try to deactivate again") : _("Deactivate"), + onClick: () => + addDevices([ + { channel: device.channel, wwpn: device.wwpn, lun: device.lun, active: false }, + ]), + }, + { + id: "remove", + title: failedActivate ? _("Do not activate") : _("Do not deactivate"), + onClick: () => + removeDevices([{ channel: device.channel, wwpn: device.wwpn, lun: device.lun }]), + }, + ]; + + const keptActions = { + activate: !device.active, + deactivate: device.active && !checkLunScan(device.channel), + remove: failedActivate || failedDeactivate, + }; + + return actions.filter((a) => keptActions[a.id]); +}; + +/** Internal state shape for the zFCP table component. */ +type ZFCPTableState = { + /** Current sorting state. */ + sortedBy: SortedBy; + /** Current active filters applied to the device list. */ + filters: ZFCPDevicesFilters; +}; + +/** + * Union of all actions that can be dispatched to update the zFCP table state. + **/ +type ZFCPTableAction = + | { type: "UPDATE_SORTING"; payload: ZFCPTableState["sortedBy"] } + | { type: "UPDATE_FILTERS"; payload: ZFCPTableState["filters"] } + | { type: "RESET_FILTERS" }; + +/** + * Initial state for `reducer`. + * + * @remarks + * Also serves as the canonical "no filters active" reference: filter changes are detected by + * comparing the current filters against this object via `JSON.stringify`. + */ +const initialState: ZFCPTableState = { + sortedBy: { index: 0, direction: "asc" }, + filters: { + status: "all", + channel: "all", + wwpn: "all", + }, +}; + +/** + * Reducer for the zFCP devices table. + * + * Handles all state transitions driven by `ZFCPTableAction` dispatches. + */ +const reducer = (state: ZFCPTableState, action: ZFCPTableAction): ZFCPTableState => { + switch (action.type) { + case "UPDATE_SORTING": { + return { ...state, sortedBy: action.payload }; + } + + case "UPDATE_FILTERS": { + return { ...state, filters: { ...state.filters, ...action.payload } }; + } + + case "RESET_FILTERS": { + return { ...state, filters: initialState.filters }; + } + } +}; + +/** + * Column definitions for the zFCP devices table. + * + * Each entry defines the column header label, how its value is derived from a device, and which + * field drives sorting. Consumed by `SelectableDataTable`. + */ +const createColumns = (checkLunScan: CheckLunScanFn) => [ + { + // TRANSLATORS: table header for a zFCP devices table. + name: _("Channel"), + value: (d: System.Device) => d.channel, + sortingKey: "channel", + }, + { + // TRANSLATORS: table header for a zFCP devices table. + name: _("WWPN"), + value: (d: System.Device) => d.wwpn, + sortingKey: "wwpn", + }, + { + // TRANSLATORS: table header for a zFCP devices table. + name: _("LUN"), + value: (d: System.Device) => d.lun, + sortingKey: "lun", + }, + { + // TRANSLATORS: table header for a zFCP devices table. + name: _("Status"), + value: (d: System.Device) => STATUS_OPTIONS[d.active ? "activated" : "deactivated"], + sortingKey: "active", + }, + { + // TRANSLATORS: table header for a zFCP devices table. + name: _("Device"), + value: (d: System.Device) => d.deviceName, + sortingKey: "deviceName", + }, + { + // TRANSLATORS: table header for a zFCP devices table. + name: _("Auto Scanned"), + value: (d: System.Device) => (checkLunScan(d.channel) ? _("Yes") : _("No")), + }, +]; + +type ZFCPDevicesTableProps = { + devices: System.Device[]; +}; + +/** + * Displays a filterable, sortable, selectable table of zFCP devices. + * + * Manages its own UI state (filters, sorting, selection, pending format requests) via a reducer. + */ +export default function ZFCPDevicesTable({ devices }: ZFCPDevicesTableProps): React.ReactNode { + const [state, dispatch] = useReducer(reducer, initialState); + const addDevices = useAddDevices(); + const removeDevices = useRemoveDevices(); + const checkLunScan = useCheckLunScan(); + const config = useConfig(); + + const columns = createColumns(checkLunScan); + + const onSortingChange = (sortedBy: SortedBy) => { + dispatch({ type: "UPDATE_SORTING", payload: sortedBy }); + }; + + const onFilterChange = (filter: keyof ZFCPDevicesFilters, value) => { + dispatch({ type: "UPDATE_FILTERS", payload: { [filter]: value } }); + }; + + const resetFilters = () => dispatch({ type: "RESET_FILTERS" }); + + // Filtering + const filteredDevices = filterDevices(devices, state.filters); + + // Sorting + const sortingKey = columns[state.sortedBy.index].sortingKey; + const sortedDevices = sortCollection(filteredDevices, state.sortedBy.direction, sortingKey); + + return ( + + d.channel)} + wwpns={devices.map((d) => d.wwpn)} + onFilterChange={onFilterChange} + onReset={resetFilters} + /> + + + buildActions(device, config, addDevices, removeDevices, checkLunScan) + } + itemActionsLabel={(d: System.Device) => `Actions for ${d.lun}`} + emptyState={ + } + variant="sm" + > + {_("Change filters and try again.")} + + + + + + + } + /> + + ); +} diff --git a/web/src/components/storage/zfcp/ZFCPPage.test.tsx b/web/src/components/storage/zfcp/ZFCPPage.test.tsx index f695d25d94..6cc5d6c867 100644 --- a/web/src/components/storage/zfcp/ZFCPPage.test.tsx +++ b/web/src/components/storage/zfcp/ZFCPPage.test.tsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2023] SUSE LLC + * Copyright (c) [2023-2026] SUSE LLC * * All Rights Reserved. * @@ -23,63 +23,146 @@ import React from "react"; import { screen } from "@testing-library/react"; import { installerRender } from "~/test-utils"; -import { ZFCPPage } from "~/components/storage/zfcp"; -import { ZFCPDisk, ZFCPController, ZFCPConfig } from "~/types/zfcp"; +import { clearMockedQueries } from "~/test-utils/tanstack-query"; +import ZFCPPage from "./ZFCPPage"; +import type { Issue } from "~/model/issue"; +import type { ZFCP as System } from "~/model/system"; -const mockZFCPConfig: ZFCPConfig = { - allowLunScan: false, +const issue: Issue = { + description: "zFCP error", + class: "something", + scope: "zfcp", }; -const mockZFCPDisk: ZFCPDisk[] = [ - { - name: "/dev/sda", - channel: "0.0.fa00", - wwpn: "0x500507630b181216", - lun: "0x4020404900000000", - }, - { - name: "/dev/sdb", - channel: "0.0.fc00", - wwpn: "0x500507630b101216", - lun: "0x0001000000000000", - }, -]; - -const mockZFCPControllers: ZFCPController[] = [ - { - id: "1", - channel: "0.0.fa00", - lunScan: false, - active: true, - lunsMap: { - "0x500507630b181216": ["0x4020404900000000"], - "0x500507680d7e284a": [], - "0x500507680d0e284a": [], - }, - }, - { - id: "2", - channel: "0.0.fc00", - lunScan: false, - active: true, - lunsMap: { - "0x500507680d7e284b": [], - "0x500507680d0e284b": [], - "0x500507630b101216": ["0x0000000000000000", "0x0001000000000000"], - }, - }, -]; - -jest.mock("~/queries/storage/zfcp", () => ({ - useZFCPDisks: () => mockZFCPDisk, - useZFCPDisksChanges: () => null, - useZFCPControllers: () => mockZFCPControllers, - useZFCPControllersChanges: () => null, - useZFCPConfig: () => mockZFCPConfig, + +const controller1: System.Controller = { + channel: "0.0.7000", + wwpns: ["0x500507630303c5f9"], + lunScan: true, + active: true, +}; + +const controller2: System.Controller = { + channel: "0.0.8000", + wwpns: ["0x500507630303c5f9"], + lunScan: true, + active: false, +}; + +const device1: System.Device = { + channel: "0.0.7000", + wwpn: "0x500507630303c5f9", + lun: "0x5022000000000000", + active: true, + deviceName: "/dev/sda", +}; + +const mockUseControllers = jest.fn(); +const mockUseDevices = jest.fn(); +const mockUseIssues = jest.fn(); + +jest.mock("~/hooks/model/system/zfcp", () => ({ + ...jest.requireActual("~/hooks/model/system/zfcp"), + useControllers: () => mockUseControllers(), + useDevices: () => mockUseDevices(), +})); + +jest.mock("~/hooks/model/issue", () => ({ + ...jest.requireActual("~/hooks/model/issue"), + useIssues: () => mockUseIssues(), })); -it("renders two sections: Controllers and Disks", () => { - installerRender(); +jest.mock("./ZFCPDevicesTable", () => () =>
devices table
); + +describe("ZFCPPage", () => { + beforeEach(() => { + jest.clearAllMocks(); + clearMockedQueries(); + mockUseControllers.mockReturnValue([]); + mockUseDevices.mockReturnValue([]); + mockUseIssues.mockReturnValue([]); + }); + + describe("if there are issues", () => { + beforeEach(() => { + mockUseIssues.mockReturnValue([issue]); + }); + + it("renders the issues", () => { + installerRender(); + expect(screen.queryByText(/zFCP error/)).toBeInTheDocument(); + }); + }); + + describe("if there are not controllers", () => { + beforeEach(() => { + mockUseControllers.mockReturnValue([]); + }); + + it("renders a text explaining zFCP is not available", () => { + installerRender(); + expect(screen.queryByText(/zFCP is not available/)).toBeInTheDocument(); + expect(screen.queryByText("devices table")).not.toBeInTheDocument(); + }); + }); + + describe("if there are not devices", () => { + beforeEach(() => { + mockUseControllers.mockReturnValue([controller1]); + mockUseDevices.mockReturnValue([]); + }); + + it("renders the controllers section", () => { + installerRender(); + expect(screen.queryByText("zFCP controllers")).toBeInTheDocument(); + }); + + it("renders a text explaining devices are not available", () => { + installerRender(); + expect(screen.queryByText(/No devices available/)).toBeInTheDocument(); + expect(screen.queryByText("devices table")).not.toBeInTheDocument(); + }); + }); + + describe("if there are devices", () => { + beforeEach(() => { + mockUseControllers.mockReturnValue([controller1]); + mockUseDevices.mockReturnValue([device1]); + }); + + it("renders the controllers section", () => { + installerRender(); + expect(screen.queryByText("zFCP controllers")).toBeInTheDocument(); + }); + + it("renders the table of devices", () => { + installerRender(); + expect(screen.queryByText("devices table")).toBeInTheDocument(); + }); + + describe("if there are deactivated controllers", () => { + beforeEach(() => { + mockUseControllers.mockReturnValue([controller2]); + }); + + it("renders an option for activating controllers", () => { + installerRender(); + expect(screen.queryByText(/There is a deactivated zFCP controller/)).toBeInTheDocument(); + expect(screen.queryByRole("link", { name: "Activate controllers" })).toBeInTheDocument(); + }); + }); + + describe("if there are not deactivated controllers", () => { + beforeEach(() => { + mockUseControllers.mockReturnValue([controller1]); + }); - screen.findByRole("heading", { name: "Controllers" }); - screen.findByRole("heading", { name: "Disks" }); + it("does not render an option for activating controllers", () => { + installerRender(); + expect(screen.queryByText(/zFCP controllers are already activated/)).toBeInTheDocument(); + expect( + screen.queryByRole("link", { name: "Activate controllers" }), + ).not.toBeInTheDocument(); + }); + }); + }); }); diff --git a/web/src/components/storage/zfcp/ZFCPPage.tsx b/web/src/components/storage/zfcp/ZFCPPage.tsx index 7fe91815c1..00c131add0 100644 --- a/web/src/components/storage/zfcp/ZFCPPage.tsx +++ b/web/src/components/storage/zfcp/ZFCPPage.tsx @@ -21,178 +21,163 @@ */ import React from "react"; +import { isEmpty } from "radashi"; +import { sprintf } from "sprintf-js"; import { - Button, + Content, + Divider, + EmptyState, + EmptyStateBody, + Flex, Grid, GridItem, - Toolbar, - ToolbarContent, - ToolbarItem, + Split, } from "@patternfly/react-core"; -import { EmptyState, Page } from "~/components/core"; +import Link from "~/components/core/Link"; +import Page from "~/components/core/Page"; +import Text from "~/components/core/Text"; +import SubtleContent from "~/components/core/SubtleContent"; +import ZFCPDevicesTable from "~/components/storage/zfcp/ZFCPDevicesTable"; +import { useControllers, useDevices } from "~/hooks/model/system/zfcp"; +import { STORAGE } from "~/routes/paths"; import { _ } from "~/i18n"; -import { - useZFCPConfig, - useZFCPControllers, - useZFCPControllersChanges, - useZFCPDisks, - useZFCPDisksChanges, -} from "~/queries/storage/zfcp"; -import ZFCPDisksTable from "./ZFCPDisksTable"; -import ZFCPControllersTable from "./ZFCPControllersTable"; -import { probeZFCP } from "~/model/storage/zfcp"; -import { STORAGE as PATHS, STORAGE } from "~/routes/paths"; -import { useNavigate } from "react-router"; -import { inactiveLuns } from "~/utils/zfcp"; - -const LUNScanInfo = () => { - const { allowLunScan } = useZFCPConfig(); - // TRANSLATORS: the text in the square brackets [] will be displayed in bold - const lunScanEnabled = _( - "Automatic LUN scan is [enabled]. Activating a controller which is " + - "running in NPIV mode will automatically configures all its LUNs.", - ); - // TRANSLATORS: the text in the square brackets [] will be displayed in bold - const lunScanDisabled = _( - "Automatic LUN scan is [disabled]. LUNs have to be manually " + - "configured after activating a controller.", - ); - - const msg = allowLunScan ? lunScanEnabled : lunScanDisabled; - const [msgStart, msgBold, msgEnd] = msg.split(/[[\]]/); +import IssuesAlert from "~/components/core/IssuesAlert"; +import { useIssues } from "~/hooks/model/issue"; +import type { ZFCP as System } from "~/model/system"; +/** + * Renders a PatternFly `EmptyState` block used when no zFCP controllers are detected on the host + * machine. + */ +const NoZFCPAvailable = (): React.ReactNode => { return ( -

- {msgStart} - {msgBold} - {msgEnd} -

+ + {_("No zFCP controllers found in this machine.")} + ); }; -const NoDisksFound = () => { - const navigate = useNavigate(); - const controllers = useZFCPControllers(); - const activeController = controllers.some((c) => c.active); - const body = activeController - ? _("Please, try to activate a zFCP disk.") - : _("Please, try to activate a zFCP controller."); - +/** + * Renders a PatternFly `EmptyState` block used when no ZFCP devices are detected on the host + * machine. + */ +const NoDevicesAvailable = (): React.ReactNode => { return ( - navigate(PATHS.zfcp.activateDisk)}> - {_("Activate zFCP disk")} - - ) - } - > - {body} + + {_("No zFCP devices found in this machine.")} ); }; -const Disks = () => { - const navigate = useNavigate(); - const disks = useZFCPDisks(); - const controllers = useZFCPControllers(); - const isDisabled = inactiveLuns(controllers, disks).length === 0; +type ZFCPControllersDescriptionProps = { + controllers: System.Controller[]; +}; - return ( - <> - - - - {/* TRANSLATORS: button label */} - - - - - - - - ); +/** + * Descripton to show in the controllers section. + */ +const ZFCPControllersDescription = ({ + controllers, +}: ZFCPControllersDescriptionProps): React.ReactNode => { + const deactivatedControllers = controllers.filter((c) => !c.active); + + if (!isEmpty(deactivatedControllers)) { + const text = + deactivatedControllers.length === 1 + ? _("There is a deactivated zFCP controller.") + : sprintf(_("There are %s deactivated zFCP controllers."), deactivatedControllers.length); + return {text}; + } + + return {_("All the available zFCP controllers are already activated.")}; }; /** - * Section for zFCP disks. + * Content switcher for the zFCP controllers. */ -const DisksSection = () => { - const disks = useZFCPDisks(); +const ZFCPControllersContent = (): React.ReactNode => { + const controllers = useControllers(); + const deactivatedControllers = controllers.filter((c) => !c.active); return ( - - {disks.length === 0 ? : } + + + {_("Activate controllers")} + + + ) + } + > + + + + {_("zFCP controllers")}{" "} + {controllers.map((c) => c.channel).join(", ")} + + + + + + ); }; /** - * Section for zFCP controllers. + * Content switcher for the zFCP devices. */ -const ControllersSection = () => ( - - - - -); - -const PageContent = () => { - const controllers = useZFCPControllers(); - - if (controllers.length === 0) { - return ( - - {_("Read zFCP devices")} - - } - > -
{_("Please, try to activate a zFCP controller.")}
-
- ); +const ZFCPDevicesContent = (): React.ReactNode => { + const devices = useDevices(); + + if (isEmpty(devices)) { + return ; + } + + return ; +}; + +/** + * Content switcher for the zFCP page. + */ +const ZFCPPageContent = (): React.ReactNode => { + const controllers = useControllers(); + + if (isEmpty(controllers)) { + return ; } return ( - - + + - - + + + ); }; /** - * Page for managing zFCP devices. + * Top-level page component for zFCP storage. */ -export default function ZFCPPage() { - useZFCPControllersChanges(); - useZFCPDisksChanges(); +export default function ZFCPPage(): React.ReactNode { + const issues = useIssues("zfcp"); return ( - + - + + - - - - {_("Back")} - - ); } diff --git a/web/src/model/issue.ts b/web/src/model/issue.ts index 70c9378999..de11a3497a 100644 --- a/web/src/model/issue.ts +++ b/web/src/model/issue.ts @@ -20,7 +20,7 @@ * find current contact information at www.suse.com. */ -type Scope = "localization" | "product" | "software" | "storage" | "users" | "iscsi"; +type Scope = "localization" | "product" | "software" | "storage" | "users" | "iscsi" | "zfcp"; type Issue = { scope: Scope; diff --git a/web/src/model/status.ts b/web/src/model/status.ts index 6d576dfdcb..946d7ef3a7 100644 --- a/web/src/model/status.ts +++ b/web/src/model/status.ts @@ -31,6 +31,7 @@ type Scope = | "storage" | "iscsi" | "dasd" + | "zfcp" | "users"; type Progress = { diff --git a/web/src/routes/paths.ts b/web/src/routes/paths.ts index fca4403430..5914face5f 100644 --- a/web/src/routes/paths.ts +++ b/web/src/routes/paths.ts @@ -99,7 +99,7 @@ const STORAGE = { dasd: "/storage/dasd", zfcp: { root: "/storage/zfcp", - activateDisk: "/storage/zfcp/active-disk", + controllers: "/storage/zfcp/controllers", }, }; diff --git a/web/src/routes/storage.tsx b/web/src/routes/storage.tsx index 6543927a4d..2623dc9319 100644 --- a/web/src/routes/storage.tsx +++ b/web/src/routes/storage.tsx @@ -21,7 +21,6 @@ */ import React from "react"; -import { redirect } from "react-router"; import { N_ } from "~/i18n"; import { Route } from "~/types/routes"; import BootSelectionPage from "~/components/storage/BootSelectionPage"; @@ -34,14 +33,11 @@ import PartitionPage from "~/components/storage/PartitionPage"; import LvmPage from "~/components/storage/LvmPage"; import LogicalVolumePage from "~/components/storage/LogicalVolumePage"; import ZFCPPage from "~/components/storage/zfcp/ZFCPPage"; -import ZFCPDiskActivationPage from "~/components/storage/zfcp/ZFCPDiskActivationPage"; +import ZFCPControllersPage from "~/components/storage/zfcp/ZFCPControllersPage"; import DASDPage from "~/components/storage/dasd/DASDPage"; import TargetLoginPage from "~/components/storage/iscsi/TargetLoginPage"; import DiscoverFormPage from "~/components/storage/iscsi/DiscoverFormPage"; import DeviceSelectorPage from "~/components/storage/DeviceSelectorPage"; -// FIXME: adapt to new API -// import { supportedDASD, probeDASD } from "~/model/storage/dasd"; -import { probeZFCP, supportedZFCP } from "~/model/storage/zfcp"; import { STORAGE as PATHS } from "~/routes/paths"; import InitiatorFormPage from "~/components/storage/iscsi/InitiatorFormPage"; @@ -117,28 +113,15 @@ const routes = (): Route => ({ path: PATHS.dasd, element: , handle: { name: N_("DASD") }, - // FIXME: adapt to new API - // loader: async () => { - // if (!supportedDASD()) return redirect(PATHS.root); - // return probeDASD(); - // }, }, { path: PATHS.zfcp.root, element: , handle: { name: N_("ZFCP") }, - loader: async () => { - if (!supportedZFCP()) return redirect(PATHS.root); - return probeZFCP(); - }, - }, - { - path: PATHS.zfcp.activateDisk, - element: , - loader: async () => { - if (!supportedZFCP()) return redirect(PATHS.root); - return probeZFCP(); - }, + }, + { + path: PATHS.zfcp.controllers, + element: , }, ], }); From ed534425d05af09517468de1079621a256e56d52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Thu, 12 Mar 2026 09:14:42 +0000 Subject: [PATCH 10/23] Allow SimpleSelector to receive untranslated strings --- web/src/components/core/SimpleSelector.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/components/core/SimpleSelector.tsx b/web/src/components/core/SimpleSelector.tsx index b39a7bc6d2..29067efd2b 100644 --- a/web/src/components/core/SimpleSelector.tsx +++ b/web/src/components/core/SimpleSelector.tsx @@ -38,7 +38,7 @@ import type { TranslatedString } from "~/i18n"; type SimpleSelectorProps = { label: TranslatedString; value: string; - options: Record; + options: Record; onChange: SelectProps["onSelect"]; }; From 51564eeda349fcf2116db54c70c97e945c7b7a8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Thu, 12 Mar 2026 09:16:03 +0000 Subject: [PATCH 11/23] Enable button for configuring zFCP --- .../storage/ConnectedDevicesMenu.test.tsx | 18 ++++++++++-------- .../storage/ConnectedDevicesMenu.tsx | 5 +++-- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/web/src/components/storage/ConnectedDevicesMenu.test.tsx b/web/src/components/storage/ConnectedDevicesMenu.test.tsx index 52cf4cdbe4..25093ef670 100644 --- a/web/src/components/storage/ConnectedDevicesMenu.test.tsx +++ b/web/src/components/storage/ConnectedDevicesMenu.test.tsx @@ -38,6 +38,12 @@ jest.mock("~/hooks/model/system/dasd", () => ({ useSystem: () => mockUseDASDSystem(), })); +const mockUseZFCPSystem = jest.fn(); +jest.mock("~/hooks/model/system/zfcp", () => ({ + ...jest.requireActual("~/hooks/model/system/zfcp"), + useSystem: () => mockUseZFCPSystem(), +})); + async function openMenu() { const { user } = installerRender(); const button = screen.getByRole("button", { name: "More storage options" }); @@ -68,11 +74,9 @@ it("allows users to configure iSCSI", async () => { }); describe("if zFCP is not supported", () => { - /* beforeEach(() => { - mockUseZFCPSupported.mockReturnValue(false); + mockUseZFCPSystem.mockReturnValue(null); }); - */ it("does not allow users to configure zFCP", async () => { const { menu } = await openMenu(); @@ -81,12 +85,10 @@ describe("if zFCP is not supported", () => { }); }); -describe.skip("if zFCP is supported", () => { - /* +describe("if zFCP is supported", () => { beforeEach(() => { - mockUseZFCPSupported.mockReturnValue(true); + mockUseZFCPSystem.mockReturnValue({}); }); - */ it("allows users to configure zFCP", async () => { const { user, menu } = await openMenu(); @@ -98,7 +100,7 @@ describe.skip("if zFCP is supported", () => { describe("if DASD is not supported", () => { beforeEach(() => { - mockUseDASDSystem.mockReturnValue(undefined); + mockUseDASDSystem.mockReturnValue(null); }); it("does not allow users to configure DASD", async () => { diff --git a/web/src/components/storage/ConnectedDevicesMenu.tsx b/web/src/components/storage/ConnectedDevicesMenu.tsx index a7862c0b74..3e9e00c87c 100644 --- a/web/src/components/storage/ConnectedDevicesMenu.tsx +++ b/web/src/components/storage/ConnectedDevicesMenu.tsx @@ -27,12 +27,13 @@ import { STORAGE } from "~/routes/paths"; import Icon from "~/components/layout/Icon"; import MenuButton from "~/components/core/MenuButton"; import { useSystem as useDASDSystem } from "~/hooks/model/system/dasd"; +import { useSystem as useZFCPSystem } from "~/hooks/model/system/zfcp"; import { _ } from "~/i18n"; export default function ConnectedDevicesMenu() { const navigate = useNavigate(); - const isZFCPSupported = false; const dasdSystem = useDASDSystem(); + const zfcpSystem = useZFCPSystem(); return ( {_("Configure iSCSI")} , - isZFCPSupported && ( + zfcpSystem && ( navigate(STORAGE.zfcp.root)} From 3c36bd62513690f386e80828ba31c91420af4652 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Thu, 12 Mar 2026 09:16:47 +0000 Subject: [PATCH 12/23] Alert about zFCP issues in the summary --- .../overview/StorageSummary.test.tsx | 40 +++++++++++++------ .../components/overview/StorageSummary.tsx | 34 +++++++++++++--- 2 files changed, 56 insertions(+), 18 deletions(-) diff --git a/web/src/components/overview/StorageSummary.test.tsx b/web/src/components/overview/StorageSummary.test.tsx index f31f89a30c..e3c33517c5 100644 --- a/web/src/components/overview/StorageSummary.test.tsx +++ b/web/src/components/overview/StorageSummary.test.tsx @@ -29,7 +29,7 @@ import { useFlattenDevices as useProposalFlattenDevices, useActions, } from "~/hooks/model/proposal/storage"; -import { useIssues } from "~/hooks/model/issue"; +import * as issueHooks from "~/hooks/model/issue"; import { STORAGE } from "~/routes/paths"; import StorageSummary from "./StorageSummary"; @@ -40,7 +40,8 @@ const mockUseDevicesFn: jest.Mock> = jest.fn(); const mockUseProposalFlattenDevicesFn: jest.Mock> = jest.fn(); const mockUseActionsFn: jest.Mock> = jest.fn(); -const mockUseIssuesFn: jest.Mock> = jest.fn(); +const mockStorageIssuesFn: jest.Mock> = jest.fn(); +const mockZFCPIssuesFn: jest.Mock> = jest.fn(); // Mock all the hooks jest.mock("~/hooks/model/storage/config-model", () => ({ @@ -61,10 +62,11 @@ jest.mock("~/hooks/model/proposal/storage", () => ({ useActions: () => mockUseActionsFn(), })); -jest.mock("~/hooks/model/issue", () => ({ - ...jest.requireActual("~/hooks/model/issue"), - useIssues: () => mockUseIssuesFn(), -})); +jest.spyOn(issueHooks, "useIssues").mockImplementation((scope) => { + if (scope === "storage") return mockStorageIssuesFn(); + if (scope === "zfcp") return mockZFCPIssuesFn(); + return []; +}); // Mock device for tests const mockDevice = { @@ -99,7 +101,8 @@ describe("StorageSummary", () => { mockUseFlattenDevicesFn.mockReturnValue([]); mockUseProposalFlattenDevicesFn.mockReturnValue([]); mockUseActionsFn.mockReturnValue([]); - mockUseIssuesFn.mockReturnValue([]); + mockStorageIssuesFn.mockReturnValue([]); + mockZFCPIssuesFn.mockReturnValue([]); }); afterEach(() => { @@ -170,11 +173,11 @@ describe("StorageSummary", () => { }); it("shows invalid settings warning when config issues exist", () => { - mockUseIssuesFn.mockReturnValue([ + mockStorageIssuesFn.mockReturnValue([ { - description: "Fake Issue", + description: "Fake issue", class: "generic", - details: "Fake Issue details", + details: "Fake issue details", scope: "storage", }, ]); @@ -182,6 +185,19 @@ describe("StorageSummary", () => { screen.getByText("Invalid settings"); }); + it("shows invalid ZFCP settings warning when ZFCP issues exist", () => { + mockZFCPIssuesFn.mockReturnValue([ + { + description: "Fake issue", + class: "generic", + scope: "zfcp", + }, + ]); + installerRender(); + screen.getByText(/Invalid/); + screen.getByText(/zFCP/); + }); + it("shows advanced configuration message when model is unavailable", () => { mockUseConfigModelFn.mockReturnValue(null); installerRender(); @@ -189,7 +205,7 @@ describe("StorageSummary", () => { }); it("ignores proposal class issues when checking config validity", () => { - mockUseIssuesFn.mockReturnValue([ + mockStorageIssuesFn.mockReturnValue([ { description: "Fake Issue", class: "proposal", @@ -254,7 +270,7 @@ describe("StorageSummary", () => { }); it("hides description when config issues exist", () => { - mockUseIssuesFn.mockReturnValue([ + mockStorageIssuesFn.mockReturnValue([ { description: "Fake Issue", class: "generic", diff --git a/web/src/components/overview/StorageSummary.tsx b/web/src/components/overview/StorageSummary.tsx index 562fe74097..2f225ee55a 100644 --- a/web/src/components/overview/StorageSummary.tsx +++ b/web/src/components/overview/StorageSummary.tsx @@ -43,6 +43,7 @@ import { _, formatList } from "~/i18n"; import type { Storage } from "~/model/system"; import type { ConfigModel } from "~/model/storage/config-model"; +import { isEmpty } from "radashi"; const findDriveDevice = (drive: ConfigModel.Drive, devices: Storage.Device[]) => devices.find((d) => d.name === drive.name); @@ -82,16 +83,33 @@ const ModelSummary = ({ model }: { model: ConfigModel.Config }): React.ReactNode return ; }; +const InvalidZFCP = (): React.ReactNode => { + // TRANSLATORS: The text in [] is used as a link. + const text = _("Invalid [zFCP] settings"); + const [textStart, textLink, textEnd] = text.split(/[[\]]/); + return ( +

+ {textStart} + + {textLink} + + {textEnd} +

+ ); +}; + const Value = () => { const availableDevices = useAvailableDevices(); const model = useConfigModel(); const issues = useIssues("storage"); + const zfcpIssues = useIssues("zfcp"); const configIssues = issues.filter((i) => i.class !== "proposal"); - if (!availableDevices.length) return _("There are no disks available for the installation"); - if (configIssues.length) { + if (isEmpty(availableDevices)) return _("There are no disks available for the installation"); + if (!isEmpty(configIssues)) { return _("Invalid settings"); } + if (!isEmpty(zfcpIssues)) return ; if (!model) return _("Using an advanced storage configuration"); @@ -103,17 +121,18 @@ const Description = () => { const staging = useProposalFlattenDevices(); const actions = useActions(); const issues = useIssues("storage"); + const zfcpIssues = useIssues("zfcp"); const configIssues = issues.filter((i) => i.class !== "proposal"); const manager = new DevicesManager(system, staging, actions); - if (configIssues.length) return; - if (!actions.length) return _("Failed to calculate a storage layout"); + if (!isEmpty(configIssues) || !isEmpty(zfcpIssues)) return; + if (isEmpty(actions)) return _("Failed to calculate a storage layout"); const deleteActions = manager.actions.filter((a) => a.delete && !a.subvol).length; if (!deleteActions) return _("No data loss is expected"); const systems = manager.deletedSystems(); - if (systems.length) { + if (!isEmpty(systems)) { return sprintf( // TRANSLATORS: %s will be replaced by a formatted list of affected systems // like "Windows and openSUSE Tumbleweed". @@ -155,7 +174,10 @@ const Description = () => { */ export default function StorageSummary() { const { loading } = useProgressTracking("storage"); - const hasIssues = !!useIssues("storage").length; + const issues = useIssues("storage"); + const zfcpIssues = useIssues("zfcp"); + + const hasIssues = !isEmpty(issues) || !isEmpty(zfcpIssues); return ( Date: Thu, 12 Mar 2026 09:18:08 +0000 Subject: [PATCH 13/23] Add zFCP issues to ProposalPage - And allow configuring zFCP in the empty state. --- .../components/storage/ProposalPage.test.tsx | 22 ++++++++++++------- web/src/components/storage/ProposalPage.tsx | 12 +++++----- 2 files changed, 21 insertions(+), 13 deletions(-) diff --git a/web/src/components/storage/ProposalPage.test.tsx b/web/src/components/storage/ProposalPage.test.tsx index 21359d4914..b42bf70fa0 100644 --- a/web/src/components/storage/ProposalPage.test.tsx +++ b/web/src/components/storage/ProposalPage.test.tsx @@ -65,6 +65,7 @@ const mockUseConfigModel = jest.fn(); const mockUseProposal = jest.fn(); const mockUseIssues = jest.fn(); const mockUseDASDSystem = jest.fn(); +const mockUseZFCPSystem = jest.fn(); jest.mock("~/hooks/model/system/storage", () => ({ ...jest.requireActual("~/hooks/model/system/storage"), @@ -96,6 +97,11 @@ jest.mock("~/hooks/model/system/dasd", () => ({ useSystem: () => mockUseDASDSystem(), })); +jest.mock("~/hooks/model/system/zfcp", () => ({ + ...jest.requireActual("~/hooks/model/system/zfcp"), + useSystem: () => mockUseZFCPSystem(), +})); + jest.mock("./ProposalFailedInfo", () => () =>
proposal failed info
); jest.mock("./UnsupportedModelInfo", () => () =>
unsupported model info
); jest.mock("./FixableConfigInfo", () => () =>
fixable config info
); @@ -133,9 +139,9 @@ describe("if there are no devices", () => { }); describe("if zFCP is not supported", () => { - // beforeEach(() => { - // mockUseZFCPSupported.mockReturnValue(false); - // }); + beforeEach(() => { + mockUseZFCPSystem.mockReturnValue(null); + }); it("does not render an option for activating zFCP", () => { installerRender(); @@ -145,7 +151,7 @@ describe("if there are no devices", () => { describe("if DASD is not supported", () => { beforeEach(() => { - mockUseDASDSystem.mockReturnValue(undefined); + mockUseDASDSystem.mockReturnValue(null); }); it("does not render an option for activating DASD", () => { @@ -154,10 +160,10 @@ describe("if there are no devices", () => { }); }); - describe.skip("if zFCP is supported", () => { - // beforeEach(() => { - // mockUseZFCPSupported.mockReturnValue(true); - // }); + describe("if zFCP is supported", () => { + beforeEach(() => { + mockUseZFCPSystem.mockReturnValue({}); + }); it("renders an option for activating zFCP", () => { installerRender(); diff --git a/web/src/components/storage/ProposalPage.tsx b/web/src/components/storage/ProposalPage.tsx index 9a731a90ff..12fa33b075 100644 --- a/web/src/components/storage/ProposalPage.tsx +++ b/web/src/components/storage/ProposalPage.tsx @@ -61,10 +61,10 @@ import { _, n_ } from "~/i18n"; import { useLocation } from "react-router"; import { useStorageUiState } from "~/context/storage-ui-state"; import { useSystem as useDASDSystem } from "~/hooks/model/system/dasd"; - -import type { Issue } from "~/model/issue"; - +import { useSystem as useZFCPSystem } from "~/hooks/model/system/zfcp"; import spacingStyles from "@patternfly/react-styles/css/utilities/Spacing/spacing"; +import IssuesAlert from "~/components/core/IssuesAlert"; +import type { Issue } from "~/model/issue"; type InvalidConfigEmptyStateProps = { issues: Issue[]; @@ -136,8 +136,8 @@ function UnknownConfigEmptyState(): React.ReactNode { } function UnavailableDevicesEmptyState(): React.ReactNode { - const isZFCPSupported = false; const dasdSystem = useDASDSystem(); + const zfcpSystem = useZFCPSystem(); const description = _( "There are not disks available for the installation. You may need to configure some device.", @@ -157,7 +157,7 @@ function UnavailableDevicesEmptyState(): React.ReactNode { {_("Connect to iSCSI targets")} - {isZFCPSupported && ( + {zfcpSystem && ( {_("Activate zFCP disks")} @@ -307,6 +307,7 @@ export default function ProposalPage(): React.ReactNode { // Hopefully this could be removed in the future. See rationale at UseStorageUiState const [resetNeeded, setResetNeeded] = useState(location.state?.resetStorageUiState); const { setUiState } = useStorageUiState(); + const zfcpIssues = useIssues("zfcp"); React.useEffect(() => { if (resetNeeded) { @@ -324,6 +325,7 @@ export default function ProposalPage(): React.ReactNode { progress={{ scope: "storage", ensureRefetched: STORAGE_MODEL_KEY }} > + From 34b404f24ef3311191375187f00e465dfbc157ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Thu, 12 Mar 2026 09:33:13 +0000 Subject: [PATCH 14/23] Delete old zFCP code --- .../zfcp/ZFCPControllersTable.test.tsx | 81 --------- .../storage/zfcp/ZFCPControllersTable.tsx | 128 ------------- .../storage/zfcp/ZFCPDiskActivationPage.tsx | 82 --------- .../storage/zfcp/ZFCPDiskForm.test.tsx | 164 ----------------- .../components/storage/zfcp/ZFCPDiskForm.tsx | 148 --------------- .../storage/zfcp/ZFCPDiskTable.test.tsx | 81 --------- .../storage/zfcp/ZFCPDisksTable.tsx | 134 -------------- web/src/components/storage/zfcp/index.ts | 24 --- web/src/model/storage/zfcp.ts | 94 ---------- web/src/queries/storage/zfcp.ts | 168 ------------------ web/src/types/zfcp.ts | 48 ----- web/src/utils/zfcp.test.ts | 87 --------- web/src/utils/zfcp.ts | 46 ----- 13 files changed, 1285 deletions(-) delete mode 100644 web/src/components/storage/zfcp/ZFCPControllersTable.test.tsx delete mode 100644 web/src/components/storage/zfcp/ZFCPControllersTable.tsx delete mode 100644 web/src/components/storage/zfcp/ZFCPDiskActivationPage.tsx delete mode 100644 web/src/components/storage/zfcp/ZFCPDiskForm.test.tsx delete mode 100644 web/src/components/storage/zfcp/ZFCPDiskForm.tsx delete mode 100644 web/src/components/storage/zfcp/ZFCPDiskTable.test.tsx delete mode 100644 web/src/components/storage/zfcp/ZFCPDisksTable.tsx delete mode 100644 web/src/components/storage/zfcp/index.ts delete mode 100644 web/src/model/storage/zfcp.ts delete mode 100644 web/src/queries/storage/zfcp.ts delete mode 100644 web/src/types/zfcp.ts delete mode 100644 web/src/utils/zfcp.test.ts delete mode 100644 web/src/utils/zfcp.ts diff --git a/web/src/components/storage/zfcp/ZFCPControllersTable.test.tsx b/web/src/components/storage/zfcp/ZFCPControllersTable.test.tsx deleted file mode 100644 index 8209cfc675..0000000000 --- a/web/src/components/storage/zfcp/ZFCPControllersTable.test.tsx +++ /dev/null @@ -1,81 +0,0 @@ -/* - * 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 the GNU General Public License as published by the Free - * Software Foundation; either version 2 of the License, or (at your option) - * any later version. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for - * more details. - * - * You should have received a copy of the GNU General Public License along - * with this program; if not, contact SUSE LLC. - * - * To contact SUSE LLC about this file by physical or electronic mail, you may - * find current contact information at www.suse.com. - */ - -import React from "react"; -import { screen } from "@testing-library/react"; -import { installerRender } from "~/test-utils"; -import { ZFCPController, ZFCPDisk } from "~/types/zfcp"; -import ZFCPControllersTable from "./ZFCPControllersTable"; - -const mockZFCPDisk: ZFCPDisk[] = [ - { - name: "/dev/sda", - channel: "0.0.fa00", - wwpn: "0x500507630b181216", - lun: "0x4020404900000000", - }, - { - name: "/dev/sdb", - channel: "0.0.fc00", - wwpn: "0x500507630b101216", - lun: "0x0001000000000000", - }, -]; - -const mockZFCPControllers: ZFCPController[] = [ - { - id: "1", - channel: "0.0.fa00", - lunScan: false, - active: true, - lunsMap: { - "0x500507630b181216": ["0x4020404900000000"], - "0x500507680d7e284a": [], - "0x500507680d0e284a": [], - }, - }, - { - id: "2", - channel: "0.0.fc00", - lunScan: false, - active: true, - lunsMap: { - "0x500507680d7e284b": [], - "0x500507680d0e284b": [], - "0x500507630b101216": ["0x0000000000000000", "0x0001000000000000"], - }, - }, -]; - -jest.mock("~/queries/storage/zfcp", () => ({ - useZFCPDisks: () => mockZFCPDisk, - useZFCPControllers: () => mockZFCPControllers, -})); - -describe("ZFCPControllersTable", () => { - describe("when there is some ZFCP controllers", () => { - it("renders those devices", () => { - installerRender(); - screen.getByText("0.0.fa00"); - }); - }); -}); diff --git a/web/src/components/storage/zfcp/ZFCPControllersTable.tsx b/web/src/components/storage/zfcp/ZFCPControllersTable.tsx deleted file mode 100644 index 97c4efca2f..0000000000 --- a/web/src/components/storage/zfcp/ZFCPControllersTable.tsx +++ /dev/null @@ -1,128 +0,0 @@ -import { Skeleton } from "@patternfly/react-core"; -import { Table, Thead, Tr, Th, Tbody, Td } from "@patternfly/react-table"; -import React, { useState } from "react"; -import { _ } from "~/i18n"; -import { useCancellablePromise } from "~/hooks/use-cancellable-promise"; -import { RowActions } from "../../core"; -import { ZFCPController } from "~/types/zfcp"; -import { activateZFCPController } from "~/model/storage/zfcp"; -import { useZFCPControllers } from "~/queries/storage/zfcp"; - -/** - * Table of zFCP controllers. - * - */ -export default function ZFCPControllersTable() { - const controllers = useZFCPControllers(); - const { cancellablePromise } = useCancellablePromise(); - - const columns = [ - { id: "channel", label: _("Channel ID") }, - { id: "active", label: _("Status") }, - { id: "lunScan", label: _("Auto LUNs Scan") }, - ]; - - const columnValue = (controller: ZFCPController, column: { id: string }) => { - let value: string; - - switch (column.id) { - case "channel": - value = controller.channel; - break; - case "active": - value = controller.active ? _("Activated") : _("Deactivated"); - break; - case "lunScan": - if (controller.active) value = controller.lunScan ? _("Yes") : _("No"); - else value = "-"; - break; - default: - value = ""; - } - - return value; - }; - - const actions = (controller: ZFCPController) => { - if (controller.active) return []; - - return [ - { - label: _("Activate"), - run: async () => await cancellablePromise(activateZFCPController(controller.id)), - }, - ]; - }; - - const [loadingRow, setLoadingRow] = useState(""); - - const sortedDevices = (): ZFCPController[] => { - return controllers.sort((d1, d2) => { - const v1 = columnValue(d1, columns[0]); - const v2 = columnValue(d2, columns[0]); - if (v1 < v2) return -1; - if (v1 > v2) return 1; - return 0; - }); - }; - - const Actions = ({ device }) => { - const deviceActions = actions(device); - if (deviceActions.length === 0) return null; - - const items = deviceActions.map((action) => ({ - title: action.label, - onClick: async () => { - setLoadingRow(device.id); - await action.run(); - setLoadingRow(undefined); - }, - })); - - return ; - }; - - return ( - - - - {columns.map((column) => ( - - ))} - - - - {sortedDevices().map((device) => { - const RowContent = () => { - if (loadingRow === device.id) { - return ( - - ); - } - - return ( - <> - {columns.map((column) => ( - - ))} - - - ); - }; - - return ( - - - - ); - })} - -
{column.label}
- - - {columnValue(device, column)} - - -
- ); -} diff --git a/web/src/components/storage/zfcp/ZFCPDiskActivationPage.tsx b/web/src/components/storage/zfcp/ZFCPDiskActivationPage.tsx deleted file mode 100644 index 079f22cac9..0000000000 --- a/web/src/components/storage/zfcp/ZFCPDiskActivationPage.tsx +++ /dev/null @@ -1,82 +0,0 @@ -/* - * Copyright (c) [2024-2026] SUSE LLC - * - * All Rights Reserved. - * - * This program is free software; you can redistribute it and/or modify it - * under the terms of the GNU General Public License as published by the Free - * Software Foundation; either version 2 of the License, or (at your option) - * any later version. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for - * more details. - * - * You should have received a copy of the GNU General Public License along - * with this program; if not, contact SUSE LLC. - * - * To contact SUSE LLC about this file by physical or electronic mail, you may - * find current contact information at www.suse.com. - */ - -import React, { useState } from "react"; -import { Grid, GridItem } from "@patternfly/react-core"; -import { Page } from "~/components/core"; -import { _ } from "~/i18n"; -import { useCancellablePromise } from "~/hooks/use-cancellable-promise"; -import { LUNInfo } from "~/types/zfcp"; -import { activateZFCPDisk } from "~/model/storage/zfcp"; -import { PATHS } from "~/routes/storage"; -import { useNavigate } from "react-router"; -import ZFCPDiskForm from "./ZFCPDiskForm"; -import { useZFCPControllersChanges, useZFCPDisksChanges } from "~/queries/storage/zfcp"; -import { STORAGE } from "~/routes/paths"; - -export default function ZFCPDiskActivationPage() { - useZFCPControllersChanges(); - useZFCPDisksChanges(); - const [isAcceptDisabled, setIsAcceptDisabled] = useState(false); - const { cancellablePromise } = useCancellablePromise(); - const navigate = useNavigate(); - - const onSubmit = async (formData: LUNInfo & { id: string }) => { - setIsAcceptDisabled(true); - const result = (await cancellablePromise( - activateZFCPDisk(formData.id, formData.wwpn, formData.lun), - )) as Awaited>; - if (result.status === 200) navigate(PATHS.zfcp.root); - - setIsAcceptDisabled(false); - return result; - }; - - const onLoading = (isLoading: boolean) => { - setIsAcceptDisabled(isLoading); - }; - - const formId = "ZFCPDiskForm"; - - return ( - - - - - - - - - - - - - - - ); -} diff --git a/web/src/components/storage/zfcp/ZFCPDiskForm.test.tsx b/web/src/components/storage/zfcp/ZFCPDiskForm.test.tsx deleted file mode 100644 index 16c014b232..0000000000 --- a/web/src/components/storage/zfcp/ZFCPDiskForm.test.tsx +++ /dev/null @@ -1,164 +0,0 @@ -/* - * Copyright (c) [2023] SUSE LLC - * - * All Rights Reserved. - * - * This program is free software; you can redistribute it and/or modify it - * under the terms of the GNU General Public License as published by the Free - * Software Foundation; either version 2 of the License, or (at your option) - * any later version. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for - * more details. - * - * You should have received a copy of the GNU General Public License along - * with this program; if not, contact SUSE LLC. - * - * To contact SUSE LLC about this file by physical or electronic mail, you may - * find current contact information at www.suse.com. - */ - -import React from "react"; -import { screen, within } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { plainRender } from "~/test-utils"; -import { ZFCPDisk, ZFCPController } from "~/types/zfcp"; -import ZFCPDiskForm from "./ZFCPDiskForm"; - -// The form does not provide a submit button by itself. -const FormWrapper = (props) => { - return ( - <> - - - - ); -}; - -const mockZFCPDisk: ZFCPDisk[] = [ - { - name: "/dev/sda", - channel: "0.0.fa00", - wwpn: "0x500507630b181216", - lun: "0x4020404900000000", - }, - { - name: "/dev/sdb", - channel: "0.0.fc00", - wwpn: "0x500507630b101216", - lun: "0x0001000000000000", - }, -]; - -const mockZFCPControllers: ZFCPController[] = [ - { - id: "1", - channel: "0.0.fa00", - lunScan: false, - active: true, - lunsMap: { - "0x500507630b181216": ["0x4020404900000000", "0x4020404900000001"], - "0x500507680d7e284a": [], - "0x500507680d0e284a": [], - }, - }, - { - id: "2", - channel: "0.0.fc00", - lunScan: false, - active: true, - lunsMap: { - "0x500507680d7e284b": [], - "0x500507680d0e284b": [], - "0x500507630b101216": ["0x0000000000000000", "0x0001000000000000"], - }, - }, -]; - -jest.mock("~/queries/storage/zfcp", () => ({ - useZFCPDisks: () => mockZFCPDisk, - useZFCPControllers: () => mockZFCPControllers, -})); - -const props = { - id: "ZFCPDiskForm", - onSubmit: jest.fn().mockResolvedValue({ data: null, status: 200 }), - onLoading: jest.fn(), -}; - -it("renders a form for selecting channel, WWPN and LUN", async () => { - plainRender(); - - const form = await screen.findByRole("form"); - const channelSelector = within(form).getByRole("combobox", { name: "Channel ID" }); - expect(within(channelSelector).getAllByRole("option").length).toBe(2); - within(channelSelector).getByRole("option", { name: "0.0.fc00" }); - - within(form).getByRole("combobox", { name: "WWPN" }); - within(form).getByRole("combobox", { name: "LUN" }); -}); - -it("offers the WWPNs of the selected channel", async () => { - plainRender(); - - const form = await screen.findByRole("form"); - const channelSelector = within(form).getByRole("combobox", { name: "Channel ID" }); - const channelOption = within(channelSelector).getByRole("option", { name: "0.0.fa00" }); - - await userEvent.selectOptions(channelSelector, channelOption); - - const wwpnSelector = within(form).getByRole("combobox", { name: "WWPN" }); - expect(within(wwpnSelector).getAllByRole("option").length).toBe(1); - within(wwpnSelector).getByRole("option", { name: "0x500507630b181216" }); -}); - -it("offers the LUNs of the selected channel and WWPN", async () => { - plainRender(); - - const form = await screen.findByRole("form"); - const channelSelector = within(form).getByRole("combobox", { name: "Channel ID" }); - const channelOption = within(channelSelector).getByRole("option", { name: "0.0.fa00" }); - - await userEvent.selectOptions(channelSelector, channelOption); - - const wwpnSelector = within(form).getByRole("combobox", { name: "WWPN" }); - expect(within(wwpnSelector).getAllByRole("option").length).toBe(1); - const wwpnOption = within(wwpnSelector).getByRole("option", { name: "0x500507630b181216" }); - - await userEvent.selectOptions(wwpnSelector, wwpnOption); - - const lunSelector = within(form).getByRole("combobox", { name: "LUN" }); - expect(within(lunSelector).getAllByRole("option").length).toBe(1); - within(lunSelector).getByRole("option", { name: "0x4020404900000001" }); -}); - -describe("when the form is submitted", () => { - it("calls to the given onSubmit prop", async () => { - const { user } = plainRender(); - - const accept = screen.getByRole("button", { name: "Accept" }); - await user.click(accept); - - expect(props.onSubmit).toHaveBeenCalledWith({ - id: "1", - channel: "0.0.fa00", - wwpn: "0x500507630b181216", - lun: "0x4020404900000001", - }); - - expect(screen.queryByText(/was not activated/)).toBeNull(); - }); - - it("shows an error if the action fails", async () => { - props.onSubmit = jest.fn().mockResolvedValue({ status: 400 }); - - const { user } = plainRender(); - - const accept = screen.getByRole("button", { name: "Accept" }); - await user.click(accept); - - screen.getByText(/was not activated/); - }); -}); diff --git a/web/src/components/storage/zfcp/ZFCPDiskForm.tsx b/web/src/components/storage/zfcp/ZFCPDiskForm.tsx deleted file mode 100644 index ab5d79445b..0000000000 --- a/web/src/components/storage/zfcp/ZFCPDiskForm.tsx +++ /dev/null @@ -1,148 +0,0 @@ -/* - * Copyright (c) [2023-2024] SUSE LLC - * - * All Rights Reserved. - * - * This program is free software; you can redistribute it and/or modify it - * under the terms of the GNU General Public License as published by the Free - * Software Foundation; either version 2 of the License, or (at your option) - * any later version. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for - * more details. - * - * You should have received a copy of the GNU General Public License along - * with this program; if not, contact SUSE LLC. - * - * To contact SUSE LLC about this file by physical or electronic mail, you may - * find current contact information at www.suse.com. - */ - -import React, { FormEvent, useEffect, useState } from "react"; -import { Alert, Form, FormGroup, FormSelect, FormSelectOption } from "@patternfly/react-core"; -import { AxiosResponse } from "axios"; -import { Page } from "~/components/core"; -import { useZFCPControllers, useZFCPDisks } from "~/queries/storage/zfcp"; -import { inactiveLuns } from "~/utils/zfcp"; -import { _ } from "~/i18n"; - -type FormData = { - id?: string; - channel?: string; - wwpn?: string; - lun?: string; -}; - -/** - * Form for activating a zFCP disk. - */ -export default function ZFCPDiskForm({ - id, - onSubmit, - onLoading, -}: { - id: string; - onSubmit: (formData: FormData) => Promise; - onLoading: (isLoading: boolean) => void; -}) { - const controllers = useZFCPControllers(); - const disks = useZFCPDisks(); - const luns = inactiveLuns(controllers, disks); - - const [formData, setFormData] = useState({} as FormData); - const [isLoading, setIsLoading] = useState(false); - const [isFailed, setIsFailed] = useState(false); - - useEffect(() => { - onLoading(isLoading); - }, [onLoading, isLoading]); - - const getChannels = () => { - const channels = [...new Set(luns.map((l) => l.channel))]; - return channels.sort(); - }; - - const getWWPNs = (channel: string) => { - const selection = luns.filter((l) => l.channel === channel); - const wwpns = [...new Set(selection.map((l) => l.wwpn))]; - return wwpns.sort(); - }; - - const getLUNs = (channel: string, wwpn: string) => { - const selection = luns.filter((l) => l.channel === channel && l.wwpn === wwpn); - return selection.map((l) => l.lun).sort(); - }; - - const select = ( - channel: string = undefined, - wwpn: string = undefined, - lun: string = undefined, - ) => { - if (!channel) channel = getChannels()[0]; - if (!wwpn) wwpn = getWWPNs(channel)[0]; - if (!lun) lun = getLUNs(channel, wwpn)[0]; - - if (channel) setFormData({ channel, wwpn, lun }); - }; - - const selectChannel = (_, channel: string) => select(channel); - - const selectWWPN = (_, wwpn: string) => select(formData.channel, wwpn); - - const selectLUN = (_, lun: string) => select(formData.channel, formData.wwpn, lun); - - const submit = async (event: FormEvent) => { - event.preventDefault(); - - setIsLoading(true); - const controller = controllers.find((c) => c.channel === formData.channel); - const result = await onSubmit({ id: controller.id, ...formData }); - setIsFailed(result.status !== 200); - setIsLoading(false); - }; - - if (!formData.channel && getChannels().length > 0) select(); - - return ( - // TRANSLATORS: zFCP disk activation form - - {isFailed && ( - -

{_("The zFCP disk was not activated.")}

-
- )} -
- - - {getChannels().map((channel, i) => ( - - ))} - - - {/* TRANSLATORS: abbrev. World Wide Port Name */} - - - {getWWPNs(formData.channel).map((wwpn, i) => ( - - ))} - - - {/* TRANSLATORS: abbrev. Logical Unit Number */} - - - {getLUNs(formData.channel, formData.wwpn).map((lun, i) => ( - - ))} - - -
-
- ); -} diff --git a/web/src/components/storage/zfcp/ZFCPDiskTable.test.tsx b/web/src/components/storage/zfcp/ZFCPDiskTable.test.tsx deleted file mode 100644 index e7b82557b9..0000000000 --- a/web/src/components/storage/zfcp/ZFCPDiskTable.test.tsx +++ /dev/null @@ -1,81 +0,0 @@ -/* - * 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 the GNU General Public License as published by the Free - * Software Foundation; either version 2 of the License, or (at your option) - * any later version. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for - * more details. - * - * You should have received a copy of the GNU General Public License along - * with this program; if not, contact SUSE LLC. - * - * To contact SUSE LLC about this file by physical or electronic mail, you may - * find current contact information at www.suse.com. - */ - -import React from "react"; -import { screen } from "@testing-library/react"; -import { installerRender } from "~/test-utils"; -import { ZFCPController, ZFCPDisk } from "~/types/zfcp"; -import ZFCPDisksTable from "./ZFCPDisksTable"; - -const mockZFCPDisk: ZFCPDisk[] = [ - { - name: "/dev/sda", - channel: "0.0.fa00", - wwpn: "0x500507630b181216", - lun: "0x4020404900000000", - }, - { - name: "/dev/sdb", - channel: "0.0.fc00", - wwpn: "0x500507630b101216", - lun: "0x0001000000000000", - }, -]; - -const mockZFCPControllers: ZFCPController[] = [ - { - id: "1", - channel: "0.0.fa00", - lunScan: false, - active: true, - lunsMap: { - "0x500507630b181216": ["0x4020404900000000"], - "0x500507680d7e284a": [], - "0x500507680d0e284a": [], - }, - }, - { - id: "2", - channel: "0.0.fc00", - lunScan: false, - active: true, - lunsMap: { - "0x500507680d7e284b": [], - "0x500507680d0e284b": [], - "0x500507630b101216": ["0x0000000000000000", "0x0001000000000000"], - }, - }, -]; - -jest.mock("~/queries/storage/zfcp", () => ({ - useZFCPDisks: () => mockZFCPDisk, - useZFCPControllers: () => mockZFCPControllers, -})); - -describe("ZFCPDiskTable", () => { - describe("when there is some ZFCP disks activated", () => { - it("renders those devices", () => { - installerRender(); - screen.getByText("0.0.fa00"); - }); - }); -}); diff --git a/web/src/components/storage/zfcp/ZFCPDisksTable.tsx b/web/src/components/storage/zfcp/ZFCPDisksTable.tsx deleted file mode 100644 index 24155be4d5..0000000000 --- a/web/src/components/storage/zfcp/ZFCPDisksTable.tsx +++ /dev/null @@ -1,134 +0,0 @@ -/* - * 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 the GNU General Public License as published by the Free - * Software Foundation; either version 2 of the License, or (at your option) - * any later version. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for - * more details. - * - * You should have received a copy of the GNU General Public License along - * with this program; if not, contact SUSE LLC. - * - * To contact SUSE LLC about this file by physical or electronic mail, you may - * find current contact information at www.suse.com. - */ - -import React, { useState } from "react"; -import { deactivateZFCPDisk } from "~/model/storage/zfcp"; -import { useZFCPControllers, useZFCPDisks } from "~/queries/storage/zfcp"; -import { ZFCPDisk } from "~/types/zfcp"; -import { useCancellablePromise } from "~/hooks/use-cancellable-promise"; -import RowActions from "../../core/RowActions"; -import { Table, Tbody, Td, Th, Thead, Tr } from "@patternfly/react-table"; -import { Skeleton } from "@patternfly/react-core"; -import { _ } from "~/i18n"; - -/** - * Table of zFCP disks. - */ -export default function ZFCPDisksTable() { - const disks = useZFCPDisks(); - const controllers = useZFCPControllers(); - const { cancellablePromise } = useCancellablePromise(); - - const columns = [ - { id: "name", label: _("Name") }, - { id: "channel", label: _("Channel ID") }, - { id: "wwpn", label: _("WWPN") }, - { id: "lun", label: _("LUN") }, - ]; - - const columnValue = (disk: ZFCPDisk, column) => disk[column.id]; - - const actions = (disk: ZFCPDisk) => { - const controller = controllers.find((c) => c.channel === disk.channel); - if (!controller || controller.lunScan) return []; - - return [ - { - label: _("Deactivate"), - run: async () => - await cancellablePromise(deactivateZFCPDisk(controller.id, disk.wwpn, disk.lun)), - }, - ]; - }; - - const [loadingRow, setLoadingRow] = useState(""); - - const sortedDisks = () => { - return disks.sort((d1, d2) => { - const v1 = columnValue(d1, columns[0]); - const v2 = columnValue(d2, columns[0]); - if (v1 < v2) return -1; - if (v1 > v2) return 1; - return 0; - }); - }; - - const Actions = ({ device }: { device: ZFCPDisk }) => { - const deviceActions = actions(device); - if (deviceActions.length === 0) return null; - - const items = deviceActions.map((action) => ({ - title: action.label, - onClick: async () => { - setLoadingRow(device.name); - await action.run(); - setLoadingRow(""); - }, - })); - - return ; - }; - - return ( - - - - {columns.map((column) => ( - - ))} - - - - {sortedDisks().map((device) => { - const RowContent = () => { - if (loadingRow === device.name) { - return ( - - ); - } - - return ( - <> - {columns.map((column) => ( - - ))} - - - ); - }; - - return ( - - - - ); - })} - -
{column.label}
- - - {columnValue(device, column)} - - -
- ); -} diff --git a/web/src/components/storage/zfcp/index.ts b/web/src/components/storage/zfcp/index.ts deleted file mode 100644 index e9da01e693..0000000000 --- a/web/src/components/storage/zfcp/index.ts +++ /dev/null @@ -1,24 +0,0 @@ -/* - * 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 the GNU General Public License as published by the Free - * Software Foundation; either version 2 of the License, or (at your option) - * any later version. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for - * more details. - * - * You should have received a copy of the GNU General Public License along - * with this program; if not, contact SUSE LLC. - * - * To contact SUSE LLC about this file by physical or electronic mail, you may - * find current contact information at www.suse.com. - */ - -export { default as ZFCPPage } from "./ZFCPPage"; -export { default as ZFCPDiskActivationPage } from "./ZFCPDiskActivationPage"; diff --git a/web/src/model/storage/zfcp.ts b/web/src/model/storage/zfcp.ts deleted file mode 100644 index bbf528d3f8..0000000000 --- a/web/src/model/storage/zfcp.ts +++ /dev/null @@ -1,94 +0,0 @@ -/* - * 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 the GNU General Public License as published by the Free - * Software Foundation; either version 2 of the License, or (at your option) - * any later version. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for - * more details. - * - * You should have received a copy of the GNU General Public License along - * with this program; if not, contact SUSE LLC. - * - * To contact SUSE LLC about this file by physical or electronic mail, you may - * find current contact information at www.suse.com. - */ - -// @todo Move to the new API. - -import { post, get } from "~/http"; -import { ZFCPDisk, ZFCPController, ZFCPConfig } from "~/types/zfcp"; - -/** - * Returns the list of zFCP controllers - */ -const fetchZFCPControllers = (): Promise => get("/api/storage/zfcp/controllers"); - -/** - * Returns the list of zFCP disks - */ -const fetchZFCPDisks = (): Promise => get("/api/storage/zfcp/disks"); - -/** - * Returns the global options for zFCP - */ -const fetchZFCPConfig = (): Promise => get("/api/storage/zfcp/global_config"); - -/** - * Returns if zFCP is supported at all - */ -const supportedZFCP = (): Promise => get("/api/storage/zfcp/supported"); - -/** - * probes zFCP devices - */ -const probeZFCP = () => post("/api/storage/zfcp/probe"); - -/** - * Activates given controller - */ -const activateZFCPController = (controllerId: string) => - post(`/api/storage/zfcp/controllers/${controllerId}/activate`); - -/** - * Returns list of WWPNs for given controller - */ -const fetchWWPNs = (controllerId: string): Promise => - get(`/api/storage/zfcp/controllers/${controllerId}/wwpns`); - -/** - * Returns list of LUNs for give controller and WWPN - */ -const fetchLUNs = (controllerId: string, wwpn: string): Promise => - get(`/api/storage/zfcp/controllers/${controllerId}/wwpns/${wwpn}/luns`); - -/** - * Actives disk on given controller with WWPN and LUN - */ -const activateZFCPDisk = (controllerId: string, wwpn: string, lun: string) => - post(`/api/storage/zfcp/controllers/${controllerId}/wwpns/${wwpn}/luns/${lun}/activate_disk`); - -/** - * Deactives disk on given controller with WWPN and LUN - */ -const deactivateZFCPDisk = (controllerId: string, wwpn: string, lun: string) => - post(`/api/storage/zfcp/controllers/${controllerId}/wwpns/${wwpn}/luns/${lun}/deactivate_disk`); - -export { - fetchZFCPControllers, - fetchZFCPDisks, - fetchZFCPConfig, - probeZFCP, - supportedZFCP, - activateZFCPController, - fetchWWPNs, - fetchLUNs, - activateZFCPDisk, - deactivateZFCPDisk, -}; diff --git a/web/src/queries/storage/zfcp.ts b/web/src/queries/storage/zfcp.ts deleted file mode 100644 index 9dd0d824b0..0000000000 --- a/web/src/queries/storage/zfcp.ts +++ /dev/null @@ -1,168 +0,0 @@ -/* - * 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 the GNU General Public License as published by the Free - * Software Foundation; either version 2 of the License, or (at your option) - * any later version. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for - * more details. - * - * You should have received a copy of the GNU General Public License along - * with this program; if not, contact SUSE LLC. - * - * To contact SUSE LLC about this file by physical or electronic mail, you may - * find current contact information at www.suse.com. - */ - -import { useQueryClient, useSuspenseQuery } from "@tanstack/react-query"; -import { - supportedZFCP, - fetchZFCPConfig, - fetchZFCPControllers, - fetchZFCPDisks, -} from "~/model/storage/zfcp"; -import { useInstallerClient } from "~/context/installer"; -import React from "react"; -import { ZFCPConfig, ZFCPController, ZFCPDisk } from "~/types/zfcp"; - -const zfcpControllersQuery = { - queryKey: ["zfcp", "controllers"], - queryFn: fetchZFCPControllers, - staleTime: Infinity, -}; - -const zfcpDisksQuery = { - queryKey: ["zfcp", "disks"], - queryFn: fetchZFCPDisks, - staleTime: Infinity, -}; - -const zfcpSupportedQuery = { - queryKey: ["zfcp", "supported"], - queryFn: supportedZFCP, -}; - -const zfcpConfigQuery = { - queryKey: ["zfcp", "config"], - queryFn: fetchZFCPConfig, -}; - -/** - * Hook that returns zFCP controllers. - */ -const useZFCPControllers = (): ZFCPController[] => { - const { data: controllers } = useSuspenseQuery(zfcpControllersQuery); - return controllers; -}; - -/** - * Hook that returns zFCP disks. - */ -const useZFCPDisks = (): ZFCPDisk[] => { - const { data: devices } = useSuspenseQuery(zfcpDisksQuery); - return devices; -}; - -/** - * Hook that returns whether zFCP is supported. - */ -const useZFCPSupported = (): boolean => { - const { data: supported } = useSuspenseQuery(zfcpSupportedQuery); - return supported; -}; - -/** - * Hook that returns zFCP config. - */ -const useZFCPConfig = (): ZFCPConfig => { - const { data: config } = useSuspenseQuery(zfcpConfigQuery); - return config; -}; - -/** - * Listens for zFCP Controller changes. - */ -const useZFCPControllersChanges = () => { - const client = useInstallerClient(); - const queryClient = useQueryClient(); - - React.useEffect(() => { - if (!client) return; - - return client.onEvent(({ type, device }) => { - if ( - !["ZFCPControllerAdded", "ZFCPControllerChanged", "ZFCPControllerRemoved"].includes(type) - ) { - return; - } - queryClient.setQueryData( - zfcpControllersQuery.queryKey, - (prev: ZFCPController[] | undefined) => { - if (prev === undefined) return; - - switch (type) { - case "ZFCPControllerAdded": { - return [...prev, device]; - } - case "ZFCPControllerRemoved": { - return prev.filter((dev) => dev.id !== device.id); - } - case "ZFCPControllerChanged": { - return prev.map((d) => (d.id === device.id ? device : d)); - } - } - }, - ); - - queryClient.invalidateQueries({ queryKey: ["zfcp", "controllers"] }); - }); - }, [client, queryClient]); -}; - -/** - * Listens for zFCP disks changes. - */ -const useZFCPDisksChanges = () => { - const client = useInstallerClient(); - const queryClient = useQueryClient(); - - React.useEffect(() => { - if (!client) return; - - return client.onEvent(({ type, device }) => { - if (!["ZFCPDiskAdded", "ZFCPDiskChanged", "ZFCPDiskRemoved"].includes(type)) { - return; - } - queryClient.setQueryData(zfcpDisksQuery.queryKey, (prev: ZFCPDisk[] | undefined) => { - if (prev === undefined) return; - - switch (type) { - case "ZFCPDiskAdded": { - return [...prev, device]; - } - case "ZFCPDiskRemoved": { - return prev.filter((dev) => dev.name !== device.name); - } - case "ZFCPDiskChanged": { - return prev.map((d) => (d.name === device.name ? device : d)); - } - } - }); - }); - }, [client, queryClient]); -}; - -export { - useZFCPControllers, - useZFCPControllersChanges, - useZFCPDisks, - useZFCPDisksChanges, - useZFCPConfig, - useZFCPSupported, -}; diff --git a/web/src/types/zfcp.ts b/web/src/types/zfcp.ts deleted file mode 100644 index f8cd0131f1..0000000000 --- a/web/src/types/zfcp.ts +++ /dev/null @@ -1,48 +0,0 @@ -/* - * 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 the GNU General Public License as published by the Free - * Software Foundation; either version 2 of the License, or (at your option) - * any later version. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for - * more details. - * - * You should have received a copy of the GNU General Public License along - * with this program; if not, contact SUSE LLC. - * - * To contact SUSE LLC about this file by physical or electronic mail, you may - * find current contact information at www.suse.com. - */ - -type ZFCPController = { - id: string; - channel: string; - active: boolean; - lunScan: boolean; - lunsMap: { [key: string]: string[] }; -}; - -type ZFCPDisk = { - name: string; - channel: string; - wwpn: string; - lun: string; -}; - -type ZFCPConfig = { - allowLunScan: boolean; -}; - -type LUNInfo = { - channel: string; - wwpn: string; - lun: string; -}; - -export type { ZFCPController, ZFCPDisk, ZFCPConfig, LUNInfo }; diff --git a/web/src/utils/zfcp.test.ts b/web/src/utils/zfcp.test.ts deleted file mode 100644 index b9309b08c0..0000000000 --- a/web/src/utils/zfcp.test.ts +++ /dev/null @@ -1,87 +0,0 @@ -/* - * 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 the GNU General Public License as published by the Free - * Software Foundation; either version 2 of the License, or (at your option) - * any later version. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for - * more details. - * - * You should have received a copy of the GNU General Public License along - * with this program; if not, contact SUSE LLC. - * - * To contact SUSE LLC about this file by physical or electronic mail, you may - * find current contact information at www.suse.com. - */ - -import { inactiveLuns } from "./zfcp"; - -import { ZFCPController, ZFCPDisk } from "~/types/zfcp"; - -const mockZFCPDisk: ZFCPDisk[] = [ - { - name: "/dev/sda", - channel: "0.0.fa00", - wwpn: "0x500507630b181216", - lun: "0x4020404900000000", - }, - { - name: "/dev/sdb", - channel: "0.0.fc00", - wwpn: "0x500507630b101216", - lun: "0x0001000000000000", - }, -]; - -const mockZFCPControllers: ZFCPController[] = [ - { - id: "1", - channel: "0.0.fa00", - lunScan: false, - active: true, - lunsMap: { - "0x500507630b181216": ["0x4020404900000000"], - "0x500507680d7e284a": [], - "0x500507680d0e284a": [], - }, - }, - { - id: "2", - channel: "0.0.fc00", - lunScan: false, - active: true, - lunsMap: { - "0x500507680d7e284b": [], - "0x500507680d0e284b": [], - "0x500507630b101216": ["0x0000000000000000", "0x0001000000000000"], - }, - }, -]; - -describe("#inactiveLuns", () => { - it("returns a list with the luns which does not have an active disk", () => { - expect(inactiveLuns(mockZFCPControllers, mockZFCPDisk)).toEqual([ - { - channel: "0.0.fc00", - wwpn: "0x500507630b101216", - lun: "0x0000000000000000", - }, - ]); - }); - - it("return an empty list with all the luns are active", () => { - mockZFCPDisk.push({ - name: "/dev/sdb", - channel: "0.0.fc00", - wwpn: "0x500507630b101216", - lun: "0x0000000000000000", - }); - expect(inactiveLuns(mockZFCPControllers, mockZFCPDisk)).toEqual([]); - }); -}); diff --git a/web/src/utils/zfcp.ts b/web/src/utils/zfcp.ts deleted file mode 100644 index 4e2083df6e..0000000000 --- a/web/src/utils/zfcp.ts +++ /dev/null @@ -1,46 +0,0 @@ -/* - * 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 the GNU General Public License as published by the Free - * Software Foundation; either version 2 of the License, or (at your option) - * any later version. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for - * more details. - * - * You should have received a copy of the GNU General Public License along - * with this program; if not, contact SUSE LLC. - * - * To contact SUSE LLC about this file by physical or electronic mail, you may - * find current contact information at www.suse.com. - */ - -import { LUNInfo, ZFCPController, ZFCPDisk } from "~/types/zfcp"; - -const inactiveLuns = (controllers: ZFCPController[], disks: ZFCPDisk[]): LUNInfo[] => { - const result: LUNInfo[] = []; - for (const controller of controllers) { - for (const [wwpn, luns] of Object.entries(controller.lunsMap)) { - for (const lun of luns) { - if ( - !disks.some((d) => d.lun === lun && d.wwpn === wwpn && d.channel === controller.channel) - ) { - result.push({ - channel: controller.channel, - wwpn, - lun, - }); - } - } - } - } - - return result; -}; - -export { inactiveLuns }; From 8ea64fe6d5abe192f8823230957b9f76b5407103 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Thu, 12 Mar 2026 12:26:24 +0000 Subject: [PATCH 15/23] Simplify test expectations --- .../storage/zfcp/ZFCPControllersPage.test.tsx | 12 ++++++------ .../components/storage/zfcp/ZFCPPage.test.tsx | 18 +++++++++--------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/web/src/components/storage/zfcp/ZFCPControllersPage.test.tsx b/web/src/components/storage/zfcp/ZFCPControllersPage.test.tsx index c333c69bc2..9fccd75c14 100644 --- a/web/src/components/storage/zfcp/ZFCPControllersPage.test.tsx +++ b/web/src/components/storage/zfcp/ZFCPControllersPage.test.tsx @@ -79,7 +79,7 @@ describe("ZFCPControllersPage", () => { it("shows an empty state if no controllers are available for activation", () => { mockUseControllers.mockReturnValue([controller1]); installerRender(); - expect(screen.getByText("No controllers available")).toBeInTheDocument(); + screen.getByText("No controllers available"); }); describe("when there are controllers to activate", () => { @@ -89,21 +89,21 @@ describe("ZFCPControllersPage", () => { it("renders the list of deactivated controllers", () => { installerRender(); - expect(screen.getByLabelText("0.0.1a11")).toBeInTheDocument(); - expect(screen.getByLabelText("0.0.1a12")).toBeInTheDocument(); + screen.getByLabelText("0.0.1a11"); + screen.getByLabelText("0.0.1a12"); expect(screen.queryByLabelText("0.0.1a10")).not.toBeInTheDocument(); }); it("shows LUN scan enabled info", () => { mockUseSystem.mockReturnValue({ lunScan: true, controllers: [], devices: [] }); installerRender(); - expect(screen.getByText("Automatic LUN scan is enabled")).toBeInTheDocument(); + screen.getByText("Automatic LUN scan is enabled"); }); it("shows LUN scan disabled info", () => { mockUseSystem.mockReturnValue({ lunScan: false, controllers: [], devices: [] }); installerRender(); - expect(screen.getByText("Automatic LUN scan is disabled")).toBeInTheDocument(); + screen.getByText("Automatic LUN scan is disabled"); }); it("allows selecting and deselecting controllers", async () => { @@ -161,7 +161,7 @@ describe("ZFCPControllersPage", () => { await user.click(acceptButton); expect(mockUseSetControllers).not.toHaveBeenCalled(); - expect(screen.getByText("Select the controllers to activate")).toBeInTheDocument(); + screen.getByText("Select the controllers to activate"); }); }); }); diff --git a/web/src/components/storage/zfcp/ZFCPPage.test.tsx b/web/src/components/storage/zfcp/ZFCPPage.test.tsx index 6cc5d6c867..22a55c528b 100644 --- a/web/src/components/storage/zfcp/ZFCPPage.test.tsx +++ b/web/src/components/storage/zfcp/ZFCPPage.test.tsx @@ -89,7 +89,7 @@ describe("ZFCPPage", () => { it("renders the issues", () => { installerRender(); - expect(screen.queryByText(/zFCP error/)).toBeInTheDocument(); + screen.getByText(/zFCP error/); }); }); @@ -100,7 +100,7 @@ describe("ZFCPPage", () => { it("renders a text explaining zFCP is not available", () => { installerRender(); - expect(screen.queryByText(/zFCP is not available/)).toBeInTheDocument(); + screen.getByText(/zFCP is not available/); expect(screen.queryByText("devices table")).not.toBeInTheDocument(); }); }); @@ -113,12 +113,12 @@ describe("ZFCPPage", () => { it("renders the controllers section", () => { installerRender(); - expect(screen.queryByText("zFCP controllers")).toBeInTheDocument(); + screen.getByText("zFCP controllers"); }); it("renders a text explaining devices are not available", () => { installerRender(); - expect(screen.queryByText(/No devices available/)).toBeInTheDocument(); + screen.getByText(/No devices available/); expect(screen.queryByText("devices table")).not.toBeInTheDocument(); }); }); @@ -131,12 +131,12 @@ describe("ZFCPPage", () => { it("renders the controllers section", () => { installerRender(); - expect(screen.queryByText("zFCP controllers")).toBeInTheDocument(); + screen.getByText("zFCP controllers"); }); it("renders the table of devices", () => { installerRender(); - expect(screen.queryByText("devices table")).toBeInTheDocument(); + screen.getByText("devices table"); }); describe("if there are deactivated controllers", () => { @@ -146,8 +146,8 @@ describe("ZFCPPage", () => { it("renders an option for activating controllers", () => { installerRender(); - expect(screen.queryByText(/There is a deactivated zFCP controller/)).toBeInTheDocument(); - expect(screen.queryByRole("link", { name: "Activate controllers" })).toBeInTheDocument(); + screen.getByText(/There is a deactivated zFCP controller/); + screen.getByRole("link", { name: "Activate controllers" }); }); }); @@ -158,7 +158,7 @@ describe("ZFCPPage", () => { it("does not render an option for activating controllers", () => { installerRender(); - expect(screen.queryByText(/zFCP controllers are already activated/)).toBeInTheDocument(); + screen.getByText(/zFCP controllers are already activated/); expect( screen.queryByRole("link", { name: "Activate controllers" }), ).not.toBeInTheDocument(); From 2b317b4bbd7007a4d2ddbd4e2c49e8a8a8121fb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Thu, 12 Mar 2026 12:28:59 +0000 Subject: [PATCH 16/23] Fix component documentation --- web/src/components/storage/zfcp/ZFCPDevicesTable.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/components/storage/zfcp/ZFCPDevicesTable.tsx b/web/src/components/storage/zfcp/ZFCPDevicesTable.tsx index ec360bcd1e..d6c85d73cb 100644 --- a/web/src/components/storage/zfcp/ZFCPDevicesTable.tsx +++ b/web/src/components/storage/zfcp/ZFCPDevicesTable.tsx @@ -359,9 +359,9 @@ type ZFCPDevicesTableProps = { }; /** - * Displays a filterable, sortable, selectable table of zFCP devices. + * Renders a table for configuring zFCP devices. * - * Manages its own UI state (filters, sorting, selection, pending format requests) via a reducer. + * Manages its own UI state (filters, sorting) via a reducer. */ export default function ZFCPDevicesTable({ devices }: ZFCPDevicesTableProps): React.ReactNode { const [state, dispatch] = useReducer(reducer, initialState); From d62ee634af1532d5144e37e425fe98207b7b8026 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Thu, 12 Mar 2026 12:56:47 +0000 Subject: [PATCH 17/23] Fix hook --- web/src/hooks/model/system/zfcp.test.ts | 8 ++++++++ web/src/hooks/model/system/zfcp.ts | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/web/src/hooks/model/system/zfcp.test.ts b/web/src/hooks/model/system/zfcp.test.ts index 0f2f8b37fb..2bb78eb126 100644 --- a/web/src/hooks/model/system/zfcp.test.ts +++ b/web/src/hooks/model/system/zfcp.test.ts @@ -202,6 +202,14 @@ describe("~/hooks/model/system/zfcp", () => { expect(result.current("0.0.8000")).toEqual(false); }); + it("returns false if there is no system", () => { + mockSystemQuery(null); + + const { result } = renderHook(() => useCheckLunScan()); + + expect(result.current("0.0.8000")).toEqual(false); + }); + it("returns true if LUN scan is active and supported by the controller", () => { mockSystemQuery({ zfcp: { diff --git a/web/src/hooks/model/system/zfcp.ts b/web/src/hooks/model/system/zfcp.ts index 12e02e8076..4897ad453d 100644 --- a/web/src/hooks/model/system/zfcp.ts +++ b/web/src/hooks/model/system/zfcp.ts @@ -65,7 +65,7 @@ type CheckLunScanFn = (channel: string) => boolean; function useCheckLunScan(): CheckLunScanFn { const system = useSystem(); return (channel: string): boolean => - [system.lunScan, system?.controllers?.find((c) => c.channel === channel)?.lunScan].every( + [system?.lunScan, system?.controllers?.find((c) => c.channel === channel)?.lunScan].every( (c) => c === true, ); } From 6442d7bbf6dcb1b34e7ade072e053eada66b71b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Thu, 12 Mar 2026 12:57:00 +0000 Subject: [PATCH 18/23] Remove leftover --- web/src/model/config/zfcp.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/model/config/zfcp.test.ts b/web/src/model/config/zfcp.test.ts index 5c497dffdd..1587641046 100644 --- a/web/src/model/config/zfcp.test.ts +++ b/web/src/model/config/zfcp.test.ts @@ -156,7 +156,7 @@ describe("model/config/zfcp", () => { }); }); - describe.only("#removeDevices", () => { + describe("#removeDevices", () => { it("preserves existing config properties while removing devices", () => { const initialConfig = { ...mockInitialConfig, From accb59b1bab396ef71f5f63b13a3d4d87c8baf4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Thu, 12 Mar 2026 13:56:17 +0000 Subject: [PATCH 19/23] Use reduce instead of recursive calls --- web/src/model/config/zfcp.ts | 45 ++++++++++++++++++++---------------- 1 file changed, 25 insertions(+), 20 deletions(-) diff --git a/web/src/model/config/zfcp.ts b/web/src/model/config/zfcp.ts index fe8986cf20..7781f138fe 100644 --- a/web/src/model/config/zfcp.ts +++ b/web/src/model/config/zfcp.ts @@ -23,23 +23,26 @@ import { Config, Device } from "~/openapi/config/zfcp"; import { replaceOrAppend, remove, isEmpty } from "radashi"; -function defaultConfig(): Config { - return {}; +const DEFAULT_CONFIG: Config = {}; + +/** Generates a copy of the given config or a default config. */ +function ensureConfig(config: Config | null): Config { + return config ? { ...config } : { ...DEFAULT_CONFIG }; } /** Returns a new config setting the given controllers. */ function setControllers(config: Config | null, controllers: string[]): Config { - const currentConfig = config || defaultConfig(); - return { ...currentConfig, controllers }; + const baseConfig = ensureConfig(config); + return { ...baseConfig, controllers }; } function addDevice(config: Config | null, device: Device): Config { - const currentConfig = config || defaultConfig(); + const baseConfig = ensureConfig(config); return { - ...currentConfig, + ...baseConfig, devices: replaceOrAppend( - currentConfig.devices, + baseConfig.devices, device, (d) => d.channel === device.channel && d.wwpn === device.wwpn && d.lun === device.lun, ), @@ -54,22 +57,23 @@ function addDevice(config: Config | null, device: Device): Config { * list replaces the device from the config. */ function addDevices(config: Config | null, devices: Device[]): Config { - const currentConfig = config || defaultConfig(); - - if (isEmpty(devices)) return { ...currentConfig }; + const baseConfig = ensureConfig(config); - const [device, ...rest] = devices; + if (isEmpty(devices)) return baseConfig; - return addDevices(addDevice(currentConfig, device), rest); + return devices.reduce( + (newConfig: Config, device: Device): Config => addDevice(newConfig, device), + baseConfig, + ); } function removeDevice(config: Config | null, device: Device): Config { - const currentConfig = config || defaultConfig(); + const baseConfig = ensureConfig(config); return { - ...currentConfig, + ...baseConfig, devices: remove( - currentConfig.devices || [], + baseConfig.devices || [], (d) => d.channel === device.channel && d.wwpn === device.wwpn && d.lun === device.lun, ), }; @@ -79,13 +83,14 @@ function removeDevice(config: Config | null, device: Device): Config { * Returns a new config removing the given devices. */ function removeDevices(config: Config | null, devices: Device[]): Config { - const currentConfig = config || defaultConfig(); - - if (isEmpty(devices) || isEmpty(currentConfig.devices)) return { ...currentConfig }; + const baseConfig = ensureConfig(config); - const [device, ...rest] = devices; + if (isEmpty(devices) || isEmpty(baseConfig.devices)) return baseConfig; - return removeDevices(removeDevice(currentConfig, device), rest); + return devices.reduce( + (newConfig: Config, device: Device): Config => removeDevice(newConfig, device), + baseConfig, + ); } export type * from "~/openapi/config/zfcp"; From 78466a04f92f13b7b7e2caf96d6ea4d8dac2739d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Fri, 13 Mar 2026 08:41:58 +0000 Subject: [PATCH 20/23] feat(web): override CSS variable for content link colors --- web/src/assets/styles/index.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/web/src/assets/styles/index.scss b/web/src/assets/styles/index.scss index 1fe088ba7e..cfa7437705 100644 --- a/web/src/assets/styles/index.scss +++ b/web/src/assets/styles/index.scss @@ -136,6 +136,7 @@ --pf-t--global--background--color--disabled--default: #dcdbdc; --pf-t--global--border--radius--pill: var(--pf-t--global--border--radius--small); --pf-t--global--color--brand--clicked: var(--agm-t--color--pine); + --pf-t--global--text--color--link--default: var(--agm-t--color--pine); } /* From 47d9f048f55d2ddf19f8dd459ae0362dab50750c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Fri, 13 Mar 2026 08:42:57 +0000 Subject: [PATCH 21/23] feat(web): improve zFCP link markup in storage summary To make it more "heading-alike" --- web/src/components/overview/StorageSummary.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/web/src/components/overview/StorageSummary.tsx b/web/src/components/overview/StorageSummary.tsx index 2f225ee55a..ec3abf430f 100644 --- a/web/src/components/overview/StorageSummary.tsx +++ b/web/src/components/overview/StorageSummary.tsx @@ -21,9 +21,12 @@ */ import React from "react"; +import { isEmpty } from "radashi"; import { sprintf } from "sprintf-js"; +import { Content } from "@patternfly/react-core"; import Summary from "~/components/core/Summary"; import Link from "~/components/core/Link"; +import Text from "~/components/core/Text"; import { useProgressTracking } from "~/hooks/use-progress-tracking"; import { useConfigModel } from "~/hooks/model/storage/config-model"; import { @@ -43,7 +46,6 @@ import { _, formatList } from "~/i18n"; import type { Storage } from "~/model/system"; import type { ConfigModel } from "~/model/storage/config-model"; -import { isEmpty } from "radashi"; const findDriveDevice = (drive: ConfigModel.Drive, devices: Storage.Device[]) => devices.find((d) => d.name === drive.name); @@ -88,13 +90,13 @@ const InvalidZFCP = (): React.ReactNode => { const text = _("Invalid [zFCP] settings"); const [textStart, textLink, textEnd] = text.split(/[[\]]/); return ( -

+ {textStart} - {textLink} + {textLink} {textEnd} -

+ ); }; From 5ba594a03db9c5167e9efa1c4be49906e2988711 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Fri, 13 Mar 2026 09:00:55 +0000 Subject: [PATCH 22/23] reafactor(web): change controllers form markup --- .../storage/zfcp/ZFCPControllersPage.tsx | 24 +++++++------------ 1 file changed, 8 insertions(+), 16 deletions(-) diff --git a/web/src/components/storage/zfcp/ZFCPControllersPage.tsx b/web/src/components/storage/zfcp/ZFCPControllersPage.tsx index 814144126d..93c6f857b5 100644 --- a/web/src/components/storage/zfcp/ZFCPControllersPage.tsx +++ b/web/src/components/storage/zfcp/ZFCPControllersPage.tsx @@ -92,18 +92,7 @@ type ControllerOptionLabelProps = { const ControllerOptionLabel = ({ controller }: ControllerOptionLabelProps): React.ReactNode => { const checkLunScan = useCheckLunScan(); - return ( - - - {controller.channel} - - {checkLunScan(controller.channel) && ( - - {_("Performs auto LUN scan")} - - )} - - ); + return checkLunScan(controller.channel) ? _("Performs auto LUN scan") : null; }; /** @@ -156,13 +145,16 @@ const ZFCPControllersForm = (): React.ReactNode => {
{error && } {deactivatedControllers.map((controller, index) => { + const channel = controller.channel; + return ( } - isChecked={selectedControllers.includes(controller.channel)} - onChange={() => toggleController(controller.channel)} + id={`controller-${channel}`} + label={{channel}} + description={} + isChecked={selectedControllers.includes(channel)} + onChange={() => toggleController(channel)} /> ); From 5012b26eb47cc4d21b52f7437f4c7e0d365b3b04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Fri, 13 Mar 2026 09:15:01 +0000 Subject: [PATCH 23/23] Fix eslint --- web/src/components/storage/zfcp/ZFCPControllersPage.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/web/src/components/storage/zfcp/ZFCPControllersPage.tsx b/web/src/components/storage/zfcp/ZFCPControllersPage.tsx index 93c6f857b5..06381fb617 100644 --- a/web/src/components/storage/zfcp/ZFCPControllersPage.tsx +++ b/web/src/components/storage/zfcp/ZFCPControllersPage.tsx @@ -31,8 +31,6 @@ import { EmptyState, EmptyStateBody, Checkbox, - Split, - SplitItem, } from "@patternfly/react-core"; import { Page } from "~/components/core"; import { STORAGE } from "~/routes/paths";