Compare commits

..

24 Commits

Author SHA1 Message Date
cong meng
d1a1832654 fix(pwd) EE-3161 ease the minimum password restrictions to 12 characters (#6920)
* fix(pwd): EE-3161 ease the minimum password restrictions to 12 characters
2022-05-12 13:16:56 +12:00
Prabhat Khera
578bacdcac bump version to 2.13.1 (#6913) 2022-05-11 13:49:13 +12:00
Dmitry Salakhov
af14db5112 fix(settings): allow empty edge url (#6908) 2022-05-10 15:51:15 -03:00
andres-portainer
790fd5f7d2 fix(tls): downgrade minimum version to TLS 1.2 to avoid proxy problems EE-3152 (#6910) 2022-05-10 15:33:46 -03:00
itsconquest
b3fd62bd96 fix(extension): always restart the backend [EE-3093] (#6889) 2022-05-06 15:14:21 +12:00
itsconquest
6efc7084cd fix(extension): add missing labels [EE-3068] (#6878) 2022-05-06 14:19:22 +12:00
Dakota Walsh
4c747bfd11 fix(extension): extend JWT auth token expiration for extension EE-3065 (#6880)
The default expiration time of 8 hours does not make sense in the
context of the docker desktop extension. This adds a new feature flag
which can be enabled with `export DOCKER_EXTENSION=1` and when 
present will set the expiration time to 99 years.

I've set this flag in the docker-compose.yml we use when building our
docker extension.
2022-05-06 09:53:15 +12:00
Chaim Lev-Ari
c273d3b787 fix(edge): allow more options for url [EE-2975] (#6883) 2022-05-05 10:03:22 +03:00
Chaim Lev-Ari
4e91ca4b1f fix(edge/aeec): add explanation about PORTAINER_EDGE_ID [EE-3056] (#6873) 2022-05-05 10:02:41 +03:00
Matt Hook
6423a7bd17 switch natural sort lib for a better one (#6863)
Switched to better natural sorting package
2022-05-02 12:37:37 +12:00
Richard Wei
8214119137 fix selector css (#6859) 2022-04-29 15:06:38 +12:00
itsconquest
fe3aeab115 fix(home): fix styles of edit button [EE-3006] (#6802)
* fix(home): fix styles of edit button [EE-3006]

* fix(home): EE-3006 fix styles of edit button

Co-authored-by: Simon Meng <simon.meng@portainer.io>
2022-04-29 11:01:02 +12:00
itsconquest
5e25f8fe7d fix(edge): fix formatting of scripts for release [EE-2987] (#6785)
* fix(edge) fix formatting for release [EE-2987]

* fix(edge) EE-2987 fix edge agent command formatting

Co-authored-by: Simon Meng <simon.meng@portainer.io>
2022-04-29 09:44:30 +12:00
Richard Wei
8471d2ae26 fix clear all button text vertical align (#6834) 2022-04-28 10:18:41 +12:00
Prabhat Khera
eb7875290d pass tagsPartialMatch query param on home screen (#6843) 2022-04-27 17:27:39 +12:00
Prabhat Khera
26649219b3 fix status filter (#6828) 2022-04-27 11:59:42 +12:00
itsconquest
c1072da667 fix(settings): fix logic for showing https section [EE-3008](#6804) 2022-04-27 10:48:25 +12:00
cong meng
a992cdbe53 fix: EE-3019 add space on top copy button (#6818) 2022-04-27 10:10:33 +12:00
Chaim Lev-Ari
175fddff8e fix(edge): show edge environment in edge views [EE-2997] (#6796) 2022-04-26 14:25:28 +03:00
Prabhat Khera
8034966ea3 fix(home): home page filters EE-2972 (#6787) 2022-04-26 11:57:50 +12:00
andres-portainer
4af28d59cf fix(aeec): enforce non-empty EdgeIDs for global key environment retrieval EE-3013 (#6809) 2022-04-25 11:35:23 -03:00
Richard Wei
6a77e8cfa3 fix add rewrite annotation should not available for traefik (#6800) 2022-04-22 20:02:56 +12:00
Chaim Lev-Ari
79c16700dd test push 2022-04-22 10:16:24 +03:00
itsconquest
c1a2ca5a51 fix(user-settings): prevent autofocus on access tokens for release [EE-2978] (#6786) 2022-04-22 11:44:59 +12:00
85 changed files with 1374 additions and 3265 deletions

View File

@@ -34,5 +34,3 @@ jobs:
prettier_dir: app/
gofmt: true
gofmt_dir: api/
- name: Typecheck
uses: icrawl/action-tsc@v1

View File

@@ -1,230 +0,0 @@
name: Nightly Code Security Scan
on:
schedule:
- cron: '0 8 * * *'
workflow_dispatch:
jobs:
client-dependencies:
name: Client dependency check
runs-on: ubuntu-latest
if: >- # only run for develop branch
github.ref == 'refs/heads/develop'
outputs:
js: ${{ steps.set-matrix.outputs.js_result }}
steps:
- uses: actions/checkout@master
- name: Run Snyk to check for vulnerabilities
uses: snyk/actions/node@master
continue-on-error: true # To make sure that artifact upload gets called
env:
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
with:
json: true
- name: Upload js security scan result as artifact
uses: actions/upload-artifact@v3
with:
name: js-security-scan-develop-result
path: snyk.json
- name: Export scan result to html file
run: |
$(docker run --rm -v ${{ github.workspace }}:/data oscarzhou/scan-report:0.1.8 summary -report-type=snyk -path="/data/snyk.json" -output-type=table -export -export-filename="/data/js-result")
- name: Upload js result html file
uses: actions/upload-artifact@v3
with:
name: html-js-result-${{github.run_id}}
path: js-result.html
- name: Analyse the js result
id: set-matrix
run: |
result=$(docker run --rm -v ${{ github.workspace }}:/data oscarzhou/scan-report:0.1.8 summary -report-type=snyk -path="/data/snyk.json" -output-type=matrix)
echo "::set-output name=js_result::${result}"
server-dependencies:
name: Server dependency check
runs-on: ubuntu-latest
if: >- # only run for develop branch
github.ref == 'refs/heads/develop'
outputs:
go: ${{ steps.set-matrix.outputs.go_result }}
steps:
- uses: actions/checkout@master
- name: Download go modules
run: cd ./api && go get -t -v -d ./...
- name: Run Snyk to check for vulnerabilities
uses: snyk/actions/golang@master
continue-on-error: true # To make sure that artifact upload gets called
env:
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
with:
args: --file=./api/go.mod
json: true
- name: Upload go security scan result as artifact
uses: actions/upload-artifact@v3
with:
name: go-security-scan-develop-result
path: snyk.json
- name: Export scan result to html file
run: |
$(docker run --rm -v ${{ github.workspace }}:/data oscarzhou/scan-report:0.1.8 summary -report-type=snyk -path="/data/snyk.json" -output-type=table -export -export-filename="/data/go-result")
- name: Upload go result html file
uses: actions/upload-artifact@v3
with:
name: html-go-result-${{github.run_id}}
path: go-result.html
- name: Analyse the go result
id: set-matrix
run: |
result=$(docker run --rm -v ${{ github.workspace }}:/data oscarzhou/scan-report:0.1.8 summary -report-type=snyk -path="/data/snyk.json" -output-type=matrix)
echo "::set-output name=go_result::${result}"
image-vulnerability:
name: Build docker image and Image vulnerability check
runs-on: ubuntu-latest
if: >-
github.ref == 'refs/heads/develop'
outputs:
image: ${{ steps.set-matrix.outputs.image_result }}
steps:
- name: Checkout code
uses: actions/checkout@master
- name: Use golang 1.18
uses: actions/setup-go@v3
with:
go-version: '1.18'
- name: Use Node.js 12.x
uses: actions/setup-node@v1
with:
node-version: 12.x
- name: Install packages and build
run: yarn install && yarn build
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- name: Build and push
uses: docker/build-push-action@v2
with:
context: .
file: build/linux/Dockerfile
tags: trivy-portainer:${{ github.sha }}
outputs: type=docker,dest=/tmp/trivy-portainer-image.tar
- name: Load docker image
run: |
docker load --input /tmp/trivy-portainer-image.tar
- name: Run Trivy vulnerability scanner
uses: docker://docker.io/aquasec/trivy:latest
continue-on-error: true
with:
args: image --ignore-unfixed=true --vuln-type="os,library" --exit-code=1 --format="json" --output="image-trivy.json" --no-progress trivy-portainer:${{ github.sha }}
- name: Upload image security scan result as artifact
uses: actions/upload-artifact@v3
with:
name: image-security-scan-develop-result
path: image-trivy.json
- name: Export scan result to html file
run: |
$(docker run --rm -v ${{ github.workspace }}:/data oscarzhou/scan-report:0.1.8 summary -report-type=trivy -path="/data/image-trivy.json" -output-type=table -export -export-filename="/data/image-result")
- name: Upload go result html file
uses: actions/upload-artifact@v3
with:
name: html-image-result-${{github.run_id}}
path: image-result.html
- name: Analyse the trivy result
id: set-matrix
run: |
result=$(docker run --rm -v ${{ github.workspace }}:/data oscarzhou/scan-report:0.1.8 summary -report-type=trivy -path="/data/image-trivy.json" -output-type=matrix)
echo "::set-output name=image_result::${result}"
result-analysis:
name: Analyse scan result
needs: [client-dependencies, server-dependencies, image-vulnerability]
runs-on: ubuntu-latest
if: >-
github.ref == 'refs/heads/develop'
strategy:
matrix:
js: ${{fromJson(needs.client-dependencies.outputs.js)}}
go: ${{fromJson(needs.server-dependencies.outputs.go)}}
image: ${{fromJson(needs.image-vulnerability.outputs.image)}}
steps:
- name: Display the results of js, go and image
run: |
echo ${{ matrix.js.status }}
echo ${{ matrix.go.status }}
echo ${{ matrix.image.status }}
echo ${{ matrix.js.summary }}
echo ${{ matrix.go.summary }}
echo ${{ matrix.image.summary }}
- name: Send Slack message
if: >-
matrix.js.status == 'failure' ||
matrix.go.status == 'failure' ||
matrix.image.status == 'failure'
uses: slackapi/slack-github-action@v1.18.0
with:
payload: |
{
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "Code Scanning Result (*${{ github.repository }}*)\n*<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|GitHub Actions Workflow URL>*"
}
}
],
"attachments": [
{
"color": "#FF0000",
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "*JS dependency check*: *${{ matrix.js.status }}*\n${{ matrix.js.summary }}"
}
},
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "*Go dependency check*: *${{ matrix.go.status }}*\n${{ matrix.go.summary }}"
}
},
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "*Image vulnerability check*: *${{ matrix.image.status }}*\n${{ matrix.image.summary }}\n"
}
}
]
}
]
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SECURITY_SLACK_WEBHOOK_URL }}
SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK

View File

@@ -1,233 +0,0 @@
name: PR Code Security Scan
on:
pull_request_review:
types:
- submitted
- edited
paths:
- 'package.json'
- 'api/go.mod'
- 'gruntfile.js'
- 'build/linux/Dockerfile'
- 'build/linux/alpine.Dockerfile'
- 'build/windows/Dockerfile'
jobs:
client-dependencies:
name: Client dependency check
runs-on: ubuntu-latest
if: >-
github.event.pull_request &&
github.event.review.body == '/scan'
outputs:
jsdiff: ${{ steps.set-diff-matrix.outputs.js_diff_result }}
steps:
- uses: actions/checkout@master
- name: Run Snyk to check for vulnerabilities
uses: snyk/actions/node@master
continue-on-error: true # To make sure that artifact upload gets called
env:
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
with:
json: true
- name: Upload js security scan result as artifact
uses: actions/upload-artifact@v3
with:
name: js-security-scan-feat-result
path: snyk.json
- name: Download artifacts from develop branch
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
mv ./snyk.json ./js-snyk-feature.json
(gh run download -n js-security-scan-develop-result -R ${{ github.repository }} 2>&1 >/dev/null) || :
if [[ -e ./snyk.json ]]; then
mv ./snyk.json ./js-snyk-develop.json
else
echo "null" > ./js-snyk-develop.json
fi
- name: Export scan result to html file
run: |
$(docker run --rm -v ${{ github.workspace }}:/data oscarzhou/scan-report:0.1.8 diff -report-type=snyk -path="/data/js-snyk-feature.json" -compare-to="/data/js-snyk-develop.json" -output-type=table -export -export-filename="/data/js-result")
- name: Upload js result html file
uses: actions/upload-artifact@v3
with:
name: html-js-result-compare-to-develop-${{github.run_id}}
path: js-result.html
- name: Analyse the js diff result
id: set-diff-matrix
run: |
result=$(docker run --rm -v ${{ github.workspace }}:/data oscarzhou/scan-report:0.1.8 diff -report-type=snyk -path="/data/js-snyk-feature.json" -compare-to="./data/js-snyk-develop.json" -output-type=matrix)
echo "::set-output name=js_diff_result::${result}"
server-dependencies:
name: Server dependency check
runs-on: ubuntu-latest
if: >-
github.event.pull_request &&
github.event.review.body == '/scan'
outputs:
godiff: ${{ steps.set-diff-matrix.outputs.go_diff_result }}
steps:
- uses: actions/checkout@master
- name: Download go modules
run: cd ./api && go get -t -v -d ./...
- name: Run Snyk to check for vulnerabilities
uses: snyk/actions/golang@master
continue-on-error: true # To make sure that artifact upload gets called
env:
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
with:
args: --file=./api/go.mod
json: true
- name: Upload go security scan result as artifact
uses: actions/upload-artifact@v3
with:
name: go-security-scan-feature-result
path: snyk.json
- name: Download artifacts from develop branch
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
mv ./snyk.json ./go-snyk-feature.json
(gh run download -n go-security-scan-develop-result -R ${{ github.repository }} 2>&1 >/dev/null) || :
if [[ -e ./snyk.json ]]; then
mv ./snyk.json ./go-snyk-develop.json
else
echo "null" > ./go-snyk-develop.json
fi
- name: Export scan result to html file
run: |
$(docker run --rm -v ${{ github.workspace }}:/data oscarzhou/scan-report:0.1.8 diff -report-type=snyk -path="/data/go-snyk-feature.json" -compare-to="/data/go-snyk-develop.json" -output-type=table -export -export-filename="/data/go-result")
- name: Upload go result html file
uses: actions/upload-artifact@v3
with:
name: html-go-result-compare-to-develop-${{github.run_id}}
path: go-result.html
- name: Analyse the go diff result
id: set-diff-matrix
run: |
result=$(docker run --rm -v ${{ github.workspace }}:/data oscarzhou/scan-report:0.1.8 diff -report-type=snyk -path="/data/go-snyk-feature.json" -compare-to="/data/go-snyk-develop.json" -output-type=matrix)
echo "::set-output name=go_diff_result::${result}"
image-vulnerability:
name: Build docker image and Image vulnerability check
runs-on: ubuntu-latest
if: >-
github.event.pull_request &&
github.event.review.body == '/scan'
outputs:
imagediff: ${{ steps.set-diff-matrix.outputs.image_diff_result }}
steps:
- name: Checkout code
uses: actions/checkout@master
- name: Use golang 1.18
uses: actions/setup-go@v3
with:
go-version: '1.18'
- name: Use Node.js 12.x
uses: actions/setup-node@v1
with:
node-version: 12.x
- name: Install packages and build
run: yarn install && yarn build
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- name: Build and push
uses: docker/build-push-action@v2
with:
context: .
file: build/linux/Dockerfile
tags: trivy-portainer:${{ github.sha }}
outputs: type=docker,dest=/tmp/trivy-portainer-image.tar
- name: Load docker image
run: |
docker load --input /tmp/trivy-portainer-image.tar
- name: Run Trivy vulnerability scanner
uses: docker://docker.io/aquasec/trivy:latest
continue-on-error: true
with:
args: image --ignore-unfixed=true --vuln-type="os,library" --exit-code=1 --format="json" --output="image-trivy.json" --no-progress trivy-portainer:${{ github.sha }}
- name: Upload image security scan result as artifact
uses: actions/upload-artifact@v3
with:
name: image-security-scan-feature-result
path: image-trivy.json
- name: Download artifacts from develop branch
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
mv ./image-trivy.json ./image-trivy-feature.json
(gh run download -n image-security-scan-develop-result -R ${{ github.repository }} 2>&1 >/dev/null) || :
if [[ -e ./image-trivy.json ]]; then
mv ./image-trivy.json ./image-trivy-develop.json
else
echo "null" > ./image-trivy-develop.json
fi
- name: Export scan result to html file
run: |
$(docker run --rm -v ${{ github.workspace }}:/data oscarzhou/scan-report:0.1.8 diff -report-type=trivy -path="/data/image-trivy-feature.json" -compare-to="/data/image-trivy-develop.json" -output-type=table -export -export-filename="/data/image-result")
- name: Upload image result html file
uses: actions/upload-artifact@v3
with:
name: html-image-result-compare-to-develop-${{github.run_id}}
path: image-result.html
- name: Analyse the image diff result
id: set-diff-matrix
run: |
result=$(docker run --rm -v ${{ github.workspace }}:/data oscarzhou/scan-report:0.1.8 diff -report-type=trivy -path="/data/image-trivy-feature.json" -compare-to="./data/image-trivy-develop.json" -output-type=matrix)
echo "::set-output name=image_diff_result::${result}"
result-analysis:
name: Analyse scan result compared to develop
needs: [client-dependencies, server-dependencies, image-vulnerability]
runs-on: ubuntu-latest
if: >-
github.event.pull_request &&
github.event.review.body == '/scan'
strategy:
matrix:
jsdiff: ${{fromJson(needs.client-dependencies.outputs.jsdiff)}}
godiff: ${{fromJson(needs.server-dependencies.outputs.godiff)}}
imagediff: ${{fromJson(needs.image-vulnerability.outputs.imagediff)}}
steps:
- name: Check job status of diff result
if: >-
matrix.jsdiff.status == 'failure' ||
matrix.godiff.status == 'failure' ||
matrix.imagediff.status == 'failure'
run: |
echo ${{ matrix.jsdiff.status }}
echo ${{ matrix.godiff.status }}
echo ${{ matrix.imagediff.status }}
echo ${{ matrix.jsdiff.summary }}
echo ${{ matrix.godiff.summary }}
echo ${{ matrix.imagediff.summary }}
exit 1

15
.github/workflows/test-client.yaml vendored Normal file
View File

@@ -0,0 +1,15 @@
name: Test Frontend
on: push
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version: '14'
cache: 'yarn'
- run: yarn install --frozen-lockfile
- name: Run tests
run: yarn test:client

View File

@@ -1,29 +0,0 @@
name: Test
on: push
jobs:
test-client:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version: '14'
cache: 'yarn'
- run: yarn --frozen-lockfile
- name: Run tests
run: yarn test:client
# test-server:
# runs-on: ubuntu-latest
# env:
# GOPRIVATE: "github.com/portainer"
# steps:
# - uses: actions/checkout@v3
# - uses: actions/setup-go@v3
# with:
# go-version: '1.18'
# - name: Run tests
# run: |
# cd api
# go test ./...

View File

@@ -16,9 +16,6 @@ module.exports = {
exportLocalsConvention: 'camelCaseOnly',
},
},
postcssLoaderOptions: {
implementation: require('postcss'),
},
},
},
],

View File

@@ -1,7 +1,6 @@
package endpoint
import (
"errors"
"fmt"
portainer "github.com/portainer/portainer/api"
@@ -84,15 +83,6 @@ func (service *Service) Create(endpoint *portainer.Endpoint) error {
return service.connection.CreateObjectWithSetSequence(BucketName, int(endpoint.ID), endpoint)
}
// CreateEndpoint assign an ID to a new environment(endpoint) and saves it.
func (service *Service) CreateWithCallback(endpoint *portainer.Endpoint, fn func(id uint64) (int, interface{})) error {
if endpoint.ID > 0 {
return errors.New("the endpoint must not have an ID")
}
return service.connection.CreateObject(BucketName, fn)
}
// GetNextIdentifier returns the next identifier for an environment(endpoint).
func (service *Service) GetNextIdentifier() int {
return service.connection.GetNextIdentifier(BucketName)

View File

@@ -97,7 +97,6 @@ type (
Endpoint(ID portainer.EndpointID) (*portainer.Endpoint, error)
Endpoints() ([]portainer.Endpoint, error)
Create(endpoint *portainer.Endpoint) error
CreateWithCallback(endpoint *portainer.Endpoint, fn func(uint64) (int, interface{})) error
UpdateEndpoint(ID portainer.EndpointID, endpoint *portainer.Endpoint) error
DeleteEndpoint(ID portainer.EndpointID) error
GetNextIdentifier() int

View File

@@ -1,8 +1,6 @@
package settings
import (
"sync"
portainer "github.com/portainer/portainer/api"
)
@@ -15,30 +13,6 @@ const (
// Service represents a service for managing environment(endpoint) data.
type Service struct {
connection portainer.Connection
cache *portainer.Settings
mu sync.RWMutex
}
func cloneSettings(src *portainer.Settings) *portainer.Settings {
if src == nil {
return nil
}
c := *src
if c.BlackListedLabels != nil {
c.BlackListedLabels = make([]portainer.Pair, len(src.BlackListedLabels))
copy(c.BlackListedLabels, src.BlackListedLabels)
}
if src.FeatureFlagSettings != nil {
c.FeatureFlagSettings = make(map[portainer.Feature]bool)
for k, v := range src.FeatureFlagSettings {
c.FeatureFlagSettings[k] = v
}
}
return &c
}
func (service *Service) BucketName() string {
@@ -59,18 +33,6 @@ func NewService(connection portainer.Connection) (*Service, error) {
// Settings retrieve the settings object.
func (service *Service) Settings() (*portainer.Settings, error) {
service.mu.RLock()
if service.cache != nil {
s := cloneSettings(service.cache)
service.mu.RUnlock()
return s, nil
}
service.mu.RUnlock()
service.mu.Lock()
defer service.mu.Unlock()
var settings portainer.Settings
err := service.connection.GetObject(BucketName, []byte(settingsKey), &settings)
@@ -78,24 +40,12 @@ func (service *Service) Settings() (*portainer.Settings, error) {
return nil, err
}
service.cache = cloneSettings(&settings)
return &settings, nil
}
// UpdateSettings persists a Settings object.
func (service *Service) UpdateSettings(settings *portainer.Settings) error {
service.mu.Lock()
defer service.mu.Unlock()
err := service.connection.UpdateObject(BucketName, []byte(settingsKey), settings)
if err != nil {
return err
}
service.cache = cloneSettings(settings)
return nil
return service.connection.UpdateObject(BucketName, []byte(settingsKey), settings)
}
func (service *Service) IsFeatureFlagEnabled(feature portainer.Feature) bool {
@@ -111,9 +61,3 @@ func (service *Service) IsFeatureFlagEnabled(feature portainer.Feature) bool {
return false
}
func (service *Service) InvalidateCache() {
service.mu.Lock()
service.cache = nil
service.mu.Unlock()
}

View File

@@ -177,7 +177,7 @@ func (store *Store) CreateEndpoint(t *testing.T, name string, endpointType porta
func (store *Store) CreateEndpointRelation(id portainer.EndpointID) {
relation := &portainer.EndpointRelation{
EndpointID: id,
EdgeStacks: map[portainer.EdgeStackID]portainer.EdgeStackStatus{},
EdgeStacks: map[portainer.EdgeStackID]bool{},
}
store.EndpointRelation().Create(relation)

View File

@@ -31,8 +31,6 @@ func (store *Store) MigrateData() error {
return werrors.Wrap(err, "while backing up db before migration")
}
store.SettingsService.InvalidateCache()
migratorParams := &migrator.MigratorParameters{
DatabaseVersion: version,
EndpointGroupService: store.EndpointGroupService,

View File

@@ -54,7 +54,7 @@ func (m *Migrator) updateEndpointsAndEndpointGroupsToDBVersion23() error {
relation := &portainer.EndpointRelation{
EndpointID: endpoint.ID,
EdgeStacks: map[portainer.EdgeStackID]portainer.EdgeStackStatus{},
EdgeStacks: map[portainer.EdgeStackID]bool{},
}
err = m.endpointRelationService.Create(relation)

View File

@@ -33,7 +33,7 @@ require (
github.com/orcaman/concurrent-map v0.0.0-20190826125027-8c72a8bb44f6
github.com/pkg/errors v0.9.1
github.com/portainer/docker-compose-wrapper v0.0.0-20220407011010-3c7408969ad3
github.com/portainer/libcrypto v0.0.0-20220506221303-1f4fb3b30f9a
github.com/portainer/libcrypto v0.0.0-20210422035235-c652195c5c3a
github.com/portainer/libhelm v0.0.0-20210929000907-825e93d62108
github.com/portainer/libhttp v0.0.0-20211208103139-07a5f798eb3f
github.com/rkl-/digest v0.0.0-20180419075440-8316caa4a777

View File

@@ -811,8 +811,6 @@ github.com/portainer/docker-compose-wrapper v0.0.0-20220407011010-3c7408969ad3 h
github.com/portainer/docker-compose-wrapper v0.0.0-20220407011010-3c7408969ad3/go.mod h1:WxDlJWZxCnicdLCPnLNEv7/gRhjeIVuCGmsv+iOPH3c=
github.com/portainer/libcrypto v0.0.0-20210422035235-c652195c5c3a h1:qY8TbocN75n5PDl16o0uVr5MevtM5IhdwSelXEd4nFM=
github.com/portainer/libcrypto v0.0.0-20210422035235-c652195c5c3a/go.mod h1:n54EEIq+MM0NNtqLeCby8ljL+l275VpolXO0ibHegLE=
github.com/portainer/libcrypto v0.0.0-20220506221303-1f4fb3b30f9a h1:B0z3skIMT+OwVNJPQhKp52X+9OWW6A9n5UWig3lHBJk=
github.com/portainer/libcrypto v0.0.0-20220506221303-1f4fb3b30f9a/go.mod h1:n54EEIq+MM0NNtqLeCby8ljL+l275VpolXO0ibHegLE=
github.com/portainer/libhelm v0.0.0-20210929000907-825e93d62108 h1:5e8KAnDa2G3cEHK7aV/ue8lOaoQwBZUzoALslwWkR04=
github.com/portainer/libhelm v0.0.0-20210929000907-825e93d62108/go.mod h1:YvYAk7krKTzB+rFwDr0jQ3sQu2BtiXK1AR0sZH7nhJA=
github.com/portainer/libhttp v0.0.0-20211208103139-07a5f798eb3f h1:GMIjRVV2LADpJprPG2+8MdRH6XvrFgC7wHm7dFUdOpc=

View File

@@ -164,9 +164,7 @@ func (handler *Handler) updateEndpoint(endpointID portainer.EndpointID) error {
edgeStackSet[edgeStackID] = true
}
for edgeStackID := range edgeStackSet {
relation.EdgeStacks[edgeStackID] = portainer.EdgeStackStatus{}
}
relation.EdgeStacks = edgeStackSet
return handler.DataStore.EndpointRelation().UpdateEndpointRelation(endpoint.ID, relation)
}

View File

@@ -105,6 +105,7 @@ func (handler *Handler) createSwarmStackFromFileContent(r *http.Request) (*porta
DeploymentType: payload.DeploymentType,
CreationDate: time.Now().Unix(),
EdgeGroups: payload.EdgeGroups,
Status: make(map[portainer.EndpointID]portainer.EdgeStackStatus),
Version: 1,
}
@@ -227,6 +228,7 @@ func (handler *Handler) createSwarmStackFromGitRepository(r *http.Request) (*por
Name: payload.Name,
CreationDate: time.Now().Unix(),
EdgeGroups: payload.EdgeGroups,
Status: make(map[portainer.EndpointID]portainer.EdgeStackStatus),
DeploymentType: payload.DeploymentType,
Version: 1,
}
@@ -335,6 +337,7 @@ func (handler *Handler) createSwarmStackFromFileUpload(r *http.Request) (*portai
DeploymentType: payload.DeploymentType,
CreationDate: time.Now().Unix(),
EdgeGroups: payload.EdgeGroups,
Status: make(map[portainer.EndpointID]portainer.EdgeStackStatus),
Version: 1,
}
@@ -408,7 +411,7 @@ func updateEndpointRelations(endpointRelationService dataservices.EndpointRelati
return fmt.Errorf("unable to find endpoint relation in database: %w", err)
}
relation.EdgeStacks[edgeStackID] = portainer.EdgeStackStatus{}
relation.EdgeStacks[edgeStackID] = true
err = endpointRelationService.UpdateEndpointRelation(endpointID, relation)
if err != nil {

View File

@@ -1,14 +1,21 @@
package edgestacks
/*
import (
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/internal/testhelpers"
"github.com/stretchr/testify/assert"
)
func Test_updateEndpointRelation_successfulRuns(t *testing.T) {
edgeStackID := portainer.EdgeStackID(5)
endpointRelations := []portainer.EndpointRelation{
{EndpointID: 1, EdgeStacks: map[portainer.EdgeStackID]portainer.EdgeStackStatus{}},
{EndpointID: 2, EdgeStacks: map[portainer.EdgeStackID]portainer.EdgeStackStatus{}},
{EndpointID: 3, EdgeStacks: map[portainer.EdgeStackID]portainer.EdgeStackStatus{}},
{EndpointID: 4, EdgeStacks: map[portainer.EdgeStackID]portainer.EdgeStackStatus{}},
{EndpointID: 5, EdgeStacks: map[portainer.EdgeStackID]portainer.EdgeStackStatus{}},
{EndpointID: 1, EdgeStacks: map[portainer.EdgeStackID]bool{}},
{EndpointID: 2, EdgeStacks: map[portainer.EdgeStackID]bool{}},
{EndpointID: 3, EdgeStacks: map[portainer.EdgeStackID]bool{}},
{EndpointID: 4, EdgeStacks: map[portainer.EdgeStackID]bool{}},
{EndpointID: 5, EdgeStacks: map[portainer.EdgeStackID]bool{}},
}
relatedIds := []portainer.EndpointID{2, 3}
@@ -29,4 +36,3 @@ func Test_updateEndpointRelation_successfulRuns(t *testing.T) {
assert.Equal(t, shouldBeRelated, relation.EdgeStacks[edgeStackID])
}
}
*/

View File

@@ -5,7 +5,6 @@ import (
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/response"
portainer "github.com/portainer/portainer/api"
)
// @id EdgeStackList
@@ -26,35 +25,5 @@ func (handler *Handler) edgeStackList(w http.ResponseWriter, r *http.Request) *h
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve edge stacks from the database", err}
}
endpointRels, err := handler.DataStore.EndpointRelation().EndpointRelations()
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoint relations from the database", err}
}
m := make(map[portainer.EdgeStackID]map[portainer.EndpointID]portainer.EdgeStackStatus)
for _, r := range endpointRels {
for edgeStackID, status := range r.EdgeStacks {
if m[edgeStackID] == nil {
m[edgeStackID] = make(map[portainer.EndpointID]portainer.EdgeStackStatus)
}
m[edgeStackID][r.EndpointID] = status
}
}
type EdgeStackWithStatus struct {
portainer.EdgeStack
Status map[portainer.EndpointID]portainer.EdgeStackStatus
}
var edgeStacksWS []EdgeStackWithStatus
for _, s := range edgeStacks {
edgeStacksWS = append(edgeStacksWS, EdgeStackWithStatus{
EdgeStack: s,
Status: m[s.ID],
})
}
return response.JSON(w, edgeStacksWS)
return response.JSON(w, edgeStacks)
}

View File

@@ -1,57 +0,0 @@
package edgestacks
import (
"net/http"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request"
"github.com/portainer/libhttp/response"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/http/middlewares"
)
func (handler *Handler) handlerDBErr(err error, msg string) *httperror.HandlerError {
httpErr := &httperror.HandlerError{http.StatusInternalServerError, msg, err}
if handler.DataStore.IsErrObjectNotFound(err) {
httpErr.StatusCode = http.StatusNotFound
}
return httpErr
}
// @id EdgeStackStatusDelete
// @summary Delete an EdgeStack status
// @description Authorized only if the request is done by an Edge Environment(Endpoint)
// @tags edge_stacks
// @produce json
// @param id path string true "EdgeStack Id"
// @success 200 {object} portainer.EdgeStack
// @failure 500
// @failure 400
// @failure 404
// @failure 403
// @router /edge_stacks/{id}/status/{endpoint_id} [delete]
func (handler *Handler) edgeStackStatusDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
stackID, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid stack identifier route variable", err}
}
endpoint, err := middlewares.FetchEndpoint(r)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a valid endpoint from the handler context", err}
}
err = handler.requestBouncer.AuthorizedEdgeEndpointOperation(r, endpoint)
if err != nil {
return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access environment", err}
}
stack, err := handler.DataStore.EdgeStack().EdgeStack(portainer.EdgeStackID(stackID))
if err != nil {
return handler.handlerDBErr(err, "Unable to find a stack with the specified identifier inside the database")
}
return response.JSON(w, stack)
}

View File

@@ -49,6 +49,13 @@ func (handler *Handler) edgeStackStatusUpdate(w http.ResponseWriter, r *http.Req
return &httperror.HandlerError{http.StatusBadRequest, "Invalid stack identifier route variable", err}
}
stack, err := handler.DataStore.EdgeStack().EdgeStack(portainer.EdgeStackID(stackID))
if handler.DataStore.IsErrObjectNotFound(err) {
return &httperror.HandlerError{http.StatusNotFound, "Unable to find a stack with the specified identifier inside the database", err}
} else if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a stack with the specified identifier inside the database", err}
}
var payload updateStatusPayload
err = request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
@@ -67,28 +74,17 @@ func (handler *Handler) edgeStackStatusUpdate(w http.ResponseWriter, r *http.Req
return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access environment", err}
}
endpointRelation, err := handler.DataStore.EndpointRelation().EndpointRelation(endpoint.ID)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find the environment relations", err}
stack.Status[*payload.EndpointID] = portainer.EdgeStackStatus{
Type: *payload.Status,
Error: payload.Error,
EndpointID: *payload.EndpointID,
}
endpointRelation.EdgeStacks[portainer.EdgeStackID(stackID)] = portainer.EdgeStackStatus{
Type: *payload.Status,
Error: payload.Error,
}
err = handler.DataStore.EndpointRelation().UpdateEndpointRelation(endpoint.ID, endpointRelation)
err = handler.DataStore.EdgeStack().UpdateEdgeStack(stack.ID, stack)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the stack changes inside the database", err}
}
stack, err := handler.DataStore.EdgeStack().EdgeStack(portainer.EdgeStackID(stackID))
if handler.DataStore.IsErrObjectNotFound(err) {
return &httperror.HandlerError{http.StatusNotFound, "Unable to find a stack with the specified identifier inside the database", err}
} else if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a stack with the specified identifier inside the database", err}
}
return response.JSON(w, stack)
}

View File

@@ -2,11 +2,10 @@ package edgestacks
import (
"errors"
"github.com/portainer/portainer/api/internal/endpointutils"
"net/http"
"strconv"
"github.com/portainer/portainer/api/internal/endpointutils"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request"
"github.com/portainer/libhttp/response"
@@ -119,7 +118,7 @@ func (handler *Handler) edgeStackUpdate(w http.ResponseWriter, r *http.Request)
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find environment relation in database", err}
}
relation.EdgeStacks[stack.ID] = portainer.EdgeStackStatus{}
relation.EdgeStacks[stack.ID] = true
err = handler.DataStore.EndpointRelation().UpdateEndpointRelation(endpointID, relation)
if err != nil {
@@ -181,6 +180,7 @@ func (handler *Handler) edgeStackUpdate(w http.ResponseWriter, r *http.Request)
if payload.Version != nil && *payload.Version != stack.Version {
stack.Version = *payload.Version
stack.Status = map[portainer.EndpointID]portainer.EdgeStackStatus{}
}
err = handler.DataStore.EdgeStack().UpdateEdgeStack(stack.ID, stack)

View File

@@ -10,7 +10,6 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/filesystem"
"github.com/portainer/portainer/api/http/middlewares"
"github.com/portainer/portainer/api/http/security"
)
@@ -25,11 +24,10 @@ type Handler struct {
}
// NewHandler creates a handler to manage environment(endpoint) group operations.
func NewHandler(bouncer *security.RequestBouncer, dataStore dataservices.DataStore) *Handler {
func NewHandler(bouncer *security.RequestBouncer) *Handler {
h := &Handler{
Router: mux.NewRouter(),
requestBouncer: bouncer,
DataStore: dataStore,
}
h.Handle("/edge_stacks",
bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeStackCreate)))).Methods(http.MethodPost)
@@ -45,12 +43,6 @@ func NewHandler(bouncer *security.RequestBouncer, dataStore dataservices.DataSto
bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeStackFile)))).Methods(http.MethodGet)
h.Handle("/edge_stacks/{id}/status",
bouncer.PublicAccess(httperror.LoggerHandler(h.edgeStackStatusUpdate))).Methods(http.MethodPut)
edgeStackStatusRouter := h.NewRoute().Subrouter()
edgeStackStatusRouter.Use(middlewares.WithEndpoint(h.DataStore.Endpoint(), "endpoint_id"))
edgeStackStatusRouter.PathPrefix("/edge_stacks/{id}/status/{endpoint_id}").Handler(bouncer.PublicAccess(httperror.LoggerHandler(h.edgeStackStatusDelete))).Methods(http.MethodDelete)
return h
}

View File

@@ -1,447 +0,0 @@
package endpointedge
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/apikey"
"github.com/portainer/portainer/api/chisel"
"github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/filesystem"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/jwt"
"github.com/stretchr/testify/assert"
)
type endpointTestCase struct {
endpoint portainer.Endpoint
endpointRelation portainer.EndpointRelation
expectedStatusCode int
}
var endpointTestCases = []endpointTestCase{
{
portainer.Endpoint{},
portainer.EndpointRelation{},
http.StatusNotFound,
},
{
portainer.Endpoint{
ID: -1,
Name: "endpoint-id--1",
Type: portainer.EdgeAgentOnDockerEnvironment,
URL: "https://portainer.io:9443",
EdgeID: "edge-id",
},
portainer.EndpointRelation{
EndpointID: -1,
},
http.StatusNotFound,
},
{
portainer.Endpoint{
ID: 2,
Name: "endpoint-id-2",
Type: portainer.EdgeAgentOnDockerEnvironment,
URL: "https://portainer.io:9443",
EdgeID: "",
},
portainer.EndpointRelation{
EndpointID: 2,
},
http.StatusBadRequest,
},
{
portainer.Endpoint{
ID: 4,
Name: "endpoint-id-4",
Type: portainer.EdgeAgentOnDockerEnvironment,
URL: "https://portainer.io:9443",
EdgeID: "edge-id",
},
portainer.EndpointRelation{
EndpointID: 4,
},
http.StatusOK,
},
}
func setupHandler() (*Handler, func(), error) {
tmpDir, err := os.MkdirTemp(os.TempDir(), "portainer-test")
if err != nil {
return nil, nil, fmt.Errorf("could not create a tmp dir: %w", err)
}
fs, err := filesystem.NewService(tmpDir, "")
if err != nil {
return nil, nil, fmt.Errorf("could not start a new filesystem service: %w", err)
}
_, store, storeTeardown := datastore.MustNewTestStore(true, true)
ctx := context.Background()
shutdownCtx, cancelFn := context.WithCancel(ctx)
teardown := func() {
cancelFn()
storeTeardown()
}
jwtService, err := jwt.NewService("1h", store)
if err != nil {
teardown()
return nil, nil, fmt.Errorf("could not start a new jwt service: %w", err)
}
apiKeyService := apikey.NewAPIKeyService(nil, nil)
settings, err := store.Settings().Settings()
if err != nil {
teardown()
return nil, nil, fmt.Errorf("could not create new settings: %w", err)
}
settings.TrustOnFirstConnect = true
err = store.Settings().UpdateSettings(settings)
if err != nil {
teardown()
return nil, nil, fmt.Errorf("could not update settings: %w", err)
}
handler := NewHandler(
security.NewRequestBouncer(store, jwtService, apiKeyService),
store,
fs,
chisel.NewService(store, shutdownCtx),
)
handler.ReverseTunnelService = chisel.NewService(store, shutdownCtx)
return handler, teardown, nil
}
func createEndpoint(handler *Handler, endpoint portainer.Endpoint, endpointRelation portainer.EndpointRelation) (err error) {
// Avoid setting ID below 0 to generate invalid test cases
if endpoint.ID <= 0 {
return nil
}
err = handler.DataStore.Endpoint().Create(&endpoint)
if err != nil {
return err
}
return handler.DataStore.EndpointRelation().Create(&endpointRelation)
}
func TestMissingEdgeIdentifier(t *testing.T) {
handler, teardown, err := setupHandler()
defer teardown()
if err != nil {
t.Fatal(err)
}
endpointID := portainer.EndpointID(45)
err = createEndpoint(handler, portainer.Endpoint{
ID: endpointID,
Name: "endpoint-id-45",
Type: portainer.EdgeAgentOnDockerEnvironment,
URL: "https://portainer.io:9443",
EdgeID: "edge-id",
}, portainer.EndpointRelation{EndpointID: endpointID})
if err != nil {
t.Fatal(err)
}
req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("/%d/edge/status", endpointID), nil)
if err != nil {
t.Fatal("request error:", err)
}
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf(fmt.Sprintf("expected a %d response, found: %d without Edge identifier", http.StatusForbidden, rec.Code))
}
}
func TestWithEndpoints(t *testing.T) {
handler, teardown, err := setupHandler()
defer teardown()
if err != nil {
t.Fatal(err)
}
for _, test := range endpointTestCases {
err = createEndpoint(handler, test.endpoint, test.endpointRelation)
if err != nil {
t.Fatal(err)
}
req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("/%d/edge/status", test.endpoint.ID), nil)
if err != nil {
t.Fatal("request error:", err)
}
req.Header.Set(portainer.PortainerAgentEdgeIDHeader, "edge-id")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != test.expectedStatusCode {
t.Fatalf(fmt.Sprintf("expected a %d response, found: %d for endpoint ID: %d", test.expectedStatusCode, rec.Code, test.endpoint.ID))
}
}
}
func TestLastCheckInDateIncreases(t *testing.T) {
handler, teardown, err := setupHandler()
defer teardown()
if err != nil {
t.Fatal(err)
}
endpointID := portainer.EndpointID(56)
endpoint := portainer.Endpoint{
ID: endpointID,
Name: "test-endpoint-56",
Type: portainer.EdgeAgentOnDockerEnvironment,
URL: "https://portainer.io:9443",
EdgeID: "edge-id",
LastCheckInDate: time.Now().Unix(),
}
endpointRelation := portainer.EndpointRelation{
EndpointID: endpoint.ID,
}
err = createEndpoint(handler, endpoint, endpointRelation)
if err != nil {
t.Fatal(err)
}
time.Sleep(1 * time.Second)
req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("/%d/edge/status", endpoint.ID), nil)
if err != nil {
t.Fatal("request error:", err)
}
req.Header.Set(portainer.PortainerAgentEdgeIDHeader, "edge-id")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf(fmt.Sprintf("expected a %d response, found: %d", http.StatusOK, rec.Code))
}
updatedEndpoint, err := handler.DataStore.Endpoint().Endpoint(endpoint.ID)
if err != nil {
t.Fatal(err)
}
assert.Greater(t, updatedEndpoint.LastCheckInDate, endpoint.LastCheckInDate)
}
func TestEmptyEdgeIdWithAgentPlatformHeader(t *testing.T) {
handler, teardown, err := setupHandler()
defer teardown()
if err != nil {
t.Fatal(err)
}
endpointID := portainer.EndpointID(44)
edgeId := "edge-id"
endpoint := portainer.Endpoint{
ID: endpointID,
Name: "test-endpoint-44",
Type: portainer.EdgeAgentOnDockerEnvironment,
URL: "https://portainer.io:9443",
EdgeID: "",
}
endpointRelation := portainer.EndpointRelation{
EndpointID: endpoint.ID,
}
err = createEndpoint(handler, endpoint, endpointRelation)
if err != nil {
t.Fatal(err)
}
req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("/%d/edge/status", endpoint.ID), nil)
if err != nil {
t.Fatal("request error:", err)
}
req.Header.Set(portainer.PortainerAgentEdgeIDHeader, edgeId)
req.Header.Set(portainer.HTTPResponseAgentPlatform, "1")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf(fmt.Sprintf("expected a %d response, found: %d with empty edge ID", http.StatusOK, rec.Code))
}
updatedEndpoint, err := handler.DataStore.Endpoint().Endpoint(endpoint.ID)
if err != nil {
t.Fatal(err)
}
assert.Equal(t, updatedEndpoint.EdgeID, edgeId)
}
/*
func TestEdgeStackStatus(t *testing.T) {
handler, teardown, err := setupHandler()
defer teardown()
if err != nil {
t.Fatal(err)
}
endpointID := portainer.EndpointID(7)
endpoint := portainer.Endpoint{
ID: endpointID,
Name: "test-endpoint-7",
Type: portainer.EdgeAgentOnDockerEnvironment,
URL: "https://portainer.io:9443",
EdgeID: "edge-id",
LastCheckInDate: time.Now().Unix(),
}
edgeStackID := portainer.EdgeStackID(17)
edgeStack := portainer.EdgeStack{
ID: edgeStackID,
Name: "test-edge-stack-17",
Status: map[portainer.EndpointID]portainer.EdgeStackStatus{
endpointID: {Type: portainer.StatusOk, Error: "", EndpointID: endpoint.ID},
},
CreationDate: time.Now().Unix(),
EdgeGroups: []portainer.EdgeGroupID{1, 2},
ProjectPath: "/project/path",
EntryPoint: "entrypoint",
Version: 237,
ManifestPath: "/manifest/path",
DeploymentType: 1,
}
endpointRelation := portainer.EndpointRelation{
EndpointID: endpoint.ID,
EdgeStacks: map[portainer.EdgeStackID]bool{
edgeStack.ID: true,
},
}
handler.DataStore.EdgeStack().Create(edgeStack.ID, &edgeStack)
err = createEndpoint(handler, endpoint, endpointRelation)
if err != nil {
t.Fatal(err)
}
req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("/%d/edge/status", endpoint.ID), nil)
if err != nil {
t.Fatal("request error:", err)
}
req.Header.Set(portainer.PortainerAgentEdgeIDHeader, "edge-id")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf(fmt.Sprintf("expected a %d response, found: %d", http.StatusOK, rec.Code))
}
var data endpointEdgeStatusInspectResponse
err = json.NewDecoder(rec.Body).Decode(&data)
if err != nil {
t.Fatal("error decoding response:", err)
}
assert.Len(t, data.Stacks, 1)
assert.Equal(t, edgeStack.ID, data.Stacks[0].ID)
assert.Equal(t, edgeStack.Version, data.Stacks[0].Version)
}
*/
func TestEdgeJobsResponse(t *testing.T) {
handler, teardown, err := setupHandler()
defer teardown()
if err != nil {
t.Fatal(err)
}
endpointID := portainer.EndpointID(77)
endpoint := portainer.Endpoint{
ID: endpointID,
Name: "test-endpoint-77",
Type: portainer.EdgeAgentOnDockerEnvironment,
URL: "https://portainer.io:9443",
EdgeID: "edge-id",
LastCheckInDate: time.Now().Unix(),
}
endpointRelation := portainer.EndpointRelation{
EndpointID: endpoint.ID,
}
err = createEndpoint(handler, endpoint, endpointRelation)
if err != nil {
t.Fatal(err)
}
path, err := handler.FileService.StoreEdgeJobFileFromBytes("test-script", []byte("pwd"))
if err != nil {
t.Fatal(err)
}
edgeJobID := portainer.EdgeJobID(35)
edgeJob := portainer.EdgeJob{
ID: edgeJobID,
Created: time.Now().Unix(),
CronExpression: "* * * * *",
Name: "test-edge-job",
ScriptPath: path,
Recurring: true,
Version: 57,
}
handler.ReverseTunnelService.AddEdgeJob(endpoint.ID, &edgeJob)
req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("/%d/edge/status", endpoint.ID), nil)
if err != nil {
t.Fatal("request error:", err)
}
req.Header.Set(portainer.PortainerAgentEdgeIDHeader, "edge-id")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf(fmt.Sprintf("expected a %d response, found: %d", http.StatusOK, rec.Code))
}
var data endpointEdgeStatusInspectResponse
err = json.NewDecoder(rec.Body).Decode(&data)
if err != nil {
t.Fatal("error decoding response:", err)
}
assert.Len(t, data.Schedules, 1)
assert.Equal(t, edgeJob.ID, data.Schedules[0].ID)
assert.Equal(t, edgeJob.CronExpression, data.Schedules[0].CronExpression)
assert.Equal(t, edgeJob.Version, data.Schedules[0].Version)
}

