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 +25,105 @@ export const fetchNetworks = (project: string): Promise => { }); }; +export type LXDClusterMemberNetworks = { + memberName: string; + memberNetworks: LxdNetwork[]; +}[]; + +export const fetchClusterMemberNetworks = async ( + project: string, + clusterMembers: LxdClusterMember[], +): Promise => { + return new Promise((resolve, reject) => { + void Promise.allSettled( + clusterMembers.map(async (member) => { + return await fetchNetworks(project, member.server_name); + }), + ) + .then((results) => { + const error = results.find((res) => res.status === "rejected") + ?.reason as Error | undefined; + + if (error) { + reject(error); + return; + } + + const result: LXDClusterMemberNetworks = []; + + for (let i = 0; i < clusterMembers.length; i++) { + const memberName = clusterMembers[i].server_name; + const networks = results[i] as PromiseFulfilledResult; + result.push({ memberName, memberNetworks: networks.value }); + } + + resolve(result); + }) + .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 type LXDClusterMemberNetwork = { + memberName: string; + network: LxdNetwork; +}[]; + +export const fetchClusterMemberNetwork = async ( + name: string, + project: string, + clusterMembers: LxdClusterMember[], +): Promise => { + return new Promise((resolve, reject) => { + void Promise.allSettled( + clusterMembers.map(async (member) => { + return await fetchNetwork(name, project, member.server_name); + }), + ) + .then((results) => { + const error = results.find((res) => res.status === "rejected") + ?.reason as Error | undefined; + + if (error) { + reject(error); + return; + } + + const result: LXDClusterMemberNetwork = []; + + for (let i = 0; i < clusterMembers.length; i++) { + const name = clusterMembers[i].server_name; + const promise = results[i] as PromiseFulfilledResult; + result.push({ memberName: name, network: promise.value }); + } + + resolve(result); + }) + .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); @@ -47,21 +133,23 @@ export const fetchNetworkState = ( export const createClusterNetwork = ( network: Partial, project: string, - clusterMembers: LxdClusterMember[], + parentsPerClusterMember?: ClusterSpecificSelectField, ): Promise => { - return new Promise((resolve, reject) => { - const memberNetwork = { - name: network.name, - description: network.description, - type: network.type, - config: { - parent: network.config?.parent, - }, - }; + if (!parentsPerClusterMember) { + return createNetwork(network, project); + } + return new Promise((resolve, reject) => { void Promise.allSettled( - clusterMembers.map(async (member) => { - await createNetwork(memberNetwork, project, member.server_name); + Object.keys(parentsPerClusterMember).map(async (memberName) => { + const memberNetwork = { + name: network.name, + type: network.type, + config: { + parent: parentsPerClusterMember[memberName], + }, + }; + await createNetwork(memberNetwork, project, memberName); }), ) .then((results) => { @@ -110,15 +198,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 ?? "invalid-etag", + }, }, - }) + ) .then(handleResponse) .then(resolve) .catch(async (e: Error) => { @@ -135,6 +228,45 @@ export const updateNetwork = ( }); }; +export const updateClusterNetwork = ( + network: Partial & Required>, + project: string, + parentsPerClusterMember?: ClusterSpecificSelectField, + networkByMembers?: LXDClusterMemberNetwork, +): Promise => { + if (!parentsPerClusterMember) { + return updateNetwork(network, project); + } + + return new Promise((resolve, reject) => { + void Promise.allSettled( + Object.keys(parentsPerClusterMember).map(async (memberName) => { + const memberNetwork = { + name: network.name, + type: network.type, + config: { + parent: parentsPerClusterMember[memberName], + }, + etag: networkByMembers?.find((item) => item.memberName === memberName) + ?.network.etag, + }; + await 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, 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..0c475e034c --- /dev/null +++ b/src/components/ClusterSpecificSelect.tsx @@ -0,0 +1,97 @@ +import { FC, ReactNode, useState } from "react"; +import { Button, Icon, MainTable, Select } from "@canonical/react-components"; +import ResourceLabel from "components/ResourceLabel"; + +export type ClusterSpecificSelectField = Record; + +interface Props { + readOnly: boolean; + options: { + memberName: string; + values: string[]; + }[]; + valueSuffix?: ReactNode; + values?: ClusterSpecificSelectField; + onChange: (value: ClusterSpecificSelectField) => void; +} + +const ClusterSpecificSelect: FC = ({ + readOnly, + options, + valueSuffix, + values, + onChange, +}) => { + const [isExpanded, setExpanded] = useState(false); + + return isExpanded ? ( + <> + {" "} + { + const activeValue = values?.[item.memberName]; + const options = item.values.map((value) => { + return { + label: value, + value: value, + }; + }); + options.unshift({ + label: options.length === 0 ? "None available" : "Select option", + value: "", + }); + + return { + columns: [ + { + content: ( + + ), + role: "cell", + "aria-label": "Cluster member", + }, + { + content: readOnly ? ( + <> + {activeValue} + {valueSuffix} + + ) : ( +