mirror of
https://github.com/portainer/portainer.git
synced 2025-08-02 20:35:25 +02:00
feat(waiting-room): choose relations when associated endpoint [EE-5187] (#8720)
This commit is contained in:
parent
511adabce2
commit
365316971b
53 changed files with 1712 additions and 303 deletions
|
@ -0,0 +1,166 @@
|
|||
import { Form, Formik } from 'formik';
|
||||
|
||||
import { addPlural } from '@/portainer/helpers/strings';
|
||||
import { useUpdateEnvironmentsRelationsMutation } from '@/react/portainer/environments/queries/useUpdateEnvironmentsRelationsMutation';
|
||||
import { notifySuccess } from '@/portainer/services/notifications';
|
||||
|
||||
import { Checkbox } from '@@/form-components/Checkbox';
|
||||
import { FormControl } from '@@/form-components/FormControl';
|
||||
import { OnSubmit, Modal } from '@@/modals';
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
import { Button, LoadingButton } from '@@/buttons';
|
||||
|
||||
import { WaitingRoomEnvironment } from '../../types';
|
||||
|
||||
import { GroupSelector, EdgeGroupsSelector, TagSelector } from './Selectors';
|
||||
import { FormValues } from './types';
|
||||
import { isAssignedToGroup } from './utils';
|
||||
import { createPayload } from './createPayload';
|
||||
|
||||
export function AssignmentDialog({
|
||||
onSubmit,
|
||||
environments,
|
||||
}: {
|
||||
onSubmit: OnSubmit<boolean>;
|
||||
environments: Array<WaitingRoomEnvironment>;
|
||||
}) {
|
||||
const assignRelationsMutation = useUpdateEnvironmentsRelationsMutation();
|
||||
|
||||
const initialValues: FormValues = {
|
||||
group: 1,
|
||||
overrideGroup: false,
|
||||
edgeGroups: [],
|
||||
overrideEdgeGroups: false,
|
||||
tags: [],
|
||||
overrideTags: false,
|
||||
};
|
||||
|
||||
const hasPreAssignedEdgeGroups = environments.some(
|
||||
(e) => e.EdgeGroups?.length > 0
|
||||
);
|
||||
const hasPreAssignedTags = environments.some((e) => e.TagIds.length > 0);
|
||||
const hasPreAssignedGroup = environments.some((e) => isAssignedToGroup(e));
|
||||
|
||||
return (
|
||||
<Modal
|
||||
aria-label="Associate and assignment"
|
||||
onDismiss={() => onSubmit()}
|
||||
size="lg"
|
||||
>
|
||||
<Modal.Header
|
||||
title={`Associate with assignment (${addPlural(
|
||||
environments.length,
|
||||
'selected edge environment'
|
||||
)})`}
|
||||
/>
|
||||
<Formik onSubmit={handleSubmit} initialValues={initialValues}>
|
||||
{({ values, setFieldValue, errors }) => (
|
||||
<Form noValidate>
|
||||
<Modal.Body>
|
||||
<div>
|
||||
<FormControl
|
||||
size="vertical"
|
||||
label="Group"
|
||||
tooltip="For managing RBAC with user access"
|
||||
errors={errors.group}
|
||||
>
|
||||
<GroupSelector />
|
||||
|
||||
{hasPreAssignedGroup && (
|
||||
<div className="mt-2">
|
||||
<Checkbox
|
||||
label="Override pre-assigned group"
|
||||
id="overrideGroup"
|
||||
bold={false}
|
||||
checked={values.overrideGroup}
|
||||
onChange={(e) =>
|
||||
setFieldValue('overrideGroup', e.target.checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</FormControl>
|
||||
|
||||
<FormControl
|
||||
size="vertical"
|
||||
label="Edge Groups"
|
||||
tooltip="Required to manage edge job and edge stack deployments"
|
||||
errors={errors.edgeGroups}
|
||||
>
|
||||
<EdgeGroupsSelector />
|
||||
|
||||
{hasPreAssignedEdgeGroups && (
|
||||
<div className="mt-2">
|
||||
<Checkbox
|
||||
label="Override pre-assigned edge groups"
|
||||
bold={false}
|
||||
id="overrideEdgeGroups"
|
||||
checked={values.overrideEdgeGroups}
|
||||
onChange={(e) =>
|
||||
setFieldValue('overrideEdgeGroups', e.target.checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</FormControl>
|
||||
|
||||
<div className="mb-3">
|
||||
<TextTip color="blue">
|
||||
Edge group(s) created here are static only, use tags to
|
||||
assign to dynamic edge groups
|
||||
</TextTip>
|
||||
</div>
|
||||
|
||||
<FormControl
|
||||
size="vertical"
|
||||
label="Tags"
|
||||
tooltip="Assigning tags will auto populate environments to dynamic edge groups that these tags are assigned to and any ege jobs or stacks that are deployed to that edge group"
|
||||
errors={errors.tags}
|
||||
>
|
||||
<TagSelector />
|
||||
|
||||
{hasPreAssignedTags && (
|
||||
<div className="mt-2">
|
||||
<Checkbox
|
||||
label="Override pre-assigned tags"
|
||||
bold={false}
|
||||
id="overrideTags"
|
||||
checked={values.overrideTags}
|
||||
onChange={(e) =>
|
||||
setFieldValue('overrideTags', e.target.checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</FormControl>
|
||||
</div>
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button onClick={() => onSubmit()} color="default">
|
||||
Cancel
|
||||
</Button>
|
||||
<LoadingButton
|
||||
isLoading={assignRelationsMutation.isLoading}
|
||||
loadingText="Associating..."
|
||||
>
|
||||
Associate
|
||||
</LoadingButton>
|
||||
</Modal.Footer>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
function handleSubmit(values: FormValues) {
|
||||
assignRelationsMutation.mutate(
|
||||
Object.fromEntries(environments.map((e) => createPayload(e, values))),
|
||||
{
|
||||
onSuccess: () => {
|
||||
notifySuccess('Success', 'Edge environments assigned successfully');
|
||||
onSubmit(true);
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,60 @@
|
|||
import { useField } from 'formik';
|
||||
import _ from 'lodash';
|
||||
|
||||
import { Select } from '@@/form-components/ReactSelect';
|
||||
import {
|
||||
Option,
|
||||
Option as OptionType,
|
||||
} from '@@/form-components/PortainerSelect';
|
||||
|
||||
export function CreatableSelector({
|
||||
name,
|
||||
options,
|
||||
onCreate,
|
||||
isLoading,
|
||||
}: {
|
||||
name: string;
|
||||
options: Array<OptionType<number>>;
|
||||
onCreate: (label: string) => Promise<number>;
|
||||
isLoading: boolean;
|
||||
}) {
|
||||
const [{ onBlur, value }, , { setValue }] = useField<Array<number>>(name);
|
||||
|
||||
const selectedValues = value.reduce(
|
||||
(acc: Array<OptionType<number>>, cur) =>
|
||||
_.compact([...acc, findOption(cur, options)]),
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<Select
|
||||
isCreatable
|
||||
options={options}
|
||||
value={
|
||||
isLoading
|
||||
? [...selectedValues, { label: 'Creating...', value: 0 }]
|
||||
: selectedValues
|
||||
}
|
||||
isMulti
|
||||
onCreateOption={handleCreate}
|
||||
onChange={handleChange}
|
||||
onBlur={onBlur}
|
||||
isLoading={isLoading}
|
||||
isDisabled={isLoading}
|
||||
closeMenuOnSelect={false}
|
||||
/>
|
||||
);
|
||||
|
||||
async function handleCreate(label: string) {
|
||||
const id = await onCreate(label);
|
||||
setValue([...value, id]);
|
||||
}
|
||||
|
||||
function handleChange(value: ReadonlyArray<{ value: number }>) {
|
||||
setValue(value.map((v) => v.value));
|
||||
}
|
||||
}
|
||||
|
||||
function findOption<T>(option: T, options: Array<Option<T>>) {
|
||||
return options.find((t) => t.value === option);
|
||||
}
|
|
@ -0,0 +1,41 @@
|
|||
import { notifySuccess } from '@/portainer/services/notifications';
|
||||
import { useCreateGroupMutation } from '@/react/edge/edge-groups/queries/useCreateEdgeGroupMutation';
|
||||
import { useEdgeGroups } from '@/react/edge/edge-groups/queries/useEdgeGroups';
|
||||
|
||||
import { CreatableSelector } from './CreatableSelector';
|
||||
|
||||
export function EdgeGroupsSelector() {
|
||||
const createMutation = useCreateGroupMutation();
|
||||
|
||||
const edgeGroupsQuery = useEdgeGroups({
|
||||
select: (edgeGroups) =>
|
||||
edgeGroups
|
||||
.filter((g) => !g.Dynamic)
|
||||
.map((opt) => ({ label: opt.Name, value: opt.Id })),
|
||||
});
|
||||
|
||||
if (!edgeGroupsQuery.data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const edgeGroups = edgeGroupsQuery.data;
|
||||
|
||||
return (
|
||||
<CreatableSelector
|
||||
name="edgeGroups"
|
||||
options={edgeGroups}
|
||||
onCreate={handleCreate}
|
||||
isLoading={createMutation.isLoading}
|
||||
/>
|
||||
);
|
||||
|
||||
async function handleCreate(newGroup: string) {
|
||||
const group = await createMutation.mutateAsync({
|
||||
name: newGroup,
|
||||
dynamic: false,
|
||||
});
|
||||
|
||||
notifySuccess('Edge group created', `Group ${group.Name} created`);
|
||||
return group.Id;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,123 @@
|
|||
import { useCallback, useState } from 'react';
|
||||
import { useField } from 'formik';
|
||||
|
||||
import { useGroups } from '@/react/portainer/environments/environment-groups/queries';
|
||||
import { EnvironmentGroupId } from '@/react/portainer/environments/environment-groups/types';
|
||||
import { useCreateGroupMutation } from '@/react/portainer/environments/environment-groups/queries/useCreateGroupMutation';
|
||||
import { notifySuccess } from '@/portainer/services/notifications';
|
||||
|
||||
import { Select } from '@@/form-components/ReactSelect';
|
||||
import { Option } from '@@/form-components/PortainerSelect';
|
||||
|
||||
import { FormValues } from '../types';
|
||||
|
||||
export function GroupSelector() {
|
||||
const [{ value, onBlur }, , { setValue }] =
|
||||
useField<FormValues['group']>('group');
|
||||
const createMutation = useCreateGroupMutation();
|
||||
|
||||
const groupsQuery = useGroups({
|
||||
select: (groups) =>
|
||||
groups
|
||||
.filter((g) => g.Id !== 1)
|
||||
.map((opt) => ({ label: opt.Name, value: opt.Id })),
|
||||
});
|
||||
|
||||
const { onInputChange, clearInputValue } = useCreateOnBlur({
|
||||
options: groupsQuery.data || [],
|
||||
setValue,
|
||||
createValue: handleCreate,
|
||||
});
|
||||
|
||||
if (!groupsQuery.data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const options = groupsQuery.data;
|
||||
const selectedValue = value ? options.find((g) => g.value === value) : null;
|
||||
|
||||
return (
|
||||
<Select
|
||||
isCreatable
|
||||
options={options}
|
||||
value={
|
||||
createMutation.isLoading
|
||||
? { label: 'Creating...', value: 0 }
|
||||
: selectedValue
|
||||
}
|
||||
onCreateOption={handleCreate}
|
||||
onChange={handleChange}
|
||||
onInputChange={onInputChange}
|
||||
onBlur={onBlur}
|
||||
isLoading={createMutation.isLoading}
|
||||
isDisabled={createMutation.isLoading}
|
||||
placeholder="Select a group"
|
||||
isClearable
|
||||
/>
|
||||
);
|
||||
|
||||
function handleCreate(newGroup: string) {
|
||||
createMutation.mutate(
|
||||
{ name: newGroup },
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
setValue(data.Id);
|
||||
notifySuccess('Group created', `Group ${data.Name} created`);
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function handleChange(value: { value: EnvironmentGroupId } | null) {
|
||||
setValue(value ? value.value : 1);
|
||||
clearInputValue();
|
||||
}
|
||||
}
|
||||
|
||||
function useCreateOnBlur({
|
||||
options,
|
||||
setValue,
|
||||
createValue,
|
||||
}: {
|
||||
options: Option<number>[];
|
||||
setValue: (value: number) => void;
|
||||
createValue: (value: string) => void;
|
||||
}) {
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
|
||||
const handleBlur = useCallback(() => {
|
||||
const label = inputValue?.trim() || '';
|
||||
if (!label) {
|
||||
return;
|
||||
}
|
||||
|
||||
const option = options.find((opt) => opt.label === label);
|
||||
if (option) {
|
||||
setValue(option.value);
|
||||
} else {
|
||||
createValue(label);
|
||||
}
|
||||
setInputValue('');
|
||||
}, [createValue, inputValue, options, setValue]);
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
(inputValue, { action }) => {
|
||||
if (action === 'input-change') {
|
||||
setInputValue(inputValue);
|
||||
}
|
||||
if (action === 'input-blur') {
|
||||
handleBlur();
|
||||
}
|
||||
},
|
||||
[handleBlur]
|
||||
);
|
||||
|
||||
const clearInputValue = useCallback(() => {
|
||||
setInputValue('');
|
||||
}, []);
|
||||
|
||||
return {
|
||||
onInputChange: handleInputChange,
|
||||
clearInputValue,
|
||||
};
|
||||
}
|
|
@ -0,0 +1,35 @@
|
|||
import { notifySuccess } from '@/portainer/services/notifications';
|
||||
import { useCreateTagMutation, useTags } from '@/portainer/tags/queries';
|
||||
|
||||
import { CreatableSelector } from './CreatableSelector';
|
||||
|
||||
export function TagSelector() {
|
||||
const createMutation = useCreateTagMutation();
|
||||
|
||||
const tagsQuery = useTags({
|
||||
select: (tags) => tags.map((opt) => ({ label: opt.Name, value: opt.ID })),
|
||||
});
|
||||
|
||||
if (!tagsQuery.data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tags = tagsQuery.data;
|
||||
|
||||
return (
|
||||
<CreatableSelector
|
||||
name="tags"
|
||||
options={tags}
|
||||
onCreate={handleCreate}
|
||||
isLoading={createMutation.isLoading}
|
||||
/>
|
||||
);
|
||||
|
||||
async function handleCreate(newTag: string) {
|
||||
const tag = await createMutation.mutateAsync(newTag);
|
||||
|
||||
notifySuccess('Tag created', `Tag ${tag.Name} created`);
|
||||
|
||||
return tag.ID;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
export { EdgeGroupsSelector } from './EdgeGroupSelector';
|
||||
export { GroupSelector } from './GroupSelector';
|
||||
export { TagSelector } from './TagSelector';
|
|
@ -0,0 +1,30 @@
|
|||
import { EnvironmentRelationsPayload } from '@/react/portainer/environments/queries/useUpdateEnvironmentsRelationsMutation';
|
||||
|
||||
import { WaitingRoomEnvironment } from '../../types';
|
||||
|
||||
import { FormValues } from './types';
|
||||
import { isAssignedToGroup } from './utils';
|
||||
|
||||
export function createPayload(
|
||||
environment: WaitingRoomEnvironment,
|
||||
values: FormValues
|
||||
) {
|
||||
const relations: Partial<EnvironmentRelationsPayload> = {};
|
||||
|
||||
if (environment.TagIds.length === 0 || values.overrideTags) {
|
||||
relations.tags = values.tags;
|
||||
}
|
||||
|
||||
if (environment.EdgeGroups.length === 0 || values.overrideEdgeGroups) {
|
||||
relations.edgeGroups = values.edgeGroups;
|
||||
}
|
||||
|
||||
if (
|
||||
(!isAssignedToGroup(environment) || values.overrideGroup) &&
|
||||
values.group
|
||||
) {
|
||||
relations.group = values.group;
|
||||
}
|
||||
|
||||
return [environment.Id, relations];
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
import { TagId } from '@/portainer/tags/types';
|
||||
import { EdgeGroup } from '@/react/edge/edge-groups/types';
|
||||
import { EnvironmentGroupId } from '@/react/portainer/environments/environment-groups/types';
|
||||
|
||||
export interface FormValues {
|
||||
group: EnvironmentGroupId | null;
|
||||
overrideGroup: boolean;
|
||||
edgeGroups: Array<EdgeGroup['Id']>;
|
||||
overrideEdgeGroups: boolean;
|
||||
tags: Array<TagId>;
|
||||
overrideTags: boolean;
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
import { Environment } from '@/react/portainer/environments/types';
|
||||
|
||||
export function isAssignedToGroup(environment: Environment) {
|
||||
return ![0, 1].includes(environment.GroupId);
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue