diff --git a/api/dataservices/edgeupdateschedule/edgeupdateschedule.go b/api/dataservices/edgeupdateschedule/edgeupdateschedule.go new file mode 100644 index 000000000..5359d464e --- /dev/null +++ b/api/dataservices/edgeupdateschedule/edgeupdateschedule.go @@ -0,0 +1,91 @@ +package edgeupdateschedule + +import ( + "fmt" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/edgetypes" + "github.com/sirupsen/logrus" +) + +const ( + // BucketName represents the name of the bucket where this service stores data. + BucketName = "edge_update_schedule" +) + +// Service represents a service for managing Edge Update Schedule data. +type Service struct { + connection portainer.Connection +} + +func (service *Service) BucketName() string { + return BucketName +} + +// NewService creates a new instance of a service. +func NewService(connection portainer.Connection) (*Service, error) { + err := connection.SetServiceName(BucketName) + if err != nil { + return nil, err + } + + return &Service{ + connection: connection, + }, nil +} + +// List return an array containing all the items in the bucket. +func (service *Service) List() ([]edgetypes.UpdateSchedule, error) { + var list = make([]edgetypes.UpdateSchedule, 0) + + err := service.connection.GetAll( + BucketName, + &edgetypes.UpdateSchedule{}, + func(obj interface{}) (interface{}, error) { + item, ok := obj.(*edgetypes.UpdateSchedule) + if !ok { + logrus.WithField("obj", obj).Errorf("Failed to convert to EdgeUpdateSchedule object") + return nil, fmt.Errorf("Failed to convert to EdgeUpdateSchedule object: %s", obj) + } + list = append(list, *item) + return &edgetypes.UpdateSchedule{}, nil + }) + + return list, err +} + +// Item returns a item by ID. +func (service *Service) Item(ID edgetypes.UpdateScheduleID) (*edgetypes.UpdateSchedule, error) { + var item edgetypes.UpdateSchedule + identifier := service.connection.ConvertToKey(int(ID)) + + err := service.connection.GetObject(BucketName, identifier, &item) + if err != nil { + return nil, err + } + + return &item, nil +} + +// Create assign an ID to a new object and saves it. +func (service *Service) Create(item *edgetypes.UpdateSchedule) error { + return service.connection.CreateObject( + BucketName, + func(id uint64) (int, interface{}) { + item.ID = edgetypes.UpdateScheduleID(id) + return int(item.ID), item + }, + ) +} + +// Update updates an item. +func (service *Service) Update(ID edgetypes.UpdateScheduleID, item *edgetypes.UpdateSchedule) error { + identifier := service.connection.ConvertToKey(int(ID)) + return service.connection.UpdateObject(BucketName, identifier, item) +} + +// Delete deletes an item. +func (service *Service) Delete(ID edgetypes.UpdateScheduleID) error { + identifier := service.connection.ConvertToKey(int(ID)) + return service.connection.DeleteObject(BucketName, identifier) +} diff --git a/api/dataservices/interface.go b/api/dataservices/interface.go index 948c946d6..2e0e5dd94 100644 --- a/api/dataservices/interface.go +++ b/api/dataservices/interface.go @@ -7,6 +7,7 @@ import ( "time" "github.com/portainer/portainer/api/dataservices/errors" + "github.com/portainer/portainer/api/edgetypes" portainer "github.com/portainer/portainer/api" ) @@ -28,6 +29,7 @@ type ( EdgeGroup() EdgeGroupService EdgeJob() EdgeJobService EdgeStack() EdgeStackService + EdgeUpdateSchedule() EdgeUpdateScheduleService Endpoint() EndpointService EndpointGroup() EndpointGroupService EndpointRelation() EndpointRelationService @@ -81,6 +83,15 @@ type ( BucketName() string } + EdgeUpdateScheduleService interface { + List() ([]edgetypes.UpdateSchedule, error) + Item(ID edgetypes.UpdateScheduleID) (*edgetypes.UpdateSchedule, error) + Create(edgeUpdateSchedule *edgetypes.UpdateSchedule) error + Update(ID edgetypes.UpdateScheduleID, edgeUpdateSchedule *edgetypes.UpdateSchedule) error + Delete(ID edgetypes.UpdateScheduleID) error + BucketName() string + } + // EdgeStackService represents a service to manage Edge stacks EdgeStackService interface { EdgeStacks() ([]portainer.EdgeStack, error) diff --git a/api/datastore/services.go b/api/datastore/services.go index 26da1ef19..d1bb2693e 100644 --- a/api/datastore/services.go +++ b/api/datastore/services.go @@ -13,6 +13,7 @@ import ( "github.com/portainer/portainer/api/dataservices/edgegroup" "github.com/portainer/portainer/api/dataservices/edgejob" "github.com/portainer/portainer/api/dataservices/edgestack" + "github.com/portainer/portainer/api/dataservices/edgeupdateschedule" "github.com/portainer/portainer/api/dataservices/endpoint" "github.com/portainer/portainer/api/dataservices/endpointgroup" "github.com/portainer/portainer/api/dataservices/endpointrelation" @@ -46,6 +47,7 @@ type Store struct { DockerHubService *dockerhub.Service EdgeGroupService *edgegroup.Service EdgeJobService *edgejob.Service + EdgeUpdateScheduleService *edgeupdateschedule.Service EdgeStackService *edgestack.Service EndpointGroupService *endpointgroup.Service EndpointService *endpoint.Service @@ -89,6 +91,12 @@ func (store *Store) initServices() error { } store.DockerHubService = dockerhubService + edgeUpdateScheduleService, err := edgeupdateschedule.NewService(store.connection) + if err != nil { + return err + } + store.EdgeUpdateScheduleService = edgeUpdateScheduleService + edgeStackService, err := edgestack.NewService(store.connection) if err != nil { return err @@ -245,6 +253,11 @@ func (store *Store) EdgeJob() dataservices.EdgeJobService { return store.EdgeJobService } +// EdgeUpdateSchedule gives access to the EdgeUpdateSchedule data management layer +func (store *Store) EdgeUpdateSchedule() dataservices.EdgeUpdateScheduleService { + return store.EdgeUpdateScheduleService +} + // EdgeStack gives access to the EdgeStack data management layer func (store *Store) EdgeStack() dataservices.EdgeStackService { return store.EdgeStackService diff --git a/api/edgetypes/edgetypes.go b/api/edgetypes/edgetypes.go new file mode 100644 index 000000000..e5a3f841c --- /dev/null +++ b/api/edgetypes/edgetypes.go @@ -0,0 +1,93 @@ +package edgetypes + +import portainer "github.com/portainer/portainer/api" + +const ( + // PortainerAgentUpdateScheduleIDHeader represents the name of the header containing the update schedule id + PortainerAgentUpdateScheduleIDHeader = "X-Portainer-Update-Schedule-ID" + // PortainerAgentUpdateStatusHeader is the name of the header that will have the update status + PortainerAgentUpdateStatusHeader = "X-Portainer-Update-Status" + // PortainerAgentUpdateErrorHeader is the name of the header that will have the update error + PortainerAgentUpdateErrorHeader = "X-Portainer-Update-Error" +) + +type ( + + // VersionUpdateStatus represents the status of an agent version update + VersionUpdateStatus struct { + Status UpdateScheduleStatusType + ScheduleID UpdateScheduleID + Error string + } + + // UpdateScheduleID represents an Edge schedule identifier + UpdateScheduleID int + + // UpdateSchedule represents a schedule for update/rollback of edge devices + UpdateSchedule struct { + // EdgeUpdateSchedule Identifier + ID UpdateScheduleID `json:"id" example:"1"` + // Name of the schedule + Name string `json:"name" example:"Update Schedule"` + // Type of the schedule + Time int64 `json:"time" example:"1564897200"` + // EdgeGroups to be updated + GroupIDs []portainer.EdgeGroupID `json:"groupIds" example:"1"` + // Type of the update (1 - update, 2 - rollback) + Type UpdateScheduleType `json:"type" example:"1" enums:"1,2"` + // Status of the schedule, grouped by environment id + Status map[portainer.EndpointID]UpdateScheduleStatus `json:"status"` + // Created timestamp + Created int64 `json:"created" example:"1564897200"` + // Created by user id + CreatedBy portainer.UserID `json:"createdBy" example:"1"` + // Version of the edge agent + Version string `json:"version" example:"1"` + } + + // UpdateScheduleType represents type of an Edge update schedule + UpdateScheduleType int + + // UpdateScheduleStatus represents status of an Edge update schedule + UpdateScheduleStatus struct { + // Status of the schedule (0 - pending, 1 - failed, 2 - success) + Status UpdateScheduleStatusType `json:"status" example:"1" enums:"1,2,3"` + // Error message if status is failed + Error string `json:"error" example:""` + // Target version of the edge agent + TargetVersion string `json:"targetVersion" example:"1"` + // Current version of the edge agent + CurrentVersion string `json:"currentVersion" example:"1"` + } + + // UpdateScheduleStatusType represents status type of an Edge update schedule + UpdateScheduleStatusType int + + VersionUpdateRequest struct { + // Target version + Version string + // Scheduled time + ScheduledTime int64 + // If need to update + Active bool + // Update schedule ID + ScheduleID UpdateScheduleID + } +) + +const ( + _ UpdateScheduleType = iota + // UpdateScheduleUpdate represents an edge device scheduled for an update + UpdateScheduleUpdate + // UpdateScheduleRollback represents an edge device scheduled for a rollback + UpdateScheduleRollback +) + +const ( + // UpdateScheduleStatusPending represents a pending edge update schedule + UpdateScheduleStatusPending UpdateScheduleStatusType = iota + // UpdateScheduleStatusError represents a failed edge update schedule + UpdateScheduleStatusError + // UpdateScheduleStatusSuccess represents a successful edge update schedule + UpdateScheduleStatusSuccess +) diff --git a/api/http/handler/docker/containers/container_gpus_inspect.go b/api/http/handler/docker/containers/container_gpus_inspect.go index cbedab5f9..9329c35c5 100644 --- a/api/http/handler/docker/containers/container_gpus_inspect.go +++ b/api/http/handler/docker/containers/container_gpus_inspect.go @@ -8,7 +8,7 @@ import ( httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" - portaineree "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/http/middlewares" "golang.org/x/exp/slices" ) @@ -43,7 +43,7 @@ func (handler *Handler) containerGpusInspect(w http.ResponseWriter, r *http.Requ return httperror.NotFound("Unable to find an environment on request context", err) } - agentTargetHeader := r.Header.Get(portaineree.PortainerAgentTargetHeader) + agentTargetHeader := r.Header.Get(portainer.PortainerAgentTargetHeader) cli, err := handler.dockerClientFactory.CreateClient(endpoint, agentTargetHeader, nil) if err != nil { diff --git a/api/http/handler/edgeupdateschedules/edgeupdateschedule_create.go b/api/http/handler/edgeupdateschedules/edgeupdateschedule_create.go new file mode 100644 index 000000000..9519b7eea --- /dev/null +++ b/api/http/handler/edgeupdateschedules/edgeupdateschedule_create.go @@ -0,0 +1,97 @@ +package edgeupdateschedules + +import ( + "errors" + "net/http" + "time" + + "github.com/asaskevich/govalidator" + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" + "github.com/portainer/libhttp/response" + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/edgetypes" + "github.com/portainer/portainer/api/http/security" +) + +type createPayload struct { + Name string + GroupIDs []portainer.EdgeGroupID + Type edgetypes.UpdateScheduleType + Version string + Time int64 +} + +func (payload *createPayload) Validate(r *http.Request) error { + if govalidator.IsNull(payload.Name) { + return errors.New("Invalid tag name") + } + + if len(payload.GroupIDs) == 0 { + return errors.New("Required to choose at least one group") + } + + if payload.Type != edgetypes.UpdateScheduleRollback && payload.Type != edgetypes.UpdateScheduleUpdate { + return errors.New("Invalid schedule type") + } + + if payload.Version == "" { + return errors.New("Invalid version") + } + + if payload.Time < time.Now().Unix() { + return errors.New("Invalid time") + } + + return nil +} + +// @id EdgeUpdateScheduleCreate +// @summary Creates a new Edge Update Schedule +// @description **Access policy**: administrator +// @tags edge_update_schedules +// @security ApiKeyAuth +// @security jwt +// @accept json +// @param body body createPayload true "Schedule details" +// @produce json +// @success 200 {object} edgetypes.UpdateSchedule +// @failure 500 +// @router /edge_update_schedules [post] +func (handler *Handler) create(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + + var payload createPayload + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return httperror.BadRequest("Invalid request payload", err) + } + + err = handler.validateUniqueName(payload.Name, 0) + if err != nil { + return httperror.NewError(http.StatusConflict, "Edge update schedule name already in use", err) + + } + + tokenData, err := security.RetrieveTokenData(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve user information from token", err) + } + + item := &edgetypes.UpdateSchedule{ + Name: payload.Name, + Time: payload.Time, + GroupIDs: payload.GroupIDs, + Status: map[portainer.EndpointID]edgetypes.UpdateScheduleStatus{}, + Created: time.Now().Unix(), + CreatedBy: tokenData.ID, + Type: payload.Type, + Version: payload.Version, + } + + err = handler.dataStore.EdgeUpdateSchedule().Create(item) + if err != nil { + return httperror.InternalServerError("Unable to persist the edge update schedule", err) + } + + return response.JSON(w, item) +} diff --git a/api/http/handler/edgeupdateschedules/edgeupdateschedule_delete.go b/api/http/handler/edgeupdateschedules/edgeupdateschedule_delete.go new file mode 100644 index 000000000..deaecc11d --- /dev/null +++ b/api/http/handler/edgeupdateschedules/edgeupdateschedule_delete.go @@ -0,0 +1,33 @@ +package edgeupdateschedules + +import ( + "net/http" + + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/response" + "github.com/portainer/portainer/api/edgetypes" + "github.com/portainer/portainer/api/http/middlewares" +) + +// @id EdgeUpdateScheduleDelete +// @summary Deletes an Edge Update Schedule +// @description **Access policy**: administrator +// @tags edge_update_schedules +// @security ApiKeyAuth +// @security jwt +// @success 204 +// @failure 500 +// @router /edge_update_schedules/{id} [delete] +func (handler *Handler) delete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + item, err := middlewares.FetchItem[edgetypes.UpdateSchedule](r, contextKey) + if err != nil { + return httperror.InternalServerError(err.Error(), err) + } + + err = handler.dataStore.EdgeUpdateSchedule().Delete(item.ID) + if err != nil { + return httperror.InternalServerError("Unable to delete the edge update schedule", err) + } + + return response.Empty(w) +} diff --git a/api/http/handler/edgeupdateschedules/edgeupdateschedule_inspect.go b/api/http/handler/edgeupdateschedules/edgeupdateschedule_inspect.go new file mode 100644 index 000000000..51d0f65d7 --- /dev/null +++ b/api/http/handler/edgeupdateschedules/edgeupdateschedule_inspect.go @@ -0,0 +1,29 @@ +package edgeupdateschedules + +import ( + "net/http" + + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/response" + "github.com/portainer/portainer/api/edgetypes" + "github.com/portainer/portainer/api/http/middlewares" +) + +// @id EdgeUpdateScheduleInspect +// @summary Returns the Edge Update Schedule with the given ID +// @description **Access policy**: administrator +// @tags edge_update_schedules +// @security ApiKeyAuth +// @security jwt +// @produce json +// @success 200 {object} edgetypes.UpdateSchedule +// @failure 500 +// @router /edge_update_schedules/{id} [get] +func (handler *Handler) inspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + item, err := middlewares.FetchItem[edgetypes.UpdateSchedule](r, contextKey) + if err != nil { + return httperror.InternalServerError(err.Error(), err) + } + + return response.JSON(w, item) +} diff --git a/api/http/handler/edgeupdateschedules/edgeupdateschedule_list.go b/api/http/handler/edgeupdateschedules/edgeupdateschedule_list.go new file mode 100644 index 000000000..04b875d58 --- /dev/null +++ b/api/http/handler/edgeupdateschedules/edgeupdateschedule_list.go @@ -0,0 +1,27 @@ +package edgeupdateschedules + +import ( + "net/http" + + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/response" +) + +// @id EdgeUpdateScheduleList +// @summary Fetches the list of Edge Update Schedules +// @description **Access policy**: administrator +// @tags edge_update_schedules +// @security ApiKeyAuth +// @security jwt +// @produce json +// @success 200 {array} edgetypes.UpdateSchedule +// @failure 500 +// @router /edge_update_schedules [get] +func (handler *Handler) list(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + list, err := handler.dataStore.EdgeUpdateSchedule().List() + if err != nil { + return httperror.InternalServerError("Unable to retrieve the edge update schedules list", err) + } + + return response.JSON(w, list) +} diff --git a/api/http/handler/edgeupdateschedules/edgeupdateschedule_update.go b/api/http/handler/edgeupdateschedules/edgeupdateschedule_update.go new file mode 100644 index 000000000..5ef031e67 --- /dev/null +++ b/api/http/handler/edgeupdateschedules/edgeupdateschedule_update.go @@ -0,0 +1,92 @@ +package edgeupdateschedules + +import ( + "errors" + "net/http" + "time" + + "github.com/asaskevich/govalidator" + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" + "github.com/portainer/libhttp/response" + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/edgetypes" + "github.com/portainer/portainer/api/http/middlewares" +) + +type updatePayload struct { + Name string + GroupIDs []portainer.EdgeGroupID + Type edgetypes.UpdateScheduleType + Version string + Time int64 +} + +func (payload *updatePayload) Validate(r *http.Request) error { + if govalidator.IsNull(payload.Name) { + return errors.New("Invalid tag name") + } + + if len(payload.GroupIDs) == 0 { + return errors.New("Required to choose at least one group") + } + + if payload.Type != edgetypes.UpdateScheduleRollback && payload.Type != edgetypes.UpdateScheduleUpdate { + return errors.New("Invalid schedule type") + } + + if payload.Version == "" { + return errors.New("Invalid version") + } + + return nil +} + +// @id EdgeUpdateScheduleUpdate +// @summary Updates an Edge Update Schedule +// @description **Access policy**: administrator +// @tags edge_update_schedules +// @security ApiKeyAuth +// @security jwt +// @accept json +// @param body body updatePayload true "Schedule details" +// @produce json +// @success 204 +// @failure 500 +// @router /edge_update_schedules [post] +func (handler *Handler) update(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + item, err := middlewares.FetchItem[edgetypes.UpdateSchedule](r, contextKey) + if err != nil { + return httperror.InternalServerError(err.Error(), err) + } + + var payload updatePayload + err = request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return httperror.BadRequest("Invalid request payload", err) + } + + if payload.Name != item.Name { + err = handler.validateUniqueName(payload.Name, item.ID) + if err != nil { + return httperror.NewError(http.StatusConflict, "Edge update schedule name already in use", err) + } + + item.Name = payload.Name + } + + // if scheduled time didn't passed, then can update the schedule + if item.Time > time.Now().Unix() { + item.GroupIDs = payload.GroupIDs + item.Time = payload.Time + item.Type = payload.Type + item.Version = payload.Version + } + + err = handler.dataStore.EdgeUpdateSchedule().Update(item.ID, item) + if err != nil { + return httperror.InternalServerError("Unable to persist the edge update schedule", err) + } + + return response.JSON(w, item) +} diff --git a/api/http/handler/edgeupdateschedules/handler.go b/api/http/handler/edgeupdateschedules/handler.go new file mode 100644 index 000000000..39c8c4c9e --- /dev/null +++ b/api/http/handler/edgeupdateschedules/handler.go @@ -0,0 +1,58 @@ +package edgeupdateschedules + +import ( + "net/http" + + httperror "github.com/portainer/libhttp/error" + + "github.com/gorilla/mux" + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/edgetypes" + "github.com/portainer/portainer/api/http/middlewares" + "github.com/portainer/portainer/api/http/security" +) + +const contextKey = "edgeUpdateSchedule_item" + +// Handler is the HTTP handler used to handle edge environment(endpoint) operations. +type Handler struct { + *mux.Router + requestBouncer *security.RequestBouncer + dataStore dataservices.DataStore +} + +// NewHandler creates a handler to manage environment(endpoint) operations. +func NewHandler(bouncer *security.RequestBouncer, dataStore dataservices.DataStore) *Handler { + h := &Handler{ + Router: mux.NewRouter(), + requestBouncer: bouncer, + dataStore: dataStore, + } + + router := h.PathPrefix("/edge_update_schedules").Subrouter() + router.Use(bouncer.AdminAccess) + router.Use(middlewares.FeatureFlag(dataStore.Settings(), portainer.FeatureFlagEdgeRemoteUpdate)) + + router.Handle("", + httperror.LoggerHandler(h.list)).Methods(http.MethodGet) + + router.Handle("", + httperror.LoggerHandler(h.create)).Methods(http.MethodPost) + + itemRouter := router.PathPrefix("/{id}").Subrouter() + itemRouter.Use(middlewares.WithItem(func(id edgetypes.UpdateScheduleID) (*edgetypes.UpdateSchedule, error) { + return dataStore.EdgeUpdateSchedule().Item(id) + }, "id", contextKey)) + + itemRouter.Handle("", + httperror.LoggerHandler(h.inspect)).Methods(http.MethodGet) + + itemRouter.Handle("", + httperror.LoggerHandler(h.update)).Methods(http.MethodPut) + + itemRouter.Handle("", + httperror.LoggerHandler(h.delete)).Methods(http.MethodDelete) + + return h +} diff --git a/api/http/handler/edgeupdateschedules/utils.go b/api/http/handler/edgeupdateschedules/utils.go new file mode 100644 index 000000000..10141df32 --- /dev/null +++ b/api/http/handler/edgeupdateschedules/utils.go @@ -0,0 +1,21 @@ +package edgeupdateschedules + +import ( + "github.com/pkg/errors" + "github.com/portainer/portainer/api/edgetypes" +) + +func (handler *Handler) validateUniqueName(name string, id edgetypes.UpdateScheduleID) error { + list, err := handler.dataStore.EdgeUpdateSchedule().List() + if err != nil { + return errors.WithMessage(err, "Unable to list edge update schedules") + } + + for _, schedule := range list { + if id != schedule.ID && schedule.Name == name { + return errors.New("Edge update schedule name already in use") + } + } + + return nil +} diff --git a/api/http/handler/handler.go b/api/http/handler/handler.go index 3a635e9b6..040f5a003 100644 --- a/api/http/handler/handler.go +++ b/api/http/handler/handler.go @@ -12,6 +12,7 @@ import ( "github.com/portainer/portainer/api/http/handler/edgejobs" "github.com/portainer/portainer/api/http/handler/edgestacks" "github.com/portainer/portainer/api/http/handler/edgetemplates" + "github.com/portainer/portainer/api/http/handler/edgeupdateschedules" "github.com/portainer/portainer/api/http/handler/endpointedge" "github.com/portainer/portainer/api/http/handler/endpointgroups" "github.com/portainer/portainer/api/http/handler/endpointproxy" @@ -43,42 +44,43 @@ import ( // Handler is a collection of all the service handlers. type Handler struct { - AuthHandler *auth.Handler - BackupHandler *backup.Handler - CustomTemplatesHandler *customtemplates.Handler - DockerHandler *docker.Handler - EdgeGroupsHandler *edgegroups.Handler - EdgeJobsHandler *edgejobs.Handler - EdgeStacksHandler *edgestacks.Handler - EdgeTemplatesHandler *edgetemplates.Handler - EndpointEdgeHandler *endpointedge.Handler - EndpointGroupHandler *endpointgroups.Handler - EndpointHandler *endpoints.Handler - EndpointHelmHandler *helm.Handler - EndpointProxyHandler *endpointproxy.Handler - HelmTemplatesHandler *helm.Handler - KubernetesHandler *kubernetes.Handler - FileHandler *file.Handler - LDAPHandler *ldap.Handler - MOTDHandler *motd.Handler - RegistryHandler *registries.Handler - ResourceControlHandler *resourcecontrols.Handler - RoleHandler *roles.Handler - SettingsHandler *settings.Handler - SSLHandler *ssl.Handler - OpenAMTHandler *openamt.Handler - FDOHandler *fdo.Handler - StackHandler *stacks.Handler - StatusHandler *status.Handler - StorybookHandler *storybook.Handler - TagHandler *tags.Handler - TeamMembershipHandler *teammemberships.Handler - TeamHandler *teams.Handler - TemplatesHandler *templates.Handler - UploadHandler *upload.Handler - UserHandler *users.Handler - WebSocketHandler *websocket.Handler - WebhookHandler *webhooks.Handler + AuthHandler *auth.Handler + BackupHandler *backup.Handler + CustomTemplatesHandler *customtemplates.Handler + DockerHandler *docker.Handler + EdgeGroupsHandler *edgegroups.Handler + EdgeJobsHandler *edgejobs.Handler + EdgeUpdateScheduleHandler *edgeupdateschedules.Handler + EdgeStacksHandler *edgestacks.Handler + EdgeTemplatesHandler *edgetemplates.Handler + EndpointEdgeHandler *endpointedge.Handler + EndpointGroupHandler *endpointgroups.Handler + EndpointHandler *endpoints.Handler + EndpointHelmHandler *helm.Handler + EndpointProxyHandler *endpointproxy.Handler + HelmTemplatesHandler *helm.Handler + KubernetesHandler *kubernetes.Handler + FileHandler *file.Handler + LDAPHandler *ldap.Handler + MOTDHandler *motd.Handler + RegistryHandler *registries.Handler + ResourceControlHandler *resourcecontrols.Handler + RoleHandler *roles.Handler + SettingsHandler *settings.Handler + SSLHandler *ssl.Handler + OpenAMTHandler *openamt.Handler + FDOHandler *fdo.Handler + StackHandler *stacks.Handler + StatusHandler *status.Handler + StorybookHandler *storybook.Handler + TagHandler *tags.Handler + TeamMembershipHandler *teammemberships.Handler + TeamHandler *teams.Handler + TemplatesHandler *templates.Handler + UploadHandler *upload.Handler + UserHandler *users.Handler + WebSocketHandler *websocket.Handler + WebhookHandler *webhooks.Handler } // @title PortainerCE API @@ -167,6 +169,8 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { http.StripPrefix("/api", h.BackupHandler).ServeHTTP(w, r) case strings.HasPrefix(r.URL.Path, "/api/custom_templates"): http.StripPrefix("/api", h.CustomTemplatesHandler).ServeHTTP(w, r) + case strings.HasPrefix(r.URL.Path, "/api/edge_update_schedules"): + http.StripPrefix("/api", h.EdgeUpdateScheduleHandler).ServeHTTP(w, r) case strings.HasPrefix(r.URL.Path, "/api/edge_stacks"): http.StripPrefix("/api", h.EdgeStacksHandler).ServeHTTP(w, r) case strings.HasPrefix(r.URL.Path, "/api/edge_groups"): diff --git a/api/http/middlewares/featureflag.go b/api/http/middlewares/featureflag.go new file mode 100644 index 000000000..ec6a033af --- /dev/null +++ b/api/http/middlewares/featureflag.go @@ -0,0 +1,25 @@ +package middlewares + +import ( + "net/http" + + "github.com/gorilla/mux" + httperror "github.com/portainer/libhttp/error" + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" +) + +func FeatureFlag(settingsService dataservices.SettingsService, feature portainer.Feature) mux.MiddlewareFunc { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, request *http.Request) { + enabled := settingsService.IsFeatureFlagEnabled(feature) + + if !enabled { + httperror.WriteError(rw, http.StatusForbidden, "This feature is not enabled", nil) + return + } + + next.ServeHTTP(rw, request) + }) + } +} diff --git a/api/http/middlewares/withitem.go b/api/http/middlewares/withitem.go new file mode 100644 index 000000000..c42d95749 --- /dev/null +++ b/api/http/middlewares/withitem.go @@ -0,0 +1,59 @@ +package middlewares + +import ( + "context" + "errors" + "net/http" + + "github.com/gorilla/mux" + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" + bolterrors "github.com/portainer/portainer/api/dataservices/errors" +) + +type ItemContextKey string + +type ItemGetter[TId ~int, TObject any] func(id TId) (*TObject, error) + +func WithItem[TId ~int, TObject any](getter ItemGetter[TId, TObject], idParam string, contextKey ItemContextKey) mux.MiddlewareFunc { + if idParam == "" { + idParam = "id" + } + + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + itemId, err := request.RetrieveNumericRouteVariableValue(req, idParam) + if err != nil { + httperror.WriteError(rw, http.StatusBadRequest, "Invalid identifier route variable", err) + return + } + + item, err := getter(TId(itemId)) + if err != nil { + statusCode := http.StatusInternalServerError + if err == bolterrors.ErrObjectNotFound { + statusCode = http.StatusNotFound + } + httperror.WriteError(rw, statusCode, "Unable to find a object with the specified identifier inside the database", err) + + return + } + ctx := context.WithValue(req.Context(), contextKey, item) + next.ServeHTTP(rw, req.WithContext(ctx)) + }) + } +} + +func FetchItem[T any](request *http.Request, contextKey string) (*T, error) { + contextData := request.Context().Value(contextKey) + if contextData == nil { + return nil, errors.New("unable to find item in request context") + } + + item, ok := contextData.(*T) + if !ok { + return nil, errors.New("unable to cast context item") + } + + return item, nil +} diff --git a/api/http/server.go b/api/http/server.go index 600cfd762..46b9d0aad 100644 --- a/api/http/server.go +++ b/api/http/server.go @@ -26,6 +26,7 @@ import ( "github.com/portainer/portainer/api/http/handler/edgejobs" "github.com/portainer/portainer/api/http/handler/edgestacks" "github.com/portainer/portainer/api/http/handler/edgetemplates" + "github.com/portainer/portainer/api/http/handler/edgeupdateschedules" "github.com/portainer/portainer/api/http/handler/endpointedge" "github.com/portainer/portainer/api/http/handler/endpointgroups" "github.com/portainer/portainer/api/http/handler/endpointproxy" @@ -152,6 +153,8 @@ func (server *Server) Start() error { edgeJobsHandler.FileService = server.FileService edgeJobsHandler.ReverseTunnelService = server.ReverseTunnelService + edgeUpdateScheduleHandler := edgeupdateschedules.NewHandler(requestBouncer, server.DataStore) + var edgeStacksHandler = edgestacks.NewHandler(requestBouncer, server.DataStore) edgeStacksHandler.FileService = server.FileService edgeStacksHandler.GitService = server.GitService @@ -274,42 +277,43 @@ func (server *Server) Start() error { webhookHandler.DockerClientFactory = server.DockerClientFactory server.Handler = &handler.Handler{ - RoleHandler: roleHandler, - AuthHandler: authHandler, - BackupHandler: backupHandler, - CustomTemplatesHandler: customTemplatesHandler, - DockerHandler: dockerHandler, - EdgeGroupsHandler: edgeGroupsHandler, - EdgeJobsHandler: edgeJobsHandler, - EdgeStacksHandler: edgeStacksHandler, - EdgeTemplatesHandler: edgeTemplatesHandler, - EndpointGroupHandler: endpointGroupHandler, - EndpointHandler: endpointHandler, - EndpointHelmHandler: endpointHelmHandler, - EndpointEdgeHandler: endpointEdgeHandler, - EndpointProxyHandler: endpointProxyHandler, - FileHandler: fileHandler, - LDAPHandler: ldapHandler, - HelmTemplatesHandler: helmTemplatesHandler, - KubernetesHandler: kubernetesHandler, - MOTDHandler: motdHandler, - OpenAMTHandler: openAMTHandler, - FDOHandler: fdoHandler, - RegistryHandler: registryHandler, - ResourceControlHandler: resourceControlHandler, - SettingsHandler: settingsHandler, - SSLHandler: sslHandler, - StatusHandler: statusHandler, - StackHandler: stackHandler, - StorybookHandler: storybookHandler, - TagHandler: tagHandler, - TeamHandler: teamHandler, - TeamMembershipHandler: teamMembershipHandler, - TemplatesHandler: templatesHandler, - UploadHandler: uploadHandler, - UserHandler: userHandler, - WebSocketHandler: websocketHandler, - WebhookHandler: webhookHandler, + RoleHandler: roleHandler, + AuthHandler: authHandler, + BackupHandler: backupHandler, + CustomTemplatesHandler: customTemplatesHandler, + DockerHandler: dockerHandler, + EdgeGroupsHandler: edgeGroupsHandler, + EdgeJobsHandler: edgeJobsHandler, + EdgeUpdateScheduleHandler: edgeUpdateScheduleHandler, + EdgeStacksHandler: edgeStacksHandler, + EdgeTemplatesHandler: edgeTemplatesHandler, + EndpointGroupHandler: endpointGroupHandler, + EndpointHandler: endpointHandler, + EndpointHelmHandler: endpointHelmHandler, + EndpointEdgeHandler: endpointEdgeHandler, + EndpointProxyHandler: endpointProxyHandler, + FileHandler: fileHandler, + LDAPHandler: ldapHandler, + HelmTemplatesHandler: helmTemplatesHandler, + KubernetesHandler: kubernetesHandler, + MOTDHandler: motdHandler, + OpenAMTHandler: openAMTHandler, + FDOHandler: fdoHandler, + RegistryHandler: registryHandler, + ResourceControlHandler: resourceControlHandler, + SettingsHandler: settingsHandler, + SSLHandler: sslHandler, + StatusHandler: statusHandler, + StackHandler: stackHandler, + StorybookHandler: storybookHandler, + TagHandler: tagHandler, + TeamHandler: teamHandler, + TeamMembershipHandler: teamMembershipHandler, + TemplatesHandler: templatesHandler, + UploadHandler: uploadHandler, + UserHandler: userHandler, + WebSocketHandler: websocketHandler, + WebhookHandler: webhookHandler, } handler := adminMonitor.WithRedirect(offlineGate.WaitingMiddleware(time.Minute, server.Handler)) diff --git a/api/internal/testhelpers/datastore.go b/api/internal/testhelpers/datastore.go index 95f670604..642a03c1e 100644 --- a/api/internal/testhelpers/datastore.go +++ b/api/internal/testhelpers/datastore.go @@ -12,6 +12,7 @@ type testDatastore struct { customTemplate dataservices.CustomTemplateService edgeGroup dataservices.EdgeGroupService edgeJob dataservices.EdgeJobService + edgeUpdateSchedule dataservices.EdgeUpdateScheduleService edgeStack dataservices.EdgeStackService endpoint dataservices.EndpointService endpointGroup dataservices.EndpointGroupService @@ -47,6 +48,9 @@ func (d *testDatastore) EdgeJob() dataservices.EdgeJobService { re func (d *testDatastore) EdgeStack() dataservices.EdgeStackService { return d.edgeStack } func (d *testDatastore) Endpoint() dataservices.EndpointService { return d.endpoint } func (d *testDatastore) EndpointGroup() dataservices.EndpointGroupService { return d.endpointGroup } +func (d *testDatastore) EdgeUpdateSchedule() dataservices.EdgeUpdateScheduleService { + return d.edgeUpdateSchedule +} func (d *testDatastore) FDOProfile() dataservices.FDOProfileService { return d.fdoProfile } diff --git a/api/portainer.go b/api/portainer.go index ba65a96d3..10459e5c6 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -254,7 +254,8 @@ type ( EdgeJobLogsStatus int // EdgeSchedule represents a scheduled job that can run on Edge environments(endpoints). - // Deprecated in favor of EdgeJob + // + // Deprecated: in favor of EdgeJob EdgeSchedule struct { // EdgeSchedule Identifier ID ScheduleID `json:"Id" example:"1"` @@ -1449,8 +1450,12 @@ const ( WebSocketKeepAlive = 1 * time.Hour ) +const FeatureFlagEdgeRemoteUpdate Feature = "edgeRemoteUpdate" + // List of supported features -var SupportedFeatureFlags = []Feature{} +var SupportedFeatureFlags = []Feature{ + FeatureFlagEdgeRemoteUpdate, +} const ( _ AuthenticationMethod = iota diff --git a/app/portainer/feature-flags/useRedirectFeatureFlag.ts b/app/portainer/feature-flags/useRedirectFeatureFlag.ts new file mode 100644 index 000000000..5aac02015 --- /dev/null +++ b/app/portainer/feature-flags/useRedirectFeatureFlag.ts @@ -0,0 +1,32 @@ +import { useRouter } from '@uirouter/react'; + +import { usePublicSettings } from '../settings/queries'; + +export enum FeatureFlag { + EdgeRemoteUpdate = 'edgeRemoteUpdate', +} + +export function useFeatureFlag( + flag: FeatureFlag, + { onSuccess }: { onSuccess?: (isEnabled: boolean) => void } = {} +) { + return usePublicSettings({ + select: (settings) => settings.Features[flag], + onSuccess, + }); +} + +export function useRedirectFeatureFlag( + flag: FeatureFlag, + to = 'portainer.home' +) { + const router = useRouter(); + + useFeatureFlag(flag, { + onSuccess(isEnabled) { + if (!isEnabled) { + router.stateService.go(to); + } + }, + }); +} diff --git a/app/portainer/models/settings.js b/app/portainer/models/settings.js index 6f7a2ee09..59b82df04 100644 --- a/app/portainer/models/settings.js +++ b/app/portainer/models/settings.js @@ -28,7 +28,6 @@ export function PublicSettingsViewModel(settings) { this.RequiredPasswordLength = settings.RequiredPasswordLength; this.EnableEdgeComputeFeatures = settings.EnableEdgeComputeFeatures; this.EnforceEdgeID = settings.EnforceEdgeID; - this.FeatureFlagSettings = settings.FeatureFlagSettings; this.LogoURL = settings.LogoURL; this.OAuthLoginURI = settings.OAuthLoginURI; this.EnableTelemetry = settings.EnableTelemetry; diff --git a/app/portainer/react/views/index.ts b/app/portainer/react/views/index.ts index 80bfb0b48..b92068e97 100644 --- a/app/portainer/react/views/index.ts +++ b/app/portainer/react/views/index.ts @@ -10,9 +10,14 @@ import { import { wizardModule } from './wizard'; import { teamsModule } from './teams'; +import { updateSchedulesModule } from './update-schedules'; export const viewsModule = angular - .module('portainer.app.react.views', [wizardModule, teamsModule]) + .module('portainer.app.react.views', [ + wizardModule, + teamsModule, + updateSchedulesModule, + ]) .component('defaultRegistryName', r2a(DefaultRegistryName, [])) .component('defaultRegistryAction', r2a(DefaultRegistryAction, [])) .component('defaultRegistryDomain', r2a(DefaultRegistryDomain, [])) diff --git a/app/portainer/react/views/update-schedules.ts b/app/portainer/react/views/update-schedules.ts new file mode 100644 index 000000000..dd5a39dde --- /dev/null +++ b/app/portainer/react/views/update-schedules.ts @@ -0,0 +1,48 @@ +import angular from 'angular'; +import { StateRegistry } from '@uirouter/angularjs'; + +import { r2a } from '@/react-tools/react2angular'; +import { + ListView, + CreateView, + ItemView, +} from '@/react/portainer/environments/update-schedules'; + +export const updateSchedulesModule = angular + .module('portainer.edge.updateSchedules', []) + .component('updateSchedulesListView', r2a(ListView, [])) + .component('updateSchedulesCreateView', r2a(CreateView, [])) + .component('updateSchedulesItemView', r2a(ItemView, [])) + .config(config).name; + +function config($stateRegistryProvider: StateRegistry) { + $stateRegistryProvider.register({ + name: 'portainer.endpoints.updateSchedules', + url: '/update-schedules', + views: { + 'content@': { + component: 'updateSchedulesListView', + }, + }, + }); + + $stateRegistryProvider.register({ + name: 'portainer.endpoints.updateSchedules.create', + url: '/update-schedules/new', + views: { + 'content@': { + component: 'updateSchedulesCreateView', + }, + }, + }); + + $stateRegistryProvider.register({ + name: 'portainer.endpoints.updateSchedules.item', + url: '/update-schedules/:id', + views: { + 'content@': { + component: 'updateSchedulesItemView', + }, + }, + }); +} diff --git a/app/portainer/settings/queries.ts b/app/portainer/settings/queries.ts index e8f358fa2..157e35466 100644 --- a/app/portainer/settings/queries.ts +++ b/app/portainer/settings/queries.ts @@ -19,14 +19,17 @@ import { DefaultRegistry, Settings } from './types'; export function usePublicSettings({ enabled, select, + onSuccess, }: { select?: (settings: PublicSettingsViewModel) => T; enabled?: boolean; + onSuccess?: (data: T) => void; } = {}) { return useQuery(['settings', 'public'], () => getPublicSettings(), { select, ...withError('Unable to retrieve public settings'), enabled, + onSuccess, }); } diff --git a/app/react/components/NavTabs/NavTabs.module.css b/app/react/components/NavTabs/NavTabs.module.css index edb83d1ac..bbcaaeafb 100644 --- a/app/react/components/NavTabs/NavTabs.module.css +++ b/app/react/components/NavTabs/NavTabs.module.css @@ -6,9 +6,9 @@ } .parent a { - background-color: initial !important; - border: 1px solid transparent !important; - cursor: inherit !important; + background-color: initial; + border: 1px solid transparent; + cursor: inherit; } .parent { @@ -22,11 +22,11 @@ } .parent a { - color: var(--white-color) !important; + color: var(--white-color); } :global([theme='dark']) .parent a { - color: var(--black-color) !important; + color: var(--black-color); } :global([theme='highcontrast']) .parent a { - color: var(--black-color) !important; + color: var(--black-color); } diff --git a/app/react/components/NavTabs/NavTabs.stories.tsx b/app/react/components/NavTabs/NavTabs.stories.tsx index 4a918c9ba..fc80463f4 100644 --- a/app/react/components/NavTabs/NavTabs.stories.tsx +++ b/app/react/components/NavTabs/NavTabs.stories.tsx @@ -18,7 +18,11 @@ function Template({ options = [] }: Args) { ); return ( - + setSelected(value)} + /> ); } diff --git a/app/react/components/NavTabs/NavTabs.tsx b/app/react/components/NavTabs/NavTabs.tsx index 2c2d2257d..e079a7927 100644 --- a/app/react/components/NavTabs/NavTabs.tsx +++ b/app/react/components/NavTabs/NavTabs.tsx @@ -3,19 +3,25 @@ import { ReactNode } from 'react'; import styles from './NavTabs.module.css'; -export interface Option { +export interface Option { label: string | ReactNode; children?: ReactNode; - id: string | number; + id: T; } -interface Props { - options: Option[]; - selectedId?: string | number; - onSelect?(id: string | number): void; +interface Props { + options: Option[]; + selectedId?: T; + onSelect?(id: T): void; + disabled?: boolean; } -export function NavTabs({ options, selectedId, onSelect = () => {} }: Props) { +export function NavTabs({ + options, + selectedId, + onSelect = () => {}, + disabled, +}: Props) { const selected = options.find((option) => option.id === selectedId); return ( @@ -52,7 +58,11 @@ export function NavTabs({ options, selectedId, onSelect = () => {} }: Props) { ); - function handleSelect(option: Option) { + function handleSelect(option: Option) { + if (disabled) { + return; + } + if (option.children) { onSelect(option.id); } diff --git a/app/react/components/datatables/Datatable.tsx b/app/react/components/datatables/Datatable.tsx index e8aa36c99..edb22a6a8 100644 --- a/app/react/components/datatables/Datatable.tsx +++ b/app/react/components/datatables/Datatable.tsx @@ -13,6 +13,7 @@ import { ReactNode } from 'react'; import { useRowSelectColumn } from '@lineup-lite/hooks'; import { PaginationControls } from '@@/PaginationControls'; +import { IconProps } from '@@/Icon'; import { Table } from './Table'; import { multiple } from './filter-types'; @@ -28,7 +29,8 @@ interface DefaultTableSettings interface TitleOptionsVisible { title: string; - icon?: string; + icon?: IconProps['icon']; + featherIcon?: IconProps['featherIcon']; hide?: never; } diff --git a/app/react/edge/edge-groups/queries/useEdgeGroups.ts b/app/react/edge/edge-groups/queries/useEdgeGroups.ts new file mode 100644 index 000000000..d70fe02ba --- /dev/null +++ b/app/react/edge/edge-groups/queries/useEdgeGroups.ts @@ -0,0 +1,18 @@ +import { useQuery } from 'react-query'; + +import axios, { parseAxiosError } from '@/portainer/services/axios'; + +import { EdgeGroup } from '../types'; + +async function getEdgeGroups() { + try { + const { data } = await axios.get('/edge_groups'); + return data; + } catch (err) { + throw parseAxiosError(err as Error, 'Failed fetching edge groups'); + } +} + +export function useEdgeGroups() { + return useQuery(['edge', 'groups'], getEdgeGroups); +} diff --git a/app/react/edge/edge-groups/types.ts b/app/react/edge/edge-groups/types.ts new file mode 100644 index 000000000..d08cb3282 --- /dev/null +++ b/app/react/edge/edge-groups/types.ts @@ -0,0 +1,11 @@ +import { EnvironmentId } from '@/portainer/environments/types'; +import { TagId } from '@/portainer/tags/types'; + +export interface EdgeGroup { + Id: number; + Name: string; + Dynamic: boolean; + TagIds: TagId[]; + Endpoints: EnvironmentId[]; + PartialMatch: boolean; +} diff --git a/app/react/portainer/environments/update-schedules/CreateView/CreateView.tsx b/app/react/portainer/environments/update-schedules/CreateView/CreateView.tsx new file mode 100644 index 000000000..3cc46518c --- /dev/null +++ b/app/react/portainer/environments/update-schedules/CreateView/CreateView.tsx @@ -0,0 +1,96 @@ +import { Settings } from 'react-feather'; +import { Formik, Form as FormikForm } from 'formik'; +import { useRouter } from '@uirouter/react'; + +import { notifySuccess } from '@/portainer/services/notifications'; +import { + useRedirectFeatureFlag, + FeatureFlag, +} from '@/portainer/feature-flags/useRedirectFeatureFlag'; + +import { PageHeader } from '@@/PageHeader'; +import { Widget } from '@@/Widget'; +import { LoadingButton } from '@@/buttons'; + +import { ScheduleType } from '../types'; +import { useCreateMutation } from '../queries/create'; +import { FormValues } from '../common/types'; +import { validation } from '../common/validation'; +import { UpdateTypeTabs } from '../common/UpdateTypeTabs'; +import { useList } from '../queries/list'; +import { EdgeGroupsField } from '../common/EdgeGroupsField'; +import { NameField } from '../common/NameField'; + +const initialValues: FormValues = { + name: '', + groupIds: [], + type: ScheduleType.Update, + version: 'latest', + time: Math.floor(Date.now() / 1000) + 60 * 60, +}; + +export function CreateView() { + useRedirectFeatureFlag(FeatureFlag.EdgeRemoteUpdate); + const schedulesQuery = useList(); + + const createMutation = useCreateMutation(); + const router = useRouter(); + + if (!schedulesQuery.data) { + return null; + } + + const schedules = schedulesQuery.data; + + return ( + <> + + +
+
+ + + + { + createMutation.mutate(values, { + onSuccess() { + notifySuccess('Success', 'Created schedule successfully'); + router.stateService.go('^'); + }, + }); + }} + validateOnMount + validationSchema={() => validation(schedules)} + > + {({ isValid }) => ( + + + + + +
+
+ + Create Schedule + +
+
+
+ )} +
+
+
+
+
+ + ); +} diff --git a/app/react/portainer/environments/update-schedules/CreateView/index.ts b/app/react/portainer/environments/update-schedules/CreateView/index.ts new file mode 100644 index 000000000..74e592112 --- /dev/null +++ b/app/react/portainer/environments/update-schedules/CreateView/index.ts @@ -0,0 +1 @@ +export { CreateView } from './CreateView'; diff --git a/app/react/portainer/environments/update-schedules/ItemView/ItemView.tsx b/app/react/portainer/environments/update-schedules/ItemView/ItemView.tsx new file mode 100644 index 000000000..f6bde0b04 --- /dev/null +++ b/app/react/portainer/environments/update-schedules/ItemView/ItemView.tsx @@ -0,0 +1,124 @@ +import { Settings } from 'react-feather'; +import { Formik, Form as FormikForm } from 'formik'; +import { useCurrentStateAndParams, useRouter } from '@uirouter/react'; +import { useMemo } from 'react'; +import { object, SchemaOf } from 'yup'; + +import { notifySuccess } from '@/portainer/services/notifications'; +import { + useRedirectFeatureFlag, + FeatureFlag, +} from '@/portainer/feature-flags/useRedirectFeatureFlag'; + +import { PageHeader } from '@@/PageHeader'; +import { Widget } from '@@/Widget'; +import { LoadingButton } from '@@/buttons'; + +import { UpdateTypeTabs } from '../common/UpdateTypeTabs'; +import { useItem } from '../queries/useItem'; +import { validation } from '../common/validation'; +import { useUpdateMutation } from '../queries/useUpdateMutation'; +import { useList } from '../queries/list'; +import { NameField, nameValidation } from '../common/NameField'; +import { EdgeGroupsField } from '../common/EdgeGroupsField'; +import { EdgeUpdateSchedule } from '../types'; +import { FormValues } from '../common/types'; + +export function ItemView() { + useRedirectFeatureFlag(FeatureFlag.EdgeRemoteUpdate); + + const { + params: { id: idParam }, + } = useCurrentStateAndParams(); + + const id = parseInt(idParam, 10); + + if (!idParam || Number.isNaN(id)) { + throw new Error('id is a required path param'); + } + + const updateMutation = useUpdateMutation(); + const router = useRouter(); + const itemQuery = useItem(id); + const schedulesQuery = useList(); + + const isDisabled = useMemo( + () => (itemQuery.data ? itemQuery.data.time < Date.now() / 1000 : false), + [itemQuery.data] + ); + + if (!itemQuery.data || !schedulesQuery.data) { + return null; + } + + const item = itemQuery.data; + const schedules = schedulesQuery.data; + return ( + <> + + +
+
+ + + + { + updateMutation.mutate( + { id, values }, + { + onSuccess() { + notifySuccess( + 'Success', + 'Updated schedule successfully' + ); + router.stateService.go('^'); + }, + } + ); + }} + validateOnMount + validationSchema={() => updateValidation(item, schedules)} + > + {({ isValid }) => ( + + + + + + + +
+
+ + Update Schedule + +
+
+
+ )} +
+
+
+
+
+ + ); +} + +function updateValidation( + item: EdgeUpdateSchedule, + schedules: EdgeUpdateSchedule[] +): SchemaOf<{ name: string } | FormValues> { + return item.time > Date.now() / 1000 + ? validation(schedules, item.id) + : object({ name: nameValidation(schedules, item.id) }); +} diff --git a/app/react/portainer/environments/update-schedules/ItemView/index.ts b/app/react/portainer/environments/update-schedules/ItemView/index.ts new file mode 100644 index 000000000..a09ab2dde --- /dev/null +++ b/app/react/portainer/environments/update-schedules/ItemView/index.ts @@ -0,0 +1 @@ +export { ItemView } from './ItemView'; diff --git a/app/react/portainer/environments/update-schedules/ListView/ListView.tsx b/app/react/portainer/environments/update-schedules/ListView/ListView.tsx new file mode 100644 index 000000000..c57416aac --- /dev/null +++ b/app/react/portainer/environments/update-schedules/ListView/ListView.tsx @@ -0,0 +1,99 @@ +import { Clock, Trash2 } from 'react-feather'; + +import { + FeatureFlag, + useRedirectFeatureFlag, +} from '@/portainer/feature-flags/useRedirectFeatureFlag'; +import { notifySuccess } from '@/portainer/services/notifications'; +import { confirmDeletionAsync } from '@/portainer/services/modal.service/confirm'; + +import { Datatable } from '@@/datatables'; +import { PageHeader } from '@@/PageHeader'; +import { Button } from '@@/buttons'; +import { Link } from '@@/Link'; + +import { useList } from '../queries/list'; +import { EdgeUpdateSchedule } from '../types'; +import { useRemoveMutation } from '../queries/useRemoveMutation'; + +import { columns } from './columns'; +import { createStore } from './datatable-store'; + +const storageKey = 'update-schedules-list'; +const useStore = createStore(storageKey); + +export function ListView() { + useRedirectFeatureFlag(FeatureFlag.EdgeRemoteUpdate); + const listQuery = useList(); + const store = useStore(); + + if (!listQuery.data) { + return null; + } + + return ( + <> + + + ( + + )} + /> + + ); +} + +function TableActions({ + selectedRows, +}: { + selectedRows: EdgeUpdateSchedule[]; +}) { + const removeMutation = useRemoveMutation(); + return ( + <> + + + + + + + ); + + async function handleRemove() { + const confirmed = await confirmDeletionAsync( + 'Are you sure you want to remove these?' + ); + if (!confirmed) { + return; + } + + removeMutation.mutate(selectedRows, { + onSuccess: () => { + notifySuccess('Success', 'Schedules successfully removed'); + }, + }); + } +} diff --git a/app/react/portainer/environments/update-schedules/ListView/columns/created.tsx b/app/react/portainer/environments/update-schedules/ListView/columns/created.tsx new file mode 100644 index 000000000..5b4622359 --- /dev/null +++ b/app/react/portainer/environments/update-schedules/ListView/columns/created.tsx @@ -0,0 +1,13 @@ +import { Column } from 'react-table'; + +import { isoDateFromTimestamp } from '@/portainer/filters/filters'; + +import { EdgeUpdateSchedule } from '../../types'; + +export const created: Column = { + Header: 'Created', + accessor: (row) => isoDateFromTimestamp(row.created), + disableFilters: true, + Filter: () => null, + canHide: false, +}; diff --git a/app/react/portainer/environments/update-schedules/ListView/columns/groups.tsx b/app/react/portainer/environments/update-schedules/ListView/columns/groups.tsx new file mode 100644 index 000000000..aca5846e4 --- /dev/null +++ b/app/react/portainer/environments/update-schedules/ListView/columns/groups.tsx @@ -0,0 +1,29 @@ +import { CellProps, Column } from 'react-table'; +import _ from 'lodash'; + +import { EdgeGroup } from '@/react/edge/edge-groups/types'; +import { useEdgeGroups } from '@/react/edge/edge-groups/queries/useEdgeGroups'; + +import { EdgeUpdateSchedule } from '../../types'; + +export const groups: Column = { + Header: 'Groups', + accessor: 'groupIds', + Cell: GroupsCell, + disableFilters: true, + Filter: () => null, + canHide: false, + disableSortBy: true, +}; + +export function GroupsCell({ + value: groupsIds, +}: CellProps>) { + const groupsQuery = useEdgeGroups(); + + const groups = _.compact( + groupsIds.map((id) => groupsQuery.data?.find((g) => g.Id === id)) + ); + + return groups.map((g) => g.Name).join(', '); +} diff --git a/app/react/portainer/environments/update-schedules/ListView/columns/index.ts b/app/react/portainer/environments/update-schedules/ListView/columns/index.ts new file mode 100644 index 000000000..4eedb4102 --- /dev/null +++ b/app/react/portainer/environments/update-schedules/ListView/columns/index.ts @@ -0,0 +1,15 @@ +import { created } from './created'; +import { groups } from './groups'; +import { name } from './name'; +import { scheduleStatus } from './schedule-status'; +import { scheduledTime } from './scheduled-time'; +import { scheduleType } from './type'; + +export const columns = [ + name, + scheduledTime, + groups, + scheduleType, + scheduleStatus, + created, +]; diff --git a/app/react/portainer/environments/update-schedules/ListView/columns/name.tsx b/app/react/portainer/environments/update-schedules/ListView/columns/name.tsx new file mode 100644 index 000000000..de3b8bcd6 --- /dev/null +++ b/app/react/portainer/environments/update-schedules/ListView/columns/name.tsx @@ -0,0 +1,24 @@ +import { CellProps, Column } from 'react-table'; + +import { Link } from '@@/Link'; + +import { EdgeUpdateSchedule } from '../../types'; + +export const name: Column = { + Header: 'Name', + accessor: 'name', + id: 'name', + Cell: NameCell, + disableFilters: true, + Filter: () => null, + canHide: false, + sortType: 'string', +}; + +export function NameCell({ value: name, row }: CellProps) { + return ( + + {name} + + ); +} diff --git a/app/react/portainer/environments/update-schedules/ListView/columns/schedule-status.tsx b/app/react/portainer/environments/update-schedules/ListView/columns/schedule-status.tsx new file mode 100644 index 000000000..373d1cb76 --- /dev/null +++ b/app/react/portainer/environments/update-schedules/ListView/columns/schedule-status.tsx @@ -0,0 +1,44 @@ +import { CellProps, Column } from 'react-table'; + +import { EdgeUpdateSchedule, StatusType } from '../../types'; + +export const scheduleStatus: Column = { + Header: 'Status', + accessor: (row) => row.status, + disableFilters: true, + Filter: () => null, + canHide: false, + Cell: StatusCell, + disableSortBy: true, +}; + +function StatusCell({ + value: status, + row: { original: schedule }, +}: CellProps) { + if (schedule.time > Date.now() / 1000) { + return 'Scheduled'; + } + + const statusList = Object.entries(status).map( + ([environmentId, envStatus]) => ({ ...envStatus, environmentId }) + ); + + if (statusList.length === 0) { + return 'No related environments'; + } + + const error = statusList.find((s) => s.Type === StatusType.Failed); + + if (error) { + return `Failed: (ID: ${error.environmentId}) ${error.Error}`; + } + + const pending = statusList.find((s) => s.Type === StatusType.Pending); + + if (pending) { + return 'Pending'; + } + + return 'Success'; +} diff --git a/app/react/portainer/environments/update-schedules/ListView/columns/scheduled-time.tsx b/app/react/portainer/environments/update-schedules/ListView/columns/scheduled-time.tsx new file mode 100644 index 000000000..0c9640c25 --- /dev/null +++ b/app/react/portainer/environments/update-schedules/ListView/columns/scheduled-time.tsx @@ -0,0 +1,13 @@ +import { Column } from 'react-table'; + +import { isoDateFromTimestamp } from '@/portainer/filters/filters'; + +import { EdgeUpdateSchedule } from '../../types'; + +export const scheduledTime: Column = { + Header: 'Scheduled Time & Date', + accessor: (row) => isoDateFromTimestamp(row.time), + disableFilters: true, + Filter: () => null, + canHide: false, +}; diff --git a/app/react/portainer/environments/update-schedules/ListView/columns/type.tsx b/app/react/portainer/environments/update-schedules/ListView/columns/type.tsx new file mode 100644 index 000000000..681947f94 --- /dev/null +++ b/app/react/portainer/environments/update-schedules/ListView/columns/type.tsx @@ -0,0 +1,12 @@ +import { Column } from 'react-table'; + +import { EdgeUpdateSchedule, ScheduleType } from '../../types'; + +export const scheduleType: Column = { + Header: 'Type', + accessor: (row) => ScheduleType[row.type], + disableFilters: true, + Filter: () => null, + canHide: false, + sortType: 'string', +}; diff --git a/app/react/portainer/environments/update-schedules/ListView/datatable-store.ts b/app/react/portainer/environments/update-schedules/ListView/datatable-store.ts new file mode 100644 index 000000000..4a07d742e --- /dev/null +++ b/app/react/portainer/environments/update-schedules/ListView/datatable-store.ts @@ -0,0 +1,36 @@ +import create from 'zustand'; +import { persist } from 'zustand/middleware'; + +import { keyBuilder } from '@/portainer/hooks/useLocalStorage'; +import { + paginationSettings, + sortableSettings, + refreshableSettings, + hiddenColumnsSettings, + PaginationTableSettings, + RefreshableTableSettings, + SettableColumnsTableSettings, + SortableTableSettings, +} from '@/react/components/datatables/types'; + +interface TableSettings + extends SortableTableSettings, + PaginationTableSettings, + SettableColumnsTableSettings, + RefreshableTableSettings {} + +export function createStore(storageKey: string) { + return create()( + persist( + (set) => ({ + ...sortableSettings(set), + ...paginationSettings(set), + ...hiddenColumnsSettings(set), + ...refreshableSettings(set), + }), + { + name: keyBuilder(storageKey), + } + ) + ); +} diff --git a/app/react/portainer/environments/update-schedules/ListView/index.ts b/app/react/portainer/environments/update-schedules/ListView/index.ts new file mode 100644 index 000000000..dd06dfd19 --- /dev/null +++ b/app/react/portainer/environments/update-schedules/ListView/index.ts @@ -0,0 +1 @@ +export { ListView } from './ListView'; diff --git a/app/react/portainer/environments/update-schedules/common/EdgeGroupsField.tsx b/app/react/portainer/environments/update-schedules/common/EdgeGroupsField.tsx new file mode 100644 index 000000000..4b95e7f2f --- /dev/null +++ b/app/react/portainer/environments/update-schedules/common/EdgeGroupsField.tsx @@ -0,0 +1,42 @@ +import { useField } from 'formik'; + +import { useEdgeGroups } from '@/react/edge/edge-groups/queries/useEdgeGroups'; + +import { FormControl } from '@@/form-components/FormControl'; +import { Select } from '@@/form-components/ReactSelect'; + +import { FormValues } from './types'; + +interface Props { + disabled?: boolean; +} + +export function EdgeGroupsField({ disabled }: Props) { + const groupsQuery = useEdgeGroups(); + + const [{ name, onBlur, value }, { error }, { setValue }] = + useField('groupIds'); + + const selectedGroups = groupsQuery.data?.filter((group) => + value.includes(group.Id) + ); + + return ( + + + )} + + ); +} diff --git a/app/react/portainer/environments/update-schedules/common/UpdateTypeTabs.tsx b/app/react/portainer/environments/update-schedules/common/UpdateTypeTabs.tsx new file mode 100644 index 000000000..1398b8062 --- /dev/null +++ b/app/react/portainer/environments/update-schedules/common/UpdateTypeTabs.tsx @@ -0,0 +1,55 @@ +import { useField } from 'formik'; +import { number } from 'yup'; + +import { NavTabs } from '@@/NavTabs'; + +import { ScheduleType } from '../types'; + +import { FormValues } from './types'; +import { ScheduledTimeField } from './ScheduledTimeField'; + +interface Props { + disabled?: boolean; +} + +export function UpdateTypeTabs({ disabled }: Props) { + const [{ value }, , { setValue }] = useField('type'); + + return ( +
+
+ , + }, + { + id: ScheduleType.Rollback, + label: 'Rollback', + children: , + }, + ]} + selectedId={value} + onSelect={(value) => setValue(value)} + disabled={disabled} + /> +
+
+ ); +} + +function ScheduleDetails({ disabled }: Props) { + return ( +
+ +
+ ); +} + +export function typeValidation() { + return number() + .oneOf([ScheduleType.Rollback, ScheduleType.Update]) + .default(ScheduleType.Update); +} diff --git a/app/react/portainer/environments/update-schedules/common/types.ts b/app/react/portainer/environments/update-schedules/common/types.ts new file mode 100644 index 000000000..dcf7fb65f --- /dev/null +++ b/app/react/portainer/environments/update-schedules/common/types.ts @@ -0,0 +1,11 @@ +import { EdgeGroup } from '@/react/edge/edge-groups/types'; + +import { ScheduleType } from '../types'; + +export interface FormValues { + name: string; + groupIds: EdgeGroup['Id'][]; + type: ScheduleType; + version: string; + time: number; +} diff --git a/app/react/portainer/environments/update-schedules/common/validation.ts b/app/react/portainer/environments/update-schedules/common/validation.ts new file mode 100644 index 000000000..bde9f3362 --- /dev/null +++ b/app/react/portainer/environments/update-schedules/common/validation.ts @@ -0,0 +1,22 @@ +import { array, number, object, SchemaOf, string } from 'yup'; + +import { EdgeUpdateSchedule } from '../types'; + +import { nameValidation } from './NameField'; +import { FormValues } from './types'; +import { typeValidation } from './UpdateTypeTabs'; + +export function validation( + schedules: EdgeUpdateSchedule[], + currentId?: EdgeUpdateSchedule['id'] +): SchemaOf { + return object({ + groupIds: array().min(1, 'At least one group is required'), + name: nameValidation(schedules, currentId), + type: typeValidation(), + time: number() + .min(Date.now() / 1000) + .required(), + version: string().required(), + }); +} diff --git a/app/react/portainer/environments/update-schedules/index.ts b/app/react/portainer/environments/update-schedules/index.ts new file mode 100644 index 000000000..05a564662 --- /dev/null +++ b/app/react/portainer/environments/update-schedules/index.ts @@ -0,0 +1,3 @@ +export { ListView } from './ListView'; +export { CreateView } from './CreateView'; +export { ItemView } from './ItemView'; diff --git a/app/react/portainer/environments/update-schedules/queries/create.ts b/app/react/portainer/environments/update-schedules/queries/create.ts new file mode 100644 index 000000000..5ca763667 --- /dev/null +++ b/app/react/portainer/environments/update-schedules/queries/create.ts @@ -0,0 +1,31 @@ +import { useMutation, useQueryClient } from 'react-query'; + +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { withError, withInvalidate } from '@/react-tools/react-query'; + +import { EdgeUpdateSchedule } from '../types'; +import { FormValues } from '../common/types'; + +import { queryKeys } from './query-keys'; +import { buildUrl } from './urls'; + +async function create(schedule: FormValues) { + try { + const { data } = await axios.post(buildUrl(), schedule); + + return data; + } catch (err) { + throw parseAxiosError( + err as Error, + 'Failed to create edge update schedule' + ); + } +} + +export function useCreateMutation() { + const queryClient = useQueryClient(); + return useMutation(create, { + ...withInvalidate(queryClient, [queryKeys.list()]), + ...withError(), + }); +} diff --git a/app/react/portainer/environments/update-schedules/queries/list.ts b/app/react/portainer/environments/update-schedules/queries/list.ts new file mode 100644 index 000000000..b5d67f331 --- /dev/null +++ b/app/react/portainer/environments/update-schedules/queries/list.ts @@ -0,0 +1,24 @@ +import { useQuery } from 'react-query'; + +import axios, { parseAxiosError } from '@/portainer/services/axios'; + +import { EdgeUpdateSchedule } from '../types'; + +import { queryKeys } from './query-keys'; +import { buildUrl } from './urls'; + +async function getList() { + try { + const { data } = await axios.get(buildUrl()); + return data; + } catch (err) { + throw parseAxiosError( + err as Error, + 'Failed to get list of edge update schedules' + ); + } +} + +export function useList() { + return useQuery(queryKeys.list(), getList); +} diff --git a/app/react/portainer/environments/update-schedules/queries/query-keys.ts b/app/react/portainer/environments/update-schedules/queries/query-keys.ts new file mode 100644 index 000000000..a6acf96be --- /dev/null +++ b/app/react/portainer/environments/update-schedules/queries/query-keys.ts @@ -0,0 +1,6 @@ +import { EdgeUpdateSchedule } from '../types'; + +export const queryKeys = { + list: () => ['edge', 'update_schedules'] as const, + item: (id: EdgeUpdateSchedule['id']) => [...queryKeys.list(), id] as const, +}; diff --git a/app/react/portainer/environments/update-schedules/queries/urls.ts b/app/react/portainer/environments/update-schedules/queries/urls.ts new file mode 100644 index 000000000..93f7600f4 --- /dev/null +++ b/app/react/portainer/environments/update-schedules/queries/urls.ts @@ -0,0 +1,7 @@ +import { EdgeUpdateSchedule } from '../types'; + +export const BASE_URL = '/edge_update_schedules'; + +export function buildUrl(id?: EdgeUpdateSchedule['id']) { + return !id ? BASE_URL : `${BASE_URL}/${id}`; +} diff --git a/app/react/portainer/environments/update-schedules/queries/useItem.ts b/app/react/portainer/environments/update-schedules/queries/useItem.ts new file mode 100644 index 000000000..729631c24 --- /dev/null +++ b/app/react/portainer/environments/update-schedules/queries/useItem.ts @@ -0,0 +1,24 @@ +import { useQuery } from 'react-query'; + +import axios, { parseAxiosError } from '@/portainer/services/axios'; + +import { EdgeUpdateSchedule } from '../types'; + +import { queryKeys } from './query-keys'; +import { buildUrl } from './urls'; + +export function useItem(id: EdgeUpdateSchedule['id']) { + return useQuery(queryKeys.item(id), () => getItem(id)); +} + +async function getItem(id: EdgeUpdateSchedule['id']) { + try { + const { data } = await axios.get(buildUrl(id)); + return data; + } catch (err) { + throw parseAxiosError( + err as Error, + 'Failed to get list of edge update schedules' + ); + } +} diff --git a/app/react/portainer/environments/update-schedules/queries/useRemoveMutation.ts b/app/react/portainer/environments/update-schedules/queries/useRemoveMutation.ts new file mode 100644 index 000000000..4b84a7000 --- /dev/null +++ b/app/react/portainer/environments/update-schedules/queries/useRemoveMutation.ts @@ -0,0 +1,42 @@ +import { useQueryClient, useMutation } from 'react-query'; + +import { promiseSequence } from '@/portainer/helpers/promise-utils'; +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { + mutationOptions, + withError, + withInvalidate, +} from '@/react-tools/react-query'; + +import { EdgeUpdateSchedule } from '../types'; + +import { buildUrl } from './urls'; +import { queryKeys } from './query-keys'; + +export function useRemoveMutation() { + const queryClient = useQueryClient(); + + return useMutation( + (schedules: EdgeUpdateSchedule[]) => + promiseSequence( + schedules.map((schedule) => () => deleteUpdateSchedule(schedule.id)) + ), + + mutationOptions( + withInvalidate(queryClient, [queryKeys.list()]), + withError() + ) + ); +} + +async function deleteUpdateSchedule(id: EdgeUpdateSchedule['id']) { + try { + const { data } = await axios.delete(buildUrl(id)); + return data; + } catch (err) { + throw parseAxiosError( + err as Error, + 'Failed to delete edge update schedule' + ); + } +} diff --git a/app/react/portainer/environments/update-schedules/queries/useUpdateMutation.ts b/app/react/portainer/environments/update-schedules/queries/useUpdateMutation.ts new file mode 100644 index 000000000..60f07d22e --- /dev/null +++ b/app/react/portainer/environments/update-schedules/queries/useUpdateMutation.ts @@ -0,0 +1,36 @@ +import { useMutation, useQueryClient } from 'react-query'; + +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { withError, withInvalidate } from '@/react-tools/react-query'; + +import { EdgeUpdateSchedule } from '../types'; +import { FormValues } from '../common/types'; + +import { queryKeys } from './query-keys'; +import { buildUrl } from './urls'; + +interface Update { + id: EdgeUpdateSchedule['id']; + values: FormValues; +} + +async function update({ id, values }: Update) { + try { + const { data } = await axios.put(buildUrl(id), values); + + return data; + } catch (err) { + throw parseAxiosError( + err as Error, + 'Failed to update edge update schedule' + ); + } +} + +export function useUpdateMutation() { + const queryClient = useQueryClient(); + return useMutation(update, { + ...withInvalidate(queryClient, [queryKeys.list()]), + ...withError(), + }); +} diff --git a/app/react/portainer/environments/update-schedules/types.ts b/app/react/portainer/environments/update-schedules/types.ts new file mode 100644 index 000000000..c7c0a8d4e --- /dev/null +++ b/app/react/portainer/environments/update-schedules/types.ts @@ -0,0 +1,31 @@ +import { EnvironmentId } from '@/portainer/environments/types'; +import { UserId } from '@/portainer/users/types'; +import { EdgeGroup } from '@/react/edge/edge-groups/types'; + +export enum ScheduleType { + Update = 1, + Rollback, +} + +export enum StatusType { + Pending, + Failed, + Success, +} + +interface Status { + Type: StatusType; + Error: string; +} + +export type EdgeUpdateSchedule = { + id: number; + name: string; + time: number; + groupIds: EdgeGroup['Id'][]; + type: ScheduleType; + status: { [key: EnvironmentId]: Status }; + created: number; + createdBy: UserId; + version: string; +}; diff --git a/app/react/sidebar/SettingsSidebar.tsx b/app/react/sidebar/SettingsSidebar.tsx index 58608cff6..fb6a8c32f 100644 --- a/app/react/sidebar/SettingsSidebar.tsx +++ b/app/react/sidebar/SettingsSidebar.tsx @@ -8,6 +8,10 @@ import { } from 'react-feather'; import { usePublicSettings } from '@/portainer/settings/queries'; +import { + FeatureFlag, + useFeatureFlag, +} from '@/portainer/feature-flags/useRedirectFeatureFlag'; import { SidebarItem } from './SidebarItem'; import { SidebarSection } from './SidebarSection'; @@ -22,6 +26,10 @@ export function SettingsSidebar({ isAdmin, isTeamLeader }: Props) { select: (settings) => settings.TeamSync, }); + const isEdgeRemoteUpgradeEnabledQuery = useFeatureFlag( + FeatureFlag.EdgeRemoteUpdate + ); + const showUsersSection = !window.ddExtension && (isAdmin || (isTeamLeader && !teamSyncQuery.data)); @@ -68,6 +76,13 @@ export function SettingsSidebar({ isAdmin, isTeamLeader }: Props) { label="Tags" data-cy="portainerSidebar-environmentTags" /> + {isEdgeRemoteUpgradeEnabledQuery.data && ( + + )}