feat(gpu): rework docker GPU for UI performance [EE-4918] (#8518)

pull/8596/head
Ali 2023-03-03 14:47:10 +13:00 committed by GitHub
parent 769c8372fb
commit fd916bc8a2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
52 changed files with 692 additions and 285 deletions

View File

@ -689,20 +689,16 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
log.Fatal().Err(err).Msg("failed initializing upgrade service") log.Fatal().Err(err).Msg("failed initializing upgrade service")
} }
// FIXME: In 2.16 we changed the way ingress controller permissions are // Our normal migrations run as part of the database initialization
// stored. Instead of being stored as annotation on an ingress rule, we keep // but some more complex migrations require access to a kubernetes or docker
// them in our database. However, in order to run the migration we need an // client. Therefore we run a separate migration process just before
// admin kube client to run lookup the old ingress rules and compare them // starting the server.
// with the current existing ingress classes. postInitMigrator := datastore.NewPostInitMigrator(
// kubernetesClientFactory,
// Unfortunately, our migrations run as part of the database initialization dockerClientFactory,
// and our kubeclients require an initialized database. So it is not dataStore,
// possible to do this migration as part of our normal flow. We DO have a )
// migration which toggles a boolean in kubernetes configuration that if err := postInitMigrator.PostInitMigrate(); err != nil {
// indicated that this "post init" migration should be run. If/when this is
// resolved we can remove this function.
err = kubernetesClientFactory.PostInitMigrateIngresses()
if err != nil {
log.Fatal().Err(err).Msg("failure during post init migrations") log.Fatal().Err(err).Msg("failure during post init migrations")
} }

View File

@ -0,0 +1,116 @@
package datastore
import (
"context"
"github.com/docker/docker/api/types"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/docker"
"github.com/portainer/portainer/api/kubernetes/cli"
"github.com/rs/zerolog/log"
)
type PostInitMigrator struct {
kubeFactory *cli.ClientFactory
dockerFactory *docker.ClientFactory
dataStore dataservices.DataStore
}
func NewPostInitMigrator(
kubeFactory *cli.ClientFactory,
dockerFactory *docker.ClientFactory,
dataStore dataservices.DataStore,
) *PostInitMigrator {
return &PostInitMigrator{
kubeFactory: kubeFactory,
dockerFactory: dockerFactory,
dataStore: dataStore,
}
}
func (migrator *PostInitMigrator) PostInitMigrate() error {
if err := migrator.PostInitMigrateIngresses(); err != nil {
return err
}
migrator.PostInitMigrateGPUs()
return nil
}
func (migrator *PostInitMigrator) PostInitMigrateIngresses() error {
endpoints, err := migrator.dataStore.Endpoint().Endpoints()
if err != nil {
return err
}
for i := range endpoints {
// Early exit if we do not need to migrate!
if endpoints[i].PostInitMigrations.MigrateIngresses == false {
return nil
}
err := migrator.kubeFactory.MigrateEndpointIngresses(&endpoints[i])
if err != nil {
log.Debug().Err(err).Msg("failure migrating endpoint ingresses")
}
}
return nil
}
// PostInitMigrateGPUs will check all docker endpoints for containers with GPUs and set EnableGPUManagement to true if any are found
// If there's an error getting the containers, we'll log it and move on
func (migrator *PostInitMigrator) PostInitMigrateGPUs() {
environments, err := migrator.dataStore.Endpoint().Endpoints()
if err != nil {
log.Err(err).Msg("failure getting endpoints")
return
}
for i := range environments {
if environments[i].Type == portainer.DockerEnvironment {
// // Early exit if we do not need to migrate!
if environments[i].PostInitMigrations.MigrateGPUs == false {
return
}
// set the MigrateGPUs flag to false so we don't run this again
environments[i].PostInitMigrations.MigrateGPUs = false
migrator.dataStore.Endpoint().UpdateEndpoint(environments[i].ID, &environments[i])
// create a docker client
dockerClient, err := migrator.dockerFactory.CreateClient(&environments[i], "", nil)
if err != nil {
log.Err(err).Msg("failure creating docker client for environment: " + environments[i].Name)
return
}
defer dockerClient.Close()
// get all containers
containers, err := dockerClient.ContainerList(context.Background(), types.ContainerListOptions{All: true})
if err != nil {
log.Err(err).Msg("failed to list containers")
return
}
// check for a gpu on each container. If even one GPU is found, set EnableGPUManagement to true for the whole endpoint
containersLoop:
for _, container := range containers {
// https://www.sobyte.net/post/2022-10/go-docker/ has nice documentation on the docker client with GPUs
containerDetails, err := dockerClient.ContainerInspect(context.Background(), container.ID)
if err != nil {
log.Err(err).Msg("failed to inspect container")
return
}
deviceRequests := containerDetails.HostConfig.Resources.DeviceRequests
for _, deviceRequest := range deviceRequests {
if deviceRequest.Driver == "nvidia" {
environments[i].EnableGPUManagement = true
migrator.dataStore.Endpoint().UpdateEndpoint(environments[i].ID, &environments[i])
break containersLoop
}
}
}
}
}
}

View File

@ -3,11 +3,16 @@ package migrator
import ( import (
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
portainer "github.com/portainer/portainer/api"
portainerDsErrors "github.com/portainer/portainer/api/dataservices/errors" portainerDsErrors "github.com/portainer/portainer/api/dataservices/errors"
) )
func (m *Migrator) migrateDBVersionToDB90() error { func (m *Migrator) migrateDBVersionToDB90() error {
if err := m.updateUserThemForDB90(); err != nil { if err := m.updateUserThemeForDB90(); err != nil {
return err
}
if err := m.updateEnableGpuManagementFeatures(); err != nil {
return err return err
} }
@ -39,7 +44,7 @@ func (m *Migrator) updateEdgeStackStatusForDB90() error {
return nil return nil
} }
func (m *Migrator) updateUserThemForDB90() error { func (m *Migrator) updateUserThemeForDB90() error {
log.Info().Msg("updating existing user theme settings") log.Info().Msg("updating existing user theme settings")
users, err := m.userService.Users() users, err := m.userService.Users()
@ -60,3 +65,28 @@ func (m *Migrator) updateUserThemForDB90() error {
return nil return nil
} }
func (m *Migrator) updateEnableGpuManagementFeatures() error {
// get all environments
environments, err := m.endpointService.Endpoints()
if err != nil {
return err
}
for _, environment := range environments {
if environment.Type == portainer.DockerEnvironment {
// set the PostInitMigrations.MigrateGPUs to true on this environment to run the migration only on the 2.18 upgrade
environment.PostInitMigrations.MigrateGPUs = true
// if there's one or more gpu, set the EnableGpuManagement setting to true
gpuList := environment.Gpus
if len(gpuList) > 0 {
environment.EnableGPUManagement = true
}
// update the environment
if err := m.endpointService.UpdateEndpoint(environment.ID, &environment); err != nil {
return err
}
}
}
return nil
}

View File

@ -46,6 +46,7 @@
}, },
"EdgeCheckinInterval": 0, "EdgeCheckinInterval": 0,
"EdgeKey": "", "EdgeKey": "",
"EnableGPUManagement": false,
"Gpus": [], "Gpus": [],
"GroupId": 1, "GroupId": 1,
"Id": 1, "Id": 1,
@ -71,6 +72,7 @@
"LastCheckInDate": 0, "LastCheckInDate": 0,
"Name": "local", "Name": "local",
"PostInitMigrations": { "PostInitMigrations": {
"MigrateGPUs": true,
"MigrateIngresses": true "MigrateIngresses": true
}, },
"PublicURL": "", "PublicURL": "",

View File

