LCOV - code coverage report
Current view: top level - pkg/networkmanager - dialogs-common.jsx Coverage Total Hit
Test: cockpit Lines: 1.7 % 233 4
Test Date: 2026-07-02 14:11:36

            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 : };
        

Generated by: LCOV version 2.0-1