@@ -145,7 +145,7 @@
- {{ k }}
+ {{ k }}
{{ v }}
diff --git a/app/docker/views/images/edit/imageController.js b/app/docker/views/images/edit/imageController.js
index a461d7bb1..4b6bb51da 100644
--- a/app/docker/views/images/edit/imageController.js
+++ b/app/docker/views/images/edit/imageController.js
@@ -2,6 +2,7 @@ import _ from 'lodash-es';
import { PorImageRegistryModel } from 'Docker/models/porImageRegistry';
import { confirmImageExport } from '@/react/docker/images/common/ConfirmExportModal';
import { confirmDelete } from '@@/modals/confirm';
+import { fullURIIntoRepoAndTag } from '@/react/docker/images/utils';
angular.module('portainer.docker').controller('ImageController', [
'$async',
@@ -71,8 +72,9 @@ angular.module('portainer.docker').controller('ImageController', [
const registryModel = $scope.formValues.RegistryModel;
const image = ImageHelper.createImageConfigForContainer(registryModel);
+ const { repo, tag } = fullURIIntoRepoAndTag(image.fromImage);
- ImageService.tagImage($transition$.params().id, image.fromImage)
+ ImageService.tagImage($transition$.params().id, repo, tag)
.then(function success() {
Notifications.success('Success', 'Image successfully tagged');
$state.go('docker.images.image', { id: $transition$.params().id }, { reload: true });
diff --git a/app/docker/views/images/import/importImageController.js b/app/docker/views/images/import/importImageController.js
index a6449976e..dfdb8ab1a 100644
--- a/app/docker/views/images/import/importImageController.js
+++ b/app/docker/views/images/import/importImageController.js
@@ -1,15 +1,17 @@
import { PorImageRegistryModel } from 'Docker/models/porImageRegistry';
+import { fullURIIntoRepoAndTag } from '@/react/docker/images/utils';
angular.module('portainer.docker').controller('ImportImageController', [
'$scope',
'$state',
+ '$async',
'ImageService',
'Notifications',
'HttpRequestHelper',
'Authentication',
'ImageHelper',
'endpoint',
- function ($scope, $state, ImageService, Notifications, HttpRequestHelper, Authentication, ImageHelper, endpoint) {
+ function ($scope, $state, $async, ImageService, Notifications, HttpRequestHelper, Authentication, ImageHelper, endpoint) {
$scope.state = {
actionInProgress: false,
};
@@ -33,15 +35,20 @@ angular.module('portainer.docker').controller('ImportImageController', [
const registryModel = $scope.formValues.RegistryModel;
if (registryModel.Image) {
const image = ImageHelper.createImageConfigForContainer(registryModel);
+ const { repo, tag } = fullURIIntoRepoAndTag(image.fromImage);
try {
- await ImageService.tagImage(id, image.fromImage);
+ await ImageService.tagImage(id, repo, tag);
} catch (err) {
Notifications.error('Failure', err, 'Unable to tag image');
}
}
}
- $scope.uploadImage = async function () {
+ $scope.uploadImage = function () {
+ return $async(uploadImageAsync);
+ };
+
+ async function uploadImageAsync() {
$scope.state.actionInProgress = true;
var nodeName = $scope.formValues.NodeName;
@@ -52,7 +59,8 @@ angular.module('portainer.docker').controller('ImportImageController', [
if (data.error) {
Notifications.error('Failure', data.error, 'Unable to upload image');
} else if (data.stream) {
- var regex = /Loaded.*?: (.*?)\n$/g;
+ // docker has /n at the end of the stream, podman doesn't
+ var regex = /Loaded.*?: (.*?)(?:\n|$)/g;
var imageIds = regex.exec(data.stream);
if (imageIds && imageIds.length == 2) {
await tagImage(imageIds[1]);
@@ -67,6 +75,6 @@ angular.module('portainer.docker').controller('ImportImageController', [
} finally {
$scope.state.actionInProgress = false;
}
- };
+ }
},
]);
diff --git a/app/docker/views/images/import/importimage.html b/app/docker/views/images/import/importimage.html
index cda5dce97..9780dc900 100644
--- a/app/docker/views/images/import/importimage.html
+++ b/app/docker/views/images/import/importimage.html
@@ -12,7 +12,7 @@
{{ volume.Id }}
+
+
+ Browse
+
Remove this volume
diff --git a/app/docker/views/volumes/edit/volumeController.js b/app/docker/views/volumes/edit/volumeController.js
index 67eae5108..b5e38213c 100644
--- a/app/docker/views/volumes/edit/volumeController.js
+++ b/app/docker/views/volumes/edit/volumeController.js
@@ -9,10 +9,12 @@ angular.module('portainer.docker').controller('VolumeController', [
'ContainerService',
'Notifications',
'HttpRequestHelper',
+ 'Authentication',
'endpoint',
- function ($scope, $state, $transition$, VolumeService, ContainerService, Notifications, HttpRequestHelper, endpoint) {
+ function ($scope, $state, $transition$, VolumeService, ContainerService, Notifications, HttpRequestHelper, Authentication, endpoint) {
$scope.resourceType = ResourceControlType.Volume;
$scope.endpoint = endpoint;
+ $scope.showBrowseAction = false;
$scope.onUpdateResourceControlSuccess = function () {
$state.reload();
@@ -41,6 +43,7 @@ angular.module('portainer.docker').controller('VolumeController', [
function initView() {
HttpRequestHelper.setPortainerAgentTargetHeader($transition$.params().nodeName);
+ $scope.showBrowseAction = $scope.applicationState.endpoint.mode.agentProxy && (Authentication.isAdmin() || endpoint.SecuritySettings.allowVolumeBrowserForRegularUsers);
VolumeService.volume($transition$.params().id)
.then(function success(data) {
diff --git a/app/portainer/filters/filters.js b/app/portainer/filters/filters.js
index 5e75f9d7b..bdaadd247 100644
--- a/app/portainer/filters/filters.js
+++ b/app/portainer/filters/filters.js
@@ -1,12 +1,6 @@
import moment from 'moment';
import _ from 'lodash-es';
import filesize from 'filesize';
-import { Cloud } from 'lucide-react';
-
-import Kube from '@/assets/ico/kube.svg?c';
-import DockerIcon from '@/assets/ico/vendor/docker-icon.svg?c';
-import MicrosoftIcon from '@/assets/ico/vendor/microsoft-icon.svg?c';
-import { EnvironmentType } from '@/react/portainer/environments/types';
export function truncateLeftRight(text, max, left, right) {
max = isNaN(max) ? 50 : max;
@@ -105,24 +99,6 @@ export function endpointTypeName(type) {
return '';
}
-export function environmentTypeIcon(type) {
- 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:
- return DockerIcon;
- default:
- throw new Error(`type ${type}-${EnvironmentType[type]} is not supported`);
- }
-}
-
export function truncate(text, length, end) {
if (isNaN(length)) {
length = 10;
diff --git a/app/portainer/filters/index.js b/app/portainer/filters/index.js
index 0e817d29f..d261caed6 100644
--- a/app/portainer/filters/index.js
+++ b/app/portainer/filters/index.js
@@ -4,7 +4,6 @@ import _ from 'lodash-es';
import { ownershipIcon } from '@/react/docker/components/datatable/createOwnershipColumn';
import {
arrayToStr,
- environmentTypeIcon,
endpointTypeName,
getPairKey,
getPairValue,
@@ -34,5 +33,4 @@ angular
.filter('arraytostr', () => arrayToStr)
.filter('labelsToStr', () => labelsToStr)
.filter('endpointtypename', () => endpointTypeName)
- .filter('endpointtypeicon', () => environmentTypeIcon)
.filter('ownershipicon', () => ownershipIcon);
diff --git a/app/portainer/views/endpoints/edit/endpointController.js b/app/portainer/views/endpoints/edit/endpointController.js
index 4b8d3d585..3658a6c13 100644
--- a/app/portainer/views/endpoints/edit/endpointController.js
+++ b/app/portainer/views/endpoints/edit/endpointController.js
@@ -53,7 +53,7 @@ function EndpointController(
showAMTInfo: false,
showTLSConfig: false,
edgeScriptCommands: {
- linux: _.compact([commandsTabs.k8sLinux, commandsTabs.swarmLinux, commandsTabs.standaloneLinux]),
+ linux: _.compact([commandsTabs.k8sLinux, commandsTabs.swarmLinux, commandsTabs.standaloneLinux, commandsTabs.podmanLinux]),
win: [commandsTabs.swarmWindows, commandsTabs.standaloneWindow],
},
};
@@ -297,7 +297,6 @@ function EndpointController(
return $async(async () => {
try {
const [endpoint, groups, settings] = await Promise.all([EndpointService.endpoint($transition$.params().id), GroupService.groups(), SettingsService.settings()]);
-
if (isDockerAPIEnvironment(endpoint)) {
$scope.state.showTLSConfig = true;
}
diff --git a/app/portainer/views/stacks/create/createstack.html b/app/portainer/views/stacks/create/createstack.html
index c7e9ffd77..4ddd88261 100644
--- a/app/portainer/views/stacks/create/createstack.html
+++ b/app/portainer/views/stacks/create/createstack.html
@@ -162,7 +162,7 @@
+
);
diff --git a/app/react/components/buttons/CopyButton/CopyButton.tsx b/app/react/components/buttons/CopyButton/CopyButton.tsx
index 1d4693049..1be902990 100644
--- a/app/react/components/buttons/CopyButton/CopyButton.tsx
+++ b/app/react/components/buttons/CopyButton/CopyButton.tsx
@@ -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({
{indicatorPosition === 'left' && copiedIndicator()}
(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, 'settingsManager'>) {
+ const tableState = useTableState(settingsStore, storageKey);
+ return (
+
+ );
+}
+
+describe('Datatable', () => {
+ it('renders the table with correct data', () => {
+ render(
+
+ );
+
+ 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(
+
+ );
+
+ expect(screen.getByText('Test Table')).toBeInTheDocument();
+ });
+
+ it('handles row selection when not disabled', () => {
+ render(
+
+ );
+
+ 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(
+
+ );
+
+ const checkboxes = screen.queryAllByRole('checkbox');
+ expect(checkboxes.length).toBe(0);
+ });
+
+ it('handles sorting', () => {
+ render(
+
+ );
+
+ 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(
+
+ );
+
+ expect(screen.getByText('Loading...')).toBeInTheDocument();
+ });
+
+ it('renders empty state', () => {
+ render(
+
+ );
+
+ 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();
+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
+ );
+ });
+});
diff --git a/app/react/components/datatables/Datatable.tsx b/app/react/components/datatables/Datatable.tsx
index 8fb79f1d1..a7f8b2862 100644
--- a/app/react/components/datatables/Datatable.tsx
+++ b/app/react/components/datatables/Datatable.tsx
@@ -272,6 +272,21 @@ export function defaultGlobalFilterFn(
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(
) {
return value.toString().toLowerCase().includes(filterValueLower);
}
-
- if (Array.isArray(value)) {
- return value.some((item) =>
- item.toString().toLowerCase().includes(filterValueLower)
- );
- }
-
return false;
}
diff --git a/app/react/docker/DashboardView/EnvironmentInfo.DockerInfo.tsx b/app/react/docker/DashboardView/EnvironmentInfo.DockerInfo.tsx
index 98f565ad4..7ea5183a3 100644
--- a/app/react/docker/DashboardView/EnvironmentInfo.DockerInfo.tsx
+++ b/app/react/docker/DashboardView/EnvironmentInfo.DockerInfo.tsx
@@ -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 (
-
- {isSwarm ? 'Swarm' : 'Standalone'} {info.ServerVersion}
+
+
+ {type} {info.ServerVersion}
+
{isAgent && (
-
-
- Agent
-
+ <>
+ -
+
+
+ Agent
+
+ >
)}
);
diff --git a/app/react/docker/DashboardView/EnvironmentInfo.tsx b/app/react/docker/DashboardView/EnvironmentInfo.tsx
index 9c32ba7ad..e3bab5d3c 100644
--- a/app/react/docker/DashboardView/EnvironmentInfo.tsx
+++ b/app/react/docker/DashboardView/EnvironmentInfo.tsx
@@ -44,7 +44,8 @@ export function EnvironmentInfo() {
{environment.Name}
- -
+
+ -
diff --git a/app/react/docker/containers/CreateView/BaseForm/PortsMappingField.tsx b/app/react/docker/containers/CreateView/BaseForm/PortsMappingField.tsx
index 4a2e32d75..cbf33470a 100644
--- a/app/react/docker/containers/CreateView/BaseForm/PortsMappingField.tsx
+++ b/app/react/docker/containers/CreateView/BaseForm/PortsMappingField.tsx
@@ -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({
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}
/>
diff --git a/app/react/docker/containers/CreateView/CommandsTab/LoggerConfig.tsx b/app/react/docker/containers/CreateView/CommandsTab/LoggerConfig.tsx
index c6f472f8e..5235fa3af 100644
--- a/app/react/docker/containers/CreateView/CommandsTab/LoggerConfig.tsx
+++ b/app/react/docker/containers/CreateView/CommandsTab/LoggerConfig.tsx
@@ -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;
}) {
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;
diff --git a/app/react/docker/containers/CreateView/NetworkTab/NetworkTab.tsx b/app/react/docker/containers/CreateView/NetworkTab/NetworkTab.tsx
index 771ee9552..c3654f069 100644
--- a/app/react/docker/containers/CreateView/NetworkTab/NetworkTab.tsx
+++ b/app/react/docker/containers/CreateView/NetworkTab/NetworkTab.tsx
@@ -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;
}) {
+ const envId = useEnvironmentId();
+ const isPodman = useIsPodman(envId);
+ const additionalOptions = getAdditionalOptions(isPodman);
return (
setFieldValue('networkMode', networkMode)}
/>
@@ -105,3 +111,10 @@ export function NetworkTab({
);
}
+
+function getAdditionalOptions(isPodman?: boolean) {
+ if (isPodman) {
+ return [];
+ }
+ return [{ label: 'Container', value: CONTAINER_MODE }];
+}
diff --git a/app/react/docker/containers/CreateView/NetworkTab/toViewModel.test.ts b/app/react/docker/containers/CreateView/NetworkTab/toViewModel.test.ts
new file mode 100644
index 000000000..961f2aaf4
--- /dev/null
+++ b/app/react/docker/containers/CreateView/NetworkTab/toViewModel.test.ts
@@ -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 = [
+ {
+ 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 = [
+ {
+ 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']);
+ });
+});
diff --git a/app/react/docker/containers/CreateView/NetworkTab/toViewModel.ts b/app/react/docker/containers/CreateView/NetworkTab/toViewModel.ts
index 4adc3baab..d827da7e8 100644
--- a/app/react/docker/containers/CreateView/NetworkTab/toViewModel.ts
+++ b/app/react/docker/containers/CreateView/NetworkTab/toViewModel.ts
@@ -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,
- runningContainers: Array = []
+ runningContainers: Array = [],
+ 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,
- runningContainers: Array = []
+ runningContainers: Array = [],
+ 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;
}
diff --git a/app/react/docker/containers/CreateView/useInitialValues.ts b/app/react/docker/containers/CreateView/useInitialValues.ts
index ca97957b9..2e4b892c1 100644
--- a/app/react/docker/containers/CreateView/useInitialValues.ts
+++ b/app/react/docker/containers/CreateView/useInitialValues.ts
@@ -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(),
diff --git a/app/react/docker/containers/StatsView/ProcessesDatatable.tsx b/app/react/docker/containers/StatsView/ProcessesDatatable.tsx
index df8a4f72a..e4892bba0 100644
--- a/app/react/docker/containers/StatsView/ProcessesDatatable.tsx
+++ b/app/react/docker/containers/StatsView/ProcessesDatatable.tsx
@@ -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>;
- headers?: Array;
-}) {
- 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]);
-
- const columns = useMemo(
- () =>
- headers
- ? headers.map(
- (header) =>
- ({ header, accessorKey: header }) satisfies ColumnDef<{
- [k: string]: string;
- }>
- )
- : [],
- [headers]
+type ProcessesDatatableProps = {
+ rows: Array;
+ columns: Array>;
+};
+
+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 (
);
}
+
+// 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,
+ };
+}
diff --git a/app/react/docker/containers/components/NetworkSelector.tsx b/app/react/docker/containers/components/NetworkSelector.tsx
index 846c0d830..4eba4008d 100644
--- a/app/react/docker/containers/components/NetworkSelector.tsx
+++ b/app/react/docker/containers/components/NetworkSelector.tsx
@@ -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 };
+ });
},
});
diff --git a/app/react/docker/containers/queries/query-keys.ts b/app/react/docker/containers/queries/query-keys.ts
index ec39d14dd..30661cdaa 100644
--- a/app/react/docker/containers/queries/query-keys.ts
+++ b/app/react/docker/containers/queries/query-keys.ts
@@ -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,
};
diff --git a/app/react/docker/containers/queries/types.ts b/app/react/docker/containers/queries/types.ts
index 115524cfe..681da4700 100644
--- a/app/react/docker/containers/queries/types.ts
+++ b/app/react/docker/containers/queries/types.ts
@@ -7,3 +7,8 @@ export interface Filters {
network?: NetworkId[];
status?: ContainerStatus[];
}
+
+export type ContainerProcesses = {
+ Processes: Array>;
+ Titles: Array;
+};
diff --git a/app/react/docker/containers/queries/useContainerTop.ts b/app/react/docker/containers/queries/useContainerTop.ts
index 26c1b7734..f07f1a7fe 100644
--- a/app/react/docker/containers/queries/useContainerTop.ts
+++ b/app/react/docker/containers/queries/useContainerTop.ts
@@ -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(
+ 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(
buildDockerProxyUrl(environmentId, 'containers', id, 'top')
);
return data;
diff --git a/app/react/docker/images/ListView/ImagesDatatable/columns/id.tsx b/app/react/docker/images/ListView/ImagesDatatable/columns/id.tsx
index 3a56e715a..00081aa7e 100644
--- a/app/react/docker/images/ListView/ImagesDatatable/columns/id.tsx
+++ b/app/react/docker/images/ListView/ImagesDatatable/columns/id.tsx
@@ -73,11 +73,11 @@ function Cell({
});
return (
- <>
+
);
}
diff --git a/app/react/docker/images/ListView/ImagesDatatable/columns/tags.tsx b/app/react/docker/images/ListView/ImagesDatatable/columns/tags.tsx
index 2a3262c04..9ca5a71ba 100644
--- a/app/react/docker/images/ListView/ImagesDatatable/columns/tags.tsx
+++ b/app/react/docker/images/ListView/ImagesDatatable/columns/tags.tsx
@@ -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 (
- <>
+
{repoTags?.map((tag, idx) => (
-
+
{tag}
-
+
))}
- >
+
);
}
diff --git a/app/react/docker/images/utils.test.ts b/app/react/docker/images/utils.test.ts
new file mode 100644
index 000000000..05f0e39ff
--- /dev/null
+++ b/app/react/docker/images/utils.test.ts
@@ -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',
+ });
+ });
+});
diff --git a/app/react/docker/images/utils.ts b/app/react/docker/images/utils.ts
index f2b7f6a1c..10d6ae0e9 100644
--- a/app/react/docker/images/utils.ts
+++ b/app/react/docker/images/utils.ts
@@ -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,
+ };
+}
diff --git a/app/react/docker/proxy/queries/images/useTagImageMutation.ts b/app/react/docker/proxy/queries/images/useTagImageMutation.ts
index 95ca22517..30eb278f2 100644
--- a/app/react/docker/proxy/queries/images/useTagImageMutation.ts
+++ b/app/react/docker/proxy/queries/images/useTagImageMutation.ts
@@ -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) {
diff --git a/app/react/docker/proxy/queries/useServicePlugins.ts b/app/react/docker/proxy/queries/usePlugins.ts
similarity index 89%
rename from app/react/docker/proxy/queries/useServicePlugins.ts
rename to app/react/docker/proxy/queries/usePlugins.ts
index 327be9ce6..1f88cb7d5 100644
--- a/app/react/docker/proxy/queries/useServicePlugins.ts
+++ b/app/react/docker/proxy/queries/usePlugins.ts
@@ -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(
diff --git a/app/react/docker/volumes/ListView/VolumesDatatable/columns/name.tsx b/app/react/docker/volumes/ListView/VolumesDatatable/columns/name.tsx
index 0ae680492..1758ae940 100644
--- a/app/react/docker/volumes/ListView/VolumesDatatable/columns/name.tsx
+++ b/app/react/docker/volumes/ListView/VolumesDatatable/columns/name.tsx
@@ -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
)}
diff --git a/app/react/edge/components/EdgeScriptForm/EdgeScriptSettingsFieldset.tsx b/app/react/edge/components/EdgeScriptForm/EdgeScriptSettingsFieldset.tsx
index 3aed3f6bd..226886efa 100644
--- a/app/react/edge/components/EdgeScriptForm/EdgeScriptSettingsFieldset.tsx
+++ b/app/react/edge/components/EdgeScriptForm/EdgeScriptSettingsFieldset.tsx
@@ -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({
diff --git a/app/react/edge/components/EdgeScriptForm/scripts.ts b/app/react/edge/components/EdgeScriptForm/scripts.ts
index 1f547502f..2e005b2fb 100644
--- a/app/react/edge/components/EdgeScriptForm/scripts.ts
+++ b/app/react/edge/components/EdgeScriptForm/scripts.ts
@@ -35,6 +35,11 @@ export const commandsTabs: Record
= {
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,
diff --git a/app/react/edge/components/EdgeScriptForm/types.ts b/app/react/edge/components/EdgeScriptForm/types.ts
index 0e702655c..4113563e7 100644
--- a/app/react/edge/components/EdgeScriptForm/types.ts
+++ b/app/react/edge/components/EdgeScriptForm/types.ts
@@ -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 {
diff --git a/app/react/edge/edge-devices/WaitingRoomView/WaitingRoomView.tsx b/app/react/edge/edge-devices/WaitingRoomView/WaitingRoomView.tsx
index 0a6428611..062e1dc42 100644
--- a/app/react/edge/edge-devices/WaitingRoomView/WaitingRoomView.tsx
+++ b/app/react/edge/edge-devices/WaitingRoomView/WaitingRoomView.tsx
@@ -26,9 +26,15 @@ function WaitingRoomView() {
- 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{' '}
+
+ auto onboarding
+ {' '}
+ script will appear here, manually added environments and edge
+ devices will bypass the waiting room.
diff --git a/app/react/hooks/useEnvironmentId.ts b/app/react/hooks/useEnvironmentId.ts
index 7ba1667b8..be75e2c54 100644
--- a/app/react/hooks/useEnvironmentId.ts
+++ b/app/react/hooks/useEnvironmentId.ts
@@ -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 },
diff --git a/app/react/portainer/HomeView/EnvironmentList/EnvironmentItem/EngineVersion.tsx b/app/react/portainer/HomeView/EnvironmentList/EnvironmentItem/EngineVersion.tsx
index 727010574..83e337686 100644
--- a/app/react/portainer/HomeView/EnvironmentList/EnvironmentItem/EngineVersion.tsx
+++ b/app/react/portainer/HomeView/EnvironmentList/EnvironmentItem/EngineVersion.tsx
@@ -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 ;
+ return (
+
+ );
case PlatformType.Kubernetes:
return (
- {snapshot.Swarm ? 'Swarm' : 'Standalone'} {snapshot.DockerVersion}
+ {type} {snapshot.DockerVersion}
);
}
diff --git a/app/react/portainer/HomeView/EnvironmentList/EnvironmentItem/EnvironmentIcon.tsx b/app/react/portainer/HomeView/EnvironmentList/EnvironmentItem/EnvironmentIcon.tsx
index f87458e72..8580baa04 100644
--- a/app/react/portainer/HomeView/EnvironmentList/EnvironmentItem/EnvironmentIcon.tsx
+++ b/app/react/portainer/HomeView/EnvironmentList/EnvironmentItem/EnvironmentIcon.tsx
@@ -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 (
+
+ );
+ }
return (
-
+
);
case EnvironmentType.Azure:
return (
-
+
);
case EnvironmentType.EdgeAgentOnDocker:
+ if (containerEngine === ContainerEngine.Podman) {
+ return (
+
+ );
+ }
return (
-
+
);
case EnvironmentType.KubernetesLocal:
case EnvironmentType.AgentOnKubernetes:
- return ;
+ return ;
case EnvironmentType.EdgeAgentOnKubernetes:
return (
-
+
);
default:
return (
);
diff --git a/app/react/portainer/HomeView/EnvironmentList/EnvironmentItem/EnvironmentItem.tsx b/app/react/portainer/HomeView/EnvironmentList/EnvironmentItem/EnvironmentItem.tsx
index a09713c8f..48c939e6c 100644
--- a/app/react/portainer/HomeView/EnvironmentList/EnvironmentItem/EnvironmentItem.tsx
+++ b/app/react/portainer/HomeView/EnvironmentList/EnvironmentItem/EnvironmentItem.tsx
@@ -66,7 +66,10 @@ export function EnvironmentItem({
params={dashboardRoute.params}
>
-
+
diff --git a/app/react/portainer/HomeView/EnvironmentList/EnvironmentList.tsx b/app/react/portainer/HomeView/EnvironmentList/EnvironmentList.tsx
index 57cd47312..e2c20111a 100644
--- a/app/react/portainer/HomeView/EnvironmentList/EnvironmentList.tsx
+++ b/app/react/portainer/HomeView/EnvironmentList/EnvironmentList.tsx
@@ -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,
diff --git a/app/react/portainer/HomeView/EnvironmentList/EnvironmentListFilters.tsx b/app/react/portainer/HomeView/EnvironmentList/EnvironmentListFilters.tsx
index 0a64c7396..3c4d993e4 100644
--- a/app/react/portainer/HomeView/EnvironmentList/EnvironmentListFilters.tsx
+++ b/app/react/portainer/HomeView/EnvironmentList/EnvironmentListFilters.tsx
@@ -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,
diff --git a/app/react/portainer/custom-templates/components/TemplateTypeSelector.tsx b/app/react/portainer/custom-templates/components/TemplateTypeSelector.tsx
index 482fc1947..7016eec41 100644
--- a/app/react/portainer/custom-templates/components/TemplateTypeSelector.tsx
+++ b/app/react/portainer/custom-templates/components/TemplateTypeSelector.tsx
@@ -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({
diff --git a/app/react/portainer/environments/ListView/columns/type.tsx b/app/react/portainer/environments/ListView/columns/type.tsx
index 5f82deace..58d984317 100644
--- a/app/react/portainer/environments/ListView/columns/type.tsx
+++ b/app/react/portainer/environments/ListView/columns/type.tsx
@@ -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;
+};
+
+export const type = columnHelper.accessor(
+ (rowItem): TypeCellContext => ({
+ type: rowItem.Type,
+ containerEngine: rowItem.ContainerEngine,
+ }),
+ {
+ header: 'Type',
+ cell: Cell,
+ id: 'Type',
+ }
+);
-function Cell({ getValue }: CellContext
) {
- const type = getValue();
+function Cell({ getValue }: CellContext) {
+ const { type, containerEngine } = getValue();
return (
-
- {getPlatformTypeName(type)}
+
+ {getPlatformTypeName(type, containerEngine)}
);
}
diff --git a/app/react/portainer/environments/environment.service/create.ts b/app/react/portainer/environments/environment.service/create.ts
index a2cd00c62..f07a62397 100644
--- a/app/react/portainer/environments/environment.service/create.ts
+++ b/app/react/portainer/environments/environment.service/create.ts
@@ -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;
+ 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,
diff --git a/app/react/portainer/environments/queries/useIsPodman.ts b/app/react/portainer/environments/queries/useIsPodman.ts
new file mode 100644
index 000000000..12f272012
--- /dev/null
+++ b/app/react/portainer/environments/queries/useIsPodman.ts
@@ -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;
+}
diff --git a/app/react/portainer/environments/types.ts b/app/react/portainer/environments/types.ts
index 47b5a4284..ed87643cd 100644
--- a/app/react/portainer/environments/types.ts
+++ b/app/react/portainer/environments/types.ts
@@ -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,
}
diff --git a/app/react/portainer/environments/utils/get-platform-icon.ts b/app/react/portainer/environments/utils/get-platform-icon.ts
index c26231a05..a84e4ab63 100644
--- a/app/react/portainer/environments/utils/get-platform-icon.ts
+++ b/app/react/portainer/environments/utils/get-platform-icon.ts
@@ -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];
}
diff --git a/app/react/portainer/environments/utils/getDockerEnvironmentType.ts b/app/react/portainer/environments/utils/getDockerEnvironmentType.ts
new file mode 100644
index 000000000..7f1e8297a
--- /dev/null
+++ b/app/react/portainer/environments/utils/getDockerEnvironmentType.ts
@@ -0,0 +1,6 @@
+export function getDockerEnvironmentType(isSwarm: boolean, isPodman?: boolean) {
+ if (isPodman) {
+ return 'Podman';
+ }
+ return isSwarm ? 'Swarm' : 'Standalone';
+}
diff --git a/app/react/portainer/environments/utils/index.ts b/app/react/portainer/environments/utils/index.ts
index 2ae3185dc..35fc4982c 100644
--- a/app/react/portainer/environments/utils/index.ts
+++ b/app/react/portainer/environments/utils/index.ts
@@ -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`);
+ }
+}
diff --git a/app/react/portainer/environments/wizard/EnvironmentTypeSelectView/EndpointTypeView.tsx b/app/react/portainer/environments/wizard/EnvironmentTypeSelectView/EnvironmentTypeSelectView.tsx
similarity index 96%
rename from app/react/portainer/environments/wizard/EnvironmentTypeSelectView/EndpointTypeView.tsx
rename to app/react/portainer/environments/wizard/EnvironmentTypeSelectView/EnvironmentTypeSelectView.tsx
index ee4ad0ce6..6786ed8ad 100644
--- a/app/react/portainer/environments/wizard/EnvironmentTypeSelectView/EndpointTypeView.tsx
+++ b/app/react/portainer/environments/wizard/EnvironmentTypeSelectView/EnvironmentTypeSelectView.tsx
@@ -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
@@ -80,11 +82,6 @@ export function EnvironmentTypeSelectView() {
return;
}
- const environmentTypes = [
- ...existingEnvironmentTypes,
- ...newEnvironmentTypes,
- ];
-
const steps = _.compact(
types.map((id) => environmentTypes.find((eType) => eType.id === id))
);
diff --git a/app/react/portainer/environments/wizard/EnvironmentTypeSelectView/environment-types.ts b/app/react/portainer/environments/wizard/EnvironmentTypeSelectView/environment-types.ts
index 132554ba3..695feb53e 100644
--- a/app/react/portainer/environments/wizard/EnvironmentTypeSelectView/environment-types.ts
+++ b/app/react/portainer/environments/wizard/EnvironmentTypeSelectView/environment-types.ts
@@ -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 = {
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',
diff --git a/app/react/portainer/environments/wizard/EnvironmentTypeSelectView/index.ts b/app/react/portainer/environments/wizard/EnvironmentTypeSelectView/index.ts
index 7bb47f3d9..5db681c12 100644
--- a/app/react/portainer/environments/wizard/EnvironmentTypeSelectView/index.ts
+++ b/app/react/portainer/environments/wizard/EnvironmentTypeSelectView/index.ts
@@ -1 +1 @@
-export { EnvironmentTypeSelectView } from './EndpointTypeView';
+export { EnvironmentTypeSelectView } from './EnvironmentTypeSelectView';
diff --git a/app/react/portainer/environments/wizard/EnvironmentsCreationView/EnvironmentsCreationView.module.css b/app/react/portainer/environments/wizard/EnvironmentsCreationView/EnvironmentsCreationView.module.css
index 179949606..5ae1223e2 100644
--- a/app/react/portainer/environments/wizard/EnvironmentsCreationView/EnvironmentsCreationView.module.css
+++ b/app/react/portainer/environments/wizard/EnvironmentsCreationView/EnvironmentsCreationView.module.css
@@ -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';
diff --git a/app/react/portainer/environments/wizard/EnvironmentsCreationView/EnvironmentsCreationView.tsx b/app/react/portainer/environments/wizard/EnvironmentsCreationView/EnvironmentsCreationView.tsx
index ff9e24104..a56095bfc 100644
--- a/app/react/portainer/environments/wizard/EnvironmentsCreationView/EnvironmentsCreationView.tsx
+++ b/app/react/portainer/environments/wizard/EnvironmentsCreationView/EnvironmentsCreationView.tsx
@@ -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({
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 };
diff --git a/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardDocker/APITab/DeploymentScripts.tsx b/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardDocker/APITab/DeploymentScripts.tsx
index c747c8d2e..fcb5c2149 100644
--- a/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardDocker/APITab/DeploymentScripts.tsx
+++ b/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardDocker/APITab/DeploymentScripts.tsx
@@ -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 (
<>
-
+
When using the socket, ensure that you have started the Portainer
container with the following Docker flag:
-
+
{code}
diff --git a/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardDocker/AgentTab/AgentTab.tsx b/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardDocker/AgentTab/AgentTab.tsx
index 7e9eac9d0..6cfe40fcc 100644
--- a/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardDocker/AgentTab/AgentTab.tsx
+++ b/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardDocker/AgentTab/AgentTab.tsx
@@ -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) {
>
);
diff --git a/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardDocker/SocketTab/SocketForm.tsx b/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardDocker/SocketTab/SocketForm.tsx
index b8eeecb80..f8344ea96 100644
--- a/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardDocker/SocketTab/SocketForm.tsx
+++ b/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardDocker/SocketTab/SocketForm.tsx
@@ -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) {
diff --git a/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardDocker/SocketTab/SocketTab.tsx b/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardDocker/SocketTab/SocketTab.tsx
index a93b0e253..32b0bdacb 100644
--- a/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardDocker/SocketTab/SocketTab.tsx
+++ b/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardDocker/SocketTab/SocketTab.tsx
@@ -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) {
-
+
>
);
diff --git a/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardDocker/WizardDocker.tsx b/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardDocker/WizardDocker.tsx
index 88867811f..5aab71935 100644
--- a/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardDocker/WizardDocker.tsx
+++ b/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardDocker/WizardDocker.tsx
@@ -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:
diff --git a/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardEndpointsList/WizardEndpointsList.module.css b/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardEndpointsList/WizardEndpointsList.module.css
index 65feb01cc..c0d4b42ae 100644
--- a/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardEndpointsList/WizardEndpointsList.module.css
+++ b/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardEndpointsList/WizardEndpointsList.module.css
@@ -22,8 +22,6 @@
.wizard-list-image {
grid-area: image;
- font-size: 35px;
- color: #337ab7;
}
.wizard-list-title {
diff --git a/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardEndpointsList/WizardEndpointsList.tsx b/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardEndpointsList/WizardEndpointsList.tsx
index 50a884f28..99778edd1 100644
--- a/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardEndpointsList/WizardEndpointsList.tsx
+++ b/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardEndpointsList/WizardEndpointsList.tsx
@@ -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) {
{environments.map((environment) => (
-
+
diff --git a/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardPodman/AgentTab/AgentTab.tsx b/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardPodman/AgentTab/AgentTab.tsx
new file mode 100644
index 000000000..6ccbfd16b
--- /dev/null
+++ b/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardPodman/AgentTab/AgentTab.tsx
@@ -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 (
+ <>
+
+
+
+ >
+ );
+}
diff --git a/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardPodman/AgentTab/DeploymentScripts.tsx b/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardPodman/AgentTab/DeploymentScripts.tsx
new file mode 100644
index 000000000..31ca3fe7d
--- /dev/null
+++ b/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardPodman/AgentTab/DeploymentScripts.tsx
@@ -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:
,
+ };
+ });
+
+ return (
+
+ setDeployType(id)}
+ selectedId={deployType}
+ />
+
+ );
+}
+
+interface DeployCodeProps {
+ code: string;
+}
+
+function DeployCode({ code }: DeployCodeProps) {
+ return (
+ <>
+
+ {code}
+
+
+
+ Copy command
+
+
+ >
+ );
+}
+
+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}
+`;
+}
diff --git a/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardPodman/AgentTab/index.ts b/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardPodman/AgentTab/index.ts
new file mode 100644
index 000000000..fb72ea615
--- /dev/null
+++ b/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardPodman/AgentTab/index.ts
@@ -0,0 +1 @@
+export { AgentTab } from './AgentTab';
diff --git a/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardPodman/SocketTab/DeploymentScripts.tsx b/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardPodman/SocketTab/DeploymentScripts.tsx
new file mode 100644
index 000000000..c24a50643
--- /dev/null
+++ b/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardPodman/SocketTab/DeploymentScripts.tsx
@@ -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:
,
+ }));
+
+ return (
+
+ setDeployType(id)}
+ selectedId={deployType}
+ />
+
+ );
+}
+
+interface DeployCodeProps {
+ code: string;
+}
+
+function DeployCode({ code }: DeployCodeProps) {
+ const bindMountCode = `-v "/run/podman/podman.sock:/var/run/docker.sock"`;
+ return (
+ <>
+
+ When using the socket, ensure that you have started the Portainer
+ container with the following Podman flag:
+
+
{bindMountCode}
+
+
+ Copy command
+
+
+
+
+ To use the socket, ensure that you have started the Podman rootful
+ socket:
+
+
{code}
+
+
+ Copy command
+
+
+ >
+ );
+}
diff --git a/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardPodman/SocketTab/SocketForm.tsx b/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardPodman/SocketTab/SocketForm.tsx
new file mode 100644
index 000000000..00824f318
--- /dev/null
+++ b/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardPodman/SocketTab/SocketForm.tsx
@@ -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 (
+
+ {({ isValid, dirty }) => (
+
+ )}
+
+ );
+
+ 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
();
+
+ return (
+ <>
+
+
+ setFieldValue('overridePath', checked)}
+ label="Override default socket path"
+ labelClass="col-sm-3 col-lg-2"
+ />
+
+
+ {values.overridePath && (
+
+
+
+ )}
+ >
+ );
+}
diff --git a/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardPodman/SocketTab/SocketForm.validation.tsx b/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardPodman/SocketTab/SocketForm.validation.tsx
new file mode 100644
index 000000000..f8b48cf0d
--- /dev/null
+++ b/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardPodman/SocketTab/SocketForm.validation.tsx
@@ -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 {
+ 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
+ ),
+ });
+}
diff --git a/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardPodman/SocketTab/SocketTab.tsx b/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardPodman/SocketTab/SocketTab.tsx
new file mode 100644
index 000000000..fb6ef3dce
--- /dev/null
+++ b/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardPodman/SocketTab/SocketTab.tsx
@@ -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 (
+ <>
+
+ To connect via socket, Portainer server must be running in a Podman
+ container.
+
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardPodman/SocketTab/index.ts b/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardPodman/SocketTab/index.ts
new file mode 100644
index 000000000..96425ea31
--- /dev/null
+++ b/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardPodman/SocketTab/index.ts
@@ -0,0 +1 @@
+export { SocketTab } from './SocketTab';
diff --git a/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardPodman/SocketTab/types.ts b/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardPodman/SocketTab/types.ts
new file mode 100644
index 000000000..9bc7fdad1
--- /dev/null
+++ b/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardPodman/SocketTab/types.ts
@@ -0,0 +1,8 @@
+import { EnvironmentMetadata } from '@/react/portainer/environments/environment.service/create';
+
+export interface FormValues {
+ name: string;
+ socketPath: string;
+ overridePath: boolean;
+ meta: EnvironmentMetadata;
+}
diff --git a/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardPodman/WizardPodman.tsx b/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardPodman/WizardPodman.tsx
new file mode 100644
index 000000000..61025d342
--- /dev/null
+++ b/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardPodman/WizardPodman.tsx
@@ -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: ,
+ label: 'Agent',
+ description: '',
+ value: 'agent',
+ },
+ {
+ id: 'socket',
+ icon: ,
+ label: 'Socket',
+ description: '',
+ value: 'socket',
+ },
+ {
+ id: 'edgeAgentStandard',
+ icon: ,
+ label: 'Edge Agent Standard',
+ description: '',
+ value: 'edgeAgentStandard',
+ },
+ isBE && {
+ id: 'edgeAgentAsync',
+ icon: ,
+ 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 (
+
+ setCreationType(v)}
+ options={options}
+ value={creationType}
+ radioName="creation-type"
+ />
+
+ Currently, Portainer only supports Podman 5 running in rootful
+ (privileged) mode on CentOS 9 Linux environments. Rootless mode
+ and other Linux distros may work, but aren't officially supported.
+
+ {tab}
+
+ );
+
+ function getTab(
+ creationType:
+ | 'agent'
+ | 'api'
+ | 'socket'
+ | 'edgeAgentStandard'
+ | 'edgeAgentAsync'
+ ) {
+ switch (creationType) {
+ case 'agent':
+ return (
+ onCreate(environment, 'podmanAgent')}
+ />
+ );
+ case 'socket':
+ return (
+
+ onCreate(environment, 'podmanLocalEnvironment')
+ }
+ />
+ );
+ case 'edgeAgentStandard':
+ return (
+
+ onCreate(environment, 'podmanEdgeAgentStandard')
+ }
+ commands={[commandsTabs.podmanLinux]}
+ containerEngine={containerEngine}
+ />
+ );
+ case 'edgeAgentAsync':
+ return (
+
+ onCreate(environment, 'podmanEdgeAgentAsync')
+ }
+ commands={[commandsTabs.podmanLinux]}
+ containerEngine={containerEngine}
+ />
+ );
+ default:
+ return null;
+ }
+ }
+}
diff --git a/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardPodman/index.ts b/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardPodman/index.ts
new file mode 100644
index 000000000..5042118cf
--- /dev/null
+++ b/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardPodman/index.ts
@@ -0,0 +1 @@
+export { WizardPodman } from './WizardPodman';
diff --git a/app/react/portainer/environments/wizard/EnvironmentsCreationView/shared/AgentForm/AgentForm.tsx b/app/react/portainer/environments/wizard/EnvironmentsCreationView/shared/AgentForm/AgentForm.tsx
index 53e2885d1..86505ec8c 100644
--- a/app/react/portainer/environments/wizard/EnvironmentsCreationView/shared/AgentForm/AgentForm.tsx
+++ b/app/react/portainer/environments/wizard/EnvironmentsCreationView/shared/AgentForm/AgentForm.tsx
@@ -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);
+ },
+ }
+ );
}
}
diff --git a/app/react/portainer/environments/wizard/EnvironmentsCreationView/shared/AgentForm/AgentForm.validation.tsx b/app/react/portainer/environments/wizard/EnvironmentsCreationView/shared/AgentForm/AgentForm.validation.tsx
index 4829d9bbf..090b03165 100644
--- a/app/react/portainer/environments/wizard/EnvironmentsCreationView/shared/AgentForm/AgentForm.validation.tsx
+++ b/app/react/portainer/environments/wizard/EnvironmentsCreationView/shared/AgentForm/AgentForm.validation.tsx
@@ -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 {
name: useNameValidation(),
environmentUrl: environmentValidation(),
meta: metadataValidation(),
+ containerEngine: mixed().oneOf(['docker', 'podman']),
});
}
diff --git a/app/react/portainer/environments/wizard/EnvironmentsCreationView/shared/EdgeAgentTab/EdgeAgentForm/EdgeAgentForm.tsx b/app/react/portainer/environments/wizard/EnvironmentsCreationView/shared/EdgeAgentTab/EdgeAgentForm/EdgeAgentForm.tsx
index 9b4e89330..94531ef6b 100644
--- a/app/react/portainer/environments/wizard/EnvironmentsCreationView/shared/EdgeAgentTab/EdgeAgentForm/EdgeAgentForm.tsx
+++ b/app/react/portainer/environments/wizard/EnvironmentsCreationView/shared/EdgeAgentTab/EdgeAgentForm/EdgeAgentForm.tsx
@@ -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) {
diff --git a/app/react/portainer/environments/wizard/EnvironmentsCreationView/shared/EdgeAgentTab/EdgeAgentTab.tsx b/app/react/portainer/environments/wizard/EnvironmentsCreationView/shared/EdgeAgentTab/EdgeAgentTab.tsx
index 8f5e619da..c1cbbfa15 100644
--- a/app/react/portainer/environments/wizard/EnvironmentsCreationView/shared/EdgeAgentTab/EdgeAgentTab.tsx
+++ b/app/react/portainer/environments/wizard/EnvironmentsCreationView/shared/EdgeAgentTab/EdgeAgentTab.tsx
@@ -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>;
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();
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 && (
diff --git a/app/react/portainer/environments/wizard/EnvironmentsCreationView/types.ts b/app/react/portainer/environments/wizard/EnvironmentsCreationView/types.ts
index 13317d129..185491ea6 100644
--- a/app/react/portainer/environments/wizard/EnvironmentsCreationView/types.ts
+++ b/app/react/portainer/environments/wizard/EnvironmentsCreationView/types.ts
@@ -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;
diff --git a/app/react/portainer/environments/wizard/HomeView/useFetchOrCreateLocalEnvironment.ts b/app/react/portainer/environments/wizard/HomeView/useFetchOrCreateLocalEnvironment.ts
index dec99359c..48e8e8d58 100644
--- a/app/react/portainer/environments/wizard/HomeView/useFetchOrCreateLocalEnvironment.ts
+++ b/app/react/portainer/environments/wizard/HomeView/useFetchOrCreateLocalEnvironment.ts
@@ -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() {
diff --git a/app/react/portainer/templates/custom-templates/CreateView/CreateView.tsx b/app/react/portainer/templates/custom-templates/CreateView/CreateView.tsx
index 8320c6ec1..c19d8f10f 100644
--- a/app/react/portainer/templates/custom-templates/CreateView/CreateView.tsx
+++ b/app/react/portainer/templates/custom-templates/CreateView/CreateView.tsx
@@ -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 (
diff --git a/app/react/portainer/templates/custom-templates/ListView/ListView.tsx b/app/react/portainer/templates/custom-templates/ListView/ListView.tsx
index bce1d18e7..9530a7cbf 100644
--- a/app/react/portainer/templates/custom-templates/ListView/ListView.tsx
+++ b/app/react/portainer/templates/custom-templates/ListView/ListView.tsx
@@ -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() {
<>
- {viewType === 'docker' && !!selectedTemplateId && (
+ {viewType === ContainerEngine.Docker && !!selectedTemplateId && (
)}
diff --git a/app/react/sidebar/EnvironmentSidebar.tsx b/app/react/sidebar/EnvironmentSidebar.tsx
index bcae59f38..2e51b449c 100644
--- a/app/react/sidebar/EnvironmentSidebar.tsx
+++ b/app/react/sidebar/EnvironmentSidebar.tsx
@@ -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 (
diff --git a/dev/run_container_podman.sh b/dev/run_container_podman.sh
new file mode 100755
index 000000000..1893efd0b
--- /dev/null
+++ b/dev/run_container_podman.sh
@@ -0,0 +1,22 @@
+#!/bin/bash
+set -euo pipefail
+
+PORTAINER_DATA=${PORTAINER_DATA:-/tmp/portainer}
+PORTAINER_PROJECT=${PORTAINER_PROJECT:-$(pwd)}
+PORTAINER_FLAGS=${PORTAINER_FLAGS:-}
+
+sudo podman rm -f portainer
+
+# rootful podman (sudo required)
+sudo podman run -d \
+ -p 8000:8000 \
+ -p 9000:9000 \
+ -p 9443:9443 \
+ -v "$PORTAINER_PROJECT/dist:/app" \
+ -v "$PORTAINER_DATA:/data" \
+ -v /run/podman/podman.sock:/var/run/docker.sock \
+ -v /tmp:/tmp \
+ --privileged \
+ --name portainer \
+ portainer/base \
+ /app/portainer $PORTAINER_FLAGS