Skip to content

Commit

Permalink
feat: [WD-18264] CMS fields for storage pool source
Browse files Browse the repository at this point in the history
Signed-off-by: Nkeiruka <nkeiruka.whenu@canonical.com>
  • Loading branch information
Kxiru committed Jan 24, 2025
1 parent 8964b14 commit a44c722
Show file tree
Hide file tree
Showing 11 changed files with 324 additions and 47 deletions.
15 changes: 5 additions & 10 deletions src/api/networks.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { handleEtagResponse, handleResponse } from "util/helpers";
import {
constructMemberError,
handleEtagResponse,
handleResponse,
} from "util/helpers";
import type {
LxdNetwork,
LXDNetworkOnClusterMember,
Expand Down Expand Up @@ -29,15 +33,6 @@ export const fetchNetworks = (
});
};

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[],
Expand Down
52 changes: 48 additions & 4 deletions src/api/storage-pools.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import {
constructMemberError,
handleEtagResponse,
handleResponse,
handleSettledResult,
} from "util/helpers";
import {
LxdStoragePool,
LXDStoragePoolOnClusterMember,
LxdStoragePoolResources,
LxdStorageVolume,
LxdStorageVolumeState,
Expand All @@ -14,6 +16,7 @@ import type { LxdApiResponse } from "types/apiResponse";
import type { LxdOperationResponse } from "types/operation";
import axios, { AxiosResponse } from "axios";
import type { LxdClusterMember } from "types/cluster";
import { ClusterSpecificValues } from "components/ClusterSpecificSelect";

export const fetchStoragePool = (
pool: string,
Expand Down Expand Up @@ -66,7 +69,7 @@ export const createPool = (
});
};

