1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-08-05 13:55:21 +02:00

feat(waiting-room): choose relations when associated endpoint [EE-5187] (#8720)

This commit is contained in:
Chaim Lev-Ari 2023-05-14 09:26:11 +07:00 committed by GitHub
parent 511adabce2
commit 365316971b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
53 changed files with 1712 additions and 303 deletions

View file

@ -9,3 +9,15 @@ export function promiseSequence<T>(promises: (() => Promise<T>)[]) {
Promise.resolve<T>(undefined as unknown as T)
);
}
export function isFulfilled<T>(
result: PromiseSettledResult<T>
): result is PromiseFulfilledResult<T> {
return result.status === 'fulfilled';
}
export function getFulfilledResults<T>(
results: Array<PromiseSettledResult<T>>
) {
return results.filter(isFulfilled).map((result) => result.value);
}

View file

@ -10,7 +10,7 @@ import { EnvironmentId } from '@/react/portainer/environments/types';
import { createTag, getTags } from './tags.service';
import { Tag, TagId } from './types';
const tagKeys = {
export const tagKeys = {
all: ['tags'] as const,
tag: (id: TagId) => [...tagKeys.all, id] as const,
};

View file

@ -19,8 +19,9 @@ export function TextTip({
children,
}: PropsWithChildren<Props>) {
return (
<div className={clsx('small inline-flex items-center gap-1', className)}>
<Icon icon={icon} mode={getMode(color)} className="shrink-0" />
<div className={clsx('small inline-flex gap-1', className)}>
<Icon icon={icon} mode={getMode(color)} className="!mt-[2px]" />
<span className="text-muted">{children}</span>
</div>
);

View file

@ -1,3 +1,4 @@
import clsx from 'clsx';
import {
forwardRef,
useRef,
@ -16,11 +17,21 @@ interface Props extends HTMLProps<HTMLInputElement> {
className?: string;
role?: string;
onChange?: ChangeEventHandler<HTMLInputElement>;
bold?: boolean;
}
export const Checkbox = forwardRef<HTMLInputElement, Props>(
(
{ indeterminate, title, label, id, checked, onChange, ...props }: Props,
{
indeterminate,
title,
label,
id,
checked,
onChange,
bold = true,
...props
}: Props,
ref
) => {
const defaultRef = useRef<HTMLInputElement>(null);
@ -50,7 +61,9 @@ export const Checkbox = forwardRef<HTMLInputElement, Props>(
// eslint-disable-next-line react/jsx-props-no-spreading
{...props}
/>
<label htmlFor={id}>{label}</label>
<label htmlFor={id} className={clsx({ '!font-normal': !bold })}>
{label}
</label>
</div>
);
}

View file

@ -1,4 +1,4 @@
import { PropsWithChildren, useState } from 'react';
import { PropsWithChildren, ReactNode, useState } from 'react';
import { ChevronDown, ChevronRight } from 'lucide-react';
import { Icon } from '@@/Icon';
@ -6,7 +6,7 @@ import { Icon } from '@@/Icon';
import { FormSectionTitle } from '../FormSectionTitle';
interface Props {
title: string;
title: ReactNode;
isFoldable?: boolean;
}

View file

@ -6,7 +6,9 @@ import { OnSubmit } from './Modal/types';
let counter = 0;
export async function openModal<TProps, TResult>(
Modal: ComponentType<{ onSubmit: OnSubmit<TResult> } & TProps>,
Modal: ComponentType<
{ onSubmit: OnSubmit<TResult> } & Omit<TProps, 'onSubmit'>
>,
props: TProps = {} as TProps
) {
const modal = document.createElement('div');

View file

@ -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);
},
}
);
}
}

View file

@ -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);
}

View file

@ -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;
}
}

View file

@ -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,
};
}

View file

@ -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;
}
}

View file

@ -0,0 +1,3 @@
export { EdgeGroupsSelector } from './EdgeGroupSelector';
export { GroupSelector } from './GroupSelector';
export { TagSelector } from './TagSelector';

View file

