diff --git a/src/app/base/components/ModelActionForm/ModelActionForm.tsx b/src/app/base/components/ModelActionForm/ModelActionForm.tsx index 82f6872d2e..4f2c8564a0 100644 --- a/src/app/base/components/ModelActionForm/ModelActionForm.tsx +++ b/src/app/base/components/ModelActionForm/ModelActionForm.tsx @@ -1,3 +1,5 @@ +import type { ReactNode } from "react"; + import { Col, Row } from "@canonical/react-components"; import type { Props as FormikFormProps } from "@/app/base/components/FormikForm/FormikForm"; @@ -6,7 +8,7 @@ import type { EmptyObject } from "@/app/base/types"; type Props = { modelType: string; - message?: string; + message?: ReactNode; } & FormikFormProps; const ModelActionForm = ({ @@ -27,9 +29,14 @@ const ModelActionForm = ({

- {message - ? message - : `Are you sure you want to delete this ${modelType}? This action is permanent and can not be undone.`} + {message ? ( + message + ) : ( + <> + Are you sure you want to delete this {modelType}? This action is + permanent and can not be undone. + + )}

diff --git a/src/app/base/components/node/networking/NetworkTable/NetworkTable.test.tsx b/src/app/base/components/node/networking/NetworkTable/NetworkTable.test.tsx index fb05568f97..e73ac710e1 100644 --- a/src/app/base/components/node/networking/NetworkTable/NetworkTable.test.tsx +++ b/src/app/base/components/node/networking/NetworkTable/NetworkTable.test.tsx @@ -1,6 +1,5 @@ import NetworkTable, { Label } from "./NetworkTable"; -import { ExpandedState } from "@/app/base/components/NodeNetworkTab/NodeNetworkTab"; import { Label as PXEColumnLabel } from "@/app/base/components/node/networking/NetworkTable/PXEColumn/PXEColumn"; import { Label as NetworkTableActionsLabel } from "@/app/machines/views/MachineDetails/MachineNetwork/NetworkTable/NetworkTableActions/NetworkTableActions"; import type { MachineDetails } from "@/app/store/machine/types"; @@ -77,7 +76,6 @@ describe("NetworkTable", () => { state.machine.items = [machine]; renderWithBrowserRouter( { state.machine.items = [machine]; renderWithBrowserRouter( { renderWithBrowserRouter( { state.machine.items = [machine]; renderWithBrowserRouter( { expect(within(ipPrimary).getByText("1.2.3.101")).toBeInTheDocument(); }); - it("expands a row when a matching link is found", () => { - machine = machineDetailsFactory({ - interfaces: [ - machineInterfaceFactory({ - discovered: null, - links: [networkLinkFactory(), networkLinkFactory({ id: 2 })], - name: "alias", - type: NetworkInterfaceTypes.ALIAS, - }), - ], - system_id: "abc123", - }); - state.machine.items = [machine]; - renderWithBrowserRouter( - , - { state } - ); - const alias = screen.getByTestId("alias:1"); - expect(alias.className.includes("is-active")).toBe(true); - }); - - it("expands a row when a matching nic is found", () => { - machine = machineDetailsFactory({ - interfaces: [ - machineInterfaceFactory({ - id: 2, - discovered: null, - links: [], - name: "eth0", - type: NetworkInterfaceTypes.PHYSICAL, - }), - ], - system_id: "abc123", - }); - state.machine.items = [machine]; - renderWithBrowserRouter( - , - { state } - ); - const alias = screen.getByTestId("eth0"); - expect(alias.className.includes("is-active")).toBe(true); - }); - it("displays actions", () => { machine = machineDetailsFactory({ interfaces: [ @@ -294,7 +234,6 @@ describe("NetworkTable", () => { state.machine.items = [machine]; renderWithBrowserRouter( { it("does not display a checkbox for parent interfaces", () => { renderWithBrowserRouter( { const setSelected = vi.fn(); renderWithBrowserRouter( { it("does not display a boot icon for parent interfaces", () => { renderWithBrowserRouter( { it("does not display a fabric column for parent interfaces", () => { renderWithBrowserRouter( { it("does not display a DHCP column for parent interfaces", () => { renderWithBrowserRouter( { it("does not display a subnet column for parent interfaces", () => { renderWithBrowserRouter( { it("does not display an IP column for parent interfaces", () => { renderWithBrowserRouter( { it("does not display an actions menu for parent interfaces", () => { renderWithBrowserRouter( { it("groups the bonds and bridges", () => { renderWithBrowserRouter( { it("groups the bonds and bridges when in reverse order", async () => { renderWithBrowserRouter( { if (link && !nic) { [nic] = getLinkInterface(node, link); @@ -148,10 +147,6 @@ const generateRow = ( nic, link ); - const isExpanded = - !!expanded && - ((link && expanded.linkId === link.id) || - (!link && expanded.nicId === nic?.id)); const showCheckbox = !isABondOrBridgeParent && hasActions; const select = showCheckbox ? { @@ -162,7 +157,6 @@ const generateRow = ( return { className: classNames("p-table__row", { "truncated-border": isABondOrBridgeParent, - "is-active": isExpanded, }), columns: [ { @@ -231,7 +225,8 @@ const generateRow = ( ) : null, @@ -240,17 +235,6 @@ const generateRow = ( : []), ], "data-testid": name, - expanded: isExpanded, - expandedContent: - hasActions && isExpanded && nodeIsMachine(node) && setExpanded ? ( - - ) : null, key: name, select, sortData: { @@ -283,8 +267,8 @@ const generateRows = ( vlans: VLAN[], vlansLoaded: boolean, hasActions: boolean, - setExpanded?: (expanded: Expanded | null) => void, - expanded?: Expanded | null + setSelected: Props["setSelected"], + setExpanded?: (expanded: Expanded | null) => void ): NetworkRow[] => { const rows: NetworkRow[] = []; // Create a list of interfaces and aliases to use to generate the table rows. @@ -306,8 +290,8 @@ const generateRows = ( vlans, vlansLoaded, hasActions, - setExpanded, - expanded + setSelected, + setExpanded ); if (nic.links.length === 0) { const row = createRow(null, nic); @@ -416,14 +400,12 @@ type BaseProps = { }; type ActionProps = BaseProps & { - expanded?: Expanded | null; selected?: Selected[]; setExpanded?: SetExpanded; setSelected?: SetSelected; }; type WithoutActionProps = BaseProps & { - expanded?: never; selected?: never; setExpanded?: never; setSelected?: never; @@ -432,7 +414,6 @@ type WithoutActionProps = BaseProps & { type Props = ActionProps | WithoutActionProps; const NetworkTable = ({ - expanded, node, selected, setExpanded, @@ -477,8 +458,8 @@ const NetworkTable = ({ vlans, vlansLoaded, hasActions, - setExpanded, - expanded + setSelected, + setExpanded ); const sortedRows = sortRows(rows); // Generate a list of ids for interfaces that have checkboxes. diff --git a/src/app/machines/components/MachineForms/MachineForms.tsx b/src/app/machines/components/MachineForms/MachineForms.tsx index a0565f4e60..4093b3c2e4 100644 --- a/src/app/machines/components/MachineForms/MachineForms.tsx +++ b/src/app/machines/components/MachineForms/MachineForms.tsx @@ -2,6 +2,10 @@ import { useCallback } from "react"; import type { ValueOf } from "@canonical/react-components"; +import MarkConnectedForm from "../../views/MachineDetails/MachineNetwork/MarkConnectedForm"; +import { ConnectionState } from "../../views/MachineDetails/MachineNetwork/MarkConnectedForm/MarkConnectedForm"; +import RemovePhysicalForm from "../../views/MachineDetails/MachineNetwork/RemovePhysicalForm"; + import AddChassisForm from "./AddChassis/AddChassisForm"; import AddMachineForm from "./AddMachine/AddMachineForm"; import MachineActionFormWrapper from "./MachineActionFormWrapper"; @@ -13,8 +17,8 @@ import UpdateDatastore from "@/app/base/components/node/StorageTables/AvailableS import AddSpecialFilesystem from "@/app/base/components/node/StorageTables/FilesystemsTable/AddSpecialFilesystem"; import type { SidePanelContentTypes } from "@/app/base/side-panel-context"; import type { SetSearchFilter } from "@/app/base/types"; -import { MachineSidePanelViews } from "@/app/machines/constants"; import type { MachineActionSidePanelViews } from "@/app/machines/constants"; +import { MachineSidePanelViews } from "@/app/machines/constants"; import type { MachineActionVariableProps, MachineSidePanelContent, @@ -23,6 +27,7 @@ import AddAliasOrVlan from "@/app/machines/views/MachineDetails/MachineNetwork/A import AddBondForm from "@/app/machines/views/MachineDetails/MachineNetwork/AddBondForm"; import AddBridgeForm from "@/app/machines/views/MachineDetails/MachineNetwork/AddBridgeForm"; import AddInterface from "@/app/machines/views/MachineDetails/MachineNetwork/AddInterface"; +import EditInterface from "@/app/machines/views/MachineDetails/MachineNetwork/EditInterface"; import ChangeStorageLayout from "@/app/machines/views/MachineDetails/MachineStorage/ChangeStorageLayout"; import { NetworkInterfaceTypes } from "@/app/store/types/enum"; @@ -58,11 +63,14 @@ export const MachineForms = ({ const setSelected = extras && "setSelected" in extras ? extras.setSelected : undefined; const nic = extras && "nic" in extras ? extras.nic : undefined; + const link = extras && "link" in extras ? extras.link : undefined; const bulkActionSelected = extras && "bulkActionSelected" in extras ? extras.bulkActionSelected : undefined; const node = extras && "node" in extras ? extras.node : undefined; + const linkId = extras && "linkId" in extras ? extras.linkId : undefined; + const nicId = extras && "nicId" in extras ? extras.nicId : undefined; switch (sidePanelContent.view) { case MachineSidePanelViews.ADD_CHASSIS: @@ -168,6 +176,54 @@ export const MachineForms = ({ /> ); } + case MachineSidePanelViews.EDIT_PHYSICAL: { + if (!systemId || !selected || !setSelected) return null; + return ( + + ); + } + case MachineSidePanelViews.MARK_CONNECTED: { + if (!systemId) return null; + return ( + + ); + } + case MachineSidePanelViews.MARK_DISCONNECTED: { + if (!systemId) return null; + return ( + + ); + } + case MachineSidePanelViews.REMOVE_PHYSICAL: { + if (!systemId) return null; + return ( + + ); + } case MachineSidePanelViews.UPDATE_DATASTORE: { if (!bulkActionSelected || !systemId) return null; return ( diff --git a/src/app/machines/constants.ts b/src/app/machines/constants.ts index 4e2be01430..7c4dde0e3c 100644 --- a/src/app/machines/constants.ts +++ b/src/app/machines/constants.ts @@ -42,6 +42,10 @@ export const MachineNonActionSidePanelViews = { CREATE_DATASTORE: ["machineNonActionForm", "createDatastore"], CREATE_RAID: ["machineNonActionForm", "createRaid"], CREATE_VOLUME_GROUP: ["machineNonActionForm", "createVolumeGroup"], + EDIT_PHYSICAL: ["machineNonActionForm", "editPhysical"], + MARK_CONNECTED: ["machineNonActionForm", "markConnected"], + MARK_DISCONNECTED: ["machineNonActionForm", "markDisconnected"], + REMOVE_PHYSICAL: ["machineNonActionForm", "removePhysical"], UPDATE_DATASTORE: ["machineNonActionForm", "updateDatastore"], } as const; diff --git a/src/app/machines/types.ts b/src/app/machines/types.ts index ea48af2ff0..cf8ea5cbfb 100644 --- a/src/app/machines/types.ts +++ b/src/app/machines/types.ts @@ -20,7 +20,12 @@ import type { StorageLayoutOption, } from "@/app/store/machine/types"; import type { Script } from "@/app/store/script/types"; -import type { Disk, NetworkInterface, Partition } from "@/app/store/types/node"; +import type { + Disk, + NetworkInterface, + NetworkLink, + Partition, +} from "@/app/store/types/node"; export type MachineSidePanelContent = | SidePanelContent< @@ -64,6 +69,24 @@ export type MachineSidePanelContent = { node: MachineDetails; } + > + | SidePanelContent< + ValueOf, + { + systemId?: Machine["system_id"]; + link?: NetworkLink | null; + nic?: NetworkInterface | null; + } + > + | SidePanelContent< + ValueOf, + { + systemId?: Machine["system_id"]; + selected: Selected[]; + setSelected: SetSelected; + linkId?: NetworkLink["id"]; + nicId?: NetworkInterface["id"]; + } >; export type MachineSetSidePanelContent = diff --git a/src/app/machines/views/MachineDetails/MachineNetwork/AddAliasOrVlan/AddAliasOrVlanFields/AddAliasOrVlanFields.tsx b/src/app/machines/views/MachineDetails/MachineNetwork/AddAliasOrVlan/AddAliasOrVlanFields/AddAliasOrVlanFields.tsx index 70977ed682..9046752102 100644 --- a/src/app/machines/views/MachineDetails/MachineNetwork/AddAliasOrVlan/AddAliasOrVlanFields/AddAliasOrVlanFields.tsx +++ b/src/app/machines/views/MachineDetails/MachineNetwork/AddAliasOrVlan/AddAliasOrVlanFields/AddAliasOrVlanFields.tsx @@ -41,7 +41,7 @@ export const AddAliasOrVlanFields = ({ const nextNicName = getNextNicName(machine, interfaceType, nic, vlan?.vid); return ( - + {isVLAN ? : null} - + + aria-label={isAlias ? "Edit alias" : "Edit VLAN"} cleanup={cleanup} errors={errors} initialValues={{ @@ -142,12 +143,12 @@ const EditAliasOrVlanForm = ({ > {isVLAN ? ( - +

VLAN details

) : null} - +

Network

allowUnchanged + aria-label="Edit bridge" cleanup={cleanup} errors={errors} initialValues={{ diff --git a/src/app/machines/views/MachineDetails/MachineNetwork/EditInterface/EditInterface.test.tsx b/src/app/machines/views/MachineDetails/MachineNetwork/EditInterface/EditInterface.test.tsx index 8a9f010cce..4abb0e45e3 100644 --- a/src/app/machines/views/MachineDetails/MachineNetwork/EditInterface/EditInterface.test.tsx +++ b/src/app/machines/views/MachineDetails/MachineNetwork/EditInterface/EditInterface.test.tsx @@ -71,7 +71,7 @@ describe("EditInterface", () => { } ); expect( - screen.getByRole("heading", { name: "Edit Physical" }) + screen.getByRole("form", { name: "Edit physical" }) ).toBeInTheDocument(); expect( screen.getByRole("button", { name: "Save interface" }) @@ -105,7 +105,7 @@ describe("EditInterface", () => { } ); expect( - screen.getByRole("heading", { name: "Edit Alias" }) + screen.getByRole("form", { name: "Edit alias" }) ).toBeInTheDocument(); expect( screen.getByRole("button", { name: "Save Alias" }) @@ -135,9 +135,7 @@ describe("EditInterface", () => { state, } ); - expect( - screen.getByRole("heading", { name: "Edit VLAN" }) - ).toBeInTheDocument(); + expect(screen.getByRole("form", { name: "Edit VLAN" })).toBeInTheDocument(); expect( screen.getByRole("button", { name: "Save VLAN" }) ).toBeInTheDocument(); @@ -167,7 +165,7 @@ describe("EditInterface", () => { } ); expect( - screen.getByRole("heading", { name: "Edit Bridge" }) + screen.getByRole("form", { name: "Edit bridge" }) ).toBeInTheDocument(); expect( screen.getByRole("button", { name: "Save Bridge" }) diff --git a/src/app/machines/views/MachineDetails/MachineNetwork/EditInterface/EditInterface.tsx b/src/app/machines/views/MachineDetails/MachineNetwork/EditInterface/EditInterface.tsx index 9f7a973c59..f5e9e6b865 100644 --- a/src/app/machines/views/MachineDetails/MachineNetwork/EditInterface/EditInterface.tsx +++ b/src/app/machines/views/MachineDetails/MachineNetwork/EditInterface/EditInterface.tsx @@ -7,9 +7,7 @@ import EditAliasOrVlanForm from "../EditAliasOrVlanForm"; import EditBondForm from "../EditBondForm"; import EditBridgeForm from "../EditBridgeForm"; import EditPhysicalForm from "../EditPhysicalForm"; -import InterfaceFormTable from "../InterfaceFormTable"; -import FormCard from "@/app/base/components/FormCard"; import type { Selected, SetSelected, @@ -20,11 +18,7 @@ import { isMachineDetails } from "@/app/store/machine/utils"; import type { RootState } from "@/app/store/root/types"; import { NetworkInterfaceTypes } from "@/app/store/types/enum"; import type { NetworkInterface, NetworkLink } from "@/app/store/types/node"; -import { - getInterfaceTypeText, - getInterfaceType, - getLinkFromNic, -} from "@/app/store/utils"; +import { getInterfaceType, getLinkFromNic } from "@/app/store/utils"; type Props = { close: () => void; @@ -55,8 +49,6 @@ const EditInterface = ({ } const interfaceType = getInterfaceType(machine, nic, link); let form: ReactNode; - let showTable = true; - const interfaceTypeDisplay = getInterfaceTypeText(machine, nic, link); if (interfaceType === NetworkInterfaceTypes.PHYSICAL) { form = ( ); } else if (interfaceType === NetworkInterfaceTypes.BOND) { - showTable = false; form = ( ); } - return ( - - {showTable && ( - - )} - {form} - - ); + return <>{form}; }; export default EditInterface; diff --git a/src/app/machines/views/MachineDetails/MachineNetwork/EditPhysicalForm/EditPhysicalFields/EditPhysicalFields.tsx b/src/app/machines/views/MachineDetails/MachineNetwork/EditPhysicalForm/EditPhysicalFields/EditPhysicalFields.tsx index 69606b7f18..d269826adc 100644 --- a/src/app/machines/views/MachineDetails/MachineNetwork/EditPhysicalForm/EditPhysicalFields/EditPhysicalFields.tsx +++ b/src/app/machines/views/MachineDetails/MachineNetwork/EditPhysicalForm/EditPhysicalFields/EditPhysicalFields.tsx @@ -26,7 +26,7 @@ const EditPhysicalFields = ({ nic }: Props): JSX.Element | null => { } return ( - +

Physical details

@@ -45,7 +45,7 @@ const EditPhysicalFields = ({ nic }: Props): JSX.Element | null => { type="text" /> - +

Network

diff --git a/src/app/machines/views/MachineDetails/MachineNetwork/EditPhysicalForm/EditPhysicalForm.tsx b/src/app/machines/views/MachineDetails/MachineNetwork/EditPhysicalForm/EditPhysicalForm.tsx index e79d9c6c22..3d0a929d74 100644 --- a/src/app/machines/views/MachineDetails/MachineNetwork/EditPhysicalForm/EditPhysicalForm.tsx +++ b/src/app/machines/views/MachineDetails/MachineNetwork/EditPhysicalForm/EditPhysicalForm.tsx @@ -108,6 +108,7 @@ const EditPhysicalForm = ({ return ( + aria-label="Edit physical" cleanup={cleanup} errors={errors} initialValues={{ diff --git a/src/app/machines/views/MachineDetails/MachineNetwork/MachineNetwork.tsx b/src/app/machines/views/MachineDetails/MachineNetwork/MachineNetwork.tsx index 095dd097bd..8eac0f2987 100644 --- a/src/app/machines/views/MachineDetails/MachineNetwork/MachineNetwork.tsx +++ b/src/app/machines/views/MachineDetails/MachineNetwork/MachineNetwork.tsx @@ -3,15 +3,10 @@ import { useState } from "react"; import { Spinner } from "@canonical/react-components"; import { useSelector } from "react-redux"; -import AddBondForm from "./AddBondForm"; -import AddBridgeForm from "./AddBridgeForm"; -import AddInterface from "./AddInterface"; -import EditInterface from "./EditInterface"; import MachineNetworkActions from "./MachineNetworkActions"; import DHCPTable from "@/app/base/components/DHCPTable"; import NodeNetworkTab from "@/app/base/components/NodeNetworkTab"; -import { ExpandedState } from "@/app/base/components/NodeNetworkTab/NodeNetworkTab"; import NetworkTable from "@/app/base/components/node/networking/NetworkTable"; import type { Selected } from "@/app/base/components/node/networking/types"; import { useWindowTitle } from "@/app/base/hooks"; @@ -50,9 +45,6 @@ const MachineNetwork = ({ id, setSidePanelContent }: Props): JSX.Element => { systemId={id} /> )} - addInterface={(_, setExpanded) => ( - setExpanded(null)} systemId={id} /> - )} aria-label="Machine network" dhcpTable={() => ( { node={machine} /> )} - expandedForm={(expanded, setExpanded) => { - if (expanded?.content === ExpandedState.EDIT) { - return ( - setExpanded(null)} - linkId={expanded?.linkId} - nicId={expanded?.nicId} - selected={selected} - setSelected={setSelected} - systemId={id} - /> - ); - } else if (expanded?.content === ExpandedState.ADD_BOND) { - return ( - { - setExpanded(null); - setSelected([]); - }} - selected={selected} - setSelected={setSelected} - systemId={id} - /> - ); - } else if (expanded?.content === ExpandedState.ADD_BRIDGE) { - return ( - { - setExpanded(null); - setSelected([]); - }} - selected={selected} - setSelected={setSelected} - systemId={id} - /> - ); - } - return null; - }} - interfaceTable={(expanded, setExpanded) => ( + interfaceTable={(_, setExpanded) => ( { + state = rootStateFactory({ + machine: machineStateFactory({ + items: [ + machineDetailsFactory({ + system_id: "abc123", + }), + ], + statuses: machineStatusesFactory({ + abc123: machineStatusFactory(), + }), + }), + }); +}); + +it("renders a mark connected form", () => { + const nic = machineInterfaceFactory({ + type: NetworkInterfaceTypes.PHYSICAL, + link_connected: false, + }); + state.machine.items = [ + machineDetailsFactory({ + system_id: "abc123", + interfaces: [nic], + }), + ]; + renderWithBrowserRouter( + , + { state } + ); + + expect( + screen.getByRole("form", { name: "Mark connected" }) + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Mark as connected" }) + ).toBeInTheDocument(); +}); + +it("renders a mark disconnected form", () => { + const nic = machineInterfaceFactory({ + type: NetworkInterfaceTypes.PHYSICAL, + link_connected: true, + }); + state.machine.items = [ + machineDetailsFactory({ + system_id: "abc123", + interfaces: [nic], + }), + ]; + renderWithBrowserRouter( + , + { state } + ); + + expect( + screen.getByRole("form", { name: "Mark disconnected" }) + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Mark as disconnected" }) + ).toBeInTheDocument(); +}); + +it("displays a disconnected warning", () => { + const nic = machineInterfaceFactory({ + type: NetworkInterfaceTypes.PHYSICAL, + link_connected: false, + }); + state.machine.items = [ + machineDetailsFactory({ + system_id: "abc123", + interfaces: [nic], + }), + ]; + renderWithBrowserRouter( + , + { state } + ); + + expect( + screen.getByRole("form", { name: "Mark connected" }) + ).toBeInTheDocument(); + expect( + screen.getByText(/If this is no longer true, mark cable as connected/i) // using this phrase because the warning is broken into different lines + ).toBeInTheDocument(); +}); diff --git a/src/app/machines/views/MachineDetails/MachineNetwork/MarkConnectedForm/MarkConnectedForm.tsx b/src/app/machines/views/MachineDetails/MachineNetwork/MarkConnectedForm/MarkConnectedForm.tsx new file mode 100644 index 0000000000..f03290c778 --- /dev/null +++ b/src/app/machines/views/MachineDetails/MachineNetwork/MarkConnectedForm/MarkConnectedForm.tsx @@ -0,0 +1,111 @@ +import { useState, type ReactNode } from "react"; + +import { useDispatch, useSelector } from "react-redux"; + +import ModelActionForm from "@/app/base/components/ModelActionForm"; +import { actions as machineActions } from "@/app/store/machine"; +import machineSelectors from "@/app/store/machine/selectors"; +import type { MachineDetails } from "@/app/store/machine/types"; +import type { RootState } from "@/app/store/root/types"; +import type { NetworkInterface, NetworkLink } from "@/app/store/types/node"; +import { getLinkInterface } from "@/app/store/utils"; + +export enum ConnectionState { + DISCONNECTED_WARNING = "disconnectedWarning", + MARK_CONNECTED = "markConnected", + MARK_DISCONNECTED = "markDisconnected", +} + +type Props = { + close: () => void; + systemId: MachineDetails["system_id"]; + link?: NetworkLink | null; + nic?: NetworkInterface | null; + connectionState: ConnectionState; +}; + +const MarkConnectedForm = ({ + close, + systemId, + nic, + link, + connectionState, +}: Props) => { + const dispatch = useDispatch(); + const machine = useSelector((state: RootState) => + machineSelectors.getById(state, systemId) + ); + const [saved, setSaved] = useState(false); + if (machine && link && !nic) { + [nic] = getLinkInterface(machine, link); + } + if (!machine || !nic) { + return null; + } + const showDisconnectedWarning = + connectionState === ConnectionState.DISCONNECTED_WARNING; + const markConnected = + connectionState === ConnectionState.MARK_CONNECTED || + showDisconnectedWarning; + const event = markConnected ? "connected" : "disconnected"; + let message: ReactNode; + const updateConnection = () => { + if (nic?.id) { + dispatch( + machineActions.updateInterface({ + interface_id: nic?.id, + link_connected: !!markConnected, + system_id: machine.system_id, + }) + ); + } + setSaved(true); + }; + + if (showDisconnectedWarning) { + message = ( + <> + This interface is disconnected, it cannot be configured + unless a cable is connected. +
+ If this is no longer true, mark cable as connected. + + ); + } else { + message = ( + <> + This interface was detected as{" "} + {nic.link_connected ? "connected" : "disconnected"}. + Are you sure you want to mark it as {event}? + {markConnected ? null : ( + <> +
+ When the interface is disconnected, it cannot be configured. + + )} + + ); + } + + return ( + + ); +}; + +export default MarkConnectedForm; diff --git a/src/app/machines/views/MachineDetails/MachineNetwork/MarkConnectedForm/index.ts b/src/app/machines/views/MachineDetails/MachineNetwork/MarkConnectedForm/index.ts new file mode 100644 index 0000000000..7bcafc8017 --- /dev/null +++ b/src/app/machines/views/MachineDetails/MachineNetwork/MarkConnectedForm/index.ts @@ -0,0 +1 @@ +export { default } from "./MarkConnectedForm"; diff --git a/src/app/machines/views/MachineDetails/MachineNetwork/NetworkTable/NetworkTableActions/NetworkTableActions.test.tsx b/src/app/machines/views/MachineDetails/MachineNetwork/NetworkTable/NetworkTableActions/NetworkTableActions.test.tsx index eeea2dc156..b1c8f0ea72 100644 --- a/src/app/machines/views/MachineDetails/MachineNetwork/NetworkTable/NetworkTableActions/NetworkTableActions.test.tsx +++ b/src/app/machines/views/MachineDetails/MachineNetwork/NetworkTable/NetworkTableActions/NetworkTableActions.test.tsx @@ -1,6 +1,7 @@ import NetworkTableActions from "./NetworkTableActions"; -import { ExpandedState } from "@/app/base/components/NodeNetworkTab/NodeNetworkTab"; +import * as sidePanelHooks from "@/app/base/side-panel-context"; +import { MachineSidePanelViews } from "@/app/machines/constants"; import type { MachineDetails } from "@/app/store/machine/types"; import type { RootState } from "@/app/store/root/types"; import { NetworkInterfaceTypes, NetworkLinkMode } from "@/app/store/types/enum"; @@ -30,6 +31,15 @@ const openMenu = async () => { describe("NetworkTableActions", () => { let nic: NetworkInterface; let state: RootState; + const setSidePanelContent = vi.fn(); + beforeAll(() => { + vi.spyOn(sidePanelHooks, "useSidePanel").mockReturnValue({ + setSidePanelContent, + sidePanelContent: null, + setSidePanelSize: vi.fn(), + sidePanelSize: "regular", + }); + }); beforeEach(() => { nic = machineInterfaceFactory(); state = rootStateFactory({ @@ -46,10 +56,9 @@ describe("NetworkTableActions", () => { }); it("can display the menu", () => { - renderWithMockStore( - , - { state } - ); + renderWithMockStore(, { + state, + }); expect( screen.getByRole("button", { name: "Take action:" }) ).toBeInTheDocument(); @@ -60,10 +69,9 @@ describe("NetworkTableActions", () => { state.machine.items[0].status = NodeStatus.NEW; nic.type = NetworkInterfaceTypes.VLAN; - renderWithMockStore( - , - { state } - ); + renderWithMockStore(, { + state, + }); expect(screen.getByRole("button", { name: "Take action:" })).toBeDisabled(); }); @@ -71,10 +79,9 @@ describe("NetworkTableActions", () => { nic.type = NetworkInterfaceTypes.PHYSICAL; nic.link_connected = false; - renderWithMockStore( - , - { state } - ); + renderWithMockStore(, { + state, + }); // Open the menu: await openMenu(); @@ -87,10 +94,9 @@ describe("NetworkTableActions", () => { nic.type = NetworkInterfaceTypes.PHYSICAL; nic.link_connected = true; - renderWithMockStore( - , - { state } - ); + renderWithMockStore(, { + state, + }); // Open the menu: await openMenu(); @@ -106,12 +112,7 @@ describe("NetworkTableActions", () => { nic.links = [networkLinkFactory(), link]; renderWithMockStore( - , + , { state } ); // Open the menu: @@ -128,12 +129,7 @@ describe("NetworkTableActions", () => { nic.links = [networkLinkFactory(), link]; renderWithMockStore( - , + , { state } ); // Open the menu: @@ -146,10 +142,9 @@ describe("NetworkTableActions", () => { it("can display an item to remove the interface", async () => { nic.type = NetworkInterfaceTypes.BOND; - renderWithMockStore( - , - { state } - ); + renderWithMockStore(, { + state, + }); // Open the menu: await openMenu(); expect( @@ -159,16 +154,10 @@ describe("NetworkTableActions", () => { it("can display an item to edit the interface", async () => { nic.type = NetworkInterfaceTypes.BOND; - const setExpanded = vi.fn(); - renderWithMockStore( - , - { state } - ); + renderWithMockStore(, { + state, + }); // Open the menu: await openMenu(); const editBondButton = screen.getByRole("button", { @@ -176,25 +165,20 @@ describe("NetworkTableActions", () => { }); expect(editBondButton).toBeInTheDocument(); await userEvent.click(editBondButton); - expect(setExpanded).toHaveBeenCalledWith({ - content: ExpandedState.EDIT, - nicId: nic.id, - }); + expect(setSidePanelContent).toHaveBeenCalledWith( + expect.objectContaining({ + view: MachineSidePanelViews.EDIT_PHYSICAL, + }) + ); }); it("can display a warning when trying to edit a disconnected interface", async () => { nic.type = NetworkInterfaceTypes.PHYSICAL; nic.link_connected = false; - const setExpanded = vi.fn(); - renderWithMockStore( - , - { state } - ); + renderWithMockStore(, { + state, + }); // Open the menu: await openMenu(); const editPhysicalButton = screen.getByRole("button", { @@ -202,20 +186,20 @@ describe("NetworkTableActions", () => { }); expect(editPhysicalButton).toBeInTheDocument(); await userEvent.click(editPhysicalButton); - expect(setExpanded).toHaveBeenCalledWith({ - content: ExpandedState.DISCONNECTED_WARNING, - nicId: nic.id, - }); + expect(setSidePanelContent).toHaveBeenCalledWith( + expect.objectContaining({ + view: MachineSidePanelViews.MARK_CONNECTED, + }) + ); }); it("can display an action to add an alias", async () => { nic.type = NetworkInterfaceTypes.PHYSICAL; nic.links = [networkLinkFactory()]; - renderWithMockStore( - , - { state } - ); + renderWithMockStore(, { + state, + }); // Open the menu: await openMenu(); const addAlias = screen.getByRole("button", { @@ -235,10 +219,9 @@ describe("NetworkTableActions", () => { nic.type = NetworkInterfaceTypes.PHYSICAL; nic.links = [networkLinkFactory({ mode: NetworkLinkMode.LINK_UP })]; - renderWithMockStore( - , - { state } - ); + renderWithMockStore(, { + state, + }); // Open the menu: await openMenu(); const addAlias = screen.getByRole("button", { @@ -262,10 +245,9 @@ describe("NetworkTableActions", () => { state.vlan.items = [vlan]; nic.vlan_id = vlan.id; - renderWithMockStore( - , - { state } - ); + renderWithMockStore(, { + state, + }); // Open the menu: await openMenu(); const addVLAN = screen.getByRole("button", { name: /Add VLAN/i }); @@ -282,10 +264,9 @@ describe("NetworkTableActions", () => { nic.type = NetworkInterfaceTypes.PHYSICAL; state.vlan.items = []; - renderWithMockStore( - , - { state } - ); + renderWithMockStore(, { + state, + }); // Open the menu: await openMenu(); const addVLAN = screen.getByRole("button", { name: /Add VLAN/i }); @@ -307,10 +288,9 @@ describe("NetworkTableActions", () => { state.machine.items[0].permissions = []; state.machine.items[0].status = NodeStatus.NEW; - renderWithMockStore( - , - { state } - ); + renderWithMockStore(, { + state, + }); // Open the menu: await openMenu(); expect( diff --git a/src/app/machines/views/MachineDetails/MachineNetwork/NetworkTable/NetworkTableActions/NetworkTableActions.tsx b/src/app/machines/views/MachineDetails/MachineNetwork/NetworkTable/NetworkTableActions/NetworkTableActions.tsx index 404c7ece1b..45ae22d174 100644 --- a/src/app/machines/views/MachineDetails/MachineNetwork/NetworkTable/NetworkTableActions/NetworkTableActions.tsx +++ b/src/app/machines/views/MachineDetails/MachineNetwork/NetworkTable/NetworkTableActions/NetworkTableActions.tsx @@ -1,11 +1,16 @@ import { useSelector } from "react-redux"; -import type { SetExpanded } from "@/app/base/components/NodeNetworkTab/NodeNetworkTab"; import { ExpandedState } from "@/app/base/components/NodeNetworkTab/NodeNetworkTab"; import TableMenu from "@/app/base/components/TableMenu"; import type { Props as TableMenuProps } from "@/app/base/components/TableMenu/TableMenu"; import TooltipButton from "@/app/base/components/TooltipButton"; +import type { + Selected, + SetSelected, +} from "@/app/base/components/node/networking/types"; import { useIsAllNetworkingDisabled } from "@/app/base/hooks"; +import { useSidePanel } from "@/app/base/side-panel-context"; +import { MachineSidePanelViews } from "@/app/machines/constants"; import machineSelectors from "@/app/store/machine/selectors"; import type { Machine } from "@/app/store/machine/types"; import { @@ -26,8 +31,9 @@ import { type Props = { link?: NetworkLink | null; nic?: NetworkInterface | null; - setExpanded: SetExpanded; systemId: Machine["system_id"]; + selected?: Selected[]; + setSelected?: SetSelected; }; export enum Label { @@ -37,9 +43,11 @@ export enum Label { const NetworkTableActions = ({ link, nic, - setExpanded, systemId, + selected, + setSelected, }: Props): JSX.Element | null => { + const { setSidePanelContent } = useSidePanel(); const machine = useSelector((state: RootState) => machineSelectors.getById(state, systemId) ); @@ -61,16 +69,19 @@ const NetworkTableActions = ({ ); let actions: TableMenuProps["links"] = []; if (machine && nic) { + const showDisconnectedWarning = isPhysical && !nic?.link_connected; actions = [ { inMenu: !nic.link_connected && isPhysical, state: ExpandedState.MARK_CONNECTED, label: "Mark as connected", + view: MachineSidePanelViews.MARK_CONNECTED, }, { inMenu: nic.link_connected && isPhysical, state: ExpandedState.MARK_DISCONNECTED, label: "Mark as disconnected", + view: MachineSidePanelViews.MARK_DISCONNECTED, }, { disabled: !itCanAddAlias, @@ -82,6 +93,7 @@ const NetworkTableActions = ({ : "IP mode needs to be configured for this interface.", state: ExpandedState.ADD_ALIAS, label: "Add alias", + view: MachineSidePanelViews.ADD_ALIAS, }, { disabled: !canAddVLAN, @@ -98,19 +110,23 @@ const NetworkTableActions = ({ ? null : "There are no unused VLANS for this interface.", label: "Add VLAN", + view: MachineSidePanelViews.ADD_VLAN, }, { inMenu: true, - state: - isPhysical && !nic?.link_connected - ? ExpandedState.DISCONNECTED_WARNING - : ExpandedState.EDIT, + state: showDisconnectedWarning + ? ExpandedState.DISCONNECTED_WARNING + : ExpandedState.EDIT, label: `Edit ${getInterfaceTypeText(machine, nic, link)}`, + view: showDisconnectedWarning + ? MachineSidePanelViews.MARK_CONNECTED + : MachineSidePanelViews.EDIT_PHYSICAL, }, { inMenu: !isAllNetworkingDisabled, state: ExpandedState.REMOVE, label: `Remove ${getInterfaceTypeText(machine, nic, link)}...`, + view: MachineSidePanelViews.REMOVE_PHYSICAL, }, ].reduce((items, item) => { if (item.inMenu && item.state) { @@ -132,11 +148,25 @@ const NetworkTableActions = ({ ), disabled: item.disabled, onClick: () => { - setExpanded({ - content: item.state, - linkId: link?.id, - nicId: link ? null : nic?.id, - }); + item.state === ExpandedState.EDIT + ? setSidePanelContent({ + view: item.view, + extras: { + linkId: link?.id, + nicId: nic?.id, + selected, + setSelected, + systemId: machine.system_id, + }, + }) + : setSidePanelContent({ + view: item.view, + extras: { + link, + nic, + systemId: machine.system_id, + }, + }); }, }); } diff --git a/src/app/machines/views/MachineDetails/MachineNetwork/RemovePhysicalForm/RemovePhysicalForm.test.tsx b/src/app/machines/views/MachineDetails/MachineNetwork/RemovePhysicalForm/RemovePhysicalForm.test.tsx new file mode 100644 index 0000000000..0d5df57ba9 --- /dev/null +++ b/src/app/machines/views/MachineDetails/MachineNetwork/RemovePhysicalForm/RemovePhysicalForm.test.tsx @@ -0,0 +1,69 @@ +import configureStore from "redux-mock-store"; + +import RemovePhysicalForm from "./RemovePhysicalForm"; + +import type { RootState } from "@/app/store/root/types"; +import { NetworkInterfaceTypes } from "@/app/store/types/enum"; +import { + machineDetails as machineDetailsFactory, + machineInterface as machineInterfaceFactory, + machineState as machineStateFactory, + machineStatus as machineStatusFactory, + machineStatuses as machineStatusesFactory, + rootState as rootStateFactory, +} from "@/testing/factories"; +import { renderWithBrowserRouter, screen, userEvent } from "@/testing/utils"; + +let state: RootState; +const mockStore = configureStore(); +const nic = machineInterfaceFactory({ + type: NetworkInterfaceTypes.PHYSICAL, +}); +beforeEach(() => { + state = rootStateFactory({ + machine: machineStateFactory({ + items: [ + machineDetailsFactory({ + system_id: "abc123", + interfaces: [nic], + }), + ], + statuses: machineStatusesFactory({ + abc123: machineStatusFactory(), + }), + }), + }); +}); + +it("renders a remove physical form", () => { + renderWithBrowserRouter( + , + { + state, + } + ); + + expect( + screen.getByText("Are you sure you want to remove this interface?") + ).toBeInTheDocument(); +}); + +it("dispatches a delete interface action", async () => { + const store = mockStore(state); + renderWithBrowserRouter( + , + { + store, + } + ); + + await userEvent.click(screen.getByRole("button", { name: "Remove" })); + + const actions = store.getActions(); + expect( + actions.some((action) => action.type === "machine/deleteInterface") + ).toBe(true); + expect( + screen.getByText("Are you sure you want to remove this interface?") + ).toBeInTheDocument(); +}); diff --git a/src/app/machines/views/MachineDetails/MachineNetwork/RemovePhysicalForm/RemovePhysicalForm.tsx b/src/app/machines/views/MachineDetails/MachineNetwork/RemovePhysicalForm/RemovePhysicalForm.tsx new file mode 100644 index 0000000000..5ae9a1cd84 --- /dev/null +++ b/src/app/machines/views/MachineDetails/MachineNetwork/RemovePhysicalForm/RemovePhysicalForm.tsx @@ -0,0 +1,96 @@ +import React from "react"; + +import { useDispatch, useSelector } from "react-redux"; + +import ModelActionForm from "@/app/base/components/ModelActionForm"; +import { useMachineDetailsForm } from "@/app/machines/hooks"; +import { actions as machineActions } from "@/app/store/machine"; +import machineSelectors from "@/app/store/machine/selectors"; +import type { Machine } from "@/app/store/machine/types"; +import type { RootState } from "@/app/store/root/types"; +import type { NetworkInterface, NetworkLink } from "@/app/store/types/node"; +import { + getLinkInterface, + getRemoveTypeText, + isAlias, +} from "@/app/store/utils"; + +interface Props { + link?: NetworkLink | null; + nic?: NetworkInterface | null; + systemId: Machine["system_id"]; + close: () => void; +} + +const RemovePhysicalForm: React.FC = ({ + link, + nic, + systemId, + close, +}) => { + const dispatch = useDispatch(); + const machine = useSelector((state: RootState) => + machineSelectors.getById(state, systemId) + ); + const { saved: deletedInterface, saving: deletingInterface } = + useMachineDetailsForm( + systemId, + "deletingInterface", + "deleteInterface", + () => close() + ); + const { saved: unlinkedSubnet, saving: unlinkingSubnet } = + useMachineDetailsForm(systemId, "unlinkingSubnet", "unlinkSubnet", () => + close() + ); + if (machine && link && !nic) { + [nic] = getLinkInterface(machine, link); + } + if (!machine || !nic) { + return null; + } + + const removeTypeText = getRemoveTypeText(machine, nic, link); + const isAnAlias = isAlias(machine, link); + + return ( + Are you sure you want to remove this {removeTypeText}?} + modelType={removeTypeText || ""} + onCancel={close} + onSaveAnalytics={{ + action: `Remove ${removeTypeText}`, + category: "Machine network", + label: "Remove", + }} + onSubmit={() => { + dispatch(machineActions.cleanup()); + if (isAnAlias) { + if (nic?.id && link?.id) { + dispatch( + machineActions.unlinkSubnet({ + interfaceId: nic?.id, + linkId: link?.id, + systemId: machine.system_id, + }) + ); + } + } else if (nic?.id) { + dispatch( + machineActions.deleteInterface({ + interfaceId: nic?.id, + systemId: machine.system_id, + }) + ); + } + }} + onSuccess={close} + saved={deletedInterface || unlinkedSubnet} + saving={deletingInterface || unlinkingSubnet} + submitLabel="Remove" + /> + ); +}; + +export default RemovePhysicalForm; diff --git a/src/app/machines/views/MachineDetails/MachineNetwork/RemovePhysicalForm/index.ts b/src/app/machines/views/MachineDetails/MachineNetwork/RemovePhysicalForm/index.ts new file mode 100644 index 0000000000..9d624dceb5 --- /dev/null +++ b/src/app/machines/views/MachineDetails/MachineNetwork/RemovePhysicalForm/index.ts @@ -0,0 +1 @@ +export { default } from "./RemovePhysicalForm"; diff --git a/src/app/store/utils/node/base.ts b/src/app/store/utils/node/base.ts index b994d55fc7..c32730045f 100644 --- a/src/app/store/utils/node/base.ts +++ b/src/app/store/utils/node/base.ts @@ -236,14 +236,22 @@ export const getSidePanelTitle = ( return "Edit interface"; case SidePanelViews.CREATE_ZONE[1]: return "Add AZ"; + case SidePanelViews.EDIT_PHYSICAL[1]: + return "Edit physical"; case SidePanelViews.DELETE_IMAGE[1]: return "Delete image"; case SidePanelViews.DELETE_SPACE[1]: return "Delete space"; case SidePanelViews.DELETE_FABRIC[1]: return "Delete fabric"; + case SidePanelViews.MARK_CONNECTED[1]: + return "Mark as connected"; + case SidePanelViews.MARK_DISCONNECTED[1]: + return "Mark as disconnected"; case SidePanelViews.REMOVE_INTERFACE[1]: return "Remove interface"; + case SidePanelViews.REMOVE_PHYSICAL[1]: + return "Remove physical"; case SidePanelViews.SET_DEFAULT[1]: return "Set default"; case SidePanelViews.UPDATE_DATASTORE[1]: