Compare commits

...

6 Commits

27 changed files with 896 additions and 700 deletions
+26 -10
View File
@@ -3,8 +3,10 @@ package cli
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"io" "io"
"github.com/rs/zerolog/log"
v1 "k8s.io/api/core/v1" v1 "k8s.io/api/core/v1"
"k8s.io/client-go/kubernetes/scheme" "k8s.io/client-go/kubernetes/scheme"
"k8s.io/client-go/rest" "k8s.io/client-go/rest"
@@ -55,29 +57,43 @@ func (kcl *KubeClient) StartExecProcess(token string, useAdminToken bool, namesp
TTY: true, TTY: true,
}, scheme.ParameterCodec) }, scheme.ParameterCodec)
streamOpts := remotecommand.StreamOptions{
Stdin: stdin,
Stdout: stdout,
Tty: true,
}
// Try WebSocket executor first, fall back to SPDY if it fails
exec, err := remotecommand.NewWebSocketExecutorForProtocols( exec, err := remotecommand.NewWebSocketExecutorForProtocols(
config, config,
"GET", // WebSocket uses GET for the upgrade request "GET", // WebSocket uses GET for the upgrade request
req.URL().String(), req.URL().String(),
channelProtocolList..., channelProtocolList...,
) )
if err != nil { if err == nil {
exec, err = remotecommand.NewSPDYExecutor(config, "POST", req.URL()) err = exec.StreamWithContext(context.TODO(), streamOpts)
if err != nil { if err == nil {
errChan <- err
return return
} }
log.Warn().
Err(err).
Str("context", "StartExecProcess").
Msg("WebSocket exec failed, falling back to SPDY")
} }
err = exec.StreamWithContext(context.TODO(), remotecommand.StreamOptions{ // Fall back to SPDY executor
Stdin: stdin, exec, err = remotecommand.NewSPDYExecutor(config, "POST", req.URL())
Stdout: stdout, if err != nil {
Tty: true, errChan <- fmt.Errorf("unable to create SPDY executor: %w", err)
}) return
}
err = exec.StreamWithContext(context.TODO(), streamOpts)
if err != nil { if err != nil {
var exitError utilexec.ExitError var exitError utilexec.ExitError
if !errors.As(err, &exitError) { if !errors.As(err, &exitError) {
errChan <- errors.New("unable to start exec process") errChan <- fmt.Errorf("unable to start exec process: %w", err)
} }
} }
} }
+1 -1
View File
@@ -576,7 +576,7 @@ type (
} }
PolicyChartBundle struct { PolicyChartBundle struct {
PolicyChartSummary PolicyChartSummary `mapstructure:",squash"`
EncodedTgz string `json:"EncodedTgz"` EncodedTgz string `json:"EncodedTgz"`
Namespace string `json:"Namespace"` Namespace string `json:"Namespace"`
PreReleaseManifest string `json:"PreReleaseManifest,omitempty"` PreReleaseManifest string `json:"PreReleaseManifest,omitempty"`
+2 -10
View File
@@ -74,18 +74,10 @@ func (d *stackDeployer) DeployComposeStack(stack *portainer.Stack, endpoint *por
} }
} }
if err := d.composeStackManager.Up(context.TODO(), stack, endpoint, portainer.ComposeUpOptions{ return d.composeStackManager.Up(context.TODO(), stack, endpoint, portainer.ComposeUpOptions{
ComposeOptions: options, ComposeOptions: options,
ForceRecreate: forceRecreate, ForceRecreate: forceRecreate,
}); err != nil { })
if err := d.composeStackManager.Down(context.TODO(), stack, endpoint); err != nil {
log.Warn().Err(err).Msg("failed to cleanup compose stack after failed deployment")
}
return err
}
return nil
} }
func (d *stackDeployer) DeployKubernetesStack(stack *portainer.Stack, endpoint *portainer.Endpoint, user *portainer.User) error { func (d *stackDeployer) DeployKubernetesStack(stack *portainer.Stack, endpoint *portainer.Endpoint, user *portainer.User) error {
@@ -66,7 +66,6 @@
.pagination > .active > span:focus, .pagination > .active > span:focus,
.pagination > .active > button:focus { .pagination > .active > button:focus {
@apply text-blue-7; @apply text-blue-7;
z-index: 3;
cursor: default; cursor: default;
/* background-color: var(--text-pagination-span-color); */ /* background-color: var(--text-pagination-span-color); */
background-color: var(--bg-pagination-color); background-color: var(--bg-pagination-color);
+2 -2
View File
@@ -65,7 +65,7 @@ const SheetOverlay = forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay <SheetPrimitive.Overlay
className={clsx( className={clsx(
'fixed inset-0 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0', 'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className className
)} )}
// eslint-disable-next-line react/jsx-props-no-spreading // eslint-disable-next-line react/jsx-props-no-spreading
@@ -76,7 +76,7 @@ const SheetOverlay = forwardRef<
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName; SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
const sheetVariants = cva( const sheetVariants = cva(
'fixed gap-4 bg-widget-color p-5 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500', 'fixed z-50 bg-widget-color p-5 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
{ {
variants: { variants: {
side: { side: {
@@ -15,11 +15,10 @@ export function StickyFooter({
<div <div
className={clsx( className={clsx(
styles.actionBar, styles.actionBar,
// The sticky footer should be below the modal overlay `Modal.tsx` and react select menu `ReactSelect.css` (z-50) 'fixed bottom-0 right-0 z-40 h-16',
'fixed bottom-0 right-0 z-10 h-16',
'flex items-center px-6', 'flex items-center px-6',
'bg-[var(--bg-widget-color)] border-t border-[var(--border-widget-color)]', 'bg-[var(--bg-widget-color)] border-t border-[var(--border-widget-color)]',
'shadow-[0_-2px_10px_rgba(0,0,0,0.1)]', 'shadow-[0_-2px_5px_rgba(0,0,0,0.1)]',
className className
)} )}
> >
+12 -3
View File
@@ -1,12 +1,13 @@
import { Trash2 } from 'lucide-react'; import { Trash2 } from 'lucide-react';
import { ComponentProps, PropsWithChildren, ReactNode } from 'react'; import { ComponentProps, PropsWithChildren, ReactNode } from 'react';
import clsx from 'clsx';
import { AutomationTestingProps } from '@/types'; import { AutomationTestingProps } from '@/types';
import { confirmDelete } from '@@/modals/confirm'; import { confirmDelete } from '@@/modals/confirm';
import { Button } from './Button';
import { LoadingButton } from './LoadingButton'; import { LoadingButton } from './LoadingButton';
import { Button } from './Button';
type ConfirmOrClick = type ConfirmOrClick =
| { | {
@@ -26,7 +27,10 @@ export function DeleteButton({
size, size,
children, children,
isLoading, isLoading,
text = 'Remove',
loadingText = 'Removing...', loadingText = 'Removing...',
icon = false,
type,
'data-cy': dataCy, 'data-cy': dataCy,
...props ...props
}: PropsWithChildren< }: PropsWithChildren<
@@ -35,7 +39,10 @@ export function DeleteButton({
size?: ComponentProps<typeof Button>['size']; size?: ComponentProps<typeof Button>['size'];
disabled?: boolean; disabled?: boolean;
isLoading?: boolean; isLoading?: boolean;
text?: string;
loadingText?: string; loadingText?: string;
icon?: boolean;
type?: ComponentProps<typeof Button>['type'];
} }
>) { >) {
if (isLoading === undefined) { if (isLoading === undefined) {
@@ -46,10 +53,11 @@ export function DeleteButton({
disabled={disabled || isLoading} disabled={disabled || isLoading}
onClick={() => handleClick()} onClick={() => handleClick()}
icon={Trash2} icon={Trash2}
className="!m-0" className={clsx('!m-0', icon ? 'btn-icon' : '')}
data-cy={dataCy} data-cy={dataCy}
type={type}
> >
{children || 'Remove'} {children || text}
</Button> </Button>
); );
} }
@@ -65,6 +73,7 @@ export function DeleteButton({
data-cy={dataCy} data-cy={dataCy}
isLoading={isLoading} isLoading={isLoading}
loadingText={loadingText} loadingText={loadingText}
type={type}
> >
{children || 'Remove'} {children || 'Remove'}
</LoadingButton> </LoadingButton>
+21 -9
View File
@@ -7,31 +7,43 @@ interface Props<D extends DefaultType = DefaultType> {
cells: Cell<D, unknown>[]; cells: Cell<D, unknown>[];
className?: string; className?: string;
onClick?: () => void; onClick?: () => void;
'aria-selected'?: boolean;
} }
export function TableRow<D extends DefaultType = DefaultType>({ export function TableRow<D extends DefaultType = DefaultType>({
cells, cells,
className, className,
onClick, onClick,
'aria-selected': ariaSelected,
}: Props<D>) { }: Props<D>) {
return ( return (
<tr <tr
className={clsx(className, { 'cursor-pointer': !!onClick })} className={clsx(className, { 'cursor-pointer': !!onClick })}
onClick={onClick} onClick={onClick}
aria-selected={ariaSelected}
> >
{cells.map((cell) => ( {cells.map((cell) => {
<td key={cell.id} className={getClassName(cell.column.columnDef.meta)}> const { className, width } = parseMeta(cell.column.columnDef.meta);
{flexRender(cell.column.columnDef.cell, cell.getContext())} return (
</td> <td key={cell.id} className={className} style={{ width }}>
))} {flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
);
})}
</tr> </tr>
); );
} }
function getClassName<D extends DefaultType = DefaultType>( function parseMeta<D extends DefaultType = DefaultType>(
meta: ColumnMeta<D, unknown> | undefined meta: ColumnMeta<D, unknown> | undefined
) { ) {
return !!meta && 'className' in meta && typeof meta.className === 'string' const className =
? meta.className !!meta && 'className' in meta && typeof meta.className === 'string'
: ''; ? meta.className
: '';
const width =
!!meta && 'width' in meta && typeof meta.width === 'string'
? meta.width
: undefined;
return { className, width };
} }
@@ -67,7 +67,7 @@ export function createSelectColumn<T>(dataCy: string): ColumnDef<T> {
), ),
enableHiding: false, enableHiding: false,
meta: { meta: {
width: 50, width: '50px',
}, },
}; };
} }
@@ -52,7 +52,7 @@ export function FormSection({
</FormSectionTitle> </FormSectionTitle>
{/* col-sm-12 in the title has a 'float: left' style - 'clear-both' makes sure it doesn't get in the way of the next div */} {/* col-sm-12 in the title has a 'float: left' style - 'clear-both' makes sure it doesn't get in the way of the next div */}
{/* https://stackoverflow.com/questions/7759837/put-divs-below-floatleft-divs */} {/* https://stackoverflow.com/questions/7759837/put-divs-below-floatleft-divs */}
{isExpanded && <div className="clear-both">{children}</div>} <div className="clear-both">{isExpanded && children}</div>
</div> </div>
); );
} }
+4 -4
View File
@@ -37,11 +37,11 @@ export function Modal({
<Context.Provider value> <Context.Provider value>
<DialogOverlay <DialogOverlay
isOpen isOpen
className={clsx( className={clsx(styles.overlay, 'flex items-center justify-center')}
styles.overlay,
'flex items-center justify-center z-50'
)}
onDismiss={onDismiss} onDismiss={onDismiss}
// When a Sheet is open and then a Modal opens, Radix DismissableLayer sets body.style.pointerEvents="none" for this modal overlay, so make it auto here.
// z-index ensures the modal renders above the base views and any Sheet (z-50).
style={{ zIndex: 60, pointerEvents: 'auto' }}
> >
<DialogContent <DialogContent
aria-label={ariaLabel} aria-label={ariaLabel}
@@ -30,7 +30,7 @@ export function CreateGroupView() {
]} ]}
/> />
<div className="row"> <div className="row pb-20">
<div className="col-sm-12"> <div className="col-sm-12">
<Widget> <Widget>
<Widget.Body> <Widget.Body>
@@ -298,14 +298,13 @@ describe('EditGroupView', () => {
await user.click(submitButton); await user.click(submitButton);
// Verify the request URL and body // Verify the request URL and body.
await waitFor(() => { await waitFor(() => {
expect(requestUrl).toBe('/api/endpoint_groups/2'); expect(requestUrl).toBe('/api/endpoint_groups/2');
expect(requestBody).toEqual({ expect(requestBody).toEqual({
Name: 'Updated Group', Name: 'Updated Group',
Description: 'Test description', Description: 'Test description',
TagIDs: [1], TagIDs: [1],
AssociatedEndpoints: [1], // The associated environment ID
}); });
}); });
}); });
@@ -451,22 +450,9 @@ describe('EditGroupView', () => {
expect(elements[0]).toBeVisible(); expect(elements[0]).toBeVisible();
}); });
it('should include associated environment IDs in update payload', async () => { it('should NOT include AssociatedEndpoints in update payload (backend preserves associations)', async () => {
let requestBody: DefaultBodyType; let requestBody: DefaultBodyType;
const associatedEnvs = [
{
...mockEnvironment,
Id: 100,
Name: 'Env 100',
} as Partial<Environment>,
{
...mockEnvironment,
Id: 200,
Name: 'Env 200',
} as Partial<Environment>,
];
server.use( server.use(
http.put('/api/endpoint_groups/:id', async ({ request }) => { http.put('/api/endpoint_groups/:id', async ({ request }) => {
requestBody = await request.json(); requestBody = await request.json();
@@ -475,9 +461,7 @@ describe('EditGroupView', () => {
); );
const user = userEvent.setup(); const user = userEvent.setup();
renderEditGroupView({ renderEditGroupView();
associatedEnvironments: associatedEnvs,
});
// Wait for form to populate // Wait for form to populate
const nameInput = await screen.findByLabelText(/Name/i); const nameInput = await screen.findByLabelText(/Name/i);
@@ -495,11 +479,9 @@ describe('EditGroupView', () => {
await user.click(submitButton); await user.click(submitButton);
// Verify the associated environments are included in payload // Verify AssociatedEndpoints is absent — backend nil-check preserves existing memberships
await waitFor(() => { await waitFor(() => {
expect(requestBody).toMatchObject({ expect(requestBody).not.toHaveProperty('AssociatedEndpoints');
AssociatedEndpoints: [100, 200],
});
}); });
}); });
}); });
@@ -2,8 +2,6 @@ import { useRouter } from '@uirouter/react';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { FormikHelpers } from 'formik'; import { FormikHelpers } from 'formik';
import { useEnvironmentList } from '@/react/portainer/environments/queries';
import { notifySuccess } from '@/portainer/services/notifications';
import { useIdParam } from '@/react/hooks/useIdParam'; import { useIdParam } from '@/react/hooks/useIdParam';
import { Widget } from '@@/Widget'; import { Widget } from '@@/Widget';
@@ -13,32 +11,22 @@ import { Alert } from '@@/Alert';
import { useGroup } from '../queries/useGroup'; import { useGroup } from '../queries/useGroup';
import { useUpdateGroupMutation } from '../queries/useUpdateGroupMutation'; import { useUpdateGroupMutation } from '../queries/useUpdateGroupMutation';
import { GroupForm, GroupFormValues } from '../components/GroupForm'; import { GroupForm, GroupFormValues } from '../components/GroupForm';
import { AssociatedEnvironmentsSelector } from '../components/AssociatedEnvironmentsSelector/AssociatedEnvironmentsSelector';
export function EditGroupView() { export function EditGroupView() {
const groupId = useIdParam(); const groupId = useIdParam();
const router = useRouter(); const router = useRouter();
const groupQuery = useGroup(groupId); const groupQuery = useGroup(groupId);
const updateMutation = useUpdateGroupMutation();
// Fetch associated environments for this group (not for unassigned group)
const isUnassignedGroup = groupId === 1; const isUnassignedGroup = groupId === 1;
const environmentsQuery = useEnvironmentList( const updateMutation = useUpdateGroupMutation();
{ groupIds: [groupId], pageLimit: 0 },
{ enabled: !!groupId && !isUnassignedGroup }
);
const isLoading =
groupQuery.isLoading || (!isUnassignedGroup && environmentsQuery.isLoading);
const initialValues: GroupFormValues = useMemo( const initialValues: GroupFormValues = useMemo(
() => ({ () => ({
name: groupQuery.data?.Name ?? '', name: groupQuery.data?.Name ?? '',
description: groupQuery.data?.Description ?? '', description: groupQuery.data?.Description ?? '',
tagIds: groupQuery.data?.TagIds ?? [], tagIds: groupQuery.data?.TagIds ?? [],
associatedEnvironments:
environmentsQuery.environments?.map((e) => e.Id) ?? [],
}), }),
[groupQuery.data, environmentsQuery.environments] [groupQuery.data]
); );
return ( return (
@@ -54,7 +42,7 @@ export function EditGroupView() {
<div className="row"> <div className="row">
<div className="col-sm-12"> <div className="col-sm-12">
<Widget> <Widget>
<Widget.Body loading={isLoading}> <Widget.Body loading={groupQuery.isLoading}>
{groupQuery.isError && ( {groupQuery.isError && (
<Alert color="error" title="Error"> <Alert color="error" title="Error">
Failed to load group details Failed to load group details
@@ -73,6 +61,15 @@ export function EditGroupView() {
</Widget> </Widget>
</div> </div>
</div> </div>
<div className="row pb-20">
<div className="col-sm-12">
<AssociatedEnvironmentsSelector
groupId={groupId}
readOnly={isUnassignedGroup}
/>
</div>
</div>
</> </>
); );
@@ -86,12 +83,11 @@ export function EditGroupView() {
name: values.name, name: values.name,
description: values.description, description: values.description,
tagIds: values.tagIds, tagIds: values.tagIds,
associatedEnvironments: values.associatedEnvironments, // associatedEnvironments omitted — backend preserves existing when field is absent (nil)
}, },
{ {
onSuccess() { onSuccess() {
resetForm(); resetForm();
notifySuccess('Success', 'Group successfully updated');
router.stateService.go('portainer.groups'); router.stateService.go('portainer.groups');
}, },
} }
@@ -0,0 +1,174 @@
import clsx from 'clsx';
import { useState } from 'react';
import { createColumnHelper } from '@tanstack/react-table';
import { truncate } from 'lodash';
import { useEnvironmentList } from '@/react/portainer/environments/queries';
import { isSortType } from '@/react/portainer/environments/queries/useEnvironmentList';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { Datatable } from '@@/datatables';
import { useTableStateWithoutStorage } from '@@/datatables/useTableState';
import { withControlledSelected } from '@@/datatables/extend-options/withControlledSelected';
import { TableRow } from '@@/datatables/TableRow';
import { Sheet, SheetContent, SheetClose, SheetHeader } from '@@/Sheet';
import { Button, LoadingButton } from '@@/buttons';
import { EnvironmentTableData } from './types';
const columnHelper = createColumnHelper<EnvironmentTableData>();
const columns = [
columnHelper.accessor('Name', {
header: 'Name',
id: 'Name',
cell: ({ getValue }) => (
<span title={getValue()}>{truncate(getValue(), { length: 64 })}</span>
),
}),
];
interface Props {
open: boolean;
onClose(): void;
/** IDs already in the group — excluded from the available list */
excludeIds: EnvironmentId[];
/** Called with the full env objects so callers can display names or extract IDs.
* Returns true if the add was committed, false if the user cancelled. */
onAdd:
| ((envs: EnvironmentTableData[]) => Promise<boolean>)
| ((envs: EnvironmentTableData[]) => void);
/** Loading state from the parent — disables buttons and shows spinner */
isLoading?: boolean;
}
export function AddEnvironmentsDrawer({
open,
onClose,
excludeIds,
onAdd,
isLoading,
}: Props) {
const tableState = useTableStateWithoutStorage('Name');
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [selectedEnvs, setSelectedEnvs] = useState<EnvironmentTableData[]>([]);
const [page, setPage] = useState(0);
const {
environments,
totalCount,
isLoading: isEnvsLoading,
} = useEnvironmentList({
pageLimit: tableState.pageSize,
page: page + 1,
search: tableState.search,
sort: isSortType(tableState.sortBy?.id) ? tableState.sortBy.id : 'Name',
order: tableState.sortBy?.desc ? 'desc' : 'asc',
groupIds: [1],
excludeIds,
});
function handleSelectionChange(ids: string[]) {
setSelectedIds(ids);
const currentDataMap = new Map(
(environments ?? []).map((env) => [String(env.Id), env])
);
setSelectedEnvs((prev) => {
const prevMap = new Map(prev.map((e) => [String(e.Id), e]));
// Keep already-tracked envs that remain selected
const kept = prev.filter((e) => ids.includes(String(e.Id)));
// Add newly selected envs from the current page
const added = ids
.filter((id) => !prevMap.has(id) && currentDataMap.has(id))
.map((id) => currentDataMap.get(id)!);
return [...kept, ...added];
});
}
async function handleAdd() {
const committed = await onAdd(selectedEnvs);
// Close only if the add was committed or there was no confirmation needed
if (committed || committed === undefined) {
resetSelection();
onClose();
}
}
function resetSelection() {
setSelectedIds([]);
setSelectedEnvs([]);
}
return (
<Sheet
open={open}
onOpenChange={(isOpen) => {
if (!isOpen) {
resetSelection();
onClose();
}
}}
>
<SheetContent className="flex flex-col !p-0">
<div className="flex-1 p-4 overflow-auto">
<SheetHeader title="Add environments" />
<Datatable<EnvironmentTableData>
title="Available environments"
columns={columns}
dataset={environments ?? []}
settingsManager={tableState}
isLoading={isEnvsLoading}
isServerSidePagination
page={page}
onPageChange={setPage}
totalCount={totalCount}
getRowId={(row) => String(row.Id)}
renderRow={(row) => (
<TableRow<EnvironmentTableData>
cells={row.getVisibleCells()}
onClick={() => row.toggleSelected()}
className={clsx({ active: row.getIsSelected() })}
aria-selected={row.getIsSelected()}
/>
)}
extendTableOptions={withControlledSelected(
handleSelectionChange,
selectedIds
)}
data-cy="add-environments-drawer-table"
/>
</div>
{/* Don't use StickyFooter here. StickyFooter has classes for the menu to the left that we don't want here */}
<div
className={clsx(
'bottom-0 left-0 right-0 w-full z-50 h-16 sticky justify-end gap-4',
'flex items-center px-6',
'bg-[var(--bg-widget-color)] border-t border-[var(--border-widget-color)]',
'shadow-[0_-2px_5px_rgba(0,0,0,0.1)]'
)}
>
<SheetClose asChild>
<Button
color="default"
disabled={isLoading}
data-cy="add-environments-cancel-button"
size="medium"
>
Cancel
</Button>
</SheetClose>
<LoadingButton
onClick={handleAdd}
disabled={selectedIds.length === 0 || isLoading}
isLoading={!!isLoading}
loadingText="Adding..."
data-cy="add-environments-confirm-button"
size="medium"
>
Confirm
</LoadingButton>
</div>
</SheetContent>
</Sheet>
);
}
@@ -1,4 +1,4 @@
import { render, screen, waitFor, within } from '@testing-library/react'; import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw'; import { http, HttpResponse } from 'msw';
import { vi } from 'vitest'; import { vi } from 'vitest';
@@ -11,227 +11,204 @@ import {
EnvironmentId, EnvironmentId,
} from '@/react/portainer/environments/types'; } from '@/react/portainer/environments/types';
import { EnvironmentGroup } from '../../types';
import { AssociatedEnvironmentsSelector } from './AssociatedEnvironmentsSelector'; import { AssociatedEnvironmentsSelector } from './AssociatedEnvironmentsSelector';
function createEnv(id: EnvironmentId, name: string): Environment { vi.mock('@@/modals/confirm', () => ({
return createMockEnvironment({ Id: id, Name: name, GroupId: 1 }); openConfirm: vi.fn().mockResolvedValue(true),
confirmDelete: vi.fn().mockResolvedValue(true),
}));
const mockGroup: EnvironmentGroup = {
Id: 2,
Name: 'Test Group',
Description: '',
TagIds: [],
};
function createEnv(id: EnvironmentId, name: string): Partial<Environment> {
return createMockEnvironment({ Id: id, Name: name });
} }
function setupMockServer(environments: Array<Environment> = []) { function setupMockServer({
associatedEnvs = [] as Array<Partial<Environment>>,
availableEnvs = [] as Array<Partial<Environment>>,
onPut = undefined as ((body: unknown) => void) | undefined,
} = {}) {
server.use( server.use(
http.get('/api/endpoints', () => http.get('/api/endpoint_groups/2', () => HttpResponse.json(mockGroup)),
HttpResponse.json(environments, { http.get('/api/endpoints', ({ request }) => {
headers: { const url = new URL(request.url);
'x-total-count': String(environments.length), const groupIds = [
'x-total-available': String(environments.length), ...url.searchParams.getAll('groupIds'),
}, ...url.searchParams.getAll('groupIds[]'),
}) ];
)
function makeResponse(envs: Array<Partial<Environment>>) {
return HttpResponse.json(envs, {
headers: {
'x-total-count': String(envs.length),
'x-total-available': String(envs.length),
},
});
}
if (groupIds.includes('2')) return makeResponse(associatedEnvs);
if (groupIds.includes('1')) return makeResponse(availableEnvs);
return makeResponse([]);
}),
http.put('/api/endpoint_groups/2', async ({ request }) => {
const body = await request.json();
onPut?.(body);
return HttpResponse.json(mockGroup);
})
); );
} }
function renderComponent({ function renderComponent(groupId = 2) {
associatedEnvironmentIds = [] as Array<EnvironmentId>,
initialAssociatedEnvironmentIds = [] as Array<EnvironmentId>,
onChange = vi.fn(),
}: {
associatedEnvironmentIds?: Array<EnvironmentId>;
initialAssociatedEnvironmentIds?: Array<EnvironmentId>;
onChange?: (ids: Array<EnvironmentId>) => void;
} = {}) {
const Wrapped = withTestQueryProvider(() => ( const Wrapped = withTestQueryProvider(() => (
<AssociatedEnvironmentsSelector <AssociatedEnvironmentsSelector groupId={groupId} readOnly={false} />
associatedEnvironmentIds={associatedEnvironmentIds}
initialAssociatedEnvironmentIds={initialAssociatedEnvironmentIds}
onChange={onChange}
/>
)); ));
return { return render(<Wrapped />);
...render(<Wrapped />),
onChange,
};
} }
describe('AssociatedEnvironmentsSelector', () => { describe('AssociatedEnvironmentsSelector', () => {
describe('Rendering', () => { describe('Rendering', () => {
it('should render both Available and Associated environments tables', async () => { it('renders the associated environments table', async () => {
setupMockServer(); setupMockServer();
renderComponent(); renderComponent();
expect( expect(
screen.getByRole('heading', { name: 'Available environments' }) await screen.findByRole('heading', { name: 'Associated environments' })
).toBeVisible();
expect(
screen.getByRole('heading', { name: 'Associated environments' })
).toBeVisible(); ).toBeVisible();
}); });
it('should render instruction text', async () => { it('renders an Add button', async () => {
setupMockServer(); setupMockServer();
renderComponent(); renderComponent();
expect( expect(
await screen.findByText(/click on any environment entry to move it/i) await screen.findByTestId('add-environments-button')
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
it('should render Associated environments table with data-cy attribute', async () => { it('renders a Remove button that is initially disabled', async () => {
setupMockServer(); setupMockServer({ associatedEnvs: [createEnv(10, 'my-env')] });
renderComponent(); renderComponent();
await waitFor(() => { await screen.findByText('my-env');
expect(screen.getByTestId('group-associatedEndpoints')).toBeVisible();
}); const removeBtn = screen.getByTestId('remove-environments-button');
expect(removeBtn).toBeDisabled();
}); });
it('should display initially associated environments in Associated table', async () => { it('displays environments returned by the API', async () => {
const envs = [createEnv(10, 'associated-env-1')]; setupMockServer({ associatedEnvs: [createEnv(10, 'env-alpha')] });
renderComponent();
setupMockServer(envs); expect(await screen.findByText('env-alpha')).toBeInTheDocument();
renderComponent({
associatedEnvironmentIds: [10],
initialAssociatedEnvironmentIds: [10],
});
await waitFor(() => {
expect(screen.getByText('associated-env-1')).toBeInTheDocument();
});
});
});
describe('Adding environments', () => {
it('should call onChange with new environment ID when clicking an available environment', async () => {
const user = userEvent.setup();
const onChange = vi.fn();
const envs = [createEnv(1, 'available-env')];
setupMockServer(envs);
renderComponent({ onChange });
const envRow = await screen.findByText('available-env');
await user.click(envRow);
expect(onChange).toHaveBeenCalledWith([1]);
});
it('should append new environment to existing associated IDs', async () => {
const user = userEvent.setup();
const onChange = vi.fn();
const envs = [createEnv(1, 'available-env'), createEnv(10, 'existing')];
setupMockServer(envs);
renderComponent({
associatedEnvironmentIds: [10],
initialAssociatedEnvironmentIds: [10],
onChange,
});
// Wait for the available table to be ready and find the row
const availableTable = await screen.findByTestId(
'group-availableEndpoints'
);
await within(availableTable).findByText('available-env');
// Find the row element that contains the text and click it
const rows = within(availableTable).getAllByRole('row');
const envRow = rows.find(
(row) => row.textContent?.includes('available-env')
);
expect(envRow).toBeDefined();
await user.click(envRow!);
// Wait for onChange to be called with the new environment ID appended
await waitFor(() => {
expect(onChange).toHaveBeenCalledWith([10, 1]);
});
}); });
}); });
describe('Removing environments', () => { describe('Removing environments', () => {
it('should call onChange without the removed environment ID', async () => { it('enables Remove button when a row is selected', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const onChange = vi.fn(); setupMockServer({ associatedEnvs: [createEnv(10, 'env-to-remove')] });
renderComponent();
const envs = [ await screen.findByText('env-to-remove');
createEnv(10, 'associated-env-1'),
createEnv(11, 'associated-env-2'),
];
setupMockServer(envs);
renderComponent({ // First checkbox is the select-all header, second is the first row
associatedEnvironmentIds: [10, 11], const checkboxes = screen.getAllByRole('checkbox');
initialAssociatedEnvironmentIds: [10, 11], await user.click(checkboxes[1]);
onChange,
await waitFor(() => {
expect(screen.getByTestId('remove-environments-button')).toBeEnabled();
}); });
// Wait for initial query to load and row to appear in Associated table, then click
const associatedTable = await screen.findByTestId(
'group-associatedEndpoints'
);
const envRow =
await within(associatedTable).findByText('associated-env-1');
await user.click(envRow);
expect(onChange).toHaveBeenCalledWith([11]);
}); });
it('should call onChange with empty array when removing last environment', async () => { it('calls PUT with filtered environment IDs after confirming remove', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const onChange = vi.fn(); let requestBody: unknown;
const envs = [createEnv(10, 'only-env')]; setupMockServer({
setupMockServer(envs); associatedEnvs: [createEnv(10, 'env-a'), createEnv(11, 'env-b')],
onPut: (body) => {
renderComponent({ requestBody = body;
associatedEnvironmentIds: [10], },
initialAssociatedEnvironmentIds: [10],
onChange,
}); });
renderComponent();
const associatedTable = await screen.findByTestId( await screen.findByText('env-a');
'group-associatedEndpoints'
);
const envRow = await within(associatedTable).findByText('only-env');
await user.click(envRow);
expect(onChange).toHaveBeenCalledWith([]); // Select first row checkbox
const checkboxes = screen.getAllByRole('checkbox');
await user.click(checkboxes[1]);
const removeBtn = await screen.findByTestId('remove-environments-button');
await waitFor(() => expect(removeBtn).toBeEnabled());
await user.click(removeBtn);
await waitFor(() => {
expect(requestBody).toMatchObject({
AssociatedEndpoints: [11],
});
});
}); });
}); });
describe('Computed values', () => { describe('Adding environments (drawer)', () => {
it('should identify added IDs (current but not initial)', () => { it('opens the drawer when Add button is clicked', async () => {
// addedIds = associatedEnvironmentIds.filter(id => !initialAssociatedEnvironmentIds.includes(id)) const user = userEvent.setup();
// When current=[1,2,3] and initial=[2,3], added=[1] setupMockServer({ availableEnvs: [createEnv(20, 'available-env')] });
setupMockServer(); renderComponent();
// This test validates the component's internal logic by checking the highlightIds const addBtn = await screen.findByTestId('add-environments-button');
// passed to AssociatedEnvironmentsTable (newly added envs get "Unsaved" badge) await user.click(addBtn);
renderComponent({
associatedEnvironmentIds: [1, 2, 3],
initialAssociatedEnvironmentIds: [2, 3],
});
// The component will compute addedIds=[1] internally expect(await screen.findByText('Add environments')).toBeVisible();
// We can't directly test internal state, but we verify it renders
expect(screen.getByTestId('group-associatedEndpoints')).toBeVisible();
}); });
it('should identify removed IDs (initial but not current)', () => { it('calls PUT with merged IDs when environments are added from drawer', async () => {
// removedIds = initialAssociatedEnvironmentIds.filter(id => !associatedEnvironmentIds.includes(id)) const user = userEvent.setup();
// When current=[2,3] and initial=[1,2,3], removed=[1] let requestBody: unknown;
setupMockServer();
renderComponent({ setupMockServer({
associatedEnvironmentIds: [2, 3], associatedEnvs: [createEnv(10, 'existing-env')],
initialAssociatedEnvironmentIds: [1, 2, 3], availableEnvs: [createEnv(20, 'new-env')],
onPut: (body) => {
requestBody = body;
},
}); });
renderComponent();
// The component will compute removedIds=[1] internally // Open drawer
// and pass it as includeIds to AvailableEnvironmentsTable const addBtn = await screen.findByTestId('add-environments-button');
expect(screen.getByTestId('group-availableEndpoints')).toBeVisible(); await user.click(addBtn);
// Wait for drawer to open and available env to appear
await screen.findByText('Add environments');
await screen.findByText('new-env');
// Select the available env — find the drawer's checkboxes
// The drawer table has its own checkboxes after the main table ones
const allCheckboxes = screen.getAllByRole('checkbox');
// Last checkbox belongs to the drawer table row
await user.click(allCheckboxes[allCheckboxes.length - 1]);
// Click the Add button in the drawer footer
const confirmAddBtn = screen.getByTestId(
'add-environments-confirm-button'
);
await user.click(confirmAddBtn);
await waitFor(() => {
expect(requestBody).toMatchObject({
AssociatedEndpoints: expect.arrayContaining([10, 20]),
});
});
}); });
}); });
}); });
@@ -1,126 +1,94 @@
import { useMemo, useState } from 'react'; import { useState } from 'react';
import { useEnvironmentList } from '@/react/portainer/environments/queries'; import { useEnvironmentList } from '@/react/portainer/environments/queries';
import { EnvironmentGroupId } from '@/react/portainer/environments/types';
import { FormSection } from '@@/form-components/FormSection'; import { openConfirm } from '@@/modals/confirm';
import { buildConfirmButton } from '@@/modals/utils';
import { Environment, EnvironmentId } from '../../../types'; import { useGroup } from '../../queries/useGroup';
import { useUpdateGroupMutation } from '../../queries/useUpdateGroupMutation';
import { EnvironmentTableData } from './types'; import { EnvironmentTableData } from './types';
import { AssociatedEnvironmentsTable } from './AssociatedEnvironmentsTable'; import { AssociatedEnvironmentsTable } from './AssociatedEnvironmentsTable';
import { AvailableEnvironmentsTable } from './AvailableEnvironmentsTable'; import { AddEnvironmentsDrawer } from './AddEnvironmentsDrawer';
interface Props { interface Props {
/** Group ID when editing an existing group */ groupId: EnvironmentGroupId;
groupId?: number; /* For unassigned group, don't show the add/remove buttons and hide the checkbox */
/** IDs of currently associated environments */ readOnly: boolean;
associatedEnvironmentIds: Array<EnvironmentId>;
/** IDs of initially associated environments for tracking unsaved changes */
initialAssociatedEnvironmentIds: Array<EnvironmentId>;
/** Called when environment IDs change */
onChange: (ids: Array<EnvironmentId>) => void;
} }
export function AssociatedEnvironmentsSelector({ export function AssociatedEnvironmentsSelector({ groupId, readOnly }: Props) {
groupId, const [drawerOpen, setDrawerOpen] = useState(false);
associatedEnvironmentIds,
initialAssociatedEnvironmentIds,
onChange,
}: Props) {
// Track full environment objects for display (populated when clicking rows)
const [environmentCache, setEnvironmentCache] = useState<
Map<EnvironmentId, EnvironmentTableData>
>(new Map());
// Fetch initially associated environments to populate the cache const groupQuery = useGroup(groupId);
const initialEnvsQuery = useEnvironmentList( const environmentsQuery = useEnvironmentList({
groupId groupIds: [groupId],
? { pageLimit: 0,
groupIds: [groupId], });
pageLimit: 0, const updateMutation = useUpdateGroupMutation();
}
: {
endpointIds: initialAssociatedEnvironmentIds,
},
{
enabled: groupId
? groupId !== 1
: initialAssociatedEnvironmentIds.length > 0,
}
);
const environmentMap = useMemo( const currentEnvironments = environmentsQuery.environments ?? [];
() => buildEnvironmentMap(environmentCache, initialEnvsQuery.environments), const currentIds = currentEnvironments.map((e) => e.Id);
[environmentCache, initialEnvsQuery.environments]
);
const associatedSet = new Set(associatedEnvironmentIds);
const initialSet = new Set(initialAssociatedEnvironmentIds);
const addedIds = associatedEnvironmentIds.filter((id) => !initialSet.has(id));
const removedIds = initialAssociatedEnvironmentIds.filter(
(id) => !associatedSet.has(id)
);
const excludeIdsForAvailableEnvironments = groupId
? addedIds
: associatedEnvironmentIds;
const associatedEnvironments = associatedEnvironmentIds
.map((id) => environmentMap.get(id))
.filter((env): env is Environment => env !== undefined);
return ( return (
<FormSection title="Associated environments"> <>
<div className="small text-muted"> <AssociatedEnvironmentsTable
You can select which environment should be part of this group by moving title="Associated environments"
them to the associated environments table. Simply click on any environments={currentEnvironments}
environment entry to move it from one table to the other. isLoading={environmentsQuery.isLoading}
</div> onRemove={handleRemove}
onOpenAddDrawer={() => setDrawerOpen(true)}
isRemoving={updateMutation.isLoading}
data-cy="group-associatedEndpoints"
readOnly={readOnly}
/>
<div className="flex mt-4 gap-5 items-stretch"> <AddEnvironmentsDrawer
<div className="w-1/2 flex flex-col"> open={drawerOpen}
<AvailableEnvironmentsTable onClose={() => setDrawerOpen(false)}
title="Available environments" excludeIds={currentIds}
excludeIds={excludeIdsForAvailableEnvironments} onAdd={handleAdd}
includeIds={removedIds} isLoading={updateMutation.isLoading}
highlightIds={removedIds} />
onClickRow={handleAddEnvironment} </>
data-cy="group-availableEndpoints"
/>
</div>
<div className="w-1/2 flex flex-col">
<AssociatedEnvironmentsTable
title="Associated environments"
environments={associatedEnvironments}
highlightIds={addedIds}
onClickRow={handleRemoveEnvironment}
data-cy="group-associatedEndpoints"
/>
</div>
</div>
</FormSection>
); );
function handleAddEnvironment(env: EnvironmentTableData) { function handleRemove(selected: EnvironmentTableData[]) {
if (!associatedEnvironmentIds.includes(env.Id)) { const selectedIds = new Set(selected.map((e) => e.Id));
setEnvironmentCache((prev) => new Map(prev).set(env.Id, env)); const remainingIds = currentIds.filter((id) => !selectedIds.has(id));
onChange([...associatedEnvironmentIds, env.Id]);
} updateMutation.mutate({
id: groupId,
name: groupQuery.data?.Name ?? '',
description: groupQuery.data?.Description,
tagIds: groupQuery.data?.TagIds,
associatedEnvironments: remainingIds,
});
} }
function handleRemoveEnvironment(env: EnvironmentTableData) { async function handleAdd(newEnvs: EnvironmentTableData[]): Promise<boolean> {
onChange(associatedEnvironmentIds.filter((id) => id !== env.Id)); const confirmed = await openConfirm({
title: 'Are you sure?',
message: `Are you sure you want to add the selected environment(s) to this group?`,
confirmButton: buildConfirmButton('Add'),
});
if (!confirmed) return false;
const mergedIds = [
...new Set([...currentIds, ...newEnvs.map((e) => e.Id)]),
];
updateMutation.mutate({
id: groupId,
name: groupQuery.data?.Name ?? '',
description: groupQuery.data?.Description,
tagIds: groupQuery.data?.TagIds,
associatedEnvironments: mergedIds,
});
return true;
} }
} }
function buildEnvironmentMap(
cache: Map<EnvironmentId, EnvironmentTableData>,
envs: Array<Environment> | undefined
): Map<EnvironmentId, EnvironmentTableData> {
return new Map([
...cache.entries(),
...(envs ?? []).map(
(env) => [env.Id, { Name: env.Name, Id: env.Id }] as const
),
]);
}
@@ -1,15 +1,17 @@
import { createColumnHelper } from '@tanstack/react-table'; import { createColumnHelper } from '@tanstack/react-table';
import clsx from 'clsx';
import { truncate } from 'lodash'; import { truncate } from 'lodash';
import { useMemo } from 'react'; import { useMemo, useState } from 'react';
import { Plus } from 'lucide-react';
import clsx from 'clsx';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { AutomationTestingProps } from '@/types'; import { AutomationTestingProps } from '@/types';
import { useTableStateWithoutStorage } from '@@/datatables/useTableState'; import { useTableStateWithoutStorage } from '@@/datatables/useTableState';
import { Datatable, TableRow } from '@@/datatables'; import { Datatable } from '@@/datatables';
import { Badge } from '@@/Badge'; import { withControlledSelected } from '@@/datatables/extend-options/withControlledSelected';
import { Widget } from '@@/Widget'; import { TableRow } from '@@/datatables/TableRow';
import { DeleteButton } from '@@/buttons/DeleteButton';
import { Button } from '@@/buttons';
import { EnvironmentTableData } from './types'; import { EnvironmentTableData } from './types';
@@ -18,64 +20,101 @@ const columnHelper = createColumnHelper<EnvironmentTableData>();
interface Props extends AutomationTestingProps { interface Props extends AutomationTestingProps {
title: string; title: string;
environments: Array<EnvironmentTableData>; environments: Array<EnvironmentTableData>;
onClickRow?: (env: EnvironmentTableData) => void; onRemove(selected: EnvironmentTableData[]): void;
highlightIds?: Array<EnvironmentId>; onOpenAddDrawer(): void;
isRemoving?: boolean;
isLoading?: boolean;
/** When false, Remove fires immediately without a confirmation dialog (e.g. create mode) */
confirmRemove?: boolean;
/** When true, don't show the add/remove buttons and hide the checkbox */
readOnly?: boolean;
} }
export function AssociatedEnvironmentsTable({ export function AssociatedEnvironmentsTable({
title, title,
environments, environments,
onClickRow, onRemove,
highlightIds = [], onOpenAddDrawer,
isRemoving,
isLoading,
confirmRemove = true,
readOnly = false,
'data-cy': dataCy, 'data-cy': dataCy,
}: Props) { }: Props) {
const tableState = useTableStateWithoutStorage('Name'); const tableState = useTableStateWithoutStorage('Name');
const columns = useMemo(() => buildColumns(highlightIds), [highlightIds]); const [selectedIds, setSelectedIds] = useState<string[]>([]);
const columns = useMemo(() => buildColumns(), []);
return ( return (
<Widget className="flex-1 flex flex-col"> // avoid padding issues with the widget
<div <div className="-mx-[15px]">
className={clsx( <Datatable<EnvironmentTableData>
'h-full flex flex-col', disableSelect={readOnly}
'[&_section.datatable]:flex-1 [&_section.datatable]:flex [&_section.datatable]:flex-col', isLoading={isLoading}
'[&_.footer]:!mt-auto' title={title}
columns={columns}
settingsManager={tableState}
dataset={environments}
getRowId={(row) => String(row.Id)}
renderRow={(row) => (
<TableRow<EnvironmentTableData>
cells={row.getVisibleCells()}
onClick={() => row.toggleSelected()}
className={clsx({ active: row.getIsSelected() })}
aria-selected={row.getIsSelected()}
/>
)} )}
> extendTableOptions={withControlledSelected(setSelectedIds, selectedIds)}
<Datatable<EnvironmentTableData> renderTableActions={(selectedItems) =>
// noWidget to avoid padding issues with TableContainer readOnly ? null : (
noWidget <>
title={title} {confirmRemove ? (
columns={columns} <DeleteButton
settingsManager={tableState} disabled={selectedItems.length === 0}
dataset={environments} isLoading={isRemoving}
renderRow={(row) => ( confirmMessage="Are you sure you want to remove the selected environment(s) from this group?"
<TableRow<EnvironmentTableData> onConfirmed={() => handleRemove(selectedItems)}
cells={row.getVisibleCells()} data-cy="remove-environments-button"
onClick={onClickRow ? () => onClickRow(row.original) : undefined} type="button"
/> />
)} ) : (
disableSelect <DeleteButton
data-cy={dataCy || 'environment-table'} disabled={selectedItems.length === 0}
/> onClick={() => {
</div> handleRemove(selectedItems);
</Widget> }}
data-cy="remove-environments-button"
type="button"
/>
)}
<Button
icon={Plus}
onClick={onOpenAddDrawer}
data-cy="add-environments-button"
>
Add
</Button>
</>
)
}
data-cy={dataCy || 'environment-table'}
/>
</div>
); );
function handleRemove(selectedItems: EnvironmentTableData[]) {
onRemove(selectedItems);
setSelectedIds([]);
}
} }
function buildColumns(highlightIds: Array<EnvironmentId>) { function buildColumns() {
return [ return [
columnHelper.accessor('Name', { columnHelper.accessor('Name', {
header: 'Name', header: 'Name',
id: 'Name', id: 'Name',
cell: ({ getValue, row }) => ( cell: ({ getValue }) => (
<span className="flex items-center gap-2"> <span title={getValue()}>{truncate(getValue(), { length: 64 })}</span>
<span title={getValue()}>{truncate(getValue(), { length: 64 })}</span>
{highlightIds.includes(row.original.Id) && (
<Badge type="muted" data-cy="unsaved-badge">
Unsaved
</Badge>
)}
</span>
), ),
}), }),
]; ];
@@ -1,176 +0,0 @@
import { createColumnHelper } from '@tanstack/react-table';
import { truncate } from 'lodash';
import { useMemo, useState } from 'react';
import clsx from 'clsx';
import { useEnvironmentList } from '@/react/portainer/environments/queries';
import { isSortType } from '@/react/portainer/environments/queries/useEnvironmentList';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { AutomationTestingProps } from '@/types';
import { semverCompare } from '@/react/common/semver-utils';
import { createPersistedStore } from '@@/datatables/types';
import { useTableState } from '@@/datatables/useTableState';
import { Datatable, TableRow } from '@@/datatables';
import { Badge } from '@@/Badge';
import { Widget } from '@@/Widget';
import { EnvironmentTableData } from './types';
const columnHelper = createColumnHelper<EnvironmentTableData>();
const tableKey = 'available-environments';
const settingsStore = createPersistedStore(tableKey, 'Name');
interface Props extends AutomationTestingProps {
title: string;
/** IDs to exclude from the query (environments already associated) */
excludeIds: Array<EnvironmentId>;
/** IDs to include in the query (e.g., recently removed from associated - will be highlighted) */
includeIds?: Array<EnvironmentId>;
/** IDs to highlight (unsaved badge) */
highlightIds?: Array<EnvironmentId>;
onClickRow?: (env: EnvironmentTableData) => void;
}
export function AvailableEnvironmentsTable({
title,
excludeIds,
includeIds = [],
highlightIds = [],
onClickRow,
'data-cy': dataCy,
}: Props) {
const tableState = useTableState(settingsStore, tableKey);
const [page, setPage] = useState(0);
const columns = useMemo(
() => buildColumns(new Set(highlightIds)),
[highlightIds]
);
// Query unassigned environments (group 1)
const unassignedQuery = useEnvironmentList({
pageLimit: tableState.pageSize,
page: page + 1,
search: tableState.search,
sort: isSortType(tableState.sortBy?.id) ? tableState.sortBy.id : 'Name',
order: tableState.sortBy?.desc ? 'desc' : 'asc',
groupIds: [1],
excludeIds,
});
// Query removed environments by ID (these are still in their original group until saved)
const removedQuery = useEnvironmentList(
{
endpointIds: includeIds,
search: tableState.search,
},
{ enabled: includeIds.length > 0 }
);
// Merge results: removed environments + unassigned environments (deduped)
const { environments, uniqueRemovedCount } = useMemo(() => {
const unassigned = unassignedQuery.environments || [];
const removed =
includeIds.length > 0 ? removedQuery.environments || [] : [];
if (removed.length === 0) {
return { environments: unassigned, uniqueRemovedCount: 0 };
}
const unassignedIds = new Set(unassigned.map((e) => e.Id));
const uniqueRemoved = removed.filter((e) => !unassignedIds.has(e.Id));
// Sort combined results by name to maintain order
const combined = [...uniqueRemoved, ...unassigned];
const isDesc = tableState.sortBy?.desc ?? false;
// useTypeGuard on tableState.sortBy.id to use as a key for sorting
const sortKey = getSortKey(tableState.sortBy?.id);
if (sortKey) {
return {
environments: combined.sort((a, b) => {
const cmp = semverCompare(
a[sortKey].toString(),
b[sortKey].toString()
);
return isDesc ? -cmp : cmp;
}),
uniqueRemovedCount: uniqueRemoved.length,
};
}
return { environments: combined, uniqueRemovedCount: uniqueRemoved.length };
}, [
unassignedQuery.environments,
removedQuery.environments,
includeIds.length,
tableState.sortBy?.desc,
tableState.sortBy?.id,
]);
const totalCount = unassignedQuery.totalCount + uniqueRemovedCount;
return (
<Widget className="flex-1 flex flex-col">
<div
className={clsx(
'h-full flex flex-col',
'[&_section.datatable]:flex-1 [&_section.datatable]:flex [&_section.datatable]:flex-col',
'[&_.footer]:!mt-auto'
)}
>
<Datatable<EnvironmentTableData>
// noWidget to avoid padding issues with TableContainer
noWidget
title={title}
columns={columns}
settingsManager={tableState}
dataset={environments}
isServerSidePagination
page={page}
onPageChange={setPage}
totalCount={totalCount}
renderRow={(row) => (
<TableRow<EnvironmentTableData>
cells={row.getVisibleCells()}
onClick={onClickRow ? () => onClickRow(row.original) : undefined}
/>
)}
disableSelect
data-cy={dataCy || 'available-environments-table'}
/>
</div>
</Widget>
);
}
function buildColumns(highlightIds: Set<EnvironmentId>) {
return [
columnHelper.accessor('Name', {
header: 'Name',
id: 'Name',
cell: ({ getValue, row }) => (
<span className="flex items-center gap-2">
<span title={getValue()}>{truncate(getValue(), { length: 64 })}</span>
{highlightIds.has(row.original.Id) && (
<Badge type="muted" data-cy="unsaved-badge">
Unsaved
</Badge>
)}
</span>
),
}),
];
}
function getSortKey(sortId?: string): keyof EnvironmentTableData | undefined {
if (!sortId) {
return undefined;
}
switch (sortId) {
case 'Name':
return 'Name';
default:
return 'Name';
}
// extend to other keys as needed
}
@@ -0,0 +1,62 @@
import { useState } from 'react';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { FormSection } from '@@/form-components/FormSection';
import { EnvironmentTableData } from './types';
import { AssociatedEnvironmentsTable } from './AssociatedEnvironmentsTable';
import { AddEnvironmentsDrawer } from './AddEnvironmentsDrawer';
interface Props {
selectedIds: EnvironmentId[];
onChange(ids: EnvironmentId[]): void;
}
/**
* Similar to AssociatedEnvironmentsSelector, but instead of making API calls and getting the environment list from the server, it holds the the selected environments and ids in local state.
*
* This is because on create, there is no group to add / remove environments from yet.
*/
export function FormModeEnvironmentsSelector({ selectedIds, onChange }: Props) {
const [drawerOpen, setDrawerOpen] = useState(false);
const [selectedEnvironments, setSelectedEnvironments] = useState<
EnvironmentTableData[]
>([]);
return (
<FormSection title="Associate environments">
<p className="small text-muted">
Assocate environments to this group by clicking the add button below.
</p>
<AssociatedEnvironmentsTable
title="Associated environments"
environments={selectedEnvironments}
onRemove={handleRemove}
onOpenAddDrawer={() => setDrawerOpen(true)}
confirmRemove={false}
data-cy="group-associatedEndpoints"
/>
<AddEnvironmentsDrawer
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
excludeIds={selectedIds}
onAdd={handleAdd}
/>
</FormSection>
);
async function handleRemove(toRemove: EnvironmentTableData[]) {
const removeIds = new Set(toRemove.map((env) => env.Id));
setSelectedEnvironments((prev) => prev.filter((e) => !removeIds.has(e.Id)));
onChange(selectedIds.filter((id) => !removeIds.has(id)));
}
function handleAdd(newEnvs: EnvironmentTableData[]) {
const existingIds = new Set(selectedIds);
const toAdd = newEnvs.filter((e) => !existingIds.has(e.Id));
setSelectedEnvironments((prev) => [...prev, ...toAdd]);
onChange([...selectedIds, ...toAdd.map((e) => e.Id)]);
}
}
@@ -19,7 +19,6 @@ function renderGroupForm({
name: '', name: '',
description: '', description: '',
tagIds: [], tagIds: [],
associatedEnvironments: [],
}, },
onSubmit = vi.fn(), onSubmit = vi.fn(),
submitLabel = 'Create', submitLabel = 'Create',
@@ -40,7 +39,7 @@ function renderGroupForm({
{ ID: 2, Name: 'staging' }, { ID: 2, Name: 'staging' },
]) ])
), ),
// Mock environments list for AssociatedEnvironmentsSelector // Mock environments list for AssociatedEnvironmentsSelector and InlineAvailableEnvironmentsTable
http.get('/api/endpoints', () => http.get('/api/endpoints', () =>
HttpResponse.json([], { HttpResponse.json([], {
headers: { headers: {
@@ -48,6 +47,16 @@ function renderGroupForm({
'x-total-available': '0', 'x-total-available': '0',
}, },
}) })
),
// Mock group endpoint for AssociatedEnvironmentsSelector (edit mode)
http.get('/api/endpoint_groups/:id', ({ params }) =>
HttpResponse.json({
Id: Number(params.id),
Name: 'Test Group',
Description: '',
TagIds: [],
Policies: [],
})
) )
); );
@@ -88,48 +97,43 @@ describe('GroupForm', () => {
).toBeVisible(); ).toBeVisible();
}); });
it('should show Associated environments section when groupId is provided (not unassigned group)', async () => { it('should not show Associated environments section when groupId is provided (edit mode)', async () => {
renderGroupForm({ groupId: 2 }); renderGroupForm({ groupId: 2 });
// Wait for form to render
await screen.findByLabelText(/Name/i); await screen.findByLabelText(/Name/i);
// Check for section title using findByRole // In edit mode, environments are managed by AssociatedEnvironmentsSelector component (rendered separately in EditGroupView)
expect( expect(
await screen.findByRole('heading', { name: /Associated environments/i }) screen.queryByRole('heading', { name: /Associated environments/i })
).toBeVisible(); ).not.toBeInTheDocument();
expect(
screen.queryByTestId('add-environments-button')
).not.toBeInTheDocument();
}); });
it('should show Unassociated environments section when groupId is 1 (unassigned group)', async () => { it('should not show environment section when groupId is 1 (unassigned group)', async () => {
renderGroupForm({ groupId: 1 }); renderGroupForm({ groupId: 1 });
// Wait for form to render
await screen.findByLabelText(/Name/i); await screen.findByLabelText(/Name/i);
// Check for section title using findByRole // In edit mode, environments are managed by AssociatedEnvironmentsSelector component (rendered separately)
expect( expect(
await screen.findByRole('heading', { screen.queryByRole('heading', { name: /Associated environments/i })
name: /Unassociated environments/i, ).not.toBeInTheDocument();
}) expect(
).toBeVisible(); screen.queryByTestId('add-environments-button')
).not.toBeInTheDocument();
// Should NOT show "Associated environments" section (exact match to exclude "Unassociated")
const associatedElements = screen.queryAllByText(
/^Associated environments$/i
);
expect(associatedElements).toHaveLength(0);
}); });
it('should show Associated environments section in create mode (no groupId)', async () => { it('should show associated environments table with Add button in create mode (no groupId)', async () => {
renderGroupForm(); renderGroupForm();
// Wait for form to render
await screen.findByLabelText(/Name/i); await screen.findByLabelText(/Name/i);
// Check for section title using findByRole // FormModeEnvironmentsSelector renders AssociatedEnvironmentsTable with an Add button
expect( expect(
await screen.findByRole('heading', { name: /Associated environments/i }) await screen.findByTestId('add-environments-button')
).toBeVisible(); ).toBeInTheDocument();
}); });
}); });
@@ -161,7 +165,6 @@ describe('GroupForm', () => {
name: 'existing-group', name: 'existing-group',
description: '', description: '',
tagIds: [], tagIds: [],
associatedEnvironments: [],
}, },
}); });
@@ -169,7 +172,6 @@ describe('GroupForm', () => {
name: /Create/i, name: /Create/i,
}); });
// Form is valid but not dirty, so should be disabled
expect(submitButton).toBeDisabled(); expect(submitButton).toBeDisabled();
}); });
@@ -235,7 +237,6 @@ describe('GroupForm', () => {
name: 'test-group', name: 'test-group',
description: 'Test description', description: 'Test description',
tagIds: [], tagIds: [],
associatedEnvironments: [],
}), }),
expect.anything() expect.anything()
); );
@@ -244,7 +245,6 @@ describe('GroupForm', () => {
it('should show loading state during submission', async () => { it('should show loading state during submission', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
// Create a promise that we can control
let resolveSubmit: () => void; let resolveSubmit: () => void;
const onSubmit = vi.fn().mockImplementation( const onSubmit = vi.fn().mockImplementation(
() => () =>
@@ -269,14 +269,12 @@ describe('GroupForm', () => {
await user.click(submitButton); await user.click(submitButton);
// Should show loading state
await waitFor(() => { await waitFor(() => {
expect( expect(
screen.getByRole('button', { name: /Creating.../i }) screen.getByRole('button', { name: /Creating.../i })
).toBeVisible(); ).toBeVisible();
}); });
// Resolve the submission
resolveSubmit!(); resolveSubmit!();
}); });
}); });
@@ -306,7 +304,6 @@ describe('GroupForm', () => {
name: 'pre-filled-name', name: 'pre-filled-name',
description: 'pre-filled-description', description: 'pre-filled-description',
tagIds: [], tagIds: [],
associatedEnvironments: [],
}, },
}); });
@@ -9,26 +9,28 @@ import { object, string, array, number } from 'yup';
import { useRef } from 'react'; import { useRef } from 'react';
import { TagId } from '@/portainer/tags/types'; import { TagId } from '@/portainer/tags/types';
import {
EnvironmentId,
EnvironmentGroupId,
} from '@/react/portainer/environments/types';
import { useIsPureAdmin } from '@/react/hooks/useUser'; import { useIsPureAdmin } from '@/react/hooks/useUser';
import { useCanExit } from '@/react/hooks/useCanExit'; import { useCanExit } from '@/react/hooks/useCanExit';
import { FormControl } from '@@/form-components/FormControl'; import { FormControl } from '@@/form-components/FormControl';
import { Input } from '@@/form-components/Input'; import { Input } from '@@/form-components/Input';
import { FormSection } from '@@/form-components/FormSection';
import { TagSelector } from '@@/TagSelector'; import { TagSelector } from '@@/TagSelector';
import { confirmGenericDiscard } from '@@/modals/confirm'; import { confirmGenericDiscard } from '@@/modals/confirm';
import { FormActions } from '@@/form-components/FormActions'; import { LoadingButton } from '@@/buttons';
import { StickyFooter } from '@@/StickyFooter/StickyFooter';
import { EnvironmentGroupId, EnvironmentId } from '../../types'; import { FormModeEnvironmentsSelector } from './AssociatedEnvironmentsSelector/FormModeEnvironmentsSelector';
import { AssociatedEnvironmentsSelector } from './AssociatedEnvironmentsSelector/AssociatedEnvironmentsSelector';
import { AvailableEnvironmentsTable } from './AssociatedEnvironmentsSelector/AvailableEnvironmentsTable';
export interface GroupFormValues { export interface GroupFormValues {
name: string; name: string;
description: string; description: string;
tagIds: Array<TagId>; tagIds: Array<TagId>;
associatedEnvironments: Array<EnvironmentId>; /** Used in create mode only — undefined in edit mode */
associatedEnvironments?: Array<EnvironmentId>;
} }
interface Props { interface Props {
@@ -48,7 +50,6 @@ const validationSchema = object({
name: string().required('Name is required'), name: string().required('Name is required'),
description: string(), description: string(),
tagIds: array(number()), tagIds: array(number()),
associatedEnvironments: array(),
}); });
export function GroupForm({ export function GroupForm({
@@ -71,7 +72,6 @@ export function GroupForm({
enableReinitialize enableReinitialize
> >
<InnerForm <InnerForm
initialValues={initialValues}
submitLabel={submitLabel} submitLabel={submitLabel}
submitLoadingLabel={submitLoadingLabel} submitLoadingLabel={submitLoadingLabel}
groupId={groupId} groupId={groupId}
@@ -81,20 +81,17 @@ export function GroupForm({
} }
interface InnerFormProps { interface InnerFormProps {
initialValues: GroupFormValues;
submitLabel: string; submitLabel: string;
submitLoadingLabel: string; submitLoadingLabel: string;
groupId?: EnvironmentGroupId; groupId?: EnvironmentGroupId;
} }
function InnerForm({ function InnerForm({
initialValues,
submitLabel, submitLabel,
submitLoadingLabel, submitLoadingLabel,
groupId, groupId,
}: InnerFormProps) { }: InnerFormProps) {
const isPureAdmin = useIsPureAdmin(); const isPureAdmin = useIsPureAdmin();
const isUnassignedGroup = groupId === 1;
const { const {
values, values,
errors, errors,
@@ -104,6 +101,7 @@ function InnerForm({
dirty, dirty,
isSubmitting, isSubmitting,
} = useFormikContext<GroupFormValues>(); } = useFormikContext<GroupFormValues>();
const isCreateMode = !groupId;
return ( return (
<Form className="form-horizontal"> <Form className="form-horizontal">
@@ -140,31 +138,25 @@ function InnerForm({
allowCreate={isPureAdmin} allowCreate={isPureAdmin}
/> />
{isUnassignedGroup ? ( {isCreateMode && (
<FormSection title="Unassociated environments"> // Same UI as edit mode, but updates form values instead of the API
<AvailableEnvironmentsTable <FormModeEnvironmentsSelector
title="Unassociated environments" selectedIds={values.associatedEnvironments ?? []}
excludeIds={[]}
data-cy="group-unassociatedEndpoints"
/>
</FormSection>
) : (
<AssociatedEnvironmentsSelector
groupId={groupId}
associatedEnvironmentIds={values.associatedEnvironments}
initialAssociatedEnvironmentIds={initialValues.associatedEnvironments}
onChange={(ids) => setFieldValue('associatedEnvironments', ids)} onChange={(ids) => setFieldValue('associatedEnvironments', ids)}
/> />
)} )}
<FormActions <StickyFooter className="justify-end gap-4">
submitLabel={submitLabel} <LoadingButton
loadingText={submitLoadingLabel} size="medium"
isLoading={isSubmitting} loadingText={submitLoadingLabel}
isValid={isValid && !isSubmitting && dirty} isLoading={isSubmitting}
errors={errors} disabled={!isValid || isSubmitting || !dirty}
data-cy="group-submit-button" data-cy="group-submit-button"
/> >
{submitLabel}
</LoadingButton>
</StickyFooter>
</Form> </Form>
); );
} }
@@ -2,8 +2,9 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
import axios, { parseAxiosError } from '@/portainer/services/axios'; import axios, { parseAxiosError } from '@/portainer/services/axios';
import { TagId } from '@/portainer/tags/types'; import { TagId } from '@/portainer/tags/types';
import { withGlobalError, withInvalidate } from '@/react-tools/react-query'; import { withGlobalError } from '@/react-tools/react-query';
import { environmentQueryKeys } from '@/react/portainer/environments/queries/query-keys'; import { environmentQueryKeys } from '@/react/portainer/environments/queries/query-keys';
import { notifySuccess } from '@/portainer/services/notifications';
import { EnvironmentGroupId, EnvironmentId } from '../../types'; import { EnvironmentGroupId, EnvironmentId } from '../../types';
import { EnvironmentGroup } from '../types'; import { EnvironmentGroup } from '../types';
@@ -44,10 +45,11 @@ export function useUpdateGroupMutation() {
return useMutation({ return useMutation({
mutationFn: updateGroup, mutationFn: updateGroup,
onSuccess: () => {
queryClient.invalidateQueries(queryKeys.base());
queryClient.invalidateQueries(environmentQueryKeys.base());
notifySuccess('Success', 'Group successfully updated');
},
...withGlobalError('Failed to update group'), ...withGlobalError('Failed to update group'),
...withInvalidate(queryClient, [
queryKeys.base(),
environmentQueryKeys.base(),
]),
}); });
} }
@@ -14,6 +14,10 @@ import EdgeAgentAsyncIcon from '@/react/edge/components/edge-agent-async.svg?c';
import { BoxSelector, type BoxSelectorOption } from '@@/BoxSelector'; import { BoxSelector, type BoxSelectorOption } from '@@/BoxSelector';
import { BadgeIcon } from '@@/BadgeIcon'; import { BadgeIcon } from '@@/BadgeIcon';
import { Alert } from '@@/Alert'; import { Alert } from '@@/Alert';
import { FormSection } from '@@/form-components/FormSection';
import { Badge } from '@@/Badge';
import { ExternalLink } from '@@/ExternalLink';
import { useDocsUrl } from '@@/PageHeader/ContextHelp';
import { AnalyticsStateKey } from '../types'; import { AnalyticsStateKey } from '../types';
import { EdgeAgentTab } from '../shared/EdgeAgentTab'; import { EdgeAgentTab } from '../shared/EdgeAgentTab';
@@ -27,50 +31,78 @@ interface Props {
isDockerStandalone?: boolean; isDockerStandalone?: boolean;
} }
const options: BoxSelectorOption< type CreationType =
'agent' | 'api' | 'socket' | 'edgeAgentStandard' | 'edgeAgentAsync' | 'agent'
>[] = _.compact([ | 'api'
| 'socket'
| 'edgeAgentStandard'
| 'edgeAgentAsync';
const primaryOptions: BoxSelectorOption<CreationType>[] = _.compact([
{ {
id: 'edgeAgentStandard', id: 'edgeAgentStandard',
icon: <BadgeIcon icon={EdgeAgentStandardIcon} size="3xl" />, icon: <BadgeIcon icon={EdgeAgentStandardIcon} size="3xl" />,
label: 'Edge Agent Standard', label: 'Edge Agent Standard',
description: '', description: (
<>
<span>
<Badge type="infoSecondary">Recommended</Badge>{' '}
<Badge type="infoSecondary">Supports Policies</Badge>
</span>
<span className="mt-1 block">
The remote environment will initiate connections to the Portainer
server, with the ability to open a secure on-demand tunnel for
real-time interaction. The Portainer server must be accessible from
the Edge Agent environment.
</span>
</>
),
value: 'edgeAgentStandard', value: 'edgeAgentStandard',
}, },
isBE && { isBE && {
id: 'edgeAgentAsync', id: 'edgeAgentAsync',
icon: <BadgeIcon icon={EdgeAgentAsyncIcon} size="3xl" />, icon: <BadgeIcon icon={EdgeAgentAsyncIcon} size="3xl" />,
label: 'Edge Agent Async', label: 'Edge Agent Async',
description: '', description:
value: 'edgeAgentAsync', 'The remote environment will initiate connections to the Portainer server, without the ability to open a real-time tunnel. The Portainer server must be accessible from the Edge Agent environment.',
value: 'edgeAgentAsync' as CreationType,
}, },
]);
const legacyOptions: BoxSelectorOption<CreationType>[] = [
{ {
id: 'agent', id: 'agent',
icon: <BadgeIcon icon={Zap} size="3xl" />, icon: <BadgeIcon icon={Zap} size="3xl" />,
label: 'Agent', label: 'Agent',
description: '', description:
'The Portainer Server will initiate connections to the remote environment. The agent on the remote environment must be accessible from the Portainer server environment.',
value: 'agent', value: 'agent',
}, },
{ {
id: 'api', id: 'api',
icon: <BadgeIcon icon={Network} size="3xl" />, icon: <BadgeIcon icon={Network} size="3xl" />,
label: 'API', label: 'API',
description: '', description: 'Connect to the environment directly via the Docker API.',
value: 'api', value: 'api',
}, },
{ {
id: 'socket', id: 'socket',
icon: <BadgeIcon icon={Plug2} size="3xl" />, icon: <BadgeIcon icon={Plug2} size="3xl" />,
label: 'Socket', label: 'Socket',
description: '', description: 'Connect to the environment directly via the Docker socket.',
value: 'socket', value: 'socket',
}, },
]); ];
const containerEngine = ContainerEngine.Docker; const containerEngine = ContainerEngine.Docker;
export function WizardDocker({ onCreate, isDockerStandalone }: Props) { export function WizardDocker({ onCreate, isDockerStandalone }: Props) {
const [creationType, setCreationType] = useState(options[0].value); const edgeAgentDocsUrl = useDocsUrl(
'/faqs/getting-started/why-do-we-recommend-using-the-edge-agent-instead-of-the-traditional-agent'
);
const [creationType, setCreationType] = useState<CreationType>(
primaryOptions[0].value
);
const tab = getTab(creationType); const tab = getTab(creationType);
@@ -89,23 +121,43 @@ export function WizardDocker({ onCreate, isDockerStandalone }: Props) {
)} )}
<BoxSelector <BoxSelector
onChange={(v) => setCreationType(v)} onChange={(v) => setCreationType(v)}
options={options} options={primaryOptions}
value={creationType} value={creationType}
radioName="creation-type" radioName="creation-type"
className="!-mb-2"
/> />
<FormSection
key="legacy-options"
title="More options"
titleSize="sm"
isFoldable
defaultFolded={false}
className="[&>label]:mb-5"
>
<p className="text-xs text-muted mb-2">
These are legacy options that don&apos;t support edge features or
policy management. For most use cases,{' '}
<ExternalLink
to={edgeAgentDocsUrl}
data-cy="wizard-edge-agent-docs-link"
>
the Edge Agent is recommended
</ExternalLink>
</p>
<BoxSelector
onChange={(v) => setCreationType(v)}
options={legacyOptions}
value={creationType}
radioName="creation-type"
/>
</FormSection>
{tab} {tab}
</div> </div>
); );
function getTab( function getTab(creationType: CreationType) {
creationType:
| 'agent'
| 'api'
| 'socket'
| 'edgeAgentStandard'
| 'edgeAgentAsync'
) {
switch (creationType) { switch (creationType) {
case 'agent': case 'agent':
return ( return (
@@ -3,6 +3,7 @@ import { render, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery'; import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
import { withTestRouter } from '@/react/test-utils/withRouter';
import { server, http } from '@/setup-tests/server'; import { server, http } from '@/setup-tests/server';
import { WizardKubernetes } from './WizardKubernetes'; import { WizardKubernetes } from './WizardKubernetes';
@@ -37,9 +38,10 @@ function renderComponent() {
) )
); );
const Wrapped = withTestQueryProvider(() => ( const WithRouter = withTestRouter(() => (
<WizardKubernetes onCreate={() => {}} /> <WizardKubernetes onCreate={() => {}} />
)); ));
const Wrapped = withTestQueryProvider(WithRouter);
return render(<Wrapped />); return render(<Wrapped />);
} }
@@ -15,6 +15,10 @@ import EdgeAgentAsyncIcon from '@/react/edge/components/edge-agent-async.svg?c';
import { BoxSelectorOption } from '@@/BoxSelector/types'; import { BoxSelectorOption } from '@@/BoxSelector/types';
import { BoxSelector } from '@@/BoxSelector'; import { BoxSelector } from '@@/BoxSelector';
import { BEOverlay } from '@@/BEFeatureIndicator/BEOverlay'; import { BEOverlay } from '@@/BEFeatureIndicator/BEOverlay';
import { FormSection } from '@@/form-components/FormSection';
import { Badge } from '@@/Badge';
import { ExternalLink } from '@@/ExternalLink';
import { useDocsUrl } from '@@/PageHeader/ContextHelp';
import { AnalyticsStateKey } from '../types'; import { AnalyticsStateKey } from '../types';
import { EdgeAgentTab } from '../shared/EdgeAgentTab'; import { EdgeAgentTab } from '../shared/EdgeAgentTab';
@@ -32,21 +36,26 @@ type CreationType =
| 'agent' | 'agent'
| 'kubeconfig'; | 'kubeconfig';
const options: BoxSelectorOption<CreationType>[] = _.compact([ const primaryOptions: BoxSelectorOption<CreationType>[] = _.compact([
{
id: 'agent_endpoint',
icon: Zap,
iconType: 'badge',
label: 'Agent',
value: 'agent',
description: '',
},
{ {
id: 'edgeAgentStandard', id: 'edgeAgentStandard',
icon: EdgeAgentStandardIcon, icon: EdgeAgentStandardIcon,
iconType: 'badge', iconType: 'badge',
label: 'Edge Agent Standard', label: 'Edge Agent Standard',
description: '', description: (
<>
<span>
<Badge type="infoSecondary">Recommended</Badge>{' '}
<Badge type="infoSecondary">Supports Policies</Badge>
</span>
<span className="mt-1 block">
The remote environment will initiate connections to the Portainer
server, with the ability to open a secure on-demand tunnel for
real-time interaction. The Portainer server must be accessible from
the Edge Agent environment.
</span>
</>
),
value: 'edgeAgentStandard', value: 'edgeAgentStandard',
}, },
isBE && { isBE && {
@@ -54,22 +63,40 @@ const options: BoxSelectorOption<CreationType>[] = _.compact([
icon: EdgeAgentAsyncIcon, icon: EdgeAgentAsyncIcon,
iconType: 'badge', iconType: 'badge',
label: 'Edge Agent Async', label: 'Edge Agent Async',
description: '', description:
'The remote environment will initiate connections to the Portainer server, without the ability to open a real-time tunnel. The Portainer server must be accessible from the Edge Agent environment.',
value: 'edgeAgentAsync', value: 'edgeAgentAsync',
}, },
]);
const legacyOptions: BoxSelectorOption<CreationType>[] = [
{
id: 'agent_endpoint',
icon: Zap,
iconType: 'badge',
label: 'Agent',
value: 'agent',
description:
'The Portainer Server will initiate connections to the remote environment. The agent on the remote environment must be accessible from the Portainer server environment.',
},
{ {
id: 'kubeconfig_endpoint', id: 'kubeconfig_endpoint',
icon: UploadCloud, icon: UploadCloud,
iconType: 'badge', iconType: 'badge',
label: 'Import', label: 'Import',
value: 'kubeconfig', value: 'kubeconfig',
description: 'Import an existing Kubernetes config', description: 'Import an existing Kubernetes config.',
feature: FeatureId.K8S_CREATE_FROM_KUBECONFIG, feature: FeatureId.K8S_CREATE_FROM_KUBECONFIG,
}, },
]); ];
export function WizardKubernetes({ onCreate }: Props) { export function WizardKubernetes({ onCreate }: Props) {
const [creationType, setCreationType] = useState(options[0].value); const edgeAgentDocsUrl = useDocsUrl(
'/faqs/getting-started/why-do-we-recommend-using-the-edge-agent-instead-of-the-traditional-agent'
);
const [creationType, setCreationType] = useState<CreationType>(
primaryOptions[0].value
);
const tab = getTab(creationType); const tab = getTab(creationType);
@@ -77,11 +104,38 @@ export function WizardKubernetes({ onCreate }: Props) {
<div className="form-horizontal"> <div className="form-horizontal">
<BoxSelector <BoxSelector
onChange={(v) => setCreationType(v)} onChange={(v) => setCreationType(v)}
options={options} options={primaryOptions}
value={creationType} value={creationType}
radioName="creation-type" radioName="creation-type"
className="!-mb-2"
/> />
<FormSection
key="legacy-options"
title="More options"
titleSize="sm"
isFoldable
defaultFolded={false}
className="[&>label]:mb-5"
>
<p className="text-xs text-muted mb-2">
These are legacy options that don&apos;t support edge features or
policy management. For most use cases,{' '}
<ExternalLink
to={edgeAgentDocsUrl}
data-cy="wizard-edge-agent-docs-link"
>
the Edge Agent is recommended
</ExternalLink>
</p>
<BoxSelector
onChange={(v) => setCreationType(v)}
options={legacyOptions}
value={creationType}
radioName="creation-type"
/>
</FormSection>
{tab} {tab}
</div> </div>
); );
@@ -14,6 +14,10 @@ import EdgeAgentAsyncIcon from '@/react/edge/components/edge-agent-async.svg?c';
import { BoxSelector, type BoxSelectorOption } from '@@/BoxSelector'; import { BoxSelector, type BoxSelectorOption } from '@@/BoxSelector';
import { BadgeIcon } from '@@/BadgeIcon'; import { BadgeIcon } from '@@/BadgeIcon';
import { TextTip } from '@@/Tip/TextTip'; import { TextTip } from '@@/Tip/TextTip';
import { FormSection } from '@@/form-components/FormSection';
import { Badge } from '@@/Badge';
import { ExternalLink } from '@@/ExternalLink';
import { useDocsUrl } from '@@/PageHeader/ContextHelp';
import { AnalyticsStateKey } from '../types'; import { AnalyticsStateKey } from '../types';
import { EdgeAgentTab } from '../shared/EdgeAgentTab'; import { EdgeAgentTab } from '../shared/EdgeAgentTab';
@@ -25,43 +29,66 @@ interface Props {
onCreate(environment: Environment, analytics: AnalyticsStateKey): void; onCreate(environment: Environment, analytics: AnalyticsStateKey): void;
} }
const options: BoxSelectorOption< type CreationType = 'agent' | 'socket' | 'edgeAgentStandard' | 'edgeAgentAsync';
'agent' | 'api' | 'socket' | 'edgeAgentStandard' | 'edgeAgentAsync'
>[] = _.compact([ const primaryOptions: BoxSelectorOption<CreationType>[] = _.compact([
{ {
id: 'edgeAgentStandard', id: 'edgeAgentStandard',
icon: <BadgeIcon icon={EdgeAgentStandardIcon} size="3xl" />, icon: <BadgeIcon icon={EdgeAgentStandardIcon} size="3xl" />,
label: 'Edge Agent Standard', label: 'Edge Agent Standard',
description: '', description: (
<>
<span>
<Badge type="infoSecondary">Recommended</Badge>{' '}
<Badge type="infoSecondary">Supports Policies</Badge>
</span>
<span className="mt-1 block">
The remote environment will initiate connections to the Portainer
server, with the ability to open a secure on-demand tunnel for
real-time interaction. The Portainer server must be accessible from
the Edge Agent environment.
</span>
</>
),
value: 'edgeAgentStandard', value: 'edgeAgentStandard',
}, },
isBE && { isBE && {
id: 'edgeAgentAsync', id: 'edgeAgentAsync',
icon: <BadgeIcon icon={EdgeAgentAsyncIcon} size="3xl" />, icon: <BadgeIcon icon={EdgeAgentAsyncIcon} size="3xl" />,
label: 'Edge Agent Async', label: 'Edge Agent Async',
description: '', description:
'The remote environment will initiate connections to the Portainer server, without the ability to open a real-time tunnel. The Portainer server must be accessible from the Edge Agent environment.',
value: 'edgeAgentAsync', value: 'edgeAgentAsync',
}, },
]);
const legacyOptions: BoxSelectorOption<CreationType>[] = [
{ {
id: 'agent', id: 'agent',
icon: <BadgeIcon icon={Zap} size="3xl" />, icon: <BadgeIcon icon={Zap} size="3xl" />,
label: 'Agent', label: 'Agent',
description: '', description:
'The Portainer Server will initiate connections to the remote environment. The agent on the remote environment must be accessible from the Portainer server environment.',
value: 'agent', value: 'agent',
}, },
{ {
id: 'socket', id: 'socket',
icon: <BadgeIcon icon={Plug2} size="3xl" />, icon: <BadgeIcon icon={Plug2} size="3xl" />,
label: 'Socket', label: 'Socket',
description: '', description: 'Connect to the environment directly via the Docker socket.',
value: 'socket', value: 'socket',
}, },
]); ];
const containerEngine = ContainerEngine.Podman; const containerEngine = ContainerEngine.Podman;
export function WizardPodman({ onCreate }: Props) { export function WizardPodman({ onCreate }: Props) {
const [creationType, setCreationType] = useState(options[0].value); const edgeAgentDocsUrl = useDocsUrl(
'/faqs/getting-started/why-do-we-recommend-using-the-edge-agent-instead-of-the-traditional-agent'
);
const [creationType, setCreationType] = useState<CreationType>(
primaryOptions[0].value
);
const tab = getTab(creationType); const tab = getTab(creationType);
@@ -69,10 +96,38 @@ export function WizardPodman({ onCreate }: Props) {
<div className="form-horizontal"> <div className="form-horizontal">
<BoxSelector <BoxSelector
onChange={(v) => setCreationType(v)} onChange={(v) => setCreationType(v)}
options={options} options={primaryOptions}
value={creationType} value={creationType}
radioName="creation-type" radioName="creation-type"
className="!-mb-2"
/> />
<FormSection
key="legacy-options"
title="More options"
titleSize="sm"
isFoldable
defaultFolded={false}
className="[&>label]:mb-5"
>
<p className="text-xs text-muted mb-2">
These are legacy options that don&apos;t support edge features or
policy management. For most use cases,{' '}
<ExternalLink
to={edgeAgentDocsUrl}
data-cy="wizard-edge-agent-docs-link"
>
the Edge Agent is recommended
</ExternalLink>
</p>
<BoxSelector
onChange={(v) => setCreationType(v)}
options={legacyOptions}
value={creationType}
radioName="creation-type"
/>
</FormSection>
<TextTip color="orange" className="mb-2" inline={false}> <TextTip color="orange" className="mb-2" inline={false}>
Currently, Portainer only supports <b>Podman 5</b> running in rootful Currently, Portainer only supports <b>Podman 5</b> running in rootful
(privileged) mode on <b>CentOS 9</b> Linux environments. Rootless mode (privileged) mode on <b>CentOS 9</b> Linux environments. Rootless mode
@@ -82,14 +137,7 @@ export function WizardPodman({ onCreate }: Props) {
</div> </div>
); );
function getTab( function getTab(creationType: CreationType) {
creationType:
| 'agent'
| 'api'
| 'socket'
| 'edgeAgentStandard'
| 'edgeAgentAsync'
) {
switch (creationType) { switch (creationType) {
case 'agent': case 'agent':
return ( return (