Line data Source code
1 : /*
2 : * Copyright (C) 2021 Red Hat, Inc.
3 : * SPDX-License-Identifier: LGPL-2.1-or-later
4 : */
5 :
6 1 : import React, { useContext, useEffect, useState } from 'react';
7 1 : import cockpit from 'cockpit';
8 : import { getPackageManager } from "packagemanager";
9 :
10 : import { Button } from "@patternfly/react-core/dist/esm/components/Button/index.js";
11 : import { Checkbox } from "@patternfly/react-core/dist/esm/components/Checkbox/index.js";
12 : import { Form, FormGroup } from "@patternfly/react-core/dist/esm/components/Form/index.js";
13 : import {
14 : Modal, ModalBody, ModalFooter, ModalHeader
15 : } from '@patternfly/react-core/dist/esm/components/Modal/index.js';
16 : import { Stack } from "@patternfly/react-core/dist/esm/layouts/Stack/index.js";
17 : import { TextInput } from "@patternfly/react-core/dist/esm/components/TextInput/index.js";
18 :
19 : import { BondDialog, getGhostSettings as getBondGhostSettings } from './bond.jsx';
20 : import { BridgeDialog, getGhostSettings as getBridgeGhostSettings } from './bridge.jsx';
21 : import { BridgePortDialog } from './bridgeport.jsx';
22 : import { IpSettingsDialog } from './ip-settings.jsx';
23 : import { TeamDialog, getGhostSettings as getTeamGhostSettings } from './team.jsx';
24 : import { TeamPortDialog } from './teamport.jsx';
25 : import { VlanDialog, getGhostSettings as getVlanGhostSettings } from './vlan.jsx';
26 : import { WireGuardDialog, getWireGuardGhostSettings } from './wireguard.jsx';
27 : import { MtuDialog } from './mtu.jsx';
28 : import { MacDialog } from './mac.jsx';
29 : import { ModalError } from 'cockpit-components-inline-notification.jsx';
30 : import { ModelContext } from './model-context.jsx';
31 : import { useDialogs } from "dialogs.jsx";
32 : import { install_dialog } from "cockpit-components-install-dialog.jsx";
33 : import { read_os_release } from "os-release.js";
34 : import { TypeaheadSelect } from "cockpit-components-typeahead-select";
35 :
36 : import {
37 : apply_group_member,
38 : syn_click,
39 : with_checkpoint, with_settings_checkpoint,
40 : connection_devices,
41 : settings_applier,
42 : show_unexpected_error,
43 : } from './interfaces.js';
44 : import { isNonPersistentMultiCon } from './utils.js';
45 :
46 1 : const _ = cockpit.gettext;
47 : // nm-dbus-interface.h
48 1 : const NM_CAPABILITY_TEAM = 1;
49 :
50 0 : export const MacMenu = ({ idPrefix, model, mac, setMAC }) => {
51 0 : const [optionsMap, setOptionsMap] = useState([]);
52 :
53 0 : useEffect(() => {
54 : // Find all macs and the interfaces that use them.
55 0 : const macs = {};
56 0 : model.list_interfaces().forEach(iface => {
57 0 : if (iface.Device && iface.Device.HwAddress && iface.Device.HwAddress !== "00:00:00:00:00:00") {
58 0 : if (!macs[iface.Device.HwAddress])
59 0 : macs[iface.Device.HwAddress] = [];
60 0 : macs[iface.Device.HwAddress].push(iface.Name);
61 0 : }
62 0 : });
63 :
64 0 : const optionsMapInit = [];
65 0 : Object.keys(macs).sort().forEach(mac => {
66 0 : optionsMapInit.push({
67 0 : content: cockpit.format("$0 ($1)", mac, macs[mac].join(", ")),
68 0 : value: mac,
69 0 : });
70 0 : });
71 :
72 0 : optionsMapInit.push(
73 0 : { content: _("Permanent"), value: "permanent" },
74 0 : { content: _("Preserve"), value: "preserve" },
75 0 : { content: _("Random"), value: "random" },
76 0 : { content: _("Stable"), value: "stable" },
77 0 : );
78 0 : setOptionsMap(optionsMapInit);
79 0 : }, [model]);
80 :
81 0 : const clearSelection = () => {
82 0 : setMAC(undefined);
83 0 : };
84 :
85 0 : const onSelect = (_, selection) => {
86 0 : setMAC(selection);
87 0 : };
88 :
89 0 : return (
90 0 : <TypeaheadSelect toggleProps={{ id: idPrefix + "-mac-input" }}
91 0 : isScrollable
92 0 : placeholder=""
93 0 : isCreatable
94 0 : createOptionMessage={val => cockpit.format(_("Use $0"), val)}
95 0 : onClearSelection={clearSelection}
96 0 : onSelect={onSelect}
97 0 : selected={mac}
98 0 : selectOptions={optionsMap} />
99 : );
100 0 : };
101 :
102 0 : export const MemberInterfaceChoices = ({ idPrefix, memberChoices, setMemberChoices, model, group }) => {
103 0 : return (
104 0 : <Stack id={idPrefix + "-interface-members-list"}>
105 0 : {Object.keys(memberChoices).map((iface, idx) => (
106 0 : <Checkbox data-iface={iface}
107 0 : id={idPrefix + "-interface-members-" + iface}
108 0 : isChecked={memberChoices[iface]}
109 0 : key={iface}
110 0 : label={iface}
111 0 : onChange={(_event, checked) => setMemberChoices({ ...memberChoices, [iface]: checked })}
112 0 : />
113 0 : ))}
114 0 : </Stack>
115 : );
116 0 : };
117 :
118 0 : export const Name = ({ idPrefix, iface, setIface }) => {
119 0 : return (
120 0 : <FormGroup fieldId={idPrefix + "-interface-name-input"} label={_("Name")}>
121 0 : <TextInput id={idPrefix + "-interface-name-input"} value={iface} onChange={(_event, value) => setIface(value)} />
122 0 : </FormGroup>
123 : );
124 0 : };
125 :
126 0 : export const NetworkModal = ({ dialogError, help, idPrefix, title, onSubmit, children, isFormHorizontal, isCreateDialog, submitDisabled = false }) => {
127 0 : const Dialogs = useDialogs();
128 :
129 0 : return (
130 0 : <Modal id={idPrefix + "-dialog"} position="top" variant="medium"
131 0 : isOpen
132 0 : onClose={Dialogs.close}
133 : >
134 0 : <ModalHeader title={title} help={help} />
135 0 : <ModalBody>
136 0 : <Form id={idPrefix + "-body"} onSubmit={onSubmit} isHorizontal={isFormHorizontal !== false}>
137 0 : {dialogError && <ModalError id={idPrefix + "-error"} dialogError={_("Failed to save settings")} dialogErrorDetail={dialogError} />}
138 0 : {children}
139 0 : </Form>
140 0 : </ModalBody>
141 0 : <ModalFooter>
142 0 : <Button variant='primary' id={idPrefix + "-save"} onClick={onSubmit} isDisabled={submitDisabled}>
143 0 : {isCreateDialog ? _("Add") : _("Save")}
144 0 : </Button>
145 0 : <Button variant='link' id={idPrefix + "-cancel"} onClick={Dialogs.close}>
146 0 : {_("Cancel")}
147 0 : </Button>
148 0 : </ModalFooter>
149 0 : </Modal>
150 : );
151 0 : };
152 :
153 0 : export const NetworkAction = ({ buttonText, iface, connectionSettings, type }) => {
154 0 : const Dialogs = useDialogs();
155 0 : const model = useContext(ModelContext);
156 :
157 0 : if (type == "team" && !model.get_manager().Capabilities.includes(NM_CAPABILITY_TEAM))
158 0 : return null;
159 :
160 0 : const con = iface && iface.MainConnection;
161 0 : const dev = iface && iface.Device;
162 :
163 0 : const getName = () => {
164 0 : let name;
165 : // Find the first free interface name
166 0 : for (let i = 0; i < 100; i++) {
167 0 : name = type + i;
168 0 : if (!model.find_interface(name))
169 0 : break;
170 0 : }
171 0 : return name;
172 0 : };
173 :
174 0 : const newIfaceName = !iface ? getName() : undefined;
175 :
176 0 : let settings = connectionSettings;
177 0 : if (!settings) {
178 0 : if (type == 'bond') settings = getBondGhostSettings({ newIfaceName });
179 0 : if (type == 'vlan') settings = getVlanGhostSettings();
180 0 : if (type == 'team') settings = getTeamGhostSettings({ newIfaceName });
181 0 : if (type == 'bridge') settings = getBridgeGhostSettings({ newIfaceName });
182 0 : if (type == 'wg') settings = getWireGuardGhostSettings({ newIfaceName });
183 0 : }
184 :
185 0 : const properties = { connection: con, dev, settings };
186 :
187 0 : async function resolveDeps(type) {
188 0 : if (type === 'wg') {
189 0 : try {
190 0 : await cockpit.script("command -v wg");
191 0 : } catch {
192 0 : const os_release = await read_os_release();
193 :
194 0 : try {
195 0 : await getPackageManager();
196 : // RHEL 8 does not have wireguard-tools
197 0 : if (os_release.PLATFORM_ID !== "platform:el8")
198 0 : await install_dialog("wireguard-tools");
199 0 : } catch (exc) {
200 0 : console.log("no package manager support");
201 0 : }
202 0 : }
203 0 : }
204 0 : }
205 :
206 0 : function show() {
207 0 : let dlg = null;
208 0 : if (type == 'bond')
209 0 : dlg = <BondDialog {...properties} />;
210 0 : else if (type == 'vlan')
211 0 : dlg = <VlanDialog {...properties} />;
212 0 : else if (type == 'team')
213 0 : dlg = <TeamDialog {...properties} />;
214 0 : else if (type == 'bridge')
215 0 : dlg = <BridgeDialog {...properties} />;
216 0 : else if (type == 'wg')
217 0 : dlg = <WireGuardDialog {...properties} />;
218 0 : else if (type == 'mtu')
219 0 : dlg = <MtuDialog {...properties} />;
220 0 : else if (type == 'mac')
221 0 : dlg = <MacDialog {...properties} />;
222 0 : else if (type == 'teamport')
223 0 : dlg = <TeamPortDialog {...properties} />;
224 0 : else if (type == 'bridgeport')
225 0 : dlg = <BridgePortDialog {...properties} />;
226 0 : else if (type == 'ipv4')
227 0 : dlg = <IpSettingsDialog topic="ipv4" {...properties} />;
228 0 : else if (type == 'ipv6')
229 0 : dlg = <IpSettingsDialog topic="ipv6" {...properties} />;
230 :
231 0 : if (dlg)
232 0 : resolveDeps(type)
233 0 : .then(() => Dialogs.show(dlg))
234 0 : .catch(err => console.error("NetworkAction Dialog failed:", err)); // not-covered: OS error
235 0 : }
236 :
237 0 : return (
238 0 : <Button id={"networking-" + (!iface ? "add-" : "edit-") + type}
239 0 : isInline={!!iface}
240 0 : onClick={syn_click(model, show)}
241 0 : variant={!iface ? "secondary" : "link"}>
242 0 : {buttonText || _("edit")}
243 0 : </Button>
244 : );
245 0 : };
246 :
247 0 : function reactivateConnection({ con, dev }) {
248 0 : if (con.Settings.connection.interface_name &&
249 0 : con.Settings.connection.interface_name != dev.Interface) {
250 0 : return dev.disconnect()
251 0 : .then(() => con.activate(null, null))
252 0 : .catch(show_unexpected_error);
253 0 : } else {
254 0 : return con.activate(dev, null)
255 0 : .catch(show_unexpected_error);
256 0 : }
257 0 : }
258 :
259 0 : export const dialogSave = ({ model, dev, connection, members, membersInit, settings, setDialogError, onClose }) => {
260 0 : const apply_settings = settings_applier(model, dev, connection);
261 0 : const iface = settings.connection.interface_name ?? dev?.Interface;
262 0 : const type = settings.connection.type;
263 0 : const membersChanged = members ? Object.keys(membersInit).some(iface => membersInit[iface] != members[iface]) : false;
264 :
265 0 : model.set_operation_in_progress(true);
266 :
267 0 : const modify = () => {
268 0 : return ((members !== undefined)
269 0 : ? apply_group_member(members,
270 0 : model,
271 0 : apply_settings,
272 0 : connection,
273 0 : settings,
274 0 : type)
275 0 : : apply_settings(settings))
276 0 : .then(() => {
277 0 : onClose();
278 0 : if (connection && iface)
279 0 : cockpit.location.go([iface]);
280 0 : else if (!connection && iface)
281 0 : return model.synchronize().then(() => cockpit.location.go([iface]));
282 0 : if (connection && dev?.ActiveConnection?.Connection === connection && !isNonPersistentMultiCon(connection)) {
283 0 : return reactivateConnection({ con: connection, dev });
284 0 : }
285 0 : })
286 0 : .catch(ex => setDialogError(typeof ex === 'string' ? ex : ex.message))
287 0 : .then(() => model.set_operation_in_progress(false));
288 0 : };
289 0 : if (connection) {
290 0 : with_settings_checkpoint(model, modify,
291 0 : {
292 0 : ...(type != 'vlan' && {
293 0 : devices: (membersChanged ? [] : connection_devices(connection))
294 0 : }),
295 0 : hack_does_add_or_remove: type == 'vlan' || membersChanged || isNonPersistentMultiCon(connection),
296 0 : rollback_on_failure: type !== 'vlan' && membersChanged
297 0 : });
298 0 : } else {
299 0 : try {
300 0 : with_checkpoint(
301 0 : model,
302 0 : modify,
303 0 : {
304 0 : fail_text: cockpit.format(_("Creating this $0 will break the connection to the server, and will make the administration UI unavailable."), type == 'vlan' ? 'VLAN' : type),
305 0 : anyway_text: _("Create it"),
306 0 : hack_does_add_or_remove: true,
307 0 : rollback_on_failure: type != 'vlan',
308 0 : });
309 0 : } catch (e) {
310 0 : setDialogError(typeof e === 'string' ? e : e.message);
311 0 : }
312 0 : }
313 0 : };
|