diff --git a/rust/agama-lib/src/network/types.rs b/rust/agama-lib/src/network/types.rs index c2119b8f7b..ac33d4f4a1 100644 --- a/rust/agama-lib/src/network/types.rs +++ b/rust/agama-lib/src/network/types.rs @@ -27,6 +27,8 @@ use std::{ use thiserror::Error; use zbus; +use super::settings::NetworkConnection; + /// Network device #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type")] @@ -78,71 +80,67 @@ pub enum DeviceType { Bridge = 6, } -// For now this mirrors NetworkManager, because it was less mental work than coming up with -// what exactly Agama needs. Expected to be adapted. -#[derive(Debug, Default, Clone, Copy, PartialEq, Serialize, Deserialize, utoipa::ToSchema)] +/// Network device state. +#[derive( + Default, + Serialize, + Deserialize, + Debug, + PartialEq, + Eq, + Clone, + Copy, + strum::Display, + strum::EnumString, + utoipa::ToSchema, +)] +#[strum(serialize_all = "camelCase")] #[serde(rename_all = "camelCase")] pub enum DeviceState { #[default] - Unknown = 0, - Unmanaged = 10, - Unavailable = 20, - Disconnected = 30, - Prepare = 40, - Config = 50, - NeedAuth = 60, - IpConfig = 70, - IpCheck = 80, - Secondaries = 90, - Activated = 100, - Deactivating = 110, - Failed = 120, + /// The device's state is unknown. + Unknown, + /// The device is recognized but not managed by Agama. + Unmanaged, + /// The device is detected but it cannot be used (wireless switched off, missing firmware, etc.). + Unavailable, + /// The device is connecting to the network. + Connecting, + /// The device is successfully connected to the network. + Connected, + /// The device is disconnecting from the network. + Disconnecting, + /// The device is disconnected from the network. + Disconnected, + /// The device failed to connect to a network. + Failed, } -#[derive(Debug, Error, PartialEq)] -#[error("Invalid state: {0}")] -pub struct InvalidDeviceState(String); -impl TryFrom for DeviceState { - type Error = InvalidDeviceState; - - fn try_from(value: u8) -> Result { - match value { - 0 => Ok(DeviceState::Unknown), - 10 => Ok(DeviceState::Unmanaged), - 20 => Ok(DeviceState::Unavailable), - 30 => Ok(DeviceState::Disconnected), - 40 => Ok(DeviceState::Prepare), - 50 => Ok(DeviceState::Config), - 60 => Ok(DeviceState::NeedAuth), - 70 => Ok(DeviceState::IpConfig), - 80 => Ok(DeviceState::IpCheck), - 90 => Ok(DeviceState::Secondaries), - 100 => Ok(DeviceState::Activated), - 110 => Ok(DeviceState::Deactivating), - 120 => Ok(DeviceState::Failed), - _ => Err(InvalidDeviceState(value.to_string())), - } - } -} -impl fmt::Display for DeviceState { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let name = match &self { - DeviceState::Unknown => "unknown", - DeviceState::Unmanaged => "unmanaged", - DeviceState::Unavailable => "unavailable", - DeviceState::Disconnected => "disconnected", - DeviceState::Prepare => "prepare", - DeviceState::Config => "config", - DeviceState::NeedAuth => "need_auth", - DeviceState::IpConfig => "ip_config", - DeviceState::IpCheck => "ip_check", - DeviceState::Secondaries => "secondaries", - DeviceState::Activated => "activated", - DeviceState::Deactivating => "deactivating", - DeviceState::Failed => "failed", - }; - write!(f, "{}", name) - } +#[derive( + Default, + Serialize, + Deserialize, + Debug, + PartialEq, + Eq, + Clone, + Copy, + strum::Display, + strum::EnumString, + utoipa::ToSchema, +)] +#[strum(serialize_all = "camelCase")] +#[serde(rename_all = "camelCase")] +pub enum ConnectionState { + /// The connection is getting activated. + Activating, + /// The connection is activated. + Activated, + /// The connection is getting deactivated. + Deactivating, + #[default] + /// The connection is deactivated. + Deactivated, } #[derive(Debug, Default, Clone, Copy, PartialEq, Serialize, Deserialize, utoipa::ToSchema)] @@ -294,6 +292,17 @@ impl From for zbus::fdo::Error { } } +// FIXME: found a better place for the HTTP types. +// +// TODO: If the client ignores the additional "state" field, this struct +// does not need to be here. +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct NetworkConnectionWithState { + #[serde(flatten)] + pub connection: NetworkConnection, + pub state: ConnectionState, +} + #[cfg(test)] mod tests { use super::*; diff --git a/rust/agama-server/src/network/action.rs b/rust/agama-server/src/network/action.rs index baa55b91c5..28437e2a36 100644 --- a/rust/agama-server/src/network/action.rs +++ b/rust/agama-server/src/network/action.rs @@ -19,7 +19,7 @@ // find current contact information at www.suse.com. use crate::network::model::{AccessPoint, Connection, Device}; -use agama_lib::network::types::DeviceType; +use agama_lib::network::types::{ConnectionState, DeviceType}; use tokio::sync::oneshot; use uuid::Uuid; @@ -62,6 +62,8 @@ pub enum Action { /// Gets all the existent devices GetDevices(Responder>), GetGeneralState(Responder), + /// Connection state changed + ChangeConnectionState(String, ConnectionState), /// Sets a controller's ports. It uses the Uuid of the controller and the IDs or interface names /// of the ports. SetPorts( diff --git a/rust/agama-server/src/network/model.rs b/rust/agama-server/src/network/model.rs index 5496d7c55c..4cfb06b53f 100644 --- a/rust/agama-server/src/network/model.rs +++ b/rust/agama-server/src/network/model.rs @@ -26,7 +26,7 @@ use crate::network::error::NetworkStateError; use agama_lib::network::settings::{ BondSettings, IEEE8021XSettings, NetworkConnection, WirelessSettings, }; -use agama_lib::network::types::{BondMode, DeviceState, DeviceType, Status, SSID}; +use agama_lib::network::types::{BondMode, ConnectionState, DeviceState, DeviceType, Status, SSID}; use agama_lib::openapi::schemas; use cidr::IpInet; use serde::{Deserialize, Serialize}; @@ -455,6 +455,7 @@ mod tests { /// Network state #[serde_as] #[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] pub struct GeneralState { pub hostname: String, pub connectivity: bool, @@ -479,7 +480,7 @@ pub struct AccessPoint { /// Network device #[serde_as] #[skip_serializing_none] -#[derive(Default, Debug, Clone, Serialize, utoipa::ToSchema)] +#[derive(Default, Debug, Clone, PartialEq, Serialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct Device { pub name: String, @@ -491,7 +492,6 @@ pub struct Device { // Connection.id pub connection: Option, pub state: DeviceState, - pub state_reason: u8, } /// Represents a known network connection. @@ -515,6 +515,7 @@ pub struct Connection { pub config: ConnectionConfig, pub ieee_8021x_config: Option, pub autoconnect: bool, + pub state: ConnectionState, } impl Connection { @@ -587,6 +588,7 @@ impl Default for Connection { config: Default::default(), ieee_8021x_config: Default::default(), autoconnect: true, + state: Default::default(), } } } @@ -1783,6 +1785,8 @@ pub enum NetworkChange { /// original device name, which is especially useful if the /// device gets renamed. DeviceUpdated(String, Device), + /// A connection state has changed. + ConnectionStateChanged { id: String, state: ConnectionState }, } #[derive(Default, Debug, PartialEq, Clone, Serialize, utoipa::ToSchema)] diff --git a/rust/agama-server/src/network/nm.rs b/rust/agama-server/src/network/nm.rs index dbe8eebce6..03c5c0cd15 100644 --- a/rust/agama-server/src/network/nm.rs +++ b/rust/agama-server/src/network/nm.rs @@ -31,6 +31,7 @@ mod dbus; mod error; mod model; mod proxies; +mod streams; mod watcher; pub use adapter::NetworkManagerAdapter; diff --git a/rust/agama-server/src/network/nm/builder.rs b/rust/agama-server/src/network/nm/builder.rs index 4dc98ae649..8ccb2bd371 100644 --- a/rust/agama-server/src/network/nm/builder.rs +++ b/rust/agama-server/src/network/nm/builder.rs @@ -35,6 +35,8 @@ use anyhow::Context; use cidr::IpInet; use std::{collections::HashMap, net::IpAddr, str::FromStr}; +use super::model::NmDeviceState; + /// Builder to create a [Device] from its corresponding NetworkManager D-Bus representation. pub struct DeviceFromProxyBuilder<'a> { connection: zbus::Connection, @@ -57,21 +59,14 @@ impl<'a> DeviceFromProxyBuilder<'a> { .try_into() .context("Unsupported device type: {device_type}")?; - let state = self.proxy.state().await? as u8; - let (_, state_reason) = self.proxy.state_reason().await?; - let state: DeviceState = state - .try_into() - .context("Unsupported device state: {state}")?; - let mut device = Device { name: self.proxy.interface().await?, + state: self.device_state_from_proxy().await?, type_, - state, - state_reason: state_reason as u8, ..Default::default() }; - if state == DeviceState::Activated { + if device.state == DeviceState::Connected { device.ip_config = self.build_ip_config().await?; } @@ -249,4 +244,44 @@ impl<'a> DeviceFromProxyBuilder<'a> { Some(id.to_string()) } + + /// Map the combination of state + reason to the Agama set of states. + /// + /// See https://www.networkmanager.dev/docs/api/latest/nm-dbus-types.html#NMDeviceState + /// and https://www.networkmanager.dev/docs/api/latest/nm-dbus-types.html#NMDeviceStateReason + /// for further information. + async fn device_state_from_proxy(&self) -> Result { + const USER_REQUESTED: u32 = 39; + + let (state, reason) = self.proxy.state_reason().await?; + let state: NmDeviceState = (state as u8) + .try_into() + .context("Unsupported device state: {state}")?; + + let device_state = match state { + NmDeviceState::Unknown => DeviceState::Unknown, + NmDeviceState::Unmanaged => DeviceState::Unmanaged, + NmDeviceState::Unavailable => DeviceState::Unavailable, + NmDeviceState::Prepare + | NmDeviceState::IpConfig + | NmDeviceState::NeedAuth + | NmDeviceState::Config + | NmDeviceState::Secondaries + | NmDeviceState::IpCheck => DeviceState::Connecting, + NmDeviceState::Activated => DeviceState::Connected, + NmDeviceState::Deactivating => DeviceState::Disconnecting, + NmDeviceState::Disconnected => { + // If the connection failed, NetworkManager sets the state to "disconnected". + // Let's consider it a problem unless it was requested by the user. + if reason == USER_REQUESTED { + DeviceState::Disconnected + } else { + DeviceState::Failed + } + } + NmDeviceState::Failed => DeviceState::Failed, + }; + + Ok(device_state) + } } diff --git a/rust/agama-server/src/network/nm/client.rs b/rust/agama-server/src/network/nm/client.rs index acc0340a90..59ac31c85c 100644 --- a/rust/agama-server/src/network/nm/client.rs +++ b/rust/agama-server/src/network/nm/client.rs @@ -26,12 +26,15 @@ use super::dbus::{ cleanup_dbus_connection, connection_from_dbus, connection_to_dbus, controller_from_dbus, merge_dbus_connections, }; -use super::model::NmDeviceType; +use super::model::{NmConnectionState, NmDeviceType}; use super::proxies::{ AccessPointProxy, ActiveConnectionProxy, ConnectionProxy, DeviceProxy, NetworkManagerProxy, SettingsProxy, WirelessProxy, }; -use crate::network::model::{AccessPoint, Connection, Device, GeneralState}; +use crate::network::model::{ + AccessPoint, Connection, ConnectionConfig, Device, GeneralState, SecurityProtocol, +}; +use agama_lib::dbus::get_optional_property; use agama_lib::error::ServiceError; use agama_lib::network::types::{DeviceType, SSID}; use log; @@ -187,6 +190,7 @@ impl<'a> NetworkManagerClient<'a> { let proxy = SettingsProxy::new(&self.connection).await?; let paths = proxy.list_connections().await?; let mut connections: Vec = Vec::with_capacity(paths.len()); + let states = self.connection_states().await?; for path in paths { let proxy = ConnectionProxy::builder(&self.connection) .path(path.as_str())? @@ -200,11 +204,18 @@ impl<'a> NetworkManagerClient<'a> { } let settings = proxy.get_settings().await?; - let controller = controller_from_dbus(&settings)?; match connection_from_dbus(settings) { Ok(mut connection) => { + let state = states + .get(&connection.id) + .map(|s| NmConnectionState(s.clone())); + if let Some(state) = state { + connection.state = state.try_into().unwrap_or_default(); + } + + Self::add_secrets(&mut connection.config, &proxy).await?; if let Some(controller) = controller { controlled_by.insert(connection.uuid, controller.to_string()); } @@ -241,6 +252,20 @@ impl<'a> NetworkManagerClient<'a> { Ok(connections) } + pub async fn connection_states(&self) -> Result, ServiceError> { + let mut states = HashMap::new(); + + for active_path in &self.nm_proxy.active_connections().await? { + let proxy = ActiveConnectionProxy::builder(&self.connection) + .path(active_path.as_str())? + .build() + .await?; + states.insert(proxy.id().await?, proxy.state().await?); + } + + Ok(states) + } + /// Adds or updates a connection if it already exists. /// /// * `conn`: connection to add or update. @@ -366,4 +391,30 @@ impl<'a> NetworkManagerClient<'a> { Ok(None) } + + /// Ancillary function to add secrets to a connection. + /// + /// TODO: add support for more security protocols. + pub async fn add_secrets( + config: &mut ConnectionConfig, + proxy: &ConnectionProxy<'_>, + ) -> Result<(), ServiceError> { + let ConnectionConfig::Wireless(ref mut wireless) = config else { + return Ok(()); + }; + + if wireless.security == SecurityProtocol::WPA2 { + match proxy.get_secrets("802-11-wireless-security").await { + Ok(secrets) => { + if let Some(secret) = secrets.get("802-11-wireless-security") { + wireless.password = get_optional_property(&secret, "psk")?; + } + } + Err(error) => { + tracing::error!("Could not read connection secrets: {:?}", error); + } + } + } + Ok(()) + } } diff --git a/rust/agama-server/src/network/nm/dbus.rs b/rust/agama-server/src/network/nm/dbus.rs index 9f3785d979..85215fa7ac 100644 --- a/rust/agama-server/src/network/nm/dbus.rs +++ b/rust/agama-server/src/network/nm/dbus.rs @@ -470,6 +470,10 @@ fn wireless_config_to_dbus(config: &'_ WirelessConfig) -> NestedHash<'_> { wireless.insert("bssid", bssid.as_bytes().into()); } + if config.security == SecurityProtocol::WEP && config.wep_security.is_none() { + return NestedHash::from([(WIRELESS_KEY, wireless)]); + } + let mut security: HashMap<&str, zvariant::Value> = HashMap::from([ ("key-mgmt", config.security.to_string().into()), ( diff --git a/rust/agama-server/src/network/nm/error.rs b/rust/agama-server/src/network/nm/error.rs index 6a717b154d..cb5487874e 100644 --- a/rust/agama-server/src/network/nm/error.rs +++ b/rust/agama-server/src/network/nm/error.rs @@ -30,6 +30,8 @@ pub enum NmError { UnsupportedIpMethod(String), #[error("Unsupported device type: '{0}'")] UnsupportedDeviceType(u32), + #[error("Unsupported connection state: '{0}'")] + UnsupportedConnectionState(u32), #[error("Unsupported security protocol: '{0}'")] UnsupportedSecurityProtocol(String), #[error("Unsupported wireless mode: '{0}'")] diff --git a/rust/agama-server/src/network/nm/model.rs b/rust/agama-server/src/network/nm/model.rs index 9a71dc2c56..e41144fa29 100644 --- a/rust/agama-server/src/network/nm/model.rs +++ b/rust/agama-server/src/network/nm/model.rs @@ -30,7 +30,7 @@ use crate::network::{ model::{Ipv4Method, Ipv6Method, SecurityProtocol, WirelessMode}, nm::error::NmError, }; -use agama_lib::network::types::DeviceType; +use agama_lib::network::types::{ConnectionState, DeviceType}; use std::fmt; use std::str::FromStr; @@ -111,6 +111,86 @@ impl TryFrom for DeviceType { } } +/// Device state +#[derive(Default, Debug, PartialEq, Copy, Clone)] +pub enum NmDeviceState { + #[default] + Unknown = 0, + Unmanaged = 10, + Unavailable = 20, + Disconnected = 30, + Prepare = 40, + Config = 50, + NeedAuth = 60, + IpConfig = 70, + IpCheck = 80, + Secondaries = 90, + Activated = 100, + Deactivating = 110, + Failed = 120, +} + +#[derive(Debug, thiserror::Error, PartialEq)] +#[error("Unsupported device state: {0}")] +pub struct InvalidNmDeviceState(String); + +impl TryFrom for NmDeviceState { + type Error = InvalidNmDeviceState; + + fn try_from(value: u8) -> Result { + match value { + 0 => Ok(NmDeviceState::Unknown), + 10 => Ok(NmDeviceState::Unmanaged), + 20 => Ok(NmDeviceState::Unavailable), + 30 => Ok(NmDeviceState::Disconnected), + 40 => Ok(NmDeviceState::Prepare), + 50 => Ok(NmDeviceState::Config), + 60 => Ok(NmDeviceState::NeedAuth), + 70 => Ok(NmDeviceState::IpConfig), + 80 => Ok(NmDeviceState::IpCheck), + 90 => Ok(NmDeviceState::Secondaries), + 100 => Ok(NmDeviceState::Activated), + 110 => Ok(NmDeviceState::Deactivating), + 120 => Ok(NmDeviceState::Failed), + _ => Err(InvalidNmDeviceState(value.to_string())), + } + } +} + +/// Connection type +/// +/// As we are just converting the number to its high-level representation, +/// a newtype might be enough. +#[derive(Debug, Default, Clone, Copy)] +pub struct NmConnectionState(pub u32); + +impl From for u32 { + fn from(value: NmConnectionState) -> u32 { + value.0 + } +} + +impl fmt::Display for NmConnectionState { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl TryFrom for ConnectionState { + type Error = NmError; + + fn try_from(value: NmConnectionState) -> Result { + match value { + NmConnectionState(0) => Ok(ConnectionState::Deactivated), + NmConnectionState(1) => Ok(ConnectionState::Activating), + NmConnectionState(2) => Ok(ConnectionState::Activated), + NmConnectionState(3) => Ok(ConnectionState::Deactivating), + NmConnectionState(4) => Ok(ConnectionState::Deactivated), + NmConnectionState(_) => Err(NmError::UnsupportedConnectionState(value.into())), + } + } +} + /// Key management /// /// Using the newtype pattern around an String is enough. For proper support, we might replace this diff --git a/rust/agama-server/src/network/nm/streams.rs b/rust/agama-server/src/network/nm/streams.rs new file mode 100644 index 0000000000..7e402d559e --- /dev/null +++ b/rust/agama-server/src/network/nm/streams.rs @@ -0,0 +1,27 @@ +// 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. + +mod common; +mod connections; +mod devices; + +pub use common::NmChange; +pub use connections::ActiveConnectionChangedStream; +pub use devices::DeviceChangedStream; diff --git a/rust/agama-server/src/network/nm/streams/common.rs b/rust/agama-server/src/network/nm/streams/common.rs new file mode 100644 index 0000000000..010d920903 --- /dev/null +++ b/rust/agama-server/src/network/nm/streams/common.rs @@ -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. + +use agama_lib::error::ServiceError; +use zbus::{message::Type as MessageType, zvariant::OwnedObjectPath, MatchRule, MessageStream}; + +#[derive(Debug, Clone)] +pub enum NmChange { + DeviceAdded(OwnedObjectPath), + DeviceUpdated(OwnedObjectPath), + DeviceRemoved(OwnedObjectPath), + IP4ConfigChanged(OwnedObjectPath), + IP6ConfigChanged(OwnedObjectPath), + ActiveConnectionAdded(OwnedObjectPath), + ActiveConnectionUpdated(OwnedObjectPath), + ActiveConnectionRemoved(OwnedObjectPath), +} + +pub async fn build_added_and_removed_stream( + connection: &zbus::Connection, +) -> Result { + let rule = MatchRule::builder() + .msg_type(MessageType::Signal) + .path("/org/freedesktop")? + .interface("org.freedesktop.DBus.ObjectManager")? + .build(); + let stream = MessageStream::for_match_rule(rule, connection, Some(1)).await?; + Ok(stream) +} + +/// Returns a stream of properties changes to be used by DeviceChangedStream. +/// +/// It listens for changes in several objects that are related to a network device. +pub async fn build_properties_changed_stream( + connection: &zbus::Connection, +) -> Result { + let rule = MatchRule::builder() + .msg_type(MessageType::Signal) + .interface("org.freedesktop.DBus.Properties")? + .member("PropertiesChanged")? + .build(); + let stream = MessageStream::for_match_rule(rule, connection, Some(1)).await?; + Ok(stream) +} diff --git a/rust/agama-server/src/network/nm/streams/connections.rs b/rust/agama-server/src/network/nm/streams/connections.rs new file mode 100644 index 0000000000..207228eeb3 --- /dev/null +++ b/rust/agama-server/src/network/nm/streams/connections.rs @@ -0,0 +1,150 @@ +// 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. + +use agama_lib::error::ServiceError; +use futures_util::ready; +use pin_project::pin_project; +use std::{ + pin::Pin, + task::{Context, Poll}, +}; +use tokio_stream::{Stream, StreamMap}; +use zbus::{ + fdo::{InterfacesAdded, InterfacesRemoved, PropertiesChanged}, + names::InterfaceName, + zvariant::OwnedObjectPath, + Message, MessageStream, +}; + +use super::{ + common::{build_added_and_removed_stream, build_properties_changed_stream}, + NmChange, +}; + +/// Stream of active connections state changes. +/// +/// This stream listens for active connection state changes and converts +/// them into [ConnectionStateChange] events. +/// +/// It is implemented as a struct because it needs to keep the ProxiesRegistry alive. +#[pin_project] +pub struct ActiveConnectionChangedStream { + connection: zbus::Connection, + #[pin] + inner: StreamMap<&'static str, MessageStream>, +} + +impl ActiveConnectionChangedStream { + /// Builds a new stream using the given D-Bus connection. + /// + /// * `connection`: D-Bus connection. + pub async fn new(connection: &zbus::Connection) -> Result { + let connection = connection.clone(); + let mut inner = StreamMap::new(); + inner.insert( + "object_manager", + build_added_and_removed_stream(&connection).await?, + ); + inner.insert( + "properties", + build_properties_changed_stream(&connection).await?, + ); + Ok(Self { connection, inner }) + } + + fn handle_added(message: InterfacesAdded) -> Option { + let args = message.args().ok()?; + let interfaces: Vec = args + .interfaces_and_properties() + .keys() + .map(|i| i.to_string()) + .collect(); + + if interfaces.contains(&"org.freedesktop.NetworkManager.Connection.Active".to_string()) { + let path = OwnedObjectPath::from(args.object_path().clone()); + return Some(NmChange::ActiveConnectionAdded(path)); + } + + None + } + + fn handle_removed(message: InterfacesRemoved) -> Option { + let args = message.args().ok()?; + + let interface = + InterfaceName::from_str_unchecked("org.freedesktop.NetworkManager.Connection.Active"); + if args.interfaces.contains(&interface) { + let path = OwnedObjectPath::from(args.object_path().clone()); + return Some(NmChange::ActiveConnectionRemoved(path)); + } + + None + } + + fn handle_changed(message: PropertiesChanged) -> Option { + let args = message.args().ok()?; + let inner = message.message(); + let path = OwnedObjectPath::from(inner.header().path()?.to_owned()); + + if args.interface_name.as_str() == "org.freedesktop.NetworkManager.Connection.Active" { + return Some(NmChange::ActiveConnectionUpdated(path)); + } + + None + } + + fn handle_message(message: Result) -> Option { + let Ok(message) = message else { + return None; + }; + + if let Some(added) = InterfacesAdded::from_message(message.clone()) { + return Self::handle_added(added); + } + + if let Some(removed) = InterfacesRemoved::from_message(message.clone()) { + return Self::handle_removed(removed); + } + + if let Some(changed) = PropertiesChanged::from_message(message.clone()) { + return Self::handle_changed(changed); + } + + None + } +} + +impl Stream for ActiveConnectionChangedStream { + type Item = NmChange; + + fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let mut pinned = self.project(); + Poll::Ready(loop { + let item = ready!(pinned.inner.as_mut().poll_next(cx)); + let next_value = match item { + Some((_, message)) => Self::handle_message(message), + _ => None, + }; + if next_value.is_some() { + break next_value; + } + }) + } +} diff --git a/rust/agama-server/src/network/nm/streams/devices.rs b/rust/agama-server/src/network/nm/streams/devices.rs new file mode 100644 index 0000000000..be031ee08f --- /dev/null +++ b/rust/agama-server/src/network/nm/streams/devices.rs @@ -0,0 +1,178 @@ +// Copyright (c) [2024-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. + +use agama_lib::error::ServiceError; +use futures_util::ready; +use pin_project::pin_project; +use std::{ + collections::HashMap, + pin::Pin, + task::{Context, Poll}, +}; +use tokio_stream::{Stream, StreamMap}; +use zbus::{ + fdo::{InterfacesAdded, InterfacesRemoved, PropertiesChanged}, + names::InterfaceName, + zvariant::OwnedObjectPath, + Message, MessageStream, +}; + +use super::common::{build_added_and_removed_stream, build_properties_changed_stream, NmChange}; + +/// Stream of device-related events. +/// +/// This stream listens for many NetworkManager events that are related to network devices (state, +/// IP configuration, etc.) and converts them into variants of the [DeviceChange] enum. +/// +/// It is implemented as a struct because it needs to keep the ObjectManagerProxy alive. +#[pin_project] +pub struct DeviceChangedStream { + connection: zbus::Connection, + #[pin] + inner: StreamMap<&'static str, MessageStream>, +} + +impl DeviceChangedStream { + /// Builds a new stream using the given D-Bus connection. + /// + /// * `connection`: D-Bus connection. + pub async fn new(connection: &zbus::Connection) -> Result { + let connection = connection.clone(); + let mut inner = StreamMap::new(); + inner.insert( + "object_manager", + build_added_and_removed_stream(&connection).await?, + ); + inner.insert( + "properties", + build_properties_changed_stream(&connection).await?, + ); + Ok(Self { connection, inner }) + } + + fn handle_added(message: InterfacesAdded) -> Option { + let args = message.args().ok()?; + let interfaces: Vec = args + .interfaces_and_properties() + .keys() + .map(|i| i.to_string()) + .collect(); + + if interfaces.contains(&"org.freedesktop.NetworkManager.Device".to_string()) { + let path = OwnedObjectPath::from(args.object_path().clone()); + return Some(NmChange::DeviceAdded(path)); + } + + None + } + + fn handle_removed(message: InterfacesRemoved) -> Option { + let args = message.args().ok()?; + + let interface = InterfaceName::from_str_unchecked("org.freedesktop.NetworkManager.Device"); + if args.interfaces.contains(&interface) { + let path = OwnedObjectPath::from(args.object_path().clone()); + return Some(NmChange::DeviceRemoved(path)); + } + + None + } + + fn handle_changed(message: PropertiesChanged) -> Option { + const IP_CONFIG_PROPS: &[&str] = &["AddressData", "Gateway", "NameserverData", "RouteData"]; + const DEVICE_PROPS: &[&str] = &[ + "DeviceType", + "HwAddress", + "Interface", + "State", + "StateReason", + ]; + + let args = message.args().ok()?; + let inner = message.message(); + let path = OwnedObjectPath::from(inner.header().path()?.to_owned()); + + match args.interface_name.as_str() { + "org.freedesktop.NetworkManager.IP4Config" => { + if Self::include_properties(IP_CONFIG_PROPS, &args.changed_properties) { + return Some(NmChange::IP4ConfigChanged(path)); + } + } + "org.freedesktop.NetworkManager.IP6Config" => { + if Self::include_properties(IP_CONFIG_PROPS, &args.changed_properties) { + return Some(NmChange::IP6ConfigChanged(path)); + } + } + "org.freedesktop.NetworkManager.Device" => { + if Self::include_properties(DEVICE_PROPS, &args.changed_properties) { + return Some(NmChange::DeviceUpdated(path)); + } + } + _ => {} + }; + None + } + + fn include_properties( + wanted: &[&str], + changed: &HashMap<&'_ str, zbus::zvariant::Value<'_>>, + ) -> bool { + let properties: Vec<_> = changed.keys().collect(); + wanted.iter().any(|i| properties.contains(&i)) + } + + fn handle_message(message: Result) -> Option { + let Ok(message) = message else { + return None; + }; + + if let Some(added) = InterfacesAdded::from_message(message.clone()) { + return Self::handle_added(added); + } + + if let Some(removed) = InterfacesRemoved::from_message(message.clone()) { + return Self::handle_removed(removed); + } + + if let Some(changed) = PropertiesChanged::from_message(message.clone()) { + return Self::handle_changed(changed); + } + + None + } +} + +impl Stream for DeviceChangedStream { + type Item = NmChange; + + fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let mut pinned = self.project(); + Poll::Ready(loop { + let item = ready!(pinned.inner.as_mut().poll_next(cx)); + let next_value = match item { + Some((_, message)) => Self::handle_message(message), + _ => None, + }; + if next_value.is_some() { + break next_value; + } + }) + } +} diff --git a/rust/agama-server/src/network/nm/watcher.rs b/rust/agama-server/src/network/nm/watcher.rs index 33070c626b..065eca6550 100644 --- a/rust/agama-server/src/network/nm/watcher.rs +++ b/rust/agama-server/src/network/nm/watcher.rs @@ -1,4 +1,4 @@ -// Copyright (c) [2024] SUSE LLC +// Copyright (c) [2024-2025] SUSE LLC // // All Rights Reserved. // @@ -23,30 +23,24 @@ //! Monitors NetworkManager's D-Bus service and emit [actions](crate::network::Action] to update //! the NetworkSystem state when devices or active connections change. +use std::collections::{hash_map::Entry, HashMap}; + use crate::network::{ adapter::Watcher, model::Device, nm::proxies::DeviceProxy, Action, NetworkAdapterError, }; use agama_lib::error::ServiceError; use async_trait::async_trait; -use futures_util::ready; -use pin_project::pin_project; -use std::{ - collections::{hash_map::Entry, HashMap}, - pin::Pin, - task::{Context, Poll}, -}; use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender}; -use tokio_stream::{Stream, StreamExt, StreamMap}; -use zbus::{ - fdo::{InterfacesAdded, InterfacesRemoved, PropertiesChanged}, - message::Type as MessageType, - names::InterfaceName, - zvariant::OwnedObjectPath, - MatchRule, Message, MessageStream, +use tokio_stream::StreamExt; +use zbus::zvariant::OwnedObjectPath; + +use super::{ + builder::DeviceFromProxyBuilder, + model::NmConnectionState, + proxies::{ActiveConnectionProxy, NetworkManagerProxy}, + streams::{ActiveConnectionChangedStream, DeviceChangedStream, NmChange}, }; -use super::{builder::DeviceFromProxyBuilder, proxies::NetworkManagerProxy}; - /// Implements a [crate::network::adapter::Watcher] for NetworkManager. /// /// This process is composed of the following pieces: @@ -86,9 +80,24 @@ impl Watcher for NetworkManagerWatcher { // Process the DeviceChangedStream in a separate task. let connection = self.connection.clone(); + let tx_clone = tx.clone(); tokio::spawn(async move { let mut stream = DeviceChangedStream::new(&connection).await.unwrap(); + while let Some(change) = stream.next().await { + if let Err(e) = tx_clone.send(change) { + tracing::error!("Could not dispatch a network change: {e}"); + } + } + }); + + // Process the ConnectionChangedStream in a separate task. + let connection = self.connection.clone(); + tokio::spawn(async move { + let mut stream = ActiveConnectionChangedStream::new(&connection) + .await + .unwrap(); + while let Some(change) = stream.next().await { if let Err(e) = tx.send(change) { tracing::error!("Could not dispatch a network change: {e}"); @@ -109,7 +118,7 @@ impl Watcher for NetworkManagerWatcher { struct ActionDispatcher<'a> { connection: zbus::Connection, proxies: ProxiesRegistry<'a>, - updates_rx: UnboundedReceiver, + updates_rx: UnboundedReceiver, actions_tx: UnboundedSender, } @@ -121,7 +130,7 @@ impl ActionDispatcher<'_> { /// * `actions_tx`: Channel to dispatch the network actions. pub fn new( connection: zbus::Connection, - updates_rx: UnboundedReceiver, + updates_rx: UnboundedReceiver, actions_tx: UnboundedSender, ) -> Self { Self { @@ -139,11 +148,17 @@ impl ActionDispatcher<'_> { self.read_devices().await?; while let Some(update) = self.updates_rx.recv().await { let result = match update { - DeviceChange::DeviceAdded(path) => self.handle_device_added(path).await, - DeviceChange::DeviceUpdated(path) => self.handle_device_updated(path).await, - DeviceChange::DeviceRemoved(path) => self.handle_device_removed(path).await, - DeviceChange::IP4ConfigChanged(path) => self.handle_ip4_config_changed(path).await, - DeviceChange::IP6ConfigChanged(path) => self.handle_ip6_config_changed(path).await, + NmChange::DeviceAdded(path) => self.handle_device_added(path).await, + NmChange::DeviceUpdated(path) => self.handle_device_updated(path).await, + NmChange::DeviceRemoved(path) => self.handle_device_removed(path).await, + NmChange::IP4ConfigChanged(path) => self.handle_ip4_config_changed(path).await, + NmChange::IP6ConfigChanged(path) => self.handle_ip6_config_changed(path).await, + NmChange::ActiveConnectionAdded(path) | NmChange::ActiveConnectionUpdated(path) => { + self.handle_active_connection_updated(path).await + } + NmChange::ActiveConnectionRemoved(path) => { + self.handle_active_connection_removed(path).await + } }; if let Err(error) = result { @@ -231,197 +246,61 @@ impl ActionDispatcher<'_> { Ok(()) } - async fn device_from_proxy( - connection: &zbus::Connection, - proxy: DeviceProxy<'_>, - ) -> Result { - let builder = DeviceFromProxyBuilder::new(connection, &proxy); - builder.build().await - } -} - -/// Stream of device-related events. -/// -/// This stream listens for many NetworkManager events that are related to network devices (state, -/// IP configuration, etc.) and converts them into variants of the [DeviceChange] enum. -/// -/// It is implemented as a struct because it needs to keep the ObjectManagerProxy alive. -#[pin_project] -struct DeviceChangedStream { - connection: zbus::Connection, - #[pin] - inner: StreamMap<&'static str, MessageStream>, -} - -impl DeviceChangedStream { - /// Builds a new stream using the given D-Bus connection. + /// Handles the case where a new active connection appears. /// - /// * `connection`: D-Bus connection. - pub async fn new(connection: &zbus::Connection) -> Result { - let connection = connection.clone(); - let mut inner = StreamMap::new(); - inner.insert( - "object_manager", - build_added_and_removed_stream(&connection).await?, - ); - inner.insert( - "properties", - build_properties_changed_stream(&connection).await?, - ); - Ok(Self { connection, inner }) - } - - fn handle_added(message: InterfacesAdded) -> Option { - let args = message.args().ok()?; - let interfaces: Vec = args - .interfaces_and_properties() - .keys() - .map(|i| i.to_string()) - .collect(); - - if interfaces.contains(&"org.freedesktop.NetworkManager.Device".to_string()) { - let path = OwnedObjectPath::from(args.object_path().clone()); - return Some(DeviceChange::DeviceAdded(path)); - } - - None - } - - fn handle_removed(message: InterfacesRemoved) -> Option { - let args = message.args().ok()?; - - let interface = InterfaceName::from_str_unchecked("org.freedesktop.NetworkManager.Device"); - if args.interfaces.contains(&interface) { - let path = OwnedObjectPath::from(args.object_path().clone()); - return Some(DeviceChange::DeviceRemoved(path)); + /// * `path`: D-Bus object path of the new active connection. + async fn handle_active_connection_updated( + &mut self, + path: OwnedObjectPath, + ) -> Result<(), ServiceError> { + let proxy = self.proxies.find_or_add_active_connection(&path).await?; + let id = proxy.id().await?; + let state = proxy.state().await.map(|s| NmConnectionState(s.clone()))?; + if let Ok(state) = state.try_into() { + _ = self + .actions_tx + .send(Action::ChangeConnectionState(id, state)); } + // TODO: report an error if the device cannot get generated - None + Ok(()) } - fn handle_changed(message: PropertiesChanged) -> Option { - const IP_CONFIG_PROPS: &[&str] = &["AddressData", "Gateway", "NameserverData", "RouteData"]; - const DEVICE_PROPS: &[&str] = &[ - "DeviceType", - "HwAddress", - "Interface", - "State", - "StateReason", - ]; - - let args = message.args().ok()?; - let inner = message.message(); - let path = OwnedObjectPath::from(inner.header().path()?.to_owned()); - - match args.interface_name.as_str() { - "org.freedesktop.NetworkManager.IP4Config" => { - if Self::include_properties(IP_CONFIG_PROPS, &args.changed_properties) { - return Some(DeviceChange::IP4ConfigChanged(path)); - } - } - "org.freedesktop.NetworkManager.IP6Config" => { - if Self::include_properties(IP_CONFIG_PROPS, &args.changed_properties) { - return Some(DeviceChange::IP6ConfigChanged(path)); - } - } - "org.freedesktop.NetworkManager.Device" => { - if Self::include_properties(DEVICE_PROPS, &args.changed_properties) { - return Some(DeviceChange::DeviceUpdated(path)); - } + /// Handles the case where a device is removed. + /// + /// * `path`: D-Bus object path of the removed device. + async fn handle_active_connection_removed( + &mut self, + path: OwnedObjectPath, + ) -> Result<(), ServiceError> { + if let Some(proxy) = self.proxies.remove_active_connection(&path) { + let id = proxy.id().await?; + let state = proxy.state().await.map(|s| NmConnectionState(s.clone()))?; + if let Ok(state) = state.try_into() { + _ = self + .actions_tx + .send(Action::ChangeConnectionState(id, state)); } - _ => {} - }; - None - } - - fn include_properties( - wanted: &[&str], - changed: &HashMap<&'_ str, zbus::zvariant::Value<'_>>, - ) -> bool { - let properties: Vec<_> = changed.keys().collect(); - wanted.iter().any(|i| properties.contains(&i)) - } - - fn handle_message(message: Result) -> Option { - let Ok(message) = message else { - return None; - }; - - if let Some(added) = InterfacesAdded::from_message(message.clone()) { - return Self::handle_added(added); - } - - if let Some(removed) = InterfacesRemoved::from_message(message.clone()) { - return Self::handle_removed(removed); } - if let Some(changed) = PropertiesChanged::from_message(message.clone()) { - return Self::handle_changed(changed); - } - - None + Ok(()) } -} -impl Stream for DeviceChangedStream { - type Item = DeviceChange; - - fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - let mut pinned = self.project(); - Poll::Ready(loop { - let item = ready!(pinned.inner.as_mut().poll_next(cx)); - let next_value = match item { - Some((_, message)) => Self::handle_message(message), - _ => None, - }; - if next_value.is_some() { - break next_value; - } - }) + async fn device_from_proxy( + connection: &zbus::Connection, + proxy: DeviceProxy<'_>, + ) -> Result { + let builder = DeviceFromProxyBuilder::new(connection, &proxy); + builder.build().await } } -async fn build_added_and_removed_stream( - connection: &zbus::Connection, -) -> Result { - let rule = MatchRule::builder() - .msg_type(MessageType::Signal) - .path("/org/freedesktop")? - .interface("org.freedesktop.DBus.ObjectManager")? - .build(); - let stream = MessageStream::for_match_rule(rule, connection, Some(1)).await?; - Ok(stream) -} - -/// Returns a stream of properties changes to be used by DeviceChangedStream. -/// -/// It listens for changes in several objects that are related to a network device. -async fn build_properties_changed_stream( - connection: &zbus::Connection, -) -> Result { - let rule = MatchRule::builder() - .msg_type(MessageType::Signal) - .interface("org.freedesktop.DBus.Properties")? - .member("PropertiesChanged")? - .build(); - let stream = MessageStream::for_match_rule(rule, connection, Some(1)).await?; - Ok(stream) -} - -#[derive(Debug, Clone)] -enum DeviceChange { - DeviceAdded(OwnedObjectPath), - DeviceUpdated(OwnedObjectPath), - DeviceRemoved(OwnedObjectPath), - IP4ConfigChanged(OwnedObjectPath), - IP6ConfigChanged(OwnedObjectPath), -} - /// Ancillary class to track the devices and their related D-Bus objects. -struct ProxiesRegistry<'a> { +pub struct ProxiesRegistry<'a> { connection: zbus::Connection, // the String is the device name like eth0 devices: HashMap)>, + active_connections: HashMap>, } impl<'a> ProxiesRegistry<'a> { @@ -429,6 +308,7 @@ impl<'a> ProxiesRegistry<'a> { Self { connection: connection.clone(), devices: HashMap::new(), + active_connections: HashMap::new(), } } @@ -454,6 +334,37 @@ impl<'a> ProxiesRegistry<'a> { } } + /// Finds or adds an active connection to the registry. + /// + /// * `path`: D-Bus object path. + pub async fn find_or_add_active_connection( + &mut self, + path: &OwnedObjectPath, + ) -> Result<&ActiveConnectionProxy<'a>, ServiceError> { + // Cannot use entry(...).or_insert_with(...) because of the async call. + match self.active_connections.entry(path.clone()) { + Entry::Vacant(entry) => { + let proxy = ActiveConnectionProxy::builder(&self.connection.clone()) + .path(path.clone())? + .build() + .await?; + + Ok(entry.insert(proxy)) + } + Entry::Occupied(entry) => Ok(entry.into_mut()), + } + } + + /// Removes an active connection from the registry. + /// + /// * `path`: D-Bus object path. + pub fn remove_active_connection( + &mut self, + path: &OwnedObjectPath, + ) -> Option { + self.active_connections.remove(path) + } + /// Removes a device from the registry. /// /// * `path`: D-Bus object path. diff --git a/rust/agama-server/src/network/system.rs b/rust/agama-server/src/network/system.rs index 94939f53cf..0edd5f45ef 100644 --- a/rust/agama-server/src/network/system.rs +++ b/rust/agama-server/src/network/system.rs @@ -327,6 +327,11 @@ impl NetworkSystemServer { return Ok(Some(NetworkChange::DeviceAdded(*device))); } Action::UpdateDevice(name, device) => { + if let Some(old_device) = self.state.get_device(&name) { + if old_device == device.as_ref() { + return Ok(None); + } + } self.state.update_device(&name, *device.clone())?; return Ok(Some(NetworkChange::DeviceUpdated(name, *device))); } @@ -345,6 +350,12 @@ impl NetworkSystemServer { let result = self.state.update_connection(*conn); tx.send(result).unwrap(); } + Action::ChangeConnectionState(id, state) => { + if let Some(conn) = self.state.get_connection_mut(&id) { + conn.state = state; + return Ok(Some(NetworkChange::ConnectionStateChanged { id, state })); + } + } Action::UpdateGeneralState(general_state) => { self.state.general_state = general_state; } diff --git a/rust/agama-server/src/network/web.rs b/rust/agama-server/src/network/web.rs index 5c0218fd8a..a8f42db8f2 100644 --- a/rust/agama-server/src/network/web.rs +++ b/rust/agama-server/src/network/web.rs @@ -41,7 +41,10 @@ use super::{ }; use crate::network::{model::Connection, model::Device, NetworkSystem}; -use agama_lib::{error::ServiceError, network::settings::NetworkConnection}; +use agama_lib::{ + error::ServiceError, + network::{settings::NetworkConnection, types::NetworkConnectionWithState}, +}; use serde_json::json; use thiserror::Error; @@ -209,14 +212,21 @@ async fn devices( )] async fn connections( State(state): State, -) -> Result>, NetworkError> { +) -> Result>, NetworkError> { let connections = state.network.get_connections().await?; - let connections = connections - .iter() - .map(|c| NetworkConnection::try_from(c.clone()).unwrap()) - .collect(); + let mut result = vec![]; + + for conn in connections { + let state = conn.state.clone(); + let network_connection = NetworkConnection::try_from(conn)?; + let connection_with_state = NetworkConnectionWithState { + connection: network_connection, + state, + }; + result.push(connection_with_state); + } - Ok(Json(connections)) + Ok(Json(result)) } #[utoipa::path( diff --git a/rust/agama-server/src/web/docs/network.rs b/rust/agama-server/src/web/docs/network.rs index 93d4789e58..604eac227a 100644 --- a/rust/agama-server/src/web/docs/network.rs +++ b/rust/agama-server/src/web/docs/network.rs @@ -57,6 +57,7 @@ impl ApiDocBuilder for NetworkApiDocBuilder { .schema_from::() .schema_from::() .schema_from::() + .schema_from::() .schema_from::() .schema_from::() .schema_from::() diff --git a/rust/agama-server/tests/network_service.rs b/rust/agama-server/tests/network_service.rs index d1ec7884fb..7bad291c96 100644 --- a/rust/agama-server/tests/network_service.rs +++ b/rust/agama-server/tests/network_service.rs @@ -89,7 +89,7 @@ async fn test_network_state() -> Result<(), Box> { let response = network_service.oneshot(request).await?; assert_eq!(response.status(), StatusCode::OK); let body = body_to_string(response.into_body()).await; - assert!(body.contains(r#""wireless_enabled":false"#)); + assert!(body.contains(r#""wirelessEnabled":false"#)); Ok(()) } diff --git a/rust/package/agama.changes b/rust/package/agama.changes index 4e229ae60b..14caa0ef44 100644 --- a/rust/package/agama.changes +++ b/rust/package/agama.changes @@ -1,3 +1,9 @@ +------------------------------------------------------------------- +Mon Apr 21 12:46:07 UTC 2025 - Imobach Gonzalez Sosa + +- Report and emit changes to the connections states. (gh#agama-project/agama#2247). +- Do not write wireless security settings when they are not used. + ------------------------------------------------------------------- Wed Apr 16 10:45:33 UTC 2025 - José Iván López González diff --git a/web/package/agama-web-ui.changes b/web/package/agama-web-ui.changes index d6ea8b56b7..702ee8a06b 100644 --- a/web/package/agama-web-ui.changes +++ b/web/package/agama-web-ui.changes @@ -1,3 +1,14 @@ +------------------------------------------------------------------- +Mon Apr 21 12:37:20 UTC 2025 - Imobach Gonzalez Sosa + +- Rework the network page to (gh#agama-project/agama#2247): + - Fix several problems with Wi-Fi networks handling. + - Improve error reporting when the connection failed. + - Use a regular form to connect, instead of the old "drawer" + component. + - Drop the "disconnect" and "forget" actions by now. + - Reduce the amount of requests to the backend. + ------------------------------------------------------------------- Wed Apr 16 06:09:58 UTC 2025 - José Iván López González diff --git a/web/src/assets/styles/index.scss b/web/src/assets/styles/index.scss index 82de4a1f73..34951881fe 100644 --- a/web/src/assets/styles/index.scss +++ b/web/src/assets/styles/index.scss @@ -389,6 +389,13 @@ label.pf-m-disabled + .pf-v6-c-check__description { justify-self: flex-start; } +// Fix select toggle icon visibility by creating more space at the end :/ +.pf-v6-c-form-control > select { + --pf-v6-c-form-control--PaddingInlineEnd: calc( + var(--pf-v6-c-form-control__select--PaddingInlineEnd) * 3 + ); +} + .pf-v6-c-check { row-gap: var(--pf-t--global--spacer--xs); align-content: baseline; @@ -403,6 +410,10 @@ label.pf-m-disabled + .pf-v6-c-check__description { --pf-v6-c-list--m-inline--ColumnGap: var(--pf-t--global--spacer--md); } +.pf-v6-c-data-list li.pf-m-clickable { + --pf-v6-c-data-list__item--hover--BackgroundColor: var(--agm-t--action--background--color--hover); +} + // Nested content inside forms should respect parent grid gap .pf-v6-c-form [class*="pf-v6-u-mx"] { display: grid; diff --git a/web/src/components/layout/Icon.tsx b/web/src/components/layout/Icon.tsx index a125de5311..5530404d00 100644 --- a/web/src/components/layout/Icon.tsx +++ b/web/src/components/layout/Icon.tsx @@ -43,8 +43,10 @@ import Lock from "@icons/lock.svg?component"; import ManageAccounts from "@icons/manage_accounts.svg?component"; import Menu from "@icons/menu.svg?component"; import MoreVert from "@icons/more_vert.svg?component"; +import NetworkWifi from "@icons/network_wifi.svg?component"; +import NetworkWifi1Bar from "@icons/network_wifi_1_bar.svg?component"; +import NetworkWifi3Bar from "@icons/network_wifi_3_bar.svg?component"; import SettingsEthernet from "@icons/settings_ethernet.svg?component"; -import SignalCellularAlt from "@icons/signal_cellular_alt.svg?component"; import Warning from "@icons/warning.svg?component"; import Visibility from "@icons/visibility.svg?component"; import VisibilityOff from "@icons/visibility_off.svg?component"; @@ -71,8 +73,10 @@ const icons = { manage_accounts: ManageAccounts, menu: Menu, more_vert: MoreVert, + network_wifi: NetworkWifi, + network_wifi_1_bar: NetworkWifi1Bar, + network_wifi_3_bar: NetworkWifi3Bar, settings_ethernet: SettingsEthernet, - signal_cellular_alt: SignalCellularAlt, visibility: Visibility, visibility_off: VisibilityOff, warning: Warning, diff --git a/web/src/components/network/AddressesDataList.tsx b/web/src/components/network/AddressesDataList.tsx index a68be69682..67dff18ed4 100644 --- a/web/src/components/network/AddressesDataList.tsx +++ b/web/src/components/network/AddressesDataList.tsx @@ -36,10 +36,9 @@ import { DataListCell, DataListAction, Flex, - Stack, + FormGroup, } from "@patternfly/react-core"; -import { FormLabel } from "~/components/core"; import IpAddressInput from "~/components/network/IpAddressInput"; import IpPrefixInput from "~/components/network/IpPrefixInput"; import { _ } from "~/i18n"; @@ -139,19 +138,20 @@ export default function AddressesDataList({ const newAddressButtonText = addresses.length ? _("Add another address") : _("Add an address"); return ( - - - {_("Addresses")} - - {/** FIXME: try to use an aria-labelledby instead when PatternFly permits it (or open a bug report) */} - - {addresses.map((address) => renderAddress(address))} - - + + + {/** FIXME: try to use an aria-labelledby instead when PatternFly permits it (or open a bug report) */} + + {addresses.map((address) => renderAddress(address))} + - + ); } diff --git a/web/src/components/network/ConnectionsTable.test.tsx b/web/src/components/network/ConnectionsTable.test.tsx index 025405ead6..721cc4f810 100644 --- a/web/src/components/network/ConnectionsTable.test.tsx +++ b/web/src/components/network/ConnectionsTable.test.tsx @@ -32,7 +32,7 @@ const enp1s0: Device = { name: "enp1s0", connection: "enp1s0", type: ConnectionType.ETHERNET, - state: DeviceState.ACTIVATED, + state: DeviceState.CONNECTED, addresses: [{ address: "192.168.69.200", prefix: 24 }], nameservers: ["192.168.69.1"], method4: ConnectionMethod.MANUAL, @@ -46,7 +46,7 @@ const wlan0: Device = { name: "wlan0", connection: "WiFi", type: ConnectionType.WIFI, - state: DeviceState.ACTIVATED, + state: DeviceState.CONNECTED, addresses: [{ address: "192.168.69.201", prefix: 24 }], nameservers: ["192.168.69.1"], method4: ConnectionMethod.MANUAL, diff --git a/web/src/components/network/DnsDataList.tsx b/web/src/components/network/DnsDataList.tsx index 6e67b1c8d7..1306c4dfb2 100644 --- a/web/src/components/network/DnsDataList.tsx +++ b/web/src/components/network/DnsDataList.tsx @@ -36,10 +36,9 @@ import { DataListCell, DataListAction, Flex, - Stack, + FormGroup, } from "@patternfly/react-core"; -import { FormLabel } from "~/components/core"; import IpAddressInput from "~/components/network/IpAddressInput"; import { _ } from "~/i18n"; @@ -116,19 +115,20 @@ export default function DnsDataList({ const newDnsButtonText = servers.length ? _("Add another DNS") : _("Add DNS"); return ( - - - {_("DNS")} - - {/** FIXME: try to use an aria-labelledby instead when PatternFly permits it (or open a bug report) */} - - {servers.map((server) => renderDns(server))} - - + + + {/** FIXME: try to use an aria-labelledby instead when PatternFly permits it (or open a bug report) */} + + {servers.map((server) => renderDns(server))} + - + ); } diff --git a/web/src/components/network/IpSettingsForm.tsx b/web/src/components/network/IpSettingsForm.tsx index 4712b8c186..48b44fc310 100644 --- a/web/src/components/network/IpSettingsForm.tsx +++ b/web/src/components/network/IpSettingsForm.tsx @@ -23,6 +23,7 @@ import React, { useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { + ActionGroup, Alert, Content, Form, @@ -31,11 +32,8 @@ import { FormSelect, FormSelectOption, FormSelectProps, - Grid, - GridItem, HelperText, HelperTextItem, - Stack, TextInput, } from "@patternfly/react-core"; import { Page } from "~/components/core"; @@ -73,7 +71,8 @@ export default function IpSettingsForm() { return isSetAsInvalid(field) ? "error" : "default"; }; - const cleanAddresses = (addrs: IPAddress[]) => addrs.filter((addr) => addr.address !== ""); + const cleanAddresses = (addresses: IPAddress[]) => + addresses.filter((address) => address.address !== ""); const cleanError = (field: string) => { if (isSetAsInvalid(field)) { @@ -161,84 +160,64 @@ export default function IpSettingsForm() { )}
- - - - - - - - {/* TRANSLATORS: manual network configuration mode with a static IP address */} - - - {renderError("method")} - - - setGateway(value)} - /> - {isGatewayDisabled && ( - - - - {/** FIXME: check if that afirmation is true */} - {_("Gateway can be defined only in 'Manual' mode")} - - - - )} - - - - - - - - - - - - - - - - - + + + + {/* TRANSLATORS: manual network configuration mode with a static IP address */} + + + {renderError("method")} + + + setGateway(value)} + /> + {isGatewayDisabled && ( + + + + {/** FIXME: check if that affirmation is true */} + {_("Gateway can be defined only in 'Manual' mode")} + + + + )} + + + + + + + + + {_("Cancel")} + - - - - - ); } diff --git a/web/src/components/network/NetworkPage.test.tsx b/web/src/components/network/NetworkPage.test.tsx index 841bb245cb..873631ce2d 100644 --- a/web/src/components/network/NetworkPage.test.tsx +++ b/web/src/components/network/NetworkPage.test.tsx @@ -21,124 +21,57 @@ */ import React from "react"; -import { screen, within } from "@testing-library/react"; +import { screen } from "@testing-library/react"; import { installerRender } from "~/test-utils"; import NetworkPage from "~/components/network/NetworkPage"; -import { Connection, ConnectionMethod, ConnectionStatus, ConnectionType } from "~/types/network"; -jest.mock("~/components/product/ProductRegistrationAlert", () => () => ( -
ProductRegistrationAlert Mock
-)); - -const wiredConnection = new Connection("eth0", { - iface: "eth0", - method4: ConnectionMethod.MANUAL, - method6: ConnectionMethod.MANUAL, - addresses: [{ address: "192.168.122.20", prefix: 24 }], - nameservers: ["192.168.122.1"], - gateway4: "192.168.122.1", -}); - -const wifiConnection = new Connection("AgamaNetwork", { - iface: "wlan0", - method4: ConnectionMethod.AUTO, - method6: ConnectionMethod.AUTO, - wireless: { - ssid: "Agama", - security: "wpa-psk", - mode: "infrastructure", - password: "agama.test", - }, - addresses: [{ address: "192.168.69.200", prefix: 24 }], - nameservers: [], -}); +jest.mock( + "~/components/product/ProductRegistrationAlert", + () => () => -(
ProductRegistrationAlert Mock
), +); -const ethernetDevice = { - name: "eth0", - connection: "eth0", - type: ConnectionType.ETHERNET, - addresses: [{ address: "192.168.122.20", prefix: 24 }], - macAddress: "00:11:22:33:44::55", - status: ConnectionStatus.UP, -}; +jest.mock("~/components/network/WifiNetworksList", () => () =>
WifiNetworksList Mock
); -const wifiDevice = { - name: "wlan0", - connection: "AgamaNetwork", - type: ConnectionType.WIFI, - state: "activated", - addresses: [{ address: "192.168.69.200", prefix: 24 }], - macAddress: "AA:11:22:33:44::FF", - status: ConnectionStatus.UP, -}; +jest.mock("~/components/network/WiredConnectionsList", () => () => ( +
WiredConnectionsList Mock
+)); -const mockDevices = [ethernetDevice, wifiDevice]; -let mockActiveConnections = [wiredConnection, wifiConnection]; -let mockNetworkSettings = { - wireless_enabled: true, +const mockNetworkState = { + wirelessEnabled: true, }; -const mockAccessPoints = []; - jest.mock("~/queries/network", () => ({ - useNetworkConfigChanges: jest.fn(), - useNetwork: () => ({ - connections: mockActiveConnections, - devices: mockDevices, - settings: mockNetworkSettings, - accessPoints: mockAccessPoints, - }), + useNetworkChanges: jest.fn(), + useNetworkState: () => mockNetworkState, })); describe("NetworkPage", () => { it("renders a section for wired connections", () => { installerRender(); - const section = screen.getByRole("region", { name: "Wired" }); - within(section).getByText("eth0"); - within(section).getByText("192.168.122.20/24"); - }); - - it("renders a section for WiFi connections", () => { - installerRender(); - const section = screen.getByRole("region", { name: "Wi-Fi" }); - within(section).getByText("Connected to AgamaNetwork"); - within(section).getByText("192.168.69.200/24"); - }); - - describe("when wired connection were not found", () => { - beforeEach(() => { - mockActiveConnections = [wifiConnection]; - }); - - it("renders information about it", () => { - installerRender(); - screen.getByText("No wired connections found"); - }); + expect(screen.queryByText("WiredConnectionsList Mock")).toBeInTheDocument(); }); - describe("when WiFi scan is supported but no connection found", () => { + describe("when Wi-Fi support is enabled", () => { beforeEach(() => { - mockActiveConnections = [wiredConnection]; + mockNetworkState.wirelessEnabled = true; }); - it("renders information about it and a link going to the connection page", () => { + it("renders the list of Wi-Fi networks", () => { installerRender(); - const section = screen.getByRole("region", { name: "Wi-Fi" }); - within(section).getByText("No connected yet"); - within(section).getByRole("link", { name: "Connect" }); + expect(screen.queryByText("WifiNetworksList Mock")).toBeInTheDocument(); }); }); - describe("when WiFi scan is not supported", () => { + describe("when Wi-Fi support is disabled", () => { beforeEach(() => { - mockNetworkSettings = { wireless_enabled: false }; + mockNetworkState.wirelessEnabled = false; }); - it("renders information about it, without links for connecting", async () => { + it("does not render the list of Wi-Fi networks", () => { installerRender(); - screen.getByText("No Wi-Fi supported"); - const connectionButton = screen.queryByRole("link", { name: "Connect" }); - expect(connectionButton).toBeNull(); + expect( + screen.queryByText(/The system does not support Wi-Fi connections/), + ).toBeInTheDocument(); }); }); }); diff --git a/web/src/components/network/NetworkPage.tsx b/web/src/components/network/NetworkPage.tsx index 05d318c937..56b193a074 100644 --- a/web/src/components/network/NetworkPage.tsx +++ b/web/src/components/network/NetworkPage.tsx @@ -22,69 +22,15 @@ import React from "react"; import { Content, Grid, GridItem } from "@patternfly/react-core"; -import { Link, EmptyState, Page } from "~/components/core"; -import ConnectionsTable from "~/components/network/ConnectionsTable"; +import { EmptyState, Page } from "~/components/core"; import { _ } from "~/i18n"; -import { connectionAddresses } from "~/utils/network"; -import { sprintf } from "sprintf-js"; -import { useNetwork, useNetworkConfigChanges } from "~/queries/network"; -import { NETWORK as PATHS } from "~/routes/paths"; -import { partition } from "~/utils"; -import { Connection, Device } from "~/types/network"; - -const WiredConnections = ({ connections, devices }) => { - const wiredConnections = connections.length; - - const sectionProps = wiredConnections > 0 ? { title: _("Wired") } : {}; - - return ( - - {wiredConnections > 0 ? ( - - ) : ( - - )} - - ); -}; - -const WifiConnections = ({ connections, devices }) => { - const activeWifiDevice = devices.find( - (d: Device) => d.type === "wireless" && d.state === "activated", - ); - const activeConnection = connections.find( - (c: Connection) => c.id === activeWifiDevice?.connection, - ); - - return ( - - {activeConnection ? _("Change") : _("Connect")} - - } - > - {activeConnection ? ( - - {connectionAddresses(activeConnection, devices)} - - ) : ( - - {_("The system has not been configured for connecting to a Wi-Fi network yet.")} - - )} - - ); -}; +import { useNetworkChanges, useNetworkState } from "~/queries/network"; +import WifiNetworksList from "./WifiNetworksList"; +import WiredConnectionsList from "./WiredConnectionsList"; const NoWifiAvailable = () => ( - + {_( "The system does not support Wi-Fi connections, probably because of missing or disabled hardware.", )} @@ -96,9 +42,8 @@ const NoWifiAvailable = () => ( * Page component holding Network settings */ export default function NetworkPage() { - useNetworkConfigChanges(); - const { connections, devices, settings } = useNetwork(); - const [wifiConnections, wiredConnections] = partition(connections, (c) => !!c.wireless); + useNetworkChanges(); + const networkState = useNetworkState(); return ( @@ -109,11 +54,15 @@ export default function NetworkPage() { - + + + - {settings.wireless_enabled ? ( - + {networkState.wirelessEnabled ? ( + + + ) : ( )} diff --git a/web/src/components/network/WifiConnectionDetails.tsx b/web/src/components/network/WifiConnectionDetails.tsx new file mode 100644 index 0000000000..0f92d5af44 --- /dev/null +++ b/web/src/components/network/WifiConnectionDetails.tsx @@ -0,0 +1,177 @@ +/* + * 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 { generatePath } from "react-router-dom"; +import { + DescriptionList, + DescriptionListDescription, + DescriptionListGroup, + DescriptionListTerm, + Flex, + FlexItem, + Grid, + GridItem, + Stack, +} from "@patternfly/react-core"; +import { Link, Page } from "~/components/core"; +import { Device, WifiNetwork } from "~/types/network"; +import { formatIp } from "~/utils/network"; +import { NETWORK } from "~/routes/paths"; +import { _ } from "~/i18n"; + +const NetworkDetails = ({ network }: { network: WifiNetwork }) => { + return ( + + + + {_("SSID")} + {network.ssid} + + + {_("Signal strength")} + {network.strength}% + + + {_("Status")} + {network.status} + + + {_("Security")} + {network.security} + + + + ); +}; + +const DeviceDetails = ({ device }: { device: Device }) => { + if (!device) return; + + return ( + + + + {_("Interface")} + {device.name} + + + {_("Status")} + {device.state} + + + {_("MAC")} + {device.macAddress} + + + + ); +}; + +const IpDetails = ({ device, settings }: { device: Device; settings: WifiNetwork["settings"] }) => { + if (!device) return; + + return ( + + {_("Edit")} + + } + > + + + {_("Mode")} + + + + {_("IPv4")} {settings.method4} + + + {_("IPv6")} {settings.method6} + + + + + + {_("Addresses")} + + + {device.addresses.map((ip, idx) => ( + {formatIp(ip)} + ))} + + + + + {_("Gateway")} + + + {device.gateway4} + {device.gateway6} + + + + + {_("DNS")} + + + {device.nameservers.map((dns, idx) => ( + {dns} + ))} + + + + + {_("Routes")} + + + {device.routes4.map((route, idx) => ( + {formatIp(route.destination)} + ))} + + + + + + ); +}; + +export default function WifiConnectionDetails({ network }: { network: WifiNetwork }) { + if (!network) return; + + return ( + + + + + + + + + + + + ); +} diff --git a/web/src/components/network/WifiConnectionForm.test.tsx b/web/src/components/network/WifiConnectionForm.test.tsx index c7ce53ff98..e12a17589e 100644 --- a/web/src/components/network/WifiConnectionForm.test.tsx +++ b/web/src/components/network/WifiConnectionForm.test.tsx @@ -21,41 +21,26 @@ */ import React from "react"; -import { screen, waitFor } from "@testing-library/react"; +import { screen } from "@testing-library/react"; import { plainRender } from "~/test-utils"; -import WifiConnectionForm from "~/components/network/WifiConnectionForm"; -import { - Connection, - SecurityProtocols, - WifiNetwork, - WifiNetworkStatus, - Wireless, -} from "~/types/network"; +import WifiConnectionForm from "./WifiConnectionForm"; +import { Connection, SecurityProtocols, WifiNetworkStatus, Wireless } from "~/types/network"; const mockAddConnection = jest.fn(); const mockUpdateConnection = jest.fn(); -const mockUpdateSelectedWifi = jest.fn(); -const mockOnCancelFn = jest.fn(); jest.mock("~/queries/network", () => ({ ...jest.requireActual("~/queries/network"), - useNetworkConfigChanges: jest.fn(), + useNetworkChanges: jest.fn(), useAddConnectionMutation: () => ({ - mutate: mockAddConnection, + mutateAsync: mockAddConnection, }), useConnectionMutation: () => ({ - mutate: mockUpdateConnection, - }), - useSelectedWifiChange: () => ({ - mutate: mockUpdateSelectedWifi, + mutateAsync: mockUpdateConnection, }), + useConnections: () => [], })); -const hiddenNetworkMock = { - hidden: true, - status: WifiNetworkStatus.NOT_CONFIGURED, -} as WifiNetwork; - const networkMock = { ssid: "Visible Network", hidden: false, @@ -68,81 +53,45 @@ const networkMock = { }), }; -const renderForm = (network: WifiNetwork, errors = {}) => - plainRender(); +const publicNetworkMock = { ...networkMock, security: [] }; describe("WifiConnectionForm", () => { - it("renders a generic warning when mounted with no needsAuth erorr", () => { - renderForm(networkMock, { errorsId: true }); - screen.getByText("Connect"); - screen.getByText("Warning alert:"); + beforeEach(() => { + mockAddConnection.mockResolvedValue(undefined); + mockUpdateConnection.mockResolvedValue(undefined); }); - it("renders an authentication failed warning when mounted with needsAuth erorr", () => { - renderForm(networkMock, { needsAuth: true }); - screen.getByText("Connect"); - screen.getByText("Warning alert:"); - screen.getByText(/Authentication failed/); - }); - - describe("when mounted for connecting to a hidden network", () => { - it("renders the SSID input", async () => { - renderForm(hiddenNetworkMock); - screen.getByRole("textbox", { name: "SSID" }); + describe("when rendered for a public network", () => { + it("warns the user about connecting to an unprotected network", () => { + plainRender(); + screen.getByText("Warning alert:"); + screen.getByText("Not protected network"); }); - }); - describe("when mounted for connecting to a visible network", () => { - it("does not render the SSID input", () => { - renderForm(networkMock); - expect(screen.queryByRole("textbox", { name: "SSID" })).not.toBeInTheDocument(); + it("renders only the Connect and Cancel actions", () => { + plainRender(); + expect(screen.queryByRole("combobox", { name: "Security" })).toBeNull(); + screen.getByRole("button", { name: "Connect" }); + screen.getByRole("button", { name: "Cancel" }); }); }); - describe("when form is send", () => { - // Note, not using rerender for next two test examples because it doesn not work always - // because previous first render somehow leaks in the next one. - it("updates information about selected network (visible network version)", async () => { - const { user } = renderForm(networkMock); + describe("when form is submitted", () => { + it("replaces form by an informative alert ", async () => { + const { user } = plainRender(); + screen.getByRole("form", { name: "Wi-Fi connection form" }); const connectButton = screen.getByRole("button", { name: "Connect" }); await user.click(connectButton); - expect(mockUpdateSelectedWifi).toHaveBeenCalledWith({ - ssid: "Visible Network", - needsAuth: null, - }); + expect(screen.queryByRole("form", { name: "Wi-Fi connection form" })).toBeNull(); + screen.getByText("Setting up connection"); }); - it("updates information about selected network (hidden network version)", async () => { - const { user } = renderForm(hiddenNetworkMock); - const ssidInput = screen.getByRole("textbox", { name: "SSID" }); - const connectButton = screen.getByRole("button", { name: "Connect" }); - await user.type(ssidInput, "Secret Network"); - await user.click(connectButton); - expect(mockUpdateSelectedWifi).toHaveBeenCalledWith({ - ssid: "Secret Network", - needsAuth: null, - }); - }); - - it("disables cancel and submission actions", async () => { - const { user } = renderForm(networkMock); - const connectButton = screen.getByRole("button", { name: "Connect" }); - const cancelButton = screen.getByRole("button", { name: "Cancel" }); - - expect(connectButton).not.toBeDisabled(); - expect(cancelButton).not.toBeDisabled(); - - await waitFor(() => { - user.click(connectButton); - expect(connectButton).toBeDisabled(); - expect(cancelButton).toBeDisabled(); - }); - }); + it.todo("re-render the form with an error if connection fails"); describe("for a not configured network", () => { it("triggers a mutation for adding and connecting to the network", async () => { const { settings: _, ...notConfiguredNetwork } = networkMock; - const { user } = renderForm(notConfiguredNetwork); + const { user } = plainRender(); const securitySelector = screen.getByRole("combobox", { name: "Security" }); const connectButton = screen.getByText("Connect"); await user.selectOptions(securitySelector, "wpa-psk"); @@ -161,15 +110,19 @@ describe("WifiConnectionForm", () => { describe("for an already configured network", () => { it("triggers a mutation for updating and connecting to the network", async () => { - const { user } = renderForm({ - ...networkMock, - settings: new Connection(networkMock.ssid, { - wireless: new Wireless({ - security: "wpa-psk", - password: "wrong-wifi-password", - }), - }), - }); + const { user } = plainRender( + , + ); const connectButton = screen.getByText("Connect"); const passwordInput = screen.getByLabelText("WPA Password"); await user.clear(passwordInput); @@ -189,27 +142,4 @@ describe("WifiConnectionForm", () => { }); }); }); - - it("allows connecting to hidden network", async () => { - const { user } = renderForm(hiddenNetworkMock); - const ssidInput = screen.getByRole("textbox", { name: "SSID" }); - const securitySelector = screen.getByRole("combobox", { name: "Security" }); - const wpaOption = screen.getByRole("option", { name: /WPA/ }); - const connectButton = screen.getByRole("button", { name: "Connect" }); - await user.type(ssidInput, "AHiddenNetwork"); - await user.selectOptions(securitySelector, wpaOption); - const passwordInput = screen.getByLabelText("WPA Password"); - await user.type(passwordInput, "ASecretPassword"); - await user.click(connectButton); - expect(mockAddConnection).toHaveBeenCalledWith( - expect.objectContaining({ - id: "AHiddenNetwork", - wireless: expect.objectContaining({ - hidden: true, - ssid: "AHiddenNetwork", - password: "ASecretPassword", - }), - }), - ); - }); }); diff --git a/web/src/components/network/WifiConnectionForm.tsx b/web/src/components/network/WifiConnectionForm.tsx index d25b65bd3a..b8c2035f14 100644 --- a/web/src/components/network/WifiConnectionForm.tsx +++ b/web/src/components/network/WifiConnectionForm.tsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2022-2024] SUSE LLC + * Copyright (c) [2022-2025] SUSE LLC * * All Rights Reserved. * @@ -20,139 +20,174 @@ * find current contact information at www.suse.com. */ -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; import { ActionGroup, Alert, - Button, + Content, Form, FormGroup, FormSelect, FormSelectOption, - TextInput, + Spinner, } from "@patternfly/react-core"; -import { PasswordInput } from "~/components/core"; -import { - useAddConnectionMutation, - useConnectionMutation, - useSelectedWifiChange, -} from "~/queries/network"; -import { Connection, WifiNetwork, Wireless } from "~/types/network"; +import { Page, PasswordInput } from "~/components/core"; +import { useAddConnectionMutation, useConnectionMutation, useConnections } from "~/queries/network"; +import { Connection, ConnectionState, WifiNetwork, Wireless } from "~/types/network"; import { _ } from "~/i18n"; +import { sprintf } from "sprintf-js"; +import { isEmpty } from "~/utils"; -/* - * FIXME: it should be moved to the SecurityProtocols enum that already exists or to a class based - * enum pattern in the network_manager adapter. - */ -const security_options = [ +const securityOptions = [ // TRANSLATORS: WiFi authentication mode - { value: "", label: _("None") }, + { value: "none", label: _("None") }, // TRANSLATORS: WiFi authentication mode { value: "wpa-psk", label: _("WPA & WPA2 Personal") }, ]; -const selectorOptions = security_options.map((security) => ( - -)); - -const securityFrom = (supported) => { +const securityFrom = (supported: string[]) => { if (supported.includes("WPA2")) return "wpa-psk"; if (supported.includes("WPA1")) return "wpa-psk"; return ""; }; +const PublicNetworkAlert = () => { + return ( + + + {_("You will connect to a public network without encryption. Your data may not be secure.")} + + + ); +}; + +const ConnectingAlert = () => { + return ( + } + title={_("Setting up connection")} + > + {_("It may take some time.")} + + {_("Details will appear after the connection is successfully established.")} + + + ); +}; + +const ConnectionError = ({ ssid, isPublicNetwork }) => { + // TRANSLATORS: %s will be replaced by network ssid. + const title = sprintf(_("Could not connect to %s"), ssid); + return ( + + {!isPublicNetwork && ( + {_("Check the authentication parameters.")} + )} + + ); +}; + // FIXME: improve error handling. The errors props should have a key/value error // and the component should show all of them, if any -export default function WifiConnectionForm({ - network, - errors = {}, - onCancel, -}: { - network: WifiNetwork; - errors?: { [key: string]: boolean | string }; - onCancel: () => void; -}) { +export default function WifiConnectionForm({ network }: { network: WifiNetwork }) { + const connections = useConnections(); + const connection = connections.find((c) => c.id === network.ssid); const settings = network.settings?.wireless || new Wireless(); - const { mutate: addConnection } = useAddConnectionMutation(); - const { mutate: updateConnection } = useConnectionMutation(); - const { mutate: updateSelectedNetwork } = useSelectedWifiChange(); - const [ssid, setSsid] = useState(network.ssid); + const [error, setError] = useState(false); const [security, setSecurity] = useState( settings?.security || securityFrom(network?.security || []), ); - const [password, setPassword] = useState(settings.password); - const [showErrors, setShowErrors] = useState(Object.keys(errors).length > 0); - const [isConnecting, setIsConnecting] = useState(false); - const hidden = network?.hidden || false; + const [password, setPassword] = useState(settings.password || ""); + const [isActivating, setIsActivating] = useState(false); + const [isConnecting, setIsConnecting] = useState( + connection?.state === ConnectionState.activating, + ); + const { mutateAsync: addConnection } = useAddConnectionMutation(); + const { mutateAsync: updateConnection } = useConnectionMutation(); + + useEffect(() => { + if (!isActivating) return; + + if (connection.state === ConnectionState.deactivated) { + setError(true); + setIsConnecting(false); + setIsActivating(false); + } + }, [isActivating, connection?.state]); + + useEffect(() => { + if (isConnecting && connection?.state === ConnectionState.activating) { + setIsActivating(true); + } + }, [isConnecting, connection]); const accept = async (e) => { e.preventDefault(); - setShowErrors(false); - setIsConnecting(true); - updateSelectedNetwork({ ssid, needsAuth: null }); - const connection = network.settings || new Connection(ssid); - connection.wireless = new Wireless({ ssid, security, password, hidden }); + // FIXME: do not mutate the original object! + const nextConnection = network.settings || new Connection(network.ssid); + nextConnection.wireless = new Wireless({ + ssid: network.ssid, + security: security || "none", + password, + hidden: false, + }); const action = network.settings ? updateConnection : addConnection; - action(connection); + action(nextConnection).catch(() => setError(true)); + setError(false); + setIsConnecting(true); }; - return ( - /** TRANSLATORS: accessible name for the WiFi connection form */ -
- {showErrors && ( - - {!errors.needsAuth &&

{_("Please, review provided settings and try again.")}

} -
- )} + const isPublicNetwork = isEmpty(network.security); - {hidden && ( - // TRANSLATORS: SSID (Wifi network name) configuration - - setSsid(v)} /> - - )} + if (isConnecting) return ; - {/* TRANSLATORS: Wifi security configuration (password protected or not) */} - - setSecurity(v)} - > - {selectorOptions} - - - {security === "wpa-psk" && ( - // TRANSLATORS: WiFi password - - setPassword(v)} - /> - - )} - - - {/* TRANSLATORS: button label */} - - - + return ( + <> + {isPublicNetwork && } + {/** TRANSLATORS: accessible name for the WiFi connection form */} +
+ {error && } + + {/* TRANSLATORS: Wifi security configuration (password protected or not) */} + {!isEmpty(network.security) && ( + + setSecurity(v)} + > + {securityOptions.map((security) => ( + + ))} + + + )} + {security === "wpa-psk" && ( + // TRANSLATORS: WiFi password + + setPassword(v)} + /> + + )} + + + {/* TRANSLATORS: button label, connect to a Wi-Fi network */} + {_("Connect")} + + {_("Cancel")} + + + ); } diff --git a/web/src/components/network/WifiNetworkPage.tsx b/web/src/components/network/WifiNetworkPage.tsx new file mode 100644 index 0000000000..7ff06ff184 --- /dev/null +++ b/web/src/components/network/WifiNetworkPage.tsx @@ -0,0 +1,84 @@ +/* + * 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 { useParams } from "react-router-dom"; +import { + Content, + EmptyState, + EmptyStateActions, + EmptyStateBody, + EmptyStateFooter, +} from "@patternfly/react-core"; +import { Link, Page } from "~/components/core"; +import { Icon } from "~/components/layout"; +import WifiConnectionForm from "./WifiConnectionForm"; +import WifiConnectionDetails from "./WifiConnectionDetails"; +import { useNetworkChanges, useWifiNetworks } from "~/queries/network"; +import { DeviceState } from "~/types/network"; +import { PATHS } from "~/routes/network"; +import { _ } from "~/i18n"; +import { sprintf } from "sprintf-js"; + +const NetworkNotFound = ({ ssid }) => { + // TRANSLATORS: %s will be replaced with the network ssid + const text = sprintf(_('"%s" does not exist or is no longer available.'), ssid); + return ( + } + > + {text} + + + + {_("Go to network page")} + + + + + ); +}; + +export default function WifiNetworkPage() { + useNetworkChanges(); + const { ssid } = useParams(); + const networks = useWifiNetworks(); + const network = networks.find((c) => c.ssid === ssid); + + const connected = network?.device?.state === DeviceState.CONNECTED; + const title = connected ? _("Connection details") : sprintf(_("Connect to %s"), ssid); + + return ( + + + {title} + + + {!network && } + {network && !connected && } + {network && connected && } + + + ); +} diff --git a/web/src/components/network/WifiNetworksListPage.test.tsx b/web/src/components/network/WifiNetworksList.test.tsx similarity index 51% rename from web/src/components/network/WifiNetworksListPage.test.tsx rename to web/src/components/network/WifiNetworksList.test.tsx index b9d0668cb9..9f53bc1e9d 100644 --- a/web/src/components/network/WifiNetworksListPage.test.tsx +++ b/web/src/components/network/WifiNetworksList.test.tsx @@ -20,12 +20,13 @@ */ import React from "react"; -import { screen } from "@testing-library/react"; +import { screen, within } from "@testing-library/react"; import { installerRender } from "~/test-utils"; -import WifiNetworksListPage from "~/components/network/WifiNetworksListPage"; +import WifiNetworksList from "~/components/network/WifiNetworksList"; import { Connection, ConnectionMethod, + ConnectionState, ConnectionType, Device, DeviceState, @@ -38,7 +39,7 @@ const wlan0: Device = { name: "wlan0", connection: "Network 1", type: ConnectionType.WIFI, - state: DeviceState.ACTIVATED, + state: DeviceState.CONNECTED, addresses: [{ address: "192.168.69.201", prefix: 24 }], nameservers: ["192.168.69.1"], method4: ConnectionMethod.MANUAL, @@ -51,13 +52,11 @@ const wlan0: Device = { const mockConnectionRemoval = jest.fn(); const mockAddConnection = jest.fn(); let mockWifiNetworks: WifiNetwork[]; +let mockWifiConnections: Connection[]; -// NOTE: mock only backend related queries. -// I.e., do not mock useSelectedWifi nor useSelectedWifiChange here to being able -// to test them along with user interactions jest.mock("~/queries/network", () => ({ ...jest.requireActual("~/queries/network"), - useNetworkConfigChanges: jest.fn(), + useNetworkChanges: jest.fn(), useRemoveConnectionMutation: () => ({ mutate: mockConnectionRemoval, }), @@ -65,15 +64,29 @@ jest.mock("~/queries/network", () => ({ mutate: mockAddConnection, }), useWifiNetworks: () => mockWifiNetworks, + useConnections: () => mockWifiConnections, })); -describe("WifiNetworksListPage", () => { +describe("WifiNetworksList", () => { describe("when visible networks are found", () => { beforeEach(() => { + mockWifiConnections = [ + new Connection("Newtwork 2", { + method4: ConnectionMethod.AUTO, + method6: ConnectionMethod.AUTO, + wireless: { + security: "none", + ssid: "Network 2", + mode: "infrastructure", + }, + state: ConnectionState.activating, + }), + ]; + mockWifiNetworks = [ { ssid: "Network 1", - strength: 4, + strength: 25, hwAddress: "??", security: [SecurityProtocols.RSN], device: wlan0, @@ -85,7 +98,7 @@ describe("WifiNetworksListPage", () => { }, { ssid: "Network 2", - strength: 8, + strength: 88, hwAddress: "??", security: [SecurityProtocols.RSN], settings: new Connection("Network 2", { @@ -96,63 +109,70 @@ describe("WifiNetworksListPage", () => { }, { ssid: "Network 3", - strength: 6, + strength: 66, hwAddress: "??", - security: [SecurityProtocols.RSN], + security: [], status: WifiNetworkStatus.NOT_CONFIGURED, }, ]; }); it("renders a list of available wifi networks", () => { - installerRender(); - screen.getByRole("listitem", { name: "Network 1" }); - screen.getByRole("listitem", { name: "Network 2" }); - screen.getByRole("listitem", { name: "Network 3" }); + // @ts-expect-error: you need to specify the aria-label + installerRender(); + screen.getByRole("listitem", { name: "Secured network Network 1 Weak signal" }); + screen.getByRole("listitem", { name: "Secured network Network 2 Excellent signal" }); + screen.getByRole("listitem", { name: "Public network Network 3 Good signal" }); }); - it("allows opening the connection form for a hidden network", async () => { - const { user } = installerRender(); - const button = screen.getByRole("button", { name: "Connect to hidden network" }); - await user.click(button); - screen.getByRole("heading", { name: "Connect to hidden network" }); - screen.getByRole("form", { name: "WiFi connection form" }); + it("renders a spinner in network in connecting state", () => { + // @ts-expect-error: you need to specify the aria-label + installerRender(); + const network2 = screen.getByRole("listitem", { + name: "Secured network Network 2 Excellent signal", + }); + within(network2).getByRole("progressbar", { name: "Connecting to Network 2" }); }); - describe("and user selects a connected network", () => { + describe.skip("and user selects a connected network", () => { it("renders basic network information and actions instead of the connection form", async () => { - const { user } = installerRender(); - const network1 = screen.getByRole("listitem", { name: "Network 1" }); + // @ts-expect-error: you need to specify the aria-label + const { user } = installerRender(); + const network1 = screen.getByRole("listitem", { + name: "Secured network Network 1 Weak signal", + }); await user.click(network1); - screen.getByRole("heading", { name: "Network 1" }); - expect(screen.queryByRole("form")).toBeNull(); + screen.getByRole("heading", { name: "Connection details" }); + expect(screen.queryByRole("form", { name: "Wi-Fi connection form" })).toBeNull(); screen.getByText("192.168.69.201/24"); - screen.getByRole("button", { name: "Disconnect" }); - screen.getByRole("link", { name: "Edit" }); - screen.getByRole("button", { name: "Forget" }); }); }); - describe("and user selects a configured network", () => { - it("renders actions instead of the connection form", async () => { - const { user } = installerRender(); - const network2 = screen.getByRole("listitem", { name: "Network 2" }); + describe.skip("and user selects a configured network", () => { + it("renders the connection form", async () => { + // @ts-expect-error: you need to specify the aria-label + const { user } = installerRender(); + const network2 = screen.getByRole("listitem", { + name: "Secured network Network 2 Excellent signal", + }); await user.click(network2); - screen.getByRole("heading", { name: "Network 2" }); - expect(screen.queryByRole("form")).toBeNull(); + screen.getByRole("heading", { name: "Connect to Network 2" }); + screen.queryByRole("form", { name: "Wi-Fi connection form" }); screen.getByRole("button", { name: "Connect" }); - screen.getByRole("link", { name: "Edit" }); - screen.getByRole("button", { name: "Forget" }); + screen.getByRole("button", { name: "Cancel" }); }); }); - describe("and user selects a not configured network", () => { + describe.skip("and user selects a not configured network", () => { it("renders the connection form", async () => { - const { user } = installerRender(); - const network3 = screen.getByRole("listitem", { name: "Network 3" }); + // @ts-expect-error: you need to specify the aria-label + const { user } = installerRender(); + const network3 = screen.getByRole("listitem", { + name: "Public network Network 3 Good signal", + }); await user.click(network3); - screen.getByRole("heading", { name: "Network 3" }); - screen.queryByRole("form", { name: "WiFi connection form" }); + screen.getByRole("heading", { name: "Connect to Network 3" }); + screen.queryByRole("form", { name: "Wi-Fi connection form" }); }); }); }); @@ -163,16 +183,9 @@ describe("WifiNetworksListPage", () => { }); it("renders information about it", () => { - installerRender(); - screen.getByText("No visible Wi-Fi networks found"); - }); - - it("allows opening the connection form for a hidden network", async () => { - const { user } = installerRender(); - const button = screen.getByRole("button", { name: "Connect to hidden network" }); - await user.click(button); - screen.getByRole("heading", { name: "Connect to hidden network" }); - screen.getByRole("form", { name: "WiFi connection form" }); + // @ts-expect-error: you need to specify the aria-label + installerRender(); + screen.getByText("No Wi-Fi networks were found"); }); }); }); diff --git a/web/src/components/network/WifiNetworksList.tsx b/web/src/components/network/WifiNetworksList.tsx new file mode 100644 index 0000000000..f4b9852aff --- /dev/null +++ b/web/src/components/network/WifiNetworksList.tsx @@ -0,0 +1,202 @@ +/* + * Copyright (c) [2024-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, { useId } from "react"; +import { generatePath, useNavigate } from "react-router-dom"; +import { + Content, + DataList, + DataListCell, + DataListItem, + DataListItemCells, + DataListItemRow, + DataListProps, + Flex, + Label, + Spinner, +} from "@patternfly/react-core"; +import a11yStyles from "@patternfly/react-styles/css/utilities/Accessibility/accessibility"; +import { EmptyState } from "~/components/core"; +import Icon, { IconProps } from "~/components/layout/Icon"; +import { Connection, ConnectionState, WifiNetwork, WifiNetworkStatus } from "~/types/network"; +import { useConnections, useNetworkChanges, useWifiNetworks } from "~/queries/network"; +import { NETWORK as PATHS } from "~/routes/paths"; +import { isEmpty } from "~/utils"; +import { formatIp } from "~/utils/network"; +import { _ } from "~/i18n"; +import { sprintf } from "sprintf-js"; + +const NetworkSignal = ({ id, signal }) => { + let label: string; + let icon: IconProps["name"]; + + if (signal > 70) { + label = _("Excellent signal"); + icon = "network_wifi"; + } else if (signal > 30) { + label = _("Good signal"); + icon = "network_wifi_3_bar"; + } else { + label = _("Weak signal"); + icon = "network_wifi_1_bar"; + } + + return ( + <> + + + {label} + + + ); +}; + +const NetworkSecurity = ({ id, security }) => { + if (!isEmpty(security)) { + return ( + <> + + + {_("Secured network")} + + + ); + } + + return ( + + {_("Public network")} + + ); +}; + +type ConnectingSpinnerProps = { ssid: WifiNetwork["ssid"]; state: ConnectionState | undefined }; +const ConnectingSpinner = ({ state, ssid }: ConnectingSpinnerProps) => { + if (state !== ConnectionState.activating) return; + + // TRANSLATORS: %s will be replaced by Wi-Fi network SSID + const label = sprintf(_("Connecting to %s"), ssid); + + return ; +}; + +type NetworkListItemProps = { + network: WifiNetwork; + connection: Connection | undefined; + showIp: boolean; +}; + +const NetworkListItem = ({ network, connection, showIp }: NetworkListItemProps) => { + const nameId = useId(); + const securityId = useId(); + const statusId = useId(); + const signalId = useId(); + const ipId = useId(); + + return ( + + + + + + + {network.ssid} + + {connection?.state === ConnectionState.activated && ( + + )} + + + {showIp && network.device && ( + + {_("IP addresses")} + {network.device?.addresses.map(formatIp).join(", ")} + + )} + + , + + + + + + + , + ]} + /> + + + ); +}; + +type WifiNetworksListProps = DataListProps & { showIp?: boolean }; + +/** + * Component for displaying a list of available Wi-Fi networks + */ +function WifiNetworksList({ showIp = true, ...props }: WifiNetworksListProps) { + useNetworkChanges(); + const navigate = useNavigate(); + const networks: WifiNetwork[] = useWifiNetworks(); + const connections = useConnections(); + + if (networks.length === 0) + return ; + + const statusOrder = [ + WifiNetworkStatus.CONNECTED, + WifiNetworkStatus.CONFIGURED, + WifiNetworkStatus.NOT_CONFIGURED, + ]; + + // Sort networks by status and signal + networks.sort( + (a, b) => + statusOrder.indexOf(a.status) - statusOrder.indexOf(b.status) || b.strength - a.strength, + ); + + return ( + navigate(generatePath(PATHS.wifiNetwork, { ssid }))} + {...props} + > + {networks.map((n) => ( + c?.wireless?.ssid === n.ssid)} + showIp={showIp} + /> + ))} + + ); +} + +export default WifiNetworksList; diff --git a/web/src/components/network/WifiNetworksListPage.tsx b/web/src/components/network/WifiNetworksListPage.tsx deleted file mode 100644 index ff8d2fd9c8..0000000000 --- a/web/src/components/network/WifiNetworksListPage.tsx +++ /dev/null @@ -1,304 +0,0 @@ -/* - * Copyright (c) [2024-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 { - Button, - Card, - CardBody, - Content, - DataList, - DataListCell, - DataListItem, - DataListItemCells, - DataListItemRow, - Drawer, - DrawerActions, - DrawerCloseButton, - DrawerContent, - DrawerContentBody, - DrawerHead, - DrawerPanelBody, - DrawerPanelContent, - Flex, - Label, - Spinner, - Split, - Stack, -} from "@patternfly/react-core"; -import { generatePath } from "react-router-dom"; -import { Icon } from "~/components/layout"; -import { Link, EmptyState } from "~/components/core"; -import WifiConnectionForm from "~/components/network/WifiConnectionForm"; -import { DeviceState, WifiNetwork } from "~/types/network"; -import { NETWORK as PATHS } from "~/routes/paths"; -import { _ } from "~/i18n"; -import { formatIp } from "~/utils/network"; -import { - useRemoveConnectionMutation, - useSelectedWifi, - useSelectedWifiChange, - useNetworkConfigChanges, - useWifiNetworks, -} from "~/queries/network"; -import { slugify } from "~/utils"; -import { connect, disconnect } from "~/api/network"; - -type HiddenNetwork = { hidden: boolean }; -const HIDDEN_NETWORK: HiddenNetwork = Object.freeze({ hidden: true }); - -// FIXME: Move to the model and stop using translations for checking the state -const networkState = (state: DeviceState): string => { - switch (state) { - case DeviceState.CONFIG: - case DeviceState.IPCHECK: - // TRANSLATORS: Wifi network status - return _("Connecting"); - case DeviceState.ACTIVATED: - // TRANSLATORS: Wifi network status - return _("Connected"); - case DeviceState.DEACTIVATING: - case DeviceState.FAILED: - case DeviceState.DISCONNECTED: - // TRANSLATORS: Wifi network status - return _("Disconnected"); - default: - return ""; - } -}; - -// FIXME: too similar to utils/network#connectionAddresses method. Try to join them. -const connectionAddresses = (network: WifiNetwork) => { - const { device, settings } = network; - const addresses = device ? device.addresses : settings?.addresses; - - return addresses?.map(formatIp).join(", "); -}; - -const ConnectionData = ({ network }: { network: WifiNetwork }) => { - return {connectionAddresses(network)}; -}; - -const WifiDrawerPanelBody = ({ - network, - onCancel, -}: { - network: WifiNetwork; - onCancel: () => void; -}) => { - const { mutate: removeConnection } = useRemoveConnectionMutation(); - const selectedWifi = useSelectedWifi(); - - const forgetNetwork = async () => { - removeConnection(network.settings.id); - }; - - if (!network) return; - - const Form = ({ errors = {} }) => ( - - ); - - if (network === HIDDEN_NETWORK) return
; - - if (selectedWifi?.needsAuth) return ; - - if (network.settings && !network.device) { - return ( - - - - {_("Edit")} - - - - ); - } - - // FIXME: stop using translations - switch (networkState(network.device?.state)) { - case _("Connecting"): - return ; - case _("Disconnected"): - return !network?.settings && ; - case _("Connected"): - return ( - - - - - - {_("Edit")} - - - - - ); - default: - return ; - } -}; - -const NetworkFormName = ({ network }) => { - if (!network) return; - - return ( - - {network === HIDDEN_NETWORK ? _("Connect to hidden network") : network.ssid} - - ); -}; - -const NetworkListName = ({ network, ...props }) => { - const state = networkState(network.device?.state); - - return ( - - {network.ssid} - {network.settings && ( - - )} - {state === _("Connected") && ( - - )} - - ); -}; - -const NetworkListItem = ({ network }) => { - const headerId = slugify(`network-${network.ssid}`); - return ( - - - - - - -
- {network.security.join(", ")} -
-
- {network.strength} -
-
-
- , - ]} - /> -
-
- ); -}; - -/** - * Component for displaying a list of available Wi-Fi networks - */ -function WifiNetworksListPage() { - useNetworkConfigChanges(); - const networks: WifiNetwork[] = useWifiNetworks(); - const { ssid: selectedSsid, hidden } = useSelectedWifi(); - // FIXME: improve below type casting, if possible - const selected = hidden - ? (HIDDEN_NETWORK as unknown as WifiNetwork) - : networks.find((n) => n.ssid === selectedSsid); - const { mutate: changeSelection } = useSelectedWifiChange(); - - const selectHiddneNetwork = () => { - changeSelection(HIDDEN_NETWORK); - }; - - const selectNetwork = (ssid: string) => { - changeSelection({ ssid, needsAuth: null }); - }; - - const unselectNetwork = () => { - changeSelection({ ssid: null, needsAuth: null }); - }; - - return ( - - - - - - - - - - - - - - - } - > - - - {networks.length === 0 ? ( - - ) : ( - // @ts-expect-error: related to https://github.com/patternfly/patternfly-react/issues/9823 - selectNetwork(ssid)} - > - {networks.map((n) => ( - - ))} - - )} - - - - - - - - ); -} - -export default WifiNetworksListPage; diff --git a/web/src/components/network/WifiSelectorPage.tsx b/web/src/components/network/WifiSelectorPage.tsx deleted file mode 100644 index c1d7d000da..0000000000 --- a/web/src/components/network/WifiSelectorPage.tsx +++ /dev/null @@ -1,54 +0,0 @@ -/* - * Copyright (c) [2024-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 { Content, Grid, GridItem } from "@patternfly/react-core"; -import { Page } from "~/components/core"; -import WifiNetworksListPage from "~/components/network/WifiNetworksListPage"; -import { useNetworkConfigChanges } from "~/queries/network"; -import { _ } from "~/i18n"; - -function WifiSelectorPage() { - useNetworkConfigChanges(); - - return ( - - - {_("Connect to a Wi-Fi network")} - - - - - - - - - - - - - - - ); -} - -export default WifiSelectorPage; diff --git a/web/src/components/network/WiredConnectionDetails.tsx b/web/src/components/network/WiredConnectionDetails.tsx new file mode 100644 index 0000000000..34d6b326ca --- /dev/null +++ b/web/src/components/network/WiredConnectionDetails.tsx @@ -0,0 +1,157 @@ +/* + * 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 { generatePath } from "react-router-dom"; +import { + DescriptionList, + DescriptionListDescription, + DescriptionListGroup, + DescriptionListTerm, + Flex, + FlexItem, + Grid, + GridItem, + Stack, +} from "@patternfly/react-core"; +import { Link, Page } from "~/components/core"; +import { Connection, Device } from "~/types/network"; +import { formatIp } from "~/utils/network"; +import { NETWORK } from "~/routes/paths"; +import { useNetworkDevices } from "~/queries/network"; +import { _ } from "~/i18n"; + +const DeviceDetails = ({ device }: { device: Device }) => { + if (!device) return; + + return ( + + + + {_("Interface")} + {device.name} + + + {_("Status")} + {device.state} + + + {_("MAC")} + {device.macAddress} + + + + ); +}; + +const IpDetails = ({ connection, device }: { connection: Connection; device: Device }) => { + if (!device) return; + + return ( + + {_("Edit")} + + } + > + + + {_("Mode")} + + + + {_("IPv4")} {connection.method4} + + + {_("IPv6")} {connection.method6} + + {device.gateway6} + + + + + {_("Gateway")} + + + {device.gateway4} + {device.gateway6} + + + + + {_("IP Addresses")} + + + {device.addresses.map((ip, idx) => ( + {formatIp(ip)} + ))} + + + + + {_("DNS")} + + + {device.nameservers.map((dns, idx) => ( + {dns} + ))} + + + + + {_("Routes")} + + + {device.routes4.map((route, idx) => ( + {formatIp(route.destination)} + ))} + + + + + + ); +}; + +export default function WiredConnectionDetails({ connection }: { connection: Connection }) { + const devices = useNetworkDevices(); + + const device = devices.find( + ({ connection: deviceConnectionId }) => deviceConnectionId === connection.id, + ); + + return ( + + + + + + + + + + + ); +} diff --git a/web/src/components/network/WiredConnectionPage.tsx b/web/src/components/network/WiredConnectionPage.tsx new file mode 100644 index 0000000000..556a2faf89 --- /dev/null +++ b/web/src/components/network/WiredConnectionPage.tsx @@ -0,0 +1,84 @@ +/* + * 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 { useParams } from "react-router-dom"; +import { + Content, + EmptyState, + EmptyStateActions, + EmptyStateBody, + EmptyStateFooter, +} from "@patternfly/react-core"; +import { Link, Page } from "~/components/core"; +import { useConnections, useNetworkChanges } from "~/queries/network"; +import { _ } from "~/i18n"; +import { sprintf } from "sprintf-js"; +import WiredConnectionDetails from "./WiredConnectionDetails"; +import { Icon } from "../layout"; +import { NETWORK } from "~/routes/paths"; + +const ConnectionNotFound = ({ id }) => { + // TRANSLATORS: %s will be replaced with connection id + const text = sprintf(_('"%s" does not exist or is no longer available.'), id); + + return ( + } + > + {text} + + + + {_("Go to network page")} + + + + + ); +}; + +export default function WiredConnectionPage() { + useNetworkChanges(); + const { id } = useParams(); + const connections = useConnections(); + const connection = connections.find((c) => c.id === id); + + const title = _("Connection details"); + + return ( + + + {title} + + + {connection ? ( + + ) : ( + + )} + + + ); +} diff --git a/web/src/components/network/WiredConnectionsList.tsx b/web/src/components/network/WiredConnectionsList.tsx new file mode 100644 index 0000000000..35754810bc --- /dev/null +++ b/web/src/components/network/WiredConnectionsList.tsx @@ -0,0 +1,102 @@ +/* + * 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, { useId } from "react"; +import { generatePath, useNavigate } from "react-router-dom"; +import { + Content, + DataList, + DataListCell, + DataListItem, + DataListItemCells, + DataListItemRow, + DataListProps, + Flex, +} from "@patternfly/react-core"; +import a11yStyles from "@patternfly/react-styles/css/utilities/Accessibility/accessibility"; +import { EmptyState } from "~/components/core"; +import { Connection } from "~/types/network"; +import { useConnections, useNetworkDevices } from "~/queries/network"; +import { NETWORK as PATHS } from "~/routes/paths"; +import { formatIp } from "~/utils/network"; +import { _ } from "~/i18n"; + +type ConnectionListItemProps = { connection: Connection }; + +const ConnectionListItem = ({ connection }: ConnectionListItemProps) => { + const nameId = useId(); + const ipId = useId(); + const devices = useNetworkDevices(); + + const device = devices.find( + ({ connection: deviceConnectionId }) => deviceConnectionId === connection.id, + ); + const addresses = device ? device.addresses : connection.addresses; + + return ( + + + + + + {connection.id} + + + {_("IP addresses")} + {addresses.map(formatIp).join(", ")} + + + , + ]} + /> + + + ); +}; + +/** + * Component for displaying a list of available wired connections + */ +function WiredConnectionsList(props: DataListProps) { + const navigate = useNavigate(); + const connections = useConnections(); + const wiredConnections = connections.filter((c) => !c.wireless); + + if (wiredConnections.length === 0) { + return ; + } + + return ( + navigate(generatePath(PATHS.wiredConnection, { id }))} + {...props} + > + {wiredConnections.map((c: Connection) => ( + + ))} + + ); +} + +export default WiredConnectionsList; diff --git a/web/src/components/network/index.ts b/web/src/components/network/index.ts index adb70e5525..5cf19da22b 100644 --- a/web/src/components/network/index.ts +++ b/web/src/components/network/index.ts @@ -22,4 +22,5 @@ export { default as NetworkPage } from "./NetworkPage"; export { default as IpSettingsForm } from "./IpSettingsForm"; -export { default as WifiSelectorPage } from "./WifiSelectorPage"; +export { default as WifiNetworkPage } from "./WifiNetworkPage"; +export { default as WiredConnectionPage } from "./WiredConnectionPage"; diff --git a/web/src/queries/network.ts b/web/src/queries/network.ts index f059d5cd12..7b1620f445 100644 --- a/web/src/queries/network.ts +++ b/web/src/queries/network.ts @@ -20,20 +20,16 @@ * find current contact information at www.suse.com. */ -import React from "react"; -import { - useQueryClient, - useMutation, - useSuspenseQuery, - useSuspenseQueries, - useQuery, -} from "@tanstack/react-query"; +import React, { useCallback } from "react"; +import { useQueryClient, useMutation, useSuspenseQuery } from "@tanstack/react-query"; import { useInstallerClient } from "~/context/installer"; import { AccessPoint, Connection, + ConnectionState, Device, DeviceState, + NetworkGeneralState, WifiNetwork, WifiNetworkStatus, } from "~/types/network"; @@ -50,7 +46,7 @@ import { } from "~/api/network"; /** - * Returns a query for retrieving the network configuration + * Returns a query for retrieving the general network configuration */ const stateQuery = () => { return { @@ -72,7 +68,7 @@ const devicesQuery = () => ({ }); /** - * Returns a query for retrieving data for the given conneciton name + * Returns a query for retrieving data for the given connection name */ const connectionQuery = (name: string) => ({ queryKey: ["network", "connections", name], @@ -96,13 +92,14 @@ const connectionsQuery = () => ({ }); /** - * Returns a query for retrieving the list of known access points + * Returns a query for retrieving the list of known access points sortered by + * the signal strength. */ const accessPointsQuery = () => ({ queryKey: ["network", "accessPoints"], queryFn: async (): Promise => { const accessPoints = await fetchAccessPoints(); - return accessPoints.map(AccessPoint.fromApi).sort((a, b) => (a.strength < b.strength ? -1 : 1)); + return accessPoints.map(AccessPoint.fromApi).sort((a, b) => b.strength - a.strength); }, // FIXME: Infinity vs 1second staleTime: 1000, @@ -117,9 +114,7 @@ const useAddConnectionMutation = () => { const queryClient = useQueryClient(); const query = { mutationFn: (newConnection: Connection) => - addConnection(newConnection.toApi()) - .then(() => applyChanges()) - .catch((e) => console.log(e)), + addConnection(newConnection.toApi()).then(() => applyChanges()), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["network", "connections"] }); queryClient.invalidateQueries({ queryKey: ["network", "devices"] }); @@ -137,9 +132,7 @@ const useConnectionMutation = () => { const queryClient = useQueryClient(); const query = { mutationFn: (newConnection: Connection) => - updateConnection(newConnection.toApi()) - .then(() => applyChanges()) - .catch((e) => console.log(e)), + updateConnection(newConnection.toApi()).then(() => applyChanges()), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["network", "connections"] }); queryClient.invalidateQueries({ queryKey: ["network", "devices"] }); @@ -168,114 +161,120 @@ const useRemoveConnectionMutation = () => { return useMutation(query); }; -/** - * Returns selected Wi-Fi network - */ -const selectedWiFiNetworkQuery = () => ({ - // TODO: use right key, once we stop invalidating everything under network - // queryKey: ["network", "wifi", "selected"], - queryKey: ["wifi", "selected"], - queryFn: async () => { - return Promise.resolve({ ssid: null, needsAuth: null }); - }, - staleTime: Infinity, -}); - -const useSelectedWifi = (): { ssid?: string; needsAuth?: boolean; hidden?: boolean } => { - const { data } = useQuery(selectedWiFiNetworkQuery()); - return data || {}; -}; - -const useSelectedWifiChange = () => { - type SelectedWifi = { - ssid?: string; - hidden?: boolean; - needsAuth?: boolean; - }; - - const queryClient = useQueryClient(); - - const mutation = useMutation({ - mutationFn: async (data: SelectedWifi): Promise => Promise.resolve(data), - onSuccess: (data: SelectedWifi) => { - queryClient.setQueryData(["wifi", "selected"], (prev: SelectedWifi) => ({ - ssid: prev.ssid, - ...data, - })); - }, - }); - - return mutation; -}; - /** * Hook that returns a useEffect to listen for NetworkChanged events * * When the configuration changes, it invalidates the config query and forces the router to * revalidate its data (executing the loaders again). */ -const useNetworkConfigChanges = () => { +const useNetworkChanges = () => { const queryClient = useQueryClient(); const client = useInstallerClient(); - const changeSelected = useSelectedWifiChange(); + + const updateDevices = useCallback( + (func: (devices: Device[]) => Device[]) => { + const devices: Device[] = queryClient.getQueryData(["network", "devices"]); + if (!devices) return; + + const updatedDevices = func(devices); + queryClient.setQueryData(["network", "devices"], updatedDevices); + }, + [queryClient], + ); + + const updateConnectionState = useCallback( + (id: string, state: string) => { + const connections: Connection[] = queryClient.getQueryData(["network", "connections"]); + if (!connections) return; + + const updatedConnections = connections.map((conn) => { + if (conn.id === id) { + const { id: _, ...nextConnection } = conn; + nextConnection.state = state as ConnectionState; + return new Connection(id, nextConnection); + } + return conn; + }); + queryClient.setQueryData(["network", "connections"], updatedConnections); + }, + [queryClient], + ); React.useEffect(() => { if (!client) return; return client.onEvent((event) => { if (event.type === "NetworkChange") { - if (event.deviceRemoved || event.deviceAdded) { - queryClient.invalidateQueries({ queryKey: ["network"] }); + if (event.deviceAdded) { + const newDevice = Device.fromApi(event.deviceAdded); + updateDevices((devices) => [...devices, newDevice]); } if (event.deviceUpdated) { - const [name, data] = event.deviceUpdated; - const devices: Device[] = queryClient.getQueryData(["network", "devices"]); - if (!devices) return; + const [name, apiDevice] = event.deviceUpdated; + const device = Device.fromApi(apiDevice); + updateDevices((devices) => + devices.map((d) => { + if (d.name === name) { + return device; + } - if (name !== data.name) { - return queryClient.invalidateQueries({ queryKey: ["network"] }); - } + return d; + }), + ); + } + + if (event.deviceRemoved) { + updateDevices((devices) => devices.filter((d) => d !== event.deviceRemoved)); + } - const current_device = devices.find((d) => d.name === name); - if ( - [DeviceState.DISCONNECTED, DeviceState.ACTIVATED, DeviceState.UNAVAILABLE].includes( - data.state, - ) - ) { - if (current_device.state !== data.state) { - queryClient.invalidateQueries({ queryKey: ["network"] }); - return changeSelected.mutate({ needsAuth: false }); - } - } - if ([DeviceState.NEEDAUTH, DeviceState.FAILED].includes(data.state)) { - return changeSelected.mutate({ needsAuth: true }); - } + if (event.connectionStateChanged) { + const { id, state } = event.connectionStateChanged; + updateConnectionState(id, state); } } }); - }, [client, queryClient, changeSelected]); + }, [client, queryClient, updateDevices, updateConnectionState]); }; -const useConnection = (name) => { +const useConnection = (name: string) => { const { data } = useSuspenseQuery(connectionQuery(name)); return data; }; -const useNetwork = () => { - const [{ data: state }, { data: devices }, { data: connections }, { data: accessPoints }] = - useSuspenseQueries({ - queries: [stateQuery(), devicesQuery(), connectionsQuery(), accessPointsQuery()], - }); +/** + * Returns the general state of the network. + */ +const useNetworkState = (): NetworkGeneralState => { + const { data } = useSuspenseQuery(stateQuery()); + return data; +}; - return { connections, settings: state, devices, accessPoints }; +/** + * Returns the network devices. + */ +const useNetworkDevices = (): Device[] => { + const { data } = useSuspenseQuery(devicesQuery()); + return data; +}; + +/** + * Returns the network connections. + */ +const useConnections = (): Connection[] => { + const { data } = useSuspenseQuery(connectionsQuery()); + return data; }; +/** + * Return the list of Wi-Fi networks. + */ const useWifiNetworks = () => { const knownSsids: string[] = []; - const [{ data: devices }, { data: connections }, { data: accessPoints }] = useSuspenseQueries({ - queries: [devicesQuery(), connectionsQuery(), accessPointsQuery()], - }); + + const devices = useNetworkDevices(); + const connections = useConnections(); + const { data: accessPoints } = useSuspenseQuery(accessPointsQuery()); return accessPoints .filter((ap: AccessPoint) => { @@ -290,12 +289,14 @@ const useWifiNetworks = () => { .sort((a: AccessPoint, b: AccessPoint) => b.strength - a.strength) .map((ap: AccessPoint): WifiNetwork => { const settings = connections.find((c: Connection) => c.wireless?.ssid === ap.ssid); - const device = devices.find((d: Device) => d.connection === ap.ssid); - const status = device - ? WifiNetworkStatus.CONNECTED - : settings - ? WifiNetworkStatus.CONFIGURED - : WifiNetworkStatus.NOT_CONFIGURED; + const device = devices.find((d: Device) => d.connection === settings?.id); + + let status: WifiNetworkStatus; + if (device?.state === DeviceState.CONNECTED) { + status = WifiNetworkStatus.CONNECTED; + } else { + status = settings ? WifiNetworkStatus.CONFIGURED : WifiNetworkStatus.NOT_CONFIGURED; + } return { ...ap, @@ -312,14 +313,13 @@ export { connectionQuery, connectionsQuery, accessPointsQuery, - selectedWiFiNetworkQuery, useAddConnectionMutation, + useConnections, useConnectionMutation, useRemoveConnectionMutation, useConnection, - useNetwork, - useSelectedWifi, - useSelectedWifiChange, - useNetworkConfigChanges, + useNetworkDevices, + useNetworkState, + useNetworkChanges, useWifiNetworks, }; diff --git a/web/src/routes/network.tsx b/web/src/routes/network.tsx index 7e73609172..21e433c081 100644 --- a/web/src/routes/network.tsx +++ b/web/src/routes/network.tsx @@ -21,7 +21,12 @@ */ import React from "react"; -import { NetworkPage, IpSettingsForm, WifiSelectorPage } from "~/components/network"; +import { + NetworkPage, + IpSettingsForm, + WifiNetworkPage, + WiredConnectionPage, +} from "~/components/network"; import { Route } from "~/types/routes"; import { NETWORK as PATHS } from "~/routes/paths"; import { N_ } from "~/i18n"; @@ -39,8 +44,12 @@ const routes = (): Route => ({ element: , }, { - path: PATHS.wifis, - element: , + path: PATHS.wifiNetwork, + element: , + }, + { + path: PATHS.wiredConnection, + element: , }, ], }); diff --git a/web/src/routes/paths.ts b/web/src/routes/paths.ts index 33074f28e0..ed44abd311 100644 --- a/web/src/routes/paths.ts +++ b/web/src/routes/paths.ts @@ -30,7 +30,8 @@ const L10N = { const NETWORK = { root: "/network", editConnection: "/network/connections/:id/edit", - wifis: "/network/wifis", + wifiNetwork: "/network/wifi_networks/:ssid", + wiredConnection: "/network/wired_connection/:id", }; const PRODUCT = { diff --git a/web/src/types/network.ts b/web/src/types/network.ts index f4d2a7732b..bbf65fe58c 100644 --- a/web/src/types/network.ts +++ b/web/src/types/network.ts @@ -70,31 +70,14 @@ enum ConnectionType { UNKNOWN = "unknown", } -/** - * Enum for the active connection state values - * - * @readonly - * @enum { number } - * https://networkmanager.dev/docs/api/latest/nm-dbus-types.html#NMActiveConnectionState - */ -enum ConnectionState { - UNKNOWN = 0, - ACTIVATING = 1, - ACTIVATED = 2, - DEACTIVATING = 3, - DEACTIVATED = 4, -} - enum DeviceState { UNKNOWN = "unknown", UNMANAGED = "unmanaged", UNAVAILABLE = "unavailable", + CONNECTING = "connecting", + CONNECTED = "connected", + DISCONNECTING = "disconnecting", DISCONNECTED = "disconnected", - CONFIG = "config", - IPCHECK = "ipCheck", - NEEDAUTH = "needAuth", - ACTIVATED = "activated", - DEACTIVATING = "deactivating", FAILED = "failed", } @@ -103,6 +86,14 @@ enum ConnectionStatus { DOWN = "down", } +// Current state of the connection. +enum ConnectionState { + activating = "activating", + activated = "activated", + deactivating = "deactivating", + deactivated = "deactivated", +} + enum ConnectionMethod { MANUAL = "manual", AUTO = "auto", @@ -240,6 +231,7 @@ type APIConnection = { method6: string; wireless?: Wireless; status: ConnectionStatus; + state: ConnectionState; }; type WirelessOptions = { @@ -275,11 +267,13 @@ type ConnectionOptions = { method4?: ConnectionMethod; method6?: ConnectionMethod; wireless?: Wireless; + state?: ConnectionState; }; class Connection { id: string; status: ConnectionStatus = ConnectionStatus.UP; + state: ConnectionState; iface: string; addresses: IPAddress[] = []; nameservers: string[] = []; @@ -348,8 +342,8 @@ type WifiNetwork = AccessPoint & { type NetworkGeneralState = { connectivity: boolean; hostname: string; - networking_enabled: boolean; - wireless_enabled: boolean; + networkingEnabled: boolean; + wirelessEnabled: boolean; }; export { diff --git a/web/src/utils.test.ts b/web/src/utils.test.ts index 99ced7f4e0..8a1d64d876 100644 --- a/web/src/utils.test.ts +++ b/web/src/utils.test.ts @@ -30,6 +30,7 @@ import { localConnection, isObject, slugify, + isEmpty, } from "./utils"; describe("noop", () => { @@ -164,6 +165,48 @@ describe("isObject", () => { }); }); +describe("isEmpty", () => { + it("returns true when called with null", () => { + expect(isEmpty(null)).toBe(true); + }); + + it("returns true when called with undefined", () => { + expect(isEmpty(undefined)).toBe(true); + }); + + it("returns false when called with a function", () => { + expect(isEmpty(() => {})).toBe(false); + }); + + it("returns false when called with a number", () => { + expect(isEmpty(1)).toBe(false); + }); + + it("returns true when called with an empty string", () => { + expect(isEmpty("")).toBe(true); + }); + + it("returns false when called with a not empty string", () => { + expect(isEmpty("not empty")).toBe(false); + }); + + it("returns true when called with an empty array", () => { + expect(isEmpty([])).toBe(true); + }); + + it("returns false when called with a not empty array", () => { + expect(isEmpty([""])).toBe(false); + }); + + it("returns true when called with an empty object", () => { + expect(isEmpty({})).toBe(true); + }); + + it("returns false when called with a not empty object", () => { + expect(isEmpty({ not: "empty" })).toBe(false); + }); +}); + describe("slugify", () => { it("converts given input into a slug", () => { expect(slugify("Agama! / Network 1")).toEqual("agama-network-1"); diff --git a/web/src/utils.ts b/web/src/utils.ts index e2b5509f69..4e1e6973ab 100644 --- a/web/src/utils.ts +++ b/web/src/utils.ts @@ -62,11 +62,11 @@ const isEmpty = (value) => { return true; } - if (typeof value === "number" && !Number.isNaN(value)) { + if (typeof value === "function") { return false; } - if (typeof value === "function") { + if (typeof value === "number" && !Number.isNaN(value)) { return false; } @@ -74,6 +74,10 @@ const isEmpty = (value) => { return value.trim() === ""; } + if (Array.isArray(value)) { + return value.length === 0; + } + if (isObject(value)) { return isObjectEmpty(value); } diff --git a/web/src/utils/network.ts b/web/src/utils/network.ts index c0189076be..17975e1cf1 100644 --- a/web/src/utils/network.ts +++ b/web/src/utils/network.ts @@ -26,22 +26,12 @@ import { ApFlags, ApSecurityFlags, Connection, - ConnectionState, Device, IPAddress, Route, SecurityProtocols, } from "~/types/network"; -/** - * Returns a human readable connection state - */ -const connectionHumanState = (state: number): string => { - const stateIndex = Object.values(ConnectionState).indexOf(state); - const stateKey = Object.keys(ConnectionState)[stateIndex]; - return stateKey.toLowerCase(); -}; - /** * Check if an IP is valid * @@ -203,7 +193,6 @@ export { buildAddresses, buildRoutes, connectionAddresses, - connectionHumanState, formatIp, intToIPString, ipPrefixFor,