mirror of
https://github.com/portainer/portainer.git
synced 2025-08-02 12:25:22 +02:00
feat(podman): support add podman envs in the wizard [r8s-20] (#12056)
Some checks failed
ci / build_images (map[arch:amd64 platform:linux version:]) (push) Has been cancelled
ci / build_images (map[arch:amd64 platform:windows version:1809]) (push) Has been cancelled
ci / build_images (map[arch:amd64 platform:windows version:ltsc2022]) (push) Has been cancelled
ci / build_images (map[arch:arm platform:linux version:]) (push) Has been cancelled
ci / build_images (map[arch:arm64 platform:linux version:]) (push) Has been cancelled
ci / build_images (map[arch:ppc64le platform:linux version:]) (push) Has been cancelled
/ triage (push) Has been cancelled
Lint / Run linters (push) Has been cancelled
Test / test-client (push) Has been cancelled
Test / test-server (map[arch:amd64 platform:linux]) (push) Has been cancelled
Test / test-server (map[arch:amd64 platform:windows version:1809]) (push) Has been cancelled
Test / test-server (map[arch:amd64 platform:windows version:ltsc2022]) (push) Has been cancelled
Test / test-server (map[arch:arm64 platform:linux]) (push) Has been cancelled
ci / build_manifests (push) Has been cancelled
Some checks failed
ci / build_images (map[arch:amd64 platform:linux version:]) (push) Has been cancelled
ci / build_images (map[arch:amd64 platform:windows version:1809]) (push) Has been cancelled
ci / build_images (map[arch:amd64 platform:windows version:ltsc2022]) (push) Has been cancelled
ci / build_images (map[arch:arm platform:linux version:]) (push) Has been cancelled
ci / build_images (map[arch:arm64 platform:linux version:]) (push) Has been cancelled
ci / build_images (map[arch:ppc64le platform:linux version:]) (push) Has been cancelled
/ triage (push) Has been cancelled
Lint / Run linters (push) Has been cancelled
Test / test-client (push) Has been cancelled
Test / test-server (map[arch:amd64 platform:linux]) (push) Has been cancelled
Test / test-server (map[arch:amd64 platform:windows version:1809]) (push) Has been cancelled
Test / test-server (map[arch:amd64 platform:windows version:ltsc2022]) (push) Has been cancelled
Test / test-server (map[arch:arm64 platform:linux]) (push) Has been cancelled
ci / build_manifests (push) Has been cancelled
This commit is contained in:
parent
db616bc8a5
commit
32e94d4e4e
108 changed files with 1921 additions and 272 deletions
|
@ -46,7 +46,7 @@ export function Tooltip({
|
|||
position={position}
|
||||
className={className}
|
||||
>
|
||||
<HelpCircle className="lucide" aria-hidden="true" />
|
||||
<HelpCircle className="lucide" />
|
||||
</TooltipWithChildren>
|
||||
</span>
|
||||
);
|
||||
|
|
|
@ -25,7 +25,7 @@ export function CopyButton({
|
|||
fadeDelay = 1000,
|
||||
displayText = 'copied',
|
||||
className,
|
||||
color,
|
||||
color = 'default',
|
||||
indicatorPosition = 'right',
|
||||
children,
|
||||
'data-cy': dataCy,
|
||||
|
@ -52,7 +52,7 @@ export function CopyButton({
|
|||
<div className={styles.container}>
|
||||
{indicatorPosition === 'left' && copiedIndicator()}
|
||||
<Button
|
||||
className={className}
|
||||
className={clsx(className, '!ml-0')}
|
||||
color={color}
|
||||
size="small"
|
||||
onClick={handleCopy}
|
||||
|
|
302
app/react/components/datatables/Datatable.test.tsx
Normal file
302
app/react/components/datatables/Datatable.test.tsx
Normal file
|
@ -0,0 +1,302 @@
|
|||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
createColumnHelper,
|
||||
createTable,
|
||||
getCoreRowModel,
|
||||
} from '@tanstack/react-table';
|
||||
|
||||
import { Datatable, defaultGlobalFilterFn, Props } from './Datatable';
|
||||
import {
|
||||
BasicTableSettings,
|
||||
createPersistedStore,
|
||||
refreshableSettings,
|
||||
RefreshableTableSettings,
|
||||
} from './types';
|
||||
import { useTableState } from './useTableState';
|
||||
|
||||
// Mock data and dependencies
|
||||
type MockData = { id: string; name: string; age: number };
|
||||
const mockData = [
|
||||
{ id: '1', name: 'John Doe', age: 30 },
|
||||
{ id: '2', name: 'Jane Smith', age: 25 },
|
||||
{ id: '3', name: 'Bob Johnson', age: 35 },
|
||||
];
|
||||
|
||||
const mockColumns = [
|
||||
{ accessorKey: 'name', header: 'Name' },
|
||||
{ accessorKey: 'age', header: 'Age' },
|
||||
];
|
||||
|
||||
// mock table settings / state
|
||||
export interface TableSettings
|
||||
extends BasicTableSettings,
|
||||
RefreshableTableSettings {}
|
||||
function createStore(storageKey: string) {
|
||||
return createPersistedStore<TableSettings>(storageKey, 'name', (set) => ({
|
||||
...refreshableSettings(set),
|
||||
}));
|
||||
}
|
||||
const storageKey = 'test-table';
|
||||
const settingsStore = createStore(storageKey);
|
||||
const mockSettingsManager = {
|
||||
pageSize: 10,
|
||||
search: '',
|
||||
sortBy: undefined,
|
||||
setSearch: vitest.fn(),
|
||||
setSortBy: vitest.fn(),
|
||||
setPageSize: vitest.fn(),
|
||||
};
|
||||
|
||||
function DatatableWithStore(props: Omit<Props<MockData>, 'settingsManager'>) {
|
||||
const tableState = useTableState(settingsStore, storageKey);
|
||||
return (
|
||||
<Datatable {...props} settingsManager={tableState} data-cy="test-table" />
|
||||
);
|
||||
}
|
||||
|
||||
describe('Datatable', () => {
|
||||
it('renders the table with correct data', () => {
|
||||
render(
|
||||
<DatatableWithStore
|
||||
dataset={mockData}
|
||||
columns={mockColumns}
|
||||
data-cy="test-table"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('John Doe')).toBeInTheDocument();
|
||||
expect(screen.getByText('Jane Smith')).toBeInTheDocument();
|
||||
expect(screen.getByText('Bob Johnson')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the table with a title', () => {
|
||||
render(
|
||||
<DatatableWithStore
|
||||
dataset={mockData}
|
||||
columns={mockColumns}
|
||||
title="Test Table"
|
||||
data-cy="test-table"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Test Table')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles row selection when not disabled', () => {
|
||||
render(
|
||||
<DatatableWithStore
|
||||
dataset={mockData}
|
||||
columns={mockColumns}
|
||||
data-cy="test-table"
|
||||
/>
|
||||
);
|
||||
|
||||
const checkboxes = screen.getAllByRole('checkbox');
|
||||
fireEvent.click(checkboxes[1]); // Select the first row
|
||||
|
||||
// Check if the row is selected (you might need to adapt this based on your implementation)
|
||||
expect(checkboxes[1]).toBeChecked();
|
||||
});
|
||||
|
||||
it('disables row selection when disableSelect is true', () => {
|
||||
render(
|
||||
<DatatableWithStore
|
||||
dataset={mockData}
|
||||
columns={mockColumns}
|
||||
disableSelect
|
||||
data-cy="test-table"
|
||||
/>
|
||||
);
|
||||
|
||||
const checkboxes = screen.queryAllByRole('checkbox');
|
||||
expect(checkboxes.length).toBe(0);
|
||||
});
|
||||
|
||||
it('handles sorting', () => {
|
||||
render(
|
||||
<Datatable
|
||||
dataset={mockData}
|
||||
columns={mockColumns}
|
||||
settingsManager={mockSettingsManager}
|
||||
data-cy="test-table"
|
||||
/>
|
||||
);
|
||||
|
||||
const nameHeader = screen.getByText('Name');
|
||||
fireEvent.click(nameHeader);
|
||||
|
||||
// Check if setSortBy was called with the correct arguments
|
||||
expect(mockSettingsManager.setSortBy).toHaveBeenCalledWith('name', true);
|
||||
});
|
||||
|
||||
it('renders loading state', () => {
|
||||
render(
|
||||
<DatatableWithStore
|
||||
dataset={mockData}
|
||||
columns={mockColumns}
|
||||
isLoading
|
||||
data-cy="test-table"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Loading...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders empty state', () => {
|
||||
render(
|
||||
<DatatableWithStore
|
||||
dataset={[]}
|
||||
columns={mockColumns}
|
||||
emptyContentLabel="No data available"
|
||||
data-cy="test-table"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('No data available')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// Test the defaultGlobalFilterFn used in searches
|
||||
type Person = {
|
||||
id: string;
|
||||
name: string;
|
||||
age: number;
|
||||
isEmployed: boolean;
|
||||
tags?: string[];
|
||||
city?: string;
|
||||
family?: { sister: string; uncles?: string[] };
|
||||
};
|
||||
const data: Person[] = [
|
||||
{
|
||||
// searching primitives should be supported
|
||||
id: '1',
|
||||
name: 'Alice',
|
||||
age: 30,
|
||||
isEmployed: true,
|
||||
// supporting arrays of primitives should be supported
|
||||
tags: ['music', 'likes-pixar'],
|
||||
// supporting objects of primitives should be supported (values only).
|
||||
// but shouldn't be support nested objects / arrays
|
||||
family: { sister: 'sophie', uncles: ['john', 'david'] },
|
||||
},
|
||||
];
|
||||
const columnHelper = createColumnHelper<Person>();
|
||||
const columns = [
|
||||
columnHelper.accessor('name', {
|
||||
id: 'name',
|
||||
}),
|
||||
columnHelper.accessor('isEmployed', {
|
||||
id: 'isEmployed',
|
||||
}),
|
||||
columnHelper.accessor('age', {
|
||||
id: 'age',
|
||||
}),
|
||||
columnHelper.accessor('tags', {
|
||||
id: 'tags',
|
||||
}),
|
||||
columnHelper.accessor('family', {
|
||||
id: 'family',
|
||||
}),
|
||||
];
|
||||
const mockTable = createTable({
|
||||
columns,
|
||||
data,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
state: {},
|
||||
onStateChange() {},
|
||||
renderFallbackValue: undefined,
|
||||
getRowId: (row) => row.id,
|
||||
});
|
||||
const mockRow = mockTable.getRow('1');
|
||||
|
||||
describe('defaultGlobalFilterFn', () => {
|
||||
it('should return true when filterValue is null', () => {
|
||||
const result = defaultGlobalFilterFn(mockRow, 'Name', null);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when filterValue.search is empty', () => {
|
||||
const result = defaultGlobalFilterFn(mockRow, 'Name', {
|
||||
search: '',
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should filter string values correctly', () => {
|
||||
expect(
|
||||
defaultGlobalFilterFn(mockRow, 'name', {
|
||||
search: 'hello',
|
||||
})
|
||||
).toBe(false);
|
||||
expect(
|
||||
defaultGlobalFilterFn(mockRow, 'name', {
|
||||
search: 'ALICE',
|
||||
})
|
||||
).toBe(true);
|
||||
expect(
|
||||
defaultGlobalFilterFn(mockRow, 'name', {
|
||||
search: 'Alice',
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should filter number values correctly', () => {
|
||||
expect(defaultGlobalFilterFn(mockRow, 'age', { search: '123' })).toBe(
|
||||
false
|
||||
);
|
||||
expect(defaultGlobalFilterFn(mockRow, 'age', { search: '30' })).toBe(true);
|
||||
expect(defaultGlobalFilterFn(mockRow, 'age', { search: '67' })).toBe(false);
|
||||
});
|
||||
|
||||
it('should filter boolean values correctly', () => {
|
||||
expect(
|
||||
defaultGlobalFilterFn(mockRow, 'isEmployed', { search: 'true' })
|
||||
).toBe(true);
|
||||
expect(
|
||||
defaultGlobalFilterFn(mockRow, 'isEmployed', { search: 'false' })
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should filter object values correctly', () => {
|
||||
expect(defaultGlobalFilterFn(mockRow, 'family', { search: 'sophie' })).toBe(
|
||||
true
|
||||
);
|
||||
expect(defaultGlobalFilterFn(mockRow, 'family', { search: '30' })).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it('should filter array values correctly', () => {
|
||||
expect(defaultGlobalFilterFn(mockRow, 'tags', { search: 'music' })).toBe(
|
||||
true
|
||||
);
|
||||
expect(
|
||||
defaultGlobalFilterFn(mockRow, 'tags', { search: 'Likes-Pixar' })
|
||||
).toBe(true);
|
||||
expect(defaultGlobalFilterFn(mockRow, 'tags', { search: 'grape' })).toBe(
|
||||
false
|
||||
);
|
||||
expect(defaultGlobalFilterFn(mockRow, 'tags', { search: 'likes' })).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle complex nested structures', () => {
|
||||
expect(defaultGlobalFilterFn(mockRow, 'family', { search: 'sophie' })).toBe(
|
||||
true
|
||||
);
|
||||
expect(defaultGlobalFilterFn(mockRow, 'family', { search: 'mason' })).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it('should not filter non-primitive values within objects and arrays', () => {
|
||||
expect(defaultGlobalFilterFn(mockRow, 'family', { search: 'john' })).toBe(
|
||||
false
|
||||
);
|
||||
expect(defaultGlobalFilterFn(mockRow, 'family', { search: 'david' })).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
});
|
|
@ -272,6 +272,21 @@ export function defaultGlobalFilterFn<D, TFilter extends { search: string }>(
|
|||
|
||||
const filterValueLower = filterValue.search.toLowerCase();
|
||||
|
||||
if (typeof value === 'object') {
|
||||
return Object.values(value).some((item) =>
|
||||
filterPrimitive(item, filterValueLower)
|
||||
);
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.some((item) => filterPrimitive(item, filterValueLower));
|
||||
}
|
||||
|
||||
return filterPrimitive(value, filterValueLower);
|
||||
}
|
||||
|
||||
// only filter primitive values within objects and arrays, to avoid searching nested objects
|
||||
function filterPrimitive(value: unknown, filterValueLower: string) {
|
||||
if (
|
||||
typeof value === 'string' ||
|
||||
typeof value === 'number' ||
|
||||
|
@ -279,13 +294,6 @@ export function defaultGlobalFilterFn<D, TFilter extends { search: string }>(
|
|||
) {
|
||||
return value.toString().toLowerCase().includes(filterValueLower);
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.some((item) =>
|
||||
item.toString().toLowerCase().includes(filterValueLower)
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
import { ZapIcon } from 'lucide-react';
|
||||
|
||||
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||
import { getDockerEnvironmentType } from '@/react/portainer/environments/utils/getDockerEnvironmentType';
|
||||
import { useIsPodman } from '@/react/portainer/environments/queries/useIsPodman';
|
||||
|
||||
import { Icon } from '@@/Icon';
|
||||
|
||||
|
@ -9,6 +11,7 @@ import { useInfo } from '../proxy/queries/useInfo';
|
|||
export function DockerInfo({ isAgent }: { isAgent: boolean }) {
|
||||
const envId = useEnvironmentId();
|
||||
const infoQuery = useInfo(envId);
|
||||
const isPodman = useIsPodman(envId);
|
||||
|
||||
if (!infoQuery.data) {
|
||||
return null;
|
||||
|
@ -16,16 +19,22 @@ export function DockerInfo({ isAgent }: { isAgent: boolean }) {
|
|||
|
||||
const info = infoQuery.data;
|
||||
|
||||
const isSwarm = info.Swarm && info.Swarm.NodeID !== '';
|
||||
const isSwarm = info.Swarm !== undefined && info.Swarm?.NodeID !== '';
|
||||
const type = getDockerEnvironmentType(isSwarm, isPodman);
|
||||
|
||||
return (
|
||||
<span className="small text-muted">
|
||||
{isSwarm ? 'Swarm' : 'Standalone'} {info.ServerVersion}
|
||||
<span className="inline-flex gap-x-2 small text-muted">
|
||||
<span>
|
||||
{type} {info.ServerVersion}
|
||||
</span>
|
||||
{isAgent && (
|
||||
<span className="flex gap-1 items-center">
|
||||
<Icon icon={ZapIcon} />
|
||||
Agent
|
||||
</span>
|
||||
<>
|
||||
<span>-</span>
|
||||
<span className="inline-flex items-center">
|
||||
<Icon icon={ZapIcon} />
|
||||
Agent
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
|
|
|
@ -44,7 +44,8 @@ export function EnvironmentInfo() {
|
|||
<DetailsTable.Row label="Environment">
|
||||
<div className="flex items-center gap-2">
|
||||
{environment.Name}
|
||||
<SnapshotStats snapshot={environment.Snapshots[0]} />-
|
||||
<SnapshotStats snapshot={environment.Snapshots[0]} />
|
||||
<span className="text-muted">-</span>
|
||||
<DockerInfo isAgent={isAgent} />
|
||||
</div>
|
||||
</DetailsTable.Row>
|
||||
|
|
|
@ -39,7 +39,7 @@ export function PortsMappingField({
|
|||
label="Port mapping"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
addLabel="map additional port"
|
||||
addLabel="Map additional port"
|
||||
itemBuilder={() => ({
|
||||
hostPort: '',
|
||||
containerPort: '',
|
||||
|
@ -79,7 +79,7 @@ function Item({
|
|||
readOnly={readOnly}
|
||||
value={item.hostPort}
|
||||
onChange={(e) => handleChange('hostPort', e.target.value)}
|
||||
label="host"
|
||||
label="Host"
|
||||
placeholder="e.g. 80"
|
||||
className="w-1/2"
|
||||
id={`hostPort-${index}`}
|
||||
|
@ -95,7 +95,7 @@ function Item({
|
|||
readOnly={readOnly}
|
||||
value={item.containerPort}
|
||||
onChange={(e) => handleChange('containerPort', e.target.value)}
|
||||
label="container"
|
||||
label="Container"
|
||||
placeholder="e.g. 80"
|
||||
className="w-1/2"
|
||||
id={`containerPort-${index}`}
|
||||
|
@ -105,7 +105,10 @@ function Item({
|
|||
<ButtonSelector<Protocol>
|
||||
onChange={(value) => handleChange('protocol', value)}
|
||||
value={item.protocol}
|
||||
options={[{ value: 'tcp' }, { value: 'udp' }]}
|
||||
options={[
|
||||
{ value: 'tcp', label: 'TCP' },
|
||||
{ value: 'udp', label: 'UDP' },
|
||||
]}
|
||||
disabled={disabled}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
|
|
|
@ -2,8 +2,9 @@ import { FormikErrors } from 'formik';
|
|||
import { array, object, SchemaOf, string } from 'yup';
|
||||
import _ from 'lodash';
|
||||
|
||||
import { useLoggingPlugins } from '@/react/docker/proxy/queries/useServicePlugins';
|
||||
import { useLoggingPlugins } from '@/react/docker/proxy/queries/usePlugins';
|
||||
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||
import { useIsPodman } from '@/react/portainer/environments/queries/useIsPodman';
|
||||
|
||||
import { FormControl } from '@@/form-components/FormControl';
|
||||
import { FormSection } from '@@/form-components/FormSection';
|
||||
|
@ -30,8 +31,9 @@ export function LoggerConfig({
|
|||
errors?: FormikErrors<LogConfig>;
|
||||
}) {
|
||||
const envId = useEnvironmentId();
|
||||
|
||||
const pluginsQuery = useLoggingPlugins(envId, apiVersion < 1.25);
|
||||
const isPodman = useIsPodman(envId);
|
||||
const isSystem = apiVersion < 1.25;
|
||||
const pluginsQuery = useLoggingPlugins(envId, isSystem, isPodman);
|
||||
|
||||
if (!pluginsQuery.data) {
|
||||
return null;
|
||||
|
|
|
@ -1,5 +1,8 @@
|
|||
import { FormikErrors } from 'formik';
|
||||
|
||||
import { useIsPodman } from '@/react/portainer/environments/queries/useIsPodman';
|
||||
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||
|
||||
import { FormControl } from '@@/form-components/FormControl';
|
||||
import { Input } from '@@/form-components/Input';
|
||||
|
||||
|
@ -19,12 +22,15 @@ export function NetworkTab({
|
|||
setFieldValue: (field: string, value: unknown) => void;
|
||||
errors?: FormikErrors<Values>;
|
||||
}) {
|
||||
const envId = useEnvironmentId();
|
||||
const isPodman = useIsPodman(envId);
|
||||
const additionalOptions = getAdditionalOptions(isPodman);
|
||||
return (
|
||||
<div className="mt-3">
|
||||
<FormControl label="Network" errors={errors?.networkMode}>
|
||||
<NetworkSelector
|
||||
value={values.networkMode}
|
||||
additionalOptions={[{ label: 'Container', value: CONTAINER_MODE }]}
|
||||
additionalOptions={additionalOptions}
|
||||
onChange={(networkMode) => setFieldValue('networkMode', networkMode)}
|
||||
/>
|
||||
</FormControl>
|
||||
|
@ -105,3 +111,10 @@ export function NetworkTab({
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getAdditionalOptions(isPodman?: boolean) {
|
||||
if (isPodman) {
|
||||
return [];
|
||||
}
|
||||
return [{ label: 'Container', value: CONTAINER_MODE }];
|
||||
}
|
||||
|
|
|
@ -0,0 +1,147 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { DockerNetwork } from '@/react/docker/networks/types';
|
||||
|
||||
import { ContainerListViewModel } from '../../types';
|
||||
import { ContainerDetailsJSON } from '../../queries/useContainer';
|
||||
|
||||
import { getDefaultViewModel, getNetworkMode } from './toViewModel';
|
||||
|
||||
describe('getDefaultViewModel', () => {
|
||||
it('should return the correct default view model for Windows', () => {
|
||||
const result = getDefaultViewModel(true);
|
||||
expect(result).toEqual({
|
||||
networkMode: 'nat',
|
||||
hostname: '',
|
||||
domain: '',
|
||||
macAddress: '',
|
||||
ipv4Address: '',
|
||||
ipv6Address: '',
|
||||
primaryDns: '',
|
||||
secondaryDns: '',
|
||||
hostsFileEntries: [],
|
||||
container: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return the correct default view model for Podman', () => {
|
||||
const result = getDefaultViewModel(false, true);
|
||||
expect(result).toEqual({
|
||||
networkMode: 'podman',
|
||||
hostname: '',
|
||||
domain: '',
|
||||
macAddress: '',
|
||||
ipv4Address: '',
|
||||
ipv6Address: '',
|
||||
primaryDns: '',
|
||||
secondaryDns: '',
|
||||
hostsFileEntries: [],
|
||||
container: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return the correct default view model for Linux Docker', () => {
|
||||
const result = getDefaultViewModel(false);
|
||||
expect(result).toEqual({
|
||||
networkMode: 'bridge',
|
||||
hostname: '',
|
||||
domain: '',
|
||||
macAddress: '',
|
||||
ipv4Address: '',
|
||||
ipv6Address: '',
|
||||
primaryDns: '',
|
||||
secondaryDns: '',
|
||||
hostsFileEntries: [],
|
||||
container: '',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNetworkMode', () => {
|
||||
const mockNetworks: Array<DockerNetwork> = [
|
||||
{
|
||||
Name: 'bridge',
|
||||
Id: 'bridge-id',
|
||||
Driver: 'bridge',
|
||||
Scope: 'local',
|
||||
Attachable: false,
|
||||
Internal: false,
|
||||
IPAM: { Config: [], Driver: '', Options: {} },
|
||||
Options: {},
|
||||
Containers: {},
|
||||
},
|
||||
{
|
||||
Name: 'host',
|
||||
Id: 'host-id',
|
||||
Driver: 'host',
|
||||
Scope: 'local',
|
||||
Attachable: false,
|
||||
Internal: false,
|
||||
IPAM: { Config: [], Driver: '', Options: {} },
|
||||
Options: {},
|
||||
Containers: {},
|
||||
},
|
||||
{
|
||||
Name: 'custom',
|
||||
Id: 'custom-id',
|
||||
Driver: 'bridge',
|
||||
Scope: 'local',
|
||||
Attachable: true,
|
||||
Internal: false,
|
||||
IPAM: { Config: [], Driver: '', Options: {} },
|
||||
Options: {},
|
||||
Containers: {},
|
||||
},
|
||||
];
|
||||
|
||||
const mockRunningContainers: Array<ContainerListViewModel> = [
|
||||
{
|
||||
Id: 'container-1',
|
||||
Names: ['container-1-name'],
|
||||
} as ContainerListViewModel, // gaslight the type to avoid over-specifying
|
||||
];
|
||||
|
||||
it('should return the network mode from HostConfig', () => {
|
||||
const config: ContainerDetailsJSON = {
|
||||
HostConfig: { NetworkMode: 'host' },
|
||||
};
|
||||
expect(getNetworkMode(config, mockNetworks)).toEqual(['host']);
|
||||
});
|
||||
|
||||
it('should return the network mode from NetworkSettings if HostConfig is empty', () => {
|
||||
const config: ContainerDetailsJSON = {
|
||||
NetworkSettings: { Networks: { custom: {} } },
|
||||
};
|
||||
expect(getNetworkMode(config, mockNetworks)).toEqual(['custom']);
|
||||
});
|
||||
|
||||
it('should return container mode when NetworkMode starts with "container:"', () => {
|
||||
const config: ContainerDetailsJSON = {
|
||||
HostConfig: { NetworkMode: 'container:container-1' },
|
||||
};
|
||||
expect(getNetworkMode(config, mockNetworks, mockRunningContainers)).toEqual(
|
||||
['container', 'container-1-name']
|
||||
);
|
||||
});
|
||||
|
||||
it('should return "podman" for bridge network when isPodman is true', () => {
|
||||
const config: ContainerDetailsJSON = {
|
||||
HostConfig: { NetworkMode: 'bridge' },
|
||||
};
|
||||
expect(getNetworkMode(config, mockNetworks, [], true)).toEqual(['podman']);
|
||||
});
|
||||
|
||||
it('should return "bridge" for default network mode on Docker', () => {
|
||||
const config: ContainerDetailsJSON = {
|
||||
HostConfig: { NetworkMode: 'default' },
|
||||
};
|
||||
expect(getNetworkMode(config, mockNetworks)).toEqual(['bridge']);
|
||||
});
|
||||
|
||||
it('should return the first available network if no matching network is found', () => {
|
||||
const config: ContainerDetailsJSON = {
|
||||
HostConfig: { NetworkMode: 'non-existent' },
|
||||
};
|
||||
expect(getNetworkMode(config, mockNetworks)).toEqual(['bridge']);
|
||||
});
|
||||
});
|
|
@ -5,8 +5,8 @@ import { ContainerListViewModel } from '../../types';
|
|||
|
||||
import { CONTAINER_MODE, Values } from './types';
|
||||
|
||||
export function getDefaultViewModel(isWindows: boolean) {
|
||||
const networkMode = isWindows ? 'nat' : 'bridge';
|
||||
export function getDefaultViewModel(isWindows: boolean, isPodman?: boolean) {
|
||||
const networkMode = getDefaultNetworkMode(isWindows, isPodman);
|
||||
return {
|
||||
networkMode,
|
||||
hostname: '',
|
||||
|
@ -21,10 +21,17 @@ export function getDefaultViewModel(isWindows: boolean) {
|
|||
};
|
||||
}
|
||||
|
||||
export function getDefaultNetworkMode(isWindows: boolean, isPodman?: boolean) {
|
||||
if (isWindows) return 'nat';
|
||||
if (isPodman) return 'podman';
|
||||
return 'bridge';
|
||||
}
|
||||
|
||||
export function toViewModel(
|
||||
config: ContainerDetailsJSON,
|
||||
networks: Array<DockerNetwork>,
|
||||
runningContainers: Array<ContainerListViewModel> = []
|
||||
runningContainers: Array<ContainerListViewModel> = [],
|
||||
isPodman?: boolean
|
||||
): Values {
|
||||
const dns = config.HostConfig?.Dns;
|
||||
const [primaryDns = '', secondaryDns = ''] = dns || [];
|
||||
|
@ -34,7 +41,8 @@ export function toViewModel(
|
|||
const [networkMode, container = ''] = getNetworkMode(
|
||||
config,
|
||||
networks,
|
||||
runningContainers
|
||||
runningContainers,
|
||||
isPodman
|
||||
);
|
||||
|
||||
const networkSettings = config.NetworkSettings?.Networks?.[networkMode];
|
||||
|
@ -61,10 +69,11 @@ export function toViewModel(
|
|||
};
|
||||
}
|
||||
|
||||
function getNetworkMode(
|
||||
export function getNetworkMode(
|
||||
config: ContainerDetailsJSON,
|
||||
networks: Array<DockerNetwork>,
|
||||
runningContainers: Array<ContainerListViewModel> = []
|
||||
runningContainers: Array<ContainerListViewModel> = [],
|
||||
isPodman?: boolean
|
||||
) {
|
||||
let networkMode = config.HostConfig?.NetworkMode || '';
|
||||
if (!networkMode) {
|
||||
|
@ -85,6 +94,9 @@ function getNetworkMode(
|
|||
const networkNames = networks.map((n) => n.Name);
|
||||
|
||||
if (networkNames.includes(networkMode)) {
|
||||
if (isPodman && networkMode === 'bridge') {
|
||||
return ['podman'] as const;
|
||||
}
|
||||
return [networkMode] as const;
|
||||
}
|
||||
|
||||
|
@ -92,6 +104,9 @@ function getNetworkMode(
|
|||
networkNames.includes('bridge') &&
|
||||
(!networkMode || networkMode === 'default' || networkMode === 'bridge')
|
||||
) {
|
||||
if (isPodman) {
|
||||
return ['podman'] as const;
|
||||
}
|
||||
return ['bridge'] as const;
|
||||
}
|
||||
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -1,58 +1,83 @@
|
|||
import { ColumnDef } from '@tanstack/react-table';
|
||||
import { List } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
import { useCurrentStateAndParams } from '@uirouter/react';
|
||||
|
||||
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||
|
||||
import { Datatable } from '@@/datatables';
|
||||
import { createPersistedStore } from '@@/datatables/types';
|
||||
import { useTableState } from '@@/datatables/useTableState';
|
||||
|
||||
import { useContainerTop } from '../queries/useContainerTop';
|
||||
import { ContainerProcesses } from '../queries/types';
|
||||
|
||||
const tableKey = 'container-processes';
|
||||
const store = createPersistedStore(tableKey);
|
||||
|
||||
export function ProcessesDatatable({
|
||||
dataset,
|
||||
headers,
|
||||
}: {
|
||||
dataset?: Array<Array<string | number>>;
|
||||
headers?: Array<string>;
|
||||
}) {
|
||||
const tableState = useTableState(store, tableKey);
|
||||
const rows = useMemo(() => {
|
||||
if (!dataset || !headers) {
|
||||
return [];
|
||||
}
|
||||
type ProcessRow = {
|
||||
id: number;
|
||||
};
|
||||
|
||||
return dataset.map((row, index) => ({
|
||||
id: index,
|
||||
...Object.fromEntries(
|
||||
headers.map((header, index) => [header, row[index]])
|
||||
),
|
||||
}));
|
||||
}, [dataset, headers]);
|
||||
type ProcessesDatatableProps = {
|
||||
rows: Array<ProcessRow>;
|
||||
columns: Array<ColumnDef<ProcessRow>>;
|
||||
};
|
||||
|
||||
const columns = useMemo(
|
||||
() =>
|
||||
headers
|
||||
? headers.map(
|
||||
(header) =>
|
||||
({ header, accessorKey: header }) satisfies ColumnDef<{
|
||||
[k: string]: string;
|
||||
}>
|
||||
)
|
||||
: [],
|
||||
[headers]
|
||||
export function ProcessesDatatable() {
|
||||
const {
|
||||
params: { id: containerId },
|
||||
} = useCurrentStateAndParams();
|
||||
const environmentId = useEnvironmentId();
|
||||
const topQuery = useContainerTop(
|
||||
environmentId,
|
||||
containerId,
|
||||
(containerProcesses: ContainerProcesses) =>
|
||||
parseContainerProcesses(containerProcesses)
|
||||
);
|
||||
const tableState = useTableState(store, tableKey);
|
||||
|
||||
return (
|
||||
<Datatable
|
||||
title="Processes"
|
||||
titleIcon={List}
|
||||
dataset={rows}
|
||||
columns={columns}
|
||||
dataset={topQuery.data?.rows ?? []}
|
||||
columns={topQuery.data?.columns ?? []}
|
||||
settingsManager={tableState}
|
||||
disableSelect
|
||||
isLoading={!dataset}
|
||||
isLoading={topQuery.isLoading}
|
||||
data-cy="docker-container-stats-processes-datatable"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// transform the data from the API into the format expected by the datatable
|
||||
function parseContainerProcesses(
|
||||
containerProcesses: ContainerProcesses
|
||||
): ProcessesDatatableProps {
|
||||
const { Processes: processes, Titles: titles } = containerProcesses;
|
||||
const rows = processes?.map((row, index) => {
|
||||
// docker has the row data as an array of many strings
|
||||
// podman has the row data as an array with a single string separated by one or many spaces
|
||||
const processArray = row.length === 1 ? row[0].split(/\s+/) : row;
|
||||
return {
|
||||
id: index,
|
||||
...Object.fromEntries(
|
||||
titles.map((header, index) => [header, processArray[index]])
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
const columns = titles
|
||||
? titles.map(
|
||||
(header) =>
|
||||
({ header, accessorKey: header }) satisfies ColumnDef<{
|
||||
[k: string]: string;
|
||||
}>
|
||||
)
|
||||
: [];
|
||||
|
||||
return {
|
||||
rows,
|
||||
columns,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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 };
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -7,3 +7,8 @@ export interface Filters {
|
|||
network?: NetworkId[];
|
||||
status?: ContainerStatus[];
|
||||
}
|
||||
|
||||
export type ContainerProcesses = {
|
||||
Processes: Array<Array<string>>;
|
||||
Titles: Array<string>;
|
||||
};
|
||||
|
|
|
@ -1,21 +1,40 @@
|
|||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
|
||||
import { ContainerId } from '../types';
|
||||
import { buildDockerProxyUrl } from '../../proxy/queries/buildDockerProxyUrl';
|
||||
|
||||
import { queryKeys } from './query-keys';
|
||||
import { ContainerProcesses } from './types';
|
||||
|
||||
export function useContainerTop<T = ContainerProcesses>(
|
||||
environmentId: EnvironmentId,
|
||||
id: ContainerId,
|
||||
select?: (environment: ContainerProcesses) => T
|
||||
) {
|
||||
// many containers don't allow this call, so fail early, and omit withError to silently fail
|
||||
return useQuery({
|
||||
queryKey: queryKeys.top(environmentId, id),
|
||||
queryFn: () => getContainerTop(environmentId, id),
|
||||
retry: false,
|
||||
select,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Raw docker API proxy
|
||||
* @param environmentId
|
||||
* @param id
|
||||
* @returns
|
||||
*/
|
||||
export async function containerTop(
|
||||
export async function getContainerTop(
|
||||
environmentId: EnvironmentId,
|
||||
id: ContainerId
|
||||
) {
|
||||
try {
|
||||
const { data } = await axios.get(
|
||||
const { data } = await axios.get<ContainerProcesses>(
|
||||
buildDockerProxyUrl(environmentId, 'containers', id, 'top')
|
||||
);
|
||||
return data;
|
||||
|
|
|
@ -73,11 +73,11 @@ function Cell({
|
|||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex gap-1">
|
||||
<a href={linkProps.href} onClick={linkProps.onClick} title={name}>
|
||||
{truncate(name, 40)}
|
||||
</a>
|
||||
{!image.used && <UnusedBadge />}
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -2,6 +2,8 @@ import { CellContext } from '@tanstack/react-table';
|
|||
|
||||
import { ImagesListResponse } from '@/react/docker/images/queries/useImages';
|
||||
|
||||
import { Badge } from '@@/Badge';
|
||||
|
||||
import { columnHelper } from './helper';
|
||||
|
||||
export const tags = columnHelper.accessor((item) => item.tags?.join(','), {
|
||||
|
@ -16,12 +18,12 @@ function Cell({
|
|||
const repoTags = item.tags;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{repoTags?.map((tag, idx) => (
|
||||
<span key={idx} className="label label-primary image-tag" title={tag}>
|
||||
<Badge key={idx} type="info">
|
||||
{tag}
|
||||
</span>
|
||||
</Badge>
|
||||
))}
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
51
app/react/docker/images/utils.test.ts
Normal file
51
app/react/docker/images/utils.test.ts
Normal file
|
@ -0,0 +1,51 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { fullURIIntoRepoAndTag } from './utils';
|
||||
|
||||
describe('fullURIIntoRepoAndTag', () => {
|
||||
it('splits registry/image-repo:tag correctly', () => {
|
||||
const result = fullURIIntoRepoAndTag('registry.example.com/my-image:v1.0');
|
||||
expect(result).toEqual({
|
||||
repo: 'registry.example.com/my-image',
|
||||
tag: 'v1.0',
|
||||
});
|
||||
});
|
||||
|
||||
it('splits image-repo:tag correctly', () => {
|
||||
const result = fullURIIntoRepoAndTag('nginx:latest');
|
||||
expect(result).toEqual({ repo: 'nginx', tag: 'latest' });
|
||||
});
|
||||
|
||||
it('splits registry:port/image-repo:tag correctly', () => {
|
||||
const result = fullURIIntoRepoAndTag(
|
||||
'registry.example.com:5000/my-image:v2.1'
|
||||
);
|
||||
expect(result).toEqual({
|
||||
repo: 'registry.example.com:5000/my-image',
|
||||
tag: 'v2.1',
|
||||
});
|
||||
});
|
||||
|
||||
it('handles empty string input', () => {
|
||||
const result = fullURIIntoRepoAndTag('');
|
||||
expect(result).toEqual({ repo: '', tag: 'latest' });
|
||||
});
|
||||
|
||||
it('handles input with multiple colons', () => {
|
||||
const result = fullURIIntoRepoAndTag('registry:5000/namespace/image:v1.0');
|
||||
expect(result).toEqual({
|
||||
repo: 'registry:5000/namespace/image',
|
||||
tag: 'v1.0',
|
||||
});
|
||||
});
|
||||
|
||||
it('handles input with @ symbol (digest)', () => {
|
||||
const result = fullURIIntoRepoAndTag(
|
||||
'myregistry.azurecr.io/image@sha256:123456'
|
||||
);
|
||||
expect(result).toEqual({
|
||||
repo: 'myregistry.azurecr.io/image@sha256',
|
||||
tag: '123456',
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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(
|
|
@ -84,7 +84,6 @@ function Cell({
|
|||
id: item.Id,
|
||||
nodeName: item.NodeName,
|
||||
}}
|
||||
className="monospaced"
|
||||
data-cy={`volume-link-${name}`}
|
||||
>
|
||||
{truncate(name, 40)}
|
||||
|
@ -106,7 +105,7 @@ function Cell({
|
|||
}}
|
||||
data-cy={`volume-browse-button-${name}`}
|
||||
>
|
||||
browse
|
||||
Browse
|
||||
</Button>
|
||||
</Authorized>
|
||||
)}
|
||||
|
|
|
@ -57,6 +57,7 @@ export function EdgeScriptSettingsFieldset({
|
|||
type="text"
|
||||
value={values.edgeIdGenerator}
|
||||
name="edgeIdGenerator"
|
||||
placeholder="e.g. uuidgen"
|
||||
id="edge-id-generator-input"
|
||||
onChange={(e) => setFieldValue(e.target.name, e.target.value)}
|
||||
data-cy="edge-id-generator-input"
|
||||
|
@ -81,7 +82,7 @@ export function EdgeScriptSettingsFieldset({
|
|||
<Field
|
||||
name="envVars"
|
||||
as={Input}
|
||||
placeholder="foo=bar,myvar"
|
||||
placeholder="e.g. foo=bar"
|
||||
id="env-variables-input"
|
||||
/>
|
||||
</FormControl>
|
||||
|
|
|
@ -35,6 +35,11 @@ export const commandsTabs: Record<string, CommandTab> = {
|
|||
label: 'Docker Standalone',
|
||||
command: buildLinuxStandaloneCommand,
|
||||
},
|
||||
podmanLinux: {
|
||||
id: 'podman',
|
||||
label: 'Podman',
|
||||
command: buildLinuxPodmanCommand,
|
||||
},
|
||||
swarmWindows: {
|
||||
id: 'swarm',
|
||||
label: 'Docker Swarm',
|
||||
|
@ -83,6 +88,45 @@ docker run -d \\
|
|||
`;
|
||||
}
|
||||
|
||||
function buildLinuxPodmanCommand(
|
||||
agentVersion: string,
|
||||
edgeKey: string,
|
||||
properties: ScriptFormValues,
|
||||
useAsyncMode: boolean,
|
||||
edgeId?: string,
|
||||
agentSecret?: string
|
||||
) {
|
||||
const { allowSelfSignedCertificates, edgeIdGenerator, envVars } = properties;
|
||||
|
||||
const env = buildDockerEnvVars(envVars, [
|
||||
...buildDefaultDockerEnvVars(
|
||||
edgeKey,
|
||||
allowSelfSignedCertificates,
|
||||
!edgeIdGenerator ? edgeId : undefined,
|
||||
agentSecret,
|
||||
useAsyncMode
|
||||
),
|
||||
...metaEnvVars(properties),
|
||||
]);
|
||||
|
||||
return `${
|
||||
edgeIdGenerator ? `PORTAINER_EDGE_ID=$(${edgeIdGenerator}) \n\n` : ''
|
||||
}\
|
||||
sudo systemctl enable --now podman.socket
|
||||
sudo podman volume create portainer
|
||||
sudo podman run -d \\
|
||||
-v /run/podman/podman.sock:/var/run/docker.sock \\
|
||||
-v /var/lib/containers/storage/volumes:/var/lib/docker/volumes \\
|
||||
-v /:/host \\
|
||||
-v portainer_agent_data:/data \\
|
||||
--restart always \\
|
||||
--privileged \\
|
||||
${env} \\
|
||||
--name portainer_edge_agent \\
|
||||
portainer/agent:${agentVersion}
|
||||
`;
|
||||
}
|
||||
|
||||
export function buildWindowsStandaloneCommand(
|
||||
agentVersion: string,
|
||||
edgeKey: string,
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -26,9 +26,15 @@ function WaitingRoomView() {
|
|||
<div className="col-sm-12">
|
||||
<InformationPanel>
|
||||
<TextTip color="blue">
|
||||
Only environments generated from the AEEC script will appear here,
|
||||
manually added environments and edge devices will bypass the
|
||||
waiting room.
|
||||
Only environments generated from the{' '}
|
||||
<Link
|
||||
to="portainer.endpoints.edgeAutoCreateScript"
|
||||
data-cy="waitingRoom-edgeAutoCreateScriptLink"
|
||||
>
|
||||
auto onboarding
|
||||
</Link>{' '}
|
||||
script will appear here, manually added environments and edge
|
||||
devices will bypass the waiting room.
|
||||
</TextTip>
|
||||
</InformationPanel>
|
||||
</div>
|
||||
|
|
|
@ -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 },
|
||||
|
|
|
@ -1,17 +1,25 @@
|
|||
import { DockerSnapshot } from '@/react/docker/snapshots/types';
|
||||
import { useIsPodman } from '@/react/portainer/environments/queries/useIsPodman';
|
||||
import {
|
||||
Environment,
|
||||
PlatformType,
|
||||
KubernetesSnapshot,
|
||||
} from '@/react/portainer/environments/types';
|
||||
import { getPlatformType } from '@/react/portainer/environments/utils';
|
||||
import { getDockerEnvironmentType } from '@/react/portainer/environments/utils/getDockerEnvironmentType';
|
||||
|
||||
export function EngineVersion({ environment }: { environment: Environment }) {
|
||||
const platform = getPlatformType(environment.Type);
|
||||
const isPodman = useIsPodman(environment.Id);
|
||||
|
||||
switch (platform) {
|
||||
case PlatformType.Docker:
|
||||
return <DockerEngineVersion snapshot={environment.Snapshots[0]} />;
|
||||
return (
|
||||
<DockerEngineVersion
|
||||
snapshot={environment.Snapshots[0]}
|
||||
isPodman={isPodman}
|
||||
/>
|
||||
);
|
||||
case PlatformType.Kubernetes:
|
||||
return (
|
||||
<KubernetesEngineVersion
|
||||
|
@ -23,14 +31,21 @@ export function EngineVersion({ environment }: { environment: Environment }) {
|
|||
}
|
||||
}
|
||||
|
||||
function DockerEngineVersion({ snapshot }: { snapshot?: DockerSnapshot }) {
|
||||
function DockerEngineVersion({
|
||||
snapshot,
|
||||
isPodman,
|
||||
}: {
|
||||
snapshot?: DockerSnapshot;
|
||||
isPodman?: boolean;
|
||||
}) {
|
||||
if (!snapshot) {
|
||||
return null;
|
||||
}
|
||||
const type = getDockerEnvironmentType(snapshot.Swarm, isPodman);
|
||||
|
||||
return (
|
||||
<span className="small text-muted vertical-center">
|
||||
{snapshot.Swarm ? 'Swarm' : 'Standalone'} {snapshot.DockerVersion}
|
||||
{type} {snapshot.DockerVersion}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,43 +1,86 @@
|
|||
import { environmentTypeIcon } from '@/portainer/filters/filters';
|
||||
import dockerEdge from '@/assets/images/edge_endpoint.png';
|
||||
import { getEnvironmentTypeIcon } from '@/react/portainer/environments/utils';
|
||||
import dockerEdge from '@/assets/ico/docker-edge-environment.svg';
|
||||
import podmanEdge from '@/assets/ico/podman-edge-environment.svg';
|
||||
import kube from '@/assets/images/kubernetes_endpoint.png';
|
||||
import kubeEdge from '@/assets/images/kubernetes_edge_endpoint.png';
|
||||
import { EnvironmentType } from '@/react/portainer/environments/types';
|
||||
import kubeEdge from '@/assets/ico/kubernetes-edge-environment.svg';
|
||||
import {
|
||||
ContainerEngine,
|
||||
EnvironmentType,
|
||||
} from '@/react/portainer/environments/types';
|
||||
import azure from '@/assets/ico/vendor/azure.svg';
|
||||
import docker from '@/assets/ico/vendor/docker.svg';
|
||||
import podman from '@/assets/ico/vendor/podman.svg';
|
||||
|
||||
import { Icon } from '@@/Icon';
|
||||
|
||||
interface Props {
|
||||
type: EnvironmentType;
|
||||
containerEngine?: ContainerEngine;
|
||||
}
|
||||
|
||||
export function EnvironmentIcon({ type }: Props) {
|
||||
export function EnvironmentIcon({ type, containerEngine }: Props) {
|
||||
switch (type) {
|
||||
case EnvironmentType.AgentOnDocker:
|
||||
case EnvironmentType.Docker:
|
||||
if (containerEngine === ContainerEngine.Podman) {
|
||||
return (
|
||||
<img
|
||||
src={podman}
|
||||
width="60"
|
||||
alt="podman environment"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<img src={docker} width="60" alt="docker endpoint" aria-hidden="true" />
|
||||
<img
|
||||
src={docker}
|
||||
width="60"
|
||||
alt="docker environment"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
case EnvironmentType.Azure:
|
||||
return (
|
||||
<img src={azure} width="60" alt="azure endpoint" aria-hidden="true" />
|
||||
<img
|
||||
src={azure}
|
||||
width="60"
|
||||
alt="azure environment"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
case EnvironmentType.EdgeAgentOnDocker:
|
||||
if (containerEngine === ContainerEngine.Podman) {
|
||||
return (
|
||||
<img
|
||||
src={podmanEdge}
|
||||
alt="podman edge environment"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<img src={dockerEdge} alt="docker edge endpoint" aria-hidden="true" />
|
||||
<img
|
||||
src={dockerEdge}
|
||||
alt="docker edge environment"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
case EnvironmentType.KubernetesLocal:
|
||||
case EnvironmentType.AgentOnKubernetes:
|
||||
return <img src={kube} alt="kubernetes endpoint" aria-hidden="true" />;
|
||||
return <img src={kube} alt="kubernetes environment" aria-hidden="true" />;
|
||||
case EnvironmentType.EdgeAgentOnKubernetes:
|
||||
return (
|
||||
<img src={kubeEdge} alt="kubernetes edge endpoint" aria-hidden="true" />
|
||||
<img
|
||||
src={kubeEdge}
|
||||
alt="kubernetes edge environment"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Icon
|
||||
icon={environmentTypeIcon(type)}
|
||||
icon={getEnvironmentTypeIcon(type, containerEngine)}
|
||||
className="blue-icon !h-16 !w-16"
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -66,7 +66,10 @@ export function EnvironmentItem({
|
|||
params={dashboardRoute.params}
|
||||
>
|
||||
<div className="ml-2 flex justify-center self-center">
|
||||
<EnvironmentIcon type={environment.Type} />
|
||||
<EnvironmentIcon
|
||||
type={environment.Type}
|
||||
containerEngine={environment.ContainerEngine}
|
||||
/>
|
||||
</div>
|
||||
<div className="ml-3 mr-auto flex flex-col items-start justify-center gap-3">
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -1,28 +1,41 @@
|
|||
import { CellContext } from '@tanstack/react-table';
|
||||
|
||||
import { environmentTypeIcon } from '@/portainer/filters/filters';
|
||||
import {
|
||||
Environment,
|
||||
EnvironmentType,
|
||||
} from '@/react/portainer/environments/types';
|
||||
import { getPlatformTypeName } from '@/react/portainer/environments/utils';
|
||||
getEnvironmentTypeIcon,
|
||||
getPlatformTypeName,
|
||||
} from '@/react/portainer/environments/utils';
|
||||
|
||||
import { Icon } from '@@/Icon';
|
||||
|
||||
import { EnvironmentListItem } from '../types';
|
||||
import { EnvironmentType, ContainerEngine } from '../../types';
|
||||
|
||||
import { columnHelper } from './helper';
|
||||
|
||||
export const type = columnHelper.accessor('Type', {
|
||||
header: 'Type',
|
||||
cell: Cell,
|
||||
});
|
||||
type TypeCellContext = {
|
||||
type: EnvironmentType;
|
||||
containerEngine?: ContainerEngine;
|
||||
};
|
||||
|
||||
function Cell({ getValue }: CellContext<Environment, EnvironmentType>) {
|
||||
const type = getValue();
|
||||
export const type = columnHelper.accessor(
|
||||
(rowItem): TypeCellContext => ({
|
||||
type: rowItem.Type,
|
||||
containerEngine: rowItem.ContainerEngine,
|
||||
}),
|
||||
{
|
||||
header: 'Type',
|
||||
cell: Cell,
|
||||
id: 'Type',
|
||||
}
|
||||
);
|
||||
|
||||
function Cell({ getValue }: CellContext<EnvironmentListItem, TypeCellContext>) {
|
||||
const { type, containerEngine } = getValue();
|
||||
|
||||
return (
|
||||
<span className="flex items-center gap-1">
|
||||
<Icon icon={environmentTypeIcon(type)} />
|
||||
{getPlatformTypeName(type)}
|
||||
<Icon icon={getEnvironmentTypeIcon(type, containerEngine)} />
|
||||
{getPlatformTypeName(type, containerEngine)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -7,7 +7,11 @@ import { type EnvironmentGroupId } from '@/react/portainer/environments/environm
|
|||
import { type TagId } from '@/portainer/tags/types';
|
||||
import { EdgeAsyncIntervalsValues } from '@/react/edge/components/EdgeAsyncIntervalsForm';
|
||||
|
||||
import { type Environment, EnvironmentCreationTypes } from '../types';
|
||||
import {
|
||||
type Environment,
|
||||
ContainerEngine,
|
||||
EnvironmentCreationTypes,
|
||||
} from '../types';
|
||||
|
||||
import { buildUrl } from './utils';
|
||||
|
||||
|
@ -21,6 +25,7 @@ interface CreateLocalDockerEnvironment {
|
|||
socketPath?: string;
|
||||
publicUrl?: string;
|
||||
meta?: EnvironmentMetadata;
|
||||
containerEngine?: ContainerEngine;
|
||||
}
|
||||
|
||||
export async function createLocalDockerEnvironment({
|
||||
|
@ -28,6 +33,7 @@ export async function createLocalDockerEnvironment({
|
|||
socketPath = '',
|
||||
publicUrl = '',
|
||||
meta = { tagIds: [] },
|
||||
containerEngine,
|
||||
}: CreateLocalDockerEnvironment) {
|
||||
const url = prefixPath(socketPath);
|
||||
|
||||
|
@ -38,6 +44,7 @@ export async function createLocalDockerEnvironment({
|
|||
url,
|
||||
publicUrl,
|
||||
meta,
|
||||
containerEngine,
|
||||
}
|
||||
);
|
||||
|
||||
|
@ -115,6 +122,7 @@ export interface EnvironmentOptions {
|
|||
pollFrequency?: number;
|
||||
edge?: EdgeSettings;
|
||||
tunnelServerAddr?: string;
|
||||
containerEngine?: ContainerEngine;
|
||||
}
|
||||
|
||||
interface CreateRemoteEnvironment {
|
||||
|
@ -125,6 +133,7 @@ interface CreateRemoteEnvironment {
|
|||
>;
|
||||
url: string;
|
||||
options?: Omit<EnvironmentOptions, 'url'>;
|
||||
containerEngine?: ContainerEngine;
|
||||
}
|
||||
|
||||
export async function createRemoteEnvironment({
|
||||
|
@ -143,11 +152,13 @@ export interface CreateAgentEnvironmentValues {
|
|||
name: string;
|
||||
environmentUrl: string;
|
||||
meta: EnvironmentMetadata;
|
||||
containerEngine?: ContainerEngine;
|
||||
}
|
||||
|
||||
export function createAgentEnvironment({
|
||||
name,
|
||||
environmentUrl,
|
||||
containerEngine = ContainerEngine.Docker,
|
||||
meta = { tagIds: [] },
|
||||
}: CreateAgentEnvironmentValues) {
|
||||
return createRemoteEnvironment({
|
||||
|
@ -160,6 +171,7 @@ export function createAgentEnvironment({
|
|||
skipVerify: true,
|
||||
skipClientVerify: true,
|
||||
},
|
||||
containerEngine,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
@ -171,6 +183,7 @@ interface CreateEdgeAgentEnvironment {
|
|||
meta?: EnvironmentMetadata;
|
||||
pollFrequency: number;
|
||||
edge: EdgeSettings;
|
||||
containerEngine: ContainerEngine;
|
||||
}
|
||||
|
||||
export function createEdgeAgentEnvironment({
|
||||
|
@ -179,6 +192,7 @@ export function createEdgeAgentEnvironment({
|
|||
meta = { tagIds: [] },
|
||||
pollFrequency,
|
||||
edge,
|
||||
containerEngine,
|
||||
}: CreateEdgeAgentEnvironment) {
|
||||
return createEnvironment(
|
||||
name,
|
||||
|
@ -192,6 +206,7 @@ export function createEdgeAgentEnvironment({
|
|||
pollFrequency,
|
||||
edge,
|
||||
meta,
|
||||
containerEngine,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
@ -207,7 +222,8 @@ async function createEnvironment(
|
|||
};
|
||||
|
||||
if (options) {
|
||||
const { groupId, tagIds = [] } = options.meta || {};
|
||||
const { tls, azure, meta, containerEngine } = options;
|
||||
const { groupId, tagIds = [] } = meta || {};
|
||||
|
||||
payload = {
|
||||
...payload,
|
||||
|
@ -216,10 +232,9 @@ async function createEnvironment(
|
|||
GroupID: groupId,
|
||||
TagIds: arrayToJson(tagIds),
|
||||
EdgeCheckinInterval: options.pollFrequency,
|
||||
ContainerEngine: containerEngine,
|
||||
};
|
||||
|
||||
const { tls, azure } = options;
|
||||
|
||||
if (tls) {
|
||||
payload = {
|
||||
...payload,
|
||||
|
|
15
app/react/portainer/environments/queries/useIsPodman.ts
Normal file
15
app/react/portainer/environments/queries/useIsPodman.ts
Normal file
|
@ -0,0 +1,15 @@
|
|||
import { ContainerEngine, EnvironmentId } from '../types';
|
||||
|
||||
import { useEnvironment } from './useEnvironment';
|
||||
|
||||
/**
|
||||
* useIsPodman returns true if the current environment is using podman as container engine.
|
||||
* @returns isPodman boolean, can also be undefined if the environment hasn't loaded yet.
|
||||
*/
|
||||
export function useIsPodman(envId: EnvironmentId) {
|
||||
const { data: isPodman } = useEnvironment(
|
||||
envId,
|
||||
(env) => env.ContainerEngine === ContainerEngine.Podman
|
||||
);
|
||||
return isPodman;
|
||||
}
|
|
@ -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,
|
||||
}
|
||||
|
|
|
@ -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];
|
||||
}
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
export function getDockerEnvironmentType(isSwarm: boolean, isPodman?: boolean) {
|
||||
if (isPodman) {
|
||||
return 'Podman';
|
||||
}
|
||||
return isSwarm ? 'Swarm' : 'Standalone';
|
||||
}
|
|
@ -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`);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -15,6 +15,7 @@ import {
|
|||
EnvironmentOptionValue,
|
||||
existingEnvironmentTypes,
|
||||
newEnvironmentTypes,
|
||||
environmentTypes,
|
||||
} from './environment-types';
|
||||
|
||||
export function EnvironmentTypeSelectView() {
|
||||
|
@ -65,6 +66,7 @@ export function EnvironmentTypeSelectView() {
|
|||
disabled={types.length === 0}
|
||||
data-cy="start-wizard-button"
|
||||
onClick={() => startWizard()}
|
||||
className="!ml-0"
|
||||
>
|
||||
Start Wizard
|
||||
</Button>
|
||||
|
@ -80,11 +82,6 @@ export function EnvironmentTypeSelectView() {
|
|||
return;
|
||||
}
|
||||
|
||||
const environmentTypes = [
|
||||
...existingEnvironmentTypes,
|
||||
...newEnvironmentTypes,
|
||||
];
|
||||
|
||||
const steps = _.compact(
|
||||
types.map((id) => environmentTypes.find((eType) => eType.id === id))
|
||||
);
|
|
@ -1,5 +1,6 @@
|
|||
import { FeatureId } from '@/react/portainer/feature-flags/enums';
|
||||
import Docker from '@/assets/ico/vendor/docker.svg?c';
|
||||
import Podman from '@/assets/ico/vendor/podman.svg?c';
|
||||
import Kubernetes from '@/assets/ico/vendor/kubernetes.svg?c';
|
||||
import Azure from '@/assets/ico/vendor/azure.svg?c';
|
||||
import KaaS from '@/assets/ico/vendor/kaas-icon.svg?c';
|
||||
|
@ -10,6 +11,7 @@ import { BoxSelectorOption } from '@@/BoxSelector';
|
|||
export type EnvironmentOptionValue =
|
||||
| 'dockerStandalone'
|
||||
| 'dockerSwarm'
|
||||
| 'podman'
|
||||
| 'kubernetes'
|
||||
| 'aci'
|
||||
| 'kaas'
|
||||
|
@ -20,7 +22,6 @@ export interface EnvironmentOption
|
|||
id: EnvironmentOptionValue;
|
||||
value: EnvironmentOptionValue;
|
||||
}
|
||||
|
||||
export const existingEnvironmentTypes: EnvironmentOption[] = [
|
||||
{
|
||||
id: 'dockerStandalone',
|
||||
|
@ -38,6 +39,14 @@ export const existingEnvironmentTypes: EnvironmentOption[] = [
|
|||
iconType: 'logo',
|
||||
description: 'Connect to Docker Swarm via URL/IP, API or Socket',
|
||||
},
|
||||
{
|
||||
id: 'podman',
|
||||
value: 'podman',
|
||||
label: 'Podman',
|
||||
icon: Podman,
|
||||
iconType: 'logo',
|
||||
description: 'Connect to Podman via URL/IP or Socket',
|
||||
},
|
||||
{
|
||||
id: 'kubernetes',
|
||||
value: 'kubernetes',
|
||||
|
@ -80,7 +89,7 @@ export const newEnvironmentTypes: EnvironmentOption[] = [
|
|||
},
|
||||
];
|
||||
|
||||
export const environmentTypes = [
|
||||
export const environmentTypes: EnvironmentOption[] = [
|
||||
...existingEnvironmentTypes,
|
||||
...newEnvironmentTypes,
|
||||
];
|
||||
|
@ -88,6 +97,7 @@ export const environmentTypes = [
|
|||
export const formTitles: Record<EnvironmentOptionValue, string> = {
|
||||
dockerStandalone: 'Connect to your Docker Standalone environment',
|
||||
dockerSwarm: 'Connect to your Docker Swarm environment',
|
||||
podman: 'Connect to your Podman environment',
|
||||
kubernetes: 'Connect to your Kubernetes environment',
|
||||
aci: 'Connect to your ACI environment',
|
||||
kaas: 'Provision a KaaS environment',
|
||||
|
|
|
@ -1 +1 @@
|
|||
export { EnvironmentTypeSelectView } from './EndpointTypeView';
|
||||
export { EnvironmentTypeSelectView } from './EnvironmentTypeSelectView';
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -22,6 +22,7 @@ import {
|
|||
EnvironmentOptionValue,
|
||||
environmentTypes,
|
||||
formTitles,
|
||||
EnvironmentOption,
|
||||
} from '../EnvironmentTypeSelectView/environment-types';
|
||||
|
||||
import { WizardDocker } from './WizardDocker';
|
||||
|
@ -30,6 +31,7 @@ import { WizardKubernetes } from './WizardKubernetes';
|
|||
import { AnalyticsState, AnalyticsStateKey } from './types';
|
||||
import styles from './EnvironmentsCreationView.module.css';
|
||||
import { WizardEndpointsList } from './WizardEndpointsList';
|
||||
import { WizardPodman } from './WizardPodman';
|
||||
|
||||
export function EnvironmentCreationView() {
|
||||
const {
|
||||
|
@ -161,7 +163,7 @@ function useParamEnvironmentTypes(): EnvironmentOptionValue[] {
|
|||
}
|
||||
|
||||
function useStepper(
|
||||
steps: (typeof environmentTypes)[number][],
|
||||
steps: EnvironmentOption[][number][],
|
||||
onFinish: () => void
|
||||
) {
|
||||
const [currentStepIndex, setCurrentStepIndex] = useState(0);
|
||||
|
@ -197,6 +199,8 @@ function useStepper(
|
|||
case 'dockerStandalone':
|
||||
case 'dockerSwarm':
|
||||
return WizardDocker;
|
||||
case 'podman':
|
||||
return WizardPodman;
|
||||
case 'aci':
|
||||
return WizardAzure;
|
||||
case 'kubernetes':
|
||||
|
@ -211,14 +215,18 @@ function useAnalyticsState() {
|
|||
const [analytics, setAnalyticsState] = useState<AnalyticsState>({
|
||||
dockerAgent: 0,
|
||||
dockerApi: 0,
|
||||
dockerEdgeAgentAsync: 0,
|
||||
dockerEdgeAgentStandard: 0,
|
||||
podmanAgent: 0,
|
||||
podmanEdgeAgentAsync: 0,
|
||||
podmanEdgeAgentStandard: 0,
|
||||
podmanLocalEnvironment: 0,
|
||||
kubernetesAgent: 0,
|
||||
kubernetesEdgeAgentAsync: 0,
|
||||
kubernetesEdgeAgentStandard: 0,
|
||||
kaasAgent: 0,
|
||||
aciApi: 0,
|
||||
localEndpoint: 0,
|
||||
dockerEdgeAgentAsync: 0,
|
||||
dockerEdgeAgentStandard: 0,
|
||||
});
|
||||
|
||||
return { analytics, setAnalytics };
|
||||
|
|
|
@ -4,6 +4,7 @@ import { CopyButton } from '@@/buttons/CopyButton';
|
|||
import { Code } from '@@/Code';
|
||||
import { NavTabs } from '@@/NavTabs';
|
||||
import { NavContainer } from '@@/NavTabs/NavContainer';
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
|
||||
const deployments = [
|
||||
{
|
||||
|
@ -45,10 +46,10 @@ interface DeployCodeProps {
|
|||
function DeployCode({ code }: DeployCodeProps) {
|
||||
return (
|
||||
<>
|
||||
<span className="text-muted small">
|
||||
<TextTip color="blue" className="mb-1">
|
||||
When using the socket, ensure that you have started the Portainer
|
||||
container with the following Docker flag:
|
||||
</span>
|
||||
</TextTip>
|
||||
|
||||
<Code>{code}</Code>
|
||||
<div className="mt-2">
|
||||
|
|
|
@ -1,4 +1,7 @@
|
|||
import { Environment } from '@/react/portainer/environments/types';
|
||||
import {
|
||||
ContainerEngine,
|
||||
Environment,
|
||||
} from '@/react/portainer/environments/types';
|
||||
|
||||
import { AgentForm } from '../../shared/AgentForm/AgentForm';
|
||||
|
||||
|
@ -15,7 +18,10 @@ export function AgentTab({ onCreate, isDockerStandalone }: Props) {
|
|||
<DeploymentScripts isDockerStandalone={isDockerStandalone} />
|
||||
|
||||
<div className="mt-5">
|
||||
<AgentForm onCreate={onCreate} />
|
||||
<AgentForm
|
||||
onCreate={onCreate}
|
||||
containerEngine={ContainerEngine.Docker}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -1,4 +1,7 @@
|
|||
import { Environment } from '@/react/portainer/environments/types';
|
||||
import {
|
||||
ContainerEngine,
|
||||
Environment,
|
||||
} from '@/react/portainer/environments/types';
|
||||
|
||||
import { DeploymentScripts } from '../APITab/DeploymentScripts';
|
||||
|
||||
|
@ -14,7 +17,10 @@ export function SocketTab({ onCreate }: Props) {
|
|||
<DeploymentScripts />
|
||||
|
||||
<div className="mt-5">
|
||||
<SocketForm onCreate={onCreate} />
|
||||
<SocketForm
|
||||
onCreate={onCreate}
|
||||
containerEngine={ContainerEngine.Docker}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -22,8 +22,6 @@
|
|||
|
||||
.wizard-list-image {
|
||||
grid-area: image;
|
||||
font-size: 35px;
|
||||
color: #337ab7;
|
||||
}
|
||||
|
||||
.wizard-list-title {
|
||||
|
|
|
@ -1,15 +1,13 @@
|
|||
import { Plug2 } from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
import { endpointTypeName, stripProtocol } from '@/portainer/filters/filters';
|
||||
import {
|
||||
environmentTypeIcon,
|
||||
endpointTypeName,
|
||||
stripProtocol,
|
||||
} from '@/portainer/filters/filters';
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
import {
|
||||
getEnvironmentTypeIcon,
|
||||
isEdgeEnvironment,
|
||||
isUnassociatedEdgeEnvironment,
|
||||
} from '@/react/portainer/environments/utils';
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
import {
|
||||
ENVIRONMENTS_POLLING_INTERVAL,
|
||||
useEnvironmentList,
|
||||
|
@ -51,9 +49,17 @@ export function WizardEndpointsList({ environmentIds }: Props) {
|
|||
<WidgetBody>
|
||||
{environments.map((environment) => (
|
||||
<div className={styles.wizardListWrapper} key={environment.Id}>
|
||||
<div className={styles.wizardListImage}>
|
||||
<div
|
||||
className={clsx(
|
||||
styles.wizardListImage,
|
||||
'text-blue-8 th-dark:text-blue-7 th-highcontrast:text-white text-5xl'
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
icon={environmentTypeIcon(environment.Type)}
|
||||
icon={getEnvironmentTypeIcon(
|
||||
environment.Type,
|
||||
environment.ContainerEngine
|
||||
)}
|
||||
className="mr-1"
|
||||
/>
|
||||
</div>
|
||||
|
|
|
@ -0,0 +1,27 @@
|
|||
import {
|
||||
ContainerEngine,
|
||||
Environment,
|
||||
} from '@/react/portainer/environments/types';
|
||||
|
||||
import { AgentForm } from '../../shared/AgentForm/AgentForm';
|
||||
|
||||
import { DeploymentScripts } from './DeploymentScripts';
|
||||
|
||||
interface Props {
|
||||
onCreate(environment: Environment): void;
|
||||
}
|
||||
|
||||
export function AgentTab({ onCreate }: Props) {
|
||||
return (
|
||||
<>
|
||||
<DeploymentScripts />
|
||||
|
||||
<div className="mt-5">
|
||||
<AgentForm
|
||||
onCreate={onCreate}
|
||||
containerEngine={ContainerEngine.Podman}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,86 @@
|
|||
import { useState } from 'react';
|
||||
|
||||
import { useAgentDetails } from '@/react/portainer/environments/queries/useAgentDetails';
|
||||
|
||||
import { CopyButton } from '@@/buttons/CopyButton';
|
||||
import { Code } from '@@/Code';
|
||||
import { NavTabs } from '@@/NavTabs';
|
||||
import { NavContainer } from '@@/NavTabs/NavContainer';
|
||||
|
||||
const deploymentPodman = [
|
||||
{
|
||||
id: 'all',
|
||||
label: 'Linux (CentOS)',
|
||||
command: linuxPodmanCommandRootful,
|
||||
},
|
||||
];
|
||||
|
||||
export function DeploymentScripts() {
|
||||
const deployments = deploymentPodman;
|
||||
const [deployType, setDeployType] = useState(deployments[0].id);
|
||||
|
||||
const agentDetailsQuery = useAgentDetails();
|
||||
|
||||
if (!agentDetailsQuery) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { agentVersion, agentSecret } = agentDetailsQuery;
|
||||
|
||||
const options = deployments.map((c) => {
|
||||
const code = c.command(agentVersion, agentSecret);
|
||||
|
||||
return {
|
||||
id: c.id,
|
||||
label: c.label,
|
||||
children: <DeployCode code={code} />,
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<NavContainer>
|
||||
<NavTabs
|
||||
options={options}
|
||||
onSelect={(id: string) => setDeployType(id)}
|
||||
selectedId={deployType}
|
||||
/>
|
||||
</NavContainer>
|
||||
);
|
||||
}
|
||||
|
||||
interface DeployCodeProps {
|
||||
code: string;
|
||||
}
|
||||
|
||||
function DeployCode({ code }: DeployCodeProps) {
|
||||
return (
|
||||
<>
|
||||
<div className="code-script">
|
||||
<Code>{code}</Code>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<CopyButton copyText={code} data-cy="copy-deployment-script">
|
||||
Copy command
|
||||
</CopyButton>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function linuxPodmanCommandRootful(agentVersion: string, agentSecret: string) {
|
||||
const secret =
|
||||
agentSecret === '' ? '' : `\\\n -e AGENT_SECRET=${agentSecret} `;
|
||||
|
||||
return `sudo systemctl enable --now podman.socket\n
|
||||
sudo podman volume create portainer\n
|
||||
sudo podman run -d \\
|
||||
-p 9001:9001 ${secret}\\
|
||||
--name portainer_agent \\
|
||||
--restart=always \\
|
||||
--privileged \\
|
||||
-v /run/podman/podman.sock:/var/run/docker.sock \\
|
||||
-v /var/lib/containers/storage/volumes:/var/lib/docker/volumes \\
|
||||
-v /:/host \\
|
||||
portainer/agent:${agentVersion}
|
||||
`;
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { AgentTab } from './AgentTab';
|
|
@ -0,0 +1,68 @@
|
|||
import { useState } from 'react';
|
||||
|
||||
import { CopyButton } from '@@/buttons/CopyButton';
|
||||
import { Code } from '@@/Code';
|
||||
import { NavTabs } from '@@/NavTabs';
|
||||
import { NavContainer } from '@@/NavTabs/NavContainer';
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
|
||||
const deployments = [
|
||||
{
|
||||
id: 'linux',
|
||||
label: 'Linux (CentOS)',
|
||||
command: `sudo systemctl enable --now podman.socket`,
|
||||
},
|
||||
];
|
||||
|
||||
export function DeploymentScripts() {
|
||||
const [deployType, setDeployType] = useState(deployments[0].id);
|
||||
|
||||
const options = deployments.map((c) => ({
|
||||
id: c.id,
|
||||
label: c.label,
|
||||
children: <DeployCode code={c.command} />,
|
||||
}));
|
||||
|
||||
return (
|
||||
<NavContainer>
|
||||
<NavTabs
|
||||
options={options}
|
||||
onSelect={(id: string) => setDeployType(id)}
|
||||
selectedId={deployType}
|
||||
/>
|
||||
</NavContainer>
|
||||
);
|
||||
}
|
||||
|
||||
interface DeployCodeProps {
|
||||
code: string;
|
||||
}
|
||||
|
||||
function DeployCode({ code }: DeployCodeProps) {
|
||||
const bindMountCode = `-v "/run/podman/podman.sock:/var/run/docker.sock"`;
|
||||
return (
|
||||
<>
|
||||
<TextTip color="blue" className="mb-1">
|
||||
When using the socket, ensure that you have started the Portainer
|
||||
container with the following Podman flag:
|
||||
</TextTip>
|
||||
<Code>{bindMountCode}</Code>
|
||||
<div className="mt-2 mb-4">
|
||||
<CopyButton copyText={bindMountCode} data-cy="copy-deployment-command">
|
||||
Copy command
|
||||
</CopyButton>
|
||||
</div>
|
||||
|
||||
<TextTip color="blue" className="mb-1">
|
||||
To use the socket, ensure that you have started the Podman rootful
|
||||
socket:
|
||||
</TextTip>
|
||||
<Code>{code}</Code>
|
||||
<div className="mt-2">
|
||||
<CopyButton copyText={code} data-cy="copy-deployment-command">
|
||||
Copy command
|
||||
</CopyButton>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,125 @@
|
|||
import { Field, Form, Formik, useFormikContext } from 'formik';
|
||||
import { useReducer } from 'react';
|
||||
import { Plug2 } from 'lucide-react';
|
||||
|
||||
import { notifySuccess } from '@/portainer/services/notifications';
|
||||
import { useCreateLocalDockerEnvironmentMutation } from '@/react/portainer/environments/queries/useCreateEnvironmentMutation';
|
||||
import {
|
||||
ContainerEngine,
|
||||
Environment,
|
||||
} from '@/react/portainer/environments/types';
|
||||
|
||||
import { LoadingButton } from '@@/buttons/LoadingButton';
|
||||
import { FormControl } from '@@/form-components/FormControl';
|
||||
import { Input } from '@@/form-components/Input';
|
||||
import { SwitchField } from '@@/form-components/SwitchField';
|
||||
|
||||
import { NameField } from '../../shared/NameField';
|
||||
import { MoreSettingsSection } from '../../shared/MoreSettingsSection';
|
||||
|
||||
import { useValidation } from './SocketForm.validation';
|
||||
import { FormValues } from './types';
|
||||
|
||||
interface Props {
|
||||
onCreate(environment: Environment): void;
|
||||
containerEngine: ContainerEngine;
|
||||
}
|
||||
|
||||
export function SocketForm({ onCreate, containerEngine }: Props) {
|
||||
const [formKey, clearForm] = useReducer((state) => state + 1, 0);
|
||||
const initialValues: FormValues = {
|
||||
name: '',
|
||||
socketPath: '',
|
||||
overridePath: false,
|
||||
meta: { groupId: 1, tagIds: [] },
|
||||
};
|
||||
|
||||
const mutation = useCreateLocalDockerEnvironmentMutation();
|
||||
const validation = useValidation();
|
||||
|
||||
return (
|
||||
<Formik
|
||||
initialValues={initialValues}
|
||||
onSubmit={handleSubmit}
|
||||
validationSchema={validation}
|
||||
validateOnMount
|
||||
key={formKey}
|
||||
>
|
||||
{({ isValid, dirty }) => (
|
||||
<Form>
|
||||
<NameField />
|
||||
|
||||
<OverrideSocketFieldset />
|
||||
|
||||
<MoreSettingsSection />
|
||||
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
<LoadingButton
|
||||
className="wizard-connect-button vertical-center"
|
||||
data-cy="docker-socket-connect-button"
|
||||
loadingText="Connecting environment..."
|
||||
isLoading={mutation.isLoading}
|
||||
disabled={!dirty || !isValid}
|
||||
icon={Plug2}
|
||||
>
|
||||
Connect
|
||||
</LoadingButton>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
);
|
||||
|
||||
function handleSubmit(values: FormValues) {
|
||||
mutation.mutate(
|
||||
{
|
||||
name: values.name,
|
||||
socketPath: values.overridePath ? values.socketPath : '',
|
||||
meta: values.meta,
|
||||
containerEngine,
|
||||
},
|
||||
{
|
||||
onSuccess(environment) {
|
||||
notifySuccess('Environment created', environment.Name);
|
||||
clearForm();
|
||||
onCreate(environment);
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function OverrideSocketFieldset() {
|
||||
const { values, setFieldValue, errors } = useFormikContext<FormValues>();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
<SwitchField
|
||||
checked={values.overridePath}
|
||||
data-cy="create-docker-env-socket-override-switch"
|
||||
onChange={(checked) => setFieldValue('overridePath', checked)}
|
||||
label="Override default socket path"
|
||||
labelClass="col-sm-3 col-lg-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{values.overridePath && (
|
||||
<FormControl
|
||||
label="Socket Path"
|
||||
tooltip="Path to the Podman socket. Remember to bind-mount the socket, see the important notice above for more information."
|
||||
errors={errors.socketPath}
|
||||
>
|
||||
<Field
|
||||
name="socketPath"
|
||||
as={Input}
|
||||
placeholder="e.g. /run/podman/podman.sock (on Linux)"
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,23 @@
|
|||
import { boolean, object, SchemaOf, string } from 'yup';
|
||||
|
||||
import { metadataValidation } from '../../shared/MetadataFieldset/validation';
|
||||
import { useNameValidation } from '../../shared/NameField';
|
||||
|
||||
import { FormValues } from './types';
|
||||
|
||||
export function useValidation(): SchemaOf<FormValues> {
|
||||
return object({
|
||||
name: useNameValidation(),
|
||||
meta: metadataValidation(),
|
||||
overridePath: boolean().default(false),
|
||||
socketPath: string()
|
||||
.default('')
|
||||
.when('overridePath', (overridePath, schema) =>
|
||||
overridePath
|
||||
? schema.required(
|
||||
'Socket Path is required when override path is enabled'
|
||||
)
|
||||
: schema
|
||||
),
|
||||
});
|
||||
}
|
|
@ -0,0 +1,33 @@
|
|||
import {
|
||||
ContainerEngine,
|
||||
Environment,
|
||||
} from '@/react/portainer/environments/types';
|
||||
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
|
||||
import { DeploymentScripts } from './DeploymentScripts';
|
||||
import { SocketForm } from './SocketForm';
|
||||
|
||||
interface Props {
|
||||
onCreate(environment: Environment): void;
|
||||
}
|
||||
|
||||
export function SocketTab({ onCreate }: Props) {
|
||||
return (
|
||||
<>
|
||||
<TextTip color="orange" className="mb-2" inline={false}>
|
||||
To connect via socket, Portainer server must be running in a Podman
|
||||
container.
|
||||
</TextTip>
|
||||
|
||||
<DeploymentScripts />
|
||||
|
||||
<div className="mt-5">
|
||||
<SocketForm
|
||||
onCreate={onCreate}
|
||||
containerEngine={ContainerEngine.Podman}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { SocketTab } from './SocketTab';
|
|
@ -0,0 +1,8 @@
|
|||
import { EnvironmentMetadata } from '@/react/portainer/environments/environment.service/create';
|
||||
|
||||
export interface FormValues {
|
||||
name: string;
|
||||
socketPath: string;
|
||||
overridePath: boolean;
|
||||
meta: EnvironmentMetadata;
|
||||
}
|
|
@ -0,0 +1,133 @@
|
|||
import { useState } from 'react';
|
||||
import { Zap, Plug2 } from 'lucide-react';
|
||||
import _ from 'lodash';
|
||||
|
||||
import {
|
||||
ContainerEngine,
|
||||
Environment,
|
||||
} from '@/react/portainer/environments/types';
|
||||
import { commandsTabs } from '@/react/edge/components/EdgeScriptForm/scripts';
|
||||
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
|
||||
import EdgeAgentStandardIcon from '@/react/edge/components/edge-agent-standard.svg?c';
|
||||
import EdgeAgentAsyncIcon from '@/react/edge/components/edge-agent-async.svg?c';
|
||||
|
||||
import { BoxSelector, type BoxSelectorOption } from '@@/BoxSelector';
|
||||
import { BadgeIcon } from '@@/BadgeIcon';
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
|
||||
import { AnalyticsStateKey } from '../types';
|
||||
import { EdgeAgentTab } from '../shared/EdgeAgentTab';
|
||||
|
||||
import { AgentTab } from './AgentTab';
|
||||
import { SocketTab } from './SocketTab';
|
||||
|
||||
interface Props {
|
||||
onCreate(environment: Environment, analytics: AnalyticsStateKey): void;
|
||||
}
|
||||
|
||||
const options: BoxSelectorOption<
|
||||
'agent' | 'api' | 'socket' | 'edgeAgentStandard' | 'edgeAgentAsync'
|
||||
>[] = _.compact([
|
||||
{
|
||||
id: 'agent',
|
||||
icon: <BadgeIcon icon={Zap} size="3xl" />,
|
||||
label: 'Agent',
|
||||
description: '',
|
||||
value: 'agent',
|
||||
},
|
||||
{
|
||||
id: 'socket',
|
||||
icon: <BadgeIcon icon={Plug2} size="3xl" />,
|
||||
label: 'Socket',
|
||||
description: '',
|
||||
value: 'socket',
|
||||
},
|
||||
{
|
||||
id: 'edgeAgentStandard',
|
||||
icon: <BadgeIcon icon={EdgeAgentStandardIcon} size="3xl" />,
|
||||
label: 'Edge Agent Standard',
|
||||
description: '',
|
||||
value: 'edgeAgentStandard',
|
||||
},
|
||||
isBE && {
|
||||
id: 'edgeAgentAsync',
|
||||
icon: <BadgeIcon icon={EdgeAgentAsyncIcon} size="3xl" />,
|
||||
label: 'Edge Agent Async',
|
||||
description: '',
|
||||
value: 'edgeAgentAsync',
|
||||
},
|
||||
]);
|
||||
|
||||
const containerEngine = ContainerEngine.Podman;
|
||||
|
||||
export function WizardPodman({ onCreate }: Props) {
|
||||
const [creationType, setCreationType] = useState(options[0].value);
|
||||
|
||||
const tab = getTab(creationType);
|
||||
|
||||
return (
|
||||
<div className="form-horizontal">
|
||||
<BoxSelector
|
||||
onChange={(v) => setCreationType(v)}
|
||||
options={options}
|
||||
value={creationType}
|
||||
radioName="creation-type"
|
||||
/>
|
||||
<TextTip color="orange" className="mb-2" inline={false}>
|
||||
Currently, Portainer only supports <b>Podman 5</b> running in rootful
|
||||
(privileged) mode on <b>CentOS 9</b> Linux environments. Rootless mode
|
||||
and other Linux distros may work, but aren't officially supported.
|
||||
</TextTip>
|
||||
{tab}
|
||||
</div>
|
||||
);
|
||||
|
||||
function getTab(
|
||||
creationType:
|
||||
| 'agent'
|
||||
| 'api'
|
||||
| 'socket'
|
||||
| 'edgeAgentStandard'
|
||||
| 'edgeAgentAsync'
|
||||
) {
|
||||
switch (creationType) {
|
||||
case 'agent':
|
||||
return (
|
||||
<AgentTab
|
||||
onCreate={(environment) => onCreate(environment, 'podmanAgent')}
|
||||
/>
|
||||
);
|
||||
case 'socket':
|
||||
return (
|
||||
<SocketTab
|
||||
onCreate={(environment) =>
|
||||
onCreate(environment, 'podmanLocalEnvironment')
|
||||
}
|
||||
/>
|
||||
);
|
||||
case 'edgeAgentStandard':
|
||||
return (
|
||||
<EdgeAgentTab
|
||||
onCreate={(environment) =>
|
||||
onCreate(environment, 'podmanEdgeAgentStandard')
|
||||
}
|
||||
commands={[commandsTabs.podmanLinux]}
|
||||
containerEngine={containerEngine}
|
||||
/>
|
||||
);
|
||||
case 'edgeAgentAsync':
|
||||
return (
|
||||
<EdgeAgentTab
|
||||
asyncMode
|
||||
onCreate={(environment) =>
|
||||
onCreate(environment, 'podmanEdgeAgentAsync')
|
||||
}
|
||||
commands={[commandsTabs.podmanLinux]}
|
||||
containerEngine={containerEngine}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { WizardPodman } from './WizardPodman';
|
|
@ -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);
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { object, SchemaOf, string } from 'yup';
|
||||
import { mixed, object, SchemaOf, string } from 'yup';
|
||||
|
||||
import { CreateAgentEnvironmentValues } from '@/react/portainer/environments/environment.service/create';
|
||||
|
||||
|
@ -10,6 +10,7 @@ export function useValidation(): SchemaOf<CreateAgentEnvironmentValues> {
|
|||
name: useNameValidation(),
|
||||
environmentUrl: environmentValidation(),
|
||||
meta: metadataValidation(),
|
||||
containerEngine: mixed().oneOf(['docker', 'podman']),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -1,7 +1,10 @@
|
|||
import { v4 as uuid } from 'uuid';
|
||||
import { useReducer, useState } from 'react';
|
||||
|
||||
import { Environment } from '@/react/portainer/environments/types';
|
||||
import {
|
||||
ContainerEngine,
|
||||
Environment,
|
||||
} from '@/react/portainer/environments/types';
|
||||
import { EdgeScriptForm } from '@/react/edge/components/EdgeScriptForm';
|
||||
import { CommandTab } from '@/react/edge/components/EdgeScriptForm/scripts';
|
||||
import { OS, EdgeInfo } from '@/react/edge/components/EdgeScriptForm/types';
|
||||
|
@ -15,9 +18,15 @@ interface Props {
|
|||
onCreate: (environment: Environment) => void;
|
||||
commands: CommandTab[] | Partial<Record<OS, CommandTab[]>>;
|
||||
asyncMode?: boolean;
|
||||
containerEngine?: ContainerEngine;
|
||||
}
|
||||
|
||||
export function EdgeAgentTab({ onCreate, commands, asyncMode = false }: Props) {
|
||||
export function EdgeAgentTab({
|
||||
onCreate,
|
||||
commands,
|
||||
asyncMode = false,
|
||||
containerEngine = ContainerEngine.Docker,
|
||||
}: Props) {
|
||||
const [edgeInfo, setEdgeInfo] = useState<EdgeInfo>();
|
||||
const [formKey, clearForm] = useReducer((state) => state + 1, 0);
|
||||
|
||||
|
@ -28,6 +37,7 @@ export function EdgeAgentTab({ onCreate, commands, asyncMode = false }: Props) {
|
|||
readonly={!!edgeInfo}
|
||||
key={formKey}
|
||||
asyncMode={asyncMode}
|
||||
containerEngine={containerEngine}
|
||||
/>
|
||||
|
||||
{edgeInfo && (
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||
import { useIsSwarm } from '@/react/docker/proxy/queries/useInfo';
|
||||
import { StackType } from '@/react/common/stacks/types';
|
||||
import { ContainerEngine } from '@/react/portainer/environments/types';
|
||||
|
||||
import { PageHeader } from '@@/PageHeader';
|
||||
import { Widget } from '@@/Widget';
|
||||
|
@ -12,16 +13,18 @@ import { CreateForm } from './CreateForm';
|
|||
export function CreateView() {
|
||||
const viewType = useViewType();
|
||||
const environmentId = useEnvironmentId(false);
|
||||
const isSwarm = useIsSwarm(environmentId, { enabled: viewType === 'docker' });
|
||||
const isSwarm = useIsSwarm(environmentId, {
|
||||
enabled: viewType === ContainerEngine.Docker,
|
||||
});
|
||||
const defaultType = getDefaultType(viewType, isSwarm);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Create Custom template"
|
||||
title="Create Custom Template"
|
||||
breadcrumbs={[
|
||||
{ label: 'Custom Templates', link: '^' },
|
||||
'Create Custom template',
|
||||
'Create Custom Template',
|
||||
]}
|
||||
/>
|
||||
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { notifySuccess } from '@/portainer/services/notifications';
|
||||
import { useParamState } from '@/react/hooks/useParamState';
|
||||
import { ContainerEngine } from '@/react/portainer/environments/types';
|
||||
|
||||
import { PageHeader } from '@@/PageHeader';
|
||||
import { confirmDelete } from '@@/modals/confirm';
|
||||
|
@ -28,7 +29,7 @@ export function ListView() {
|
|||
<>
|
||||
<PageHeader title="Custom Templates" breadcrumbs="Custom Templates" />
|
||||
|
||||
{viewType === 'docker' && !!selectedTemplateId && (
|
||||
{viewType === ContainerEngine.Docker && !!selectedTemplateId && (
|
||||
<StackFromCustomTemplateFormWidget templateId={selectedTemplateId} />
|
||||
)}
|
||||
|
||||
|
|
|
@ -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 (
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue