1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-08-02 12:25:22 +02:00

feat(podman): support add podman envs in the wizard [r8s-20] (#12056)
Some checks failed
ci / build_images (map[arch:amd64 platform:linux version:]) (push) Has been cancelled
ci / build_images (map[arch:amd64 platform:windows version:1809]) (push) Has been cancelled
ci / build_images (map[arch:amd64 platform:windows version:ltsc2022]) (push) Has been cancelled
ci / build_images (map[arch:arm platform:linux version:]) (push) Has been cancelled
ci / build_images (map[arch:arm64 platform:linux version:]) (push) Has been cancelled
ci / build_images (map[arch:ppc64le platform:linux version:]) (push) Has been cancelled
/ triage (push) Has been cancelled
Lint / Run linters (push) Has been cancelled
Test / test-client (push) Has been cancelled
Test / test-server (map[arch:amd64 platform:linux]) (push) Has been cancelled
Test / test-server (map[arch:amd64 platform:windows version:1809]) (push) Has been cancelled
Test / test-server (map[arch:amd64 platform:windows version:ltsc2022]) (push) Has been cancelled
Test / test-server (map[arch:arm64 platform:linux]) (push) Has been cancelled
ci / build_manifests (push) Has been cancelled

This commit is contained in:
Ali 2024-09-25 11:55:07 +12:00 committed by GitHub
parent db616bc8a5
commit 32e94d4e4e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
108 changed files with 1921 additions and 272 deletions

View file

@ -46,7 +46,7 @@ export function Tooltip({
position={position}
className={className}
>
<HelpCircle className="lucide" aria-hidden="true" />
<HelpCircle className="lucide" />
</TooltipWithChildren>
</span>
);

View file

@ -25,7 +25,7 @@ export function CopyButton({
fadeDelay = 1000,
displayText = 'copied',
className,
color,
color = 'default',
indicatorPosition = 'right',
children,
'data-cy': dataCy,
@ -52,7 +52,7 @@ export function CopyButton({
<div className={styles.container}>
{indicatorPosition === 'left' && copiedIndicator()}
<Button
className={className}
className={clsx(className, '!ml-0')}
color={color}
size="small"
onClick={handleCopy}

View file

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

View file

@ -272,6 +272,21 @@ export function defaultGlobalFilterFn<D, TFilter extends { search: string }>(
const filterValueLower = filterValue.search.toLowerCase();
if (typeof value === 'object') {
return Object.values(value).some((item) =>
filterPrimitive(item, filterValueLower)
);
}
if (Array.isArray(value)) {
return value.some((item) => filterPrimitive(item, filterValueLower));
}
return filterPrimitive(value, filterValueLower);
}
// only filter primitive values within objects and arrays, to avoid searching nested objects
function filterPrimitive(value: unknown, filterValueLower: string) {
if (
typeof value === 'string' ||
typeof value === 'number' ||
@ -279,13 +294,6 @@ export function defaultGlobalFilterFn<D, TFilter extends { search: string }>(
) {
return value.toString().toLowerCase().includes(filterValueLower);
}
if (Array.isArray(value)) {
return value.some((item) =>
item.toString().toLowerCase().includes(filterValueLower)
);
}
return false;
}

View file

@ -1,6 +1,8 @@
import { ZapIcon } from 'lucide-react';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { getDockerEnvironmentType } from '@/react/portainer/environments/utils/getDockerEnvironmentType';
import { useIsPodman } from '@/react/portainer/environments/queries/useIsPodman';
import { Icon } from '@@/Icon';
@ -9,6 +11,7 @@ import { useInfo } from '../proxy/queries/useInfo';
export function DockerInfo({ isAgent }: { isAgent: boolean }) {
const envId = useEnvironmentId();
const infoQuery = useInfo(envId);
const isPodman = useIsPodman(envId);
if (!infoQuery.data) {
return null;
@ -16,16 +19,22 @@ export function DockerInfo({ isAgent }: { isAgent: boolean }) {
const info = infoQuery.data;
const isSwarm = info.Swarm && info.Swarm.NodeID !== '';
const isSwarm = info.Swarm !== undefined && info.Swarm?.NodeID !== '';
const type = getDockerEnvironmentType(isSwarm, isPodman);
return (
<span className="small text-muted">
{isSwarm ? 'Swarm' : 'Standalone'} {info.ServerVersion}
<span className="inline-flex gap-x-2 small text-muted">
<span>
{type} {info.ServerVersion}
</span>
{isAgent && (
<span className="flex gap-1 items-center">
<Icon icon={ZapIcon} />
Agent
</span>
<>
<span>-</span>
<span className="inline-flex items-center">
<Icon icon={ZapIcon} />
Agent
</span>
</>
)}
</span>
);

View file

@ -44,7 +44,8 @@ export function EnvironmentInfo() {
<DetailsTable.Row label="Environment">
<div className="flex items-center gap-2">
{environment.Name}
<SnapshotStats snapshot={environment.Snapshots[0]} />-
<SnapshotStats snapshot={environment.Snapshots[0]} />
<span className="text-muted">-</span>
<DockerInfo isAgent={isAgent} />
</div>
</DetailsTable.Row>

View file

@ -39,7 +39,7 @@ export function PortsMappingField({
label="Port mapping"
value={value}
onChange={onChange}
addLabel="map additional port"
addLabel="Map additional port"
itemBuilder={() => ({
hostPort: '',
containerPort: '',
@ -79,7 +79,7 @@ function Item({
readOnly={readOnly}
value={item.hostPort}
onChange={(e) => handleChange('hostPort', e.target.value)}
label="host"
label="Host"
placeholder="e.g. 80"
className="w-1/2"
id={`hostPort-${index}`}
@ -95,7 +95,7 @@ function Item({
readOnly={readOnly}
value={item.containerPort}
onChange={(e) => handleChange('containerPort', e.target.value)}
label="container"
label="Container"
placeholder="e.g. 80"
className="w-1/2"
id={`containerPort-${index}`}
@ -105,7 +105,10 @@ function Item({
<ButtonSelector<Protocol>
onChange={(value) => handleChange('protocol', value)}
value={item.protocol}
options={[{ value: 'tcp' }, { value: 'udp' }]}
options={[
{ value: 'tcp', label: 'TCP' },
{ value: 'udp', label: 'UDP' },
]}
disabled={disabled}
readOnly={readOnly}
/>

View file

@ -2,8 +2,9 @@ import { FormikErrors } from 'formik';
import { array, object, SchemaOf, string } from 'yup';
import _ from 'lodash';
import { useLoggingPlugins } from '@/react/docker/proxy/queries/useServicePlugins';
import { useLoggingPlugins } from '@/react/docker/proxy/queries/usePlugins';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { useIsPodman } from '@/react/portainer/environments/queries/useIsPodman';
import { FormControl } from '@@/form-components/FormControl';
import { FormSection } from '@@/form-components/FormSection';
@ -30,8 +31,9 @@ export function LoggerConfig({
errors?: FormikErrors<LogConfig>;
}) {
const envId = useEnvironmentId();
const pluginsQuery = useLoggingPlugins(envId, apiVersion < 1.25);
const isPodman = useIsPodman(envId);
const isSystem = apiVersion < 1.25;
const pluginsQuery = useLoggingPlugins(envId, isSystem, isPodman);
if (!pluginsQuery.data) {
return null;

View file

@ -1,5 +1,8 @@
import { FormikErrors } from 'formik';
import { useIsPodman } from '@/react/portainer/environments/queries/useIsPodman';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { FormControl } from '@@/form-components/FormControl';
import { Input } from '@@/form-components/Input';
@ -19,12 +22,15 @@ export function NetworkTab({
setFieldValue: (field: string, value: unknown) => void;
errors?: FormikErrors<Values>;
}) {
const envId = useEnvironmentId();
const isPodman = useIsPodman(envId);
const additionalOptions = getAdditionalOptions(isPodman);
return (
<div className="mt-3">
<FormControl label="Network" errors={errors?.networkMode}>
<NetworkSelector
value={values.networkMode}
additionalOptions={[{ label: 'Container', value: CONTAINER_MODE }]}
additionalOptions={additionalOptions}
onChange={(networkMode) => setFieldValue('networkMode', networkMode)}
/>
</FormControl>
@ -105,3 +111,10 @@ export function NetworkTab({
</div>
);
}
function getAdditionalOptions(isPodman?: boolean) {
if (isPodman) {
return [];
}
return [{ label: 'Container', value: CONTAINER_MODE }];
}

View file

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

View file

@ -5,8 +5,8 @@ import { ContainerListViewModel } from '../../types';
import { CONTAINER_MODE, Values } from './types';
export function getDefaultViewModel(isWindows: boolean) {
const networkMode = isWindows ? 'nat' : 'bridge';
export function getDefaultViewModel(isWindows: boolean, isPodman?: boolean) {
const networkMode = getDefaultNetworkMode(isWindows, isPodman);
return {
networkMode,
hostname: '',
@ -21,10 +21,17 @@ export function getDefaultViewModel(isWindows: boolean) {
};
}
export function getDefaultNetworkMode(isWindows: boolean, isPodman?: boolean) {
if (isWindows) return 'nat';
if (isPodman) return 'podman';
return 'bridge';
}
export function toViewModel(
config: ContainerDetailsJSON,
networks: Array<DockerNetwork>,
runningContainers: Array<ContainerListViewModel> = []
runningContainers: Array<ContainerListViewModel> = [],
isPodman?: boolean
): Values {
const dns = config.HostConfig?.Dns;
const [primaryDns = '', secondaryDns = ''] = dns || [];
@ -34,7 +41,8 @@ export function toViewModel(
const [networkMode, container = ''] = getNetworkMode(
config,
networks,
runningContainers
runningContainers,
isPodman
);
const networkSettings = config.NetworkSettings?.Networks?.[networkMode];
@ -61,10 +69,11 @@ export function toViewModel(
};
}
function getNetworkMode(
export function getNetworkMode(
config: ContainerDetailsJSON,
networks: Array<DockerNetwork>,
runningContainers: Array<ContainerListViewModel> = []
runningContainers: Array<ContainerListViewModel> = [],
isPodman?: boolean
) {
let networkMode = config.HostConfig?.NetworkMode || '';
if (!networkMode) {
@ -85,6 +94,9 @@ function getNetworkMode(
const networkNames = networks.map((n) => n.Name);
if (networkNames.includes(networkMode)) {
if (isPodman && networkMode === 'bridge') {
return ['podman'] as const;
}
return [networkMode] as const;
}
@ -92,6 +104,9 @@ function getNetworkMode(
networkNames.includes('bridge') &&
(!networkMode || networkMode === 'default' || networkMode === 'bridge')
) {
if (isPodman) {
return ['podman'] as const;
}
return ['bridge'] as const;
}

View file

@ -1,5 +1,6 @@
import { useCurrentStateAndParams } from '@uirouter/react';
import { useIsPodman } from '@/react/portainer/environments/queries/useIsPodman';
import {
BaseFormValues,
baseFormUtils,
@ -46,6 +47,8 @@ import { useNetworksForSelector } from '../components/NetworkSelector';
import { useContainers } from '../queries/useContainers';
import { useContainer } from '../queries/useContainer';
import { getDefaultNetworkMode } from './NetworkTab/toViewModel';
export interface Values extends BaseFormValues {
commands: CommandsTabValues;
volumes: VolumesTabValues;
@ -80,6 +83,7 @@ export function useInitialValues(submitting: boolean, isWindows: boolean) {
const registriesQuery = useEnvironmentRegistries(environmentId, {
enabled: !!from,
});
const isPodman = useIsPodman(environmentId);
if (!networksQuery.data) {
return null;
@ -87,7 +91,13 @@ export function useInitialValues(submitting: boolean, isWindows: boolean) {
if (!from) {
return {
initialValues: defaultValues(isPureAdmin, user.Id, nodeName, isWindows),
initialValues: defaultValues(
isPureAdmin,
user.Id,
nodeName,
isWindows,
isPodman
),
};
}
@ -110,7 +120,11 @@ export function useInitialValues(submitting: boolean, isWindows: boolean) {
const extraNetworks = Object.entries(
fromContainer.NetworkSettings?.Networks || {}
)
.filter(([n]) => n !== network.networkMode)
.filter(
([n]) =>
n !== network.networkMode &&
n !== getDefaultNetworkMode(isWindows, isPodman)
)
.map(([networkName, network]) => ({
networkName,
aliases: (network.Aliases || []).filter(
@ -129,7 +143,8 @@ export function useInitialValues(submitting: boolean, isWindows: boolean) {
network: networkTabUtils.toViewModel(
fromContainer,
networksQuery.data,
runningContainersQuery.data
runningContainersQuery.data,
isPodman
),
labels: labelsTabUtils.toViewModel(fromContainer),
restartPolicy: restartPolicyTabUtils.toViewModel(fromContainer),
@ -153,12 +168,13 @@ function defaultValues(
isPureAdmin: boolean,
currentUserId: UserId,
nodeName: string,
isWindows: boolean
isWindows: boolean,
isPodman?: boolean
): Values {
return {
commands: commandsTabUtils.getDefaultViewModel(),
volumes: volumesTabUtils.getDefaultViewModel(),
network: networkTabUtils.getDefaultViewModel(isWindows), // windows containers should default to the nat network, not the bridge
network: networkTabUtils.getDefaultViewModel(isWindows, isPodman), // windows containers should default to the nat network, not the bridge
labels: labelsTabUtils.getDefaultViewModel(),
restartPolicy: restartPolicyTabUtils.getDefaultViewModel(),
resources: resourcesTabUtils.getDefaultViewModel(),

View file

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

View file

@ -5,6 +5,7 @@ import { DockerNetwork } from '@/react/docker/networks/types';
import { useIsSwarm } from '@/react/docker/proxy/queries/useInfo';
import { useApiVersion } from '@/react/docker/proxy/queries/useVersion';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { useIsPodman } from '@/react/portainer/environments/queries/useIsPodman';
import { Option, PortainerSelect } from '@@/form-components/PortainerSelect';
@ -19,9 +20,17 @@ export function NetworkSelector({
onChange: (value: string) => void;
hiddenNetworks?: string[];
}) {
const envId = useEnvironmentId();
const isPodman = useIsPodman(envId);
const networksQuery = useNetworksForSelector({
select(networks) {
return networks.map((n) => ({ label: n.Name, value: n.Name }));
return networks.map((n) => {
// The name of the 'bridge' network is 'podman' in Podman
if (n.Name === 'bridge' && isPodman) {
return { label: 'podman', value: 'podman' };
}
return { label: n.Name, value: n.Name };
});
},
});

View file

@ -18,4 +18,7 @@ export const queryKeys = {
gpus: (environmentId: EnvironmentId, id: string) =>
[...queryKeys.container(environmentId, id), 'gpus'] as const,
top: (environmentId: EnvironmentId, id: string) =>
[...queryKeys.container(environmentId, id), 'top'] as const,
};

View file

@ -7,3 +7,8 @@ export interface Filters {
network?: NetworkId[];
status?: ContainerStatus[];
}
export type ContainerProcesses = {
Processes: Array<Array<string>>;
Titles: Array<string>;
};

View file

@ -1,21 +1,40 @@
import { useQuery } from '@tanstack/react-query';
import { EnvironmentId } from '@/react/portainer/environments/types';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { ContainerId } from '../types';
import { buildDockerProxyUrl } from '../../proxy/queries/buildDockerProxyUrl';
import { queryKeys } from './query-keys';
import { ContainerProcesses } from './types';
export function useContainerTop<T = ContainerProcesses>(
environmentId: EnvironmentId,
id: ContainerId,
select?: (environment: ContainerProcesses) => T
) {
// many containers don't allow this call, so fail early, and omit withError to silently fail
return useQuery({
queryKey: queryKeys.top(environmentId, id),
queryFn: () => getContainerTop(environmentId, id),
retry: false,
select,
});
}
/**
* Raw docker API proxy
* @param environmentId
* @param id
* @returns
*/
export async function containerTop(
export async function getContainerTop(
environmentId: EnvironmentId,
id: ContainerId
) {
try {
const { data } = await axios.get(
const { data } = await axios.get<ContainerProcesses>(
buildDockerProxyUrl(environmentId, 'containers', id, 'top')
);
return data;

View file

@ -73,11 +73,11 @@ function Cell({
});
return (
<>
<div className="flex gap-1">
<a href={linkProps.href} onClick={linkProps.onClick} title={name}>
{truncate(name, 40)}
</a>
{!image.used && <UnusedBadge />}
</>
</div>
);
}

View file

@ -2,6 +2,8 @@ import { CellContext } from '@tanstack/react-table';
import { ImagesListResponse } from '@/react/docker/images/queries/useImages';
import { Badge } from '@@/Badge';
import { columnHelper } from './helper';
export const tags = columnHelper.accessor((item) => item.tags?.join(','), {
@ -16,12 +18,12 @@ function Cell({
const repoTags = item.tags;
return (
<>
<div className="flex flex-wrap gap-1">
{repoTags?.map((tag, idx) => (
<span key={idx} className="label label-primary image-tag" title={tag}>
<Badge key={idx} type="info">
{tag}
</span>
</Badge>
))}
</>
</div>
);
}

View file

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

View file

@ -9,6 +9,12 @@ import {
import { DockerImage } from './types';
import { DockerImageResponse } from './types/response';
type ImageModel = {
UseRegistry: boolean;
Registry?: Registry;
Image: string;
};
export function parseViewModel(response: DockerImageResponse): DockerImage {
return {
...response,
@ -40,11 +46,7 @@ export function imageContainsURL(image: string) {
return false;
}
export function buildImageFullURIFromModel(imageModel: {
UseRegistry: boolean;
Registry?: Registry;
Image: string;
}) {
export function buildImageFullURIFromModel(imageModel: ImageModel) {
const registry = imageModel.UseRegistry ? imageModel.Registry : undefined;
return buildImageFullURI(imageModel.Image, registry);
}
@ -107,3 +109,24 @@ function buildImageFullURIWithRegistry(image: string, registry: Registry) {
return url + image;
}
}
/**
* Splits a full URI into repository and tag.
*
* @param fullURI - The full URI to be split.
* @returns An object containing the repository and tag.
*/
export function fullURIIntoRepoAndTag(fullURI: string) {
// possible fullURI values (all should contain a tag):
// - registry/image-repo:tag
// - image-repo:tag
// - registry:port/image-repo:tag
// buildImageFullURIFromModel always gives a tag (defaulting to 'latest'), so the tag is always present after the last ':'
const parts = fullURI.split(':');
const tag = parts.pop() || 'latest';
const repo = parts.join(':');
return {
repo,
tag,
};
}

View file

@ -14,13 +14,14 @@ import { buildDockerProxyUrl } from '../buildDockerProxyUrl';
export async function tagImage(
environmentId: EnvironmentId,
id: ImageId | ImageName,
repo: string
repo: string,
tag?: string
) {
try {
const { data } = await axios.post(
buildDockerProxyUrl(environmentId, 'images', id, 'tag'),
{},
{ params: { repo } }
{ params: { repo, tag } }
);
return data;
} catch (e) {

View file

@ -99,9 +99,18 @@ export function aggregateData(
export function useLoggingPlugins(
environmentId: EnvironmentId,
systemOnly: boolean
systemOnly: boolean,
isPodman?: boolean
) {
return useServicePlugins(environmentId, systemOnly, 'Log');
// systemOnly false + podman false|undefined -> both
// systemOnly true + podman false|undefined -> system
// systemOnly false + podman true -> system
// systemOnly true + podman true -> system
return useServicePlugins(
environmentId,
systemOnly || isPodman === true,
'Log'
);
}
export function useVolumePlugins(

View file

@ -84,7 +84,6 @@ function Cell({
id: item.Id,
nodeName: item.NodeName,
}}
className="monospaced"
data-cy={`volume-link-${name}`}
>
{truncate(name, 40)}
@ -106,7 +105,7 @@ function Cell({
}}
data-cy={`volume-browse-button-${name}`}
>
browse
Browse
</Button>
</Authorized>
)}

View file

@ -57,6 +57,7 @@ export function EdgeScriptSettingsFieldset({
type="text"
value={values.edgeIdGenerator}
name="edgeIdGenerator"
placeholder="e.g. uuidgen"
id="edge-id-generator-input"
onChange={(e) => setFieldValue(e.target.name, e.target.value)}
data-cy="edge-id-generator-input"
@ -81,7 +82,7 @@ export function EdgeScriptSettingsFieldset({
<Field
name="envVars"
as={Input}
placeholder="foo=bar,myvar"
placeholder="e.g. foo=bar"
id="env-variables-input"
/>
</FormControl>

View file

@ -35,6 +35,11 @@ export const commandsTabs: Record<string, CommandTab> = {
label: 'Docker Standalone',
command: buildLinuxStandaloneCommand,
},
podmanLinux: {
id: 'podman',
label: 'Podman',
command: buildLinuxPodmanCommand,
},
swarmWindows: {
id: 'swarm',
label: 'Docker Swarm',
@ -83,6 +88,45 @@ docker run -d \\
`;
}
function buildLinuxPodmanCommand(
agentVersion: string,
edgeKey: string,
properties: ScriptFormValues,
useAsyncMode: boolean,
edgeId?: string,
agentSecret?: string
) {
const { allowSelfSignedCertificates, edgeIdGenerator, envVars } = properties;
const env = buildDockerEnvVars(envVars, [
...buildDefaultDockerEnvVars(
edgeKey,
allowSelfSignedCertificates,
!edgeIdGenerator ? edgeId : undefined,
agentSecret,
useAsyncMode
),
...metaEnvVars(properties),
]);
return `${
edgeIdGenerator ? `PORTAINER_EDGE_ID=$(${edgeIdGenerator}) \n\n` : ''
}\
sudo systemctl enable --now podman.socket
sudo podman volume create portainer
sudo podman run -d \\
-v /run/podman/podman.sock:/var/run/docker.sock \\
-v /var/lib/containers/storage/volumes:/var/lib/docker/volumes \\
-v /:/host \\
-v portainer_agent_data:/data \\
--restart always \\
--privileged \\
${env} \\
--name portainer_edge_agent \\
portainer/agent:${agentVersion}
`;
}
export function buildWindowsStandaloneCommand(
agentVersion: string,
edgeKey: string,

View file

@ -3,7 +3,7 @@ import { EnvironmentGroupId } from '@/react/portainer/environments/environment-g
import { EdgeGroup } from '../../edge-groups/types';
export type Platform = 'standalone' | 'swarm' | 'k8s';
export type Platform = 'standalone' | 'swarm' | 'podman' | 'k8s';
export type OS = 'win' | 'linux';
export interface ScriptFormValues {

View file

@ -26,9 +26,15 @@ function WaitingRoomView() {
<div className="col-sm-12">
<InformationPanel>
<TextTip color="blue">
Only environments generated from the AEEC script will appear here,
manually added environments and edge devices will bypass the
waiting room.
Only environments generated from the{' '}
<Link
to="portainer.endpoints.edgeAutoCreateScript"
data-cy="waitingRoom-edgeAutoCreateScriptLink"
>
auto onboarding
</Link>{' '}
script will appear here, manually added environments and edge
devices will bypass the waiting room.
</TextTip>
</InformationPanel>
</div>

View file

@ -2,6 +2,12 @@ import { useCurrentStateAndParams } from '@uirouter/react';
import { EnvironmentId } from '@/react/portainer/environments/types';
/**
* useEnvironmentId is a hook that returns the environmentId from the url params.
* use only when endpointId is set in the path.
* for example: /kubernetes/clusters/:endpointId
* for `:id` paths, use a different hook
*/
export function useEnvironmentId(force = true): EnvironmentId {
const {
params: { endpointId: environmentId },

View file

@ -1,17 +1,25 @@
import { DockerSnapshot } from '@/react/docker/snapshots/types';
import { useIsPodman } from '@/react/portainer/environments/queries/useIsPodman';
import {
Environment,
PlatformType,
KubernetesSnapshot,
} from '@/react/portainer/environments/types';
import { getPlatformType } from '@/react/portainer/environments/utils';
import { getDockerEnvironmentType } from '@/react/portainer/environments/utils/getDockerEnvironmentType';
export function EngineVersion({ environment }: { environment: Environment }) {
const platform = getPlatformType(environment.Type);
const isPodman = useIsPodman(environment.Id);
switch (platform) {
case PlatformType.Docker:
return <DockerEngineVersion snapshot={environment.Snapshots[0]} />;
return (
<DockerEngineVersion
snapshot={environment.Snapshots[0]}
isPodman={isPodman}
/>
);
case PlatformType.Kubernetes:
return (
<KubernetesEngineVersion
@ -23,14 +31,21 @@ export function EngineVersion({ environment }: { environment: Environment }) {
}
}
function DockerEngineVersion({ snapshot }: { snapshot?: DockerSnapshot }) {
function DockerEngineVersion({
snapshot,
isPodman,
}: {
snapshot?: DockerSnapshot;
isPodman?: boolean;
}) {
if (!snapshot) {
return null;
}
const type = getDockerEnvironmentType(snapshot.Swarm, isPodman);
return (
<span className="small text-muted vertical-center">
{snapshot.Swarm ? 'Swarm' : 'Standalone'} {snapshot.DockerVersion}
{type} {snapshot.DockerVersion}
</span>
);
}

View file

@ -1,43 +1,86 @@
import { environmentTypeIcon } from '@/portainer/filters/filters';
import dockerEdge from '@/assets/images/edge_endpoint.png';
import { getEnvironmentTypeIcon } from '@/react/portainer/environments/utils';
import dockerEdge from '@/assets/ico/docker-edge-environment.svg';
import podmanEdge from '@/assets/ico/podman-edge-environment.svg';
import kube from '@/assets/images/kubernetes_endpoint.png';
import kubeEdge from '@/assets/images/kubernetes_edge_endpoint.png';
import { EnvironmentType } from '@/react/portainer/environments/types';
import kubeEdge from '@/assets/ico/kubernetes-edge-environment.svg';
import {
ContainerEngine,
EnvironmentType,
} from '@/react/portainer/environments/types';
import azure from '@/assets/ico/vendor/azure.svg';
import docker from '@/assets/ico/vendor/docker.svg';
import podman from '@/assets/ico/vendor/podman.svg';
import { Icon } from '@@/Icon';
interface Props {
type: EnvironmentType;
containerEngine?: ContainerEngine;
}
export function EnvironmentIcon({ type }: Props) {
export function EnvironmentIcon({ type, containerEngine }: Props) {
switch (type) {
case EnvironmentType.AgentOnDocker:
case EnvironmentType.Docker:
if (containerEngine === ContainerEngine.Podman) {
return (
<img
src={podman}
width="60"
alt="podman environment"
aria-hidden="true"
/>
);
}
return (
<img src={docker} width="60" alt="docker endpoint" aria-hidden="true" />
<img
src={docker}
width="60"
alt="docker environment"
aria-hidden="true"
/>
);
case EnvironmentType.Azure:
return (
<img src={azure} width="60" alt="azure endpoint" aria-hidden="true" />
<img
src={azure}
width="60"
alt="azure environment"
aria-hidden="true"
/>
);
case EnvironmentType.EdgeAgentOnDocker:
if (containerEngine === ContainerEngine.Podman) {
return (
<img
src={podmanEdge}
alt="podman edge environment"
aria-hidden="true"
/>
);
}
return (
<img src={dockerEdge} alt="docker edge endpoint" aria-hidden="true" />
<img
src={dockerEdge}
alt="docker edge environment"
aria-hidden="true"
/>
);
case EnvironmentType.KubernetesLocal:
case EnvironmentType.AgentOnKubernetes:
return <img src={kube} alt="kubernetes endpoint" aria-hidden="true" />;
return <img src={kube} alt="kubernetes environment" aria-hidden="true" />;
case EnvironmentType.EdgeAgentOnKubernetes:
return (
<img src={kubeEdge} alt="kubernetes edge endpoint" aria-hidden="true" />
<img
src={kubeEdge}
alt="kubernetes edge environment"
aria-hidden="true"
/>
);
default:
return (
<Icon
icon={environmentTypeIcon(type)}
icon={getEnvironmentTypeIcon(type, containerEngine)}
className="blue-icon !h-16 !w-16"
/>
);

View file

@ -66,7 +66,10 @@ export function EnvironmentItem({
params={dashboardRoute.params}
>
<div className="ml-2 flex justify-center self-center">
<EnvironmentIcon type={environment.Type} />
<EnvironmentIcon
type={environment.Type}
containerEngine={environment.ContainerEngine}
/>
</div>
<div className="ml-3 mr-auto flex flex-col items-start justify-center gap-3">
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">

View file

@ -265,6 +265,12 @@ export function EnvironmentList({ onClickBrowse, onRefresh }: Props) {
EnvironmentType.AgentOnDocker,
EnvironmentType.EdgeAgentOnDocker,
],
// for podman keep the env type as docker (the containerEngine distinguishes podman from docker)
[PlatformType.Podman]: [
EnvironmentType.Docker,
EnvironmentType.AgentOnDocker,
EnvironmentType.EdgeAgentOnDocker,
],
[PlatformType.Azure]: [EnvironmentType.Azure],
[PlatformType.Kubernetes]: [
EnvironmentType.KubernetesLocal,

View file

@ -171,6 +171,13 @@ function getConnectionTypeOptions(platformTypes: PlatformType[]) {
ConnectionType.EdgeAgentStandard,
ConnectionType.EdgeAgentAsync,
],
[PlatformType.Podman]: [
// api includes a socket connection, so keep this for podman
ConnectionType.API,
ConnectionType.Agent,
ConnectionType.EdgeAgentStandard,
ConnectionType.EdgeAgentAsync,
],
[PlatformType.Azure]: [ConnectionType.API],
[PlatformType.Kubernetes]: [
ConnectionType.Agent,

View file

@ -5,7 +5,7 @@ import { Select } from '@@/form-components/Input';
const typeOptions = [
{ label: 'Swarm', value: StackType.DockerSwarm },
{ label: 'Standalone', value: StackType.DockerCompose },
{ label: 'Standalone / Podman', value: StackType.DockerCompose },
];
export function TemplateTypeSelector({

View file

@ -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', {
header: 'Type',
cell: Cell,
});
type TypeCellContext = {
type: EnvironmentType;
containerEngine?: ContainerEngine;
};
function Cell({ getValue }: CellContext<Environment, EnvironmentType>) {
const type = getValue();
export const type = columnHelper.accessor(
(rowItem): TypeCellContext => ({
type: rowItem.Type,
containerEngine: rowItem.ContainerEngine,
}),
{
header: 'Type',
cell: Cell,
id: 'Type',
}
);
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>
);
}

View file

@ -7,7 +7,11 @@ import { type EnvironmentGroupId } from '@/react/portainer/environments/environm
import { type TagId } from '@/portainer/tags/types';
import { EdgeAsyncIntervalsValues } from '@/react/edge/components/EdgeAsyncIntervalsForm';
import { type Environment, EnvironmentCreationTypes } from '../types';
import {
type Environment,
ContainerEngine,
EnvironmentCreationTypes,
} from '../types';
import { buildUrl } from './utils';
@ -21,6 +25,7 @@ interface CreateLocalDockerEnvironment {
socketPath?: string;
publicUrl?: string;
meta?: EnvironmentMetadata;
containerEngine?: ContainerEngine;
}
export async function createLocalDockerEnvironment({
@ -28,6 +33,7 @@ export async function createLocalDockerEnvironment({
socketPath = '',
publicUrl = '',
meta = { tagIds: [] },
containerEngine,
}: CreateLocalDockerEnvironment) {
const url = prefixPath(socketPath);
@ -38,6 +44,7 @@ export async function createLocalDockerEnvironment({
url,
publicUrl,
meta,
containerEngine,
}
);
@ -115,6 +122,7 @@ export interface EnvironmentOptions {
pollFrequency?: number;
edge?: EdgeSettings;
tunnelServerAddr?: string;
containerEngine?: ContainerEngine;
}
interface CreateRemoteEnvironment {
@ -125,6 +133,7 @@ interface CreateRemoteEnvironment {
>;
url: string;
options?: Omit<EnvironmentOptions, 'url'>;
containerEngine?: ContainerEngine;
}
export async function createRemoteEnvironment({
@ -143,11 +152,13 @@ export interface CreateAgentEnvironmentValues {
name: string;
environmentUrl: string;
meta: EnvironmentMetadata;
containerEngine?: ContainerEngine;
}
export function createAgentEnvironment({
name,
environmentUrl,
containerEngine = ContainerEngine.Docker,
meta = { tagIds: [] },
}: CreateAgentEnvironmentValues) {
return createRemoteEnvironment({
@ -160,6 +171,7 @@ export function createAgentEnvironment({
skipVerify: true,
skipClientVerify: true,
},
containerEngine,
},
});
}
@ -171,6 +183,7 @@ interface CreateEdgeAgentEnvironment {
meta?: EnvironmentMetadata;
pollFrequency: number;
edge: EdgeSettings;
containerEngine: ContainerEngine;
}
export function createEdgeAgentEnvironment({
@ -179,6 +192,7 @@ export function createEdgeAgentEnvironment({
meta = { tagIds: [] },
pollFrequency,
edge,
containerEngine,
}: CreateEdgeAgentEnvironment) {
return createEnvironment(
name,
@ -192,6 +206,7 @@ export function createEdgeAgentEnvironment({
pollFrequency,
edge,
meta,
containerEngine,
}
);
}
@ -207,7 +222,8 @@ async function createEnvironment(
};
if (options) {
const { groupId, tagIds = [] } = options.meta || {};
const { tls, azure, meta, containerEngine } = options;
const { groupId, tagIds = [] } = meta || {};
payload = {
...payload,
@ -216,10 +232,9 @@ async function createEnvironment(
GroupID: groupId,
TagIds: arrayToJson(tagIds),
EdgeCheckinInterval: options.pollFrequency,
ContainerEngine: containerEngine,
};
const { tls, azure } = options;
if (tls) {
payload = {
...payload,

View file

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

View file

@ -4,6 +4,9 @@ import { DockerSnapshot } from '@/react/docker/snapshots/types';
export type EnvironmentId = number;
/**
* matches portainer.EndpointType in app/portainer.go
*/
export enum EnvironmentType {
// Docker represents an environment(endpoint) connected to a Docker environment(endpoint)
Docker = 1,
@ -124,6 +127,7 @@ export type Environment = {
Agent: { Version: string };
Id: EnvironmentId;
Type: EnvironmentType;
ContainerEngine?: ContainerEngine;
TagIds: TagId[];
GroupId: EnvironmentGroupId;
DeploymentOptions: DeploymentOptions | null;
@ -168,8 +172,14 @@ export enum EnvironmentCreationTypes {
KubeConfigEnvironment,
}
export enum ContainerEngine {
Docker = 'docker',
Podman = 'podman',
}
export enum PlatformType {
Docker,
Kubernetes,
Azure,
Podman,
}

View file

@ -1,8 +1,10 @@
import { getPlatformType } from '@/react/portainer/environments/utils';
import {
ContainerEngine,
EnvironmentType,
PlatformType,
} from '@/react/portainer/environments/types';
import Podman from '@/assets/ico/vendor/podman.svg?c';
import Docker from './docker.svg?c';
import Azure from './azure.svg?c';
@ -12,12 +14,16 @@ const icons: {
[key in PlatformType]: SvgrComponent;
} = {
[PlatformType.Docker]: Docker,
[PlatformType.Podman]: Podman,
[PlatformType.Kubernetes]: Kubernetes,
[PlatformType.Azure]: Azure,
};
export function getPlatformIcon(type: EnvironmentType) {
const platform = getPlatformType(type);
export function getPlatformIcon(
type: EnvironmentType,
containerEngine?: ContainerEngine
) {
const platform = getPlatformType(type, containerEngine);
return icons[platform];
}

View file

@ -0,0 +1,6 @@
export function getDockerEnvironmentType(isSwarm: boolean, isPodman?: boolean) {
if (isPodman) {
return 'Podman';
}
return isSwarm ? 'Swarm' : 'Standalone';
}

View file

@ -1,6 +1,21 @@
import { Environment, EnvironmentType, PlatformType } from '../types';
import { Cloud } from 'lucide-react';
export function getPlatformType(envType: EnvironmentType) {
import Kube from '@/assets/ico/kube.svg?c';
import PodmanIcon from '@/assets/ico/vendor/podman-icon.svg?c';
import DockerIcon from '@/assets/ico/vendor/docker-icon.svg?c';
import MicrosoftIcon from '@/assets/ico/vendor/microsoft-icon.svg?c';
import {
Environment,
EnvironmentType,
ContainerEngine,
PlatformType,
} from '../types';
export function getPlatformType(
envType: EnvironmentType,
containerEngine?: ContainerEngine
) {
switch (envType) {
case EnvironmentType.KubernetesLocal:
case EnvironmentType.AgentOnKubernetes:
@ -9,6 +24,9 @@ export function getPlatformType(envType: EnvironmentType) {
case EnvironmentType.Docker:
case EnvironmentType.AgentOnDocker:
case EnvironmentType.EdgeAgentOnDocker:
if (containerEngine === ContainerEngine.Podman) {
return PlatformType.Podman;
}
return PlatformType.Docker;
case EnvironmentType.Azure:
return PlatformType.Azure;
@ -25,8 +43,11 @@ export function isKubernetesEnvironment(envType: EnvironmentType) {
return getPlatformType(envType) === PlatformType.Kubernetes;
}
export function getPlatformTypeName(envType: EnvironmentType): string {
return PlatformType[getPlatformType(envType)];
export function getPlatformTypeName(
envType: EnvironmentType,
containerEngine?: ContainerEngine
): string {
return PlatformType[getPlatformType(envType, containerEngine)];
}
export function isAgentEnvironment(envType: EnvironmentType) {
@ -104,3 +125,27 @@ export function getDashboardRoute(environment: Environment) {
}
}
}
export function getEnvironmentTypeIcon(
type: EnvironmentType,
containerEngine?: ContainerEngine
) {
switch (type) {
case EnvironmentType.Azure:
return MicrosoftIcon;
case EnvironmentType.EdgeAgentOnDocker:
return Cloud;
case EnvironmentType.AgentOnKubernetes:
case EnvironmentType.EdgeAgentOnKubernetes:
case EnvironmentType.KubernetesLocal:
return Kube;
case EnvironmentType.AgentOnDocker:
case EnvironmentType.Docker:
if (containerEngine === ContainerEngine.Podman) {
return PodmanIcon;
}
return DockerIcon;
default:
throw new Error(`type ${type}-${EnvironmentType[type]} is not supported`);
}
}

View file

@ -15,6 +15,7 @@ import {
EnvironmentOptionValue,
existingEnvironmentTypes,
newEnvironmentTypes,
environmentTypes,
} from './environment-types';
export function EnvironmentTypeSelectView() {
@ -65,6 +66,7 @@ export function EnvironmentTypeSelectView() {
disabled={types.length === 0}
data-cy="start-wizard-button"
onClick={() => startWizard()}
className="!ml-0"
>
Start Wizard
</Button>
@ -80,11 +82,6 @@ export function EnvironmentTypeSelectView() {
return;
}
const environmentTypes = [
...existingEnvironmentTypes,
...newEnvironmentTypes,
];
const steps = _.compact(
types.map((id) => environmentTypes.find((eType) => eType.id === id))
);

View file

@ -1,5 +1,6 @@
import { FeatureId } from '@/react/portainer/feature-flags/enums';
import Docker from '@/assets/ico/vendor/docker.svg?c';
import Podman from '@/assets/ico/vendor/podman.svg?c';
import Kubernetes from '@/assets/ico/vendor/kubernetes.svg?c';
import Azure from '@/assets/ico/vendor/azure.svg?c';
import KaaS from '@/assets/ico/vendor/kaas-icon.svg?c';
@ -10,6 +11,7 @@ import { BoxSelectorOption } from '@@/BoxSelector';
export type EnvironmentOptionValue =
| 'dockerStandalone'
| 'dockerSwarm'
| 'podman'
| 'kubernetes'
| 'aci'
| 'kaas'
@ -20,7 +22,6 @@ export interface EnvironmentOption
id: EnvironmentOptionValue;
value: EnvironmentOptionValue;
}
export const existingEnvironmentTypes: EnvironmentOption[] = [
{
id: 'dockerStandalone',
@ -38,6 +39,14 @@ export const existingEnvironmentTypes: EnvironmentOption[] = [
iconType: 'logo',
description: 'Connect to Docker Swarm via URL/IP, API or Socket',
},
{
id: 'podman',
value: 'podman',
label: 'Podman',
icon: Podman,
iconType: 'logo',
description: 'Connect to Podman via URL/IP or Socket',
},
{
id: 'kubernetes',
value: 'kubernetes',
@ -80,7 +89,7 @@ export const newEnvironmentTypes: EnvironmentOption[] = [
},
];
export const environmentTypes = [
export const environmentTypes: EnvironmentOption[] = [
...existingEnvironmentTypes,
...newEnvironmentTypes,
];
@ -88,6 +97,7 @@ export const environmentTypes = [
export const formTitles: Record<EnvironmentOptionValue, string> = {
dockerStandalone: 'Connect to your Docker Standalone environment',
dockerSwarm: 'Connect to your Docker Swarm environment',
podman: 'Connect to your Podman environment',
kubernetes: 'Connect to your Kubernetes environment',
aci: 'Connect to your ACI environment',
kaas: 'Provision a KaaS environment',

View file

@ -1 +1 @@
export { EnvironmentTypeSelectView } from './EndpointTypeView';
export { EnvironmentTypeSelectView } from './EnvironmentTypeSelectView';

View file

@ -7,7 +7,7 @@
.wizard-wrapper {
display: grid;
grid-template-columns: 1fr 400px;
grid-template-columns: 2fr minmax(300px, 1fr);
grid-template-areas:
'main sidebar'
'footer sidebar';

View file

@ -22,6 +22,7 @@ import {
EnvironmentOptionValue,
environmentTypes,
formTitles,
EnvironmentOption,
} from '../EnvironmentTypeSelectView/environment-types';
import { WizardDocker } from './WizardDocker';
@ -30,6 +31,7 @@ import { WizardKubernetes } from './WizardKubernetes';
import { AnalyticsState, AnalyticsStateKey } from './types';
import styles from './EnvironmentsCreationView.module.css';
import { WizardEndpointsList } from './WizardEndpointsList';
import { WizardPodman } from './WizardPodman';
export function EnvironmentCreationView() {
const {
@ -161,7 +163,7 @@ function useParamEnvironmentTypes(): EnvironmentOptionValue[] {
}
function useStepper(
steps: (typeof environmentTypes)[number][],
steps: EnvironmentOption[][number][],
onFinish: () => void
) {
const [currentStepIndex, setCurrentStepIndex] = useState(0);
@ -197,6 +199,8 @@ function useStepper(
case 'dockerStandalone':
case 'dockerSwarm':
return WizardDocker;
case 'podman':
return WizardPodman;
case 'aci':
return WizardAzure;
case 'kubernetes':
@ -211,14 +215,18 @@ function useAnalyticsState() {
const [analytics, setAnalyticsState] = useState<AnalyticsState>({
dockerAgent: 0,
dockerApi: 0,
dockerEdgeAgentAsync: 0,
dockerEdgeAgentStandard: 0,
podmanAgent: 0,
podmanEdgeAgentAsync: 0,
podmanEdgeAgentStandard: 0,
podmanLocalEnvironment: 0,
kubernetesAgent: 0,
kubernetesEdgeAgentAsync: 0,
kubernetesEdgeAgentStandard: 0,
kaasAgent: 0,
aciApi: 0,
localEndpoint: 0,
dockerEdgeAgentAsync: 0,
dockerEdgeAgentStandard: 0,
});
return { analytics, setAnalytics };

View file

@ -4,6 +4,7 @@ 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 = [
{
@ -45,10 +46,10 @@ interface DeployCodeProps {
function DeployCode({ code }: DeployCodeProps) {
return (
<>
<span className="text-muted small">
<TextTip color="blue" className="mb-1">
When using the socket, ensure that you have started the Portainer
container with the following Docker flag:
</span>
</TextTip>
<Code>{code}</Code>
<div className="mt-2">

View file

@ -1,4 +1,7 @@
import { Environment } from '@/react/portainer/environments/types';
import {
ContainerEngine,
Environment,
} from '@/react/portainer/environments/types';
import { AgentForm } from '../../shared/AgentForm/AgentForm';
@ -15,7 +18,10 @@ export function AgentTab({ onCreate, isDockerStandalone }: Props) {
<DeploymentScripts isDockerStandalone={isDockerStandalone} />
<div className="mt-5">
<AgentForm onCreate={onCreate} />
<AgentForm
onCreate={onCreate}
containerEngine={ContainerEngine.Docker}
/>
</div>
</>
);

View file

@ -4,7 +4,10 @@ import { Plug2 } from 'lucide-react';
import { useCreateLocalDockerEnvironmentMutation } from '@/react/portainer/environments/queries/useCreateEnvironmentMutation';
import { notifySuccess } from '@/portainer/services/notifications';
import { Environment } from '@/react/portainer/environments/types';
import {
ContainerEngine,
Environment,
} from '@/react/portainer/environments/types';
import { LoadingButton } from '@@/buttons/LoadingButton';
import { FormControl } from '@@/form-components/FormControl';
@ -19,9 +22,10 @@ import { FormValues } from './types';
interface Props {
onCreate(environment: Environment): void;
containerEngine: ContainerEngine;
}
export function SocketForm({ onCreate }: Props) {
export function SocketForm({ onCreate, containerEngine }: Props) {
const [formKey, clearForm] = useReducer((state) => state + 1, 0);
const initialValues: FormValues = {
name: '',
@ -74,6 +78,7 @@ export function SocketForm({ onCreate }: Props) {
name: values.name,
socketPath: values.overridePath ? values.socketPath : '',
meta: values.meta,
containerEngine,
},
{
onSuccess(environment) {

View file

@ -1,4 +1,7 @@
import { Environment } from '@/react/portainer/environments/types';
import {
ContainerEngine,
Environment,
} from '@/react/portainer/environments/types';
import { DeploymentScripts } from '../APITab/DeploymentScripts';
@ -14,7 +17,10 @@ export function SocketTab({ onCreate }: Props) {
<DeploymentScripts />
<div className="mt-5">
<SocketForm onCreate={onCreate} />
<SocketForm
onCreate={onCreate}
containerEngine={ContainerEngine.Docker}
/>
</div>
</>
);

View file

@ -2,7 +2,10 @@ import { useState } from 'react';
import { Zap, Network, Plug2 } from 'lucide-react';
import _ from 'lodash';
import { Environment } from '@/react/portainer/environments/types';
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';
@ -64,6 +67,8 @@ const options: BoxSelectorOption<
},
]);
const containerEngine = ContainerEngine.Docker;
export function WizardDocker({ onCreate, isDockerStandalone }: Props) {
const [creationType, setCreationType] = useState(options[0].value);
@ -135,6 +140,7 @@ export function WizardDocker({ onCreate, isDockerStandalone }: Props) {
? [commandsTabs.standaloneWindow]
: [commandsTabs.swarmWindows],
}}
containerEngine={containerEngine}
/>
);
case 'edgeAgentAsync':
@ -152,6 +158,7 @@ export function WizardDocker({ onCreate, isDockerStandalone }: Props) {
? [commandsTabs.standaloneWindow]
: [commandsTabs.swarmWindows],
}}
containerEngine={containerEngine}
/>
);
default:

View file

@ -22,8 +22,6 @@
.wizard-list-image {
grid-area: image;
font-size: 35px;
color: #337ab7;
}
.wizard-list-title {

View file

@ -1,15 +1,13 @@
import { Plug2 } from 'lucide-react';
import clsx from 'clsx';
import { endpointTypeName, stripProtocol } from '@/portainer/filters/filters';
import {
environmentTypeIcon,
endpointTypeName,
stripProtocol,
} from '@/portainer/filters/filters';
import { EnvironmentId } from '@/react/portainer/environments/types';
import {
getEnvironmentTypeIcon,
isEdgeEnvironment,
isUnassociatedEdgeEnvironment,
} from '@/react/portainer/environments/utils';
import { EnvironmentId } from '@/react/portainer/environments/types';
import {
ENVIRONMENTS_POLLING_INTERVAL,
useEnvironmentList,
@ -51,9 +49,17 @@ export function WizardEndpointsList({ environmentIds }: Props) {
<WidgetBody>
{environments.map((environment) => (
<div className={styles.wizardListWrapper} key={environment.Id}>
<div className={styles.wizardListImage}>
<div
className={clsx(
styles.wizardListImage,
'text-blue-8 th-dark:text-blue-7 th-highcontrast:text-white text-5xl'
)}
>
<Icon
icon={environmentTypeIcon(environment.Type)}
icon={getEnvironmentTypeIcon(
environment.Type,
environment.ContainerEngine
)}
className="mr-1"
/>
</div>

View file

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

View file

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

View file

@ -0,0 +1 @@
export { AgentTab } from './AgentTab';

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1 @@
export { SocketTab } from './SocketTab';

View file

@ -0,0 +1,8 @@
import { EnvironmentMetadata } from '@/react/portainer/environments/environment.service/create';
export interface FormValues {
name: string;
socketPath: string;
overridePath: boolean;
meta: EnvironmentMetadata;
}

View file

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

View file

@ -0,0 +1 @@
export { WizardPodman } from './WizardPodman';

View file

@ -4,7 +4,10 @@ import { Plug2 } from 'lucide-react';
import { useCreateAgentEnvironmentMutation } from '@/react/portainer/environments/queries/useCreateEnvironmentMutation';
import { notifySuccess } from '@/portainer/services/notifications';
import { Environment } from '@/react/portainer/environments/types';
import {
ContainerEngine,
Environment,
} from '@/react/portainer/environments/types';
import { CreateAgentEnvironmentValues } from '@/react/portainer/environments/environment.service/create';
import { LoadingButton } from '@@/buttons/LoadingButton';
@ -18,6 +21,7 @@ import { useValidation } from './AgentForm.validation';
interface Props {
onCreate(environment: Environment): void;
envDefaultPort?: string;
containerEngine?: ContainerEngine;
}
const initialValues: CreateAgentEnvironmentValues = {
@ -29,7 +33,11 @@ const initialValues: CreateAgentEnvironmentValues = {
},
};
export function AgentForm({ onCreate, envDefaultPort }: Props) {
export function AgentForm({
onCreate,
envDefaultPort,
containerEngine = ContainerEngine.Docker,
}: Props) {
const [formKey, clearForm] = useReducer((state) => state + 1, 0);
const mutation = useCreateAgentEnvironmentMutation();
@ -70,12 +78,15 @@ export function AgentForm({ onCreate, envDefaultPort }: Props) {
);
function handleSubmit(values: CreateAgentEnvironmentValues) {
mutation.mutate(values, {
onSuccess(environment) {
notifySuccess('Environment created', environment.Name);
clearForm();
onCreate(environment);
},
});
mutation.mutate(
{ ...values, containerEngine },
{
onSuccess(environment) {
notifySuccess('Environment created', environment.Name);
clearForm();
onCreate(environment);
},
}
);
}
}

View file

@ -1,4 +1,4 @@
import { object, SchemaOf, string } from 'yup';
import { mixed, object, SchemaOf, string } from 'yup';
import { CreateAgentEnvironmentValues } from '@/react/portainer/environments/environment.service/create';
@ -10,6 +10,7 @@ export function useValidation(): SchemaOf<CreateAgentEnvironmentValues> {
name: useNameValidation(),
environmentUrl: environmentValidation(),
meta: metadataValidation(),
containerEngine: mixed().oneOf(['docker', 'podman']),
});
}

View file

@ -1,7 +1,10 @@
import { Formik, Form } from 'formik';
import { Plug2 } from 'lucide-react';
import { Environment } from '@/react/portainer/environments/types';
import {
ContainerEngine,
Environment,
} from '@/react/portainer/environments/types';
import { useCreateEdgeAgentEnvironmentMutation } from '@/react/portainer/environments/queries/useCreateEnvironmentMutation';
import { Settings } from '@/react/portainer/settings/types';
import { EdgeCheckinIntervalField } from '@/react/edge/components/EdgeCheckInIntervalField';
@ -26,9 +29,15 @@ interface Props {
onCreate(environment: Environment): void;
readonly: boolean;
asyncMode: boolean;
containerEngine: ContainerEngine;
}
export function EdgeAgentForm({ onCreate, readonly, asyncMode }: Props) {
export function EdgeAgentForm({
onCreate,
readonly,
asyncMode,
containerEngine,
}: Props) {
const settingsQuery = useSettings();
const createMutation = useCreateEdgeAgentEnvironmentMutation();
@ -100,6 +109,7 @@ export function EdgeAgentForm({ onCreate, readonly, asyncMode }: Props) {
...values.edge,
asyncMode,
},
containerEngine,
},
{
onSuccess(environment) {

View file

@ -1,7 +1,10 @@
import { v4 as uuid } from 'uuid';
import { useReducer, useState } from 'react';
import { Environment } from '@/react/portainer/environments/types';
import {
ContainerEngine,
Environment,
} from '@/react/portainer/environments/types';
import { EdgeScriptForm } from '@/react/edge/components/EdgeScriptForm';
import { CommandTab } from '@/react/edge/components/EdgeScriptForm/scripts';
import { OS, EdgeInfo } from '@/react/edge/components/EdgeScriptForm/types';
@ -15,9 +18,15 @@ interface Props {
onCreate: (environment: Environment) => void;
commands: CommandTab[] | Partial<Record<OS, CommandTab[]>>;
asyncMode?: boolean;
containerEngine?: ContainerEngine;
}
export function EdgeAgentTab({ onCreate, commands, asyncMode = false }: Props) {
export function EdgeAgentTab({
onCreate,
commands,
asyncMode = false,
containerEngine = ContainerEngine.Docker,
}: Props) {
const [edgeInfo, setEdgeInfo] = useState<EdgeInfo>();
const [formKey, clearForm] = useReducer((state) => state + 1, 0);
@ -28,6 +37,7 @@ export function EdgeAgentTab({ onCreate, commands, asyncMode = false }: Props) {
readonly={!!edgeInfo}
key={formKey}
asyncMode={asyncMode}
containerEngine={containerEngine}
/>
{edgeInfo && (

View file

@ -3,12 +3,16 @@ export interface AnalyticsState {
dockerApi: number;
dockerEdgeAgentStandard: number;
dockerEdgeAgentAsync: number;
podmanAgent: number;
podmanEdgeAgentStandard: number;
podmanEdgeAgentAsync: number;
podmanLocalEnvironment: number; // podman socket
kubernetesAgent: number;
kubernetesEdgeAgentStandard: number;
kubernetesEdgeAgentAsync: number;
kaasAgent: number;
aciApi: number;
localEndpoint: number;
localEndpoint: number; // docker socket
}
export type AnalyticsStateKey = keyof AnalyticsState;

View file

@ -3,6 +3,7 @@ import { useMutation } from '@tanstack/react-query';
import { useEnvironmentList } from '@/react/portainer/environments/queries/useEnvironmentList';
import {
ContainerEngine,
Environment,
EnvironmentType,
} from '@/react/portainer/environments/types';
@ -62,11 +63,30 @@ function getStatus(
}
async function createLocalEnvironment() {
try {
return await createLocalKubernetesEnvironment({ name: 'local' });
} catch (err) {
return await createLocalDockerEnvironment({ name: 'local' });
const name = 'local';
const attempts = [
() => createLocalKubernetesEnvironment({ name }),
() =>
createLocalDockerEnvironment({
name,
containerEngine: ContainerEngine.Podman,
}),
() =>
createLocalDockerEnvironment({
name,
containerEngine: ContainerEngine.Docker,
}),
];
for (let i = 0; i < attempts.length; i++) {
try {
return await attempts[i]();
} catch (err) {
// Continue to next attempt
}
}
throw new Error('Failed to create local environment with any method');
}
function useFetchLocalEnvironment() {

View file

@ -1,6 +1,7 @@
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { useIsSwarm } from '@/react/docker/proxy/queries/useInfo';
import { StackType } from '@/react/common/stacks/types';
import { ContainerEngine } from '@/react/portainer/environments/types';
import { PageHeader } from '@@/PageHeader';
import { Widget } from '@@/Widget';
@ -12,16 +13,18 @@ import { CreateForm } from './CreateForm';
export function CreateView() {
const viewType = useViewType();
const environmentId = useEnvironmentId(false);
const isSwarm = useIsSwarm(environmentId, { enabled: viewType === 'docker' });
const isSwarm = useIsSwarm(environmentId, {
enabled: viewType === ContainerEngine.Docker,
});
const defaultType = getDefaultType(viewType, isSwarm);
return (
<div>
<PageHeader
title="Create Custom template"
title="Create Custom Template"
breadcrumbs={[
{ label: 'Custom Templates', link: '^' },
'Create Custom template',
'Create Custom Template',
]}
/>

View file

@ -1,5 +1,6 @@
import { notifySuccess } from '@/portainer/services/notifications';
import { useParamState } from '@/react/hooks/useParamState';
import { ContainerEngine } from '@/react/portainer/environments/types';
import { PageHeader } from '@@/PageHeader';
import { confirmDelete } from '@@/modals/confirm';
@ -28,7 +29,7 @@ export function ListView() {
<>
<PageHeader title="Custom Templates" breadcrumbs="Custom Templates" />
{viewType === 'docker' && !!selectedTemplateId && (
{viewType === ContainerEngine.Docker && !!selectedTemplateId && (
<StackFromCustomTemplateFormWidget templateId={selectedTemplateId} />
)}

View file

@ -85,6 +85,7 @@ function Content({ environment, onClear }: ContentProps) {
} = {
[PlatformType.Azure]: AzureSidebar,
[PlatformType.Docker]: DockerSidebar,
[PlatformType.Podman]: DockerSidebar, // same as docker for now, until pod management is added
[PlatformType.Kubernetes]: KubernetesSidebar,
};
@ -124,7 +125,10 @@ interface TitleProps {
function Title({ environment, onClear }: TitleProps) {
const { isOpen } = useSidebarState();
const EnvironmentIcon = getPlatformIcon(environment.Type);
const EnvironmentIcon = getPlatformIcon(
environment.Type,
environment.ContainerEngine
);
if (!isOpen) {
return (