diff --git a/rust/agama-lib/src/auth.rs b/rust/agama-lib/src/auth.rs index b3f403edaa..b6e972a358 100644 --- a/rust/agama-lib/src/auth.rs +++ b/rust/agama-lib/src/auth.rs @@ -45,7 +45,7 @@ const USER_TOKEN_PATH: &str = ".local/agama/token"; const AGAMA_TOKEN_FILE: &str = "/run/agama/token"; use std::{ - fmt::Display, + fmt, fs::{self, File}, io::{self, BufRead, BufReader, Write}, os::unix::fs::OpenOptionsExt, @@ -184,8 +184,8 @@ impl AuthToken { } } -impl Display for AuthToken { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +impl fmt::Display for AuthToken { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", self.0) } } @@ -223,6 +223,16 @@ impl ClientId { pub fn new() -> Self { ClientId(Uuid::new_v4()) } + + pub fn new_from_uuid(uuid: Uuid) -> Self { + ClientId(uuid) + } +} + +impl fmt::Display for ClientId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } } #[cfg(test)] diff --git a/rust/agama-lib/src/http/event.rs b/rust/agama-lib/src/http/event.rs index 1072a4424e..fb4e92ae21 100644 --- a/rust/agama-lib/src/http/event.rs +++ b/rust/agama-lib/src/http/event.rs @@ -103,6 +103,7 @@ pub enum EventPayload { #[serde(flatten)] change: NetworkChange, }, + StorageChanged, // TODO: it should include the full software proposal or, at least, // all the relevant changes. SoftwareProposalChanged { diff --git a/rust/agama-lib/src/manager.rs b/rust/agama-lib/src/manager.rs index da7fa8b393..56dfc8cfc7 100644 --- a/rust/agama-lib/src/manager.rs +++ b/rust/agama-lib/src/manager.rs @@ -23,6 +23,7 @@ pub mod http_client; pub use http_client::ManagerHTTPClient; use serde::{Deserialize, Serialize}; +use std::collections::HashMap; use crate::error::ServiceError; use crate::proxies::ServiceStatusProxy; @@ -141,15 +142,21 @@ impl<'a> ManagerClient<'a> { } /// Starts the probing process. - pub async fn probe(&self) -> Result<(), ServiceError> { + pub async fn probe(&self, client_id: String) -> Result<(), ServiceError> { self.wait().await?; - Ok(self.manager_proxy.probe().await?) + Ok(self + .manager_proxy + .probe(HashMap::from([("client_id", &client_id.into())])) + .await?) } /// Starts the reprobing process. - pub async fn reprobe(&self) -> Result<(), ServiceError> { + pub async fn reprobe(&self, client_id: String) -> Result<(), ServiceError> { self.wait().await?; - Ok(self.manager_proxy.reprobe().await?) + Ok(self + .manager_proxy + .reprobe(HashMap::from([("client_id", &client_id.into())])) + .await?) } /// Starts the installation. diff --git a/rust/agama-lib/src/proxies/manager1.rs b/rust/agama-lib/src/proxies/manager1.rs index f852a7fa26..81b50d4e66 100644 --- a/rust/agama-lib/src/proxies/manager1.rs +++ b/rust/agama-lib/src/proxies/manager1.rs @@ -39,10 +39,16 @@ pub trait Manager1 { fn finish(&self, method: &str) -> zbus::Result; /// Probe method - fn probe(&self) -> zbus::Result<()>; + fn probe( + &self, + data: std::collections::HashMap<&str, &zbus::zvariant::Value<'_>>, + ) -> zbus::Result<()>; /// Reprobe method - fn reprobe(&self) -> zbus::Result<()>; + fn reprobe( + &self, + data: std::collections::HashMap<&str, &zbus::zvariant::Value<'_>>, + ) -> zbus::Result<()>; /// BusyServices property #[zbus(property)] diff --git a/rust/agama-lib/src/storage/client.rs b/rust/agama-lib/src/storage/client.rs index 34cd4bba93..6f40de592d 100644 --- a/rust/agama-lib/src/storage/client.rs +++ b/rust/agama-lib/src/storage/client.rs @@ -150,31 +150,50 @@ impl<'a> StorageClient<'a> { } /// Runs the probing process - pub async fn probe(&self) -> Result<(), ServiceError> { - Ok(self.storage_proxy.probe().await?) + pub async fn probe(&self, client_id: String) -> Result<(), ServiceError> { + Ok(self + .storage_proxy + .probe(HashMap::from([("client_id", &client_id.into())])) + .await?) } /// Runs the reprobing process - pub async fn reprobe(&self) -> Result<(), ServiceError> { - Ok(self.storage_proxy.reprobe().await?) + pub async fn reprobe(&self, client_id: String) -> Result<(), ServiceError> { + Ok(self + .storage_proxy + .reprobe(HashMap::from([("client_id", &client_id.into())])) + .await?) } /// Runs the reactivation process - pub async fn reactivate(&self) -> Result<(), ServiceError> { - Ok(self.storage_proxy.reactivate().await?) + pub async fn reactivate(&self, client_id: String) -> Result<(), ServiceError> { + Ok(self + .storage_proxy + .reactivate(HashMap::from([("client_id", &client_id.into())])) + .await?) } /// Set the storage config according to the JSON schema - pub async fn set_config(&self, settings: StorageSettings) -> Result { + pub async fn set_config( + &self, + settings: StorageSettings, + client_id: String, + ) -> Result { Ok(self .storage_proxy - .set_config(serde_json::to_string(&settings)?.as_str()) + .set_config( + serde_json::to_string(&settings)?.as_str(), + HashMap::from([("client_id", &client_id.into())]), + ) .await?) } /// Reset the storage config to the default value - pub async fn reset_config(&self) -> Result { - Ok(self.storage_proxy.reset_config().await?) + pub async fn reset_config(&self, client_id: String) -> Result { + Ok(self + .storage_proxy + .reset_config(HashMap::from([("client_id", &client_id.into())])) + .await?) } /// Get the storage config according to the JSON schema @@ -185,10 +204,17 @@ impl<'a> StorageClient<'a> { } /// Set the storage config model according to the JSON schema - pub async fn set_config_model(&self, model: Box) -> Result { + pub async fn set_config_model( + &self, + model: Box, + client_id: String, + ) -> Result { Ok(self .storage_proxy - .set_config_model(serde_json::to_string(&model).unwrap().as_str()) + .set_config_model( + serde_json::to_string(&model).unwrap().as_str(), + HashMap::from([("client_id", &client_id.into())]), + ) .await?) } diff --git a/rust/agama-lib/src/storage/proxies/storage1.rs b/rust/agama-lib/src/storage/proxies/storage1.rs index 19128aa144..d8b59af15b 100644 --- a/rust/agama-lib/src/storage/proxies/storage1.rs +++ b/rust/agama-lib/src/storage/proxies/storage1.rs @@ -47,6 +47,10 @@ use zbus::proxy; assume_defaults = true )] pub trait Storage1 { + /// Storage configured signal + #[zbus(signal)] + fn configured(&self, client_id: &str) -> zbus::Result<()>; + /// Finish method fn finish(&self) -> zbus::Result<()>; @@ -54,26 +58,46 @@ pub trait Storage1 { fn install(&self) -> zbus::Result<()>; /// Probe method - fn probe(&self) -> zbus::Result<()>; + fn probe( + &self, + data: std::collections::HashMap<&str, &zbus::zvariant::Value<'_>>, + ) -> zbus::Result<()>; /// Reprobe method - fn reprobe(&self) -> zbus::Result<()>; + fn reprobe( + &self, + data: std::collections::HashMap<&str, &zbus::zvariant::Value<'_>>, + ) -> zbus::Result<()>; /// Reactivate method - fn reactivate(&self) -> zbus::Result<()>; + fn reactivate( + &self, + data: std::collections::HashMap<&str, &zbus::zvariant::Value<'_>>, + ) -> zbus::Result<()>; /// Set the storage config according to the JSON schema - fn set_config(&self, settings: &str) -> zbus::Result; + fn set_config( + &self, + settings: &str, + data: std::collections::HashMap<&str, &zbus::zvariant::Value<'_>>, + ) -> zbus::Result; /// Reset the storage config to the default value - fn reset_config(&self) -> zbus::Result; + fn reset_config( + &self, + data: std::collections::HashMap<&str, &zbus::zvariant::Value<'_>>, + ) -> zbus::Result; + + /// Set the storage config model according to the JSON schema + fn set_config_model( + &self, + model: &str, + data: std::collections::HashMap<&str, &zbus::zvariant::Value<'_>>, + ) -> zbus::Result; /// Get the current storage config according to the JSON schema fn get_config(&self) -> zbus::Result; - /// Set the storage config model according to the JSON schema - fn set_config_model(&self, model: &str) -> zbus::Result; - /// Get the storage config model according to the JSON schema fn get_config_model(&self) -> zbus::Result; diff --git a/rust/agama-server/src/manager/web.rs b/rust/agama-server/src/manager/web.rs index 201352876b..3cc7a49bc4 100644 --- a/rust/agama-server/src/manager/web.rs +++ b/rust/agama-server/src/manager/web.rs @@ -26,6 +26,7 @@ //! * `manager_stream` which offers an stream that emits the manager events coming from D-Bus. use agama_lib::{ + auth::ClientId, error::ServiceError, event, logs, manager::{FinishMethod, InstallationPhase, InstallerStatus, ManagerClient}, @@ -38,9 +39,11 @@ use axum::{ http::{header, status::StatusCode, HeaderMap, HeaderValue}, response::IntoResponse, routing::{get, post}, - Json, Router, + Extension, Json, Router, }; +use std::collections::HashMap; use std::pin::Pin; +use std::sync::Arc; use tokio_stream::{Stream, StreamExt}; use tokio_util::io::ReaderStream; @@ -129,7 +132,10 @@ pub async fn manager_service( ) ) )] -async fn probe_action(State(state): State>) -> Result<(), Error> { +async fn probe_action( + State(state): State>, + Extension(client_id): Extension>, +) -> Result<(), Error> { let dbus = state.dbus.clone(); tokio::spawn(async move { let result = dbus @@ -138,7 +144,7 @@ async fn probe_action(State(state): State>) -> Result<(), Error "/org/opensuse/Agama/Manager1", Some("org.opensuse.Agama.Manager1"), "Probe", - &(), + &HashMap::from([("client_id", client_id.to_string())]), ) .await; if let Err(error) = result { @@ -158,8 +164,11 @@ async fn probe_action(State(state): State>) -> Result<(), Error (status = 200, description = "Probing done.") ) )] -async fn probe_sync_action(State(state): State>) -> Result<(), Error> { - state.manager.probe().await?; +async fn probe_sync_action( + State(state): State>, + Extension(client_id): Extension>, +) -> Result<(), Error> { + state.manager.probe(client_id.to_string()).await?; Ok(()) } @@ -172,8 +181,11 @@ async fn probe_sync_action(State(state): State>) -> Result<(), (status = 200, description = "Re-probing done.") ) )] -async fn reprobe_sync_action(State(state): State>) -> Result<(), Error> { - state.manager.reprobe().await?; +async fn reprobe_sync_action( + State(state): State>, + Extension(client_id): Extension>, +) -> Result<(), Error> { + state.manager.reprobe(client_id.to_string()).await?; Ok(()) } diff --git a/rust/agama-server/src/storage/web.rs b/rust/agama-server/src/storage/web.rs index b08807d04f..fbac63e58c 100644 --- a/rust/agama-server/src/storage/web.rs +++ b/rust/agama-server/src/storage/web.rs @@ -48,6 +48,7 @@ use iscsi::storage_iscsi_service; use serde::{Deserialize, Serialize}; use serde_json::value::RawValue; use tokio_stream::{Stream, StreamExt}; +use uuid::Uuid; use zfcp::{zfcp_service, zfcp_stream}; pub mod dasd; @@ -67,10 +68,16 @@ use crate::{ }; pub async fn storage_streams(dbus: zbus::Connection) -> Result { - let mut result: EventStreams = vec![( - "devices_dirty", - Box::pin(devices_dirty_stream(dbus.clone()).await?), - )]; + let mut result: EventStreams = vec![ + ( + "devices_dirty", + Box::pin(devices_dirty_stream(dbus.clone()).await?), + ), + ( + "configured", + Box::pin(configured_stream(dbus.clone()).await?), + ), + ]; let mut iscsi = iscsi_stream(&dbus).await?; let mut dasd = dasd_stream(&dbus).await?; let mut zfcp = zfcp_stream(&dbus).await?; @@ -96,6 +103,19 @@ async fn devices_dirty_stream(dbus: zbus::Connection) -> Result Result, Error> { + let proxy = Storage1Proxy::new(&dbus).await?; + let stream = proxy.receive_configured().await?.filter_map(|signal| { + if let Ok(args) = signal.args() { + if let Ok(uuid) = Uuid::parse_str(args.client_id) { + return Some(event!(StorageChanged, &ClientId::new_from_uuid(uuid))); + } + } + None + }); + Ok(stream) +} + #[derive(Clone)] struct StorageState<'a> { client: StorageClient<'a>, @@ -197,11 +217,12 @@ async fn get_config( )] async fn set_config( State(state): State>, + Extension(client_id): Extension>, Json(settings): Json, ) -> Result, Error> { let _status: u32 = state .client - .set_config(settings) + .set_config(settings, client_id.to_string()) .await .map_err(Error::Service)?; Ok(Json(())) @@ -246,8 +267,15 @@ async fn get_config_model( (status = 400, description = "The D-Bus service could not perform the action") ) )] -async fn reset_config(State(state): State>) -> Result, Error> { - let _status: u32 = state.client.reset_config().await.map_err(Error::Service)?; +async fn reset_config( + State(state): State>, + Extension(client_id): Extension>, +) -> Result, Error> { + let _status: u32 = state + .client + .reset_config(client_id.to_string()) + .await + .map_err(Error::Service)?; Ok(Json(())) } @@ -268,11 +296,12 @@ async fn reset_config(State(state): State>) -> Result, )] async fn set_config_model( State(state): State>, + Extension(client_id): Extension>, Json(model): Json>, ) -> Result, Error> { let _status: u32 = state .client - .set_config_model(model) + .set_config_model(model, client_id.to_string()) .await .map_err(Error::Service)?; Ok(Json(())) @@ -319,8 +348,11 @@ struct SolveModelQuery { ), operation_id = "storage_probe" )] -async fn probe(State(state): State>) -> Result, Error> { - Ok(Json(state.client.probe().await?)) +async fn probe( + State(state): State>, + Extension(client_id): Extension>, +) -> Result, Error> { + Ok(Json(state.client.probe(client_id.to_string()).await?)) } /// Reprobes the storage devices. @@ -334,8 +366,11 @@ async fn probe(State(state): State>) -> Result, Error> ), operation_id = "storage_reprobe" )] -async fn reprobe(State(state): State>) -> Result, Error> { - Ok(Json(state.client.reprobe().await?)) +async fn reprobe( + State(state): State>, + Extension(client_id): Extension>, +) -> Result, Error> { + Ok(Json(state.client.reprobe(client_id.to_string()).await?)) } /// Reactivate the storage devices. @@ -349,8 +384,11 @@ async fn reprobe(State(state): State>) -> Result, Erro ), operation_id = "storage_reactivate" )] -async fn reactivate(State(state): State>) -> Result, Error> { - Ok(Json(state.client.reactivate().await?)) +async fn reactivate( + State(state): State>, + Extension(client_id): Extension>, +) -> Result, Error> { + Ok(Json(state.client.reactivate(client_id.to_string()).await?)) } /// Gets whether the system is in a deprecated status. diff --git a/service/lib/agama/dbus/base_object.rb b/service/lib/agama/dbus/base_object.rb index 79c8db4884..07a853cd39 100644 --- a/service/lib/agama/dbus/base_object.rb +++ b/service/lib/agama/dbus/base_object.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2021] SUSE LLC +# Copyright (c) [2021-2025] SUSE LLC # # All Rights Reserved. # @@ -38,6 +38,27 @@ def initialize(path, logger: nil) # @return [Logger] attr_reader :logger + + # Extra data provided to the D-Bus call (e.g., the client_id requesting the action). + # + # @return [Hash] + def request_data + @request_data ||= {} + end + + # Executes a block ensuring the given request data is available during the process. + # + # Saving the request data is needed in order to have it available while emitting signals as + # part of the block execution. + # + # @param data [Hash] Extra data, see {#request_data}. + # @param block [Proc] + def request(data = {}, &block) + @request_data = data + block.call + ensure + @request_data = {} + end end end end diff --git a/service/lib/agama/dbus/clients/storage.rb b/service/lib/agama/dbus/clients/storage.rb index fcab970a98..66b00ca95d 100644 --- a/service/lib/agama/dbus/clients/storage.rb +++ b/service/lib/agama/dbus/clients/storage.rb @@ -48,14 +48,17 @@ def service_name # If a block is given, the method returns immediately and the probing is performed in an # asynchronous way. # + # @param data [Hash] Extra data provided to the D-Bus call. # @param done [Proc] Block to execute once the probing is done - def probe(&done) - dbus_object[STORAGE_IFACE].Probe(&done) + def probe(data = {}, &done) + dbus_object[STORAGE_IFACE].Probe(data, &done) end # Reprobes (keeps the current settings). - def reprobe - dbus_object.Reprobe + # + # @param data [Hash] Extra data provided to the D-Bus call. + def reprobe(data = {}) + dbus_object.Reprobe(data) end # Performs the packages installation diff --git a/service/lib/agama/dbus/manager.rb b/service/lib/agama/dbus/manager.rb index e847339478..b6b3c65950 100644 --- a/service/lib/agama/dbus/manager.rb +++ b/service/lib/agama/dbus/manager.rb @@ -63,8 +63,8 @@ def initialize(backend, logger) FINISH_PHASE = 3 dbus_interface MANAGER_INTERFACE do - dbus_method(:Probe, "") { config_phase } - dbus_method(:Reprobe, "") { config_phase(reprobe: true) } + dbus_method(:Probe, "in data:a{sv}") { |data| config_phase(data: data) } + dbus_method(:Reprobe, "in data:a{sv}") { |data| config_phase(reprobe: true, data: data) } dbus_method(:Commit, "") { install_phase } dbus_method(:CanInstall, "out result:b") { can_install? } dbus_method(:CollectLogs, "out tarball_filesystem_path:s") { collect_logs } @@ -76,9 +76,12 @@ def initialize(backend, logger) end # Runs the config phase - def config_phase(reprobe: false) + # + # @param reprobe [Boolean] Whether a reprobe should be done instead of a probe. + # @param data [Hash] Extra data provided to the D-Bus calls. + def config_phase(reprobe: false, data: {}) safe_run do - busy_while { backend.config_phase(reprobe: reprobe) } + busy_while { backend.config_phase(reprobe: reprobe, data: data) } end end diff --git a/service/lib/agama/dbus/storage/manager.rb b/service/lib/agama/dbus/storage/manager.rb index 96eedc978d..16f41e2660 100644 --- a/service/lib/agama/dbus/storage/manager.rb +++ b/service/lib/agama/dbus/storage/manager.rb @@ -35,6 +35,7 @@ require "agama/dbus/storage/volume_conversion" require "agama/dbus/with_progress" require "agama/dbus/with_service_status" +require "agama/storage/config_conversions" require "agama/storage/encryption_settings" require "agama/storage/proposal_settings" require "agama/storage/volume_templates_builder" @@ -70,7 +71,6 @@ def initialize(backend, service_status: nil, logger: nil) @actions = read_actions register_storage_callbacks - register_proposal_callbacks register_progress_callbacks register_service_status_callbacks register_iscsi_callbacks @@ -120,19 +120,34 @@ def probe(keep_config: false, keep_activation: true) # @return [Integer] 0 success; 1 error def apply_config(serialized_config) logger.info("Setting storage config from D-Bus: #{serialized_config}") - config_json = JSON.parse(serialized_config, symbolize_names: true) - proposal.calculate_from_json(config_json) - proposal.success? ? 0 : 1 + configure(config_json) + end + + # Applies the given serialized config model according to the JSON schema. + # + # @param serialized_model [String] Serialized storage config model. + # @return [Integer] 0 success; 1 error + def apply_config_model(serialized_model) + logger.info("Setting storage config model from D-Bus: #{serialized_model}") + + model_json = JSON.parse(serialized_model, symbolize_names: true) + config = Agama::Storage::ConfigConversions::FromModel.new( + model_json, + product_config: product_config, + storage_system: proposal.storage_system + ).convert + config_json = { storage: Agama::Storage::ConfigConversions::ToJSON.new(config).convert } + + configure(config_json) end - # Calculates the initial proposal. + # Resets to the default config. # # @return [Integer] 0 success; 1 error def reset_config logger.info("Reset storage config from D-Bus") - backend.calculate_proposal - backend.proposal.success? ? 0 : 1 + configure end # Gets and serializes the storage config used for calculating the current proposal. @@ -143,18 +158,6 @@ def recover_config JSON.pretty_generate(json) end - # Applies the given serialized config model according to the JSON schema. - # - # @param serialized_model [String] Serialized storage config model. - # @return [Integer] 0 success; 1 error - def apply_config_model(serialized_model) - logger.info("Setting storage config model from D-Bus: #{serialized_model}") - - model_json = JSON.parse(serialized_model, symbolize_names: true) - proposal.calculate_from_model(model_json) - proposal.success? ? 0 : 1 - end - # Gets and serializes the storage config model. # # @return [String] @@ -195,19 +198,32 @@ def deprecated_system # they should return whether the config was actually applied. # * Methods like #Probe or #Install return nothing. dbus_interface STORAGE_INTERFACE do - dbus_method(:Probe) { probe } - dbus_method(:Reprobe) { probe(keep_config: true) } - dbus_method(:Reactivate) { probe(keep_config: true, keep_activation: false) } - dbus_method(:SetConfig, "in serialized_config:s, out result:u") do |serialized_config| - busy_while { apply_config(serialized_config) } + dbus_signal :Configured, "client_id:s" + dbus_method(:Probe, "in data:a{sv}") do |data| + busy_request(data) { probe } end - dbus_method(:ResetConfig, "out result:u") do - busy_while { reset_config } + dbus_method(:Reprobe, "in data:a{sv}") do |data| + busy_request(data) { probe(keep_config: true) } end - dbus_method(:GetConfig, "out serialized_config:s") { recover_config } - dbus_method(:SetConfigModel, "in serialized_model:s, out result:u") do |serialized_model| - busy_while { apply_config_model(serialized_model) } + dbus_method(:Reactivate, "in data:a{sv}") do |data| + busy_request(data) { probe(keep_config: true, keep_activation: false) } + end + dbus_method( + :SetConfig, + "in serialized_config:s, in data:a{sv}, out result:u" + ) do |serialized_config, data| + busy_request(data) { apply_config(serialized_config) } + end + dbus_method(:ResetConfig, "in data:a{sv}, out result:u") do |data| + busy_request(data) { reset_config } end + dbus_method( + :SetConfigModel, + "in serialized_model:s, in data:a{sv}, out result:u" + ) do |serialized_model, data| + busy_request(data) { apply_config_model(serialized_model) } + end + dbus_method(:GetConfig, "out serialized_config:s") { recover_config } dbus_method(:GetConfigModel, "out serialized_model:s") { recover_model } dbus_method(:SolveConfigModel, "in sparse_model:s, out solved_model:s") do |sparse_model| solve_model(sparse_model) @@ -312,8 +328,7 @@ def system_device_path(device) end dbus_interface STORAGE_DEVICES_INTERFACE do - # PropertiesChanged signal if a proposal is calculated, see - # {#register_proposal_callbacks}. + # PropertiesChanged signal if storage is configured, see {#register_callbacks}. dbus_reader_attr_accessor :actions, "aa{sv}" dbus_reader :available_drives, "ao" @@ -333,7 +348,7 @@ def calculate_guided_proposal(settings_dbus) logger.info("Calculating guided storage proposal from D-Bus: #{settings_dbus}") settings = ProposalSettingsConversion.from_dbus(settings_dbus, - config: config, logger: logger) + config: product_config, logger: logger) proposal.calculate_guided(settings) proposal.success? ? 0 : 1 @@ -471,6 +486,20 @@ def iscsi_delete(path) # @return [DBus::Storage::Proposal, nil] attr_reader :dbus_proposal + # Configures storage. + # + # @param config_json [Hash, nil] Storage config according to the JSON schema. If nil, then + # the default config is applied. + # @return [Integer] 0 success; 1 error + def configure(config_json = nil) + success = backend.configure(config_json) + success ? 0 : 1 + end + + def send_configured_signal + self.Configured(request_data["client_id"].to_s) + end + def add_s390_interfaces require "agama/dbus/storage/interfaces/dasd_manager" require "agama/dbus/storage/interfaces/zfcp_manager" @@ -491,14 +520,12 @@ def register_storage_callbacks backend.on_issues_change { issues_properties_changed } backend.on_deprecated_system_change { storage_properties_changed } backend.on_probe { refresh_system_devices } - end - - def register_proposal_callbacks - proposal.on_calculate do + backend.on_configure do export_proposal proposal_properties_changed refresh_staging_devices update_actions + send_configured_signal end end @@ -594,13 +621,13 @@ def tree_path(tree_root) end # @return [Agama::Config] - def config + def product_config backend.product_config end # @return [Agama::VolumeTemplatesBuilder] def volume_templates_builder - Agama::Storage::VolumeTemplatesBuilder.new_from_config(config) + Agama::Storage::VolumeTemplatesBuilder.new_from_config(product_config) end end end diff --git a/service/lib/agama/dbus/with_service_status.rb b/service/lib/agama/dbus/with_service_status.rb index 1e9cb169fd..6a01a65c4f 100644 --- a/service/lib/agama/dbus/with_service_status.rb +++ b/service/lib/agama/dbus/with_service_status.rb @@ -39,6 +39,14 @@ def service_status def busy_while(&block) service_status.busy_while(&block) end + + # Executes a block setting the service as busy, see {BaseObject#request}. + # + # @param data [Hash] see {BaseObject#request_data}. + # @param block [Proc] + def busy_request(data, &block) + busy_while { request(data, &block) } + end end end end diff --git a/service/lib/agama/manager.rb b/service/lib/agama/manager.rb index afcc79686f..f23a1df270 100644 --- a/service/lib/agama/manager.rb +++ b/service/lib/agama/manager.rb @@ -91,10 +91,13 @@ def startup_phase end # Runs the config phase - def config_phase(reprobe: false) + # + # @param reprobe [Boolean] Whether a reprobe should be done instead of a probe. + # @param data [Hash] Extra data provided to the D-Bus calls. + def config_phase(reprobe: false, data: {}) installation_phase.config start_progress_with_descriptions(_("Analyze disks"), _("Configure software")) - progress.step { reprobe ? storage.reprobe : storage.probe } + progress.step { reprobe ? storage.reprobe(data) : storage.probe(data) } progress.step { software.probe } logger.info("Config phase done") diff --git a/service/lib/agama/storage/config_conversions/to_json_conversions/size.rb b/service/lib/agama/storage/config_conversions/to_json_conversions/size.rb index f43d1c44b6..2a266f782c 100644 --- a/service/lib/agama/storage/config_conversions/to_json_conversions/size.rb +++ b/service/lib/agama/storage/config_conversions/to_json_conversions/size.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2024] SUSE LLC +# Copyright (c) [2024-2025] SUSE LLC # # All Rights Reserved. # @@ -33,6 +33,13 @@ def initialize(config) @config = config end + # The size is not generated for default size. + # + # @see Base#convert + def convert + super unless config.default? + end + private # @see Base#conversions diff --git a/service/lib/agama/storage/manager.rb b/service/lib/agama/storage/manager.rb index a5ea9c31ca..9bce3c5228 100644 --- a/service/lib/agama/storage/manager.rb +++ b/service/lib/agama/storage/manager.rb @@ -70,7 +70,6 @@ def initialize(product_config, logger: nil) @bootloader = Bootloader.new(logger) register_progress_callbacks - register_proposal_callbacks end # Whether the system is in a deprecated status @@ -115,6 +114,14 @@ def on_probe(&block) @on_probe_callbacks << block end + # Registers a callback to be called when storage is configured. + # + # @param block [Proc] + def on_configure(&block) + @on_configure_callbacks ||= [] + @on_configure_callbacks << block + end + # Probes storage devices and performs an initial proposal # # @param keep_config [Boolean] Whether to use the current storage config for calculating the @@ -137,7 +144,10 @@ def probe(keep_config: false, keep_activation: true) progress.step { activate_devices(keep_activation: keep_activation) } progress.step { probe_devices } - progress.step { calculate_proposal(keep_config: keep_config) } + progress.step do + config_json = proposal.storage_json if keep_config + configure(config_json) + end # The system is not deprecated anymore self.deprecated_system = false @@ -169,13 +179,16 @@ def finish Finisher.new(logger, product_config, security).run end - # Calculates the proposal. + # Configures storage. # - # @param keep_config [Boolean] Whether to use the current storage config for calculating the - # proposal. If false, then the default config from the product is used. - def calculate_proposal(keep_config: false) - config_json = proposal.storage_json if keep_config - Configurator.new(proposal).configure(config_json) + # @param config_json [Hash, nil] Storage config according to the JSON schema. If nil, then + # the default config is applied. + # @return [Boolean] Whether storage was successfully configured. + def configure(config_json = nil) + result = Configurator.new(proposal).configure(config_json) + update_issues + @on_configure_callbacks&.each(&:call) + result end # Storage proposal manager @@ -245,11 +258,6 @@ def register_progress_callbacks on_progress_change { logger.info(progress.to_s) } end - # Issues are updated when the proposal is calculated - def register_proposal_callbacks - proposal.on_calculate { update_issues } - end - # Activates the devices, calling activation callbacks if needed # # @param keep_activation [Boolean] Whether to keep the current activation (e.g., provided LUKS diff --git a/service/lib/agama/storage/proposal.rb b/service/lib/agama/storage/proposal.rb index 116edc2c06..236537a72c 100644 --- a/service/lib/agama/storage/proposal.rb +++ b/service/lib/agama/storage/proposal.rb @@ -46,7 +46,6 @@ def initialize(product_config, logger: nil) @product_config = product_config @logger = logger || Logger.new($stdout) - @on_calculate_callbacks = [] end # Whether the proposal was already calculated. @@ -63,11 +62,6 @@ def success? calculated? && !proposal.failed? && issues.none?(&:error?) end - # Stores callbacks to be called after calculating a proposal. - def on_calculate(&block) - @on_calculate_callbacks << block - end - # Default storage config according to the JSON schema. # # The default config depends on the target device. @@ -154,18 +148,6 @@ def calculate_from_json(source_json) success? end - # Calculates a new proposal from a config model. - # - # @param model_json [Hash] Source config model according to the JSON schema. - # @return [Boolean] Whether the proposal successes. - def calculate_from_model(model_json) - config = ConfigConversions::FromModel - .new(model_json, product_config: product_config, storage_system: storage_system) - .convert - - calculate_agama(config) - end - # Calculates a new proposal using the guided strategy. # # @param settings [Agama::Storage::ProposalSettings] @@ -342,7 +324,6 @@ def calculate raise e end - @on_calculate_callbacks.each(&:call) success? end diff --git a/service/test/agama/dbus/storage/manager_test.rb b/service/test/agama/dbus/storage/manager_test.rb index 7ae67f32e6..19d4a119ab 100644 --- a/service/test/agama/dbus/storage/manager_test.rb +++ b/service/test/agama/dbus/storage/manager_test.rb @@ -70,12 +70,12 @@ def serialize(value) # Speed up tests by avoding real check of TPM presence. allow(Y2Storage::EncryptionMethod::TPM_FDE).to receive(:possible?).and_return(true) allow(Yast::Arch).to receive(:s390).and_return false - allow(proposal).to receive(:on_calculate) + allow(backend).to receive(:on_configure) + allow(backend).to receive(:on_issues_change) allow(backend).to receive(:actions).and_return([]) allow(backend).to receive(:iscsi).and_return(iscsi) allow(backend).to receive(:software).and_return(software) allow(backend).to receive(:proposal).and_return(proposal) - allow(backend).to receive(:calculate_proposal) mock_storage(devicegraph: "empty-hd-50GiB.yaml") end diff --git a/service/test/agama/storage/autoyast_proposal_test.rb b/service/test/agama/storage/autoyast_proposal_test.rb index 085a110bea..3a71918e2d 100644 --- a/service/test/agama/storage/autoyast_proposal_test.rb +++ b/service/test/agama/storage/autoyast_proposal_test.rb @@ -141,19 +141,6 @@ def root_filesystem(disk) subject.calculate_autoyast(partitioning) expect(subject.issues).to be_empty end - - it "runs all the callbacks" do - callback1 = proc {} - callback2 = proc {} - - subject.on_calculate(&callback1) - subject.on_calculate(&callback2) - - expect(callback1).to receive(:call) - expect(callback2).to receive(:call) - - subject.calculate_autoyast(partitioning) - end end context "if no root is specified" do @@ -184,19 +171,6 @@ def root_filesystem(disk) ) ) end - - it "runs all the callbacks" do - callback1 = proc {} - callback2 = proc {} - - subject.on_calculate(&callback1) - subject.on_calculate(&callback2) - - expect(callback1).to receive(:call) - expect(callback2).to receive(:call) - - subject.calculate_autoyast(partitioning) - end end end @@ -553,19 +527,6 @@ def root_filesystem(disk) expect(root.snapshots?).to eq(false) end end - - it "runs all the callbacks" do - callback1 = proc {} - callback2 = proc {} - - subject.on_calculate(&callback1) - subject.on_calculate(&callback2) - - expect(callback1).to receive(:call) - expect(callback2).to receive(:call) - - subject.calculate_autoyast(partitioning) - end end end end diff --git a/service/test/agama/storage/config_conversions/to_json_conversions/examples.rb b/service/test/agama/storage/config_conversions/to_json_conversions/examples.rb index 7d1613a265..c02eee9116 100644 --- a/service/test/agama/storage/config_conversions/to_json_conversions/examples.rb +++ b/service/test/agama/storage/config_conversions/to_json_conversions/examples.rb @@ -386,7 +386,7 @@ end end - context "if size was solved" do + context "if size is default" do before do size_config = config.size size_config.default = true @@ -396,12 +396,7 @@ it "generates the expected JSON" do config_json = subject.convert - expect(config_json[:size]).to eq( - { - min: 5.GiB.to_i, - max: 25.GiB.to_i - } - ) + expect(config_json.keys).to_not include(:size) end end end diff --git a/service/test/agama/storage/manager_test.rb b/service/test/agama/storage/manager_test.rb index 06be2dff27..bd71f04c3e 100644 --- a/service/test/agama/storage/manager_test.rb +++ b/service/test/agama/storage/manager_test.rb @@ -322,6 +322,86 @@ end end + describe "#configure" do + before do + allow(proposal).to receive(:issues).and_return(proposal_issues) + allow(proposal).to receive(:calculate_from_json) + allow(proposal).to receive(:storage_json).and_return(config_json) + allow_any_instance_of(Agama::Storage::Configurator) + .to receive(:generate_configs).and_return([default_config]) + end + + let(:proposal) { Agama::Storage::Proposal.new(config, logger: logger) } + + let(:default_config) do + { + storage: { + drives: [ + search: "/dev/vda1" + ] + } + } + end + + let(:config_json) do + { + storage: { + drives: [ + search: "/dev/vda2" + ] + } + } + end + + let(:proposal_issues) { [Agama::Issue.new("proposal issue")] } + + let(:callback) { proc {} } + + it "calculates a proposal using the default config if no config is given" do + expect(proposal).to receive(:calculate_from_json).with(default_config) + storage.configure + end + + it "calculates a proposal using the given config" do + expect(proposal).to receive(:calculate_from_json).with(config_json) + storage.configure(config_json) + end + + it "adds the proposal issues" do + storage.configure + + expect(storage.issues).to include( + an_object_having_attributes(description: /proposal issue/) + ) + end + + it "executes the on_configure callbacks" do + storage.on_configure(&callback) + expect(callback).to receive(:call) + storage.configure + end + + context "if the proposal was correctly calculated" do + before do + allow(proposal).to receive(:success?).and_return(true) + end + + it "returns true" do + expect(storage.configure).to eq(true) + end + end + + context "if the proposal was not correctly calculated" do + before do + allow(proposal).to receive(:success?).and_return(false) + end + + it "returns false" do + expect(storage.configure).to eq(false) + end + end + end + describe "#install" do before do allow(y2storage_manager).to receive(:staging).and_return(proposed_devicegraph) diff --git a/service/test/agama/storage/proposal_test.rb b/service/test/agama/storage/proposal_test.rb index 7d3c4a7a68..0d1a3f5f34 100644 --- a/service/test/agama/storage/proposal_test.rb +++ b/service/test/agama/storage/proposal_test.rb @@ -592,21 +592,6 @@ def drive(partitions) end end - shared_examples "check proposal callbacks" do |action, settings| - it "runs all the callbacks" do - callback1 = proc {} - callback2 = proc {} - - subject.on_calculate(&callback1) - subject.on_calculate(&callback2) - - expect(callback1).to receive(:call) - expect(callback2).to receive(:call) - - subject.public_send(action, send(settings)) - end - end - shared_examples "check proposal return" do |action, achivable_settings, impossible_settings| it "returns whether the proposal was successful" do result = subject.public_send(action, send(achivable_settings)) @@ -628,19 +613,6 @@ def drive(partitions) expect(Y2Storage::StorageManager.instance.proposal).to be_nil end - it "does not run the callbacks" do - callback1 = proc {} - callback2 = proc {} - - subject.on_calculate(&callback1) - subject.on_calculate(&callback2) - - expect(callback1).to_not receive(:call) - expect(callback2).to_not receive(:call) - - subject.public_send(action, send(settings)) - end - it "returns false" do result = subject.public_send(action, send(settings)) expect(result).to eq(false) @@ -684,8 +656,6 @@ def drive(partitions) ) end - include_examples "check proposal callbacks", :calculate_guided, :achivable_settings - include_examples "check proposal return", :calculate_guided, :achivable_settings, :impossible_settings @@ -741,8 +711,6 @@ def drive(partitions) expect(Y2Storage::StorageManager.instance.proposal).to be_a(Y2Storage::AgamaProposal) end - include_examples "check proposal callbacks", :calculate_agama, :achivable_config - include_examples "check proposal return", :calculate_agama, :achivable_config, :impossible_config @@ -784,8 +752,6 @@ def drive(partitions) expect(Y2Storage::StorageManager.instance.proposal).to be_a(Y2Storage::AutoinstProposal) end - include_examples "check proposal callbacks", :calculate_autoyast, :achivable_settings - include_examples "check proposal return", :calculate_autoyast, :achivable_settings, :impossible_settings @@ -878,34 +844,6 @@ def drive(partitions) end end - describe "#calculate_from_model" do - let(:model_json) do - { - drives: [ - { - name: "/dev/vda", - filesystem: { - type: "xfs" - } - } - ] - } - end - - it "calculates a proposal with the agama strategy and with the expected config" do - expect(subject).to receive(:calculate_agama) do |config| - expect(config).to be_a(Agama::Storage::Config) - expect(config.drives.size).to eq(1) - - drive = config.drives.first - expect(drive.search.name).to eq("/dev/vda") - expect(drive.filesystem.type.fs_type).to eq(Y2Storage::Filesystems::Type::XFS) - end - - subject.calculate_from_model(model_json) - end - end - describe "#actions" do it "returns an empty list if calculate has not been called yet" do expect(subject.actions).to eq([]) diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index d7d1f58ec9..b796dba67d 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -90,7 +90,10 @@ describe("App", () => { // setting the language through a cookie document.cookie = "agamaLang=en-US; path=/;"; (createClient as jest.Mock).mockImplementation(() => { - return { isConnected: () => true }; + return { + onEvent: jest.fn(), + isConnected: () => true, + }; }); mockProducts = [tumbleweed, microos]; diff --git a/web/src/App.tsx b/web/src/App.tsx index f223b5e60e..6fcb657bf3 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -31,6 +31,7 @@ import { useDeprecatedChanges } from "~/queries/storage"; import { ROOT, PRODUCT } from "~/routes/paths"; import { InstallationPhase } from "~/types/status"; import { useQueryClient } from "@tanstack/react-query"; +import AlertOutOfSync from "~/components/core/AlertOutOfSync"; /** * Main application component. @@ -97,7 +98,13 @@ function App() { return ; }; - return ; + return ( + <> + {/* So far, only the storage backend is able to detect external changes.*/} + + + + ); } export default App; diff --git a/web/src/assets/styles/index.scss b/web/src/assets/styles/index.scss index 29723f3686..8826719701 100644 --- a/web/src/assets/styles/index.scss +++ b/web/src/assets/styles/index.scss @@ -529,3 +529,8 @@ button:focus-visible { .storage-structure:has(> li:nth-child(2)) span.action-text { display: none; } + +.agm-backdrop-gray-and-blur { + background-color: rgb(0 0 0 / 30%); + backdrop-filter: grayscale(100%) blur(4px); +} diff --git a/web/src/components/core/AlertOutOfSync.test.tsx b/web/src/components/core/AlertOutOfSync.test.tsx index 9e2f76b1aa..b607a3cd61 100644 --- a/web/src/components/core/AlertOutOfSync.test.tsx +++ b/web/src/components/core/AlertOutOfSync.test.tsx @@ -21,7 +21,7 @@ */ import React, { act } from "react"; -import { screen } from "@testing-library/dom"; +import { screen, within } from "@testing-library/dom"; import { installerRender, plainRender } from "~/test-utils"; import AlertOutOfSync from "./AlertOutOfSync"; @@ -82,31 +82,29 @@ describe("AlertOutOfSync", () => { installerRender(); // Should not render the alert initially - expect(screen.queryByText("Info alert:")).toBeNull(); + expect(screen.queryByRole("dialog")).toBeNull(); // Simulate a change event for a different scope act(() => { eventCallback({ type: "NotWatchedChanged", clientId: "other-client" }); }); - expect(screen.queryByText("Info alert:")).toBeNull(); + expect(screen.queryByRole("dialog")).toBeNull(); // Simulate a change event for the subscribed scope, from current client act(() => { eventCallback({ type: "WatchedChanged", clientId: "current-client" }); }); - expect(screen.queryByText("Info alert:")).toBeNull(); + expect(screen.queryByRole("dialog")).toBeNull(); // Simulate a change event for the subscribed scope, from different client act(() => { eventCallback({ type: "WatchedChanged", clientId: "other-client" }); }); - screen.getByText("Info alert:"); - screen.getByText("Configuration out of sync"); - screen.getByText(/issues or data loss/); - screen.getByRole("button", { name: "Reload now" }); + const dialog = screen.getByRole("dialog", { name: "Configuration out of sync" }); + within(dialog).getByRole("button", { name: "Reload now" }); }); it("dismisses automatically the alert on matching changes event from current client for subscribed scope", () => { @@ -123,17 +121,14 @@ describe("AlertOutOfSync", () => { eventCallback({ type: "WatchedChanged", clientId: "other-client" }); }); - screen.getByText("Info alert:"); - screen.getByText("Configuration out of sync"); - screen.getByText(/issues or data loss/); - screen.getByRole("button", { name: "Reload now" }); + screen.getByRole("dialog", { name: "Configuration out of sync" }); // Simulate a change event for the subscribed scope, from current client act(() => { eventCallback({ type: "WatchedChanged", clientId: "current-client" }); }); - expect(screen.queryByText("Info alert:")).toBeNull(); + expect(screen.queryByRole("dialog")).toBeNull(); }); it("triggers a location relaod when clicking on `Reload now`", async () => { diff --git a/web/src/components/core/AlertOutOfSync.tsx b/web/src/components/core/AlertOutOfSync.tsx index 773bed87c2..29e34fffeb 100644 --- a/web/src/components/core/AlertOutOfSync.tsx +++ b/web/src/components/core/AlertOutOfSync.tsx @@ -21,20 +21,14 @@ */ import React, { useEffect, useState } from "react"; -import { - Alert, - AlertActionCloseButton, - AlertGroup, - AlertProps, - Button, - Content, -} from "@patternfly/react-core"; +import { Content } from "@patternfly/react-core"; import { useInstallerClient } from "~/context/installer"; import { isEmpty } from "radashi"; import { _ } from "~/i18n"; import { locationReload } from "~/utils"; +import Popup, { PopupProps } from "~/components/core/Popup"; -type AlertOutOfSyncProps = Partial & { +type AlertOutOfSyncProps = Partial> & { /** * The scope to listen for change events on (e.g., `SoftwareProposal`, * `L10nConfig`). @@ -43,20 +37,13 @@ type AlertOutOfSyncProps = Partial & { }; /** - * Reactive alert shown when the configuration for a given scope has been - * changed externally. + * Reactive alert shown when the configuration for a given scope has been changed externally. * - * It warns that the interface may be out of sync and recommends reloading - * before continuing to avoid issues and data loss. Reloading is intentionally - * left up to the user rather than forced automatically, to prevent confusion - * caused by unexpected refreshes. + * It warns that the interface may be out of sync and forces reloading before continuing to avoid + * issues and data loss. * - * It works by listening for "Changed" events on the specified scope: - * - * - Displays a toast alert if the event originates from a different client - * (based on client ID). - * - Automatically dismisses the alert if a subsequent event originates from - * the current client. + * It works by listening for "Changed" events on the specified scope and displays a popup if the + * event originates from a different client (based on client ID). * * @example * ```tsx @@ -84,32 +71,19 @@ export default function AlertOutOfSync({ scope, ...alertProps }: AlertOutOfSyncP const title = _("Configuration out of sync"); return ( - - {active && ( - setActive(false)} - /> - } - {...alertProps} - key={`${scope}-out-of-sync`} - > - - {_( - "The configuration has been updated externally. \ -Reload the page to get the latest data and avoid issues or data loss.", - )} - - - - )} - + + {_("The configuration has been updated externally.")} + + {_("Reloading is required to get the latest data and avoid issues or data loss.")} + + + {_("Reload now")} + + ); } diff --git a/web/src/components/core/ResourceNotFound.test.tsx b/web/src/components/core/ResourceNotFound.test.tsx new file mode 100644 index 0000000000..c976af9042 --- /dev/null +++ b/web/src/components/core/ResourceNotFound.test.tsx @@ -0,0 +1,63 @@ +/* + * Copyright (c) [2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React from "react"; +import { screen } from "@testing-library/dom"; +import { plainRender } from "~/test-utils"; +import ResourceNotFound from "./ResourceNotFound"; +import { ROOT } from "~/routes/paths"; + +describe("ResourceNotFound", () => { + it("renders the default title when none is given", () => { + plainRender(); + screen.getByRole("heading", { name: "Resource not found or lost", level: 3 }); + }); + + it("renders the given title", () => { + plainRender( + , + ); + screen.getByRole("heading", { name: "Not found", level: 3 }); + }); + + it("renders the default body when none is given", () => { + plainRender(); + screen.getByText("It doesn't exist or can't be reached."); + }); + + it("renders the given body", () => { + plainRender( + , + ); + screen.getByText("Unexpected path, nothing to show"); + }); + + it("renders a link with given text and path", () => { + plainRender(); + const link = screen.getByRole("link", { name: "Go to homepage" }); + expect(link).toHaveAttribute("href", ROOT.root); + }); +}); diff --git a/web/src/components/core/ResourceNotFound.tsx b/web/src/components/core/ResourceNotFound.tsx new file mode 100644 index 0000000000..c7b2dfc548 --- /dev/null +++ b/web/src/components/core/ResourceNotFound.tsx @@ -0,0 +1,61 @@ +/* + * Copyright (c) [2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React from "react"; +import { + EmptyState, + EmptyStateActions, + EmptyStateBody, + EmptyStateFooter, +} from "@patternfly/react-core"; +import Icon from "../layout/Icon"; +import Link from "../core/Link"; +import { _ } from "~/i18n"; + +type ResourceNotFoundProps = { + title?: string; + body?: React.ReactNode; + linkText: string; + linkPath: string; +}; + +export default function ResourceNotFound({ + title = _("Resource not found or lost"), + body = _("It doesn't exist or can't be reached."), + linkText, + linkPath, +}: ResourceNotFoundProps) { + return ( + }> + {body} + {linkText && linkPath && ( + + + + {linkText} + + + + )} + + ); +} diff --git a/web/src/components/storage/PartitionPage.tsx b/web/src/components/storage/PartitionPage.tsx index 926b98f525..7263e0e633 100644 --- a/web/src/components/storage/PartitionPage.tsx +++ b/web/src/components/storage/PartitionPage.tsx @@ -47,6 +47,9 @@ import { Page, SelectWrapper as Select, SubtleContent } from "~/components/core/ import { SelectWrapperProps as SelectProps } from "~/components/core/SelectWrapper"; import SelectTypeaheadCreatable from "~/components/core/SelectTypeaheadCreatable"; import AutoSizeText from "~/components/storage/AutoSizeText"; +import SizeModeSelect, { SizeMode, SizeRange } from "~/components/storage/SizeModeSelect"; +import AlertOutOfSync from "~/components/core/AlertOutOfSync"; +import ResourceNotFound from "~/components/core/ResourceNotFound"; import { useAddPartition, useEditPartition } from "~/hooks/storage/partition"; import { useMissingMountPaths } from "~/hooks/storage/product"; import { useModel } from "~/hooks/storage/model"; @@ -62,10 +65,9 @@ import { deviceSize, deviceLabel, filesystemLabel, parseToBytes } from "~/compon import { _ } from "~/i18n"; import { sprintf } from "sprintf-js"; import { apiModel } from "~/api/storage/types"; -import { STORAGE as PATHS } from "~/routes/paths"; -import { unique } from "radashi"; +import { STORAGE as PATHS, STORAGE } from "~/routes/paths"; +import { isUndefined, unique } from "radashi"; import { compact } from "~/utils"; -import SizeModeSelect, { SizeMode, SizeRange } from "~/components/storage/SizeModeSelect"; const NO_VALUE = ""; const NEW_PARTITION = "new"; @@ -688,7 +690,7 @@ function AutoSizeInfo({ value }: AutoSizeInfoProps): React.ReactNode { * @fixme This component has to be adapted to use the new hooks from ~/hooks/storage/ instead of the * deprecated hooks from ~/queries/storage/config-model. */ -export default function PartitionPage() { +const PartitionPageForm = () => { const navigate = useNavigate(); const headingId = useId(); const [mountPoint, setMountPoint] = React.useState(NO_VALUE); @@ -708,6 +710,7 @@ export default function PartitionPage() { const { errors, getVisibleError } = useErrors(value); const device = useModelDevice(); + const unusedMountPoints = useUnusedMountPoints(); const addPartition = useAddPartition(); @@ -812,6 +815,7 @@ export default function PartitionPage() { +
@@ -905,4 +909,14 @@ export default function PartitionPage() { ); +}; + +export default function PartitionPage() { + const device = useModelDevice(); + + return isUndefined(device) ? ( + + ) : ( + + ); }