@ -28,6 +28,10 @@ type endpointSettingsUpdatePayload struct {
AllowSysctlSettingForRegularUsers *bool `json:"allowSysctlSettingForRegularUsers" example:"true"` AllowSysctlSettingForRegularUsers *bool `json:"allowSysctlSettingForRegularUsers" example:"true"`
// Whether host management features are enabled // Whether host management features are enabled
EnableHostManagementFeatures *bool `json:"enableHostManagementFeatures" example:"true"` EnableHostManagementFeatures *bool `json:"enableHostManagementFeatures" example:"true"`
EnableGPUManagement *bool `json:"enableGPUManagement" example:"false"`
Gpus []portainer.Pair `json:"gpus"`
} }
func (payload *endpointSettingsUpdatePayload) Validate(r *http.Request) error { func (payload *endpointSettingsUpdatePayload) Validate(r *http.Request) error {
@ -107,6 +111,14 @@ func (handler *Handler) endpointSettingsUpdate(w http.ResponseWriter, r *http.Re
securitySettings.EnableHostManagementFeatures = *payload.EnableHostManagementFeatures securitySettings.EnableHostManagementFeatures = *payload.EnableHostManagementFeatures
} }
if payload.EnableGPUManagement != nil {
endpoint.EnableGPUManagement = *payload.EnableGPUManagement
}
if payload.Gpus != nil {
endpoint.Gpus = payload.Gpus
}
endpoint.SecuritySettings = securitySettings endpoint.SecuritySettings = securitySettings
err = handler.DataStore.Endpoint().UpdateEndpoint(portainer.EndpointID(endpointID), endpoint) err = handler.DataStore.Endpoint().UpdateEndpoint(portainer.EndpointID(endpointID), endpoint)

View File

@ -12,7 +12,6 @@ import (
"github.com/pkg/errors" "github.com/pkg/errors"
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/dataservices"
"github.com/rs/zerolog/log"
"k8s.io/client-go/kubernetes" "k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest" "k8s.io/client-go/rest"
"k8s.io/client-go/tools/clientcmd" "k8s.io/client-go/tools/clientcmd"
@ -221,27 +220,7 @@ func buildLocalClient() (*kubernetes.Clientset, error) {
return kubernetes.NewForConfig(config) return kubernetes.NewForConfig(config)
} }
func (factory *ClientFactory) PostInitMigrateIngresses() error { func (factory *ClientFactory) MigrateEndpointIngresses(e *portainer.Endpoint) error {
endpoints, err := factory.dataStore.Endpoint().Endpoints()
if err != nil {
return err
}
for i := range endpoints {
// Early exit if we do not need to migrate!
if endpoints[i].PostInitMigrations.MigrateIngresses == false {
return nil
}
err := factory.migrateEndpointIngresses(&endpoints[i])
if err != nil {
log.Debug().Err(err).Msg("failure migrating endpoint ingresses")
}
}
return nil
}
func (factory *ClientFactory) migrateEndpointIngresses(e *portainer.Endpoint) error {
// classes is a list of controllers which have been manually added to the // classes is a list of controllers which have been manually added to the
// cluster setup view. These need to all be allowed globally, but then // cluster setup view. These need to all be allowed globally, but then
// blocked in specific namespaces which they were not previously allowed in. // blocked in specific namespaces which they were not previously allowed in.

View File

@ -402,6 +402,8 @@ type (
Version string `example:"1.0.0"` Version string `example:"1.0.0"`
} }
EnableGPUManagement bool `json:"EnableGPUManagement"`
// Deprecated fields // Deprecated fields
// Deprecated in DBVersion == 4 // Deprecated in DBVersion == 4
TLS bool `json:"TLS,omitempty"` TLS bool `json:"TLS,omitempty"`
@ -502,6 +504,7 @@ type (
// EndpointPostInitMigrations // EndpointPostInitMigrations
EndpointPostInitMigrations struct { EndpointPostInitMigrations struct {
MigrateIngresses bool `json:"MigrateIngresses"` MigrateIngresses bool `json:"MigrateIngresses"`
MigrateGPUs bool `json:"MigrateGPUs"`
} }
// Extension represents a deprecated Portainer extension // Extension represents a deprecated Portainer extension

View File

@ -358,11 +358,6 @@ input:-webkit-autofill {
} }
/* Overide Vendor CSS */ /* Overide Vendor CSS */
.btn-link:hover {
color: var(--text-link-hover-color) !important;
}
.multiSelect.inlineBlock button { .multiSelect.inlineBlock button {
margin: 0; margin: 0;
} }

View File

@ -36,7 +36,7 @@
title="Search image on Docker Hub" title="Search image on Docker Hub"
target="_blank" target="_blank"
> >
<pr-icon icon="'svg-docker'" size="'lg'"></pr-icon> Search <pr-icon icon="'svg-docker'" size="'md'"></pr-icon> Search
</a> </a>
</span> </span>
</div> </div>

View File

@ -37,5 +37,12 @@ export const componentsModule = angular
) )
.component( .component(
'gpu', 'gpu',
r2a(Gpu, ['values', 'onChange', 'gpus', 'usedGpus', 'usedAllGpus']) r2a(Gpu, [
'values',
'onChange',
'gpus',
'usedGpus',
'usedAllGpus',
'enableGpuManagement',
])
).name; ).name;

View File

