feat(edge/update): remote update structure [EE-4040] (#7553)

pull/7591/head^2
Chaim Lev-Ari 2022-09-13 16:56:38 +03:00 committed by GitHub
parent dd1662c8b8
commit 6c4c958bf0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
61 changed files with 1952 additions and 96 deletions

View File

@ -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)
}

View File

@ -7,6 +7,7 @@ import (
"time" "time"
"github.com/portainer/portainer/api/dataservices/errors" "github.com/portainer/portainer/api/dataservices/errors"
"github.com/portainer/portainer/api/edgetypes"
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
) )
@ -28,6 +29,7 @@ type (
EdgeGroup() EdgeGroupService EdgeGroup() EdgeGroupService
EdgeJob() EdgeJobService EdgeJob() EdgeJobService
EdgeStack() EdgeStackService EdgeStack() EdgeStackService
EdgeUpdateSchedule() EdgeUpdateScheduleService
Endpoint() EndpointService Endpoint() EndpointService
EndpointGroup() EndpointGroupService EndpointGroup() EndpointGroupService
EndpointRelation() EndpointRelationService EndpointRelation() EndpointRelationService
@ -81,6 +83,15 @@ type (
BucketName() string 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 represents a service to manage Edge stacks
EdgeStackService interface { EdgeStackService interface {
EdgeStacks() ([]portainer.EdgeStack, error) EdgeStacks() ([]portainer.EdgeStack, error)

View File

@ -13,6 +13,7 @@ import (
"github.com/portainer/portainer/api/dataservices/edgegroup" "github.com/portainer/portainer/api/dataservices/edgegroup"
"github.com/portainer/portainer/api/dataservices/edgejob" "github.com/portainer/portainer/api/dataservices/edgejob"
"github.com/portainer/portainer/api/dataservices/edgestack" "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/endpoint"
"github.com/portainer/portainer/api/dataservices/endpointgroup" "github.com/portainer/portainer/api/dataservices/endpointgroup"
"github.com/portainer/portainer/api/dataservices/endpointrelation" "github.com/portainer/portainer/api/dataservices/endpointrelation"
@ -46,6 +47,7 @@ type Store struct {
DockerHubService *dockerhub.Service DockerHubService *dockerhub.Service
EdgeGroupService *edgegroup.Service EdgeGroupService *edgegroup.Service
EdgeJobService *edgejob.Service EdgeJobService *edgejob.Service
EdgeUpdateScheduleService *edgeupdateschedule.Service
EdgeStackService *edgestack.Service EdgeStackService *edgestack.Service
EndpointGroupService *endpointgroup.Service EndpointGroupService *endpointgroup.Service
EndpointService *endpoint.Service EndpointService *endpoint.Service
@ -89,6 +91,12 @@ func (store *Store) initServices() error {
} }
store.DockerHubService = dockerhubService store.DockerHubService = dockerhubService
edgeUpdateScheduleService, err := edgeupdateschedule.NewService(store.connection)
if err != nil {
return err
}
store.EdgeUpdateScheduleService = edgeUpdateScheduleService
edgeStackService, err := edgestack.NewService(store.connection) edgeStackService, err := edgestack.NewService(store.connection)
if err != nil { if err != nil {
return err return err
@ -245,6 +253,11 @@ func (store *Store) EdgeJob() dataservices.EdgeJobService {
return store.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 // EdgeStack gives access to the EdgeStack data management layer
func (store *Store) EdgeStack() dataservices.EdgeStackService { func (store *Store) EdgeStack() dataservices.EdgeStackService {
return store.EdgeStackService return store.EdgeStackService

View File

@ -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
)

View File

@ -8,7 +8,7 @@ import (
httperror "github.com/portainer/libhttp/error" httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request" "github.com/portainer/libhttp/request"
"github.com/portainer/libhttp/response" "github.com/portainer/libhttp/response"
portaineree "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/http/middlewares" "github.com/portainer/portainer/api/http/middlewares"
"golang.org/x/exp/slices" "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) 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) cli, err := handler.dockerClientFactory.CreateClient(endpoint, agentTargetHeader, nil)
if err != nil { if err != nil {

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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
}

View File

@ -12,6 +12,7 @@ import (
"github.com/portainer/portainer/api/http/handler/edgejobs" "github.com/portainer/portainer/api/http/handler/edgejobs"
"github.com/portainer/portainer/api/http/handler/edgestacks" "github.com/portainer/portainer/api/http/handler/edgestacks"
"github.com/portainer/portainer/api/http/handler/edgetemplates" "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/endpointedge"
"github.com/portainer/portainer/api/http/handler/endpointgroups" "github.com/portainer/portainer/api/http/handler/endpointgroups"
"github.com/portainer/portainer/api/http/handler/endpointproxy" "github.com/portainer/portainer/api/http/handler/endpointproxy"
@ -43,42 +44,43 @@ import (
// Handler is a collection of all the service handlers. // Handler is a collection of all the service handlers.
type Handler struct { type Handler struct {
AuthHandler *auth.Handler AuthHandler *auth.Handler
BackupHandler *backup.Handler BackupHandler *backup.Handler
CustomTemplatesHandler *customtemplates.Handler CustomTemplatesHandler *customtemplates.Handler
DockerHandler *docker.Handler DockerHandler *docker.Handler
EdgeGroupsHandler *edgegroups.Handler EdgeGroupsHandler *edgegroups.Handler
EdgeJobsHandler *edgejobs.Handler EdgeJobsHandler *edgejobs.Handler
EdgeStacksHandler *edgestacks.Handler EdgeUpdateScheduleHandler *edgeupdateschedules.Handler
EdgeTemplatesHandler *edgetemplates.Handler EdgeStacksHandler *edgestacks.Handler
EndpointEdgeHandler *endpointedge.Handler EdgeTemplatesHandler *edgetemplates.Handler
EndpointGroupHandler *endpointgroups.Handler EndpointEdgeHandler *endpointedge.Handler
EndpointHandler *endpoints.Handler EndpointGroupHandler *endpointgroups.Handler
EndpointHelmHandler *helm.Handler EndpointHandler *endpoints.Handler
EndpointProxyHandler *endpointproxy.Handler EndpointHelmHandler *helm.Handler
HelmTemplatesHandler *helm.Handler EndpointProxyHandler *endpointproxy.Handler
KubernetesHandler *kubernetes.Handler HelmTemplatesHandler *helm.Handler
FileHandler *file.Handler KubernetesHandler *kubernetes.Handler
LDAPHandler *ldap.Handler FileHandler *file.Handler
MOTDHandler *motd.Handler LDAPHandler *ldap.Handler
RegistryHandler *registries.Handler MOTDHandler *motd.Handler
ResourceControlHandler *resourcecontrols.Handler RegistryHandler *registries.Handler
RoleHandler *roles.Handler ResourceControlHandler *resourcecontrols.Handler
SettingsHandler *settings.Handler RoleHandler *roles.Handler
SSLHandler *ssl.Handler SettingsHandler *settings.Handler
OpenAMTHandler *openamt.Handler SSLHandler *ssl.Handler
FDOHandler *fdo.Handler OpenAMTHandler *openamt.Handler
StackHandler *stacks.Handler FDOHandler *fdo.Handler
StatusHandler *status.Handler StackHandler *stacks.Handler
StorybookHandler *storybook.Handler StatusHandler *status.Handler
TagHandler *tags.Handler StorybookHandler *storybook.Handler
TeamMembershipHandler *teammemberships.Handler TagHandler *tags.Handler
TeamHandler *teams.Handler TeamMembershipHandler *teammemberships.Handler
TemplatesHandler *templates.Handler TeamHandler *teams.Handler
UploadHandler *upload.Handler TemplatesHandler *templates.Handler
UserHandler *users.Handler UploadHandler *upload.Handler
WebSocketHandler *websocket.Handler UserHandler *users.Handler
WebhookHandler *webhooks.Handler WebSocketHandler *websocket.Handler
WebhookHandler *webhooks.Handler
} }
// @title PortainerCE API // @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) http.StripPrefix("/api", h.BackupHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/api/custom_templates"): case strings.HasPrefix(r.URL.Path, "/api/custom_templates"):
http.StripPrefix("/api", h.CustomTemplatesHandler).ServeHTTP(w, r) 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"): case strings.HasPrefix(r.URL.Path, "/api/edge_stacks"):
http.StripPrefix("/api", h.EdgeStacksHandler).ServeHTTP(w, r) http.StripPrefix("/api", h.EdgeStacksHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/api/edge_groups"): case strings.HasPrefix(r.URL.Path, "/api/edge_groups"):

View File

@ -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)
})
}
}

View File

@ -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
}

View File

@ -26,6 +26,7 @@ import (
"github.com/portainer/portainer/api/http/handler/edgejobs" "github.com/portainer/portainer/api/http/handler/edgejobs"
"github.com/portainer/portainer/api/http/handler/edgestacks" "github.com/portainer/portainer/api/http/handler/edgestacks"
"github.com/portainer/portainer/api/http/handler/edgetemplates" "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/endpointedge"
"github.com/portainer/portainer/api/http/handler/endpointgroups" "github.com/portainer/portainer/api/http/handler/endpointgroups"
"github.com/portainer/portainer/api/http/handler/endpointproxy" "github.com/portainer/portainer/api/http/handler/endpointproxy"
@ -152,6 +153,8 @@ func (server *Server) Start() error {
edgeJobsHandler.FileService = server.FileService edgeJobsHandler.FileService = server.FileService
edgeJobsHandler.ReverseTunnelService = server.ReverseTunnelService edgeJobsHandler.ReverseTunnelService = server.ReverseTunnelService
edgeUpdateScheduleHandler := edgeupdateschedules.NewHandler(requestBouncer, server.DataStore)
var edgeStacksHandler = edgestacks.NewHandler(requestBouncer, server.DataStore) var edgeStacksHandler = edgestacks.NewHandler(requestBouncer, server.DataStore)
edgeStacksHandler.FileService = server.FileService edgeStacksHandler.FileService = server.FileService
edgeStacksHandler.GitService = server.GitService edgeStacksHandler.GitService = server.GitService
@ -274,42 +277,43 @@ func (server *Server) Start() error {
webhookHandler.DockerClientFactory = server.DockerClientFactory webhookHandler.DockerClientFactory = server.DockerClientFactory
server.Handler = &handler.Handler{ server.Handler = &handler.Handler{
RoleHandler: roleHandler, RoleHandler: roleHandler,
AuthHandler: authHandler, AuthHandler: authHandler,
BackupHandler: backupHandler, BackupHandler: backupHandler,
CustomTemplatesHandler: customTemplatesHandler, CustomTemplatesHandler: customTemplatesHandler,
DockerHandler: dockerHandler, DockerHandler: dockerHandler,
EdgeGroupsHandler: edgeGroupsHandler, EdgeGroupsHandler: edgeGroupsHandler,
EdgeJobsHandler: edgeJobsHandler, EdgeJobsHandler: edgeJobsHandler,
EdgeStacksHandler: edgeStacksHandler, EdgeUpdateScheduleHandler: edgeUpdateScheduleHandler,
EdgeTemplatesHandler: edgeTemplatesHandler, EdgeStacksHandler: edgeStacksHandler,
EndpointGroupHandler: endpointGroupHandler, EdgeTemplatesHandler: edgeTemplatesHandler,
EndpointHandler: endpointHandler, EndpointGroupHandler: endpointGroupHandler,
EndpointHelmHandler: endpointHelmHandler, EndpointHandler: endpointHandler,
EndpointEdgeHandler: endpointEdgeHandler, EndpointHelmHandler: endpointHelmHandler,
EndpointProxyHandler: endpointProxyHandler, EndpointEdgeHandler: endpointEdgeHandler,
FileHandler: fileHandler, EndpointProxyHandler: endpointProxyHandler,
LDAPHandler: ldapHandler, FileHandler: fileHandler,
HelmTemplatesHandler: helmTemplatesHandler, LDAPHandler: ldapHandler,
KubernetesHandler: kubernetesHandler, HelmTemplatesHandler: helmTemplatesHandler,
MOTDHandler: motdHandler, KubernetesHandler: kubernetesHandler,
OpenAMTHandler: openAMTHandler, MOTDHandler: motdHandler,
FDOHandler: fdoHandler, OpenAMTHandler: openAMTHandler,
RegistryHandler: registryHandler, FDOHandler: fdoHandler,
ResourceControlHandler: resourceControlHandler, RegistryHandler: registryHandler,
SettingsHandler: settingsHandler, ResourceControlHandler: resourceControlHandler,
SSLHandler: sslHandler, SettingsHandler: settingsHandler,
StatusHandler: statusHandler, SSLHandler: sslHandler,
StackHandler: stackHandler, StatusHandler: statusHandler,
StorybookHandler: storybookHandler, StackHandler: stackHandler,
TagHandler: tagHandler, StorybookHandler: storybookHandler,
TeamHandler: teamHandler, TagHandler: tagHandler,
TeamMembershipHandler: teamMembershipHandler, TeamHandler: teamHandler,
TemplatesHandler: templatesHandler, TeamMembershipHandler: teamMembershipHandler,
UploadHandler: uploadHandler, TemplatesHandler: templatesHandler,
UserHandler: userHandler, UploadHandler: uploadHandler,
WebSocketHandler: websocketHandler, UserHandler: userHandler,
WebhookHandler: webhookHandler, WebSocketHandler: websocketHandler,
WebhookHandler: webhookHandler,
} }
handler := adminMonitor.WithRedirect(offlineGate.WaitingMiddleware(time.Minute, server.Handler)) handler := adminMonitor.WithRedirect(offlineGate.WaitingMiddleware(time.Minute, server.Handler))

View File

@ -12,6 +12,7 @@ type testDatastore struct {
customTemplate dataservices.CustomTemplateService customTemplate dataservices.CustomTemplateService
edgeGroup dataservices.EdgeGroupService edgeGroup dataservices.EdgeGroupService
edgeJob dataservices.EdgeJobService edgeJob dataservices.EdgeJobService
edgeUpdateSchedule dataservices.EdgeUpdateScheduleService
edgeStack dataservices.EdgeStackService edgeStack dataservices.EdgeStackService
endpoint dataservices.EndpointService endpoint dataservices.EndpointService
endpointGroup dataservices.EndpointGroupService 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) EdgeStack() dataservices.EdgeStackService { return d.edgeStack }
func (d *testDatastore) Endpoint() dataservices.EndpointService { return d.endpoint } func (d *testDatastore) Endpoint() dataservices.EndpointService { return d.endpoint }
func (d *testDatastore) EndpointGroup() dataservices.EndpointGroupService { return d.endpointGroup } func (d *testDatastore) EndpointGroup() dataservices.EndpointGroupService { return d.endpointGroup }
func (d *testDatastore) EdgeUpdateSchedule() dataservices.EdgeUpdateScheduleService {
return d.edgeUpdateSchedule
}
func (d *testDatastore) FDOProfile() dataservices.FDOProfileService { func (d *testDatastore) FDOProfile() dataservices.FDOProfileService {
return d.fdoProfile return d.fdoProfile
} }

View File

@ -254,7 +254,8 @@ type (
EdgeJobLogsStatus int EdgeJobLogsStatus int
// EdgeSchedule represents a scheduled job that can run on Edge environments(endpoints). // 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 struct {
// EdgeSchedule Identifier // EdgeSchedule Identifier
ID ScheduleID `json:"Id" example:"1"` ID ScheduleID `json:"Id" example:"1"`
@ -1449,8 +1450,12 @@ const (
WebSocketKeepAlive = 1 * time.Hour WebSocketKeepAlive = 1 * time.Hour
) )
const FeatureFlagEdgeRemoteUpdate Feature = "edgeRemoteUpdate"
// List of supported features // List of supported features
var SupportedFeatureFlags = []Feature{} var SupportedFeatureFlags = []Feature{
FeatureFlagEdgeRemoteUpdate,
}
const ( const (
_ AuthenticationMethod = iota _ AuthenticationMethod = iota

View File

@ -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<boolean>({
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);
}
},
});
}

View File

@ -28,7 +28,6 @@ export function PublicSettingsViewModel(settings) {
this.RequiredPasswordLength = settings.RequiredPasswordLength; this.RequiredPasswordLength = settings.RequiredPasswordLength;
this.EnableEdgeComputeFeatures = settings.EnableEdgeComputeFeatures; this.EnableEdgeComputeFeatures = settings.EnableEdgeComputeFeatures;
this.EnforceEdgeID = settings.EnforceEdgeID; this.EnforceEdgeID = settings.EnforceEdgeID;
this.FeatureFlagSettings = settings.FeatureFlagSettings;
this.LogoURL = settings.LogoURL; this.LogoURL = settings.LogoURL;
this.OAuthLoginURI = settings.OAuthLoginURI; this.OAuthLoginURI = settings.OAuthLoginURI;
this.EnableTelemetry = settings.EnableTelemetry; this.EnableTelemetry = settings.EnableTelemetry;

View File

@ -10,9 +10,14 @@ import {
import { wizardModule } from './wizard'; import { wizardModule } from './wizard';
import { teamsModule } from './teams'; import { teamsModule } from './teams';
import { updateSchedulesModule } from './update-schedules';
export const viewsModule = angular export const viewsModule = angular
.module('portainer.app.react.views', [wizardModule, teamsModule]) .module('portainer.app.react.views', [
wizardModule,
teamsModule,
updateSchedulesModule,
])
.component('defaultRegistryName', r2a(DefaultRegistryName, [])) .component('defaultRegistryName', r2a(DefaultRegistryName, []))
.component('defaultRegistryAction', r2a(DefaultRegistryAction, [])) .component('defaultRegistryAction', r2a(DefaultRegistryAction, []))
.component('defaultRegistryDomain', r2a(DefaultRegistryDomain, [])) .component('defaultRegistryDomain', r2a(DefaultRegistryDomain, []))

View File

@ -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',
},
},
});
}

View File

@ -19,14 +19,17 @@ import { DefaultRegistry, Settings } from './types';
export function usePublicSettings<T = PublicSettingsViewModel>({ export function usePublicSettings<T = PublicSettingsViewModel>({
enabled, enabled,
select, select,
onSuccess,
}: { }: {
select?: (settings: PublicSettingsViewModel) => T; select?: (settings: PublicSettingsViewModel) => T;
enabled?: boolean; enabled?: boolean;
onSuccess?: (data: T) => void;
} = {}) { } = {}) {
return useQuery(['settings', 'public'], () => getPublicSettings(), { return useQuery(['settings', 'public'], () => getPublicSettings(), {
select, select,
...withError('Unable to retrieve public settings'), ...withError('Unable to retrieve public settings'),
enabled, enabled,
onSuccess,
}); });
} }

View File

@ -6,9 +6,9 @@
} }
.parent a { .parent a {
background-color: initial !important; background-color: initial;
border: 1px solid transparent !important; border: 1px solid transparent;
cursor: inherit !important; cursor: inherit;
} }
.parent { .parent {
@ -22,11 +22,11 @@
} }
.parent a { .parent a {
color: var(--white-color) !important; color: var(--white-color);
} }
:global([theme='dark']) .parent a { :global([theme='dark']) .parent a {
color: var(--black-color) !important; color: var(--black-color);
} }
:global([theme='highcontrast']) .parent a { :global([theme='highcontrast']) .parent a {
color: var(--black-color) !important; color: var(--black-color);
} }

View File

@ -18,7 +18,11 @@ function Template({ options = [] }: Args) {
); );
return ( return (
<NavTabs options={options} selectedId={selected} onSelect={setSelected} /> <NavTabs
options={options}
selectedId={selected}
onSelect={(value) => setSelected(value)}
/>
); );
} }

View File

@ -3,19 +3,25 @@ import { ReactNode } from 'react';
import styles from './NavTabs.module.css'; import styles from './NavTabs.module.css';
export interface Option { export interface Option<T extends string | number = string> {
label: string | ReactNode; label: string | ReactNode;
children?: ReactNode; children?: ReactNode;
id: string | number; id: T;
} }
interface Props { interface Props<T extends string | number> {
options: Option[]; options: Option<T>[];
selectedId?: string | number; selectedId?: T;
onSelect?(id: string | number): void; onSelect?(id: T): void;
disabled?: boolean;
} }
export function NavTabs({ options, selectedId, onSelect = () => {} }: Props) { export function NavTabs<T extends string | number = string>({
options,
selectedId,
onSelect = () => {},
disabled,
}: Props<T>) {
const selected = options.find((option) => option.id === selectedId); const selected = options.find((option) => option.id === selectedId);
return ( return (
@ -52,7 +58,11 @@ export function NavTabs({ options, selectedId, onSelect = () => {} }: Props) {
</div> </div>
); );
function handleSelect(option: Option) { function handleSelect(option: Option<T>) {
if (disabled) {
return;
}
if (option.children) { if (option.children) {
onSelect(option.id); onSelect(option.id);
} }

View File

@ -13,6 +13,7 @@ import { ReactNode } from 'react';
import { useRowSelectColumn } from '@lineup-lite/hooks'; import { useRowSelectColumn } from '@lineup-lite/hooks';
import { PaginationControls } from '@@/PaginationControls'; import { PaginationControls } from '@@/PaginationControls';
import { IconProps } from '@@/Icon';
import { Table } from './Table'; import { Table } from './Table';
import { multiple } from './filter-types'; import { multiple } from './filter-types';
@ -28,7 +29,8 @@ interface DefaultTableSettings
interface TitleOptionsVisible { interface TitleOptionsVisible {
title: string; title: string;
icon?: string; icon?: IconProps['icon'];
featherIcon?: IconProps['featherIcon'];
hide?: never; hide?: never;
} }

View File

@ -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<EdgeGroup[]>('/edge_groups');
return data;
} catch (err) {
throw parseAxiosError(err as Error, 'Failed fetching edge groups');
}
}
export function useEdgeGroups() {
return useQuery(['edge', 'groups'], getEdgeGroups);
}

View File

@ -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;
}

View File

@ -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 (
<>
<PageHeader
title="Update & Rollback"
breadcrumbs="Edge agent update and rollback"
/>
<div className="row">
<div className="col-sm-12">
<Widget>
<Widget.Title title="Update & Rollback Scheduler" icon={Settings} />
<Widget.Body>
<Formik
initialValues={initialValues}
onSubmit={(values) => {
createMutation.mutate(values, {
onSuccess() {
notifySuccess('Success', 'Created schedule successfully');
router.stateService.go('^');
},
});
}}
validateOnMount
validationSchema={() => validation(schedules)}
>
{({ isValid }) => (
<FormikForm className="form-horizontal">
<NameField />
<EdgeGroupsField />
<UpdateTypeTabs />
<div className="form-group">
<div className="col-sm-12">
<LoadingButton
disabled={!isValid}
isLoading={createMutation.isLoading}
loadingText="Creating..."
>
Create Schedule
</LoadingButton>
</div>
</div>
</FormikForm>
)}
</Formik>
</Widget.Body>
</Widget>
</div>
</div>
</>
);
}

View File

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

View File

@ -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 (
<>
<PageHeader
title="Update & Rollback"
breadcrumbs={['Edge agent update and rollback', item.name]}
/>
<div className="row">
<div className="col-sm-12">
<Widget>
<Widget.Title title="Update & Rollback Scheduler" icon={Settings} />
<Widget.Body>
<Formik
initialValues={item}
onSubmit={(values) => {
updateMutation.mutate(
{ id, values },
{
onSuccess() {
notifySuccess(
'Success',
'Updated schedule successfully'
);
router.stateService.go('^');
},
}
);
}}
validateOnMount
validationSchema={() => updateValidation(item, schedules)}
>
{({ isValid }) => (
<FormikForm className="form-horizontal">
<NameField />
<EdgeGroupsField disabled={isDisabled} />
<UpdateTypeTabs disabled={isDisabled} />
<div className="form-group">
<div className="col-sm-12">
<LoadingButton
disabled={!isValid}
isLoading={updateMutation.isLoading}
loadingText="Updating..."
>
Update Schedule
</LoadingButton>
</div>
</div>
</FormikForm>
)}
</Formik>
</Widget.Body>
</Widget>
</div>
</div>
</>
);
}
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) });
}

