From fb1b4e96d52fb1c22c82e3fd8207b8a543c7ba49 Mon Sep 17 00:00:00 2001 From: David Edler Date: Fri, 10 Jan 2025 18:01:22 +0100 Subject: [PATCH] fix(network) ensure network configuration is fully supported in a clustered backend Signed-off-by: David Edler --- src/App.tsx | 8 + src/api/networks.tsx | 172 +++++++- src/components/ClusterSpecificSelect.tsx | 183 ++++++++ src/components/RenameHeader.tsx | 10 +- src/components/forms/NetworkDevicesForm.tsx | 10 +- src/pages/networks/CreateNetwork.tsx | 25 +- src/pages/networks/CreateNetworkForward.tsx | 10 +- src/pages/networks/EditNetwork.tsx | 74 +++- src/pages/networks/EditNetworkForward.tsx | 10 +- src/pages/networks/NetworkDetail.tsx | 32 +- src/pages/networks/NetworkDetailHeader.tsx | 12 +- src/pages/networks/NetworkForwardCount.tsx | 2 +- src/pages/networks/NetworkList.tsx | 403 ++++++++++++------ src/pages/networks/NetworkSearchFilter.tsx | 107 +++++ src/pages/networks/NetworkTopology.tsx | 96 ++++- src/pages/networks/forms/NetworkAddresses.tsx | 26 +- src/pages/networks/forms/NetworkForm.tsx | 8 +- src/pages/networks/forms/NetworkFormMain.tsx | 15 +- .../networks/forms/NetworkParentSelector.tsx | 99 ++++- .../networks/forms/NetworkStatistics.tsx | 19 +- src/pages/networks/forms/UplinkSelector.tsx | 36 +- src/sass/_network_topology.scss | 24 +- src/sass/cluster-specific-input.scss | 11 + src/sass/styles.scss | 1 + src/types/network.d.ts | 4 + src/util/intersection.tsx | 21 + src/util/networkForm.tsx | 16 +- 27 files changed, 1182 insertions(+), 252 deletions(-) create mode 100644 src/components/ClusterSpecificSelect.tsx create mode 100644 src/pages/networks/NetworkSearchFilter.tsx create mode 100644 src/sass/cluster-specific-input.scss create mode 100644 src/util/intersection.tsx diff --git a/src/App.tsx b/src/App.tsx index aef94e5db3..07ed53530a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -218,6 +218,14 @@ const App: FC = () => { /> } /> + } />} + /> + } + /> => { +export const fetchNetworks = ( + project: string, + target?: string, +): Promise => { + const targetParam = target ? `&target=${target}` : ""; return new Promise((resolve, reject) => { - fetch(`/1.0/networks?project=${project}&recursion=1`) + fetch(`/1.0/networks?project=${project}&recursion=1${targetParam}`) .then(handleResponse) .then((data: LxdApiResponse) => { const filteredNetworks = data.metadata.filter( @@ -20,24 +29,97 @@ export const fetchNetworks = (project: string): Promise => { }); }; +const constructMemberError = ( + result: PromiseRejectedResult, + member: string, +) => { + const reason = result.reason as Error; + const message = `Error from cluster member ${member}: ${reason.message}`; + return new Error(message); +}; + +export const fetchNetworksFromClusterMembers = ( + project: string, + clusterMembers: LxdClusterMember[], +): Promise => { + return new Promise((resolve, reject) => { + Promise.allSettled( + clusterMembers.map((member) => { + return fetchNetworks(project, member.server_name); + }), + ) + .then((results) => { + const networksOnMembers: LXDNetworkOnClusterMember[] = []; + for (let i = 0; i < clusterMembers.length; i++) { + const memberName = clusterMembers[i].server_name; + const result = results[i]; + if (result.status === "rejected") { + reject(constructMemberError(result, memberName)); + } + if (result.status === "fulfilled") { + result.value.forEach((network) => + networksOnMembers.push({ ...network, memberName }), + ); + } + } + resolve(networksOnMembers); + }) + .catch(reject); + }); +}; + export const fetchNetwork = ( name: string, project: string, + target?: string, ): Promise => { + const targetParam = target ? `&target=${target}` : ""; return new Promise((resolve, reject) => { - fetch(`/1.0/networks/${name}?project=${project}`) + fetch(`/1.0/networks/${name}?project=${project}${targetParam}`) .then(handleEtagResponse) .then((data) => resolve(data as LxdNetwork)) .catch(reject); }); }; +export const fetchNetworkFromClusterMembers = ( + name: string, + project: string, + clusterMembers: LxdClusterMember[], +): Promise => { + return new Promise((resolve, reject) => { + Promise.allSettled( + clusterMembers.map((member) => { + return fetchNetwork(name, project, member.server_name); + }), + ) + .then((results) => { + const networkOnMembers: LXDNetworkOnClusterMember[] = []; + for (let i = 0; i < clusterMembers.length; i++) { + const memberName = clusterMembers[i].server_name; + const result = results[i]; + if (result.status === "rejected") { + reject(constructMemberError(result, memberName)); + } + if (result.status === "fulfilled") { + const promise = results[i] as PromiseFulfilledResult; + networkOnMembers.push({ ...promise.value, memberName: memberName }); + } + } + resolve(networkOnMembers); + }) + .catch(reject); + }); +}; + export const fetchNetworkState = ( name: string, project: string, + target?: string, ): Promise => { + const targetParam = target ? `&target=${target}` : ""; return new Promise((resolve, reject) => { - fetch(`/1.0/networks/${name}/state?project=${project}`) + fetch(`/1.0/networks/${name}/state?project=${project}${targetParam}`) .then(handleResponse) .then((data: LxdApiResponse) => resolve(data.metadata)) .catch(reject); @@ -48,20 +130,19 @@ export const createClusterNetwork = ( network: Partial, project: string, clusterMembers: LxdClusterMember[], + parentsPerClusterMember?: ClusterSpecificValues, ): Promise => { return new Promise((resolve, reject) => { - const memberNetwork = { - name: network.name, - description: network.description, - type: network.type, - config: { - parent: network.config?.parent, - }, - }; - Promise.allSettled( - clusterMembers.map(async (member) => { - await createNetwork(memberNetwork, project, member.server_name); + clusterMembers.map((member) => { + const memberNetwork = { + name: network.name, + type: network.type, + config: { + parent: parentsPerClusterMember?.[member.server_name], + }, + }; + return createNetwork(memberNetwork, project, member.server_name); }), ) .then((results) => { @@ -72,6 +153,7 @@ export const createClusterNetwork = ( reject(error); return; } + // The network parent is cluster member specific, so we omit it on the cluster wide network configuration. delete network.config?.parent; createNetwork(network, project).then(resolve).catch(reject); @@ -110,15 +192,20 @@ export const createNetwork = ( export const updateNetwork = ( network: Partial & Required>, project: string, + target?: string, ): Promise => { return new Promise((resolve, reject) => { - fetch(`/1.0/networks/${network.name ?? ""}?project=${project}`, { - method: "PUT", - body: JSON.stringify(network), - headers: { - "If-Match": network.etag ?? "invalid-etag", + const targetParam = target ? `&target=${target}` : ""; + fetch( + `/1.0/networks/${network.name ?? ""}?project=${project}${targetParam}`, + { + method: "PUT", + body: JSON.stringify(network), + headers: { + "If-Match": network.etag ?? "", + }, }, - }) + ) .then(handleResponse) .then(resolve) .catch(async (e: Error) => { @@ -135,6 +222,45 @@ export const updateNetwork = ( }); }; +export const updateClusterNetwork = ( + network: Partial & Required>, + project: string, + parentsPerClusterMember?: ClusterSpecificValues, +): Promise => { + if (!parentsPerClusterMember) { + return updateNetwork(network, project); + } + + return new Promise((resolve, reject) => { + Promise.allSettled( + Object.keys(parentsPerClusterMember).map((memberName) => { + const memberNetwork = { + name: network.name, + type: network.type, + config: { + parent: parentsPerClusterMember[memberName], + }, + etag: "", + }; + return updateNetwork(memberNetwork, project, memberName); + }), + ) + .then((results) => { + const error = results.find((res) => res.status === "rejected") + ?.reason as Error | undefined; + + if (error) { + reject(error); + return; + } + updateNetwork({ ...network, etag: "" }, project) + .then(resolve) + .catch(reject); + }) + .catch(reject); + }); +}; + export const renameNetwork = ( oldName: string, newName: string, diff --git a/src/components/ClusterSpecificSelect.tsx b/src/components/ClusterSpecificSelect.tsx new file mode 100644 index 0000000000..23a319b070 --- /dev/null +++ b/src/components/ClusterSpecificSelect.tsx @@ -0,0 +1,183 @@ +import { FC, Fragment, type OptionHTMLAttributes, useState } from "react"; +import { + Button, + CheckboxInput, + Icon, + Select, +} from "@canonical/react-components"; +import ResourceLink from "components/ResourceLink"; +import { useParams } from "react-router-dom"; +import { intersection } from "util/intersection"; + +export type ClusterSpecificValues = Record; + +export interface ClusterSpecificSelectOption { + memberName: string; + values: string[]; +} + +interface Props { + id: string; + isReadOnly: boolean; + onChange: (value: ClusterSpecificValues) => void; + toggleReadOnly: () => void; + options: ClusterSpecificSelectOption[]; + values?: ClusterSpecificValues; + canToggleSpecific?: boolean; + isDefaultSpecific?: boolean; +} + +const ClusterSpecificSelect: FC = ({ + id, + isReadOnly, + options, + values, + onChange, + toggleReadOnly, + canToggleSpecific = true, + isDefaultSpecific = false, +}) => { + const { project } = useParams<{ project: string }>(); + + const [isSpecific, setIsSpecific] = useState(isDefaultSpecific); + + const toSelectOption = ( + value: string, + ): OptionHTMLAttributes => { + return { + label: value, + value: value, + }; + }; + + const allMemberOptions: OptionHTMLAttributes[] = []; + if (options.length > 0) { + const optionsPerMember = options.map((item) => item.values); + const optionsOnAllMembers = intersection(optionsPerMember); + optionsOnAllMembers.forEach((value) => { + const option = toSelectOption(value); + allMemberOptions.push(option); + }); + } + allMemberOptions.unshift({ + label: + allMemberOptions.length === 0 ? "No option available" : "Select option", + value: "", + }); + + const firstValue = Object.values(values ?? {})[0]; + + const setValueForAllMembers = (value: string) => { + const update: ClusterSpecificValues = {}; + options.map((item) => (update[item.memberName] = value)); + onChange(update); + }; + + const setValueForMember = (value: string, member: string) => { + const update = { + ...values, + [member]: value, + }; + onChange(update); + }; + + const editButton = ( + + ); + + return ( +
+ {canToggleSpecific && !isReadOnly && ( + { + if (isSpecific) { + setValueForAllMembers( + (allMemberOptions?.[1]?.value ?? "") as string, + ); + } + setIsSpecific((val) => !val); + }} + /> + )} + {isSpecific && ( +
+ {options.map((item) => { + const activeValue = values?.[item.memberName]; + const selectOptions = item.values.map(toSelectOption); + selectOptions.unshift({ + label: + selectOptions.length === 0 ? "None available" : "Select option", + value: "", + }); + + return ( + +
+ +
+
+ {isReadOnly ? ( + <> + {activeValue} + {editButton} + + ) : ( + setValueForAllMembers(e.target.value)} + value={firstValue} + /> + )} +
+ )} +
+ ); +}; + +export default ClusterSpecificSelect; diff --git a/src/components/RenameHeader.tsx b/src/components/RenameHeader.tsx index 036c7048df..3c1bf4c783 100644 --- a/src/components/RenameHeader.tsx +++ b/src/components/RenameHeader.tsx @@ -15,6 +15,7 @@ export interface RenameHeaderValues { interface Props { name: string; + relatedChip?: ReactNode; titleClassName?: string; parentItems: ReactNode[]; centerControls?: ReactNode; @@ -26,6 +27,7 @@ interface Props { const RenameHeader: FC = ({ name, + relatedChip, titleClassName, parentItems, centerControls, @@ -56,7 +58,10 @@ const RenameHeader: FC = ({ className="p-breadcrumbs p-breadcrumbs--large" aria-label="Breadcrumbs" > -
    +
      {parentItems.map((item, key) => (
    1. = ({
    2. )}
    + {relatedChip && ( + {relatedChip} + )} {!formik?.values.isRenaming && centerControls}
diff --git a/src/components/forms/NetworkDevicesForm.tsx b/src/components/forms/NetworkDevicesForm.tsx index 5a9468d5ac..3e6d73aaff 100644 --- a/src/components/forms/NetworkDevicesForm.tsx +++ b/src/components/forms/NetworkDevicesForm.tsx @@ -1,4 +1,4 @@ -import { FC } from "react"; +import { FC, useEffect } from "react"; import { Button, Icon, @@ -54,9 +54,11 @@ const NetworkDevicesForm: FC = ({ formik, project }) => { queryFn: () => fetchNetworks(project), }); - if (networkError) { - notify.failure("Loading networks failed", networkError); - } + useEffect(() => { + if (networkError) { + notify.failure("Loading networks failed", networkError); + } + }, [networkError]); if (isProfileLoading || isNetworkLoading) { return ; diff --git a/src/pages/networks/CreateNetwork.tsx b/src/pages/networks/CreateNetwork.tsx index d3fe583481..e2458253df 100644 --- a/src/pages/networks/CreateNetwork.tsx +++ b/src/pages/networks/CreateNetwork.tsx @@ -7,7 +7,7 @@ import { } from "@canonical/react-components"; import { useFormik } from "formik"; import * as Yup from "yup"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useQueryClient } from "@tanstack/react-query"; import { queryKeys } from "util/queryKeys"; import { useNavigate, useParams } from "react-router-dom"; import { checkDuplicateName } from "util/helpers"; @@ -22,7 +22,6 @@ import Loader from "components/Loader"; import { yamlToObject } from "util/yaml"; import { dump as dumpYaml } from "js-yaml"; import { isClusteredServer, supportsOvnNetwork } from "util/settings"; -import { fetchClusterMembers } from "api/cluster"; import BaseLayout from "components/BaseLayout"; import { GENERAL, @@ -34,6 +33,7 @@ import { useToastNotification } from "context/toastNotificationProvider"; import YamlSwitch from "components/forms/YamlSwitch"; import ResourceLink from "components/ResourceLink"; import { scrollToElement } from "util/scroll"; +import { useClusterMembers } from "context/useClusterMembers"; const CreateNetwork: FC = () => { const navigate = useNavigate(); @@ -46,12 +46,7 @@ const CreateNetwork: FC = () => { const { data: settings, isLoading } = useSettings(); const isClustered = isClusteredServer(settings); const hasOvn = supportsOvnNetwork(settings); - - const { data: clusterMembers = [] } = useQuery({ - queryKey: [queryKeys.cluster, queryKeys.members], - queryFn: fetchClusterMembers, - enabled: isClustered, - }); + const { data: clusterMembers = [] } = useClusterMembers(); if (!project) { return <>Missing project; @@ -94,7 +89,13 @@ const CreateNetwork: FC = () => { const mutation = isClustered && values.networkType !== "ovn" - ? () => createClusterNetwork(network, project, clusterMembers) + ? () => + createClusterNetwork( + network, + project, + clusterMembers, + values.parentPerClusterMember, + ) : () => createNetwork(network, project); mutation() @@ -180,7 +181,11 @@ const CreateNetwork: FC = () => { !formik.isValid || !formik.values.name || (formik.values.networkType === "ovn" && !formik.values.network) || - (formik.values.networkType === "physical" && !formik.values.parent) + (formik.values.networkType === "physical" && + !formik.values.parent && + Object.values(formik.values.parentPerClusterMember ?? {}).filter( + (item) => item.length > 0, + ).length !== clusterMembers.length) } onClick={() => void formik.submitForm()} > diff --git a/src/pages/networks/CreateNetworkForward.tsx b/src/pages/networks/CreateNetworkForward.tsx index d467bee8c8..e269fd1881 100644 --- a/src/pages/networks/CreateNetworkForward.tsx +++ b/src/pages/networks/CreateNetworkForward.tsx @@ -1,4 +1,4 @@ -import { FC } from "react"; +import { FC, useEffect } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { queryKeys } from "util/queryKeys"; import { ActionButton, useNotify } from "@canonical/react-components"; @@ -28,11 +28,17 @@ const CreateNetworkForward: FC = () => { project: string; }>(); - const { data: network } = useQuery({ + const { data: network, error: networkError } = useQuery({ queryKey: [queryKeys.projects, project, queryKeys.networks, networkName], queryFn: () => fetchNetwork(networkName ?? "", project ?? ""), }); + useEffect(() => { + if (networkError) { + notify.failure("Loading networks failed", networkError); + } + }, [networkError]); + const getDefaultListenAddress = () => { if (network?.type !== "ovn") { return ""; diff --git a/src/pages/networks/EditNetwork.tsx b/src/pages/networks/EditNetwork.tsx index 43988780ec..0ae3f1f1e5 100644 --- a/src/pages/networks/EditNetwork.tsx +++ b/src/pages/networks/EditNetwork.tsx @@ -2,10 +2,14 @@ import { FC, useEffect, useState } from "react"; import { Button, useNotify } from "@canonical/react-components"; import { useFormik } from "formik"; import * as Yup from "yup"; -import { useQueryClient } from "@tanstack/react-query"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { queryKeys } from "util/queryKeys"; import { checkDuplicateName } from "util/helpers"; -import { updateNetwork } from "api/networks"; +import { + fetchNetworkFromClusterMembers, + updateNetwork, + updateClusterNetwork, +} from "api/networks"; import NetworkForm, { NetworkFormValues, toNetwork, @@ -27,6 +31,7 @@ import YamlSwitch from "components/forms/YamlSwitch"; import FormSubmitBtn from "components/forms/FormSubmitBtn"; import ResourceLink from "components/ResourceLink"; import { scrollToElement } from "util/scroll"; +import { useClusterMembers } from "context/useClusterMembers"; interface Props { network: LxdNetwork; @@ -44,6 +49,27 @@ const EditNetwork: FC = ({ network, project }) => { const queryClient = useQueryClient(); const controllerState = useState(null); const [version, setVersion] = useState(0); + const { data: clusterMembers = [] } = useClusterMembers(); + const isClustered = clusterMembers.length > 0; + + const { data: networkOnMembers = [], error } = useQuery({ + queryKey: [ + queryKeys.projects, + project, + queryKeys.networks, + network.name, + queryKeys.cluster, + ], + queryFn: () => + fetchNetworkFromClusterMembers(network.name, project, clusterMembers), + enabled: isClustered && network.managed && network.type === "physical", + }); + + useEffect(() => { + if (error) { + notify.failure("Loading network from cluster members failed", error); + } + }, [error]); const NetworkSchema = Yup.object().shape({ name: Yup.string() @@ -65,16 +91,27 @@ const EditNetwork: FC = ({ network, project }) => { }); const formik = useFormik({ - initialValues: toNetworkFormValues(network), + initialValues: toNetworkFormValues(network, networkOnMembers), validationSchema: NetworkSchema, enableReinitialize: true, onSubmit: (values) => { const yaml = values.yaml ? values.yaml : getYaml(); - const saveNetwork = yamlToObject(yaml) as LxdNetwork; - updateNetwork({ ...saveNetwork, etag: network.etag }, project) + const yamlNetwork = yamlToObject(yaml) as LxdNetwork; + const saveNetwork = { ...yamlNetwork, etag: network.etag }; + + const mutation = isClustered + ? () => + updateClusterNetwork( + saveNetwork, + project, + values.parentPerClusterMember, + ) + : () => updateNetwork(saveNetwork, project); + + mutation() .then(() => { formik.resetForm({ - values: toNetworkFormValues(saveNetwork), + values: toNetworkFormValues(yamlNetwork, networkOnMembers), }); void queryClient.invalidateQueries({ @@ -85,6 +122,17 @@ const EditNetwork: FC = ({ network, project }) => { network.name, ], }); + + void queryClient.invalidateQueries({ + queryKey: [ + queryKeys.projects, + project, + queryKeys.networks, + network.name, + queryKeys.cluster, + ], + }); + toastNotify.success( <> Network{""} @@ -166,7 +214,9 @@ const EditNetwork: FC = ({ network, project }) => { appearance="base" onClick={() => { setVersion((old) => old + 1); - void formik.setValues(toNetworkFormValues(network)); + void formik.setValues( + toNetworkFormValues(network, networkOnMembers), + ); }} > Cancel @@ -174,7 +224,15 @@ const EditNetwork: FC = ({ network, project }) => { item.length > 0).length !== + clusterMembers.length) + } /> )} diff --git a/src/pages/networks/EditNetworkForward.tsx b/src/pages/networks/EditNetworkForward.tsx index 60d769f40e..c5dca5fa55 100644 --- a/src/pages/networks/EditNetworkForward.tsx +++ b/src/pages/networks/EditNetworkForward.tsx @@ -1,4 +1,4 @@ -import { FC } from "react"; +import { FC, useEffect } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { queryKeys } from "util/queryKeys"; import { ActionButton, useNotify } from "@canonical/react-components"; @@ -36,11 +36,17 @@ const EditNetworkForward: FC = () => { forwardAddress: string; }>(); - const { data: network } = useQuery({ + const { data: network, error } = useQuery({ queryKey: [queryKeys.projects, project, queryKeys.networks, networkName], queryFn: () => fetchNetwork(networkName ?? "", project ?? ""), }); + useEffect(() => { + if (error) { + notify.failure("Loading network failed", error); + } + }, [error]); + const { data: forward } = useQuery({ queryKey: [ queryKeys.projects, diff --git a/src/pages/networks/NetworkDetail.tsx b/src/pages/networks/NetworkDetail.tsx index 1ac834327f..3cb1fb447a 100644 --- a/src/pages/networks/NetworkDetail.tsx +++ b/src/pages/networks/NetworkDetail.tsx @@ -1,4 +1,4 @@ -import { FC } from "react"; +import { FC, useEffect } from "react"; import { useQuery } from "@tanstack/react-query"; import { queryKeys } from "util/queryKeys"; import { useParams } from "react-router-dom"; @@ -7,15 +7,18 @@ import NotificationRow from "components/NotificationRow"; import EditNetwork from "pages/networks/EditNetwork"; import NetworkDetailHeader from "pages/networks/NetworkDetailHeader"; import Loader from "components/Loader"; -import { Row } from "@canonical/react-components"; +import { Row, useNotify } from "@canonical/react-components"; import CustomLayout from "components/CustomLayout"; import TabLinks from "components/TabLinks"; import NetworkForwards from "pages/networks/NetworkForwards"; const NetworkDetail: FC = () => { - const { name, project, activeTab } = useParams<{ + const notify = useNotify(); + + const { name, project, member, activeTab } = useParams<{ name: string; project: string; + member: string; activeTab?: string; }>(); @@ -27,11 +30,28 @@ const NetworkDetail: FC = () => { return <>Missing project; } - const { data: network, isLoading } = useQuery({ - queryKey: [queryKeys.projects, project, queryKeys.networks, name], - queryFn: () => fetchNetwork(name, project), + const { + data: network, + error, + isLoading, + } = useQuery({ + queryKey: [ + queryKeys.projects, + project, + queryKeys.networks, + name, + queryKeys.members, + member, + ], + queryFn: () => fetchNetwork(name, project, member), }); + useEffect(() => { + if (error) { + notify.failure("Loading network failed", error); + } + }, [error]); + if (isLoading) { return ; } diff --git a/src/pages/networks/NetworkDetailHeader.tsx b/src/pages/networks/NetworkDetailHeader.tsx index 630866d553..b820550e13 100644 --- a/src/pages/networks/NetworkDetailHeader.tsx +++ b/src/pages/networks/NetworkDetailHeader.tsx @@ -1,5 +1,5 @@ import { FC, useState } from "react"; -import { Link, useNavigate } from "react-router-dom"; +import { Link, useNavigate, useParams } from "react-router-dom"; import RenameHeader, { RenameHeaderValues } from "components/RenameHeader"; import { useFormik } from "formik"; import * as Yup from "yup"; @@ -18,6 +18,7 @@ interface Props { } const NetworkDetailHeader: FC = ({ name, network, project }) => { + const { member } = useParams<{ member: string }>(); const navigate = useNavigate(); const notify = useNotify(); const toastNotify = useToastNotification(); @@ -72,6 +73,15 @@ const NetworkDetailHeader: FC = ({ name, network, project }) => { return ( + ) + } parentItems={[ Networks diff --git a/src/pages/networks/NetworkForwardCount.tsx b/src/pages/networks/NetworkForwardCount.tsx index 99ec69d070..192706420d 100644 --- a/src/pages/networks/NetworkForwardCount.tsx +++ b/src/pages/networks/NetworkForwardCount.tsx @@ -10,7 +10,7 @@ interface Props { } const NetworkForwardCount: FC = ({ network, project }) => { - if (network.managed === false) { + if (network.managed === false || network.type === "physical") { return <>-; } diff --git a/src/pages/networks/NetworkList.tsx b/src/pages/networks/NetworkList.tsx index 71256ed32f..e8fb009e1b 100644 --- a/src/pages/networks/NetworkList.tsx +++ b/src/pages/networks/NetworkList.tsx @@ -1,4 +1,4 @@ -import { FC } from "react"; +import { FC, useEffect } from "react"; import { Button, EmptyState, @@ -7,18 +7,35 @@ import { Row, useNotify, } from "@canonical/react-components"; -import { fetchNetworks } from "api/networks"; -import BaseLayout from "components/BaseLayout"; +import { fetchNetworksFromClusterMembers, fetchNetworks } from "api/networks"; import { useQuery } from "@tanstack/react-query"; import { queryKeys } from "util/queryKeys"; import Loader from "components/Loader"; -import { Link, useNavigate, useParams } from "react-router-dom"; +import { + Link, + useNavigate, + useParams, + useSearchParams, +} from "react-router-dom"; import NotificationRow from "components/NotificationRow"; import HelpLink from "components/HelpLink"; import { useDocs } from "context/useDocs"; import NetworkForwardCount from "pages/networks/NetworkForwardCount"; import { useSmallScreen } from "context/useSmallScreen"; import { renderNetworkType } from "util/networks"; +import ResourceLink from "components/ResourceLink"; +import { useClusterMembers } from "context/useClusterMembers"; +import PageHeader from "components/PageHeader"; +import CustomLayout from "components/CustomLayout"; +import NetworkSearchFilter, { + MANAGED, + NetworkFilters, + MEMBER, + STATE, + TYPE, + QUERY, +} from "pages/networks/NetworkSearchFilter"; +import { LXDNetworkOnClusterMember } from "types/network"; const NetworkList: FC = () => { const docBaseLink = useDocs(); @@ -26,6 +43,17 @@ const NetworkList: FC = () => { const notify = useNotify(); const { project } = useParams<{ project: string }>(); const isSmallScreen = useSmallScreen(); + const { data: clusterMembers = [] } = useClusterMembers(); + const isClustered = clusterMembers.length > 0; + const [searchParams] = useSearchParams(); + + const filters: NetworkFilters = { + queries: searchParams.getAll("query"), + type: searchParams.getAll(TYPE).map((value) => value.toLowerCase()), + managed: searchParams.getAll(MANAGED).map((value) => value.toLowerCase()), + member: searchParams.getAll(MEMBER), + state: searchParams.getAll(STATE), + }; if (!project) { return <>Missing project; @@ -40,16 +68,49 @@ const NetworkList: FC = () => { queryFn: () => fetchNetworks(project), }); - if (error) { - notify.failure("Loading networks failed", error); - } + useEffect(() => { + if (error) { + notify.failure("Loading networks failed", error); + } + }, [error]); + + const { + data: networksOnClusterMembers = [], + error: clusterNetworkError, + isLoading: isClusterNetworksLoading, + } = useQuery({ + queryKey: [queryKeys.networks, project, queryKeys.cluster], + queryFn: () => fetchNetworksFromClusterMembers(project, clusterMembers), + enabled: clusterMembers.length > 0, + }); - const hasNetworks = networks.length > 0; + useEffect(() => { + if (clusterNetworkError) { + notify.failure("Loading cluster networks failed", clusterNetworkError); + } + }, [clusterNetworkError]); + + const renderNetworks: LXDNetworkOnClusterMember[] = networks + .filter((network) => !isClustered || network.managed) + .map((network) => { + return { + ...network, + memberName: "Cluster-wide", + }; + }); + networksOnClusterMembers.forEach((network) => { + if (!network.managed) { + renderNetworks.push(network); + } + }); + + const hasNetworks = renderNetworks.length > 0; const headers = [ { content: "Name", sortKey: "name" }, { content: "Type", sortKey: "type" }, { content: "Managed", sortKey: "managed" }, + ...(isClustered ? [{ content: "Cluster member", sortKey: "member" }] : []), { content: "IPV4", className: "u-align--right" }, { content: "IPV6" }, { content: "Description", sortKey: "description" }, @@ -58,106 +119,170 @@ const NetworkList: FC = () => { { content: "State", sortKey: "state" }, ]; - const rows = networks.map((network) => { - return { - columns: [ - { - content: ( - - {network.name} - - ), - role: "rowheader", - "aria-label": "Name", - }, - { - content: renderNetworkType(network.type), - role: "rowheader", - "aria-label": "Type", - }, - { - content: network.managed ? "Yes" : "No", - role: "rowheader", - "aria-label": "Managed", - }, - { - content: network.config["ipv4.address"], - className: "u-align--right", - role: "rowheader", - "aria-label": "IPV4", - }, - { - content: network.config["ipv6.address"], - role: "rowheader", - "aria-label": "IPV6", - }, - { - content: ( -
- {network.description} -
- ), - role: "rowheader", - "aria-label": "Description", - }, - { - content: , - role: "rowheader", - className: "u-align--right", - "aria-label": "Forwards", - }, - { - content: network.used_by?.length ?? "0", - role: "rowheader", - className: "u-align--right", - "aria-label": "Used by", - }, - { - content: network.status, - role: "rowheader", - "aria-label": "State", + const rows = renderNetworks + .filter((network) => { + if ( + !filters.queries.every( + (q) => + network.name.toLowerCase().includes(q) || + network.description?.toLowerCase().includes(q), + ) + ) { + return false; + } + if (filters.type.length > 0 && !filters.type.includes(network.type)) { + return false; + } + if ( + filters.managed.length > 0 && + !filters.managed.includes(network.managed ? "yes" : "no") + ) { + return false; + } + if ( + filters.member.length > 0 && + !filters.member.includes(network.memberName) + ) { + return false; + } + if ( + filters.state.length > 0 && + !filters.state.includes(network.status ?? "") + ) { + return false; + } + return true; + }) + .map((network) => { + const href = + network.memberName === "Cluster-wide" + ? `/ui/project/${project}/network/${network.name}` + : `/ui/project/${project}/member/${network.memberName}/network/${network.name}`; + + let memberBaseUrl = `/ui/project/${project}/networks?search=${Number(searchParams.get("search")) + 1}`; + const appendExistingSearchParams = (field: string) => { + searchParams + .getAll(field) + .forEach((item) => (memberBaseUrl += `&${field}=${item}`)); + }; + appendExistingSearchParams(STATE); + appendExistingSearchParams(TYPE); + appendExistingSearchParams(MANAGED); + appendExistingSearchParams(QUERY); + + return { + columns: [ + { + content: {network.name}, + role: "rowheader", + "aria-label": "Name", + }, + { + content: renderNetworkType(network.type), + role: "rowheader", + "aria-label": "Type", + }, + { + content: network.managed ? "Yes" : "No", + role: "rowheader", + "aria-label": "Managed", + }, + ...(isClustered + ? [ + { + content: + network.memberName === "Cluster-wide" ? ( + + ) : ( + + ), + role: "rowheader", + "aria-label": "Cluster member", + }, + ] + : []), + { + content: network.config["ipv4.address"], + className: "u-align--right", + role: "rowheader", + "aria-label": "IPV4", + }, + { + content: network.config["ipv6.address"], + role: "rowheader", + "aria-label": "IPV6", + }, + { + content: ( +
+ {network.description} +
+ ), + role: "rowheader", + "aria-label": "Description", + }, + { + content: ( + + ), + role: "rowheader", + className: "u-align--right", + "aria-label": "Forwards", + }, + { + content: network.used_by?.length ?? "0", + role: "rowheader", + className: "u-align--right", + "aria-label": "Used by", + }, + { + content: network.status, + role: "rowheader", + "aria-label": "State", + }, + ], + sortData: { + name: network.name.toLowerCase(), + type: network.type, + managed: network.managed, + description: network.description?.toLowerCase(), + state: network.status, + usedBy: network.used_by?.length ?? 0, + member: network.memberName, }, - ], - sortData: { - name: network.name.toLowerCase(), - type: network.type, - managed: network.managed, - description: network.description?.toLowerCase(), - state: network.status, - usedBy: network.used_by?.length ?? 0, - }, - }; - }); + }; + }); - if (isLoading) { + if (isLoading || isClusterNetworksLoading) { return ; } return ( - <> - - Networks - - } - controls={ - <> - {hasNetworks && ( - - )} + Networks + + + + + + + - - } - > - - - {hasNetworks && ( - - )} - {!isLoading && !hasNetworks && ( - } - title="No networks found" - > -

There are no networks in this project.

-

- - Learn more about networks - - -

-
- )} -
-
- + + + } + > + + + {hasNetworks && ( + + )} + {!isLoading && !hasNetworks && ( + } + title="No networks found" + > +

There are no networks in this project.

+

+ + Learn more about networks + + +

+
+ )} +
+ ); }; diff --git a/src/pages/networks/NetworkSearchFilter.tsx b/src/pages/networks/NetworkSearchFilter.tsx new file mode 100644 index 0000000000..db311d433b --- /dev/null +++ b/src/pages/networks/NetworkSearchFilter.tsx @@ -0,0 +1,107 @@ +import { FC, memo } from "react"; +import { SearchAndFilter } from "@canonical/react-components"; +import type { + SearchAndFilterChip, + SearchAndFilterData, +} from "@canonical/react-components/dist/components/SearchAndFilter/types"; +import { useSearchParams } from "react-router-dom"; +import { + paramsFromSearchData, + searchParamsToChips, +} from "util/searchAndFilter"; +import { useClusterMembers } from "context/useClusterMembers"; + +export const QUERY = "query"; +export const TYPE = "type"; +export const MANAGED = "managed"; +export const MEMBER = "member"; +export const STATE = "state"; + +const QUERY_PARAMS = [QUERY, TYPE, MANAGED, MEMBER, STATE]; + +export interface NetworkFilters { + queries: string[]; + type: string[]; + managed: string[]; + member: string[]; + state: string[]; +} + +const NetworkSearchFilter: FC = () => { + const [searchParams, setSearchParams] = useSearchParams(); + const { data: clusterMembers = [] } = useClusterMembers(); + + const locationSet = [ + ...new Set(clusterMembers.map((member) => member.server_name)), + ]; + + const searchAndFilterData: SearchAndFilterData[] = [ + { + id: 1, + heading: "Type", + chips: ["OVN", "Bridge", "Physical"].map((type) => { + return { lead: TYPE, value: type }; + }), + }, + { + id: 2, + heading: "Managed", + chips: ["Yes", "No"].map((managed) => { + return { lead: MANAGED, value: managed }; + }), + }, + { + id: 3, + heading: "State", + chips: ["Created", "Pending", "Unknown", "Unavailable", "Errored"].map( + (state) => { + return { lead: STATE, value: state }; + }, + ), + }, + ...(clusterMembers.length > 0 + ? [ + { + id: 4, + heading: "Cluster member", + chips: ["Cluster-wide"].concat(locationSet).map((location) => { + return { lead: MEMBER, value: location }; + }), + }, + ] + : []), + ]; + + const onSearchDataChange = (searchData: SearchAndFilterChip[]) => { + const newParams = paramsFromSearchData( + searchData, + searchParams, + QUERY_PARAMS, + ); + + if (newParams.toString() !== searchParams.toString()) { + setSearchParams(newParams); + } + }; + + return ( + <> +

Search and filter

+ { + window.dispatchEvent( + new CustomEvent("resize", { detail: "search-and-filter" }), + ); + }} + onPanelToggle={() => { + window.dispatchEvent(new CustomEvent("sfp-toggle")); + }} + /> + + ); +}; + +export default memo(NetworkSearchFilter); diff --git a/src/pages/networks/NetworkTopology.tsx b/src/pages/networks/NetworkTopology.tsx index c22f06e1d6..3c82891597 100644 --- a/src/pages/networks/NetworkTopology.tsx +++ b/src/pages/networks/NetworkTopology.tsx @@ -1,23 +1,27 @@ -import React, { FC, useState } from "react"; +import React, { FC, useEffect, useState } from "react"; import { FormikProps } from "formik/dist/types"; import { NetworkFormValues } from "pages/networks/forms/NetworkForm"; import { slugify } from "util/slugify"; import { CONNECTIONS } from "pages/networks/forms/NetworkFormMenu"; import ResourceLink from "components/ResourceLink"; import { filterUsedByType } from "util/usedBy"; -import { Button, Icon } from "@canonical/react-components"; +import { Button, Icon, useNotify } from "@canonical/react-components"; import classNames from "classnames"; import { useQuery } from "@tanstack/react-query"; import { queryKeys } from "util/queryKeys"; import { fetchNetworks } from "api/networks"; import classnames from "classnames"; +import { useParams } from "react-router-dom"; interface Props { formik: FormikProps; project: string; + isServerClustered: boolean; } -const NetworkTopology: FC = ({ formik, project }) => { +const NetworkTopology: FC = ({ formik, project, isServerClustered }) => { + const { member } = useParams<{ member: string }>(); + const notify = useNotify(); const [isNetworksCollapsed, setNetworksCollapsed] = useState(true); const [isInstancesCollapsed, setInstancesCollapsed] = useState(true); const network = formik.values.bareNetwork; @@ -26,44 +30,100 @@ const NetworkTopology: FC = ({ formik, project }) => { return; } - const { data: networks = [] } = useQuery({ - queryKey: [queryKeys.projects, project, queryKeys.networks], + const hasClusteredUplinks = + isServerClustered && ["physical", "bridge"].includes(network.type); + + const { data: networks = [], error } = useQuery({ + queryKey: [queryKeys.networks, project], queryFn: () => fetchNetworks(project), }); - const downstreamNetworks = networks.filter( - (network) => - network.config.network === formik.values.name || - network.config.parent === formik.values.name, - ); + useEffect(() => { + if (error) { + notify.failure("Loading networks failed", error); + } + }, [error]); + + const downstreamNetworks = networks.filter((downStreamCandidate) => { + return ( + downStreamCandidate.config.network === formik.values.name || + downStreamCandidate.config.parent === formik.values.name || + network.used_by?.includes(`/1.0/networks/${downStreamCandidate.name}`) + ); + }); const instances = filterUsedByType("instance", network.used_by); const uplink = formik.values.parent ?? formik.values.network; + const clusterUplinks = Object.keys( + formik.values.parentPerClusterMember ?? {}, + ).map((clusterMember) => { + const memberUplink = formik.values.parentPerClusterMember?.[clusterMember]; + + if (!memberUplink) { + return null; + } + + return ( +
+ + —— + +
+ ); + }); + + const hasUplink = + uplink ?? clusterUplinks.filter((item) => item !== null).length > 0; + return ( <>

Connections

- {uplink && ( + {hasUplink && (
- + {hasClusteredUplinks + ? clusterUplinks + : uplink && ( +
+ +
+ )}
)}
0 || downstreamNetworks.length > 0, - "has-parent": !!uplink, + "has-parent": hasUplink, })} > + {member && ( + <> + + —— + + )}
diff --git a/src/pages/networks/forms/NetworkAddresses.tsx b/src/pages/networks/forms/NetworkAddresses.tsx index ce1f14af46..4500c9c2e4 100644 --- a/src/pages/networks/forms/NetworkAddresses.tsx +++ b/src/pages/networks/forms/NetworkAddresses.tsx @@ -1,10 +1,11 @@ -import { FC } from "react"; +import { FC, useEffect } from "react"; import { FormikProps } from "formik/dist/types"; import { NetworkFormValues } from "pages/networks/forms/NetworkForm"; import { useQuery } from "@tanstack/react-query"; import { queryKeys } from "util/queryKeys"; import { fetchNetworkState } from "api/networks"; -import { MainTable } from "@canonical/react-components"; +import { MainTable, useNotify } from "@canonical/react-components"; +import { useParams } from "react-router-dom"; interface Props { formik: FormikProps; @@ -12,20 +13,31 @@ interface Props { } const NetworkAddresses: FC = ({ formik, project }) => { - const { data: networkState } = useQuery({ + const { member } = useParams<{ member: string }>(); + const notify = useNotify(); + + const networkName = formik.values.bareNetwork?.name ?? ""; + + const { data: networkState, error } = useQuery({ queryKey: [ queryKeys.projects, project, queryKeys.networks, - formik.values.bareNetwork?.name, + networkName, + queryKeys.members, + member, queryKeys.state, ], retry: 0, // physical managed networks can sometimes 404, show error right away and don't retry - queryFn: () => - fetchNetworkState(formik.values.bareNetwork?.name ?? "", project), - enabled: !formik.values.isCreating, + queryFn: () => fetchNetworkState(networkName, project, member), }); + useEffect(() => { + if (error) { + notify.failure("Loading network state failed", error); + } + }, [error]); + return ( <>

Addresses

diff --git a/src/pages/networks/forms/NetworkForm.tsx b/src/pages/networks/forms/NetworkForm.tsx index 94715065e7..131a2d445f 100644 --- a/src/pages/networks/forms/NetworkForm.tsx +++ b/src/pages/networks/forms/NetworkForm.tsx @@ -35,6 +35,7 @@ import ScrollableContainer from "components/ScrollableContainer"; import NetworkTopology from "pages/networks/NetworkTopology"; import { debounce } from "util/debounce"; import { MainTableRow } from "@canonical/react-components/dist/components/MainTable/MainTable"; +import { ClusterSpecificValues } from "components/ClusterSpecificSelect"; export interface NetworkFormValues { readOnly: boolean; @@ -75,6 +76,7 @@ export interface NetworkFormValues { network?: string; ovn_ingress_mode?: string; parent?: string; + parentPerClusterMember?: ClusterSpecificValues; yaml?: string; entityType: "network"; bareNetwork?: LxdNetwork; @@ -237,7 +239,11 @@ const NetworkForm: FC = ({ {!formik.values.isCreating && query.length < 1 && section !== slugify(YAML_CONFIGURATION) && ( - + )}
{section !== slugify(YAML_CONFIGURATION) && ( diff --git a/src/pages/networks/forms/NetworkFormMain.tsx b/src/pages/networks/forms/NetworkFormMain.tsx index 592c7c741d..f72409975a 100644 --- a/src/pages/networks/forms/NetworkFormMain.tsx +++ b/src/pages/networks/forms/NetworkFormMain.tsx @@ -94,14 +94,13 @@ const NetworkFormMain: FC = ({ formik, project, isClustered }) => { formik={formik} /> )} - {formik.values.networkType === "physical" && - isManagedNetwork && - (formik.values.isCreating || !isClustered) && ( - - )} + {formik.values.networkType === "physical" && isManagedNetwork && ( + + )} {formik.values.networkType !== "physical" && isManagedNetwork && ( <> ; formik: FormikProps; + isClustered: boolean; } -const NetworkParentSelector: FC = ({ props, formik }) => { +const NetworkParentSelector: FC = ({ props, formik, isClustered }) => { const { project } = useParams<{ project: string }>(); + const { data: clusterMembers = [] } = useClusterMembers(); + const notify = useNotify(); if (!project) { return <>Missing project; } - const { data: networks = [], isLoading: isNetworkLoading } = useQuery({ + const { + data: networks = [], + error: networkError, + isLoading: isNetworkLoading, + } = useQuery({ queryKey: [queryKeys.networks, project], queryFn: () => fetchNetworks(project), + enabled: !isClustered, }); + useEffect(() => { + if (networkError) { + notify.failure("Loading networks failed", networkError); + } + }, [networkError]); + + const { + data: networksOnClusterMembers = [], + error: clusterNetworkError, + isLoading: isClusterNetworksLoading, + } = useQuery({ + queryKey: [queryKeys.networks, "default", queryKeys.cluster], + queryFn: () => fetchNetworksFromClusterMembers("default", clusterMembers), + enabled: clusterMembers.length > 0, + }); + + useEffect(() => { + if (clusterNetworkError) { + notify.failure("Loading cluster networks failed", clusterNetworkError); + } + }, [clusterNetworkError]); + const options = networks .filter((network) => network.managed === false) .map((network) => { @@ -40,10 +80,57 @@ const NetworkParentSelector: FC = ({ props, formik }) => { value: "", }); - if (isNetworkLoading) { + if (isNetworkLoading || isClusterNetworksLoading) { return ; } + if (isClustered) { + const currentValues = Object.values( + formik.values.parentPerClusterMember ?? {}, + ); + + const options: ClusterSpecificSelectOption[] = []; + clusterMembers.forEach((member) => + options.push({ + memberName: member.server_name, + values: networksOnClusterMembers + .filter( + (item) => + item.memberName === member.server_name && item.managed === false, + ) + .map((item) => item.name), + }), + ); + + return ( +
+
+ +
+
+ { + void formik.setFieldValue("parentPerClusterMember", value); + }} + isReadOnly={formik.values.readOnly} + toggleReadOnly={() => { + ensureEditMode(formik); + focusField("parent"); + }} + isDefaultSpecific={currentValues.some( + (item) => item !== currentValues[0], + )} + /> +
+
+ ); + } + return (
diff --git a/src/pages/networks/forms/NetworkStatistics.tsx b/src/pages/networks/forms/NetworkStatistics.tsx index fcc9baba93..180555a522 100644 --- a/src/pages/networks/forms/NetworkStatistics.tsx +++ b/src/pages/networks/forms/NetworkStatistics.tsx @@ -1,10 +1,12 @@ -import { FC } from "react"; +import { FC, useEffect } from "react"; import { FormikProps } from "formik/dist/types"; import { NetworkFormValues } from "pages/networks/forms/NetworkForm"; import { humanFileSize } from "util/helpers"; import { useQuery } from "@tanstack/react-query"; import { queryKeys } from "util/queryKeys"; import { fetchNetworkState } from "api/networks"; +import { useParams } from "react-router-dom"; +import { useNotify } from "@canonical/react-components"; interface Props { formik: FormikProps; @@ -12,20 +14,31 @@ interface Props { } const NetworkStatistics: FC = ({ formik, project }) => { - const { data: networkState } = useQuery({ + const { member } = useParams<{ member: string }>(); + const notify = useNotify(); + + const { data: networkState, error } = useQuery({ queryKey: [ queryKeys.projects, project, queryKeys.networks, formik.values.bareNetwork?.name, + queryKeys.members, + member, queryKeys.state, ], retry: 0, // physical managed networks can sometimes 404, show error right away and don't retry queryFn: () => - fetchNetworkState(formik.values.bareNetwork?.name ?? "", project), + fetchNetworkState(formik.values.bareNetwork?.name ?? "", project, member), enabled: !formik.values.isCreating, }); + useEffect(() => { + if (error) { + notify.failure("Loading network state failed", error); + } + }, [error]); + const isManagedNetwork = formik.values.bareNetwork?.managed ?? true; return ( diff --git a/src/pages/networks/forms/UplinkSelector.tsx b/src/pages/networks/forms/UplinkSelector.tsx index 16b60ff941..c44eee8c10 100644 --- a/src/pages/networks/forms/UplinkSelector.tsx +++ b/src/pages/networks/forms/UplinkSelector.tsx @@ -1,5 +1,11 @@ -import { FC } from "react"; -import { Button, Icon, Label, Select } from "@canonical/react-components"; +import { FC, useEffect } from "react"; +import { + Button, + Icon, + Label, + Select, + useNotify, +} from "@canonical/react-components"; import Loader from "components/Loader"; import { useQuery } from "@tanstack/react-query"; import { queryKeys } from "util/queryKeys"; @@ -19,16 +25,38 @@ interface Props { } const UplinkSelector: FC = ({ project: projectName, props, formik }) => { - const { data: networks = [], isLoading: isNetworkLoading } = useQuery({ + const notify = useNotify(); + + const { + data: networks = [], + error: networkError, + isLoading: isNetworkLoading, + } = useQuery({ queryKey: [queryKeys.networks, "default"], queryFn: () => fetchNetworks("default"), }); - const { data: project, isLoading: isProjectLoading } = useQuery({ + useEffect(() => { + if (networkError) { + notify.failure("Loading networks failed", networkError); + } + }, [networkError]); + + const { + data: project, + error: projectError, + isLoading: isProjectLoading, + } = useQuery({ queryKey: [queryKeys.projects, projectName], queryFn: () => fetchProject(projectName), }); + useEffect(() => { + if (projectError) { + notify.failure("Loading projects failed", projectError); + } + }, [projectError]); + const availableUplinks = project?.config?.["restricted.networks.uplinks"]?.split(",") || networks diff --git a/src/sass/_network_topology.scss b/src/sass/_network_topology.scss index 6e63d67424..98c85e9bd2 100644 --- a/src/sass/_network_topology.scss +++ b/src/sass/_network_topology.scss @@ -3,7 +3,7 @@ margin-bottom: $spv--x-large; .current-network { - .p-chip { + .active-chip { background-color: $colors--dark-theme--background-active !important; * { @@ -16,7 +16,11 @@ max-width: 13vw !important; } - .uplink::after, + .uplink { + text-align: right; + } + + .uplink-item::after, .has-parent::before, .has-descendents::after, .downstream-item::before { @@ -29,12 +33,12 @@ width: 2rem; } - .uplink::after, .has-parent::before, .has-descendents::after { clip-path: polygon(100% 80%, 0 80%, 0 83%, 100% 83%); } + .uplink-item::after, .downstream-item::before { clip-path: polygon( 100% 80%, @@ -54,14 +58,21 @@ display: contents; } + .uplink-item::after { + transform: scale(-1, 1); + } + + .uplink-item:first-child::after, .downstream-item:first-child::before { clip-path: polygon(100% 80%, 0 80%, 0 100%, 4% 100%, 4% 83%, 100% 83%); } + .uplink-item:last-child::after, .downstream-item:last-child::before { clip-path: polygon(100% 80%, 4% 80%, 4% 0, 0 0, 0 83%, 100% 83%); } + .uplink-item:only-child::after, .downstream-item:only-child::before { clip-path: polygon(100% 80%, 0 80%, 0 83%, 100% 83%); } @@ -69,9 +80,10 @@ .downstream { display: flex; flex-direction: column; + } - .downstream-item { - height: 2rem; - } + .uplink-item, + .downstream-item { + height: 2rem; } } diff --git a/src/sass/cluster-specific-input.scss b/src/sass/cluster-specific-input.scss new file mode 100644 index 0000000000..1b035a6e25 --- /dev/null +++ b/src/sass/cluster-specific-input.scss @@ -0,0 +1,11 @@ +.cluster-specific-input { + align-items: center; + display: grid; + gap: $sp-medium; + grid-template-columns: fit-content(20%) 1fr; + padding-bottom: $spv--large; + + .cluster-specific-member { + overflow: hidden; + } +} diff --git a/src/sass/styles.scss b/src/sass/styles.scss index 02d1202250..c6ff6e4a3d 100644 --- a/src/sass/styles.scss +++ b/src/sass/styles.scss @@ -70,6 +70,7 @@ $border-thin: 1px solid $color-mid-light !default; @import "certificates"; +@import "cluster-specific-input"; @import "cluster_group_form"; @import "cluster_list"; @import "configuration_table"; diff --git a/src/types/network.d.ts b/src/types/network.d.ts index 91d46b105b..b35f0010fc 100644 --- a/src/types/network.d.ts +++ b/src/types/network.d.ts @@ -72,6 +72,10 @@ export interface LxdNetwork { etag?: string; } +export type LXDNetworkOnClusterMember = LxdNetwork & { + memberName: string; +}; + export interface LxdNetworkStateAddress { family: string; address: string; diff --git a/src/util/intersection.tsx b/src/util/intersection.tsx new file mode 100644 index 0000000000..15b719cda7 --- /dev/null +++ b/src/util/intersection.tsx @@ -0,0 +1,21 @@ +// returns array of items present in all lists +export const intersection = (lists: string[][]): string[] => { + const result = []; + + for (let i = 0; i < lists.length; i++) { + const currentList = lists[i]; + for (let y = 0; y < currentList.length; y++) { + const currentValue = currentList[y]; + if (result.indexOf(currentValue) === -1) { + if ( + lists.filter(function (obj) { + return obj.indexOf(currentValue) == -1; + }).length == 0 + ) { + result.push(currentValue); + } + } + } + } + return result; +}; diff --git a/src/util/networkForm.tsx b/src/util/networkForm.tsx index ab718fdc98..c999cacf17 100644 --- a/src/util/networkForm.tsx +++ b/src/util/networkForm.tsx @@ -1,12 +1,23 @@ -import { +import type { LxdNetwork, LxdNetworkBridgeDriver, LxdNetworkDnsMode, + LXDNetworkOnClusterMember, } from "types/network"; import { NetworkFormValues } from "pages/networks/forms/NetworkForm"; import { getNetworkKey } from "util/networks"; +import { ClusterSpecificValues } from "components/ClusterSpecificSelect"; + +export const toNetworkFormValues = ( + network: LxdNetwork, + networkOnMembers?: LXDNetworkOnClusterMember[], +): NetworkFormValues => { + const parentPerClusterMember: ClusterSpecificValues = {}; + networkOnMembers?.map( + (item) => + (parentPerClusterMember[item.memberName] = item.config.parent ?? ""), + ); -export const toNetworkFormValues = (network: LxdNetwork): NetworkFormValues => { return { readOnly: true, isCreating: false, @@ -48,6 +59,7 @@ export const toNetworkFormValues = (network: LxdNetwork): NetworkFormValues => { ovn_ingress_mode: network.config[getNetworkKey("ovn_ingress_mode")], network: network.config.network, parent: network.config.parent, + parentPerClusterMember, entityType: "network", bareNetwork: network, };