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