View File

@@ -35,12 +35,11 @@ func (handler *Handler) updateEndpointRelations(endpoint *portainer.Endpoint, en
}
endpointStacks := edge.EndpointRelatedEdgeStacks(endpoint, endpointGroup, edgeGroups, edgeStacks)
updatedStacks := make(map[portainer.EdgeStackID]portainer.EdgeStackStatus)
stacksSet := map[portainer.EdgeStackID]bool{}
for _, edgeStackID := range endpointStacks {
updatedStacks[edgeStackID] = endpointRelation.EdgeStacks[edgeStackID]
stacksSet[edgeStackID] = true
}
endpointRelation.EdgeStacks = updatedStacks
endpointRelation.EdgeStacks = stacksSet
return handler.DataStore.EndpointRelation().UpdateEndpointRelation(endpoint.ID, endpointRelation)
}

View File

@@ -209,15 +209,13 @@ func (handler *Handler) endpointCreate(w http.ResponseWriter, r *http.Request) *
relationObject := &portainer.EndpointRelation{
EndpointID: endpoint.ID,
EdgeStacks: map[portainer.EdgeStackID]portainer.EdgeStackStatus{},
EdgeStacks: map[portainer.EdgeStackID]bool{},
}
if endpoint.Type == portainer.EdgeAgentOnDockerEnvironment || endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment {
relatedEdgeStacks := edge.EndpointRelatedEdgeStacks(endpoint, endpointGroup, edgeGroups, edgeStacks)
for _, stackID := range relatedEdgeStacks {
relationObject.EdgeStacks[stackID] = portainer.EdgeStackStatus{
Type: portainer.StatusAcknowledged,
}
relationObject.EdgeStacks[stackID] = true
}
}
@@ -301,17 +299,17 @@ func (handler *Handler) createAzureEndpoint(payload *endpointCreatePayload) (*po
}
func (handler *Handler) createEdgeAgentEndpoint(payload *endpointCreatePayload) (*portainer.Endpoint, *httperror.HandlerError) {
//endpointID := handler.DataStore.Endpoint().GetNextIdentifier()
endpointID := handler.DataStore.Endpoint().GetNextIdentifier()
portainerHost, err := edge.ParseHostForEdge(payload.URL)
if err != nil {
return nil, httperror.BadRequest("Unable to parse host", err)
}
//edgeKey := handler.ReverseTunnelService.GenerateEdgeKey(payload.URL, portainerHost, endpointID)
edgeKey := handler.ReverseTunnelService.GenerateEdgeKey(payload.URL, portainerHost, endpointID)
endpoint := &portainer.Endpoint{
//ID: portainer.EndpointID(endpointID),
ID: portainer.EndpointID(endpointID),
Name: payload.Name,
URL: portainerHost,
Type: portainer.EdgeAgentOnDockerEnvironment,
@@ -319,12 +317,12 @@ func (handler *Handler) createEdgeAgentEndpoint(payload *endpointCreatePayload)
TLSConfig: portainer.TLSConfiguration{
TLS: false,
},
UserAccessPolicies: portainer.UserAccessPolicies{},
TeamAccessPolicies: portainer.TeamAccessPolicies{},
TagIDs: payload.TagIDs,
Status: portainer.EndpointStatusUp,
Snapshots: []portainer.DockerSnapshot{},
//EdgeKey: edgeKey,
UserAccessPolicies: portainer.UserAccessPolicies{},
TeamAccessPolicies: portainer.TeamAccessPolicies{},
TagIDs: payload.TagIDs,
Status: portainer.EndpointStatusUp,
Snapshots: []portainer.DockerSnapshot{},
EdgeKey: edgeKey,
EdgeCheckinInterval: payload.EdgeCheckinInterval,
Kubernetes: portainer.KubernetesDefault(),
IsEdgeDevice: payload.IsEdgeDevice,
@@ -345,15 +343,7 @@ func (handler *Handler) createEdgeAgentEndpoint(payload *endpointCreatePayload)
endpoint.EdgeID = edgeID.String()
}
err = handler.saveEndpointAndUpdateAuthorizationsWithCallback(endpoint, func(id uint64) (int, interface{}) {
endpoint.ID = portainer.EndpointID(id)
if endpoint.Type == portainer.EdgeAgentOnDockerEnvironment {
endpoint.EdgeKey = handler.ReverseTunnelService.GenerateEdgeKey(payload.URL, portainerHost, int(id))
}
return int(id), endpoint
})
err = handler.saveEndpointAndUpdateAuthorizations(endpoint)
if err != nil {
return nil, &httperror.HandlerError{http.StatusInternalServerError, "An error occured while trying to create the environment", err}
}
@@ -521,42 +511,6 @@ func (handler *Handler) saveEndpointAndUpdateAuthorizations(endpoint *portainer.
return nil
}
func (handler *Handler) saveEndpointAndUpdateAuthorizationsWithCallback(endpoint *portainer.Endpoint, fn func(id uint64) (int, interface{})) error {
endpoint.SecuritySettings = portainer.EndpointSecuritySettings{
AllowVolumeBrowserForRegularUsers: false,
EnableHostManagementFeatures: false,
AllowSysctlSettingForRegularUsers: true,
AllowBindMountsForRegularUsers: true,
AllowPrivilegedModeForRegularUsers: true,
AllowHostNamespaceForRegularUsers: true,
AllowContainerCapabilitiesForRegularUsers: true,
AllowDeviceMappingForRegularUsers: true,
AllowStackManagementForRegularUsers: true,
}
err := handler.DataStore.Endpoint().CreateWithCallback(endpoint, fn)
if err != nil {
return err
}
for _, tagID := range endpoint.TagIDs {
tag, err := handler.DataStore.Tag().Tag(tagID)
if err != nil {
return err
}
tag.Endpoints[endpoint.ID] = true
err = handler.DataStore.Tag().UpdateTag(tagID, tag)
if err != nil {
return err
}
}
return nil
}
func (handler *Handler) storeTLSFiles(endpoint *portainer.Endpoint, payload *endpointCreatePayload) *httperror.HandlerError {
folder := strconv.Itoa(int(endpoint.ID))

View File

@@ -87,6 +87,22 @@ func (handler *Handler) endpointDelete(w http.ResponseWriter, r *http.Request) *
}
}
edgeStacks, err := handler.DataStore.EdgeStack().EdgeStacks()
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve edge stacks from the database", err}
}
for idx := range edgeStacks {
edgeStack := &edgeStacks[idx]
if _, ok := edgeStack.Status[endpoint.ID]; ok {
delete(edgeStack.Status, endpoint.ID)
err = handler.DataStore.EdgeStack().UpdateEdgeStack(edgeStack.ID, edgeStack)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update edge stack", err}
}
}
}
registries, err := handler.DataStore.Registry().Registries()
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve registries from the database", err}