@ -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];
}

View file

@ -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;
}

View file

@ -0,0 +1,5 @@
import { Environment } from '@/react/portainer/environments/types';
export function isAssignedToGroup(environment: Environment) {
return ![0, 1].includes(environment.GroupId);
}

View file

@ -1,22 +1,10 @@
import { Trash2 } from 'lucide-react';
import { Environment } from '@/react/portainer/environments/types';
import { notifySuccess } from '@/portainer/services/notifications';
import { useDeleteEnvironmentsMutation } from '@/react/portainer/environments/queries/useDeleteEnvironmentsMutation';
import { Datatable as GenericDatatable } from '@@/datatables';
import { Button } from '@@/buttons';
import { createPersistedStore } from '@@/datatables/types';
import { useTableState } from '@@/datatables/useTableState';
import { confirm } from '@@/modals/confirm';
import { buildConfirmButton } from '@@/modals/utils';
import { ModalType } from '@@/modals';
import { TooltipWithChildren } from '@@/Tip/TooltipWithChildren';
import { useAssociateDeviceMutation, useLicenseOverused } from '../queries';
import { columns } from './columns';
import { Filter } from './Filter';
import { TableActions } from './TableActions';
import { useEnvironments } from './useEnvironments';
const storageKey = 'edge-devices-waiting-room';
@ -24,9 +12,6 @@ const storageKey = 'edge-devices-waiting-room';
const settingsStore = createPersistedStore(storageKey, 'Name');
export function Datatable() {
const associateMutation = useAssociateDeviceMutation();
const removeMutation = useDeleteEnvironmentsMutation();
const { willExceed } = useLicenseOverused();
const tableState = useTableState(settingsStore, storageKey);
const { data: environments, totalCount, isLoading } = useEnvironments();
@ -38,76 +23,11 @@ export function Datatable() {
title="Edge Devices Waiting Room"
emptyContentLabel="No Edge Devices found"
renderTableActions={(selectedRows) => (
<>
<Button
onClick={() => handleRemoveDevice(selectedRows)}
disabled={selectedRows.length === 0}
color="dangerlight"
icon={Trash2}
>
Remove Device
</Button>
<TooltipWithChildren
message={
willExceed(selectedRows.length) && (
<>
Associating devices is disabled as your node count exceeds
your license limit
</>
)
}
>
<span>
<Button
onClick={() => handleAssociateDevice(selectedRows)}
disabled={
selectedRows.length === 0 || willExceed(selectedRows.length)
}
>
Associate Device
</Button>
</span>
</TooltipWithChildren>
</>
<TableActions selectedRows={selectedRows} />
)}
isLoading={isLoading}
totalCount={totalCount}
description={<Filter />}
/>
);
function handleAssociateDevice(devices: Environment[]) {
associateMutation.mutate(
devices.map((d) => d.Id),
{
onSuccess() {
notifySuccess('Success', 'Edge devices associated successfully');
},
}
);
}
async function handleRemoveDevice(devices: Environment[]) {
const confirmed = await confirm({
title: 'Are you sure?',
message:
"You're about to remove edge device(s) from waiting room, which will not be shown until next agent startup.",
confirmButton: buildConfirmButton('Remove', 'danger'),
modalType: ModalType.Destructive,
});
if (!confirmed) {
return;
}
removeMutation.mutate(
devices.map((d) => d.Id),
{
onSuccess() {
notifySuccess('Success', 'Edge devices were hidden successfully');
},
}
);
}
}

View file

