diff --git a/ui/webui/src/actions/network-actions.js b/ui/webui/src/actions/network-actions.js new file mode 100644 index 00000000000..f270db51047 --- /dev/null +++ b/ui/webui/src/actions/network-actions.js @@ -0,0 +1,31 @@ +/* + * Copyright (C) 2023 Red Hat, Inc. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation; either version 2.1 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 + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with This program; If not, see . + */ + +import { + getConnected, +} from "../apis/network.js"; + +export const getConnectedAction = () => { + return async (dispatch) => { + const connected = await getConnected(); + + return dispatch({ + type: "GET_NETWORK_CONNECTED", + payload: { connected } + }); + }; +}; diff --git a/ui/webui/src/apis/network.js b/ui/webui/src/apis/network.js new file mode 100644 index 00000000000..7954aceedea --- /dev/null +++ b/ui/webui/src/apis/network.js @@ -0,0 +1,83 @@ +/* + * Copyright (C) 2023 Red Hat, Inc. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation; either version 2.1 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 + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with This program; If not, see . + */ + +import cockpit from "cockpit"; + +import { getConnectedAction } from "../actions/network-actions.js"; +import { debug } from "../helpers/log.js"; + +export class NetworkClient { + constructor (address) { + if (NetworkClient.instance && (!address || NetworkClient.instance.address === address)) { + return NetworkClient.instance; + } + + NetworkClient.instance?.client.close(); + + NetworkClient.instance = this; + + this.client = cockpit.dbus( + "org.fedoraproject.Anaconda.Modules.Network", + { superuser: "try", bus: "none", address } + ); + this.address = address; + } + + init () { + this.client.addEventListener("close", () => console.error("Network client closed")); + } +} + +/** + * @returns {Promise} The bool state of the network connection + */ +export const getConnected = () => { + return ( + new NetworkClient().client.call( + "/org/fedoraproject/Anaconda/Modules/Network", + "org.freedesktop.DBus.Properties", + "Get", + ["org.fedoraproject.Anaconda.Modules.Network", "Connected"] + ) + .then(res => res[0].v) + ); +}; + +export const startEventMonitorNetwork = ({ dispatch }) => { + return new NetworkClient().client.subscribe( + { }, + (path, iface, signal, args) => { + switch (signal) { + case "PropertiesChanged": + if (args[0] === "org.fedoraproject.Anaconda.Modules.Network" && Object.hasOwn(args[1], "Connected")) { + dispatch(getConnectedAction()); + } else { + debug(`Unhandled signal on ${path}: ${iface}.${signal}`, JSON.stringify(args)); + } + break; + default: + debug(`Unhandled signal on ${path}: ${iface}.${signal}`, JSON.stringify(args)); + } + } + ); +}; + +export const initDataNetwork = ({ dispatch }) => { + return Promise.all([ + dispatch(getConnectedAction()) + ]); +}; diff --git a/ui/webui/src/components/AnacondaHeader.jsx b/ui/webui/src/components/AnacondaHeader.jsx index b1610935a16..f3d722ed040 100644 --- a/ui/webui/src/components/AnacondaHeader.jsx +++ b/ui/webui/src/components/AnacondaHeader.jsx @@ -31,7 +31,7 @@ import { HeaderKebab } from "./HeaderKebab.jsx"; const _ = cockpit.gettext; -export const AnacondaHeader = ({ beta, title, reportLinkURL }) => { +export const AnacondaHeader = ({ beta, title, reportLinkURL, isConnected }) => { const prerelease = _("Pre-release"); const betanag = beta ? ( @@ -68,7 +68,7 @@ export const AnacondaHeader = ({ beta, title, reportLinkURL }) => { {title} {betanag} - + ); diff --git a/ui/webui/src/components/Error.jsx b/ui/webui/src/components/Error.jsx index bbe2629a051..3e353d015f8 100644 --- a/ui/webui/src/components/Error.jsx +++ b/ui/webui/src/components/Error.jsx @@ -27,12 +27,14 @@ import { HelperTextItem, Modal, ModalVariant, + Stack, + StackItem, TextArea, TextContent, TextVariants, Text, } from "@patternfly/react-core"; -import { ExternalLinkAltIcon } from "@patternfly/react-icons"; +import { ExternalLinkAltIcon, DisconnectedIcon } from "@patternfly/react-icons"; import { exitGui } from "../helpers/exit.js"; @@ -62,7 +64,8 @@ export const BZReportModal = ({ logFile, detailsLabel, detailsContent, - buttons + buttons, + isConnected }) => { const [logContent, setLogContent] = useState(); const [preparingReport, setPreparingReport] = useState(false); @@ -92,18 +95,27 @@ export const BZReportModal = ({ titleIconVariant={titleIconVariant} variant={ModalVariant.large} footer={ - <> - - {buttons} - + + + + {isConnected + ? {_("Reporting an issue will send information over the network. Please review and edit the attached log to remove any sensitive information.")} + : }> {_("Network not available. Configure the network in the top bar menu to report the issue.")} } + + + + + {buttons} + + }>
{detailsLabel && @@ -125,11 +137,6 @@ export const BZReportModal = ({ isDisabled={logContent === undefined || preparingReport} rows={25} /> - - - {_("Reporting an issue will send information over the network. Plese review and edit the attached log to remove any sensitive information.")} - -
@@ -168,7 +175,7 @@ const quitButton = (isBootIso) => { ); }; -export const CriticalError = ({ exception, isBootIso, reportLinkURL }) => { +export const CriticalError = ({ exception, isBootIso, isConnected, reportLinkURL }) => { const context = exception.contextData?.context; const description = context ? cockpit.format(_("The installer cannot continue due to a critical error: $0"), _(context)) @@ -186,6 +193,7 @@ export const CriticalError = ({ exception, isBootIso, reportLinkURL }) => { detailsLabel={_("Error details")} detailsContent={exceptionInfo(exception, idPrefix)} buttons={[quitButton(isBootIso)]} + isConnected={isConnected} /> ); @@ -208,7 +216,7 @@ const cancelButton = (onClose) => { ); }; -export const UserIssue = ({ reportLinkURL, setIsReportIssueOpen }) => { +export const UserIssue = ({ reportLinkURL, setIsReportIssueOpen, isConnected }) => { return ( { titleIconVariant={null} logFile="/tmp/webui.log" buttons={[cancelButton(() => setIsReportIssueOpen(false))]} + isConnected={isConnected} /> ); }; diff --git a/ui/webui/src/components/HeaderKebab.jsx b/ui/webui/src/components/HeaderKebab.jsx index 65cd415c5d2..d21a1886d0b 100644 --- a/ui/webui/src/components/HeaderKebab.jsx +++ b/ui/webui/src/components/HeaderKebab.jsx @@ -110,7 +110,7 @@ const AnacondaAboutModal = ({ isModalOpen, setIsAboutModalOpen }) => { ); }; -export const HeaderKebab = ({ reportLinkURL }) => { +export const HeaderKebab = ({ reportLinkURL, isConnected }) => { const [isOpen, setIsOpen] = useState(false); const [isAboutModalOpen, setIsAboutModalOpen] = useState(false); const [isReportIssueOpen, setIsReportIssueOpen] = useState(false); @@ -167,6 +167,7 @@ export const HeaderKebab = ({ reportLinkURL }) => { } ); diff --git a/ui/webui/src/components/app.jsx b/ui/webui/src/components/app.jsx index a399afa52c0..dff3f722f6b 100644 --- a/ui/webui/src/components/app.jsx +++ b/ui/webui/src/components/app.jsx @@ -36,6 +36,7 @@ import { LocalizationClient, initDataLocalization, startEventMonitorLocalization import { StorageClient, initDataStorage, startEventMonitorStorage } from "../apis/storage.js"; import { PayloadsClient } from "../apis/payloads"; import { RuntimeClient, getIsFinal } from "../apis/runtime"; +import { NetworkClient, initDataNetwork, startEventMonitorNetwork } from "../apis/network.js"; import { readConf } from "../helpers/conf.js"; import { debug } from "../helpers/log.js"; @@ -69,7 +70,8 @@ export const Application = () => { new StorageClient(address), new PayloadsClient(address), new RuntimeClient(address), - new BossClient(address) + new BossClient(address), + new NetworkClient(address), ]; clients.forEach(c => c.init()); @@ -78,11 +80,13 @@ export const Application = () => { Promise.all([ initDataStorage({ dispatch }), initDataLocalization({ dispatch }), + initDataNetwork({ dispatch }), ]) .then(() => { setStoreInitialized(true); startEventMonitorStorage({ dispatch }); startEventMonitorLocalization({ dispatch }); + startEventMonitorNetwork({ dispatch }); }, onCritFail({ context: N_("Reading information about the computer failed.") })); getIsFinal().then( @@ -128,7 +132,7 @@ export const Application = () => { const page = ( <> {criticalError && - } + } @@ -156,7 +160,12 @@ export const Application = () => { })} } - + diff --git a/ui/webui/src/reducer.js b/ui/webui/src/reducer.js index 9e8aaba23a2..bae1b36d551 100644 --- a/ui/webui/src/reducer.js +++ b/ui/webui/src/reducer.js @@ -35,10 +35,16 @@ export const localizationInitialState = { commonLocales: [] }; +/* Intial state for the network store substate */ +export const networkInitialState = { + connected: null +}; + /* Initial state for the global store */ export const initialState = { localization: localizationInitialState, storage: storageInitialState, + network: networkInitialState, }; /* Custom hook to use the reducer with async actions */ @@ -64,6 +70,7 @@ export const reducer = (state, action) => { return ({ localization: localizationReducer(state.localization, action), storage: storageReducer(state.storage, action), + network: networkReducer(state.network, action), }); }; @@ -90,3 +97,11 @@ export const localizationReducer = (state = localizationInitialState, action) => return state; } }; + +export const networkReducer = (state = networkInitialState, action) => { + if (action.type === "GET_NETWORK_CONNECTED") { + return { ...state, connected: action.payload.connected }; + } else { + return state; + } +};