From f38e15bd0248ee8549bca9edcafdf6345bc31ae3 Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Thu, 5 Sep 2024 11:38:48 +0200 Subject: [PATCH] feat(web): adapt to HTTP API of DASD operations (#1549) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bring back the DASD management support adapting the web UI using the new HTTP/JSON API introduced by https://github.com/openSUSE/agama/pull/1532 --------- Co-authored-by: Knut Anderssen Co-authored-by: Imobach González Sosa --- rust/agama-server/src/web/common/jobs.rs | 1 + .../agama/dbus/storage/dasds_format_job.rb | 2 +- .../test/agama/dbus/storage/jobs_tree_test.rb | 2 +- web/package/agama-web-ui.changes | 5 + web/src/api/dasd.ts | 89 ++++++ web/src/api/network.ts | 2 +- web/src/api/storage.ts | 36 +++ web/src/client/storage.js | 262 +----------------- web/src/client/storage.test.js | 197 +------------ web/src/components/layout/Icon.jsx | 2 + .../components/storage/DASDFormatProgress.jsx | 71 ----- .../storage/DASDFormatProgress.test.tsx | 100 +++++++ .../components/storage/DASDFormatProgress.tsx | 61 ++++ web/src/components/storage/DASDPage.jsx | 190 ------------- web/src/components/storage/DASDPage.tsx | 45 +++ web/src/components/storage/DASDTable.test.tsx | 60 ++++ .../storage/{DASDTable.jsx => DASDTable.tsx} | 216 ++++++++------- .../components/storage/DevicesTechMenu.jsx | 7 +- .../storage/DevicesTechMenu.test.jsx | 16 +- web/src/queries/dasd.ts | 247 +++++++++++++++++ web/src/routes/storage.tsx | 14 +- web/src/types/dasd.ts | 46 +++ web/src/types/job.ts | 28 ++ web/src/utils.js | 4 + 24 files changed, 869 insertions(+), 834 deletions(-) create mode 100644 web/src/api/dasd.ts create mode 100644 web/src/api/storage.ts delete mode 100644 web/src/components/storage/DASDFormatProgress.jsx create mode 100644 web/src/components/storage/DASDFormatProgress.test.tsx create mode 100644 web/src/components/storage/DASDFormatProgress.tsx delete mode 100644 web/src/components/storage/DASDPage.jsx create mode 100644 web/src/components/storage/DASDPage.tsx create mode 100644 web/src/components/storage/DASDTable.test.tsx rename web/src/components/storage/{DASDTable.jsx => DASDTable.tsx} (54%) create mode 100644 web/src/queries/dasd.ts create mode 100644 web/src/types/dasd.ts create mode 100644 web/src/types/job.ts diff --git a/rust/agama-server/src/web/common/jobs.rs b/rust/agama-server/src/web/common/jobs.rs index 1e7484ebf2..8dbbd9df20 100644 --- a/rust/agama-server/src/web/common/jobs.rs +++ b/rust/agama-server/src/web/common/jobs.rs @@ -121,6 +121,7 @@ impl JobsStream { values: &HashMap, ) -> Result<&'a Job, ServiceError> { let job = cache.find_or_create(path); + job.id = path.to_string(); property_from_dbus!(job, running, "Running", values, bool); property_from_dbus!(job, exit_code, "ExitCode", values, u32); Ok(job) diff --git a/service/lib/agama/dbus/storage/dasds_format_job.rb b/service/lib/agama/dbus/storage/dasds_format_job.rb index f08c239a27..a2c89737ec 100644 --- a/service/lib/agama/dbus/storage/dasds_format_job.rb +++ b/service/lib/agama/dbus/storage/dasds_format_job.rb @@ -119,7 +119,7 @@ def initialize(initial, dasds_tree, path, logger: nil) # Current status, in the format described by the D-Bus API def summary result = {} - @infos.each_value { |i| result[i.path] = i.to_dbus if i.path } + @infos.each_value { |i| result[i.id] = i.to_dbus if i.id } result end diff --git a/service/test/agama/dbus/storage/jobs_tree_test.rb b/service/test/agama/dbus/storage/jobs_tree_test.rb index 801455e99d..ab73f9aee2 100644 --- a/service/test/agama/dbus/storage/jobs_tree_test.rb +++ b/service/test/agama/dbus/storage/jobs_tree_test.rb @@ -62,7 +62,7 @@ expect(job.path).to match(/#{described_class::ROOT_PATH}\/[0-9]+/) expect(job.summary).to eq( - { "/path/dasd1" => [1000, 0, false], "/path/dasd2" => [2000, 0, false] } + { "0.0.001" => [1000, 0, false], "0.0.002" => [2000, 0, false] } ) end diff --git a/web/package/agama-web-ui.changes b/web/package/agama-web-ui.changes index 3f0b2a424d..bfd54e86d1 100644 --- a/web/package/agama-web-ui.changes +++ b/web/package/agama-web-ui.changes @@ -1,3 +1,8 @@ +------------------------------------------------------------------- +Wed Sep 4 21:00:34 UTC 2024 - Knut Anderssen + +- Bring back DASD management support (gh#openSUSE/agama#1549). + ------------------------------------------------------------------- Tue Aug 13 14:57:21 UTC 2024 - David Diaz diff --git a/web/src/api/dasd.ts b/web/src/api/dasd.ts new file mode 100644 index 0000000000..4178b3fb18 --- /dev/null +++ b/web/src/api/dasd.ts @@ -0,0 +1,89 @@ +/* + * Copyright (c) [2024] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import { post, get, put } from "~/api/http"; +import { DASDDevice } from "~/types/dasd"; + +/** + * Returns the list of DASD devices + */ +const fetchDASDDevices = (): Promise => get("/api/storage/dasd/devices"); + +/** + * Returns if DASD is supported at all + */ +const DASDSupported = (): Promise => get("/api/storage/dasd/supported"); + +/** + * probes DASD devices + */ +const probeDASD = () => post("/api/storage/dasd/probe"); + +/** + * Start format job for given list of DASD devices + * @param devicesIDs - array of DASD device ids + * @return id of format job + */ +const formatDASD = (devicesIDs: string[]): Promise => + post("/api/storage/dasd/format", { devices: devicesIDs }).then(({ data }) => data); + +/** + * Enable given list of DASD devices + * + * @param devicesIDs - array of DASD device ids + */ +const enableDASD = (devicesIDs: string[]) => + post("/api/storage/dasd/enable", { devices: devicesIDs }); + +/** + * Disable given list of DASD devices + * + * @param devicesIDs - array of DASD device ids + */ +const disableDASD = (devicesIDs: string[]) => + post("/api/storage/dasd/disable", { devices: devicesIDs }); + +/** + * Enables diag on given list of DASD devices + * + * @param devicesIDs - array of DASD device ids + */ +const enableDiag = (devicesIDs: string[]) => + put("/api/storage/dasd/diag", { devices: devicesIDs, diag: true }); + +/** + * Disables diag on given list of DASD devices + * + * @param devicesIDs - array of DASD device ids + */ +const disableDiag = (devicesIDs: string[]) => + put("/api/storage/dasd/diag", { devices: devicesIDs, diag: false }); + +export { + fetchDASDDevices, + DASDSupported, + formatDASD, + probeDASD, + enableDASD, + disableDASD, + enableDiag, + disableDiag, +}; diff --git a/web/src/api/network.ts b/web/src/api/network.ts index 749fec8e6d..d623c5fbcd 100644 --- a/web/src/api/network.ts +++ b/web/src/api/network.ts @@ -58,7 +58,7 @@ const addConnection = (connection: APIConnection) => post("/api/network/connecti /** * Updates given connection * - * @param connection - connection to be added + * @param connection - connection to be updated */ const updateConnection = (connection: APIConnection) => put(`/api/network/connections/${connection.id}`, connection); diff --git a/web/src/api/storage.ts b/web/src/api/storage.ts new file mode 100644 index 0000000000..47c6cad15b --- /dev/null +++ b/web/src/api/storage.ts @@ -0,0 +1,36 @@ +/* + * Copyright (c) [2024] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import { get } from "~/api/http"; +import { Job } from "~/types/job"; + +/** + * Returns the list of jobs + */ +const fetchStorageJobs = (): Promise => get("/api/storage/jobs"); + +/** + * Returns the job with given id or undefined + */ +const findStorageJob = (id: string): Promise => + fetchStorageJobs().then((jobs: Job[]) => jobs.find((value) => value.id === id)); + +export { fetchStorageJobs, findStorageJob }; diff --git a/web/src/client/storage.js b/web/src/client/storage.js index 3a14b4e318..ed594c118a 100644 --- a/web/src/client/storage.js +++ b/web/src/client/storage.js @@ -31,10 +31,6 @@ const STORAGE_OBJECT = "/org/opensuse/Agama/Storage1"; const STORAGE_JOBS_NAMESPACE = "/org/opensuse/Agama/Storage1/jobs"; const STORAGE_JOB_IFACE = "org.opensuse.Agama.Storage1.Job"; const ISCSI_NODES_NAMESPACE = "/storage/iscsi/nodes"; -const DASD_MANAGER_IFACE = "org.opensuse.Agama.Storage1.DASD.Manager"; -const DASD_DEVICES_NAMESPACE = "/org/opensuse/Agama/Storage1/dasds"; -const DASD_DEVICE_IFACE = "org.opensuse.Agama.Storage1.DASD.Device"; -const DASD_STATUS_IFACE = "org.opensuse.Agama.Storage1.DASD.Format"; const ZFCP_MANAGER_IFACE = "org.opensuse.Agama.Storage1.ZFCP.Manager"; const ZFCP_CONTROLLERS_NAMESPACE = "/org/opensuse/Agama/Storage1/zfcp_controllers"; const ZFCP_CONTROLLER_IFACE = "org.opensuse.Agama.Storage1.ZFCP.Controller"; @@ -723,260 +719,6 @@ class ProposalManager { } } -/** - * Class providing an API for managing Direct Access Storage Devices (DASDs) - */ -class DASDManager { - /** - * @param {string} service - D-Bus service name - * @param {string} address - D-Bus address - */ - constructor(service, address) { - this.service = service; - this.address = address; - this.proxies = {}; - } - - /** - * @return {DBusClient} client - */ - client() { - // return this.assigned_client; - if (!this._client) { - this._client = new DBusClient(this.service, this.address); - } - - return this._client; - } - - // FIXME: use info from ObjectManager instead. - // https://github.com/openSUSE/Agama/pull/501#discussion_r1147707515 - async isSupported() { - const proxy = await this.managerProxy(); - - return proxy !== undefined; - } - - /** - * Build a job - * - * @returns {StorageJob} - * - * @typedef {object} StorageJob - * @property {string} path - * @property {boolean} running - * @property {number} exitCode - */ - buildJob(job) { - return { - path: job.path, - running: job.Running, - exitCode: job.ExitCode, - }; - } - - /** - * Triggers a DASD probing - */ - async probe() { - const proxy = await this.managerProxy(); - await proxy?.Probe(); - } - - /** - * Gets the list of DASD devices - * - * @returns {Promise} - */ - async getDevices() { - // FIXME: should we do the probing here? - await this.probe(); - const devices = await this.devicesProxy(); - return Object.values(devices).map(this.buildDevice); - } - - /** - * Requests the format action for given devices - * - * @param {DASDDevice[]} devices - */ - async format(devices) { - const proxy = await this.managerProxy(); - const devicesPath = devices.map((d) => this.devicePath(d)); - proxy.Format(devicesPath); - } - - /** - * Set DIAG for given devices - * - * @param {DASDDevice[]} devices - * @param {boolean} value - */ - async setDIAG(devices, value) { - const proxy = await this.managerProxy(); - const devicesPath = devices.map((d) => this.devicePath(d)); - proxy.SetDiag(devicesPath, value); - } - - /** - * Enables given DASD devices - * - * @param {DASDDevice[]} devices - */ - async enableDevices(devices) { - const proxy = await this.managerProxy(); - const devicesPath = devices.map((d) => this.devicePath(d)); - proxy.Enable(devicesPath); - } - - /** - * Disables given DASD devices - * - * @param {DASDDevice[]} devices - */ - async disableDevices(devices) { - const proxy = await this.managerProxy(); - const devicesPath = devices.map((d) => this.devicePath(d)); - proxy.Disable(devicesPath); - } - - /** - * @private - * Proxy for objects implementing org.opensuse.Agama.Storage1.Job iface - * - * @note The jobs are dynamically exported. - * - * @returns {Promise} - */ - async jobsProxy() { - if (!this.proxies.jobs) - this.proxies.jobs = await this.client().proxies(STORAGE_JOB_IFACE, STORAGE_JOBS_NAMESPACE); - - return this.proxies.jobs; - } - - async getJobs() { - const proxy = await this.jobsProxy(); - return Object.values(proxy) - .filter((p) => p.Running) - .map(this.buildJob); - } - - async onJobAdded(handler) { - const proxy = await this.jobsProxy(); - proxy.addEventListener("added", (_, proxy) => handler(this.buildJob(proxy))); - } - - async onJobChanged(handler) { - const proxy = await this.jobsProxy(); - proxy.addEventListener("changed", (_, proxy) => handler(this.buildJob(proxy))); - } - - /** - * @private - * Proxy for objects implementing org.opensuse.Agama.Storage1.Job iface - * - * @note The jobs are dynamically exported. - * - * @returns {Promise} - */ - async formatProxy(jobPath) { - const proxy = await this.client().proxy(DASD_STATUS_IFACE, jobPath); - return proxy; - } - - async onFormatProgress(jobPath, handler) { - const proxy = await this.formatProxy(jobPath); - proxy.addEventListener("changed", (_, proxy) => { - handler(proxy.Summary); - }); - } - - /** - * @private - * Proxy for objects implementing org.opensuse.Agama.Storage1.DASD.Device iface - * - * @note The DASD devices are dynamically exported. - * - * @returns {Promise} - */ - async devicesProxy() { - if (!this.proxies.devices) - this.proxies.devices = await this.client().proxies(DASD_DEVICE_IFACE, DASD_DEVICES_NAMESPACE); - - return this.proxies.devices; - } - - /** - * @private - * Proxy for org.opensuse.Agama.Storage1.DASD.Manager iface - * - * @returns {Promise} - */ - async managerProxy() { - if (!this.proxies.dasdManager) - this.proxies.dasdManager = await this.client().proxy(DASD_MANAGER_IFACE, STORAGE_OBJECT); - - return this.proxies.dasdManager; - } - - async deviceEventListener(signal, handler) { - const proxy = await this.devicesProxy(); - const action = (_, proxy) => handler(this.buildDevice(proxy)); - - proxy.addEventListener(signal, action); - return () => proxy.removeEventListener(signal, action); - } - - /** - * Build a list of DASD devices - * - * @returns {DASDDevice} - * - * @typedef {object} DASDDevice - * @property {string} id - * @property {number} hexId - * @property {string} accessType - * @property {string} channelId - * @property {boolean} diag - * @property {boolean} enabled - * @property {boolean} formatted - * @property {string} name - * @property {string} partitionInfo - * @property {string} status - * @property {string} type - */ - buildDevice(device) { - const id = device.path.split("/").slice(-1)[0]; - const enabled = device.Enabled; - - return { - id, - accessType: enabled ? device.AccessType : "offline", - channelId: device.Id, - diag: device.Diag, - enabled, - formatted: device.Formatted, - hexId: hex(device.Id), - name: device.DeviceName, - partitionInfo: enabled ? device.PartitionInfo : "", - status: device.Status, - type: device.Type, - }; - } - - /** - * @private - * Builds the D-Bus path for the given DASD device - * - * @param {DASDDevice} device - * @returns {string} - */ - devicePath(device) { - return DASD_DEVICES_NAMESPACE + "/" + device.id; - } -} - /** * Class providing an API for managing zFCP through D-Bus */ @@ -1602,8 +1344,6 @@ class StorageBaseClient { this.proposal = new ProposalManager(this.client, this.system); this.iscsi = new ISCSIManager(this.client); // @ts-ignore - this.dasd = new DASDManager(StorageBaseClient.SERVICE, client); - // @ts-ignore this.zfcp = new ZFCPManager(StorageBaseClient.SERVICE, client); } @@ -1652,6 +1392,6 @@ class StorageBaseClient { /** * Allows interacting with the storage settings */ -class StorageClient extends WithStatus(StorageBaseClient, "/storage/status", SERVICE_NAME) {} +class StorageClient extends WithStatus(StorageBaseClient, "/storage/status", SERVICE_NAME) { } export { StorageClient, EncryptionMethods }; diff --git a/web/src/client/storage.test.js b/web/src/client/storage.test.js index 6b9d214d0a..813c3a54ba 100644 --- a/web/src/client/storage.test.js +++ b/web/src/client/storage.test.js @@ -20,7 +20,7 @@ */ // @ts-check -// cspell:ignore ECKD dasda ddgdcbibhd wwpns +// cspell:ignore ddgdcbibhd wwpns import { HTTPClient } from "./http"; import DBusClient from "./dbus"; @@ -273,31 +273,6 @@ const multipath = { udevPaths: [], }; -/** @type {StorageDevice} */ -const dasd = { - sid: 69, - isDrive: true, - type: "dasd", - vendor: "IBM", - model: "IBM", - driver: [], - bus: "", - busId: "0.0.0150", - transport: "", - dellBOSS: false, - sdCard: false, - active: true, - name: "/dev/dasda", - description: "", - size: 2048, - start: 0, - encrypted: false, - shrinking: { unsupported: ["Resizing is not supported"] }, - systems: [], - udevIds: [], - udevPaths: [], -}; - /** @type {StorageDevice} */ const sdf = { sid: 70, @@ -469,7 +444,6 @@ const systemDevices = { md0, raid, multipath, - dasd, sdf, sdf1, lvmVg, @@ -600,35 +574,6 @@ const contexts = { startup: "onboot", }, ], - withoutDASDDevices: () => { - cockpitProxies.dasdDevices = {}; - }, - withDASDDevices: () => { - cockpitProxies.dasdDevices = { - "/org/opensuse/Agama/Storage1/dasds/8": { - path: "/org/opensuse/Agama/Storage1/dasds/8", - AccessType: "", - DeviceName: "dasd_sample_8", - Diag: false, - Enabled: true, - Formatted: false, - Id: "0.0.019e", - PartitionInfo: "", - Type: "ECKD", - }, - "/org/opensuse/Agama/Storage1/dasds/9": { - path: "/org/opensuse/Agama/Storage1/dasds/9", - AccessType: "rw", - DeviceName: "dasd_sample_9", - Diag: false, - Enabled: true, - Formatted: false, - Id: "0.0.ffff", - PartitionInfo: "/dev/dasd_sample_9", - Type: "FBA", - }, - }; - }, withoutZFCPControllers: () => { cockpitProxies.zfcpControllers = {}; }, @@ -985,36 +930,6 @@ const contexts = { wires: ["/dev/sdd", "/dev/sde"], }, }, - { - deviceInfo: { - sid: 69, - name: "/dev/dasda", - description: "", - }, - blockDevice: { - active: true, - encrypted: false, - size: 2048, - start: 0, - shrinking: { unsupported: ["Resizing is not supported"] }, - systems: [], - udevIds: [], - udevPaths: [], - }, - drive: { - type: "dasd", - vendor: "IBM", - model: "IBM", - driver: [], - bus: "", - busId: "0.0.0150", - transport: "", - info: { - dellBOSS: false, - sdCard: false, - }, - }, - }, { deviceInfo: { sid: 70, @@ -1147,8 +1062,6 @@ const mockProxy = (iface, path) => { return cockpitProxies.iscsiInitiator; case "org.opensuse.Agama.Storage1.ISCSI.Node": return cockpitProxies.iscsiNode[path]; - case "org.opensuse.Agama.Storage1.DASD.Manager": - return cockpitProxies.dasdManager; case "org.opensuse.Agama.Storage1.ZFCP.Manager": return cockpitProxies.zfcpManager; case "org.opensuse.Agama.Storage1.ZFCP.Controller": @@ -1160,8 +1073,6 @@ const mockProxies = (iface) => { switch (iface) { case "org.opensuse.Agama.Storage1.ISCSI.Node": return cockpitProxies.iscsiNodes; - case "org.opensuse.Agama.Storage1.DASD.Device": - return cockpitProxies.dasdDevices; case "org.opensuse.Agama.Storage1.ZFCP.Controller": return cockpitProxies.zfcpControllers; case "org.opensuse.Agama.Storage1.ZFCP.Disk": @@ -1192,8 +1103,6 @@ const reset = () => { cockpitProxies.iscsiInitiator = {}; cockpitProxies.iscsiNodes = {}; cockpitProxies.iscsiNode = {}; - cockpitProxies.dasdManager = {}; - cockpitProxies.dasdDevices = {}; cockpitProxies.zfcpManager = {}; cockpitProxies.zfcpControllers = {}; cockpitProxies.zfcpDisks = {}; @@ -1897,110 +1806,6 @@ describe("#proposal", () => { }); }); -describe.skip("#dasd", () => { - const sampleDasdDevice = { - id: "8", - accessType: "", - channelId: "0.0.019e", - diag: false, - enabled: true, - formatted: false, - hexId: 414, - name: "sample_dasd_device", - partitionInfo: "", - type: "ECKD", - }; - - const probeFn = jest.fn(); - const setDiagFn = jest.fn(); - const enableFn = jest.fn(); - const disableFn = jest.fn(); - - beforeEach(() => { - client = new StorageClient(); - cockpitProxies.dasdManager = { - Probe: probeFn, - SetDiag: setDiagFn, - Enable: enableFn, - Disable: disableFn, - }; - contexts.withDASDDevices(); - }); - - describe("#getDevices", () => { - it("triggers probing", async () => { - await client.dasd.getDevices(); - expect(probeFn).toHaveBeenCalled(); - }); - - describe("if there is no exported DASD devices yet", () => { - beforeEach(() => { - contexts.withoutDASDDevices(); - }); - - it("returns an empty list", async () => { - const result = await client.dasd.getDevices(); - expect(result).toStrictEqual([]); - }); - }); - - describe("if there are exported DASD devices", () => { - it("returns a list with the exported DASD devices", async () => { - const result = await client.dasd.getDevices(); - expect(result.length).toEqual(2); - expect(result).toContainEqual({ - id: "8", - accessType: "", - channelId: "0.0.019e", - diag: false, - enabled: true, - formatted: false, - hexId: 414, - name: "dasd_sample_8", - partitionInfo: "", - type: "ECKD", - }); - expect(result).toContainEqual({ - id: "9", - accessType: "rw", - channelId: "0.0.ffff", - diag: false, - enabled: true, - formatted: false, - hexId: 65535, - name: "dasd_sample_9", - partitionInfo: "/dev/dasd_sample_9", - type: "FBA", - }); - }); - }); - }); - - describe("#setDIAG", () => { - it("requests for setting DIAG for given devices", async () => { - await client.dasd.setDIAG([sampleDasdDevice], true); - expect(setDiagFn).toHaveBeenCalledWith(["/org/opensuse/Agama/Storage1/dasds/8"], true); - - await client.dasd.setDIAG([sampleDasdDevice], false); - expect(setDiagFn).toHaveBeenCalledWith(["/org/opensuse/Agama/Storage1/dasds/8"], false); - }); - }); - - describe("#enableDevices", () => { - it("requests for enabling given devices", async () => { - await client.dasd.enableDevices([sampleDasdDevice]); - expect(enableFn).toHaveBeenCalledWith(["/org/opensuse/Agama/Storage1/dasds/8"]); - }); - }); - - describe("#disableDevices", () => { - it("requests for disabling given devices", async () => { - await client.dasd.disableDevices([sampleDasdDevice]); - expect(disableFn).toHaveBeenCalledWith(["/org/opensuse/Agama/Storage1/dasds/8"]); - }); - }); -}); - describe.skip("#zfcp", () => { const probeFn = jest.fn(); let controllersCallbacks; diff --git a/web/src/components/layout/Icon.jsx b/web/src/components/layout/Icon.jsx index 47ad45b5ec..85dc96c6a3 100644 --- a/web/src/components/layout/Icon.jsx +++ b/web/src/components/layout/Icon.jsx @@ -26,6 +26,7 @@ import React from "react"; import AddAPhoto from "@icons/add_a_photo.svg?component"; import Apps from "@icons/apps.svg?component"; import Badge from "@icons/badge.svg?component"; +import Backspace from "@icons/backspace.svg?component"; import CheckCircle from "@icons/check_circle.svg?component"; import ChevronRight from "@icons/chevron_right.svg?component"; import CollapseAll from "@icons/collapse_all.svg?component"; @@ -94,6 +95,7 @@ const icons = { add_a_photo: AddAPhoto, apps: Apps, badge: Badge, + backspace: Backspace, check_circle: CheckCircle, chevron_right: ChevronRight, collapse_all: CollapseAll, diff --git a/web/src/components/storage/DASDFormatProgress.jsx b/web/src/components/storage/DASDFormatProgress.jsx deleted file mode 100644 index 5caac19079..0000000000 --- a/web/src/components/storage/DASDFormatProgress.jsx +++ /dev/null @@ -1,71 +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 version 2 of the GNU General Public License as published - * by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for - * more details. - * - * You should have received a copy of the GNU General Public License along - * with this program; if not, contact SUSE LLC. - * - * To contact SUSE LLC about this file by physical or electronic mail, you may - * find current contact information at www.suse.com. - */ - -import React, { useEffect, useState } from "react"; -import { Progress, Skeleton, Stack } from "@patternfly/react-core"; -import { Popup } from "~/components/core"; -import { _ } from "~/i18n"; -import { useInstallerClient } from "~/context/installer"; - -export default function DASDFormatProgress({ job, devices, isOpen = true }) { - const { storage: client } = useInstallerClient(); - const [progress, setProgress] = useState(undefined); - - useEffect(() => { - client.dasd.onFormatProgress(job.path, (p) => setProgress(p)); - }, [client.dasd, job.path]); - - const ProgressContent = ({ progress }) => { - return ( - - {Object.entries(progress).map(([path, [total, step, done]]) => { - const device = devices.find((d) => d.id === path.split("/").slice(-1)[0]); - - return ( - - ); - })} - - ); - }; - - const WaitingProgress = () => ( - -
{_("Waiting for progress report")}
- - -
- ); - - return ( - - {progress ? : } - - ); -} diff --git a/web/src/components/storage/DASDFormatProgress.test.tsx b/web/src/components/storage/DASDFormatProgress.test.tsx new file mode 100644 index 0000000000..730665d934 --- /dev/null +++ b/web/src/components/storage/DASDFormatProgress.test.tsx @@ -0,0 +1,100 @@ +/* + * Copyright (c) [2024] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React from "react"; +import { screen } from "@testing-library/react"; +import { installerRender, plainRender } from "~/test-utils"; +import DASDFormatProgress from "./DASDFormatProgress"; +import { DASDDevice, FormatJob } from "~/types/dasd"; + +let mockDASDFormatJobs: FormatJob[]; +let mockDASDDevices: DASDDevice[]; + +jest.mock("~/queries/dasd", () => ({ + useDASDRunningFormatJobs: () => mockDASDFormatJobs, + useDASDDevices: () => mockDASDDevices, +})); + +describe("DASDFormatProgress", () => { + describe("when there is already some progress", () => { + beforeEach(() => { + mockDASDFormatJobs = [ + { + jobId: "0.0.0200", + summary: { + "0.0.0200": { + total: 5, + step: 1, + done: false, + }, + }, + }, + ]; + + mockDASDDevices = [ + { + id: "0.0.0200", + enabled: false, + deviceName: "dasda", + deviceType: "eckd", + formatted: false, + diag: false, + status: "active", + accessType: "rw", + partitionInfo: "1", + hexId: 0x200, + }, + ]; + }); + + it("renders the progress", () => { + installerRender(); + expect(screen.queryByRole("progressbar")).toBeInTheDocument(); + screen.getByText("0.0.0200 - dasda"); + }); + }); + + describe("when there are no running jobs", () => { + beforeEach(() => { + mockDASDFormatJobs = []; + + mockDASDDevices = [ + { + id: "0.0.0200", + enabled: false, + deviceName: "dasda", + deviceType: "eckd", + formatted: false, + diag: false, + status: "active", + accessType: "rw", + partitionInfo: "1", + hexId: 0x200, + }, + ]; + }); + + it("does not render any progress", () => { + installerRender(); + expect(screen.queryByRole("progressbar")).not.toBeInTheDocument(); + }); + }); +}); diff --git a/web/src/components/storage/DASDFormatProgress.tsx b/web/src/components/storage/DASDFormatProgress.tsx new file mode 100644 index 0000000000..835bd56eaa --- /dev/null +++ b/web/src/components/storage/DASDFormatProgress.tsx @@ -0,0 +1,61 @@ +/* + * 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 version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React from "react"; +import { Progress, Stack } from "@patternfly/react-core"; +import { Popup } from "~/components/core"; +import { _ } from "~/i18n"; +import { useDASDDevices, useDASDRunningFormatJobs } from "~/queries/dasd"; +import { DASDDevice, FormatSummary } from "~/types/dasd"; + +const DeviceProgress = ({ device, progress }: { device: DASDDevice; progress: FormatSummary }) => ( + +); + +export default function DASDFormatProgress() { + const devices = useDASDDevices(); + const runningJobs = useDASDRunningFormatJobs().filter( + (job) => Object.keys(job.summary || {}).length > 0, + ); + + return ( + 0} disableFocusTrap> + + {runningJobs.map((job) => + Object.entries(job.summary).map(([id, progress]) => { + const device = devices.find((d) => d.id === id); + return ( + + ); + }), + )} + + + ); +} diff --git a/web/src/components/storage/DASDPage.jsx b/web/src/components/storage/DASDPage.jsx deleted file mode 100644 index 2a11d067ed..0000000000 --- a/web/src/components/storage/DASDPage.jsx +++ /dev/null @@ -1,190 +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 version 2 of the GNU General Public License as published - * by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for - * more details. - * - * You should have received a copy of the GNU General Public License along - * with this program; if not, contact SUSE LLC. - * - * To contact SUSE LLC about this file by physical or electronic mail, you may - * find current contact information at www.suse.com. - */ - -import React, { useEffect, useReducer } from "react"; -import DASDTable from "~/components/storage/DASDTable"; -import DASDFormatProgress from "~/components/storage/DASDFormatProgress"; -import { _ } from "~/i18n"; -import { useCancellablePromise } from "~/utils"; -import { useInstallerClient } from "~/context/installer"; - -const reducer = (state, action) => { - const { type, payload } = action; - - switch (type) { - case "SET_DEVICES": { - return { ...state, devices: payload.devices }; - } - - case "ADD_DEVICE": { - const { device } = payload; - if (state.devices.find((d) => d.id === device.id)) return state; - - return { ...state, devices: [...state.devices, device] }; - } - - case "UPDATE_DEVICE": { - const { device } = payload; - const index = state.devices.findIndex((d) => d.id === device.id); - const devices = [...state.devices]; - index !== -1 ? (devices[index] = device) : devices.push(device); - - const selectedDevicesIds = state.selectedDevices.map((d) => d.id); - const selectedDevices = devices.filter((d) => selectedDevicesIds.includes(d.id)); - - return { ...state, devices, selectedDevices }; - } - - case "REMOVE_DEVICE": { - const { device } = payload; - - return { ...state, devices: state.devices.filter((d) => d.id !== device.id) }; - } - - case "SET_MIN_CHANNEL": { - return { ...state, minChannel: payload.minChannel, selectedDevices: [] }; - } - - case "SET_MAX_CHANNEL": { - return { ...state, maxChannel: payload.maxChannel, selectedDevices: [] }; - } - - case "SELECT_DEVICE": { - const { device } = payload; - - return { ...state, selectedDevices: [...state.selectedDevices, device] }; - } - - case "UNSELECT_DEVICE": { - const { device } = payload; - - return { ...state, selectedDevices: state.selectedDevices.filter((d) => d.id !== device.id) }; - } - - case "SELECT_ALL_DEVICES": { - return { ...state, selectedDevices: payload.devices }; - } - - case "UNSELECT_ALL_DEVICES": { - return { ...state, selectedDevices: [] }; - } - - case "START_FORMAT_JOB": { - const { data: formatJob } = payload; - - if (!formatJob.running) return state; - const newState = { ...state, formatJob }; - - return newState; - } - - case "UPDATE_FORMAT_JOB": { - const { data: formatJob } = payload; - - if (formatJob.path !== state.formatJob.path) return state; - - return { ...state, formatJob }; - } - - case "START_LOADING": { - return { ...state, isLoading: true }; - } - - case "STOP_LOADING": { - return { ...state, isLoading: false }; - } - - default: { - return state; - } - } -}; - -const initialState = { - devices: [], - selectedDevices: [], - minChannel: "", - maxChannel: "", - isLoading: true, - formatJob: {}, -}; - -export default function DASDPage() { - const { storage: client } = useInstallerClient(); - const { cancellablePromise } = useCancellablePromise(); - const [state, dispatch] = useReducer(reducer, initialState); - - useEffect(() => { - const loadDevices = async () => { - dispatch({ type: "START_LOADING" }); - const devices = await cancellablePromise(client.dasd.getDevices()); - dispatch({ type: "SET_DEVICES", payload: { devices } }); - dispatch({ type: "STOP_LOADING" }); - }; - - const loadJobs = async () => { - const jobs = await cancellablePromise(client.dasd.getJobs()); - if (jobs.length > 0) { - dispatch({ type: "START_FORMAT_JOB", payload: { data: jobs[0] } }); - } - }; - - loadDevices().catch(console.error); - loadJobs().catch(console.error); - }, [client.dasd, cancellablePromise]); - - useEffect(() => { - const subscriptions = []; - - const subscribe = async () => { - const action = (type, device) => dispatch({ type, payload: { device } }); - - subscriptions.push( - await client.dasd.deviceEventListener("added", (d) => action("ADD_DEVICE", d)), - await client.dasd.deviceEventListener("removed", (d) => action("REMOVE_DEVICE", d)), - await client.dasd.deviceEventListener("changed", (d) => action("UPDATE_DEVICE", d)), - ); - - await client.dasd.onJobAdded((data) => - dispatch({ type: "START_FORMAT_JOB", payload: { data } }), - ); - await client.dasd.onJobChanged((data) => - dispatch({ type: "UPDATE_FORMAT_JOB", payload: { data } }), - ); - }; - - const unsubscribe = () => { - subscriptions.forEach((fn) => fn()); - }; - - subscribe(); - return unsubscribe; - }, [client.dasd]); - - return ( - <> - - {state.formatJob.running && ( - - )} - - ); -} diff --git a/web/src/components/storage/DASDPage.tsx b/web/src/components/storage/DASDPage.tsx new file mode 100644 index 0000000000..28a77e13aa --- /dev/null +++ b/web/src/components/storage/DASDPage.tsx @@ -0,0 +1,45 @@ +/* + * 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 version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React from "react"; +import DASDTable from "~/components/storage/DASDTable"; +import DASDFormatProgress from "~/components/storage/DASDFormatProgress"; +import { _ } from "~/i18n"; +import { Page } from "~/components/core"; +import { useDASDDevicesChanges, useDASDFormatJobChanges } from "~/queries/dasd"; + +export default function DASDPage() { + useDASDDevicesChanges(); + useDASDFormatJobChanges(); + + return ( + + +

{_("DASD")}

+
+ + + + + +
+ ); +} diff --git a/web/src/components/storage/DASDTable.test.tsx b/web/src/components/storage/DASDTable.test.tsx new file mode 100644 index 0000000000..6643e41029 --- /dev/null +++ b/web/src/components/storage/DASDTable.test.tsx @@ -0,0 +1,60 @@ +/* + * Copyright (c) [2024] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React from "react"; +import { screen } from "@testing-library/react"; +import { installerRender } from "~/test-utils"; +import DASDTable from "~/components/storage/DASDTable"; +import { DASDDevice } from "~/types/dasd"; + +let mockDASDDevices: DASDDevice[] = []; + +jest.mock("~/queries/dasd", () => ({ + useDASDDevices: () => mockDASDDevices, + useDASDMutation: () => jest.fn(), + useFormatDASDMutation: () => jest.fn(), +})); + +describe("DASDTable", () => { + describe("when there is some DASD devices available", () => { + beforeEach(() => { + mockDASDDevices = [ + { + id: "0.0.0200", + enabled: false, + deviceName: "dasda", + deviceType: "eckd", + formatted: false, + diag: false, + status: "active", + accessType: "rw", + partitionInfo: "1", + hexId: 0x200, + }, + ]; + }); + + it("renders those devices", () => { + installerRender(); + screen.getByText("active"); + }); + }); +}); diff --git a/web/src/components/storage/DASDTable.jsx b/web/src/components/storage/DASDTable.tsx similarity index 54% rename from web/src/components/storage/DASDTable.jsx rename to web/src/components/storage/DASDTable.tsx index 9937b1c4c9..54fd22fec8 100644 --- a/web/src/components/storage/DASDTable.jsx +++ b/web/src/components/storage/DASDTable.tsx @@ -22,6 +22,7 @@ import React, { useState } from "react"; import { Button, + CardBody, Divider, Dropdown, DropdownItem, @@ -37,15 +38,16 @@ import { } from "@patternfly/react-core"; import { Table, Thead, Tr, Th, Tbody, Td } from "@patternfly/react-table"; import { Icon } from "~/components/layout"; -import { SectionSkeleton } from "~/components/core"; +import { CardField } from "~/components/core"; import { _ } from "~/i18n"; import { hex } from "~/utils"; import { sort } from "fast-sort"; -import { useInstallerClient } from "~/context/installer"; +import { DASDDevice } from "~/types/dasd"; +import { useDASDDevices, useDASDMutation, useFormatDASDMutation } from "~/queries/dasd"; // FIXME: please, note that this file still requiring refinements until reach a // reasonable stable version -const columnData = (device, column) => { +const columnData = (device: DASDDevice, column: { id: string; sortId?: string; label: string }) => { let data = device[column.id]; switch (column.id) { @@ -66,10 +68,10 @@ const columnData = (device, column) => { }; const columns = [ - { id: "channelId", sortId: "hexId", label: _("Channel ID") }, + { id: "id", sortId: "hexId", label: _("Channel ID") }, { id: "status", label: _("Status") }, - { id: "name", label: _("Device") }, - { id: "type", label: _("Type") }, + { id: "deviceName", label: _("Device") }, + { id: "deviceType", label: _("Type") }, // TRANSLATORS: table header, the column contains "Yes"/"No" values // for the DIAG access mode (special disk access mode on IBM mainframes), // usually keep untranslated @@ -78,17 +80,19 @@ const columns = [ { id: "partitionInfo", label: _("Partition Info") }, ]; -const Actions = ({ devices, isDisabled }) => { - const { storage: client } = useInstallerClient(); +const Actions = ({ devices, isDisabled }: { devices: DASDDevice[]; isDisabled: boolean }) => { + const { mutate: updateDASD } = useDASDMutation(); + const { mutate: formatDASD } = useFormatDASDMutation(); const [isOpen, setIsOpen] = useState(false); const onToggle = () => setIsOpen(!isOpen); const onSelect = () => setIsOpen(false); - const activate = () => client.dasd.enableDevices(devices); - const deactivate = () => client.dasd.disableDevices(devices); - const setDiagOn = () => client.dasd.setDIAG(devices, true); - const setDiagOff = () => client.dasd.setDIAG(devices, false); + const deviceIds = devices.map((d) => d.id); + const activate = () => updateDASD({ action: "enable", devices: deviceIds }); + const deactivate = () => updateDASD({ action: "disable", devices: deviceIds }); + const setDiagOn = () => updateDASD({ action: "diagOn", devices: deviceIds }); + const setDiagOff = () => updateDASD({ action: "diagOff", devices: deviceIds }); const format = () => { const offline = devices.filter((d) => !d.enabled); @@ -96,7 +100,7 @@ const Actions = ({ devices, isDisabled }) => { return false; } - return client.dasd.format(devices); + return formatDASD(devices.map((d) => d.id)); }; const Action = ({ children, ...props }) => ( @@ -144,7 +148,7 @@ const Actions = ({ devices, isDisabled }) => { ); }; -const filterDevices = (devices, from, to) => { +const filterDevices = (devices: DASDDevice[], from: string, to: string): DASDDevice[] => { const allChannels = devices.map((d) => d.hexId); const min = hex(from) || Math.min(...allChannels); const max = hex(to) || Math.max(...allChannels); @@ -152,28 +156,43 @@ const filterDevices = (devices, from, to) => { return devices.filter((d) => d.hexId >= min && d.hexId <= max); }; -export default function DASDTable({ state, dispatch }) { +type FilterOptions = { + minChannel?: string; + maxChannel?: string; +}; +type SelectionOptions = { + unselect?: boolean; + device?: DASDDevice; + devices?: DASDDevice[]; +}; + +export default function DASDTable() { + const devices = useDASDDevices(); + const [selectedDASD, setSelectedDASD] = useState([]); + const [{ minChannel, maxChannel }, setFilters] = useState({ + minChannel: "", + maxChannel: "", + }); + const [sortingColumn, setSortingColumn] = useState(columns[0]); - const [sortDirection, setSortDirection] = useState("asc"); + const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc"); const sortColumnIndex = () => columns.findIndex((c) => c.id === sortingColumn.id); - const filteredDevices = filterDevices(state.devices, state.minChannel, state.maxChannel); - const selectedDevicesIds = state.selectedDevices.map((d) => d.id); + const filteredDevices = filterDevices(devices, minChannel, maxChannel); + const selectedDevicesIds = selectedDASD.map((d) => d.id); // Selecting const selectAll = (isSelecting = true) => { - const type = isSelecting ? "SELECT_ALL_DEVICES" : "UNSELECT_ALL_DEVICES"; - dispatch({ type, payload: { devices: filteredDevices } }); + changeSelected({ unselect: !isSelecting, devices: filteredDevices }); }; const selectDevice = (device, isSelecting = true) => { - const type = isSelecting ? "SELECT_DEVICE" : "UNSELECT_DEVICE"; - dispatch({ type, payload: { device } }); + changeSelected({ unselect: !isSelecting, device }); }; // Sorting // See https://github.com/snovakovic/fast-sort - const sortBy = sortingColumn.sortBy || sortingColumn.id; + const sortBy = sortingColumn.sortId || sortingColumn.id; const sortedDevices = sort(filteredDevices)[sortDirection]((d) => d[sortBy]); // FIXME: this can be improved and even extracted to be used with other tables. @@ -188,34 +207,33 @@ export default function DASDTable({ state, dispatch }) { }; }; - // Filtering - const onMinChannelFilterChange = (_event, value) => { - dispatch({ type: "SET_MIN_CHANNEL", payload: { minChannel: value } }); - }; - - const onMaxChannelFilterChange = (_event, value) => { - dispatch({ type: "SET_MAX_CHANNEL", payload: { maxChannel: value } }); - }; - - const removeMinChannelFilter = () => { - dispatch({ type: "SET_MIN_CHANNEL", payload: { minChannel: "" } }); + const updateFilter = (newFilters: FilterOptions) => { + setFilters((currentFilters) => ({ ...currentFilters, ...newFilters })); }; - const removeMaxChannelFilter = () => { - dispatch({ type: "SET_MAX_CHANNEL", payload: { maxChannel: "" } }); + const changeSelected = (newSelection: SelectionOptions) => { + setSelectedDASD((prevSelection) => { + if (newSelection.unselect) { + if (newSelection.device) + return prevSelection.filter((d) => d.id !== newSelection.device.id); + if (newSelection.devices) return []; + } else { + if (newSelection.device) return [...prevSelection, newSelection.device]; + if (newSelection.devices) return newSelection.devices; + } + }); }; const Content = () => { - if (state.isLoading) return ; - return (
selectAll(isSelecting), - isSelected: filteredDevices.length === state.selectedDevices.length, + isSelected: filteredDevices.length === selectedDASD.length, }} /> {columns.map((column, index) => ( @@ -229,6 +247,7 @@ export default function DASDTable({ state, dispatch }) { {sortedDevices.map((device, rowIndex) => (
selectDevice(device, isSelecting), @@ -249,68 +268,67 @@ export default function DASDTable({ state, dispatch }) { }; return ( - <> - - - - - - - {state.minChannel !== "" && ( - - - - )} - - - - - - {state.maxChannel !== "" && ( - - - - )} - - + + + + + + + + updateFilter({ minChannel })} + /> + {minChannel !== "" && ( + + + + )} + + + + + updateFilter({ maxChannel })} + /> + {maxChannel !== "" && ( + + + + )} + + - + - - - - - - + + + + + + - - + + + ); } diff --git a/web/src/components/storage/DevicesTechMenu.jsx b/web/src/components/storage/DevicesTechMenu.jsx index dc0a47a659..12449889b5 100644 --- a/web/src/components/storage/DevicesTechMenu.jsx +++ b/web/src/components/storage/DevicesTechMenu.jsx @@ -26,6 +26,7 @@ import { useHref } from "react-router-dom"; import { MenuToggle, Select, SelectList, SelectOption } from "@patternfly/react-core"; import { _ } from "~/i18n"; import { useInstallerClient } from "~/context/installer"; +import { DASDSupported } from "~/api/dasd"; /** * Internal component for building the link to Storage/DASD page @@ -85,9 +86,9 @@ export default function DevicesTechMenu({ label }) { const { storage: client } = useInstallerClient(); useEffect(() => { - client.dasd.isSupported().then(setShowDasdLink); + DASDSupported().then(setShowDasdLink); client.zfcp.isSupported().then(setShowZFCPLink); - }, [client.dasd, client.zfcp]); + }, [client.zfcp]); const toggle = (toggleRef) => ( setIsOpen(!isOpen)} isExpanded={isOpen}> @@ -95,7 +96,7 @@ export default function DevicesTechMenu({ label }) { ); - const onSelect = (_event, value) => { + const onSelect = () => { setIsOpen(false); }; diff --git a/web/src/components/storage/DevicesTechMenu.test.jsx b/web/src/components/storage/DevicesTechMenu.test.jsx index 6cd7917a1b..b46f4f2be8 100644 --- a/web/src/components/storage/DevicesTechMenu.test.jsx +++ b/web/src/components/storage/DevicesTechMenu.test.jsx @@ -24,14 +24,10 @@ import { screen } from "@testing-library/react"; import { installerRender } from "~/test-utils"; import { createClient } from "~/client"; import DevicesTechMenu from "./DevicesTechMenu"; +import { DASDSupported } from "~/api/dasd"; jest.mock("~/client"); - -const isDASDSupportedFn = jest.fn(); - -const dasd = { - isSupported: isDASDSupportedFn, -}; +jest.mock("~/api/dasd"); const isZFCPSupportedFn = jest.fn(); @@ -40,12 +36,12 @@ const zfcp = { }; beforeEach(() => { - isDASDSupportedFn.mockResolvedValue(false); + DASDSupported.mockResolvedValue(false); isZFCPSupportedFn.mockResolvedValue(false); createClient.mockImplementation(() => { return { - storage: { dasd, zfcp }, + storage: { zfcp }, }; }); }); @@ -59,7 +55,7 @@ it("contains an entry for configuring iSCSI", async () => { }); it("contains an entry for configuring DASD when is supported", async () => { - isDASDSupportedFn.mockResolvedValue(true); + DASDSupported.mockResolvedValue(true); const { user } = installerRender(); const toggler = screen.getByRole("button"); await user.click(toggler); @@ -68,7 +64,7 @@ it("contains an entry for configuring DASD when is supported", async () => { }); it("does not contain an entry for configuring DASD when is NOT supported", async () => { - isDASDSupportedFn.mockResolvedValue(false); + DASDSupported.mockResolvedValue(false); const { user } = installerRender(); const toggler = screen.getByRole("button"); await user.click(toggler); diff --git a/web/src/queries/dasd.ts b/web/src/queries/dasd.ts new file mode 100644 index 0000000000..68c09fe5a4 --- /dev/null +++ b/web/src/queries/dasd.ts @@ -0,0 +1,247 @@ +/* + * Copyright (c) [2024] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import { useMutation, useQuery, useQueryClient, useSuspenseQuery } from "@tanstack/react-query"; +import { _ } from "~/i18n"; +import { + disableDASD, + disableDiag, + enableDASD, + enableDiag, + fetchDASDDevices, + formatDASD, +} from "~/api/dasd"; +import { useInstallerClient } from "~/context/installer"; +import React from "react"; +import { hex } from "~/utils"; +import { DASDDevice, FormatJob } from "~/types/dasd"; +import { fetchStorageJobs } from "~/api/storage"; + +/** + * Returns a query for retrieving the dasd devices + */ +const DASDDevicesQuery = () => ({ + queryKey: ["dasd", "devices"], + queryFn: fetchDASDDevices, +}); + +/** + * Hook that returns DASD devices. + */ +const useDASDDevices = () => { + const { data: devices } = useSuspenseQuery(DASDDevicesQuery()); + return devices.map((d) => ({ ...d, hexId: hex(d.id) })); +}; + +/** + * Returns a query for retrieving the running dasd format jobs + */ +const DASDRunningFormatJobsQuery = () => ({ + queryKey: ["dasd", "formatJobs", "running"], + queryFn: () => + fetchStorageJobs().then((jobs) => + jobs.filter((j) => j.running).map(({ id }) => ({ jobId: id })), + ), + staleTime: 200, +}); + +/** + * Hook that returns and specific DASD format job. + */ +const useDASDRunningFormatJobs = (): FormatJob[] => { + const { data: jobs } = useSuspenseQuery(DASDRunningFormatJobsQuery()); + return jobs; +}; + +/** + * Listens for DASD format job changes. + */ +const useDASDFormatJobChanges = () => { + const client = useInstallerClient(); + const queryClient = useQueryClient(); + + React.useEffect(() => { + if (!client) return; + + return client.ws().onEvent((event) => { + // TODO: for simplicity we now just invalidate query instead of manually adding, removing or changing devices + switch (event.type) { + case "DASDFormatJobChanged": { + const data = queryClient.getQueryData(["dasd", "formatJobs", "running"]) as FormatJob[]; + const nextData = data.map((job) => { + if (job.jobId !== event.jobId) return job; + + return { + ...job, + summary: { ...job?.summary, ...event.summary }, + }; + }); + queryClient.setQueryData(["dasd", "formatJobs", "running"], nextData); + break; + } + case "JobAdded": { + const formatJob: FormatJob = { jobId: event.job.id }; + const data = queryClient.getQueryData(["dasd", "formatJobs", "running"]) as FormatJob[]; + + queryClient.setQueryData(["dasd", "formatJobs", "running"], [...data, formatJob]); + break; + } + case "JobChanged": { + const { id, running } = event.job; + if (running) return; + const data = queryClient.getQueryData(["dasd", "formatJobs", "running"]) as FormatJob[]; + const nextData = data.filter((j) => j.jobId !== id); + if (data.length !== nextData.length) { + queryClient.setQueryData(["dasd", "formatJobs", "running"], nextData); + } + break; + } + } + }); + }); + + const { data: jobs } = useSuspenseQuery(DASDRunningFormatJobsQuery()); + return jobs; +}; + +/** + * Listens for DASD devices changes. + */ +const useDASDDevicesChanges = () => { + const client = useInstallerClient(); + const queryClient = useQueryClient(); + + React.useEffect(() => { + if (!client) return; + + return client.ws().onEvent((event) => { + switch (event.type) { + case "DASDDeviceAdded": { + const device: DASDDevice = event.device; + queryClient.setQueryData(["dasd", "devices"], (prev: DASDDevice[]) => { + return [...prev, device]; + }); + break; + } + case "DASDDeviceRemoved": { + const device: DASDDevice = event.device; + const { id } = device; + queryClient.setQueryData(["dasd", "devices"], (prev: DASDDevice[]) => { + const res = prev.filter((dev) => dev.id !== id); + return res; + }); + break; + } + case "DASDDeviceChanged": { + const device: DASDDevice = event.device; + const { id } = device; + queryClient.setQueryData(["dasd", "devices"], (prev: DASDDevice[]) => { + // deep copy of original to have it immutable + const res = [...prev]; + const index = res.findIndex((dev) => dev.id === id); + res[index] = device; + return res; + }); + break; + } + } + }); + }); + + const { data: devices } = useSuspenseQuery(DASDDevicesQuery()); + return devices; +}; + +const useDASDMutation = () => { + const queryClient = useQueryClient(); + const query = { + mutationFn: ({ action, devices }: { action: string; devices: string[] }) => { + switch (action) { + case "enable": { + return enableDASD(devices); + } + case "disable": { + return disableDASD(devices); + } + case "diagOn": { + return enableDiag(devices); + } + case "diagOff": { + return disableDiag(devices); + } + } + }, + onSuccess: (_: object, { action, devices }: { action: string; devices: string[] }) => { + queryClient.setQueryData(["dasd", "devices"], (prev: DASDDevice[]) => { + const nextData = prev.map((prevDev) => { + const dev = { ...prevDev }; + if (devices.includes(dev.id)) { + switch (action) { + case "enable": { + dev.enabled = true; + break; + } + case "disable": { + dev.enabled = false; + break; + } + case "diagOn": { + dev.diag = true; + break; + } + case "diagOff": { + dev.diag = false; + break; + } + } + } + + return dev; + }); + + return nextData; + }); + }, + }; + + return useMutation(query); +}; + +const useFormatDASDMutation = () => { + const queryClient = useQueryClient(); + const query = { + mutationFn: formatDASD, + onSuccess: (data: string) => { + queryClient.setQueryData(["dasd", "formatJob", data], { jobId: data }); + }, + }; + + return useMutation(query); +}; + +export { + useDASDDevices, + useDASDDevicesChanges, + useDASDFormatJobChanges, + useDASDRunningFormatJobs, + useFormatDASDMutation, + useDASDMutation, +}; diff --git a/web/src/routes/storage.tsx b/web/src/routes/storage.tsx index 4c7fcb5c02..0df131b543 100644 --- a/web/src/routes/storage.tsx +++ b/web/src/routes/storage.tsx @@ -23,10 +23,12 @@ import React from "react"; import BootSelection from "~/components/storage/BootSelection"; import DeviceSelection from "~/components/storage/DeviceSelection"; import SpacePolicySelection from "~/components/storage/SpacePolicySelection"; -import ISCSIPage from "~/components/storage/ISCSIPage"; +import { DASDPage, ISCSIPage } from "~/components/storage"; import ProposalPage from "~/components/storage/ProposalPage"; import { Route } from "~/types/routes"; import { N_ } from "~/i18n"; +import { DASDSupported, probeDASD } from "~/api/dasd"; +import { redirect } from "react-router-dom"; const PATHS = { root: "/storage", @@ -34,6 +36,7 @@ const PATHS = { bootingPartition: "/storage/booting-partition", spacePolicy: "/storage/space-policy", iscsi: "/storage/iscsi", + dasd: "/storage/dasd", }; const routes = (): Route => ({ @@ -61,6 +64,15 @@ const routes = (): Route => ({ element: , handle: { name: N_("iSCSI") }, }, + { + path: PATHS.dasd, + element: , + handle: { name: N_("DASD") }, + loader: async () => { + if (!DASDSupported()) return redirect(PATHS.root); + return probeDASD(); + }, + }, ], }); diff --git a/web/src/types/dasd.ts b/web/src/types/dasd.ts new file mode 100644 index 0000000000..0acfad270a --- /dev/null +++ b/web/src/types/dasd.ts @@ -0,0 +1,46 @@ +/* + * Copyright (c) [2024] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +type DASDDevice = { + id: string; + enabled: boolean; + deviceName: string; + formatted: boolean; + diag: boolean; + status: string; // TODO: sync with rust when it switch to enum + deviceType: string; // TODO: sync with rust when it switch to enum + accessType: string; // TODO: sync with rust when it switch to enum + partitionInfo: string; + hexId: number; +}; + +type FormatSummary = { + total: number, + step: number, + done: boolean +} + +type FormatJob = { + jobId: string, + summary?: { [key: string]: FormatSummary } +} + +export type { DASDDevice, FormatSummary, FormatJob }; diff --git a/web/src/types/job.ts b/web/src/types/job.ts new file mode 100644 index 0000000000..68453f2b9c --- /dev/null +++ b/web/src/types/job.ts @@ -0,0 +1,28 @@ +/* + * Copyright (c) [2024] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +type Job = { + id: string; + running: boolean; + exitCode: number; + }; + + export type { Job }; \ No newline at end of file diff --git a/web/src/utils.js b/web/src/utils.js index 019ddd69b8..a88cb27c6b 100644 --- a/web/src/utils.js +++ b/web/src/utils.js @@ -287,6 +287,10 @@ const useDebounce = (callback, delay) => { return debouncedCallback; }; +/** + * @param {string} + * @returns {number} + */ const hex = (value) => { const sanitizedValue = value.replaceAll(".", ""); return parseInt(sanitizedValue, 16);