refactor(containers): migrate volumes tab to react [EE-5209] (#10284)

pull/10344/head
Chaim Lev-Ari 2023-09-21 05:31:00 +03:00 committed by GitHub
parent 16ccf5871e
commit e92f067e42
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 398 additions and 143 deletions

View File

@ -4,19 +4,22 @@ import { ComponentProps } from 'react';
import { withUIRouter } from '@/react-tools/withUIRouter';
import { withReactQuery } from '@/react-tools/withReactQuery';
import { withFormValidation } from '@/react-tools/withFormValidation';
import { r2a } from '@/react-tools/react2angular';
import { withCurrentUser } from '@/react-tools/withCurrentUser';
import { ContainerNetworksDatatable } from '@/react/docker/containers/ItemView/ContainerNetworksDatatable';
import {
CommandsTab,
CommandsTabValues,
commandsTabValidation,
} from '@/react/docker/containers/CreateView/CommandsTab';
import { r2a } from '@/react-tools/react2angular';
import { withCurrentUser } from '@/react-tools/withCurrentUser';
import { ContainerNetworksDatatable } from '@/react/docker/containers/ItemView/ContainerNetworksDatatable';
import {
EnvVarsTab,
Values as EnvVarsTabValues,
envVarsTabUtils,
} from '@/react/docker/containers/CreateView/EnvVarsTab';
import {
VolumesTab,
volumesTabUtils,
} from '@/react/docker/containers/CreateView/VolumesTab';
const ngModule = angular
.module('portainer.docker.react.components.containers', [])
@ -39,10 +42,18 @@ withFormValidation<ComponentProps<typeof CommandsTab>, CommandsTabValues>(
commandsTabValidation
);
withFormValidation<ComponentProps<typeof EnvVarsTab>, EnvVarsTabValues>(
withFormValidation(
ngModule,
withUIRouter(withReactQuery(EnvVarsTab)),
'dockerCreateContainerEnvVarsTab',
[],
envVarsTabUtils.validation
);
withFormValidation(
ngModule,
withUIRouter(withReactQuery(VolumesTab)),
'dockerCreateContainerVolumesTab',
['allowBindMounts'],
volumesTabUtils.validation
);

View File

@ -7,6 +7,7 @@ import { FeatureId } from '@/react/portainer/feature-flags/enums';
import { buildConfirmButton } from '@@/modals/utils';
import { commandsTabUtils } from '@/react/docker/containers/CreateView/CommandsTab';
import { volumesTabUtils } from '@/react/docker/containers/CreateView/VolumesTab';
import { ContainerCapabilities, ContainerCapability } from '@/docker/models/containerCapabilities';
import { AccessControlFormData } from '@/portainer/components/accessControlForm/porAccessControlFormModel';
import { ContainerDetailsViewModel } from '@/docker/models/container';
@ -25,7 +26,6 @@ angular.module('portainer.docker').controller('CreateContainerController', [
'Container',
'ContainerHelper',
'ImageHelper',
'Volume',
'NetworkService',
'ResourceControlService',
'Authentication',
@ -49,7 +49,6 @@ angular.module('portainer.docker').controller('CreateContainerController', [
Container,
ContainerHelper,
ImageHelper,
Volume,
NetworkService,
ResourceControlService,
Authentication,
@ -75,7 +74,6 @@ angular.module('portainer.docker').controller('CreateContainerController', [
selectedGPUs: ['all'],
capabilities: ['compute', 'utility'],
},
Volumes: [],
NetworkContainer: null,
Labels: [],
ExtraHosts: [],
@ -95,6 +93,7 @@ angular.module('portainer.docker').controller('CreateContainerController', [
RegistryModel: new PorImageRegistryModel(),
commands: commandsTabUtils.getDefaultViewModel(),
envVars: envVarsTabUtils.getDefaultViewModel(),
volumes: volumesTabUtils.getDefaultViewModel(),
};
$scope.extraNetworks = {};
@ -128,6 +127,12 @@ angular.module('portainer.docker').controller('CreateContainerController', [
});
}
$scope.onVolumesChange = function (volumes) {
return $scope.$evalAsync(() => {
$scope.formValues.volumes = volumes;
});
};
function onAlwaysPullChange(checked) {
return $scope.$evalAsync(() => {
$scope.formValues.alwaysPull = checked;
@ -215,14 +220,6 @@ angular.module('portainer.docker').controller('CreateContainerController', [
Labels: {},
};
$scope.addVolume = function () {
$scope.formValues.Volumes.push({ name: '', containerPath: '', readOnly: false, type: 'volume' });
};
$scope.removeVolume = function (index) {
$scope.formValues.Volumes.splice(index, 1);
};
$scope.addPortBinding = function () {
$scope.config.HostConfig.PortBindings.push({ hostPort: '', containerPort: '', protocol: 'tcp' });
};
@ -283,26 +280,6 @@ angular.module('portainer.docker').controller('CreateContainerController', [
config.HostConfig.PortBindings = bindings;
}
function prepareVolumes(config) {
var binds = [];
var volumes = {};
$scope.formValues.Volumes.forEach(function (volume) {
var name = volume.name;
var containerPath = volume.containerPath;
if (name && containerPath) {
var bind = name + ':' + containerPath;
volumes[containerPath] = {};
if (volume.readOnly) {
bind += ':ro';
}
binds.push(bind);
}
});
config.HostConfig.Binds = binds;
config.Volumes = volumes;
}
function prepareNetworkConfig(config) {
var mode = config.HostConfig.NetworkMode;
var container = $scope.formValues.NetworkContainer;
@ -461,11 +438,11 @@ angular.module('portainer.docker').controller('CreateContainerController', [
var config = angular.copy($scope.config);
config = commandsTabUtils.toRequest(config, $scope.formValues.commands);
config = envVarsTabUtils.toRequest(config, $scope.formValues.envVars);
config = volumesTabUtils.toRequest(config, $scope.formValues.volumes);
prepareNetworkConfig(config);
prepareImageConfig(config);
preparePortBindings(config);
prepareVolumes(config);
prepareLabels(config);
prepareDevices(config);
prepareResources(config);
@ -480,21 +457,6 @@ angular.module('portainer.docker').controller('CreateContainerController', [
$scope.config.HostConfig.PortBindings = bindings;
}
function loadFromContainerVolumes(d) {
for (var v in d.Mounts) {
if ({}.hasOwnProperty.call(d.Mounts, v)) {
var mount = d.Mounts[v];
var volume = {
type: mount.Type,
name: mount.Name || mount.Source,
containerPath: mount.Destination,
readOnly: mount.RW === false,
};
$scope.formValues.Volumes.push(volume);
}
}
}
$scope.resetNetworkConfig = function () {
$scope.config.NetworkingConfig = {
EndpointsConfig: {},
@ -682,9 +644,10 @@ angular.module('portainer.docker').controller('CreateContainerController', [
$scope.formValues.commands = commandsTabUtils.toViewModel(d);
$scope.formValues.envVars = envVarsTabUtils.toViewModel(d);
$scope.formValues.volumes = volumesTabUtils.toViewModel(d);
loadFromContainerPortBindings(d);
loadFromContainerVolumes(d);
loadFromContainerNetworkConfig(d);
loadFromContainerLabels(d);
@ -714,18 +677,6 @@ angular.module('portainer.docker').controller('CreateContainerController', [
$scope.areContainerCapabilitiesEnabled = await checkIfContainerCapabilitiesEnabled();
$scope.isAdminOrEndpointAdmin = Authentication.isAdmin();
Volume.query(
{},
function (d) {
$scope.availableVolumes = d.Volumes.sort((vol1, vol2) => {
return vol1.Name.localeCompare(vol2.Name);
});
},
function (e) {
Notifications.error('Failure', e, 'Unable to retrieve volumes');
}
);
var provider = $scope.applicationState.endpoint.mode.provider;
var apiVersion = $scope.applicationState.endpoint.apiVersion;
NetworkService.networks(provider === 'DOCKER_STANDALONE' || provider === 'DOCKER_SWARM_MODE', false, provider === 'DOCKER_SWARM_MODE' && apiVersion >= 1.25)

View File

@ -214,77 +214,12 @@
></docker-create-container-commands-tab>
</div>
<!-- !tab-command -->
<!-- tab-volume -->
<div class="tab-pane" id="volumes">
<form class="form-horizontal" style="margin-top: 15px">
<!-- volumes -->
<div class="form-group">
<div class="col-sm-12" style="margin-top: 5px">
<label class="control-label text-left">Volume mapping</label>
<span class="label label-default interactive" style="margin-left: 10px" ng-click="addVolume()">
<pr-icon icon="'plus'" mode="'alt'"></pr-icon> map additional volume
</span>
</div>
<!-- volumes-input-list -->
<div class="form-inline" style="margin-top: 10px">
<div ng-repeat="volume in formValues.Volumes">
<!-- volume-line1 -->
<div class="col-sm-12 form-inline" style="margin-top: 10px">
<!-- container-path -->
<div class="input-group input-group-sm col-sm-6">
<span class="input-group-addon">container</span>
<input type="text" class="form-control" ng-model="volume.containerPath" placeholder="e.g. /path/in/container" />
</div>
<!-- !container-path -->
<!-- volume-type -->
<div class="input-group col-sm-5" style="margin-left: 5px">
<div class="btn-group btn-group-sm" ng-if="allowBindMounts">
<label class="btn btn-light" ng-model="volume.type" uib-btn-radio="'volume'" ng-click="volume.name = ''">Volume</label>
<label class="btn btn-light" ng-model="volume.type" uib-btn-radio="'bind'" ng-click="volume.name = ''">Bind</label>
</div>
<button class="btn btn-light" type="button" ng-click="removeVolume($index)">
<pr-icon icon="'trash-2'" class-name="'icon-secondary icon-md'"></pr-icon>
</button>
</div>
<!-- !volume-type -->
</div>
<!-- !volume-line1 -->
<!-- volume-line2 -->
<div class="col-sm-12 form-inline" style="margin-top: 5px">
<pr-icon icon="'arrow-right'"></pr-icon>
<!-- volume -->
<div class="input-group input-group-sm col-sm-6" ng-if="volume.type === 'volume'">
<span class="input-group-addon">volume</span>
<select class="form-control" ng-model="volume.name">
<option selected disabled hidden value="">Select a volume</option>
<option ng-repeat="vol in availableVolumes" ng-value="vol.Name">{{ vol.Name | truncate: 30 }} - {{ vol.Driver | truncate: 30 }}</option>
</select>
</div>
<!-- !volume -->
<!-- bind -->
<div class="input-group input-group-sm col-sm-6" ng-if="volume.type === 'bind'">
<span class="input-group-addon">host</span>
<input type="text" class="form-control" ng-model="volume.name" placeholder="e.g. /path/on/host" />
</div>
<!-- !bind -->
<!-- read-only -->
<div class="input-group input-group-sm col-sm-5" style="margin-left: 5px">
<div class="btn-group btn-group-sm">
<label class="btn btn-light" ng-model="volume.readOnly" uib-btn-radio="false">Writable</label>
<label class="btn btn-light" ng-model="volume.readOnly" uib-btn-radio="true">Read-only</label>
</div>
</div>
<!-- !read-only -->
</div>
<!-- !volume-line2 -->
</div>
</div>
<!-- !volumes-input-list -->
</div>
</form>
<!-- !volumes -->
<docker-create-container-volumes-tab ng-if="state.containerIsLoaded" values="formValues.volumes" on-change="(onVolumesChange)" allow-bind-mounts="allowBindMounts">
</docker-create-container-volumes-tab>
</div>
<!-- !tab-volume -->
<!-- tab-network -->
<div class="tab-pane" id="network">
<form class="form-horizontal" style="margin-top: 15px">

View File

@ -16,10 +16,10 @@ export function HeaderTitle({ title, children }: PropsWithChildren<Props>) {
return (
<div className="flex justify-between whitespace-normal pt-3">
<div className="flex items-center gap-2">
<div className="text-2xl font-medium text-gray-11 th-highcontrast:text-white th-dark:text-white">
<h1 className="m-0 text-2xl font-medium text-gray-11 th-highcontrast:text-white th-dark:text-white">
{title}
</div>
{children && <span>{children}</span>}
</h1>
{children && <>{children}</>}
</div>
<div className="flex items-end">
<NotificationsMenu />

View File

@ -5,15 +5,21 @@ export type Size = 'xsmall' | 'small' | 'large';
export interface Props {
size?: Size;
className?: string;
'aria-label'?: string;
}
export function ButtonGroup({
size = 'small',
children,
className,
'aria-label': ariaLabel,
}: PropsWithChildren<Props>) {
return (
<div className={clsx('btn-group', sizeClass(size), className)} role="group">
<div
className={clsx('btn-group', sizeClass(size), className)}
role="group"
aria-label={ariaLabel}
>
{children}
</div>
);

View File

@ -19,9 +19,10 @@ interface Props<T> {
disabled?: boolean;
readOnly?: boolean;
className?: string;
'aria-label'?: string;
}
export function ButtonSelector<T extends string | number>({
export function ButtonSelector<T extends string | number | boolean>({
value,
onChange,
size,
@ -29,12 +30,17 @@ export function ButtonSelector<T extends string | number>({
disabled,
readOnly,
className,
'aria-label': ariaLabel,
}: Props<T>) {
return (
<ButtonGroup size={size} className={clsx(styles.group, className)}>
<ButtonGroup
size={size}
className={clsx(styles.group, className)}
aria-label={ariaLabel}
>
{options.map((option) => (
<OptionItem
key={option.value}
key={option.value.toString()}
selected={value === option.value}
onChange={() => onChange(option.value)}
disabled={disabled}

View File

@ -17,8 +17,8 @@ type ArrElement<ArrType> = ArrType extends readonly (infer ElementType)[]
? ElementType
: never;
export type ArrayError<T> =
| FormikErrors<ArrElement<T>>[]
export type ArrayError<TArray> =
| Array<FormikErrors<ArrElement<TArray> | undefined>>
| string
| string[]
| undefined;

View File

@ -0,0 +1,98 @@
import _ from 'lodash';
import { ArrowRight } from 'lucide-react';
import { Icon } from '@@/Icon';
import { ButtonSelector } from '@@/form-components/ButtonSelector/ButtonSelector';
import { FormError } from '@@/form-components/FormError';
import { InputGroup } from '@@/form-components/InputGroup';
import { ItemProps } from '@@/form-components/InputList';
import { InputLabeled } from '@@/form-components/Input/InputLabeled';
import { Volume } from './types';
import { useInputContext } from './context';
import { VolumeSelector } from './VolumeSelector';
export function Item({
item: volume,
onChange,
error,
index,
}: ItemProps<Volume>) {
const allowBindMounts = useInputContext();
return (
<div>
<div className="col-sm-12 form-inline flex gap-1">
<InputLabeled
label="container"
placeholder="e.g. /path/in/container"
value={volume.containerPath}
onChange={(e) => setValue({ containerPath: e.target.value })}
size="small"
className="flex-1"
id={`container-path-${index}`}
/>
{allowBindMounts && (
<InputGroup size="small">
<ButtonSelector
value={volume.type}
onChange={(type) => {
onChange({ ...volume, type, name: '' });
}}
options={[
{ value: 'volume', label: 'Volume' },
{ value: 'bind', label: 'Bind' },
]}
aria-label="Volume type"
/>
</InputGroup>
)}
</div>
<div className="col-sm-12 form-inline mt-1 flex items-center gap-1">
<Icon icon={ArrowRight} />
{volume.type === 'volume' && (
<InputGroup size="small" className="flex-1">
<InputGroup.Addon as="label" htmlFor={`volume-${index}`}>
volume
</InputGroup.Addon>
<VolumeSelector
value={volume.name}
onChange={(name) => setValue({ name })}
inputId={`volume-${index}`}
/>
</InputGroup>
)}
{volume.type === 'bind' && (
<InputLabeled
size="small"
className="flex-1"
label="host"
placeholder="e.g. /path/on/host"
value={volume.name}
onChange={(e) => setValue({ name: e.target.value })}
id={`host-path-${index}`}
/>
)}
<InputGroup size="small">
<ButtonSelector<boolean>
aria-label="ReadWrite"
value={volume.readOnly}
onChange={(readOnly) => setValue({ readOnly })}
options={[
{ value: false, label: 'Writable' },
{ value: true, label: 'Read-only' },
]}
/>
</InputGroup>
</div>
{error && <FormError>{_.first(Object.values(error))}</FormError>}
</div>
);
function setValue(partial: Partial<Volume>) {
onChange({ ...volume, ...partial });
}
}

View File

@ -0,0 +1,45 @@
import { truncate } from '@/portainer/filters/filters';
import { useVolumes } from '@/react/docker/volumes/queries/useVolumes';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { Select } from '@@/form-components/ReactSelect';
export function VolumeSelector({
value,
onChange,
inputId,
}: {
value: string;
onChange: (value?: string) => void;
inputId?: string;
}) {
const environmentId = useEnvironmentId();
const volumesQuery = useVolumes(environmentId, {
select(volumes) {
return volumes.sort((vol1, vol2) => vol1.Name.localeCompare(vol2.Name));
},
});
if (!volumesQuery.data) {
return null;
}
const volumes = volumesQuery.data;
const selectedValue = volumes.find((vol) => vol.Name === value);
return (
<Select
placeholder="Select a volume"
options={volumes}
getOptionLabel={(vol) =>
`${truncate(vol.Name, 30)} - ${truncate(vol.Driver, 30)}`
}
getOptionValue={(vol) => vol.Name}
isMulti={false}
value={selectedValue}
onChange={(vol) => onChange(vol?.Name)}
inputId={inputId}
/>
);
}

View File

@ -0,0 +1,46 @@
import { useState } from 'react';
import { FormikErrors } from 'formik';
import { InputList } from '@@/form-components/InputList';
import { Values, Volume } from './types';
import { InputContext } from './context';
import { Item } from './Item';
export function VolumesTab({
onChange,
values,
allowBindMounts,
errors,
}: {
onChange: (values: Values) => void;
values: Values;
allowBindMounts: boolean;
errors?: FormikErrors<Values>;
}) {
const [controlledValues, setControlledValues] = useState(values);
return (
<InputContext.Provider value={allowBindMounts}>
<InputList<Volume>
errors={Array.isArray(errors) ? errors : []}
label="Volume mapping"
onChange={(volumes) => handleChange(volumes)}
value={controlledValues}
addLabel="map additional volume"
item={Item}
itemBuilder={() => ({
containerPath: '',
type: 'volume',
name: '',
readOnly: false,
})}
/>
</InputContext.Provider>
);
function handleChange(newValues: Values) {
onChange(newValues);
setControlledValues(() => newValues);
}
}

View File

@ -0,0 +1,13 @@
import { createContext, useContext } from 'react';
export const InputContext = createContext<boolean | null>(null);
export function useInputContext() {
const value = useContext(InputContext);
if (value === null) {
throw new Error('useContext must be used within a Context.Provider');
}
return value;
}

View File

@ -0,0 +1,14 @@
import { validation } from './validation';
import { toRequest } from './toRequest';
import { toViewModel, getDefaultViewModel } from './toViewModel';
export { VolumesTab } from './VolumesTab';
export { type Values as VolumesTabValues } from './types';
export const volumesTabUtils = {
toRequest,
toViewModel,
validation,
getDefaultViewModel,
};

View File

@ -0,0 +1,33 @@
import { CreateContainerRequest } from '../types';
import { Values } from './types';
export function toRequest(
oldConfig: CreateContainerRequest,
values: Values
): CreateContainerRequest {
const validValues = values.filter(
(volume) => volume.containerPath && volume.name
);
const volumes = Object.fromEntries(
validValues.map((volume) => [volume.containerPath, {}])
);
const binds = validValues.map((volume) => {
let bind = `${volume.name}:${volume.containerPath}`;
if (volume.readOnly) {
bind += ':ro';
}
return bind;
});
return {
...oldConfig,
Volumes: volumes,
HostConfig: {
...oldConfig.HostConfig,
Binds: binds,
},
};
}

View File

@ -0,0 +1,16 @@
import { ContainerJSON } from '../../queries/container';
import { VolumeType, Values } from './types';
export function toViewModel(config: ContainerJSON): Values {
return Object.values(config.Mounts || {}).map((mount) => ({
type: (mount.Type || 'volume') as VolumeType,
name: mount.Name || mount.Source || '',
containerPath: mount.Destination || '',
readOnly: mount.RW === false,
}));
}
export function getDefaultViewModel(): Values {
return [];
}

View File

@ -0,0 +1,12 @@
export const volumeTypes = ['bind', 'volume'] as const;
export type VolumeType = (typeof volumeTypes)[number];
export interface Volume {
containerPath: string;
type: VolumeType;
name: string;
readOnly: boolean;
}
export type Values = Array<Volume>;

View File

@ -0,0 +1,16 @@
import { object, SchemaOf, array, string, mixed } from 'yup';
import { Values, VolumeType, volumeTypes } from './types';
export function validation(): SchemaOf<Values> {
return array(
object({
containerPath: string().required('Container path is required'),
type: mixed<VolumeType>()
.oneOf([...volumeTypes])
.default('volume'),
name: string().required('Volume name is required'),
readOnly: mixed<boolean>().default(false),
})
).default([]);
}

View File

@ -0,0 +1,8 @@
import { EnvironmentId } from '@/react/portainer/environments/types';
import { queryKeys as dockerQueryKeys } from '../../queries/utils/root';
export const queryKeys = {
base: (environmentId: EnvironmentId) =>
[...dockerQueryKeys.root(environmentId), 'volumes'] as const,
};

View File

@ -0,0 +1,45 @@
import { useQuery } from 'react-query';
import { Volume } from 'docker-types/generated/1.41';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { buildUrl as buildDockerUrl } from '@/react/docker/proxy/queries/build-url';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { queryKeys } from './query-keys';
export function useVolumes<T = Volume[]>(
environmentId: EnvironmentId,
{ select }: { select?: (data: Volume[]) => T } = {}
) {
return useQuery(
queryKeys.base(environmentId),
() => getVolumes(environmentId),
{ select }
);
}
interface VolumesResponse {
Volumes: Volume[];
}
export async function getVolumes(environmentId: EnvironmentId) {
try {
const { data } = await axios.get<VolumesResponse>(
buildUrl(environmentId, 'volumes')
);
return data.Volumes;
} catch (error) {
throw parseAxiosError(error as Error, 'Unable to retrieve volumes');
}
}
function buildUrl(environmentId: EnvironmentId, action: string, id?: string) {
let url = buildDockerUrl(environmentId, action);
if (id) {
url += `/${id}`;
}
return url;
}