const getClusterAndMemberPools = (pool: Partial<LxdStoragePool>) => {
const getClusterAndMemberPoolPayload = (pool: Partial<LxdStoragePool>) => {
const memberSpecificConfigKeys = new Set([
"source",
"size",
Expand Down Expand Up @@ -97,11 +100,21 @@ const getClusterAndMemberPools = (pool: Partial<LxdStoragePool>) => {
export const createClusteredPool = (
pool: LxdStoragePool,
clusterMembers: LxdClusterMember[],
sourcePerClusterMember?: ClusterSpecificValues,
): Promise<void> => {
const { memberPool, clusterPool } = getClusterAndMemberPools(pool);
const { memberPool, clusterPool } = getClusterAndMemberPoolPayload(pool);
return new Promise((resolve, reject) => {
Promise.allSettled(
clusterMembers.map((item) => createPool(memberPool, item.server_name)),
clusterMembers.map((item) => {
const clusteredMemberPool = {
...memberPool,
config: {
...memberPool.config,
source: sourcePerClusterMember?.[item.server_name],
},
};
return createPool(clusteredMemberPool, item.server_name);
}),
)
.then(handleSettledResult)
.then(() => {
Expand Down Expand Up @@ -132,7 +145,7 @@ export const updateClusteredPool = (
pool: Partial<LxdStoragePool>,
clusterMembers: LxdClusterMember[],
): Promise<void> => {
const { memberPool, clusterPool } = getClusterAndMemberPools(pool);
const { memberPool, clusterPool } = getClusterAndMemberPoolPayload(pool);
return new Promise((resolve, reject) => {
Promise.allSettled(
clusterMembers.map(async (item) =>
Expand Down Expand Up @@ -174,6 +187,37 @@ export const deleteStoragePool = (pool: string): Promise<void> => {
});
};

export const fetchPoolFromClusterMembers = (
poolName: string,
clusterMembers: LxdClusterMember[],
): Promise<LXDStoragePoolOnClusterMember[]> => {
return new Promise((resolve, reject) => {
Promise.allSettled(
clusterMembers.map((member) => {
return fetchStoragePool(poolName, member.server_name);
}),
)
.then((results) => {
const poolOnMembers: LXDStoragePoolOnClusterMember[] = [];
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<LxdStoragePool>;
poolOnMembers.push({ ...promise.value, memberName: memberName });
}
}
resolve(poolOnMembers);
})
.catch(reject);
});
};

export const fetchStorageVolumes = (
pool: string,
project: string,
Expand Down
137 changes: 137 additions & 0 deletions src/components/forms/ClusterSpecificInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { FC, Fragment, useEffect, useRef, useState } from "react";
import { CheckboxInput, Input } from "@canonical/react-components";
import ResourceLink from "components/ResourceLink";
import FormEditButton from "components/FormEditButton";
import { ClusterSpecificValues } from "components/ClusterSpecificSelect";

interface Props {
id: string;
isReadOnly: boolean;
onChange: (value: ClusterSpecificValues) => void;
toggleReadOnly: () => void;
memberNames: string[];
values?: ClusterSpecificValues;
canToggleSpecific?: boolean;
isDefaultSpecific?: boolean;
clusterMemberLinkTarget?: (member: string) => string;
disabledGlobalInput?: boolean;
disabledClusteredInput?: boolean;
}

const ClusterSpecificInput: FC<Props> = ({
values,
id,
isReadOnly,
memberNames,
onChange,
toggleReadOnly,
canToggleSpecific = true,
isDefaultSpecific = false,
clusterMemberLinkTarget = () => "/ui/cluster",
disabledGlobalInput = false,
disabledClusteredInput = false,
}) => {
const [isSpecific, setIsSpecific] = useState(isDefaultSpecific);
const firstValue = Object.values(values ?? {})[0];
const isUserActionRef = useRef(false);

useEffect(() => {
if (!isUserActionRef.current && values && Object.keys(values).length > 0) {
const newDefaultSpecific = !Object.values(values).every(
(item) => item === Object.values(values)[0],
);
setIsSpecific(newDefaultSpecific);
}
isUserActionRef.current = false;
}, [values]);

const setValueForAllMembers = (value: string) => {
const update: ClusterSpecificValues = {};
memberNames.forEach((member) => (update[member] = value));
onChange(update);
};

const setValueForMember = (value: string, member: string) => {
const update = {
...values,
[member]: value,
};
onChange(update);
};

return (
<div className="u-sv3">
{canToggleSpecific && !isReadOnly && (
<CheckboxInput
id={`${id}-same-for-all-toggle`}
label="Same for all cluster members"
checked={!isSpecific}
onChange={() => {
isUserActionRef.current = true;
if (isSpecific) {
setValueForAllMembers(values?.[memberNames[0]] ?? "");
}
setIsSpecific((val) => !val);
}}
/>
)}
{isSpecific && (
<div className="cluster-specific-input">
{memberNames.map((item) => {
const activeValue = values?.[item];

return (
<Fragment key={item}>
<div className="cluster-specific-member">
<ResourceLink
type="cluster-member"
value={item}
to={clusterMemberLinkTarget(item)}
/>
</div>
<div className="cluster-specific-value">
{isReadOnly ? (
<>
{activeValue}
<FormEditButton toggleReadOnly={toggleReadOnly} />
</>
) : (
<Input
id={`${id}-${item}`}
type="text"
className="u-no-margin--bottom"
value={activeValue}
onChange={(e) => setValueForMember(e.target.value, item)}
disabled={disabledClusteredInput}
/>
)}
</div>
</Fragment>
);
})}
</div>
)}
{!isSpecific && (
<div>
{isReadOnly ? (
<>
{firstValue}
<FormEditButton toggleReadOnly={toggleReadOnly} />
</>
) : (
<Input
id={id}
type="text"
className="u-no-margin--bottom"
value={firstValue}
onChange={(e) => setValueForAllMembers(e.target.value)}
disabled={disabledGlobalInput}
/>
)}
</div>
)}
</div>
);
};

export default ClusterSpecificInput;
7 changes: 6 additions & 1 deletion src/pages/storage/CreateStoragePool.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,12 @@ const CreateStoragePool: FC = () => {

const mutation =
clusterMembers.length > 0
? () => createClusteredPool(storagePool, clusterMembers)
? () =>
createClusteredPool(
storagePool,
clusterMembers,
values.sourcePerClusterMember,
)
: () => createPool(storagePool);

mutation()
Expand Down
28 changes: 23 additions & 5 deletions src/pages/storage/EditStoragePool.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { FC, useState } from "react";
import { FC, useEffect, useState } from "react";
import { Button, useNotify } from "@canonical/react-components";
import { useQueryClient } from "@tanstack/react-query";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
fetchPoolFromClusterMembers,
fetchStoragePool,
updateClusteredPool,
updatePool,
Expand Down Expand Up @@ -49,11 +50,24 @@ const EditStoragePool: FC<Props> = ({ pool }) => {
const controllerState = useState<AbortController | null>(null);
const { data: clusterMembers = [] } = useClusterMembers();
const [version, setVersion] = useState(0);
const isClustered = clusterMembers.length > 0;

if (!project) {
return <>Missing project</>;
}

const { data: poolOnMembers = [], error } = useQuery({
queryKey: [queryKeys.storage, pool.name, queryKeys.cluster],
queryFn: () => fetchPoolFromClusterMembers(pool.name, clusterMembers),
enabled: isClustered,
});

useEffect(() => {
if (error) {
notify.failure("Loading network from cluster members failed", error);
}
}, [error]);

const StoragePoolSchema = Yup.object().shape({
name: Yup.string()
.test(
Expand All @@ -67,7 +81,7 @@ const EditStoragePool: FC<Props> = ({ pool }) => {
});

const formik = useFormik<StoragePoolFormValues>({
initialValues: toStoragePoolFormValues(pool),
initialValues: toStoragePoolFormValues(pool, poolOnMembers),
validationSchema: StoragePoolSchema,
enableReinitialize: true,
onSubmit: (values) => {
Expand Down Expand Up @@ -95,7 +109,9 @@ const EditStoragePool: FC<Props> = ({ pool }) => {
);
const member = clusterMembers[0]?.server_name ?? undefined;
const updatedPool = await fetchStoragePool(values.name, member);
void formik.setValues(toStoragePoolFormValues(updatedPool));
void formik.setValues(
toStoragePoolFormValues(updatedPool, poolOnMembers),
);
})
.catch((e) => {
notify.failure("Storage pool update failed", e);
Expand Down Expand Up @@ -143,7 +159,9 @@ const EditStoragePool: FC<Props> = ({ pool }) => {
appearance="base"
onClick={() => {
setVersion((old) => old + 1);
void formik.setValues(toStoragePoolFormValues(pool));
void formik.setValues(
toStoragePoolFormValues(pool, poolOnMembers),
);
}}
>
Cancel
Expand Down
41 changes: 41 additions & 0 deletions src/pages/storage/forms/StoragePoolClusteredSourceSelector.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { Label } from "@canonical/react-components";
import ClusterSpecificInput from "components/forms/ClusterSpecificInput";
import { FormikProps } from "formik";
import { FC } from "react";
import { ensureEditMode } from "util/instanceEdit";
import { StoragePoolFormValues } from "./StoragePoolForm";
import { useClusterMembers } from "context/useClusterMembers";
import { focusField } from "util/formFields";

interface Props {
formik: FormikProps<StoragePoolFormValues>;
}

const StoragePoolClusteredSourceSelector: FC<Props> = ({ formik }) => {
const { data: clusterMembers = [] } = useClusterMembers();
const memberNames: string[] = [];
clusterMembers.forEach((member) => memberNames.push(member.server_name));

return (
<>
<Label>Source</Label>
<ClusterSpecificInput
values={formik.values.sourcePerClusterMember}
id="sourcePerClusterMember"
isReadOnly={false}
onChange={(value) => {
void formik.setFieldValue("sourcePerClusterMember", value);
}}
toggleReadOnly={() => {
ensureEditMode(formik);
focusField("sourcePerClusterMember");
}}
memberNames={memberNames}
disabledGlobalInput={!formik.values.isCreating}
disabledClusteredInput={!formik.values.isCreating}
/>
</>
);
};

export default StoragePoolClusteredSourceSelector;
Loading

0 comments on commit a44c722

Please sign in to comment.