diff --git a/api/dataservices/edgeupdateschedule/edgeupdateschedule.go b/api/dataservices/edgeupdateschedule/edgeupdateschedule.go index 5359d464e..28df7bacc 100644 --- a/api/dataservices/edgeupdateschedule/edgeupdateschedule.go +++ b/api/dataservices/edgeupdateschedule/edgeupdateschedule.go @@ -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 +} diff --git a/api/dataservices/interface.go b/api/dataservices/interface.go index 2e0e5dd94..e6e071afe 100644 --- a/api/dataservices/interface.go +++ b/api/dataservices/interface.go @@ -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 diff --git a/api/edgetypes/edgetypes.go b/api/edgetypes/edgetypes.go index e5a3f841c..e81a91cf6 100644 --- a/api/edgetypes/edgetypes.go +++ b/api/edgetypes/edgetypes.go @@ -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 ( diff --git a/api/http/handler/edgeupdateschedules/agentversions.go b/api/http/handler/edgeupdateschedules/agentversions.go new file mode 100644 index 000000000..491fbeae5 --- /dev/null +++ b/api/http/handler/edgeupdateschedules/agentversions.go @@ -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 + }) +} diff --git a/api/http/handler/edgeupdateschedules/edgeupdateschedule_activeschedules.go b/api/http/handler/edgeupdateschedules/edgeupdateschedule_activeschedules.go new file mode 100644 index 000000000..67cfa1796 --- /dev/null +++ b/api/http/handler/edgeupdateschedules/edgeupdateschedule_activeschedules.go @@ -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) +} diff --git a/api/http/handler/edgeupdateschedules/edgeupdateschedule_create.go b/api/http/handler/edgeupdateschedules/edgeupdateschedule_create.go index 9519b7eea..b3b7fe7aa 100644 --- a/api/http/handler/edgeupdateschedules/edgeupdateschedule_create.go +++ b/api/http/handler/edgeupdateschedules/edgeupdateschedule_create.go @@ -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) diff --git a/api/http/handler/edgeupdateschedules/edgeupdateschedule_update.go b/api/http/handler/edgeupdateschedules/edgeupdateschedule_update.go index 5ef031e67..47ec3970b 100644 --- a/api/http/handler/edgeupdateschedules/edgeupdateschedule_update.go +++ b/api/http/handler/edgeupdateschedules/edgeupdateschedule_update.go @@ -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) diff --git a/api/http/handler/edgeupdateschedules/handler.go b/api/http/handler/edgeupdateschedules/handler.go index 39c8c4c9e..76c9f1266 100644 --- a/api/http/handler/edgeupdateschedules/handler.go +++ b/api/http/handler/edgeupdateschedules/handler.go @@ -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) diff --git a/api/http/handler/edgeupdateschedules/previous_versions.go b/api/http/handler/edgeupdateschedules/previous_versions.go new file mode 100644 index 000000000..215a57af0 --- /dev/null +++ b/api/http/handler/edgeupdateschedules/previous_versions.go @@ -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 +} diff --git a/api/http/handler/edgeupdateschedules/previous_versions_test.go b/api/http/handler/edgeupdateschedules/previous_versions_test.go new file mode 100644 index 000000000..e1b94faa4 --- /dev/null +++ b/api/http/handler/edgeupdateschedules/previous_versions_test.go @@ -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) + +} diff --git a/api/http/handler/endpointedge/endpoint_edgestatus_inspect_test.go b/api/http/handler/endpointedge/endpointedge_status_inspect_test.go similarity index 100% rename from api/http/handler/endpointedge/endpoint_edgestatus_inspect_test.go rename to api/http/handler/endpointedge/endpointedge_status_inspect_test.go diff --git a/app/assets/css/vendor-override.css b/app/assets/css/vendor-override.css index a8390bb7c..d29639a7d 100644 --- a/app/assets/css/vendor-override.css +++ b/app/assets/css/vendor-override.css @@ -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; -} diff --git a/app/react/components/NavTabs/NavTabs.tsx b/app/react/components/NavTabs/NavTabs.tsx index e079a7927..cf4ab6a98 100644 --- a/app/react/components/NavTabs/NavTabs.tsx +++ b/app/react/components/NavTabs/NavTabs.tsx @@ -32,6 +32,7 @@ export function NavTabs({ className={clsx({ active: option.id === selectedId, [styles.parent]: !option.children, + disabled, })} key={option.id} > @@ -53,7 +54,7 @@ export function NavTabs({ ))} {selected && selected.children && ( -
{selected.children}
+
{selected.children}
)} ); diff --git a/app/react/components/form-components/Checkbox.tsx b/app/react/components/form-components/Checkbox.tsx index 1bec896e2..e1603f161 100644 --- a/app/react/components/form-components/Checkbox.tsx +++ b/app/react/components/form-components/Checkbox.tsx @@ -40,7 +40,7 @@ export const Checkbox = forwardRef( }, [resolvedRef, indeterminate]); return ( -
+
({ + select, +}: { + select?: (groups: EdgeGroup[]) => T; +} = {}) { + return useQuery(['edge', 'groups'], getEdgeGroups, { select }); } diff --git a/app/react/portainer/environments/update-schedules/CreateView/CreateView.tsx b/app/react/portainer/environments/update-schedules/CreateView/CreateView.tsx index 3cc46518c..f6a0a03ea 100644 --- a/app/react/portainer/environments/update-schedules/CreateView/CreateView.tsx +++ b/app/react/portainer/environments/update-schedules/CreateView/CreateView.tsx @@ -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() { { - createMutation.mutate(values, { - onSuccess() { - notifySuccess('Success', 'Created schedule successfully'); - router.stateService.go('^'); - }, - }); - }} + onSubmit={handleSubmit} validateOnMount validationSchema={() => validation(schedules)} > {({ isValid }) => ( - - +
); + + function handleSubmit(values: FormValues) { + createMutation.mutate(values, { + onSuccess() { + notifySuccess('Success', 'Created schedule successfully'); + router.stateService.go('^'); + }, + }); + } } diff --git a/app/react/portainer/environments/update-schedules/ItemView/ItemView.tsx b/app/react/portainer/environments/update-schedules/ItemView/ItemView.tsx index f6bde0b04..3d0fca091 100644 --- a/app/react/portainer/environments/update-schedules/ItemView/ItemView.tsx +++ b/app/react/portainer/environments/update-schedules/ItemView/ItemView.tsx @@ -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 ( <>
@@ -66,7 +85,7 @@ export function ItemView() { { updateMutation.mutate( { id, values }, @@ -82,7 +101,9 @@ export function ItemView() { ); }} validateOnMount - validationSchema={() => updateValidation(item, schedules)} + validationSchema={() => + updateValidation(item.id, item.time, schedules) + } > {({ isValid }) => ( @@ -90,7 +111,11 @@ export function ItemView() { - + {isDisabled ? ( + + ) : ( + + )}
@@ -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) }); } diff --git a/app/react/portainer/environments/update-schedules/ItemView/ScheduleDetails.tsx b/app/react/portainer/environments/update-schedules/ItemView/ScheduleDetails.tsx new file mode 100644 index 000000000..5b2d64983 --- /dev/null +++ b/app/react/portainer/environments/update-schedules/ItemView/ScheduleDetails.tsx @@ -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 ( +
+
+ , + }, + { + id: ScheduleType.Rollback, + label: 'Rollback', + children: , + }, + ]} + selectedId={schedule.type} + onSelect={() => {}} + disabled + /> +
+
+ ); +} + +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 ( + <> +
+
+ {schedulesCount.map(({ count, currentVersion, targetVersion }) => ( +
+ {count} edge device(s) selected for{' '} + {schedule.type === ScheduleType.Rollback ? 'rollback' : 'update'}{' '} + from v{currentVersion} to v{targetVersion} +
+ ))} +
+
+ + + + ); +} diff --git a/app/react/portainer/environments/update-schedules/ListView/columns/schedule-status.tsx b/app/react/portainer/environments/update-schedules/ListView/columns/schedule-status.tsx index 373d1cb76..768047990 100644 --- a/app/react/portainer/environments/update-schedules/ListView/columns/schedule-status.tsx +++ b/app/react/portainer/environments/update-schedules/ListView/columns/schedule-status.tsx @@ -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'; diff --git a/app/react/portainer/environments/update-schedules/common/ActiveSchedulesNotice.tsx b/app/react/portainer/environments/update-schedules/common/ActiveSchedulesNotice.tsx new file mode 100644 index 000000000..113394f65 --- /dev/null +++ b/app/react/portainer/environments/update-schedules/common/ActiveSchedulesNotice.tsx @@ -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 ( +
+
+ {edgeGroups.map(({ edgeGroupId, edgeGroupName, schedules }) => + schedules.map( + ({ currentVersion, scheduleCount, targetVersion, type }) => ( + + ) + ) + )} +
+
+ ); +} + +function ActiveSchedulesNoticeItem({ + name, + scheduleCount, + version, + currentVersion, + scheduleType, +}: { + name: string; + scheduleCount: number; + version: string; + currentVersion: string; + scheduleType: ScheduleType; +}) { + return ( +
+ + {scheduleCount} edge devices in {name} are scheduled for{' '} + {scheduleType === ScheduleType.Rollback ? 'rollback' : 'update'} from{' '} + {currentVersion} to {version} +
+ ); +} diff --git a/app/react/portainer/environments/update-schedules/common/EnvironmentSelection.tsx b/app/react/portainer/environments/update-schedules/common/EnvironmentSelection.tsx new file mode 100644 index 000000000..9cd313048 --- /dev/null +++ b/app/react/portainer/environments/update-schedules/common/EnvironmentSelection.tsx @@ -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 ( + + The are no update options available for yor selected groups(s) + + ); + } + + return ( +
+
+ {versionGroups.map(([version, environmentIds]) => ( + + ))} +
+
+ ); +} diff --git a/app/react/portainer/environments/update-schedules/common/EnvironmentSelectionItem.tsx b/app/react/portainer/environments/update-schedules/common/EnvironmentSelectionItem.tsx new file mode 100644 index 000000000..1573eda8d --- /dev/null +++ b/app/react/portainer/environments/update-schedules/common/EnvironmentSelectionItem.tsx @@ -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('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 ( +
+ handleChange(!isChecked)} + disabled={disabled} + /> + + + {environmentIds.length} edge agents update from v{currentVersion} to +