Compare commits

...

46 Commits

Author SHA1 Message Date
Prabhat Khera 7abed624d9 fix showing default ns for ingresses on edit (#10196)
Test / test-client (push) Has been cancelled
Test / test-server (push) Has been cancelled
2023-08-29 15:12:40 +12:00
cmeng 1e24451cc9 fix(relative-path): not deploy git stack via unpacker EE-6043 (#10194) 2023-08-29 11:48:57 +12:00
Prabhat Khera adcfcdd6e3 fix ECR registry token refresh (#10190) 2023-08-29 10:32:47 +12:00
Dakota Walsh e6e3810fa4 fix(registry): ecr secret fix [EE-5673] (#10108) 2023-08-28 08:38:40 +12:00
andres-portainer 5e20854f86 fix(docker): use version negotiation for the Docker client EE-5797 (#9251) 2023-08-22 17:59:46 -03:00
Chaim Lev-Ari 69f3670ce5 fix(ui/datatables): sync page count with filtering [EE-5890] (#10009) 2023-08-22 09:36:27 +03:00
Chaim Lev-Ari f24555c6c9 feat(ui): add confirmation to delete actions [EE-4612] (#10002) 2023-08-19 19:18:58 +03:00
cmeng 1c79f10ae8 fix(migrator): prevent duplicated migration EE-5777 (#10076) 2023-08-18 21:40:42 +12:00
Chaim Lev-Ari dc76900a28 feat(edge/stacks): reload edge stacks from server [EE-5970] (#10062) 2023-08-17 14:09:43 +03:00
cmeng 74eeb9da06 fix(datatable): image page not loading image list EE-5978 (#10070) 2023-08-17 09:53:25 +12:00
Chaim Lev-Ari 77120abf33 fix(edge/groups): filter selected environments [EE-5891] (#10016) 2023-08-16 12:24:43 +03:00
Chaim Lev-Ari dffdf6783c fix(edge/stacks): show pending envs [EE-5913] (#10051) 2023-08-16 10:22:37 +03:00
Ali 55236129ea fix(ingress): empty initial selection + fixes [EE-5852] (#10067)
Co-authored-by: testa113 <testa113>
2023-08-16 18:07:49 +12:00
Ali d54dd47b21 fix(environments): fix env table [EE-5971] (#10060)
Co-authored-by: testa113 <testa113>
2023-08-16 13:21:16 +12:00
Prabhat Khera 360969c93e fix edit namespace resource quota issue (#10063) 2023-08-16 10:24:55 +12:00
Chaim Lev-Ari 3ea6d2b9d9 feat(edge/configs): add context help [EE-5963] (#10054) 2023-08-15 18:46:53 +03:00
Chaim Lev-Ari 577a36e04e fix(edge/devices): search waiting room devices [EE-5895] (#10015) 2023-08-15 06:05:14 +03:00
matias-portainer 6aa978d5e9 fix(authentication): allow whitespaces when loading AD OU name EE-5206 (#9978) 2023-08-14 12:18:21 -03:00
matias-portainer 0b8d72bfd4 fix(edge/stacks): add pagination to environments list EE-5908 (#10043) 2023-08-14 12:16:49 -03:00
Chaim Lev-Ari faa1387110 feat(edge/stacks): info for old agent status [EE-5792] (#10012) 2023-08-14 16:04:20 +03:00
Ali f5cc245c63 fix(app): use correct withCurrentUser wrapper [EE-5928] (#10041)
Co-authored-by: testa113 <testa113>
2023-08-14 16:53:36 +12:00
cmeng 20c6965ce0 fix(stack): fail to start swarm stack with private image EE-4797 (#10046) 2023-08-14 16:13:15 +12:00
Ali 53679f9381 fix(microk8s): PO ui fixes [EE-5900] (#10032)
Co-authored-by: testa113 <testa113>
2023-08-14 12:35:03 +12:00
andres-portainer e1951baac0 fix(unpacker): implement unpacker error parsing EE-5779 (#10006) 2023-08-10 10:26:09 -03:00
Oscar Zhou 187ec2aa9a fix(stagger): introduce stack version into DeploymentInfo struct (#10027) 2023-08-10 11:58:47 +12:00
matias-portainer 125db4f0de fix(edge/stacks): fix UI issues EE-5844 (#10022) 2023-08-09 10:09:15 -03:00
cmeng 59be96e9e8 fix(edge-stack): detaching swarm stack from git repository EE-5812 (#9997) 2023-08-07 10:33:08 +12:00
Oscar Zhou d3420f39c1 fix(react/datatable): override getColumnCanGlobalFilter method (#9991) 2023-08-07 10:30:31 +12:00
cmeng 004c86578d fix(edge-stack): detaching from git repository EE-5812 (#9988) 2023-08-04 15:17:51 +12:00
cmeng b3d404b378 fix(registry): registry login failure for regular stack EE-5832 (#9985) 2023-08-04 15:17:04 +12:00
Ali 82faf20c68 fix(app): update summary with ingresses [EE-5847] (#9974)
Co-authored-by: testa113 <testa113>
2023-08-04 13:48:18 +12:00
Chaim Lev-Ari 18e40cd973 fix(home): empty default sort [EE-5822] (#9950) 2023-08-03 16:21:00 -03:00
Chaim Lev-Ari 9c4d512a4c fix(docker/images): show empty size cell [EE-5823] (#9953) 2023-08-03 16:19:50 -03:00
Ali ce5c38f841 fix(ingress): ingress ui feedback [EE-5852] (#9983)
Co-authored-by: testa113 <testa113>
2023-08-03 23:03:07 +12:00
cmeng dbb79a181e fix(edge-stack): unable to edit edge stack EE-5845 (#9980) 2023-08-03 17:20:56 +12:00
matias-portainer 2177c27dc4 fix(endpoints): fix nil pointer dereference EE-5843 (#9970) 2023-08-02 11:06:43 -03:00
Matt Hook bfdd72d644 show kube icon for custom template (#9967) 2023-08-02 09:43:39 +12:00
Ali 998bf481f7 fix(ingress): loading and ui fixes [EE-5132] (#9960) 2023-08-01 19:31:29 +12:00
Matt Hook c97ef40cc0 bump compose to 2.20.2 (#9965) 2023-08-01 12:27:28 +12:00
Ali cbae7bdf82 fix(app): improve perceived ingress load time [EE-5805] (#9948)
Co-authored-by: testa113 <testa113>
2023-07-31 20:18:52 +12:00
cmeng f4ec4d6175 fix(stack): update gitops updates tooltip EE-5827 (#9961) 2023-07-31 18:46:04 +12:00
Prabhat Khera ec39d5a88e upgrade helm binary to v3.12.2 (#9264) 2023-07-28 15:06:53 +12:00
Matt Hook d0d9c2a93b post po review changes (#9265) 2023-07-28 07:53:21 +12:00
Ali 73010efd8d fix(UI): PO review tweaks [EE-5776] (#9268)
Co-authored-by: testa113 <testa113>
2023-07-28 07:50:46 +12:00
Dakota Walsh 88de50649f fix(metrics): node chart race condition EE-5447 (#9252) 2023-07-27 11:46:46 +12:00
Dakota Walsh fc89066846 fix(jwt): replace deprecated gorilla/securecookie [EE-5153] (#9262) 2023-07-27 09:44:43 +12:00
117 changed files with 1462 additions and 798 deletions
-13
View File
@@ -1,9 +1,6 @@
package apikey package apikey
import ( import (
"crypto/rand"
"io"
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
) )
@@ -18,13 +15,3 @@ type APIKeyService interface {
DeleteAPIKey(apiKeyID portainer.APIKeyID) error DeleteAPIKey(apiKeyID portainer.APIKeyID) error
InvalidateUserKeyCache(userId portainer.UserID) bool InvalidateUserKeyCache(userId portainer.UserID) bool
} }
// generateRandomKey generates a random key of specified length
// source: https://github.com/gorilla/securecookie/blob/master/securecookie.go#L515
func generateRandomKey(length int) []byte {
k := make([]byte, length)
if _, err := io.ReadFull(rand.Reader, k); err != nil {
return nil
}
return k
}
+3 -2
View File
@@ -3,6 +3,7 @@ package apikey
import ( import (
"testing" "testing"
"github.com/portainer/portainer/api/internal/securecookie"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
@@ -33,7 +34,7 @@ func Test_generateRandomKey(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
got := generateRandomKey(tt.wantLenth) got := securecookie.GenerateRandomKey(tt.wantLenth)
is.Equal(tt.wantLenth, len(got)) is.Equal(tt.wantLenth, len(got))
}) })
} }
@@ -41,7 +42,7 @@ func Test_generateRandomKey(t *testing.T) {
t.Run("Generated keys are unique", func(t *testing.T) { t.Run("Generated keys are unique", func(t *testing.T) {
keys := make(map[string]bool) keys := make(map[string]bool)
for i := 0; i < 100; i++ { for i := 0; i < 100; i++ {
key := generateRandomKey(8) key := securecookie.GenerateRandomKey(8)
_, ok := keys[string(key)] _, ok := keys[string(key)]
is.False(ok) is.False(ok)
keys[string(key)] = true keys[string(key)] = true
+2 -1
View File
@@ -8,6 +8,7 @@ import (
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/internal/securecookie"
"github.com/pkg/errors" "github.com/pkg/errors"
) )
@@ -39,7 +40,7 @@ func (a *apiKeyService) HashRaw(rawKey string) []byte {
// GenerateApiKey generates a raw API key for a user (for one-time display). // GenerateApiKey generates a raw API key for a user (for one-time display).
// The generated API key is stored in the cache and database. // The generated API key is stored in the cache and database.
func (a *apiKeyService) GenerateApiKey(user portainer.User, description string) (string, *portainer.APIKey, error) { func (a *apiKeyService) GenerateApiKey(user portainer.User, description string) (string, *portainer.APIKey, error) {
randKey := generateRandomKey(32) randKey := securecookie.GenerateRandomKey(32)
encodedRawAPIKey := base64.StdEncoding.EncodeToString(randKey) encodedRawAPIKey := base64.StdEncoding.EncodeToString(randKey)
prefixedAPIKey := portainerAPIKeyPrefix + encodedRawAPIKey prefixedAPIKey := portainerAPIKeyPrefix + encodedRawAPIKey
+5
View File
@@ -20,6 +20,7 @@ import (
"github.com/portainer/portainer/api/database/models" "github.com/portainer/portainer/api/database/models"
"github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/datastore" "github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/datastore/migrator"
"github.com/portainer/portainer/api/demo" "github.com/portainer/portainer/api/demo"
"github.com/portainer/portainer/api/docker" "github.com/portainer/portainer/api/docker"
dockerclient "github.com/portainer/portainer/api/docker/client" dockerclient "github.com/portainer/portainer/api/docker/client"
@@ -119,11 +120,15 @@ func initDataStore(flags *portainer.CLIFlags, secretKey []byte, fileService port
log.Fatal().Err(err).Msg("failed generating instance id") log.Fatal().Err(err).Msg("failed generating instance id")
} }
migratorInstance := migrator.NewMigrator(&migrator.MigratorParameters{})
migratorCount := migratorInstance.GetMigratorCountOfCurrentAPIVersion()
// from MigrateData // from MigrateData
v := models.Version{ v := models.Version{
SchemaVersion: portainer.APIVersion, SchemaVersion: portainer.APIVersion,
Edition: int(portainer.PortainerCE), Edition: int(portainer.PortainerCE),
InstanceID: instanceId.String(), InstanceID: instanceId.String(),
MigratorCount: migratorCount,
} }
store.VersionService.UpdateVersion(&v) store.VersionService.UpdateVersion(&v)
+1 -1
View File
@@ -50,7 +50,7 @@ func (store *Store) MigrateData() error {
if err != nil { if err != nil {
err = errors.Wrap(err, "failed to migrate database") err = errors.Wrap(err, "failed to migrate database")
log.Warn().Msg("migration failed, restoring database to previous version") log.Warn().Err(err).Msg("migration failed, restoring database to previous version")
err = store.restoreWithOptions(&BackupOptions{BackupPath: backupPath}) err = store.restoreWithOptions(&BackupOptions{BackupPath: backupPath})
if err != nil { if err != nil {
return errors.Wrap(err, "failed to restore database") return errors.Wrap(err, "failed to restore database")
+10 -4
View File
@@ -115,10 +115,16 @@ func (m *Migrator) updateEdgeStackStatusForDB100() error {
} }
if environmentStatus.Details.Ok { if environmentStatus.Details.Ok {
statusArray = append(statusArray, portainer.EdgeStackDeploymentStatus{ statusArray = append(statusArray,
Type: portainer.EdgeStackStatusRunning, portainer.EdgeStackDeploymentStatus{
Time: time.Now().Unix(), Type: portainer.EdgeStackStatusDeploymentReceived,
}) Time: time.Now().Unix(),
},
portainer.EdgeStackDeploymentStatus{
Type: portainer.EdgeStackStatusRunning,
Time: time.Now().Unix(),
},
)
} }
if environmentStatus.Details.ImagesPulled { if environmentStatus.Details.ImagesPulled {
+11
View File
@@ -148,6 +148,17 @@ func (m *Migrator) LatestMigrations() Migrations {
return m.migrations[len(m.migrations)-1] return m.migrations[len(m.migrations)-1]
} }
func (m *Migrator) GetMigratorCountOfCurrentAPIVersion() int {
migratorCount := 0
latestMigrations := m.LatestMigrations()
if latestMigrations.Version.Equal(semver.MustParse(portainer.APIVersion)) {
migratorCount = len(latestMigrations.MigrationFuncs)
}
return migratorCount
}
// !NOTE: Migration funtions should ideally be idempotent. // !NOTE: Migration funtions should ideally be idempotent.
// ! Which simply means the function can run over the same data many times but only transform it once. // ! Which simply means the function can run over the same data many times but only transform it once.
// ! In practice this really just means an extra check or two to ensure we're not destroying valid data. // ! In practice this really just means an extra check or two to ensure we're not destroying valid data.
+6 -6
View File
@@ -57,20 +57,20 @@ func (factory *ClientFactory) CreateClient(endpoint *portainer.Endpoint, nodeNam
func createLocalClient(endpoint *portainer.Endpoint) (*client.Client, error) { func createLocalClient(endpoint *portainer.Endpoint) (*client.Client, error) {
return client.NewClientWithOpts( return client.NewClientWithOpts(
client.WithHost(endpoint.URL), client.WithHost(endpoint.URL),
client.WithVersion(dockerClientVersion), client.WithAPIVersionNegotiation(),
) )
} }
func CreateClientFromEnv() (*client.Client, error) { func CreateClientFromEnv() (*client.Client, error) {
return client.NewClientWithOpts( return client.NewClientWithOpts(
client.FromEnv, client.FromEnv,
client.WithVersion(dockerClientVersion), client.WithAPIVersionNegotiation(),
) )
} }
func CreateSimpleClient() (*client.Client, error) { func CreateSimpleClient() (*client.Client, error) {
return client.NewClientWithOpts( return client.NewClientWithOpts(
client.WithVersion(dockerClientVersion), client.WithAPIVersionNegotiation(),
) )
} }
@@ -82,7 +82,7 @@ func createTCPClient(endpoint *portainer.Endpoint, timeout *time.Duration) (*cli
return client.NewClientWithOpts( return client.NewClientWithOpts(
client.WithHost(endpoint.URL), client.WithHost(endpoint.URL),
client.WithVersion(dockerClientVersion), client.WithAPIVersionNegotiation(),
client.WithHTTPClient(httpCli), client.WithHTTPClient(httpCli),
) )
} }
@@ -116,7 +116,7 @@ func createEdgeClient(endpoint *portainer.Endpoint, signatureService portainer.D
return client.NewClientWithOpts( return client.NewClientWithOpts(
client.WithHost(endpointURL), client.WithHost(endpointURL),
client.WithVersion(dockerClientVersion), client.WithAPIVersionNegotiation(),
client.WithHTTPClient(httpCli), client.WithHTTPClient(httpCli),
client.WithHTTPHeaders(headers), client.WithHTTPHeaders(headers),
) )
@@ -144,7 +144,7 @@ func createAgentClient(endpoint *portainer.Endpoint, signatureService portainer.
return client.NewClientWithOpts( return client.NewClientWithOpts(
client.WithHost(endpoint.URL), client.WithHost(endpoint.URL),
client.WithVersion(dockerClientVersion), client.WithAPIVersionNegotiation(),
client.WithHTTPClient(httpCli), client.WithHTTPClient(httpCli),
client.WithHTTPHeaders(headers), client.WithHTTPHeaders(headers),
) )
+23 -3
View File
@@ -15,6 +15,7 @@ import (
"github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/internal/registryutils" "github.com/portainer/portainer/api/internal/registryutils"
"github.com/portainer/portainer/api/stacks/stackutils" "github.com/portainer/portainer/api/stacks/stackutils"
"github.com/rs/zerolog/log"
) )
// SwarmStackManager represents a service for managing stacks. // SwarmStackManager represents a service for managing stacks.
@@ -64,16 +65,35 @@ func (manager *SwarmStackManager) Login(registries []portainer.Registry, endpoin
if registry.Authentication { if registry.Authentication {
err = registryutils.EnsureRegTokenValid(manager.dataStore, &registry) err = registryutils.EnsureRegTokenValid(manager.dataStore, &registry)
if err != nil { if err != nil {
return err log.
Warn().
Err(err).
Str("RegistryName", registry.Name).
Msg("Failed to validate registry token. Skip logging with this registry.")
continue
} }
username, password, err := registryutils.GetRegEffectiveCredential(&registry) username, password, err := registryutils.GetRegEffectiveCredential(&registry)
if err != nil { if err != nil {
return err log.
Warn().
Err(err).
Str("RegistryName", registry.Name).
Msg("Failed to get effective credential. Skip logging with this registry.")
continue
} }
registryArgs := append(args, "login", "--username", username, "--password", password, registry.URL) registryArgs := append(args, "login", "--username", username, "--password", password, registry.URL)
runCommandAndCaptureStdErr(command, registryArgs, nil, "") err = runCommandAndCaptureStdErr(command, registryArgs, nil, "")
if err != nil {
log.
Warn().
Err(err).
Str("RegistryName", registry.Name).
Msg("Failed to login.")
}
} }
} }
-1
View File
@@ -27,7 +27,6 @@ require (
github.com/google/go-cmp v0.5.9 github.com/google/go-cmp v0.5.9
github.com/gorilla/handlers v1.5.1 github.com/gorilla/handlers v1.5.1
github.com/gorilla/mux v1.8.0 github.com/gorilla/mux v1.8.0
github.com/gorilla/securecookie v1.1.1
github.com/gorilla/websocket v1.5.0 github.com/gorilla/websocket v1.5.0
github.com/hashicorp/golang-lru v0.5.4 github.com/hashicorp/golang-lru v0.5.4
github.com/joho/godotenv v1.4.0 github.com/joho/godotenv v1.4.0
-2
View File
@@ -203,8 +203,6 @@ github.com/gorilla/handlers v1.5.1 h1:9lRY6j8DEeeBT10CvO9hGW0gmky0BprnvDI5vfhUHH
github.com/gorilla/handlers v1.5.1/go.mod h1:t8XrUpc4KVXb7HGyJ4/cEnwQiaxrX/hz1Zv/4g96P1Q= github.com/gorilla/handlers v1.5.1/go.mod h1:t8XrUpc4KVXb7HGyJ4/cEnwQiaxrX/hz1Zv/4g96P1Q=
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
@@ -50,7 +50,7 @@ func (handler *Handler) storeStackFile(stack *portainer.EdgeStack, deploymentTyp
entryPoint = stack.ManifestPath entryPoint = stack.ManifestPath
} }
_, err := handler.FileService.StoreEdgeStackFileFromBytesByVersion(stackFolder, entryPoint, stack.Version, config) _, err := handler.FileService.StoreEdgeStackFileFromBytes(stackFolder, entryPoint, config)
if err != nil { if err != nil {
return fmt.Errorf("unable to persist updated Compose file with version on disk: %w", err) return fmt.Errorf("unable to persist updated Compose file with version on disk: %w", err)
} }
@@ -294,7 +294,7 @@ func shouldReloadTLSConfiguration(endpoint *portainer.Endpoint, payload *endpoin
// When updating Docker API environment, as long as TLS is true and TLSSkipVerify is false, // When updating Docker API environment, as long as TLS is true and TLSSkipVerify is false,
// we assume that new TLS files have been uploaded and we need to reload the TLS configuration. // we assume that new TLS files have been uploaded and we need to reload the TLS configuration.
if endpoint.Type != portainer.DockerEnvironment || if endpoint.Type != portainer.DockerEnvironment ||
!strings.HasPrefix(*payload.URL, "tcp://") || (payload.URL != nil && !strings.HasPrefix(*payload.URL, "tcp://")) ||
payload.TLS == nil || !*payload.TLS { payload.TLS == nil || !*payload.TLS {
return false return false
} }
+19 -3
View File
@@ -34,6 +34,7 @@ type EnvironmentsQuery struct {
edgeCheckInPassedSeconds int edgeCheckInPassedSeconds int
edgeStackId portainer.EdgeStackID edgeStackId portainer.EdgeStackID
edgeStackStatus *portainer.EdgeStackStatusType edgeStackStatus *portainer.EdgeStackStatusType
excludeIds []portainer.EndpointID
} }
func parseQuery(r *http.Request) (EnvironmentsQuery, error) { func parseQuery(r *http.Request) (EnvironmentsQuery, error) {
@@ -69,6 +70,11 @@ func parseQuery(r *http.Request) (EnvironmentsQuery, error) {
return EnvironmentsQuery{}, err return EnvironmentsQuery{}, err
} }
excludeIDs, err := getNumberArrayQueryParameter[portainer.EndpointID](r, "excludeIds")
if err != nil {
return EnvironmentsQuery{}, err
}
agentVersions := getArrayQueryParameter(r, "agentVersions") agentVersions := getArrayQueryParameter(r, "agentVersions")
name, _ := request.RetrieveQueryParameter(r, "name", true) name, _ := request.RetrieveQueryParameter(r, "name", true)
@@ -97,6 +103,7 @@ func parseQuery(r *http.Request) (EnvironmentsQuery, error) {
types: endpointTypes, types: endpointTypes,
tagIds: tagIDs, tagIds: tagIDs,
endpointIds: endpointIDs, endpointIds: endpointIDs,
excludeIds: excludeIDs,
tagsPartialMatch: tagsPartialMatch, tagsPartialMatch: tagsPartialMatch,
groupIds: groupIDs, groupIds: groupIDs,
status: status, status: status,
@@ -118,6 +125,12 @@ func (handler *Handler) filterEndpointsByQuery(filteredEndpoints []portainer.End
filteredEndpoints = filteredEndpointsByIds(filteredEndpoints, query.endpointIds) filteredEndpoints = filteredEndpointsByIds(filteredEndpoints, query.endpointIds)
} }
if len(query.excludeIds) > 0 {
filteredEndpoints = filter(filteredEndpoints, func(endpoint portainer.Endpoint) bool {
return !slices.Contains(query.excludeIds, endpoint.ID)
})
}
if len(query.groupIds) > 0 { if len(query.groupIds) > 0 {
filteredEndpoints = filterEndpointsByGroupIDs(filteredEndpoints, query.groupIds) filteredEndpoints = filterEndpointsByGroupIDs(filteredEndpoints, query.groupIds)
} }
@@ -208,9 +221,12 @@ func endpointStatusInStackMatchesFilter(edgeStackStatus map[portainer.EndpointID
status, ok := edgeStackStatus[envId] status, ok := edgeStackStatus[envId]
// consider that if the env has no status in the stack it is in Pending state // consider that if the env has no status in the stack it is in Pending state
// workaround because Stack.Status[EnvId].Details.Pending is never set to True in the codebase if statusFilter == portainer.EdgeStackStatusPending {
if !ok && statusFilter == portainer.EdgeStackStatusPending { return !ok || len(status.Status) == 0
return true }
if !ok {
return false
} }
return slices.ContainsFunc(status.Status, func(s portainer.EdgeStackDeploymentStatus) bool { return slices.ContainsFunc(status.Status, func(s portainer.EdgeStackDeploymentStatus) bool {
+23
View File
@@ -5,6 +5,7 @@ import (
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/datastore" "github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/internal/slices"
"github.com/portainer/portainer/api/internal/testhelpers" "github.com/portainer/portainer/api/internal/testhelpers"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
@@ -124,6 +125,28 @@ func Test_Filter_edgeFilter(t *testing.T) {
runTests(tests, t, handler, endpoints) runTests(tests, t, handler, endpoints)
} }
func Test_Filter_excludeIDs(t *testing.T) {
ids := []portainer.EndpointID{1, 2, 3, 4, 5, 6, 7, 8, 9}
environments := slices.Map(ids, func(id portainer.EndpointID) portainer.Endpoint {
return portainer.Endpoint{ID: id, GroupID: 1, Type: portainer.DockerEnvironment}
})
handler := setupFilterTest(t, environments)
tests := []filterTest{
{
title: "should exclude IDs 2,5,8",
expected: []portainer.EndpointID{1, 3, 4, 6, 7, 9},
query: EnvironmentsQuery{
excludeIds: []portainer.EndpointID{2, 5, 8},
},
},
}
runTests(tests, t, handler, environments)
}
func runTests(tests []filterTest, t *testing.T, handler *Handler, endpoints []portainer.Endpoint) { func runTests(tests []filterTest, t *testing.T, handler *Handler, endpoints []portainer.Endpoint) {
for _, test := range tests { for _, test := range tests {
t.Run(test.title, func(t *testing.T) { t.Run(test.title, func(t *testing.T) {
@@ -13,6 +13,7 @@ import (
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/git/update" "github.com/portainer/portainer/api/git/update"
"github.com/portainer/portainer/api/internal/endpointutils" "github.com/portainer/portainer/api/internal/endpointutils"
"github.com/portainer/portainer/api/internal/registryutils"
k "github.com/portainer/portainer/api/kubernetes" k "github.com/portainer/portainer/api/kubernetes"
"github.com/portainer/portainer/api/stacks/deployments" "github.com/portainer/portainer/api/stacks/deployments"
"github.com/portainer/portainer/api/stacks/stackbuilders" "github.com/portainer/portainer/api/stacks/stackbuilders"
@@ -176,6 +177,14 @@ func (handler *Handler) createKubernetesStackFromFileContent(w http.ResponseWrit
handler.KubernetesDeployer, handler.KubernetesDeployer,
user) user)
// Refresh ECR registry secret if needed
// RefreshEcrSecret method checks if the namespace has any ECR registry
// otherwise return nil
cli, err := handler.KubernetesClientFactory.GetKubeClient(endpoint)
if err == nil {
registryutils.RefreshEcrSecret(cli, endpoint, handler.DataStore, payload.Namespace)
}
stackBuilderDirector := stackbuilders.NewStackBuilderDirector(k8sStackBuilder) stackBuilderDirector := stackbuilders.NewStackBuilderDirector(k8sStackBuilder)
_, httpErr := stackBuilderDirector.Build(&stackPayload, endpoint) _, httpErr := stackBuilderDirector.Build(&stackPayload, endpoint)
if httpErr != nil { if httpErr != nil {
+2 -2
View File
@@ -190,7 +190,7 @@ func (handler *Handler) deleteStack(userID portainer.UserID, stack *portainer.St
if stack.Type == portainer.DockerSwarmStack { if stack.Type == portainer.DockerSwarmStack {
stack.Name = handler.SwarmStackManager.NormalizeStackName(stack.Name) stack.Name = handler.SwarmStackManager.NormalizeStackName(stack.Name)
if stackutils.IsGitStack(stack) { if stackutils.IsRelativePathStack(stack) {
return handler.StackDeployer.UndeployRemoteSwarmStack(stack, endpoint) return handler.StackDeployer.UndeployRemoteSwarmStack(stack, endpoint)
} }
@@ -200,7 +200,7 @@ func (handler *Handler) deleteStack(userID portainer.UserID, stack *portainer.St
if stack.Type == portainer.DockerComposeStack { if stack.Type == portainer.DockerComposeStack {
stack.Name = handler.ComposeStackManager.NormalizeStackName(stack.Name) stack.Name = handler.ComposeStackManager.NormalizeStackName(stack.Name)
if stackutils.IsGitStack(stack) { if stackutils.IsRelativePathStack(stack) {
return handler.StackDeployer.UndeployRemoteComposeStack(stack, endpoint) return handler.StackDeployer.UndeployRemoteComposeStack(stack, endpoint)
} }
+21 -5
View File
@@ -117,7 +117,7 @@ func (handler *Handler) stackStart(w http.ResponseWriter, r *http.Request) *http
stack.AutoUpdate.JobID = jobID stack.AutoUpdate.JobID = jobID
} }
err = handler.startStack(stack, endpoint) err = handler.startStack(stack, endpoint, securityContext)
if err != nil { if err != nil {
return httperror.InternalServerError("Unable to start stack", err) return httperror.InternalServerError("Unable to start stack", err)
} }
@@ -136,12 +136,16 @@ func (handler *Handler) stackStart(w http.ResponseWriter, r *http.Request) *http
return response.JSON(w, stack) return response.JSON(w, stack)
} }
func (handler *Handler) startStack(stack *portainer.Stack, endpoint *portainer.Endpoint) error { func (handler *Handler) startStack(
stack *portainer.Stack,
endpoint *portainer.Endpoint,
securityContext *security.RestrictedRequestContext,
) error {
switch stack.Type { switch stack.Type {
case portainer.DockerComposeStack: case portainer.DockerComposeStack:
stack.Name = handler.ComposeStackManager.NormalizeStackName(stack.Name) stack.Name = handler.ComposeStackManager.NormalizeStackName(stack.Name)
if stackutils.IsGitStack(stack) { if stackutils.IsRelativePathStack(stack) {
return handler.StackDeployer.StartRemoteComposeStack(stack, endpoint) return handler.StackDeployer.StartRemoteComposeStack(stack, endpoint)
} }
@@ -149,11 +153,23 @@ func (handler *Handler) startStack(stack *portainer.Stack, endpoint *portainer.E
case portainer.DockerSwarmStack: case portainer.DockerSwarmStack:
stack.Name = handler.SwarmStackManager.NormalizeStackName(stack.Name) stack.Name = handler.SwarmStackManager.NormalizeStackName(stack.Name)
if stackutils.IsGitStack(stack) { if stackutils.IsRelativePathStack(stack) {
return handler.StackDeployer.StartRemoteSwarmStack(stack, endpoint) return handler.StackDeployer.StartRemoteSwarmStack(stack, endpoint)
} }
return handler.SwarmStackManager.Deploy(stack, true, true, endpoint) user, err := handler.DataStore.User().Read(securityContext.UserID)
if err != nil {
return fmt.Errorf("unable to load user information from the database: %w", err)
}
registries, err := handler.DataStore.Registry().ReadAll()
if err != nil {
return fmt.Errorf("unable to retrieve registries from the database: %w", err)
}
filteredRegistries := security.FilterRegistries(registries, user, securityContext.UserMemberships, endpoint.ID)
return handler.StackDeployer.DeploySwarmStack(stack, endpoint, filteredRegistries, true, true)
} }
return nil return nil
+2 -2
View File
@@ -125,7 +125,7 @@ func (handler *Handler) stopStack(stack *portainer.Stack, endpoint *portainer.En
case portainer.DockerComposeStack: case portainer.DockerComposeStack:
stack.Name = handler.ComposeStackManager.NormalizeStackName(stack.Name) stack.Name = handler.ComposeStackManager.NormalizeStackName(stack.Name)
if stackutils.IsGitStack(stack) { if stackutils.IsRelativePathStack(stack) {
return handler.StackDeployer.StopRemoteComposeStack(stack, endpoint) return handler.StackDeployer.StopRemoteComposeStack(stack, endpoint)
} }
@@ -133,7 +133,7 @@ func (handler *Handler) stopStack(stack *portainer.Stack, endpoint *portainer.En
case portainer.DockerSwarmStack: case portainer.DockerSwarmStack:
stack.Name = handler.SwarmStackManager.NormalizeStackName(stack.Name) stack.Name = handler.SwarmStackManager.NormalizeStackName(stack.Name)
if stackutils.IsGitStack(stack) { if stackutils.IsRelativePathStack(stack) {
return handler.StackDeployer.StopRemoteSwarmStack(stack, endpoint) return handler.StackDeployer.StopRemoteSwarmStack(stack, endpoint)
} }
+10
View File
@@ -198,6 +198,11 @@ func (handler *Handler) updateComposeStack(r *http.Request, stack *portainer.Sta
stack.Env = payload.Env stack.Env = payload.Env
if stack.GitConfig != nil {
// detach from git
stack.GitConfig = nil
}
stackFolder := strconv.Itoa(int(stack.ID)) stackFolder := strconv.Itoa(int(stack.ID))
_, err = handler.FileService.UpdateStoreStackFileFromBytes(stackFolder, stack.EntryPoint, []byte(payload.StackFileContent)) _, err = handler.FileService.UpdateStoreStackFileFromBytes(stackFolder, stack.EntryPoint, []byte(payload.StackFileContent))
if err != nil { if err != nil {
@@ -263,6 +268,11 @@ func (handler *Handler) updateSwarmStack(r *http.Request, stack *portainer.Stack
stack.Env = payload.Env stack.Env = payload.Env
if stack.GitConfig != nil {
// detach from git
stack.GitConfig = nil
}
stackFolder := strconv.Itoa(int(stack.ID)) stackFolder := strconv.Itoa(int(stack.ID))
_, err = handler.FileService.UpdateStoreStackFileFromBytes(stackFolder, stack.EntryPoint, []byte(payload.StackFileContent)) _, err = handler.FileService.UpdateStoreStackFileFromBytes(stackFolder, stack.EntryPoint, []byte(payload.StackFileContent))
if err != nil { if err != nil {
@@ -13,6 +13,7 @@ import (
gittypes "github.com/portainer/portainer/api/git/types" gittypes "github.com/portainer/portainer/api/git/types"
"github.com/portainer/portainer/api/git/update" "github.com/portainer/portainer/api/git/update"
"github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/registryutils"
k "github.com/portainer/portainer/api/kubernetes" k "github.com/portainer/portainer/api/kubernetes"
"github.com/portainer/portainer/api/stacks/deployments" "github.com/portainer/portainer/api/stacks/deployments"
@@ -113,6 +114,14 @@ func (handler *Handler) updateKubernetesStack(r *http.Request, stack *portainer.
return httperror.InternalServerError("Failed to persist deployment file in a temp directory", err) return httperror.InternalServerError("Failed to persist deployment file in a temp directory", err)
} }
// Refresh ECR registry secret if needed
// RefreshEcrSecret method checks if the namespace has any ECR registry
// otherwise return nil
cli, err := handler.KubernetesClientFactory.GetKubeClient(endpoint)
if err == nil {
registryutils.RefreshEcrSecret(cli, endpoint, handler.DataStore, stack.Namespace)
}
//use temp dir as the stack project path for deployment //use temp dir as the stack project path for deployment
//so if the deployment failed, the original file won't be over-written //so if the deployment failed, the original file won't be over-written
stack.ProjectPath = tempFileDir stack.ProjectPath = tempFileDir
@@ -6,7 +6,7 @@ import (
func (transport *baseTransport) proxyDeploymentsRequest(request *http.Request, namespace, requestPath string) (*http.Response, error) { func (transport *baseTransport) proxyDeploymentsRequest(request *http.Request, namespace, requestPath string) (*http.Response, error) {
switch request.Method { switch request.Method {
case http.MethodPost, http.MethodPatch: case http.MethodPost, http.MethodPatch, http.MethodPut:
transport.refreshRegistry(request, namespace) transport.refreshRegistry(request, namespace)
} }
+16
View File
@@ -0,0 +1,16 @@
package securecookie
import (
"crypto/rand"
"io"
)
// GenerateRandomKey generates a random key of specified length
// source: https://github.com/gorilla/securecookie/blob/master/securecookie.go#L515
func GenerateRandomKey(length int) []byte {
k := make([]byte, length)
if _, err := io.ReadFull(rand.Reader, k); err != nil {
return nil
}
return k
}
+9
View File
@@ -63,3 +63,12 @@ func RemoveIndex[T any](s []T, index int) []T {
s[index] = s[len(s)-1] s[index] = s[len(s)-1]
return s[:len(s)-1] return s[:len(s)-1]
} }
// Map applies the given function to each element of the slice and returns a new slice with the results
func Map[T, U any](s []T, f func(T) U) []U {
result := make([]U, len(s))
for i, v := range s {
result[i] = f(v)
}
return result
}
+4 -1
View File
@@ -90,7 +90,10 @@ func (service *service) upgradeDocker(licenseKey, version, envType string) error
} }
func (service *service) checkImageForDocker(ctx context.Context, image string, skipPullImage bool) error { func (service *service) checkImageForDocker(ctx context.Context, image string, skipPullImage bool) error {
cli, err := client.NewClientWithOpts(client.FromEnv) cli, err := client.NewClientWithOpts(
client.FromEnv,
client.WithAPIVersionNegotiation(),
)
if err != nil { if err != nil {
return errors.Wrap(err, "failed to create docker client") return errors.Wrap(err, "failed to create docker client")
} }
+1 -1
View File
@@ -9,7 +9,7 @@ import (
"github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/dataservices"
"github.com/golang-jwt/jwt/v4" "github.com/golang-jwt/jwt/v4"
"github.com/gorilla/securecookie" "github.com/portainer/portainer/api/internal/securecookie"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
) )
+2
View File
@@ -301,6 +301,8 @@ type (
// StackDeploymentInfo records the information of a deployed stack // StackDeploymentInfo records the information of a deployed stack
StackDeploymentInfo struct { StackDeploymentInfo struct {
// Version is the version of the stack and also is the deployed version in edge agent
Version int `json:"Version"`
// FileVersion is the version of the stack file, used to detect changes // FileVersion is the version of the stack file, used to detect changes
FileVersion int `json:"FileVersion"` FileVersion int `json:"FileVersion"`
// ConfigHash is the commit hash of the git repository used for deploying the stack // ConfigHash is the commit hash of the git repository used for deploying the stack
+2 -2
View File
@@ -85,7 +85,7 @@ func RedeployWhenChanged(stackID portainer.StackID, deployer StackDeployer, data
switch stack.Type { switch stack.Type {
case portainer.DockerComposeStack: case portainer.DockerComposeStack:
if stackutils.IsGitStack(stack) { if stackutils.IsRelativePathStack(stack) {
err = deployer.DeployRemoteComposeStack(stack, endpoint, registries, true, false) err = deployer.DeployRemoteComposeStack(stack, endpoint, registries, true, false)
} else { } else {
err = deployer.DeployComposeStack(stack, endpoint, registries, true, false) err = deployer.DeployComposeStack(stack, endpoint, registries, true, false)
@@ -95,7 +95,7 @@ func RedeployWhenChanged(stackID portainer.StackID, deployer StackDeployer, data
return errors.WithMessagef(err, "failed to deploy a docker compose stack %v", stackID) return errors.WithMessagef(err, "failed to deploy a docker compose stack %v", stackID)
} }
case portainer.DockerSwarmStack: case portainer.DockerSwarmStack:
if stackutils.IsGitStack(stack) { if stackutils.IsRelativePathStack(stack) {
err = deployer.DeployRemoteSwarmStack(stack, endpoint, registries, true, true) err = deployer.DeployRemoteSwarmStack(stack, endpoint, registries, true, true)
} else { } else {
err = deployer.DeploySwarmStack(stack, endpoint, registries, true, true) err = deployer.DeploySwarmStack(stack, endpoint, registries, true, true)
+28 -3
View File
@@ -1,7 +1,9 @@
package deployments package deployments
import ( import (
"bytes"
"context" "context"
"encoding/json"
"fmt" "fmt"
"io" "io"
"math/rand" "math/rand"
@@ -12,6 +14,7 @@ import (
"github.com/docker/docker/api/types" "github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/swarm" "github.com/docker/docker/api/types/swarm"
"github.com/docker/docker/pkg/stdcopy"
"github.com/pkg/errors" "github.com/pkg/errors"
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/filesystem" "github.com/portainer/portainer/api/filesystem"
@@ -184,16 +187,18 @@ func (d *stackDeployer) remoteStack(stack *portainer.Stack, endpoint *portainer.
case <-statusCh: case <-statusCh:
} }
stdErr := &bytes.Buffer{}
out, err := cli.ContainerLogs(ctx, unpackerContainer.ID, types.ContainerLogsOptions{ShowStdout: true, ShowStderr: true}) out, err := cli.ContainerLogs(ctx, unpackerContainer.ID, types.ContainerLogsOptions{ShowStdout: true, ShowStderr: true})
if err != nil { if err != nil {
log.Error().Err(err).Msg("unable to get logs from unpacker container") log.Error().Err(err).Msg("unable to get logs from unpacker container")
} else { } else {
outputBytes, err := io.ReadAll(out) _, err = stdcopy.StdCopy(io.Discard, stdErr, out)
if err != nil { if err != nil {
log.Error().Err(err).Msg("unable to parse logs from unpacker container") log.Warn().Err(err).Msg("unable to parse logs from unpacker container")
} else { } else {
log.Info(). log.Info().
Str("output", string(outputBytes)). Str("output", stdErr.String()).
Msg("Stack deployment output") Msg("Stack deployment output")
} }
} }
@@ -204,6 +209,26 @@ func (d *stackDeployer) remoteStack(stack *portainer.Stack, endpoint *portainer.
} }
if status.State.ExitCode != 0 { if status.State.ExitCode != 0 {
dec := json.NewDecoder(stdErr)
for {
errorStruct := struct {
Level string
Error string
}{}
if err := dec.Decode(&errorStruct); errors.Is(err, io.EOF) {
break
} else if err != nil {
log.Warn().Err(err).Msg("unable to parse logs from unpacker container")
continue
}
if errorStruct.Level == "error" {
return fmt.Errorf("an error occurred while running unpacker container with exit code %d: %s", status.State.ExitCode, errorStruct.Error)
}
}
return fmt.Errorf("an error occurred while running unpacker container with exit code %d", status.State.ExitCode) return fmt.Errorf("an error occurred while running unpacker container with exit code %d", status.State.ExitCode)
} }
@@ -84,7 +84,7 @@ func (config *ComposeStackDeploymentConfig) Deploy() error {
return err return err
} }
} }
if stackutils.IsGitStack(config.stack) { if stackutils.IsRelativePathStack(config.stack) {
return config.StackDeployer.DeployRemoteComposeStack(config.stack, config.endpoint, config.registries, config.forcePullImage, config.ForceCreate) return config.StackDeployer.DeployRemoteComposeStack(config.stack, config.endpoint, config.registries, config.forcePullImage, config.ForceCreate)
} }
@@ -78,7 +78,7 @@ func (config *SwarmStackDeploymentConfig) Deploy() error {
} }
} }
if stackutils.IsGitStack(config.stack) { if stackutils.IsRelativePathStack(config.stack) {
return config.StackDeployer.DeployRemoteSwarmStack(config.stack, config.endpoint, config.registries, config.prune, config.pullImage) return config.StackDeployer.DeployRemoteSwarmStack(config.stack, config.endpoint, config.registries, config.prune, config.pullImage)
} }
+7
View File
@@ -47,3 +47,10 @@ func SanitizeLabel(value string) string {
func IsGitStack(stack *portainer.Stack) bool { func IsGitStack(stack *portainer.Stack) bool {
return stack.GitConfig != nil && len(stack.GitConfig.URL) != 0 return stack.GitConfig != nil && len(stack.GitConfig.URL) != 0
} }
// IsRelativePathStack checks if the stack is a git stack or not
func IsRelativePathStack(stack *portainer.Stack) bool {
// Always return false in CE
// This function is only for code consistency with EE
return false
}
+34 -21
View File
@@ -1,6 +1,7 @@
import _ from 'lodash-es'; import _ from 'lodash-es';
import { PorImageRegistryModel } from 'Docker/models/porImageRegistry'; import { PorImageRegistryModel } from 'Docker/models/porImageRegistry';
import { confirmImageExport } from '@/react/docker/images/common/ConfirmExportModal'; import { confirmImageExport } from '@/react/docker/images/common/ConfirmExportModal';
import { confirmDelete } from '@@/modals/confirm';
angular.module('portainer.docker').controller('ImageController', [ angular.module('portainer.docker').controller('ImageController', [
'$async', '$async',
@@ -120,30 +121,42 @@ angular.module('portainer.docker').controller('ImageController', [
} }
$scope.removeTag = function (repository) { $scope.removeTag = function (repository) {
ImageService.deleteImage(repository, false) return $async(async () => {
.then(function success() { if (!(await confirmDelete('Are you sure you want to delete this tag?'))) {
if ($scope.image.RepoTags.length === 1) { return;
Notifications.success('Image successfully deleted', repository); }
$state.go('docker.images', {}, { reload: true });
} else { ImageService.deleteImage(repository, false)
Notifications.success('Tag successfully deleted', repository); .then(function success() {
$state.go('docker.images.image', { id: $transition$.params().id }, { reload: true }); if ($scope.image.RepoTags.length === 1) {
} Notifications.success('Image successfully deleted', repository);
}) $state.go('docker.images', {}, { reload: true });
.catch(function error(err) { } else {
Notifications.error('Failure', err, 'Unable to remove image'); Notifications.success('Tag successfully deleted', repository);
}); $state.go('docker.images.image', { id: $transition$.params().id }, { reload: true });
}
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to remove image');
});
});
}; };
$scope.removeImage = function (id) { $scope.removeImage = function (id) {
ImageService.deleteImage(id, false) return $async(async () => {
.then(function success() { if (!(await confirmDelete('Deleting this image will also delete all associated tags. Are you sure you want to delete this image?'))) {
Notifications.success('Image successfully deleted', id); return;
$state.go('docker.images', {}, { reload: true }); }
})
.catch(function error(err) { ImageService.deleteImage(id, false)
Notifications.error('Failure', err, 'Unable to remove image'); .then(function success() {
}); Notifications.success('Image successfully deleted', id);
$state.go('docker.images', {}, { reload: true });
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to remove image');
});
});
}; };
function exportImage(image) { function exportImage(image) {
+3 -2
View File
@@ -57,7 +57,8 @@ angular.module('portainer.docker').controller('ImagesController', [
function confirmImageForceRemoval() { function confirmImageForceRemoval() {
return confirmDestructive({ return confirmDestructive({
title: 'Are you sure?', title: 'Are you sure?',
message: 'Forcing the removal of the image will remove the image even if it has multiple tags or if it is used by stopped containers.', message:
"Forcing removal of an image will remove it even if it's used by stopped containers, and delete all associated tags. Are you sure you want to remove the selected image(s)?",
confirmButton: buildConfirmButton('Remove the image', 'danger'), confirmButton: buildConfirmButton('Remove the image', 'danger'),
}); });
} }
@@ -65,7 +66,7 @@ angular.module('portainer.docker').controller('ImagesController', [
function confirmRegularRemove() { function confirmRegularRemove() {
return confirmDestructive({ return confirmDestructive({
title: 'Are you sure?', title: 'Are you sure?',
message: 'Removing the image will remove all tags associated to that image. Are you sure you want to remove the image?', message: 'Removing an image will also delete all associated tags. Are you sure you want to remove the selected image(s)?',
confirmButton: buildConfirmButton('Remove the image', 'danger'), confirmButton: buildConfirmButton('Remove the image', 'danger'),
}); });
} }
+5
View File
@@ -72,6 +72,11 @@ angular
component: 'editEdgeStackView', component: 'editEdgeStackView',
}, },
}, },
params: {
status: {
dynamic: true,
},
},
}; };
const edgeJobs = { const edgeJobs = {
-1
View File
@@ -92,7 +92,6 @@ export const componentsModule = angular
'query', 'query',
'title', 'title',
'data-cy', 'data-cy',
'hideEnvironmentIds',
]) ])
) )
.component( .component(
@@ -1,4 +1,5 @@
import _ from 'lodash-es'; import _ from 'lodash-es';
import { confirmDelete } from '@@/modals/confirm';
export class EdgeGroupsController { export class EdgeGroupsController {
/* @ngInject */ /* @ngInject */
@@ -26,6 +27,10 @@ export class EdgeGroupsController {
} }
async removeActionAsync(selectedItems) { async removeActionAsync(selectedItems) {
if (!(await confirmDelete('Do you want to remove the selected Edge Group(s)?'))) {
return;
}
for (let item of selectedItems) { for (let item of selectedItems) {
try { try {
await this.EdgeGroupService.remove(item.Id); await this.EdgeGroupService.remove(item.Id);
@@ -15,7 +15,7 @@ export class EdgeJobsViewController {
} }
removeAction(selectedItems) { removeAction(selectedItems) {
confirmDelete('Do you want to remove the selected edge job(s)?').then((confirmed) => { confirmDelete('Do you want to remove the selected Edge job(s)?').then((confirmed) => {
if (!confirmed) { if (!confirmed) {
return; return;
} }
@@ -14,9 +14,13 @@
<pr-icon icon="'info'" mode="'primary'"></pr-icon> <pr-icon icon="'info'" mode="'primary'"></pr-icon>
Switch to advanced mode to copy and paste multiple key/values Switch to advanced mode to copy and paste multiple key/values
</div> </div>
<div class="col-sm-12 small text-muted vertical-center" ng-if="!$ctrl.formValues.IsSimple"> <div class="col-sm-12 small text-muted vertical-center" ng-if="!$ctrl.formValues.IsSimple && $ctrl.type === 'configmap'">
<pr-icon icon="'info'" mode="'primary'"></pr-icon> <pr-icon icon="'info'" mode="'primary'"></pr-icon>
Generate a configuration entry per line, use YAML format Generate a ConfigMap entry per line, use YAML format
</div>
<div class="col-sm-12 small text-muted vertical-center" ng-if="!$ctrl.formValues.IsSimple && $ctrl.type === 'secret'">
<pr-icon icon="'info'" mode="'primary'"></pr-icon>
Generate a Secret entry per line, use YAML format
</div> </div>
</div> </div>
@@ -8,5 +8,6 @@ angular.module('portainer.kubernetes').component('kubernetesConfigurationData',
isValid: '=', isValid: '=',
isCreation: '=', isCreation: '=',
isEditorDirty: '=', isEditorDirty: '=',
type: '<',
}, },
}); });
+1 -2
View File
@@ -16,7 +16,6 @@ import {
ApplicationSummaryWidget, ApplicationSummaryWidget,
ApplicationDetailsWidget, ApplicationDetailsWidget,
} from '@/react/kubernetes/applications/DetailsView'; } from '@/react/kubernetes/applications/DetailsView';
import { withUserProvider } from '@/react/test-utils/withUserProvider';
import { withFormValidation } from '@/react-tools/withFormValidation'; import { withFormValidation } from '@/react-tools/withFormValidation';
import { withCurrentUser } from '@/react-tools/withCurrentUser'; import { withCurrentUser } from '@/react-tools/withCurrentUser';
@@ -104,7 +103,7 @@ export const ngModule = angular
.component( .component(
'applicationDetailsWidget', 'applicationDetailsWidget',
r2a( r2a(
withUIRouter(withReactQuery(withUserProvider(ApplicationDetailsWidget))), withUIRouter(withReactQuery(withCurrentUser(ApplicationDetailsWidget))),
[] []
) )
); );
@@ -352,7 +352,7 @@
<!-- #region CONFIGMAPS --> <!-- #region CONFIGMAPS -->
<div class="form-group"> <div class="form-group">
<div class="col-sm-12 vertical-center"> <div class="col-sm-12 vertical-center">
<label class="control-label !pt-0 text-left">ConfigMap</label> <label class="control-label !pt-0 text-left">ConfigMaps</label>
</div> </div>
<div class="col-sm-12 small text-muted vertical-center" style="margin-top: 15px" ng-if="ctrl.formValues.ConfigMaps.length"> <div class="col-sm-12 small text-muted vertical-center" style="margin-top: 15px" ng-if="ctrl.formValues.ConfigMaps.length">
<pr-icon icon="'info'" mode="'primary'"></pr-icon> <pr-icon icon="'info'" mode="'primary'"></pr-icon>
@@ -503,7 +503,7 @@
<!-- #region SECRETS --> <!-- #region SECRETS -->
<div class="form-group"> <div class="form-group">
<div class="col-sm-12 vertical-center pt-2.5"> <div class="col-sm-12 vertical-center pt-2.5">
<label class="control-label !pt-0 text-left">Secret</label> <label class="control-label !pt-0 text-left">Secrets</label>
</div> </div>
<div class="col-sm-12 small text-muted vertical-center" style="margin-top: 15px" ng-if="ctrl.formValues.Secrets.length"> <div class="col-sm-12 small text-muted vertical-center" style="margin-top: 15px" ng-if="ctrl.formValues.Secrets.length">
<pr-icon icon="'info'" mode="'primary'"></pr-icon> <pr-icon icon="'info'" mode="'primary'"></pr-icon>
@@ -26,95 +26,93 @@
<kubernetes-view-loading view-ready="ctrl.state.viewReady"></kubernetes-view-loading> <kubernetes-view-loading view-ready="ctrl.state.viewReady"></kubernetes-view-loading>
<div ng-if="ctrl.state.viewReady"> <information-panel ng-if="!ctrl.state.getMetrics" title-text="Unable to retrieve container metrics">
<information-panel ng-if="!ctrl.state.getMetrics" title-text="Unable to retrieve container metrics"> <span class="small text-warning vertical-center">
<span class="small text-warning vertical-center"> <pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon>
<pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon> Portainer was unable to retrieve any metrics associated to that container. Please contact your administrator to ensure that the Kubernetes metrics feature is properly
Portainer was unable to retrieve any metrics associated to that container. Please contact your administrator to ensure that the Kubernetes metrics feature is properly configured.
configured. </span>
</span> </information-panel>
</information-panel> <div class="row" ng-if="ctrl.state.getMetrics">
<div class="row" ng-if="ctrl.state.getMetrics"> <div class="col-md-12">
<div class="col-md-12"> <rd-widget>
<rd-widget> <div class="toolBar px-5 pt-5">
<div class="toolBar px-5 pt-5"> <div class="toolBarTitle flex">
<div class="toolBarTitle flex"> <div class="widget-icon space-right">
<div class="widget-icon space-right"> <pr-icon icon="'info'"></pr-icon>
<pr-icon icon="'info'"></pr-icon>
</div>
<span class="vertical-center"> About statistics </span>
</div> </div>
<span class="vertical-center"> About statistics </span>
</div> </div>
<rd-widget-body> </div>
<form class="form-horizontal"> <rd-widget-body>
<div class="form-group"> <form class="form-horizontal">
<div class="col-sm-12"> <div class="form-group">
<span class="small text-warning"> <div class="col-sm-12">
This view displays real-time statistics about the container <b>{{ ctrl.state.transition.containerName | trimcontainername }}</b <span class="small text-warning">
>. This view displays real-time statistics about the container <b>{{ ctrl.state.transition.containerName | trimcontainername }}</b
</span> >.
</div>
</div>
<div class="form-group">
<label for="refreshRate" class="col-sm-3 col-md-2 col-lg-2 margin-sm-top control-label text-left"> Refresh rate </label>
<div class="col-sm-3 col-md-2">
<select id="refreshRate" ng-model="ctrl.state.refreshRate" ng-change="ctrl.changeUpdateRepeater()" class="form-control">
<option value="30">30s</option>
<option value="60">60s</option>
</select>
</div>
<span>
<pr-icon id="refreshRateChange" icon="'check'" mode="'success'" size="'sm'"></pr-icon>
</span> </span>
</div> </div>
<div class="form-group" ng-if="ctrl.state.networkStatsUnavailable"> </div>
<div class="col-sm-12"> <div class="form-group">
<span class="small text-muted"> <label for="refreshRate" class="col-sm-3 col-md-2 col-lg-2 margin-sm-top control-label text-left"> Refresh rate </label>
<pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon> <div class="col-sm-3 col-md-2">
Network stats are unavailable for this container. <select id="refreshRate" ng-model="ctrl.state.refreshRate" ng-change="ctrl.changeUpdateRepeater()" class="form-control">
</span> <option value="30">30s</option>
</div> <option value="60">60s</option>
</select>
</div> </div>
</form> <span>
</rd-widget-body> <pr-icon id="refreshRateChange" icon="'check'" mode="'success'" size="'sm'"></pr-icon>
</rd-widget> </span>
</div> </div>
</div> <div class="form-group" ng-if="ctrl.state.networkStatsUnavailable">
<div class="col-sm-12">
<div class="row" ng-if="ctrl.state.getMetrics"> <span class="small text-muted">
<div class="col-lg-6 col-md-12 col-sm-12"> <pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon>
<rd-widget> Network stats are unavailable for this container.
<div class="toolBar px-5 pt-5"> </span>
<div class="toolBarTitle flex">
<div class="widget-icon space-right">
<pr-icon icon="'svg-memory'"></pr-icon>
</div> </div>
<span class="vertical-center"> Memory usage </span>
</div> </div>
</div> </form>
<rd-widget-body> </rd-widget-body>
<div class="chart-container" style="position: relative"> </rd-widget>
<canvas id="memoryChart" width="770" height="300"></canvas> </div>
</div> </div>
</rd-widget-body>
</rd-widget> <div class="row" ng-if="ctrl.state.getMetrics">
</div> <div class="col-lg-6 col-md-12 col-sm-12">
<div class="col-lg-6 col-md-12 col-sm-12" ng-if="!ctrl.state.networkStatsUnavailable"> <rd-widget>
<rd-widget> <div class="toolBar px-5 pt-5">
<div class="toolBar px-5 pt-5"> <div class="toolBarTitle flex">
<div class="toolBarTitle flex"> <div class="widget-icon space-right">
<div class="widget-icon space-right"> <pr-icon icon="'svg-memory'"></pr-icon>
<pr-icon icon="'cpu'"></pr-icon> </div>
</div> <span class="vertical-center"> Memory usage </span>
<span class="vertical-center"> CPU usage </span> </div>
</div> </div>
</div> <rd-widget-body>
<rd-widget-body> <div class="chart-container" style="position: relative">
<div class="chart-container" style="position: relative"> <canvas id="memoryChart" width="770" height="300"></canvas>
<canvas id="cpuChart" width="770" height="300"></canvas> </div>
</div> </rd-widget-body>
</rd-widget-body> </rd-widget>
</rd-widget> </div>
</div> <div class="col-lg-6 col-md-12 col-sm-12" ng-if="!ctrl.state.networkStatsUnavailable">
<rd-widget>
<div class="toolBar px-5 pt-5">
<div class="toolBarTitle flex">
<div class="widget-icon space-right">
<pr-icon icon="'cpu'"></pr-icon>
</div>
<span class="vertical-center"> CPU usage </span>
</div>
</div>
<rd-widget-body>
<div class="chart-container" style="position: relative">
<canvas id="cpuChart" width="770" height="300"></canvas>
</div>
</rd-widget-body>
</rd-widget>
</div> </div>
</div> </div>
@@ -19,6 +19,7 @@ class KubernetesApplicationStatsController {
this.ChartService = ChartService; this.ChartService = ChartService;
this.onInit = this.onInit.bind(this); this.onInit = this.onInit.bind(this);
this.initCharts = this.initCharts.bind(this);
} }
changeUpdateRepeater() { changeUpdateRepeater() {
@@ -68,17 +69,26 @@ class KubernetesApplicationStatsController {
} }
initCharts() { initCharts() {
const cpuChartCtx = $('#cpuChart'); let i = 0;
const cpuChart = this.ChartService.CreateCPUChart(cpuChartCtx); const findCharts = setInterval(() => {
this.cpuChart = cpuChart; let cpuChartCtx = $('#cpuChart');
let memoryChartCtx = $('#memoryChart');
const memoryChartCtx = $('#memoryChart'); if (cpuChartCtx.length !== 0 && memoryChartCtx.length !== 0) {
const memoryChart = this.ChartService.CreateMemoryChart(memoryChartCtx); const cpuChart = this.ChartService.CreateCPUChart(cpuChartCtx);
this.memoryChart = memoryChart; this.cpuChart = cpuChart;
const memoryChart = this.ChartService.CreateMemoryChart(memoryChartCtx);
this.updateCPUChart(); this.memoryChart = memoryChart;
this.updateMemoryChart(); this.updateCPUChart();
this.setUpdateRepeater(); this.updateMemoryChart();
this.setUpdateRepeater();
clearInterval(findCharts);
return;
}
i++;
if (i >= 10) {
clearInterval(findCharts);
}
}, 200);
} }
getStats() { getStats() {
@@ -15,86 +15,84 @@
<kubernetes-view-loading view-ready="ctrl.state.viewReady"></kubernetes-view-loading> <kubernetes-view-loading view-ready="ctrl.state.viewReady"></kubernetes-view-loading>
<div ng-if="ctrl.state.viewReady"> <information-panel ng-if="!ctrl.state.getMetrics" title-text="Unable to retrieve node metrics">
<information-panel ng-if="!ctrl.state.getMetrics" title-text="Unable to retrieve node metrics"> <span class="small text-muted vertical-center">
<span class="small text-muted vertical-center"> <pr-icon icon="'alert-triangle'" mode="'primary'"></pr-icon>
<pr-icon icon="'alert-triangle'" mode="'primary'"></pr-icon> Portainer was unable to retrieve any metrics associated to that node. Please contact your administrator to ensure that the Kubernetes metrics feature is properly configured.
Portainer was unable to retrieve any metrics associated to that node. Please contact your administrator to ensure that the Kubernetes metrics feature is properly configured. </span>
</span> </information-panel>
</information-panel> <div class="row" ng-if="ctrl.state.getMetrics">
<div class="row" ng-if="ctrl.state.getMetrics"> <div class="col-md-12">
<div class="col-md-12"> <rd-widget>
<rd-widget> <div class="toolBar px-5 pt-5">
<div class="toolBar px-5 pt-5"> <div class="toolBarTitle flex">
<div class="toolBarTitle flex"> <div class="widget-icon space-right">
<div class="widget-icon space-right"> <pr-icon icon="'info'"></pr-icon>
<pr-icon icon="'info'"></pr-icon>
</div>
<span class="vertical-center"> About statistics </span>
</div> </div>
<span class="vertical-center"> About statistics </span>
</div> </div>
<rd-widget-body> </div>
<form class="form-horizontal"> <rd-widget-body>
<div class="form-group"> <form class="form-horizontal">
<div class="col-sm-12"> <div class="form-group">
<span class="small text-muted"> <div class="col-sm-12">
This view displays real-time statistics about the node <b>{{ ctrl.state.transition.nodeName }}</b <span class="small text-muted">
>. This view displays real-time statistics about the node <b>{{ ctrl.state.transition.nodeName }}</b
</span> >.
</div>
</div>
<div class="form-group">
<label for="refreshRate" class="col-sm-3 col-md-2 col-lg-2 margin-sm-top control-label text-left"> Refresh rate </label>
<div class="col-sm-3 col-md-2">
<select id="refreshRate" ng-model="ctrl.state.refreshRate" ng-change="ctrl.changeUpdateRepeater()" class="form-control">
<option value="30">30s</option>
<option value="60">60s</option>
</select>
</div>
<span>
<pr-icon id="refreshRateChange" icon="'check'" mode="'success'" style="display: none"></pr-icon>
</span> </span>
</div> </div>
</form> </div>
</rd-widget-body> <div class="form-group">
</rd-widget> <label for="refreshRate" class="col-sm-3 col-md-2 col-lg-2 margin-sm-top control-label text-left"> Refresh rate </label>
</div> <div class="col-sm-3 col-md-2">
</div> <select id="refreshRate" ng-model="ctrl.state.refreshRate" ng-change="ctrl.changeUpdateRepeater()" class="form-control">
<option value="30">30s</option>
<div class="row" ng-show="ctrl.state.getMetrics"> <option value="60">60s</option>
<div class="col-lg-6 col-md-12 col-sm-12"> </select>
<rd-widget>
<div class="toolBar px-5 pt-5">
<div class="toolBarTitle flex">
<div class="widget-icon space-right">
<pr-icon icon="'svg-memory'"></pr-icon>
</div> </div>
<span class="vertical-center"> Memory usage </span> <span>
<pr-icon id="refreshRateChange" icon="'check'" mode="'success'" style="display: none"></pr-icon>
</span>
</div> </div>
</div> </form>
<rd-widget-body> </rd-widget-body>
<div class="chart-node" style="position: relative"> </rd-widget>
<canvas id="memoryChart" width="770" height="300"></canvas> </div>
</div> </div>
</rd-widget-body>
</rd-widget> <div class="row" ng-show="ctrl.state.getMetrics">
</div> <div class="col-lg-6 col-md-12 col-sm-12">
<div class="col-lg-6 col-md-12 col-sm-12"> <rd-widget>
<rd-widget> <div class="toolBar px-5 pt-5">
<div class="toolBar px-5 pt-5"> <div class="toolBarTitle flex">
<div class="toolBarTitle flex"> <div class="widget-icon space-right">
<div class="widget-icon space-right"> <pr-icon icon="'svg-memory'"></pr-icon>
<pr-icon icon="'cpu'"></pr-icon> </div>
</div> <span class="vertical-center"> Memory usage </span>
<span class="vertical-center"> CPU usage </span> </div>
</div> </div>
</div> <rd-widget-body>
<rd-widget-body> <div class="chart-node" style="position: relative">
<div class="chart-node" style="position: relative"> <canvas id="memoryChart" width="770" height="300"></canvas>
<canvas id="cpuChart" width="770" height="300"></canvas> </div>
</div> </rd-widget-body>
</rd-widget-body> </rd-widget>
</rd-widget> </div>
</div> <div class="col-lg-6 col-md-12 col-sm-12">
<rd-widget>
<div class="toolBar px-5 pt-5">
<div class="toolBarTitle flex">
<div class="widget-icon space-right">
<pr-icon icon="'cpu'"></pr-icon>
</div>
<span class="vertical-center"> CPU usage </span>
</div>
</div>
<rd-widget-body>
<div class="chart-node" style="position: relative">
<canvas id="cpuChart" width="770" height="300"></canvas>
</div>
</rd-widget-body>
</rd-widget>
</div> </div>
</div> </div>
@@ -17,6 +17,7 @@ class KubernetesNodeStatsController {
this.ChartService = ChartService; this.ChartService = ChartService;
this.onInit = this.onInit.bind(this); this.onInit = this.onInit.bind(this);
this.initCharts = this.initCharts.bind(this);
} }
changeUpdateRepeater() { changeUpdateRepeater() {
@@ -63,17 +64,20 @@ class KubernetesNodeStatsController {
} }
initCharts() { initCharts() {
const cpuChartCtx = $('#cpuChart'); const findCharts = setInterval(() => {
const cpuChart = this.ChartService.CreateCPUChart(cpuChartCtx); let cpuChartCtx = $('#cpuChart');
this.cpuChart = cpuChart; let memoryChartCtx = $('#memoryChart');
if (cpuChartCtx.length !== 0 && memoryChartCtx.length !== 0) {
const memoryChartCtx = $('#memoryChart'); const cpuChart = this.ChartService.CreateCPUChart(cpuChartCtx);
const memoryChart = this.ChartService.CreateMemoryChart(memoryChartCtx); this.cpuChart = cpuChart;
this.memoryChart = memoryChart; const memoryChart = this.ChartService.CreateMemoryChart(memoryChartCtx);
this.memoryChart = memoryChart;
this.updateCPUChart(); this.updateCPUChart();
this.updateMemoryChart(); this.updateMemoryChart();
this.setUpdateRepeater(); this.setUpdateRepeater();
clearInterval(findCharts);
}
}, 200);
} }
getStats() { getStats() {
@@ -84,7 +88,7 @@ class KubernetesNodeStatsController {
const memory = filesizeParser(stats.usage.memory); const memory = filesizeParser(stats.usage.memory);
const cpu = KubernetesResourceReservationHelper.parseCPU(stats.usage.cpu); const cpu = KubernetesResourceReservationHelper.parseCPU(stats.usage.cpu);
this.stats = { this.stats = {
read: stats.creationTimestamp, read: stats.metadata.creationTimestamp,
MemoryUsage: memory, MemoryUsage: memory,
CPUUsage: (cpu / this.nodeCPU) * 100, CPUUsage: (cpu / this.nodeCPU) * 100,
}; };
@@ -118,12 +122,6 @@ class KubernetesNodeStatsController {
this.nodeCPU = node.CPU || 1; this.nodeCPU = node.CPU || 1;
await this.getStats(); await this.getStats();
if (this.state.getMetrics) {
this.$document.ready(() => {
this.initCharts();
});
}
} else { } else {
this.state.getMetrics = false; this.state.getMetrics = false;
} }
@@ -132,6 +130,11 @@ class KubernetesNodeStatsController {
this.Notifications.error('Failure', err, 'Unable to retrieve node stats'); this.Notifications.error('Failure', err, 'Unable to retrieve node stats');
} finally { } finally {
this.state.viewReady = true; this.state.viewReady = true;
if (this.state.getMetrics) {
this.$document.ready(() => {
this.initCharts();
});
}
} }
} }
@@ -88,6 +88,7 @@
is-valid="ctrl.state.isDataValid" is-valid="ctrl.state.isDataValid"
on-change-validation="ctrl.isFormValid()" on-change-validation="ctrl.isFormValid()"
is-creation="true" is-creation="true"
type="'configmap'"
is-editor-dirty="ctrl.state.isEditorDirty" is-editor-dirty="ctrl.state.isEditorDirty"
></kubernetes-configuration-data> ></kubernetes-configuration-data>
</div> </div>
@@ -100,6 +100,7 @@
is-valid="ctrl.state.isDataValid" is-valid="ctrl.state.isDataValid"
on-change-validation="ctrl.isFormValid()" on-change-validation="ctrl.isFormValid()"
is-creation="false" is-creation="false"
type="'configmap'"
is-editor-dirty="ctrl.state.isEditorDirty" is-editor-dirty="ctrl.state.isEditorDirty"
></kubernetes-configuration-data> ></kubernetes-configuration-data>
@@ -87,7 +87,7 @@
<pr-icon icon="'info'" mode="'primary'"></pr-icon> <pr-icon icon="'info'" mode="'primary'"></pr-icon>
<span> <span>
More information about types of secret can be found in the official More information about types of secret can be found in the official
<a class="hyperlink" href="https://kubernetes.io/docs/concepts/configuration/secret/#secret-types" target="_blank">kubernetes documentation</a>. <a class="hyperlink" href="https://kubernetes.io/docs/concepts/configuration/secret/#secret-types" target="_blank">Kubernetes documentation</a>.
</span> </span>
</div> </div>
</div> </div>
@@ -186,6 +186,7 @@
is-valid="ctrl.state.isDataValid" is-valid="ctrl.state.isDataValid"
on-change-validation="ctrl.isFormValid()" on-change-validation="ctrl.isFormValid()"
is-creation="true" is-creation="true"
type="'secret'"
is-editor-dirty="ctrl.state.isEditorDirty" is-editor-dirty="ctrl.state.isEditorDirty"
></kubernetes-configuration-data> ></kubernetes-configuration-data>
@@ -107,6 +107,7 @@
is-valid="ctrl.state.isDataValid" is-valid="ctrl.state.isDataValid"
on-change-validation="ctrl.isFormValid()" on-change-validation="ctrl.isFormValid()"
is-creation="false" is-creation="false"
type="'secret'"
is-editor-dirty="ctrl.state.isEditorDirty" is-editor-dirty="ctrl.state.isEditorDirty"
></kubernetes-configuration-data> ></kubernetes-configuration-data>
+53 -54
View File
@@ -22,7 +22,7 @@
<rd-widget> <rd-widget>
<rd-widget-body> <rd-widget-body>
<form class="form-horizontal" name="kubernetesClusterSetupForm"> <form class="form-horizontal" name="kubernetesClusterSetupForm">
<div class="col-sm-12 form-section-title"> Networking </div> <div class="col-sm-12 form-section-title"> Networking - Services </div>
<div class="form-group"> <div class="form-group">
<div class="col-sm-12 text-muted small"> <div class="col-sm-12 text-muted small">
@@ -41,6 +41,8 @@
</div> </div>
</div> </div>
<div class="col-sm-12 form-section-title"> Networking - Ingresses </div>
<ingress-class-datatable <ingress-class-datatable
on-change-controllers="(ctrl.onChangeControllers)" on-change-controllers="(ctrl.onChangeControllers)"
allow-none-ingress-class="ctrl.formValues.AllowNoneIngressClass" allow-none-ingress-class="ctrl.formValues.AllowNoneIngressClass"
@@ -51,47 +53,57 @@
view="'cluster'" view="'cluster'"
></ingress-class-datatable> ></ingress-class-datatable>
<label htmlFor="foldingButtonIngControllerSettings" class="col-sm-12 form-section-title flex cursor-pointer items-center"> <div class="form-group">
<button <div class="col-sm-12">
id="foldingButtonIngControllerSettings" <por-switch-field
type="button" checked="ctrl.formValues.AllowNoneIngressClass"
class="mx-2 !ml-0 inline-flex w-2 items-center justify-center border-0 bg-transparent" name="'allowNoIngressClass'"
ng-click="ctrl.toggleAdvancedIngSettings($event)" label="'Allow ingress class to be set to &quot;none&quot;'"
tooltip="'This allows users setting up ingresses to select &quot;none&quot; as the ingress class.'"
on-change="(ctrl.onToggleAllowNoneIngressClass)"
label-class="'col-sm-5 col-lg-4 px-0 !m-0'"
switch-class="'col-sm-8'"
>
</por-switch-field>
</div>
</div>
<div class="form-group">
<div class="col-sm-12">
<por-switch-field
checked="ctrl.formValues.IngressAvailabilityPerNamespace"
name="'ingressAvailabilityPerNamespace'"
label="'Configure ingress controller availability per namespace'"
tooltip="'This allows an administrator to configure, in each namespace, which ingress controllers will be available for users to select when setting up ingresses for applications.'"
on-change="(ctrl.onToggleIngressAvailabilityPerNamespace)"
label-class="'col-sm-5 col-lg-4 px-0 !m-0'"
switch-class="'col-sm-8'"
>
</por-switch-field>
</div>
</div>
<div class="form-group">
<div class="col-sm-12">
<por-switch-field
checked="ctrl.formValues.RestrictStandardUserIngressW"
name="'restrictStandardUserIngressW'"
label="'Only allow admins to deploy ingresses'"
tooltip="'Enforces only allowing admins to deploy ingresses (and disallows standard users from doing so).'"
on-change="(ctrl.onToggleRestrictStandardUserIngressW)"
label-class="'col-sm-5 col-lg-4 px-0 !m-0'"
switch-class="'col-sm-8 text-muted'"
data-cy="kubeSetup-restrictStandardUserIngressWToggle"
disabled="!ctrl.isRBACEnabled"
>
</por-switch-field>
</div>
</div>
<div class="mb-4 !inline-flex gap-1 !align-top">
<div class="icon icon-sm"><pr-icon icon="'alert-circle'" mode="'primary'"></pr-icon></div>
<div class="text-muted small"
>You may set up ingress defaults (hostnames and annotations) via Create/Edit ingress. Users may then select them via the hostname dropdown in Create/Edit
application.</div
> >
<pr-icon ng-if="!ctrl.state.isIngToggleSectionExpanded" icon="'chevron-right'"></pr-icon>
<pr-icon ng-if="ctrl.state.isIngToggleSectionExpanded" icon="'chevron-down'"></pr-icon>
</button>
More settings
</label>
<div ng-if="ctrl.state.isIngToggleSectionExpanded" class="ml-4">
<div class="form-group">
<div class="col-sm-12">
<por-switch-field
checked="ctrl.formValues.AllowNoneIngressClass"
name="'allowNoIngressClass'"
label="'Allow ingress class to be set to &quot;none&quot;'"
tooltip="'This allows users setting up ingresses to select &quot;none&quot; as the ingress class.'"
on-change="(ctrl.onToggleAllowNoneIngressClass)"
label-class="'col-sm-5 col-lg-4 px-0 !m-0'"
switch-class="'col-sm-8'"
>
</por-switch-field>
</div>
</div>
<div class="form-group">
<div class="col-sm-12">
<por-switch-field
checked="ctrl.formValues.IngressAvailabilityPerNamespace"
name="'ingressAvailabilityPerNamespace'"
label="'Configure ingress controller availability per namespace'"
tooltip="'This allows an administrator to configure, in each namespace, which ingress controllers will be available for users to select when setting up ingresses for applications.'"
on-change="(ctrl.onToggleIngressAvailabilityPerNamespace)"
label-class="'col-sm-5 col-lg-4 px-0 !m-0'"
switch-class="'col-sm-8'"
>
</por-switch-field>
</div>
</div>
</div> </div>
<!-- auto update window --> <!-- auto update window -->
@@ -161,19 +173,6 @@
> >
</por-switch-field> </por-switch-field>
</div> </div>
<div class="col-sm-12 mt-5">
<por-switch-field
name="'restrictStandardUserIngressW'"
label="'Only allow admins to deploy ingresses'"
tooltip="'Enforces only allowing admins to deploy ingresses (and disallows standard users from doing so).'"
label-class="'col-sm-5 col-lg-4 px-0 !m-0'"
switch-class="'col-sm-8 text-muted'"
data-cy="kubeSetup-restrictStandardUserIngressWToggle"
feature-id="ctrl.limitedFeatureIngressDeploy"
disabled="!ctrl.isRBACEnabled"
></por-switch-field>
</div>
</div> </div>
<!-- #endregion --> <!-- #endregion -->
@@ -189,7 +189,7 @@ class KubernetesConfigureController {
await getMetricsForAllNodes(this.endpoint.Id); await getMetricsForAllNodes(this.endpoint.Id);
this.state.metrics.isServerRunning = true; this.state.metrics.isServerRunning = true;
this.state.metrics.pending = false; this.state.metrics.pending = false;
this.state.metrics.userClick = false; this.state.metrics.userClick = true;
this.formValues.UseServerMetrics = true; this.formValues.UseServerMetrics = true;
} catch (_) { } catch (_) {
this.state.metrics.isServerRunning = false; this.state.metrics.isServerRunning = false;
@@ -16,7 +16,7 @@ import { FeatureId } from '@/react/portainer/feature-flags/enums';
import { updateIngressControllerClassMap, getIngressControllerClassMap } from '@/react/kubernetes/cluster/ingressClass/utils'; import { updateIngressControllerClassMap, getIngressControllerClassMap } from '@/react/kubernetes/cluster/ingressClass/utils';
import { confirmUpdate } from '@@/modals/confirm'; import { confirmUpdate } from '@@/modals/confirm';
import { confirmUpdateNamespace } from '@/react/kubernetes/namespaces/ItemView/ConfirmUpdateNamespace'; import { confirmUpdateNamespace } from '@/react/kubernetes/namespaces/ItemView/ConfirmUpdateNamespace';
import { getMetricsForAllNodes, getMetricsForAllPods } from '@/react/kubernetes/services/service.ts'; import { getMetricsForAllPods } from '@/react/kubernetes/services/service.ts';
class KubernetesResourcePoolController { class KubernetesResourcePoolController {
/* #region CONSTRUCTOR */ /* #region CONSTRUCTOR */
@@ -36,7 +36,8 @@ class KubernetesResourcePoolController {
KubernetesApplicationService, KubernetesApplicationService,
KubernetesIngressService, KubernetesIngressService,
KubernetesVolumeService, KubernetesVolumeService,
KubernetesNamespaceService KubernetesNamespaceService,
KubernetesNodeService
) { ) {
Object.assign(this, { Object.assign(this, {
$async, $async,
@@ -54,6 +55,7 @@ class KubernetesResourcePoolController {
KubernetesIngressService, KubernetesIngressService,
KubernetesVolumeService, KubernetesVolumeService,
KubernetesNamespaceService, KubernetesNamespaceService,
KubernetesNodeService,
}); });
this.IngressClassTypes = KubernetesIngressClassTypes; this.IngressClassTypes = KubernetesIngressClassTypes;
@@ -366,7 +368,7 @@ class KubernetesResourcePoolController {
const name = this.$state.params.id; const name = this.$state.params.id;
const [nodes, pools] = await Promise.all([getMetricsForAllNodes, this.KubernetesResourcePoolService.get('', { getQuota: true })]); const [nodes, pools] = await Promise.all([this.KubernetesNodeService.get(), this.KubernetesResourcePoolService.get('', { getQuota: true })]);
this.ingressControllers = []; this.ingressControllers = [];
if (this.state.ingressAvailabilityPerNamespace) { if (this.state.ingressAvailabilityPerNamespace) {
@@ -20,7 +20,7 @@ const { CREATE, UPDATE, DELETE } = KubernetesResourceActions;
* Get summary of Kubernetes resources to be created, updated or deleted * Get summary of Kubernetes resources to be created, updated or deleted
* @param {KubernetesApplicationFormValues} formValues * @param {KubernetesApplicationFormValues} formValues
*/ */
export default function (formValues, oldFormValues = {}) { export function getApplicationResources(formValues, oldFormValues = {}) {
if (oldFormValues instanceof KubernetesApplicationFormValues) { if (oldFormValues instanceof KubernetesApplicationFormValues) {
const resourceSummary = getUpdatedApplicationResources(oldFormValues, formValues); const resourceSummary = getUpdatedApplicationResources(oldFormValues, formValues);
return resourceSummary; return resourceSummary;
@@ -139,9 +139,9 @@ function getUpdatedApplicationResources(oldFormValues, newFormValues) {
} }
// Ingress // Ingress
const oldIngresses = KubernetesIngressConverter.applicationFormValuesToIngresses(oldFormValues, oldService.Name);
const newServicePorts = newFormValues.Services.flatMap((service) => service.Ports);
const oldServicePorts = oldFormValues.Services.flatMap((service) => service.Ports); const oldServicePorts = oldFormValues.Services.flatMap((service) => service.Ports);
const oldIngresses = generateNewIngressesFromFormPaths(oldFormValues.OriginalIngresses, oldServicePorts, oldServicePorts);
const newServicePorts = newFormValues.Services.flatMap((service) => service.Ports);
const newIngresses = generateNewIngressesFromFormPaths(newFormValues.OriginalIngresses, newServicePorts, oldServicePorts); const newIngresses = generateNewIngressesFromFormPaths(newFormValues.OriginalIngresses, newServicePorts, oldServicePorts);
resources.push(...getIngressUpdateSummary(oldIngresses, newIngresses)); resources.push(...getIngressUpdateSummary(oldIngresses, newIngresses));
} else if (!oldService && newService) { } else if (!oldService && newService) {
@@ -190,7 +190,7 @@ function getApplicationResourceType(app) {
function getIngressUpdateSummary(oldIngresses, newIngresses) { function getIngressUpdateSummary(oldIngresses, newIngresses) {
const ingressesSummaries = newIngresses const ingressesSummaries = newIngresses
.map((newIng) => { .map((newIng) => {
const oldIng = _.find(oldIngresses, { Name: newIng.Name }); const oldIng = oldIngresses.find((oldIng) => oldIng.Name === newIng.Name);
return getIngressUpdateResourceSummary(oldIng, newIng); return getIngressUpdateResourceSummary(oldIng, newIng);
}) })
.filter((s) => s); // remove nulls .filter((s) => s); // remove nulls
@@ -3,7 +3,7 @@ import { KubernetesConfigurationFormValues } from 'Kubernetes/models/configurati
import { KubernetesResourcePoolFormValues } from 'Kubernetes/models/resource-pool/formValues'; import { KubernetesResourcePoolFormValues } from 'Kubernetes/models/resource-pool/formValues';
import { KubernetesApplicationFormValues } from 'Kubernetes/models/application/formValues'; import { KubernetesApplicationFormValues } from 'Kubernetes/models/application/formValues';
import { KubernetesResourceActions, KubernetesResourceTypes } from 'Kubernetes/models/resource-types/models'; import { KubernetesResourceActions, KubernetesResourceTypes } from 'Kubernetes/models/resource-types/models';
import getApplicationResources from './resources/applicationResources'; import { getApplicationResources } from './resources/applicationResources';
import getNamespaceResources from './resources/namespaceResources'; import getNamespaceResources from './resources/namespaceResources';
import getConfigurationResources from './resources/configurationResources'; import getConfigurationResources from './resources/configurationResources';
@@ -14,14 +14,19 @@
{{ $ctrl.model.Title }} {{ $ctrl.model.Title }}
</span> </span>
<div class="space-left blocklist-item-subtitle inline-flex items-center"> <div class="space-left blocklist-item-subtitle inline-flex items-center">
<pr-icon ng-if="$ctrl.model.Platform === 1 || $ctrl.model.Platform === 'linux' || !$ctrl.model.Platform" icon="'svg-linux'" class="mr-1"></pr-icon> <div ng-if="$ctrl.typeLabel !== 'manifest'" class="vertical-center gap-1">
<span ng-if="!$ctrl.model.Platform"> &amp; </span> <pr-icon ng-if="$ctrl.model.Platform === 1 || $ctrl.model.Platform === 'linux' || !$ctrl.model.Platform" icon="'svg-linux'" class="mr-1"></pr-icon>
<pr-icon <pr-icon
icon="'svg-microsoft'" icon="'svg-microsoft'"
ng-if="$ctrl.model.Platform === 2 || $ctrl.model.Platform === 'windows' || !$ctrl.model.Platform" ng-if="$ctrl.model.Platform === 2 || $ctrl.model.Platform === 'windows' || !$ctrl.model.Platform"
class-name="'[&>*]:flex [&>*]:items-center'" class-name="'[&>*]:flex [&>*]:items-center'"
size="'lg'" size="'lg'"
></pr-icon> ></pr-icon>
</div>
<!-- currently only kubernetes uses the typeLabel of 'manifest' -->
<div ng-if="$ctrl.typeLabel === 'manifest'" class="vertical-center">
<pr-icon icon="'svg-kubernetes'" size="'lg'" class="align-bottom" class-name="'[&>*]:flex [&>*]:items-center'"></pr-icon>
</div>
{{ $ctrl.typeLabel }} {{ $ctrl.typeLabel }}
</div> </div>
</div> </div>
@@ -41,7 +41,7 @@ export default class LdapSettingsBaseDnBuilderController {
} }
getOUValues(dn, domainSuffix = '') { getOUValues(dn, domainSuffix = '') {
const regex = /(\w+)=(\w*),?/; const regex = /(\w+)=([a-zA-Z0-9_ ]*),?/;
let ouValues = []; let ouValues = [];
let left = dn; let left = dn;
let match = left.match(regex); let match = left.match(regex);
+2 -2
View File
@@ -117,8 +117,8 @@ export function createMockEnvironment(): Environment {
StartTime: '', StartTime: '',
}, },
StatusMessage: { StatusMessage: {
Detail: '', detail: '',
Summary: '', summary: '',
}, },
}; };
} }
@@ -1,4 +1,4 @@
import { semverCompare } from './utils'; import { semverCompare } from './semver-utils';
describe('semverCompare', () => { describe('semverCompare', () => {
test('sort array', () => { test('sort array', () => {
+27
View File
@@ -0,0 +1,27 @@
/**
* Compares two semver strings.
*
* returns:
* - `-1` if `a < b`
* - `0` if `a == b`
* - `1` if `a > b`
*/
export function semverCompare(a: string, b: string) {
if (a.startsWith(`${b}-`)) {
return -1;
}
if (b.startsWith(`${a}-`)) {
return 1;
}
return a.localeCompare(b, undefined, {
numeric: true,
sensitivity: 'case',
caseFirst: 'upper',
});
}
export function isVersionSmaller(a: string, b: string) {
return semverCompare(a, b) < 0;
}
@@ -0,0 +1,19 @@
import { Story, Meta } from '@storybook/react';
import { PropsWithChildren } from 'react';
import { InlineLoader, Props } from './InlineLoader';
export default {
title: 'Components/InlineLoader',
component: InlineLoader,
} as Meta;
function Template({ className, children }: PropsWithChildren<Props>) {
return <InlineLoader className={className}>{children}</InlineLoader>;
}
export const Primary: Story<PropsWithChildren<Props>> = Template.bind({});
Primary.args = {
className: 'test-class',
children: 'Loading...',
};
@@ -0,0 +1,23 @@
import { Loader2 } from 'lucide-react';
import { PropsWithChildren } from 'react';
import clsx from 'clsx';
import { Icon } from '@@/Icon';
export type Props = {
className: string;
};
export function InlineLoader({
children,
className,
}: PropsWithChildren<Props>) {
return (
<div
className={clsx('text-muted flex items-center gap-2 text-sm', className)}
>
<Icon icon={Loader2} className="animate-spin-slow" />
{children}
</div>
);
}
@@ -0,0 +1 @@
export { InlineLoader } from './InlineLoader';
@@ -247,6 +247,12 @@ const docURLs = [
locationRegex: /#!\/edge\/jobs/, locationRegex: /#!\/edge\/jobs/,
examples: ['#!/edge/jobs', '#!/edge/jobs/new'], examples: ['#!/edge/jobs', '#!/edge/jobs/new'],
}, },
{
desc: 'Edge Compute / Edge Configurations',
docURL: 'https://docs.portainer.io/user/edge/configurations',
locationRegex: /#!\/edge\/configurations/,
examples: ['#!/edge/configurations', '#!/edge/configurations/new'],
},
{ {
desc: 'Nomad / Dashboard', desc: 'Nomad / Dashboard',
docURL: 'https://docs.portainer.io/user/nomad/dashboard', docURL: 'https://docs.portainer.io/user/nomad/dashboard',
@@ -8,29 +8,22 @@ interface Props {
boundaryLinks?: boolean; boundaryLinks?: boolean;
currentPage: number; currentPage: number;
directionLinks?: boolean; directionLinks?: boolean;
itemsPerPage: number;
onPageChange(page: number): void; onPageChange(page: number): void;
totalCount: number; pageCount: number;
maxSize: number; maxSize: number;
isInputVisible?: boolean; isInputVisible?: boolean;
} }
export function PageSelector({ export function PageSelector({
currentPage, currentPage,
totalCount, pageCount,
itemsPerPage,
onPageChange, onPageChange,
maxSize = 5, maxSize = 5,
directionLinks = true, directionLinks = true,
boundaryLinks = false, boundaryLinks = false,
isInputVisible = false, isInputVisible = false,
}: Props) { }: Props) {
const pages = generatePagesArray( const pages = generatePagesArray(currentPage, pageCount, maxSize);
currentPage,
totalCount,
itemsPerPage,
maxSize
);
const last = pages[pages.length - 1]; const last = pages[pages.length - 1];
if (pages.length <= 1) { if (pages.length <= 1) {
@@ -42,7 +35,7 @@ export function PageSelector({
{isInputVisible && ( {isInputVisible && (
<PageInput <PageInput
onChange={(page) => onPageChange(page)} onChange={(page) => onPageChange(page)}
totalPages={Math.ceil(totalCount / itemsPerPage)} totalPages={pageCount}
/> />
)} )}
<ul className="pagination"> <ul className="pagination">
@@ -9,7 +9,7 @@ interface Props {
page: number; page: number;
pageLimit: number; pageLimit: number;
showAll?: boolean; showAll?: boolean;
totalCount: number; pageCount: number;
isPageInputVisible?: boolean; isPageInputVisible?: boolean;
className?: string; className?: string;
} }
@@ -20,7 +20,7 @@ export function PaginationControls({
onPageLimitChange, onPageLimitChange,
showAll, showAll,
onPageChange, onPageChange,
totalCount, pageCount,
isPageInputVisible, isPageInputVisible,
className, className,
}: Props) { }: Props) {
@@ -38,8 +38,7 @@ export function PaginationControls({
maxSize={5} maxSize={5}
onPageChange={onPageChange} onPageChange={onPageChange}
currentPage={page} currentPage={page}
itemsPerPage={pageLimit} pageCount={pageCount}
totalCount={totalCount}
isInputVisible={isPageInputVisible} isInputVisible={isPageInputVisible}
/> />
)} )}
@@ -12,12 +12,10 @@ export /**
*/ */
function generatePagesArray( function generatePagesArray(
currentPage: number, currentPage: number,
collectionLength: number, totalPages: number,
rowsPerPage: number,
paginationRange: number paginationRange: number
): (number | '...')[] { ): (number | '...')[] {
const pages: (number | '...')[] = []; const pages: (number | '...')[] = [];
const totalPages = Math.ceil(collectionLength / rowsPerPage);
const halfWay = Math.ceil(paginationRange / 2); const halfWay = Math.ceil(paginationRange / 2);
let position; let position;
@@ -17,7 +17,7 @@ interface WidgetProps {
} }
const meta: Meta<WidgetProps> = { const meta: Meta<WidgetProps> = {
title: 'Widget', title: 'Components/Widget',
component: Widget, component: Widget,
args: { args: {
loading: false, loading: false,
+43 -13
View File
@@ -3,6 +3,7 @@ import {
TableState, TableState,
useReactTable, useReactTable,
Row, Row,
Column,
getCoreRowModel, getCoreRowModel,
getPaginationRowModel, getPaginationRowModel,
getFilteredRowModel, getFilteredRowModel,
@@ -33,6 +34,20 @@ import { createSelectColumn } from './select-column';
import { TableRow } from './TableRow'; import { TableRow } from './TableRow';
import { type TableState as GlobalTableState } from './useTableState'; import { type TableState as GlobalTableState } from './useTableState';
export type PaginationProps =
| {
isServerSidePagination?: false;
totalCount?: never;
page?: never;
onPageChange?: never;
}
| {
isServerSidePagination: true;
totalCount: number;
page: number;
onPageChange(page: number): void;
};
export interface Props< export interface Props<
D extends Record<string, unknown>, D extends Record<string, unknown>,
TMeta extends TableMeta<D> = TableMeta<D> TMeta extends TableMeta<D> = TableMeta<D>
@@ -49,12 +64,8 @@ export interface Props<
titleIcon?: IconProps['icon']; titleIcon?: IconProps['icon'];
initialTableState?: Partial<TableState>; initialTableState?: Partial<TableState>;
isLoading?: boolean; isLoading?: boolean;
totalCount?: number;
description?: ReactNode; description?: ReactNode;
pageCount?: number;
highlightedItemId?: string; highlightedItemId?: string;
onPageChange?(page: number): void;
settingsManager: GlobalTableState<BasicTableSettings>; settingsManager: GlobalTableState<BasicTableSettings>;
renderRow?(row: Row<D>, highlightedItemId?: string): ReactNode; renderRow?(row: Row<D>, highlightedItemId?: string): ReactNode;
getRowCanExpand?(row: Row<D>): boolean; getRowCanExpand?(row: Row<D>): boolean;
@@ -78,10 +89,7 @@ export function Datatable<
emptyContentLabel, emptyContentLabel,
initialTableState = {}, initialTableState = {},
isLoading, isLoading,
totalCount = dataset.length,
description, description,
pageCount,
onPageChange = () => null,
settingsManager: settings, settingsManager: settings,
renderRow = defaultRenderRow, renderRow = defaultRenderRow,
highlightedItemId, highlightedItemId,
@@ -89,8 +97,16 @@ export function Datatable<
getRowCanExpand, getRowCanExpand,
'data-cy': dataCy, 'data-cy': dataCy,
meta, meta,
}: Props<D, TMeta>) { onPageChange = () => {},
const isServerSidePagination = typeof pageCount !== 'undefined'; page,
totalCount = dataset.length,
isServerSidePagination = false,
}: Props<D, TMeta> & PaginationProps) {
const pageCount = useMemo(
() => Math.ceil(totalCount / settings.pageSize),
[settings.pageSize, totalCount]
);
const enableRowSelection = getIsSelectionEnabled( const enableRowSelection = getIsSelectionEnabled(
disableSelect, disableSelect,
isRowSelectable isRowSelectable
@@ -107,6 +123,7 @@ export function Datatable<
initialState: { initialState: {
pagination: { pagination: {
pageSize: settings.pageSize, pageSize: settings.pageSize,
pageIndex: page || 0,
}, },
sorting: settings.sortBy ? [settings.sortBy] : [], sorting: settings.sortBy ? [settings.sortBy] : [],
globalFilter: settings.search, globalFilter: settings.search,
@@ -116,6 +133,7 @@ export function Datatable<
defaultColumn: { defaultColumn: {
enableColumnFilter: false, enableColumnFilter: false,
enableHiding: true, enableHiding: true,
sortingFn: 'alphanumeric',
}, },
enableRowSelection, enableRowSelection,
autoResetExpanded: false, autoResetExpanded: false,
@@ -123,14 +141,18 @@ export function Datatable<
getRowId, getRowId,
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(), getFilteredRowModel: getFilteredRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(), getPaginationRowModel: getPaginationRowModel(),
getFacetedRowModel: getFacetedRowModel(), getFacetedRowModel: getFacetedRowModel(),
getFacetedUniqueValues: getFacetedUniqueValues(), getFacetedUniqueValues: getFacetedUniqueValues(),
getFacetedMinMaxValues: getFacetedMinMaxValues(), getFacetedMinMaxValues: getFacetedMinMaxValues(),
getExpandedRowModel: getExpandedRowModel(), getExpandedRowModel: getExpandedRowModel(),
getRowCanExpand, getRowCanExpand,
...(isServerSidePagination ? { manualPagination: true, pageCount } : {}), getColumnCanGlobalFilter,
...(isServerSidePagination
? { manualPagination: true, pageCount }
: {
getSortedRowModel: getSortedRowModel(),
}),
meta, meta,
}); });
@@ -158,6 +180,7 @@ export function Datatable<
renderTableActions={() => renderTableActions(selectedItems)} renderTableActions={() => renderTableActions(selectedItems)}
renderTableSettings={() => renderTableSettings(tableInstance)} renderTableSettings={() => renderTableSettings(tableInstance)}
/> />
<DatatableContent<D> <DatatableContent<D>
tableInstance={tableInstance} tableInstance={tableInstance}
renderRow={(row) => renderRow(row, highlightedItemId)} renderRow={(row) => renderRow(row, highlightedItemId)}
@@ -170,9 +193,9 @@ export function Datatable<
<DatatableFooter <DatatableFooter
onPageChange={handlePageChange} onPageChange={handlePageChange}
onPageSizeChange={handlePageSizeChange} onPageSizeChange={handlePageSizeChange}
page={tableState.pagination.pageIndex} page={typeof page === 'number' ? page : tableState.pagination.pageIndex}
pageSize={tableState.pagination.pageSize} pageSize={tableState.pagination.pageSize}
totalCount={totalCount} pageCount={tableInstance.getPageCount()}
totalSelected={selectedItems.length} totalSelected={selectedItems.length}
/> />
</Table.Container> </Table.Container>
@@ -258,3 +281,10 @@ function globalFilterFn<D>(
return false; return false;
} }
function getColumnCanGlobalFilter<D>(column: Column<D, unknown>): boolean {
if (column.id === 'select') {
return false;
}
return true;
}
@@ -8,7 +8,7 @@ interface Props {
pageSize: number; pageSize: number;
page: number; page: number;
onPageChange(page: number): void; onPageChange(page: number): void;
totalCount: number; pageCount: number;
onPageSizeChange(pageSize: number): void; onPageSizeChange(pageSize: number): void;
} }
@@ -17,7 +17,7 @@ export function DatatableFooter({
pageSize, pageSize,
page, page,
onPageChange, onPageChange,
totalCount, pageCount,
onPageSizeChange, onPageSizeChange,
}: Props) { }: Props) {
return ( return (
@@ -28,7 +28,7 @@ export function DatatableFooter({
pageLimit={pageSize} pageLimit={pageSize}
page={page + 1} page={page + 1}
onPageChange={(page) => onPageChange(page - 1)} onPageChange={(page) => onPageChange(page - 1)}
totalCount={totalCount} pageCount={pageCount}
onPageLimitChange={onPageSizeChange} onPageLimitChange={onPageSizeChange}
/> />
</Table.Footer> </Table.Footer>
@@ -2,7 +2,11 @@ import { Row } from '@tanstack/react-table';
import { ReactNode } from 'react'; import { ReactNode } from 'react';
import { ExpandableDatatableTableRow } from './ExpandableDatatableRow'; import { ExpandableDatatableTableRow } from './ExpandableDatatableRow';
import { Datatable, Props as DatatableProps } from './Datatable'; import {
Datatable,
Props as DatatableProps,
PaginationProps,
} from './Datatable';
interface Props<D extends Record<string, unknown>> interface Props<D extends Record<string, unknown>>
extends Omit<DatatableProps<D>, 'renderRow' | 'expandable'> { extends Omit<DatatableProps<D>, 'renderRow' | 'expandable'> {
@@ -15,7 +19,7 @@ export function ExpandableDatatable<D extends Record<string, unknown>>({
getRowCanExpand = () => true, getRowCanExpand = () => true,
expandOnRowClick, expandOnRowClick,
...props ...props
}: Props<D>) { }: Props<D> & PaginationProps) {
return ( return (
<Datatable<D> <Datatable<D>
// eslint-disable-next-line react/jsx-props-no-spreading // eslint-disable-next-line react/jsx-props-no-spreading
@@ -17,7 +17,6 @@ export function buildNameColumn<T extends Record<string, unknown>>(
cell, cell,
enableSorting: true, enableSorting: true,
enableHiding: false, enableHiding: false,
sortingFn: 'text',
}; };
function createCell<T extends Record<string, unknown>>() { function createCell<T extends Record<string, unknown>>() {
@@ -31,6 +31,22 @@
display: none; display: none;
} }
.portainer-selector-root .portainer-selector__group-heading {
text-transform: none !important;
font-size: 85% !important;
}
.input-group .portainer-selector-root:last-child .portainer-selector__control {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
border-top-right-radius: 5px;
border-bottom-right-radius: 5px;
}
.input-group .portainer-selector-root:not(:first-child):not(:last-child) .portainer-selector__control {
border-radius: 0;
}
/* input style */ /* input style */
.portainer-selector-root .portainer-selector__control { .portainer-selector-root .portainer-selector__control {
border-color: var(--border-form-control-color); border-color: var(--border-form-control-color);
@@ -7,6 +7,6 @@ export const size = columnHelper.accessor('VirtualSize', {
header: 'Size', header: 'Size',
cell: ({ getValue }) => { cell: ({ getValue }) => {
const value = getValue(); const value = getValue();
return humanize(value); return humanize(value) || '-';
}, },
}); });
@@ -28,6 +28,7 @@ export function AssociatedEdgeEnvironmentsSelector({
emptyContentLabel="No environment available" emptyContentLabel="No environment available"
query={{ query={{
types: EdgeTypes, types: EdgeTypes,
excludeIds: value,
}} }}
onClickRow={(env) => { onClickRow={(env) => {
if (!value.includes(env.Id)) { if (!value.includes(env.Id)) {
@@ -35,7 +36,6 @@ export function AssociatedEdgeEnvironmentsSelector({
} }
}} }}
data-cy="edgeGroupCreate-availableEndpoints" data-cy="edgeGroupCreate-availableEndpoints"
hideEnvironmentIds={value}
/> />
</div> </div>
<div className="w-1/2"> <div className="w-1/2">
@@ -3,10 +3,7 @@ import { truncate } from 'lodash';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { useEnvironmentList } from '@/react/portainer/environments/queries'; import { useEnvironmentList } from '@/react/portainer/environments/queries';
import { import { Environment } from '@/react/portainer/environments/types';
Environment,
EnvironmentId,
} from '@/react/portainer/environments/types';
import { useGroups } from '@/react/portainer/environments/environment-groups/queries'; import { useGroups } from '@/react/portainer/environments/environment-groups/queries';
import { useTags } from '@/portainer/tags/queries'; import { useTags } from '@/portainer/tags/queries';
import { EnvironmentsQueryParams } from '@/react/portainer/environments/environment.service'; import { EnvironmentsQueryParams } from '@/react/portainer/environments/environment.service';
@@ -47,19 +44,17 @@ export function EdgeGroupAssociationTable({
emptyContentLabel, emptyContentLabel,
onClickRow, onClickRow,
'data-cy': dataCy, 'data-cy': dataCy,
hideEnvironmentIds = [],
}: { }: {
title: string; title: string;
query: EnvironmentsQueryParams; query: EnvironmentsQueryParams;
emptyContentLabel: string; emptyContentLabel: string;
onClickRow: (env: Environment) => void; onClickRow: (env: Environment) => void;
hideEnvironmentIds?: EnvironmentId[];
} & AutomationTestingProps) { } & AutomationTestingProps) {
const tableState = useTableStateWithoutStorage('Name'); const tableState = useTableStateWithoutStorage('Name');
const [page, setPage] = useState(1); const [page, setPage] = useState(0);
const environmentsQuery = useEnvironmentList({ const environmentsQuery = useEnvironmentList({
pageLimit: tableState.pageSize, pageLimit: tableState.pageSize,
page, page: page + 1,
search: tableState.search, search: tableState.search,
sort: tableState.sortBy.id as 'Group' | 'Name', sort: tableState.sortBy.id as 'Group' | 'Name',
order: tableState.sortBy.desc ? 'desc' : 'asc', order: tableState.sortBy.desc ? 'desc' : 'asc',
@@ -74,25 +69,17 @@ export function EdgeGroupAssociationTable({
const environments: Array<DecoratedEnvironment> = useMemo( const environments: Array<DecoratedEnvironment> = useMemo(
() => () =>
environmentsQuery.environments environmentsQuery.environments.map((env) => ({
.filter((e) => !hideEnvironmentIds.includes(e.Id)) ...env,
.map((env) => ({ Group: groupsQuery.data?.find((g) => g.Id === env.GroupId)?.Name || '',
...env, Tags: env.TagIds.map(
Group: (tagId) => tagsQuery.data?.find((t) => t.ID === tagId)?.Name || ''
groupsQuery.data?.find((g) => g.Id === env.GroupId)?.Name || '', ),
Tags: env.TagIds.map( })),
(tagId) => tagsQuery.data?.find((t) => t.ID === tagId)?.Name || '' [environmentsQuery.environments, groupsQuery.data, tagsQuery.data]
),
})),
[
environmentsQuery.environments,
groupsQuery.data,
hideEnvironmentIds,
tagsQuery.data,
]
); );
const totalCount = environmentsQuery.totalCount - hideEnvironmentIds.length; const { totalCount } = environmentsQuery;
return ( return (
<Datatable<DecoratedEnvironment> <Datatable<DecoratedEnvironment>
@@ -100,8 +87,10 @@ export function EdgeGroupAssociationTable({
columns={columns} columns={columns}
settingsManager={tableState} settingsManager={tableState}
dataset={environments} dataset={environments}
isServerSidePagination
page={page}
onPageChange={setPage} onPageChange={setPage}
pageCount={Math.ceil(totalCount / tableState.pageSize)} totalCount={totalCount}
renderRow={(row) => ( renderRow={(row) => (
<TableRow<DecoratedEnvironment> <TableRow<DecoratedEnvironment>
cells={row.getVisibleCells()} cells={row.getVisibleCells()}
@@ -111,7 +100,6 @@ export function EdgeGroupAssociationTable({
emptyContentLabel={emptyContentLabel} emptyContentLabel={emptyContentLabel}
data-cy={dataCy} data-cy={dataCy}
disableSelect disableSelect
totalCount={totalCount}
/> />
); );
} }
@@ -9,11 +9,20 @@ import { useEnvironments } from './useEnvironments';
const storageKey = 'edge-devices-waiting-room'; const storageKey = 'edge-devices-waiting-room';
const settingsStore = createPersistedStore(storageKey, 'Name'); const settingsStore = createPersistedStore(storageKey);
export function Datatable() { export function Datatable() {
const tableState = useTableState(settingsStore, storageKey); const tableState = useTableState(settingsStore, storageKey);
const { data: environments, totalCount, isLoading } = useEnvironments(); const {
data: environments,
totalCount,
isLoading,
page,
setPage,
} = useEnvironments({
pageLimit: tableState.pageSize,
search: tableState.search,
});
return ( return (
<GenericDatatable <GenericDatatable
@@ -26,6 +35,9 @@ export function Datatable() {
<TableActions selectedRows={selectedRows} /> <TableActions selectedRows={selectedRows} />
)} )}
isLoading={isLoading} isLoading={isLoading}
isServerSidePagination
page={page}
onPageChange={setPage}
totalCount={totalCount} totalCount={totalCount}
description={<Filter />} description={<Filter />}
/> />
@@ -1,4 +1,5 @@
import _ from 'lodash'; import _ from 'lodash';
import { useEffect, useMemo, useState } from 'react';
import { useTags } from '@/portainer/tags/queries'; import { useTags } from '@/portainer/tags/queries';
import { useEdgeGroups } from '@/react/edge/edge-groups/queries/useEdgeGroups'; import { useEdgeGroups } from '@/react/edge/edge-groups/queries/useEdgeGroups';
@@ -10,27 +11,59 @@ import { WaitingRoomEnvironment } from '../types';
import { useFilterStore } from './filter-store'; import { useFilterStore } from './filter-store';
export function useEnvironments() { export function useEnvironments({
pageLimit = 10,
search,
}: {
pageLimit: number;
search: string;
}) {
const [page, setPage] = useState(0);
const filterStore = useFilterStore(); const filterStore = useFilterStore();
const edgeGroupsQuery = useEdgeGroups(); const edgeGroupsQuery = useEdgeGroups();
const filterByEnvironmentsIds = filterStore.edgeGroups.length const filterByEnvironmentsIds = useMemo(
? _.compact( () =>
filterStore.edgeGroups.flatMap( filterStore.edgeGroups.length
(groupId) => ? _.compact(
edgeGroupsQuery.data?.find((g) => g.Id === groupId)?.Endpoints filterStore.edgeGroups.flatMap(
) (groupId) =>
) edgeGroupsQuery.data?.find((g) => g.Id === groupId)?.Endpoints
: undefined; )
)
: undefined,
[edgeGroupsQuery.data, filterStore.edgeGroups]
);
const query = useMemo(
() => ({
pageLimit,
edgeDeviceUntrusted: true,
excludeSnapshots: true,
types: EdgeTypes,
tagIds: filterStore.tags.length ? filterStore.tags : undefined,
groupIds: filterStore.groups.length ? filterStore.groups : undefined,
endpointIds: filterByEnvironmentsIds,
edgeCheckInPassedSeconds: filterStore.checkIn,
search,
}),
[
filterByEnvironmentsIds,
filterStore.checkIn,
filterStore.groups,
filterStore.tags,
pageLimit,
search,
]
);
useEffect(() => {
setPage(0);
}, [query]);
const environmentsQuery = useEnvironmentList({ const environmentsQuery = useEnvironmentList({
edgeDeviceUntrusted: true, page: page + 1,
excludeSnapshots: true, ...query,
types: EdgeTypes,
tagIds: filterStore.tags.length ? filterStore.tags : undefined,
groupIds: filterStore.groups.length ? filterStore.groups : undefined,
endpointIds: filterByEnvironmentsIds,
edgeCheckInPassedSeconds: filterStore.checkIn,
}); });
const groupsQuery = useGroups({ const groupsQuery = useGroups({
@@ -52,24 +85,45 @@ export function useEnvironments() {
Object.fromEntries(tags.map((tag) => [tag.ID, tag.Name] as const)), Object.fromEntries(tags.map((tag) => [tag.ID, tag.Name] as const)),
}); });
const envs: Array<WaitingRoomEnvironment> = const envs: Array<WaitingRoomEnvironment> = useMemo(
environmentsQuery.environments.map((env) => ({ () =>
...env, environmentsQuery.environments.map((env) => ({
Group: (env.GroupId !== 1 && groupsQuery.data?.[env.GroupId]) || '', ...env,
EdgeGroups: Group: (env.GroupId !== 1 && groupsQuery.data?.[env.GroupId]) || '',
environmentEdgeGroupsQuery.data?.[env.Id]?.map((env) => env.group) || EdgeGroups:
[], environmentEdgeGroupsQuery.data?.[env.Id]?.map((env) => env.group) ||
Tags: [],
_.compact(env.TagIds?.map((tagId) => tagsQuery.data?.[tagId])) || [], Tags:
})); _.compact(env.TagIds?.map((tagId) => tagsQuery.data?.[tagId])) || [],
})),
[
environmentEdgeGroupsQuery.data,
environmentsQuery.environments,
groupsQuery.data,
tagsQuery.data,
]
);
return { return useMemo(
data: envs, () => ({
isLoading: data: envs,
environmentsQuery.isLoading || isLoading:
groupsQuery.isLoading || environmentsQuery.isLoading ||
environmentEdgeGroupsQuery.isLoading || groupsQuery.isLoading ||
environmentEdgeGroupsQuery.isLoading ||
tagsQuery.isLoading,
totalCount: environmentsQuery.totalCount,
page,
setPage,
}),
[
environmentEdgeGroupsQuery.isLoading,
environmentsQuery.isLoading,
environmentsQuery.totalCount,
envs,
groupsQuery.isLoading,
page,
tagsQuery.isLoading, tagsQuery.isLoading,
totalCount: environmentsQuery.totalCount, ]
}; );
} }
@@ -214,7 +214,7 @@ function InnerForm({
checked={values.retryDeploy} checked={values.retryDeploy}
name="retryDeploy" name="retryDeploy"
label="Retry deployment" label="Retry deployment"
tooltip="When enabled, this will allow edge agent keep retrying deployment if failure occur" tooltip="When enabled, this will allow the edge agent to retry deployment if failed to deploy initially"
labelClass="col-sm-3 col-lg-2" labelClass="col-sm-3 col-lg-2"
onChange={(value) => setFieldValue('retryDeploy', value)} onChange={(value) => setFieldValue('retryDeploy', value)}
/> />
@@ -4,7 +4,6 @@ import { useMemo, useState } from 'react';
import { EdgeStackStatus, StatusType } from '@/react/edge/edge-stacks/types'; import { EdgeStackStatus, StatusType } from '@/react/edge/edge-stacks/types';
import { useEnvironmentList } from '@/react/portainer/environments/queries'; import { useEnvironmentList } from '@/react/portainer/environments/queries';
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
import { useParamState } from '@/react/hooks/useParamState'; import { useParamState } from '@/react/hooks/useParamState';
import { EnvironmentId } from '@/react/portainer/environments/types'; import { EnvironmentId } from '@/react/portainer/environments/types';
@@ -41,7 +40,7 @@ export function EnvironmentsDatatable() {
(value) => (value ? parseInt(value, 10) : undefined) (value) => (value ? parseInt(value, 10) : undefined)
); );
const tableState = useTableStateWithoutStorage('name'); const tableState = useTableStateWithoutStorage('name');
const endpointsQuery = useEnvironmentList({ const environmentsQuery = useEnvironmentList({
pageLimit: tableState.pageSize, pageLimit: tableState.pageSize,
page: page + 1, page: page + 1,
search: tableState.search, search: tableState.search,
@@ -57,7 +56,7 @@ export function EnvironmentsDatatable() {
const gitConfigCommitHash = edgeStackQuery.data?.GitConfig?.ConfigHash || ''; const gitConfigCommitHash = edgeStackQuery.data?.GitConfig?.ConfigHash || '';
const environments: Array<EdgeStackEnvironment> = useMemo( const environments: Array<EdgeStackEnvironment> = useMemo(
() => () =>
endpointsQuery.environments.map( environmentsQuery.environments.map(
(env) => (env) =>
({ ({
...env, ...env,
@@ -73,7 +72,7 @@ export function EnvironmentsDatatable() {
[ [
currentFileVersion, currentFileVersion,
edgeStackQuery.data?.Status, edgeStackQuery.data?.Status,
endpointsQuery.environments, environmentsQuery.environments,
gitConfigCommitHash, gitConfigCommitHash,
gitConfigURL, gitConfigURL,
] ]
@@ -82,32 +81,33 @@ export function EnvironmentsDatatable() {
return ( return (
<Datatable <Datatable
columns={columns} columns={columns}
isLoading={endpointsQuery.isLoading} isLoading={environmentsQuery.isLoading}
dataset={environments} dataset={environments}
settingsManager={tableState} settingsManager={tableState}
title="Environments Status" title="Environments Status"
titleIcon={HardDrive} titleIcon={HardDrive}
isServerSidePagination
page={page}
onPageChange={setPage} onPageChange={setPage}
totalCount={environmentsQuery.totalCount}
emptyContentLabel="No environment available." emptyContentLabel="No environment available."
disableSelect disableSelect
description={ description={
isBE && ( <div className="w-1/4">
<div className="w-1/4"> <PortainerSelect<StatusType | undefined>
<PortainerSelect<StatusType | undefined> isClearable
isClearable bindToBody
bindToBody value={statusFilter}
value={statusFilter} onChange={(e) => setStatusFilter(e ?? undefined)}
onChange={(e) => setStatusFilter(e || undefined)} options={[
options={[ { value: StatusType.Pending, label: 'Pending' },
{ value: StatusType.Pending, label: 'Pending' }, { value: StatusType.Acknowledged, label: 'Acknowledged' },
{ value: StatusType.Acknowledged, label: 'Acknowledged' }, { value: StatusType.ImagesPulled, label: 'Images pre-pulled' },
{ value: StatusType.ImagesPulled, label: 'Images pre-pulled' }, { value: StatusType.Running, label: 'Deployed' },
{ value: StatusType.Running, label: 'Deployed' }, { value: StatusType.Error, label: 'Failed' },
{ value: StatusType.Error, label: 'Failed' }, ]}
]} />
/> </div>
</div>
)
} }
/> />
); );
@@ -121,6 +121,7 @@ function ErrorCell({ getValue }: CellContext<EdgeStackEnvironment, string>) {
return ( return (
<Button <Button
color="none"
className="flex cursor-pointer" className="flex cursor-pointer"
onClick={() => setIsExpanded(!isExpanded)} onClick={() => setIsExpanded(!isExpanded)}
> >
@@ -5,9 +5,14 @@ import {
type Icon as IconType, type Icon as IconType,
Loader2, Loader2,
XCircle, XCircle,
MinusCircle,
} from 'lucide-react'; } from 'lucide-react';
import { useEnvironmentList } from '@/react/portainer/environments/queries';
import { isVersionSmaller } from '@/react/common/semver-utils';
import { Icon, IconMode } from '@@/Icon'; import { Icon, IconMode } from '@@/Icon';
import { Tooltip } from '@@/Tip/Tooltip';
import { DeploymentStatus, EdgeStack, StatusType } from '../../types'; import { DeploymentStatus, EdgeStack, StatusType } from '../../types';
@@ -15,28 +20,51 @@ export function EdgeStackStatus({ edgeStack }: { edgeStack: EdgeStack }) {
const status = Object.values(edgeStack.Status); const status = Object.values(edgeStack.Status);
const lastStatus = _.compact(status.map((s) => _.last(s.Status))); const lastStatus = _.compact(status.map((s) => _.last(s.Status)));
const { icon, label, mode, spin } = getStatus( const environmentsQuery = useEnvironmentList({ edgeStackId: edgeStack.Id });
if (environmentsQuery.isLoading) {
return null;
}
const hasOldVersion = environmentsQuery.environments.some((env) =>
isVersionSmaller(env.Agent.Version, '2.19.0')
);
const { icon, label, mode, spin, tooltip } = getStatus(
edgeStack.NumDeployments, edgeStack.NumDeployments,
lastStatus lastStatus,
hasOldVersion
); );
return ( return (
<div className="mx-auto inline-flex items-center gap-2"> <div className="mx-auto inline-flex items-center gap-2">
{icon && <Icon icon={icon} spin={spin} mode={mode} />} {icon && <Icon icon={icon} spin={spin} mode={mode} />}
{label} {label}
{tooltip && <Tooltip message={tooltip} />}
</div> </div>
); );
} }
function getStatus( function getStatus(
numDeployments: number, numDeployments: number,
envStatus: Array<DeploymentStatus> envStatus: Array<DeploymentStatus>,
hasOldVersion: boolean
): { ): {
label: string; label: string;
icon?: IconType; icon?: IconType;
spin?: boolean; spin?: boolean;
mode?: IconMode; mode?: IconMode;
tooltip?: string;
} { } {
if (!numDeployments || hasOldVersion) {
return {
label: 'Unavailable',
icon: MinusCircle,
mode: 'secondary',
tooltip: getUnavailableTooltip(),
};
}
if (envStatus.length < numDeployments) { if (envStatus.length < numDeployments) {
return { return {
label: 'Deploying', label: 'Deploying',
@@ -56,7 +84,11 @@ function getStatus(
}; };
} }
const allRunning = envStatus.every((s) => s.Type === StatusType.Running); const allRunning = envStatus.every(
(s) =>
s.Type === StatusType.Running ||
(s.Type === StatusType.DeploymentReceived && hasOldVersion)
);
if (allRunning) { if (allRunning) {
return { return {
@@ -84,4 +116,16 @@ function getStatus(
spin: true, spin: true,
mode: 'primary', mode: 'primary',
}; };
function getUnavailableTooltip() {
if (!numDeployments) {
return 'Your edge stack is currently unavailable due to the absence of an available environment in your edge group';
}
if (hasOldVersion) {
return 'Please note that the new status feature for the Edge stack is only available for Edge Agent versions 2.19.0 and above. To access the status of your edge stack, it is essential to upgrade your Edge Agent to a corresponding version that is compatible with your Portainer server.';
}
return '';
}
} }
@@ -20,16 +20,18 @@ export function TableSettingsMenus({
return ( return (
<> <>
<ColumnVisibilityMenu<DecoratedEdgeStack> {columnsToHide && columnsToHide.length > 0 && (
columns={columnsToHide} <ColumnVisibilityMenu<DecoratedEdgeStack>
onChange={(hiddenColumns) => { columns={columnsToHide}
tableState.setHiddenColumns(hiddenColumns); onChange={(hiddenColumns) => {
tableInstance.setColumnVisibility( tableState.setHiddenColumns(hiddenColumns);
Object.fromEntries(hiddenColumns.map((col) => [col, false])) tableInstance.setColumnVisibility(
); Object.fromEntries(hiddenColumns.map((col) => [col, false]))
}} );
value={tableState.hiddenColumns} }}
/> value={tableState.hiddenColumns}
/>
)}
<TableSettingsMenu> <TableSettingsMenu>
<TableSettingsMenuAutoRefresh <TableSettingsMenuAutoRefresh
value={tableState.autoRefreshRate} value={tableState.autoRefreshRate}
@@ -45,13 +45,19 @@ export const columns = _.compact([
(item) => item.aggregatedStatus[StatusType.ImagesPulled] || 0, (item) => item.aggregatedStatus[StatusType.ImagesPulled] || 0,
{ {
header: 'Images pre-pulled', header: 'Images pre-pulled',
cell: ({ getValue, row }) => ( cell: ({ getValue, row: { original: item } }) => {
<DeploymentCounter if (!item.PrePullImage) {
count={getValue()} return <div className="text-center">-</div>;
type={StatusType.ImagesPulled} }
total={row.original.NumDeployments}
/> return (
), <DeploymentCounter
count={getValue()}
type={StatusType.ImagesPulled}
total={item.NumDeployments}
/>
);
},
enableSorting: false, enableSorting: false,
enableHiding: false, enableHiding: false,
meta: { meta: {
@@ -1,11 +1,22 @@
import { useQueryClient } from 'react-query';
import { PageHeader } from '@@/PageHeader'; import { PageHeader } from '@@/PageHeader';
import { queryKeys } from '../queries/query-keys';
import { EdgeStacksDatatable } from './EdgeStacksDatatable'; import { EdgeStacksDatatable } from './EdgeStacksDatatable';
export function ListView() { export function ListView() {
const queryClient = useQueryClient();
return ( return (
<> <>
<PageHeader title="Edge Stacks list" breadcrumbs="Edge Stacks" reload /> <PageHeader
title="Edge Stacks list"
breadcrumbs="Edge Stacks"
reload
onReload={() => queryClient.invalidateQueries(queryKeys.base())}
/>
<EdgeStacksDatatable /> <EdgeStacksDatatable />
</> </>
@@ -38,7 +38,7 @@ export function PrivateRegistryFieldset({
const [selected, setSelected] = useState(value); const [selected, setSelected] = useState(value);
const tooltipMessage = const tooltipMessage =
'Use this when using a private registry that requires credentials'; 'This allows you to provide credentials when using a private registry that requires authentication';
useEffect(() => { useEffect(() => {
if (checked) { if (checked) {
@@ -2,6 +2,11 @@ import { useEffect, useMemo, useState } from 'react';
import { FormikErrors } from 'formik'; import { FormikErrors } from 'formik';
import { KubernetesApplicationPublishingTypes } from '@/kubernetes/models/application/models'; import { KubernetesApplicationPublishingTypes } from '@/kubernetes/models/application/models';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import {
useIngressControllers,
useIngresses,
} from '@/react/kubernetes/ingresses/queries';
import { FormSection } from '@@/form-components/FormSection'; import { FormSection } from '@@/form-components/FormSection';
@@ -50,6 +55,11 @@ export function KubeServicesForm({
const [selectedServiceType, setSelectedServiceType] = const [selectedServiceType, setSelectedServiceType] =
useState<ServiceTypeValue>('ClusterIP'); useState<ServiceTypeValue>('ClusterIP');
// start loading ingresses and controllers early to reduce perceived loading time
const environmentId = useEnvironmentId();
useIngresses(environmentId, namespace ? [namespace] : []);
useIngressControllers(environmentId, namespace);
// when the appName changes, update the names for each service // when the appName changes, update the names for each service
// and the serviceNames for each service port // and the serviceNames for each service port
const newServiceNames = useMemo( const newServiceNames = useMemo(
@@ -10,9 +10,9 @@ export function PublishingExplaination() {
src={ingressDiagram} src={ingressDiagram}
alt="ingress explaination" alt="ingress explaination"
width={646} width={646}
className="flex w-full max-w-2xl basis-1/2 flex-col object-contain lg:w-1/2" className="flex w-full max-w-2xl basis-1/2 flex-col rounded border border-solid border-gray-5 object-contain lg:w-1/2"
/> />
<div className="ml-8 basis-1/2"> <div className="text-muted ml-8 basis-1/2 text-xs">
Expose the application workload via{' '} Expose the application workload via{' '}
<a <a
href="https://kubernetes.io/docs/concepts/services-networking/service/" href="https://kubernetes.io/docs/concepts/services-networking/service/"
@@ -111,10 +111,6 @@ export function AppIngressPathForm({
value={selectedIngress} value={selectedIngress}
defaultValue={ingressHostOptions[0]} defaultValue={ingressHostOptions[0]}
placeholder="Select a hostname..." placeholder="Select a hostname..."
theme={(theme) => ({
...theme,
borderRadius: 0,
})}
size="sm" size="sm"
onChange={(ingressOption) => { onChange={(ingressOption) => {
setSelectedIngress(ingressOption); setSelectedIngress(ingressOption);
@@ -44,13 +44,10 @@ export function AppIngressPathsForm({
namespace ? [namespace] : undefined namespace ? [namespace] : undefined
); );
const { data: ingresses } = ingressesQuery; const { data: ingresses } = ingressesQuery;
const ingressControllersQuery = useIngressControllers( const { data: ingressControllers, ...ingressControllersQuery } =
environmentId, useIngressControllers(environmentId, namespace);
namespace
);
const { data: ingressControllers } = ingressControllersQuery;
// if some ingress controllers are restricted by namespace, then filter the ingresses that use allowed ingress controllers // filter for the ingresses that use allowed ingress controllers
const allowedIngressHostNameOptions = useMemo(() => { const allowedIngressHostNameOptions = useMemo(() => {
const allowedIngressClasses = const allowedIngressClasses =
ingressControllers ingressControllers
@@ -20,12 +20,12 @@ export function ApplicationEnvVarsTable({ namespace, app }: Props) {
<> <>
<div className="text-muted mb-4 mt-6 flex items-center"> <div className="text-muted mb-4 mt-6 flex items-center">
<Icon icon={File} className="!mr-2" /> <Icon icon={File} className="!mr-2" />
Configuration Environment variables, ConfigMaps or Secrets
</div> </div>
{appEnvVars.length === 0 && ( {appEnvVars.length === 0 && (
<TextTip color="blue"> <TextTip color="blue">
This application is not using any environment variable or This application is not using any environment variable, ConfigMap or
configuration. Secret.
</TextTip> </TextTip>
)} )}
{appEnvVars.length > 0 && ( {appEnvVars.length > 0 && (
@@ -11,7 +11,7 @@ export function confirmDeleteAccess() {
environment. Removing the registry access could lead to a service environment. Removing the registry access could lead to a service
interruption for these applications. interruption for these applications.
</p> </p>
<p>Do you wish to continue?</p> <p>Are you sure you wish to continue?</p>
</> </>
), ),
confirmButton: buildConfirmButton('Remove', 'danger'), confirmButton: buildConfirmButton('Remove', 'danger'),
@@ -199,7 +199,7 @@ export function IngressClassDatatable({
</p> </p>
<ul className="ml-6"> <ul className="ml-6">
{usedControllersToDisallow.map((controller) => ( {usedControllersToDisallow.map((controller) => (
<li key={controller.ClassName}>${controller.ClassName}</li> <li key={controller.ClassName}>{controller.ClassName}</li>
))} ))}
</ul> </ul>
<p> <p>
@@ -9,6 +9,7 @@ import {
notifySuccess, notifySuccess,
} from '@/portainer/services/notifications'; } from '@/portainer/services/notifications';
import { isFulfilled, isRejected } from '@/portainer/helpers/promise-utils'; import { isFulfilled, isRejected } from '@/portainer/helpers/promise-utils';
import { pluralize } from '@/portainer/helpers/strings';
import { parseKubernetesAxiosError } from '../axiosError'; import { parseKubernetesAxiosError } from '../axiosError';
@@ -104,7 +105,10 @@ export function useMutationDeleteConfigMaps(environmentId: EnvironmentId) {
// show one summary message for all successful deletes // show one summary message for all successful deletes
if (successfulConfigMaps.length) { if (successfulConfigMaps.length) {
notifySuccess( notifySuccess(
'ConfigMaps successfully removed', `${pluralize(
successfulConfigMaps.length,
'ConfigMap'
)} successfully removed`,
successfulConfigMaps.join(', ') successfulConfigMaps.join(', ')
); );
} }
@@ -9,6 +9,7 @@ import {
notifySuccess, notifySuccess,
} from '@/portainer/services/notifications'; } from '@/portainer/services/notifications';
import { isFulfilled, isRejected } from '@/portainer/helpers/promise-utils'; import { isFulfilled, isRejected } from '@/portainer/helpers/promise-utils';
import { pluralize } from '@/portainer/helpers/strings';
import { parseKubernetesAxiosError } from '../axiosError'; import { parseKubernetesAxiosError } from '../axiosError';
@@ -100,7 +101,10 @@ export function useMutationDeleteSecrets(environmentId: EnvironmentId) {
// show one summary message for all successful deletes // show one summary message for all successful deletes
if (successfulSecrets.length) { if (successfulSecrets.length) {
notifySuccess( notifySuccess(
'Secrets successfully removed', `${pluralize(
successfulSecrets.length,
'Secret'
)} successfully removed`,
successfulSecrets.join(', ') successfulSecrets.join(', ')
); );
} }
@@ -7,7 +7,7 @@ import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { useConfigurations } from '@/react/kubernetes/configs/queries'; import { useConfigurations } from '@/react/kubernetes/configs/queries';
import { useNamespaces } from '@/react/kubernetes/namespaces/queries'; import { useNamespaces } from '@/react/kubernetes/namespaces/queries';
import { useServices } from '@/react/kubernetes/networks/services/queries'; import { useServices } from '@/react/kubernetes/networks/services/queries';
import { notifySuccess, notifyError } from '@/portainer/services/notifications'; import { notifyError, notifySuccess } from '@/portainer/services/notifications';
import { useAuthorizations } from '@/react/hooks/useUser'; import { useAuthorizations } from '@/react/hooks/useUser';
import { Link } from '@@/Link'; import { Link } from '@@/Link';
@@ -23,8 +23,7 @@ import {
useIngressControllers, useIngressControllers,
} from '../queries'; } from '../queries';
import { Annotation } from './Annotations/types'; import { Rule, Path, Host, GroupedServiceOptions } from './types';
import { Rule, Path, Host } from './types';
import { IngressForm } from './IngressForm'; import { IngressForm } from './IngressForm';
import { import {
prepareTLS, prepareTLS,
@@ -33,6 +32,7 @@ import {
prepareRuleFromIngress, prepareRuleFromIngress,
checkIfPathExistsWithHost, checkIfPathExistsWithHost,
} from './utils'; } from './utils';
import { Annotation } from './Annotations/types';
export function CreateIngressView() { export function CreateIngressView() {
const environmentId = useEnvironmentId(); const environmentId = useEnvironmentId();
@@ -53,35 +53,27 @@ export function CreateIngressView() {
const [namespace, setNamespace] = useState<string>(params.namespace || ''); const [namespace, setNamespace] = useState<string>(params.namespace || '');
const [ingressRule, setIngressRule] = useState<Rule>({} as Rule); const [ingressRule, setIngressRule] = useState<Rule>({} as Rule);
// isEditClassNameSet is used to prevent premature validation of the classname in the edit view
const [isEditClassNameSet, setIsEditClassNameSet] = useState<boolean>(false);
const [errors, setErrors] = useState<Record<string, ReactNode>>( const [errors, setErrors] = useState<Record<string, ReactNode>>(
{} as Record<string, string> {} as Record<string, string>
); );
const namespacesResults = useNamespaces(environmentId); const { data: namespaces, ...namespacesQuery } = useNamespaces(environmentId);
const servicesResults = useServices(environmentId, namespace); const { data: allServices } = useServices(environmentId, namespace);
const configResults = useConfigurations(environmentId, namespace); const configResults = useConfigurations(environmentId, namespace);
const ingressesResults = useIngresses( const ingressesResults = useIngresses(
environmentId, environmentId,
namespacesResults.data ? Object.keys(namespacesResults?.data || {}) : [] namespaces ? Object.keys(namespaces || {}) : []
);
const ingressControllersResults = useIngressControllers(
environmentId,
namespace
); );
const { data: ingressControllers, ...ingressControllersQuery } =
useIngressControllers(environmentId, namespace);
const createIngressMutation = useCreateIngress(); const createIngressMutation = useCreateIngress();
const updateIngressMutation = useUpdateIngress(); const updateIngressMutation = useUpdateIngress();
const isLoading =
(servicesResults.isLoading &&
configResults.isLoading &&
namespacesResults.isLoading &&
ingressesResults.isLoading &&
ingressControllersResults.isLoading) ||
(isEdit && !ingressRule.IngressName);
const [ingressNames, ingresses, ruleCounterByNamespace, hostWithTLS] = const [ingressNames, ingresses, ruleCounterByNamespace, hostWithTLS] =
useMemo((): [ useMemo((): [
string[], string[],
@@ -121,40 +113,55 @@ export function CreateIngressView() {
]; ];
}, [ingressesResults.data, namespace]); }, [ingressesResults.data, namespace]);
const namespacesOptions: Option<string>[] = [ const namespaceOptions = useMemo(
{ label: 'Select a namespace', value: '' },
];
Object.entries(namespacesResults?.data || {}).forEach(([ns, val]) => {
if (!val.IsSystem) {
namespacesOptions.push({
label: ns,
value: ns,
});
}
});
const clusterIpServices = useMemo(
() => servicesResults.data?.filter((s) => s.Type === 'ClusterIP'),
[servicesResults.data]
);
const servicesOptions = useMemo(
() => () =>
clusterIpServices?.map((service) => ({ Object.entries(namespaces || {})
label: service.Name, .filter(([, nsValue]) => !nsValue.IsSystem)
value: service.Name, .map(([nsKey]) => ({
})), label: nsKey,
[clusterIpServices] value: nsKey,
})),
[namespaces]
); );
const serviceOptions = [ const serviceOptions: GroupedServiceOptions = useMemo(() => {
{ label: 'Select a service', value: '' }, const groupedOptions: GroupedServiceOptions = (
...(servicesOptions || []), allServices?.reduce<GroupedServiceOptions>(
]; (groupedOptions, service) => {
// add a new option to the group that matches the service type
const newGroupedOptions = groupedOptions.map((group) => {
if (group.label === service.Type) {
return {
...group,
options: [
...group.options,
{
label: service.Name,
selectedLabel: `${service.Name} (${service.Type})`,
value: service.Name,
},
],
};
}
return group;
});
return newGroupedOptions;
},
[
{ label: 'ClusterIP', options: [] },
{ label: 'NodePort', options: [] },
{ label: 'LoadBalancer', options: [] },
] as GroupedServiceOptions
) || []
).filter((group) => group.options.length > 0);
return groupedOptions;
}, [allServices]);
const servicePorts = useMemo( const servicePorts = useMemo(
() => () =>
clusterIpServices allServices
? Object.fromEntries( ? Object.fromEntries(
clusterIpServices?.map((service) => [ allServices?.map((service) => [
service.Name, service.Name,
service.Ports.map((port) => ({ service.Ports.map((port) => ({
label: String(port.Port), label: String(port.Port),
@@ -163,42 +170,96 @@ export function CreateIngressView() {
]) ])
) )
: {}, : {},
[clusterIpServices] [allServices]
); );
const existingIngressClass = useMemo( const existingIngressClass = useMemo(
() => () =>
ingressControllersResults.data?.find( ingressControllers?.find(
(i) => (controller) =>
i.ClassName === ingressRule.IngressClassName || controller.ClassName === ingressRule.IngressClassName ||
(i.Type === 'custom' && ingressRule.IngressClassName === '') (controller.Type === 'custom' && ingressRule.IngressClassName === '')
), ),
[ingressControllersResults.data, ingressRule.IngressClassName] [ingressControllers, ingressRule.IngressClassName]
); );
const ingressClassOptions: Option<string>[] = [
{ label: 'Select an ingress class', value: '' },
...(ingressControllersResults.data
?.filter((cls) => cls.Availability)
.map((cls) => ({
label: cls.ClassName,
value: cls.ClassName,
})) || []),
];
if ( const ingressClassOptions: Option<string>[] = useMemo(() => {
(!existingIngressClass || const allowedIngressClassOptions =
(existingIngressClass && !existingIngressClass.Availability)) && ingressControllers
ingressRule.IngressClassName && ?.filter((controller) => !!controller.Availability)
!ingressControllersResults.isLoading .map((cls) => ({
) { label: cls.ClassName,
const optionLabel = !ingressRule.IngressType value: cls.ClassName,
? `${ingressRule.IngressClassName} - NOT FOUND` })) || [];
: `${ingressRule.IngressClassName} - DISALLOWED`;
ingressClassOptions.push({ // if the ingress class is not set, return only the allowed ingress classes
label: optionLabel, if (ingressRule.IngressClassName === '' || !isEdit) {
value: ingressRule.IngressClassName, return allowedIngressClassOptions;
}); }
}
// if the ingress class is set and it exists (even if disallowed), return the allowed ingress classes + the disallowed option
const disallowedIngressClasses =
ingressControllers
?.filter(
(controller) =>
!controller.Availability &&
existingIngressClass?.ClassName === controller.ClassName
)
.map((controller) => ({
label: `${controller.ClassName} - DISALLOWED`,
value: controller.ClassName,
})) || [];
const existingIngressClassFound = ingressControllers?.find(
(controller) => existingIngressClass?.ClassName === controller.ClassName
);
if (existingIngressClassFound) {
return [...allowedIngressClassOptions, ...disallowedIngressClasses];
}
// if the ingress class is set and it doesn't exist, return the allowed ingress classes + the not found option
const notFoundIngressClassOption = {
label: `${ingressRule.IngressClassName} - NOT FOUND`,
value: ingressRule.IngressClassName || '',
};
return [...allowedIngressClassOptions, notFoundIngressClassOption];
}, [
existingIngressClass?.ClassName,
ingressControllers,
ingressRule.IngressClassName,
isEdit,
]);
const handleIngressChange = useCallback(
(key: string, val: string) => {
setIngressRule((prevRules) => {
const rule = { ...prevRules, [key]: val };
if (key === 'IngressClassName') {
rule.IngressType = ingressControllers?.find(
(c) => c.ClassName === val
)?.Type;
}
return rule;
});
},
[ingressControllers]
);
// when them selected ingress class option update is no longer available set to an empty value
useEffect(() => {
const ingressClasses = ingressClassOptions.map((option) => option.value);
if (
!ingressClasses.includes(ingressRule.IngressClassName) &&
ingressControllersQuery.isSuccess
) {
handleIngressChange('IngressClassName', '');
}
}, [
handleIngressChange,
ingressClassOptions,
ingressControllersQuery.isSuccess,
ingressRule.IngressClassName,
]);
const matchedConfigs = configResults?.data?.filter( const matchedConfigs = configResults?.data?.filter(
(config) => (config) =>
@@ -221,15 +282,15 @@ export function CreateIngressView() {
!!params.name && !!params.name &&
ingressesResults.data && ingressesResults.data &&
!ingressRule.IngressName && !ingressRule.IngressName &&
!ingressControllersResults.isLoading && !ingressControllersQuery.isLoading &&
!ingressControllersResults.isLoading !ingressControllersQuery.isLoading
) { ) {
// if it is an edit screen, prepare the rule from the ingress // if it is an edit screen, prepare the rule from the ingress
const ing = ingressesResults.data?.find( const ing = ingressesResults.data?.find(
(ing) => ing.Name === params.name && ing.Namespace === params.namespace (ing) => ing.Name === params.name && ing.Namespace === params.namespace
); );
if (ing) { if (ing) {
const type = ingressControllersResults.data?.find( const type = ingressControllers?.find(
(c) => (c) =>
c.ClassName === ing.ClassName || c.ClassName === ing.ClassName ||
(c.Type === 'custom' && !ing.ClassName) (c.Type === 'custom' && !ing.ClassName)
@@ -237,13 +298,14 @@ export function CreateIngressView() {
const r = prepareRuleFromIngress(ing, type); const r = prepareRuleFromIngress(ing, type);
r.IngressType = type || r.IngressType; r.IngressType = type || r.IngressType;
setIngressRule(r); setIngressRule(r);
setIsEditClassNameSet(true);
} }
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [ }, [
params.name, params.name,
ingressesResults.data, ingressesResults.data,
ingressControllersResults.data, ingressControllers,
ingressRule.IngressName, ingressRule.IngressName,
params.namespace, params.namespace,
]); ]);
@@ -291,7 +353,7 @@ export function CreateIngressView() {
( (
ingressRule: Rule, ingressRule: Rule,
ingressNames: string[], ingressNames: string[],
serviceOptions: Option<string>[], groupedServiceOptions: GroupedServiceOptions,
existingIngressClass?: IngressController existingIngressClass?: IngressController
) => { ) => {
const errors: Record<string, ReactNode> = {}; const errors: Record<string, ReactNode> = {};
@@ -313,12 +375,15 @@ export function CreateIngressView() {
errors.ingressName = 'Ingress name already exists'; errors.ingressName = 'Ingress name already exists';
} }
if (!rule.IngressClassName) { if (
(!ingressClassOptions.length || !rule.IngressClassName) &&
ingressControllersQuery.isSuccess
) {
errors.className = 'Ingress class is required'; errors.className = 'Ingress class is required';
} }
} }
if (isEdit && !ingressRule.IngressClassName) { if (isEdit && !ingressRule.IngressClassName && isEditClassNameSet) {
errors.className = errors.className =
'No ingress class is currently set for this ingress - use of the Portainer UI requires one to be set.'; 'No ingress class is currently set for this ingress - use of the Portainer UI requires one to be set.';
} }
@@ -397,10 +462,14 @@ export function CreateIngressView() {
'Service name is required'; 'Service name is required';
} }
const availableServiceNames = groupedServiceOptions.flatMap(
(optionGroup) => optionGroup.options.map((option) => option.value)
);
if ( if (
isEdit && isEdit &&
path.ServiceName && path.ServiceName &&
!serviceOptions.find((s) => s.value === path.ServiceName) !availableServiceNames.find((sn) => sn === path.ServiceName)
) { ) {
errors[`hosts[${hi}].paths[${pi}].servicename`] = ( errors[`hosts[${hi}].paths[${pi}].servicename`] = (
<span> <span>
@@ -455,26 +524,33 @@ export function CreateIngressView() {
} }
return true; return true;
}, },
[ingresses, environmentId, isEdit, params.name] [
isEdit,
isEditClassNameSet,
ingressClassOptions.length,
ingressControllersQuery.isSuccess,
environmentId,
ingresses,
params.name,
]
); );
const debouncedValidate = useMemo(() => debounce(validate, 300), [validate]); const debouncedValidate = useMemo(() => debounce(validate, 500), [validate]);
useEffect(() => { useEffect(() => {
if (namespace.length > 0) { if (namespace.length > 0) {
debouncedValidate( debouncedValidate(
ingressRule, ingressRule,
ingressNames || [], ingressNames || [],
servicesOptions || [], serviceOptions || [],
existingIngressClass existingIngressClass
); );
} }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ }, [
ingressRule, ingressRule,
namespace, namespace,
ingressNames, ingressNames,
servicesOptions, serviceOptions,
existingIngressClass, existingIngressClass,
debouncedValidate, debouncedValidate,
]); ]);
@@ -482,14 +558,14 @@ export function CreateIngressView() {
return ( return (
<> <>
<PageHeader <PageHeader
title={isEdit ? 'Edit ingress' : 'Add ingress'} title={isEdit ? 'Edit ingress' : 'Create ingress'}
breadcrumbs={[ breadcrumbs={[
{ {
link: 'kubernetes.ingresses', link: 'kubernetes.ingresses',
label: 'Ingresses', label: 'Ingresses',
}, },
{ {
label: isEdit ? 'Edit ingress' : 'Add ingress', label: isEdit ? 'Edit ingress' : 'Create ingress',
}, },
]} ]}
/> />
@@ -497,10 +573,10 @@ export function CreateIngressView() {
<div className="col-sm-12"> <div className="col-sm-12">
<IngressForm <IngressForm
environmentID={environmentId} environmentID={environmentId}
isLoading={isLoading}
isEdit={isEdit} isEdit={isEdit}
rule={ingressRule} rule={ingressRule}
ingressClassOptions={ingressClassOptions} ingressClassOptions={ingressClassOptions}
isIngressClassOptionsLoading={ingressControllersQuery.isLoading}
errors={errors} errors={errors}
servicePorts={servicePorts} servicePorts={servicePorts}
tlsOptions={tlsOptions} tlsOptions={tlsOptions}
@@ -519,10 +595,13 @@ export function CreateIngressView() {
handleAnnotationChange={handleAnnotationChange} handleAnnotationChange={handleAnnotationChange}
namespace={namespace} namespace={namespace}
handleNamespaceChange={handleNamespaceChange} handleNamespaceChange={handleNamespaceChange}
namespacesOptions={namespacesOptions} namespacesOptions={namespaceOptions}
isNamespaceOptionsLoading={namespacesQuery.isLoading}
// wait for ingress results too to set a name that's not taken with handleNamespaceChange()
isIngressNamesLoading={ingressesResults.isLoading}
/> />
</div> </div>
{namespace && !isLoading && ( {namespace && (
<div className="col-sm-12"> <div className="col-sm-12">
<Button <Button
onClick={() => handleCreateIngressRules()} onClick={() => handleCreateIngressRules()}
@@ -543,18 +622,6 @@ export function CreateIngressView() {
} }
} }
function handleIngressChange(key: string, val: string) {
setIngressRule((prevRules) => {
const rule = { ...prevRules, [key]: val };
if (key === 'IngressClassName') {
rule.IngressType = ingressControllersResults.data?.find(
(c) => c.ClassName === val
)?.Type;
}
return rule;
});
}
function handleTLSChange(hostIndex: number, tls: string) { function handleTLSChange(hostIndex: number, tls: string) {
setIngressRule((prevRules) => { setIngressRule((prevRules) => {
const rule = { ...prevRules }; const rule = { ...prevRules };
@@ -636,7 +703,8 @@ export function CreateIngressView() {
Key: uuidv4(), Key: uuidv4(),
Namespace: namespace, Namespace: namespace,
IngressName: newKey, IngressName: newKey,
IngressClassName: '', IngressClassName: ingressRule.IngressClassName || '',
IngressType: ingressRule.IngressType || '',
Hosts: [host], Hosts: [host],
}; };
@@ -1,19 +1,23 @@
import { ChangeEvent, ReactNode } from 'react'; import { ChangeEvent, ReactNode, useEffect } from 'react';
import { Plus, RefreshCw, Trash2 } from 'lucide-react'; import { Plus, RefreshCw, Trash2 } from 'lucide-react';
import Route from '@/assets/ico/route.svg?c'; import Route from '@/assets/ico/route.svg?c';
import { Link } from '@@/Link'; import { Link } from '@@/Link';
import { Select, Option } from '@@/form-components/Input/Select'; import { Option } from '@@/form-components/Input/Select';
import { FormError } from '@@/form-components/FormError'; import { FormError } from '@@/form-components/FormError';
import { Widget, WidgetBody, WidgetTitle } from '@@/Widget'; import { Widget, WidgetBody, WidgetTitle } from '@@/Widget';
import { Tooltip } from '@@/Tip/Tooltip'; import { Tooltip } from '@@/Tip/Tooltip';
import { Button } from '@@/buttons'; import { Button } from '@@/buttons';
import { TooltipWithChildren } from '@@/Tip/TooltipWithChildren'; import { TooltipWithChildren } from '@@/Tip/TooltipWithChildren';
import { TextTip } from '@@/Tip/TextTip'; import { TextTip } from '@@/Tip/TextTip';
import { Card } from '@@/Card';
import { InputGroup } from '@@/form-components/InputGroup';
import { InlineLoader } from '@@/InlineLoader';
import { Select } from '@@/form-components/ReactSelect';
import { Annotations } from './Annotations'; import { Annotations } from './Annotations';
import { Rule, ServicePorts } from './types'; import { GroupedServiceOptions, Rule, ServicePorts } from './types';
import '../style.css'; import '../style.css';
@@ -33,15 +37,17 @@ interface Props {
rule: Rule; rule: Rule;
errors: Record<string, ReactNode>; errors: Record<string, ReactNode>;
isLoading: boolean;
isEdit: boolean; isEdit: boolean;
namespace: string; namespace: string;
servicePorts: ServicePorts; servicePorts: ServicePorts;
ingressClassOptions: Option<string>[]; ingressClassOptions: Option<string>[];
serviceOptions: Option<string>[]; isIngressClassOptionsLoading: boolean;
serviceOptions: GroupedServiceOptions;
tlsOptions: Option<string>[]; tlsOptions: Option<string>[];
namespacesOptions: Option<string>[]; namespacesOptions: Option<string>[];
isNamespaceOptionsLoading: boolean;
isIngressNamesLoading: boolean;
removeIngressRoute: (hostIndex: number, pathIndex: number) => void; removeIngressRoute: (hostIndex: number, pathIndex: number) => void;
removeIngressHost: (hostIndex: number) => void; removeIngressHost: (hostIndex: number) => void;
@@ -76,7 +82,6 @@ interface Props {
export function IngressForm({ export function IngressForm({
environmentID, environmentID,
rule, rule,
isLoading,
isEdit, isEdit,
servicePorts, servicePorts,
tlsOptions, tlsOptions,
@@ -94,20 +99,40 @@ export function IngressForm({
reloadTLSCerts, reloadTLSCerts,
handleAnnotationChange, handleAnnotationChange,
ingressClassOptions, ingressClassOptions,
isIngressClassOptionsLoading,
errors, errors,
namespacesOptions, namespacesOptions,
isNamespaceOptionsLoading,
isIngressNamesLoading,
handleNamespaceChange, handleNamespaceChange,
namespace, namespace,
}: Props) { }: Props) {
if (isLoading) {
return <div>Loading...</div>;
}
const hasNoHostRule = rule.Hosts?.some((host) => host.NoHost); const hasNoHostRule = rule.Hosts?.some((host) => host.NoHost);
const placeholderAnnotation = const placeholderAnnotation =
PlaceholderAnnotations[rule.IngressType || 'other'] || PlaceholderAnnotations[rule.IngressType || 'other'] ||
PlaceholderAnnotations.other; PlaceholderAnnotations.other;
const pathTypes = PathTypes[rule.IngressType || 'other'] || PathTypes.other; const pathTypes = PathTypes[rule.IngressType || 'other'] || PathTypes.other;
// when the namespace options update the value to an available one
useEffect(() => {
const namespaces = namespacesOptions.map((option) => option.value);
if (
!isEdit &&
!namespaces.includes(namespace) &&
namespaces.length > 0 &&
!isIngressNamesLoading
) {
handleNamespaceChange(namespaces[0]);
}
}, [
namespacesOptions,
namespace,
handleNamespaceChange,
isNamespaceOptionsLoading,
isEdit,
isIngressNamesLoading,
]);
return ( return (
<Widget> <Widget>
<WidgetTitle icon={Route} title="Ingress" /> <WidgetTitle icon={Route} title="Ingress" />
@@ -121,19 +146,40 @@ export function IngressForm({
> >
Namespace Namespace
</label> </label>
<div className="col-sm-4"> {isNamespaceOptionsLoading && (
{isEdit ? ( <div className="col-sm-4">
namespace <InlineLoader className="pt-2">
) : ( Loading namespaces...
<Select </InlineLoader>
name="namespaces" </div>
options={namespacesOptions || []} )}
onChange={(e) => handleNamespaceChange(e.target.value)} {!isNamespaceOptionsLoading && (
defaultValue={namespace} <div className={`col-sm-4 ${isEdit && 'control-label'}`}>
disabled={isEdit} {isEdit ? (
/> namespace
)} ) : (
</div> <Select
name="namespaces"
options={namespacesOptions}
value={
namespace
? { value: namespace, label: namespace }
: null
}
isDisabled={isEdit}
onChange={(val) =>
handleNamespaceChange(val?.value || '')
}
placeholder={
namespacesOptions.length
? 'Select a namespace'
: 'No namespaces available'
}
noOptionsMessage={() => 'No namespaces available'}
/>
)}
</div>
)}
</div> </div>
</div> </div>
</div> </div>
@@ -180,20 +226,43 @@ export function IngressForm({
Ingress class Ingress class
</label> </label>
<div className="col-sm-4"> <div className="col-sm-4">
<Select {isIngressClassOptionsLoading && (
name="ingress_class" <InlineLoader className="pt-2">
className="form-control" Loading ingress classes...
placeholder="Ingress name" </InlineLoader>
defaultValue={rule.IngressClassName} )}
onChange={(e: ChangeEvent<HTMLSelectElement>) => {!isIngressClassOptionsLoading && (
handleIngressChange('IngressClassName', e.target.value) <>
} <Select
options={ingressClassOptions} name="ingress_class"
/> placeholder={
{errors.className && ( ingressClassOptions.length
<FormError className="error-inline mt-1"> ? 'Select an ingress class'
{errors.className} : 'No ingress classes available'
</FormError> }
options={ingressClassOptions}
value={
rule.IngressClassName
? {
label: rule.IngressClassName,
value: rule.IngressClassName,
}
: null
}
onChange={(ingressClassOption) =>
handleIngressChange(
'IngressClassName',
ingressClassOption?.value || ''
)
}
noOptionsMessage={() => 'No ingress classes available'}
/>
{errors.className && (
<FormError className="error-inline mt-1">
{errors.className}
</FormError>
)}
</>
)} )}
</div> </div>
</div> </div>
@@ -300,9 +369,9 @@ export function IngressForm({
{namespace && {namespace &&
rule?.Hosts?.map((host, hostIndex) => ( rule?.Hosts?.map((host, hostIndex) => (
<div className="row rule bordered mb-5" key={host.Key}> <Card key={host.Key} className="mb-5">
<div className="col-sm-12"> <div className="flex flex-col">
<div className="row rule-actions mt-5"> <div className="row rule-actions">
<div className="col-sm-3 p-0"> <div className="col-sm-3 p-0">
{!host.NoHost ? 'Rule' : 'Fallback rule'} {!host.NoHost ? 'Rule' : 'Fallback rule'}
</div> </div>
@@ -323,11 +392,9 @@ export function IngressForm({
{!host.NoHost && ( {!host.NoHost && (
<div className="row"> <div className="row">
<div className="form-group col-sm-6 col-lg-4 !pl-0 !pr-2"> <div className="form-group col-sm-6 col-lg-4 !pl-0 !pr-2">
<div className="input-group input-group-sm"> <InputGroup size="small">
<span className="input-group-addon required"> <InputGroup.Addon required>Hostname</InputGroup.Addon>
Hostname <InputGroup.Input
</span>
<input
name={`ingress_host_${hostIndex}`} name={`ingress_host_${hostIndex}`}
type="text" type="text"
className="form-control form-control-sm" className="form-control form-control-sm"
@@ -337,7 +404,7 @@ export function IngressForm({
handleHostChange(hostIndex, e.target.value) handleHostChange(hostIndex, e.target.value)
} }
/> />
</div> </InputGroup>
{errors[`hosts[${hostIndex}].host`] && ( {errors[`hosts[${hostIndex}].host`] && (
<FormError className="mt-1 !mb-0"> <FormError className="mt-1 !mb-0">
{errors[`hosts[${hostIndex}].host`]} {errors[`hosts[${hostIndex}].host`]}
@@ -346,17 +413,29 @@ export function IngressForm({
</div> </div>
<div className="form-group col-sm-6 col-lg-4 !pr-0 !pl-2"> <div className="form-group col-sm-6 col-lg-4 !pr-0 !pl-2">
<div className="input-group input-group-sm"> <InputGroup size="small">
<span className="input-group-addon">TLS secret</span> <InputGroup.Addon>TLS secret</InputGroup.Addon>
<Select <Select
key={tlsOptions.toString() + host.Secret} key={tlsOptions.toString() + host.Secret}
name={`ingress_tls_${hostIndex}`} name={`ingress_tls_${hostIndex}`}
options={tlsOptions} value={
onChange={(e: ChangeEvent<HTMLSelectElement>) => host.Secret !== undefined
handleTLSChange(hostIndex, e.target.value) ? {
value: host.Secret,
label: host.Secret || 'No TLS',
}
: null
} }
defaultValue={host.Secret} onChange={(TLSOption) =>
className="!rounded-r-none" handleTLSChange(hostIndex, TLSOption?.value || '')
}
placeholder={
tlsOptions.length
? 'Select a TLS secret'
: 'No TLS secrets available'
}
noOptionsMessage={() => 'No TLS secrets available'}
size="sm"
/> />
{!host.NoHost && ( {!host.NoHost && (
<div className="input-group-btn"> <div className="input-group-btn">
@@ -367,7 +446,7 @@ export function IngressForm({
/> />
</div> </div>
)} )}
</div> </InputGroup>
</div> </div>
<div className="col-sm-12 col-lg-4 flex h-[30px] items-center pl-2"> <div className="col-sm-12 col-lg-4 flex h-[30px] items-center pl-2">
@@ -414,25 +493,40 @@ export function IngressForm({
key={`path_${path.Key}}`} key={`path_${path.Key}}`}
> >
<div className="form-group col-sm-3 col-xl-2 !m-0 !pl-0"> <div className="form-group col-sm-3 col-xl-2 !m-0 !pl-0">
<div className="input-group input-group-sm"> <InputGroup size="small">
<span className="input-group-addon required"> <InputGroup.Addon required>Service</InputGroup.Addon>
Service
</span>
<Select <Select
key={serviceOptions.toString() + path.ServiceName} key={serviceOptions.toString() + path.ServiceName}
name={`ingress_service_${hostIndex}_${pathIndex}`} name={`ingress_service_${hostIndex}_${pathIndex}`}
options={serviceOptions} options={serviceOptions}
onChange={(e: ChangeEvent<HTMLSelectElement>) => value={
path.ServiceName
? {
value: path.ServiceName,
label: getServiceLabel(
serviceOptions,
path.ServiceName
),
}
: null
}
onChange={(serviceOption) =>
handlePathChange( handlePathChange(
hostIndex, hostIndex,
pathIndex, pathIndex,
'ServiceName', 'ServiceName',
e.target.value serviceOption?.value || ''
) )
} }
defaultValue={path.ServiceName} placeholder={
serviceOptions.length
? 'Select a service'
: 'No services available'
}
noOptionsMessage={() => 'No services available'}
size="sm"
/> />
</div> </InputGroup>
{errors[ {errors[
`hosts[${hostIndex}].paths[${pathIndex}].servicename` `hosts[${hostIndex}].paths[${pathIndex}].servicename`
] && ( ] && (
@@ -446,38 +540,49 @@ export function IngressForm({
)} )}
</div> </div>
<div className="form-group col-sm-2 col-xl-2 !m-0 !pl-0"> <div className="form-group col-sm-2 col-xl-2 !m-0 min-w-[170px] !pl-0">
{servicePorts && ( {servicePorts && (
<> <>
<div className="input-group input-group-sm"> <InputGroup size="small">
<span className="input-group-addon required"> <InputGroup.Addon required>
Service port Service port
</span> </InputGroup.Addon>
<Select <Select
key={servicePorts.toString() + path.ServicePort} key={servicePorts.toString() + path.ServicePort}
name={`ingress_servicePort_${hostIndex}_${pathIndex}`} name={`ingress_servicePort_${hostIndex}_${pathIndex}`}
options={ options={
path.ServiceName && servicePorts[path.ServiceName]?.map(
servicePorts[path.ServiceName] (portOption) => ({
? servicePorts[path.ServiceName] ...portOption,
: [ value: portOption.value.toString(),
{ })
label: 'Select port', ) || []
value: '',
},
]
} }
onChange={(e: ChangeEvent<HTMLSelectElement>) => onChange={(option) =>
handlePathChange( handlePathChange(
hostIndex, hostIndex,
pathIndex, pathIndex,
'ServicePort', 'ServicePort',
e.target.value option?.value || ''
) )
} }
defaultValue={path.ServicePort} value={
path.ServicePort
? {
label: path.ServicePort.toString(),
value: path.ServicePort.toString(),
}
: null
}
placeholder={
servicePorts[path.ServiceName]?.length
? 'Select a port'
: 'No ports available'
}
noOptionsMessage={() => 'No ports available'}
size="sm"
/> />
</div> </InputGroup>
{errors[ {errors[
`hosts[${hostIndex}].paths[${pathIndex}].serviceport` `hosts[${hostIndex}].paths[${pathIndex}].serviceport`
] && ( ] && (
@@ -494,30 +599,42 @@ export function IngressForm({
</div> </div>
<div className="form-group col-sm-3 col-xl-2 !m-0 !pl-0"> <div className="form-group col-sm-3 col-xl-2 !m-0 !pl-0">
<div className="input-group input-group-sm"> <InputGroup size="small">
<span className="input-group-addon">Path type</span> <InputGroup.Addon>Path type</InputGroup.Addon>
<Select <Select<Option<string>>
key={servicePorts.toString() + path.PathType} key={servicePorts.toString() + path.PathType}
name={`ingress_pathType_${hostIndex}_${pathIndex}`} name={`ingress_pathType_${hostIndex}_${pathIndex}`}
options={ options={
pathTypes pathTypes?.map((type) => ({
? pathTypes.map((type) => ({ label: type,
label: type, value: type,
value: type, })) || []
}))
: []
} }
onChange={(e: ChangeEvent<HTMLSelectElement>) => onChange={(option) =>
handlePathChange( handlePathChange(
hostIndex, hostIndex,
pathIndex, pathIndex,
'PathType', 'PathType',
e.target.value option?.value || ''
) )
} }
defaultValue={path.PathType} value={
path.PathType
? {
label: path.PathType,
value: path.PathType,
}
: null
}
placeholder={
pathTypes?.length
? 'Select a path type'
: 'No path types available'
}
noOptionsMessage={() => 'No path types available'}
size="sm"
/> />
</div> </InputGroup>
{errors[ {errors[
`hosts[${hostIndex}].paths[${pathIndex}].pathType` `hosts[${hostIndex}].paths[${pathIndex}].pathType`
] && ( ] && (
@@ -532,9 +649,9 @@ export function IngressForm({
</div> </div>
<div className="form-group col-sm-3 col-xl-3 !m-0 !pl-0"> <div className="form-group col-sm-3 col-xl-3 !m-0 !pl-0">
<div className="input-group input-group-sm"> <InputGroup size="small">
<span className="input-group-addon required">Path</span> <InputGroup.Addon required>Path</InputGroup.Addon>
<input <InputGroup.Input
className="form-control" className="form-control"
name={`ingress_route_${hostIndex}-${pathIndex}`} name={`ingress_route_${hostIndex}-${pathIndex}`}
placeholder="/example" placeholder="/example"
@@ -550,7 +667,7 @@ export function IngressForm({
) )
} }
/> />
</div> </InputGroup>
{errors[ {errors[
`hosts[${hostIndex}].paths[${pathIndex}].path` `hosts[${hostIndex}].paths[${pathIndex}].path`
] && ( ] && (
@@ -566,7 +683,7 @@ export function IngressForm({
<div className="form-group col-sm-1 !m-0 !pl-0"> <div className="form-group col-sm-1 !m-0 !pl-0">
<Button <Button
className="btn-only-icon vertical-center !ml-0" className="!ml-0 h-[30px]"
color="dangerlight" color="dangerlight"
type="button" type="button"
data-cy={`k8sAppCreate-rmPortButton_${hostIndex}-${pathIndex}`} data-cy={`k8sAppCreate-rmPortButton_${hostIndex}-${pathIndex}`}
@@ -592,7 +709,7 @@ export function IngressForm({
</Button> </Button>
</div> </div>
</div> </div>
</div> </Card>
))} ))}
{namespace && ( {namespace && (
@@ -628,3 +745,9 @@ export function IngressForm({
</Widget> </Widget>
); );
} }
function getServiceLabel(options: GroupedServiceOptions, value: string) {
const allOptions = options.flatMap((group) => group.options);
const option = allOptions.find((o) => o.value === value);
return option?.selectedLabel || '';
}
@@ -31,3 +31,12 @@ export interface Rule {
export interface ServicePorts { export interface ServicePorts {
[serviceName: string]: Option<string>[]; [serviceName: string]: Option<string>[];
} }
interface ServiceOption extends Option<string> {
selectedLabel: string;
}
export type GroupedServiceOptions = {
label: string;
options: ServiceOption[];
}[];
@@ -192,7 +192,6 @@ export function useIngressControllers(
namespace ? getIngressControllers(environmentId, namespace) : [], namespace ? getIngressControllers(environmentId, namespace) : [],
{ {
enabled: !!namespace, enabled: !!namespace,
cacheTime: 0,
...withError('Unable to get ingress controllers'), ...withError('Unable to get ingress controllers'),
} }
); );
@@ -30,6 +30,7 @@ export function confirmUpdateNamespace(
a service interruption for these applications. a service interruption for these applications.
</p> </p>
)} )}
<p>Are you sure you want to continue?</p>
</> </>
); );

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