mirror of
https://github.com/portainer/portainer.git
synced 2025-07-24 15:59:41 +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
|
@ -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>
|
||||
)}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue