Compare commits

...

47 Commits

Author SHA1 Message Date
Ali
04156af50f fix(texttip): fix texttip in edge groups [EE-4990] (#8442)
Co-authored-by: testa113 <testa113>
2023-02-03 13:39:48 +13:00
Chaim Lev-Ari
ea6ec14451 fix(home): show tooltip for disabled envs [EE-4859] (#8284) 2023-02-02 18:24:11 -03:00
LP B
7d52427c5b fix(app/edge): updater/rollback calendar visual issues (#8385) 2023-02-02 12:30:27 -03:00
LP B
fadd71e7b9 fix(api/edgestack): update deployments count when env relations are updated (#8434) 2023-02-02 12:05:13 -03:00
Oscar Zhou
194f65107a fix(schedule): update date picker after removing edge group [EE-4963] (#8419) 2023-02-02 11:15:10 +13:00
cmeng
a491d89789 fix(update): prevent formik reinitialize [EE-4962] (#8425) 2023-02-02 09:59:23 +13:00
Ali
6bfbc3c338 fix(texttip): fix texttip placement [EE-4990] (#8428)
Co-authored-by: testa113 <testa113>
2023-02-01 20:34:08 +13:00
Chaim Lev-Ari
76072d11b6 fix(home): reduce update text for small screens [EE-4936] (#8421) 2023-02-01 12:14:56 +05:30
andres-portainer
1000d57cd2 fix(edgestacks): fix edge stacks cache invalidation EE-4909 (#8400) 2023-02-01 02:15:31 -03:00
cmeng
2216d2cdd2 fix(edge/stack): not clear stack status if version not updated [EE-4970] (#8409) 2023-02-01 09:18:12 +13:00
matias-portainer
70c9172112 fix(doc): update endpoint creation swagger documentation EE-4925 (#8416) 2023-01-31 11:06:19 -03:00
Ali
043ec008fe apply changes from EE (#8406)
Co-authored-by: testa113 <testa113>
2023-01-31 10:03:30 +13:00
Dakota Walsh
a3b8b0d58e fix(kube): 30 second delay to storage detection EE-4822 (#8361) 2023-01-31 09:58:50 +13:00
cmeng
fc09e5574f fix(edge/job): init endpoints if it is null [EE-4972] (#8410) 2023-01-27 22:08:07 +13:00
cmeng
1dcdce9feb fix(settings): EE-4959 Cannot turn on Edge Compute Features on CE (#8397) 2023-01-27 17:04:48 +13:00
Chaim Lev-Ari
12ea9fa404 fix(system/update): submit license form [EE-4743] (#8382) 2023-01-26 20:35:02 +05:30
cmeng
38f09fc8ad fix(UI): EE-4937 low resolution hides add container button (#8402) 2023-01-26 17:40:30 +13:00
Ali
c1c4dd190d chore(kompose): remove from settings EE-4741 (#8376) 2023-01-26 16:03:32 +13:00
Chaim Lev-Ari
674a98e432 refactor(auth): cache user data [EE-4935] (#8379) 2023-01-26 07:40:07 +05:30
matias-portainer
da7351cec7 fix(endpoints): check environment type before start metrics detection EE-4944 (#8392) 2023-01-25 10:29:16 -03:00
Chaim Lev-Ari
c9562d1252 fix(edge/update): remove schedule date for old envs [EE-3023] (#8314) 2023-01-24 12:20:59 +05:30
Prabhat Khera
cae257ddfc fix(kubernetes): detect metrics API for kubernetes endspoints EE-4865 (#8352) 2023-01-24 09:05:22 +13:00
LP B
7a063cb2fa chore(dependencies): upgrade msw to fix xmldom CVE (#8363)
* chore(dependencies): upgrade msw to fix xmldom CVE

* refactor(msw): rename msw DefaultRequestBody to DefaultBodyType
2023-01-23 14:03:02 +01:00
Chaim Lev-Ari
d2c967282e fix(edge/group): show tag selector when no tags [EE-4924] (#8367) 2023-01-23 11:05:25 +05:30
Ali
a7cad6fd09 fix(namespace): move warning [EE-4885] (#8371)
Co-authored-by: testa113 <testa113>
2023-01-23 10:41:43 +13:00
Prabhat Khera
d11ae40822 fix(ui): tooltip link color EE-4914 (#8364) 2023-01-23 10:15:41 +13:00
Chaim Lev-Ari
503ef6b415 fix(settings): save only the existing values [EE-4903] (#8325) 2023-01-23 02:34:41 +05:30
cmeng
b8b69ce116 fix(home): EE-4874 homepage ui issues (#8320) 2023-01-23 09:14:13 +13:00
LP B
54fb7242c3 fix(home): dont display disconnected status similar to disabled (#8312) 2023-01-20 17:34:09 +01:00
Chaim Lev-Ari
2887c11c93 feat(system/upgrade): add get license dialog [EE-4743] (#8278) 2023-01-19 15:31:51 +02:00
Chaim Lev-Ari
00b64beed7 fix(home): dark mode for edit buttons [EE-4828] (#8270) 2023-01-19 10:16:30 +02:00
cmeng
1199fe19ec fix(home): EE-4906 home page tiles for edge devices have the wrong url (#8354) 2023-01-19 09:00:52 +13:00
Ali
6d0f473f76 fix(ingress): update label [EE-4902] (#8331)
Co-authored-by: testa113 <testa113>
2023-01-18 13:30:04 +13:00
Ali
6b804152fe fix(app): update redeploy wording [EE-4889] (#8318)
Co-authored-by: testa113 <testa113>
2023-01-18 08:30:00 +13:00
matias-portainer
9e2849dd10 fix(edgejobs): fix edge jobs log collection EE-4893 (#8329) 2023-01-17 14:21:03 -03:00
matias-portainer
eeff16a300 fix(edgejobs): remove endpoint from edge job mapping on endpoint deletion EE-4764 (#8279) 2023-01-17 09:47:14 -03:00
Chaim Lev-Ari
de05fa869d fix(ldap): sync user teams when needed [EE-4802] (#8269) 2023-01-16 10:41:41 +02:00
Chaim Lev-Ari
81ae01d3a2 fix(home): enable kubeconfig button [EE-4833] (#8277) 2023-01-15 11:32:42 +02:00
cmeng
4e0d264bc4 fix(UI) EE-4883 stack repository method console error (#8305)
Co-authored-by: Simon Meng <simon.meng@portainer.io>
2023-01-13 16:08:12 +13:00
Chaim Lev-Ari
3f2220e340 fix(home): link to correct env route [EE-4877] (#8296) 2023-01-12 15:02:40 +02:00
Chaim Lev-Ari
b67c28f128 fix(home): allow non-admins [EE-4873] (#8298) 2023-01-12 15:01:54 +02:00
Chaim Lev-Ari
3a3ec0c50c feat(home): remove margins from env list [EE-4868] (#8286) 2023-01-11 10:59:58 +02:00
Chaim Lev-Ari
26f975f1d5 fix(sidebar): put behind modal [EE-4866] (#8281)
* fix(sidebar): put behind modal

* fix(system/upgrade): validate on open dialog
2023-01-11 08:31:57 +13:00
Chaim Lev-Ari
00f72a80f2 feat(environments): add edge device [EE-4840] (#8271)
* feat(environments): add edge device [EE-4840]

fix [EE-4840]

* fix(home): fix tests
2023-01-11 08:30:45 +13:00
Dakota Walsh
fc7d226f38 fix(padding): fix margin-right on DatatableFooter pagination EE-4831 (#8274) 2023-01-10 10:14:56 +13:00
Matt Hook
995579fda7 fix(rolling-restart): wording and icon changes [EE-4834] (#8275)
* grammar, wording and icon changes

* edit icon

* Rename be-only-button to be-teaser-button
2023-01-10 10:01:57 +13:00
Ali
0fd59eb6a8 fix(rbac): fix false negative rbac result in github microk8s environments [EE-4829] 2023-01-09 17:55:38 +13:00
130 changed files with 2130 additions and 1028 deletions

View File

@@ -83,6 +83,7 @@ overrides:
'newlines-between': 'always',
},
]
no-plusplus: off
func-style: [error, 'declaration']
import/prefer-default-export: off
no-use-before-define: ['error', { functions: false }]

View File

@@ -5,7 +5,6 @@ import (
"sync"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/internal/edge/cache"
"github.com/rs/zerolog/log"
)
@@ -17,9 +16,10 @@ const (
// Service represents a service for managing Edge stack data.
type Service struct {
connection portainer.Connection
idxVersion map[portainer.EdgeStackID]int
mu sync.RWMutex
connection portainer.Connection
idxVersion map[portainer.EdgeStackID]int
mu sync.RWMutex
cacheInvalidationFn func(portainer.EdgeStackID)
}
func (service *Service) BucketName() string {
@@ -27,15 +27,20 @@ func (service *Service) BucketName() string {
}
// 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)
if err != nil {
return nil, err
}
s := &Service{
connection: connection,
idxVersion: make(map[portainer.EdgeStackID]int),
connection: connection,
idxVersion: make(map[portainer.EdgeStackID]int),
cacheInvalidationFn: cacheInvalidationFn,
}
if s.cacheInvalidationFn == nil {
s.cacheInvalidationFn = func(portainer.EdgeStackID) {}
}
es, err := s.EdgeStacks()
@@ -109,12 +114,9 @@ func (service *Service) Create(id portainer.EdgeStackID, edgeStack *portainer.Ed
service.mu.Lock()
service.idxVersion[id] = edgeStack.Version
service.cacheInvalidationFn(id)
service.mu.Unlock()
for endpointID := range edgeStack.Status {
cache.Del(endpointID)
}
return nil
}
@@ -123,37 +125,15 @@ func (service *Service) UpdateEdgeStack(ID portainer.EdgeStackID, edgeStack *por
service.mu.Lock()
defer service.mu.Unlock()
prevEdgeStack, err := service.EdgeStack(ID)
if err != nil {
return err
}
identifier := service.connection.ConvertToKey(int(ID))
err = service.connection.UpdateObject(BucketName, identifier, edgeStack)
err := service.connection.UpdateObject(BucketName, identifier, edgeStack)
if err != nil {
return err
}
service.idxVersion[ID] = edgeStack.Version
// 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)
}
service.cacheInvalidationFn(ID)
return nil
}
@@ -167,35 +147,10 @@ func (service *Service) UpdateEdgeStackFunc(ID portainer.EdgeStackID, updateFunc
defer service.mu.Unlock()
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)
prevVersion := service.idxVersion[ID]
service.idxVersion[ID] = edgeStack.Version
// 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)
}
service.cacheInvalidationFn(ID)
})
}
@@ -204,23 +159,16 @@ func (service *Service) DeleteEdgeStack(ID portainer.EdgeStackID) error {
service.mu.Lock()
defer service.mu.Unlock()
edgeStack, err := service.EdgeStack(ID)
if err != nil {
return err
}
identifier := service.connection.ConvertToKey(int(ID))
err = service.connection.DeleteObject(BucketName, identifier)
err := service.connection.DeleteObject(BucketName, identifier)
if err != nil {
return err
}
delete(service.idxVersion, ID)
for endpointID := range edgeStack.Status {
cache.Del(endpointID)
}
service.cacheInvalidationFn(ID)
return nil
}

View File

@@ -16,13 +16,18 @@ const (
// Service represents a service for managing environment(endpoint) relation data.
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 {
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.
func NewService(connection portainer.Connection) (*Service, error) {
err := connection.SetServiceName(BucketName)
@@ -78,20 +83,122 @@ func (service *Service) Create(endpointRelation *portainer.EndpointRelation) 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 {
previousRelationState, _ := service.EndpointRelation(endpointID)
identifier := service.connection.ConvertToKey(int(endpointID))
err := service.connection.UpdateObject(BucketName, identifier, endpointRelation)
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
func (service *Service) DeleteEndpointRelation(endpointID portainer.EndpointID) error {
deletedRelation, _ := service.EndpointRelation(endpointID)
identifier := service.connection.ConvertToKey(int(endpointID))
err := service.connection.DeleteObject(BucketName, identifier)
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
})
}
}
}

View File

@@ -126,6 +126,7 @@ type (
EndpointRelation(EndpointID portainer.EndpointID) (*portainer.EndpointRelation, error)
Create(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
BucketName() string
}

View File

@@ -2,12 +2,67 @@ package migrator
import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/internal/endpointutils"
"github.com/rs/zerolog/log"
)
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 {

View File

@@ -93,11 +93,18 @@ func (store *Store) initServices() error {
}
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 {
return err
}
store.EdgeStackService = edgeStackService
endpointRelationService.RegisterUpdateStackFunction(edgeStackService.UpdateEdgeStackFunc)
edgeGroupService, err := edgegroup.NewService(store.connection)
if err != nil {
@@ -123,12 +130,6 @@ func (store *Store) initServices() error {
}
store.EndpointService = endpointService
endpointRelationService, err := endpointrelation.NewService(store.connection)
if err != nil {
return err
}
store.EndpointRelationService = endpointRelationService
extensionService, err := extension.NewService(store.connection)
if err != nil {
return err

View File

@@ -62,6 +62,10 @@
"UseLoadBalancer": false,
"UseServerMetrics": false
},
"Flags": {
"IsServerMetricsDetected": false,
"IsServerStorageDetected": false
},
"Snapshots": []
},
"LastCheckInDate": 0,

View File

@@ -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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
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/go.mod h1:03UmPLyjiPUexGJuW20mQXvmsoSpeErvMlItJGtq/Ww=
github.com/portainer/libcrypto v0.0.0-20220506221303-1f4fb3b30f9a h1:B0z3skIMT+OwVNJPQhKp52X+9OWW6A9n5UWig3lHBJk=

View File

@@ -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 {
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)
@@ -150,7 +150,12 @@ func (handler *Handler) persistAndWriteToken(w http.ResponseWriter, tokenData *p
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()
if err != nil {
return err

View File

@@ -163,11 +163,6 @@ func (handler *Handler) edgeGroupUpdate(w http.ResponseWriter, r *http.Request)
}
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)
if err != nil {
return err
@@ -195,9 +190,9 @@ func (handler *Handler) updateEndpointStacks(endpointID portainer.EndpointID) er
edgeStackSet[edgeStackID] = true
}
relation.EdgeStacks = edgeStackSet
return handler.DataStore.EndpointRelation().UpdateEndpointRelation(endpoint.ID, relation)
return handler.DataStore.EndpointRelation().UpdateEndpointRelationFunc(endpoint.ID, func(relation *portainer.EndpointRelation) {
relation.EdgeStacks = edgeStackSet
})
}
func (handler *Handler) updateEndpointEdgeJobs(edgeGroupID portainer.EdgeGroupID, endpointID portainer.EndpointID, edgeJobs []portainer.EdgeJob, operation string) error {

View File

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

View File

@@ -38,17 +38,14 @@ func (handler *Handler) edgeStackStatusDelete(w http.ResponseWriter, r *http.Req
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 {
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)
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)
return response.JSON(w, edgeStack)
}

View File

@@ -94,15 +94,12 @@ func (handler *Handler) edgeStackUpdate(w http.ResponseWriter, r *http.Request)
}
for endpointID := range endpointsToRemove {
relation, err := handler.DataStore.EndpointRelation().EndpointRelation(endpointID)
if err != nil {
err = handler.DataStore.EndpointRelation().UpdateEndpointRelationFunc(endpointID, func(relation *portainer.EndpointRelation) {
delete(relation.EdgeStacks, stack.ID)
})
if handler.DataStore.IsErrObjectNotFound(err) {
return httperror.InternalServerError("Unable to find environment relation in database", err)
}
delete(relation.EdgeStacks, stack.ID)
err = handler.DataStore.EndpointRelation().UpdateEndpointRelation(endpointID, relation)
if err != nil {
} else if err != nil {
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 {
relation, err := handler.DataStore.EndpointRelation().EndpointRelation(endpointID)
if err != nil {
err = handler.DataStore.EndpointRelation().UpdateEndpointRelationFunc(endpointID, func(relation *portainer.EndpointRelation) {
relation.EdgeStacks[stack.ID] = true
})
if handler.DataStore.IsErrObjectNotFound(err) {
return httperror.InternalServerError("Unable to find environment relation in database", err)
}
relation.EdgeStacks[stack.ID] = true
err = handler.DataStore.EndpointRelation().UpdateEndpointRelation(endpointID, relation)
if err != nil {
} else if err != nil {
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.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)
if err != nil {

View File

@@ -19,11 +19,6 @@ func (handler *Handler) updateEndpointRelations(endpoint *portainer.Endpoint, en
endpointGroup = unassignedGroup
}
endpointRelation, err := handler.DataStore.EndpointRelation().EndpointRelation(endpoint.ID)
if err != nil {
return err
}
edgeGroups, err := handler.DataStore.EdgeGroup().EdgeGroups()
if err != nil {
return err
@@ -39,7 +34,8 @@ func (handler *Handler) updateEndpointRelations(endpoint *portainer.Endpoint, en
for _, edgeStackID := range endpointStacks {
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
})
}

View File

@@ -55,13 +55,13 @@ const (
func (payload *endpointCreatePayload) Validate(r *http.Request) error {
name, err := request.RetrieveMultiPartFormValue(r, "Name", false)
if err != nil {
return errors.New("Invalid environment name")
return errors.New("invalid environment name")
}
payload.Name = name
endpointCreationType, err := request.RetrieveNumericMultiPartFormValue(r, "EndpointCreationType", false)
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)
@@ -74,7 +74,7 @@ func (payload *endpointCreatePayload) Validate(r *http.Request) error {
var tagIDs []portainer.TagID
err = request.RetrieveMultiPartFormJSONValue(r, "TagIds", &tagIDs, true)
if err != nil {
return errors.New("Invalid TagIds parameter")
return errors.New("invalid TagIds parameter")
}
payload.TagIDs = tagIDs
if payload.TagIDs == nil {
@@ -93,7 +93,7 @@ func (payload *endpointCreatePayload) Validate(r *http.Request) error {
if !payload.TLSSkipVerify {
caCert, _, err := request.RetrieveMultiPartFormFile(r, "TLSCACertFile")
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
}
@@ -101,13 +101,13 @@ func (payload *endpointCreatePayload) Validate(r *http.Request) error {
if !payload.TLSSkipClientVerify {
cert, _, err := request.RetrieveMultiPartFormFile(r, "TLSCertFile")
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
key, _, err := request.RetrieveMultiPartFormFile(r, "TLSKeyFile")
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
}
@@ -117,19 +117,19 @@ func (payload *endpointCreatePayload) Validate(r *http.Request) error {
case azureEnvironment:
azureApplicationID, err := request.RetrieveMultiPartFormValue(r, "AzureApplicationID", false)
if err != nil {
return errors.New("Invalid Azure application ID")
return errors.New("invalid Azure application ID")
}
payload.AzureApplicationID = azureApplicationID
azureTenantID, err := request.RetrieveMultiPartFormValue(r, "AzureTenantID", false)
if err != nil {
return errors.New("Invalid Azure tenant ID")
return errors.New("invalid Azure tenant ID")
}
payload.AzureTenantID = azureTenantID
azureAuthenticationKey, err := request.RetrieveMultiPartFormValue(r, "AzureAuthenticationKey", false)
if err != nil {
return errors.New("Invalid Azure authentication key")
return errors.New("invalid Azure authentication key")
}
payload.AzureAuthenticationKey = azureAuthenticationKey
@@ -146,7 +146,7 @@ func (payload *endpointCreatePayload) Validate(r *http.Request) error {
default:
endpointURL, err := request.RetrieveMultiPartFormValue(r, "URL", true)
if err != nil {
return errors.New("Invalid environment URL")
return errors.New("invalid environment URL")
}
payload.URL = endpointURL
@@ -157,7 +157,7 @@ func (payload *endpointCreatePayload) Validate(r *http.Request) error {
gpus := make([]portainer.Pair, 0)
err = request.RetrieveMultiPartFormJSONValue(r, "Gpus", &gpus, true)
if err != nil {
return errors.New("Invalid Gpus parameter")
return errors.New("invalid Gpus parameter")
}
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 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 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"
// @failure 400 "Invalid request"
// @failure 500 "Server error"

View File

@@ -9,6 +9,7 @@ import (
"github.com/portainer/libhttp/response"
portainer "github.com/portainer/portainer/api"
httperrors "github.com/portainer/portainer/api/http/errors"
"github.com/portainer/portainer/api/internal/endpointutils"
)
// @id EndpointDelete
@@ -100,8 +101,9 @@ func (handler *Handler) endpointDelete(w http.ResponseWriter, r *http.Request) *
for idx := range edgeStacks {
edgeStack := &edgeStacks[idx]
if _, ok := edgeStack.Status[endpoint.ID]; ok {
delete(edgeStack.Status, endpoint.ID)
err = handler.DataStore.EdgeStack().UpdateEdgeStack(edgeStack.ID, edgeStack)
err = handler.DataStore.EdgeStack().UpdateEdgeStackFunc(edgeStack.ID, func(stack *portainer.EdgeStack) {
delete(stack.Status, endpoint.ID)
})
if err != nil {
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)
}

View File

@@ -7,6 +7,7 @@ import (
"github.com/portainer/libhttp/request"
"github.com/portainer/libhttp/response"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/internal/endpointutils"
)
// @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)
}

View File

@@ -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 {
endpointRelation, err := handler.DataStore.EndpointRelation().EndpointRelation(endpoint.ID)
if err != nil {
return err
}
endpointGroup, err := handler.DataStore.EndpointGroup().EndpointGroup(endpoint.GroupID)
if err != nil {
return err
@@ -121,9 +116,10 @@ func (handler *Handler) updateEndpointRelations(endpoint portainer.Endpoint, edg
for _, edgeStackID := range endpointStacks {
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 {

View File

@@ -6,11 +6,13 @@ import (
"strings"
"time"
"github.com/pkg/errors"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/internal/edge"
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.
@@ -112,15 +114,12 @@ func (service *Service) updateEndpointRelations(edgeStackID portainer.EdgeStackI
endpointRelationService := service.dataStore.EndpointRelation()
for _, endpointID := range relatedEndpointIds {
relation, err := endpointRelationService.EndpointRelation(endpointID)
if err != nil {
err := endpointRelationService.UpdateEndpointRelationFunc(endpointID, func(relation *portainer.EndpointRelation) {
relation.EdgeStacks[edgeStackID] = true
})
if service.dataStore.IsErrObjectNotFound(err) {
return fmt.Errorf("unable to find endpoint relation in database: %w", err)
}
relation.EdgeStacks[edgeStackID] = true
err = endpointRelationService.UpdateEndpointRelation(endpointID, relation)
if err != nil {
} else if err != nil {
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 {
relation, err := service.dataStore.EndpointRelation().EndpointRelation(endpointID)
if err != nil {
return errors.WithMessage(err, "Unable to find environment relation in database")
}
delete(relation.EdgeStacks, edgeStackID)
err = service.dataStore.EndpointRelation().UpdateEndpointRelation(endpointID, relation)
if err != nil {
service.dataStore.EndpointRelation().UpdateEndpointRelationFunc(endpointID, func(relation *portainer.EndpointRelation) {
delete(relation.EdgeStacks, edgeStackID)
})
if service.dataStore.IsErrObjectNotFound(err) {
log.Warn().
Int("endpoint_id", int(endpointID)).
Msg("Unable to find endpoint relation in database, skipping")
continue
} else if err != nil {
return errors.WithMessage(err, "Unable to persist environment relation in database")
}
}

View File

@@ -1,7 +1,9 @@
package endpointutils
import (
"fmt"
"strings"
"time"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
@@ -116,6 +118,7 @@ func InitialMetricsDetection(endpoint *portainer.Endpoint, endpointService datas
return
}
endpoint.Kubernetes.Configuration.UseServerMetrics = true
endpoint.Kubernetes.Flags.IsServerMetricsDetected = true
err = endpointService.UpdateEndpoint(
portainer.EndpointID(endpoint.ID),
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)
if err != nil {
log.Debug().Err(err).Msg("unable to create Kubernetes client for initial storage detection")
return
return err
}
storage, err := cli.GetStorage()
if err != nil {
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
err = endpointService.UpdateEndpoint(
@@ -145,6 +152,23 @@ func InitialStorageDetection(endpoint *portainer.Endpoint, endpointService datas
)
if err != nil {
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
}
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")
}()
}

View File

@@ -277,6 +277,16 @@ func (s *stubEndpointService) UpdateEndpoint(ID portainer.EndpointID, endpoint *
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 {
endpoints := []portainer.Endpoint{}

View File

@@ -164,7 +164,7 @@ func (kcl *KubeClient) CreateIngress(namespace string, info models.K8sIngressInf
if ingress.Labels == nil {
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.
var tls []netv1.IngressTLS

View File

@@ -2,6 +2,7 @@ package cli
import (
"context"
"time"
"github.com/portainer/portainer/api/internal/randomstring"
@@ -133,10 +134,34 @@ func createRoleBinding(roleBindingClient rbacv1types.RoleBindingInterface, clust
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
}
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) {
err := roleBindingClient.Delete(context.Background(), name, metav1.DeleteOptions{})
if err != nil {

View File

@@ -562,6 +562,12 @@ type (
KubernetesData struct {
Snapshots []KubernetesSnapshot `json:"Snapshots"`
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

View File

@@ -210,25 +210,12 @@ input[type='checkbox'] {
margin-right: 10px;
}
.blocklist-item--disabled {
cursor: auto;
background-color: var(--grey-12);
}
.blocklist-item--selected {
background-color: var(--bg-blocklist-item-selected-color);
border: 2px solid var(--border-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 {
display: flex;
}

View File

@@ -13,7 +13,6 @@
.btn[disabled],
fieldset[disabled] .btn {
@apply opacity-40;
pointer-events: none;
touch-action: none;
}

View File

@@ -121,11 +121,6 @@ pr-icon {
width: 20px;
}
.icon-container {
display: flex;
align-items: center;
}
.btn-only-icon {
padding: 6px;
}

View File

@@ -21,3 +21,4 @@ import '../fonts/nomad-icon.css';
import './bootstrap-override.css';
import './icon.css';
import './button.css';
import './react-datetime-picker-override.css';

View File

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

View File

@@ -72,6 +72,7 @@
--blue-11: #3ea5ff;
--blue-12: #41a6ff;
--blue-14: #357ebd;
--blue-15: #36bffa;
--red-1: #a94442;
--red-2: #c7254e;
@@ -222,7 +223,6 @@
--border-table-color: var(--grey-19);
--border-table-top-color: var(--grey-19);
--border-datatable-top-color: var(--grey-10);
--border-blocklist-color: var(--grey-44);
--border-input-group-addon-color: var(--grey-44);
--border-btn-default-color: var(--grey-44);
--border-boxselector-color: var(--grey-6);
@@ -231,7 +231,6 @@
--border-navtabs-color: var(--ui-white);
--border-codemirror-cursor-color: var(--black-color);
--border-pre-color: var(--grey-43);
--border-blocklist-item-selected-color: var(--grey-46);
--border-pagination-span-color: var(--ui-white);
--border-pagination-hover-color: var(--ui-white);
--border-panel-color: var(--white-color);
@@ -245,6 +244,7 @@
--border-sortbutton: var(--grey-8);
--border-bootbox: 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-nav-container-color: var(--ui-gray-5);
--border-stepper-color: var(--ui-gray-4);
@@ -408,7 +408,6 @@
--border-table-color: var(--grey-3);
--border-table-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-btn-default-color: var(--grey-38);
--border-boxselector-color: var(--grey-1);
@@ -417,6 +416,7 @@
--border-navtabs-color: var(--grey-38);
--border-codemirror-cursor-color: var(--white-color);
--border-pre-color: var(--grey-3);
--border-blocklist: var(--ui-gray-9);
--border-blocklist-item-selected-color: var(--grey-38);
--border-pagination-span-color: var(--grey-1);
--border-pagination-hover-color: var(--grey-3);
@@ -430,7 +430,6 @@
--border-modal: 0px;
--border-sortbutton: var(--grey-3);
--border-bootbox: var(--ui-gray-9);
--border-blocklist: var(--ui-gray-9);
--border-widget: var(--grey-3);
--border-pagination-color: var(--grey-1);
--border-nav-container-color: var(--ui-gray-neutral-8);
@@ -600,7 +599,6 @@
--border-pre-color: var(--grey-3);
--border-codemirror-cursor-color: var(--white-color);
--border-modal: 1px solid var(--white-color);
--border-blocklist-color: var(--white-color);
--border-sortbutton: var(--black-color);
--border-bootbox: var(--black-color);
--border-blocklist: var(--white-color);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,30 +4,42 @@
<div class="col-sm-12">
<edge-groups-selector value="$ctrl.model.EdgeGroups" items="$ctrl.edgeGroups" on-change="($ctrl.onChangeGroups)"></edge-groups-selector>
</div>
</div>
<div class="form-group" ng-if="!$ctrl.validateEndpointsForDeployment()">
<div class="col-sm-12">
<div class="small text-muted space-right text-warning">
<pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon>
One or more of the selected Edge group contains Edge Docker endpoints that cannot be used with a Kubernetes Edge stack.
</div>
</div>
<p class="col-sm-12 vertical-center help-block small text-warning" ng-if="$ctrl.model.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>
<p class="col-sm-12 vertical-center help-block small text-warning" ng-if="$ctrl.model.DeploymentType === $ctrl.EditorType.Compose && $ctrl.hasKubeEndpoint()">
<pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon> Edge groups with kubernetes environments no longer support compose deployment types in Portainer. Please select
edge groups that only have docker environments when using compose deployment types.
</p>
</div>
<edge-stack-deployment-type-selector
allow-kube-to-select-compose="$ctrl.allowKubeToSelectCompose"
value="$ctrl.model.DeploymentType"
has-docker-endpoint="$ctrl.hasDockerEndpoint"
has-docker-endpoint="$ctrl.hasDockerEndpoint()"
has-kube-endpoint="$ctrl.hasKubeEndpoint()"
on-change="($ctrl.onChangeDeploymentType)"
read-only="$ctrl.state.readOnlyCompose"
></edge-stack-deployment-type-selector>
<div class="form-group" ng-if="$ctrl.model.DeploymentType === 0 && $ctrl.hasKubeEndpoint()">
<div class="col-sm-12">
<div class="small text-muted space-right">
<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 class="flex gap-1 text-muted small" ng-show="!$ctrl.model.DeploymentType && $ctrl.hasKubeEndpoint()">
<pr-icon icon="'alert-circle'" mode="'warning'" class-name="'!mt-1'"></pr-icon>
<div>
<p>
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
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
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>
@@ -38,6 +50,7 @@
identifier="compose-editor"
placeholder="# Define or paste the content of your docker compose file here"
on-change="($ctrl.onChangeComposeConfig)"
read-only="$ctrl.hasKubeEndpoint()"
>
<editor-description>
<div>
@@ -82,8 +95,8 @@
<div class="col-sm-12">
<button
type="button"
class="btn btn-primary btn-sm"
ng-disabled="$ctrl.actionInProgress || !$ctrl.isFormValid()"
class="btn btn-primary btn-sm !ml-0"
ng-disabled="$ctrl.actionInProgress || !$ctrl.isFormValid() || (!$ctrl.model.DeploymentType && $ctrl.hasKubeEndpoint())"
ng-click="$ctrl.submitAction()"
button-spinner="$ctrl.actionInProgress"
>

View File

@@ -1,12 +1,13 @@
import { PortainerEndpointTypes } from '@/portainer/models/endpoint/models';
import { EditorType } from '@/react/edge/edge-stacks/types';
import { getValidEditorTypes } from '@/react/edge/edge-stacks/utils';
export class EditEdgeStackFormController {
/* @ngInject */
constructor($scope) {
this.$scope = $scope;
this.state = {
endpointTypes: [],
readOnlyCompose: false,
};
this.fileContents = {
@@ -26,6 +27,7 @@ export class EditEdgeStackFormController {
this.removeLineBreaks = this.removeLineBreaks.bind(this);
this.onChangeFileContent = this.onChangeFileContent.bind(this);
this.onChangeUseManifestNamespaces = this.onChangeUseManifestNamespaces.bind(this);
this.selectValidDeploymentType = this.selectValidDeploymentType.bind(this);
}
onChangeUseManifestNamespaces(value) {
@@ -45,8 +47,9 @@ export class EditEdgeStackFormController {
onChangeGroups(groups) {
return this.$scope.$evalAsync(() => {
this.model.EdgeGroups = groups;
this.checkEndpointTypes(groups);
this.setEnvironmentTypesInSelection(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();
}
checkEndpointTypes(groups) {
setEnvironmentTypesInSelection(groups) {
const edgeGroups = groups.map((id) => this.edgeGroups.find((e) => e.Id === id));
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) {
return value.replace(/(\r\n|\n|\r)/gm, '');
}
@@ -81,9 +92,10 @@ export class EditEdgeStackFormController {
}
onChangeDeploymentType(deploymentType) {
this.model.DeploymentType = deploymentType;
this.model.StackFileContent = this.fileContents[deploymentType];
return this.$scope.$evalAsync(() => {
this.model.DeploymentType = deploymentType;
this.model.StackFileContent = this.fileContents[deploymentType];
});
}
validateEndpointsForDeployment() {
@@ -91,6 +103,14 @@ export class EditEdgeStackFormController {
}
$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();
}
}

View File

@@ -6,6 +6,7 @@ import { withReactQuery } from '@/react-tools/withReactQuery';
import { EdgeCheckinIntervalField } from '@/react/edge/components/EdgeCheckInIntervalField';
import { EdgeScriptForm } from '@/react/edge/components/EdgeScriptForm';
import { EdgeAsyncIntervalsForm } from '@/react/edge/components/EdgeAsyncIntervalsForm';
import { EdgeStackDeploymentTypeSelector } from '@/react/edge/edge-stacks/components/EdgeStackDeploymentTypeSelector';
export const componentsModule = angular
.module('portainer.edge.react.components', [])
@@ -43,4 +44,14 @@ export const componentsModule = angular
'readonly',
'fieldSettings',
])
)
.component(
'edgeStackDeploymentTypeSelector',
r2a(withReactQuery(EdgeStackDeploymentTypeSelector), [
'value',
'onChange',
'hasDockerEndpoint',
'hasKubeEndpoint',
'allowKubeToSelectCompose',
])
).name;

View File

@@ -154,6 +154,7 @@ export class EdgeJobController {
this.tags = tags;
this.edgeJob.EdgeGroups = this.edgeJob.EdgeGroups ? this.edgeJob.EdgeGroups : [];
this.edgeJob.Endpoints = this.edgeJob.Endpoints ? this.edgeJob.Endpoints : [];
if (results.length > 0) {
const endpointIds = _.map(results, (result) => result.EndpointId);

View File

@@ -1,4 +1,6 @@
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 {
/* @ngInject */
@@ -43,6 +45,7 @@ export default class CreateEdgeStackViewController {
this.createStackFromGitRepository = this.createStackFromGitRepository.bind(this);
this.onChangeGroups = this.onChangeGroups.bind(this);
this.hasDockerEndpoint = this.hasDockerEndpoint.bind(this);
this.hasKubeEndpoint = this.hasKubeEndpoint.bind(this);
this.onChangeDeploymentType = this.onChangeDeploymentType.bind(this);
}
@@ -134,18 +137,23 @@ export default class CreateEdgeStackViewController {
checkIfEndpointTypes(groups) {
const edgeGroups = groups.map((id) => this.edgeGroups.find((e) => e.Id === id));
this.state.endpointTypes = edgeGroups.flatMap((group) => group.EndpointTypes);
this.selectValidDeploymentType();
}
if (this.hasDockerEndpoint() && this.formValues.DeploymentType == 1) {
this.onChangeDeploymentType(0);
selectValidDeploymentType() {
const validTypes = getValidEditorTypes(this.state.endpointTypes);
if (!validTypes.includes(this.formValues.DeploymentType)) {
this.onChangeDeploymentType(validTypes[0]);
}
}
hasKubeEndpoint() {
return this.state.endpointTypes.includes(7);
return this.state.endpointTypes.includes(PortainerEndpointTypes.EdgeAgentOnKubernetesEnvironment);
}
hasDockerEndpoint() {
return this.state.endpointTypes.includes(4);
return this.state.endpointTypes.includes(PortainerEndpointTypes.EdgeAgentOnDockerEnvironment);
}
validateForm(method) {
@@ -217,9 +225,11 @@ export default class CreateEdgeStackViewController {
}
onChangeDeploymentType(deploymentType) {
this.formValues.DeploymentType = deploymentType;
this.state.Method = 'editor';
this.formValues.StackFileContent = '';
return this.$scope.$evalAsync(() => {
this.formValues.DeploymentType = deploymentType;
this.state.Method = 'editor';
this.formValues.StackFileContent = '';
});
}
formIsInvalid() {

View File

@@ -39,24 +39,19 @@
<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.
</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>
<edge-stack-deployment-type-selector
value="$ctrl.formValues.DeploymentType"
has-docker-endpoint="$ctrl.hasDockerEndpoint"
has-docker-endpoint="$ctrl.hasDockerEndpoint()"
has-kube-endpoint="$ctrl.hasKubeEndpoint()"
on-change="($ctrl.onChangeDeploymentType)"
></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
ng-if="$ctrl.formValues.DeploymentType == $ctrl.EditorType.Compose"
form-values="$ctrl.formValues"

View File

@@ -59,7 +59,11 @@ export class EditEdgeStackViewController {
}
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();
}
}

42
app/global.d.ts vendored
View File

@@ -30,11 +30,53 @@ declare module 'axios-progress-bar' {
): 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 {
/**
* will be true if portainer is run as a Docker Desktop Extension
*/
ddExtension?: boolean;
hbspt?: {
forms: {
create: (options: HubSpotCreateFormOptions) => void;
};
};
}
declare module 'process' {

View File

@@ -22,12 +22,12 @@
<pr-icon class="vertical-center" icon="'check'" size="'md'" mode="'success'"></pr-icon> copied
</span>
<be-only-button
<be-teaser-button
class="float-right"
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.'"
heading="'Apply YAML changes'"
button-text="'Apply changes'"
></be-only-button>
></be-teaser-button>
</div>
</div>

View File

@@ -1,6 +1,5 @@
export const KubernetesDeployManifestTypes = Object.freeze({
KUBERNETES: 1,
COMPOSE: 2,
});
export const KubernetesDeployBuildMethods = Object.freeze({

View File

@@ -4,7 +4,6 @@ import { r2a } from '@/react-tools/react2angular';
import { withCurrentUser } from '@/react-tools/withCurrentUser';
import { withReactQuery } from '@/react-tools/withReactQuery';
import { withUIRouter } from '@/react-tools/withUIRouter';
import { BEOnlyButton } from '@/kubernetes/react/views/beOnlyButton';
import { IngressesDatatableView } from '@/react/kubernetes/ingresses/IngressDatatable';
import { CreateIngressView } from '@/react/kubernetes/ingresses/CreateIngressView';
@@ -20,15 +19,4 @@ export const viewsModule = angular
.component(
'kubernetesIngressesCreateView',
r2a(withUIRouter(withReactQuery(withCurrentUser(CreateIngressView))), [])
)
.component(
'beOnlyButton',
r2a(withUIRouter(withReactQuery(withCurrentUser(BEOnlyButton))), [
'featureId',
'heading',
'message',
'buttonText',
'className',
'icon',
])
).name;

View File

@@ -1,5 +1,5 @@
<page-header
ng-if="!ctrl.state.isEdit"
ng-if="!ctrl.state.isEdit && !ctrl.stack.IsComposeFormat && ctrl.state.viewReady"
title="'Create application'"
breadcrumbs="[
{ label:'Applications', link:'kubernetes.applications' },
@@ -10,7 +10,7 @@
</page-header>
<page-header
ng-if="ctrl.state.isEdit"
ng-if="ctrl.state.isEdit && !ctrl.stack.IsComposeFormat && ctrl.state.viewReady"
title="'Edit application'"
breadcrumbs="[
{ label:'Namespaces', link:'kubernetes.resourcePools' },
@@ -31,6 +31,28 @@
>
</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>
<div ng-if="ctrl.state.viewReady">
<div class="row kubernetes-create">
@@ -88,6 +110,7 @@
<!-- #region web editor -->
<web-editor-form
read-only="ctrl.stack.IsComposeFormat"
ng-if="ctrl.state.appType === ctrl.KubernetesDeploymentTypes.CONTENT"
value="ctrl.stackFileContent"
yml="true"
@@ -96,27 +119,24 @@
on-change="(ctrl.onChangeFileContent)"
>
<editor-description>
<span class="text-muted small" ng-show="ctrl.stack.IsComposeFormat">
<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>
<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
>
</span>
<div class="flex gap-1 text-muted small" ng-show="ctrl.stack.IsComposeFormat">
<pr-icon icon="'alert-circle'" mode="'warning'" class-name="'!mt-1'"></pr-icon>
<div>
<p>
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 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 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>
<span class="text-muted small" ng-show="!ctrl.stack.IsComposeFormat">
<p class="vertical-center">
<pr-icon icon="'info'" mode="'primary'"></pr-icon>
@@ -1345,9 +1365,9 @@
<!-- 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 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 -->
<div class="form-group">
<div class="form-group" ng-hide="ctrl.stack.IsComposeFormat">
<div class="col-sm-12">
<button
ng-if="ctrl.state.appType === ctrl.KubernetesDeploymentTypes.APPLICATION_FORM"

View File

@@ -223,7 +223,7 @@
style="margin-left: 0"
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
authorization="K8sApplicationDetailsW"
@@ -233,15 +233,17 @@
ui-sref="kubernetes.applications.application.edit"
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>
<be-only-button
<be-teaser-button
icon="'refresh-cw'"
feature-id="ctrl.limitedFeature"
message="'A rolling restart of the application is performed.'"
heading="'Rolling restart'"
button-text="'Rolling restart'"
></be-only-button>
class-name="'be-tooltip-teaser'"
className="'be-tooltip-teaser'"
></be-teaser-button>
<button
ng-if="ctrl.application.ApplicationType !== ctrl.KubernetesApplicationTypes.POD"
type="button"

View File

@@ -224,7 +224,7 @@ class KubernetesApplicationController {
}
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) {
return this.$async(this.rollbackApplicationAsync);
}
@@ -234,6 +234,15 @@ class KubernetesApplicationController {
* REDEPLOY
*/
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 {
const promises = _.map(this.application.Pods, (item) => this.KubernetesPodService.delete(item));
await Promise.all(promises);
@@ -245,11 +254,7 @@ class KubernetesApplicationController {
}
redeployApplication() {
this.ModalService.confirmUpdate('Redeploying the application may cause a service interruption. Do you wish to continue?', (confirmed) => {
if (confirmed) {
return this.$async(this.redeployApplicationAsync);
}
});
return this.$async(this.redeployApplicationAsync);
}
/**
@@ -318,6 +323,9 @@ class KubernetesApplicationController {
this.KubernetesNodeService.get(),
]);
this.application = application;
if (this.application.StackId) {
this.stack = await this.StackService.stack(application.StackId);
}
this.allContainers = KubernetesApplicationHelper.associateAllContainersAndApplication(application);
this.formValues.Note = this.application.Note;
this.formValues.Services = this.application.Services;

View File

@@ -116,20 +116,7 @@
placeholder="# Define or paste the content of your manifest file here"
>
<editor-description>
<span class="col-sm-12 text-muted small" ng-show="ctrl.state.DeployType === ctrl.ManifestDeployTypes.COMPOSE">
<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">
<span class="col-sm-12 text-muted small">
<p class="vertical-center">
<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...).

View File

@@ -7,9 +7,8 @@ import PortainerError from '@/portainer/error';
import { KubernetesDeployManifestTypes, KubernetesDeployBuildMethods, KubernetesDeployRequestMethods, RepositoryMechanismTypes } from 'Kubernetes/models/deploy';
import { renderTemplate } from '@/react/portainer/custom-templates/components/utils';
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 { getPublicSettings } from '@/react/portainer/settings/settings.service';
class KubernetesDeployController {
/* @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.$window.onbeforeunload = () => {

View File

@@ -50,14 +50,6 @@
</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">
<kubernetes-resource-reservation
ng-if="ctrl.pool.Quota"
@@ -76,6 +68,14 @@
<div ng-if="ctrl.formValues.HasQuota && ctrl.isAdmin && ctrl.isEditable">
<div class="col-sm-12 form-section-title"> Resource limits </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 -->
<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>

View File

@@ -5,6 +5,10 @@ angular.module('portainer.app').controller('CodeEditorController', function Code
if (value && value.currentValue && ctrl.editor && ctrl.editor.getValue() !== value.currentValue) {
ctrl.editor.setValue(value.currentValue);
}
if (ctrl.editor) {
ctrl.editor.setOption('readOnly', ctrl.readOnly);
}
};
this.$onInit = function () {

View File

@@ -111,17 +111,13 @@
.datatable .footer .paginationControls {
float: right;
margin: 10px 0 5px 0;
margin: 10px 10px 5px 0;
}
.datatable .footer .paginationControls .limitSelector {
font-size: 12px;
}
.datatable .footer .paginationControls .limitSelector:not(:last-child) {
margin-right: 10px;
}
.datatable .footer .paginationControls .pagination {
margin: 0;
}

View File

@@ -1,4 +1,4 @@
<ng-form name="autoUpdateForm form-group">
<ng-form name="autoUpdateForm" class="form-group">
<div class="small vertical-center mb-2">
<pr-icon icon="'info'" mode="'primary'"></pr-icon>
<span class="text-muted">
@@ -44,8 +44,8 @@
<label for="repository_mechanism" class="col-sm-3 col-lg-2 control-label text-left">
Webhook
<portainer-tooltip
message="$ctrl.environmentType === 'KUBERNETES' ?
'See&nbsp;<a href=\'https://docs.portainer.io/user/kubernetes/applications/manifest#automatic-updates\' target=\'_blank\' rel=\'noreferrer\'>Portainer documentation on webhook usage</a>.' :
message="$ctrl.environmentType === 'KUBERNETES' ?
'See&nbsp;<a href=\'https://docs.portainer.io/user/kubernetes/applications/webhooks\' target=\'_blank\' rel=\'noreferrer\'>Portainer documentation on webhook usage</a>.' :
'See&nbsp;<a href=\'https://docs.portainer.io/user/docker/stacks/webhooks\' target=\'_blank\' rel=\'noreferrer\'>Portainer documentation on webhook usage</a>.'"
set-html-message="true"
></portainer-tooltip>

View File

@@ -20,7 +20,6 @@ export function SettingsViewModel(data) {
this.EnforceEdgeID = data.EnforceEdgeID;
this.AgentSecret = data.AgentSecret;
this.EdgePortainerUrl = data.EdgePortainerUrl;
this.ShowKomposeBuildOption = data.ShowKomposeBuildOption;
}
export function PublicSettingsViewModel(settings) {
@@ -37,7 +36,6 @@ export function PublicSettingsViewModel(settings) {
this.Features = settings.Features;
this.Edge = new EdgeSettingsViewModel(settings.Edge);
this.DefaultRegistry = settings.DefaultRegistry;
this.ShowKomposeBuildOption = settings.ShowKomposeBuildOption;
this.IsAMTEnabled = settings.IsAMTEnabled;
this.IsFDOEnabled = settings.IsFDOEnabled;
}

View File

@@ -35,6 +35,7 @@ import { TeamsSelector } from '@@/TeamsSelector';
import { PortainerSelect } from '@@/form-components/PortainerSelect';
import { Slider } from '@@/form-components/Slider';
import { TagButton } from '@@/TagButton';
import { BETeaserButton } from '@@/BETeaserButton';
import { fileUploadField } from './file-upload-field';
import { switchField } from './switch-field';
@@ -44,7 +45,22 @@ export const componentsModule = angular
.module('portainer.app.react.components', [customTemplatesModule])
.component(
'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(
'tagButton',

View File

@@ -32,8 +32,8 @@ export const viewsModule = angular
)
.component(
'settingsEdgeCompute',
r2a(withReactQuery(withCurrentUser(EdgeComputeSettingsView)), [
'onSubmit',
'settings',
])
r2a(
withUIRouter(withReactQuery(withCurrentUser(EdgeComputeSettingsView))),
['onSubmit', 'settings']
)
).name;

View File

@@ -1,6 +1,8 @@
import _ from 'lodash-es';
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';
@@ -15,8 +17,8 @@ export function UserService($q, Users, TeamService, TeamMembershipService) {
return users.map((u) => new UserViewModel(u));
};
service.user = async function (includeAdministrators) {
const user = await getUser(includeAdministrators);
service.user = async function (userId) {
const user = await getUser(userId);
return new UserViewModel(user);
};

View File

@@ -0,0 +1,27 @@
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';
export function useUser(
id: UserId,
{ staleTime }: { staleTime?: number } = {}
) {
return useQuery(['users', 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');
}
}

View File

@@ -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) {
try {
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';
if (id) {

View File

@@ -184,16 +184,6 @@
tooltip="'Hides the \'Add with form\' buttons and prevents adding/editing of resources via forms'"
></por-switch-field>
</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 -->
<!-- actions -->
<div class="form-group">

View File

@@ -1,14 +1,10 @@
import angular from 'angular';
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';
angular.module('portainer.app').controller('SettingsController', [
'$scope',
'$analytics',
'$state',
'Notifications',
'SettingsService',
'ModalService',
@@ -16,7 +12,7 @@ angular.module('portainer.app').controller('SettingsController', [
'BackupService',
'FileSaver',
'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.s3BackupFeatureId = FeatureId.S3_BACKUP_SETTING;
$scope.enforceDeploymentOptions = FeatureId.ENFORCE_DEPLOYMENT_OPTIONS;
@@ -57,7 +53,6 @@ angular.module('portainer.app').controller('SettingsController', [
$scope.formValues = {
customLogo: false,
ShowKomposeBuildOption: false,
KubeconfigExpiry: undefined,
HelmRepositoryURL: undefined,
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.$evalAsync(() => {
$scope.formValues.scheduleAutomaticBackups = checked;
@@ -187,13 +155,8 @@ angular.module('portainer.app').controller('SettingsController', [
KubeconfigExpiry: $scope.formValues.KubeconfigExpiry,
HelmRepositoryURL: $scope.formValues.HelmRepositoryURL,
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;
updateSettings(kubeSettingsPayload, 'Kubernetes settings updated');
};
@@ -205,7 +168,6 @@ angular.module('portainer.app').controller('SettingsController', [
StateManager.updateLogo(settings.LogoURL);
StateManager.updateSnapshotInterval(settings.SnapshotInterval);
StateManager.updateEnableTelemetry(settings.EnableTelemetry);
$scope.initialFormValues.ShowKomposeBuildOption = response.ShowKomposeBuildOption;
$scope.initialFormValues.enableTelemetry = response.EnableTelemetry;
$scope.formValues.BlackListedLabels = response.BlackListedLabels;
})
@@ -235,11 +197,6 @@ angular.module('portainer.app').controller('SettingsController', [
$scope.formValues.KubeconfigExpiry = settings.KubeconfigExpiry;
$scope.formValues.HelmRepositoryURL = settings.HelmRepositoryURL;
$scope.formValues.BlackListedLabels = settings.BlackListedLabels;
if (settings.ShowKomposeBuildOption) {
$scope.formValues.ShowKomposeBuildOption = settings.ShowKomposeBuildOption;
}
$scope.initialFormValues.ShowKomposeBuildOption = settings.ShowKomposeBuildOption;
$scope.initialFormValues.enableTelemetry = settings.EnableTelemetry;
})
.catch(function error(err) {

View File

@@ -2,6 +2,8 @@ import { ComponentType } from 'react';
import { UserProvider } from '@/react/hooks/useUser';
import { withReactQuery } from './withReactQuery';
export function withCurrentUser<T>(
WrappedComponent: ComponentType<T>
): ComponentType<T> {
@@ -12,13 +14,14 @@ export function withCurrentUser<T>(
function WrapperComponent(props: T) {
return (
<UserProvider>
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
<WrappedComponent {...props} />
</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);
}

View File

@@ -10,13 +10,12 @@ export function withI18nSuspense<T>(
function WrapperComponent(props: T) {
return (
<Suspense fallback="Loading translations...">
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
<WrappedComponent {...props} />
</Suspense>
);
}
WrapperComponent.displayName = displayName;
WrapperComponent.displayName = `withI18nSuspense(${displayName})`;
return WrapperComponent;
}

View File

@@ -14,13 +14,12 @@ export function withReactQuery<T>(
function WrapperComponent(props: T) {
return (
<QueryClientProvider client={queryClient}>
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
<WrappedComponent {...props} />
</QueryClientProvider>
);
}
WrapperComponent.displayName = displayName;
WrapperComponent.displayName = `withReactQuery(${displayName})`;
return WrapperComponent;
}

View File

@@ -11,13 +11,12 @@ export function withUIRouter<T>(
function WrapperComponent(props: T) {
return (
<UIRouterContextComponent>
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
<WrappedComponent {...props} />
</UIRouterContextComponent>
);
}
WrapperComponent.displayName = displayName;
WrapperComponent.displayName = `withUIRouter(${displayName})`;
return WrapperComponent;
}

View File

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

View File

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

View File

@@ -0,0 +1 @@
export { Alert } from './Alert';

View File

@@ -14,7 +14,7 @@ interface Props {
icon?: ReactNode;
}
export function BEOnlyButton({
export function BETeaserButton({
featureId,
heading,
message,
@@ -29,16 +29,18 @@ export function BEOnlyButton({
BEFeatureID={featureId}
message={message}
>
<Button
icon={icon}
type="button"
color="warninglight"
size="small"
onClick={() => {}}
disabled
>
{buttonText}
</Button>
<span>
<Button
icon={icon}
type="button"
color="warninglight"
size="small"
onClick={() => {}}
disabled
>
{buttonText}
</Button>
</span>
</TooltipWithChildren>
);
}

View File

@@ -1,7 +1,7 @@
import clsx from 'clsx';
import { PropsWithChildren } from 'react';
import { Tooltip } from '@@/Tip/Tooltip';
import { TooltipWithChildren } from '@@/Tip/TooltipWithChildren';
import './BoxSelectorItem.css';
@@ -29,7 +29,7 @@ export function BoxOption<T extends number | string>({
type = 'radio',
children,
}: PropsWithChildren<Props<T>>) {
return (
const BoxOption = (
<div className={clsx('box-selector-item', className)}>
<input
type={type}
@@ -44,13 +44,13 @@ export function BoxOption<T extends number | string>({
<label htmlFor={option.id} data-cy={`${radioName}_${option.value}`}>
{children}
</label>
{tooltip && (
<Tooltip
position="bottom"
className="portainer-tooltip"
message={tooltip}
/>
)}
</div>
);
if (tooltip) {
return (
<TooltipWithChildren message={tooltip}>{BoxOption}</TooltipWithChildren>
);
}
return BoxOption;
}

View File

@@ -1,6 +1,5 @@
import { createMockEnvironment } from '@/react-tools/test-mocks';
import { renderWithQueryClient } from '@/react-tools/test-utils';
import { rest, server } from '@/setup-tests/server';
import { EdgeIndicator } from './EdgeIndicator';
@@ -25,8 +24,6 @@ async function renderComponent(
checkInInterval = 0,
queryDate = 0
) {
server.use(rest.get('/api/settings', (req, res, ctx) => res(ctx.json({}))));
const environment = createMockEnvironment();
environment.EdgeID = edgeId;

View File

@@ -27,7 +27,7 @@ export function EdgeIndicator({
return (
<span role="status" aria-label="edge-status">
<EnvironmentStatusBadgeItem aria-label="unassociated">
<s>associated</s>
<span className="whitespace-nowrap">Not associated</span>
</EnvironmentStatusBadgeItem>
</span>
);
@@ -41,6 +41,7 @@ export function EdgeIndicator({
>
<EnvironmentStatusBadgeItem
color={isValid ? 'success' : 'danger'}
icon={isValid ? 'svg-heartbeatup' : 'svg-heartbeatdown'}
aria-label="edge-heartbeat"
>
heartbeat

View File

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

View File

@@ -25,14 +25,10 @@ export function InformationPanel({
<WidgetBody className={bodyClassName}>
<div style={wrapperStyle}>
{title && (
<div className="col-sm-12 form-section-title">
<span style={{ float: 'left' }}>{title}</span>
<div className="form-section-title">
<span>{title}</span>
{!!onDismiss && (
<span
className="small"
style={{ float: 'right' }}
ng-if="dismissAction"
>
<span className="small" style={{ float: 'right' }}>
<Button color="link" icon={X} onClick={() => onDismiss()}>
dismiss
</Button>

View File

@@ -16,7 +16,6 @@ export function LinkButton({
return (
<Button
title={title}
size="medium"
// eslint-disable-next-line react/jsx-props-no-spreading
{...props}
className={clsx(className, '!m-0 no-link')}

View File

@@ -1,3 +1,5 @@
import clsx from 'clsx';
import { ItemsPerPageSelector } from './ItemsPerPageSelector';
import { PageSelector } from './PageSelector';
@@ -9,6 +11,7 @@ interface Props {
showAll?: boolean;
totalCount: number;
isPageInputVisible?: boolean;
className?: string;
}
export function PaginationControls({
@@ -19,9 +22,10 @@ export function PaginationControls({
onPageChange,
totalCount,
isPageInputVisible,
className,
}: Props) {
return (
<div className="paginationControls">
<div className={clsx('paginationControls', className)}>
<div className="form-inline flex">
<ItemsPerPageSelector
value={pageLimit}

View File

@@ -4,6 +4,8 @@ import darkmode from '@/assets/ico/theme/darkmode.svg?c';
import lightmode from '@/assets/ico/theme/lightmode.svg?c';
import highcontrastmode from '@/assets/ico/theme/highcontrastmode.svg?c';
// 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 dataflow from '@/assets/ico/dataflow-1.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;
export const SvgIcons = {
heartbeatup,
heartbeatdown,
automode,
darkmode,
lightmode,

View File

@@ -1,41 +1,35 @@
import clsx from 'clsx';
import { PropsWithChildren } from 'react';
import { AlertCircle } from 'lucide-react';
import { Icon } from '@@/Icon';
import { Icon, IconMode } from '@@/Icon';
type Color = 'orange' | 'blue';
export interface Props {
icon?: React.ReactNode;
color?: Color;
}
export function TextTip({
color = 'orange',
icon = AlertCircle,
children,
}: PropsWithChildren<Props>) {
let iconClass: string;
switch (color) {
case 'blue':
iconClass = 'icon-primary';
break;
case 'orange':
iconClass = 'icon-warning';
break;
default:
iconClass = 'icon-warning';
}
return (
<p className="small vertical-center">
<i className="icon-container">
<Icon
icon={AlertCircle}
className={clsx(`${iconClass}`, 'space-right')}
/>
</i>
<p className="small inline-flex items-center gap-1">
<Icon icon={icon} mode={getMode(color)} className="shrink-0" />
<span className="text-muted">{children}</span>
</p>
);
}
function getMode(color: Color): IconMode {
switch (color) {
case 'blue':
return 'primary';
case 'orange':
default:
return 'warning';
}
}

View File

@@ -25,6 +25,10 @@
font-size: 12px !important;
}
.tooltip-message a {
color: var(--blue-15) !important;
}
.tooltip-heading {
font-weight: 500;
}

View File

@@ -54,7 +54,7 @@ export function TooltipWithChildren({
)}
</div>
)}
<div>{message}</div>
<div className={styles.tooltipMessage}>{message}</div>
</div>
);

View File

@@ -12,7 +12,11 @@ export function CloseButton({
return (
<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()}
>
×

View File

@@ -3,7 +3,6 @@
}
.modal-dialog {
width: 450px;
display: inline-block;
text-align: left;
vertical-align: middle;

View File

@@ -21,6 +21,8 @@ interface Props {
onDismiss?(): void;
'aria-label'?: string;
'aria-labelledby'?: string;
size?: 'md' | 'lg';
className?: string;
}
export function Modal({
@@ -28,6 +30,8 @@ export function Modal({
onDismiss,
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledBy,
size = 'md',
className,
}: PropsWithChildren<Props>) {
return (
<Context.Provider value>
@@ -43,9 +47,12 @@ export function Modal({
<DialogContent
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
className={clsx(styles.modalDialog, 'p-0 bg-transparent')}
className={clsx(styles.modalDialog, 'p-0 bg-transparent', {
'w-[450px]': size === 'md',
'w-[700px]': size === 'lg',
})}
>
<div className={clsx(styles.modalContent, 'relative')}>
<div className={clsx(styles.modalContent, 'relative', className)}>
{children}
{onDismiss && <CloseButton onClose={onDismiss} />}
</div>

View File

@@ -0,0 +1,57 @@
import { EditorType } from '@/react/edge/edge-stacks/types';
import { BoxSelector } from '@@/BoxSelector';
import { BoxSelectorOption } from '@@/BoxSelector/types';
import {
compose,
kubernetes,
} from '@@/BoxSelector/common-options/deployment-methods';
interface Props {
value: number;
onChange(value: number): void;
hasDockerEndpoint: boolean;
hasKubeEndpoint: boolean;
allowKubeToSelectCompose?: boolean;
}
export function EdgeStackDeploymentTypeSelector({
value,
onChange,
hasDockerEndpoint,
hasKubeEndpoint,
allowKubeToSelectCompose,
}: Props) {
const deploymentOptions: BoxSelectorOption<number>[] = [
{
...compose,
value: EditorType.Compose,
disabled: () => (allowKubeToSelectCompose ? false : hasKubeEndpoint),
tooltip: () =>
hasKubeEndpoint
? 'Cannot use this option with Edge Kubernetes environments'
: '',
},
{
...kubernetes,
value: EditorType.Kubernetes,
disabled: () => hasDockerEndpoint,
tooltip: () =>
hasDockerEndpoint
? 'Cannot use this option with Edge Docker environments'
: '',
},
];
return (
<>
<div className="col-sm-12 form-section-title"> Deployment type</div>
<BoxSelector
radioName="deploymentType"
value={value}
options={deploymentOptions}
onChange={onChange}
/>
</>
);
}

View File

@@ -0,0 +1,40 @@
import { EnvironmentType } from '@/react/portainer/environments/types';
import { EditorType } from './types';
import { getValidEditorTypes } from './utils';
interface GetValidEditorTypesTest {
endpointTypes: EnvironmentType[];
expected: EditorType[];
title: string;
}
describe('getValidEditorTypes', () => {
const tests: GetValidEditorTypesTest[] = [
{
endpointTypes: [EnvironmentType.EdgeAgentOnDocker],
expected: [EditorType.Compose],
title: 'should return compose for docker envs',
},
{
endpointTypes: [EnvironmentType.EdgeAgentOnKubernetes],
expected: [EditorType.Kubernetes],
title: 'should return kubernetes for kubernetes envs',
},
{
endpointTypes: [
EnvironmentType.EdgeAgentOnDocker,
EnvironmentType.EdgeAgentOnKubernetes,
],
expected: [],
title: 'should return empty for docker and kubernetes envs',
},
];
tests.forEach((test) => {
// eslint-disable-next-line jest/valid-title
it(test.title, () => {
expect(getValidEditorTypes(test.endpointTypes)).toEqual(test.expected);
});
});
});

View File

@@ -0,0 +1,21 @@
import _ from 'lodash';
import { EnvironmentType } from '@/react/portainer/environments/types';
import { EditorType } from './types';
export function getValidEditorTypes(
endpointTypes: EnvironmentType[],
allowKubeToSelectCompose?: boolean
) {
const right: Partial<Record<EnvironmentType, EditorType[]>> = {
[EnvironmentType.EdgeAgentOnDocker]: [EditorType.Compose],
[EnvironmentType.EdgeAgentOnKubernetes]: allowKubeToSelectCompose
? [EditorType.Kubernetes, EditorType.Compose]
: [EditorType.Kubernetes],
};
return endpointTypes.length
? _.intersection(...endpointTypes.map((type) => right[type]))
: [EditorType.Compose, EditorType.Kubernetes];
}

View File

@@ -1,6 +1,6 @@
import { Environment } from '@/react/portainer/environments/types';
import { usePublicSettings } from '@/react/portainer/settings/queries';
import { PublicSettingsViewModel } from '@/portainer/models/settings';
import { PublicSettingsResponse } from '@/react/portainer/settings/types';
export function useHasHeartbeat(environment: Environment) {
const associated = !!environment.EdgeID;
@@ -30,7 +30,7 @@ export function useHasHeartbeat(environment: Environment) {
function getCheckinInterval(
environment: Environment,
settings: PublicSettingsViewModel
settings: PublicSettingsResponse
) {
const asyncMode = environment.Edge.AsyncMode;

View File

@@ -4,16 +4,14 @@ import {
createContext,
ReactNode,
useContext,
useEffect,
useState,
useMemo,
PropsWithChildren,
} from 'react';
import { isAdmin } from '@/portainer/users/user.helpers';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { getUser } from '@/portainer/users/user.service';
import { User, UserId } from '@/portainer/users/types';
import { User } from '@/portainer/users/types';
import { useUser as useLoadUser } from '@/portainer/users/queries/useUser';
import { useLocalStorage } from './useLocalStorage';
@@ -24,7 +22,12 @@ interface State {
export const UserContext = createContext<State | null>(null);
UserContext.displayName = 'UserContext';
export function useUser() {
/**
* @deprecated use `useCurrentUser` instead
*/
export const useUser = useCurrentUser;
export function useCurrentUser() {
const context = useContext(UserContext);
if (context === null) {
@@ -147,23 +150,19 @@ interface UserProviderProps {
export function UserProvider({ children }: UserProviderProps) {
const [jwt] = useLocalStorage('JWT', '');
const [user, setUser] = useState<User>();
useEffect(() => {
if (jwt !== '') {
const tokenPayload = jwtDecode(jwt) as { id: number };
const tokenPayload = useMemo(() => jwtDecode(jwt) as { id: number }, [jwt]);
loadUser(tokenPayload.id);
}
}, [jwt]);
const userQuery = useLoadUser(tokenPayload.id, {
staleTime: Infinity, // should reload te user details only on page load
});
const providerState = useMemo(() => ({ user }), [user]);
const providerState = useMemo(
() => ({ user: userQuery.data }),
[userQuery.data]
);
if (jwt === '') {
return null;
}
if (!providerState.user) {
if (jwt === '' || !providerState.user) {
return null;
}
@@ -172,9 +171,4 @@ export function UserProvider({ children }: UserProviderProps) {
{children}
</UserContext.Provider>
);
async function loadUser(id: UserId) {
const user = await getUser(id);
setUser(user);
}
}

View File

@@ -2,7 +2,7 @@ import { Link } from 'lucide-react';
import { useState } from 'react';
import { Environment } from '@/react/portainer/environments/types';
import { useSettings } from '@/react/portainer/settings/queries';
import { usePublicSettings } from '@/react/portainer/settings/queries';
import { Query } from '@/react/portainer/environments/queries/useEnvironmentList';
import { isEdgeEnvironment } from '@/react/portainer/environments/utils';
@@ -18,11 +18,10 @@ export function AMTButton({
envQueryParams: Query;
}) {
const [isOpenDialog, setOpenDialog] = useState(false);
const isOpenAmtEnabledQuery = useSettings(
(settings) =>
settings.EnableEdgeComputeFeatures &&
settings.openAMTConfiguration.enabled
);
const isOpenAmtEnabledQuery = usePublicSettings({
select: (settings) =>
settings.EnableEdgeComputeFeatures && settings.IsAMTEnabled,
});
const isOpenAMTEnabled = !!isOpenAmtEnabledQuery.data;

View File

@@ -20,6 +20,12 @@ export function EditButtons({ environment }: { environment: Environment }) {
const isEdgeAsync = checkEdgeAsync(environment);
const configRoute = getConfigRoute(environment);
const buttonsClasses = clsx(
'w-full h-full !ml-0 !rounded-none',
'hover:bg-gray-3 th-dark:hover:bg-gray-9 th-highcontrast:hover:bg-white'
);
return (
<ButtonsGrid className="w-11 ml-3">
<LinkButton
@@ -29,7 +35,7 @@ export function EditButtons({ environment }: { environment: Environment }) {
color="none"
icon={Edit2}
size="medium"
className="w-full h-full !ml-0 hover:bg-gray-3 !rounded-none"
className={buttonsClasses}
title="Edit"
/>
@@ -40,7 +46,7 @@ export function EditButtons({ environment }: { environment: Environment }) {
color="none"
icon={Settings}
size="medium"
className="w-full h-full !ml-0 hover:bg-gray-3 !rounded-none"
className={buttonsClasses}
title="Configuration"
/>
</ButtonsGrid>
@@ -79,7 +85,9 @@ function ButtonsGrid({
return (
<div
className={clsx(
'grid border border-solid border-gray-5 rounded-r-lg',
'grid border border-solid rounded-r-lg',
'border-gray-5 th-dark:border-gray-9 th-highcontrast:border-white',
'overflow-hidden',
className
)}
>
@@ -87,7 +95,7 @@ function ButtonsGrid({
<div
key={index}
className={clsx({
'border-0 border-b border-solid border-b-gray-5':
'border-0 border-b border-solid border-b-inherit':
index < children.length - 1,
})}
>

View File

@@ -1,4 +1,5 @@
import { History, Wifi, WifiOff } from 'lucide-react';
import { History, Wifi, WifiOff, X } from 'lucide-react';
import clsx from 'clsx';
import { Environment } from '@/react/portainer/environments/types';
import {
@@ -9,52 +10,89 @@ import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
import { Icon } from '@@/Icon';
import { LinkButton } from '@@/LinkButton';
import { Button } from '@@/buttons';
type BrowseStatus = 'snapshot' | 'connected' | 'disconnected';
export function EnvironmentBrowseButtons({
environment,
onClickBrowse,
onClickDisconnect,
isActive,
}: {
environment: Environment;
onClickBrowse(): void;
onClickDisconnect(): void;
isActive: boolean;
}) {
const isEdgeAsync = checkEdgeAsync(environment);
const browseStatus = getStatus(isActive, isEdgeAsync);
const dashboardRoute = getDashboardRoute(environment);
return (
<div className="flex flex-col gap-1 justify-center [&>*]:h-1/3 h-24">
{isBE && (
<div className="flex flex-col gap-2 justify-center [&>*]:h-1/3 h-24 w-full">
{isBE &&
(browseStatus !== 'snapshot' ? (
<LinkButton
icon={History}
disabled={!isEdgeAsync}
to="edge.browse.dashboard"
params={{
environmentId: environment.Id,
}}
size="medium"
color="light"
className="w-full !py-0 !m-0"
title={
!isEdgeAsync
? 'Browse snapshot is only available for async environments'
: ''
}
>
Browse snapshot
</LinkButton>
) : (
<Button
icon={X}
onClick={onClickDisconnect}
className="w-full !py-0 !m-0 opacity-60"
size="medium"
color="light"
>
Close snapshot
</Button>
))}
{browseStatus !== 'connected' ? (
<LinkButton
icon={History}
disabled={!isEdgeAsync || browseStatus === 'snapshot'}
to="edge.browse.dashboard"
params={{
environmentId: environment.Id,
}}
color="light"
title={
isEdgeAsync
? 'Live connection is not available for async environments'
: ''
}
icon={Wifi}
disabled={isEdgeAsync}
to={dashboardRoute.to}
params={dashboardRoute.params}
size="medium"
onClick={onClickBrowse}
color="primary"
className="w-full !py-0 !m-0"
>
Browse snapshot
Live connect
</LinkButton>
) : (
<Button
icon={WifiOff}
onClick={onClickDisconnect}
className="w-full !py-0 !m-0 opacity-60"
size="medium"
color="primary"
>
Disconnect
</Button>
)}
<LinkButton
title="Live connection is not available for async environments"
icon={Wifi}
disabled={isEdgeAsync || browseStatus === 'connected'}
to={getDashboardRoute(environment)}
params={{
endpointId: environment.Id,
}}
onClick={onClickBrowse}
color="primary"
className="w-full !py-0 !m-0"
>
Live connect
</LinkButton>
<BrowseStatusTag status={browseStatus} />
</div>
);
@@ -87,7 +125,7 @@ function BrowseStatusTag({ status }: { status: BrowseStatus }) {
function Disconnected() {
return (
<div className="vertical-center justify-center opacity-50">
<div className="flex items-center gap-2 justify-center">
<Icon icon={WifiOff} />
Disconnected
</div>
@@ -96,8 +134,14 @@ function Disconnected() {
function Connected() {
return (
<div className="vertical-center gap-2 justify-center text-green-8 bg-green-3 rounded-lg">
<div className="rounded-full h-2 w-2 bg-green-8" />
<div
className={clsx(
'flex items-center gap-2 justify-center rounded-lg',
'text-green-8 th-dark:text-green-4',
'bg-green-3 th-dark:bg-green-3/30'
)}
>
<div className="rounded-full h-2 w-2 bg-green-8 th-dark:bg-green-4" />
Connected
</div>
);
@@ -105,7 +149,13 @@ function Connected() {
function Snapshot() {
return (
<div className="vertical-center gap-2 justify-center text-warning-7 bg-warning-3 rounded-lg">
<div
className={clsx(
'flex items-center gap-2 justify-center rounded-lg',
'text-warning-7 th-dark:text-warning-4',
'bg-warning-3 th-dark:bg-warning-3/10 th-highcontrast:bg-warning-3/30'
)}
>
<div className="rounded-full h-2 w-2 bg-warning-7" />
Browsing Snapshot
</div>

View File

@@ -23,6 +23,7 @@ function Template({ environment }: Args) {
<EnvironmentItem
environment={environment}
onClickBrowse={() => {}}
onClickDisconnect={() => {}}
isActive={false}
/>
);

View File

@@ -46,6 +46,7 @@ function renderComponent(
<EnvironmentItem
isActive={false}
onClickBrowse={() => {}}
onClickDisconnect={() => {}}
environment={env}
groupName={group.Name}
/>

View File

@@ -33,12 +33,14 @@ interface Props {
environment: Environment;
groupName?: string;
onClickBrowse(): void;
onClickDisconnect(): void;
isActive: boolean;
}
export function EnvironmentItem({
environment,
onClickBrowse,
onClickDisconnect,
groupName,
isActive,
}: Props) {
@@ -47,15 +49,13 @@ export function EnvironmentItem({
const snapshotTime = getSnapshotTime(environment);
const tags = useEnvironmentTagNames(environment.TagIds);
const dashboardRoute = getDashboardRoute(environment);
return (
<div className="relative">
<Link
to={getDashboardRoute(environment)}
params={{
endpointId: environment.Id,
environmentId: environment.Id,
}}
to={dashboardRoute.to}
params={dashboardRoute.params}
className="no-link"
>
<button
@@ -115,10 +115,11 @@ export function EnvironmentItem({
see https://stackoverflow.com/questions/66409964/warning-validatedomnesting-a-cannot-appear-as-a-descendant-of-a
*/}
<div className="absolute inset-y-0 right-0 flex justify-end w-56">
<div className="py-3 flex items-center">
<div className="py-3 flex items-center flex-1">
<EnvironmentBrowseButtons
environment={environment}
onClickBrowse={onClickBrowse}
onClickDisconnect={onClickDisconnect}
isActive={isActive}
/>
</div>

View File

@@ -46,7 +46,7 @@ const storageKey = 'home_endpoints';
export function EnvironmentList({ onClickBrowse, onRefresh }: Props) {
const { isAdmin } = useUser();
const { environmentId: currentEnvironmentId } = useStore(environmentStore);
const currentEnvStore = useStore(environmentStore);
const [platformTypes, setPlatformTypes] = useHomePageFilter<
Filter<PlatformType>[]
@@ -140,110 +140,112 @@ export function EnvironmentList({ onClickBrowse, onRefresh }: Props) {
return (
<>
{totalAvailable === 0 && <NoEnvironmentsInfoPanel isAdmin={isAdmin} />}
<div className="row">
<div className="col-sm-12">
<TableContainer>
<div className="px-4">
<TableTitle
className="!px-0"
icon={HardDrive}
label="Environments"
description={
<div className="w-full text-sm text-gray-7">
Click on an environment to manage
</div>
}
>
<div className="flex gap-4">
<SearchBar
className="!bg-transparent !m-0"
value={searchBarValue}
onChange={setSearchBarValue}
placeholder="Search by name, group, tag, status, URL..."
data-cy="home-endpointsSearchInput"
/>
{isAdmin && (
<Button
onClick={onRefresh}
data-cy="home-refreshEndpointsButton"
size="medium"
color="light"
icon={RefreshCcw}
className="!m-0"
>
Refresh
</Button>
)}
<KubeconfigButton
environments={environments}
envQueryParams={queryWithSort}
/>
<AMTButton
environments={environments}
envQueryParams={queryWithSort}
/>
<TableContainer>
<div className="px-4">
<TableTitle
className="!px-0"
icon={HardDrive}
label="Environments"
description={
<div className="w-full text-sm text-gray-7">
Click on an environment to manage
</div>
}
>
<div className="flex gap-4 items-center">
<SearchBar
className="!bg-transparent !m-0 !min-w-[350px]"
value={searchBarValue}
onChange={setSearchBarValue}
placeholder="Search by name, group, tag, status, URL..."
data-cy="home-endpointsSearchInput"
/>
{isAdmin && (
<Button
onClick={onRefresh}
data-cy="home-refreshEndpointsButton"
size="medium"
color="light"
icon={RefreshCcw}
className="!m-0"
>
Refresh
</Button>
)}
<KubeconfigButton
environments={environments}
envQueryParams={queryWithSort}
/>
{updateAvailable && <UpdateBadge />}
</div>
</TableTitle>
<div className="-mt-3">
<EnvironmentListFilters
setPlatformTypes={setPlatformTypes}
platformTypes={platformTypes}
setConnectionTypes={setConnectionTypes}
connectionTypes={connectionTypes}
statusOnChange={statusOnChange}
statusState={statusState}
tagOnChange={tagOnChange}
tagState={tagState}
groupOnChange={groupOnChange}
groupState={groupState}
setAgentVersions={setAgentVersions}
agentVersions={agentVersions}
clearFilter={clearFilter}
sortOnchange={sortOnchange}
sortOnDescending={sortOnDescending}
sortByDescending={sortByDescending}
sortByButton={sortByButton}
sortByState={sortByState}
/>
</div>
<div
className="blocklist !p-0 mt-5 !space-y-2"
data-cy="home-endpointList"
>
{renderItems(
isLoading,
totalCount,
environments.map((env) => (
<EnvironmentItem
key={env.Id}
environment={env}
groupName={
groupsQuery.data?.find((g) => g.Id === env.GroupId)
?.Name
}
onClickBrowse={() => onClickBrowse(env)}
isActive={env.Id === currentEnvironmentId}
/>
))
)}
</div>
<TableFooter>
<PaginationControls
showAll={totalCount <= 100}
pageLimit={pageLimit}
page={page}
onPageChange={setPage}
totalCount={totalCount}
onPageLimitChange={setPageLimit}
/>
</TableFooter>
<AMTButton
environments={environments}
envQueryParams={queryWithSort}
/>
{updateAvailable && <UpdateBadge />}
</div>
</TableContainer>
</TableTitle>
<div className="-mt-3">
<EnvironmentListFilters
setPlatformTypes={setPlatformTypes}
platformTypes={platformTypes}
setConnectionTypes={setConnectionTypes}
connectionTypes={connectionTypes}
statusOnChange={statusOnChange}
statusState={statusState}
tagOnChange={tagOnChange}
tagState={tagState}
groupOnChange={groupOnChange}
groupState={groupState}
setAgentVersions={setAgentVersions}
agentVersions={agentVersions}
clearFilter={clearFilter}
sortOnchange={sortOnchange}
sortOnDescending={sortOnDescending}
sortByDescending={sortByDescending}
sortByButton={sortByButton}
sortByState={sortByState}
/>
</div>
<div
className="blocklist !p-0 mt-5 !space-y-2"
data-cy="home-endpointList"
>
{renderItems(
isLoading,
totalCount,
environments.map((env) => (
<EnvironmentItem
key={env.Id}
environment={env}
groupName={
groupsQuery.data?.find((g) => g.Id === env.GroupId)?.Name
}
onClickBrowse={() => onClickBrowse(env)}
onClickDisconnect={() =>
env.Id === currentEnvStore.environmentId
? currentEnvStore.clear()
: null
}
isActive={env.Id === currentEnvStore.environmentId}
/>
))
)}
</div>
<TableFooter>
<PaginationControls
className="!mr-0"
showAll={totalCount <= 100}
pageLimit={pageLimit}
page={page}
onPageChange={setPage}
totalCount={totalCount}
onPageLimitChange={setPageLimit}
/>
</TableFooter>
</div>
</div>
</TableContainer>
</>
);

View File

@@ -19,7 +19,11 @@ export interface Props {
export function KubeconfigButton({ environments, envQueryParams }: Props) {
const [isOpen, setIsOpen] = useState(false);
if (!isKubeconfigButtonVisible(environments)) {
const kubeEnvs = environments.filter((env) =>
isKubernetesEnvironment(env.Type)
);
if (!isKubeconfigButtonVisible()) {
return null;
}
@@ -29,10 +33,8 @@ export function KubeconfigButton({ environments, envQueryParams }: Props) {
onClick={handleClick}
size="medium"
className="!m-0"
disabled={environments.some(
(env) => !isKubernetesEnvironment(env.Type)
)}
icon={Download}
disabled={kubeEnvs.length === 0}
color="light"
>
Kubeconfig
@@ -57,11 +59,8 @@ export function KubeconfigButton({ environments, envQueryParams }: Props) {
setIsOpen(false);
}
function isKubeconfigButtonVisible(environments: Environment[]) {
if (window.location.protocol !== 'https:') {
return false;
}
return environments.some((env) => isKubernetesEnvironment(env.Type));
function isKubeconfigButtonVisible() {
return window.location.protocol === 'https:';
}
function prompt() {
@@ -70,7 +69,7 @@ export function KubeconfigButton({ environments, envQueryParams }: Props) {
<KubeconfigPrompt
envQueryParams={envQueryParams}
onClose={handleClose}
selectedItems={environments.map((env) => env.Id)}
selectedItems={kubeEnvs.map((env) => env.Id)}
/>
)
);

View File

@@ -11,17 +11,19 @@ export function UpdateBadge() {
return (
<span
className={clsx(
'badge inline-flex items-center px-[2px] font-normal border-solid border border-transparent rounded-xl',
'inline-flex items-center gap-2 p-1 font-normal border-solid border border-transparent rounded-3xl h-fit',
'bg-blue-3 text-blue-8',
'th-dark:bg-blue-8 th-dark:text-white',
'th-highcontrast:bg-transparent th-highcontrast:text-white th-highcontrast:border-white'
)}
>
Update Available: Edge Agent {version}
<span className="hidden 2xl:!inline text-sm">
Update Available: Edge Agent {version}
</span>
<Link
to="portainer.endpoints.updateSchedules.create"
className={clsx(
'badge font-normal ml-2 border-solid border border-transparent',
'badge font-normal border-solid border border-transparent',
'bg-blue-8 text-blue-3',
'th-dark:bg-blue-3 th-dark:text-blue-8 th-dark:hover:bg-blue-5 th-dark:hover:text-blue-8',
'th-highcontrast:bg-transparent th-highcontrast:text-white th-highcontrast:hover:bg-gray-warm-7 th-highcontrast:hover:text-white th-highcontrast:border-white'

View File

@@ -1,10 +1,10 @@
import { useMemo } from 'react';
import { Settings } from 'lucide-react';
import { Formik, Form as FormikForm } from 'formik';
import { useRouter } from '@uirouter/react';
import { notifySuccess } from '@/portainer/services/notifications';
import { withLimitToBE } from '@/react/hooks/useLimitToBE';
import { isoDate } from '@/portainer/filters/filters';
import { PageHeader } from '@@/PageHeader';
import { Widget } from '@@/Widget';
@@ -21,17 +21,21 @@ import { useList } from '../queries/list';
import { NameField } from '../common/NameField';
import { EdgeGroupsField } from '../common/EdgeGroupsField';
import { BetaAlert } from '../common/BetaAlert';
import { defaultValue } from '../common/ScheduledTimeField';
export default withLimitToBE(CreateView);
function CreateView() {
const initialValues: FormValues = {
name: '',
groupIds: [],
type: ScheduleType.Update,
version: '',
scheduledTime: isoDate(Date.now() + 24 * 60 * 60 * 1000),
};
const initialValues = useMemo<FormValues>(
() => ({
name: '',
groupIds: [],
type: ScheduleType.Update,
version: '',
scheduledTime: defaultValue(),
}),
[]
);
const schedulesQuery = useList();

Some files were not shown because too many files have changed in this diff Show More