@ -21,6 +21,7 @@ angular.module('portainer.docker').controller('CreateContainerController', [
'$timeout', '$timeout',
'$transition$', '$transition$',
'$filter', '$filter',
'$analytics',
'Container', 'Container',
'ContainerHelper', 'ContainerHelper',
'Image', 'Image',
@ -35,6 +36,7 @@ angular.module('portainer.docker').controller('CreateContainerController', [
'FormValidator', 'FormValidator',
'RegistryService', 'RegistryService',
'SystemService', 'SystemService',
'SettingsService',
'PluginService', 'PluginService',
'HttpRequestHelper', 'HttpRequestHelper',
'endpoint', 'endpoint',
@ -46,6 +48,7 @@ angular.module('portainer.docker').controller('CreateContainerController', [
$timeout, $timeout,
$transition$, $transition$,
$filter, $filter,
$analytics,
Container, Container,
ContainerHelper, ContainerHelper,
Image, Image,
@ -60,6 +63,7 @@ angular.module('portainer.docker').controller('CreateContainerController', [
FormValidator, FormValidator,
RegistryService, RegistryService,
SystemService, SystemService,
SettingsService,
PluginService, PluginService,
HttpRequestHelper, HttpRequestHelper,
endpoint endpoint
@ -1042,6 +1046,18 @@ angular.module('portainer.docker').controller('CreateContainerController', [
}); });
} }
async function sendAnalytics() {
const publicSettings = await SettingsService.publicSettings();
const analyticsAllowed = publicSettings.EnableTelemetry;
const image = `${$scope.formValues.RegistryModel.Registry.URL}/${$scope.formValues.RegistryModel.Image}`;
if (analyticsAllowed && $scope.formValues.GPU.enabled) {
$analytics.eventTrack('gpuContainerCreated', {
category: 'docker',
metadata: { gpu: $scope.formValues.GPU, containerImage: image },
});
}
}
function applyResourceControl(newContainer) { function applyResourceControl(newContainer) {
const userId = Authentication.getUserDetails().ID; const userId = Authentication.getUserDetails().ID;
const resourceControl = newContainer.Portainer.ResourceControl; const resourceControl = newContainer.Portainer.ResourceControl;
@ -1101,7 +1117,8 @@ angular.module('portainer.docker').controller('CreateContainerController', [
return validateForm(accessControlData, $scope.isAdmin); return validateForm(accessControlData, $scope.isAdmin);
} }
function onSuccess() { async function onSuccess() {
await sendAnalytics();
Notifications.success('Success', 'Container successfully created'); Notifications.success('Success', 'Container successfully created');
$state.go('docker.containers', {}, { reload: true }); $state.go('docker.containers', {}, { reload: true });
} }

View File

@ -17,8 +17,8 @@
<form class="form-horizontal" autocomplete="off"> <form class="form-horizontal" autocomplete="off">
<!-- name-input --> <!-- name-input -->
<div class="form-group"> <div class="form-group">
<label for="container_name" class="col-sm-2 control-label text-left">Name</label> <label for="container_name" class="col-sm-3 col-lg-2 control-label text-left">Name</label>
<div class="col-sm-10"> <div class="col-sm-8">
<input type="text" class="form-control" ng-model="config.name" id="container_name" placeholder="e.g. myContainer" /> <input type="text" class="form-control" ng-model="config.name" id="container_name" placeholder="e.g. myContainer" />
</div> </div>
</div> </div>
@ -37,8 +37,6 @@
model="formValues.RegistryModel" model="formValues.RegistryModel"
ng-if="formValues.RegistryModel.Registry" ng-if="formValues.RegistryModel.Registry"
auto-complete="true" auto-complete="true"
label-class="col-sm-1"
input-class="col-sm-11"
endpoint="endpoint" endpoint="endpoint"
is-admin="isAdmin" is-admin="isAdmin"
check-rate-limits="formValues.alwaysPull" check-rate-limits="formValues.alwaysPull"
@ -169,7 +167,7 @@
<div class="col-sm-12"> <div class="col-sm-12">
<button <button
type="button" type="button"
class="btn btn-primary btn-sm" class="btn btn-primary btn-sm !ml-0"
ng-disabled="state.actionInProgress || !formValues.RegistryModel.Image || (!formValues.RegistryModel.Registry && fromContainer) ng-disabled="state.actionInProgress || !formValues.RegistryModel.Image || (!formValues.RegistryModel.Registry && fromContainer)
|| (fromContainer.IsPortainer && fromContainer.Name === '/' + config.name)" || (fromContainer.IsPortainer && fromContainer.Name === '/' + config.name)"
ng-click="create()" ng-click="create()"
@ -701,17 +699,20 @@
</div> </div>
<!-- !shm-size-input --> <!-- !shm-size-input -->
<!-- #region GPU --> <!-- #region GPU -->
<div class="col-sm-12 form-section-title"> GPU </div> <div ng-if="applicationState.endpoint.mode.provider === 'DOCKER_STANDALONE'">
<div class="col-sm-12 form-section-title"> GPU </div>
<gpu <gpu
ng-if="applicationState.endpoint.apiVersion >= 1.4" ng-if="applicationState.endpoint.apiVersion >= 1.4"
values="formValues.GPU" values="formValues.GPU"
on-change="(onGpuChange)" on-change="(onGpuChange)"
gpus="endpoint.Gpus" gpus="endpoint.Gpus"
used-gpus="gpuUseList" used-gpus="gpuUseList"
used-all-gpus="gpuUseAll" used-all-gpus="gpuUseAll"
> enable-gpu-management="endpoint.EnableGPUManagement"
</gpu> >
</gpu>
</div>
<!-- #endregion GPU --> <!-- #endregion GPU -->
<div ng-class="{ 'edit-resources': state.mode == 'duplicate' }"> <div ng-class="{ 'edit-resources': state.mode == 'duplicate' }">

View File

@ -106,7 +106,12 @@
</a> </a>
<div> <div>
<dashboard-item icon="'cpu'" type="'GPU'" value="endpoint.Gpus.length"></dashboard-item> <dashboard-item
ng-if="endpoint.EnableGPUManagement && applicationState.endpoint.mode.provider === 'DOCKER_STANDALONE'"
icon="'cpu'"
type="'GPU'"
value="endpoint.Gpus.length"
></dashboard-item>
</div> </div>
</div> </div>
</div> </div>

View File

@ -2,10 +2,13 @@ import { FeatureId } from '@/react/portainer/feature-flags/enums';
export default class DockerFeaturesConfigurationController { export default class DockerFeaturesConfigurationController {
/* @ngInject */ /* @ngInject */
constructor($async, $scope, EndpointService, Notifications, StateManager) { constructor($async, $scope, $state, $analytics, EndpointService, SettingsService, Notifications, StateManager) {
this.$async = $async; this.$async = $async;
this.$scope = $scope; this.$scope = $scope;
this.$state = $state;
this.$analytics = $analytics;
this.EndpointService = EndpointService; this.EndpointService = EndpointService;
this.SettingsService = SettingsService;
this.Notifications = Notifications; this.Notifications = Notifications;
this.StateManager = StateManager; this.StateManager = StateManager;
@ -35,6 +38,8 @@ export default class DockerFeaturesConfigurationController {
this.save = this.save.bind(this); this.save = this.save.bind(this);
this.onChangeField = this.onChangeField.bind(this); this.onChangeField = this.onChangeField.bind(this);
this.onToggleAutoUpdate = this.onToggleAutoUpdate.bind(this); this.onToggleAutoUpdate = this.onToggleAutoUpdate.bind(this);
this.onToggleGPUManagement = this.onToggleGPUManagement.bind(this);
this.onGpusChange = this.onGpusChange.bind(this);
this.onChangeEnableHostManagementFeatures = this.onChangeField('enableHostManagementFeatures'); this.onChangeEnableHostManagementFeatures = this.onChangeField('enableHostManagementFeatures');
this.onChangeAllowVolumeBrowserForRegularUsers = this.onChangeField('allowVolumeBrowserForRegularUsers'); this.onChangeAllowVolumeBrowserForRegularUsers = this.onChangeField('allowVolumeBrowserForRegularUsers');
this.onChangeDisableBindMountsForRegularUsers = this.onChangeField('disableBindMountsForRegularUsers'); this.onChangeDisableBindMountsForRegularUsers = this.onChangeField('disableBindMountsForRegularUsers');
@ -52,6 +57,12 @@ export default class DockerFeaturesConfigurationController {
}); });
} }
onToggleGPUManagement(checked) {
this.$scope.$evalAsync(() => {
this.state.enableGPUManagement = checked;
});
}
onChange(values) { onChange(values) {
return this.$scope.$evalAsync(() => { return this.$scope.$evalAsync(() => {
this.formValues = { this.formValues = {
@ -69,6 +80,12 @@ export default class DockerFeaturesConfigurationController {
}; };
} }
onGpusChange(value) {
return this.$async(async () => {
this.endpoint.Gpus = value;
});
}
isContainerEditDisabled() { isContainerEditDisabled() {
const { const {
disableBindMountsForRegularUsers, disableBindMountsForRegularUsers,
@ -92,7 +109,11 @@ export default class DockerFeaturesConfigurationController {
return this.$async(async () => { return this.$async(async () => {
try { try {
this.state.actionInProgress = true; this.state.actionInProgress = true;
const securitySettings = {
const validGpus = this.endpoint.Gpus.filter((gpu) => gpu.name && gpu.value);
const gpus = this.state.enableGPUManagement ? validGpus : [];
const settings = {
enableHostManagementFeatures: this.formValues.enableHostManagementFeatures, enableHostManagementFeatures: this.formValues.enableHostManagementFeatures,
allowBindMountsForRegularUsers: !this.formValues.disableBindMountsForRegularUsers, allowBindMountsForRegularUsers: !this.formValues.disableBindMountsForRegularUsers,
allowPrivilegedModeForRegularUsers: !this.formValues.disablePrivilegedModeForRegularUsers, allowPrivilegedModeForRegularUsers: !this.formValues.disablePrivilegedModeForRegularUsers,
@ -102,33 +123,53 @@ export default class DockerFeaturesConfigurationController {
allowStackManagementForRegularUsers: !this.formValues.disableStackManagementForRegularUsers, allowStackManagementForRegularUsers: !this.formValues.disableStackManagementForRegularUsers,
allowContainerCapabilitiesForRegularUsers: !this.formValues.disableContainerCapabilitiesForRegularUsers, allowContainerCapabilitiesForRegularUsers: !this.formValues.disableContainerCapabilitiesForRegularUsers,
allowSysctlSettingForRegularUsers: !this.formValues.disableSysctlSettingForRegularUsers, allowSysctlSettingForRegularUsers: !this.formValues.disableSysctlSettingForRegularUsers,
enableGPUManagement: this.state.enableGPUManagement,
gpus,
}; };
await this.EndpointService.updateSecuritySettings(this.endpoint.Id, securitySettings); const publicSettings = await this.SettingsService.publicSettings();
const analyticsAllowed = publicSettings.EnableTelemetry;
if (analyticsAllowed) {
// send analytics if GPU management is changed (with the new state)
if (this.initialEnableGPUManagement !== this.state.enableGPUManagement) {
this.$analytics.eventTrack('enable-gpu-management-updated', { category: 'portainer', metadata: { enableGPUManagementState: this.state.enableGPUManagement } });
}
// send analytics if the number of GPUs is changed (with a list of the names)
if (gpus.length > this.initialGPUs.length) {
const numberOfGPUSAdded = this.endpoint.Gpus.length - this.initialGPUs.length;
this.$analytics.eventTrack('gpus-added', { category: 'portainer', metadata: { gpus: gpus.map((gpu) => gpu.name), numberOfGPUSAdded } });
}
if (gpus.length < this.initialGPUs.length) {
const numberOfGPUSRemoved = this.initialGPUs.length - this.endpoint.Gpus.length;
this.$analytics.eventTrack('gpus-removed', { category: 'portainer', metadata: { gpus: gpus.map((gpu) => gpu.name), numberOfGPUSRemoved } });
}
this.initialGPUs = gpus;
this.initialEnableGPUManagement = this.state.enableGPUManagement;
}
this.endpoint.SecuritySettings = securitySettings; await this.EndpointService.updateSecuritySettings(this.endpoint.Id, settings);
this.endpoint.SecuritySettings = settings;
this.Notifications.success('Success', 'Saved settings successfully'); this.Notifications.success('Success', 'Saved settings successfully');
} catch (e) { } catch (e) {
this.Notifications.error('Failure', e, 'Failed saving settings'); this.Notifications.error('Failure', e, 'Failed saving settings');
} }
this.state.actionInProgress = false; this.state.actionInProgress = false;
this.$state.reload();
}); });
} }
checkAgent() {
const applicationState = this.StateManager.getState();
return applicationState.endpoint.mode.agentProxy;
}
$onInit() { $onInit() {
const securitySettings = this.endpoint.SecuritySettings; const securitySettings = this.endpoint.SecuritySettings;
const isAgent = this.checkAgent(); const applicationState = this.StateManager.getState();
this.isAgent = isAgent; this.isAgent = applicationState.endpoint.mode.agentProxy;
this.isDockerStandaloneEnv = applicationState.endpoint.mode.provider === 'DOCKER_STANDALONE';
this.formValues = { this.formValues = {
enableHostManagementFeatures: isAgent && securitySettings.enableHostManagementFeatures, enableHostManagementFeatures: this.isAgent && securitySettings.enableHostManagementFeatures,
allowVolumeBrowserForRegularUsers: isAgent && securitySettings.allowVolumeBrowserForRegularUsers, allowVolumeBrowserForRegularUsers: this.isAgent && securitySettings.allowVolumeBrowserForRegularUsers,
disableBindMountsForRegularUsers: !securitySettings.allowBindMountsForRegularUsers, disableBindMountsForRegularUsers: !securitySettings.allowBindMountsForRegularUsers,
disablePrivilegedModeForRegularUsers: !securitySettings.allowPrivilegedModeForRegularUsers, disablePrivilegedModeForRegularUsers: !securitySettings.allowPrivilegedModeForRegularUsers,
disableHostNamespaceForRegularUsers: !securitySettings.allowHostNamespaceForRegularUsers, disableHostNamespaceForRegularUsers: !securitySettings.allowHostNamespaceForRegularUsers,
@ -137,5 +178,9 @@ export default class DockerFeaturesConfigurationController {
disableContainerCapabilitiesForRegularUsers: !securitySettings.allowContainerCapabilitiesForRegularUsers, disableContainerCapabilitiesForRegularUsers: !securitySettings.allowContainerCapabilitiesForRegularUsers,
disableSysctlSettingForRegularUsers: !securitySettings.allowSysctlSettingForRegularUsers, disableSysctlSettingForRegularUsers: !securitySettings.allowSysctlSettingForRegularUsers,
}; };
this.state.enableGPUManagement = this.isDockerStandaloneEnv && (this.endpoint.EnableGPUManagement || this.endpoint.Gpus.length > 0);
this.initialGPUs = this.endpoint.Gpus;
this.initialEnableGPUManagement = this.endpoint.EnableGPUManagement;
} }
} }

View File

@ -150,9 +150,42 @@
<!-- other --> <!-- other -->
<div class="col-sm-12 form-section-title"> Other </div> <div class="col-sm-12 form-section-title"> Other </div>
<div class="form-group"> <div class="form-group">
<div class="col-sm-12 pb-3">
<insights-box
header="'GPU settings update'"
set-html-content="true"
insight-close-id="'gpu-settings-update-closed'"
content="'
<p>
From 2.18 on, the set-up of available GPUs for a Docker Standalone environment has been shifted from Add environment and Environment details to Host -> Setup, so as to align with other settings.
</p>
<p>
A toggle has been introduced for enabling/disabling management of GPU settings in the Portainer UI - to alleviate the performance impact of showing those settings.
</p>
<p>
The UI has been updated to clarify that GPU settings support is only for Docker Standalone (and not Docker Swarm, which was never supported in the UI).
</p>'"
></insights-box>
</div>
<div class="col-sm-12"> <div class="col-sm-12">
<por-switch-field <por-switch-field
label="'Show a notification to indicate out-of-date images for Docker environments'" label="'Show GPU in the UI'"
tooltip="'This allows managing of GPUs for container/stack hardware acceleration via the Portainer UI.'"
checked="$ctrl.state.enableGPUManagement"
name="'enableGPUManagement'"
on-change="($ctrl.onToggleGPUManagement)"
label-class="'col-sm-7 col-lg-4'"
disabled="!$ctrl.isDockerStandaloneEnv"
></por-switch-field>
</div>
<div class="col-sm-12">
<div class="pl-4">
<gpus-list ng-if="$ctrl.state.enableGPUManagement && $ctrl.endpoint" value="$ctrl.endpoint.Gpus" on-change="($ctrl.onGpusChange)"></gpus-list>
</div>
</div>
<div class="col-sm-12">
<por-switch-field
label="'Show an image(s) up to date indicator for Stacks, Services and Containers'"
checked="false" checked="false"
name="'outOfDateImageToggle'" name="'outOfDateImageToggle'"
label-class="'col-sm-7 col-lg-4'" label-class="'col-sm-7 col-lg-4'"
@ -166,7 +199,13 @@
<div class="col-sm-12 form-section-title"> Actions </div> <div class="col-sm-12 form-section-title"> Actions </div>
<div class="form-group"> <div class="form-group">
<div class="col-sm-12"> <div class="col-sm-12">
<button type="button" class="btn btn-primary btn-sm" ng-click="$ctrl.save()" ng-disabled="$ctrl.state.actionInProgress" button-spinner="$ctrl.state.actionInProgress"> <button
type="button"
class="btn btn-primary btn-sm !ml-0"
ng-click="$ctrl.save()"
ng-disabled="$ctrl.state.actionInProgress"
button-spinner="$ctrl.state.actionInProgress"
>
<span ng-hide="$ctrl.state.actionInProgress">Save configuration</span> <span ng-hide="$ctrl.state.actionInProgress">Save configuration</span>
<span ng-show="$ctrl.state.actionInProgress">Saving...</span> <span ng-show="$ctrl.state.actionInProgress">Saving...</span>
</button> </button>

View File

@ -4,7 +4,7 @@ import { r2a } from '@/react-tools/react2angular';
import { withControlledInput } from '@/react-tools/withControlledInput'; import { withControlledInput } from '@/react-tools/withControlledInput';
import { EdgeKeyDisplay } from '@/react/portainer/environments/ItemView/EdgeKeyDisplay'; import { EdgeKeyDisplay } from '@/react/portainer/environments/ItemView/EdgeKeyDisplay';
import { KVMControl } from '@/react/portainer/environments/KvmView/KVMControl'; import { KVMControl } from '@/react/portainer/environments/KvmView/KVMControl';
import { GpusList } from '@/react/portainer/environments/wizard/EnvironmentsCreationView/shared/Hardware/GpusList'; import { GpusList } from '@/react/docker/host/SetupView/GpusList';
export const environmentsModule = angular export const environmentsModule = angular
.module('portainer.app.react.components.environments', []) .module('portainer.app.react.components.environments', [])

View File

@ -25,6 +25,7 @@ import { Slider } from '@@/form-components/Slider';
import { TagButton } from '@@/TagButton'; import { TagButton } from '@@/TagButton';
import { BETeaserButton } from '@@/BETeaserButton'; import { BETeaserButton } from '@@/BETeaserButton';
import { CodeEditor } from '@@/CodeEditor'; import { CodeEditor } from '@@/CodeEditor';
import { InsightsBox } from '@@/InsightsBox';
import { fileUploadField } from './file-upload-field'; import { fileUploadField } from './file-upload-field';
import { switchField } from './switch-field'; import { switchField } from './switch-field';
@ -32,6 +33,7 @@ import { customTemplatesModule } from './custom-templates';
import { gitFormModule } from './git-form'; import { gitFormModule } from './git-form';
import { settingsModule } from './settings'; import { settingsModule } from './settings';
import { accessControlModule } from './access-control'; import { accessControlModule } from './access-control';
import { environmentsModule } from './envronments';
import { envListModule } from './enviroments-list-view-components'; import { envListModule } from './enviroments-list-view-components';
export const componentsModule = angular export const componentsModule = angular
@ -40,6 +42,7 @@ export const componentsModule = angular
gitFormModule, gitFormModule,
settingsModule, settingsModule,
accessControlModule, accessControlModule,
environmentsModule,
envListModule, envListModule,
]) ])
.component( .component(
@ -74,6 +77,10 @@ export const componentsModule = angular
.component('badge', r2a(Badge, ['type', 'className'])) .component('badge', r2a(Badge, ['type', 'className']))
.component('fileUploadField', fileUploadField) .component('fileUploadField', fileUploadField)
.component('porSwitchField', switchField) .component('porSwitchField', switchField)
.component(
'insightsBox',
r2a(InsightsBox, ['header', 'content', 'setHtmlContent', 'insightCloseId'])
)
.component( .component(
'passwordCheckHint', 'passwordCheckHint',
r2a(withReactQuery(PasswordCheckHint), [ r2a(withReactQuery(PasswordCheckHint), [

View File

@ -36,7 +36,6 @@ angular.module('portainer.app').factory('Authentication', [
await setUser(jwt); await setUser(jwt);
return true; return true;
} catch (error) { } catch (error) {
console.log('Unable to initialize authentication service', error);
return tryAutoLoginExtension(); return tryAutoLoginExtension();
} }
} }

View File

@ -210,14 +210,30 @@
</div> </div>
<!-- !open-amt info --> <!-- !open-amt info -->
<!-- gpus info --> <!-- gpus info -->
<div class="col-sm-12 form-section-title">Hardware acceleration</div> <div class="mb-4">
<gpus-list ng-if="endpoint && endpoint.Gpus" value="endpoint.Gpus" on-change="(onGpusChange)"></gpus-list> <insights-box
ng-if="isDockerStandaloneEnv"
header="'GPU settings update'"
set-html-content="true"
insight-close-id="'gpu-settings-update-closed'"
content="'
<p>
From 2.18 on, the set-up of available GPUs for a Docker Standalone environment has been shifted from Add environment and Environment details to Host -> Setup, so as to align with other settings.
</p>
<p>
A toggle has been introduced for enabling/disabling management of GPU settings in the Portainer UI - to alleviate the performance impact of showing those settings.
</p>
<p>
The UI has been updated to clarify that GPU settings support is only for Docker Standalone (and not Docker Swarm, which was never supported in the UI).
</p>'"
></insights-box>
</div>
<!-- gpus info --> <!-- gpus info -->
<div class="form-group"> <div class="form-group">
<div class="col-sm-12"> <div class="col-sm-12">
<button <button
type="button" type="button"
class="btn btn-primary btn-sm" class="btn btn-primary btn-sm !ml-0"
ng-disabled="state.actionInProgress || !endpoint.Name || !endpoint.URL || (endpoint.TLS && ((endpoint.TLSVerify && !formValues.TLSCACert) || (endpoint.TLSClientCert && (!formValues.TLSCert || !formValues.TLSKey))))" ng-disabled="state.actionInProgress || !endpoint.Name || !endpoint.URL || (endpoint.TLS && ((endpoint.TLSVerify && !formValues.TLSCACert) || (endpoint.TLSClientCert && (!formValues.TLSCert || !formValues.TLSKey))))"
ng-click="updateEndpoint()" ng-click="updateEndpoint()"
button-spinner="state.actionInProgress" button-spinner="state.actionInProgress"

View File

@ -9,11 +9,10 @@ import { confirmDestructive } from '@@/modals/confirm';
import { isEdgeEnvironment } from '@/react/portainer/environments/utils'; import { isEdgeEnvironment } from '@/react/portainer/environments/utils';
import { commandsTabs } from '@/react/edge/components/EdgeScriptForm/scripts'; import { commandsTabs } from '@/react/edge/components/EdgeScriptForm/scripts';
import { GpusListAngular } from '@/react/portainer/environments/wizard/EnvironmentsCreationView/shared/Hardware/GpusList';
import { confirmDisassociate } from '@/react/portainer/environments/ItemView/ConfirmDisassociateModel'; import { confirmDisassociate } from '@/react/portainer/environments/ItemView/ConfirmDisassociateModel';
import { buildConfirmButton } from '@@/modals/utils'; import { buildConfirmButton } from '@@/modals/utils';
angular.module('portainer.app').component('gpusList', GpusListAngular).controller('EndpointController', EndpointController); angular.module('portainer.app').controller('EndpointController', EndpointController);
/* @ngInject */ /* @ngInject */
function EndpointController( function EndpointController(
@ -25,6 +24,7 @@ function EndpointController(
clipboard, clipboard,
EndpointService, EndpointService,
GroupService, GroupService,
StateManager,
Notifications, Notifications,
Authentication, Authentication,
@ -143,8 +143,6 @@ function EndpointController(
}); });
} }
$scope.onGpusChange = onGpusChange;
Array.prototype.indexOf = function (val) { Array.prototype.indexOf = function (val) {
for (var i = 0; i < this.length; i++) { for (var i = 0; i < this.length; i++) {
if (this[i] == val) return i; if (this[i] == val) return i;
@ -158,21 +156,6 @@ function EndpointController(
} }
}; };
function onGpusChange(value) {
return $async(async () => {
$scope.endpoint.Gpus = value;
});
}
function verifyGpus() {
var i = ($scope.endpoint.Gpus || []).length;
while (i--) {
if ($scope.endpoint.Gpus[i].name === '' || $scope.endpoint.Gpus[i].name === null) {
$scope.endpoint.Gpus.splice(i, 1);
}
}
}
$scope.updateEndpoint = async function () { $scope.updateEndpoint = async function () {
var endpoint = $scope.endpoint; var endpoint = $scope.endpoint;
var securityData = $scope.formValues.SecurityFormData; var securityData = $scope.formValues.SecurityFormData;
@ -193,7 +176,6 @@ function EndpointController(
} }
} }
verifyGpus();
var payload = { var payload = {
Name: endpoint.Name, Name: endpoint.Name,
PublicURL: endpoint.PublicURL, PublicURL: endpoint.PublicURL,
@ -290,6 +272,9 @@ function EndpointController(
try { try {
const [endpoint, groups, settings] = await Promise.all([EndpointService.endpoint($transition$.params().id), GroupService.groups(), SettingsService.settings()]); const [endpoint, groups, settings] = await Promise.all([EndpointService.endpoint($transition$.params().id), GroupService.groups(), SettingsService.settings()]);
const applicationState = StateManager.getState();
$scope.isDockerStandaloneEnv = applicationState.endpoint.mode.provider === 'DOCKER_STANDALONE';
if (endpoint.URL.indexOf('unix://') === 0 || endpoint.URL.indexOf('npipe://') === 0) { if (endpoint.URL.indexOf('unix://') === 0 || endpoint.URL.indexOf('npipe://') === 0) {
$scope.endpointType = 'local'; $scope.endpointType = 'local';
} else { } else {

View File

@ -83,6 +83,7 @@ export function createMockEnvironment(): Environment {
}, },
Nomad: { Snapshots: [] }, Nomad: { Snapshots: [] },
EdgeKey: '', EdgeKey: '',
EnableGPUManagement: false,
Id: 3, Id: 3,
UserTrusted: false, UserTrusted: false,
Edge: { Edge: {

View File

@ -0,0 +1,18 @@
import { Meta, Story } from '@storybook/react';
import { InsightsBox, Props } from './InsightsBox';
export default {
component: InsightsBox,
header: 'Components/InsightsBox',
} as Meta;
function Template({ header, content }: Props) {
return <InsightsBox header={header} content={content} />;
}
export const Primary: Story<Props> = Template.bind({});
Primary.args = {
header: 'Insights box header',
content: 'This is the content of the insights box',
};

View File

@ -0,0 +1,16 @@
import { render } from '@testing-library/react';
import { InsightsBox } from './InsightsBox';
test('should display a InsightsBox with a header and content', async () => {
const header = 'test header';
const content = 'test content';
const { findByText } = render(
<InsightsBox header={header} content={content} />
);
const headerFound = await findByText(header);
expect(headerFound).toBeTruthy();
const contentFound = await findByText(content);
expect(contentFound).toBeTruthy();
});

View File

@ -0,0 +1,63 @@
import clsx from 'clsx';
import { Lightbulb, X } from 'lucide-react';
import { ReactNode, useMemo } from 'react';
import sanitize from 'sanitize-html';
import { useStore } from 'zustand';
import { Button } from '@@/buttons';
import { insightStore } from './insights-store';
export type Props = {
header: string;
content: ReactNode;
setHtmlContent?: boolean;
insightCloseId?: string; // set if you want to be able to close the box and not show it again
};
export function InsightsBox({
header,
content,
setHtmlContent,
insightCloseId,
}: Props) {
// allow to close the box and not show it again in local storage with zustand
const { addInsightIDClosed, isClosed } = useStore(insightStore);
const isInsightClosed = isClosed(insightCloseId);
// allow angular views to set html messages for the insights box
const htmlContent = useMemo(() => {
if (setHtmlContent && typeof content === 'string') {
// eslint-disable-next-line react/no-danger
return <div dangerouslySetInnerHTML={{ __html: sanitize(content) }} />;
}
return null;
}, [setHtmlContent, content]);
if (isInsightClosed) {
return null;
}
return (
<div className="relative flex w-full gap-1 rounded-lg bg-gray-modern-3 p-4 text-sm th-highcontrast:bg-legacy-grey-3 th-dark:bg-legacy-grey-3">
<div className="shrink-0">
<Lightbulb className="h-4 text-warning-7 th-highcontrast:text-warning-6 th-dark:text-warning-6" />
</div>
<div>
<p className={clsx('mb-2 font-bold', insightCloseId && 'pr-4')}>
{header}
</p>
<div>{htmlContent || content}</div>
</div>
{insightCloseId && (
<Button
icon={X}
className="absolute top-2 right-2 flex !text-gray-7 hover:!text-gray-8 th-highcontrast:!text-gray-6 th-highcontrast:hover:!text-gray-5 th-dark:!text-gray-6 th-dark:hover:!text-gray-5"
color="link"
size="medium"
onClick={() => addInsightIDClosed(insightCloseId)}
/>
)}
</div>
);
}

View File

@ -0,0 +1 @@
export { InsightsBox } from './InsightsBox';

View File

@ -0,0 +1,35 @@
import { createStore } from 'zustand';
import { persist } from 'zustand/middleware';
import { keyBuilder } from '@/react/hooks/useLocalStorage';
interface InsightsStore {
insightIDsClosed: string[];
addInsightIDClosed: (insightIDClosed: string) => void;
isClosed: (insightID?: string) => boolean;
}
export const insightStore = createStore<InsightsStore>()(
persist(
(set, get) => ({
insightIDsClosed: [],
addInsightIDClosed: (insightIDClosed: string) => {
set((state) => {
const currentIDsClosed = state.insightIDsClosed || [];
return { insightIDsClosed: [...currentIDsClosed, insightIDClosed] };
});
},
isClosed: (insightID?: string) => {
if (!insightID) {
return false;
}
const currentIDsClosed = get().insightIDsClosed || [];
return currentIDsClosed.includes(insightID);
},
}),
{
name: keyBuilder('insightIDsClosed'),
getStorage: () => localStorage,
}
)
);

View File

@ -1,7 +1,3 @@
.items {
margin-top: 10px;
}
.items > * + * { .items > * + * {
margin-top: 2px; margin-top: 2px;
} }
@ -24,11 +20,6 @@
margin-bottom: 20px; margin-bottom: 20px;
} }
.item-actions {
display: flex;
margin-left: 2px;
}
.default-item { .default-item {
width: 100% !important; width: 100% !important;
} }

View File

@ -1,9 +1,9 @@
import { ComponentType } from 'react'; import { ComponentType } from 'react';
import clsx from 'clsx'; import clsx from 'clsx';
import { FormikErrors } from 'formik'; import { FormikErrors } from 'formik';
import { ArrowDown, ArrowUp, Trash2 } from 'lucide-react'; import { ArrowDown, ArrowUp, Plus, Trash2 } from 'lucide-react';
import { AddButton, Button } from '@@/buttons'; import { Button } from '@@/buttons';
import { Tooltip } from '@@/Tip/Tooltip'; import { Tooltip } from '@@/Tip/Tooltip';
import { TextTip } from '@@/Tip/TextTip'; import { TextTip } from '@@/Tip/TextTip';
@ -79,18 +79,10 @@ export function InputList<T = DefaultType>({
return ( return (
<div className={clsx('form-group', styles.root)}> <div className={clsx('form-group', styles.root)}>
<div className={clsx('col-sm-12', styles.header)}> <div className={clsx('col-sm-12', styles.header)}>
<div className={clsx('control-label text-left', styles.label)}> <span className="control-label space-right pt-2 text-left !font-bold">
{label} {label}
{tooltip && <Tooltip message={tooltip} />} {tooltip && <Tooltip message={tooltip} />}
</div> </span>
{!(isAddButtonHidden || readOnly) && (
<AddButton
label={addLabel}
className="space-left"
onClick={handleAdd}
disabled={disabled}
/>
)}
</div> </div>
{textTip && ( {textTip && (
@ -99,68 +91,86 @@ export function InputList<T = DefaultType>({
</div> </div>
)} )}
<div className={clsx('col-sm-12', styles.items, 'space-y-4')}> {value.length > 0 && (
{value.map((item, index) => { <div className="col-sm-12 mt-5 flex flex-col gap-y-5">
const key = itemKeyGetter(item, index); {value.map((item, index) => {
const error = typeof errors === 'object' ? errors[index] : undefined; const key = itemKeyGetter(item, index);
const error =
typeof errors === 'object' ? errors[index] : undefined;
return ( return (
<div <div
key={key} key={key}
className={clsx( className={clsx(
styles.itemLine, styles.itemLine,
{ [styles.hasError]: !!error }, { [styles.hasError]: !!error },
'vertical-center' 'vertical-center'
)}
>
{Item ? (
<Item
item={item}
onChange={(value: T) => handleChangeItem(key, value)}
error={error}
disabled={disabled}
readOnly={readOnly}
/>
) : (
renderItem(
item,
(value: T) => handleChangeItem(key, value),
error
)
)}
<div className={clsx(styles.itemActions, 'items-start')}>
{!readOnly && movable && (
<>
<Button
size="medium"
disabled={disabled || index === 0}
onClick={() => handleMoveUp(index)}
className="vertical-center btn-only-icon"
icon={ArrowUp}
/>
<Button
size="medium"
type="button"
disabled={disabled || index === value.length - 1}
onClick={() => handleMoveDown(index)}
className="vertical-center btn-only-icon"
icon={ArrowDown}
/>
</>
)} )}
{!readOnly && ( >
<Button {Item ? (
color="dangerlight" <Item
size="medium" item={item}
onClick={() => handleRemoveItem(key, item)} onChange={(value: T) => handleChangeItem(key, value)}
className="vertical-center btn-only-icon" error={error}
icon={Trash2} disabled={disabled}
readOnly={readOnly}
/> />
) : (
renderItem(
item,
(value: T) => handleChangeItem(key, value),
error
)
)} )}
<div className="items-start">
{!readOnly && movable && (
<>
<Button
size="medium"
disabled={disabled || index === 0}
onClick={() => handleMoveUp(index)}
className="vertical-center btn-only-icon"
icon={ArrowUp}
/>
<Button
size="medium"
type="button"
disabled={disabled || index === value.length - 1}
onClick={() => handleMoveDown(index)}
className="vertical-center btn-only-icon"
icon={ArrowDown}
/>
</>
)}
{!readOnly && (
<Button
color="dangerlight"
size="medium"
onClick={() => handleRemoveItem(key, item)}
className="vertical-center btn-only-icon"
icon={Trash2}
/>
)}
</div>
</div> </div>
</div> );
); })}
})} </div>
)}
<div className="col-sm-12 mt-5">
{!(isAddButtonHidden || readOnly) && (
<Button
onClick={handleAdd}
disabled={disabled}
type="button"
color="default"
className="!ml-0"
size="small"
icon={Plus}
>
{addLabel}
</Button>
)}
</div> </div>
</div> </div>
); );

View File

@ -10,6 +10,7 @@ import { OptionProps } from 'react-select/dist/declarations/src/components/Optio
import { Select } from '@@/form-components/ReactSelect'; import { Select } from '@@/form-components/ReactSelect';
import { Switch } from '@@/form-components/SwitchField/Switch'; import { Switch } from '@@/form-components/SwitchField/Switch';
import { Tooltip } from '@@/Tip/Tooltip'; import { Tooltip } from '@@/Tip/Tooltip';
import { TextTip } from '@@/Tip/TextTip';
interface Values { interface Values {
enabled: boolean; enabled: boolean;
@ -35,6 +36,7 @@ export interface Props {
gpus: GPU[]; gpus: GPU[];
usedGpus: string[]; usedGpus: string[];
usedAllGpus: boolean; usedAllGpus: boolean;
enableGpuManagement?: boolean;
} }
const NvidiaCapabilitiesOptions = [ const NvidiaCapabilitiesOptions = [
@ -103,6 +105,7 @@ export function Gpu({
gpus = [], gpus = [],
usedGpus = [], usedGpus = [],
usedAllGpus, usedAllGpus,
enableGpuManagement,
}: Props) { }: Props) {
const options = useMemo(() => { const options = useMemo(() => {
const options = (gpus || []).map((gpu) => ({ const options = (gpus || []).map((gpu) => ({
@ -181,30 +184,38 @@ export function Gpu({
return ( return (
<div> <div>
{!enableGpuManagement && (
<TextTip color="blue">
GPU in the UI is not currently enabled for this environment.
</TextTip>
)}
<div className="form-group"> <div className="form-group">
<div className="col-sm-3 col-lg-2 control-label text-left"> <div className="col-sm-3 col-lg-2 control-label text-left">
Enable GPU Enable GPU
<Switch <Switch
id="enabled" id="enabled"
name="enabled" name="enabled"
checked={values.enabled} checked={values.enabled && !!enableGpuManagement}
onChange={toggleEnableGpu} onChange={toggleEnableGpu}
className="ml-2" className="ml-2"
disabled={enableGpuManagement === false}
/> />
</div> </div>
<div className="col-sm-9 col-lg-10 text-left"> {enableGpuManagement && values.enabled && (
<Select<GpuOption, true> <div className="col-sm-9 col-lg-10 text-left">
isMulti <Select<GpuOption, true>
closeMenuOnSelect isMulti
value={gpuValue} closeMenuOnSelect
isClearable={false} value={gpuValue}
backspaceRemovesValue={false} isClearable={false}
isDisabled={!values.enabled} backspaceRemovesValue={false}
onChange={onChangeSelectedGpus} isDisabled={!values.enabled}
options={options} onChange={onChangeSelectedGpus}
components={{ MultiValueRemove }} options={options}
/> components={{ MultiValueRemove }}
</div> />
</div>
)}
</div> </div>
{values.enabled && ( {values.enabled && (

View File

@ -15,7 +15,7 @@ import { gpus } from './gpus';
export function useColumns( export function useColumns(
isHostColumnVisible: boolean, isHostColumnVisible: boolean,
isGPUsColumnVisible: boolean isGPUsColumnVisible?: boolean
) { ) {
return useMemo( return useMemo(
() => () =>

View File

@ -1,8 +1,9 @@
import _ from 'lodash'; import _ from 'lodash';
import { useInfo } from '@/docker/services/system.service';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { ResourceControlViewModel } from '@/react/portainer/access-control/models/ResourceControlViewModel'; import { ResourceControlViewModel } from '@/react/portainer/access-control/models/ResourceControlViewModel';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { useInfo } from '@/docker/services/system.service';
import { useEnvironment } from '@/react/portainer/environments/queries';
import { DockerContainer, ContainerStatus } from './types'; import { DockerContainer, ContainerStatus } from './types';
import { DockerContainerResponse } from './types/response'; import { DockerContainerResponse } from './types/response';
@ -95,10 +96,13 @@ function createStatus(statusText = ''): ContainerStatus {
} }
export function useShowGPUsColumn(environmentID: EnvironmentId) { export function useShowGPUsColumn(environmentID: EnvironmentId) {
const envInfoQuery = useInfo( const isDockerStandaloneQuery = useInfo(
environmentID, environmentID,
(info) => !!info.Swarm?.NodeID && !!info.Swarm?.ControlAvailable (info) => !(!!info.Swarm?.NodeID && !!info.Swarm?.ControlAvailable) // is not a swarm environment, therefore docker standalone
); );
const enableGPUManagementQuery = useEnvironment(
return envInfoQuery.data !== true && !envInfoQuery.isLoading; environmentID,
(env) => env?.EnableGPUManagement
);
return isDockerStandaloneQuery.data && enableGPUManagementQuery.data;
} }

View File

@ -48,11 +48,12 @@ function Item({ item, onChange }: ItemProps<Gpu>) {
export function GpusList({ value, onChange }: Props) { export function GpusList({ value, onChange }: Props) {
return ( return (
<InputList<Gpu> <InputList<Gpu>
label="GPU" label="GPUs"
tooltip="You may optionally set up the GPUs that will be selectable against containers, although 'All GPUs' will always be available."
value={value} value={value}
onChange={onChange} onChange={onChange}
itemBuilder={() => ({ value: '', name: '' })} itemBuilder={() => ({ value: '', name: '' })}
addLabel="add" addLabel="Add GPU"
item={Item} item={Item}
/> />
); );

View File

@ -1,4 +1,3 @@
import { Gpu } from '@/react/portainer/environments/wizard/EnvironmentsCreationView/shared/Hardware/GpusList';
import axios, { parseAxiosError } from '@/portainer/services/axios'; import axios, { parseAxiosError } from '@/portainer/services/axios';
import { type EnvironmentGroupId } from '@/react/portainer/environments/environment-groups/types'; import { type EnvironmentGroupId } from '@/react/portainer/environments/environment-groups/types';
import { type TagId } from '@/portainer/tags/types'; import { type TagId } from '@/portainer/tags/types';
@ -18,7 +17,6 @@ interface CreateLocalDockerEnvironment {
socketPath?: string; socketPath?: string;
publicUrl?: string; publicUrl?: string;
meta?: EnvironmentMetadata; meta?: EnvironmentMetadata;
gpus?: Gpu[];
} }
export async function createLocalDockerEnvironment({ export async function createLocalDockerEnvironment({
@ -26,7 +24,6 @@ export async function createLocalDockerEnvironment({
socketPath = '', socketPath = '',
publicUrl = '', publicUrl = '',
meta = { tagIds: [] }, meta = { tagIds: [] },
gpus = [],
}: CreateLocalDockerEnvironment) { }: CreateLocalDockerEnvironment) {
const url = prefixPath(socketPath); const url = prefixPath(socketPath);
@ -37,7 +34,6 @@ export async function createLocalDockerEnvironment({
url, url,
publicUrl, publicUrl,
meta, meta,
gpus,
} }
); );
@ -113,7 +109,6 @@ export interface EnvironmentOptions {
azure?: AzureSettings; azure?: AzureSettings;
tls?: TLSSettings; tls?: TLSSettings;
isEdgeDevice?: boolean; isEdgeDevice?: boolean;
gpus?: Gpu[];
pollFrequency?: number; pollFrequency?: number;
edge?: EdgeSettings; edge?: EdgeSettings;
tunnelServerAddr?: string; tunnelServerAddr?: string;
@ -145,7 +140,6 @@ export interface CreateAgentEnvironmentValues {
name: string; name: string;
environmentUrl: string; environmentUrl: string;
meta: EnvironmentMetadata; meta: EnvironmentMetadata;
gpus: Gpu[];
} }
export function createAgentEnvironment({ export function createAgentEnvironment({
@ -173,7 +167,6 @@ interface CreateEdgeAgentEnvironment {
tunnelServerAddr?: string; tunnelServerAddr?: string;
meta?: EnvironmentMetadata; meta?: EnvironmentMetadata;
pollFrequency: number; pollFrequency: number;
gpus?: Gpu[];
isEdgeDevice?: boolean; isEdgeDevice?: boolean;
edge: EdgeSettings; edge: EdgeSettings;
} }
@ -182,7 +175,6 @@ export function createEdgeAgentEnvironment({
name, name,
portainerUrl, portainerUrl,
meta = { tagIds: [] }, meta = { tagIds: [] },
gpus = [],
isEdgeDevice, isEdgeDevice,
pollFrequency, pollFrequency,
edge, edge,
@ -196,7 +188,6 @@ export function createEdgeAgentEnvironment({
skipVerify: true, skipVerify: true,
skipClientVerify: true, skipClientVerify: true,
}, },
gpus,
isEdgeDevice, isEdgeDevice,
pollFrequency, pollFrequency,
edge, edge,
@ -226,7 +217,6 @@ async function createEnvironment(
TagIds: arrayToJson(tagIds), TagIds: arrayToJson(tagIds),
CheckinInterval: options.pollFrequency, CheckinInterval: options.pollFrequency,
IsEdgeDevice: options.isEdgeDevice, IsEdgeDevice: options.isEdgeDevice,
Gpus: arrayToJson(options.gpus),
}; };
const { tls, azure } = options; const { tls, azure } = options;

View File

@ -1,11 +1,18 @@
import { useQuery } from 'react-query'; import { useQuery } from 'react-query';
import { getEndpoint } from '@/react/portainer/environments/environment.service'; import { getEndpoint } from '@/react/portainer/environments/environment.service';
import { EnvironmentId } from '@/react/portainer/environments/types'; import {
Environment,
EnvironmentId,
} from '@/react/portainer/environments/types';
import { withError } from '@/react-tools/react-query'; import { withError } from '@/react-tools/react-query';
export function useEnvironment(id?: EnvironmentId) { export function useEnvironment<T = Environment | null>(
id?: EnvironmentId,
select?: (environment: Environment | null) => T
) {
return useQuery(['environments', id], () => (id ? getEndpoint(id) : null), { return useQuery(['environments', id], () => (id ? getEndpoint(id) : null), {
select,
...withError('Failed loading environment'), ...withError('Failed loading environment'),
staleTime: 50, staleTime: 50,
enabled: !!id, enabled: !!id,

View File

@ -131,6 +131,7 @@ export type Environment = {
TagIds: TagId[]; TagIds: TagId[];
GroupId: EnvironmentGroupId; GroupId: EnvironmentGroupId;
DeploymentOptions: DeploymentOptions | null; DeploymentOptions: DeploymentOptions | null;
EnableGPUManagement: boolean;
EdgeID?: string; EdgeID?: string;
EdgeKey: string; EdgeKey: string;
EdgeCheckinInterval?: number; EdgeCheckinInterval?: number;

View File

@ -3,7 +3,6 @@ import { useReducer } from 'react';
import { Plug2 } from 'lucide-react'; import { Plug2 } from 'lucide-react';
import { useCreateRemoteEnvironmentMutation } from '@/react/portainer/environments/queries/useCreateEnvironmentMutation'; import { useCreateRemoteEnvironmentMutation } from '@/react/portainer/environments/queries/useCreateEnvironmentMutation';
import { Hardware } from '@/react/portainer/environments/wizard/EnvironmentsCreationView/shared/Hardware/Hardware';
import { notifySuccess } from '@/portainer/services/notifications'; import { notifySuccess } from '@/portainer/services/notifications';
import { import {
Environment, Environment,
@ -13,6 +12,7 @@ import {
import { LoadingButton } from '@@/buttons/LoadingButton'; import { LoadingButton } from '@@/buttons/LoadingButton';
import { FormControl } from '@@/form-components/FormControl'; import { FormControl } from '@@/form-components/FormControl';
import { Input } from '@@/form-components/Input'; import { Input } from '@@/form-components/Input';
import { InsightsBox } from '@@/InsightsBox';
import { NameField } from '../../shared/NameField'; import { NameField } from '../../shared/NameField';
import { MoreSettingsSection } from '../../shared/MoreSettingsSection'; import { MoreSettingsSection } from '../../shared/MoreSettingsSection';
@ -23,9 +23,10 @@ import { TLSFieldset } from './TLSFieldset';
interface Props { interface Props {
onCreate(environment: Environment): void; onCreate(environment: Environment): void;
isDockerStandalone?: boolean;
} }
export function APIForm({ onCreate }: Props) { export function APIForm({ onCreate, isDockerStandalone }: Props) {
const [formKey, clearForm] = useReducer((state) => state + 1, 0); const [formKey, clearForm] = useReducer((state) => state + 1, 0);
const initialValues: FormValues = { const initialValues: FormValues = {
url: '', url: '',
@ -35,7 +36,6 @@ export function APIForm({ onCreate }: Props) {
groupId: 1, groupId: 1,
tagIds: [], tagIds: [],
}, },
gpus: [],
}; };
const mutation = useCreateRemoteEnvironmentMutation( const mutation = useCreateRemoteEnvironmentMutation(
@ -73,7 +73,33 @@ export function APIForm({ onCreate }: Props) {
<TLSFieldset /> <TLSFieldset />
<MoreSettingsSection> <MoreSettingsSection>
<Hardware /> {isDockerStandalone && (
<InsightsBox
content={
<>
<p>
From 2.18 on, the set-up of available GPUs for a Docker
Standalone environment has been shifted from Add
environment and Environment details to Host -&gt; Setup,
so as to align with other settings.
</p>
<p>
A toggle has been introduced for enabling/disabling
management of GPU settings in the Portainer UI - to
alleviate the performance impact of showing those
settings.
</p>
<p>
The UI has been updated to clarify that GPU settings
support is only for Docker Standalone (and not Docker
Swarm, which was never supported in the UI).
</p>
</>
}
header="GPU settings update"
insightCloseId="gpu-settings-update-closed"
/>
)}
</MoreSettingsSection> </MoreSettingsSection>
<div className="form-group"> <div className="form-group">
@ -104,7 +130,6 @@ export function APIForm({ onCreate }: Props) {
options: { options: {
tls, tls,
meta: values.meta, meta: values.meta,
gpus: values.gpus,
}, },
}, },
{ {

View File

@ -1,7 +1,5 @@
import { boolean, object, SchemaOf, string } from 'yup'; import { boolean, object, SchemaOf, string } from 'yup';
import { gpusListValidation } from '@/react/portainer/environments/wizard/EnvironmentsCreationView/shared/Hardware/GpusList';
import { metadataValidation } from '../../shared/MetadataFieldset/validation'; import { metadataValidation } from '../../shared/MetadataFieldset/validation';
import { useNameValidation } from '../../shared/NameField'; import { useNameValidation } from '../../shared/NameField';
@ -16,6 +14,5 @@ export function useValidation(): SchemaOf<FormValues> {
skipVerify: boolean(), skipVerify: boolean(),
meta: metadataValidation(), meta: metadataValidation(),
...certsValidation(), ...certsValidation(),
gpus: gpusListValidation(),
}); });
} }

View File

@ -4,12 +4,13 @@ import { APIForm } from './APIForm';
interface Props { interface Props {
onCreate(environment: Environment): void; onCreate(environment: Environment): void;
isDockerStandalone?: boolean;
} }
export function APITab({ onCreate }: Props) { export function APITab({ onCreate, isDockerStandalone }: Props) {
return ( return (
<div className="mt-5"> <div className="mt-5">
<APIForm onCreate={onCreate} /> <APIForm onCreate={onCreate} isDockerStandalone={isDockerStandalone} />
</div> </div>
); );
} }

View File

@ -1,4 +1,3 @@
import { Gpu } from '@/react/portainer/environments/wizard/EnvironmentsCreationView/shared/Hardware/GpusList';
import { EnvironmentMetadata } from '@/react/portainer/environments/environment.service/create'; import { EnvironmentMetadata } from '@/react/portainer/environments/environment.service/create';
export interface FormValues { export interface FormValues {
@ -10,5 +9,4 @@ export interface FormValues {
certFile?: File; certFile?: File;
keyFile?: File; keyFile?: File;
meta: EnvironmentMetadata; meta: EnvironmentMetadata;
gpus?: Gpu[];
} }

View File

@ -3,7 +3,6 @@ import { useReducer } from 'react';
import { Plug2 } from 'lucide-react'; import { Plug2 } from 'lucide-react';
import { useCreateLocalDockerEnvironmentMutation } from '@/react/portainer/environments/queries/useCreateEnvironmentMutation'; import { useCreateLocalDockerEnvironmentMutation } from '@/react/portainer/environments/queries/useCreateEnvironmentMutation';
import { Hardware } from '@/react/portainer/environments/wizard/EnvironmentsCreationView/shared/Hardware/Hardware';
import { notifySuccess } from '@/portainer/services/notifications'; import { notifySuccess } from '@/portainer/services/notifications';
import { Environment } from '@/react/portainer/environments/types'; import { Environment } from '@/react/portainer/environments/types';
@ -11,6 +10,7 @@ import { LoadingButton } from '@@/buttons/LoadingButton';
import { FormControl } from '@@/form-components/FormControl'; import { FormControl } from '@@/form-components/FormControl';
import { Input } from '@@/form-components/Input'; import { Input } from '@@/form-components/Input';
import { SwitchField } from '@@/form-components/SwitchField'; import { SwitchField } from '@@/form-components/SwitchField';
import { InsightsBox } from '@@/InsightsBox';
import { NameField } from '../../shared/NameField'; import { NameField } from '../../shared/NameField';
import { MoreSettingsSection } from '../../shared/MoreSettingsSection'; import { MoreSettingsSection } from '../../shared/MoreSettingsSection';
@ -20,16 +20,16 @@ import { FormValues } from './types';
interface Props { interface Props {
onCreate(environment: Environment): void; onCreate(environment: Environment): void;
isDockerStandalone?: boolean;
} }
export function SocketForm({ onCreate }: Props) { export function SocketForm({ onCreate, isDockerStandalone }: Props) {
const [formKey, clearForm] = useReducer((state) => state + 1, 0); const [formKey, clearForm] = useReducer((state) => state + 1, 0);
const initialValues: FormValues = { const initialValues: FormValues = {
name: '', name: '',
socketPath: '', socketPath: '',
overridePath: false, overridePath: false,
meta: { groupId: 1, tagIds: [] }, meta: { groupId: 1, tagIds: [] },
gpus: [],
}; };
const mutation = useCreateLocalDockerEnvironmentMutation(); const mutation = useCreateLocalDockerEnvironmentMutation();
@ -50,7 +50,33 @@ export function SocketForm({ onCreate }: Props) {
<OverrideSocketFieldset /> <OverrideSocketFieldset />
<MoreSettingsSection> <MoreSettingsSection>
<Hardware /> {isDockerStandalone && (
<InsightsBox
content={
<>
<p>
From 2.18 on, the set-up of available GPUs for a Docker
Standalone environment has been shifted from Add
environment and Environment details to Host -&gt; Setup,
so as to align with other settings.
</p>
<p>
A toggle has been introduced for enabling/disabling
management of GPU settings in the Portainer UI - to
alleviate the performance impact of showing those
settings.
</p>
<p>
The UI has been updated to clarify that GPU settings
support is only for Docker Standalone (and not Docker
Swarm, which was never supported in the UI).
</p>
</>
}
header="GPU settings update"
insightCloseId="gpu-settings-update-closed"
/>
)}
</MoreSettingsSection> </MoreSettingsSection>
<div className="form-group"> <div className="form-group">
@ -76,7 +102,6 @@ export function SocketForm({ onCreate }: Props) {
{ {
name: values.name, name: values.name,
socketPath: values.overridePath ? values.socketPath : '', socketPath: values.overridePath ? values.socketPath : '',
gpus: values.gpus,
meta: values.meta, meta: values.meta,
}, },
{ {

View File

@ -1,7 +1,5 @@
import { boolean, object, SchemaOf, string } from 'yup'; import { boolean, object, SchemaOf, string } from 'yup';
import { gpusListValidation } from '@/react/portainer/environments/wizard/EnvironmentsCreationView/shared/Hardware/GpusList';
import { metadataValidation } from '../../shared/MetadataFieldset/validation'; import { metadataValidation } from '../../shared/MetadataFieldset/validation';
import { useNameValidation } from '../../shared/NameField'; import { useNameValidation } from '../../shared/NameField';
@ -21,6 +19,5 @@ export function useValidation(): SchemaOf<FormValues> {
) )
: schema : schema
), ),
gpus: gpusListValidation(),
}); });
} }

View File

@ -6,15 +6,19 @@ import { SocketForm } from './SocketForm';
interface Props { interface Props {
onCreate(environment: Environment): void; onCreate(environment: Environment): void;
isDockerStandalone?: boolean;
} }
export function SocketTab({ onCreate }: Props) { export function SocketTab({ onCreate, isDockerStandalone }: Props) {
return ( return (
<> <>
<DeploymentScripts /> <DeploymentScripts />
<div className="mt-5"> <div className="mt-5">
<SocketForm onCreate={onCreate} /> <SocketForm
onCreate={onCreate}
isDockerStandalone={isDockerStandalone}
/>
</div> </div>
</> </>
); );

View File

@ -1,4 +1,3 @@
import { Gpu } from '@/react/portainer/environments/wizard/EnvironmentsCreationView/shared/Hardware/GpusList';
import { EnvironmentMetadata } from '@/react/portainer/environments/environment.service/create'; import { EnvironmentMetadata } from '@/react/portainer/environments/environment.service/create';
export interface FormValues { export interface FormValues {
@ -6,5 +5,4 @@ export interface FormValues {
socketPath: string; socketPath: string;
overridePath: boolean; overridePath: boolean;
meta: EnvironmentMetadata; meta: EnvironmentMetadata;
gpus: Gpu[];
} }

View File

@ -109,12 +109,14 @@ export function WizardDocker({ onCreate, isDockerStandalone }: Props) {
return ( return (
<APITab <APITab
onCreate={(environment) => onCreate(environment, 'dockerApi')} onCreate={(environment) => onCreate(environment, 'dockerApi')}
isDockerStandalone={isDockerStandalone}
/> />
); );
case 'socket': case 'socket':
return ( return (
<SocketTab <SocketTab
onCreate={(environment) => onCreate(environment, 'localEndpoint')} onCreate={(environment) => onCreate(environment, 'localEndpoint')}
isDockerStandalone={isDockerStandalone}
/> />
); );
case 'edgeAgentStandard': case 'edgeAgentStandard':

View File

@ -11,14 +11,12 @@ import { LoadingButton } from '@@/buttons/LoadingButton';
import { NameField } from '../NameField'; import { NameField } from '../NameField';
import { MoreSettingsSection } from '../MoreSettingsSection'; import { MoreSettingsSection } from '../MoreSettingsSection';
import { Hardware } from '../Hardware/Hardware';
import { EnvironmentUrlField } from './EnvironmentUrlField'; import { EnvironmentUrlField } from './EnvironmentUrlField';
import { useValidation } from './AgentForm.validation'; import { useValidation } from './AgentForm.validation';
interface Props { interface Props {
onCreate(environment: Environment): void; onCreate(environment: Environment): void;
showGpus?: boolean;
} }
const initialValues: CreateAgentEnvironmentValues = { const initialValues: CreateAgentEnvironmentValues = {
@ -28,10 +26,9 @@ const initialValues: CreateAgentEnvironmentValues = {
groupId: 1, groupId: 1,
tagIds: [], tagIds: [],
}, },
gpus: [],
}; };
export function AgentForm({ onCreate, showGpus = false }: Props) { export function AgentForm({ onCreate }: Props) {
const [formKey, clearForm] = useReducer((state) => state + 1, 0); const [formKey, clearForm] = useReducer((state) => state + 1, 0);
const mutation = useCreateAgentEnvironmentMutation(); const mutation = useCreateAgentEnvironmentMutation();
@ -50,7 +47,7 @@ export function AgentForm({ onCreate, showGpus = false }: Props) {
<NameField /> <NameField />
<EnvironmentUrlField /> <EnvironmentUrlField />
<MoreSettingsSection>{showGpus && <Hardware />}</MoreSettingsSection> <MoreSettingsSection />
<div className="form-group"> <div className="form-group">
<div className="col-sm-12"> <div className="col-sm-12">

View File

@ -1,6 +1,5 @@
import { object, SchemaOf, string } from 'yup'; import { object, SchemaOf, string } from 'yup';
import { gpusListValidation } from '@/react/portainer/environments/wizard/EnvironmentsCreationView/shared/Hardware/GpusList';
import { CreateAgentEnvironmentValues } from '@/react/portainer/environments/environment.service/create'; import { CreateAgentEnvironmentValues } from '@/react/portainer/environments/environment.service/create';
import { metadataValidation } from '../MetadataFieldset/validation'; import { metadataValidation } from '../MetadataFieldset/validation';
@ -11,7 +10,6 @@ export function useValidation(): SchemaOf<CreateAgentEnvironmentValues> {
name: useNameValidation(), name: useNameValidation(),
environmentUrl: environmentValidation(), environmentUrl: environmentValidation(),
meta: metadataValidation(), meta: metadataValidation(),
gpus: gpusListValidation(),
}); });
} }

View File

@ -17,7 +17,6 @@ import { FormSection } from '@@/form-components/FormSection';
import { LoadingButton } from '@@/buttons/LoadingButton'; import { LoadingButton } from '@@/buttons/LoadingButton';
import { MoreSettingsSection } from '../../MoreSettingsSection'; import { MoreSettingsSection } from '../../MoreSettingsSection';
import { Hardware } from '../../Hardware/Hardware';
import { EdgeAgentFieldset } from './EdgeAgentFieldset'; import { EdgeAgentFieldset } from './EdgeAgentFieldset';
import { useValidationSchema } from './EdgeAgentForm.validation'; import { useValidationSchema } from './EdgeAgentForm.validation';
@ -26,16 +25,10 @@ import { FormValues } from './types';
interface Props { interface Props {
onCreate(environment: Environment): void; onCreate(environment: Environment): void;
readonly: boolean; readonly: boolean;
showGpus?: boolean;
asyncMode: boolean; asyncMode: boolean;
} }
export function EdgeAgentForm({ export function EdgeAgentForm({ onCreate, readonly, asyncMode }: Props) {
onCreate,
readonly,
asyncMode,
showGpus = false,
}: Props) {
const settingsQuery = useSettings(); const settingsQuery = useSettings();
const createMutation = useCreateEdgeAgentEnvironmentMutation(); const createMutation = useCreateEdgeAgentEnvironmentMutation();
@ -76,7 +69,6 @@ export function EdgeAgentForm({
/> />
)} )}
</FormSection> </FormSection>
{showGpus && <Hardware />}
</MoreSettingsSection> </MoreSettingsSection>
{!readonly && ( {!readonly && (
@ -133,6 +125,5 @@ export function buildInitialValues(settings: Settings): FormValues {
PingInterval: EDGE_ASYNC_INTERVAL_USE_DEFAULT, PingInterval: EDGE_ASYNC_INTERVAL_USE_DEFAULT,
SnapshotInterval: EDGE_ASYNC_INTERVAL_USE_DEFAULT, SnapshotInterval: EDGE_ASYNC_INTERVAL_USE_DEFAULT,
}, },
gpus: [],
}; };
} }

View File

@ -4,7 +4,6 @@ import {
edgeAsyncIntervalsValidation, edgeAsyncIntervalsValidation,
EdgeAsyncIntervalsValues, EdgeAsyncIntervalsValues,
} from '@/react/edge/components/EdgeAsyncIntervalsForm'; } from '@/react/edge/components/EdgeAsyncIntervalsForm';
import { gpusListValidation } from '@/react/portainer/environments/wizard/EnvironmentsCreationView/shared/Hardware/GpusList';
import { validation as urlValidation } from '@/react/portainer/common/PortainerTunnelAddrField'; import { validation as urlValidation } from '@/react/portainer/common/PortainerTunnelAddrField';
import { validation as addressValidation } from '@/react/portainer/common/PortainerUrlField'; import { validation as addressValidation } from '@/react/portainer/common/PortainerUrlField';
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service'; import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
@ -23,7 +22,6 @@ export function useValidationSchema(asyncMode: boolean): SchemaOf<FormValues> {
tunnelServerAddr: asyncMode ? string() : addressValidation(), tunnelServerAddr: asyncMode ? string() : addressValidation(),
pollFrequency: number().required(), pollFrequency: number().required(),
meta: metadataValidation(), meta: metadataValidation(),
gpus: gpusListValidation(),
edge: isBE edge: isBE
? edgeAsyncIntervalsValidation() ? edgeAsyncIntervalsValidation()
: (null as unknown as SchemaOf<EdgeAsyncIntervalsValues>), : (null as unknown as SchemaOf<EdgeAsyncIntervalsValues>),

View File

@ -1,5 +1,4 @@
import { EdgeAsyncIntervalsValues } from '@/react/edge/components/EdgeAsyncIntervalsForm'; import { EdgeAsyncIntervalsValues } from '@/react/edge/components/EdgeAsyncIntervalsForm';
import { Gpu } from '@/react/portainer/environments/wizard/EnvironmentsCreationView/shared/Hardware/GpusList';
import { EnvironmentMetadata } from '@/react/portainer/environments/environment.service/create'; import { EnvironmentMetadata } from '@/react/portainer/environments/environment.service/create';
export interface FormValues { export interface FormValues {
@ -9,7 +8,6 @@ export interface FormValues {
tunnelServerAddr?: string; tunnelServerAddr?: string;
pollFrequency: number; pollFrequency: number;
meta: EnvironmentMetadata; meta: EnvironmentMetadata;
gpus: Gpu[];
edge: EdgeAsyncIntervalsValues; edge: EdgeAsyncIntervalsValues;
} }

View File

@ -15,7 +15,6 @@ interface Props {
onCreate: (environment: Environment) => void; onCreate: (environment: Environment) => void;
commands: CommandTab[] | Partial<Record<OS, CommandTab[]>>; commands: CommandTab[] | Partial<Record<OS, CommandTab[]>>;
isNomadTokenVisible?: boolean; isNomadTokenVisible?: boolean;
showGpus?: boolean;
asyncMode?: boolean; asyncMode?: boolean;
} }
@ -23,7 +22,6 @@ export function EdgeAgentTab({
onCreate, onCreate,
commands, commands,
isNomadTokenVisible, isNomadTokenVisible,
showGpus = false,
asyncMode = false, asyncMode = false,
}: Props) { }: Props) {
const [edgeInfo, setEdgeInfo] = useState<EdgeInfo>(); const [edgeInfo, setEdgeInfo] = useState<EdgeInfo>();
@ -35,7 +33,6 @@ export function EdgeAgentTab({
onCreate={handleCreate} onCreate={handleCreate}
readonly={!!edgeInfo} readonly={!!edgeInfo}
key={formKey} key={formKey}
showGpus={showGpus}
asyncMode={asyncMode} asyncMode={asyncMode}
/> />

View File

@ -1,22 +0,0 @@
import { useField } from 'formik';
import {
Gpu,
GpusList,
} from '@/react/portainer/environments/wizard/EnvironmentsCreationView/shared/Hardware/GpusList';
import { FormSection } from '@@/form-components/FormSection';
export function Hardware() {
const [field, , helpers] = useField('gpus');
function onChange(value: Gpu[]) {
helpers.setValue(value);
}
return (
<FormSection title="Hardware acceleration">
<GpusList value={field.value} onChange={onChange} />
</FormSection>
);
}