Compare commits

...

46 Commits

Author SHA1 Message Date
Prabhat Khera
7abed624d9 fix showing default ns for ingresses on edit (#10196) 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

View File

@@ -1,9 +1,6 @@
package apikey
import (
"crypto/rand"
"io"
portainer "github.com/portainer/portainer/api"
)
@@ -18,13 +15,3 @@ type APIKeyService interface {
DeleteAPIKey(apiKeyID portainer.APIKeyID) error
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
}

View File

@@ -3,6 +3,7 @@ package apikey
import (
"testing"
"github.com/portainer/portainer/api/internal/securecookie"
"github.com/stretchr/testify/assert"
)
@@ -33,7 +34,7 @@ func Test_generateRandomKey(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := generateRandomKey(tt.wantLenth)
got := securecookie.GenerateRandomKey(tt.wantLenth)
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) {
keys := make(map[string]bool)
for i := 0; i < 100; i++ {
key := generateRandomKey(8)
key := securecookie.GenerateRandomKey(8)
_, ok := keys[string(key)]
is.False(ok)
keys[string(key)] = true

View File

@@ -8,6 +8,7 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/internal/securecookie"
"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).
// The generated API key is stored in the cache and database.
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)
prefixedAPIKey := portainerAPIKeyPrefix + encodedRawAPIKey

View File

@@ -20,6 +20,7 @@ import (
"github.com/portainer/portainer/api/database/models"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/datastore/migrator"
"github.com/portainer/portainer/api/demo"
"github.com/portainer/portainer/api/docker"
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")
}
migratorInstance := migrator.NewMigrator(&migrator.MigratorParameters{})
migratorCount := migratorInstance.GetMigratorCountOfCurrentAPIVersion()
// from MigrateData
v := models.Version{
SchemaVersion: portainer.APIVersion,
Edition: int(portainer.PortainerCE),
InstanceID: instanceId.String(),
MigratorCount: migratorCount,
}
store.VersionService.UpdateVersion(&v)

View File

@@ -50,7 +50,7 @@ func (store *Store) MigrateData() error {
if err != nil {
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})
if err != nil {
return errors.Wrap(err, "failed to restore database")

View File

@@ -115,10 +115,16 @@ func (m *Migrator) updateEdgeStackStatusForDB100() error {
}
if environmentStatus.Details.Ok {
statusArray = append(statusArray, portainer.EdgeStackDeploymentStatus{
Type: portainer.EdgeStackStatusRunning,
Time: time.Now().Unix(),
})
statusArray = append(statusArray,
portainer.EdgeStackDeploymentStatus{
Type: portainer.EdgeStackStatusDeploymentReceived,
Time: time.Now().Unix(),
},
portainer.EdgeStackDeploymentStatus{
Type: portainer.EdgeStackStatusRunning,
Time: time.Now().Unix(),
},
)
}
if environmentStatus.Details.ImagesPulled {

View File

@@ -148,6 +148,17 @@ func (m *Migrator) LatestMigrations() Migrations {
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.
// ! 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.

View File

@@ -57,20 +57,20 @@ func (factory *ClientFactory) CreateClient(endpoint *portainer.Endpoint, nodeNam
func createLocalClient(endpoint *portainer.Endpoint) (*client.Client, error) {
return client.NewClientWithOpts(
client.WithHost(endpoint.URL),
client.WithVersion(dockerClientVersion),
client.WithAPIVersionNegotiation(),
)
}
func CreateClientFromEnv() (*client.Client, error) {
return client.NewClientWithOpts(
client.FromEnv,
client.WithVersion(dockerClientVersion),
client.WithAPIVersionNegotiation(),
)
}
func CreateSimpleClient() (*client.Client, error) {
return client.NewClientWithOpts(
client.WithVersion(dockerClientVersion),
client.WithAPIVersionNegotiation(),
)
}
@@ -82,7 +82,7 @@ func createTCPClient(endpoint *portainer.Endpoint, timeout *time.Duration) (*cli
return client.NewClientWithOpts(
client.WithHost(endpoint.URL),
client.WithVersion(dockerClientVersion),
client.WithAPIVersionNegotiation(),
client.WithHTTPClient(httpCli),
)
}
@@ -116,7 +116,7 @@ func createEdgeClient(endpoint *portainer.Endpoint, signatureService portainer.D
return client.NewClientWithOpts(
client.WithHost(endpointURL),
client.WithVersion(dockerClientVersion),
client.WithAPIVersionNegotiation(),
client.WithHTTPClient(httpCli),
client.WithHTTPHeaders(headers),
)
@@ -144,7 +144,7 @@ func createAgentClient(endpoint *portainer.Endpoint, signatureService portainer.
return client.NewClientWithOpts(
client.WithHost(endpoint.URL),
client.WithVersion(dockerClientVersion),
client.WithAPIVersionNegotiation(),
client.WithHTTPClient(httpCli),
client.WithHTTPHeaders(headers),
)

View File

@@ -15,6 +15,7 @@ import (
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/internal/registryutils"
"github.com/portainer/portainer/api/stacks/stackutils"
"github.com/rs/zerolog/log"
)
// SwarmStackManager represents a service for managing stacks.
@@ -64,16 +65,35 @@ func (manager *SwarmStackManager) Login(registries []portainer.Registry, endpoin
if registry.Authentication {
err = registryutils.EnsureRegTokenValid(manager.dataStore, &registry)
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)
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)
runCommandAndCaptureStdErr(command, registryArgs, nil, "")
err = runCommandAndCaptureStdErr(command, registryArgs, nil, "")
if err != nil {
log.
Warn().
Err(err).
Str("RegistryName", registry.Name).
Msg("Failed to login.")
}
}
}

View File

@@ -27,7 +27,6 @@ require (
github.com/google/go-cmp v0.5.9
github.com/gorilla/handlers v1.5.1
github.com/gorilla/mux v1.8.0
github.com/gorilla/securecookie v1.1.1
github.com/gorilla/websocket v1.5.0
github.com/hashicorp/golang-lru v0.5.4
github.com/joho/godotenv v1.4.0

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/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
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.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=

View File

@@ -50,7 +50,7 @@ func (handler *Handler) storeStackFile(stack *portainer.EdgeStack, deploymentTyp
entryPoint = stack.ManifestPath
}
_, err := handler.FileService.StoreEdgeStackFileFromBytesByVersion(stackFolder, entryPoint, stack.Version, config)
_, err := handler.FileService.StoreEdgeStackFileFromBytes(stackFolder, entryPoint, config)
if err != nil {
return fmt.Errorf("unable to persist updated Compose file with version on disk: %w", err)
}

View File

@@ -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,
// we assume that new TLS files have been uploaded and we need to reload the TLS configuration.
if endpoint.Type != portainer.DockerEnvironment ||
!strings.HasPrefix(*payload.URL, "tcp://") ||
(payload.URL != nil && !strings.HasPrefix(*payload.URL, "tcp://")) ||
payload.TLS == nil || !*payload.TLS {
return false
}

View File

@@ -34,6 +34,7 @@ type EnvironmentsQuery struct {
edgeCheckInPassedSeconds int
edgeStackId portainer.EdgeStackID
edgeStackStatus *portainer.EdgeStackStatusType
excludeIds []portainer.EndpointID
}
func parseQuery(r *http.Request) (EnvironmentsQuery, error) {
@@ -69,6 +70,11 @@ func parseQuery(r *http.Request) (EnvironmentsQuery, error) {
return EnvironmentsQuery{}, err
}
excludeIDs, err := getNumberArrayQueryParameter[portainer.EndpointID](r, "excludeIds")
if err != nil {
return EnvironmentsQuery{}, err
}
agentVersions := getArrayQueryParameter(r, "agentVersions")
name, _ := request.RetrieveQueryParameter(r, "name", true)
@@ -97,6 +103,7 @@ func parseQuery(r *http.Request) (EnvironmentsQuery, error) {
types: endpointTypes,
tagIds: tagIDs,
endpointIds: endpointIDs,
excludeIds: excludeIDs,
tagsPartialMatch: tagsPartialMatch,
groupIds: groupIDs,
status: status,
@@ -118,6 +125,12 @@ func (handler *Handler) filterEndpointsByQuery(filteredEndpoints []portainer.End
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 {
filteredEndpoints = filterEndpointsByGroupIDs(filteredEndpoints, query.groupIds)
}
@@ -208,9 +221,12 @@ func endpointStatusInStackMatchesFilter(edgeStackStatus map[portainer.EndpointID
status, ok := edgeStackStatus[envId]
// 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 !ok && statusFilter == portainer.EdgeStackStatusPending {
return true
if statusFilter == portainer.EdgeStackStatusPending {
return !ok || len(status.Status) == 0
}
if !ok {
return false
}
return slices.ContainsFunc(status.Status, func(s portainer.EdgeStackDeploymentStatus) bool {

View File

@@ -5,6 +5,7 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/internal/slices"
"github.com/portainer/portainer/api/internal/testhelpers"
"github.com/stretchr/testify/assert"
)
@@ -124,6 +125,28 @@ func Test_Filter_edgeFilter(t *testing.T) {
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) {
for _, test := range tests {
t.Run(test.title, func(t *testing.T) {

View File

@@ -13,6 +13,7 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/git/update"
"github.com/portainer/portainer/api/internal/endpointutils"
"github.com/portainer/portainer/api/internal/registryutils"
k "github.com/portainer/portainer/api/kubernetes"
"github.com/portainer/portainer/api/stacks/deployments"
"github.com/portainer/portainer/api/stacks/stackbuilders"
@@ -176,6 +177,14 @@ func (handler *Handler) createKubernetesStackFromFileContent(w http.ResponseWrit
handler.KubernetesDeployer,
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)
_, httpErr := stackBuilderDirector.Build(&stackPayload, endpoint)
if httpErr != nil {

View File

@@ -190,7 +190,7 @@ func (handler *Handler) deleteStack(userID portainer.UserID, stack *portainer.St
if stack.Type == portainer.DockerSwarmStack {
stack.Name = handler.SwarmStackManager.NormalizeStackName(stack.Name)
if stackutils.IsGitStack(stack) {
if stackutils.IsRelativePathStack(stack) {
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 {
stack.Name = handler.ComposeStackManager.NormalizeStackName(stack.Name)
if stackutils.IsGitStack(stack) {
if stackutils.IsRelativePathStack(stack) {
return handler.StackDeployer.UndeployRemoteComposeStack(stack, endpoint)
}

View File

@@ -117,7 +117,7 @@ func (handler *Handler) stackStart(w http.ResponseWriter, r *http.Request) *http
stack.AutoUpdate.JobID = jobID
}
err = handler.startStack(stack, endpoint)
err = handler.startStack(stack, endpoint, securityContext)
if err != nil {
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)
}
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 {
case portainer.DockerComposeStack:
stack.Name = handler.ComposeStackManager.NormalizeStackName(stack.Name)
if stackutils.IsGitStack(stack) {
if stackutils.IsRelativePathStack(stack) {
return handler.StackDeployer.StartRemoteComposeStack(stack, endpoint)
}
@@ -149,11 +153,23 @@ func (handler *Handler) startStack(stack *portainer.Stack, endpoint *portainer.E
case portainer.DockerSwarmStack:
stack.Name = handler.SwarmStackManager.NormalizeStackName(stack.Name)
if stackutils.IsGitStack(stack) {
if stackutils.IsRelativePathStack(stack) {
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

View File

@@ -125,7 +125,7 @@ func (handler *Handler) stopStack(stack *portainer.Stack, endpoint *portainer.En
case portainer.DockerComposeStack:
stack.Name = handler.ComposeStackManager.NormalizeStackName(stack.Name)
if stackutils.IsGitStack(stack) {
if stackutils.IsRelativePathStack(stack) {
return handler.StackDeployer.StopRemoteComposeStack(stack, endpoint)
}
@@ -133,7 +133,7 @@ func (handler *Handler) stopStack(stack *portainer.Stack, endpoint *portainer.En
case portainer.DockerSwarmStack:
stack.Name = handler.SwarmStackManager.NormalizeStackName(stack.Name)
if stackutils.IsGitStack(stack) {
if stackutils.IsRelativePathStack(stack) {
return handler.StackDeployer.StopRemoteSwarmStack(stack, endpoint)
}

View File

@@ -198,6 +198,11 @@ func (handler *Handler) updateComposeStack(r *http.Request, stack *portainer.Sta
stack.Env = payload.Env
if stack.GitConfig != nil {
// detach from git
stack.GitConfig = nil
}
stackFolder := strconv.Itoa(int(stack.ID))
_, err = handler.FileService.UpdateStoreStackFileFromBytes(stackFolder, stack.EntryPoint, []byte(payload.StackFileContent))
if err != nil {
@@ -263,6 +268,11 @@ func (handler *Handler) updateSwarmStack(r *http.Request, stack *portainer.Stack
stack.Env = payload.Env
if stack.GitConfig != nil {
// detach from git
stack.GitConfig = nil
}
stackFolder := strconv.Itoa(int(stack.ID))
_, err = handler.FileService.UpdateStoreStackFileFromBytes(stackFolder, stack.EntryPoint, []byte(payload.StackFileContent))
if err != nil {

View File

@@ -13,6 +13,7 @@ import (
gittypes "github.com/portainer/portainer/api/git/types"
"github.com/portainer/portainer/api/git/update"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/registryutils"
k "github.com/portainer/portainer/api/kubernetes"
"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)
}
// 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
//so if the deployment failed, the original file won't be over-written
stack.ProjectPath = tempFileDir

View File

@@ -6,7 +6,7 @@ import (
func (transport *baseTransport) proxyDeploymentsRequest(request *http.Request, namespace, requestPath string) (*http.Response, error) {
switch request.Method {
case http.MethodPost, http.MethodPatch:
case http.MethodPost, http.MethodPatch, http.MethodPut:
transport.refreshRegistry(request, namespace)
}

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
}

View File

@@ -63,3 +63,12 @@ func RemoveIndex[T any](s []T, index int) []T {
s[index] = 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
}

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 {
cli, err := client.NewClientWithOpts(client.FromEnv)
cli, err := client.NewClientWithOpts(
client.FromEnv,
client.WithAPIVersionNegotiation(),
)
if err != nil {
return errors.Wrap(err, "failed to create docker client")
}

View File

@@ -9,7 +9,7 @@ import (
"github.com/portainer/portainer/api/dataservices"
"github.com/golang-jwt/jwt/v4"
"github.com/gorilla/securecookie"
"github.com/portainer/portainer/api/internal/securecookie"
"github.com/rs/zerolog/log"
)

View File

@@ -301,6 +301,8 @@ type (
// StackDeploymentInfo records the information of a deployed stack
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 int `json:"FileVersion"`
// ConfigHash is the commit hash of the git repository used for deploying the stack

View File

@@ -85,7 +85,7 @@ func RedeployWhenChanged(stackID portainer.StackID, deployer StackDeployer, data
switch stack.Type {
case portainer.DockerComposeStack:
if stackutils.IsGitStack(stack) {
if stackutils.IsRelativePathStack(stack) {
err = deployer.DeployRemoteComposeStack(stack, endpoint, registries, true, false)
} else {
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)
}
case portainer.DockerSwarmStack:
if stackutils.IsGitStack(stack) {
if stackutils.IsRelativePathStack(stack) {
err = deployer.DeployRemoteSwarmStack(stack, endpoint, registries, true, true)
} else {
err = deployer.DeploySwarmStack(stack, endpoint, registries, true, true)

View File

@@ -1,7 +1,9 @@
package deployments
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"math/rand"
@@ -12,6 +14,7 @@ import (
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/swarm"
"github.com/docker/docker/pkg/stdcopy"
"github.com/pkg/errors"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/filesystem"
@@ -184,16 +187,18 @@ func (d *stackDeployer) remoteStack(stack *portainer.Stack, endpoint *portainer.
case <-statusCh:
}
stdErr := &bytes.Buffer{}
out, err := cli.ContainerLogs(ctx, unpackerContainer.ID, types.ContainerLogsOptions{ShowStdout: true, ShowStderr: true})
if err != nil {
log.Error().Err(err).Msg("unable to get logs from unpacker container")
} else {
outputBytes, err := io.ReadAll(out)
_, err = stdcopy.StdCopy(io.Discard, stdErr, out)
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 {
log.Info().
Str("output", string(outputBytes)).
Str("output", stdErr.String()).
Msg("Stack deployment output")
}
}
@@ -204,6 +209,26 @@ func (d *stackDeployer) remoteStack(stack *portainer.Stack, endpoint *portainer.
}
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)
}

View File

@@ -84,7 +84,7 @@ func (config *ComposeStackDeploymentConfig) Deploy() error {
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)
}

View File

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

View File

@@ -47,3 +47,10 @@ func SanitizeLabel(value string) string {
func IsGitStack(stack *portainer.Stack) bool {
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
}

View File

@@ -1,6 +1,7 @@
import _ from 'lodash-es';
import { PorImageRegistryModel } from 'Docker/models/porImageRegistry';
import { confirmImageExport } from '@/react/docker/images/common/ConfirmExportModal';
import { confirmDelete } from '@@/modals/confirm';
angular.module('portainer.docker').controller('ImageController', [
'$async',
@@ -120,30 +121,42 @@ angular.module('portainer.docker').controller('ImageController', [
}
$scope.removeTag = function (repository) {
ImageService.deleteImage(repository, false)
.then(function success() {
if ($scope.image.RepoTags.length === 1) {
Notifications.success('Image successfully deleted', repository);
$state.go('docker.images', {}, { reload: true });
} else {
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');
});
return $async(async () => {
if (!(await confirmDelete('Are you sure you want to delete this tag?'))) {
return;
}
ImageService.deleteImage(repository, false)
.then(function success() {
if ($scope.image.RepoTags.length === 1) {
Notifications.success('Image successfully deleted', repository);
$state.go('docker.images', {}, { reload: true });
} else {
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) {
ImageService.deleteImage(id, false)
.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');
});
return $async(async () => {
if (!(await confirmDelete('Deleting this image will also delete all associated tags. Are you sure you want to delete this image?'))) {
return;
}
ImageService.deleteImage(id, false)
.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) {

View File

@@ -57,7 +57,8 @@ angular.module('portainer.docker').controller('ImagesController', [
function confirmImageForceRemoval() {
return confirmDestructive({
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'),
});
}
@@ -65,7 +66,7 @@ angular.module('portainer.docker').controller('ImagesController', [
function confirmRegularRemove() {
return confirmDestructive({
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'),
});
}

View File

@@ -72,6 +72,11 @@ angular
component: 'editEdgeStackView',
},
},
params: {
status: {
dynamic: true,
},
},
};
const edgeJobs = {

View File

@@ -92,7 +92,6 @@ export const componentsModule = angular
'query',
'title',
'data-cy',
'hideEnvironmentIds',
])
)
.component(

View File

@@ -1,4 +1,5 @@
import _ from 'lodash-es';
import { confirmDelete } from '@@/modals/confirm';
export class EdgeGroupsController {
/* @ngInject */
@@ -26,6 +27,10 @@ export class EdgeGroupsController {
}
async removeActionAsync(selectedItems) {
if (!(await confirmDelete('Do you want to remove the selected Edge Group(s)?'))) {
return;
}
for (let item of selectedItems) {
try {
await this.EdgeGroupService.remove(item.Id);

View File

@@ -15,7 +15,7 @@ export class EdgeJobsViewController {
}
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) {
return;
}

View File

@@ -14,9 +14,13 @@
<pr-icon icon="'info'" mode="'primary'"></pr-icon>
Switch to advanced mode to copy and paste multiple key/values
</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>
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>

View File

@@ -8,5 +8,6 @@ angular.module('portainer.kubernetes').component('kubernetesConfigurationData',
isValid: '=',
isCreation: '=',
isEditorDirty: '=',
type: '<',
},
});

View File

@@ -16,7 +16,6 @@ import {
ApplicationSummaryWidget,
ApplicationDetailsWidget,
} from '@/react/kubernetes/applications/DetailsView';
import { withUserProvider } from '@/react/test-utils/withUserProvider';
import { withFormValidation } from '@/react-tools/withFormValidation';
import { withCurrentUser } from '@/react-tools/withCurrentUser';
@@ -104,7 +103,7 @@ export const ngModule = angular
.component(
'applicationDetailsWidget',
r2a(
withUIRouter(withReactQuery(withUserProvider(ApplicationDetailsWidget))),
withUIRouter(withReactQuery(withCurrentUser(ApplicationDetailsWidget))),
[]
)
);

View File

@@ -352,7 +352,7 @@
<!-- #region CONFIGMAPS -->
<div class="form-group">
<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 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>
@@ -503,7 +503,7 @@
<!-- #region SECRETS -->
<div class="form-group">
<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 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>

View File

@@ -26,95 +26,93 @@
<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">
<span class="small text-warning vertical-center">
<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
configured.
</span>
</information-panel>
<div class="row" ng-if="ctrl.state.getMetrics">
<div class="col-md-12">
<rd-widget>
<div class="toolBar px-5 pt-5">
<div class="toolBarTitle flex">
<div class="widget-icon space-right">
<pr-icon icon="'info'"></pr-icon>
</div>
<span class="vertical-center"> About statistics </span>
<information-panel ng-if="!ctrl.state.getMetrics" title-text="Unable to retrieve container metrics">
<span class="small text-warning vertical-center">
<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
configured.
</span>
</information-panel>
<div class="row" ng-if="ctrl.state.getMetrics">
<div class="col-md-12">
<rd-widget>
<div class="toolBar px-5 pt-5">
<div class="toolBarTitle flex">
<div class="widget-icon space-right">
<pr-icon icon="'info'"></pr-icon>
</div>
<span class="vertical-center"> About statistics </span>
</div>
<rd-widget-body>
<form class="form-horizontal">
<div class="form-group">
<div class="col-sm-12">
<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>
</div>
<rd-widget-body>
<form class="form-horizontal">
<div class="form-group">
<div class="col-sm-12">
<span class="small text-warning">
This view displays real-time statistics about the container <b>{{ ctrl.state.transition.containerName | trimcontainername }}</b
>.
</span>
</div>
<div class="form-group" ng-if="ctrl.state.networkStatsUnavailable">
<div class="col-sm-12">
<span class="small text-muted">
<pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon>
Network stats are unavailable for this container.
</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>
</form>
</rd-widget-body>
</rd-widget>
</div>
</div>
<div class="row" ng-if="ctrl.state.getMetrics">
<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="'svg-memory'"></pr-icon>
<span>
<pr-icon id="refreshRateChange" icon="'check'" mode="'success'" size="'sm'"></pr-icon>
</span>
</div>
<div class="form-group" ng-if="ctrl.state.networkStatsUnavailable">
<div class="col-sm-12">
<span class="small text-muted">
<pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon>
Network stats are unavailable for this container.
</span>
</div>
<span class="vertical-center"> Memory usage </span>
</div>
</div>
<rd-widget-body>
<div class="chart-container" style="position: relative">
<canvas id="memoryChart" width="770" height="300"></canvas>
</div>
</rd-widget-body>
</rd-widget>
</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>
</form>
</rd-widget-body>
</rd-widget>
</div>
</div>
<div class="row" ng-if="ctrl.state.getMetrics">
<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="'svg-memory'"></pr-icon>
</div>
<span class="vertical-center"> Memory usage </span>
</div>
</div>
<rd-widget-body>
<div class="chart-container" style="position: relative">
<canvas id="memoryChart" width="770" height="300"></canvas>
</div>
</rd-widget-body>
</rd-widget>
</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>

View File

@@ -19,6 +19,7 @@ class KubernetesApplicationStatsController {
this.ChartService = ChartService;
this.onInit = this.onInit.bind(this);
this.initCharts = this.initCharts.bind(this);
}
changeUpdateRepeater() {
@@ -68,17 +69,26 @@ class KubernetesApplicationStatsController {
}
initCharts() {
const cpuChartCtx = $('#cpuChart');
const cpuChart = this.ChartService.CreateCPUChart(cpuChartCtx);
this.cpuChart = cpuChart;
const memoryChartCtx = $('#memoryChart');
const memoryChart = this.ChartService.CreateMemoryChart(memoryChartCtx);
this.memoryChart = memoryChart;
this.updateCPUChart();
this.updateMemoryChart();
this.setUpdateRepeater();
let i = 0;
const findCharts = setInterval(() => {
let cpuChartCtx = $('#cpuChart');
let memoryChartCtx = $('#memoryChart');
if (cpuChartCtx.length !== 0 && memoryChartCtx.length !== 0) {
const cpuChart = this.ChartService.CreateCPUChart(cpuChartCtx);
this.cpuChart = cpuChart;
const memoryChart = this.ChartService.CreateMemoryChart(memoryChartCtx);
this.memoryChart = memoryChart;
this.updateCPUChart();
this.updateMemoryChart();
this.setUpdateRepeater();
clearInterval(findCharts);
return;
}
i++;
if (i >= 10) {
clearInterval(findCharts);
}
}, 200);
}
getStats() {

View File

@@ -15,86 +15,84 @@
<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">
<span class="small text-muted vertical-center">
<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.
</span>
</information-panel>
<div class="row" ng-if="ctrl.state.getMetrics">
<div class="col-md-12">
<rd-widget>
<div class="toolBar px-5 pt-5">
<div class="toolBarTitle flex">
<div class="widget-icon space-right">
<pr-icon icon="'info'"></pr-icon>
</div>
<span class="vertical-center"> About statistics </span>
<information-panel ng-if="!ctrl.state.getMetrics" title-text="Unable to retrieve node metrics">
<span class="small text-muted vertical-center">
<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.
</span>
</information-panel>
<div class="row" ng-if="ctrl.state.getMetrics">
<div class="col-md-12">
<rd-widget>
<div class="toolBar px-5 pt-5">
<div class="toolBarTitle flex">
<div class="widget-icon space-right">
<pr-icon icon="'info'"></pr-icon>
</div>
<span class="vertical-center"> About statistics </span>
</div>
<rd-widget-body>
<form class="form-horizontal">
<div class="form-group">
<div class="col-sm-12">
<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>
</div>
<rd-widget-body>
<form class="form-horizontal">
<div class="form-group">
<div class="col-sm-12">
<span class="small text-muted">
This view displays real-time statistics about the node <b>{{ ctrl.state.transition.nodeName }}</b
>.
</span>
</div>
</form>
</rd-widget-body>
</rd-widget>
</div>
</div>
<div class="row" ng-show="ctrl.state.getMetrics">
<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="'svg-memory'"></pr-icon>
</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 class="vertical-center"> Memory usage </span>
<span>
<pr-icon id="refreshRateChange" icon="'check'" mode="'success'" style="display: none"></pr-icon>
</span>
</div>
</div>
<rd-widget-body>
<div class="chart-node" style="position: relative">
<canvas id="memoryChart" width="770" height="300"></canvas>
</div>
</rd-widget-body>
</rd-widget>
</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>
</form>
</rd-widget-body>
</rd-widget>
</div>
</div>
<div class="row" ng-show="ctrl.state.getMetrics">
<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="'svg-memory'"></pr-icon>
</div>
<span class="vertical-center"> Memory usage </span>
</div>
</div>
<rd-widget-body>
<div class="chart-node" style="position: relative">
<canvas id="memoryChart" width="770" height="300"></canvas>
</div>
</rd-widget-body>
</rd-widget>
</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>

View File

@@ -17,6 +17,7 @@ class KubernetesNodeStatsController {
this.ChartService = ChartService;
this.onInit = this.onInit.bind(this);
this.initCharts = this.initCharts.bind(this);
}
changeUpdateRepeater() {
@@ -63,17 +64,20 @@ class KubernetesNodeStatsController {
}
initCharts() {
const cpuChartCtx = $('#cpuChart');
const cpuChart = this.ChartService.CreateCPUChart(cpuChartCtx);
this.cpuChart = cpuChart;
const memoryChartCtx = $('#memoryChart');
const memoryChart = this.ChartService.CreateMemoryChart(memoryChartCtx);
this.memoryChart = memoryChart;
this.updateCPUChart();
this.updateMemoryChart();
this.setUpdateRepeater();
const findCharts = setInterval(() => {
let cpuChartCtx = $('#cpuChart');
let memoryChartCtx = $('#memoryChart');
if (cpuChartCtx.length !== 0 && memoryChartCtx.length !== 0) {
const cpuChart = this.ChartService.CreateCPUChart(cpuChartCtx);
this.cpuChart = cpuChart;
const memoryChart = this.ChartService.CreateMemoryChart(memoryChartCtx);
this.memoryChart = memoryChart;
this.updateCPUChart();
this.updateMemoryChart();
this.setUpdateRepeater();
clearInterval(findCharts);
}
}, 200);
}
getStats() {
@@ -84,7 +88,7 @@ class KubernetesNodeStatsController {
const memory = filesizeParser(stats.usage.memory);
const cpu = KubernetesResourceReservationHelper.parseCPU(stats.usage.cpu);
this.stats = {
read: stats.creationTimestamp,
read: stats.metadata.creationTimestamp,
MemoryUsage: memory,
CPUUsage: (cpu / this.nodeCPU) * 100,
};
@@ -118,12 +122,6 @@ class KubernetesNodeStatsController {
this.nodeCPU = node.CPU || 1;
await this.getStats();
if (this.state.getMetrics) {
this.$document.ready(() => {
this.initCharts();
});
}
} else {
this.state.getMetrics = false;
}
@@ -132,6 +130,11 @@ class KubernetesNodeStatsController {
this.Notifications.error('Failure', err, 'Unable to retrieve node stats');
} finally {
this.state.viewReady = true;
if (this.state.getMetrics) {
this.$document.ready(() => {
this.initCharts();
});
}
}
}

View File

@@ -88,6 +88,7 @@
is-valid="ctrl.state.isDataValid"
on-change-validation="ctrl.isFormValid()"
is-creation="true"
type="'configmap'"
is-editor-dirty="ctrl.state.isEditorDirty"
></kubernetes-configuration-data>
</div>

View File

@@ -100,6 +100,7 @@
is-valid="ctrl.state.isDataValid"
on-change-validation="ctrl.isFormValid()"
is-creation="false"
type="'configmap'"
is-editor-dirty="ctrl.state.isEditorDirty"
></kubernetes-configuration-data>

View File

@@ -87,7 +87,7 @@
<pr-icon icon="'info'" mode="'primary'"></pr-icon>
<span>
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>
</div>
</div>
@@ -186,6 +186,7 @@
is-valid="ctrl.state.isDataValid"
on-change-validation="ctrl.isFormValid()"
is-creation="true"
type="'secret'"
is-editor-dirty="ctrl.state.isEditorDirty"
></kubernetes-configuration-data>

View File

@@ -107,6 +107,7 @@
is-valid="ctrl.state.isDataValid"
on-change-validation="ctrl.isFormValid()"
is-creation="false"
type="'secret'"
is-editor-dirty="ctrl.state.isEditorDirty"
></kubernetes-configuration-data>

View File

@@ -22,7 +22,7 @@
<rd-widget>
<rd-widget-body>
<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="col-sm-12 text-muted small">
@@ -41,6 +41,8 @@
</div>
</div>
<div class="col-sm-12 form-section-title"> Networking - Ingresses </div>
<ingress-class-datatable
on-change-controllers="(ctrl.onChangeControllers)"
allow-none-ingress-class="ctrl.formValues.AllowNoneIngressClass"
@@ -51,47 +53,57 @@
view="'cluster'"
></ingress-class-datatable>
<label htmlFor="foldingButtonIngControllerSettings" class="col-sm-12 form-section-title flex cursor-pointer items-center">
<button
id="foldingButtonIngControllerSettings"
type="button"
class="mx-2 !ml-0 inline-flex w-2 items-center justify-center border-0 bg-transparent"
ng-click="ctrl.toggleAdvancedIngSettings($event)"
<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 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>
<!-- auto update window -->
@@ -161,19 +173,6 @@
>
</por-switch-field>
</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>
<!-- #endregion -->

View File

@@ -189,7 +189,7 @@ class KubernetesConfigureController {
await getMetricsForAllNodes(this.endpoint.Id);
this.state.metrics.isServerRunning = true;
this.state.metrics.pending = false;
this.state.metrics.userClick = false;
this.state.metrics.userClick = true;
this.formValues.UseServerMetrics = true;
} catch (_) {
this.state.metrics.isServerRunning = false;

View File

@@ -16,7 +16,7 @@ import { FeatureId } from '@/react/portainer/feature-flags/enums';
import { updateIngressControllerClassMap, getIngressControllerClassMap } from '@/react/kubernetes/cluster/ingressClass/utils';
import { confirmUpdate } from '@@/modals/confirm';
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 {
/* #region CONSTRUCTOR */
@@ -36,7 +36,8 @@ class KubernetesResourcePoolController {
KubernetesApplicationService,
KubernetesIngressService,
KubernetesVolumeService,
KubernetesNamespaceService
KubernetesNamespaceService,
KubernetesNodeService
) {
Object.assign(this, {
$async,
@@ -54,6 +55,7 @@ class KubernetesResourcePoolController {
KubernetesIngressService,
KubernetesVolumeService,
KubernetesNamespaceService,
KubernetesNodeService,
});
this.IngressClassTypes = KubernetesIngressClassTypes;
@@ -366,7 +368,7 @@ class KubernetesResourcePoolController {
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 = [];
if (this.state.ingressAvailabilityPerNamespace) {

View File

@@ -20,7 +20,7 @@ const { CREATE, UPDATE, DELETE } = KubernetesResourceActions;
* Get summary of Kubernetes resources to be created, updated or deleted
* @param {KubernetesApplicationFormValues} formValues
*/
export default function (formValues, oldFormValues = {}) {
export function getApplicationResources(formValues, oldFormValues = {}) {
if (oldFormValues instanceof KubernetesApplicationFormValues) {
const resourceSummary = getUpdatedApplicationResources(oldFormValues, formValues);
return resourceSummary;
@@ -139,9 +139,9 @@ function getUpdatedApplicationResources(oldFormValues, newFormValues) {
}
// 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 oldIngresses = generateNewIngressesFromFormPaths(oldFormValues.OriginalIngresses, oldServicePorts, oldServicePorts);
const newServicePorts = newFormValues.Services.flatMap((service) => service.Ports);
const newIngresses = generateNewIngressesFromFormPaths(newFormValues.OriginalIngresses, newServicePorts, oldServicePorts);
resources.push(...getIngressUpdateSummary(oldIngresses, newIngresses));
} else if (!oldService && newService) {
@@ -190,7 +190,7 @@ function getApplicationResourceType(app) {
function getIngressUpdateSummary(oldIngresses, newIngresses) {
const ingressesSummaries = newIngresses
.map((newIng) => {
const oldIng = _.find(oldIngresses, { Name: newIng.Name });
const oldIng = oldIngresses.find((oldIng) => oldIng.Name === newIng.Name);
return getIngressUpdateResourceSummary(oldIng, newIng);
})
.filter((s) => s); // remove nulls

View File

@@ -3,7 +3,7 @@ import { KubernetesConfigurationFormValues } from 'Kubernetes/models/configurati
import { KubernetesResourcePoolFormValues } from 'Kubernetes/models/resource-pool/formValues';
import { KubernetesApplicationFormValues } from 'Kubernetes/models/application/formValues';
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 getConfigurationResources from './resources/configurationResources';

View File

@@ -14,14 +14,19 @@
{{ $ctrl.model.Title }}
</span>
<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>
<span ng-if="!$ctrl.model.Platform"> &amp; </span>
<pr-icon
icon="'svg-microsoft'"
ng-if="$ctrl.model.Platform === 2 || $ctrl.model.Platform === 'windows' || !$ctrl.model.Platform"
class-name="'[&>*]:flex [&>*]:items-center'"
size="'lg'"
></pr-icon>
<div ng-if="$ctrl.typeLabel !== 'manifest'" class="vertical-center gap-1">
<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
icon="'svg-microsoft'"
ng-if="$ctrl.model.Platform === 2 || $ctrl.model.Platform === 'windows' || !$ctrl.model.Platform"
class-name="'[&>*]:flex [&>*]:items-center'"
size="'lg'"
></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 }}
</div>
</div>

View File

@@ -41,7 +41,7 @@ export default class LdapSettingsBaseDnBuilderController {
}
getOUValues(dn, domainSuffix = '') {
const regex = /(\w+)=(\w*),?/;
const regex = /(\w+)=([a-zA-Z0-9_ ]*),?/;
let ouValues = [];
let left = dn;
let match = left.match(regex);

View File

@@ -117,8 +117,8 @@ export function createMockEnvironment(): Environment {
StartTime: '',
},
StatusMessage: {
Detail: '',
Summary: '',
detail: '',
summary: '',
},
};
}

View File

@@ -1,4 +1,4 @@
import { semverCompare } from './utils';
import { semverCompare } from './semver-utils';
describe('semverCompare', () => {
test('sort array', () => {

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

View File

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

View File

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

View File

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

View File

@@ -247,6 +247,12 @@ const docURLs = [
locationRegex: /#!\/edge\/jobs/,
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',
docURL: 'https://docs.portainer.io/user/nomad/dashboard',

View File

@@ -8,29 +8,22 @@ interface Props {
boundaryLinks?: boolean;
currentPage: number;
directionLinks?: boolean;
itemsPerPage: number;
onPageChange(page: number): void;
totalCount: number;
pageCount: number;
maxSize: number;
isInputVisible?: boolean;
}
export function PageSelector({
currentPage,
totalCount,
itemsPerPage,
pageCount,
onPageChange,
maxSize = 5,
directionLinks = true,
boundaryLinks = false,
isInputVisible = false,
}: Props) {
const pages = generatePagesArray(
currentPage,
totalCount,
itemsPerPage,
maxSize
);
const pages = generatePagesArray(currentPage, pageCount, maxSize);
const last = pages[pages.length - 1];
if (pages.length <= 1) {
@@ -42,7 +35,7 @@ export function PageSelector({
{isInputVisible && (
<PageInput
onChange={(page) => onPageChange(page)}
totalPages={Math.ceil(totalCount / itemsPerPage)}
totalPages={pageCount}
/>
)}
<ul className="pagination">

View File

@@ -9,7 +9,7 @@ interface Props {
page: number;
pageLimit: number;
showAll?: boolean;
totalCount: number;
pageCount: number;
isPageInputVisible?: boolean;
className?: string;
}
@@ -20,7 +20,7 @@ export function PaginationControls({
onPageLimitChange,
showAll,
onPageChange,
totalCount,
pageCount,
isPageInputVisible,
className,
}: Props) {
@@ -38,8 +38,7 @@ export function PaginationControls({
maxSize={5}
onPageChange={onPageChange}
currentPage={page}
itemsPerPage={pageLimit}
totalCount={totalCount}
pageCount={pageCount}
isInputVisible={isPageInputVisible}
/>
)}

View File

@@ -12,12 +12,10 @@ export /**
*/
function generatePagesArray(
currentPage: number,
collectionLength: number,
rowsPerPage: number,
totalPages: number,
paginationRange: number
): (number | '...')[] {
const pages: (number | '...')[] = [];
const totalPages = Math.ceil(collectionLength / rowsPerPage);
const halfWay = Math.ceil(paginationRange / 2);
let position;

View File

@@ -17,7 +17,7 @@ interface WidgetProps {
}
const meta: Meta<WidgetProps> = {
title: 'Widget',
title: 'Components/Widget',
component: Widget,
args: {
loading: false,

View File

@@ -3,6 +3,7 @@ import {
TableState,
useReactTable,
Row,
Column,
getCoreRowModel,
getPaginationRowModel,
getFilteredRowModel,
@@ -33,6 +34,20 @@ import { createSelectColumn } from './select-column';
import { TableRow } from './TableRow';
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<
D extends Record<string, unknown>,
TMeta extends TableMeta<D> = TableMeta<D>
@@ -49,12 +64,8 @@ export interface Props<
titleIcon?: IconProps['icon'];
initialTableState?: Partial<TableState>;
isLoading?: boolean;
totalCount?: number;
description?: ReactNode;
pageCount?: number;
highlightedItemId?: string;
onPageChange?(page: number): void;
settingsManager: GlobalTableState<BasicTableSettings>;
renderRow?(row: Row<D>, highlightedItemId?: string): ReactNode;
getRowCanExpand?(row: Row<D>): boolean;
@@ -78,10 +89,7 @@ export function Datatable<
emptyContentLabel,
initialTableState = {},
isLoading,
totalCount = dataset.length,
description,
pageCount,
onPageChange = () => null,
settingsManager: settings,
renderRow = defaultRenderRow,
highlightedItemId,
@@ -89,8 +97,16 @@ export function Datatable<
getRowCanExpand,
'data-cy': dataCy,
meta,
}: Props<D, TMeta>) {
const isServerSidePagination = typeof pageCount !== 'undefined';
onPageChange = () => {},
page,
totalCount = dataset.length,
isServerSidePagination = false,
}: Props<D, TMeta> & PaginationProps) {
const pageCount = useMemo(
() => Math.ceil(totalCount / settings.pageSize),
[settings.pageSize, totalCount]
);
const enableRowSelection = getIsSelectionEnabled(
disableSelect,
isRowSelectable
@@ -107,6 +123,7 @@ export function Datatable<
initialState: {
pagination: {
pageSize: settings.pageSize,
pageIndex: page || 0,
},
sorting: settings.sortBy ? [settings.sortBy] : [],
globalFilter: settings.search,
@@ -116,6 +133,7 @@ export function Datatable<
defaultColumn: {
enableColumnFilter: false,
enableHiding: true,
sortingFn: 'alphanumeric',
},
enableRowSelection,
autoResetExpanded: false,
@@ -123,14 +141,18 @@ export function Datatable<
getRowId,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getFacetedRowModel: getFacetedRowModel(),
getFacetedUniqueValues: getFacetedUniqueValues(),
getFacetedMinMaxValues: getFacetedMinMaxValues(),
getExpandedRowModel: getExpandedRowModel(),
getRowCanExpand,
...(isServerSidePagination ? { manualPagination: true, pageCount } : {}),
getColumnCanGlobalFilter,
...(isServerSidePagination
? { manualPagination: true, pageCount }
: {
getSortedRowModel: getSortedRowModel(),
}),
meta,
});
@@ -158,6 +180,7 @@ export function Datatable<
renderTableActions={() => renderTableActions(selectedItems)}
renderTableSettings={() => renderTableSettings(tableInstance)}
/>
<DatatableContent<D>
tableInstance={tableInstance}
renderRow={(row) => renderRow(row, highlightedItemId)}
@@ -170,9 +193,9 @@ export function Datatable<
<DatatableFooter
onPageChange={handlePageChange}
onPageSizeChange={handlePageSizeChange}
page={tableState.pagination.pageIndex}
page={typeof page === 'number' ? page : tableState.pagination.pageIndex}
pageSize={tableState.pagination.pageSize}
totalCount={totalCount}
pageCount={tableInstance.getPageCount()}
totalSelected={selectedItems.length}
/>
</Table.Container>
@@ -258,3 +281,10 @@ function globalFilterFn<D>(
return false;
}
function getColumnCanGlobalFilter<D>(column: Column<D, unknown>): boolean {
if (column.id === 'select') {
return false;
}
return true;
}

View File

@@ -8,7 +8,7 @@ interface Props {
pageSize: number;
page: number;
onPageChange(page: number): void;
totalCount: number;
pageCount: number;
onPageSizeChange(pageSize: number): void;
}
@@ -17,7 +17,7 @@ export function DatatableFooter({
pageSize,
page,
onPageChange,
totalCount,
pageCount,
onPageSizeChange,
}: Props) {
return (
@@ -28,7 +28,7 @@ export function DatatableFooter({
pageLimit={pageSize}
page={page + 1}
onPageChange={(page) => onPageChange(page - 1)}
totalCount={totalCount}
pageCount={pageCount}
onPageLimitChange={onPageSizeChange}
/>
</Table.Footer>

View File

@@ -2,7 +2,11 @@ import { Row } from '@tanstack/react-table';
import { ReactNode } from 'react';
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>>
extends Omit<DatatableProps<D>, 'renderRow' | 'expandable'> {
@@ -15,7 +19,7 @@ export function ExpandableDatatable<D extends Record<string, unknown>>({
getRowCanExpand = () => true,
expandOnRowClick,
...props
}: Props<D>) {
}: Props<D> & PaginationProps) {
return (
<Datatable<D>
// eslint-disable-next-line react/jsx-props-no-spreading

View File

@@ -17,7 +17,6 @@ export function buildNameColumn<T extends Record<string, unknown>>(
cell,
enableSorting: true,
enableHiding: false,
sortingFn: 'text',
};
function createCell<T extends Record<string, unknown>>() {

View File

@@ -31,6 +31,22 @@
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 */
.portainer-selector-root .portainer-selector__control {
border-color: var(--border-form-control-color);

View File

@@ -7,6 +7,6 @@ export const size = columnHelper.accessor('VirtualSize', {
header: 'Size',
cell: ({ getValue }) => {
const value = getValue();
return humanize(value);
return humanize(value) || '-';
},
});

View File

@@ -28,6 +28,7 @@ export function AssociatedEdgeEnvironmentsSelector({
emptyContentLabel="No environment available"
query={{
types: EdgeTypes,
excludeIds: value,
}}
onClickRow={(env) => {
if (!value.includes(env.Id)) {
@@ -35,7 +36,6 @@ export function AssociatedEdgeEnvironmentsSelector({
}
}}
data-cy="edgeGroupCreate-availableEndpoints"
hideEnvironmentIds={value}
/>
</div>
<div className="w-1/2">

View File

@@ -3,10 +3,7 @@ import { truncate } from 'lodash';
import { useMemo, useState } from 'react';
import { useEnvironmentList } from '@/react/portainer/environments/queries';
import {
Environment,
EnvironmentId,
} from '@/react/portainer/environments/types';
import { Environment } from '@/react/portainer/environments/types';
import { useGroups } from '@/react/portainer/environments/environment-groups/queries';
import { useTags } from '@/portainer/tags/queries';
import { EnvironmentsQueryParams } from '@/react/portainer/environments/environment.service';
@@ -47,19 +44,17 @@ export function EdgeGroupAssociationTable({
emptyContentLabel,
onClickRow,
'data-cy': dataCy,
hideEnvironmentIds = [],
}: {
title: string;
query: EnvironmentsQueryParams;
emptyContentLabel: string;
onClickRow: (env: Environment) => void;
hideEnvironmentIds?: EnvironmentId[];
} & AutomationTestingProps) {
const tableState = useTableStateWithoutStorage('Name');
const [page, setPage] = useState(1);
const [page, setPage] = useState(0);
const environmentsQuery = useEnvironmentList({
pageLimit: tableState.pageSize,
page,
page: page + 1,
search: tableState.search,
sort: tableState.sortBy.id as 'Group' | 'Name',
order: tableState.sortBy.desc ? 'desc' : 'asc',
@@ -74,25 +69,17 @@ export function EdgeGroupAssociationTable({
const environments: Array<DecoratedEnvironment> = useMemo(
() =>
environmentsQuery.environments
.filter((e) => !hideEnvironmentIds.includes(e.Id))
.map((env) => ({
...env,
Group:
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,
hideEnvironmentIds,
tagsQuery.data,
]
environmentsQuery.environments.map((env) => ({
...env,
Group: 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]
);
const totalCount = environmentsQuery.totalCount - hideEnvironmentIds.length;
const { totalCount } = environmentsQuery;
return (
<Datatable<DecoratedEnvironment>
@@ -100,8 +87,10 @@ export function EdgeGroupAssociationTable({
columns={columns}
settingsManager={tableState}
dataset={environments}
isServerSidePagination
page={page}
onPageChange={setPage}
pageCount={Math.ceil(totalCount / tableState.pageSize)}
totalCount={totalCount}
renderRow={(row) => (
<TableRow<DecoratedEnvironment>
cells={row.getVisibleCells()}
@@ -111,7 +100,6 @@ export function EdgeGroupAssociationTable({
emptyContentLabel={emptyContentLabel}
data-cy={dataCy}
disableSelect
totalCount={totalCount}
/>
);
}

View File

@@ -9,11 +9,20 @@ import { useEnvironments } from './useEnvironments';
const storageKey = 'edge-devices-waiting-room';
const settingsStore = createPersistedStore(storageKey, 'Name');
const settingsStore = createPersistedStore(storageKey);
export function Datatable() {
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 (
<GenericDatatable
@@ -26,6 +35,9 @@ export function Datatable() {
<TableActions selectedRows={selectedRows} />
)}
isLoading={isLoading}
isServerSidePagination
page={page}
onPageChange={setPage}
totalCount={totalCount}
description={<Filter />}
/>

View File

@@ -1,4 +1,5 @@
import _ from 'lodash';
import { useEffect, useMemo, useState } from 'react';
import { useTags } from '@/portainer/tags/queries';
import { useEdgeGroups } from '@/react/edge/edge-groups/queries/useEdgeGroups';
@@ -10,27 +11,59 @@ import { WaitingRoomEnvironment } from '../types';
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 edgeGroupsQuery = useEdgeGroups();
const filterByEnvironmentsIds = filterStore.edgeGroups.length
? _.compact(
filterStore.edgeGroups.flatMap(
(groupId) =>
edgeGroupsQuery.data?.find((g) => g.Id === groupId)?.Endpoints
)
)
: undefined;
const filterByEnvironmentsIds = useMemo(
() =>
filterStore.edgeGroups.length
? _.compact(
filterStore.edgeGroups.flatMap(
(groupId) =>
edgeGroupsQuery.data?.find((g) => g.Id === groupId)?.Endpoints
)
)
: 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({
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,
page: page + 1,
...query,
});
const groupsQuery = useGroups({
@@ -52,24 +85,45 @@ export function useEnvironments() {
Object.fromEntries(tags.map((tag) => [tag.ID, tag.Name] as const)),
});
const envs: Array<WaitingRoomEnvironment> =
environmentsQuery.environments.map((env) => ({
...env,
Group: (env.GroupId !== 1 && groupsQuery.data?.[env.GroupId]) || '',
EdgeGroups:
environmentEdgeGroupsQuery.data?.[env.Id]?.map((env) => env.group) ||
[],
Tags:
_.compact(env.TagIds?.map((tagId) => tagsQuery.data?.[tagId])) || [],
}));
const envs: Array<WaitingRoomEnvironment> = useMemo(
() =>
environmentsQuery.environments.map((env) => ({
...env,
Group: (env.GroupId !== 1 && groupsQuery.data?.[env.GroupId]) || '',
EdgeGroups:
environmentEdgeGroupsQuery.data?.[env.Id]?.map((env) => env.group) ||
[],
Tags:
_.compact(env.TagIds?.map((tagId) => tagsQuery.data?.[tagId])) || [],
})),
[
environmentEdgeGroupsQuery.data,
environmentsQuery.environments,
groupsQuery.data,
tagsQuery.data,
]
);
return {
data: envs,
isLoading:
environmentsQuery.isLoading ||
groupsQuery.isLoading ||
environmentEdgeGroupsQuery.isLoading ||
return useMemo(
() => ({
data: envs,
isLoading:
environmentsQuery.isLoading ||
groupsQuery.isLoading ||
environmentEdgeGroupsQuery.isLoading ||
tagsQuery.isLoading,
totalCount: environmentsQuery.totalCount,
page,
setPage,
}),
[
environmentEdgeGroupsQuery.isLoading,
environmentsQuery.isLoading,
environmentsQuery.totalCount,
envs,
groupsQuery.isLoading,
page,
tagsQuery.isLoading,
totalCount: environmentsQuery.totalCount,
};
]
);
}

View File

@@ -214,7 +214,7 @@ function InnerForm({
checked={values.retryDeploy}
name="retryDeploy"
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"
onChange={(value) => setFieldValue('retryDeploy', value)}
/>

View File

@@ -4,7 +4,6 @@ import { useMemo, useState } from 'react';
import { EdgeStackStatus, StatusType } from '@/react/edge/edge-stacks/types';
import { useEnvironmentList } from '@/react/portainer/environments/queries';
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
import { useParamState } from '@/react/hooks/useParamState';
import { EnvironmentId } from '@/react/portainer/environments/types';
@@ -41,7 +40,7 @@ export function EnvironmentsDatatable() {
(value) => (value ? parseInt(value, 10) : undefined)
);
const tableState = useTableStateWithoutStorage('name');
const endpointsQuery = useEnvironmentList({
const environmentsQuery = useEnvironmentList({
pageLimit: tableState.pageSize,
page: page + 1,
search: tableState.search,
@@ -57,7 +56,7 @@ export function EnvironmentsDatatable() {
const gitConfigCommitHash = edgeStackQuery.data?.GitConfig?.ConfigHash || '';
const environments: Array<EdgeStackEnvironment> = useMemo(
() =>
endpointsQuery.environments.map(
environmentsQuery.environments.map(
(env) =>
({
...env,
@@ -73,7 +72,7 @@ export function EnvironmentsDatatable() {
[
currentFileVersion,
edgeStackQuery.data?.Status,
endpointsQuery.environments,
environmentsQuery.environments,
gitConfigCommitHash,
gitConfigURL,
]
@@ -82,32 +81,33 @@ export function EnvironmentsDatatable() {
return (
<Datatable
columns={columns}
isLoading={endpointsQuery.isLoading}
isLoading={environmentsQuery.isLoading}
dataset={environments}
settingsManager={tableState}
title="Environments Status"
titleIcon={HardDrive}
isServerSidePagination
page={page}
onPageChange={setPage}
totalCount={environmentsQuery.totalCount}
emptyContentLabel="No environment available."
disableSelect
description={
isBE && (
<div className="w-1/4">
<PortainerSelect<StatusType | undefined>
isClearable
bindToBody
value={statusFilter}
onChange={(e) => setStatusFilter(e || undefined)}
options={[
{ value: StatusType.Pending, label: 'Pending' },
{ value: StatusType.Acknowledged, label: 'Acknowledged' },
{ value: StatusType.ImagesPulled, label: 'Images pre-pulled' },
{ value: StatusType.Running, label: 'Deployed' },
{ value: StatusType.Error, label: 'Failed' },
]}
/>
</div>
)
<div className="w-1/4">
<PortainerSelect<StatusType | undefined>
isClearable
bindToBody
value={statusFilter}
onChange={(e) => setStatusFilter(e ?? undefined)}
options={[
{ value: StatusType.Pending, label: 'Pending' },
{ value: StatusType.Acknowledged, label: 'Acknowledged' },
{ value: StatusType.ImagesPulled, label: 'Images pre-pulled' },
{ value: StatusType.Running, label: 'Deployed' },
{ value: StatusType.Error, label: 'Failed' },
]}
/>
</div>
}
/>
);

View File

@@ -121,6 +121,7 @@ function ErrorCell({ getValue }: CellContext<EdgeStackEnvironment, string>) {
return (
<Button
color="none"
className="flex cursor-pointer"
onClick={() => setIsExpanded(!isExpanded)}
>

View File

@@ -5,9 +5,14 @@ import {
type Icon as IconType,
Loader2,
XCircle,
MinusCircle,
} from 'lucide-react';
import { useEnvironmentList } from '@/react/portainer/environments/queries';
import { isVersionSmaller } from '@/react/common/semver-utils';
import { Icon, IconMode } from '@@/Icon';
import { Tooltip } from '@@/Tip/Tooltip';
import { DeploymentStatus, EdgeStack, StatusType } from '../../types';
@@ -15,28 +20,51 @@ export function EdgeStackStatus({ edgeStack }: { edgeStack: EdgeStack }) {
const status = Object.values(edgeStack.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,
lastStatus
lastStatus,
hasOldVersion
);
return (
<div className="mx-auto inline-flex items-center gap-2">
{icon && <Icon icon={icon} spin={spin} mode={mode} />}
{label}
{tooltip && <Tooltip message={tooltip} />}
</div>
);
}
function getStatus(
numDeployments: number,
envStatus: Array<DeploymentStatus>
envStatus: Array<DeploymentStatus>,
hasOldVersion: boolean
): {
label: string;
icon?: IconType;
spin?: boolean;
mode?: IconMode;
tooltip?: string;
} {
if (!numDeployments || hasOldVersion) {
return {
label: 'Unavailable',
icon: MinusCircle,
mode: 'secondary',
tooltip: getUnavailableTooltip(),
};
}
if (envStatus.length < numDeployments) {
return {
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) {
return {
@@ -84,4 +116,16 @@ function getStatus(
spin: true,
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 '';
}
}

View File

@@ -20,16 +20,18 @@ export function TableSettingsMenus({
return (
<>
<ColumnVisibilityMenu<DecoratedEdgeStack>
columns={columnsToHide}
onChange={(hiddenColumns) => {
tableState.setHiddenColumns(hiddenColumns);
tableInstance.setColumnVisibility(
Object.fromEntries(hiddenColumns.map((col) => [col, false]))
);
}}
value={tableState.hiddenColumns}
/>
{columnsToHide && columnsToHide.length > 0 && (
<ColumnVisibilityMenu<DecoratedEdgeStack>
columns={columnsToHide}
onChange={(hiddenColumns) => {
tableState.setHiddenColumns(hiddenColumns);
tableInstance.setColumnVisibility(
Object.fromEntries(hiddenColumns.map((col) => [col, false]))
);
}}
value={tableState.hiddenColumns}
/>
)}
<TableSettingsMenu>
<TableSettingsMenuAutoRefresh
value={tableState.autoRefreshRate}

View File

@@ -45,13 +45,19 @@ export const columns = _.compact([
(item) => item.aggregatedStatus[StatusType.ImagesPulled] || 0,
{
header: 'Images pre-pulled',
cell: ({ getValue, row }) => (
<DeploymentCounter
count={getValue()}
type={StatusType.ImagesPulled}
total={row.original.NumDeployments}
/>
),
cell: ({ getValue, row: { original: item } }) => {
if (!item.PrePullImage) {
return <div className="text-center">-</div>;
}
return (
<DeploymentCounter
count={getValue()}
type={StatusType.ImagesPulled}
total={item.NumDeployments}
/>
);
},
enableSorting: false,
enableHiding: false,
meta: {

View File

@@ -1,11 +1,22 @@
import { useQueryClient } from 'react-query';
import { PageHeader } from '@@/PageHeader';
import { queryKeys } from '../queries/query-keys';
import { EdgeStacksDatatable } from './EdgeStacksDatatable';
export function ListView() {
const queryClient = useQueryClient();
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 />
</>

View File

@@ -38,7 +38,7 @@ export function PrivateRegistryFieldset({
const [selected, setSelected] = useState(value);
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(() => {
if (checked) {

View File

@@ -2,6 +2,11 @@ import { useEffect, useMemo, useState } from 'react';
import { FormikErrors } from 'formik';
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';
@@ -50,6 +55,11 @@ export function KubeServicesForm({
const [selectedServiceType, setSelectedServiceType] =
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
// and the serviceNames for each service port
const newServiceNames = useMemo(

View File

@@ -10,9 +10,9 @@ export function PublishingExplaination() {
src={ingressDiagram}
alt="ingress explaination"
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{' '}
<a
href="https://kubernetes.io/docs/concepts/services-networking/service/"

View File

@@ -111,10 +111,6 @@ export function AppIngressPathForm({
value={selectedIngress}
defaultValue={ingressHostOptions[0]}
placeholder="Select a hostname..."
theme={(theme) => ({
...theme,
borderRadius: 0,
})}
size="sm"
onChange={(ingressOption) => {
setSelectedIngress(ingressOption);

View File

@@ -44,13 +44,10 @@ export function AppIngressPathsForm({
namespace ? [namespace] : undefined
);
const { data: ingresses } = ingressesQuery;
const ingressControllersQuery = useIngressControllers(
environmentId,
namespace
);
const { data: ingressControllers } = ingressControllersQuery;
const { data: ingressControllers, ...ingressControllersQuery } =
useIngressControllers(environmentId, namespace);
// 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 allowedIngressClasses =
ingressControllers

View File

@@ -20,12 +20,12 @@ export function ApplicationEnvVarsTable({ namespace, app }: Props) {
<>
<div className="text-muted mb-4 mt-6 flex items-center">
<Icon icon={File} className="!mr-2" />
Configuration
Environment variables, ConfigMaps or Secrets
</div>
{appEnvVars.length === 0 && (
<TextTip color="blue">
This application is not using any environment variable or
configuration.
This application is not using any environment variable, ConfigMap or
Secret.
</TextTip>
)}
{appEnvVars.length > 0 && (

View File

@@ -11,7 +11,7 @@ export function confirmDeleteAccess() {
environment. Removing the registry access could lead to a service
interruption for these applications.
</p>
<p>Do you wish to continue?</p>
<p>Are you sure you wish to continue?</p>
</>
),
confirmButton: buildConfirmButton('Remove', 'danger'),

View File

@@ -199,7 +199,7 @@ export function IngressClassDatatable({
</p>
<ul className="ml-6">
{usedControllersToDisallow.map((controller) => (
<li key={controller.ClassName}>${controller.ClassName}</li>
<li key={controller.ClassName}>{controller.ClassName}</li>
))}
</ul>
<p>

View File

@@ -9,6 +9,7 @@ import {
notifySuccess,
} from '@/portainer/services/notifications';
import { isFulfilled, isRejected } from '@/portainer/helpers/promise-utils';
import { pluralize } from '@/portainer/helpers/strings';
import { parseKubernetesAxiosError } from '../axiosError';
@@ -104,7 +105,10 @@ export function useMutationDeleteConfigMaps(environmentId: EnvironmentId) {
// show one summary message for all successful deletes
if (successfulConfigMaps.length) {
notifySuccess(
'ConfigMaps successfully removed',
`${pluralize(
successfulConfigMaps.length,
'ConfigMap'
)} successfully removed`,
successfulConfigMaps.join(', ')
);
}

View File

@@ -9,6 +9,7 @@ import {
notifySuccess,
} from '@/portainer/services/notifications';
import { isFulfilled, isRejected } from '@/portainer/helpers/promise-utils';
import { pluralize } from '@/portainer/helpers/strings';
import { parseKubernetesAxiosError } from '../axiosError';
@@ -100,7 +101,10 @@ export function useMutationDeleteSecrets(environmentId: EnvironmentId) {
// show one summary message for all successful deletes
if (successfulSecrets.length) {
notifySuccess(
'Secrets successfully removed',
`${pluralize(
successfulSecrets.length,
'Secret'
)} successfully removed`,
successfulSecrets.join(', ')
);
}

View File

@@ -7,7 +7,7 @@ import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { useConfigurations } from '@/react/kubernetes/configs/queries';
import { useNamespaces } from '@/react/kubernetes/namespaces/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 { Link } from '@@/Link';
@@ -23,8 +23,7 @@ import {
useIngressControllers,
} from '../queries';
import { Annotation } from './Annotations/types';
import { Rule, Path, Host } from './types';
import { Rule, Path, Host, GroupedServiceOptions } from './types';
import { IngressForm } from './IngressForm';
import {
prepareTLS,
@@ -33,6 +32,7 @@ import {
prepareRuleFromIngress,
checkIfPathExistsWithHost,
} from './utils';
import { Annotation } from './Annotations/types';
export function CreateIngressView() {
const environmentId = useEnvironmentId();
@@ -53,35 +53,27 @@ export function CreateIngressView() {
const [namespace, setNamespace] = useState<string>(params.namespace || '');
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>>(
{} 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 ingressesResults = useIngresses(
environmentId,
namespacesResults.data ? Object.keys(namespacesResults?.data || {}) : []
);
const ingressControllersResults = useIngressControllers(
environmentId,
namespace
namespaces ? Object.keys(namespaces || {}) : []
);
const { data: ingressControllers, ...ingressControllersQuery } =
useIngressControllers(environmentId, namespace);
const createIngressMutation = useCreateIngress();
const updateIngressMutation = useUpdateIngress();
const isLoading =
(servicesResults.isLoading &&
configResults.isLoading &&
namespacesResults.isLoading &&
ingressesResults.isLoading &&
ingressControllersResults.isLoading) ||
(isEdit && !ingressRule.IngressName);
const [ingressNames, ingresses, ruleCounterByNamespace, hostWithTLS] =
useMemo((): [
string[],
@@ -121,40 +113,55 @@ export function CreateIngressView() {
];
}, [ingressesResults.data, namespace]);
const namespacesOptions: Option<string>[] = [
{ 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(
const namespaceOptions = useMemo(
() =>
clusterIpServices?.map((service) => ({
label: service.Name,
value: service.Name,
})),
[clusterIpServices]
Object.entries(namespaces || {})
.filter(([, nsValue]) => !nsValue.IsSystem)
.map(([nsKey]) => ({
label: nsKey,
value: nsKey,
})),
[namespaces]
);
const serviceOptions = [
{ label: 'Select a service', value: '' },
...(servicesOptions || []),
];
const serviceOptions: GroupedServiceOptions = useMemo(() => {
const groupedOptions: GroupedServiceOptions = (
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(
() =>
clusterIpServices
allServices
? Object.fromEntries(
clusterIpServices?.map((service) => [
allServices?.map((service) => [
service.Name,
service.Ports.map((port) => ({
label: String(port.Port),
@@ -163,42 +170,96 @@ export function CreateIngressView() {
])
)
: {},
[clusterIpServices]
[allServices]
);
const existingIngressClass = useMemo(
() =>
ingressControllersResults.data?.find(
(i) =>
i.ClassName === ingressRule.IngressClassName ||
(i.Type === 'custom' && ingressRule.IngressClassName === '')
ingressControllers?.find(
(controller) =>
controller.ClassName === 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 (
(!existingIngressClass ||
(existingIngressClass && !existingIngressClass.Availability)) &&
ingressRule.IngressClassName &&
!ingressControllersResults.isLoading
) {
const optionLabel = !ingressRule.IngressType
? `${ingressRule.IngressClassName} - NOT FOUND`
: `${ingressRule.IngressClassName} - DISALLOWED`;
ingressClassOptions.push({
label: optionLabel,
value: ingressRule.IngressClassName,
});
}
const ingressClassOptions: Option<string>[] = useMemo(() => {
const allowedIngressClassOptions =
ingressControllers
?.filter((controller) => !!controller.Availability)
.map((cls) => ({
label: cls.ClassName,
value: cls.ClassName,
})) || [];
// if the ingress class is not set, return only the allowed ingress classes
if (ingressRule.IngressClassName === '' || !isEdit) {
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(
(config) =>
@@ -221,15 +282,15 @@ export function CreateIngressView() {
!!params.name &&
ingressesResults.data &&
!ingressRule.IngressName &&
!ingressControllersResults.isLoading &&
!ingressControllersResults.isLoading
!ingressControllersQuery.isLoading &&
!ingressControllersQuery.isLoading
) {
// if it is an edit screen, prepare the rule from the ingress
const ing = ingressesResults.data?.find(
(ing) => ing.Name === params.name && ing.Namespace === params.namespace
);
if (ing) {
const type = ingressControllersResults.data?.find(
const type = ingressControllers?.find(
(c) =>
c.ClassName === ing.ClassName ||
(c.Type === 'custom' && !ing.ClassName)
@@ -237,13 +298,14 @@ export function CreateIngressView() {
const r = prepareRuleFromIngress(ing, type);
r.IngressType = type || r.IngressType;
setIngressRule(r);
setIsEditClassNameSet(true);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
params.name,
ingressesResults.data,
ingressControllersResults.data,
ingressControllers,
ingressRule.IngressName,
params.namespace,
]);
@@ -291,7 +353,7 @@ export function CreateIngressView() {
(
ingressRule: Rule,
ingressNames: string[],
serviceOptions: Option<string>[],
groupedServiceOptions: GroupedServiceOptions,
existingIngressClass?: IngressController
) => {
const errors: Record<string, ReactNode> = {};
@@ -313,12 +375,15 @@ export function CreateIngressView() {
errors.ingressName = 'Ingress name already exists';
}
if (!rule.IngressClassName) {
if (
(!ingressClassOptions.length || !rule.IngressClassName) &&
ingressControllersQuery.isSuccess
) {
errors.className = 'Ingress class is required';
}
}
if (isEdit && !ingressRule.IngressClassName) {
if (isEdit && !ingressRule.IngressClassName && isEditClassNameSet) {
errors.className =
'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';
}
const availableServiceNames = groupedServiceOptions.flatMap(
(optionGroup) => optionGroup.options.map((option) => option.value)
);
if (
isEdit &&
path.ServiceName &&
!serviceOptions.find((s) => s.value === path.ServiceName)
!availableServiceNames.find((sn) => sn === path.ServiceName)
) {
errors[`hosts[${hi}].paths[${pi}].servicename`] = (
<span>
@@ -455,26 +524,33 @@ export function CreateIngressView() {
}
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(() => {
if (namespace.length > 0) {
debouncedValidate(
ingressRule,
ingressNames || [],
servicesOptions || [],
serviceOptions || [],
existingIngressClass
);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
ingressRule,
namespace,
ingressNames,
servicesOptions,
serviceOptions,
existingIngressClass,
debouncedValidate,
]);
@@ -482,14 +558,14 @@ export function CreateIngressView() {
return (
<>
<PageHeader
title={isEdit ? 'Edit ingress' : 'Add ingress'}
title={isEdit ? 'Edit ingress' : 'Create ingress'}
breadcrumbs={[
{
link: 'kubernetes.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">
<IngressForm
environmentID={environmentId}
isLoading={isLoading}
isEdit={isEdit}
rule={ingressRule}
ingressClassOptions={ingressClassOptions}
isIngressClassOptionsLoading={ingressControllersQuery.isLoading}
errors={errors}
servicePorts={servicePorts}
tlsOptions={tlsOptions}
@@ -519,10 +595,13 @@ export function CreateIngressView() {
handleAnnotationChange={handleAnnotationChange}
namespace={namespace}
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>
{namespace && !isLoading && (
{namespace && (
<div className="col-sm-12">
<Button
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) {
setIngressRule((prevRules) => {
const rule = { ...prevRules };
@@ -636,7 +703,8 @@ export function CreateIngressView() {
Key: uuidv4(),
Namespace: namespace,
IngressName: newKey,
IngressClassName: '',
IngressClassName: ingressRule.IngressClassName || '',
IngressType: ingressRule.IngressType || '',
Hosts: [host],
};

View File

@@ -1,19 +1,23 @@
import { ChangeEvent, ReactNode } from 'react';
import { ChangeEvent, ReactNode, useEffect } from 'react';
import { Plus, RefreshCw, Trash2 } from 'lucide-react';
import Route from '@/assets/ico/route.svg?c';
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 { Widget, WidgetBody, WidgetTitle } from '@@/Widget';
import { Tooltip } from '@@/Tip/Tooltip';
import { Button } from '@@/buttons';
import { TooltipWithChildren } from '@@/Tip/TooltipWithChildren';
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 { Rule, ServicePorts } from './types';
import { GroupedServiceOptions, Rule, ServicePorts } from './types';
import '../style.css';
@@ -33,15 +37,17 @@ interface Props {
rule: Rule;
errors: Record<string, ReactNode>;
isLoading: boolean;
isEdit: boolean;
namespace: string;
servicePorts: ServicePorts;
ingressClassOptions: Option<string>[];
serviceOptions: Option<string>[];
isIngressClassOptionsLoading: boolean;
serviceOptions: GroupedServiceOptions;
tlsOptions: Option<string>[];
namespacesOptions: Option<string>[];
isNamespaceOptionsLoading: boolean;
isIngressNamesLoading: boolean;
removeIngressRoute: (hostIndex: number, pathIndex: number) => void;
removeIngressHost: (hostIndex: number) => void;
@@ -76,7 +82,6 @@ interface Props {
export function IngressForm({
environmentID,
rule,
isLoading,
isEdit,
servicePorts,
tlsOptions,
@@ -94,20 +99,40 @@ export function IngressForm({
reloadTLSCerts,
handleAnnotationChange,
ingressClassOptions,
isIngressClassOptionsLoading,
errors,
namespacesOptions,
isNamespaceOptionsLoading,
isIngressNamesLoading,
handleNamespaceChange,
namespace,
}: Props) {
if (isLoading) {
return <div>Loading...</div>;
}
const hasNoHostRule = rule.Hosts?.some((host) => host.NoHost);
const placeholderAnnotation =
PlaceholderAnnotations[rule.IngressType || 'other'] ||
PlaceholderAnnotations.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 (
<Widget>
<WidgetTitle icon={Route} title="Ingress" />
@@ -121,19 +146,40 @@ export function IngressForm({
>
Namespace
</label>
<div className="col-sm-4">
{isEdit ? (
namespace
) : (
<Select
name="namespaces"
options={namespacesOptions || []}
onChange={(e) => handleNamespaceChange(e.target.value)}
defaultValue={namespace}
disabled={isEdit}
/>
)}
</div>
{isNamespaceOptionsLoading && (
<div className="col-sm-4">
<InlineLoader className="pt-2">
Loading namespaces...
</InlineLoader>
</div>
)}
{!isNamespaceOptionsLoading && (
<div className={`col-sm-4 ${isEdit && 'control-label'}`}>
{isEdit ? (
namespace
) : (
<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>
@@ -180,20 +226,43 @@ export function IngressForm({
Ingress class
</label>
<div className="col-sm-4">
<Select
name="ingress_class"
className="form-control"
placeholder="Ingress name"
defaultValue={rule.IngressClassName}
onChange={(e: ChangeEvent<HTMLSelectElement>) =>
handleIngressChange('IngressClassName', e.target.value)
}
options={ingressClassOptions}
/>
{errors.className && (
<FormError className="error-inline mt-1">
{errors.className}
</FormError>
{isIngressClassOptionsLoading && (
<InlineLoader className="pt-2">
Loading ingress classes...
</InlineLoader>
)}
{!isIngressClassOptionsLoading && (
<>
<Select
name="ingress_class"
placeholder={
ingressClassOptions.length
? 'Select an ingress class'
: 'No ingress classes available'
}
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>
@@ -300,9 +369,9 @@ export function IngressForm({
{namespace &&
rule?.Hosts?.map((host, hostIndex) => (
<div className="row rule bordered mb-5" key={host.Key}>
<div className="col-sm-12">
<div className="row rule-actions mt-5">
<Card key={host.Key} className="mb-5">
<div className="flex flex-col">
<div className="row rule-actions">
<div className="col-sm-3 p-0">
{!host.NoHost ? 'Rule' : 'Fallback rule'}
</div>
@@ -323,11 +392,9 @@ export function IngressForm({
{!host.NoHost && (
<div className="row">
<div className="form-group col-sm-6 col-lg-4 !pl-0 !pr-2">
<div className="input-group input-group-sm">
<span className="input-group-addon required">
Hostname
</span>
<input
<InputGroup size="small">
<InputGroup.Addon required>Hostname</InputGroup.Addon>
<InputGroup.Input
name={`ingress_host_${hostIndex}`}
type="text"
className="form-control form-control-sm"
@@ -337,7 +404,7 @@ export function IngressForm({
handleHostChange(hostIndex, e.target.value)
}
/>
</div>
</InputGroup>
{errors[`hosts[${hostIndex}].host`] && (
<FormError className="mt-1 !mb-0">
{errors[`hosts[${hostIndex}].host`]}
@@ -346,17 +413,29 @@ export function IngressForm({
</div>
<div className="form-group col-sm-6 col-lg-4 !pr-0 !pl-2">
<div className="input-group input-group-sm">
<span className="input-group-addon">TLS secret</span>
<InputGroup size="small">
<InputGroup.Addon>TLS secret</InputGroup.Addon>
<Select
key={tlsOptions.toString() + host.Secret}
name={`ingress_tls_${hostIndex}`}
options={tlsOptions}
onChange={(e: ChangeEvent<HTMLSelectElement>) =>
handleTLSChange(hostIndex, e.target.value)
value={
host.Secret !== undefined
? {
value: host.Secret,
label: host.Secret || 'No TLS',
}
: null
}
defaultValue={host.Secret}
className="!rounded-r-none"
onChange={(TLSOption) =>
handleTLSChange(hostIndex, TLSOption?.value || '')
}
placeholder={
tlsOptions.length
? 'Select a TLS secret'
: 'No TLS secrets available'
}
noOptionsMessage={() => 'No TLS secrets available'}
size="sm"
/>
{!host.NoHost && (
<div className="input-group-btn">
@@ -367,7 +446,7 @@ export function IngressForm({
/>
</div>
)}
</div>
</InputGroup>
</div>
<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}}`}
>
<div className="form-group col-sm-3 col-xl-2 !m-0 !pl-0">
<div className="input-group input-group-sm">
<span className="input-group-addon required">
Service
</span>
<InputGroup size="small">
<InputGroup.Addon required>Service</InputGroup.Addon>
<Select
key={serviceOptions.toString() + path.ServiceName}
name={`ingress_service_${hostIndex}_${pathIndex}`}
options={serviceOptions}
onChange={(e: ChangeEvent<HTMLSelectElement>) =>
value={
path.ServiceName
? {
value: path.ServiceName,
label: getServiceLabel(
serviceOptions,
path.ServiceName
),
}
: null
}
onChange={(serviceOption) =>
handlePathChange(
hostIndex,
pathIndex,
'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[
`hosts[${hostIndex}].paths[${pathIndex}].servicename`
] && (
@@ -446,38 +540,49 @@ export function IngressForm({
)}
</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 && (
<>
<div className="input-group input-group-sm">
<span className="input-group-addon required">
<InputGroup size="small">
<InputGroup.Addon required>
Service port
</span>
</InputGroup.Addon>
<Select
key={servicePorts.toString() + path.ServicePort}
name={`ingress_servicePort_${hostIndex}_${pathIndex}`}
options={
path.ServiceName &&
servicePorts[path.ServiceName]
? servicePorts[path.ServiceName]
: [
{
label: 'Select port',
value: '',
},
]
servicePorts[path.ServiceName]?.map(
(portOption) => ({
...portOption,
value: portOption.value.toString(),
})
) || []
}
onChange={(e: ChangeEvent<HTMLSelectElement>) =>
onChange={(option) =>
handlePathChange(
hostIndex,
pathIndex,
'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[
`hosts[${hostIndex}].paths[${pathIndex}].serviceport`
] && (
@@ -494,30 +599,42 @@ export function IngressForm({
</div>
<div className="form-group col-sm-3 col-xl-2 !m-0 !pl-0">
<div className="input-group input-group-sm">
<span className="input-group-addon">Path type</span>
<Select
<InputGroup size="small">
<InputGroup.Addon>Path type</InputGroup.Addon>
<Select<Option<string>>
key={servicePorts.toString() + path.PathType}
name={`ingress_pathType_${hostIndex}_${pathIndex}`}
options={
pathTypes
? pathTypes.map((type) => ({
label: type,
value: type,
}))
: []
pathTypes?.map((type) => ({
label: type,
value: type,
})) || []
}
onChange={(e: ChangeEvent<HTMLSelectElement>) =>
onChange={(option) =>
handlePathChange(
hostIndex,
pathIndex,
'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[
`hosts[${hostIndex}].paths[${pathIndex}].pathType`
] && (
@@ -532,9 +649,9 @@ export function IngressForm({
</div>
<div className="form-group col-sm-3 col-xl-3 !m-0 !pl-0">
<div className="input-group input-group-sm">
<span className="input-group-addon required">Path</span>
<input
<InputGroup size="small">
<InputGroup.Addon required>Path</InputGroup.Addon>
<InputGroup.Input
className="form-control"
name={`ingress_route_${hostIndex}-${pathIndex}`}
placeholder="/example"
@@ -550,7 +667,7 @@ export function IngressForm({
)
}
/>
</div>
</InputGroup>
{errors[
`hosts[${hostIndex}].paths[${pathIndex}].path`
] && (
@@ -566,7 +683,7 @@ export function IngressForm({
<div className="form-group col-sm-1 !m-0 !pl-0">
<Button
className="btn-only-icon vertical-center !ml-0"
className="!ml-0 h-[30px]"
color="dangerlight"
type="button"
data-cy={`k8sAppCreate-rmPortButton_${hostIndex}-${pathIndex}`}
@@ -592,7 +709,7 @@ export function IngressForm({
</Button>
</div>
</div>
</div>
</Card>
))}
{namespace && (
@@ -628,3 +745,9 @@ export function IngressForm({
</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 || '';
}

View File

@@ -31,3 +31,12 @@ export interface Rule {
export interface ServicePorts {
[serviceName: string]: Option<string>[];
}
interface ServiceOption extends Option<string> {
selectedLabel: string;
}
export type GroupedServiceOptions = {
label: string;
options: ServiceOption[];
}[];

View File

@@ -192,7 +192,6 @@ export function useIngressControllers(
namespace ? getIngressControllers(environmentId, namespace) : [],
{
enabled: !!namespace,
cacheTime: 0,
...withError('Unable to get ingress controllers'),
}
);

View File

@@ -30,6 +30,7 @@ export function confirmUpdateNamespace(
a service interruption for these applications.
</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