View File

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

View File

@ -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 (
<>
<PageHeader
title="Update & Rollback"
reload
breadcrumbs="Update and rollback"
/>
<Datatable
columns={columns}
titleOptions={{
title: 'Update & rollback',
icon: Clock,
}}
dataset={listQuery.data}
settingsStore={store}
storageKey={storageKey}
emptyContentLabel="No schedules found"
isLoading={listQuery.isLoading}
totalCount={listQuery.data.length}
renderTableActions={(selectedRows) => (
<TableActions selectedRows={selectedRows} />
)}
/>
</>
);
}
function TableActions({
selectedRows,
}: {
selectedRows: EdgeUpdateSchedule[];
}) {
const removeMutation = useRemoveMutation();
return (
<>
<Button
icon={Trash2}
color="dangerlight"
onClick={() => handleRemove()}
disabled={selectedRows.length === 0}
>
Remove
</Button>
<Link to=".create">
<Button>Add update & rollback schedule</Button>
</Link>
</>
);
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');
},
});
}
}

View File

@ -0,0 +1,13 @@
import { Column } from 'react-table';
import { isoDateFromTimestamp } from '@/portainer/filters/filters';
import { EdgeUpdateSchedule } from '../../types';
export const created: Column<EdgeUpdateSchedule> = {
Header: 'Created',
accessor: (row) => isoDateFromTimestamp(row.created),
disableFilters: true,
Filter: () => null,
canHide: false,
};

