diff --git a/rust/Cargo.lock b/rust/Cargo.lock index bd02cd3525..39686dd44e 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -88,6 +88,7 @@ dependencies = [ "tokio-tungstenite 0.26.2", "url", "utoipa", + "uuid", "zbus", ] @@ -4694,12 +4695,14 @@ dependencies = [ [[package]] name = "uuid" -version = "1.16.0" +version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "458f7a779bf54acc9f347480ac654f68407d3aab21269a6e3c9f922acd9e2da9" +checksum = "3cf4199d1e5d15ddd86a694e4d0dffa9c323ce759fea589f00fef9d81cc1931d" dependencies = [ "getrandom 0.3.2", + "js-sys", "serde", + "wasm-bindgen", ] [[package]] diff --git a/rust/agama-lib/Cargo.toml b/rust/agama-lib/Cargo.toml index 76219dde95..86717c2b40 100644 --- a/rust/agama-lib/Cargo.toml +++ b/rust/agama-lib/Cargo.toml @@ -45,6 +45,7 @@ fluent-uri = { version = "0.3.2", features = ["serde"] } tokio-tungstenite = { version = "0.26.2", features = ["native-tls"] } tokio-native-tls = "0.3.1" percent-encoding = "2.3.1" +uuid = { version = "1.17.0", features = ["serde", "v4"] } [dev-dependencies] httpmock = "0.7.0" diff --git a/rust/agama-lib/src/auth.rs b/rust/agama-lib/src/auth.rs index 6cdab442e7..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, @@ -56,6 +56,7 @@ use chrono::{Duration, Utc}; use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation}; use serde::{Deserialize, Serialize}; use thiserror::Error; +use uuid::Uuid; #[derive(Error, Debug)] #[error("Invalid authentication token: {0}")] @@ -183,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) } } @@ -195,8 +196,10 @@ impl Display for AuthToken { #[derive(Debug, Serialize, Deserialize)] pub struct TokenClaims { pub exp: i64, + pub client_id: ClientId, } +// FIXME: replace with TokenClaims::new, as it does not exist a "default" token. impl Default for TokenClaims { fn default() -> Self { let mut exp = Utc::now(); @@ -207,10 +210,31 @@ impl Default for TokenClaims { Self { exp: exp.timestamp(), + client_id: ClientId::new(), } } } +/// Identifies a client. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct ClientId(Uuid); + +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)] mod tests { use tempfile::tempdir; diff --git a/rust/agama-lib/src/http.rs b/rust/agama-lib/src/http.rs index b8d31ccbd5..b4ea8cbb2b 100644 --- a/rust/agama-lib/src/http.rs +++ b/rust/agama-lib/src/http.rs @@ -22,7 +22,7 @@ mod base_http_client; pub use base_http_client::{BaseHTTPClient, BaseHTTPClientError}; mod event; -pub use event::Event; +pub use event::{Event, EventPayload}; mod websocket; pub use websocket::{WebSocketClient, WebSocketError}; diff --git a/rust/agama-lib/src/http/event.rs b/rust/agama-lib/src/http/event.rs index 9fdabb8b1c..fb4e92ae21 100644 --- a/rust/agama-lib/src/http/event.rs +++ b/rust/agama-lib/src/http/event.rs @@ -19,6 +19,7 @@ // find current contact information at www.suse.com. use crate::{ + auth::ClientId, jobs::Job, localization::model::LocaleConfig, manager::InstallationPhase, @@ -39,9 +40,47 @@ use std::collections::HashMap; use crate::issue::Issue; +/// Agama event. +/// +/// It represents an event that occurs in Agama. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Event { + /// The identifier of the client which caused the event. + #[serde(skip_serializing_if = "Option::is_none")] + pub client_id: Option, + /// Event payload. + #[serde(flatten)] + pub payload: EventPayload, +} + +impl Event { + /// Creates a new event. + /// + /// * `payload`: event payload. + pub fn new(payload: EventPayload) -> Self { + Event { + client_id: None, + payload, + } + } + + /// Creates a new event with a client ID. + /// + /// * `payload`: event payload. + /// * `client_id`: client ID. + pub fn new_with_client_id(payload: EventPayload, client_id: &ClientId) -> Self { + Event { + client_id: Some(client_id.clone()), + payload, + } + } +} + #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(tag = "type")] -pub enum Event { +pub enum EventPayload { + ClientConnected, L10nConfigChanged(LocaleConfig), LocaleChanged { locale: String, @@ -64,6 +103,7 @@ pub enum Event { #[serde(flatten)] change: NetworkChange, }, + StorageChanged, // TODO: it should include the full software proposal or, at least, // all the relevant changes. SoftwareProposalChanged { @@ -144,3 +184,69 @@ pub enum Event { device: ZFCPController, }, } + +/// Makes it easier to create an event, reducing the boilerplate. +/// +/// # Event without additional data +/// +/// ``` +/// # use agama_lib::{event, http::EventPayload}; +/// let my_event = event!(ClientConnected); +/// assert!(matches!(my_event.payload, EventPayload::ClientConnected)); +/// assert!(my_event.client_id.is_none()); +/// ``` +/// +/// # Event with some additional data +/// +/// ``` +/// # use agama_lib::{event, http::EventPayload}; +/// let my_event = event!(LocaleChanged { locale: "es_ES".to_string() }); +/// assert!(matches!( +/// my_event.payload, +/// EventPayload::LocaleChanged { locale: _ } +/// )); +/// ``` +/// +/// # Adding the client ID +/// +/// ``` +/// # use agama_lib::{auth::ClientId, event, http::EventPayload}; +/// let client_id = ClientId::new(); +/// let my_event = event!(ClientConnected, &client_id); +/// assert!(matches!(my_event.payload, EventPayload::ClientConnected)); +/// assert!(my_event.client_id.is_some()); +/// ``` +/// +/// # Add the client ID to a complex event +/// +/// ``` +/// # use agama_lib::{auth::ClientId, event, http::EventPayload}; +/// let client_id = ClientId::new(); +/// let my_event = event!(LocaleChanged { locale: "es_ES".to_string() }, &client_id); +/// assert!(matches!( +/// my_event.payload, +/// EventPayload::LocaleChanged { locale: _ } +/// )); +/// assert!(my_event.client_id.is_some()); +/// ``` +#[macro_export] +macro_rules! event { + ($variant:ident) => { + agama_lib::http::Event::new(agama_lib::http::EventPayload::$variant) + }; + ($variant:ident, $client:expr) => { + agama_lib::http::Event::new_with_client_id( + agama_lib::http::EventPayload::$variant, + $client, + ) + }; + ($variant:ident $inner:tt, $client:expr) => { + agama_lib::http::Event::new_with_client_id( + agama_lib::http::EventPayload::$variant $inner, + $client + ) + }; + ($variant:ident $inner:tt) => { + agama_lib::http::Event::new(agama_lib::http::EventPayload::$variant $inner) + }; +} 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/monitor.rs b/rust/agama-lib/src/monitor.rs index c56ecd7075..8a2d5d4369 100644 --- a/rust/agama-lib/src/monitor.rs +++ b/rust/agama-lib/src/monitor.rs @@ -55,7 +55,9 @@ use std::collections::HashMap; use tokio::sync::{broadcast, mpsc, oneshot}; use crate::{ - http::{BaseHTTPClient, BaseHTTPClientError, Event, WebSocketClient, WebSocketError}, + http::{ + BaseHTTPClient, BaseHTTPClientError, Event, EventPayload, WebSocketClient, WebSocketError, + }, manager::{InstallationPhase, InstallerStatus}, progress::Progress, }; @@ -223,16 +225,16 @@ impl Monitor { /// /// * `event`: Agama event. fn handle_event(&mut self, event: Event) { - match event { - Event::ProgressChanged { path, progress } => { + match event.payload { + EventPayload::ProgressChanged { path, progress } => { self.status.update_progress(path, progress); } - Event::ServiceStatusChanged { service, status } => { + EventPayload::ServiceStatusChanged { service, status } => { if service.as_str() == MANAGER_PROGRESS_OBJECT_PATH { self.status.set_is_busy(status == 1); } } - Event::InstallationPhaseChanged { phase } => { + EventPayload::InstallationPhaseChanged { phase } => { self.status.set_phase(phase); } _ => {} 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/l10n/web.rs b/rust/agama-server/src/l10n/web.rs index a4e138e611..ed87be2c22 100644 --- a/rust/agama-server/src/l10n/web.rs +++ b/rust/agama-server/src/l10n/web.rs @@ -26,7 +26,10 @@ use super::{ }; use crate::{error::Error, web::EventsSender}; use agama_lib::{ - error::ServiceError, http::Event, localization::model::LocaleConfig, localization::LocaleProxy, + auth::ClientId, + error::ServiceError, + event, + localization::{model::LocaleConfig, LocaleProxy}, proxies::LocaleMixinProxy as ManagerLocaleProxy, }; use agama_locale_data::LocaleId; @@ -35,7 +38,7 @@ use axum::{ http::StatusCode, response::IntoResponse, routing::{get, patch}, - Json, Router, + Extension, Json, Router, }; use std::sync::Arc; use tokio::sync::RwLock; @@ -130,6 +133,7 @@ async fn keymaps(State(state): State>) -> Json> { )] async fn set_config( State(state): State>, + Extension(client_id): Extension>, Json(value): Json, ) -> Result { let mut data = state.locale.write().await; @@ -160,10 +164,9 @@ async fn set_config( let locale_string = locale.to_string(); state.manager_proxy.set_locale(&locale_string).await?; changes.ui_locale = Some(locale_string); - - _ = state.events.send(Event::LocaleChanged { + _ = state.events.send(event!(LocaleChanged { locale: locale.to_string(), - }); + })); } if let Some(ui_keymap) = &value.ui_keymap { @@ -174,7 +177,9 @@ async fn set_config( if let Err(e) = update_dbus(&state.proxy, &changes).await { tracing::warn!("Could not synchronize settings in the localization D-Bus service: {e}"); } - _ = state.events.send(Event::L10nConfigChanged(changes)); + _ = state + .events + .send(event!(L10nConfigChanged(changes), client_id.as_ref())); Ok(StatusCode::NO_CONTENT) } diff --git a/rust/agama-server/src/manager/web.rs b/rust/agama-server/src/manager/web.rs index ad37553a91..3cc7a49bc4 100644 --- a/rust/agama-server/src/manager/web.rs +++ b/rust/agama-server/src/manager/web.rs @@ -26,8 +26,9 @@ //! * `manager_stream` which offers an stream that emits the manager events coming from D-Bus. use agama_lib::{ + auth::ClientId, error::ServiceError, - logs, + event, logs, manager::{FinishMethod, InstallationPhase, InstallerStatus, ManagerClient}, proxies::Manager1Proxy, }; @@ -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; @@ -71,7 +74,7 @@ pub async fn manager_stream( .then(|change| async move { if let Ok(phase) = change.get().await { match InstallationPhase::try_from(phase) { - Ok(phase) => Some(Event::InstallationPhaseChanged { phase }), + Ok(phase) => Some(event!(InstallationPhaseChanged { phase })), Err(error) => { tracing::warn!("Ignoring the installation phase change. Error: {}", error); None @@ -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/network/web.rs b/rust/agama-server/src/network/web.rs index e6742c9b8c..37303ecd22 100644 --- a/rust/agama-server/src/network/web.rs +++ b/rust/agama-server/src/network/web.rs @@ -21,7 +21,6 @@ //! This module implements the web API for the network module. use crate::{error::Error, web::EventsSender}; -use agama_lib::http::Event; use anyhow::Context; use axum::{ extract::{Path, State}, @@ -34,6 +33,7 @@ use uuid::Uuid; use agama_lib::{ error::ServiceError, + event, network::{ error::NetworkStateError, model::{AccessPoint, Connection, Device, GeneralState}, @@ -100,7 +100,8 @@ pub async fn network_service( loop { match changes.recv().await { Ok(message) => { - if let Err(e) = events.send(Event::NetworkChange { change: message }) { + let change = event!(NetworkChange { change: message }); + if let Err(e) = events.send(change) { eprintln!("Could not send the event: {}", e); } } diff --git a/rust/agama-server/src/questions/web.rs b/rust/agama-server/src/questions/web.rs index ee0d7bd301..bd34f708de 100644 --- a/rust/agama-server/src/questions/web.rs +++ b/rust/agama-server/src/questions/web.rs @@ -28,6 +28,7 @@ use crate::error::Error; use agama_lib::{ error::ServiceError, + event, http::Event, proxies::questions::{GenericQuestionProxy, QuestionWithPasswordProxy, QuestionsProxy}, questions::{ @@ -299,11 +300,11 @@ pub async fn questions_stream( let add_stream = proxy .receive_interfaces_added() .await? - .then(|_| async move { Event::QuestionsChanged }); + .then(|_| async move { event!(QuestionsChanged) }); let remove_stream = proxy .receive_interfaces_removed() .await? - .then(|_| async move { Event::QuestionsChanged }); + .then(|_| async move { event!(QuestionsChanged) }); let stream = StreamExt::merge(add_stream, remove_stream); Ok(Box::pin(stream)) } diff --git a/rust/agama-server/src/software/web.rs b/rust/agama-server/src/software/web.rs index 9c8eb0258c..03273e45e3 100644 --- a/rust/agama-server/src/software/web.rs +++ b/rust/agama-server/src/software/web.rs @@ -38,7 +38,8 @@ use crate::{ use agama_lib::{ error::ServiceError, - http::Event, + event, + http::{Event, EventPayload}, product::{proxies::RegistrationProxy, Product, ProductClient}, software::{ model::{ @@ -116,7 +117,7 @@ async fn product_changed_stream( .await .then(|change| async move { if let Ok(id) = change.get().await { - return Some(Event::ProductChanged { id }); + return Some(event!(ProductChanged { id })); } None }) @@ -143,7 +144,7 @@ async fn patterns_changed_stream( } None }) - .filter_map(|e| e.map(|patterns| Event::SoftwareProposalChanged { patterns })); + .filter_map(|e| e.map(|patterns| event!(SoftwareProposalChanged { patterns }))); Ok(stream) } @@ -165,7 +166,7 @@ async fn conflicts_changed_stream( } None }) - .filter_map(|e| e.map(|conflicts| Event::ConflictsChanged { conflicts })); + .filter_map(|e| e.map(|conflicts| event!(ConflictsChanged { conflicts }))); Ok(stream) } @@ -179,7 +180,7 @@ async fn registration_email_changed_stream( .then(|change| async move { if let Ok(_id) = change.get().await { // TODO: add to stream also proxy and return whole cached registration info - return Some(Event::RegistrationChanged); + return Some(event!(RegistrationChanged)); } None }) @@ -196,7 +197,7 @@ async fn registration_code_changed_stream( .await .then(|change| async move { if let Ok(_id) = change.get().await { - return Some(Event::RegistrationChanged); + return Some(event!(RegistrationChanged)); } None }) @@ -228,7 +229,7 @@ pub async fn receive_events( client: ProductClient<'_>, ) { while let Ok(event) = events.recv().await { - if let Event::LocaleChanged { locale: _ } = event { + if let EventPayload::LocaleChanged { locale: _ } = event.payload { let mut cached_products = products.write().await; if let Ok(products) = client.products().await { *cached_products = products; diff --git a/rust/agama-server/src/storage/web.rs b/rust/agama-server/src/storage/web.rs index e6effdbb1c..fbac63e58c 100644 --- a/rust/agama-server/src/storage/web.rs +++ b/rust/agama-server/src/storage/web.rs @@ -25,8 +25,13 @@ //! * `storage_service` which returns the Axum service. //! * `storage_stream` which offers an stream that emits the storage events coming from D-Bus. +use std::sync::Arc; + use agama_lib::{ + auth::ClientId, error::ServiceError, + event, + http::Event, storage::{ model::{Action, Device, DeviceSid, ProposalSettings, ProposalSettingsPatch, Volume}, proxies::Storage1Proxy, @@ -37,12 +42,13 @@ use anyhow::Context; use axum::{ extract::{Query, State}, routing::{get, post, put}, - Json, Router, + Extension, Json, Router, }; 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; @@ -60,13 +66,18 @@ use crate::{ ProgressClient, ProgressRouterBuilder, }, }; -use agama_lib::http::Event; 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?; @@ -84,7 +95,7 @@ async fn devices_dirty_stream(dbus: zbus::Connection) -> Result 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>, @@ -193,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(())) @@ -218,7 +243,9 @@ async fn set_config( )] async fn get_config_model( State(state): State>, + Extension(client_id): Extension>, ) -> Result>, Error> { + tracing::debug!("{client_id:?}"); let config_model = state .client .get_config_model() @@ -240,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(())) } @@ -262,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(())) @@ -313,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. @@ -328,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. @@ -343,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/rust/agama-server/src/storage/web/dasd/stream.rs b/rust/agama-server/src/storage/web/dasd/stream.rs index f19e11eb6e..2312967dcd 100644 --- a/rust/agama-server/src/storage/web/dasd/stream.rs +++ b/rust/agama-server/src/storage/web/dasd/stream.rs @@ -24,6 +24,7 @@ use std::{collections::HashMap, task::Poll}; use agama_lib::{ error::ServiceError, + event, http::Event, storage::{ client::dasd::DASDClient, @@ -137,19 +138,19 @@ impl DASDDeviceStream { match change { DBusObjectChange::Added(path, values) => { let device = Self::update_device(cache, path, values)?; - Ok(Event::DASDDeviceAdded { + Ok(event!(DASDDeviceAdded { device: device.clone(), - }) + })) } DBusObjectChange::Changed(path, updated) => { let device = Self::update_device(cache, path, updated)?; - Ok(Event::DASDDeviceChanged { + Ok(event!(DASDDeviceChanged { device: device.clone(), - }) + })) } DBusObjectChange::Removed(path) => { let device = Self::remove_device(cache, path)?; - Ok(Event::DASDDeviceRemoved { device }) + Ok(event!(DASDDeviceRemoved { device })) } } } @@ -251,10 +252,10 @@ impl DASDFormatJobStream { ); } - Some(Event::DASDFormatJobChanged { + Some(event!(DASDFormatJobChanged { job_id: path.to_string(), summary: format_summary, - }) + })) } } diff --git a/rust/agama-server/src/storage/web/iscsi.rs b/rust/agama-server/src/storage/web/iscsi.rs index d9837f37fb..216d383c71 100644 --- a/rust/agama-server/src/storage/web/iscsi.rs +++ b/rust/agama-server/src/storage/web/iscsi.rs @@ -31,6 +31,7 @@ use crate::{ }; use agama_lib::{ error::ServiceError, + event, http::Event, storage::{ client::iscsi::{ISCSIAuth, ISCSIInitiator, ISCSINode, LoginResult}, @@ -104,7 +105,7 @@ fn handle_initiator_change(change: PropertiesChanged) -> Result, S let changes = to_owned_hash(args.changed_properties())?; let name = get_optional_property(&changes, "InitiatorName")?; let ibft = get_optional_property(&changes, "IBFT")?; - Ok(Some(Event::ISCSIInitiatorChanged { ibft, name })) + Ok(Some(event!(ISCSIInitiatorChanged { ibft, name }))) } #[derive(Clone)] diff --git a/rust/agama-server/src/storage/web/iscsi/stream.rs b/rust/agama-server/src/storage/web/iscsi/stream.rs index 3de2e94a7a..6749a5a4da 100644 --- a/rust/agama-server/src/storage/web/iscsi/stream.rs +++ b/rust/agama-server/src/storage/web/iscsi/stream.rs @@ -22,6 +22,7 @@ use std::{collections::HashMap, task::Poll}; use agama_lib::{ error::ServiceError, + event, http::Event, storage::{ISCSIClient, ISCSINode}, }; @@ -131,15 +132,15 @@ impl ISCSINodeStream { match change { DBusObjectChange::Added(path, values) => { let node = Self::update_node(cache, path, values)?; - Ok(Event::ISCSINodeAdded { node: node.clone() }) + Ok(event!(ISCSINodeAdded { node: node.clone() })) } DBusObjectChange::Changed(path, updated) => { let node = Self::update_node(cache, path, updated)?; - Ok(Event::ISCSINodeChanged { node: node.clone() }) + Ok(event!(ISCSINodeChanged { node: node.clone() })) } DBusObjectChange::Removed(path) => { let node = Self::remove_node(cache, path)?; - Ok(Event::ISCSINodeRemoved { node }) + Ok(event!(ISCSINodeRemoved { node })) } } } diff --git a/rust/agama-server/src/storage/web/zfcp/stream.rs b/rust/agama-server/src/storage/web/zfcp/stream.rs index f58be37d73..99a46669b9 100644 --- a/rust/agama-server/src/storage/web/zfcp/stream.rs +++ b/rust/agama-server/src/storage/web/zfcp/stream.rs @@ -24,6 +24,7 @@ use std::{collections::HashMap, task::Poll}; use agama_lib::{ error::ServiceError, + event, http::Event, storage::{ client::zfcp::ZFCPClient, @@ -127,19 +128,19 @@ impl ZFCPDiskStream { match change { DBusObjectChange::Added(path, values) => { let device = Self::update_device(cache, path, values)?; - Ok(Event::ZFCPDiskAdded { + Ok(event!(ZFCPDiskAdded { device: device.clone(), - }) + })) } DBusObjectChange::Changed(path, updated) => { let device = Self::update_device(cache, path, updated)?; - Ok(Event::ZFCPDiskChanged { + Ok(event!(ZFCPDiskChanged { device: device.clone(), - }) + })) } DBusObjectChange::Removed(path) => { let device = Self::remove_device(cache, path)?; - Ok(Event::ZFCPDiskRemoved { device }) + Ok(event!(ZFCPDiskRemoved { device })) } } } @@ -260,19 +261,19 @@ impl ZFCPControllerStream { match change { DBusObjectChange::Added(path, values) => { let device = Self::update_device(cache, path, values)?; - Ok(Event::ZFCPControllerAdded { + Ok(event!(ZFCPControllerAdded { device: device.clone(), - }) + })) } DBusObjectChange::Changed(path, updated) => { let device = Self::update_device(cache, path, updated)?; - Ok(Event::ZFCPControllerChanged { + Ok(event!(ZFCPControllerChanged { device: device.clone(), - }) + })) } DBusObjectChange::Removed(path) => { let device = Self::remove_device(cache, path)?; - Ok(Event::ZFCPControllerRemoved { device }) + Ok(event!(ZFCPControllerRemoved { device })) } } } diff --git a/rust/agama-server/src/users/web.rs b/rust/agama-server/src/users/web.rs index 293f540a19..30cc24d3b7 100644 --- a/rust/agama-server/src/users/web.rs +++ b/rust/agama-server/src/users/web.rs @@ -31,6 +31,7 @@ use crate::{ }; use agama_lib::{ error::ServiceError, + event, http::Event, users::{model::RootPatchSettings, proxies::Users1Proxy, FirstUser, RootUser, UsersClient}, }; @@ -54,7 +55,7 @@ struct UsersState<'a> { /// Returns streams that emits users related events coming from D-Bus. /// -/// It emits the Event::RootPasswordChange, Event::RootSSHKeyChanged and Event::FirstUserChanged events. +/// It emits the RootPasswordChange, RootSSHKeyChanged and FirstUserChanged events. /// /// * `connection`: D-Bus connection to listen for events. pub async fn users_streams(dbus: zbus::Connection) -> Result { @@ -89,7 +90,7 @@ async fn first_user_changed_stream( password: user.2, hashed_password: user.3, }; - return Some(Event::FirstUserChanged(user_struct)); + return Some(event!(FirstUserChanged(user_struct))); } None }) @@ -107,7 +108,7 @@ async fn root_user_changed_stream( .then(|change| async move { if let Ok(user) = change.get().await { if let Ok(root) = RootUser::from_dbus(user) { - return Some(Event::RootUserChanged(root)); + return Some(event!(RootUserChanged(root))); } } None diff --git a/rust/agama-server/src/web/common.rs b/rust/agama-server/src/web/common.rs index 2a28120dde..0820cd884f 100644 --- a/rust/agama-server/src/web/common.rs +++ b/rust/agama-server/src/web/common.rs @@ -22,7 +22,7 @@ use std::pin::Pin; -use agama_lib::{error::ServiceError, proxies::ServiceStatusProxy}; +use agama_lib::{error::ServiceError, event, proxies::ServiceStatusProxy}; use axum::{extract::State, routing::get, Json, Router}; use serde::Serialize; use tokio_stream::{Stream, StreamExt}; @@ -115,10 +115,10 @@ pub async fn service_status_stream( .await .then(move |change| async move { if let Ok(status) = change.get().await { - Some(Event::ServiceStatusChanged { + Some(event!(ServiceStatusChanged { service: destination.to_string(), status, - }) + })) } else { None } diff --git a/rust/agama-server/src/web/common/issues.rs b/rust/agama-server/src/web/common/issues.rs index 5e9887805f..a9cf8c10ac 100644 --- a/rust/agama-server/src/web/common/issues.rs +++ b/rust/agama-server/src/web/common/issues.rs @@ -36,7 +36,7 @@ //! At this point, it only handles the issues that are exposed through D-Bus. use crate::web::EventsSender; -use agama_lib::{http::Event, issue::Issue}; +use agama_lib::{event, http::Event, issue::Issue}; use agama_utils::dbus::build_properties_changed_stream; use axum::{extract::State, routing::get, Json, Router}; use std::collections::HashMap; @@ -172,10 +172,10 @@ impl IssuesService { self.cache.insert(path.to_string(), issues.clone()); - let event = Event::IssuesChanged { + let event = event!(IssuesChanged { path: path.to_string(), issues, - }; + }); self.events.send(event)?; Ok(()) } diff --git a/rust/agama-server/src/web/common/jobs.rs b/rust/agama-server/src/web/common/jobs.rs index 9e13333029..0f67483deb 100644 --- a/rust/agama-server/src/web/common/jobs.rs +++ b/rust/agama-server/src/web/common/jobs.rs @@ -22,6 +22,8 @@ use std::{collections::HashMap, pin::Pin, task::Poll}; use agama_lib::{ error::ServiceError, + event, + http::Event, jobs::{client::JobsClient, Job}, }; use agama_utils::{dbus::get_optional_property, property_from_dbus}; @@ -35,7 +37,6 @@ use zbus::zvariant::{ObjectPath, OwnedObjectPath, OwnedValue}; use crate::{ dbus::{DBusObjectChange, DBusObjectChangesStream, ObjectsCache}, error::Error, - web::Event, }; /// Builds a router for the jobs objects. @@ -162,15 +163,15 @@ impl JobsStream { match change { DBusObjectChange::Added(path, values) => { let job = Self::update_job(cache, path, values)?; - Ok(Event::JobAdded { job: job.clone() }) + Ok(event!(JobAdded { job: job.clone() })) } DBusObjectChange::Changed(path, updated) => { let job = Self::update_job(cache, path, updated)?; - Ok(Event::JobChanged { job: job.clone() }) + Ok(event!(JobChanged { job: job.clone() })) } DBusObjectChange::Removed(path) => { let job = Self::remove_job(cache, path)?; - Ok(Event::JobRemoved { job }) + Ok(event!(JobRemoved { job })) } } } diff --git a/rust/agama-server/src/web/common/progress.rs b/rust/agama-server/src/web/common/progress.rs index 7b123c2456..5a4056cea5 100644 --- a/rust/agama-server/src/web/common/progress.rs +++ b/rust/agama-server/src/web/common/progress.rs @@ -37,6 +37,7 @@ use crate::web::{event::log_event, EventsSender}; use agama_lib::{ + event, http::Event, progress::{Progress, ProgressSequence}, proxies::{ProgressChanged, ProgressProxy}, @@ -170,10 +171,10 @@ impl ProgressService { }; self.cache.insert(path.to_string(), sequence.clone()); - let event = Event::ProgressChanged { + let event = event!(ProgressChanged { path: path.to_string(), progress, - }; + }); log_event(&event); self.events.send(event)?; Ok(()) diff --git a/rust/agama-server/src/web/service.rs b/rust/agama-server/src/web/service.rs index 6e7160a925..a6fe10d0eb 100644 --- a/rust/agama-server/src/web/service.rs +++ b/rust/agama-server/src/web/service.rs @@ -22,6 +22,7 @@ use super::http::{login, login_from_query, logout, session}; use super::{config::ServiceConfig, state::ServiceState, EventsSender}; use agama_lib::auth::TokenClaims; use axum::http::HeaderValue; +use axum::middleware::Next; use axum::{ body::Body, extract::Request, @@ -31,6 +32,7 @@ use axum::{ Router, }; use hyper::header::CACHE_CONTROL; +use std::sync::Arc; use std::time::Duration; use std::{ convert::Infallible, @@ -107,8 +109,9 @@ impl MainServiceBuilder { let api_router = self .api_router - .route_layer(middleware::from_extractor_with_state::( + .route_layer(middleware::from_fn_with_state( state.clone(), + auth_middleware, )) .route("/ping", get(super::http::ping)) .route("/auth", post(login).get(session).delete(logout)); @@ -149,3 +152,13 @@ impl MainServiceBuilder { .with_state(state) } } + +// Authentication middleware. +// +// 1. Extracts the claims of the authentication token. +// 2. Adds the client ID as a extension to the request. +async fn auth_middleware(claims: TokenClaims, mut request: Request, next: Next) -> Response { + request.extensions_mut().insert(Arc::new(claims.client_id)); + let response = next.run(request).await; + response +} diff --git a/rust/agama-server/src/web/ws.rs b/rust/agama-server/src/web/ws.rs index 3f8a3a2c52..c42bc0caff 100644 --- a/rust/agama-server/src/web/ws.rs +++ b/rust/agama-server/src/web/ws.rs @@ -20,24 +20,35 @@ //! Implements the websocket handling. +use std::sync::Arc; + use super::{state::ServiceState, EventsSender}; +use agama_lib::auth::ClientId; use axum::{ extract::{ ws::{Message, WebSocket}, State, WebSocketUpgrade, }, response::IntoResponse, + Extension, }; pub async fn ws_handler( State(state): State, + Extension(client_id): Extension>, ws: WebSocketUpgrade, ) -> impl IntoResponse { - ws.on_upgrade(move |socket| handle_socket(socket, state.events)) + ws.on_upgrade(move |socket| handle_socket(socket, state.events, client_id)) } -async fn handle_socket(mut socket: WebSocket, events: EventsSender) { +async fn handle_socket(mut socket: WebSocket, events: EventsSender, client_id: Arc) { let mut rx = events.subscribe(); + + let conn_event = agama_lib::event!(ClientConnected, client_id.as_ref()); + if let Ok(json) = serde_json::to_string(&conn_event) { + _ = socket.send(Message::Text(json)).await; + } + while let Ok(msg) = rx.recv().await { match serde_json::to_string(&msg) { Ok(json) => { diff --git a/rust/package/agama.changes b/rust/package/agama.changes index 9ab09ad3e7..67418416cf 100644 --- a/rust/package/agama.changes +++ b/rust/package/agama.changes @@ -1,3 +1,9 @@ +------------------------------------------------------------------- +Wed Aug 6 12:52:41 UTC 2025 - José Iván López González + +- Emit HTTP event when storage is configured, including the client + id (gh#agama-project/agama#2640). + ------------------------------------------------------------------- Mon Aug 4 09:29:29 UTC 2025 - Knut Anderssen 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/package/rubygem-agama-yast.changes b/service/package/rubygem-agama-yast.changes index 0633a7d19f..a4176587dd 100644 --- a/service/package/rubygem-agama-yast.changes +++ b/service/package/rubygem-agama-yast.changes @@ -1,3 +1,9 @@ +------------------------------------------------------------------- +Wed Aug 6 12:47:13 UTC 2025 - José Iván López González + +- Emit D-Bus signal when storage is configured, including the + client id (gh#agama-project/agama#2640). + ------------------------------------------------------------------- Tue Aug 5 14:01:23 UTC 2025 - José Iván López González 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..24bdbb6851 100644 --- a/service/test/agama/storage/autoyast_proposal_test.rb +++ b/service/test/agama/storage/autoyast_proposal_test.rb @@ -36,7 +36,7 @@ before do mock_storage(devicegraph: scenario) - allow(Y2Storage::StorageManager.instance).to receive(:arch).and_return(arch) + allow(Y2Storage::Arch).to receive(:new).and_return(arch) end let(:scenario) { "windows-linux-pc.yml" } @@ -121,7 +121,7 @@ def root_filesystem(disk) expect(efi).to have_attributes( filesystem_type: Y2Storage::Filesystems::Type::VFAT, filesystem_mountpoint: "/boot/efi", - size: 1.GiB + size: 512.MiB ) expect(root).to have_attributes( @@ -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 @@ -292,7 +266,7 @@ def root_filesystem(disk) filesystem_type: Y2Storage::Filesystems::Type::VFAT, filesystem_mountpoint: "/boot/efi", id: Y2Storage::PartitionId::ESP, - size: 1.GiB + size: 512.MiB ) 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/package/agama-web-ui.changes b/web/package/agama-web-ui.changes index 07cee2b824..aee998cd60 100644 --- a/web/package/agama-web-ui.changes +++ b/web/package/agama-web-ui.changes @@ -1,3 +1,9 @@ +------------------------------------------------------------------- +Thu Jul 31 09:13:09 UTC 2025 - David Diaz + +- Block UI if storage is configured by any other client + (gh#agama-project/agama#2640). + ------------------------------------------------------------------- Fri Jul 25 19:42:18 UTC 2025 - David Diaz 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 8c1dc3f55e..8826719701 100644 --- a/web/src/assets/styles/index.scss +++ b/web/src/assets/styles/index.scss @@ -339,7 +339,7 @@ strong { // --pf-v6-c-nav--PaddingBlockStart: 0; // } -.pf-v6-c-alert { +:not(.pf-v6-c-alert-group__item) > .pf-v6-c-alert { --pf-v6-c-alert--m-info__title--Color: var(--agm-t--color--waterhole); --pf-v6-c-alert__icon--FontSize: var(--pf-t--global--font--size--md); --pf-v6-c-content--MarginBlockEnd: var(--pf-t--global--spacer--xs); @@ -377,6 +377,10 @@ strong { } } +.pf-v6-c-alert-group.pf-m-toast { + padding: var(--pf-t--global--spacer--xl); +} + .pf-v6-c-alert__title { font-size: var(--pf-t--global--font--size--md); } @@ -525,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/client/index.ts b/web/src/client/index.ts index 81c33e12db..c881f3d887 100644 --- a/web/src/client/index.ts +++ b/web/src/client/index.ts @@ -26,6 +26,8 @@ type VoidFn = () => void; type BooleanFn = () => boolean; export type InstallerClient = { + /** Unique client identifier. */ + id?: string; /** Whether the client is connected. */ isConnected: BooleanFn; /** Whether the client is recoverable after disconnecting. */ diff --git a/web/src/components/core/AlertOutOfSync.test.tsx b/web/src/components/core/AlertOutOfSync.test.tsx new file mode 100644 index 0000000000..b607a3cd61 --- /dev/null +++ b/web/src/components/core/AlertOutOfSync.test.tsx @@ -0,0 +1,152 @@ +/* + * 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, { act } from "react"; +import { screen, within } from "@testing-library/dom"; +import { installerRender, plainRender } from "~/test-utils"; +import AlertOutOfSync from "./AlertOutOfSync"; + +const mockOnEvent = jest.fn(); +const mockReload = jest.fn(); + +const mockClient = { + id: "current-client", + isConnected: jest.fn().mockResolvedValue(true), + isRecoverable: jest.fn(), + onConnect: jest.fn(), + onClose: jest.fn(), + onError: jest.fn(), + onEvent: mockOnEvent, +}; + +let consoleErrorSpy: jest.SpyInstance; + +jest.mock("~/context/installer", () => ({ + ...jest.requireActual("~/context/installer"), + useInstallerClient: () => mockClient, +})); +jest.mock("~/utils", () => ({ + ...jest.requireActual("~/utils"), + locationReload: () => mockReload(), +})); + +describe("AlertOutOfSync", () => { + beforeAll(() => { + consoleErrorSpy = jest.spyOn(console, "error"); + consoleErrorSpy.mockImplementation(); + }); + + it("renders nothing if scope is missing", () => { + // @ts-expect-error: scope is required prop + const { container } = plainRender(); + expect(container).toBeEmptyDOMElement(); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining("must receive a value for `scope`"), + ); + }); + + it("renders nothing if scope empty", () => { + const { container } = plainRender(); + expect(container).toBeEmptyDOMElement(); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining("must receive a value for `scope`"), + ); + }); + + it("shows alert on matching changes event from a different client for subscribed scope", () => { + let eventCallback; + mockClient.onEvent.mockImplementation((cb) => { + eventCallback = cb; + return () => {}; + }); + + installerRender(); + + // Should not render the alert initially + expect(screen.queryByRole("dialog")).toBeNull(); + + // Simulate a change event for a different scope + act(() => { + eventCallback({ type: "NotWatchedChanged", clientId: "other-client" }); + }); + + 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.queryByRole("dialog")).toBeNull(); + + // Simulate a change event for the subscribed scope, from different client + act(() => { + eventCallback({ type: "WatchedChanged", clientId: "other-client" }); + }); + + 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", () => { + let eventCallback; + mockClient.onEvent.mockImplementation((cb) => { + eventCallback = cb; + return () => {}; + }); + + installerRender(); + + // Simulate a change event for the subscribed scope, from different client + act(() => { + eventCallback({ type: "WatchedChanged", clientId: "other-client" }); + }); + + 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.queryByRole("dialog")).toBeNull(); + }); + + it("triggers a location relaod when clicking on `Reload now`", async () => { + let eventCallback; + mockClient.onEvent.mockImplementation((cb) => { + eventCallback = cb; + return () => {}; + }); + + const { user } = installerRender(); + + // Simulate a change event for the subscribed scope, from different client + act(() => { + eventCallback({ type: "WatchedChanged", clientId: "other-client" }); + }); + + const reloadButton = screen.getByRole("button", { name: "Reload now" }); + await user.click(reloadButton); + expect(mockReload).toHaveBeenCalled(); + }); +}); diff --git a/web/src/components/core/AlertOutOfSync.tsx b/web/src/components/core/AlertOutOfSync.tsx new file mode 100644 index 0000000000..29e34fffeb --- /dev/null +++ b/web/src/components/core/AlertOutOfSync.tsx @@ -0,0 +1,89 @@ +/* + * 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, { useEffect, useState } from "react"; +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> & { + /** + * The scope to listen for change events on (e.g., `SoftwareProposal`, + * `L10nConfig`). + */ + scope: string; +}; + +/** + * 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 forces reloading before continuing to avoid + * issues and data loss. + * + * 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 + * + * ``` + */ +export default function AlertOutOfSync({ scope, ...alertProps }: AlertOutOfSyncProps) { + const client = useInstallerClient(); + const [active, setActive] = useState(false); + const missingScope = isEmpty(scope); + + useEffect(() => { + if (missingScope) return; + + return client.onEvent((event) => { + event.type === `${scope}Changed` && setActive(event.clientId !== client.id); + }); + }); + + if (missingScope) { + console.error("AlertOutOfSync must receive a value for `scope` prop"); + return; + } + + const title = _("Configuration out of sync"); + + return ( + + {_("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/Link.tsx b/web/src/components/core/Link.tsx index c5816fcfa1..d05401db7b 100644 --- a/web/src/components/core/Link.tsx +++ b/web/src/components/core/Link.tsx @@ -22,11 +22,13 @@ import React from "react"; import { Button, ButtonProps } from "@patternfly/react-core"; -import { To, useHref } from "react-router-dom"; +import { To, useHref, useLinkClickHandler } from "react-router-dom"; export type LinkProps = Omit & { /** The target route */ to: string | To; + /** Whether the link should replace the current entry in the browser history */ + replace?: boolean; /** Whether use PF/Button primary variant */ isPrimary?: boolean; }; @@ -37,11 +39,29 @@ export type LinkProps = Omit & { * @note when isPrimary not given or false and props does not contain a variant prop, * it will default to "secondary" variant */ -export default function Link({ to, isPrimary, variant, children, ...props }: LinkProps) { +export default function Link({ + to, + replace = false, + isPrimary, + variant, + children, + onClick, + ...props +}: LinkProps) { const href = useHref(to); const linkVariant = isPrimary ? "primary" : variant || "secondary"; + const handleClick = useLinkClickHandler(to, { replace }); return ( - ); 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) ? ( + + ) : ( + + ); } diff --git a/web/src/context/installer.tsx b/web/src/context/installer.tsx index 4a2f8c650f..799419ef79 100644 --- a/web/src/context/installer.tsx +++ b/web/src/context/installer.tsx @@ -53,6 +53,13 @@ function InstallerClientProvider({ children, client = null }: InstallerClientPro useEffect(() => { const connectClient = async () => { const client = await createDefaultClient(); + + client.onEvent((event) => { + if (event.type === "ClientConnected") { + client.id = event.clientId; + } + }); + setValue(client); }; diff --git a/web/src/test-utils.tsx b/web/src/test-utils.tsx index c74603f33f..bd4fb144e3 100644 --- a/web/src/test-utils.tsx +++ b/web/src/test-utils.tsx @@ -96,6 +96,11 @@ jest.mock("react-router-dom", () => ({ Navigate: ({ to: route }) => <>Navigating to {route}, Outlet: () => <>Outlet Content, useRevalidator: () => mockUseRevalidator, + useLinkClickHandler: + ({ to }) => + () => { + to; + }, })); const Providers = ({ children, withL10n }) => {