diff --git a/rust/agama-server/src/network/nm/builder.rs b/rust/agama-server/src/network/nm/builder.rs index 737e33f117..e8d79c32d2 100644 --- a/rust/agama-server/src/network/nm/builder.rs +++ b/rust/agama-server/src/network/nm/builder.rs @@ -38,6 +38,7 @@ impl<'a> DeviceFromProxyBuilder<'a> { .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}")?; @@ -46,6 +47,7 @@ impl<'a> DeviceFromProxyBuilder<'a> { name: self.proxy.interface().await?, type_, state, + state_reason: state_reason as u8, ..Default::default() }; diff --git a/rust/agama-server/src/network/nm/client.rs b/rust/agama-server/src/network/nm/client.rs index 9e3fc06fe6..9e409b5e64 100644 --- a/rust/agama-server/src/network/nm/client.rs +++ b/rust/agama-server/src/network/nm/client.rs @@ -140,7 +140,7 @@ impl<'a> NetworkManagerClient<'a> { /// Returns the list of network devices. pub async fn devices(&self) -> Result, ServiceError> { let mut devs = vec![]; - for path in &self.nm_proxy.get_devices().await? { + for path in &self.nm_proxy.get_all_devices().await? { let proxy = DeviceProxy::builder(&self.connection) .path(path.as_str())? .build() @@ -172,6 +172,12 @@ impl<'a> NetworkManagerClient<'a> { .path(path.as_str())? .build() .await?; + let flags = proxy.flags().await?; + if flags > 0 { + log::warn!("Skipped connection because of flags: {}", flags); + continue; + } + let settings = proxy.get_settings().await?; if let Some(mut connection) = connection_from_dbus(settings.clone()) { diff --git a/rust/agama-server/src/network/nm/model.rs b/rust/agama-server/src/network/nm/model.rs index c3065f9bbb..1226649c88 100644 --- a/rust/agama-server/src/network/nm/model.rs +++ b/rust/agama-server/src/network/nm/model.rs @@ -80,11 +80,12 @@ impl TryFrom for DeviceType { fn try_from(value: NmDeviceType) -> Result { match value { - NmDeviceType(32) => Ok(DeviceType::Loopback), NmDeviceType(1) => Ok(DeviceType::Ethernet), NmDeviceType(2) => Ok(DeviceType::Wireless), - NmDeviceType(22) => Ok(DeviceType::Dummy), NmDeviceType(10) => Ok(DeviceType::Bond), + NmDeviceType(13) => Ok(DeviceType::Bridge), + NmDeviceType(22) => Ok(DeviceType::Dummy), + NmDeviceType(32) => Ok(DeviceType::Loopback), NmDeviceType(_) => Err(NmError::UnsupportedDeviceType(value.into())), } } diff --git a/rust/agama-server/src/network/nm/watcher.rs b/rust/agama-server/src/network/nm/watcher.rs index 2b24aade5c..df04339487 100644 --- a/rust/agama-server/src/network/nm/watcher.rs +++ b/rust/agama-server/src/network/nm/watcher.rs @@ -282,7 +282,13 @@ impl DeviceChangedStream { fn handle_changed(message: PropertiesChanged) -> Option { const IP_CONFIG_PROPS: &[&str] = &["AddressData", "Gateway", "NameserverData", "RouteData"]; - const DEVICE_PROPS: &[&str] = &["DeviceType", "HwAddress", "Interface", "State"]; + const DEVICE_PROPS: &[&str] = &[ + "DeviceType", + "HwAddress", + "Interface", + "State", + "StateReason", + ]; let path = OwnedObjectPath::from(message.path()?); let args = message.args().ok()?; diff --git a/web/src/client/network/index.js b/web/src/client/network/index.js index a56bf51feb..dd8f02180e 100644 --- a/web/src/client/network/index.js +++ b/web/src/client/network/index.js @@ -237,6 +237,38 @@ class NetworkClient { return this.client.get(`/network/connections/${connection.id}/disconnect`); } + async loadNetworks(devices, connections, accessPoints) { + const knownSsids = []; + + return accessPoints + .sort((a, b) => b.strength - a.strength) + .reduce((networks, ap) => { + // Do not include networks without SSID + if (!ap.ssid || ap.ssid === "") return networks; + // Do not include "duplicates" + if (knownSsids.includes(ap.ssid)) return networks; + + const network = { + ...ap, + settings: connections.find(c => c.wireless?.ssid === ap.ssid), + device: devices.find(c => c.connection === ap.ssid) + }; + + // Group networks + if (network.device) { + networks.connected.push(network); + } else if (network.settings) { + networks.configured.push(network); + } else { + networks.others.push(network); + } + + knownSsids.push(network.ssid); + + return networks; + }, { connected: [], configured: [], others: [] }); + } + /** * Apply network changes */ diff --git a/web/src/client/network/model.js b/web/src/client/network/model.js index c8a8a069e1..d8b4d910e9 100644 --- a/web/src/client/network/model.js +++ b/web/src/client/network/model.js @@ -42,6 +42,7 @@ const DeviceState = Object.freeze({ UNAVAILABLE: "unavailable", DISCONNECTED: "disconnected", CONFIG: "config", + IPCHECK: "ipCheck", NEEDAUTH: "needAuth", ACTIVATED: "activated", DEACTIVATING: "deactivating", diff --git a/web/src/components/layout/Icon.jsx b/web/src/components/layout/Icon.jsx index e9d6840b4f..3ecc03369b 100644 --- a/web/src/components/layout/Icon.jsx +++ b/web/src/components/layout/Icon.jsx @@ -79,6 +79,7 @@ import Visibility from "@icons/visibility.svg?component"; import VisibilityOff from "@icons/visibility_off.svg?component"; import Wifi from "@icons/wifi.svg?component"; import WifiFind from "@icons/wifi_find.svg?component"; +import WifiOff from "@icons/wifi_off.svg?component"; // Icons from react-simple-icons @@ -146,6 +147,7 @@ const icons = { warning: Warning, wifi: Wifi, wifi_find: WifiFind, + wifi_off: WifiOff, // brand icons linux_logo: SiLinux, windows_logo: SiWindows diff --git a/web/src/components/network/IpSettingsForm.jsx b/web/src/components/network/IpSettingsForm.jsx index fc81ad3822..c0aa779cd1 100644 --- a/web/src/components/network/IpSettingsForm.jsx +++ b/web/src/components/network/IpSettingsForm.jsx @@ -108,12 +108,12 @@ export default function IpSettingsForm() { ...connection, addresses: sanitizedAddresses, method4: method, - gatewa4: gateway, + gateway4: gateway, nameservers: sanitizedNameservers.map(s => s.address) }; client.network.updateConnection(updatedConnection) - .then(navigate("..")) + .then(navigate(-1)) // TODO: better error reporting. By now, it sets an error for the whole connection. .catch(({ message }) => setErrors({ object: message })); }; @@ -205,7 +205,7 @@ export default function IpSettingsForm() { - + {_("Accept")} diff --git a/web/src/components/network/NetworkPage.jsx b/web/src/components/network/NetworkPage.jsx index b4cffe83f5..c5c60d1e66 100644 --- a/web/src/components/network/NetworkPage.jsx +++ b/web/src/components/network/NetworkPage.jsx @@ -21,32 +21,17 @@ // @ts-check -import React, { useEffect, useState } from "react"; +import React, { useCallback, useEffect, useState } from "react"; +import { Button, CardBody, Grid, GridItem, Split, Skeleton, Stack } from "@patternfly/react-core"; import { useLoaderData } from "react-router-dom"; -import { Button, CardBody, Flex, Grid, GridItem, Skeleton, Stack } from "@patternfly/react-core"; -import { useInstallerClient } from "~/context/installer"; -import { CardField, Page } from "~/components/core"; -import { ConnectionsTable, WifiSelector } from "~/components/network"; +import { ButtonLink, CardField, EmptyState, Page } from "~/components/core"; +import { ConnectionsTable } from "~/components/network"; import { NetworkEventTypes } from "~/client/network"; +import { useInstallerClient } from "~/context/installer"; import { _ } from "~/i18n"; - -const WifiSelection = ({ wifiScanSupported }) => { - const [wifiSelectorOpen, setWifiSelectorOpen] = useState(false); - - if (!wifiScanSupported) return; - - const openWifiSelector = () => setWifiSelectorOpen(true); - const closeWifiSelector = () => setWifiSelectorOpen(false); - - return ( - <> - - - - ); -}; +import { formatIp } from "~/client/network/utils"; +import { sprintf } from "sprintf-js"; +import { DeviceState } from "~/client/network/model"; /** * Internal component for displaying info when none wire connection is found @@ -58,135 +43,119 @@ const NoWiredConnections = () => { ); }; -/** - * Internal component for displaying info when none WiFi connection is found - * @component - * - * @param {object} props - * @param {boolean} props.supported - whether the system supports scanning WiFi networks - * @param {boolean} props.openWifiSelector - the function for opening the WiFi selector - */ -const NoWifiConnections = ({ wifiScanSupported }) => { - const message = wifiScanSupported - ? _("The system has not been configured for connecting to a WiFi network yet.") - : _("The system does not support WiFi connections, probably because of missing or disabled hardware."); - - return ( - -
{_("No WiFi connections found.")}
-
{message}
-
- ); -}; - /** * Page component holding Network settings * @component */ export default function NetworkPage() { const { network: client } = useInstallerClient(); - const { connections: initialConnections, settings } = useLoaderData(); + const { connections: initialConnections, devices: initialDevices, settings } = useLoaderData(); const [connections, setConnections] = useState(initialConnections); - const [devices, setDevices] = useState(undefined); - const [selectedConnection, setSelectedConnection] = useState(null); + const [devices, setDevices] = useState(initialDevices); + const [updateState, setUpdateState] = useState(false); + + const fetchState = useCallback(async () => { + const devices = await client.devices(); + const connections = await client.connections(); + setDevices(devices); + setConnections(connections); + }, [client]); useEffect(() => { - return client.onNetworkChange(({ type, payload }) => { - switch (type) { - case NetworkEventTypes.DEVICE_ADDED: { - setDevices((devs) => { - const newDevices = devs.filter((d) => d.name !== payload.name); - return [...newDevices, client.fromApiDevice(payload)]; - }); - break; - } + if (!updateState) return; - case NetworkEventTypes.DEVICE_UPDATED: { - const [name, data] = payload; - setDevices(devs => { - const newDevices = devs.filter((d) => d.name !== name); - return [...newDevices, client.fromApiDevice(data)]; - }); - break; - } + setUpdateState(false); + fetchState(); + }, [fetchState, updateState]); - case NetworkEventTypes.DEVICE_REMOVED: { - setDevices(devs => devs.filter((d) => d.name !== payload)); - break; - } + useEffect(() => { + return client.onNetworkChange(({ type }) => { + if ([NetworkEventTypes.DEVICE_ADDED, NetworkEventTypes.DEVICE_UPDATED, NetworkEventTypes.DEVICE_REMOVED].includes(type)) { + setUpdateState(true); } - client.connections().then(setConnections); }); - }, [client, devices]); - - useEffect(() => { - if (devices !== undefined) return; + }); - client.devices().then(setDevices); - }, [client, devices]); - - const selectConnection = ({ id }) => { - client.getConnection(id).then(setSelectedConnection); - }; + const connectionDevice = ({ id }) => devices?.find(({ connection }) => id === connection); + const connectionAddresses = (connection) => { + const device = connectionDevice(connection); + const addresses = device ? device.addresses : connection.addresses; - const forgetConnection = async ({ id }) => { - await client.deleteConnection(id); - setConnections(undefined); - }; - - const updateConnections = async () => { - setConnections(undefined); - setDevices(undefined); + return addresses?.map(formatIp).join(", "); }; const ready = (connections !== undefined) && (devices !== undefined); const WifiConnections = () => { - const wifiConnections = connections.filter(c => c.wireless); + const { wireless_enabled: wifiAvailable } = settings; - if (wifiConnections.length === 0) { + if (!wifiAvailable) { return ( - + + + + {_("The system does not support Wi-Fi connections, probably because of missing or disabled hardware.")} + + + ); } + const wifiConnections = connections.filter(c => c.wireless); + const activeWifiDevice = devices.find(d => d.type === "wireless" && d.state === "activated"); + const activeConnection = wifiConnections.find(c => c.id === activeWifiDevice?.connection); + return ( - + + {activeConnection ? _("Change") : _("Connect")} + + } + > + + {activeConnection + ? ( + + {connectionAddresses(activeConnection)} + + ) + : ( + + {_("The system has not been configured for connecting to a Wi-Fi network yet.")} + + )} + + ); }; const WiredConnections = () => { - const wiredConnections = connections.filter(c => !c.wireless && (c.id !== "lo")); + const wiredConnections = connections.filter(c => !c.wireless); if (wiredConnections.length === 0) return ; - return ; + return ; }; return ( <> - -

{_("Network")}

- -
+

{_("Network")}

- - + + {ready ? : } - - - - {ready ? : } - - + + diff --git a/web/src/components/network/WifiConnectionForm.jsx b/web/src/components/network/WifiConnectionForm.jsx index 55de9cee70..655bb322e1 100644 --- a/web/src/components/network/WifiConnectionForm.jsx +++ b/web/src/components/network/WifiConnectionForm.jsx @@ -19,7 +19,7 @@ * find current contact information at www.suse.com. */ -import React, { useEffect, useRef, useState } from "react"; +import React, { useState } from "react"; import { ActionGroup, Alert, @@ -59,7 +59,6 @@ const securityFrom = (supported) => { export default function WifiConnectionForm({ network, onCancel, onSubmitCallback }) { const { network: client } = useInstallerClient(); - const formRef = useRef(); const [error, setError] = useState(false); const [isConnecting, setIsConnecting] = useState(false); const [ssid, setSsid] = useState(network?.ssid || ""); @@ -67,10 +66,6 @@ export default function WifiConnectionForm({ network, onCancel, onSubmitCallback const [security, setSecurity] = useState(securityFrom(network?.security || [])); const hidden = network?.hidden || false; - useEffect(() => { - setTimeout(() => { formRef.current?.scrollIntoView({ behavior: "smooth" }) }, 200); - }, []); - const accept = async e => { e.preventDefault(); setError(false); @@ -86,7 +81,7 @@ export default function WifiConnectionForm({ network, onCancel, onSubmitCallback }; return ( -
+ {error &&

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

diff --git a/web/src/components/network/WifiHiddenNetworkForm.jsx b/web/src/components/network/WifiHiddenNetworkForm.jsx index a20b16b11e..3519816f4f 100644 --- a/web/src/components/network/WifiHiddenNetworkForm.jsx +++ b/web/src/components/network/WifiHiddenNetworkForm.jsx @@ -35,20 +35,15 @@ import { _ } from "~/i18n"; * @param {function} props.beforeDisplaying - callback to trigger before displaying the form * @param {function} props.beforeHiding - callback to trigger before hiding the form */ -function WifiHiddenNetworkForm({ network, visible, beforeDisplaying, beforeHiding, onSubmitCallback }) { +function WifiHiddenNetworkForm({ network, visible, beforeHiding, onSubmitCallback }) { return ( <> - { visible && + {visible && } - { !visible && - } + />} ); } diff --git a/web/src/components/network/WifiNetworkListItem.jsx b/web/src/components/network/WifiNetworkListItem.jsx deleted file mode 100644 index f67a235997..0000000000 --- a/web/src/components/network/WifiNetworkListItem.jsx +++ /dev/null @@ -1,109 +0,0 @@ -/* - * Copyright (c) [2022] SUSE LLC - * - * All Rights Reserved. - * - * This program is free software; you can redistribute it and/or modify it - * under the terms of version 2 of the GNU General Public License as published - * by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for - * more details. - * - * You should have received a copy of the GNU General Public License along - * with this program; if not, contact SUSE LLC. - * - * To contact SUSE LLC about this file by physical or electronic mail, you may - * find current contact information at www.suse.com. - */ - -import React from "react"; -import { Flex, Radio, Spinner, Split, Text } from "@patternfly/react-core"; -import { Icon } from "~/components/layout"; -import { WifiNetworkMenu, WifiConnectionForm } from "~/components/network"; -import { DeviceState } from "~/client/network/model"; -import { _ } from "~/i18n"; -import { sprintf } from "sprintf-js"; - -const networkState = (state) => { - switch (state) { - case DeviceState.CONFIG: - // TRANSLATORS: Wifi network status - return _("Connecting"); - case DeviceState.ACTIVATED: - // TRANSLATORS: Wifi network status - return _("Connected"); - case DeviceState.DEACTIVATING: - // TRANSLATORS: Wifi network status - return _("Disconnecting"); - case DeviceState.FAILED: - return _("Failed"); - case DeviceState.DISCONNECTED: - // TRANSLATORS: Wifi network status - return _("Disconnected"); - default: - return ""; - } -}; - -const isStateChanging = (network) => { - const state = network.device?.state; - return state === DeviceState.CONFIG || state === DeviceState.NEEDAUTH || state === DeviceState.DEACTIVATING || state === DeviceState.FAILED; -}; - -/** - * Component for displaying a Wi-Fi network within a NetworkList - * - * @param {object} props - component props - * @param {object} props.networks - the ap/configured network to be displayed - * @param {boolean} [props.isSelected] - whether the network has been selected by the user - * @param {boolean} [props.isActive] - whether the network is currently active - * @param {function} props.onSelect - function to execute when the network is selected - * @param {function} props.onCancel - function to execute when the selection is cancelled - */ -function WifiNetworkListItem({ network, isSelected, isActive, onSelect, onCancel }) { - // Do not wait until receive the next D-Bus network event to have the connection object available - // and display the spinner as soon as possible. I.e., renders it immediately when the user clicks - // on an already configured network. - const showSpinner = (isSelected && network.settings && !network.device) || isStateChanging(network); - - return ( -
  • - - - {network.security.join(", ")}{" "} - {network.strength} - - } - isChecked={isSelected || isActive || false} - onClick={onSelect} - /> - - {/* TRANSLATORS: %s is replaced by a WiFi network name */} - {showSpinner && } - - {showSpinner && !network.device && _("Connecting")} - {networkState(network.device?.state)} - - {network.settings && - } - - - {isSelected && (!network.settings || network.error) && -
    - -
    } -
  • - ); -} - -export default WifiNetworkListItem; diff --git a/web/src/components/network/WifiNetworkListItem.test.jsx b/web/src/components/network/WifiNetworkListItem.test.jsx deleted file mode 100644 index f18b84d759..0000000000 --- a/web/src/components/network/WifiNetworkListItem.test.jsx +++ /dev/null @@ -1,151 +0,0 @@ -/* - * Copyright (c) [2022] SUSE LLC - * - * All Rights Reserved. - * - * This program is free software; you can redistribute it and/or modify it - * under the terms of version 2 of the GNU General Public License as published - * by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for - * more details. - * - * You should have received a copy of the GNU General Public License along - * with this program; if not, contact SUSE LLC. - * - * To contact SUSE LLC about this file by physical or electronic mail, you may - * find current contact information at www.suse.com. - */ - -import React from "react"; - -import { screen } from "@testing-library/react"; -import { installerRender } from "~/test-utils"; - -import { WifiNetworkListItem } from "~/components/network"; -import { createClient } from "~/client"; - -jest.mock("~/components/network/WifiConnectionForm", () => () =>
    WifiConnectionForm mock
    ); -jest.mock("~/components/network/WifiNetworkMenu", () => () =>
    WifiNetworkMenu mock
    ); - -jest.mock("~/client"); -const onSelectCallback = jest.fn(); -const fakeNetwork = { - ssid: "Fake Wi-Fi AP", - security: ["WPA2"], - strength: 86 -}; - -const fakeSettings = { - wireless: { - password: "notSecret" - } -}; - -const accessPoints = [{ - ssid: "Fake Wi-Fi AP", - hw_address: "00:11:22:33:44:55", - strength: 61, - security: ["WPA2"] -}]; - -const settingsFn = jest.fn(); -const networkSettings = { wireless_enabled: false, hostname: "test", networking_enabled: true, connectivity: true }; - -describe("NetworkListItem", () => { - beforeEach(() => { - settingsFn.mockReturnValue({ ...networkSettings }); - - createClient.mockImplementation(() => { - return { - network: { - setUp: () => Promise.resolve(true), - connections: () => Promise.resolve([]), - accessPoints: () => Promise.resolve(accessPoints), - onNetworkEvent: jest.fn(), - settings: () => Promise.resolve(settingsFn()) - } - }; - }); - }); - - it("renders an input radio for selecting the network", async () => { - installerRender(); - - await screen.findByRole("radio", { name: fakeNetwork.ssid, checked: false }); - }); - - describe("when isSelected prop is true", () => { - it("renders network as focused", async () => { - installerRender(); - - const wrapper = await screen.findByRole("listitem"); - expect(wrapper).toHaveAttribute("data-state", "focused"); - }); - - it("renders the input radio as checked", async () => { - installerRender(); - - await screen.findByRole("radio", { name: fakeNetwork.ssid, checked: true }); - }); - }); - - describe("when isActive prop is true", () => { - it("renders the input radio as checked", async () => { - installerRender(); - - await screen.findByRole("radio", { name: fakeNetwork.ssid, checked: true }); - }); - }); - - describe("when given network already has settings", () => { - const network = { ...fakeNetwork, settings: { ...fakeSettings } }; - - it("renders the WifiNetworkMenu", async () => { - installerRender(); - await screen.findByText("WifiNetworkMenu mock"); - }); - - describe("and it is selected", () => { - it("does not render the WifiConnectionForm", async () => { - installerRender(); - expect(screen.queryByText("WifiConnectionForm mock")).not.toBeInTheDocument(); - }); - }); - }); - - describe("when given network does not have settings", () => { - it("does not render the WifiNetworkMenu", async () => { - installerRender(); - expect(screen.queryByText("WifiNetworkMenu mock")).not.toBeInTheDocument(); - }); - - describe("and it is selected", () => { - it("renders the WifiConnectionForm", async () => { - installerRender(); - await screen.findByText("WifiConnectionForm mock"); - }); - - it("renders network as focused", async () => { - installerRender(); - - const wrapper = await screen.findByRole("listitem"); - expect(wrapper).toHaveAttribute("data-state", "focused"); - }); - }); - }); - - describe("when the user clicks on the input radio", () => { - it("triggers callback given into onSelect prop", async () => { - const { user } = installerRender( - - ); - const radio = await screen.findByRole("radio", { name: fakeNetwork.ssid }); - await user.click(radio); - - expect(onSelectCallback).toHaveBeenCalled(); - }); - }); -}); diff --git a/web/src/components/network/WifiNetworkMenu.jsx b/web/src/components/network/WifiNetworkMenu.jsx deleted file mode 100644 index dd37b47722..0000000000 --- a/web/src/components/network/WifiNetworkMenu.jsx +++ /dev/null @@ -1,81 +0,0 @@ -/* - * Copyright (c) [2022] SUSE LLC - * - * All Rights Reserved. - * - * This program is free software; you can redistribute it and/or modify it - * under the terms of version 2 of the GNU General Public License as published - * by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for - * more details. - * - * You should have received a copy of the GNU General Public License along - * with this program; if not, contact SUSE LLC. - * - * To contact SUSE LLC about this file by physical or electronic mail, you may - * find current contact information at www.suse.com. - */ - -import React, { useState } from "react"; -import { Dropdown, DropdownList, DropdownItem, MenuToggle } from '@patternfly/react-core'; -import { Icon } from "~/components/layout"; -import { useInstallerClient } from "~/context/installer"; -import { _ } from "~/i18n"; - -const KebabToggle = ({ toggleRef, onClick }) => ( - - - -); - -export default function WifiNetworkMenu({ settings, position = "right", device, onConnect }) { - const client = useInstallerClient(); - const [isOpen, setIsOpen] = useState(false); - const toggle = () => setIsOpen(!isOpen); - - return ( - } - popperProps={{ position }} - position={position} - > - - {!device && - { - await client.network.connectTo(settings); - onConnect(); - }} - icon={} - > - {/* TRANSLATORS: menu label, connect to the selected WiFi network */} - {_("Connect")} - } - {device && - await client.network.disconnect(settings)} - icon={} - > - {/* TRANSLATORS: menu label, disconnect from the selected WiFi network */} - {_("Disconnect")} - } - await client.network.deleteConnection(settings.id)} - icon={} - > - {/* TRANSLATORS: menu label, remove the selected WiFi network settings */} - {_("Forget network")} - - - - ); -} diff --git a/web/src/components/network/WifiNetworksList.jsx b/web/src/components/network/WifiNetworksList.jsx deleted file mode 100644 index 7f6d188f01..0000000000 --- a/web/src/components/network/WifiNetworksList.jsx +++ /dev/null @@ -1,83 +0,0 @@ -/* - * Copyright (c) [2022] SUSE LLC - * - * All Rights Reserved. - * - * This program is free software; you can redistribute it and/or modify it - * under the terms of version 2 of the GNU General Public License as published - * by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for - * more details. - * - * You should have received a copy of the GNU General Public License along - * with this program; if not, contact SUSE LLC. - * - * To contact SUSE LLC about this file by physical or electronic mail, you may - * find current contact information at www.suse.com. - */ - -import React from "react"; - -import { WifiNetworkListItem, WifiHiddenNetworkForm } from "~/components/network"; - -/** - * Component for displaying a list of available Wi-Fi networks - * - * @param {object} props - component props - * @param {object[]} [props.networks=[]] - list of networks to show - * @param {object} [props.activeNetwork] - the active network - * @param {object} [props.selectedNetwork] - the selected network (not necessarily the same as active) - * @param {function} props.onSelectionCallback - the function to trigger when user selects a network - * @param {function} props.onCancelCallback - the function to trigger when user cancel dismiss before connecting to a network - */ -function WifiNetworksList({ - networks = [], - hiddenNetwork, - activeNetwork, - selectedNetwork, - onSelectionCallback, - onCancelSelectionCallback, - showHiddenForm -}) { - const renderElements = () => { - return networks.map(n => { - const isSelected = n.ssid === selectedNetwork?.ssid; - const isActive = !selectedNetwork && n.ssid === activeNetwork?.ssid; - - return ( - { - if (!isSelected) onSelectionCallback(n); - }} - onCancel={onCancelSelectionCallback} - /> - ); - }); - }; - - return ( -
      - {renderElements()} -
    • -
      - onSelectionCallback(hiddenNetwork)} - beforeHiding={() => onSelectionCallback(activeNetwork)} - onSubmitCallback={onSelectionCallback} - /> -
      -
    • -
    - ); -} - -export default WifiNetworksList; diff --git a/web/src/components/network/WifiNetworksList.test.jsx b/web/src/components/network/WifiNetworksList.test.jsx deleted file mode 100644 index dc80fe273f..0000000000 --- a/web/src/components/network/WifiNetworksList.test.jsx +++ /dev/null @@ -1,116 +0,0 @@ -/* - * Copyright (c) [2022] SUSE LLC - * - * All Rights Reserved. - * - * This program is free software; you can redistribute it and/or modify it - * under the terms of version 2 of the GNU General Public License as published - * by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for - * more details. - * - * You should have received a copy of the GNU General Public License along - * with this program; if not, contact SUSE LLC. - * - * To contact SUSE LLC about this file by physical or electronic mail, you may - * find current contact information at www.suse.com. - */ - -import React from "react"; - -import { screen } from "@testing-library/react"; -import { installerRender } from "~/test-utils"; - -import { WifiNetworksList } from "~/components/network"; -import { createClient } from "~/client"; - -jest.mock("~/client"); -const onSelectionCallback = jest.fn(); - -const myNetwork = { - ssid: "My Wi-Fi Network", - security: ["WPA2"], - strength: 85, - settings: { wireless: { hidden: false } } -}; - -const otherNetwork = { - ssid: "My Neighbor Network", - security: ["WPA2"], - strength: 35 -}; - -const networksMock = [myNetwork, otherNetwork]; -const settingsFn = jest.fn(); -const accessPoints = [{ - ssid: "My Wi-Fi Network", - hw_address: "00:11:22:33:44:55", - strength: 61, - security: ["WPA2"] -}, -{ - ssid: "My Neighbor Network", - hw_address: "00:12:23:34:45:55", - strength: 12, - security: ["WPA2"] -}]; - -const networkSettings = { wireless_enabled: false, hostname: "test", networking_enabled: true, connectivity: true }; - -describe("WifiNetworksList", () => { - beforeEach(() => { - settingsFn.mockReturnValue({ ...networkSettings }); - - createClient.mockImplementation(() => { - return { - network: { - setUp: () => Promise.resolve(true), - connections: () => Promise.resolve([]), - accessPoints: () => Promise.resolve(accessPoints), - onNetworkEvent: jest.fn(), - settings: () => Promise.resolve(settingsFn()) - } - }; - }); - }); - it("renders link for connect to a hidden network", () => { - installerRender(); - screen.getByRole("button", { name: "Connect to hidden network" }); - }); - - it("displays networks information", async () => { - installerRender(); - - expect(screen.getByText("My Wi-Fi Network")).toBeInTheDocument(); - expect(screen.getByText("My Neighbor Network")).toBeInTheDocument(); - }); - - describe("when the user clicks on a not selected network", () => { - it("triggers the onSelectionCallback", async () => { - const { user } = installerRender( - - ); - - const radio = await screen.findByRole("radio", { name: "My Wi-Fi Network" }); - await user.click(radio); - - expect(onSelectionCallback).toHaveBeenCalled(); - }); - }); - - describe("when the user clicks on an already selected network", () => { - it("does not trigger the onSelectionCallback", async () => { - const { user } = installerRender( - - ); - - const radio = await screen.findByRole("radio", { name: "My Wi-Fi Network" }); - await user.click(radio); - - expect(onSelectionCallback).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/web/src/components/network/WifiNetworksListPage.jsx b/web/src/components/network/WifiNetworksListPage.jsx new file mode 100644 index 0000000000..a12f1d6a4c --- /dev/null +++ b/web/src/components/network/WifiNetworksListPage.jsx @@ -0,0 +1,255 @@ +/* + * Copyright (c) [2024] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React from "react"; +import { + Button, + Card, CardBody, + DataList, DataListCell, DataListItem, DataListItemCells, DataListItemRow, + Drawer, DrawerActions, DrawerCloseButton, DrawerContent, DrawerContentBody, DrawerHead, DrawerPanelBody, DrawerPanelContent, + Flex, + Label, + Spinner, + Split, + Stack +} from "@patternfly/react-core"; +import { Icon } from "~/components/layout"; +import { WifiConnectionForm } from "~/components/network"; +import { ButtonLink, EmptyState } from "~/components/core"; +import { DeviceState } from "~/client/network/model"; +import { useInstallerClient } from "~/context/installer"; +import { _ } from "~/i18n"; +import { formatIp } from "~/client/network/utils"; +import { sprintf } from "sprintf-js"; + +const HIDDEN_NETWORK = Object.freeze({ hidden: true }); + +// FIXME: Move to the model and stop using translations for checking the state +const networkState = (state) => { + switch (state) { + case DeviceState.CONFIG: + case DeviceState.IPCHECK: + // TRANSLATORS: Wifi network status + return _("Connecting"); + case DeviceState.ACTIVATED: + // TRANSLATORS: Wifi network status + return _("Connected"); + case DeviceState.DEACTIVATING: + case DeviceState.FAILED: + case DeviceState.DISCONNECTED: + // TRANSLATORS: Wifi network status + return _("Disconnected"); + default: + return ""; + } +}; + +const connectionAddresses = (network) => { + const { device, settings } = network; + const addresses = device ? device.addresses : settings?.addresses; + + return addresses?.map(formatIp).join(", "); +}; + +const ConnectionData = ({ network }) => { + return ( + + {connectionAddresses(network)} + + ); +}; + +const WifiDrawerPanelBody = ({ network, onCancel, onForget }) => { + const client = useInstallerClient(); + const forgetNetwork = async () => { + await client.network.deleteConnection(network.settings.id); + onForget(); + }; + + if (!network) return; + + const Form = () => ; + + if (network === HIDDEN_NETWORK) return ; + + if (network.settings && !network.device) { + return ( + + await client.network.connectTo(network.settings)}> + {_("Connect")} + + + {_("Edit")} + + + + ); + } + + // FIXME: stop using translations + switch (networkState(network.device?.state)) { + case _("Connecting"): + return ; + case _("Disconnected"): + return !network?.settings && ; + case _("Connected"): + return ( + + + + await client.network.disconnect(network.settings)}> + {_("Disconnect")} + + + {_("Edit")} + + + + + ); + default: + return ; + } +}; + +const NetworkFormName = ({ network }) => { + if (!network) return; + + return ( +

    + {network === HIDDEN_NETWORK ? _("Connect to a hidden network") : network.ssid} +

    + ); +}; + +const NetworkListName = ({ network }) => { + const state = networkState(network.device?.state); + + return ( + + {network.ssid} + {network.settings && } + {state === _("Connected") && } + + ); +}; + +/** + * Component for displaying a list of available Wi-Fi networks + * + * @param {object} props - component props + * @param {object[]} [props.networks=[]] - list of networks to show + * @param {object} [props.activeNetwork] - the active network + * @param {object} [props.selectedNetwork] - the selected network (not necessarily the same as active) + * @param {function} props.onSelectionCallback - the function to trigger when user selects a network + * @param {function} props.onCancelCallback - the function to trigger when user cancel dismiss before connecting to a network + */ +function WifiNetworksListPage({ + selected, + onSelectionChange, + networks = [], + forceUpdateNetworksCallback = () => { } +}) { + const selectHiddenNetwork = () => { + onSelectionChange(HIDDEN_NETWORK); + }; + + const selectNetwork = (ssid) => { + onSelectionChange(networks.find(n => n.ssid === ssid)); + }; + + const unselectNetwork = () => { + onSelectionChange(undefined); + }; + + const renderElements = () => { + return networks.map(n => { + return ( + + + + + + +
    {n.security.join(", ")}
    +
    {n.strength}
    +
    +
    + + ]} + /> +
    +
    + ); + }); + }; + + return ( + + + + + + + + + + + + + + + } + > + + + selectNetwork(ssid)} + > + {renderElements()} + + + + + + + + + ); +} + +export default WifiNetworksListPage; diff --git a/web/src/components/network/WifiSelector.jsx b/web/src/components/network/WifiSelector.jsx deleted file mode 100644 index 26dca25963..0000000000 --- a/web/src/components/network/WifiSelector.jsx +++ /dev/null @@ -1,154 +0,0 @@ -/* - * Copyright (c) [2022] SUSE LLC - * - * All Rights Reserved. - * - * This program is free software; you can redistribute it and/or modify it - * under the terms of version 2 of the GNU General Public License as published - * by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for - * more details. - * - * You should have received a copy of the GNU General Public License along - * with this program; if not, contact SUSE LLC. - * - * To contact SUSE LLC about this file by physical or electronic mail, you may - * find current contact information at www.suse.com. - */ - -import React, { useEffect, useState } from "react"; - -import { useInstallerClient } from "~/context/installer"; -import { NetworkEventTypes } from "~/client/network"; -import { Popup } from "~/components/core"; -import { WifiNetworksList } from "~/components/network"; -import { _ } from "~/i18n"; - -const networksFromValues = (networks) => Object.values(networks).flat(); -const baseHiddenNetwork = { ssid: undefined, hidden: true }; - -function WifiSelector({ isOpen = false, onClose }) { - const { network: client } = useInstallerClient(); - const [networks, setNetworks] = useState([]); - const [showHiddenForm, setShowHiddenForm] = useState(false); - const [devices, setDevices] = useState([]); - const [connections, setConnections] = useState([]); - const [accessPoints, setAccessPoints] = useState([]); - const [selectedNetwork, setSelectedNetwork] = useState(null); - const [activeNetwork, setActiveNetwork] = useState(null); - - const switchSelectedNetwork = (network) => { - setShowHiddenForm(network === baseHiddenNetwork); - setSelectedNetwork(network); - }; - - useEffect(() => { - client.devices().then(setDevices); - client.connections().then(setConnections); - client.accessPoints().then(setAccessPoints); - }, [client]); - - useEffect(() => { - const loadNetworks = async () => { - const knownSsids = []; - - return accessPoints - .sort((a, b) => b.strength - a.strength) - .reduce((networks, ap) => { - // Do not include networks without SSID - if (!ap.ssid || ap.ssid === "") return networks; - // Do not include "duplicates" - if (knownSsids.includes(ap.ssid)) return networks; - - const network = { - ...ap, - settings: connections.find(c => c.wireless?.ssid === ap.ssid), - device: devices.find(c => c.connection === ap.ssid), - error: undefined - }; - - // Group networks - if (network.device) { - networks.connected.push(network); - } else if (network.settings) { - networks.configured.push(network); - } else { - networks.others.push(network); - } - - knownSsids.push(network.ssid); - - return networks; - }, { connected: [], configured: [], others: [] }); - }; - - loadNetworks().then((data) => { - setNetworks(data); - setActiveNetwork(networksFromValues(data).find(d => d.device)); - }); - }, [client, connections, devices, accessPoints, isOpen]); - - useEffect(() => { - return client.onNetworkChange(({ type, payload }) => { - switch (type) { - case NetworkEventTypes.DEVICE_ADDED: { - setDevices((devs) => { - const newDevices = devs.filter((d) => d.name !== payload.name); - return [...newDevices, client.fromApiDevice(payload)]; - }); - break; - } - - case NetworkEventTypes.DEVICE_UPDATED: { - const [name, data] = payload; - if (data.state === "failed" && selectedNetwork) { - selectedNetwork.error = "Failed"; - } - setDevices(devs => { - const newDevices = devs.filter((d) => d.name !== name); - return [...newDevices, client.fromApiDevice(data)]; - }); - break; - } - - case NetworkEventTypes.DEVICE_REMOVED: { - setDevices(devs => devs.filter((d) => d.name !== payload)); - break; - } - } - client.connections().then(setConnections); - }); - }); - - return ( - - { - switchSelectedNetwork(network); - if (network.settings && !network.device) { - client.connectTo(network.settings); - } - }} - onCancelSelectionCallback={() => switchSelectedNetwork(activeNetwork)} - /> - - {_("Close")} - - - ); -} - -export default WifiSelector; diff --git a/web/src/components/network/WifiSelectorPage.jsx b/web/src/components/network/WifiSelectorPage.jsx new file mode 100644 index 0000000000..2e81023694 --- /dev/null +++ b/web/src/components/network/WifiSelectorPage.jsx @@ -0,0 +1,157 @@ +/* + * Copyright (c) [2024] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React, { useCallback, useEffect, useState } from "react"; +import { useInstallerClient } from "~/context/installer"; +import { NetworkEventTypes } from "~/client/network"; +import { Page } from "~/components/core"; +import { WifiNetworksListPage } from "~/components/network"; +import { _ } from "~/i18n"; +import { DeviceState } from "~/client/network/model"; +import { Grid, GridItem, Timestamp } from "@patternfly/react-core"; +import { useLoaderData } from "react-router-dom"; +import { useLocalStorage } from "~/utils"; + +const networksFromValues = (networks) => Object.values(networks).flat(); +const baseHiddenNetwork = { ssid: undefined, hidden: true }; + +// FIXME: use a reducer + +function WifiSelectorPage() { + const { network: client } = useInstallerClient(); + const { connections: initialConnections, devices: initialDevices, accessPoints, networks: initialNetworks } = useLoaderData(); + const [data, saveData] = useLocalStorage("agama-network", { selectedWifi: null }); + // Reevaluate how to keep the state in the future + const [selected, setSelected] = useState(data.selectedWifi); + const [networks, setNetworks] = useState(initialNetworks); + const [showHiddenForm, setShowHiddenForm] = useState(false); + const [devices, setDevices] = useState(initialDevices); + const [connections, setConnections] = useState(initialConnections); + const [activeNetwork, setActiveNetwork] = useState(null); + const [updateNetworks, setUpdateNetworks] = useState(false); + const [needAuth, setNeedAuth] = useState(null); + + const reloadNetworks = () => setUpdateNetworks(true); + + const selectNetwork = (network) => { + saveData({ selectedWifi: network }); + setSelected(network); + }; + + const switchSelectedNetwork = (network) => { + setSelected(network === baseHiddenNetwork); + }; + + const fetchNetworks = useCallback(async () => { + const devices = await client.devices(); + const connections = await client.connections(); + const networks = await client.loadNetworks(devices, connections, accessPoints); + setDevices(devices); + setConnections(connections); + setNetworks(networks); + }, [client, accessPoints]); + + useEffect(() => { + saveData(data); + }, [data, saveData]); + + useEffect(() => { + // Let's keep the selected network up to date after networks information is + // updated (e.g., if the network status change); + if (networks) { + setSelected(prev => { + return networksFromValues(networks).find(n => n.ssid === prev?.ssid); + }); + } + }, [networks]); + + useEffect(() => { + setActiveNetwork(networksFromValues(networks).find(d => d.device)); + }, [networks]); + + useEffect(() => { + if (!updateNetworks) return; + + setUpdateNetworks(false); + fetchNetworks(); + }, [fetchNetworks, updateNetworks]); + + useEffect(() => { + return client.onNetworkChange(({ type, payload }) => { + switch (type) { + case NetworkEventTypes.DEVICE_ADDED: { + reloadNetworks(); + break; + } + + case NetworkEventTypes.DEVICE_UPDATED: { + const [name, data] = payload; + const current_device = devices.find((d) => d.name === name); + + if (data.state === DeviceState.FAILED) { + if (current_device && (data.stateReason === 7)) { + console.log(`FAILED Device ${name} updated' with data`, data); + setNeedAuth(current_device.connection); + } + } + + reloadNetworks(); + break; + } + + case NetworkEventTypes.DEVICE_REMOVED: { + reloadNetworks(); + break; + } + } + }); + }); + + return ( + <> + +

    {_("Connect to a Wi-Fi network")}

    +
    + + + + + + + + + + + + + ); +} + +export default WifiSelectorPage; diff --git a/web/src/components/network/index.js b/web/src/components/network/index.js index 5cb1b445b2..24c2942a22 100644 --- a/web/src/components/network/index.js +++ b/web/src/components/network/index.js @@ -28,7 +28,5 @@ export { default as IpPrefixInput } from "./IpPrefixInput"; export { default as IpSettingsForm } from "./IpSettingsForm"; export { default as WifiConnectionForm } from "./WifiConnectionForm"; export { default as WifiHiddenNetworkForm } from "./WifiHiddenNetworkForm"; -export { default as WifiNetworkListItem } from "./WifiNetworkListItem"; -export { default as WifiNetworkMenu } from "./WifiNetworkMenu"; -export { default as WifiNetworksList } from "./WifiNetworksList"; -export { default as WifiSelector } from "./WifiSelector"; +export { default as WifiNetworksListPage } from "./WifiNetworksListPage"; +export { default as WifiSelectorPage } from "./WifiSelectorPage"; diff --git a/web/src/components/network/routes.js b/web/src/components/network/routes.js index 662ee7f137..b05b01479b 100644 --- a/web/src/components/network/routes.js +++ b/web/src/components/network/routes.js @@ -25,6 +25,7 @@ import { Page } from "~/components/core"; import NetworkPage from "./NetworkPage"; import IpSettingsForm from "./IpSettingsForm"; import { createDefaultClient } from "~/client"; +import WifiSelectorPage from "./WifiSelectorPage"; // FIXME: just to be discussed, most probably we should reading data directly in // the component in order to get it subscribed to changes. @@ -32,14 +33,23 @@ const client = await createDefaultClient(); const loaders = { all: async () => { + const devices = await client.network.devices(); const connections = await client.network.connections(); const settings = await client.network.settings(); - return { connections, settings }; + return { connections, devices, settings }; }, connection: async ({ params }) => { const connections = await client.network.connections(); return connections.find(c => c.id === params.id); - } + }, + wifis: async () => { + const connections = await client.network.connections(); + const devices = await client.network.devices(); + const accessPoints = await client.network.accessPoints(); + const networks = await client.network.loadNetworks(devices, connections, accessPoints); + + return { connections, devices, accessPoints, networks }; + }, }; const routes = { @@ -58,6 +68,11 @@ const routes = { handle: { name: _("Edit connection %s") } + }, + { + path: "wifis", + element: , + loader: loaders.wifis, } ] };