Compare commits
46 Commits
release/2.
...
2.19.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7abed624d9 | ||
|
|
1e24451cc9 | ||
|
|
adcfcdd6e3 | ||
|
|
e6e3810fa4 | ||
|
|
5e20854f86 | ||
|
|
69f3670ce5 | ||
|
|
f24555c6c9 | ||
|
|
1c79f10ae8 | ||
|
|
dc76900a28 | ||
|
|
74eeb9da06 | ||
|
|
77120abf33 | ||
|
|
dffdf6783c | ||
|
|
55236129ea | ||
|
|
d54dd47b21 | ||
|
|
360969c93e | ||
|
|
3ea6d2b9d9 | ||
|
|
577a36e04e | ||
|
|
6aa978d5e9 | ||
|
|
0b8d72bfd4 | ||
|
|
faa1387110 | ||
|
|
f5cc245c63 | ||
|
|
20c6965ce0 | ||
|
|
53679f9381 | ||
|
|
e1951baac0 | ||
|
|
187ec2aa9a | ||
|
|
125db4f0de | ||
|
|
59be96e9e8 | ||
|
|
d3420f39c1 | ||
|
|
004c86578d | ||
|
|
b3d404b378 | ||
|
|
82faf20c68 | ||
|
|
18e40cd973 | ||
|
|
9c4d512a4c | ||
|
|
ce5c38f841 | ||
|
|
dbb79a181e | ||
|
|
2177c27dc4 | ||
|
|
bfdd72d644 | ||
|
|
998bf481f7 | ||
|
|
c97ef40cc0 | ||
|
|
cbae7bdf82 | ||
|
|
f4ec4d6175 | ||
|
|
ec39d5a88e | ||
|
|
d0d9c2a93b | ||
|
|
73010efd8d | ||
|
|
88de50649f | ||
|
|
fc89066846 |
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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, ®istry)
|
||||
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(®istry)
|
||||
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.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
16
api/internal/securecookie/securecookie.go
Normal file
16
api/internal/securecookie/securecookie.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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'),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -72,6 +72,11 @@ angular
|
||||
component: 'editEdgeStackView',
|
||||
},
|
||||
},
|
||||
params: {
|
||||
status: {
|
||||
dynamic: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const edgeJobs = {
|
||||
|
||||
@@ -92,7 +92,6 @@ export const componentsModule = angular
|
||||
'query',
|
||||
'title',
|
||||
'data-cy',
|
||||
'hideEnvironmentIds',
|
||||
])
|
||||
)
|
||||
.component(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -8,5 +8,6 @@ angular.module('portainer.kubernetes').component('kubernetesConfigurationData',
|
||||
isValid: '=',
|
||||
isCreation: '=',
|
||||
isEditorDirty: '=',
|
||||
type: '<',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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))),
|
||||
[]
|
||||
)
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 "none"'"
|
||||
tooltip="'This allows users setting up ingresses to select "none" 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 "none"'"
|
||||
tooltip="'This allows users setting up ingresses to select "none" 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 -->
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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"> & </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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -117,8 +117,8 @@ export function createMockEnvironment(): Environment {
|
||||
StartTime: '',
|
||||
},
|
||||
StatusMessage: {
|
||||
Detail: '',
|
||||
Summary: '',
|
||||
detail: '',
|
||||
summary: '',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { semverCompare } from './utils';
|
||||
import { semverCompare } from './semver-utils';
|
||||
|
||||
describe('semverCompare', () => {
|
||||
test('sort array', () => {
|
||||
27
app/react/common/semver-utils.ts
Normal file
27
app/react/common/semver-utils.ts
Normal 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;
|
||||
}
|
||||
19
app/react/components/InlineLoader/InlineLoader.stories.tsx
Normal file
19
app/react/components/InlineLoader/InlineLoader.stories.tsx
Normal 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...',
|
||||
};
|
||||
23
app/react/components/InlineLoader/InlineLoader.tsx
Normal file
23
app/react/components/InlineLoader/InlineLoader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1
app/react/components/InlineLoader/index.ts
Normal file
1
app/react/components/InlineLoader/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { InlineLoader } from './InlineLoader';
|
||||
@@ -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',
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -17,7 +17,7 @@ interface WidgetProps {
|
||||
}
|
||||
|
||||
const meta: Meta<WidgetProps> = {
|
||||
title: 'Widget',
|
||||
title: 'Components/Widget',
|
||||
component: Widget,
|
||||
args: {
|
||||
loading: false,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>>() {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -7,6 +7,6 @@ export const size = columnHelper.accessor('VirtualSize', {
|
||||
header: 'Size',
|
||||
cell: ({ getValue }) => {
|
||||
const value = getValue();
|
||||
return humanize(value);
|
||||
return humanize(value) || '-';
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 />}
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -121,6 +121,7 @@ function ErrorCell({ getValue }: CellContext<EdgeStackEnvironment, string>) {
|
||||
|
||||
return (
|
||||
<Button
|
||||
color="none"
|
||||
className="flex cursor-pointer"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
|
||||
@@ -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 '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 />
|
||||
</>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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/"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(', ')
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(', ')
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
};
|
||||
|
||||
|
||||
@@ -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 || '';
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
}[];
|
||||
|
||||
@@ -192,7 +192,6 @@ export function useIngressControllers(
|
||||
namespace ? getIngressControllers(environmentId, namespace) : [],
|
||||
{
|
||||
enabled: !!namespace,
|
||||
cacheTime: 0,
|
||||
...withError('Unable to get ingress controllers'),
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user