diff --git a/rust/agama-lib/share/profile.schema.json b/rust/agama-lib/share/profile.schema.json index ceb10ec09e..97edff8bf7 100644 --- a/rust/agama-lib/share/profile.schema.json +++ b/rust/agama-lib/share/profile.schema.json @@ -365,13 +365,21 @@ "type": "string" } }, - "dnsSearchlist": { + "dnsSearchList": { "type": "array", "items": { "description": "DNS search domains", "type": "string" } }, + "dnsSearchlist": { + "type": "array", + "items": { + "description": "DNS search domains", + "type": "string" + }, + "deprecated": true + }, "ignoreAutoDns": { "description": "Whether DNS options provided via DHCP are used or not", "type": "boolean" diff --git a/rust/agama-network/src/action.rs b/rust/agama-network/src/action.rs index fc6348ce8c..4706968005 100644 --- a/rust/agama-network/src/action.rs +++ b/rust/agama-network/src/action.rs @@ -54,6 +54,10 @@ pub enum Action { GetConnections(Responder>), /// Gets all scanned access points GetAccessPoints(Responder>), + /// Adds a new access point. + AddAccessPoint(Box), + /// Removes an access point by its hardware address. + RemoveAccessPoint(String), /// Adds a new device. AddDevice(Box), /// Updates a device by its `name`. @@ -66,7 +70,7 @@ pub enum Action { GetDevices(Responder>), GetGeneralState(Responder), /// Connection state changed - ChangeConnectionState(String, ConnectionState), + ChangeConnectionState(Uuid, ConnectionState), /// Persists existing connections if none exist and the network copy is not disabled. ProposeDefault(Responder>), // Copies persistent connections to the target system @@ -77,8 +81,8 @@ pub enum Action { UpdateGeneralState(GeneralState), /// Forces a wireless networks scan refresh RefreshScan(Responder>), - /// Remove the connection with the given ID. - RemoveConnection(String), + /// Remove the connection with the given UUID. + RemoveConnection(Uuid), /// Apply the current configuration. Apply(Responder>), } diff --git a/rust/agama-network/src/error.rs b/rust/agama-network/src/error.rs index c539b4f825..651f001aee 100644 --- a/rust/agama-network/src/error.rs +++ b/rust/agama-network/src/error.rs @@ -40,6 +40,8 @@ pub enum NetworkStateError { CannotUpdateConnection(String), #[error("Unknown device '{0}'")] UnknownDevice(String), + #[error("Unknown access point '{0}'")] + UnknownAccessPoint(String), #[error("Invalid connection UUID: '{0}'")] InvalidUuid(String), #[error("Invalid IP address: '{0}'")] diff --git a/rust/agama-network/src/model.rs b/rust/agama-network/src/model.rs index bb5ae8be41..2234c9686c 100644 --- a/rust/agama-network/src/model.rs +++ b/rust/agama-network/src/model.rs @@ -296,12 +296,10 @@ impl NetworkState { /// Updates a connection with a new one. /// - /// It uses the `id` to decide which connection to update. - /// - /// Additionally, it registers the connection to be removed when the changes are applied. + /// It uses the `uuid` to decide which connection to update. pub fn update_connection(&mut self, conn: Connection) -> Result<(), NetworkStateError> { - let Some(old_conn) = self.get_connection_mut(&conn.id) else { - return Err(NetworkStateError::UnknownConnection(conn.id.clone())); + let Some(old_conn) = self.get_connection_by_uuid_mut(conn.uuid) else { + return Err(NetworkStateError::UnknownConnection(conn.uuid.to_string())); }; *old_conn = conn; @@ -311,9 +309,9 @@ impl NetworkState { /// Removes a connection from the state. /// /// Additionally, it registers the connection to be removed when the changes are applied. - pub fn remove_connection(&mut self, id: &str) -> Result<(), NetworkStateError> { - let Some(position) = self.connections.iter().position(|d| d.id == id) else { - return Err(NetworkStateError::UnknownConnection(id.to_string())); + pub fn remove_connection(&mut self, uuid: Uuid) -> Result<(), NetworkStateError> { + let Some(position) = self.connections.iter().position(|d| d.uuid == uuid) else { + return Err(NetworkStateError::UnknownConnection(uuid.to_string())); }; self.connections.remove(position); @@ -343,6 +341,34 @@ impl NetworkState { Ok(()) } + pub fn add_access_point(&mut self, ap: AccessPoint) -> Result<(), NetworkStateError> { + if let Some(position) = self + .access_points + .iter() + .position(|a| a.hw_address == ap.hw_address) + { + self.access_points.remove(position); + } + self.access_points.push(ap); + + Ok(()) + } + + pub fn remove_access_point(&mut self, hw_address: &str) -> Result<(), NetworkStateError> { + let Some(position) = self + .access_points + .iter() + .position(|a| a.hw_address == hw_address) + else { + return Err(NetworkStateError::UnknownAccessPoint( + hw_address.to_string(), + )); + }; + + self.access_points.remove(position); + Ok(()) + } + /// Sets a controller's ports. /// /// If the connection is not a controller, returns an error. @@ -417,22 +443,23 @@ mod tests { #[test] fn test_update_connection() { let mut state = NetworkState::default(); + let uuid = Uuid::new_v4(); let conn0 = Connection { id: "eth0".to_string(), - uuid: Uuid::new_v4(), + uuid, ..Default::default() }; state.add_connection(conn0).unwrap(); - let uuid = Uuid::new_v4(); let conn1 = Connection { id: "eth0".to_string(), uuid, + firewall_zone: Some("public".to_string()), ..Default::default() }; state.update_connection(conn1).unwrap(); - let found = state.get_connection("eth0").unwrap(); - assert_eq!(found.uuid, uuid); + let found = state.get_connection_by_uuid(uuid).unwrap(); + assert_eq!(found.firewall_zone, Some("public".to_string())); } #[test] @@ -446,20 +473,77 @@ mod tests { #[test] fn test_remove_connection() { let mut state = NetworkState::default(); - let conn0 = Connection::new("eth0".to_string(), DeviceType::Ethernet); + let uuid = Uuid::new_v4(); + let conn0 = Connection { + id: "eth0".to_string(), + uuid, + ..Default::default() + }; state.add_connection(conn0).unwrap(); - state.remove_connection("eth0".as_ref()).unwrap(); - let found = state.get_connection("eth0"); + state.remove_connection(uuid).unwrap(); + let found = state.get_connection_by_uuid(uuid); assert!(found.is_none()); } #[test] fn test_remove_unknown_connection() { let mut state = NetworkState::default(); - let error = state.remove_connection("unknown".as_ref()).unwrap_err(); + let uuid = Uuid::new_v4(); + let error = state.remove_connection(uuid).unwrap_err(); assert!(matches!(error, NetworkStateError::UnknownConnection(_))); } + #[test] + fn test_remove_device() { + let mut state = NetworkState::default(); + let device = Device { + name: "eth0".to_string(), + ..Default::default() + }; + state.add_device(device).unwrap(); + state.remove_device("eth0").unwrap(); + assert!(state.get_device("eth0").is_none()); + } + + #[test] + fn test_add_access_point() { + let mut state = NetworkState::default(); + let ap = AccessPoint { + hw_address: "AA:BB:CC:DD:EE:FF".to_string(), + ssid: SSID(b"test".to_vec()), + ..Default::default() + }; + state.add_access_point(ap.clone()).unwrap(); + assert_eq!(state.access_points.len(), 1); + assert_eq!(state.access_points[0].hw_address, "AA:BB:CC:DD:EE:FF"); + + // Adding same AP should replace it (in our implementation we remove and push) + let mut ap2 = ap.clone(); + ap2.strength = 80; + state.add_access_point(ap2).unwrap(); + assert_eq!(state.access_points.len(), 1); + assert_eq!(state.access_points[0].strength, 80); + } + + #[test] + fn test_remove_access_point() { + let mut state = NetworkState::default(); + let ap = AccessPoint { + hw_address: "AA:BB:CC:DD:EE:FF".to_string(), + ..Default::default() + }; + state.add_access_point(ap).unwrap(); + state.remove_access_point("AA:BB:CC:DD:EE:FF").unwrap(); + assert_eq!(state.access_points.len(), 0); + } + + #[test] + fn test_remove_unknown_access_point() { + let mut state = NetworkState::default(); + let error = state.remove_access_point("unknown").unwrap_err(); + assert!(matches!(error, NetworkStateError::UnknownAccessPoint(_))); + } + #[test] fn test_is_loopback() { let conn = Connection::new("eth0".to_string(), DeviceType::Ethernet); @@ -1726,7 +1810,7 @@ pub struct TunConfig { #[serde(rename_all = "camelCase")] pub enum NetworkChange { ConnectionAdded(Connection), - ConnectionRemoved(String), + ConnectionRemoved(Uuid), /// A new device has been added. DeviceAdded(Device), /// A device has been removed. @@ -1737,9 +1821,13 @@ pub enum NetworkChange { DeviceUpdated(String, Device), /// A connection state has changed. ConnectionStateChanged { - id: String, + uuid: Uuid, state: ConnectionState, }, + /// A new access point has been added. + AccessPointAdded(AccessPoint), + /// An access point has been removed. + AccessPointRemoved(String), } #[derive(Default, Debug, PartialEq, Clone, Deserialize, Serialize, utoipa::ToSchema)] diff --git a/rust/agama-network/src/nm/builder.rs b/rust/agama-network/src/nm/builder.rs index d6eef00f56..6261917c15 100644 --- a/rust/agama-network/src/nm/builder.rs +++ b/rust/agama-network/src/nm/builder.rs @@ -25,9 +25,12 @@ use crate::{ nm::{ dbus::connection_from_dbus, model::NmDeviceType, - proxies::{ConnectionProxy, DeviceProxy, IP4ConfigProxy, IP6ConfigProxy}, + proxies::{AccessPointProxy, ConnectionProxy, DeviceProxy, IP4ConfigProxy, IP6ConfigProxy}, + }, + types::{ + AccessPoint, ConnectionFlags, Device, DeviceState, DeviceType, IpConfig, IpRoute, + MacAddress, SSID, }, - types::{ConnectionFlags, Device, DeviceState, DeviceType, IpConfig, IpRoute, MacAddress}, }; use cidr::IpInet; use std::{collections::HashMap, net::IpAddr, str::FromStr}; @@ -44,6 +47,38 @@ pub struct DeviceFromProxyBuilder<'a> { proxy: &'a DeviceProxy<'a>, } +/// Builder to create an [AccessPoint] from its corresponding NetworkManager D-Bus representation. +pub struct AccessPointFromProxyBuilder<'a> { + device_name: String, + proxy: &'a AccessPointProxy<'a>, +} + +impl<'a> AccessPointFromProxyBuilder<'a> { + pub fn new(device_name: String, proxy: &'a AccessPointProxy<'a>) -> Self { + Self { device_name, proxy } + } + + /// Creates an [AccessPoint] starting on the [AccessPointProxy]. + pub async fn build(&self) -> Result { + let ssid = SSID(self.proxy.ssid().await?); + let hw_address = self.proxy.hw_address().await?; + let strength = self.proxy.strength().await?; + let flags = self.proxy.flags().await?; + let rsn_flags = self.proxy.rsn_flags().await?; + let wpa_flags = self.proxy.wpa_flags().await?; + + Ok(AccessPoint { + device: self.device_name.clone(), + ssid, + hw_address, + strength, + flags, + rsn_flags, + wpa_flags, + }) + } +} + impl<'a> ConnectionFromProxyBuilder<'a> { pub fn new(_connection: &zbus::Connection, proxy: &'a ConnectionProxy<'a>) -> Self { Self { proxy } @@ -139,6 +174,7 @@ impl<'a> DeviceFromProxyBuilder<'a> { ) -> Result { let address_data = ip4_proxy.address_data().await?; let nameserver_data = ip4_proxy.nameserver_data().await?; + let mut dns_searchlist = ip4_proxy.searches().await?; let mut addresses: Vec = vec![]; let mut nameservers: Vec = vec![]; @@ -160,6 +196,12 @@ impl<'a> DeviceFromProxyBuilder<'a> { nameservers.push(address) } } + + for search in ip6_proxy.searches().await? { + if !dns_searchlist.contains(&search) { + dns_searchlist.push(search); + } + } // FIXME: Convert from Vec to [u8; 16] and take into account big vs little endian order, // in IP6Config there is no nameserver-data. // @@ -188,6 +230,7 @@ impl<'a> DeviceFromProxyBuilder<'a> { let mut ip_config = IpConfig { addresses, nameservers, + dns_searchlist, routes4, routes6, ..Default::default() diff --git a/rust/agama-network/src/nm/streams/common.rs b/rust/agama-network/src/nm/streams/common.rs index 3118a785b4..6d59bc1b8f 100644 --- a/rust/agama-network/src/nm/streams/common.rs +++ b/rust/agama-network/src/nm/streams/common.rs @@ -35,6 +35,8 @@ pub enum NmChange { ActiveConnectionAdded(OwnedObjectPath), ActiveConnectionUpdated(OwnedObjectPath), ActiveConnectionRemoved(OwnedObjectPath), + AccessPointAdded(OwnedObjectPath, OwnedObjectPath), + AccessPointRemoved(OwnedObjectPath, OwnedObjectPath), } pub async fn build_added_and_removed_stream( @@ -63,3 +65,17 @@ pub async fn build_properties_changed_stream( let stream = MessageStream::for_match_rule(rule, connection, Some(1)).await?; Ok(stream) } + +/// Returns a stream of wireless signals to be used by DeviceChangedStream. +/// +/// It listens for AccessPointAdded and AccessPointRemoved signals. +pub async fn build_wireless_signals_stream( + connection: &zbus::Connection, +) -> Result { + let rule = MatchRule::builder() + .msg_type(MessageType::Signal) + .interface("org.freedesktop.NetworkManager.Device.Wireless")? + .build(); + let stream = MessageStream::for_match_rule(rule, connection, Some(1)).await?; + Ok(stream) +} diff --git a/rust/agama-network/src/nm/streams/devices.rs b/rust/agama-network/src/nm/streams/devices.rs index 0da8c2f8e9..e7c1009b33 100644 --- a/rust/agama-network/src/nm/streams/devices.rs +++ b/rust/agama-network/src/nm/streams/devices.rs @@ -33,7 +33,10 @@ use zbus::{ Message, MessageStream, }; -use super::common::{build_added_and_removed_stream, build_properties_changed_stream, NmChange}; +use super::common::{ + build_added_and_removed_stream, build_properties_changed_stream, build_wireless_signals_stream, + NmChange, +}; use crate::nm::error::NmError; /// Stream of device-related events. @@ -64,6 +67,10 @@ impl DeviceChangedStream { "properties", build_properties_changed_stream(&connection).await?, ); + inner.insert( + "wireless", + build_wireless_signals_stream(&connection).await?, + ); Ok(Self { connection, inner }) } @@ -96,7 +103,13 @@ impl DeviceChangedStream { } fn handle_changed(message: PropertiesChanged) -> Option { - const IP_CONFIG_PROPS: &[&str] = &["AddressData", "Gateway", "NameserverData", "RouteData"]; + const IP_CONFIG_PROPS: &[&str] = &[ + "AddressData", + "Gateway", + "NameserverData", + "RouteData", + "Searches", + ]; const DEVICE_PROPS: &[&str] = &[ "DeviceType", "HwAddress", @@ -155,6 +168,25 @@ impl DeviceChangedStream { return Self::handle_changed(changed); } + let header = message.header(); + let interface = header.interface()?; + if interface == "org.freedesktop.NetworkManager.Device.Wireless" { + let path = OwnedObjectPath::from(header.path()?.to_owned()); + let member = header.member()?; + + match member.as_str() { + "AccessPointAdded" => { + let ap_path: OwnedObjectPath = message.body().deserialize().ok()?; + return Some(NmChange::AccessPointAdded(path, ap_path)); + } + "AccessPointRemoved" => { + let ap_path: OwnedObjectPath = message.body().deserialize().ok()?; + return Some(NmChange::AccessPointRemoved(path, ap_path)); + } + _ => {} + } + } + None } } diff --git a/rust/agama-network/src/nm/streams/general.rs b/rust/agama-network/src/nm/streams/general.rs index 21c30fb4a2..917ece87b0 100644 --- a/rust/agama-network/src/nm/streams/general.rs +++ b/rust/agama-network/src/nm/streams/general.rs @@ -54,7 +54,7 @@ impl GeneralStateChangedStream { } fn handle_changed(message: PropertiesChanged) -> Option { - const GENERAL_PROPS: &[&str] = &["Connectivity", "WirelessEnabled"]; + const GENERAL_PROPS: &[&str] = &["Connectivity", "WirelessEnabled", "NetworkingEnabled"]; let args = message.args().ok()?; let inner = message.message(); diff --git a/rust/agama-network/src/nm/watcher.rs b/rust/agama-network/src/nm/watcher.rs index 887574ac92..597367d7c9 100644 --- a/rust/agama-network/src/nm/watcher.rs +++ b/rust/agama-network/src/nm/watcher.rs @@ -24,10 +24,16 @@ //! the NetworkSystem state when devices or active connections change. use crate::model::Connection; +use crate::nm::proxies::SettingsProxy; use std::collections::{hash_map::Entry, HashMap}; +use std::str::FromStr; use crate::types::Device; -use crate::{adapter::Watcher, nm::proxies::DeviceProxy, Action, NetworkAdapterError}; +use crate::{ + adapter::Watcher, + nm::proxies::{AccessPointProxy, DeviceProxy}, + Action, NetworkAdapterError, +}; use anyhow::anyhow; use async_trait::async_trait; use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender}; @@ -35,7 +41,7 @@ use tokio_stream::StreamExt; use zbus::zvariant::OwnedObjectPath; use super::{ - builder::{ConnectionFromProxyBuilder, DeviceFromProxyBuilder}, + builder::{AccessPointFromProxyBuilder, ConnectionFromProxyBuilder, DeviceFromProxyBuilder}, client::NetworkManagerClient, dbus::connection_from_dbus, error::NmError, @@ -63,6 +69,8 @@ use super::{ /// * A device is added, changed or removed. /// * The status of a device changes. /// * The IPv4 or IPv6 configuration changes. +/// * A connection is added or removed. +/// * An access point is added or removed. pub struct NetworkManagerWatcher { connection: zbus::Connection, } @@ -183,6 +191,7 @@ impl ActionDispatcher<'_> { /// It runs until the updates channel is closed. pub async fn run(&mut self) -> Result<(), NmError> { self.read_devices().await?; + self.read_connections().await?; while let Some(update) = self.updates_rx.recv().await { let result = match update { NmChange::GeneralStateChanged => self.handle_general_state_changed().await, @@ -199,6 +208,12 @@ impl ActionDispatcher<'_> { NmChange::ActiveConnectionRemoved(path) => { self.handle_active_connection_removed(path).await } + NmChange::AccessPointAdded(device_path, ap_path) => { + self.handle_access_point_added(device_path, ap_path).await + } + NmChange::AccessPointRemoved(device_path, ap_path) => { + self.handle_access_point_removed(device_path, ap_path).await + } }; if let Err(error) = result { @@ -217,12 +232,29 @@ impl ActionDispatcher<'_> { Ok(()) } + /// Reads the connections. + async fn read_connections(&mut self) -> Result<(), NmError> { + let settings_proxy = SettingsProxy::new(&self.connection).await?; + for path in settings_proxy.list_connections().await? { + match self.proxies.find_or_add_connection(&path).await { + Ok((uuid, _)) => tracing::info!("Adding connection {}", &uuid), + Err(e) => tracing::info!("Cannot add connection {} because {:?}", &path, &e), + } + } + Ok(()) + } + /// Handles a general state change. async fn handle_general_state_changed(&mut self) -> Result<(), NmError> { tracing::info!("General state was changed"); let client = NetworkManagerClient::new(self.connection.clone()).await?; - if let Ok(state) = client.general_state().await { - _ = self.actions_tx.send(Action::UpdateGeneralState(state)); + match client.general_state().await { + Ok(state) => { + _ = self.actions_tx.send(Action::UpdateGeneralState(state)); + } + Err(e) => { + tracing::warn!("Could not get the general state: {}", e); + } } Ok(()) } @@ -246,8 +278,8 @@ impl ActionDispatcher<'_> { /// * `path`: D-Bus object path of the removed connection. async fn handle_connection_removed(&mut self, path: OwnedObjectPath) -> Result<(), NmError> { tracing::info!("Connection was removed"); - if let Some((id, _)) = self.proxies.remove_connection(&path) { - _ = self.actions_tx.send(Action::RemoveConnection(id)); + if let Some((uuid, _)) = self.proxies.remove_connection(&path) { + _ = self.actions_tx.send(Action::RemoveConnection(uuid)); } Ok(()) } @@ -323,12 +355,12 @@ impl ActionDispatcher<'_> { path: OwnedObjectPath, ) -> Result<(), NmError> { let proxy = self.proxies.find_or_add_active_connection(&path).await?; - let id = proxy.id().await?; + let uuid = Uuid::from_str(&proxy.uuid().await?).map_err(NmError::InvalidNetworkUUID)?; let state = proxy.state().await.map(NmConnectionState)?; if let Ok(state) = state.try_into() { _ = self .actions_tx - .send(Action::ChangeConnectionState(id, state)); + .send(Action::ChangeConnectionState(uuid, state)); } // TODO: report an error if the device cannot get generated @@ -343,18 +375,51 @@ impl ActionDispatcher<'_> { path: OwnedObjectPath, ) -> Result<(), NmError> { if let Some(proxy) = self.proxies.remove_active_connection(&path) { - let id = proxy.id().await?; + let uuid = Uuid::from_str(&proxy.uuid().await?).map_err(NmError::InvalidNetworkUUID)?; let state = proxy.state().await.map(NmConnectionState)?; if let Ok(state) = state.try_into() { _ = self .actions_tx - .send(Action::ChangeConnectionState(id, state)); + .send(Action::ChangeConnectionState(uuid, state)); } } Ok(()) } + /// Handles the case where a new access point appears. + /// + /// * `device_path`: D-Bus object path of the device. + /// * `ap_path`: D-Bus object path of the new access point. + async fn handle_access_point_added( + &mut self, + device_path: OwnedObjectPath, + ap_path: OwnedObjectPath, + ) -> Result<(), NmError> { + let (name, _) = self.proxies.find_or_add_device(&device_path).await?; + let device_name = name.clone(); + let (_, proxy) = self.proxies.find_or_add_access_point(&ap_path).await?; + if let Ok(ap) = Self::access_point_from_proxy(device_name, proxy.clone()).await { + _ = self.actions_tx.send(Action::AddAccessPoint(Box::new(ap))); + } + Ok(()) + } + + /// Handles the removal of an access point. + /// + /// * `device_path`: D-Bus object path of the device. + /// * `ap_path`: D-Bus object path of the removed access point. + async fn handle_access_point_removed( + &mut self, + _device_path: OwnedObjectPath, + ap_path: OwnedObjectPath, + ) -> Result<(), NmError> { + if let Some((hw_address, _)) = self.proxies.remove_access_point(&ap_path) { + _ = self.actions_tx.send(Action::RemoveAccessPoint(hw_address)); + } + Ok(()) + } + async fn connection_from_proxy( connection: &zbus::Connection, proxy: ConnectionProxy<'_>, @@ -370,15 +435,26 @@ impl ActionDispatcher<'_> { let builder = DeviceFromProxyBuilder::new(connection, &proxy); builder.build().await } + + async fn access_point_from_proxy( + device_name: String, + proxy: AccessPointProxy<'_>, + ) -> Result { + let builder = AccessPointFromProxyBuilder::new(device_name, &proxy); + builder.build().await + } } +use uuid::Uuid; + /// Ancillary class to track the devices and their related D-Bus objects. pub struct ProxiesRegistry<'a> { connection: zbus::Connection, - connections: HashMap)>, + connections: HashMap)>, // the String is the device name like eth0 devices: HashMap)>, active_connections: HashMap>, + access_points: HashMap)>, } impl<'a> ProxiesRegistry<'a> { @@ -388,6 +464,7 @@ impl<'a> ProxiesRegistry<'a> { connections: HashMap::new(), devices: HashMap::new(), active_connections: HashMap::new(), + access_points: HashMap::new(), } } @@ -419,7 +496,7 @@ impl<'a> ProxiesRegistry<'a> { pub async fn find_or_add_connection( &mut self, path: &OwnedObjectPath, - ) -> Result<&(String, ConnectionProxy<'a>), NmError> { + ) -> Result<&(Uuid, ConnectionProxy<'a>), NmError> { // Cannot use entry(...).or_insert_with(...) because of the async call. match self.connections.entry(path.clone()) { Entry::Vacant(entry) => { @@ -429,7 +506,7 @@ impl<'a> ProxiesRegistry<'a> { .await?; let settings = proxy.get_settings().await?; match connection_from_dbus(settings) { - Ok(conn) => Ok(entry.insert((conn.id, proxy))), + Ok(conn) => Ok(entry.insert((conn.uuid, proxy))), Err(e) => { tracing::warn!("Could not process connection {}: {}", &path, e); Err(e) @@ -461,13 +538,35 @@ impl<'a> ProxiesRegistry<'a> { } } + /// Finds or adds an access point to the registry. + /// + /// * `path`: D-Bus object path. + pub async fn find_or_add_access_point( + &mut self, + path: &OwnedObjectPath, + ) -> Result<&(String, AccessPointProxy<'a>), NmError> { + // Cannot use entry(...).or_insert_with(...) because of the async call. + match self.access_points.entry(path.clone()) { + Entry::Vacant(entry) => { + let proxy = AccessPointProxy::builder(&self.connection.clone()) + .path(path.clone())? + .build() + .await?; + let hw_address = proxy.hw_address().await?; + + Ok(entry.insert((hw_address, proxy))) + } + Entry::Occupied(entry) => Ok(entry.into_mut()), + } + } + /// Removes a connection from the registry. /// /// * `path`: D-Bus object path. pub fn remove_connection( &mut self, path: &OwnedObjectPath, - ) -> Option<(String, ConnectionProxy<'_>)> { + ) -> Option<(Uuid, ConnectionProxy<'_>)> { self.connections.remove(path) } @@ -488,6 +587,16 @@ impl<'a> ProxiesRegistry<'a> { self.devices.remove(path) } + /// Removes an access point from the registry. + /// + /// * `path`: D-Bus object path. + pub fn remove_access_point( + &mut self, + path: &OwnedObjectPath, + ) -> Option<(String, AccessPointProxy<'_>)> { + self.access_points.remove(path) + } + //// Updates a device name. /// /// * `path`: D-Bus object path. @@ -520,11 +629,11 @@ impl<'a> ProxiesRegistry<'a> { /// * `ip6_config_path`: D-Bus object path of the IPv6 configuration. pub async fn find_device_for_ip6( &self, - ip4_config_path: &OwnedObjectPath, + ip6_config_path: &OwnedObjectPath, ) -> Option<&(String, DeviceProxy<'_>)> { for device in self.devices.values() { - if let Ok(path) = device.1.ip4_config().await { - if path == *ip4_config_path { + if let Ok(path) = device.1.ip6_config().await { + if path == *ip6_config_path { return Some(device); } } diff --git a/rust/agama-network/src/service.rs b/rust/agama-network/src/service.rs index 95a5ce46b5..cf79e6fbf4 100644 --- a/rust/agama-network/src/service.rs +++ b/rust/agama-network/src/service.rs @@ -273,7 +273,7 @@ impl Service { _ = self.output.send(update); } Err(error) => { - eprintln!("Could not process the action: {}", error); + tracing::error!("Could not process the action: {}", error); } _ => {} } @@ -390,14 +390,38 @@ impl Service { Action::GetDevices(tx) => { tx.send(self.state.devices.clone()).unwrap(); } + Action::AddAccessPoint(ap) => { + self.state.add_access_point(*ap.clone())?; + tracing::info!("Access point added: {:?}", &ap); + //self.events.send(Event::SystemChanged { + // scope: (Scope::Network), + //})?; + return Ok(Some(NetworkChange::AccessPointAdded(*ap))); + } + Action::RemoveAccessPoint(hw_address) => { + self.state.remove_access_point(&hw_address)?; + tracing::info!("Access point removed: {:?}", &hw_address); + //self.events.send(Event::SystemChanged { + // scope: (Scope::Network), + //})?; + return Ok(Some(NetworkChange::AccessPointRemoved(hw_address))); + } Action::UpdateConnection(conn, tx) => { 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::ChangeConnectionState(uuid, state) => { + if let Some(conn) = self.state.get_connection_by_uuid_mut(uuid) { + if conn.state != state { + tracing::info!( + "Changed connection {} state: ({} -> {})", + conn.id, + conn.state, + state + ); + conn.state = state; + } + return Ok(Some(NetworkChange::ConnectionStateChanged { uuid, state })); } } Action::UpdateGeneralState(general_state) => { @@ -411,11 +435,11 @@ impl Service { })?; } } - Action::RemoveConnection(id) => { - if let Some(conn) = self.state.get_connection(id.as_ref()) { + Action::RemoveConnection(uuid) => { + if let Some(conn) = self.state.get_connection_by_uuid(uuid) { if !conn.is_removed() { tracing::info!("Connection {:?} exists, removing it", conn); - self.state.remove_connection(id.as_str())?; + self.state.remove_connection(uuid)?; self.events.send(Event::ConfigChanged { scope: (Scope::Network), })?; @@ -426,9 +450,9 @@ impl Service { ); } } else { - tracing::info!("Connection {} does not exists, skipping it", id); + tracing::info!("Connection {} does not exists, skipping it", uuid); } - return Ok(Some(NetworkChange::ConnectionRemoved(id))); + return Ok(Some(NetworkChange::ConnectionRemoved(uuid))); } Action::Apply(tx) => { let result = self.apply().await; @@ -459,7 +483,10 @@ impl Service { Ok(state) => { if self.state != state { self.state = state; - self.events.send(Event::ConfigChanged { + self.events.send(Event::ProposalChanged { + scope: (Scope::Network), + })?; + self.events.send(Event::SystemChanged { scope: (Scope::Network), })?; } diff --git a/rust/agama-utils/src/api/network/settings.rs b/rust/agama-utils/src/api/network/settings.rs index cb1c8f2f6d..2dbe9b84cb 100644 --- a/rust/agama-utils/src/api/network/settings.rs +++ b/rust/agama-utils/src/api/network/settings.rs @@ -239,7 +239,12 @@ pub struct NetworkConnection { #[schema(schema_with = schemas::ip_addr_array)] pub nameservers: Vec, /// List of search domains for DNS resolution - #[serde(skip_serializing_if = "Vec::is_empty", default)] + #[serde( + skip_serializing_if = "Vec::is_empty", + default, + rename = "dnsSearchList", + alias = "dnsSearchlist" + )] pub dns_searchlist: Vec, /// Specifies whether to ignore automatically assigned DNS settings #[serde(skip_serializing_if = "Option::is_none")] @@ -320,3 +325,32 @@ pub struct NetworkConnectionWithState { pub connection: NetworkConnection, pub state: ConnectionState, } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_network_connection_dns_searchlist_serialization() { + let json = r#"{ + "id": "eth0", + "dnsSearchList": ["example.com"] + }"#; + let conn: NetworkConnection = serde_json::from_str(json).unwrap(); + assert_eq!(conn.dns_searchlist, vec!["example.com"]); + + let serialized = serde_json::to_string(&conn).unwrap(); + assert!(serialized.contains("\"dnsSearchList\":[\"example.com\"]")); + assert!(!serialized.contains("\"dnsSearchlist\"")); + } + + #[test] + fn test_network_connection_dns_searchlist_alias() { + let json = r#"{ + "id": "eth0", + "dnsSearchlist": ["example.org"] + }"#; + let conn: NetworkConnection = serde_json::from_str(json).unwrap(); + assert_eq!(conn.dns_searchlist, vec!["example.org"]); + } +} diff --git a/rust/agama-utils/src/api/network/types.rs b/rust/agama-utils/src/api/network/types.rs index e35453c9de..2d080cf4e7 100644 --- a/rust/agama-utils/src/api/network/types.rs +++ b/rust/agama-utils/src/api/network/types.rs @@ -170,7 +170,12 @@ pub struct IpConfig { #[serde(default, skip_serializing_if = "Vec::is_empty")] #[schema(schema_with = schemas::ip_addr_array)] pub nameservers: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] + #[serde( + default, + skip_serializing_if = "Vec::is_empty", + rename = "dnsSearchList", + alias = "dnsSearchlist" + )] pub dns_searchlist: Vec, pub ignore_auto_dns: bool, #[schema(schema_with = schemas::ip_addr)] @@ -768,6 +773,36 @@ mod tests { assert_eq!(format!("{}", mode), "active-backup"); } + #[test] + fn test_ip_config_dns_searchlist_serialization() { + let json = r#"{ + "method4": "auto", + "method6": "auto", + "dnsSearchList": ["example.com"], + "ignoreAutoDns": false, + "linkLocal4": "default" + }"#; + let config: IpConfig = serde_json::from_str(json).unwrap(); + assert_eq!(config.dns_searchlist, vec!["example.com"]); + + let serialized = serde_json::to_string(&config).unwrap(); + assert!(serialized.contains("\"dnsSearchList\":[\"example.com\"]")); + assert!(!serialized.contains("\"dnsSearchlist\"")); + } + + #[test] + fn test_ip_config_dns_searchlist_alias() { + let json = r#"{ + "method4": "auto", + "method6": "auto", + "dnsSearchlist": ["example.org"], + "ignoreAutoDns": false, + "linkLocal4": "default" + }"#; + let config: IpConfig = serde_json::from_str(json).unwrap(); + assert_eq!(config.dns_searchlist, vec!["example.org"]); + } + #[test] fn test_macaddress() { let mut val: Option = None; diff --git a/rust/package/agama.changes b/rust/package/agama.changes index 72f988e86b..350e01f0ea 100644 --- a/rust/package/agama.changes +++ b/rust/package/agama.changes @@ -1,3 +1,15 @@ +------------------------------------------------------------------- +Fri Mar 13 16:17:33 UTC 2026 - Knut Anderssen + +- Extend the network service to monitor connections, access points, + and general state changes, ensuring awareness of external + modifications to the network. +- Notify about System or Proposal changes when applying the + configuration and added progress reporting. +- Notify about System changes when devices change + (gh#agama-project/agama#3244, gh#agama-project/agama#3247, + gh#agama-project/agama#3276). + ------------------------------------------------------------------- Tue Mar 10 09:59:45 UTC 2026 - Josef Reidinger diff --git a/web/package/agama-web-ui.changes b/web/package/agama-web-ui.changes index b8019a975c..22194aac2f 100644 --- a/web/package/agama-web-ui.changes +++ b/web/package/agama-web-ui.changes @@ -1,3 +1,9 @@ +------------------------------------------------------------------- +Fri Mar 13 16:16:47 UTC 2026 - David Diaz + +- Change network web user interface to offer more control and + interaction with connections (gh#agama-project/agama#3247). + ------------------------------------------------------------------- Mon Mar 9 19:44:43 UTC 2026 - David Diaz diff --git a/web/src/api.ts b/web/src/api.ts index bc168c773e..416d129670 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -124,6 +124,7 @@ export { startInstallation, finishInstallation, passwordCheck, + postAction, }; export type { Response, PasswordCheckResult }; diff --git a/web/src/components/core/Page.test.tsx b/web/src/components/core/Page.test.tsx index da7c60593c..e31331b021 100644 --- a/web/src/components/core/Page.test.tsx +++ b/web/src/components/core/Page.test.tsx @@ -180,7 +180,8 @@ describe("Page", () => { }); describe("Page.Section", () => { - it("outputs to console.error if both are missing, title and aria-label", () => { + it.todo("re-activate or drop below test accordingly to decision taken with these attributes"); + it.skip("outputs to console.error if both are missing, title and aria-label", () => { plainRender(Content); expect(console.error).toHaveBeenCalledWith(expect.stringContaining("must have either")); }); diff --git a/web/src/components/core/Page.tsx b/web/src/components/core/Page.tsx index ea4aae464d..ddf3f0e9f9 100644 --- a/web/src/components/core/Page.tsx +++ b/web/src/components/core/Page.tsx @@ -152,9 +152,10 @@ const Section = ({ !isEmpty(ariaLabel) || (isObject(pfCardProps) && "aria-label" in pfCardProps); const props = { ...defaultCardProps, "aria-label": ariaLabel }; - if (!hasTitle && !hasAriaLabel) { - console.error("Page.Section must have either, a title or aria-label"); - } + // FIXME: review and improve or drop + // if (!hasTitle && !hasAriaLabel) { + // console.error("Page.Section must have either, a title or aria-label"); + // } if (hasTitle && !hasAriaLabel) props["aria-labelledby"] = titleId; diff --git a/web/src/components/network/BindingSettingsForm.test.tsx b/web/src/components/network/BindingSettingsForm.test.tsx index 9dfc887c76..9608f38458 100644 --- a/web/src/components/network/BindingSettingsForm.test.tsx +++ b/web/src/components/network/BindingSettingsForm.test.tsx @@ -40,6 +40,7 @@ const mockDevice: Device = { state: DeviceState.CONNECTED, addresses: [{ address: "192.168.69.201", prefix: 24 }], nameservers: ["192.168.69.100"], + dnsSearchList: [], gateway4: "192.168.69.4", gateway6: "192.168.69.6", method4: ConnectionMethod.AUTO, @@ -58,6 +59,7 @@ const mockMutation = jest.fn(() => Promise.resolve()); jest.mock("~/hooks/model/system/network", () => ({ ...jest.requireActual("~/hooks/model/system/network"), useDevices: () => [mockDevice], + useSystem: () => ({ state: { wirelessEnabled: true } }), })); jest.mock("~/hooks/model/proposal/network", () => ({ diff --git a/web/src/components/network/BindingSettingsForm.tsx b/web/src/components/network/BindingSettingsForm.tsx index a7af50495e..99a0d84962 100644 --- a/web/src/components/network/BindingSettingsForm.tsx +++ b/web/src/components/network/BindingSettingsForm.tsx @@ -22,66 +22,17 @@ import React, { useReducer } from "react"; import { generatePath, useNavigate, useParams } from "react-router"; -import { - ActionGroup, - Content, - Form, - FormGroup, - FormSelect, - FormSelectOption, - FormSelectProps, - Stack, -} from "@patternfly/react-core"; +import { ActionGroup, Content, Form, FormGroup, Stack } from "@patternfly/react-core"; import { Page, SubtleContent } from "~/components/core"; import { Connection, ConnectionBindingMode, Device } from "~/types/network"; +import DevicesSelector from "~/components/network/DevicesSelector"; import Radio from "~/components/core/RadioEnhanced"; -import { sprintf } from "sprintf-js"; -import { _ } from "~/i18n"; import { connectionBindingMode } from "~/utils/network"; import { useConnection } from "~/hooks/model/proposal/network"; import { useConnectionMutation } from "~/hooks/model/config/network"; import { useDevices } from "~/hooks/model/system/network"; import { NETWORK } from "~/routes/paths"; - -type DevicesSelectProps = Omit & { - /** - * The key from the device object whose value should be used for a select value - */ - valueKey: keyof Device; -}; - -/** - * A specialized `FormSelect` component for displaying and selecting network - * devices. - * - * The options' labels are formatted as "Device Name - MAC Address" or "MAC - * Address - Device Name" based on the `valueKey` prop, ensuring both key - * identifiers are visible. - */ -function DevicesSelect({ - value, - valueKey, - ...formSelectProps -}: DevicesSelectProps): React.ReactNode { - const devices = useDevices(); - - const labelAttrs = valueKey === "macAddress" ? ["macAddress", "name"] : ["name", "macAddress"]; - - return ( - - {devices.map((device, index) => { - // TRANSLATORS: A label shown in a dropdown for selecting a network - // device. It combines the device name and MAC address, with the order - // determined by the component settings: some selectors will show the - // name first, others the MAC address. I.e. "enp1s0 - CC-7F-C8-FC-7A-A1" - // or "CC-7F-C8-FC-7A-A1 - enp1s0". You may change the separator, but - // please keep both %s placeholders. - const label = sprintf(_("%s - %s"), device[labelAttrs[0]], device[labelAttrs[1]]); - return ; - })} - - ); -} +import { _ } from "~/i18n"; /** * Represents the form state. @@ -190,7 +141,7 @@ export default function BindingSettingsForm() { label={_("Bind to device name")} body={ - - ({ + useConnections: () => mockConnections, + useConnectionMutation: () => ({ mutateAsync: mockMutateAsync }), +})); + +jest.mock("~/hooks/model/system/network", () => ({ + useDevices: () => mockDevices, + useSystem: () => ({ state: { wirelessEnabled: true } }), +})); + +describe("ConnectionsTable", () => { + it("renders the connections in the table", () => { + installerRender(); + expect(screen.getByText("Wired connection 0")).toBeInTheDocument(); + expect(screen.getByText("Wifi1")).toBeInTheDocument(); + expect(screen.getByText("MAC connection")).toBeInTheDocument(); + }); + + it("renders the Status column", () => { + installerRender(); + // Wired connection 0 has status UP + expect(screen.getByText("Connected")).toBeInTheDocument(); + // Wifi1 has status DOWN + expect(screen.getAllByText("Disconnected").length).toBeGreaterThan(0); + }); + + it("shows the device name with a binding hint when a connection is bound by interface name", () => { + installerRender(); + const row = screen.getByText("Wired connection 0").closest("tr"); + within(row).getByText("eth0"); + within(row).getByText("(bound by name)"); + }); + + it("shows the device name with a binding hint when a connection is bound by MAC address", () => { + installerRender(); + const row = screen.getByText("MAC connection").closest("tr"); + within(row).getByText("enp2s0"); + within(row).getByText("(bound by MAC)"); + }); + + it("filters the connections by status", async () => { + const { user } = installerRender(); + // Select Status "Up" + await user.click(screen.getByLabelText("Status")); + await user.click(screen.getByRole("option", { name: "Up" })); + expect(screen.getByText("Wired connection 0")).toBeInTheDocument(); + expect(screen.queryByText("Wifi1")).not.toBeInTheDocument(); + + // Select Status "Down" + await user.click(screen.getByLabelText("Status")); + await user.click(screen.getByRole("option", { name: "Down" })); + expect(screen.queryByText("Wired connection 0")).not.toBeInTheDocument(); + expect(screen.getByText("Wifi1")).toBeInTheDocument(); + expect(screen.getByText("MAC connection")).toBeInTheDocument(); + }); + + it("calls mutateConnection with status UP when 'Connect' is clicked", async () => { + const { user } = installerRender(); + await user.click(screen.getByRole("button", { name: /actions for Wifi1/i })); + await user.click(screen.getByText("Connect")); + expect(mockMutateAsync).toHaveBeenCalledWith( + expect.objectContaining({ + id: "Wifi1", + status: "up", + }), + ); + }); + + it("calls mutateConnection with status DOWN when 'Disconnect' is clicked", async () => { + const { user } = installerRender(); + await user.click(screen.getByRole("button", { name: /actions for Wired connection 0/i })); + await user.click(screen.getByText("Disconnect")); + expect(mockMutateAsync).toHaveBeenCalledWith( + expect.objectContaining({ + id: "Wired connection 0", + status: "down", + }), + ); + }); + + it("navigates to the wired connection page when 'Details' is clicked for an ethernet connection", async () => { + const { user } = installerRender(); + await user.click(screen.getByRole("button", { name: /actions for Wired connection 0/i })); + await user.click(screen.getByText("Details")); + expect(mockNavigateFn).toHaveBeenCalledWith("/network/wired_connection/Wired%20connection%200"); + }); + + it("navigates to the wired connection page when 'Details' is clicked for a wifi connection", async () => { + const { user } = installerRender(); + await user.click(screen.getByRole("button", { name: /actions for Wifi1/i })); + await user.click(screen.getByText("Details")); + expect(mockNavigateFn).toHaveBeenCalledWith("/network/wired_connection/Wifi1"); + }); + + it("navigates to the edit connection page when 'Edit connection' is clicked", async () => { + const { user } = installerRender(); + await user.click(screen.getByRole("button", { name: /actions for Wired connection 0/i })); + await user.click(screen.getByText("Edit connection")); + expect(mockNavigateFn).toHaveBeenCalledWith("/network/connections/Wired%20connection%200/edit"); + }); + + it("navigates to the edit binding page when 'Edit binding' is clicked for an ethernet connection", async () => { + const { user } = installerRender(); + await user.click(screen.getByRole("button", { name: /actions for Wired connection 0/i })); + await user.click(screen.getByText("Edit binding")); + expect(mockNavigateFn).toHaveBeenCalledWith( + "/network/connections/Wired%20connection%200/binding/edit", + ); + }); + + it("does not show 'Edit binding' for wifi connections", async () => { + const { user } = installerRender(); + await user.click(screen.getByRole("button", { name: /actions for Wifi1/i })); + expect(screen.queryByText("Edit binding")).not.toBeInTheDocument(); + }); + + it("calls mutateConnection with status DELETE when 'Delete' is clicked", async () => { + const { user } = installerRender(); + await user.click(screen.getByRole("button", { name: /actions for Wired connection 0/i })); + await user.click(screen.getByText("Delete")); + expect(mockMutateAsync).toHaveBeenCalledWith( + expect.objectContaining({ + id: "Wired connection 0", + status: "removed", + }), + ); + }); +}); diff --git a/web/src/components/network/ConnectionsTable.tsx b/web/src/components/network/ConnectionsTable.tsx new file mode 100644 index 0000000000..f9ddc46d51 --- /dev/null +++ b/web/src/components/network/ConnectionsTable.tsx @@ -0,0 +1,401 @@ +/* + * Copyright (c) [2026] 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, { useReducer } from "react"; +import { generatePath, useNavigate } from "react-router"; +import { isEmpty } from "radashi"; +import { sprintf } from "sprintf-js"; +import { + Button, + Content, + Divider, + EmptyState, + EmptyStateActions, + EmptyStateBody, + EmptyStateFooter, + Toolbar, + ToolbarContent, + ToolbarGroup, + ToolbarItem, +} from "@patternfly/react-core"; +import Icon from "~/components/layout/Icon"; +import Text from "~/components/core/Text"; +import SelectableDataTable, { SortedBy } from "~/components/core/SelectableDataTable"; +import TextinputFilter from "~/components/storage/dasd/TextinputFilter"; +import SimpleSelector from "~/components/core/SimpleSelector"; +import { useConnections, useConnectionMutation } from "~/hooks/model/config/network"; +import { useDevices, useSystem } from "~/hooks/model/system/network"; +import { sortCollection } from "~/utils"; +import { formatIp } from "~/utils/network"; +import { _ } from "~/i18n"; +import { Connection, ConnectionStatus, Device } from "~/types/network"; +import { NETWORK } from "~/routes/paths"; + +/** + * Filter options for narrowing down network connections shown in the table. + */ +type ConnectionsFilters = { + name?: string; + device?: string; + type?: "all" | "wifi" | "ethernet"; + status?: "all" | "up" | "down"; +}; + +/** Internal state shape for the connections table component. */ +type TableState = { + /** Current sorting state */ + sortedBy: SortedBy; + /** Current active filters applied to the connection list */ + filters: ConnectionsFilters; +}; + +const initialState: TableState = { + sortedBy: { index: 0, direction: "asc" }, + filters: { + name: "", + device: "", + type: "all", + status: "all", + }, +}; + +type TableAction = + | { type: "UPDATE_SORTING"; payload: TableState["sortedBy"] } + | { type: "UPDATE_FILTERS"; payload: TableState["filters"] } + | { type: "RESET_FILTERS" }; + +const reducer = (state: TableState, action: TableAction): TableState => { + switch (action.type) { + case "UPDATE_SORTING": { + return { ...state, sortedBy: action.payload }; + } + case "UPDATE_FILTERS": { + return { ...state, filters: { ...state.filters, ...action.payload } }; + } + case "RESET_FILTERS": { + return { ...state, filters: initialState.filters }; + } + } +}; + +const filterConnections = ( + connections: Connection[], + filters: ConnectionsFilters, +): Connection[] => { + const { name, device, type, status } = filters; + + return connections.filter((c) => { + if (!isEmpty(name) && !c.id.toLowerCase().includes(name.toLowerCase())) { + return false; + } + + if ( + !isEmpty(device) && + !(c.iface || c.macAddress || "").toLowerCase().includes(device.toLowerCase()) + ) { + return false; + } + + if (type && type !== "all") { + const isWifi = !!c.wireless; + if (type === "wifi" && !isWifi) return false; + if (type === "ethernet" && isWifi) return false; + } + + if (status && status !== "all") { + const isUp = c.status === ConnectionStatus.UP; + if (status === "up" && !isUp) return false; + if (status === "down" && isUp) return false; + } + + return true; + }); +}; + +/** + * Returns a hint indicating how a connection is bound to a device, + * or `null` if the connection has no binding constraint. + * + * A connection can be bound either by interface name or by MAC address. + * This is used as a supplementary label in the "Device" column of the + * network connections table. + */ +const bindingHint = (connection: Connection) => { + if (!isEmpty(connection.iface)) return _("(bound by name)"); + if (!isEmpty(connection.macAddress)) return _("(bound by MAC)"); + return null; +}; + +const createColumns = (devices: Device[]) => [ + { + name: _("Name"), + value: (c: Connection) => c.id, + sortingKey: (c: Connection) => c.id, + }, + { + name: _("Type"), + value: (c: Connection) => (c.wireless ? _("Wi-Fi") : _("Ethernet")), + sortingKey: (c: Connection) => (c.wireless ? "wifi" : "ethernet"), + }, + { + name: _("Status"), + value: (c: Connection) => + c.status === ConnectionStatus.UP ? _("Connected") : _("Disconnected"), + sortingKey: (c: Connection) => c.status, + }, + { + name: _("Device"), + value: (c: Connection) => { + const usingDevices = devices.filter((d) => d.connection === c.id); + + if (isEmpty(usingDevices)) return "-"; + + if (usingDevices.length === 1) { + return ( + + {usingDevices[0].name}{" "} + {bindingHint(c)} + + ); + } + + return usingDevices.map((d) => d.name).join(", "); + }, + sortingKey: (c: Connection) => { + const usingDevices = devices.filter((d) => d.connection === c.id); + return usingDevices.map((d) => d.name).join(", "); + }, + }, + { + name: _("IP Addresses"), + value: (c: Connection) => { + const device = devices.find((d) => d.connection === c.id); + const addresses = device ? device.addresses : c.addresses; + return addresses.map(formatIp).join(", ") || "-"; + }, + }, +]; + +export default function ConnectionsTable() { + const [state, dispatch] = useReducer(reducer, initialState); + const connections = useConnections(); + const devices = useDevices(); + const { state: systemState } = useSystem(); + const { mutateAsync: mutateConnection } = useConnectionMutation(); + const navigate = useNavigate(); + + const columns = createColumns(devices); + + const onSortingChange = (sortedBy: SortedBy) => { + dispatch({ type: "UPDATE_SORTING", payload: sortedBy }); + }; + + const onFilterChange = (filter: keyof ConnectionsFilters, value) => { + dispatch({ type: "UPDATE_FILTERS", payload: { [filter]: value } }); + }; + + const resetFilters = () => dispatch({ type: "RESET_FILTERS" }); + + const upConnection = (connection: Connection) => { + const conn = new Connection(connection.id, { + ...connection, + status: ConnectionStatus.UP, + }); + mutateConnection(conn); + }; + + const downConnection = (connection: Connection) => { + const conn = new Connection(connection.id, { + ...connection, + status: ConnectionStatus.DOWN, + }); + mutateConnection(conn); + }; + + const deleteConnection = (connection: Connection) => { + const toDelete = new Connection(connection.id, { + ...connection, + status: ConnectionStatus.DELETE, + }); + mutateConnection(toDelete); + }; + + const filteredConnections = filterConnections(connections, state.filters); + const sortedConnections = sortCollection( + filteredConnections, + state.sortedBy.direction, + columns[state.sortedBy.index].sortingKey, + ); + + const hasActiveFilters = JSON.stringify(state.filters) !== JSON.stringify(initialState.filters); + + const countText = hasActiveFilters + ? sprintf( + // TRANSLATORS: shown in the filter toolbar when filters are active. + // %1$s is the number of matching connections, %2$s is the total number. + _("%1$d of %2$d connections match filters"), + filteredConnections.length, + connections.length, + ) + : sprintf( + // TRANSLATORS: shown in the filter toolbar when no filters are active. + // %s is the total number of connections. + _("%d connections available"), + connections.length, + ); + + return ( + + + + + + onFilterChange("name", v)} + /> + + + onFilterChange("type", v)} + /> + + + onFilterChange("status", v)} + /> + + + + + {countText} + + {hasActiveFilters && ( + + + + )} + + + + + + + { + const isWifi = !!c.wireless; + const isConnected = c.status === ConnectionStatus.UP; + const isDisconnected = c.status === ConnectionStatus.DOWN; + const canConnect = !isWifi || systemState.wirelessEnabled; + + return [ + { + id: "details", + title: _("Details"), + onClick: () => { + // FIXME: create a shared connection page and route + navigate(generatePath(NETWORK.wiredConnection, { id: c.id })); + }, + }, + { + id: "edit", + title: _("Edit connection"), + onClick: () => navigate(generatePath(NETWORK.editConnection, { id: c.id })), + }, + !isWifi && { + id: "editBinding", + title: _("Edit binding"), + onClick: () => navigate(generatePath(NETWORK.editBindingSettings, { id: c.id })), + }, + { + isSeparator: true, + }, + isDisconnected && + canConnect && { + id: "connect", + title: _("Connect"), + onClick: () => upConnection(c), + }, + isConnected && { + id: "disconnect", + title: _("Disconnect"), + onClick: () => downConnection(c), + }, + { + isSeparator: true, + }, + { + id: "delete", + title: _("Delete"), + isDanger: true, + onClick: () => deleteConnection(c), + }, + ].filter(Boolean); + }} + itemActionsLabel={(c: Connection) => `Actions for ${c.id}`} + emptyState={ + } + variant="sm" + > + {_("Change filters and try again.")} + + + + + + + } + /> + + ); +} diff --git a/web/src/components/network/DevicesSelector.test.tsx b/web/src/components/network/DevicesSelector.test.tsx new file mode 100644 index 0000000000..01a97befae --- /dev/null +++ b/web/src/components/network/DevicesSelector.test.tsx @@ -0,0 +1,200 @@ +/* + * Copyright (c) [2025-2026] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React from "react"; +import { screen, within } from "@testing-library/react"; +import { installerRender } from "~/test-utils"; +import DevicesSelector from "./DevicesSelector"; +import { ConnectionMethod, ConnectionType, Device, DeviceState } from "~/types/network"; + +const mockDevice1: Device = { + name: "enp1s0", + connection: "Network 1", + type: ConnectionType.ETHERNET, + state: DeviceState.CONNECTED, + addresses: [{ address: "192.168.69.201", prefix: 24 }], + nameservers: ["192.168.69.100"], + dnsSearchList: [], + gateway4: "192.168.69.4", + gateway6: "192.168.69.6", + method4: ConnectionMethod.AUTO, + method6: ConnectionMethod.AUTO, + macAddress: "AA:11:22:33:44:FF", + routes4: [], + routes6: [], +}; + +const mockDevice2: Device = { + name: "wlan0", + connection: "Network 2", + type: ConnectionType.WIFI, + state: DeviceState.DISCONNECTED, + addresses: [{ address: "192.168.1.50", prefix: 24 }], + nameservers: [], + dnsSearchList: [], + gateway4: "192.168.1.1", + gateway6: "", + method4: ConnectionMethod.AUTO, + method6: ConnectionMethod.AUTO, + macAddress: "52:54:00:46:2A:F9", + routes4: [], + routes6: [], +}; + +const mockUseDevicesFn = jest.fn(); +const mockUseSystemFn = jest.fn(); + +jest.mock("~/hooks/model/system/network", () => ({ + ...jest.requireActual("~/hooks/model/system/network"), + useDevices: () => mockUseDevicesFn(), + useSystem: () => mockUseSystemFn(), +})); + +describe("DevicesSelector", () => { + beforeEach(() => { + mockUseDevicesFn.mockReturnValue([mockDevice1, mockDevice2]); + mockUseSystemFn.mockReturnValue({ state: { wirelessEnabled: true } }); + }); + describe("when valueKey is 'name'", () => { + it("renders options with 'name - macAddress' label form and name as value", () => { + installerRender( + , + ); + + const option1 = screen.getByRole("option", { + name: `${mockDevice1.name} - ${mockDevice1.macAddress}`, + }); + const option2 = screen.getByRole("option", { + name: `${mockDevice2.name} - ${mockDevice2.macAddress}`, + }); + + expect(option1).toHaveValue(mockDevice1.name); + expect(option2).toHaveValue(mockDevice2.name); + }); + + it("reflects the selected value", () => { + installerRender( + , + ); + + const select = screen.getByRole("combobox", { name: "Choose device to bind by name" }); + expect(select).toHaveValue(mockDevice1.name); + }); + }); + + describe("when valueKey is 'macAddress'", () => { + it("renders options with 'macAddress - name' label form and MAC address as value", () => { + installerRender( + , + ); + + const option1 = screen.getByRole("option", { + name: `${mockDevice1.macAddress} - ${mockDevice1.name}`, + }); + const option2 = screen.getByRole("option", { + name: `${mockDevice2.macAddress} - ${mockDevice2.name}`, + }); + + expect(option1).toHaveValue(mockDevice1.macAddress); + expect(option2).toHaveValue(mockDevice2.macAddress); + }); + + it("reflects the selected value", () => { + installerRender( + , + ); + + const select = screen.getByRole("combobox", { name: "Choose device to bind by MAC" }); + expect(select).toHaveValue(mockDevice1.macAddress); + }); + }); + + describe("when includesNone is true", () => { + it("renders a 'None (unbound)' option as the first option", () => { + installerRender( + , + ); + + const select = screen.getByRole("combobox", { name: "Choose device to bind by name" }); + const options = within(select).getAllByRole("option"); + + expect(options[0]).toHaveTextContent("None (unbound)"); + expect(options[0]).toHaveValue(""); + }); + }); + + describe("when disabled", () => { + it("renders the select as disabled", () => { + installerRender( + , + ); + + const select = screen.getByRole("combobox", { name: "Choose device to bind by name" }); + expect(select).toBeDisabled(); + }); + }); + + describe("when there are no devices", () => { + beforeEach(() => { + mockUseDevicesFn.mockReturnValue([]); + }); + + it("renders an empty select", () => { + installerRender( + , + ); + + const select = screen.getByRole("combobox", { name: "Choose device to bind by name" }); + expect(within(select).queryAllByRole("option")).toHaveLength(0); + }); + }); + + describe("when wirelessEnabled is false", () => { + beforeEach(() => { + mockUseSystemFn.mockReturnValue({ state: { wirelessEnabled: false } }); + }); + + it("filters out Wi-Fi devices", () => { + installerRender( + , + ); + + const option1 = screen.getByRole("option", { + name: `${mockDevice1.name} - ${mockDevice1.macAddress}`, + }); + const option2 = screen.queryByRole("option", { + name: `${mockDevice2.name} - ${mockDevice2.macAddress}`, + }); + + expect(option1).toBeInTheDocument(); + expect(option2).not.toBeInTheDocument(); + }); + }); +}); diff --git a/web/src/components/network/DevicesSelector.tsx b/web/src/components/network/DevicesSelector.tsx new file mode 100644 index 0000000000..c02c4dfd06 --- /dev/null +++ b/web/src/components/network/DevicesSelector.tsx @@ -0,0 +1,81 @@ +/* + * Copyright (c) [2025-2026] 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 { sprintf } from "sprintf-js"; +import { FormSelect, FormSelectOption, FormSelectProps } from "@patternfly/react-core"; +import { ConnectionType, Device } from "~/types/network"; +import { useDevices, useSystem } from "~/hooks/model/system/network"; +import { _ } from "~/i18n"; + +type DevicesSelectorProps = Omit & { + /** + * The key from the device object whose value should be used for a select value + */ + valueKey: keyof Device; + + /** + * When true, prepends a "None (unbound)" option with a null value, allowing + * the user to explicitly leave the connection unbound to any device. + */ + includesNone?: boolean; +}; + +/** + * A specialized `FormSelect` component for displaying and selecting network + * devices. + * + * The options' labels are formatted as "Device Name - MAC Address" or "MAC + * Address - Device Name" based on the `valueKey` prop, ensuring both key + * identifiers are visible. + */ +export default function DevicesSelector({ + value, + valueKey, + includesNone = false, + ...formSelectProps +}: DevicesSelectorProps): React.ReactNode { + const devices = useDevices(); + const { state } = useSystem(); + + const filteredDevices = state.wirelessEnabled + ? devices + : devices.filter((d) => d.type !== ConnectionType.WIFI); + + const labelAttrs = valueKey === "macAddress" ? ["macAddress", "name"] : ["name", "macAddress"]; + + return ( + + {includesNone && } + {filteredDevices.map((device, index) => { + // TRANSLATORS: A label shown in a dropdown for selecting a network + // device. It combines the device name and MAC address, with the order + // determined by the component settings: some selectors will show the + // name first, others the MAC address. I.e. "enp1s0 - CC-7F-C8-FC-7A-A1" + // or "CC-7F-C8-FC-7A-A1 - enp1s0". You may change the separator, but + // please keep both %s placeholders. + const label = sprintf(_("%s - %s"), device[labelAttrs[0]], device[labelAttrs[1]]); + return ; + })} + + ); +} diff --git a/web/src/components/network/DnsSearchDataList.tsx b/web/src/components/network/DnsSearchDataList.tsx new file mode 100644 index 0000000000..4a54b37401 --- /dev/null +++ b/web/src/components/network/DnsSearchDataList.tsx @@ -0,0 +1,131 @@ +/* + * Copyright (c) [2026] 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, + DataList, + DataListItem, + DataListItemRow, + DataListItemCells, + DataListCell, + DataListAction, + Flex, + FormGroup, + TextInput, +} from "@patternfly/react-core"; + +import { _ } from "~/i18n"; + +type DnsSearch = { + id?: number; + domain: string; +}; + +let index = 0; + +export default function DnsSearchDataList({ + searchList: originalSearchList, + updateDnsSearchList, +}: { + searchList: DnsSearch[]; + updateDnsSearchList: (searchList: DnsSearch[]) => void; +}) { + const searchList = originalSearchList.map((item: DnsSearch) => { + if (!item.id) item.id = index++; + return item; + }); + + const addDomain = () => { + searchList.push({ domain: "", id: index++ }); + updateDnsSearchList(searchList); + }; + + const updateDomain = (id: number, value: string) => { + const item = searchList.find((i) => i.id === id); + if (item) { + item.domain = value; + updateDnsSearchList(searchList); + } + }; + + const deleteDomain = (id: number) => { + const itemIdx = searchList.findIndex((i) => i.id === id); + searchList.splice(itemIdx, 1); + updateDnsSearchList(searchList); + }; + + const renderDomain = ({ id, domain }: DnsSearch) => { + return ( + + + + updateDomain(id!, value)} + /> + , + ]} + /> + {/** @ts-expect-error: https://github.com/patternfly/patternfly-react/issues/9823 */} + + + + + + ); + }; + + // TRANSLATORS: button label + const newDomainButtonText = searchList.length + ? _("Add another search domain") + : _("Add search domain"); + + return ( + + + + {searchList.map((item) => renderDomain(item))} + + + + + ); +} diff --git a/web/src/components/network/IpSettingsForm.test.tsx b/web/src/components/network/IpSettingsForm.test.tsx new file mode 100644 index 0000000000..132d22dc75 --- /dev/null +++ b/web/src/components/network/IpSettingsForm.test.tsx @@ -0,0 +1,175 @@ +/* + * Copyright (c) [2026] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React from "react"; +import { screen, waitFor } from "@testing-library/react"; +import { installerRender, mockParams } from "~/test-utils"; +import IpSettingsForm from "~/components/network/IpSettingsForm"; +import { + Connection, + ConnectionMethod, + ConnectionType, + Device, + DeviceState, + Wireless, +} from "~/types/network"; + +const mockDevice1: Device = { + name: "enp1s0", + connection: "Network 1", + type: ConnectionType.ETHERNET, + state: DeviceState.CONNECTED, + addresses: [{ address: "192.168.69.201", prefix: 24 }], + nameservers: ["192.168.69.100"], + dnsSearchList: [], + gateway4: "192.168.69.4", + gateway6: "192.168.69.6", + method4: ConnectionMethod.AUTO, + method6: ConnectionMethod.AUTO, + macAddress: "AA:11:22:33:44:FF", + routes4: [], + routes6: [], +}; + +const mockConnection = new Connection("Network 1", { + wireless: new Wireless({ ssid: "Network 1" }), +}); + +const mockMutateAsync = jest.fn().mockResolvedValue({}); + +jest.mock("~/hooks/model/config/network", () => ({ + useConnectionMutation: () => ({ mutateAsync: mockMutateAsync }), +})); + +const mockUseConnection = jest.fn(); + +jest.mock("~/hooks/model/proposal/network", () => ({ + useConnection: (id: string) => mockUseConnection(id), +})); + +jest.mock("~/hooks/model/system/network", () => ({ + useDevices: () => [mockDevice1], + useSystem: () => ({ state: { wirelessEnabled: true } }), +})); + +describe("IpSettingsForm", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("when adding a new connection", () => { + beforeEach(() => { + mockParams({ id: "Connection #1" }); + mockUseConnection.mockReturnValue(undefined); + }); + + it("renders with the ID from params", async () => { + installerRender(); + const nameInput = screen.getByLabelText("Name"); + expect(nameInput).toHaveValue("Connection #1"); + expect(nameInput).toBeEnabled(); + }); + + it("shows 'New connection' in breadcrumbs", () => { + installerRender(); + screen.getByText("New connection"); + }); + + it("allows editing the connection ID", async () => { + const { user } = installerRender(); + const nameInput = screen.getByLabelText("Name"); + await user.clear(nameInput); + await user.type(nameInput, "My New Connection"); + expect(nameInput).toHaveValue("My New Connection"); + const saveButton = screen.getByRole("button", { name: /save|accept|ok/i }); + await user.click(saveButton); + await waitFor(() => { + expect(mockMutateAsync).toHaveBeenCalledWith( + expect.objectContaining({ id: "My New Connection" }), + ); + }); + }); + + it("does not send iface when 'None (unbound)' is selected", async () => { + const { user } = installerRender(); + const select = screen.getByRole("combobox", { name: /Interface/ }); + await user.selectOptions(select, "None (unbound)"); + const saveButton = screen.getByRole("button", { name: /save|accept|ok/i }); + await user.click(saveButton); + await waitFor(() => { + expect(mockMutateAsync).toHaveBeenCalledWith(expect.objectContaining({ iface: undefined })); + }); + }); + }); + + describe("when editing an existing wired connection", () => { + const existingConnection = new Connection("Network 1"); + + beforeEach(() => { + mockParams({ id: "Network 1" }); + mockUseConnection.mockReturnValue(existingConnection); + }); + + it("renders with the existing connection ID and it is disabled", () => { + installerRender(); + const nameInput = screen.getByLabelText("Name"); + expect(nameInput).toHaveValue("Network 1"); + expect(nameInput).toBeDisabled(); + }); + + it("shows 'Edit' in breadcrumbs", () => { + installerRender(); + screen.getByText("Edit"); + }); + + it("links to the wired connection page in breadcrumbs", () => { + installerRender(); + const breadcrumbLink = screen.getByRole("link", { name: "Network 1" }); + expect(breadcrumbLink).toHaveAttribute("href", "/network/wired_connection/Network%201"); + }); + }); + + describe("when editing an existing wireless connection", () => { + beforeEach(() => { + mockParams({ id: "Network 1" }); + mockUseConnection.mockReturnValue(mockConnection); + }); + + it("renders with the existing connection ID and it is disabled", () => { + installerRender(); + const nameInput = screen.getByLabelText("Name"); + expect(nameInput).toHaveValue("Network 1"); + expect(nameInput).toBeDisabled(); + }); + + it("shows 'Edit' in breadcrumbs", () => { + installerRender(); + screen.getByText("Edit"); + }); + + it("links to the wired connection page in breadcrumbs", () => { + installerRender(); + const breadcrumbLink = screen.getByRole("link", { name: "Network 1" }); + expect(breadcrumbLink).toHaveAttribute("href", "/network/wired_connection/Network%201"); + }); + }); +}); diff --git a/web/src/components/network/IpSettingsForm.tsx b/web/src/components/network/IpSettingsForm.tsx index 2dceb3629b..4d9f5b6feb 100644 --- a/web/src/components/network/IpSettingsForm.tsx +++ b/web/src/components/network/IpSettingsForm.tsx @@ -21,6 +21,7 @@ */ import React, { useState } from "react"; +import { isEmpty } from "radashi"; import { generatePath, useNavigate, useParams } from "react-router"; import { ActionGroup, @@ -39,29 +40,48 @@ import { import { Page } from "~/components/core"; import AddressesDataList from "~/components/network/AddressesDataList"; import DnsDataList from "~/components/network/DnsDataList"; -import { _ } from "~/i18n"; -import { IPAddress, Connection, ConnectionMethod } from "~/types/network"; +import DnsSearchDataList from "~/components/network/DnsSearchDataList"; +import { + IPAddress, + Connection, + ConnectionMethod, + ConnectionStatus, + ConnectionOptions, +} from "~/types/network"; import { useConnectionMutation } from "~/hooks/model/config/network"; import { useConnection } from "~/hooks/model/proposal/network"; import { NETWORK } from "~/routes/paths"; +import DevicesSelector from "./DevicesSelector"; +import { _ } from "~/i18n"; const usingDHCP = (method: ConnectionMethod) => method === ConnectionMethod.AUTO; // FIXME: rename to connedtioneditpage or so? // FIXME: improve the layout a bit. export default function IpSettingsForm() { - const { id } = useParams(); + const { id: initialId } = useParams(); const navigate = useNavigate(); const { mutateAsync: updateConnection } = useConnectionMutation(); - const connection = useConnection(id); + const connectionFromStore = useConnection(initialId!); + const [connection] = useState( + connectionFromStore || + new Connection(initialId!, { status: ConnectionStatus.UP, persistent: true }), + ); + const [id, setId] = useState(connection.id); const [addresses, setAddresses] = useState(connection.addresses); const [nameservers, setNameservers] = useState( connection.nameservers.map((a) => { return { address: a }; }), ); + const [dnsSearchList, setDnsSearchList] = useState( + connection.dnsSearchList.map((domain) => { + return { domain }; + }), + ); const [method, setMethod] = useState(connection.method4); - const [gateway, setGateway] = useState(connection.gateway4); + const [iface, setIface] = useState(connection.iface); + const [gateway, setGateway] = useState(connection.gateway4 || ""); const [fieldErrors, setFieldErrors] = useState({}); const [requestError, setRequestError] = useState(); @@ -75,6 +95,9 @@ export default function IpSettingsForm() { const cleanAddresses = (addresses: IPAddress[]) => addresses.filter((address) => address.address !== ""); + const cleanDnsSearchList = (list: { domain: string }[]) => + list.filter((item) => item.domain !== ""); + const cleanError = (field: string) => { if (isSetAsInvalid(field)) { const nextErrors = { ...fieldErrors }; @@ -97,6 +120,10 @@ export default function IpSettingsForm() { setMethod(value as ConnectionMethod); }; + const onIfaceChange: FormSelectProps["onChange"] = (_, value) => { + setIface(isEmpty(value) ? undefined : value); + }; + const validate = (sanitizedAddresses: IPAddress[]) => { setFieldErrors({}); @@ -116,16 +143,20 @@ export default function IpSettingsForm() { const sanitizedAddresses = cleanAddresses(addresses); const sanitizedNameservers = cleanAddresses(nameservers); + const sanitizedDnsSearchList = cleanDnsSearchList(dnsSearchList); if (!validate(sanitizedAddresses)) return; // TODO: deal with DNS servers - const updatedConnection = new Connection(connection.id, { - ...connection, + const { id: _, ...connectionOptions } = connection; + const updatedConnection = new Connection(id, { + ...connectionOptions, + iface, addresses: sanitizedAddresses, method4: method, gateway4: gateway, nameservers: sanitizedNameservers.map((s) => s.address), + dnsSearchList: sanitizedDnsSearchList.map((s) => s.domain), }); updateConnection(updatedConnection) .then(() => navigate(-1)) @@ -148,9 +179,14 @@ export default function IpSettingsForm() { const breadcrumbs = [ { label: _("Network"), path: NETWORK.root }, - { label: connection.id, path: generatePath(NETWORK.wiredConnection, { id: connection.id }) }, - { label: _("Edit") }, - ]; + connectionFromStore && { + label: connectionFromStore.id, + path: generatePath(NETWORK.wiredConnection, { + id: connectionFromStore.id, + }), + }, + { label: connectionFromStore ? _("Edit") : "New connection" }, + ].filter(Boolean); // TRANSLATORS: manual network configuration mode with a static IP address // %s is replaced by the connection name @@ -164,6 +200,36 @@ export default function IpSettingsForm() { )}
+ + setId(value)} + isDisabled={!!connectionFromStore} + /> + + + {_("Interface")} {_("(bind by name)")} + + } + isStack + > + + + + + {_("Cancel")} diff --git a/web/src/components/network/NetworkPage.test.tsx b/web/src/components/network/NetworkPage.test.tsx index 071d7fd759..3442d4955c 100644 --- a/web/src/components/network/NetworkPage.test.tsx +++ b/web/src/components/network/NetworkPage.test.tsx @@ -25,18 +25,10 @@ import { screen } from "@testing-library/react"; import { installerRender } from "~/test-utils"; import NetworkPage from "~/components/network/NetworkPage"; -jest.mock("~/components/network/WifiNetworksList", () => () =>
WifiNetworksList Mock
); - -jest.mock("~/components/network/WiredConnectionsList", () => () => ( -
WiredConnectionsList Mock
-)); - -jest.mock("~/components/network/NoPersistentConnectionsAlert", () => () => ( -
NoPersistentConnectionsAlert Mock
-)); - +const mockProgressFn = jest.fn(); const mockSystem = { connections: [], + devices: [], state: { connectivity: true, copyNetwork: true, @@ -45,43 +37,63 @@ const mockSystem = { }, }; +jest.mock("~/components/network/ConnectionsTable", () => () =>
ConnectionsTable Mock
); + +jest.mock("~/components/network/NoPersistentConnectionsAlert", () => () => ( +
NoPersistentConnectionsAlert Mock
+)); + +jest.mock("~/hooks/model/progress", () => ({ + useProgress: () => mockProgressFn(), +})); + jest.mock("~/hooks/model/system/network", () => ({ useNetworkChanges: jest.fn(), useSystem: () => mockSystem, })); +jest.mock("~/hooks/model/config/network", () => ({ + useConnections: () => [], + useConnectionMutation: () => ({ mutateAsync: jest.fn() }), +})); + describe("NetworkPage", () => { + beforeEach(() => { + mockProgressFn.mockReturnValue(undefined); + }); + it("mounts alert for all connections status", () => { installerRender(); expect(screen.queryByText("NoPersistentConnectionsAlert Mock")).toBeInTheDocument(); }); - it("renders a section for wired connections", () => { + it("renders a section for connections", () => { installerRender(); - expect(screen.queryByText("WiredConnectionsList Mock")).toBeInTheDocument(); + expect(screen.queryByText("ConnectionsTable Mock")).toBeInTheDocument(); }); - describe("when Wi-Fi support is enabled", () => { - beforeEach(() => { - mockSystem.state.wirelessEnabled = true; + it("shows the progress backdrop when there is an active progress", () => { + mockProgressFn.mockReturnValue({ + scope: "network", + step: "Performing some network task", + index: 1, + size: 1, }); - it("renders the list of Wi-Fi networks", () => { - installerRender(); - expect(screen.queryByText("WifiNetworksList Mock")).toBeInTheDocument(); - }); + installerRender(); + expect(screen.queryByText("Performing some network task")).toBeInTheDocument(); }); - describe("when Wi-Fi support is disabled", () => { - beforeEach(() => { - mockSystem.state.wirelessEnabled = false; - }); + it("does not render the 'Connect to Wi-Fi network' link when wireless is disabled", () => { + installerRender(); + expect( + screen.queryByRole("link", { name: "Connect to Wi-Fi network" }), + ).not.toBeInTheDocument(); + }); - it("does not render the list of Wi-Fi networks", () => { - installerRender(); - expect( - screen.queryByText(/The system does not support Wi-Fi connections/), - ).toBeInTheDocument(); - }); + it("renders the 'Connect to Wi-Fi network' link when wireless is enabled", () => { + mockSystem.state.wirelessEnabled = true; + installerRender(); + expect(screen.getByRole("link", { name: "Connect to Wi-Fi network" })).toBeInTheDocument(); }); }); diff --git a/web/src/components/network/NetworkPage.tsx b/web/src/components/network/NetworkPage.tsx index df0ed63261..f08845f43e 100644 --- a/web/src/components/network/NetworkPage.tsx +++ b/web/src/components/network/NetworkPage.tsx @@ -21,52 +21,56 @@ */ import React from "react"; -import { Grid, GridItem } from "@patternfly/react-core"; -import { EmptyState, Page } from "~/components/core"; -import WifiNetworksList from "./WifiNetworksList"; -import WiredConnectionsList from "./WiredConnectionsList"; -import NoPersistentConnectionsAlert from "./NoPersistentConnectionsAlert"; -import { _ } from "~/i18n"; +import { Flex, Stack } from "@patternfly/react-core"; +import { Link, Page } from "~/components/core"; +import Text from "~/components/core/Text"; +import Icon from "~/components/layout/Icon"; +import NoPersistentConnectionsAlert from "~/components/network/NoPersistentConnectionsAlert"; +import ConnectionsTable from "~/components/network/ConnectionsTable"; import { useNetworkChanges, useSystem } from "~/hooks/model/system/network"; - -const NoWifiAvailable = () => ( - - - {_( - "The system does not support Wi-Fi connections, probably because of missing or disabled hardware.", - )} - - -); +import { NETWORK } from "~/routes/paths"; +import { _ } from "~/i18n"; /** * Page component holding Network settings */ export default function NetworkPage() { useNetworkChanges(); - const { state: networkState } = useSystem(); + const { state } = useSystem(); return ( - + - - - - - - - - - {networkState.wirelessEnabled ? ( - - - - ) : ( - - )} - - + + + {_("Manage available connections, connect to Wi-Fi, or add a new connection.")} + + + + + {_("Add connection")} + + + {state.wirelessEnabled && ( + + + {_("Connect to Wi-Fi network")} + + + )} + + } + > + + + ); diff --git a/web/src/components/network/WifiConnectionDetails.test.tsx b/web/src/components/network/WifiConnectionDetails.test.tsx deleted file mode 100644 index f527742e56..0000000000 --- a/web/src/components/network/WifiConnectionDetails.test.tsx +++ /dev/null @@ -1,115 +0,0 @@ -/* - * Copyright (c) [2025] SUSE LLC - * - * All Rights Reserved. - * - * This program is free software; you can redistribute it and/or modify it - * under the terms of the GNU General Public License as published by the Free - * Software Foundation; either version 2 of the License, or (at your option) - * any later version. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for - * more details. - * - * You should have received a copy of the GNU General Public License along - * with this program; if not, contact SUSE LLC. - * - * To contact SUSE LLC about this file by physical or electronic mail, you may - * find current contact information at www.suse.com. - */ - -import React from "react"; -import { screen, within } from "@testing-library/react"; -import { installerRender } from "~/test-utils"; -import WifiConnectionDetails from "./WifiConnectionDetails"; -import { - Connection, - ConnectionMethod, - ConnectionType, - Device, - DeviceState, - SecurityProtocols, - WifiNetworkStatus, -} from "~/types/network"; - -jest.mock("~/components/network/InstallationOnlySwitch", () => () => ( -
InstallationOnlySwitch mock
-)); - -const wlan0: Device = { - name: "wlan0", - connection: "Network 1", - type: ConnectionType.WIFI, - state: DeviceState.CONNECTED, - addresses: [{ address: "192.168.69.201", prefix: 24 }], - nameservers: ["192.168.69.100"], - method4: ConnectionMethod.MANUAL, - method6: ConnectionMethod.AUTO, - gateway4: "192.168.69.4", - gateway6: "192.168.69.6", - macAddress: "AA:11:22:33:44::FF", - routes4: [], - routes6: [], -}; - -const mockNetwork = { - ssid: "Network 1", - strength: 25, - hwAddress: "??", - security: [SecurityProtocols.RSN], - device: wlan0, - deviceName: "wlan0", - settings: new Connection("Network 1", { - iface: "wlan0", - addresses: [{ address: "192.168.69.201", prefix: 24 }], - }), - status: WifiNetworkStatus.CONNECTED, -}; - -describe("WifiConnectionDetails", () => { - it("renders the device data", () => { - installerRender(); - const section = screen.getByRole("region", { name: "Device" }); - within(section).getByText("wlan0"); - within(section).getByText("connected"); - within(section).getByText("AA:11:22:33:44::FF"); - }); - - it("renders the network data", () => { - installerRender(); - const section = screen.getByRole("region", { name: "Network" }); - within(section).getByText("Network 1"); - within(section).getByText("25%"); - within(section).getByText("connected"); - within(section).getByText("WPA2"); - }); - - it("renders the IP data", () => { - installerRender(); - const section = screen.getByRole("region", { name: "IP settings" }); - within(section).getByText("IPv4 auto"); - within(section).getByText("IPv6 auto"); - // IP - within(section).getByText("192.168.69.201/24"); - // DNS - within(section).getByText("192.168.69.100"); - // Gateway 4 - within(section).getByText("192.168.69.4"); - // Gateway 6 - within(section).getByText("192.168.69.6"); - }); - - it("renders link for editing the connection", () => { - installerRender(); - const section = screen.getByRole("region", { name: "IP settings" }); - const editLink = within(section).getByRole("link", { name: "Edit" }); - expect(editLink).toHaveAttribute("href", "/network/connections/Network%201/edit"); - }); - - it("renders the switch for making connection available only during installation", () => { - installerRender(); - screen.getByText("InstallationOnlySwitch mock"); - }); -}); diff --git a/web/src/components/network/WifiConnectionDetails.tsx b/web/src/components/network/WifiConnectionDetails.tsx deleted file mode 100644 index 3e52572baa..0000000000 --- a/web/src/components/network/WifiConnectionDetails.tsx +++ /dev/null @@ -1,181 +0,0 @@ -/* - * 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 { - DescriptionList, - DescriptionListDescription, - DescriptionListGroup, - DescriptionListTerm, - Flex, - FlexItem, - Grid, - GridItem, - Stack, -} from "@patternfly/react-core"; -import { generatePath } from "react-router"; -import { Link, Page } from "~/components/core"; -import InstallationOnlySwitch from "./InstallationOnlySwitch"; -import { Device, WifiNetwork } from "~/types/network"; -import { NETWORK } from "~/routes/paths"; -import { formatIp } from "~/utils/network"; -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 170067d315..d69a6cd377 100644 --- a/web/src/components/network/WifiConnectionForm.test.tsx +++ b/web/src/components/network/WifiConnectionForm.test.tsx @@ -21,40 +21,13 @@ */ import React from "react"; -import { screen } from "@testing-library/react"; -import { installerRender } from "~/test-utils"; +import { screen, waitFor } from "@testing-library/react"; +import { installerRender, mockNavigateFn } from "~/test-utils"; import WifiConnectionForm from "./WifiConnectionForm"; -import { Connection, SecurityProtocols, WifiNetworkStatus, Wireless } from "~/types/network"; - -const mockConnection = new Connection("Visible Network", { - wireless: new Wireless({ ssid: "Visible Network" }), -}); - -const mockSystem = { - connections: [mockConnection], - state: { - connectivity: true, - wiredEnabled: true, - wirelessEnabled: false, - persistNetwork: true, - copyEnabled: false, - }, -}; - -const networkMock = { - ssid: "Visible Network", - hidden: false, - deviceName: "wlan0", - status: WifiNetworkStatus.NOT_CONFIGURED, - hwAddress: "00:EB:D8:17:6B:56", - security: [SecurityProtocols.WPA], - strength: 85, - settings: mockConnection, -}; - -const publicNetworkMock = { ...networkMock, security: [] }; +import { WifiNetworkStatus } from "~/types/network"; const mockUpdateConnection = jest.fn(); +const mockUseWifiNetworks = jest.fn(); jest.mock("~/hooks/model/config/network", () => ({ ...jest.requireActual("~/hooks/model/config/network"), @@ -63,109 +36,108 @@ jest.mock("~/hooks/model/config/network", () => ({ }), })); -jest.mock("~/api", () => ({ - ...jest.requireActual("~/api"), - configureL10nAction: () => jest.fn(), -})); - -jest.mock("~/hooks/model/system", () => ({ - ...jest.requireActual("~/hooks/model/system"), - useSystem: () => jest.fn(), -})); - jest.mock("~/hooks/model/system/network", () => ({ ...jest.requireActual("~/hooks/model/system/network"), - useSystem: () => mockSystem, - useConnections: () => mockSystem.connections, + useNetworkChanges: jest.fn(), + useWifiNetworks: () => mockUseWifiNetworks(), })); +const visibleNetwork = { + ssid: "Visible Network", + hidden: false, + deviceName: "wlan0", + status: WifiNetworkStatus.NOT_CONFIGURED, + hwAddress: "00:EB:D8:17:6B:56", + security: ["WPA2"], + strength: 85, +}; + +const publicNetwork = { + ...visibleNetwork, + ssid: "Public Network", + security: [], +}; + describe("WifiConnectionForm", () => { beforeEach(() => { + jest.clearAllMocks(); mockUpdateConnection.mockResolvedValue(undefined); + mockUseWifiNetworks.mockReturnValue([visibleNetwork, publicNetwork]); }); - describe("when rendered for a public network", () => { - it("warns the user about connecting to an unprotected network", () => { - installerRender(, { withL10n: true }); + it("renders an empty state when no networks are found", () => { + mockUseWifiNetworks.mockReturnValue([]); + installerRender(, { withL10n: true }); + screen.getByText("No Wi-Fi networks were found"); + }); + + it("renders the network selector", () => { + installerRender(, { withL10n: true }); + screen.getByRole("combobox", { name: "Network" }); + }); + + describe("when a public network is selected", () => { + it("warns the user about connecting to an unprotected network", async () => { + const { user } = installerRender(, { withL10n: true }); + await user.selectOptions(screen.getByRole("combobox", { name: "Network" }), "Public Network"); screen.getByText("Warning alert:"); screen.getByText("Not protected network"); }); - it("renders only the Connect and Cancel actions", () => { - installerRender(, { withL10n: true }); + it("does not render the security selector", async () => { + const { user } = installerRender(, { withL10n: true }); + await user.selectOptions(screen.getByRole("combobox", { name: "Network" }), "Public Network"); expect(screen.queryByRole("combobox", { name: "Security" })).toBeNull(); - screen.getByRole("button", { name: "Connect" }); - screen.getByRole("button", { name: "Cancel" }); }); }); - describe("when form is submitted", () => { - it("replaces form by an informative alert ", async () => { - const { user } = installerRender(, { - withL10n: true, - }); - screen.getByRole("form", { name: "Wi-Fi connection form" }); - const connectButton = screen.getByRole("button", { name: "Connect" }); - await user.click(connectButton); - expect(screen.queryByRole("form", { name: "Wi-Fi connection form" })).toBeNull(); - screen.getByText("Setting up connection"); + describe("when a protected network is selected", () => { + it("renders the security selector", async () => { + const { user } = installerRender(, { withL10n: true }); + await user.selectOptions( + screen.getByRole("combobox", { name: "Network" }), + "Visible Network", + ); + screen.getByRole("combobox", { name: "Security" }); }); - 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 } = installerRender(, { - withL10n: true, - }); - const securitySelector = screen.getByRole("combobox", { name: "Security" }); - const connectButton = screen.getByText("Connect"); - await user.selectOptions(securitySelector, "wpa-psk"); - const passwordInput = screen.getByLabelText("WPA Password"); - await user.type(passwordInput, "wifi-password"); - await user.click(connectButton); - - expect(mockUpdateConnection).toHaveBeenCalledWith( - expect.objectContaining({ - wireless: expect.objectContaining({ security: "wpa-psk", password: "wifi-password" }), - }), - ); + it("pre-selects the security based on network supported protocols", async () => { + const { user } = installerRender(, { withL10n: true }); + await user.selectOptions( + screen.getByRole("combobox", { name: "Network" }), + "Visible Network", + ); + await waitFor(() => { + expect(screen.getByRole("combobox", { name: "Security" })).toHaveValue("wpa-psk"); }); }); + }); - describe("for an already configured network", () => { - it("triggers a mutation for updating and connecting to the network", async () => { - const { user } = installerRender( - , - { withL10n: true }, - ); - const connectButton = screen.getByText("Connect"); - const passwordInput = screen.getByLabelText("WPA Password"); - await user.clear(passwordInput); - await user.type(passwordInput, "right-wifi-password"); - await user.click(connectButton); - + describe("when the form is submitted", () => { + it("triggers a mutation and navigates to the network page", async () => { + const { user } = installerRender(, { withL10n: true }); + await user.selectOptions( + screen.getByRole("combobox", { name: "Network" }), + "Visible Network", + ); + await waitFor(() => { + expect(screen.getByRole("combobox", { name: "Security" })).toHaveValue("wpa-psk"); + }); + const passwordInput = screen.getByLabelText("WPA Password"); + await user.type(passwordInput, "wifi-password"); + await user.click(screen.getByRole("button", { name: "Connect" })); + await waitFor(() => { expect(mockUpdateConnection).toHaveBeenCalledWith( expect.objectContaining({ - id: networkMock.ssid, wireless: expect.objectContaining({ + ssid: "Visible Network", security: "wpa-psk", - password: "right-wifi-password", + password: "wifi-password", }), }), ); }); + expect(mockNavigateFn).toHaveBeenCalledWith("/network"); }); }); }); diff --git a/web/src/components/network/WifiConnectionForm.tsx b/web/src/components/network/WifiConnectionForm.tsx index 6216fdbb8a..b98807cf5e 100644 --- a/web/src/components/network/WifiConnectionForm.tsx +++ b/web/src/components/network/WifiConnectionForm.tsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2022-2025] SUSE LLC + * Copyright (c) [2022-2026] SUSE LLC * * All Rights Reserved. * @@ -20,24 +20,27 @@ * find current contact information at www.suse.com. */ -import React, { useEffect, useState } from "react"; +import React, { useEffect, useReducer } from "react"; +import { isEmpty } from "radashi"; import { ActionGroup, Alert, Content, + EmptyState, Form, FormGroup, FormSelect, FormSelectOption, - Spinner, } from "@patternfly/react-core"; +import Icon from "~/components/layout/Icon"; import { Page, PasswordInput } from "~/components/core"; -import { Connection, ConnectionState, WifiNetwork, Wireless } from "~/types/network"; -import { isEmpty } from "radashi"; -import { sprintf } from "sprintf-js"; -import { N_, _ } from "~/i18n"; -import { useConnections } from "~/hooks/model/system/network"; +import { Connection, WifiNetwork, Wireless } from "~/types/network"; +import { useWifiNetworks } from "~/hooks/model/system/network"; import { useConnectionMutation } from "~/hooks/model/config/network"; +import WifiNetworksSelector from "./WifiNetworksSelector"; +import { useNavigate } from "react-router"; +import { PATHS } from "~/routes/network"; +import { N_, _ } from "~/i18n"; const securityOptions = [ // TRANSLATORS: WiFi authentication mode @@ -49,7 +52,7 @@ const securityOptions = [ const securityFrom = (supported: string[]) => { if (supported.includes("WPA2")) return "wpa-psk"; if (supported.includes("WPA1")) return "wpa-psk"; - return ""; + return "none"; }; const PublicNetworkAlert = () => { @@ -62,132 +65,144 @@ const PublicNetworkAlert = () => { ); }; -const ConnectingAlert = () => { - return ( - } - title={_("Setting up connection")} - > - {_("It may take some time.")} - - {_("Details will appear after the connection is successfully established.")} - - - ); +type FormState = { + ssid: string; + security: string; + password: string; }; -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.")} - )} - - ); +type FormAction = + | { type: "INITIALIZE"; networks: WifiNetwork[] } + | { type: "SET_SSID"; ssid: string; networks: WifiNetwork[] } + | { type: "SET_SECURITY"; security: string } + | { type: "SET_PASSWORD"; password: string }; + +const formReducer = (state: FormState, action: FormAction): FormState => { + switch (action.type) { + case "INITIALIZE": { + const network = action.networks[0]; + return network + ? { ssid: network.ssid, security: securityFrom(network.security), password: "" } + : state; + } + case "SET_SSID": { + const network = action.networks.find((n) => n.ssid === action.ssid); + return { + ssid: action.ssid, + security: network ? securityFrom(network.security) : "", + password: "", + }; + } + case "SET_SECURITY": + return { ...state, security: action.security }; + case "SET_PASSWORD": + return { ...state, password: action.password }; + } }; -// 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 }: { network: WifiNetwork }) { - const connections = useConnections(); - const connection = connections.find((c) => c.id === network.ssid); - const settings = network.settings?.wireless || new Wireless(); - const [error, setError] = useState(false); - const [security, setSecurity] = useState( - settings?.security || securityFrom(network?.security || []), - ); - const [password, setPassword] = useState(settings.password || ""); - const [isActivating, setIsActivating] = useState(false); - const [isConnecting, setIsConnecting] = useState( - connection?.state === ConnectionState.activating, - ); +function WifiConnectionFormContent() { + const navigate = useNavigate(); + const networks = useWifiNetworks(); + const [form, dispatch] = useReducer(formReducer, { ssid: "", security: "", password: "" }); const { mutateAsync: updateConnection } = useConnectionMutation(); useEffect(() => { - if (!isActivating) return; - - if (connection.state === ConnectionState.deactivated) { - setError(true); - setIsConnecting(false); - setIsActivating(false); + if (!isEmpty(networks) && !form.ssid) { + dispatch({ type: "INITIALIZE", networks }); } - }, [isActivating, connection?.state]); + }, [networks, form.ssid]); - useEffect(() => { - if (isConnecting && connection?.state === ConnectionState.activating) { - setIsActivating(true); - } - }, [isConnecting, connection]); + const network = networks.find((n) => n.ssid === form.ssid); + const isPublicNetwork = isEmpty(network?.security); const accept = async (e) => { e.preventDefault(); - // 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 nextConnection = new Connection(form.ssid, { + wireless: new Wireless({ + ssid: form.ssid, + security: form.security || "none", + password: form.password, + hidden: false, + }), }); - updateConnection(nextConnection).catch(() => setError(true)); - setError(false); - setIsConnecting(true); + updateConnection(nextConnection); + navigate(PATHS.root); }; - const isPublicNetwork = isEmpty(network.security); - - if (isConnecting) return ; + if (networks.length === 0) + return ( + } + /> + ); return ( - <> + + + dispatch({ type: "SET_SSID", ssid: v, networks })} + /> + + {isPublicNetwork && } - {/** TRANSLATORS: accessible name for the WiFi connection form */} - - {error && } + {/* TRANSLATORS: Wifi security configuration (password protected or not) */} + {!isEmpty(network?.security) && ( + + dispatch({ type: "SET_SECURITY", security: v })} + > + {securityOptions.map((security) => ( + + ))} + + + )} + {form.security === "wpa-psk" && ( + // TRANSLATORS: WiFi password + + dispatch({ type: "SET_PASSWORD", password: v })} + /> + + )} + + + {/* TRANSLATORS: button label, connect to a Wi-Fi network */} + {_("Connect")} + + {_("Cancel")} + + + ); +} - {/* 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")} - - - +export default function wifiConnectionForm() { + return ( + + + + + ); } diff --git a/web/src/components/network/WifiNetworkPage.tsx b/web/src/components/network/WifiNetworkPage.tsx deleted file mode 100644 index 8317ee3c4c..0000000000 --- a/web/src/components/network/WifiNetworkPage.tsx +++ /dev/null @@ -1,85 +0,0 @@ -/* - * Copyright (c) [2025-2026] 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"; -import { - 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 "~/hooks/model/system/network"; -import { DeviceState } from "~/types/network"; -import { PATHS } from "~/routes/network"; -import { _ } from "~/i18n"; -import { sprintf } from "sprintf-js"; -import { NETWORK } from "~/routes/paths"; - -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; - - return ( - - - {!network && } - {network && !connected && } - {network && connected && } - - - ); -} diff --git a/web/src/components/network/WifiNetworksList.test.tsx b/web/src/components/network/WifiNetworksList.test.tsx index e362e8b694..580093b225 100644 --- a/web/src/components/network/WifiNetworksList.test.tsx +++ b/web/src/components/network/WifiNetworksList.test.tsx @@ -42,6 +42,7 @@ const wlan0: Device = { state: DeviceState.CONNECTED, addresses: [{ address: "192.168.69.201", prefix: 24 }], nameservers: ["192.168.69.1"], + dnsSearchList: [], method4: ConnectionMethod.MANUAL, method6: ConnectionMethod.AUTO, gateway4: "192.168.69.1", @@ -64,7 +65,11 @@ jest.mock("~/hooks/model/system/network", () => ({ useConnections: () => mockWifiConnections, })); -describe("WifiNetworksList", () => { +it.todo( + "Reenable the test if component is used again. If not, drop both, test and component files.", +); + +describe.skip("WifiNetworksList", () => { describe("when visible networks are found", () => { beforeEach(() => { mockWifiConnections = [ diff --git a/web/src/components/network/WifiNetworksList.tsx b/web/src/components/network/WifiNetworksList.tsx index 98b0957f95..fb64efcef6 100644 --- a/web/src/components/network/WifiNetworksList.tsx +++ b/web/src/components/network/WifiNetworksList.tsx @@ -114,7 +114,7 @@ const NetworkListItem = ({ network, connection, showIp }: NetworkListItemProps) const ipId = useId(); return ( - + navigate(generatePath(PATHS.wifiNetwork, { ssid }))} + onSelectDataListItem={(_, id) => navigate(generatePath(PATHS.wiredConnection, { id }))} {...props} > {networks.map((n) => ( diff --git a/web/src/components/network/WifiNetworksSelector.test.tsx b/web/src/components/network/WifiNetworksSelector.test.tsx new file mode 100644 index 0000000000..b10fa079df --- /dev/null +++ b/web/src/components/network/WifiNetworksSelector.test.tsx @@ -0,0 +1,112 @@ +/* + * Copyright (c) [2026] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React from "react"; +import { screen } from "@testing-library/react"; +import { installerRender } from "~/test-utils"; +import WifiNetworksSelector from "./WifiNetworksSelector"; +import { WifiNetworkStatus } from "~/types/network"; + +const connectedNetwork = { + ssid: "Connected Network", + hidden: false, + deviceName: "wlan0", + status: WifiNetworkStatus.CONNECTED, + hwAddress: "00:EB:D8:17:6B:56", + security: ["WPA2"], + strength: 75, +}; + +const configuredNetwork = { + ssid: "Configured Network", + hidden: false, + deviceName: "wlan0", + status: WifiNetworkStatus.CONFIGURED, + hwAddress: "00:EB:D8:17:6B:57", + security: ["WPA2"], + strength: 50, +}; + +const notConfiguredNetwork = { + ssid: "Not Configured Network", + hidden: false, + deviceName: "wlan0", + status: WifiNetworkStatus.NOT_CONFIGURED, + hwAddress: "00:EB:D8:17:6B:58", + security: [], + strength: 25, +}; + +const weakNetwork = { + ssid: "Weak Network", + hidden: false, + deviceName: "wlan0", + status: WifiNetworkStatus.NOT_CONFIGURED, + hwAddress: "00:EB:D8:17:6B:59", + security: [], + strength: 10, +}; + +const mockUseWifiNetworks = jest.fn(); + +jest.mock("~/hooks/model/system/network", () => ({ + ...jest.requireActual("~/hooks/model/system/network"), + useNetworkChanges: jest.fn(), + useWifiNetworks: () => mockUseWifiNetworks(), +})); + +describe("WifiNetworksSelector", () => { + beforeEach(() => { + mockUseWifiNetworks.mockReturnValue([ + notConfiguredNetwork, + weakNetwork, + configuredNetwork, + connectedNetwork, + ]); + }); + + it("renders all available networks as options", () => { + installerRender(); + const selector = screen.getByRole("combobox"); + expect(selector).toBeInTheDocument(); + screen.getByRole("option", { name: "Connected Network" }); + screen.getByRole("option", { name: "Configured Network" }); + screen.getByRole("option", { name: "Not Configured Network" }); + screen.getByRole("option", { name: "Weak Network" }); + }); + + it("sorts networks by status first, then by signal strength", () => { + installerRender(); + const options = screen.getAllByRole("option").map((o) => o.textContent); + expect(options).toEqual([ + "Connected Network", + "Configured Network", + "Not Configured Network", + "Weak Network", + ]); + }); + + it("renders with the given value selected", () => { + installerRender(); + expect(screen.getByRole("combobox")).toHaveValue("Configured Network"); + }); +}); diff --git a/web/src/components/network/WifiNetworksSelector.tsx b/web/src/components/network/WifiNetworksSelector.tsx new file mode 100644 index 0000000000..d5f144f053 --- /dev/null +++ b/web/src/components/network/WifiNetworksSelector.tsx @@ -0,0 +1,59 @@ +/* + * Copyright (c) [2026] 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 { FormSelect, FormSelectOption, FormSelectProps } from "@patternfly/react-core"; +import { WifiNetwork, WifiNetworkStatus } from "~/types/network"; +import { useNetworkChanges, useWifiNetworks } from "~/hooks/model/system/network"; + +type WifiNetworksSelectorProps = Omit; + +/** + * Component for displaying a list of available Wi-Fi networks + */ +export default function WifiNetworksSelector({ + value, + ...formSelectorProps +}: WifiNetworksSelectorProps) { + useNetworkChanges(); + const networks: WifiNetwork[] = useWifiNetworks(); + + 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 ( + + {networks.map((network, index) => ( + + ))} + + ); +} diff --git a/web/src/components/network/WiredConnectionDetails.test.tsx b/web/src/components/network/WiredConnectionDetails.test.tsx index be64eab6ba..698f3e8e6a 100644 --- a/web/src/components/network/WiredConnectionDetails.test.tsx +++ b/web/src/components/network/WiredConnectionDetails.test.tsx @@ -44,6 +44,7 @@ const mockDevice: Device = { state: DeviceState.CONNECTED, addresses: [{ address: "192.168.69.201", prefix: 24 }], nameservers: ["192.168.69.100"], + dnsSearchList: ["example.com"], gateway4: "192.168.69.4", gateway6: "192.168.69.6", method4: ConnectionMethod.AUTO, @@ -59,6 +60,7 @@ const mockAnotherDevice: Device = { state: DeviceState.CONNECTED, addresses: [{ address: "192.168.69.101", prefix: 24 }], nameservers: ["192.168.69.50"], + dnsSearchList: ["another.example.com"], gateway4: "192.168.69.70", gateway6: "192.168.69.80", method4: ConnectionMethod.AUTO, diff --git a/web/src/components/network/WiredConnectionDetails.tsx b/web/src/components/network/WiredConnectionDetails.tsx index 1affe24a05..fdeb0c98b6 100644 --- a/web/src/components/network/WiredConnectionDetails.tsx +++ b/web/src/components/network/WiredConnectionDetails.tsx @@ -38,12 +38,13 @@ import { TabTitleText, } from "@patternfly/react-core"; import { generatePath } from "react-router"; -import { Link, Page } from "~/components/core"; +import Text from "~/components/core/Text"; +import { Link, NestedContent, Page } from "~/components/core"; import InstallationOnlySwitch from "./InstallationOnlySwitch"; import { Connection, Device } from "~/types/network"; import { connectionBindingMode, formatIp } from "~/utils/network"; import { NETWORK } from "~/routes/paths"; -import { useDevices } from "~/hooks/model/system/network"; +import { useDevices, useWifiNetworks } from "~/hooks/model/system/network"; import { isEmpty } from "radashi"; import { sprintf } from "sprintf-js"; import { _ } from "~/i18n"; @@ -86,6 +87,45 @@ const BindingSettings = ({ connection }: { connection: Connection }) => { ); }; +const NetworkDetails = ({ connection }: { connection: Connection }) => { + const networks = useWifiNetworks(); + const network = networks.find((c) => c.ssid === connection.wireless?.ssid); + + return ( + + + + {_("SSID")} + {connection.wireless.ssid} + + + {_("Security")} + {connection.wireless.security} + + {network && ( + <> + + {_("Status")} + {network.status} + + + {_("Signal strength")} + {network.strength}% + + + )} + + {!network && ( + + + {_("Network not availble")} + + + )} + + ); +}; + const DeviceDetails = ({ device }: { device: Device }) => { return ( { + + {_("DNS Search List")} + + + {device.dnsSearchList.map((domain, idx) => ( + {domain} + ))} + + + {_("Routes")} @@ -255,6 +305,18 @@ const ConnectionDetails = ({ connection }: { connection: Connection }) => { + + {_("DNS Search List")} + + + {isEmpty(connection.dnsSearchList) + ? _("None set") + : connection.dnsSearchList.map((domain, idx) => ( + {domain} + ))} + + + @@ -274,7 +336,11 @@ export default function WiredConnectionDetails({ connection }: { connection: Con - + {connection.wireless ? ( + + ) : ( + + )} diff --git a/web/src/components/network/WiredConnectionPage.test.tsx b/web/src/components/network/WiredConnectionPage.test.tsx index fe2979580c..4cde7d5080 100644 --- a/web/src/components/network/WiredConnectionPage.test.tsx +++ b/web/src/components/network/WiredConnectionPage.test.tsx @@ -25,6 +25,11 @@ import { screen } from "@testing-library/react"; import { installerRender, mockParams } from "~/test-utils"; import WiredConnectionPage from "~/components/network/WiredConnectionPage"; import { Connection, ConnectionState } from "~/types/network"; +import { useProgress } from "~/hooks/model/progress"; + +jest.mock("~/hooks/model/progress", () => ({ + useProgress: jest.fn(), +})); const mockConnection: Connection = new Connection("Network 1", { state: ConnectionState.activated, @@ -70,4 +75,22 @@ describe("", () => { screen.getByText("Connection not found or lost"); }); }); + + describe("when there is an active progress", () => { + it("shows the progress backdrop", () => { + (useProgress as jest.Mock).mockImplementation((scope) => + scope === "network" + ? { + scope: "network", + step: "Performing some network task", + index: 1, + size: 1, + } + : undefined, + ); + + installerRender(); + expect(screen.queryByText("Performing some network task")).toBeInTheDocument(); + }); + }); }); diff --git a/web/src/components/network/WiredConnectionPage.tsx b/web/src/components/network/WiredConnectionPage.tsx index 7d828468bc..c7b025e970 100644 --- a/web/src/components/network/WiredConnectionPage.tsx +++ b/web/src/components/network/WiredConnectionPage.tsx @@ -65,7 +65,10 @@ export default function WiredConnectionPage() { const connection = connections.find((c) => c.id === id); return ( - + {connection ? ( diff --git a/web/src/components/network/WiredConnectionsList.test.tsx b/web/src/components/network/WiredConnectionsList.test.tsx deleted file mode 100644 index 0fa8fa1944..0000000000 --- a/web/src/components/network/WiredConnectionsList.test.tsx +++ /dev/null @@ -1,101 +0,0 @@ -/* - * Copyright (c) [2024] SUSE LLC - * - * All Rights Reserved. - * - * This program is free software; you can redistribute it and/or modify it - * under the terms of version 2 of the GNU General Public License as published - * by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for - * more details. - * - * You should have received a copy of the GNU General Public License along - * with this program; if not, contact SUSE LLC. - * - * To contact SUSE LLC about this file by physical or electronic mail, you may - * find current contact information at www.suse.com. - */ - -import React from "react"; -import { screen } from "@testing-library/react"; -import { installerRender } from "~/test-utils"; -import WiredConnectionsList from "~/components/network/WiredConnectionsList"; -import { - Connection, - ConnectionMethod, - ConnectionState, - ConnectionType, - Device, - DeviceState, -} from "~/types/network"; - -const mockDevice: Device = { - name: "enp1s0", - connection: "Network 1", - type: ConnectionType.ETHERNET, - state: DeviceState.CONNECTED, - addresses: [{ address: "192.168.69.201", prefix: 24 }], - nameservers: ["192.168.69.100"], - gateway4: "192.168.69.4", - gateway6: "192.168.69.6", - method4: ConnectionMethod.AUTO, - method6: ConnectionMethod.AUTO, - macAddress: "AA:11:22:33:44::FF", - routes4: [], - routes6: [], -}; - -let mockConnections: Connection[]; - -jest.mock("~/hooks/model/proposal/network", () => ({ - ...jest.requireActual("~/hooks/model/proposal/network"), - useConnections: () => mockConnections, -})); - -jest.mock("~/hooks/model/system/network", () => ({ - ...jest.requireActual("~/hooks/model/system/network"), - useDevices: () => [mockDevice], -})); - -describe("WiredConnectionsList", () => { - describe("and the connection is persistent", () => { - beforeEach(() => { - mockConnections = [ - new Connection("Newtwork 1", { - method4: ConnectionMethod.AUTO, - method6: ConnectionMethod.AUTO, - state: ConnectionState.activating, - persistent: true, - }), - ]; - }); - - it("does not render any hint", () => { - // @ts-expect-error: you need to specify the aria-label - installerRender(); - expect(screen.queryByText("Configured for installation only")).toBeNull; - }); - }); - - describe("and the connection is not persistent", () => { - beforeEach(() => { - mockConnections = [ - new Connection("Newtwork 1", { - method4: ConnectionMethod.AUTO, - method6: ConnectionMethod.AUTO, - state: ConnectionState.activating, - persistent: false, - }), - ]; - }); - - it("renders an installation only hint", () => { - // @ts-expect-error: you need to specify the aria-label - installerRender(); - screen.getByText("Configured for installation only"); - }); - }); -}); diff --git a/web/src/components/network/WiredConnectionsList.tsx b/web/src/components/network/WiredConnectionsList.tsx deleted file mode 100644 index b420463281..0000000000 --- a/web/src/components/network/WiredConnectionsList.tsx +++ /dev/null @@ -1,106 +0,0 @@ -/* - * 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"; -import { - Content, - DataList, - DataListCell, - DataListItem, - DataListItemCells, - DataListItemRow, - DataListProps, - Flex, -} from "@patternfly/react-core"; -import a11yStyles from "@patternfly/react-styles/css/utilities/Accessibility/accessibility"; -import { Annotation, EmptyState } from "~/components/core"; -import { Connection } from "~/types/network"; -import { useConnections } from "~/hooks/model/proposal/network"; -import { useDevices } from "~/hooks/model/system/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 = useDevices(); - - 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(", ")} - - {!connection.persistent && ( - {_("Configured for installation only")} - )} - - , - ]} - /> - - - ); -}; - -/** - * 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 5cf19da22b..8faec628f1 100644 --- a/web/src/components/network/index.ts +++ b/web/src/components/network/index.ts @@ -22,5 +22,4 @@ export { default as NetworkPage } from "./NetworkPage"; export { default as IpSettingsForm } from "./IpSettingsForm"; -export { default as WifiNetworkPage } from "./WifiNetworkPage"; export { default as WiredConnectionPage } from "./WiredConnectionPage"; diff --git a/web/src/hooks/model/config/network.ts b/web/src/hooks/model/config/network.ts index aa33a867ac..c5030c6600 100644 --- a/web/src/hooks/model/config/network.ts +++ b/web/src/hooks/model/config/network.ts @@ -24,7 +24,6 @@ import { useSuspenseQuery, useMutation, useQueryClient } from "@tanstack/react-q import { Connection, NetworkConfig } from "~/types/network"; import { Network, Proposal } from "~/model/proposal"; import { Config } from "~/model/config"; -import { Config as APIConfig } from "~/model/config/network"; import { patchConfig } from "~/api"; import { configQuery } from "../config"; import { proposalQuery } from "../proposal"; @@ -37,12 +36,17 @@ import { proposalQuery } from "../proposal"; const useConnectionMutation = () => { const queryClient = useQueryClient(); const query = { - mutationFn: (newConnection: Connection) => { - const config: APIConfig = { connections: [newConnection.toApi()] }; - const networkConfig: Config = { network: config }; - return patchConfig(networkConfig); + mutationFn: async (newConnection: Connection) => { + const currentConfig = (await queryClient.fetchQuery(configQuery)) || {}; + const networkConfig = NetworkConfig.fromApi(currentConfig.network || {}); + + networkConfig.addOrUpdateConnection(newConnection); + + const config: Config = { ...currentConfig, network: networkConfig.toApi() }; + return patchConfig(config); }, onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["config"] }); queryClient.invalidateQueries({ queryKey: ["proposal"] }); queryClient.invalidateQueries({ queryKey: ["system"] }); }, @@ -59,6 +63,7 @@ const useConfigMutation = () => { const query = { mutationFn: patchConfig, onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["config"] }); queryClient.invalidateQueries({ queryKey: ["proposal"] }); queryClient.invalidateQueries({ queryKey: ["system"] }); }, diff --git a/web/src/hooks/model/system/network.test.ts b/web/src/hooks/model/system/network.test.ts index 8a9ea33260..95c7ac1f2f 100644 --- a/web/src/hooks/model/system/network.test.ts +++ b/web/src/hooks/model/system/network.test.ts @@ -28,7 +28,7 @@ import { ConnectionType, Device, DeviceState, -} from "~/model/network/types"; +} from "~/types/network"; const createConnection = ( id: string, @@ -49,6 +49,7 @@ const createDevice = (overrides: Partial = {}): Device => ({ state: DeviceState.CONNECTED, addresses: [{ address: "192.168.1.100", prefix: 24 }], nameservers: [], + dnsSearchList: [], gateway4: "192.168.1.1", gateway6: "", method4: ConnectionMethod.AUTO, diff --git a/web/src/hooks/model/system/network.ts b/web/src/hooks/model/system/network.ts index d12bfe31a8..74071b5a35 100644 --- a/web/src/hooks/model/system/network.ts +++ b/web/src/hooks/model/system/network.ts @@ -86,7 +86,9 @@ const useState = (): GeneralState => { const useWifiNetworks = () => { const knownSsids: string[] = []; - const { devices, connections, accessPoints } = useSystem(); + const { devices, connections, accessPoints, state } = useSystem(); + + if (!state.wirelessEnabled) return []; return accessPoints .filter((ap: AccessPoint) => { diff --git a/web/src/model/network/types.ts b/web/src/model/network/types.ts index f900bcd8d0..c9cf5af81e 100644 --- a/web/src/model/network/types.ts +++ b/web/src/model/network/types.ts @@ -189,6 +189,7 @@ class Device { type: ConnectionType; addresses: IPAddress[]; nameservers: string[]; + dnsSearchList: string[]; gateway4: string; gateway6: string; method4: ConnectionMethod; @@ -206,6 +207,7 @@ class Device { return { ...newDevice, nameservers: ipConfig?.nameservers || [], + dnsSearchList: ipConfig?.dnsSearchList || [], addresses: buildAddresses(ipConfig?.addresses), routes4: buildRoutes(ipConfig?.routes4), routes6: buildRoutes(ipConfig?.routes6), @@ -220,6 +222,7 @@ class Device { type IPConfig = { addresses: string[]; nameservers?: string[]; + dnsSearchList?: string[]; gateway4?: string; gateway6?: string; method4: ConnectionMethod; @@ -250,6 +253,7 @@ type APIConnection = { macAddress?: string; addresses?: string[]; nameservers?: string[]; + dnsSearchList?: string[]; gateway4?: string; gateway6?: string; method4: string; @@ -289,11 +293,13 @@ type ConnectionOptions = { macAddress?: string; addresses?: IPAddress[]; nameservers?: string[]; + dnsSearchList?: string[]; gateway4?: string; gateway6?: string; method4?: ConnectionMethod; method6?: ConnectionMethod; wireless?: Wireless; + status?: ConnectionStatus; state?: ConnectionState; persistent?: boolean; }; @@ -306,6 +312,7 @@ class Connection { macAddress?: string; addresses: IPAddress[] = []; nameservers: string[] = []; + dnsSearchList: string[] = []; gateway4?: string = ""; gateway6?: string = ""; method4: ConnectionMethod = ConnectionMethod.AUTO; @@ -326,15 +333,18 @@ class Connection { static fromApi(connection: APIConnection) { const { id, status, interface: iface, ...options } = connection; const nameservers = connection.nameservers || []; + const dnsSearchList = connection.dnsSearchList || []; const addresses = connection.addresses?.map(buildAddress) || []; return new Connection(id, { ...options, + status, // FIXME: try a better approach for methods/gateway and/or typecasting method4: options.method4 as ConnectionMethod, method6: options.method6 as ConnectionMethod, iface, addresses, nameservers, + dnsSearchList, }); } diff --git a/web/src/routes/network.tsx b/web/src/routes/network.tsx index 9e9645ae96..d447425e82 100644 --- a/web/src/routes/network.tsx +++ b/web/src/routes/network.tsx @@ -21,13 +21,11 @@ */ import React from "react"; -import { - NetworkPage, - IpSettingsForm, - WifiNetworkPage, - WiredConnectionPage, -} from "~/components/network"; import BindingSettingsForm from "~/components/network/BindingSettingsForm"; +import IpSettingsForm from "~/components/network/IpSettingsForm"; +import NetworkPage from "~/components/network/NetworkPage"; +import WifiConnectionForm from "~/components/network/WifiConnectionForm"; +import WiredConnectionPage from "~/components/network/WiredConnectionPage"; import { Route } from "~/types/routes"; import { NETWORK as PATHS } from "~/routes/paths"; import { N_ } from "~/i18n"; @@ -44,13 +42,17 @@ const routes = (): Route => ({ path: PATHS.editConnection, element: , }, + { + path: PATHS.newConnection, + element: , + }, { path: PATHS.editBindingSettings, element: , }, { - path: PATHS.wifiNetwork, - element: , + path: PATHS.newWiFiConnection, + element: , }, { path: PATHS.wiredConnection, diff --git a/web/src/routes/paths.ts b/web/src/routes/paths.ts index fca4403430..01c3a2f642 100644 --- a/web/src/routes/paths.ts +++ b/web/src/routes/paths.ts @@ -29,10 +29,12 @@ const L10N = { const NETWORK = { root: "/network", + newConnection: "/network/connections/new", editConnection: "/network/connections/:id/edit", editBindingSettings: "/network/connections/:id/binding/edit", - wifiNetwork: "/network/wifi_networks/:ssid", wiredConnection: "/network/wired_connection/:id", + newWiFiConnection: "/network/wifi_networks/new", + wifiConnection: "/network/wifi_networks/:id", }; const PRODUCT = { diff --git a/web/src/types/network.test.ts b/web/src/types/network.test.ts new file mode 100644 index 0000000000..070b9b34d8 --- /dev/null +++ b/web/src/types/network.test.ts @@ -0,0 +1,53 @@ +/* + * Copyright (c) [2026] 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 { Connection, ConnectionStatus, NetworkConfig, NetworkProposal } from "./network"; + +describe("NetworkConfig", () => { + describe("addOrUpdateConnection", () => { + it("keeps connections with DELETE status in the array", () => { + const config = new NetworkConfig([new Connection("eth0", { status: ConnectionStatus.UP })]); + + const toDelete = new Connection("eth0", { status: ConnectionStatus.DELETE }); + config.addOrUpdateConnection(toDelete); + + expect(config.connections).toHaveLength(1); + expect(config.connections[0].status).toBe(ConnectionStatus.DELETE); + }); + }); +}); + +describe("NetworkProposal", () => { + describe("addOrUpdateConnection", () => { + it("keeps connections with DELETE status in the array", () => { + const proposal = new NetworkProposal([ + new Connection("eth0", { status: ConnectionStatus.UP }), + ]); + + const toDelete = new Connection("eth0", { status: ConnectionStatus.DELETE }); + proposal.addOrUpdateConnection(toDelete); + + expect(proposal.connections).toHaveLength(1); + expect(proposal.connections[0].status).toBe(ConnectionStatus.DELETE); + }); + }); +}); diff --git a/web/src/types/network.ts b/web/src/types/network.ts index ba9c476b9f..a52ab6f5bb 100644 --- a/web/src/types/network.ts +++ b/web/src/types/network.ts @@ -92,7 +92,7 @@ enum DeviceState { enum ConnectionStatus { UP = "up", DOWN = "down", - DELETE = "delete", + DELETE = "removed", } // Current state of the connection. @@ -114,6 +114,8 @@ enum DeviceType { WIRELESS = 2, DUMMY = 3, BOND = 4, + VLAN = 5, + BRIDGE = 6, } enum NetworkState { @@ -189,6 +191,7 @@ class Device { type: ConnectionType; addresses: IPAddress[]; nameservers: string[]; + dnsSearchList: string[]; gateway4: string; gateway6: string; method4: ConnectionMethod; @@ -206,6 +209,7 @@ class Device { return { ...newDevice, nameservers: ipConfig?.nameservers || [], + dnsSearchList: ipConfig?.dnsSearchList || [], addresses: buildAddresses(ipConfig?.addresses), routes4: buildRoutes(ipConfig?.routes4), routes6: buildRoutes(ipConfig?.routes6), @@ -220,6 +224,7 @@ class Device { type IPConfig = { addresses: string[]; nameservers?: string[]; + dnsSearchList?: string[]; gateway4?: string; gateway6?: string; method4: ConnectionMethod; @@ -250,6 +255,7 @@ type APIConnection = { macAddress?: string; addresses?: string[]; nameservers?: string[]; + dnsSearchList?: string[]; gateway4?: string; gateway6?: string; method4: string; @@ -289,11 +295,13 @@ type ConnectionOptions = { macAddress?: string; addresses?: IPAddress[]; nameservers?: string[]; + dnsSearchList?: string[]; gateway4?: string; gateway6?: string; method4?: ConnectionMethod; method6?: ConnectionMethod; wireless?: Wireless; + status?: ConnectionStatus; state?: ConnectionState; persistent?: boolean; }; @@ -306,6 +314,7 @@ class Connection { macAddress?: string; addresses: IPAddress[] = []; nameservers: string[] = []; + dnsSearchList: string[] = []; gateway4?: string = ""; gateway6?: string = ""; method4: ConnectionMethod = ConnectionMethod.AUTO; @@ -326,15 +335,18 @@ class Connection { static fromApi(connection: APIConnection) { const { id, status, interface: iface, ...options } = connection; const nameservers = connection.nameservers || []; + const dnsSearchList = connection.dnsSearchList || []; const addresses = connection.addresses?.map(buildAddress) || []; return new Connection(id, { ...options, + status, // FIXME: try a better approach for methods/gateway and/or typecasting method4: options.method4 as ConnectionMethod, method6: options.method6 as ConnectionMethod, iface, addresses, nameservers, + dnsSearchList, }); } @@ -420,18 +432,23 @@ class NetworkConfig { static fromApi(options: APIConfig) { const { connections, state } = options; - const conns = connections.map((c) => Connection.fromApi(c)); + const conns = (connections || []).map((c) => Connection.fromApi(c)); return new NetworkConfig(conns, state); } addOrUpdateConnection(connection: Connection) { - const connections = this.connections.map((c) => (c.id === connection.id ? connection : c)); - this.connections = connections; + const index = (this.connections || []).findIndex((c) => c.id === connection.id); + + if (index !== -1) { + this.connections![index] = connection; + } else { + this.connections = [...(this.connections || []), connection]; + } } toApi(): APIConfig { - const connections = this.connections.map((c) => c.toApi()); + const connections = this.connections?.map((c) => c.toApi()) || []; return { connections, state: this.state }; } @@ -453,14 +470,19 @@ class NetworkProposal { static fromApi(options: APIProposal) { const { connections, state } = options; - const conns = connections.map((c) => Connection.fromApi(c)); + const conns = (connections || []).map((c) => Connection.fromApi(c)); return new NetworkProposal(conns, state); } addOrUpdateConnection(connection: Connection) { - const connections = this.connections.map((c) => (c.id === connection.id ? connection : c)); - this.connections = connections; + const index = (this.connections || []).findIndex((c) => c.id === connection.id); + + if (index !== -1) { + this.connections[index] = connection; + } else { + this.connections = [...(this.connections || []), connection]; + } } toApi(): APIProposal {