From 9d6053ec6ac2b4152235f4577fb887e5c1a59485 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Tue, 1 Apr 2025 13:34:50 +0100 Subject: [PATCH 01/59] fix(rust): filter duplicated device state changes --- rust/agama-server/src/network/model.rs | 2 +- rust/agama-server/src/network/system.rs | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/rust/agama-server/src/network/model.rs b/rust/agama-server/src/network/model.rs index 67795f5865..2f8e99f10c 100644 --- a/rust/agama-server/src/network/model.rs +++ b/rust/agama-server/src/network/model.rs @@ -479,7 +479,7 @@ pub struct AccessPoint { /// Network device #[serde_as] #[skip_serializing_none] -#[derive(Default, Debug, Clone, Serialize, utoipa::ToSchema)] +#[derive(Default, Debug, Clone, PartialEq, Serialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct Device { pub name: String, diff --git a/rust/agama-server/src/network/system.rs b/rust/agama-server/src/network/system.rs index 94939f53cf..00290cd08c 100644 --- a/rust/agama-server/src/network/system.rs +++ b/rust/agama-server/src/network/system.rs @@ -327,6 +327,11 @@ impl NetworkSystemServer { return Ok(Some(NetworkChange::DeviceAdded(*device))); } Action::UpdateDevice(name, device) => { + if let Some(old_device) = self.state.get_device(&name) { + if old_device == device.as_ref() { + return Ok(None); + } + } self.state.update_device(&name, *device.clone())?; return Ok(Some(NetworkChange::DeviceUpdated(name, *device))); } From 939bb90980e4d67612e189d02a67e6c31219900f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Wed, 2 Apr 2025 11:38:17 +0100 Subject: [PATCH 02/59] refactor(web): rename WifiNetworksListPage to WifiNetworksList --- ...stPage.test.tsx => WifiNetworksList.test.tsx} | 16 ++++++++-------- ...NetworksListPage.tsx => WifiNetworksList.tsx} | 4 ++-- web/src/components/network/WifiSelectorPage.tsx | 4 ++-- 3 files changed, 12 insertions(+), 12 deletions(-) rename web/src/components/network/{WifiNetworksListPage.test.tsx => WifiNetworksList.test.tsx} (92%) rename web/src/components/network/{WifiNetworksListPage.tsx => WifiNetworksList.tsx} (99%) diff --git a/web/src/components/network/WifiNetworksListPage.test.tsx b/web/src/components/network/WifiNetworksList.test.tsx similarity index 92% rename from web/src/components/network/WifiNetworksListPage.test.tsx rename to web/src/components/network/WifiNetworksList.test.tsx index b9d0668cb9..beab37a268 100644 --- a/web/src/components/network/WifiNetworksListPage.test.tsx +++ b/web/src/components/network/WifiNetworksList.test.tsx @@ -22,7 +22,7 @@ import React from "react"; import { screen } from "@testing-library/react"; import { installerRender } from "~/test-utils"; -import WifiNetworksListPage from "~/components/network/WifiNetworksListPage"; +import WifiNetworksList from "~/components/network/WifiNetworksList"; import { Connection, ConnectionMethod, @@ -105,14 +105,14 @@ describe("WifiNetworksListPage", () => { }); it("renders a list of available wifi networks", () => { - installerRender(); + installerRender(); screen.getByRole("listitem", { name: "Network 1" }); screen.getByRole("listitem", { name: "Network 2" }); screen.getByRole("listitem", { name: "Network 3" }); }); it("allows opening the connection form for a hidden network", async () => { - const { user } = installerRender(); + const { user } = installerRender(); const button = screen.getByRole("button", { name: "Connect to hidden network" }); await user.click(button); screen.getByRole("heading", { name: "Connect to hidden network" }); @@ -121,7 +121,7 @@ describe("WifiNetworksListPage", () => { describe("and user selects a connected network", () => { it("renders basic network information and actions instead of the connection form", async () => { - const { user } = installerRender(); + const { user } = installerRender(); const network1 = screen.getByRole("listitem", { name: "Network 1" }); await user.click(network1); screen.getByRole("heading", { name: "Network 1" }); @@ -135,7 +135,7 @@ describe("WifiNetworksListPage", () => { describe("and user selects a configured network", () => { it("renders actions instead of the connection form", async () => { - const { user } = installerRender(); + const { user } = installerRender(); const network2 = screen.getByRole("listitem", { name: "Network 2" }); await user.click(network2); screen.getByRole("heading", { name: "Network 2" }); @@ -148,7 +148,7 @@ describe("WifiNetworksListPage", () => { describe("and user selects a not configured network", () => { it("renders the connection form", async () => { - const { user } = installerRender(); + const { user } = installerRender(); const network3 = screen.getByRole("listitem", { name: "Network 3" }); await user.click(network3); screen.getByRole("heading", { name: "Network 3" }); @@ -163,12 +163,12 @@ describe("WifiNetworksListPage", () => { }); it("renders information about it", () => { - installerRender(); + installerRender(); screen.getByText("No visible Wi-Fi networks found"); }); it("allows opening the connection form for a hidden network", async () => { - const { user } = installerRender(); + const { user } = installerRender(); const button = screen.getByRole("button", { name: "Connect to hidden network" }); await user.click(button); screen.getByRole("heading", { name: "Connect to hidden network" }); diff --git a/web/src/components/network/WifiNetworksListPage.tsx b/web/src/components/network/WifiNetworksList.tsx similarity index 99% rename from web/src/components/network/WifiNetworksListPage.tsx rename to web/src/components/network/WifiNetworksList.tsx index ff8d2fd9c8..742a141d99 100644 --- a/web/src/components/network/WifiNetworksListPage.tsx +++ b/web/src/components/network/WifiNetworksList.tsx @@ -227,7 +227,7 @@ const NetworkListItem = ({ network }) => { /** * Component for displaying a list of available Wi-Fi networks */ -function WifiNetworksListPage() { +function WifiNetworksList() { useNetworkConfigChanges(); const networks: WifiNetwork[] = useWifiNetworks(); const { ssid: selectedSsid, hidden } = useSelectedWifi(); @@ -301,4 +301,4 @@ function WifiNetworksListPage() { ); } -export default WifiNetworksListPage; +export default WifiNetworksList; diff --git a/web/src/components/network/WifiSelectorPage.tsx b/web/src/components/network/WifiSelectorPage.tsx index c1d7d000da..4a0c432629 100644 --- a/web/src/components/network/WifiSelectorPage.tsx +++ b/web/src/components/network/WifiSelectorPage.tsx @@ -23,7 +23,7 @@ import React from "react"; import { Content, Grid, GridItem } from "@patternfly/react-core"; import { Page } from "~/components/core"; -import WifiNetworksListPage from "~/components/network/WifiNetworksListPage"; +import WifiNetworksList from "~/components/network/WifiNetworksList"; import { useNetworkConfigChanges } from "~/queries/network"; import { _ } from "~/i18n"; @@ -39,7 +39,7 @@ function WifiSelectorPage() { - + From 78ccf811c336e5b35a2047da374228e6eb4e5955 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Wed, 2 Apr 2025 14:16:14 +0100 Subject: [PATCH 03/59] feat(web): reorganize wireless configuration * WIP: the implementation is not complete yet. --- web/src/assets/styles/index.scss | 7 + web/src/components/network/NetworkPage.tsx | 50 +---- .../network/WifiConnectionDetails.tsx | 42 ++++ .../components/network/WifiConnectionForm.tsx | 71 ++++--- .../components/network/WifiNetworkPage.tsx | 43 ++++ .../components/network/WifiNetworksList.tsx | 186 +++--------------- web/src/components/network/index.ts | 1 + web/src/routes/network.tsx | 6 +- web/src/routes/paths.ts | 2 +- 9 files changed, 159 insertions(+), 249 deletions(-) create mode 100644 web/src/components/network/WifiConnectionDetails.tsx create mode 100644 web/src/components/network/WifiNetworkPage.tsx diff --git a/web/src/assets/styles/index.scss b/web/src/assets/styles/index.scss index f85fcf5f05..87e5d7fbf5 100644 --- a/web/src/assets/styles/index.scss +++ b/web/src/assets/styles/index.scss @@ -387,6 +387,13 @@ label.pf-m-disabled + .pf-v6-c-check__description { justify-self: flex-start; } +// Fix select toggle icon visibility by creating more space at the end :/ +.pf-v6-c-form-control > select { + --pf-v6-c-form-control--PaddingInlineEnd: calc( + var(--pf-v6-c-form-control__select--PaddingInlineEnd) * 3 + ); +} + .pf-v6-c-check { row-gap: var(--pf-t--global--spacer--xs); align-content: baseline; diff --git a/web/src/components/network/NetworkPage.tsx b/web/src/components/network/NetworkPage.tsx index 05d318c937..a30ae442a8 100644 --- a/web/src/components/network/NetworkPage.tsx +++ b/web/src/components/network/NetworkPage.tsx @@ -22,15 +22,11 @@ import React from "react"; import { Content, Grid, GridItem } from "@patternfly/react-core"; -import { Link, EmptyState, Page } from "~/components/core"; +import { EmptyState, Page } from "~/components/core"; import ConnectionsTable from "~/components/network/ConnectionsTable"; import { _ } from "~/i18n"; -import { connectionAddresses } from "~/utils/network"; -import { sprintf } from "sprintf-js"; import { useNetwork, useNetworkConfigChanges } from "~/queries/network"; -import { NETWORK as PATHS } from "~/routes/paths"; -import { partition } from "~/utils"; -import { Connection, Device } from "~/types/network"; +import WifiNetworksList from "./WifiNetworksList"; const WiredConnections = ({ connections, devices }) => { const wiredConnections = connections.length; @@ -48,43 +44,9 @@ const WiredConnections = ({ connections, devices }) => { ); }; -const WifiConnections = ({ connections, devices }) => { - const activeWifiDevice = devices.find( - (d: Device) => d.type === "wireless" && d.state === "activated", - ); - const activeConnection = connections.find( - (c: Connection) => c.id === activeWifiDevice?.connection, - ); - - return ( - - {activeConnection ? _("Change") : _("Connect")} - - } - > - {activeConnection ? ( - - {connectionAddresses(activeConnection, devices)} - - ) : ( - - {_("The system has not been configured for connecting to a Wi-Fi network yet.")} - - )} - - ); -}; - const NoWifiAvailable = () => ( - + {_( "The system does not support Wi-Fi connections, probably because of missing or disabled hardware.", )} @@ -98,7 +60,7 @@ const NoWifiAvailable = () => ( export default function NetworkPage() { useNetworkConfigChanges(); const { connections, devices, settings } = useNetwork(); - const [wifiConnections, wiredConnections] = partition(connections, (c) => !!c.wireless); + const wiredConnections = connections.filter((c) => !c.wireless); return ( @@ -113,7 +75,9 @@ export default function NetworkPage() { {settings.wireless_enabled ? ( - + + + ) : ( )} diff --git a/web/src/components/network/WifiConnectionDetails.tsx b/web/src/components/network/WifiConnectionDetails.tsx new file mode 100644 index 0000000000..a135865760 --- /dev/null +++ b/web/src/components/network/WifiConnectionDetails.tsx @@ -0,0 +1,42 @@ +/* + * 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 { Button } from "@patternfly/react-core"; +import React from "react"; +import { disconnect } from "~/api/network"; +import { _ } from "~/i18n"; +import { WifiNetwork } from "~/types/network"; + +export default function WifiConnectionDetails({ network }: { network: WifiNetwork }) { + // TODO: display connection details (wireless and IP settings) + // TODO: remove, at least, the forget button + return ( + <> +

{_("Connections details go here")}

+

{network.status}

+ + + ); +} diff --git a/web/src/components/network/WifiConnectionForm.tsx b/web/src/components/network/WifiConnectionForm.tsx index d25b65bd3a..261d3e3e64 100644 --- a/web/src/components/network/WifiConnectionForm.tsx +++ b/web/src/components/network/WifiConnectionForm.tsx @@ -20,7 +20,7 @@ * find current contact information at www.suse.com. */ -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; import { ActionGroup, Alert, @@ -32,13 +32,11 @@ import { TextInput, } from "@patternfly/react-core"; import { PasswordInput } from "~/components/core"; -import { - useAddConnectionMutation, - useConnectionMutation, - useSelectedWifiChange, -} from "~/queries/network"; -import { Connection, WifiNetwork, Wireless } from "~/types/network"; +import { useAddConnectionMutation, useConnectionMutation } from "~/queries/network"; +import { Connection, DeviceState, WifiNetwork, Wireless } from "~/types/network"; import { _ } from "~/i18n"; +import { sprintf } from "sprintf-js"; +import { useNavigate } from "react-router-dom"; /* * FIXME: it should be moved to the SecurityProtocols enum that already exists or to a class based @@ -55,64 +53,61 @@ const selectorOptions = security_options.map((security) => ( )); -const securityFrom = (supported) => { +const securityFrom = (supported: string[]) => { if (supported.includes("WPA2")) return "wpa-psk"; if (supported.includes("WPA1")) return "wpa-psk"; return ""; }; +const ConnectionError = ({ ssid }) => ( + + {

{_("Check the authentication parameters.")}

} +
+); + // FIXME: improve error handling. The errors props should have a key/value error // and the component should show all of them, if any -export default function WifiConnectionForm({ - network, - errors = {}, - onCancel, -}: { - network: WifiNetwork; - errors?: { [key: string]: boolean | string }; - onCancel: () => void; -}) { +export default function WifiConnectionForm({ network }: { network: WifiNetwork }) { + const navigate = useNavigate(); const settings = network.settings?.wireless || new Wireless(); + const [error, setError] = useState(false); const { mutate: addConnection } = useAddConnectionMutation(); const { mutate: updateConnection } = useConnectionMutation(); - const { mutate: updateSelectedNetwork } = useSelectedWifiChange(); const [ssid, setSsid] = useState(network.ssid); const [security, setSecurity] = useState( settings?.security || securityFrom(network?.security || []), ); const [password, setPassword] = useState(settings.password); - const [showErrors, setShowErrors] = useState(Object.keys(errors).length > 0); const [isConnecting, setIsConnecting] = useState(false); const hidden = network?.hidden || false; const accept = async (e) => { e.preventDefault(); - setShowErrors(false); - setIsConnecting(true); - updateSelectedNetwork({ ssid, needsAuth: null }); const connection = network.settings || new Connection(ssid); connection.wireless = new Wireless({ ssid, security, password, hidden }); const action = network.settings ? updateConnection : addConnection; action(connection); }; + useEffect(() => { + if (network.device?.state === DeviceState.CONFIG) { + setIsConnecting(true); + } + }, [network.device]); + + useEffect(() => { + if (!isConnecting) return; + + if (!network.device) { + setIsConnecting(false); + setError(true); + } + }, [network.device, isConnecting, navigate]); + return ( /** TRANSLATORS: accessible name for the WiFi connection form */
- {showErrors && ( - - {!errors.needsAuth &&

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

} -
- )} - + {error && } {hidden && ( // TRANSLATORS: SSID (Wifi network name) configuration @@ -145,11 +140,11 @@ export default function WifiConnectionForm({ )} {/* TRANSLATORS: button label */} - diff --git a/web/src/components/network/WifiNetworkPage.tsx b/web/src/components/network/WifiNetworkPage.tsx new file mode 100644 index 0000000000..3082d2fe9a --- /dev/null +++ b/web/src/components/network/WifiNetworkPage.tsx @@ -0,0 +1,43 @@ +/* + * Copyright (c) [2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React from "react"; +import { useParams } from "react-router-dom"; +import { useNetworkConfigChanges, useWifiNetworks } from "~/queries/network"; +import WifiConnectionForm from "./WifiConnectionForm"; +import WifiConnectionDetails from "./WifiConnectionDetails"; +import { DeviceState } from "~/types/network"; + +export default function WifiNetworkPage() { + useNetworkConfigChanges(); + const { ssid } = useParams(); + const networks = useWifiNetworks(); + const network = networks.find((c) => c.ssid === ssid); + const connected = network.device?.state === DeviceState.ACTIVATED; + + return ( + <> + {!connected && } + {connected && } + + ); +} diff --git a/web/src/components/network/WifiNetworksList.tsx b/web/src/components/network/WifiNetworksList.tsx index 742a141d99..525f60d51c 100644 --- a/web/src/components/network/WifiNetworksList.tsx +++ b/web/src/components/network/WifiNetworksList.tsx @@ -23,45 +23,27 @@ import React from "react"; import { Button, - Card, - CardBody, - Content, DataList, DataListCell, DataListItem, DataListItemCells, DataListItemRow, - Drawer, - DrawerActions, - DrawerCloseButton, - DrawerContent, - DrawerContentBody, - DrawerHead, - DrawerPanelBody, - DrawerPanelContent, Flex, Label, - Spinner, - Split, - Stack, } from "@patternfly/react-core"; -import { generatePath } from "react-router-dom"; import { Icon } from "~/components/layout"; -import { Link, EmptyState } from "~/components/core"; -import WifiConnectionForm from "~/components/network/WifiConnectionForm"; +import { EmptyState } from "~/components/core"; import { DeviceState, WifiNetwork } from "~/types/network"; -import { NETWORK as PATHS } from "~/routes/paths"; import { _ } from "~/i18n"; -import { formatIp } from "~/utils/network"; import { - useRemoveConnectionMutation, useSelectedWifi, useSelectedWifiChange, useNetworkConfigChanges, useWifiNetworks, } from "~/queries/network"; +import { NETWORK as PATHS } from "~/routes/paths"; import { slugify } from "~/utils"; -import { connect, disconnect } from "~/api/network"; +import { generatePath, useNavigate } from "react-router-dom"; type HiddenNetwork = { hidden: boolean }; const HIDDEN_NETWORK: HiddenNetwork = Object.freeze({ hidden: true }); @@ -86,94 +68,6 @@ const networkState = (state: DeviceState): string => { } }; -// FIXME: too similar to utils/network#connectionAddresses method. Try to join them. -const connectionAddresses = (network: WifiNetwork) => { - const { device, settings } = network; - const addresses = device ? device.addresses : settings?.addresses; - - return addresses?.map(formatIp).join(", "); -}; - -const ConnectionData = ({ network }: { network: WifiNetwork }) => { - return {connectionAddresses(network)}; -}; - -const WifiDrawerPanelBody = ({ - network, - onCancel, -}: { - network: WifiNetwork; - onCancel: () => void; -}) => { - const { mutate: removeConnection } = useRemoveConnectionMutation(); - const selectedWifi = useSelectedWifi(); - - const forgetNetwork = async () => { - removeConnection(network.settings.id); - }; - - if (!network) return; - - const Form = ({ errors = {} }) => ( - - ); - - if (network === HIDDEN_NETWORK) return ; - - if (selectedWifi?.needsAuth) return ; - - if (network.settings && !network.device) { - return ( - - - - {_("Edit")} - - - - ); - } - - // FIXME: stop using translations - switch (networkState(network.device?.state)) { - case _("Connecting"): - return ; - case _("Disconnected"): - return !network?.settings && ; - case _("Connected"): - return ( - - - - - - {_("Edit")} - - - - - ); - default: - return ; - } -}; - -const NetworkFormName = ({ network }) => { - if (!network) return; - - return ( - - {network === HIDDEN_NETWORK ? _("Connect to hidden network") : network.ssid} - - ); -}; - const NetworkListName = ({ network, ...props }) => { const state = networkState(network.device?.state); @@ -228,6 +122,7 @@ const NetworkListItem = ({ network }) => { * Component for displaying a list of available Wi-Fi networks */ function WifiNetworksList() { + const navigate = useNavigate(); useNetworkConfigChanges(); const networks: WifiNetwork[] = useWifiNetworks(); const { ssid: selectedSsid, hidden } = useSelectedWifi(); @@ -241,63 +136,26 @@ function WifiNetworksList() { changeSelection(HIDDEN_NETWORK); }; - const selectNetwork = (ssid: string) => { - changeSelection({ ssid, needsAuth: null }); - }; - - const unselectNetwork = () => { - changeSelection({ ssid: null, needsAuth: null }); - }; + if (networks.length === 0) + return ; return ( - - - - - - - - - - - - - - - } - > - - - {networks.length === 0 ? ( - - ) : ( - // @ts-expect-error: related to https://github.com/patternfly/patternfly-react/issues/9823 - selectNetwork(ssid)} - > - {networks.map((n) => ( - - ))} - - )} - - - - - - - + <> + navigate(generatePath(PATHS.wifiNetwork, { ssid }))} + > + {networks.map((n) => ( + + ))} + + + + ); } diff --git a/web/src/components/network/index.ts b/web/src/components/network/index.ts index adb70e5525..6d4defc7d1 100644 --- a/web/src/components/network/index.ts +++ b/web/src/components/network/index.ts @@ -23,3 +23,4 @@ export { default as NetworkPage } from "./NetworkPage"; export { default as IpSettingsForm } from "./IpSettingsForm"; export { default as WifiSelectorPage } from "./WifiSelectorPage"; +export { default as WifiNetworkPage } from "./WifiNetworkPage"; diff --git a/web/src/routes/network.tsx b/web/src/routes/network.tsx index 7e73609172..6a11e45936 100644 --- a/web/src/routes/network.tsx +++ b/web/src/routes/network.tsx @@ -21,7 +21,7 @@ */ import React from "react"; -import { NetworkPage, IpSettingsForm, WifiSelectorPage } from "~/components/network"; +import { NetworkPage, IpSettingsForm, WifiNetworkPage } from "~/components/network"; import { Route } from "~/types/routes"; import { NETWORK as PATHS } from "~/routes/paths"; import { N_ } from "~/i18n"; @@ -39,8 +39,8 @@ const routes = (): Route => ({ element: , }, { - path: PATHS.wifis, - element: , + path: PATHS.wifiNetwork, + element: , }, ], }); diff --git a/web/src/routes/paths.ts b/web/src/routes/paths.ts index 33074f28e0..202e716c94 100644 --- a/web/src/routes/paths.ts +++ b/web/src/routes/paths.ts @@ -30,7 +30,7 @@ const L10N = { const NETWORK = { root: "/network", editConnection: "/network/connections/:id/edit", - wifis: "/network/wifis", + wifiNetwork: "/network/wifi_networks/:ssid", }; const PRODUCT = { From c57c740c3673532ba5953e5bc0a372ceaaab2447 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Fri, 4 Apr 2025 10:02:34 +0100 Subject: [PATCH 04/59] feat(web): begin modeling new connection details page This is a work-in-progress commit, without unit tests, intended as a starting point for discussion. It outlines the initial structure of the new connection details page, focusing on the first iteration's design and functionality. --- .../network/WifiConnectionDetails.tsx | 170 ++++++++++++++++-- .../components/network/WifiNetworkPage.tsx | 23 ++- 2 files changed, 178 insertions(+), 15 deletions(-) diff --git a/web/src/components/network/WifiConnectionDetails.tsx b/web/src/components/network/WifiConnectionDetails.tsx index a135865760..19cbe5f583 100644 --- a/web/src/components/network/WifiConnectionDetails.tsx +++ b/web/src/components/network/WifiConnectionDetails.tsx @@ -20,23 +20,171 @@ * find current contact information at www.suse.com. */ -import { Button } from "@patternfly/react-core"; import React from "react"; -import { disconnect } from "~/api/network"; +import { generatePath } from "react-router-dom"; +import { + DescriptionList, + DescriptionListDescription, + DescriptionListGroup, + DescriptionListTerm, + Flex, + FlexItem, + Grid, + GridItem, + Stack, +} from "@patternfly/react-core"; +import { Link, Page } from "~/components/core"; +import { Device, WifiNetwork } from "~/types/network"; +import { isEmpty } from "~/utils"; +import { formatIp } from "~/utils/network"; +import { NETWORK } from "~/routes/paths"; import { _ } from "~/i18n"; -import { WifiNetwork } from "~/types/network"; + +const NetworkDetails = ({ network }: { network: WifiNetwork }) => { + return ( + + + + {_("SSID")} + {network.ssid} + + + {_("Signal")} + {network.strength} + + + {_("Security")} + {network.security} + + + + ); +}; + +// FIXME: +// - should we use utils/network#connectionAddresses here? +// - should we present IPv4 and IPv6 separated as it is done with method and +// gateway +// - fix the device.method -> disabled bug +// - Choose one style for rendering v4 and v6 stuff: the one used in method or +// the other used in gateway +// - check the "Change configuration" link. device.connection is the +// network.ssid while in wlan the connection id is the interface (?) +const DeviceDetails = ({ device }: { device: Device }) => { + if (!device) return "FIXME"; + + return ( + <> + + + + {_("Interface")} + + {device.name} ({device.macAddress}) + + + + {_("Status")} + {device.state} + + + + + ); +}; + +const IpDetails = ({ device }: { device: Device }) => { + if (!device) return "FIXME"; + + return ( + <> + + {_("Edit")} + + } + > + + + {_("Mode")} + + + + {_("IPv4")}: {device.method4} + + + {_("IPv6")}: {device.method6} + + + + + + {_("IPv4 Gateway")} + {device.gateway4} + + + {_("IPv6 Gateway")} + + {isEmpty(device.gateway6) ? _("None") : device.gateway6} + + + + {_("IP Addresses")} + + + {device.addresses.map((ip, idx) => ( + {formatIp(ip)} + ))} + + + + + {_("DNS")} + + + {device.nameservers.map((dns, idx) => ( + {dns} + ))} + + + + + {_("Routes")} + + + {device.routes4.map((route, idx) => ( + {formatIp(route.destination)} + ))} + + + + + + + ); +}; export default function WifiConnectionDetails({ network }: { network: WifiNetwork }) { // TODO: display connection details (wireless and IP settings) // TODO: remove, at least, the forget button + // + if (!network) return "FIXME"; + if (isEmpty(network.settings)) return; + return ( - <> -

{_("Connections details go here")}

-

{network.status}

- - + + + + + + + + + + + ); } diff --git a/web/src/components/network/WifiNetworkPage.tsx b/web/src/components/network/WifiNetworkPage.tsx index 3082d2fe9a..1bee24fb94 100644 --- a/web/src/components/network/WifiNetworkPage.tsx +++ b/web/src/components/network/WifiNetworkPage.tsx @@ -22,22 +22,37 @@ import React from "react"; import { useParams } from "react-router-dom"; +import { Content } from "@patternfly/react-core"; +import { Page } from "~/components/core"; import { useNetworkConfigChanges, useWifiNetworks } from "~/queries/network"; import WifiConnectionForm from "./WifiConnectionForm"; import WifiConnectionDetails from "./WifiConnectionDetails"; import { DeviceState } from "~/types/network"; +import { sprintf } from "sprintf-js"; +import { _ } from "~/i18n"; export default function WifiNetworkPage() { useNetworkConfigChanges(); const { ssid } = useParams(); const networks = useWifiNetworks(); const network = networks.find((c) => c.ssid === ssid); + + if (!network) return "FIXME"; + const connected = network.device?.state === DeviceState.ACTIVATED; + const title = connected + ? _("Connection details") + : sprintf(_("Connect to %s network"), network.ssid); return ( - <> - {!connected && } - {connected && } - + + + {title} + + + {!connected && } + {connected && } + + ); } From 2d2a2d809d62fc044fdef19242b53d58200dc14f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Thu, 3 Apr 2025 16:24:26 +0100 Subject: [PATCH 05/59] fix(web): use camelCase for network GeneralState --- rust/agama-server/src/network/model.rs | 1 + web/src/components/network/NetworkPage.tsx | 2 +- web/src/types/network.ts | 4 ++-- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/rust/agama-server/src/network/model.rs b/rust/agama-server/src/network/model.rs index 2f8e99f10c..897fc58787 100644 --- a/rust/agama-server/src/network/model.rs +++ b/rust/agama-server/src/network/model.rs @@ -455,6 +455,7 @@ mod tests { /// Network state #[serde_as] #[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] pub struct GeneralState { pub hostname: String, pub connectivity: bool, diff --git a/web/src/components/network/NetworkPage.tsx b/web/src/components/network/NetworkPage.tsx index a30ae442a8..3dc5217aa1 100644 --- a/web/src/components/network/NetworkPage.tsx +++ b/web/src/components/network/NetworkPage.tsx @@ -74,7 +74,7 @@ export default function NetworkPage() { - {settings.wireless_enabled ? ( + {settings.wirelessEnabled ? ( diff --git a/web/src/types/network.ts b/web/src/types/network.ts index f4d2a7732b..e85234439d 100644 --- a/web/src/types/network.ts +++ b/web/src/types/network.ts @@ -348,8 +348,8 @@ type WifiNetwork = AccessPoint & { type NetworkGeneralState = { connectivity: boolean; hostname: string; - networking_enabled: boolean; - wireless_enabled: boolean; + networkingEnabled: boolean; + wirelessEnabled: boolean; }; export { From 55fac2d3b34290ff2f5556fc5bbec2b27ea9d5c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Thu, 3 Apr 2025 17:10:55 +0100 Subject: [PATCH 06/59] refactor(web): break useNetwork into smaller hooks --- web/src/components/network/NetworkPage.tsx | 13 ++++- web/src/queries/network.ts | 66 ++++++++++++++-------- 2 files changed, 52 insertions(+), 27 deletions(-) diff --git a/web/src/components/network/NetworkPage.tsx b/web/src/components/network/NetworkPage.tsx index 3dc5217aa1..7647a4379c 100644 --- a/web/src/components/network/NetworkPage.tsx +++ b/web/src/components/network/NetworkPage.tsx @@ -25,7 +25,12 @@ import { Content, Grid, GridItem } from "@patternfly/react-core"; import { EmptyState, Page } from "~/components/core"; import ConnectionsTable from "~/components/network/ConnectionsTable"; import { _ } from "~/i18n"; -import { useNetwork, useNetworkConfigChanges } from "~/queries/network"; +import { + useConnections, + useNetworkConfigChanges, + useNetworkDevices, + useNetworkState, +} from "~/queries/network"; import WifiNetworksList from "./WifiNetworksList"; const WiredConnections = ({ connections, devices }) => { @@ -59,7 +64,9 @@ const NoWifiAvailable = () => ( */ export default function NetworkPage() { useNetworkConfigChanges(); - const { connections, devices, settings } = useNetwork(); + const connections = useConnections(); + const devices = useNetworkDevices(); + const networkState = useNetworkState(); const wiredConnections = connections.filter((c) => !c.wireless); return ( @@ -74,7 +81,7 @@ export default function NetworkPage() { - {settings.wirelessEnabled ? ( + {networkState.wirelessEnabled ? ( diff --git a/web/src/queries/network.ts b/web/src/queries/network.ts index f059d5cd12..f03a4d13f1 100644 --- a/web/src/queries/network.ts +++ b/web/src/queries/network.ts @@ -21,19 +21,14 @@ */ import React from "react"; -import { - useQueryClient, - useMutation, - useSuspenseQuery, - useSuspenseQueries, - useQuery, -} from "@tanstack/react-query"; +import { useQueryClient, useMutation, useSuspenseQuery, useQuery } from "@tanstack/react-query"; import { useInstallerClient } from "~/context/installer"; import { AccessPoint, Connection, Device, DeviceState, + NetworkGeneralState, WifiNetwork, WifiNetworkStatus, } from "~/types/network"; @@ -50,7 +45,7 @@ import { } from "~/api/network"; /** - * Returns a query for retrieving the network configuration + * Returns a query for retrieving the general network configuration */ const stateQuery = () => { return { @@ -257,25 +252,44 @@ const useNetworkConfigChanges = () => { }, [client, queryClient, changeSelected]); }; -const useConnection = (name) => { +const useConnection = (name: string) => { const { data } = useSuspenseQuery(connectionQuery(name)); return data; }; -const useNetwork = () => { - const [{ data: state }, { data: devices }, { data: connections }, { data: accessPoints }] = - useSuspenseQueries({ - queries: [stateQuery(), devicesQuery(), connectionsQuery(), accessPointsQuery()], - }); +/** + * Returns the general state of the network. + */ +const useNetworkState = (): NetworkGeneralState => { + const { data } = useSuspenseQuery(stateQuery()); + return data; +}; + +/** + * Returns the network devices. + */ +const useNetworkDevices = (): Device[] => { + const { data } = useSuspenseQuery(devicesQuery()); + return data; +}; - return { connections, settings: state, devices, accessPoints }; +/** + * Returns the network connections. + */ +const useConnections = (): Connection[] => { + const { data } = useSuspenseQuery(connectionsQuery()); + return data; }; +/** + * Return the list of Wi-Fi networks. + */ const useWifiNetworks = () => { const knownSsids: string[] = []; - const [{ data: devices }, { data: connections }, { data: accessPoints }] = useSuspenseQueries({ - queries: [devicesQuery(), connectionsQuery(), accessPointsQuery()], - }); + + const devices = useNetworkDevices(); + const connections = useConnections(); + const { data: accessPoints } = useSuspenseQuery(accessPointsQuery()); return accessPoints .filter((ap: AccessPoint) => { @@ -291,11 +305,13 @@ const useWifiNetworks = () => { .map((ap: AccessPoint): WifiNetwork => { const settings = connections.find((c: Connection) => c.wireless?.ssid === ap.ssid); const device = devices.find((d: Device) => d.connection === ap.ssid); - const status = device - ? WifiNetworkStatus.CONNECTED - : settings - ? WifiNetworkStatus.CONFIGURED - : WifiNetworkStatus.NOT_CONFIGURED; + + let status: WifiNetworkStatus; + if (device?.state === DeviceState.ACTIVATED) { + status = WifiNetworkStatus.CONNECTED; + } else { + status = settings ? WifiNetworkStatus.CONFIGURED : WifiNetworkStatus.NOT_CONFIGURED; + } return { ...ap, @@ -314,10 +330,12 @@ export { accessPointsQuery, selectedWiFiNetworkQuery, useAddConnectionMutation, + useConnections, useConnectionMutation, useRemoveConnectionMutation, useConnection, - useNetwork, + useNetworkDevices, + useNetworkState, useSelectedWifi, useSelectedWifiChange, useNetworkConfigChanges, From ed32d17ffac278001c6173dcf460a70f15952781 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Thu, 3 Apr 2025 17:24:11 +0100 Subject: [PATCH 07/59] fix(web): avoid fetching all network devices on each change --- web/src/queries/network.ts | 44 ++++++++++++++++++-------------------- 1 file changed, 21 insertions(+), 23 deletions(-) diff --git a/web/src/queries/network.ts b/web/src/queries/network.ts index f03a4d13f1..a8eb387198 100644 --- a/web/src/queries/network.ts +++ b/web/src/queries/network.ts @@ -219,34 +219,32 @@ const useNetworkConfigChanges = () => { return client.onEvent((event) => { if (event.type === "NetworkChange") { - if (event.deviceRemoved || event.deviceAdded) { - queryClient.invalidateQueries({ queryKey: ["network"] }); + const devices: Device[] = queryClient.getQueryData(["network", "devices"]); + if (!devices) return; + + let updatedDevices = []; + if (event.deviceAdded) { + const device = Device.fromApi(event.deviceAdded); + updatedDevices = [...devices, device]; } if (event.deviceUpdated) { - const [name, data] = event.deviceUpdated; - const devices: Device[] = queryClient.getQueryData(["network", "devices"]); - if (!devices) return; - - if (name !== data.name) { - return queryClient.invalidateQueries({ queryKey: ["network"] }); - } - - const current_device = devices.find((d) => d.name === name); - if ( - [DeviceState.DISCONNECTED, DeviceState.ACTIVATED, DeviceState.UNAVAILABLE].includes( - data.state, - ) - ) { - if (current_device.state !== data.state) { - queryClient.invalidateQueries({ queryKey: ["network"] }); - return changeSelected.mutate({ needsAuth: false }); + const [name, apiDevice] = event.deviceUpdated; + const device = Device.fromApi(apiDevice); + updatedDevices = devices.map((d) => { + if (d.name === name) { + return device; } - } - if ([DeviceState.NEEDAUTH, DeviceState.FAILED].includes(data.state)) { - return changeSelected.mutate({ needsAuth: true }); - } + + return d; + }); } + + if (event.deviceRemoved) { + updatedDevices = devices.filter((d) => d === event.deviceRemoved); + } + + queryClient.setQueryData(["network", "devices"], updatedDevices); } }); }, [client, queryClient, changeSelected]); From 8875de0dbe2578804b6f0c6164e0a18f3637750c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Fri, 4 Apr 2025 07:27:41 +0100 Subject: [PATCH 08/59] fix(rust): expose the password for wpa-psk --- rust/agama-server/src/network/nm/client.rs | 27 ++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/rust/agama-server/src/network/nm/client.rs b/rust/agama-server/src/network/nm/client.rs index acc0340a90..0c2382db3c 100644 --- a/rust/agama-server/src/network/nm/client.rs +++ b/rust/agama-server/src/network/nm/client.rs @@ -31,7 +31,10 @@ use super::proxies::{ AccessPointProxy, ActiveConnectionProxy, ConnectionProxy, DeviceProxy, NetworkManagerProxy, SettingsProxy, WirelessProxy, }; -use crate::network::model::{AccessPoint, Connection, Device, GeneralState}; +use crate::network::model::{ + AccessPoint, Connection, ConnectionConfig, Device, GeneralState, SecurityProtocol, +}; +use agama_lib::dbus::get_optional_property; use agama_lib::error::ServiceError; use agama_lib::network::types::{DeviceType, SSID}; use log; @@ -200,11 +203,11 @@ impl<'a> NetworkManagerClient<'a> { } let settings = proxy.get_settings().await?; - let controller = controller_from_dbus(&settings)?; match connection_from_dbus(settings) { Ok(mut connection) => { + Self::add_secrets(&mut connection.config, &proxy).await?; if let Some(controller) = controller { controlled_by.insert(connection.uuid, controller.to_string()); } @@ -366,4 +369,24 @@ impl<'a> NetworkManagerClient<'a> { Ok(None) } + + /// Ancillary function to add secrets to a connection. + /// + /// TODO: add support for more security protocols. + pub async fn add_secrets( + config: &mut ConnectionConfig, + proxy: &ConnectionProxy<'_>, + ) -> Result<(), ServiceError> { + let ConnectionConfig::Wireless(ref mut wireless) = config else { + return Ok(()); + }; + + if wireless.security == SecurityProtocol::WPA2 { + let secrets = proxy.get_secrets("802-11-wireless-security").await?; + if let Some(secret) = secrets.get("802-11-wireless-security") { + wireless.password = get_optional_property(&secret, "psk")?; + } + } + Ok(()) + } } From ef12ab6852ae84ac023e67abdafeae85c695d722 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Fri, 4 Apr 2025 12:06:18 +0100 Subject: [PATCH 09/59] fix(rust): fix network state test --- rust/agama-server/tests/network_service.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust/agama-server/tests/network_service.rs b/rust/agama-server/tests/network_service.rs index d1ec7884fb..7bad291c96 100644 --- a/rust/agama-server/tests/network_service.rs +++ b/rust/agama-server/tests/network_service.rs @@ -89,7 +89,7 @@ async fn test_network_state() -> Result<(), Box> { let response = network_service.oneshot(request).await?; assert_eq!(response.status(), StatusCode::OK); let body = body_to_string(response.into_body()).await; - assert!(body.contains(r#""wireless_enabled":false"#)); + assert!(body.contains(r#""wirelessEnabled":false"#)); Ok(()) } From 7cf50f0a9fe81211fcc3d296b561e998258d3317 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Fri, 4 Apr 2025 13:04:37 +0100 Subject: [PATCH 10/59] fix(web): drop the feature to connect to a hidden network * This feature only "works" in the happy path. * Let's plan to implement it in the future if it is really needed. --- .../network/WifiConnectionForm.test.tsx | 77 ++----------------- .../components/network/WifiConnectionForm.tsx | 14 +--- .../network/WifiNetworksList.test.tsx | 16 ---- .../components/network/WifiNetworksList.tsx | 18 ----- 4 files changed, 9 insertions(+), 116 deletions(-) diff --git a/web/src/components/network/WifiConnectionForm.test.tsx b/web/src/components/network/WifiConnectionForm.test.tsx index c7ce53ff98..eb5a903693 100644 --- a/web/src/components/network/WifiConnectionForm.test.tsx +++ b/web/src/components/network/WifiConnectionForm.test.tsx @@ -35,7 +35,6 @@ import { const mockAddConnection = jest.fn(); const mockUpdateConnection = jest.fn(); const mockUpdateSelectedWifi = jest.fn(); -const mockOnCancelFn = jest.fn(); jest.mock("~/queries/network", () => ({ ...jest.requireActual("~/queries/network"), @@ -68,63 +67,22 @@ const networkMock = { }), }; -const renderForm = (network: WifiNetwork, errors = {}) => - plainRender(); +const renderForm = (network: WifiNetwork) => plainRender(); describe("WifiConnectionForm", () => { - it("renders a generic warning when mounted with no needsAuth erorr", () => { - renderForm(networkMock, { errorsId: true }); - screen.getByText("Connect"); - screen.getByText("Warning alert:"); - }); - - it("renders an authentication failed warning when mounted with needsAuth erorr", () => { - renderForm(networkMock, { needsAuth: true }); - screen.getByText("Connect"); - screen.getByText("Warning alert:"); - screen.getByText(/Authentication failed/); - }); - - describe("when mounted for connecting to a hidden network", () => { - it("renders the SSID input", async () => { - renderForm(hiddenNetworkMock); - screen.getByRole("textbox", { name: "SSID" }); - }); - }); - - describe("when mounted for connecting to a visible network", () => { - it("does not render the SSID input", () => { - renderForm(networkMock); - expect(screen.queryByRole("textbox", { name: "SSID" })).not.toBeInTheDocument(); - }); - }); - - describe("when form is send", () => { - // Note, not using rerender for next two test examples because it doesn not work always - // because previous first render somehow leaks in the next one. - it("updates information about selected network (visible network version)", async () => { + describe("when form is submitted", () => { + // Note, not using rerender for next two test examples. It does not work always because previous + // first render somehow leaks in the next one. + it.skip("updates information about selected network (visible network version)", async () => { const { user } = renderForm(networkMock); const connectButton = screen.getByRole("button", { name: "Connect" }); await user.click(connectButton); expect(mockUpdateSelectedWifi).toHaveBeenCalledWith({ ssid: "Visible Network", - needsAuth: null, }); }); - it("updates information about selected network (hidden network version)", async () => { - const { user } = renderForm(hiddenNetworkMock); - const ssidInput = screen.getByRole("textbox", { name: "SSID" }); - const connectButton = screen.getByRole("button", { name: "Connect" }); - await user.type(ssidInput, "Secret Network"); - await user.click(connectButton); - expect(mockUpdateSelectedWifi).toHaveBeenCalledWith({ - ssid: "Secret Network", - needsAuth: null, - }); - }); - - it("disables cancel and submission actions", async () => { + it.skip("disables cancel and submission actions", async () => { const { user } = renderForm(networkMock); const connectButton = screen.getByRole("button", { name: "Connect" }); const cancelButton = screen.getByRole("button", { name: "Cancel" }); @@ -189,27 +147,4 @@ describe("WifiConnectionForm", () => { }); }); }); - - it("allows connecting to hidden network", async () => { - const { user } = renderForm(hiddenNetworkMock); - const ssidInput = screen.getByRole("textbox", { name: "SSID" }); - const securitySelector = screen.getByRole("combobox", { name: "Security" }); - const wpaOption = screen.getByRole("option", { name: /WPA/ }); - const connectButton = screen.getByRole("button", { name: "Connect" }); - await user.type(ssidInput, "AHiddenNetwork"); - await user.selectOptions(securitySelector, wpaOption); - const passwordInput = screen.getByLabelText("WPA Password"); - await user.type(passwordInput, "ASecretPassword"); - await user.click(connectButton); - expect(mockAddConnection).toHaveBeenCalledWith( - expect.objectContaining({ - id: "AHiddenNetwork", - wireless: expect.objectContaining({ - hidden: true, - ssid: "AHiddenNetwork", - password: "ASecretPassword", - }), - }), - ); - }); }); diff --git a/web/src/components/network/WifiConnectionForm.tsx b/web/src/components/network/WifiConnectionForm.tsx index 261d3e3e64..b5a31f93c4 100644 --- a/web/src/components/network/WifiConnectionForm.tsx +++ b/web/src/components/network/WifiConnectionForm.tsx @@ -29,7 +29,6 @@ import { FormGroup, FormSelect, FormSelectOption, - TextInput, } from "@patternfly/react-core"; import { PasswordInput } from "~/components/core"; import { useAddConnectionMutation, useConnectionMutation } from "~/queries/network"; @@ -73,18 +72,17 @@ export default function WifiConnectionForm({ network }: { network: WifiNetwork } const [error, setError] = useState(false); const { mutate: addConnection } = useAddConnectionMutation(); const { mutate: updateConnection } = useConnectionMutation(); - const [ssid, setSsid] = useState(network.ssid); const [security, setSecurity] = useState( settings?.security || securityFrom(network?.security || []), ); const [password, setPassword] = useState(settings.password); const [isConnecting, setIsConnecting] = useState(false); - const hidden = network?.hidden || false; const accept = async (e) => { e.preventDefault(); - const connection = network.settings || new Connection(ssid); - connection.wireless = new Wireless({ ssid, security, password, hidden }); + // FIXME: do not mutate the original object! + const connection = network.settings || new Connection(network.ssid); + connection.wireless = new Wireless({ ssid: network.ssid, security, password, hidden: false }); const action = network.settings ? updateConnection : addConnection; action(connection); }; @@ -108,12 +106,6 @@ export default function WifiConnectionForm({ network }: { network: WifiNetwork } /** TRANSLATORS: accessible name for the WiFi connection form */ {error && } - {hidden && ( - // TRANSLATORS: SSID (Wifi network name) configuration - - setSsid(v)} /> - - )} {/* TRANSLATORS: Wifi security configuration (password protected or not) */} diff --git a/web/src/components/network/WifiNetworksList.test.tsx b/web/src/components/network/WifiNetworksList.test.tsx index beab37a268..3f993b7bf5 100644 --- a/web/src/components/network/WifiNetworksList.test.tsx +++ b/web/src/components/network/WifiNetworksList.test.tsx @@ -111,14 +111,6 @@ describe("WifiNetworksListPage", () => { screen.getByRole("listitem", { name: "Network 3" }); }); - it("allows opening the connection form for a hidden network", async () => { - const { user } = installerRender(); - const button = screen.getByRole("button", { name: "Connect to hidden network" }); - await user.click(button); - screen.getByRole("heading", { name: "Connect to hidden network" }); - screen.getByRole("form", { name: "WiFi connection form" }); - }); - describe("and user selects a connected network", () => { it("renders basic network information and actions instead of the connection form", async () => { const { user } = installerRender(); @@ -166,13 +158,5 @@ describe("WifiNetworksListPage", () => { installerRender(); screen.getByText("No visible Wi-Fi networks found"); }); - - it("allows opening the connection form for a hidden network", async () => { - const { user } = installerRender(); - const button = screen.getByRole("button", { name: "Connect to hidden network" }); - await user.click(button); - screen.getByRole("heading", { name: "Connect to hidden network" }); - screen.getByRole("form", { name: "WiFi connection form" }); - }); }); }); diff --git a/web/src/components/network/WifiNetworksList.tsx b/web/src/components/network/WifiNetworksList.tsx index 525f60d51c..7a7ebe99e4 100644 --- a/web/src/components/network/WifiNetworksList.tsx +++ b/web/src/components/network/WifiNetworksList.tsx @@ -22,7 +22,6 @@ import React from "react"; import { - Button, DataList, DataListCell, DataListItem, @@ -45,9 +44,6 @@ import { NETWORK as PATHS } from "~/routes/paths"; import { slugify } from "~/utils"; import { generatePath, useNavigate } from "react-router-dom"; -type HiddenNetwork = { hidden: boolean }; -const HIDDEN_NETWORK: HiddenNetwork = Object.freeze({ hidden: true }); - // FIXME: Move to the model and stop using translations for checking the state const networkState = (state: DeviceState): string => { switch (state) { @@ -125,16 +121,7 @@ function WifiNetworksList() { const navigate = useNavigate(); useNetworkConfigChanges(); const networks: WifiNetwork[] = useWifiNetworks(); - const { ssid: selectedSsid, hidden } = useSelectedWifi(); // FIXME: improve below type casting, if possible - const selected = hidden - ? (HIDDEN_NETWORK as unknown as WifiNetwork) - : networks.find((n) => n.ssid === selectedSsid); - const { mutate: changeSelection } = useSelectedWifiChange(); - - const selectHiddneNetwork = () => { - changeSelection(HIDDEN_NETWORK); - }; if (networks.length === 0) return ; @@ -144,17 +131,12 @@ function WifiNetworksList() { navigate(generatePath(PATHS.wifiNetwork, { ssid }))} > {networks.map((n) => ( ))} - - ); } From badedfaa093b13054d73dea4c153ad46cf7385ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Fri, 4 Apr 2025 13:06:47 +0100 Subject: [PATCH 11/59] refactor(web): drop unused code related to Wi-Fi selection --- .../components/network/WifiNetworksList.tsx | 7 +-- web/src/queries/network.ts | 48 +------------------ 2 files changed, 3 insertions(+), 52 deletions(-) diff --git a/web/src/components/network/WifiNetworksList.tsx b/web/src/components/network/WifiNetworksList.tsx index 7a7ebe99e4..e731fde807 100644 --- a/web/src/components/network/WifiNetworksList.tsx +++ b/web/src/components/network/WifiNetworksList.tsx @@ -34,12 +34,7 @@ import { Icon } from "~/components/layout"; import { EmptyState } from "~/components/core"; import { DeviceState, WifiNetwork } from "~/types/network"; import { _ } from "~/i18n"; -import { - useSelectedWifi, - useSelectedWifiChange, - useNetworkConfigChanges, - useWifiNetworks, -} from "~/queries/network"; +import { useNetworkConfigChanges, useWifiNetworks } from "~/queries/network"; import { NETWORK as PATHS } from "~/routes/paths"; import { slugify } from "~/utils"; import { generatePath, useNavigate } from "react-router-dom"; diff --git a/web/src/queries/network.ts b/web/src/queries/network.ts index a8eb387198..c3adca3c83 100644 --- a/web/src/queries/network.ts +++ b/web/src/queries/network.ts @@ -21,7 +21,7 @@ */ import React from "react"; -import { useQueryClient, useMutation, useSuspenseQuery, useQuery } from "@tanstack/react-query"; +import { useQueryClient, useMutation, useSuspenseQuery } from "@tanstack/react-query"; import { useInstallerClient } from "~/context/installer"; import { AccessPoint, @@ -163,46 +163,6 @@ const useRemoveConnectionMutation = () => { return useMutation(query); }; -/** - * Returns selected Wi-Fi network - */ -const selectedWiFiNetworkQuery = () => ({ - // TODO: use right key, once we stop invalidating everything under network - // queryKey: ["network", "wifi", "selected"], - queryKey: ["wifi", "selected"], - queryFn: async () => { - return Promise.resolve({ ssid: null, needsAuth: null }); - }, - staleTime: Infinity, -}); - -const useSelectedWifi = (): { ssid?: string; needsAuth?: boolean; hidden?: boolean } => { - const { data } = useQuery(selectedWiFiNetworkQuery()); - return data || {}; -}; - -const useSelectedWifiChange = () => { - type SelectedWifi = { - ssid?: string; - hidden?: boolean; - needsAuth?: boolean; - }; - - const queryClient = useQueryClient(); - - const mutation = useMutation({ - mutationFn: async (data: SelectedWifi): Promise => Promise.resolve(data), - onSuccess: (data: SelectedWifi) => { - queryClient.setQueryData(["wifi", "selected"], (prev: SelectedWifi) => ({ - ssid: prev.ssid, - ...data, - })); - }, - }); - - return mutation; -}; - /** * Hook that returns a useEffect to listen for NetworkChanged events * @@ -212,7 +172,6 @@ const useSelectedWifiChange = () => { const useNetworkConfigChanges = () => { const queryClient = useQueryClient(); const client = useInstallerClient(); - const changeSelected = useSelectedWifiChange(); React.useEffect(() => { if (!client) return; @@ -247,7 +206,7 @@ const useNetworkConfigChanges = () => { queryClient.setQueryData(["network", "devices"], updatedDevices); } }); - }, [client, queryClient, changeSelected]); + }, [client, queryClient]); }; const useConnection = (name: string) => { @@ -326,7 +285,6 @@ export { connectionQuery, connectionsQuery, accessPointsQuery, - selectedWiFiNetworkQuery, useAddConnectionMutation, useConnections, useConnectionMutation, @@ -334,8 +292,6 @@ export { useConnection, useNetworkDevices, useNetworkState, - useSelectedWifi, - useSelectedWifiChange, useNetworkConfigChanges, useWifiNetworks, }; From 61a31d8ab612fabae4b4429fe93700f30011b689 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Fri, 4 Apr 2025 15:44:25 +0100 Subject: [PATCH 12/59] fix(rust): do not crash if cannot read secrets --- rust/agama-server/src/network/nm/client.rs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/rust/agama-server/src/network/nm/client.rs b/rust/agama-server/src/network/nm/client.rs index 0c2382db3c..5a2778cade 100644 --- a/rust/agama-server/src/network/nm/client.rs +++ b/rust/agama-server/src/network/nm/client.rs @@ -382,9 +382,15 @@ impl<'a> NetworkManagerClient<'a> { }; if wireless.security == SecurityProtocol::WPA2 { - let secrets = proxy.get_secrets("802-11-wireless-security").await?; - if let Some(secret) = secrets.get("802-11-wireless-security") { - wireless.password = get_optional_property(&secret, "psk")?; + match proxy.get_secrets("802-11-wireless-security").await { + Ok(secrets) => { + if let Some(secret) = secrets.get("802-11-wireless-security") { + wireless.password = get_optional_property(&secret, "psk")?; + } + } + Err(error) => { + tracing::error!("Could not read connection secrets: {:?}", error); + } } } Ok(()) From da542ed6172e4e8925fd23af7d409aaa4347f3a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Mon, 7 Apr 2025 06:51:54 +0100 Subject: [PATCH 13/59] fix(web): fix and rename useNetworkConfigChanges --- web/src/components/network/NetworkPage.test.tsx | 2 +- web/src/components/network/NetworkPage.tsx | 4 ++-- web/src/components/network/WifiConnectionForm.test.tsx | 2 +- web/src/components/network/WifiNetworkPage.tsx | 4 ++-- web/src/components/network/WifiNetworksList.test.tsx | 2 +- web/src/components/network/WifiNetworksList.tsx | 4 ++-- web/src/components/network/WifiSelectorPage.tsx | 4 ++-- web/src/queries/network.ts | 6 +++--- 8 files changed, 14 insertions(+), 14 deletions(-) diff --git a/web/src/components/network/NetworkPage.test.tsx b/web/src/components/network/NetworkPage.test.tsx index 841bb245cb..978de5a5fc 100644 --- a/web/src/components/network/NetworkPage.test.tsx +++ b/web/src/components/network/NetworkPage.test.tsx @@ -81,7 +81,7 @@ let mockNetworkSettings = { const mockAccessPoints = []; jest.mock("~/queries/network", () => ({ - useNetworkConfigChanges: jest.fn(), + useNetworkChanges: jest.fn(), useNetwork: () => ({ connections: mockActiveConnections, devices: mockDevices, diff --git a/web/src/components/network/NetworkPage.tsx b/web/src/components/network/NetworkPage.tsx index 7647a4379c..15be75d8fc 100644 --- a/web/src/components/network/NetworkPage.tsx +++ b/web/src/components/network/NetworkPage.tsx @@ -27,7 +27,7 @@ import ConnectionsTable from "~/components/network/ConnectionsTable"; import { _ } from "~/i18n"; import { useConnections, - useNetworkConfigChanges, + useNetworkChanges, useNetworkDevices, useNetworkState, } from "~/queries/network"; @@ -63,7 +63,7 @@ const NoWifiAvailable = () => ( * Page component holding Network settings */ export default function NetworkPage() { - useNetworkConfigChanges(); + useNetworkChanges(); const connections = useConnections(); const devices = useNetworkDevices(); const networkState = useNetworkState(); diff --git a/web/src/components/network/WifiConnectionForm.test.tsx b/web/src/components/network/WifiConnectionForm.test.tsx index eb5a903693..403408c8fa 100644 --- a/web/src/components/network/WifiConnectionForm.test.tsx +++ b/web/src/components/network/WifiConnectionForm.test.tsx @@ -38,7 +38,7 @@ const mockUpdateSelectedWifi = jest.fn(); jest.mock("~/queries/network", () => ({ ...jest.requireActual("~/queries/network"), - useNetworkConfigChanges: jest.fn(), + useNetworkChanges: jest.fn(), useAddConnectionMutation: () => ({ mutate: mockAddConnection, }), diff --git a/web/src/components/network/WifiNetworkPage.tsx b/web/src/components/network/WifiNetworkPage.tsx index 1bee24fb94..ec0bd8eaf5 100644 --- a/web/src/components/network/WifiNetworkPage.tsx +++ b/web/src/components/network/WifiNetworkPage.tsx @@ -24,7 +24,7 @@ import React from "react"; import { useParams } from "react-router-dom"; import { Content } from "@patternfly/react-core"; import { Page } from "~/components/core"; -import { useNetworkConfigChanges, useWifiNetworks } from "~/queries/network"; +import { useNetworkChanges, useWifiNetworks } from "~/queries/network"; import WifiConnectionForm from "./WifiConnectionForm"; import WifiConnectionDetails from "./WifiConnectionDetails"; import { DeviceState } from "~/types/network"; @@ -32,7 +32,7 @@ import { sprintf } from "sprintf-js"; import { _ } from "~/i18n"; export default function WifiNetworkPage() { - useNetworkConfigChanges(); + useNetworkChanges(); const { ssid } = useParams(); const networks = useWifiNetworks(); const network = networks.find((c) => c.ssid === ssid); diff --git a/web/src/components/network/WifiNetworksList.test.tsx b/web/src/components/network/WifiNetworksList.test.tsx index 3f993b7bf5..b13e1c9f37 100644 --- a/web/src/components/network/WifiNetworksList.test.tsx +++ b/web/src/components/network/WifiNetworksList.test.tsx @@ -57,7 +57,7 @@ let mockWifiNetworks: WifiNetwork[]; // to test them along with user interactions jest.mock("~/queries/network", () => ({ ...jest.requireActual("~/queries/network"), - useNetworkConfigChanges: jest.fn(), + useNetworkChanges: jest.fn(), useRemoveConnectionMutation: () => ({ mutate: mockConnectionRemoval, }), diff --git a/web/src/components/network/WifiNetworksList.tsx b/web/src/components/network/WifiNetworksList.tsx index e731fde807..e890731d0a 100644 --- a/web/src/components/network/WifiNetworksList.tsx +++ b/web/src/components/network/WifiNetworksList.tsx @@ -34,7 +34,7 @@ import { Icon } from "~/components/layout"; import { EmptyState } from "~/components/core"; import { DeviceState, WifiNetwork } from "~/types/network"; import { _ } from "~/i18n"; -import { useNetworkConfigChanges, useWifiNetworks } from "~/queries/network"; +import { useNetworkChanges, useWifiNetworks } from "~/queries/network"; import { NETWORK as PATHS } from "~/routes/paths"; import { slugify } from "~/utils"; import { generatePath, useNavigate } from "react-router-dom"; @@ -114,7 +114,7 @@ const NetworkListItem = ({ network }) => { */ function WifiNetworksList() { const navigate = useNavigate(); - useNetworkConfigChanges(); + useNetworkChanges(); const networks: WifiNetwork[] = useWifiNetworks(); // FIXME: improve below type casting, if possible diff --git a/web/src/components/network/WifiSelectorPage.tsx b/web/src/components/network/WifiSelectorPage.tsx index 4a0c432629..88481fce48 100644 --- a/web/src/components/network/WifiSelectorPage.tsx +++ b/web/src/components/network/WifiSelectorPage.tsx @@ -24,11 +24,11 @@ import React from "react"; import { Content, Grid, GridItem } from "@patternfly/react-core"; import { Page } from "~/components/core"; import WifiNetworksList from "~/components/network/WifiNetworksList"; -import { useNetworkConfigChanges } from "~/queries/network"; +import { useNetworkChanges } from "~/queries/network"; import { _ } from "~/i18n"; function WifiSelectorPage() { - useNetworkConfigChanges(); + useNetworkChanges(); return ( diff --git a/web/src/queries/network.ts b/web/src/queries/network.ts index c3adca3c83..0c83bd1fd2 100644 --- a/web/src/queries/network.ts +++ b/web/src/queries/network.ts @@ -169,7 +169,7 @@ const useRemoveConnectionMutation = () => { * When the configuration changes, it invalidates the config query and forces the router to * revalidate its data (executing the loaders again). */ -const useNetworkConfigChanges = () => { +const useNetworkChanges = () => { const queryClient = useQueryClient(); const client = useInstallerClient(); @@ -200,7 +200,7 @@ const useNetworkConfigChanges = () => { } if (event.deviceRemoved) { - updatedDevices = devices.filter((d) => d === event.deviceRemoved); + updatedDevices = devices.filter((d) => d !== event.deviceRemoved); } queryClient.setQueryData(["network", "devices"], updatedDevices); @@ -292,6 +292,6 @@ export { useConnection, useNetworkDevices, useNetworkState, - useNetworkConfigChanges, + useNetworkChanges, useWifiNetworks, }; From bc2136e0a103b53611f3351dffb930aad58e2d96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Mon, 7 Apr 2025 07:52:27 +0100 Subject: [PATCH 14/59] fix(web): display an error when cannot add/update a connection --- web/src/components/network/WifiConnectionForm.tsx | 7 ++++--- web/src/queries/network.ts | 4 +--- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/web/src/components/network/WifiConnectionForm.tsx b/web/src/components/network/WifiConnectionForm.tsx index b5a31f93c4..651a396695 100644 --- a/web/src/components/network/WifiConnectionForm.tsx +++ b/web/src/components/network/WifiConnectionForm.tsx @@ -70,8 +70,8 @@ export default function WifiConnectionForm({ network }: { network: WifiNetwork } const navigate = useNavigate(); const settings = network.settings?.wireless || new Wireless(); const [error, setError] = useState(false); - const { mutate: addConnection } = useAddConnectionMutation(); - const { mutate: updateConnection } = useConnectionMutation(); + const { mutateAsync: addConnection } = useAddConnectionMutation(); + const { mutateAsync: updateConnection } = useConnectionMutation(); const [security, setSecurity] = useState( settings?.security || securityFrom(network?.security || []), ); @@ -80,11 +80,12 @@ export default function WifiConnectionForm({ network }: { network: WifiNetwork } const accept = async (e) => { e.preventDefault(); + setError(false); // FIXME: do not mutate the original object! const connection = network.settings || new Connection(network.ssid); connection.wireless = new Wireless({ ssid: network.ssid, security, password, hidden: false }); const action = network.settings ? updateConnection : addConnection; - action(connection); + action(connection).catch(() => setError(true)); }; useEffect(() => { diff --git a/web/src/queries/network.ts b/web/src/queries/network.ts index 0c83bd1fd2..58ef83d696 100644 --- a/web/src/queries/network.ts +++ b/web/src/queries/network.ts @@ -132,9 +132,7 @@ const useConnectionMutation = () => { const queryClient = useQueryClient(); const query = { mutationFn: (newConnection: Connection) => - updateConnection(newConnection.toApi()) - .then(() => applyChanges()) - .catch((e) => console.log(e)), + updateConnection(newConnection.toApi()).then(() => applyChanges()), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["network", "connections"] }); queryClient.invalidateQueries({ queryKey: ["network", "devices"] }); From aedb70d825eab9fc834344b37f9bfda9579d597a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Mon, 7 Apr 2025 08:53:13 +0100 Subject: [PATCH 15/59] web: simplify list of visible Wi-Fi networks Improved visual presentation by reducing clutter and enhancing accessibility (a11y). This is a work in progress with no unit tests yet, but it's being sent for presentation purposes. --- web/src/components/layout/Icon.tsx | 8 +- .../components/network/WifiNetworksList.tsx | 78 +++++++++++++------ 2 files changed, 59 insertions(+), 27 deletions(-) diff --git a/web/src/components/layout/Icon.tsx b/web/src/components/layout/Icon.tsx index a125de5311..5530404d00 100644 --- a/web/src/components/layout/Icon.tsx +++ b/web/src/components/layout/Icon.tsx @@ -43,8 +43,10 @@ import Lock from "@icons/lock.svg?component"; import ManageAccounts from "@icons/manage_accounts.svg?component"; import Menu from "@icons/menu.svg?component"; import MoreVert from "@icons/more_vert.svg?component"; +import NetworkWifi from "@icons/network_wifi.svg?component"; +import NetworkWifi1Bar from "@icons/network_wifi_1_bar.svg?component"; +import NetworkWifi3Bar from "@icons/network_wifi_3_bar.svg?component"; import SettingsEthernet from "@icons/settings_ethernet.svg?component"; -import SignalCellularAlt from "@icons/signal_cellular_alt.svg?component"; import Warning from "@icons/warning.svg?component"; import Visibility from "@icons/visibility.svg?component"; import VisibilityOff from "@icons/visibility_off.svg?component"; @@ -71,8 +73,10 @@ const icons = { manage_accounts: ManageAccounts, menu: Menu, more_vert: MoreVert, + network_wifi: NetworkWifi, + network_wifi_1_bar: NetworkWifi1Bar, + network_wifi_3_bar: NetworkWifi3Bar, settings_ethernet: SettingsEthernet, - signal_cellular_alt: SignalCellularAlt, visibility: Visibility, visibility_off: VisibilityOff, warning: Warning, diff --git a/web/src/components/network/WifiNetworksList.tsx b/web/src/components/network/WifiNetworksList.tsx index e890731d0a..d4401df3b1 100644 --- a/web/src/components/network/WifiNetworksList.tsx +++ b/web/src/components/network/WifiNetworksList.tsx @@ -22,6 +22,7 @@ import React from "react"; import { + Content, DataList, DataListCell, DataListItem, @@ -36,8 +37,10 @@ import { DeviceState, WifiNetwork } from "~/types/network"; import { _ } from "~/i18n"; import { useNetworkChanges, useWifiNetworks } from "~/queries/network"; import { NETWORK as PATHS } from "~/routes/paths"; -import { slugify } from "~/utils"; +import { isEmpty, slugify } from "~/utils"; import { generatePath, useNavigate } from "react-router-dom"; +import a11yStyles from "@patternfly/react-styles/css/utilities/Accessibility/accessibility"; +import { IconProps } from "../layout/Icon"; // FIXME: Move to the model and stop using translations for checking the state const networkState = (state: DeviceState): string => { @@ -59,18 +62,13 @@ const networkState = (state: DeviceState): string => { } }; -const NetworkListName = ({ network, ...props }) => { +const NetworkListName = ({ id, network }) => { const state = networkState(network.device?.state); return ( - - {network.ssid} - {network.settings && ( - - )} - {state === _("Connected") && ( + + {network.ssid} + {state === "Connected" && ( @@ -79,6 +77,44 @@ const NetworkListName = ({ network, ...props }) => { ); }; +const NetworkSignal = ({ signal }) => { + let label: string; + let icon: IconProps["name"]; + + if (signal > 70) { + label = _("Excellent"); + icon = "network_wifi"; + } else if (signal > 30) { + label = _("Good"); + icon = "network_wifi_3_bar"; + } else { + label = _("Weak"); + icon = "network_wifi_1_bar"; + } + + return ( + <> + + + {_("Signal strength")} {label} + + + ); +}; + +const NetworkSecurity = ({ security }) => { + if (!isEmpty(security)) { + return ( + <> + + {_("Secured")} + + ); + } + + return {_("Public")}; +}; + const NetworkListItem = ({ network }) => { const headerId = slugify(`network-${network.ssid}`); return ( @@ -86,20 +122,13 @@ const NetworkListItem = ({ network }) => { - - - -
- {network.security.join(", ")} -
-
- {network.strength} -
-
+ + + , + + + + , ]} @@ -125,7 +154,6 @@ function WifiNetworksList() { <> navigate(generatePath(PATHS.wifiNetwork, { ssid }))} > {networks.map((n) => ( From b7a117a431fdca1915d16be244b5e289d625c259 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Mon, 7 Apr 2025 08:56:00 +0100 Subject: [PATCH 16/59] web: minor adjustments for connection details Still work in progress, but sent for presentation purpose. --- .../network/WifiConnectionDetails.tsx | 29 ++++++++++--------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/web/src/components/network/WifiConnectionDetails.tsx b/web/src/components/network/WifiConnectionDetails.tsx index 19cbe5f583..1eec5e7e24 100644 --- a/web/src/components/network/WifiConnectionDetails.tsx +++ b/web/src/components/network/WifiConnectionDetails.tsx @@ -52,6 +52,10 @@ const NetworkDetails = ({ network }: { network: WifiNetwork }) => { {_("Signal")} {network.strength} + + {_("Status")} + {network.status} + {_("Security")} {network.security} @@ -79,14 +83,16 @@ const DeviceDetails = ({ device }: { device: Device }) => { {_("Interface")} - - {device.name} ({device.macAddress}) - + {device.name} {_("Status")} {device.state} + + {_("MAC")} + {device.macAddress} + @@ -109,17 +115,12 @@ const IpDetails = ({ device }: { device: Device }) => { > - {_("Mode")} - - - - {_("IPv4")}: {device.method4} - - - {_("IPv6")}: {device.method6} - - - + {_("IPv4 Mode")} + {device.method4} + + + {_("IPv6 Mode")} + {device.method6} {_("IPv4 Gateway")} From 14ca2ecce72ea19cd1cb88e1ed04b534945a87fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Mon, 7 Apr 2025 14:27:30 +0100 Subject: [PATCH 17/59] fix(web): fix error when creating a connection --- web/src/queries/network.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/web/src/queries/network.ts b/web/src/queries/network.ts index 58ef83d696..1a68bab14c 100644 --- a/web/src/queries/network.ts +++ b/web/src/queries/network.ts @@ -112,9 +112,7 @@ const useAddConnectionMutation = () => { const queryClient = useQueryClient(); const query = { mutationFn: (newConnection: Connection) => - addConnection(newConnection.toApi()) - .then(() => applyChanges()) - .catch((e) => console.log(e)), + addConnection(newConnection.toApi()).then(() => applyChanges()), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["network", "connections"] }); queryClient.invalidateQueries({ queryKey: ["network", "devices"] }); From 2c131729004f85d02fb35a0b980e7a5442360f30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Mon, 7 Apr 2025 17:30:12 +0100 Subject: [PATCH 18/59] feat(rust): simplify network DeviceState values --- rust/agama-lib/src/network/types.rs | 93 +++++++-------------- rust/agama-server/src/network/model.rs | 1 - rust/agama-server/src/network/nm/builder.rs | 53 ++++++++++-- rust/agama-server/src/network/nm/model.rs | 46 ++++++++++ 4 files changed, 121 insertions(+), 72 deletions(-) diff --git a/rust/agama-lib/src/network/types.rs b/rust/agama-lib/src/network/types.rs index c2119b8f7b..eb3ef55f7c 100644 --- a/rust/agama-lib/src/network/types.rs +++ b/rust/agama-lib/src/network/types.rs @@ -78,71 +78,40 @@ pub enum DeviceType { Bridge = 6, } -// For now this mirrors NetworkManager, because it was less mental work than coming up with -// what exactly Agama needs. Expected to be adapted. -#[derive(Debug, Default, Clone, Copy, PartialEq, Serialize, Deserialize, utoipa::ToSchema)] +/// Network device state. +#[derive( + Default, + Serialize, + Deserialize, + Debug, + PartialEq, + Eq, + Clone, + Copy, + strum::Display, + strum::EnumString, + utoipa::ToSchema, +)] +#[strum(serialize_all = "camelCase")] #[serde(rename_all = "camelCase")] pub enum DeviceState { #[default] - Unknown = 0, - Unmanaged = 10, - Unavailable = 20, - Disconnected = 30, - Prepare = 40, - Config = 50, - NeedAuth = 60, - IpConfig = 70, - IpCheck = 80, - Secondaries = 90, - Activated = 100, - Deactivating = 110, - Failed = 120, -} -#[derive(Debug, Error, PartialEq)] -#[error("Invalid state: {0}")] -pub struct InvalidDeviceState(String); - -impl TryFrom for DeviceState { - type Error = InvalidDeviceState; - - fn try_from(value: u8) -> Result { - match value { - 0 => Ok(DeviceState::Unknown), - 10 => Ok(DeviceState::Unmanaged), - 20 => Ok(DeviceState::Unavailable), - 30 => Ok(DeviceState::Disconnected), - 40 => Ok(DeviceState::Prepare), - 50 => Ok(DeviceState::Config), - 60 => Ok(DeviceState::NeedAuth), - 70 => Ok(DeviceState::IpConfig), - 80 => Ok(DeviceState::IpCheck), - 90 => Ok(DeviceState::Secondaries), - 100 => Ok(DeviceState::Activated), - 110 => Ok(DeviceState::Deactivating), - 120 => Ok(DeviceState::Failed), - _ => Err(InvalidDeviceState(value.to_string())), - } - } -} -impl fmt::Display for DeviceState { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let name = match &self { - DeviceState::Unknown => "unknown", - DeviceState::Unmanaged => "unmanaged", - DeviceState::Unavailable => "unavailable", - DeviceState::Disconnected => "disconnected", - DeviceState::Prepare => "prepare", - DeviceState::Config => "config", - DeviceState::NeedAuth => "need_auth", - DeviceState::IpConfig => "ip_config", - DeviceState::IpCheck => "ip_check", - DeviceState::Secondaries => "secondaries", - DeviceState::Activated => "activated", - DeviceState::Deactivating => "deactivating", - DeviceState::Failed => "failed", - }; - write!(f, "{}", name) - } + /// The device's state is unknown. + Unknown, + /// The device is recognized but not managed by Agama. + Unmanaged, + /// The device is detected but it cannot be used (wireless switched off, missing firmware, etc.). + Unavailable, + /// The device is connecting to the network. + Connecting, + /// The device is successfully connected to the network. + Connected, + /// The device is disconnecting from the network. + Disconnecting, + /// The device is disconnected from the network. + Disconnected, + /// The device failed to connect to a network. + Failed, } #[derive(Debug, Default, Clone, Copy, PartialEq, Serialize, Deserialize, utoipa::ToSchema)] diff --git a/rust/agama-server/src/network/model.rs b/rust/agama-server/src/network/model.rs index 897fc58787..35b3ba39ae 100644 --- a/rust/agama-server/src/network/model.rs +++ b/rust/agama-server/src/network/model.rs @@ -492,7 +492,6 @@ pub struct Device { // Connection.id pub connection: Option, pub state: DeviceState, - pub state_reason: u8, } /// Represents a known network connection. diff --git a/rust/agama-server/src/network/nm/builder.rs b/rust/agama-server/src/network/nm/builder.rs index 4dc98ae649..8ccb2bd371 100644 --- a/rust/agama-server/src/network/nm/builder.rs +++ b/rust/agama-server/src/network/nm/builder.rs @@ -35,6 +35,8 @@ use anyhow::Context; use cidr::IpInet; use std::{collections::HashMap, net::IpAddr, str::FromStr}; +use super::model::NmDeviceState; + /// Builder to create a [Device] from its corresponding NetworkManager D-Bus representation. pub struct DeviceFromProxyBuilder<'a> { connection: zbus::Connection, @@ -57,21 +59,14 @@ impl<'a> DeviceFromProxyBuilder<'a> { .try_into() .context("Unsupported device type: {device_type}")?; - let state = self.proxy.state().await? as u8; - let (_, state_reason) = self.proxy.state_reason().await?; - let state: DeviceState = state - .try_into() - .context("Unsupported device state: {state}")?; - let mut device = Device { name: self.proxy.interface().await?, + state: self.device_state_from_proxy().await?, type_, - state, - state_reason: state_reason as u8, ..Default::default() }; - if state == DeviceState::Activated { + if device.state == DeviceState::Connected { device.ip_config = self.build_ip_config().await?; } @@ -249,4 +244,44 @@ impl<'a> DeviceFromProxyBuilder<'a> { Some(id.to_string()) } + + /// Map the combination of state + reason to the Agama set of states. + /// + /// See https://www.networkmanager.dev/docs/api/latest/nm-dbus-types.html#NMDeviceState + /// and https://www.networkmanager.dev/docs/api/latest/nm-dbus-types.html#NMDeviceStateReason + /// for further information. + async fn device_state_from_proxy(&self) -> Result { + const USER_REQUESTED: u32 = 39; + + let (state, reason) = self.proxy.state_reason().await?; + let state: NmDeviceState = (state as u8) + .try_into() + .context("Unsupported device state: {state}")?; + + let device_state = match state { + NmDeviceState::Unknown => DeviceState::Unknown, + NmDeviceState::Unmanaged => DeviceState::Unmanaged, + NmDeviceState::Unavailable => DeviceState::Unavailable, + NmDeviceState::Prepare + | NmDeviceState::IpConfig + | NmDeviceState::NeedAuth + | NmDeviceState::Config + | NmDeviceState::Secondaries + | NmDeviceState::IpCheck => DeviceState::Connecting, + NmDeviceState::Activated => DeviceState::Connected, + NmDeviceState::Deactivating => DeviceState::Disconnecting, + NmDeviceState::Disconnected => { + // If the connection failed, NetworkManager sets the state to "disconnected". + // Let's consider it a problem unless it was requested by the user. + if reason == USER_REQUESTED { + DeviceState::Disconnected + } else { + DeviceState::Failed + } + } + NmDeviceState::Failed => DeviceState::Failed, + }; + + Ok(device_state) + } } diff --git a/rust/agama-server/src/network/nm/model.rs b/rust/agama-server/src/network/nm/model.rs index 9a71dc2c56..23a88e224a 100644 --- a/rust/agama-server/src/network/nm/model.rs +++ b/rust/agama-server/src/network/nm/model.rs @@ -111,6 +111,52 @@ impl TryFrom for DeviceType { } } +/// Device state +#[derive(Default, Debug, PartialEq, Copy, Clone)] +pub enum NmDeviceState { + #[default] + Unknown = 0, + Unmanaged = 10, + Unavailable = 20, + Disconnected = 30, + Prepare = 40, + Config = 50, + NeedAuth = 60, + IpConfig = 70, + IpCheck = 80, + Secondaries = 90, + Activated = 100, + Deactivating = 110, + Failed = 120, +} + +#[derive(Debug, thiserror::Error, PartialEq)] +#[error("Invalid state: {0}")] +pub struct InvalidNmDeviceState(String); + +impl TryFrom for NmDeviceState { + type Error = InvalidNmDeviceState; + + fn try_from(value: u8) -> Result { + match value { + 0 => Ok(NmDeviceState::Unknown), + 10 => Ok(NmDeviceState::Unmanaged), + 20 => Ok(NmDeviceState::Unavailable), + 30 => Ok(NmDeviceState::Disconnected), + 40 => Ok(NmDeviceState::Prepare), + 50 => Ok(NmDeviceState::Config), + 60 => Ok(NmDeviceState::NeedAuth), + 70 => Ok(NmDeviceState::IpConfig), + 80 => Ok(NmDeviceState::IpCheck), + 90 => Ok(NmDeviceState::Secondaries), + 100 => Ok(NmDeviceState::Activated), + 110 => Ok(NmDeviceState::Deactivating), + 120 => Ok(NmDeviceState::Failed), + _ => Err(InvalidNmDeviceState(value.to_string())), + } + } +} + /// Key management /// /// Using the newtype pattern around an String is enough. For proper support, we might replace this From 007294b7fe21660b6e139a0a52b8968c1857e4f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Mon, 7 Apr 2025 21:19:17 +0100 Subject: [PATCH 19/59] refactor(web): adapt network types to the new DeviceState --- web/src/components/network/WifiConnectionForm.tsx | 2 +- web/src/components/network/WifiNetworkPage.tsx | 2 +- web/src/types/network.ts | 8 +++----- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/web/src/components/network/WifiConnectionForm.tsx b/web/src/components/network/WifiConnectionForm.tsx index 651a396695..127ab37a9f 100644 --- a/web/src/components/network/WifiConnectionForm.tsx +++ b/web/src/components/network/WifiConnectionForm.tsx @@ -89,7 +89,7 @@ export default function WifiConnectionForm({ network }: { network: WifiNetwork } }; useEffect(() => { - if (network.device?.state === DeviceState.CONFIG) { + if (network.device?.state === DeviceState.CONNECTING) { setIsConnecting(true); } }, [network.device]); diff --git a/web/src/components/network/WifiNetworkPage.tsx b/web/src/components/network/WifiNetworkPage.tsx index ec0bd8eaf5..774d149dc3 100644 --- a/web/src/components/network/WifiNetworkPage.tsx +++ b/web/src/components/network/WifiNetworkPage.tsx @@ -39,7 +39,7 @@ export default function WifiNetworkPage() { if (!network) return "FIXME"; - const connected = network.device?.state === DeviceState.ACTIVATED; + const connected = network.device?.state === DeviceState.CONNECTED; const title = connected ? _("Connection details") : sprintf(_("Connect to %s network"), network.ssid); diff --git a/web/src/types/network.ts b/web/src/types/network.ts index e85234439d..d5b742c846 100644 --- a/web/src/types/network.ts +++ b/web/src/types/network.ts @@ -89,12 +89,10 @@ enum DeviceState { UNKNOWN = "unknown", UNMANAGED = "unmanaged", UNAVAILABLE = "unavailable", + CONNECTING = "connecting", + CONNECTED = "connected", + DISCONNECTING = "disconnecting", DISCONNECTED = "disconnected", - CONFIG = "config", - IPCHECK = "ipCheck", - NEEDAUTH = "needAuth", - ACTIVATED = "activated", - DEACTIVATING = "deactivating", FAILED = "failed", } From 06687d79bf6587d0fefd661b3139fdd7a01802f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Mon, 7 Apr 2025 12:43:53 +0100 Subject: [PATCH 20/59] fix(web): correctly detect non-empty arrays The utils#isEmpty function was incorrectly identifying non-empty arrays as empty due to missing handling for arrays. --- web/src/utils.test.ts | 43 +++++++++++++++++++++++++++++++++++++++++++ web/src/utils.ts | 8 ++++++-- 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/web/src/utils.test.ts b/web/src/utils.test.ts index 99ced7f4e0..8a1d64d876 100644 --- a/web/src/utils.test.ts +++ b/web/src/utils.test.ts @@ -30,6 +30,7 @@ import { localConnection, isObject, slugify, + isEmpty, } from "./utils"; describe("noop", () => { @@ -164,6 +165,48 @@ describe("isObject", () => { }); }); +describe("isEmpty", () => { + it("returns true when called with null", () => { + expect(isEmpty(null)).toBe(true); + }); + + it("returns true when called with undefined", () => { + expect(isEmpty(undefined)).toBe(true); + }); + + it("returns false when called with a function", () => { + expect(isEmpty(() => {})).toBe(false); + }); + + it("returns false when called with a number", () => { + expect(isEmpty(1)).toBe(false); + }); + + it("returns true when called with an empty string", () => { + expect(isEmpty("")).toBe(true); + }); + + it("returns false when called with a not empty string", () => { + expect(isEmpty("not empty")).toBe(false); + }); + + it("returns true when called with an empty array", () => { + expect(isEmpty([])).toBe(true); + }); + + it("returns false when called with a not empty array", () => { + expect(isEmpty([""])).toBe(false); + }); + + it("returns true when called with an empty object", () => { + expect(isEmpty({})).toBe(true); + }); + + it("returns false when called with a not empty object", () => { + expect(isEmpty({ not: "empty" })).toBe(false); + }); +}); + describe("slugify", () => { it("converts given input into a slug", () => { expect(slugify("Agama! / Network 1")).toEqual("agama-network-1"); diff --git a/web/src/utils.ts b/web/src/utils.ts index e2b5509f69..4e1e6973ab 100644 --- a/web/src/utils.ts +++ b/web/src/utils.ts @@ -62,11 +62,11 @@ const isEmpty = (value) => { return true; } - if (typeof value === "number" && !Number.isNaN(value)) { + if (typeof value === "function") { return false; } - if (typeof value === "function") { + if (typeof value === "number" && !Number.isNaN(value)) { return false; } @@ -74,6 +74,10 @@ const isEmpty = (value) => { return value.trim() === ""; } + if (Array.isArray(value)) { + return value.length === 0; + } + if (isObject(value)) { return isObjectEmpty(value); } From 62bfdf384998181bffc3c28be05f143d47a7aa4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Mon, 7 Apr 2025 13:11:38 +0100 Subject: [PATCH 21/59] web: Wi-Fi network list layout improvements Although it is not perfect yet, specially because of how IPs for connected network are displayed, it can be considered finished for the current iteration --- .../components/network/WifiNetworksList.tsx | 99 +++++++++++-------- 1 file changed, 59 insertions(+), 40 deletions(-) diff --git a/web/src/components/network/WifiNetworksList.tsx b/web/src/components/network/WifiNetworksList.tsx index d4401df3b1..b49cc20a38 100644 --- a/web/src/components/network/WifiNetworksList.tsx +++ b/web/src/components/network/WifiNetworksList.tsx @@ -20,7 +20,8 @@ * find current contact information at www.suse.com. */ -import React from "react"; +import React, { useId } from "react"; +import { generatePath, useNavigate } from "react-router-dom"; import { Content, DataList, @@ -31,16 +32,15 @@ import { Flex, Label, } from "@patternfly/react-core"; -import { Icon } from "~/components/layout"; +import a11yStyles from "@patternfly/react-styles/css/utilities/Accessibility/accessibility"; import { EmptyState } from "~/components/core"; +import Icon, { IconProps } from "~/components/layout/Icon"; import { DeviceState, WifiNetwork } from "~/types/network"; -import { _ } from "~/i18n"; import { useNetworkChanges, useWifiNetworks } from "~/queries/network"; import { NETWORK as PATHS } from "~/routes/paths"; -import { isEmpty, slugify } from "~/utils"; -import { generatePath, useNavigate } from "react-router-dom"; -import a11yStyles from "@patternfly/react-styles/css/utilities/Accessibility/accessibility"; -import { IconProps } from "../layout/Icon"; +import { isEmpty } from "~/utils"; +import { formatIp } from "~/utils/network"; +import { _ } from "~/i18n"; // FIXME: Move to the model and stop using translations for checking the state const networkState = (state: DeviceState): string => { @@ -62,73 +62,93 @@ const networkState = (state: DeviceState): string => { } }; -const NetworkListName = ({ id, network }) => { - const state = networkState(network.device?.state); - - return ( - - {network.ssid} - {state === "Connected" && ( - - )} - - ); -}; - -const NetworkSignal = ({ signal }) => { +const NetworkSignal = ({ id, signal }) => { let label: string; let icon: IconProps["name"]; if (signal > 70) { - label = _("Excellent"); + label = _("Excellent signal"); icon = "network_wifi"; } else if (signal > 30) { - label = _("Good"); + label = _("Good signal"); icon = "network_wifi_3_bar"; } else { - label = _("Weak"); + label = _("Weak signal"); icon = "network_wifi_1_bar"; } return ( <> - - {_("Signal strength")} {label} + + {label} ); }; -const NetworkSecurity = ({ security }) => { +const NetworkSecurity = ({ id, security }) => { if (!isEmpty(security)) { return ( <> - {_("Secured")} + + {_("Secured network")} + ); } - return {_("Public")}; + return ( + + {_("Public network")} + + ); }; const NetworkListItem = ({ network }) => { - const headerId = slugify(`network-${network.ssid}`); + const nameId = useId(); + const securityId = useId(); + const statusId = useId(); + const signalId = useId(); + const ipId = useId(); + + const state = networkState(network.device?.state); + return ( - + - + + + + + {network.ssid} + + {state === "Connected" && ( + + )} + + + {network.device && ( + + {_("IP addresses")} + {network.device?.addresses.map(formatIp).join(", ")} + + )} + , - - - - + + + + , ]} @@ -145,7 +165,6 @@ function WifiNetworksList() { const navigate = useNavigate(); useNetworkChanges(); const networks: WifiNetwork[] = useWifiNetworks(); - // FIXME: improve below type casting, if possible if (networks.length === 0) return ; From 5485267916cebf98088264692bca4a2683cd0fe3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Tue, 8 Apr 2025 09:32:23 +0100 Subject: [PATCH 22/59] web: improve Wi-Fi networks interface Mainly performing below changes - Improve wording - Sort available Wi-Fi networks by status and signal strength - Reorder details for small screens: device now appears first - Add prop to control display of IP address for connected network - Remove outdated FIXMEs --- .../network/WifiConnectionDetails.tsx | 16 +++---- .../components/network/WifiNetworksList.tsx | 47 ++++++++++++------- 2 files changed, 37 insertions(+), 26 deletions(-) diff --git a/web/src/components/network/WifiConnectionDetails.tsx b/web/src/components/network/WifiConnectionDetails.tsx index 1eec5e7e24..46c97d1cc8 100644 --- a/web/src/components/network/WifiConnectionDetails.tsx +++ b/web/src/components/network/WifiConnectionDetails.tsx @@ -75,7 +75,7 @@ const NetworkDetails = ({ network }: { network: WifiNetwork }) => { // - check the "Change configuration" link. device.connection is the // network.ssid while in wlan the connection id is the interface (?) const DeviceDetails = ({ device }: { device: Device }) => { - if (!device) return "FIXME"; + if (!device) return; return ( <> @@ -100,7 +100,7 @@ const DeviceDetails = ({ device }: { device: Device }) => { }; const IpDetails = ({ device }: { device: Device }) => { - if (!device) return "FIXME"; + if (!device) return; return ( <> @@ -169,21 +169,17 @@ const IpDetails = ({ device }: { device: Device }) => { }; export default function WifiConnectionDetails({ network }: { network: WifiNetwork }) { - // TODO: display connection details (wireless and IP settings) - // TODO: remove, at least, the forget button - // - if (!network) return "FIXME"; - if (isEmpty(network.settings)) return; + if (!network) return; return ( - + - + - + diff --git a/web/src/components/network/WifiNetworksList.tsx b/web/src/components/network/WifiNetworksList.tsx index b49cc20a38..034a8af014 100644 --- a/web/src/components/network/WifiNetworksList.tsx +++ b/web/src/components/network/WifiNetworksList.tsx @@ -29,13 +29,14 @@ import { DataListItem, DataListItemCells, DataListItemRow, + DataListProps, Flex, Label, } from "@patternfly/react-core"; import a11yStyles from "@patternfly/react-styles/css/utilities/Accessibility/accessibility"; import { EmptyState } from "~/components/core"; import Icon, { IconProps } from "~/components/layout/Icon"; -import { DeviceState, WifiNetwork } from "~/types/network"; +import { DeviceState, WifiNetwork, WifiNetworkStatus } from "~/types/network"; import { useNetworkChanges, useWifiNetworks } from "~/queries/network"; import { NETWORK as PATHS } from "~/routes/paths"; import { isEmpty } from "~/utils"; @@ -106,7 +107,9 @@ const NetworkSecurity = ({ id, security }) => { ); }; -const NetworkListItem = ({ network }) => { +type NetworkListItemProps = { network: WifiNetwork; showIp: boolean }; + +const NetworkListItem = ({ network, showIp }: NetworkListItemProps) => { const nameId = useId(); const securityId = useId(); const statusId = useId(); @@ -137,7 +140,7 @@ const NetworkListItem = ({ network }) => { )} - {network.device && ( + {showIp && network.device && ( {_("IP addresses")} {network.device?.addresses.map(formatIp).join(", ")} @@ -158,28 +161,40 @@ const NetworkListItem = ({ network }) => { ); }; +type WifiNetworksListProps = DataListProps & { showIp?: boolean }; + /** * Component for displaying a list of available Wi-Fi networks */ -function WifiNetworksList() { - const navigate = useNavigate(); +function WifiNetworksList({ showIp = true, ...props }: WifiNetworksListProps) { useNetworkChanges(); + const navigate = useNavigate(); const networks: WifiNetwork[] = useWifiNetworks(); if (networks.length === 0) - return ; + return ; + + const statusOrder = [ + WifiNetworkStatus.CONNECTED, + WifiNetworkStatus.CONFIGURED, + WifiNetworkStatus.NOT_CONFIGURED, + ]; + + // Sort networks by status and signal + networks.sort( + (a, b) => + statusOrder.indexOf(a.status) - statusOrder.indexOf(b.status) || b.strength - a.strength, + ); return ( - <> - navigate(generatePath(PATHS.wifiNetwork, { ssid }))} - > - {networks.map((n) => ( - - ))} - - + navigate(generatePath(PATHS.wifiNetwork, { ssid }))} + {...props} + > + {networks.map((n) => ( + + ))} + ); } From 90666ab9e400a9b17de98c5715afb120d0c2b9e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Tue, 8 Apr 2025 09:38:41 +0100 Subject: [PATCH 23/59] web: add new route and components for wired connections Add a new route for connection details, which is currently rendered separately from wireless connections. In the future, these may be rendered by the same component. Additionally, a new component is introduced to display a list of wired connections, similar to how Wi-Fi networks are presented. --- .../network/WiredConnectionPage.tsx | 104 ++++++++++++++++++ .../network/WiredConnectionsList.tsx | 102 +++++++++++++++++ web/src/components/network/index.ts | 1 + web/src/routes/network.tsx | 11 +- web/src/routes/paths.ts | 1 + 5 files changed, 218 insertions(+), 1 deletion(-) create mode 100644 web/src/components/network/WiredConnectionPage.tsx create mode 100644 web/src/components/network/WiredConnectionsList.tsx diff --git a/web/src/components/network/WiredConnectionPage.tsx b/web/src/components/network/WiredConnectionPage.tsx new file mode 100644 index 0000000000..1352ad663a --- /dev/null +++ b/web/src/components/network/WiredConnectionPage.tsx @@ -0,0 +1,104 @@ +/* + * Copyright (c) [2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React from "react"; +import { useParams } from "react-router-dom"; +import { + Alert, + Content, + EmptyState, + EmptyStateActions, + EmptyStateBody, + EmptyStateFooter, +} from "@patternfly/react-core"; +import { Link, Page } from "~/components/core"; +import { useConnections, useNetworkChanges } from "~/queries/network"; +import { _ } from "~/i18n"; +import { sprintf } from "sprintf-js"; +import WiredConnectionDetails from "./WiredConnectionDetails"; +import { Icon } from "../layout"; +import { NETWORK } from "~/routes/paths"; + +// FIXME: Choose between EmptyState or Alert. Or work to make Empty State to +// look better on big screens. Empty State puts the content too +// far in big resolution. +const ConnectionNotFound = ({ + id, + variant = "alert", +}: { + id: string; + variant: "emptystate" | "alert"; +}) => { + if (variant === "emptystate") { + return ( + }> + {sprintf(_("There is not a connection with id `%s`"), id)} + + + + + {_("Go to network page")} + + + + + ); + } + + return ( + + {_("Go to network page")} + + } + > + {sprintf(_("There is not a connection with id `%s`"), id)} + + ); +}; + +export default function WiredConnectionPage() { + useNetworkChanges(); + const { id } = useParams(); + const connections = useConnections(); + const connection = connections.find((c) => c.id === id); + + const title = _("Connection details"); + + return ( + + + {title} + + + {connection ? ( + + ) : ( + + )} + + + ); +} diff --git a/web/src/components/network/WiredConnectionsList.tsx b/web/src/components/network/WiredConnectionsList.tsx new file mode 100644 index 0000000000..35754810bc --- /dev/null +++ b/web/src/components/network/WiredConnectionsList.tsx @@ -0,0 +1,102 @@ +/* + * Copyright (c) [2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React, { useId } from "react"; +import { generatePath, useNavigate } from "react-router-dom"; +import { + Content, + DataList, + DataListCell, + DataListItem, + DataListItemCells, + DataListItemRow, + DataListProps, + Flex, +} from "@patternfly/react-core"; +import a11yStyles from "@patternfly/react-styles/css/utilities/Accessibility/accessibility"; +import { EmptyState } from "~/components/core"; +import { Connection } from "~/types/network"; +import { useConnections, useNetworkDevices } from "~/queries/network"; +import { NETWORK as PATHS } from "~/routes/paths"; +import { formatIp } from "~/utils/network"; +import { _ } from "~/i18n"; + +type ConnectionListItemProps = { connection: Connection }; + +const ConnectionListItem = ({ connection }: ConnectionListItemProps) => { + const nameId = useId(); + const ipId = useId(); + const devices = useNetworkDevices(); + + const device = devices.find( + ({ connection: deviceConnectionId }) => deviceConnectionId === connection.id, + ); + const addresses = device ? device.addresses : connection.addresses; + + return ( + + + + + + {connection.id} + + + {_("IP addresses")} + {addresses.map(formatIp).join(", ")} + + + , + ]} + /> + + + ); +}; + +/** + * Component for displaying a list of available wired connections + */ +function WiredConnectionsList(props: DataListProps) { + const navigate = useNavigate(); + const connections = useConnections(); + const wiredConnections = connections.filter((c) => !c.wireless); + + if (wiredConnections.length === 0) { + return ; + } + + return ( + navigate(generatePath(PATHS.wiredConnection, { id }))} + {...props} + > + {wiredConnections.map((c: Connection) => ( + + ))} + + ); +} + +export default WiredConnectionsList; diff --git a/web/src/components/network/index.ts b/web/src/components/network/index.ts index 6d4defc7d1..631511f46f 100644 --- a/web/src/components/network/index.ts +++ b/web/src/components/network/index.ts @@ -24,3 +24,4 @@ export { default as NetworkPage } from "./NetworkPage"; export { default as IpSettingsForm } from "./IpSettingsForm"; export { default as WifiSelectorPage } from "./WifiSelectorPage"; export { default as WifiNetworkPage } from "./WifiNetworkPage"; +export { default as WiredConnectionPage } from "./WiredConnectionPage"; diff --git a/web/src/routes/network.tsx b/web/src/routes/network.tsx index 6a11e45936..21e433c081 100644 --- a/web/src/routes/network.tsx +++ b/web/src/routes/network.tsx @@ -21,7 +21,12 @@ */ import React from "react"; -import { NetworkPage, IpSettingsForm, WifiNetworkPage } from "~/components/network"; +import { + NetworkPage, + IpSettingsForm, + WifiNetworkPage, + WiredConnectionPage, +} from "~/components/network"; import { Route } from "~/types/routes"; import { NETWORK as PATHS } from "~/routes/paths"; import { N_ } from "~/i18n"; @@ -42,6 +47,10 @@ const routes = (): Route => ({ path: PATHS.wifiNetwork, element: , }, + { + path: PATHS.wiredConnection, + element: , + }, ], }); diff --git a/web/src/routes/paths.ts b/web/src/routes/paths.ts index 202e716c94..ed44abd311 100644 --- a/web/src/routes/paths.ts +++ b/web/src/routes/paths.ts @@ -31,6 +31,7 @@ const NETWORK = { root: "/network", editConnection: "/network/connections/:id/edit", wifiNetwork: "/network/wifi_networks/:ssid", + wiredConnection: "/network/wired_connection/:id", }; const PRODUCT = { From bcf1be19d97cb813a37ee28bed27de6d4e520eb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Tue, 8 Apr 2025 09:48:26 +0100 Subject: [PATCH 24/59] web: adapt network page For making use of new components and improving the wording a bit. --- web/src/components/network/NetworkPage.tsx | 36 +++++----------------- 1 file changed, 7 insertions(+), 29 deletions(-) diff --git a/web/src/components/network/NetworkPage.tsx b/web/src/components/network/NetworkPage.tsx index 15be75d8fc..56b193a074 100644 --- a/web/src/components/network/NetworkPage.tsx +++ b/web/src/components/network/NetworkPage.tsx @@ -23,31 +23,10 @@ import React from "react"; import { Content, Grid, GridItem } from "@patternfly/react-core"; import { EmptyState, Page } from "~/components/core"; -import ConnectionsTable from "~/components/network/ConnectionsTable"; import { _ } from "~/i18n"; -import { - useConnections, - useNetworkChanges, - useNetworkDevices, - useNetworkState, -} from "~/queries/network"; +import { useNetworkChanges, useNetworkState } from "~/queries/network"; import WifiNetworksList from "./WifiNetworksList"; - -const WiredConnections = ({ connections, devices }) => { - const wiredConnections = connections.length; - - const sectionProps = wiredConnections > 0 ? { title: _("Wired") } : {}; - - return ( - - {wiredConnections > 0 ? ( - - ) : ( - - )} - - ); -}; +import WiredConnectionsList from "./WiredConnectionsList"; const NoWifiAvailable = () => ( @@ -64,10 +43,7 @@ const NoWifiAvailable = () => ( */ export default function NetworkPage() { useNetworkChanges(); - const connections = useConnections(); - const devices = useNetworkDevices(); const networkState = useNetworkState(); - const wiredConnections = connections.filter((c) => !c.wireless); return ( @@ -78,12 +54,14 @@ export default function NetworkPage() { - + + + {networkState.wirelessEnabled ? ( - - + + ) : ( From 4985d2e9ba097b5994d97406d070d2db06f8d6f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Tue, 8 Apr 2025 09:48:58 +0100 Subject: [PATCH 25/59] fix(web): render IP settings form in a single column Adjust the layout of the IP settings form to display in a single column, aligning it with the rest of the forms. This commit focuses only on the layout change. Further improvements and bug fixes must be addressed in future updates. --- .../components/network/AddressesDataList.tsx | 24 +-- web/src/components/network/DnsDataList.tsx | 24 +-- web/src/components/network/IpSettingsForm.tsx | 139 ++++++++---------- 3 files changed, 83 insertions(+), 104 deletions(-) diff --git a/web/src/components/network/AddressesDataList.tsx b/web/src/components/network/AddressesDataList.tsx index a68be69682..67dff18ed4 100644 --- a/web/src/components/network/AddressesDataList.tsx +++ b/web/src/components/network/AddressesDataList.tsx @@ -36,10 +36,9 @@ import { DataListCell, DataListAction, Flex, - Stack, + FormGroup, } from "@patternfly/react-core"; -import { FormLabel } from "~/components/core"; import IpAddressInput from "~/components/network/IpAddressInput"; import IpPrefixInput from "~/components/network/IpPrefixInput"; import { _ } from "~/i18n"; @@ -139,19 +138,20 @@ export default function AddressesDataList({ const newAddressButtonText = addresses.length ? _("Add another address") : _("Add an address"); return ( - - - {_("Addresses")} - - {/** FIXME: try to use an aria-labelledby instead when PatternFly permits it (or open a bug report) */} - - {addresses.map((address) => renderAddress(address))} - - + + + {/** FIXME: try to use an aria-labelledby instead when PatternFly permits it (or open a bug report) */} + + {addresses.map((address) => renderAddress(address))} + - +
); } diff --git a/web/src/components/network/DnsDataList.tsx b/web/src/components/network/DnsDataList.tsx index 6e67b1c8d7..1306c4dfb2 100644 --- a/web/src/components/network/DnsDataList.tsx +++ b/web/src/components/network/DnsDataList.tsx @@ -36,10 +36,9 @@ import { DataListCell, DataListAction, Flex, - Stack, + FormGroup, } from "@patternfly/react-core"; -import { FormLabel } from "~/components/core"; import IpAddressInput from "~/components/network/IpAddressInput"; import { _ } from "~/i18n"; @@ -116,19 +115,20 @@ export default function DnsDataList({ const newDnsButtonText = servers.length ? _("Add another DNS") : _("Add DNS"); return ( - - - {_("DNS")} - - {/** FIXME: try to use an aria-labelledby instead when PatternFly permits it (or open a bug report) */} - - {servers.map((server) => renderDns(server))} - - + + + {/** FIXME: try to use an aria-labelledby instead when PatternFly permits it (or open a bug report) */} + + {servers.map((server) => renderDns(server))} + - +
); } diff --git a/web/src/components/network/IpSettingsForm.tsx b/web/src/components/network/IpSettingsForm.tsx index 4712b8c186..2309d76d90 100644 --- a/web/src/components/network/IpSettingsForm.tsx +++ b/web/src/components/network/IpSettingsForm.tsx @@ -23,6 +23,7 @@ import React, { useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { + ActionGroup, Alert, Content, Form, @@ -31,11 +32,8 @@ import { FormSelect, FormSelectOption, FormSelectProps, - Grid, - GridItem, HelperText, HelperTextItem, - Stack, TextInput, } from "@patternfly/react-core"; import { Page } from "~/components/core"; @@ -73,7 +71,8 @@ export default function IpSettingsForm() { return isSetAsInvalid(field) ? "error" : "default"; }; - const cleanAddresses = (addrs: IPAddress[]) => addrs.filter((addr) => addr.address !== ""); + const cleanAddresses = (addresses: IPAddress[]) => + addresses.filter((address) => address.address !== ""); const cleanError = (field: string) => { if (isSetAsInvalid(field)) { @@ -161,84 +160,64 @@ export default function IpSettingsForm() { )} - - - - - - - - {/* TRANSLATORS: manual network configuration mode with a static IP address */} - - - {renderError("method")} - - - setGateway(value)} - /> - {isGatewayDisabled && ( - - - - {/** FIXME: check if that afirmation is true */} - {_("Gateway can be defined only in 'Manual' mode")} - - - - )} - - - - - - - - - - - - - - - - - + + + + {/* TRANSLATORS: manual network configuration mode with a static IP address */} + + + {renderError("method")} + + + setGateway(value)} + /> + {isGatewayDisabled && ( + + + + {/** FIXME: check if that affirmation is true */} + {_("Gateway can be defined only in 'Manual' mode")} + + + + )} + + + + + + + + + + - - - - -
); } From 7acb5aeff56a228c100419c1b5c150d4db0c0369 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Tue, 8 Apr 2025 09:52:56 +0100 Subject: [PATCH 26/59] web: change hover color for clickable data list items To make them looks like the rest of actions. --- web/src/assets/styles/index.scss | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/web/src/assets/styles/index.scss b/web/src/assets/styles/index.scss index 87e5d7fbf5..c58e4fe39e 100644 --- a/web/src/assets/styles/index.scss +++ b/web/src/assets/styles/index.scss @@ -408,6 +408,10 @@ label.pf-m-disabled + .pf-v6-c-check__description { --pf-v6-c-list--m-inline--ColumnGap: var(--pf-t--global--spacer--md); } +.pf-v6-c-data-list li.pf-m-clickable { + --pf-v6-c-data-list__item--hover--BackgroundColor: var(--agm-t--action--background--color--hover); +} + // Nested content inside forms should respect parent grid gap .pf-v6-c-form [class*="pf-v6-u-mx"] { display: grid; From b769cdd688fdc5cedc34ccc5b3a42cc23a9e5052 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Tue, 8 Apr 2025 10:42:40 +0100 Subject: [PATCH 27/59] fix(web): add missing component That should be commited in 90666ab9e400a9b17de98c5715afb120d0c2b9e7 --- .../network/WiredConnectionDetails.tsx | 153 ++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 web/src/components/network/WiredConnectionDetails.tsx diff --git a/web/src/components/network/WiredConnectionDetails.tsx b/web/src/components/network/WiredConnectionDetails.tsx new file mode 100644 index 0000000000..8c231c60cf --- /dev/null +++ b/web/src/components/network/WiredConnectionDetails.tsx @@ -0,0 +1,153 @@ +/* + * Copyright (c) [2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React from "react"; +import { generatePath } from "react-router-dom"; +import { + DescriptionList, + DescriptionListDescription, + DescriptionListGroup, + DescriptionListTerm, + Flex, + FlexItem, + Grid, + GridItem, + Stack, +} from "@patternfly/react-core"; +import { Link, Page } from "~/components/core"; +import { Connection, Device } from "~/types/network"; +import { isEmpty } from "~/utils"; +import { formatIp } from "~/utils/network"; +import { NETWORK } from "~/routes/paths"; +import { useNetworkDevices } from "~/queries/network"; +import { _ } from "~/i18n"; + +const DeviceDetails = ({ device }: { device: Device }) => { + if (!device) return; + + return ( + + + + {_("Interface")} + {device.name} + + + {_("Status")} + {device.state} + + + {_("MAC")} + {device.macAddress} + + + + ); +}; + +const IpDetails = ({ connection, device }: { connection: Connection; device: Device }) => { + if (!device) return; + + return ( + + {_("Edit")} + + } + > + + + {_("IPv4 Mode")} + {connection.method4} + + + {_("IPv6 Mode")} + {connection.method6} + + + {_("IPv4 Gateway")} + {device.gateway4} + + + {_("IPv6 Gateway")} + + {isEmpty(device.gateway6) ? _("None") : device.gateway6} + + + + {_("IP Addresses")} + + + {device.addresses.map((ip, idx) => ( + {formatIp(ip)} + ))} + + + + + {_("DNS")} + + + {device.nameservers.map((dns, idx) => ( + {dns} + ))} + + + + + {_("Routes")} + + + {device.routes4.map((route, idx) => ( + {formatIp(route.destination)} + ))} + + + + + + ); +}; + +export default function WiredConnectionDetails({ connection }: { connection: Connection }) { + const devices = useNetworkDevices(); + + const device = devices.find( + ({ connection: deviceConnectionId }) => deviceConnectionId === connection.id, + ); + + return ( + + + + + + + + + + + ); +} From 3e1e5e9b43054f0b0527c9c9f5546186d5da5a6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Tue, 8 Apr 2025 13:10:47 +0100 Subject: [PATCH 28/59] fix(web): adapt DeviceState to the new values --- web/src/components/network/ConnectionsTable.test.tsx | 4 ++-- web/src/components/network/WifiNetworksList.test.tsx | 2 +- web/src/components/network/WifiNetworksList.tsx | 11 +++++++---- web/src/queries/network.ts | 2 +- 4 files changed, 11 insertions(+), 8 deletions(-) diff --git a/web/src/components/network/ConnectionsTable.test.tsx b/web/src/components/network/ConnectionsTable.test.tsx index 025405ead6..721cc4f810 100644 --- a/web/src/components/network/ConnectionsTable.test.tsx +++ b/web/src/components/network/ConnectionsTable.test.tsx @@ -32,7 +32,7 @@ const enp1s0: Device = { name: "enp1s0", connection: "enp1s0", type: ConnectionType.ETHERNET, - state: DeviceState.ACTIVATED, + state: DeviceState.CONNECTED, addresses: [{ address: "192.168.69.200", prefix: 24 }], nameservers: ["192.168.69.1"], method4: ConnectionMethod.MANUAL, @@ -46,7 +46,7 @@ const wlan0: Device = { name: "wlan0", connection: "WiFi", type: ConnectionType.WIFI, - state: DeviceState.ACTIVATED, + state: DeviceState.CONNECTED, addresses: [{ address: "192.168.69.201", prefix: 24 }], nameservers: ["192.168.69.1"], method4: ConnectionMethod.MANUAL, diff --git a/web/src/components/network/WifiNetworksList.test.tsx b/web/src/components/network/WifiNetworksList.test.tsx index b13e1c9f37..209732628d 100644 --- a/web/src/components/network/WifiNetworksList.test.tsx +++ b/web/src/components/network/WifiNetworksList.test.tsx @@ -38,7 +38,7 @@ const wlan0: Device = { name: "wlan0", connection: "Network 1", type: ConnectionType.WIFI, - state: DeviceState.ACTIVATED, + state: DeviceState.CONNECTED, addresses: [{ address: "192.168.69.201", prefix: 24 }], nameservers: ["192.168.69.1"], method4: ConnectionMethod.MANUAL, diff --git a/web/src/components/network/WifiNetworksList.tsx b/web/src/components/network/WifiNetworksList.tsx index 034a8af014..f1c736b35c 100644 --- a/web/src/components/network/WifiNetworksList.tsx +++ b/web/src/components/network/WifiNetworksList.tsx @@ -46,15 +46,18 @@ import { _ } from "~/i18n"; // FIXME: Move to the model and stop using translations for checking the state const networkState = (state: DeviceState): string => { switch (state) { - case DeviceState.CONFIG: - case DeviceState.IPCHECK: + case DeviceState.CONNECTING: // TRANSLATORS: Wifi network status return _("Connecting"); - case DeviceState.ACTIVATED: + case DeviceState.CONNECTED: // TRANSLATORS: Wifi network status return _("Connected"); - case DeviceState.DEACTIVATING: + case DeviceState.DISCONNECTING: + // TRANSLATORS: Wifi network status + return _("Disconnecting"); case DeviceState.FAILED: + // TRANSLATORS: Wifi network status + return _("Failed"); case DeviceState.DISCONNECTED: // TRANSLATORS: Wifi network status return _("Disconnected"); diff --git a/web/src/queries/network.ts b/web/src/queries/network.ts index 1a68bab14c..176b739cee 100644 --- a/web/src/queries/network.ts +++ b/web/src/queries/network.ts @@ -260,7 +260,7 @@ const useWifiNetworks = () => { const device = devices.find((d: Device) => d.connection === ap.ssid); let status: WifiNetworkStatus; - if (device?.state === DeviceState.ACTIVATED) { + if (device?.state === DeviceState.CONNECTED) { status = WifiNetworkStatus.CONNECTED; } else { status = settings ? WifiNetworkStatus.CONFIGURED : WifiNetworkStatus.NOT_CONFIGURED; From 280ae2059bc23034b9461c89de97364fc0949207 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Tue, 8 Apr 2025 13:11:20 +0100 Subject: [PATCH 29/59] fix(web): drop the unused ConnectionState enum --- web/src/types/network.ts | 16 ---------------- web/src/utils/network.ts | 11 ----------- 2 files changed, 27 deletions(-) diff --git a/web/src/types/network.ts b/web/src/types/network.ts index d5b742c846..5fc28a70f1 100644 --- a/web/src/types/network.ts +++ b/web/src/types/network.ts @@ -70,21 +70,6 @@ enum ConnectionType { UNKNOWN = "unknown", } -/** - * Enum for the active connection state values - * - * @readonly - * @enum { number } - * https://networkmanager.dev/docs/api/latest/nm-dbus-types.html#NMActiveConnectionState - */ -enum ConnectionState { - UNKNOWN = 0, - ACTIVATING = 1, - ACTIVATED = 2, - DEACTIVATING = 3, - DEACTIVATED = 4, -} - enum DeviceState { UNKNOWN = "unknown", UNMANAGED = "unmanaged", @@ -355,7 +340,6 @@ export { ApFlags, ApSecurityFlags, Connection, - ConnectionState, ConnectionStatus, ConnectionMethod, ConnectionType, diff --git a/web/src/utils/network.ts b/web/src/utils/network.ts index c0189076be..17975e1cf1 100644 --- a/web/src/utils/network.ts +++ b/web/src/utils/network.ts @@ -26,22 +26,12 @@ import { ApFlags, ApSecurityFlags, Connection, - ConnectionState, Device, IPAddress, Route, SecurityProtocols, } from "~/types/network"; -/** - * Returns a human readable connection state - */ -const connectionHumanState = (state: number): string => { - const stateIndex = Object.values(ConnectionState).indexOf(state); - const stateKey = Object.keys(ConnectionState)[stateIndex]; - return stateKey.toLowerCase(); -}; - /** * Check if an IP is valid * @@ -203,7 +193,6 @@ export { buildAddresses, buildRoutes, connectionAddresses, - connectionHumanState, formatIp, intToIPString, ipPrefixFor, From 675e42eb58677d38aabbc553679c9dd8b169fff5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Tue, 8 Apr 2025 13:11:40 +0100 Subject: [PATCH 30/59] fix(web): drop an unused mock --- web/src/components/network/WifiConnectionForm.test.tsx | 5 ----- 1 file changed, 5 deletions(-) diff --git a/web/src/components/network/WifiConnectionForm.test.tsx b/web/src/components/network/WifiConnectionForm.test.tsx index 403408c8fa..3a3ed0fd55 100644 --- a/web/src/components/network/WifiConnectionForm.test.tsx +++ b/web/src/components/network/WifiConnectionForm.test.tsx @@ -50,11 +50,6 @@ jest.mock("~/queries/network", () => ({ }), })); -const hiddenNetworkMock = { - hidden: true, - status: WifiNetworkStatus.NOT_CONFIGURED, -} as WifiNetwork; - const networkMock = { ssid: "Visible Network", hidden: false, From eba6f5cd7c793754a90ecdbabce33991b1161c9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Tue, 8 Apr 2025 15:12:14 +0100 Subject: [PATCH 31/59] fix(Web): fix NetworkPage test --- .../components/network/NetworkPage.test.tsx | 115 ++++-------------- 1 file changed, 24 insertions(+), 91 deletions(-) diff --git a/web/src/components/network/NetworkPage.test.tsx b/web/src/components/network/NetworkPage.test.tsx index 978de5a5fc..be8a3024b6 100644 --- a/web/src/components/network/NetworkPage.test.tsx +++ b/web/src/components/network/NetworkPage.test.tsx @@ -21,124 +21,57 @@ */ import React from "react"; -import { screen, within } from "@testing-library/react"; -import { installerRender } from "~/test-utils"; +import { screen } from "@testing-library/react"; +import { installerRender, plainRender } from "~/test-utils"; import NetworkPage from "~/components/network/NetworkPage"; -import { Connection, ConnectionMethod, ConnectionStatus, ConnectionType } from "~/types/network"; -jest.mock("~/components/product/ProductRegistrationAlert", () => () => ( -
ProductRegistrationAlert Mock
-)); - -const wiredConnection = new Connection("eth0", { - iface: "eth0", - method4: ConnectionMethod.MANUAL, - method6: ConnectionMethod.MANUAL, - addresses: [{ address: "192.168.122.20", prefix: 24 }], - nameservers: ["192.168.122.1"], - gateway4: "192.168.122.1", -}); - -const wifiConnection = new Connection("AgamaNetwork", { - iface: "wlan0", - method4: ConnectionMethod.AUTO, - method6: ConnectionMethod.AUTO, - wireless: { - ssid: "Agama", - security: "wpa-psk", - mode: "infrastructure", - password: "agama.test", - }, - addresses: [{ address: "192.168.69.200", prefix: 24 }], - nameservers: [], -}); +jest.mock( + "~/components/product/ProductRegistrationAlert", + () => () => -(
ProductRegistrationAlert Mock
), +); -const ethernetDevice = { - name: "eth0", - connection: "eth0", - type: ConnectionType.ETHERNET, - addresses: [{ address: "192.168.122.20", prefix: 24 }], - macAddress: "00:11:22:33:44::55", - status: ConnectionStatus.UP, -}; +jest.mock("~/components/network/WifiNetworksList", () => () =>
WifiNetworksList Mock
); -const wifiDevice = { - name: "wlan0", - connection: "AgamaNetwork", - type: ConnectionType.WIFI, - state: "activated", - addresses: [{ address: "192.168.69.200", prefix: 24 }], - macAddress: "AA:11:22:33:44::FF", - status: ConnectionStatus.UP, -}; +jest.mock("~/components/network/WiredConnectionsList", () => () => ( +
WiredConnectionsList Mock
+)); -const mockDevices = [ethernetDevice, wifiDevice]; -let mockActiveConnections = [wiredConnection, wifiConnection]; -let mockNetworkSettings = { - wireless_enabled: true, +const mockNetworkState = { + wirelessEnabled: true, }; -const mockAccessPoints = []; - jest.mock("~/queries/network", () => ({ useNetworkChanges: jest.fn(), - useNetwork: () => ({ - connections: mockActiveConnections, - devices: mockDevices, - settings: mockNetworkSettings, - accessPoints: mockAccessPoints, - }), + useNetworkState: () => mockNetworkState, })); describe("NetworkPage", () => { it("renders a section for wired connections", () => { installerRender(); - const section = screen.getByRole("region", { name: "Wired" }); - within(section).getByText("eth0"); - within(section).getByText("192.168.122.20/24"); - }); - - it("renders a section for WiFi connections", () => { - installerRender(); - const section = screen.getByRole("region", { name: "Wi-Fi" }); - within(section).getByText("Connected to AgamaNetwork"); - within(section).getByText("192.168.69.200/24"); - }); - - describe("when wired connection were not found", () => { - beforeEach(() => { - mockActiveConnections = [wifiConnection]; - }); - - it("renders information about it", () => { - installerRender(); - screen.getByText("No wired connections found"); - }); + expect(screen.queryByText("WiredConnectionsList Mock")).toBeInTheDocument(); }); - describe("when WiFi scan is supported but no connection found", () => { + describe("when Wi-Fi support is enabled", () => { beforeEach(() => { - mockActiveConnections = [wiredConnection]; + mockNetworkState.wirelessEnabled = true; }); - it("renders information about it and a link going to the connection page", () => { + it("renders the list of Wi-Fi networks", () => { installerRender(); - const section = screen.getByRole("region", { name: "Wi-Fi" }); - within(section).getByText("No connected yet"); - within(section).getByRole("link", { name: "Connect" }); + expect(screen.queryByText("WifiNetworksList Mock")).toBeInTheDocument(); }); }); - describe("when WiFi scan is not supported", () => { + describe("when Wi-Fi support is disabled", () => { beforeEach(() => { - mockNetworkSettings = { wireless_enabled: false }; + mockNetworkState.wirelessEnabled = false; }); - it("renders information about it, without links for connecting", async () => { + it("does not render the list of Wi-Fi networks", () => { installerRender(); - screen.getByText("No Wi-Fi supported"); - const connectionButton = screen.queryByRole("link", { name: "Connect" }); - expect(connectionButton).toBeNull(); + expect( + screen.queryByText(/The system does not support Wi-Fi connections/), + ).toBeInTheDocument(); }); }); }); From e923262585a6d80ce7a8dd35e16fec9a67f68397 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Tue, 8 Apr 2025 15:18:48 +0100 Subject: [PATCH 32/59] fix(web): web the unused WifiSelectorPage component --- .../components/network/WifiSelectorPage.tsx | 54 ------------------- web/src/components/network/index.ts | 1 - 2 files changed, 55 deletions(-) delete mode 100644 web/src/components/network/WifiSelectorPage.tsx diff --git a/web/src/components/network/WifiSelectorPage.tsx b/web/src/components/network/WifiSelectorPage.tsx deleted file mode 100644 index 88481fce48..0000000000 --- a/web/src/components/network/WifiSelectorPage.tsx +++ /dev/null @@ -1,54 +0,0 @@ -/* - * Copyright (c) [2024-2025] SUSE LLC - * - * All Rights Reserved. - * - * This program is free software; you can redistribute it and/or modify it - * under the terms of the GNU General Public License as published by the Free - * Software Foundation; either version 2 of the License, or (at your option) - * any later version. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for - * more details. - * - * You should have received a copy of the GNU General Public License along - * with this program; if not, contact SUSE LLC. - * - * To contact SUSE LLC about this file by physical or electronic mail, you may - * find current contact information at www.suse.com. - */ - -import React from "react"; -import { Content, Grid, GridItem } from "@patternfly/react-core"; -import { Page } from "~/components/core"; -import WifiNetworksList from "~/components/network/WifiNetworksList"; -import { useNetworkChanges } from "~/queries/network"; -import { _ } from "~/i18n"; - -function WifiSelectorPage() { - useNetworkChanges(); - - return ( - - - {_("Connect to a Wi-Fi network")} - - - - - - - - - - - - - - - ); -} - -export default WifiSelectorPage; diff --git a/web/src/components/network/index.ts b/web/src/components/network/index.ts index 631511f46f..5cf19da22b 100644 --- a/web/src/components/network/index.ts +++ b/web/src/components/network/index.ts @@ -22,6 +22,5 @@ export { default as NetworkPage } from "./NetworkPage"; export { default as IpSettingsForm } from "./IpSettingsForm"; -export { default as WifiSelectorPage } from "./WifiSelectorPage"; export { default as WifiNetworkPage } from "./WifiNetworkPage"; export { default as WiredConnectionPage } from "./WiredConnectionPage"; From 10425be057985e6a92fcdcca07f630eede8317ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Tue, 8 Apr 2025 15:23:21 +0100 Subject: [PATCH 33/59] fix(web): drop an unused plainRender import --- web/src/components/network/NetworkPage.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/components/network/NetworkPage.test.tsx b/web/src/components/network/NetworkPage.test.tsx index be8a3024b6..873631ce2d 100644 --- a/web/src/components/network/NetworkPage.test.tsx +++ b/web/src/components/network/NetworkPage.test.tsx @@ -22,7 +22,7 @@ import React from "react"; import { screen } from "@testing-library/react"; -import { installerRender, plainRender } from "~/test-utils"; +import { installerRender } from "~/test-utils"; import NetworkPage from "~/components/network/NetworkPage"; jest.mock( From dcd3a770f3fd349daa718cda18b3fdae3e7ba877 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Tue, 8 Apr 2025 15:38:41 +0100 Subject: [PATCH 34/59] fix(web): update cancel action on connection edit Previously, such an action redirected to the parent route. Now, it will return to the previous page instead. --- web/src/components/network/IpSettingsForm.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/components/network/IpSettingsForm.tsx b/web/src/components/network/IpSettingsForm.tsx index 2309d76d90..48b44fc310 100644 --- a/web/src/components/network/IpSettingsForm.tsx +++ b/web/src/components/network/IpSettingsForm.tsx @@ -214,7 +214,7 @@ export default function IpSettingsForm() { - + {_("Cancel")} From 472c07cae9232b9db2288fd4e4e951f03a8a1f60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Wed, 9 Apr 2025 11:18:07 +0100 Subject: [PATCH 35/59] fix(web): ignore aria-label errors in the WifiNetworksList tests --- web/src/components/network/WifiNetworksList.test.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/web/src/components/network/WifiNetworksList.test.tsx b/web/src/components/network/WifiNetworksList.test.tsx index 209732628d..c0e56c9df1 100644 --- a/web/src/components/network/WifiNetworksList.test.tsx +++ b/web/src/components/network/WifiNetworksList.test.tsx @@ -105,6 +105,7 @@ describe("WifiNetworksListPage", () => { }); it("renders a list of available wifi networks", () => { + // @ts-expect-error: you need to specify the aria-label installerRender(); screen.getByRole("listitem", { name: "Network 1" }); screen.getByRole("listitem", { name: "Network 2" }); @@ -113,6 +114,7 @@ describe("WifiNetworksListPage", () => { describe("and user selects a connected network", () => { it("renders basic network information and actions instead of the connection form", async () => { + // @ts-expect-error: you need to specify the aria-label const { user } = installerRender(); const network1 = screen.getByRole("listitem", { name: "Network 1" }); await user.click(network1); @@ -127,6 +129,7 @@ describe("WifiNetworksListPage", () => { describe("and user selects a configured network", () => { it("renders actions instead of the connection form", async () => { + // @ts-expect-error: you need to specify the aria-label const { user } = installerRender(); const network2 = screen.getByRole("listitem", { name: "Network 2" }); await user.click(network2); @@ -140,6 +143,7 @@ describe("WifiNetworksListPage", () => { describe("and user selects a not configured network", () => { it("renders the connection form", async () => { + // @ts-expect-error: you need to specify the aria-label const { user } = installerRender(); const network3 = screen.getByRole("listitem", { name: "Network 3" }); await user.click(network3); @@ -155,6 +159,7 @@ describe("WifiNetworksListPage", () => { }); it("renders information about it", () => { + // @ts-expect-error: you need to specify the aria-label installerRender(); screen.getByText("No visible Wi-Fi networks found"); }); From fbba1c3ce4c4616af0341bfef3dba1771e4fdc16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Wed, 9 Apr 2025 20:30:53 +0100 Subject: [PATCH 36/59] web: connection details adjustments For displaying IP settings in a more consistent way. --- .../network/WifiConnectionDetails.tsx | 174 +++++++++--------- .../network/WiredConnectionDetails.tsx | 30 +-- 2 files changed, 99 insertions(+), 105 deletions(-) diff --git a/web/src/components/network/WifiConnectionDetails.tsx b/web/src/components/network/WifiConnectionDetails.tsx index 46c97d1cc8..0d02408f62 100644 --- a/web/src/components/network/WifiConnectionDetails.tsx +++ b/web/src/components/network/WifiConnectionDetails.tsx @@ -35,7 +35,6 @@ import { } from "@patternfly/react-core"; import { Link, Page } from "~/components/core"; import { Device, WifiNetwork } from "~/types/network"; -import { isEmpty } from "~/utils"; import { formatIp } from "~/utils/network"; import { NETWORK } from "~/routes/paths"; import { _ } from "~/i18n"; @@ -65,106 +64,97 @@ const NetworkDetails = ({ network }: { network: WifiNetwork }) => { ); }; -// FIXME: -// - should we use utils/network#connectionAddresses here? -// - should we present IPv4 and IPv6 separated as it is done with method and -// gateway -// - fix the device.method -> disabled bug -// - Choose one style for rendering v4 and v6 stuff: the one used in method or -// the other used in gateway -// - check the "Change configuration" link. device.connection is the -// network.ssid while in wlan the connection id is the interface (?) const DeviceDetails = ({ device }: { device: Device }) => { if (!device) return; return ( - <> - - - - {_("Interface")} - {device.name} - - - {_("Status")} - {device.state} - - - {_("MAC")} - {device.macAddress} - - - - + + + + {_("Interface")} + {device.name} + + + {_("Status")} + {device.state} + + + {_("MAC")} + {device.macAddress} + + + ); }; -const IpDetails = ({ device }: { device: Device }) => { +const IpDetails = ({ device, settings }: { device: Device; settings: WifiNetwork["settings"] }) => { if (!device) return; return ( - <> - - {_("Edit")} - - } - > - - - {_("IPv4 Mode")} - {device.method4} - - - {_("IPv6 Mode")} - {device.method6} - - - {_("IPv4 Gateway")} - {device.gateway4} - - - {_("IPv6 Gateway")} - - {isEmpty(device.gateway6) ? _("None") : device.gateway6} - - - - {_("IP Addresses")} - - - {device.addresses.map((ip, idx) => ( - {formatIp(ip)} - ))} - - - - - {_("DNS")} - - - {device.nameservers.map((dns, idx) => ( - {dns} - ))} - - - - - {_("Routes")} - - - {device.routes4.map((route, idx) => ( - {formatIp(route.destination)} - ))} - - - - - - + + {_("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)} + ))} + + + + + ); }; @@ -174,7 +164,7 @@ export default function WifiConnectionDetails({ network }: { network: WifiNetwor return ( - + diff --git a/web/src/components/network/WiredConnectionDetails.tsx b/web/src/components/network/WiredConnectionDetails.tsx index 8c231c60cf..34d6b326ca 100644 --- a/web/src/components/network/WiredConnectionDetails.tsx +++ b/web/src/components/network/WiredConnectionDetails.tsx @@ -35,7 +35,6 @@ import { } from "@patternfly/react-core"; import { Link, Page } from "~/components/core"; import { Connection, Device } from "~/types/network"; -import { isEmpty } from "~/utils"; import { formatIp } from "~/utils/network"; import { NETWORK } from "~/routes/paths"; import { useNetworkDevices } from "~/queries/network"; @@ -79,21 +78,26 @@ const IpDetails = ({ connection, device }: { connection: Connection; device: Dev > - {_("IPv4 Mode")} - {connection.method4} - - - {_("IPv6 Mode")} - {connection.method6} - - - {_("IPv4 Gateway")} - {device.gateway4} + {_("Mode")} + + + + {_("IPv4")} {connection.method4} + + + {_("IPv6")} {connection.method6} + + {device.gateway6} + + - {_("IPv6 Gateway")} + {_("Gateway")} - {isEmpty(device.gateway6) ? _("None") : device.gateway6} + + {device.gateway4} + {device.gateway6} + From be5492fb6f3c0922e5b41208ae58959c129a26cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Wed, 9 Apr 2025 21:13:28 +0100 Subject: [PATCH 37/59] web: add empty state for Wi-Fi network not found This might happen when users manually type the route or when NetworkManager ceases detecting a previously available network. --- .../components/network/WifiNetworkPage.tsx | 50 ++++++++++++++----- 1 file changed, 38 insertions(+), 12 deletions(-) diff --git a/web/src/components/network/WifiNetworkPage.tsx b/web/src/components/network/WifiNetworkPage.tsx index 774d149dc3..7ff06ff184 100644 --- a/web/src/components/network/WifiNetworkPage.tsx +++ b/web/src/components/network/WifiNetworkPage.tsx @@ -22,14 +22,43 @@ import React from "react"; import { useParams } from "react-router-dom"; -import { Content } from "@patternfly/react-core"; -import { Page } from "~/components/core"; -import { useNetworkChanges, useWifiNetworks } from "~/queries/network"; +import { + Content, + EmptyState, + EmptyStateActions, + EmptyStateBody, + EmptyStateFooter, +} from "@patternfly/react-core"; +import { Link, Page } from "~/components/core"; +import { Icon } from "~/components/layout"; import WifiConnectionForm from "./WifiConnectionForm"; import WifiConnectionDetails from "./WifiConnectionDetails"; +import { useNetworkChanges, useWifiNetworks } from "~/queries/network"; import { DeviceState } from "~/types/network"; -import { sprintf } from "sprintf-js"; +import { PATHS } from "~/routes/network"; import { _ } from "~/i18n"; +import { sprintf } from "sprintf-js"; + +const NetworkNotFound = ({ ssid }) => { + // TRANSLATORS: %s will be replaced with the network ssid + const text = sprintf(_('"%s" does not exist or is no longer available.'), ssid); + return ( + } + > + {text} + + + + {_("Go to network page")} + + + + + ); +}; export default function WifiNetworkPage() { useNetworkChanges(); @@ -37,12 +66,8 @@ export default function WifiNetworkPage() { const networks = useWifiNetworks(); const network = networks.find((c) => c.ssid === ssid); - if (!network) return "FIXME"; - - const connected = network.device?.state === DeviceState.CONNECTED; - const title = connected - ? _("Connection details") - : sprintf(_("Connect to %s network"), network.ssid); + const connected = network?.device?.state === DeviceState.CONNECTED; + const title = connected ? _("Connection details") : sprintf(_("Connect to %s"), ssid); return ( @@ -50,8 +75,9 @@ export default function WifiNetworkPage() { {title} - {!connected && } - {connected && } + {!network && } + {network && !connected && } + {network && connected && } ); From 416867ff8abc92d317a283b4d015d22e04e4335b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Wed, 9 Apr 2025 21:23:48 +0100 Subject: [PATCH 38/59] web: use empty state for wired connection not found And drop the alert alternative by now to make it consistent with the rest of the interface. Note that ConnectionNotFound and NetworkNotFound internal components could be extracted to a single one in a future round of improvements. --- .../network/WiredConnectionPage.tsx | 52 ++++++------------- 1 file changed, 16 insertions(+), 36 deletions(-) diff --git a/web/src/components/network/WiredConnectionPage.tsx b/web/src/components/network/WiredConnectionPage.tsx index 1352ad663a..42a7bd441f 100644 --- a/web/src/components/network/WiredConnectionPage.tsx +++ b/web/src/components/network/WiredConnectionPage.tsx @@ -23,7 +23,6 @@ import React from "react"; import { useParams } from "react-router-dom"; import { - Alert, Content, EmptyState, EmptyStateActions, @@ -38,44 +37,25 @@ import WiredConnectionDetails from "./WiredConnectionDetails"; import { Icon } from "../layout"; import { NETWORK } from "~/routes/paths"; -// FIXME: Choose between EmptyState or Alert. Or work to make Empty State to -// look better on big screens. Empty State puts the content too -// far in big resolution. -const ConnectionNotFound = ({ - id, - variant = "alert", -}: { - id: string; - variant: "emptystate" | "alert"; -}) => { - if (variant === "emptystate") { - return ( - }> - {sprintf(_("There is not a connection with id `%s`"), id)} - - - - - {_("Go to network page")} - - - - - ); - } +const ConnectionNotFound = ({ id }) => { + // TRANSLATORS: %s will be replaced with connection id + const text = sprintf(_('"%s" does not exist or is no longer available.'), id); return ( - - {_("Go to network page")} - - } + } > - {sprintf(_("There is not a connection with id `%s`"), id)} - + {text} + + + + {_("Go to network page")} + + + +
); }; From 7f611a9e9ffc7f83c34fee105653858c0df0313e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Wed, 9 Apr 2025 21:27:19 +0100 Subject: [PATCH 39/59] web: attemp to improve wifi connection form behavior Added a "loading" state when the user clicks on connect, which transitions to an error state if the connection fails, or to connection details if successful. Further testing is needed as it still feels fragile. --- .../components/network/WifiConnectionForm.tsx | 142 ++++++++++++------ 1 file changed, 95 insertions(+), 47 deletions(-) diff --git a/web/src/components/network/WifiConnectionForm.tsx b/web/src/components/network/WifiConnectionForm.tsx index 127ab37a9f..44c5eb10ef 100644 --- a/web/src/components/network/WifiConnectionForm.tsx +++ b/web/src/components/network/WifiConnectionForm.tsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2022-2024] SUSE LLC + * Copyright (c) [2022-2025] SUSE LLC * * All Rights Reserved. * @@ -25,17 +25,20 @@ import { ActionGroup, Alert, Button, + Content, Form, FormGroup, FormSelect, FormSelectOption, + Spinner, } from "@patternfly/react-core"; import { PasswordInput } from "~/components/core"; import { useAddConnectionMutation, useConnectionMutation } from "~/queries/network"; -import { Connection, DeviceState, WifiNetwork, Wireless } from "~/types/network"; +import { Connection, Device, DeviceState, WifiNetwork, Wireless } from "~/types/network"; import { _ } from "~/i18n"; import { sprintf } from "sprintf-js"; import { useNavigate } from "react-router-dom"; +import { isEmpty } from "~/utils"; /* * FIXME: it should be moved to the SecurityProtocols enum that already exists or to a class based @@ -58,6 +61,31 @@ const securityFrom = (supported: string[]) => { return ""; }; +const PublicNetworkAlert = () => { + return ( + + + {_("You will connect to a public network without encryption. Your data may not be secure.")} + + + ); +}; + +const ConnectingAlert = () => { + return ( + } + title={_("Setting up connection")} + > + {_("It may take some time.")} + + {_("Details will appear after the connection is successfully established.")} + + + ); +}; + const ConnectionError = ({ ssid }) => ( {

{_("Check the authentication parameters.")}

} @@ -70,6 +98,7 @@ export default function WifiConnectionForm({ network }: { network: WifiNetwork } const navigate = useNavigate(); const settings = network.settings?.wireless || new Wireless(); const [error, setError] = useState(false); + const [device, setDevice] = useState(); const { mutateAsync: addConnection } = useAddConnectionMutation(); const { mutateAsync: updateConnection } = useConnectionMutation(); const [security, setSecurity] = useState( @@ -81,6 +110,8 @@ export default function WifiConnectionForm({ network }: { network: WifiNetwork } const accept = async (e) => { e.preventDefault(); setError(false); + setIsConnecting(true); + setDevice(network.device); // FIXME: do not mutate the original object! const connection = network.settings || new Connection(network.ssid); connection.wireless = new Wireless({ ssid: network.ssid, security, password, hidden: false }); @@ -89,58 +120,75 @@ export default function WifiConnectionForm({ network }: { network: WifiNetwork } }; useEffect(() => { - if (network.device?.state === DeviceState.CONNECTING) { - setIsConnecting(true); - } + setDevice(network.device); }, [network.device]); useEffect(() => { - if (!isConnecting) return; + if (!device) return; - if (!network.device) { - setIsConnecting(false); + if (device?.state === DeviceState.CONNECTING) { + setError(false); + setIsConnecting(true); + } + + if (isConnecting && device && device.state === DeviceState.FAILED) { setError(true); + setIsConnecting(false); } - }, [network.device, isConnecting, navigate]); + }, [isConnecting, device]); + + const isPublicNetwork = isEmpty(network.security); + + if (isConnecting) return ; return ( - /** TRANSLATORS: accessible name for the WiFi connection form */ -
- {error && } - - {/* TRANSLATORS: Wifi security configuration (password protected or not) */} - - setSecurity(v)} - > - {selectorOptions} - - - {security === "wpa-psk" && ( - // TRANSLATORS: WiFi password - - setPassword(v)} - /> - - )} - - - {/* TRANSLATORS: button label */} - - - + <> + {isPublicNetwork && } + {/** TRANSLATORS: accessible name for the WiFi connection form */} +
+ {error && } + + {/* TRANSLATORS: Wifi security configuration (password protected or not) */} + {!isEmpty(network.security) && ( + + setSecurity(v)} + > + {selectorOptions} + + + )} + {security === "wpa-psk" && ( + // TRANSLATORS: WiFi password + + setPassword(v)} + /> + + )} + + + {/* TRANSLATORS: button label */} + + + + ); } From 4e6020d293917b6de6256aa7c6a952860432c786 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Thu, 10 Apr 2025 13:06:23 +0100 Subject: [PATCH 40/59] fix(web): drop reference to not used variant Related to commit 416867ff8abc92d317a283b4d015d22e04e4335b --- web/src/components/network/WiredConnectionPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/components/network/WiredConnectionPage.tsx b/web/src/components/network/WiredConnectionPage.tsx index 42a7bd441f..556a2faf89 100644 --- a/web/src/components/network/WiredConnectionPage.tsx +++ b/web/src/components/network/WiredConnectionPage.tsx @@ -76,7 +76,7 @@ export default function WiredConnectionPage() { {connection ? ( ) : ( - + )} From 224bfe7b253ae9eb7aac93e91de54750b3ec53dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Thu, 10 Apr 2025 16:56:28 +0100 Subject: [PATCH 41/59] fix(web): update WifiConnectionForm unit test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Also fix the "A component is changing an uncontrolled input to be controlled" warning by initializing the password to an empty string instead of undefined when no password is set yet. See https://react.dev/reference/react-dom/components/input#im-getting-an-error-a-component-is-changing-an-uncontrolled-input-to-be-controlled > If you provide a value to the component, it must remain a string > throughout its lifetime. > > You cannot pass value={undefined} first and later pass value="some > string" because React won’t know whether you want the component to be > uncontrolled or controlled --- .../network/WifiConnectionForm.test.tsx | 93 +++++++++---------- .../components/network/WifiConnectionForm.tsx | 45 ++++----- 2 files changed, 66 insertions(+), 72 deletions(-) diff --git a/web/src/components/network/WifiConnectionForm.test.tsx b/web/src/components/network/WifiConnectionForm.test.tsx index 3a3ed0fd55..fd35bab5eb 100644 --- a/web/src/components/network/WifiConnectionForm.test.tsx +++ b/web/src/components/network/WifiConnectionForm.test.tsx @@ -21,32 +21,22 @@ */ import React from "react"; -import { screen, waitFor } from "@testing-library/react"; +import { screen } from "@testing-library/react"; import { plainRender } from "~/test-utils"; -import WifiConnectionForm from "~/components/network/WifiConnectionForm"; -import { - Connection, - SecurityProtocols, - WifiNetwork, - WifiNetworkStatus, - Wireless, -} from "~/types/network"; +import WifiConnectionForm from "./WifiConnectionForm"; +import { Connection, SecurityProtocols, WifiNetworkStatus, Wireless } from "~/types/network"; const mockAddConnection = jest.fn(); const mockUpdateConnection = jest.fn(); -const mockUpdateSelectedWifi = jest.fn(); jest.mock("~/queries/network", () => ({ ...jest.requireActual("~/queries/network"), useNetworkChanges: jest.fn(), useAddConnectionMutation: () => ({ - mutate: mockAddConnection, + mutateAsync: mockAddConnection, }), useConnectionMutation: () => ({ - mutate: mockUpdateConnection, - }), - useSelectedWifiChange: () => ({ - mutate: mockUpdateSelectedWifi, + mutateAsync: mockUpdateConnection, }), })); @@ -62,40 +52,45 @@ const networkMock = { }), }; -const renderForm = (network: WifiNetwork) => plainRender(); +const publicNetworkMock = { ...networkMock, security: [] }; describe("WifiConnectionForm", () => { + beforeEach(() => { + mockAddConnection.mockResolvedValue(undefined); + mockUpdateConnection.mockResolvedValue(undefined); + }); + + describe("when rendered for a public network", () => { + it("warns the user about connecting to an unprotected network", () => { + plainRender(); + screen.getByText("Warning alert:"); + screen.getByText("Not protected network"); + }); + + it("renders only the Connect and Cancel actions", () => { + plainRender(); + expect(screen.queryByRole("combobox", { name: "Security" })).toBeNull(); + screen.getByRole("button", { name: "Connect" }); + screen.getByRole("button", { name: "Cancel" }); + }); + }); + describe("when form is submitted", () => { - // Note, not using rerender for next two test examples. It does not work always because previous - // first render somehow leaks in the next one. - it.skip("updates information about selected network (visible network version)", async () => { - const { user } = renderForm(networkMock); + it("replaces form by an informative alert ", async () => { + const { user } = plainRender(); + screen.getByRole("form", { name: "Wi-Fi connection form" }); const connectButton = screen.getByRole("button", { name: "Connect" }); await user.click(connectButton); - expect(mockUpdateSelectedWifi).toHaveBeenCalledWith({ - ssid: "Visible Network", - }); + expect(screen.queryByRole("form", { name: "Wi-Fi connection form" })).toBeNull(); + screen.getByText("Setting up connection"); }); - it.skip("disables cancel and submission actions", async () => { - const { user } = renderForm(networkMock); - const connectButton = screen.getByRole("button", { name: "Connect" }); - const cancelButton = screen.getByRole("button", { name: "Cancel" }); - - expect(connectButton).not.toBeDisabled(); - expect(cancelButton).not.toBeDisabled(); - - await waitFor(() => { - user.click(connectButton); - expect(connectButton).toBeDisabled(); - expect(cancelButton).toBeDisabled(); - }); - }); + it.todo("re-render the form with an error if connection fails"); describe("for a not configured network", () => { it("triggers a mutation for adding and connecting to the network", async () => { const { settings: _, ...notConfiguredNetwork } = networkMock; - const { user } = renderForm(notConfiguredNetwork); + const { user } = plainRender(); const securitySelector = screen.getByRole("combobox", { name: "Security" }); const connectButton = screen.getByText("Connect"); await user.selectOptions(securitySelector, "wpa-psk"); @@ -114,15 +109,19 @@ describe("WifiConnectionForm", () => { describe("for an already configured network", () => { it("triggers a mutation for updating and connecting to the network", async () => { - const { user } = renderForm({ - ...networkMock, - settings: new Connection(networkMock.ssid, { - wireless: new Wireless({ - security: "wpa-psk", - password: "wrong-wifi-password", - }), - }), - }); + const { user } = plainRender( + , + ); const connectButton = screen.getByText("Connect"); const passwordInput = screen.getByLabelText("WPA Password"); await user.clear(passwordInput); diff --git a/web/src/components/network/WifiConnectionForm.tsx b/web/src/components/network/WifiConnectionForm.tsx index 44c5eb10ef..be8b2910d2 100644 --- a/web/src/components/network/WifiConnectionForm.tsx +++ b/web/src/components/network/WifiConnectionForm.tsx @@ -24,7 +24,6 @@ import React, { useEffect, useState } from "react"; import { ActionGroup, Alert, - Button, Content, Form, FormGroup, @@ -32,12 +31,11 @@ import { FormSelectOption, Spinner, } from "@patternfly/react-core"; -import { PasswordInput } from "~/components/core"; +import { Page, PasswordInput } from "~/components/core"; import { useAddConnectionMutation, useConnectionMutation } from "~/queries/network"; import { Connection, Device, DeviceState, WifiNetwork, Wireless } from "~/types/network"; import { _ } from "~/i18n"; import { sprintf } from "sprintf-js"; -import { useNavigate } from "react-router-dom"; import { isEmpty } from "~/utils"; /* @@ -86,26 +84,31 @@ const ConnectingAlert = () => { ); }; -const ConnectionError = ({ ssid }) => ( - - {

{_("Check the authentication parameters.")}

} -
-); +const ConnectionError = ({ ssid, isPublicNetwork }) => { + // TRANSLATORS: %s will be replaced by network ssid. + const title = sprintf(_("Could not connect to %s"), ssid); + return ( + + {!isPublicNetwork && ( + {_("Check the authentication parameters.")} + )} + + ); +}; // FIXME: improve error handling. The errors props should have a key/value error // and the component should show all of them, if any export default function WifiConnectionForm({ network }: { network: WifiNetwork }) { - const navigate = useNavigate(); const settings = network.settings?.wireless || new Wireless(); const [error, setError] = useState(false); const [device, setDevice] = useState(); - const { mutateAsync: addConnection } = useAddConnectionMutation(); - const { mutateAsync: updateConnection } = useConnectionMutation(); const [security, setSecurity] = useState( settings?.security || securityFrom(network?.security || []), ); - const [password, setPassword] = useState(settings.password); + const [password, setPassword] = useState(settings.password || ""); const [isConnecting, setIsConnecting] = useState(false); + const { mutateAsync: addConnection } = useAddConnectionMutation(); + const { mutateAsync: updateConnection } = useConnectionMutation(); const accept = async (e) => { e.preventDefault(); @@ -145,8 +148,8 @@ export default function WifiConnectionForm({ network }: { network: WifiNetwork } <> {isPublicNetwork && } {/** TRANSLATORS: accessible name for the WiFi connection form */} -
- {error && } + + {error && } {/* TRANSLATORS: Wifi security configuration (password protected or not) */} {!isEmpty(network.security) && ( @@ -174,19 +177,11 @@ export default function WifiConnectionForm({ network }: { network: WifiNetwork } )} - - {/* TRANSLATORS: button label */} - + + {_("Cancel")} From e54db5fb7cabbc15f4a0bf3f7068bd37f3773f52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Thu, 10 Apr 2025 07:36:25 +0100 Subject: [PATCH 42/59] refactor(rust): move DevicesChangedStream to its own module --- rust/agama-server/src/network/nm.rs | 1 + rust/agama-server/src/network/nm/streams.rs | 23 ++ .../src/network/nm/streams/devices.rs | 304 ++++++++++++++++++ rust/agama-server/src/network/nm/watcher.rs | 291 +---------------- 4 files changed, 336 insertions(+), 283 deletions(-) create mode 100644 rust/agama-server/src/network/nm/streams.rs create mode 100644 rust/agama-server/src/network/nm/streams/devices.rs diff --git a/rust/agama-server/src/network/nm.rs b/rust/agama-server/src/network/nm.rs index dbe8eebce6..03c5c0cd15 100644 --- a/rust/agama-server/src/network/nm.rs +++ b/rust/agama-server/src/network/nm.rs @@ -31,6 +31,7 @@ mod dbus; mod error; mod model; mod proxies; +mod streams; mod watcher; pub use adapter::NetworkManagerAdapter; diff --git a/rust/agama-server/src/network/nm/streams.rs b/rust/agama-server/src/network/nm/streams.rs new file mode 100644 index 0000000000..3ef15bb5ba --- /dev/null +++ b/rust/agama-server/src/network/nm/streams.rs @@ -0,0 +1,23 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +mod devices; + +pub use devices::{DeviceChange, DeviceChangedStream, ProxiesRegistry}; diff --git a/rust/agama-server/src/network/nm/streams/devices.rs b/rust/agama-server/src/network/nm/streams/devices.rs new file mode 100644 index 0000000000..b03adac978 --- /dev/null +++ b/rust/agama-server/src/network/nm/streams/devices.rs @@ -0,0 +1,304 @@ +// Copyright (c) [2024-2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +use agama_lib::error::ServiceError; +use futures_util::ready; +use pin_project::pin_project; +use std::{ + collections::{hash_map::Entry, HashMap}, + pin::Pin, + task::{Context, Poll}, +}; +use tokio_stream::{Stream, StreamMap}; +use zbus::{ + fdo::{InterfacesAdded, InterfacesRemoved, PropertiesChanged}, + message::Type as MessageType, + names::InterfaceName, + zvariant::OwnedObjectPath, + MatchRule, Message, MessageStream, +}; + +use crate::network::nm::proxies::DeviceProxy; + +/// Stream of device-related events. +/// +/// This stream listens for many NetworkManager events that are related to network devices (state, +/// IP configuration, etc.) and converts them into variants of the [DeviceChange] enum. +/// +/// It is implemented as a struct because it needs to keep the ObjectManagerProxy alive. +#[pin_project] +pub struct DeviceChangedStream { + connection: zbus::Connection, + #[pin] + inner: StreamMap<&'static str, MessageStream>, +} + +impl DeviceChangedStream { + /// Builds a new stream using the given D-Bus connection. + /// + /// * `connection`: D-Bus connection. + pub async fn new(connection: &zbus::Connection) -> Result { + let connection = connection.clone(); + let mut inner = StreamMap::new(); + inner.insert( + "object_manager", + build_added_and_removed_stream(&connection).await?, + ); + inner.insert( + "properties", + build_properties_changed_stream(&connection).await?, + ); + Ok(Self { connection, inner }) + } + + fn handle_added(message: InterfacesAdded) -> Option { + let args = message.args().ok()?; + let interfaces: Vec = args + .interfaces_and_properties() + .keys() + .map(|i| i.to_string()) + .collect(); + + if interfaces.contains(&"org.freedesktop.NetworkManager.Device".to_string()) { + let path = OwnedObjectPath::from(args.object_path().clone()); + return Some(DeviceChange::DeviceAdded(path)); + } + + None + } + + fn handle_removed(message: InterfacesRemoved) -> Option { + let args = message.args().ok()?; + + let interface = InterfaceName::from_str_unchecked("org.freedesktop.NetworkManager.Device"); + if args.interfaces.contains(&interface) { + let path = OwnedObjectPath::from(args.object_path().clone()); + return Some(DeviceChange::DeviceRemoved(path)); + } + + None + } + + fn handle_changed(message: PropertiesChanged) -> Option { + const IP_CONFIG_PROPS: &[&str] = &["AddressData", "Gateway", "NameserverData", "RouteData"]; + const DEVICE_PROPS: &[&str] = &[ + "DeviceType", + "HwAddress", + "Interface", + "State", + "StateReason", + ]; + + let args = message.args().ok()?; + let inner = message.message(); + let path = OwnedObjectPath::from(inner.header().path()?.to_owned()); + + match args.interface_name.as_str() { + "org.freedesktop.NetworkManager.IP4Config" => { + if Self::include_properties(IP_CONFIG_PROPS, &args.changed_properties) { + return Some(DeviceChange::IP4ConfigChanged(path)); + } + } + "org.freedesktop.NetworkManager.IP6Config" => { + if Self::include_properties(IP_CONFIG_PROPS, &args.changed_properties) { + return Some(DeviceChange::IP6ConfigChanged(path)); + } + } + "org.freedesktop.NetworkManager.Device" => { + if Self::include_properties(DEVICE_PROPS, &args.changed_properties) { + return Some(DeviceChange::DeviceUpdated(path)); + } + } + _ => {} + }; + None + } + + fn include_properties( + wanted: &[&str], + changed: &HashMap<&'_ str, zbus::zvariant::Value<'_>>, + ) -> bool { + let properties: Vec<_> = changed.keys().collect(); + wanted.iter().any(|i| properties.contains(&i)) + } + + fn handle_message(message: Result) -> Option { + let Ok(message) = message else { + return None; + }; + + if let Some(added) = InterfacesAdded::from_message(message.clone()) { + return Self::handle_added(added); + } + + if let Some(removed) = InterfacesRemoved::from_message(message.clone()) { + return Self::handle_removed(removed); + } + + if let Some(changed) = PropertiesChanged::from_message(message.clone()) { + return Self::handle_changed(changed); + } + + None + } +} + +impl Stream for DeviceChangedStream { + type Item = DeviceChange; + + fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let mut pinned = self.project(); + Poll::Ready(loop { + let item = ready!(pinned.inner.as_mut().poll_next(cx)); + let next_value = match item { + Some((_, message)) => Self::handle_message(message), + _ => None, + }; + if next_value.is_some() { + break next_value; + } + }) + } +} + +async fn build_added_and_removed_stream( + connection: &zbus::Connection, +) -> Result { + let rule = MatchRule::builder() + .msg_type(MessageType::Signal) + .path("/org/freedesktop")? + .interface("org.freedesktop.DBus.ObjectManager")? + .build(); + let stream = MessageStream::for_match_rule(rule, connection, Some(1)).await?; + Ok(stream) +} + +/// Returns a stream of properties changes to be used by DeviceChangedStream. +/// +/// It listens for changes in several objects that are related to a network device. +async fn build_properties_changed_stream( + connection: &zbus::Connection, +) -> Result { + let rule = MatchRule::builder() + .msg_type(MessageType::Signal) + .interface("org.freedesktop.DBus.Properties")? + .member("PropertiesChanged")? + .build(); + let stream = MessageStream::for_match_rule(rule, connection, Some(1)).await?; + Ok(stream) +} + +#[derive(Debug, Clone)] +pub enum DeviceChange { + DeviceAdded(OwnedObjectPath), + DeviceUpdated(OwnedObjectPath), + DeviceRemoved(OwnedObjectPath), + IP4ConfigChanged(OwnedObjectPath), + IP6ConfigChanged(OwnedObjectPath), +} + +/// Ancillary class to track the devices and their related D-Bus objects. +pub struct ProxiesRegistry<'a> { + connection: zbus::Connection, + // the String is the device name like eth0 + devices: HashMap)>, +} + +impl<'a> ProxiesRegistry<'a> { + pub fn new(connection: &zbus::Connection) -> Self { + Self { + connection: connection.clone(), + devices: HashMap::new(), + } + } + + /// Finds or adds a device to the registry. + /// + /// * `path`: D-Bus object path. + pub async fn find_or_add_device( + &mut self, + path: &OwnedObjectPath, + ) -> Result<&(String, DeviceProxy<'a>), ServiceError> { + // Cannot use entry(...).or_insert_with(...) because of the async call. + match self.devices.entry(path.clone()) { + Entry::Vacant(entry) => { + let proxy = DeviceProxy::builder(&self.connection.clone()) + .path(path.clone())? + .build() + .await?; + let name = proxy.interface().await?; + + Ok(entry.insert((name, proxy))) + } + Entry::Occupied(entry) => Ok(entry.into_mut()), + } + } + + /// Removes a device from the registry. + /// + /// * `path`: D-Bus object path. + pub fn remove_device(&mut self, path: &OwnedObjectPath) -> Option<(String, DeviceProxy)> { + self.devices.remove(path) + } + + //// Updates a device name. + /// + /// * `path`: D-Bus object path. + /// * `new_name`: New device name. + pub fn update_device_name(&mut self, path: &OwnedObjectPath, new_name: &str) { + if let Some(value) = self.devices.get_mut(path) { + value.0 = new_name.to_string(); + }; + } + + //// For the device corresponding to a given IPv4 configuration. + /// + /// * `ip4_config_path`: D-Bus object path of the IPv4 configuration. + pub async fn find_device_for_ip4( + &self, + ip4_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 { + return Some(device); + } + } + } + None + } + + //// For the device corresponding to a given IPv6 configuration. + /// + /// * `ip6_config_path`: D-Bus object path of the IPv6 configuration. + pub async fn find_device_for_ip6( + &self, + ip4_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 { + return Some(device); + } + } + } + None + } +} diff --git a/rust/agama-server/src/network/nm/watcher.rs b/rust/agama-server/src/network/nm/watcher.rs index 33070c626b..1631813123 100644 --- a/rust/agama-server/src/network/nm/watcher.rs +++ b/rust/agama-server/src/network/nm/watcher.rs @@ -1,4 +1,4 @@ -// Copyright (c) [2024] SUSE LLC +// Copyright (c) [2024-2025] SUSE LLC // // All Rights Reserved. // @@ -28,24 +28,15 @@ use crate::network::{ }; use agama_lib::error::ServiceError; use async_trait::async_trait; -use futures_util::ready; -use pin_project::pin_project; -use std::{ - collections::{hash_map::Entry, HashMap}, - pin::Pin, - task::{Context, Poll}, -}; use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender}; -use tokio_stream::{Stream, StreamExt, StreamMap}; -use zbus::{ - fdo::{InterfacesAdded, InterfacesRemoved, PropertiesChanged}, - message::Type as MessageType, - names::InterfaceName, - zvariant::OwnedObjectPath, - MatchRule, Message, MessageStream, -}; +use tokio_stream::StreamExt; +use zbus::zvariant::OwnedObjectPath; -use super::{builder::DeviceFromProxyBuilder, proxies::NetworkManagerProxy}; +use super::{ + builder::DeviceFromProxyBuilder, + proxies::NetworkManagerProxy, + streams::{DeviceChange, DeviceChangedStream, ProxiesRegistry}, +}; /// Implements a [crate::network::adapter::Watcher] for NetworkManager. /// @@ -239,269 +230,3 @@ impl ActionDispatcher<'_> { builder.build().await } } - -/// Stream of device-related events. -/// -/// This stream listens for many NetworkManager events that are related to network devices (state, -/// IP configuration, etc.) and converts them into variants of the [DeviceChange] enum. -/// -/// It is implemented as a struct because it needs to keep the ObjectManagerProxy alive. -#[pin_project] -struct DeviceChangedStream { - connection: zbus::Connection, - #[pin] - inner: StreamMap<&'static str, MessageStream>, -} - -impl DeviceChangedStream { - /// Builds a new stream using the given D-Bus connection. - /// - /// * `connection`: D-Bus connection. - pub async fn new(connection: &zbus::Connection) -> Result { - let connection = connection.clone(); - let mut inner = StreamMap::new(); - inner.insert( - "object_manager", - build_added_and_removed_stream(&connection).await?, - ); - inner.insert( - "properties", - build_properties_changed_stream(&connection).await?, - ); - Ok(Self { connection, inner }) - } - - fn handle_added(message: InterfacesAdded) -> Option { - let args = message.args().ok()?; - let interfaces: Vec = args - .interfaces_and_properties() - .keys() - .map(|i| i.to_string()) - .collect(); - - if interfaces.contains(&"org.freedesktop.NetworkManager.Device".to_string()) { - let path = OwnedObjectPath::from(args.object_path().clone()); - return Some(DeviceChange::DeviceAdded(path)); - } - - None - } - - fn handle_removed(message: InterfacesRemoved) -> Option { - let args = message.args().ok()?; - - let interface = InterfaceName::from_str_unchecked("org.freedesktop.NetworkManager.Device"); - if args.interfaces.contains(&interface) { - let path = OwnedObjectPath::from(args.object_path().clone()); - return Some(DeviceChange::DeviceRemoved(path)); - } - - None - } - - fn handle_changed(message: PropertiesChanged) -> Option { - const IP_CONFIG_PROPS: &[&str] = &["AddressData", "Gateway", "NameserverData", "RouteData"]; - const DEVICE_PROPS: &[&str] = &[ - "DeviceType", - "HwAddress", - "Interface", - "State", - "StateReason", - ]; - - let args = message.args().ok()?; - let inner = message.message(); - let path = OwnedObjectPath::from(inner.header().path()?.to_owned()); - - match args.interface_name.as_str() { - "org.freedesktop.NetworkManager.IP4Config" => { - if Self::include_properties(IP_CONFIG_PROPS, &args.changed_properties) { - return Some(DeviceChange::IP4ConfigChanged(path)); - } - } - "org.freedesktop.NetworkManager.IP6Config" => { - if Self::include_properties(IP_CONFIG_PROPS, &args.changed_properties) { - return Some(DeviceChange::IP6ConfigChanged(path)); - } - } - "org.freedesktop.NetworkManager.Device" => { - if Self::include_properties(DEVICE_PROPS, &args.changed_properties) { - return Some(DeviceChange::DeviceUpdated(path)); - } - } - _ => {} - }; - None - } - - fn include_properties( - wanted: &[&str], - changed: &HashMap<&'_ str, zbus::zvariant::Value<'_>>, - ) -> bool { - let properties: Vec<_> = changed.keys().collect(); - wanted.iter().any(|i| properties.contains(&i)) - } - - fn handle_message(message: Result) -> Option { - let Ok(message) = message else { - return None; - }; - - if let Some(added) = InterfacesAdded::from_message(message.clone()) { - return Self::handle_added(added); - } - - if let Some(removed) = InterfacesRemoved::from_message(message.clone()) { - return Self::handle_removed(removed); - } - - if let Some(changed) = PropertiesChanged::from_message(message.clone()) { - return Self::handle_changed(changed); - } - - None - } -} - -impl Stream for DeviceChangedStream { - type Item = DeviceChange; - - fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - let mut pinned = self.project(); - Poll::Ready(loop { - let item = ready!(pinned.inner.as_mut().poll_next(cx)); - let next_value = match item { - Some((_, message)) => Self::handle_message(message), - _ => None, - }; - if next_value.is_some() { - break next_value; - } - }) - } -} - -async fn build_added_and_removed_stream( - connection: &zbus::Connection, -) -> Result { - let rule = MatchRule::builder() - .msg_type(MessageType::Signal) - .path("/org/freedesktop")? - .interface("org.freedesktop.DBus.ObjectManager")? - .build(); - let stream = MessageStream::for_match_rule(rule, connection, Some(1)).await?; - Ok(stream) -} - -/// Returns a stream of properties changes to be used by DeviceChangedStream. -/// -/// It listens for changes in several objects that are related to a network device. -async fn build_properties_changed_stream( - connection: &zbus::Connection, -) -> Result { - let rule = MatchRule::builder() - .msg_type(MessageType::Signal) - .interface("org.freedesktop.DBus.Properties")? - .member("PropertiesChanged")? - .build(); - let stream = MessageStream::for_match_rule(rule, connection, Some(1)).await?; - Ok(stream) -} - -#[derive(Debug, Clone)] -enum DeviceChange { - DeviceAdded(OwnedObjectPath), - DeviceUpdated(OwnedObjectPath), - DeviceRemoved(OwnedObjectPath), - IP4ConfigChanged(OwnedObjectPath), - IP6ConfigChanged(OwnedObjectPath), -} - -/// Ancillary class to track the devices and their related D-Bus objects. -struct ProxiesRegistry<'a> { - connection: zbus::Connection, - // the String is the device name like eth0 - devices: HashMap)>, -} - -impl<'a> ProxiesRegistry<'a> { - pub fn new(connection: &zbus::Connection) -> Self { - Self { - connection: connection.clone(), - devices: HashMap::new(), - } - } - - /// Finds or adds a device to the registry. - /// - /// * `path`: D-Bus object path. - pub async fn find_or_add_device( - &mut self, - path: &OwnedObjectPath, - ) -> Result<&(String, DeviceProxy<'a>), ServiceError> { - // Cannot use entry(...).or_insert_with(...) because of the async call. - match self.devices.entry(path.clone()) { - Entry::Vacant(entry) => { - let proxy = DeviceProxy::builder(&self.connection.clone()) - .path(path.clone())? - .build() - .await?; - let name = proxy.interface().await?; - - Ok(entry.insert((name, proxy))) - } - Entry::Occupied(entry) => Ok(entry.into_mut()), - } - } - - /// Removes a device from the registry. - /// - /// * `path`: D-Bus object path. - pub fn remove_device(&mut self, path: &OwnedObjectPath) -> Option<(String, DeviceProxy)> { - self.devices.remove(path) - } - - //// Updates a device name. - /// - /// * `path`: D-Bus object path. - /// * `new_name`: New device name. - pub fn update_device_name(&mut self, path: &OwnedObjectPath, new_name: &str) { - if let Some(value) = self.devices.get_mut(path) { - value.0 = new_name.to_string(); - }; - } - - //// For the device corresponding to a given IPv4 configuration. - /// - /// * `ip4_config_path`: D-Bus object path of the IPv4 configuration. - pub async fn find_device_for_ip4( - &self, - ip4_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 { - return Some(device); - } - } - } - None - } - - //// For the device corresponding to a given IPv6 configuration. - /// - /// * `ip6_config_path`: D-Bus object path of the IPv6 configuration. - pub async fn find_device_for_ip6( - &self, - ip4_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 { - return Some(device); - } - } - } - None - } -} From 0060f53ecf12e7cc9b8f34e818498f16d0a26c95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Thu, 10 Apr 2025 07:37:50 +0100 Subject: [PATCH 43/59] refactor(rust): add a common module for NM streams --- rust/agama-server/src/network/nm/streams.rs | 2 + .../src/network/nm/streams/common.rs | 49 +++++++++++++++++++ .../src/network/nm/streams/connections.rs | 0 .../src/network/nm/streams/devices.rs | 31 ++---------- 4 files changed, 54 insertions(+), 28 deletions(-) create mode 100644 rust/agama-server/src/network/nm/streams/common.rs create mode 100644 rust/agama-server/src/network/nm/streams/connections.rs diff --git a/rust/agama-server/src/network/nm/streams.rs b/rust/agama-server/src/network/nm/streams.rs index 3ef15bb5ba..e4b1b26370 100644 --- a/rust/agama-server/src/network/nm/streams.rs +++ b/rust/agama-server/src/network/nm/streams.rs @@ -18,6 +18,8 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. +mod common; +mod connections; mod devices; pub use devices::{DeviceChange, DeviceChangedStream, ProxiesRegistry}; diff --git a/rust/agama-server/src/network/nm/streams/common.rs b/rust/agama-server/src/network/nm/streams/common.rs new file mode 100644 index 0000000000..dcae874e56 --- /dev/null +++ b/rust/agama-server/src/network/nm/streams/common.rs @@ -0,0 +1,49 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +use agama_lib::error::ServiceError; +use zbus::{message::Type as MessageType, MatchRule, MessageStream}; + +pub async fn build_added_and_removed_stream( + connection: &zbus::Connection, +) -> Result { + let rule = MatchRule::builder() + .msg_type(MessageType::Signal) + .path("/org/freedesktop")? + .interface("org.freedesktop.DBus.ObjectManager")? + .build(); + let stream = MessageStream::for_match_rule(rule, connection, Some(1)).await?; + Ok(stream) +} + +/// Returns a stream of properties changes to be used by DeviceChangedStream. +/// +/// It listens for changes in several objects that are related to a network device. +pub async fn build_properties_changed_stream( + connection: &zbus::Connection, +) -> Result { + let rule = MatchRule::builder() + .msg_type(MessageType::Signal) + .interface("org.freedesktop.DBus.Properties")? + .member("PropertiesChanged")? + .build(); + let stream = MessageStream::for_match_rule(rule, connection, Some(1)).await?; + Ok(stream) +} diff --git a/rust/agama-server/src/network/nm/streams/connections.rs b/rust/agama-server/src/network/nm/streams/connections.rs new file mode 100644 index 0000000000..e69de29bb2 diff --git a/rust/agama-server/src/network/nm/streams/devices.rs b/rust/agama-server/src/network/nm/streams/devices.rs index b03adac978..eca7a075a3 100644 --- a/rust/agama-server/src/network/nm/streams/devices.rs +++ b/rust/agama-server/src/network/nm/streams/devices.rs @@ -29,7 +29,7 @@ use std::{ use tokio_stream::{Stream, StreamMap}; use zbus::{ fdo::{InterfacesAdded, InterfacesRemoved, PropertiesChanged}, - message::Type as MessageType, + message::Type as essageType, names::InterfaceName, zvariant::OwnedObjectPath, MatchRule, Message, MessageStream, @@ -37,6 +37,8 @@ use zbus::{ use crate::network::nm::proxies::DeviceProxy; +use super::common::{build_added_and_removed_stream, build_properties_changed_stream}; + /// Stream of device-related events. /// /// This stream listens for many NetworkManager events that are related to network devices (state, @@ -178,33 +180,6 @@ impl Stream for DeviceChangedStream { } } -async fn build_added_and_removed_stream( - connection: &zbus::Connection, -) -> Result { - let rule = MatchRule::builder() - .msg_type(MessageType::Signal) - .path("/org/freedesktop")? - .interface("org.freedesktop.DBus.ObjectManager")? - .build(); - let stream = MessageStream::for_match_rule(rule, connection, Some(1)).await?; - Ok(stream) -} - -/// Returns a stream of properties changes to be used by DeviceChangedStream. -/// -/// It listens for changes in several objects that are related to a network device. -async fn build_properties_changed_stream( - connection: &zbus::Connection, -) -> Result { - let rule = MatchRule::builder() - .msg_type(MessageType::Signal) - .interface("org.freedesktop.DBus.Properties")? - .member("PropertiesChanged")? - .build(); - let stream = MessageStream::for_match_rule(rule, connection, Some(1)).await?; - Ok(stream) -} - #[derive(Debug, Clone)] pub enum DeviceChange { DeviceAdded(OwnedObjectPath), From e2c90dc3d7a791fe3f16bbc66863c7e199715470 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Thu, 10 Apr 2025 13:57:50 +0100 Subject: [PATCH 44/59] feat(rust): expose the connection state --- rust/agama-lib/src/network/types.rs | 40 ++++ rust/agama-server/src/network/action.rs | 4 +- rust/agama-server/src/network/model.rs | 6 +- rust/agama-server/src/network/nm/client.rs | 24 ++- rust/agama-server/src/network/nm/error.rs | 2 + rust/agama-server/src/network/nm/model.rs | 38 +++- rust/agama-server/src/network/nm/streams.rs | 4 +- .../src/network/nm/streams/common.rs | 14 +- .../src/network/nm/streams/connections.rs | 150 +++++++++++++ .../src/network/nm/streams/devices.rs | 127 ++--------- rust/agama-server/src/network/nm/watcher.rs | 204 +++++++++++++++++- rust/agama-server/src/network/system.rs | 6 + rust/agama-server/src/network/web.rs | 24 ++- 13 files changed, 506 insertions(+), 137 deletions(-) diff --git a/rust/agama-lib/src/network/types.rs b/rust/agama-lib/src/network/types.rs index eb3ef55f7c..ac33d4f4a1 100644 --- a/rust/agama-lib/src/network/types.rs +++ b/rust/agama-lib/src/network/types.rs @@ -27,6 +27,8 @@ use std::{ use thiserror::Error; use zbus; +use super::settings::NetworkConnection; + /// Network device #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type")] @@ -114,6 +116,33 @@ pub enum DeviceState { Failed, } +#[derive( + Default, + Serialize, + Deserialize, + Debug, + PartialEq, + Eq, + Clone, + Copy, + strum::Display, + strum::EnumString, + utoipa::ToSchema, +)] +#[strum(serialize_all = "camelCase")] +#[serde(rename_all = "camelCase")] +pub enum ConnectionState { + /// The connection is getting activated. + Activating, + /// The connection is activated. + Activated, + /// The connection is getting deactivated. + Deactivating, + #[default] + /// The connection is deactivated. + Deactivated, +} + #[derive(Debug, Default, Clone, Copy, PartialEq, Serialize, Deserialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub enum Status { @@ -263,6 +292,17 @@ impl From for zbus::fdo::Error { } } +// FIXME: found a better place for the HTTP types. +// +// TODO: If the client ignores the additional "state" field, this struct +// does not need to be here. +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct NetworkConnectionWithState { + #[serde(flatten)] + pub connection: NetworkConnection, + pub state: ConnectionState, +} + #[cfg(test)] mod tests { use super::*; diff --git a/rust/agama-server/src/network/action.rs b/rust/agama-server/src/network/action.rs index baa55b91c5..28437e2a36 100644 --- a/rust/agama-server/src/network/action.rs +++ b/rust/agama-server/src/network/action.rs @@ -19,7 +19,7 @@ // find current contact information at www.suse.com. use crate::network::model::{AccessPoint, Connection, Device}; -use agama_lib::network::types::DeviceType; +use agama_lib::network::types::{ConnectionState, DeviceType}; use tokio::sync::oneshot; use uuid::Uuid; @@ -62,6 +62,8 @@ pub enum Action { /// Gets all the existent devices GetDevices(Responder>), GetGeneralState(Responder), + /// Connection state changed + ChangeConnectionState(String, ConnectionState), /// Sets a controller's ports. It uses the Uuid of the controller and the IDs or interface names /// of the ports. SetPorts( diff --git a/rust/agama-server/src/network/model.rs b/rust/agama-server/src/network/model.rs index 9d0cfe9251..4cfb06b53f 100644 --- a/rust/agama-server/src/network/model.rs +++ b/rust/agama-server/src/network/model.rs @@ -26,7 +26,7 @@ use crate::network::error::NetworkStateError; use agama_lib::network::settings::{ BondSettings, IEEE8021XSettings, NetworkConnection, WirelessSettings, }; -use agama_lib::network::types::{BondMode, DeviceState, DeviceType, Status, SSID}; +use agama_lib::network::types::{BondMode, ConnectionState, DeviceState, DeviceType, Status, SSID}; use agama_lib::openapi::schemas; use cidr::IpInet; use serde::{Deserialize, Serialize}; @@ -515,6 +515,7 @@ pub struct Connection { pub config: ConnectionConfig, pub ieee_8021x_config: Option, pub autoconnect: bool, + pub state: ConnectionState, } impl Connection { @@ -587,6 +588,7 @@ impl Default for Connection { config: Default::default(), ieee_8021x_config: Default::default(), autoconnect: true, + state: Default::default(), } } } @@ -1783,6 +1785,8 @@ pub enum NetworkChange { /// original device name, which is especially useful if the /// device gets renamed. DeviceUpdated(String, Device), + /// A connection state has changed. + ConnectionStateChanged { id: String, state: ConnectionState }, } #[derive(Default, Debug, PartialEq, Clone, Serialize, utoipa::ToSchema)] diff --git a/rust/agama-server/src/network/nm/client.rs b/rust/agama-server/src/network/nm/client.rs index 5a2778cade..59ac31c85c 100644 --- a/rust/agama-server/src/network/nm/client.rs +++ b/rust/agama-server/src/network/nm/client.rs @@ -26,7 +26,7 @@ use super::dbus::{ cleanup_dbus_connection, connection_from_dbus, connection_to_dbus, controller_from_dbus, merge_dbus_connections, }; -use super::model::NmDeviceType; +use super::model::{NmConnectionState, NmDeviceType}; use super::proxies::{ AccessPointProxy, ActiveConnectionProxy, ConnectionProxy, DeviceProxy, NetworkManagerProxy, SettingsProxy, WirelessProxy, @@ -190,6 +190,7 @@ impl<'a> NetworkManagerClient<'a> { let proxy = SettingsProxy::new(&self.connection).await?; let paths = proxy.list_connections().await?; let mut connections: Vec = Vec::with_capacity(paths.len()); + let states = self.connection_states().await?; for path in paths { let proxy = ConnectionProxy::builder(&self.connection) .path(path.as_str())? @@ -207,6 +208,13 @@ impl<'a> NetworkManagerClient<'a> { match connection_from_dbus(settings) { Ok(mut connection) => { + let state = states + .get(&connection.id) + .map(|s| NmConnectionState(s.clone())); + if let Some(state) = state { + connection.state = state.try_into().unwrap_or_default(); + } + Self::add_secrets(&mut connection.config, &proxy).await?; if let Some(controller) = controller { controlled_by.insert(connection.uuid, controller.to_string()); @@ -244,6 +252,20 @@ impl<'a> NetworkManagerClient<'a> { Ok(connections) } + pub async fn connection_states(&self) -> Result, ServiceError> { + let mut states = HashMap::new(); + + for active_path in &self.nm_proxy.active_connections().await? { + let proxy = ActiveConnectionProxy::builder(&self.connection) + .path(active_path.as_str())? + .build() + .await?; + states.insert(proxy.id().await?, proxy.state().await?); + } + + Ok(states) + } + /// Adds or updates a connection if it already exists. /// /// * `conn`: connection to add or update. diff --git a/rust/agama-server/src/network/nm/error.rs b/rust/agama-server/src/network/nm/error.rs index 6a717b154d..cb5487874e 100644 --- a/rust/agama-server/src/network/nm/error.rs +++ b/rust/agama-server/src/network/nm/error.rs @@ -30,6 +30,8 @@ pub enum NmError { UnsupportedIpMethod(String), #[error("Unsupported device type: '{0}'")] UnsupportedDeviceType(u32), + #[error("Unsupported connection state: '{0}'")] + UnsupportedConnectionState(u32), #[error("Unsupported security protocol: '{0}'")] UnsupportedSecurityProtocol(String), #[error("Unsupported wireless mode: '{0}'")] diff --git a/rust/agama-server/src/network/nm/model.rs b/rust/agama-server/src/network/nm/model.rs index 23a88e224a..e41144fa29 100644 --- a/rust/agama-server/src/network/nm/model.rs +++ b/rust/agama-server/src/network/nm/model.rs @@ -30,7 +30,7 @@ use crate::network::{ model::{Ipv4Method, Ipv6Method, SecurityProtocol, WirelessMode}, nm::error::NmError, }; -use agama_lib::network::types::DeviceType; +use agama_lib::network::types::{ConnectionState, DeviceType}; use std::fmt; use std::str::FromStr; @@ -131,7 +131,7 @@ pub enum NmDeviceState { } #[derive(Debug, thiserror::Error, PartialEq)] -#[error("Invalid state: {0}")] +#[error("Unsupported device state: {0}")] pub struct InvalidNmDeviceState(String); impl TryFrom for NmDeviceState { @@ -157,6 +157,40 @@ impl TryFrom for NmDeviceState { } } +/// Connection type +/// +/// As we are just converting the number to its high-level representation, +/// a newtype might be enough. +#[derive(Debug, Default, Clone, Copy)] +pub struct NmConnectionState(pub u32); + +impl From for u32 { + fn from(value: NmConnectionState) -> u32 { + value.0 + } +} + +impl fmt::Display for NmConnectionState { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl TryFrom for ConnectionState { + type Error = NmError; + + fn try_from(value: NmConnectionState) -> Result { + match value { + NmConnectionState(0) => Ok(ConnectionState::Deactivated), + NmConnectionState(1) => Ok(ConnectionState::Activating), + NmConnectionState(2) => Ok(ConnectionState::Activated), + NmConnectionState(3) => Ok(ConnectionState::Deactivating), + NmConnectionState(4) => Ok(ConnectionState::Deactivated), + NmConnectionState(_) => Err(NmError::UnsupportedConnectionState(value.into())), + } + } +} + /// Key management /// /// Using the newtype pattern around an String is enough. For proper support, we might replace this diff --git a/rust/agama-server/src/network/nm/streams.rs b/rust/agama-server/src/network/nm/streams.rs index e4b1b26370..7e402d559e 100644 --- a/rust/agama-server/src/network/nm/streams.rs +++ b/rust/agama-server/src/network/nm/streams.rs @@ -22,4 +22,6 @@ mod common; mod connections; mod devices; -pub use devices::{DeviceChange, DeviceChangedStream, ProxiesRegistry}; +pub use common::NmChange; +pub use connections::ActiveConnectionChangedStream; +pub use devices::DeviceChangedStream; diff --git a/rust/agama-server/src/network/nm/streams/common.rs b/rust/agama-server/src/network/nm/streams/common.rs index dcae874e56..010d920903 100644 --- a/rust/agama-server/src/network/nm/streams/common.rs +++ b/rust/agama-server/src/network/nm/streams/common.rs @@ -19,7 +19,19 @@ // find current contact information at www.suse.com. use agama_lib::error::ServiceError; -use zbus::{message::Type as MessageType, MatchRule, MessageStream}; +use zbus::{message::Type as MessageType, zvariant::OwnedObjectPath, MatchRule, MessageStream}; + +#[derive(Debug, Clone)] +pub enum NmChange { + DeviceAdded(OwnedObjectPath), + DeviceUpdated(OwnedObjectPath), + DeviceRemoved(OwnedObjectPath), + IP4ConfigChanged(OwnedObjectPath), + IP6ConfigChanged(OwnedObjectPath), + ActiveConnectionAdded(OwnedObjectPath), + ActiveConnectionUpdated(OwnedObjectPath), + ActiveConnectionRemoved(OwnedObjectPath), +} pub async fn build_added_and_removed_stream( connection: &zbus::Connection, diff --git a/rust/agama-server/src/network/nm/streams/connections.rs b/rust/agama-server/src/network/nm/streams/connections.rs index e69de29bb2..207228eeb3 100644 --- a/rust/agama-server/src/network/nm/streams/connections.rs +++ b/rust/agama-server/src/network/nm/streams/connections.rs @@ -0,0 +1,150 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +use agama_lib::error::ServiceError; +use futures_util::ready; +use pin_project::pin_project; +use std::{ + pin::Pin, + task::{Context, Poll}, +}; +use tokio_stream::{Stream, StreamMap}; +use zbus::{ + fdo::{InterfacesAdded, InterfacesRemoved, PropertiesChanged}, + names::InterfaceName, + zvariant::OwnedObjectPath, + Message, MessageStream, +}; + +use super::{ + common::{build_added_and_removed_stream, build_properties_changed_stream}, + NmChange, +}; + +/// Stream of active connections state changes. +/// +/// This stream listens for active connection state changes and converts +/// them into [ConnectionStateChange] events. +/// +/// It is implemented as a struct because it needs to keep the ProxiesRegistry alive. +#[pin_project] +pub struct ActiveConnectionChangedStream { + connection: zbus::Connection, + #[pin] + inner: StreamMap<&'static str, MessageStream>, +} + +impl ActiveConnectionChangedStream { + /// Builds a new stream using the given D-Bus connection. + /// + /// * `connection`: D-Bus connection. + pub async fn new(connection: &zbus::Connection) -> Result { + let connection = connection.clone(); + let mut inner = StreamMap::new(); + inner.insert( + "object_manager", + build_added_and_removed_stream(&connection).await?, + ); + inner.insert( + "properties", + build_properties_changed_stream(&connection).await?, + ); + Ok(Self { connection, inner }) + } + + fn handle_added(message: InterfacesAdded) -> Option { + let args = message.args().ok()?; + let interfaces: Vec = args + .interfaces_and_properties() + .keys() + .map(|i| i.to_string()) + .collect(); + + if interfaces.contains(&"org.freedesktop.NetworkManager.Connection.Active".to_string()) { + let path = OwnedObjectPath::from(args.object_path().clone()); + return Some(NmChange::ActiveConnectionAdded(path)); + } + + None + } + + fn handle_removed(message: InterfacesRemoved) -> Option { + let args = message.args().ok()?; + + let interface = + InterfaceName::from_str_unchecked("org.freedesktop.NetworkManager.Connection.Active"); + if args.interfaces.contains(&interface) { + let path = OwnedObjectPath::from(args.object_path().clone()); + return Some(NmChange::ActiveConnectionRemoved(path)); + } + + None + } + + fn handle_changed(message: PropertiesChanged) -> Option { + let args = message.args().ok()?; + let inner = message.message(); + let path = OwnedObjectPath::from(inner.header().path()?.to_owned()); + + if args.interface_name.as_str() == "org.freedesktop.NetworkManager.Connection.Active" { + return Some(NmChange::ActiveConnectionUpdated(path)); + } + + None + } + + fn handle_message(message: Result) -> Option { + let Ok(message) = message else { + return None; + }; + + if let Some(added) = InterfacesAdded::from_message(message.clone()) { + return Self::handle_added(added); + } + + if let Some(removed) = InterfacesRemoved::from_message(message.clone()) { + return Self::handle_removed(removed); + } + + if let Some(changed) = PropertiesChanged::from_message(message.clone()) { + return Self::handle_changed(changed); + } + + None + } +} + +impl Stream for ActiveConnectionChangedStream { + type Item = NmChange; + + fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let mut pinned = self.project(); + Poll::Ready(loop { + let item = ready!(pinned.inner.as_mut().poll_next(cx)); + let next_value = match item { + Some((_, message)) => Self::handle_message(message), + _ => None, + }; + if next_value.is_some() { + break next_value; + } + }) + } +} diff --git a/rust/agama-server/src/network/nm/streams/devices.rs b/rust/agama-server/src/network/nm/streams/devices.rs index eca7a075a3..be031ee08f 100644 --- a/rust/agama-server/src/network/nm/streams/devices.rs +++ b/rust/agama-server/src/network/nm/streams/devices.rs @@ -22,22 +22,19 @@ use agama_lib::error::ServiceError; use futures_util::ready; use pin_project::pin_project; use std::{ - collections::{hash_map::Entry, HashMap}, + collections::HashMap, pin::Pin, task::{Context, Poll}, }; use tokio_stream::{Stream, StreamMap}; use zbus::{ fdo::{InterfacesAdded, InterfacesRemoved, PropertiesChanged}, - message::Type as essageType, names::InterfaceName, zvariant::OwnedObjectPath, - MatchRule, Message, MessageStream, + Message, MessageStream, }; -use crate::network::nm::proxies::DeviceProxy; - -use super::common::{build_added_and_removed_stream, build_properties_changed_stream}; +use super::common::{build_added_and_removed_stream, build_properties_changed_stream, NmChange}; /// Stream of device-related events. /// @@ -70,7 +67,7 @@ impl DeviceChangedStream { Ok(Self { connection, inner }) } - fn handle_added(message: InterfacesAdded) -> Option { + fn handle_added(message: InterfacesAdded) -> Option { let args = message.args().ok()?; let interfaces: Vec = args .interfaces_and_properties() @@ -80,25 +77,25 @@ impl DeviceChangedStream { if interfaces.contains(&"org.freedesktop.NetworkManager.Device".to_string()) { let path = OwnedObjectPath::from(args.object_path().clone()); - return Some(DeviceChange::DeviceAdded(path)); + return Some(NmChange::DeviceAdded(path)); } None } - fn handle_removed(message: InterfacesRemoved) -> Option { + fn handle_removed(message: InterfacesRemoved) -> Option { let args = message.args().ok()?; let interface = InterfaceName::from_str_unchecked("org.freedesktop.NetworkManager.Device"); if args.interfaces.contains(&interface) { let path = OwnedObjectPath::from(args.object_path().clone()); - return Some(DeviceChange::DeviceRemoved(path)); + return Some(NmChange::DeviceRemoved(path)); } None } - fn handle_changed(message: PropertiesChanged) -> Option { + fn handle_changed(message: PropertiesChanged) -> Option { const IP_CONFIG_PROPS: &[&str] = &["AddressData", "Gateway", "NameserverData", "RouteData"]; const DEVICE_PROPS: &[&str] = &[ "DeviceType", @@ -115,17 +112,17 @@ impl DeviceChangedStream { match args.interface_name.as_str() { "org.freedesktop.NetworkManager.IP4Config" => { if Self::include_properties(IP_CONFIG_PROPS, &args.changed_properties) { - return Some(DeviceChange::IP4ConfigChanged(path)); + return Some(NmChange::IP4ConfigChanged(path)); } } "org.freedesktop.NetworkManager.IP6Config" => { if Self::include_properties(IP_CONFIG_PROPS, &args.changed_properties) { - return Some(DeviceChange::IP6ConfigChanged(path)); + return Some(NmChange::IP6ConfigChanged(path)); } } "org.freedesktop.NetworkManager.Device" => { if Self::include_properties(DEVICE_PROPS, &args.changed_properties) { - return Some(DeviceChange::DeviceUpdated(path)); + return Some(NmChange::DeviceUpdated(path)); } } _ => {} @@ -141,7 +138,7 @@ impl DeviceChangedStream { wanted.iter().any(|i| properties.contains(&i)) } - fn handle_message(message: Result) -> Option { + fn handle_message(message: Result) -> Option { let Ok(message) = message else { return None; }; @@ -163,7 +160,7 @@ impl DeviceChangedStream { } impl Stream for DeviceChangedStream { - type Item = DeviceChange; + type Item = NmChange; fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { let mut pinned = self.project(); @@ -179,101 +176,3 @@ impl Stream for DeviceChangedStream { }) } } - -#[derive(Debug, Clone)] -pub enum DeviceChange { - DeviceAdded(OwnedObjectPath), - DeviceUpdated(OwnedObjectPath), - DeviceRemoved(OwnedObjectPath), - IP4ConfigChanged(OwnedObjectPath), - IP6ConfigChanged(OwnedObjectPath), -} - -/// Ancillary class to track the devices and their related D-Bus objects. -pub struct ProxiesRegistry<'a> { - connection: zbus::Connection, - // the String is the device name like eth0 - devices: HashMap)>, -} - -impl<'a> ProxiesRegistry<'a> { - pub fn new(connection: &zbus::Connection) -> Self { - Self { - connection: connection.clone(), - devices: HashMap::new(), - } - } - - /// Finds or adds a device to the registry. - /// - /// * `path`: D-Bus object path. - pub async fn find_or_add_device( - &mut self, - path: &OwnedObjectPath, - ) -> Result<&(String, DeviceProxy<'a>), ServiceError> { - // Cannot use entry(...).or_insert_with(...) because of the async call. - match self.devices.entry(path.clone()) { - Entry::Vacant(entry) => { - let proxy = DeviceProxy::builder(&self.connection.clone()) - .path(path.clone())? - .build() - .await?; - let name = proxy.interface().await?; - - Ok(entry.insert((name, proxy))) - } - Entry::Occupied(entry) => Ok(entry.into_mut()), - } - } - - /// Removes a device from the registry. - /// - /// * `path`: D-Bus object path. - pub fn remove_device(&mut self, path: &OwnedObjectPath) -> Option<(String, DeviceProxy)> { - self.devices.remove(path) - } - - //// Updates a device name. - /// - /// * `path`: D-Bus object path. - /// * `new_name`: New device name. - pub fn update_device_name(&mut self, path: &OwnedObjectPath, new_name: &str) { - if let Some(value) = self.devices.get_mut(path) { - value.0 = new_name.to_string(); - }; - } - - //// For the device corresponding to a given IPv4 configuration. - /// - /// * `ip4_config_path`: D-Bus object path of the IPv4 configuration. - pub async fn find_device_for_ip4( - &self, - ip4_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 { - return Some(device); - } - } - } - None - } - - //// For the device corresponding to a given IPv6 configuration. - /// - /// * `ip6_config_path`: D-Bus object path of the IPv6 configuration. - pub async fn find_device_for_ip6( - &self, - ip4_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 { - return Some(device); - } - } - } - None - } -} diff --git a/rust/agama-server/src/network/nm/watcher.rs b/rust/agama-server/src/network/nm/watcher.rs index 1631813123..065eca6550 100644 --- a/rust/agama-server/src/network/nm/watcher.rs +++ b/rust/agama-server/src/network/nm/watcher.rs @@ -23,6 +23,8 @@ //! Monitors NetworkManager's D-Bus service and emit [actions](crate::network::Action] to update //! the NetworkSystem state when devices or active connections change. +use std::collections::{hash_map::Entry, HashMap}; + use crate::network::{ adapter::Watcher, model::Device, nm::proxies::DeviceProxy, Action, NetworkAdapterError, }; @@ -34,8 +36,9 @@ use zbus::zvariant::OwnedObjectPath; use super::{ builder::DeviceFromProxyBuilder, - proxies::NetworkManagerProxy, - streams::{DeviceChange, DeviceChangedStream, ProxiesRegistry}, + model::NmConnectionState, + proxies::{ActiveConnectionProxy, NetworkManagerProxy}, + streams::{ActiveConnectionChangedStream, DeviceChangedStream, NmChange}, }; /// Implements a [crate::network::adapter::Watcher] for NetworkManager. @@ -77,9 +80,24 @@ impl Watcher for NetworkManagerWatcher { // Process the DeviceChangedStream in a separate task. let connection = self.connection.clone(); + let tx_clone = tx.clone(); tokio::spawn(async move { let mut stream = DeviceChangedStream::new(&connection).await.unwrap(); + while let Some(change) = stream.next().await { + if let Err(e) = tx_clone.send(change) { + tracing::error!("Could not dispatch a network change: {e}"); + } + } + }); + + // Process the ConnectionChangedStream in a separate task. + let connection = self.connection.clone(); + tokio::spawn(async move { + let mut stream = ActiveConnectionChangedStream::new(&connection) + .await + .unwrap(); + while let Some(change) = stream.next().await { if let Err(e) = tx.send(change) { tracing::error!("Could not dispatch a network change: {e}"); @@ -100,7 +118,7 @@ impl Watcher for NetworkManagerWatcher { struct ActionDispatcher<'a> { connection: zbus::Connection, proxies: ProxiesRegistry<'a>, - updates_rx: UnboundedReceiver, + updates_rx: UnboundedReceiver, actions_tx: UnboundedSender, } @@ -112,7 +130,7 @@ impl ActionDispatcher<'_> { /// * `actions_tx`: Channel to dispatch the network actions. pub fn new( connection: zbus::Connection, - updates_rx: UnboundedReceiver, + updates_rx: UnboundedReceiver, actions_tx: UnboundedSender, ) -> Self { Self { @@ -130,11 +148,17 @@ impl ActionDispatcher<'_> { self.read_devices().await?; while let Some(update) = self.updates_rx.recv().await { let result = match update { - DeviceChange::DeviceAdded(path) => self.handle_device_added(path).await, - DeviceChange::DeviceUpdated(path) => self.handle_device_updated(path).await, - DeviceChange::DeviceRemoved(path) => self.handle_device_removed(path).await, - DeviceChange::IP4ConfigChanged(path) => self.handle_ip4_config_changed(path).await, - DeviceChange::IP6ConfigChanged(path) => self.handle_ip6_config_changed(path).await, + NmChange::DeviceAdded(path) => self.handle_device_added(path).await, + NmChange::DeviceUpdated(path) => self.handle_device_updated(path).await, + NmChange::DeviceRemoved(path) => self.handle_device_removed(path).await, + NmChange::IP4ConfigChanged(path) => self.handle_ip4_config_changed(path).await, + NmChange::IP6ConfigChanged(path) => self.handle_ip6_config_changed(path).await, + NmChange::ActiveConnectionAdded(path) | NmChange::ActiveConnectionUpdated(path) => { + self.handle_active_connection_updated(path).await + } + NmChange::ActiveConnectionRemoved(path) => { + self.handle_active_connection_removed(path).await + } }; if let Err(error) = result { @@ -222,6 +246,46 @@ impl ActionDispatcher<'_> { Ok(()) } + /// Handles the case where a new active connection appears. + /// + /// * `path`: D-Bus object path of the new active connection. + async fn handle_active_connection_updated( + &mut self, + path: OwnedObjectPath, + ) -> Result<(), ServiceError> { + let proxy = self.proxies.find_or_add_active_connection(&path).await?; + let id = proxy.id().await?; + let state = proxy.state().await.map(|s| NmConnectionState(s.clone()))?; + if let Ok(state) = state.try_into() { + _ = self + .actions_tx + .send(Action::ChangeConnectionState(id, state)); + } + // TODO: report an error if the device cannot get generated + + Ok(()) + } + + /// Handles the case where a device is removed. + /// + /// * `path`: D-Bus object path of the removed device. + async fn handle_active_connection_removed( + &mut self, + path: OwnedObjectPath, + ) -> Result<(), ServiceError> { + if let Some(proxy) = self.proxies.remove_active_connection(&path) { + let id = proxy.id().await?; + let state = proxy.state().await.map(|s| NmConnectionState(s.clone()))?; + if let Ok(state) = state.try_into() { + _ = self + .actions_tx + .send(Action::ChangeConnectionState(id, state)); + } + } + + Ok(()) + } + async fn device_from_proxy( connection: &zbus::Connection, proxy: DeviceProxy<'_>, @@ -230,3 +294,125 @@ impl ActionDispatcher<'_> { builder.build().await } } + +/// Ancillary class to track the devices and their related D-Bus objects. +pub struct ProxiesRegistry<'a> { + connection: zbus::Connection, + // the String is the device name like eth0 + devices: HashMap)>, + active_connections: HashMap>, +} + +impl<'a> ProxiesRegistry<'a> { + pub fn new(connection: &zbus::Connection) -> Self { + Self { + connection: connection.clone(), + devices: HashMap::new(), + active_connections: HashMap::new(), + } + } + + /// Finds or adds a device to the registry. + /// + /// * `path`: D-Bus object path. + pub async fn find_or_add_device( + &mut self, + path: &OwnedObjectPath, + ) -> Result<&(String, DeviceProxy<'a>), ServiceError> { + // Cannot use entry(...).or_insert_with(...) because of the async call. + match self.devices.entry(path.clone()) { + Entry::Vacant(entry) => { + let proxy = DeviceProxy::builder(&self.connection.clone()) + .path(path.clone())? + .build() + .await?; + let name = proxy.interface().await?; + + Ok(entry.insert((name, proxy))) + } + Entry::Occupied(entry) => Ok(entry.into_mut()), + } + } + + /// Finds or adds an active connection to the registry. + /// + /// * `path`: D-Bus object path. + pub async fn find_or_add_active_connection( + &mut self, + path: &OwnedObjectPath, + ) -> Result<&ActiveConnectionProxy<'a>, ServiceError> { + // Cannot use entry(...).or_insert_with(...) because of the async call. + match self.active_connections.entry(path.clone()) { + Entry::Vacant(entry) => { + let proxy = ActiveConnectionProxy::builder(&self.connection.clone()) + .path(path.clone())? + .build() + .await?; + + Ok(entry.insert(proxy)) + } + Entry::Occupied(entry) => Ok(entry.into_mut()), + } + } + + /// Removes an active connection from the registry. + /// + /// * `path`: D-Bus object path. + pub fn remove_active_connection( + &mut self, + path: &OwnedObjectPath, + ) -> Option { + self.active_connections.remove(path) + } + + /// Removes a device from the registry. + /// + /// * `path`: D-Bus object path. + pub fn remove_device(&mut self, path: &OwnedObjectPath) -> Option<(String, DeviceProxy)> { + self.devices.remove(path) + } + + //// Updates a device name. + /// + /// * `path`: D-Bus object path. + /// * `new_name`: New device name. + pub fn update_device_name(&mut self, path: &OwnedObjectPath, new_name: &str) { + if let Some(value) = self.devices.get_mut(path) { + value.0 = new_name.to_string(); + }; + } + + //// For the device corresponding to a given IPv4 configuration. + /// + /// * `ip4_config_path`: D-Bus object path of the IPv4 configuration. + pub async fn find_device_for_ip4( + &self, + ip4_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 { + return Some(device); + } + } + } + None + } + + //// For the device corresponding to a given IPv6 configuration. + /// + /// * `ip6_config_path`: D-Bus object path of the IPv6 configuration. + pub async fn find_device_for_ip6( + &self, + ip4_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 { + return Some(device); + } + } + } + None + } +} diff --git a/rust/agama-server/src/network/system.rs b/rust/agama-server/src/network/system.rs index 00290cd08c..0edd5f45ef 100644 --- a/rust/agama-server/src/network/system.rs +++ b/rust/agama-server/src/network/system.rs @@ -350,6 +350,12 @@ impl NetworkSystemServer { let result = self.state.update_connection(*conn); tx.send(result).unwrap(); } + Action::ChangeConnectionState(id, state) => { + if let Some(conn) = self.state.get_connection_mut(&id) { + conn.state = state; + return Ok(Some(NetworkChange::ConnectionStateChanged { id, state })); + } + } Action::UpdateGeneralState(general_state) => { self.state.general_state = general_state; } diff --git a/rust/agama-server/src/network/web.rs b/rust/agama-server/src/network/web.rs index 5c0218fd8a..a8f42db8f2 100644 --- a/rust/agama-server/src/network/web.rs +++ b/rust/agama-server/src/network/web.rs @@ -41,7 +41,10 @@ use super::{ }; use crate::network::{model::Connection, model::Device, NetworkSystem}; -use agama_lib::{error::ServiceError, network::settings::NetworkConnection}; +use agama_lib::{ + error::ServiceError, + network::{settings::NetworkConnection, types::NetworkConnectionWithState}, +}; use serde_json::json; use thiserror::Error; @@ -209,14 +212,21 @@ async fn devices( )] async fn connections( State(state): State, -) -> Result>, NetworkError> { +) -> Result>, NetworkError> { let connections = state.network.get_connections().await?; - let connections = connections - .iter() - .map(|c| NetworkConnection::try_from(c.clone()).unwrap()) - .collect(); + let mut result = vec![]; + + for conn in connections { + let state = conn.state.clone(); + let network_connection = NetworkConnection::try_from(conn)?; + let connection_with_state = NetworkConnectionWithState { + connection: network_connection, + state, + }; + result.push(connection_with_state); + } - Ok(Json(connections)) + Ok(Json(result)) } #[utoipa::path( From 5bb1c60ab85ba69037ca072113142b7ac19e1382 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Thu, 10 Apr 2025 23:37:11 +0100 Subject: [PATCH 45/59] feat(web): add network connections state --- web/src/queries/network.ts | 60 ++++++++++++++++++++++++++++---------- web/src/types/network.ts | 9 ++++++ 2 files changed, 53 insertions(+), 16 deletions(-) diff --git a/web/src/queries/network.ts b/web/src/queries/network.ts index 176b739cee..53a7313abf 100644 --- a/web/src/queries/network.ts +++ b/web/src/queries/network.ts @@ -20,7 +20,7 @@ * find current contact information at www.suse.com. */ -import React from "react"; +import React, { useCallback } from "react"; import { useQueryClient, useMutation, useSuspenseQuery } from "@tanstack/react-query"; import { useInstallerClient } from "~/context/installer"; import { @@ -169,40 +169,68 @@ const useNetworkChanges = () => { const queryClient = useQueryClient(); const client = useInstallerClient(); + const updateDevices = useCallback( + (func: (devices: Device[]) => Device[]) => { + const devices: Device[] = queryClient.getQueryData(["network", "devices"]); + if (!devices) return; + + const updatedDevices = func(devices); + queryClient.setQueryData(["network", "devices"], updatedDevices); + }, + [queryClient], + ); + + const updateConnectionState = useCallback( + (id: string, state: string) => { + const connections: Connection[] = queryClient.getQueryData(["network", "connections"]); + if (!connections) return; + + const updatedConnections = connections.map((conn) => { + if (conn.id === id) { + return { ...conn, state }; + } + return conn; + }); + queryClient.setQueryData(["network", "connections"], updatedConnections); + }, + [queryClient], + ); + React.useEffect(() => { if (!client) return; return client.onEvent((event) => { if (event.type === "NetworkChange") { - const devices: Device[] = queryClient.getQueryData(["network", "devices"]); - if (!devices) return; - - let updatedDevices = []; if (event.deviceAdded) { - const device = Device.fromApi(event.deviceAdded); - updatedDevices = [...devices, device]; + const newDevice = Device.fromApi(event.deviceAdded); + updateDevices((devices) => [...devices, newDevice]); } if (event.deviceUpdated) { const [name, apiDevice] = event.deviceUpdated; const device = Device.fromApi(apiDevice); - updatedDevices = devices.map((d) => { - if (d.name === name) { - return device; - } + updateDevices((devices) => + devices.map((d) => { + if (d.name === name) { + return device; + } - return d; - }); + return d; + }), + ); } if (event.deviceRemoved) { - updatedDevices = devices.filter((d) => d !== event.deviceRemoved); + updateDevices((devices) => devices.filter((d) => d !== event.deviceRemoved)); } - queryClient.setQueryData(["network", "devices"], updatedDevices); + if (event.connectionStateChanged) { + const { id, state } = event.connectionStateChanged; + updateConnectionState(id, state); + } } }); - }, [client, queryClient]); + }, [client, queryClient, updateDevices, updateConnectionState]); }; const useConnection = (name: string) => { diff --git a/web/src/types/network.ts b/web/src/types/network.ts index 5fc28a70f1..bd3e057ee7 100644 --- a/web/src/types/network.ts +++ b/web/src/types/network.ts @@ -86,6 +86,14 @@ enum ConnectionStatus { DOWN = "down", } +// Current state of the connection. +enum ConnectionState { + activating = "activating", + activated = "activated", + deactivating = "deactivating", + deactivated = "deactivated", +} + enum ConnectionMethod { MANUAL = "manual", AUTO = "auto", @@ -223,6 +231,7 @@ type APIConnection = { method6: string; wireless?: Wireless; status: ConnectionStatus; + state: ConnectionState; }; type WirelessOptions = { From 37c76829ae74d1d71f2205d0fc3d6f53183bb573 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Fri, 11 Apr 2025 14:07:04 +0100 Subject: [PATCH 46/59] fix(web): add missing property to network connection type --- web/src/types/network.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/web/src/types/network.ts b/web/src/types/network.ts index bd3e057ee7..86fa502756 100644 --- a/web/src/types/network.ts +++ b/web/src/types/network.ts @@ -272,6 +272,7 @@ type ConnectionOptions = { class Connection { id: string; status: ConnectionStatus = ConnectionStatus.UP; + state: ConnectionState; iface: string; addresses: IPAddress[] = []; nameservers: string[] = []; @@ -349,6 +350,7 @@ export { ApFlags, ApSecurityFlags, Connection, + ConnectionState, ConnectionStatus, ConnectionMethod, ConnectionType, From 70db700e2f3cd8a59e13c79812d250a052dc316a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Fri, 11 Apr 2025 14:14:49 +0100 Subject: [PATCH 47/59] fix(web): avoid crashing because malformed connection A value returned by a network query was a plain object instead of a Connection object, making a later mutation crash because of ".toApi" not defined. --- web/src/queries/network.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/web/src/queries/network.ts b/web/src/queries/network.ts index 53a7313abf..9d30108a9b 100644 --- a/web/src/queries/network.ts +++ b/web/src/queries/network.ts @@ -26,6 +26,7 @@ import { useInstallerClient } from "~/context/installer"; import { AccessPoint, Connection, + ConnectionState, Device, DeviceState, NetworkGeneralState, @@ -67,7 +68,7 @@ const devicesQuery = () => ({ }); /** - * Returns a query for retrieving data for the given conneciton name + * Returns a query for retrieving data for the given connection name */ const connectionQuery = (name: string) => ({ queryKey: ["network", "connections", name], @@ -187,7 +188,9 @@ const useNetworkChanges = () => { const updatedConnections = connections.map((conn) => { if (conn.id === id) { - return { ...conn, state }; + const { id: _, ...nextConnection } = conn; + nextConnection.state = state as ConnectionState; + return new Connection(id, nextConnection); } return conn; }); From 487ba673a6d3085a55d8028fef164b8d13769629 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Fri, 11 Apr 2025 14:16:24 +0100 Subject: [PATCH 48/59] web: make WifiConnectionForm less fragile After adapting it to latest changes in the backend to know a conneciton state. It still having room for improvements, though. --- .../network/WifiConnectionForm.test.tsx | 1 + .../components/network/WifiConnectionForm.tsx | 62 ++++++++++--------- 2 files changed, 35 insertions(+), 28 deletions(-) diff --git a/web/src/components/network/WifiConnectionForm.test.tsx b/web/src/components/network/WifiConnectionForm.test.tsx index fd35bab5eb..e12a17589e 100644 --- a/web/src/components/network/WifiConnectionForm.test.tsx +++ b/web/src/components/network/WifiConnectionForm.test.tsx @@ -38,6 +38,7 @@ jest.mock("~/queries/network", () => ({ useConnectionMutation: () => ({ mutateAsync: mockUpdateConnection, }), + useConnections: () => [], })); const networkMock = { diff --git a/web/src/components/network/WifiConnectionForm.tsx b/web/src/components/network/WifiConnectionForm.tsx index be8b2910d2..5419f3dc3e 100644 --- a/web/src/components/network/WifiConnectionForm.tsx +++ b/web/src/components/network/WifiConnectionForm.tsx @@ -32,8 +32,8 @@ import { Spinner, } from "@patternfly/react-core"; import { Page, PasswordInput } from "~/components/core"; -import { useAddConnectionMutation, useConnectionMutation } from "~/queries/network"; -import { Connection, Device, DeviceState, WifiNetwork, Wireless } from "~/types/network"; +import { useAddConnectionMutation, useConnectionMutation, useConnections } from "~/queries/network"; +import { Connection, ConnectionState, WifiNetwork, Wireless } from "~/types/network"; import { _ } from "~/i18n"; import { sprintf } from "sprintf-js"; import { isEmpty } from "~/utils"; @@ -99,46 +99,52 @@ const ConnectionError = ({ ssid, isPublicNetwork }) => { // 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 [device, setDevice] = useState(); const [security, setSecurity] = useState( settings?.security || securityFrom(network?.security || []), ); const [password, setPassword] = useState(settings.password || ""); - const [isConnecting, setIsConnecting] = useState(false); + const [isActivating, setIsActivating] = useState(false); + const [isConnecting, setIsConnecting] = useState( + connection?.state === ConnectionState.activating, + ); const { mutateAsync: addConnection } = useAddConnectionMutation(); const { mutateAsync: updateConnection } = useConnectionMutation(); - const accept = async (e) => { - e.preventDefault(); - setError(false); - setIsConnecting(true); - setDevice(network.device); - // FIXME: do not mutate the original object! - const connection = network.settings || new Connection(network.ssid); - connection.wireless = new Wireless({ ssid: network.ssid, security, password, hidden: false }); - const action = network.settings ? updateConnection : addConnection; - action(connection).catch(() => setError(true)); - }; - useEffect(() => { - setDevice(network.device); - }, [network.device]); - - useEffect(() => { - if (!device) return; - - if (device?.state === DeviceState.CONNECTING) { - setError(false); - setIsConnecting(true); - } + if (!isActivating) return; - if (isConnecting && device && device.state === DeviceState.FAILED) { + if (connection.state === ConnectionState.deactivated) { setError(true); setIsConnecting(false); + setIsActivating(false); + } + }, [isActivating, connection?.state]); + + useEffect(() => { + if (isConnecting && connection?.state === ConnectionState.activating) { + setIsActivating(true); } - }, [isConnecting, device]); + }, [isConnecting, connection]); + + 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, + password, + hidden: false, + }); + const action = network.settings ? updateConnection : addConnection; + action(nextConnection).catch(() => setError(true)); + setError(false); + setIsConnecting(true); + }; const isPublicNetwork = isEmpty(network.security); From 39207992adf5b4488abc00d30e5f5899fa62458e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Fri, 11 Apr 2025 14:33:42 +0100 Subject: [PATCH 49/59] web: fix broken unit test Although some examples has to be skipped by now to evaluate if they actually belongs to the test. --- .../network/WifiNetworksList.test.tsx | 59 +++++++++---------- 1 file changed, 29 insertions(+), 30 deletions(-) diff --git a/web/src/components/network/WifiNetworksList.test.tsx b/web/src/components/network/WifiNetworksList.test.tsx index c0e56c9df1..9f50e6d171 100644 --- a/web/src/components/network/WifiNetworksList.test.tsx +++ b/web/src/components/network/WifiNetworksList.test.tsx @@ -52,9 +52,6 @@ const mockConnectionRemoval = jest.fn(); const mockAddConnection = jest.fn(); let mockWifiNetworks: WifiNetwork[]; -// NOTE: mock only backend related queries. -// I.e., do not mock useSelectedWifi nor useSelectedWifiChange here to being able -// to test them along with user interactions jest.mock("~/queries/network", () => ({ ...jest.requireActual("~/queries/network"), useNetworkChanges: jest.fn(), @@ -67,13 +64,13 @@ jest.mock("~/queries/network", () => ({ useWifiNetworks: () => mockWifiNetworks, })); -describe("WifiNetworksListPage", () => { +describe("WifiNetworksList", () => { describe("when visible networks are found", () => { beforeEach(() => { mockWifiNetworks = [ { ssid: "Network 1", - strength: 4, + strength: 25, hwAddress: "??", security: [SecurityProtocols.RSN], device: wlan0, @@ -85,7 +82,7 @@ describe("WifiNetworksListPage", () => { }, { ssid: "Network 2", - strength: 8, + strength: 88, hwAddress: "??", security: [SecurityProtocols.RSN], settings: new Connection("Network 2", { @@ -96,9 +93,9 @@ describe("WifiNetworksListPage", () => { }, { ssid: "Network 3", - strength: 6, + strength: 66, hwAddress: "??", - security: [SecurityProtocols.RSN], + security: [], status: WifiNetworkStatus.NOT_CONFIGURED, }, ]; @@ -107,48 +104,50 @@ describe("WifiNetworksListPage", () => { it("renders a list of available wifi networks", () => { // @ts-expect-error: you need to specify the aria-label installerRender(); - screen.getByRole("listitem", { name: "Network 1" }); - screen.getByRole("listitem", { name: "Network 2" }); - screen.getByRole("listitem", { name: "Network 3" }); + screen.getByRole("listitem", { name: "Secured network Network 1 Weak signal" }); + screen.getByRole("listitem", { name: "Secured network Network 2 Excellent signal" }); + screen.getByRole("listitem", { name: "Public network Network 3 Good signal" }); }); - describe("and user selects a connected network", () => { + describe.skip("and user selects a connected network", () => { it("renders basic network information and actions instead of the connection form", async () => { // @ts-expect-error: you need to specify the aria-label const { user } = installerRender(); - const network1 = screen.getByRole("listitem", { name: "Network 1" }); + const network1 = screen.getByRole("listitem", { + name: "Secured network Network 1 Weak signal", + }); await user.click(network1); - screen.getByRole("heading", { name: "Network 1" }); - expect(screen.queryByRole("form")).toBeNull(); + screen.getByRole("heading", { name: "Connection details" }); + expect(screen.queryByRole("form", { name: "Wi-Fi connection form" })).toBeNull(); screen.getByText("192.168.69.201/24"); - screen.getByRole("button", { name: "Disconnect" }); - screen.getByRole("link", { name: "Edit" }); - screen.getByRole("button", { name: "Forget" }); }); }); - describe("and user selects a configured network", () => { - it("renders actions instead of the connection form", async () => { + describe.skip("and user selects a configured network", () => { + it("renders the connection form", async () => { // @ts-expect-error: you need to specify the aria-label const { user } = installerRender(); - const network2 = screen.getByRole("listitem", { name: "Network 2" }); + const network2 = screen.getByRole("listitem", { + name: "Secured network Network 2 Excellent signal", + }); await user.click(network2); - screen.getByRole("heading", { name: "Network 2" }); - expect(screen.queryByRole("form")).toBeNull(); + screen.getByRole("heading", { name: "Connect to Network 2" }); + screen.queryByRole("form", { name: "Wi-Fi connection form" }); screen.getByRole("button", { name: "Connect" }); - screen.getByRole("link", { name: "Edit" }); - screen.getByRole("button", { name: "Forget" }); + screen.getByRole("button", { name: "Cancel" }); }); }); - describe("and user selects a not configured network", () => { + describe.skip("and user selects a not configured network", () => { it("renders the connection form", async () => { // @ts-expect-error: you need to specify the aria-label const { user } = installerRender(); - const network3 = screen.getByRole("listitem", { name: "Network 3" }); + const network3 = screen.getByRole("listitem", { + name: "Public network Network 3 Good signal", + }); await user.click(network3); - screen.getByRole("heading", { name: "Network 3" }); - screen.queryByRole("form", { name: "WiFi connection form" }); + screen.getByRole("heading", { name: "Connect to Network 3" }); + screen.queryByRole("form", { name: "Wi-Fi connection form" }); }); }); }); @@ -161,7 +160,7 @@ describe("WifiNetworksListPage", () => { it("renders information about it", () => { // @ts-expect-error: you need to specify the aria-label installerRender(); - screen.getByText("No visible Wi-Fi networks found"); + screen.getByText("No Wi-Fi networks were found"); }); }); }); From 3c9d77aaf4cbca684150dbfd0089c494bbdf478a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Tue, 15 Apr 2025 12:23:18 +0100 Subject: [PATCH 50/59] fix(web): fix connection device detection By filtering it by the connection id instead of network ssid. --- web/src/queries/network.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/queries/network.ts b/web/src/queries/network.ts index 9d30108a9b..020fd23566 100644 --- a/web/src/queries/network.ts +++ b/web/src/queries/network.ts @@ -288,7 +288,7 @@ const useWifiNetworks = () => { .sort((a: AccessPoint, b: AccessPoint) => b.strength - a.strength) .map((ap: AccessPoint): WifiNetwork => { const settings = connections.find((c: Connection) => c.wireless?.ssid === ap.ssid); - const device = devices.find((d: Device) => d.connection === ap.ssid); + const device = devices.find((d: Device) => d.connection === settings?.id); let status: WifiNetworkStatus; if (device?.state === DeviceState.CONNECTED) { From 5a42d498d8bf2eaf50d375e33c7b410835842c03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Tue, 15 Apr 2025 12:25:50 +0100 Subject: [PATCH 51/59] fix(web): minor adjustments for Wi-Fi networks list For changing where query the connection state, if any. --- .../components/network/WifiNetworksList.tsx | 49 +++++++------------ 1 file changed, 17 insertions(+), 32 deletions(-) diff --git a/web/src/components/network/WifiNetworksList.tsx b/web/src/components/network/WifiNetworksList.tsx index f1c736b35c..3a9c104389 100644 --- a/web/src/components/network/WifiNetworksList.tsx +++ b/web/src/components/network/WifiNetworksList.tsx @@ -36,36 +36,13 @@ import { import a11yStyles from "@patternfly/react-styles/css/utilities/Accessibility/accessibility"; import { EmptyState } from "~/components/core"; import Icon, { IconProps } from "~/components/layout/Icon"; -import { DeviceState, WifiNetwork, WifiNetworkStatus } from "~/types/network"; -import { useNetworkChanges, useWifiNetworks } from "~/queries/network"; +import { Connection, ConnectionState, WifiNetwork, WifiNetworkStatus } from "~/types/network"; +import { useConnections, useNetworkChanges, useWifiNetworks } from "~/queries/network"; import { NETWORK as PATHS } from "~/routes/paths"; import { isEmpty } from "~/utils"; import { formatIp } from "~/utils/network"; import { _ } from "~/i18n"; -// FIXME: Move to the model and stop using translations for checking the state -const networkState = (state: DeviceState): string => { - switch (state) { - case DeviceState.CONNECTING: - // TRANSLATORS: Wifi network status - return _("Connecting"); - case DeviceState.CONNECTED: - // TRANSLATORS: Wifi network status - return _("Connected"); - case DeviceState.DISCONNECTING: - // TRANSLATORS: Wifi network status - return _("Disconnecting"); - case DeviceState.FAILED: - // TRANSLATORS: Wifi network status - return _("Failed"); - case DeviceState.DISCONNECTED: - // TRANSLATORS: Wifi network status - return _("Disconnected"); - default: - return ""; - } -}; - const NetworkSignal = ({ id, signal }) => { let label: string; let icon: IconProps["name"]; @@ -110,17 +87,19 @@ const NetworkSecurity = ({ id, security }) => { ); }; -type NetworkListItemProps = { network: WifiNetwork; showIp: boolean }; +type NetworkListItemProps = { + network: WifiNetwork; + connection: Connection | undefined; + showIp: boolean; +}; -const NetworkListItem = ({ network, showIp }: NetworkListItemProps) => { +const NetworkListItem = ({ network, connection, showIp }: NetworkListItemProps) => { const nameId = useId(); const securityId = useId(); const statusId = useId(); const signalId = useId(); const ipId = useId(); - const state = networkState(network.device?.state); - return ( { {network.ssid} - {state === "Connected" && ( + {connection?.state === ConnectionState.activated && ( )} @@ -173,6 +152,7 @@ function WifiNetworksList({ showIp = true, ...props }: WifiNetworksListProps) { useNetworkChanges(); const navigate = useNavigate(); const networks: WifiNetwork[] = useWifiNetworks(); + const connections = useConnections(); if (networks.length === 0) return ; @@ -195,7 +175,12 @@ function WifiNetworksList({ showIp = true, ...props }: WifiNetworksListProps) { {...props} > {networks.map((n) => ( - + c?.wireless?.ssid === n.ssid)} + showIp={showIp} + /> ))} ); From f0ef57a48ddc4eec985024006771055323f1aa92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Tue, 15 Apr 2025 12:27:55 +0100 Subject: [PATCH 52/59] web: show spinner in Wi-Fi network when connecting --- .../network/WifiNetworksList.test.tsx | 27 ++++++++++++++++++- .../components/network/WifiNetworksList.tsx | 15 ++++++++++- web/src/types/network.ts | 1 + 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/web/src/components/network/WifiNetworksList.test.tsx b/web/src/components/network/WifiNetworksList.test.tsx index 9f50e6d171..9f53bc1e9d 100644 --- a/web/src/components/network/WifiNetworksList.test.tsx +++ b/web/src/components/network/WifiNetworksList.test.tsx @@ -20,12 +20,13 @@ */ import React from "react"; -import { screen } from "@testing-library/react"; +import { screen, within } from "@testing-library/react"; import { installerRender } from "~/test-utils"; import WifiNetworksList from "~/components/network/WifiNetworksList"; import { Connection, ConnectionMethod, + ConnectionState, ConnectionType, Device, DeviceState, @@ -51,6 +52,7 @@ const wlan0: Device = { const mockConnectionRemoval = jest.fn(); const mockAddConnection = jest.fn(); let mockWifiNetworks: WifiNetwork[]; +let mockWifiConnections: Connection[]; jest.mock("~/queries/network", () => ({ ...jest.requireActual("~/queries/network"), @@ -62,11 +64,25 @@ jest.mock("~/queries/network", () => ({ mutate: mockAddConnection, }), useWifiNetworks: () => mockWifiNetworks, + useConnections: () => mockWifiConnections, })); describe("WifiNetworksList", () => { describe("when visible networks are found", () => { beforeEach(() => { + mockWifiConnections = [ + new Connection("Newtwork 2", { + method4: ConnectionMethod.AUTO, + method6: ConnectionMethod.AUTO, + wireless: { + security: "none", + ssid: "Network 2", + mode: "infrastructure", + }, + state: ConnectionState.activating, + }), + ]; + mockWifiNetworks = [ { ssid: "Network 1", @@ -109,6 +125,15 @@ describe("WifiNetworksList", () => { screen.getByRole("listitem", { name: "Public network Network 3 Good signal" }); }); + it("renders a spinner in network in connecting state", () => { + // @ts-expect-error: you need to specify the aria-label + installerRender(); + const network2 = screen.getByRole("listitem", { + name: "Secured network Network 2 Excellent signal", + }); + within(network2).getByRole("progressbar", { name: "Connecting to Network 2" }); + }); + describe.skip("and user selects a connected network", () => { it("renders basic network information and actions instead of the connection form", async () => { // @ts-expect-error: you need to specify the aria-label diff --git a/web/src/components/network/WifiNetworksList.tsx b/web/src/components/network/WifiNetworksList.tsx index 3a9c104389..f4b9852aff 100644 --- a/web/src/components/network/WifiNetworksList.tsx +++ b/web/src/components/network/WifiNetworksList.tsx @@ -32,6 +32,7 @@ import { DataListProps, Flex, Label, + Spinner, } from "@patternfly/react-core"; import a11yStyles from "@patternfly/react-styles/css/utilities/Accessibility/accessibility"; import { EmptyState } from "~/components/core"; @@ -42,6 +43,7 @@ import { NETWORK as PATHS } from "~/routes/paths"; import { isEmpty } from "~/utils"; import { formatIp } from "~/utils/network"; import { _ } from "~/i18n"; +import { sprintf } from "sprintf-js"; const NetworkSignal = ({ id, signal }) => { let label: string; @@ -87,6 +89,16 @@ const NetworkSecurity = ({ id, security }) => { ); }; +type ConnectingSpinnerProps = { ssid: WifiNetwork["ssid"]; state: ConnectionState | undefined }; +const ConnectingSpinner = ({ state, ssid }: ConnectingSpinnerProps) => { + if (state !== ConnectionState.activating) return; + + // TRANSLATORS: %s will be replaced by Wi-Fi network SSID + const label = sprintf(_("Connecting to %s"), ssid); + + return ; +}; + type NetworkListItemProps = { network: WifiNetwork; connection: Connection | undefined; @@ -130,8 +142,9 @@ const NetworkListItem = ({ network, connection, showIp }: NetworkListItemProps) )} , - + + diff --git a/web/src/types/network.ts b/web/src/types/network.ts index 86fa502756..bbf65fe58c 100644 --- a/web/src/types/network.ts +++ b/web/src/types/network.ts @@ -267,6 +267,7 @@ type ConnectionOptions = { method4?: ConnectionMethod; method6?: ConnectionMethod; wireless?: Wireless; + state?: ConnectionState; }; class Connection { From 9e0c003473c880f2a74f45a27f9544a2a1e6ad44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Wed, 16 Apr 2025 09:46:02 +0100 Subject: [PATCH 53/59] feat(rust): do not write wireless security settings when not used --- rust/agama-server/src/network/nm/dbus.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/rust/agama-server/src/network/nm/dbus.rs b/rust/agama-server/src/network/nm/dbus.rs index 9f3785d979..85215fa7ac 100644 --- a/rust/agama-server/src/network/nm/dbus.rs +++ b/rust/agama-server/src/network/nm/dbus.rs @@ -470,6 +470,10 @@ fn wireless_config_to_dbus(config: &'_ WirelessConfig) -> NestedHash<'_> { wireless.insert("bssid", bssid.as_bytes().into()); } + if config.security == SecurityProtocol::WEP && config.wep_security.is_none() { + return NestedHash::from([(WIRELESS_KEY, wireless)]); + } + let mut security: HashMap<&str, zvariant::Value> = HashMap::from([ ("key-mgmt", config.security.to_string().into()), ( From cf1625e17b424844929baa0660f6f7a5dfe9cd18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Wed, 16 Apr 2025 10:32:17 +0100 Subject: [PATCH 54/59] fix(web): use "none" as security for public networks --- .../components/network/WifiConnectionForm.tsx | 22 +++++++++---------- 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/web/src/components/network/WifiConnectionForm.tsx b/web/src/components/network/WifiConnectionForm.tsx index 5419f3dc3e..b8c2035f14 100644 --- a/web/src/components/network/WifiConnectionForm.tsx +++ b/web/src/components/network/WifiConnectionForm.tsx @@ -38,21 +38,13 @@ import { _ } from "~/i18n"; import { sprintf } from "sprintf-js"; import { isEmpty } from "~/utils"; -/* - * FIXME: it should be moved to the SecurityProtocols enum that already exists or to a class based - * enum pattern in the network_manager adapter. - */ -const security_options = [ +const securityOptions = [ // TRANSLATORS: WiFi authentication mode - { value: "", label: _("None") }, + { value: "none", label: _("None") }, // TRANSLATORS: WiFi authentication mode { value: "wpa-psk", label: _("WPA & WPA2 Personal") }, ]; -const selectorOptions = security_options.map((security) => ( - -)); - const securityFrom = (supported: string[]) => { if (supported.includes("WPA2")) return "wpa-psk"; if (supported.includes("WPA1")) return "wpa-psk"; @@ -136,7 +128,7 @@ export default function WifiConnectionForm({ network }: { network: WifiNetwork } const nextConnection = network.settings || new Connection(network.ssid); nextConnection.wireless = new Wireless({ ssid: network.ssid, - security, + security: security || "none", password, hidden: false, }); @@ -166,7 +158,13 @@ export default function WifiConnectionForm({ network }: { network: WifiNetwork } value={security} onChange={(_, v) => setSecurity(v)} > - {selectorOptions} + {securityOptions.map((security) => ( + + ))} )} From df6c7ce9f94b62eac0e81c50b012cb203d2d4295 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Wed, 16 Apr 2025 10:37:44 +0100 Subject: [PATCH 55/59] fix(web): clarify signal strength in Wi-Fi connection details --- web/src/components/network/WifiConnectionDetails.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/components/network/WifiConnectionDetails.tsx b/web/src/components/network/WifiConnectionDetails.tsx index 0d02408f62..0f92d5af44 100644 --- a/web/src/components/network/WifiConnectionDetails.tsx +++ b/web/src/components/network/WifiConnectionDetails.tsx @@ -48,8 +48,8 @@ const NetworkDetails = ({ network }: { network: WifiNetwork }) => { {network.ssid} - {_("Signal")} - {network.strength} + {_("Signal strength")} + {network.strength}% {_("Status")} From 4dfe2f51d93715406969e36f7b37938736eac33e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Fri, 18 Apr 2025 16:15:29 +0100 Subject: [PATCH 56/59] fix(rust): add ConnectionState to the OpenAPI spec --- rust/agama-server/src/web/docs/network.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/rust/agama-server/src/web/docs/network.rs b/rust/agama-server/src/web/docs/network.rs index 93d4789e58..604eac227a 100644 --- a/rust/agama-server/src/web/docs/network.rs +++ b/rust/agama-server/src/web/docs/network.rs @@ -57,6 +57,7 @@ impl ApiDocBuilder for NetworkApiDocBuilder { .schema_from::() .schema_from::() .schema_from::() + .schema_from::() .schema_from::() .schema_from::() .schema_from::() From 3301cf1b6a4d63879228cd4d10d6090aa873df7d Mon Sep 17 00:00:00 2001 From: Knut Anderssen Date: Tue, 1 Apr 2025 11:46:52 +0100 Subject: [PATCH 57/59] Fix sorting of access points in the original query --- web/src/queries/network.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/queries/network.ts b/web/src/queries/network.ts index 020fd23566..9f61fe5ac8 100644 --- a/web/src/queries/network.ts +++ b/web/src/queries/network.ts @@ -98,7 +98,7 @@ const accessPointsQuery = () => ({ queryKey: ["network", "accessPoints"], queryFn: async (): Promise => { const accessPoints = await fetchAccessPoints(); - return accessPoints.map(AccessPoint.fromApi).sort((a, b) => (a.strength < b.strength ? -1 : 1)); + return accessPoints.map(AccessPoint.fromApi).sort((a, b) => b.strength - a.strength); }, // FIXME: Infinity vs 1second staleTime: 1000, From 97c945931cd0e3acff76d33b4f0dd38fe89ad3d5 Mon Sep 17 00:00:00 2001 From: Knut Anderssen Date: Tue, 1 Apr 2025 11:50:38 +0100 Subject: [PATCH 58/59] Document the sorting of access points --- web/src/queries/network.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/web/src/queries/network.ts b/web/src/queries/network.ts index 9f61fe5ac8..7b1620f445 100644 --- a/web/src/queries/network.ts +++ b/web/src/queries/network.ts @@ -92,7 +92,8 @@ const connectionsQuery = () => ({ }); /** - * Returns a query for retrieving the list of known access points + * Returns a query for retrieving the list of known access points sortered by + * the signal strength. */ const accessPointsQuery = () => ({ queryKey: ["network", "accessPoints"], From e2d318d83a212b6585bf292f2ed3563ea4e3b8ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Mon, 21 Apr 2025 13:49:19 +0100 Subject: [PATCH 59/59] docs: update changes files --- rust/package/agama.changes | 6 ++++++ web/package/agama-web-ui.changes | 11 +++++++++++ 2 files changed, 17 insertions(+) diff --git a/rust/package/agama.changes b/rust/package/agama.changes index 7c55bfbc0e..e254597f9b 100644 --- a/rust/package/agama.changes +++ b/rust/package/agama.changes @@ -1,3 +1,9 @@ +------------------------------------------------------------------- +Mon Apr 21 12:46:07 UTC 2025 - Imobach Gonzalez Sosa + +- Report and emit changes to the connections states. (gh#agama-project/agama#2247). +- Do not write wireless security settings when they are not used. + ------------------------------------------------------------------- Wed Apr 9 09:06:18 UTC 2025 - Martin Vidner diff --git a/web/package/agama-web-ui.changes b/web/package/agama-web-ui.changes index 5003489a9a..793c0cb85d 100644 --- a/web/package/agama-web-ui.changes +++ b/web/package/agama-web-ui.changes @@ -1,3 +1,14 @@ +------------------------------------------------------------------- +Mon Apr 21 12:37:20 UTC 2025 - Imobach Gonzalez Sosa + +- Rework the network page to (gh#agama-project/agama#2247): + - Fix several problems with Wi-Fi networks handling. + - Improve error reporting when the connection failed. + - Use a regular form to connect, instead of the old "drawer" + component. + - Drop the "disconnect" and "forget" actions by now. + - Reduce the amount of requests to the backend. + ------------------------------------------------------------------- Wed Apr 9 12:38:21 UTC 2025 - David Diaz