View File

@@ -304,9 +304,7 @@ func (handler *Handler) endpointUpdate(w http.ResponseWriter, r *http.Request) *
currentEdgeStackSet[edgeStackID] = true
}
for edgeStackID := range currentEdgeStackSet {
relation.EdgeStacks[edgeStackID] = portainer.EdgeStackStatus{}
}
relation.EdgeStacks = currentEdgeStackSet
err = handler.DataStore.EndpointRelation().UpdateEndpointRelation(endpoint.ID, relation)
if err != nil {

View File

@@ -2,8 +2,6 @@ package handler
import (
"net/http"
"net/http/pprof"
"runtime"
"strings"
"github.com/portainer/portainer/api/http/handler/auth"
@@ -82,7 +80,7 @@ type Handler struct {
}
// @title PortainerCE API
// @version 2.13.0
// @version 2.13.1
// @description.markdown api-description.md
// @termsOfService
@@ -156,20 +154,9 @@ type Handler struct {
// @tag.name websocket
// @tag.description Create exec sessions using websockets
func init() {
runtime.SetBlockProfileRate(1)
runtime.SetMutexProfileFraction(1)
}
// ServeHTTP delegates a request to the appropriate subhandler.
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
switch {
case strings.HasPrefix(r.URL.Path, "/debug/pprof/profile"):
pprof.Profile(w, r)
case strings.HasPrefix(r.URL.Path, "/debug/pprof/trace"):
pprof.Trace(w, r)
case strings.HasPrefix(r.URL.Path, "/debug/pprof"):
pprof.Index(w, r)
case strings.HasPrefix(r.URL.Path, "/api/auth"):
http.StripPrefix("/api", h.AuthHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/api/backup"):

View File

@@ -126,12 +126,11 @@ func (handler *Handler) updateEndpointRelations(endpoint portainer.Endpoint, edg
}
endpointStacks := edge.EndpointRelatedEdgeStacks(&endpoint, endpointGroup, edgeGroups, edgeStacks)
updatedStacks := make(map[portainer.EdgeStackID]portainer.EdgeStackStatus)
stacksSet := map[portainer.EdgeStackID]bool{}
for _, edgeStackID := range endpointStacks {
updatedStacks[edgeStackID] = endpointRelation.EdgeStacks[edgeStackID]
stacksSet[edgeStackID] = true
}
endpointRelation.EdgeStacks = updatedStacks
endpointRelation.EdgeStacks = stacksSet
return handler.DataStore.EndpointRelation().UpdateEndpointRelation(endpoint.ID, endpointRelation)
}

View File

@@ -132,7 +132,7 @@ func (bouncer *RequestBouncer) AuthorizedEdgeEndpointOperation(r *http.Request,
}
if endpoint.EdgeID != "" && endpoint.EdgeID != edgeIdentifier {
return errors.New(fmt.Sprintf("invalid Edge identifier for endpoint %d. Expecting: %s - Received: %s", endpoint.ID, endpoint.EdgeID, edgeIdentifier))
return errors.New("invalid Edge identifier")
}
if endpoint.LastCheckInDate > 0 || endpoint.UserTrusted {

View File

@@ -139,7 +139,8 @@ func (server *Server) Start() error {
edgeJobsHandler.FileService = server.FileService
edgeJobsHandler.ReverseTunnelService = server.ReverseTunnelService
var edgeStacksHandler = edgestacks.NewHandler(requestBouncer, server.DataStore)
var edgeStacksHandler = edgestacks.NewHandler(requestBouncer)
edgeStacksHandler.DataStore = server.DataStore
edgeStacksHandler.FileService = server.FileService
edgeStacksHandler.GitService = server.GitService
edgeStacksHandler.KubernetesDeployer = server.KubernetesDeployer

View File

@@ -1,33 +1,11 @@
package passwordutils
import (
"regexp"
)
const MinPasswordLen = 12
func lengthCheck(password string) bool {
return len(password) >= MinPasswordLen
}
func comboCheck(password string) bool {
count := 0
regexps := [4]*regexp.Regexp{
regexp.MustCompile(`[a-z]`),
regexp.MustCompile(`[A-Z]`),
regexp.MustCompile(`[0-9]`),
regexp.MustCompile(`[\W_]`),
}
for _, re := range regexps {
if re.FindString(password) != "" {
count += 1
}
}
return count >= 3
}
func StrengthCheck(password string) bool {
return lengthCheck(password) && comboCheck(password)
return lengthCheck(password)
}

View File

@@ -13,9 +13,9 @@ func TestStrengthCheck(t *testing.T) {
}{
{"Empty password", args{""}, false},
{"Short password", args{"portainer"}, false},
{"Short password", args{"portaienr!@#"}, false},
{"Short password", args{"portaienr!@#"}, true},
{"Week password", args{"12345678!@#"}, false},
{"Week password", args{"portaienr123"}, false},
{"Week password", args{"portaienr123"}, true},
{"Good password", args{"Portainer123"}, true},
{"Good password", args{"Portainer___"}, true},
{"Good password", args{"^portainer12"}, true},

View File

@@ -235,13 +235,6 @@ func (s *stubEndpointService) Create(endpoint *portainer.Endpoint) error {
return nil
}
func (s *stubEndpointService) CreateWithCallback(endpoint *portainer.Endpoint, fn func(uint64) (int, interface{})) error {
s.endpoints = append(s.endpoints, *endpoint)
fn(uint64(len(s.endpoints)))
return nil
}
func (s *stubEndpointService) UpdateEndpoint(ID portainer.EndpointID, endpoint *portainer.Endpoint) error {
for i, e := range s.endpoints {
if e.ID == ID {

View File

@@ -252,13 +252,14 @@ type (
//EdgeStack represents an edge stack
EdgeStack struct {
// EdgeStack Identifier
ID EdgeStackID `json:"Id" example:"1"`
Name string `json:"Name"`
CreationDate int64 `json:"CreationDate"`
EdgeGroups []EdgeGroupID `json:"EdgeGroups"`
ProjectPath string `json:"ProjectPath"`
EntryPoint string `json:"EntryPoint"`
Version int `json:"Version"`
ID EdgeStackID `json:"Id" example:"1"`
Name string `json:"Name"`
Status map[EndpointID]EdgeStackStatus `json:"Status"`
CreationDate int64 `json:"CreationDate"`
EdgeGroups []EdgeGroupID `json:"EdgeGroups"`
ProjectPath string `json:"ProjectPath"`
EntryPoint string `json:"EntryPoint"`
Version int `json:"Version"`
ManifestPath string
DeploymentType EdgeStackDeploymentType
@@ -273,8 +274,9 @@ type (
//EdgeStackStatus represents an edge stack status
EdgeStackStatus struct {
Type EdgeStackStatusType `json:"Type"`
Error string `json:"Error"`
Type EdgeStackStatusType `json:"Type"`
Error string `json:"Error"`
EndpointID EndpointID `json:"EndpointID"`
}
//EdgeStackStatusType represents an edge stack status type
@@ -413,7 +415,7 @@ type (
// EndpointRelation represents a environment(endpoint) relation object
EndpointRelation struct {
EndpointID EndpointID
EdgeStacks map[EdgeStackID]EdgeStackStatus
EdgeStacks map[EdgeStackID]bool
}
// Extension represents a deprecated Portainer extension
@@ -1342,7 +1344,7 @@ type (
const (
// APIVersion is the version number of the Portainer API
APIVersion = "2.13.0"
APIVersion = "2.13.1"
// DBVersion is the version number of the Portainer database
DBVersion = 35
// ComposeSyntaxMaxVersion is a maximum supported version of the docker compose syntax

View File

@@ -1,7 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
html,
body,
#page-wrapper,
@@ -816,6 +812,75 @@ json-tree .branch-preview {
padding-bottom: 5px;
}
.w-full {
width: 100%;
}
.flex {
display: flex;
}
.block {
display: block;
}
.items-center {
align-items: center;
}
.space-x-2 > * + * {
margin-left: 0.5rem;
}
.space-x-3 > * + * {
margin-left: 0.75rem;
}
.space-x-4 > * + * {
margin-left: 1rem;
}
.space-y-8 > * + * {
margin-top: 2rem;
}
.my-8 {
margin-top: 2rem;
margin-bottom: 2rem;
}
.m-l-5 {
margin-left: 5px;
}
.m-l-20 {
margin-left: 20px;
}
.m-l-30 {
margin-left: 30px;
}
.m-r-2 {
margin-right: 2px;
}
.m-r-5 {
margin-right: 5px;
}
.m-t-10 {
margin-top: 10px;
}
.m-t-10 {
margin-top: 10px;
}
.m-b-10 {
margin-bottom: 10px;
}
.no-margin {
margin: 0 !important;
}
@@ -824,6 +889,54 @@ json-tree .branch-preview {
border: none;
}
.dispay-flex {
display: flex;
}
.flex-wrap {
flex-wrap: wrap;
}
.ml-0 {
margin-left: 0rem;
}
.ml-1 {
margin-left: 0.25rem;
}
.ml-2 {
margin-left: 0.5rem;
}
.ml-3 {
margin-left: 0.75rem;
}
.ml-4 {
margin-left: 1rem;
}
.ml-5 {
margin-left: 1.25rem;
}
.ml-5 {
margin-left: 1.5rem;
}
.ml-6 {
margin-left: 1.75rem;
}
.ml-7 {
margin-left: 2rem;
}
.ml-8 {
margin-left: 2.25rem;
}
.text-wrap {
word-break: break-all;
white-space: normal;
@@ -834,6 +947,10 @@ json-tree .branch-preview {
cursor: not-allowed;
}
.space-x-1 {
margin-left: 0.25rem;
}
/* used for bootbox prompt with inputType radio */
.form-check.radio {
margin-left: 15px;

View File

@@ -3,9 +3,8 @@ import angular from 'angular';
import { EnvironmentStatus } from '@/portainer/environments/types';
import containersModule from './containers';
import { componentsModule } from './components';
import { networksModule } from './networks';
angular.module('portainer.docker', ['portainer.app', containersModule, componentsModule, networksModule]).config([
angular.module('portainer.docker', ['portainer.app', containersModule, componentsModule]).config([
'$stateRegistryProvider',
function ($stateRegistryProvider) {
'use strict';
@@ -324,7 +323,8 @@ angular.module('portainer.docker', ['portainer.app', containersModule, component
url: '/:id?nodeName',
views: {
'content@': {
component: 'networkDetailsView',
templateUrl: './views/networks/edit/network.html',
controller: 'NetworkController',
},
},
};

View File

@@ -2,16 +2,10 @@ import { EnvironmentId } from '@/portainer/environments/types';
import PortainerError from '@/portainer/error';
import axios from '@/portainer/services/axios';
import { NetworkId } from '../networks/types';
import { genericHandler } from '../rest/response/handlers';
import { ContainerId, DockerContainer } from './types';
export interface Filters {
label?: string[];
network?: NetworkId[];
}
export async function startContainer(
endpointId: EnvironmentId,
id: ContainerId
@@ -92,37 +86,15 @@ export async function removeContainer(
}
}
export async function getContainers(
environmentId: EnvironmentId,
filters?: Filters
) {
try {
const { data } = await axios.get<DockerContainer[]>(
urlBuilder(environmentId, '', 'json'),
{
params: { all: 0, filters },
}
);
return data;
} catch (e) {
throw new PortainerError('Unable to retrieve containers', e as Error);
}
}
function urlBuilder(
endpointId: EnvironmentId,
id?: ContainerId,
id: ContainerId,
action?: string
) {
let url = `/endpoints/${endpointId}/docker/containers`;
if (id) {
url += `/${id}`;
}
const url = `/endpoints/${endpointId}/docker/containers/${id}`;
if (action) {
url += `/${action}`;
return `${url}/${action}`;
}
return url;

View File

@@ -1,18 +0,0 @@
import { useQuery } from 'react-query';
import { EnvironmentId } from '@/portainer/environments/types';
import { getContainers, Filters } from './containers.service';
export function useContainers(environmentId: EnvironmentId, filters?: Filters) {
return useQuery(
['environments', environmentId, 'docker', 'containers', { filters }],
() => getContainers(environmentId, filters),
{
meta: {
title: 'Failure',
message: 'Unable to get containers in network',
},
}
);
}

View File

@@ -1,52 +0,0 @@
import { renderWithQueryClient } from '@/react-tools/test-utils';
import { UserContext } from '@/portainer/hooks/useUser';
import { UserViewModel } from '@/portainer/models/user';
import { NetworkContainer } from '../types';
import { NetworkContainersTable } from './NetworkContainersTable';
const networkContainers: NetworkContainer[] = [
{
EndpointID:
'069d703f3ff4939956233137c4c6270d7d46c04fb10c44d3ec31fde1b46d6610',
IPv4Address: '10.0.1.3/24',
IPv6Address: '',
MacAddress: '02:42:0a:00:01:03',
Name: 'portainer-agent_agent.8hjjodl4hoyhuq1kscmzccyqn.wnv2pp17f8ayeopke2z56yw5x',
Id: 'd54c74b7e1c5649d2a880d3fc02c6201d1d2f85a4fee718f978ec8b147239295',
},
];
jest.mock('@uirouter/react', () => ({
...jest.requireActual('@uirouter/react'),
useCurrentStateAndParams: jest.fn(() => ({
params: { endpointId: 1 },
})),
}));
test('Network container values should be visible and the link should be valid', async () => {
const user = new UserViewModel({ Username: 'test', Role: 1 });
const { findByText } = renderWithQueryClient(
<UserContext.Provider value={{ user }}>
<NetworkContainersTable
networkContainers={networkContainers}
nodeName=""
environmentId={1}
networkId="pc8xc9s6ot043vl1q5iz4zhfs"
/>
</UserContext.Provider>
);
await expect(findByText('Containers in network')).resolves.toBeVisible();
await expect(findByText(networkContainers[0].Name)).resolves.toBeVisible();
await expect(
findByText(networkContainers[0].IPv4Address)
).resolves.toBeVisible();
await expect(
findByText(networkContainers[0].MacAddress)
).resolves.toBeVisible();
await expect(
findByText('Leave network', { exact: false })
).resolves.toBeVisible();
});

View File

@@ -1,97 +0,0 @@
import { Widget, WidgetBody, WidgetTitle } from '@/portainer/components/widget';
import { DetailsTable } from '@/portainer/components/DetailsTable';
import { Button } from '@/portainer/components/Button';
import { Authorized } from '@/portainer/hooks/useUser';
import { EnvironmentId } from '@/portainer/environments/types';
import { Link } from '@/portainer/components/Link';
import { NetworkContainer, NetworkId } from '../types';
import { useDisconnectContainer } from '../queries';
type Props = {
networkContainers: NetworkContainer[];
nodeName: string;
environmentId: EnvironmentId;
networkId: NetworkId;
};
const tableHeaders = [
'Container Name',
'IPv4 Address',
'IPv6 Address',
'MacAddress',
'Actions',
];
export function NetworkContainersTable({
networkContainers,
nodeName,
environmentId,
networkId,
}: Props) {
const disconnectContainer = useDisconnectContainer();
if (networkContainers.length === 0) {
return null;
}
return (
<div className="row">
<div className="col-lg-12 col-md-12 col-xs-12">
<Widget>
<WidgetTitle title="Containers in network" icon="fa-server" />
<WidgetBody className="nopadding">
<DetailsTable
headers={tableHeaders}
dataCy="networkDetails-networkContainers"
>
{networkContainers.map((container) => (
<tr key={container.Id}>
<td>
<Link
to="docker.containers.container"
params={{
id: container.Id,
nodeName,
}}
title={container.Name}
>
{container.Name}
</Link>
</td>
<td>{container.IPv4Address || '-'}</td>
<td>{container.IPv6Address || '-'}</td>
<td>{container.MacAddress || '-'}</td>
<td>
<Authorized authorizations="DockerNetworkDisconnect">
<Button
dataCy={`networkDetails-disconnect${container.Name}`}
size="xsmall"
color="danger"
onClick={() => {
if (container.Id) {
disconnectContainer.mutate({
containerId: container.Id,
environmentId,
networkId,
});
}
}}
>
<i
className="fa fa-trash-alt space-right"
aria-hidden="true"
/>
Leave Network
</Button>
</Authorized>
</td>
</tr>
))}
</DetailsTable>
</WidgetBody>
</Widget>
</div>
</div>
);
}

View File

@@ -1,123 +0,0 @@
import { render } from '@/react-tools/test-utils';
import { UserContext } from '@/portainer/hooks/useUser';
import { UserViewModel } from '@/portainer/models/user';
import { ResourceControlOwnership } from '@/portainer/access-control/types';
import { DockerNetwork } from '../types';
import { NetworkDetailsTable } from './NetworkDetailsTable';
jest.mock('@uirouter/react', () => ({
...jest.requireActual('@uirouter/react'),
useCurrentStateAndParams: jest.fn(() => ({
params: { endpointId: 1 },
})),
}));
test('Network details values should be visible', async () => {
const network = getNetwork('test');
const { findByText } = await renderComponent(true, network);
await expect(findByText(network.Name)).resolves.toBeVisible();
await expect(findByText(network.Id)).resolves.toBeVisible();
await expect(findByText(network.Driver)).resolves.toBeVisible();
await expect(findByText(network.Scope)).resolves.toBeVisible();
await expect(
findByText(network.IPAM?.Config[0].Gateway || 'not found', { exact: false })
).resolves.toBeVisible();
await expect(
findByText(network.IPAM?.Config[0].Subnet || 'not found', { exact: false })
).resolves.toBeVisible();
});
test(`System networks shouldn't show a delete button`, async () => {
const systemNetwork = getNetwork('bridge');
const { queryByText } = await renderComponent(true, systemNetwork);
const deleteButton = queryByText('Delete this network');
expect(deleteButton).toBeNull();
});
test('Non system networks should have a delete button', async () => {
const nonSystemNetwork = getNetwork('non system network');
const { queryByText } = await renderComponent(true, nonSystemNetwork);
const button = queryByText('Delete this network');
expect(button).toBeVisible();
});
async function renderComponent(isAdmin: boolean, network: DockerNetwork) {
const user = new UserViewModel({ Username: 'test', Role: isAdmin ? 1 : 2 });
const queries = render(
<UserContext.Provider value={{ user }}>
<NetworkDetailsTable
network={network}
onRemoveNetworkClicked={() => {}}
/>
</UserContext.Provider>
);
await expect(queries.findByText('Network details')).resolves.toBeVisible();
return queries;
}
function getNetwork(networkName: string): DockerNetwork {
return {
Attachable: false,
Containers: {
a761fcafdae3bdae42cf3702c8554b3e1b0334f85dd6b65b3584aff7246279e4: {
EndpointID:
'404afa6e25cede7c0fd70180777b662249cd83e40fa9a41aa593d2bac0fc5e18',
IPv4Address: '172.17.0.2/16',
IPv6Address: '',
MacAddress: '02:42:ac:11:00:02',
Name: 'portainer',
},
},
Driver: 'bridge',
IPAM: {
Config: [
{
Gateway: '172.17.0.1',
Subnet: '172.17.0.0/16',
},
],
Driver: 'default',
Options: null,
},
Id: '4c52a72e3772fdfb5823cf519b759e3f716e6d98cfb3bfef056e32c9c878329f',
Internal: false,
Name: networkName,
Options: {
'com.docker.network.bridge.default_bridge': 'true',
'com.docker.network.bridge.enable_icc': 'true',
'com.docker.network.bridge.enable_ip_masquerade': 'true',
'com.docker.network.bridge.host_binding_ipv4': '0.0.0.0',
'com.docker.network.bridge.name': 'docker0',
'com.docker.network.driver.mtu': '1500',
},
Portainer: {
ResourceControl: {
Id: 41,
ResourceId:
'85d807847e4a4adb374a2a105124eda607ef584bef2eb6acf8091f3afd8446db',
Type: 4,
UserAccesses: [
{
UserId: 2,
AccessLevel: 1,
},
],
TeamAccesses: [],
Ownership: ResourceControlOwnership.PUBLIC,
Public: true,
System: false,
},
},
Scope: 'local',
};
}

View File

@@ -1,119 +0,0 @@
import { Fragment } from 'react';
import DockerNetworkHelper from 'Docker/helpers/networkHelper';
import { Widget, WidgetBody, WidgetTitle } from '@/portainer/components/widget';
import { DetailsTable } from '@/portainer/components/DetailsTable';
import { Button } from '@/portainer/components/Button';
import { Authorized } from '@/portainer/hooks/useUser';
import { isSystemNetwork } from '../network.helper';
import { DockerNetwork, IPConfig } from '../types';
interface Props {
network: DockerNetwork;
onRemoveNetworkClicked: () => void;
}
export function NetworkDetailsTable({
network,
onRemoveNetworkClicked,
}: Props) {
const allowRemoveNetwork = !isSystemNetwork(network.Name);
const ipv4Configs: IPConfig[] = DockerNetworkHelper.getIPV4Configs(
network.IPAM?.Config
);
const ipv6Configs: IPConfig[] = DockerNetworkHelper.getIPV6Configs(
network.IPAM?.Config
);
return (
<div className="row">
<div className="col-lg-12 col-md-12 col-xs-12">
<Widget>
<WidgetTitle title="Network details" icon="fa-sitemap" />
<WidgetBody className="nopadding">
<DetailsTable dataCy="networkDetails-detailsTable">
{/* networkRowContent */}
<DetailsTable.Row label="Name">{network.Name}</DetailsTable.Row>
<DetailsTable.Row label="Id">
{network.Id}
{allowRemoveNetwork && (
<Authorized authorizations="DockerNetworkDelete">
<Button
dataCy="networkDetails-deleteNetwork"
size="xsmall"
color="danger"
onClick={() => onRemoveNetworkClicked()}
>
<i
className="fa fa-trash-alt space-right"
aria-hidden="true"
/>
Delete this network
</Button>
</Authorized>
)}
</DetailsTable.Row>
<DetailsTable.Row label="Driver">
{network.Driver}
</DetailsTable.Row>
<DetailsTable.Row label="Scope">{network.Scope}</DetailsTable.Row>
<DetailsTable.Row label="Attachable">
{String(network.Attachable)}
</DetailsTable.Row>
<DetailsTable.Row label="Internal">
{String(network.Internal)}
</DetailsTable.Row>
{/* IPV4 ConfigRowContent */}
{ipv4Configs.map((config) => (
<Fragment key={config.Subnet}>
<DetailsTable.Row
label={`IPV4 Subnet${getConfigDetails(config.Subnet)}`}
>
{`IPV4 Gateway${getConfigDetails(config.Gateway)}`}
</DetailsTable.Row>
<DetailsTable.Row
label={`IPV4 IP Range${getConfigDetails(config.IPRange)}`}
>
{`IPV4 Excluded IPs${getAuxiliaryAddresses(
config.AuxiliaryAddresses
)}`}
</DetailsTable.Row>
</Fragment>
))}
{/* IPV6 ConfigRowContent */}
{ipv6Configs.map((config) => (
<Fragment key={config.Subnet}>
<DetailsTable.Row
label={`IPV6 Subnet${getConfigDetails(config.Subnet)}`}
>
{`IPV6 Gateway${getConfigDetails(config.Gateway)}`}
</DetailsTable.Row>
<DetailsTable.Row
label={`IPV6 IP Range${getConfigDetails(config.IPRange)}`}
>
{`IPV6 Excluded IPs${getAuxiliaryAddresses(
config.AuxiliaryAddresses
)}`}
</DetailsTable.Row>
</Fragment>
))}
</DetailsTable>
</WidgetBody>
</Widget>
</div>
</div>
);
function getConfigDetails(configValue?: string) {
return configValue ? ` - ${configValue}` : '';
}
function getAuxiliaryAddresses(auxiliaryAddresses?: object) {
return auxiliaryAddresses
? ` - ${Object.values(auxiliaryAddresses).join(' - ')}`
: '';
}
}

View File

@@ -1,130 +0,0 @@
import { useState, useEffect } from 'react';
import { useRouter, useCurrentStateAndParams } from '@uirouter/react';
import { useQueryClient } from 'react-query';
import _ from 'lodash';
import { useEnvironmentId } from '@/portainer/hooks/useEnvironmentId';
import { PageHeader } from '@/portainer/components/PageHeader';
import { confirmDeletionAsync } from '@/portainer/services/modal.service/confirm';
import { AccessControlPanel } from '@/portainer/access-control/AccessControlPanel/AccessControlPanel';
import { ResourceControlType } from '@/portainer/access-control/types';
import { DockerContainer } from '@/docker/containers/types';
import { useNetwork, useDeleteNetwork } from '../queries';
import { isSystemNetwork } from '../network.helper';
import { useContainers } from '../../containers/queries';
import { DockerNetwork, NetworkContainer } from '../types';
import { NetworkDetailsTable } from './NetworkDetailsTable';
import { NetworkOptionsTable } from './NetworkOptionsTable';
import { NetworkContainersTable } from './NetworkContainersTable';
export function NetworkDetailsView() {
const router = useRouter();
const queryClient = useQueryClient();
const [networkContainers, setNetworkContainers] = useState<
NetworkContainer[]
>([]);
const {
params: { id: networkId, nodeName },
} = useCurrentStateAndParams();
const environmentId = useEnvironmentId();
const networkQuery = useNetwork(environmentId, networkId);
const deleteNetworkMutation = useDeleteNetwork();
const filters = {
network: [networkId],
};
const containersQuery = useContainers(environmentId, filters);
useEffect(() => {
if (networkQuery.data && containersQuery.data) {
setNetworkContainers(
filterContainersInNetwork(networkQuery.data, containersQuery.data)
);
}
}, [networkQuery.data, containersQuery.data]);
if (!networkQuery.data) {
return null;
}
return (
<>
<PageHeader
title="Network details"
breadcrumbs={[
{ link: 'docker.networks', label: 'Networks' },
{
link: 'docker.networks.network',
label: networkQuery.data.Name,
},
]}
/>
<NetworkDetailsTable
network={networkQuery.data}
onRemoveNetworkClicked={onRemoveNetworkClicked}
/>
<AccessControlPanel
onUpdateSuccess={() =>
queryClient.invalidateQueries([
'environments',
environmentId,
'docker',
'networks',
networkId,
])
}
resourceControl={networkQuery.data.Portainer?.ResourceControl}
resourceType={ResourceControlType.Network}
disableOwnershipChange={isSystemNetwork(networkQuery.data.Name)}
resourceId={networkId}
/>
<NetworkOptionsTable options={networkQuery.data.Options} />
<NetworkContainersTable
networkContainers={networkContainers}
nodeName={nodeName}
environmentId={environmentId}
networkId={networkId}
/>
</>
);
async function onRemoveNetworkClicked() {
const message = 'Do you want to delete the network?';
const confirmed = await confirmDeletionAsync(message);
if (confirmed) {
deleteNetworkMutation.mutate(
{ environmentId, networkId },
{
onSuccess: () => {
router.stateService.go('docker.networks');
},
}
);
}
}
function filterContainersInNetwork(
network: DockerNetwork,
containers: DockerContainer[]
) {
const containersInNetwork = _.compact(
containers.map((container) => {
const containerInNetworkResponse = network.Containers[container.Id];
if (containerInNetworkResponse) {
const containerInNetwork: NetworkContainer = {
...containerInNetworkResponse,
Id: container.Id,
};
return containerInNetwork;
}
return null;
})
);
return containersInNetwork;
}
}

View File

@@ -1,34 +0,0 @@
import { render } from '@/react-tools/test-utils';
import { NetworkOptions } from '../types';
import { NetworkOptionsTable } from './NetworkOptionsTable';
const options: NetworkOptions = {
'com.docker.network.bridge.default_bridge': 'true',
'com.docker.network.bridge.enable_icc': 'true',
'com.docker.network.bridge.enable_ip_masquerade': 'true',
'com.docker.network.bridge.host_binding_ipv4': '0.0.0.0',
'com.docker.network.bridge.name': 'docker0',
'com.docker.network.driver.mtu': '1500',
};
test('Network options values should be visible', async () => {
const { findByText, findAllByText } = render(
<NetworkOptionsTable options={options} />
);
await expect(findByText('Network options')).resolves.toBeVisible();
// expect to find three 'true' values for the first 3 options
const cells = await findAllByText('true');
expect(cells).toHaveLength(3);
await expect(
findByText(options['com.docker.network.bridge.host_binding_ipv4'])
).resolves.toBeVisible();
await expect(
findByText(options['com.docker.network.bridge.name'])
).resolves.toBeVisible();
await expect(
findByText(options['com.docker.network.driver.mtu'])
).resolves.toBeVisible();
});

View File

@@ -1,35 +0,0 @@
import { Widget, WidgetBody, WidgetTitle } from '@/portainer/components/widget';
import { DetailsTable } from '@/portainer/components/DetailsTable';
import { NetworkOptions } from '../types';
type Props = {
options: NetworkOptions;
};
export function NetworkOptionsTable({ options }: Props) {
const networkEntries = Object.entries(options);
if (networkEntries.length === 0) {
return null;
}
return (
<div className="row">
<div className="col-lg-12 col-md-12 col-xs-12">
<Widget>
<WidgetTitle title="Network options" icon="fa-cogs" />
<WidgetBody className="nopadding">
<DetailsTable dataCy="networkDetails-networkOptionsTable">
{networkEntries.map(([key, value]) => (
<DetailsTable.Row key={key} label={key}>
{value}
</DetailsTable.Row>
))}
</DetailsTable>
</WidgetBody>
</Widget>
</div>
</div>
);
}

View File

@@ -1,5 +0,0 @@
import { react2angular } from '@/react-tools/react2angular';
import { NetworkDetailsView } from './NetworkDetailsView';
export const NetworkDetailsViewAngular = react2angular(NetworkDetailsView, []);

View File

@@ -1,7 +0,0 @@
import angular from 'angular';
import { NetworkDetailsViewAngular } from './edit';
export const networksModule = angular
.module('portainer.docker.networks', [])
.component('networkDetailsView', NetworkDetailsViewAngular).name;

View File

@@ -1,5 +0,0 @@
const systemNetworks = ['host', 'bridge', 'none'];
export function isSystemNetwork(networkName: string) {
return systemNetworks.includes(networkName);
}

View File

@@ -1,71 +0,0 @@
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { EnvironmentId } from '@/portainer/environments/types';
import { ContainerId } from '../containers/types';
import { NetworkId, DockerNetwork } from './types';
type NetworkAction = 'connect' | 'disconnect' | 'create';
export async function getNetwork(
environmentId: EnvironmentId,
networkId: NetworkId
) {
try {
const { data: network } = await axios.get<DockerNetwork>(
buildUrl(environmentId, networkId)
);
return network;
} catch (e) {
throw parseAxiosError(e as Error, 'Unable to retrieve network details');
}
}
export async function deleteNetwork(
environmentId: EnvironmentId,
networkId: NetworkId
) {
try {
await axios.delete(buildUrl(environmentId, networkId));
return networkId;
} catch (e) {
throw parseAxiosError(e as Error, 'Unable to remove network');
}
}
export async function disconnectContainer(
environmentId: EnvironmentId,
networkId: NetworkId,
containerId: ContainerId
) {
try {
await axios.post(buildUrl(environmentId, networkId, 'disconnect'), {
Container: containerId,
Force: false,
});
return { networkId, environmentId };
} catch (e) {
throw parseAxiosError(
e as Error,
'Unable to disconnect container from network'
);
}
}
function buildUrl(
environmentId: EnvironmentId,
networkId?: NetworkId,
action?: NetworkAction
) {
let url = `endpoints/${environmentId}/docker/networks`;
if (networkId) {
url += `/${networkId}`;
}
if (action) {
url += `/${action}`;
}
return url;
}

View File

@@ -1,83 +0,0 @@
import { useQuery, useMutation, useQueryClient } from 'react-query';
import { EnvironmentId } from '@/portainer/environments/types';
import {
error as notifyError,
success as notifySuccess,
} from '@/portainer/services/notifications';
import { ContainerId } from '../containers/types';
import {
getNetwork,
deleteNetwork,
disconnectContainer,
} from './network.service';
import { NetworkId } from './types';
export function useNetwork(environmentId: EnvironmentId, networkId: NetworkId) {
return useQuery(
['environments', environmentId, 'docker', 'networks', networkId],
() => getNetwork(environmentId, networkId),
{
onError: (err) => {
notifyError('Failure', err as Error, 'Unable to get network');
},
}
);
}
export function useDeleteNetwork() {
return useMutation(
({
environmentId,
networkId,
}: {
environmentId: EnvironmentId;
networkId: NetworkId;
}) => deleteNetwork(environmentId, networkId),
{
onSuccess: (networkId) => {
notifySuccess('Network successfully removed', networkId);
},
onError: (err) => {
notifyError('Failure', err as Error, 'Unable to remove network');
},
}
);
}
export function useDisconnectContainer() {
const client = useQueryClient();
return useMutation(
({
containerId,
environmentId,
networkId,
}: {
containerId: ContainerId;
environmentId: EnvironmentId;
networkId: NetworkId;
}) => disconnectContainer(environmentId, networkId, containerId),
{
onSuccess: ({ networkId, environmentId }) => {
notifySuccess('Container successfully disconnected', networkId);
return client.invalidateQueries([
'environments',
environmentId,
'docker',
'networks',
networkId,
]);
},
onError: (err) => {
notifyError(
'Failure',
err as Error,
'Unable to disconnect container from network'
);
},
}
);
}

View File

@@ -1,50 +0,0 @@
import { ResourceControlViewModel } from '@/portainer/access-control/models/ResourceControlViewModel';
import { ContainerId } from '../containers/types';
export type IPConfig = {
Subnet: string;
Gateway: string;
IPRange?: string;
AuxiliaryAddresses?: Record<string, string>;
};
export type NetworkId = string;
export type NetworkOptions = Record<string, string>;
type IpamOptions = Record<string, string> | null;
export type NetworkResponseContainer = {
EndpointID: string;
IPv4Address: string;
IPv6Address: string;
MacAddress: string;
Name: string;
};
export interface NetworkContainer extends NetworkResponseContainer {
Id: ContainerId;
}
export type NetworkResponseContainers = Record<
ContainerId,
NetworkResponseContainer
>;
export interface DockerNetwork {
Name: string;
Id: NetworkId;
Driver: string;
Scope: string;
Attachable: boolean;
Internal: boolean;
IPAM: {
Config: IPConfig[];
Driver: string;
Options: IpamOptions;
};
Portainer: { ResourceControl?: ResourceControlViewModel };
Options: NetworkOptions;
Containers: NetworkResponseContainers;
}

View File

@@ -0,0 +1,133 @@
<rd-header>
<rd-header-title title-text="Network details"></rd-header-title>
<rd-header-content>
<a ui-sref="docker.networks">Networks</a> &gt; <a ui-sref="docker.networks.network({id: network.Id})">{{ network.Name }}</a>
</rd-header-content>
</rd-header>
<div class="row">
<div class="col-lg-12 col-md-12 col-xs-12">
<rd-widget>
<rd-widget-header icon="fa-sitemap" title-text="Network details"></rd-widget-header>
<rd-widget-body classes="no-padding">
<table class="table">
<tbody>
<tr>
<td>Name</td>
<td>{{ network.Name }}</td>
</tr>
<tr>
<td>ID</td>
<td>
{{ network.Id }}
<button authorization="DockerNetworkDelete" ng-if="allowRemove()" class="btn btn-xs btn-danger" ng-click="removeNetwork(network.Id)"
><i class="fa fa-trash-alt space-right" aria-hidden="true"></i>Delete this network</button
>
</td>
</tr>
<tr>
<td>Driver</td>
<td>{{ network.Driver }}</td>
</tr>
<tr>
<td>Scope</td>
<td>{{ network.Scope }}</td>
</tr>
<tr>
<td>Attachable</td>
<td>{{ network.Attachable }}</td>
</tr>
<tr>
<td>Internal</td>
<td>{{ network.Internal }}</td>
</tr>
<tr ng-if="network.IPAM.IPV4Configs.length > 0" ng-repeat-start="config in network.IPAM.IPV4Configs">
<td>IPV4 Subnet - {{ config.Subnet }}</td>
<td>IPV4 Gateway - {{ config.Gateway }}</td>
</tr>
<tr ng-if="network.IPAM.IPV4Configs.length > 0" ng-repeat-end>
<td>IPV4 IP range - {{ config.IPRange }}</td>
<td
>IPV4 Excluded Ips<span ng-repeat="auxAddress in config.AuxiliaryAddresses"> - {{ auxAddress }}</span></td
>
</tr>
<tr ng-if="network.IPAM.IPV6Configs.length > 0" ng-repeat-start="config in network.IPAM.IPV6Configs">
<td>IPV6 Subnet - {{ config.Subnet }}</td>
<td>IPV6 Gateway - {{ config.Gateway }}</td>
</tr>
<tr ng-if="network.IPAM.IPV6Configs.length > 0" ng-repeat-end>
<td>IPV6 IP range - {{ config.IPRange }}</td>
<td
>IPV6 Excluded Ips<span ng-repeat="auxAddress in config.AuxiliaryAddresses"> - {{ auxAddress }}</span></td
>
</tr>
</tbody>
</table>
</rd-widget-body>
</rd-widget>
</div>
</div>
<!-- access-control-panel -->
<access-control-panel
ng-if="network"
resource-id="network.Id"
resource-control="network.ResourceControl"
resource-type="resourceType"
disable-ownership-change="isSystemNetwork()"
on-update-success="(onUpdateResourceControlSuccess)"
>
</access-control-panel>
<!-- !access-control-panel -->
<div class="row" ng-if="!(network.Options | emptyobject)">
<div class="col-lg-12 col-md-12 col-xs-12">
<rd-widget>
<rd-widget-header icon="fa-cogs" title-text="Network options"></rd-widget-header>
<rd-widget-body classes="no-padding">
<table class="table">
<tbody>
<tr ng-repeat="(key, value) in network.Options">
<td>{{ key }}</td>
<td>{{ value }}</td>
</tr>
</tbody>
</table>
</rd-widget-body>
</rd-widget>
</div>
</div>
<div class="row" ng-if="containersInNetwork.length > 0">
<div class="col-lg-12 col-md-12 col-xs-12">
<rd-widget>
<rd-widget-header icon="fa-server" title-text="Containers in network"></rd-widget-header>
<rd-widget-body classes="no-padding">
<table class="table">
<thead>
<th>Container Name</th>
<th>IPv4 Address</th>
<th>IPv6 Address</th>
<th>MacAddress</th>
<th authorization="DockerNetworkDisconnect">Actions</th>
</thead>
<tbody>
<tr ng-repeat="container in containersInNetwork">
<td
><a ui-sref="docker.containers.container({ id: container.Id, nodeName: nodeName })">{{ container.Name }}</a></td
>
<td>{{ container.IPv4Address || '-' }}</td>
<td>{{ container.IPv6Address || '-' }}</td>
<td>{{ container.MacAddress || '-' }}</td>
<td authorization="DockerNetworkDisconnect">
<button type="button" class="btn btn-xs btn-danger" ng-click="containerLeaveNetwork(network, container)"
><i class="fa fa-trash-alt space-right" aria-hidden="true"></i>Leave Network</button
>
</td>
</tr>
</tbody>
</table>
</rd-widget-body>
</rd-widget>
</div>
</div>

View File

@@ -0,0 +1,120 @@
import { ResourceControlType } from '@/portainer/access-control/types';
import DockerNetworkHelper from 'Docker/helpers/networkHelper';
angular.module('portainer.docker').controller('NetworkController', [
'$scope',
'$state',
'$transition$',
'$filter',
'NetworkService',
'Container',
'Notifications',
'HttpRequestHelper',
'NetworkHelper',
function ($scope, $state, $transition$, $filter, NetworkService, Container, Notifications, HttpRequestHelper, NetworkHelper) {
$scope.resourceType = ResourceControlType.Network;
$scope.onUpdateResourceControlSuccess = function () {
$state.reload();
};
$scope.removeNetwork = function removeNetwork() {
NetworkService.remove($transition$.params().id, $transition$.params().id)
.then(function success() {
Notifications.success('Network removed', $transition$.params().id);
$state.go('docker.networks', {});
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to remove network');
});
};
$scope.containerLeaveNetwork = function containerLeaveNetwork(network, container) {
HttpRequestHelper.setPortainerAgentTargetHeader(container.NodeName);
NetworkService.disconnectContainer($transition$.params().id, container.Id, false)
.then(function success() {
Notifications.success('Container left network', $transition$.params().id);
$state.go('docker.networks.network', { id: network.Id }, { reload: true });
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to disconnect container from network');
});
};
$scope.isSystemNetwork = function () {
return $scope.network && NetworkHelper.isSystemNetwork($scope.network);
};
$scope.allowRemove = function () {
return !$scope.isSystemNetwork();
};
function filterContainersInNetwork(network, containers) {
var containersInNetwork = [];
containers.forEach(function (container) {
var containerInNetwork = network.Containers[container.Id];
if (containerInNetwork) {
containerInNetwork.Id = container.Id;
// Name is not available in Docker 1.9
if (!containerInNetwork.Name) {
containerInNetwork.Name = $filter('trimcontainername')(container.Names[0]);
}
containersInNetwork.push(containerInNetwork);
}
});
$scope.containersInNetwork = containersInNetwork;
}
function getContainersInNetwork(network) {
var apiVersion = $scope.applicationState.endpoint.apiVersion;
if (network.Containers) {
if (apiVersion < 1.24) {
Container.query(
{},
function success(data) {
var containersInNetwork = data.filter(function filter(container) {
if (container.HostConfig.NetworkMode === network.Name) {
return container;
}
});
filterContainersInNetwork(network, containersInNetwork);
},
function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve containers in network');
}
);
} else {
Container.query(
{
filters: { network: [$transition$.params().id] },
},
function success(data) {
filterContainersInNetwork(network, data);
},
function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve containers in network');
}
);
}
}
}
function initView() {
var nodeName = $transition$.params().nodeName;
HttpRequestHelper.setPortainerAgentTargetHeader(nodeName);
$scope.nodeName = nodeName;
NetworkService.network($transition$.params().id)
.then(function success(data) {
$scope.network = data;
getContainersInNetwork(data);
$scope.network.IPAM.IPV4Configs = DockerNetworkHelper.getIPV4Configs($scope.network.IPAM.Config);
$scope.network.IPAM.IPV6Configs = DockerNetworkHelper.getIPV6Configs($scope.network.IPAM.Config);
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve network info');
});
}
initView();
},
]);

View File

@@ -1,94 +0,0 @@
import { useEffect, useState } from 'react';
import { FormControl } from '@/portainer/components/form-components/FormControl';
import { Select } from '@/portainer/components/form-components/Input';
import { useSettings } from '@/portainer/settings/settings.service';
import { r2a } from '@/react-tools/react2angular';
interface Props {
value: number;
onChange(value: number): void;
isDefaultHidden?: boolean;
label?: string;
tooltip?: string;
}
export const checkinIntervalOptions = [
{ label: 'Use default interval', value: 0 },
{
label: '5 seconds',
value: 5,
},
{
label: '10 seconds',
value: 10,
},
{
label: '30 seconds',
value: 30,
},
{ label: '5 minutes', value: 300 },
{ label: '1 hour', value: 3600 },
{ label: '1 day', value: 86400 },
];
export function EdgeCheckinIntervalField({
value,
onChange,
isDefaultHidden = false,
label = 'Poll frequency',
tooltip = 'Interval used by this Edge agent to check in with the Portainer instance. Affects Edge environment management and Edge compute features.',
}: Props) {
const options = useOptions(isDefaultHidden);
return (
<FormControl inputId="edge_checkin" label={label} tooltip={tooltip}>
<Select
value={value}
onChange={(e) => {
onChange(parseInt(e.currentTarget.value, 10));
}}
options={options}
/>
</FormControl>
);
}
export const EdgeCheckinIntervalFieldAngular = r2a(EdgeCheckinIntervalField, [
'value',
'onChange',
]);
function useOptions(isDefaultHidden: boolean) {
const [options, setOptions] = useState(checkinIntervalOptions);
const settingsQuery = useSettings(
(settings) => settings.EdgeAgentCheckinInterval
);
useEffect(() => {
if (isDefaultHidden) {
setOptions(checkinIntervalOptions.filter((option) => option.value !== 0));
}
if (!isDefaultHidden && typeof settingsQuery.data !== 'undefined') {
setOptions((options) => {
let label = `${settingsQuery.data} seconds`;
const option = options.find((o) => o.value === settingsQuery.data);
if (option) {
label = option.label;
}
return [
{
value: 0,
label: `Use default interval (${label})`,
},
...options.slice(1),
];
});
}
}, [settingsQuery.data, setOptions, isDefaultHidden]);
return options;
}

View File

@@ -1,5 +1,4 @@
import _ from 'lodash-es';
import { confirmAsync } from '@/portainer/services/modal.service/confirm';
export class EdgeGroupFormController {
/* @ngInject */
@@ -18,7 +17,6 @@ export class EdgeGroupFormController {
};
this.associateEndpoint = this.associateEndpoint.bind(this);
this.dissociateEndpointAsync = this.dissociateEndpointAsync.bind(this);
this.dissociateEndpoint = this.dissociateEndpoint.bind(this);
this.getDynamicEndpointsAsync = this.getDynamicEndpointsAsync.bind(this);
this.getDynamicEndpoints = this.getDynamicEndpoints.bind(this);
@@ -41,29 +39,6 @@ export class EdgeGroupFormController {
}
dissociateEndpoint(endpoint) {
return this.$async(this.dissociateEndpointAsync, endpoint);
}
async dissociateEndpointAsync(endpoint) {
const confirmed = await confirmAsync({
title: 'Confirm action',
message: 'Removing the environment from this group will remove its corresponding edge stacks',
buttons: {
cancel: {
label: 'Cancel',
className: 'btn-default',
},
confirm: {
label: 'Confirm',
className: 'btn-primary',
},
},
});
if (!confirmed) {
return;
}
this.model.Endpoints = _.filter(this.model.Endpoints, (id) => id !== endpoint.Id);
}

View File

@@ -1,9 +1,7 @@
import angular from 'angular';
import { EdgeCheckinIntervalFieldAngular } from './EdgeCheckInIntervalField';
import { EdgeScriptFormAngular } from './EdgeScriptForm';
export const componentsModule = angular
.module('app.edge.components', [])
.component('edgeCheckinIntervalField', EdgeCheckinIntervalFieldAngular)
.component('edgeScriptForm', EdgeScriptFormAngular).name;

View File

@@ -68,11 +68,9 @@
<tr>
<td>Creation</td>
<td>
<span ng-if="ctrl.application.ApplicationOwner" style="margin-right: 5px" data-cy="k8sAppDetail-owner">
<i class="fas fa-user"></i> {{ ctrl.application.ApplicationOwner }}
</span>
<span ng-if="ctrl.application.ApplicationOwner" style="margin-right: 5px"> <i class="fas fa-user"></i> {{ ctrl.application.ApplicationOwner }} </span>
<span> <i class="fas fa-clock"></i> {{ ctrl.application.CreationDate | getisodate }}</span>
<span ng-if="ctrl.application.ApplicationOwner" data-cy="k8sAppDetail-creationMethod">
<span ng-if="ctrl.application.ApplicationOwner">
<i class="fa fa-file-code space-left space-right" aria-hidden="true"></i> Deployed from {{ ctrl.state.appType }}</span
>
</td>

View File

@@ -1,15 +0,0 @@
import { ReactNode } from 'react';
interface Props {
children?: ReactNode;
label: string;
}
export function DetailsRow({ label, children }: Props) {
return (
<tr>
<td>{label}</td>
{children && <td data-cy={`detailsTable-${label}Value`}>{children}</td>}
</tr>
);
}

View File

@@ -1,33 +0,0 @@
import { Meta, Story } from '@storybook/react';
import { DetailsTable } from './DetailsTable';
import { DetailsRow } from './DetailsRow';
type Args = {
key1: string;
val1: string;
key2: string;
val2: string;
};
export default {
component: DetailsTable,
title: 'Components/Tables/DetailsTable',
} as Meta;
function Template({ key1, val1, key2, val2 }: Args) {
return (
<DetailsTable>
<DetailsRow label={key1}>{val1}</DetailsRow>
<DetailsRow label={key2}>{val2}</DetailsRow>
</DetailsTable>
);
}
export const Default: Story<Args> = Template.bind({});
Default.args = {
key1: 'Name',
val1: 'My Cool App',
key2: 'Id',
val2: 'dmsjs1532',
};

View File

@@ -1,24 +0,0 @@
import { render } from '@/react-tools/test-utils';
import { DetailsTable } from './index';
// should display child row elements
test('should display child row elements', () => {
const person = {
name: 'Bob',
id: 'dmsjs1532',
};
const { queryByText } = render(
<DetailsTable>
<DetailsTable.Row label="Name">{person.name}</DetailsTable.Row>
<DetailsTable.Row label="Id">{person.id}</DetailsTable.Row>
</DetailsTable>
);
const nameRow = queryByText(person.name);
expect(nameRow).toBeVisible();
const idRow = queryByText(person.id);
expect(idRow).toBeVisible();
});

View File

@@ -1,27 +0,0 @@
import { PropsWithChildren } from 'react';
type Props = {
headers?: string[];
dataCy?: string;
};
export function DetailsTable({
headers = [],
dataCy,
children,
}: PropsWithChildren<Props>) {
return (
<table className="table" data-cy={dataCy}>
{headers.length > 0 && (
<thead>
<tr>
{headers.map((header) => (
<th key={header}>{header}</th>
))}
</tr>
</thead>
)}
<tbody>{children}</tbody>
</table>
);
}

View File

@@ -1,13 +0,0 @@
import { DetailsTable as MainComponent } from './DetailsTable';
import { DetailsRow } from './DetailsRow';
interface DetailsTableSubcomponents {
Row: typeof DetailsRow;
}
const DetailsTable = MainComponent as typeof MainComponent &
DetailsTableSubcomponents;
DetailsTable.Row = DetailsRow;
export { DetailsTable };

View File

@@ -2,17 +2,6 @@ import { react2angular } from '@/react-tools/react2angular';
import { MinPasswordLen } from '../helpers/password';
function PasswordCombination() {
return (
<ul className="text-muted">
<li className="ml-8"> Special characters </li>
<li className="ml-8"> Lower case characters </li>
<li className="ml-8"> Upper case characters </li>
<li className="ml-8"> Numeric characters </li>
</ul>
);
}
export function ForcePasswordUpdateHint() {
return (
<div>
@@ -25,11 +14,8 @@ export function ForcePasswordUpdateHint() {
</p>
<p className="text-muted">
The password must be at least {MinPasswordLen} characters long,
including a combination of one character of three of the below:
The password must be at least {MinPasswordLen} characters long.
</p>
<PasswordCombination />
</div>
);
}
@@ -42,12 +28,9 @@ export function PasswordCheckHint() {
{' '}
</i>
<span>
The password must be at least {MinPasswordLen} characters long,
including a combination of one character of three of the below:
The password must be at least {MinPasswordLen} characters long.
</span>
</p>
<PasswordCombination />
</div>
);
}

View File

@@ -4,19 +4,6 @@ function lengthCheck(password: string) {
return password.length >= MinPasswordLen;
}
function comboCheck(password: string) {
let count = 0;
const regexps = [/[a-z]/, /[A-Z]/, /[0-9]/, /[\W_]/];
regexps.forEach((re) => {
if (password.match(re) != null) {
count += 1;
}
});
return count >= 3;
}
export function StrengthCheck(password: string) {
return lengthCheck(password) && comboCheck(password);
return lengthCheck(password);
}

View File

@@ -148,7 +148,7 @@ function useEnvironmentTagNames(tagIds?: TagId[]) {
);
});
if (tags && tags.length > 0) {
if (tags) {
return tags.join(', ');
}

View File

@@ -58,18 +58,14 @@ export function parseAxiosError(
if ('isAxiosError' in err) {
const { error, details } = parseError(err as AxiosError);
resultErr = error;
if (msg && details) {
resultMsg = `${msg}: ${details}`;
} else {
resultMsg = msg || details;
}
resultMsg = msg ? `${msg}: ${details}` : details;
}
return new PortainerError(resultMsg, resultErr);
}
function defaultErrorParser(axiosError: AxiosError) {
const message = axiosError.response?.data.message || '';
const message = axiosError.response?.data.message;
const details = axiosError.response?.data.details || message;
const error = new Error(message);
return { error, details };

View File

@@ -1,8 +1,8 @@
<div class="form-group">
<label for="ldap_password" class="col-sm-3 col-lg-2 control-label text-left">
Connectivity check
<i class="fa fa-check green-icon ml-2" ng-if="$ctrl.state.successfulConnectivityCheck"></i>
<i class="fa fa-times red-icon ml-2" ng-if="$ctrl.state.failedConnectivityCheck"></i>
<i class="fa fa-check green-icon m-l-5" ng-if="$ctrl.state.successfulConnectivityCheck"></i>
<i class="fa fa-times red-icon m-l-5" ng-if="$ctrl.state.failedConnectivityCheck"></i>
</label>
<div class="col-sm-9 col-lg-10">
<button

View File

@@ -4,7 +4,7 @@
<rd-widget ng-repeat="config in $ctrl.settings.AdminGroupSearchSettings | limitTo: (1 - $ctrl.settings.AdminGroupSearchSettings)">
<rd-widget-body>
<div class="form-group mb-3" ng-if="$index > 0">
<div class="form-group m-b-10" ng-if="$index > 0">
<span class="col-sm-12 text-muted small"> Extra search configuration </span>
</div>
@@ -72,7 +72,7 @@
</rd-widget-body>
</rd-widget>
<div class="form-group mt-3">
<div class="form-group m-t-10">
<div class="col-sm-12">
<button
class="label label-default interactive no-border"
@@ -84,7 +84,7 @@
<i class="fa fa-plus-circle" aria-hidden="true"></i> add group search configuration
</button>
</div>
<div class="col-sm-12 mt-3">
<div class="col-sm-12 m-t-10">
<button
class="btn btm-sm btn-primary"
type="button"
@@ -96,14 +96,14 @@
>
Fetch Admin Group(s)
</button>
<span ng-if="$ctrl.groups && $ctrl.groups.length === 0" class="ml-5"> <i class="fa fa-exclamation-triangle text-warning" aria-hidden="true"></i> No groups found</span>
<span ng-if="$ctrl.groups && $ctrl.groups.length === 0" class="m-l-30"> <i class="fa fa-exclamation-triangle text-warning" aria-hidden="true"></i> No groups found</span>
</div>
</div>
<div class="form-group">
<div class="col-sm-12">
<label for="admin-auto-populate" class="control-label text-left text-muted" ng-class="{ 'text-muted': !$ctrl.enableAssignAdminGroup }"> Assign admin rights to group(s) </label>
<label class="switch ml-7" ng-class="{ 'business limited': $ctrl.isLimitedFeatureSelfContained }">
<label class="switch m-l-20" ng-class="{ 'business limited': $ctrl.isLimitedFeatureSelfContained }">
<input id="admin-auto-populate" ng-disabled="!$ctrl.enableAssignAdminGroup" name="admin-auto-populate" type="checkbox" ng-model="$ctrl.settings.AdminAutoPopulate" /><i></i>
</label>
</div>
@@ -113,7 +113,7 @@
<div class="col-sm-12">
<label for="group-access" class="control-label text-left"> Select Group(s) </label>
<span
class="ml-7"
class="m-l-20"
isteven-multi-select
ng-if="$ctrl.enableAssignAdminGroup"
input-model="$ctrl.groups"

View File

@@ -10,7 +10,7 @@
<div class="form-group">
<div class="col-sm-12 small text-muted">
<p>
<i class="fa fa-info-circle blue-icon mr-1" aria-hidden="true"></i>
<i class="fa fa-info-circle blue-icon m-r-2" aria-hidden="true"></i>
You can configure multiple LDAP Servers for authentication fallback. Make sure all servers are using the same configuration (i.e. if TLS is enabled, they should all use the
same certificates).
</p>
@@ -18,7 +18,7 @@
</div>
<div class="form-group">
<label for="ldap_url" class="col-sm-3 col-lg-2 control-label text-left flex flex-wrap">
<label for="ldap_url" class="col-sm-3 col-lg-2 control-label text-left dispay-flex flex-wrap">
LDAP Server
<button
type="button"
@@ -32,7 +32,7 @@
</button>
</label>
<div class="col-sm-9 col-lg-10">
<div class="mb-3 flex" ng-repeat="url in $ctrl.settings.URLs track by $index">
<div class="m-b-10 dispay-flex" ng-repeat="url in $ctrl.settings.URLs track by $index">
<input type="text" class="form-control" id="ldap_url" ng-model="$ctrl.settings.URLs[$index]" placeholder="e.g. 10.0.0.10:389 or myldap.domain.tld:389" required />
<button ng-if="$index > 0" class="btn btn-sm btn-danger" type="button" ng-click="$ctrl.removeLDAPUrl($index)">
<i class="fa fa-trash" aria-hidden="true"></i>
@@ -97,39 +97,35 @@
connectivity-check="$ctrl.connectivityCheck"
></ldap-connectivity-check>
<div class="space-y-10">
<ldap-custom-user-search
class="block"
settings="$ctrl.settings.SearchSettings"
on-search-click="($ctrl.onSearchUsersClick)"
limited-feature-id="$ctrl.limitedFeatureId"
></ldap-custom-user-search>
<ldap-custom-user-search
class="m-r-5"
settings="$ctrl.settings.SearchSettings"
on-search-click="($ctrl.onSearchUsersClick)"
limited-feature-id="$ctrl.limitedFeatureId"
></ldap-custom-user-search>
<ldap-custom-group-search
class="m-r-5"
settings="$ctrl.settings.GroupSearchSettings"
on-search-click="($ctrl.onSearchGroupsClick)"
limited-feature-id="$ctrl.limitedFeatureId"
></ldap-custom-group-search>
<ldap-custom-group-search
class="block"
settings="$ctrl.settings.GroupSearchSettings"
on-search-click="($ctrl.onSearchGroupsClick)"
limited-feature-id="$ctrl.limitedFeatureId"
></ldap-custom-group-search>
<ldap-custom-admin-group
class="m-r-5"
settings="$ctrl.settings"
on-search-click="($ctrl.onSearchAdminGroupsClick)"
selected-admin-groups="$ctrl.selectedAdminGroups"
default-admin-group-search-filter="'(objectClass=groupOfNames)'"
limited-feature-id="$ctrl.limitedFeatureId"
is-limited-feature-self-contained="true"
></ldap-custom-admin-group>
<ldap-custom-admin-group
class="block"
settings="$ctrl.settings"
on-search-click="($ctrl.onSearchAdminGroupsClick)"
selected-admin-groups="$ctrl.selectedAdminGroups"
default-admin-group-search-filter="'(objectClass=groupOfNames)'"
limited-feature-id="$ctrl.limitedFeatureId"
is-limited-feature-self-contained="true"
></ldap-custom-admin-group>
<ldap-settings-test-login
class="block"
settings="$ctrl.settings"
limited-feature-id="$ctrl.limitedFeatureId"
show-be-indicator-if-needed="true"
is-limited-feature-self-contained="true"
></ldap-settings-test-login>
</div>
<ldap-settings-test-login
settings="$ctrl.settings"
limited-feature-id="$ctrl.limitedFeatureId"
show-be-indicator-if-needed="true"
is-limited-feature-self-contained="true"
></ldap-settings-test-login>
<save-auth-settings-button
on-save-settings="($ctrl.onSaveSettings)"

View File

@@ -2,11 +2,10 @@ import { Formik, Form } from 'formik';
import { Switch } from '@/portainer/components/form-components/SwitchField/Switch';
import { FormControl } from '@/portainer/components/form-components/FormControl';
import { Select } from '@/portainer/components/form-components/Input/Select';
import { Widget, WidgetBody, WidgetTitle } from '@/portainer/components/widget';
import { LoadingButton } from '@/portainer/components/Button/LoadingButton';
import { TextTip } from '@/portainer/components/Tip/TextTip';
import { EdgeCheckinIntervalField } from '@/edge/components/EdgeCheckInIntervalField';
import { FormSectionTitle } from '@/portainer/components/form-components/FormSectionTitle';
import { Settings } from '../types';
@@ -24,18 +23,39 @@ interface Props {
onSubmit(values: FormValues): void;
}
const checkinIntervalOptions = [
{
value: 5,
label: '5 seconds',
},
{
value: 10,
label: '10 seconds',
},
{
value: 30,
label: '30 seconds',
},
];
export function EdgeComputeSettings({ settings, onSubmit }: Props) {
if (!settings) {
return null;
}
const initialValues: FormValues = {
EdgeAgentCheckinInterval: settings.EdgeAgentCheckinInterval,
EnableEdgeComputeFeatures: settings.EnableEdgeComputeFeatures,
EnforceEdgeID: settings.EnforceEdgeID,
};
return (
<div className="row">
<Widget>
<WidgetTitle icon="fa-laptop" title="Edge Compute settings" />
<WidgetBody>
<Formik
initialValues={settings}
initialValues={initialValues}
enableReinitialize
validationSchema={() => validationSchema()}
onSubmit={onSubmit}
@@ -56,7 +76,26 @@ export function EdgeComputeSettings({ settings, onSubmit }: Props) {
noValidate
>
<FormControl
inputId="edge_enable"
inputId="edge_checkin"
label="Edge agent default poll frequency"
size="medium"
tooltip="Interval used by default by each Edge agent to check in with the Portainer instance. Affects Edge environment management and Edge compute features."
errors={errors.EdgeAgentCheckinInterval}
>
<Select
value={values.EdgeAgentCheckinInterval}
onChange={(e) =>
setFieldValue(
'EdgeAgentCheckinInterval',
parseInt(e.currentTarget.value, 10)
)
}
options={checkinIntervalOptions}
/>
</FormControl>
<FormControl
inputId="edge_checkin"
label="Enable Edge Compute features"
size="medium"
errors={errors.EnableEdgeComputeFeatures}
@@ -95,18 +134,6 @@ export function EdgeComputeSettings({ settings, onSubmit }: Props) {
/>
</FormControl>
<FormSectionTitle>Check-in Intervals</FormSectionTitle>
<EdgeCheckinIntervalField
value={values.EdgeAgentCheckinInterval}
onChange={(value) =>
setFieldValue('EdgeAgentCheckinInterval', value)
}
isDefaultHidden
label="Edge agent default poll frequency"
tooltip="Interval used by default by each Edge agent to check in with the Portainer instance. Affects Edge environment management and Edge compute features."
/>
<div className="form-group">
<div className="col-sm-12">
<LoadingButton

View File

@@ -1,9 +1,8 @@
import { r2a } from '@/react-tools/react2angular';
import { Settings } from '../settings.service';
import { EdgeComputeSettings } from './EdgeComputeSettings';
import { AutomaticEdgeEnvCreation } from './AutomaticEdgeEnvCreation';
import { Settings } from './types';
interface Props {
settings: Settings;

View File

@@ -31,6 +31,7 @@ export interface Settings {
AuthenticationMethod: AuthenticationMethod;
SnapshotInterval: string;
TemplatesURL: string;
EdgeAgentCheckinInterval: number;
EnableEdgeComputeFeatures: boolean;
UserSessionTimeout: string;
KubeconfigExpiry: string;
@@ -41,10 +42,6 @@ export interface Settings {
EnforceEdgeID: boolean;
AgentSecret: string;
EdgePortainerUrl: string;
EdgeAgentCheckinInterval: number;
EdgePingInterval: number;
EdgeSnapshotInterval: number;
EdgeCommandInterval: number;
}
export async function getSettings() {

View File

@@ -25,7 +25,7 @@
</p>
<div>
<button type="button" class="btn btn-sm btn-primary" limited-feature-dir="{{::$ctrl.limitedFeature}}" limited-feature-class="limited-be" limited-feature-disabled>
<button type="button" class="btn btn-sm btn-primary m-r-10" limited-feature-dir="{{::$ctrl.limitedFeature}}" limited-feature-class="limited-be" limited-feature-disabled>
<i class="fa fa-download space-right" aria-hidden="true"></i>Export as CSV
</button>
<be-feature-indicator feature="$ctrl.limitedFeature"></be-feature-indicator>

View File

@@ -25,7 +25,7 @@
</p>
<div>
<button type="button" class="btn btn-sm btn-primary" limited-feature-dir="{{::$ctrl.limitedFeature}}" limited-feature-class="limited-be" limited-feature-disabled
<button type="button" class="btn btn-sm btn-primary m-r-10" limited-feature-dir="{{::$ctrl.limitedFeature}}" limited-feature-class="limited-be" limited-feature-disabled
><i class="fa fa-download space-right" aria-hidden="true"></i>Export as CSV
</button>
<be-feature-indicator feature="$ctrl.limitedFeature"></be-feature-indicator>

View File

@@ -23,9 +23,6 @@ angular
Authentication,
StateManager
) {
$scope.onChangeCheckInInterval = onChangeCheckInInterval;
$scope.setFieldValue = setFieldValue;
$scope.state = {
EnvironmentType: $state.params.isEdgeDevice ? 'edge_agent' : 'agent',
PlatformType: 'linux',
@@ -33,6 +30,24 @@ angular
deploymentTab: 0,
allowCreateTag: Authentication.isAdmin(),
isEdgeDevice: $state.params.isEdgeDevice,
availableEdgeAgentCheckinOptions: [
{ key: 'Use default interval', value: 0 },
{
key: '5 seconds',
value: 5,
},
{
key: '10 seconds',
value: 10,
},
{
key: '30 seconds',
value: 30,
},
{ key: '5 minutes', value: 300 },
{ key: '1 hour', value: 3600 },
{ key: '1 day', value: 86400 },
],
};
const agentVersion = StateManager.getState().application.version;
@@ -56,7 +71,7 @@ angular
AzureTenantId: '',
AzureAuthenticationKey: '',
TagIds: [],
CheckinInterval: 0,
CheckinInterval: $scope.state.availableEdgeAgentCheckinOptions[0].value,
};
$scope.copyAgentCommand = function () {
@@ -105,19 +120,6 @@ angular
}
}
function onChangeCheckInInterval(value) {
setFieldValue('EdgeCheckinInterval', value);
}
function setFieldValue(name, value) {
return $scope.$evalAsync(() => {
$scope.formValues = {
...$scope.formValues,
[name]: value,
};
});
}
$scope.addDockerEndpoint = function () {
var name = $scope.formValues.Name;
var URL = $filter('stripprotocol')($scope.formValues.URL);
@@ -318,7 +320,7 @@ angular
$scope.availableTags = data.tags;
const settings = data.settings;
$scope.state.availableEdgeAgentCheckinOptions[0].key += ` (${settings.EdgeAgentCheckinInterval} seconds)`;
$scope.agentSecret = settings.AgentSecret;
})
.catch(function error(err) {

View File

@@ -315,10 +315,24 @@
</div>
</div>
<!-- !portainer-instance-input -->
<div>
<div class="col-sm-12 form-section-title"> Check-in Intervals </div>
<edge-checkin-interval-field value="formValues.EdgeCheckinInterval" on-change="(onChangeCheckInInterval)"></edge-checkin-interval-field>
<div class="form-group">
<label for="edge_checkin" class="col-sm-2 control-label text-left">
Poll frequency
<portainer-tooltip
position="bottom"
message="Interval used by this Edge agent to check in with the Portainer instance. Affects Edge environment management and Edge compute features."
>
</portainer-tooltip>
</label>
<div class="col-sm-10">
<select
id="edge_checkin"
class="form-control"
ng-model="formValues.CheckinInterval"
ng-options="+(opt.value) as opt.key for opt in state.availableEdgeAgentCheckinOptions"
data-cy="endpointCreate-pollFrequencySelect"
></select>
</div>
</div>
</div>
<!-- endpoint-public-url-input -->

View File

@@ -137,12 +137,23 @@
<input type="text" class="form-control" id="endpoint_public_url" ng-model="endpoint.PublicURL" placeholder="e.g. 10.0.0.10 or mydocker.mydomain.com" />
</div>
</div>
<div ng-if="endpoint && state.edgeEndpoint">
<div class="col-sm-12 form-section-title"> Check-in Intervals </div>
<edge-checkin-interval-field value="endpoint.EdgeCheckinInterval" on-change="(onChangeCheckInInterval)"></edge-checkin-interval-field>
<div class="form-group" ng-if="state.edgeEndpoint">
<label for="edge_checkin" class="col-sm-2 control-label text-left">
Poll frequency
<portainer-tooltip
position="bottom"
message="Interval used by this Edge agent to check in with the Portainer instance. Affects Edge environment management and Edge compute features."
></portainer-tooltip>
</label>
<div class="col-sm-10">
<select
id="edge_checkin"
class="form-control"
ng-model="endpoint.EdgeCheckinInterval"
ng-options="+(opt.value) as opt.key for opt in state.availableEdgeAgentCheckinOptions"
></select>
</div>
</div>
<!-- !endpoint-public-url-input -->
<azure-endpoint-config
ng-if="state.azureEndpoint"

View File

@@ -5,8 +5,6 @@ import { PortainerEndpointTypes } from '@/portainer/models/endpoint/models';
import { EndpointSecurityFormData } from '@/portainer/components/endpointSecurity/porEndpointSecurityModel';
import EndpointHelper from '@/portainer/helpers/endpointHelper';
import { getAMTInfo } from 'Portainer/hostmanagement/open-amt/open-amt.service';
import { confirmAsync } from '@/portainer/services/modal.service/confirm';
import { isEdgeEnvironment } from '@/portainer/environments/utils';
angular.module('portainer.app').controller('EndpointController', EndpointController);
@@ -26,9 +24,6 @@ function EndpointController(
SettingsService,
ModalService
) {
$scope.onChangeCheckInInterval = onChangeCheckInInterval;
$scope.setFieldValue = setFieldValue;
$scope.state = {
uploadInProgress: false,
actionInProgress: false,
@@ -38,6 +33,24 @@ function EndpointController(
edgeEndpoint: false,
edgeAssociated: false,
allowCreate: Authentication.isAdmin(),
availableEdgeAgentCheckinOptions: [
{ key: 'Use default interval', value: 0 },
{
key: '5 seconds',
value: 5,
},
{
key: '10 seconds',
value: 10,
},
{
key: '30 seconds',
value: 30,
},
{ key: '5 minutes', value: 300 },
{ key: '1 hour', value: 3600 },
{ key: '1 day', value: 86400 },
],
allowSelfSignedCerts: true,
showAMTInfo: false,
};
@@ -94,20 +107,7 @@ function EndpointController(
}
}
function onChangeCheckInInterval(value) {
setFieldValue('EdgeCheckinInterval', value);
}
function setFieldValue(name, value) {
return $scope.$evalAsync(() => {
$scope.endpoint = {
...$scope.endpoint,
[name]: value,
};
});
}
$scope.updateEndpoint = async function () {
$scope.updateEndpoint = function () {
var endpoint = $scope.endpoint;
var securityData = $scope.formValues.SecurityFormData;
var TLS = securityData.TLS;
@@ -115,32 +115,12 @@ function EndpointController(
var TLSSkipVerify = TLS && (TLSMode === 'tls_client_noca' || TLSMode === 'tls_only');
var TLSSkipClientVerify = TLS && (TLSMode === 'tls_ca' || TLSMode === 'tls_only');
if (isEdgeEnvironment(endpoint.Type) && _.difference($scope.initialTagIds, endpoint.TagIds).length > 0) {
let confirmed = await confirmAsync({
title: 'Confirm action',
message: 'Removing tags from this environment will remove the corresponding edge stacks when dynamic grouping is being used',
buttons: {
cancel: {
label: 'Cancel',
className: 'btn-default',
},
confirm: {
label: 'Confirm',
className: 'btn-primary',
},
},
});
if (!confirmed) {
return;
}
}
var payload = {
Name: endpoint.Name,
PublicURL: endpoint.PublicURL,
GroupID: endpoint.GroupId,
TagIds: endpoint.TagIds,
EdgeCheckinInterval: endpoint.EdgeCheckinInterval,
TLS: TLS,
TLSSkipVerify: TLSSkipVerify,
TLSSkipClientVerify: TLSSkipClientVerify,
@@ -150,7 +130,6 @@ function EndpointController(
AzureApplicationID: endpoint.AzureCredentials.ApplicationID,
AzureTenantID: endpoint.AzureCredentials.TenantID,
AzureAuthenticationKey: endpoint.AzureCredentials.AuthenticationKey,
EdgeCheckinInterval: endpoint.EdgeCheckinInterval,
};
if (
@@ -249,10 +228,11 @@ function EndpointController(
$scope.state.edgeAssociated = !!endpoint.EdgeID;
endpoint.EdgeID = endpoint.EdgeID || uuidv4();
$scope.state.availableEdgeAgentCheckinOptions[0].key += ` (${settings.EdgeAgentCheckinInterval} seconds)`;
}
$scope.endpoint = endpoint;
$scope.initialTagIds = endpoint.TagIds.slice();
$scope.groups = groups;
$scope.availableTags = tags;

View File

@@ -68,14 +68,8 @@
<!-- it is a workaround for firefox that does not render component <force-password-update-hint> -->
<p>
<i class="fa fa-times red-icon space-right" aria-hidden="true"></i>
<span>The password must be at least {{ MinPasswordLen }} characters long, including a combination of one character of three of the below:</span>
<span>The password must be at least {{ MinPasswordLen }} characters long.</span>
</p>
<ul>
<li class="ml-8"> Special characters </li>
<li class="ml-8"> Lower case characters </li>
<li class="ml-8"> Upper case characters </li>
<li class="ml-8"> Numeric characters </li>
</ul>
</div>
</div>
<!-- !note -->

View File

@@ -2,7 +2,7 @@
"author": "Portainer.io",
"name": "portainer",
"homepage": "http://portainer.io",
"version": "2.11.0",
"version": "2.13.1",
"repository": {
"type": "git",
"url": "git@github.com:portainer/portainer.git"
@@ -134,7 +134,6 @@
"spinkit": "^2.0.1",
"splitargs": "github:deviantony/splitargs#semver:~0.2.0",
"strip-ansi": "^6.0.0",
"tailwindcss": "^3.0.23",
"toastr": "^2.1.4",
"ui-select": "^0.19.8",
"uuid": "^3.3.2",
@@ -175,16 +174,16 @@
"@typescript-eslint/eslint-plugin": "^5.7.0",
"@typescript-eslint/parser": "^5.7.0",
"auto-ngtemplate-loader": "^2.0.1",
"autoprefixer": "^10.4.2",
"autoprefixer": "^7.1.1",
"babel-jest": "^27.4.2",
"babel-loader": "^8.2.3",
"babel-plugin-i18next-extract": "^0.8.3",
"babel-plugin-lodash": "^3.3.4",
"clean-terminal-webpack-plugin": "^3.0.0",
"clean-webpack-plugin": "^4.0.0",
"copy-webpack-plugin": "^10.2.0",
"css-loader": "^6.6.0",
"cssnano": "^5.0.16",
"copy-webpack-plugin": "6",
"css-loader": "5",
"cssnano": "^4.1.10",
"cypress": "8.7",
"cypress-wait-until": "^1.7.1",
"dotenv-webpack": "^7.0.3",
@@ -208,6 +207,7 @@
"grunt-contrib-copy": "^1.0.0",
"grunt-env": "^0.4.4",
"grunt-filerev": "^2.3.1",
"grunt-postcss": "^0.8.0",
"grunt-replace": "^1.0.1",
"grunt-shell-spawn": "^0.4.0",
"grunt-usemin": "^3.1.1",
@@ -220,18 +220,18 @@
"lint-staged": ">=10",
"load-grunt-tasks": "^3.5.2",
"lodash-webpack-plugin": "^0.11.6",
"mini-css-extract-plugin": "^2.5.3",
"mini-css-extract-plugin": "1",
"msw-storybook-addon": "^1.5.0",
"ngtemplate-loader": "^2.1.0",
"plop": "^2.6.0",
"postcss": "^8.4.6",
"postcss-loader": "^6.2.1",
"postcss": "7",
"postcss-loader": "4",
"prettier": "^2.5.1",
"react-test-renderer": "^17.0.2",
"source-map-loader": "^3.0.0",
"speed-measure-webpack-plugin": "^1.5.0",
"storybook-css-modules-preset": "^1.1.1",
"style-loader": "^3.3.1",
"style-loader": "2",
"swagger2openapi": "^7.0.8",
"tsconfig-paths-webpack-plugin": "^3.5.2",
"typescript": "^4.5.2",
@@ -251,13 +251,12 @@
"http-proxy": "^1.18.1",
"**/@uirouter/react": "^1.0.7",
"**/@uirouter/angularjs": "1.0.11",
"**/css-loader": "^6.6.0",
"**/css-loader": "5",
"**/moment": "^2.21.0"
},
"husky": {
"hooks": {
"pre-commit": "lint-staged"
}
},
"browserslist": "last 2 versions"
}
}

View File

@@ -1,9 +1,8 @@
module.exports = ({ env }) => ({
plugins: [
plugins: {
// add vendor prefixes
require('autoprefixer'),
autoprefixer: { browsers: 'last 2 versions' },
// minify the result
env !== 'development' && require('cssnano'),
require('tailwindcss'),
],
cssnano: env !== 'development' ? {} : false,
},
});

View File

@@ -1,9 +0,0 @@
module.exports = {
content: ['./app/**/*.{html,tsx}'],
corePlugins: {
preflight: false,
},
theme: {
colors: {},
},
};

View File

@@ -78,9 +78,7 @@ module.exports = {
},
},
},
{
loader: 'postcss-loader',
},
'postcss-loader',
],
},
],

1372
yarn.lock

File diff suppressed because it is too large Load Diff