View File

@ -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<EdgeUpdateSchedule> = {
Header: 'Groups',
accessor: 'groupIds',
Cell: GroupsCell,
disableFilters: true,
Filter: () => null,
canHide: false,
disableSortBy: true,
};
export function GroupsCell({
value: groupsIds,
}: CellProps<EdgeUpdateSchedule, Array<EdgeGroup['Id']>>) {
const groupsQuery = useEdgeGroups();
const groups = _.compact(
groupsIds.map((id) => groupsQuery.data?.find((g) => g.Id === id))
);
return groups.map((g) => g.Name).join(', ');
}

View File

@ -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,
];

View File

@ -0,0 +1,24 @@
import { CellProps, Column } from 'react-table';
import { Link } from '@@/Link';
import { EdgeUpdateSchedule } from '../../types';
export const name: Column<EdgeUpdateSchedule> = {
Header: 'Name',
accessor: 'name',
id: 'name',
Cell: NameCell,
disableFilters: true,
Filter: () => null,
canHide: false,
sortType: 'string',
};
export function NameCell({ value: name, row }: CellProps<EdgeUpdateSchedule>) {
return (
<Link to=".item" params={{ id: row.original.id }}>
{name}
</Link>
);
}

View File

@ -0,0 +1,44 @@
import { CellProps, Column } from 'react-table';
import { EdgeUpdateSchedule, StatusType } from '../../types';
export const scheduleStatus: Column<EdgeUpdateSchedule> = {
Header: 'Status',
accessor: (row) => row.status,
disableFilters: true,
Filter: () => null,
canHide: false,
Cell: StatusCell,
disableSortBy: true,
};
function StatusCell({
value: status,
row: { original: schedule },
}: CellProps<EdgeUpdateSchedule, EdgeUpdateSchedule['status']>) {
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';
}

