Compare commits

...

55 Commits

Author SHA1 Message Date
oscarzhou
2bfb4c0e25 fix(stack/git): clone to non-version project path during auto update
Some checks failed
Test / test-client (push) Has been cancelled
Test / test-server (push) Has been cancelled
2023-08-17 13:12:42 +12:00
oscarzhou
748c6fa22a fix(filesystem): keep original file copy with SafeCopyDirectory 2023-08-17 11:40:41 +12:00
cmeng
95424c322d fix(datatable): image page not loading image list EE-5978 (#10071) 2023-08-17 09:53:28 +12:00
Chaim Lev-Ari
a1e610a39a fix(edge/groups): filter selected environments [EE-5891] (#10050) 2023-08-16 12:24:37 +03:00
Chaim Lev-Ari
a27cc6c0e5 fix(edge/stacks): show pending envs [EE-5913] (#10052) 2023-08-16 10:22:41 +03:00
Ali
2b4cb1b7b4 fix(ingress): empty initial selection + fixes [EE-5852] (#10066)
Co-authored-by: testa113 <testa113>
2023-08-16 18:07:46 +12:00
Ali
26074437ca fix(environments): fix env table [EE-5971] (#10059)
Co-authored-by: testa113 <testa113>
2023-08-16 13:21:23 +12:00
Prabhat Khera
665a25e448 fix edit namespace resource quota issue (#10064) 2023-08-16 10:25:01 +12:00
Chaim Lev-Ari
4a91e947ed feat(edge/configs): add context help [EE-5963] (#10055) 2023-08-15 18:46:58 +03:00
Chaim Lev-Ari
d514eeec86 fix(edge/devices): search waiting room devices [EE-5895] (#10014) 2023-08-15 06:05:10 +03:00
matias-portainer
0ef4aad79a fix(authentication): allow whitespaces when loading AD OU name EE-5206 (#9977) 2023-08-14 12:18:07 -03:00
matias-portainer
8355d449c5 fix(edge/stacks): add pagination to environments list EE-5908 (#10042) 2023-08-14 12:17:00 -03:00
Chaim Lev-Ari
fd7e8a629e feat(edge/stacks): info for old agent status [EE-5792] (#10013) 2023-08-14 16:04:24 +03:00
Ali
7757bf7a84 fix(r2a): remove withUserProvider [EE-5355] (#10048)
Co-authored-by: testa113 <testa113>
2023-08-14 19:01:31 +12:00
Ali
5862aa5dd8 fix(app): use correct withCurrentUser wrapper [EE-5928] (#10040)
Co-authored-by: testa113 <testa113>
2023-08-14 16:53:28 +12:00
cmeng
925a0d0a9a fix(stack): fail to start swarm stack with private image EE-4797 (#10047) 2023-08-14 16:13:12 +12:00
Ali
2a7a96f498 fix(microk8s): PO ui fixes [EE-5900] (#10031)
Co-authored-by: testa113 <testa113>
2023-08-14 12:34:58 +12:00
Ali
c472fe9c18 refactor(app): app events datatable [EE-5355] (#10024) 2023-08-14 05:09:40 +12:00
andres-portainer
0eaf296e1b fix(unpacker): implement unpacker error parsing EE-5779 (#10005) 2023-08-10 10:25:59 -03:00
Oscar Zhou
598b8d0f28 fix(stagger): introduce stack version into DeploymentInfo struct (#10011) 2023-08-10 11:58:40 +12:00
matias-portainer
e1a3010bc7 fix(edge/stacks): fix UI issues EE-5844 (#10021) 2023-08-09 10:09:08 -03:00
cmeng
2de4863532 fix(edge-stack): detaching swarm stack from git repository EE-5812 (#9998) 2023-08-07 10:33:04 +12:00
Oscar Zhou
8cf54cd0df fix(react/datatable): override getColumnCanGlobalFilter method (#9990) 2023-08-07 10:30:38 +12:00
cmeng
1ef1953d7d fix(edge-stack): detaching from git repository EE-5812 (#9989) 2023-08-04 15:17:46 +12:00
cmeng
5b033abaa4 fix(registry): registry login failure for regular stack EE-5832 (#9986) 2023-08-04 15:16:55 +12:00
Ali
5865f1ca77 fix(app): update summary with ingresses [EE-5847] (#9973)
Co-authored-by: testa113 <testa113>
2023-08-04 13:48:21 +12:00
Chaim Lev-Ari
f59573f306 fix(home): empty default sort [EE-5822] (#9951) 2023-08-03 16:21:09 -03:00
Chaim Lev-Ari
1cecbd7177 fix(docker/images): show empty size cell [EE-5823] (#9954) 2023-08-03 16:19:58 -03:00
Ali
acf9203580 fix(ingress): ingress ui feedback [EE-5852] (#9982)
Co-authored-by: testa113 <testa113>
2023-08-03 23:03:09 +12:00
cmeng
9845518aa9 fix(edge-stack): unable to edit edge stack EE-5845 (#9981) 2023-08-03 17:21:01 +12:00
matias-portainer
d7e83aad26 fix(endpoints): fix nil pointer dereference EE-5843 (#9969) 2023-08-02 11:06:34 -03:00
Matt Hook
df47f3d8a8 show kube icon for custom template (#9968) 2023-08-02 09:43:54 +12:00
Ali
d0ecf6c16b fix(ingress): loading and ui fixes [EE-5132] (#9959) 2023-08-01 19:31:35 +12:00
Matt Hook
e400c4dfc6 bump compose to 2.20.2 (#9964) 2023-08-01 12:27:21 +12:00
Matt Hook
721457b71d bump version to 2.20 (#9963) 2023-08-01 09:20:51 +12:00
Ali
b19800681f fix(app): improve perceived ingress load time [EE-5805] (#9946)
Co-authored-by: testa113 <testa113>
2023-07-31 20:18:45 +12:00
cmeng
6a4e44ee0a fix(stack): update gitops updates tooltip EE-5827 (#9962) 2023-07-31 18:46:00 +12:00
Chaim Lev-Ari
37ece734f0 refactor(kube/apps): convert placement table to react [EE-4662] (#8938) 2023-07-29 17:08:41 +02:00
Prabhat Khera
bf79ef7d89 fix(security): upgrade helm binary to v3.12.2 [EE-5801] (#9263) 2023-07-28 15:08:45 +12:00
James Carppe
883ef2578f fix indentation in bug report template (#9944) 2023-07-28 13:05:43 +12:00
Matt Hook
a585f34106 workding change (#9266) 2023-07-28 07:53:33 +12:00
Ali
b128139b69 fix(UI): PO review tweaks [EE-5776] (#9245)
Co-authored-by: testa113 <testa113>
2023-07-28 07:50:53 +12:00
James Carppe
4c425a7af8 Discussions updates (#9730)
* Update bug template: versions to dropdown, add license types to editions, set render on command used

* Update docs URL in help template
2023-07-27 10:27:32 +05:30
Dakota Walsh
400d95c1a5 fix(metrics): node chart race condition EE-5447 (#9249) 2023-07-27 11:46:38 +12:00
Dakota Walsh
ca617e2ac9 fix(jwt): replace deprecated gorilla/securecookie [EE-5153] (#9247) 2023-07-27 09:34:16 +12:00
samdulam
4a90b8a3f7 Fix links in Discussions and Issues Templates (#9258)
* Fix Links

* Fix links for discussions
2023-07-26 12:34:15 +05:30
samdulam
43ad3face2 Fix Links (#9257) 2023-07-26 12:11:06 +05:30
samdulam
69e61be474 file type changes (#9256) 2023-07-26 12:07:06 +05:30
samdulam
a4ea7a3709 Changes to issues templates now that Discussions are enabled (#9255)
* Discussions Enabled and Templates

* Discussions - Ideas Template
2023-07-26 12:02:38 +05:30
samdulam
c5ecf8a66d Change Issues so we can move to discussions 2023-07-26 12:00:41 +05:30
samdulam
c2c0631495 Add Discussion Templates (#9254)
* Discussions Enabled and Templates

* Discussions - Ideas Template
2023-07-26 11:57:59 +05:30
samdulam
4ff3cee72e Add workflow_dispatch so we can run manually (#9253) 2023-07-26 09:33:54 +05:30
Matt Hook
c4e8251e52 post po review changes (#9244) 2023-07-26 11:36:02 +12:00
andres-portainer
21b00c267d fix(docker): use version negotiation for the Docker client EE-5797 (#9250) 2023-07-25 19:00:21 -03:00
samdulam
86ec058347 Change stabot action version as it stopped working (#9246) 2023-07-25 14:47:07 +05:30
131 changed files with 2182 additions and 1103 deletions

11
.github/DISCUSSION_TEMPLATE/help.yaml vendored Normal file
View 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
View 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

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,6 @@
package apikey
import (
"crypto/rand"
"io"
portainer "github.com/portainer/portainer/api"
)
@@ -18,13 +15,3 @@ type APIKeyService interface {
DeleteAPIKey(apiKeyID portainer.APIKeyID) error
InvalidateUserKeyCache(userId portainer.UserID) bool
}
// generateRandomKey generates a random key of specified length
// source: https://github.com/gorilla/securecookie/blob/master/securecookie.go#L515
func generateRandomKey(length int) []byte {
k := make([]byte, length)
if _, err := io.ReadFull(rand.Reader, k); err != nil {
return nil
}
return k
}

View File

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

View File

@@ -8,6 +8,7 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/internal/securecookie"
"github.com/pkg/errors"
)
@@ -39,7 +40,7 @@ func (a *apiKeyService) HashRaw(rawKey string) []byte {
// GenerateApiKey generates a raw API key for a user (for one-time display).
// The generated API key is stored in the cache and database.
func (a *apiKeyService) GenerateApiKey(user portainer.User, description string) (string, *portainer.APIKey, error) {
randKey := generateRandomKey(32)
randKey := securecookie.GenerateRandomKey(32)
encodedRawAPIKey := base64.StdEncoding.EncodeToString(randKey)
prefixedAPIKey := portainerAPIKeyPrefix + encodedRawAPIKey

View File

@@ -50,7 +50,7 @@ func (store *Store) MigrateData() error {
if err != nil {
err = errors.Wrap(err, "failed to migrate database")
log.Warn().Msg("migration failed, restoring database to previous version")
log.Warn().Err(err).Msg("migration failed, restoring database to previous version")
err = store.restoreWithOptions(&BackupOptions{BackupPath: backupPath})
if err != nil {
return errors.Wrap(err, "failed to restore database")

View File

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

View File

@@ -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\"}"
}
}

View File

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

View File

@@ -15,6 +15,7 @@ import (
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/internal/registryutils"
"github.com/portainer/portainer/api/stacks/stackutils"
"github.com/rs/zerolog/log"
)
// SwarmStackManager represents a service for managing stacks.
@@ -64,16 +65,35 @@ func (manager *SwarmStackManager) Login(registries []portainer.Registry, endpoin
if registry.Authentication {
err = registryutils.EnsureRegTokenValid(manager.dataStore, &registry)
if err != nil {
return err
log.
Warn().
Err(err).
Str("RegistryName", registry.Name).
Msg("Failed to validate registry token. Skip logging with this registry.")
continue
}
username, password, err := registryutils.GetRegEffectiveCredential(&registry)
if err != nil {
return err
log.
Warn().
Err(err).
Str("RegistryName", registry.Name).
Msg("Failed to get effective credential. Skip logging with this registry.")
continue
}
registryArgs := append(args, "login", "--username", username, "--password", password, registry.URL)
runCommandAndCaptureStdErr(command, registryArgs, nil, "")
err = runCommandAndCaptureStdErr(command, registryArgs, nil, "")
if err != nil {
log.
Warn().
Err(err).
Str("RegistryName", registry.Name).
Msg("Failed to login.")
}
}
}

View File

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

View File

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

View File

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

View File

@@ -203,8 +203,6 @@ github.com/gorilla/handlers v1.5.1 h1:9lRY6j8DEeeBT10CvO9hGW0gmky0BprnvDI5vfhUHH
github.com/gorilla/handlers v1.5.1/go.mod h1:t8XrUpc4KVXb7HGyJ4/cEnwQiaxrX/hz1Zv/4g96P1Q=
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=

View File

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

View File

@@ -294,7 +294,7 @@ func shouldReloadTLSConfiguration(endpoint *portainer.Endpoint, payload *endpoin
// When updating Docker API environment, as long as TLS is true and TLSSkipVerify is false,
// we assume that new TLS files have been uploaded and we need to reload the TLS configuration.
if endpoint.Type != portainer.DockerEnvironment ||
!strings.HasPrefix(*payload.URL, "tcp://") ||
(payload.URL != nil && !strings.HasPrefix(*payload.URL, "tcp://")) ||
payload.TLS == nil || !*payload.TLS {
return false
}

View File

@@ -34,6 +34,7 @@ type EnvironmentsQuery struct {
edgeCheckInPassedSeconds int
edgeStackId portainer.EdgeStackID
edgeStackStatus *portainer.EdgeStackStatusType
excludeIds []portainer.EndpointID
}
func parseQuery(r *http.Request) (EnvironmentsQuery, error) {
@@ -69,6 +70,11 @@ func parseQuery(r *http.Request) (EnvironmentsQuery, error) {
return EnvironmentsQuery{}, err
}
excludeIDs, err := getNumberArrayQueryParameter[portainer.EndpointID](r, "excludeIds")
if err != nil {
return EnvironmentsQuery{}, err
}
agentVersions := getArrayQueryParameter(r, "agentVersions")
name, _ := request.RetrieveQueryParameter(r, "name", true)
@@ -97,6 +103,7 @@ func parseQuery(r *http.Request) (EnvironmentsQuery, error) {
types: endpointTypes,
tagIds: tagIDs,
endpointIds: endpointIDs,
excludeIds: excludeIDs,
tagsPartialMatch: tagsPartialMatch,
groupIds: groupIDs,
status: status,
@@ -118,6 +125,12 @@ func (handler *Handler) filterEndpointsByQuery(filteredEndpoints []portainer.End
filteredEndpoints = filteredEndpointsByIds(filteredEndpoints, query.endpointIds)
}
if len(query.excludeIds) > 0 {
filteredEndpoints = filter(filteredEndpoints, func(endpoint portainer.Endpoint) bool {
return !slices.Contains(query.excludeIds, endpoint.ID)
})
}
if len(query.groupIds) > 0 {
filteredEndpoints = filterEndpointsByGroupIDs(filteredEndpoints, query.groupIds)
}
@@ -208,9 +221,12 @@ func endpointStatusInStackMatchesFilter(edgeStackStatus map[portainer.EndpointID
status, ok := edgeStackStatus[envId]
// consider that if the env has no status in the stack it is in Pending state
// workaround because Stack.Status[EnvId].Details.Pending is never set to True in the codebase
if !ok && statusFilter == portainer.EdgeStackStatusPending {
return true
if statusFilter == portainer.EdgeStackStatusPending {
return !ok || len(status.Status) == 0
}
if !ok {
return false
}
return slices.ContainsFunc(status.Status, func(s portainer.EdgeStackDeploymentStatus) bool {

View File

@@ -5,6 +5,7 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/internal/slices"
"github.com/portainer/portainer/api/internal/testhelpers"
"github.com/stretchr/testify/assert"
)
@@ -124,6 +125,28 @@ func Test_Filter_edgeFilter(t *testing.T) {
runTests(tests, t, handler, endpoints)
}
func Test_Filter_excludeIDs(t *testing.T) {
ids := []portainer.EndpointID{1, 2, 3, 4, 5, 6, 7, 8, 9}
environments := slices.Map(ids, func(id portainer.EndpointID) portainer.Endpoint {
return portainer.Endpoint{ID: id, GroupID: 1, Type: portainer.DockerEnvironment}
})
handler := setupFilterTest(t, environments)
tests := []filterTest{
{
title: "should exclude IDs 2,5,8",
expected: []portainer.EndpointID{1, 3, 4, 6, 7, 9},
query: EnvironmentsQuery{
excludeIds: []portainer.EndpointID{2, 5, 8},
},
},
}
runTests(tests, t, handler, environments)
}
func runTests(tests []filterTest, t *testing.T, handler *Handler, endpoints []portainer.Endpoint) {
for _, test := range tests {
t.Run(test.title, func(t *testing.T) {

View File

@@ -84,7 +84,7 @@ type Handler struct {
}
// @title PortainerCE API
// @version 2.19.0
// @version 2.20.0
// @description.markdown api-description.md
// @termsOfService

View File

@@ -117,7 +117,7 @@ func (handler *Handler) stackStart(w http.ResponseWriter, r *http.Request) *http
stack.AutoUpdate.JobID = jobID
}
err = handler.startStack(stack, endpoint)
err = handler.startStack(stack, endpoint, securityContext)
if err != nil {
return httperror.InternalServerError("Unable to start stack", err)
}
@@ -136,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

View File

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

View File

@@ -0,0 +1,16 @@
package securecookie
import (
"crypto/rand"
"io"
)
// GenerateRandomKey generates a random key of specified length
// source: https://github.com/gorilla/securecookie/blob/master/securecookie.go#L515
func GenerateRandomKey(length int) []byte {
k := make([]byte, length)
if _, err := io.ReadFull(rand.Reader, k); err != nil {
return nil
}
return k
}

View File

@@ -63,3 +63,12 @@ func RemoveIndex[T any](s []T, index int) []T {
s[index] = s[len(s)-1]
return s[:len(s)-1]
}
// Map applies the given function to each element of the slice and returns a new slice with the results
func Map[T, U any](s []T, f func(T) U) []U {
result := make([]U, len(s))
for i, v := range s {
result[i] = f(v)
}
return result
}

View File

@@ -90,7 +90,10 @@ func (service *service) upgradeDocker(licenseKey, version, envType string) error
}
func (service *service) checkImageForDocker(ctx context.Context, image string, skipPullImage bool) error {
cli, err := client.NewClientWithOpts(client.FromEnv)
cli, err := client.NewClientWithOpts(
client.FromEnv,
client.WithAPIVersionNegotiation(),
)
if err != nil {
return errors.Wrap(err, "failed to create docker client")
}

View File

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

View File

@@ -301,6 +301,8 @@ type (
// StackDeploymentInfo records the information of a deployed stack
StackDeploymentInfo struct {
// Version is the version of the stack and also is the deployed version in edge agent
Version int `json:"Version"`
// FileVersion is the version of the stack file, used to detect changes
FileVersion int `json:"FileVersion"`
// ConfigHash is the commit hash of the git repository used for deploying the stack
@@ -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

View File

@@ -1,7 +1,9 @@
package deployments
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"math/rand"
@@ -12,6 +14,7 @@ import (
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/swarm"
"github.com/docker/docker/pkg/stdcopy"
"github.com/pkg/errors"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/filesystem"
@@ -184,16 +187,18 @@ func (d *stackDeployer) remoteStack(stack *portainer.Stack, endpoint *portainer.
case <-statusCh:
}
stdErr := &bytes.Buffer{}
out, err := cli.ContainerLogs(ctx, unpackerContainer.ID, types.ContainerLogsOptions{ShowStdout: true, ShowStderr: true})
if err != nil {
log.Error().Err(err).Msg("unable to get logs from unpacker container")
} else {
outputBytes, err := io.ReadAll(out)
_, err = stdcopy.StdCopy(io.Discard, stdErr, out)
if err != nil {
log.Error().Err(err).Msg("unable to parse logs from unpacker container")
log.Warn().Err(err).Msg("unable to parse logs from unpacker container")
} else {
log.Info().
Str("output", string(outputBytes)).
Str("output", stdErr.String()).
Msg("Stack deployment output")
}
}
@@ -204,6 +209,26 @@ func (d *stackDeployer) remoteStack(stack *portainer.Stack, endpoint *portainer.
}
if status.State.ExitCode != 0 {
dec := json.NewDecoder(stdErr)
for {
errorStruct := struct {
Level string
Error string
}{}
if err := dec.Decode(&errorStruct); errors.Is(err, io.EOF) {
break
} else if err != nil {
log.Warn().Err(err).Msg("unable to parse logs from unpacker container")
continue
}
if errorStruct.Level == "error" {
return fmt.Errorf("an error occurred while running unpacker container with exit code %d: %s", status.State.ExitCode, errorStruct.Error)
}
}
return fmt.Errorf("an error occurred while running unpacker container with exit code %d", status.State.ExitCode)
}

View File

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

View File

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

View File

@@ -14,9 +14,13 @@
<pr-icon icon="'info'" mode="'primary'"></pr-icon>
Switch to advanced mode to copy and paste multiple key/values
</div>
<div class="col-sm-12 small text-muted vertical-center" ng-if="!$ctrl.formValues.IsSimple">
<div class="col-sm-12 small text-muted vertical-center" ng-if="!$ctrl.formValues.IsSimple && $ctrl.type === 'configmap'">
<pr-icon icon="'info'" mode="'primary'"></pr-icon>
Generate a configuration entry per line, use YAML format
Generate a ConfigMap entry per line, use YAML format
</div>
<div class="col-sm-12 small text-muted vertical-center" ng-if="!$ctrl.formValues.IsSimple && $ctrl.type === 'secret'">
<pr-icon icon="'info'" mode="'primary'"></pr-icon>
Generate a Secret entry per line, use YAML format
</div>
</div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -352,7 +352,7 @@
<!-- #region CONFIGMAPS -->
<div class="form-group">
<div class="col-sm-12 vertical-center">
<label class="control-label !pt-0 text-left">ConfigMap</label>
<label class="control-label !pt-0 text-left">ConfigMaps</label>
</div>
<div class="col-sm-12 small text-muted vertical-center" style="margin-top: 15px" ng-if="ctrl.formValues.ConfigMaps.length">
<pr-icon icon="'info'" mode="'primary'"></pr-icon>
@@ -503,7 +503,7 @@
<!-- #region SECRETS -->
<div class="form-group">
<div class="col-sm-12 vertical-center pt-2.5">
<label class="control-label !pt-0 text-left">Secret</label>
<label class="control-label !pt-0 text-left">Secrets</label>
</div>
<div class="col-sm-12 small text-muted vertical-center" style="margin-top: 15px" ng-if="ctrl.formValues.Secrets.length">
<pr-icon icon="'info'" mode="'primary'"></pr-icon>

View File

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

View File

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

View File

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

View File

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

View File

@@ -26,95 +26,93 @@
<kubernetes-view-loading view-ready="ctrl.state.viewReady"></kubernetes-view-loading>
<div ng-if="ctrl.state.viewReady">
<information-panel ng-if="!ctrl.state.getMetrics" title-text="Unable to retrieve container metrics">
<span class="small text-warning vertical-center">
<pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon>
Portainer was unable to retrieve any metrics associated to that container. Please contact your administrator to ensure that the Kubernetes metrics feature is properly
configured.
</span>
</information-panel>
<div class="row" ng-if="ctrl.state.getMetrics">
<div class="col-md-12">
<rd-widget>
<div class="toolBar px-5 pt-5">
<div class="toolBarTitle flex">
<div class="widget-icon space-right">
<pr-icon icon="'info'"></pr-icon>
</div>
<span class="vertical-center"> About statistics </span>
<information-panel ng-if="!ctrl.state.getMetrics" title-text="Unable to retrieve container metrics">
<span class="small text-warning vertical-center">
<pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon>
Portainer was unable to retrieve any metrics associated to that container. Please contact your administrator to ensure that the Kubernetes metrics feature is properly
configured.
</span>
</information-panel>
<div class="row" ng-if="ctrl.state.getMetrics">
<div class="col-md-12">
<rd-widget>
<div class="toolBar px-5 pt-5">
<div class="toolBarTitle flex">
<div class="widget-icon space-right">
<pr-icon icon="'info'"></pr-icon>
</div>
<span class="vertical-center"> About statistics </span>
</div>
<rd-widget-body>
<form class="form-horizontal">
<div class="form-group">
<div class="col-sm-12">
<span class="small text-warning">
This view displays real-time statistics about the container <b>{{ ctrl.state.transition.containerName | trimcontainername }}</b
>.
</span>
</div>
</div>
<div class="form-group">
<label for="refreshRate" class="col-sm-3 col-md-2 col-lg-2 margin-sm-top control-label text-left"> Refresh rate </label>
<div class="col-sm-3 col-md-2">
<select id="refreshRate" ng-model="ctrl.state.refreshRate" ng-change="ctrl.changeUpdateRepeater()" class="form-control">
<option value="30">30s</option>
<option value="60">60s</option>
</select>
</div>
<span>
<pr-icon id="refreshRateChange" icon="'check'" mode="'success'" size="'sm'"></pr-icon>
</div>
<rd-widget-body>
<form class="form-horizontal">
<div class="form-group">
<div class="col-sm-12">
<span class="small text-warning">
This view displays real-time statistics about the container <b>{{ ctrl.state.transition.containerName | trimcontainername }}</b
>.
</span>
</div>
<div class="form-group" ng-if="ctrl.state.networkStatsUnavailable">
<div class="col-sm-12">
<span class="small text-muted">
<pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon>
Network stats are unavailable for this container.
</span>
</div>
</div>
<div class="form-group">
<label for="refreshRate" class="col-sm-3 col-md-2 col-lg-2 margin-sm-top control-label text-left"> Refresh rate </label>
<div class="col-sm-3 col-md-2">
<select id="refreshRate" ng-model="ctrl.state.refreshRate" ng-change="ctrl.changeUpdateRepeater()" class="form-control">
<option value="30">30s</option>
<option value="60">60s</option>
</select>
</div>
</form>
</rd-widget-body>
</rd-widget>
</div>
</div>
<div class="row" ng-if="ctrl.state.getMetrics">
<div class="col-lg-6 col-md-12 col-sm-12">
<rd-widget>
<div class="toolBar px-5 pt-5">
<div class="toolBarTitle flex">
<div class="widget-icon space-right">
<pr-icon icon="'svg-memory'"></pr-icon>
<span>
<pr-icon id="refreshRateChange" icon="'check'" mode="'success'" size="'sm'"></pr-icon>
</span>
</div>
<div class="form-group" ng-if="ctrl.state.networkStatsUnavailable">
<div class="col-sm-12">
<span class="small text-muted">
<pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon>
Network stats are unavailable for this container.
</span>
</div>
<span class="vertical-center"> Memory usage </span>
</div>
</div>
<rd-widget-body>
<div class="chart-container" style="position: relative">
<canvas id="memoryChart" width="770" height="300"></canvas>
</div>
</rd-widget-body>
</rd-widget>
</div>
<div class="col-lg-6 col-md-12 col-sm-12" ng-if="!ctrl.state.networkStatsUnavailable">
<rd-widget>
<div class="toolBar px-5 pt-5">
<div class="toolBarTitle flex">
<div class="widget-icon space-right">
<pr-icon icon="'cpu'"></pr-icon>
</div>
<span class="vertical-center"> CPU usage </span>
</div>
</div>
<rd-widget-body>
<div class="chart-container" style="position: relative">
<canvas id="cpuChart" width="770" height="300"></canvas>
</div>
</rd-widget-body>
</rd-widget>
</div>
</form>
</rd-widget-body>
</rd-widget>
</div>
</div>
<div class="row" ng-if="ctrl.state.getMetrics">
<div class="col-lg-6 col-md-12 col-sm-12">
<rd-widget>
<div class="toolBar px-5 pt-5">
<div class="toolBarTitle flex">
<div class="widget-icon space-right">
<pr-icon icon="'svg-memory'"></pr-icon>
</div>
<span class="vertical-center"> Memory usage </span>
</div>
</div>
<rd-widget-body>
<div class="chart-container" style="position: relative">
<canvas id="memoryChart" width="770" height="300"></canvas>
</div>
</rd-widget-body>
</rd-widget>
</div>
<div class="col-lg-6 col-md-12 col-sm-12" ng-if="!ctrl.state.networkStatsUnavailable">
<rd-widget>
<div class="toolBar px-5 pt-5">
<div class="toolBarTitle flex">
<div class="widget-icon space-right">
<pr-icon icon="'cpu'"></pr-icon>
</div>
<span class="vertical-center"> CPU usage </span>
</div>
</div>
<rd-widget-body>
<div class="chart-container" style="position: relative">
<canvas id="cpuChart" width="770" height="300"></canvas>
</div>
</rd-widget-body>
</rd-widget>
</div>
</div>

View File

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

View File

@@ -15,86 +15,84 @@
<kubernetes-view-loading view-ready="ctrl.state.viewReady"></kubernetes-view-loading>
<div ng-if="ctrl.state.viewReady">
<information-panel ng-if="!ctrl.state.getMetrics" title-text="Unable to retrieve node metrics">
<span class="small text-muted vertical-center">
<pr-icon icon="'alert-triangle'" mode="'primary'"></pr-icon>
Portainer was unable to retrieve any metrics associated to that node. Please contact your administrator to ensure that the Kubernetes metrics feature is properly configured.
</span>
</information-panel>
<div class="row" ng-if="ctrl.state.getMetrics">
<div class="col-md-12">
<rd-widget>
<div class="toolBar px-5 pt-5">
<div class="toolBarTitle flex">
<div class="widget-icon space-right">
<pr-icon icon="'info'"></pr-icon>
</div>
<span class="vertical-center"> About statistics </span>
<information-panel ng-if="!ctrl.state.getMetrics" title-text="Unable to retrieve node metrics">
<span class="small text-muted vertical-center">
<pr-icon icon="'alert-triangle'" mode="'primary'"></pr-icon>
Portainer was unable to retrieve any metrics associated to that node. Please contact your administrator to ensure that the Kubernetes metrics feature is properly configured.
</span>
</information-panel>
<div class="row" ng-if="ctrl.state.getMetrics">
<div class="col-md-12">
<rd-widget>
<div class="toolBar px-5 pt-5">
<div class="toolBarTitle flex">
<div class="widget-icon space-right">
<pr-icon icon="'info'"></pr-icon>
</div>
<span class="vertical-center"> About statistics </span>
</div>
<rd-widget-body>
<form class="form-horizontal">
<div class="form-group">
<div class="col-sm-12">
<span class="small text-muted">
This view displays real-time statistics about the node <b>{{ ctrl.state.transition.nodeName }}</b
>.
</span>
</div>
</div>
<div class="form-group">
<label for="refreshRate" class="col-sm-3 col-md-2 col-lg-2 margin-sm-top control-label text-left"> Refresh rate </label>
<div class="col-sm-3 col-md-2">
<select id="refreshRate" ng-model="ctrl.state.refreshRate" ng-change="ctrl.changeUpdateRepeater()" class="form-control">
<option value="30">30s</option>
<option value="60">60s</option>
</select>
</div>
<span>
<pr-icon id="refreshRateChange" icon="'check'" mode="'success'" style="display: none"></pr-icon>
</div>
<rd-widget-body>
<form class="form-horizontal">
<div class="form-group">
<div class="col-sm-12">
<span class="small text-muted">
This view displays real-time statistics about the node <b>{{ ctrl.state.transition.nodeName }}</b
>.
</span>
</div>
</form>
</rd-widget-body>
</rd-widget>
</div>
</div>
<div class="row" ng-show="ctrl.state.getMetrics">
<div class="col-lg-6 col-md-12 col-sm-12">
<rd-widget>
<div class="toolBar px-5 pt-5">
<div class="toolBarTitle flex">
<div class="widget-icon space-right">
<pr-icon icon="'svg-memory'"></pr-icon>
</div>
<div class="form-group">
<label for="refreshRate" class="col-sm-3 col-md-2 col-lg-2 margin-sm-top control-label text-left"> Refresh rate </label>
<div class="col-sm-3 col-md-2">
<select id="refreshRate" ng-model="ctrl.state.refreshRate" ng-change="ctrl.changeUpdateRepeater()" class="form-control">
<option value="30">30s</option>
<option value="60">60s</option>
</select>
</div>
<span class="vertical-center"> Memory usage </span>
<span>
<pr-icon id="refreshRateChange" icon="'check'" mode="'success'" style="display: none"></pr-icon>
</span>
</div>
</div>
<rd-widget-body>
<div class="chart-node" style="position: relative">
<canvas id="memoryChart" width="770" height="300"></canvas>
</div>
</rd-widget-body>
</rd-widget>
</div>
<div class="col-lg-6 col-md-12 col-sm-12">
<rd-widget>
<div class="toolBar px-5 pt-5">
<div class="toolBarTitle flex">
<div class="widget-icon space-right">
<pr-icon icon="'cpu'"></pr-icon>
</div>
<span class="vertical-center"> CPU usage </span>
</div>
</div>
<rd-widget-body>
<div class="chart-node" style="position: relative">
<canvas id="cpuChart" width="770" height="300"></canvas>
</div>
</rd-widget-body>
</rd-widget>
</div>
</form>
</rd-widget-body>
</rd-widget>
</div>
</div>
<div class="row" ng-show="ctrl.state.getMetrics">
<div class="col-lg-6 col-md-12 col-sm-12">
<rd-widget>
<div class="toolBar px-5 pt-5">
<div class="toolBarTitle flex">
<div class="widget-icon space-right">
<pr-icon icon="'svg-memory'"></pr-icon>
</div>
<span class="vertical-center"> Memory usage </span>
</div>
</div>
<rd-widget-body>
<div class="chart-node" style="position: relative">
<canvas id="memoryChart" width="770" height="300"></canvas>
</div>
</rd-widget-body>
</rd-widget>
</div>
<div class="col-lg-6 col-md-12 col-sm-12">
<rd-widget>
<div class="toolBar px-5 pt-5">
<div class="toolBarTitle flex">
<div class="widget-icon space-right">
<pr-icon icon="'cpu'"></pr-icon>
</div>
<span class="vertical-center"> CPU usage </span>
</div>
</div>
<rd-widget-body>
<div class="chart-node" style="position: relative">
<canvas id="cpuChart" width="770" height="300"></canvas>
</div>
</rd-widget-body>
</rd-widget>
</div>
</div>

View File

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

View File

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

View File

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

View File

@@ -87,7 +87,7 @@
<pr-icon icon="'info'" mode="'primary'"></pr-icon>
<span>
More information about types of secret can be found in the official
<a class="hyperlink" href="https://kubernetes.io/docs/concepts/configuration/secret/#secret-types" target="_blank">kubernetes documentation</a>.
<a class="hyperlink" href="https://kubernetes.io/docs/concepts/configuration/secret/#secret-types" target="_blank">Kubernetes documentation</a>.
</span>
</div>
</div>
@@ -186,6 +186,7 @@
is-valid="ctrl.state.isDataValid"
on-change-validation="ctrl.isFormValid()"
is-creation="true"
type="'secret'"
is-editor-dirty="ctrl.state.isEditorDirty"
></kubernetes-configuration-data>

View File

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

View File

@@ -22,7 +22,7 @@
<rd-widget>
<rd-widget-body>
<form class="form-horizontal" name="kubernetesClusterSetupForm">
<div class="col-sm-12 form-section-title"> Networking </div>
<div class="col-sm-12 form-section-title"> Networking - Services </div>
<div class="form-group">
<div class="col-sm-12 text-muted small">
@@ -41,6 +41,8 @@
</div>
</div>
<div class="col-sm-12 form-section-title"> Networking - Ingresses </div>
<ingress-class-datatable
on-change-controllers="(ctrl.onChangeControllers)"
allow-none-ingress-class="ctrl.formValues.AllowNoneIngressClass"
@@ -51,47 +53,57 @@
view="'cluster'"
></ingress-class-datatable>
<label htmlFor="foldingButtonIngControllerSettings" class="col-sm-12 form-section-title flex cursor-pointer items-center">
<button
id="foldingButtonIngControllerSettings"
type="button"
class="mx-2 !ml-0 inline-flex w-2 items-center justify-center border-0 bg-transparent"
ng-click="ctrl.toggleAdvancedIngSettings($event)"
<div class="form-group">
<div class="col-sm-12">
<por-switch-field
checked="ctrl.formValues.AllowNoneIngressClass"
name="'allowNoIngressClass'"
label="'Allow ingress class to be set to &quot;none&quot;'"
tooltip="'This allows users setting up ingresses to select &quot;none&quot; as the ingress class.'"
on-change="(ctrl.onToggleAllowNoneIngressClass)"
label-class="'col-sm-5 col-lg-4 px-0 !m-0'"
switch-class="'col-sm-8'"
>
</por-switch-field>
</div>
</div>
<div class="form-group">
<div class="col-sm-12">
<por-switch-field
checked="ctrl.formValues.IngressAvailabilityPerNamespace"
name="'ingressAvailabilityPerNamespace'"
label="'Configure ingress controller availability per namespace'"
tooltip="'This allows an administrator to configure, in each namespace, which ingress controllers will be available for users to select when setting up ingresses for applications.'"
on-change="(ctrl.onToggleIngressAvailabilityPerNamespace)"
label-class="'col-sm-5 col-lg-4 px-0 !m-0'"
switch-class="'col-sm-8'"
>
</por-switch-field>
</div>
</div>
<div class="form-group">
<div class="col-sm-12">
<por-switch-field
checked="ctrl.formValues.RestrictStandardUserIngressW"
name="'restrictStandardUserIngressW'"
label="'Only allow admins to deploy ingresses'"
tooltip="'Enforces only allowing admins to deploy ingresses (and disallows standard users from doing so).'"
on-change="(ctrl.onToggleRestrictStandardUserIngressW)"
label-class="'col-sm-5 col-lg-4 px-0 !m-0'"
switch-class="'col-sm-8 text-muted'"
data-cy="kubeSetup-restrictStandardUserIngressWToggle"
disabled="!ctrl.isRBACEnabled"
>
</por-switch-field>
</div>
</div>
<div class="mb-4 !inline-flex gap-1 !align-top">
<div class="icon icon-sm"><pr-icon icon="'alert-circle'" mode="'primary'"></pr-icon></div>
<div class="text-muted small"
>You may set up ingress defaults (hostnames and annotations) via Create/Edit ingress. Users may then select them via the hostname dropdown in Create/Edit
application.</div
>
<pr-icon ng-if="!ctrl.state.isIngToggleSectionExpanded" icon="'chevron-right'"></pr-icon>
<pr-icon ng-if="ctrl.state.isIngToggleSectionExpanded" icon="'chevron-down'"></pr-icon>
</button>
More settings
</label>
<div ng-if="ctrl.state.isIngToggleSectionExpanded" class="ml-4">
<div class="form-group">
<div class="col-sm-12">
<por-switch-field
checked="ctrl.formValues.AllowNoneIngressClass"
name="'allowNoIngressClass'"
label="'Allow ingress class to be set to &quot;none&quot;'"
tooltip="'This allows users setting up ingresses to select &quot;none&quot; as the ingress class.'"
on-change="(ctrl.onToggleAllowNoneIngressClass)"
label-class="'col-sm-5 col-lg-4 px-0 !m-0'"
switch-class="'col-sm-8'"
>
</por-switch-field>
</div>
</div>
<div class="form-group">
<div class="col-sm-12">
<por-switch-field
checked="ctrl.formValues.IngressAvailabilityPerNamespace"
name="'ingressAvailabilityPerNamespace'"
label="'Configure ingress controller availability per namespace'"
tooltip="'This allows an administrator to configure, in each namespace, which ingress controllers will be available for users to select when setting up ingresses for applications.'"
on-change="(ctrl.onToggleIngressAvailabilityPerNamespace)"
label-class="'col-sm-5 col-lg-4 px-0 !m-0'"
switch-class="'col-sm-8'"
>
</por-switch-field>
</div>
</div>
</div>
<!-- auto update window -->
@@ -161,19 +173,6 @@
>
</por-switch-field>
</div>
<div class="col-sm-12 mt-5">
<por-switch-field
name="'restrictStandardUserIngressW'"
label="'Only allow admins to deploy ingresses'"
tooltip="'Enforces only allowing admins to deploy ingresses (and disallows standard users from doing so).'"
label-class="'col-sm-5 col-lg-4 px-0 !m-0'"
switch-class="'col-sm-8 text-muted'"
data-cy="kubeSetup-restrictStandardUserIngressWToggle"
feature-id="ctrl.limitedFeatureIngressDeploy"
disabled="!ctrl.isRBACEnabled"
></por-switch-field>
</div>
</div>
<!-- #endregion -->

View File

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

View File

@@ -16,7 +16,7 @@ import { FeatureId } from '@/react/portainer/feature-flags/enums';
import { updateIngressControllerClassMap, getIngressControllerClassMap } from '@/react/kubernetes/cluster/ingressClass/utils';
import { confirmUpdate } from '@@/modals/confirm';
import { confirmUpdateNamespace } from '@/react/kubernetes/namespaces/ItemView/ConfirmUpdateNamespace';
import { getMetricsForAllNodes, getMetricsForAllPods } from '@/react/kubernetes/services/service.ts';
import { getMetricsForAllPods } from '@/react/kubernetes/services/service.ts';
class KubernetesResourcePoolController {
/* #region CONSTRUCTOR */
@@ -36,7 +36,8 @@ class KubernetesResourcePoolController {
KubernetesApplicationService,
KubernetesIngressService,
KubernetesVolumeService,
KubernetesNamespaceService
KubernetesNamespaceService,
KubernetesNodeService
) {
Object.assign(this, {
$async,
@@ -54,6 +55,7 @@ class KubernetesResourcePoolController {
KubernetesIngressService,
KubernetesVolumeService,
KubernetesNamespaceService,
KubernetesNodeService,
});
this.IngressClassTypes = KubernetesIngressClassTypes;
@@ -366,7 +368,7 @@ class KubernetesResourcePoolController {
const name = this.$state.params.id;
const [nodes, pools] = await Promise.all([getMetricsForAllNodes, this.KubernetesResourcePoolService.get('', { getQuota: true })]);
const [nodes, pools] = await Promise.all([this.KubernetesNodeService.get(), this.KubernetesResourcePoolService.get('', { getQuota: true })]);
this.ingressControllers = [];
if (this.state.ingressAvailabilityPerNamespace) {

View File

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

View File

@@ -3,7 +3,7 @@ import { KubernetesConfigurationFormValues } from 'Kubernetes/models/configurati
import { KubernetesResourcePoolFormValues } from 'Kubernetes/models/resource-pool/formValues';
import { KubernetesApplicationFormValues } from 'Kubernetes/models/application/formValues';
import { KubernetesResourceActions, KubernetesResourceTypes } from 'Kubernetes/models/resource-types/models';
import getApplicationResources from './resources/applicationResources';
import { getApplicationResources } from './resources/applicationResources';
import getNamespaceResources from './resources/namespaceResources';
import getConfigurationResources from './resources/configurationResources';

View File

@@ -14,14 +14,19 @@
{{ $ctrl.model.Title }}
</span>
<div class="space-left blocklist-item-subtitle inline-flex items-center">
<pr-icon ng-if="$ctrl.model.Platform === 1 || $ctrl.model.Platform === 'linux' || !$ctrl.model.Platform" icon="'svg-linux'" class="mr-1"></pr-icon>
<span ng-if="!$ctrl.model.Platform"> &amp; </span>
<pr-icon
icon="'svg-microsoft'"
ng-if="$ctrl.model.Platform === 2 || $ctrl.model.Platform === 'windows' || !$ctrl.model.Platform"
class-name="'[&>*]:flex [&>*]:items-center'"
size="'lg'"
></pr-icon>
<div ng-if="$ctrl.typeLabel !== 'manifest'" class="vertical-center gap-1">
<pr-icon ng-if="$ctrl.model.Platform === 1 || $ctrl.model.Platform === 'linux' || !$ctrl.model.Platform" icon="'svg-linux'" class="mr-1"></pr-icon>
<pr-icon
icon="'svg-microsoft'"
ng-if="$ctrl.model.Platform === 2 || $ctrl.model.Platform === 'windows' || !$ctrl.model.Platform"
class-name="'[&>*]:flex [&>*]:items-center'"
size="'lg'"
></pr-icon>
</div>
<!-- currently only kubernetes uses the typeLabel of 'manifest' -->
<div ng-if="$ctrl.typeLabel === 'manifest'" class="vertical-center">
<pr-icon icon="'svg-kubernetes'" size="'lg'" class="align-bottom" class-name="'[&>*]:flex [&>*]:items-center'"></pr-icon>
</div>
{{ $ctrl.typeLabel }}
</div>
</div>

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,27 @@
/**
* Compares two semver strings.
*
* returns:
* - `-1` if `a < b`
* - `0` if `a == b`
* - `1` if `a > b`
*/
export function semverCompare(a: string, b: string) {
if (a.startsWith(`${b}-`)) {
return -1;
}
if (b.startsWith(`${a}-`)) {
return 1;
}
return a.localeCompare(b, undefined, {
numeric: true,
sensitivity: 'case',
caseFirst: 'upper',
});
}
export function isVersionSmaller(a: string, b: string) {
return semverCompare(a, b) < 0;
}

View File

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

View File

@@ -1 +1,2 @@
export { Badge } from './Badge';
export type { BadgeType } from './Badge';

View File

@@ -0,0 +1,19 @@
import { Story, Meta } from '@storybook/react';
import { PropsWithChildren } from 'react';
import { InlineLoader, Props } from './InlineLoader';
export default {
title: 'Components/InlineLoader',
component: InlineLoader,
} as Meta;
function Template({ className, children }: PropsWithChildren<Props>) {
return <InlineLoader className={className}>{children}</InlineLoader>;
}
export const Primary: Story<PropsWithChildren<Props>> = Template.bind({});
Primary.args = {
className: 'test-class',
children: 'Loading...',
};

View File

@@ -0,0 +1,23 @@
import { Loader2 } from 'lucide-react';
import { PropsWithChildren } from 'react';
import clsx from 'clsx';
import { Icon } from '@@/Icon';
export type Props = {
className: string;
};
export function InlineLoader({
children,
className,
}: PropsWithChildren<Props>) {
return (
<div
className={clsx('text-muted flex items-center gap-2 text-sm', className)}
>
<Icon icon={Loader2} className="animate-spin-slow" />
{children}
</div>
);
}

View File

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

View File

@@ -247,6 +247,12 @@ const docURLs = [
locationRegex: /#!\/edge\/jobs/,
examples: ['#!/edge/jobs', '#!/edge/jobs/new'],
},
{
desc: 'Edge Compute / Edge Configurations',
docURL: 'https://docs.portainer.io/user/edge/configurations',
locationRegex: /#!\/edge\/configurations/,
examples: ['#!/edge/configurations', '#!/edge/configurations/new'],
},
{
desc: 'Nomad / Dashboard',
docURL: 'https://docs.portainer.io/user/nomad/dashboard',

View File

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

View File

@@ -3,6 +3,7 @@ import {
TableState,
useReactTable,
Row,
Column,
getCoreRowModel,
getPaginationRowModel,
getFilteredRowModel,
@@ -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;
}

View File

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

View File

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

View File

@@ -31,6 +31,22 @@
display: none;
}
.portainer-selector-root .portainer-selector__group-heading {
text-transform: none !important;
font-size: 85% !important;
}
.input-group .portainer-selector-root:last-child .portainer-selector__control {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
border-top-right-radius: 5px;
border-bottom-right-radius: 5px;
}
.input-group .portainer-selector-root:not(:first-child):not(:last-child) .portainer-selector__control {
border-radius: 0;
}
/* input style */
.portainer-selector-root .portainer-selector__control {
border-color: var(--border-form-control-color);

View File

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

View File

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

View File

@@ -3,10 +3,7 @@ import { truncate } from 'lodash';
import { useMemo, useState } from 'react';
import { useEnvironmentList } from '@/react/portainer/environments/queries';
import {
Environment,
EnvironmentId,
} from '@/react/portainer/environments/types';
import { Environment } from '@/react/portainer/environments/types';
import { useGroups } from '@/react/portainer/environments/environment-groups/queries';
import { useTags } from '@/portainer/tags/queries';
import { EnvironmentsQueryParams } from '@/react/portainer/environments/environment.service';
@@ -47,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>

View File

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

View File

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

View File

@@ -214,7 +214,7 @@ function InnerForm({
checked={values.retryDeploy}
name="retryDeploy"
label="Retry deployment"
tooltip="When enabled, this will allow edge agent keep retrying deployment if failure occur"
tooltip="When enabled, this will allow the edge agent to retry deployment if failed to deploy initially"
labelClass="col-sm-3 col-lg-2"
onChange={(value) => setFieldValue('retryDeploy', value)}
/>

View File

@@ -4,7 +4,6 @@ import { useMemo, useState } from 'react';
import { EdgeStackStatus, StatusType } from '@/react/edge/edge-stacks/types';
import { useEnvironmentList } from '@/react/portainer/environments/queries';
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
import { useParamState } from '@/react/hooks/useParamState';
import { EnvironmentId } from '@/react/portainer/environments/types';
@@ -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>
}
/>
);

View File

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

View File

@@ -5,9 +5,14 @@ import {
type Icon as IconType,
Loader2,
XCircle,
MinusCircle,
} from 'lucide-react';
import { useEnvironmentList } from '@/react/portainer/environments/queries';
import { isVersionSmaller } from '@/react/common/semver-utils';
import { Icon, IconMode } from '@@/Icon';
import { Tooltip } from '@@/Tip/Tooltip';
import { DeploymentStatus, EdgeStack, StatusType } from '../../types';
@@ -15,28 +20,51 @@ export function EdgeStackStatus({ edgeStack }: { edgeStack: EdgeStack }) {
const status = Object.values(edgeStack.Status);
const lastStatus = _.compact(status.map((s) => _.last(s.Status)));
const { icon, label, mode, spin } = getStatus(
const environmentsQuery = useEnvironmentList({ edgeStackId: edgeStack.Id });
if (environmentsQuery.isLoading) {
return null;
}
const hasOldVersion = environmentsQuery.environments.some((env) =>
isVersionSmaller(env.Agent.Version, '2.19.0')
);
const { icon, label, mode, spin, tooltip } = getStatus(
edgeStack.NumDeployments,
lastStatus
lastStatus,
hasOldVersion
);
return (
<div className="mx-auto inline-flex items-center gap-2">
{icon && <Icon icon={icon} spin={spin} mode={mode} />}
{label}
{tooltip && <Tooltip message={tooltip} />}
</div>
);
}
function getStatus(
numDeployments: number,
envStatus: Array<DeploymentStatus>
envStatus: Array<DeploymentStatus>,
hasOldVersion: boolean
): {
label: string;
icon?: IconType;
spin?: boolean;
mode?: IconMode;
tooltip?: string;
} {
if (!numDeployments || hasOldVersion) {
return {
label: 'Unavailable',
icon: MinusCircle,
mode: 'secondary',
tooltip: getUnavailableTooltip(),
};
}
if (envStatus.length < numDeployments) {
return {
label: 'Deploying',
@@ -56,7 +84,11 @@ function getStatus(
};
}
const allRunning = envStatus.every((s) => s.Type === StatusType.Running);
const allRunning = envStatus.every(
(s) =>
s.Type === StatusType.Running ||
(s.Type === StatusType.DeploymentReceived && hasOldVersion)
);
if (allRunning) {
return {
@@ -84,4 +116,16 @@ function getStatus(
spin: true,
mode: 'primary',
};
function getUnavailableTooltip() {
if (!numDeployments) {
return 'Your edge stack is currently unavailable due to the absence of an available environment in your edge group';
}
if (hasOldVersion) {
return 'Please note that the new status feature for the Edge stack is only available for Edge Agent versions 2.19.0 and above. To access the status of your edge stack, it is essential to upgrade your Edge Agent to a corresponding version that is compatible with your Portainer server.';
}
return '';
}
}

View File

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

View File

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

View File

@@ -38,7 +38,7 @@ export function PrivateRegistryFieldset({
const [selected, setSelected] = useState(value);
const tooltipMessage =
'Use this when using a private registry that requires credentials';
'This allows you to provide credentials when using a private registry that requires authentication';
useEffect(() => {
if (checked) {

View File

@@ -2,6 +2,11 @@ import { useEffect, useMemo, useState } from 'react';
import { FormikErrors } from 'formik';
import { KubernetesApplicationPublishingTypes } from '@/kubernetes/models/application/models';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import {
useIngressControllers,
useIngresses,
} from '@/react/kubernetes/ingresses/queries';
import { FormSection } from '@@/form-components/FormSection';
@@ -50,6 +55,11 @@ export function KubeServicesForm({
const [selectedServiceType, setSelectedServiceType] =
useState<ServiceTypeValue>('ClusterIP');
// start loading ingresses and controllers early to reduce perceived loading time
const environmentId = useEnvironmentId();
useIngresses(environmentId, namespace ? [namespace] : []);
useIngressControllers(environmentId, namespace);
// when the appName changes, update the names for each service
// and the serviceNames for each service port
const newServiceNames = useMemo(

View File

@@ -10,9 +10,9 @@ export function PublishingExplaination() {
src={ingressDiagram}
alt="ingress explaination"
width={646}
className="flex w-full max-w-2xl basis-1/2 flex-col object-contain lg:w-1/2"
className="flex w-full max-w-2xl basis-1/2 flex-col rounded border border-solid border-gray-5 object-contain lg:w-1/2"
/>
<div className="ml-8 basis-1/2">
<div className="text-muted ml-8 basis-1/2 text-xs">
Expose the application workload via{' '}
<a
href="https://kubernetes.io/docs/concepts/services-networking/service/"

View File

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

View File

@@ -44,13 +44,10 @@ export function AppIngressPathsForm({
namespace ? [namespace] : undefined
);
const { data: ingresses } = ingressesQuery;
const ingressControllersQuery = useIngressControllers(
environmentId,
namespace
);
const { data: ingressControllers } = ingressControllersQuery;
const { data: ingressControllers, ...ingressControllersQuery } =
useIngressControllers(environmentId, namespace);
// if some ingress controllers are restricted by namespace, then filter the ingresses that use allowed ingress controllers
// filter for the ingresses that use allowed ingress controllers
const allowedIngressHostNameOptions = useMemo(() => {
const allowedIngressClasses =
ingressControllers

View File

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

View File

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

View File

@@ -1,2 +1,3 @@
export { ApplicationSummaryWidget } from './ApplicationSummaryWidget';
export { ApplicationDetailsWidget } from './ApplicationDetailsWidget/ApplicationDetailsWidget';
export { ApplicationEventsDatatable } from './ApplicationEventsDatatable';

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,5 @@
import { createColumnHelper } from '@tanstack/react-table';
import { Node } from '../../types';
export const columnHelper = createColumnHelper<Node>();

View File

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

View File

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