Line data Source code
1 : /*
2 : * Copyright (C) 2021 Red Hat, Inc.
3 : * SPDX-License-Identifier: LGPL-2.1-or-later
4 : */
5 :
6 35 : import React, { useContext, useEffect, useState } from 'react';
7 35 : 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 35 : const _ = cockpit.gettext;
47 : // nm-dbus-interface.h
48 35 : const NM_CAPABILITY_TEAM = 1;
49 :
50 7 : export const MacMenu = ({ idPrefix, model, mac, setMAC }) => {
51 7 : const [optionsMap, setOptionsMap] = useState([]);
52 :
53 7 : useEffect(() => {
54 : // Find all macs and the interfaces that use them.
55 7 : const macs = {};
56 7 : model.list_interfaces().forEach(iface => {
57 7 : if (iface.Device && iface.Device.HwAddress && iface.Device.HwAddress !== "00:00:00:00:00:00") {
58 7 : if (!macs[iface.Device.HwAddress])
59 7 : macs[iface.Device.HwAddress] = [];
60 7 : macs[iface.Device.HwAddress].push(iface.Name);
61 7 : }
62 7 : });
63 :
64 7 : const optionsMapInit = [];
65 7 : Object.keys(macs).sort().forEach(mac => {
66 7 : optionsMapInit.push({
67 7 : content: cockpit.format("$0 ($1)", mac, macs[mac].join(", ")),
68 7 : value: mac,
69 7 : });
70 7 : });
71 :
72 7 : optionsMapInit.push(
73 7 : { content: _("Permanent"), value: "permanent" },
74 7 : { content: _("Preserve"), value: "preserve" },
75 7 : { content: _("Random"), value: "random" },
76 7 : { content: _("Stable"), value: "stable" },
77 7 : );
78 7 : setOptionsMap(optionsMapInit);
79 7 : }, [model]);
80 :
81 0 : const clearSelection = () => {
82 0 : setMAC(undefined);
83 0 : };
84 :
85 1 : const onSelect = (_, selection) => {
86 1 : setMAC(selection);
87 1 : };
88 :
89 7 : return (
90 7 : <TypeaheadSelect toggleProps={{ id: idPrefix + "-mac-input" }}
91 7 : isScrollable
92 7 : placeholder=""
93 7 : isCreatable
94 0 : createOptionMessage={val => cockpit.format(_("Use $0"), val)}
95 7 : onClearSelection={clearSelection}
96 7 : onSelect={onSelect}
97 7 : selected={mac}
98 7 : selectOptions={optionsMap} />
99 : );
100 7 : };
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 20 : export const NetworkModal = ({ dialogError, help, idPrefix, title, onSubmit, children, isFormHorizontal, isCreateDialog, submitDisabled = false }) => {
127 20 : const Dialogs = useDialogs();
128 :
129 20 : return (
130 20 : <Modal id={idPrefix + "-dialog"} position="top" variant="medium"
131 20 : isOpen
132 20 : onClose={Dialogs.close}
133 : >
134 20 : <ModalHeader title={title} help={help} />
135 20 : <ModalBody>
136 20 : <Form id={idPrefix + "-body"} onSubmit={onSubmit} isHorizontal={isFormHorizontal !== false}>
137 3 : {dialogError && <ModalError id={idPrefix + "-error"} dialogError={_("Failed to save settings")} dialogErrorDetail={dialogError} />}
138 20 : {children}
139 20 : </Form>
140 20 : </ModalBody>
141 20 : <ModalFooter>
142 20 : <Button variant='primary' id={idPrefix + "-save"} onClick={onSubmit} isDisabled={submitDisabled}>
143 7 : {isCreateDialog ? _("Add") : _("Save")}
144 20 : </Button>
145 20 : <Button variant='link' id={idPrefix + "-cancel"} onClick={Dialogs.close}>
146 20 : {_("Cancel")}
147 20 : </Button>
148 20 : </ModalFooter>
149 20 : </Modal>
150 : );
151 20 : };
152 :
153 33 : export const NetworkAction = ({ buttonText, iface, connectionSettings, type }) => {
154 33 : const Dialogs = useDialogs();
155 33 : const model = useContext(ModelContext);
156 :
157 33 : if (type == "team" && !model.get_manager().Capabilities.includes(NM_CAPABILITY_TEAM))
158 4 : return null;
159 :
160 28 : const con = iface && iface.MainConnection;
161 28 : const dev = iface && iface.Device;
162 :
163 33 : const getName = () => {
164 33 : let name;
165 : // Find the first free interface name
166 33 : for (let i = 0; i < 100; i++) {
167 33 : name = type + i;
168 33 : if (!model.find_interface(name))
169 33 : break;
170 33 : }
171 33 : return name;
172 33 : };
173 :
174 28 : const newIfaceName = !iface ? getName() : undefined;
175 :
176 33 : let settings = connectionSettings;
177 33 : if (!settings) {
178 33 : if (type == 'bond') settings = getBondGhostSettings({ newIfaceName });
179 33 : if (type == 'vlan') settings = getVlanGhostSettings();
180 33 : if (type == 'team') settings = getTeamGhostSettings({ newIfaceName });
181 33 : if (type == 'bridge') settings = getBridgeGhostSettings({ newIfaceName });
182 33 : if (type == 'wg') settings = getWireGuardGhostSettings({ newIfaceName });
183 33 : }
184 :
185 33 : const properties = { connection: con, dev, settings };
186 :
187 20 : 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 20 : }
205 :
206 20 : function show() {
207 20 : let dlg = null;
208 20 : if (type == 'bond')
209 1 : dlg = <BondDialog {...properties} />;
210 13 : else if (type == 'vlan')
211 1 : dlg = <VlanDialog {...properties} />;
212 12 : else if (type == 'team')
213 2 : dlg = <TeamDialog {...properties} />;
214 12 : else if (type == 'bridge')
215 2 : dlg = <BridgeDialog {...properties} />;
216 11 : else if (type == 'wg')
217 2 : dlg = <WireGuardDialog {...properties} />;
218 11 : else if (type == 'mtu')
219 1 : dlg = <MtuDialog {...properties} />;
220 10 : 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 20 : if (dlg)
232 20 : resolveDeps(type)
233 20 : .then(() => Dialogs.show(dlg))
234 0 : .catch(err => console.error("NetworkAction Dialog failed:", err)); // not-covered: OS error
235 20 : }
236 :
237 33 : return (
238 28 : <Button id={"networking-" + (!iface ? "add-" : "edit-") + type}
239 33 : isInline={!!iface}
240 33 : onClick={syn_click(model, show)}
241 28 : variant={!iface ? "secondary" : "link"}>
242 28 : {buttonText || _("edit")}
243 33 : </Button>
244 : );
245 33 : };
246 :
247 11 : function reactivateConnection({ con, dev }) {
248 11 : 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 10 : return con.activate(dev, null)
255 10 : .catch(show_unexpected_error);
256 10 : }
257 11 : }
258 :
259 20 : export const dialogSave = ({ model, dev, connection, members, membersInit, settings, setDialogError, onClose }) => {
260 20 : const apply_settings = settings_applier(model, dev, connection);
261 3 : const iface = settings.connection.interface_name ?? dev?.Interface;
262 20 : const type = settings.connection.type;
263 2 : const membersChanged = members ? Object.keys(membersInit).some(iface => membersInit[iface] != members[iface]) : false;
264 :
265 20 : model.set_operation_in_progress(true);
266 :
267 20 : const modify = () => {
268 20 : 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 12 : : apply_settings(settings))
276 20 : .then(() => {
277 20 : onClose();
278 11 : if (connection && iface)
279 11 : cockpit.location.go([iface]);
280 11 : if (connection && dev?.ActiveConnection?.Connection === connection && !isNonPersistentMultiCon(connection)) {
281 11 : return reactivateConnection({ con: connection, dev });
282 11 : }
283 20 : })
284 1 : .catch(ex => setDialogError(typeof ex === 'string' ? ex : ex.message))
285 20 : .then(() => model.set_operation_in_progress(false));
286 20 : };
287 11 : if (connection) {
288 11 : with_settings_checkpoint(model, modify,
289 11 : {
290 11 : ...(type != 'vlan' && {
291 1 : devices: (membersChanged ? [] : connection_devices(connection))
292 11 : }),
293 11 : hack_does_add_or_remove: type == 'vlan' || membersChanged || isNonPersistentMultiCon(connection),
294 11 : rollback_on_failure: type !== 'vlan' && membersChanged
295 11 : });
296 5 : } 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 20 : };
|