View File

@ -0,0 +1,13 @@
import { Column } from 'react-table';
import { isoDateFromTimestamp } from '@/portainer/filters/filters';
import { EdgeUpdateSchedule } from '../../types';
export const scheduledTime: Column<EdgeUpdateSchedule> = {
Header: 'Scheduled Time & Date',
accessor: (row) => isoDateFromTimestamp(row.time),
disableFilters: true,
Filter: () => null,
canHide: false,
};

View File

@ -0,0 +1,12 @@
import { Column } from 'react-table';
import { EdgeUpdateSchedule, ScheduleType } from '../../types';
export const scheduleType: Column<EdgeUpdateSchedule> = {
Header: 'Type',
accessor: (row) => ScheduleType[row.type],
disableFilters: true,
Filter: () => null,
canHide: false,
sortType: 'string',
};

View File

@ -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<TableSettings>()(
persist(
(set) => ({
...sortableSettings(set),
...paginationSettings(set),
...hiddenColumnsSettings(set),
...refreshableSettings(set),
}),
{
name: keyBuilder(storageKey),
}
)
);
}

View File

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

View File

@ -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<FormValues['groupIds']>('groupIds');
const selectedGroups = groupsQuery.data?.filter((group) =>
value.includes(group.Id)
);
return (
<FormControl label="Groups" required inputId="groups-select" errors={error}>
<Select
name={name}
onBlur={onBlur}
value={selectedGroups}
inputId="groups-select"
placeholder="Select one or multiple group(s)"
onChange={(selectedGroups) => setValue(selectedGroups.map((g) => g.Id))}
isMulti
options={groupsQuery.data || []}
getOptionLabel={(group) => group.Name}
getOptionValue={(group) => group.Id.toString()}
closeMenuOnSelect={false}
isDisabled={disabled}
/>
</FormControl>
);
}

