feat(edge/update): remote update structure [EE-4040] (#7553)
parent
dd1662c8b8
commit
6c4c958bf0
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
)
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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"):
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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))
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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, []))
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
|
@ -19,14 +19,17 @@ import { DefaultRegistry, Settings } from './types';
|
|||
export function usePublicSettings<T = PublicSettingsViewModel>({
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -18,7 +18,11 @@ function Template({ options = [] }: Args) {
|
|||
);
|
||||
|
||||
return (
|
||||
<NavTabs options={options} selectedId={selected} onSelect={setSelected} />
|
||||
<NavTabs
|
||||
options={options}
|
||||
selectedId={selected}
|
||||
onSelect={(value) => setSelected(value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -3,19 +3,25 @@ import { ReactNode } from 'react';
|
|||
|
||||
import styles from './NavTabs.module.css';
|
||||
|
||||
export interface Option {
|
||||
export interface Option<T extends string | number = string> {
|
||||
label: string | ReactNode;
|
||||
children?: ReactNode;
|
||||
id: string | number;
|
||||
id: T;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
options: Option[];
|
||||
selectedId?: string | number;
|
||||
onSelect?(id: string | number): void;
|
||||
interface Props<T extends string | number> {
|
||||
options: Option<T>[];
|
||||
selectedId?: T;
|
||||
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);
|
||||
|
||||
return (
|
||||
|
@ -52,7 +58,11 @@ export function NavTabs({ options, selectedId, onSelect = () => {} }: Props) {
|
|||
</div>
|
||||
);
|
||||
|
||||
function handleSelect(option: Option) {
|
||||
function handleSelect(option: Option<T>) {
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (option.children) {
|
||||
onSelect(option.id);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { CreateView } from './CreateView';
|
|
@ -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) });
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { ItemView } from './ItemView';
|
|
@ -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');
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
};
|
|
@ -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(', ');
|
||||
}
|
|
@ -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,
|
||||
];
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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';
|
||||
}
|
|
@ -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,
|
||||
};
|
|
@ -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',
|
||||
};
|
|
@ -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),
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { ListView } from './ListView';
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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)
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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(),
|
||||
});
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
export { ListView } from './ListView';
|
||||
export { CreateView } from './CreateView';
|
||||
export { ItemView } from './ItemView';
|
|
@ -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(),
|
||||
});
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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,
|
||||
};
|
|
@ -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}`;
|
||||
}
|
|
@ -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'
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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'
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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(),
|
||||
});
|
||||
}
|
|
@ -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;
|
||||
};
|
|
@ -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 && (
|
||||
<SidebarItem
|
||||
to="portainer.endpoints.updateSchedules"
|
||||
label="Update & Rollback"
|
||||
data-cy="portainerSidebar-updateSchedules"
|
||||
/>
|
||||
)}
|
||||
</SidebarItem>
|
||||
|
||||
<SidebarItem
|
||||
|
|
|
@ -124,6 +124,7 @@
|
|||
"parse-duration": "^1.0.2",
|
||||
"rc-slider": "^9.7.5",
|
||||
"react": "^17.0.2",
|
||||
"react-datetime-picker": "^3.5.0",
|
||||
"react-dom": "^17.0.2",
|
||||
"react-feather": "^2.0.9",
|
||||
"react-i18next": "^11.12.0",
|
||||
|
@ -172,6 +173,7 @@
|
|||
"@types/mustache": "^4.1.2",
|
||||
"@types/nprogress": "^0.2.0",
|
||||
"@types/react": "^17.0.37",
|
||||
"@types/react-datetime-picker": "^3.4.1",
|
||||
"@types/react-dom": "^17.0.11",
|
||||
"@types/react-is": "^17.0.3",
|
||||
"@types/react-table": "^7.7.6",
|
||||
|
|
131
yarn.lock
131
yarn.lock
|
@ -4785,6 +4785,20 @@
|
|||
resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.4.tgz#cd667bcfdd025213aafb7ca5915a932590acdcdc"
|
||||
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":
|
||||
version "17.0.11"
|
||||
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"
|
||||
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":
|
||||
version "0.7.5"
|
||||
resolved "https://registry.yarnpkg.com/@xmldom/xmldom/-/xmldom-0.7.5.tgz#09fa51e356d07d0be200642b0e4f91d8e6dd408d"
|
||||
|
@ -8302,6 +8321,11 @@ detab@2.0.4:
|
|||
dependencies:
|
||||
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:
|
||||
version "1.0.0"
|
||||
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"
|
||||
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:
|
||||
version "2.0.6"
|
||||
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"
|
||||
integrity sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs=
|
||||
|
||||
lodash.memoize@^4.1.2:
|
||||
lodash.memoize@^4.1.1, lodash.memoize@^4.1.2:
|
||||
version "4.1.2"
|
||||
resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe"
|
||||
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:
|
||||
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:
|
||||
version "1.0.1"
|
||||
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"
|
||||
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:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.1.tgz#b00aaa556dd8b44568150ec9d1b953f3f90cbb61"
|
||||
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:
|
||||
version "2.0.0"
|
||||
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"
|
||||
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:
|
||||
version "1.2.6"
|
||||
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:
|
||||
"@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:
|
||||
version "2.2.2"
|
||||
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:
|
||||
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:
|
||||
version "2.9.1"
|
||||
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"
|
||||
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:
|
||||
version "4.2.21"
|
||||
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"
|
||||
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"
|
||||
resolved "https://registry.yarnpkg.com/tiny-warning/-/tiny-warning-1.0.3.tgz#94a30db453df4c643d0fd566060d60a875d84754"
|
||||
integrity sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==
|
||||
|
@ -18123,6 +18245,11 @@ update-browserslist-db@^1.0.0:
|
|||
escalade "^3.1.1"
|
||||
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:
|
||||
version "1.1.2"
|
||||
resolved "https://registry.yarnpkg.com/upper-case-first/-/upper-case-first-1.1.2.tgz#5d79bedcff14419518fd2edb0a0507c9b6859115"
|
||||
|
|
Loading…
Reference in New Issue