@ -0,0 +1,142 @@
import { Check, CheckCircle, Trash2 } from 'lucide-react';
import { notifySuccess } from '@/portainer/services/notifications';
import { useDeleteEnvironmentsMutation } from '@/react/portainer/environments/queries/useDeleteEnvironmentsMutation';
import { Environment } from '@/react/portainer/environments/types';
import { withReactQuery } from '@/react-tools/withReactQuery';
import { Button } from '@@/buttons';
import { ModalType, openModal } from '@@/modals';
import { confirm } from '@@/modals/confirm';
import { buildConfirmButton } from '@@/modals/utils';
import { TooltipWithChildren } from '@@/Tip/TooltipWithChildren';
import { useAssociateDeviceMutation, useLicenseOverused } from '../queries';
import { WaitingRoomEnvironment } from '../types';
import { AssignmentDialog } from './AssignmentDialog/AssignmentDialog';
const overusedTooltip = (
<>
Associating devices is disabled as your node count exceeds your license
limit
</>
);
export function TableActions({
selectedRows,
}: {
selectedRows: WaitingRoomEnvironment[];
}) {
const associateMutation = useAssociateDeviceMutation();
const removeMutation = useDeleteEnvironmentsMutation();
const licenseOverused = useLicenseOverused(selectedRows.length);
return (
<>
<Button
onClick={() => handleRemoveDevice(selectedRows)}
disabled={selectedRows.length === 0}
color="dangerlight"
icon={Trash2}
>
Remove Device
</Button>
<TooltipWithChildren
message={
licenseOverused ? (
overusedTooltip
) : (
<>
Associate device(s) and assigning edge groups, group and tags with
overriding options
</>
)
}
>
<span>
<Button
onClick={() => handleAssociateAndAssign(selectedRows)}
disabled={selectedRows.length === 0 || licenseOverused}
color="secondary"
icon={CheckCircle}
>
Associate and assignment
</Button>
</span>
</TooltipWithChildren>
<TooltipWithChildren
message={
licenseOverused ? (
overusedTooltip
) : (
<>
Associate device(s) based on their pre-assigned edge groups, group
and tags
</>
)
}
>
<span>
<Button
onClick={() => handleAssociateDevice(selectedRows)}
disabled={selectedRows.length === 0 || licenseOverused}
icon={Check}
>
Associate Device
</Button>
</span>
</TooltipWithChildren>
</>
);
async function handleAssociateAndAssign(
environments: WaitingRoomEnvironment[]
) {
const assigned = await openModal(withReactQuery(AssignmentDialog), {
environments,
});
if (!assigned) {
return;
}
handleAssociateDevice(environments);
}
function handleAssociateDevice(devices: Environment[]) {
associateMutation.mutate(
devices.map((d) => d.Id),
{
onSuccess() {
notifySuccess('Success', 'Edge devices associated successfully');
},
}
);
}
async function handleRemoveDevice(devices: Environment[]) {
const confirmed = await confirm({
title: 'Are you sure?',
message:
"You're about to remove edge device(s) from waiting room, which will not be shown until next agent startup.",
confirmButton: buildConfirmButton('Remove', 'danger'),
modalType: ModalType.Destructive,
});
if (!confirmed) {
return;
}
removeMutation.mutate(
devices.map((d) => d.Id),
{
onSuccess() {
notifySuccess('Success', 'Edge devices were hidden successfully');
},
}
);
}
}

View file

