diff --git a/service/lib/agama/http/clients/base.rb b/service/lib/agama/http/clients/base.rb index 84804671fa..4a4940231a 100644 --- a/service/lib/agama/http/clients/base.rb +++ b/service/lib/agama/http/clients/base.rb @@ -29,7 +29,7 @@ module Clients # Base for HTTP clients. class Base def initialize(logger) - @base_url = "http://localhost/api/" + @base_url = "http://localhost/api/v2/" @logger = logger end diff --git a/service/lib/agama/http/clients/network.rb b/service/lib/agama/http/clients/network.rb index ad98ab1d0d..584d755655 100644 --- a/service/lib/agama/http/clients/network.rb +++ b/service/lib/agama/http/clients/network.rb @@ -26,20 +26,29 @@ module HTTP module Clients # HTTP client to interact with the network API. class Network < Base + def proposal + JSON.parse(get("proposal")) + end + def connections - JSON.parse(get("network/connections")) + proposal.fetch("network", {}).fetch("connections", []) end def devices - JSON.parse(get("network/devices")) + proposal.fetch("network", {}).fetch("devices", []) end def persist_connections - post("network/connections/persist", { value: true }) + conns = connections.map do |c| + c["persistent"] = true + c + end + + put("config", { "network" => { "connections" => conns, "generalState" => state } }) end def state - JSON.parse(get("network/state")) + proposal.fetch("network", {}).fetch("generalState", {}) end end end diff --git a/web/src/api/api.ts b/web/src/api/api.ts index 87911ea4ec..5a7bbda8ef 100644 --- a/web/src/api/api.ts +++ b/web/src/api/api.ts @@ -20,7 +20,7 @@ * find current contact information at www.suse.com. */ -import { get, patch, post } from "~/api/http"; +import { get, patch, post, put } from "~/api/http"; import { Config } from "~/types/config"; import { Proposal } from "~/types/proposal"; import { System } from "~/types/system"; @@ -39,9 +39,11 @@ const fetchProposal = (): Promise => get("/api/v2/proposal"); * Updates configuration */ const updateConfig = (config: Config) => patch("/api/v2/config", { update: config }); + +const setConfig = (config: Config) => put("/api/v2/config", config); /** * Triggers an action */ const trigger = (action) => post("/api/v2/action", action); -export { fetchSystem, fetchProposal, updateConfig, trigger }; +export { fetchSystem, fetchProposal, updateConfig, setConfig, trigger }; diff --git a/web/src/api/network.ts b/web/src/api/network.ts deleted file mode 100644 index ec919ca3d9..0000000000 --- a/web/src/api/network.ts +++ /dev/null @@ -1,109 +0,0 @@ -/* - * Copyright (c) [2024] SUSE LLC - * - * All Rights Reserved. - * - * This program is free software; you can redistribute it and/or modify it - * under the terms of 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 { del, get, post, put } from "~/api/http"; -import { APIAccessPoint, APIConnection, APIDevice, NetworkGeneralState } from "~/types/network"; - -/** - * Returns the network configuration - */ -const fetchState = (): Promise => get("/api/network/state"); - -/** - * Returns a list of known devices - */ -const fetchDevices = (): Promise => get("/api/network/devices"); - -/** - * Returns data for given connection name - */ -const fetchConnection = (name: string): Promise => - get(`/api/network/connections/${encodeURIComponent(name)}`); - -/** - * Returns the list of known connections - */ -const fetchConnections = (): Promise => get("/api/network/connections"); - -/** - * Returns the list of known access points - */ -const fetchAccessPoints = (): Promise => get("/api/network/wifi"); - -/** - * Adds a new connection - * - * @param connection - connection to be added - */ -const addConnection = (connection: APIConnection) => post("/api/network/connections", connection); - -/** - * Updates given connection - * - * @param connection - connection to be updated - */ -const updateConnection = (connection: APIConnection) => - put(`/api/network/connections/${encodeURIComponent(connection.id)}`, connection); - -/** - * Deletes the connection matching given name - */ -const deleteConnection = (name: string) => - del(`/api/network/connections/${encodeURIComponent(name)}`); - -/** - * Apply network changes - */ -const applyChanges = () => post("/api/network/system/apply"); - -/** - * Performs the connect action for connection matching given name - */ -const connect = (name: string) => - post(`/api/network/connections/${encodeURIComponent(name)}/connect`); - -/** - * Performs the disconnect action for connection matching given name - */ -const disconnect = (name: string) => - post(`/api/network/connections/${encodeURIComponent(name)}/disconnect`); - -/** - * Make the connection persistent after the installation - */ -const persist = (name: string, value: boolean) => - post(`/api/network/connections/persist`, { only: [name], value }); - -export { - fetchState, - fetchDevices, - fetchConnection, - fetchConnections, - fetchAccessPoints, - applyChanges, - addConnection, - updateConnection, - deleteConnection, - connect, - disconnect, - persist, -}; diff --git a/web/src/components/network/BindingSettingsForm.tsx b/web/src/components/network/BindingSettingsForm.tsx index d120a7a8e5..6e0a7a35c0 100644 --- a/web/src/components/network/BindingSettingsForm.tsx +++ b/web/src/components/network/BindingSettingsForm.tsx @@ -33,12 +33,14 @@ import { Stack, } from "@patternfly/react-core"; import { Page, SubtleContent } from "~/components/core"; -import { useConnection, useConnectionMutation, useNetworkDevices } from "~/queries/network"; +import { useConnection, useConfigMutation, useNetworkDevices } from "~/queries/network"; import { Connection, ConnectionBindingMode, Device } from "~/types/network"; +import { Config } from "~/types/config"; import Radio from "~/components/core/RadioEnhanced"; import { sprintf } from "sprintf-js"; import { _ } from "~/i18n"; import { connectionBindingMode } from "~/utils/network"; +import { useNetworkProposal } from "~/queries/proposal"; type DevicesSelectProps = Omit & { /** @@ -126,8 +128,9 @@ const formReducer = (state: FormState, action: FormAction): FormState => { * connection on any interface. */ export default function BindingSettingsForm() { + const proposal = useNetworkProposal(); const { id } = useParams(); - const { mutateAsync: updateConnection } = useConnectionMutation(); + const { mutateAsync: updateConfig } = useConfigMutation(); const connection = useConnection(id); const devices = useNetworkDevices(); const navigate = useNavigate(); @@ -148,7 +151,10 @@ export default function BindingSettingsForm() { macAddress: state.mode === "mac" ? state.mac : undefined, }); - updateConnection(updatedConnection) + proposal.addOrUpdateConnection(updatedConnection); + const config: Config = { network: proposal.toApi() }; + + updateConfig(config) .then(() => navigate(-1)) .catch(console.error); }; diff --git a/web/src/components/network/InstallationOnlySwitch.test.tsx b/web/src/components/network/InstallationOnlySwitch.test.tsx index 975d76ad21..a21ebc5efc 100644 --- a/web/src/components/network/InstallationOnlySwitch.test.tsx +++ b/web/src/components/network/InstallationOnlySwitch.test.tsx @@ -24,9 +24,15 @@ import React from "react"; import { screen } from "@testing-library/react"; import { plainRender } from "~/test-utils"; import InstallationOnlySwitch from "./InstallationOnlySwitch"; -import { Connection, ConnectionMethod, ConnectionOptions, ConnectionState } from "~/types/network"; +import { + Connection, + ConnectionMethod, + ConnectionOptions, + ConnectionState, + NetworkProposal, +} from "~/types/network"; -const mockPersistMutation = jest.fn(); +const mockUpdateConfig = jest.fn(); const mockConnection = (options: Partial = {}) => new Connection("Newtwork 2", { method4: ConnectionMethod.AUTO, @@ -39,11 +45,19 @@ const mockConnection = (options: Partial = {}) => state: ConnectionState.activating, ...options, }); +const mockProposal = () => { + new NetworkProposal([mockConnection({ persistent: true })]); +}; + +jest.mock("~/queries/proposal", () => ({ + ...jest.requireActual("~/queries/proposal"), + useNetworkProposal: () => mockProposal(), +})); jest.mock("~/queries/network", () => ({ ...jest.requireActual("~/queries/network"), - useConnectionPersistMutation: () => ({ - mutateAsync: mockPersistMutation, + useConfigMutation: () => ({ + mutateAsync: mockUpdateConfig, }), })); @@ -74,6 +88,6 @@ describe("InstallationOnlySwitch", () => { const { user } = plainRender(); const switchInput = screen.getByRole("switch", { name: "Use for installation only" }); await user.click(switchInput); - expect(mockPersistMutation).toHaveBeenCalledWith(connection); + expect(mockUpdateConfig).toHaveBeenCalledWith(connection); }); }); diff --git a/web/src/components/network/InstallationOnlySwitch.tsx b/web/src/components/network/InstallationOnlySwitch.tsx index cce915dbf3..d2654a775f 100644 --- a/web/src/components/network/InstallationOnlySwitch.tsx +++ b/web/src/components/network/InstallationOnlySwitch.tsx @@ -23,8 +23,10 @@ import React from "react"; import { Connection } from "~/types/network"; import { SwitchEnhanced } from "~/components/core"; -import { useConnectionPersistMutation } from "~/queries/network"; import { _ } from "~/i18n"; +import { useConfigMutation } from "~/queries/network"; +import { useNetworkProposal } from "~/queries/proposal"; +import { Config } from "~/types/config"; type InstallationOnlySwitchProps = { /** The connection to configure as installation-only or not */ @@ -39,8 +41,18 @@ type InstallationOnlySwitchProps = { * */ export default function InstallationOnlySwitch({ connection }: InstallationOnlySwitchProps) { - const { mutateAsync: togglePersist } = useConnectionPersistMutation(); - const onChange = () => togglePersist(connection); + const proposal = useNetworkProposal(); + const updatedConnection = new Connection(connection.id, { + ...connection, + persistent: !connection.persistent, + }); + const { mutateAsync: updateConfig } = useConfigMutation(); + const onChange = () => { + proposal.addOrUpdateConnection(updatedConnection); + const config: Config = { network: proposal.toApi() }; + + updateConfig(config); + }; return ( method === ConnectionMethod.AUTO; // FIXME: rename to connedtioneditpage or so? // FIXME: improve the layout a bit. export default function IpSettingsForm() { + const proposal = useNetworkProposal(); + const { mutateAsync: updateConfig } = useConfigMutation(); const { id } = useParams(); const navigate = useNavigate(); - const { mutateAsync: updateConnection } = useConnectionMutation(); const connection = useConnection(id); const [addresses, setAddresses] = useState(connection.addresses); const [nameservers, setNameservers] = useState( @@ -62,7 +65,7 @@ export default function IpSettingsForm() { const [method, setMethod] = useState(connection.method4); const [gateway, setGateway] = useState(connection.gateway4); const [fieldErrors, setFieldErrors] = useState({}); - const [requestError, setRequestError] = useState(); + const [requestError] = useState(); const isSetAsInvalid = (field: string) => Object.keys(fieldErrors).includes(field); const isGatewayDisabled = addresses.length === 0; @@ -127,11 +130,12 @@ export default function IpSettingsForm() { nameservers: sanitizedNameservers.map((s) => s.address), }); - updateConnection(updatedConnection) + proposal.addOrUpdateConnection(updatedConnection); + const config: Config = { network: proposal.toApi() }; + + updateConfig(config) .then(() => navigate(-1)) - .catch((error) => { - setRequestError(error.message); - }); + .catch(console.error); }; const renderError = (field: string) => { diff --git a/web/src/components/network/NetworkPage.test.tsx b/web/src/components/network/NetworkPage.test.tsx index 41e61ab0e3..912a94895a 100644 --- a/web/src/components/network/NetworkPage.test.tsx +++ b/web/src/components/network/NetworkPage.test.tsx @@ -24,6 +24,7 @@ import React from "react"; import { screen } from "@testing-library/react"; import { installerRender } from "~/test-utils"; import NetworkPage from "~/components/network/NetworkPage"; +import { NetworkProposal } from "~/types/network"; jest.mock( "~/components/product/ProductRegistrationAlert", @@ -41,12 +42,21 @@ jest.mock("~/components/network/NoPersistentConnectionsAlert", () => () => ( )); const mockNetworkState = { + connectivity: true, + hostname: "Agama", + networkingEnabled: true, wirelessEnabled: true, }; +const mockNetworkProposal = { + connections: [], + state: mockNetworkState, +}; + jest.mock("~/queries/network", () => ({ useNetworkChanges: jest.fn(), - useNetworkState: () => mockNetworkState, + useNetworkProposal: () => + new NetworkProposal(mockNetworkProposal.connections, mockNetworkProposal.state), })); describe("NetworkPage", () => { diff --git a/web/src/components/network/NetworkPage.tsx b/web/src/components/network/NetworkPage.tsx index 348083bc6a..dceaf99c03 100644 --- a/web/src/components/network/NetworkPage.tsx +++ b/web/src/components/network/NetworkPage.tsx @@ -23,11 +23,12 @@ import React from "react"; import { Content, Grid, GridItem } from "@patternfly/react-core"; import { EmptyState, Page } from "~/components/core"; -import { useNetworkChanges, useNetworkState } from "~/queries/network"; +import { useNetworkChanges } from "~/queries/network"; import WifiNetworksList from "./WifiNetworksList"; import WiredConnectionsList from "./WiredConnectionsList"; import NoPersistentConnectionsAlert from "./NoPersistentConnectionsAlert"; import { _ } from "~/i18n"; +import { useSystem } from "~/queries/system"; const NoWifiAvailable = () => ( @@ -44,7 +45,7 @@ const NoWifiAvailable = () => ( */ export default function NetworkPage() { useNetworkChanges(); - const networkState = useNetworkState(); + const { network: networkSystem } = useSystem(); return ( @@ -62,7 +63,7 @@ export default function NetworkPage() { - {networkState.wirelessEnabled ? ( + {networkSystem.state.wirelessEnabled ? ( diff --git a/web/src/components/network/NoPersistentConnectionsAlert.tsx b/web/src/components/network/NoPersistentConnectionsAlert.tsx index a2d0a66d90..f6ff2e8368 100644 --- a/web/src/components/network/NoPersistentConnectionsAlert.tsx +++ b/web/src/components/network/NoPersistentConnectionsAlert.tsx @@ -22,16 +22,17 @@ import React from "react"; import { Alert } from "@patternfly/react-core"; -import { useConnections } from "~/queries/network"; import { Connection } from "~/types/network"; import { _ } from "~/i18n"; +import { useNetworkProposal } from "~/queries/proposal"; /** * Displays a warning alert when no network connections are set to persist in * the installed system. */ export default function NoPersistentConnectionsAlert() { - const connections: Connection[] = useConnections(); + const proposal = useNetworkProposal(); + const connections: Connection[] = proposal.connections; const persistentConnections: number = connections.filter((c) => c.persistent).length; if (persistentConnections !== 0) return; diff --git a/web/src/components/network/WifiConnectionDetails.test.tsx b/web/src/components/network/WifiConnectionDetails.test.tsx index e792927c82..00334bd9d8 100644 --- a/web/src/components/network/WifiConnectionDetails.test.tsx +++ b/web/src/components/network/WifiConnectionDetails.test.tsx @@ -59,6 +59,7 @@ const mockNetwork = { strength: 25, hwAddress: "??", security: [SecurityProtocols.RSN], + device_name: "wlan0", device: wlan0, settings: new Connection("Network 1", { iface: "wlan0", diff --git a/web/src/components/network/WifiConnectionForm.test.tsx b/web/src/components/network/WifiConnectionForm.test.tsx index 9c0afd1844..4a49f90b47 100644 --- a/web/src/components/network/WifiConnectionForm.test.tsx +++ b/web/src/components/network/WifiConnectionForm.test.tsx @@ -32,9 +32,6 @@ const mockUpdateConnection = jest.fn(); jest.mock("~/queries/network", () => ({ ...jest.requireActual("~/queries/network"), useNetworkChanges: jest.fn(), - useAddConnectionMutation: () => ({ - mutateAsync: mockAddConnection, - }), useConnectionMutation: () => ({ mutateAsync: mockUpdateConnection, }), @@ -44,6 +41,7 @@ jest.mock("~/queries/network", () => ({ const networkMock = { ssid: "Visible Network", hidden: false, + device_name: "wlan0", status: WifiNetworkStatus.NOT_CONFIGURED, hwAddress: "00:EB:D8:17:6B:56", security: [SecurityProtocols.WPA], diff --git a/web/src/components/network/WifiConnectionForm.tsx b/web/src/components/network/WifiConnectionForm.tsx index 1da7875d18..51fd15d3bc 100644 --- a/web/src/components/network/WifiConnectionForm.tsx +++ b/web/src/components/network/WifiConnectionForm.tsx @@ -32,11 +32,13 @@ import { Spinner, } from "@patternfly/react-core"; import { Page, PasswordInput } from "~/components/core"; -import { useAddConnectionMutation, useConnectionMutation, useConnections } from "~/queries/network"; +import { useConfigMutation } from "~/queries/network"; import { Connection, ConnectionState, WifiNetwork, Wireless } from "~/types/network"; +import { Config } from "~/types/config"; import { isEmpty } from "radashi"; import { sprintf } from "sprintf-js"; import { _ } from "~/i18n"; +import { useNetworkProposal } from "~/queries/proposal"; const securityOptions = [ // TRANSLATORS: WiFi authentication mode @@ -91,8 +93,8 @@ 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 proposal = useNetworkProposal(); + const connection = proposal.connections.find((c) => c.id === network.ssid); const settings = network.settings?.wireless || new Wireless(); const [error, setError] = useState(false); const [security, setSecurity] = useState( @@ -103,8 +105,7 @@ export default function WifiConnectionForm({ network }: { network: WifiNetwork } const [isConnecting, setIsConnecting] = useState( connection?.state === ConnectionState.activating, ); - const { mutateAsync: addConnection } = useAddConnectionMutation(); - const { mutateAsync: updateConnection } = useConnectionMutation(); + const { mutateAsync: updateConfig } = useConfigMutation(); useEffect(() => { if (!isActivating) return; @@ -132,8 +133,10 @@ export default function WifiConnectionForm({ network }: { network: WifiNetwork } password, hidden: false, }); - const action = network.settings ? updateConnection : addConnection; - action(nextConnection).catch(() => setError(true)); + + proposal.addOrUpdateConnection(nextConnection); + const config: Config = { network: proposal.toApi() }; + updateConfig(config).catch(() => setError(true)); setError(false); setIsConnecting(true); }; diff --git a/web/src/components/network/WifiNetworksList.test.tsx b/web/src/components/network/WifiNetworksList.test.tsx index 437ef8456c..5009e7f63e 100644 --- a/web/src/components/network/WifiNetworksList.test.tsx +++ b/web/src/components/network/WifiNetworksList.test.tsx @@ -49,20 +49,12 @@ const wlan0: Device = { macAddress: "AA:11:22:33:44::FF", }; -const mockConnectionRemoval = jest.fn(); -const mockAddConnection = jest.fn(); let mockWifiNetworks: WifiNetwork[]; let mockWifiConnections: Connection[]; jest.mock("~/queries/network", () => ({ ...jest.requireActual("~/queries/network"), useNetworkChanges: jest.fn(), - useRemoveConnectionMutation: () => ({ - mutate: mockConnectionRemoval, - }), - useAddConnectionMutation: () => ({ - mutate: mockAddConnection, - }), useWifiNetworks: () => mockWifiNetworks, useConnections: () => mockWifiConnections, })); @@ -94,6 +86,7 @@ describe("WifiNetworksList", () => { iface: "wlan0", addresses: [{ address: "192.168.69.201", prefix: 24 }], }), + device_name: "wlan0", status: WifiNetworkStatus.CONNECTED, }, { @@ -105,6 +98,7 @@ describe("WifiNetworksList", () => { iface: "wlan1", addresses: [{ address: "192.168.69.202", prefix: 24 }], }), + device_name: "wlan1", status: WifiNetworkStatus.CONFIGURED, }, { @@ -112,6 +106,7 @@ describe("WifiNetworksList", () => { strength: 66, hwAddress: "??", security: [], + device_name: "wlan0", status: WifiNetworkStatus.NOT_CONFIGURED, }, ]; @@ -168,6 +163,7 @@ describe("WifiNetworksList", () => { iface: "wlan1", addresses: [{ address: "192.168.69.202", prefix: 24 }], }), + device_name: "wlan1", status: WifiNetworkStatus.CONFIGURED, }, ]; @@ -206,6 +202,7 @@ describe("WifiNetworksList", () => { iface: "wlan1", addresses: [{ address: "192.168.69.202", prefix: 24 }], }), + device_name: "wlan1", status: WifiNetworkStatus.CONFIGURED, }, ]; diff --git a/web/src/components/network/WiredConnectionPage.tsx b/web/src/components/network/WiredConnectionPage.tsx index affc71addf..607f52dbfa 100644 --- a/web/src/components/network/WiredConnectionPage.tsx +++ b/web/src/components/network/WiredConnectionPage.tsx @@ -30,13 +30,14 @@ import { EmptyStateFooter, } from "@patternfly/react-core"; import { Link, Page } from "~/components/core"; -import { useConnections, useNetworkChanges } from "~/queries/network"; +import { 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"; import NoPersistentConnectionsAlert from "./NoPersistentConnectionsAlert"; +import { useNetworkProposal } from "~/queries/proposal"; const ConnectionNotFound = ({ id }) => { // TRANSLATORS: %s will be replaced with connection id @@ -62,8 +63,8 @@ const ConnectionNotFound = ({ id }) => { export default function WiredConnectionPage() { useNetworkChanges(); + const { connections } = useNetworkProposal(); const { id } = useParams(); - const connections = useConnections(); const connection = connections.find((c) => c.id === id); const title = _("Connection details"); diff --git a/web/src/components/network/WiredConnectionsList.tsx b/web/src/components/network/WiredConnectionsList.tsx index 7c09839980..0be1da6f8e 100644 --- a/web/src/components/network/WiredConnectionsList.tsx +++ b/web/src/components/network/WiredConnectionsList.tsx @@ -35,17 +35,18 @@ import { import a11yStyles from "@patternfly/react-styles/css/utilities/Accessibility/accessibility"; import { Annotation, 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"; +import { useNetworkSystem } from "~/queries/system"; +import { useNetworkProposal } from "~/queries/proposal"; type ConnectionListItemProps = { connection: Connection }; const ConnectionListItem = ({ connection }: ConnectionListItemProps) => { const nameId = useId(); const ipId = useId(); - const devices = useNetworkDevices(); + const { devices } = useNetworkSystem(); const device = devices.find( ({ connection: deviceConnectionId }) => deviceConnectionId === connection.id, @@ -83,7 +84,7 @@ const ConnectionListItem = ({ connection }: ConnectionListItemProps) => { */ function WiredConnectionsList(props: DataListProps) { const navigate = useNavigate(); - const connections = useConnections(); + const { connections } = useNetworkProposal(); const wiredConnections = connections.filter((c) => !c.wireless); if (wiredConnections.length === 0) { diff --git a/web/src/queries/network.ts b/web/src/queries/network.ts index 09a37ee7e0..b91c536978 100644 --- a/web/src/queries/network.ts +++ b/web/src/queries/network.ts @@ -21,7 +21,7 @@ */ import React, { useCallback } from "react"; -import { useQueryClient, useMutation, useSuspenseQuery } from "@tanstack/react-query"; +import { useQueryClient, useMutation } from "@tanstack/react-query"; import { useInstallerClient } from "~/context/installer"; import { AccessPoint, @@ -33,178 +33,21 @@ import { WifiNetwork, WifiNetworkStatus, } from "~/types/network"; -import { - addConnection, - applyChanges, - deleteConnection, - fetchAccessPoints, - fetchConnection, - fetchConnections, - fetchDevices, - fetchState, - persist, - updateConnection, -} from "~/api/network"; - -/** - * Returns a query for retrieving the general network configuration - */ -const stateQuery = () => { - return { - queryKey: ["network", "state"], - queryFn: fetchState, - }; -}; - -/** - * Returns a query for retrieving the list of known devices - */ -const devicesQuery = () => ({ - queryKey: ["network", "devices"], - queryFn: async () => { - const devices = await fetchDevices(); - return devices.map(Device.fromApi); - }, - staleTime: Infinity, -}); - -/** - * Returns a query for retrieving data for the given connection name - */ -const connectionQuery = (name: string) => ({ - queryKey: ["network", "connections", name], - queryFn: async () => { - const connection = await fetchConnection(name); - return Connection.fromApi(connection); - }, - staleTime: Infinity, -}); - -/** - * Returns a query for retrieving the list of known connections - */ -const connectionsQuery = () => ({ - queryKey: ["network", "connections"], - queryFn: async () => { - const connections = await fetchConnections(); - return connections.map(Connection.fromApi); - }, - staleTime: Infinity, -}); - -/** - * Returns a query for retrieving the list of known access points sortered by - * the signal strength. - */ -const accessPointsQuery = () => ({ - queryKey: ["network", "accessPoints"], - queryFn: async (): Promise => { - const accessPoints = await fetchAccessPoints(); - return accessPoints.map(AccessPoint.fromApi).sort((a, b) => b.strength - a.strength); - }, - // FIXME: Infinity vs 1second - staleTime: 1000, -}); - -/** - * Hook that builds a mutation to add a new network connection - * - * It does not require to call `useMutation`. - */ -const useAddConnectionMutation = () => { - const queryClient = useQueryClient(); - const query = { - mutationFn: (newConnection: Connection) => - addConnection(newConnection.toApi()).then(() => applyChanges()), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["network", "connections"] }); - queryClient.invalidateQueries({ queryKey: ["network", "devices"] }); - queryClient.invalidateQueries({ queryKey: ["network", "accessPoints"] }); - }, - }; - return useMutation(query); -}; +import { useNetworkProposal } from "./proposal"; +import { useNetworkSystem } from "./system"; +import { updateConfig } from "~/api/api"; /** * Hook that builds a mutation to update a network connection * * It does not require to call `useMutation`. */ -const useConnectionMutation = () => { +const useConfigMutation = () => { const queryClient = useQueryClient(); const query = { - mutationFn: (newConnection: Connection) => - updateConnection(newConnection.toApi()).then(() => applyChanges()), + mutationFn: updateConfig, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["network", "connections"] }); - queryClient.invalidateQueries({ queryKey: ["network", "devices"] }); - }, - }; - return useMutation(query); -}; - -/** - * Hook that provides a mutation for toggling the "persistent" state of a network - * connection. - * - * This hook uses optimistic updates to immediately reflect the change in the UI - * before the mutation completes. If the mutation fails, it will rollback to the - * previous state. - */ -const useConnectionPersistMutation = () => { - const queryClient = useQueryClient(); - const query = { - mutationFn: (connection: Connection) => { - return persist(connection.id, !connection.persistent); - }, - onMutate: async (connection: Connection) => { - // Get the current list of cached connections - const previousConnections: Connection[] = queryClient.getQueryData([ - "network", - "connections", - ]); - - // Optimistically toggle the 'persistent' status of the matching connection - const updatedConnections = previousConnections.map((cachedConnection) => { - if (connection.id !== cachedConnection.id) return cachedConnection; - - const { id, ...nextConnection } = cachedConnection; - return new Connection(id, { ...nextConnection, persistent: !cachedConnection.persistent }); - }); - - // Update the cached data with the optimistically updated connections - queryClient.setQueryData(["network", "connections"], updatedConnections); - - // Return the previous state for potential rollback - return { previousConnections }; - }, - - /** - * Called if the mutation fails for whatever reason. Rolls back the cache to - * the previous state. - */ - onError: (_, connection: Connection, context: { previousConnections: Connection[] }) => { - queryClient.setQueryData(["network", "connections"], context.previousConnections); - }, - }; - - return useMutation(query); -}; -/** - * Hook that builds a mutation to remove a network connection - * - * It does not require to call `useMutation`. - */ -const useRemoveConnectionMutation = () => { - const queryClient = useQueryClient(); - const query = { - mutationFn: (name: string) => - deleteConnection(name) - .then(() => applyChanges()) - .catch((e) => console.log(e)), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["network", "connections"] }); - queryClient.invalidateQueries({ queryKey: ["network", "devices"] }); + queryClient.invalidateQueries({ queryKey: ["proposal"] }); }, }; return useMutation(query); @@ -287,32 +130,37 @@ const useNetworkChanges = () => { }; const useConnection = (name: string) => { - const { data } = useSuspenseQuery(connectionQuery(name)); - return data; + const { connections } = useNetworkProposal(); + const connection = connections.find((c) => c.id === name); + + return connection; }; /** * Returns the general state of the network. */ const useNetworkState = (): NetworkGeneralState => { - const { data } = useSuspenseQuery(stateQuery()); - return data; + const { state } = useNetworkProposal(); + + return state; }; /** * Returns the network devices. */ const useNetworkDevices = (): Device[] => { - const { data } = useSuspenseQuery(devicesQuery()); - return data; + const { devices } = useNetworkSystem(); + + return devices; }; /** * Returns the network connections. */ const useConnections = (): Connection[] => { - const { data } = useSuspenseQuery(connectionsQuery()); - return data; + const { connections } = useNetworkProposal(); + + return connections; }; /** @@ -321,9 +169,8 @@ const useConnections = (): Connection[] => { const useWifiNetworks = () => { const knownSsids: string[] = []; - const devices = useNetworkDevices(); + const { devices, accessPoints } = useNetworkSystem(); const connections = useConnections(); - const { data: accessPoints } = useSuspenseQuery(accessPointsQuery()); return accessPoints .filter((ap: AccessPoint) => { @@ -357,16 +204,8 @@ const useWifiNetworks = () => { }; export { - stateQuery, - devicesQuery, - connectionQuery, - connectionsQuery, - accessPointsQuery, - useAddConnectionMutation, useConnections, - useConnectionMutation, - useConnectionPersistMutation, - useRemoveConnectionMutation, + useConfigMutation, useConnection, useNetworkDevices, useNetworkState, diff --git a/web/src/queries/proposal.ts b/web/src/queries/proposal.ts index c83df902f2..80b5251dec 100644 --- a/web/src/queries/proposal.ts +++ b/web/src/queries/proposal.ts @@ -24,6 +24,7 @@ import React from "react"; import { useSuspenseQuery, useQueryClient } from "@tanstack/react-query"; import { useInstallerClient } from "~/context/installer"; import { fetchProposal } from "~/api/api"; +import { NetworkProposal } from "~/types/network"; /** * Returns a query for retrieving the proposal @@ -35,9 +36,19 @@ const proposalQuery = () => { }; }; +const useNetworkProposal = () => { + const { data } = useSuspenseQuery({ + ...proposalQuery(), + select: (d) => NetworkProposal.fromApi(d.network), + }); + + return data; +}; + const useProposal = () => { - const { data: config } = useSuspenseQuery(proposalQuery()); - return config; + const { data } = useSuspenseQuery(proposalQuery()); + + return data; }; const useProposalChanges = () => { @@ -48,10 +59,11 @@ const useProposalChanges = () => { if (!client) return; return client.onEvent((event) => { - if (event.type === "ProposalChanged" && event.scope === "localization") { + const invalidateEvents = ["l10n", "network"]; + if (invalidateEvents.includes(event.type) && event.name === "ProposalChanged") { queryClient.invalidateQueries({ queryKey: ["proposal"] }); } }); }, [client, queryClient]); }; -export { useProposal, useProposalChanges }; +export { useProposal, useProposalChanges, useNetworkProposal }; diff --git a/web/src/queries/system.ts b/web/src/queries/system.ts index 8d792c5c33..670ead4033 100644 --- a/web/src/queries/system.ts +++ b/web/src/queries/system.ts @@ -25,6 +25,7 @@ import { tzOffset } from "@date-fns/tz/tzOffset"; import { useQueryClient, useSuspenseQuery } from "@tanstack/react-query"; import { useInstallerClient } from "~/context/installer"; import { fetchSystem } from "~/api/api"; +import { NetworkSystem } from "~/types/network"; import { System } from "~/types/system"; const transformLocales = (locales) => @@ -77,6 +78,21 @@ const useSystem = () => { return system; }; +const useNetworkSystem = () => { + const { data } = useSuspenseQuery({ + ...systemQuery(), + select: (d) => NetworkSystem.fromApi(d.network), + }); + + return data; +}; + +const useNetworkDevices = () => { + const { devices } = useNetworkSystem(); + + return devices; +}; + const useSystemChanges = () => { const queryClient = useQueryClient(); const client = useInstallerClient(); @@ -92,4 +108,4 @@ const useSystemChanges = () => { }, [client, queryClient]); }; -export { useSystem, useSystemChanges }; +export { useSystem, useSystemChanges, useNetworkSystem, useNetworkDevices }; diff --git a/web/src/types/config.ts b/web/src/types/config.ts index f7248c72cf..5b441bcc48 100644 --- a/web/src/types/config.ts +++ b/web/src/types/config.ts @@ -21,9 +21,11 @@ */ import { Localization } from "./l10n"; +import { APINetworkProposal } from "./network"; type Config = { l10n?: Localization; + network?: APINetworkProposal; }; export type { Config }; diff --git a/web/src/types/network.ts b/web/src/types/network.ts index d330736f95..f900bcd8d0 100644 --- a/web/src/types/network.ts +++ b/web/src/types/network.ts @@ -92,6 +92,7 @@ enum DeviceState { enum ConnectionStatus { UP = "up", DOWN = "down", + DELETE = "delete", } // Current state of the connection. @@ -140,6 +141,7 @@ type Route = { }; type APIAccessPoint = { + device: string; ssid: string; strength: number; hwAddress: string; @@ -149,12 +151,20 @@ type APIAccessPoint = { }; class AccessPoint { + device_name: string; ssid: string; strength: number; hwAddress: string; security: SecurityProtocols[]; - constructor(ssid: string, strength: number, hwAddress: string, security: SecurityProtocols[]) { + constructor( + device: string, + ssid: string, + strength: number, + hwAddress: string, + security: SecurityProtocols[], + ) { + this.device_name = device; this.ssid = ssid; this.strength = strength; this.hwAddress = hwAddress; @@ -162,9 +172,15 @@ class AccessPoint { } static fromApi(options: APIAccessPoint) { - const { ssid, strength, hwAddress, flags, wpaFlags, rsnFlags } = options; - - return new AccessPoint(ssid, strength, hwAddress, securityFromFlags(flags, wpaFlags, rsnFlags)); + const { device, ssid, strength, hwAddress, flags, wpaFlags, rsnFlags } = options; + + return new AccessPoint( + device, + ssid, + strength, + hwAddress, + securityFromFlags(flags, wpaFlags, rsnFlags), + ); } } @@ -360,6 +376,79 @@ type NetworkGeneralState = { wirelessEnabled: boolean; }; +class NetworkSystem { + connections: Connection[]; + accessPoints: AccessPoint[]; + devices: Device[]; + state: NetworkGeneralState; + + constructor( + connections?: Connection[], + accessPoints?: AccessPoint[], + devices?: Device[], + state?: NetworkGeneralState, + ) { + if (connections !== undefined) this.connections = connections; + if (accessPoints !== undefined) this.accessPoints = accessPoints; + if (devices !== undefined) this.devices = devices; + if (state !== undefined) this.state = state; + } + + static fromApi(options: APINetworkSystem) { + const { connections: conns, accessPoints: aps, devices: devs, state } = options; + const connections = conns.map(Connection.fromApi); + const accessPoints = aps.map(AccessPoint.fromApi).sort((a, b) => b.strength - a.strength); + const devices = devs.map(Device.fromApi); + + return new NetworkSystem(connections, accessPoints, devices, state); + } +} + +class NetworkProposal { + connections: Connection[]; + state: NetworkGeneralState; + + constructor( + connections?: Connection[], + //accessPoints?: AccessPoint[], + //devices?: Device[], + state?: NetworkGeneralState, + ) { + if (connections !== undefined) this.connections = connections; + if (state !== undefined) this.state = state; + } + + static fromApi(options: APINetworkProposal) { + const { connections, state } = options; + const conns = connections.map((c) => Connection.fromApi(c)); + + return new NetworkProposal(conns, state); + } + + addOrUpdateConnection(connection: Connection) { + const connections = this.connections.map((c) => (c.id === connection.id ? connection : c)); + this.connections = connections; + } + + toApi(): APINetworkProposal { + const connections = this.connections.map((c) => c.toApi()); + + return { connections, state: this.state }; + } +} + +type APINetworkSystem = { + connections: APIConnection[]; + accessPoints: APIAccessPoint[]; + devices: APIDevice[]; + state: NetworkGeneralState; +}; + +type APINetworkProposal = { + connections?: APIConnection[]; + state?: NetworkGeneralState; +}; + export { AccessPoint, ApFlags, @@ -373,6 +462,8 @@ export { DeviceState, DeviceType, NetworkState, + NetworkProposal, + NetworkSystem, SecurityProtocols, WifiNetworkStatus, Wireless, @@ -385,6 +476,8 @@ export type { ConnectionOptions, APIDevice, IPAddress, + APINetworkProposal, + APINetworkSystem, NetworkGeneralState, Route, APIRoute, diff --git a/web/src/types/proposal.ts b/web/src/types/proposal.ts index 1eacf176f9..0954ec85cf 100644 --- a/web/src/types/proposal.ts +++ b/web/src/types/proposal.ts @@ -21,9 +21,11 @@ */ import { Localization } from "./l10n"; +import { APINetworkProposal } from "./network"; type Proposal = { l10n?: Localization; + network?: APINetworkProposal; }; export type { Proposal }; diff --git a/web/src/types/system.ts b/web/src/types/system.ts index 60fb1f35c1..2061cea3ef 100644 --- a/web/src/types/system.ts +++ b/web/src/types/system.ts @@ -21,9 +21,11 @@ */ import { Localization } from "./l10n"; +import { APINetworkSystem } from "./network"; type System = { l10n?: Localization; + network?: APINetworkSystem; }; export type { System };