View File

@ -0,0 +1,30 @@
import { Field, useField } from 'formik';
import { string } from 'yup';
import { FormControl } from '@@/form-components/FormControl';
import { Input } from '@@/form-components/Input';
import { EdgeUpdateSchedule } from '../types';
import { FormValues } from './types';
export function NameField() {
const [{ name }, { error }] = useField<FormValues['name']>('name');
return (
<FormControl label="Name" required inputId="name-input" errors={error}>
<Field as={Input} name={name} id="name-input" />
</FormControl>
);
}
export function nameValidation(
schedules: EdgeUpdateSchedule[],
currentId?: EdgeUpdateSchedule['id']
) {
return string()
.required('This field is required')
.test('unique', 'Name must be unique', (value) =>
schedules.every((s) => s.id === currentId || s.name !== value)
);
}

View File

@ -0,0 +1,42 @@
import { useField } from 'formik';
import DateTimePicker from 'react-datetime-picker';
import { Calendar, X } from 'react-feather';
import { useMemo } from 'react';
import { isoDateFromTimestamp } from '@/portainer/filters/filters';
import { FormControl } from '@@/form-components/FormControl';
import { Input } from '@@/form-components/Input';
import { FormValues } from './types';
interface Props {
disabled?: boolean;
}
export function ScheduledTimeField({ disabled }: Props) {
const [{ name, value }, { error }, { setValue }] =
useField<FormValues['time']>('time');
const dateValue = useMemo(() => new Date(value * 1000), [value]);
return (
<FormControl label="Schedule date & time" errors={error}>
{!disabled ? (
<DateTimePicker
format="y-MM-dd HH:mm:ss"
minDate={new Date()}
className="form-control [&>div]:border-0"
onChange={(date) => setValue(Math.floor(date.getTime() / 1000))}
name={name}
value={dateValue}
calendarIcon={<Calendar className="feather" />}
clearIcon={<X className="feather" />}
disableClock
/>
) : (
<Input defaultValue={isoDateFromTimestamp(value)} disabled />
)}
</FormControl>
);
}

View File

@ -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<FormValues['type']>('type');
return (
<div className="form-group">
<div className="col-sm-12">
<NavTabs
options={[
{
id: ScheduleType.Update,
label: 'Update',
children: <ScheduleDetails disabled={disabled} />,
},
{
id: ScheduleType.Rollback,
label: 'Rollback',
children: <ScheduleDetails disabled={disabled} />,
},
]}
selectedId={value}
onSelect={(value) => setValue(value)}
disabled={disabled}
/>
</div>
</div>
);
}
function ScheduleDetails({ disabled }: Props) {
return (
<div>
<ScheduledTimeField disabled={disabled} />
</div>
);
}
export function typeValidation() {
return number()
.oneOf([ScheduleType.Rollback, ScheduleType.Update])
.default(ScheduleType.Update);
}

View File

@ -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;
}

View File

@ -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<FormValues> {
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(),
});
}

View File

@ -0,0 +1,3 @@
export { ListView } from './ListView';
export { CreateView } from './CreateView';
export { ItemView } from './ItemView';

View File

@ -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<EdgeUpdateSchedule>(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(),
});
}

View File

@ -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<EdgeUpdateSchedule[]>(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);
}

View File

@ -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,
};

View File

