Compare commits
55 Commits
2.19.2
...
fix/EE-598
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2bfb4c0e25 | ||
|
|
748c6fa22a | ||
|
|
95424c322d | ||
|
|
a1e610a39a | ||
|
|
a27cc6c0e5 | ||
|
|
2b4cb1b7b4 | ||
|
|
26074437ca | ||
|
|
665a25e448 | ||
|
|
4a91e947ed | ||
|
|
d514eeec86 | ||
|
|
0ef4aad79a | ||
|
|
8355d449c5 | ||
|
|
fd7e8a629e | ||
|
|
7757bf7a84 | ||
|
|
5862aa5dd8 | ||
|
|
925a0d0a9a | ||
|
|
2a7a96f498 | ||
|
|
c472fe9c18 | ||
|
|
0eaf296e1b | ||
|
|
598b8d0f28 | ||
|
|
e1a3010bc7 | ||
|
|
2de4863532 | ||
|
|
8cf54cd0df | ||
|
|
1ef1953d7d | ||
|
|
5b033abaa4 | ||
|
|
5865f1ca77 | ||
|
|
f59573f306 | ||
|
|
1cecbd7177 | ||
|
|
acf9203580 | ||
|
|
9845518aa9 | ||
|
|
d7e83aad26 | ||
|
|
df47f3d8a8 | ||
|
|
d0ecf6c16b | ||
|
|
e400c4dfc6 | ||
|
|
721457b71d | ||
|
|
b19800681f | ||
|
|
6a4e44ee0a | ||
|
|
37ece734f0 | ||
|
|
bf79ef7d89 | ||
|
|
883ef2578f | ||
|
|
a585f34106 | ||
|
|
b128139b69 | ||
|
|
4c425a7af8 | ||
|
|
400d95c1a5 | ||
|
|
ca617e2ac9 | ||
|
|
4a90b8a3f7 | ||
|
|
43ad3face2 | ||
|
|
69e61be474 | ||
|
|
a4ea7a3709 | ||
|
|
c5ecf8a66d | ||
|
|
c2c0631495 | ||
|
|
4ff3cee72e | ||
|
|
c4e8251e52 | ||
|
|
21b00c267d | ||
|
|
86ec058347 |
11
.github/DISCUSSION_TEMPLATE/help.yaml
vendored
Normal file
11
.github/DISCUSSION_TEMPLATE/help.yaml
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Before asking a question, make sure it hasn't been already asked and answered. You can search our [discussions](https://github.com/orgs/portainer/discussions) and [bug reports](https://github.com/portainer/portainer/issues) in GitHub. Also, be sure to check our [knowledge base](https://portal.portainer.io/knowledge) and [documentation](https://docs.portainer.io/) first.
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Ask a Question!
|
||||
validations:
|
||||
required: true
|
||||
38
.github/DISCUSSION_TEMPLATE/ideas.yaml
vendored
Normal file
38
.github/DISCUSSION_TEMPLATE/ideas.yaml
vendored
Normal file
@@ -0,0 +1,38 @@
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
# Welcome!
|
||||
|
||||
Thanks for suggesting an idea for Portainer!
|
||||
|
||||
Before opening a new idea or feature request, make sure that we do not have any duplicates already open. You can ensure this by [searching this discussion cagetory](https://github.com/orgs/portainer/discussions/categories/ideas). If there is a duplicate, please add a comment to the existing idea instead.
|
||||
|
||||
Also, be sure to check our [knowledge base](https://portal.portainer.io/knowledge) and [documentation](https://docs.portainer.io) as they may point you toward a solution.
|
||||
|
||||
**DO NOT FILE DUPLICATE REQUESTS.**
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Is your feature request related to a problem? Please describe
|
||||
description: Short list of what the feature request aims to address.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Describe the solution you'd like
|
||||
description: A clear and concise description of what you want to happen.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Describe alternatives you've considered
|
||||
description: A clear and concise description of any alternative solutions or features you've considered.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Additional context
|
||||
description: Add any other context or screenshots about the feature request here.
|
||||
validations:
|
||||
required: false
|
||||
54
.github/ISSUE_TEMPLATE/Bug_report.md
vendored
54
.github/ISSUE_TEMPLATE/Bug_report.md
vendored
@@ -1,54 +0,0 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a bug report
|
||||
title: ''
|
||||
labels: bug/need-confirmation, kind/bug
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
<!--
|
||||
|
||||
Thanks for reporting a bug for Portainer !
|
||||
|
||||
You can find more information about Portainer support framework policy here: https://www.portainer.io/2019/04/portainer-support-policy/
|
||||
|
||||
Do you need help or have a question? Come chat with us on Slack https://portainer.io/slack/
|
||||
|
||||
Before opening a new issue, make sure that we do not have any duplicates
|
||||
already open. You can ensure this by searching the issue list for this
|
||||
repository. If there is a duplicate, please close your issue and add a comment
|
||||
to the existing issue instead.
|
||||
|
||||
Also, be sure to check our FAQ and documentation first: https://documentation.portainer.io/
|
||||
-->
|
||||
|
||||
**Bug description**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Portainer Logs**
|
||||
Provide the logs of your Portainer container or Service.
|
||||
You can see how [here](https://documentation.portainer.io/r/portainer-logs)
|
||||
|
||||
**Steps to reproduce the issue:**
|
||||
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Technical details:**
|
||||
|
||||
- Portainer version:
|
||||
- Docker version (managed by Portainer):
|
||||
- Kubernetes version (managed by Portainer):
|
||||
- Platform (windows/linux):
|
||||
- Command used to start Portainer (`docker run -p 9443:9443 portainer/portainer`):
|
||||
- Browser:
|
||||
- Use Case (delete as appropriate): Using Portainer at Home, Using Portainer in a Commercial setup.
|
||||
- Have you reviewed our technical documentation and knowledge base? Yes/No
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
164
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
164
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
@@ -0,0 +1,164 @@
|
||||
name: Bug Report
|
||||
description: Create a report to help us improve.
|
||||
labels: kind/bug,bug/need-confirmation
|
||||
body:
|
||||
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
# Welcome!
|
||||
|
||||
The issue tracker is for reporting bugs. If you have an [idea for a new feature](https://github.com/orgs/portainer/discussions/categories/ideas) or a [general question about Portainer](https://github.com/orgs/portainer/discussions/categories/help) please post in our [GitHub Discussions](https://github.com/orgs/portainer/discussions).
|
||||
|
||||
You can also ask for help in our [community Slack channel](https://join.slack.com/t/portainer/shared_invite/zt-txh3ljab-52QHTyjCqbe5RibC2lcjKA).
|
||||
|
||||
**DO NOT FILE ISSUES FOR GENERAL SUPPORT QUESTIONS**.
|
||||
|
||||
- type: checkboxes
|
||||
id: terms
|
||||
attributes:
|
||||
label: Before you start please confirm the following.
|
||||
options:
|
||||
- label: Yes, I've searched similar issues on [GitHub](https://github.com/portainer/portainer/issues).
|
||||
required: true
|
||||
- label: Yes, I've checked whether this issue is covered in the Portainer [documentation](https://docs.portainer.io) or [knowledge base](https://portal.portainer.io/knowledge).
|
||||
required: true
|
||||
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
# About your issue
|
||||
|
||||
Tell us a bit about the issue you're having.
|
||||
|
||||
How to write a good bug report:
|
||||
|
||||
- Respect the issue template as much as possible.
|
||||
- Summarize the issue so that we understand what is going wrong.
|
||||
- Describe what you would have expected to have happened, and what actually happened instead.
|
||||
- Provide easy to follow steps to reproduce the issue.
|
||||
- Remain clear and concise.
|
||||
- Format your messages to help the reader focus on what matters and understand the structure of your message, use [Markdown syntax](https://help.github.com/articles/github-flavored-markdown).
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Problem Description
|
||||
description: A clear and concise description of what the bug is.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Expected Behavior
|
||||
description: A clear and concise description of what you expected to happen.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Actual Behavior
|
||||
description: A clear and concise description of what actually happens.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Steps to Reproduce
|
||||
description: Please be as detailed as possible when providing steps to reproduce.
|
||||
placeholder: |
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Portainer logs or screenshots
|
||||
description: Provide Portainer container logs or any screenshots related to the issue.
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
# About your environment
|
||||
|
||||
Tell us a bit about your Portainer environment.
|
||||
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: Portainer version
|
||||
description: We only provide support for the most recent version of Portainer and the previous 3 versions. If you are on an older version of Portainer we recommend [upgrading first](https://docs.portainer.io/start/upgrade) in case your bug has already been fixed.
|
||||
multiple: false
|
||||
options:
|
||||
- '2.18.4'
|
||||
- '2.18.3'
|
||||
- '2.18.2'
|
||||
- '2.18.1'
|
||||
- '2.17.1'
|
||||
- '2.17.0'
|
||||
- '2.16.2'
|
||||
- '2.16.1'
|
||||
- '2.16.0'
|
||||
- '2.15.1'
|
||||
- '2.15.0'
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: Portainer Edition
|
||||
multiple: false
|
||||
options:
|
||||
- 'Business Edition (BE/EE) with 5NF / 3NF license'
|
||||
- 'Business Edition (BE/EE) with Home & Student license'
|
||||
- 'Business Edition (BE/EE) with Starter license'
|
||||
- 'Business Edition (BE/EE) with Professional or Enterprise license'
|
||||
- 'Community Edition (CE)'
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
attributes:
|
||||
label: Platform and Version
|
||||
description: |
|
||||
Enter your container management platform (Docker | Swarm | Kubernetes) along with the version.
|
||||
Example: Docker 24.0.3 | Docker Swarm 24.0.3 | Kubernetes 1.26
|
||||
You can find our supported platforms [in our documentation](https://docs.portainer.io/start/requirements-and-prerequisites).
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
attributes:
|
||||
label: OS and Architecture
|
||||
description: |
|
||||
Enter your Operating System, Version and Architecture. Example: Ubuntu 22.04, AMD64 | Raspbian OS, ARM64
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
attributes:
|
||||
label: Browser
|
||||
description: |
|
||||
Enter your browser and version. Example: Google Chrome 114.0
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: What command did you use to deploy Portainer?
|
||||
description: |
|
||||
Example: `docker run -d -p 8000:8000 -p 9443:9443 --name portainer --restart=always -v /var/run/docker.sock:/var/run/docker.sock -v portainer_data:/data portainer/portainer-ce:latest`
|
||||
If you deployed Portainer using a compose file or manifest you can provide this here as well.
|
||||
render: bash
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Additional Information
|
||||
description: Any additional information about your environment, the bug, or anything else you think might be helpful.
|
||||
validations:
|
||||
required: false
|
||||
10
.github/ISSUE_TEMPLATE/config.yml
vendored
10
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -1,5 +1,11 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Portainer Business Edition - Get 3 nodes free
|
||||
url: https://www.portainer.io/take-3
|
||||
- name: Question
|
||||
url: https://github.com/orgs/portainer/discussions/new?category=help
|
||||
about: Ask us a question about Portainer usage or deployment.
|
||||
- name: Idea or Feature Request
|
||||
url: https://github.com/orgs/portainer/discussions/new?category=ideas
|
||||
about: Suggest an idea or feature/enhancement that should be added in Portainer.
|
||||
- name: Portainer Business Edition - Get 3 Nodes Free
|
||||
url: https://www.portainer.io/take-3
|
||||
about: Portainer Business Edition has more features, more support and you can now get 3 nodes free for as long as you want.
|
||||
|
||||
5
.github/workflows/stale.yml
vendored
5
.github/workflows/stale.yml
vendored
@@ -1,7 +1,8 @@
|
||||
name: Close Stale Issues
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 12 * * *'
|
||||
- cron: '0 12 * * *'
|
||||
workflow_dispatch:
|
||||
jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -9,7 +10,7 @@ jobs:
|
||||
issues: write
|
||||
|
||||
steps:
|
||||
- uses: actions/stale@v4.0.0
|
||||
- uses: actions/stale@v8
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -944,6 +944,6 @@
|
||||
}
|
||||
],
|
||||
"version": {
|
||||
"VERSION": "{\"SchemaVersion\":\"2.19.0\",\"MigratorCount\":3,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
|
||||
"VERSION": "{\"SchemaVersion\":\"2.20.0\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
|
||||
}
|
||||
}
|
||||
@@ -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.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -899,18 +899,22 @@ func FileExists(filePath string) (bool, error) {
|
||||
}
|
||||
|
||||
// SafeCopyDirectory copies a directory from src to dst in a safe way.
|
||||
func (service *Service) SafeMoveDirectory(originalPath, newPath string) error {
|
||||
func (service *Service) SafeCopyDirectory(originalPath, newPath string) error {
|
||||
// 1. Backup the source directory to a different folder
|
||||
backupDir := fmt.Sprintf("%s-%s", filepath.Dir(originalPath), "backup")
|
||||
err := MoveDirectory(originalPath, backupDir)
|
||||
err := CopyDir(originalPath, backupDir, false)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to backup source directory: %w", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if err != nil {
|
||||
restoreErr := os.RemoveAll(originalPath)
|
||||
if err != nil {
|
||||
log.Warn().Err(restoreErr).Msg("failed to cleanup original directory")
|
||||
}
|
||||
// If an error occurred, rollback the backup directory
|
||||
restoreErr := restoreBackup(originalPath, backupDir)
|
||||
restoreErr = restoreBackup(originalPath, backupDir)
|
||||
if restoreErr != nil {
|
||||
log.Warn().Err(restoreErr).Msg("failed to restore backup during creating versioning folder")
|
||||
}
|
||||
|
||||
@@ -48,10 +48,6 @@ func UpdateGitObject(gitService portainer.GitService, objId string, gitConfig *g
|
||||
}
|
||||
|
||||
toDir := projectPath
|
||||
if enableVersionFolder {
|
||||
toDir = filesystem.JoinPaths(projectPath, newHash)
|
||||
}
|
||||
|
||||
cloneParams := &cloneRepositoryParameters{
|
||||
url: gitConfig.URL,
|
||||
ref: gitConfig.ReferenceName,
|
||||
@@ -65,10 +61,20 @@ func UpdateGitObject(gitService portainer.GitService, objId string, gitConfig *g
|
||||
}
|
||||
}
|
||||
|
||||
// For backward compatibility, we need to clone the repository in the old project path
|
||||
if err := cloneGitRepository(gitService, cloneParams); err != nil {
|
||||
return false, "", errors.WithMessagef(err, "failed to do a fresh clone of %v", objId)
|
||||
}
|
||||
|
||||
if enableVersionFolder {
|
||||
toDir = filesystem.JoinPaths(projectPath, newHash)
|
||||
cloneParams.toDir = toDir
|
||||
|
||||
if err := cloneGitRepository(gitService, cloneParams); err != nil {
|
||||
return false, "", errors.WithMessagef(err, "failed to do a fresh clone of %v", objId)
|
||||
}
|
||||
}
|
||||
|
||||
log.Debug().
|
||||
Str("hash", newHash).
|
||||
Str("url", gitConfig.URL).
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -84,7 +84,7 @@ type Handler struct {
|
||||
}
|
||||
|
||||
// @title PortainerCE API
|
||||
// @version 2.19.0
|
||||
// @version 2.20.0
|
||||
// @description.markdown api-description.md
|
||||
// @termsOfService
|
||||
|
||||
|
||||
@@ -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,7 +136,11 @@ 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)
|
||||
@@ -153,7 +157,19 @@ func (handler *Handler) startStack(stack *portainer.Stack, endpoint *portainer.E
|
||||
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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
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
|
||||
@@ -1410,7 +1412,7 @@ type (
|
||||
GetEdgeStackProjectPathByVersion(edgeStackIdentifier string, version int, commitHash string) string
|
||||
StoreEdgeStackFileFromBytesByVersion(edgeStackIdentifier, fileName string, version int, data []byte) (string, error)
|
||||
FormProjectPathByVersion(projectPath string, version int, commitHash string) string
|
||||
SafeMoveDirectory(src, dst string) error
|
||||
SafeCopyDirectory(src, dst string) error
|
||||
StoreRegistryManagementFileFromBytes(folder, fileName string, data []byte) (string, error)
|
||||
KeyPairFilesExist() (bool, error)
|
||||
StoreKeyPair(private, public []byte, privatePEMHeader, publicPEMHeader string) error
|
||||
@@ -1557,7 +1559,7 @@ type (
|
||||
|
||||
const (
|
||||
// APIVersion is the version number of the Portainer API
|
||||
APIVersion = "2.19.0"
|
||||
APIVersion = "2.20.0"
|
||||
// Edition is what this edition of Portainer is called
|
||||
Edition = PortainerCE
|
||||
// ComposeSyntaxMaxVersion is a maximum supported version of the docker compose syntax
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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: '<',
|
||||
},
|
||||
});
|
||||
|
||||
33
app/kubernetes/filters/application.ts
Normal file
33
app/kubernetes/filters/application.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { KubernetesPodNodeAffinityNodeSelectorRequirementOperators } from '../pod/models';
|
||||
|
||||
export function nodeAffinityValues(
|
||||
values: string | string[],
|
||||
operator: KubernetesPodNodeAffinityNodeSelectorRequirementOperators
|
||||
) {
|
||||
if (
|
||||
operator === KubernetesPodNodeAffinityNodeSelectorRequirementOperators.IN ||
|
||||
operator ===
|
||||
KubernetesPodNodeAffinityNodeSelectorRequirementOperators.NOT_IN
|
||||
) {
|
||||
return values;
|
||||
}
|
||||
|
||||
if (
|
||||
operator ===
|
||||
KubernetesPodNodeAffinityNodeSelectorRequirementOperators.EXISTS ||
|
||||
operator ===
|
||||
KubernetesPodNodeAffinityNodeSelectorRequirementOperators.DOES_NOT_EXIST
|
||||
) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (
|
||||
operator ===
|
||||
KubernetesPodNodeAffinityNodeSelectorRequirementOperators.GREATER_THAN ||
|
||||
operator ===
|
||||
KubernetesPodNodeAffinityNodeSelectorRequirementOperators.LOWER_THAN
|
||||
) {
|
||||
return values[0];
|
||||
}
|
||||
return '';
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import _ from 'lodash-es';
|
||||
import { KubernetesApplicationDataAccessPolicies } from 'Kubernetes/models/application/models';
|
||||
import { KubernetesApplicationTypes, KubernetesApplicationTypeStrings } from 'Kubernetes/models/application/models';
|
||||
import { KubernetesPodNodeAffinityNodeSelectorRequirementOperators } from 'Kubernetes/pod/models';
|
||||
import { nodeAffinityValues } from './application';
|
||||
|
||||
angular
|
||||
.module('portainer.kubernetes')
|
||||
@@ -65,22 +65,7 @@ angular
|
||||
})
|
||||
.filter('kubernetesApplicationConstraintNodeAffinityValue', function () {
|
||||
'use strict';
|
||||
return function (values, operator) {
|
||||
if (operator === KubernetesPodNodeAffinityNodeSelectorRequirementOperators.IN || operator === KubernetesPodNodeAffinityNodeSelectorRequirementOperators.NOT_IN) {
|
||||
return values;
|
||||
} else if (
|
||||
operator === KubernetesPodNodeAffinityNodeSelectorRequirementOperators.EXISTS ||
|
||||
operator === KubernetesPodNodeAffinityNodeSelectorRequirementOperators.DOES_NOT_EXIST
|
||||
) {
|
||||
return '';
|
||||
} else if (
|
||||
operator === KubernetesPodNodeAffinityNodeSelectorRequirementOperators.GREATER_THAN ||
|
||||
operator === KubernetesPodNodeAffinityNodeSelectorRequirementOperators.LOWER_THAN
|
||||
) {
|
||||
return values[0];
|
||||
}
|
||||
return '';
|
||||
};
|
||||
return nodeAffinityValues;
|
||||
})
|
||||
.filter('kubernetesNodeLabelHumanReadbleText', function () {
|
||||
'use strict';
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
export const KubernetesPodNodeAffinityNodeSelectorRequirementOperators = Object.freeze({
|
||||
IN: 'In',
|
||||
NOT_IN: 'NotIn',
|
||||
EXISTS: 'Exists',
|
||||
DOES_NOT_EXIST: 'DoesNotExist',
|
||||
GREATER_THAN: 'Gt',
|
||||
LOWER_THAN: 'Lt',
|
||||
});
|
||||
export enum KubernetesPodNodeAffinityNodeSelectorRequirementOperators {
|
||||
IN = 'In',
|
||||
NOT_IN = 'NotIn',
|
||||
EXISTS = 'Exists',
|
||||
DOES_NOT_EXIST = 'DoesNotExist',
|
||||
GREATER_THAN = 'Gt',
|
||||
LOWER_THAN = 'Lt',
|
||||
}
|
||||
|
||||
/**
|
||||
* KubernetesPodAffinity Model
|
||||
@@ -15,10 +15,11 @@ import { withUIRouter } from '@/react-tools/withUIRouter';
|
||||
import {
|
||||
ApplicationSummaryWidget,
|
||||
ApplicationDetailsWidget,
|
||||
ApplicationEventsDatatable,
|
||||
} from '@/react/kubernetes/applications/DetailsView';
|
||||
import { withUserProvider } from '@/react/test-utils/withUserProvider';
|
||||
import { withFormValidation } from '@/react-tools/withFormValidation';
|
||||
import { withCurrentUser } from '@/react-tools/withCurrentUser';
|
||||
import { PlacementsDatatable } from '@/react/kubernetes/applications/ItemView/PlacementsDatatable';
|
||||
|
||||
export const ngModule = angular
|
||||
.module('portainer.kubernetes.react.components', [])
|
||||
@@ -104,9 +105,23 @@ export const ngModule = angular
|
||||
.component(
|
||||
'applicationDetailsWidget',
|
||||
r2a(
|
||||
withUIRouter(withReactQuery(withUserProvider(ApplicationDetailsWidget))),
|
||||
withUIRouter(withReactQuery(withCurrentUser(ApplicationDetailsWidget))),
|
||||
[]
|
||||
)
|
||||
)
|
||||
.component(
|
||||
'applicationEventsDatatable',
|
||||
r2a(
|
||||
withUIRouter(withReactQuery(withCurrentUser(ApplicationEventsDatatable))),
|
||||
[]
|
||||
)
|
||||
)
|
||||
.component(
|
||||
'kubernetesApplicationPlacementsDatatable',
|
||||
r2a(withUIRouter(withCurrentUser(PlacementsDatatable)), [
|
||||
'dataset',
|
||||
'onRefresh',
|
||||
])
|
||||
);
|
||||
|
||||
export const componentsModule = ngModule.name;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -40,15 +40,11 @@
|
||||
<pr-icon icon="'info'" mode="'primary'"></pr-icon>
|
||||
The placement component helps you understand whether or not this application can be deployed on a specific node.
|
||||
</div>
|
||||
|
||||
<kubernetes-application-placements-datatable
|
||||
title-text="Placement constraints/preferences"
|
||||
title-icon="minimize-2"
|
||||
ng-if="ctrl.placements"
|
||||
dataset="ctrl.placements"
|
||||
table-key="kubernetes.application.placements"
|
||||
order-by="Name"
|
||||
reverse-order="false"
|
||||
loading="ctrl.state.dataLoading"
|
||||
refresh-callback="ctrl.getApplication"
|
||||
on-refresh="(ctrl.getApplication)"
|
||||
></kubernetes-application-placements-datatable>
|
||||
</uib-tab>
|
||||
|
||||
@@ -60,16 +56,7 @@
|
||||
{{ ctrl.state.eventWarningCount }} warning(s)
|
||||
</div>
|
||||
</uib-tab-heading>
|
||||
<kubernetes-events-datatable
|
||||
title-text="Events"
|
||||
title-icon="history"
|
||||
dataset="ctrl.events"
|
||||
table-key="kubernetes.application.events"
|
||||
order-by="Date"
|
||||
reverse-order="true"
|
||||
loading="ctrl.state.eventsLoading"
|
||||
refresh-callback="ctrl.getEvents"
|
||||
></kubernetes-events-datatable>
|
||||
<application-events-datatable />
|
||||
</uib-tab>
|
||||
|
||||
<uib-tab index="3" ng-if="ctrl.application.Yaml" select="ctrl.showEditor()" classes="btn-sm">
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
import _ from 'lodash-es';
|
||||
|
||||
angular.module('portainer.docker').controller('KubernetesApplicationPlacementsDatatableController', function ($scope, $controller, DatatableService, Authentication) {
|
||||
angular.extend(this, $controller('GenericDatatableController', { $scope: $scope }));
|
||||
this.state = Object.assign(this.state, {
|
||||
expandedItems: [],
|
||||
expandAll: false,
|
||||
});
|
||||
|
||||
this.expandItem = function (item, expanded) {
|
||||
if (!this.itemCanExpand(item)) {
|
||||
return;
|
||||
}
|
||||
|
||||
item.Expanded = expanded;
|
||||
if (!expanded) {
|
||||
item.Highlighted = false;
|
||||
}
|
||||
};
|
||||
|
||||
this.itemCanExpand = function (item) {
|
||||
return !item.AcceptsApplication;
|
||||
};
|
||||
|
||||
this.hasExpandableItems = function () {
|
||||
return _.filter(this.state.filteredDataSet, (item) => this.itemCanExpand(item)).length;
|
||||
};
|
||||
|
||||
this.expandAll = function () {
|
||||
this.state.expandAll = !this.state.expandAll;
|
||||
_.forEach(this.state.filteredDataSet, (item) => {
|
||||
if (this.itemCanExpand(item)) {
|
||||
this.expandItem(item, this.state.expandAll);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
this.$onInit = function () {
|
||||
this.isAdmin = Authentication.isAdmin();
|
||||
this.setDefaults();
|
||||
this.prepareTableFromDataset();
|
||||
|
||||
this.state.orderBy = this.orderBy;
|
||||
var storedOrder = DatatableService.getDataTableOrder(this.tableKey);
|
||||
if (storedOrder !== null) {
|
||||
this.state.reverseOrder = storedOrder.reverse;
|
||||
this.state.orderBy = storedOrder.orderBy;
|
||||
}
|
||||
|
||||
var textFilter = DatatableService.getDataTableTextFilters(this.tableKey);
|
||||
if (textFilter !== null) {
|
||||
this.state.textFilter = textFilter;
|
||||
this.onTextFilterChange();
|
||||
}
|
||||
|
||||
var storedFilters = DatatableService.getDataTableFilters(this.tableKey);
|
||||
if (storedFilters !== null) {
|
||||
this.filters = storedFilters;
|
||||
}
|
||||
if (this.filters && this.filters.state) {
|
||||
this.filters.state.open = false;
|
||||
}
|
||||
|
||||
var storedSettings = DatatableService.getDataTableSettings(this.tableKey);
|
||||
if (storedSettings !== null) {
|
||||
this.settings = storedSettings;
|
||||
this.settings.open = false;
|
||||
}
|
||||
this.onSettingsRepeaterChange();
|
||||
};
|
||||
});
|
||||
@@ -1,15 +0,0 @@
|
||||
angular.module('portainer.kubernetes').component('kubernetesApplicationPlacementsDatatable', {
|
||||
templateUrl: './template.html',
|
||||
controller: 'KubernetesApplicationPlacementsDatatableController',
|
||||
bindings: {
|
||||
titleText: '@',
|
||||
titleIcon: '@',
|
||||
dataset: '<',
|
||||
tableKey: '@',
|
||||
orderBy: '@',
|
||||
reverseOrder: '<',
|
||||
refreshCallback: '<',
|
||||
loading: '<',
|
||||
removeAction: '<',
|
||||
},
|
||||
});
|
||||
@@ -1,191 +0,0 @@
|
||||
<div class="datatable">
|
||||
<div class="toolBar">
|
||||
<div class="toolBarTitle flex">
|
||||
<div class="widget-icon space-right">
|
||||
<pr-icon icon="$ctrl.titleIcon"></pr-icon>
|
||||
</div>
|
||||
<span class="vertical-center">
|
||||
{{ $ctrl.titleText }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="searchBar">
|
||||
<pr-icon icon="'search'" class="vertical-center"></pr-icon>
|
||||
<input
|
||||
type="text"
|
||||
class="searchInput ml-1"
|
||||
ng-model="$ctrl.state.textFilter"
|
||||
ng-change="$ctrl.onTextFilterChange()"
|
||||
placeholder="Search for a node..."
|
||||
auto-focus
|
||||
ng-model-options="{ debounce: 300 }"
|
||||
/>
|
||||
</div>
|
||||
<div class="settings">
|
||||
<span class="setting" ng-class="{ 'setting-active': $ctrl.settings.open }" uib-dropdown dropdown-append-to-body auto-close="disabled" is-open="$ctrl.settings.open">
|
||||
<span uib-dropdown-toggle><pr-icon icon="'more-vertical'"></pr-icon></span>
|
||||
<div class="dropdown-menu dropdown-menu-right" uib-dropdown-menu>
|
||||
<div class="tableMenu">
|
||||
<div class="menuHeader"> Table settings </div>
|
||||
<div class="menuContent">
|
||||
<div>
|
||||
<div class="md-checkbox">
|
||||
<input id="setting_auto_refresh" type="checkbox" ng-model="$ctrl.settings.repeater.autoRefresh" ng-change="$ctrl.onSettingsRepeaterChange()" />
|
||||
<label for="setting_auto_refresh">Auto refresh</label>
|
||||
</div>
|
||||
<div ng-if="$ctrl.settings.repeater.autoRefresh">
|
||||
<label for="settings_refresh_rate"> Refresh rate </label>
|
||||
<select id="settings_refresh_rate" ng-model="$ctrl.settings.repeater.refreshRate" ng-change="$ctrl.onSettingsRepeaterChange()" class="small-select">
|
||||
<option value="10">10s</option>
|
||||
<option value="30">30s</option>
|
||||
<option value="60">1min</option>
|
||||
<option value="120">2min</option>
|
||||
<option value="300">5min</option>
|
||||
</select>
|
||||
<span>
|
||||
<pr-icon id="refreshRateChange" style="display: none" icon="'check'" mode="'success'"></pr-icon>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<a type="button" class="btn btn-default btn-sm" ng-click="$ctrl.settings.open = false;">Close</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive border-none">
|
||||
<table class="table-hover nowrap-cells table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 2%">
|
||||
<a ng-click="$ctrl.expandAll()" ng-if="$ctrl.hasExpandableItems()">
|
||||
<pr-icon icon="'chevron-down'" ng-if="$ctrl.state.expandAll"></pr-icon>
|
||||
<pr-icon icon="'chevron-up'" ng-if="!$ctrl.state.expandAll"></pr-icon>
|
||||
</a>
|
||||
</th>
|
||||
<th style="width: 98%">
|
||||
<table-column-header
|
||||
col-title="'Node'"
|
||||
can-sort="true"
|
||||
is-sorted="$ctrl.state.orderBy === 'Node'"
|
||||
is-sorted-desc="$ctrl.state.orderBy === 'Node' && $ctrl.state.reverseOrder"
|
||||
ng-click="$ctrl.changeOrderBy('Node')"
|
||||
></table-column-header>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
dir-paginate-start="item in ($ctrl.state.filteredDataSet = ($ctrl.dataset | filter:$ctrl.state.textFilter | filter: $ctrl.isDisplayed | orderBy:$ctrl.state.orderBy:$ctrl.state.reverseOrder | itemsPerPage: $ctrl.state.paginatedItemLimit: $ctrl.tableKey))"
|
||||
ng-class="{ active: item.Checked, 'datatable-highlighted': item.Highlighted }"
|
||||
ng-click="$ctrl.expandItem(item, !item.Expanded)"
|
||||
pagination-id="$ctrl.tableKey"
|
||||
>
|
||||
<td>
|
||||
<a ng-if="$ctrl.itemCanExpand(item)">
|
||||
<pr-icon icon="'chevron-down'" class="mr-1" ng-if="item.Expanded"></pr-icon>
|
||||
<pr-icon icon="'chevron-up'" class="mr-1" ng-if="!item.Expanded"></pr-icon>
|
||||
</a>
|
||||
<pr-icon icon="'check'" ng-if="item.AcceptsApplication" mode="'success'"></pr-icon>
|
||||
<pr-icon icon="'x'" ng-if="!item.AcceptsApplication" mode="'error'"></pr-icon>
|
||||
</td>
|
||||
<td>
|
||||
{{ item.Name }}
|
||||
</td>
|
||||
</tr>
|
||||
<!-- ADMIN + UNMET TAINTS -->
|
||||
<tr
|
||||
ng-if="$ctrl.isAdmin"
|
||||
ng-show="item.Expanded"
|
||||
ng-repeat="taint in item.UnmetTaints"
|
||||
ng-class="{ 'datatable-highlighted': item.Highlighted, 'datatable-unhighlighted': !item.Highlighted }"
|
||||
>
|
||||
<td colspan="2">
|
||||
This application is missing a toleration for the taint <code>{{ taint.Key }}{{ taint.Value ? '=' + taint.Value : '' }}:{{ taint.Effect }}</code>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- !ADMIN + UNMET TAINTS -->
|
||||
<!-- USER + UNMET TAINTS -->
|
||||
<tr
|
||||
ng-if="!$ctrl.isAdmin && item.UnmetTaints.length"
|
||||
ng-show="item.Expanded"
|
||||
ng-class="{ 'datatable-highlighted': item.Highlighted, 'datatable-unhighlighted': !item.Highlighted }"
|
||||
>
|
||||
<td colspan="2"> Placement constraint not respected for that node. </td>
|
||||
</tr>
|
||||
<!-- ! USER + UNMET TAINTS -->
|
||||
<!-- ADMIN + UNMET NODE SELECTOR LABELS -->
|
||||
<tr
|
||||
ng-if="$ctrl.isAdmin"
|
||||
ng-show="item.Expanded"
|
||||
ng-repeat="label in item.UnmatchedNodeSelectorLabels"
|
||||
ng-class="{ 'datatable-highlighted': item.Highlighted, 'datatable-unhighlighted': !item.Highlighted }"
|
||||
>
|
||||
<td colspan="2">
|
||||
This application can only be scheduled on a node where the label <code>{{ label.key }}</code> is set to <code>{{ label.value }}</code>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- ! ADMIN + UNMET NODE SELECTOR LABELS -->
|
||||
<!-- USER + UNMET NODE SELECTOR LABELS || UNMET NODE AFFINITIES -->
|
||||
<tr
|
||||
ng-if="!$ctrl.isAdmin && (item.UnmatchedNodeSelectorLabels.length || item.UnmatchedNodeAffinities.length)"
|
||||
ng-show="item.Expanded"
|
||||
ng-class="{ 'datatable-highlighted': item.Highlighted, 'datatable-unhighlighted': !item.Highlighted }"
|
||||
>
|
||||
<td colspan="2"> Placement label not respected for that node. </td>
|
||||
</tr>
|
||||
<!-- ! USER + UNMET NODE SELECTOR LABELS || UNMET NODE AFFINITIES -->
|
||||
<!-- ADMIN + UNMET NODE AFFINITIES -->
|
||||
<tr
|
||||
ng-if="$ctrl.isAdmin"
|
||||
ng-show="item.Expanded && item.UnmatchedNodeAffinities.length"
|
||||
ng-class="{ 'datatable-highlighted': item.Highlighted, 'datatable-unhighlighted': !item.Highlighted }"
|
||||
>
|
||||
<td colspan="2"> This application can only be scheduled on nodes respecting one of the following labels combination: </td>
|
||||
</tr>
|
||||
<tr
|
||||
dir-paginate-end
|
||||
ng-if="$ctrl.isAdmin"
|
||||
ng-show="item.Expanded"
|
||||
ng-repeat="aff in item.UnmatchedNodeAffinities"
|
||||
ng-class="{ 'datatable-highlighted': item.Highlighted, 'datatable-unhighlighted': !item.Highlighted }"
|
||||
>
|
||||
<td></td>
|
||||
<td>
|
||||
<code ng-repeat-start="term in aff track by $index">
|
||||
{{ term.key }} {{ term.operator }} {{ term.values | kubernetesApplicationConstraintNodeAffinityValue : term.operator }}
|
||||
</code>
|
||||
<span ng-repeat-end>{{ $last ? '' : ' + ' }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- ! ADMIN + UNMET NODE AFFINITIES -->
|
||||
<tr ng-if="$ctrl.loading">
|
||||
<td colspan="2" class="text-muted text-center">Loading...</td>
|
||||
</tr>
|
||||
<tr ng-if="!$ctrl.loading && (!$ctrl.dataset || $ctrl.state.filteredDataSet.length === 0)">
|
||||
<td colspan="2" class="text-muted text-center">No node available.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="footer" ng-if="$ctrl.dataset">
|
||||
<div class="infoBar" ng-if="$ctrl.state.selectedItemCount !== 0"> {{ $ctrl.state.selectedItemCount }} item(s) selected </div>
|
||||
<div class="paginationControls">
|
||||
<form class="form-inline">
|
||||
<span class="limitSelector">
|
||||
<span style="margin-right: 5px"> Items per page </span>
|
||||
<select class="form-control" ng-model="$ctrl.state.paginatedItemLimit" ng-change="$ctrl.changePaginationLimit()" data-cy="component-paginationSelect">
|
||||
<option value="0">All</option>
|
||||
<option value="10">10</option>
|
||||
<option value="25">25</option>
|
||||
<option value="50">50</option>
|
||||
<option value="100">100</option>
|
||||
</select>
|
||||
</span>
|
||||
<dir-pagination-controls max-size="5" pagination-id="$ctrl.tableKey"></dir-pagination-controls>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import clsx from 'clsx';
|
||||
import { PropsWithChildren } from 'react';
|
||||
|
||||
type BadgeType = 'success' | 'danger' | 'warn' | 'info';
|
||||
export type BadgeType = 'success' | 'danger' | 'warn' | 'info';
|
||||
|
||||
export interface Props {
|
||||
type?: BadgeType;
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export { Badge } from './Badge';
|
||||
export type { BadgeType } from './Badge';
|
||||
|
||||
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',
|
||||
|
||||
@@ -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,
|
||||
@@ -53,6 +54,7 @@ export interface Props<
|
||||
description?: ReactNode;
|
||||
pageCount?: number;
|
||||
highlightedItemId?: string;
|
||||
page?: number;
|
||||
onPageChange?(page: number): void;
|
||||
|
||||
settingsManager: GlobalTableState<BasicTableSettings>;
|
||||
@@ -81,6 +83,7 @@ export function Datatable<
|
||||
totalCount = dataset.length,
|
||||
description,
|
||||
pageCount,
|
||||
page,
|
||||
onPageChange = () => null,
|
||||
settingsManager: settings,
|
||||
renderRow = defaultRenderRow,
|
||||
@@ -107,6 +110,7 @@ export function Datatable<
|
||||
initialState: {
|
||||
pagination: {
|
||||
pageSize: settings.pageSize,
|
||||
pageIndex: page || 0,
|
||||
},
|
||||
sorting: settings.sortBy ? [settings.sortBy] : [],
|
||||
globalFilter: settings.search,
|
||||
@@ -130,6 +134,7 @@ export function Datatable<
|
||||
getFacetedMinMaxValues: getFacetedMinMaxValues(),
|
||||
getExpandedRowModel: getExpandedRowModel(),
|
||||
getRowCanExpand,
|
||||
getColumnCanGlobalFilter,
|
||||
...(isServerSidePagination ? { manualPagination: true, pageCount } : {}),
|
||||
meta,
|
||||
});
|
||||
@@ -158,6 +163,7 @@ export function Datatable<
|
||||
renderTableActions={() => renderTableActions(selectedItems)}
|
||||
renderTableSettings={() => renderTableSettings(tableInstance)}
|
||||
/>
|
||||
|
||||
<DatatableContent<D>
|
||||
tableInstance={tableInstance}
|
||||
renderRow={(row) => renderRow(row, highlightedItemId)}
|
||||
@@ -170,7 +176,7 @@ 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}
|
||||
totalSelected={selectedItems.length}
|
||||
@@ -258,3 +264,10 @@ function globalFilterFn<D>(
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function getColumnCanGlobalFilter<D>(column: Column<D, unknown>): boolean {
|
||||
if (column.id === 'select') {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ export function ExpandableDatatableTableRow<D extends Record<string, unknown>>({
|
||||
cells={cells}
|
||||
onClick={expandOnClick ? () => row.toggleExpanded() : undefined}
|
||||
/>
|
||||
{row.getIsExpanded() && (
|
||||
{row.getIsExpanded() && row.getCanExpand() && (
|
||||
<tr>
|
||||
{!disableSelect && <td />}
|
||||
<td colSpan={disableSelect ? cells.length : cells.length - 1}>
|
||||
|
||||
@@ -9,7 +9,7 @@ export function buildExpandColumn<
|
||||
return {
|
||||
id: 'expand',
|
||||
header: ({ table }) => {
|
||||
const hasExpandableItems = table.getExpandedRowModel().rows.length > 0;
|
||||
const hasExpandableItems = table.getCanSomeRowsExpand();
|
||||
|
||||
return (
|
||||
hasExpandableItems && (
|
||||
|
||||
@@ -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,13 +44,11 @@ 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);
|
||||
@@ -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>
|
||||
|
||||
@@ -9,11 +9,22 @@ 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,
|
||||
});
|
||||
|
||||
const pageCount = Math.ceil(totalCount / tableState.pageSize);
|
||||
|
||||
return (
|
||||
<GenericDatatable
|
||||
@@ -27,6 +38,9 @@ export function Datatable() {
|
||||
)}
|
||||
isLoading={isLoading}
|
||||
totalCount={totalCount}
|
||||
pageCount={pageCount}
|
||||
page={page}
|
||||
onPageChange={setPage}
|
||||
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';
|
||||
|
||||
@@ -88,26 +87,25 @@ export function EnvironmentsDatatable() {
|
||||
title="Environments Status"
|
||||
titleIcon={HardDrive}
|
||||
onPageChange={setPage}
|
||||
totalCount={endpointsQuery.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: {
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useCurrentStateAndParams } from '@uirouter/react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { createStore } from '@/react/kubernetes/datatables/default-kube-datatable-store';
|
||||
|
||||
import { useTableState } from '@@/datatables/useTableState';
|
||||
|
||||
import {
|
||||
useApplication,
|
||||
useApplicationPods,
|
||||
useApplicationServices,
|
||||
} from '../application.queries';
|
||||
import { EventsDatatable } from '../../components/KubernetesEventsDatatable';
|
||||
|
||||
import { useNamespaceEventsQuery } from './useNamespaceEventsQuery';
|
||||
|
||||
const storageKey = 'k8sAppEventsDatatable';
|
||||
const settingsStore = createStore(storageKey, { id: 'Date', desc: true });
|
||||
|
||||
export function ApplicationEventsDatatable() {
|
||||
const tableState = useTableState(settingsStore, storageKey);
|
||||
const {
|
||||
params: {
|
||||
namespace,
|
||||
name,
|
||||
'resource-type': resourceType,
|
||||
endpointId: environmentId,
|
||||
},
|
||||
} = useCurrentStateAndParams();
|
||||
|
||||
const { data: application, ...applicationQuery } = useApplication(
|
||||
environmentId,
|
||||
namespace,
|
||||
name,
|
||||
resourceType
|
||||
);
|
||||
const { data: services, ...servicesQuery } = useApplicationServices(
|
||||
environmentId,
|
||||
namespace,
|
||||
name,
|
||||
application
|
||||
);
|
||||
const { data: pods, ...podsQuery } = useApplicationPods(
|
||||
environmentId,
|
||||
namespace,
|
||||
name,
|
||||
application
|
||||
);
|
||||
const { data: events, ...eventsQuery } = useNamespaceEventsQuery(
|
||||
environmentId,
|
||||
namespace,
|
||||
{
|
||||
autoRefreshRate: tableState.autoRefreshRate * 1000,
|
||||
}
|
||||
);
|
||||
|
||||
// related events are events that have the application id, or the id of a service or pod from the application
|
||||
const relatedEvents = useMemo(() => {
|
||||
const serviceIds = services?.map((service) => service?.metadata?.uid);
|
||||
const podIds = pods?.map((pod) => pod?.metadata?.uid);
|
||||
return (
|
||||
events?.filter(
|
||||
(event) =>
|
||||
event.involvedObject.uid === application?.metadata?.uid ||
|
||||
serviceIds?.includes(event.involvedObject.uid) ||
|
||||
podIds?.includes(event.involvedObject.uid)
|
||||
) || []
|
||||
);
|
||||
}, [application?.metadata?.uid, events, pods, services]);
|
||||
|
||||
return (
|
||||
<EventsDatatable
|
||||
dataset={relatedEvents}
|
||||
tableState={tableState}
|
||||
isLoading={
|
||||
applicationQuery.isLoading ||
|
||||
eventsQuery.isLoading ||
|
||||
servicesQuery.isLoading ||
|
||||
podsQuery.isLoading
|
||||
}
|
||||
data-cy="k8sAppDetail-eventsTable"
|
||||
noWidget
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
export { ApplicationSummaryWidget } from './ApplicationSummaryWidget';
|
||||
export { ApplicationDetailsWidget } from './ApplicationDetailsWidget/ApplicationDetailsWidget';
|
||||
export { ApplicationEventsDatatable } from './ApplicationEventsDatatable';
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { EventList } from 'kubernetes-types/core/v1';
|
||||
import { useQuery } from 'react-query';
|
||||
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
import { withError } from '@/react-tools/react-query';
|
||||
|
||||
async function getNamespaceEvents(
|
||||
environmentId: EnvironmentId,
|
||||
namespace: string,
|
||||
labelSelector?: string
|
||||
) {
|
||||
try {
|
||||
const { data } = await axios.get<EventList>(
|
||||
`/endpoints/${environmentId}/kubernetes/api/v1/namespaces/${namespace}/events`,
|
||||
{
|
||||
params: {
|
||||
labelSelector,
|
||||
},
|
||||
}
|
||||
);
|
||||
return data.items;
|
||||
} catch (e) {
|
||||
throw parseAxiosError(e as Error, 'Unable to retrieve events');
|
||||
}
|
||||
}
|
||||
|
||||
export function useNamespaceEventsQuery(
|
||||
environmentId: EnvironmentId,
|
||||
namespace: string,
|
||||
options?: { autoRefreshRate?: number },
|
||||
labelSelector?: string
|
||||
) {
|
||||
return useQuery(
|
||||
[
|
||||
'environments',
|
||||
environmentId,
|
||||
'kubernetes',
|
||||
'events',
|
||||
namespace,
|
||||
labelSelector,
|
||||
],
|
||||
() => getNamespaceEvents(environmentId, namespace, labelSelector),
|
||||
{
|
||||
...withError('Unable to retrieve events'),
|
||||
refetchInterval() {
|
||||
return options?.autoRefreshRate ?? false;
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Minimize2 } from 'lucide-react';
|
||||
|
||||
import {
|
||||
BasicTableSettings,
|
||||
createPersistedStore,
|
||||
refreshableSettings,
|
||||
RefreshableTableSettings,
|
||||
} from '@@/datatables/types';
|
||||
import { ExpandableDatatable } from '@@/datatables/ExpandableDatatable';
|
||||
import { useRepeater } from '@@/datatables/useRepeater';
|
||||
import { TableSettingsMenu } from '@@/datatables';
|
||||
import { TableSettingsMenuAutoRefresh } from '@@/datatables/TableSettingsMenuAutoRefresh';
|
||||
import { useTableState } from '@@/datatables/useTableState';
|
||||
|
||||
import { Node } from '../types';
|
||||
|
||||
import { SubRow } from './PlacementsDatatableSubRow';
|
||||
import { columns } from './columns';
|
||||
|
||||
interface TableSettings extends BasicTableSettings, RefreshableTableSettings {}
|
||||
|
||||
function createStore(storageKey: string) {
|
||||
return createPersistedStore<TableSettings>(storageKey, 'node', (set) => ({
|
||||
...refreshableSettings(set),
|
||||
}));
|
||||
}
|
||||
|
||||
const storageKey = 'kubernetes.application.placements';
|
||||
const settingsStore = createStore(storageKey);
|
||||
|
||||
export function PlacementsDatatable({
|
||||
dataset,
|
||||
onRefresh,
|
||||
}: {
|
||||
dataset: Node[];
|
||||
onRefresh: () => Promise<void>;
|
||||
}) {
|
||||
const tableState = useTableState(settingsStore, storageKey);
|
||||
|
||||
useRepeater(tableState.autoRefreshRate, onRefresh);
|
||||
|
||||
return (
|
||||
<ExpandableDatatable
|
||||
getRowCanExpand={(row) => !row.original.AcceptsApplication}
|
||||
title="Placement constraints/preferences"
|
||||
titleIcon={Minimize2}
|
||||
dataset={dataset}
|
||||
settingsManager={tableState}
|
||||
columns={columns}
|
||||
disableSelect
|
||||
noWidget
|
||||
renderTableSettings={() => (
|
||||
<TableSettingsMenu>
|
||||
<TableSettingsMenuAutoRefresh
|
||||
value={tableState.autoRefreshRate}
|
||||
onChange={tableState.setAutoRefreshRate}
|
||||
/>
|
||||
</TableSettingsMenu>
|
||||
)}
|
||||
emptyContentLabel="No node available."
|
||||
renderSubRow={(row) => (
|
||||
<SubRow node={row.original} cellCount={row.getVisibleCells().length} />
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import clsx from 'clsx';
|
||||
import { Fragment } from 'react';
|
||||
|
||||
import { nodeAffinityValues } from '@/kubernetes/filters/application';
|
||||
import { useAuthorizations } from '@/react/hooks/useUser';
|
||||
|
||||
import { Affinity, Label, Node, Taint } from '../types';
|
||||
|
||||
interface SubRowProps {
|
||||
node: Node;
|
||||
cellCount: number;
|
||||
}
|
||||
|
||||
export function SubRow({ node, cellCount }: SubRowProps) {
|
||||
const authorized = useAuthorizations(
|
||||
'K8sApplicationErrorDetailsR',
|
||||
undefined,
|
||||
true
|
||||
);
|
||||
|
||||
if (!authorized) {
|
||||
<>
|
||||
{isDefined(node.UnmetTaints) && (
|
||||
<tr
|
||||
className={clsx({
|
||||
'datatable-highlighted': node.Highlighted,
|
||||
'datatable-unhighlighted': !node.Highlighted,
|
||||
})}
|
||||
>
|
||||
<td colSpan={cellCount}>
|
||||
Placement constraint not respected for that node.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
|
||||
{(isDefined(node.UnmatchedNodeSelectorLabels) ||
|
||||
isDefined(node.UnmatchedNodeAffinities)) && (
|
||||
<tr
|
||||
className={clsx({
|
||||
'datatable-highlighted': node.Highlighted,
|
||||
'datatable-unhighlighted': !node.Highlighted,
|
||||
})}
|
||||
>
|
||||
<td colSpan={cellCount}>
|
||||
Placement label not respected for that node.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{isDefined(node.UnmetTaints) && (
|
||||
<UnmetTaintsInfo
|
||||
taints={node.UnmetTaints}
|
||||
cellCount={cellCount}
|
||||
isHighlighted={node.Highlighted}
|
||||
/>
|
||||
)}
|
||||
{isDefined(node.UnmatchedNodeSelectorLabels) && (
|
||||
<UnmatchedLabelsInfo
|
||||
labels={node.UnmatchedNodeSelectorLabels}
|
||||
cellCount={cellCount}
|
||||
isHighlighted={node.Highlighted}
|
||||
/>
|
||||
)}
|
||||
{isDefined(node.UnmatchedNodeAffinities) && (
|
||||
<UnmatchedAffinitiesInfo
|
||||
affinities={node.UnmatchedNodeAffinities}
|
||||
cellCount={cellCount}
|
||||
isHighlighted={node.Highlighted}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function isDefined<T>(arr?: Array<T>): arr is Array<T> {
|
||||
return !!arr && arr.length > 0;
|
||||
}
|
||||
|
||||
function UnmetTaintsInfo({
|
||||
taints,
|
||||
isHighlighted,
|
||||
cellCount,
|
||||
}: {
|
||||
taints: Array<Taint>;
|
||||
isHighlighted: boolean;
|
||||
cellCount: number;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{taints.map((taint) => (
|
||||
<tr
|
||||
className={clsx({
|
||||
'datatable-highlighted': isHighlighted,
|
||||
'datatable-unhighlighted': !isHighlighted,
|
||||
})}
|
||||
key={taint.Key}
|
||||
>
|
||||
<td colSpan={cellCount}>
|
||||
This application is missing a toleration for the taint
|
||||
<code className="space-left">
|
||||
{taint.Key}
|
||||
{taint.Value ? `=${taint.Value}` : ''}:{taint.Effect}
|
||||
</code>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function UnmatchedLabelsInfo({
|
||||
labels,
|
||||
isHighlighted,
|
||||
cellCount,
|
||||
}: {
|
||||
labels: Array<Label>;
|
||||
isHighlighted: boolean;
|
||||
cellCount: number;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{labels.map((label) => (
|
||||
<tr
|
||||
className={clsx({
|
||||
'datatable-highlighted': isHighlighted,
|
||||
'datatable-unhighlighted': !isHighlighted,
|
||||
})}
|
||||
key={label.key}
|
||||
>
|
||||
<td colSpan={cellCount}>
|
||||
This application can only be scheduled on a node where the label{' '}
|
||||
<code>{label.key}</code> is set to <code>{label.value}</code>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function UnmatchedAffinitiesInfo({
|
||||
affinities,
|
||||
isHighlighted,
|
||||
cellCount,
|
||||
}: {
|
||||
affinities: Array<Affinity>;
|
||||
isHighlighted: boolean;
|
||||
cellCount: number;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<tr
|
||||
className={clsx({
|
||||
'datatable-highlighted': isHighlighted,
|
||||
'datatable-unhighlighted': !isHighlighted,
|
||||
})}
|
||||
>
|
||||
<td colSpan={cellCount}>
|
||||
This application can only be scheduled on nodes respecting one of the
|
||||
following labels combination:
|
||||
</td>
|
||||
</tr>
|
||||
{affinities.map((aff) => (
|
||||
<tr
|
||||
className={clsx({
|
||||
'datatable-highlighted': isHighlighted,
|
||||
'datatable-unhighlighted': !isHighlighted,
|
||||
})}
|
||||
>
|
||||
<td />
|
||||
<td colSpan={cellCount - 1}>
|
||||
{aff.map((term, index) => (
|
||||
<Fragment key={index}>
|
||||
<code>
|
||||
{term.key} {term.operator}{' '}
|
||||
{nodeAffinityValues(term.values, term.operator)}
|
||||
</code>
|
||||
<span>{index === aff.length - 1 ? '' : ' + '}</span>
|
||||
</Fragment>
|
||||
))}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createColumnHelper } from '@tanstack/react-table';
|
||||
|
||||
import { Node } from '../../types';
|
||||
|
||||
export const columnHelper = createColumnHelper<Node>();
|
||||
@@ -0,0 +1,15 @@
|
||||
import { buildExpandColumn } from '@@/datatables/expand-column';
|
||||
|
||||
import { Node } from '../../types';
|
||||
|
||||
import { columnHelper } from './helper';
|
||||
import { status } from './status';
|
||||
|
||||
export const columns = [
|
||||
buildExpandColumn<Node>(),
|
||||
status,
|
||||
columnHelper.accessor('Name', {
|
||||
header: 'Node',
|
||||
id: 'node',
|
||||
}),
|
||||
];
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Check, X } from 'lucide-react';
|
||||
|
||||
import { Icon } from '@@/Icon';
|
||||
|
||||
import { columnHelper } from './helper';
|
||||
|
||||
export const status = columnHelper.accessor('AcceptsApplication', {
|
||||
header: '',
|
||||
id: 'status',
|
||||
enableSorting: false,
|
||||
cell: ({ getValue }) => {
|
||||
const acceptsApplication = getValue();
|
||||
return (
|
||||
<Icon
|
||||
icon={acceptsApplication ? Check : X}
|
||||
mode={acceptsApplication ? 'success' : 'danger'}
|
||||
size="sm"
|
||||
/>
|
||||
);
|
||||
},
|
||||
meta: {
|
||||
width: 30,
|
||||
},
|
||||
enableResizing: false,
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user