diff --git a/rust/agama-lib/src/storage.rs b/rust/agama-lib/src/storage.rs index e371b36e68..b32d6afcf6 100644 --- a/rust/agama-lib/src/storage.rs +++ b/rust/agama-lib/src/storage.rs @@ -9,6 +9,7 @@ mod store; pub use client::{ iscsi::{ISCSIAuth, ISCSIClient, ISCSIInitiator, ISCSINode}, + zfcp::ZFCPClient, StorageClient, }; pub use settings::StorageSettings; diff --git a/rust/agama-lib/src/storage/client.rs b/rust/agama-lib/src/storage/client.rs index 37b53a6936..037acc3958 100644 --- a/rust/agama-lib/src/storage/client.rs +++ b/rust/agama-lib/src/storage/client.rs @@ -16,6 +16,7 @@ use zbus::zvariant::{OwnedObjectPath, OwnedValue}; use zbus::Connection; pub mod dasd; pub mod iscsi; +pub mod zfcp; type DBusObject = ( OwnedObjectPath, diff --git a/rust/agama-lib/src/storage/client/zfcp.rs b/rust/agama-lib/src/storage/client/zfcp.rs new file mode 100644 index 0000000000..6534fde392 --- /dev/null +++ b/rust/agama-lib/src/storage/client/zfcp.rs @@ -0,0 +1,190 @@ +//! Implements a client to access Agama's D-Bus API related to zFCP management. + +use std::collections::HashMap; + +use futures_util::future::join_all; +use zbus::{fdo::ObjectManagerProxy, zvariant::OwnedObjectPath, Connection}; + +use crate::{ + dbus::{extract_id_from_path, get_property}, + error::ServiceError, + storage::{ + model::zfcp::{ZFCPController, ZFCPDisk}, + proxies::{ZFCPControllerProxy, ZFCPManagerProxy}, + }, +}; + +const ZFCP_CONTROLLER_PREFIX: &'static str = "/org/opensuse/Agama/Storage1/zfcp_controllers"; + +/// Client to connect to Agama's D-Bus API for zFCP management. +#[derive(Clone)] +pub struct ZFCPClient<'a> { + manager_proxy: ZFCPManagerProxy<'a>, + object_manager_proxy: ObjectManagerProxy<'a>, + connection: Connection, +} + +impl<'a> ZFCPClient<'a> { + pub async fn new(connection: Connection) -> Result { + let manager_proxy = ZFCPManagerProxy::new(&connection).await?; + let object_manager_proxy = ObjectManagerProxy::builder(&connection) + .destination("org.opensuse.Agama.Storage1")? + .path("/org/opensuse/Agama/Storage1")? + .build() + .await?; + Ok(Self { + manager_proxy, + object_manager_proxy, + connection, + }) + } + + pub async fn supported(&self) -> Result { + let introspect = self.manager_proxy.introspect().await?; + // simply check if introspection contain given interface + Ok(introspect.contains("org.opensuse.Agama.Storage1.ZFCP.Manager")) + } + + pub async fn is_lun_scan_allowed(&self) -> Result { + let allowed = self.manager_proxy.allow_lunscan().await?; + // simply check if introspection contain given interface + Ok(allowed) + } + + pub async fn probe(&self) -> Result<(), ServiceError> { + Ok(self.manager_proxy.probe().await?) + } + + pub async fn get_disks(&self) -> Result, ServiceError> { + let managed_objects = self.object_manager_proxy.get_managed_objects().await?; + + let mut devices: Vec<(OwnedObjectPath, ZFCPDisk)> = vec![]; + for (path, ifaces) in managed_objects { + if let Some(properties) = ifaces.get("org.opensuse.Agama.Storage1.ZFCP.Disk") { + match ZFCPDisk::try_from(properties) { + Ok(device) => { + devices.push((path, device)); + } + Err(error) => { + log::warn!("Not a valid zFCP disk: {}", error); + } + } + } + } + Ok(devices) + } + + pub async fn get_controllers( + &self, + ) -> Result, ServiceError> { + let managed_objects = self.object_manager_proxy.get_managed_objects().await?; + + let mut devices: Vec<(OwnedObjectPath, ZFCPController)> = vec![]; + for (path, ifaces) in managed_objects { + if let Some(properties) = ifaces.get("org.opensuse.Agama.Storage1.ZFCP.Controller") { + let id = extract_id_from_path(&path)?.to_string(); + devices.push(( + path, + ZFCPController { + id: id.clone(), + channel: get_property(properties, "Channel")?, + lun_scan: get_property(properties, "LUNScan")?, + active: get_property(properties, "Active")?, + luns_map: self.get_luns_map(id.as_str()).await?, + }, + )) + } + } + Ok(devices) + } + + async fn get_controller_proxy( + &self, + controller_id: &str, + ) -> Result { + let dbus = ZFCPControllerProxy::builder(&self.connection) + .path(ZFCP_CONTROLLER_PREFIX.to_string() + "/" + controller_id)? + .build() + .await?; + Ok(dbus) + } + + pub async fn activate_controller(&self, controller_id: &str) -> Result<(), ServiceError> { + let controller = self.get_controller_proxy(controller_id).await?; + controller.activate().await?; + Ok(()) + } + + pub async fn get_wwpns(&self, controller_id: &str) -> Result, ServiceError> { + let controller = self.get_controller_proxy(controller_id).await?; + let result = controller.get_wwpns().await?; + Ok(result) + } + + pub async fn get_luns( + &self, + controller_id: &str, + wwpn: &str, + ) -> Result, ServiceError> { + let controller = self.get_controller_proxy(controller_id).await?; + let result = controller.get_luns(wwpn).await?; + Ok(result) + } + + /// Obtains a LUNs map for the given controller + /// + /// Given a controller id it returns a HashMap with each of its WWPNs as keys and the list of + /// LUNS corresponding to that specific WWPN as values. + /// + /// Arguments: + /// + /// `controller_id`: controller id + pub async fn get_luns_map( + &self, + controller_id: &str, + ) -> Result>, ServiceError> { + let wwpns = self.get_wwpns(controller_id).await?; + let aresult = wwpns.into_iter().map(|wwpn| async move { + Ok(( + wwpn.clone(), + self.get_luns(controller_id, wwpn.as_str()).await?, + )) + }); + let sresult = join_all(aresult).await; + sresult + .into_iter() + .collect::>, _>>() + } + + pub async fn activate_disk( + &self, + controller_id: &str, + wwpn: &str, + lun: &str, + ) -> Result<(), ServiceError> { + let controller = self.get_controller_proxy(controller_id).await?; + let result = controller.activate_disk(wwpn, lun).await?; + if result == 0 { + Ok(()) + } else { + let text = format!("Failed to activate disk. chzdev exit code {}", result); + Err(ServiceError::UnsuccessfulAction(text)) + } + } + + pub async fn deactivate_disk( + &self, + controller_id: &str, + wwpn: &str, + lun: &str, + ) -> Result<(), ServiceError> { + let controller = self.get_controller_proxy(controller_id).await?; + let result = controller.deactivate_disk(wwpn, lun).await?; + if result == 0 { + Ok(()) + } else { + let text = format!("Failed to deactivate disk. chzdev exit code {}", result); + Err(ServiceError::UnsuccessfulAction(text)) + } + } +} diff --git a/rust/agama-lib/src/storage/model.rs b/rust/agama-lib/src/storage/model.rs index 81c3b0f65e..ffe46f011a 100644 --- a/rust/agama-lib/src/storage/model.rs +++ b/rust/agama-lib/src/storage/model.rs @@ -6,6 +6,7 @@ use zbus::zvariant::{OwnedValue, Value}; use crate::dbus::{get_optional_property, get_property}; pub mod dasd; +pub mod zfcp; #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct DeviceSid(u32); diff --git a/rust/agama-lib/src/storage/model/zfcp.rs b/rust/agama-lib/src/storage/model/zfcp.rs new file mode 100644 index 0000000000..35d679ae7d --- /dev/null +++ b/rust/agama-lib/src/storage/model/zfcp.rs @@ -0,0 +1,50 @@ +//! Implements a data model for zFCP devices management. +use std::collections::HashMap; + +use serde::Serialize; +use zbus::zvariant::OwnedValue; + +use crate::{dbus::get_property, error::ServiceError}; + +/// Represents a zFCP disk (specific to s390x systems). +#[derive(Clone, Debug, Serialize, Default, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ZFCPDisk { + /// Name of the zFCP device (e.g., /dev/sda) + pub name: String, + /// zFCP controller channel id (e.g., 0.0.fa00) + pub channel: String, + /// WWPN of the targer port (e.g., 0x500507630300c562) + pub wwpn: String, + /// LUN of the SCSI device (e.g. 0x4010403300000000) + pub lun: String, +} + +impl TryFrom<&HashMap> for ZFCPDisk { + type Error = ServiceError; + + fn try_from(value: &HashMap) -> Result { + Ok(ZFCPDisk { + name: get_property(value, "Name")?, + channel: get_property(value, "Channel")?, + wwpn: get_property(value, "WWPN")?, + lun: get_property(value, "LUN")?, + }) + } +} + +/// Represents a zFCP controller (specific to s390x systems). +#[derive(Clone, Debug, Serialize, Default, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ZFCPController { + /// unique internal ID for given controller + pub id: String, + /// zFCP controller channel id (e.g., 0.0.fa00) + pub channel: String, + /// flag whenever channel is performing LUN auto scan + pub lun_scan: bool, + /// flag whenever channel is active + pub active: bool, + /// map of associated WWPNs and its LUNs + pub luns_map: HashMap>, +} diff --git a/rust/agama-lib/src/storage/proxies.rs b/rust/agama-lib/src/storage/proxies.rs index c5d2de158b..a813813b4d 100644 --- a/rust/agama-lib/src/storage/proxies.rs +++ b/rust/agama-lib/src/storage/proxies.rs @@ -228,3 +228,76 @@ trait DASDDevice { #[dbus_proxy(property)] fn type_(&self) -> zbus::Result; } + +#[dbus_proxy( + interface = "org.opensuse.Agama.Storage1.ZFCP.Manager", + default_service = "org.opensuse.Agama.Storage1", + default_path = "/org/opensuse/Agama/Storage1" +)] +trait ZFCPManager { + /// Probe method + fn probe(&self) -> zbus::Result<()>; + + /// AllowLUNScan property + #[dbus_proxy(property, name = "AllowLUNScan")] + fn allow_lunscan(&self) -> zbus::Result; +} + +#[dbus_proxy( + interface = "org.opensuse.Agama.Storage1.ZFCP.Controller", + default_service = "org.opensuse.Agama.Storage1", + default_path = "/org/opensuse/Agama/Storage1" +)] +trait ZFCPController { + /// Activate method + fn activate(&self) -> zbus::Result; + + /// ActivateDisk method + fn activate_disk(&self, wwpn: &str, lun: &str) -> zbus::Result; + + /// DeactivateDisk method + fn deactivate_disk(&self, wwpn: &str, lun: &str) -> zbus::Result; + + /// GetLUNs method + #[dbus_proxy(name = "GetLUNs")] + fn get_luns(&self, wwpn: &str) -> zbus::Result>; + + /// GetWWPNs method + #[dbus_proxy(name = "GetWWPNs")] + fn get_wwpns(&self) -> zbus::Result>; + + /// Active property + #[dbus_proxy(property)] + fn active(&self) -> zbus::Result; + + /// Channel property + #[dbus_proxy(property)] + fn channel(&self) -> zbus::Result; + + /// LUNScan property + #[dbus_proxy(property, name = "LUNScan")] + fn lunscan(&self) -> zbus::Result; +} + +#[dbus_proxy( + interface = "org.opensuse.Agama.Storage1.ZFCP.Disk", + default_service = "org.opensuse.Agama.Storage1", + default_path = "/org/opensuse/Agama/Storage1" +)] +trait Disk { + /// Channel property + #[dbus_proxy(property)] + fn channel(&self) -> zbus::Result; + + /// LUN property + #[dbus_proxy(property, name = "LUN")] + fn lun(&self) -> zbus::Result; + + /// Name property + #[dbus_proxy(property)] + fn name(&self) -> zbus::Result; + + /// WWPN property + #[dbus_proxy(property, name = "WWPN")] + fn wwpn(&self) -> zbus::Result; +} diff --git a/rust/agama-server/src/storage/web.rs b/rust/agama-server/src/storage/web.rs index 191a6ddbab..d7bf05a2ae 100644 --- a/rust/agama-server/src/storage/web.rs +++ b/rust/agama-server/src/storage/web.rs @@ -20,9 +20,11 @@ use axum::{ }; use serde::{Deserialize, Serialize}; use tokio_stream::{Stream, StreamExt}; +use zfcp::{zfcp_service, zfcp_stream}; pub mod dasd; pub mod iscsi; +pub mod zfcp; use crate::{ error::Error, @@ -45,9 +47,11 @@ pub async fn storage_streams(dbus: zbus::Connection) -> Result Result Result Result { + let stream: EventStreams = vec![ + ("zfcp_disks", Box::pin(ZFCPDiskStream::new(dbus).await?)), + ( + "zfcp_controllers", + Box::pin(ZFCPControllerStream::new(dbus).await?), + ), + ]; + Ok(stream) +} + +#[derive(Clone)] +struct ZFCPState<'a> { + client: ZFCPClient<'a>, +} + +pub async fn zfcp_service(dbus: &zbus::Connection) -> Result, ServiceError> { + let client = ZFCPClient::new(dbus.clone()).await?; + let state = ZFCPState { client }; + let router = Router::new() + .route("/supported", get(supported)) + .route("/controllers", get(controllers)) + .route( + "/controllers/:controller_id/activate", + post(activate_controller), + ) + .route("/controllers/:controller_id/wwpns", get(get_wwpns)) + .route( + "/controllers/:controller_id/wwpns/:wwpn_id/luns", + get(get_luns), + ) + .route( + "/controllers/:controller_id/wwpns/:wwpn_id/luns/:lun_id/activate_disk", + post(activate_disk), + ) + .route( + "/controllers/:controller_id/wwpns/:wwpn_id/luns/:lun_id/deactivate_disk", + post(deactivate_disk), + ) + .route("/disks", get(get_disks)) + .route("/probe", post(probe)) + .route("/config", get(get_config)) + .with_state(state); + Ok(router) +} + +/// Returns whether zFCP technology is supported or not +#[utoipa::path( + get, + path="/supported", + context_path="/api/storage/zfcp", + responses( + (status = OK, description = "Returns whether zFCP technology is supported") + ) +)] +async fn supported(State(state): State>) -> Result, Error> { + Ok(Json(state.client.supported().await?)) +} + +/// Represents a zFCP global config (specific to s390x systems). +#[derive(Clone, Debug, Default, Serialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ZFCPConfig { + /// flag whenever allow_lun_scan is active + pub allow_lun_scan: bool, +} + +/// Returns global zFCP configuration +#[utoipa::path( + get, + path="/config", + context_path="/api/storage/zfcp", + responses( + (status = OK, description = "Returns global zFCP configuration", body=ZFCPConfig) + ) +)] +async fn get_config(State(state): State>) -> Result, Error> { + let lun_scan = state.client.is_lun_scan_allowed().await?; + Ok(Json(ZFCPConfig { + allow_lun_scan: lun_scan, + })) +} + +/// Returns the list of known zFCP disks. +#[utoipa::path( + get, + path="/disks", + context_path="/api/storage/zfcp", + responses( + (status = OK, description = "List of ZFCP disks", body = Vec) + ) +)] +async fn get_disks(State(state): State>) -> Result>, Error> { + let devices = state + .client + .get_disks() + .await? + .into_iter() + .map(|(_path, device)| device) + .collect(); + Ok(Json(devices)) +} + +/// Returns the list of known zFCP controllers. +#[utoipa::path( + get, + path="/controllers", + context_path="/api/storage/zfcp", + responses( + (status = OK, description = "List of zFCP controllers", body = Vec) + ) +)] +async fn controllers( + State(state): State>, +) -> Result>, Error> { + let devices = state + .client + .get_controllers() + .await? + .into_iter() + .map(|(_path, device)| device) + .collect(); + Ok(Json(devices)) +} + +/// Activate given zFCP controller. +#[utoipa::path( + post, + path="/controllers/:controller_id/activate", + context_path="/api/storage/zfcp", + responses( + (status = OK, description = "controller activated") + ) +)] +async fn activate_controller( + State(state): State>, + Path(controller_id): Path, +) -> Result, Error> { + state + .client + .activate_controller(controller_id.as_str()) + .await?; + Ok(Json(())) +} + +/// List WWPNs for given controller. +#[utoipa::path( + post, + path="/controllers/:controller_id/wwpns", + context_path="/api/storage/zfcp", + responses( + (status = OK, description = "List of WWPNs", body=Vec) + ) +)] +async fn get_wwpns( + State(state): State>, + Path(controller_id): Path, +) -> Result>, Error> { + let result = state.client.get_wwpns(controller_id.as_str()).await?; + Ok(Json(result)) +} + +/// List LUNS for given controller and wwpn. +#[utoipa::path( + post, + path="/controllers/:controller_id/wwpns/:wwpn_id/luns", + context_path="/api/storage/zfcp", + responses( + (status = OK, description = "list of luns", body=Vec) + ) +)] +async fn get_luns( + State(state): State>, + Path((controller_id, wwpn_id)): Path<(String, String)>, +) -> Result>, Error> { + let result = state.client.get_luns(&controller_id, &wwpn_id).await?; + Ok(Json(result)) +} + +/// Activates a disk on given controller with given WWPN id and LUN id. +#[utoipa::path( + post, + path="/controllers/:controller_id/wwpns/:wwpn_id/luns/:lun_id/activate_disk", + context_path="/api/storage/zfcp", + responses( + (status = OK, description = "The activation was succesful.") + ) +)] +async fn activate_disk( + State(state): State>, + Path((controller_id, wwpn_id, lun_id)): Path<(String, String, String)>, +) -> Result, Error> { + state + .client + .activate_disk(&controller_id, &wwpn_id, &lun_id) + .await?; + Ok(Json(())) +} + +/// Deactivates disk on given controller with given WWPN id and LUN id. +#[utoipa::path( + post, + path="/controllers/:controller_id/wwpns/:wwpn_id/luns/:lun_id/deactivate_disk", + context_path="/api/storage/zfcp", + responses( + (status = OK, description = "The activation was succesful.") + ) +)] +async fn deactivate_disk( + State(state): State>, + Path((controller_id, wwpn_id, lun_id)): Path<(String, String, String)>, +) -> Result, Error> { + state + .client + .deactivate_disk(&controller_id, &wwpn_id, &lun_id) + .await?; + Ok(Json(())) +} + +/// Find zFCP devices in the system. +#[utoipa::path( + post, + path="/probe", + context_path="/api/storage/zfcp", + responses( + (status = OK, description = "The probing process ran successfully") + ) +)] +async fn probe(State(state): State>) -> Result, Error> { + Ok(Json(state.client.probe().await?)) +} diff --git a/rust/agama-server/src/storage/web/zfcp/stream.rs b/rust/agama-server/src/storage/web/zfcp/stream.rs new file mode 100644 index 0000000000..f4ec2002da --- /dev/null +++ b/rust/agama-server/src/storage/web/zfcp/stream.rs @@ -0,0 +1,291 @@ +// FIXME: the code is pretty similar to iscsi::stream and dasd::stream. Refactor the stream to reduce the repetition. + +use std::{collections::HashMap, task::Poll}; + +use agama_lib::{ + dbus::get_optional_property, + error::ServiceError, + property_from_dbus, + storage::{ + client::zfcp::ZFCPClient, + model::zfcp::{ZFCPController, ZFCPDisk}, + }, +}; +use futures_util::{ready, Stream}; +use pin_project::pin_project; +use thiserror::Error; +use tokio::sync::mpsc::unbounded_channel; +use tokio_stream::{wrappers::UnboundedReceiverStream, StreamExt}; +use zbus::zvariant::{ObjectPath, OwnedObjectPath, OwnedValue}; + +use crate::{ + dbus::{DBusObjectChange, DBusObjectChangesStream, ObjectsCache}, + web::Event, +}; + +#[derive(Debug, Error)] +enum ZFCPDiskStreamError { + #[error("Service error: {0}")] + Service(#[from] ServiceError), + #[error("Unknown ZFCP disk: {0}")] + UnknownDevice(OwnedObjectPath), +} + +/// This stream listens for changes in the collection of zFCP disks and emits +/// the updated objects. +/// +/// It relies on the [DBusObjectChangesStream] stream and uses a cache to avoid holding a bunch of +/// proxy objects. +#[pin_project] +pub struct ZFCPDiskStream { + dbus: zbus::Connection, + cache: ObjectsCache, + #[pin] + inner: UnboundedReceiverStream, +} + +impl ZFCPDiskStream { + /// Creates a new stream + /// + /// * `dbus`: D-Bus connection to listen on. + pub async fn new(dbus: &zbus::Connection) -> Result { + const MANAGER_PATH: &str = "/org/opensuse/Agama/Storage1"; + const NAMESPACE: &str = "/org/opensuse/Agama/Storage1/zfcp_disks"; + + let (tx, rx) = unbounded_channel(); + let mut stream = DBusObjectChangesStream::new( + dbus, + &ObjectPath::from_str_unchecked(MANAGER_PATH), + &ObjectPath::from_str_unchecked(NAMESPACE), + "org.opensuse.Agama.Storage1.ZFCP.Disk", + ) + .await?; + + tokio::spawn(async move { + while let Some(change) = stream.next().await { + let _ = tx.send(change); + } + }); + let rx = UnboundedReceiverStream::new(rx); + + let mut cache: ObjectsCache = Default::default(); + let client = ZFCPClient::new(dbus.clone()).await?; + for (path, device) in client.get_disks().await? { + cache.add(path.into(), device); + } + + Ok(Self { + dbus: dbus.clone(), + cache, + inner: rx, + }) + } + + fn update_device<'a>( + cache: &'a mut ObjectsCache, + path: &OwnedObjectPath, + values: &HashMap, + ) -> Result<&'a ZFCPDisk, ServiceError> { + let device = cache.find_or_create(path); + property_from_dbus!(device, name, "Name", values, str); + property_from_dbus!(device, channel, "Channel", values, str); + property_from_dbus!(device, wwpn, "WWPN", values, str); + property_from_dbus!(device, lun, "LUN", values, str); + Ok(device) + } + + fn remove_device( + cache: &mut ObjectsCache, + path: &OwnedObjectPath, + ) -> Result { + cache + .remove(path) + .ok_or_else(|| ZFCPDiskStreamError::UnknownDevice(path.clone())) + } + + fn handle_change( + cache: &mut ObjectsCache, + change: &DBusObjectChange, + ) -> Result { + match change { + DBusObjectChange::Added(path, values) => { + let device = Self::update_device(cache, path, values)?; + Ok(Event::ZFCPDiskAdded { + device: device.clone(), + }) + } + DBusObjectChange::Changed(path, updated) => { + let device = Self::update_device(cache, path, updated)?; + Ok(Event::ZFCPDiskChanged { + device: device.clone(), + }) + } + DBusObjectChange::Removed(path) => { + let device = Self::remove_device(cache, path)?; + Ok(Event::ZFCPDiskRemoved { device }) + } + } + } +} + +impl Stream for ZFCPDiskStream { + type Item = Event; + + fn poll_next( + self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + let mut pinned = self.project(); + + Poll::Ready(loop { + let change = ready!(pinned.inner.as_mut().poll_next(cx)); + let next_value = match change { + Some(change) => { + if let Ok(event) = Self::handle_change(pinned.cache, &change) { + Some(event) + } else { + log::warn!("Could not process change {:?}", &change); + None + } + } + None => break None, + }; + if next_value.is_some() { + break next_value; + } + }) + } +} + +#[derive(Debug, Error)] +enum ZFCPControllerStreamError { + #[error("Service error: {0}")] + Service(#[from] ServiceError), + #[error("Unknown ZFCP controller: {0}")] + UnknownDevice(OwnedObjectPath), +} + +/// This stream listens for changes in the collection of zFCP controllers and emits +/// the updated objects. +/// +/// It relies on the [DBusObjectChangesStream] stream and uses a cache to avoid holding a bunch of +/// proxy objects. +#[pin_project] +pub struct ZFCPControllerStream { + dbus: zbus::Connection, + cache: ObjectsCache, + #[pin] + inner: UnboundedReceiverStream, +} + +impl ZFCPControllerStream { + /// Creates a new stream + /// + /// * `dbus`: D-Bus connection to listen on. + pub async fn new(dbus: &zbus::Connection) -> Result { + const MANAGER_PATH: &str = "/org/opensuse/Agama/Storage1"; + const NAMESPACE: &str = "/org/opensuse/Agama/Storage1/zfcp_controllers"; + + let (tx, rx) = unbounded_channel(); + let mut stream = DBusObjectChangesStream::new( + dbus, + &ObjectPath::from_str_unchecked(MANAGER_PATH), + &ObjectPath::from_str_unchecked(NAMESPACE), + "org.opensuse.Agama.Storage1.ZFCP.Controller", + ) + .await?; + + tokio::spawn(async move { + while let Some(change) = stream.next().await { + let _ = tx.send(change); + } + }); + let rx = UnboundedReceiverStream::new(rx); + + let mut cache: ObjectsCache = Default::default(); + let client = ZFCPClient::new(dbus.clone()).await?; + for (path, device) in client.get_controllers().await? { + cache.add(path.into(), device); + } + + Ok(Self { + dbus: dbus.clone(), + cache, + inner: rx, + }) + } + + fn update_device<'a>( + cache: &'a mut ObjectsCache, + path: &OwnedObjectPath, + values: &HashMap, + ) -> Result<&'a ZFCPController, ServiceError> { + let device = cache.find_or_create(path); + property_from_dbus!(device, channel, "Channel", values, str); + property_from_dbus!(device, lun_scan, "LUNScan", values, bool); + property_from_dbus!(device, active, "Active", values, bool); + Ok(device) + } + + fn remove_device( + cache: &mut ObjectsCache, + path: &OwnedObjectPath, + ) -> Result { + cache + .remove(path) + .ok_or_else(|| ZFCPControllerStreamError::UnknownDevice(path.clone())) + } + + fn handle_change( + cache: &mut ObjectsCache, + change: &DBusObjectChange, + ) -> Result { + match change { + DBusObjectChange::Added(path, values) => { + let device = Self::update_device(cache, path, values)?; + Ok(Event::ZFCPControllerAdded { + device: device.clone(), + }) + } + DBusObjectChange::Changed(path, updated) => { + let device = Self::update_device(cache, path, updated)?; + Ok(Event::ZFCPControllerChanged { + device: device.clone(), + }) + } + DBusObjectChange::Removed(path) => { + let device = Self::remove_device(cache, path)?; + Ok(Event::ZFCPControllerRemoved { device }) + } + } + } +} + +impl Stream for ZFCPControllerStream { + type Item = Event; + + fn poll_next( + self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + let mut pinned = self.project(); + + Poll::Ready(loop { + let change = ready!(pinned.inner.as_mut().poll_next(cx)); + let next_value = match change { + Some(change) => { + if let Ok(event) = Self::handle_change(pinned.cache, &change) { + Some(event) + } else { + log::warn!("Could not process change {:?}", &change); + None + } + } + None => break None, + }; + if next_value.is_some() { + break next_value; + } + }) + } +} diff --git a/rust/agama-server/src/web/docs.rs b/rust/agama-server/src/web/docs.rs index c28827f949..22fe5fd343 100644 --- a/rust/agama-server/src/web/docs.rs +++ b/rust/agama-server/src/web/docs.rs @@ -49,6 +49,21 @@ use utoipa::OpenApi; crate::storage::web::iscsi::nodes, crate::storage::web::iscsi::update_initiator, crate::storage::web::iscsi::update_node, + crate::storage::web::dasd::probe, + crate::storage::web::dasd::supported, + crate::storage::web::dasd::devices, + crate::storage::web::dasd::format, + crate::storage::web::dasd::enable, + crate::storage::web::dasd::disable, + crate::storage::web::dasd::set_diag, + crate::storage::web::zfcp::supported, + crate::storage::web::zfcp::controllers, + crate::storage::web::zfcp::activate_controller, + crate::storage::web::zfcp::activate_disk, + crate::storage::web::zfcp::deactivate_disk, + crate::storage::web::zfcp::get_disks, + crate::storage::web::zfcp::get_wwpns, + crate::storage::web::zfcp::get_luns, crate::users::web::get_root_config, crate::users::web::get_user_config, crate::users::web::patch_root, @@ -88,6 +103,10 @@ use utoipa::OpenApi; schemas(agama_lib::storage::model::Volume), schemas(agama_lib::storage::model::VolumeOutline), schemas(agama_lib::storage::model::VolumeTarget), + schemas(agama_lib::storage::model::dasd::DASDDevice), + schemas(agama_lib::storage::model::dasd::DASDFormatSummary), + schemas(agama_lib::storage::model::zfcp::ZFCPDisk), + schemas(agama_lib::storage::model::zfcp::ZFCPController), schemas(agama_lib::storage::client::iscsi::ISCSIAuth), schemas(agama_lib::storage::client::iscsi::ISCSIInitiator), schemas(agama_lib::storage::client::iscsi::ISCSINode), diff --git a/rust/agama-server/src/web/event.rs b/rust/agama-server/src/web/event.rs index a9ce2d1460..0ff0c7d93e 100644 --- a/rust/agama-server/src/web/event.rs +++ b/rust/agama-server/src/web/event.rs @@ -6,8 +6,13 @@ use agama_lib::{ product::RegistrationRequirement, progress::Progress, software::SelectedBy, - storage::model::dasd::{DASDDevice, DASDFormatSummary}, - storage::ISCSINode, + storage::{ + model::{ + dasd::{DASDDevice, DASDFormatSummary}, + zfcp::{ZFCPController, ZFCPDisk}, + }, + ISCSINode, + }, users::FirstUser, }; use serde::Serialize; @@ -106,6 +111,24 @@ pub enum Event { job_id: String, summary: HashMap, }, + ZFCPDiskAdded { + device: ZFCPDisk, + }, + ZFCPDiskChanged { + device: ZFCPDisk, + }, + ZFCPDiskRemoved { + device: ZFCPDisk, + }, + ZFCPControllerAdded { + device: ZFCPController, + }, + ZFCPControllerChanged { + device: ZFCPController, + }, + ZFCPControllerRemoved { + device: ZFCPController, + }, } pub type EventsSender = Sender; diff --git a/rust/package/agama.changes b/rust/package/agama.changes index 8d2137f993..3ba296c6e8 100644 --- a/rust/package/agama.changes +++ b/rust/package/agama.changes @@ -1,3 +1,8 @@ +------------------------------------------------------------------- +Tue Sep 18 13:20:47 UTC 2024 - Josef Reidinger + +- Expose the zFCP D-Bus API through HTTP (gh#openSUSE/agama#1570). + ------------------------------------------------------------------- Wed Sep 18 08:27:13 UTC 2024 - Martin Vidner diff --git a/web/package/agama-web-ui.changes b/web/package/agama-web-ui.changes index cdee0f2f1e..dd9c438e2d 100644 --- a/web/package/agama-web-ui.changes +++ b/web/package/agama-web-ui.changes @@ -1,3 +1,8 @@ +------------------------------------------------------------------- +Tue Sep 17 21:19:45 UTC 2024 - Knut Anderssen + +- Bring back zFCP management support (gh#openSUSE/agama#1570). + ------------------------------------------------------------------- Mon Sep 16 05:40:25 UTC 2024 - Imobach Gonzalez Sosa diff --git a/web/src/api/dasd.ts b/web/src/api/storage/dasd.ts similarity index 96% rename from web/src/api/dasd.ts rename to web/src/api/storage/dasd.ts index 4178b3fb18..64958f351a 100644 --- a/web/src/api/dasd.ts +++ b/web/src/api/storage/dasd.ts @@ -30,7 +30,7 @@ const fetchDASDDevices = (): Promise => get("/api/storage/dasd/dev /** * Returns if DASD is supported at all */ -const DASDSupported = (): Promise => get("/api/storage/dasd/supported"); +const supportedDASD = (): Promise => get("/api/storage/dasd/supported"); /** * probes DASD devices @@ -79,7 +79,7 @@ const disableDiag = (devicesIDs: string[]) => export { fetchDASDDevices, - DASDSupported, + supportedDASD, formatDASD, probeDASD, enableDASD, diff --git a/web/src/api/storage/zfcp.ts b/web/src/api/storage/zfcp.ts new file mode 100644 index 0000000000..5a48b5f9b3 --- /dev/null +++ b/web/src/api/storage/zfcp.ts @@ -0,0 +1,91 @@ +/* + * 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 } from "~/api/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/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/components/core/EmptyState.jsx b/web/src/components/core/EmptyState.jsx index 490226433a..0a6ca1c002 100644 --- a/web/src/components/core/EmptyState.jsx +++ b/web/src/components/core/EmptyState.jsx @@ -22,7 +22,14 @@ // @ts-check import React from "react"; -import { EmptyState, EmptyStateHeader, EmptyStateBody, Stack } from "@patternfly/react-core"; +import { + EmptyState, + EmptyStateHeader, + EmptyStateBody, + Stack, + EmptyStateFooter, + EmptyStateActions, +} from "@patternfly/react-core"; import { Icon } from "~/components/layout"; /** @@ -44,6 +51,7 @@ import { Icon } from "~/components/layout"; * @param {string} [props.color="color-100"] * @param {EmptyStateHeaderProps["headingLevel"]} [props.headingLevel="h4"] * @param {boolean} [props.noPadding=false] + * @param {React.ReactNode} [props.actions] * @param {React.ReactNode} [props.children] * @param {EmptyStateProps} [props.rest] * @todo write documentation @@ -51,9 +59,10 @@ import { Icon } from "~/components/layout"; export default function EmptyStateWrapper({ title, icon, - color, + color = "color-100", headingLevel = "h4", noPadding = false, + actions, children, ...rest }) { @@ -74,6 +83,11 @@ export default function EmptyStateWrapper({ {children} )} + {actions && ( + + {actions} + + )} ); } diff --git a/web/src/components/core/ServerError.test.jsx b/web/src/components/core/ServerError.test.tsx similarity index 95% rename from web/src/components/core/ServerError.test.jsx rename to web/src/components/core/ServerError.test.tsx index a20e16d5ed..f5b2b6e631 100644 --- a/web/src/components/core/ServerError.test.jsx +++ b/web/src/components/core/ServerError.test.tsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2022] SUSE LLC + * Copyright (c) [2022-2024] SUSE LLC * * All Rights Reserved. * @@ -26,7 +26,7 @@ import { plainRender } from "~/test-utils"; import * as utils from "~/utils"; import { ServerError } from "~/components/core"; -describe.skip("ServerError", () => { +describe("ServerError", () => { it("includes a generic server problem message", () => { plainRender(); screen.getByText(/Cannot connect to Agama server/i); diff --git a/web/src/components/core/ServerError.jsx b/web/src/components/core/ServerError.tsx similarity index 65% rename from web/src/components/core/ServerError.jsx rename to web/src/components/core/ServerError.tsx index 0846b5b136..27a418225c 100644 --- a/web/src/components/core/ServerError.jsx +++ b/web/src/components/core/ServerError.tsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2022] SUSE LLC + * Copyright (c) [2022-2024] SUSE LLC * * All Rights Reserved. * @@ -32,29 +32,29 @@ import SimpleLayout from "~/SimpleLayout"; import { _ } from "~/i18n"; import { locationReload } from "~/utils"; -// TODO: refactor, and if possible use a route for it. Related with needed -// changes in src/App.jsx - const ErrorIcon = () => ; function ServerError() { return ( - -
- - } - /> - {_("Please, check whether it is running.")} - -
-
- - {_("Reload")} - + + +
+ + } + /> + {_("Please, check whether it is running.")} + +
+
+ + + {_("Reload")} + +
); } diff --git a/web/src/components/storage/DevicesTechMenu.test.jsx b/web/src/components/storage/DevicesTechMenu.test.tsx similarity index 72% rename from web/src/components/storage/DevicesTechMenu.test.jsx rename to web/src/components/storage/DevicesTechMenu.test.tsx index b46f4f2be8..1fccde5f06 100644 --- a/web/src/components/storage/DevicesTechMenu.test.jsx +++ b/web/src/components/storage/DevicesTechMenu.test.tsx @@ -22,68 +22,55 @@ import React from "react"; import { screen } from "@testing-library/react"; import { installerRender } from "~/test-utils"; -import { createClient } from "~/client"; import DevicesTechMenu from "./DevicesTechMenu"; -import { DASDSupported } from "~/api/dasd"; +import { _ } from "~/i18n"; +import { supportedDASD } from "~/api/storage/dasd"; +import { supportedZFCP } from "~/api/storage/zfcp"; -jest.mock("~/client"); -jest.mock("~/api/dasd"); - -const isZFCPSupportedFn = jest.fn(); - -const zfcp = { - isSupported: isZFCPSupportedFn, -}; +jest.mock("~/api/storage/dasd"); +jest.mock("~/api/storage/zfcp"); beforeEach(() => { - DASDSupported.mockResolvedValue(false); - isZFCPSupportedFn.mockResolvedValue(false); - - createClient.mockImplementation(() => { - return { - storage: { zfcp }, - }; - }); + (supportedDASD as jest.Mock).mockResolvedValue(false); + (supportedZFCP as jest.Mock).mockResolvedValue(false); }); it("contains an entry for configuring iSCSI", async () => { - const { user } = installerRender(); + const { user } = installerRender(); const toggler = screen.getByRole("button"); await user.click(toggler); const link = screen.getByRole("option", { name: /iSCSI/ }); expect(link).toHaveAttribute("href", "/storage/iscsi"); }); +it("does not contain an entry for configuring DASD when is NOT supported", async () => { + const { user } = installerRender(); + const toggler = screen.getByRole("button"); + await user.click(toggler); + expect(screen.queryByRole("option", { name: /DASD/ })).toBeNull(); +}); + it("contains an entry for configuring DASD when is supported", async () => { - DASDSupported.mockResolvedValue(true); - const { user } = installerRender(); + (supportedDASD as jest.Mock).mockResolvedValue(true); + const { user } = installerRender(); const toggler = screen.getByRole("button"); await user.click(toggler); const link = screen.getByRole("option", { name: /DASD/ }); expect(link).toHaveAttribute("href", "/storage/dasd"); }); -it("does not contain an entry for configuring DASD when is NOT supported", async () => { - DASDSupported.mockResolvedValue(false); - const { user } = installerRender(); +it("does not contain an entry for configuring zFCP when is NOT supported", async () => { + const { user } = installerRender(); const toggler = screen.getByRole("button"); await user.click(toggler); expect(screen.queryByRole("option", { name: /DASD/ })).toBeNull(); }); it("contains an entry for configuring zFCP when is supported", async () => { - isZFCPSupportedFn.mockResolvedValue(true); - const { user } = installerRender(); + (supportedZFCP as jest.Mock).mockResolvedValue(true); + const { user } = installerRender(); const toggler = screen.getByRole("button"); await user.click(toggler); const link = screen.getByRole("option", { name: /zFCP/ }); expect(link).toHaveAttribute("href", "/storage/zfcp"); }); - -it("does not contain an entry for configuring zFCP when is NOT supported", async () => { - isZFCPSupportedFn.mockResolvedValue(false); - const { user } = installerRender(); - const toggler = screen.getByRole("button"); - await user.click(toggler); - expect(screen.queryByRole("option", { name: /DASD/ })).toBeNull(); -}); diff --git a/web/src/components/storage/DevicesTechMenu.jsx b/web/src/components/storage/DevicesTechMenu.tsx similarity index 80% rename from web/src/components/storage/DevicesTechMenu.jsx rename to web/src/components/storage/DevicesTechMenu.tsx index 12449889b5..f925c0cb2b 100644 --- a/web/src/components/storage/DevicesTechMenu.jsx +++ b/web/src/components/storage/DevicesTechMenu.tsx @@ -19,18 +19,21 @@ * find current contact information at www.suse.com. */ -// @ts-check - import React, { useEffect, useState } from "react"; import { useHref } from "react-router-dom"; -import { MenuToggle, Select, SelectList, SelectOption } from "@patternfly/react-core"; +import { + MenuToggle, + MenuToggleElement, + Select, + SelectList, + SelectOption, +} from "@patternfly/react-core"; import { _ } from "~/i18n"; -import { useInstallerClient } from "~/context/installer"; -import { DASDSupported } from "~/api/dasd"; +import { supportedDASD } from "~/api/storage/dasd"; +import { supportedZFCP } from "~/api/storage/zfcp"; /** * Internal component for building the link to Storage/DASD page - * @component */ const DASDLink = () => { const href = useHref("/storage/dasd"); @@ -44,7 +47,6 @@ const DASDLink = () => { /** * Internal component for building the link to Storage/zFCP page - * @component */ const ZFCPLink = () => { const href = useHref("/storage/zfcp"); @@ -70,27 +72,23 @@ const ISCSILink = () => { ); }; +type ProposalMenuProps = { + label: string; +}; + /** * Component for rendering the options available from Storage/ProposalPage - * @component - * - * @typedef {object} ProposalMenuProps - * @property {string} label - * - * @param {ProposalMenuProps} props */ -export default function DevicesTechMenu({ label }) { +export default function DevicesTechMenu({ label }: ProposalMenuProps) { const [isOpen, setIsOpen] = useState(false); const [showDasdLink, setShowDasdLink] = useState(false); const [showZFCPLink, setShowZFCPLink] = useState(false); - const { storage: client } = useInstallerClient(); useEffect(() => { - DASDSupported().then(setShowDasdLink); - client.zfcp.isSupported().then(setShowZFCPLink); - }, [client.zfcp]); - - const toggle = (toggleRef) => ( + supportedDASD().then(setShowDasdLink); + supportedZFCP().then(setShowZFCPLink); + }, []); + const toggle = (toggleRef: React.Ref) => ( setIsOpen(!isOpen)} isExpanded={isOpen}> {label} diff --git a/web/src/components/storage/ZFCPPage.jsx b/web/src/components/storage/ZFCPPage.jsx deleted file mode 100644 index a39d4aba57..0000000000 --- a/web/src/components/storage/ZFCPPage.jsx +++ /dev/null @@ -1,740 +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. - */ - -// cspell:ignore wwpns npiv - -import React, { useCallback, useEffect, useReducer, useState } from "react"; -import { - Button, - Skeleton, - Stack, - Toolbar, - ToolbarContent, - ToolbarItem, -} from "@patternfly/react-core"; -import { Table, Thead, Tr, Th, Tbody, Td } from "@patternfly/react-table"; -import { Popup, RowActions, Section, SectionSkeleton } from "~/components/core"; -import { ZFCPDiskForm } from "~/components/storage"; -import { _ } from "~/i18n"; -import { noop, useCancellablePromise } from "~/utils"; -import { useInstallerClient } from "~/context/installer"; - -/** - * @typedef {import(~/clients/storage).ZFCPManager} ZFCPManager - * @typedef {import(~/clients/storage).ZFCPController} Controller - * @typedef {import(~/clients/storage).ZFCPDisk} Disk - * - * @typedef {object} LUN - * @property {string} channel - * @property {string} wwpn - * @property {string} lun - */ - -/** - * Internal class for managing zFCP data in a format that is useful for components. - */ -class Manager { - /** - * @param {Controller[]} controllers - * @param {Disk[]} disks - * @param {LUN[]} luns - */ - constructor(controllers = [], disks = [], luns = []) { - this.controllers = controllers; - this.disks = disks; - this.luns = luns; - } - - /** - * Gets the activated controllers. - * - * @returns {Controller[]} - */ - getActiveControllers() { - return this.controllers.filter((c) => c.active); - } - - /** - * Gets the controller for the given channel. - * - * @param {string} channel - * @returns {Controller|undefined} - */ - getController(channel) { - const index = this.findController(channel); - if (index === -1) return undefined; - - return this.controllers[index]; - } - - /** - * Updates the info about the given controller. - * - * @param {Controller} controller - * @returns {void} - */ - updateController(controller) { - const index = this.findController(controller.channel); - if (index === -1) return; - - this.controllers[index] = controller; - } - - /** - * Gets the disk for the given channel, WWPN and LUN. - * - * @param {string} channel - * @param {wwpn} wwpn - * @param {lun} lun - * @returns {Disk|undefined} - */ - getDisk(channel, wwpn, lun) { - const index = this.findDisk(channel, wwpn, lun); - if (index === -1) return undefined; - - return this.disks[index]; - } - - /** - * Adds the given disk to the list of disks. - * - * @param {Disk} disk - * @returns {void} - */ - addDisk(disk) { - if (this.getDisk(disk.channel, disk.wwpn, disk.lun)) return; - - this.disks.push(disk); - } - - /** - * Removes the given disk from the list of disks. - * @param {Disk} disk - * @returns {void} - */ - removeDisk(disk) { - const index = this.findDisk(disk.channel, disk.wwpn, disk.lun); - if (index === -1) return; - - this.disks.splice(index, 1); - } - - /** - * Adds the given LUNs - * - * @param {LUN[]} luns - * @returns {void} - */ - addLUNs(luns) { - for (const lun of luns) { - const existingLUN = this.luns.find( - (l) => l.channel === lun.channel && l.wwpn === lun.wwpn && l.lun === lun.lun, - ); - if (!existingLUN) this.luns.push(lun); - } - } - - /** - * Gets the list of inactive LUNs. - * - * @returns {LUN[]} - */ - getInactiveLUNs() { - const luns = this.getActiveControllers().map((controller) => { - return this.luns.filter( - (l) => l.channel === controller.channel && !this.isLUNActive(l.channel, l.wwpn, l.lun), - ); - }); - - return luns.flat(); - } - - /** - * Whether the LUN is active. - * - * @param {string} channel - * @param {string} wwpn - * @param {string} lun - * @returns {boolean} - */ - isLUNActive(channel, wwpn, lun) { - const disk = this.getDisk(channel, wwpn, lun); - return disk !== undefined; - } - - /** - * @private - * Index of the controller for the given channel. - * - * @param {string} channel - * @returns {number} - */ - findController(channel) { - return this.controllers.findIndex((c) => c.channel === channel); - } - - /** - * @private - * Index of the disk with the given channel, WWPN and LUN. - * - * @param {string} channel - * @param {string} wwpn - * @param {string} lun - * @returns {number} - */ - findDisk(channel, wwpn, lun) { - return this.disks.findIndex((d) => d.channel === channel && d.wwpn === wwpn && d.lun === lun); - } -} - -/** - * Generic table for zFCP devices. - * - * It shows a row as loading meanwhile performing an action. - * - * @component - * - * @param {object} props - * @param {Controller[]|Disk[]} [props.devices] - Devices to show in the table. - * @param {Column[]} [props.columns] - Columns to show. - * @param {ColumnValueFn} [props.columnValue] - Function to get the value of a column for a given device. - * @param {DeviceActionsFn} [props.actions] - Function to get the actions for a given device. - * - * @callback ColumnValueFn - * @param {Controller} controller - * @param {Column} Column - * @returns {string} - * - * @typedef {object} Column - * @property {string} id - * @property {string} label - * - * @callback DeviceActionsFn - * @param {Controller|Disk} device - * @returns {DeviceAction[]} - * - * @typedef {DeviceAction} - * @property {string} label - * @property {function} call - */ -const DevicesTable = ({ devices = [], columns = [], columnValue = noop, actions = noop }) => { - const [loadingRow, setLoadingRow] = useState(); - - const sortedDevices = () => { - return devices.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)} - - -
- ); -}; - -/** - * Table of zFCP controllers. - * @component - * - * @param {object} props - * @param {ZFCPManager} props.client - * @param {Manager} props.manager - */ -const ControllersTable = ({ client, manager }) => { - const { cancellablePromise } = useCancellablePromise(); - - const columns = [ - { id: "channel", label: _("Channel ID") }, - { id: "status", label: _("Status") }, - { id: "lunScan", label: _("Auto LUNs Scan") }, - ]; - - const columnValue = (controller, column) => { - let value; - - switch (column.id) { - case "channel": - value = controller.channel; - break; - case "status": - value = controller.active ? _("Activated") : _("Deactivated"); - break; - case "lunScan": - if (controller.active) value = controller.lunScan ? _("Yes") : _("No"); - else value = "-"; - break; - } - - return value; - }; - - const actions = (controller) => { - if (controller.active) return []; - - return [ - { - label: _("Activate"), - run: async () => await cancellablePromise(client.activateController(controller)), - }, - ]; - }; - - return ( - - ); -}; - -/** - * Table of zFCP disks. - * @component - * - * @param {object} props - * @param {ZFCPManager} props.client - * @param {Manager} props.manager - */ -const DisksTable = ({ client, manager }) => { - 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, column) => disk[column.id]; - - const actions = (disk) => { - const controller = manager.getController(disk.channel); - if (!controller || controller.lunScan) return []; - - return [ - { - label: _("Deactivate"), - run: async () => - await cancellablePromise(client.deactivateDisk(controller, disk.wwpn, disk.lun)), - }, - ]; - }; - - return ( - - ); -}; - -/** - * Section for zFCP controllers. - * @component - * - * @param {object} props - * @param {ZFCPManager} props.client - * @param {Manager} props.manager - * @param {function} props.load - Allows loading the zFCP data. - * @param {boolean} props.isLoading - */ -const ControllersSection = ({ client, manager, load = noop, isLoading = false }) => { - const [allowLUNScan, setAllowLUNScan] = useState(false); - - useEffect(() => { - const load = async () => { - const autoScan = await client.getAllowLUNScan(); - setAllowLUNScan(autoScan); - }; - - load(); - }, [client, setAllowLUNScan]); - - const EmptyState = () => { - return ( - -
{_("No zFCP controllers found.")}
-
{_("Please, try to read the zFCP devices again.")}
- {/* TRANSLATORS: button label */} - -
- ); - }; - - const Content = () => { - const LUNScanInfo = () => { - const msg = allowLUNScan - ? // TRANSLATORS: the text in the square brackets [] will be displayed in bold - _( - "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 - _( - "Automatic LUN scan is [disabled]. LUNs have to be manually \ -configured after activating a controller.", - ); - - const [msgStart, msgBold, msgEnd] = msg.split(/[[\]]/); - - return ( -

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

- ); - }; - - return ( - <> - - - - ); - }; - - return ( -
- {isLoading && } - {!isLoading && manager.controllers.length === 0 ? : } -
- ); -}; - -/** - * Popup to show the zFCP disk form. - * @component - * - * @param {object} props - * @param {ZFCPManager} props.client - * @param {Manager} props.manager - * @param {function} props.onClose - Callback to be called when closing the popup. - */ -const DiskPopup = ({ client, manager, onClose = noop }) => { - const [isAcceptDisabled, setIsAcceptDisabled] = useState(false); - const { cancellablePromise } = useCancellablePromise(); - - const onSubmit = async (formData) => { - setIsAcceptDisabled(true); - const controller = manager.getController(formData.channel); - const result = await cancellablePromise( - client.activateDisk(controller, formData.wwpn, formData.lun), - ); - setIsAcceptDisabled(false); - - if (result === 0) onClose(); - }; - - const onLoading = (isLoading) => { - setIsAcceptDisabled(isLoading); - }; - - const formId = "ZFCPDiskForm"; - - return ( - - - - - {_("Accept")} - - - - - ); -}; - -/** - * Section for zFCP disks. - * @component - * - * @param {object} props - * @param {ZFCPManager} props.client - * @param {Manager} props.manager - * @param {boolean} props.isLoading - */ -const DisksSection = ({ client, manager, isLoading = false }) => { - const [isActivateOpen, setIsActivateOpen] = useState(false); - - const openActivate = () => setIsActivateOpen(true); - const closeActivate = () => setIsActivateOpen(false); - - const EmptyState = () => { - const NoActiveControllers = () => { - return
{_("Please, try to activate a zFCP controller.")}
; - }; - - const NoActiveDisks = () => { - return ( - <> -
{_("Please, try to activate a zFCP disk.")}
- {/* TRANSLATORS: button label */} - - - ); - }; - - return ( - -
{_("No zFCP disks found.")}
- {manager.getActiveControllers().length === 0 ? : } -
- ); - }; - - const Content = () => { - const isDisabled = manager.getInactiveLUNs().length === 0; - - return ( - <> - - - - {/* TRANSLATORS: button label */} - - - - - - - - ); - }; - - return ( - // TRANSLATORS: section title -
- {isLoading && } - {!isLoading && manager.disks.length === 0 ? : } - {isActivateOpen && } -
- ); -}; - -const reducer = (state, action) => { - const { type, payload } = action; - - switch (type) { - case "START_LOADING": { - return { ...state, isLoading: true }; - } - - case "STOP_LOADING": { - return { ...state, isLoading: false }; - } - - case "SET_MANAGER": { - const { manager } = payload; - return { ...state, manager }; - } - - case "UPDATE_CONTROLLER": { - state.manager.updateController(payload.controller); - return { ...state }; - } - - case "ADD_DISK": { - const { disk } = payload; - state.manager.addDisk(disk); - return { ...state }; - } - - case "REMOVE_DISK": { - const { disk } = payload; - state.manager.removeDisk(disk); - return { ...state }; - } - - case "ADD_LUNS": { - const { luns } = payload; - state.manager.addLUNs(luns); - return { ...state }; - } - - default: { - return state; - } - } -}; - -const initialState = { - manager: new Manager(), - isLoading: true, -}; - -/** - * Page for managing zFCP devices. - * @component - */ -export default function ZFCPPage() { - const { storage: client } = useInstallerClient(); - const { cancellablePromise } = useCancellablePromise(); - const [state, dispatch] = useReducer(reducer, initialState); - - const getLUNs = useCallback( - async (controller) => { - const luns = []; - const wwpns = await cancellablePromise(client.zfcp.getWWPNs(controller)); - for (const wwpn of wwpns) { - const all = await cancellablePromise(client.zfcp.getLUNs(controller, wwpn)); - for (const lun of all) { - luns.push({ channel: controller.channel, wwpn, lun }); - } - } - return luns; - }, - [client.zfcp, cancellablePromise], - ); - - const load = useCallback(async () => { - dispatch({ type: "START_LOADING" }); - await cancellablePromise(client.zfcp.probe()); - const controllers = await cancellablePromise(client.zfcp.getControllers()); - const disks = await cancellablePromise(client.zfcp.getDisks()); - const luns = []; - for (const controller of controllers) { - if (controller.active && !controller.lunScan) { - luns.push(await getLUNs(controller)); - } - } - const manager = new Manager(controllers, disks, luns.flat()); - dispatch({ type: "SET_MANAGER", payload: { manager } }); - dispatch({ type: "STOP_LOADING" }); - }, [client.zfcp, cancellablePromise, getLUNs]); - - useEffect(() => { - load().catch(console.error); - }, [load]); - - useEffect(() => { - const subscriptions = []; - - const subscribe = async () => { - const action = (type, payload) => dispatch({ type, payload }); - - subscriptions.push( - await client.zfcp.onControllerChanged(async (controller) => { - action("UPDATE_CONTROLLER", { controller }); - if (controller.active && !controller.lunScan) { - const luns = await getLUNs(controller); - action("ADD_LUNS", { luns }); - } - }), - await client.zfcp.onDiskAdded((d) => action("ADD_DISK", { disk: d })), - await client.zfcp.onDiskRemoved((d) => action("REMOVE_DISK", { disk: d })), - ); - }; - - const unsubscribe = () => { - subscriptions.forEach((fn) => fn()); - }; - - subscribe(); - return unsubscribe; - }, [client.zfcp, cancellablePromise, getLUNs]); - - return ( - <> - - - - ); -} diff --git a/web/src/components/storage/ZFCPPage.test.jsx b/web/src/components/storage/ZFCPPage.test.jsx deleted file mode 100644 index ba2fac17f3..0000000000 --- a/web/src/components/storage/ZFCPPage.test.jsx +++ /dev/null @@ -1,381 +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 from "react"; -import { screen, waitFor, within } from "@testing-library/react"; -import { installerRender } from "~/test-utils"; -import { createClient } from "~/client"; -import { ZFCPPage } from "~/components/storage"; - -jest.mock("~/client"); -jest.mock("@patternfly/react-core", () => { - const original = jest.requireActual("@patternfly/react-core"); - - return { - ...original, - Skeleton: () =>
PFSkeleton
, - }; -}); - -const controllers = [ - { id: "1", channel: "0.0.fa00", active: false, lunScan: false }, - { id: "2", channel: "0.0.fb00", active: true, lunScan: false }, -]; - -const disks = [ - { - id: "1", - name: "/dev/sda", - channel: "0.0.fb00", - wwpn: "0x500507630703d3b3", - lun: "0x0000000000000001", - }, - { - id: "2", - name: "/dev/sdb", - channel: "0.0.fb00", - wwpn: "0x500507630703d3b3", - lun: "0x0000000000000002", - }, -]; - -const defaultClient = { - probe: jest.fn().mockResolvedValue(), - getControllers: jest.fn().mockResolvedValue(controllers), - getDisks: jest.fn().mockResolvedValue(disks), - getWWPNs: jest.fn().mockResolvedValue([]), - getLUNs: jest.fn().mockResolvedValue([]), - onControllerChanged: jest.fn().mockResolvedValue(jest.fn()), - onDiskAdded: jest.fn().mockResolvedValue(jest.fn()), - onDiskChanged: jest.fn().mockResolvedValue(jest.fn()), - onDiskRemoved: jest.fn().mockResolvedValue(jest.fn()), - activateController: jest.fn().mockResolvedValue(0), - getAllowLUNScan: jest.fn().mockResolvedValue(false), - activateDisk: jest.fn().mockResolvedValue(0), - deactivateDisk: jest.fn().mockResolvedValue(0), -}; - -let client; - -beforeEach(() => { - client = { ...defaultClient }; - createClient.mockImplementation(() => ({ storage: { zfcp: client } })); -}); - -it("renders two sections: Controllers and Disks", () => { - installerRender(); - - screen.findByRole("heading", { name: "Controllers" }); - screen.findByRole("heading", { name: "Disks" }); -}); - -it.skip("loads the zFCP devices", async () => { - client.getWWPNs = jest.fn().mockResolvedValue(["0x500507630703d3b3", "0x500507630704d3b3"]); - installerRender(); - - screen.getAllByText(/PFSkeleton/); - expect(screen.queryAllByRole("grid").length).toBe(0); - await waitFor(() => expect(client.probe).toHaveBeenCalled()); - await waitFor(() => - expect(client.getLUNs).toHaveBeenCalledWith(controllers[1], "0x500507630703d3b3"), - ); - await waitFor(() => - expect(client.getLUNs).toHaveBeenCalledWith(controllers[1], "0x500507630704d3b3"), - ); - await waitFor(() => - expect(client.getLUNs).not.toHaveBeenCalledWith(controllers[0], "0x500507630703d3b3"), - ); - await waitFor(() => - expect(client.getLUNs).not.toHaveBeenCalledWith(controllers[0], "0x500507630704d3b3"), - ); - expect(screen.getAllByRole("grid").length).toBe(2); -}); - -describe.skip("if allow-lun-scan is activated", () => { - beforeEach(() => { - client.getAllowLUNScan = jest.fn().mockResolvedValue(true); - }); - - it("renders an explanation about allow-lun-scan", async () => { - installerRender(); - - await screen.findByText(/automatically configures all its LUNs/); - }); -}); - -describe.skip("if allow-lun-scan is not activated", () => { - beforeEach(() => { - client.getAllowLUNScan = jest.fn().mockResolvedValue(false); - }); - - it("renders an explanation about not using allow-lun-scan", async () => { - installerRender(); - - await screen.findByText(/LUNs have to be manually configured/); - }); -}); - -describe.skip("if there are controllers", () => { - it("renders the information for each controller", async () => { - installerRender(); - - await screen.findByRole("row", { name: "0.0.fa00 Deactivated -" }); - await screen.findByRole("row", { name: "0.0.fb00 Activated No" }); - }); - - it("allows activating the controller", async () => { - const { user } = installerRender(); - - const row = await screen.findByRole("row", { name: /^0.0.fa00/ }); - const actions = within(row).getByRole("button", { name: "Actions" }); - await user.click(actions); - const activate = within(row).getByRole("menuitem", { name: "Activate" }); - await user.click(activate); - - await waitFor(() => expect(client.activateController).toHaveBeenCalledWith(controllers[0])); - }); - - it("does not allow activating an already activated controller", async () => { - installerRender(); - - const row = await screen.findByRole("row", { name: /^0.0.fb00/ }); - await waitFor(() => expect(within(row).queryByRole("button", { name: "Actions" })).toBeNull()); - }); -}); - -describe.skip("if there are not controllers", () => { - beforeEach(() => { - client.getControllers = jest.fn().mockResolvedValue([]); - }); - - it("does not render controllers information", async () => { - installerRender(); - - await waitFor(() => expect(screen.queryAllByRole("row", { name: /^0\.0\.f/ }).length).toBe(0)); - }); - - it("renders a button for reading zFCP devices", async () => { - installerRender(); - - await screen.findByText("No zFCP controllers found."); - await screen.findByRole("button", { name: /Read zFCP devices/ }); - }); - - it("loads the zFCP devices if the button is clicked", async () => { - const { user } = installerRender(); - - const button = await screen.findByRole("button", { name: /Read zFCP devices/ }); - - client.getControllers = jest.fn().mockResolvedValue(controllers); - - await user.click(button); - await waitFor(() => expect(client.probe).toHaveBeenCalled()); - await waitFor(() => expect(screen.getAllByRole("row", { name: /^0\.0\.f/ }).length).toBe(2)); - await waitFor(() => expect(screen.getAllByRole("row", { name: /^\/dev\// }).length).toBe(2)); - }); -}); - -describe.skip("if there are disks", () => { - beforeEach(() => { - client.getWWPNs = jest.fn().mockResolvedValue(["0x500507630703d3b3"]); - client.getLUNs = jest - .fn() - .mockResolvedValue(["0x0000000000000001", "0x0000000000000002", "0x0000000000000003"]); - }); - - it("renders the information for each disk", async () => { - installerRender(); - - await screen.findByRole("row", { - name: "/dev/sda 0.0.fb00 0x500507630703d3b3 0x0000000000000001", - }); - await screen.findByRole("row", { - name: "/dev/sdb 0.0.fb00 0x500507630703d3b3 0x0000000000000002", - }); - }); - - it("renders a button for activating a disk", async () => { - installerRender(); - - const button = await screen.findByRole("button", { name: "Activate new disk" }); - expect(button).toBeEnabled(); - }); - - describe("if there are not inactive LUNs", () => { - beforeEach(() => { - client.getWWPNs = jest.fn().mockResolvedValue(["0x500507630703d3b3"]); - client.getLUNs = jest.fn().mockResolvedValue(["0x0000000000000001", "0x0000000000000002"]); - }); - - it("disables the button for activating a disk", async () => { - installerRender(); - - const button = await screen.findByRole("button", { name: "Activate new disk" }); - expect(button).toBeDisabled(); - }); - }); - - describe("if the controller is not using auto LUN scan", () => { - beforeEach(() => { - client.getControllers = jest - .fn() - .mockResolvedValue([{ id: "1", channel: "0.0.fb00", active: true, lunScan: false }]); - }); - - it("allows deactivating a disk", async () => { - const { user } = installerRender(); - - const row = await screen.findByRole("row", { name: /^\/dev\/sda/ }); - const actions = within(row).getByRole("button", { name: "Actions" }); - await user.click(actions); - const deactivate = within(row).getByRole("menuitem", { name: "Deactivate" }); - await user.click(deactivate); - - const [controller] = await client.getControllers(); - const [disk] = await client.getDisks(); - - await waitFor(() => - expect(client.deactivateDisk).toHaveBeenCalledWith(controller, disk.wwpn, disk.lun), - ); - }); - }); - - describe("if the controller is using auto LUN scan", () => { - beforeEach(() => { - client.getControllers = jest - .fn() - .mockResolvedValue([{ id: "1", channel: "0.0.fb00", active: true, lunScan: true }]); - }); - - it("does not allow deactivating a disk", async () => { - installerRender(); - - const row = await screen.findByRole("row", { name: /^\/dev\/sda/ }); - waitFor(() => expect(within(row).queryByRole("button", { name: "Actions" })).toBeNull()); - }); - }); -}); - -describe.skip("if there are not disks", () => { - beforeEach(() => { - client.getDisks = jest.fn().mockResolvedValue([]); - }); - - it("does not render disks information", async () => { - installerRender(); - - await waitFor(() => expect(screen.queryAllByRole("row", { name: /^\/dev\// }).length).toBe(0)); - }); - - it("renders a button for activating a disk", async () => { - installerRender(); - - await screen.findByText("No zFCP disks found."); - await screen.findByText(/try to activate a zFCP disk/); - screen.findByRole("button", { name: "Activate zFCP disk" }); - }); - - describe("and there is no active controller", () => { - beforeEach(() => { - client.getControllers = jest.fn().mockResolvedValue([controllers[0]]); - }); - - it("does not render a button for activating a disk", async () => { - installerRender(); - - await screen.findByText("No zFCP disks found."); - await screen.findByText(/try to activate a zFCP controller/); - await waitFor(() => - expect(screen.queryByRole("button", { name: "Activate zFCP disk" })).toBeNull(), - ); - }); - }); -}); - -describe.skip("if the button for adding a disk is used", () => { - beforeEach(() => { - client.getWWPNs = jest.fn().mockResolvedValue(["0x500507630703d3b3"]); - client.getLUNs = jest - .fn() - .mockResolvedValue(["0x0000000000000001", "0x0000000000000002", "0x0000000000000003"]); - }); - - it("opens a popup with the form for a new disk", async () => { - const { user } = installerRender(); - - const button = await screen.findByRole("button", { name: "Activate new disk" }); - await user.click(button); - - const popup = await screen.findByRole("dialog"); - within(popup).getByText("Activate a zFCP disk"); - - const form = screen.getByRole("form"); - within(form).getByRole("combobox", { name: "Channel ID" }); - within(form).getByRole("combobox", { name: "WWPN" }); - within(form).getByRole("combobox", { name: "LUN" }); - }); - - it("only allows to select an active controller with inactive LUNs", async () => { - const { user } = installerRender(); - - const button = await screen.findByRole("button", { name: "Activate new disk" }); - await user.click(button); - const popup = await screen.findByRole("dialog"); - within(popup).getByText("Activate a zFCP disk"); - const form = screen.getByRole("form"); - - const channelSelector = within(form).getByRole("combobox", { name: "Channel ID" }); - expect(within(channelSelector).getAllByRole("option").length).toBe(1); - within(channelSelector).getByRole("option", { name: "0.0.fb00" }); - }); - - it("submits the form if accept is clicked", async () => { - const { user } = installerRender(); - - const button = await screen.findByRole("button", { name: "Activate new disk" }); - await user.click(button); - const popup = await screen.findByRole("dialog"); - - const accept = await within(popup).findByRole("button", { name: "Accept" }); - await user.click(accept); - - expect(client.activateDisk).toHaveBeenCalledWith( - controllers[1], - "0x500507630703d3b3", - "0x0000000000000003", - ); - expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); - }); - - it("closes the popup if cancel is clicked", async () => { - const { user } = installerRender(); - - const button = await screen.findByRole("button", { name: "Activate new disk" }); - await user.click(button); - const popup = await screen.findByRole("dialog"); - - const cancel = await within(popup).findByRole("button", { name: "Cancel" }); - await user.click(cancel); - - expect(client.activateDisk).not.toHaveBeenCalled(); - expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); - }); -}); diff --git a/web/src/components/storage/DASDFormatProgress.test.tsx b/web/src/components/storage/dasd/DASDFormatProgress.test.tsx similarity index 98% rename from web/src/components/storage/DASDFormatProgress.test.tsx rename to web/src/components/storage/dasd/DASDFormatProgress.test.tsx index 92f4b8d1a4..f8bc8f335e 100644 --- a/web/src/components/storage/DASDFormatProgress.test.tsx +++ b/web/src/components/storage/dasd/DASDFormatProgress.test.tsx @@ -28,7 +28,7 @@ import { DASDDevice, FormatJob } from "~/types/dasd"; let mockDASDFormatJobs: FormatJob[]; let mockDASDDevices: DASDDevice[]; -jest.mock("~/queries/dasd", () => ({ +jest.mock("~/queries/storage/dasd", () => ({ useDASDRunningFormatJobs: () => mockDASDFormatJobs, useDASDDevices: () => mockDASDDevices, })); diff --git a/web/src/components/storage/DASDFormatProgress.tsx b/web/src/components/storage/dasd/DASDFormatProgress.tsx similarity index 99% rename from web/src/components/storage/DASDFormatProgress.tsx rename to web/src/components/storage/dasd/DASDFormatProgress.tsx index 835bd56eaa..df0dd01127 100644 --- a/web/src/components/storage/DASDFormatProgress.tsx +++ b/web/src/components/storage/dasd/DASDFormatProgress.tsx @@ -23,7 +23,7 @@ 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 { useDASDDevices, useDASDRunningFormatJobs } from "~/queries/storage/dasd"; import { DASDDevice, FormatSummary } from "~/types/dasd"; const DeviceProgress = ({ device, progress }: { device: DASDDevice; progress: FormatSummary }) => ( diff --git a/web/src/components/storage/DASDPage.tsx b/web/src/components/storage/dasd/DASDPage.tsx similarity index 66% rename from web/src/components/storage/DASDPage.tsx rename to web/src/components/storage/dasd/DASDPage.tsx index 12c5195962..a4d1d4eaa4 100644 --- a/web/src/components/storage/DASDPage.tsx +++ b/web/src/components/storage/dasd/DASDPage.tsx @@ -20,11 +20,13 @@ */ import React from "react"; -import DASDTable from "~/components/storage/DASDTable"; -import DASDFormatProgress from "~/components/storage/DASDFormatProgress"; +import { Stack } from "@patternfly/react-core"; import { _ } from "~/i18n"; import { Page } from "~/components/core"; -import { useDASDDevicesChanges, useDASDFormatJobChanges } from "~/queries/dasd"; +import { useDASDDevicesChanges, useDASDFormatJobChanges } from "~/queries/storage/dasd"; +import { PATHS } from "~/routes/storage"; +import DASDTable from "./DASDTable"; +import DASDFormatProgress from "./DASDFormatProgress"; export default function DASDPage() { useDASDDevicesChanges(); @@ -37,9 +39,20 @@ export default function DASDPage() { - + {/** TRANSLATORS: DASD devices selection table */} + + + + + + + + + {_("Back to device selection")} + + ); } diff --git a/web/src/components/storage/DASDTable.test.tsx b/web/src/components/storage/dasd/DASDTable.test.tsx similarity index 94% rename from web/src/components/storage/DASDTable.test.tsx rename to web/src/components/storage/dasd/DASDTable.test.tsx index 6643e41029..ce7a1e1b94 100644 --- a/web/src/components/storage/DASDTable.test.tsx +++ b/web/src/components/storage/dasd/DASDTable.test.tsx @@ -22,12 +22,12 @@ 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"; +import DASDTable from "./DASDTable"; let mockDASDDevices: DASDDevice[] = []; -jest.mock("~/queries/dasd", () => ({ +jest.mock("~/queries/storage/dasd", () => ({ useDASDDevices: () => mockDASDDevices, useDASDMutation: () => jest.fn(), useFormatDASDMutation: () => jest.fn(), diff --git a/web/src/components/storage/DASDTable.tsx b/web/src/components/storage/dasd/DASDTable.tsx similarity index 99% rename from web/src/components/storage/DASDTable.tsx rename to web/src/components/storage/dasd/DASDTable.tsx index ef60f07ed7..37ea85464f 100644 --- a/web/src/components/storage/DASDTable.tsx +++ b/web/src/components/storage/dasd/DASDTable.tsx @@ -42,7 +42,7 @@ import { _ } from "~/i18n"; import { hex } from "~/utils"; import { sort } from "fast-sort"; import { DASDDevice } from "~/types/dasd"; -import { useDASDDevices, useDASDMutation, useFormatDASDMutation } from "~/queries/dasd"; +import { useDASDDevices, useDASDMutation, useFormatDASDMutation } from "~/queries/storage/dasd"; // FIXME: please, note that this file still requiring refinements until reach a // reasonable stable version diff --git a/web/src/components/storage/dasd/index.js b/web/src/components/storage/dasd/index.js new file mode 100644 index 0000000000..77f566d7ae --- /dev/null +++ b/web/src/components/storage/dasd/index.js @@ -0,0 +1,22 @@ +/* + * 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. + */ + +export { default as DASDPage } from "./DASDPage"; diff --git a/web/src/components/storage/index.js b/web/src/components/storage/index.js index 70fbc8a447..f3619a266d 100644 --- a/web/src/components/storage/index.js +++ b/web/src/components/storage/index.js @@ -24,11 +24,6 @@ export { default as ProposalSettingsSection } from "./ProposalSettingsSection"; export { default as ProposalTransactionalInfo } from "./ProposalTransactionalInfo"; export { default as ProposalActionsDialog } from "./ProposalActionsDialog"; export { default as ProposalResultSection } from "./ProposalResultSection"; -export { default as DASDPage } from "./DASDPage"; -export { default as DASDTable } from "./DASDTable"; -export { default as DASDFormatProgress } from "./DASDFormatProgress"; -export { default as ZFCPPage } from "./ZFCPPage"; -export { default as ZFCPDiskForm } from "./ZFCPDiskForm"; export { default as ISCSIPage } from "./ISCSIPage"; export { default as BootSelection } from "./BootSelection"; export { default as DeviceSelectorTable } from "./DeviceSelectorTable"; diff --git a/web/src/components/storage/zfcp/ZFCPControllersTable.test.tsx b/web/src/components/storage/zfcp/ZFCPControllersTable.test.tsx new file mode 100644 index 0000000000..80ccff0768 --- /dev/null +++ b/web/src/components/storage/zfcp/ZFCPControllersTable.test.tsx @@ -0,0 +1,80 @@ +/* + * 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 { 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 new file mode 100644 index 0000000000..0502b6ee9c --- /dev/null +++ b/web/src/components/storage/zfcp/ZFCPControllersTable.tsx @@ -0,0 +1,128 @@ +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 "~/utils"; +import { RowActions } from "../../core"; +import { ZFCPController } from "~/types/zfcp"; +import { activateZFCPController } from "~/api/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 new file mode 100644 index 0000000000..32564aef2d --- /dev/null +++ b/web/src/components/storage/zfcp/ZFCPDiskActivationPage.tsx @@ -0,0 +1,78 @@ +/* + * 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, { useState } from "react"; +import { Grid, GridItem } from "@patternfly/react-core"; +import { Page } from "~/components/core"; +import { _ } from "~/i18n"; +import { useCancellablePromise } from "~/utils"; +import { LUNInfo } from "~/types/zfcp"; +import { activateZFCPDisk } from "~/api/storage/zfcp"; +import { PATHS } from "~/routes/storage"; +import { useNavigate } from "react-router-dom"; +import ZFCPDiskForm from "./ZFCPDiskForm"; +import { useZFCPControllersChanges, useZFCPDisksChanges } from "~/queries/storage/zfcp"; + +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), + ); + if (result.status === 200) navigate(PATHS.zfcp.root); + + setIsAcceptDisabled(false); + return result; + }; + + const onLoading = (isLoading: boolean) => { + setIsAcceptDisabled(isLoading); + }; + + const formId = "ZFCPDiskForm"; + + return ( + + +

{_("zFCP Disk Activation")}

+
+ + + + + + + + + + + + + +
+ ); +} diff --git a/web/src/components/storage/ZFCPDiskForm.test.jsx b/web/src/components/storage/zfcp/ZFCPDiskForm.test.tsx similarity index 69% rename from web/src/components/storage/ZFCPDiskForm.test.jsx rename to web/src/components/storage/zfcp/ZFCPDiskForm.test.tsx index 101390775f..655c066291 100644 --- a/web/src/components/storage/ZFCPDiskForm.test.jsx +++ b/web/src/components/storage/zfcp/ZFCPDiskForm.test.tsx @@ -23,7 +23,8 @@ import React from "react"; import { screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { plainRender } from "~/test-utils"; -import { ZFCPDiskForm } from "~/components/storage"; +import { ZFCPDisk, ZFCPController } from "~/types/zfcp"; +import ZFCPDiskForm from "./ZFCPDiskForm"; // The form does not provide a submit button by itself. const FormWrapper = (props) => { @@ -35,25 +36,56 @@ const FormWrapper = (props) => { ); }; -const luns = [ - { channel: "0.0.fa00", wwpn: "0x500507630703d3b3", lun: "0x0000000000000001" }, - { channel: "0.0.fa00", wwpn: "0x500507630703d3b3", lun: "0x0000000000000002" }, - { channel: "0.0.fa00", wwpn: "0x500507630704d3b3", lun: "0x0000000000000010" }, - { channel: "0.0.fa00", wwpn: "0x500507630704d3b3", lun: "0x0000000000000020" }, - { channel: "0.0.fb00", wwpn: "0x500507630705d3b3", lun: "0x0000000000000100" }, - { channel: "0.0.fb00", wwpn: "0x500507630705d3b3", lun: "0x0000000000000200" }, +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", + }, ]; -let props = {}; +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"], + }, + }, +]; -beforeEach(() => { - props = { - id: "ZFCPDiskForm", - luns, - onSubmit: jest.fn().mockResolvedValue(0), - onLoading: jest.fn(), - }; -}); +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(); @@ -61,8 +93,7 @@ it("renders a form for selecting channel, WWPN and LUN", async () => { 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.fa00" }); - within(channelSelector).getByRole("option", { name: "0.0.fb00" }); + within(channelSelector).getByRole("option", { name: "0.0.fc00" }); within(form).getByRole("combobox", { name: "WWPN" }); within(form).getByRole("combobox", { name: "LUN" }); @@ -73,13 +104,13 @@ it("offers the WWPNs of the selected channel", async () => { const form = await screen.findByRole("form"); const channelSelector = within(form).getByRole("combobox", { name: "Channel ID" }); - const channelOption = within(channelSelector).getByRole("option", { name: "0.0.fb00" }); + 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: "0x500507630705d3b3" }); + within(wwpnSelector).getByRole("option", { name: "0x500507630b181216" }); }); it("offers the LUNs of the selected channel and WWPN", async () => { @@ -92,15 +123,14 @@ it("offers the LUNs of the selected channel and WWPN", async () => { await userEvent.selectOptions(channelSelector, channelOption); const wwpnSelector = within(form).getByRole("combobox", { name: "WWPN" }); - expect(within(wwpnSelector).getAllByRole("option").length).toBe(2); - const wwpnOption = within(wwpnSelector).getByRole("option", { name: "0x500507630704d3b3" }); + 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(2); - within(lunSelector).getByRole("option", { name: "0x0000000000000010" }); - within(lunSelector).getByRole("option", { name: "0x0000000000000020" }); + expect(within(lunSelector).getAllByRole("option").length).toBe(1); + within(lunSelector).getByRole("option", { name: "0x4020404900000001" }); }); describe("when the form is submitted", () => { @@ -111,16 +141,17 @@ describe("when the form is submitted", () => { await user.click(accept); expect(props.onSubmit).toHaveBeenCalledWith({ + id: "1", channel: "0.0.fa00", - wwpn: "0x500507630703d3b3", - lun: "0x0000000000000001", + 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(1); + props.onSubmit = jest.fn().mockResolvedValue({ status: 400 }); const { user } = plainRender(); diff --git a/web/src/components/storage/ZFCPDiskForm.jsx b/web/src/components/storage/zfcp/ZFCPDiskForm.tsx similarity index 67% rename from web/src/components/storage/ZFCPDiskForm.jsx rename to web/src/components/storage/zfcp/ZFCPDiskForm.tsx index e2a8597055..a738ff9b73 100644 --- a/web/src/components/storage/ZFCPDiskForm.jsx +++ b/web/src/components/storage/zfcp/ZFCPDiskForm.tsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2023] SUSE LLC + * Copyright (c) [2023-2024] SUSE LLC * * All Rights Reserved. * @@ -21,35 +21,38 @@ // cspell:ignore wwpns -import React, { useEffect, useState } from "react"; +import React, { FormEvent, useEffect, useState } from "react"; import { Alert, Form, FormGroup, FormSelect, FormSelectOption } from "@patternfly/react-core"; +import { AxiosResponseHeaders } from "axios"; +import { Page } from "~/components/core"; +import { useZFCPControllers, useZFCPDisks } from "~/queries/storage/zfcp"; +import { inactiveLuns } from "~/utils/zfcp"; import { _ } from "~/i18n"; -import { noop } from "~/utils"; + +type FormData = { + id?: string; + channel?: string; + wwpn?: string; + lun?: string; +}; /** * Form for activating a zFCP disk. - * @component - * - * @param {object} props - * @param {string} props.id - Form Id - * @param {import(~/components/storage/ZFCPPage).LUN[]} [props.luns] - * @param {onSubmitFn} [props.onSubmit] - Callback to be called when the form is submitted. - * @param {onLoadingFn} [props.onValidate] - Callback to be called when the form starts/stops loading. - * - * @callback onSubmitFn - * @param {FormData} - * @returns {number} 0 on success - * - * @typedef {object} FormData - * @property {string} channel - * @property {string} wwpn - * @property {string} lun - * - * @callback onLoadingFn - * @param {boolean} isLoading - Whether the form is loading. */ -export default function ZFCPDiskForm({ id, luns = [], onSubmit = noop, onLoading = noop }) { - const [formData, setFormData] = useState({}); +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); @@ -62,18 +65,22 @@ export default function ZFCPDiskForm({ id, luns = [], onSubmit = noop, onLoading return channels.sort(); }; - const getWWPNs = (channel) => { + 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, wwpn) => { + 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, wwpn, lun) => { + 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]; @@ -81,26 +88,27 @@ export default function ZFCPDiskForm({ id, luns = [], onSubmit = noop, onLoading if (channel) setFormData({ channel, wwpn, lun }); }; - const selectChannel = (_, channel) => select(channel); + const selectChannel = (_, channel: string) => select(channel); - const selectWWPN = (_, wwpn) => select(formData.channel, wwpn); + const selectWWPN = (_, wwpn: string) => select(formData.channel, wwpn); - const selectLUN = (_, lun) => select(formData.channel, formData.wwpn, lun); + const selectLUN = (_, lun: string) => select(formData.channel, formData.wwpn, lun); - const submit = async (event) => { + const submit = async (event: FormEvent) => { event.preventDefault(); setIsLoading(true); - const result = await onSubmit(formData); + const controller = controllers.find((c) => c.channel === formData.channel); + const result = await onSubmit({ id: controller.id, ...formData }); + setIsFailed(result.status !== 200); setIsLoading(false); - - setIsFailed(result !== 0); }; if (!formData.channel && getChannels().length > 0) select(); return ( - <> + // TRANSLATORS: zFCP disk activation form + {isFailed && (

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

@@ -136,6 +144,6 @@ export default function ZFCPDiskForm({ id, luns = [], onSubmit = noop, onLoading - +
); } diff --git a/web/src/components/storage/zfcp/ZFCPDiskTable.test.tsx b/web/src/components/storage/zfcp/ZFCPDiskTable.test.tsx new file mode 100644 index 0000000000..186626e6b7 --- /dev/null +++ b/web/src/components/storage/zfcp/ZFCPDiskTable.test.tsx @@ -0,0 +1,80 @@ +/* + * 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 { 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 new file mode 100644 index 0000000000..14dfd1f7dd --- /dev/null +++ b/web/src/components/storage/zfcp/ZFCPDisksTable.tsx @@ -0,0 +1,133 @@ +/* + * 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, { useState } from "react"; +import { deactivateZFCPDisk } from "~/api/storage/zfcp"; +import { useZFCPControllers, useZFCPDisks } from "~/queries/storage/zfcp"; +import { ZFCPDisk } from "~/types/zfcp"; +import { useCancellablePromise } from "~/utils"; +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/ZFCPPage.test.tsx b/web/src/components/storage/zfcp/ZFCPPage.test.tsx new file mode 100644 index 0000000000..81736b406c --- /dev/null +++ b/web/src/components/storage/zfcp/ZFCPPage.test.tsx @@ -0,0 +1,84 @@ +/* + * 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 { screen } from "@testing-library/react"; +import { installerRender } from "~/test-utils"; +import { ZFCPPage } from "~/components/storage/zfcp"; +import { ZFCPDisk, ZFCPController, ZFCPConfig } from "~/types/zfcp"; + +const mockZFCPConfig: ZFCPConfig = { + allowLunScan: false, +}; +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, +})); + +it("renders two sections: Controllers and Disks", () => { + installerRender(); + + screen.findByRole("heading", { name: "Controllers" }); + screen.findByRole("heading", { name: "Disks" }); +}); diff --git a/web/src/components/storage/zfcp/ZFCPPage.tsx b/web/src/components/storage/zfcp/ZFCPPage.tsx new file mode 100644 index 0000000000..748d5a3e84 --- /dev/null +++ b/web/src/components/storage/zfcp/ZFCPPage.tsx @@ -0,0 +1,204 @@ +/* + * 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 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. + */ + +// cspell:ignore npiv + +import React from "react"; +import { + Button, + Grid, + GridItem, + Toolbar, + ToolbarContent, + ToolbarItem, +} from "@patternfly/react-core"; +import { EmptyState, Page } from "~/components/core"; +import { _ } from "~/i18n"; +import { + useZFCPConfig, + useZFCPControllers, + useZFCPControllersChanges, + useZFCPDisks, + useZFCPDisksChanges, +} from "~/queries/storage/zfcp"; +import ZFCPDisksTable from "./ZFCPDisksTable"; +import ZFCPControllersTable from "./ZFCPControllersTable"; +import { probeZFCP } from "~/api/storage/zfcp"; +import { PATHS } from "~/routes/storage"; +import { useNavigate } from "react-router-dom"; +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(/[[\]]/); + + return ( +

+ {msgStart} + {msgBold} + {msgEnd} +

+ ); +}; + +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."); + + return ( + navigate(PATHS.zfcp.activateDisk)}> + {_("Activate zFCP disk")} + + ) + } + > + {body} + + ); +}; + +const Disks = () => { + const navigate = useNavigate(); + const disks = useZFCPDisks(); + const controllers = useZFCPControllers(); + const isDisabled = inactiveLuns(controllers, disks).length === 0; + + return ( + <> + + + + {/* TRANSLATORS: button label */} + + + + + + + + ); +}; + +/** + * Section for zFCP disks. + */ +const DisksSection = () => { + const disks = useZFCPDisks(); + + return ( + + {disks.length === 0 ? : } + + ); +}; + +/** + * Section for zFCP controllers. + */ +const ControllersSection = () => ( + + + + +); + +const Content = () => { + const controllers = useZFCPControllers(); + + if (controllers.length === 0) { + return ( + + {_("Read zFCP devices")} + + } + > +
{_("Please, try to activate a zFCP controller.")}
+
+ ); + } + + return ( + + + + + + + + + ); +}; + +/** + * Page for managing zFCP devices. + */ +export default function ZFCPPage() { + useZFCPControllersChanges(); + useZFCPDisksChanges(); + + return ( + + +

{_("zFCP")}

+
+ + + + + + + + {_("Back to device selection")} + + +
+ ); +} diff --git a/web/src/components/storage/zfcp/index.js b/web/src/components/storage/zfcp/index.js new file mode 100644 index 0000000000..7555773f8d --- /dev/null +++ b/web/src/components/storage/zfcp/index.js @@ -0,0 +1,23 @@ +/* + * 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. + */ + +export { default as ZFCPPage } from "./ZFCPPage"; +export { default as ZFCPDiskActivationPage } from "./ZFCPDiskActivationPage"; diff --git a/web/src/queries/dasd.ts b/web/src/queries/storage/dasd.ts similarity index 94% rename from web/src/queries/dasd.ts rename to web/src/queries/storage/dasd.ts index 444863942b..1b16969869 100644 --- a/web/src/queries/dasd.ts +++ b/web/src/queries/storage/dasd.ts @@ -27,7 +27,7 @@ import { enableDiag, fetchDASDDevices, formatDASD, -} from "~/api/dasd"; +} from "~/api/storage/dasd"; import { useInstallerClient } from "~/context/installer"; import React from "react"; import { hex } from "~/utils"; @@ -37,7 +37,7 @@ import { fetchStorageJobs } from "~/api/storage"; /** * Returns a query for retrieving the dasd devices */ -const DASDDevicesQuery = () => ({ +const dasdDevicesQuery = () => ({ queryKey: ["dasd", "devices"], queryFn: fetchDASDDevices, }); @@ -46,14 +46,14 @@ const DASDDevicesQuery = () => ({ * Hook that returns DASD devices. */ const useDASDDevices = () => { - const { data: devices } = useSuspenseQuery(DASDDevicesQuery()); + 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 = () => ({ +const dasdRunningFormatJobsQuery = () => ({ queryKey: ["dasd", "formatJobs", "running"], queryFn: () => fetchStorageJobs().then((jobs) => @@ -66,7 +66,7 @@ const DASDRunningFormatJobsQuery = () => ({ * Hook that returns and specific DASD format job. */ const useDASDRunningFormatJobs = (): FormatJob[] => { - const { data: jobs } = useSuspenseQuery(DASDRunningFormatJobsQuery()); + const { data: jobs } = useSuspenseQuery(dasdRunningFormatJobsQuery()); return jobs; }; @@ -117,7 +117,7 @@ const useDASDFormatJobChanges = () => { }); }); - const { data: jobs } = useSuspenseQuery(DASDRunningFormatJobsQuery()); + const { data: jobs } = useSuspenseQuery(dasdRunningFormatJobsQuery()); return jobs; }; @@ -165,7 +165,7 @@ const useDASDDevicesChanges = () => { }); }); - const { data: devices } = useSuspenseQuery(DASDDevicesQuery()); + const { data: devices } = useSuspenseQuery(dasdDevicesQuery()); return devices; }; diff --git a/web/src/queries/storage/zfcp.ts b/web/src/queries/storage/zfcp.ts new file mode 100644 index 0000000000..d2e1a1cd8e --- /dev/null +++ b/web/src/queries/storage/zfcp.ts @@ -0,0 +1,164 @@ +/* + * 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 { useQueryClient, useSuspenseQuery } from "@tanstack/react-query"; +import { + supportedZFCP, + fetchZFCPConfig, + fetchZFCPControllers, + fetchZFCPDisks, +} from "~/api/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 zFCP config. + */ +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.ws().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)); + } + } + }, + ); + }); + }, [client, queryClient]); +}; + +/** + * Listens for zFCP disks changes. + */ +const useZFCPDisksChanges = () => { + const client = useInstallerClient(); + const queryClient = useQueryClient(); + + React.useEffect(() => { + if (!client) return; + + return client.ws().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/routes/storage.tsx b/web/src/routes/storage.tsx index 0df131b543..476304395e 100644 --- a/web/src/routes/storage.tsx +++ b/web/src/routes/storage.tsx @@ -21,14 +21,16 @@ import React from "react"; import BootSelection from "~/components/storage/BootSelection"; -import DeviceSelection from "~/components/storage/DeviceSelection"; import SpacePolicySelection from "~/components/storage/SpacePolicySelection"; -import { DASDPage, ISCSIPage } from "~/components/storage"; -import ProposalPage from "~/components/storage/ProposalPage"; +import { DeviceSelection, ISCSIPage, ProposalPage } from "~/components/storage"; + import { Route } from "~/types/routes"; import { N_ } from "~/i18n"; -import { DASDSupported, probeDASD } from "~/api/dasd"; +import { supportedDASD, probeDASD } from "~/api/storage/dasd"; +import { probeZFCP, supportedZFCP } from "~/api/storage/zfcp"; import { redirect } from "react-router-dom"; +import { ZFCPPage, ZFCPDiskActivationPage } from "~/components/storage/zfcp"; +import { DASDPage } from "~/components/storage/dasd"; const PATHS = { root: "/storage", @@ -37,6 +39,10 @@ const PATHS = { spacePolicy: "/storage/space-policy", iscsi: "/storage/iscsi", dasd: "/storage/dasd", + zfcp: { + root: "/storage/zfcp", + activateDisk: "/storage/zfcp/active-disk", + }, }; const routes = (): Route => ({ @@ -69,10 +75,27 @@ const routes = (): Route => ({ element: , handle: { name: N_("DASD") }, loader: async () => { - if (!DASDSupported()) return redirect(PATHS.root); + if (!supportedDASD()) return redirect(PATHS.targetDevice); return probeDASD(); }, }, + { + path: PATHS.zfcp.root, + element: , + handle: { name: N_("ZFCP") }, + loader: async () => { + if (!supportedZFCP()) return redirect(PATHS.targetDevice); + return probeZFCP(); + }, + }, + { + path: PATHS.zfcp.activateDisk, + element: , + loader: async () => { + if (!supportedZFCP()) return redirect(PATHS.targetDevice); + return probeZFCP(); + }, + }, ], }); diff --git a/web/src/types/zfcp.ts b/web/src/types/zfcp.ts new file mode 100644 index 0000000000..3d046d1d8a --- /dev/null +++ b/web/src/types/zfcp.ts @@ -0,0 +1,47 @@ +/* + * Copyright (c) [2024] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +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 new file mode 100644 index 0000000000..049a1a88bc --- /dev/null +++ b/web/src/utils/zfcp.test.ts @@ -0,0 +1,86 @@ +/* + * 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 { 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 new file mode 100644 index 0000000000..49dbe8f8b6 --- /dev/null +++ b/web/src/utils/zfcp.ts @@ -0,0 +1,45 @@ +/* + * 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 { 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 };