Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: [WD-18264] CMS fields for storage pool source #1070

Merged
merged 1 commit into from
Jan 28, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
68 changes: 57 additions & 11 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 All @@ -85,27 +88,38 @@ const getClusterAndMemberPools = (pool: Partial<LxdStoragePool>) => {
}
}

const clusterPool = { ...pool, config: clusterConfig };
const memberPool = { ...pool, config: memberConfig };
const clusterPoolPayload = { ...pool, config: clusterConfig };
const memberPoolPayload = { ...pool, config: memberConfig };

return {
clusterPool,
memberPool,
clusterPoolPayload,
memberPoolPayload,
};
};

export const createClusteredPool = (
pool: LxdStoragePool,
clusterMembers: LxdClusterMember[],
sourcePerClusterMember?: ClusterSpecificValues,
): Promise<void> => {
const { memberPool, clusterPool } = getClusterAndMemberPools(pool);
const { memberPoolPayload, clusterPoolPayload } =
getClusterAndMemberPoolPayload(pool);
return new Promise((resolve, reject) => {
Promise.allSettled(
clusterMembers.map((item) => createPool(memberPool, item.server_name)),
clusterMembers.map((item) => {
const clusteredMemberPool = {
...memberPoolPayload,
config: {
...memberPoolPayload.config,
source: sourcePerClusterMember?.[item.server_name],
},
};
return createPool(clusteredMemberPool, item.server_name);
}),
)
.then(handleSettledResult)
.then(() => {
return createPool(clusterPool);
return createPool(clusterPoolPayload);
})
.then(resolve)
.catch(reject);
Expand All @@ -132,15 +146,16 @@ export const updateClusteredPool = (
pool: Partial<LxdStoragePool>,
clusterMembers: LxdClusterMember[],
): Promise<void> => {
const { memberPool, clusterPool } = getClusterAndMemberPools(pool);
const { memberPoolPayload, clusterPoolPayload } =
getClusterAndMemberPoolPayload(pool);
return new Promise((resolve, reject) => {
Promise.allSettled(
clusterMembers.map(async (item) =>
updatePool(memberPool, item.server_name),
updatePool(memberPoolPayload, item.server_name),
),
)
.then(handleSettledResult)
.then(() => updatePool(clusterPool))
.then(() => updatePool(clusterPoolPayload))
.then(resolve)
.catch(reject);
});
Expand Down Expand Up @@ -174,6 +189,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
141 changes: 141 additions & 0 deletions src/components/forms/ClusterSpecificInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import { FC, Fragment, useEffect, 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;
disabled?: boolean;
helpText?: string;
}

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

useEffect(() => {
const rawValues = Object.values(values ?? {});
if (isSpecific === null && rawValues.length > 0) {
const newDefaultSpecific = rawValues.some(
(item) => item !== rawValues[0],
);
setIsSpecific(newDefaultSpecific);
}
}, [isSpecific, 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={() => {
setIsSpecific((val) => !val);
}}
/>
)}
{isSpecific && (
<div className="cluster-specific-input">
mas-who marked this conversation as resolved.
Show resolved Hide resolved
{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={
memberNames.indexOf(item) === 0 ? id : `${id}-${item}`
}
type="text"
className="u-no-margin--bottom"
value={activeValue}
onChange={(e) => setValueForMember(e.target.value, item)}
disabled={disabled}
edlerd marked this conversation as resolved.
Show resolved Hide resolved
/>
)}
</div>
</Fragment>
);
})}
{helpText && (
<div className="p-form-help-text cluster-specific-helptext">
{helpText}
</div>
)}
</div>
)}
{!isSpecific && (
<div>
{isReadOnly ? (
<>
{firstValue}
<FormEditButton toggleReadOnly={toggleReadOnly} />
</>
) : (
<Input
id={id}
type="text"
value={firstValue}
onChange={(e) => setValueForAllMembers(e.target.value)}
disabled={disabled}
help={helpText}
/>
)}
</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 storage pool 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
Loading