mirror of https://github.com/portainer/portainer
refactor(containers): migrate volumes tab to react [EE-5209] (#10284)
parent
16ccf5871e
commit
e92f067e42
|
@ -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
|
||||
);
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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 />
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 });
|
||||
}
|
||||
}
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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,
|
||||
};
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
}
|
|
@ -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 [];
|
||||
}
|
|
@ -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>;
|
|
@ -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([]);
|
||||
}
|
|
@ -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,
|
||||
};
|
|
@ -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;
|
||||
}
|
Loading…
Reference in New Issue