feat(edge/update): select endpoints to update [EE-4043] (#7602)
parent
36e7981ab7
commit
4d123895ea
|
@ -2,7 +2,9 @@ package edgeupdateschedule
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/pkg/errors"
|
||||||
portainer "github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
"github.com/portainer/portainer/api/edgetypes"
|
"github.com/portainer/portainer/api/edgetypes"
|
||||||
"github.com/sirupsen/logrus"
|
"github.com/sirupsen/logrus"
|
||||||
|
@ -16,6 +18,9 @@ const (
|
||||||
// Service represents a service for managing Edge Update Schedule data.
|
// Service represents a service for managing Edge Update Schedule data.
|
||||||
type Service struct {
|
type Service struct {
|
||||||
connection portainer.Connection
|
connection portainer.Connection
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
idxActiveSchedules map[portainer.EndpointID]*edgetypes.EndpointUpdateScheduleRelation
|
||||||
}
|
}
|
||||||
|
|
||||||
func (service *Service) BucketName() string {
|
func (service *Service) BucketName() string {
|
||||||
|
@ -29,9 +34,44 @@ func NewService(connection portainer.Connection) (*Service, error) {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return &Service{
|
service := &Service{
|
||||||
connection: connection,
|
connection: connection,
|
||||||
}, nil
|
}
|
||||||
|
|
||||||
|
service.idxActiveSchedules = map[portainer.EndpointID]*edgetypes.EndpointUpdateScheduleRelation{}
|
||||||
|
|
||||||
|
schedules, err := service.List()
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.WithMessage(err, "Unable to list schedules")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, schedule := range schedules {
|
||||||
|
service.setRelation(&schedule)
|
||||||
|
}
|
||||||
|
|
||||||
|
return service, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service *Service) ActiveSchedule(environmentID portainer.EndpointID) *edgetypes.EndpointUpdateScheduleRelation {
|
||||||
|
service.mu.Lock()
|
||||||
|
defer service.mu.Unlock()
|
||||||
|
|
||||||
|
return service.idxActiveSchedules[environmentID]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service *Service) ActiveSchedules(environmentsIDs []portainer.EndpointID) []edgetypes.EndpointUpdateScheduleRelation {
|
||||||
|
service.mu.Lock()
|
||||||
|
defer service.mu.Unlock()
|
||||||
|
|
||||||
|
schedules := []edgetypes.EndpointUpdateScheduleRelation{}
|
||||||
|
|
||||||
|
for _, environmentID := range environmentsIDs {
|
||||||
|
if s, ok := service.idxActiveSchedules[environmentID]; ok {
|
||||||
|
schedules = append(schedules, *s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return schedules
|
||||||
}
|
}
|
||||||
|
|
||||||
// List return an array containing all the items in the bucket.
|
// List return an array containing all the items in the bucket.
|
||||||
|
@ -45,7 +85,7 @@ func (service *Service) List() ([]edgetypes.UpdateSchedule, error) {
|
||||||
item, ok := obj.(*edgetypes.UpdateSchedule)
|
item, ok := obj.(*edgetypes.UpdateSchedule)
|
||||||
if !ok {
|
if !ok {
|
||||||
logrus.WithField("obj", obj).Errorf("Failed to convert to EdgeUpdateSchedule object")
|
logrus.WithField("obj", obj).Errorf("Failed to convert to EdgeUpdateSchedule object")
|
||||||
return nil, fmt.Errorf("Failed to convert to EdgeUpdateSchedule object: %s", obj)
|
return nil, fmt.Errorf("failed to convert to EdgeUpdateSchedule object: %s", obj)
|
||||||
}
|
}
|
||||||
list = append(list, *item)
|
list = append(list, *item)
|
||||||
return &edgetypes.UpdateSchedule{}, nil
|
return &edgetypes.UpdateSchedule{}, nil
|
||||||
|
@ -69,23 +109,77 @@ func (service *Service) Item(ID edgetypes.UpdateScheduleID) (*edgetypes.UpdateSc
|
||||||
|
|
||||||
// Create assign an ID to a new object and saves it.
|
// Create assign an ID to a new object and saves it.
|
||||||
func (service *Service) Create(item *edgetypes.UpdateSchedule) error {
|
func (service *Service) Create(item *edgetypes.UpdateSchedule) error {
|
||||||
return service.connection.CreateObject(
|
err := service.connection.CreateObject(
|
||||||
BucketName,
|
BucketName,
|
||||||
func(id uint64) (int, interface{}) {
|
func(id uint64) (int, interface{}) {
|
||||||
item.ID = edgetypes.UpdateScheduleID(id)
|
item.ID = edgetypes.UpdateScheduleID(id)
|
||||||
return int(item.ID), item
|
return int(item.ID), item
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return service.setRelation(item)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update updates an item.
|
// Update updates an item.
|
||||||
func (service *Service) Update(ID edgetypes.UpdateScheduleID, item *edgetypes.UpdateSchedule) error {
|
func (service *Service) Update(id edgetypes.UpdateScheduleID, item *edgetypes.UpdateSchedule) error {
|
||||||
identifier := service.connection.ConvertToKey(int(ID))
|
identifier := service.connection.ConvertToKey(int(id))
|
||||||
return service.connection.UpdateObject(BucketName, identifier, item)
|
err := service.connection.UpdateObject(BucketName, identifier, item)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
service.cleanRelation(id)
|
||||||
|
|
||||||
|
return service.setRelation(item)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete deletes an item.
|
// Delete deletes an item.
|
||||||
func (service *Service) Delete(ID edgetypes.UpdateScheduleID) error {
|
func (service *Service) Delete(id edgetypes.UpdateScheduleID) error {
|
||||||
identifier := service.connection.ConvertToKey(int(ID))
|
|
||||||
|
service.cleanRelation(id)
|
||||||
|
|
||||||
|
identifier := service.connection.ConvertToKey(int(id))
|
||||||
return service.connection.DeleteObject(BucketName, identifier)
|
return service.connection.DeleteObject(BucketName, identifier)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (service *Service) cleanRelation(id edgetypes.UpdateScheduleID) {
|
||||||
|
service.mu.Lock()
|
||||||
|
defer service.mu.Unlock()
|
||||||
|
|
||||||
|
for _, schedule := range service.idxActiveSchedules {
|
||||||
|
if schedule != nil && schedule.ScheduleID == id {
|
||||||
|
delete(service.idxActiveSchedules, schedule.EnvironmentID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service *Service) setRelation(schedule *edgetypes.UpdateSchedule) error {
|
||||||
|
service.mu.Lock()
|
||||||
|
defer service.mu.Unlock()
|
||||||
|
|
||||||
|
for environmentID, environmentStatus := range schedule.Status {
|
||||||
|
if environmentStatus.Status != edgetypes.UpdateScheduleStatusPending {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// this should never happen
|
||||||
|
if service.idxActiveSchedules[environmentID] != nil && service.idxActiveSchedules[environmentID].ScheduleID != schedule.ID {
|
||||||
|
return errors.New("Multiple schedules are pending for the same environment")
|
||||||
|
}
|
||||||
|
|
||||||
|
service.idxActiveSchedules[environmentID] = &edgetypes.EndpointUpdateScheduleRelation{
|
||||||
|
EnvironmentID: environmentID,
|
||||||
|
ScheduleID: schedule.ID,
|
||||||
|
TargetVersion: environmentStatus.TargetVersion,
|
||||||
|
Status: environmentStatus.Status,
|
||||||
|
Error: environmentStatus.Error,
|
||||||
|
Type: schedule.Type,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
|
@ -84,6 +84,8 @@ type (
|
||||||
}
|
}
|
||||||
|
|
||||||
EdgeUpdateScheduleService interface {
|
EdgeUpdateScheduleService interface {
|
||||||
|
ActiveSchedule(environmentID portainer.EndpointID) *edgetypes.EndpointUpdateScheduleRelation
|
||||||
|
ActiveSchedules(environmentIDs []portainer.EndpointID) []edgetypes.EndpointUpdateScheduleRelation
|
||||||
List() ([]edgetypes.UpdateSchedule, error)
|
List() ([]edgetypes.UpdateSchedule, error)
|
||||||
Item(ID edgetypes.UpdateScheduleID) (*edgetypes.UpdateSchedule, error)
|
Item(ID edgetypes.UpdateScheduleID) (*edgetypes.UpdateSchedule, error)
|
||||||
Create(edgeUpdateSchedule *edgetypes.UpdateSchedule) error
|
Create(edgeUpdateSchedule *edgetypes.UpdateSchedule) error
|
||||||
|
|
|
@ -13,13 +13,6 @@ const (
|
||||||
|
|
||||||
type (
|
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 represents an Edge schedule identifier
|
||||||
UpdateScheduleID int
|
UpdateScheduleID int
|
||||||
|
|
||||||
|
@ -41,8 +34,6 @@ type (
|
||||||
Created int64 `json:"created" example:"1564897200"`
|
Created int64 `json:"created" example:"1564897200"`
|
||||||
// Created by user id
|
// Created by user id
|
||||||
CreatedBy portainer.UserID `json:"createdBy" example:"1"`
|
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 represents type of an Edge update schedule
|
||||||
|
@ -73,6 +64,24 @@ type (
|
||||||
// Update schedule ID
|
// Update schedule ID
|
||||||
ScheduleID UpdateScheduleID
|
ScheduleID UpdateScheduleID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// VersionUpdateStatus represents the status of an agent version update
|
||||||
|
VersionUpdateStatus struct {
|
||||||
|
Status UpdateScheduleStatusType
|
||||||
|
ScheduleID UpdateScheduleID
|
||||||
|
Error string
|
||||||
|
}
|
||||||
|
|
||||||
|
// EndpointUpdateScheduleRelation represents the relation between an environment(endpoint) and an update schedule
|
||||||
|
EndpointUpdateScheduleRelation struct {
|
||||||
|
EnvironmentID portainer.EndpointID `json:"environmentId"`
|
||||||
|
ScheduleID UpdateScheduleID `json:"scheduleId"`
|
||||||
|
TargetVersion string `json:"targetVersion"`
|
||||||
|
Status UpdateScheduleStatusType `json:"status"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
Type UpdateScheduleType `json:"type"`
|
||||||
|
ScheduledTime int64 `json:"scheduledTime"`
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|
|
@ -0,0 +1,32 @@
|
||||||
|
package edgeupdateschedules
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
httperror "github.com/portainer/libhttp/error"
|
||||||
|
"github.com/portainer/libhttp/response"
|
||||||
|
)
|
||||||
|
|
||||||
|
// @id AgentVersions
|
||||||
|
// @summary Fetches the supported versions of the agent to update/rollback
|
||||||
|
// @description
|
||||||
|
// @description **Access policy**: authenticated
|
||||||
|
// @tags edge_update_schedules
|
||||||
|
// @security ApiKeyAuth
|
||||||
|
// @security jwt
|
||||||
|
// @produce json
|
||||||
|
// @success 200 {array} string
|
||||||
|
// @failure 400 "Invalid request"
|
||||||
|
// @failure 500 "Server error"
|
||||||
|
// @router /edge_update_schedules/agent_versions [get]
|
||||||
|
func (h *Handler) agentVersions(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||||
|
return response.JSON(w, []string{
|
||||||
|
"2.13.0",
|
||||||
|
"2.13.1",
|
||||||
|
"2.14.0",
|
||||||
|
"2.14.1",
|
||||||
|
"2.14.2",
|
||||||
|
"2.15", // for develop only
|
||||||
|
"develop", // for develop only
|
||||||
|
})
|
||||||
|
}
|
|
@ -0,0 +1,42 @@
|
||||||
|
package edgeupdateschedules
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
httperror "github.com/portainer/libhttp/error"
|
||||||
|
"github.com/portainer/libhttp/request"
|
||||||
|
"github.com/portainer/libhttp/response"
|
||||||
|
portainer "github.com/portainer/portainer/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
type activeSchedulePayload struct {
|
||||||
|
EnvironmentIDs []portainer.EndpointID
|
||||||
|
}
|
||||||
|
|
||||||
|
func (payload *activeSchedulePayload) Validate(r *http.Request) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// @id EdgeUpdateScheduleActiveSchedulesList
|
||||||
|
// @summary Fetches the list of Active Edge Update Schedules
|
||||||
|
// @description **Access policy**: administrator
|
||||||
|
// @tags edge_update_schedules
|
||||||
|
// @security ApiKeyAuth
|
||||||
|
// @security jwt
|
||||||
|
// @accept json
|
||||||
|
// @param body body activeSchedulePayload true "Active schedule query"
|
||||||
|
// @produce json
|
||||||
|
// @success 200 {array} edgetypes.EdgeUpdateSchedule
|
||||||
|
// @failure 500
|
||||||
|
// @router /edge_update_schedules/active [get]
|
||||||
|
func (handler *Handler) activeSchedules(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||||
|
var payload activeSchedulePayload
|
||||||
|
err := request.DecodeAndValidateJSONPayload(r, &payload)
|
||||||
|
if err != nil {
|
||||||
|
return httperror.BadRequest("Invalid request payload", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
list := handler.dataStore.EdgeUpdateSchedule().ActiveSchedules(payload.EnvironmentIDs)
|
||||||
|
|
||||||
|
return response.JSON(w, list)
|
||||||
|
}
|
|
@ -15,11 +15,11 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
type createPayload struct {
|
type createPayload struct {
|
||||||
Name string
|
Name string
|
||||||
GroupIDs []portainer.EdgeGroupID
|
GroupIDs []portainer.EdgeGroupID
|
||||||
Type edgetypes.UpdateScheduleType
|
Type edgetypes.UpdateScheduleType
|
||||||
Version string
|
Environments map[portainer.EndpointID]string
|
||||||
Time int64
|
Time int64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (payload *createPayload) Validate(r *http.Request) error {
|
func (payload *createPayload) Validate(r *http.Request) error {
|
||||||
|
@ -35,8 +35,8 @@ func (payload *createPayload) Validate(r *http.Request) error {
|
||||||
return errors.New("Invalid schedule type")
|
return errors.New("Invalid schedule type")
|
||||||
}
|
}
|
||||||
|
|
||||||
if payload.Version == "" {
|
if len(payload.Environments) == 0 {
|
||||||
return errors.New("Invalid version")
|
return errors.New("No Environment is scheduled for update")
|
||||||
}
|
}
|
||||||
|
|
||||||
if payload.Time < time.Now().Unix() {
|
if payload.Time < time.Now().Unix() {
|
||||||
|
@ -85,7 +85,44 @@ func (handler *Handler) create(w http.ResponseWriter, r *http.Request) *httperro
|
||||||
Created: time.Now().Unix(),
|
Created: time.Now().Unix(),
|
||||||
CreatedBy: tokenData.ID,
|
CreatedBy: tokenData.ID,
|
||||||
Type: payload.Type,
|
Type: payload.Type,
|
||||||
Version: payload.Version,
|
}
|
||||||
|
|
||||||
|
schedules, err := handler.dataStore.EdgeUpdateSchedule().List()
|
||||||
|
if err != nil {
|
||||||
|
return httperror.InternalServerError("Unable to list edge update schedules", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
prevVersions := map[portainer.EndpointID]string{}
|
||||||
|
if item.Type == edgetypes.UpdateScheduleRollback {
|
||||||
|
prevVersions = previousVersions(schedules)
|
||||||
|
}
|
||||||
|
|
||||||
|
for environmentID, version := range payload.Environments {
|
||||||
|
environment, err := handler.dataStore.Endpoint().Endpoint(environmentID)
|
||||||
|
if err != nil {
|
||||||
|
return httperror.InternalServerError("Unable to retrieve environment from the database", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO check that env is standalone (snapshots)
|
||||||
|
if environment.Type != portainer.EdgeAgentOnDockerEnvironment {
|
||||||
|
return httperror.BadRequest("Only standalone docker Environments are supported for remote update", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// validate version id is valid for rollback
|
||||||
|
if item.Type == edgetypes.UpdateScheduleRollback {
|
||||||
|
if prevVersions[environmentID] == "" {
|
||||||
|
return httperror.BadRequest("No previous version found for environment", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
if version != prevVersions[environmentID] {
|
||||||
|
return httperror.BadRequest("Rollback version must match previous version", nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
item.Status[environmentID] = edgetypes.UpdateScheduleStatus{
|
||||||
|
TargetVersion: version,
|
||||||
|
CurrentVersion: environment.Agent.Version,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
err = handler.dataStore.EdgeUpdateSchedule().Create(item)
|
err = handler.dataStore.EdgeUpdateSchedule().Create(item)
|
||||||
|
|
|
@ -15,11 +15,11 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
type updatePayload struct {
|
type updatePayload struct {
|
||||||
Name string
|
Name string
|
||||||
GroupIDs []portainer.EdgeGroupID
|
GroupIDs []portainer.EdgeGroupID
|
||||||
Type edgetypes.UpdateScheduleType
|
Environments map[portainer.EndpointID]string
|
||||||
Version string
|
Type edgetypes.UpdateScheduleType
|
||||||
Time int64
|
Time int64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (payload *updatePayload) Validate(r *http.Request) error {
|
func (payload *updatePayload) Validate(r *http.Request) error {
|
||||||
|
@ -35,8 +35,8 @@ func (payload *updatePayload) Validate(r *http.Request) error {
|
||||||
return errors.New("Invalid schedule type")
|
return errors.New("Invalid schedule type")
|
||||||
}
|
}
|
||||||
|
|
||||||
if payload.Version == "" {
|
if len(payload.Environments) == 0 {
|
||||||
return errors.New("Invalid version")
|
return errors.New("No Environment is scheduled for update")
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
@ -80,7 +80,23 @@ func (handler *Handler) update(w http.ResponseWriter, r *http.Request) *httperro
|
||||||
item.GroupIDs = payload.GroupIDs
|
item.GroupIDs = payload.GroupIDs
|
||||||
item.Time = payload.Time
|
item.Time = payload.Time
|
||||||
item.Type = payload.Type
|
item.Type = payload.Type
|
||||||
item.Version = payload.Version
|
|
||||||
|
item.Status = map[portainer.EndpointID]edgetypes.UpdateScheduleStatus{}
|
||||||
|
for environmentID, version := range payload.Environments {
|
||||||
|
environment, err := handler.dataStore.Endpoint().Endpoint(environmentID)
|
||||||
|
if err != nil {
|
||||||
|
return httperror.InternalServerError("Unable to retrieve environment from the database", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if environment.Type != portainer.EdgeAgentOnDockerEnvironment {
|
||||||
|
return httperror.BadRequest("Only standalone docker Environments are supported for remote update", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
item.Status[environmentID] = edgetypes.UpdateScheduleStatus{
|
||||||
|
TargetVersion: version,
|
||||||
|
CurrentVersion: environment.Agent.Version,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
err = handler.dataStore.EdgeUpdateSchedule().Update(item.ID, item)
|
err = handler.dataStore.EdgeUpdateSchedule().Update(item.ID, item)
|
||||||
|
|
|
@ -15,14 +15,14 @@ import (
|
||||||
|
|
||||||
const contextKey = "edgeUpdateSchedule_item"
|
const contextKey = "edgeUpdateSchedule_item"
|
||||||
|
|
||||||
// Handler is the HTTP handler used to handle edge environment(endpoint) operations.
|
// Handler is the HTTP handler used to handle edge environment update operations.
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
*mux.Router
|
*mux.Router
|
||||||
requestBouncer *security.RequestBouncer
|
requestBouncer *security.RequestBouncer
|
||||||
dataStore dataservices.DataStore
|
dataStore dataservices.DataStore
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHandler creates a handler to manage environment(endpoint) operations.
|
// NewHandler creates a handler to manage environment update operations.
|
||||||
func NewHandler(bouncer *security.RequestBouncer, dataStore dataservices.DataStore) *Handler {
|
func NewHandler(bouncer *security.RequestBouncer, dataStore dataservices.DataStore) *Handler {
|
||||||
h := &Handler{
|
h := &Handler{
|
||||||
Router: mux.NewRouter(),
|
Router: mux.NewRouter(),
|
||||||
|
@ -40,6 +40,15 @@ func NewHandler(bouncer *security.RequestBouncer, dataStore dataservices.DataSto
|
||||||
router.Handle("",
|
router.Handle("",
|
||||||
httperror.LoggerHandler(h.create)).Methods(http.MethodPost)
|
httperror.LoggerHandler(h.create)).Methods(http.MethodPost)
|
||||||
|
|
||||||
|
router.Handle("/active",
|
||||||
|
httperror.LoggerHandler(h.activeSchedules)).Methods(http.MethodPost)
|
||||||
|
|
||||||
|
router.Handle("/agent_versions",
|
||||||
|
httperror.LoggerHandler(h.agentVersions)).Methods(http.MethodGet)
|
||||||
|
|
||||||
|
router.Handle("/previous_versions",
|
||||||
|
httperror.LoggerHandler(h.previousVersions)).Methods(http.MethodGet)
|
||||||
|
|
||||||
itemRouter := router.PathPrefix("/{id}").Subrouter()
|
itemRouter := router.PathPrefix("/{id}").Subrouter()
|
||||||
itemRouter.Use(middlewares.WithItem(func(id edgetypes.UpdateScheduleID) (*edgetypes.UpdateSchedule, error) {
|
itemRouter.Use(middlewares.WithItem(func(id edgetypes.UpdateScheduleID) (*edgetypes.UpdateSchedule, error) {
|
||||||
return dataStore.EdgeUpdateSchedule().Item(id)
|
return dataStore.EdgeUpdateSchedule().Item(id)
|
||||||
|
|
|
@ -0,0 +1,86 @@
|
||||||
|
package edgeupdateschedules
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
httperror "github.com/portainer/libhttp/error"
|
||||||
|
"github.com/portainer/libhttp/response"
|
||||||
|
portainer "github.com/portainer/portainer/api"
|
||||||
|
"github.com/portainer/portainer/api/edgetypes"
|
||||||
|
"golang.org/x/exp/slices"
|
||||||
|
)
|
||||||
|
|
||||||
|
// @id EdgeUpdatePreviousVersions
|
||||||
|
// @summary Fetches the previous versions of updated agents
|
||||||
|
// @description
|
||||||
|
// @description **Access policy**: authenticated
|
||||||
|
// @tags edge_update_schedules
|
||||||
|
// @security ApiKeyAuth
|
||||||
|
// @security jwt
|
||||||
|
// @produce json
|
||||||
|
// @success 200 {array} string
|
||||||
|
// @failure 400 "Invalid request"
|
||||||
|
// @failure 500 "Server error"
|
||||||
|
// @router /edge_update_schedules/agent_versions [get]
|
||||||
|
func (handler *Handler) previousVersions(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||||
|
schedules, err := handler.dataStore.EdgeUpdateSchedule().List()
|
||||||
|
if err != nil {
|
||||||
|
return httperror.InternalServerError("Unable to retrieve the edge update schedules list", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
versionMap := previousVersions(schedules)
|
||||||
|
|
||||||
|
return response.JSON(w, versionMap)
|
||||||
|
}
|
||||||
|
|
||||||
|
type EnvironmentVersionDetails struct {
|
||||||
|
version string
|
||||||
|
skip bool
|
||||||
|
skipReason string
|
||||||
|
}
|
||||||
|
|
||||||
|
func previousVersions(schedules []edgetypes.UpdateSchedule) map[portainer.EndpointID]string {
|
||||||
|
|
||||||
|
slices.SortFunc(schedules, func(a edgetypes.UpdateSchedule, b edgetypes.UpdateSchedule) bool {
|
||||||
|
return a.Created > b.Created
|
||||||
|
})
|
||||||
|
|
||||||
|
environmentMap := map[portainer.EndpointID]*EnvironmentVersionDetails{}
|
||||||
|
// to all schedules[:schedule index -1].Created > schedule.Created
|
||||||
|
for _, schedule := range schedules {
|
||||||
|
for environmentId, status := range schedule.Status {
|
||||||
|
props, ok := environmentMap[environmentId]
|
||||||
|
if !ok {
|
||||||
|
environmentMap[environmentId] = &EnvironmentVersionDetails{}
|
||||||
|
props = environmentMap[environmentId]
|
||||||
|
}
|
||||||
|
|
||||||
|
if props.version != "" || props.skip {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if schedule.Type == edgetypes.UpdateScheduleRollback {
|
||||||
|
props.skip = true
|
||||||
|
props.skipReason = "has rollback"
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if status.Status == edgetypes.UpdateScheduleStatusPending || status.Status == edgetypes.UpdateScheduleStatusError {
|
||||||
|
props.skip = true
|
||||||
|
props.skipReason = "has active schedule"
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
props.version = status.CurrentVersion
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
versionMap := map[portainer.EndpointID]string{}
|
||||||
|
for environmentId, props := range environmentMap {
|
||||||
|
if !props.skip {
|
||||||
|
versionMap[environmentId] = props.version
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return versionMap
|
||||||
|
}
|
|
@ -0,0 +1,63 @@
|
||||||
|
package edgeupdateschedules
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
portainer "github.com/portainer/portainer/api"
|
||||||
|
"github.com/portainer/portainer/api/edgetypes"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPreviousVersions(t *testing.T) {
|
||||||
|
|
||||||
|
schedules := []edgetypes.UpdateSchedule{
|
||||||
|
{
|
||||||
|
ID: 1,
|
||||||
|
Type: edgetypes.UpdateScheduleUpdate,
|
||||||
|
Status: map[portainer.EndpointID]edgetypes.UpdateScheduleStatus{
|
||||||
|
1: {
|
||||||
|
TargetVersion: "2.14.0",
|
||||||
|
CurrentVersion: "2.11.0",
|
||||||
|
Status: edgetypes.UpdateScheduleStatusSuccess,
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
TargetVersion: "2.13.0",
|
||||||
|
CurrentVersion: "2.12.0",
|
||||||
|
Status: edgetypes.UpdateScheduleStatusSuccess,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Created: 1500000000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: 2,
|
||||||
|
Type: edgetypes.UpdateScheduleRollback,
|
||||||
|
Status: map[portainer.EndpointID]edgetypes.UpdateScheduleStatus{
|
||||||
|
1: {
|
||||||
|
TargetVersion: "2.11.0",
|
||||||
|
CurrentVersion: "2.14.0",
|
||||||
|
Status: edgetypes.UpdateScheduleStatusSuccess,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Created: 1500000001,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: 3,
|
||||||
|
Type: edgetypes.UpdateScheduleUpdate,
|
||||||
|
Status: map[portainer.EndpointID]edgetypes.UpdateScheduleStatus{
|
||||||
|
2: {
|
||||||
|
TargetVersion: "2.14.0",
|
||||||
|
CurrentVersion: "2.13.0",
|
||||||
|
Status: edgetypes.UpdateScheduleStatusSuccess,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Created: 1500000002,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
actual := previousVersions(schedules)
|
||||||
|
|
||||||
|
assert.Equal(t, map[portainer.EndpointID]string{
|
||||||
|
2: "2.13.0",
|
||||||
|
}, actual)
|
||||||
|
|
||||||
|
}
|
|
@ -101,6 +101,26 @@ code {
|
||||||
background-color: var(--bg-code-color);
|
background-color: var(--bg-code-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nav-container {
|
||||||
|
border: 1px solid var(--border-nav-container-color);
|
||||||
|
background-color: var(--bg-nav-container-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tabs {
|
||||||
|
border-bottom: 1px solid var(--border-navtabs-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tabs > li {
|
||||||
|
background-color: var(--bg-nav-tabs-active-color);
|
||||||
|
border-top-right-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tabs > li.active > a {
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
.nav-tabs > li.active > a,
|
.nav-tabs > li.active > a,
|
||||||
.nav-tabs > li.active > a:hover,
|
.nav-tabs > li.active > a:hover,
|
||||||
.nav-tabs > li.active > a:focus {
|
.nav-tabs > li.active > a:focus {
|
||||||
|
@ -109,8 +129,9 @@ code {
|
||||||
border: 1px solid var(--border-navtabs-color);
|
border: 1px solid var(--border-navtabs-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-tabs {
|
.nav-tabs > li.disabled > a {
|
||||||
border-bottom: 1px solid var(--border-navtabs-color);
|
border-color: transparent;
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-tabs > li > a:hover {
|
.nav-tabs > li > a:hover {
|
||||||
|
@ -397,10 +418,6 @@ input:-webkit-autofill {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-tabs > li.active > a {
|
|
||||||
border: 0px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.label-default {
|
.label-default {
|
||||||
line-height: 11px;
|
line-height: 11px;
|
||||||
}
|
}
|
||||||
|
@ -412,34 +429,3 @@ input:-webkit-autofill {
|
||||||
border-bottom-right-radius: 8px;
|
border-bottom-right-radius: 8px;
|
||||||
padding: 5px;
|
padding: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-container {
|
|
||||||
border: 1px solid var(--border-nav-container-color);
|
|
||||||
background-color: var(--bg-nav-container-color);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-tabs > li {
|
|
||||||
background-color: var(--bg-nav-tabs-active-color);
|
|
||||||
border-top-right-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Code Script Style */
|
|
||||||
.code-script {
|
|
||||||
background-color: var(--bg-code-script-color);
|
|
||||||
border-bottom-left-radius: 8px;
|
|
||||||
border-bottom-right-radius: 8px;
|
|
||||||
padding: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-container {
|
|
||||||
border: 1px solid var(--border-nav-container-color);
|
|
||||||
background-color: var(--bg-nav-container-color);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-tabs > li {
|
|
||||||
border-top-right-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
|
@ -32,6 +32,7 @@ export function NavTabs<T extends string | number = string>({
|
||||||
className={clsx({
|
className={clsx({
|
||||||
active: option.id === selectedId,
|
active: option.id === selectedId,
|
||||||
[styles.parent]: !option.children,
|
[styles.parent]: !option.children,
|
||||||
|
disabled,
|
||||||
})}
|
})}
|
||||||
key={option.id}
|
key={option.id}
|
||||||
>
|
>
|
||||||
|
@ -53,7 +54,7 @@ export function NavTabs<T extends string | number = string>({
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
{selected && selected.children && (
|
{selected && selected.children && (
|
||||||
<div className="tab-content">{selected.children}</div>
|
<div className="tab-content mt-3">{selected.children}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -40,7 +40,7 @@ export const Checkbox = forwardRef<HTMLInputElement, Props>(
|
||||||
}, [resolvedRef, indeterminate]);
|
}, [resolvedRef, indeterminate]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="md-checkbox" title={title || label}>
|
<div className="md-checkbox flex" title={title || label}>
|
||||||
<input
|
<input
|
||||||
id={id}
|
id={id}
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
|
|
@ -13,6 +13,10 @@ async function getEdgeGroups() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useEdgeGroups() {
|
export function useEdgeGroups<T = EdgeGroup[]>({
|
||||||
return useQuery(['edge', 'groups'], getEdgeGroups);
|
select,
|
||||||
|
}: {
|
||||||
|
select?: (groups: EdgeGroup[]) => T;
|
||||||
|
} = {}) {
|
||||||
|
return useQuery(['edge', 'groups'], getEdgeGroups, { select });
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,7 +16,7 @@ import { ScheduleType } from '../types';
|
||||||
import { useCreateMutation } from '../queries/create';
|
import { useCreateMutation } from '../queries/create';
|
||||||
import { FormValues } from '../common/types';
|
import { FormValues } from '../common/types';
|
||||||
import { validation } from '../common/validation';
|
import { validation } from '../common/validation';
|
||||||
import { UpdateTypeTabs } from '../common/UpdateTypeTabs';
|
import { ScheduleTypeSelector } from '../common/ScheduleTypeSelector';
|
||||||
import { useList } from '../queries/list';
|
import { useList } from '../queries/list';
|
||||||
import { EdgeGroupsField } from '../common/EdgeGroupsField';
|
import { EdgeGroupsField } from '../common/EdgeGroupsField';
|
||||||
import { NameField } from '../common/NameField';
|
import { NameField } from '../common/NameField';
|
||||||
|
@ -25,8 +25,8 @@ const initialValues: FormValues = {
|
||||||
name: '',
|
name: '',
|
||||||
groupIds: [],
|
groupIds: [],
|
||||||
type: ScheduleType.Update,
|
type: ScheduleType.Update,
|
||||||
version: 'latest',
|
|
||||||
time: Math.floor(Date.now() / 1000) + 60 * 60,
|
time: Math.floor(Date.now() / 1000) + 60 * 60,
|
||||||
|
environments: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
export function CreateView() {
|
export function CreateView() {
|
||||||
|
@ -56,23 +56,15 @@ export function CreateView() {
|
||||||
<Widget.Body>
|
<Widget.Body>
|
||||||
<Formik
|
<Formik
|
||||||
initialValues={initialValues}
|
initialValues={initialValues}
|
||||||
onSubmit={(values) => {
|
onSubmit={handleSubmit}
|
||||||
createMutation.mutate(values, {
|
|
||||||
onSuccess() {
|
|
||||||
notifySuccess('Success', 'Created schedule successfully');
|
|
||||||
router.stateService.go('^');
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
validateOnMount
|
validateOnMount
|
||||||
validationSchema={() => validation(schedules)}
|
validationSchema={() => validation(schedules)}
|
||||||
>
|
>
|
||||||
{({ isValid }) => (
|
{({ isValid }) => (
|
||||||
<FormikForm className="form-horizontal">
|
<FormikForm className="form-horizontal">
|
||||||
<NameField />
|
<NameField />
|
||||||
|
|
||||||
<EdgeGroupsField />
|
<EdgeGroupsField />
|
||||||
<UpdateTypeTabs />
|
<ScheduleTypeSelector />
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<div className="col-sm-12">
|
<div className="col-sm-12">
|
||||||
<LoadingButton
|
<LoadingButton
|
||||||
|
@ -93,4 +85,13 @@ export function CreateView() {
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
function handleSubmit(values: FormValues) {
|
||||||
|
createMutation.mutate(values, {
|
||||||
|
onSuccess() {
|
||||||
|
notifySuccess('Success', 'Created schedule successfully');
|
||||||
|
router.stateService.go('^');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,7 +14,7 @@ import { PageHeader } from '@@/PageHeader';
|
||||||
import { Widget } from '@@/Widget';
|
import { Widget } from '@@/Widget';
|
||||||
import { LoadingButton } from '@@/buttons';
|
import { LoadingButton } from '@@/buttons';
|
||||||
|
|
||||||
import { UpdateTypeTabs } from '../common/UpdateTypeTabs';
|
import { ScheduleTypeSelector } from '../common/ScheduleTypeSelector';
|
||||||
import { useItem } from '../queries/useItem';
|
import { useItem } from '../queries/useItem';
|
||||||
import { validation } from '../common/validation';
|
import { validation } from '../common/validation';
|
||||||
import { useUpdateMutation } from '../queries/useUpdateMutation';
|
import { useUpdateMutation } from '../queries/useUpdateMutation';
|
||||||
|
@ -24,6 +24,8 @@ import { EdgeGroupsField } from '../common/EdgeGroupsField';
|
||||||
import { EdgeUpdateSchedule } from '../types';
|
import { EdgeUpdateSchedule } from '../types';
|
||||||
import { FormValues } from '../common/types';
|
import { FormValues } from '../common/types';
|
||||||
|
|
||||||
|
import { ScheduleDetails } from './ScheduleDetails';
|
||||||
|
|
||||||
export function ItemView() {
|
export function ItemView() {
|
||||||
useRedirectFeatureFlag(FeatureFlag.EdgeRemoteUpdate);
|
useRedirectFeatureFlag(FeatureFlag.EdgeRemoteUpdate);
|
||||||
|
|
||||||
|
@ -53,11 +55,28 @@ export function ItemView() {
|
||||||
|
|
||||||
const item = itemQuery.data;
|
const item = itemQuery.data;
|
||||||
const schedules = schedulesQuery.data;
|
const schedules = schedulesQuery.data;
|
||||||
|
|
||||||
|
const initialValues: FormValues = {
|
||||||
|
name: item.name,
|
||||||
|
groupIds: item.groupIds,
|
||||||
|
type: item.type,
|
||||||
|
time: item.time,
|
||||||
|
environments: Object.fromEntries(
|
||||||
|
Object.entries(item.status).map(([envId, status]) => [
|
||||||
|
parseInt(envId, 10),
|
||||||
|
status.targetVersion,
|
||||||
|
])
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Update & Rollback"
|
title="Update & Rollback"
|
||||||
breadcrumbs={['Edge agent update and rollback', item.name]}
|
breadcrumbs={[
|
||||||
|
{ label: 'Edge agent update and rollback', link: '^' },
|
||||||
|
item.name,
|
||||||
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="row">
|
<div className="row">
|
||||||
|
@ -66,7 +85,7 @@ export function ItemView() {
|
||||||
<Widget.Title title="Update & Rollback Scheduler" icon={Settings} />
|
<Widget.Title title="Update & Rollback Scheduler" icon={Settings} />
|
||||||
<Widget.Body>
|
<Widget.Body>
|
||||||
<Formik
|
<Formik
|
||||||
initialValues={item}
|
initialValues={initialValues}
|
||||||
onSubmit={(values) => {
|
onSubmit={(values) => {
|
||||||
updateMutation.mutate(
|
updateMutation.mutate(
|
||||||
{ id, values },
|
{ id, values },
|
||||||
|
@ -82,7 +101,9 @@ export function ItemView() {
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
validateOnMount
|
validateOnMount
|
||||||
validationSchema={() => updateValidation(item, schedules)}
|
validationSchema={() =>
|
||||||
|
updateValidation(item.id, item.time, schedules)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{({ isValid }) => (
|
{({ isValid }) => (
|
||||||
<FormikForm className="form-horizontal">
|
<FormikForm className="form-horizontal">
|
||||||
|
@ -90,7 +111,11 @@ export function ItemView() {
|
||||||
|
|
||||||
<EdgeGroupsField disabled={isDisabled} />
|
<EdgeGroupsField disabled={isDisabled} />
|
||||||
|
|
||||||
<UpdateTypeTabs disabled={isDisabled} />
|
{isDisabled ? (
|
||||||
|
<ScheduleDetails schedule={item} />
|
||||||
|
) : (
|
||||||
|
<ScheduleTypeSelector />
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<div className="col-sm-12">
|
<div className="col-sm-12">
|
||||||
|
@ -115,10 +140,11 @@ export function ItemView() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateValidation(
|
function updateValidation(
|
||||||
item: EdgeUpdateSchedule,
|
itemId: EdgeUpdateSchedule['id'],
|
||||||
|
scheduledTime: number,
|
||||||
schedules: EdgeUpdateSchedule[]
|
schedules: EdgeUpdateSchedule[]
|
||||||
): SchemaOf<{ name: string } | FormValues> {
|
): SchemaOf<{ name: string } | FormValues> {
|
||||||
return item.time > Date.now() / 1000
|
return scheduledTime > Date.now() / 1000
|
||||||
? validation(schedules, item.id)
|
? validation(schedules, itemId)
|
||||||
: object({ name: nameValidation(schedules, item.id) });
|
: object({ name: nameValidation(schedules, itemId) });
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,67 @@
|
||||||
|
import _ from 'lodash';
|
||||||
|
|
||||||
|
import { NavTabs } from '@@/NavTabs';
|
||||||
|
|
||||||
|
import { EdgeUpdateSchedule, ScheduleType } from '../types';
|
||||||
|
import { ScheduledTimeField } from '../common/ScheduledTimeField';
|
||||||
|
|
||||||
|
export function ScheduleDetails({
|
||||||
|
schedule,
|
||||||
|
}: {
|
||||||
|
schedule: EdgeUpdateSchedule;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="form-group">
|
||||||
|
<div className="col-sm-12">
|
||||||
|
<NavTabs
|
||||||
|
options={[
|
||||||
|
{
|
||||||
|
id: ScheduleType.Update,
|
||||||
|
label: 'Update',
|
||||||
|
children: <UpdateDetails schedule={schedule} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: ScheduleType.Rollback,
|
||||||
|
label: 'Rollback',
|
||||||
|
children: <UpdateDetails schedule={schedule} />,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
selectedId={schedule.type}
|
||||||
|
onSelect={() => {}}
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function UpdateDetails({ schedule }: { schedule: EdgeUpdateSchedule }) {
|
||||||
|
const schedulesCount = Object.values(
|
||||||
|
_.groupBy(
|
||||||
|
schedule.status,
|
||||||
|
(status) => `${status.currentVersion}-${status.targetVersion}`
|
||||||
|
)
|
||||||
|
).map((statuses) => ({
|
||||||
|
count: statuses.length,
|
||||||
|
currentVersion: statuses[0].currentVersion,
|
||||||
|
targetVersion: statuses[0].targetVersion,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="form-group">
|
||||||
|
<div className="col-sm-12">
|
||||||
|
{schedulesCount.map(({ count, currentVersion, targetVersion }) => (
|
||||||
|
<div key={`${currentVersion}-${targetVersion}`}>
|
||||||
|
{count} edge device(s) selected for{' '}
|
||||||
|
{schedule.type === ScheduleType.Rollback ? 'rollback' : 'update'}{' '}
|
||||||
|
from v{currentVersion} to v{targetVersion}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScheduledTimeField disabled />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -28,13 +28,13 @@ function StatusCell({
|
||||||
return 'No related environments';
|
return 'No related environments';
|
||||||
}
|
}
|
||||||
|
|
||||||
const error = statusList.find((s) => s.Type === StatusType.Failed);
|
const error = statusList.find((s) => s.status === StatusType.Failed);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return `Failed: (ID: ${error.environmentId}) ${error.Error}`;
|
return `Failed: (ID: ${error.environmentId}) ${error.error}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const pending = statusList.find((s) => s.Type === StatusType.Pending);
|
const pending = statusList.find((s) => s.status === StatusType.Pending);
|
||||||
|
|
||||||
if (pending) {
|
if (pending) {
|
||||||
return 'Pending';
|
return 'Pending';
|
||||||
|
|
|
@ -0,0 +1,109 @@
|
||||||
|
import _ from 'lodash';
|
||||||
|
import { Clock } from 'react-feather';
|
||||||
|
|
||||||
|
import { Environment } from '@/portainer/environments/types';
|
||||||
|
import { useEdgeGroups } from '@/react/edge/edge-groups/queries/useEdgeGroups';
|
||||||
|
import { EdgeGroup } from '@/react/edge/edge-groups/types';
|
||||||
|
|
||||||
|
import { ActiveSchedule } from '../queries/useActiveSchedules';
|
||||||
|
import { ScheduleType } from '../types';
|
||||||
|
|
||||||
|
export function ActiveSchedulesNotice({
|
||||||
|
selectedEdgeGroupIds,
|
||||||
|
activeSchedules,
|
||||||
|
environments,
|
||||||
|
}: {
|
||||||
|
selectedEdgeGroupIds: EdgeGroup['Id'][];
|
||||||
|
activeSchedules: ActiveSchedule[];
|
||||||
|
environments: Environment[];
|
||||||
|
}) {
|
||||||
|
const groupsQuery = useEdgeGroups();
|
||||||
|
|
||||||
|
if (!groupsQuery.data) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// environmentId -> {currentVersion, targetVersion}
|
||||||
|
const environmentScheduleGroup = Object.fromEntries(
|
||||||
|
activeSchedules.map((schedule) => [
|
||||||
|
schedule.environmentId,
|
||||||
|
{
|
||||||
|
currentVersion:
|
||||||
|
environments.find((env) => env.Id === schedule.environmentId)?.Agent
|
||||||
|
.Version || '',
|
||||||
|
targetVersion: schedule.targetVersion,
|
||||||
|
type: schedule.type,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
const edgeGroups = groupsQuery.data
|
||||||
|
.filter((edgeGroup) => selectedEdgeGroupIds.includes(edgeGroup.Id))
|
||||||
|
.map((edgeGroup) => ({
|
||||||
|
edgeGroupId: edgeGroup.Id,
|
||||||
|
edgeGroupName: edgeGroup.Name,
|
||||||
|
schedules: Object.values(
|
||||||
|
_.groupBy(
|
||||||
|
_.compact(
|
||||||
|
edgeGroup.Endpoints.map((eId) => environmentScheduleGroup[eId])
|
||||||
|
),
|
||||||
|
(schedule) =>
|
||||||
|
`${schedule.currentVersion}_${schedule.targetVersion}_${schedule.type}`
|
||||||
|
)
|
||||||
|
).map((schedules) => ({
|
||||||
|
currentVersion: schedules[0].currentVersion,
|
||||||
|
targetVersion: schedules[0].targetVersion,
|
||||||
|
scheduleCount: schedules.length,
|
||||||
|
type: schedules[0].type,
|
||||||
|
})),
|
||||||
|
}))
|
||||||
|
.filter((group) => group.schedules.length > 0);
|
||||||
|
|
||||||
|
if (edgeGroups.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="form-group">
|
||||||
|
<div className="col-sm-12 space-y-1">
|
||||||
|
{edgeGroups.map(({ edgeGroupId, edgeGroupName, schedules }) =>
|
||||||
|
schedules.map(
|
||||||
|
({ currentVersion, scheduleCount, targetVersion, type }) => (
|
||||||
|
<ActiveSchedulesNoticeItem
|
||||||
|
currentVersion={currentVersion || 'unknown version'}
|
||||||
|
key={`${edgeGroupId}-${currentVersion}-${targetVersion}`}
|
||||||
|
name={edgeGroupName}
|
||||||
|
scheduleCount={scheduleCount}
|
||||||
|
version={targetVersion}
|
||||||
|
scheduleType={type}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ActiveSchedulesNoticeItem({
|
||||||
|
name,
|
||||||
|
scheduleCount,
|
||||||
|
version,
|
||||||
|
currentVersion,
|
||||||
|
scheduleType,
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
scheduleCount: number;
|
||||||
|
version: string;
|
||||||
|
currentVersion: string;
|
||||||
|
scheduleType: ScheduleType;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1 text-sm">
|
||||||
|
<Clock className="feather" />
|
||||||
|
{scheduleCount} edge devices in {name} are scheduled for{' '}
|
||||||
|
{scheduleType === ScheduleType.Rollback ? 'rollback' : 'update'} from{' '}
|
||||||
|
{currentVersion} to {version}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,73 @@
|
||||||
|
import _ from 'lodash';
|
||||||
|
|
||||||
|
import { Environment } from '@/portainer/environments/types';
|
||||||
|
|
||||||
|
import { TextTip } from '@@/Tip/TextTip';
|
||||||
|
|
||||||
|
import { ActiveSchedule } from '../queries/useActiveSchedules';
|
||||||
|
import { useSupportedAgentVersions } from '../queries/useSupportedAgentVersions';
|
||||||
|
|
||||||
|
import { EnvironmentSelectionItem } from './EnvironmentSelectionItem';
|
||||||
|
import { compareVersion } from './utils';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
environments: Environment[];
|
||||||
|
activeSchedules: ActiveSchedule[];
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EnvironmentSelection({
|
||||||
|
environments,
|
||||||
|
activeSchedules,
|
||||||
|
disabled,
|
||||||
|
}: Props) {
|
||||||
|
const supportedAgentVersionsQuery = useSupportedAgentVersions({
|
||||||
|
select: (versions) =>
|
||||||
|
versions.map((version) => ({ label: version, value: version })),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!supportedAgentVersionsQuery.data) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const supportedAgentVersions = supportedAgentVersionsQuery.data;
|
||||||
|
|
||||||
|
const latestVersion = _.last(supportedAgentVersions)?.value;
|
||||||
|
|
||||||
|
const environmentsToUpdate = environments.filter(
|
||||||
|
(env) =>
|
||||||
|
activeSchedules.every((schedule) => schedule.environmentId !== env.Id) &&
|
||||||
|
compareVersion(env.Agent.Version, latestVersion)
|
||||||
|
);
|
||||||
|
|
||||||
|
const versionGroups = Object.entries(
|
||||||
|
_.mapValues(
|
||||||
|
_.groupBy(environmentsToUpdate, (env) => env.Agent.Version),
|
||||||
|
(envs) => envs.map((env) => env.Id)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (environmentsToUpdate.length === 0) {
|
||||||
|
return (
|
||||||
|
<TextTip>
|
||||||
|
The are no update options available for yor selected groups(s)
|
||||||
|
</TextTip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="form-group">
|
||||||
|
<div className="col-sm-12">
|
||||||
|
{versionGroups.map(([version, environmentIds]) => (
|
||||||
|
<EnvironmentSelectionItem
|
||||||
|
currentVersion={version}
|
||||||
|
environmentIds={environmentIds}
|
||||||
|
key={version}
|
||||||
|
versions={supportedAgentVersions}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,85 @@
|
||||||
|
import { useField } from 'formik';
|
||||||
|
import _ from 'lodash';
|
||||||
|
import { useState, ChangeEvent } from 'react';
|
||||||
|
|
||||||
|
import { EnvironmentId } from '@/portainer/environments/types';
|
||||||
|
|
||||||
|
import { Select } from '@@/form-components/Input';
|
||||||
|
import { Checkbox } from '@@/form-components/Checkbox';
|
||||||
|
|
||||||
|
import { FormValues } from './types';
|
||||||
|
import { compareVersion } from './utils';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
currentVersion: string;
|
||||||
|
environmentIds: EnvironmentId[];
|
||||||
|
versions: { label: string; value: string }[];
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EnvironmentSelectionItem({
|
||||||
|
environmentIds,
|
||||||
|
versions,
|
||||||
|
currentVersion = 'unknown',
|
||||||
|
disabled,
|
||||||
|
}: Props) {
|
||||||
|
const [{ value }, , { setValue }] =
|
||||||
|
useField<FormValues['environments']>('environments');
|
||||||
|
const isChecked = environmentIds.every((envId) => !!value[envId]);
|
||||||
|
const supportedVersions = versions.filter(
|
||||||
|
({ value }) => compareVersion(currentVersion, value) // versions that are bigger than the current version
|
||||||
|
);
|
||||||
|
|
||||||
|
const maxVersion = _.last(supportedVersions)?.value;
|
||||||
|
|
||||||
|
const [selectedVersion, setSelectedVersion] = useState(
|
||||||
|
value[environmentIds[0]] || maxVersion || ''
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Checkbox
|
||||||
|
className="flex items-center"
|
||||||
|
id={`version_checkbox_${currentVersion}`}
|
||||||
|
checked={isChecked}
|
||||||
|
onChange={() => handleChange(!isChecked)}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<span className="font-normal flex items-center whitespace-nowrap gap-1">
|
||||||
|
{environmentIds.length} edge agents update from v{currentVersion} to
|
||||||
|
<Select
|
||||||
|
disabled={disabled}
|
||||||
|
value={selectedVersion}
|
||||||
|
options={supportedVersions}
|
||||||
|
onChange={handleVersionChange}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleVersionChange(e: ChangeEvent<HTMLSelectElement>) {
|
||||||
|
const version = e.target.value;
|
||||||
|
setSelectedVersion(version);
|
||||||
|
if (isChecked) {
|
||||||
|
handleChange(isChecked, version);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleChange(isChecked: boolean, version: string = selectedVersion) {
|
||||||
|
const newValue = !isChecked
|
||||||
|
? Object.fromEntries(
|
||||||
|
Object.entries(value).filter(
|
||||||
|
([envId]) => !environmentIds.includes(parseInt(envId, 10))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
: {
|
||||||
|
...value,
|
||||||
|
...Object.fromEntries(
|
||||||
|
environmentIds.map((envId) => [envId, version])
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
setValue(newValue);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,95 @@
|
||||||
|
import { useFormikContext } from 'formik';
|
||||||
|
import { useEffect, useMemo } from 'react';
|
||||||
|
|
||||||
|
import { useEdgeGroups } from '@/react/edge/edge-groups/queries/useEdgeGroups';
|
||||||
|
|
||||||
|
import { TextTip } from '@@/Tip/TextTip';
|
||||||
|
|
||||||
|
import { usePreviousVersions } from '../queries/usePreviousVersions';
|
||||||
|
|
||||||
|
import { FormValues } from './types';
|
||||||
|
import { useEdgeGroupsEnvironmentIds } from './useEdgeGroupsEnvironmentIds';
|
||||||
|
import { ScheduledTimeField } from './ScheduledTimeField';
|
||||||
|
|
||||||
|
export function RollbackScheduleDetailsFieldset() {
|
||||||
|
const environmentsCount = useSelectedEnvironmentsCount();
|
||||||
|
const { isLoading } = useSelectEnvironmentsOnMount();
|
||||||
|
|
||||||
|
const groupNames = useGroupNames();
|
||||||
|
|
||||||
|
if (isLoading || !groupNames) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-3">
|
||||||
|
{environmentsCount > 0 ? (
|
||||||
|
<div className="form-group">
|
||||||
|
<div className="col-sm-12">
|
||||||
|
{environmentsCount} edge device(s) from {groupNames} will rollback
|
||||||
|
to their previous versions
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<TextTip>
|
||||||
|
The are no rollback options available for yor selected groups(s)
|
||||||
|
</TextTip>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ScheduledTimeField />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function useSelectedEnvironmentsCount() {
|
||||||
|
const {
|
||||||
|
values: { environments },
|
||||||
|
} = useFormikContext<FormValues>();
|
||||||
|
|
||||||
|
return Object.keys(environments).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function useSelectEnvironmentsOnMount() {
|
||||||
|
const previousVersionsQuery = usePreviousVersions();
|
||||||
|
|
||||||
|
const {
|
||||||
|
values: { groupIds },
|
||||||
|
setFieldValue,
|
||||||
|
} = useFormikContext<FormValues>();
|
||||||
|
|
||||||
|
const edgeGroupsEnvironmentIds = useEdgeGroupsEnvironmentIds(groupIds);
|
||||||
|
|
||||||
|
const envIdsToUpdate = useMemo(
|
||||||
|
() =>
|
||||||
|
previousVersionsQuery.data
|
||||||
|
? Object.fromEntries(
|
||||||
|
edgeGroupsEnvironmentIds
|
||||||
|
.map((id) => [id, previousVersionsQuery.data[id] || ''] as const)
|
||||||
|
.filter(([, version]) => !!version)
|
||||||
|
)
|
||||||
|
: [],
|
||||||
|
[edgeGroupsEnvironmentIds, previousVersionsQuery.data]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setFieldValue('environments', envIdsToUpdate);
|
||||||
|
}, [envIdsToUpdate, setFieldValue]);
|
||||||
|
|
||||||
|
return { isLoading: previousVersionsQuery.isLoading };
|
||||||
|
}
|
||||||
|
|
||||||
|
function useGroupNames() {
|
||||||
|
const {
|
||||||
|
values: { groupIds },
|
||||||
|
} = useFormikContext<FormValues>();
|
||||||
|
|
||||||
|
const groupsQuery = useEdgeGroups({
|
||||||
|
select: (groups) => Object.fromEntries(groups.map((g) => [g.Id, g.Name])),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!groupsQuery.data) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return groupIds.map((id) => groupsQuery.data[id]).join(', ');
|
||||||
|
}
|
|
@ -6,13 +6,10 @@ import { NavTabs } from '@@/NavTabs';
|
||||||
import { ScheduleType } from '../types';
|
import { ScheduleType } from '../types';
|
||||||
|
|
||||||
import { FormValues } from './types';
|
import { FormValues } from './types';
|
||||||
import { ScheduledTimeField } from './ScheduledTimeField';
|
import { UpdateScheduleDetailsFieldset } from './UpdateScheduleDetailsFieldset';
|
||||||
|
import { RollbackScheduleDetailsFieldset } from './RollbackScheduleDetailsFieldset';
|
||||||
|
|
||||||
interface Props {
|
export function ScheduleTypeSelector() {
|
||||||
disabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function UpdateTypeTabs({ disabled }: Props) {
|
|
||||||
const [{ value }, , { setValue }] = useField<FormValues['type']>('type');
|
const [{ value }, , { setValue }] = useField<FormValues['type']>('type');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -23,31 +20,22 @@ export function UpdateTypeTabs({ disabled }: Props) {
|
||||||
{
|
{
|
||||||
id: ScheduleType.Update,
|
id: ScheduleType.Update,
|
||||||
label: 'Update',
|
label: 'Update',
|
||||||
children: <ScheduleDetails disabled={disabled} />,
|
children: <UpdateScheduleDetailsFieldset />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: ScheduleType.Rollback,
|
id: ScheduleType.Rollback,
|
||||||
label: 'Rollback',
|
label: 'Rollback',
|
||||||
children: <ScheduleDetails disabled={disabled} />,
|
children: <RollbackScheduleDetailsFieldset />,
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
selectedId={value}
|
selectedId={value}
|
||||||
onSelect={(value) => setValue(value)}
|
onSelect={(value) => setValue(value)}
|
||||||
disabled={disabled}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ScheduleDetails({ disabled }: Props) {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<ScheduledTimeField disabled={disabled} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function typeValidation() {
|
export function typeValidation() {
|
||||||
return number()
|
return number()
|
||||||
.oneOf([ScheduleType.Rollback, ScheduleType.Update])
|
.oneOf([ScheduleType.Rollback, ScheduleType.Update])
|
|
@ -0,0 +1,64 @@
|
||||||
|
import { useFormikContext } from 'formik';
|
||||||
|
import { useCurrentStateAndParams } from '@uirouter/react';
|
||||||
|
|
||||||
|
import { EdgeTypes, EnvironmentId } from '@/portainer/environments/types';
|
||||||
|
import { useEnvironmentList } from '@/portainer/environments/queries/useEnvironmentList';
|
||||||
|
|
||||||
|
import { useActiveSchedules } from '../queries/useActiveSchedules';
|
||||||
|
|
||||||
|
import { ScheduledTimeField } from './ScheduledTimeField';
|
||||||
|
import { FormValues } from './types';
|
||||||
|
import { EnvironmentSelection } from './EnvironmentSelection';
|
||||||
|
import { ActiveSchedulesNotice } from './ActiveSchedulesNotice';
|
||||||
|
import { useEdgeGroupsEnvironmentIds } from './useEdgeGroupsEnvironmentIds';
|
||||||
|
|
||||||
|
export function UpdateScheduleDetailsFieldset() {
|
||||||
|
const { values } = useFormikContext<FormValues>();
|
||||||
|
|
||||||
|
const edgeGroupsEnvironmentIds = useEdgeGroupsEnvironmentIds(values.groupIds);
|
||||||
|
|
||||||
|
const environments = useEnvironments(edgeGroupsEnvironmentIds);
|
||||||
|
const activeSchedules = useRelevantActiveSchedules(edgeGroupsEnvironmentIds);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ActiveSchedulesNotice
|
||||||
|
selectedEdgeGroupIds={values.groupIds}
|
||||||
|
activeSchedules={activeSchedules}
|
||||||
|
environments={environments}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<EnvironmentSelection
|
||||||
|
activeSchedules={activeSchedules}
|
||||||
|
environments={environments}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ScheduledTimeField />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function useEnvironments(environmentsIds: Array<EnvironmentId>) {
|
||||||
|
const environmentsQuery = useEnvironmentList(
|
||||||
|
{ endpointIds: environmentsIds, types: EdgeTypes },
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
environmentsIds.length > 0
|
||||||
|
);
|
||||||
|
|
||||||
|
return environmentsQuery.environments;
|
||||||
|
}
|
||||||
|
|
||||||
|
function useRelevantActiveSchedules(environmentIds: EnvironmentId[]) {
|
||||||
|
const { params } = useCurrentStateAndParams();
|
||||||
|
|
||||||
|
const scheduleId = params.id ? parseInt(params.id, 10) : 0;
|
||||||
|
|
||||||
|
const activeSchedulesQuery = useActiveSchedules(environmentIds);
|
||||||
|
|
||||||
|
return (
|
||||||
|
activeSchedulesQuery.data?.filter(
|
||||||
|
(schedule) => schedule.scheduleId !== scheduleId
|
||||||
|
) || []
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { EnvironmentId } from '@/portainer/environments/types';
|
||||||
import { EdgeGroup } from '@/react/edge/edge-groups/types';
|
import { EdgeGroup } from '@/react/edge/edge-groups/types';
|
||||||
|
|
||||||
import { ScheduleType } from '../types';
|
import { ScheduleType } from '../types';
|
||||||
|
@ -6,6 +7,6 @@ export interface FormValues {
|
||||||
name: string;
|
name: string;
|
||||||
groupIds: EdgeGroup['Id'][];
|
groupIds: EdgeGroup['Id'][];
|
||||||
type: ScheduleType;
|
type: ScheduleType;
|
||||||
version: string;
|
|
||||||
time: number;
|
time: number;
|
||||||
|
environments: Record<EnvironmentId, string>;
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,26 @@
|
||||||
|
import _ from 'lodash';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
import { useEdgeGroups } from '@/react/edge/edge-groups/queries/useEdgeGroups';
|
||||||
|
import { EdgeGroup } from '@/react/edge/edge-groups/types';
|
||||||
|
|
||||||
|
export function useEdgeGroupsEnvironmentIds(
|
||||||
|
edgeGroupsIds: Array<EdgeGroup['Id']>
|
||||||
|
) {
|
||||||
|
const groupsQuery = useEdgeGroups({
|
||||||
|
select: (groups) =>
|
||||||
|
Object.fromEntries(groups.map((g) => [g.Id, g.Endpoints])),
|
||||||
|
});
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() =>
|
||||||
|
_.uniq(
|
||||||
|
_.compact(
|
||||||
|
edgeGroupsIds.flatMap((id) =>
|
||||||
|
groupsQuery.data ? groupsQuery.data[id] : []
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
[edgeGroupsIds, groupsQuery.data]
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,23 @@
|
||||||
|
import semverCompare from 'semver-compare';
|
||||||
|
|
||||||
|
export function compareVersion(
|
||||||
|
currentVersion: string,
|
||||||
|
version = '',
|
||||||
|
bigger = false
|
||||||
|
) {
|
||||||
|
if (!currentVersion) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// if supplied version is not a string, e.g develop
|
||||||
|
if (!version.includes('.')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bigger) {
|
||||||
|
return semverCompare(currentVersion, version) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// env version is less than the supplied
|
||||||
|
return semverCompare(currentVersion, version) < 0;
|
||||||
|
}
|
|
@ -1,15 +1,14 @@
|
||||||
import { array, number, object, SchemaOf, string } from 'yup';
|
import { array, number, object } from 'yup';
|
||||||
|
|
||||||
import { EdgeUpdateSchedule } from '../types';
|
import { EdgeUpdateSchedule } from '../types';
|
||||||
|
|
||||||
import { nameValidation } from './NameField';
|
import { nameValidation } from './NameField';
|
||||||
import { FormValues } from './types';
|
import { typeValidation } from './ScheduleTypeSelector';
|
||||||
import { typeValidation } from './UpdateTypeTabs';
|
|
||||||
|
|
||||||
export function validation(
|
export function validation(
|
||||||
schedules: EdgeUpdateSchedule[],
|
schedules: EdgeUpdateSchedule[],
|
||||||
currentId?: EdgeUpdateSchedule['id']
|
currentId?: EdgeUpdateSchedule['id']
|
||||||
): SchemaOf<FormValues> {
|
) {
|
||||||
return object({
|
return object({
|
||||||
groupIds: array().min(1, 'At least one group is required'),
|
groupIds: array().min(1, 'At least one group is required'),
|
||||||
name: nameValidation(schedules, currentId),
|
name: nameValidation(schedules, currentId),
|
||||||
|
@ -17,6 +16,6 @@ export function validation(
|
||||||
time: number()
|
time: number()
|
||||||
.min(Date.now() / 1000)
|
.min(Date.now() / 1000)
|
||||||
.required(),
|
.required(),
|
||||||
version: string().required(),
|
environments: object().default({}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,12 @@
|
||||||
|
import { EnvironmentId } from '@/portainer/environments/types';
|
||||||
|
|
||||||
import { EdgeUpdateSchedule } from '../types';
|
import { EdgeUpdateSchedule } from '../types';
|
||||||
|
|
||||||
export const queryKeys = {
|
export const queryKeys = {
|
||||||
list: () => ['edge', 'update_schedules'] as const,
|
list: () => ['edge', 'update_schedules'] as const,
|
||||||
item: (id: EdgeUpdateSchedule['id']) => [...queryKeys.list(), id] as const,
|
item: (id: EdgeUpdateSchedule['id']) => [...queryKeys.list(), id] as const,
|
||||||
|
activeSchedules: (environmentIds: EnvironmentId[]) =>
|
||||||
|
[queryKeys.list(), 'active', { environmentIds }] as const,
|
||||||
|
supportedAgentVersions: () => [queryKeys.list(), 'agent_versions'] as const,
|
||||||
|
previousVersions: () => [queryKeys.list(), 'previous_versions'] as const,
|
||||||
};
|
};
|
||||||
|
|
|
@ -2,6 +2,16 @@ import { EdgeUpdateSchedule } from '../types';
|
||||||
|
|
||||||
export const BASE_URL = '/edge_update_schedules';
|
export const BASE_URL = '/edge_update_schedules';
|
||||||
|
|
||||||
export function buildUrl(id?: EdgeUpdateSchedule['id']) {
|
export function buildUrl(id?: EdgeUpdateSchedule['id'], action?: string) {
|
||||||
return !id ? BASE_URL : `${BASE_URL}/${id}`;
|
let url = BASE_URL;
|
||||||
|
|
||||||
|
if (id) {
|
||||||
|
url += `/${id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action) {
|
||||||
|
url += `/${action}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return url;
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,41 @@
|
||||||
|
import { useQuery } from 'react-query';
|
||||||
|
|
||||||
|
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||||
|
import { EnvironmentId } from '@/portainer/environments/types';
|
||||||
|
|
||||||
|
import { EdgeUpdateSchedule, ScheduleType, StatusType } from '../types';
|
||||||
|
|
||||||
|
import { queryKeys } from './query-keys';
|
||||||
|
import { buildUrl } from './urls';
|
||||||
|
|
||||||
|
export interface ActiveSchedule {
|
||||||
|
environmentId: EnvironmentId;
|
||||||
|
scheduleId: EdgeUpdateSchedule['id'];
|
||||||
|
targetVersion: string;
|
||||||
|
status: StatusType;
|
||||||
|
error: string;
|
||||||
|
type: ScheduleType;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getActiveSchedules(environmentIds: EnvironmentId[]) {
|
||||||
|
try {
|
||||||
|
const { data } = await axios.post<ActiveSchedule[]>(
|
||||||
|
buildUrl(undefined, 'active'),
|
||||||
|
{ environmentIds }
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
} catch (err) {
|
||||||
|
throw parseAxiosError(
|
||||||
|
err as Error,
|
||||||
|
'Failed to get list of edge update schedules'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useActiveSchedules(environmentIds: EnvironmentId[]) {
|
||||||
|
return useQuery(
|
||||||
|
queryKeys.activeSchedules(environmentIds),
|
||||||
|
() => getActiveSchedules(environmentIds),
|
||||||
|
{ enabled: environmentIds.length > 0 }
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { useQuery } from 'react-query';
|
||||||
|
|
||||||
|
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||||
|
import { EnvironmentId } from '@/portainer/environments/types';
|
||||||
|
|
||||||
|
import { queryKeys } from './query-keys';
|
||||||
|
import { buildUrl } from './urls';
|
||||||
|
|
||||||
|
export function usePreviousVersions<T = Record<EnvironmentId, string>>({
|
||||||
|
select,
|
||||||
|
}: { select?: (data: Record<EnvironmentId, string>) => T } = {}) {
|
||||||
|
return useQuery(queryKeys.previousVersions(), getPreviousVersions, {
|
||||||
|
select,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getPreviousVersions() {
|
||||||
|
try {
|
||||||
|
const { data } = await axios.get<Record<EnvironmentId, string>>(
|
||||||
|
buildUrl(undefined, 'previous_versions')
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
} catch (err) {
|
||||||
|
throw parseAxiosError(
|
||||||
|
err as Error,
|
||||||
|
'Failed to get list of edge update schedules'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { useQuery } from 'react-query';
|
||||||
|
|
||||||
|
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||||
|
|
||||||
|
import { queryKeys } from './query-keys';
|
||||||
|
import { buildUrl } from './urls';
|
||||||
|
|
||||||
|
export function useSupportedAgentVersions<T = string[]>({
|
||||||
|
select,
|
||||||
|
}: { select?: (data: string[]) => T } = {}) {
|
||||||
|
return useQuery(
|
||||||
|
queryKeys.supportedAgentVersions(),
|
||||||
|
getSupportedAgentVersions,
|
||||||
|
{ select }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getSupportedAgentVersions() {
|
||||||
|
try {
|
||||||
|
const { data } = await axios.get<string[]>(
|
||||||
|
buildUrl(undefined, 'agent_versions')
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
} catch (err) {
|
||||||
|
throw parseAxiosError(
|
||||||
|
err as Error,
|
||||||
|
'Failed to get list of edge update schedules'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -14,8 +14,10 @@ export enum StatusType {
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Status {
|
interface Status {
|
||||||
Type: StatusType;
|
status: StatusType;
|
||||||
Error: string;
|
error: string;
|
||||||
|
targetVersion: string;
|
||||||
|
currentVersion: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type EdgeUpdateSchedule = {
|
export type EdgeUpdateSchedule = {
|
||||||
|
@ -27,5 +29,4 @@ export type EdgeUpdateSchedule = {
|
||||||
status: { [key: EnvironmentId]: Status };
|
status: { [key: EnvironmentId]: Status };
|
||||||
created: number;
|
created: number;
|
||||||
createdBy: UserId;
|
createdBy: UserId;
|
||||||
version: string;
|
|
||||||
};
|
};
|
||||||
|
|
|
@ -135,6 +135,7 @@
|
||||||
"react-tooltip": "^4.2.21",
|
"react-tooltip": "^4.2.21",
|
||||||
"react2angular": "^4.0.6",
|
"react2angular": "^4.0.6",
|
||||||
"sanitize-html": "^2.5.3",
|
"sanitize-html": "^2.5.3",
|
||||||
|
"semver-compare": "^1.0.0",
|
||||||
"spinkit": "^2.0.1",
|
"spinkit": "^2.0.1",
|
||||||
"splitargs": "github:deviantony/splitargs#semver:~0.2.0",
|
"splitargs": "github:deviantony/splitargs#semver:~0.2.0",
|
||||||
"strip-ansi": "^6.0.0",
|
"strip-ansi": "^6.0.0",
|
||||||
|
@ -178,6 +179,7 @@
|
||||||
"@types/react-is": "^17.0.3",
|
"@types/react-is": "^17.0.3",
|
||||||
"@types/react-table": "^7.7.6",
|
"@types/react-table": "^7.7.6",
|
||||||
"@types/sanitize-html": "^2.5.0",
|
"@types/sanitize-html": "^2.5.0",
|
||||||
|
"@types/semver-compare": "^1.0.1",
|
||||||
"@types/toastr": "^2.1.39",
|
"@types/toastr": "^2.1.39",
|
||||||
"@types/uuid": "^8.3.4",
|
"@types/uuid": "^8.3.4",
|
||||||
"@typescript-eslint/eslint-plugin": "^5.7.0",
|
"@typescript-eslint/eslint-plugin": "^5.7.0",
|
||||||
|
|
|
@ -4853,6 +4853,11 @@
|
||||||
resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.2.tgz#1a62f89525723dde24ba1b01b092bf5df8ad4d39"
|
resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.2.tgz#1a62f89525723dde24ba1b01b092bf5df8ad4d39"
|
||||||
integrity sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==
|
integrity sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==
|
||||||
|
|
||||||
|
"@types/semver-compare@^1.0.1":
|
||||||
|
version "1.0.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/semver-compare/-/semver-compare-1.0.1.tgz#17d1dc62c516c133ab01efb7803a537ee6eaf3d5"
|
||||||
|
integrity sha512-wx2LQVvKlEkhXp/HoKIZ/aSL+TvfJdKco8i0xJS3aR877mg4qBHzNT6+B5a61vewZHo79EdZavskGnRXEC2H6A==
|
||||||
|
|
||||||
"@types/serve-index@^1.9.1":
|
"@types/serve-index@^1.9.1":
|
||||||
version "1.9.1"
|
version "1.9.1"
|
||||||
resolved "https://registry.yarnpkg.com/@types/serve-index/-/serve-index-1.9.1.tgz#1b5e85370a192c01ec6cec4735cf2917337a6278"
|
resolved "https://registry.yarnpkg.com/@types/serve-index/-/serve-index-1.9.1.tgz#1b5e85370a192c01ec6cec4735cf2917337a6278"
|
||||||
|
@ -16503,7 +16508,7 @@ selfsigned@^2.0.0:
|
||||||
semver-compare@^1.0.0:
|
semver-compare@^1.0.0:
|
||||||
version "1.0.0"
|
version "1.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/semver-compare/-/semver-compare-1.0.0.tgz#0dee216a1c941ab37e9efb1788f6afc5ff5537fc"
|
resolved "https://registry.yarnpkg.com/semver-compare/-/semver-compare-1.0.0.tgz#0dee216a1c941ab37e9efb1788f6afc5ff5537fc"
|
||||||
integrity sha1-De4hahyUGrN+nvsXiPavxf9VN/w=
|
integrity sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==
|
||||||
|
|
||||||
semver-regex@^2.0.0:
|
semver-regex@^2.0.0:
|
||||||
version "2.0.0"
|
version "2.0.0"
|
||||||
|
|
Loading…
Reference in New Issue