feat(edge/update): select endpoints to update [EE-4043] (#7602)

pull/7100/head
Chaim Lev-Ari 2022-09-18 14:42:18 +03:00 committed by GitHub
parent 36e7981ab7
commit 4d123895ea
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
37 changed files with 1192 additions and 130 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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",

View File

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