@ -55,7 +55,7 @@ export function useEnvironments() {
const envs: Array<WaitingRoomEnvironment> =
environmentsQuery.environments.map((env) => ({
...env,
Group: groupsQuery.data?.[env.GroupId] || '',
Group: (env.GroupId !== 1 && groupsQuery.data?.[env.GroupId]) || '',
EdgeGroups:
environmentEdgeGroupsQuery.data?.[env.Id]?.map((env) => env.group) ||
[],

View file

@ -13,7 +13,7 @@ export default withLimitToBE(WaitingRoomView);
function WaitingRoomView() {
const untrustedCount = useUntrustedCount();
const { willExceed } = useLicenseOverused();
const licenseOverused = useLicenseOverused(untrustedCount);
return (
<>
<PageHeader
@ -32,7 +32,7 @@ function WaitingRoomView() {
</TextTip>
</InformationPanel>
{willExceed(untrustedCount) && (
{licenseOverused && (
<div className="row">
<div className="col-sm-12">
<Alert color="warn">

View file

@ -5,6 +5,14 @@ import axios, { parseAxiosError } from '@/portainer/services/axios';
import { promiseSequence } from '@/portainer/helpers/promise-utils';
import { useIntegratedLicenseInfo } from '@/react/portainer/licenses/use-license.service';
import { useEnvironmentList } from '@/react/portainer/environments/queries';
import {
mutationOptions,
withError,
withInvalidate,
} from '@/react-tools/react-query';
import { queryKey as nodesCountQueryKey } from '@/react/portainer/system/useNodesCount';
import { LicenseType } from '@/react/portainer/licenses/types';
import { queryKeys } from '@/react/portainer/environments/queries/query-keys';
export function useAssociateDeviceMutation() {
const queryClient = useQueryClient();
@ -12,17 +20,10 @@ export function useAssociateDeviceMutation() {
return useMutation(
(ids: EnvironmentId[]) =>
promiseSequence(ids.map((id) => () => associateDevice(id))),
{
onSuccess: () => {
queryClient.invalidateQueries(['environments']);
},
meta: {
error: {
title: 'Failure',
message: 'Failed to associate devices',
},
},
}
mutationOptions(
withError('Failed to associate devices'),
withInvalidate(queryClient, [queryKeys.base(), nodesCountQueryKey])
)
);
}
@ -34,19 +35,14 @@ async function associateDevice(environmentId: EnvironmentId) {
}
}
export function useLicenseOverused() {
export function useLicenseOverused(moreNodes: number) {
const integratedInfo = useIntegratedLicenseInfo();
return {
willExceed,
isOverused: willExceed(0),
};
function willExceed(moreNodes: number) {
return (
!!integratedInfo &&
integratedInfo.usedNodes + moreNodes >= integratedInfo.licenseInfo.nodes
);
}
return (
!!integratedInfo &&
integratedInfo.licenseInfo.type === LicenseType.Essentials &&
integratedInfo.usedNodes + moreNodes > integratedInfo.licenseInfo.nodes
);
}
export function useUntrustedCount() {

View file

@ -0,0 +1,3 @@
export function buildUrl() {
return '/edge_groups';
}

View file

@ -0,0 +1,3 @@
export const queryKeys = {
base: () => ['edge', 'groups'] as const,
};

View file

@ -0,0 +1,47 @@
import { useMutation, useQueryClient } from 'react-query';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { TagId } from '@/portainer/tags/types';
import {
mutationOptions,
withError,
withInvalidate,
} from '@/react-tools/react-query';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { EdgeGroup } from '../types';
import { buildUrl } from './build-url';
import { queryKeys } from './query-keys';
interface CreateGroupPayload {
name: string;
dynamic: boolean;
tagIds?: TagId[];
endpoints?: EnvironmentId[];
partialMatch?: boolean;
}
export async function createEdgeGroup(requestPayload: CreateGroupPayload) {
try {
const { data: group } = await axios.post<EdgeGroup>(
buildUrl(),
requestPayload
);
return group;
} catch (e) {
throw parseAxiosError(e as Error, 'Failed to create Edge group');
}
}
export function useCreateGroupMutation() {
const queryClient = useQueryClient();
return useMutation(
createEdgeGroup,
mutationOptions(
withError('Failed to create Edge group'),
withInvalidate(queryClient, [queryKeys.base()])
)
);
}

View file

@ -5,15 +5,16 @@ import { EnvironmentType } from '@/react/portainer/environments/types';
import { EdgeGroup } from '../types';
import { queryKeys } from './query-keys';
import { buildUrl } from './build-url';
interface EdgeGroupListItemResponse extends EdgeGroup {
EndpointTypes: Array<EnvironmentType>;
}
async function getEdgeGroups() {
try {
const { data } = await axios.get<EdgeGroupListItemResponse[]>(
'/edge_groups'
);
const { data } = await axios.get<EdgeGroupListItemResponse[]>(buildUrl());
return data;
} catch (err) {
throw parseAxiosError(err as Error, 'Failed fetching edge groups');
@ -25,5 +26,5 @@ export function useEdgeGroups<T = EdgeGroupListItemResponse[]>({
}: {
select?: (groups: EdgeGroupListItemResponse[]) => T;
} = {}) {
return useQuery(['edge', 'groups'], getEdgeGroups, { select });
return useQuery(queryKeys.base(), getEdgeGroups, { select });
}

View file

@ -4,7 +4,7 @@ import { compact } from 'lodash';
import { withError } from '@/react-tools/react-query';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { isFulfilled } from '@/react/utils';
import { isFulfilled } from '@/portainer/helpers/promise-utils';
import { getNamespaces } from '../namespaces/service';

View file

@ -9,7 +9,7 @@ import {
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { isFulfilled } from '@/react/utils';
import { isFulfilled } from '@/portainer/helpers/promise-utils';
import { getPod, getPods, patchPod } from './pod.service';
import { getNakedPods } from './utils';

View file

@ -7,7 +7,7 @@ import {
withInvalidate,
} from '@/react-tools/react-query';
import { getServices } from '@/react/kubernetes/networks/services/service';
import { isFulfilled } from '@/react/utils';
import { isFulfilled } from '@/portainer/helpers/promise-utils';
import {
getIngresses,

View file

@ -1,5 +1,6 @@
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { buildUrl } from './queries/build-url';
import { EnvironmentGroup, EnvironmentGroupId } from './types';
export async function getGroup(id: EnvironmentGroupId) {
@ -19,17 +20,3 @@ export async function getGroups() {
throw parseAxiosError(e as Error, '');
}
}
function buildUrl(id?: EnvironmentGroupId, action?: string) {
let url = '/endpoint_groups';
if (id) {
url += `/${id}`;
}
if (action) {
url += `/${action}`;
}
return url;
}

View file

@ -4,11 +4,12 @@ import { error as notifyError } from '@/portainer/services/notifications';
import { EnvironmentGroup, EnvironmentGroupId } from './types';
import { getGroup, getGroups } from './environment-groups.service';
import { queryKeys } from './queries/query-keys';
export function useGroups<T = EnvironmentGroup[]>({
select,
}: { select?: (group: EnvironmentGroup[]) => T } = {}) {
return useQuery(['environment-groups'], getGroups, {
return useQuery(queryKeys.base(), getGroups, {
select,
});
}
@ -17,17 +18,13 @@ export function useGroup<T = EnvironmentGroup>(
groupId: EnvironmentGroupId,
select?: (group: EnvironmentGroup) => T
) {
const { data } = useQuery(
['environment-groups', groupId],
() => getGroup(groupId),
{
staleTime: 50,
select,
onError(error) {
notifyError('Failed loading group', error as Error);
},
}
);
const { data } = useQuery(queryKeys.group(groupId), () => getGroup(groupId), {
staleTime: 50,
select,
onError(error) {
notifyError('Failed loading group', error as Error);
},
});
return data;
}

View file

@ -0,0 +1,15 @@
import { EnvironmentGroupId } from '../types';
export function buildUrl(id?: EnvironmentGroupId, action?: string) {
let url = '/endpoint_groups';
if (id) {
url += `/${id}`;
}
if (action) {
url += `/${action}`;
}
return url;
}

View file

@ -0,0 +1,6 @@
import { EnvironmentGroupId } from '../types';
export const queryKeys = {
base: () => ['environment-groups'] as const,
group: (id: EnvironmentGroupId) => [...queryKeys.base(), id] as const,
};

View file

@ -0,0 +1,46 @@
import { useMutation, useQueryClient } from 'react-query';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { TagId } from '@/portainer/tags/types';
import {
mutationOptions,
withError,
withInvalidate,
} from '@/react-tools/react-query';
import { EnvironmentId } from '../../types';
import { EnvironmentGroup } from '../types';
import { buildUrl } from './build-url';
import { queryKeys } from './query-keys';
interface CreateGroupPayload {
name: string;
description?: string;
associatedEndpoints?: EnvironmentId[];
tagIds?: TagId[];
}
export async function createGroup(requestPayload: CreateGroupPayload) {
try {
const { data: group } = await axios.post<EnvironmentGroup>(
buildUrl(),
requestPayload
);
return group;
} catch (e) {
throw parseAxiosError(e as Error, 'Failed to create group');
}
}
export function useCreateGroupMutation() {
const queryClient = useQueryClient();
return useMutation(
createGroup,
mutationOptions(
withError('Failed to create group'),
withInvalidate(queryClient, [queryKeys.base()])
)
);
}

View file

@ -140,75 +140,6 @@ export async function disassociateEndpoint(id: EnvironmentId) {
}
}
interface UpdatePayload {
TLSCACert?: File;
TLSCert?: File;
TLSKey?: File;
Name: string;
PublicURL: string;
GroupID: EnvironmentGroupId;
TagIds: TagId[];
EdgeCheckinInterval: number;
TLS: boolean;
TLSSkipVerify: boolean;
TLSSkipClientVerify: boolean;
AzureApplicationID: string;
AzureTenantID: string;
AzureAuthenticationKey: string;
}
async function uploadTLSFilesForEndpoint(
id: EnvironmentId,
tlscaCert?: File,
tlsCert?: File,
tlsKey?: File
) {
await Promise.all([
uploadCert('ca', tlscaCert),
uploadCert('cert', tlsCert),
uploadCert('key', tlsKey),
]);
function uploadCert(type: 'ca' | 'cert' | 'key', cert?: File) {
if (!cert) {
return null;
}
try {
return axios.post<void>(`upload/tls/${type}`, cert, {
params: { folder: id },
});
} catch (e) {
throw parseAxiosError(e as Error);
}
}
}
export async function updateEndpoint(
id: EnvironmentId,
payload: UpdatePayload
) {
try {
await uploadTLSFilesForEndpoint(
id,
payload.TLSCACert,
payload.TLSCert,
payload.TLSKey
);
const { data: endpoint } = await axios.put<Environment>(
buildUrl(id),
payload
);
return endpoint;
} catch (e) {
throw parseAxiosError(e as Error, 'Unable to update environment');
}
}
export async function deleteEndpoint(id: EnvironmentId) {
try {
await axios.delete(buildUrl(id));

View file

@ -0,0 +1,6 @@
import { EnvironmentId } from '../types';
export const queryKeys = {
base: () => ['environments'] as const,
item: (id: EnvironmentId) => [...queryKeys.base(), id] as const,
};

View file

@ -2,6 +2,10 @@ import { useQuery } from 'react-query';
import { getAgentVersions } from '../environment.service';
import { queryKeys } from './query-keys';
export function useAgentVersionsList() {
return useQuery(['environments', 'agentVersions'], () => getAgentVersions());
return useQuery([...queryKeys.base(), 'agentVersions'], () =>
getAgentVersions()
);
}

View file

@ -7,14 +7,20 @@ import {
} from '@/react/portainer/environments/types';
import { withError } from '@/react-tools/react-query';
import { queryKeys } from './query-keys';
export function useEnvironment<T = Environment | null>(
id?: EnvironmentId,
select?: (environment: Environment | null) => T
) {
return useQuery(['environments', id], () => (id ? getEndpoint(id) : null), {
select,
...withError('Failed loading environment'),
staleTime: 50,
enabled: !!id,
});
return useQuery(
id ? queryKeys.item(id) : [],
() => (id ? getEndpoint(id) : null),
{
select,
...withError('Failed loading environment'),
staleTime: 50,
enabled: !!id,
}
);
}

View file

@ -8,6 +8,8 @@ import {
getEnvironments,
} from '../environment.service';
import { queryKeys } from './query-keys';
export const ENVIRONMENTS_POLLING_INTERVAL = 30000; // in ms
export interface Query extends EnvironmentsQueryParams {
@ -46,7 +48,7 @@ export function useEnvironmentList(
) {
const { isLoading, data } = useQuery(
[
'environments',
...queryKeys.base(),
{
page,
pageLimit,

View file

@ -0,0 +1,93 @@
import { useMutation, useQueryClient } from 'react-query';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { TagId } from '@/portainer/tags/types';
import { withError } from '@/react-tools/react-query';
import { EnvironmentGroupId } from '../environment-groups/types';
import { buildUrl } from '../environment.service/utils';
import { EnvironmentId, Environment } from '../types';
import { queryKeys } from './query-keys';
export function useUpdateEnvironmentMutation() {
const queryClient = useQueryClient();
return useMutation(updateEnvironment, {
onSuccess(data, { id }) {
queryClient.invalidateQueries(queryKeys.item(id));
},
...withError('Unable to update environment'),
});
}
export interface UpdatePayload {
TLSCACert?: File;
TLSCert?: File;
TLSKey?: File;
Name: string;
PublicURL: string;
GroupID: EnvironmentGroupId;
TagIds: TagId[];
EdgeCheckinInterval: number;
TLS: boolean;
TLSSkipVerify: boolean;
TLSSkipClientVerify: boolean;
AzureApplicationID: string;
AzureTenantID: string;
AzureAuthenticationKey: string;
}
async function updateEnvironment({
id,
payload,
}: {
id: EnvironmentId;
payload: Partial<UpdatePayload>;
}) {
try {
await uploadTLSFilesForEndpoint(
id,
payload.TLSCACert,
payload.TLSCert,
payload.TLSKey
);
const { data: endpoint } = await axios.put<Environment>(
buildUrl(id),
payload
);
return endpoint;
} catch (e) {
throw parseAxiosError(e as Error, 'Unable to update environment');
}
}
async function uploadTLSFilesForEndpoint(
id: EnvironmentId,
tlscaCert?: File,
tlsCert?: File,
tlsKey?: File
) {
await Promise.all([
uploadCert('ca', tlscaCert),
uploadCert('cert', tlsCert),
uploadCert('key', tlsKey),
]);
function uploadCert(type: 'ca' | 'cert' | 'key', cert?: File) {
if (!cert) {
return null;
}
try {
return axios.post<void>(`upload/tls/${type}`, cert, {
params: { folder: id },
});
} catch (e) {
throw parseAxiosError(e as Error);
}
}
}

View file

@ -0,0 +1,52 @@
import { useMutation, useQueryClient } from 'react-query';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import {
mutationOptions,
withError,
withInvalidate,
} from '@/react-tools/react-query';
import { EdgeGroup } from '@/react/edge/edge-groups/types';
import { TagId } from '@/portainer/tags/types';
import { queryKeys as edgeGroupQueryKeys } from '@/react/edge/edge-groups/queries/query-keys';
import { queryKeys as groupQueryKeys } from '@/react/portainer/environments/environment-groups/queries/query-keys';
import { tagKeys } from '@/portainer/tags/queries';
import { EnvironmentId } from '../types';
import { buildUrl } from '../environment.service/utils';
import { EnvironmentGroupId } from '../environment-groups/types';
import { queryKeys } from './query-keys';
export function useUpdateEnvironmentsRelationsMutation() {
const queryClient = useQueryClient();
return useMutation(
updateEnvironmentRelations,
mutationOptions(
withInvalidate(queryClient, [
queryKeys.base(),
edgeGroupQueryKeys.base(),
groupQueryKeys.base(),
tagKeys.all,
]),
withError('Unable to update environment relations')
)
);
}
export interface EnvironmentRelationsPayload {
edgeGroups: Array<EdgeGroup['Id']>;
group: EnvironmentGroupId;
tags: Array<TagId>;
}
export async function updateEnvironmentRelations(
relations: Record<EnvironmentId, EnvironmentRelationsPayload>
) {
try {
await axios.put(buildUrl(undefined, 'relations'), { relations });
} catch (e) {
throw parseAxiosError(e as Error, 'Unable to update environment relations');
}
}

View file

@ -1,5 +0,0 @@
export function isFulfilled<T>(
input: PromiseSettledResult<T>
): input is PromiseFulfilledResult<T> {
return input.status === 'fulfilled';
}