feat(edge/update): select endpoints to update [EE-4043] (#7602)
parent
36e7981ab7
commit
4d123895ea
|
@ -2,7 +2,9 @@ package edgeupdateschedule
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/edgetypes"
|
||||
"github.com/sirupsen/logrus"
|
||||
|
@ -16,6 +18,9 @@ const (
|
|||
// Service represents a service for managing Edge Update Schedule data.
|
||||
type Service struct {
|
||||
connection portainer.Connection
|
||||
|
||||
mu sync.Mutex
|
||||
idxActiveSchedules map[portainer.EndpointID]*edgetypes.EndpointUpdateScheduleRelation
|
||||
}
|
||||
|
||||
func (service *Service) BucketName() string {
|
||||
|
@ -29,9 +34,44 @@ func NewService(connection portainer.Connection) (*Service, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
return &Service{
|
||||
service := &Service{
|
||||
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.
|
||||
|
@ -45,7 +85,7 @@ func (service *Service) List() ([]edgetypes.UpdateSchedule, 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)
|
||||
return nil, fmt.Errorf("failed to convert to EdgeUpdateSchedule object: %s", obj)
|
||||
}
|
||||
list = append(list, *item)
|
||||
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.
|
||||
func (service *Service) Create(item *edgetypes.UpdateSchedule) error {
|
||||
return service.connection.CreateObject(
|
||||
err := service.connection.CreateObject(
|
||||
BucketName,
|
||||
func(id uint64) (int, interface{}) {
|
||||
item.ID = edgetypes.UpdateScheduleID(id)
|
||||
return int(item.ID), item
|
||||
},
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return service.setRelation(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)
|
||||
func (service *Service) Update(id edgetypes.UpdateScheduleID, item *edgetypes.UpdateSchedule) error {
|
||||
identifier := service.connection.ConvertToKey(int(id))
|
||||
err := service.connection.UpdateObject(BucketName, identifier, item)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
service.cleanRelation(id)
|
||||
|
||||
return service.setRelation(item)
|
||||
}
|
||||
|
||||
// Delete deletes an item.
|
||||
func (service *Service) Delete(ID edgetypes.UpdateScheduleID) error {
|
||||
identifier := service.connection.ConvertToKey(int(ID))
|
||||
func (service *Service) Delete(id edgetypes.UpdateScheduleID) error {
|
||||
|
||||
service.cleanRelation(id)
|
||||
|
||||
identifier := service.connection.ConvertToKey(int(id))
|
||||
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 {
|
||||
ActiveSchedule(environmentID portainer.EndpointID) *edgetypes.EndpointUpdateScheduleRelation
|
||||
ActiveSchedules(environmentIDs []portainer.EndpointID) []edgetypes.EndpointUpdateScheduleRelation
|
||||
List() ([]edgetypes.UpdateSchedule, error)
|
||||
Item(ID edgetypes.UpdateScheduleID) (*edgetypes.UpdateSchedule, error)
|
||||
Create(edgeUpdateSchedule *edgetypes.UpdateSchedule) error
|
||||
|
|
|
@ -13,13 +13,6 @@ const (
|
|||
|
||||
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
|
||||
|
||||
|
@ -41,8 +34,6 @@ type (
|
|||
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
|
||||
|
@ -73,6 +64,24 @@ type (
|
|||
// Update schedule ID
|
||||
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 (
|
||||
|
|
|
@ -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 {
|
||||
Name string
|
||||
GroupIDs []portainer.EdgeGroupID
|
||||
Type edgetypes.UpdateScheduleType
|
||||
Version string
|
||||
Time int64
|
||||
Name string
|
||||
GroupIDs []portainer.EdgeGroupID
|
||||
Type edgetypes.UpdateScheduleType
|
||||
Environments map[portainer.EndpointID]string
|
||||
Time int64
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
if payload.Version == "" {
|
||||
return errors.New("Invalid version")
|
||||
if len(payload.Environments) == 0 {
|
||||
return errors.New("No Environment is scheduled for update")
|
||||
}
|
||||
|
||||
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(),
|
||||
CreatedBy: tokenData.ID,
|
||||
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)
|
||||
|
|
|
@ -15,11 +15,11 @@ import (
|
|||
)
|
||||
|
||||
type updatePayload struct {
|
||||
Name string
|
||||
GroupIDs []portainer.EdgeGroupID
|
||||
Type edgetypes.UpdateScheduleType
|
||||
Version string
|
||||
Time int64
|
||||
Name string
|
||||
GroupIDs []portainer.EdgeGroupID
|
||||
Environments map[portainer.EndpointID]string
|
||||
Type edgetypes.UpdateScheduleType
|
||||
Time int64
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
if payload.Version == "" {
|
||||
return errors.New("Invalid version")
|
||||
if len(payload.Environments) == 0 {
|
||||
return errors.New("No Environment is scheduled for update")
|
||||
}
|
||||
|
||||
return nil
|
||||
|
@ -80,7 +80,23 @@ func (handler *Handler) update(w http.ResponseWriter, r *http.Request) *httperro
|
|||
item.GroupIDs = payload.GroupIDs
|
||||
item.Time = payload.Time
|
||||
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)
|
||||
|
|
|
@ -15,14 +15,14 @@ import (
|
|||
|
||||
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 {
|
||||
*mux.Router
|
||||
requestBouncer *security.RequestBouncer
|
||||
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 {
|
||||
h := &Handler{
|
||||
Router: mux.NewRouter(),
|
||||
|
@ -40,6 +40,15 @@ func NewHandler(bouncer *security.RequestBouncer, dataStore dataservices.DataSto
|
|||
router.Handle("",
|
||||
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.Use(middlewares.WithItem(func(id edgetypes.UpdateScheduleID) (*edgetypes.UpdateSchedule, error) {
|
||||
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);
|
||||
}
|
||||
|
||||
.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:hover,
|
||||
.nav-tabs > li.active > a:focus {
|
||||
|
@ -109,8 +129,9 @@ code {
|
|||
border: 1px solid var(--border-navtabs-color);
|
||||
}
|
||||
|
||||
.nav-tabs {
|
||||
border-bottom: 1px solid var(--border-navtabs-color);
|
||||
.nav-tabs > li.disabled > a {
|
||||
border-color: transparent;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.nav-tabs > li > a:hover {
|
||||
|
@ -397,10 +418,6 @@ input:-webkit-autofill {
|
|||
margin: 0;
|
||||
}
|
||||
|
||||
.nav-tabs > li.active > a {
|
||||
border: 0px;
|
||||
}
|
||||
|
||||
.label-default {
|
||||
line-height: 11px;
|
||||
}
|
||||
|
@ -412,34 +429,3 @@ input:-webkit-autofill {
|
|||
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 {
|
||||
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({
|
||||
active: option.id === selectedId,
|
||||
[styles.parent]: !option.children,
|
||||
disabled,
|
||||
})}
|
||||
key={option.id}
|
||||
>
|
||||
|
@ -53,7 +54,7 @@ export function NavTabs<T extends string | number = string>({
|
|||
))}
|
||||
</ul>
|
||||
{selected && selected.children && (
|
||||
<div className="tab-content">{selected.children}</div>
|
||||
<div className="tab-content mt-3">{selected.children}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -40,7 +40,7 @@ export const Checkbox = forwardRef<HTMLInputElement, Props>(
|
|||
}, [resolvedRef, indeterminate]);
|
||||
|
||||
return (
|
||||
<div className="md-checkbox" title={title || label}>
|
||||
<div className="md-checkbox flex" title={title || label}>
|
||||
<input
|
||||
id={id}
|
||||
type="checkbox"
|
||||
|
|
|
@ -13,6 +13,10 @@ async function getEdgeGroups() {
|
|||
}
|
||||
}
|
||||
|
||||
export function useEdgeGroups() {
|
||||
return useQuery(['edge', 'groups'], getEdgeGroups);
|
||||
export function useEdgeGroups<T = EdgeGroup[]>({
|
||||
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 { FormValues } from '../common/types';
|
||||
import { validation } from '../common/validation';
|
||||
import { UpdateTypeTabs } from '../common/UpdateTypeTabs';
|
||||
import { ScheduleTypeSelector } from '../common/ScheduleTypeSelector';
|
||||
import { useList } from '../queries/list';
|
||||
import { EdgeGroupsField } from '../common/EdgeGroupsField';
|
||||
import { NameField } from '../common/NameField';
|
||||
|
@ -25,8 +25,8 @@ const initialValues: FormValues = {
|
|||
name: '',
|
||||
groupIds: [],
|
||||
type: ScheduleType.Update,
|
||||
version: 'latest',
|
||||
time: Math.floor(Date.now() / 1000) + 60 * 60,
|
||||
environments: {},
|
||||
};
|
||||
|
||||
export function CreateView() {
|
||||
|
@ -56,23 +56,15 @@ export function CreateView() {
|
|||
<Widget.Body>
|
||||
<Formik
|
||||
initialValues={initialValues}
|
||||
onSubmit={(values) => {
|
||||
createMutation.mutate(values, {
|
||||
onSuccess() {
|
||||
notifySuccess('Success', 'Created schedule successfully');
|
||||
router.stateService.go('^');
|
||||
},
|
||||
});
|
||||
}}
|
||||
onSubmit={handleSubmit}
|
||||
validateOnMount
|
||||
validationSchema={() => validation(schedules)}
|
||||
>
|
||||
{({ isValid }) => (
|
||||
<FormikForm className="form-horizontal">
|
||||
<NameField />
|
||||
|
||||
<EdgeGroupsField />
|
||||
<UpdateTypeTabs />
|
||||
<ScheduleTypeSelector />
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
<LoadingButton
|
||||
|
@ -93,4 +85,13 @@ export function CreateView() {
|
|||
</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 { LoadingButton } from '@@/buttons';
|
||||
|
||||
import { UpdateTypeTabs } from '../common/UpdateTypeTabs';
|
||||
import { ScheduleTypeSelector } from '../common/ScheduleTypeSelector';
|
||||
import { useItem } from '../queries/useItem';
|
||||
import { validation } from '../common/validation';
|
||||
import { useUpdateMutation } from '../queries/useUpdateMutation';
|
||||
|
@ -24,6 +24,8 @@ import { EdgeGroupsField } from '../common/EdgeGroupsField';
|
|||
import { EdgeUpdateSchedule } from '../types';
|
||||
import { FormValues } from '../common/types';
|
||||
|
||||
import { ScheduleDetails } from './ScheduleDetails';
|
||||
|
||||
export function ItemView() {
|
||||
useRedirectFeatureFlag(FeatureFlag.EdgeRemoteUpdate);
|
||||
|
||||
|
@ -53,11 +55,28 @@ export function ItemView() {
|
|||
|
||||
const item = itemQuery.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 (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Update & Rollback"
|
||||
breadcrumbs={['Edge agent update and rollback', item.name]}
|
||||
breadcrumbs={[
|
||||
{ label: 'Edge agent update and rollback', link: '^' },
|
||||
item.name,
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="row">
|
||||
|
@ -66,7 +85,7 @@ export function ItemView() {
|
|||
<Widget.Title title="Update & Rollback Scheduler" icon={Settings} />
|
||||
<Widget.Body>
|
||||
<Formik
|
||||
initialValues={item}
|
||||
initialValues={initialValues}
|
||||
onSubmit={(values) => {
|
||||
updateMutation.mutate(
|
||||
{ id, values },
|
||||
|
@ -82,7 +101,9 @@ export function ItemView() {
|
|||
);
|
||||
}}
|
||||
validateOnMount
|
||||
validationSchema={() => updateValidation(item, schedules)}
|
||||
validationSchema={() =>
|
||||
updateValidation(item.id, item.time, schedules)
|
||||
}
|
||||
>
|
||||
{({ isValid }) => (
|
||||
<FormikForm className="form-horizontal">
|
||||
|
@ -90,7 +111,11 @@ export function ItemView() {
|
|||
|
||||
<EdgeGroupsField disabled={isDisabled} />
|
||||
|
||||
<UpdateTypeTabs disabled={isDisabled} />
|
||||
{isDisabled ? (
|
||||
<ScheduleDetails schedule={item} />
|
||||
) : (
|
||||
<ScheduleTypeSelector />
|
||||
)}
|
||||
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
|
@ -115,10 +140,11 @@ export function ItemView() {
|
|||
}
|
||||
|
||||
function updateValidation(
|
||||
item: EdgeUpdateSchedule,
|
||||
itemId: EdgeUpdateSchedule['id'],
|
||||
scheduledTime: number,
|
||||
schedules: EdgeUpdateSchedule[]
|
||||
): SchemaOf<{ name: string } | FormValues> {
|
||||
return item.time > Date.now() / 1000
|
||||
? validation(schedules, item.id)
|
||||
: object({ name: nameValidation(schedules, item.id) });
|
||||
return scheduledTime > Date.now() / 1000
|
||||
? validation(schedules, itemId)
|
||||
: 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';
|
||||
}
|
||||
|
||||
const error = statusList.find((s) => s.Type === StatusType.Failed);
|
||||
const error = statusList.find((s) => s.status === StatusType.Failed);
|
||||
|
||||
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) {
|
||||
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 { FormValues } from './types';
|
||||
import { ScheduledTimeField } from './ScheduledTimeField';
|
||||
import { UpdateScheduleDetailsFieldset } from './UpdateScheduleDetailsFieldset';
|
||||
import { RollbackScheduleDetailsFieldset } from './RollbackScheduleDetailsFieldset';
|
||||
|
||||
interface Props {
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function UpdateTypeTabs({ disabled }: Props) {
|
||||
export function ScheduleTypeSelector() {
|
||||
const [{ value }, , { setValue }] = useField<FormValues['type']>('type');
|
||||
|
||||
return (
|
||||
|
@ -23,31 +20,22 @@ export function UpdateTypeTabs({ disabled }: Props) {
|
|||
{
|
||||
id: ScheduleType.Update,
|
||||
label: 'Update',
|
||||
children: <ScheduleDetails disabled={disabled} />,
|
||||
children: <UpdateScheduleDetailsFieldset />,
|
||||
},
|
||||
{
|
||||
id: ScheduleType.Rollback,
|
||||
label: 'Rollback',
|
||||
children: <ScheduleDetails disabled={disabled} />,
|
||||
children: <RollbackScheduleDetailsFieldset />,
|
||||
},
|
||||
]}
|
||||
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])
|
|
@ -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 { ScheduleType } from '../types';
|
||||
|
@ -6,6 +7,6 @@ export interface FormValues {
|
|||
name: string;
|
||||
groupIds: EdgeGroup['Id'][];
|
||||
type: ScheduleType;
|
||||
version: string;
|
||||
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 { nameValidation } from './NameField';
|
||||
import { FormValues } from './types';
|
||||
import { typeValidation } from './UpdateTypeTabs';
|
||||
import { typeValidation } from './ScheduleTypeSelector';
|
||||
|
||||
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),
|
||||
|
@ -17,6 +16,6 @@ export function validation(
|
|||
time: number()
|
||||
.min(Date.now() / 1000)
|
||||
.required(),
|
||||
version: string().required(),
|
||||
environments: object().default({}),
|
||||
});
|
||||
}
|
||||
|
|
|
@ -1,6 +1,12 @@
|
|||
import { EnvironmentId } from '@/portainer/environments/types';
|
||||
|
||||
import { EdgeUpdateSchedule } from '../types';
|
||||
|
||||
export const queryKeys = {
|
||||
list: () => ['edge', 'update_schedules'] 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 function buildUrl(id?: EdgeUpdateSchedule['id']) {
|
||||
return !id ? BASE_URL : `${BASE_URL}/${id}`;
|
||||
export function buildUrl(id?: EdgeUpdateSchedule['id'], action?: string) {
|
||||
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 {
|
||||
Type: StatusType;
|
||||
Error: string;
|
||||
status: StatusType;
|
||||
error: string;
|
||||
targetVersion: string;
|
||||
currentVersion: string;
|
||||
}
|
||||
|
||||
export type EdgeUpdateSchedule = {
|
||||
|
@ -27,5 +29,4 @@ export type EdgeUpdateSchedule = {
|
|||
status: { [key: EnvironmentId]: Status };
|
||||
created: number;
|
||||
createdBy: UserId;
|
||||
version: string;
|
||||
};
|
||||
|
|
|
@ -135,6 +135,7 @@
|
|||
"react-tooltip": "^4.2.21",
|
||||
"react2angular": "^4.0.6",
|
||||
"sanitize-html": "^2.5.3",
|
||||
"semver-compare": "^1.0.0",
|
||||
"spinkit": "^2.0.1",
|
||||
"splitargs": "github:deviantony/splitargs#semver:~0.2.0",
|
||||
"strip-ansi": "^6.0.0",
|
||||
|
@ -178,6 +179,7 @@
|
|||
"@types/react-is": "^17.0.3",
|
||||
"@types/react-table": "^7.7.6",
|
||||
"@types/sanitize-html": "^2.5.0",
|
||||
"@types/semver-compare": "^1.0.1",
|
||||
"@types/toastr": "^2.1.39",
|
||||
"@types/uuid": "^8.3.4",
|
||||
"@typescript-eslint/eslint-plugin": "^5.7.0",
|
||||
|
|
|
@ -4853,6 +4853,11 @@
|
|||
resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.2.tgz#1a62f89525723dde24ba1b01b092bf5df8ad4d39"
|
||||
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":
|
||||
version "1.9.1"
|
||||
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:
|
||||
version "1.0.0"
|
||||
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:
|
||||
version "2.0.0"
|
||||
|
|
Loading…
Reference in New Issue