Compare commits
52 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8a80f2bdb4 | |||
| 5882c916c9 | |||
| 7b98811367 | |||
| 45db4d6ff4 | |||
| 2d318e1af0 | |||
| 04156af50f | |||
| ea6ec14451 | |||
| 7d52427c5b | |||
| fadd71e7b9 | |||
| 194f65107a | |||
| a491d89789 | |||
| 6bfbc3c338 | |||
| 76072d11b6 | |||
| 1000d57cd2 | |||
| 2216d2cdd2 | |||
| 70c9172112 | |||
| 043ec008fe | |||
| a3b8b0d58e | |||
| fc09e5574f | |||
| 1dcdce9feb | |||
| 12ea9fa404 | |||
| 38f09fc8ad | |||
| c1c4dd190d | |||
| 674a98e432 | |||
| da7351cec7 | |||
| c9562d1252 | |||
| cae257ddfc | |||
| 7a063cb2fa | |||
| d2c967282e | |||
| a7cad6fd09 | |||
| d11ae40822 | |||
| 503ef6b415 | |||
| b8b69ce116 | |||
| 54fb7242c3 | |||
| 2887c11c93 | |||
| 00b64beed7 | |||
| 1199fe19ec | |||
| 6d0f473f76 | |||
| 6b804152fe | |||
| 9e2849dd10 | |||
| eeff16a300 | |||
| de05fa869d | |||
| 81ae01d3a2 | |||
| 4e0d264bc4 | |||
| 3f2220e340 | |||
| b67c28f128 | |||
| 3a3ec0c50c | |||
| 26f975f1d5 | |||
| 00f72a80f2 | |||
| fc7d226f38 | |||
| 995579fda7 | |||
| 0fd59eb6a8 |
@@ -83,6 +83,7 @@ overrides:
|
|||||||
'newlines-between': 'always',
|
'newlines-between': 'always',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
no-plusplus: off
|
||||||
func-style: [error, 'declaration']
|
func-style: [error, 'declaration']
|
||||||
import/prefer-default-export: off
|
import/prefer-default-export: off
|
||||||
no-use-before-define: ['error', { functions: false }]
|
no-use-before-define: ['error', { functions: false }]
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
portainer "github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
"github.com/portainer/portainer/api/internal/edge/cache"
|
|
||||||
|
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
@@ -17,9 +16,10 @@ const (
|
|||||||
|
|
||||||
// Service represents a service for managing Edge stack data.
|
// Service represents a service for managing Edge stack data.
|
||||||
type Service struct {
|
type Service struct {
|
||||||
connection portainer.Connection
|
connection portainer.Connection
|
||||||
idxVersion map[portainer.EdgeStackID]int
|
idxVersion map[portainer.EdgeStackID]int
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
|
cacheInvalidationFn func(portainer.EdgeStackID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (service *Service) BucketName() string {
|
func (service *Service) BucketName() string {
|
||||||
@@ -27,15 +27,20 @@ func (service *Service) BucketName() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NewService creates a new instance of a service.
|
// NewService creates a new instance of a service.
|
||||||
func NewService(connection portainer.Connection) (*Service, error) {
|
func NewService(connection portainer.Connection, cacheInvalidationFn func(portainer.EdgeStackID)) (*Service, error) {
|
||||||
err := connection.SetServiceName(BucketName)
|
err := connection.SetServiceName(BucketName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
s := &Service{
|
s := &Service{
|
||||||
connection: connection,
|
connection: connection,
|
||||||
idxVersion: make(map[portainer.EdgeStackID]int),
|
idxVersion: make(map[portainer.EdgeStackID]int),
|
||||||
|
cacheInvalidationFn: cacheInvalidationFn,
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.cacheInvalidationFn == nil {
|
||||||
|
s.cacheInvalidationFn = func(portainer.EdgeStackID) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
es, err := s.EdgeStacks()
|
es, err := s.EdgeStacks()
|
||||||
@@ -109,12 +114,9 @@ func (service *Service) Create(id portainer.EdgeStackID, edgeStack *portainer.Ed
|
|||||||
|
|
||||||
service.mu.Lock()
|
service.mu.Lock()
|
||||||
service.idxVersion[id] = edgeStack.Version
|
service.idxVersion[id] = edgeStack.Version
|
||||||
|
service.cacheInvalidationFn(id)
|
||||||
service.mu.Unlock()
|
service.mu.Unlock()
|
||||||
|
|
||||||
for endpointID := range edgeStack.Status {
|
|
||||||
cache.Del(endpointID)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,37 +125,15 @@ func (service *Service) UpdateEdgeStack(ID portainer.EdgeStackID, edgeStack *por
|
|||||||
service.mu.Lock()
|
service.mu.Lock()
|
||||||
defer service.mu.Unlock()
|
defer service.mu.Unlock()
|
||||||
|
|
||||||
prevEdgeStack, err := service.EdgeStack(ID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
identifier := service.connection.ConvertToKey(int(ID))
|
identifier := service.connection.ConvertToKey(int(ID))
|
||||||
|
|
||||||
err = service.connection.UpdateObject(BucketName, identifier, edgeStack)
|
err := service.connection.UpdateObject(BucketName, identifier, edgeStack)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
service.idxVersion[ID] = edgeStack.Version
|
service.idxVersion[ID] = edgeStack.Version
|
||||||
|
service.cacheInvalidationFn(ID)
|
||||||
// Invalidate cache for removed environments
|
|
||||||
for endpointID := range prevEdgeStack.Status {
|
|
||||||
if _, ok := edgeStack.Status[endpointID]; !ok {
|
|
||||||
cache.Del(endpointID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Invalidate cache when version changes and for added environments
|
|
||||||
for endpointID := range edgeStack.Status {
|
|
||||||
if prevEdgeStack.Version == edgeStack.Version {
|
|
||||||
if _, ok := prevEdgeStack.Status[endpointID]; ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cache.Del(endpointID)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -167,35 +147,10 @@ func (service *Service) UpdateEdgeStackFunc(ID portainer.EdgeStackID, updateFunc
|
|||||||
defer service.mu.Unlock()
|
defer service.mu.Unlock()
|
||||||
|
|
||||||
return service.connection.UpdateObjectFunc(BucketName, id, edgeStack, func() {
|
return service.connection.UpdateObjectFunc(BucketName, id, edgeStack, func() {
|
||||||
prevEndpoints := make(map[portainer.EndpointID]struct{}, len(edgeStack.Status))
|
|
||||||
for endpointID := range edgeStack.Status {
|
|
||||||
if _, ok := edgeStack.Status[endpointID]; !ok {
|
|
||||||
prevEndpoints[endpointID] = struct{}{}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updateFunc(edgeStack)
|
updateFunc(edgeStack)
|
||||||
|
|
||||||
prevVersion := service.idxVersion[ID]
|
|
||||||
service.idxVersion[ID] = edgeStack.Version
|
service.idxVersion[ID] = edgeStack.Version
|
||||||
|
service.cacheInvalidationFn(ID)
|
||||||
// Invalidate cache for removed environments
|
|
||||||
for endpointID := range prevEndpoints {
|
|
||||||
if _, ok := edgeStack.Status[endpointID]; !ok {
|
|
||||||
cache.Del(endpointID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Invalidate cache when version changes and for added environments
|
|
||||||
for endpointID := range edgeStack.Status {
|
|
||||||
if prevVersion == edgeStack.Version {
|
|
||||||
if _, ok := prevEndpoints[endpointID]; ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cache.Del(endpointID)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,23 +159,16 @@ func (service *Service) DeleteEdgeStack(ID portainer.EdgeStackID) error {
|
|||||||
service.mu.Lock()
|
service.mu.Lock()
|
||||||
defer service.mu.Unlock()
|
defer service.mu.Unlock()
|
||||||
|
|
||||||
edgeStack, err := service.EdgeStack(ID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
identifier := service.connection.ConvertToKey(int(ID))
|
identifier := service.connection.ConvertToKey(int(ID))
|
||||||
|
|
||||||
err = service.connection.DeleteObject(BucketName, identifier)
|
err := service.connection.DeleteObject(BucketName, identifier)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
delete(service.idxVersion, ID)
|
delete(service.idxVersion, ID)
|
||||||
|
|
||||||
for endpointID := range edgeStack.Status {
|
service.cacheInvalidationFn(ID)
|
||||||
cache.Del(endpointID)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,13 +16,18 @@ const (
|
|||||||
|
|
||||||
// Service represents a service for managing environment(endpoint) relation data.
|
// Service represents a service for managing environment(endpoint) relation data.
|
||||||
type Service struct {
|
type Service struct {
|
||||||
connection portainer.Connection
|
connection portainer.Connection
|
||||||
|
updateStackFn func(ID portainer.EdgeStackID, updateFunc func(edgeStack *portainer.EdgeStack)) error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (service *Service) BucketName() string {
|
func (service *Service) BucketName() string {
|
||||||
return BucketName
|
return BucketName
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (service *Service) RegisterUpdateStackFunction(updateFunc func(ID portainer.EdgeStackID, updateFunc func(edgeStack *portainer.EdgeStack)) error) {
|
||||||
|
service.updateStackFn = updateFunc
|
||||||
|
}
|
||||||
|
|
||||||
// NewService creates a new instance of a service.
|
// NewService creates a new instance of a service.
|
||||||
func NewService(connection portainer.Connection) (*Service, error) {
|
func NewService(connection portainer.Connection) (*Service, error) {
|
||||||
err := connection.SetServiceName(BucketName)
|
err := connection.SetServiceName(BucketName)
|
||||||
@@ -78,20 +83,122 @@ func (service *Service) Create(endpointRelation *portainer.EndpointRelation) err
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateEndpointRelation updates an Environment(Endpoint) relation object
|
// Deprecated: Use UpdateEndpointRelationFunc instead.
|
||||||
func (service *Service) UpdateEndpointRelation(endpointID portainer.EndpointID, endpointRelation *portainer.EndpointRelation) error {
|
func (service *Service) UpdateEndpointRelation(endpointID portainer.EndpointID, endpointRelation *portainer.EndpointRelation) error {
|
||||||
|
previousRelationState, _ := service.EndpointRelation(endpointID)
|
||||||
|
|
||||||
identifier := service.connection.ConvertToKey(int(endpointID))
|
identifier := service.connection.ConvertToKey(int(endpointID))
|
||||||
err := service.connection.UpdateObject(BucketName, identifier, endpointRelation)
|
err := service.connection.UpdateObject(BucketName, identifier, endpointRelation)
|
||||||
cache.Del(endpointID)
|
cache.Del(endpointID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
return err
|
updatedRelationState, _ := service.EndpointRelation(endpointID)
|
||||||
|
|
||||||
|
service.updateEdgeStacksAfterRelationChange(previousRelationState, updatedRelationState)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateEndpointRelationFunc updates an Environment(Endpoint) relation object
|
||||||
|
func (service *Service) UpdateEndpointRelationFunc(endpointID portainer.EndpointID, updateFunc func(endpointRelation *portainer.EndpointRelation)) error {
|
||||||
|
previousRelationState, _ := service.EndpointRelation(endpointID)
|
||||||
|
|
||||||
|
id := service.connection.ConvertToKey(int(endpointID))
|
||||||
|
endpointRelation := &portainer.EndpointRelation{}
|
||||||
|
|
||||||
|
err := service.connection.UpdateObjectFunc(BucketName, id, endpointRelation, func() {
|
||||||
|
updateFunc(endpointRelation)
|
||||||
|
|
||||||
|
cache.Del(endpointID)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
updatedRelationState, _ := service.EndpointRelation(endpointID)
|
||||||
|
|
||||||
|
service.updateEdgeStacksAfterRelationChange(previousRelationState, updatedRelationState)
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteEndpointRelation deletes an Environment(Endpoint) relation object
|
// DeleteEndpointRelation deletes an Environment(Endpoint) relation object
|
||||||
func (service *Service) DeleteEndpointRelation(endpointID portainer.EndpointID) error {
|
func (service *Service) DeleteEndpointRelation(endpointID portainer.EndpointID) error {
|
||||||
|
deletedRelation, _ := service.EndpointRelation(endpointID)
|
||||||
|
|
||||||
identifier := service.connection.ConvertToKey(int(endpointID))
|
identifier := service.connection.ConvertToKey(int(endpointID))
|
||||||
err := service.connection.DeleteObject(BucketName, identifier)
|
err := service.connection.DeleteObject(BucketName, identifier)
|
||||||
cache.Del(endpointID)
|
cache.Del(endpointID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
return err
|
service.updateEdgeStacksAfterRelationChange(deletedRelation, nil)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service *Service) InvalidateEdgeCacheForEdgeStack(edgeStackID portainer.EdgeStackID) {
|
||||||
|
rels, err := service.EndpointRelations()
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Msg("cannot retrieve endpoint relations")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, rel := range rels {
|
||||||
|
for id := range rel.EdgeStacks {
|
||||||
|
if edgeStackID == id {
|
||||||
|
cache.Del(rel.EndpointID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service *Service) updateEdgeStacksAfterRelationChange(previousRelationState *portainer.EndpointRelation, updatedRelationState *portainer.EndpointRelation) {
|
||||||
|
relations, _ := service.EndpointRelations()
|
||||||
|
|
||||||
|
stacksToUpdate := map[portainer.EdgeStackID]bool{}
|
||||||
|
|
||||||
|
if previousRelationState != nil {
|
||||||
|
for stackId, enabled := range previousRelationState.EdgeStacks {
|
||||||
|
// flag stack for update if stack is not in the updated relation state
|
||||||
|
// = stack has been removed for this relation
|
||||||
|
// or this relation has been deleted
|
||||||
|
if enabled && (updatedRelationState == nil || !updatedRelationState.EdgeStacks[stackId]) {
|
||||||
|
stacksToUpdate[stackId] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if updatedRelationState != nil {
|
||||||
|
for stackId, enabled := range updatedRelationState.EdgeStacks {
|
||||||
|
// flag stack for update if stack is not in the previous relation state
|
||||||
|
// = stack has been added for this relation
|
||||||
|
if enabled && (previousRelationState == nil || !previousRelationState.EdgeStacks[stackId]) {
|
||||||
|
stacksToUpdate[stackId] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// for each stack referenced by the updated relation
|
||||||
|
// list how many time this stack is referenced in all relations
|
||||||
|
// in order to update the stack deployments count
|
||||||
|
for refStackId, refStackEnabled := range stacksToUpdate {
|
||||||
|
if refStackEnabled {
|
||||||
|
numDeployments := 0
|
||||||
|
for _, r := range relations {
|
||||||
|
for sId, enabled := range r.EdgeStacks {
|
||||||
|
if enabled && sId == refStackId {
|
||||||
|
numDeployments += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
service.updateStackFn(refStackId, func(edgeStack *portainer.EdgeStack) {
|
||||||
|
edgeStack.NumDeployments = numDeployments
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -126,6 +126,7 @@ type (
|
|||||||
EndpointRelation(EndpointID portainer.EndpointID) (*portainer.EndpointRelation, error)
|
EndpointRelation(EndpointID portainer.EndpointID) (*portainer.EndpointRelation, error)
|
||||||
Create(endpointRelation *portainer.EndpointRelation) error
|
Create(endpointRelation *portainer.EndpointRelation) error
|
||||||
UpdateEndpointRelation(EndpointID portainer.EndpointID, endpointRelation *portainer.EndpointRelation) error
|
UpdateEndpointRelation(EndpointID portainer.EndpointID, endpointRelation *portainer.EndpointRelation) error
|
||||||
|
UpdateEndpointRelationFunc(EndpointID portainer.EndpointID, updateFunc func(*portainer.EndpointRelation)) error
|
||||||
DeleteEndpointRelation(EndpointID portainer.EndpointID) error
|
DeleteEndpointRelation(EndpointID portainer.EndpointID) error
|
||||||
BucketName() string
|
BucketName() string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,67 @@ package migrator
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
portainer "github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
|
"github.com/portainer/portainer/api/internal/endpointutils"
|
||||||
|
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (m *Migrator) migrateDBVersionToDB80() error {
|
func (m *Migrator) migrateDBVersionToDB80() error {
|
||||||
return m.updateEdgeStackStatusForDB80()
|
if err := m.updateEdgeStackStatusForDB80(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := m.updateExistingEndpointsToNotDetectMetricsAPIForDB80(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := m.updateExistingEndpointsToNotDetectStorageAPIForDB80(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Migrator) updateExistingEndpointsToNotDetectMetricsAPIForDB80() error {
|
||||||
|
log.Info().Msg("updating existing endpoints to not detect metrics API for existing endpoints (k8s)")
|
||||||
|
|
||||||
|
endpoints, err := m.endpointService.Endpoints()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, endpoint := range endpoints {
|
||||||
|
if endpointutils.IsKubernetesEndpoint(&endpoint) {
|
||||||
|
endpoint.Kubernetes.Flags.IsServerMetricsDetected = true
|
||||||
|
err = m.endpointService.UpdateEndpoint(endpoint.ID, &endpoint)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Migrator) updateExistingEndpointsToNotDetectStorageAPIForDB80() error {
|
||||||
|
log.Info().Msg("updating existing endpoints to not detect metrics API for existing endpoints (k8s)")
|
||||||
|
|
||||||
|
endpoints, err := m.endpointService.Endpoints()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, endpoint := range endpoints {
|
||||||
|
if endpointutils.IsKubernetesEndpoint(&endpoint) {
|
||||||
|
endpoint.Kubernetes.Flags.IsServerStorageDetected = true
|
||||||
|
err = m.endpointService.UpdateEndpoint(endpoint.ID, &endpoint)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Migrator) updateEdgeStackStatusForDB80() error {
|
func (m *Migrator) updateEdgeStackStatusForDB80() error {
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package migrator
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (m *Migrator) migrateDBVersionToDB81() error {
|
||||||
|
if err := m.updateUserThemForDB81(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Migrator) updateUserThemForDB81() error {
|
||||||
|
log.Info().Msg("updating existing user theme settings")
|
||||||
|
|
||||||
|
users, err := m.userService.Users()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range users {
|
||||||
|
user := &users[i]
|
||||||
|
if user.UserTheme != "" {
|
||||||
|
user.ThemeSettings.Color = user.UserTheme
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := m.userService.UpdateUser(user.ID, user); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -205,6 +205,7 @@ func (m *Migrator) initMigrations() {
|
|||||||
m.addMigrations("2.16", m.migrateDBVersionToDB70)
|
m.addMigrations("2.16", m.migrateDBVersionToDB70)
|
||||||
m.addMigrations("2.16.1", m.migrateDBVersionToDB71)
|
m.addMigrations("2.16.1", m.migrateDBVersionToDB71)
|
||||||
m.addMigrations("2.17", m.migrateDBVersionToDB80)
|
m.addMigrations("2.17", m.migrateDBVersionToDB80)
|
||||||
|
m.addMigrations("2.17.1", m.migrateDBVersionToDB81)
|
||||||
|
|
||||||
// Add new migrations below...
|
// Add new migrations below...
|
||||||
// One function per migration, each versions migration funcs in the same file.
|
// One function per migration, each versions migration funcs in the same file.
|
||||||
|
|||||||
@@ -93,11 +93,18 @@ func (store *Store) initServices() error {
|
|||||||
}
|
}
|
||||||
store.DockerHubService = dockerhubService
|
store.DockerHubService = dockerhubService
|
||||||
|
|
||||||
edgeStackService, err := edgestack.NewService(store.connection)
|
endpointRelationService, err := endpointrelation.NewService(store.connection)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
store.EndpointRelationService = endpointRelationService
|
||||||
|
|
||||||
|
edgeStackService, err := edgestack.NewService(store.connection, endpointRelationService.InvalidateEdgeCacheForEdgeStack)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
store.EdgeStackService = edgeStackService
|
store.EdgeStackService = edgeStackService
|
||||||
|
endpointRelationService.RegisterUpdateStackFunction(edgeStackService.UpdateEdgeStackFunc)
|
||||||
|
|
||||||
edgeGroupService, err := edgegroup.NewService(store.connection)
|
edgeGroupService, err := edgegroup.NewService(store.connection)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -123,12 +130,6 @@ func (store *Store) initServices() error {
|
|||||||
}
|
}
|
||||||
store.EndpointService = endpointService
|
store.EndpointService = endpointService
|
||||||
|
|
||||||
endpointRelationService, err := endpointrelation.NewService(store.connection)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
store.EndpointRelationService = endpointRelationService
|
|
||||||
|
|
||||||
extensionService, err := extension.NewService(store.connection)
|
extensionService, err := extension.NewService(store.connection)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -62,6 +62,10 @@
|
|||||||
"UseLoadBalancer": false,
|
"UseLoadBalancer": false,
|
||||||
"UseServerMetrics": false
|
"UseServerMetrics": false
|
||||||
},
|
},
|
||||||
|
"Flags": {
|
||||||
|
"IsServerMetricsDetected": false,
|
||||||
|
"IsServerStorageDetected": false
|
||||||
|
},
|
||||||
"Snapshots": []
|
"Snapshots": []
|
||||||
},
|
},
|
||||||
"LastCheckInDate": 0,
|
"LastCheckInDate": 0,
|
||||||
@@ -898,6 +902,10 @@
|
|||||||
"PortainerUserRevokeToken": true
|
"PortainerUserRevokeToken": true
|
||||||
},
|
},
|
||||||
"Role": 1,
|
"Role": 1,
|
||||||
|
"ThemeSettings": {
|
||||||
|
"color": "",
|
||||||
|
"subtleUpgradeButton": false
|
||||||
|
},
|
||||||
"TokenIssueAt": 0,
|
"TokenIssueAt": 0,
|
||||||
"UserTheme": "",
|
"UserTheme": "",
|
||||||
"Username": "admin"
|
"Username": "admin"
|
||||||
@@ -925,12 +933,16 @@
|
|||||||
"PortainerUserRevokeToken": true
|
"PortainerUserRevokeToken": true
|
||||||
},
|
},
|
||||||
"Role": 1,
|
"Role": 1,
|
||||||
|
"ThemeSettings": {
|
||||||
|
"color": "",
|
||||||
|
"subtleUpgradeButton": false
|
||||||
|
},
|
||||||
"TokenIssueAt": 0,
|
"TokenIssueAt": 0,
|
||||||
"UserTheme": "",
|
"UserTheme": "",
|
||||||
"Username": "prabhat"
|
"Username": "prabhat"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"version": {
|
"version": {
|
||||||
"VERSION": "{\"SchemaVersion\":\"2.17.0\",\"MigratorCount\":1,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
|
"VERSION": "{\"SchemaVersion\":\"2.17.1\",\"MigratorCount\":1,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -59,6 +59,19 @@ func createLocalClient(endpoint *portainer.Endpoint) (*client.Client, error) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func CreateClientFromEnv() (*client.Client, error) {
|
||||||
|
return client.NewClientWithOpts(
|
||||||
|
client.FromEnv,
|
||||||
|
client.WithVersion(dockerClientVersion),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func CreateSimpleClient() (*client.Client, error) {
|
||||||
|
return client.NewClientWithOpts(
|
||||||
|
client.WithVersion(dockerClientVersion),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
func createTCPClient(endpoint *portainer.Endpoint, timeout *time.Duration) (*client.Client, error) {
|
func createTCPClient(endpoint *portainer.Endpoint, timeout *time.Duration) (*client.Client, error) {
|
||||||
httpCli, err := httpClient(endpoint, timeout)
|
httpCli, err := httpClient(endpoint, timeout)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -344,8 +344,6 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
|||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/portainer/docker-compose-wrapper v0.0.0-20221122145319-915b021aea84 h1:d1P8i0pCPvAfxH6nSLUFm6NYoi8tMrIpafaZXSV8Lac=
|
|
||||||
github.com/portainer/docker-compose-wrapper v0.0.0-20221122145319-915b021aea84/go.mod h1:03UmPLyjiPUexGJuW20mQXvmsoSpeErvMlItJGtq/Ww=
|
|
||||||
github.com/portainer/docker-compose-wrapper v0.0.0-20221215210951-2c30d1b17a27 h1:PceCpp86SDYb3lZHT4KpuBCkmcJMW5x1qrdFNEfAdUo=
|
github.com/portainer/docker-compose-wrapper v0.0.0-20221215210951-2c30d1b17a27 h1:PceCpp86SDYb3lZHT4KpuBCkmcJMW5x1qrdFNEfAdUo=
|
||||||
github.com/portainer/docker-compose-wrapper v0.0.0-20221215210951-2c30d1b17a27/go.mod h1:03UmPLyjiPUexGJuW20mQXvmsoSpeErvMlItJGtq/Ww=
|
github.com/portainer/docker-compose-wrapper v0.0.0-20221215210951-2c30d1b17a27/go.mod h1:03UmPLyjiPUexGJuW20mQXvmsoSpeErvMlItJGtq/Ww=
|
||||||
github.com/portainer/libcrypto v0.0.0-20220506221303-1f4fb3b30f9a h1:B0z3skIMT+OwVNJPQhKp52X+9OWW6A9n5UWig3lHBJk=
|
github.com/portainer/libcrypto v0.0.0-20220506221303-1f4fb3b30f9a h1:B0z3skIMT+OwVNJPQhKp52X+9OWW6A9n5UWig3lHBJk=
|
||||||
|
|||||||
@@ -127,9 +127,9 @@ func (handler *Handler) authenticateLDAP(w http.ResponseWriter, user *portainer.
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
err = handler.addUserIntoTeams(user, ldapSettings)
|
err = handler.syncUserTeamsWithLDAPGroups(user, ldapSettings)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warn().Err(err).Msg("unable to automatically add user into teams")
|
log.Warn().Err(err).Msg("unable to automatically sync user teams with ldap")
|
||||||
}
|
}
|
||||||
|
|
||||||
return handler.writeToken(w, user, false)
|
return handler.writeToken(w, user, false)
|
||||||
@@ -150,7 +150,12 @@ func (handler *Handler) persistAndWriteToken(w http.ResponseWriter, tokenData *p
|
|||||||
return response.JSON(w, &authenticateResponse{JWT: token})
|
return response.JSON(w, &authenticateResponse{JWT: token})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (handler *Handler) addUserIntoTeams(user *portainer.User, settings *portainer.LDAPSettings) error {
|
func (handler *Handler) syncUserTeamsWithLDAPGroups(user *portainer.User, settings *portainer.LDAPSettings) error {
|
||||||
|
// only sync if there is a group base DN
|
||||||
|
if len(settings.GroupSearchSettings) == 0 || len(settings.GroupSearchSettings[0].GroupBaseDN) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
teams, err := handler.DataStore.Team().Teams()
|
teams, err := handler.DataStore.Team().Teams()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -163,11 +163,6 @@ func (handler *Handler) edgeGroupUpdate(w http.ResponseWriter, r *http.Request)
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (handler *Handler) updateEndpointStacks(endpointID portainer.EndpointID) error {
|
func (handler *Handler) updateEndpointStacks(endpointID portainer.EndpointID) error {
|
||||||
relation, err := handler.DataStore.EndpointRelation().EndpointRelation(endpointID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
endpoint, err := handler.DataStore.Endpoint().Endpoint(endpointID)
|
endpoint, err := handler.DataStore.Endpoint().Endpoint(endpointID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -195,9 +190,9 @@ func (handler *Handler) updateEndpointStacks(endpointID portainer.EndpointID) er
|
|||||||
edgeStackSet[edgeStackID] = true
|
edgeStackSet[edgeStackID] = true
|
||||||
}
|
}
|
||||||
|
|
||||||
relation.EdgeStacks = edgeStackSet
|
return handler.DataStore.EndpointRelation().UpdateEndpointRelationFunc(endpoint.ID, func(relation *portainer.EndpointRelation) {
|
||||||
|
relation.EdgeStacks = edgeStackSet
|
||||||
return handler.DataStore.EndpointRelation().UpdateEndpointRelation(endpoint.ID, relation)
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (handler *Handler) updateEndpointEdgeJobs(edgeGroupID portainer.EdgeGroupID, endpointID portainer.EndpointID, edgeJobs []portainer.EdgeJob, operation string) error {
|
func (handler *Handler) updateEndpointEdgeJobs(edgeGroupID portainer.EdgeGroupID, endpointID portainer.EndpointID, edgeJobs []portainer.EdgeJob, operation string) error {
|
||||||
|
|||||||
@@ -66,5 +66,16 @@ func (handler *Handler) edgeJobTasksCollect(w http.ResponseWriter, r *http.Reque
|
|||||||
return httperror.InternalServerError("Unable to persist Edge job changes in the database", err)
|
return httperror.InternalServerError("Unable to persist Edge job changes in the database", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
endpoint, err := handler.DataStore.Endpoint().Endpoint(endpointID)
|
||||||
|
if err != nil {
|
||||||
|
return httperror.InternalServerError("Unable to retrieve environment from the database", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if endpoint.Edge.AsyncMode {
|
||||||
|
return httperror.BadRequest("Async Edge Endpoints are not supported in Portainer CE", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
handler.ReverseTunnelService.AddEdgeJob(endpointID, edgeJob)
|
||||||
|
|
||||||
return response.Empty(w)
|
return response.Empty(w)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,17 +38,14 @@ func (handler *Handler) edgeStackStatusDelete(w http.ResponseWriter, r *http.Req
|
|||||||
return httperror.Forbidden("Permission denied to access environment", err)
|
return httperror.Forbidden("Permission denied to access environment", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
stack, err := handler.DataStore.EdgeStack().EdgeStack(portainer.EdgeStackID(stackID))
|
var edgeStack *portainer.EdgeStack
|
||||||
|
err = handler.DataStore.EdgeStack().UpdateEdgeStackFunc(portainer.EdgeStackID(stackID), func(stack *portainer.EdgeStack) {
|
||||||
|
delete(stack.Status, endpoint.ID)
|
||||||
|
edgeStack = stack
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return handler.handlerDBErr(err, "Unable to find a stack with the specified identifier inside the database")
|
return handler.handlerDBErr(err, "Unable to persist the stack changes inside the database")
|
||||||
}
|
}
|
||||||
|
|
||||||
delete(stack.Status, endpoint.ID)
|
return response.JSON(w, edgeStack)
|
||||||
|
|
||||||
err = handler.DataStore.EdgeStack().UpdateEdgeStack(stack.ID, stack)
|
|
||||||
if err != nil {
|
|
||||||
return httperror.InternalServerError("Unable to persist the stack changes inside the database", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.JSON(w, stack)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,15 +94,12 @@ func (handler *Handler) edgeStackUpdate(w http.ResponseWriter, r *http.Request)
|
|||||||
}
|
}
|
||||||
|
|
||||||
for endpointID := range endpointsToRemove {
|
for endpointID := range endpointsToRemove {
|
||||||
relation, err := handler.DataStore.EndpointRelation().EndpointRelation(endpointID)
|
err = handler.DataStore.EndpointRelation().UpdateEndpointRelationFunc(endpointID, func(relation *portainer.EndpointRelation) {
|
||||||
if err != nil {
|
delete(relation.EdgeStacks, stack.ID)
|
||||||
|
})
|
||||||
|
if handler.DataStore.IsErrObjectNotFound(err) {
|
||||||
return httperror.InternalServerError("Unable to find environment relation in database", err)
|
return httperror.InternalServerError("Unable to find environment relation in database", err)
|
||||||
}
|
} else if err != nil {
|
||||||
|
|
||||||
delete(relation.EdgeStacks, stack.ID)
|
|
||||||
|
|
||||||
err = handler.DataStore.EndpointRelation().UpdateEndpointRelation(endpointID, relation)
|
|
||||||
if err != nil {
|
|
||||||
return httperror.InternalServerError("Unable to persist environment relation in database", err)
|
return httperror.InternalServerError("Unable to persist environment relation in database", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -114,15 +111,12 @@ func (handler *Handler) edgeStackUpdate(w http.ResponseWriter, r *http.Request)
|
|||||||
}
|
}
|
||||||
|
|
||||||
for endpointID := range endpointsToAdd {
|
for endpointID := range endpointsToAdd {
|
||||||
relation, err := handler.DataStore.EndpointRelation().EndpointRelation(endpointID)
|
err = handler.DataStore.EndpointRelation().UpdateEndpointRelationFunc(endpointID, func(relation *portainer.EndpointRelation) {
|
||||||
if err != nil {
|
relation.EdgeStacks[stack.ID] = true
|
||||||
|
})
|
||||||
|
if handler.DataStore.IsErrObjectNotFound(err) {
|
||||||
return httperror.InternalServerError("Unable to find environment relation in database", err)
|
return httperror.InternalServerError("Unable to find environment relation in database", err)
|
||||||
}
|
} else if err != nil {
|
||||||
|
|
||||||
relation.EdgeStacks[stack.ID] = true
|
|
||||||
|
|
||||||
err = handler.DataStore.EndpointRelation().UpdateEndpointRelation(endpointID, relation)
|
|
||||||
if err != nil {
|
|
||||||
return httperror.InternalServerError("Unable to persist environment relation in database", err)
|
return httperror.InternalServerError("Unable to persist environment relation in database", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -192,7 +186,10 @@ func (handler *Handler) edgeStackUpdate(w http.ResponseWriter, r *http.Request)
|
|||||||
}
|
}
|
||||||
|
|
||||||
stack.NumDeployments = len(relatedEndpointIds)
|
stack.NumDeployments = len(relatedEndpointIds)
|
||||||
stack.Status = make(map[portainer.EndpointID]portainer.EdgeStackStatus)
|
|
||||||
|
if versionUpdated {
|
||||||
|
stack.Status = make(map[portainer.EndpointID]portainer.EdgeStackStatus)
|
||||||
|
}
|
||||||
|
|
||||||
err = handler.DataStore.EdgeStack().UpdateEdgeStack(stack.ID, stack)
|
err = handler.DataStore.EdgeStack().UpdateEdgeStack(stack.ID, stack)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -19,11 +19,6 @@ func (handler *Handler) updateEndpointRelations(endpoint *portainer.Endpoint, en
|
|||||||
endpointGroup = unassignedGroup
|
endpointGroup = unassignedGroup
|
||||||
}
|
}
|
||||||
|
|
||||||
endpointRelation, err := handler.DataStore.EndpointRelation().EndpointRelation(endpoint.ID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
edgeGroups, err := handler.DataStore.EdgeGroup().EdgeGroups()
|
edgeGroups, err := handler.DataStore.EdgeGroup().EdgeGroups()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -39,7 +34,8 @@ func (handler *Handler) updateEndpointRelations(endpoint *portainer.Endpoint, en
|
|||||||
for _, edgeStackID := range endpointStacks {
|
for _, edgeStackID := range endpointStacks {
|
||||||
stacksSet[edgeStackID] = true
|
stacksSet[edgeStackID] = true
|
||||||
}
|
}
|
||||||
endpointRelation.EdgeStacks = stacksSet
|
|
||||||
|
|
||||||
return handler.DataStore.EndpointRelation().UpdateEndpointRelation(endpoint.ID, endpointRelation)
|
return handler.DataStore.EndpointRelation().UpdateEndpointRelationFunc(endpoint.ID, func(relation *portainer.EndpointRelation) {
|
||||||
|
relation.EdgeStacks = stacksSet
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,13 +55,13 @@ const (
|
|||||||
func (payload *endpointCreatePayload) Validate(r *http.Request) error {
|
func (payload *endpointCreatePayload) Validate(r *http.Request) error {
|
||||||
name, err := request.RetrieveMultiPartFormValue(r, "Name", false)
|
name, err := request.RetrieveMultiPartFormValue(r, "Name", false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.New("Invalid environment name")
|
return errors.New("invalid environment name")
|
||||||
}
|
}
|
||||||
payload.Name = name
|
payload.Name = name
|
||||||
|
|
||||||
endpointCreationType, err := request.RetrieveNumericMultiPartFormValue(r, "EndpointCreationType", false)
|
endpointCreationType, err := request.RetrieveNumericMultiPartFormValue(r, "EndpointCreationType", false)
|
||||||
if err != nil || endpointCreationType == 0 {
|
if err != nil || endpointCreationType == 0 {
|
||||||
return errors.New("Invalid environment type value. Value must be one of: 1 (Docker environment), 2 (Agent environment), 3 (Azure environment), 4 (Edge Agent environment) or 5 (Local Kubernetes environment)")
|
return errors.New("invalid environment type value. Value must be one of: 1 (Docker environment), 2 (Agent environment), 3 (Azure environment), 4 (Edge Agent environment) or 5 (Local Kubernetes environment)")
|
||||||
}
|
}
|
||||||
payload.EndpointCreationType = endpointCreationEnum(endpointCreationType)
|
payload.EndpointCreationType = endpointCreationEnum(endpointCreationType)
|
||||||
|
|
||||||
@@ -74,7 +74,7 @@ func (payload *endpointCreatePayload) Validate(r *http.Request) error {
|
|||||||
var tagIDs []portainer.TagID
|
var tagIDs []portainer.TagID
|
||||||
err = request.RetrieveMultiPartFormJSONValue(r, "TagIds", &tagIDs, true)
|
err = request.RetrieveMultiPartFormJSONValue(r, "TagIds", &tagIDs, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.New("Invalid TagIds parameter")
|
return errors.New("invalid TagIds parameter")
|
||||||
}
|
}
|
||||||
payload.TagIDs = tagIDs
|
payload.TagIDs = tagIDs
|
||||||
if payload.TagIDs == nil {
|
if payload.TagIDs == nil {
|
||||||
@@ -93,7 +93,7 @@ func (payload *endpointCreatePayload) Validate(r *http.Request) error {
|
|||||||
if !payload.TLSSkipVerify {
|
if !payload.TLSSkipVerify {
|
||||||
caCert, _, err := request.RetrieveMultiPartFormFile(r, "TLSCACertFile")
|
caCert, _, err := request.RetrieveMultiPartFormFile(r, "TLSCACertFile")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.New("Invalid CA certificate file. Ensure that the file is uploaded correctly")
|
return errors.New("invalid CA certificate file. Ensure that the file is uploaded correctly")
|
||||||
}
|
}
|
||||||
payload.TLSCACertFile = caCert
|
payload.TLSCACertFile = caCert
|
||||||
}
|
}
|
||||||
@@ -101,13 +101,13 @@ func (payload *endpointCreatePayload) Validate(r *http.Request) error {
|
|||||||
if !payload.TLSSkipClientVerify {
|
if !payload.TLSSkipClientVerify {
|
||||||
cert, _, err := request.RetrieveMultiPartFormFile(r, "TLSCertFile")
|
cert, _, err := request.RetrieveMultiPartFormFile(r, "TLSCertFile")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.New("Invalid certificate file. Ensure that the file is uploaded correctly")
|
return errors.New("invalid certificate file. Ensure that the file is uploaded correctly")
|
||||||
}
|
}
|
||||||
payload.TLSCertFile = cert
|
payload.TLSCertFile = cert
|
||||||
|
|
||||||
key, _, err := request.RetrieveMultiPartFormFile(r, "TLSKeyFile")
|
key, _, err := request.RetrieveMultiPartFormFile(r, "TLSKeyFile")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.New("Invalid key file. Ensure that the file is uploaded correctly")
|
return errors.New("invalid key file. Ensure that the file is uploaded correctly")
|
||||||
}
|
}
|
||||||
payload.TLSKeyFile = key
|
payload.TLSKeyFile = key
|
||||||
}
|
}
|
||||||
@@ -117,19 +117,19 @@ func (payload *endpointCreatePayload) Validate(r *http.Request) error {
|
|||||||
case azureEnvironment:
|
case azureEnvironment:
|
||||||
azureApplicationID, err := request.RetrieveMultiPartFormValue(r, "AzureApplicationID", false)
|
azureApplicationID, err := request.RetrieveMultiPartFormValue(r, "AzureApplicationID", false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.New("Invalid Azure application ID")
|
return errors.New("invalid Azure application ID")
|
||||||
}
|
}
|
||||||
payload.AzureApplicationID = azureApplicationID
|
payload.AzureApplicationID = azureApplicationID
|
||||||
|
|
||||||
azureTenantID, err := request.RetrieveMultiPartFormValue(r, "AzureTenantID", false)
|
azureTenantID, err := request.RetrieveMultiPartFormValue(r, "AzureTenantID", false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.New("Invalid Azure tenant ID")
|
return errors.New("invalid Azure tenant ID")
|
||||||
}
|
}
|
||||||
payload.AzureTenantID = azureTenantID
|
payload.AzureTenantID = azureTenantID
|
||||||
|
|
||||||
azureAuthenticationKey, err := request.RetrieveMultiPartFormValue(r, "AzureAuthenticationKey", false)
|
azureAuthenticationKey, err := request.RetrieveMultiPartFormValue(r, "AzureAuthenticationKey", false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.New("Invalid Azure authentication key")
|
return errors.New("invalid Azure authentication key")
|
||||||
}
|
}
|
||||||
payload.AzureAuthenticationKey = azureAuthenticationKey
|
payload.AzureAuthenticationKey = azureAuthenticationKey
|
||||||
|
|
||||||
@@ -146,7 +146,7 @@ func (payload *endpointCreatePayload) Validate(r *http.Request) error {
|
|||||||
default:
|
default:
|
||||||
endpointURL, err := request.RetrieveMultiPartFormValue(r, "URL", true)
|
endpointURL, err := request.RetrieveMultiPartFormValue(r, "URL", true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.New("Invalid environment URL")
|
return errors.New("invalid environment URL")
|
||||||
}
|
}
|
||||||
payload.URL = endpointURL
|
payload.URL = endpointURL
|
||||||
|
|
||||||
@@ -157,7 +157,7 @@ func (payload *endpointCreatePayload) Validate(r *http.Request) error {
|
|||||||
gpus := make([]portainer.Pair, 0)
|
gpus := make([]portainer.Pair, 0)
|
||||||
err = request.RetrieveMultiPartFormJSONValue(r, "Gpus", &gpus, true)
|
err = request.RetrieveMultiPartFormJSONValue(r, "Gpus", &gpus, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.New("Invalid Gpus parameter")
|
return errors.New("invalid Gpus parameter")
|
||||||
}
|
}
|
||||||
payload.Gpus = gpus
|
payload.Gpus = gpus
|
||||||
|
|
||||||
@@ -195,6 +195,9 @@ func (payload *endpointCreatePayload) Validate(r *http.Request) error {
|
|||||||
// @param AzureAuthenticationKey formData string false "Azure authentication key. Required if environment(endpoint) type is set to 3"
|
// @param AzureAuthenticationKey formData string false "Azure authentication key. Required if environment(endpoint) type is set to 3"
|
||||||
// @param TagIDs formData []int false "List of tag identifiers to which this environment(endpoint) is associated"
|
// @param TagIDs formData []int false "List of tag identifiers to which this environment(endpoint) is associated"
|
||||||
// @param EdgeCheckinInterval formData int false "The check in interval for edge agent (in seconds)"
|
// @param EdgeCheckinInterval formData int false "The check in interval for edge agent (in seconds)"
|
||||||
|
// @param EdgeTunnelServerAddress formData string true "URL or IP address that will be used to establish a reverse tunnel"
|
||||||
|
// @param IsEdgeDevice formData bool false "Is Edge Device"
|
||||||
|
// @param Gpus formData array false "List of GPUs"
|
||||||
// @success 200 {object} portainer.Endpoint "Success"
|
// @success 200 {object} portainer.Endpoint "Success"
|
||||||
// @failure 400 "Invalid request"
|
// @failure 400 "Invalid request"
|
||||||
// @failure 500 "Server error"
|
// @failure 500 "Server error"
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"github.com/portainer/libhttp/response"
|
"github.com/portainer/libhttp/response"
|
||||||
portainer "github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
httperrors "github.com/portainer/portainer/api/http/errors"
|
httperrors "github.com/portainer/portainer/api/http/errors"
|
||||||
|
"github.com/portainer/portainer/api/internal/endpointutils"
|
||||||
)
|
)
|
||||||
|
|
||||||
// @id EndpointDelete
|
// @id EndpointDelete
|
||||||
@@ -100,8 +101,9 @@ func (handler *Handler) endpointDelete(w http.ResponseWriter, r *http.Request) *
|
|||||||
for idx := range edgeStacks {
|
for idx := range edgeStacks {
|
||||||
edgeStack := &edgeStacks[idx]
|
edgeStack := &edgeStacks[idx]
|
||||||
if _, ok := edgeStack.Status[endpoint.ID]; ok {
|
if _, ok := edgeStack.Status[endpoint.ID]; ok {
|
||||||
delete(edgeStack.Status, endpoint.ID)
|
err = handler.DataStore.EdgeStack().UpdateEdgeStackFunc(edgeStack.ID, func(stack *portainer.EdgeStack) {
|
||||||
err = handler.DataStore.EdgeStack().UpdateEdgeStack(edgeStack.ID, edgeStack)
|
delete(stack.Status, endpoint.ID)
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return httperror.InternalServerError("Unable to update edge stack", err)
|
return httperror.InternalServerError("Unable to update edge stack", err)
|
||||||
}
|
}
|
||||||
@@ -124,6 +126,26 @@ func (handler *Handler) endpointDelete(w http.ResponseWriter, r *http.Request) *
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !endpointutils.IsEdgeEndpoint(endpoint) {
|
||||||
|
return response.Empty(w)
|
||||||
|
}
|
||||||
|
|
||||||
|
edgeJobs, err := handler.DataStore.EdgeJob().EdgeJobs()
|
||||||
|
if err != nil {
|
||||||
|
return httperror.InternalServerError("Unable to retrieve edge jobs from the database", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for idx := range edgeJobs {
|
||||||
|
edgeJob := &edgeJobs[idx]
|
||||||
|
if _, ok := edgeJob.Endpoints[endpoint.ID]; ok {
|
||||||
|
delete(edgeJob.Endpoints, endpoint.ID)
|
||||||
|
err = handler.DataStore.EdgeJob().UpdateEdgeJob(edgeJob.ID, edgeJob)
|
||||||
|
if err != nil {
|
||||||
|
return httperror.InternalServerError("Unable to update edge job", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return response.Empty(w)
|
return response.Empty(w)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"github.com/portainer/libhttp/request"
|
"github.com/portainer/libhttp/request"
|
||||||
"github.com/portainer/libhttp/response"
|
"github.com/portainer/libhttp/response"
|
||||||
portainer "github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
|
"github.com/portainer/portainer/api/internal/endpointutils"
|
||||||
)
|
)
|
||||||
|
|
||||||
// @id EndpointInspect
|
// @id EndpointInspect
|
||||||
@@ -51,6 +52,24 @@ func (handler *Handler) endpointInspect(w http.ResponseWriter, r *http.Request)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isServerMetricsDetected := endpoint.Kubernetes.Flags.IsServerMetricsDetected
|
||||||
|
if endpointutils.IsKubernetesEndpoint(endpoint) && !isServerMetricsDetected && handler.K8sClientFactory != nil {
|
||||||
|
endpointutils.InitialMetricsDetection(
|
||||||
|
endpoint,
|
||||||
|
handler.DataStore.Endpoint(),
|
||||||
|
handler.K8sClientFactory,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
isServerStorageDetected := endpoint.Kubernetes.Flags.IsServerStorageDetected
|
||||||
|
if !isServerStorageDetected && handler.K8sClientFactory != nil {
|
||||||
|
endpointutils.InitialStorageDetection(
|
||||||
|
endpoint,
|
||||||
|
handler.DataStore.Endpoint(),
|
||||||
|
handler.K8sClientFactory,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return response.JSON(w, endpoint)
|
return response.JSON(w, endpoint)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ type Handler struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// @title PortainerCE API
|
// @title PortainerCE API
|
||||||
// @version 2.17.0
|
// @version 2.17.1
|
||||||
// @description.markdown api-description.md
|
// @description.markdown api-description.md
|
||||||
// @termsOfService
|
// @termsOfService
|
||||||
|
|
||||||
|
|||||||
@@ -106,11 +106,6 @@ func (handler *Handler) tagDelete(w http.ResponseWriter, r *http.Request) *httpe
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (handler *Handler) updateEndpointRelations(endpoint portainer.Endpoint, edgeGroups []portainer.EdgeGroup, edgeStacks []portainer.EdgeStack) error {
|
func (handler *Handler) updateEndpointRelations(endpoint portainer.Endpoint, edgeGroups []portainer.EdgeGroup, edgeStacks []portainer.EdgeStack) error {
|
||||||
endpointRelation, err := handler.DataStore.EndpointRelation().EndpointRelation(endpoint.ID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
endpointGroup, err := handler.DataStore.EndpointGroup().EndpointGroup(endpoint.GroupID)
|
endpointGroup, err := handler.DataStore.EndpointGroup().EndpointGroup(endpoint.GroupID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -121,9 +116,10 @@ func (handler *Handler) updateEndpointRelations(endpoint portainer.Endpoint, edg
|
|||||||
for _, edgeStackID := range endpointStacks {
|
for _, edgeStackID := range endpointStacks {
|
||||||
stacksSet[edgeStackID] = true
|
stacksSet[edgeStackID] = true
|
||||||
}
|
}
|
||||||
endpointRelation.EdgeStacks = stacksSet
|
|
||||||
|
|
||||||
return handler.DataStore.EndpointRelation().UpdateEndpointRelation(endpoint.ID, endpointRelation)
|
return handler.DataStore.EndpointRelation().UpdateEndpointRelationFunc(endpoint.ID, func(relation *portainer.EndpointRelation) {
|
||||||
|
relation.EdgeStacks = stacksSet
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func removeElement(slice []portainer.TagID, elem portainer.TagID) []portainer.TagID {
|
func removeElement(slice []portainer.TagID, elem portainer.TagID) []portainer.TagID {
|
||||||
|
|||||||
@@ -15,10 +15,18 @@ import (
|
|||||||
"github.com/asaskevich/govalidator"
|
"github.com/asaskevich/govalidator"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type themePayload struct {
|
||||||
|
// Color represents the color theme of the UI
|
||||||
|
Color *string `json:"color" example:"dark" enums:"dark,light,highcontrast,auto"`
|
||||||
|
// SubtleUpgradeButton indicates if the upgrade banner should be displayed in a subtle way
|
||||||
|
SubtleUpgradeButton *bool `json:"subtleUpgradeButton" example:"false"`
|
||||||
|
}
|
||||||
|
|
||||||
type userUpdatePayload struct {
|
type userUpdatePayload struct {
|
||||||
Username string `validate:"required" example:"bob"`
|
Username string `validate:"required" example:"bob"`
|
||||||
Password string `validate:"required" example:"cg9Wgky3"`
|
Password string `validate:"required" example:"cg9Wgky3"`
|
||||||
UserTheme string `example:"dark"`
|
Theme *themePayload
|
||||||
|
|
||||||
// User role (1 for administrator account and 2 for regular account)
|
// User role (1 for administrator account and 2 for regular account)
|
||||||
Role int `validate:"required" enums:"1,2" example:"2"`
|
Role int `validate:"required" enums:"1,2" example:"2"`
|
||||||
}
|
}
|
||||||
@@ -108,8 +116,14 @@ func (handler *Handler) userUpdate(w http.ResponseWriter, r *http.Request) *http
|
|||||||
user.TokenIssueAt = time.Now().Unix()
|
user.TokenIssueAt = time.Now().Unix()
|
||||||
}
|
}
|
||||||
|
|
||||||
if payload.UserTheme != "" {
|
if payload.Theme != nil {
|
||||||
user.UserTheme = payload.UserTheme
|
if payload.Theme.Color != nil {
|
||||||
|
user.ThemeSettings.Color = *payload.Theme.Color
|
||||||
|
}
|
||||||
|
|
||||||
|
if payload.Theme.SubtleUpgradeButton != nil {
|
||||||
|
user.ThemeSettings.SubtleUpgradeButton = *payload.Theme.SubtleUpgradeButton
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if payload.Role != 0 {
|
if payload.Role != 0 {
|
||||||
|
|||||||
@@ -6,11 +6,13 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/pkg/errors"
|
|
||||||
portainer "github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
"github.com/portainer/portainer/api/dataservices"
|
"github.com/portainer/portainer/api/dataservices"
|
||||||
"github.com/portainer/portainer/api/internal/edge"
|
"github.com/portainer/portainer/api/internal/edge"
|
||||||
edgetypes "github.com/portainer/portainer/api/internal/edge/types"
|
edgetypes "github.com/portainer/portainer/api/internal/edge/types"
|
||||||
|
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Service represents a service for managing edge stacks.
|
// Service represents a service for managing edge stacks.
|
||||||
@@ -112,15 +114,12 @@ func (service *Service) updateEndpointRelations(edgeStackID portainer.EdgeStackI
|
|||||||
endpointRelationService := service.dataStore.EndpointRelation()
|
endpointRelationService := service.dataStore.EndpointRelation()
|
||||||
|
|
||||||
for _, endpointID := range relatedEndpointIds {
|
for _, endpointID := range relatedEndpointIds {
|
||||||
relation, err := endpointRelationService.EndpointRelation(endpointID)
|
err := endpointRelationService.UpdateEndpointRelationFunc(endpointID, func(relation *portainer.EndpointRelation) {
|
||||||
if err != nil {
|
relation.EdgeStacks[edgeStackID] = true
|
||||||
|
})
|
||||||
|
if service.dataStore.IsErrObjectNotFound(err) {
|
||||||
return fmt.Errorf("unable to find endpoint relation in database: %w", err)
|
return fmt.Errorf("unable to find endpoint relation in database: %w", err)
|
||||||
}
|
} else if err != nil {
|
||||||
|
|
||||||
relation.EdgeStacks[edgeStackID] = true
|
|
||||||
|
|
||||||
err = endpointRelationService.UpdateEndpointRelation(endpointID, relation)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("unable to persist endpoint relation in database: %w", err)
|
return fmt.Errorf("unable to persist endpoint relation in database: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -142,15 +141,15 @@ func (service *Service) DeleteEdgeStack(edgeStackID portainer.EdgeStackID, relat
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, endpointID := range relatedEndpointIds {
|
for _, endpointID := range relatedEndpointIds {
|
||||||
relation, err := service.dataStore.EndpointRelation().EndpointRelation(endpointID)
|
service.dataStore.EndpointRelation().UpdateEndpointRelationFunc(endpointID, func(relation *portainer.EndpointRelation) {
|
||||||
if err != nil {
|
delete(relation.EdgeStacks, edgeStackID)
|
||||||
return errors.WithMessage(err, "Unable to find environment relation in database")
|
})
|
||||||
}
|
if service.dataStore.IsErrObjectNotFound(err) {
|
||||||
|
log.Warn().
|
||||||
delete(relation.EdgeStacks, edgeStackID)
|
Int("endpoint_id", int(endpointID)).
|
||||||
|
Msg("Unable to find endpoint relation in database, skipping")
|
||||||
err = service.dataStore.EndpointRelation().UpdateEndpointRelation(endpointID, relation)
|
continue
|
||||||
if err != nil {
|
} else if err != nil {
|
||||||
return errors.WithMessage(err, "Unable to persist environment relation in database")
|
return errors.WithMessage(err, "Unable to persist environment relation in database")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package endpointutils
|
package endpointutils
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
portainer "github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
"github.com/portainer/portainer/api/dataservices"
|
"github.com/portainer/portainer/api/dataservices"
|
||||||
@@ -116,6 +118,7 @@ func InitialMetricsDetection(endpoint *portainer.Endpoint, endpointService datas
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
endpoint.Kubernetes.Configuration.UseServerMetrics = true
|
endpoint.Kubernetes.Configuration.UseServerMetrics = true
|
||||||
|
endpoint.Kubernetes.Flags.IsServerMetricsDetected = true
|
||||||
err = endpointService.UpdateEndpoint(
|
err = endpointService.UpdateEndpoint(
|
||||||
portainer.EndpointID(endpoint.ID),
|
portainer.EndpointID(endpoint.ID),
|
||||||
endpoint,
|
endpoint,
|
||||||
@@ -126,17 +129,21 @@ func InitialMetricsDetection(endpoint *portainer.Endpoint, endpointService datas
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func InitialStorageDetection(endpoint *portainer.Endpoint, endpointService dataservices.EndpointService, factory *cli.ClientFactory) {
|
func storageDetect(endpoint *portainer.Endpoint, endpointService dataservices.EndpointService, factory *cli.ClientFactory) error {
|
||||||
cli, err := factory.GetKubeClient(endpoint)
|
cli, err := factory.GetKubeClient(endpoint)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Debug().Err(err).Msg("unable to create Kubernetes client for initial storage detection")
|
log.Debug().Err(err).Msg("unable to create Kubernetes client for initial storage detection")
|
||||||
return
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
storage, err := cli.GetStorage()
|
storage, err := cli.GetStorage()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Debug().Err(err).Msg("unable to fetch storage classes: leaving storage classes disabled")
|
log.Debug().Err(err).Msg("unable to fetch storage classes: leaving storage classes disabled")
|
||||||
return
|
return err
|
||||||
|
}
|
||||||
|
if len(storage) == 0 {
|
||||||
|
log.Info().Err(err).Msg("zero storage classes found: they may be still building, retrying in 30 seconds")
|
||||||
|
return fmt.Errorf("zero storage classes found: they may be still building, retrying in 30 seconds")
|
||||||
}
|
}
|
||||||
endpoint.Kubernetes.Configuration.StorageClasses = storage
|
endpoint.Kubernetes.Configuration.StorageClasses = storage
|
||||||
err = endpointService.UpdateEndpoint(
|
err = endpointService.UpdateEndpoint(
|
||||||
@@ -145,6 +152,23 @@ func InitialStorageDetection(endpoint *portainer.Endpoint, endpointService datas
|
|||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Debug().Err(err).Msg("unable to enable storage class inside the database")
|
log.Debug().Err(err).Msg("unable to enable storage class inside the database")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func InitialStorageDetection(endpoint *portainer.Endpoint, endpointService dataservices.EndpointService, factory *cli.ClientFactory) {
|
||||||
|
log.Info().Msg("attempting to detect storage classes in the cluster")
|
||||||
|
err := storageDetect(endpoint, endpointService, factory)
|
||||||
|
if err == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
log.Err(err).Msg("error while detecting storage classes")
|
||||||
|
go func() {
|
||||||
|
// Retry after 30 seconds if the initial detection failed.
|
||||||
|
log.Info().Msg("retrying storage detection in 30 seconds")
|
||||||
|
time.Sleep(30 * time.Second)
|
||||||
|
err := storageDetect(endpoint, endpointService, factory)
|
||||||
|
log.Err(err).Msg("final error while detecting storage classes")
|
||||||
|
}()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -277,6 +277,16 @@ func (s *stubEndpointService) UpdateEndpoint(ID portainer.EndpointID, endpoint *
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *stubEndpointRelationService) UpdateEndpointRelationFunc(ID portainer.EndpointID, updateFunc func(relation *portainer.EndpointRelation)) error {
|
||||||
|
for i, r := range s.relations {
|
||||||
|
if r.EndpointID == ID {
|
||||||
|
updateFunc(&s.relations[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *stubEndpointService) DeleteEndpoint(ID portainer.EndpointID) error {
|
func (s *stubEndpointService) DeleteEndpoint(ID portainer.EndpointID) error {
|
||||||
endpoints := []portainer.Endpoint{}
|
endpoints := []portainer.Endpoint{}
|
||||||
|
|
||||||
|
|||||||
@@ -11,10 +11,10 @@ import (
|
|||||||
"github.com/cbroglie/mustache"
|
"github.com/cbroglie/mustache"
|
||||||
"github.com/docker/docker/api/types"
|
"github.com/docker/docker/api/types"
|
||||||
"github.com/docker/docker/api/types/filters"
|
"github.com/docker/docker/api/types/filters"
|
||||||
"github.com/docker/docker/client"
|
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
libstack "github.com/portainer/docker-compose-wrapper"
|
libstack "github.com/portainer/docker-compose-wrapper"
|
||||||
portainer "github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
|
"github.com/portainer/portainer/api/docker"
|
||||||
"github.com/portainer/portainer/api/filesystem"
|
"github.com/portainer/portainer/api/filesystem"
|
||||||
"github.com/portainer/portainer/api/platform"
|
"github.com/portainer/portainer/api/platform"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
@@ -150,7 +150,7 @@ func (service *service) upgradeDocker(licenseKey, version, envType string) error
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (service *service) checkImage(ctx context.Context, image string, skipPullImage bool) error {
|
func (service *service) checkImage(ctx context.Context, image string, skipPullImage bool) error {
|
||||||
cli, err := client.NewClientWithOpts(client.FromEnv)
|
cli, err := docker.CreateClientFromEnv()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err, "failed to create docker client")
|
return errors.Wrap(err, "failed to create docker client")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ func (kcl *KubeClient) CreateIngress(namespace string, info models.K8sIngressInf
|
|||||||
if ingress.Labels == nil {
|
if ingress.Labels == nil {
|
||||||
ingress.Labels = make(map[string]string)
|
ingress.Labels = make(map[string]string)
|
||||||
}
|
}
|
||||||
ingress.Labels["io.portainer.kubernetes.application.owner"] = stackutils.SanitizeLabel(owner)
|
ingress.Labels["io.portainer.kubernetes.ingress.owner"] = stackutils.SanitizeLabel(owner)
|
||||||
|
|
||||||
// Store TLS information.
|
// Store TLS information.
|
||||||
var tls []netv1.IngressTLS
|
var tls []netv1.IngressTLS
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package cli
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/portainer/portainer/api/internal/randomstring"
|
"github.com/portainer/portainer/api/internal/randomstring"
|
||||||
|
|
||||||
@@ -133,10 +134,34 @@ func createRoleBinding(roleBindingClient rbacv1types.RoleBindingInterface, clust
|
|||||||
APIGroup: "rbac.authorization.k8s.io",
|
APIGroup: "rbac.authorization.k8s.io",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
_, err := roleBindingClient.Create(context.Background(), clusterRoleBinding, metav1.CreateOptions{})
|
roleBinding, err := roleBindingClient.Create(context.Background(), clusterRoleBinding, metav1.CreateOptions{})
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Msg("Error creating role binding: " + clusterRoleBindingName)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retry checkRoleBinding a maximum of 5 times with a 100ms wait after each attempt
|
||||||
|
maxRetries := 5
|
||||||
|
for i := 0; i < maxRetries; i++ {
|
||||||
|
err = checkRoleBinding(roleBindingClient, roleBinding.Name)
|
||||||
|
time.Sleep(100 * time.Millisecond) // Wait for 100ms, even if the check passes
|
||||||
|
if err == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func checkRoleBinding(roleBindingClient rbacv1types.RoleBindingInterface, name string) error {
|
||||||
|
_, err := roleBindingClient.Get(context.Background(), name, metav1.GetOptions{})
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Msg("Error finding rolebinding: " + name)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func deleteRoleBinding(roleBindingClient rbacv1types.RoleBindingInterface, name string) {
|
func deleteRoleBinding(roleBindingClient rbacv1types.RoleBindingInterface, name string) {
|
||||||
err := roleBindingClient.Delete(context.Background(), name, metav1.DeleteOptions{})
|
err := roleBindingClient.Delete(context.Background(), name, metav1.DeleteOptions{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
|
|
||||||
"github.com/docker/docker/client"
|
"github.com/docker/docker/client"
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
|
"github.com/portainer/portainer/api/docker"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -53,7 +54,7 @@ func DetermineContainerPlatform() (ContainerPlatform, error) {
|
|||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
dockerCli, err := client.NewClientWithOpts()
|
dockerCli, err := docker.CreateSimpleClient()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", errors.WithMessage(err, "failed to create docker client")
|
return "", errors.WithMessage(err, "failed to create docker client")
|
||||||
}
|
}
|
||||||
|
|||||||
+24
-7
@@ -562,6 +562,12 @@ type (
|
|||||||
KubernetesData struct {
|
KubernetesData struct {
|
||||||
Snapshots []KubernetesSnapshot `json:"Snapshots"`
|
Snapshots []KubernetesSnapshot `json:"Snapshots"`
|
||||||
Configuration KubernetesConfiguration `json:"Configuration"`
|
Configuration KubernetesConfiguration `json:"Configuration"`
|
||||||
|
Flags KubernetesFlags `json:"Flags"`
|
||||||
|
}
|
||||||
|
|
||||||
|
KubernetesFlags struct {
|
||||||
|
IsServerMetricsDetected bool `json:"IsServerMetricsDetected"`
|
||||||
|
IsServerStorageDetected bool `json:"IsServerStorageDetected"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// KubernetesSnapshot represents a snapshot of a specific Kubernetes environment(endpoint) at a specific time
|
// KubernetesSnapshot represents a snapshot of a specific Kubernetes environment(endpoint) at a specific time
|
||||||
@@ -1231,16 +1237,19 @@ type (
|
|||||||
ID UserID `json:"Id" example:"1"`
|
ID UserID `json:"Id" example:"1"`
|
||||||
Username string `json:"Username" example:"bob"`
|
Username string `json:"Username" example:"bob"`
|
||||||
Password string `json:"Password,omitempty" swaggerignore:"true"`
|
Password string `json:"Password,omitempty" swaggerignore:"true"`
|
||||||
// User Theme
|
|
||||||
UserTheme string `example:"dark"`
|
|
||||||
// User role (1 for administrator account and 2 for regular account)
|
// User role (1 for administrator account and 2 for regular account)
|
||||||
Role UserRole `json:"Role" example:"1"`
|
Role UserRole `json:"Role" example:"1"`
|
||||||
TokenIssueAt int64 `json:"TokenIssueAt" example:"1"`
|
TokenIssueAt int64 `json:"TokenIssueAt" example:"1"`
|
||||||
|
ThemeSettings UserThemeSettings
|
||||||
|
|
||||||
// Deprecated fields
|
// Deprecated fields
|
||||||
|
|
||||||
|
// Deprecated
|
||||||
|
UserTheme string `example:"dark"`
|
||||||
// Deprecated in DBVersion == 25
|
// Deprecated in DBVersion == 25
|
||||||
PortainerAuthorizations Authorizations `json:"PortainerAuthorizations"`
|
PortainerAuthorizations Authorizations
|
||||||
EndpointAuthorizations EndpointAuthorizations `json:"EndpointAuthorizations"`
|
// Deprecated in DBVersion == 25
|
||||||
|
EndpointAuthorizations EndpointAuthorizations
|
||||||
}
|
}
|
||||||
|
|
||||||
// UserAccessPolicies represent the association of an access policy and a user
|
// UserAccessPolicies represent the association of an access policy and a user
|
||||||
@@ -1259,6 +1268,14 @@ type (
|
|||||||
// or a regular user
|
// or a regular user
|
||||||
UserRole int
|
UserRole int
|
||||||
|
|
||||||
|
// UserThemeSettings represents the theme settings for a user
|
||||||
|
UserThemeSettings struct {
|
||||||
|
// Color represents the color theme of the UI
|
||||||
|
Color string `json:"color" example:"dark" enums:"dark,light,highcontrast,auto"`
|
||||||
|
// SubtleUpgradeButton indicates if the upgrade banner should be displayed in a subtle way
|
||||||
|
SubtleUpgradeButton bool `json:"subtleUpgradeButton"`
|
||||||
|
}
|
||||||
|
|
||||||
// Webhook represents a url webhook that can be used to update a service
|
// Webhook represents a url webhook that can be used to update a service
|
||||||
Webhook struct {
|
Webhook struct {
|
||||||
// Webhook Identifier
|
// Webhook Identifier
|
||||||
@@ -1479,7 +1496,7 @@ type (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
// APIVersion is the version number of the Portainer API
|
// APIVersion is the version number of the Portainer API
|
||||||
APIVersion = "2.17.0"
|
APIVersion = "2.17.1"
|
||||||
// Edition is what this edition of Portainer is called
|
// Edition is what this edition of Portainer is called
|
||||||
Edition = PortainerCE
|
Edition = PortainerCE
|
||||||
// ComposeSyntaxMaxVersion is a maximum supported version of the docker compose syntax
|
// ComposeSyntaxMaxVersion is a maximum supported version of the docker compose syntax
|
||||||
|
|||||||
@@ -210,25 +210,12 @@ input[type='checkbox'] {
|
|||||||
margin-right: 10px;
|
margin-right: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.blocklist-item--disabled {
|
|
||||||
cursor: auto;
|
|
||||||
background-color: var(--grey-12);
|
|
||||||
}
|
|
||||||
|
|
||||||
.blocklist-item--selected {
|
.blocklist-item--selected {
|
||||||
background-color: var(--bg-blocklist-item-selected-color);
|
background-color: var(--bg-blocklist-item-selected-color);
|
||||||
border: 2px solid var(--border-blocklist-item-selected-color);
|
border: 2px solid var(--border-blocklist-item-selected-color);
|
||||||
color: var(--text-blocklist-item-selected-color);
|
color: var(--text-blocklist-item-selected-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.blocklist-item:not(.blocklist-item-not-interactive):hover {
|
|
||||||
@apply border border-blue-7;
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
background-color: var(--bg-blocklist-hover-color);
|
|
||||||
color: var(--text-blocklist-hover-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.blocklist-item-box {
|
.blocklist-item-box {
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,6 @@
|
|||||||
.btn[disabled],
|
.btn[disabled],
|
||||||
fieldset[disabled] .btn {
|
fieldset[disabled] .btn {
|
||||||
@apply opacity-40;
|
@apply opacity-40;
|
||||||
pointer-events: none;
|
|
||||||
touch-action: none;
|
touch-action: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -121,11 +121,6 @@ pr-icon {
|
|||||||
width: 20px;
|
width: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon-container {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-only-icon {
|
.btn-only-icon {
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,3 +21,4 @@ import '../fonts/nomad-icon.css';
|
|||||||
import './bootstrap-override.css';
|
import './bootstrap-override.css';
|
||||||
import './icon.css';
|
import './icon.css';
|
||||||
import './button.css';
|
import './button.css';
|
||||||
|
import './react-datetime-picker-override.css';
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
/* react-datetime-picker */
|
||||||
|
/* https://github.com/wojtekmaj/react-datetime-picker#custom-styling */
|
||||||
|
|
||||||
|
/*
|
||||||
|
library css for buttons is overriden by `.widget .widget-body button`
|
||||||
|
so we have to force margin: 0
|
||||||
|
*/
|
||||||
|
.react-datetime-picker .react-calendar button {
|
||||||
|
margin: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Extending Calendar.css from react-datetime-picker
|
||||||
|
*/
|
||||||
|
.react-datetime-picker .react-calendar {
|
||||||
|
background: var(--bg-calendar-color);
|
||||||
|
color: var(--text-main-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* calendar nav buttons */
|
||||||
|
.react-datetime-picker .react-calendar__navigation button:disabled {
|
||||||
|
background-color: var(--bg-calendar-color);
|
||||||
|
@apply opacity-60;
|
||||||
|
@apply brightness-95 th-dark:brightness-110;
|
||||||
|
}
|
||||||
|
.react-datetime-picker .react-calendar__navigation button:enabled:hover,
|
||||||
|
.react-datetime-picker .react-calendar__navigation button:enabled:focus {
|
||||||
|
background-color: var(--bg-daterangepicker-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* date tile */
|
||||||
|
.react-datetime-picker .react-calendar__tile:disabled {
|
||||||
|
background-color: var(--bg-calendar-color);
|
||||||
|
@apply opacity-60;
|
||||||
|
@apply brightness-95 th-dark:brightness-110;
|
||||||
|
}
|
||||||
|
.react-datetime-picker .react-calendar__tile:enabled:hover,
|
||||||
|
.react-datetime-picker .react-calendar__tile:enabled:focus {
|
||||||
|
background-color: var(--bg-daterangepicker-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* today's date tile */
|
||||||
|
.react-datetime-picker .react-calendar__tile--now {
|
||||||
|
/* use background color to avoid white on yellow in dark/high contrast modes */
|
||||||
|
@apply th-dark:text-[color:var(--bg-calendar-color)] th-highcontrast:text-[color:var(--bg-calendar-color)];
|
||||||
|
}
|
||||||
|
.react-datetime-picker .react-calendar__tile--now:enabled:hover,
|
||||||
|
.react-datetime-picker .react-calendar__tile--now:enabled:focus {
|
||||||
|
background: var(--bg-daterangepicker-hover);
|
||||||
|
color: var(--text-daterangepicker-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* probably date tile in range */
|
||||||
|
.react-datetime-picker .react-calendar__tile--hasActive {
|
||||||
|
background: var(--bg-daterangepicker-end-date);
|
||||||
|
color: var(--text-daterangepicker-end-date);
|
||||||
|
}
|
||||||
|
.react-datetime-picker .react-calendar__tile--hasActive:enabled:hover,
|
||||||
|
.react-datetime-picker .react-calendar__tile--hasActive:enabled:focus {
|
||||||
|
background: var(--bg-daterangepicker-hover);
|
||||||
|
color: var(--text-daterangepicker-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* selected date tile */
|
||||||
|
.react-datetime-picker .react-calendar__tile--active {
|
||||||
|
background: var(--bg-daterangepicker-active);
|
||||||
|
color: var(--text-daterangepicker-active);
|
||||||
|
}
|
||||||
|
.react-datetime-picker .react-calendar__tile--active:enabled:hover,
|
||||||
|
.react-datetime-picker .react-calendar__tile--active:enabled:focus {
|
||||||
|
background: var(--bg-daterangepicker-hover);
|
||||||
|
color: var(--text-daterangepicker-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* on range select hover */
|
||||||
|
.react-datetime-picker .react-calendar--selectRange .react-calendar__tile--hover {
|
||||||
|
background-color: var(--bg-daterangepicker-in-range);
|
||||||
|
color: var(--text-daterangepicker-in-range);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Extending DateTimePicker.css from react-datetime-picker
|
||||||
|
*/
|
||||||
|
.react-datetime-picker .react-datetime-picker--disabled {
|
||||||
|
@apply opacity-40;
|
||||||
|
}
|
||||||
@@ -72,6 +72,7 @@
|
|||||||
--blue-11: #3ea5ff;
|
--blue-11: #3ea5ff;
|
||||||
--blue-12: #41a6ff;
|
--blue-12: #41a6ff;
|
||||||
--blue-14: #357ebd;
|
--blue-14: #357ebd;
|
||||||
|
--blue-15: #36bffa;
|
||||||
|
|
||||||
--red-1: #a94442;
|
--red-1: #a94442;
|
||||||
--red-2: #c7254e;
|
--red-2: #c7254e;
|
||||||
@@ -222,7 +223,6 @@
|
|||||||
--border-table-color: var(--grey-19);
|
--border-table-color: var(--grey-19);
|
||||||
--border-table-top-color: var(--grey-19);
|
--border-table-top-color: var(--grey-19);
|
||||||
--border-datatable-top-color: var(--grey-10);
|
--border-datatable-top-color: var(--grey-10);
|
||||||
--border-blocklist-color: var(--grey-44);
|
|
||||||
--border-input-group-addon-color: var(--grey-44);
|
--border-input-group-addon-color: var(--grey-44);
|
||||||
--border-btn-default-color: var(--grey-44);
|
--border-btn-default-color: var(--grey-44);
|
||||||
--border-boxselector-color: var(--grey-6);
|
--border-boxselector-color: var(--grey-6);
|
||||||
@@ -231,7 +231,6 @@
|
|||||||
--border-navtabs-color: var(--ui-white);
|
--border-navtabs-color: var(--ui-white);
|
||||||
--border-codemirror-cursor-color: var(--black-color);
|
--border-codemirror-cursor-color: var(--black-color);
|
||||||
--border-pre-color: var(--grey-43);
|
--border-pre-color: var(--grey-43);
|
||||||
--border-blocklist-item-selected-color: var(--grey-46);
|
|
||||||
--border-pagination-span-color: var(--ui-white);
|
--border-pagination-span-color: var(--ui-white);
|
||||||
--border-pagination-hover-color: var(--ui-white);
|
--border-pagination-hover-color: var(--ui-white);
|
||||||
--border-panel-color: var(--white-color);
|
--border-panel-color: var(--white-color);
|
||||||
@@ -245,6 +244,7 @@
|
|||||||
--border-sortbutton: var(--grey-8);
|
--border-sortbutton: var(--grey-8);
|
||||||
--border-bootbox: var(--ui-gray-5);
|
--border-bootbox: var(--ui-gray-5);
|
||||||
--border-blocklist: var(--ui-gray-5);
|
--border-blocklist: var(--ui-gray-5);
|
||||||
|
--border-blocklist-item-selected-color: var(--grey-46);
|
||||||
--border-widget: var(--ui-gray-5);
|
--border-widget: var(--ui-gray-5);
|
||||||
--border-nav-container-color: var(--ui-gray-5);
|
--border-nav-container-color: var(--ui-gray-5);
|
||||||
--border-stepper-color: var(--ui-gray-4);
|
--border-stepper-color: var(--ui-gray-4);
|
||||||
@@ -408,7 +408,6 @@
|
|||||||
--border-table-color: var(--grey-3);
|
--border-table-color: var(--grey-3);
|
||||||
--border-table-top-color: var(--grey-3);
|
--border-table-top-color: var(--grey-3);
|
||||||
--border-datatable-top-color: var(--grey-3);
|
--border-datatable-top-color: var(--grey-3);
|
||||||
--border-blocklist-color: var(--grey-3);
|
|
||||||
--border-input-group-addon-color: var(--grey-38);
|
--border-input-group-addon-color: var(--grey-38);
|
||||||
--border-btn-default-color: var(--grey-38);
|
--border-btn-default-color: var(--grey-38);
|
||||||
--border-boxselector-color: var(--grey-1);
|
--border-boxselector-color: var(--grey-1);
|
||||||
@@ -417,6 +416,7 @@
|
|||||||
--border-navtabs-color: var(--grey-38);
|
--border-navtabs-color: var(--grey-38);
|
||||||
--border-codemirror-cursor-color: var(--white-color);
|
--border-codemirror-cursor-color: var(--white-color);
|
||||||
--border-pre-color: var(--grey-3);
|
--border-pre-color: var(--grey-3);
|
||||||
|
--border-blocklist: var(--ui-gray-9);
|
||||||
--border-blocklist-item-selected-color: var(--grey-38);
|
--border-blocklist-item-selected-color: var(--grey-38);
|
||||||
--border-pagination-span-color: var(--grey-1);
|
--border-pagination-span-color: var(--grey-1);
|
||||||
--border-pagination-hover-color: var(--grey-3);
|
--border-pagination-hover-color: var(--grey-3);
|
||||||
@@ -430,7 +430,6 @@
|
|||||||
--border-modal: 0px;
|
--border-modal: 0px;
|
||||||
--border-sortbutton: var(--grey-3);
|
--border-sortbutton: var(--grey-3);
|
||||||
--border-bootbox: var(--ui-gray-9);
|
--border-bootbox: var(--ui-gray-9);
|
||||||
--border-blocklist: var(--ui-gray-9);
|
|
||||||
--border-widget: var(--grey-3);
|
--border-widget: var(--grey-3);
|
||||||
--border-pagination-color: var(--grey-1);
|
--border-pagination-color: var(--grey-1);
|
||||||
--border-nav-container-color: var(--ui-gray-neutral-8);
|
--border-nav-container-color: var(--ui-gray-neutral-8);
|
||||||
@@ -600,7 +599,6 @@
|
|||||||
--border-pre-color: var(--grey-3);
|
--border-pre-color: var(--grey-3);
|
||||||
--border-codemirror-cursor-color: var(--white-color);
|
--border-codemirror-cursor-color: var(--white-color);
|
||||||
--border-modal: 1px solid var(--white-color);
|
--border-modal: 1px solid var(--white-color);
|
||||||
--border-blocklist-color: var(--white-color);
|
|
||||||
--border-sortbutton: var(--black-color);
|
--border-sortbutton: var(--black-color);
|
||||||
--border-bootbox: var(--black-color);
|
--border-bootbox: var(--black-color);
|
||||||
--border-blocklist: var(--white-color);
|
--border-blocklist: var(--white-color);
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<svg width="16" height="15" viewBox="0 0 16 15" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M7.91895 2.19267C6.33456 0.34043 3.69255 -0.157821 1.70745 1.53828C-0.277637 3.23438 -0.557105 6.0702 1.0018 8.07617C2.17927 9.59131 5.52429 12.6331 7.0907 14.0309C7.37867 14.2878 7.52266 14.4164 7.69121 14.4669C7.83757 14.5108 8.00026 14.5108 8.14662 14.4669C8.31517 14.4164 8.45916 14.2878 8.74713 14.0309C10.3135 12.6331 13.6586 9.59131 14.836 8.07617C16.3949 6.0702 16.1496 3.21655 14.1304 1.53828C12.1112 -0.139975 9.50327 0.34043 7.91895 2.19267Z" fill="#D92D20"/>
|
||||||
|
<path d="M8.03754 9.71338L8.03754 4.94044M8.03754 9.71338L5.90125 7.57709M8.03754 9.71338L10.1738 7.57709" stroke="white" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 754 B |
@@ -0,0 +1,4 @@
|
|||||||
|
<svg width="16" height="15" viewBox="0 0 16 15" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M7.91895 2.19267C6.33456 0.34043 3.69255 -0.157821 1.70745 1.53828C-0.277637 3.23438 -0.557105 6.0702 1.0018 8.07617C2.17927 9.59131 5.52429 12.6331 7.0907 14.0309C7.37867 14.2878 7.52266 14.4164 7.69121 14.4669C7.83757 14.5108 8.00026 14.5108 8.14662 14.4669C8.31517 14.4164 8.45916 14.2878 8.74713 14.0309C10.3135 12.6331 13.6586 9.59131 14.836 8.07617C16.3949 6.0702 16.1496 3.21655 14.1304 1.53828C12.1112 -0.139975 9.50327 0.34043 7.91895 2.19267Z" fill="#039855"/>
|
||||||
|
<path d="M8.03754 4.94043L8.03754 9.71337M8.03754 4.94043L10.1738 7.07672M8.03754 4.94043L5.90125 7.07672" stroke="white" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 754 B |
-23
@@ -1,23 +0,0 @@
|
|||||||
import { compose, kubernetes } from '@@/BoxSelector/common-options/deployment-methods';
|
|
||||||
|
|
||||||
export default class EdgeStackDeploymentTypeSelectorController {
|
|
||||||
/* @ngInject */
|
|
||||||
constructor() {
|
|
||||||
this.deploymentOptions = [
|
|
||||||
{
|
|
||||||
...compose,
|
|
||||||
value: 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
...kubernetes,
|
|
||||||
value: 1,
|
|
||||||
disabled: () => {
|
|
||||||
return this.hasDockerEndpoint();
|
|
||||||
},
|
|
||||||
tooltip: () => {
|
|
||||||
return this.hasDockerEndpoint() ? 'Cannot use this option with Edge Docker endpoints' : '';
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-2
@@ -1,2 +0,0 @@
|
|||||||
<div class="col-sm-12 form-section-title"> Deployment type </div>
|
|
||||||
<box-selector radio-name="'deploymentType'" value="$ctrl.value" options="$ctrl.deploymentOptions" on-change="($ctrl.onChange)"></box-selector>
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import angular from 'angular';
|
|
||||||
import controller from './edge-stack-deployment-type-selector.controller.js';
|
|
||||||
|
|
||||||
export const edgeStackDeploymentTypeSelector = {
|
|
||||||
templateUrl: './edge-stack-deployment-type-selector.html',
|
|
||||||
controller,
|
|
||||||
|
|
||||||
bindings: {
|
|
||||||
value: '<',
|
|
||||||
onChange: '<',
|
|
||||||
hasDockerEndpoint: '<',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
angular.module('portainer.edge').component('edgeStackDeploymentTypeSelector', edgeStackDeploymentTypeSelector);
|
|
||||||
@@ -4,30 +4,42 @@
|
|||||||
<div class="col-sm-12">
|
<div class="col-sm-12">
|
||||||
<edge-groups-selector value="$ctrl.model.EdgeGroups" items="$ctrl.edgeGroups" on-change="($ctrl.onChangeGroups)"></edge-groups-selector>
|
<edge-groups-selector value="$ctrl.model.EdgeGroups" items="$ctrl.edgeGroups" on-change="($ctrl.onChangeGroups)"></edge-groups-selector>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group" ng-if="!$ctrl.validateEndpointsForDeployment()">
|
<p class="col-sm-12 vertical-center help-block small text-warning" ng-if="$ctrl.model.DeploymentType === undefined">
|
||||||
<div class="col-sm-12">
|
<pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon> There are no available deployment types when there is more than one type of environment in your edge group
|
||||||
<div class="small text-muted space-right text-warning">
|
selection (e.g. Kubernetes and Docker environments). Please select edge groups that have environments of the same type.
|
||||||
<pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon>
|
</p>
|
||||||
One or more of the selected Edge group contains Edge Docker endpoints that cannot be used with a Kubernetes Edge stack.
|
<p class="col-sm-12 vertical-center help-block small text-warning" ng-if="$ctrl.model.DeploymentType === $ctrl.EditorType.Compose && $ctrl.hasKubeEndpoint()">
|
||||||
</div>
|
<pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon> Edge groups with kubernetes environments no longer support compose deployment types in Portainer. Please select
|
||||||
</div>
|
edge groups that only have docker environments when using compose deployment types.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<edge-stack-deployment-type-selector
|
<edge-stack-deployment-type-selector
|
||||||
|
allow-kube-to-select-compose="$ctrl.allowKubeToSelectCompose"
|
||||||
value="$ctrl.model.DeploymentType"
|
value="$ctrl.model.DeploymentType"
|
||||||
has-docker-endpoint="$ctrl.hasDockerEndpoint"
|
has-docker-endpoint="$ctrl.hasDockerEndpoint()"
|
||||||
|
has-kube-endpoint="$ctrl.hasKubeEndpoint()"
|
||||||
on-change="($ctrl.onChangeDeploymentType)"
|
on-change="($ctrl.onChangeDeploymentType)"
|
||||||
|
read-only="$ctrl.state.readOnlyCompose"
|
||||||
></edge-stack-deployment-type-selector>
|
></edge-stack-deployment-type-selector>
|
||||||
|
|
||||||
<div class="form-group" ng-if="$ctrl.model.DeploymentType === 0 && $ctrl.hasKubeEndpoint()">
|
<div class="flex gap-1 text-muted small" ng-show="!$ctrl.model.DeploymentType && $ctrl.hasKubeEndpoint()">
|
||||||
<div class="col-sm-12">
|
<pr-icon icon="'alert-circle'" mode="'warning'" class-name="'!mt-1'"></pr-icon>
|
||||||
<div class="small text-muted space-right">
|
<div>
|
||||||
<pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon>
|
<p>
|
||||||
Portainer uses <a href="https://kompose.io/" target="_blank">Kompose</a> to convert your Compose manifest to a Kubernetes compliant manifest. Be wary that not all the
|
Portainer no longer supports <a href="https://docs.docker.com/compose/compose-file/" target="_blank">docker-compose</a> format manifests for Kubernetes deployments, and we
|
||||||
Compose format options are supported by Kompose at the moment.
|
have removed the <a href="https://kompose.io/" target="_blank">Kompose</a> conversion tool which enables this. The reason for this is because Kompose now poses a security
|
||||||
</div>
|
risk, since it has a number of Common Vulnerabilities and Exposures (CVEs).
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
>Unfortunately, while the Kompose project has a maintainer and is part of the CNCF, it is not being actively maintained. Releases are very infrequent and new pull requests
|
||||||
|
to the project (including ones we've submitted) are taking months to be merged, with new CVEs arising in the meantime.</p
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
We advise installing your own instance of Kompose in a sandbox environment, performing conversions of your Docker Compose files to Kubernetes manifests and using those
|
||||||
|
manifests to set up applications.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -38,6 +50,7 @@
|
|||||||
identifier="compose-editor"
|
identifier="compose-editor"
|
||||||
placeholder="# Define or paste the content of your docker compose file here"
|
placeholder="# Define or paste the content of your docker compose file here"
|
||||||
on-change="($ctrl.onChangeComposeConfig)"
|
on-change="($ctrl.onChangeComposeConfig)"
|
||||||
|
read-only="$ctrl.hasKubeEndpoint()"
|
||||||
>
|
>
|
||||||
<editor-description>
|
<editor-description>
|
||||||
<div>
|
<div>
|
||||||
@@ -82,8 +95,8 @@
|
|||||||
<div class="col-sm-12">
|
<div class="col-sm-12">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-primary btn-sm"
|
class="btn btn-primary btn-sm !ml-0"
|
||||||
ng-disabled="$ctrl.actionInProgress || !$ctrl.isFormValid()"
|
ng-disabled="$ctrl.actionInProgress || !$ctrl.isFormValid() || (!$ctrl.model.DeploymentType && $ctrl.hasKubeEndpoint())"
|
||||||
ng-click="$ctrl.submitAction()"
|
ng-click="$ctrl.submitAction()"
|
||||||
button-spinner="$ctrl.actionInProgress"
|
button-spinner="$ctrl.actionInProgress"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { PortainerEndpointTypes } from '@/portainer/models/endpoint/models';
|
import { PortainerEndpointTypes } from '@/portainer/models/endpoint/models';
|
||||||
import { EditorType } from '@/react/edge/edge-stacks/types';
|
import { EditorType } from '@/react/edge/edge-stacks/types';
|
||||||
|
import { getValidEditorTypes } from '@/react/edge/edge-stacks/utils';
|
||||||
export class EditEdgeStackFormController {
|
export class EditEdgeStackFormController {
|
||||||
/* @ngInject */
|
/* @ngInject */
|
||||||
constructor($scope) {
|
constructor($scope) {
|
||||||
this.$scope = $scope;
|
this.$scope = $scope;
|
||||||
this.state = {
|
this.state = {
|
||||||
endpointTypes: [],
|
endpointTypes: [],
|
||||||
|
readOnlyCompose: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
this.fileContents = {
|
this.fileContents = {
|
||||||
@@ -26,6 +27,7 @@ export class EditEdgeStackFormController {
|
|||||||
this.removeLineBreaks = this.removeLineBreaks.bind(this);
|
this.removeLineBreaks = this.removeLineBreaks.bind(this);
|
||||||
this.onChangeFileContent = this.onChangeFileContent.bind(this);
|
this.onChangeFileContent = this.onChangeFileContent.bind(this);
|
||||||
this.onChangeUseManifestNamespaces = this.onChangeUseManifestNamespaces.bind(this);
|
this.onChangeUseManifestNamespaces = this.onChangeUseManifestNamespaces.bind(this);
|
||||||
|
this.selectValidDeploymentType = this.selectValidDeploymentType.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
onChangeUseManifestNamespaces(value) {
|
onChangeUseManifestNamespaces(value) {
|
||||||
@@ -45,8 +47,9 @@ export class EditEdgeStackFormController {
|
|||||||
onChangeGroups(groups) {
|
onChangeGroups(groups) {
|
||||||
return this.$scope.$evalAsync(() => {
|
return this.$scope.$evalAsync(() => {
|
||||||
this.model.EdgeGroups = groups;
|
this.model.EdgeGroups = groups;
|
||||||
|
this.setEnvironmentTypesInSelection(groups);
|
||||||
this.checkEndpointTypes(groups);
|
this.selectValidDeploymentType();
|
||||||
|
this.state.readOnlyCompose = this.hasKubeEndpoint();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,11 +57,19 @@ export class EditEdgeStackFormController {
|
|||||||
return this.model.EdgeGroups.length && this.model.StackFileContent && this.validateEndpointsForDeployment();
|
return this.model.EdgeGroups.length && this.model.StackFileContent && this.validateEndpointsForDeployment();
|
||||||
}
|
}
|
||||||
|
|
||||||
checkEndpointTypes(groups) {
|
setEnvironmentTypesInSelection(groups) {
|
||||||
const edgeGroups = groups.map((id) => this.edgeGroups.find((e) => e.Id === id));
|
const edgeGroups = groups.map((id) => this.edgeGroups.find((e) => e.Id === id));
|
||||||
this.state.endpointTypes = edgeGroups.flatMap((group) => group.EndpointTypes);
|
this.state.endpointTypes = edgeGroups.flatMap((group) => group.EndpointTypes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
selectValidDeploymentType() {
|
||||||
|
const validTypes = getValidEditorTypes(this.state.endpointTypes, this.allowKubeToSelectCompose);
|
||||||
|
|
||||||
|
if (!validTypes.includes(this.model.DeploymentType)) {
|
||||||
|
this.onChangeDeploymentType(validTypes[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
removeLineBreaks(value) {
|
removeLineBreaks(value) {
|
||||||
return value.replace(/(\r\n|\n|\r)/gm, '');
|
return value.replace(/(\r\n|\n|\r)/gm, '');
|
||||||
}
|
}
|
||||||
@@ -81,9 +92,10 @@ export class EditEdgeStackFormController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onChangeDeploymentType(deploymentType) {
|
onChangeDeploymentType(deploymentType) {
|
||||||
this.model.DeploymentType = deploymentType;
|
return this.$scope.$evalAsync(() => {
|
||||||
|
this.model.DeploymentType = deploymentType;
|
||||||
this.model.StackFileContent = this.fileContents[deploymentType];
|
this.model.StackFileContent = this.fileContents[deploymentType];
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
validateEndpointsForDeployment() {
|
validateEndpointsForDeployment() {
|
||||||
@@ -91,6 +103,14 @@ export class EditEdgeStackFormController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$onInit() {
|
$onInit() {
|
||||||
this.checkEndpointTypes(this.model.EdgeGroups);
|
this.setEnvironmentTypesInSelection(this.model.EdgeGroups);
|
||||||
|
this.fileContents[this.model.DeploymentType] = this.model.StackFileContent;
|
||||||
|
|
||||||
|
// allow kube to view compose if it's an existing kube compose stack
|
||||||
|
const initiallyContainsKubeEnv = this.hasKubeEndpoint();
|
||||||
|
const isComposeStack = this.model.DeploymentType === 0;
|
||||||
|
this.allowKubeToSelectCompose = initiallyContainsKubeEnv && isComposeStack;
|
||||||
|
this.state.readOnlyCompose = this.allowKubeToSelectCompose;
|
||||||
|
this.selectValidDeploymentType();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { withReactQuery } from '@/react-tools/withReactQuery';
|
|||||||
import { EdgeCheckinIntervalField } from '@/react/edge/components/EdgeCheckInIntervalField';
|
import { EdgeCheckinIntervalField } from '@/react/edge/components/EdgeCheckInIntervalField';
|
||||||
import { EdgeScriptForm } from '@/react/edge/components/EdgeScriptForm';
|
import { EdgeScriptForm } from '@/react/edge/components/EdgeScriptForm';
|
||||||
import { EdgeAsyncIntervalsForm } from '@/react/edge/components/EdgeAsyncIntervalsForm';
|
import { EdgeAsyncIntervalsForm } from '@/react/edge/components/EdgeAsyncIntervalsForm';
|
||||||
|
import { EdgeStackDeploymentTypeSelector } from '@/react/edge/edge-stacks/components/EdgeStackDeploymentTypeSelector';
|
||||||
|
|
||||||
export const componentsModule = angular
|
export const componentsModule = angular
|
||||||
.module('portainer.edge.react.components', [])
|
.module('portainer.edge.react.components', [])
|
||||||
@@ -43,4 +44,14 @@ export const componentsModule = angular
|
|||||||
'readonly',
|
'readonly',
|
||||||
'fieldSettings',
|
'fieldSettings',
|
||||||
])
|
])
|
||||||
|
)
|
||||||
|
.component(
|
||||||
|
'edgeStackDeploymentTypeSelector',
|
||||||
|
r2a(withReactQuery(EdgeStackDeploymentTypeSelector), [
|
||||||
|
'value',
|
||||||
|
'onChange',
|
||||||
|
'hasDockerEndpoint',
|
||||||
|
'hasKubeEndpoint',
|
||||||
|
'allowKubeToSelectCompose',
|
||||||
|
])
|
||||||
).name;
|
).name;
|
||||||
|
|||||||
@@ -154,6 +154,7 @@ export class EdgeJobController {
|
|||||||
this.tags = tags;
|
this.tags = tags;
|
||||||
|
|
||||||
this.edgeJob.EdgeGroups = this.edgeJob.EdgeGroups ? this.edgeJob.EdgeGroups : [];
|
this.edgeJob.EdgeGroups = this.edgeJob.EdgeGroups ? this.edgeJob.EdgeGroups : [];
|
||||||
|
this.edgeJob.Endpoints = this.edgeJob.Endpoints ? this.edgeJob.Endpoints : [];
|
||||||
|
|
||||||
if (results.length > 0) {
|
if (results.length > 0) {
|
||||||
const endpointIds = _.map(results, (result) => result.EndpointId);
|
const endpointIds = _.map(results, (result) => result.EndpointId);
|
||||||
|
|||||||
+17
-7
@@ -1,4 +1,6 @@
|
|||||||
import { EditorType } from '@/react/edge/edge-stacks/types';
|
import { EditorType } from '@/react/edge/edge-stacks/types';
|
||||||
|
import { PortainerEndpointTypes } from '@/portainer/models/endpoint/models';
|
||||||
|
import { getValidEditorTypes } from '@/react/edge/edge-stacks/utils';
|
||||||
|
|
||||||
export default class CreateEdgeStackViewController {
|
export default class CreateEdgeStackViewController {
|
||||||
/* @ngInject */
|
/* @ngInject */
|
||||||
@@ -43,6 +45,7 @@ export default class CreateEdgeStackViewController {
|
|||||||
this.createStackFromGitRepository = this.createStackFromGitRepository.bind(this);
|
this.createStackFromGitRepository = this.createStackFromGitRepository.bind(this);
|
||||||
this.onChangeGroups = this.onChangeGroups.bind(this);
|
this.onChangeGroups = this.onChangeGroups.bind(this);
|
||||||
this.hasDockerEndpoint = this.hasDockerEndpoint.bind(this);
|
this.hasDockerEndpoint = this.hasDockerEndpoint.bind(this);
|
||||||
|
this.hasKubeEndpoint = this.hasKubeEndpoint.bind(this);
|
||||||
this.onChangeDeploymentType = this.onChangeDeploymentType.bind(this);
|
this.onChangeDeploymentType = this.onChangeDeploymentType.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,18 +137,23 @@ export default class CreateEdgeStackViewController {
|
|||||||
checkIfEndpointTypes(groups) {
|
checkIfEndpointTypes(groups) {
|
||||||
const edgeGroups = groups.map((id) => this.edgeGroups.find((e) => e.Id === id));
|
const edgeGroups = groups.map((id) => this.edgeGroups.find((e) => e.Id === id));
|
||||||
this.state.endpointTypes = edgeGroups.flatMap((group) => group.EndpointTypes);
|
this.state.endpointTypes = edgeGroups.flatMap((group) => group.EndpointTypes);
|
||||||
|
this.selectValidDeploymentType();
|
||||||
|
}
|
||||||
|
|
||||||
if (this.hasDockerEndpoint() && this.formValues.DeploymentType == 1) {
|
selectValidDeploymentType() {
|
||||||
this.onChangeDeploymentType(0);
|
const validTypes = getValidEditorTypes(this.state.endpointTypes);
|
||||||
|
|
||||||
|
if (!validTypes.includes(this.formValues.DeploymentType)) {
|
||||||
|
this.onChangeDeploymentType(validTypes[0]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
hasKubeEndpoint() {
|
hasKubeEndpoint() {
|
||||||
return this.state.endpointTypes.includes(7);
|
return this.state.endpointTypes.includes(PortainerEndpointTypes.EdgeAgentOnKubernetesEnvironment);
|
||||||
}
|
}
|
||||||
|
|
||||||
hasDockerEndpoint() {
|
hasDockerEndpoint() {
|
||||||
return this.state.endpointTypes.includes(4);
|
return this.state.endpointTypes.includes(PortainerEndpointTypes.EdgeAgentOnDockerEnvironment);
|
||||||
}
|
}
|
||||||
|
|
||||||
validateForm(method) {
|
validateForm(method) {
|
||||||
@@ -217,9 +225,11 @@ export default class CreateEdgeStackViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onChangeDeploymentType(deploymentType) {
|
onChangeDeploymentType(deploymentType) {
|
||||||
this.formValues.DeploymentType = deploymentType;
|
return this.$scope.$evalAsync(() => {
|
||||||
this.state.Method = 'editor';
|
this.formValues.DeploymentType = deploymentType;
|
||||||
this.formValues.StackFileContent = '';
|
this.state.Method = 'editor';
|
||||||
|
this.formValues.StackFileContent = '';
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
formIsInvalid() {
|
formIsInvalid() {
|
||||||
|
|||||||
@@ -39,24 +39,19 @@
|
|||||||
<div ng-if="$ctrl.noGroups" class="col-sm-12 small text-muted">
|
<div ng-if="$ctrl.noGroups" class="col-sm-12 small text-muted">
|
||||||
No Edge groups are available. Head over to the <a ui-sref="edge.groups">Edge groups view</a> to create one.
|
No Edge groups are available. Head over to the <a ui-sref="edge.groups">Edge groups view</a> to create one.
|
||||||
</div>
|
</div>
|
||||||
|
<p class="col-sm-12 vertical-center help-block small text-warning" ng-if="$ctrl.formValues.DeploymentType === undefined">
|
||||||
|
<pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon> There are no available deployment types when there is more than one type of environment in your edge
|
||||||
|
group selection (e.g. Kubernetes and Docker environments). Please select edge groups that have environments of the same type.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<edge-stack-deployment-type-selector
|
<edge-stack-deployment-type-selector
|
||||||
value="$ctrl.formValues.DeploymentType"
|
value="$ctrl.formValues.DeploymentType"
|
||||||
has-docker-endpoint="$ctrl.hasDockerEndpoint"
|
has-docker-endpoint="$ctrl.hasDockerEndpoint()"
|
||||||
|
has-kube-endpoint="$ctrl.hasKubeEndpoint()"
|
||||||
on-change="($ctrl.onChangeDeploymentType)"
|
on-change="($ctrl.onChangeDeploymentType)"
|
||||||
></edge-stack-deployment-type-selector>
|
></edge-stack-deployment-type-selector>
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<div class="col-sm-12">
|
|
||||||
<div class="small text-muted space-right" ng-if="$ctrl.formValues.DeploymentType === 0 && $ctrl.hasKubeEndpoint()">
|
|
||||||
<pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon>
|
|
||||||
Portainer uses <a href="https://kompose.io/" target="_blank">Kompose</a> to convert your Compose manifest to a Kubernetes compliant manifest. Be wary that not all
|
|
||||||
the Compose format options are supported by Kompose at the moment.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<edge-stacks-docker-compose-form
|
<edge-stacks-docker-compose-form
|
||||||
ng-if="$ctrl.formValues.DeploymentType == $ctrl.EditorType.Compose"
|
ng-if="$ctrl.formValues.DeploymentType == $ctrl.EditorType.Compose"
|
||||||
form-values="$ctrl.formValues"
|
form-values="$ctrl.formValues"
|
||||||
|
|||||||
@@ -59,7 +59,11 @@ export class EditEdgeStackViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async uiCanExit() {
|
async uiCanExit() {
|
||||||
if (this.formValues.StackFileContent.replace(/(\r\n|\n|\r)/gm, '') !== this.oldFileContent.replace(/(\r\n|\n|\r)/gm, '') && this.state.isEditorDirty) {
|
if (
|
||||||
|
this.formValues.StackFileContent &&
|
||||||
|
this.formValues.StackFileContent.replace(/(\r\n|\n|\r)/gm, '') !== this.oldFileContent.replace(/(\r\n|\n|\r)/gm, '') &&
|
||||||
|
this.state.isEditorDirty
|
||||||
|
) {
|
||||||
return this.ModalService.confirmWebEditorDiscard();
|
return this.ModalService.confirmWebEditorDiscard();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+42
@@ -30,11 +30,53 @@ declare module 'axios-progress-bar' {
|
|||||||
): void;
|
): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface HubSpotCreateFormOptions {
|
||||||
|
/** User's portal ID */
|
||||||
|
portalId: string;
|
||||||
|
/** Unique ID of the form you wish to build */
|
||||||
|
formId: string;
|
||||||
|
|
||||||
|
region: string;
|
||||||
|
/**
|
||||||
|
* jQuery style selector specifying an existing element on the page into which the form will be placed once built.
|
||||||
|
*
|
||||||
|
* NOTE: If you're including multiple forms on the page, it is strongly recommended that you include a separate, specific target for each form.
|
||||||
|
*/
|
||||||
|
target: string;
|
||||||
|
/**
|
||||||
|
* Callback that executes after form is validated, just before the data is actually sent.
|
||||||
|
* This is for any logic that needs to execute during the submit.
|
||||||
|
* Any changes will not be validated.
|
||||||
|
* Takes the jQuery form object as the argument: onFormSubmit($form).
|
||||||
|
*
|
||||||
|
* Note: Performing a browser redirect in this callback is not recommended and could prevent the form submission
|
||||||
|
*/
|
||||||
|
onFormSubmit?: (form: JQuery<HTMLFormElement>) => void;
|
||||||
|
/**
|
||||||
|
* Callback when the data is actually sent.
|
||||||
|
* This allows you to perform an action when the submission is fully complete,
|
||||||
|
* such as displaying a confirmation or thank you message.
|
||||||
|
*/
|
||||||
|
onFormSubmitted?: (form: JQuery<HTMLFormElement>) => void;
|
||||||
|
/**
|
||||||
|
* Callback that executes after form is built, placed in the DOM, and validation has been initialized.
|
||||||
|
* This is perfect for any logic that needs to execute when the form is on the page.
|
||||||
|
*
|
||||||
|
* Takes the jQuery form object as the argument: onFormReady($form)
|
||||||
|
*/
|
||||||
|
onFormReady?: (form: JQuery<HTMLFormElement>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
interface Window {
|
interface Window {
|
||||||
/**
|
/**
|
||||||
* will be true if portainer is run as a Docker Desktop Extension
|
* will be true if portainer is run as a Docker Desktop Extension
|
||||||
*/
|
*/
|
||||||
ddExtension?: boolean;
|
ddExtension?: boolean;
|
||||||
|
hbspt?: {
|
||||||
|
forms: {
|
||||||
|
create: (options: HubSpotCreateFormOptions) => void;
|
||||||
|
};
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module 'process' {
|
declare module 'process' {
|
||||||
|
|||||||
@@ -22,12 +22,12 @@
|
|||||||
<pr-icon class="vertical-center" icon="'check'" size="'md'" mode="'success'"></pr-icon> copied
|
<pr-icon class="vertical-center" icon="'check'" size="'md'" mode="'success'"></pr-icon> copied
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<be-only-button
|
<be-teaser-button
|
||||||
class="float-right"
|
class="float-right"
|
||||||
feature-id="$ctrl.limitedFeature"
|
feature-id="$ctrl.limitedFeature"
|
||||||
message="'Applies any changes that you make in the YAML editor by calling the Kubernetes API to patch the relevant resources. Any resource removals or unexpected resource additions that you make in the YAML will be ignored. Note that editing is disabled for resources in namespaces marked as system.'"
|
message="'Applies any changes that you make in the YAML editor by calling the Kubernetes API to patch the relevant resources. Any resource removals or unexpected resource additions that you make in the YAML will be ignored. Note that editing is disabled for resources in namespaces marked as system.'"
|
||||||
heading="'Apply YAML changes'"
|
heading="'Apply YAML changes'"
|
||||||
button-text="'Apply changes'"
|
button-text="'Apply changes'"
|
||||||
></be-only-button>
|
></be-teaser-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
export const KubernetesDeployManifestTypes = Object.freeze({
|
export const KubernetesDeployManifestTypes = Object.freeze({
|
||||||
KUBERNETES: 1,
|
KUBERNETES: 1,
|
||||||
COMPOSE: 2,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const KubernetesDeployBuildMethods = Object.freeze({
|
export const KubernetesDeployBuildMethods = Object.freeze({
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { r2a } from '@/react-tools/react2angular';
|
|||||||
import { withCurrentUser } from '@/react-tools/withCurrentUser';
|
import { withCurrentUser } from '@/react-tools/withCurrentUser';
|
||||||
import { withReactQuery } from '@/react-tools/withReactQuery';
|
import { withReactQuery } from '@/react-tools/withReactQuery';
|
||||||
import { withUIRouter } from '@/react-tools/withUIRouter';
|
import { withUIRouter } from '@/react-tools/withUIRouter';
|
||||||
import { BEOnlyButton } from '@/kubernetes/react/views/beOnlyButton';
|
|
||||||
import { IngressesDatatableView } from '@/react/kubernetes/ingresses/IngressDatatable';
|
import { IngressesDatatableView } from '@/react/kubernetes/ingresses/IngressDatatable';
|
||||||
import { CreateIngressView } from '@/react/kubernetes/ingresses/CreateIngressView';
|
import { CreateIngressView } from '@/react/kubernetes/ingresses/CreateIngressView';
|
||||||
|
|
||||||
@@ -20,15 +19,4 @@ export const viewsModule = angular
|
|||||||
.component(
|
.component(
|
||||||
'kubernetesIngressesCreateView',
|
'kubernetesIngressesCreateView',
|
||||||
r2a(withUIRouter(withReactQuery(withCurrentUser(CreateIngressView))), [])
|
r2a(withUIRouter(withReactQuery(withCurrentUser(CreateIngressView))), [])
|
||||||
)
|
|
||||||
.component(
|
|
||||||
'beOnlyButton',
|
|
||||||
r2a(withUIRouter(withReactQuery(withCurrentUser(BEOnlyButton))), [
|
|
||||||
'featureId',
|
|
||||||
'heading',
|
|
||||||
'message',
|
|
||||||
'buttonText',
|
|
||||||
'className',
|
|
||||||
'icon',
|
|
||||||
])
|
|
||||||
).name;
|
).name;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<page-header
|
<page-header
|
||||||
ng-if="!ctrl.state.isEdit"
|
ng-if="!ctrl.state.isEdit && !ctrl.stack.IsComposeFormat && ctrl.state.viewReady"
|
||||||
title="'Create application'"
|
title="'Create application'"
|
||||||
breadcrumbs="[
|
breadcrumbs="[
|
||||||
{ label:'Applications', link:'kubernetes.applications' },
|
{ label:'Applications', link:'kubernetes.applications' },
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
</page-header>
|
</page-header>
|
||||||
|
|
||||||
<page-header
|
<page-header
|
||||||
ng-if="ctrl.state.isEdit"
|
ng-if="ctrl.state.isEdit && !ctrl.stack.IsComposeFormat && ctrl.state.viewReady"
|
||||||
title="'Edit application'"
|
title="'Edit application'"
|
||||||
breadcrumbs="[
|
breadcrumbs="[
|
||||||
{ label:'Namespaces', link:'kubernetes.resourcePools' },
|
{ label:'Namespaces', link:'kubernetes.resourcePools' },
|
||||||
@@ -31,6 +31,28 @@
|
|||||||
>
|
>
|
||||||
</page-header>
|
</page-header>
|
||||||
|
|
||||||
|
<page-header
|
||||||
|
ng-if="ctrl.stack.IsComposeFormat"
|
||||||
|
title="'View application'"
|
||||||
|
breadcrumbs="[
|
||||||
|
{ label:'Namespaces', link:'kubernetes.resourcePools' },
|
||||||
|
{
|
||||||
|
label:ctrl.application.ResourcePool,
|
||||||
|
link: 'kubernetes.resourcePools.resourcePool',
|
||||||
|
linkParams:{ id: ctrl.application.ResourcePool }
|
||||||
|
},
|
||||||
|
{ label:'Applications', link:'kubernetes.applications' },
|
||||||
|
{
|
||||||
|
label:ctrl.application.Name,
|
||||||
|
link: 'kubernetes.applications.application',
|
||||||
|
linkParams:{ name: ctrl.application.Name, namespace: ctrl.application.ResourcePool }
|
||||||
|
},
|
||||||
|
'View',
|
||||||
|
]"
|
||||||
|
reload="true"
|
||||||
|
>
|
||||||
|
</page-header>
|
||||||
|
|
||||||
<kubernetes-view-loading view-ready="ctrl.state.viewReady"></kubernetes-view-loading>
|
<kubernetes-view-loading view-ready="ctrl.state.viewReady"></kubernetes-view-loading>
|
||||||
<div ng-if="ctrl.state.viewReady">
|
<div ng-if="ctrl.state.viewReady">
|
||||||
<div class="row kubernetes-create">
|
<div class="row kubernetes-create">
|
||||||
@@ -88,6 +110,7 @@
|
|||||||
|
|
||||||
<!-- #region web editor -->
|
<!-- #region web editor -->
|
||||||
<web-editor-form
|
<web-editor-form
|
||||||
|
read-only="ctrl.stack.IsComposeFormat"
|
||||||
ng-if="ctrl.state.appType === ctrl.KubernetesDeploymentTypes.CONTENT"
|
ng-if="ctrl.state.appType === ctrl.KubernetesDeploymentTypes.CONTENT"
|
||||||
value="ctrl.stackFileContent"
|
value="ctrl.stackFileContent"
|
||||||
yml="true"
|
yml="true"
|
||||||
@@ -96,27 +119,24 @@
|
|||||||
on-change="(ctrl.onChangeFileContent)"
|
on-change="(ctrl.onChangeFileContent)"
|
||||||
>
|
>
|
||||||
<editor-description>
|
<editor-description>
|
||||||
<span class="text-muted small" ng-show="ctrl.stack.IsComposeFormat">
|
<div class="flex gap-1 text-muted small" ng-show="ctrl.stack.IsComposeFormat">
|
||||||
<p class="vertical-center">
|
<pr-icon icon="'alert-circle'" mode="'warning'" class-name="'!mt-1'"></pr-icon>
|
||||||
<pr-icon icon="'alert-circle'" mode="'warning'"></pr-icon>
|
<div>
|
||||||
<span>
|
<p>
|
||||||
Portainer uses <a href="https://kompose.io/" target="_blank">Kompose</a> to convert your Compose manifest to a Kubernetes compliant manifest. Be wary that
|
Portainer no longer supports <a href="https://docs.docker.com/compose/compose-file/" target="_blank">docker-compose</a> format manifests for Kubernetes
|
||||||
not all the Compose format options are supported by Kompose at the moment.
|
deployments, and we have removed the <a href="https://kompose.io/" target="_blank">Kompose</a> conversion tool which enables this. The reason for this is
|
||||||
</span>
|
because Kompose now poses a security risk, since it has a number of Common Vulnerabilities and Exposures (CVEs).
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p
|
||||||
You can get more information about Compose file format in the
|
>Unfortunately, while the Kompose project has a maintainer and is part of the CNCF, it is not being actively maintained. Releases are very infrequent and
|
||||||
<a href="https://docs.docker.com/compose/compose-file/" target="_blank">official documentation</a>.
|
new pull requests to the project (including ones we've submitted) are taking months to be merged, with new CVEs arising in the meantime.</p
|
||||||
</p>
|
>
|
||||||
<p
|
<p>
|
||||||
>In a forthcoming Portainer release, we plan to remove support for docker-compose format manifests for Kubernetes deployments, and the Kompose conversion tool
|
We advise installing your own instance of Kompose in a sandbox environment, performing conversions of your Docker Compose files to Kubernetes manifests and
|
||||||
which enables this. The reason for this is because Kompose now poses a security risk, since it has a number of Common Vulnerabilities and Exposures (CVEs).</p
|
using those manifests to set up applications.
|
||||||
>
|
</p>
|
||||||
<p
|
</div>
|
||||||
>Unfortunately, while the Kompose project has a maintainer and is part of the CNCF, it is not being actively maintained. Releases are very infrequent and new
|
</div>
|
||||||
pull requests to the project (including ones we've submitted) are taking months to be merged, with new CVEs arising in the meantime.</p
|
|
||||||
>
|
|
||||||
</span>
|
|
||||||
<span class="text-muted small" ng-show="!ctrl.stack.IsComposeFormat">
|
<span class="text-muted small" ng-show="!ctrl.stack.IsComposeFormat">
|
||||||
<p class="vertical-center">
|
<p class="vertical-center">
|
||||||
<pr-icon icon="'info'" mode="'primary'"></pr-icon>
|
<pr-icon icon="'info'" mode="'primary'"></pr-icon>
|
||||||
@@ -1345,9 +1365,9 @@
|
|||||||
<!-- kubernetes summary for external application -->
|
<!-- kubernetes summary for external application -->
|
||||||
<kubernetes-summary-view ng-if="ctrl.isExternalApplication()" form-values="ctrl.formValues" old-form-values="ctrl.savedFormValues"></kubernetes-summary-view>
|
<kubernetes-summary-view ng-if="ctrl.isExternalApplication()" form-values="ctrl.formValues" old-form-values="ctrl.savedFormValues"></kubernetes-summary-view>
|
||||||
<!-- kubernetes summary for external application -->
|
<!-- kubernetes summary for external application -->
|
||||||
<div class="col-sm-12 form-section-title" ng-if="ctrl.state.appType !== ctrl.KubernetesDeploymentTypes.GIT"> Actions </div>
|
<div class="col-sm-12 form-section-title" ng-if="ctrl.state.appType !== ctrl.KubernetesDeploymentTypes.GIT" ng-hide="ctrl.stack.IsComposeFormat"> Actions </div>
|
||||||
<!-- #region ACTIONS -->
|
<!-- #region ACTIONS -->
|
||||||
<div class="form-group">
|
<div class="form-group" ng-hide="ctrl.stack.IsComposeFormat">
|
||||||
<div class="col-sm-12">
|
<div class="col-sm-12">
|
||||||
<button
|
<button
|
||||||
ng-if="ctrl.state.appType === ctrl.KubernetesDeploymentTypes.APPLICATION_FORM"
|
ng-if="ctrl.state.appType === ctrl.KubernetesDeploymentTypes.APPLICATION_FORM"
|
||||||
|
|||||||
@@ -223,7 +223,7 @@
|
|||||||
style="margin-left: 0"
|
style="margin-left: 0"
|
||||||
data-cy="k8sAppDetail-editAppButton"
|
data-cy="k8sAppDetail-editAppButton"
|
||||||
>
|
>
|
||||||
<pr-icon icon="'code'" class="mr-1"></pr-icon>Edit this application
|
<pr-icon icon="'pencil'" class="mr-1"></pr-icon>{{ ctrl.stack.IsComposeFormat ? 'View this application' : 'Edit this application' }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
authorization="K8sApplicationDetailsW"
|
authorization="K8sApplicationDetailsW"
|
||||||
@@ -233,15 +233,17 @@
|
|||||||
ui-sref="kubernetes.applications.application.edit"
|
ui-sref="kubernetes.applications.application.edit"
|
||||||
data-cy="k8sAppDetail-editAppButton"
|
data-cy="k8sAppDetail-editAppButton"
|
||||||
>
|
>
|
||||||
<pr-icon icon="'code'" class-name="'mr-1'"></pr-icon>Edit External application
|
<pr-icon icon="'pencil'" class-name="'mr-1'"></pr-icon>Edit external application
|
||||||
</button>
|
</button>
|
||||||
<be-only-button
|
<be-teaser-button
|
||||||
icon="'refresh-cw'"
|
icon="'refresh-cw'"
|
||||||
feature-id="ctrl.limitedFeature"
|
feature-id="ctrl.limitedFeature"
|
||||||
message="'A rolling restart of the application is performed.'"
|
message="'A rolling restart of the application is performed.'"
|
||||||
heading="'Rolling restart'"
|
heading="'Rolling restart'"
|
||||||
button-text="'Rolling restart'"
|
button-text="'Rolling restart'"
|
||||||
></be-only-button>
|
class-name="'be-tooltip-teaser'"
|
||||||
|
className="'be-tooltip-teaser'"
|
||||||
|
></be-teaser-button>
|
||||||
<button
|
<button
|
||||||
ng-if="ctrl.application.ApplicationType !== ctrl.KubernetesApplicationTypes.POD"
|
ng-if="ctrl.application.ApplicationType !== ctrl.KubernetesApplicationTypes.POD"
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -224,7 +224,7 @@ class KubernetesApplicationController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
rollbackApplication() {
|
rollbackApplication() {
|
||||||
this.ModalService.confirmUpdate('Rolling back the application to a previous configuration may cause a service interruption. Do you wish to continue?', (confirmed) => {
|
this.ModalService.confirmUpdate('Rolling back the application to a previous configuration may cause service interruption. Do you wish to continue?', (confirmed) => {
|
||||||
if (confirmed) {
|
if (confirmed) {
|
||||||
return this.$async(this.rollbackApplicationAsync);
|
return this.$async(this.rollbackApplicationAsync);
|
||||||
}
|
}
|
||||||
@@ -234,6 +234,15 @@ class KubernetesApplicationController {
|
|||||||
* REDEPLOY
|
* REDEPLOY
|
||||||
*/
|
*/
|
||||||
async redeployApplicationAsync() {
|
async redeployApplicationAsync() {
|
||||||
|
const confirmed = await this.ModalService.confirmAsync({
|
||||||
|
title: 'Are you sure?',
|
||||||
|
message: 'Redeploying the application may cause a service interruption. Do you wish to continue?',
|
||||||
|
buttons: { confirm: { label: 'Redeploy', className: 'btn-primary' } },
|
||||||
|
});
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const promises = _.map(this.application.Pods, (item) => this.KubernetesPodService.delete(item));
|
const promises = _.map(this.application.Pods, (item) => this.KubernetesPodService.delete(item));
|
||||||
await Promise.all(promises);
|
await Promise.all(promises);
|
||||||
@@ -245,11 +254,7 @@ class KubernetesApplicationController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
redeployApplication() {
|
redeployApplication() {
|
||||||
this.ModalService.confirmUpdate('Redeploying the application may cause a service interruption. Do you wish to continue?', (confirmed) => {
|
return this.$async(this.redeployApplicationAsync);
|
||||||
if (confirmed) {
|
|
||||||
return this.$async(this.redeployApplicationAsync);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -318,6 +323,9 @@ class KubernetesApplicationController {
|
|||||||
this.KubernetesNodeService.get(),
|
this.KubernetesNodeService.get(),
|
||||||
]);
|
]);
|
||||||
this.application = application;
|
this.application = application;
|
||||||
|
if (this.application.StackId) {
|
||||||
|
this.stack = await this.StackService.stack(application.StackId);
|
||||||
|
}
|
||||||
this.allContainers = KubernetesApplicationHelper.associateAllContainersAndApplication(application);
|
this.allContainers = KubernetesApplicationHelper.associateAllContainersAndApplication(application);
|
||||||
this.formValues.Note = this.application.Note;
|
this.formValues.Note = this.application.Note;
|
||||||
this.formValues.Services = this.application.Services;
|
this.formValues.Services = this.application.Services;
|
||||||
|
|||||||
@@ -116,20 +116,7 @@
|
|||||||
placeholder="# Define or paste the content of your manifest file here"
|
placeholder="# Define or paste the content of your manifest file here"
|
||||||
>
|
>
|
||||||
<editor-description>
|
<editor-description>
|
||||||
<span class="col-sm-12 text-muted small" ng-show="ctrl.state.DeployType === ctrl.ManifestDeployTypes.COMPOSE">
|
<span class="col-sm-12 text-muted small">
|
||||||
<p class="vertical-center">
|
|
||||||
<pr-icon icon="'alert-circle'" mode="'warning'"></pr-icon>
|
|
||||||
<span>
|
|
||||||
Portainer uses <a href="https://kompose.io/" target="_blank">Kompose</a> to convert your Compose manifest to a Kubernetes compliant manifest. Be wary
|
|
||||||
that not all the Compose format options are supported by Kompose at the moment.
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
You can get more information about Compose file format in the
|
|
||||||
<a href="https://docs.docker.com/compose/compose-file/" target="_blank">official documentation</a>.
|
|
||||||
</p>
|
|
||||||
</span>
|
|
||||||
<span class="col-sm-12 text-muted small" ng-show="ctrl.state.DeployType === ctrl.ManifestDeployTypes.KUBERNETES">
|
|
||||||
<p class="vertical-center">
|
<p class="vertical-center">
|
||||||
<pr-icon icon="'info'" mode="'primary'"></pr-icon>
|
<pr-icon icon="'info'" mode="'primary'"></pr-icon>
|
||||||
This feature allows you to deploy any kind of Kubernetes resource in this environment (Deployment, Secret, ConfigMap...).
|
This feature allows you to deploy any kind of Kubernetes resource in this environment (Deployment, Secret, ConfigMap...).
|
||||||
|
|||||||
@@ -7,9 +7,8 @@ import PortainerError from '@/portainer/error';
|
|||||||
import { KubernetesDeployManifestTypes, KubernetesDeployBuildMethods, KubernetesDeployRequestMethods, RepositoryMechanismTypes } from 'Kubernetes/models/deploy';
|
import { KubernetesDeployManifestTypes, KubernetesDeployBuildMethods, KubernetesDeployRequestMethods, RepositoryMechanismTypes } from 'Kubernetes/models/deploy';
|
||||||
import { renderTemplate } from '@/react/portainer/custom-templates/components/utils';
|
import { renderTemplate } from '@/react/portainer/custom-templates/components/utils';
|
||||||
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
|
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
|
||||||
import { compose, kubernetes } from '@@/BoxSelector/common-options/deployment-methods';
|
import { kubernetes } from '@@/BoxSelector/common-options/deployment-methods';
|
||||||
import { editor, git, template, url } from '@@/BoxSelector/common-options/build-methods';
|
import { editor, git, template, url } from '@@/BoxSelector/common-options/build-methods';
|
||||||
import { getPublicSettings } from '@/react/portainer/settings/settings.service';
|
|
||||||
|
|
||||||
class KubernetesDeployController {
|
class KubernetesDeployController {
|
||||||
/* @ngInject */
|
/* @ngInject */
|
||||||
@@ -339,16 +338,6 @@ class KubernetesDeployController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
|
||||||
const publicSettings = await getPublicSettings();
|
|
||||||
this.showKomposeBuildOption = publicSettings.ShowKomposeBuildOption;
|
|
||||||
} catch (err) {
|
|
||||||
this.Notifications.error('Failure', err, 'Unable to get public settings');
|
|
||||||
}
|
|
||||||
if (this.showKomposeBuildOption) {
|
|
||||||
this.deployOptions = [...this.deployOptions, { ...compose, value: KubernetesDeployManifestTypes.COMPOSE }];
|
|
||||||
}
|
|
||||||
|
|
||||||
this.state.viewReady = true;
|
this.state.viewReady = true;
|
||||||
|
|
||||||
this.$window.onbeforeunload = () => {
|
this.$window.onbeforeunload = () => {
|
||||||
|
|||||||
@@ -50,14 +50,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group" ng-if="ctrl.formValues.HasQuota && ctrl.isAdmin && ctrl.isEditable && !ctrl.isQuotaValid()">
|
|
||||||
<div class="col-sm-12 small text-warning">
|
|
||||||
<p class="vertical-center">
|
|
||||||
<pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon>
|
|
||||||
Not enough resources available in the cluster to apply a resource reservation.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div ng-if="ctrl.formValues.HasQuota">
|
<div ng-if="ctrl.formValues.HasQuota">
|
||||||
<kubernetes-resource-reservation
|
<kubernetes-resource-reservation
|
||||||
ng-if="ctrl.pool.Quota"
|
ng-if="ctrl.pool.Quota"
|
||||||
@@ -76,6 +68,14 @@
|
|||||||
<div ng-if="ctrl.formValues.HasQuota && ctrl.isAdmin && ctrl.isEditable">
|
<div ng-if="ctrl.formValues.HasQuota && ctrl.isAdmin && ctrl.isEditable">
|
||||||
<div class="col-sm-12 form-section-title"> Resource limits </div>
|
<div class="col-sm-12 form-section-title"> Resource limits </div>
|
||||||
<div>
|
<div>
|
||||||
|
<div class="form-group">
|
||||||
|
<span class="col-sm-12 small text-warning" ng-switch on="ctrl.formValues.HasQuota && ctrl.isAdmin && ctrl.isEditable && !ctrl.isQuotaValid()">
|
||||||
|
<p class="vertical-center mb-0" ng-switch-when="true"
|
||||||
|
><pr-icon class="vertical-center" icon="'alert-triangle'" mode="'warning'"></pr-icon> At least a single limit must be set for the quota to be valid.
|
||||||
|
</p>
|
||||||
|
<p class="vertical-center mb-0" ng-switch-default></p>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<!-- memory-limit-input -->
|
<!-- memory-limit-input -->
|
||||||
<div class="form-group flex">
|
<div class="form-group flex">
|
||||||
<label for="memory-limit" class="col-sm-3 col-lg-2 control-label text-left vertical-center"> Memory limit (MB) </label>
|
<label for="memory-limit" class="col-sm-3 col-lg-2 control-label text-left vertical-center"> Memory limit (MB) </label>
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ angular.module('portainer.app').controller('CodeEditorController', function Code
|
|||||||
if (value && value.currentValue && ctrl.editor && ctrl.editor.getValue() !== value.currentValue) {
|
if (value && value.currentValue && ctrl.editor && ctrl.editor.getValue() !== value.currentValue) {
|
||||||
ctrl.editor.setValue(value.currentValue);
|
ctrl.editor.setValue(value.currentValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (ctrl.editor) {
|
||||||
|
ctrl.editor.setOption('readOnly', ctrl.readOnly);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
this.$onInit = function () {
|
this.$onInit = function () {
|
||||||
|
|||||||
@@ -111,17 +111,13 @@
|
|||||||
|
|
||||||
.datatable .footer .paginationControls {
|
.datatable .footer .paginationControls {
|
||||||
float: right;
|
float: right;
|
||||||
margin: 10px 0 5px 0;
|
margin: 10px 10px 5px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.datatable .footer .paginationControls .limitSelector {
|
.datatable .footer .paginationControls .limitSelector {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.datatable .footer .paginationControls .limitSelector:not(:last-child) {
|
|
||||||
margin-right: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.datatable .footer .paginationControls .pagination {
|
.datatable .footer .paginationControls .pagination {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
<ng-form name="autoUpdateForm form-group">
|
<ng-form name="autoUpdateForm" class="form-group">
|
||||||
<div class="small vertical-center mb-2">
|
<div class="small vertical-center mb-2">
|
||||||
<pr-icon icon="'info'" mode="'primary'"></pr-icon>
|
<pr-icon icon="'info'" mode="'primary'"></pr-icon>
|
||||||
<span class="text-muted">
|
<span class="text-muted">
|
||||||
@@ -45,7 +45,7 @@
|
|||||||
Webhook
|
Webhook
|
||||||
<portainer-tooltip
|
<portainer-tooltip
|
||||||
message="$ctrl.environmentType === 'KUBERNETES' ?
|
message="$ctrl.environmentType === 'KUBERNETES' ?
|
||||||
'See <a href=\'https://docs.portainer.io/user/kubernetes/applications/manifest#automatic-updates\' target=\'_blank\' rel=\'noreferrer\'>Portainer documentation on webhook usage</a>.' :
|
'See <a href=\'https://docs.portainer.io/user/kubernetes/applications/webhooks\' target=\'_blank\' rel=\'noreferrer\'>Portainer documentation on webhook usage</a>.' :
|
||||||
'See <a href=\'https://docs.portainer.io/user/docker/stacks/webhooks\' target=\'_blank\' rel=\'noreferrer\'>Portainer documentation on webhook usage</a>.'"
|
'See <a href=\'https://docs.portainer.io/user/docker/stacks/webhooks\' target=\'_blank\' rel=\'noreferrer\'>Portainer documentation on webhook usage</a>.'"
|
||||||
set-html-message="true"
|
set-html-message="true"
|
||||||
></portainer-tooltip>
|
></portainer-tooltip>
|
||||||
|
|||||||
@@ -1,34 +1,51 @@
|
|||||||
|
import { notifyError, notifySuccess } from '@/portainer/services/notifications';
|
||||||
|
import { queryKeys } from '@/portainer/users/queries/queryKeys';
|
||||||
|
import { queryClient } from '@/react-tools/react-query';
|
||||||
import { options } from './options';
|
import { options } from './options';
|
||||||
|
|
||||||
export default class ThemeSettingsController {
|
export default class ThemeSettingsController {
|
||||||
/* @ngInject */
|
/* @ngInject */
|
||||||
constructor($async, Authentication, ThemeManager, StateManager, UserService, Notifications) {
|
constructor($async, Authentication, ThemeManager, StateManager, UserService) {
|
||||||
this.$async = $async;
|
this.$async = $async;
|
||||||
this.Authentication = Authentication;
|
this.Authentication = Authentication;
|
||||||
this.ThemeManager = ThemeManager;
|
this.ThemeManager = ThemeManager;
|
||||||
this.StateManager = StateManager;
|
this.StateManager = StateManager;
|
||||||
this.UserService = UserService;
|
this.UserService = UserService;
|
||||||
this.Notifications = Notifications;
|
|
||||||
|
|
||||||
this.setTheme = this.setTheme.bind(this);
|
this.setThemeColor = this.setThemeColor.bind(this);
|
||||||
|
this.setSubtleUpgradeButton = this.setSubtleUpgradeButton.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
async setTheme(theme) {
|
async setThemeColor(color) {
|
||||||
try {
|
return this.$async(async () => {
|
||||||
if (theme === 'auto' || !theme) {
|
if (color === 'auto' || !color) {
|
||||||
this.ThemeManager.autoTheme();
|
this.ThemeManager.autoTheme();
|
||||||
} else {
|
} else {
|
||||||
this.ThemeManager.setTheme(theme);
|
this.ThemeManager.setTheme(color);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.state.userTheme = theme;
|
this.state.themeColor = color;
|
||||||
|
this.updateThemeSettings({ color });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async setSubtleUpgradeButton(value) {
|
||||||
|
return this.$async(async () => {
|
||||||
|
this.state.subtleUpgradeButton = value;
|
||||||
|
this.updateThemeSettings({ subtleUpgradeButton: value });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateThemeSettings(theme) {
|
||||||
|
try {
|
||||||
if (!this.state.isDemo) {
|
if (!this.state.isDemo) {
|
||||||
await this.UserService.updateUserTheme(this.state.userId, this.state.userTheme);
|
await this.UserService.updateUserTheme(this.state.userId, theme);
|
||||||
|
await queryClient.invalidateQueries(queryKeys.user(this.state.userId));
|
||||||
}
|
}
|
||||||
|
|
||||||
this.Notifications.success('Success', 'User theme successfully updated');
|
notifySuccess('Success', 'User theme settings successfully updated');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.Notifications.error('Failure', err, 'Unable to update user theme');
|
notifyError('Failure', err, 'Unable to update user theme settings');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,19 +55,21 @@ export default class ThemeSettingsController {
|
|||||||
|
|
||||||
this.state = {
|
this.state = {
|
||||||
userId: null,
|
userId: null,
|
||||||
userTheme: '',
|
themeColor: 'auto',
|
||||||
defaultTheme: 'auto',
|
|
||||||
isDemo: state.application.demoEnvironment.enabled,
|
isDemo: state.application.demoEnvironment.enabled,
|
||||||
|
subtleUpgradeButton: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
this.state.availableThemes = options;
|
this.state.availableThemes = options;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.state.userId = await this.Authentication.getUserDetails().ID;
|
this.state.userId = await this.Authentication.getUserDetails().ID;
|
||||||
const data = await this.UserService.user(this.state.userId);
|
const user = await this.UserService.user(this.state.userId);
|
||||||
this.state.userTheme = data.UserTheme || this.state.defaultTheme;
|
|
||||||
|
this.state.themeColor = user.ThemeSettings.color || this.state.themeColor;
|
||||||
|
this.state.subtleUpgradeButton = !!user.ThemeSettings.subtleUpgradeButton;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.Notifications.error('Failure', err, 'Unable to get user details');
|
notifyError('Failure', err, 'Unable to get user details');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,23 @@
|
|||||||
<rd-widget-header icon="sliders" title-text="User theme"></rd-widget-header>
|
<rd-widget-header icon="sliders" title-text="User theme"></rd-widget-header>
|
||||||
<rd-widget-body>
|
<rd-widget-body>
|
||||||
<form class="form-horizontal">
|
<form class="form-horizontal">
|
||||||
<box-selector radio-name="'theme'" value="$ctrl.state.userTheme" options="$ctrl.state.availableThemes" on-change="($ctrl.setTheme)"></box-selector>
|
<box-selector radio-name="'theme'" value="$ctrl.state.themeColor" options="$ctrl.state.availableThemes" on-change="($ctrl.setThemeColor)"></box-selector>
|
||||||
|
|
||||||
|
<p class="mt-2 vertical-center">
|
||||||
|
<pr-icon icon="'alert-circle'" class-name="'icon-primary'"></pr-icon>
|
||||||
|
<span class="small">Dark and High-contrast theme are experimental. Some UI components might not display properly.</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mt-3">
|
||||||
|
<por-switch-field
|
||||||
|
tooltip="'This setting toggles a more subtle UI for the upgrade button located at the top of the sidebar'"
|
||||||
|
label-class="'col-sm-2'"
|
||||||
|
label="'Subtle upgrade button'"
|
||||||
|
checked="$ctrl.state.subtleUpgradeButton"
|
||||||
|
on-change="($ctrl.setSubtleUpgradeButton)"
|
||||||
|
></por-switch-field>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<p class="mt-2 vertical-center">
|
|
||||||
<pr-icon icon="'alert-circle'" class-name="'icon-primary'"></pr-icon>
|
|
||||||
Dark and High-contrast theme are experimental. Some UI components might not display properly.
|
|
||||||
</p>
|
|
||||||
</rd-widget-body>
|
</rd-widget-body>
|
||||||
</rd-widget>
|
</rd-widget>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ export function SettingsViewModel(data) {
|
|||||||
this.EnforceEdgeID = data.EnforceEdgeID;
|
this.EnforceEdgeID = data.EnforceEdgeID;
|
||||||
this.AgentSecret = data.AgentSecret;
|
this.AgentSecret = data.AgentSecret;
|
||||||
this.EdgePortainerUrl = data.EdgePortainerUrl;
|
this.EdgePortainerUrl = data.EdgePortainerUrl;
|
||||||
this.ShowKomposeBuildOption = data.ShowKomposeBuildOption;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PublicSettingsViewModel(settings) {
|
export function PublicSettingsViewModel(settings) {
|
||||||
@@ -37,7 +36,6 @@ export function PublicSettingsViewModel(settings) {
|
|||||||
this.Features = settings.Features;
|
this.Features = settings.Features;
|
||||||
this.Edge = new EdgeSettingsViewModel(settings.Edge);
|
this.Edge = new EdgeSettingsViewModel(settings.Edge);
|
||||||
this.DefaultRegistry = settings.DefaultRegistry;
|
this.DefaultRegistry = settings.DefaultRegistry;
|
||||||
this.ShowKomposeBuildOption = settings.ShowKomposeBuildOption;
|
|
||||||
this.IsAMTEnabled = settings.IsAMTEnabled;
|
this.IsAMTEnabled = settings.IsAMTEnabled;
|
||||||
this.IsFDOEnabled = settings.IsFDOEnabled;
|
this.IsFDOEnabled = settings.IsFDOEnabled;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ export function UserViewModel(data) {
|
|||||||
this.Id = data.Id;
|
this.Id = data.Id;
|
||||||
this.Username = data.Username;
|
this.Username = data.Username;
|
||||||
this.Role = data.Role;
|
this.Role = data.Role;
|
||||||
this.UserTheme = data.UserTheme;
|
this.ThemeSettings = data.ThemeSettings;
|
||||||
if (data.Role === 1) {
|
if (data.Role === 1) {
|
||||||
this.RoleName = 'administrator';
|
this.RoleName = 'administrator';
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import { TeamsSelector } from '@@/TeamsSelector';
|
|||||||
import { PortainerSelect } from '@@/form-components/PortainerSelect';
|
import { PortainerSelect } from '@@/form-components/PortainerSelect';
|
||||||
import { Slider } from '@@/form-components/Slider';
|
import { Slider } from '@@/form-components/Slider';
|
||||||
import { TagButton } from '@@/TagButton';
|
import { TagButton } from '@@/TagButton';
|
||||||
|
import { BETeaserButton } from '@@/BETeaserButton';
|
||||||
|
|
||||||
import { fileUploadField } from './file-upload-field';
|
import { fileUploadField } from './file-upload-field';
|
||||||
import { switchField } from './switch-field';
|
import { switchField } from './switch-field';
|
||||||
@@ -44,7 +45,22 @@ export const componentsModule = angular
|
|||||||
.module('portainer.app.react.components', [customTemplatesModule])
|
.module('portainer.app.react.components', [customTemplatesModule])
|
||||||
.component(
|
.component(
|
||||||
'tagSelector',
|
'tagSelector',
|
||||||
r2a(withReactQuery(TagSelector), ['allowCreate', 'onChange', 'value'])
|
r2a(withUIRouter(withReactQuery(TagSelector)), [
|
||||||
|
'allowCreate',
|
||||||
|
'onChange',
|
||||||
|
'value',
|
||||||
|
])
|
||||||
|
)
|
||||||
|
.component(
|
||||||
|
'beTeaserButton',
|
||||||
|
r2a(BETeaserButton, [
|
||||||
|
'featureId',
|
||||||
|
'heading',
|
||||||
|
'message',
|
||||||
|
'buttonText',
|
||||||
|
'className',
|
||||||
|
'icon',
|
||||||
|
])
|
||||||
)
|
)
|
||||||
.component(
|
.component(
|
||||||
'tagButton',
|
'tagButton',
|
||||||
|
|||||||
@@ -32,8 +32,8 @@ export const viewsModule = angular
|
|||||||
)
|
)
|
||||||
.component(
|
.component(
|
||||||
'settingsEdgeCompute',
|
'settingsEdgeCompute',
|
||||||
r2a(withReactQuery(withCurrentUser(EdgeComputeSettingsView)), [
|
r2a(
|
||||||
'onSubmit',
|
withUIRouter(withReactQuery(withCurrentUser(EdgeComputeSettingsView))),
|
||||||
'settings',
|
['onSubmit', 'settings']
|
||||||
])
|
)
|
||||||
).name;
|
).name;
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import _ from 'lodash-es';
|
import _ from 'lodash-es';
|
||||||
|
|
||||||
import { UserTokenModel, UserViewModel } from '@/portainer/models/user';
|
import { UserTokenModel, UserViewModel } from '@/portainer/models/user';
|
||||||
import { getUser, getUsers } from '@/portainer/users/user.service';
|
import { getUsers } from '@/portainer/users/user.service';
|
||||||
|
import { getUser } from '@/portainer/users/queries/useUser';
|
||||||
|
|
||||||
import { TeamMembershipModel } from '../../models/teamMembership';
|
import { TeamMembershipModel } from '../../models/teamMembership';
|
||||||
|
|
||||||
@@ -15,8 +17,8 @@ export function UserService($q, Users, TeamService, TeamMembershipService) {
|
|||||||
return users.map((u) => new UserViewModel(u));
|
return users.map((u) => new UserViewModel(u));
|
||||||
};
|
};
|
||||||
|
|
||||||
service.user = async function (includeAdministrators) {
|
service.user = async function (userId) {
|
||||||
const user = await getUser(includeAdministrators);
|
const user = await getUser(userId);
|
||||||
|
|
||||||
return new UserViewModel(user);
|
return new UserViewModel(user);
|
||||||
};
|
};
|
||||||
@@ -65,8 +67,8 @@ export function UserService($q, Users, TeamService, TeamMembershipService) {
|
|||||||
return Users.updatePassword({ id: id }, payload).$promise;
|
return Users.updatePassword({ id: id }, payload).$promise;
|
||||||
};
|
};
|
||||||
|
|
||||||
service.updateUserTheme = function (id, userTheme) {
|
service.updateUserTheme = function (id, theme) {
|
||||||
return Users.updateTheme({ id }, { userTheme }).$promise;
|
return Users.updateTheme({ id }, { theme }).$promise;
|
||||||
};
|
};
|
||||||
|
|
||||||
service.userMemberships = function (id) {
|
service.userMemberships = function (id) {
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ angular.module('portainer.app').factory('Authentication', [
|
|||||||
const data = await UserService.user(user.ID);
|
const data = await UserService.user(user.ID);
|
||||||
|
|
||||||
// Initialize user theme base on UserTheme from database
|
// Initialize user theme base on UserTheme from database
|
||||||
const userTheme = data.UserTheme;
|
const userTheme = data.ThemeSettings ? data.ThemeSettings.color : 'auto';
|
||||||
if (userTheme === 'auto' || !userTheme) {
|
if (userTheme === 'auto' || !userTheme) {
|
||||||
ThemeManager.autoTheme();
|
ThemeManager.autoTheme();
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
angular.module('portainer.app').service('ThemeManager', ThemeManager);
|
angular.module('portainer.app').service('ThemeManager', ThemeManager);
|
||||||
|
|
||||||
/* @ngInject */
|
/* @ngInject */
|
||||||
|
|
||||||
export function ThemeManager(StateManager) {
|
export function ThemeManager(StateManager) {
|
||||||
return {
|
return {
|
||||||
setTheme,
|
setTheme,
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { UserId } from '../types';
|
||||||
|
|
||||||
|
export const queryKeys = {
|
||||||
|
base: () => ['users'] as const,
|
||||||
|
user: (id: UserId) => [...queryKeys.base(), id] as const,
|
||||||
|
};
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { useQuery } from 'react-query';
|
||||||
|
|
||||||
|
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||||
|
import { withError } from '@/react-tools/react-query';
|
||||||
|
|
||||||
|
import { buildUrl } from '../user.service';
|
||||||
|
import { User, UserId } from '../types';
|
||||||
|
|
||||||
|
import { queryKeys } from './queryKeys';
|
||||||
|
|
||||||
|
export function useUser(
|
||||||
|
id: UserId,
|
||||||
|
{ staleTime }: { staleTime?: number } = {}
|
||||||
|
) {
|
||||||
|
return useQuery(queryKeys.user(id), () => getUser(id), {
|
||||||
|
...withError('Unable to retrieve user details'),
|
||||||
|
staleTime,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUser(id: UserId) {
|
||||||
|
try {
|
||||||
|
const { data: user } = await axios.get<User>(buildUrl(id));
|
||||||
|
|
||||||
|
return user;
|
||||||
|
} catch (e) {
|
||||||
|
throw parseAxiosError(e as Error, 'Unable to retrieve user details');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,15 +20,8 @@ export type User = {
|
|||||||
EndpointAuthorizations: {
|
EndpointAuthorizations: {
|
||||||
[endpointId: EnvironmentId]: AuthorizationMap;
|
[endpointId: EnvironmentId]: AuthorizationMap;
|
||||||
};
|
};
|
||||||
// UserTheme: string;
|
ThemeSettings: {
|
||||||
// this.EndpointAuthorizations = data.EndpointAuthorizations;
|
color: 'dark' | 'light' | 'highcontrast' | 'auto';
|
||||||
// this.PortainerAuthorizations = data.PortainerAuthorizations;
|
subtleUpgradeButton: boolean;
|
||||||
// if (data.Role === 1) {
|
};
|
||||||
// this.RoleName = 'administrator';
|
|
||||||
// } else {
|
|
||||||
// this.RoleName = 'user';
|
|
||||||
// }
|
|
||||||
// this.AuthenticationMethod = data.AuthenticationMethod;
|
|
||||||
// this.Checked = false;
|
|
||||||
// this.EndpointAuthorizations = data.EndpointAuthorizations;
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -19,16 +19,6 @@ export async function getUsers(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getUser(id: UserId) {
|
|
||||||
try {
|
|
||||||
const { data: user } = await axios.get<User>(buildUrl(id));
|
|
||||||
|
|
||||||
return user;
|
|
||||||
} catch (e) {
|
|
||||||
throw parseAxiosError(e as Error, 'Unable to retrieve user details');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getUserMemberships(id: UserId) {
|
export async function getUserMemberships(id: UserId) {
|
||||||
try {
|
try {
|
||||||
const { data } = await axios.get<TeamMembership[]>(
|
const { data } = await axios.get<TeamMembership[]>(
|
||||||
@@ -40,7 +30,7 @@ export async function getUserMemberships(id: UserId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildUrl(id?: UserId, entity?: string) {
|
export function buildUrl(id?: UserId, entity?: string) {
|
||||||
let url = '/users';
|
let url = '/users';
|
||||||
|
|
||||||
if (id) {
|
if (id) {
|
||||||
|
|||||||
@@ -6,14 +6,12 @@ angular.module('portainer.app').controller('AccountController', [
|
|||||||
'Notifications',
|
'Notifications',
|
||||||
'SettingsService',
|
'SettingsService',
|
||||||
'StateManager',
|
'StateManager',
|
||||||
'ThemeManager',
|
|
||||||
'ModalService',
|
'ModalService',
|
||||||
function ($scope, $state, Authentication, UserService, Notifications, SettingsService, StateManager, ThemeManager, ModalService) {
|
function ($scope, $state, Authentication, UserService, Notifications, SettingsService, StateManager, ModalService) {
|
||||||
$scope.formValues = {
|
$scope.formValues = {
|
||||||
currentPassword: '',
|
currentPassword: '',
|
||||||
newPassword: '',
|
newPassword: '',
|
||||||
confirmPassword: '',
|
confirmPassword: '',
|
||||||
userTheme: '',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
$scope.updatePassword = async function () {
|
$scope.updatePassword = async function () {
|
||||||
@@ -94,24 +92,6 @@ angular.module('portainer.app').controller('AccountController', [
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Update DOM for theme attribute & LocalStorage
|
|
||||||
$scope.setTheme = function (theme) {
|
|
||||||
ThemeManager.setTheme(theme);
|
|
||||||
StateManager.updateTheme(theme);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Rest API Call to update theme with userID in DB
|
|
||||||
$scope.updateTheme = function () {
|
|
||||||
UserService.updateUserTheme($scope.userID, $scope.formValues.userTheme)
|
|
||||||
.then(function success() {
|
|
||||||
Notifications.success('Success', 'User theme successfully updated');
|
|
||||||
$state.reload();
|
|
||||||
})
|
|
||||||
.catch(function error(err) {
|
|
||||||
Notifications.error('Failure', err, err.msg);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
async function initView() {
|
async function initView() {
|
||||||
const state = StateManager.getState();
|
const state = StateManager.getState();
|
||||||
const userDetails = Authentication.getUserDetails();
|
const userDetails = Authentication.getUserDetails();
|
||||||
@@ -124,10 +104,6 @@ angular.module('portainer.app').controller('AccountController', [
|
|||||||
$scope.isDemoUser = state.application.demoEnvironment.users.includes($scope.userID);
|
$scope.isDemoUser = state.application.demoEnvironment.users.includes($scope.userID);
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await UserService.user($scope.userID);
|
|
||||||
|
|
||||||
$scope.formValues.userTheme = data.UserTheme;
|
|
||||||
|
|
||||||
SettingsService.publicSettings()
|
SettingsService.publicSettings()
|
||||||
.then(function success(data) {
|
.then(function success(data) {
|
||||||
$scope.AuthenticationMethod = data.AuthenticationMethod;
|
$scope.AuthenticationMethod = data.AuthenticationMethod;
|
||||||
|
|||||||
@@ -184,16 +184,6 @@
|
|||||||
tooltip="'Hides the \'Add with form\' buttons and prevents adding/editing of resources via forms'"
|
tooltip="'Hides the \'Add with form\' buttons and prevents adding/editing of resources via forms'"
|
||||||
></por-switch-field>
|
></por-switch-field>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
|
||||||
<por-switch-field
|
|
||||||
label="'Allow docker-compose format Kubernetes manifests'"
|
|
||||||
checked="formValues.ShowKomposeBuildOption"
|
|
||||||
name="'toggle_showKomposeBuildOption'"
|
|
||||||
on-change="(onToggleShowKompose)"
|
|
||||||
field-class="'col-sm-12'"
|
|
||||||
label-class="'col-sm-3 col-lg-2'"
|
|
||||||
></por-switch-field>
|
|
||||||
</div>
|
|
||||||
<!-- !deployment options -->
|
<!-- !deployment options -->
|
||||||
<!-- actions -->
|
<!-- actions -->
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -367,7 +357,7 @@
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
class="form-control"
|
class="form-control"
|
||||||
placeholder="us-west-1"
|
placeholder="default region is us-east-1 if left empty"
|
||||||
id="region"
|
id="region"
|
||||||
name="region"
|
name="region"
|
||||||
ng-model="formValues.region"
|
ng-model="formValues.region"
|
||||||
|
|||||||
@@ -1,14 +1,10 @@
|
|||||||
import angular from 'angular';
|
import angular from 'angular';
|
||||||
|
|
||||||
import { FeatureId } from '@/react/portainer/feature-flags/enums';
|
import { FeatureId } from '@/react/portainer/feature-flags/enums';
|
||||||
// import trackEvent directly because the event only fires once with $analytics.trackEvent
|
|
||||||
import { trackEvent } from '@/angulartics.matomo/analytics-services';
|
|
||||||
import { options } from './options';
|
import { options } from './options';
|
||||||
|
|
||||||
angular.module('portainer.app').controller('SettingsController', [
|
angular.module('portainer.app').controller('SettingsController', [
|
||||||
'$scope',
|
'$scope',
|
||||||
'$analytics',
|
|
||||||
'$state',
|
|
||||||
'Notifications',
|
'Notifications',
|
||||||
'SettingsService',
|
'SettingsService',
|
||||||
'ModalService',
|
'ModalService',
|
||||||
@@ -16,7 +12,7 @@ angular.module('portainer.app').controller('SettingsController', [
|
|||||||
'BackupService',
|
'BackupService',
|
||||||
'FileSaver',
|
'FileSaver',
|
||||||
'Blob',
|
'Blob',
|
||||||
function ($scope, $analytics, $state, Notifications, SettingsService, ModalService, StateManager, BackupService, FileSaver) {
|
function ($scope, Notifications, SettingsService, ModalService, StateManager, BackupService, FileSaver) {
|
||||||
$scope.customBannerFeatureId = FeatureId.CUSTOM_LOGIN_BANNER;
|
$scope.customBannerFeatureId = FeatureId.CUSTOM_LOGIN_BANNER;
|
||||||
$scope.s3BackupFeatureId = FeatureId.S3_BACKUP_SETTING;
|
$scope.s3BackupFeatureId = FeatureId.S3_BACKUP_SETTING;
|
||||||
$scope.enforceDeploymentOptions = FeatureId.ENFORCE_DEPLOYMENT_OPTIONS;
|
$scope.enforceDeploymentOptions = FeatureId.ENFORCE_DEPLOYMENT_OPTIONS;
|
||||||
@@ -57,7 +53,6 @@ angular.module('portainer.app').controller('SettingsController', [
|
|||||||
|
|
||||||
$scope.formValues = {
|
$scope.formValues = {
|
||||||
customLogo: false,
|
customLogo: false,
|
||||||
ShowKomposeBuildOption: false,
|
|
||||||
KubeconfigExpiry: undefined,
|
KubeconfigExpiry: undefined,
|
||||||
HelmRepositoryURL: undefined,
|
HelmRepositoryURL: undefined,
|
||||||
BlackListedLabels: [],
|
BlackListedLabels: [],
|
||||||
@@ -83,33 +78,6 @@ angular.module('portainer.app').controller('SettingsController', [
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
$scope.onToggleShowKompose = async function onToggleShowKompose(checked) {
|
|
||||||
if (checked) {
|
|
||||||
ModalService.confirmWarn({
|
|
||||||
title: 'Are you sure?',
|
|
||||||
message: `<p>In a forthcoming Portainer release, we plan to remove support for docker-compose format manifests for Kubernetes deployments, and the Kompose conversion tool which enables this. The reason for this is because Kompose now poses a security risk, since it has a number of Common Vulnerabilities and Exposures (CVEs).</p>
|
|
||||||
<p>Unfortunately, while the Kompose project has a maintainer and is part of the CNCF, it is not being actively maintained. Releases are very infrequent and new pull requests to the project (including ones we've submitted) are taking months to be merged, with new CVEs arising in the meantime.</p>`,
|
|
||||||
buttons: {
|
|
||||||
confirm: {
|
|
||||||
label: 'Ok',
|
|
||||||
className: 'btn-warning',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
callback: function (confirmed) {
|
|
||||||
$scope.setShowCompose(confirmed);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
$scope.setShowCompose(checked);
|
|
||||||
};
|
|
||||||
|
|
||||||
$scope.setShowCompose = function setShowCompose(checked) {
|
|
||||||
return $scope.$evalAsync(() => {
|
|
||||||
$scope.formValues.ShowKomposeBuildOption = checked;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
$scope.onToggleAutoBackups = function onToggleAutoBackups(checked) {
|
$scope.onToggleAutoBackups = function onToggleAutoBackups(checked) {
|
||||||
$scope.$evalAsync(() => {
|
$scope.$evalAsync(() => {
|
||||||
$scope.formValues.scheduleAutomaticBackups = checked;
|
$scope.formValues.scheduleAutomaticBackups = checked;
|
||||||
@@ -187,13 +155,8 @@ angular.module('portainer.app').controller('SettingsController', [
|
|||||||
KubeconfigExpiry: $scope.formValues.KubeconfigExpiry,
|
KubeconfigExpiry: $scope.formValues.KubeconfigExpiry,
|
||||||
HelmRepositoryURL: $scope.formValues.HelmRepositoryURL,
|
HelmRepositoryURL: $scope.formValues.HelmRepositoryURL,
|
||||||
GlobalDeploymentOptions: $scope.formValues.GlobalDeploymentOptions,
|
GlobalDeploymentOptions: $scope.formValues.GlobalDeploymentOptions,
|
||||||
ShowKomposeBuildOption: $scope.formValues.ShowKomposeBuildOption,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (kubeSettingsPayload.ShowKomposeBuildOption !== $scope.initialFormValues.ShowKomposeBuildOption && $scope.initialFormValues.enableTelemetry) {
|
|
||||||
trackEvent('kubernetes-allow-compose', { category: 'kubernetes', metadata: { 'kubernetes-allow-compose': kubeSettingsPayload.ShowKomposeBuildOption } });
|
|
||||||
}
|
|
||||||
|
|
||||||
$scope.state.kubeSettingsActionInProgress = true;
|
$scope.state.kubeSettingsActionInProgress = true;
|
||||||
updateSettings(kubeSettingsPayload, 'Kubernetes settings updated');
|
updateSettings(kubeSettingsPayload, 'Kubernetes settings updated');
|
||||||
};
|
};
|
||||||
@@ -205,7 +168,6 @@ angular.module('portainer.app').controller('SettingsController', [
|
|||||||
StateManager.updateLogo(settings.LogoURL);
|
StateManager.updateLogo(settings.LogoURL);
|
||||||
StateManager.updateSnapshotInterval(settings.SnapshotInterval);
|
StateManager.updateSnapshotInterval(settings.SnapshotInterval);
|
||||||
StateManager.updateEnableTelemetry(settings.EnableTelemetry);
|
StateManager.updateEnableTelemetry(settings.EnableTelemetry);
|
||||||
$scope.initialFormValues.ShowKomposeBuildOption = response.ShowKomposeBuildOption;
|
|
||||||
$scope.initialFormValues.enableTelemetry = response.EnableTelemetry;
|
$scope.initialFormValues.enableTelemetry = response.EnableTelemetry;
|
||||||
$scope.formValues.BlackListedLabels = response.BlackListedLabels;
|
$scope.formValues.BlackListedLabels = response.BlackListedLabels;
|
||||||
})
|
})
|
||||||
@@ -235,11 +197,6 @@ angular.module('portainer.app').controller('SettingsController', [
|
|||||||
$scope.formValues.KubeconfigExpiry = settings.KubeconfigExpiry;
|
$scope.formValues.KubeconfigExpiry = settings.KubeconfigExpiry;
|
||||||
$scope.formValues.HelmRepositoryURL = settings.HelmRepositoryURL;
|
$scope.formValues.HelmRepositoryURL = settings.HelmRepositoryURL;
|
||||||
$scope.formValues.BlackListedLabels = settings.BlackListedLabels;
|
$scope.formValues.BlackListedLabels = settings.BlackListedLabels;
|
||||||
if (settings.ShowKomposeBuildOption) {
|
|
||||||
$scope.formValues.ShowKomposeBuildOption = settings.ShowKomposeBuildOption;
|
|
||||||
}
|
|
||||||
|
|
||||||
$scope.initialFormValues.ShowKomposeBuildOption = settings.ShowKomposeBuildOption;
|
|
||||||
$scope.initialFormValues.enableTelemetry = settings.EnableTelemetry;
|
$scope.initialFormValues.enableTelemetry = settings.EnableTelemetry;
|
||||||
})
|
})
|
||||||
.catch(function error(err) {
|
.catch(function error(err) {
|
||||||
|
|||||||
@@ -12,12 +12,15 @@ export function createMockUsers(
|
|||||||
Id: value,
|
Id: value,
|
||||||
Username: `user${value}`,
|
Username: `user${value}`,
|
||||||
Role: getRoles(roles, value),
|
Role: getRoles(roles, value),
|
||||||
UserTheme: '',
|
|
||||||
RoleName: '',
|
RoleName: '',
|
||||||
AuthenticationMethod: '',
|
AuthenticationMethod: '',
|
||||||
Checked: false,
|
Checked: false,
|
||||||
EndpointAuthorizations: {},
|
EndpointAuthorizations: {},
|
||||||
PortainerAuthorizations: {},
|
PortainerAuthorizations: {},
|
||||||
|
ThemeSettings: {
|
||||||
|
color: 'auto',
|
||||||
|
subtleUpgradeButton: false,
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { ComponentType } from 'react';
|
|||||||
|
|
||||||
import { UserProvider } from '@/react/hooks/useUser';
|
import { UserProvider } from '@/react/hooks/useUser';
|
||||||
|
|
||||||
|
import { withReactQuery } from './withReactQuery';
|
||||||
|
|
||||||
export function withCurrentUser<T>(
|
export function withCurrentUser<T>(
|
||||||
WrappedComponent: ComponentType<T>
|
WrappedComponent: ComponentType<T>
|
||||||
): ComponentType<T> {
|
): ComponentType<T> {
|
||||||
@@ -12,13 +14,14 @@ export function withCurrentUser<T>(
|
|||||||
function WrapperComponent(props: T) {
|
function WrapperComponent(props: T) {
|
||||||
return (
|
return (
|
||||||
<UserProvider>
|
<UserProvider>
|
||||||
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
|
|
||||||
<WrappedComponent {...props} />
|
<WrappedComponent {...props} />
|
||||||
</UserProvider>
|
</UserProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
WrapperComponent.displayName = displayName;
|
WrapperComponent.displayName = `withCurrentUser(${displayName})`;
|
||||||
|
|
||||||
return WrapperComponent;
|
// User provider makes a call to the API to get the current user.
|
||||||
|
// We need to wrap it with React Query to make that call.
|
||||||
|
return withReactQuery(WrapperComponent);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,13 +10,12 @@ export function withI18nSuspense<T>(
|
|||||||
function WrapperComponent(props: T) {
|
function WrapperComponent(props: T) {
|
||||||
return (
|
return (
|
||||||
<Suspense fallback="Loading translations...">
|
<Suspense fallback="Loading translations...">
|
||||||
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
|
|
||||||
<WrappedComponent {...props} />
|
<WrappedComponent {...props} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
WrapperComponent.displayName = displayName;
|
WrapperComponent.displayName = `withI18nSuspense(${displayName})`;
|
||||||
|
|
||||||
return WrapperComponent;
|
return WrapperComponent;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,13 +14,12 @@ export function withReactQuery<T>(
|
|||||||
function WrapperComponent(props: T) {
|
function WrapperComponent(props: T) {
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
|
|
||||||
<WrappedComponent {...props} />
|
<WrappedComponent {...props} />
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
WrapperComponent.displayName = displayName;
|
WrapperComponent.displayName = `withReactQuery(${displayName})`;
|
||||||
|
|
||||||
return WrapperComponent;
|
return WrapperComponent;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,13 +11,12 @@ export function withUIRouter<T>(
|
|||||||
function WrapperComponent(props: T) {
|
function WrapperComponent(props: T) {
|
||||||
return (
|
return (
|
||||||
<UIRouterContextComponent>
|
<UIRouterContextComponent>
|
||||||
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
|
|
||||||
<WrappedComponent {...props} />
|
<WrappedComponent {...props} />
|
||||||
</UIRouterContextComponent>
|
</UIRouterContextComponent>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
WrapperComponent.displayName = displayName;
|
WrapperComponent.displayName = `withUIRouter(${displayName})`;
|
||||||
|
|
||||||
return WrapperComponent;
|
return WrapperComponent;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { Meta, Story } from '@storybook/react';
|
||||||
|
|
||||||
|
import { Alert } from './Alert';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
component: Alert,
|
||||||
|
title: 'Components/Alert',
|
||||||
|
} as Meta;
|
||||||
|
|
||||||
|
interface Args {
|
||||||
|
color: 'success' | 'error' | 'info';
|
||||||
|
title: string;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Template({ text, color, title }: Args) {
|
||||||
|
return (
|
||||||
|
<Alert color={color} title={title}>
|
||||||
|
{text}
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Success: Story<Args> = Template.bind({});
|
||||||
|
Success.args = {
|
||||||
|
color: 'success',
|
||||||
|
title: 'Success',
|
||||||
|
text: 'This is a success alert. Very long text, Very long text,Very long text ,Very long text ,Very long text, Very long text',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Error: Story<Args> = Template.bind({});
|
||||||
|
Error.args = {
|
||||||
|
color: 'error',
|
||||||
|
title: 'Error',
|
||||||
|
text: 'This is an error alert',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Info: Story<Args> = Template.bind({});
|
||||||
|
Info.args = {
|
||||||
|
color: 'info',
|
||||||
|
title: 'Info',
|
||||||
|
text: 'This is an info alert',
|
||||||
|
};
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import clsx from 'clsx';
|
||||||
|
import { AlertCircle, CheckCircle, XCircle } from 'lucide-react';
|
||||||
|
import { PropsWithChildren, ReactNode } from 'react';
|
||||||
|
|
||||||
|
import { Icon } from '@@/Icon';
|
||||||
|
|
||||||
|
type AlertType = 'success' | 'error' | 'info';
|
||||||
|
|
||||||
|
const alertSettings: Record<
|
||||||
|
AlertType,
|
||||||
|
{ container: string; header: string; body: string; icon: ReactNode }
|
||||||
|
> = {
|
||||||
|
success: {
|
||||||
|
container:
|
||||||
|
'border-green-4 bg-green-2 th-dark:bg-green-3 th-dark:border-green-5',
|
||||||
|
header: 'text-green-8',
|
||||||
|
body: 'text-green-7',
|
||||||
|
icon: CheckCircle,
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
container:
|
||||||
|
'border-error-4 bg-error-2 th-dark:bg-error-3 th-dark:border-error-5',
|
||||||
|
header: 'text-error-8',
|
||||||
|
body: 'text-error-7',
|
||||||
|
icon: XCircle,
|
||||||
|
},
|
||||||
|
info: {
|
||||||
|
container:
|
||||||
|
'border-blue-4 bg-blue-2 th-dark:bg-blue-3 th-dark:border-blue-5',
|
||||||
|
header: 'text-blue-8',
|
||||||
|
body: 'text-blue-7',
|
||||||
|
icon: AlertCircle,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Alert({
|
||||||
|
color,
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
}: PropsWithChildren<{ color: AlertType; title: string }>) {
|
||||||
|
const { container, header, body, icon } = alertSettings[color];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AlertContainer className={container}>
|
||||||
|
<AlertHeader className={header}>
|
||||||
|
<Icon icon={icon} />
|
||||||
|
{title}
|
||||||
|
</AlertHeader>
|
||||||
|
<AlertBody className={body}>{children}</AlertBody>
|
||||||
|
</AlertContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertContainer({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
}: PropsWithChildren<{ className?: string }>) {
|
||||||
|
return (
|
||||||
|
<div className={clsx('border-2 border-solid rounded-md', 'p-3', className)}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertHeader({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
}: PropsWithChildren<{ className?: string }>) {
|
||||||
|
return (
|
||||||
|
<h4
|
||||||
|
className={clsx('text-base', 'flex gap-2 items-center !m-0', className)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</h4>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertBody({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
}: PropsWithChildren<{ className?: string }>) {
|
||||||
|
return <div className={clsx('ml-6 mt-2 text-sm', className)}>{children}</div>;
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { Alert } from './Alert';
|
||||||
+13
-11
@@ -14,7 +14,7 @@ interface Props {
|
|||||||
icon?: ReactNode;
|
icon?: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BEOnlyButton({
|
export function BETeaserButton({
|
||||||
featureId,
|
featureId,
|
||||||
heading,
|
heading,
|
||||||
message,
|
message,
|
||||||
@@ -29,16 +29,18 @@ export function BEOnlyButton({
|
|||||||
BEFeatureID={featureId}
|
BEFeatureID={featureId}
|
||||||
message={message}
|
message={message}
|
||||||
>
|
>
|
||||||
<Button
|
<span>
|
||||||
icon={icon}
|
<Button
|
||||||
type="button"
|
icon={icon}
|
||||||
color="warninglight"
|
type="button"
|
||||||
size="small"
|
color="warninglight"
|
||||||
onClick={() => {}}
|
size="small"
|
||||||
disabled
|
onClick={() => {}}
|
||||||
>
|
disabled
|
||||||
{buttonText}
|
>
|
||||||
</Button>
|
{buttonText}
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
</TooltipWithChildren>
|
</TooltipWithChildren>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import { PropsWithChildren } from 'react';
|
import { PropsWithChildren } from 'react';
|
||||||
|
|
||||||
import { Tooltip } from '@@/Tip/Tooltip';
|
import { TooltipWithChildren } from '@@/Tip/TooltipWithChildren';
|
||||||
|
|
||||||
import './BoxSelectorItem.css';
|
import './BoxSelectorItem.css';
|
||||||
|
|
||||||
@@ -29,7 +29,7 @@ export function BoxOption<T extends number | string>({
|
|||||||
type = 'radio',
|
type = 'radio',
|
||||||
children,
|
children,
|
||||||
}: PropsWithChildren<Props<T>>) {
|
}: PropsWithChildren<Props<T>>) {
|
||||||
return (
|
const BoxOption = (
|
||||||
<div className={clsx('box-selector-item', className)}>
|
<div className={clsx('box-selector-item', className)}>
|
||||||
<input
|
<input
|
||||||
type={type}
|
type={type}
|
||||||
@@ -44,13 +44,13 @@ export function BoxOption<T extends number | string>({
|
|||||||
<label htmlFor={option.id} data-cy={`${radioName}_${option.value}`}>
|
<label htmlFor={option.id} data-cy={`${radioName}_${option.value}`}>
|
||||||
{children}
|
{children}
|
||||||
</label>
|
</label>
|
||||||
{tooltip && (
|
|
||||||
<Tooltip
|
|
||||||
position="bottom"
|
|
||||||
className="portainer-tooltip"
|
|
||||||
message={tooltip}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (tooltip) {
|
||||||
|
return (
|
||||||
|
<TooltipWithChildren message={tooltip}>{BoxOption}</TooltipWithChildren>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return BoxOption;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { createMockEnvironment } from '@/react-tools/test-mocks';
|
import { createMockEnvironment } from '@/react-tools/test-mocks';
|
||||||
import { renderWithQueryClient } from '@/react-tools/test-utils';
|
import { renderWithQueryClient } from '@/react-tools/test-utils';
|
||||||
import { rest, server } from '@/setup-tests/server';
|
|
||||||
|
|
||||||
import { EdgeIndicator } from './EdgeIndicator';
|
import { EdgeIndicator } from './EdgeIndicator';
|
||||||
|
|
||||||
@@ -25,8 +24,6 @@ async function renderComponent(
|
|||||||
checkInInterval = 0,
|
checkInInterval = 0,
|
||||||
queryDate = 0
|
queryDate = 0
|
||||||
) {
|
) {
|
||||||
server.use(rest.get('/api/settings', (req, res, ctx) => res(ctx.json({}))));
|
|
||||||
|
|
||||||
const environment = createMockEnvironment();
|
const environment = createMockEnvironment();
|
||||||
|
|
||||||
environment.EdgeID = edgeId;
|
environment.EdgeID = edgeId;
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export function EdgeIndicator({
|
|||||||
return (
|
return (
|
||||||
<span role="status" aria-label="edge-status">
|
<span role="status" aria-label="edge-status">
|
||||||
<EnvironmentStatusBadgeItem aria-label="unassociated">
|
<EnvironmentStatusBadgeItem aria-label="unassociated">
|
||||||
<s>associated</s>
|
<span className="whitespace-nowrap">Not associated</span>
|
||||||
</EnvironmentStatusBadgeItem>
|
</EnvironmentStatusBadgeItem>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
@@ -41,6 +41,7 @@ export function EdgeIndicator({
|
|||||||
>
|
>
|
||||||
<EnvironmentStatusBadgeItem
|
<EnvironmentStatusBadgeItem
|
||||||
color={isValid ? 'success' : 'danger'}
|
color={isValid ? 'success' : 'danger'}
|
||||||
|
icon={isValid ? 'svg-heartbeatup' : 'svg-heartbeatdown'}
|
||||||
aria-label="edge-heartbeat"
|
aria-label="edge-heartbeat"
|
||||||
>
|
>
|
||||||
heartbeat
|
heartbeat
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { ReactNode, useRef } from 'react';
|
||||||
|
import { useQuery } from 'react-query';
|
||||||
|
|
||||||
|
let globalId = 0;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
portalId: HubSpotCreateFormOptions['portalId'];
|
||||||
|
formId: HubSpotCreateFormOptions['formId'];
|
||||||
|
region: HubSpotCreateFormOptions['region'];
|
||||||
|
|
||||||
|
onSubmitted: () => void;
|
||||||
|
|
||||||
|
loading?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HubspotForm({
|
||||||
|
loading,
|
||||||
|
portalId,
|
||||||
|
region,
|
||||||
|
formId,
|
||||||
|
onSubmitted,
|
||||||
|
}: Props) {
|
||||||
|
const elRef = useRef<HTMLDivElement>(null);
|
||||||
|
const id = useRef(`reactHubspotForm${globalId++}`);
|
||||||
|
const { isLoading } = useHubspotForm({
|
||||||
|
elId: id.current,
|
||||||
|
formId,
|
||||||
|
portalId,
|
||||||
|
region,
|
||||||
|
onSubmitted,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
ref={elRef}
|
||||||
|
id={id.current}
|
||||||
|
style={{ display: isLoading ? 'none' : 'block' }}
|
||||||
|
/>
|
||||||
|
{isLoading && loading}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function useHubspotForm({
|
||||||
|
elId,
|
||||||
|
formId,
|
||||||
|
portalId,
|
||||||
|
region,
|
||||||
|
onSubmitted,
|
||||||
|
}: {
|
||||||
|
elId: string;
|
||||||
|
portalId: HubSpotCreateFormOptions['portalId'];
|
||||||
|
formId: HubSpotCreateFormOptions['formId'];
|
||||||
|
region: HubSpotCreateFormOptions['region'];
|
||||||
|
|
||||||
|
onSubmitted: () => void;
|
||||||
|
}) {
|
||||||
|
return useQuery(
|
||||||
|
['hubspot', { elId, formId, portalId, region }],
|
||||||
|
async () => {
|
||||||
|
await loadHubspot();
|
||||||
|
await createForm(`#${elId}`, {
|
||||||
|
formId,
|
||||||
|
portalId,
|
||||||
|
region,
|
||||||
|
onFormSubmitted: onSubmitted,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadHubspot() {
|
||||||
|
return new Promise<void>((resolve) => {
|
||||||
|
if (window.hbspt) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const script = document.createElement(`script`);
|
||||||
|
|
||||||
|
script.defer = true;
|
||||||
|
script.onload = () => {
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
script.src = `//js.hsforms.net/forms/v2.js`;
|
||||||
|
document.head.appendChild(script);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createForm(
|
||||||
|
target: string,
|
||||||
|
options: Omit<HubSpotCreateFormOptions, 'target'>
|
||||||
|
) {
|
||||||
|
return new Promise<void>((resolve) => {
|
||||||
|
if (!window.hbspt) {
|
||||||
|
throw new Error('hbspt object is missing');
|
||||||
|
}
|
||||||
|
|
||||||
|
window.hbspt.forms.create({
|
||||||
|
...options,
|
||||||
|
target,
|
||||||
|
onFormReady(...rest) {
|
||||||
|
options.onFormReady?.(...rest);
|
||||||
|
resolve();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -25,14 +25,10 @@ export function InformationPanel({
|
|||||||
<WidgetBody className={bodyClassName}>
|
<WidgetBody className={bodyClassName}>
|
||||||
<div style={wrapperStyle}>
|
<div style={wrapperStyle}>
|
||||||
{title && (
|
{title && (
|
||||||
<div className="col-sm-12 form-section-title">
|
<div className="form-section-title">
|
||||||
<span style={{ float: 'left' }}>{title}</span>
|
<span>{title}</span>
|
||||||
{!!onDismiss && (
|
{!!onDismiss && (
|
||||||
<span
|
<span className="small" style={{ float: 'right' }}>
|
||||||
className="small"
|
|
||||||
style={{ float: 'right' }}
|
|
||||||
ng-if="dismissAction"
|
|
||||||
>
|
|
||||||
<Button color="link" icon={X} onClick={() => onDismiss()}>
|
<Button color="link" icon={X} onClick={() => onDismiss()}>
|
||||||
dismiss
|
dismiss
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ export function LinkButton({
|
|||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
title={title}
|
title={title}
|
||||||
size="medium"
|
|
||||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||||
{...props}
|
{...props}
|
||||||
className={clsx(className, '!m-0 no-link')}
|
className={clsx(className, '!m-0 no-link')}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import clsx from 'clsx';
|
||||||
|
|
||||||
import { ItemsPerPageSelector } from './ItemsPerPageSelector';
|
import { ItemsPerPageSelector } from './ItemsPerPageSelector';
|
||||||
import { PageSelector } from './PageSelector';
|
import { PageSelector } from './PageSelector';
|
||||||
|
|
||||||
@@ -9,6 +11,7 @@ interface Props {
|
|||||||
showAll?: boolean;
|
showAll?: boolean;
|
||||||
totalCount: number;
|
totalCount: number;
|
||||||
isPageInputVisible?: boolean;
|
isPageInputVisible?: boolean;
|
||||||
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PaginationControls({
|
export function PaginationControls({
|
||||||
@@ -19,9 +22,10 @@ export function PaginationControls({
|
|||||||
onPageChange,
|
onPageChange,
|
||||||
totalCount,
|
totalCount,
|
||||||
isPageInputVisible,
|
isPageInputVisible,
|
||||||
|
className,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
return (
|
return (
|
||||||
<div className="paginationControls">
|
<div className={clsx('paginationControls', className)}>
|
||||||
<div className="form-inline flex">
|
<div className="form-inline flex">
|
||||||
<ItemsPerPageSelector
|
<ItemsPerPageSelector
|
||||||
value={pageLimit}
|
value={pageLimit}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import darkmode from '@/assets/ico/theme/darkmode.svg?c';
|
|||||||
import lightmode from '@/assets/ico/theme/lightmode.svg?c';
|
import lightmode from '@/assets/ico/theme/lightmode.svg?c';
|
||||||
import highcontrastmode from '@/assets/ico/theme/highcontrastmode.svg?c';
|
import highcontrastmode from '@/assets/ico/theme/highcontrastmode.svg?c';
|
||||||
// general icons
|
// general icons
|
||||||
|
import heartbeatup from '@/assets/ico/heartbeat-up.svg?c';
|
||||||
|
import heartbeatdown from '@/assets/ico/heartbeat-down.svg?c';
|
||||||
import checked from '@/assets/ico/checked.svg?c';
|
import checked from '@/assets/ico/checked.svg?c';
|
||||||
import dataflow from '@/assets/ico/dataflow-1.svg?c';
|
import dataflow from '@/assets/ico/dataflow-1.svg?c';
|
||||||
import git from '@/assets/ico/git.svg?c';
|
import git from '@/assets/ico/git.svg?c';
|
||||||
@@ -44,6 +46,8 @@ import quay from '@/assets/ico/vendor/quay.svg?c';
|
|||||||
const placeholder = Placeholder;
|
const placeholder = Placeholder;
|
||||||
|
|
||||||
export const SvgIcons = {
|
export const SvgIcons = {
|
||||||
|
heartbeatup,
|
||||||
|
heartbeatdown,
|
||||||
automode,
|
automode,
|
||||||
darkmode,
|
darkmode,
|
||||||
lightmode,
|
lightmode,
|
||||||
|
|||||||
@@ -1,41 +1,35 @@
|
|||||||
import clsx from 'clsx';
|
|
||||||
import { PropsWithChildren } from 'react';
|
import { PropsWithChildren } from 'react';
|
||||||
import { AlertCircle } from 'lucide-react';
|
import { AlertCircle } from 'lucide-react';
|
||||||
|
|
||||||
import { Icon } from '@@/Icon';
|
import { Icon, IconMode } from '@@/Icon';
|
||||||
|
|
||||||
type Color = 'orange' | 'blue';
|
type Color = 'orange' | 'blue';
|
||||||
|
|
||||||
export interface Props {
|
export interface Props {
|
||||||
|
icon?: React.ReactNode;
|
||||||
color?: Color;
|
color?: Color;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TextTip({
|
export function TextTip({
|
||||||
color = 'orange',
|
color = 'orange',
|
||||||
|
icon = AlertCircle,
|
||||||
children,
|
children,
|
||||||
}: PropsWithChildren<Props>) {
|
}: PropsWithChildren<Props>) {
|
||||||
let iconClass: string;
|
|
||||||
|
|
||||||
switch (color) {
|
|
||||||
case 'blue':
|
|
||||||
iconClass = 'icon-primary';
|
|
||||||
break;
|
|
||||||
case 'orange':
|
|
||||||
iconClass = 'icon-warning';
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
iconClass = 'icon-warning';
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<p className="small vertical-center">
|
<p className="small inline-flex items-center gap-1">
|
||||||
<i className="icon-container">
|
<Icon icon={icon} mode={getMode(color)} className="shrink-0" />
|
||||||
<Icon
|
|
||||||
icon={AlertCircle}
|
|
||||||
className={clsx(`${iconClass}`, 'space-right')}
|
|
||||||
/>
|
|
||||||
</i>
|
|
||||||
<span className="text-muted">{children}</span>
|
<span className="text-muted">{children}</span>
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getMode(color: Color): IconMode {
|
||||||
|
switch (color) {
|
||||||
|
case 'blue':
|
||||||
|
return 'primary';
|
||||||
|
case 'orange':
|
||||||
|
default:
|
||||||
|
return 'warning';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -25,6 +25,10 @@
|
|||||||
font-size: 12px !important;
|
font-size: 12px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tooltip-message a {
|
||||||
|
color: var(--blue-15) !important;
|
||||||
|
}
|
||||||
|
|
||||||
.tooltip-heading {
|
.tooltip-heading {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export function TooltipWithChildren({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div>{message}</div>
|
<div className={styles.tooltipMessage}>{message}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ export function createMockUser(id: number, username: string): UserViewModel {
|
|||||||
Id: id,
|
Id: id,
|
||||||
Username: username,
|
Username: username,
|
||||||
Role: 2,
|
Role: 2,
|
||||||
UserTheme: '',
|
|
||||||
EndpointAuthorizations: {},
|
EndpointAuthorizations: {},
|
||||||
PortainerAuthorizations: {
|
PortainerAuthorizations: {
|
||||||
PortainerDockerHubInspect: true,
|
PortainerDockerHubInspect: true,
|
||||||
@@ -25,5 +24,9 @@ export function createMockUser(id: number, username: string): UserViewModel {
|
|||||||
RoleName: 'user',
|
RoleName: 'user',
|
||||||
Checked: false,
|
Checked: false,
|
||||||
AuthenticationMethod: '',
|
AuthenticationMethod: '',
|
||||||
|
ThemeSettings: {
|
||||||
|
color: 'auto',
|
||||||
|
subtleUpgradeButton: false,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,11 @@ export function CloseButton({
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={clsx(styles.close, className, 'absolute top-4 right-5')}
|
className={clsx(
|
||||||
|
styles.close,
|
||||||
|
className,
|
||||||
|
'absolute top-4 right-5 close-button'
|
||||||
|
)}
|
||||||
onClick={() => onClose()}
|
onClick={() => onClose()}
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user