feat(podman): support add podman envs in the wizard [r8s-20] (#12056)
After Width: | Height: | Size: 4.1 KiB |
After Width: | Height: | Size: 5.8 KiB |
After Width: | Height: | Size: 12 KiB |
After Width: | Height: | Size: 11 KiB |
After Width: | Height: | Size: 16 KiB |
Before Width: | Height: | Size: 1.1 KiB |
Before Width: | Height: | Size: 3.0 KiB |
@ -0,0 +1,302 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
createColumnHelper,
|
||||
createTable,
|
||||
getCoreRowModel,
|
||||
} from '@tanstack/react-table';
|
||||
|
||||
import { Datatable, defaultGlobalFilterFn, Props } from './Datatable';
|
||||
import {
|
||||
BasicTableSettings,
|
||||
createPersistedStore,
|
||||
refreshableSettings,
|
||||
RefreshableTableSettings,
|
||||
} from './types';
|
||||
import { useTableState } from './useTableState';
|
||||
|
||||
// Mock data and dependencies
|
||||
type MockData = { id: string; name: string; age: number };
|
||||
const mockData = [
|
||||
{ id: '1', name: 'John Doe', age: 30 },
|
||||
{ id: '2', name: 'Jane Smith', age: 25 },
|
||||
{ id: '3', name: 'Bob Johnson', age: 35 },
|
||||
];
|
||||
|
||||
const mockColumns = [
|
||||
{ accessorKey: 'name', header: 'Name' },
|
||||
{ accessorKey: 'age', header: 'Age' },
|
||||
];
|
||||
|
||||
// mock table settings / state
|
||||
export interface TableSettings
|
||||
extends BasicTableSettings,
|
||||
RefreshableTableSettings {}
|
||||
function createStore(storageKey: string) {
|
||||
return createPersistedStore<TableSettings>(storageKey, 'name', (set) => ({
|
||||
...refreshableSettings(set),
|
||||
}));
|
||||
}
|
||||
const storageKey = 'test-table';
|
||||
const settingsStore = createStore(storageKey);
|
||||
const mockSettingsManager = {
|
||||
pageSize: 10,
|
||||
search: '',
|
||||
sortBy: undefined,
|
||||
setSearch: vitest.fn(),
|
||||
setSortBy: vitest.fn(),
|
||||
setPageSize: vitest.fn(),
|
||||
};
|
||||
|
||||
function DatatableWithStore(props: Omit<Props<MockData>, 'settingsManager'>) {
|
||||
const tableState = useTableState(settingsStore, storageKey);
|
||||
return (
|
||||
<Datatable {...props} settingsManager={tableState} data-cy="test-table" />
|
||||
);
|
||||
}
|
||||
|
||||
describe('Datatable', () => {
|
||||
it('renders the table with correct data', () => {
|
||||
render(
|
||||
<DatatableWithStore
|
||||
dataset={mockData}
|
||||
columns={mockColumns}
|
||||
data-cy="test-table"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('John Doe')).toBeInTheDocument();
|
||||
expect(screen.getByText('Jane Smith')).toBeInTheDocument();
|
||||
expect(screen.getByText('Bob Johnson')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the table with a title', () => {
|
||||
render(
|
||||
<DatatableWithStore
|
||||
dataset={mockData}
|
||||
columns={mockColumns}
|
||||
title="Test Table"
|
||||
data-cy="test-table"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Test Table')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles row selection when not disabled', () => {
|
||||
render(
|
||||
<DatatableWithStore
|
||||
dataset={mockData}
|
||||
columns={mockColumns}
|
||||
data-cy="test-table"
|
||||
/>
|
||||
);
|
||||
|
||||
const checkboxes = screen.getAllByRole('checkbox');
|
||||
fireEvent.click(checkboxes[1]); // Select the first row
|
||||
|
||||
// Check if the row is selected (you might need to adapt this based on your implementation)
|
||||
expect(checkboxes[1]).toBeChecked();
|
||||
});
|
||||
|
||||
it('disables row selection when disableSelect is true', () => {
|
||||
render(
|
||||
<DatatableWithStore
|
||||
dataset={mockData}
|
||||
columns={mockColumns}
|
||||
disableSelect
|
||||
data-cy="test-table"
|
||||
/>
|
||||
);
|
||||
|
||||
const checkboxes = screen.queryAllByRole('checkbox');
|
||||
expect(checkboxes.length).toBe(0);
|
||||
});
|
||||
|
||||
it('handles sorting', () => {
|
||||
render(
|
||||
<Datatable
|
||||
dataset={mockData}
|
||||
columns={mockColumns}
|
||||
settingsManager={mockSettingsManager}
|
||||
data-cy="test-table"
|
||||
/>
|
||||
);
|
||||
|
||||
const nameHeader = screen.getByText('Name');
|
||||
fireEvent.click(nameHeader);
|
||||
|
||||
// Check if setSortBy was called with the correct arguments
|
||||
expect(mockSettingsManager.setSortBy).toHaveBeenCalledWith('name', true);
|
||||
});
|
||||
|
||||
it('renders loading state', () => {
|
||||
render(
|
||||
<DatatableWithStore
|
||||
dataset={mockData}
|
||||
columns={mockColumns}
|
||||
isLoading
|
||||
data-cy="test-table"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Loading...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders empty state', () => {
|
||||
render(
|
||||
<DatatableWithStore
|
||||
dataset={[]}
|
||||
columns={mockColumns}
|
||||
emptyContentLabel="No data available"
|
||||
data-cy="test-table"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('No data available')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// Test the defaultGlobalFilterFn used in searches
|
||||
type Person = {
|
||||
id: string;
|
||||
name: string;
|
||||
age: number;
|
||||
isEmployed: boolean;
|
||||
tags?: string[];
|
||||
city?: string;
|
||||
family?: { sister: string; uncles?: string[] };
|
||||
};
|
||||
const data: Person[] = [
|
||||
{
|
||||
// searching primitives should be supported
|
||||
id: '1',
|
||||
name: 'Alice',
|
||||
age: 30,
|
||||
isEmployed: true,
|
||||
// supporting arrays of primitives should be supported
|
||||
tags: ['music', 'likes-pixar'],
|
||||
// supporting objects of primitives should be supported (values only).
|
||||
// but shouldn't be support nested objects / arrays
|
||||
family: { sister: 'sophie', uncles: ['john', 'david'] },
|
||||
},
|
||||
];
|
||||
const columnHelper = createColumnHelper<Person>();
|
||||
const columns = [
|
||||
columnHelper.accessor('name', {
|
||||
id: 'name',
|
||||
}),
|
||||
columnHelper.accessor('isEmployed', {
|
||||
id: 'isEmployed',
|
||||
}),
|
||||
columnHelper.accessor('age', {
|
||||
id: 'age',
|
||||
}),
|
||||
columnHelper.accessor('tags', {
|
||||
id: 'tags',
|
||||
}),
|
||||
columnHelper.accessor('family', {
|
||||
id: 'family',
|
||||
}),
|
||||
];
|
||||
const mockTable = createTable({
|
||||
columns,
|
||||
data,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
state: {},
|
||||
onStateChange() {},
|
||||
renderFallbackValue: undefined,
|
||||
getRowId: (row) => row.id,
|
||||
});
|
||||
const mockRow = mockTable.getRow('1');
|
||||
|
||||
describe('defaultGlobalFilterFn', () => {
|
||||
it('should return true when filterValue is null', () => {
|
||||
const result = defaultGlobalFilterFn(mockRow, 'Name', null);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when filterValue.search is empty', () => {
|
||||
const result = defaultGlobalFilterFn(mockRow, 'Name', {
|
||||
search: '',
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should filter string values correctly', () => {
|
||||
expect(
|
||||
defaultGlobalFilterFn(mockRow, 'name', {
|
||||
search: 'hello',
|
||||
})
|
||||
).toBe(false);
|
||||
expect(
|
||||
defaultGlobalFilterFn(mockRow, 'name', {
|
||||
search: 'ALICE',
|
||||
})
|
||||
).toBe(true);
|
||||
expect(
|
||||
defaultGlobalFilterFn(mockRow, 'name', {
|
||||
search: 'Alice',
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should filter number values correctly', () => {
|
||||
expect(defaultGlobalFilterFn(mockRow, 'age', { search: '123' })).toBe(
|
||||
false
|
||||
);
|
||||
expect(defaultGlobalFilterFn(mockRow, 'age', { search: '30' })).toBe(true);
|
||||
expect(defaultGlobalFilterFn(mockRow, 'age', { search: '67' })).toBe(false);
|
||||
});
|
||||
|
||||
it('should filter boolean values correctly', () => {
|
||||
expect(
|
||||
defaultGlobalFilterFn(mockRow, 'isEmployed', { search: 'true' })
|
||||
).toBe(true);
|
||||
expect(
|
||||
defaultGlobalFilterFn(mockRow, 'isEmployed', { search: 'false' })
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should filter object values correctly', () => {
|
||||
expect(defaultGlobalFilterFn(mockRow, 'family', { search: 'sophie' })).toBe(
|
||||
true
|
||||
);
|
||||
expect(defaultGlobalFilterFn(mockRow, 'family', { search: '30' })).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it('should filter array values correctly', () => {
|
||||
expect(defaultGlobalFilterFn(mockRow, 'tags', { search: 'music' })).toBe(
|
||||
true
|
||||
);
|
||||
expect(
|
||||
defaultGlobalFilterFn(mockRow, 'tags', { search: 'Likes-Pixar' })
|
||||
).toBe(true);
|
||||
expect(defaultGlobalFilterFn(mockRow, 'tags', { search: 'grape' })).toBe(
|
||||
false
|
||||
);
|
||||
expect(defaultGlobalFilterFn(mockRow, 'tags', { search: 'likes' })).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle complex nested structures', () => {
|
||||
expect(defaultGlobalFilterFn(mockRow, 'family', { search: 'sophie' })).toBe(
|
||||
true
|
||||
);
|
||||
expect(defaultGlobalFilterFn(mockRow, 'family', { search: 'mason' })).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it('should not filter non-primitive values within objects and arrays', () => {
|
||||
expect(defaultGlobalFilterFn(mockRow, 'family', { search: 'john' })).toBe(
|
||||
false
|
||||
);
|
||||
expect(defaultGlobalFilterFn(mockRow, 'family', { search: 'david' })).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
});
|
@ -0,0 +1,147 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { DockerNetwork } from '@/react/docker/networks/types';
|
||||
|
||||
import { ContainerListViewModel } from '../../types';
|
||||
import { ContainerDetailsJSON } from '../../queries/useContainer';
|
||||
|
||||
import { getDefaultViewModel, getNetworkMode } from './toViewModel';
|
||||
|
||||
describe('getDefaultViewModel', () => {
|
||||
it('should return the correct default view model for Windows', () => {
|
||||
const result = getDefaultViewModel(true);
|
||||
expect(result).toEqual({
|
||||
networkMode: 'nat',
|
||||
hostname: '',
|
||||
domain: '',
|
||||
macAddress: '',
|
||||
ipv4Address: '',
|
||||
ipv6Address: '',
|
||||
primaryDns: '',
|
||||
secondaryDns: '',
|
||||
hostsFileEntries: [],
|
||||
container: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return the correct default view model for Podman', () => {
|
||||
const result = getDefaultViewModel(false, true);
|
||||
expect(result).toEqual({
|
||||
networkMode: 'podman',
|
||||
hostname: '',
|
||||
domain: '',
|
||||
macAddress: '',
|
||||
ipv4Address: '',
|
||||
ipv6Address: '',
|
||||
primaryDns: '',
|
||||
secondaryDns: '',
|
||||
hostsFileEntries: [],
|
||||
container: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return the correct default view model for Linux Docker', () => {
|
||||
const result = getDefaultViewModel(false);
|
||||
expect(result).toEqual({
|
||||
networkMode: 'bridge',
|
||||
hostname: '',
|
||||
domain: '',
|
||||
macAddress: '',
|
||||
ipv4Address: '',
|
||||
ipv6Address: '',
|
||||
primaryDns: '',
|
||||
secondaryDns: '',
|
||||
hostsFileEntries: [],
|
||||
container: '',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNetworkMode', () => {
|
||||
const mockNetworks: Array<DockerNetwork> = [
|
||||
{
|
||||
Name: 'bridge',
|
||||
Id: 'bridge-id',
|
||||
Driver: 'bridge',
|
||||
Scope: 'local',
|
||||
Attachable: false,
|
||||
Internal: false,
|
||||
IPAM: { Config: [], Driver: '', Options: {} },
|
||||
Options: {},
|
||||
Containers: {},
|
||||
},
|
||||
{
|
||||
Name: 'host',
|
||||
Id: 'host-id',
|
||||
Driver: 'host',
|
||||
Scope: 'local',
|
||||
Attachable: false,
|
||||
Internal: false,
|
||||
IPAM: { Config: [], Driver: '', Options: {} },
|
||||
Options: {},
|
||||
Containers: {},
|
||||
},
|
||||
{
|
||||
Name: 'custom',
|
||||
Id: 'custom-id',
|
||||
Driver: 'bridge',
|
||||
Scope: 'local',
|
||||
Attachable: true,
|
||||
Internal: false,
|
||||
IPAM: { Config: [], Driver: '', Options: {} },
|
||||
Options: {},
|
||||
Containers: {},
|
||||
},
|
||||
];
|
||||
|
||||
const mockRunningContainers: Array<ContainerListViewModel> = [
|
||||
{
|
||||
Id: 'container-1',
|
||||
Names: ['container-1-name'],
|
||||
} as ContainerListViewModel, // gaslight the type to avoid over-specifying
|
||||
];
|
||||
|
||||
it('should return the network mode from HostConfig', () => {
|
||||
const config: ContainerDetailsJSON = {
|
||||
HostConfig: { NetworkMode: 'host' },
|
||||
};
|
||||
expect(getNetworkMode(config, mockNetworks)).toEqual(['host']);
|
||||
});
|
||||
|
||||
it('should return the network mode from NetworkSettings if HostConfig is empty', () => {
|
||||
const config: ContainerDetailsJSON = {
|
||||
NetworkSettings: { Networks: { custom: {} } },
|
||||
};
|
||||
expect(getNetworkMode(config, mockNetworks)).toEqual(['custom']);
|
||||
});
|
||||
|
||||
it('should return container mode when NetworkMode starts with "container:"', () => {
|
||||
const config: ContainerDetailsJSON = {
|
||||
HostConfig: { NetworkMode: 'container:container-1' },
|
||||
};
|
||||
expect(getNetworkMode(config, mockNetworks, mockRunningContainers)).toEqual(
|
||||
['container', 'container-1-name']
|
||||
);
|
||||
});
|
||||
|
||||
it('should return "podman" for bridge network when isPodman is true', () => {
|
||||
const config: ContainerDetailsJSON = {
|
||||
HostConfig: { NetworkMode: 'bridge' },
|
||||
};
|
||||
expect(getNetworkMode(config, mockNetworks, [], true)).toEqual(['podman']);
|
||||
});
|
||||
|
||||
it('should return "bridge" for default network mode on Docker', () => {
|
||||
const config: ContainerDetailsJSON = {
|
||||
HostConfig: { NetworkMode: 'default' },
|
||||
};
|
||||
expect(getNetworkMode(config, mockNetworks)).toEqual(['bridge']);
|
||||
});
|
||||
|
||||
it('should return the first available network if no matching network is found', () => {
|
||||
const config: ContainerDetailsJSON = {
|
||||
HostConfig: { NetworkMode: 'non-existent' },
|
||||
};
|
||||
expect(getNetworkMode(config, mockNetworks)).toEqual(['bridge']);
|
||||
});
|
||||
});
|
@ -1,58 +1,83 @@
|
||||
import { ColumnDef } from '@tanstack/react-table';
|
||||
import { List } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
import { useCurrentStateAndParams } from '@uirouter/react';
|
||||
|
||||
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||
|
||||
import { Datatable } from '@@/datatables';
|
||||
import { createPersistedStore } from '@@/datatables/types';
|
||||
import { useTableState } from '@@/datatables/useTableState';
|
||||
|
||||
import { useContainerTop } from '../queries/useContainerTop';
|
||||
import { ContainerProcesses } from '../queries/types';
|
||||
|
||||
const tableKey = 'container-processes';
|
||||
const store = createPersistedStore(tableKey);
|
||||
|
||||
export function ProcessesDatatable({
|
||||
dataset,
|
||||
headers,
|
||||
}: {
|
||||
dataset?: Array<Array<string | number>>;
|
||||
headers?: Array<string>;
|
||||
}) {
|
||||
const tableState = useTableState(store, tableKey);
|
||||
const rows = useMemo(() => {
|
||||
if (!dataset || !headers) {
|
||||
return [];
|
||||
}
|
||||
type ProcessRow = {
|
||||
id: number;
|
||||
};
|
||||
|
||||
return dataset.map((row, index) => ({
|
||||
id: index,
|
||||
...Object.fromEntries(
|
||||
headers.map((header, index) => [header, row[index]])
|
||||
),
|
||||
}));
|
||||
}, [dataset, headers]);
|
||||
type ProcessesDatatableProps = {
|
||||
rows: Array<ProcessRow>;
|
||||
columns: Array<ColumnDef<ProcessRow>>;
|
||||
};
|
||||
|
||||
const columns = useMemo(
|
||||
() =>
|
||||
headers
|
||||
? headers.map(
|
||||
(header) =>
|
||||
({ header, accessorKey: header }) satisfies ColumnDef<{
|
||||
[k: string]: string;
|
||||
}>
|
||||
)
|
||||
: [],
|
||||
[headers]
|
||||
export function ProcessesDatatable() {
|
||||
const {
|
||||
params: { id: containerId },
|
||||
} = useCurrentStateAndParams();
|
||||
const environmentId = useEnvironmentId();
|
||||
const topQuery = useContainerTop(
|
||||
environmentId,
|
||||
containerId,
|
||||
(containerProcesses: ContainerProcesses) =>
|
||||
parseContainerProcesses(containerProcesses)
|
||||
);
|
||||
const tableState = useTableState(store, tableKey);
|
||||
|
||||
return (
|
||||
<Datatable
|
||||
title="Processes"
|
||||
titleIcon={List}
|
||||
dataset={rows}
|
||||
columns={columns}
|
||||
dataset={topQuery.data?.rows ?? []}
|
||||
columns={topQuery.data?.columns ?? []}
|
||||
settingsManager={tableState}
|
||||
disableSelect
|
||||
isLoading={!dataset}
|
||||
isLoading={topQuery.isLoading}
|
||||
data-cy="docker-container-stats-processes-datatable"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// transform the data from the API into the format expected by the datatable
|
||||
function parseContainerProcesses(
|
||||
containerProcesses: ContainerProcesses
|
||||
): ProcessesDatatableProps {
|
||||
const { Processes: processes, Titles: titles } = containerProcesses;
|
||||
const rows = processes?.map((row, index) => {
|
||||
// docker has the row data as an array of many strings
|
||||
// podman has the row data as an array with a single string separated by one or many spaces
|
||||
const processArray = row.length === 1 ? row[0].split(/\s+/) : row;
|
||||
return {
|
||||
id: index,
|
||||
...Object.fromEntries(
|
||||
titles.map((header, index) => [header, processArray[index]])
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
const columns = titles
|
||||
? titles.map(
|
||||
(header) =>
|
||||
({ header, accessorKey: header }) satisfies ColumnDef<{
|
||||
[k: string]: string;
|
||||
}>
|
||||
)
|
||||
: [];
|
||||
|
||||
return {
|
||||
rows,
|
||||
columns,
|
||||
};
|
||||
}
|
||||
|
@ -0,0 +1,51 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { fullURIIntoRepoAndTag } from './utils';
|
||||
|
||||
describe('fullURIIntoRepoAndTag', () => {
|
||||
it('splits registry/image-repo:tag correctly', () => {
|
||||
const result = fullURIIntoRepoAndTag('registry.example.com/my-image:v1.0');
|
||||
expect(result).toEqual({
|
||||
repo: 'registry.example.com/my-image',
|
||||
tag: 'v1.0',
|
||||
});
|
||||
});
|
||||
|
||||
it('splits image-repo:tag correctly', () => {
|
||||
const result = fullURIIntoRepoAndTag('nginx:latest');
|
||||
expect(result).toEqual({ repo: 'nginx', tag: 'latest' });
|
||||
});
|
||||
|
||||
it('splits registry:port/image-repo:tag correctly', () => {
|
||||
const result = fullURIIntoRepoAndTag(
|
||||
'registry.example.com:5000/my-image:v2.1'
|
||||
);
|
||||
expect(result).toEqual({
|
||||
repo: 'registry.example.com:5000/my-image',
|
||||
tag: 'v2.1',
|
||||
});
|
||||
});
|
||||
|
||||
it('handles empty string input', () => {
|
||||
const result = fullURIIntoRepoAndTag('');
|
||||
expect(result).toEqual({ repo: '', tag: 'latest' });
|
||||
});
|
||||
|
||||
it('handles input with multiple colons', () => {
|
||||
const result = fullURIIntoRepoAndTag('registry:5000/namespace/image:v1.0');
|
||||
expect(result).toEqual({
|
||||
repo: 'registry:5000/namespace/image',
|
||||
tag: 'v1.0',
|
||||
});
|
||||
});
|
||||
|
||||
it('handles input with @ symbol (digest)', () => {
|
||||
const result = fullURIIntoRepoAndTag(
|
||||
'myregistry.azurecr.io/image@sha256:123456'
|
||||
);
|
||||
expect(result).toEqual({
|
||||
repo: 'myregistry.azurecr.io/image@sha256',
|
||||
tag: '123456',
|
||||
});
|
||||
});
|
||||
});
|
@ -1,28 +1,41 @@
|
||||
import { CellContext } from '@tanstack/react-table';
|
||||
|
||||
import { environmentTypeIcon } from '@/portainer/filters/filters';
|
||||
import {
|
||||
Environment,
|
||||
EnvironmentType,
|
||||
} from '@/react/portainer/environments/types';
|
||||
import { getPlatformTypeName } from '@/react/portainer/environments/utils';
|
||||
getEnvironmentTypeIcon,
|
||||
getPlatformTypeName,
|
||||
} from '@/react/portainer/environments/utils';
|
||||
|
||||
import { Icon } from '@@/Icon';
|
||||
|
||||
import { EnvironmentListItem } from '../types';
|
||||
import { EnvironmentType, ContainerEngine } from '../../types';
|
||||
|
||||
import { columnHelper } from './helper';
|
||||
|
||||
export const type = columnHelper.accessor('Type', {
|
||||
type TypeCellContext = {
|
||||
type: EnvironmentType;
|
||||
containerEngine?: ContainerEngine;
|
||||
};
|
||||
|
||||
export const type = columnHelper.accessor(
|
||||
(rowItem): TypeCellContext => ({
|
||||
type: rowItem.Type,
|
||||
containerEngine: rowItem.ContainerEngine,
|
||||
}),
|
||||
{
|
||||
header: 'Type',
|
||||
cell: Cell,
|
||||
});
|
||||
id: 'Type',
|
||||
}
|
||||
);
|
||||
|
||||
function Cell({ getValue }: CellContext<Environment, EnvironmentType>) {
|
||||
const type = getValue();
|
||||
function Cell({ getValue }: CellContext<EnvironmentListItem, TypeCellContext>) {
|
||||
const { type, containerEngine } = getValue();
|
||||
|
||||
return (
|
||||
<span className="flex items-center gap-1">
|
||||
<Icon icon={environmentTypeIcon(type)} />
|
||||
{getPlatformTypeName(type)}
|
||||
<Icon icon={getEnvironmentTypeIcon(type, containerEngine)} />
|
||||
{getPlatformTypeName(type, containerEngine)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
@ -0,0 +1,15 @@
|
||||
import { ContainerEngine, EnvironmentId } from '../types';
|
||||
|
||||
import { useEnvironment } from './useEnvironment';
|
||||
|
||||
/**
|
||||
* useIsPodman returns true if the current environment is using podman as container engine.
|
||||
* @returns isPodman boolean, can also be undefined if the environment hasn't loaded yet.
|
||||
*/
|
||||
export function useIsPodman(envId: EnvironmentId) {
|
||||
const { data: isPodman } = useEnvironment(
|
||||
envId,
|
||||
(env) => env.ContainerEngine === ContainerEngine.Podman
|
||||
);
|
||||
return isPodman;
|
||||
}
|
@ -0,0 +1,6 @@
|
||||
export function getDockerEnvironmentType(isSwarm: boolean, isPodman?: boolean) {
|
||||
if (isPodman) {
|
||||
return 'Podman';
|
||||
}
|
||||
return isSwarm ? 'Swarm' : 'Standalone';
|
||||
}
|
@ -1 +1 @@
|
||||
export { EnvironmentTypeSelectView } from './EndpointTypeView';
|
||||
export { EnvironmentTypeSelectView } from './EnvironmentTypeSelectView';
|
||||
|
@ -0,0 +1,27 @@
|
||||
import {
|
||||
ContainerEngine,
|
||||
Environment,
|
||||
} from '@/react/portainer/environments/types';
|
||||
|
||||
import { AgentForm } from '../../shared/AgentForm/AgentForm';
|
||||
|
||||
import { DeploymentScripts } from './DeploymentScripts';
|
||||
|
||||
interface Props {
|
||||
onCreate(environment: Environment): void;
|
||||
}
|
||||
|
||||
export function AgentTab({ onCreate }: Props) {
|
||||
return (
|
||||
<>
|
||||
<DeploymentScripts />
|
||||
|
||||
<div className="mt-5">
|
||||
<AgentForm
|
||||
onCreate={onCreate}
|
||||
containerEngine={ContainerEngine.Podman}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
@ -0,0 +1,86 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useAgentDetails } from '@/react/portainer/environments/queries/useAgentDetails';
|
||||
|
||||
import { CopyButton } from '@@/buttons/CopyButton';
|
||||
import { Code } from '@@/Code';
|
||||
import { NavTabs } from '@@/NavTabs';
|
||||
import { NavContainer } from '@@/NavTabs/NavContainer';
|
||||
|
||||
const deploymentPodman = [
|
||||
{
|
||||
id: 'all',
|
||||
label: 'Linux (CentOS)',
|
||||
command: linuxPodmanCommandRootful,
|
||||
},
|
||||
];
|
||||
|
||||
export function DeploymentScripts() {
|
||||
const deployments = deploymentPodman;
|
||||
const [deployType, setDeployType] = useState(deployments[0].id);
|
||||
|
||||
const agentDetailsQuery = useAgentDetails();
|
||||
|
||||
if (!agentDetailsQuery) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { agentVersion, agentSecret } = agentDetailsQuery;
|
||||
|
||||
const options = deployments.map((c) => {
|
||||
const code = c.command(agentVersion, agentSecret);
|
||||
|
||||
return {
|
||||
id: c.id,
|
||||
label: c.label,
|
||||
children: <DeployCode code={code} />,
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<NavContainer>
|
||||
<NavTabs
|
||||
options={options}
|
||||
onSelect={(id: string) => setDeployType(id)}
|
||||
selectedId={deployType}
|
||||
/>
|
||||
</NavContainer>
|
||||
);
|
||||
}
|
||||
|
||||
interface DeployCodeProps {
|
||||
code: string;
|
||||
}
|
||||
|
||||
function DeployCode({ code }: DeployCodeProps) {
|
||||
return (
|
||||
<>
|
||||
<div className="code-script">
|
||||
<Code>{code}</Code>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<CopyButton copyText={code} data-cy="copy-deployment-script">
|
||||
Copy command
|
||||
</CopyButton>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function linuxPodmanCommandRootful(agentVersion: string, agentSecret: string) {
|
||||
const secret =
|
||||
agentSecret === '' ? '' : `\\\n -e AGENT_SECRET=${agentSecret} `;
|
||||
|
||||
return `sudo systemctl enable --now podman.socket\n
|
||||
sudo podman volume create portainer\n
|
||||
sudo podman run -d \\
|
||||
-p 9001:9001 ${secret}\\
|
||||
--name portainer_agent \\
|
||||
--restart=always \\
|
||||
--privileged \\
|
||||
-v /run/podman/podman.sock:/var/run/docker.sock \\
|
||||
-v /var/lib/containers/storage/volumes:/var/lib/docker/volumes \\
|
||||
-v /:/host \\
|
||||
portainer/agent:${agentVersion}
|
||||
`;
|
||||
}
|
@ -0,0 +1 @@
|
||||
export { AgentTab } from './AgentTab';
|
@ -0,0 +1,68 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { CopyButton } from '@@/buttons/CopyButton';
|
||||
import { Code } from '@@/Code';
|
||||
import { NavTabs } from '@@/NavTabs';
|
||||
import { NavContainer } from '@@/NavTabs/NavContainer';
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
|
||||
const deployments = [
|
||||
{
|
||||
id: 'linux',
|
||||
label: 'Linux (CentOS)',
|
||||
command: `sudo systemctl enable --now podman.socket`,
|
||||
},
|
||||
];
|
||||
|
||||
export function DeploymentScripts() {
|
||||
const [deployType, setDeployType] = useState(deployments[0].id);
|
||||
|
||||
const options = deployments.map((c) => ({
|
||||
id: c.id,
|
||||
label: c.label,
|
||||
children: <DeployCode code={c.command} />,
|
||||
}));
|
||||
|
||||
return (
|
||||
<NavContainer>
|
||||
<NavTabs
|
||||
options={options}
|
||||
onSelect={(id: string) => setDeployType(id)}
|
||||
selectedId={deployType}
|
||||
/>
|
||||
</NavContainer>
|
||||
);
|
||||
}
|
||||
|
||||
interface DeployCodeProps {
|
||||
code: string;
|
||||
}
|
||||
|
||||
function DeployCode({ code }: DeployCodeProps) {
|
||||
const bindMountCode = `-v "/run/podman/podman.sock:/var/run/docker.sock"`;
|
||||
return (
|
||||
<>
|
||||
<TextTip color="blue" className="mb-1">
|
||||
When using the socket, ensure that you have started the Portainer
|
||||
container with the following Podman flag:
|
||||
</TextTip>
|
||||
<Code>{bindMountCode}</Code>
|
||||
<div className="mt-2 mb-4">
|
||||
<CopyButton copyText={bindMountCode} data-cy="copy-deployment-command">
|
||||
Copy command
|
||||
</CopyButton>
|
||||
</div>
|
||||
|
||||
<TextTip color="blue" className="mb-1">
|
||||
To use the socket, ensure that you have started the Podman rootful
|
||||
socket:
|
||||
</TextTip>
|
||||
<Code>{code}</Code>
|
||||
<div className="mt-2">
|
||||
<CopyButton copyText={code} data-cy="copy-deployment-command">
|
||||
Copy command
|
||||
</CopyButton>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
@ -0,0 +1,125 @@
|
||||
import { Field, Form, Formik, useFormikContext } from 'formik';
|
||||
import { useReducer } from 'react';
|
||||
import { Plug2 } from 'lucide-react';
|
||||
|
||||
import { notifySuccess } from '@/portainer/services/notifications';
|
||||
import { useCreateLocalDockerEnvironmentMutation } from '@/react/portainer/environments/queries/useCreateEnvironmentMutation';
|
||||
import {
|
||||
ContainerEngine,
|
||||
Environment,
|
||||
} from '@/react/portainer/environments/types';
|
||||
|
||||
import { LoadingButton } from '@@/buttons/LoadingButton';
|
||||
import { FormControl } from '@@/form-components/FormControl';
|
||||
import { Input } from '@@/form-components/Input';
|
||||
import { SwitchField } from '@@/form-components/SwitchField';
|
||||
|
||||
import { NameField } from '../../shared/NameField';
|
||||
import { MoreSettingsSection } from '../../shared/MoreSettingsSection';
|
||||
|
||||
import { useValidation } from './SocketForm.validation';
|
||||
import { FormValues } from './types';
|
||||
|
||||
interface Props {
|
||||
onCreate(environment: Environment): void;
|
||||
containerEngine: ContainerEngine;
|
||||
}
|
||||
|
||||
export function SocketForm({ onCreate, containerEngine }: Props) {
|
||||
const [formKey, clearForm] = useReducer((state) => state + 1, 0);
|
||||
const initialValues: FormValues = {
|
||||
name: '',
|
||||
socketPath: '',
|
||||
overridePath: false,
|
||||
meta: { groupId: 1, tagIds: [] },
|
||||
};
|
||||
|
||||
const mutation = useCreateLocalDockerEnvironmentMutation();
|
||||
const validation = useValidation();
|
||||
|
||||
return (
|
||||
<Formik
|
||||
initialValues={initialValues}
|
||||
onSubmit={handleSubmit}
|
||||
validationSchema={validation}
|
||||
validateOnMount
|
||||
key={formKey}
|
||||
>
|
||||
{({ isValid, dirty }) => (
|
||||
<Form>
|
||||
<NameField />
|
||||
|
||||
<OverrideSocketFieldset />
|
||||
|
||||
<MoreSettingsSection />
|
||||
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
<LoadingButton
|
||||
className="wizard-connect-button vertical-center"
|
||||
data-cy="docker-socket-connect-button"
|
||||
loadingText="Connecting environment..."
|
||||
isLoading={mutation.isLoading}
|
||||
disabled={!dirty || !isValid}
|
||||
icon={Plug2}
|
||||
>
|
||||
Connect
|
||||
</LoadingButton>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
);
|
||||
|
||||
function handleSubmit(values: FormValues) {
|
||||
mutation.mutate(
|
||||
{
|
||||
name: values.name,
|
||||
socketPath: values.overridePath ? values.socketPath : '',
|
||||
meta: values.meta,
|
||||
containerEngine,
|
||||
},
|
||||
{
|
||||
onSuccess(environment) {
|
||||
notifySuccess('Environment created', environment.Name);
|
||||
clearForm();
|
||||
onCreate(environment);
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function OverrideSocketFieldset() {
|
||||
const { values, setFieldValue, errors } = useFormikContext<FormValues>();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
<SwitchField
|
||||
checked={values.overridePath}
|
||||
data-cy="create-docker-env-socket-override-switch"
|
||||
onChange={(checked) => setFieldValue('overridePath', checked)}
|
||||
label="Override default socket path"
|
||||
labelClass="col-sm-3 col-lg-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{values.overridePath && (
|
||||
<FormControl
|
||||
label="Socket Path"
|
||||
tooltip="Path to the Podman socket. Remember to bind-mount the socket, see the important notice above for more information."
|
||||
errors={errors.socketPath}
|
||||
>
|
||||
<Field
|
||||
name="socketPath"
|
||||
as={Input}
|
||||
placeholder="e.g. /run/podman/podman.sock (on Linux)"
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
@ -0,0 +1,23 @@
|
||||
import { boolean, object, SchemaOf, string } from 'yup';
|
||||
|
||||
import { metadataValidation } from '../../shared/MetadataFieldset/validation';
|
||||
import { useNameValidation } from '../../shared/NameField';
|
||||
|
||||
import { FormValues } from './types';
|
||||
|
||||
export function useValidation(): SchemaOf<FormValues> {
|
||||
return object({
|
||||
name: useNameValidation(),
|
||||
meta: metadataValidation(),
|
||||
overridePath: boolean().default(false),
|
||||
socketPath: string()
|
||||
.default('')
|
||||
.when('overridePath', (overridePath, schema) =>
|
||||
overridePath
|
||||
? schema.required(
|
||||
'Socket Path is required when override path is enabled'
|
||||
)
|
||||
: schema
|
||||
),
|
||||
});
|
||||
}
|
@ -0,0 +1,33 @@
|
||||
import {
|
||||
ContainerEngine,
|
||||
Environment,
|
||||
} from '@/react/portainer/environments/types';
|
||||
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
|
||||
import { DeploymentScripts } from './DeploymentScripts';
|
||||
import { SocketForm } from './SocketForm';
|
||||
|
||||
interface Props {
|
||||
onCreate(environment: Environment): void;
|
||||
}
|
||||
|
||||
export function SocketTab({ onCreate }: Props) {
|
||||
return (
|
||||
<>
|
||||
<TextTip color="orange" className="mb-2" inline={false}>
|
||||
To connect via socket, Portainer server must be running in a Podman
|
||||
container.
|
||||
</TextTip>
|
||||
|
||||
<DeploymentScripts />
|
||||
|
||||
<div className="mt-5">
|
||||
<SocketForm
|
||||
onCreate={onCreate}
|
||||
containerEngine={ContainerEngine.Podman}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
@ -0,0 +1 @@
|
||||
export { SocketTab } from './SocketTab';
|
@ -0,0 +1,8 @@
|
||||
import { EnvironmentMetadata } from '@/react/portainer/environments/environment.service/create';
|
||||
|
||||
export interface FormValues {
|
||||
name: string;
|
||||
socketPath: string;
|
||||
overridePath: boolean;
|
||||
meta: EnvironmentMetadata;
|
||||
}
|
@ -0,0 +1,133 @@
|
||||
import { useState } from 'react';
|
||||
import { Zap, Plug2 } from 'lucide-react';
|
||||
import _ from 'lodash';
|
||||
|
||||
import {
|
||||
ContainerEngine,
|
||||
Environment,
|
||||
} from '@/react/portainer/environments/types';
|
||||
import { commandsTabs } from '@/react/edge/components/EdgeScriptForm/scripts';
|
||||
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
|
||||
import EdgeAgentStandardIcon from '@/react/edge/components/edge-agent-standard.svg?c';
|
||||
import EdgeAgentAsyncIcon from '@/react/edge/components/edge-agent-async.svg?c';
|
||||
|
||||
import { BoxSelector, type BoxSelectorOption } from '@@/BoxSelector';
|
||||
import { BadgeIcon } from '@@/BadgeIcon';
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
|
||||
import { AnalyticsStateKey } from '../types';
|
||||
import { EdgeAgentTab } from '../shared/EdgeAgentTab';
|
||||
|
||||
import { AgentTab } from './AgentTab';
|
||||
import { SocketTab } from './SocketTab';
|
||||
|
||||
interface Props {
|
||||
onCreate(environment: Environment, analytics: AnalyticsStateKey): void;
|
||||
}
|
||||
|
||||
const options: BoxSelectorOption<
|
||||
'agent' | 'api' | 'socket' | 'edgeAgentStandard' | 'edgeAgentAsync'
|
||||
>[] = _.compact([
|
||||
{
|
||||
id: 'agent',
|
||||
icon: <BadgeIcon icon={Zap} size="3xl" />,
|
||||
label: 'Agent',
|
||||
description: '',
|
||||
value: 'agent',
|
||||
},
|
||||
{
|
||||
id: 'socket',
|
||||
icon: <BadgeIcon icon={Plug2} size="3xl" />,
|
||||
label: 'Socket',
|
||||
description: '',
|
||||
value: 'socket',
|
||||
},
|
||||
{
|
||||
id: 'edgeAgentStandard',
|
||||
icon: <BadgeIcon icon={EdgeAgentStandardIcon} size="3xl" />,
|
||||
label: 'Edge Agent Standard',
|
||||
description: '',
|
||||
value: 'edgeAgentStandard',
|
||||
},
|
||||
isBE && {
|
||||
id: 'edgeAgentAsync',
|
||||
icon: <BadgeIcon icon={EdgeAgentAsyncIcon} size="3xl" />,
|
||||
label: 'Edge Agent Async',
|
||||
description: '',
|
||||
value: 'edgeAgentAsync',
|
||||
},
|
||||
]);
|
||||
|
||||
const containerEngine = ContainerEngine.Podman;
|
||||
|
||||
export function WizardPodman({ onCreate }: Props) {
|
||||
const [creationType, setCreationType] = useState(options[0].value);
|
||||
|
||||
const tab = getTab(creationType);
|
||||
|
||||
return (
|
||||
<div className="form-horizontal">
|
||||
<BoxSelector
|
||||
onChange={(v) => setCreationType(v)}
|
||||
options={options}
|
||||
value={creationType}
|
||||
radioName="creation-type"
|
||||
/>
|
||||
<TextTip color="orange" className="mb-2" inline={false}>
|
||||
Currently, Portainer only supports <b>Podman 5</b> running in rootful
|
||||
(privileged) mode on <b>CentOS 9</b> Linux environments. Rootless mode
|
||||
and other Linux distros may work, but aren't officially supported.
|
||||
</TextTip>
|
||||
{tab}
|
||||
</div>
|
||||
);
|
||||
|
||||
function getTab(
|
||||
creationType:
|
||||
| 'agent'
|
||||
| 'api'
|
||||
| 'socket'
|
||||
| 'edgeAgentStandard'
|
||||
| 'edgeAgentAsync'
|
||||
) {
|
||||
switch (creationType) {
|
||||
case 'agent':
|
||||
return (
|
||||
<AgentTab
|
||||
onCreate={(environment) => onCreate(environment, 'podmanAgent')}
|
||||
/>
|
||||
);
|
||||
case 'socket':
|
||||
return (
|
||||
<SocketTab
|
||||
onCreate={(environment) =>
|
||||
onCreate(environment, 'podmanLocalEnvironment')
|
||||
}
|
||||
/>
|
||||
);
|
||||
case 'edgeAgentStandard':
|
||||
return (
|
||||
<EdgeAgentTab
|
||||
onCreate={(environment) =>
|
||||
onCreate(environment, 'podmanEdgeAgentStandard')
|
||||
}
|
||||
commands={[commandsTabs.podmanLinux]}
|
||||
containerEngine={containerEngine}
|
||||
/>
|
||||
);
|
||||
case 'edgeAgentAsync':
|
||||
return (
|
||||
<EdgeAgentTab
|
||||
asyncMode
|
||||
onCreate={(environment) =>
|
||||
onCreate(environment, 'podmanEdgeAgentAsync')
|
||||
}
|
||||
commands={[commandsTabs.podmanLinux]}
|
||||
containerEngine={containerEngine}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1 @@
|
||||
export { WizardPodman } from './WizardPodman';
|