@ -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}`;
}

View File

@ -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<EdgeUpdateSchedule>(buildUrl(id));
return data;
} catch (err) {
throw parseAxiosError(
err as Error,
'Failed to get list of edge update schedules'
);
}
}

View File

@ -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<EdgeUpdateSchedule[]>(buildUrl(id));
return data;
} catch (err) {
throw parseAxiosError(
err as Error,
'Failed to delete edge update schedule'
);
}
}

View File

@ -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<EdgeUpdateSchedule>(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(),
});
}

View File

@ -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;
};

View File

@ -8,6 +8,10 @@ import {
} from 'react-feather'; } from 'react-feather';
import { usePublicSettings } from '@/portainer/settings/queries'; import { usePublicSettings } from '@/portainer/settings/queries';
import {
FeatureFlag,
useFeatureFlag,
} from '@/portainer/feature-flags/useRedirectFeatureFlag';
import { SidebarItem } from './SidebarItem'; import { SidebarItem } from './SidebarItem';
import { SidebarSection } from './SidebarSection'; import { SidebarSection } from './SidebarSection';
@ -22,6 +26,10 @@ export function SettingsSidebar({ isAdmin, isTeamLeader }: Props) {
select: (settings) => settings.TeamSync, select: (settings) => settings.TeamSync,
}); });
const isEdgeRemoteUpgradeEnabledQuery = useFeatureFlag(
FeatureFlag.EdgeRemoteUpdate
);
const showUsersSection = const showUsersSection =
!window.ddExtension && (isAdmin || (isTeamLeader && !teamSyncQuery.data)); !window.ddExtension && (isAdmin || (isTeamLeader && !teamSyncQuery.data));
@ -68,6 +76,13 @@ export function SettingsSidebar({ isAdmin, isTeamLeader }: Props) {
label="Tags" label="Tags"
data-cy="portainerSidebar-environmentTags" data-cy="portainerSidebar-environmentTags"
/> />
{isEdgeRemoteUpgradeEnabledQuery.data && (
<SidebarItem
to="portainer.endpoints.updateSchedules"
label="Update & Rollback"
data-cy="portainerSidebar-updateSchedules"
/>
)}
</SidebarItem> </SidebarItem>
<SidebarItem <SidebarItem

View File

@ -124,6 +124,7 @@
"parse-duration": "^1.0.2", "parse-duration": "^1.0.2",
"rc-slider": "^9.7.5", "rc-slider": "^9.7.5",
"react": "^17.0.2", "react": "^17.0.2",
"react-datetime-picker": "^3.5.0",
"react-dom": "^17.0.2", "react-dom": "^17.0.2",
"react-feather": "^2.0.9", "react-feather": "^2.0.9",
"react-i18next": "^11.12.0", "react-i18next": "^11.12.0",
@ -172,6 +173,7 @@
"@types/mustache": "^4.1.2", "@types/mustache": "^4.1.2",
"@types/nprogress": "^0.2.0", "@types/nprogress": "^0.2.0",
"@types/react": "^17.0.37", "@types/react": "^17.0.37",
"@types/react-datetime-picker": "^3.4.1",
"@types/react-dom": "^17.0.11", "@types/react-dom": "^17.0.11",
"@types/react-is": "^17.0.3", "@types/react-is": "^17.0.3",
"@types/react-table": "^7.7.6", "@types/react-table": "^7.7.6",

131
yarn.lock
View File

@ -4785,6 +4785,20 @@
resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.4.tgz#cd667bcfdd025213aafb7ca5915a932590acdcdc" resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.4.tgz#cd667bcfdd025213aafb7ca5915a932590acdcdc"
integrity sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw== integrity sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==
"@types/react-calendar@^3.0.0":
version "3.5.2"
resolved "https://registry.yarnpkg.com/@types/react-calendar/-/react-calendar-3.5.2.tgz#e401034e4bb82f4510ba87aa490e98b5746e16e0"
integrity sha512-8gkU9KaE33VVbu3YWvxXjEk4BsalgSYR3c/5XF9XNJiQ/2MKxiGkTg/PfOHUX/BvcADykRBMAEJiCi6jFPEE3A==
dependencies:
"@types/react" "*"
"@types/react-datetime-picker@^3.4.1":
version "3.4.1"
resolved "https://registry.yarnpkg.com/@types/react-datetime-picker/-/react-datetime-picker-3.4.1.tgz#8acbc3e6f4e69fac0f91be4e920c3efdc28f3ed7"
integrity sha512-JHqB74+8Zq6cY0PTJ6Wi5Pm6qkNUmooyFfW5SiknSY2xJG1UG8+ljyWTZAvgHvj0XpqcWCHqqYUPiAVagnf9Sg==
dependencies:
"@types/react" "*"
"@types/react-dom@^17.0.11": "@types/react-dom@^17.0.11":
version "17.0.11" version "17.0.11"
resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-17.0.11.tgz#e1eadc3c5e86bdb5f7684e00274ae228e7bcc466" resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-17.0.11.tgz#e1eadc3c5e86bdb5f7684e00274ae228e7bcc466"
@ -5383,6 +5397,11 @@
resolved "https://registry.yarnpkg.com/@webpack-cli/serve/-/serve-1.6.1.tgz#0de2875ac31b46b6c5bb1ae0a7d7f0ba5678dffe" resolved "https://registry.yarnpkg.com/@webpack-cli/serve/-/serve-1.6.1.tgz#0de2875ac31b46b6c5bb1ae0a7d7f0ba5678dffe"
integrity sha512-gNGTiTrjEVQ0OcVnzsRSqTxaBSr+dmTfm+qJsCDluky8uhdLWep7Gcr62QsAKHTMxjCS/8nEITsmFAhfIx+QSw== integrity sha512-gNGTiTrjEVQ0OcVnzsRSqTxaBSr+dmTfm+qJsCDluky8uhdLWep7Gcr62QsAKHTMxjCS/8nEITsmFAhfIx+QSw==
"@wojtekmaj/date-utils@^1.0.0", "@wojtekmaj/date-utils@^1.0.2", "@wojtekmaj/date-utils@^1.0.3":
version "1.0.3"
resolved "https://registry.yarnpkg.com/@wojtekmaj/date-utils/-/date-utils-1.0.3.tgz#2dcfd92881425c5923e429c2aec86fb3609032a1"
integrity sha512-1VPkkTBk07gMR1fjpBtse4G+oJqpmE+0gUFB0dg3VIL7qJmUVaBoD/vlzMm/jNeOPfvlmerl1lpnsZyBUFIRuw==
"@xmldom/xmldom@^0.7.2": "@xmldom/xmldom@^0.7.2":
version "0.7.5" version "0.7.5"
resolved "https://registry.yarnpkg.com/@xmldom/xmldom/-/xmldom-0.7.5.tgz#09fa51e356d07d0be200642b0e4f91d8e6dd408d" resolved "https://registry.yarnpkg.com/@xmldom/xmldom/-/xmldom-0.7.5.tgz#09fa51e356d07d0be200642b0e4f91d8e6dd408d"
@ -8302,6 +8321,11 @@ detab@2.0.4:
dependencies: dependencies:
repeat-string "^1.5.4" repeat-string "^1.5.4"
detect-element-overflow@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/detect-element-overflow/-/detect-element-overflow-1.2.0.tgz#86e504292ffedc3aef813395fbdf0261aaf6afa9"
integrity sha512-Jtr9ivYPhpd9OJux+hjL0QjUKiS1Ghgy8tvIufUjFslQgIWvgGr4mn57H190APbKkiOmXnmtMI6ytaKzMusecg==
detect-file@^1.0.0: detect-file@^1.0.0:
version "1.0.0" version "1.0.0"
resolved "https://registry.yarnpkg.com/detect-file/-/detect-file-1.0.0.tgz#f0d66d03672a825cb1b73bdb3fe62310c8e552b7" resolved "https://registry.yarnpkg.com/detect-file/-/detect-file-1.0.0.tgz#f0d66d03672a825cb1b73bdb3fe62310c8e552b7"
@ -10149,6 +10173,13 @@ get-symbol-description@^1.0.0:
call-bind "^1.0.2" call-bind "^1.0.2"
get-intrinsic "^1.1.1" get-intrinsic "^1.1.1"
get-user-locale@^1.2.0, get-user-locale@^1.4.0:
version "1.5.1"
resolved "https://registry.yarnpkg.com/get-user-locale/-/get-user-locale-1.5.1.tgz#18a9ba2cfeed0e713ea00968efa75d620523a5ea"
integrity sha512-WiNpoFRcHn1qxP9VabQljzGwkAQDrcpqUtaP0rNBEkFxJdh4f3tik6MfZsMYZc+UgQJdGCxWEjL9wnCUlRQXag==
dependencies:
lodash.memoize "^4.1.1"
get-value@^2.0.3, get-value@^2.0.6: get-value@^2.0.3, get-value@^2.0.6:
version "2.0.6" version "2.0.6"
resolved "https://registry.yarnpkg.com/get-value/-/get-value-2.0.6.tgz#dc15ca1c672387ca76bd37ac0a395ba2042a2c28" resolved "https://registry.yarnpkg.com/get-value/-/get-value-2.0.6.tgz#dc15ca1c672387ca76bd37ac0a395ba2042a2c28"
@ -12842,7 +12873,7 @@ lodash.isplainobject@^4.0.6:
resolved "https://registry.yarnpkg.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz#7c526a52d89b45c45cc690b88163be0497f550cb" resolved "https://registry.yarnpkg.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz#7c526a52d89b45c45cc690b88163be0497f550cb"
integrity sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs= integrity sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs=
lodash.memoize@^4.1.2: lodash.memoize@^4.1.1, lodash.memoize@^4.1.2:
version "4.1.2" version "4.1.2"
resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe" resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe"
integrity sha1-vMbEmkKihA7Zl/Mj6tpezRguC/4= integrity sha1-vMbEmkKihA7Zl/Mj6tpezRguC/4=
@ -12973,6 +13004,11 @@ make-dir@^3.0.0, make-dir@^3.0.2, make-dir@^3.1.0:
dependencies: dependencies:
semver "^6.0.0" semver "^6.0.0"
make-event-props@^1.1.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/make-event-props/-/make-event-props-1.3.0.tgz#2434cb390d58bcf40898d009ef5b1f936de9671b"
integrity sha512-oWiDZMcVB1/A487251hEWza1xzgCzl6MXxe9aF24l5Bt9N9UEbqTqKumEfuuLhmlhRZYnc+suVvW4vUs8bwO7Q==
make-iterator@^1.0.0: make-iterator@^1.0.0:
version "1.0.1" version "1.0.1"
resolved "https://registry.yarnpkg.com/make-iterator/-/make-iterator-1.0.1.tgz#29b33f312aa8f547c4a5e490f56afcec99133ad6" resolved "https://registry.yarnpkg.com/make-iterator/-/make-iterator-1.0.1.tgz#29b33f312aa8f547c4a5e490f56afcec99133ad6"
@ -13154,11 +13190,21 @@ meow@^3.1.0:
redent "^1.0.0" redent "^1.0.0"
trim-newlines "^1.0.0" trim-newlines "^1.0.0"
merge-class-names@^1.1.1:
version "1.4.2"
resolved "https://registry.yarnpkg.com/merge-class-names/-/merge-class-names-1.4.2.tgz#78d6d95ab259e7e647252a7988fd25a27d5a8835"
integrity sha512-bOl98VzwCGi25Gcn3xKxnR5p/WrhWFQB59MS/aGENcmUc6iSm96yrFDF0XSNurX9qN4LbJm0R9kfvsQ17i8zCw==
merge-descriptors@1.0.1: merge-descriptors@1.0.1:
version "1.0.1" version "1.0.1"
resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.1.tgz#b00aaa556dd8b44568150ec9d1b953f3f90cbb61" resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.1.tgz#b00aaa556dd8b44568150ec9d1b953f3f90cbb61"
integrity sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E= integrity sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E=
merge-refs@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/merge-refs/-/merge-refs-1.0.0.tgz#388348bce22e623782c6df9d3c4fc55888276120"
integrity sha512-WZ4S5wqD9FCR9hxkLgvcHJCBxzXzy3VVE6p8W2OzxRzB+hLRlcadGE2bW9xp2KSzk10rvp4y+pwwKO6JQVguMg==
merge-stream@^2.0.0: merge-stream@^2.0.0:
version "2.0.0" version "2.0.0"
resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60" resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60"
@ -15385,6 +15431,16 @@ rc-util@^5.16.1, rc-util@^5.2.1, rc-util@^5.3.0, rc-util@^5.5.0:
react-is "^16.12.0" react-is "^16.12.0"
shallowequal "^1.1.0" shallowequal "^1.1.0"
react-calendar@^3.3.1:
version "3.7.0"
resolved "https://registry.yarnpkg.com/react-calendar/-/react-calendar-3.7.0.tgz#951d56e91afb33b1c1e019cb790349fbffcc6894"
integrity sha512-zkK95zWLWLC6w3O7p3SHx/FJXEyyD2UMd4jr3CrKD+G73N+G5vEwrXxYQCNivIPoFNBjqoyYYGlkHA+TBDPLCw==
dependencies:
"@wojtekmaj/date-utils" "^1.0.2"
get-user-locale "^1.2.0"
merge-class-names "^1.1.1"
prop-types "^15.6.0"
react-clientside-effect@^1.2.6: react-clientside-effect@^1.2.6:
version "1.2.6" version "1.2.6"
resolved "https://registry.npmmirror.com/react-clientside-effect/-/react-clientside-effect-1.2.6.tgz#29f9b14e944a376b03fb650eed2a754dd128ea3a" resolved "https://registry.npmmirror.com/react-clientside-effect/-/react-clientside-effect-1.2.6.tgz#29f9b14e944a376b03fb650eed2a754dd128ea3a"
@ -15392,6 +15448,48 @@ react-clientside-effect@^1.2.6:
dependencies: dependencies:
"@babel/runtime" "^7.12.13" "@babel/runtime" "^7.12.13"
react-clock@^3.0.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/react-clock/-/react-clock-3.1.0.tgz#6fd9579e63597b85e50c22eb893eeb565d650d0e"
integrity sha512-KLV3pDBcETc7lHPPqK6EpRaPS8NA3STAes+zIdfr7IY67vYgYc3brOAnGC9IcgA4X4xNPnLZwwaLJXmHrQ/MnQ==
dependencies:
"@wojtekmaj/date-utils" "^1.0.0"
get-user-locale "^1.4.0"
merge-class-names "^1.1.1"
prop-types "^15.6.0"
react-date-picker@^8.4.0:
version "8.4.0"
resolved "https://registry.yarnpkg.com/react-date-picker/-/react-date-picker-8.4.0.tgz#2d166bbaa59b08ec8686f671fde553458d19f8c8"
integrity sha512-zocntugDUyiHmV2Nq1qnsk4kDQuhBLUsDTz7akfIEJ0jVX925w0K5Ai5oZzWFNQOzXL/ITxafmDMuSbzlpBt/A==
dependencies:
"@types/react-calendar" "^3.0.0"
"@wojtekmaj/date-utils" "^1.0.3"
get-user-locale "^1.2.0"
make-event-props "^1.1.0"
merge-class-names "^1.1.1"
merge-refs "^1.0.0"
prop-types "^15.6.0"
react-calendar "^3.3.1"
react-fit "^1.4.0"
update-input-width "^1.2.2"
react-datetime-picker@^3.5.0:
version "3.5.0"
resolved "https://registry.yarnpkg.com/react-datetime-picker/-/react-datetime-picker-3.5.0.tgz#36518703439d98eed87e4174dbd1809afc407170"
integrity sha512-Df5HQbzUmT+a8IlH4veVZylDgHLbUxjTS+Tv1YoWsJ7La/7K/mAycaSC++bV7myVlfMUrMUPPULavakAsiIFAQ==
dependencies:
"@wojtekmaj/date-utils" "^1.0.3"
get-user-locale "^1.2.0"
make-event-props "^1.1.0"
merge-class-names "^1.1.1"
prop-types "^15.6.0"
react-calendar "^3.3.1"
react-clock "^3.0.0"
react-date-picker "^8.4.0"
react-fit "^1.4.0"
react-time-picker "^4.5.0"
react-docgen-typescript@^2.1.1: react-docgen-typescript@^2.1.1:
version "2.2.2" version "2.2.2"
resolved "https://registry.yarnpkg.com/react-docgen-typescript/-/react-docgen-typescript-2.2.2.tgz#4611055e569edc071204aadb20e1c93e1ab1659c" resolved "https://registry.yarnpkg.com/react-docgen-typescript/-/react-docgen-typescript-2.2.2.tgz#4611055e569edc071204aadb20e1c93e1ab1659c"
@ -15443,6 +15541,15 @@ react-feather@^2.0.9:
dependencies: dependencies:
prop-types "^15.7.2" prop-types "^15.7.2"
react-fit@^1.4.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/react-fit/-/react-fit-1.4.0.tgz#6b6e3c75215561cc3cfb9854a6811b4347628666"
integrity sha512-cf9sFKbr1rlTB9fNIKE5Uy4NCMUOqrX2mdJ69V4RtmV4KubPdtnbIP1tEar16GXaToCRr7I7c9d2wkTNk9TV5g==
dependencies:
detect-element-overflow "^1.2.0"
prop-types "^15.6.0"
tiny-warning "^1.0.0"
react-focus-lock@^2.5.2: react-focus-lock@^2.5.2:
version "2.9.1" version "2.9.1"
resolved "https://registry.npmmirror.com/react-focus-lock/-/react-focus-lock-2.9.1.tgz#094cfc19b4f334122c73bb0bff65d77a0c92dd16" resolved "https://registry.npmmirror.com/react-focus-lock/-/react-focus-lock-2.9.1.tgz#094cfc19b4f334122c73bb0bff65d77a0c92dd16"
@ -15590,6 +15697,21 @@ react-test-renderer@^17.0.2:
react-shallow-renderer "^16.13.1" react-shallow-renderer "^16.13.1"
scheduler "^0.20.2" scheduler "^0.20.2"
react-time-picker@^4.5.0:
version "4.5.0"
resolved "https://registry.yarnpkg.com/react-time-picker/-/react-time-picker-4.5.0.tgz#661624ce8e0fde583e143f4b30fb9f8a3f78036b"
integrity sha512-06ViW8t3hGmkrwGvUtaoZ5ud/uSlQwMexn86eL3uoTV6FnIeRhKq0H944L4bA1ne4xIndO4Fro5tGUMmWUA9gw==
dependencies:
"@wojtekmaj/date-utils" "^1.0.0"
get-user-locale "^1.2.0"
make-event-props "^1.1.0"
merge-class-names "^1.1.1"
merge-refs "^1.0.0"
prop-types "^15.6.0"
react-clock "^3.0.0"
react-fit "^1.4.0"
update-input-width "^1.2.2"
react-tooltip@^4.2.21: react-tooltip@^4.2.21:
version "4.2.21" version "4.2.21"
resolved "https://registry.yarnpkg.com/react-tooltip/-/react-tooltip-4.2.21.tgz#840123ed86cf33d50ddde8ec8813b2960bfded7f" resolved "https://registry.yarnpkg.com/react-tooltip/-/react-tooltip-4.2.21.tgz#840123ed86cf33d50ddde8ec8813b2960bfded7f"
@ -17618,7 +17740,7 @@ timsort@^0.3.0:
resolved "https://registry.yarnpkg.com/timsort/-/timsort-0.3.0.tgz#405411a8e7e6339fe64db9a234de11dc31e02bd4" resolved "https://registry.yarnpkg.com/timsort/-/timsort-0.3.0.tgz#405411a8e7e6339fe64db9a234de11dc31e02bd4"
integrity sha1-QFQRqOfmM5/mTbmiNN4R3DHgK9Q= integrity sha1-QFQRqOfmM5/mTbmiNN4R3DHgK9Q=
tiny-warning@^1.0.2, tiny-warning@^1.0.3: tiny-warning@^1.0.0, tiny-warning@^1.0.2, tiny-warning@^1.0.3:
version "1.0.3" version "1.0.3"
resolved "https://registry.yarnpkg.com/tiny-warning/-/tiny-warning-1.0.3.tgz#94a30db453df4c643d0fd566060d60a875d84754" resolved "https://registry.yarnpkg.com/tiny-warning/-/tiny-warning-1.0.3.tgz#94a30db453df4c643d0fd566060d60a875d84754"
integrity sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA== integrity sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==
@ -18123,6 +18245,11 @@ update-browserslist-db@^1.0.0:
escalade "^3.1.1" escalade "^3.1.1"
picocolors "^1.0.0" picocolors "^1.0.0"
update-input-width@^1.2.2:
version "1.2.2"
resolved "https://registry.yarnpkg.com/update-input-width/-/update-input-width-1.2.2.tgz#9a6a35858ae8e66fbfe0304437b23a4934fc7d37"
integrity sha512-6QwD9ZVSXb96PxOZ01DU0DJTPwQGY7qBYgdniZKJN02Xzom2m+9J6EPxMbefskqtj4x78qbe5psDSALq9iNEYg==
upper-case-first@^1.1.0, upper-case-first@^1.1.2: upper-case-first@^1.1.0, upper-case-first@^1.1.2:
version "1.1.2" version "1.1.2"
resolved "https://registry.yarnpkg.com/upper-case-first/-/upper-case-first-1.1.2.tgz#5d79bedcff14419518fd2edb0a0507c9b6859115" resolved "https://registry.yarnpkg.com/upper-case-first/-/upper-case-first-1.1.2.tgz#5d79bedcff14419518fd2edb0a0507c9b6859115"